ReadViewSDK/Doc/WXRead/analysis/EPUB渲染管线详解.md

583 lines
14 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.

# EPUB 渲染管线详解
读书 (WeRead) 的 EPUB 渲染管线将原始 XHTML 文件转换为可分页、可交互的阅读视图。本文档完整描述从 EPUB 文件到屏幕像素的每一步。
---
## 1. 管线总览
```
EPUB 文件 (.epub)
|
v
[WREpubParser] 解析 EPUB 结构
|
v
[WREpubTypesetter] XHTML → NSAttributedString
| (CSS 级联 + HTML 解析 + 后处理)
v
[WRCoreTextLayouter] NSAttributedString → 分页布局
| (CTTypesetter + 分页算法)
v
[WRCoreTextLayoutFrame] 单页布局帧
| (CTFrame + 行提取 + 避免断页)
v
[WRPageView] 渲染到屏幕
| (CGContext 绘制 + 图片 + 装饰)
v
屏幕像素
```
---
## 2. 阶段一: EPUB 解析 (WREpubParser)
### 2.1 输入
EPUB 文件路径 + WRBook 模型对象
### 2.2 解析步骤
```
1. parseContainerXML
- 读取 META-INF/container.xml
- 提取 OPF 文件路径 (rootfile full-path)
2. parseOPFAtRelativePath
- 解析 content.opf
- 提取 manifest (id → href 映射)
- 提取 spine (阅读顺序 idref 列表)
- 提取 metadata (书名、标识符等)
3. parseNCX
- 解析 toc.ncx (目录)
- 提取 navPoint 树 (id, label, src, playOrder)
4. _buildChapterList
- 将 spine idref 映射到 manifest href
- 生成 chapters 数组 [{id, href, mediaType, fullPath}]
5. _buildResourceMap
- 构建资源路径映射 (href → 绝对路径)
- 同时索引文件名用于快速查找
```
### 2.3 输出
- chapters 数组(有序章节列表)
- resourceMap 字典(资源路径映射)
- WRBook 元数据更新
---
## 3. 阶段二: CSS 级联 (WREpubTypesetter)
### 3.1 CSS 加载顺序
```
Layer 1: default.css (App Bundle)
↓ 覆盖
Layer 2: replace.css (App Bundle)
↓ 覆盖
Layer 3: dark.css (App Bundle, 仅暗色模式)
↓ 覆盖
Layer 4: EPUB 嵌入 CSS (书籍自带)
↓ 覆盖
Layer 5: 用户设置 CSS (运行时生成)
```
### 3.2 各层内容
**Layer 1 - default.css**:
```css
body { font-family: "PingFang SC", sans-serif; margin: 0; padding: 10px 15px; }
p { margin-top: 0.5em; margin-bottom: 0.5em; }
h1 { font-size: 1.8em; font-weight: bold; }
h2 { font-size: 1.5em; font-weight: bold; }
h3 { font-size: 1.3em; font-weight: bold; }
ul, ol { padding-left: 1.5em; }
blockquote { margin-left: 1em; font-style: italic; }
```
**Layer 2 - replace.css**:
```css
h1, h2, h3 { font-family: "Source Han Serif CN", serif; }
pre, code { font-family: "Menlo", monospace; }
img.bodyPic { wr-vertical-center-style: 2; max-width: 100%; }
.conQuot { /* 引用块样式 */ }
```
**Layer 3 - dark.css**:
```css
body { background-color: #1a1a1a; color: #cccccc; }
a { color: #6eaad7; }
img { filter: brightness(0.85); }
```
**Layer 5 - 用户设置 CSS** (动态生成):
```css
body { font-size: 18px; line-height: 1.8; font-family: "PingFang SC", sans-serif; }
body { background-color: #f5f0e8; } /* 护眼模式 */
p { text-indent: 2em; } /* 首行缩进 */
```
### 3.3 级联合并
使用 DTCSSStylesheet.mergeStylesheet: 方法按顺序合并,后加载的覆盖先前的同名规则。
---
## 4. 阶段三: HTML 解析 (DTHTMLAttributedStringBuilder)
### 4.1 SAX 解析流程
```
XHTML 数据
|
v
DTHTMLParser (SAX)
|
├── didStartElement: → 创建 DTHTMLElement 节点
| 应用 CSS 样式
| 处理自定义属性
|
├── foundCharacters: → 累积文本到当前元素
|
├── foundCDATA: → 处理 CDATA 内容
|
└── didEndElement: → 弹出元素栈
调用 interpretAttributes
插入分页标记
```
### 4.2 元素处理
每个 HTML 标签被转换为 DTHTMLElement 节点:
```
DTHTMLElement
├── tagName: "p" / "div" / "img" / ...
├── classNames: ["bodyPic", "conQuot"]
├── fontDescriptor: 字体描述符
├── paragraphStyle: 段落样式
├── textColor / backgroundColor
├── children: [DTHTMLElement]
├── textAttachment (图片)
├── linkURL (链接)
├── [WeRead 扩展]
│ ├── verticalCenterStyle
│ ├── pageRelate
│ ├── shouldAvoidPageBreakInside
│ ├── pageBreakAfter / pageBreakBefore
│ ├── pageBackgroundColor
│ └── pageBackgroundImage
```
### 4.3 样式应用顺序
```
1. CSS 样式表规则 (class, id, tag 选择器)
2. 内联 style="" 属性
3. WeRead 自定义 CSS 属性
4. 元素默认样式 (基于标签名)
```
### 4.4 后处理 (_WRPostProcessElementTree)
在元素树转为 NSAttributedString 之前,执行 WeRead 特有的后处理:
**图片处理**:
- 设置最大显示尺寸 (1080x1920)
- 超限图片按比例缩放
- 添加 .bodyPic CSS 类
- 设置 wr-vertical-center-style: 2
**链接处理**:
- 添加下划线样式
- 存储链接 URL 到自定义属性
**自定义属性处理**:
- wr-vertical-center-style → DTHTMLVerticalCenterAttribute
- weread-page-relate → DTPageRelateAttribute
---
## 5. 阶段四: NSAttributedString 生成
### 5.1 递归转换
```
DTHTMLElement.attributedString
|
├── 处理 void 元素 (br → "\n", img → attachment, hr → "\n")
|
├── 插入 pageBreakBefore 标记 (如果需要)
|
├── 转换文本内容
| ├── 应用 CTFont (从 fontDescriptor)
| ├── 应用前景色 (kCTForegroundColorAttributeName)
| ├── 应用背景色 (DTBackgroundColor)
| └── 应用链接 (DTLink)
|
├── 递归处理子元素
|
├── 应用段落样式 (kCTParagraphStyleAttributeName)
|
├── 插入 pageBreakAfter 标记 (如果需要)
|
└── 应用 WeRead 页面属性
├── DTHTMLVerticalCenterAttribute
├── DTPageRelateAttribute
├── DTPageBreakInsideAvoidAttribute
├── DTPageBackgroundColorAttribute
└── DTPageBackgroundImageAttribute
```
### 5.2 输出
一个 NSAttributedString包含
- 标准 CoreText 属性 (字体、颜色、段落样式)
- DTCoreText 标准属性 (链接、附件、列表)
- WeRead 自定义属性 (分页、居中、背景)
---
## 6. 阶段五: 分页布局 (WRCoreTextLayouter)
### 6.1 Typesetter 创建
```
NSAttributedString
|
v
CTTypesetterCreateWithAttributedString
|
v
CTTypesetter (内部缓存字形分析结果)
```
### 6.2 分页算法
```
输入: NSAttributedString + pageSize
1. 计算可用宽度 = pageSize.width - edgeInsets.left - edgeInsets.right
2. 初始化 currentIndex = 0
3. 循环:
a. lineBreakIndex = CTTypesetterSuggestLineBreak(typesetter, currentIndex, usableWidth)
b. 如果 lineBreakIndex <= 0, 退出循环
c. 创建 WRCoreTextLayoutFrame(range: currentIndex..<currentIndex+lineBreakIndex)
d. currentIndex += lineBreakIndex
4. 返回 [WRCoreTextLayoutFrame] 数组 (每帧 = 一页)
```
### 6.3 页面范围查询
`rangeForPageAtIndex:pageSize:` 通过模拟分页快速定位指定页的文本范围,无需创建布局帧对象。
### 6.4 行高建议
`suggestedLineFragHeights` 逐行创建临时 CTLine 测量高度,用于精确分页计算和避免孤行/寡行。
---
## 7. 阶段六: 单页布局 (WRCoreTextLayoutFrame)
### 7.1 CTFrame 创建
```
CTTypesetter + range + CGPath(rect)
|
v
CTTypesetterCreateFrame
|
v
CTFrame (包含 CTLines 和 CTRuns)
```
### 7.2 行提取
```
CTFrameGetLines → CTLine 数组
CTFrameGetLineOrigins → 行原点数组
对每个 CTLine:
CTLineGetStringRange → 字符范围
CTLineGetTypographicBounds → ascent/descent/leading
创建 WRCoreTextLayoutLine 对象
判断每行是否为段落末尾行 (检查下一个字符是否为 '\n')
```
### 7.3 avoidPageBreakInside 实现
```
1. 从最后一行向前遍历
2. 检查行的 attributedString 属性:
- WRAvoidPageBreakInside == YES?
- WRBlockType ∈ {table, code, list, blockquote}?
3. 如果是受保护元素的一部分,标记需要移除
4. 最多移除 3 行 (kMaxLinesToRemove)
5. 调用 rebuildFrameWithoutLastLines: 更新帧
6. 返回 YES 表示有行被移除
```
### 7.4 渲染高度计算
```
getRenderHeight:
lastLine = lines.lastObject
lastLineBottom = lastLine.origin.y - lastLine.descent
renderedHeight = frameHeight - lastLineBottom + insets.top + insets.bottom
```
---
## 8. 阶段七: 绘制到屏幕 (WRCoreTextLayoutFrame)
### 8.1 绘制流程
```
drawInContext:image:size:inRect:position:
1. CGContextSaveGState
2. 坐标系翻转 (CoreText 底左原点 → UIKit 顶左原点)
CGContextTranslateCTM(0, height)
CGContextScaleCTM(1, -1)
3. 应用位置偏移 (多列/多页布局)
4. 应用内容内边距
5. 绘制封面图片 (如果有)
6. CTFrameDraw(ctFrame, context) — 绘制文本
7. drawAttachmentsInContext: — 绘制图片附件
8. drawDecorativeElementsInContext: — 绘制装饰元素
├── 删除线
├── 下划线
└── 高亮背景
9. CGContextRestoreGState
```
### 8.2 图片绘制
```
遍历 attachments 数组:
对每个 attachment:
获取 image, position, size
CGContextDrawImage(context, imageRect, image.CGImage)
```
### 8.3 装饰元素绘制
**删除线**:
```
遍历 strikethroughRanges:
对每个 range:
找到相交的行
计算 startX, endX (CTLineGetOffsetForStringIndex)
y = line.origin.y + ascent * 0.3
CGContextStrokePath
```
**下划线**:
```
遍历 underlineRanges:
对每个 range:
找到相交的行
y = line.origin.y - descent
CGContextStrokePath (蓝色)
```
**高亮**:
```
遍历 highlightRanges:
对每个 range:
找到相交的行
计算高亮矩形 (descent 到 ascent)
CGContextFillRect (半透明黄色)
```
---
## 9. 交互层
### 9.1 文本选择
```
characterIndexAtPoint:
遍历所有行
检查点是否在行的垂直范围内
使用 CTLineGetStringIndexForPosition 定位字符
selectTextInRange:
遍历所有行
找到与 range 相交的行
添加到 selectedLineIndices
通过 RACSubject 发送选择通知
```
### 9.2 搜索高亮
```
highlightSearchResults:
使用 NSString rangeOfString: 搜索
收集所有匹配范围
创建 highlightRanges (黄色半透明)
返回匹配数量
```
### 9.3 Hit Testing
```
stringIndexAtPoint:
遍历行 → 找到目标行
CTLineGetStringIndexForPosition → 字符索引
rectForCharacterAtIndex:
遍历行 → 找到包含索引的行
CTLineGetOffsetForStringIndex → x 坐标
返回 CGRect
```
---
## 10. 附加功能
### 10.1 繁简转换
```
如果 book.language ∈ {zh-Hant, zh-TW, zh-HK}:
CFStringTransform(Hans → Latin → Hant)
```
### 10.2 免费试读截断
```
如果 isFreeTrial && result.length > trialCharacterLimit:
从限制位置向前搜索段落边界 (\n 或 U+2029)
截取子串
追加 "\n\n...\n\n" 指示器
```
### 10.3 附件注入
```
如果 insertArticleToolAttachment:
追加 NSTextAttachment {type: "articleTool"}
如果 insertBookChapterToolAttachment:
追加 NSTextAttachment {type: "bookChapterTool"}
如果 insertRecommendView:
追加 NSTextAttachment {type: "recommendView"}
```
### 10.4 页面背景生成
```
pageBackgroundImageAtRange:themeBgColor:
检查是否为章节开头页
创建 UIGraphicsImageContext
填充主题背景色
如果是章节开头: 绘制装饰边框
否则: 绘制边距参考线
缓存结果
```
### 10.5 图片缩放
```
resizedImageForImagePath:rect:position:sizePattern:darkMode:themeBgColor:
根据 sizePattern 计算目标尺寸:
"full" → 全宽
"half" → 半宽
"third" → 1/3 宽
"quarter" → 1/4 宽
如果 darkMode: 混合背景色
高质量插值缩放
缓存结果
```
---
## 11. 性能优化
### 11.1 缓存策略
- **布局帧缓存**: NSCache, 最多 20 个 (DTCoreTextLayouter)
- **图片缓存**: NSCache, 最多 50 个 (WRCoreTextLayouter)
- **页面背景缓存**: NSCache, 按 range + 颜色键 (WRCoreTextLayouter)
- **内容高度缓存**: 布局帧级别 (DTCoreTextLayoutFrame)
### 11.2 懒加载
- CTTypesetter 按需创建 (createTypesetter)
- CTFramesetter 按需创建 (createFramesetter)
- 行数据按需提取 (extractLines)
- 字形数据按需提取 (extractGlyphs)
### 11.3 线程安全
所有核心类使用 NSLock 保护:
- DTCoreTextLayouter._lock
- DTCoreTextLayoutFrame._lock
- DTCoreTextLayoutLine._lock
- DTCoreTextGlyphRun._lock
- WRCoreTextLayouter._layoutLock
- WRCoreTextLayoutFrame._frameLock
### 11.4 增量更新
- attributedString 变化时标记 typesetterDirty
- 仅在下次访问时重建 typesetter
- 布局帧缓存自动失效
---
## 12. 数据流总结
```
EPUB 文件
├─ container.xml → OPF 路径
├─ content.opf → manifest + spine + metadata
├─ toc.ncx → 目录树
└─ *.xhtml → 章节内容
XHTML 字符串
│ (繁简转换)
CSS 级联合并
│ (5 层合并)
DTHTMLAttributedStringBuilder
│ (SAX 解析 + DOM 构建 + 样式应用)
DTHTMLElement 树
│ (后处理: 图片/链接/自定义属性)
NSAttributedString
│ (包含标准 + 自定义属性)
CTTypesetter
│ (字形分析 + 行断点计算)
WRCoreTextLayoutFrame[]
│ (每帧 = 一页, 含 avoidPageBreakInside)
CGContext 绘制
│ (文本 + 图片 + 装饰)
屏幕像素
```