feat(wxread): align pagination, rendering, and docs
This commit is contained in:
@@ -6,19 +6,22 @@ public struct RDEPUBLocation: Codable, Equatable {
|
||||
public var progression: Double
|
||||
public var lastProgression: Double?
|
||||
public var fragment: String?
|
||||
public var rangeAnchor: RDEPUBTextRangeAnchor?
|
||||
|
||||
public init(
|
||||
bookIdentifier: String? = nil,
|
||||
href: String,
|
||||
progression: Double,
|
||||
lastProgression: Double? = nil,
|
||||
fragment: String? = nil
|
||||
fragment: String? = nil,
|
||||
rangeAnchor: RDEPUBTextRangeAnchor? = nil
|
||||
) {
|
||||
self.bookIdentifier = bookIdentifier
|
||||
self.href = href
|
||||
self.progression = Self.clamp(progression)
|
||||
self.lastProgression = lastProgression.map(Self.clamp)
|
||||
self.fragment = fragment?.nilIfEmpty
|
||||
self.rangeAnchor = rangeAnchor
|
||||
}
|
||||
|
||||
public var navigationProgression: Double {
|
||||
|
||||
@@ -114,7 +114,8 @@ public final class RDEPUBResourceResolver {
|
||||
href: normalizedTargetHref,
|
||||
progression: location.progression,
|
||||
lastProgression: location.lastProgression,
|
||||
fragment: fragment
|
||||
fragment: fragment,
|
||||
rangeAnchor: location.rangeAnchor
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ public struct RDEPUBSearchMatch: Codable, Equatable {
|
||||
public var localMatchIndex: Int
|
||||
public var rangeLocation: Int?
|
||||
public var rangeLength: Int
|
||||
public var rangeAnchor: RDEPUBTextRangeAnchor?
|
||||
|
||||
public init(
|
||||
href: String,
|
||||
@@ -14,7 +15,8 @@ public struct RDEPUBSearchMatch: Codable, Equatable {
|
||||
previewText: String,
|
||||
localMatchIndex: Int,
|
||||
rangeLocation: Int? = nil,
|
||||
rangeLength: Int
|
||||
rangeLength: Int,
|
||||
rangeAnchor: RDEPUBTextRangeAnchor? = nil
|
||||
) {
|
||||
self.href = href
|
||||
self.progression = progression
|
||||
@@ -22,6 +24,7 @@ public struct RDEPUBSearchMatch: Codable, Equatable {
|
||||
self.localMatchIndex = localMatchIndex
|
||||
self.rangeLocation = rangeLocation
|
||||
self.rangeLength = rangeLength
|
||||
self.rangeAnchor = rangeAnchor
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import Foundation
|
||||
|
||||
public struct RDEPUBTextAnchor: Codable, Equatable {
|
||||
public let fileIndex: Int
|
||||
public let row: Int
|
||||
public let column: Int
|
||||
public let chapterOffset: Int
|
||||
public let fragmentID: String?
|
||||
|
||||
public var spineIndex: Int { fileIndex }
|
||||
|
||||
public init(
|
||||
fileIndex: Int,
|
||||
row: Int,
|
||||
column: Int,
|
||||
chapterOffset: Int,
|
||||
fragmentID: String? = nil
|
||||
) {
|
||||
self.fileIndex = fileIndex
|
||||
self.row = row
|
||||
self.column = column
|
||||
self.chapterOffset = chapterOffset
|
||||
self.fragmentID = fragmentID
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fileIndex
|
||||
case spineIndex
|
||||
case row
|
||||
case column
|
||||
case chapterOffset
|
||||
case fragmentID
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let decodedFileIndex = try container.decodeIfPresent(Int.self, forKey: .fileIndex)
|
||||
?? container.decode(Int.self, forKey: .spineIndex)
|
||||
self.fileIndex = decodedFileIndex
|
||||
self.row = try container.decodeIfPresent(Int.self, forKey: .row) ?? 0
|
||||
self.column = try container.decodeIfPresent(Int.self, forKey: .column) ?? 0
|
||||
self.chapterOffset = try container.decode(Int.self, forKey: .chapterOffset)
|
||||
self.fragmentID = try container.decodeIfPresent(String.self, forKey: .fragmentID)
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(fileIndex, forKey: .fileIndex)
|
||||
try container.encode(row, forKey: .row)
|
||||
try container.encode(column, forKey: .column)
|
||||
try container.encode(chapterOffset, forKey: .chapterOffset)
|
||||
try container.encodeIfPresent(fragmentID, forKey: .fragmentID)
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBTextRangeAnchor: Codable, Equatable {
|
||||
public let start: RDEPUBTextAnchor
|
||||
public let end: RDEPUBTextAnchor
|
||||
|
||||
public init(start: RDEPUBTextAnchor, end: RDEPUBTextAnchor) {
|
||||
self.start = start
|
||||
self.end = end
|
||||
}
|
||||
|
||||
public var nsRange: NSRange {
|
||||
NSRange(location: start.chapterOffset, length: max(end.chapterOffset - start.chapterOffset, 0))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
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 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
|
||||
)
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
public func href(for fileIndex: Int) -> String? {
|
||||
fileIndexToHref[fileIndex]
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user