refactor: 添加中文注释 + 优化模块结构
- 给全部 78 个 Swift 源文件添加详细的中文注释(文件级、类级、方法级) - 删除 LegacyRDReaderController/ 死代码目录(16 文件 4592 行) - 根目录翻页容器文件移入 ReaderView/ 目录 - Resources/ 移入 EPUBCore/Resources/(与使用者归属一致) - RDEPUBTextIndexTable.swift 移入 EPUBTextRendering/(消除反向依赖) - RDURLReaderController.swift 移入 EPUBUI/(入口控制器归入 UI 层) - 更新 podspec 资源路径
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
// RDEPUBTextIndexTable.swift
|
||||
// EPUB 文本索引表
|
||||
// 构建全书的文本索引映射:章节起始偏移、href 与章节/spine 索引的对应关系、
|
||||
// fragment 偏移映射、以及行列索引映射。支持锚点与位置之间的双向转换,
|
||||
// 用于精确定位文本选择和搜索匹配。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 行-列索引条目,表示文本中某一行的字符范围
|
||||
public struct RDEPUBRowColumnIndex: Codable, Equatable {
|
||||
/// 行号
|
||||
public let row: Int
|
||||
/// 该行的起始字符偏移
|
||||
public let startOffset: Int
|
||||
/// 该行的结束字符偏移
|
||||
public let endOffset: Int
|
||||
|
||||
public init(row: Int, startOffset: Int, endOffset: Int) {
|
||||
self.row = row
|
||||
self.startOffset = startOffset
|
||||
self.endOffset = endOffset
|
||||
}
|
||||
|
||||
/// 判断给定偏移量是否在该行范围内
|
||||
public func contains(_ offset: Int) -> Bool {
|
||||
offset >= startOffset && offset <= endOffset
|
||||
}
|
||||
}
|
||||
|
||||
/// 文本索引表,存储全书的文本结构映射
|
||||
public struct RDEPUBTextIndexTable {
|
||||
/// 各章节的起始绝对偏移量(累加长度)
|
||||
public let chapterStartOffsets: [Int]
|
||||
/// 各章节的文本长度
|
||||
public let chapterLengths: [Int]
|
||||
/// href → 章节索引的映射
|
||||
public let hrefToChapterIndex: [String: Int]
|
||||
/// href → spine 文件索引的映射
|
||||
public let hrefToFileIndex: [String: Int]
|
||||
/// href → (fragment ID → 章节内偏移) 的映射
|
||||
public let fragmentOffsetsByHref: [String: [String: Int]]
|
||||
/// spine 文件索引 → href 的反向映射
|
||||
public let fileIndexToHref: [Int: String]
|
||||
/// spine 文件索引 → 行列索引数组的映射
|
||||
public let fileRowColumnMap: [Int: [RDEPUBRowColumnIndex]]
|
||||
|
||||
/// 从文本章节列表构建索引表
|
||||
public init(chapters: [RDEPUBTextChapter]) {
|
||||
var offsets: [Int] = []
|
||||
var lengths: [Int] = []
|
||||
var hrefMap: [String: Int] = [:]
|
||||
var hrefToFileMap: [String: Int] = [:]
|
||||
var fragmentMap: [String: [String: Int]] = [:]
|
||||
var fileIndexMap: [Int: String] = [:]
|
||||
var rowColumnMap: [Int: [RDEPUBRowColumnIndex]] = [:]
|
||||
var running = 0
|
||||
|
||||
for (index, chapter) in chapters.enumerated() {
|
||||
hrefMap[chapter.href] = index
|
||||
hrefToFileMap[chapter.href] = chapter.spineIndex
|
||||
offsets.append(running)
|
||||
lengths.append(chapter.attributedContent.length)
|
||||
running += chapter.attributedContent.length
|
||||
fragmentMap[chapter.href] = chapter.fragmentOffsets
|
||||
fileIndexMap[chapter.spineIndex] = chapter.href
|
||||
rowColumnMap[chapter.spineIndex] = Self.makeRowColumnIndices(for: chapter.attributedContent.string)
|
||||
}
|
||||
|
||||
self.chapterStartOffsets = offsets
|
||||
self.chapterLengths = lengths
|
||||
self.hrefToChapterIndex = hrefMap
|
||||
self.hrefToFileIndex = hrefToFileMap
|
||||
self.fragmentOffsetsByHref = fragmentMap
|
||||
self.fileIndexToHref = fileIndexMap
|
||||
self.fileRowColumnMap = rowColumnMap
|
||||
}
|
||||
|
||||
/// 根据绝对索引和章节构建文本锚点
|
||||
public func anchor(forAbsoluteIndex index: Int, in chapter: RDEPUBTextChapter) -> RDEPUBTextAnchor {
|
||||
let normalizedIndex = clampedOffset(index, in: chapter)
|
||||
let fragmentID = nearestFragmentID(beforeOrAt: normalizedIndex, in: chapter)
|
||||
let row = row(forAbsoluteIndex: normalizedIndex, inFileIndex: chapter.spineIndex)
|
||||
let column = column(forAbsoluteIndex: normalizedIndex, inFileIndex: chapter.spineIndex)
|
||||
return RDEPUBTextAnchor(
|
||||
fileIndex: chapter.spineIndex,
|
||||
row: row,
|
||||
column: column,
|
||||
chapterOffset: normalizedIndex,
|
||||
fragmentID: fragmentID
|
||||
)
|
||||
}
|
||||
|
||||
/// 根据阅读位置构建文本锚点(优先使用 rangeAnchor,其次根据 fragment 或 progression 计算)
|
||||
public func anchor(for location: RDEPUBLocation) -> RDEPUBTextAnchor? {
|
||||
if let anchor = location.rangeAnchor?.start {
|
||||
return anchor
|
||||
}
|
||||
|
||||
guard let chapterIndex = hrefToChapterIndex[location.href],
|
||||
let fileIndex = hrefToFileIndex[location.href] else { return nil }
|
||||
let fragments = fragmentOffsetsByHref[location.href] ?? [:]
|
||||
let chapterOffset: Int
|
||||
|
||||
if let fragment = location.fragment, let fragmentOffset = fragments[fragment] {
|
||||
chapterOffset = fragmentOffset
|
||||
} else {
|
||||
let estimatedLength = max(chapterLengths.indices.contains(chapterIndex) ? chapterLengths[chapterIndex] : 0, 1)
|
||||
let lastOffset = max(estimatedLength - 1, 0)
|
||||
chapterOffset = min(lastOffset, max(0, Int(round(Double(lastOffset) * location.navigationProgression))))
|
||||
}
|
||||
|
||||
return RDEPUBTextAnchor(
|
||||
fileIndex: fileIndex,
|
||||
row: row(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
|
||||
column: column(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
|
||||
chapterOffset: chapterOffset,
|
||||
fragmentID: location.fragment
|
||||
)
|
||||
}
|
||||
|
||||
/// 根据锚点查找对应的绝对页码
|
||||
public func pageNumber(for anchor: RDEPUBTextAnchor, in book: RDEPUBTextBook) -> Int? {
|
||||
guard let chapter = book.chapters.first(where: { $0.spineIndex == anchor.fileIndex }) else { return nil }
|
||||
let basePageIndex = chapter.pages.first?.absolutePageIndex ?? 0
|
||||
let resolvedOffset = absoluteIndex(for: anchor)
|
||||
return chapter.pages.firstIndex { page in
|
||||
NSLocationInRange(resolvedOffset, page.contentRange)
|
||||
}.map { $0 + basePageIndex }
|
||||
}
|
||||
|
||||
/// 通过文件索引获取 href
|
||||
public func href(for fileIndex: Int) -> String? {
|
||||
fileIndexToHref[fileIndex]
|
||||
}
|
||||
|
||||
/// 通过 href 获取章节索引
|
||||
public func chapterIndex(for href: String) -> Int? {
|
||||
hrefToChapterIndex[href]
|
||||
}
|
||||
|
||||
/// 将锚点转换为全书绝对字符索引
|
||||
public func absoluteIndex(for anchor: RDEPUBTextAnchor) -> Int {
|
||||
absoluteIndex(
|
||||
fileIndex: anchor.fileIndex,
|
||||
row: anchor.row,
|
||||
column: anchor.column
|
||||
) ?? anchor.chapterOffset
|
||||
}
|
||||
|
||||
/// 将范围锚点转换为全书绝对 NSRange
|
||||
public func absoluteRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
|
||||
let start = absoluteIndex(for: rangeAnchor.start)
|
||||
let end = max(start, absoluteIndex(for: rangeAnchor.end))
|
||||
return NSRange(location: start, length: max(end - start, 0))
|
||||
}
|
||||
|
||||
/// 将单个锚点转换为阅读位置(计算 progression)
|
||||
public func location(
|
||||
for anchor: RDEPUBTextAnchor,
|
||||
in chapter: RDEPUBTextChapter,
|
||||
bookIdentifier: String?
|
||||
) -> RDEPUBLocation {
|
||||
let absoluteOffset = absoluteIndex(for: anchor)
|
||||
let totalLength = max(chapter.attributedContent.length - 1, 1)
|
||||
let progression = Double(min(max(absoluteOffset, 0), totalLength)) / Double(totalLength)
|
||||
return RDEPUBLocation(
|
||||
bookIdentifier: bookIdentifier,
|
||||
href: chapter.href,
|
||||
progression: progression,
|
||||
lastProgression: progression,
|
||||
fragment: anchor.fragmentID,
|
||||
rangeAnchor: RDEPUBTextRangeAnchor(start: anchor, end: anchor)
|
||||
)
|
||||
}
|
||||
|
||||
/// 将范围锚点转换为阅读位置(起止 progression)
|
||||
public func location(
|
||||
for rangeAnchor: RDEPUBTextRangeAnchor,
|
||||
in chapter: RDEPUBTextChapter,
|
||||
bookIdentifier: String?
|
||||
) -> RDEPUBLocation {
|
||||
let start = absoluteIndex(for: rangeAnchor.start)
|
||||
let end = max(start, absoluteIndex(for: rangeAnchor.end))
|
||||
let totalLength = max(chapter.attributedContent.length - 1, 1)
|
||||
let clampedStart = min(max(start, 0), totalLength)
|
||||
let clampedEnd = min(max(end, clampedStart), totalLength)
|
||||
return RDEPUBLocation(
|
||||
bookIdentifier: bookIdentifier,
|
||||
href: chapter.href,
|
||||
progression: Double(clampedStart) / Double(totalLength),
|
||||
lastProgression: Double(clampedEnd) / Double(totalLength),
|
||||
fragment: rangeAnchor.start.fragmentID,
|
||||
rangeAnchor: rangeAnchor
|
||||
)
|
||||
}
|
||||
|
||||
/// 根据绝对索引和文件索引查找所在行号
|
||||
public func row(forAbsoluteIndex index: Int, inFileIndex fileIndex: Int) -> Int {
|
||||
guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return 0 }
|
||||
if let rowIndex = rows.first(where: { $0.contains(index) })?.row {
|
||||
return rowIndex
|
||||
}
|
||||
return rows.last?.row ?? 0
|
||||
}
|
||||
|
||||
/// 根据绝对索引和文件索引查找所在列号(行内偏移)
|
||||
public func column(forAbsoluteIndex index: Int, inFileIndex fileIndex: Int) -> Int {
|
||||
guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return 0 }
|
||||
if let rowEntry = rows.first(where: { $0.contains(index) }) {
|
||||
return max(index - rowEntry.startOffset, 0)
|
||||
}
|
||||
guard let lastRow = rows.last else { return 0 }
|
||||
return max(index - lastRow.startOffset, 0)
|
||||
}
|
||||
|
||||
/// 根据文件索引、行号和列号计算全书绝对索引
|
||||
public func absoluteIndex(fileIndex: Int, row: Int, column: Int) -> Int? {
|
||||
guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return nil }
|
||||
let normalizedRow = min(max(row, 0), rows.count - 1)
|
||||
let rowEntry = rows[normalizedRow]
|
||||
let maxColumn = max(rowEntry.endOffset - rowEntry.startOffset, 0)
|
||||
return rowEntry.startOffset + min(max(column, 0), maxColumn)
|
||||
}
|
||||
|
||||
/// 将偏移量限制在章节范围内
|
||||
private func clampedOffset(_ index: Int, in chapter: RDEPUBTextChapter) -> Int {
|
||||
let lastOffset = max(chapter.attributedContent.length - 1, 0)
|
||||
return min(max(index, 0), lastOffset)
|
||||
}
|
||||
|
||||
/// 查找距离给定偏移量最近(且在其之前)的 fragment ID
|
||||
private func nearestFragmentID(beforeOrAt offset: Int, in chapter: RDEPUBTextChapter) -> String? {
|
||||
var bestID: String?
|
||||
var bestOffset = -1
|
||||
|
||||
for (id, fragOffset) in chapter.fragmentOffsets {
|
||||
if fragOffset <= offset && fragOffset > bestOffset {
|
||||
bestOffset = fragOffset
|
||||
bestID = id
|
||||
}
|
||||
}
|
||||
|
||||
return bestID
|
||||
}
|
||||
|
||||
/// 从文本字符串构建行列索引数组(按行分割,记录每行的起止偏移)
|
||||
private static func makeRowColumnIndices(for text: String) -> [RDEPUBRowColumnIndex] {
|
||||
let nsText = text as NSString
|
||||
let length = nsText.length
|
||||
guard length > 0 else {
|
||||
return [RDEPUBRowColumnIndex(row: 0, startOffset: 0, endOffset: 0)]
|
||||
}
|
||||
|
||||
var rows: [RDEPUBRowColumnIndex] = []
|
||||
var rowNumber = 0
|
||||
var lineStart = 0
|
||||
|
||||
nsText.enumerateSubstrings(
|
||||
in: NSRange(location: 0, length: length),
|
||||
options: [.byLines, .substringNotRequired]
|
||||
) { _, substringRange, enclosingRange, _ in
|
||||
let startOffset = enclosingRange.location
|
||||
let lineLength = max(substringRange.length, 0)
|
||||
let endOffset = max(startOffset + max(lineLength - 1, 0), startOffset)
|
||||
rows.append(
|
||||
RDEPUBRowColumnIndex(
|
||||
row: rowNumber,
|
||||
startOffset: startOffset,
|
||||
endOffset: min(endOffset, max(length - 1, 0))
|
||||
)
|
||||
)
|
||||
rowNumber += 1
|
||||
lineStart = enclosingRange.location + enclosingRange.length
|
||||
}
|
||||
|
||||
if rows.isEmpty {
|
||||
rows.append(RDEPUBRowColumnIndex(row: 0, startOffset: 0, endOffset: max(length - 1, 0)))
|
||||
} else if lineStart == length, text.hasSuffix("\n") {
|
||||
rows.append(RDEPUBRowColumnIndex(row: rowNumber, startOffset: length, endOffset: length))
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user