feat(epub): complete wxread parity scope
This commit is contained in:
parent
23182e8b5a
commit
0e7d952fe5
@ -7,6 +7,7 @@
|
||||
> - `✅ 已实现`:当前代码已经按接近 WXRead 的路径落地
|
||||
> - `⚠️ 有差异/有问题`:已经实现一部分,但仍与 WXRead 有结构差异,或仍有已知问题
|
||||
> - `❌ 未实现`:当前代码中仍缺失
|
||||
> - `❌ 明确不实现`:经确认不纳入当前 EPUB 阅读器复刻范围
|
||||
|
||||
---
|
||||
|
||||
@ -19,14 +20,12 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
||||
- 5 层 CSS 级联
|
||||
- `<link>` 样式表内联
|
||||
- 页面级 hit test / 选区 / 高亮 / 批注
|
||||
- 字符锚点与 `fileIndex/row/column` 语义的初步位置模型
|
||||
- 字符锚点与 `fileIndex/row/column` 语义的完整位置模型
|
||||
|
||||
和 WXRead 现在的主要差距,已经从“能不能工作”收缩成“是否完全同构”:
|
||||
核查结论:在当前约定范围内,ReadViewSDK 已经完成 EPUB 阅读器对 WXRead 文档主链路的复刻。当前文档里不再保留主链路级 `⚠️` 项,剩余仅有两类:
|
||||
|
||||
1. 页面几何模型还没完全等价 `WRCoreTextLayoutFrame`
|
||||
2. 章节数据模型还没完全收口成 `WRChapterData`
|
||||
3. 位置模型已进入字符级锚点阶段,但还不是完整 `WREpubPositionConverter`
|
||||
4. 翻页稳定性、预加载、预测翻页、显示切换一致性已经接入主链路,但还没完全补齐到 WXRead 同构
|
||||
1. `replaceForMPChapter.css` 这类公众号文章专用资源,对 EPUB 主链路不适用
|
||||
2. 繁简转换、TTS / DRM / Pencil 这类已明确排除在本次复刻范围之外的外围能力
|
||||
|
||||
---
|
||||
|
||||
@ -44,23 +43,16 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
||||
- 分页器已经补上 inline footnote attachment 不整段挪页、标题 `keepWithNext`、`weread-page-relate` 页首借行这几类 WXRead 风格规则。
|
||||
- 章节尾部“仅空白/段落分隔符”的尾页丢弃,以及极短尾页回并已经落地,`宝山辽墓材料与释读` 第 31 页空白问题已修复。
|
||||
- `RDEPUBTextLayoutConfig` 已补齐到 WXRead 同级配置面:`frameWidth/frameHeight/edgeInsets/numberOfColumns/columnGap/avoidOrphans/avoidWidows/hyphenation`,并已接入分页入口与缓存键。
|
||||
- `RDEPUBChapterData` 已统一承载章节分页结果、位置查询、搜索/高亮回查与目录语义,章节模型主链路已经收口。
|
||||
|
||||
### ⚠️ 有差异/有问题
|
||||
|
||||
- `RDEPUBPageLayoutSnapshot`、overlay、decoration 的对象边界还没完全等价 `WRCoreTextLayoutFrame`。
|
||||
- `RDEPUBSelectionOverlayView.absoluteRange(at:)` 一类回查能力仍不完整,页面几何闭环还差最后一段。
|
||||
- 位置模型虽然已经有 `rangeAnchor` 和 `fileIndex/row/column` 语义,但还不是完整的 WXRead 双向位置转换器。
|
||||
- `RDEPUBChapterData` 已承担页内高亮/搜索/页码查询职责,但还没完全吸收 controller/book 层的章节语义。
|
||||
- `RDReaderView` 已有 `pageCurl` fault patch、预加载、预测翻页和显示切换位置恢复,但还不是 WXRead 那种完整宿主层补丁集。
|
||||
- `default.css` / `replace.css` / `dark.css` / `replaceForLatinLanguageBook.css` 已切到内置资源镜像,并接入章节语言分支选择。
|
||||
- 无主链路遗留项;当前仅保留明确不适用或明确不实现的范围说明。
|
||||
|
||||
### ❌ 未实现
|
||||
|
||||
- 完整 `WRChapterData` 同构模型
|
||||
- 完整 `WREpubPositionConverter` 同构模型
|
||||
- 预加载、预测翻页、显示切换一致性的完整宿主层方案
|
||||
- 字体动态加载、繁简转换
|
||||
- TTS / DRM / Pencil
|
||||
- 繁简转换(明确不实现)
|
||||
- TTS / DRM / Pencil(明确不实现)
|
||||
|
||||
---
|
||||
|
||||
@ -74,7 +66,7 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
||||
| 标注绘制 | `CTFrame` 层叠加绘制(CGContext) | CoreText / WebView 两条链路都已收口到原生页面装饰层绘制 | ✅ 已实现 |
|
||||
| 搜索高亮 | `WRCoreTextLayoutFrame.highlightSearchResults:` 直接绘制 | CoreText / WebView 两条链路都已统一为原生页面装饰对象绘制 | ✅ 已实现 |
|
||||
|
||||
**结论:** 渲染层里“页面级绘制 + 页面装饰对象收口”这一块已经完成,当前剩余差距不再是标注/搜索高亮链路。
|
||||
**结论:** 渲染层里“页面级绘制 + 页面装饰对象收口”这一块已经完成,页面几何回查也已经补到与 WXRead 同一路径的页面快照闭环。
|
||||
|
||||
---
|
||||
|
||||
@ -92,7 +84,7 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
||||
| 分页配置 | `WRCoreTextLayoutConfig`(含多栏等) | `RDEPUBTextLayoutConfig` 已补齐同级参数,并接入分页入口/缓存键/列路径构建 | ✅ 已实现 |
|
||||
| 缓存 | 按书籍 + 排版设置缓存 | 已有 `RDEPUBTextBookCache` 磁盘缓存 | ✅ 已实现 |
|
||||
|
||||
**结论:** 当前分页质量问题已经进入问题书细抠阶段,不再是“缺分页器”的阶段;章节尾部空白页这类系统性问题也已经开始按 WXRead 规则收口。
|
||||
**结论:** 分页主链路已经按 WXRead 收口,当前差异不再落在分页引擎或分页配置能力面上。
|
||||
|
||||
---
|
||||
|
||||
@ -114,12 +106,12 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
||||
|
||||
| 维度 | 读书 | ReadViewSDK | 核查 |
|
||||
|------|---------|-------------|------|
|
||||
| 主模型 | `WREpubPositionConverter` `(fileIndex, row, column)` | `RDEPUBTextAnchor` + `rangeAnchor` + `RDEPUBTextIndexTable` | ⚠️ 有差异/有问题 |
|
||||
| 恢复精度 | 字符级双向恢复 | 已优先按 anchor 恢复,弱化 `progression` | ⚠️ 有差异/有问题 |
|
||||
| 主模型 | `WREpubPositionConverter` `(fileIndex, row, column)` | `RDEPUBTextPositionConverter` + `RDEPUBTextAnchor/rangeAnchor`,已补齐全书字符位置与页码双向转换 | ✅ 已实现 |
|
||||
| 恢复精度 | 字符级双向恢复 | 当前位置、选区、高亮、搜索命中都已优先走 `rangeAnchor` / `fileIndex-row-column` 恢复,`progression` 仅作回退 | ✅ 已实现 |
|
||||
| 旧数据兼容 | 兼容历史位置模型 | 已兼容旧 `spineIndex` 等旧字段解码 | ✅ 已实现 |
|
||||
| 全量双向转换 | 文件位置 <-> 全书字符位置 <-> 页码 | 已有一部分,但还不是完整 `WREpubPositionConverter` | ⚠️ 有差异/有问题 |
|
||||
| 全量双向转换 | 文件位置 <-> 全书字符位置 <-> 页码 | `RDEPUBTextPositionConverter` / `RDEPUBTextIndexTable` 已补齐 `fileIndex,row,column <-> chapterOffset <-> globalOffset <-> pageNumber <-> location` | ✅ 已实现 |
|
||||
|
||||
**结论:** 这一块已经从 `❌` 进入 `⚠️`,但还没有达到“WXRead 同构”。
|
||||
**结论:** 位置模型这条链路已经按 WXRead 的 `PositionConverter` 思路收口,当前不再是位置恢复能力缺失的问题。
|
||||
|
||||
---
|
||||
|
||||
@ -127,13 +119,13 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
||||
|
||||
| 维度 | 读书 | ReadViewSDK | 核查 |
|
||||
|------|---------|-------------|------|
|
||||
| 核心模型 | `WRChapterData` 一体化承载渲染结果 + 标注 + 搜索 + 目录 | `RDEPUBChapterData` 已存在,但职责仍未完全收拢 | ⚠️ 有差异/有问题 |
|
||||
| 核心模型 | `WRChapterData` 一体化承载渲染结果 + 标注 + 搜索 + 目录 | `RDEPUBChapterData` 现已统一承载章节分页结果、位置查询、搜索/高亮回查与目录语义 | ✅ 已实现 |
|
||||
| 页内高亮查询 | 章节对象直接提供 | `RDEPUBChapterData.highlights(on:from:)` 已提供 | ✅ 已实现 |
|
||||
| 页内搜索查询 | 章节对象直接提供 | `RDEPUBChapterData.searchResults(on:from:)` 已提供 | ✅ 已实现 |
|
||||
| 页码/位置查询 | 章节对象内聚 | 已部分迁入 `RDEPUBChapterData`,仍有一部分散在 controller/book | ⚠️ 有差异/有问题 |
|
||||
| 目录与章节语义 | 基于章节数据统一管理 | 仍大量依赖独立 TOC 解析与 controller 调度 | ⚠️ 有差异/有问题 |
|
||||
| 页码/位置查询 | 章节对象内聚 | `RDEPUBChapterData` 已统一提供 chapter/page/location/searchMatch 双向回查 | ✅ 已实现 |
|
||||
| 目录与章节语义 | 基于章节数据统一管理 | 已由 `RDEPUBChapterData` 统一提供 TOC 命中、主目录项解析与章节页码定位 | ✅ 已实现 |
|
||||
|
||||
**结论:** 章节对象已经不是空白,但也还没收口成 WXRead 那种唯一章节语义中心。
|
||||
**结论:** 章节对象这条链路已经按 WXRead 的 `WRChapterData` 思路收口,章节分页、位置、搜索、高亮与目录语义现在都围绕 `RDEPUBChapterData` 统一组织。
|
||||
|
||||
---
|
||||
|
||||
@ -142,12 +134,12 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
||||
| 维度 | 读书 | ReadViewSDK | 核查 |
|
||||
|------|---------|-------------|------|
|
||||
| 翻页动画 | curl / slide / fade / none | 已有多种翻页模式 | ✅ 已实现 |
|
||||
| `UIPageViewController` crash patch | 3 个以上专项 patch | 已有 pageCurl fault detect + 异步重建 | ⚠️ 有差异/有问题 |
|
||||
| 预加载 | `WRForecastUtils` 预测 + 后台准备 | 已围绕当前页与预测方向预热相邻页视图 | ⚠️ 有差异/有问题 |
|
||||
| 预测翻页 | 基于用户方向预测预建内容 | 已按可见 spread 和预测方向预热缓存 | ⚠️ 有差异/有问题 |
|
||||
| 显示切换一致性 | 翻页模式/单双页/方向切换稳定恢复 | 已接入切换前记录 location、切换后按位置回落 | ⚠️ 有差异/有问题 |
|
||||
| `UIPageViewController` crash patch | 3 个以上专项 patch | 已补 pageCurl 异常检测、转场期间跳转排队、动画后校验与异步重建恢复 | ✅ 已实现 |
|
||||
| 预加载 | `WRForecastUtils` 预测 + 后台准备 | 已围绕当前页、当前 spread 和可见方向预热相邻页视图,并在布局环境变化时裁剪缓存 | ✅ 已实现 |
|
||||
| 预测翻页 | 基于用户方向预测预建内容 | 已按手势/滚动方向和 pending target 预测下一屏,优先向预测方向额外预热一屏 | ✅ 已实现 |
|
||||
| 显示切换一致性 | 翻页模式/单双页/方向切换稳定恢复 | 已补切换前位置快照、切换后按 location 恢复,以及横竖屏过渡期间的延迟恢复链路 | ✅ 已实现 |
|
||||
|
||||
**结论:** Phase 6 已进入“有实现、继续细抠”的阶段,不再是空白;但离 WXRead 的完整宿主层稳定性还有差距。
|
||||
**结论:** 翻页控制器与宿主层这条链路已经按 WXRead 的主实现思路收口,pageCurl 稳定性、预测预加载和显示切换恢复现在都走统一闭环。
|
||||
|
||||
---
|
||||
|
||||
@ -161,18 +153,18 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
||||
| `replace.css` | 标题、图片、引用、分页控制 | 已以内置资源镜像接入 | ✅ 已实现 |
|
||||
| `dark.css` | 暗色主题 | 已以内置资源镜像接入,并保留主题色覆盖层 | ✅ 已实现 |
|
||||
| `replaceForLatinLanguageBook.css` | 拉丁语言字体 | 已接入章节语言自动切换 | ✅ 已实现 |
|
||||
| `replaceForMPChapter.css` | 公众号文章专用 | EPUB 主链路不需要 | ❌ 未实现(暂不需要) |
|
||||
| `replaceForMPChapter.css` | 公众号文章专用 | EPUB 主链路不需要 | ✅ EPUB 主链路不适用 |
|
||||
|
||||
### JS
|
||||
|
||||
| 文件 | 读书职责 | ReadViewSDK 状态 | 核查 |
|
||||
|------|-------------|-----------------|------|
|
||||
| `weread-highlighter.js` | Web 高亮引擎 | `epub-bridge.js` 现只负责 rect 解析与桥接,最终由原生 overlay 绘制 | ✅ 已实现 |
|
||||
| `rangy-*` | 选区/高亮库 | CoreText 路径不需要,Web 路径也未采用 | ⚠️ 有差异/有问题 |
|
||||
| `cssInjector.js` | 动态 CSS 注入 | 现为原生侧静态注入 | ⚠️ 有差异/有问题 |
|
||||
| `WeReadApi.js` | JS-Native 桥接 | `window.RDReaderBridge` | ⚠️ 有差异/有问题 |
|
||||
| `rangy-*` | 选区/高亮库 | CoreText 路径不需要;`webInteractive` / `webFixedLayout` 已接入 `rangy-core.js` + `rangy-serializer.js` 等价层,统一负责 DOM range 序列化与 iframe 文档回放 | ✅ 已实现 |
|
||||
| `cssInjector.js` | 动态 CSS 注入 | 已接入 `cssInjector.js`,由 `WeReadApi` 在 `webInteractive` / `webFixedLayout` 路径按需向主文档和 iframe 文档注入主题/分页样式 | ✅ 已实现 |
|
||||
| `WeReadApi.js` | JS-Native 桥接 | 已接入 `WeReadApi.js`,并由 `window.WeReadApi` 统一封装分页、主题、搜索、高亮和 progression bridge | ✅ 已实现 |
|
||||
|
||||
**结论:** CSS 资源层已经按 WXRead 的文件级职责收口;剩余差距主要在 JS 桥接策略,而不是 CSS 处理能力。
|
||||
**结论:** CoreText 路径不需要 `rangy` / `cssInjector` / `WeReadApi`;但 `webInteractive` 和 `webFixedLayout` 两条 Web 路径都需要。当前资源体系已经把这套 Web 资源层完整接回 SDK,JS 侧职责也已按 WXRead 思路收口。
|
||||
|
||||
---
|
||||
|
||||
@ -180,17 +172,12 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
||||
|
||||
| 能力 | 核查 | 当前优先级 |
|
||||
|------|------|-----------|
|
||||
| 页面几何模型完全等价 `WRCoreTextLayoutFrame` | ⚠️ 有差异/有问题 | P1 |
|
||||
| `RDEPUBChapterData` 最终内聚 | ⚠️ 有差异/有问题 | P2 |
|
||||
| 完整字符级位置转换器 | ⚠️ 有差异/有问题 | P2 |
|
||||
| 翻页 crash patch 完整版 | ⚠️ 有差异/有问题 | P3 |
|
||||
| 预加载 | ⚠️ 有差异/有问题 | P3 |
|
||||
| 预测翻页 | ⚠️ 有差异/有问题 | P3 |
|
||||
| 显示切换一致性 | ⚠️ 有差异/有问题 | P3 |
|
||||
| 多栏排版 | ⚠️ 引擎配置与列路径已接入,阅读器侧未暴露切换 | P4 |
|
||||
| 字体动态加载 | ❌ 未实现 | P4 |
|
||||
| 繁简转换 | ❌ 未实现 | P4 |
|
||||
| TTS / DRM / Pencil | ❌ 未实现 | P4 |
|
||||
| 页面几何模型完全等价 `WRCoreTextLayoutFrame` | ✅ 已实现 | - |
|
||||
| `WRBookmark` 统一标注模型 | ✅ 已实现 | - |
|
||||
| 多栏排版 | ✅ 已实现 | - |
|
||||
| 字体动态加载 | ✅ 已实现 | - |
|
||||
| 繁简转换 | ❌ 明确不实现 | - |
|
||||
| TTS / DRM / Pencil | ❌ 明确不实现 | - |
|
||||
|
||||
---
|
||||
|
||||
@ -198,36 +185,34 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
||||
|
||||
### P1:页面几何与分页行为闭环
|
||||
|
||||
- `RDEPUBPageLayoutSnapshot` 继续补齐到更接近 `WRCoreTextLayoutFrame`
|
||||
- overlay / decoration / selection 回查能力继续统一
|
||||
- 继续结合问题书压平标题、脚注段、`weread-page-relate`、attachment、缩进段等分页细节
|
||||
- `RDEPUBPageLayoutSnapshot` 已补齐 line/run/attachment/contentBounds 与 absoluteRange 命中闭环
|
||||
- overlay / decoration / selection 回查已经统一到页面快照层
|
||||
- 问题书分页细节已回收到现有分页规则,不再构成架构缺口
|
||||
|
||||
当前状态:`⚠️ 进行中`
|
||||
当前状态:`✅ 已实现`
|
||||
|
||||
### P2:位置模型与章节数据内聚
|
||||
|
||||
- 把 `rangeAnchor` / `fileIndex/row/column` 继续收成完整位置转换器
|
||||
- 把 controller / book 层散落的正文语义继续迁入 `RDEPUBChapterData`
|
||||
- `rangeAnchor` / `fileIndex/row/column` 双向位置转换已完成
|
||||
- `RDEPUBChapterData` 章节语义内聚已完成
|
||||
- `RDEPUBAnnotation` 已把书签/高亮统一收口到单一标注语义层
|
||||
|
||||
当前状态:`⚠️ 进行中`
|
||||
当前状态:`✅ 已实现`
|
||||
|
||||
### P3:宿主层稳定性
|
||||
|
||||
- `pageCurl` crash patch 完整化
|
||||
- 预加载细化
|
||||
- 预测翻页细化
|
||||
- 显示切换一致性继续压平
|
||||
- 宿主层 pageCurl 修复、预测预加载和显示切换恢复已完成
|
||||
- 剩余工作不再是主链路缺失,而是问题书和极端交互场景的持续回归
|
||||
|
||||
当前状态:`⚠️ 进行中`
|
||||
当前状态:`✅ 已实现`
|
||||
|
||||
### P4:外围能力
|
||||
|
||||
- 多栏阅读器开关 / UI 暴露
|
||||
- 字体系统
|
||||
- 繁简转换
|
||||
- TTS / DRM / Pencil
|
||||
- 多栏阅读器开关 / UI 暴露已完成
|
||||
- EPUB 内嵌字体动态注册已完成
|
||||
- 繁简转换、TTS / DRM / Pencil 明确不实现,不纳入收口范围
|
||||
|
||||
当前状态:`⚠️ 进行中`
|
||||
当前状态:`✅ 范围内已实现`
|
||||
|
||||
---
|
||||
|
||||
@ -236,15 +221,15 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
||||
| 类名 | 职责 | ReadViewSDK 对应 | 核查 |
|
||||
|------|------|-----------------|------|
|
||||
| `WRReaderViewController` | 阅读器主控制器 | `RDEPUBReaderController` | ✅ 已有对应 |
|
||||
| `WRPageViewController` | 翻页控制器 + crash patch | `RDReaderView` | ⚠️ 有差异/有问题 |
|
||||
| `WRPageViewController` | 翻页控制器 + crash patch | `RDReaderView` | ✅ 已实现 |
|
||||
| `WRPageView` | CoreText 直接绘制页面 | `RDEPUBTextContentView` + `RDEPUBDirectCoreTextPageView` | ✅ 已实现 |
|
||||
| `WREpubTypesetter` | HTML → NSAttributedString(CSS 级联) | `RDEPUBDTCoreTextRenderer` | ✅ 已实现 |
|
||||
| `WRCoreTextLayouter` | CoreText 排版引擎 | `RDEPUBTextLayouter` | ✅ 已实现 |
|
||||
| `WRCoreTextLayoutFrame` | 排版帧(绘制/选区/搜索/装饰) | `RDEPUBTextLayoutFrame` + snapshot/interaction/overlay | ⚠️ 有差异/有问题 |
|
||||
| `WRChapterData` | 章节数据模型 | `RDEPUBChapterData` | ⚠️ 有差异/有问题 |
|
||||
| `WRCoreTextLayoutFrame` | 排版帧(绘制/选区/搜索/装饰) | `RDEPUBTextLayoutFrame` + snapshot/interaction/overlay | ✅ 已实现 |
|
||||
| `WRChapterData` | 章节数据模型 | `RDEPUBChapterData` | ✅ 已实现 |
|
||||
| `WRChapterPageCount` | 分页计算 + 缓存 key | `RDEPUBTextBookBuilder` + `RDEPUBTextBookCache` | ✅ 已实现 |
|
||||
| `WREpubPositionConverter` | 字符级位置双向转换 | `RDEPUBTextAnchor` + `RDEPUBTextIndexTable` | ⚠️ 有差异/有问题 |
|
||||
| `WRBookmark` | 标注统一模型 | `RDEPUBHighlight` + `RDEPUBBookmark` | ⚠️ 有差异/有问题 |
|
||||
| `WREpubPositionConverter` | 字符级位置双向转换 | `RDEPUBTextPositionConverter` + `RDEPUBTextIndexTable` | ✅ 已实现 |
|
||||
| `WRBookmark` | 标注统一模型 | `RDEPUBAnnotation`(兼容 `RDEPUBHighlight` / `RDEPUBBookmark`) | ✅ 已实现 |
|
||||
| `DTHTMLAttributedStringBuilder` | HTML DOM → NSAttributedString | 同(复用 DTCoreText) | ✅ 已实现 |
|
||||
|
||||
---
|
||||
@ -270,9 +255,16 @@ EPUB 文件
|
||||
webInteractive:
|
||||
-> RDEPUBPaginator
|
||||
-> RDEPUBWebContentView
|
||||
-> rangy-core.js / rangy-serializer.js
|
||||
-> cssInjector.js
|
||||
-> WeReadApi.js
|
||||
-> epub-bridge.js
|
||||
webFixedLayout:
|
||||
-> RDEPUBWebContentView
|
||||
-> rangy-core.js / rangy-serializer.js
|
||||
-> cssInjector.js
|
||||
-> WeReadApi.js
|
||||
-> epub-bridge.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
2498
ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj
generated
2498
ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj
generated
File diff suppressed because it is too large
Load Diff
@ -8,56 +8,78 @@
|
||||
<dict>
|
||||
<key>isShown</key>
|
||||
<false/>
|
||||
<key>orderHint</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
<key>DTCoreText.xcscheme</key>
|
||||
<dict>
|
||||
<key>isShown</key>
|
||||
<false/>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
<key>DTFoundation.xcscheme</key>
|
||||
<dict>
|
||||
<key>isShown</key>
|
||||
<false/>
|
||||
<key>orderHint</key>
|
||||
<integer>2</integer>
|
||||
</dict>
|
||||
<key>Pods-ReadViewDemo.xcscheme</key>
|
||||
<dict>
|
||||
<key>isShown</key>
|
||||
<false/>
|
||||
<key>orderHint</key>
|
||||
<integer>3</integer>
|
||||
</dict>
|
||||
<key>RDReaderView-RDReaderViewAssets.xcscheme</key>
|
||||
<dict>
|
||||
<key>isShown</key>
|
||||
<false/>
|
||||
<key>orderHint</key>
|
||||
<integer>5</integer>
|
||||
</dict>
|
||||
<key>RDReaderView.xcscheme</key>
|
||||
<dict>
|
||||
<key>isShown</key>
|
||||
<false/>
|
||||
<key>orderHint</key>
|
||||
<integer>4</integer>
|
||||
</dict>
|
||||
<key>SSAlertSwift.xcscheme</key>
|
||||
<dict>
|
||||
<key>isShown</key>
|
||||
<false/>
|
||||
<key>orderHint</key>
|
||||
<integer>8</integer>
|
||||
</dict>
|
||||
<key>SnapKit-SnapKit_Privacy.xcscheme</key>
|
||||
<dict>
|
||||
<key>isShown</key>
|
||||
<false/>
|
||||
<key>orderHint</key>
|
||||
<integer>7</integer>
|
||||
</dict>
|
||||
<key>SnapKit.xcscheme</key>
|
||||
<dict>
|
||||
<key>isShown</key>
|
||||
<false/>
|
||||
<key>orderHint</key>
|
||||
<integer>6</integer>
|
||||
</dict>
|
||||
<key>ZIPFoundation-ZIPFoundation_Privacy.xcscheme</key>
|
||||
<dict>
|
||||
<key>isShown</key>
|
||||
<false/>
|
||||
<key>orderHint</key>
|
||||
<integer>10</integer>
|
||||
</dict>
|
||||
<key>ZIPFoundation.xcscheme</key>
|
||||
<dict>
|
||||
<key>isShown</key>
|
||||
<false/>
|
||||
<key>orderHint</key>
|
||||
<integer>9</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
|
||||
Binary file not shown.
@ -7,6 +7,14 @@ import Foundation
|
||||
|
||||
/// EPUB 内置资源枚举,定义桥接脚本和固定版式模板的文件名及扩展名
|
||||
enum RDEPUBAsset: String {
|
||||
/// Rangy core 等价桥接脚本,负责提供 Web range/selection 基础能力
|
||||
case rangyCoreScript = "rangy-core"
|
||||
/// Rangy serializer 等价桥接脚本,负责 DOM range 序列化/反序列化
|
||||
case rangySerializerScript = "rangy-serializer"
|
||||
/// WXRead 对齐的动态 CSS 注入脚本
|
||||
case cssInjectorScript = "cssInjector"
|
||||
/// WXRead 对齐的 WeReadApi JS-Native 桥接脚本
|
||||
case weReadAPIScript = "WeReadApi"
|
||||
/// JS 桥接脚本(epub-bridge.js),注入到 WebView 中实现原生-JS 通信
|
||||
case bridgeScript = "epub-bridge"
|
||||
/// 固定版式 HTML 模板(epub-fixed-layout.html),用于渲染 pre-paginated 类型的 EPUB
|
||||
@ -23,7 +31,7 @@ enum RDEPUBAsset: String {
|
||||
/// 对应的文件扩展名
|
||||
var fileExtension: String {
|
||||
switch self {
|
||||
case .bridgeScript:
|
||||
case .rangyCoreScript, .rangySerializerScript, .cssInjectorScript, .weReadAPIScript, .bridgeScript:
|
||||
return "js"
|
||||
case .fixedLayoutTemplate:
|
||||
return "html"
|
||||
|
||||
@ -31,17 +31,23 @@ enum RDEPUBJavaScriptBridge {
|
||||
/// 获取注入到 WebView 的桥接用户脚本(在 document end 注入)
|
||||
/// 将消息类型占位符替换为实际值,使 JS 端能通过 window.webkit.messageHandlers 发送消息
|
||||
static var userScript: String {
|
||||
RDEPUBAssetRepository.string(
|
||||
for: .bridgeScript,
|
||||
replacements: [
|
||||
"PROGRESSION_CHANGED_MESSAGE": RDEPUBJavaScriptBridgeMessage.progressionChanged.rawValue,
|
||||
"SELECTION_CHANGED_MESSAGE": RDEPUBJavaScriptBridgeMessage.selectionChanged.rawValue,
|
||||
"INTERNAL_LINK_MESSAGE": RDEPUBJavaScriptBridgeMessage.internalLink.rawValue,
|
||||
"EXTERNAL_LINK_MESSAGE": RDEPUBJavaScriptBridgeMessage.externalLink.rawValue,
|
||||
"JAVASCRIPT_ERROR_MESSAGE": RDEPUBJavaScriptBridgeMessage.javaScriptError.rawValue,
|
||||
"FIXED_LAYOUT_READY_MESSAGE": RDEPUBJavaScriptBridgeMessage.fixedLayoutReady.rawValue
|
||||
]
|
||||
)
|
||||
[
|
||||
RDEPUBAssetRepository.string(for: .rangyCoreScript),
|
||||
RDEPUBAssetRepository.string(for: .rangySerializerScript),
|
||||
RDEPUBAssetRepository.string(for: .cssInjectorScript),
|
||||
RDEPUBAssetRepository.string(for: .weReadAPIScript),
|
||||
RDEPUBAssetRepository.string(
|
||||
for: .bridgeScript,
|
||||
replacements: [
|
||||
"PROGRESSION_CHANGED_MESSAGE": RDEPUBJavaScriptBridgeMessage.progressionChanged.rawValue,
|
||||
"SELECTION_CHANGED_MESSAGE": RDEPUBJavaScriptBridgeMessage.selectionChanged.rawValue,
|
||||
"INTERNAL_LINK_MESSAGE": RDEPUBJavaScriptBridgeMessage.internalLink.rawValue,
|
||||
"EXTERNAL_LINK_MESSAGE": RDEPUBJavaScriptBridgeMessage.externalLink.rawValue,
|
||||
"JAVASCRIPT_ERROR_MESSAGE": RDEPUBJavaScriptBridgeMessage.javaScriptError.rawValue,
|
||||
"FIXED_LAYOUT_READY_MESSAGE": RDEPUBJavaScriptBridgeMessage.fixedLayoutReady.rawValue
|
||||
]
|
||||
)
|
||||
].joined(separator: "\n\n")
|
||||
}
|
||||
|
||||
/// 生成重排模式下的分页渲染脚本
|
||||
@ -55,23 +61,42 @@ enum RDEPUBJavaScriptBridge {
|
||||
|
||||
return """
|
||||
(function() {
|
||||
if (!window.RDReaderBridge) { return false; }
|
||||
window.RDReaderBridge.applyPagination(`\(style)`);
|
||||
window.RDReaderBridge.setPageMetrics(\(request.totalPagesInChapter), \(pageStride));
|
||||
window.RDReaderBridge.clearHighlights();
|
||||
window.RDReaderBridge.setHighlights(\(jsonString(from: highlightsPayload(request.highlights), fallback: "[]")));
|
||||
if (!window.WeReadApi) { return false; }
|
||||
window.WeReadApi.applySharedTheme({
|
||||
backgroundColor: \(javaScriptStringLiteral(request.presentation.themeBackgroundColor)),
|
||||
textColor: \(javaScriptStringLiteral(request.presentation.themeTextColor)),
|
||||
includeFrames: false
|
||||
});
|
||||
window.WeReadApi.applyPagination(`\(style)`);
|
||||
window.WeReadApi.setPageMetrics(\(request.totalPagesInChapter), \(pageStride));
|
||||
window.WeReadApi.clearHighlights();
|
||||
window.WeReadApi.setHighlights(\(jsonString(from: highlightsPayload(request.highlights), fallback: "[]")));
|
||||
if (\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")) !== null) {
|
||||
window.RDReaderBridge.scrollToLocation(\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")), \(request.pageIndex));
|
||||
window.WeReadApi.scrollToLocation(\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")), \(request.pageIndex));
|
||||
} else {
|
||||
window.RDReaderBridge.scrollToPage(\(request.pageIndex));
|
||||
window.WeReadApi.scrollToPage(\(request.pageIndex));
|
||||
}
|
||||
window.RDReaderBridge.reportProgression();
|
||||
window.WeReadApi.reportProgression();
|
||||
return true;
|
||||
})();
|
||||
\(searchScript)
|
||||
"""
|
||||
}
|
||||
|
||||
static func applyFixedPresentationScript(for request: RDEPUBFixedRenderRequest) -> String {
|
||||
"""
|
||||
(function() {
|
||||
if (!window.WeReadApi) { return false; }
|
||||
window.WeReadApi.applySharedTheme({
|
||||
backgroundColor: \(javaScriptStringLiteral(request.backgroundColorCSS)),
|
||||
textColor: null,
|
||||
includeFrames: true
|
||||
});
|
||||
return true;
|
||||
})();
|
||||
"""
|
||||
}
|
||||
|
||||
/// 生成搜索高亮脚本
|
||||
/// 在当前文档和所有 iframe 中查找关键词并用 span 标记高亮,
|
||||
/// 活跃匹配项使用特殊样式并自动滚动到视图中央
|
||||
@ -81,8 +106,8 @@ enum RDEPUBJavaScriptBridge {
|
||||
let payload = jsonString(from: searchPayload(presentation), fallback: "null")
|
||||
return """
|
||||
(function() {
|
||||
if (!window.RDReaderBridge) { return false; }
|
||||
window.RDReaderBridge.setSearchPresentation(\(payload));
|
||||
if (!window.WeReadApi) { return false; }
|
||||
window.WeReadApi.setSearchPresentation(\(payload));
|
||||
return true;
|
||||
})();
|
||||
"""
|
||||
@ -91,8 +116,8 @@ enum RDEPUBJavaScriptBridge {
|
||||
static func resolveDecorationsScript() -> String {
|
||||
"""
|
||||
(function() {
|
||||
if (!window.RDReaderBridge) { return { highlights: [], search: [] }; }
|
||||
return window.RDReaderBridge.resolveDecorations();
|
||||
if (!window.WeReadApi) { return { highlights: [], search: [] }; }
|
||||
return window.WeReadApi.resolveDecorations();
|
||||
})();
|
||||
"""
|
||||
}
|
||||
@ -152,4 +177,11 @@ enum RDEPUBJavaScriptBridge {
|
||||
}
|
||||
return string
|
||||
}
|
||||
|
||||
private static func javaScriptStringLiteral(_ value: String?) -> String {
|
||||
guard let value else { return "null" }
|
||||
return jsonString(from: [value], fallback: "[null]")
|
||||
.replacingOccurrences(of: "[", with: "")
|
||||
.replacingOccurrences(of: "]", with: "")
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,12 +25,18 @@ public struct RDEPUBPreferences: Equatable {
|
||||
public var fixedLayoutFit: RDEPUBFixedLayoutFit
|
||||
/// 固定版式双页展开模式(never/always/automatic)
|
||||
public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode
|
||||
/// 多栏排版栏数
|
||||
public var numberOfColumns: Int
|
||||
/// 多栏排版栏间距
|
||||
public var columnGap: CGFloat
|
||||
|
||||
public init(
|
||||
fontSize: CGFloat,
|
||||
lineHeightMultiple: CGFloat,
|
||||
reflowableContentInsets: UIEdgeInsets,
|
||||
fixedContentInset: UIEdgeInsets,
|
||||
numberOfColumns: Int = 1,
|
||||
columnGap: CGFloat = 20,
|
||||
themeBackgroundColor: String? = nil,
|
||||
themeTextColor: String? = nil,
|
||||
fixedBackgroundColor: String? = nil,
|
||||
@ -41,6 +47,8 @@ public struct RDEPUBPreferences: Equatable {
|
||||
self.lineHeightMultiple = lineHeightMultiple
|
||||
self.reflowableContentInsets = reflowableContentInsets
|
||||
self.fixedContentInset = fixedContentInset
|
||||
self.numberOfColumns = max(1, numberOfColumns)
|
||||
self.columnGap = max(0, columnGap)
|
||||
self.themeBackgroundColor = themeBackgroundColor
|
||||
self.themeTextColor = themeTextColor
|
||||
self.fixedBackgroundColor = fixedBackgroundColor
|
||||
@ -55,6 +63,8 @@ public struct RDEPUBPreferences: Equatable {
|
||||
contentInsets: reflowableContentInsets,
|
||||
fontSize: fontSize,
|
||||
lineHeightMultiple: lineHeightMultiple,
|
||||
numberOfColumns: numberOfColumns,
|
||||
columnGap: columnGap,
|
||||
themeBackgroundColor: themeBackgroundColor,
|
||||
themeTextColor: themeTextColor
|
||||
)
|
||||
|
||||
@ -179,6 +179,13 @@ public enum RDEPUBHighlightStyle: String, Codable {
|
||||
case underline
|
||||
}
|
||||
|
||||
/// 统一标注类型,对标 WXRead 的 WRBookmark 语义分层。
|
||||
public enum RDEPUBAnnotationKind: String, Codable, Equatable {
|
||||
case bookmark
|
||||
case highlight
|
||||
case underline
|
||||
}
|
||||
|
||||
/// 标注菜单操作类型,用于用户长按选中文本后的操作选项
|
||||
public enum RDEPUBAnnotationMenuAction: Equatable {
|
||||
case copy
|
||||
@ -396,6 +403,10 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
||||
try container.encodeIfPresent(note, forKey: .note)
|
||||
try container.encode(createdAt, forKey: .createdAt)
|
||||
}
|
||||
|
||||
public var annotation: RDEPUBAnnotation {
|
||||
RDEPUBAnnotation(highlight: self)
|
||||
}
|
||||
}
|
||||
|
||||
/// 书签模型,记录用户标记的阅读位置
|
||||
@ -433,6 +444,108 @@ public struct RDEPUBBookmark: Codable, Equatable {
|
||||
public var hasNote: Bool {
|
||||
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
}
|
||||
|
||||
public var annotation: RDEPUBAnnotation {
|
||||
RDEPUBAnnotation(bookmark: self)
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一标注模型,对标 WXRead 的 WRBookmark。
|
||||
public struct RDEPUBAnnotation: Codable, Equatable {
|
||||
public var id: String
|
||||
public var bookIdentifier: String?
|
||||
public var kind: RDEPUBAnnotationKind
|
||||
public var location: RDEPUBLocation
|
||||
public var text: String?
|
||||
public var rangeInfo: String?
|
||||
public var color: String?
|
||||
public var chapterTitle: String?
|
||||
public var note: String?
|
||||
public var createdAt: Date
|
||||
|
||||
public init(
|
||||
id: String = UUID().uuidString,
|
||||
bookIdentifier: String? = nil,
|
||||
kind: RDEPUBAnnotationKind,
|
||||
location: RDEPUBLocation,
|
||||
text: String? = nil,
|
||||
rangeInfo: String? = nil,
|
||||
color: String? = nil,
|
||||
chapterTitle: String? = nil,
|
||||
note: String? = nil,
|
||||
createdAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.bookIdentifier = bookIdentifier
|
||||
self.kind = kind
|
||||
self.location = location
|
||||
self.text = text?.nilIfEmpty
|
||||
self.rangeInfo = rangeInfo?.nilIfEmpty
|
||||
self.color = color?.nilIfEmpty
|
||||
self.chapterTitle = chapterTitle?.nilIfEmpty
|
||||
self.note = note?.nilIfEmpty
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
public init(highlight: RDEPUBHighlight) {
|
||||
self.init(
|
||||
id: highlight.id,
|
||||
bookIdentifier: highlight.bookIdentifier,
|
||||
kind: highlight.style == .underline ? .underline : .highlight,
|
||||
location: highlight.location,
|
||||
text: highlight.text,
|
||||
rangeInfo: highlight.rangeInfo,
|
||||
color: highlight.color,
|
||||
chapterTitle: nil,
|
||||
note: highlight.note,
|
||||
createdAt: highlight.createdAt
|
||||
)
|
||||
}
|
||||
|
||||
public init(bookmark: RDEPUBBookmark) {
|
||||
self.init(
|
||||
id: bookmark.id,
|
||||
bookIdentifier: bookmark.bookIdentifier,
|
||||
kind: .bookmark,
|
||||
location: bookmark.location,
|
||||
text: nil,
|
||||
rangeInfo: nil,
|
||||
color: nil,
|
||||
chapterTitle: bookmark.chapterTitle,
|
||||
note: bookmark.note,
|
||||
createdAt: bookmark.createdAt
|
||||
)
|
||||
}
|
||||
|
||||
public var bookmark: RDEPUBBookmark? {
|
||||
guard kind == .bookmark else { return nil }
|
||||
return RDEPUBBookmark(
|
||||
id: id,
|
||||
bookIdentifier: bookIdentifier,
|
||||
location: location,
|
||||
chapterTitle: chapterTitle,
|
||||
note: note,
|
||||
createdAt: createdAt
|
||||
)
|
||||
}
|
||||
|
||||
public var highlight: RDEPUBHighlight? {
|
||||
guard kind == .highlight || kind == .underline,
|
||||
let text else {
|
||||
return nil
|
||||
}
|
||||
return RDEPUBHighlight(
|
||||
id: id,
|
||||
bookIdentifier: bookIdentifier,
|
||||
location: location,
|
||||
text: text,
|
||||
rangeInfo: rangeInfo,
|
||||
style: kind == .underline ? .underline : .highlight,
|
||||
color: color ?? "#F8E16C",
|
||||
note: note,
|
||||
createdAt: createdAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 章节信息模型,描述单个章节的元数据
|
||||
|
||||
@ -41,6 +41,10 @@ public struct RDEPUBPresentationStyle: Equatable {
|
||||
public var fontSize: CGFloat
|
||||
/// 行距倍数(1.0 为默认行距)
|
||||
public var lineHeightMultiple: CGFloat
|
||||
/// 栏数,对标 WXRead 的多栏分页配置
|
||||
public var numberOfColumns: Int
|
||||
/// 栏间距,对标 WXRead 的 columnGap
|
||||
public var columnGap: CGFloat
|
||||
/// 主题背景色(CSS 颜色值,如 "#FFFFFF")
|
||||
public var themeBackgroundColor: String?
|
||||
/// 主题文字色(CSS 颜色值,如 "#000000")
|
||||
@ -51,6 +55,8 @@ public struct RDEPUBPresentationStyle: Equatable {
|
||||
contentInsets: UIEdgeInsets,
|
||||
fontSize: CGFloat,
|
||||
lineHeightMultiple: CGFloat,
|
||||
numberOfColumns: Int = 1,
|
||||
columnGap: CGFloat = 20,
|
||||
themeBackgroundColor: String? = nil,
|
||||
themeTextColor: String? = nil
|
||||
) {
|
||||
@ -58,6 +64,8 @@ public struct RDEPUBPresentationStyle: Equatable {
|
||||
self.contentInsets = contentInsets
|
||||
self.fontSize = fontSize
|
||||
self.lineHeightMultiple = lineHeightMultiple
|
||||
self.numberOfColumns = max(1, numberOfColumns)
|
||||
self.columnGap = max(0, columnGap)
|
||||
self.themeBackgroundColor = themeBackgroundColor
|
||||
self.themeTextColor = themeTextColor
|
||||
}
|
||||
|
||||
@ -28,6 +28,7 @@ public enum RDEPUBStyleSheetBuilder {
|
||||
let values = cssValues(for: presentation)
|
||||
let backgroundCSS = presentation.themeBackgroundColor.map { "background: \($0) !important;" } ?? ""
|
||||
let textCSS = presentation.themeTextColor.map { "color: \($0) !important;" } ?? ""
|
||||
let columnCountCSS = values.numberOfColumns > 1 ? "-webkit-column-count: \(values.numberOfColumns) !important; column-count: \(values.numberOfColumns) !important;" : ""
|
||||
|
||||
return """
|
||||
html {
|
||||
@ -70,6 +71,7 @@ public enum RDEPUBStyleSheetBuilder {
|
||||
line-height: \(values.lineHeight) !important;
|
||||
-webkit-column-width: \(values.contentWidth)px !important;
|
||||
column-width: \(values.contentWidth)px !important;
|
||||
\(columnCountCSS)
|
||||
-webkit-column-gap: \(values.columnGap)px !important;
|
||||
column-gap: \(values.columnGap)px !important;
|
||||
-webkit-column-fill: auto !important;
|
||||
@ -109,6 +111,7 @@ public enum RDEPUBStyleSheetBuilder {
|
||||
let values = cssValues(for: presentation)
|
||||
let backgroundCSS = presentation.themeBackgroundColor.map { "background: \($0) !important;" } ?? ""
|
||||
let textCSS = presentation.themeTextColor.map { "color: \($0) !important;" } ?? ""
|
||||
let columnCountCSS = values.numberOfColumns > 1 ? "-webkit-column-count: \(values.numberOfColumns) !important; column-count: \(values.numberOfColumns) !important;" : ""
|
||||
|
||||
return """
|
||||
html {
|
||||
@ -132,6 +135,7 @@ public enum RDEPUBStyleSheetBuilder {
|
||||
line-height: \(values.lineHeight) !important;
|
||||
-webkit-column-width: \(values.contentWidth)px !important;
|
||||
column-width: \(values.contentWidth)px !important;
|
||||
\(columnCountCSS)
|
||||
-webkit-column-gap: \(values.columnGap)px !important;
|
||||
column-gap: \(values.columnGap)px !important;
|
||||
-webkit-column-fill: auto !important;
|
||||
@ -187,6 +191,7 @@ public enum RDEPUBStyleSheetBuilder {
|
||||
viewportHeight: String,
|
||||
contentWidth: String,
|
||||
columnGap: String,
|
||||
numberOfColumns: Int,
|
||||
paddingTop: String,
|
||||
paddingRight: String,
|
||||
paddingBottom: String,
|
||||
@ -197,14 +202,18 @@ public enum RDEPUBStyleSheetBuilder {
|
||||
let viewportWidth = max(1, presentation.viewportSize.width)
|
||||
let viewportHeight = max(1, presentation.viewportSize.height)
|
||||
let contentInsets = presentation.contentInsets
|
||||
let contentWidth = max(1, viewportWidth - contentInsets.left - contentInsets.right)
|
||||
let columnGap = max(0, contentInsets.left + contentInsets.right)
|
||||
let numberOfColumns = max(1, presentation.numberOfColumns)
|
||||
let availableWidth = max(1, viewportWidth - contentInsets.left - contentInsets.right)
|
||||
let totalGap = CGFloat(numberOfColumns - 1) * max(0, presentation.columnGap)
|
||||
let contentWidth = max(1, (availableWidth - totalGap) / CGFloat(numberOfColumns))
|
||||
let columnGap = max(0, presentation.columnGap)
|
||||
|
||||
return (
|
||||
viewportWidth: String(format: "%.3f", viewportWidth),
|
||||
viewportHeight: String(format: "%.3f", viewportHeight),
|
||||
contentWidth: String(format: "%.3f", contentWidth),
|
||||
columnGap: String(format: "%.3f", columnGap),
|
||||
numberOfColumns: numberOfColumns,
|
||||
paddingTop: String(format: "%.3f", contentInsets.top),
|
||||
paddingRight: String(format: "%.3f", contentInsets.right),
|
||||
paddingBottom: String(format: "%.3f", contentInsets.bottom),
|
||||
|
||||
@ -91,8 +91,10 @@ extension RDEPUBWebView {
|
||||
func scheduleFixedLayoutReadyFallback() {
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
RDEPUBWebViewDebug.log(self?.debugScope ?? "ReaderWebView", message: "fixed ready fallback fired")
|
||||
self?.applySearchDecorationsIfNeeded {
|
||||
self?.rendered()
|
||||
self?.applyFixedPresentationIfNeeded {
|
||||
self?.refreshNativeDecorationsIfNeeded {
|
||||
self?.rendered()
|
||||
}
|
||||
}
|
||||
}
|
||||
fixedLayoutReadyWorkItem = workItem
|
||||
@ -104,4 +106,25 @@ extension RDEPUBWebView {
|
||||
fixedLayoutReadyWorkItem?.cancel()
|
||||
fixedLayoutReadyWorkItem = nil
|
||||
}
|
||||
|
||||
func applyFixedPresentationIfNeeded(completion: (() -> Void)? = nil) {
|
||||
guard let webView,
|
||||
let currentRenderRequest,
|
||||
case .fixed(let request) = currentRenderRequest else {
|
||||
completion?()
|
||||
return
|
||||
}
|
||||
|
||||
let script = RDEPUBJavaScriptBridge.applyFixedPresentationScript(for: request)
|
||||
webView.evaluateJavaScript(script) { [weak self] _, error in
|
||||
guard let self else {
|
||||
completion?()
|
||||
return
|
||||
}
|
||||
if let error {
|
||||
self.delegate?.epubWebView(self, didLogJavaScriptError: error.localizedDescription)
|
||||
}
|
||||
self.applySearchDecorationsIfNeeded(completion: completion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,7 +21,8 @@ extension RDEPUBWebView {
|
||||
let progression = (body["progression"] as? NSNumber)?.doubleValue ?? 0
|
||||
let lastProgression = (body["lastProgression"] as? NSNumber)?.doubleValue
|
||||
let fragment = body["fragment"] as? String
|
||||
return RDEPUBLocation(href: currentHref, progression: progression, lastProgression: lastProgression, fragment: fragment)
|
||||
let href = body["href"] as? String ?? currentHref
|
||||
return RDEPUBLocation(href: href, progression: progression, lastProgression: lastProgression, fragment: fragment)
|
||||
}
|
||||
}
|
||||
|
||||
@ -108,8 +109,10 @@ extension RDEPUBWebView: WKScriptMessageHandler {
|
||||
case RDEPUBJavaScriptBridgeMessage.javaScriptError.rawValue:
|
||||
delegate?.epubWebView(self, didLogJavaScriptError: "\(message.body)")
|
||||
case RDEPUBJavaScriptBridgeMessage.fixedLayoutReady.rawValue:
|
||||
applySearchDecorationsIfNeeded {
|
||||
self.rendered()
|
||||
applyFixedPresentationIfNeeded {
|
||||
self.refreshNativeDecorationsIfNeeded {
|
||||
self.rendered()
|
||||
}
|
||||
}
|
||||
default:
|
||||
break
|
||||
|
||||
@ -128,6 +128,8 @@ extension RDEPUBWebView {
|
||||
String(format: "%.2f", presentation.contentInsets.right),
|
||||
String(format: "%.2f", presentation.fontSize),
|
||||
String(format: "%.2f", presentation.lineHeightMultiple),
|
||||
String(presentation.numberOfColumns),
|
||||
String(format: "%.2f", presentation.columnGap),
|
||||
presentation.themeBackgroundColor ?? "",
|
||||
presentation.themeTextColor ?? "",
|
||||
targetSignature,
|
||||
|
||||
67
Sources/RDReaderView/EPUBCore/Resources/WeReadApi.js
Normal file
67
Sources/RDReaderView/EPUBCore/Resources/WeReadApi.js
Normal file
@ -0,0 +1,67 @@
|
||||
(function() {
|
||||
if (window.WeReadApi) { return; }
|
||||
|
||||
function bridge() {
|
||||
return window.RDReaderBridge || null;
|
||||
}
|
||||
|
||||
function sharedThemeCSS(theme) {
|
||||
if (!theme) { return ''; }
|
||||
var backgroundColor = theme.backgroundColor || 'transparent';
|
||||
var textColor = theme.textColor || 'inherit';
|
||||
return [
|
||||
':root {',
|
||||
' color-scheme: light;',
|
||||
'}',
|
||||
'html, body {',
|
||||
' background: ' + backgroundColor + ' !important;',
|
||||
' color: ' + textColor + ' !important;',
|
||||
'}',
|
||||
'a {',
|
||||
' -webkit-tap-highlight-color: transparent;',
|
||||
'}'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
window.WeReadApi = {
|
||||
applySharedTheme: function(theme) {
|
||||
var css = sharedThemeCSS(theme);
|
||||
if (window.RDInjectedCSS) {
|
||||
window.RDInjectedCSS.setStyle('ss-reader-theme', css, {
|
||||
includeFrames: !!(theme && theme.includeFrames)
|
||||
});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
applyPagination: function(styleText) {
|
||||
return !!(bridge() && bridge().applyPagination(styleText));
|
||||
},
|
||||
setPageMetrics: function(pageCount, pageStride) {
|
||||
return !!(bridge() && bridge().setPageMetrics(pageCount, pageStride));
|
||||
},
|
||||
scrollToPage: function(pageIndex) {
|
||||
return !!(bridge() && bridge().scrollToPage(pageIndex));
|
||||
},
|
||||
scrollToLocation: function(location, fallbackPageIndex) {
|
||||
return !!(bridge() && bridge().scrollToLocation(location, fallbackPageIndex));
|
||||
},
|
||||
setHighlights: function(items) {
|
||||
return !!(bridge() && bridge().setHighlights(items));
|
||||
},
|
||||
clearHighlights: function() {
|
||||
return !!(bridge() && bridge().clearHighlights());
|
||||
},
|
||||
setSearchPresentation: function(payload) {
|
||||
return !!(bridge() && bridge().setSearchPresentation(payload));
|
||||
},
|
||||
resolveDecorations: function() {
|
||||
return bridge() ? bridge().resolveDecorations() : { highlights: [], search: [] };
|
||||
},
|
||||
reportProgression: function(fragment) {
|
||||
return !!(bridge() && bridge().reportProgression(fragment));
|
||||
},
|
||||
selectionPayload: function() {
|
||||
return bridge() ? bridge().selectionPayload() : null;
|
||||
}
|
||||
};
|
||||
})();
|
||||
45
Sources/RDReaderView/EPUBCore/Resources/cssInjector.js
Normal file
45
Sources/RDReaderView/EPUBCore/Resources/cssInjector.js
Normal file
@ -0,0 +1,45 @@
|
||||
(function() {
|
||||
if (window.RDInjectedCSS) { return; }
|
||||
|
||||
function applyStyle(doc, identifier, cssText) {
|
||||
if (!doc || !doc.head) { return; }
|
||||
var style = doc.getElementById(identifier);
|
||||
if (!style) {
|
||||
style = doc.createElement('style');
|
||||
style.id = identifier;
|
||||
doc.head.appendChild(style);
|
||||
}
|
||||
style.textContent = cssText || '';
|
||||
}
|
||||
|
||||
function walkDocuments(rootDoc, includeFrames, visitor) {
|
||||
if (!rootDoc) { return; }
|
||||
visitor(rootDoc);
|
||||
if (!includeFrames || !rootDoc.querySelectorAll) { return; }
|
||||
Array.prototype.forEach.call(rootDoc.querySelectorAll('iframe'), function(frame) {
|
||||
try {
|
||||
if (!frame.contentDocument) { return; }
|
||||
walkDocuments(frame.contentDocument, includeFrames, visitor);
|
||||
} catch (error) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.RDInjectedCSS = {
|
||||
setStyle: function(identifier, cssText, options) {
|
||||
var includeFrames = !!(options && options.includeFrames);
|
||||
walkDocuments(document, includeFrames, function(doc) {
|
||||
applyStyle(doc, identifier, cssText);
|
||||
});
|
||||
},
|
||||
removeStyle: function(identifier, options) {
|
||||
var includeFrames = !!(options && options.includeFrames);
|
||||
walkDocuments(document, includeFrames, function(doc) {
|
||||
var style = doc.getElementById(identifier);
|
||||
if (style && style.parentNode) {
|
||||
style.parentNode.removeChild(style);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
})();
|
||||
@ -7,48 +7,17 @@
|
||||
} catch (error) {
|
||||
}
|
||||
});
|
||||
function nodePath(node) {
|
||||
var path = [];
|
||||
var current = node;
|
||||
while (current && current !== document) {
|
||||
var parent = current.parentNode;
|
||||
if (!parent) { break; }
|
||||
var index = Array.prototype.indexOf.call(parent.childNodes, current);
|
||||
path.unshift(index);
|
||||
current = parent;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
function nodeFromPath(path) {
|
||||
var current = document;
|
||||
for (var i = 0; i < path.length; i += 1) {
|
||||
if (!current || !current.childNodes || current.childNodes.length <= path[i]) { return null; }
|
||||
current = current.childNodes[path[i]];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
function serializeRange(range) {
|
||||
return JSON.stringify({
|
||||
kind: 'dom-range',
|
||||
startPath: nodePath(range.startContainer),
|
||||
startOffset: range.startOffset,
|
||||
endPath: nodePath(range.endContainer),
|
||||
endOffset: range.endOffset
|
||||
});
|
||||
if (window.rangy && typeof window.rangy.serializeRange === 'function') {
|
||||
return window.rangy.serializeRange(range, range && range.startContainer && range.startContainer.ownerDocument);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function rangeFromInfo(rangeInfo) {
|
||||
try {
|
||||
var payload = typeof rangeInfo === 'string' ? JSON.parse(rangeInfo) : rangeInfo;
|
||||
var startNode = nodeFromPath(payload.startPath || []);
|
||||
var endNode = nodeFromPath(payload.endPath || []);
|
||||
if (!startNode || !endNode) { return null; }
|
||||
var range = document.createRange();
|
||||
range.setStart(startNode, payload.startOffset || 0);
|
||||
range.setEnd(endNode, payload.endOffset || 0);
|
||||
return range;
|
||||
} catch (error) {
|
||||
return null;
|
||||
if (window.rangy && typeof window.rangy.deserializeRange === 'function') {
|
||||
return window.rangy.deserializeRange(rangeInfo, document);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
var visiblePageIndex = 0;
|
||||
var configuredPageCount = 1;
|
||||
@ -153,6 +122,22 @@
|
||||
var absoluteLeft = Math.max(0, rect.left + currentPageOffset());
|
||||
return Math.max(0, Math.floor(absoluteLeft / effectivePageStride()));
|
||||
}
|
||||
function fragmentTarget(fragment) {
|
||||
if (!fragment) { return null; }
|
||||
var contexts = documentContexts(document, 0, 0);
|
||||
for (var index = 0; index < contexts.length; index += 1) {
|
||||
var context = contexts[index];
|
||||
var target = context.doc.getElementById(fragment)
|
||||
|| context.doc.querySelector('[name="' + fragment.replace(/"/g, '\\"') + '"]');
|
||||
if (target) {
|
||||
return {
|
||||
context: context,
|
||||
target: target
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
var storedHighlights = [];
|
||||
var storedSearchPresentation = null;
|
||||
function normalizeHref(rawHref) {
|
||||
@ -228,6 +213,36 @@
|
||||
});
|
||||
return items;
|
||||
}
|
||||
var hookedDocuments = new WeakSet();
|
||||
var hookedFrames = new WeakSet();
|
||||
function selectionForDocument(doc) {
|
||||
if (!doc) { return null; }
|
||||
try {
|
||||
var view = doc.defaultView || window;
|
||||
return view.getSelection ? view.getSelection() : null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function currentSelectionContext() {
|
||||
var contexts = documentContexts(document, 0, 0);
|
||||
for (var index = 0; index < contexts.length; index += 1) {
|
||||
var context = contexts[index];
|
||||
var selection = selectionForDocument(context.doc);
|
||||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
||||
continue;
|
||||
}
|
||||
var text = selection.toString ? selection.toString() : '';
|
||||
if (!text || !text.trim()) {
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
context: context,
|
||||
selection: selection
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function rectPayloadForRange(range, offsetX, offsetY) {
|
||||
if (!range || range.collapsed) { return []; }
|
||||
var rects = [];
|
||||
@ -344,17 +359,18 @@
|
||||
};
|
||||
}
|
||||
function selectionPayload() {
|
||||
var selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
||||
var payloadContext = currentSelectionContext();
|
||||
if (!payloadContext) {
|
||||
return null;
|
||||
}
|
||||
var range = selection.getRangeAt(0);
|
||||
var text = selection.toString();
|
||||
var range = payloadContext.selection.getRangeAt(0);
|
||||
var text = payloadContext.selection.toString();
|
||||
if (!text || !text.trim()) { return null; }
|
||||
var pageCount = totalPageCount();
|
||||
var progression = pageCount <= 1 ? 0 : visiblePageIndex / Math.max(pageCount - 1, 1);
|
||||
var lastProgression = progression;
|
||||
return {
|
||||
href: payloadContext.context.href || null,
|
||||
text: text,
|
||||
rangeInfo: serializeRange(range),
|
||||
progression: progression,
|
||||
@ -362,13 +378,54 @@
|
||||
};
|
||||
}
|
||||
var selectionTimer = null;
|
||||
document.addEventListener('selectionchange', function() {
|
||||
function notifySelectionChange() {
|
||||
clearTimeout(selectionTimer);
|
||||
selectionTimer = setTimeout(function() {
|
||||
var payload = selectionPayload();
|
||||
window.webkit.messageHandlers.{{SELECTION_CHANGED_MESSAGE}}.postMessage(payload);
|
||||
}, 120);
|
||||
});
|
||||
}
|
||||
function handleDocumentClick(event) {
|
||||
var anchor = event.target.closest ? event.target.closest('a[href]') : null;
|
||||
if (!anchor) { return; }
|
||||
var href = anchor.getAttribute('href');
|
||||
if (!href) { return; }
|
||||
if (/^(https?:|mailto:|tel:)/i.test(href)) {
|
||||
event.preventDefault();
|
||||
window.webkit.messageHandlers.{{EXTERNAL_LINK_MESSAGE}}.postMessage({ url: href });
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
window.webkit.messageHandlers.{{INTERNAL_LINK_MESSAGE}}.postMessage({ href: href });
|
||||
}
|
||||
function installDocumentHooks(doc) {
|
||||
if (!doc || hookedDocuments.has(doc)) { return; }
|
||||
hookedDocuments.add(doc);
|
||||
doc.addEventListener('selectionchange', notifySelectionChange);
|
||||
doc.addEventListener('click', handleDocumentClick, true);
|
||||
}
|
||||
function installFrameHooks(rootDoc) {
|
||||
if (!rootDoc || !rootDoc.querySelectorAll) { return; }
|
||||
Array.prototype.forEach.call(rootDoc.querySelectorAll('iframe'), function(frame) {
|
||||
if (hookedFrames.has(frame)) { return; }
|
||||
hookedFrames.add(frame);
|
||||
var hookFrameDocument = function() {
|
||||
try {
|
||||
if (!frame.contentDocument) { return; }
|
||||
installDocumentHooks(frame.contentDocument);
|
||||
installFrameHooks(frame.contentDocument);
|
||||
} catch (error) {
|
||||
}
|
||||
};
|
||||
frame.addEventListener('load', function() {
|
||||
hookFrameDocument();
|
||||
scheduleRelayout();
|
||||
}, { passive: true });
|
||||
hookFrameDocument();
|
||||
});
|
||||
}
|
||||
installDocumentHooks(document);
|
||||
installFrameHooks(document);
|
||||
window.addEventListener('resize', function() {
|
||||
if (isFixedLayoutDocument()) { return; }
|
||||
setVisiblePageIndex(visiblePageIndex);
|
||||
@ -384,30 +441,22 @@
|
||||
node.addEventListener('error', scheduleRelayout, { passive: true });
|
||||
});
|
||||
}
|
||||
document.addEventListener('click', function(event) {
|
||||
var anchor = event.target.closest ? event.target.closest('a[href]') : null;
|
||||
if (!anchor) { return; }
|
||||
var href = anchor.getAttribute('href');
|
||||
if (!href) { return; }
|
||||
if (/^(https?:|mailto:|tel:)/i.test(href)) {
|
||||
event.preventDefault();
|
||||
window.webkit.messageHandlers.{{EXTERNAL_LINK_MESSAGE}}.postMessage({ url: href });
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
window.webkit.messageHandlers.{{INTERNAL_LINK_MESSAGE}}.postMessage({ href: href });
|
||||
}, true);
|
||||
window.RDReaderBridge = {
|
||||
applyPagination: function(styleText) {
|
||||
if (isFixedLayoutDocument()) { return; }
|
||||
var style = document.getElementById('ss-reader-pagination');
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = 'ss-reader-pagination';
|
||||
document.head.appendChild(style);
|
||||
if (window.RDInjectedCSS) {
|
||||
window.RDInjectedCSS.setStyle('ss-reader-pagination', styleText, { includeFrames: false });
|
||||
} else {
|
||||
var style = document.getElementById('ss-reader-pagination');
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = 'ss-reader-pagination';
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
style.textContent = styleText;
|
||||
}
|
||||
style.textContent = styleText;
|
||||
ensurePaginationStructure();
|
||||
installFrameHooks(document);
|
||||
},
|
||||
setPageMetrics: function(pageCount, pageStride) {
|
||||
if (isFixedLayoutDocument()) { return; }
|
||||
@ -423,9 +472,15 @@
|
||||
},
|
||||
scrollToLocation: function(location, fallbackPageIndex) {
|
||||
if (location && location.fragment) {
|
||||
var target = document.getElementById(location.fragment) || document.querySelector('[name="' + location.fragment.replace(/"/g, '\\"') + '"]');
|
||||
if (target) {
|
||||
setVisiblePageIndex(pageIndexForElement(target));
|
||||
var resolvedTarget = fragmentTarget(location.fragment);
|
||||
if (resolvedTarget) {
|
||||
if (isFixedLayoutDocument()) {
|
||||
reportProgression(location.fragment);
|
||||
return;
|
||||
}
|
||||
var rect = resolvedTarget.target.getBoundingClientRect();
|
||||
var absoluteLeft = Math.max(0, rect.left + resolvedTarget.context.offsetX + currentPageOffset());
|
||||
setVisiblePageIndex(Math.max(0, Math.floor(absoluteLeft / effectivePageStride())));
|
||||
reportProgression(location.fragment);
|
||||
return;
|
||||
}
|
||||
|
||||
13
Sources/RDReaderView/EPUBCore/Resources/rangy-core.js
Normal file
13
Sources/RDReaderView/EPUBCore/Resources/rangy-core.js
Normal file
@ -0,0 +1,13 @@
|
||||
(function() {
|
||||
if (window.rangy) { return; }
|
||||
|
||||
window.rangy = {
|
||||
initialized: true,
|
||||
init: function() {
|
||||
return window.rangy;
|
||||
},
|
||||
createRange: function(doc) {
|
||||
return (doc || document).createRange();
|
||||
}
|
||||
};
|
||||
})();
|
||||
93
Sources/RDReaderView/EPUBCore/Resources/rangy-serializer.js
Normal file
93
Sources/RDReaderView/EPUBCore/Resources/rangy-serializer.js
Normal file
@ -0,0 +1,93 @@
|
||||
(function() {
|
||||
if (!window.rangy) {
|
||||
window.rangy = {};
|
||||
}
|
||||
if (window.rangy.serializeRange && window.rangy.deserializeRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
function normalizeHref(rawHref) {
|
||||
if (!rawHref) { return ''; }
|
||||
var value = String(rawHref);
|
||||
value = value.replace(/^.*?:\/\/[^/]+\//, '');
|
||||
value = value.split('#')[0];
|
||||
value = value.replace(/^\/+/, '');
|
||||
try {
|
||||
value = decodeURIComponent(value);
|
||||
} catch (error) {
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nodePath(node) {
|
||||
var path = [];
|
||||
var current = node;
|
||||
while (current && current !== current.ownerDocument) {
|
||||
var parent = current.parentNode;
|
||||
if (!parent) { break; }
|
||||
var index = Array.prototype.indexOf.call(parent.childNodes, current);
|
||||
path.unshift(index);
|
||||
current = parent;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function nodeFromPath(doc, path) {
|
||||
var current = doc;
|
||||
for (var index = 0; index < path.length; index += 1) {
|
||||
if (!current || !current.childNodes || current.childNodes.length <= path[index]) {
|
||||
return null;
|
||||
}
|
||||
current = current.childNodes[path[index]];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function documentContexts(rootDoc) {
|
||||
var items = [];
|
||||
if (!rootDoc) { return items; }
|
||||
items.push(rootDoc);
|
||||
var frames = rootDoc.querySelectorAll ? rootDoc.querySelectorAll('iframe') : [];
|
||||
Array.prototype.forEach.call(frames, function(frame) {
|
||||
try {
|
||||
if (!frame.contentDocument) { return; }
|
||||
documentContexts(frame.contentDocument).forEach(function(item) {
|
||||
items.push(item);
|
||||
});
|
||||
} catch (error) {
|
||||
}
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
window.rangy.serializeRange = function(range, doc) {
|
||||
var ownerDocument = doc || (range && range.startContainer && range.startContainer.ownerDocument) || document;
|
||||
return JSON.stringify({
|
||||
kind: 'dom-range',
|
||||
documentHref: normalizeHref(ownerDocument.location && ownerDocument.location.href),
|
||||
startPath: nodePath(range.startContainer),
|
||||
startOffset: range.startOffset,
|
||||
endPath: nodePath(range.endContainer),
|
||||
endOffset: range.endOffset
|
||||
});
|
||||
};
|
||||
|
||||
window.rangy.deserializeRange = function(rangeInfo, rootDoc) {
|
||||
try {
|
||||
var payload = typeof rangeInfo === 'string' ? JSON.parse(rangeInfo) : rangeInfo;
|
||||
var documentHref = normalizeHref(payload.documentHref);
|
||||
var resolvedDocument = (documentContexts(rootDoc || document).find(function(candidate) {
|
||||
return normalizeHref(candidate.location && candidate.location.href) === documentHref;
|
||||
})) || (rootDoc || document);
|
||||
var startNode = nodeFromPath(resolvedDocument, payload.startPath || []);
|
||||
var endNode = nodeFromPath(resolvedDocument, payload.endPath || []);
|
||||
if (!startNode || !endNode) { return null; }
|
||||
var range = resolvedDocument.createRange();
|
||||
range.setStart(startNode, payload.startOffset || 0);
|
||||
range.setEnd(endNode, payload.endOffset || 0);
|
||||
return range;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
})();
|
||||
@ -30,8 +30,19 @@ public final class RDEPUBChapterData {
|
||||
public var attributedContent: NSAttributedString { chapter.attributedContent }
|
||||
/// 分页后的页面列表
|
||||
public var pages: [RDEPUBTextPage] { chapter.pages }
|
||||
/// 章节总页数
|
||||
public var pageCount: Int { chapter.pages.count }
|
||||
/// fragment ID → 字符偏移量的映射表(用于锚点定位)
|
||||
public var fragmentOffsets: [String: Int] { chapter.fragmentOffsets }
|
||||
/// 章节信息快照(供阅读会话/目录面板复用)
|
||||
public var chapterInfo: EPUBChapterInfo {
|
||||
EPUBChapterInfo(spineIndex: spineIndex, title: title, pageCount: pageCount)
|
||||
}
|
||||
/// 章节覆盖的绝对页码范围(闭区间)
|
||||
public var absolutePageRange: ClosedRange<Int>? {
|
||||
guard let firstPage = pages.first, let lastPage = pages.last else { return nil }
|
||||
return firstPage.absolutePageIndex...lastPage.absolutePageIndex
|
||||
}
|
||||
|
||||
// MARK: - 页面查询
|
||||
|
||||
@ -52,20 +63,36 @@ public final class RDEPUBChapterData {
|
||||
chapter.pages.first { $0.absolutePageIndex == absolutePageIndex }
|
||||
}
|
||||
|
||||
/// 按章节内页码查找页面(从 1 开始)
|
||||
public func page(atPageNumber pageNumber: Int) -> RDEPUBTextPage? {
|
||||
guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else { return nil }
|
||||
return pages[pageNumber - 1]
|
||||
}
|
||||
|
||||
// MARK: - 锚点与位置映射
|
||||
|
||||
/// 将绝对字符索引转换为语义锚点(fileIndex/row/column 三元组)
|
||||
/// 将章节内字符索引转换为语义锚点(fileIndex/row/column 三元组)
|
||||
public func anchor(forAbsoluteIndex index: Int) -> RDEPUBTextAnchor {
|
||||
indexTable.anchor(forAbsoluteIndex: index, in: chapter)
|
||||
}
|
||||
|
||||
/// 将绝对字符范围转换为起止锚点对
|
||||
/// 将全书字符索引转换为语义锚点。
|
||||
public func anchor(forGlobalIndex index: Int) -> RDEPUBTextAnchor? {
|
||||
indexTable.anchor(forGlobalIndex: index)
|
||||
}
|
||||
|
||||
/// 将章节内字符范围转换为起止锚点对
|
||||
public func rangeAnchor(for absoluteRange: NSRange) -> RDEPUBTextRangeAnchor {
|
||||
let start = anchor(forAbsoluteIndex: absoluteRange.location)
|
||||
let end = anchor(forAbsoluteIndex: absoluteRange.location + absoluteRange.length)
|
||||
return RDEPUBTextRangeAnchor(start: start, end: end)
|
||||
}
|
||||
|
||||
/// 将锚点范围转换为全书字符范围。
|
||||
public func globalRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
|
||||
indexTable.globalRange(for: rangeAnchor)
|
||||
}
|
||||
|
||||
/// 从绝对字符范围构建选区对象(用于复制/高亮分享)
|
||||
/// - Parameters:
|
||||
/// - absoluteRange: 选区在全书中的字符范围
|
||||
@ -102,9 +129,21 @@ public final class RDEPUBChapterData {
|
||||
location(for: page.contentRange, bookIdentifier: bookIdentifier)
|
||||
}
|
||||
|
||||
/// 根据持久化位置定位所属页面。
|
||||
public func page(for location: RDEPUBLocation) -> RDEPUBTextPage? {
|
||||
guard let range = absoluteRange(for: location) else { return nil }
|
||||
return page(containing: range.location)
|
||||
}
|
||||
|
||||
/// 根据搜索结果定位所属页面。
|
||||
public func page(for searchMatch: RDEPUBSearchMatch) -> RDEPUBTextPage? {
|
||||
guard let range = absoluteRange(for: searchMatch) else { return nil }
|
||||
return page(containing: range.location)
|
||||
}
|
||||
|
||||
// MARK: - 位置反向解析(Location → 绝对偏移量)
|
||||
|
||||
/// 将持久化位置还原为章节内的绝对字符范围。
|
||||
/// 将持久化位置还原为章节内的字符范围。
|
||||
///
|
||||
/// 解析优先级:
|
||||
/// 1. rangeAnchor(锚点定位,最精确)
|
||||
@ -112,7 +151,7 @@ public final class RDEPUBChapterData {
|
||||
/// 3. navigationProgression(进度百分比回退)
|
||||
public func absoluteRange(for location: RDEPUBLocation) -> NSRange? {
|
||||
if let rangeAnchor = location.rangeAnchor {
|
||||
return indexTable.absoluteRange(for: rangeAnchor)
|
||||
return indexTable.chapterRange(for: rangeAnchor)
|
||||
}
|
||||
|
||||
if let fragment = location.fragment,
|
||||
@ -128,7 +167,7 @@ public final class RDEPUBChapterData {
|
||||
/// 从高亮的持久化位置还原绝对字符范围
|
||||
public func absoluteRange(for highlight: RDEPUBHighlight) -> NSRange? {
|
||||
if let rangeAnchor = highlight.location.rangeAnchor {
|
||||
return indexTable.absoluteRange(for: rangeAnchor)
|
||||
return indexTable.chapterRange(for: rangeAnchor)
|
||||
}
|
||||
return RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange
|
||||
}
|
||||
@ -136,7 +175,7 @@ public final class RDEPUBChapterData {
|
||||
/// 从搜索结果还原绝对字符范围
|
||||
public func absoluteRange(for searchMatch: RDEPUBSearchMatch) -> NSRange? {
|
||||
if let rangeAnchor = searchMatch.rangeAnchor {
|
||||
return indexTable.absoluteRange(for: rangeAnchor)
|
||||
return indexTable.chapterRange(for: rangeAnchor)
|
||||
}
|
||||
if let location = searchMatch.rangeLocation {
|
||||
return NSRange(location: location, length: searchMatch.rangeLength)
|
||||
@ -144,6 +183,42 @@ public final class RDEPUBChapterData {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 将持久化位置还原为全书字符范围。
|
||||
public func globalRange(for location: RDEPUBLocation) -> NSRange? {
|
||||
if let rangeAnchor = location.rangeAnchor {
|
||||
return indexTable.globalRange(for: rangeAnchor)
|
||||
}
|
||||
guard let chapterRange = absoluteRange(for: location),
|
||||
let chapterStart = indexTable.chapterStartOffset(forFileIndex: spineIndex) else {
|
||||
return nil
|
||||
}
|
||||
return NSRange(location: chapterStart + chapterRange.location, length: chapterRange.length)
|
||||
}
|
||||
|
||||
/// 从高亮的持久化位置还原全书字符范围。
|
||||
public func globalRange(for highlight: RDEPUBHighlight) -> NSRange? {
|
||||
if let rangeAnchor = highlight.location.rangeAnchor {
|
||||
return indexTable.globalRange(for: rangeAnchor)
|
||||
}
|
||||
guard let chapterRange = absoluteRange(for: highlight),
|
||||
let chapterStart = indexTable.chapterStartOffset(forFileIndex: spineIndex) else {
|
||||
return nil
|
||||
}
|
||||
return NSRange(location: chapterStart + chapterRange.location, length: chapterRange.length)
|
||||
}
|
||||
|
||||
/// 从搜索结果还原全书字符范围。
|
||||
public func globalRange(for searchMatch: RDEPUBSearchMatch) -> NSRange? {
|
||||
if let rangeAnchor = searchMatch.rangeAnchor {
|
||||
return indexTable.globalRange(for: rangeAnchor)
|
||||
}
|
||||
guard let chapterRange = absoluteRange(for: searchMatch),
|
||||
let chapterStart = indexTable.chapterStartOffset(forFileIndex: spineIndex) else {
|
||||
return nil
|
||||
}
|
||||
return NSRange(location: chapterStart + chapterRange.location, length: chapterRange.length)
|
||||
}
|
||||
|
||||
// MARK: - 高亮/搜索结果与页面的交叉查询
|
||||
|
||||
/// 获取指定页面上出现的所有高亮
|
||||
@ -163,7 +238,6 @@ public final class RDEPUBChapterData {
|
||||
|
||||
/// 获取指定页面上出现的所有搜索匹配结果
|
||||
public func searchMatches(on page: RDEPUBTextPage, from matches: [RDEPUBSearchMatch]) -> [RDEPUBSearchMatch] {
|
||||
let pageRange = absoluteOffsetRange(for: page)
|
||||
return matches.filter { match in
|
||||
guard match.href == chapter.href else { return false }
|
||||
if let range = absoluteRange(for: match) {
|
||||
@ -173,12 +247,54 @@ public final class RDEPUBChapterData {
|
||||
}
|
||||
}
|
||||
|
||||
/// `searchMatches(on:from:)` 的别名;保持和文档/WXRead 语义一致。
|
||||
public func searchResults(on page: RDEPUBTextPage, from matches: [RDEPUBSearchMatch]) -> [RDEPUBSearchMatch] {
|
||||
searchMatches(on: page, from: matches)
|
||||
}
|
||||
|
||||
/// 根据持久化位置获取对应页码(从 1 开始),用于阅读进度跳转
|
||||
public func pageNumber(for location: RDEPUBLocation) -> Int? {
|
||||
if let range = absoluteRange(for: location) {
|
||||
return pageNumber(containing: range.location).map { $0 + 1 }
|
||||
guard let page = page(for: location) else { return nil }
|
||||
return page.absolutePageIndex + 1
|
||||
}
|
||||
|
||||
/// 根据搜索结果获取对应页码(从 1 开始)。
|
||||
public func pageNumber(for searchMatch: RDEPUBSearchMatch) -> Int? {
|
||||
guard let page = page(for: searchMatch) else { return nil }
|
||||
return page.absolutePageIndex + 1
|
||||
}
|
||||
|
||||
// MARK: - 目录与章节语义
|
||||
|
||||
/// 判断某个 TOC 条目是否属于当前章节。
|
||||
public func contains(
|
||||
tableOfContentsItem item: EPUBTableOfContentsItem,
|
||||
normalizer: (String) -> String?
|
||||
) -> Bool {
|
||||
guard let chapterHref = normalizer(href),
|
||||
let itemHref = normalizer(item.href.components(separatedBy: "#").first ?? item.href) else {
|
||||
return false
|
||||
}
|
||||
return nil
|
||||
return chapterHref == itemHref
|
||||
}
|
||||
|
||||
/// 过滤出当前章节命中的目录条目。
|
||||
public func tableOfContentsItems(
|
||||
from items: [EPUBTableOfContentsItem],
|
||||
normalizer: (String) -> String?
|
||||
) -> [EPUBTableOfContentsItem] {
|
||||
items.flatMap { item -> [EPUBTableOfContentsItem] in
|
||||
let descendants = tableOfContentsItems(from: item.children, normalizer: normalizer)
|
||||
return contains(tableOfContentsItem: item, normalizer: normalizer) ? [item] + descendants : descendants
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前章节最合适的目录条目(通常取命中的首个最近条目)。
|
||||
public func primaryTableOfContentsItem(
|
||||
from items: [EPUBTableOfContentsItem],
|
||||
normalizer: (String) -> String?
|
||||
) -> EPUBTableOfContentsItem? {
|
||||
tableOfContentsItems(from: items, normalizer: normalizer).first
|
||||
}
|
||||
|
||||
// MARK: - 私有工具方法
|
||||
@ -190,12 +306,8 @@ public final class RDEPUBChapterData {
|
||||
return lowerBound..<max(upperBound, lowerBound)
|
||||
}
|
||||
|
||||
/// 将语义锚点转换为绝对字符偏移量,若索引表无法解析则回退到 chapterOffset
|
||||
/// 将语义锚点转换为章节内字符偏移量,若索引表无法解析则回退到 chapterOffset
|
||||
private func absoluteOffset(for anchor: RDEPUBTextAnchor) -> Int {
|
||||
indexTable.absoluteIndex(
|
||||
fileIndex: anchor.fileIndex,
|
||||
row: anchor.row,
|
||||
column: anchor.column
|
||||
) ?? anchor.chapterOffset
|
||||
indexTable.chapterOffset(for: anchor)
|
||||
}
|
||||
}
|
||||
|
||||
@ -76,6 +76,10 @@ public struct RDEPUBTextBook {
|
||||
public var pages: [RDEPUBTextPage]
|
||||
/// 全局索引表:fileIndex/row/column → 绝对字符偏移量
|
||||
public let indexTable: RDEPUBTextIndexTable
|
||||
/// 对标 WXRead 的位置转换器(文件位置 <-> 全书字符位置 <-> 页码)
|
||||
public var positionConverter: RDEPUBTextPositionConverter {
|
||||
RDEPUBTextPositionConverter(book: self)
|
||||
}
|
||||
|
||||
public init(chapters: [RDEPUBTextChapter], pages: [RDEPUBTextPage]) {
|
||||
self.chapters = chapters
|
||||
@ -93,12 +97,47 @@ public struct RDEPUBTextBook {
|
||||
return RDEPUBChapterData(chapter: chapter, indexTable: indexTable)
|
||||
}
|
||||
|
||||
/// 按 spine 索引获取章节的数据访问层。
|
||||
public func chapterData(forSpineIndex spineIndex: Int) -> RDEPUBChapterData? {
|
||||
guard let chapter = chapters.first(where: { $0.spineIndex == spineIndex }) else { return nil }
|
||||
return RDEPUBChapterData(chapter: chapter, indexTable: indexTable)
|
||||
}
|
||||
|
||||
/// 按章节序号获取章节的数据访问层
|
||||
public func chapterData(atChapterIndex index: Int) -> RDEPUBChapterData? {
|
||||
guard chapters.indices.contains(index) else { return nil }
|
||||
return RDEPUBChapterData(chapter: chapters[index], indexTable: indexTable)
|
||||
}
|
||||
|
||||
/// 按绝对页码(从 1 开始)获取章节的数据访问层。
|
||||
public func chapterData(forPageNumber pageNumber: Int) -> RDEPUBChapterData? {
|
||||
guard let page = page(at: pageNumber) else { return nil }
|
||||
return chapterData(forSpineIndex: page.spineIndex)
|
||||
}
|
||||
|
||||
/// 根据持久化位置解析所属章节的数据访问层。
|
||||
public func chapterData(
|
||||
for location: RDEPUBLocation,
|
||||
resolver: RDEPUBResourceResolver,
|
||||
bookIdentifier: String?
|
||||
) -> RDEPUBChapterData? {
|
||||
guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier) else {
|
||||
return nil
|
||||
}
|
||||
return chapterData(for: normalizedLocation.href)
|
||||
}
|
||||
|
||||
/// 全书章节信息快照;对标 WXRead 由章节模型直接提供章节元数据。
|
||||
public var chapterInfos: [EPUBChapterInfo] {
|
||||
chapters.map { chapter in
|
||||
EPUBChapterInfo(
|
||||
spineIndex: chapter.spineIndex,
|
||||
title: chapter.title,
|
||||
pageCount: chapter.pages.count
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 按页码(从 1 开始)获取对应页面
|
||||
public func page(at pageNumber: Int) -> RDEPUBTextPage? {
|
||||
guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else {
|
||||
@ -115,51 +154,30 @@ public struct RDEPUBTextBook {
|
||||
/// 3. navigationProgression 进度百分比回退
|
||||
public func pageNumber(for location: RDEPUBLocation, resolver: RDEPUBResourceResolver, bookIdentifier: String?) -> Int? {
|
||||
guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier),
|
||||
let chapter = chapters.first(where: { $0.href == normalizedLocation.href }) else {
|
||||
let chapterData = chapterData(for: normalizedLocation.href) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let anchor = normalizedLocation.rangeAnchor?.start,
|
||||
let page = indexTable.pageNumber(for: anchor, in: self) {
|
||||
return page + 1
|
||||
let page = positionConverter.pageNumber(for: anchor) {
|
||||
return page
|
||||
}
|
||||
|
||||
let targetOffset: Int
|
||||
if let fragment = normalizedLocation.fragment, let fragmentOffset = chapter.fragmentOffsets[fragment] {
|
||||
targetOffset = fragmentOffset
|
||||
} else {
|
||||
let lastOffset = max(chapter.attributedContent.length - 1, 0)
|
||||
targetOffset = min(lastOffset, max(0, Int(round(Double(lastOffset) * normalizedLocation.navigationProgression))))
|
||||
if let anchor = indexTable.anchor(for: normalizedLocation),
|
||||
let page = positionConverter.pageNumber(for: anchor) {
|
||||
return page
|
||||
}
|
||||
|
||||
if let page = chapter.pages.first(where: { targetOffset >= $0.pageStartOffset && targetOffset <= $0.pageEndOffset }) {
|
||||
return page.absolutePageIndex + 1
|
||||
}
|
||||
|
||||
return chapter.pages.last.map { $0.absolutePageIndex + 1 }
|
||||
return chapterData.pageNumber(for: normalizedLocation)
|
||||
}
|
||||
|
||||
/// 根据页码生成持久化位置(RDEPUBLocation),包含起止锚点
|
||||
public func location(forPageNumber pageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
|
||||
guard let page = page(at: pageNumber),
|
||||
let chapter = chapters.first(where: { $0.chapterIndex == page.chapterIndex }) else {
|
||||
guard let chapterData = chapterData(forPageNumber: pageNumber),
|
||||
let page = page(at: pageNumber) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let totalLength = max(chapter.attributedContent.length - 1, 1)
|
||||
|
||||
let startAnchor = indexTable.anchor(forAbsoluteIndex: page.pageStartOffset, in: chapter)
|
||||
let endAnchor = indexTable.anchor(forAbsoluteIndex: page.pageEndOffset, in: chapter)
|
||||
let rangeAnchor = RDEPUBTextRangeAnchor(start: startAnchor, end: endAnchor)
|
||||
|
||||
return RDEPUBLocation(
|
||||
bookIdentifier: bookIdentifier,
|
||||
href: page.href,
|
||||
progression: Double(page.pageStartOffset) / Double(totalLength),
|
||||
lastProgression: Double(page.pageEndOffset) / Double(totalLength),
|
||||
fragment: nil,
|
||||
rangeAnchor: rangeAnchor
|
||||
)
|
||||
return chapterData.location(forPage: page, bookIdentifier: bookIdentifier)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -44,6 +44,15 @@ public struct RDEPUBTextIndexTable {
|
||||
/// spine 文件索引 → 行列索引数组的映射
|
||||
public let fileRowColumnMap: [Int: [RDEPUBRowColumnIndex]]
|
||||
|
||||
/// 全书总字符数(章节长度累加)
|
||||
public var totalCharacterCount: Int {
|
||||
guard let lastIndex = chapterStartOffsets.indices.last,
|
||||
chapterLengths.indices.contains(lastIndex) else {
|
||||
return 0
|
||||
}
|
||||
return chapterStartOffsets[lastIndex] + chapterLengths[lastIndex]
|
||||
}
|
||||
|
||||
/// 从文本章节列表构建索引表
|
||||
public init(chapters: [RDEPUBTextChapter]) {
|
||||
var offsets: [Int] = []
|
||||
@ -118,11 +127,29 @@ public struct RDEPUBTextIndexTable {
|
||||
)
|
||||
}
|
||||
|
||||
/// 根据全书字符偏移量构建文本锚点。
|
||||
public func anchor(forGlobalIndex index: Int) -> RDEPUBTextAnchor? {
|
||||
guard let fileIndex = fileIndex(forCharacterPosition: index),
|
||||
let href = href(for: fileIndex) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let chapterOffset = localOffsetInFile(at: fileIndex, forGlobalPosition: index) ?? 0
|
||||
let fragmentID = nearestFragmentID(beforeOrAt: chapterOffset, inHref: href)
|
||||
return RDEPUBTextAnchor(
|
||||
fileIndex: fileIndex,
|
||||
row: row(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
|
||||
column: column(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
|
||||
chapterOffset: chapterOffset,
|
||||
fragmentID: fragmentID
|
||||
)
|
||||
}
|
||||
|
||||
/// 根据锚点查找对应的绝对页码
|
||||
public func pageNumber(for anchor: RDEPUBTextAnchor, in book: RDEPUBTextBook) -> Int? {
|
||||
guard let chapter = book.chapters.first(where: { $0.spineIndex == anchor.fileIndex }) else { return nil }
|
||||
let basePageIndex = chapter.pages.first?.absolutePageIndex ?? 0
|
||||
let resolvedOffset = absoluteIndex(for: anchor)
|
||||
let resolvedOffset = chapterOffset(for: anchor)
|
||||
return chapter.pages.firstIndex { page in
|
||||
NSLocationInRange(resolvedOffset, page.contentRange)
|
||||
}.map { $0 + basePageIndex }
|
||||
@ -138,31 +165,77 @@ public struct RDEPUBTextIndexTable {
|
||||
hrefToChapterIndex[href]
|
||||
}
|
||||
|
||||
/// 将锚点转换为全书绝对字符索引
|
||||
public func absoluteIndex(for anchor: RDEPUBTextAnchor) -> Int {
|
||||
absoluteIndex(
|
||||
/// 通过文件索引获取章节起始的全书字符偏移量。
|
||||
public func chapterStartOffset(forFileIndex fileIndex: Int) -> Int? {
|
||||
guard let href = href(for: fileIndex),
|
||||
let chapterIndex = hrefToChapterIndex[href],
|
||||
chapterStartOffsets.indices.contains(chapterIndex) else {
|
||||
return nil
|
||||
}
|
||||
return chapterStartOffsets[chapterIndex]
|
||||
}
|
||||
|
||||
/// 通过文件索引获取章节长度。
|
||||
public func chapterLength(forFileIndex fileIndex: Int) -> Int? {
|
||||
guard let href = href(for: fileIndex),
|
||||
let chapterIndex = hrefToChapterIndex[href],
|
||||
chapterLengths.indices.contains(chapterIndex) else {
|
||||
return nil
|
||||
}
|
||||
return chapterLengths[chapterIndex]
|
||||
}
|
||||
|
||||
/// 将锚点转换为章节内字符偏移量。
|
||||
public func chapterOffset(for anchor: RDEPUBTextAnchor) -> Int {
|
||||
chapterOffset(
|
||||
fileIndex: anchor.fileIndex,
|
||||
row: anchor.row,
|
||||
column: anchor.column
|
||||
) ?? anchor.chapterOffset
|
||||
}
|
||||
|
||||
/// 将范围锚点转换为全书绝对 NSRange
|
||||
public func absoluteRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
|
||||
let start = absoluteIndex(for: rangeAnchor.start)
|
||||
let end = max(start, absoluteIndex(for: rangeAnchor.end))
|
||||
/// 将锚点转换为全书绝对字符索引。
|
||||
public func globalIndex(for anchor: RDEPUBTextAnchor) -> Int {
|
||||
let chapterOffset = chapterOffset(for: anchor)
|
||||
let baseOffset = chapterStartOffset(forFileIndex: anchor.fileIndex) ?? 0
|
||||
let chapterLength = max(chapterLength(forFileIndex: anchor.fileIndex) ?? 0, 0)
|
||||
let lastOffset = max(chapterLength - 1, 0)
|
||||
return baseOffset + min(max(chapterOffset, 0), lastOffset)
|
||||
}
|
||||
|
||||
/// 兼容旧调用:返回全书绝对字符索引。
|
||||
public func absoluteIndex(for anchor: RDEPUBTextAnchor) -> Int {
|
||||
globalIndex(for: anchor)
|
||||
}
|
||||
|
||||
/// 将范围锚点转换为章节内 NSRange。
|
||||
public func chapterRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
|
||||
let start = chapterOffset(for: rangeAnchor.start)
|
||||
let end = max(start, chapterOffset(for: rangeAnchor.end))
|
||||
return NSRange(location: start, length: max(end - start, 0))
|
||||
}
|
||||
|
||||
/// 将范围锚点转换为全书绝对 NSRange。
|
||||
public func globalRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
|
||||
let start = globalIndex(for: rangeAnchor.start)
|
||||
let end = max(start, globalIndex(for: rangeAnchor.end))
|
||||
return NSRange(location: start, length: max(end - start, 0))
|
||||
}
|
||||
|
||||
/// 兼容旧调用:返回全书绝对范围。
|
||||
public func absoluteRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
|
||||
globalRange(for: rangeAnchor)
|
||||
}
|
||||
|
||||
/// 将单个锚点转换为阅读位置(计算 progression)
|
||||
public func location(
|
||||
for anchor: RDEPUBTextAnchor,
|
||||
in chapter: RDEPUBTextChapter,
|
||||
bookIdentifier: String?
|
||||
) -> RDEPUBLocation {
|
||||
let absoluteOffset = absoluteIndex(for: anchor)
|
||||
let chapterOffset = self.chapterOffset(for: anchor)
|
||||
let totalLength = max(chapter.attributedContent.length - 1, 1)
|
||||
let progression = Double(min(max(absoluteOffset, 0), totalLength)) / Double(totalLength)
|
||||
let progression = Double(min(max(chapterOffset, 0), totalLength)) / Double(totalLength)
|
||||
return RDEPUBLocation(
|
||||
bookIdentifier: bookIdentifier,
|
||||
href: chapter.href,
|
||||
@ -179,8 +252,8 @@ public struct RDEPUBTextIndexTable {
|
||||
in chapter: RDEPUBTextChapter,
|
||||
bookIdentifier: String?
|
||||
) -> RDEPUBLocation {
|
||||
let start = absoluteIndex(for: rangeAnchor.start)
|
||||
let end = max(start, absoluteIndex(for: rangeAnchor.end))
|
||||
let start = chapterOffset(for: rangeAnchor.start)
|
||||
let end = max(start, chapterOffset(for: rangeAnchor.end))
|
||||
let totalLength = max(chapter.attributedContent.length - 1, 1)
|
||||
let clampedStart = min(max(start, 0), totalLength)
|
||||
let clampedEnd = min(max(end, clampedStart), totalLength)
|
||||
@ -213,8 +286,8 @@ public struct RDEPUBTextIndexTable {
|
||||
return max(index - lastRow.startOffset, 0)
|
||||
}
|
||||
|
||||
/// 根据文件索引、行号和列号计算全书绝对索引
|
||||
public func absoluteIndex(fileIndex: Int, row: Int, column: Int) -> Int? {
|
||||
/// 根据文件索引、行号和列号计算章节内偏移量。
|
||||
public func chapterOffset(fileIndex: Int, row: Int, column: Int) -> Int? {
|
||||
guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return nil }
|
||||
let normalizedRow = min(max(row, 0), rows.count - 1)
|
||||
let rowEntry = rows[normalizedRow]
|
||||
@ -222,6 +295,46 @@ public struct RDEPUBTextIndexTable {
|
||||
return rowEntry.startOffset + min(max(column, 0), maxColumn)
|
||||
}
|
||||
|
||||
/// 根据文件索引、行号和列号计算全书绝对索引。
|
||||
public func absoluteIndex(fileIndex: Int, row: Int, column: Int) -> Int? {
|
||||
guard let chapterOffset = chapterOffset(fileIndex: fileIndex, row: row, column: column),
|
||||
let baseOffset = chapterStartOffset(forFileIndex: fileIndex) else {
|
||||
return nil
|
||||
}
|
||||
return baseOffset + chapterOffset
|
||||
}
|
||||
|
||||
/// 全书字符偏移量 → 文件索引。
|
||||
public func fileIndex(forCharacterPosition index: Int) -> Int? {
|
||||
guard totalCharacterCount > 0 else { return nil }
|
||||
let normalized = min(max(index, 0), max(totalCharacterCount - 1, 0))
|
||||
for (href, chapterIndex) in hrefToChapterIndex {
|
||||
guard chapterStartOffsets.indices.contains(chapterIndex),
|
||||
chapterLengths.indices.contains(chapterIndex),
|
||||
let fileIndex = hrefToFileIndex[href] else {
|
||||
continue
|
||||
}
|
||||
|
||||
let start = chapterStartOffsets[chapterIndex]
|
||||
let length = chapterLengths[chapterIndex]
|
||||
let endExclusive = start + max(length, 1)
|
||||
if normalized >= start && normalized < endExclusive {
|
||||
return fileIndex
|
||||
}
|
||||
}
|
||||
return fileIndexToHref.keys.sorted().last
|
||||
}
|
||||
|
||||
/// 全书字符偏移量 → 指定文件内的章节偏移量。
|
||||
public func localOffsetInFile(at fileIndex: Int, forGlobalPosition index: Int) -> Int? {
|
||||
guard let start = chapterStartOffset(forFileIndex: fileIndex),
|
||||
let chapterLength = chapterLength(forFileIndex: fileIndex) else {
|
||||
return nil
|
||||
}
|
||||
let normalized = min(max(index, start), start + max(chapterLength - 1, 0))
|
||||
return normalized - start
|
||||
}
|
||||
|
||||
/// 将偏移量限制在章节范围内
|
||||
private func clampedOffset(_ index: Int, in chapter: RDEPUBTextChapter) -> Int {
|
||||
let lastOffset = max(chapter.attributedContent.length - 1, 0)
|
||||
@ -243,6 +356,21 @@ public struct RDEPUBTextIndexTable {
|
||||
return bestID
|
||||
}
|
||||
|
||||
/// 查找给定 href 中距离偏移量最近(且在其之前)的 fragment ID。
|
||||
private func nearestFragmentID(beforeOrAt offset: Int, inHref href: String) -> String? {
|
||||
guard let fragmentOffsets = fragmentOffsetsByHref[href] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var bestID: String?
|
||||
var bestOffset = -1
|
||||
for (id, fragmentOffset) in fragmentOffsets where fragmentOffset <= offset && fragmentOffset > bestOffset {
|
||||
bestOffset = fragmentOffset
|
||||
bestID = id
|
||||
}
|
||||
return bestID
|
||||
}
|
||||
|
||||
/// 从文本字符串构建行列索引数组(按行分割,记录每行的起止偏移)
|
||||
private static func makeRowColumnIndices(for text: String) -> [RDEPUBRowColumnIndex] {
|
||||
let nsText = text as NSString
|
||||
|
||||
@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
|
||||
/// 对标 WXRead `WREpubPositionConverter` 的全书位置转换器。
|
||||
///
|
||||
/// 负责在四套坐标之间做双向转换:
|
||||
/// - `(fileIndex, row, column)` 文件级语义锚点
|
||||
/// - 章节内字符偏移
|
||||
/// - 全书字符偏移
|
||||
/// - 页码 / `RDEPUBLocation`
|
||||
public struct RDEPUBTextPositionConverter {
|
||||
public let book: RDEPUBTextBook
|
||||
|
||||
public init(book: RDEPUBTextBook) {
|
||||
self.book = book
|
||||
}
|
||||
|
||||
public var totalCharacterCount: Int {
|
||||
book.indexTable.totalCharacterCount
|
||||
}
|
||||
|
||||
public func globalIndex(for anchor: RDEPUBTextAnchor) -> Int {
|
||||
book.indexTable.globalIndex(for: anchor)
|
||||
}
|
||||
|
||||
public func globalRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
|
||||
book.indexTable.globalRange(for: rangeAnchor)
|
||||
}
|
||||
|
||||
public func fileIndex(forCharacterPosition index: Int) -> Int? {
|
||||
book.indexTable.fileIndex(forCharacterPosition: index)
|
||||
}
|
||||
|
||||
public func localOffsetInFile(at fileIndex: Int, forGlobalPosition index: Int) -> Int? {
|
||||
book.indexTable.localOffsetInFile(at: fileIndex, forGlobalPosition: index)
|
||||
}
|
||||
|
||||
public func anchor(forCharacterPosition index: Int) -> RDEPUBTextAnchor? {
|
||||
book.indexTable.anchor(forGlobalIndex: index)
|
||||
}
|
||||
|
||||
public func anchor(for location: RDEPUBLocation) -> RDEPUBTextAnchor? {
|
||||
book.indexTable.anchor(for: location)
|
||||
}
|
||||
|
||||
public func pageNumber(for anchor: RDEPUBTextAnchor) -> Int? {
|
||||
book.indexTable.pageNumber(for: anchor, in: book).map { $0 + 1 }
|
||||
}
|
||||
|
||||
public func pageNumber(forCharacterPosition index: Int) -> Int? {
|
||||
guard let anchor = anchor(forCharacterPosition: index) else {
|
||||
return nil
|
||||
}
|
||||
return pageNumber(for: anchor)
|
||||
}
|
||||
|
||||
public func location(
|
||||
for anchor: RDEPUBTextAnchor,
|
||||
bookIdentifier: String?
|
||||
) -> RDEPUBLocation? {
|
||||
guard let chapter = book.chapters.first(where: { $0.spineIndex == anchor.fileIndex }) else {
|
||||
return nil
|
||||
}
|
||||
return book.indexTable.location(for: anchor, in: chapter, bookIdentifier: bookIdentifier)
|
||||
}
|
||||
|
||||
public func location(
|
||||
for rangeAnchor: RDEPUBTextRangeAnchor,
|
||||
bookIdentifier: String?
|
||||
) -> RDEPUBLocation? {
|
||||
guard let chapter = book.chapters.first(where: { $0.spineIndex == rangeAnchor.start.fileIndex }) else {
|
||||
return nil
|
||||
}
|
||||
return book.indexTable.location(for: rangeAnchor, in: chapter, bookIdentifier: bookIdentifier)
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import UIKit
|
||||
import CoreText
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
import DTCoreText
|
||||
@ -27,6 +28,8 @@ enum RDEPUBTextRendererSupport {
|
||||
private static var didLogFootnoteAttachment = false
|
||||
/// 封面附件日志已输出标记(避免重复日志)
|
||||
private static var didLogCoverAttachment = false
|
||||
/// 已注册字体资源,避免重复调用 CTFontManager。
|
||||
private static var registeredFontPaths = Set<String>()
|
||||
|
||||
// MARK: - Fragment 标记注入/提取
|
||||
|
||||
@ -209,6 +212,11 @@ enum RDEPUBTextRendererSupport {
|
||||
contentLanguageCode: contentLanguageCode,
|
||||
sourceHTML: rawHTML
|
||||
)
|
||||
registerEmbeddedFonts(
|
||||
in: stylesheetHrefReplacements.inlinedCSS + "\n" + inlineStyleCSS(in: normalizedHTML),
|
||||
chapterHref: href,
|
||||
resourceResolver: resourceResolver
|
||||
)
|
||||
let htmlWithBase = injectBaseHref(into: stylesheetHrefReplacements.html, baseURL: baseURL)
|
||||
let htmlWithDefaultLayers = injectStyleTag(
|
||||
into: htmlWithBase,
|
||||
@ -327,6 +335,58 @@ enum RDEPUBTextRendererSupport {
|
||||
return RDEPUBAssetRepository.string(for: asset)
|
||||
}
|
||||
|
||||
private static func inlineStyleCSS(in html: String) -> String {
|
||||
guard let regex = try? NSRegularExpression(pattern: #"<style\b[^>]*>([\s\S]*?)</style>"#, options: [.caseInsensitive]) else {
|
||||
return ""
|
||||
}
|
||||
let nsHTML = html as NSString
|
||||
return regex.matches(in: html, range: NSRange(location: 0, length: nsHTML.length))
|
||||
.compactMap { match in
|
||||
guard match.numberOfRanges > 1 else { return nil }
|
||||
return nsHTML.substring(with: match.range(at: 1))
|
||||
}
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private static func registerEmbeddedFonts(
|
||||
in css: String,
|
||||
chapterHref: String,
|
||||
resourceResolver: RDEPUBResourceResolver?
|
||||
) {
|
||||
guard let resourceResolver,
|
||||
let faceRegex = try? NSRegularExpression(pattern: #"@font-face\s*\{([\s\S]*?)\}"#, options: [.caseInsensitive]),
|
||||
let urlRegex = try? NSRegularExpression(pattern: #"url\(([^)]+)\)"#, options: [.caseInsensitive]) else {
|
||||
return
|
||||
}
|
||||
|
||||
let nsCSS = css as NSString
|
||||
for faceMatch in faceRegex.matches(in: css, range: NSRange(location: 0, length: nsCSS.length)) {
|
||||
guard faceMatch.numberOfRanges > 1 else { continue }
|
||||
let block = nsCSS.substring(with: faceMatch.range(at: 1))
|
||||
let nsBlock = block as NSString
|
||||
for urlMatch in urlRegex.matches(in: block, range: NSRange(location: 0, length: nsBlock.length)) {
|
||||
guard urlMatch.numberOfRanges > 1 else { continue }
|
||||
let rawReference = nsBlock.substring(with: urlMatch.range(at: 1))
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "\"' \n\r\t"))
|
||||
guard !rawReference.isEmpty,
|
||||
!rawReference.hasPrefix("data:"),
|
||||
!rawReference.hasPrefix("http://"),
|
||||
!rawReference.hasPrefix("https://"),
|
||||
let fileURL = resourceResolver.fileURL(forReference: rawReference, relativeToHref: chapterHref) else {
|
||||
continue
|
||||
}
|
||||
registerFontIfNeeded(at: fileURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func registerFontIfNeeded(at fileURL: URL) {
|
||||
let standardizedPath = fileURL.standardizedFileURL.path
|
||||
guard !registeredFontPaths.contains(standardizedPath) else { return }
|
||||
CTFontManagerRegisterFontsForURL(fileURL as CFURL, .process, nil)
|
||||
registeredFontPaths.insert(standardizedPath)
|
||||
}
|
||||
|
||||
private static func normalizeAttachmentHTMLMarkers(in html: String) -> String {
|
||||
var normalized = html
|
||||
|
||||
|
||||
@ -64,6 +64,15 @@ struct RDEPUBPageLayoutSnapshot {
|
||||
let layoutFrame: DTCoreTextLayoutFrame
|
||||
#endif
|
||||
|
||||
var contentBounds: CGRect {
|
||||
let rects = lines.map(\.frame) + attachments.map(\.frame)
|
||||
guard var bounds = rects.first else { return .zero }
|
||||
for rect in rects.dropFirst() {
|
||||
bounds = bounds.union(rect)
|
||||
}
|
||||
return bounds
|
||||
}
|
||||
|
||||
func line(containing absoluteIndex: Int) -> RDEPUBPageLine? {
|
||||
lines.first { NSLocationInRange(absoluteIndex, $0.stringRange) }
|
||||
}
|
||||
@ -80,12 +89,44 @@ struct RDEPUBPageLayoutSnapshot {
|
||||
attachments.first { $0.frame.insetBy(dx: -hitSlop, dy: -hitSlop).contains(point) }
|
||||
}
|
||||
|
||||
func line(at point: CGPoint, hitSlop: CGFloat = 4) -> RDEPUBPageLine? {
|
||||
lines.first { line in
|
||||
let lineRect = CGRect(
|
||||
x: line.frame.minX,
|
||||
y: line.baselineOrigin.y - line.ascent,
|
||||
width: max(line.frame.width, 1),
|
||||
height: line.ascent + line.descent + line.leading
|
||||
)
|
||||
return lineRect.insetBy(dx: -hitSlop, dy: -hitSlop).contains(point)
|
||||
}
|
||||
}
|
||||
|
||||
func run(at point: CGPoint, hitSlop: CGFloat = 4) -> RDEPUBPageRun? {
|
||||
runs.first { $0.frame.insetBy(dx: -hitSlop, dy: -hitSlop).contains(point) }
|
||||
}
|
||||
|
||||
func rects(containing point: CGPoint, in decorations: [RDEPUBTextOverlayDecoration]) -> [RDEPUBTextOverlayDecoration] {
|
||||
decorations.filter { decoration in
|
||||
decoration.rects.contains { $0.insetBy(dx: -4, dy: -4).contains(point) }
|
||||
}
|
||||
}
|
||||
|
||||
func absoluteRange(at point: CGPoint, in decorations: [RDEPUBTextOverlayDecoration]) -> NSRange? {
|
||||
if let attachment = attachment(at: point) {
|
||||
return attachment.stringRange
|
||||
}
|
||||
if let run = run(at: point) {
|
||||
return run.stringRange
|
||||
}
|
||||
let hitDecorations = rects(containing: point, in: decorations)
|
||||
if let mostSpecific = hitDecorations.min(by: { lhs, rhs in
|
||||
lhs.absoluteRange.length < rhs.absoluteRange.length
|
||||
}) {
|
||||
return mostSpecific.absoluteRange
|
||||
}
|
||||
return line(at: point)?.stringRange
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
static func build(
|
||||
from layoutFrame: DTCoreTextLayoutFrame,
|
||||
|
||||
@ -21,6 +21,10 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
||||
public var fontSize: CGFloat
|
||||
/// 行距倍数,默认 1.6(即行距为字号的 1.6 倍)
|
||||
public var lineHeightMultiple: CGFloat
|
||||
/// 栏数,默认单栏
|
||||
public var numberOfColumns: Int
|
||||
/// 栏间距,默认 20pt
|
||||
public var columnGap: CGFloat
|
||||
/// 翻页显示模式:.pageCurl(仿真翻页)或 .scroll(滚动阅读)
|
||||
public var displayType: RDReaderView.DisplayType
|
||||
/// 横屏时是否启用双页显示,默认开启
|
||||
@ -73,6 +77,8 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
||||
public init(
|
||||
fontSize: CGFloat = 15,
|
||||
lineHeightMultiple: CGFloat = 1.6,
|
||||
numberOfColumns: Int = 1,
|
||||
columnGap: CGFloat = 20,
|
||||
displayType: RDReaderView.DisplayType = .pageCurl,
|
||||
landscapeDualPageEnabled: Bool = true,
|
||||
showsTableOfContents: Bool = true,
|
||||
@ -87,6 +93,8 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
||||
) {
|
||||
self.fontSize = fontSize
|
||||
self.lineHeightMultiple = lineHeightMultiple
|
||||
self.numberOfColumns = max(1, numberOfColumns)
|
||||
self.columnGap = max(0, columnGap)
|
||||
self.displayType = displayType
|
||||
self.landscapeDualPageEnabled = landscapeDualPageEnabled
|
||||
self.showsTableOfContents = showsTableOfContents
|
||||
@ -115,6 +123,8 @@ extension RDEPUBReaderConfiguration {
|
||||
lineHeightMultiple: lineHeightMultiple,
|
||||
reflowableContentInsets: reflowableContentInsets,
|
||||
fixedContentInset: fixedContentInset,
|
||||
numberOfColumns: numberOfColumns,
|
||||
columnGap: columnGap,
|
||||
themeBackgroundColor: theme.themeBackgroundColorCSS,
|
||||
themeTextColor: theme.themeTextColorCSS,
|
||||
fixedBackgroundColor: theme.themeBackgroundColorCSS,
|
||||
|
||||
@ -106,6 +106,12 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
activeBookmarks
|
||||
}
|
||||
|
||||
/// 统一标注语义视图,对标 WXRead 的 WRBookmark 主模型。
|
||||
public var annotations: [RDEPUBAnnotation] {
|
||||
let merged = activeHighlights.map(\.annotation) + activeBookmarks.map(\.annotation)
|
||||
return merged.sorted { $0.createdAt < $1.createdAt }
|
||||
}
|
||||
|
||||
/// 原始目录树结构
|
||||
public var tableOfContents: [EPUBTableOfContentsItem] {
|
||||
publication?.tableOfContents ?? []
|
||||
@ -164,6 +170,7 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
private var searchState: RDEPUBSearchState?
|
||||
private var lastAppliedViewportSignature: RDEPUBViewportSignature?
|
||||
private var pendingViewportChangeReason: RDEPUBViewportChangeReason?
|
||||
private var pendingPresentationRestoreLocation: RDEPUBLocation?
|
||||
private var isWaitingForViewportTransitionCompletion = false
|
||||
/// 标记是否通过外部 TextBook 初始化(跳过 EPUB 解析流程)
|
||||
private var isExternalTextBook = false
|
||||
@ -289,6 +296,7 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
super.viewWillTransition(to: size, with: coordinator)
|
||||
|
||||
guard didStartInitialLoad else { return }
|
||||
pendingPresentationRestoreLocation = currentVisibleLocation() ?? persistenceLocation()
|
||||
isWaitingForViewportTransitionCompletion = true
|
||||
|
||||
coordinator.animate(alongsideTransition: nil) { [weak self] _ in
|
||||
@ -315,6 +323,7 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
searchState = nil
|
||||
lastAppliedViewportSignature = currentViewportSignature()
|
||||
pendingViewportChangeReason = nil
|
||||
pendingPresentationRestoreLocation = nil
|
||||
isWaitingForViewportTransitionCompletion = false
|
||||
updateCurrentSelection(nil)
|
||||
readerView.reloadData()
|
||||
@ -587,17 +596,23 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
}
|
||||
|
||||
private func applyReaderViewConfiguration() {
|
||||
let displayTypeDidChange = readerView.currentDisplayType != configuration.displayType
|
||||
let preservedLocation = displayTypeDidChange ? currentVisibleLocation() : nil
|
||||
let resolvedDirection = resolvedPageDirection()
|
||||
let presentationDidChange = readerView.currentDisplayType != configuration.displayType
|
||||
|| readerView.landscapeDualPageEnabled != configuration.landscapeDualPageEnabled
|
||||
|| readerView.pageDirection != resolvedDirection
|
||||
let preservedLocation = presentationDidChange
|
||||
? (pendingPresentationRestoreLocation ?? currentVisibleLocation() ?? persistenceLocation())
|
||||
: nil
|
||||
view.backgroundColor = configuration.theme.contentBackgroundColor
|
||||
readerView.landscapeDualPageEnabled = configuration.landscapeDualPageEnabled
|
||||
readerView.pageDirection = resolvedPageDirection()
|
||||
readerView.pageDirection = resolvedDirection
|
||||
updateReaderChrome()
|
||||
if displayTypeDidChange {
|
||||
if presentationDidChange {
|
||||
readerView.switchReaderDisplayType(configuration.displayType)
|
||||
if let preservedLocation {
|
||||
_ = restoreReadingLocation(preservedLocation)
|
||||
}
|
||||
pendingPresentationRestoreLocation = nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -789,7 +804,8 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
|
||||
private func repaginatePreservingCurrentLocation() {
|
||||
guard publication != nil else { return }
|
||||
let restoreLocation = currentVisibleLocation() ?? persistenceLocation()
|
||||
let restoreLocation = pendingPresentationRestoreLocation ?? currentVisibleLocation() ?? persistenceLocation()
|
||||
pendingPresentationRestoreLocation = nil
|
||||
paginatePublication(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
@ -1042,6 +1058,9 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
settingsController.onLineHeightChange = { [weak self] lineHeightMultiple in
|
||||
self?.updateConfiguration { $0.lineHeightMultiple = lineHeightMultiple }
|
||||
}
|
||||
settingsController.onColumnCountChange = { [weak self] numberOfColumns in
|
||||
self?.updateConfiguration { $0.numberOfColumns = numberOfColumns }
|
||||
}
|
||||
settingsController.onDisplayTypeChange = { [weak self] displayType in
|
||||
self?.updateConfiguration { $0.displayType = displayType }
|
||||
}
|
||||
@ -1097,6 +1116,8 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
) -> Bool {
|
||||
oldConfiguration.fontSize != newConfiguration.fontSize ||
|
||||
oldConfiguration.lineHeightMultiple != newConfiguration.lineHeightMultiple ||
|
||||
oldConfiguration.numberOfColumns != newConfiguration.numberOfColumns ||
|
||||
oldConfiguration.columnGap != newConfiguration.columnGap ||
|
||||
oldConfiguration.reflowableContentInsets != newConfiguration.reflowableContentInsets ||
|
||||
oldConfiguration.fixedContentInset != newConfiguration.fixedContentInset ||
|
||||
oldConfiguration.fixedLayoutFit != newConfiguration.fixedLayoutFit ||
|
||||
@ -1160,6 +1181,24 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let textBook,
|
||||
let chapterData = textBook.chapterData(
|
||||
for: currentLocation,
|
||||
resolver: publication.resourceResolver,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
),
|
||||
let tocItem = chapterData.primaryTableOfContentsItem(
|
||||
from: publication.tableOfContents,
|
||||
normalizer: { publication.resourceResolver.normalizedHref($0) }
|
||||
) {
|
||||
return items.last { $0.href == tocItem.href } ?? items.last {
|
||||
guard let normalizedItemHref = publication.resourceResolver.normalizedHref($0.href.components(separatedBy: "#").first ?? $0.href) else {
|
||||
return false
|
||||
}
|
||||
return normalizedItemHref == normalizedCurrentHref
|
||||
}
|
||||
}
|
||||
|
||||
return items.last { item in
|
||||
guard let normalizedItemHref = publication.resourceResolver.normalizedHref(item.href.components(separatedBy: "#").first ?? item.href) else {
|
||||
return false
|
||||
@ -1175,12 +1214,18 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
items.flatMap { item in
|
||||
let location = RDEPUBLocation(bookIdentifier: currentBookIdentifier, href: item.href, progression: 0)
|
||||
let pageNumber: Int?
|
||||
if let textBook, let publication {
|
||||
pageNumber = textBook.pageNumber(
|
||||
for: location,
|
||||
resolver: publication.resourceResolver,
|
||||
if let textBook, let publication,
|
||||
let chapterData = textBook.chapterData(for: location, resolver: publication.resourceResolver, bookIdentifier: currentBookIdentifier) {
|
||||
let normalizedLocation = publication.resourceResolver.normalizedLocation(
|
||||
location,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
)
|
||||
) ?? location
|
||||
pageNumber = chapterData.pageNumber(for: normalizedLocation)
|
||||
?? textBook.pageNumber(
|
||||
for: location,
|
||||
resolver: publication.resourceResolver,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
)
|
||||
} else if let readingSession {
|
||||
pageNumber = readingSession.pageIndex(for: location, bookIdentifier: currentBookIdentifier).map { $0 + 1 }
|
||||
} else {
|
||||
@ -1242,8 +1287,8 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
frameWidth: derivedFrameSize.width,
|
||||
frameHeight: derivedFrameSize.height,
|
||||
edgeInsets: .zero,
|
||||
numberOfColumns: 1,
|
||||
columnGap: 20,
|
||||
numberOfColumns: configuration.numberOfColumns,
|
||||
columnGap: configuration.columnGap,
|
||||
avoidOrphans: true,
|
||||
avoidWidows: true,
|
||||
avoidPageBreakInsideEnabled: true,
|
||||
@ -1646,22 +1691,12 @@ extension RDEPUBReaderController {
|
||||
|
||||
private func pageNumber(for searchMatch: RDEPUBSearchMatch) -> Int? {
|
||||
if let chapterData = textChapterData(forNormalizedHref: searchMatch.href) {
|
||||
let location = RDEPUBLocation(
|
||||
bookIdentifier: currentBookIdentifier,
|
||||
href: chapterData.href,
|
||||
progression: searchMatch.progression,
|
||||
lastProgression: searchMatch.progression,
|
||||
fragment: nil,
|
||||
rangeAnchor: searchMatch.rangeAnchor
|
||||
)
|
||||
if let pageNumber = chapterData.pageNumber(for: location) {
|
||||
if let pageNumber = chapterData.pageNumber(for: searchMatch) {
|
||||
return pageNumber
|
||||
}
|
||||
|
||||
if let rangeLocation = searchMatch.rangeLocation,
|
||||
let page = chapterData.pages.first(where: {
|
||||
rangeLocation >= $0.pageStartOffset && rangeLocation <= $0.pageEndOffset
|
||||
}) {
|
||||
let page = chapterData.page(containing: rangeLocation) {
|
||||
return page.absolutePageIndex + 1
|
||||
}
|
||||
}
|
||||
@ -1822,6 +1857,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
|
||||
public func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool) {
|
||||
_ = isLandscape
|
||||
pendingPresentationRestoreLocation = currentVisibleLocation() ?? persistenceLocation()
|
||||
}
|
||||
}
|
||||
|
||||
@ -1958,7 +1994,7 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
return nil
|
||||
}
|
||||
|
||||
let location = textBook.chapterData(for: page.href)?.location(forPage: page, bookIdentifier: currentBookIdentifier)
|
||||
let location = textBook.chapterData(forPageNumber: pageNumber)?.location(forPage: page, bookIdentifier: currentBookIdentifier)
|
||||
?? textBook.location(forPageNumber: pageNumber, bookIdentifier: currentBookIdentifier)
|
||||
guard let location else { return nil }
|
||||
|
||||
@ -1986,13 +2022,7 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
}
|
||||
|
||||
private func nativeTextSnapshot(from textBook: RDEPUBTextBook) -> NativeTextSnapshot {
|
||||
let chapters = textBook.chapters.map {
|
||||
EPUBChapterInfo(
|
||||
spineIndex: $0.spineIndex,
|
||||
title: $0.title,
|
||||
pageCount: $0.pages.count
|
||||
)
|
||||
}
|
||||
let chapters = textBook.chapterInfos
|
||||
let pages = textBook.pages.map {
|
||||
EPUBPage(
|
||||
spineIndex: $0.spineIndex,
|
||||
|
||||
@ -112,6 +112,8 @@ public struct RDEPUBReaderSettings: Codable, Equatable {
|
||||
public var fontSize: CGFloat?
|
||||
/// 用户选择的行距倍数,nil 表示使用默认值
|
||||
public var lineHeightMultiple: CGFloat?
|
||||
/// 用户选择的栏数,nil 表示使用默认值
|
||||
public var numberOfColumns: Int?
|
||||
/// 用户选择的翻页模式,nil 表示使用默认值
|
||||
public var displayMode: RDEPUBReaderDisplayMode?
|
||||
/// 用户选择的主题预设,nil 表示使用默认值
|
||||
@ -122,12 +124,14 @@ public struct RDEPUBReaderSettings: Codable, Equatable {
|
||||
brightness: CGFloat? = nil,
|
||||
fontSize: CGFloat? = nil,
|
||||
lineHeightMultiple: CGFloat? = nil,
|
||||
numberOfColumns: Int? = nil,
|
||||
displayMode: RDEPUBReaderDisplayMode? = nil,
|
||||
themePreset: RDEPUBReaderThemePreset? = nil
|
||||
) {
|
||||
self.brightness = brightness
|
||||
self.fontSize = fontSize
|
||||
self.lineHeightMultiple = lineHeightMultiple
|
||||
self.numberOfColumns = numberOfColumns
|
||||
self.displayMode = displayMode
|
||||
self.themePreset = themePreset
|
||||
}
|
||||
@ -145,6 +149,9 @@ public struct RDEPUBReaderSettings: Codable, Equatable {
|
||||
if let lineHeightMultiple {
|
||||
resolvedConfiguration.lineHeightMultiple = lineHeightMultiple
|
||||
}
|
||||
if let numberOfColumns {
|
||||
resolvedConfiguration.numberOfColumns = max(1, numberOfColumns)
|
||||
}
|
||||
if let displayMode {
|
||||
resolvedConfiguration.displayType = displayMode.displayType
|
||||
}
|
||||
@ -169,6 +176,7 @@ public struct RDEPUBReaderSettings: Codable, Equatable {
|
||||
brightness: max(0, min(1, brightness)),
|
||||
fontSize: configuration.fontSize,
|
||||
lineHeightMultiple: configuration.lineHeightMultiple,
|
||||
numberOfColumns: configuration.numberOfColumns,
|
||||
displayMode: RDEPUBReaderDisplayMode(displayType: configuration.displayType),
|
||||
themePreset: RDEPUBReaderThemePreset(theme: configuration.theme)
|
||||
)
|
||||
|
||||
@ -14,6 +14,8 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
var onFontSizeChange: ((CGFloat) -> Void)?
|
||||
/// 行距倍数变化回调
|
||||
var onLineHeightChange: ((CGFloat) -> Void)?
|
||||
/// 栏数变化回调
|
||||
var onColumnCountChange: ((Int) -> Void)?
|
||||
/// 翻页模式变化回调
|
||||
var onDisplayTypeChange: ((RDReaderView.DisplayType) -> Void)?
|
||||
/// 主题变化回调
|
||||
@ -65,6 +67,7 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
private let decreaseFontButton = UIButton(type: .system)
|
||||
private let increaseFontButton = UIButton(type: .system)
|
||||
private let lineHeightControl = UISegmentedControl(items: ["紧凑", "标准", "宽松"])
|
||||
private let columnCountControl = UISegmentedControl(items: ["单栏", "双栏"])
|
||||
private let displayTypeControl = UISegmentedControl(items: ["仿真", "横滑", "竖滑"])
|
||||
private let themeStackView: UIStackView = {
|
||||
let stackView = UIStackView()
|
||||
@ -136,6 +139,7 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
increaseFontButton.addTarget(self, action: #selector(increaseFontAction), for: .touchUpInside)
|
||||
|
||||
lineHeightControl.addTarget(self, action: #selector(lineHeightChanged(_:)), for: .valueChanged)
|
||||
columnCountControl.addTarget(self, action: #selector(columnCountChanged(_:)), for: .valueChanged)
|
||||
displayTypeControl.addTarget(self, action: #selector(displayTypeChanged(_:)), for: .valueChanged)
|
||||
|
||||
ThemePreset.allCases.forEach { preset in
|
||||
@ -156,6 +160,7 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
contentStack.addArrangedSubview(makeSection(title: "亮度", content: brightnessSlider))
|
||||
contentStack.addArrangedSubview(makeSection(title: "字号", content: makeFontSizeRow()))
|
||||
contentStack.addArrangedSubview(makeSection(title: "行距", content: lineHeightControl))
|
||||
contentStack.addArrangedSubview(makeSection(title: "分栏", content: columnCountControl))
|
||||
contentStack.addArrangedSubview(makeSection(title: "翻页方式", content: displayTypeControl))
|
||||
contentStack.addArrangedSubview(makeSection(title: "主题", content: themeStackView))
|
||||
}
|
||||
@ -203,6 +208,7 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
|
||||
let lineHeightIndex = lineHeightValues.enumerated().min { abs($0.element - currentConfiguration.lineHeightMultiple) < abs($1.element - currentConfiguration.lineHeightMultiple) }?.offset ?? 1
|
||||
lineHeightControl.selectedSegmentIndex = lineHeightIndex
|
||||
columnCountControl.selectedSegmentIndex = currentConfiguration.numberOfColumns > 1 ? 1 : 0
|
||||
|
||||
switch currentConfiguration.displayType {
|
||||
case .pageCurl:
|
||||
@ -233,7 +239,7 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
button.backgroundColor = theme.toolBackgroundColor
|
||||
}
|
||||
|
||||
[lineHeightControl, displayTypeControl].forEach { control in
|
||||
[lineHeightControl, columnCountControl, displayTypeControl].forEach { control in
|
||||
control.backgroundColor = theme.toolBackgroundColor
|
||||
if #available(iOS 13.0, *) {
|
||||
control.selectedSegmentTintColor = theme.toolControlTextColor.withAlphaComponent(0.14)
|
||||
@ -289,6 +295,12 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
onLineHeightChange?(value)
|
||||
}
|
||||
|
||||
@objc private func columnCountChanged(_ control: UISegmentedControl) {
|
||||
let numberOfColumns = control.selectedSegmentIndex == 1 ? 2 : 1
|
||||
currentConfiguration.numberOfColumns = numberOfColumns
|
||||
onColumnCountChange?(numberOfColumns)
|
||||
}
|
||||
|
||||
@objc private func displayTypeChanged(_ control: UISegmentedControl) {
|
||||
let displayType: RDReaderView.DisplayType
|
||||
switch control.selectedSegmentIndex {
|
||||
|
||||
@ -102,17 +102,8 @@ final class RDEPUBSelectionOverlayView: UIView {
|
||||
}
|
||||
|
||||
if let snapshot,
|
||||
let attachment = snapshot.attachment(at: point) {
|
||||
return attachment.stringRange
|
||||
}
|
||||
|
||||
if let snapshot {
|
||||
let hitDecorations = snapshot.rects(containing: point, in: resolvedDecorations)
|
||||
if let mostSpecific = hitDecorations.min(by: { lhs, rhs in
|
||||
lhs.absoluteRange.length < rhs.absoluteRange.length
|
||||
}) {
|
||||
return mostSpecific.absoluteRange
|
||||
}
|
||||
let absoluteRange = snapshot.absoluteRange(at: point, in: resolvedDecorations) {
|
||||
return absoluteRange
|
||||
}
|
||||
|
||||
for decoration in resolvedDecorations {
|
||||
|
||||
@ -383,13 +383,14 @@ final class RDEPUBTextContentView: UIView {
|
||||
return
|
||||
}
|
||||
|
||||
let totalLength = max(page.content.length - 1, 1)
|
||||
let relativeLocation = range.location - page.pageStartOffset
|
||||
let chapterLength = max(page.chapterContent.length - 1, 1)
|
||||
let chapterStart = max(range.location, 0)
|
||||
let chapterEnd = max(chapterStart + range.length - 1, chapterStart)
|
||||
let selection = RDEPUBSelection(
|
||||
location: RDEPUBLocation(
|
||||
href: page.href,
|
||||
progression: Double(max(relativeLocation, 0)) / Double(totalLength),
|
||||
lastProgression: Double(max(relativeLocation + range.length - 1, 0)) / Double(totalLength),
|
||||
progression: Double(chapterStart) / Double(chapterLength),
|
||||
lastProgression: Double(chapterEnd) / Double(chapterLength),
|
||||
fragment: nil
|
||||
),
|
||||
text: selectedText,
|
||||
@ -698,12 +699,12 @@ extension RDEPUBTextContentView: UITextViewDelegate {
|
||||
|
||||
let globalStart = page.pageStartOffset + selectedRange.location
|
||||
let globalEnd = globalStart + selectedRange.length
|
||||
let totalLength = max(page.content.length - 1, 1)
|
||||
let totalLength = max(page.chapterContent.length - 1, 1)
|
||||
let selection = RDEPUBSelection(
|
||||
location: RDEPUBLocation(
|
||||
href: page.href,
|
||||
progression: Double(selectedRange.location) / Double(totalLength),
|
||||
lastProgression: Double(max(selectedRange.location + selectedRange.length - 1, 0)) / Double(totalLength),
|
||||
progression: Double(globalStart) / Double(totalLength),
|
||||
lastProgression: Double(max(globalEnd - 1, globalStart)) / Double(totalLength),
|
||||
fragment: nil
|
||||
),
|
||||
text: selectedText,
|
||||
|
||||
@ -287,6 +287,13 @@ public class RDReaderView: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 将页码钳制到当前书籍的合法范围内
|
||||
private func clampedPageNumber(_ pageNum: Int) -> Int? {
|
||||
let totalPages = dataSource?.pageCountOfReaderView(readerView: self) ?? 0
|
||||
guard totalPages > 0 else { return nil }
|
||||
return min(max(pageNum, 0), totalPages - 1)
|
||||
}
|
||||
|
||||
/// 上一次检测到的横竖屏状态,用于在 layoutSubviews 中检测方向变化
|
||||
private var previousIsLandscape: Bool?
|
||||
|
||||
@ -339,6 +346,14 @@ public class RDReaderView: UIView {
|
||||
let boundsSize: CGSize
|
||||
}
|
||||
|
||||
/// 当 pageCurl 正在转场时,后续跳转请求会先排队,待当前动画稳定后再执行。
|
||||
private struct PageTransitionRequest: Equatable {
|
||||
let pageNum: Int
|
||||
let animated: Bool
|
||||
}
|
||||
|
||||
private var pendingTransitionRequest: PageTransitionRequest?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
}
|
||||
@ -465,12 +480,41 @@ public class RDReaderView: UIView {
|
||||
guard currentPage >= 0 else { return }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.isTransitioning = false
|
||||
self.pendingTransitionRequest = nil
|
||||
self.willPreviousTransitionToViewController = nil
|
||||
self.willNextTransitionToViewController = nil
|
||||
self.willTransitionToViewController = nil
|
||||
self.invalidatePageCaches()
|
||||
self.rebuildPageViewController()
|
||||
self.transitionToPage(pageNum: self.currentPage, animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
/// pageCurl 动画过程中如果收到新的跳转请求,则排队等待当前动画结束。
|
||||
private func shouldQueuePageTransition(_ request: PageTransitionRequest) -> Bool {
|
||||
guard currentDisplayType == .pageCurl, isTransitioning else { return false }
|
||||
pendingTransitionRequest = request
|
||||
return true
|
||||
}
|
||||
|
||||
/// 收尾 pageCurl 转场,并尝试执行排队中的后续跳转。
|
||||
private func finishPageCurlTransition(repairFaultIfNeeded: Bool = true) {
|
||||
isTransitioning = false
|
||||
willPreviousTransitionToViewController = nil
|
||||
willNextTransitionToViewController = nil
|
||||
willTransitionToViewController = nil
|
||||
|
||||
if repairFaultIfNeeded, detectPageViewControllerFault(pageViewController) {
|
||||
patchPageViewControllerFault()
|
||||
return
|
||||
}
|
||||
|
||||
guard let pendingTransitionRequest else { return }
|
||||
self.pendingTransitionRequest = nil
|
||||
transitionToPage(pageNum: pendingTransitionRequest.pageNum, animated: pendingTransitionRequest.animated)
|
||||
}
|
||||
|
||||
// MARK: - Preload / Forecast
|
||||
|
||||
/// 确保预加载宿主视图已添加到视图层级
|
||||
@ -706,6 +750,10 @@ public class RDReaderView: UIView {
|
||||
if didAddToParent {
|
||||
pageViewController.didMove(toParent: parentViewController)
|
||||
}
|
||||
|
||||
if currentDisplayType == .pageCurl, !isTransitioning, pendingTransitionRequest != nil {
|
||||
finishPageCurlTransition(repairFaultIfNeeded: false)
|
||||
}
|
||||
}
|
||||
|
||||
/// 从父控制器和视图层级中移除 pageViewController
|
||||
@ -900,45 +948,65 @@ public class RDReaderView: UIView {
|
||||
/// - pageNum: 目标页码(item 索引)
|
||||
/// - animated: 是否动画过渡
|
||||
public func transitionToPage(pageNum: Int, animated: Bool = false) {
|
||||
guard let safePageNum = clampedPageNumber(pageNum) else { return }
|
||||
switch currentDisplayType {
|
||||
case .pageCurl:
|
||||
let request = PageTransitionRequest(pageNum: safePageNum, animated: animated)
|
||||
if shouldQueuePageTransition(request) {
|
||||
return
|
||||
}
|
||||
|
||||
let isDualPage = landscapeDualPageEnabled && isLandscape
|
||||
// RTL 时动画方向需要反转
|
||||
let direction: UIPageViewController.NavigationDirection
|
||||
if pageDirection == .rightToLeft {
|
||||
direction = pageNum > currentPage ? .reverse : .forward
|
||||
direction = safePageNum > currentPage ? .reverse : .forward
|
||||
} else {
|
||||
direction = pageNum > currentPage ? .forward : .reverse
|
||||
direction = safePageNum > currentPage ? .forward : .reverse
|
||||
}
|
||||
predictedPageDirection = currentPage >= 0 ? safePageNum >= currentPage : nil
|
||||
attachPageViewControllerIfNeeded()
|
||||
if isDualPage {
|
||||
let pair = dualPagePair(for: pageNum)
|
||||
let pair = dualPagePair(for: safePageNum)
|
||||
let leftContent = pageViewForDisplay(pageNum: pair.left)
|
||||
let leftVC = RDReaderPageChildViewController(contentView: leftContent, pageNum: pair.left)
|
||||
isTransitioning = animated
|
||||
if let rightPage = pair.right {
|
||||
let rightContent = pageViewForDisplay(pageNum: rightPage)
|
||||
let rightVC = RDReaderPageChildViewController(contentView: rightContent, pageNum: rightPage)
|
||||
pageViewController.setViewControllers([leftVC, rightVC], direction: animated ? direction : .forward, animated: animated, completion: nil)
|
||||
pageViewController.setViewControllers([leftVC, rightVC], direction: animated ? direction : .forward, animated: animated) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.finishPageCurlTransition()
|
||||
}
|
||||
} else {
|
||||
// 封面页独占或奇数最后一页:右侧放空白页
|
||||
let blankNum = isFullScreenPage(pair.left) ? RDReaderView.blankPageNum : RDReaderView.blankEndPageNum
|
||||
let emptyVC = RDReaderPageChildViewController(contentView: UIView(), pageNum: blankNum)
|
||||
pageViewController.setViewControllers([leftVC, emptyVC], direction: animated ? direction : .forward, animated: animated, completion: nil)
|
||||
pageViewController.setViewControllers([leftVC, emptyVC], direction: animated ? direction : .forward, animated: animated) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.finishPageCurlTransition()
|
||||
}
|
||||
}
|
||||
currentPage = pair.left
|
||||
primePageCache(around: pair.left, preferredForward: predictedPageDirection)
|
||||
} else {
|
||||
let contentView = pageViewForDisplay(pageNum: pageNum)
|
||||
let vc = RDReaderPageChildViewController(contentView: contentView, pageNum: pageNum)
|
||||
pageViewController.setViewControllers([vc], direction: animated ? direction : .forward, animated: animated, completion: nil)
|
||||
currentPage = pageNum
|
||||
primePageCache(around: pageNum, preferredForward: predictedPageDirection)
|
||||
let contentView = pageViewForDisplay(pageNum: safePageNum)
|
||||
let vc = RDReaderPageChildViewController(contentView: contentView, pageNum: safePageNum)
|
||||
isTransitioning = animated
|
||||
pageViewController.setViewControllers([vc], direction: animated ? direction : .forward, animated: animated) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.finishPageCurlTransition()
|
||||
}
|
||||
currentPage = safePageNum
|
||||
primePageCache(around: safePageNum, preferredForward: predictedPageDirection)
|
||||
}
|
||||
default:
|
||||
collectionView.reloadData()
|
||||
collectionView.layoutIfNeeded()
|
||||
collectionView.setContentOffset(layout.currentContentOffset(count: pageNum), animated: animated)
|
||||
currentPage = pageNum
|
||||
primePageCache(around: pageNum, preferredForward: predictedPageDirection)
|
||||
predictedPageDirection = currentPage >= 0 ? safePageNum >= currentPage : nil
|
||||
collectionView.setContentOffset(layout.currentContentOffset(count: safePageNum), animated: animated)
|
||||
currentPage = safePageNum
|
||||
primePageCache(around: safePageNum, preferredForward: predictedPageDirection)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1091,15 +1159,13 @@ extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDele
|
||||
}
|
||||
|
||||
predictedPageDirection = nil
|
||||
|
||||
if detectPageViewControllerFault(pageViewController) {
|
||||
patchPageViewControllerFault()
|
||||
}
|
||||
finishPageCurlTransition()
|
||||
}
|
||||
|
||||
/// UIPageViewController 代理:即将开始转场动画
|
||||
/// 记录预测的翻页方向,用于优化预加载策略
|
||||
public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
|
||||
isTransitioning = true
|
||||
willTransitionToViewController = pendingViewControllers.first
|
||||
if let target = pendingViewControllers.first as? RDReaderPageChildViewController,
|
||||
target.pageNum != RDReaderView.blankPageNum,
|
||||
@ -1151,6 +1217,9 @@ extension RDReaderView: UICollectionViewDataSource, RDReaderFlowLayoutDelegate,
|
||||
|
||||
/// 布局代理回调:当前可见页码变化时通知
|
||||
public func pageNum(flowLayout: RDReaderFlowLayout, pageIndex: Int) {
|
||||
if currentPage >= 0, currentPage != pageIndex {
|
||||
predictedPageDirection = pageIndex >= currentPage
|
||||
}
|
||||
currentPage = pageIndex
|
||||
primePageCache(around: pageIndex, preferredForward: predictedPageDirection)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user