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

15 KiB
Raw Blame History

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.swiftEPUBTextRendering/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.spineitem.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 NumberRDEPUBTextBook.pageNumber(for:resolver:bookIdentifier:)

  1. 有 fragment 时:用 fragmentOffsets 查找字符偏移,再定位到对应页面
  2. 无 fragment 时:用 navigationProgressionprogression 和 lastProgression 的中点)计算字符偏移
  3. 遍历 pages 找到 pageStartOffset <= offset < pageEndOffset 的页面

Page Number → LocationRDEPUBTextBook.location(forPageNumber:bookIdentifier:)

  1. 通过 page number 查找 RDEPUBTextPage
  2. 计算 progression = pageStartOffset / totalCharacters
  3. 计算 lastProgression = pageEndOffset / totalCharacters
  4. 构建 RDEPUBLocationhref + progression + lastProgression

3.5 标注定位与恢复

textReflowable 路径不复用 WebView 的 DOM range而使用章节字符偏移作为精确锚点

{
  "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 样式叠加 .backgroundColorunderline 样式叠加 .underlineStyle.underlineColor
  5. 字号、行高、主题变化导致重新分页后,标注仍以章节全局字符偏移恢复到新的页面切片

4. 异常与边界处理

  • RDEPUBTextRenderingError.htmlEncodingFailedHTML 字符串无法编码为 UTF-8 Data极罕见
  • DTCoreText builder 返回 nil回退到 fallbackAttributedString,生成纯文本(含 HTML 标签原文),降级体验
  • 空白章节跳过href 包含 "cover" 或 "title" 且渲染后文本为空的章节被跳过
  • 分页零结果兜底:pageRanges 为空但 content 非空时,整个章节作为一页
  • CoreText 零高度安全保护:visibleRange.length == 0 时 break 防止无限循环
  • Token 取消:paginationTokenUUID防止异步完成后的过期结果污染 UI

5. 数据结构与字段映射

5.1 渲染配置

RDEPUBTextRenderStyle
  ├── font: UIFont              // 基础字体
  ├── lineSpacing: CGFloat      // 额外行间距
  ├── textColor: UIColor?       // 文本颜色nil 时保持 HTML 原始颜色)
  └── backgroundColor: UIColor? // 背景色

5.2 渲染请求上下文

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 渲染输出

RDEPUBRenderedChapterContent
  ├── attributedString: NSAttributedString  // 渲染后的富文本
  ├── fragmentOffsets: [String: Int]        // fragment ID -> 字符偏移映射
  └── resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]

5.4 分页帧模型(新增)

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 页面模型

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 章节模型

RDEPUBTextChapter
  ├── chapterIndex: Int
  ├── spineIndex: Int
  ├── href: String
  ├── title: String?
  ├── attributedContent: NSAttributedString  // 整章渲染后的富文本
  ├── fragmentOffsets: [String: Int]
  └── pages: [RDEPUBTextPage]

5.7 书籍模型

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),影响有效页面尺寸
  • 主题色:通过 RDEPUBReaderThemecontentTextColor / 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 包含预期的文本内容
    • 搜索为大小写不敏感的线性扫描