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