322 lines
15 KiB
Markdown
322 lines
15 KiB
Markdown
# EPUBTextRendering 功能实现逻辑
|
||
|
||
## 1. 范围与目标
|
||
|
||
- 代码范围:`Sources/RDReaderView/EPUBTextRendering/`(9 个 Swift 文件)
|
||
- 目标:说明文本渲染路径如何将 EPUB HTML 转换为 NSAttributedString、按字符范围分页、构建书籍模型,并支持全文搜索与 textReflowable 标注定位。
|
||
- 主链路关键词:`RDEPUBParser HTML -> DTCoreText 渲染 -> 片段标记注入/提取 -> CoreText 分页 -> RDEPUBTextBook -> 页面查找/位置转换`。
|
||
- 适用范围:仅用于 `textReflowable` 阅读配置文件(纯文本可重排 EPUB,如小说)。固定版式和交互式 EPUB 使用 WebView 渲染路径。
|
||
|
||
## 2. 关键对象职责
|
||
|
||
### 2.1 渲染协议 `RDEPUBTextRenderer`
|
||
|
||
- 文件:`EPUBTextRendering/RDEPUBTextRenderer.swift`
|
||
- 职责:
|
||
- 定义双方法协议:
|
||
- `renderChapter(request: RDEPUBTextChapterRenderRequest) throws -> RDEPUBRenderedChapterContent`(主方法,携带完整上下文)
|
||
- `renderChapter(html:baseURL:style:) throws -> RDEPUBRenderedChapterContent`(便捷方法,默认实现转发到主方法)
|
||
- 作为渲染后端的抽象点,当前仅 `RDEPUBDTCoreTextRenderer` 一个实现
|
||
|
||
### 2.2 DTCoreText 渲染器 `RDEPUBDTCoreTextRenderer`
|
||
|
||
- 文件:`EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift`
|
||
- 职责:
|
||
- 实现 `RDEPUBTextRenderer` 协议
|
||
- 使用 `DTHTMLAttributedStringBuilder` 将 HTML 转为 NSAttributedString
|
||
- 渲染后处理:提取片段标记、统一字体/行间距/颜色
|
||
- DTCoreText 不可用时回退到纯文本渲染
|
||
|
||
### 2.3 渲染支持工具 `RDEPUBTextRendererSupport`
|
||
|
||
- 文件:`EPUBTextRendering/RDEPUBTextRendererSupport.swift`
|
||
- 职责:
|
||
- 片段标记注入:扫描 HTML 中带 `id` 属性的元素,在其前注入 `${id=<fragmentID>}` 不可见标记
|
||
- 片段偏移提取:从 NSAttributedString 中提取标记位置并删除标记
|
||
- 字体标准化:保留 HTML 中的粗体/斜体 trait,统一使用配置的基础字体族和字号
|
||
- 段落样式创建:强制应用配置的行间距和段间距
|
||
- 兜底渲染:DTCoreText 不可用时从原始 HTML 字符串生成纯文本 NSAttributedString
|
||
|
||
### 2.4 分页支持 `NSAttributedString.ss_pageRanges(size:)` / `rd_paginatedFrames(size:)`
|
||
|
||
- 文件:`EPUBTextRendering/RDEPUBTextPaginationSupport.swift`
|
||
- 职责:
|
||
- `rd_paginatedFrames(size:) -> [RDEPUBTextLayoutFrame]`:基于 `RDEPUBTextLayouter` 的增强分页,返回带语义元数据的帧对象
|
||
- `ss_pageRanges(size:) -> [NSRange]`:便捷方法,内部调用 `rd_paginatedFrames` 后提取 contentRange
|
||
- 从位置 0 开始,反复创建 CTFrame 测量可见字符范围
|
||
- 返回 `[NSRange]`,每个 range 对应一页
|
||
- 安全保护:`visibleRange.length == 0` 时 break 防止死循环
|
||
|
||
### 2.5 书籍构建器 `RDEPUBTextBookBuilder`
|
||
|
||
- 文件:`EPUBTextRendering/RDEPUBTextBookBuilder.swift`
|
||
- 职责:
|
||
- 遍历 publication 的 linear spine 项(仅 HTML/XHTML 类型)
|
||
- 对每项:HTML 标准化 → 渲染 → 跳过空白封面/标题页 → 分页 → 构建 `RDEPUBTextPage` 数组
|
||
- 汇总所有章节为 `RDEPUBTextBook`
|
||
- 后台队列执行,主线程回调结果
|
||
|
||
### 2.6 全文搜索引擎 `RDEPUBTextSearchEngine`
|
||
|
||
- 文件:`EPUBTextRendering/RDEPUBTextSearchEngine.swift`
|
||
- 职责:
|
||
- 实现 `RDEPUBSearchEngine` 协议
|
||
- 在已渲染的 NSAttributedString 纯文本上执行线性搜索
|
||
- 返回 `RDEPUBSearchMatch` 数组(含 href、progression、预览文本、匹配位置)
|
||
|
||
### 2.7 CoreText 分页引擎 `RDEPUBTextLayouter` / `RDEPUBTextLayoutFrame`
|
||
|
||
- 文件:`EPUBTextRendering/RDEPUBTextLayouter.swift`、`EPUBTextRendering/RDEPUBTextLayoutFrame.swift`
|
||
- 职责:
|
||
- `RDEPUBTextLayouter`:封装 `CTFramesetter`,逐帧计算分页结果,支持语义边界调整(避免在附件/代码块/列表中间断页)
|
||
- `RDEPUBTextLayoutFrame`:单帧分页结果,包含 contentRange、breakReason、blockRange、attachment 信息、语义提示和诊断数据
|
||
- 替代原有的纯 `NSRange` 分页,返回带丰富元数据的帧对象
|
||
|
||
### 2.8 纯文本构建器 `RDPlainTextBookBuilder`
|
||
|
||
- 文件:`EPUBTextRendering/RDPlainTextBookBuilder.swift`
|
||
- 职责:
|
||
- 从纯文本文件(.txt 等)构建 `RDEPUBTextBook`
|
||
- 将纯文本按段落切分后渲染为 NSAttributedString
|
||
- 按页面尺寸分页,输出与 EPUB 文本路径一致的书籍模型
|
||
|
||
## 3. 主流程(代码级)
|
||
|
||
### 3.1 完整渲染-分页流程
|
||
|
||
1. 入口:`RDEPUBTextBookBuilder.build(parser:publication:pageSize:style:)`
|
||
2. 遍历 `publication.spine` 中 `item.linear == true` 的项
|
||
3. 过滤仅处理 mediaType 包含 "html" 或 "xhtml" 的项
|
||
4. 读取 `parser.htmlString(forRelativePath: item.href)` 获取原始 HTML
|
||
5. HTML 标准化:
|
||
- 移除中文分页标记:`<hr lang="zh-CN">分页符</hr>`
|
||
- `\r` 替换为 `\n`
|
||
- 连续 `\n` 合并为单个
|
||
6. 检查是否跳过(空白且 href 包含 "cover" 或 "title")
|
||
7. 调用 `renderer.renderChapter(html:baseURL:style:)` 渲染
|
||
|
||
### 3.2 DTCoreText 渲染管线
|
||
|
||
1. 片段标记注入:`RDEPUBTextRendererSupport.injectFragmentMarkers(into: html)`
|
||
- 正则 `(<[^>]+\sid="([^"]+)"[^>]*>)` 匹配带 id 的元素
|
||
- 在匹配标签前插入 `${id=<fragmentID>}` 文本标记
|
||
2. UTF-8 编码为 Data
|
||
3. `DTHTMLAttributedStringBuilder(html:options:documentAttributes:)` 执行渲染
|
||
4. DTCoreText 选项:
|
||
- `NSTextSizeMultiplierDocumentOption: 1.0`
|
||
- `DTDefaultFontFamily` / `DTDefaultFontName` / `DTDefaultFontSize`:来自 `style.font`
|
||
- `DTDefaultLineHeightMultiplier`:`(font.lineHeight + lineSpacing) / max(font.lineHeight, 1)`
|
||
- `DTUseiOS6Attributes: true`:生成 UIKit 兼容属性
|
||
- `NSBaseURLDocumentOption`:资源相对路径解析
|
||
- `DTDefaultTextColor`:可选文本颜色
|
||
5. 后处理:
|
||
- 提取片段偏移并删除标记
|
||
- `normalizeReadingAttributes`:遍历每个属性 run,统一字体(保留粗/斜体 trait)、强制行间距/段间距、覆盖前景色
|
||
|
||
### 3.3 CoreText 分页算法
|
||
|
||
1. 从 `NSAttributedString` 创建 `CTFramesetter`
|
||
2. 创建页面大小的 `CGPath`
|
||
3. 从 location = 0 开始循环:
|
||
- 创建 CTFrame(range 从当前 location 到字符串末尾)
|
||
- `CTFrameGetVisibleStringRange(frame)` 获取可见字符数
|
||
- 追加 `NSRange(location: currentLocation, length: visibleRange.length)`
|
||
- location += visibleRange.length
|
||
4. 循环终止条件:`location + visibleRange.length >= attributedString.length`
|
||
5. 安全保护:`visibleRange.length == 0` 时 break
|
||
|
||
### 3.4 页面与位置互转
|
||
|
||
**Location → Page Number**(`RDEPUBTextBook.pageNumber(for:resolver:bookIdentifier:)`):
|
||
1. 有 fragment 时:用 `fragmentOffsets` 查找字符偏移,再定位到对应页面
|
||
2. 无 fragment 时:用 `navigationProgression`(progression 和 lastProgression 的中点)计算字符偏移
|
||
3. 遍历 pages 找到 `pageStartOffset <= offset < pageEndOffset` 的页面
|
||
|
||
**Page Number → Location**(`RDEPUBTextBook.location(forPageNumber:bookIdentifier:)`):
|
||
1. 通过 page number 查找 `RDEPUBTextPage`
|
||
2. 计算 `progression = pageStartOffset / totalCharacters`
|
||
3. 计算 `lastProgression = pageEndOffset / totalCharacters`
|
||
4. 构建 `RDEPUBLocation`(href + progression + lastProgression)
|
||
|
||
### 3.5 标注定位与恢复
|
||
|
||
textReflowable 路径不复用 WebView 的 DOM range,而使用章节字符偏移作为精确锚点:
|
||
|
||
```json
|
||
{
|
||
"kind": "text-offset",
|
||
"href": "chapter.xhtml",
|
||
"start": 1234,
|
||
"end": 1260
|
||
}
|
||
```
|
||
|
||
1. `RDEPUBTextContentView` 保留当前 `RDEPUBTextPage`,从 `page.pageStartOffset` 将页内 `UITextView.selectedRange` 映射为章节全局 `start/end`
|
||
2. 生成 `RDEPUBSelection` 后回传 `RDEPUBReaderController`,共享层继续负责创建、去重、持久化、列表和跳转
|
||
3. 页面重绘时,`RDEPUBTextContentView` 仅消费 `kind == "text-offset"` 的 rangeInfo,计算当前页 `[pageStartOffset, pageEndOffset]` 与标注 `[start, end)` 的重叠
|
||
4. `highlight` 样式叠加 `.backgroundColor`,`underline` 样式叠加 `.underlineStyle` 和 `.underlineColor`
|
||
5. 字号、行高、主题变化导致重新分页后,标注仍以章节全局字符偏移恢复到新的页面切片
|
||
|
||
## 4. 异常与边界处理
|
||
|
||
- `RDEPUBTextRenderingError.htmlEncodingFailed`:HTML 字符串无法编码为 UTF-8 Data(极罕见)
|
||
- DTCoreText builder 返回 nil:回退到 `fallbackAttributedString`,生成纯文本(含 HTML 标签原文),降级体验
|
||
- 空白章节跳过:href 包含 "cover" 或 "title" 且渲染后文本为空的章节被跳过
|
||
- 分页零结果兜底:`pageRanges` 为空但 content 非空时,整个章节作为一页
|
||
- CoreText 零高度安全保护:`visibleRange.length == 0` 时 break 防止无限循环
|
||
- Token 取消:`paginationToken`(UUID)防止异步完成后的过期结果污染 UI
|
||
|
||
## 5. 数据结构与字段映射
|
||
|
||
### 5.1 渲染配置
|
||
|
||
```swift
|
||
RDEPUBTextRenderStyle
|
||
├── font: UIFont // 基础字体
|
||
├── lineSpacing: CGFloat // 额外行间距
|
||
├── textColor: UIColor? // 文本颜色(nil 时保持 HTML 原始颜色)
|
||
└── backgroundColor: UIColor? // 背景色
|
||
```
|
||
|
||
### 5.2 渲染请求上下文
|
||
|
||
```swift
|
||
RDEPUBTextChapterRenderRequest
|
||
├── context: RDEPUBTextChapterContext
|
||
│ ├── href: String
|
||
│ ├── title: String
|
||
│ ├── html: String
|
||
│ ├── baseURL: URL?
|
||
│ ├── stylesheet: RDEPUBTextStyleSheetPackage
|
||
│ │ ├── layers: [RDEPUBTextStyleSheetLayer]
|
||
│ │ │ └── kind (.default/.replace/.dark/.epub/.user) + css
|
||
│ │ └── combinedCSS (computed)
|
||
│ └── resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
|
||
└── style: RDEPUBTextRenderStyle
|
||
```
|
||
|
||
### 5.3 渲染输出
|
||
|
||
```swift
|
||
RDEPUBRenderedChapterContent
|
||
├── attributedString: NSAttributedString // 渲染后的富文本
|
||
├── fragmentOffsets: [String: Int] // fragment ID -> 字符偏移映射
|
||
└── resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
|
||
```
|
||
|
||
### 5.4 分页帧模型(新增)
|
||
|
||
```swift
|
||
RDEPUBTextLayoutFrame
|
||
├── contentRange: NSRange // 本帧字符范围
|
||
├── breakReason: RDEPUBTextPageBreakReason // 分页原因
|
||
├── blockRange: NSRange? // 所属 block 范围
|
||
├── attachmentRanges: [NSRange] // 附件范围
|
||
├── attachmentKinds: [RDEPUBTextAttachmentKind]
|
||
├── blockKinds: [RDEPUBTextBlockKind] // (.paragraph/.list/.table/.code/.blockquote/.attachment/.generic)
|
||
├── semanticHints: [RDEPUBTextSemanticHint] // (.avoidPageBreakInside/.pageBreakBefore/.pageBreakAfter/.pageRelate)
|
||
├── attachmentPlacements: [RDEPUBTextAttachmentPlacement] // (.inline/.baseline/.centered)
|
||
├── trailingFragmentID: String? // 帧尾最近的 fragment ID
|
||
└── diagnostics: [String] // 分页诊断信息
|
||
|
||
RDEPUBTextLayouter
|
||
├── init(attributedString:pageSize:)
|
||
└── layoutFrames(fragmentOffsets:) -> [RDEPUBTextLayoutFrame]
|
||
```
|
||
|
||
### 5.5 页面模型
|
||
|
||
```swift
|
||
RDEPUBTextPage
|
||
├── absolutePageIndex: Int // 全局页码
|
||
├── chapterIndex: Int // 章节索引
|
||
├── spineIndex: Int // spine 索引
|
||
├── href: String // 章节 href
|
||
├── chapterTitle: String? // 章节标题
|
||
├── pageIndexInChapter: Int // 章节内页码
|
||
├── totalPagesInChapter: Int // 章节总页数
|
||
├── content: NSAttributedString // 本页的富文本切片
|
||
├── contentRange: NSRange // 在整章富文本中的 range
|
||
├── pageStartOffset: Int // 起始字符偏移
|
||
└── pageEndOffset: Int // 结束字符偏移
|
||
```
|
||
|
||
### 5.6 章节模型
|
||
|
||
```swift
|
||
RDEPUBTextChapter
|
||
├── chapterIndex: Int
|
||
├── spineIndex: Int
|
||
├── href: String
|
||
├── title: String?
|
||
├── attributedContent: NSAttributedString // 整章渲染后的富文本
|
||
├── fragmentOffsets: [String: Int]
|
||
└── pages: [RDEPUBTextPage]
|
||
```
|
||
|
||
### 5.7 书籍模型
|
||
|
||
```swift
|
||
RDEPUBTextBook
|
||
├── chapters: [RDEPUBTextChapter]
|
||
├── pages: [RDEPUBTextPage] // 扁平化页面列表
|
||
├── page(at:) -> RDEPUBTextPage? // 1-based 页码查找
|
||
├── pageNumber(for:resolver:bookIdentifier:) -> Int? // Location -> 页码
|
||
└── location(forPageNumber:bookIdentifier:) -> RDEPUBLocation? // 页码 -> Location
|
||
```
|
||
|
||
## 6. 与 EPUBCore 层的集成
|
||
|
||
### 消费的数据
|
||
|
||
| 来源 | 数据 | 用途 |
|
||
|------|------|------|
|
||
| `RDEPUBParser` | `htmlString(forRelativePath:)` | 读取章节 HTML 内容 |
|
||
| `RDEPUBParser` | `fileURL(forRelativePath:)` | 计算 DTCoreText 的 baseURL |
|
||
| `RDEPUBPublication` | `spine` | 遍历 linear HTML/XHTML 项 |
|
||
| `RDEPUBPublication` | `tableOfContents` | 匹配章节标题 |
|
||
| `RDEPUBSpineItem` | `href`, `mediaType`, `linear`, `title` | 过滤和标识章节 |
|
||
| `RDEPUBResourceResolver` | `normalizedLocation` / `normalizedHref` | 位置查找时标准化 href |
|
||
| `RDEPUBLocation` | `href`, `fragment`, `navigationProgression` | 页面定位 |
|
||
| `RDEPUBSearchEngine` | 协议接口 | `RDEPUBTextSearchEngine` 实现此协议 |
|
||
| `RDEPUBHighlight` | `style`, `rangeInfo`, `color`, `note` | text-offset 标注恢复与页内绘制 |
|
||
|
||
## 7. 配置参数
|
||
|
||
- **字号**:`RDEPUBReaderConfiguration.fontSize`,默认 15pt
|
||
- **行高倍数**:`lineHeightMultiple`,默认 1.6(行间距 = font.lineHeight * 0.6,最低 4pt)
|
||
- **内容边距**:`reflowableContentInsets`,默认 (40, 16, 40, 16),影响有效页面尺寸
|
||
- **主题色**:通过 `RDEPUBReaderTheme` 的 `contentTextColor` / `contentBackgroundColor` 传入
|
||
- **渲染引擎**:`RDEPUBTextRenderingEngine`,当前仅 `.dtCoreText`
|
||
|
||
## 8. 性能特征
|
||
|
||
- **全文渲染**:每章一次性渲染为完整的 NSMutableAttributedString,无分块/懒加载
|
||
- **内存**:`RDEPUBTextBook` 同时持有 chapters(含完整 attributedContent)和 pages(含子串切片),存在一定程度的内存重复
|
||
- **后台执行**:`build` 方法在 `DispatchQueue.global(qos: .userInitiated)` 执行,不阻塞主线程
|
||
- **搜索性能**:线性扫描每章的 `attributedContent.string`,无索引,搜索时间与总文本量线性相关
|
||
- **无分页缓存**:字号/行高变化时全书重新渲染和分页,无增量更新
|
||
|
||
## 9. 联调与排查建议
|
||
|
||
- 排查 1:渲染后文本为空
|
||
- 检查 `parser.htmlString(forRelativePath:)` 是否返回非 nil
|
||
- 检查 spine 项的 mediaType 是否包含 "html" 或 "xhtml"
|
||
- 空白封面/标题页会被 `shouldSkipChapter` 跳过
|
||
- 排查 2:分页页数不正确
|
||
- 检查 `pageSize` 计算是否正确(viewport - contentInsets)
|
||
- 检查 DTCoreText 行高倍数是否正确应用
|
||
- 检查 CoreText 分页是否有 `visibleRange.length == 0` 的异常情况
|
||
- 排查 3:字体/颜色不正确
|
||
- 检查 `RDEPUBTextRenderStyle` 配置
|
||
- 检查 `normalizeReadingAttributes` 是否正确保留粗/斜体 trait
|
||
- 检查 `foregroundColor` 覆盖逻辑
|
||
- 排查 4:Fragment 定位失败
|
||
- 检查 `injectFragmentMarkers` 是否正确注入标记
|
||
- 检查 `extractFragmentOffsets` 是否正确提取偏移
|
||
- 确认标记在 DTCoreText 渲染后仍存在于 attributed string 中
|
||
- 排查 5:搜索结果不准确
|
||
- 搜索基于已渲染的纯文本(去 HTML 标签),非原始 HTML
|
||
- 确认 `attributedContent.string` 包含预期的文本内容
|
||
- 搜索为大小写不敏感的线性扫描
|