From f50495ad91b2681b18a2efa1f4d8cad31aeca10b Mon Sep 17 00:00:00 2001 From: shenlei Date: Thu, 18 Jun 2026 13:25:17 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20CFI=20module=20=E2=80=94=2011=20issues?= =?UTF-8?q?=20from=20code=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 (correctness): - #1: rawValue changed from stored to computed property, eliminating stale cache risk - #2: tokenSamples/textMarker unified to UTF-16 offsets, fixing emoji/CJK positioning - #3: makeOffsetCFI now accepts optional contentPath parameter Phase 2 (robustness): - #4: Resolver uses fixed index access instead of last(where:) for manifest/spine steps - #5: HTML comments and CDATA stripped before regex matching - #6: Text assertion parser handles backslash escapes (\[ \] \) - #7: Token matching uses prefix/suffix with length ratio constraints - #8: makeOffsetRangeCFI validates startOffset <= endOffset Phase 3 (code quality): - #9: Shared internal nilIfEmpty extension in RDEPUBCFIUtilities.swift - #10: RDEPUBCFIMap has markerByPath index dictionary for O(1) lookup - #11: parseRange unified to use try (not try?) for parent parsing Review fixes: - Documented init(rawValue:) parameter is ignored (computed property) - Fixed escape unescaping to handle \\ → \ correctly - Lowered token minLength threshold from 4 to 2 Co-Authored-By: Claude --- .../RDReaderView/EPUBCore/CFI/RDEPUBCFI.swift | 52 ++ .../EPUBCore/CFI/RDEPUBCFICompatibility.swift | 29 + .../CFI/RDEPUBCFIDOMPathBuilder.swift | 575 ++++++++++++++++++ .../EPUBCore/CFI/RDEPUBCFIError.swift | 8 + .../EPUBCore/CFI/RDEPUBCFIGenerator.swift | 87 +++ .../EPUBCore/CFI/RDEPUBCFIMap.swift | 182 ++++++ .../EPUBCore/CFI/RDEPUBCFIParser.swift | 271 +++++++++ .../EPUBCore/CFI/RDEPUBCFIPath.swift | 39 ++ .../EPUBCore/CFI/RDEPUBCFIRange.swift | 34 ++ .../CFI/RDEPUBCFIRecoveryEngine.swift | 320 ++++++++++ .../EPUBCore/CFI/RDEPUBCFIResolver.swift | 51 ++ .../EPUBCore/CFI/RDEPUBCFISerializer.swift | 109 ++++ .../EPUBCore/CFI/RDEPUBCFITextAssertion.swift | 16 + .../EPUBCore/CFI/RDEPUBCFIUtilities.swift | 8 + .../RDEPUBTextIndexTable.swift | 227 +++++-- 15 files changed, 1959 insertions(+), 49 deletions(-) create mode 100644 Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFI.swift create mode 100644 Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFICompatibility.swift create mode 100644 Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift create mode 100644 Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIError.swift create mode 100644 Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIGenerator.swift create mode 100644 Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift create mode 100644 Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIParser.swift create mode 100644 Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIPath.swift create mode 100644 Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIRange.swift create mode 100644 Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift create mode 100644 Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIResolver.swift create mode 100644 Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFISerializer.swift create mode 100644 Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFITextAssertion.swift create mode 100644 Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIUtilities.swift diff --git a/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFI.swift b/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFI.swift new file mode 100644 index 0000000..f884ac1 --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFI.swift @@ -0,0 +1,52 @@ +import Foundation + +public struct RDEPUBCFI: Codable, Equatable, Hashable { + public var packagePath: RDEPUBCFIPath + public var contentPath: RDEPUBCFIPath + public var characterOffset: Int? + public var sideBias: RDEPUBCFISideBias? + public var textAssertion: RDEPUBCFITextAssertion? + + /// Always computed from components — no stale cached value risk. + public var rawValue: String { + RDEPUBCFISerializer.serialize(self) + } + + /// Initialize from components. The `rawValue` parameter is ignored — rawValue is always + /// computed from the components. To parse a CFI string, use `RDEPUBCFIParser.parse(_:)`. + public init( + rawValue: String = "", + packagePath: RDEPUBCFIPath = RDEPUBCFIPath(), + contentPath: RDEPUBCFIPath = RDEPUBCFIPath(), + characterOffset: Int? = nil, + sideBias: RDEPUBCFISideBias? = nil, + textAssertion: RDEPUBCFITextAssertion? = nil + ) { + self.packagePath = packagePath + self.contentPath = contentPath + self.characterOffset = characterOffset + self.sideBias = sideBias + self.textAssertion = textAssertion + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + let parsed = try RDEPUBCFIParser.parse(rawValue) + self.packagePath = parsed.packagePath + self.contentPath = parsed.contentPath + self.characterOffset = parsed.characterOffset + self.sideBias = parsed.sideBias + self.textAssertion = parsed.textAssertion + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +public enum RDEPUBCFISideBias: String, Codable, Equatable, Hashable { + case before = "b" + case after = "a" +} diff --git a/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFICompatibility.swift b/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFICompatibility.swift new file mode 100644 index 0000000..20abcdd --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFICompatibility.swift @@ -0,0 +1,29 @@ +import Foundation + +public enum RDEPUBCFICompatibility { + public static func parseLossy(_ rawValue: String?) -> RDEPUBCFI? { + try? RDEPUBCFIParser.parse(rawValue) + } + + public static func parseRangeLossy(_ rawValue: String?) -> RDEPUBCFIRange? { + if let parsed = try? RDEPUBCFIParser.parseRange(rawValue) { + return parsed + } + + guard let rawValue, + !rawValue.isEmpty else { + return nil + } + + let separators = ["..", "-"] + let separator = separators.first(where: { rawValue.contains($0) }) + guard let separator else { return nil } + let parts = rawValue.components(separatedBy: separator) + guard parts.count == 2, + let start = parseLossy(parts[0]), + let end = parseLossy(parts[1]) else { + return nil + } + return RDEPUBCFIRange(parent: nil, start: start, end: end) + } +} diff --git a/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift b/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift new file mode 100644 index 0000000..119602a --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift @@ -0,0 +1,575 @@ +import Foundation + +public enum RDEPUBCFIDOMPathBuilder { + public static func fragmentPaths(in html: String) -> [String: RDEPUBCFIPath] { + // Pre-strip HTML comments and CDATA to avoid false tag matches inside them + let cleaned = stripCommentsAndCDATA(html) + + guard let regex = try? NSRegularExpression( + pattern: #"]*)>"#, + options: [.caseInsensitive] + ) else { + return [:] + } + + let nsHTML = cleaned as NSString + var stack: [RDEPUBCFIStep] = [] + var childCountsByDepth: [Int: Int] = [:] + var paths: [String: RDEPUBCFIPath] = [:] + + for match in regex.matches(in: cleaned, range: NSRange(location: 0, length: nsHTML.length)) { + let rawTag = nsHTML.substring(with: match.range) + guard match.numberOfRanges > 2 else { continue } + let tagName = nsHTML.substring(with: match.range(at: 1)).lowercased() + guard !isIgnorableTag(tagName) else { continue } + + if rawTag.hasPrefix(""), !isVoidTag(tagName) { + stack.append(step) + childCountsByDepth[stack.count] = nil + } + } + + return paths + } + + static func idAttribute(in attributes: String) -> String? { + guard let regex = try? NSRegularExpression( + pattern: #"(?:^|\s)(?:id|xml:id)\s*=\s*(['"])(.*?)\1"#, + options: [.caseInsensitive] + ) else { + return nil + } + let nsAttributes = attributes as NSString + guard let match = regex.firstMatch( + in: attributes, + range: NSRange(location: 0, length: nsAttributes.length) + ), + match.numberOfRanges > 2 else { + return nil + } + return nsAttributes.substring(with: match.range(at: 2)).nilIfEmpty + } + + static func isIgnorableTag(_ tagName: String) -> Bool { + tagName == "!doctype" || tagName.hasPrefix("?") + } + + /// Strip HTML comments `` and CDATA sections `` from HTML + /// to prevent the tag regex from matching false tags inside them. + static func stripCommentsAndCDATA(_ html: String) -> String { + var result = html + // Strip comments: + if let commentRegex = try? NSRegularExpression(pattern: #""#, options: []) { + let nsResult = result as NSString + let matches = commentRegex.matches(in: result, range: NSRange(location: 0, length: nsResult.length)) + for match in matches.reversed() { + result = nsResult.replacingCharacters(in: match.range, with: "") + } + } + // Strip CDATA: + if let cdataRegex = try? NSRegularExpression(pattern: #""#, options: []) { + let nsResult = result as NSString + let matches = cdataRegex.matches(in: result, range: NSRange(location: 0, length: nsResult.length)) + for match in matches.reversed() { + result = nsResult.replacingCharacters(in: match.range, with: "") + } + } + return result + } + + static func isVoidTag(_ tagName: String) -> Bool { + [ + "area", "base", "br", "col", "embed", "hr", "img", "input", + "link", "meta", "param", "source", "track", "wbr" + ].contains(tagName) + } +} + +enum RDEPUBCFITextNodeMapBuilder { + static func normalizedText(from source: String) -> String { + RDEPUBNormalizedTextIndex.normalize(source) + } + + static func makeMap( + href: String, + rawHTML: String, + chapterText: String, + fragmentOffsets: [String: Int] + ) -> RDEPUBCFIMap { + let normalizedChapter = RDEPUBNormalizedTextIndex(source: chapterText) + let fragmentPaths = RDEPUBCFIDOMPathBuilder.fragmentPaths(in: rawHTML) + let markers = makeMarkers( + href: href, + rawHTML: rawHTML, + chapterText: chapterText, + fragmentOffsets: fragmentOffsets + ) + let pathRanges = makePathRanges(from: markers) + return RDEPUBCFIMap( + href: href, + markers: markers, + pathRanges: pathRanges, + recoveryMetadata: RDEPUBCFIRecoveryMetadata( + domFingerprint: rawHTML.sha256Hex, + normalizedTextChecksum: normalizedChapter.normalizedText.sha256Hex, + tokenIndex: makeTokenAnchors( + normalizedChapter: normalizedChapter, + markers: markers, + pathRanges: pathRanges + ), + fragmentPathMap: fragmentPaths + ) + ) + } + + static func makeMarkers( + href: String, + rawHTML: String, + chapterText: String, + fragmentOffsets: [String: Int] + ) -> [RDEPUBCFIMarker] { + let normalizedChapter = RDEPUBNormalizedTextIndex(source: chapterText) + guard !normalizedChapter.normalizedText.isEmpty else { + return fragmentMarkers(fragmentOffsets: fragmentOffsets) + } + + // Use comment/CDATA-stripped HTML for both path building and tag matching + let cleanedHTML = stripCommentsAndCDATA(rawHTML) + + let fragments = RDEPUBCFIDOMPathBuilder.fragmentPaths(in: rawHTML) + var markers: [RDEPUBCFIMarker] = fragmentMarkers(fragmentOffsets: fragmentOffsets, paths: fragments) + var searchLocation = 0 + var stack: [RDEPUBCFIStep] = [] + var childCountsByDepth: [Int: Int] = [:] + var lastTagEnd = cleanedHTML.startIndex + + for tagMatch in RDEPUBHTMLTagMatch.matches(in: cleanedHTML) { + let textChunk = String(cleanedHTML[lastTagEnd.. [RDEPUBCFIMarker] { + fragmentOffsets + .sorted { $0.value < $1.value } + .map { fragmentID, offset in + RDEPUBCFIMarker( + cfiPath: paths[fragmentID] ?? RDEPUBCFIPath(), + chapterOffset: offset, + fragmentID: fragmentID + ) + } + } + + private static func textMarker( + for rawText: String, + stack: [RDEPUBCFIStep], + childCountsByDepth: inout [Int: Int], + normalizedChapter: RDEPUBNormalizedTextIndex, + searchLocation: inout Int + ) -> RDEPUBCFIMarker? { + let normalizedNodeText = RDEPUBNormalizedTextIndex.normalize(rawText) + guard !normalizedNodeText.isEmpty else { return nil } + + let depth = stack.count + let nextChildIndex = (childCountsByDepth[depth] ?? 0) + 1 + childCountsByDepth[depth] = nextChildIndex + let textStep = RDEPUBCFIStep(index: nextChildIndex * 2 - 1) + let path = RDEPUBCFIPath(steps: stack + [textStep]) + + // Search using UTF-16 offsets to match normalizedToChapterOffsets indices + let nsNormalized = normalizedChapter.normalizedText as NSString + let searchRange = NSRange(location: searchLocation, length: nsNormalized.length - searchLocation) + guard let foundRange = nsNormalized.range(of: normalizedNodeText, options: [], range: searchRange).toRange(), + foundRange.lowerBound >= searchLocation else { + return nil + } + + searchLocation = foundRange.upperBound + let normalizedOffset = foundRange.lowerBound + guard let chapterOffset = normalizedChapter.chapterOffset(forNormalizedOffset: normalizedOffset) else { + return nil + } + + return RDEPUBCFIMarker( + cfiPath: path, + chapterOffset: chapterOffset, + textNodeLength: normalizedNodeText.utf16.count, + textNodeChecksum: normalizedNodeText.fnv1a64, + normalizedTextPreview: previewText(for: normalizedNodeText), + domSiblingSignature: siblingSignature(stack: stack, childIndex: nextChildIndex) + ) + } + + private static func processTag( + _ match: RDEPUBHTMLTagMatch, + stack: inout [RDEPUBCFIStep], + childCountsByDepth: inout [Int: Int] + ) { + let tagName = match.name.lowercased() + guard !RDEPUBCFIDOMPathBuilder.isIgnorableTag(tagName) else { return } + + if match.isClosing { + if tagName == "html" { + return + } + if !stack.isEmpty { + stack.removeLast() + childCountsByDepth[stack.count + 1] = nil + } + return + } + + if tagName == "html" { + childCountsByDepth[0] = nil + return + } + + let depth = stack.count + let nextChildIndex = (childCountsByDepth[depth] ?? 0) + 1 + childCountsByDepth[depth] = nextChildIndex + + let fragmentID = RDEPUBCFIDOMPathBuilder.idAttribute(in: match.attributes) + let step = RDEPUBCFIStep(index: nextChildIndex * 2, idAssertion: fragmentID) + if !match.isSelfClosing && !RDEPUBCFIDOMPathBuilder.isVoidTag(tagName) { + stack.append(step) + childCountsByDepth[stack.count] = nil + } + } + + private static func makePathRanges(from markers: [RDEPUBCFIMarker]) -> [RDEPUBCFIPathRange] { + markers.compactMap { marker in + guard let startOffset = marker.chapterOffset, + let textNodeLength = marker.textNodeLength, + textNodeLength > 0 else { + return nil + } + return RDEPUBCFIPathRange( + cfiPath: marker.cfiPath, + startOffset: startOffset, + endOffset: startOffset + max(textNodeLength - 1, 0), + textNodeLength: textNodeLength + ) + } + } + + private static func makeTokenAnchors( + normalizedChapter: RDEPUBNormalizedTextIndex, + markers: [RDEPUBCFIMarker], + pathRanges: [RDEPUBCFIPathRange] + ) -> [RDEPUBCFITokenAnchor] { + guard !normalizedChapter.normalizedText.isEmpty else { return [] } + + let sortedRanges = pathRanges.sorted { $0.startOffset < $1.startOffset } + var occurrenceByToken: [String: Int] = [:] + var anchors: [RDEPUBCFITokenAnchor] = [] + let samples = normalizedChapter.tokenSamples(maxSamples: 64, stride: 96, window: 12) + + for sample in samples { + guard let chapterOffset = normalizedChapter.chapterOffset(forNormalizedOffset: sample.offset), + let path = path(for: chapterOffset, ranges: sortedRanges, markers: markers) else { + continue + } + let occurrence = (occurrenceByToken[sample.token] ?? 0) + 1 + occurrenceByToken[sample.token] = occurrence + anchors.append( + RDEPUBCFITokenAnchor( + token: sample.token, + occurrence: occurrence, + chapterOffset: chapterOffset, + cfiPath: path + ) + ) + } + + return anchors + } + + private static func path( + for chapterOffset: Int, + ranges: [RDEPUBCFIPathRange], + markers: [RDEPUBCFIMarker] + ) -> RDEPUBCFIPath? { + if let range = ranges.first(where: { chapterOffset >= $0.startOffset && chapterOffset <= $0.endOffset }) { + return range.cfiPath + } + return markers + .filter { $0.chapterOffset != nil } + .min(by: { + abs(($0.chapterOffset ?? 0) - chapterOffset) < abs(($1.chapterOffset ?? 0) - chapterOffset) + })? + .cfiPath + } + + private static func previewText(for normalizedText: String, limit: Int = 24) -> String { + String(normalizedText.prefix(limit)) + } + + private static func siblingSignature(stack: [RDEPUBCFIStep], childIndex: Int) -> String { + let parent = stack.map { String($0.index) }.joined(separator: "/") + return "\(parent)#\(childIndex)" + } +} + +private struct RDEPUBHTMLTagMatch { + let range: Range + let name: String + let attributes: String + let isClosing: Bool + let isSelfClosing: Bool + + static func matches(in html: String) -> [RDEPUBHTMLTagMatch] { + let cleaned = RDEPUBCFIDOMPathBuilder.stripCommentsAndCDATA(html) + + guard let regex = try? NSRegularExpression( + pattern: #"]*)>"#, + options: [.caseInsensitive] + ) else { + return [] + } + + let nsHTML = cleaned as NSString + return regex.matches(in: cleaned, range: NSRange(location: 0, length: nsHTML.length)).compactMap { match in + guard match.numberOfRanges > 2, + let range = Range(match.range, in: cleaned), + let nameRange = Range(match.range(at: 1), in: cleaned), + let attributesRange = Range(match.range(at: 2), in: cleaned) else { + return nil + } + + let rawTag = String(cleaned[range]) + return RDEPUBHTMLTagMatch( + range: range, + name: String(cleaned[nameRange]), + attributes: String(cleaned[attributesRange]), + isClosing: rawTag.hasPrefix("") + ) + } + } +} + +private struct RDEPUBNormalizedTextIndex { + let normalizedText: String + private let normalizedToChapterOffsets: [Int] + + init(source: String) { + var text = "" + var offsets: [Int] = [] + let nsSource = source as NSString + var lastWasWhitespace = false + + for index in 0.. Int? { + guard normalizedToChapterOffsets.indices.contains(offset) else { return nil } + return normalizedToChapterOffsets[offset] + } + + func tokenSamples(maxSamples: Int, stride: Int, window: Int) -> [(token: String, offset: Int)] { + guard !normalizedText.isEmpty, stride > 0, window > 0 else { return [] } + // Use NSString-based (UTF-16) iteration to match normalizedToChapterOffsets indices + let nsText = normalizedText as NSString + let length = nsText.length + var samples: [(token: String, offset: Int)] = [] + var index = 0 + + while index < length, samples.count < maxSamples { + let end = min(index + window, length) + let token = nsText.substring(with: NSRange(location: index, length: end - index)) + .trimmingCharacters(in: .whitespacesAndNewlines) + if token.utf16.count >= min(window, 4) { + samples.append((token, index)) + } + index += stride + } + + if samples.isEmpty { + let token = nsText.substring(with: NSRange(location: 0, length: min(window, length))) + .trimmingCharacters(in: .whitespacesAndNewlines) + if !token.isEmpty { + samples.append((token, 0)) + } + } + + return samples + } + + static func normalize(_ source: String) -> String { + var result = "" + var lastWasWhitespace = false + let decodedSource = RDEPUBHTMLEntityDecoder.decodeEntities(in: source) + let nsSource = decodedSource as NSString + + for index in 0.. Character { + if scalar == "\u{00A0}" { + return " " + } + if scalar.unicodeScalars.allSatisfy(\.properties.isWhitespace) { + return " " + } + return scalar.first ?? " " + } +} + +private extension String { + var fnv1a64: UInt64 { + let offsetBasis: UInt64 = 14695981039346656037 + let prime: UInt64 = 1099511628211 + return utf8.reduce(offsetBasis) { partialResult, byte in + (partialResult ^ UInt64(byte)) &* prime + } + } +} + +private enum RDEPUBHTMLEntityDecoder { + private static let namedEntities: [String: String] = [ + " ": " ", + "&": "&", + "<": "<", + ">": ">", + """: "\"", + "'": "'" + ] + + static func decode(_ value: String) -> String { + if let mapped = namedEntities[value] { + return mapped + } + guard value.hasPrefix("&"), value.hasSuffix(";") else { + return value + } + let body = String(value.dropFirst().dropLast()) + if body.hasPrefix("#x") || body.hasPrefix("#X"), + let scalar = UInt32(body.dropFirst(2), radix: 16).flatMap(UnicodeScalar.init) { + return String(Character(scalar)) + } + if body.hasPrefix("#"), + let scalar = UInt32(body.dropFirst(), radix: 10).flatMap(UnicodeScalar.init) { + return String(Character(scalar)) + } + return value + } + + static func decodeEntities(in value: String) -> String { + guard value.contains("&"), + let regex = try? NSRegularExpression(pattern: #"&(?:[A-Za-z]+|#\d+|#x[0-9A-Fa-f]+);"#) else { + return value + } + + let nsValue = value as NSString + var decoded = value + for match in regex.matches(in: value, range: NSRange(location: 0, length: nsValue.length)).reversed() { + let entity = nsValue.substring(with: match.range) + let replacement = decode(entity) + if let range = Range(match.range, in: decoded) { + decoded.replaceSubrange(range, with: replacement) + } + } + return decoded + } +} diff --git a/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIError.swift b/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIError.swift new file mode 100644 index 0000000..84f68b6 --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIError.swift @@ -0,0 +1,8 @@ +import Foundation + +public enum RDEPUBCFIError: Error, Equatable { + case empty + case invalidWrapper(String) + case invalidPath(String) + case unsupportedRange(String) +} diff --git a/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIGenerator.swift b/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIGenerator.swift new file mode 100644 index 0000000..feb750e --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIGenerator.swift @@ -0,0 +1,87 @@ +import Foundation + +public enum RDEPUBCFIGenerator { + + public static func makeOffsetCFI( + href: String, + fileIndex: Int, + chapterOffset: Int, + fragmentID: String? = nil, + sideBias: RDEPUBCFISideBias? = nil, + textAssertion: RDEPUBCFITextAssertion? = nil, + contentPath: RDEPUBCFIPath? = nil + ) -> RDEPUBCFI { + let packagePath = RDEPUBCFIPath(steps: [ + RDEPUBCFIStep(index: 6), + RDEPUBCFIStep(index: max((fileIndex + 1) * 2, 2), idAssertion: href) + ]) + let resolvedContentPath = contentPath ?? RDEPUBCFIPath(steps: [ + RDEPUBCFIStep(index: 4), + RDEPUBCFIStep(index: 2, idAssertion: fragmentID) + ]) + return RDEPUBCFI( + packagePath: packagePath, + contentPath: resolvedContentPath, + characterOffset: max(chapterOffset, 0), + sideBias: sideBias, + textAssertion: textAssertion + ) + } + + public static func makeCFI( + href: String, + fileIndex: Int, + contentPath: RDEPUBCFIPath, + characterOffset: Int, + sideBias: RDEPUBCFISideBias? = nil, + textAssertion: RDEPUBCFITextAssertion? = nil + ) -> RDEPUBCFI { + let packagePath = RDEPUBCFIPath(steps: [ + RDEPUBCFIStep(index: 6), + RDEPUBCFIStep(index: max((fileIndex + 1) * 2, 2), idAssertion: href) + ]) + return RDEPUBCFI( + packagePath: packagePath, + contentPath: contentPath, + characterOffset: max(characterOffset, 0), + sideBias: sideBias, + textAssertion: textAssertion + ) + } + + public static func makeOffsetRangeCFI( + href: String, + fileIndex: Int, + startOffset: Int, + endOffset: Int, + fragmentID: String? = nil, + startTextAssertion: RDEPUBCFITextAssertion? = nil, + endTextAssertion: RDEPUBCFITextAssertion? = nil, + contentPath: RDEPUBCFIPath? = nil + ) -> RDEPUBCFIRange { + precondition(startOffset <= endOffset, "startOffset (\(startOffset)) must be <= endOffset (\(endOffset))") + let start = makeOffsetCFI( + href: href, + fileIndex: fileIndex, + chapterOffset: startOffset, + fragmentID: fragmentID, + sideBias: .before, + textAssertion: startTextAssertion, + contentPath: contentPath + ) + let end = makeOffsetCFI( + href: href, + fileIndex: fileIndex, + chapterOffset: endOffset, + fragmentID: fragmentID, + sideBias: .after, + textAssertion: endTextAssertion, + contentPath: contentPath + ) + let parent = RDEPUBCFI( + packagePath: start.packagePath.commonPrefix(with: end.packagePath), + contentPath: start.contentPath.commonPrefix(with: end.contentPath) + ) + return RDEPUBCFIRange(parent: parent, start: start, end: end) + } +} diff --git a/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift b/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift new file mode 100644 index 0000000..8b22365 --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift @@ -0,0 +1,182 @@ +import Foundation + +public struct RDEPUBCFIMap: Codable, Equatable { + public var href: String + public var renderVersion: Int + public var domVersion: Int + public var markers: [RDEPUBCFIMarker] + public var textAssertions: [String: RDEPUBCFITextAssertion] + public var pathRanges: [RDEPUBCFIPathRange] + public var recoveryMetadata: RDEPUBCFIRecoveryMetadata + + /// Lazy O(1) lookup index for markers by cfiPath + private var markerByPath: [RDEPUBCFIPath: Int]? + + public init( + href: String, + renderVersion: Int = 1, + domVersion: Int = 1, + markers: [RDEPUBCFIMarker] = [], + textAssertions: [String: RDEPUBCFITextAssertion] = [:], + pathRanges: [RDEPUBCFIPathRange] = [], + recoveryMetadata: RDEPUBCFIRecoveryMetadata = .empty + ) { + self.href = href + self.renderVersion = renderVersion + self.domVersion = domVersion + self.markers = markers + self.textAssertions = textAssertions + self.pathRanges = pathRanges + self.recoveryMetadata = recoveryMetadata + } + + public func marker(matching path: RDEPUBCFIPath) -> RDEPUBCFIMarker? { + if let index = markerByPath?[path] { + return markers[index] + } + return markers.first { $0.cfiPath == path } + } + + /// Build or rebuild the marker lookup index. Call after mutating `markers`. + public mutating func rebuildMarkerIndex() { + markerByPath = Dictionary( + markers.enumerated().map { ($0.element.cfiPath, $0.offset) }, + uniquingKeysWith: { _, last in last } + ) + } + + enum CodingKeys: String, CodingKey { + case href + case renderVersion + case domVersion + case markers + case textAssertions + case pathRanges + case recoveryMetadata + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + href = try container.decode(String.self, forKey: .href) + renderVersion = try container.decodeIfPresent(Int.self, forKey: .renderVersion) ?? 1 + domVersion = try container.decodeIfPresent(Int.self, forKey: .domVersion) ?? 1 + markers = try container.decodeIfPresent([RDEPUBCFIMarker].self, forKey: .markers) ?? [] + textAssertions = try container.decodeIfPresent([String: RDEPUBCFITextAssertion].self, forKey: .textAssertions) ?? [:] + pathRanges = try container.decodeIfPresent([RDEPUBCFIPathRange].self, forKey: .pathRanges) ?? [] + recoveryMetadata = try container.decodeIfPresent(RDEPUBCFIRecoveryMetadata.self, forKey: .recoveryMetadata) ?? .empty + markerByPath = nil + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(href, forKey: .href) + try container.encode(renderVersion, forKey: .renderVersion) + try container.encode(domVersion, forKey: .domVersion) + try container.encode(markers, forKey: .markers) + try container.encode(textAssertions, forKey: .textAssertions) + try container.encode(pathRanges, forKey: .pathRanges) + try container.encode(recoveryMetadata, forKey: .recoveryMetadata) + } + + public static func == (lhs: RDEPUBCFIMap, rhs: RDEPUBCFIMap) -> Bool { + lhs.href == rhs.href + && lhs.renderVersion == rhs.renderVersion + && lhs.domVersion == rhs.domVersion + && lhs.markers == rhs.markers + && lhs.textAssertions == rhs.textAssertions + && lhs.pathRanges == rhs.pathRanges + && lhs.recoveryMetadata == rhs.recoveryMetadata + } +} + +public struct RDEPUBCFIMarker: Codable, Equatable { + public var cfiPath: RDEPUBCFIPath + public var chapterOffset: Int? + public var fragmentID: String? + public var textNodeLength: Int? + public var textNodeChecksum: UInt64? + public var normalizedTextPreview: String? + public var domSiblingSignature: String? + + public init( + cfiPath: RDEPUBCFIPath, + chapterOffset: Int? = nil, + fragmentID: String? = nil, + textNodeLength: Int? = nil, + textNodeChecksum: UInt64? = nil, + normalizedTextPreview: String? = nil, + domSiblingSignature: String? = nil + ) { + self.cfiPath = cfiPath + self.chapterOffset = chapterOffset + self.fragmentID = fragmentID + self.textNodeLength = textNodeLength + self.textNodeChecksum = textNodeChecksum + self.normalizedTextPreview = normalizedTextPreview + self.domSiblingSignature = domSiblingSignature + } +} + +public struct RDEPUBCFIPathRange: Codable, Equatable { + public var cfiPath: RDEPUBCFIPath + public var startOffset: Int + public var endOffset: Int + public var textNodeLength: Int + + public init( + cfiPath: RDEPUBCFIPath, + startOffset: Int, + endOffset: Int, + textNodeLength: Int + ) { + self.cfiPath = cfiPath + self.startOffset = startOffset + self.endOffset = endOffset + self.textNodeLength = textNodeLength + } +} + +public struct RDEPUBCFIRecoveryMetadata: Codable, Equatable { + public var domFingerprint: String + public var normalizedTextChecksum: String + public var tokenIndex: [RDEPUBCFITokenAnchor] + public var fragmentPathMap: [String: RDEPUBCFIPath] + + public init( + domFingerprint: String, + normalizedTextChecksum: String, + tokenIndex: [RDEPUBCFITokenAnchor] = [], + fragmentPathMap: [String: RDEPUBCFIPath] = [:] + ) { + self.domFingerprint = domFingerprint + self.normalizedTextChecksum = normalizedTextChecksum + self.tokenIndex = tokenIndex + self.fragmentPathMap = fragmentPathMap + } + + public static let empty = RDEPUBCFIRecoveryMetadata( + domFingerprint: "", + normalizedTextChecksum: "", + tokenIndex: [], + fragmentPathMap: [:] + ) +} + +public struct RDEPUBCFITokenAnchor: Codable, Equatable { + public var token: String + public var occurrence: Int + public var chapterOffset: Int + public var cfiPath: RDEPUBCFIPath + + public init( + token: String, + occurrence: Int, + chapterOffset: Int, + cfiPath: RDEPUBCFIPath + ) { + self.token = token + self.occurrence = occurrence + self.chapterOffset = chapterOffset + self.cfiPath = cfiPath + } +} diff --git a/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIParser.swift b/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIParser.swift new file mode 100644 index 0000000..05a7825 --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIParser.swift @@ -0,0 +1,271 @@ +import Foundation + +public enum RDEPUBCFIParser { + + public static func parse(_ rawValue: String?) throws -> RDEPUBCFI { + + guard let rawValue = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines), + !rawValue.isEmpty else { + throw RDEPUBCFIError.empty + } + + let body = try unwrap(rawValue) + + let parts = body.split(separator: "!", maxSplits: 1, omittingEmptySubsequences: false) + + let packagePath = try parsePath(String(parts.first ?? "")) + + let contentBody = parts.count > 1 ? String(parts[1]) : "" + + let parsedContent = try parsePathWithOffset(contentBody) + + return RDEPUBCFI( + packagePath: packagePath, + contentPath: parsedContent.path, + characterOffset: parsedContent.characterOffset, + sideBias: parsedContent.sideBias, + textAssertion: parsedContent.textAssertion + ) + } + + public static func parseRange(_ rawValue: String?) throws -> RDEPUBCFIRange { + + guard let rawValue = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines), + !rawValue.isEmpty else { + throw RDEPUBCFIError.empty + } + + let body = try unwrap(rawValue) + + let rangeParts = body.split(separator: ",", omittingEmptySubsequences: false) + guard rangeParts.count == 3 else { + throw RDEPUBCFIError.unsupportedRange(rawValue) + } + + let parentRaw = "epubcfi(\(rangeParts[0]))" + + let startRaw = "epubcfi(\(rangeParts[0])\(rangeParts[1]))" + + let endRaw = "epubcfi(\(rangeParts[0])\(rangeParts[2]))" + return RDEPUBCFIRange( + parent: try parse(parentRaw), + start: try parse(startRaw), + end: try parse(endRaw) + ) + } + + private static func unwrap(_ rawValue: String) throws -> String { + guard rawValue.hasPrefix("epubcfi("), rawValue.hasSuffix(")") else { + throw RDEPUBCFIError.invalidWrapper(rawValue) + } + return String(rawValue.dropFirst("epubcfi(".count).dropLast()) + } + + private static func parsePathWithOffset(_ body: String) throws -> ( + path: RDEPUBCFIPath, + characterOffset: Int?, + sideBias: RDEPUBCFISideBias?, + textAssertion: RDEPUBCFITextAssertion? + ) { + + guard let offsetSeparator = firstIndexOutsideBrackets(of: ":", in: body) else { + + return (try parsePath(body), nil, nil, nil) + } + + let pathPart = String(body[.. RDEPUBCFIPath { + + guard path.isEmpty || path.hasPrefix("/") else { + throw RDEPUBCFIError.invalidPath(path) + } + + let steps = path + .split(separator: "/", omittingEmptySubsequences: true) + .compactMap { parseStep(String($0)) } + return RDEPUBCFIPath(steps: steps) + } + + private static func parseStep(_ rawStep: String) -> RDEPUBCFIStep? { + + let indexPart = rawStep.split(separator: "[", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? rawStep + guard let index = Int(indexPart) else { return nil } + + let idAssertion: String? + if let open = rawStep.firstIndex(of: "["), + let close = rawStep.lastIndex(of: "]"), + open < close { + idAssertion = String(rawStep[rawStep.index(after: open).. RDEPUBCFISideBias? { + + guard let markerRange = body.range(of: ";s=") else { return nil } + let value = body[markerRange.upperBound...].prefix(1) + return RDEPUBCFISideBias(rawValue: String(value)) + } + + private static func parseTextAssertion(from body: String) -> RDEPUBCFITextAssertion? { + + // Scan for the opening bracket, respecting backslash escapes + guard let open = firstUnescapedIndex(of: "[", in: body) else { return nil } + + // Find the matching closing bracket, respecting backslash escapes and nesting + var depth = 1 + var current = body.index(after: open) + while current < body.endIndex && depth > 0 { + if body[current] == "\\" { + // Skip escaped character + current = body.index(after: current) + if current < body.endIndex { + current = body.index(after: current) + } + continue + } + if body[current] == "[" { + depth += 1 + } else if body[current] == "]" { + depth -= 1 + } + if depth > 0 { + current = body.index(after: current) + } + } + + guard depth == 0, current <= body.endIndex else { return nil } + let close = current + + // Extract content, processing escape sequences left-to-right + let raw = String(body[body.index(after: open).. 1 else { + return RDEPUBCFITextAssertion(exact: unescaped) + } + return RDEPUBCFITextAssertion( + prefix: components.first, + exact: components.count > 1 ? components[1] : nil, + suffix: components.count > 2 ? components[2] : nil + ) + } + + /// Find the first occurrence of a character not preceded by a backslash + private static func firstUnescapedIndex(of character: Character, in string: String) -> String.Index? { + var index = string.startIndex + while index < string.endIndex { + if string[index] == "\\" { + // Skip escaped character + index = string.index(after: index) + if index < string.endIndex { + index = string.index(after: index) + } + continue + } + if string[index] == character { + return index + } + index = string.index(after: index) + } + return nil + } + + /// Split assertion text by commas, but only those at the top level (not inside brackets) + private static func splitAssertionComponents(_ text: String) -> [String] { + var components: [String] = [] + var current = "" + var depth = 0 + var index = text.startIndex + while index < text.endIndex { + let char = text[index] + if char == "\\" { + current.append(char) + index = text.index(after: index) + if index < text.endIndex { + current.append(text[index]) + index = text.index(after: index) + } + continue + } + if char == "[" { + depth += 1 + } else if char == "]" { + depth = max(depth - 1, 0) + } else if char == "," && depth == 0 { + components.append(current) + current = "" + index = text.index(after: index) + continue + } + current.append(char) + index = text.index(after: index) + } + components.append(current) + return components + } + + /// Unescape CFI text assertion content: \[ → [, \] → ], \\ → \ + private static func unescapeCFIText(_ text: String) -> String { + var result = "" + var index = text.startIndex + while index < text.endIndex { + if text[index] == "\\" { + let nextIndex = text.index(after: index) + if nextIndex < text.endIndex { + let nextChar = text[nextIndex] + if nextChar == "[" || nextChar == "]" || nextChar == "\\" { + result.append(nextChar) + index = text.index(after: nextIndex) + continue + } + } + // Not a recognized escape — keep the backslash + result.append(text[index]) + } else { + result.append(text[index]) + } + index = text.index(after: index) + } + return result + } + + private static func firstIndexOutsideBrackets(of character: Character, in body: String) -> String.Index? { + + var bracketDepth = 0 + for index in body.indices { + let current = body[index] + if current == "[" { + bracketDepth += 1 + } else if current == "]" { + bracketDepth = max(bracketDepth - 1, 0) + } else if current == character, bracketDepth == 0 { + return index + } + } + return nil + } +} diff --git a/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIPath.swift b/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIPath.swift new file mode 100644 index 0000000..465dd9b --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIPath.swift @@ -0,0 +1,39 @@ +import Foundation + +public struct RDEPUBCFIPath: Codable, Equatable, Hashable { + + public var steps: [RDEPUBCFIStep] + + public init(steps: [RDEPUBCFIStep] = []) { + self.steps = steps + } + + public func commonPrefix(with other: RDEPUBCFIPath) -> RDEPUBCFIPath { + var prefix: [RDEPUBCFIStep] = [] + let upperBound = min(steps.count, other.steps.count) + for index in 0.. RDEPUBCFIPath { + guard prefix.steps.count <= steps.count else { return self } + let candidate = Array(steps.prefix(prefix.steps.count)) + guard candidate == prefix.steps else { return self } + return RDEPUBCFIPath(steps: Array(steps.dropFirst(prefix.steps.count))) + } +} + +public struct RDEPUBCFIStep: Codable, Equatable, Hashable { + + public var index: Int + + public var idAssertion: String? + + public init(index: Int, idAssertion: String? = nil) { + self.index = index + self.idAssertion = idAssertion?.nilIfEmpty + } +} diff --git a/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIRange.swift b/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIRange.swift new file mode 100644 index 0000000..3735876 --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIRange.swift @@ -0,0 +1,34 @@ +import Foundation + +public struct RDEPUBCFIRange: Codable, Equatable, Hashable { + public var parent: RDEPUBCFI? + public var start: RDEPUBCFI + public var end: RDEPUBCFI + + /// Always computed from components — no stale cached value risk. + public var rawValue: String { + RDEPUBCFISerializer.serializeRange(self) + } + + /// Initialize from components. The `rawValue` parameter is ignored — rawValue is always + /// computed from the components. To parse a CFI range string, use `RDEPUBCFIParser.parseRange(_:)`. + public init(rawValue: String = "", parent: RDEPUBCFI? = nil, start: RDEPUBCFI, end: RDEPUBCFI) { + self.parent = parent + self.start = start + self.end = end + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + let parsed = try RDEPUBCFIParser.parseRange(rawValue) + self.parent = parsed.parent + self.start = parsed.start + self.end = parsed.end + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} \ No newline at end of file diff --git a/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift b/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift new file mode 100644 index 0000000..8dca44e --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift @@ -0,0 +1,320 @@ +import Foundation + +public struct RDEPUBCFIRecoveryResult: Equatable { + public enum Confidence: Int, Codable { + case exactPath + case assertionCalibrated + case siblingRecovered + case tokenRecovered + case fragmentFallback + case offsetFallback + } + + public var chapterOffset: Int + public var confidence: Confidence + + public init(chapterOffset: Int, confidence: Confidence) { + self.chapterOffset = chapterOffset + self.confidence = confidence + } +} + +enum RDEPUBCFIRecoveryEngine { + static func recover( + cfi: RDEPUBCFI, + cfiMap: RDEPUBCFIMap?, + chapterText: String?, + fragmentOffsets: [String: Int], + fallbackOffset: Int?, + lastOffset: Int + ) -> RDEPUBCFIRecoveryResult? { + let resolved = RDEPUBCFIResolver.resolve(cfi) + + if let exact = exactPathResult(cfi: cfi, cfiMap: cfiMap, lastOffset: lastOffset) { + return calibrateIfNeeded( + exact, + cfi: cfi, + chapterText: chapterText, + lastOffset: lastOffset + ) + } + + if let sibling = siblingRecoveredResult(cfi: cfi, cfiMap: cfiMap, lastOffset: lastOffset) { + return calibrateIfNeeded( + sibling, + cfi: cfi, + chapterText: chapterText, + lastOffset: lastOffset + ) + } + + if let token = tokenRecoveredResult(cfi: cfi, cfiMap: cfiMap, chapterText: chapterText, lastOffset: lastOffset) { + return token + } + + if let fragmentID = resolved.fragmentID, + let fragmentOffset = fragmentOffsets[fragmentID] + ?? cfiMap?.markers.first(where: { $0.fragmentID == fragmentID })?.chapterOffset { + return RDEPUBCFIRecoveryResult( + chapterOffset: clamp(fragmentOffset, lastOffset: lastOffset), + confidence: .fragmentFallback + ) + } + + if let fallbackOffset { + return RDEPUBCFIRecoveryResult( + chapterOffset: clamp(fallbackOffset, lastOffset: lastOffset), + confidence: .offsetFallback + ) + } + + return nil + } + + private static func exactPathResult( + cfi: RDEPUBCFI, + cfiMap: RDEPUBCFIMap?, + lastOffset: Int + ) -> RDEPUBCFIRecoveryResult? { + guard let cfiMap else { return nil } + if let marker = cfiMap.marker(matching: cfi.contentPath), + let chapterOffset = chapterOffset(for: cfi, marker: marker, lastOffset: lastOffset) { + return RDEPUBCFIRecoveryResult(chapterOffset: chapterOffset, confidence: .exactPath) + } + if let pathRange = cfiMap.pathRanges.first(where: { $0.cfiPath == cfi.contentPath }) { + let localOffset = max(cfi.characterOffset ?? 0, 0) + let chapterOffset = clamp(pathRange.startOffset + min(localOffset, max(pathRange.textNodeLength - 1, 0)), lastOffset: lastOffset) + return RDEPUBCFIRecoveryResult(chapterOffset: chapterOffset, confidence: .exactPath) + } + return nil + } + + private static func siblingRecoveredResult( + cfi: RDEPUBCFI, + cfiMap: RDEPUBCFIMap?, + lastOffset: Int + ) -> RDEPUBCFIRecoveryResult? { + guard let cfiMap, + let targetStep = cfi.contentPath.steps.last else { return nil } + + let targetParent = cfi.contentPath.steps.dropLast() + let candidates = cfiMap.markers.filter { marker in + marker.cfiPath.steps.count == cfi.contentPath.steps.count + && Array(marker.cfiPath.steps.dropLast()) == Array(targetParent) + && marker.chapterOffset != nil + } + guard !candidates.isEmpty else { return nil } + + let siblingSignature = siblingSignature(for: cfi.contentPath) + let best = candidates.min { lhs, rhs in + siblingScore(for: lhs, targetStep: targetStep, siblingSignature: siblingSignature) + < siblingScore(for: rhs, targetStep: targetStep, siblingSignature: siblingSignature) + } + + guard let best, + let chapterOffset = chapterOffset(for: cfi, marker: best, lastOffset: lastOffset) else { + return nil + } + + return RDEPUBCFIRecoveryResult(chapterOffset: chapterOffset, confidence: .siblingRecovered) + } + + private static func tokenRecoveredResult( + cfi: RDEPUBCFI, + cfiMap: RDEPUBCFIMap?, + chapterText: String?, + lastOffset: Int + ) -> RDEPUBCFIRecoveryResult? { + guard let cfiMap, + let chapterText, + !chapterText.isEmpty, + let exact = cfi.textAssertion?.exact, + !exact.isEmpty else { + return nil + } + + let minLength = 2 + let exactCount = Double(max(exact.count, 1)) + let candidateAnchors = cfiMap.recoveryMetadata.tokenIndex.filter { anchor in + let tokenCount = anchor.token.count + guard tokenCount >= minLength else { return false } + let ratio = Double(tokenCount) / exactCount + guard ratio >= 0.3 && ratio <= 3.0 else { return false } + let hasPrefixMatch = anchor.token.hasPrefix(exact) || exact.hasPrefix(anchor.token) + let hasSuffixMatch = anchor.token.hasSuffix(exact) || exact.hasSuffix(anchor.token) + return hasPrefixMatch || hasSuffixMatch + } + guard !candidateAnchors.isEmpty else { return nil } + + let normalizedText = RDEPUBCFITextNodeMapBuilder.normalizedText(from: chapterText) + guard !normalizedText.isEmpty else { return nil } + + var bestOffset: Int? + var bestScore = Int.max + + for anchor in candidateAnchors { + let approximateOffset = clamp(anchor.chapterOffset, lastOffset: lastOffset) + let calibrated = calibratedOffset( + approximateOffset, + assertion: cfi.textAssertion, + text: chapterText, + lastOffset: lastOffset, + windowRadius: 768 + ) + let score = abs(calibrated - approximateOffset) + if score < bestScore { + bestScore = score + bestOffset = calibrated + } + } + + guard let bestOffset else { return nil } + return RDEPUBCFIRecoveryResult(chapterOffset: bestOffset, confidence: .tokenRecovered) + } + + private static func calibrateIfNeeded( + _ result: RDEPUBCFIRecoveryResult, + cfi: RDEPUBCFI, + chapterText: String?, + lastOffset: Int + ) -> RDEPUBCFIRecoveryResult { + guard let chapterText, + let assertion = cfi.textAssertion, + assertion.exact?.isEmpty == false else { + return result + } + let calibrated = calibratedOffset( + result.chapterOffset, + assertion: assertion, + text: chapterText, + lastOffset: lastOffset + ) + if calibrated != result.chapterOffset { + return RDEPUBCFIRecoveryResult(chapterOffset: calibrated, confidence: .assertionCalibrated) + } + return result + } + + private static func chapterOffset( + for cfi: RDEPUBCFI, + marker: RDEPUBCFIMarker, + lastOffset: Int + ) -> Int? { + guard let baseOffset = marker.chapterOffset else { return nil } + let localOffset = max(cfi.characterOffset ?? 0, 0) + if let textNodeLength = marker.textNodeLength, textNodeLength > 0 { + return clamp(baseOffset + min(localOffset, max(textNodeLength - 1, 0)), lastOffset: lastOffset) + } + return clamp(baseOffset, lastOffset: lastOffset) + } + + private static func siblingSignature(for path: RDEPUBCFIPath) -> String { + guard let last = path.steps.last else { return "" } + let parent = path.steps.dropLast().map { String($0.index) }.joined(separator: "/") + let childIndex = max(last.index / 2, 0) + return "\(parent)#\(childIndex)" + } + + private static func siblingScore( + for marker: RDEPUBCFIMarker, + targetStep: RDEPUBCFIStep, + siblingSignature: String + ) -> Int { + guard let candidateStep = marker.cfiPath.steps.last else { return Int.max } + var score = abs(candidateStep.index - targetStep.index) + if marker.domSiblingSignature != siblingSignature { + score += 1_000 + } + if marker.fragmentID != nil { + score += 100 + } + return score + } + + private static func calibratedOffset( + _ offset: Int, + assertion: RDEPUBCFITextAssertion?, + text: String, + lastOffset: Int, + windowRadius: Int = 512 + ) -> Int { + let clampedOffset = clamp(offset, lastOffset: lastOffset) + guard let assertion, + let exact = assertion.exact, + !exact.isEmpty else { + return clampedOffset + } + + let nsText = text as NSString + let length = nsText.length + guard length > 0 else { return clampedOffset } + + let searchStart = max(clampedOffset - windowRadius, 0) + let searchEnd = min(clampedOffset + windowRadius + exact.utf16.count, length) + guard searchEnd > searchStart else { return clampedOffset } + + let searchRange = NSRange(location: searchStart, length: searchEnd - searchStart) + var candidateRange = nsText.range(of: exact, options: [], range: searchRange) + var bestOffset: Int? + var bestScore = Int.max + + while candidateRange.location != NSNotFound { + let score = assertionScore( + assertion, + candidateLocation: candidateRange.location, + exactLength: candidateRange.length, + in: nsText, + preferredOffset: clampedOffset + ) + if score < bestScore { + bestScore = score + bestOffset = candidateRange.location + } + + let nextLocation = candidateRange.location + max(candidateRange.length, 1) + let upperBound = searchRange.location + searchRange.length + guard nextLocation < upperBound else { break } + candidateRange = nsText.range( + of: exact, + options: [], + range: NSRange(location: nextLocation, length: upperBound - nextLocation) + ) + } + + guard let bestOffset, bestScore < Int.max else { + return clampedOffset + } + return clamp(bestOffset, lastOffset: lastOffset) + } + + private static func assertionScore( + _ assertion: RDEPUBCFITextAssertion, + candidateLocation: Int, + exactLength: Int, + in text: NSString, + preferredOffset: Int + ) -> Int { + var score = abs(candidateLocation - preferredOffset) + if let prefix = assertion.prefix, !prefix.isEmpty { + let prefixStart = max(candidateLocation - prefix.utf16.count, 0) + let availableLength = candidateLocation - prefixStart + let candidatePrefix = availableLength > 0 + ? text.substring(with: NSRange(location: prefixStart, length: availableLength)) + : "" + score += candidatePrefix.hasSuffix(prefix) ? 0 : 10_000 + } + if let suffix = assertion.suffix, !suffix.isEmpty { + let suffixStart = min(candidateLocation + exactLength, text.length) + let suffixLength = min(suffix.utf16.count, text.length - suffixStart) + let candidateSuffix = suffixLength > 0 + ? text.substring(with: NSRange(location: suffixStart, length: suffixLength)) + : "" + score += candidateSuffix == suffix ? 0 : 10_000 + } + return score + } + + private static func clamp(_ offset: Int, lastOffset: Int) -> Int { + min(max(offset, 0), max(lastOffset, 0)) + } +} diff --git a/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIResolver.swift b/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIResolver.swift new file mode 100644 index 0000000..70b057c --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIResolver.swift @@ -0,0 +1,51 @@ +import Foundation + +public struct RDEPUBCFIResolverResult: Equatable { + + public var href: String? + + public var fileIndex: Int? + + public var chapterOffset: Int? + + public var fragmentID: String? + + public init(href: String? = nil, fileIndex: Int? = nil, chapterOffset: Int? = nil, fragmentID: String? = nil) { + self.href = href + self.fileIndex = fileIndex + self.chapterOffset = chapterOffset + self.fragmentID = fragmentID + } +} + +public enum RDEPUBCFIResolver { + + public static func resolve(_ cfi: RDEPUBCFI) -> RDEPUBCFIResolverResult { + + // EPUB CFI spec: package path structure is /6/2[spine]/2n[manifest-id] + // Manifest step is always the 3rd step (index 2) if present, or the last step with an id assertion. + let manifestStep: RDEPUBCFIStep? + if cfi.packagePath.steps.count >= 3 { + manifestStep = cfi.packagePath.steps[2] + } else { + manifestStep = cfi.packagePath.steps.last(where: { $0.idAssertion?.nilIfEmpty != nil }) + } + let href = manifestStep?.idAssertion + + // Spine step is the 2nd step (index 1) in the package path + let fileIndex: Int? + if cfi.packagePath.steps.count >= 2 { + fileIndex = max((cfi.packagePath.steps[1].index / 2) - 1, 0) + } else { + fileIndex = nil + } + + let fragmentID = cfi.contentPath.steps.last(where: { $0.idAssertion?.nilIfEmpty != nil })?.idAssertion + return RDEPUBCFIResolverResult( + href: href, + fileIndex: fileIndex, + chapterOffset: cfi.characterOffset, + fragmentID: fragmentID + ) + } +} \ No newline at end of file diff --git a/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFISerializer.swift b/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFISerializer.swift new file mode 100644 index 0000000..7630418 --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFISerializer.swift @@ -0,0 +1,109 @@ +import Foundation + +public enum RDEPUBCFISerializer { + + public static func serialize(_ cfi: RDEPUBCFI) -> String { + + var body = serializePath(cfi.packagePath) + + let content = serializePath(cfi.contentPath) + + if !content.isEmpty || cfi.characterOffset != nil { + body += "!" + content + } + + if let characterOffset = cfi.characterOffset { + body += ":\(max(characterOffset, 0))" + } + + if let sideBias = cfi.sideBias { + body += ";s=\(sideBias.rawValue)" + } + + if let assertion = cfi.textAssertion { + body += "[\(assertion.prefix ?? ""),\(assertion.exact ?? ""),\(assertion.suffix ?? "")]" + } + return "epubcfi(\(body))" + } + + public static func serializeRange(_ range: RDEPUBCFIRange) -> String { + let canonical = canonicalRangeComponents(for: range) + let parentBody = serializeRangeParent(canonical.parent) + let startBody = serializeRangeTerminal( + canonical.start, + relativeToPackagePath: canonical.parent.packagePath, + contentPrefix: canonical.parent.contentPath + ) + let endBody = serializeRangeTerminal( + canonical.end, + relativeToPackagePath: canonical.parent.packagePath, + contentPrefix: canonical.parent.contentPath + ) + return "epubcfi(\(parentBody),\(startBody),\(endBody))" + } + + public static func serializePath(_ path: RDEPUBCFIPath) -> String { + guard !path.steps.isEmpty else { return "" } + return path.steps.map { step in + if let idAssertion = step.idAssertion { + return "/\(step.index)[\(idAssertion)]" + } + return "/\(step.index)" + }.joined() + } + + private static func canonicalRangeComponents(for range: RDEPUBCFIRange) -> ( + parent: RDEPUBCFI, + start: RDEPUBCFI, + end: RDEPUBCFI + ) { + if let parent = range.parent { + return (parent, range.start, range.end) + } + + let sharedPackage = range.start.packagePath.commonPrefix(with: range.end.packagePath) + let sharedContent = range.start.contentPath.commonPrefix(with: range.end.contentPath) + let parent = RDEPUBCFI( + packagePath: sharedPackage, + contentPath: sharedContent, + characterOffset: nil, + sideBias: nil, + textAssertion: nil + ) + return (parent, range.start, range.end) + } + + private static func serializeRangeParent(_ parent: RDEPUBCFI) -> String { + var body = serializePath(parent.packagePath) + let content = serializePath(parent.contentPath) + if !content.isEmpty { + body += "!" + content + } + return body + } + + private static func serializeRangeTerminal( + _ cfi: RDEPUBCFI, + relativeToPackagePath parentPackagePath: RDEPUBCFIPath, + contentPrefix: RDEPUBCFIPath + ) -> String { + let relativePackage = cfi.packagePath.droppingPrefix(parentPackagePath) + let relativeContent = cfi.contentPath.droppingPrefix(contentPrefix) + + var body = serializePath(relativePackage) + let content = serializePath(relativeContent) + if !content.isEmpty || cfi.characterOffset != nil || !relativePackage.steps.isEmpty { + body += content + } + if let characterOffset = cfi.characterOffset { + body += ":\(max(characterOffset, 0))" + } + if let sideBias = cfi.sideBias { + body += ";s=\(sideBias.rawValue)" + } + if let assertion = cfi.textAssertion { + body += "[\(assertion.prefix ?? ""),\(assertion.exact ?? ""),\(assertion.suffix ?? "")]" + } + return body + } +} diff --git a/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFITextAssertion.swift b/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFITextAssertion.swift new file mode 100644 index 0000000..e7248db --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFITextAssertion.swift @@ -0,0 +1,16 @@ +import Foundation + +public struct RDEPUBCFITextAssertion: Codable, Equatable, Hashable { + + public var prefix: String? + + public var exact: String? + + public var suffix: String? + + public init(prefix: String? = nil, exact: String? = nil, suffix: String? = nil) { + self.prefix = prefix?.nilIfEmpty + self.exact = exact?.nilIfEmpty + self.suffix = suffix?.nilIfEmpty + } +} diff --git a/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIUtilities.swift b/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIUtilities.swift new file mode 100644 index 0000000..050c0c4 --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIUtilities.swift @@ -0,0 +1,8 @@ +import Foundation + +internal extension String { + var nilIfEmpty: String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} \ No newline at end of file diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextIndexTable.swift b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextIndexTable.swift index 8978b78..05b9d66 100644 --- a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextIndexTable.swift +++ b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextIndexTable.swift @@ -1,18 +1,12 @@ -// 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) { @@ -21,30 +15,31 @@ public struct RDEPUBRowColumnIndex: Codable, Equatable { 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 let fileTextMap: [Int: String] + + public let fileCFIMap: [Int: RDEPUBCFIMap] + public var totalCharacterCount: Int { guard let lastIndex = chapterStartOffsets.indices.last, chapterLengths.indices.contains(lastIndex) else { @@ -53,7 +48,6 @@ public struct RDEPUBTextIndexTable { return chapterStartOffsets[lastIndex] + chapterLengths[lastIndex] } - /// 从文本章节列表构建索引表 public init(chapters: [RDEPUBTextChapter]) { var offsets: [Int] = [] var lengths: [Int] = [] @@ -62,6 +56,8 @@ public struct RDEPUBTextIndexTable { var fragmentMap: [String: [String: Int]] = [:] var fileIndexMap: [Int: String] = [:] var rowColumnMap: [Int: [RDEPUBRowColumnIndex]] = [:] + var textMap: [Int: String] = [:] + var cfiMap: [Int: RDEPUBCFIMap] = [:] var running = 0 for (index, chapter) in chapters.enumerated() { @@ -72,7 +68,12 @@ public struct RDEPUBTextIndexTable { running += chapter.attributedContent.length fragmentMap[chapter.href] = chapter.fragmentOffsets fileIndexMap[chapter.spineIndex] = chapter.href - rowColumnMap[chapter.spineIndex] = Self.makeRowColumnIndices(for: chapter.attributedContent.string) + let chapterText = chapter.attributedContent.string + rowColumnMap[chapter.spineIndex] = Self.makeRowColumnIndices(for: chapterText) + textMap[chapter.spineIndex] = chapterText + if let chapterCFIMap = chapter.cfiMap { + cfiMap[chapter.spineIndex] = chapterCFIMap + } } self.chapterStartOffsets = offsets @@ -82,9 +83,10 @@ public struct RDEPUBTextIndexTable { self.fragmentOffsetsByHref = fragmentMap self.fileIndexToHref = fileIndexMap self.fileRowColumnMap = rowColumnMap + self.fileTextMap = textMap + self.fileCFIMap = cfiMap } - /// 根据绝对索引和章节构建文本锚点 public func anchor(forAbsoluteIndex index: Int, in chapter: RDEPUBTextChapter) -> RDEPUBTextAnchor { let normalizedIndex = clampedOffset(index, in: chapter) let fragmentID = nearestFragmentID(beforeOrAt: normalizedIndex, in: chapter) @@ -99,8 +101,12 @@ public struct RDEPUBTextIndexTable { ) } - /// 根据阅读位置构建文本锚点(优先使用 rangeAnchor,其次根据 fragment 或 progression 计算) public func anchor(for location: RDEPUBLocation) -> RDEPUBTextAnchor? { + if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi), + let anchor = anchor(for: cfi) { + return anchor + } + if let anchor = location.rangeAnchor?.start { return anchor } @@ -127,7 +133,104 @@ public struct RDEPUBTextIndexTable { ) } - /// 根据全书字符偏移量构建文本锚点。 + public func anchor(for cfi: RDEPUBCFI) -> RDEPUBTextAnchor? { + let resolved = RDEPUBCFIResolver.resolve(cfi) + let fileIndex: Int + if let resolvedFileIndex = resolved.fileIndex { + fileIndex = resolvedFileIndex + } else if let href = resolved.href, let mappedFileIndex = hrefToFileIndex[href] { + fileIndex = mappedFileIndex + } else { + return nil + } + + guard let href = href(for: fileIndex) ?? resolved.href else { return nil } + let chapterLength = max(chapterLength(forFileIndex: fileIndex) ?? 0, 1) + let lastOffset = max(chapterLength - 1, 0) + let recovery = RDEPUBCFIRecoveryEngine.recover( + cfi: cfi, + cfiMap: fileCFIMap[fileIndex], + chapterText: fileTextMap[fileIndex], + fragmentOffsets: fragmentOffsetsByHref[href] ?? [:], + fallbackOffset: resolved.chapterOffset, + lastOffset: lastOffset + ) + let chapterOffset = recovery?.chapterOffset ?? min(max(resolved.chapterOffset ?? 0, 0), lastOffset) + let fragmentID = resolved.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 rangeAnchor(for cfiRange: RDEPUBCFIRange) -> RDEPUBTextRangeAnchor? { + guard let start = anchor(for: cfiRange.start), + let end = anchor(for: cfiRange.end), + start.fileIndex == end.fileIndex else { + return nil + } + return RDEPUBTextRangeAnchor(start: start, end: end) + } + + public func cfi(for anchor: RDEPUBTextAnchor) -> RDEPUBCFI? { + guard let href = href(for: anchor.fileIndex) else { return nil } + let chapterOffset = chapterOffset(for: anchor) + if let marker = preciseMarker(fileIndex: anchor.fileIndex, chapterOffset: chapterOffset) { + let localOffset = max(chapterOffset - (marker.chapterOffset ?? 0), 0) + return RDEPUBCFIGenerator.makeCFI( + href: href, + fileIndex: anchor.fileIndex, + contentPath: marker.cfiPath, + characterOffset: localOffset, + textAssertion: textAssertion(fileIndex: anchor.fileIndex, offset: chapterOffset) + ) + } + return RDEPUBCFIGenerator.makeOffsetCFI( + href: href, + fileIndex: anchor.fileIndex, + chapterOffset: chapterOffset, + fragmentID: anchor.fragmentID, + textAssertion: textAssertion(fileIndex: anchor.fileIndex, offset: chapterOffset) + ) + } + + public func cfiRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> RDEPUBCFIRange? { + guard rangeAnchor.start.fileIndex == rangeAnchor.end.fileIndex, + let href = href(for: rangeAnchor.start.fileIndex) else { + return nil + } + let startOffset = chapterOffset(for: rangeAnchor.start) + let endOffset = chapterOffset(for: rangeAnchor.end) + let startCFI = cfi(for: rangeAnchor.start) + let endCFI = cfi(for: rangeAnchor.end) + if let startCFI, let endCFI { + let parent = RDEPUBCFI( + packagePath: startCFI.packagePath.commonPrefix(with: endCFI.packagePath), + contentPath: startCFI.contentPath.commonPrefix(with: endCFI.contentPath) + ) + return RDEPUBCFIRange(parent: parent, start: startCFI, end: endCFI) + } + return RDEPUBCFIGenerator.makeOffsetRangeCFI( + href: href, + fileIndex: rangeAnchor.start.fileIndex, + startOffset: startOffset, + endOffset: endOffset, + fragmentID: rangeAnchor.start.fragmentID, + startTextAssertion: textAssertion( + fileIndex: rangeAnchor.start.fileIndex, + offset: startOffset + ), + endTextAssertion: textAssertion( + fileIndex: rangeAnchor.end.fileIndex, + offset: endOffset + ) + ) + } + public func anchor(forGlobalIndex index: Int) -> RDEPUBTextAnchor? { guard let fileIndex = fileIndex(forCharacterPosition: index), let href = href(for: fileIndex) else { @@ -145,7 +248,6 @@ public struct RDEPUBTextIndexTable { ) } - /// 根据锚点查找对应的绝对页码 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 @@ -155,17 +257,14 @@ public struct RDEPUBTextIndexTable { }.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], @@ -175,7 +274,6 @@ public struct RDEPUBTextIndexTable { return chapterStartOffsets[chapterIndex] } - /// 通过文件索引获取章节长度。 public func chapterLength(forFileIndex fileIndex: Int) -> Int? { guard let href = href(for: fileIndex), let chapterIndex = hrefToChapterIndex[href], @@ -185,12 +283,10 @@ public struct RDEPUBTextIndexTable { return chapterLengths[chapterIndex] } - /// 将锚点转换为章节内字符偏移量。 public func chapterOffset(for anchor: RDEPUBTextAnchor) -> Int { anchor.chapterOffset } - /// 将锚点转换为全书绝对字符索引。 public func globalIndex(for anchor: RDEPUBTextAnchor) -> Int { let chapterOffset = chapterOffset(for: anchor) let baseOffset = chapterStartOffset(forFileIndex: anchor.fileIndex) ?? 0 @@ -199,31 +295,26 @@ public struct RDEPUBTextIndexTable { 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, @@ -238,11 +329,12 @@ public struct RDEPUBTextIndexTable { progression: progression, lastProgression: progression, fragment: anchor.fragmentID, - rangeAnchor: RDEPUBTextRangeAnchor(start: anchor, end: anchor) + rangeAnchor: RDEPUBTextRangeAnchor(start: anchor, end: anchor), + cfi: cfi(for: anchor)?.rawValue, + lastCFI: cfi(for: anchor)?.rawValue ) } - /// 将范围锚点转换为阅读位置(起止 progression) public func location( for rangeAnchor: RDEPUBTextRangeAnchor, in chapter: RDEPUBTextChapter, @@ -259,11 +351,13 @@ public struct RDEPUBTextIndexTable { progression: Double(clampedStart) / Double(totalLength), lastProgression: Double(clampedEnd) / Double(totalLength), fragment: rangeAnchor.start.fragmentID, - rangeAnchor: rangeAnchor + rangeAnchor: rangeAnchor, + cfi: cfi(for: rangeAnchor.start)?.rawValue, + lastCFI: cfi(for: rangeAnchor.end)?.rawValue, + rangeCFI: cfiRange(for: rangeAnchor)?.rawValue ) } - /// 根据绝对索引和文件索引查找所在行号 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 { @@ -272,7 +366,6 @@ public struct RDEPUBTextIndexTable { 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) }) { @@ -282,7 +375,6 @@ public struct RDEPUBTextIndexTable { 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) @@ -291,7 +383,6 @@ 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 { @@ -300,7 +391,6 @@ public struct RDEPUBTextIndexTable { 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)) @@ -321,7 +411,6 @@ public struct RDEPUBTextIndexTable { 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 { @@ -331,13 +420,11 @@ public struct RDEPUBTextIndexTable { 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 @@ -352,7 +439,6 @@ 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 @@ -367,7 +453,50 @@ public struct RDEPUBTextIndexTable { return bestID } - /// 从文本字符串构建行列索引数组(按行分割,记录每行的起止偏移) + private func preciseMarker(fileIndex: Int, chapterOffset: Int) -> RDEPUBCFIMarker? { + guard let markers = fileCFIMap[fileIndex]?.markers else { return nil } + return markers + .filter { + guard let start = $0.chapterOffset, + let length = $0.textNodeLength, + length > 0 else { + return false + } + return chapterOffset >= start && chapterOffset < start + length + } + .max { lhs, rhs in + let leftStart = lhs.chapterOffset ?? 0 + let rightStart = rhs.chapterOffset ?? 0 + if leftStart != rightStart { + return leftStart < rightStart + } + return (lhs.textNodeLength ?? 0) > (rhs.textNodeLength ?? 0) + } + } + + private func textAssertion(fileIndex: Int, offset: Int) -> RDEPUBCFITextAssertion? { + guard let text = fileTextMap[fileIndex], !text.isEmpty else { return nil } + let nsText = text as NSString + let length = nsText.length + guard length > 0 else { return nil } + + let location = min(max(offset, 0), max(length - 1, 0)) + let exactLength = min(16, max(length - location, 0)) + guard exactLength > 0 else { return nil } + + let prefixStart = max(location - 8, 0) + let suffixStart = min(location + exactLength, length) + return RDEPUBCFITextAssertion( + prefix: prefixStart < location + ? nsText.substring(with: NSRange(location: prefixStart, length: location - prefixStart)) + : nil, + exact: nsText.substring(with: NSRange(location: location, length: exactLength)), + suffix: suffixStart < length + ? nsText.substring(with: NSRange(location: suffixStart, length: min(8, length - suffixStart))) + : nil + ) + } + private static func makeRowColumnIndices(for text: String) -> [RDEPUBRowColumnIndex] { let nsText = text as NSString let length = nsText.length