- 实现EPUB阅读器搜索功能及选中注释功能 - 优化CFI模块,修复代码审查发现的11个问题 - 实现大书远距目录跳转与后台补全优化方案 - 优化设置面板与章节运行时联动 - 重构及大量改进优化
13 KiB
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
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 内部同时维护了两种偏移体系,但没有统一:
-
normalizedToChapterOffsets数组(init中构建):通过遍历nsSource.length(UTF-16 code unit 数)逐个构建,每个 UTF-16 code unit 对应一个数组条目。因此数组下标是 UTF-16 索引。 -
tokenSamples()返回的 offset:使用Array(normalizedText)生成 token,Array按 Swift Character 拆分,返回的 offset 是characters数组的下标——即 Character 索引。 -
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
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
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
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
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
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
建议:
- 设置最小 token 长度阈值(如 ≥ 4 字符)
- 改为前缀/后缀匹配而非任意包含
- 要求 token 与 exact 的长度比在合理范围内(如 0.5 ~ 2.0)
8. makeOffsetRangeCFI 的 end offset 边界
文件: RDEPUBCFIGenerator.swift:78-93
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 为负数:
start的chapterOffset被max(chapterOffset, 0)clamp 到 0end的chapterOffset是max(startOffset, endOffset),如果endOffset也为负,结果为负数,再被 clamp 到 0- 最终 start 和 end 都是 0,range 退化为点
但如果 startOffset = -5,endOffset = 10:
start.chapterOffset=max(-5, 0)= 0end.chapterOffset=max(-5, 10)= 10- 这个结果是正确的
然而如果 startOffset = 10,endOffset = 5(反向 range):
start.chapterOffset= 10end.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
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
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 静默失败 → 统一错误处理