# EPUBTextRendering 功能实现逻辑 ## 1. 范围与目标 - 代码范围:`Sources/RDReaderView/EPUBTextRendering/`(6 个 Swift 文件) - 目标:说明文本渲染路径如何将 EPUB HTML 转换为 NSAttributedString、按字符范围分页、构建书籍模型,并支持全文搜索与 textReflowable 标注定位。 - 主链路关键词:`RDEPUBParser HTML -> DTCoreText 渲染 -> 片段标记注入/提取 -> CoreText 分页 -> RDEPUBTextBook -> 页面查找/位置转换`。 - 适用范围:仅用于 `textReflowable` 阅读配置文件(纯文本可重排 EPUB,如小说)。固定版式和交互式 EPUB 使用 WebView 渲染路径。 ## 2. 关键对象职责 ### 2.1 渲染协议 `RDEPUBTextRenderer` - 文件:`EPUBTextRendering/RDEPUBTextRenderer.swift` - 职责: - 定义单方法协议:`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=}` 不可见标记 - 片段偏移提取:从 NSAttributedString 中提取标记位置并删除标记 - 字体标准化:保留 HTML 中的粗体/斜体 trait,统一使用配置的基础字体族和字号 - 段落样式创建:强制应用配置的行间距和段间距 - 兜底渲染:DTCoreText 不可用时从原始 HTML 字符串生成纯文本 NSAttributedString ### 2.4 分页支持 `NSAttributedString.ss_pageRanges(size:)` - 文件:`EPUBTextRendering/RDEPUBTextPaginationSupport.swift` - 职责: - 基于 CoreText framesetter 的分页 - 从位置 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、预览文本、匹配位置) ## 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 标准化: - 移除中文分页标记:`
分页符` - `\r` 替换为 `\n` - 连续 `\n` 合并为单个 6. 检查是否跳过(空白且 href 包含 "cover" 或 "title") 7. 调用 `renderer.renderChapter(html:baseURL:style:)` 渲染 ### 3.2 DTCoreText 渲染管线 1. 片段标记注入:`RDEPUBTextRendererSupport.injectFragmentMarkers(into: html)` - 正则 `(<[^>]+\sid="([^"]+)"[^>]*>)` 匹配带 id 的元素 - 在匹配标签前插入 `${id=}` 文本标记 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 RDEPUBRenderedChapterContent ├── attributedString: NSAttributedString // 渲染后的富文本 └── fragmentOffsets: [String: Int] // fragment ID -> 字符偏移映射 ``` ### 5.3 页面模型 ```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.4 章节模型 ```swift RDEPUBTextChapter ├── chapterIndex: Int ├── spineIndex: Int ├── href: String ├── title: String? ├── attributedContent: NSAttributedString // 整章渲染后的富文本 ├── fragmentOffsets: [String: Int] └── pages: [RDEPUBTextPage] ``` ### 5.5 书籍模型 ```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` 包含预期的文本内容 - 搜索为大小写不敏感的线性扫描