feat: EPUB阅读器搜索、注释、CFI模块及大书远距跳转优化

- 实现EPUB阅读器搜索功能及选中注释功能
- 优化CFI模块,修复代码审查发现的11个问题
- 实现大书远距目录跳转与后台补全优化方案
- 优化设置面板与章节运行时联动
- 重构及大量改进优化
This commit is contained in:
shenlei
2026-06-22 20:26:34 +08:00
parent f50495ad91
commit c65c190b71
178 changed files with 11380 additions and 6728 deletions
+349
View File
@@ -0,0 +1,349 @@
# 排版管线详解
> 最后更新:2026-06-18
本文档详细描述 ReadViewSDK 文本排版管线(Typesetter Pipeline)的 8 个处理阶段、核心算法和数据流。
---
## 1. 概述
排版管线是 EPUBTextRendering 层的核心组件,负责将 EPUB 原始 HTML 转换为可分页的 NSAttributedString。它是 textReflowable 渲染路径(本 SDK 核心路径)的关键环节。
**入口**`RDEPUBTextTypesetterPipeline.makeRequest(from:)`
**输入**`RDEPUBTypesettingInput`(原始 HTML、样式、布局配置)
**输出**`RDEPUBTypesettingOutput`(渲染请求、诊断信息、兼容性报告)
**关键文件**`Sources/RDReaderView/EPUBTextRendering/Typesetter/`
---
## 2. 管线总览
```
Raw HTML (从 EPUB 解压目录读取)
▼ [阶段 1] RDEPUBHTMLNormalizer
│ 去 CR、合并空行、规范化附件 HTML
▼ [阶段 2] RDEPUBSemanticMarkerInjector
│ 注入分页语义标记 ${rd-sem-start/end}
▼ [阶段 3] RDEPUBCFIMarkerInjector
│ 注入 CFI 路径标记
▼ [阶段 4] RDEPUBStyleSheetComposer
│ 内联 <link stylesheet>、构建 5 层 CSS、注入 <base href>
▼ [阶段 5] RDEPUBFontNormalizer
│ 解析 @font-face、注册嵌入字体
▼ [阶段 6] RDEPUBFragmentMarkerInjector
│ 注入 fragment 锚点标记 ${id=xxx}
▼ [阶段 7] RDEPUBRenderDiagnosticsCollector
│ 收集图片诊断、资源引用检查
▼ 构建 RDEPUBTextChapterRenderRequest
▼ [阶段 8] RDEPUBDTCoreTextRenderer
HTML → NSAttributedString → 后处理 → 分页
```
---
## 3. 阶段 1HTML 规范化(RDEPUBHTMLNormalizer
**文件**`RDEPUBHTMLNormalizer.swift`
### 3.1 处理内容
| 规则 | 说明 |
|------|------|
| CR → LF | 统一换行符 |
| 合并连续空行 | 多个空行合并为一个 |
| 删除分页标记 | `<hr lang="zh-CN">分页符</hr>` |
| 附件 HTML 规范化 | 见下表 |
### 3.2 附件 HTML 规范化规则
| 原始 HTML | 规范化结果 |
|-----------|-----------|
| `div.qrbodyPic / div.bodyPic` | 合并样式到 `<img>` |
| `img.qqreader-footnote` | 行内 1em×1em |
| `h1.frontCover > img` | 封面图片 100% 宽度 |
### 3.3 Base URL 注入
`<head>` 开头注入 `<base href="...">` 用于相对路径解析:
```swift
static func injectBaseHref(into html: String, baseURL: URL?) -> String
```
---
## 4. 阶段 2:语义标记注入(RDEPUBSemanticMarkerInjector
**文件**`RDEPUBSemanticMarkerInjector.swift`
### 4.1 标记语法
```html
${rd-sem-start:id=X;block=...;hints=...;placement=...}
...内容...
${rd-sem-end:id=X}
```
### 4.2 注入规则
- 遍历所有 HTML 标签,维护开标签栈
- 为有分页语义的标签注入标记:
- `block`:块类型(paragraph、heading、list 等)
- `hints`:分页提示(avoidPageBreakInside、keepWithNext 等)
- `placement`:附件位置(inline、block
- 空标签(img, br, hr)同时注入开始和结束标记
### 4.3 语义提示类型
| 提示 | 说明 |
|------|------|
| `avoidPageBreakInside` | 避免在块内分页 |
| `keepWithNext` | 与下一个块保持同页 |
| `attachmentBlock` | 块级附件(图片等) |
---
## 5. 阶段 3CFI 标记注入(RDEPUBCFIMarkerInjector
**文件**`RDEPUBCFIMarkerInjector.swift`
注入 CFI 路径标记,用于后续 CFI 映射构建。在每个文本节点前注入 CFI 路径信息,使渲染后的 NSAttributedString 能够建立 CFI 路径到文本偏移量的映射。
---
## 6. 阶段 4CSS 层合成(RDEPUBStyleSheetComposer
**文件**`RDEPUBStyleSheetComposer.swift`
### 6.1 五层 CSS 架构
按优先级从低到高:
| 层 | Kind | 说明 | 注入位置 |
|----|------|------|----------|
| 1 | `.default` | 基础阅读器样式(隐藏 head/title/style,默认字体大小) | `<head>` 开头 |
| 2 | `.replace` | 格式化样式(代码块、标题、引用、列表) | `<head>` 开头 |
| 3 | `.dark` | 暗色模式覆盖(仅暗色主题时注入) | `<head>` 开头 |
| 4 | `.epub` | EPUB 自带样式(内联后的 `<link stylesheet>` | `<head>` 末尾 |
| 5 | `.user` | 用户设置(字号、行距、颜色,!important) | `<head>` 末尾 |
### 6.2 语言检测
```swift
static func prefersLatinLanguageCSS(
languageCode: String?,
sourceHTML: String
) -> Bool
```
检测逻辑:
1. 检查 `lang` 属性(如 `lang="en"`
2. 对 HTML 文本采样,统计拉丁字符比例
3. 拉丁语言使用专用 CSS`wxread-replace-latin.css`
### 6.3 样式表内联
`RDEPUBRenderDiagnosticsCollector.inlineLinkedStyleSheets()` 负责:
1. 查找 `<link rel=stylesheet href=...>`
2. 读取 CSS 文件内容
3. 重写 CSS 中的相对 `url()` 引用
4. 内联到 HTML 的 `<head>`
---
## 7. 阶段 5:字体注册(RDEPUBFontNormalizer
**文件**`RDEPUBFontNormalizer.swift`
### 7.1 处理流程
1. 解析 `@font-face { url(...) }`
2. 提取字体文件路径和 family 名称
3. 通过 `CTFontManagerRegisterFontsForURL(.process)` 注册嵌入字体
4. 已注册字体路径缓存在 `registeredFontPaths` 集合中,避免重复注册
### 7.2 注册结果
```swift
struct RDEPUBFontRegistrationResult {
let descriptor: RDEPUBFontDescriptor
let didRegister: Bool
let errorDescription: String?
}
```
注册失败的字体不会阻断管线,但会记录到兼容性报告中。
---
## 8. 阶段 6Fragment 标记注入(RDEPUBFragmentMarkerInjector
**文件**`RDEPUBFragmentMarkerInjector.swift`
扫描 HTML 中的 `id` 属性,在其前面注入 `${id=xxx}` 标记:
```html
<h2 id="section1">标题</h2>
${id=section1}<h2 id="section1">标题</h2>
```
渲染后这些标记会被转换为 NSAttributedString 属性,用于 fragment 偏移量提取。
---
## 9. 阶段 7:诊断收集(RDEPUBRenderDiagnosticsCollector
**文件**`RDEPUBRenderDiagnosticsCollector.swift`
### 9.1 图片诊断
- 扫描 `<img src="...">` 标签
- 解析引用路径,检查文件是否存在
- 收集诊断信息用于调试
### 9.2 资源引用检查
- 检查 CSS 中的 `url()` 引用
- 验证字体文件是否存在
- 记录缺失资源的诊断信息
---
## 10. 阶段 8:渲染与后处理(RDEPUBDTCoreTextRenderer
**文件**`RDEPUBDTCoreTextRenderer.swift`
### 10.1 HTML → NSAttributedString
```swift
func renderChapter(request: RDEPUBTextChapterRenderRequest) throws -> RDEPUBRenderedChapterContent
```
1. 将 HTML 编码为 `Data`
2. 使用 `DTHTMLAttributedStringBuilder` 构建 `NSAttributedString`
3.`willFlushCallback` 中对每个 DOM 元素调用 `RDEPUBAttachmentNormalizer.prepareHTMLElementForReaderRendering()`
### 10.2 后处理
**applyPaginationSemantics()**
-`${rd-sem-start/end}` 标记转为 NSAttributedString 属性
- 属性键:`.rdPageSemanticHints``.rdPageBlockKind``.rdPageAttachmentPlacement`
**extractFragmentOffsets()**
- 提取 `${id=xxx}` 标记
- 生成 fragment ID → 字符偏移量映射
- 删除标记文本
**normalizeReadingAttributes()**
- 规范化字体(应用用户选择的字体)
- 调整行距(应用 lineHeightMultiple
- 设置文字颜色(应用主题颜色)
- 处理附件(图片缩放、对齐)
---
## 11. 分页计算
### 11.1 RDEPUBChapterPageCounter
**文件**`Pagination/RDEPUBChapterPageCounter.swift`
使用 CoreText 迭代分页:
```swift
func layoutFrames(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame]
```
**分页循环**
```
location = 0
while location < totalLength:
1. 创建 CTFrame(通过 CTFramesetter
2. 获取可见范围 (CTFrameGetVisibleStringRange)
3. 应用 avoidPageBreakInside 规则(最多移除 3 行尾部)
4. 应用 keepWithNext 规则(最多移除 3 行尾部)
5. 应用 widow/orphan 控制
6. 应用 pageBreakPolicy 调整
7. 记录页面范围
8. location = 调整后的范围末尾
```
### 11.2 RDEPUBPageBreakPolicy
**文件**`Pagination/RDEPUBPageBreakPolicy.swift`
分页规则优先级:
| 规则 | 说明 | 最大调整行数 |
|------|------|-------------|
| `avoidPageBreakInside` | 块内不分页(标题、图片等) | 3 行 |
| `keepWithNext` | 标题与正文不分离 | 3 行 |
| widow control | 段落最后一行不留到下一页 | 1 行 |
| orphan control | 段落第一行不单独在上一页 | 1 行 |
| attachment boundary | 块级图片前后分页 | 0(精确切分) |
**判断方法**
```swift
func lineIsInAvoidPageBreakInsideBlock(_ lineRange: NSRange) -> Bool
func lineIsInKeepWithNextBlock(_ lineRange: NSRange) -> Bool
func adjustedRange(from:totalLength:lineRanges:factory:) -> (range, breakReason, ...)
```
### 11.3 RDEPUBChapterTailNormalizer
**文件**`BuildPipeline/RDEPUBChapterTailNormalizer.swift`
三遍处理:
1. **删除空白中间帧**:无可见字符且无附件的帧
2. **删除空白尾部帧**:从末尾开始删除同类空白帧
3. **合并短尾帧**:如果最后一帧 ≤2 个可见字符且前一帧 ≥8 倍长,合并
---
## 12. 诊断与调试
### 12.1 兼容性报告
```swift
struct RDEPUBCSSCompatibilityReport {
let unsupportedRules: [String] // CSS
let normalizedRules: [String] //
let fontFailures: [String] //
}
```
### 12.2 资源诊断
```swift
struct RDEPUBTextResourceReferenceDiagnostic {
let href: String //
let exists: Bool //
let type: String // image/font/stylesheet
}
```
### 12.3 语义摘要
`RDEPUBReaderController.nativeTextSemanticSummary()` 返回当前页面的语义摘要,包含:
- 页码
- 分页原因(breakReason
- 块类型(blockKinds
- 语义提示(semanticHints
- 附件位置(attachmentPlacements