- 新增 RDEPUBReaderSearchCoordinator 与 RDEPUBSelectionState 管理搜索和选中状态 - 新增 BookmarkChromeStateTests、NavigationBackwardTests、SelectionAnnotateTests 等 UI 测试 - 新增多个边界测试 epub 样本(损坏结构、空归档、缺失文件、流式外链验证) - 重构阅读器 chrome 状态管理,统一 tool bar 与 search bar 交互 - 优化大书分页缓存策略(RDEPUBChapterSummaryDiskCache、RDEPUBPageCountCache) - 移除废弃的 RDEPUBLocationConverter 和 RDEPUBPageBreakPolicy - 更新 epub-bridge.js 与 JS bridge 通信协议 - 全面更新现有 UI 测试以适配新的 helper 和状态管理
409 lines
17 KiB
Swift
409 lines
17 KiB
Swift
// 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
|
||
}
|
||
}
|