ReadViewSDK/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextIndexTable.swift
shenlei d7fcda345d refactor: rename RDReaderView -> RDEpubReaderView, update pod config and docs
- Rename source module from RDReaderView to RDEpubReaderView
- Move all source files from Sources/RDReaderView/ to Sources/RDEpubReaderView/
- Update podspec: RDReaderView.podspec -> RDEpubReaderView.podspec
- Update Podfile, demo project, and CocoaPods config for new pod name
- Delete old RDReaderView pod support files from ReadViewDemo/Pods
- Add new RDEpubReaderView pod support files
- Update documentation (API ref, architecture, UML, conventions, etc.)
- Add FixedLayoutRotationTests
- Update .gitignore: exclude .DS_Store, manual unpack backups, _ssoft-output
2026-07-10 19:44:53 +09:00

543 lines
21 KiB
Swift

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]
public let hrefToChapterIndex: [String: Int]
public let hrefToFileIndex: [String: Int]
public let fragmentOffsetsByHref: [String: [String: Int]]
public let fileIndexToHref: [Int: String]
public let fileRowColumnMap: [Int: [RDEPUBRowColumnIndex]]
public let fileTextMap: [Int: String]
public let fileCFIMap: [Int: RDEPUBCFIMap]
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 textMap: [Int: String] = [:]
var cfiMap: [Int: RDEPUBCFIMap] = [:]
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
let chapterText = chapter.attributedContent.string
rowColumnMap[chapter.spineIndex] = Self.makeRowColumnIndices(for: chapterText)
textMap[chapter.spineIndex] = chapterText
if let chapterCFIMap = chapter.cfiMap {
cfiMap[chapter.spineIndex] = chapterCFIMap
}
}
self.chapterStartOffsets = offsets
self.chapterLengths = lengths
self.hrefToChapterIndex = hrefMap
self.hrefToFileIndex = hrefToFileMap
self.fragmentOffsetsByHref = fragmentMap
self.fileIndexToHref = fileIndexMap
self.fileRowColumnMap = rowColumnMap
self.fileTextMap = textMap
self.fileCFIMap = cfiMap
}
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
)
}
public func anchor(for location: RDEPUBLocation) -> RDEPUBTextAnchor? {
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(location.rangeCFI),
let anchor = anchor(for: cfiRange.start) {
return anchor
}
if let anchor = location.rangeAnchor?.start {
return anchor
}
if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi),
let anchor = anchor(for: cfi) {
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(for cfi: RDEPUBCFI) -> RDEPUBTextAnchor? {
let resolved = RDEPUBCFIResolver.resolve(cfi)
let fileIndex: Int
if let resolvedFileIndex = resolved.fileIndex {
fileIndex = resolvedFileIndex
} else if let href = resolved.href, let mappedFileIndex = hrefToFileIndex[href] {
fileIndex = mappedFileIndex
} else {
return nil
}
guard let href = href(for: fileIndex) ?? resolved.href else { return nil }
let chapterLength = max(chapterLength(forFileIndex: fileIndex) ?? 0, 1)
let lastOffset = max(chapterLength - 1, 0)
let recovery = RDEPUBCFIRecoveryEngine.recover(
cfi: cfi,
cfiMap: fileCFIMap[fileIndex],
chapterText: fileTextMap[fileIndex],
fragmentOffsets: fragmentOffsetsByHref[href] ?? [:],
fallbackOffset: resolved.chapterOffset,
lastOffset: lastOffset
)
let chapterOffset = recovery?.chapterOffset ?? min(max(resolved.chapterOffset ?? 0, 0), lastOffset)
let fragmentID = resolved.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 rangeAnchor(for cfiRange: RDEPUBCFIRange) -> RDEPUBTextRangeAnchor? {
guard let start = anchor(for: cfiRange.start),
let end = anchor(for: cfiRange.end),
start.fileIndex == end.fileIndex else {
return nil
}
return RDEPUBTextRangeAnchor(start: start, end: end)
}
public func cfi(for anchor: RDEPUBTextAnchor) -> RDEPUBCFI? {
guard let href = href(for: anchor.fileIndex) else { return nil }
let chapterOffset = chapterOffset(for: anchor)
if let marker = preciseMarker(fileIndex: anchor.fileIndex, chapterOffset: chapterOffset) {
let localOffset = max(chapterOffset - (marker.chapterOffset ?? 0), 0)
return RDEPUBCFIGenerator.makeCFI(
href: href,
fileIndex: anchor.fileIndex,
contentPath: marker.cfiPath,
characterOffset: localOffset,
textAssertion: textAssertion(fileIndex: anchor.fileIndex, offset: chapterOffset)
)
}
return RDEPUBCFIGenerator.makeOffsetCFI(
href: href,
fileIndex: anchor.fileIndex,
chapterOffset: chapterOffset,
fragmentID: anchor.fragmentID,
textAssertion: textAssertion(fileIndex: anchor.fileIndex, offset: chapterOffset)
)
}
public func cfiRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> RDEPUBCFIRange? {
guard rangeAnchor.start.fileIndex == rangeAnchor.end.fileIndex,
let href = href(for: rangeAnchor.start.fileIndex) else {
return nil
}
let startOffset = chapterOffset(for: rangeAnchor.start)
let endOffset = chapterOffset(for: rangeAnchor.end)
let startCFI = cfi(for: rangeAnchor.start)
let endCFI = cfi(for: rangeAnchor.end)
if let startCFI, let endCFI {
let parent = RDEPUBCFI(
packagePath: startCFI.packagePath.commonPrefix(with: endCFI.packagePath),
contentPath: startCFI.contentPath.commonPrefix(with: endCFI.contentPath)
)
return RDEPUBCFIRange(parent: parent, start: startCFI, end: endCFI)
}
return RDEPUBCFIGenerator.makeOffsetRangeCFI(
href: href,
fileIndex: rangeAnchor.start.fileIndex,
startOffset: startOffset,
endOffset: endOffset,
fragmentID: rangeAnchor.start.fragmentID,
startTextAssertion: textAssertion(
fileIndex: rangeAnchor.start.fileIndex,
offset: startOffset
),
endTextAssertion: textAssertion(
fileIndex: rangeAnchor.end.fileIndex,
offset: endOffset
)
)
}
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 }
}
public func href(for fileIndex: Int) -> String? {
fileIndexToHref[fileIndex]
}
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)
}
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))
}
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)
}
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),
cfi: cfi(for: anchor)?.rawValue,
lastCFI: cfi(for: anchor)?.rawValue
)
}
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,
cfi: cfi(for: rangeAnchor.start)?.rawValue,
lastCFI: cfi(for: rangeAnchor.end)?.rawValue,
rangeCFI: cfiRange(for: rangeAnchor)?.rawValue
)
}
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)
}
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 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 func preciseMarker(fileIndex: Int, chapterOffset: Int) -> RDEPUBCFIMarker? {
guard let markers = fileCFIMap[fileIndex]?.markers else { return nil }
return markers
.filter {
guard let start = $0.chapterOffset,
let length = $0.textNodeLength,
length > 0 else {
return false
}
return chapterOffset >= start && chapterOffset < start + length
}
.max { lhs, rhs in
let leftStart = lhs.chapterOffset ?? 0
let rightStart = rhs.chapterOffset ?? 0
if leftStart != rightStart {
return leftStart < rightStart
}
return (lhs.textNodeLength ?? 0) > (rhs.textNodeLength ?? 0)
}
}
private func textAssertion(fileIndex: Int, offset: Int) -> RDEPUBCFITextAssertion? {
guard let text = fileTextMap[fileIndex], !text.isEmpty else { return nil }
let nsText = text as NSString
let length = nsText.length
guard length > 0 else { return nil }
let location = min(max(offset, 0), max(length - 1, 0))
let exactLength = min(16, max(length - location, 0))
guard exactLength > 0 else { return nil }
let prefixStart = max(location - 8, 0)
let suffixStart = min(location + exactLength, length)
return RDEPUBCFITextAssertion(
prefix: prefixStart < location
? nsText.substring(with: NSRange(location: prefixStart, length: location - prefixStart))
: nil,
exact: nsText.substring(with: NSRange(location: location, length: exactLength)),
suffix: suffixStart < length
? nsText.substring(with: NSRange(location: suffixStart, length: min(8, length - suffixStart)))
: nil
)
}
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
}
}