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

13 KiB
Raw Permalink Blame History

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-933-36

public static func serialize(_ cfi: RDEPUBCFI) -> String {
    if !cfi.rawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
        return cfi.rawValue  // 直接返回原始值,不检查组件是否变化
    }
    // ... 从组件重新构建
}

问题: RDEPUBCFI 同时存储了 rawValue(原始字符串)和解析后的组件(packagePathcontentPathcharacterOffset 等)。如果有人修改了组件但没有清空 rawValue,序列化会返回过期的旧值

RDEPUBCFIGenerator 中已经出现了这个 workaround — 先创建 rawValue: "" 的 CFI序列化后再创建一个新 CFI 填入 rawValue。这说明开发者意识到了问题,但没有从根源修复。

建议: 要么移除 rawValue 缓存,每次都从组件重新构建;要么让 rawValue 成为 computed property,在 packagePath/contentPath 等变化时自动失效。


2. UTF-16 vs Character Offset 混用

文件: RDEPUBCFIDOMPathBuilder.swift:435-458RDEPUBNormalizedTextIndex.tokenSamples)与 RDEPUBCFIDOMPathBuilder.swift:405-428RDEPUBNormalizedTextIndex.init

问题: RDEPUBNormalizedTextIndex 内部同时维护了两种偏移体系,但没有统一:

  1. normalizedToChapterOffsets 数组init 中构建):通过遍历 nsSource.lengthUTF-16 code unit 数)逐个构建,每个 UTF-16 code unit 对应一个数组条目。因此数组下标是 UTF-16 索引

  2. tokenSamples() 返回的 offset:使用 Array(normalizedText) 生成 tokenArray 按 Swift Character 拆分,返回的 offset 是 characters 数组的下标——即 Character 索引

  3. chapterOffset(forNormalizedOffset:):用传入的 offset 直接查表 normalizedToChapterOffsets[offset],期望接收 UTF-16 索引。

normalizedText 含多 code unit 字符时,normalizedText.countCharacter 数)< nsSource.lengthUTF-16 code unit 数),导致 tokenSamples 返回的 Character 索引在查 UTF-16 索引表时越界或错位。

此外,calibratedOffsetRDEPUBCFIRecoveryEngine.swift:240-279)全程使用 NSString/NSRangeUTF-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/NN 为文本节点奇数索引) /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 结构固定:/6package/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("/>") 可能在属性值以 / 结尾时误判

建议: 考虑使用 DTCoreTextlibxml2 进行真正的 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

建议:

  1. 设置最小 token 长度阈值(如 ≥ 4 字符)
  2. 改为前缀/后缀匹配而非任意包含
  3. 要求 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 为负数:

  1. startchapterOffsetmax(chapterOffset, 0) clamp 到 0
  2. endchapterOffsetmax(startOffset, endOffset),如果 endOffset 也为负,结果为负数,再被 clamp 到 0
  3. 最终 start 和 end 都是 0range 退化为点

但如果 startOffset = -5endOffset = 10

  • start.chapterOffset = max(-5, 0) = 0
  • end.chapterOffset = max(-5, 10) = 10
  • 这个结果是正确的

然而如果 startOffset = 10endOffset = 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

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)
)

问题: parenttry? 静默忽略解析失败,但 startendtry 抛出。如果 parent 解析失败,parentnil,后续 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 静默失败 → 统一错误处理