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

151 lines
9.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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 一致)
- 修复必须保持现有正确行为的测试不回归
**先询问:**
- 是否为 #3content 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 propertyCodable 自定义实现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)