- Rename source module from RDReaderView to RDEpubReaderView - Move all source files from Sources/RDReaderView/ to Sources/RDEpubReaderView/ - Update podspec: RDReaderView.podspec -> RDEpubReaderView.podspec - Update Podfile, demo project, and CocoaPods config for new pod name - Delete old RDReaderView pod support files from ReadViewDemo/Pods - Add new RDEpubReaderView pod support files - Update documentation (API ref, architecture, UML, conventions, etc.) - Add FixedLayoutRotationTests - Update .gitignore: exclude .DS_Store, manual unpack backups, _ssoft-output
9.8 KiB
排版管线详解
最后更新:2026-06-18
本文档详细描述 ReadViewSDK 文本排版管线(Typesetter Pipeline)的 8 个处理阶段、核心算法和数据流。
1. 概述
排版管线是 EPUBTextRendering 层的核心组件,负责将 EPUB 原始 HTML 转换为可分页的 NSAttributedString。它是 textReflowable 渲染路径(本 SDK 核心路径)的关键环节。
入口:RDEPUBTextTypesetterPipeline.makeRequest(from:)
输入:RDEPUBTypesettingInput(原始 HTML、样式、布局配置)
输出:RDEPUBTypesettingOutput(渲染请求、诊断信息、兼容性报告)
关键文件:Sources/RDEpubReaderView/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="..."> 用于相对路径解析:
static func injectBaseHref(into html: String, baseURL: URL?) -> String
4. 阶段 2:语义标记注入(RDEPUBSemanticMarkerInjector)
文件:RDEPUBSemanticMarkerInjector.swift
4.1 标记语法
${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 语言检测
static func prefersLatinLanguageCSS(
languageCode: String?,
sourceHTML: String
) -> Bool
检测逻辑:
- 检查
lang属性(如lang="en") - 对 HTML 文本采样,统计拉丁字符比例
- 拉丁语言使用专用 CSS(
wxread-replace-latin.css)
6.3 样式表内联
RDEPUBRenderDiagnosticsCollector.inlineLinkedStyleSheets() 负责:
- 查找
<link rel=stylesheet href=...> - 读取 CSS 文件内容
- 重写 CSS 中的相对
url()引用 - 内联到 HTML 的
<head>中
7. 阶段 5:字体注册(RDEPUBFontNormalizer)
文件:RDEPUBFontNormalizer.swift
7.1 处理流程
- 解析
@font-face { url(...) }块 - 提取字体文件路径和 family 名称
- 通过
CTFontManagerRegisterFontsForURL(.process)注册嵌入字体 - 已注册字体路径缓存在
registeredFontPaths集合中,避免重复注册
7.2 注册结果
struct RDEPUBFontRegistrationResult {
let descriptor: RDEPUBFontDescriptor
let didRegister: Bool
let errorDescription: String?
}
注册失败的字体不会阻断管线,但会记录到兼容性报告中。
8. 阶段 6:Fragment 标记注入(RDEPUBFragmentMarkerInjector)
文件:RDEPUBFragmentMarkerInjector.swift
扫描 HTML 中的 id 属性,在其前面注入 ${id=xxx} 标记:
<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
func renderChapter(request: RDEPUBTextChapterRenderRequest) throws -> RDEPUBRenderedChapterContent
- 将 HTML 编码为
Data - 使用
DTHTMLAttributedStringBuilder构建NSAttributedString - 在
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 迭代分页:
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(精确切分) |
判断方法:
func lineIsInAvoidPageBreakInsideBlock(_ lineRange: NSRange) -> Bool
func lineIsInKeepWithNextBlock(_ lineRange: NSRange) -> Bool
func adjustedRange(from:totalLength:lineRanges:factory:) -> (range, breakReason, ...)
11.3 RDEPUBChapterTailNormalizer
文件:BuildPipeline/RDEPUBChapterTailNormalizer.swift
三遍处理:
- 删除空白中间帧:无可见字符且无附件的帧
- 删除空白尾部帧:从末尾开始删除同类空白帧
- 合并短尾帧:如果最后一帧 ≤2 个可见字符且前一帧 ≥8 倍长,合并
12. 诊断与调试
12.1 兼容性报告
struct RDEPUBCSSCompatibilityReport {
let unsupportedRules: [String] // 不支持的 CSS 规则
let normalizedRules: [String] // 已规范化的规则
let fontFailures: [String] // 字体注册失败
}
12.2 资源诊断
struct RDEPUBTextResourceReferenceDiagnostic {
let href: String // 引用路径
let exists: Bool // 文件是否存在
let type: String // 资源类型(image/font/stylesheet)
}
12.3 语义摘要
RDEPUBReaderController.nativeTextSemanticSummary() 返回当前页面的语义摘要,包含:
- 页码
- 分页原因(breakReason)
- 块类型(blockKinds)
- 语义提示(semanticHints)
- 附件位置(attachmentPlacements)