14 KiB
14 KiB
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:
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:
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:
body { background-color: #1a1a1a; color: #cccccc; }
a { color: #6eaad7; }
img { filter: brightness(0.85); }
Layer 5 - 用户设置 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 绘制
│ (文本 + 图片 + 装饰)
▼
屏幕像素