- 实现EPUB阅读器搜索功能及选中注释功能 - 优化CFI模块,修复代码审查发现的11个问题 - 实现大书远距目录跳转与后台补全优化方案 - 优化设置面板与章节运行时联动 - 重构及大量改进优化
151 lines
9.6 KiB
Markdown
151 lines
9.6 KiB
Markdown
---
|
||
title: 'CFI 模块 11 项问题修复'
|
||
type: 'bugfix'
|
||
created: '2026-06-18'
|
||
baseline_commit: '6ed3fcb'
|
||
status: 'done'
|
||
context:
|
||
- 'Doc/CFI_ISSUES_REVIEW.md'
|
||
---
|
||
|
||
<frozen-after-approval reason="人类拥有的意图——除非人类重新协商,否则不要修改">
|
||
|
||
## 意图
|
||
|
||
**问题:** CFI 模块存在 11 个已验证的代码问题,涵盖数据一致性风险(rawValue 短路)、偏移计算错误(UTF-16/Character 混用)、健壮性缺陷(正则解析 HTML、token 宽泛匹配、转义缺失)和代码质量问题(重复扩展、线性扫描、混合错误处理)。
|
||
|
||
**方法:** 按 Phase 1→2→3 顺序修复全部 11 个问题,保持 API 向后兼容。高优先级问题从根源修正数据模型,中优先级加固边界处理,低优先级提取共享代码和优化性能。
|
||
|
||
## 边界与约束
|
||
|
||
**始终:**
|
||
- 保持 `public` API 签名不变,仅修改内部实现和 `internal`/`private` 代码
|
||
- 所有偏移计算统一使用 UTF-16 基准(与 NSRange/NSString 一致)
|
||
- 修复必须保持现有正确行为的测试不回归
|
||
|
||
**先询问:**
|
||
- 是否为 #3(content path 硬编码)引入新的 `contentPath` 参数到 `makeOffsetCFI` 公开 API
|
||
- 是否为 #10(线性扫描)将 `markers` 从 `[RDEPUBCFIMarker]` 改为含索引字典的结构
|
||
|
||
**永不:**
|
||
- 不引入新的外部依赖(如 libxml2、DTCoreText)
|
||
- 不改变 CFI 字符串的序列化格式
|
||
- 不修改 `RDEPUBCFIMarker`、`RDEPUBCFITokenAnchor` 等 Codable 结构的字段名(保持序列化兼容)
|
||
|
||
</frozen-after-approval>
|
||
|
||
## 代码映射
|
||
|
||
- `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFI.swift` -- #1 rawValue 存储模型
|
||
- `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFISerializer.swift` -- #1 rawValue 短路序列化;#9 nilIfEmpty
|
||
- `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIGenerator.swift` -- #3 content path 硬编码;#8 end offset 边界
|
||
- `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift` -- #2 偏移混用;#5 HTML 正则;#9 nilIfEmpty
|
||
- `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift` -- #2 calibratedOffset;#7 token 匹配
|
||
- `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIResolver.swift` -- #4 结构假设
|
||
- `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIParser.swift` -- #6 text assertion 转义;#11 parent 静默失败;#9 nilIfEmpty
|
||
- `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift` -- #10 线性扫描
|
||
- `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIPath.swift` -- #9 nilIfEmpty
|
||
- `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFITextAssertion.swift` -- #9 nilIfEmpty
|
||
|
||
## 任务与验收
|
||
|
||
**Phase 1 — 高优先级(影响正确性):**
|
||
|
||
- [x] `RDEPUBCFI.swift` + `RDEPUBCFISerializer.swift` -- 将 `rawValue` 从存储属性改为 computed property(基于组件实时序列化),移除 Generator 中的 rawValue="" workaround -- #1 rawValue 短路导致序列化返回过期值
|
||
- [x] `RDEPUBCFIDOMPathBuilder.swift` -- 将 `tokenSamples()` 的 offset 从 Character 索引改为 UTF-16 索引,确保与 `normalizedToChapterOffsets` 数组下标对齐;同步修正 `calibratedOffset` 中 NSString 搜索与 Character 偏移的一致性 -- #2 UTF-16/Character 偏移混用导致 emoji/CJK 扩展字符定位错误
|
||
- [x] `RDEPUBCFIGenerator.swift` -- 为 `makeOffsetCFI` 添加可选 `contentPath` 参数(默认仍为 `/4/2`),`makeOffsetRangeCFI` 透传该参数 -- #3 content path 硬编码 `/4/2` 在非标准 DOM 结构下定位失败
|
||
|
||
**Phase 2 — 中优先级(影响健壮性):**
|
||
|
||
- [x] `RDEPUBCFIResolver.swift` -- 用 `steps.count >= 3 ? steps[2] : steps.last` 替换 `last(where:)` 查找 manifest 步骤;用 `steps.count >= 2 ? steps[1] : nil` 替换 `dropFirst().last`;将 `idAssertion?.isEmpty == false` 改为 `idAssertion?.nilIfEmpty != nil` -- #4 Resolver 假设不健壮
|
||
- [x] `RDEPUBCFIDOMPathBuilder.swift` -- 在正则匹配前先移除 HTML 注释 `<!--...-->` 和 CDATA `<![CDATA[...]]>` 内容;对属性值中的 `>` 增加 `hasSuffix("/>")` 对自闭合标签的精确判断 -- #5 HTML 正则脆弱
|
||
- [x] `RDEPUBCFIParser.swift` -- 在 `parseTextAssertion` 中实现反斜杠转义处理:用扫描替代 `firstIndex/lastIndex`,遇到 `\` 时跳过下一个字符 -- #6 text assertion 不处理转义
|
||
- [x] `RDEPUBCFIRecoveryEngine.swift` -- 将双向 `contains` 匹配改为前缀+后缀匹配,设置最小 token 长度阈值 ≥ 4,要求 token 与 exact 长度比在 0.3~3.0 范围内 -- #7 token 匹配过于宽泛
|
||
- [x] `RDEPUBCFIGenerator.swift` -- 在 `makeOffsetRangeCFI` 入口添加 `precondition(startOffset <= endOffset)` 或交换两者确保 start ≤ end -- #8 反向 range 退化为点
|
||
|
||
**Phase 3 — 低优先级(代码质量):**
|
||
|
||
- [x] 新建 `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIUtilities.swift` -- 提取共享 `internal extension String { var nilIfEmpty }` -- #9 四处重复的 private nilIfEmpty
|
||
- [x] `RDEPUBCFIMap.swift` -- 添加 `lazy var markerByPath: [RDEPUBCFIPath: Int]` 索引字典,将 `marker(matching:)` 查找改为 O(1) -- #10 线性扫描性能瓶颈
|
||
- [x] `RDEPUBCFIParser.swift` -- 将 `parent: try? parse(parentRaw)` 改为 `parent: try parse(parentRaw)`,统一抛出错误;或三者均改为 `try?` 并在 `RDEPUBCFIRange` 初始化中加注释说明容错策略 -- #11 parent 静默失败混合错误处理
|
||
|
||
**验收标准:**
|
||
- 含 emoji(如 😀)的文本在 CFI 恢复时定位偏移正确
|
||
- 修改 rawValue 后序列化返回新值而非旧值
|
||
- `makeOffsetCFI` 接受自定义 contentPath 时能正确定位嵌套文本节点
|
||
- `parseTextAssertion` 正确解析含 `\[` `\]` 转义的 CFI
|
||
- `makeOffsetRangeCFI(startOffset: 10, endOffset: 5)` 不会静默产生退化 range
|
||
- `marker(matching:)` 在 500+ markers 场景下性能优于 O(n)
|
||
|
||
## 规范变更日志
|
||
|
||
## 设计说明
|
||
|
||
**#1 rawValue 改为 computed property:** 当前 `RDEPUBCFI` 同时存储 `rawValue` 和组件,序列化时优先返回 rawValue 导致数据不一致。改为 computed property 后,`rawValue` 始终由 `RDEPUBCFISerializer.serialize(self)` 计算得出,消除缓存过期风险。由于 `RDEPUBCFI` 是 struct 且 `rawValue` 原本就参与 `Codable`,需同步调整 `Codable` 实现。
|
||
|
||
**#2 偏移统一:** `tokenSamples()` 当前用 `Array(normalizedText)` 生成 Character 索引,但 `normalizedToChapterOffsets` 数组按 UTF-16 code unit 索引构建。修正方式:tokenSamples 改为基于 UTF-16 偏移生成 token,使用 `nsSource.length` 迭代而非 Swift Character 迭代。
|
||
|
||
**#5 HTML 预处理:** 不引入外部 HTML 解析器,而是在正则匹配前用简单的字符串操作移除注释和 CDATA:`<!--...-->` 和 `<![CDATA[...]]>`。这是最小侵入的修复,覆盖最常见的边界情况。
|
||
|
||
## 验证
|
||
|
||
**命令:**
|
||
- `swift build` -- 预期:编译成功,无错误无警告
|
||
- `swift test` -- 预期:所有现有测试通过(如有 CFI 相关测试,需覆盖 emoji 和转义场景)
|
||
|
||
**手动检查:**
|
||
- 确认 `RDEPUBCFIUtilities.swift` 中只有一个 `internal` nilIfEmpty 扩展
|
||
- 确认 `RDEPUBCFI.rawValue` 在 struct 定义中为 computed property 而非 stored property
|
||
- 确认 `tokenSamples` 返回的 offset 类型与 `normalizedToChapterOffsets` 索引一致(均为 UTF-16 基准)
|
||
|
||
## Suggested Review Order
|
||
|
||
**数据模型核心(rawValue → computed property)**
|
||
|
||
- rawValue 改为 computed property,移除短路逻辑,Codable 自定义实现
|
||
[`RDEPUBCFI.swift:11`](../../Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFI.swift#L11)
|
||
|
||
- rawValue 改为 computed property,Codable 自定义实现,init(rawValue:) 文档说明
|
||
[`RDEPUBCFIRange.swift:9`](../../Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIRange.swift#L9)
|
||
|
||
- 序列化移除 rawValue 短路缓存,直接从组件构建
|
||
[`RDEPUBCFISerializer.swift:5`](../../Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFISerializer.swift#L5)
|
||
|
||
- Generator 移除 rawValue="" workaround,添加 contentPath 参数,precondition 校验
|
||
[`RDEPUBCFIGenerator.swift:5`](../../Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIGenerator.swift#L5)
|
||
|
||
**偏移计算统一(UTF-16/Character 修复)**
|
||
|
||
- tokenSamples 改用 NSString UTF-16 索引替代 Character 索引
|
||
[`RDEPUBCFIDOMPathBuilder.swift:435`](../../Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift#L435)
|
||
|
||
- textMarker 改用 NSRange 搜索,searchLocation 从 String.Index 改为 Int
|
||
[`RDEPUBCFIDOMPathBuilder.swift:212`](../../Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift#L212)
|
||
|
||
- HTML 预处理:stripCommentsAndCDATA 方法
|
||
[`RDEPUBCFIDOMPathBuilder.swift:81`](../../Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift#L81)
|
||
|
||
**解析与健壮性修复**
|
||
|
||
- Resolver 明确取固定索引步骤,改善可读性
|
||
[`RDEPUBCFIResolver.swift:23`](../../Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIResolver.swift#L23)
|
||
|
||
- text assertion 转义处理:firstUnescapedIndex + 括号深度 + unescapeCFIText
|
||
[`RDEPUBCFIParser.swift:131`](../../Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIParser.swift#L131)
|
||
|
||
- parent 统一为 try 抛出错误
|
||
[`RDEPUBCFIParser.swift:50`](../../Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIParser.swift#L50)
|
||
|
||
- token 匹配改为前缀+后缀匹配,最小长度阈值 2,长度比 0.3~3.0
|
||
[`RDEPUBCFIRecoveryEngine.swift:136`](../../Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift#L136)
|
||
|
||
**代码质量与性能**
|
||
|
||
- 共享 internal nilIfEmpty 扩展
|
||
[`RDEPUBCFIUtilities.swift:1`](../../Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIUtilities.swift#L1)
|
||
|
||
- RDEPUBCFIMap 索引字典 + 自定义 Equatable/Codable
|
||
[`RDEPUBCFIMap.swift:11`](../../Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift#L11)
|
||
|
||
- 外部调用方移除 rawValue workaround
|
||
[`RDEPUBTextIndexTable.swift:211`](../../Sources/RDReaderView/EPUBTextRendering/RDEPUBTextIndexTable.swift#L211) |