// 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 var totalCharacterCount: Int { guard let lastIndex = chapterStartOffsets.indices.last, chapterLengths.indices.contains(lastIndex) else { return 0 } return chapterStartOffsets[lastIndex] + chapterLengths[lastIndex] } /// 从文本章节列表构建索引表 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 anchor(forGlobalIndex index: Int) -> RDEPUBTextAnchor? { guard let fileIndex = fileIndex(forCharacterPosition: index), let href = href(for: fileIndex) else { return nil } let chapterOffset = localOffsetInFile(at: fileIndex, forGlobalPosition: index) ?? 0 let fragmentID = nearestFragmentID(beforeOrAt: chapterOffset, inHref: href) return RDEPUBTextAnchor( fileIndex: fileIndex, row: row(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex), column: column(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex), chapterOffset: chapterOffset, fragmentID: fragmentID ) } /// 根据锚点查找对应的绝对页码 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 = chapterOffset(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 chapterStartOffset(forFileIndex fileIndex: Int) -> Int? { guard let href = href(for: fileIndex), let chapterIndex = hrefToChapterIndex[href], chapterStartOffsets.indices.contains(chapterIndex) else { return nil } return chapterStartOffsets[chapterIndex] } /// 通过文件索引获取章节长度。 public func chapterLength(forFileIndex fileIndex: Int) -> Int? { guard let href = href(for: fileIndex), let chapterIndex = hrefToChapterIndex[href], chapterLengths.indices.contains(chapterIndex) else { return nil } return chapterLengths[chapterIndex] } /// 将锚点转换为章节内字符偏移量。 public func chapterOffset(for anchor: RDEPUBTextAnchor) -> Int { anchor.chapterOffset } /// 将锚点转换为全书绝对字符索引。 public func globalIndex(for anchor: RDEPUBTextAnchor) -> Int { let chapterOffset = chapterOffset(for: anchor) let baseOffset = chapterStartOffset(forFileIndex: anchor.fileIndex) ?? 0 let chapterLength = max(chapterLength(forFileIndex: anchor.fileIndex) ?? 0, 0) let lastOffset = max(chapterLength - 1, 0) return baseOffset + min(max(chapterOffset, 0), lastOffset) } /// 兼容旧调用:返回全书绝对字符索引。 public func absoluteIndex(for anchor: RDEPUBTextAnchor) -> Int { globalIndex(for: anchor) } /// 将范围锚点转换为章节内 NSRange。 public func chapterRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange { let start = chapterOffset(for: rangeAnchor.start) let end = max(start, chapterOffset(for: rangeAnchor.end)) return NSRange(location: start, length: max(end - start, 0)) } /// 将范围锚点转换为全书绝对 NSRange。 public func globalRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange { let start = globalIndex(for: rangeAnchor.start) let end = max(start, globalIndex(for: rangeAnchor.end)) return NSRange(location: start, length: max(end - start, 0)) } /// 兼容旧调用:返回全书绝对范围。 public func absoluteRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange { globalRange(for: rangeAnchor) } /// 将单个锚点转换为阅读位置(计算 progression) public func location( for anchor: RDEPUBTextAnchor, in chapter: RDEPUBTextChapter, bookIdentifier: String? ) -> RDEPUBLocation { let chapterOffset = self.chapterOffset(for: anchor) let totalLength = max(chapter.attributedContent.length - 1, 1) let progression = Double(min(max(chapterOffset, 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 = chapterOffset(for: rangeAnchor.start) let end = max(start, chapterOffset(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 chapterOffset(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) } /// 根据文件索引、行号和列号计算全书绝对索引。 public func absoluteIndex(fileIndex: Int, row: Int, column: Int) -> Int? { guard let chapterOffset = chapterOffset(fileIndex: fileIndex, row: row, column: column), let baseOffset = chapterStartOffset(forFileIndex: fileIndex) else { return nil } return baseOffset + chapterOffset } /// 全书字符偏移量 → 文件索引。 public func fileIndex(forCharacterPosition index: Int) -> Int? { guard totalCharacterCount > 0 else { return nil } let normalized = min(max(index, 0), max(totalCharacterCount - 1, 0)) for (href, chapterIndex) in hrefToChapterIndex { guard chapterStartOffsets.indices.contains(chapterIndex), chapterLengths.indices.contains(chapterIndex), let fileIndex = hrefToFileIndex[href] else { continue } let start = chapterStartOffsets[chapterIndex] let length = chapterLengths[chapterIndex] let endExclusive = start + max(length, 1) if normalized >= start && normalized < endExclusive { return fileIndex } } return fileIndexToHref.keys.sorted().last } /// 全书字符偏移量 → 指定文件内的章节偏移量。 public func localOffsetInFile(at fileIndex: Int, forGlobalPosition index: Int) -> Int? { guard let start = chapterStartOffset(forFileIndex: fileIndex), let chapterLength = chapterLength(forFileIndex: fileIndex) else { return nil } let normalized = min(max(index, start), start + max(chapterLength - 1, 0)) return normalized - start } /// 将偏移量限制在章节范围内 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 } /// 查找给定 href 中距离偏移量最近(且在其之前)的 fragment ID。 private func nearestFragmentID(beforeOrAt offset: Int, inHref href: String) -> String? { guard let fragmentOffsets = fragmentOffsetsByHref[href] else { return nil } var bestID: String? var bestOffset = -1 for (id, fragmentOffset) in fragmentOffsets where fragmentOffset <= offset && fragmentOffset > bestOffset { bestOffset = fragmentOffset 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 } }