ReadViewSDK/Doc/CFI_ISSUES_REVIEW.md
shenlei c65c190b71 feat: EPUB阅读器搜索、注释、CFI模块及大书远距跳转优化
- 实现EPUB阅读器搜索功能及选中注释功能
- 优化CFI模块,修复代码审查发现的11个问题
- 实现大书远距目录跳转与后台补全优化方案
- 优化设置面板与章节运行时联动
- 重构及大量改进优化
2026-06-22 20:26:34 +08:00

315 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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), // <body> 的第 2 个元素子节点
RDEPUBCFIStep(index: 2, idAssertion: fragmentID) // 第 1 个文本节点
])
```
**问题:** `makeOffsetCFI` 硬编码 content path 为 `/4/2`,即假设目标文本在 `<body>` 的第 2 个元素子节点的第 1 个文本子节点中。以下情况会出错:
| 场景 | 预期 content path | 实际生成 |
|------|-------------------|----------|
| 文本直接在 `<body>` 下 | `/4/N`N 为文本节点奇数索引) | `/4/2` ❌ |
| 文本在 3 层嵌套中 | `/4/2/2/2` | `/4/2` ❌ |
| `<body>` 前有注释节点 | 索引需要偏移 | `/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 [:] }
```
**边界情况清单:**
| 输入 | 后果 |
|------|------|
| `<!-- <div> -->` | 正则匹配注释内的 `<div>`,导致错误的子节点计数 |
| `<![CDATA[ <tag> ]]>` | 同上 |
| `<div data-value="a>b">` | 属性值中的 `>` 导致正则截断 |
| `<input checked>` | 无值属性解析正常,但 `idAttribute` 正则要求引号 |
| `<script>if (a < b) {}</script>` | `< b` 可能被匹配为标签 |
| `<br/>` vs `<br />` | `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)..<close])
// ...
}
```
**问题:** EPUB CFI 规范中text assertion 内的 `[``]` 可以用反斜杠转义(`\[` 和 `\]`)。当前实现用 `firstIndex(of: "[")` 和 `lastIndex(of: "]")` 找括号,不处理转义。
**示例:** CFI `epubcfi(/6/4[chap01]!/4/2/1:3[前缀,文\[本\],后缀])` 中,精确文本是 `文[本]`,但当前实现会错误地在 `\]` 处截断。
---
### 7. Recovery Engine 的 token 匹配过于宽泛
**文件:** `RDEPUBCFIRecoveryEngine.swift:136-139`
```swift
let candidateAnchors = cfiMap.recoveryMetadata.tokenIndex.filter { anchor in
exact.contains(anchor.token) || anchor.token.contains(exact)
}
```
**问题:** 双向 `contains` 匹配会导致大量误匹配:
- `exact = "的"` 会匹配所有包含 "的" 的 token中文中极其常见
- `anchor.token = "这是一个很长的句子"` 如果 `exact = "是"`,也会匹配
- 英文中 `exact = "the"` 同样会匹配大量 token
**建议:**
1. 设置最小 token 长度阈值(如 ≥ 4 字符)
2. 改为前缀/后缀匹配而非任意包含
3. 要求 token 与 exact 的长度比在合理范围内(如 0.5 ~ 2.0
---
### 8. `makeOffsetRangeCFI` 的 end offset 边界
**文件:** `RDEPUBCFIGenerator.swift:78-93`
```swift
let start = makeOffsetCFI(
href: href, fileIndex: fileIndex,
chapterOffset: startOffset, // startOffset 可能为负
sideBias: .before, ...
)
let end = makeOffsetCFI(
href: href, fileIndex: fileIndex,
chapterOffset: max(startOffset, endOffset), // 基于原始 startOffset
sideBias: .after, ...
)
```
**问题:** 如果 `startOffset` 为负数:
1. `start``chapterOffset``max(chapterOffset, 0)` clamp 到 0
2. `end``chapterOffset``max(startOffset, endOffset)`,如果 `endOffset` 也为负,结果为负数,再被 clamp 到 0
3. 最终 start 和 end 都是 0range 退化为点
但如果 `startOffset = -5``endOffset = 10`
- `start.chapterOffset` = `max(-5, 0)` = 0
- `end.chapterOffset` = `max(-5, 10)` = 10
- 这个结果是正确的
然而如果 `startOffset = 10``endOffset = 5`(反向 range
- `start.chapterOffset` = 10
- `end.chapterOffset` = `max(10, 5)` = 10
- range 退化为点,丢失了反向信息
**建议:** 在入口处验证 `startOffset <= endOffset`,或支持反向 range。
---
## 低优先级
### 9. 重复的 `nilIfEmpty` 扩展
**文件:** 4 个文件各自定义了 `private extension String { var nilIfEmpty }`
| 文件 | 行号 |
|------|------|
| `RDEPUBCFIPath.swift` | 41-46 |
| `RDEPUBCFIParser.swift` | 172-177 |
| `RDEPUBCFIDOMPathBuilder.swift` | 90-95 |
| `RDEPUBCFITextAssertion.swift` | 18-24 |
**建议:** 提取为一个共享的 `internal` 扩展,放在单独文件或 `RDEPUBCFI.swift` 中。
---
### 10. `RDEPUBCFIMap.marker(matching:)` 线性扫描
**文件:** `RDEPUBCFIMap.swift:30-32`
```swift
public func marker(matching path: RDEPUBCFIPath) -> 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` | #1rawValue 存储) |
| `RDEPUBCFIParser.swift` | #6text assertion 转义)、#11parent 静默失败)、#9nilIfEmpty |
| `RDEPUBCFISerializer.swift` | #1rawValue 短路) |
| `RDEPUBCFIPath.swift` | #9nilIfEmpty |
| `RDEPUBCFIResolver.swift` | #4(结构假设) |
| `RDEPUBCFIGenerator.swift` | #3content path 硬编码)、#8end offset 边界) |
| `RDEPUBCFIMap.swift` | #10(线性扫描) |
| `RDEPUBCFIDOMPathBuilder.swift` | #2UTF-16/Character 偏移混用)、#5HTML 正则解析)、#9nilIfEmpty |
| `RDEPUBCFIRecoveryEngine.swift` | #2calibratedOffset 使用 UTF-16 偏移)、#7token 匹配) |
| `RDEPUBCFITextAssertion.swift` | #9nilIfEmpty |
| `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 静默失败 → 统一错误处理
```