feat: EPUB阅读器搜索、注释、CFI模块及大书远距跳转优化

- 实现EPUB阅读器搜索功能及选中注释功能
- 优化CFI模块,修复代码审查发现的11个问题
- 实现大书远距目录跳转与后台补全优化方案
- 优化设置面板与章节运行时联动
- 重构及大量改进优化
This commit is contained in:
shenlei
2026-06-22 20:26:34 +08:00
parent f50495ad91
commit c65c190b71
178 changed files with 11380 additions and 6728 deletions
+314
View File
@@ -0,0 +1,314 @@
# 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 静默失败 → 统一错误处理
```