ReadViewSDK/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFISerializer.swift
shenlei f50495ad91 fix: CFI module — 11 issues from code review
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 <noreply@anthropic.com>
2026-06-18 13:25:17 +08:00

110 lines
3.7 KiB
Swift

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