feat(epub): complete wxread parity scope

This commit is contained in:
shen
2026-05-26 09:19:37 +08:00
parent 23182e8b5a
commit 0e7d952fe5
31 changed files with 2619 additions and 1533 deletions
@@ -44,6 +44,15 @@ public struct RDEPUBTextIndexTable {
/// 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] = []
@@ -118,11 +127,29 @@ public struct RDEPUBTextIndexTable {
)
}
///
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 = absoluteIndex(for: anchor)
let resolvedOffset = chapterOffset(for: anchor)
return chapter.pages.firstIndex { page in
NSLocationInRange(resolvedOffset, page.contentRange)
}.map { $0 + basePageIndex }
@@ -138,31 +165,77 @@ public struct RDEPUBTextIndexTable {
hrefToChapterIndex[href]
}
///
public func absoluteIndex(for anchor: RDEPUBTextAnchor) -> Int {
absoluteIndex(
///
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
}
/// NSRange
public func absoluteRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
let start = absoluteIndex(for: rangeAnchor.start)
let end = max(start, absoluteIndex(for: rangeAnchor.end))
///
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 absoluteOffset = absoluteIndex(for: anchor)
let chapterOffset = self.chapterOffset(for: anchor)
let totalLength = max(chapter.attributedContent.length - 1, 1)
let progression = Double(min(max(absoluteOffset, 0), totalLength)) / Double(totalLength)
let progression = Double(min(max(chapterOffset, 0), totalLength)) / Double(totalLength)
return RDEPUBLocation(
bookIdentifier: bookIdentifier,
href: chapter.href,
@@ -179,8 +252,8 @@ public struct RDEPUBTextIndexTable {
in chapter: RDEPUBTextChapter,
bookIdentifier: String?
) -> RDEPUBLocation {
let start = absoluteIndex(for: rangeAnchor.start)
let end = max(start, absoluteIndex(for: rangeAnchor.end))
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)
@@ -213,8 +286,8 @@ public struct RDEPUBTextIndexTable {
return max(index - lastRow.startOffset, 0)
}
///
public func absoluteIndex(fileIndex: Int, row: Int, column: Int) -> Int? {
///
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]
@@ -222,6 +295,46 @@ public struct RDEPUBTextIndexTable {
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)
@@ -243,6 +356,21 @@ public struct RDEPUBTextIndexTable {
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