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, fallbackOffset: resolved.chapterOffset, 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?, fallbackOffset: Int?, 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 } let preferredOffset = fallbackOffset.map { clamp($0, lastOffset: lastOffset) } 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 = tokenRecoveryScore( for: anchor, targetPath: cfi.contentPath, approximateOffset: approximateOffset, calibratedOffset: calibrated, preferredOffset: preferredOffset, allAnchors: cfiMap.recoveryMetadata.tokenIndex ) 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)) } private static func tokenRecoveryScore( for anchor: RDEPUBCFITokenAnchor, targetPath: RDEPUBCFIPath, approximateOffset: Int, calibratedOffset: Int, preferredOffset: Int?, allAnchors: [RDEPUBCFITokenAnchor] ) -> Int { var score = abs(calibratedOffset - approximateOffset) score += pathDistance(anchor.cfiPath, targetPath) * 1_024 if let preferredOffset { score += abs(approximateOffset - preferredOffset) let expectedOccurrence = nearestOccurrence( forToken: anchor.token, preferredOffset: preferredOffset, anchors: allAnchors ) score += abs(anchor.occurrence - expectedOccurrence) * 256 } return score } private static func nearestOccurrence( forToken token: String, preferredOffset: Int, anchors: [RDEPUBCFITokenAnchor] ) -> Int { anchors .filter { $0.token == token } .min(by: { abs($0.chapterOffset - preferredOffset) < abs($1.chapterOffset - preferredOffset) })? .occurrence ?? 0 } private static func pathDistance(_ lhs: RDEPUBCFIPath, _ rhs: RDEPUBCFIPath) -> Int { let prefix = lhs.commonPrefix(with: rhs).steps.count let remainingLHS = lhs.steps.dropFirst(prefix) let remainingRHS = rhs.steps.dropFirst(prefix) let stepDistance = zip(remainingLHS, remainingRHS).reduce(0) { partial, pair in partial + abs(pair.0.index - pair.1.index) } return stepDistance + abs(lhs.steps.count - rhs.steps.count) * 2 } }