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

14 KiB
Raw Blame History

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 绘制
        │ (文本 + 图片 + 装饰)
        ▼
    屏幕像素