# 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: #"?\s*([A-Za-z][A-Za-z0-9:_-]*)([^>]*)>"#, options: [.caseInsensitive] ) else { return [:] } ``` **边界情况清单:** | 输入 | 后果 | |------|------| | `` | 正则匹配注释内的 `