ReadViewSDK/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextIndexTable.swift

413 lines
17 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 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 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 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 }
}
/// href
public func href(for fileIndex: Int) -> String? {
fileIndexToHref[fileIndex]
}
/// href
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 {
chapterOffset(
fileIndex: anchor.fileIndex,
row: anchor.row,
column: anchor.column
) ?? 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)
}
/// 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 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)
)
}
/// progression
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
)
}
///
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)
}
/// 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
}
/// 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
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
}
}