ReadViewSDK/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextIndexTable.swift
shen 54798ba578 refactor: 添加中文注释 + 优化模块结构
- 给全部 78 个 Swift 源文件添加详细的中文注释(文件级、类级、方法级)
- 删除 LegacyRDReaderController/ 死代码目录(16 文件 4592 行)
- 根目录翻页容器文件移入 ReaderView/ 目录
- Resources/ 移入 EPUBCore/Resources/(与使用者归属一致)
- RDEPUBTextIndexTable.swift 移入 EPUBTextRendering/(消除反向依赖)
- RDURLReaderController.swift 移入 EPUBUI/(入口控制器归入 UI 层)
- 更新 podspec 资源路径
2026-05-25 10:19:14 +08:00

285 lines
12 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 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
}
}