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 } }