# CFI 实现问题分析报告 > 最后更新:2026-06-18 --- ## 概述 本报告覆盖 `EPUBCore/CFI/` 目录下全部 13 个 Swift 文件的代码审查结果。共发现 **11 个问题**,按严重程度分为高/中/低三级。 | 严重程度 | 数量 | 说明 | |----------|------|------| | **高** | 3 | rawValue 短路、UTF-16 混用、content path 硬编码 | | **中** | 5 | Resolver 假设不健壮、HTML 正则解析、text assertion 转义、token 匹配宽泛、end offset 边界 | | **低** | 3 | 重复 nilIfEmpty、线性扫描、parent 静默失败 | --- ## 高优先级 ### 1. Serializer 的 rawValue 短路逻辑 — 数据一致性风险 **文件:** `RDEPUBCFISerializer.swift:5-9`、`33-36` ```swift public static func serialize(_ cfi: RDEPUBCFI) -> String { if !cfi.rawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return cfi.rawValue // 直接返回原始值,不检查组件是否变化 } // ... 从组件重新构建 } ``` **问题:** `RDEPUBCFI` 同时存储了 `rawValue`(原始字符串)和解析后的组件(`packagePath`、`contentPath`、`characterOffset` 等)。如果有人修改了组件但没有清空 `rawValue`,序列化会返回**过期的旧值**。 `RDEPUBCFIGenerator` 中已经出现了这个 workaround — 先创建 `rawValue: ""` 的 CFI,序列化后再创建一个新 CFI 填入 `rawValue`。这说明开发者意识到了问题,但没有从根源修复。 **建议:** 要么移除 `rawValue` 缓存,每次都从组件重新构建;要么让 `rawValue` 成为 `computed property`,在 `packagePath`/`contentPath` 等变化时自动失效。 --- ### 2. UTF-16 vs Character Offset 混用 **文件:** `RDEPUBCFIDOMPathBuilder.swift:435-458`(`RDEPUBNormalizedTextIndex.tokenSamples`)与 `RDEPUBCFIDOMPathBuilder.swift:405-428`(`RDEPUBNormalizedTextIndex.init`) **问题:** `RDEPUBNormalizedTextIndex` 内部同时维护了两种偏移体系,但没有统一: 1. **`normalizedToChapterOffsets` 数组**(`init` 中构建):通过遍历 `nsSource.length`(UTF-16 code unit 数)逐个构建,每个 UTF-16 code unit 对应一个数组条目。因此数组下标是 **UTF-16 索引**。 2. **`tokenSamples()` 返回的 offset**:使用 `Array(normalizedText)` 生成 token,`Array` 按 Swift Character 拆分,返回的 offset 是 `characters` 数组的下标——即 **Character 索引**。 3. **`chapterOffset(forNormalizedOffset:)`**:用传入的 offset 直接查表 `normalizedToChapterOffsets[offset]`,期望接收 UTF-16 索引。 当 `normalizedText` 含多 code unit 字符时,`normalizedText.count`(Character 数)< `nsSource.length`(UTF-16 code unit 数),导致 `tokenSamples` 返回的 Character 索引在查 UTF-16 索引表时越界或错位。 此外,`calibratedOffset`(`RDEPUBCFIRecoveryEngine.swift:240-279`)全程使用 `NSString`/`NSRange`(UTF-16 偏移)进行搜索和打分,这与 `tokenSamples` 的 Character 偏移体系不一致。 **影响场景:** - 含 emoji 的书籍(如 😀,UTF-16 surrogate pair 占 2 code unit,但 Character 计为 1) - 含 CJK 扩展 B 区汉字的古籍 - 含数学符号的教材(如 𝕏,占 2 code unit) **建议:** 统一偏移基准。推荐全部使用 UTF-16 偏移(与 NSRange/NSAttributedString 一致),将 `tokenSamples` 改为基于 UTF-16 索引生成 token,或在 `chapterOffset(forNormalizedOffset:)` 入口处显式转换。 --- ### 3. Generator 的 content path 硬编码 **文件:** `RDEPUBCFIGenerator.swift:17-19` ```swift let contentPath = RDEPUBCFIPath(steps: [ RDEPUBCFIStep(index: 4), // 的第 2 个元素子节点 RDEPUBCFIStep(index: 2, idAssertion: fragmentID) // 第 1 个文本节点 ]) ``` **问题:** `makeOffsetCFI` 硬编码 content path 为 `/4/2`,即假设目标文本在 `` 的第 2 个元素子节点的第 1 个文本子节点中。以下情况会出错: | 场景 | 预期 content path | 实际生成 | |------|-------------------|----------| | 文本直接在 `` 下 | `/4/N`(N 为文本节点奇数索引) | `/4/2` ❌ | | 文本在 3 层嵌套中 | `/4/2/2/2` | `/4/2` ❌ | | `` 前有注释节点 | 索引需要偏移 | `/4/2` ❌ | `makeCFI` 方法允许传入自定义 `contentPath`,但 `makeOffsetCFI` 没有这个灵活性,而它是 `makeOffsetRangeCFI` 的内部依赖。 **建议:** `makeOffsetCFI` 应接受可选的 `contentPath` 参数,或从 `RDEPUBCFIMap` 中查找正确的路径。 --- ## 中优先级 ### 4. Resolver 的 CFI 结构假设不健壮 **文件:** `RDEPUBCFIResolver.swift:23-40` ```swift public static func resolve(_ cfi: RDEPUBCFI) -> RDEPUBCFIResolverResult { let manifestStep = cfi.packagePath.steps.last(where: { $0.idAssertion?.isEmpty == false }) let href = manifestStep?.idAssertion let fileIndex = cfi.packagePath.steps .dropFirst() .last .map { max(($0.index / 2) - 1, 0) } // ... } ``` **问题 1 — manifest 步骤查找不可靠:** 用 `last(where:)` 搜索带 id assertion 的步骤。EPUB CFI 规范中 package path 结构固定:`/6`(package)→ `/2[n]`(spine)→ `/2n[manifest-id]`。应明确取第三个步骤,而非搜索。 **问题 2 — fileIndex 计算错误:** `dropFirst().last` 取的是**最后一个**步骤,不是第二个。如果 package path 有 3 个步骤(如 Fixed Layout spread),取到的是 manifest 步骤而非 spine 步骤。正确做法是 `steps.count >= 2 ? steps[1] : nil`。 **问题 3 — 可读性差:** `idAssertion?.isEmpty == false` 逻辑取反应,应该是 `idAssertion?.nilIfEmpty != nil`。 --- ### 5. DOMPathBuilder 用正则解析 HTML — 脆弱且有边界情况 **文件:** `RDEPUBCFIDOMPathBuilder.swift:5-8` ```swift guard let regex = try? NSRegularExpression( pattern: #"]*)>"#, options: [.caseInsensitive] ) else { return [:] } ``` **边界情况清单:** | 输入 | 后果 | |------|------| | `` | 正则匹配注释内的 `
`,导致错误的子节点计数 | | ` ]]>` | 同上 | | `
` | 属性值中的 `>` 导致正则截断 | | `` | 无值属性解析正常,但 `idAttribute` 正则要求引号 | | `` | `< b` 可能被匹配为标签 | | `
` vs `
` | `hasSuffix("/>")` 可能在属性值以 `/` 结尾时误判 | **建议:** 考虑使用 `DTCoreText` 或 `libxml2` 进行真正的 HTML 解析。如果必须用正则,至少先移除注释和 CDATA。 --- ### 6. Text Assertion 解析不处理转义 **文件:** `RDEPUBCFIParser.swift:133-153` ```swift private static func parseTextAssertion(from body: String) -> RDEPUBCFITextAssertion? { guard let open = body.firstIndex(of: "["), let close = body.lastIndex(of: "]"), open < close else { return nil } let raw = String(body[body.index(after: open).. RDEPUBCFIMarker? { markers.first { $0.cfiPath == path } } ``` **问题:** 对于大章节(几百个文本节点),每次查找都做 O(n) 线性扫描。在恢复引擎中可能被多次调用,性能成为瓶颈。 **建议:** 在 `RDEPUBCFIMap` 初始化时构建 `[RDEPUBCFIPath: Int]` 索引字典(path → marker 数组下标),查找降为 O(1)。 --- ### 11. Range 解析的 parent 静默失败 **文件:** `RDEPUBCFIParser.swift:51` ```swift return RDEPUBCFIRange( rawValue: rawValue, parent: try? parse(parentRaw), // 静默忽略解析失败 start: try parse(startRaw), end: try parse(endRaw) ) ``` **问题:** `parent` 用 `try?` 静默忽略解析失败,但 `start` 和 `end` 用 `try` 抛出。如果 parent 解析失败,`parent` 为 `nil`,后续 `canonicalRangeComponents` 会从 start/end 推导 parent,推导逻辑可能与原始 parent 不一致。 **建议:** 统一错误处理策略 — 要么全部抛出,要么全部容错。当前的混合策略会让调试困难。 --- ## 附录:受影响文件清单 | 文件 | 涉及问题 | |------|----------| | `RDEPUBCFI.swift` | #1(rawValue 存储) | | `RDEPUBCFIParser.swift` | #6(text assertion 转义)、#11(parent 静默失败)、#9(nilIfEmpty) | | `RDEPUBCFISerializer.swift` | #1(rawValue 短路) | | `RDEPUBCFIPath.swift` | #9(nilIfEmpty) | | `RDEPUBCFIResolver.swift` | #4(结构假设) | | `RDEPUBCFIGenerator.swift` | #3(content path 硬编码)、#8(end offset 边界) | | `RDEPUBCFIMap.swift` | #10(线性扫描) | | `RDEPUBCFIDOMPathBuilder.swift` | #2(UTF-16/Character 偏移混用)、#5(HTML 正则解析)、#9(nilIfEmpty) | | `RDEPUBCFIRecoveryEngine.swift` | #2(calibratedOffset 使用 UTF-16 偏移)、#7(token 匹配) | | `RDEPUBCFITextAssertion.swift` | #9(nilIfEmpty) | | `RDEPUBCFICompatibility.swift` | 无直接问题 | | `RDEPUBCFIError.swift` | 无直接问题 | --- ## 修复优先级建议 ``` Phase 1(高风险,影响正确性) ├── #1 rawValue 短路 → 改为 computed property 或移除缓存 ├── #2 UTF-16/Character 偏移混用 → tokenSamples 用 Character 索引查 UTF-16 索引表,统一为 UTF-16 偏移 └── #3 content path 硬编码 → 从 CFIMap 查找或接受参数 Phase 2(中风险,影响健壮性) ├── #4 Resolver 假设 → 明确取固定索引步骤 ├── #5 HTML 正则 → 先清理注释/CDATA,或换用 DOM 解析器 ├── #6 text assertion 转义 → 实现反斜杠转义处理 ├── #7 token 匹配 → 加最小长度阈值和长度比约束 └── #8 end offset → 入口验证或支持反向 range Phase 3(低风险,代码质量) ├── #9 nilIfEmpty → 提取共享扩展 ├── #10 线性扫描 → 构建索引字典 └── #11 parent 静默失败 → 统一错误处理 ```