feat: EPUB阅读器搜索、注释、CFI模块及大书远距跳转优化
- 实现EPUB阅读器搜索功能及选中注释功能 - 优化CFI模块,修复代码审查发现的11个问题 - 实现大书远距目录跳转与后台补全优化方案 - 优化设置面板与章节运行时联动 - 重构及大量改进优化
This commit is contained in:
@@ -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. 阶段 1:HTML 规范化(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. 阶段 3:CFI 标记注入(RDEPUBCFIMarkerInjector)
|
||||
|
||||
**文件**:`RDEPUBCFIMarkerInjector.swift`
|
||||
|
||||
注入 CFI 路径标记,用于后续 CFI 映射构建。在每个文本节点前注入 CFI 路径信息,使渲染后的 NSAttributedString 能够建立 CFI 路径到文本偏移量的映射。
|
||||
|
||||
---
|
||||
|
||||
## 6. 阶段 4:CSS 层合成(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. 阶段 6:Fragment 标记注入(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)
|
||||
Reference in New Issue
Block a user