diff --git a/Doc/WXRead剩余问题修复计划.md b/Doc/WXRead剩余问题修复计划.md
new file mode 100644
index 0000000..4d1011c
--- /dev/null
+++ b/Doc/WXRead剩余问题修复计划.md
@@ -0,0 +1,323 @@
+# WXRead 剩余问题修复计划
+
+> 基于 [架构对比分析_WXRead_vs_ReadViewSDK.md](/Users/shen/Work/Code/ReadViewSDK/Doc/架构对比分析_WXRead_vs_ReadViewSDK.md) 的 2026-05-24 核查结果整理。
+> 目标:把当前已完成的 CoreText 页面直绘继续收口到更接近 WXRead 的页面级排版、命中、装饰和位置模型。
+
+---
+
+## 目标概览
+
+当前已经完成的部分:
+
+- 文本页已经切到页面级 CoreText 直绘。
+- 分页器已经切到 `RDEPUBTextLayouter + rd_paginatedFrames`。
+- 4 级语义断页、`avoidPageBreakInside`、分页缓存、5 层 CSS、`` 外部 CSS 内联都已经落地。
+
+当前仍需修复的核心问题:
+
+1. 选区与命中仍依赖 `UITextView` 代理层,没有完全走 CoreText 页面几何。
+2. 高亮、下划线、搜索命中仍主要靠 attributedString 属性,不是页面级装饰绘制。
+3. `RDEPUBSelectionOverlayView` 还是半成品,未形成可用闭环。
+4. inline attachment 虽已大幅收敛,但仍需要对问题书样做专项验书。
+5. 位置模型仍是 `href + progression`,不是 WXRead 的字符级锚点。
+6. 章节数据模型仍然分散,没有形成 `WRChapterData` 式内聚模型。
+
+---
+
+## 优先级
+
+### P1:当前渲染链必须补齐
+
+- CoreText 原生命中测试与选区模型
+- 页面级装饰绘制层
+- `RDEPUBSelectionOverlayView` 闭环
+- inline attachment 验书与细节修正
+
+### P2:精度与架构内聚
+
+- 字符级位置模型
+- 章节数据模型内聚
+
+### P3:外围能力
+
+- 翻页 crash patch
+- 多栏
+- 字体系统
+- TTS / DRM / Pencil 等
+
+---
+
+## Phase 1:CoreText 命中与选区收口
+
+### 目标
+
+去掉文本页对 `UITextView` 选区代理的核心依赖,改成和 WXRead 一样基于页面 `layoutFrame` / `line` / `run` 的命中测试与选区计算。
+
+### 范围
+
+- `RDEPUBTextContentView`
+- `RDEPUBSelectionOverlayView`
+- 新增页面几何辅助对象或工具类型
+
+### 主要工作
+
+1. 基于当前页 `DTCoreTextLayoutFrame` 建立字符命中入口。
+2. 实现单点命中:`CGPoint -> character index`。
+3. 实现拖拽扩选:起点 index、终点 index、range 归一化。
+4. 输出选区矩形集合:用于高亮、手柄、菜单定位。
+5. 让 `RDEPUBSelectionOverlayView` 直接消费 CoreText 选区矩形,而不是依赖 `UITextView`。
+6. 保留 `UITextView` 仅作为兜底或临时兼容层,最终目标是让它不再决定正文选区几何。
+
+### 产出
+
+- 页面级命中测试工具
+- 页面级选区 range 计算逻辑
+- 页面级选区 rect 计算逻辑
+- `RDEPUBSelectionOverlayView` 可实际工作
+
+### 验收标准
+
+- 长按正文任意位置,选区起点与落点稳定。
+- 拖动选区不会出现明显错位、跳行、截断。
+- 脚注图标、inline image、跨行高亮附近仍能稳定选中相邻文字。
+- 不再依赖 `UITextView.selectedRange` 作为主选区真值。
+
+### 风险
+
+- DTCoreText 暴露的行/run 几何可能和系统 `UITextView` 行为不完全一致。
+- inline attachment 附近的 index 映射需要特别小心。
+
+---
+
+## Phase 2:页面级装饰绘制层
+
+### 目标
+
+把高亮、下划线、搜索命中从 attributedString 注入迁到页面级 CGContext 绘制,和 WXRead 的页面装饰层一致。
+
+### 范围
+
+- `RDEPUBDirectCoreTextPageView`
+- `RDEPUBTextContentView`
+- 可能新增 `RDEPUBPageDecorationRenderer`
+
+### 主要工作
+
+1. 将高亮、下划线、搜索命中从“内容属性”改成“页面装饰数据”。
+2. 基于当前页 range 计算每种装饰的 rect 集合。
+3. 在页面 `draw(_:)` 或 overlay 中按层次绘制:
+ - 搜索命中
+ - 高亮背景
+ - 下划线
+ - 选区高亮
+4. 统一颜色、透明度、绘制顺序,避免正文颜色污染。
+
+### 产出
+
+- 页面装饰几何模型
+- 页面装饰绘制实现
+- attributedString 注入逻辑的瘦身或移除
+
+### 验收标准
+
+- 搜索高亮不再改变底层排版结果。
+- 高亮和下划线不再依赖正文属性重排。
+- 同一页同时存在搜索命中、标注、选区时,层次稳定、不互相遮挡异常。
+
+### 风险
+
+- 需要处理跨行 range 拆分。
+- 需要处理 attachment、段前段后间距、最后一行空白的 rect 裁剪。
+
+---
+
+## Phase 3:inline attachment 专项验书与收敛
+
+### 目标
+
+继续把 inline image、脚注图标、特殊图片在排版指标和页面显示上收紧到更接近 WXRead。
+
+### 范围
+
+- `RDEPUBTextRendererSupport.normalizeAttachmentLayoutForWXRead`
+- `RDEPUBTextContentView.normalizeInlineAttachments`
+- 问题 EPUB 样本专项验证
+
+### 主要工作
+
+1. 建立 attachment 分类表:
+ - 脚注图标
+ - 普通 inline image
+ - block image
+ - cover image
+2. 逐类校正:
+ - `displaySize`
+ - `verticalAlignment`
+ - 基线占位
+ - 页内/跨页表现
+3. 对问题书样做页对页比对,记录剩余差异。
+4. 去掉重复、冲突的 attachment 二次归一化逻辑,保证“同一类 attachment 只走一套规则”。
+
+### 产出
+
+- attachment 规则矩阵
+- 书样问题清单
+- 附件归一化逻辑收口
+
+### 验收标准
+
+- `.qqreader-footnote` 与正文基线对齐稳定。
+- 普通内容图片不再出现页内漂移、倒置、重复绘制、页尾大空白。
+- 同一 EPUB 在分页和显示阶段不会再出现 attachment 指标不一致。
+
+### 风险
+
+- 不同 EPUB 资源命名习惯不同,分类可能需要结合 class、src、style 多维判断。
+
+---
+
+## Phase 4:字符级位置模型
+
+### 目标
+
+从当前 `href + progression` 提升到更接近 WXRead 的字符级位置锚点,减少重排后的标注漂移。
+
+### 范围
+
+- `RDEPUBLocation`
+- `RDEPUBTextBook`
+- `RDEPUBHighlight` / `RDEPUBBookmark`
+- 选区序列化与恢复
+
+### 主要工作
+
+1. 设计新的文本位置载体:
+ - 章节索引
+ - 章节内字符偏移
+ - 可选 fragment / 行列辅助信息
+2. 建立双向转换:
+ - 页面 → 字符 range
+ - 字符 range → 页面
+3. 保留对旧 `progression` 数据的兼容读取。
+4. 更新标注恢复、搜索跳转、书签跳转逻辑。
+
+### 产出
+
+- 新位置模型
+- 兼容迁移策略
+- 标注恢复精度提升
+
+### 验收标准
+
+- 改字号、改行距后重新分页,已有标注仍能落回正确正文范围。
+- 多次翻页、重建 `RDEPUBTextBook` 后,范围恢复稳定。
+
+### 风险
+
+- 旧数据兼容与迁移策略需要谨慎。
+- attachment、fragment marker、语义 marker 会影响纯字符 offset 映射。
+
+---
+
+## Phase 5:章节数据模型内聚
+
+### 目标
+
+把分散在 `RDEPUBTextChapter`、`RDEPUBHighlight`、目录、分页诊断里的数据逐步收敛成更像 `WRChapterData` 的一体化章节模型。
+
+### 范围
+
+- `RDEPUBTextChapter`
+- `RDEPUBTextBook`
+- 标注、目录、分页诊断承载方式
+
+### 主要工作
+
+1. 定义章节聚合对象,统一承载:
+ - attributedContent
+ - page frames / page ranges
+ - fragment offsets
+ - highlights / bookmarks
+ - outline / TOC mapping
+ - pagination diagnostics
+2. 让控制器层从“自己拼数据”改成“消费章节聚合对象”。
+3. 为后续位置模型、装饰绘制、选区恢复提供统一数据入口。
+
+### 产出
+
+- 新章节聚合模型
+- 控制器简化
+- 数据流收口
+
+### 验收标准
+
+- 阅读器控制器中的章节相关派生逻辑明显减少。
+- 标注、分页、目录不再分散在多个不一致的数据结构里。
+
+---
+
+## Phase 6:外围能力补齐
+
+### 目标
+
+处理不影响当前正文质量但和 WXRead 存在差距的外围能力。
+
+### 包含项
+
+- `UIPageViewController` crash patch
+- 多栏排版
+- 字体动态加载
+- TTS / DRM / Pencil / 预加载
+
+### 建议
+
+- 这批能力不要和当前正文排版修复混在一个阶段。
+- 先把正文页面链路闭环,再独立立项。
+
+---
+
+## 执行顺序建议
+
+1. Phase 1:CoreText 命中与选区收口
+2. Phase 2:页面级装饰绘制层
+3. Phase 3:inline attachment 专项验书与收敛
+4. Phase 4:字符级位置模型
+5. Phase 5:章节数据模型内聚
+6. Phase 6:外围能力补齐
+
+原因:
+
+- Phase 1 和 Phase 2 会直接决定“页面几何模型”是否真正闭环。
+- Phase 3 需要建立在前两步的页面几何一致性之上,否则 attachment 还会被多套逻辑干扰。
+- Phase 4 和 Phase 5 更偏位置与架构内聚,适合在页面几何稳定后推进。
+
+---
+
+## 每阶段验证方式
+
+### 功能验证
+
+- 问题 EPUB 书样页对页比对
+- 翻页前后位置恢复
+- 高亮、搜索、选区、脚注图标组合场景验证
+
+### 构建验证
+
+- `RDReaderView` scheme 构建
+- `ReadViewDemo` scheme 构建
+
+### 回归关注点
+
+- 页尾空白是否回归
+- 图片是否重复绘制
+- 内容是否倒置
+- 搜索高亮是否影响分页
+- 标注恢复是否漂移
+
+---
+
+## 建议的文档联动
+
+- 本文档用于排期和实施顺序。
+- [架构对比分析_WXRead_vs_ReadViewSDK.md](/Users/shen/Work/Code/ReadViewSDK/Doc/架构对比分析_WXRead_vs_ReadViewSDK.md) 继续作为“差距现状”文档。
+- 每完成一个阶段,回写一份“已完成/剩余风险”更新到对比分析文档中。
diff --git a/Doc/架构对比分析_WXRead_vs_ReadViewSDK.md b/Doc/架构对比分析_WXRead_vs_ReadViewSDK.md
new file mode 100644
index 0000000..de36e44
--- /dev/null
+++ b/Doc/架构对比分析_WXRead_vs_ReadViewSDK.md
@@ -0,0 +1,249 @@
+# 架构对比分析:微信读书 vs ReadViewSDK
+
+> 基于微信读书 v10.0.3 (Build 79) 逆向工程,对比当前 ReadViewSDK 项目架构差距。
+> 分析日期: 2026-05-23
+> 当前代码核查日期: 2026-05-24
+> 参考文档: `Doc/WXRead/decompiled-doc.md`、`Doc/WXRead/resources-doc.md`、`Doc/WXRead/微信读书EPUB阅读器实现架构.md`
+
+> 标注说明:
+> - `✅ 已实现`:当前代码已经按接近 WXRead 的路径落地
+> - `⚠️ 有差异/有问题`:已经实现一部分,但仍与 WXRead 有结构差异,或者当前实现仍有已知问题
+> - `❌ 未实现`:当前代码中仍缺失
+
+---
+
+## 核心结论
+
+微信读书使用**双渲染引擎架构**(Path A: 原生 CoreText / Path B: WebView)。ReadViewSDK 当前也已经是 CoreText 文本页 + WebView 双路径,但文本页仍保留 `UITextView` 作为选区代理层,因此“页面绘制”已接近 WXRead,“选区/装饰/命中测试”仍未完全收口到同一套 CoreText 页面模型。
+
+## 核查摘要
+
+### ✅ 已按 WXRead 路径落地
+
+- 文本页已经改成页面级 CoreText 直绘,不再是单纯 `UITextView` 展示。
+- `RDEPUBTextLayouter` 已经接入 `RDEPUBTextBookBuilder` / `RDPlainTextBookBuilder`。
+- 4 级语义断页、`avoidPageBreakInside` 页尾回退、分页缓存都已经落地。
+- 5 层 CSS 级联和 EPUB `` 外部样式表内联都已经落地。
+
+### ❌ 仍未实现
+
+- CoreText 原生 hit test / 选区命中。
+- 独立 CGContext 标注绘制层与搜索高亮层。
+- WXRead 式字符级位置模型(`fileIndex + row + column`)。
+- `WRChapterData` 一体化章节数据模型。
+- UIPageViewController crash patch、多栏、字体动态加载、TTS/DRM 等外围能力。
+
+### ⚠️ 已实现但仍有问题/差异
+
+- 当前文本页虽然已直绘,但仍依赖 `UITextView` 代理选区,页面几何模型没有完全统一。
+- `RDEPUBSelectionOverlayView` 仍是半成品,尚未形成完整可用的 CoreText 选区/装饰 overlay。
+- 高亮、下划线、搜索命中仍主要靠 attributedString 属性注入,而不是 WXRead 那种页面装饰绘制。
+- inline attachment 已按 WXRead 路径收敛,但仍需要继续结合问题书样验证特殊图片、脚注图标和页内基线细节。
+
+---
+
+## 1. 渲染路径对比
+
+| 维度 | 微信读书 | ReadViewSDK | 核查 |
+|------|---------|-------------|------|
+| EPUB 文本渲染 | `WRPageView.drawRect:` → `CTFrameDraw` 到 CGContext | `RDEPUBTextContentView` → `RDEPUBDirectCoreTextPageView.draw(_:)` + `DTCoreTextLayoutFrame.draw(in:)` 页面级直绘 | ✅ 已实现 |
+| WebView 路径 | WKWebView(公众号/文集文章) | WKWebView(interactive/fixed-layout EPUB) | ✅ 已实现 |
+| 文本选区 | `CTLineGetStringIndexForPosition` 坐标级 hit test | 仍主要依赖 `UITextView` 代理选区;`RDEPUBSelectionOverlayView` 仍是半成品 | ⚠️ 有差异/有问题 |
+| 标注绘制 | `CTFrame` 层叠加绘制(CGContext) | 高亮/下划线仍主要通过 attributedString 属性注入,不是独立 CGContext 装饰层 | ⚠️ 有差异/有问题 |
+| 搜索高亮 | `WRCoreTextLayoutFrame.highlightSearchResults:` 直接绘制 | 仍是 attributedString 注入临时高亮属性 | ⚠️ 有差异/有问题 |
+
+**核查结论:** 这段原分析已经过时。当前文本页的底层排版和绘制已经切到 CoreText 页面直绘,但“命中测试、标注装饰层、搜索高亮层”还没有完全像 WXRead 那样归并到同一套页面级 CoreText 几何模型。
+
+---
+
+## 2. 分页引擎对比
+
+| 维度 | 微信读书 | ReadViewSDK | 核查 |
+|------|---------|-------------|------|
+| 分页核心 | `WRCoreTextLayouter` + `WRCoreTextLayoutFrame` | `RDEPUBTextLayouter` + `RDEPUBTextLayoutFrame`,并已接入 `RDEPUBTextBookBuilder` / `RDPlainTextBookBuilder` | ✅ 已实现 |
+| 断页策略 | 4 级语义断页:语义边界 > 附件边界 > 块边界 > 帧限制 | `rd_paginatedFrames` 已启用 4 级语义断页,并改为与显示层一致的 `DTCoreTextLayoutFrame.visibleStringRange` | ✅ 已实现 |
+| 分页配置 | `WRCoreTextLayoutConfig`(列数、栏间距、孤行控制、连字符) | 已有 `RDEPUBTextLayoutConfig`,含 `avoidOrphans`/`avoidWidows`/`avoidPageBreakInsideEnabled`/`imageMaxHeightRatio`,但多栏等能力仍缺 | ⚠️ 有差异/有问题 |
+| 缓存 | `WRChapterPageCount.currentCacheKeyWithBookId:` 按排版设置缓存 | 已有 `RDEPUBTextBookCache`,按书籍 + 排版设置缓存每章分页结果 | ✅ 已实现 |
+| 跨页避让 | `avoidPageBreakInsideByRemovingLastLinesIfNeeded` — CSS `avoid-page-break-inside` 实现 | 已实现页尾最多回退 3 行的 WXRead 式 `avoidPageBreakInside` 处理 | ✅ 已实现 |
+
+**核查结论:** 这段原判断已经过时。`RDEPUBTextBookBuilder` 和 `RDPlainTextBookBuilder` 已经走 `rd_paginatedFrames(size:fragmentOffsets:)`,不再是“能力已写但未接入”的状态。
+
+---
+
+## 3. CSS 处理对比
+
+| 维度 | 微信读书 | ReadViewSDK | 核查 |
+|------|---------|-------------|------|
+| CSS 级联层级 | 5 级:`default.css < replace.css < dark.css < EPUB 内嵌 < 用户设置` | `RDEPUBTextStyleSheetPackage` 已实际生成 5 层:`default/replace/dark/epub/user`,并分别注入 | ✅ 已实现 |
+| EPUB `` CSS | `DTHTMLAttributedStringBuilder` 解析前级联合并 `` 引用的 CSS | 已实现 `` 抽取、内联、URL 重写与资源诊断 | ✅ 已实现 |
+| 自定义 CSS 属性 | `wr-vertical-center-style`(图片垂直居中)、`weread-page-relate`(跨页关联)、`DTPageBreakInsideAvoid`(断页避让) | 已部分支持:`pageRelate`、`avoidPageBreakInside`、attachment placement 推断;但并非完整 WeRead 属性集 | ⚠️ 有差异/有问题 |
+| CSS 层级模型 | 已定义 `RDEPUBTextStyleSheetPackage`/`RDEPUBTextStyleSheetLayer`(5 种 kind),但未完整使用 | 已实际使用;但和 WXRead 资源体系仍不是同一批 CSS 文件/规则 | ⚠️ 有差异/有问题 |
+
+**核查结论:** “未处理 `` 外部 CSS” 这一条已经不成立;但外部 CSS 即使已被内联,规则兼容度和 WXRead 自带 replace/default 体系仍可能存在差异。
+
+---
+
+## 4. 位置模型对比
+
+| 维度 | 微信读书 | ReadViewSDK |
+|------|---------|-------------|
+| 位置模型 | `WREpubPositionConverter` — `(fileIndex, row, column)` 三元组 | `RDEPUBLocation` — `href + progression` 浮点值 |
+| 精度 | 字符级精度 | 章节内比例精度 (0..1) |
+| 索引构建 | `initIndices` 预建全书字符索引表 | 无索引,每次实时计算 |
+| 双向转换 | `stringRangeFromFileRange:` / `fileIndexForCharacterPosition:` / `localOffsetInFileAtIndex:forGlobalPosition:` | `RDEPUBTextBook.pageNumber(for:)` / `location(forPageNumber:)` |
+
+**影响分析:** progression 是 0..1 浮点值,在跨字号/行距重排后能保持大致位置,但无法精确到字符。微信读书的三元组提供字符级精度,适合精确标注恢复。当前 progression 模式对大部分场景够用,但在密集标注场景下可能出现标注漂移。
+
+**核查:** `❌ 未实现`。当前位置模型仍然不是 WXRead 的字符级三元组。
+
+---
+
+## 5. 章节数据模型对比
+
+| 维度 | 微信读书 | ReadViewSDK |
+|------|---------|-------------|
+| 核心模型 | `WRChapterData` — 一个对象承载渲染结果 + 标注 + 目录 + 免费试读截断 | `RDEPUBTextChapter` + `RDEPUBHighlight` + `EPUBChapterInfo` 分散在多处 |
+| 标注内聚 | `addHighlightInRange:` / `addUnderLineToAttributedString:` 直接在 chapterData 上操作 | 标注在 `RDEPUBReaderController` 层管理,与章节数据分离 |
+| 目录生成 | `WRChapterData.generateOutlineContents` — 从渲染结果反向提取目录 | 目录从 NCX/Nav 独立解析,不与渲染数据关联 |
+
+**核查:** `❌ 未实现`。章节数据模型仍未收敛成 WXRead 的 `WRChapterData` 一体化模式。
+
+---
+
+## 6. 翻页控制器对比
+
+| 维度 | 微信读书 | ReadViewSDK |
+|------|---------|-------------|
+| 翻页动画 | 4 种:curl / slide / fade / none | 4 种:pageCurl / horizontalScroll / verticalScroll / horizontalCoverScroll |
+| UIPageViewController 崩溃修复 | 3 个 patch 方法处理已知 Apple bug(导航方向崩溃、子 VC 丢失、快速翻页动画故障) | 无 crash patch |
+| 导航方向检测 | `detectNavigationDirectionCrashWithPageViewController:` | 无 |
+
+**核查:** `❌ 未实现`。这一块目前仍没有 WXRead 那类 page controller crash patch。
+
+---
+
+## 7. CSS 资源文件对比
+
+### 微信读书 CSS 文件(`resources/css/`)
+
+| 文件 | 职责 | ReadViewSDK 对应 |
+|------|------|-----------------|
+| `default.css` | 基础 HTML 标签样式(font 18px, line-height 30px) | `RDEPUBStyleSheetBuilder` 注入的基础 CSS |
+| `replace.css` | EPUB 书版排版(6 级标题、首字下沉、引用、图片居中) | `RDEPUBTextRendererSupport.replaceCSS()`(部分覆盖) |
+| `dark.css` | 暗黑主题(纯黑背景 + 灰色文字) | `RDEPUBReaderTheme` 的 CSS 计算属性 |
+| `replaceForLatinLanguageBook.css` | 拉丁语言字体 | 无对应 |
+| `replaceForMPChapter.css` | 公众号文章专用 | 无对应(非 EPUB 场景) |
+
+**核查:**
+- `default.css` / `dark.css` / `replace.css` 思路已落地,但规则集合仍不是 WXRead 原文件的完整镜像,属于 `⚠️ 有差异/有问题`。
+- `replaceForLatinLanguageBook.css` 仍是 `❌ 未实现`。
+- `replaceForMPChapter.css` 属于非 EPUB 场景,当前可视为 `❌ 未实现(暂不需要)`。
+
+### 微信读书 JS 文件(`resources/js/`)
+
+| 文件 | 职责 | ReadViewSDK 对应 |
+|------|------|-----------------|
+| `weread-highlighter.js` | Rangy 高亮引擎,5 种样式 | `epub-bridge.js` + `RDEPUBWebView+JavaScriptBridge.swift` |
+| `rangy-core.js` / `rangy-highlighter.js` / `rangy-textrange.js` | Rangy 选区/高亮库 | 无(CoreText 路径不需要) |
+| `Readability.js` | HTML 正文提取 | 无(EPUB spine 直接提供内容) |
+| `cssInjector.js` | 动态 CSS 注入/移除 | 无(静态注入) |
+| `WeReadApi.js` | JS-Native 桥接 | `epub-bridge.js`(`window.RDReaderBridge`) |
+
+**核查:** 这一节整体仍然成立。WebView 路径没有按 WXRead 的 JS 资源体系一一复刻,属于 `⚠️ 有差异/有问题`,但对纯 EPUB CoreText 路径不是当前主阻塞。
+
+---
+
+## 8. 缺失能力对比
+
+| 能力 | 微信读书 | ReadViewSDK | 核查 | 当前优先级 |
+|------|---------|-------------|------|-----------|
+| CoreText 直接绘制 | `WRPageView.drawRect:` + `CTFrameDraw` | 已是页面级 CoreText 直绘,但选区/装饰层还未完全 CoreText 化 | ⚠️ 有差异/有问题 | P1 |
+| 分页引擎集成 | `WRCoreTextLayouter` 完整集成 | 已集成到 builder | ✅ 已实现 | - |
+| CSS `` 处理 | 级联合并外部 CSS | 已处理 | ✅ 已实现 | - |
+| 5 层 CSS 级联 | default/replace/dark/epub/user | 已生成并注入 | ✅ 已实现 | - |
+| 分页缓存 | `WRChapterPageCount.currentCacheKeyWithBookId:` | 已有磁盘缓存 | ✅ 已实现 | - |
+| 字符级位置精度 | `WREpubPositionConverter` | progression 浮点值 | ❌ 未实现 | P2 |
+| 字体动态加载 | CDN + `WRFontsManager` | 仅系统字体 | ❌ 未实现 | P1(延迟) |
+| 繁简转换 | `WRReaderCht2sManager` | 无 | ❌ 未实现 | P3 |
+| TTS 朗读 | wxtts 引擎 + 位置标记 | 无 | ❌ 未实现 | P3 |
+| DRM | 逐章加密 + Keychain 密钥 | 无 | ❌ 未实现 | P3 |
+| 预加载 | `WRForecastUtils` 预测 + 后台下载 | 无 | ❌ 未实现 | P3 |
+| Apple Pencil | PencilKit + COS 云同步 | 无 | ❌ 未实现 | P3 |
+| 多栏排版 | `WRCoreTextLayoutConfig.columns/columnGap` | 无 | ❌ 未实现 | P3 |
+
+---
+
+## 9. 优先级排序与 Roadmap 映射
+
+| 优先级 | 问题 | 核查 | 对应 Phase / 建议 | 说明 |
+|--------|------|------|-------------------|------|
+| ~~P0~~ | 集成 `RDEPUBTextLayouter` 到 `RDEPUBTextBookBuilder` | ✅ 已完成 | 已落地 | 不再是待办 |
+| ~~P0~~ | CSS `` 外部样式表处理 | ✅ 已完成 | 已落地 | 不再是待办 |
+| ~~P0~~ | 5 层 CSS 级联 | ✅ 已完成 | 已落地 | 不再是待办 |
+| ~~P0~~ | 分页缓存 | ✅ 已完成 | 已落地 | 不再是待办 |
+| **P1** | CoreText 原生选区 / hit test | ❌ 未实现 | 新增 phase | 当前仍主要依赖 `UITextView` |
+| **P1** | 标注/搜索独立 CGContext 装饰层 | ❌ 未实现 | 新增 phase | 当前仍用 attributedString 属性注入 |
+| **P1** | 页面几何与选区 overlay 收口 | ⚠️ 半成品 | 延续 page-geometry phase | `RDEPUBSelectionOverlayView` 仍未闭环 |
+| **P1** | inline attachment 细节对齐 | ⚠️ 已接近但仍需验书 | 延续 pagination/rendering phase | 尤其脚注图标、特殊 inline image |
+| **P1** | 字体系统 | ❌ 未实现 | 未来版本 | 内嵌固定字体集,延迟实现 |
+| **P2** | 位置精度提升 | ❌ 未实现 | 独立重构 | 字符级锚点 / 双向转换 |
+| **P2** | 章节数据模型内聚 | ❌ 未实现 | 独立重构 | WRChapterData 模式 |
+| **P3** | TTS / DRM / Pencil / 多栏 | ❌ 未实现 | 独立功能模块 | 后续迭代 |
+
+---
+
+## 10. 微信读书关键类职责速查
+
+| 类名 | 职责 | ReadViewSDK 对应 | 核查 |
+|------|------|-----------------|------|
+| `WRReaderViewController` | 阅读器主控制器(431 方法) | `RDEPUBReaderController` | ✅ 已有对应 |
+| `WRPageViewController` | 翻页控制器(含 3 个 crash patch) | `RDReaderView`(pageCurl 模式) | ⚠️ 有差异/无 crash patch |
+| `WRPageView` | CoreText 直接绘制页面 | `RDEPUBTextContentView` + `RDEPUBDirectCoreTextPageView` | ✅ 已实现 |
+| `WREpubParser` | EPUB 解析 | `RDEPUBParser` | ✅ 已有对应 |
+| `WREpubTypesetter` | HTML → NSAttributedString(CSS 级联) | `RDEPUBDTCoreTextRenderer` | ✅ 已有对应 |
+| `WRCoreTextLayouter` | CoreText 排版引擎 | `RDEPUBTextLayouter` | ✅ 已实现 |
+| `WRCoreTextLayoutFrame` | 排版帧(绘制/选区/搜索/装饰) | `RDEPUBTextLayoutFrame` | ⚠️ 绘制/分页接近,选区/装饰未闭环 |
+| `WRChapterData` | 章节数据模型(渲染+标注+目录一体) | `RDEPUBTextChapter` + `RDEPUBHighlight`(分散) | ❌ 未实现 |
+| `WRChapterPageCount` | 分页计算器(含缓存 key) | `RDEPUBTextBookBuilder` + `RDEPUBTextBookCache` | ✅ 已实现 |
+| `WREpubPositionConverter` | 字符级位置双向转换 | `RDEPUBTextBook.pageNumber(for:)`(精度较低) | ❌ 未实现 |
+| `WRBookmark` | 5 种标注类型统一模型 | `RDEPUBHighlight` + `RDEPUBBookmark` | ⚠️ 部分对应 |
+| `DTHTMLAttributedStringBuilder` | HTML DOM → NSAttributedString | 同(复用 DTCoreText) | ✅ 已实现 |
+
+---
+
+## 数据流对比
+
+### 微信读书
+
+```
+EPUB 文件
+ → WREpubParser.parse (container.xml → OPF → spine)
+ → WREpubTypesetter (CSS 级联: default < replace < dark < epub < user)
+ → DTHTMLAttributedStringBuilder (HTML → NSAttributedString)
+ → WRCoreTextLayouter (CTFramesetter → CTFrame)
+ → WRChapterPageCount (分页 NSRange)
+ → WRPageView.drawRect (CTFrameDraw → CGContext)
+ → 屏幕显示
+```
+
+### ReadViewSDK 当前(已按当前代码修正)
+
+```
+EPUB 文件
+ → RDEPUBParser.parse (container.xml → OPF → spine)
+ → readingProfile 分流:
+ textReflowable:
+ → RDEPUBDTCoreTextRenderer (DTHTMLAttributedStringBuilder + 5 层 CSS + CSS 内联)
+ → RDEPUBTextBookBuilder (rd_paginatedFrames + RDEPUBTextBookCache)
+ → RDEPUBTextContentView (页面级 CoreText 直绘 + UITextView 选区代理)
+ webInteractive:
+ → RDEPUBPaginator (离屏 WKWebView 分页)
+ → RDEPUBWebContentView (WKWebView 渲染)
+ webFixedLayout:
+ → RDEPUBWebContentView (WKWebView + fixed-layout template)
+```
+
+---
+
+*分析基础:微信读书 v10.0.3 (Build 79) 逆向工程文档*
+*产出:10 个维度对比、14 项差距识别、优先级排序*
diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift
index b8027d3..f88002c 100644
--- a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift
+++ b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift
@@ -21,6 +21,7 @@ public struct RDEPUBTextPage: Equatable {
public var chapterTitle: String
public var pageIndexInChapter: Int
public var totalPagesInChapter: Int
+ public var chapterContent: NSAttributedString
public var content: NSAttributedString
public var contentRange: NSRange
public var pageStartOffset: Int
@@ -95,11 +96,18 @@ public struct RDEPUBTextBook: Equatable {
public final class RDEPUBTextBookBuilder {
private let renderer: RDEPUBTextRenderer
+ private let cache: RDEPUBTextBookCache?
+ private let sampler: RDEPUBTextPerformanceSampler
+
public private(set) var lastBuildResourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic] = []
public private(set) var lastBuildPaginationDiagnostics: [RDEPUBTextChapterPaginationDiagnostic] = []
+ public private(set) var lastBuildPerformanceSamples: [RDEPUBTextPerformanceSample] = []
+ public private(set) var lastBuildCacheStats: (hits: Int, misses: Int) = (0, 0)
- public init(renderer: RDEPUBTextRenderer) {
+ public init(renderer: RDEPUBTextRenderer, cache: RDEPUBTextBookCache? = nil) {
self.renderer = renderer
+ self.cache = cache
+ self.sampler = RDEPUBTextPerformanceSampler()
}
public convenience init() {
@@ -139,6 +147,16 @@ public final class RDEPUBTextBookBuilder {
var flatPages: [RDEPUBTextPage] = []
lastBuildResourceDiagnostics = []
lastBuildPaginationDiagnostics = []
+ lastBuildPerformanceSamples = []
+ lastBuildCacheStats = (0, 0)
+ sampler.reset()
+
+ let buildStart = CFAbsoluteTimeGetCurrent()
+
+ // Cache lookup — WXRead style: load per-chapter page ranges
+ let bookID = publication.metadata.identifier ?? publication.metadata.title
+ let cacheKey = makeCacheKey(bookID: bookID, pageSize: pageSize, style: style)
+ let cachedPagination = cacheKey.flatMap { cache?.load(key: $0) }
for (spineIndex, item) in publication.spine.enumerated() where item.linear {
guard item.mediaType.contains("html") || item.mediaType.contains("xhtml"),
@@ -155,7 +173,11 @@ public final class RDEPUBTextBookBuilder {
style: style,
resourceResolver: publication.resourceResolver
)
+
+ let renderStart = CFAbsoluteTimeGetCurrent()
let rendered = try renderer.renderChapter(request: request)
+ let renderDuration = CFAbsoluteTimeGetCurrent() - renderStart
+
lastBuildResourceDiagnostics.append(contentsOf: rendered.resourceDiagnostics)
let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -171,7 +193,10 @@ public final class RDEPUBTextBookBuilder {
let chapterIndex = chapters.count
let content = NSMutableAttributedString(attributedString: rendered.attributedString)
+
+ let paginateStart = CFAbsoluteTimeGetCurrent()
let layoutFrames: [RDEPUBTextLayoutFrame]
+ let isCacheHit: Bool
if isAttachmentOnlyCoverChapter(item: item, content: content, plainText: plainText) {
layoutFrames = [
RDEPUBTextLayoutFrame(
@@ -191,11 +216,33 @@ public final class RDEPUBTextBookBuilder {
]
)
]
+ isCacheHit = false
+ } else if let cached = cachedPagination?[item.href] {
+ // Cache hit: use cached page ranges, skip rd_paginatedFrames
+ layoutFrames = cached.pageRanges.enumerated().map { idx, range in
+ let breakReason = idx < cached.breakReasons.count ? cached.breakReasons[idx] : .frameLimit
+ return RDEPUBTextLayoutFrame(
+ contentRange: range,
+ breakReason: breakReason,
+ blockRange: nil,
+ attachmentRanges: [],
+ attachmentKinds: [],
+ blockKinds: [],
+ semanticHints: cached.semanticHints,
+ attachmentPlacements: [],
+ trailingFragmentID: nil,
+ diagnostics: ["page break: \(breakReason.rawValue)", "page range: \(NSStringFromRange(range))", "source: cache hit"]
+ )
+ }
+ isCacheHit = true
} else {
layoutFrames = content.length > 0
? content.rd_paginatedFrames(size: pageSize, fragmentOffsets: rendered.fragmentOffsets)
: []
+ isCacheHit = false
}
+ let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart
+
let effectiveFrames = layoutFrames.isEmpty && content.length > 0
? [
RDEPUBTextLayoutFrame(
@@ -219,6 +266,21 @@ public final class RDEPUBTextBookBuilder {
print("[EPUB][Cover] paginated href=\(item.href) pages=\(effectiveFrames.count) firstRange=\(effectiveFrames.first.map { NSStringFromRange($0.contentRange) } ?? "none")")
}
+ sampler.record(RDEPUBTextPerformanceSample(
+ chapterHref: item.href,
+ renderDuration: renderDuration,
+ paginateDuration: paginateDuration,
+ pageCount: effectiveFrames.count,
+ attributedStringLength: content.length,
+ cacheHit: isCacheHit
+ ))
+ if isCacheHit {
+ lastBuildCacheStats.hits += 1
+ } else {
+ lastBuildCacheStats.misses += 1
+ }
+
+ let chapterAttributedContent = content.copy() as! NSAttributedString
let pages = effectiveFrames.enumerated().map { localPageIndex, frame in
let range = frame.contentRange
return RDEPUBTextPage(
@@ -229,6 +291,7 @@ public final class RDEPUBTextBookBuilder {
chapterTitle: chapterTitle,
pageIndexInChapter: localPageIndex,
totalPagesInChapter: effectiveFrames.count,
+ chapterContent: chapterAttributedContent,
content: content.attributedSubstring(from: range),
contentRange: range,
pageStartOffset: range.location,
@@ -243,7 +306,7 @@ public final class RDEPUBTextBookBuilder {
spineIndex: spineIndex,
href: item.href,
title: chapterTitle,
- attributedContent: content.copy() as! NSAttributedString,
+ attributedContent: chapterAttributedContent,
fragmentOffsets: rendered.fragmentOffsets,
pageBreakReasons: pages.map(\.metadata.breakReason),
pages: pages
@@ -270,7 +333,29 @@ public final class RDEPUBTextBookBuilder {
flatPages.append(contentsOf: pages)
}
- return RDEPUBTextBook(chapters: chapters, pages: flatPages)
+ let book = RDEPUBTextBook(chapters: chapters, pages: flatPages)
+
+ sampler.totalBuildDuration = CFAbsoluteTimeGetCurrent() - buildStart
+
+ if let cacheKey {
+ let paginationCache = chapters.map { chapter in
+ let pageRanges = chapter.pages.map(\.contentRange)
+ let breakReasons = chapter.pages.map(\.metadata.breakReason)
+ let semanticHints = Array(Set(chapter.pages.flatMap(\.metadata.semanticHints)))
+ return RDEPUBTextChapterPaginationCache(
+ href: chapter.href,
+ pageRanges: pageRanges,
+ breakReasons: breakReasons,
+ semanticHints: semanticHints
+ )
+ }
+ cache?.save(paginationCache, key: cacheKey)
+ }
+
+ print(sampler.summary())
+ lastBuildPerformanceSamples = sampler.samples
+
+ return book
}
private func resolvedChapterTitle(for item: RDEPUBSpineItem, toc: [EPUBTableOfContentsItem]) -> String {
@@ -345,4 +430,19 @@ public final class RDEPUBTextBookBuilder {
}
}
}
+
+ private func makeCacheKey(
+ bookID: String,
+ pageSize: CGSize,
+ style: RDEPUBTextRenderStyle
+ ) -> String? {
+ guard let cache else { return nil }
+ return cache.cacheKey(
+ bookID: bookID,
+ fontSize: style.font.pointSize,
+ lineHeightMultiple: style.lineSpacing,
+ contentInsets: .zero,
+ pageSize: pageSize
+ )
+ }
}
diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookCache.swift b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookCache.swift
new file mode 100644
index 0000000..de14ec0
--- /dev/null
+++ b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookCache.swift
@@ -0,0 +1,203 @@
+import Foundation
+import CryptoKit
+
+// MARK: - Pagination Cache (WXRead-style: page ranges only, no attributedString)
+
+/// Per-chapter pagination metadata cached to disk.
+/// Mirrors WRChapterPageCount's caching: stores page NSRange + break reasons,
+/// NOT the full attributed string. Builder re-renders HTML on cache hit but
+/// skips the rd_paginatedFrames pagination step.
+public struct RDEPUBTextChapterPaginationCache: Equatable {
+ public var href: String
+ public var pageRanges: [NSRange]
+ public var breakReasons: [RDEPUBTextPageBreakReason]
+ public var semanticHints: [RDEPUBTextSemanticHint]
+
+ public init(
+ href: String,
+ pageRanges: [NSRange],
+ breakReasons: [RDEPUBTextPageBreakReason],
+ semanticHints: [RDEPUBTextSemanticHint]
+ ) {
+ self.href = href
+ self.pageRanges = pageRanges
+ self.breakReasons = breakReasons
+ self.semanticHints = semanticHints
+ }
+}
+
+// MARK: - NSCoding Archive (simple: strings + ints only)
+
+final class PaginationCacheArchive: NSObject, NSSecureCoding {
+ static var supportsSecureCoding: Bool { true }
+
+ let chapters: [ChapterPaginationArchive]
+
+ init(chapters: [ChapterPaginationArchive]) {
+ self.chapters = chapters
+ }
+
+ func encode(with coder: NSCoder) {
+ coder.encode(chapters, forKey: "chapters")
+ }
+
+ required init?(coder: NSCoder) {
+ guard let chapters = coder.decodeObject(of: [NSArray.self, ChapterPaginationArchive.self], forKey: "chapters") as? [ChapterPaginationArchive] else { return nil }
+ self.chapters = chapters
+ }
+}
+
+final class ChapterPaginationArchive: NSObject, NSSecureCoding {
+ static var supportsSecureCoding: Bool { true }
+ let href: String
+ let rangeLocations: [NSNumber]
+ let rangeLengths: [NSNumber]
+ let breakReasons: [String]
+ let semanticHints: [String]
+
+ init(from cache: RDEPUBTextChapterPaginationCache) {
+ self.href = cache.href
+ self.rangeLocations = cache.pageRanges.map { NSNumber(value: $0.location) }
+ self.rangeLengths = cache.pageRanges.map { NSNumber(value: $0.length) }
+ self.breakReasons = cache.breakReasons.map(\.rawValue)
+ self.semanticHints = cache.semanticHints.map(\.rawValue)
+ }
+
+ func encode(with coder: NSCoder) {
+ coder.encode(href, forKey: "href")
+ coder.encode(rangeLocations, forKey: "rangeLocations")
+ coder.encode(rangeLengths, forKey: "rangeLengths")
+ coder.encode(breakReasons, forKey: "breakReasons")
+ coder.encode(semanticHints, forKey: "semanticHints")
+ }
+
+ required init?(coder: NSCoder) {
+ guard let href = coder.decodeObject(of: NSString.self, forKey: "href") as String?,
+ let rangeLocations = coder.decodeObject(of: [NSArray.self, NSNumber.self], forKey: "rangeLocations") as? [NSNumber],
+ let rangeLengths = coder.decodeObject(of: [NSArray.self, NSNumber.self], forKey: "rangeLengths") as? [NSNumber],
+ let breakReasons = coder.decodeObject(of: [NSArray.self, NSString.self], forKey: "breakReasons") as? [String],
+ let semanticHints = coder.decodeObject(of: [NSArray.self, NSString.self], forKey: "semanticHints") as? [String] else {
+ return nil
+ }
+ self.href = href
+ self.rangeLocations = rangeLocations
+ self.rangeLengths = rangeLengths
+ self.breakReasons = breakReasons
+ self.semanticHints = semanticHints
+ }
+
+ func toCache() -> RDEPUBTextChapterPaginationCache {
+ let pageRanges = zip(rangeLocations, rangeLengths).map { loc, len in
+ NSRange(location: loc.intValue, length: len.intValue)
+ }
+ return RDEPUBTextChapterPaginationCache(
+ href: href,
+ pageRanges: pageRanges,
+ breakReasons: breakReasons.compactMap(RDEPUBTextPageBreakReason.init(rawValue:)),
+ semanticHints: semanticHints.compactMap(RDEPUBTextSemanticHint.init(rawValue:))
+ )
+ }
+}
+
+// MARK: - RDEPUBTextBookCache
+
+/// Disk-persistent cache for per-chapter pagination metadata.
+/// Follows WXRead's WRChapterPageCount caching pattern:
+/// cache key = bookID + layout settings, cache value = page NSRange per chapter.
+/// Attributed strings are NOT cached — builder re-renders HTML on cache hit.
+public final class RDEPUBTextBookCache {
+
+ public var schemaVersion: Int = 1
+
+ private let queue = DispatchQueue(label: "com.rdreader.textbookcache", qos: .utility)
+ private let cacheDirectory: URL
+
+ public init(subdirectory: String = "RDEPUBTextBookCache") {
+ let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
+ .appendingPathComponent(subdirectory, isDirectory: true)
+ ?? FileManager.default.temporaryDirectory.appendingPathComponent(subdirectory, isDirectory: true)
+ self.cacheDirectory = baseURL
+ try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
+ }
+
+ // MARK: - Cache Key
+ // Mirrors WRChapterPageCount.currentCacheKeyWithBookId:
+ // Encodes bookID + fontSize + lineHeightMultiple + contentInsets + pageSize
+
+ public func cacheKey(
+ bookID: String,
+ fontSize: CGFloat,
+ lineHeightMultiple: CGFloat,
+ contentInsets: UIEdgeInsets,
+ pageSize: CGSize
+ ) -> String {
+ let raw = "\(bookID)_\(fontSize)_\(lineHeightMultiple)_\(contentInsets.top)_\(contentInsets.left)_\(contentInsets.bottom)_\(contentInsets.right)_\(pageSize.width)_\(pageSize.height)_v\(schemaVersion)"
+ let digest = SHA256.hash(data: Data(raw.utf8))
+ let hex = digest.map { String(format: "%02x", $0) }.joined()
+ return hex + ".cache"
+ }
+
+ // MARK: - Load / Save (pagination metadata only)
+
+ /// Load cached pagination metadata for all chapters.
+ /// Returns dictionary keyed by chapter href.
+ public func load(key: String) -> [String: RDEPUBTextChapterPaginationCache]? {
+ queue.sync {
+ let fileURL = cacheDirectory.appendingPathComponent(key)
+ guard FileManager.default.fileExists(atPath: fileURL.path) else {
+ print("[Cache] load MISS key=\(key)")
+ return nil
+ }
+ do {
+ let data = try Data(contentsOf: fileURL)
+ guard let archive = try NSKeyedUnarchiver.unarchivedObject(
+ ofClass: PaginationCacheArchive.self,
+ from: data
+ ) else {
+ print("[Cache] load MISS key=\(key) (unarchive returned nil)")
+ return nil
+ }
+ var result: [String: RDEPUBTextChapterPaginationCache] = [:]
+ for chapter in archive.chapters {
+ result[chapter.href] = chapter.toCache()
+ }
+ print("[Cache] load HIT key=\(key) chapters=\(result.count)")
+ return result
+ } catch {
+ print("[Cache] load MISS key=\(key) error=\(error)")
+ return nil
+ }
+ }
+ }
+
+ /// Save pagination metadata for all chapters.
+ public func save(_ chapters: [RDEPUBTextChapterPaginationCache], key: String) {
+ queue.sync {
+ let fileURL = cacheDirectory.appendingPathComponent(key)
+ do {
+ let archives = chapters.map { ChapterPaginationArchive(from: $0) }
+ let bookArchive = PaginationCacheArchive(chapters: archives)
+ let data = try NSKeyedArchiver.archivedData(withRootObject: bookArchive, requiringSecureCoding: true)
+ try data.write(to: fileURL, options: .atomic)
+ print("[Cache] save key=\(key) chapters=\(chapters.count)")
+ } catch {
+ print("[Cache] save FAILED key=\(key) error=\(error)")
+ }
+ }
+ }
+
+ // MARK: - Invalidate
+
+ public func invalidateAll() {
+ queue.sync {
+ let fileManager = FileManager.default
+ guard let contents = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) else {
+ return
+ }
+ for file in contents {
+ try? fileManager.removeItem(at: file)
+ }
+ print("[Cache] invalidateAll")
+ }
+ }
+}
diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift
index 93ab1ea..79197d3 100644
--- a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift
+++ b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift
@@ -1,15 +1,21 @@
import CoreText
import UIKit
+#if canImport(DTCoreText)
+import DTCoreText
+#endif
+
struct RDEPUBTextLayouter {
private let attributedString: NSAttributedString
private let pageSize: CGSize
private let framesetter: CTFramesetter
private let path: CGPath
+ private let config: RDEPUBTextLayoutConfig
- init(attributedString: NSAttributedString, pageSize: CGSize) {
+ init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default) {
self.attributedString = attributedString
self.pageSize = pageSize
+ self.config = config
self.framesetter = CTFramesetterCreateWithAttributedString(attributedString)
self.path = CGPath(rect: CGRect(origin: .zero, size: pageSize), transform: nil)
}
@@ -19,6 +25,18 @@ struct RDEPUBTextLayouter {
return []
}
+#if canImport(DTCoreText)
+ return layoutFramesUsingDTCoreText(fragmentOffsets: fragmentOffsets)
+#else
+ return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
+#endif
+ }
+
+ private func layoutFramesUsingCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
+ guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
+ return []
+ }
+
var frames: [RDEPUBTextLayoutFrame] = []
var location = 0
@@ -30,7 +48,11 @@ struct RDEPUBTextLayouter {
}
let proposedRange = NSRange(location: location, length: visibleRange.length)
- let adjusted = adjustedRange(from: proposedRange, totalLength: attributedString.length)
+
+ // Line-level avoidPageBreakInside (WXRead approach: scan CTFrame lines backward)
+ let lineAdjusted = trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange)
+
+ let adjusted = adjustedRange(from: lineAdjusted, totalLength: attributedString.length)
let trailingFragmentID = nearestTrailingFragmentID(
endingAt: adjusted.range.location + adjusted.range.length,
fragmentOffsets: fragmentOffsets
@@ -61,6 +83,63 @@ struct RDEPUBTextLayouter {
return frames
}
+#if canImport(DTCoreText)
+ private func layoutFramesUsingDTCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
+ guard let layouter = DTCoreTextLayouter(attributedString: attributedString) else {
+ return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
+ }
+
+ layouter.shouldCacheLayoutFrames = false
+
+ var frames: [RDEPUBTextLayoutFrame] = []
+ var location = 0
+ let pageRect = CGRect(origin: .zero, size: pageSize)
+
+ while location < attributedString.length {
+ guard let layoutFrame = layouter.layoutFrame(with: pageRect, range: NSRange(location: location, length: 0)) else {
+ break
+ }
+
+ let visibleRange = layoutFrame.visibleStringRange()
+ guard visibleRange.length > 0 else {
+ break
+ }
+
+ let proposedRange = NSRange(location: location, length: visibleRange.length)
+ let lineAdjusted = trimmedRangeForAvoidPageBreakInside(from: layoutFrame, proposed: proposedRange)
+ let adjusted = adjustedRange(from: lineAdjusted, totalLength: attributedString.length)
+ let trailingFragmentID = nearestTrailingFragmentID(
+ endingAt: adjusted.range.location + adjusted.range.length,
+ fragmentOffsets: fragmentOffsets
+ )
+
+ frames.append(
+ RDEPUBTextLayoutFrame(
+ contentRange: adjusted.range,
+ breakReason: adjusted.breakReason,
+ blockRange: adjusted.blockRange,
+ attachmentRanges: adjusted.attachmentRanges,
+ attachmentKinds: adjusted.attachmentKinds,
+ blockKinds: adjusted.blockKinds,
+ semanticHints: adjusted.semanticHints,
+ attachmentPlacements: adjusted.attachmentPlacements,
+ trailingFragmentID: trailingFragmentID,
+ diagnostics: adjusted.diagnostics
+ )
+ )
+
+ let nextLocation = adjusted.range.location + adjusted.range.length
+ guard nextLocation > location else {
+ location += max(visibleRange.length, 1)
+ continue
+ }
+ location = nextLocation
+ }
+
+ return frames
+ }
+#endif
+
private func adjustedRange(
from proposedRange: NSRange,
totalLength: Int
@@ -164,32 +243,6 @@ struct RDEPUBTextLayouter {
)
}
- if let blockBoundary = preferredBlockBoundary(
- near: pageEnd,
- lowerBound: minimumEnd
- ) {
- let adjustedRange = NSRange(location: proposedRange.location, length: blockBoundary - proposedRange.location)
- return (
- range: adjustedRange,
- breakReason: .blockBoundary,
- blockRange: currentBlockRange,
- attachmentRanges: currentAttachmentRanges,
- attachmentKinds: currentAttachmentKinds,
- blockKinds: currentBlockKinds,
- semanticHints: currentSemanticHints,
- attachmentPlacements: currentAttachmentPlacements,
- diagnostics: diagnostics(
- reason: .blockBoundary,
- range: adjustedRange,
- attachmentRanges: currentAttachmentRanges,
- blockRange: currentBlockRange,
- blockKinds: currentBlockKinds,
- semanticHints: currentSemanticHints,
- attachmentPlacements: currentAttachmentPlacements
- )
- )
- }
-
return (
range: proposedRange,
breakReason: .frameLimit,
@@ -239,15 +292,6 @@ struct RDEPUBTextLayouter {
stop.pointee = true
return
}
-
- if hints.contains(.avoidPageBreakInside),
- attributeRange.location > range.location,
- attributeRange.location < range.location + range.length,
- attributeRange.location >= minimumEnd,
- attributeEnd > range.location + range.length {
- boundary = (attributeRange.location, RDEPUBTextSemanticHint.avoidPageBreakInside.rawValue)
- stop.pointee = true
- }
}
return boundary
}
@@ -265,23 +309,6 @@ struct RDEPUBTextLayouter {
return boundary
}
- private func preferredBlockBoundary(near location: Int, lowerBound: Int) -> Int? {
- var probe = max(lowerBound, 0)
- let searchEnd = min(location, attributedString.length)
- guard probe < searchEnd else { return nil }
-
- var lastBoundary: Int?
- while probe < searchEnd {
- let block = blockRange(at: probe) ?? paragraphRange(containing: probe)
- let candidate = block.location
- if candidate > lowerBound, candidate < location {
- lastBoundary = candidate
- }
- probe = max(block.location + max(block.length, 1), probe + 1)
- }
- return lastBoundary
- }
-
private func blockRange(at location: Int) -> NSRange? {
guard location >= 0, location < attributedString.length else { return nil }
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
@@ -367,6 +394,123 @@ struct RDEPUBTextLayouter {
.key
}
+ // MARK: - Line-level avoidPageBreakInside (WXRead approach)
+
+ /// Scans CTFrame lines backward from the last line, removing trailing lines
+ /// that fall inside an avoidPageBreakInside block. Mirrors WXRead's
+ /// WRCoreTextLayoutFrame.avoidPageBreakInsideByRemovingLastLinesIfNeeded.
+ private func trimmedRangeForAvoidPageBreakInside(
+ from frame: CTFrame,
+ proposed: NSRange
+ ) -> NSRange {
+ guard config.avoidPageBreakInsideEnabled else { return proposed }
+
+ let lines = CTFrameGetLines(frame) as! [CTLine]
+ guard !lines.isEmpty else { return proposed }
+
+ var origins = [CGPoint](repeating: .zero, count: lines.count)
+ CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
+
+ // Scan backward: count consecutive trailing lines inside protected blocks
+ // kMaxLinesToRemove = 3 (same as WXRead)
+ let kMaxLinesToRemove = 3
+ var linesToRemove = 0
+
+ for i in stride(from: lines.count - 1, through: 0, by: -1) {
+ let lineRange = CTLineGetStringRange(lines[i])
+ let lineNSRange = NSRange(location: lineRange.location, length: lineRange.length)
+
+ if lineIsInAvoidPageBreakInsideBlock(lineNSRange) {
+ linesToRemove += 1
+ if linesToRemove >= kMaxLinesToRemove {
+ // Don't remove more than kMaxLinesToRemove; stop here
+ // (line at index i stays, so linesToRemove stays at kMaxLinesToRemove)
+ linesToRemove = kMaxLinesToRemove
+ break
+ }
+ } else {
+ break
+ }
+ }
+
+ guard linesToRemove > 0 else { return proposed }
+
+ let validLineCount = lines.count - linesToRemove
+ guard validLineCount > 0 else {
+ // All lines are in protected blocks; fall back to proposed
+ return proposed
+ }
+
+ let lastValidLine = lines[validLineCount - 1]
+ let lastLineRange = CTLineGetStringRange(lastValidLine)
+ let endLocation = lastLineRange.location + lastLineRange.length
+ let adjustedLength = endLocation - proposed.location
+
+ guard adjustedLength > 0 else { return proposed }
+
+ return NSRange(location: proposed.location, length: adjustedLength)
+ }
+
+#if canImport(DTCoreText)
+ private func trimmedRangeForAvoidPageBreakInside(
+ from layoutFrame: DTCoreTextLayoutFrame,
+ proposed: NSRange
+ ) -> NSRange {
+ guard config.avoidPageBreakInsideEnabled else { return proposed }
+
+ guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine], !lines.isEmpty else {
+ return proposed
+ }
+
+ let kMaxLinesToRemove = 3
+ var linesToRemove = 0
+
+ for line in lines.reversed() {
+ let lineRange = line.stringRange()
+ if lineIsInAvoidPageBreakInsideBlock(lineRange) {
+ linesToRemove += 1
+ if linesToRemove >= kMaxLinesToRemove {
+ linesToRemove = kMaxLinesToRemove
+ break
+ }
+ } else {
+ break
+ }
+ }
+
+ guard linesToRemove > 0 else { return proposed }
+
+ let validLineCount = lines.count - linesToRemove
+ guard validLineCount > 0 else { return proposed }
+
+ let lastValidLine = lines[validLineCount - 1]
+ let lastLineRange = lastValidLine.stringRange()
+ let endLocation = lastLineRange.location + lastLineRange.length
+ let adjustedLength = endLocation - proposed.location
+
+ guard adjustedLength > 0 else { return proposed }
+
+ return NSRange(location: proposed.location, length: adjustedLength)
+ }
+#endif
+
+ /// Checks if a line's string range intersects with an avoidPageBreakInside block.
+ private func lineIsInAvoidPageBreakInsideBlock(_ lineRange: NSRange) -> Bool {
+ var found = false
+ let probeRange = NSRange(location: lineRange.location, length: max(lineRange.length, 1))
+ attributedString.enumerateAttribute(.rdPageSemanticHints, in: probeRange) { value, _, stop in
+ guard let rawValue = value as? String else { return }
+ let hints = rawValue
+ .split(separator: ",")
+ .compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
+ if hints.contains(.avoidPageBreakInside) {
+ found = true
+ stop.pointee = true
+ }
+ }
+ return found
+ }
+
private func diagnostics(
reason: RDEPUBTextPageBreakReason,
range: NSRange,
diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPaginationSupport.swift b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPaginationSupport.swift
index 44f777a..c3f6831 100644
--- a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPaginationSupport.swift
+++ b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPaginationSupport.swift
@@ -4,9 +4,10 @@ import UIKit
extension NSAttributedString {
func rd_paginatedFrames(
size: CGSize,
- fragmentOffsets: [String: Int] = [:]
+ fragmentOffsets: [String: Int] = [:],
+ config: RDEPUBTextLayoutConfig = .default
) -> [RDEPUBTextLayoutFrame] {
- RDEPUBTextLayouter(attributedString: self, pageSize: size)
+ RDEPUBTextLayouter(attributedString: self, pageSize: size, config: config)
.layoutFrames(fragmentOffsets: fragmentOffsets)
}
diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPerformanceSampler.swift b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPerformanceSampler.swift
new file mode 100644
index 0000000..4fbe77a
--- /dev/null
+++ b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPerformanceSampler.swift
@@ -0,0 +1,58 @@
+import Foundation
+
+// MARK: - Performance Sample
+
+public struct RDEPUBTextPerformanceSample: Equatable {
+ public var chapterHref: String
+ public var renderDuration: TimeInterval
+ public var paginateDuration: TimeInterval
+ public var pageCount: Int
+ public var attributedStringLength: Int
+ public var cacheHit: Bool
+
+ public init(
+ chapterHref: String,
+ renderDuration: TimeInterval,
+ paginateDuration: TimeInterval,
+ pageCount: Int,
+ attributedStringLength: Int,
+ cacheHit: Bool
+ ) {
+ self.chapterHref = chapterHref
+ self.renderDuration = renderDuration
+ self.paginateDuration = paginateDuration
+ self.pageCount = pageCount
+ self.attributedStringLength = attributedStringLength
+ self.cacheHit = cacheHit
+ }
+}
+
+// MARK: - Performance Sampler
+
+public final class RDEPUBTextPerformanceSampler {
+ public private(set) var samples: [RDEPUBTextPerformanceSample] = []
+ public var totalBuildDuration: TimeInterval = 0
+
+ public init() {}
+
+ public func record(_ sample: RDEPUBTextPerformanceSample) {
+ samples.append(sample)
+ print("[PERF] \(sample.chapterHref): render=\(formatMS(sample.renderDuration)) paginate=\(formatMS(sample.paginateDuration)) pages=\(sample.pageCount) cache=\(sample.cacheHit ? "HIT" : "MISS")")
+ }
+
+ public func summary() -> String {
+ let totalRender = samples.reduce(0) { $0 + $1.renderDuration }
+ let totalPaginate = samples.reduce(0) { $0 + $1.paginateDuration }
+ let hitCount = samples.filter(\.cacheHit).count
+ return "[PERF] chapters=\(samples.count) render=\(formatMS(totalRender)) paginate=\(formatMS(totalPaginate)) total=\(formatMS(totalBuildDuration)) cacheHits=\(hitCount)/\(samples.count)"
+ }
+
+ public func reset() {
+ samples.removeAll()
+ totalBuildDuration = 0
+ }
+
+ private func formatMS(_ duration: TimeInterval) -> String {
+ String(format: "%.0fms", duration * 1000)
+ }
+}
diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift
index f89d640..6f90128 100644
--- a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift
+++ b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift
@@ -47,6 +47,27 @@ public struct RDEPUBTextRenderStyle {
}
}
+public struct RDEPUBTextLayoutConfig: Equatable {
+ public var avoidOrphans: Bool
+ public var avoidWidows: Bool
+ public var avoidPageBreakInsideEnabled: Bool
+ public var imageMaxHeightRatio: CGFloat
+
+ public init(
+ avoidOrphans: Bool = true,
+ avoidWidows: Bool = true,
+ avoidPageBreakInsideEnabled: Bool = true,
+ imageMaxHeightRatio: CGFloat = 0.85
+ ) {
+ self.avoidOrphans = avoidOrphans
+ self.avoidWidows = avoidWidows
+ self.avoidPageBreakInsideEnabled = avoidPageBreakInsideEnabled
+ self.imageMaxHeightRatio = imageMaxHeightRatio
+ }
+
+ public static let `default` = RDEPUBTextLayoutConfig()
+}
+
public enum RDEPUBTextStyleSheetLayerKind: String, CaseIterable, Equatable {
case `default`
case replace
diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRendererSupport.swift b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRendererSupport.swift
index e079eca..c7fcd1b 100644
--- a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRendererSupport.swift
+++ b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRendererSupport.swift
@@ -88,6 +88,7 @@ enum RDEPUBTextRendererSupport {
static func normalizeReadingAttributes(in attributedString: NSMutableAttributedString, style: RDEPUBTextRenderStyle) {
let fullRange = NSRange(location: 0, length: attributedString.length)
var blockIndex = 0
+ let sourceText = attributedString.string as NSString
attributedString.enumerateAttributes(in: fullRange) { attributes, range, _ in
let sourceFont = attributes[.font] as? UIFont
let normalizedFont = normalizedFont(from: sourceFont, baseFont: style.font)
@@ -102,7 +103,13 @@ enum RDEPUBTextRendererSupport {
updatedAttributes[.foregroundColor] = textColor
}
normalizeAttachmentDisplayIfNeeded(in: &updatedAttributes, font: normalizedFont)
- updatedAttributes[.rdPageBlockRange] = NSStringFromRange(range)
+ let semanticBlockRange = (attributes[.rdPageBlockRange] as? String)
+ .flatMap(NSRangeFromString)
+ .flatMap { $0.length > 0 ? $0 : nil }
+ let paragraphRange = sourceText.length > 0
+ ? sourceText.paragraphRange(for: NSRange(location: min(range.location, max(sourceText.length - 1, 0)), length: 0))
+ : range
+ updatedAttributes[.rdPageBlockRange] = NSStringFromRange(semanticBlockRange ?? paragraphRange)
updatedAttributes[.rdPageBlockIndex] = blockIndex
if let attachmentKind = attachmentKind(for: attributes) {
updatedAttributes[.rdPageAttachmentKind] = attachmentKind.rawValue
@@ -219,45 +226,16 @@ enum RDEPUBTextRendererSupport {
style: RDEPUBTextRenderStyle
) {
guard let attachment = element.textAttachment else { return }
-
- let lowercasedClasses = (((attachment.attributes["class"] as? String) ?? (element.attributes["class"] as? String) ?? "")).lowercased()
- let lowercasedPath = attachment.contentURL?.lastPathComponent.lowercased()
- ?? ((attachment.attributes["src"] as? String) ?? (element.attributes["src"] as? String) ?? "").lowercased()
let pointSize = max(element.fontDescriptor.pointSize, style.font.pointSize)
-
- if lowercasedClasses.contains("qqreader-footnote") || lowercasedPath == "note.png" {
- let targetHeight = max(round(pointSize * 0.54), 1)
- let originalSize = attachment.originalSize
- let aspectRatio = originalSize.height > 0 ? originalSize.width / originalSize.height : 1
- let targetWidth = max(round(targetHeight * max(aspectRatio, 0.1)), 1)
- attachment.displaySize = CGSize(width: targetWidth, height: targetHeight)
- attachment.verticalAlignment = .center
+ let maxSize = CGSize(
+ width: round(UIScreen.main.bounds.insetBy(dx: 20, dy: 28).width),
+ height: round(UIScreen.main.bounds.insetBy(dx: 20, dy: 28).height * 0.85)
+ )
+ normalizeAttachmentLayoutForWXRead(attachment, fontPointSize: pointSize, maxImageSize: maxSize)
+ if isFootnoteAttachment(attachment) {
element.displayStyle = .inline
- if !didLogFootnoteAttachment {
- didLogFootnoteAttachment = true
- print("[EPUB][Attachment] footnote classes=\(lowercasedClasses) path=\(lowercasedPath) original=\(string(from: originalSize)) display=\(string(from: attachment.displaySize)) font=\(pointSize)")
- }
- return
- }
-
- if lowercasedClasses.contains("rd-front-cover-image") || lowercasedPath == "cover.jpg" {
- let maxSize = UIScreen.main.bounds.insetBy(dx: 20, dy: 28).size
- let originalSize = attachment.originalSize
- if originalSize.width > 0, originalSize.height > 0 {
- let scale = min(maxSize.width / originalSize.width, maxSize.height / originalSize.height)
- attachment.displaySize = CGSize(
- width: round(originalSize.width * scale),
- height: round(originalSize.height * scale)
- )
- } else {
- attachment.displaySize = CGSize(width: round(maxSize.width), height: round(maxSize.height))
- }
- attachment.verticalAlignment = .baseline
+ } else if isCoverAttachment(attachment) {
element.displayStyle = .block
- if !didLogCoverAttachment {
- didLogCoverAttachment = true
- print("[EPUB][Attachment] cover classes=\(lowercasedClasses) path=\(lowercasedPath) original=\(string(from: originalSize)) display=\(string(from: attachment.displaySize))")
- }
}
}
#endif
@@ -472,26 +450,7 @@ enum RDEPUBTextRendererSupport {
#if canImport(DTCoreText)
if let textAttachment = attachment as? DTTextAttachment {
- let lowercasedClasses = ((textAttachment.attributes["class"] as? String) ?? "").lowercased()
- let lowercasedPath = textAttachment.contentURL?.lastPathComponent.lowercased() ?? ""
- if lowercasedClasses.contains("qqreader-footnote") || lowercasedPath == "note.png" {
- let targetHeight = max(round(font.pointSize * 0.14), 1)
- let originalSize = textAttachment.originalSize
- let aspectRatio = originalSize.height > 0 ? originalSize.width / originalSize.height : 1
- let targetWidth = max(round(targetHeight * max(aspectRatio, 0.1)), 1)
- textAttachment.displaySize = CGSize(width: targetWidth, height: round(targetHeight))
- textAttachment.verticalAlignment = .center
- } else if lowercasedClasses.contains("rd-front-cover-image") {
- let originalSize = textAttachment.originalSize
- let maxWidth = max(UIScreen.main.bounds.width - 48, font.lineHeight * 8)
- if originalSize.width > 0, originalSize.height > 0 {
- let scaledHeight = round(originalSize.height * (maxWidth / originalSize.width))
- textAttachment.displaySize = CGSize(width: round(maxWidth), height: scaledHeight)
- } else {
- textAttachment.displaySize = CGSize(width: round(maxWidth), height: round(maxWidth * 1.4))
- }
- textAttachment.verticalAlignment = .baseline
- }
+ normalizeAttachmentLayoutForWXRead(textAttachment, fontPointSize: font.pointSize)
attributes[.attachment] = textAttachment
return
}
@@ -887,7 +846,7 @@ enum RDEPUBTextRendererSupport {
if rawTag.contains("avoidpagebreakinside") ||
rawTag.contains("break-inside: avoid") ||
rawTag.contains("page-break-inside: avoid") ||
- ["blockquote", "pre", "code", "table", "ul", "ol"].contains(tagName) {
+ ["blockquote", "pre", "code", "table", "ul", "ol", "figure", "img"].contains(tagName) {
hints.append(.avoidPageBreakInside)
}
if rawTag.contains("pagebreakbefore") ||
@@ -977,6 +936,7 @@ enum RDEPUBTextRendererSupport {
in attributedString: NSMutableAttributedString
) {
var attributes: [NSAttributedString.Key: Any] = [:]
+ attributes[.rdPageBlockRange] = NSStringFromRange(range)
if let blockKind = semantics.blockKind {
attributes[.rdPageBlockKind] = blockKind.rawValue
}
@@ -1025,4 +985,85 @@ enum RDEPUBTextRendererSupport {
var hints: [RDEPUBTextSemanticHint]
var attachmentPlacement: RDEPUBTextAttachmentPlacement?
}
+
+#if canImport(DTCoreText)
+ static func normalizeAttachmentLayoutForWXRead(
+ _ attachment: DTTextAttachment,
+ fontPointSize: CGFloat,
+ maxImageSize: CGSize? = nil
+ ) {
+ let pointSize = max(fontPointSize, 1)
+ let originalSize = attachment.originalSize
+
+ if isFootnoteAttachment(attachment) {
+ let targetWidth = max(round(pointSize), 1)
+ let aspectRatio = originalSize.height > 0 ? originalSize.width / originalSize.height : 1
+ let targetHeight = max(round(targetWidth / max(aspectRatio, 0.1)), 1)
+ attachment.displaySize = CGSize(width: targetWidth, height: targetHeight)
+ attachment.verticalAlignment = .baseline
+
+ if !didLogFootnoteAttachment {
+ didLogFootnoteAttachment = true
+ print("[EPUB][Attachment] footnote original=\(string(from: originalSize)) display=\(string(from: attachment.displaySize)) font=\(pointSize)")
+ }
+ return
+ }
+
+ if isCoverAttachment(attachment) {
+ let maxSize = maxImageSize ?? CGSize(width: round(UIScreen.main.bounds.width - 40), height: round(UIScreen.main.bounds.height - 56))
+ if originalSize.width > 0, originalSize.height > 0 {
+ let scale = min(maxSize.width / originalSize.width, maxSize.height / originalSize.height)
+ attachment.displaySize = CGSize(
+ width: round(originalSize.width * scale),
+ height: round(originalSize.height * scale)
+ )
+ } else {
+ attachment.displaySize = maxSize
+ }
+ attachment.verticalAlignment = .baseline
+
+ if !didLogCoverAttachment {
+ didLogCoverAttachment = true
+ print("[EPUB][Attachment] cover original=\(string(from: originalSize)) display=\(string(from: attachment.displaySize))")
+ }
+ return
+ }
+
+ var resolvedSize = attachment.displaySize
+ if resolvedSize.width <= 0 || resolvedSize.height <= 0 {
+ resolvedSize = originalSize
+ }
+ if resolvedSize.width <= 0 || resolvedSize.height <= 0 {
+ resolvedSize = CGSize(width: pointSize, height: pointSize)
+ }
+
+ if let maxImageSize,
+ resolvedSize.width > 0,
+ resolvedSize.height > 0,
+ (resolvedSize.width > maxImageSize.width || resolvedSize.height > maxImageSize.height) {
+ let scale = min(maxImageSize.width / resolvedSize.width, maxImageSize.height / resolvedSize.height)
+ resolvedSize = CGSize(
+ width: round(resolvedSize.width * scale),
+ height: round(resolvedSize.height * scale)
+ )
+ }
+
+ attachment.displaySize = CGSize(width: round(resolvedSize.width), height: round(resolvedSize.height))
+ attachment.verticalAlignment = .center
+ }
+
+ private static func isFootnoteAttachment(_ attachment: DTTextAttachment) -> Bool {
+ let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased()
+ let lowercasedPath = attachment.contentURL?.lastPathComponent.lowercased()
+ ?? ((attachment.attributes["src"] as? String) ?? "").lowercased()
+ return lowercasedClasses.contains("qqreader-footnote") || lowercasedPath == "note.png"
+ }
+
+ private static func isCoverAttachment(_ attachment: DTTextAttachment) -> Bool {
+ let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased()
+ let lowercasedPath = attachment.contentURL?.lastPathComponent.lowercased()
+ ?? ((attachment.attributes["src"] as? String) ?? "").lowercased()
+ return lowercasedClasses.contains("rd-front-cover-image") || lowercasedPath == "cover.jpg"
+ }
+#endif
}
diff --git a/Sources/RDReaderView/EPUBTextRendering/RDPlainTextBookBuilder.swift b/Sources/RDReaderView/EPUBTextRendering/RDPlainTextBookBuilder.swift
index eb97764..5ccb080 100644
--- a/Sources/RDReaderView/EPUBTextRendering/RDPlainTextBookBuilder.swift
+++ b/Sources/RDReaderView/EPUBTextRendering/RDPlainTextBookBuilder.swift
@@ -46,6 +46,7 @@ public final class RDPlainTextBookBuilder {
: layoutFrames
let href = "chapter_\(index).xhtml"
+ let chapterAttributedContent = content.copy() as! NSAttributedString
let pages = effectiveFrames.enumerated().map { localPageIndex, frame in
let range = frame.contentRange
return RDEPUBTextPage(
@@ -56,6 +57,7 @@ public final class RDPlainTextBookBuilder {
chapterTitle: spec.title ?? "第 \(index + 1) 章",
pageIndexInChapter: localPageIndex,
totalPagesInChapter: effectiveFrames.count,
+ chapterContent: chapterAttributedContent,
content: content.attributedSubstring(from: range),
contentRange: range,
pageStartOffset: range.location,
@@ -70,7 +72,7 @@ public final class RDPlainTextBookBuilder {
spineIndex: index,
href: href,
title: spec.title ?? "第 \(index + 1) 章",
- attributedContent: content.copy() as! NSAttributedString,
+ attributedContent: chapterAttributedContent,
fragmentOffsets: [:],
pageBreakReasons: pages.map { $0.metadata.breakReason },
pages: pages
diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift
index 54b19ca..26c542e 100644
--- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift
+++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift
@@ -111,6 +111,7 @@ public final class RDEPUBReaderController: UIViewController {
fileprivate lazy var topToolView = makeTopToolView()
fileprivate lazy var bottomToolView = makeBottomToolView()
fileprivate var currentBookIdentifier: String?
+ private let textBookCache = RDEPUBTextBookCache()
private var currentBrightness: CGFloat
private var didStartInitialLoad = false
private var isRepaginating = false
@@ -575,7 +576,7 @@ public final class RDEPUBReaderController: UIViewController {
if publication.readingProfile == .textReflowable {
let renderer = resolvedTextRenderer()
- let builder = RDEPUBTextBookBuilder(renderer: renderer)
+ let builder = RDEPUBTextBookBuilder(renderer: renderer, cache: textBookCache)
let pageSize = currentTextPageSize()
let renderStyle = currentTextRenderStyle()
diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBSelectionOverlayView.swift b/Sources/RDReaderView/EPUBUI/RDEPUBSelectionOverlayView.swift
new file mode 100644
index 0000000..8bb2a96
--- /dev/null
+++ b/Sources/RDReaderView/EPUBUI/RDEPUBSelectionOverlayView.swift
@@ -0,0 +1,130 @@
+import UIKit
+
+struct RDEPUBTextOverlayDecoration {
+ enum Kind: String {
+ case selection
+ case highlight
+ case underline
+ case search
+ case activeSearch
+ case locate
+ }
+
+ var kind: Kind
+ var absoluteRange: NSRange
+ var rects: [CGRect]
+ var color: UIColor
+}
+
+final class RDEPUBSelectionOverlayView: UIView {
+ private var page: RDEPUBTextPage?
+ private var selectionRange: NSRange?
+ private var decorations: [RDEPUBTextOverlayDecoration] = []
+ private let selectionVerticalAdjustment: CGFloat = -1
+
+ var selectionColor: UIColor = UIColor(red: 70 / 255, green: 140 / 255, blue: 1, alpha: 0.24) {
+ didSet {
+ setNeedsDisplay()
+ }
+ }
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ backgroundColor = .clear
+ isOpaque = false
+ isUserInteractionEnabled = false
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ func configure(page: RDEPUBTextPage, selectionColor: UIColor) {
+ self.page = page
+ self.selectionColor = selectionColor
+ selectionRange = nil
+ decorations = []
+ setNeedsDisplay()
+ }
+
+ func updateSelection(absoluteRange: NSRange?) {
+ selectionRange = absoluteRange
+ setNeedsDisplay()
+ }
+
+ func applyDecorations(_ decorations: [RDEPUBTextOverlayDecoration]) {
+ self.decorations = decorations
+ setNeedsDisplay()
+ }
+
+ func clearSelection() {
+ updateSelection(absoluteRange: nil)
+ }
+
+ func absoluteRange(at point: CGPoint) -> NSRange? {
+ return nil
+ }
+
+ func decorationSummary() -> String {
+ let counts = Dictionary(grouping: resolvedDecorations, by: \.kind).mapValues(\.count)
+ let selectionLabel = selectionRange.map(NSStringFromRange) ?? "none"
+ let highlightCount = counts[.highlight, default: 0]
+ let underlineCount = counts[.underline, default: 0]
+ let searchCount = counts[.search, default: 0]
+ let activeSearchCount = counts[.activeSearch, default: 0]
+ let locateLabel = resolvedDecorations.first(where: { $0.kind == .locate }).map { NSStringFromRange($0.absoluteRange) } ?? "none"
+ return [
+ "selection \(selectionLabel)",
+ "highlight \(highlightCount)",
+ "underline \(underlineCount)",
+ "search \(searchCount)",
+ "activeSearch \(activeSearchCount)",
+ "locate \(locateLabel)"
+ ].joined(separator: " · ")
+ }
+
+ override func draw(_ rect: CGRect) {
+ guard let context = UIGraphicsGetCurrentContext() else { return }
+
+ for decoration in resolvedDecorations {
+ switch decoration.kind {
+ case .underline:
+ context.setStrokeColor(decoration.color.cgColor)
+ context.setLineWidth(2)
+ for underlineRect in decoration.rects {
+ let y = underlineRect.maxY - 1
+ context.move(to: CGPoint(x: underlineRect.minX, y: y))
+ context.addLine(to: CGPoint(x: underlineRect.maxX, y: y))
+ context.strokePath()
+ }
+ default:
+ context.setFillColor(decoration.color.cgColor)
+ for selectionRect in decoration.rects {
+ let adjustedRect = selectionRect.offsetBy(dx: 0, dy: selectionVerticalAdjustment)
+ let path = UIBezierPath(roundedRect: adjustedRect.insetBy(dx: -1, dy: -1), cornerRadius: 4)
+ context.addPath(path.cgPath)
+ context.fillPath()
+ }
+ }
+ }
+ }
+
+ private var resolvedDecorations: [RDEPUBTextOverlayDecoration] {
+ guard let page else { return [] }
+
+ let nonSelection = decorations.filter { !$0.rects.isEmpty }
+ guard let selectionRange else { return nonSelection }
+
+ let selectionRects:[CGRect] = []
+ guard !selectionRects.isEmpty else { return nonSelection }
+
+ return [
+ RDEPUBTextOverlayDecoration(
+ kind: .selection,
+ absoluteRange: selectionRange,
+ rects: selectionRects,
+ color: selectionColor
+ )
+ ]
+ }
+}
diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBTextContentView.swift b/Sources/RDReaderView/EPUBUI/RDEPUBTextContentView.swift
index 618e529..fbe4e6a 100644
--- a/Sources/RDReaderView/EPUBUI/RDEPUBTextContentView.swift
+++ b/Sources/RDReaderView/EPUBUI/RDEPUBTextContentView.swift
@@ -37,12 +37,60 @@ final class RDEPUBSelectableTextView: UITextView {
}
}
+#if canImport(DTCoreText)
+final class RDEPUBDirectCoreTextPageView: UIView {
+ var layoutFrame: DTCoreTextLayoutFrame? {
+ didSet {
+ setNeedsDisplay()
+ }
+ }
+
+ var drawOptions: DTCoreTextLayoutFrameDrawingOptions = DTCoreTextLayoutFrameDrawingOptions(rawValue: 1)! {
+ didSet {
+ setNeedsDisplay()
+ }
+ }
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ backgroundColor = .clear
+ isOpaque = false
+ contentMode = .redraw
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func draw(_ rect: CGRect) {
+ guard let context = UIGraphicsGetCurrentContext(),
+ let layoutFrame else { return }
+
+ context.saveGState()
+ layoutFrame.draw(in: context, options: drawOptions)
+ context.restoreGState()
+ }
+}
+#endif
+
final class RDEPUBTextContentView: UIView {
private var contentInsets: UIEdgeInsets = .zero
private var currentPage: RDEPUBTextPage?
private var highlightedRanges: [RDEPUBHighlight] = []
weak var delegate: RDEPUBTextContentViewDelegate?
+#if canImport(DTCoreText)
+ private let coreTextContentView: RDEPUBDirectCoreTextPageView = {
+ let view = RDEPUBDirectCoreTextPageView()
+ view.backgroundColor = .clear
+ view.isOpaque = false
+ return view
+ }()
+
+ private var coreTextDisplayContent: NSAttributedString?
+ private var coreTextDisplayRange: NSRange?
+#endif
+
private let textView: RDEPUBSelectableTextView = {
let view = RDEPUBSelectableTextView()
view.isEditable = false
@@ -70,6 +118,9 @@ final class RDEPUBTextContentView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
addSubview(coverImageView)
+#if canImport(DTCoreText)
+ addSubview(coreTextContentView)
+#endif
addSubview(textView)
addSubview(pageNumberLabel)
textView.delegate = self
@@ -91,6 +142,10 @@ final class RDEPUBTextContentView: UIView {
override func layoutSubviews() {
super.layoutSubviews()
+#if canImport(DTCoreText)
+ coreTextContentView.frame = bounds.inset(by: contentInsets)
+ updateCoreTextLayoutFrameIfNeeded()
+#endif
textView.frame = bounds.inset(by: contentInsets)
coverImageView.frame = bounds.inset(by: contentInsets)
@@ -119,6 +174,12 @@ final class RDEPUBTextContentView: UIView {
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
if configureCoverIfNeeded(for: page) {
+#if canImport(DTCoreText)
+ coreTextContentView.isHidden = true
+ coreTextContentView.layoutFrame = nil
+ coreTextDisplayContent = nil
+ coreTextDisplayRange = nil
+#endif
textView.attributedText = nil
textView.selectedRange = NSRange(location: 0, length: 0)
delegate?.textContentView(self, didChangeSelection: nil)
@@ -129,7 +190,17 @@ final class RDEPUBTextContentView: UIView {
coverImageView.isHidden = true
coverImageView.image = nil
- let displayContent = NSMutableAttributedString(attributedString: page.content)
+ let selectionContent = NSMutableAttributedString(attributedString: page.content)
+ let selectionRange = NSRange(location: 0, length: selectionContent.length)
+ selectionContent.addAttribute(
+ .foregroundColor,
+ value: configuration.theme.contentTextColor,
+ range: selectionRange
+ )
+ normalizeInlineAttachments(in: selectionContent, basePointSize: configuration.fontSize)
+
+#if canImport(DTCoreText)
+ let displayContent = NSMutableAttributedString(attributedString: page.chapterContent)
let fullRange = NSRange(location: 0, length: displayContent.length)
displayContent.addAttribute(
.foregroundColor,
@@ -137,10 +208,20 @@ final class RDEPUBTextContentView: UIView {
range: fullRange
)
normalizeInlineAttachments(in: displayContent, basePointSize: configuration.fontSize)
- applyHighlights(to: displayContent, page: page)
- applySearchHighlights(to: displayContent, page: page, searchState: searchState)
+ applyHighlights(to: displayContent, page: page, contentBaseOffset: 0)
+ applySearchHighlights(to: displayContent, page: page, searchState: searchState, contentBaseOffset: 0)
+ coreTextContentView.isHidden = false
+ coreTextContentView.backgroundColor = .clear
+ coreTextDisplayContent = displayContent
+ coreTextDisplayRange = page.contentRange
+ updateCoreTextLayoutFrameIfNeeded()
+#else
+ applyHighlights(to: selectionContent, page: page, contentBaseOffset: page.pageStartOffset)
+ applySearchHighlights(to: selectionContent, page: page, searchState: searchState, contentBaseOffset: page.pageStartOffset)
+#endif
+
textView.tintColor = configuration.theme.toolControlTextColor
- textView.attributedText = displayContent
+ textView.attributedText = selectionProxyContent(from: selectionContent)
textView.selectedRange = NSRange(location: 0, length: 0)
delegate?.textContentView(self, didChangeSelection: nil)
setNeedsLayout()
@@ -152,6 +233,14 @@ final class RDEPUBTextContentView: UIView {
}
private func applyHighlights(to content: NSMutableAttributedString, page: RDEPUBTextPage) {
+ applyHighlights(to: content, page: page, contentBaseOffset: page.pageStartOffset)
+ }
+
+ private func applyHighlights(
+ to content: NSMutableAttributedString,
+ page: RDEPUBTextPage,
+ contentBaseOffset: Int
+ ) {
let pageRange = absoluteOffsetRange(for: page)
let pageStart = pageRange.lowerBound
let pageEndExclusive = pageRange.upperBound
@@ -163,7 +252,7 @@ final class RDEPUBTextContentView: UIView {
guard overlapStart < overlapEnd else { continue }
let relativeRange = NSRange(
- location: overlapStart - pageStart,
+ location: overlapStart - contentBaseOffset,
length: overlapEnd - overlapStart
)
switch highlight.style {
@@ -186,6 +275,20 @@ final class RDEPUBTextContentView: UIView {
to content: NSMutableAttributedString,
page: RDEPUBTextPage,
searchState: RDEPUBSearchState?
+ ) {
+ applySearchHighlights(
+ to: content,
+ page: page,
+ searchState: searchState,
+ contentBaseOffset: page.pageStartOffset
+ )
+ }
+
+ private func applySearchHighlights(
+ to content: NSMutableAttributedString,
+ page: RDEPUBTextPage,
+ searchState: RDEPUBSearchState?,
+ contentBaseOffset: Int
) {
guard let searchState else { return }
@@ -202,7 +305,7 @@ final class RDEPUBTextContentView: UIView {
let overlapEnd = min(matchEnd, pageEndExclusive)
guard overlapStart < overlapEnd else { continue }
- let relativeRange = NSRange(location: Int(overlapStart - pageStart), length: Int(overlapEnd - overlapStart))
+ let relativeRange = NSRange(location: Int(overlapStart - contentBaseOffset), length: Int(overlapEnd - overlapStart))
let color = match == searchState.currentMatch ? activeColor : normalColor
content.addAttribute(.backgroundColor, value: color, range: relativeRange)
}
@@ -223,6 +326,12 @@ final class RDEPUBTextContentView: UIView {
coverImageView.image = image
coverImageView.isHidden = false
+#if canImport(DTCoreText)
+ coreTextContentView.isHidden = true
+ coreTextContentView.layoutFrame = nil
+ coreTextDisplayContent = nil
+ coreTextDisplayRange = nil
+#endif
textView.attributedText = nil
return true
}
@@ -265,22 +374,61 @@ final class RDEPUBTextContentView: UIView {
content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, range, _ in
#if canImport(DTCoreText)
if let attachment = value as? DTTextAttachment {
- let classes = ((attachment.attributes["class"] as? String) ?? "").lowercased()
- let source = (attachment.contentURL?.lastPathComponent ?? (attachment.attributes["src"] as? String) ?? "").lowercased()
- guard classes.contains("qqreader-footnote") || source.contains("note.png") else { return }
-
- let pointSize = max(basePointSize, 1)
- let targetHeight = max(round(pointSize * 0.14), 1)
- let aspectRatio = attachment.originalSize.height > 0 ? attachment.originalSize.width / attachment.originalSize.height : 1
- let targetWidth = max(round(targetHeight * max(aspectRatio, 0.1)), 1)
- attachment.displaySize = CGSize(width: targetWidth, height: targetHeight)
- attachment.verticalAlignment = .center
+ RDEPUBTextRendererSupport.normalizeAttachmentLayoutForWXRead(
+ attachment,
+ fontPointSize: basePointSize
+ )
content.addAttribute(.attachment, value: attachment, range: range)
}
#endif
}
}
+ private func selectionProxyContent(from content: NSAttributedString) -> NSAttributedString {
+ let proxy = NSMutableAttributedString(attributedString: content)
+ let fullRange = NSRange(location: 0, length: proxy.length)
+ proxy.removeAttribute(.backgroundColor, range: fullRange)
+ proxy.addAttribute(.foregroundColor, value: UIColor.clear, range: fullRange)
+
+ var attachmentRanges: [NSRange] = []
+ proxy.enumerateAttribute(.attachment, in: fullRange) { value, range, _ in
+ guard value != nil else { return }
+ attachmentRanges.append(range)
+ }
+
+ for range in attachmentRanges.reversed() {
+ let replacement = NSAttributedString(
+ string: String(repeating: " ", count: max(range.length, 1)),
+ attributes: [
+ .font: proxy.attribute(.font, at: max(range.location - 1, 0), effectiveRange: nil) as Any,
+ .foregroundColor: UIColor.clear
+ ]
+ )
+ proxy.replaceCharacters(in: range, with: replacement)
+ }
+
+ return proxy
+ }
+
+#if canImport(DTCoreText)
+ private func updateCoreTextLayoutFrameIfNeeded() {
+ guard !coreTextContentView.isHidden,
+ let displayContent = coreTextDisplayContent,
+ let displayRange = coreTextDisplayRange,
+ coreTextContentView.bounds.width > 0,
+ coreTextContentView.bounds.height > 0 else {
+ return
+ }
+
+ guard let layouter = DTCoreTextLayouter(attributedString: displayContent) else {
+ coreTextContentView.layoutFrame = nil
+ return
+ }
+ layouter.shouldCacheLayoutFrames = false
+ coreTextContentView.layoutFrame = layouter.layoutFrame(with: coreTextContentView.bounds, range: displayRange)
+ }
+#endif
+
}
extension RDEPUBTextContentView: UITextViewDelegate {