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 {