- 更新模块文件数(EPUBCore 31+2, EPUBTextRendering 13, ReaderView 5, EPUBUI 19) - 更新 Swift 版本(5.10)、iOS 版本(15.0)、行数等基本信息 - 补充新增文件文档(TextAnchor, RenderRequest, WebViewDebug, AssetRepository, TextIndexTable, TextPerformanceSampler, ChapterData, PageInteractionController 等) - 更新 RDEPUBLocation 模型(新增 rangeAnchor 字段) - 更新 RDEPUBReaderConfiguration(13 项配置)、Delegate 签名、Persistence(8 方法) - 修正 RDURLReaderController 归属(ReaderView → EPUBUI) - 移除过时的 LegacyRDReaderController 引用 - 标记 SS→RD 命名迁移为已完成
17 KiB
17 KiB
EPUBTextRendering 功能实现逻辑
1. 范围与目标
- 代码范围:
Sources/RDReaderView/EPUBTextRendering/(13 个 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
- 片段标记注入:扫描 HTML 中带
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.6.1 章节数据模型 RDEPUBChapterData
- 文件:
EPUBTextRendering/RDEPUBChapterData.swift - 职责:
- 每章节的数据聚合模型,管理高亮、搜索结果和页面查询
- 支持按页码范围过滤当前页的高亮列表
- 支持搜索结果在章节内的定位和匹配索引计算
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 文本路径一致的书籍模型
- 从纯文本文件(.txt 等)构建
2.9 文本索引表 RDEPUBTextIndexTable
- 文件:
EPUBTextRendering/RDEPUBTextIndexTable.swift - 职责:
- 构建全书的文本结构映射:章节起始偏移、href 与章节/spine 索引对应、fragment 偏移映射、行列索引映射
RDEPUBRowColumnIndex:行-列索引条目,记录每行的字符范围- 支持锚点与位置之间的双向转换:
anchor(forAbsoluteIndex:in:):绝对索引 → 文本锚点anchor(for:):阅读位置 → 文本锚点(优先 rangeAnchor,其次 fragment/progression)absoluteIndex(for:):锚点 → 全书绝对索引location(for:in:bookIdentifier:):锚点/范围锚点 → 阅读位置
- 支持页码查找:
pageNumber(for:in:)通过锚点定位到对应页面
2.10 性能采样器 RDEPUBTextPerformanceSampler
- 文件:
EPUBTextRendering/RDEPUBTextPerformanceSampler.swift - 职责:
RDEPUBTextPerformanceSample:单章节性能数据(chapterHref、renderDuration、paginateDuration、pageCount、attributedStringLength、cacheHit)RDEPUBTextPerformanceSampler:书籍构建过程的性能采样器- 由
RDEPUBTextBookBuilder在构建过程中使用,每章记录一个采样点 summary()输出汇总报告:总渲染/分页耗时和缓存命中率
3. 主流程(代码级)
3.1 完整渲染-分页流程
- 入口:
RDEPUBTextBookBuilder.build(parser:publication:pageSize:style:) - 遍历
publication.spine中item.linear == true的项 - 过滤仅处理 mediaType 包含 "html" 或 "xhtml" 的项
- 读取
parser.htmlString(forRelativePath: item.href)获取原始 HTML - HTML 标准化:
- 移除中文分页标记:
<hr lang="zh-CN">分页符</hr> \r替换为\n- 连续
\n合并为单个
- 移除中文分页标记:
- 检查是否跳过(空白且 href 包含 "cover" 或 "title")
- 调用
renderer.renderChapter(html:baseURL:style:)渲染
3.2 DTCoreText 渲染管线
- 片段标记注入:
RDEPUBTextRendererSupport.injectFragmentMarkers(into: html)- 正则
(<[^>]+\sid="([^"]+)"[^>]*>)匹配带 id 的元素 - 在匹配标签前插入
${id=<fragmentID>}文本标记
- 正则
- UTF-8 编码为 Data
DTHTMLAttributedStringBuilder(html:options:documentAttributes:)执行渲染- DTCoreText 选项:
NSTextSizeMultiplierDocumentOption: 1.0DTDefaultFontFamily/DTDefaultFontName/DTDefaultFontSize:来自style.fontDTDefaultLineHeightMultiplier:(font.lineHeight + lineSpacing) / max(font.lineHeight, 1)DTUseiOS6Attributes: true:生成 UIKit 兼容属性NSBaseURLDocumentOption:资源相对路径解析DTDefaultTextColor:可选文本颜色
- 后处理:
- 提取片段偏移并删除标记
normalizeReadingAttributes:遍历每个属性 run,统一字体(保留粗/斜体 trait)、强制行间距/段间距、覆盖前景色
3.3 CoreText 分页算法
- 从
NSAttributedString创建CTFramesetter - 创建页面大小的
CGPath - 从 location = 0 开始循环:
- 创建 CTFrame(range 从当前 location 到字符串末尾)
CTFrameGetVisibleStringRange(frame)获取可见字符数- 追加
NSRange(location: currentLocation, length: visibleRange.length) - location += visibleRange.length
- 循环终止条件:
location + visibleRange.length >= attributedString.length - 安全保护:
visibleRange.length == 0时 break
3.4 页面与位置互转
Location → Page Number(RDEPUBTextBook.pageNumber(for:resolver:bookIdentifier:)):
- 有 fragment 时:用
fragmentOffsets查找字符偏移,再定位到对应页面 - 无 fragment 时:用
navigationProgression(progression 和 lastProgression 的中点)计算字符偏移 - 遍历 pages 找到
pageStartOffset <= offset < pageEndOffset的页面
Page Number → Location(RDEPUBTextBook.location(forPageNumber:bookIdentifier:)):
- 通过 page number 查找
RDEPUBTextPage - 计算
progression = pageStartOffset / totalCharacters - 计算
lastProgression = pageEndOffset / totalCharacters - 构建
RDEPUBLocation(href + progression + lastProgression)
3.5 标注定位与恢复
textReflowable 路径不复用 WebView 的 DOM range,而使用章节字符偏移作为精确锚点:
{
"kind": "text-offset",
"href": "chapter.xhtml",
"start": 1234,
"end": 1260
}
RDEPUBTextContentView保留当前RDEPUBTextPage,从page.pageStartOffset将页内UITextView.selectedRange映射为章节全局start/end- 生成
RDEPUBSelection后回传RDEPUBReaderController,共享层继续负责创建、去重、持久化、列表和跳转 - 页面重绘时,
RDEPUBTextContentView仅消费kind == "text-offset"的 rangeInfo,计算当前页[pageStartOffset, pageEndOffset]与标注[start, end)的重叠 highlight样式叠加.backgroundColor,underline样式叠加.underlineStyle和.underlineColor- 字号、行高、主题变化导致重新分页后,标注仍以章节全局字符偏移恢复到新的页面切片
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 渲染配置
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),影响有效页面尺寸 - 主题色:通过
RDEPUBReaderTheme的contentTextColor/contentBackgroundColor传入 - 渲染引擎:
RDEPUBTextRenderingEngine,当前仅.dtCoreText
8. 性能特征
- 全文渲染:每章一次性渲染为完整的 NSMutableAttributedString,无分块/懒加载
- 内存:
RDEPUBTextBook同时持有 chapters(含完整 attributedContent)和 pages(含子串切片),存在一定程度的内存重复 - 后台执行:
build方法在DispatchQueue.global(qos: .userInitiated)执行,不阻塞主线程 - 搜索性能:线性扫描每章的
attributedContent.string,无索引,搜索时间与总文本量线性相关 - 分页缓存:
RDEPUBTextBookCache支持磁盘缓存分页结果,字号/行高变化时优先从缓存恢复 - 性能采样:
RDEPUBTextPerformanceSampler记录每章的渲染和分页耗时,支持缓存命中率统计
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包含预期的文本内容 - 搜索为大小写不敏感的线性扫描