Files
ReadViewSDK/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextIndexTable.swift
T
2026-05-26 09:19:37 +08:00

413 lines
17 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 {
chapterOffset(
fileIndex: anchor.fileIndex,
row: anchor.row,
column: anchor.column
) ?? 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
}
}