250 lines
9.7 KiB
Swift
250 lines
9.7 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 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
|
|
}
|
|
}
|