- 实现EPUB阅读器搜索功能及选中注释功能 - 优化CFI模块,修复代码审查发现的11个问题 - 实现大书远距目录跳转与后台补全优化方案 - 优化设置面板与章节运行时联动 - 重构及大量改进优化
315 lines
13 KiB
Markdown
315 lines
13 KiB
Markdown
# 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 都是 0,range 退化为点
|
||
|
||
但如果 `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` | #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 静默失败 → 统一错误处理
|
||
```
|