ReadViewSDK/Doc/EPUBTextRendering_功能实现逻辑.md

322 lines
15 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.

# 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 开始循环:
- 创建 CTFramerange 从当前 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` 覆盖逻辑
- 排查 4Fragment 定位失败
- 检查 `injectFragmentMarkers` 是否正确注入标记
- 检查 `extractFragmentOffsets` 是否正确提取偏移
- 确认标记在 DTCoreText 渲染后仍存在于 attributed string 中
- 排查 5搜索结果不准确
- 搜索基于已渲染的纯文本(去 HTML 标签),非原始 HTML
- 确认 `attributedContent.string` 包含预期的文本内容
- 搜索为大小写不敏感的线性扫描