Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83dfa40299 | ||
|
|
c4be426299 | ||
|
|
e4e629a06c | ||
|
|
5a41066b66 | ||
|
|
d0efe3f2cc | ||
|
|
ca408c20ab | ||
|
|
5ae7823ef8 | ||
|
|
bd6e915fbd | ||
|
|
7132e4952b | ||
|
|
f796823db8 | ||
|
|
47fe2dc450 | ||
|
|
9e91207011 |
@@ -0,0 +1,154 @@
|
|||||||
|
# 长章节内存优化 — 代码实施清单
|
||||||
|
|
||||||
|
> 背景:DTCoreText 文本页已采用"整章上下文布局",分页与显示边界一致(不可回退)。现状代价:每次翻页/预加载都整章重拷贝 + 重排版后丢弃,且章节缓存里每页常驻一份子串副本。
|
||||||
|
>
|
||||||
|
> 总原则:保留"整章参与排版",去掉"整章按页复制、按页可变、按页缓存"。不改分页器,不上窗口化。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P0:立即可做
|
||||||
|
|
||||||
|
### P0-1 削减每页 `content` 子串常驻副本
|
||||||
|
|
||||||
|
**现状**:分页产物为每页保存整段 `attributedSubstring`,全章加总约等于又一份整章副本,随邻接窗口(radius 1,共 3 章)常驻。
|
||||||
|
|
||||||
|
生成点:
|
||||||
|
|
||||||
|
- `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift:494`(`buildPagesFromRanges`)
|
||||||
|
- `Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift:294`
|
||||||
|
- `Sources/RDReaderView/EPUBTextRendering/RDPlainTextBookBuilder.swift:78`
|
||||||
|
|
||||||
|
现存全部消费点(已盘点确认,仅 3 处):
|
||||||
|
|
||||||
|
- `RDEPUBTextContentView.swift:562` — 封面检测 `coverImage(from: page.content)`,仅 `pageIndexInChapter == 0` 且 href 含 "cover"
|
||||||
|
- `RDEPUBTextContentView.swift:641` — `normalizedPageContent`,仅非 DTCoreText 的 `#else` 回退路径调用
|
||||||
|
- `RDEPUBTextBookBuilder.swift:93` — 构建期 debug 日志 preview
|
||||||
|
|
||||||
|
**任务**:
|
||||||
|
|
||||||
|
- [x] 将 `RDEPUBTextPage.content` 改为按需构造:已改为基于 `chapterContent` + `contentRange` 的计算属性(带范围钳制),公开读取 API 不变
|
||||||
|
- [x] 三个构造点(`RDEPUBChapterLoader` / `RDEPUBTextBookBuilder` / `RDPlainTextBookBuilder`)不再生成每页子串;消费点经计算属性透明按需构造
|
||||||
|
- [x] `Equatable` 确认:`content` 为派生值,由 `contentRange` + `chapterContent` 判等覆盖,语义不变
|
||||||
|
|
||||||
|
**API 兼容性注意**:
|
||||||
|
|
||||||
|
- `RDEPUBTextPage` 当前是 `public struct`,`content` 也是公开存储属性;如果 SDK 已存在外部接入方直接读取 `page.content`,则不应直接移除该字段
|
||||||
|
- 优先方案是保留 `content` 对外访问语义,但将其改为按需构造的计算属性或受控访问器,避免对外 API 断裂
|
||||||
|
- 若确认当前版本尚无稳定外部依赖,才可以评估把 `content` 从存储属性调整为内部实现细节
|
||||||
|
- 在实施前应补做一次全仓库与接入侧检索,确认是否有外部调用依赖 `page.content` 的“可变存储”特性
|
||||||
|
|
||||||
|
**验收**:打开 3 章窗口后常驻内存下降约"一整章文本规模 × 窗口章数";封面页显示正常;非 DTCoreText 回退路径回归通过;`--demo-pagination-validate` 命中数不回升。
|
||||||
|
|
||||||
|
### P0-2 增加内存打点
|
||||||
|
|
||||||
|
- [x] 已新增 `RDEPUBMemoryProbe`(`--demo-memory-probe` 启动参数开启,输出 phys_footprint);挂载点:`RDEPUBChapterRuntimeStore.insertChapter`、`pageNum` 回调每 20 次翻页、`invalidateAllForSettingsChange`、`viewWillTransition` 转屏完成
|
||||||
|
- [x] 用 `--demo-memory-probe` 运行 Demo 即可采集对比基线
|
||||||
|
|
||||||
|
### P0-3 保持现有缓存限制,防止错误复用
|
||||||
|
|
||||||
|
- [x] `shouldAvoidReaderPageCaching`(`RDEPUBTextContentView.swift:319`)保持现状,已补注释说明放开的前置条件是 P1-1 落地
|
||||||
|
- [x] 本阶段未改 `RDReaderPreloadController` 行为
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P1:第二波(P1-1 与 P1-2 必须同批落地)
|
||||||
|
|
||||||
|
### P1-1 章节级共享 displayContent
|
||||||
|
|
||||||
|
**现状**:`RDEPUBTextContentView.configure` 每次创建整章可变副本(`RDEPUBTextContentView.swift:443`)并按页写入三类属性:页范围主题色(453)、页范围暗黑图附件替换(448)、按页裁剪的高亮属性(459 `applyHighlightsToContent`)。
|
||||||
|
|
||||||
|
**约束**:共享字符串一旦被任一页的 `DTCoreTextLayouter`/framesetter 持有,就不得再修改——所有属性必须在共享内容构建时一次性注入,或转为 overlay(见 P1-2)。
|
||||||
|
|
||||||
|
**任务**:
|
||||||
|
|
||||||
|
- [x] 已新建 `RDEPUBChapterDisplayContentCache`(LRU 容量 2,主线程限定,`RDEPUBReaderController` 持有):`content` + `layouter` + `signature`
|
||||||
|
- [x] 构建共享内容时一次性注入:全章范围主题色;高亮/下划线因 P1-2 转 overlay,不进共享内容也不进签名
|
||||||
|
- [x] 暗黑图策略选 a:构建时全章一次性替换,复用 `RDEPUBDarkImageAdjuster` 的 NSCache
|
||||||
|
- [x] `RDEPUBTextContentView.configure` 已改为接收 `displayCache` 参数并引用共享 entry,整章拷贝已删除
|
||||||
|
- [x] 签名 = 章节内容对象标识 + 长度 + 主题双色 + 暗黑图配置;设置/主题变化经签名自动失效重建(高亮变化不触发重建,仅重建 overlay 装饰)
|
||||||
|
- [x] 控制器在 `didReceiveMemoryWarning` 清空 display cache;设置变更重建 chapterContent 实例后由签名兜底换代
|
||||||
|
|
||||||
|
**验收**:连续翻页不再出现整章拷贝(打点确认);翻页延迟下降;高亮显示、高亮点击菜单、underline、搜索 currentMatch、附件点击、选区拖拽全部回归通过。
|
||||||
|
|
||||||
|
> 2026-07-08 回归记录:高亮/批注/选区/翻页/设置 13 项 UI 用例全过;SearchTests 10 项失败,但已在会话前提交 5a41066 与 P0 提交 e4e629a 上复现同样失败(搜索 0 命中),确认为先于本优化存在的独立回归(最后一次已知通过是 2026-06-08 全量跑),需单独排查,与 P1 改动无关。
|
||||||
|
|
||||||
|
### P1-2 高亮/下划线剥离为 overlay(与 P1-1 绑定)
|
||||||
|
|
||||||
|
**现状**:搜索高亮已走 overlay(`RDEPUBTextContentView.swift:759` 处 `buildDecorations` 传 `highlights: []`,注释明确为避免双画);普通高亮/下划线仍靠写 `kRDEPUBHighlightAttributeName` / `kRDEPUBUnderlineAttributeName` 属性由 render view 绘制。
|
||||||
|
|
||||||
|
**任务**:
|
||||||
|
|
||||||
|
- [x] `buildDecorations` 已传入真实 highlights,高亮进背景层(文字下方)、下划线进前景层
|
||||||
|
- [x] `applyHighlightsToContent` 与 render view 的 `drawHighlights`/`computeHighlightRects`/`attributedDisplayContent` 已整体移除,无双画
|
||||||
|
- [x] 高亮命中与菜单锚点继续走 `interactionController.selectionRects`,坐标保持 chapter-absolute(未改动)
|
||||||
|
|
||||||
|
**验收**:高亮/下划线视觉与改前一致(含跨页高亮的页内裁剪);高亮点击弹菜单正常;与搜索高亮叠加时无双画。
|
||||||
|
|
||||||
|
### P1-3 章节级共享 display layouter
|
||||||
|
|
||||||
|
**现状**:每个页面视图各建一个 `DTCoreTextLayouter`(`RDEPUBTextContentView.swift:465`);章节运行时已有分页用 `RDEPUBTextLayouter`(`RDEPUBRuntimeChapter.layouter`),display layouter 向同一模式靠拢。
|
||||||
|
|
||||||
|
**任务**:
|
||||||
|
|
||||||
|
- [x] `DTCoreTextLayouter` 已随共享内容放入 display cache,与 signature 同生命周期
|
||||||
|
- [x] 页面按 `bounds` + `page.contentRange` 取 `layoutFrame`;`shouldCacheLayoutFrames = false` 保持,layoutFrame 按页新建(VerticalJustifier 的修改不会污染共享对象)
|
||||||
|
- [x] framesetter 只依赖字符串、与 bounds 无关,bounds 变化无需失效共享对象;layoutFrame 随页面视图释放
|
||||||
|
- [x] `RDEPUBTextPageBoundaryValidator` 校验路径不变
|
||||||
|
|
||||||
|
**验收**:翻页时不再重建 framesetter(打点确认);横竖屏切换后布局正确;`--demo-pagination-validate` 不回升。
|
||||||
|
|
||||||
|
### P1-4 评估重新放开文本页预加载缓存
|
||||||
|
|
||||||
|
前置:P1-1~P1-3 全部落地后,页面视图不再持有整章副本。
|
||||||
|
|
||||||
|
- [ ] 调整 `shouldAvoidReaderPageCaching` 判定,允许轻量化后的文本页进入 `RDReaderPreloadController` 缓存
|
||||||
|
- [ ] 对比放开前后的翻页流畅度与峰值内存,数据不佳则回退此项
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P2:视 P0/P1 打点数据决定
|
||||||
|
|
||||||
|
### P2-1 章节级 LRU 分层淘汰
|
||||||
|
|
||||||
|
**现状**:`RDEPUBChapterRuntimeStore` 已有邻接窗口(`windowSpineIndices`,radius 1)、`evictableSpineIndices` / `evict` / `evictAllExceptCurrent` / `handleMemoryWarning` / `invalidateAllForSettingsChange`;磁盘侧有 `RDEPUBChapterSummaryDiskCache`(分页结果 + cfiMap + metadata)。当前邻接章节仍持有完整 `RDEPUBRuntimeChapter`(typesetString + pages + layouter)。
|
||||||
|
|
||||||
|
**任务**:
|
||||||
|
|
||||||
|
- [ ] 邻接章节降级:保留 page map / chapter summary / 分页结果,释放 `typesetAttributedString`、display content、layouter
|
||||||
|
- [ ] 进入邻接章节时按 summary 重建,测量重建延迟;延迟不可接受则维持现状
|
||||||
|
- [ ] 远距章节仅保留磁盘缓存 + 轻量 metadata(现有 evict 已覆盖,确认即可)
|
||||||
|
|
||||||
|
### P2-2 图片附件深化
|
||||||
|
|
||||||
|
**现状**:暗黑图已走 `NSCache`(50 MB / 100 张,`RDEPUBDarkImageAdjuster.swift:9`)且仅处理当前页范围;章节运行时另有 100 MB `imageCache`。
|
||||||
|
|
||||||
|
**任务**:
|
||||||
|
|
||||||
|
- [ ] 大图按显示尺寸下采样后再参与 attachment
|
||||||
|
- [ ] 页面离屏后释放 attachment 强引用的已解码大图;避免共享 content 长期持有
|
||||||
|
- [ ] 回归:图片点击查看、暗黑模式切换、附件位置与命中
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P3:仅在 P0–P2 之后数据仍不达标时
|
||||||
|
|
||||||
|
- [ ] 上下文窗口化布局(当前页所在段落块 + 前后足以影响断行的文本窗口)。风险最高:易重新引入分页/显示边界不一致(CTTypesetter 断行上下文敏感,为已知已解 bug 的根因),选区/搜索/高亮/附件索引需全部重验。默认不做。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 明确不做
|
||||||
|
|
||||||
|
1. 回退"页内子串重新布局"(重新引入显示不全/页末孤字)
|
||||||
|
2. 取消 chapter-absolute 索引(破坏点击/选区/高亮定位一致性)
|
||||||
|
3. 先改分页器(正确性与性能问题混杂,无法回归)
|
||||||
|
4. 未落地 P1 前放开文本页 reader 级缓存(会驻留多份整章副本)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 各阶段统一验收指标
|
||||||
|
|
||||||
|
**正确性**:`--demo-pagination-validate` 命中数不回升;长章节页末无孤字/缺字;高亮显示、搜索高亮、附件点击、选区拖拽行为一致。
|
||||||
|
|
||||||
|
**内存**(对比 P0-2 基线):打开长章节后常驻;连续翻页 20 页峰值;设置变更前后峰值;横竖屏切换峰值。
|
||||||
|
|
||||||
|
**性能**:首次打开章节耗时;连续翻页帧稳定性;设置切换恢复时间。
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# 分页问题调查记录(2026-07-06)
|
||||||
|
|
||||||
|
调查载体:《宝山辽墓材料与释读》(textReflowable / DTCoreText 路径),iPhone 15 Pro Max 尺寸(430×932,内容区 398×815pt)。当日共调查两个独立问题:
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题一:页底留空一行多,下一页首行未上移(已修复)
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
|
||||||
|
第 6 页底部留有约 1.7 行高的空白,第 7 页首行"人目为帝羓,信有之也。[注]"本可容纳在第 6 页。截图实测:行距 75px@3x(25pt),第 6 页底部空隙 138px,足够再排一行。
|
||||||
|
|
||||||
|
### 根因链
|
||||||
|
|
||||||
|
1. `RDEPUBSemanticMarkerInjector.inferredHints` 给**所有** `<img>` 打上 `avoidPageBreakInside` 提示——包括行内脚注小图标(`<img class="qqreader-footnote">`,即"注"字图标)。
|
||||||
|
2. `RDEPUBPageBreakPolicy.shouldTreatAvoidHintAsBlockProtection` 本有豁免:`blockKind == .attachment && placement != .centered → 不保护`。但 `applyPaginationSemantics` 按**结束标记的字符串顺序**应用属性:`<img>` 的结束标记先于外层 `<p>`,段落随后把图标位置的 `.rdPageBlockKind` 从 `attachment` 覆盖为 `paragraph`;而 `hints` 与 `placement` 因段落不携带这两个属性而幸存。豁免条件因此失效。
|
||||||
|
3. `trimmedRangeForAvoidPageBreakInside` 把含注图标的行当作"不可分页块"的行,从页底裁掉(最多 3 行),推到下一页,留下空白。
|
||||||
|
|
||||||
|
### 验证手段
|
||||||
|
|
||||||
|
- Demo 启动参数 `--demo-pagination-debug` 输出 `[PAGINATION-DEBUG] avoidPageBreakInside removed N lines: …`;修复前被裁的行**全部**含 ``(脚注图标)。
|
||||||
|
- 复现命令:`--demo-book-title 宝山 --demo-page 6 --demo-reset-state --demo-clear-cache --demo-pagination-debug`。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
`RDEPUBPageBreakPolicy.swift`:豁免条件放宽为 `(blockKind == .attachment || placement != nil) && placement != .centered`——不再依赖易被覆盖的 blockKind,只要附件 placement 是行内/基线(非居中)就不锁行。居中插图(bodyPic)的整块保护不受影响;同时覆盖 `s-pic`/`h-pic`/`g-pic` 等生僻字行内小图。
|
||||||
|
|
||||||
|
修复后验证:含 `` 的裁剪从多处降为 0;"人目为帝羓"行回到第 6 页且下一段首行补齐;全书页数 280 → 273(消除欠填页)。
|
||||||
|
|
||||||
|
> 备选方案(未采用):修 `applyPaginationSemantics` 的嵌套覆盖顺序(内层优先)。会改变列表/表格的 blockRange 语义,风险大。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题二:页范围与显示内容度量错配,行中断页(2026-07-07 已定位根因,见文末新增章节)
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
|
||||||
|
第 10 页最后一行只有孤字"相",第 11 页从"对简化,且无构造细致的翼墙。"开始——断点落在句子中间而非行边界。
|
||||||
|
|
||||||
|
### 铁证推理
|
||||||
|
|
||||||
|
当前设置下满行 26 字(字号 15、内容宽 398pt)。第 10 页范围要在"…而2号墓的门楼则相"后断开,要求该行装下 **27 字**——当前排版下不可能。**结论:页范围产生于另一套度量(更小字号或更宽版面),与显示时的排版不一致。** 同一套排版中分页断点必然落在行边界,孤字不可能出现。
|
||||||
|
|
||||||
|
### 已排除(受控实验)
|
||||||
|
|
||||||
|
在 `RDEPUBChapterLoader` 临时加 `--demo-chapter-dump` 转储(typesetString 逐属性段 + 页范围,写入 app Documents,实验后已还原):
|
||||||
|
|
||||||
|
- 冷启动(`MISS(fullRender)` 全量渲染分页)与热启动(`HIT(diskSummary)` 复用磁盘页范围)两条路径的字符串与页范围**完全一致**。字体压平、双重 `normalizeReadingAttributes`、语言码缺失(`makeChapterRenderRequest` 未传 `contentLanguageCode`)、`TailNormalizer` 尾页合并均验证为无影响或幂等。
|
||||||
|
- 持久缓存键控正确:`RDEPUBChapterCacheKey.renderSignature` 含字体名/字号/行距倍数/lineSpacing/`layoutConfig.cacheSignature`(宽高、四边距、分栏、hyphenation、schemaVersion=17)+ 章节内容哈希。同设置跨启动复用安全。
|
||||||
|
|
||||||
|
### 主要嫌疑:会话中改设置的换表过渡态
|
||||||
|
|
||||||
|
- 用户两组截图之间调过行距(行距 25pt/页 32 行 → 28pt/页 28 行,字号未变);出现问题时显示总页数 196,既非 1.6 档全量分页(~273)也非 1.8 档(~290)——当时显示的是**未收敛的中间页表**。
|
||||||
|
- 代码窗口:
|
||||||
|
- `RDEPUBReaderRuntime.scheduleSettingsPreviewRepagination`:设置面板打开期间只重排**当前章**并 `applySettingsPreviewPageMap` 换部分页表,全量重排推迟到面板关闭(`needsFullRepaginationAfterSettingsClose`)。
|
||||||
|
- `RDEPUBChapterRuntimeStore.chapterDataCache` 仅按 spineIndex 键控(无样式维度);`invalidateAllForSettingsChange` 清空后,**变更前已在飞行中的构建**完成时会把旧样式章节重新 `insertChapter` 插回。
|
||||||
|
- 这些窗口里可能出现"旧页范围 + 新排版内容"的错配。
|
||||||
|
|
||||||
|
### 待办 / 复现所需
|
||||||
|
|
||||||
|
1. 确认触发操作:是否在设置面板调整行距/字号后(未关面板或刚关)翻页出现。
|
||||||
|
2. 确认重启 app 后是否自愈(受控实验表明缓存路径本身一致,理应自愈;若不自愈则有持久化的脏状态)。
|
||||||
|
3. 修复方向(待复现后定):设置变更代(generation)标记贯穿构建流程,飞行中的旧代构建完成后丢弃、不得插回 store;或 `chapterDataCache` 键加入 renderSignature。
|
||||||
|
|
||||||
|
### 经验教训
|
||||||
|
|
||||||
|
- 跨启动比较"第 N 页"截图无效:章节渐进加载过程中全局页码会漂移。
|
||||||
|
- 判断断点是否合法的快捷方法:数满行字数。断点不在行边界 ⟺ 范围与显示度量不一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关工具与命令备忘
|
||||||
|
|
||||||
|
| 用途 | 方法 |
|
||||||
|
|---|---|
|
||||||
|
| 分页裁剪日志 | 启动参数 `--demo-pagination-debug` |
|
||||||
|
| 自动打开书籍/翻页 | `--demo-book-title 宝山 --demo-page N --demo-clear-cache --demo-reset-state` |
|
||||||
|
| 注入阅读设置 | `simctl spawn <sim> defaults write cn.shen.ReadViewDemo ssreader.epub.settings -data <hex(JSON)>`,JSON 形如 `{"fontSize":15,"lineHeightMultiple":1.8}` |
|
||||||
|
| 截屏 | `xcrun simctl io <sim> screenshot <绝对路径>`(相对路径会因只读文件系统失败) |
|
||||||
|
| 模拟器 | 预装仅 16/17 系列;`simctl create` 可建 iPhone 15 Pro Max(430×932@3x 与问题截图同尺寸) |
|
||||||
|
| 页边界/换行一致性校验 | 启动参数 `--demo-pagination-validate`(RDEPUBTextPageBoundaryValidator,逐页比对显示换行与全章上下文换行,输出 STALE-RANGE / DISPLAY-DIVERGE / DIAGNOSE / ATTR-DIFF) |
|
||||||
|
| 设置面板自动翻转 | 启动参数 `--demo-settings-flip`(RDEPUBSettingsFlipAutomation,自动开面板→改行距→关面板→翻页,三种时序) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题二根因(2026-07-07 续查确认)
|
||||||
|
|
||||||
|
### 结论
|
||||||
|
|
||||||
|
**分页与显示使用同一引擎(DTCoreText/CoreText)但输入字符串不同:分页在“全章字符串”上下文中断行;显示把页内容取成子串后重新断行。`CTTypesetterSuggestLineBreak` 在同一文字、同一属性、同一宽度下,会因字符串上下文不同而在 CJK 标点压缩/悬挂处产生 ±1 字的断点漂移。** 页范围(全章上下文产物)与显示换行(子串产物)在页内任何一行发生漂移,累积到页尾就出现孤字(用户看到的「相」)或半空行。
|
||||||
|
|
||||||
|
与缓存、设置换表竞态无关:设置变更只是**搬动了页边界位置**,让某个边界恰好落在漂移点上,症状因此看似随设置变化出现/消失。这同时解释了此前所有观察:冷/热启动转储完全一致(分页自身是一致的,错配发生在分页 vs 显示);重启后"第 N 页"不复现(边界挪走了)。
|
||||||
|
|
||||||
|
### 证据(宝山书 spine 3,字号 15 / 行距 1.6 / 宽 398pt)
|
||||||
|
|
||||||
|
`--demo-pagination-validate` 基线运行(无任何设置操作)即命中,前 8 页中 5 页分叉。决定性样本 page 8/55 第 11 行(行首 offset 4224):
|
||||||
|
|
||||||
|
- 显示端断点 4252(行尾"…可以相"——孤字机制当场复现),全章上下文断点 4253("…可以相当")
|
||||||
|
- 逐字符属性 diff:**完全一致**(无 ATTR-DIFF)
|
||||||
|
- 对照排版:原始子串 = 4252,段落级上下文 = 4252,**只有全章上下文 = 4253**
|
||||||
|
- page 6/55 的上下文探针行宽 408.39 > 框宽 398 —— 标点悬挂/压缩越界的直接证据
|
||||||
|
|
||||||
|
即断点差异既不是首行缩进归一化(`normalizedPageContent` 的 firstLineHeadIndent 处理本身是正确的),也不是属性差异,而是 CoreText 断行对字符串上下文(跨段落!)敏感。
|
||||||
|
|
||||||
|
### 修复(方案 A,2026-07-07 已实施)
|
||||||
|
|
||||||
|
显示端改为**全章上下文排版**:`RDEPUBTextContentView.configure` 取 `page.chapterContent` 的整章副本作为显示内容,`DTCoreTextLayouter(章节副本).layoutFrame(bounds, range: page.contentRange)` 只排当页范围。断行与分页器按构造一致(同串、同起点、同宽)。
|
||||||
|
|
||||||
|
配套改动:
|
||||||
|
|
||||||
|
- layoutFrame 的 stringRange 因此为**章节绝对坐标**。`RDEPUBPageLayoutSnapshot.build` 去掉 +pageStartOffset 归一(本就输出绝对坐标给消费方),`RDEPUBPageInteractionController` 去掉全部 -pageOffset 反换算;选区/高亮/点击测试/装饰层的消费接口本来就是绝对坐标,无需变动。
|
||||||
|
- 主题前景色、暗色图片调整(`RDEPUBDarkImageAdjuster` 新增 `in range:` 参数)、高亮标记只施加在章节副本的当页范围上;`applyHighlightsToContent` 改用绝对 overlap 范围。
|
||||||
|
- 删除 DTCoreText 路径对 `normalizedPageContent` 的调用——续段首行缩进/段前距归一化 hack 由上下文排版天然取代(非 DTCoreText 回退路径仍保留)。
|
||||||
|
- layouter(framesetter)随显示内容缓存于 cell(`coreTextLayouter`),bounds 变化只重建 layoutFrame,避免每次 layout pass 对整章重建 framesetter。
|
||||||
|
- `RDEPUBTextPageBoundaryValidator` 适配绝对坐标,保留为回归警报。
|
||||||
|
|
||||||
|
验证:`--demo-pagination-validate` 基线(原先 spine 3 前 8 页 5 处分叉)修复后 **0 命中**;第 10 页首行断点与分页一致(「…使用了更多」);Selection/Annotation UI 测试回归通过(见下)。
|
||||||
|
|
||||||
|
### 遗留(独立的潜在缺陷,本次未触发)
|
||||||
|
|
||||||
|
- `RDEPUBChapterRuntimeStore.chapterDataCache` 仅按 spineIndex 键控;设置变更时飞行中的旧构建完成后仍会插回(`RDEPUBChapterLoader.loadChapter` 的 `insertChapter` 无代际校验)。
|
||||||
|
- `RDEPUBChapterLoader.pendingLoads` 按 spineIndex 合流,跨设置变更合流会把旧样式章节交给新请求。
|
||||||
|
- 建议修复问题二后用 `--demo-settings-flip` + `--demo-pagination-validate` 回归验证这两个窗口。
|
||||||
@@ -37,6 +37,8 @@
|
|||||||
| [CHAPTER_RUNTIME.md](CHAPTER_RUNTIME.md) | 章节运行时详解:按需加载、章节窗口协调、页图管理、磁盘缓存、后台补全 |
|
| [CHAPTER_RUNTIME.md](CHAPTER_RUNTIME.md) | 章节运行时详解:按需加载、章节窗口协调、页图管理、磁盘缓存、后台补全 |
|
||||||
| [CFI_SUBSYSTEM.md](CFI_SUBSYSTEM.md) | CFI 子系统详解:EPUB CFI 解析、生成、序列化、范围、恢复引擎 |
|
| [CFI_SUBSYSTEM.md](CFI_SUBSYSTEM.md) | CFI 子系统详解:EPUB CFI 解析、生成、序列化、范围、恢复引擎 |
|
||||||
| [CFI_ISSUES_REVIEW.md](CFI_ISSUES_REVIEW.md) | CFI 实现问题分析报告:11 个问题(3 高 / 5 中 / 3 低),含修复优先级建议 |
|
| [CFI_ISSUES_REVIEW.md](CFI_ISSUES_REVIEW.md) | CFI 实现问题分析报告:11 个问题(3 高 / 5 中 / 3 低),含修复优先级建议 |
|
||||||
|
| [PAGINATION_ISSUES_2026-07-06.md](PAGINATION_ISSUES_2026-07-06.md) | 分页问题调查记录:脚注图标 avoid-trim 裁行(已修复)+页范围/显示度量错配行中断页(待复现),含调试命令备忘 |
|
||||||
|
| [LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md](LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md) | 长章节内存优化低风险实施方案:在保留整章上下文布局正确性的前提下,分阶段收敛整章副本、共享 layouter、治理缓存与图片内存 |
|
||||||
| [API_REFERENCE.md](API_REFERENCE.md) | 公开 API 参考:RDEPUBReaderController 公开接口、委托协议、配置模型 |
|
| [API_REFERENCE.md](API_REFERENCE.md) | 公开 API 参考:RDEPUBReaderController 公开接口、委托协议、配置模型 |
|
||||||
| [当前阅读器问题修复开发清单.md](当前阅读器问题修复开发清单.md) | 基于当前代码实现整理的逐任务开发计划:10 个任务、修改位置、实施步骤、风险点与验收标准 |
|
| [当前阅读器问题修复开发清单.md](当前阅读器问题修复开发清单.md) | 基于当前代码实现整理的逐任务开发计划:10 个任务、修改位置、实施步骤、风险点与验收标准 |
|
||||||
| [大书远距目录跳转与后台补全优化方案.md](大书远距目录跳转与后台补全优化方案.md) | 面向大书按需分页模式的完整优化方案:远距目录跳转、后台补全优先级、页图接管协议与分阶段实施路径 |
|
| [大书远距目录跳转与后台补全优化方案.md](大书远距目录跳转与后台补全优化方案.md) | 面向大书按需分页模式的完整优化方案:远距目录跳转、后台补全优先级、页图接管协议与分阶段实施路径 |
|
||||||
|
|||||||
+1541
-1517
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@
|
|||||||
1A2B3C4D00000015AABBCC01 /* AsyncChapterLoadingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000016AABBCC01 /* AsyncChapterLoadingTests.swift */; };
|
1A2B3C4D00000015AABBCC01 /* AsyncChapterLoadingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000016AABBCC01 /* AsyncChapterLoadingTests.swift */; };
|
||||||
23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */; };
|
23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */; };
|
||||||
369C9658D870DCAFC17EB7F7 /* SearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */; };
|
369C9658D870DCAFC17EB7F7 /* SearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */; };
|
||||||
|
1A2B3C4D00000021AABBCC01 /* MemoryFootprintTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000020AABBCC01 /* MemoryFootprintTests.swift */; };
|
||||||
3BC5C96D7A0ACF35F2192CC7 /* XCUIApplication+Launch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */; };
|
3BC5C96D7A0ACF35F2192CC7 /* XCUIApplication+Launch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */; };
|
||||||
4509ED928F228F43888E063D /* ReaderToolbarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */; };
|
4509ED928F228F43888E063D /* ReaderToolbarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */; };
|
||||||
52A6A0C9941A4B5F9B88C6B0 /* ReaderAnnotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */; };
|
52A6A0C9941A4B5F9B88C6B0 /* ReaderAnnotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */; };
|
||||||
@@ -64,6 +65,7 @@
|
|||||||
9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderOpenCloseTests.swift; sourceTree = "<group>"; };
|
9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderOpenCloseTests.swift; sourceTree = "<group>"; };
|
||||||
BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderToolbarTests.swift; sourceTree = "<group>"; };
|
BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderToolbarTests.swift; sourceTree = "<group>"; };
|
||||||
CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchTests.swift; sourceTree = "<group>"; };
|
CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchTests.swift; sourceTree = "<group>"; };
|
||||||
|
1A2B3C4D00000020AABBCC01 /* MemoryFootprintTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MemoryFootprintTests.swift; sourceTree = "<group>"; };
|
||||||
DE070C1D2FBF0CC900ED065F /* ReadViewDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ReadViewDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
DE070C1D2FBF0CC900ED065F /* ReadViewDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ReadViewDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DisplayTypeTests.swift; sourceTree = "<group>"; };
|
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DisplayTypeTests.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
@@ -192,6 +194,7 @@
|
|||||||
1A2B3C4D00000010AABBCC01 /* MetadataParseBenchmarkTests.swift */,
|
1A2B3C4D00000010AABBCC01 /* MetadataParseBenchmarkTests.swift */,
|
||||||
1A2B3C4D00000013AABBCC01 /* FanrenParseTimeTest.swift */,
|
1A2B3C4D00000013AABBCC01 /* FanrenParseTimeTest.swift */,
|
||||||
CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */,
|
CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */,
|
||||||
|
1A2B3C4D00000020AABBCC01 /* MemoryFootprintTests.swift */,
|
||||||
1A2B3C4D00000016AABBCC01 /* AsyncChapterLoadingTests.swift */,
|
1A2B3C4D00000016AABBCC01 /* AsyncChapterLoadingTests.swift */,
|
||||||
);
|
);
|
||||||
path = ReaderUITests;
|
path = ReaderUITests;
|
||||||
@@ -357,6 +360,7 @@
|
|||||||
1A2B3C4D0000000FAABBCC01 /* MetadataParseBenchmarkTests.swift in Sources */,
|
1A2B3C4D0000000FAABBCC01 /* MetadataParseBenchmarkTests.swift in Sources */,
|
||||||
1A2B3C4D00000014AABBCC01 /* FanrenParseTimeTest.swift in Sources */,
|
1A2B3C4D00000014AABBCC01 /* FanrenParseTimeTest.swift in Sources */,
|
||||||
369C9658D870DCAFC17EB7F7 /* SearchTests.swift in Sources */,
|
369C9658D870DCAFC17EB7F7 /* SearchTests.swift in Sources */,
|
||||||
|
1A2B3C4D00000021AABBCC01 /* MemoryFootprintTests.swift in Sources */,
|
||||||
1A2B3C4D00000015AABBCC01 /* AsyncChapterLoadingTests.swift in Sources */,
|
1A2B3C4D00000015AABBCC01 /* AsyncChapterLoadingTests.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ struct DemoReaderState {
|
|||||||
var lastExternalURL: String? { decodedField("lastExternalURL") }
|
var lastExternalURL: String? { decodedField("lastExternalURL") }
|
||||||
var lastError: String? { decodedField("lastError") }
|
var lastError: String? { decodedField("lastError") }
|
||||||
var searchMatchText: String? { decodedField("searchMatchText") }
|
var searchMatchText: String? { decodedField("searchMatchText") }
|
||||||
|
var footprintMB: Double? { fields["footprintMB"].flatMap(Double.init) }
|
||||||
}
|
}
|
||||||
|
|
||||||
extension XCUIApplication {
|
extension XCUIApplication {
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// Quantitative memory-footprint harness for the long-chapter optimization
|
||||||
|
/// (Doc/LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md, P0-2 / P1-4 / P2). Reads
|
||||||
|
/// phys_footprint back through the demo state `footprintMB` field, so these
|
||||||
|
/// tests double as a decision tool: the recorded numbers are attached to the
|
||||||
|
/// result, and the assertions are deliberately loose upper bounds that only
|
||||||
|
/// catch gross regressions (e.g. each visible page pinning a full-chapter
|
||||||
|
/// copy), not few-MB noise.
|
||||||
|
final class MemoryFootprintTests: XCTestCase {
|
||||||
|
private let app = XCUIApplication()
|
||||||
|
private let largeBookQuery = "凡人修仙传"
|
||||||
|
|
||||||
|
/// Turning 20 pages must not grow footprint beyond this over the post-open
|
||||||
|
/// baseline. Sized to flag "per-page full-chapter copy" class leaks while
|
||||||
|
/// tolerating cache warmup and simulator noise.
|
||||||
|
private let pageTurnGrowthBudgetMB = 180.0
|
||||||
|
|
||||||
|
/// A settings change rebuilds display objects; peak may spike transiently
|
||||||
|
/// but must settle back within this of the pre-change reading.
|
||||||
|
private let settingsChangeGrowthBudgetMB = 220.0
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
continueAfterFailure = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFootprintStableAcrossTwentyPageTurns() throws {
|
||||||
|
app.launchAndOpenSampleBook(
|
||||||
|
bookTitleQuery: largeBookQuery,
|
||||||
|
displayType: "scroll",
|
||||||
|
resetsReaderState: true
|
||||||
|
)
|
||||||
|
app.waitForReader(timeout: 20)
|
||||||
|
app.waitForDemoReaderState(timeout: 8, description: "display=horizontalScroll") {
|
||||||
|
$0.display == "horizontalScroll"
|
||||||
|
}
|
||||||
|
|
||||||
|
let baseline = try requireFootprint(description: "打开长书后基线")
|
||||||
|
let paging = app.collectionViews[IDs.readerPaging]
|
||||||
|
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页滚动视图未出现")
|
||||||
|
|
||||||
|
var readings: [Double] = [baseline]
|
||||||
|
var lastPage = app.currentDemoReaderState()?.page ?? 0
|
||||||
|
var turnsMade = 0
|
||||||
|
|
||||||
|
for _ in 0..<20 {
|
||||||
|
paging.swipeLeft()
|
||||||
|
let state = app.waitForDemoReaderState(timeout: 8, description: "翻页后页码变化") { state in
|
||||||
|
guard let page = state.page else { return false }
|
||||||
|
return page != lastPage
|
||||||
|
}
|
||||||
|
guard let page = state.page, page != lastPage else { break }
|
||||||
|
lastPage = page
|
||||||
|
turnsMade += 1
|
||||||
|
if let footprint = state.footprintMB {
|
||||||
|
readings.append(footprint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let peak = readings.max() ?? baseline
|
||||||
|
let final = readings.last ?? baseline
|
||||||
|
let growth = final - baseline
|
||||||
|
attachReport(
|
||||||
|
name: "page-turns",
|
||||||
|
lines: [
|
||||||
|
"turnsMade=\(turnsMade)",
|
||||||
|
"baselineMB=\(fmt(baseline))",
|
||||||
|
"peakMB=\(fmt(peak))",
|
||||||
|
"finalMB=\(fmt(final))",
|
||||||
|
"growthMB=\(fmt(growth))",
|
||||||
|
"budgetMB=\(fmt(pageTurnGrowthBudgetMB))",
|
||||||
|
"series=\(readings.map(fmt).joined(separator: ","))"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertGreaterThan(turnsMade, 0, "未能翻动任何页,无法测量翻页内存")
|
||||||
|
XCTAssertLessThan(
|
||||||
|
growth, pageTurnGrowthBudgetMB,
|
||||||
|
"连续翻页后常驻内存增长 \(fmt(growth))MB 超出预算 \(fmt(pageTurnGrowthBudgetMB))MB(疑似每页驻留整章副本)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFootprintSettlesAfterFontSizeChange() throws {
|
||||||
|
app.launchAndOpenSampleBook(
|
||||||
|
bookTitleQuery: largeBookQuery,
|
||||||
|
resetsReaderState: true
|
||||||
|
)
|
||||||
|
app.waitForReader(timeout: 20)
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
|
||||||
|
let before = try requireFootprint(description: "改字号前")
|
||||||
|
|
||||||
|
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 5), "设置按钮不存在")
|
||||||
|
app.buttons[IDs.readerSettings].tap()
|
||||||
|
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5), "设置面板未出现")
|
||||||
|
XCTAssertTrue(app.buttons[IDs.settingsFontIncrease].waitForExistence(timeout: 3), "字号增大按钮不存在")
|
||||||
|
app.buttons[IDs.settingsFontIncrease].tap()
|
||||||
|
app.buttons[IDs.settingsFontIncrease].tap()
|
||||||
|
XCTAssertTrue(app.buttons[IDs.settingsDone].waitForExistence(timeout: 3), "完成按钮不存在")
|
||||||
|
app.buttons[IDs.settingsDone].tap()
|
||||||
|
|
||||||
|
// Allow repagination + display-cache rebuild to settle.
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(2.5))
|
||||||
|
let after = try requireFootprint(description: "改字号并重排后")
|
||||||
|
|
||||||
|
let growth = after - before
|
||||||
|
attachReport(
|
||||||
|
name: "font-size-change",
|
||||||
|
lines: [
|
||||||
|
"beforeMB=\(fmt(before))",
|
||||||
|
"afterMB=\(fmt(after))",
|
||||||
|
"growthMB=\(fmt(growth))",
|
||||||
|
"budgetMB=\(fmt(settingsChangeGrowthBudgetMB))"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertLessThan(
|
||||||
|
growth, settingsChangeGrowthBudgetMB,
|
||||||
|
"改字号重排后常驻内存增长 \(fmt(growth))MB 超出预算 \(fmt(settingsChangeGrowthBudgetMB))MB(疑似旧显示对象未释放)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func requireFootprint(description: String) throws -> Double {
|
||||||
|
let state = app.waitForDemoReaderState(timeout: 8, description: description) {
|
||||||
|
($0.footprintMB ?? 0) > 0
|
||||||
|
}
|
||||||
|
guard let footprint = state.footprintMB, footprint > 0 else {
|
||||||
|
throw XCTSkip("demo 状态未暴露 footprintMB,跳过内存测量:\(state.rawValue)")
|
||||||
|
}
|
||||||
|
return footprint
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fmt(_ value: Double) -> String {
|
||||||
|
String(format: "%.1f", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func attachReport(name: String, lines: [String]) {
|
||||||
|
let text = lines.joined(separator: "\n")
|
||||||
|
let attachment = XCTAttachment(string: text)
|
||||||
|
attachment.name = "memory-\(name)"
|
||||||
|
attachment.lifetime = .keepAlways
|
||||||
|
add(attachment)
|
||||||
|
print("[MemoryFootprintTests] \(name)\n\(text)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -209,7 +209,9 @@ final class SearchTests: XCTestCase {
|
|||||||
XCTAssertFalse(searchBar.waitForExistence(timeout: 2), "关闭后搜索栏应消失")
|
XCTAssertFalse(searchBar.waitForExistence(timeout: 2), "关闭后搜索栏应消失")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 搜索栏与工具栏联动
|
// MARK: - 搜索面板与工具栏联动(全屏面板模型)
|
||||||
|
// 全屏搜索面板覆盖内容区,面板打开时无法点击内容切换 chrome;
|
||||||
|
// 选中结果后面板关闭(搜索状态保留),chrome 隐藏/恢复联动面板。
|
||||||
|
|
||||||
func testSearchBarHidesWhenToolbarsHide() {
|
func testSearchBarHidesWhenToolbarsHide() {
|
||||||
app.launchAndOpenSampleBook(searchKeyword: "的")
|
app.launchAndOpenSampleBook(searchKeyword: "的")
|
||||||
@@ -217,12 +219,15 @@ final class SearchTests: XCTestCase {
|
|||||||
app.showReaderChromeIfNeeded()
|
app.showReaderChromeIfNeeded()
|
||||||
|
|
||||||
let searchBar = app.otherElements[IDs.searchBar]
|
let searchBar = app.otherElements[IDs.searchBar]
|
||||||
XCTAssertTrue(searchBar.waitForExistence(timeout: 10), "搜索栏应可见")
|
XCTAssertTrue(searchBar.waitForExistence(timeout: 10), "搜索面板应可见")
|
||||||
|
|
||||||
// 隐藏工具栏
|
// 选中结果后面板关闭并跳转,搜索状态保留
|
||||||
|
selectSearchResult(at: 0)
|
||||||
|
XCTAssertFalse(searchBar.waitForExistence(timeout: 2), "选中结果后搜索面板应关闭")
|
||||||
|
|
||||||
|
// 隐藏工具栏,面板应保持关闭
|
||||||
app.hideReaderChromeIfNeeded()
|
app.hideReaderChromeIfNeeded()
|
||||||
|
XCTAssertFalse(searchBar.waitForExistence(timeout: 2), "工具栏隐藏时搜索面板应保持关闭")
|
||||||
XCTAssertFalse(searchBar.waitForExistence(timeout: 2), "工具栏隐藏时搜索栏也应隐藏")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSearchBarRestoresWhenToolbarsShow() {
|
func testSearchBarRestoresWhenToolbarsShow() {
|
||||||
@@ -233,13 +238,14 @@ final class SearchTests: XCTestCase {
|
|||||||
let searchBar = app.otherElements[IDs.searchBar]
|
let searchBar = app.otherElements[IDs.searchBar]
|
||||||
XCTAssertTrue(searchBar.waitForExistence(timeout: 10))
|
XCTAssertTrue(searchBar.waitForExistence(timeout: 10))
|
||||||
|
|
||||||
// 隐藏工具栏
|
// 选中结果关闭面板,再隐藏工具栏
|
||||||
|
selectSearchResult(at: 0)
|
||||||
app.hideReaderChromeIfNeeded()
|
app.hideReaderChromeIfNeeded()
|
||||||
XCTAssertFalse(searchBar.waitForExistence(timeout: 2))
|
XCTAssertFalse(searchBar.waitForExistence(timeout: 2))
|
||||||
|
|
||||||
// 重新显示工具栏
|
// 重新显示工具栏:搜索状态存在时面板应自动恢复
|
||||||
app.showReaderChromeIfNeeded()
|
app.showReaderChromeIfNeeded()
|
||||||
XCTAssertTrue(searchBar.waitForExistence(timeout: 3), "工具栏恢复时搜索栏也应恢复")
|
XCTAssertTrue(searchBar.waitForExistence(timeout: 3), "工具栏恢复时搜索面板也应恢复")
|
||||||
|
|
||||||
// 搜索状态应保留
|
// 搜索状态应保留
|
||||||
let countLabel = app.staticTexts[IDs.searchCount]
|
let countLabel = app.staticTexts[IDs.searchCount]
|
||||||
@@ -286,6 +292,9 @@ final class SearchTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
let rangeCFIBefore = stateBefore.rangeCFI
|
let rangeCFIBefore = stateBefore.rangeCFI
|
||||||
|
|
||||||
|
// 全屏面板挡住工具栏,先选中当前匹配关闭面板(保留搜索状态)
|
||||||
|
selectSearchResult(at: 0)
|
||||||
|
|
||||||
// 改变字体触发重排
|
// 改变字体触发重排
|
||||||
app.showReaderChromeIfNeeded()
|
app.showReaderChromeIfNeeded()
|
||||||
let settingsButton = app.buttons[IDs.readerSettings]
|
let settingsButton = app.buttons[IDs.readerSettings]
|
||||||
@@ -304,8 +313,8 @@ final class SearchTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
Thread.sleep(forTimeInterval: 1)
|
Thread.sleep(forTimeInterval: 1)
|
||||||
|
|
||||||
// 重新打开搜索并导航到匹配
|
// 重新打开搜索面板(搜索状态保留),前后导航强制重解析当前命中
|
||||||
app.showReaderChromeIfNeeded()
|
reopenSearchPanel()
|
||||||
let prevButton = app.buttons[IDs.searchPrevious]
|
let prevButton = app.buttons[IDs.searchPrevious]
|
||||||
let nextButton = app.buttons[IDs.searchNext]
|
let nextButton = app.buttons[IDs.searchNext]
|
||||||
if prevButton.waitForExistence(timeout: 3), prevButton.isEnabled {
|
if prevButton.waitForExistence(timeout: 3), prevButton.isEnabled {
|
||||||
@@ -349,6 +358,9 @@ final class SearchTests: XCTestCase {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 全屏面板挡住工具栏,先选中当前匹配关闭面板(保留搜索状态)
|
||||||
|
selectSearchResult(at: 0)
|
||||||
|
|
||||||
// 改变字体大小触发重新排版
|
// 改变字体大小触发重新排版
|
||||||
app.showReaderChromeIfNeeded()
|
app.showReaderChromeIfNeeded()
|
||||||
let settingsButton = app.buttons[IDs.readerSettings]
|
let settingsButton = app.buttons[IDs.readerSettings]
|
||||||
@@ -367,15 +379,9 @@ final class SearchTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
Thread.sleep(forTimeInterval: 1)
|
Thread.sleep(forTimeInterval: 1)
|
||||||
|
|
||||||
// 测试多个匹配位置的跳转正确性
|
// 跳到第 2 个匹配,记录页码
|
||||||
let nextButton = app.buttons[IDs.searchNext]
|
reopenSearchPanel()
|
||||||
let prevButton = app.buttons[IDs.searchPrevious]
|
selectSearchResult(at: 1)
|
||||||
XCTAssertTrue(nextButton.waitForExistence(timeout: 3), "下一个按钮应存在")
|
|
||||||
XCTAssertTrue(prevButton.waitForExistence(timeout: 3), "上一个按钮应存在")
|
|
||||||
|
|
||||||
// 导航到第 2 个匹配,记录页码
|
|
||||||
nextButton.tap()
|
|
||||||
Thread.sleep(forTimeInterval: 0.5)
|
|
||||||
let stateMatch2 = app.waitForDemoReaderState(timeout: 5, description: "match 2 page") {
|
let stateMatch2 = app.waitForDemoReaderState(timeout: 5, description: "match 2 page") {
|
||||||
($0.page ?? 0) > 0 && $0.rangeCFI?.isEmpty == false
|
($0.page ?? 0) > 0 && $0.rangeCFI?.isEmpty == false
|
||||||
}
|
}
|
||||||
@@ -383,18 +389,11 @@ final class SearchTests: XCTestCase {
|
|||||||
let rangeCFIAtMatch2 = stateMatch2.rangeCFI
|
let rangeCFIAtMatch2 = stateMatch2.rangeCFI
|
||||||
|
|
||||||
// 翻到其他页
|
// 翻到其他页
|
||||||
let content = app.otherElements[IDs.readerContent].firstMatch
|
swipeAwayFromCurrentPage()
|
||||||
if content.waitForExistence(timeout: 3) {
|
|
||||||
for _ in 0..<4 {
|
|
||||||
content.swipeLeft()
|
|
||||||
Thread.sleep(forTimeInterval: 0.3)
|
|
||||||
}
|
|
||||||
Thread.sleep(forTimeInterval: 0.5)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 跳转到第 3 个匹配
|
// 跳到第 3 个匹配
|
||||||
nextButton.tap()
|
reopenSearchPanel()
|
||||||
Thread.sleep(forTimeInterval: 0.5)
|
selectSearchResult(at: 2)
|
||||||
let stateMatch3 = app.waitForDemoReaderState(timeout: 5, description: "match 3 page") {
|
let stateMatch3 = app.waitForDemoReaderState(timeout: 5, description: "match 3 page") {
|
||||||
($0.page ?? 0) > 0 && $0.rangeCFI?.isEmpty == false
|
($0.page ?? 0) > 0 && $0.rangeCFI?.isEmpty == false
|
||||||
}
|
}
|
||||||
@@ -402,17 +401,11 @@ final class SearchTests: XCTestCase {
|
|||||||
let rangeCFIAtMatch3 = stateMatch3.rangeCFI
|
let rangeCFIAtMatch3 = stateMatch3.rangeCFI
|
||||||
|
|
||||||
// 再翻到其他页
|
// 再翻到其他页
|
||||||
if content.waitForExistence(timeout: 3) {
|
swipeAwayFromCurrentPage()
|
||||||
for _ in 0..<4 {
|
|
||||||
content.swipeLeft()
|
|
||||||
Thread.sleep(forTimeInterval: 0.3)
|
|
||||||
}
|
|
||||||
Thread.sleep(forTimeInterval: 0.5)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 跳回第 2 个匹配,验证页码不变
|
// 跳回第 2 个匹配,验证页码不变
|
||||||
prevButton.tap()
|
reopenSearchPanel()
|
||||||
Thread.sleep(forTimeInterval: 0.5)
|
selectSearchResult(at: 1)
|
||||||
let stateBack2 = app.waitForDemoReaderState(timeout: 5, description: "back to match 2") {
|
let stateBack2 = app.waitForDemoReaderState(timeout: 5, description: "back to match 2") {
|
||||||
$0.page == pageAtMatch2 && $0.rangeCFI == rangeCFIAtMatch2
|
$0.page == pageAtMatch2 && $0.rangeCFI == rangeCFIAtMatch2
|
||||||
}
|
}
|
||||||
@@ -422,17 +415,11 @@ final class SearchTests: XCTestCase {
|
|||||||
"重排后搜索跳回第 2 个匹配应命中同一段文本")
|
"重排后搜索跳回第 2 个匹配应命中同一段文本")
|
||||||
|
|
||||||
// 再翻到其他页
|
// 再翻到其他页
|
||||||
if content.waitForExistence(timeout: 3) {
|
swipeAwayFromCurrentPage()
|
||||||
for _ in 0..<4 {
|
|
||||||
content.swipeLeft()
|
|
||||||
Thread.sleep(forTimeInterval: 0.3)
|
|
||||||
}
|
|
||||||
Thread.sleep(forTimeInterval: 0.5)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 跳到第 3 个匹配,验证页码不变
|
// 跳到第 3 个匹配,验证页码不变
|
||||||
nextButton.tap()
|
reopenSearchPanel()
|
||||||
Thread.sleep(forTimeInterval: 0.5)
|
selectSearchResult(at: 2)
|
||||||
let stateBack3 = app.waitForDemoReaderState(timeout: 5, description: "back to match 3") {
|
let stateBack3 = app.waitForDemoReaderState(timeout: 5, description: "back to match 3") {
|
||||||
$0.page == pageAtMatch3 && $0.rangeCFI == rangeCFIAtMatch3
|
$0.page == pageAtMatch3 && $0.rangeCFI == rangeCFIAtMatch3
|
||||||
}
|
}
|
||||||
@@ -452,4 +439,39 @@ final class SearchTests: XCTestCase {
|
|||||||
let searchBar = app.otherElements[IDs.searchBar]
|
let searchBar = app.otherElements[IDs.searchBar]
|
||||||
XCTAssertTrue(searchBar.waitForExistence(timeout: 3), "搜索栏应出现")
|
XCTAssertTrue(searchBar.waitForExistence(timeout: 3), "搜索栏应出现")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 点击全屏搜索面板中的结果行:跳转到对应匹配并关闭面板(搜索状态保留)
|
||||||
|
private func selectSearchResult(at matchIndex: Int) {
|
||||||
|
let cell = app.cells["epub.reader.search.result.\(matchIndex)"].firstMatch
|
||||||
|
XCTAssertTrue(cell.waitForExistence(timeout: 10), "搜索结果第 \(matchIndex) 项应存在")
|
||||||
|
cell.tap()
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重新打开搜索面板。chrome 恢复时若有搜索状态面板会自动恢复;
|
||||||
|
/// chrome 本就可见时需点击工具栏搜索按钮。
|
||||||
|
private func reopenSearchPanel() {
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
let searchBar = app.otherElements[IDs.searchBar]
|
||||||
|
if searchBar.waitForExistence(timeout: 2) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let searchButton = app.buttons[IDs.readerSearch]
|
||||||
|
XCTAssertTrue(searchButton.waitForExistence(timeout: 3), "搜索按钮应存在")
|
||||||
|
searchButton.tap()
|
||||||
|
XCTAssertTrue(searchBar.waitForExistence(timeout: 3), "搜索面板应重新打开")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 隐藏 chrome 后向后翻若干页,使当前页离开搜索命中页
|
||||||
|
private func swipeAwayFromCurrentPage() {
|
||||||
|
app.hideReaderChromeIfNeeded()
|
||||||
|
let content = app.otherElements[IDs.readerContent].firstMatch
|
||||||
|
if content.waitForExistence(timeout: 3) {
|
||||||
|
for _ in 0..<4 {
|
||||||
|
content.swipeLeft()
|
||||||
|
Thread.sleep(forTimeInterval: 0.3)
|
||||||
|
}
|
||||||
|
Thread.sleep(forTimeInterval: 0.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ public struct RDEPUBNavigatorLayoutContext: Equatable {
|
|||||||
pagesPerScreen: Int = 1,
|
pagesPerScreen: Int = 1,
|
||||||
safeAreaInsets: UIEdgeInsets = .zero,
|
safeAreaInsets: UIEdgeInsets = .zero,
|
||||||
userInterfaceIdiom: UIUserInterfaceIdiom = .phone,
|
userInterfaceIdiom: UIUserInterfaceIdiom = .phone,
|
||||||
reflowableContentInsets: UIEdgeInsets = UIEdgeInsets(top: 40, left: 16, bottom: 40, right: 16)
|
reflowableContentInsets: UIEdgeInsets = RDEPUBSafeArea.defaultReflowableContentInsets()
|
||||||
) {
|
) {
|
||||||
self.containerSize = containerSize
|
self.containerSize = containerSize
|
||||||
self.pagesPerScreen = max(1, pagesPerScreen)
|
self.pagesPerScreen = max(1, pagesPerScreen)
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// Device-level safe area lookup modeled after GKNavigationBarSwift.
|
||||||
|
/// Reads the real safe area from the key window so correct values are
|
||||||
|
/// available even before a view has been laid out in the hierarchy.
|
||||||
|
/// Must be called on the main thread.
|
||||||
|
public enum RDEPUBSafeArea {
|
||||||
|
|
||||||
|
/// Minimum text margins applied when the device safe area on an edge is
|
||||||
|
/// smaller (e.g. no notch / no home indicator). These are aesthetic
|
||||||
|
/// paddings, not approximations of the safe area itself.
|
||||||
|
public static let minimumVerticalTextMargin: CGFloat = 20
|
||||||
|
|
||||||
|
public static let minimumHorizontalTextMargin: CGFloat = 16
|
||||||
|
|
||||||
|
public static func keyWindow() -> UIWindow? {
|
||||||
|
let scenes = UIApplication.shared.connectedScenes
|
||||||
|
.compactMap { $0 as? UIWindowScene }
|
||||||
|
if let window = scenes
|
||||||
|
.filter({ $0.activationState == .foregroundActive })
|
||||||
|
.flatMap({ $0.windows })
|
||||||
|
.first(where: { $0.isKeyWindow }) {
|
||||||
|
return window
|
||||||
|
}
|
||||||
|
if let window = scenes
|
||||||
|
.flatMap({ $0.windows })
|
||||||
|
.first(where: { $0.isKeyWindow }) {
|
||||||
|
return window
|
||||||
|
}
|
||||||
|
return UIApplication.shared.delegate?.window ?? nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func insets() -> UIEdgeInsets {
|
||||||
|
if let window = keyWindow() {
|
||||||
|
return window.safeAreaInsets
|
||||||
|
}
|
||||||
|
// No key window yet (early launch): create a detached window to read
|
||||||
|
// the device safe area, same fallback as GKNavigationBarSwift.
|
||||||
|
let window = UIWindow(frame: UIScreen.main.bounds)
|
||||||
|
if window.safeAreaInsets.bottom <= 0 {
|
||||||
|
window.rootViewController = UIViewController()
|
||||||
|
}
|
||||||
|
return window.safeAreaInsets
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prefers insets measured from a view already installed in the hierarchy;
|
||||||
|
/// falls back to the key-window insets when the view is not laid out yet
|
||||||
|
/// and reports .zero.
|
||||||
|
public static func resolve(_ measuredInsets: UIEdgeInsets?) -> UIEdgeInsets {
|
||||||
|
if let measuredInsets, measuredInsets != .zero {
|
||||||
|
return measuredInsets
|
||||||
|
}
|
||||||
|
return insets()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default reflowable content insets derived from the live device safe
|
||||||
|
/// area instead of hard-coded heights.
|
||||||
|
public static func defaultReflowableContentInsets() -> UIEdgeInsets {
|
||||||
|
let safe = insets()
|
||||||
|
return UIEdgeInsets(
|
||||||
|
top: max(safe.top, minimumVerticalTextMargin),
|
||||||
|
left: max(safe.left, minimumHorizontalTextMargin),
|
||||||
|
bottom: max(safe.bottom, minimumVerticalTextMargin),
|
||||||
|
right: max(safe.right, minimumHorizontalTextMargin)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -291,7 +291,6 @@ public final class RDEPUBTextBookBuilder {
|
|||||||
pageIndexInChapter: localPageIndex,
|
pageIndexInChapter: localPageIndex,
|
||||||
totalPagesInChapter: effectiveFrames.count,
|
totalPagesInChapter: effectiveFrames.count,
|
||||||
chapterContent: chapterAttributedContent,
|
chapterContent: chapterAttributedContent,
|
||||||
content: content.attributedSubstring(from: range),
|
|
||||||
contentRange: range,
|
contentRange: range,
|
||||||
pageStartOffset: range.location,
|
pageStartOffset: range.location,
|
||||||
pageEndOffset: range.location + max(range.length - 1, 0),
|
pageEndOffset: range.location + max(range.length - 1, 0),
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ public final class RDEPUBTextBookCache {
|
|||||||
let fileURL = cacheDirectory.appendingPathComponent(key)
|
let fileURL = cacheDirectory.appendingPathComponent(key)
|
||||||
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("[Cache] load MISS key=\(key)")
|
// print("[Cache] load MISS key=\(key)")
|
||||||
#endif
|
#endif
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -148,7 +148,7 @@ public final class RDEPUBTextBookCache {
|
|||||||
from: data
|
from: data
|
||||||
) else {
|
) else {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("[Cache] load MISS key=\(key) (unarchive returned nil)")
|
// print("[Cache] load MISS key=\(key) (unarchive returned nil)")
|
||||||
#endif
|
#endif
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -157,12 +157,12 @@ public final class RDEPUBTextBookCache {
|
|||||||
result[chapter.href] = chapter.toCache()
|
result[chapter.href] = chapter.toCache()
|
||||||
}
|
}
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("[Cache] load HIT key=\(key) chapters=\(result.count)")
|
// print("[Cache] load HIT key=\(key) chapters=\(result.count)")
|
||||||
#endif
|
#endif
|
||||||
return result
|
return result
|
||||||
} catch {
|
} catch {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("[Cache] load MISS key=\(key) error=\(error)")
|
// print("[Cache] load MISS key=\(key) error=\(error)")
|
||||||
#endif
|
#endif
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,15 @@ public struct RDEPUBTextPage: Equatable {
|
|||||||
|
|
||||||
public var chapterContent: NSAttributedString
|
public var chapterContent: NSAttributedString
|
||||||
|
|
||||||
public var content: NSAttributedString
|
/// Page substring derived on demand from `chapterContent` + `contentRange`.
|
||||||
|
/// Not stored: keeping a per-page substring alive roughly doubles the
|
||||||
|
/// chapter's resident text memory across the chapter cache window.
|
||||||
|
public var content: NSAttributedString {
|
||||||
|
let bounds = NSRange(location: 0, length: chapterContent.length)
|
||||||
|
let clamped = NSIntersectionRange(contentRange, bounds)
|
||||||
|
guard clamped.length > 0 else { return NSAttributedString() }
|
||||||
|
return chapterContent.attributedSubstring(from: clamped)
|
||||||
|
}
|
||||||
|
|
||||||
public var contentRange: NSRange
|
public var contentRange: NSRange
|
||||||
|
|
||||||
|
|||||||
@@ -188,7 +188,12 @@ struct RDEPUBPageBreakPolicy {
|
|||||||
let blockKind = (attributes[.rdPageBlockKind] as? String)
|
let blockKind = (attributes[.rdPageBlockKind] as? String)
|
||||||
.flatMap(RDEPUBTextBlockKind.init(rawValue:))
|
.flatMap(RDEPUBTextBlockKind.init(rawValue:))
|
||||||
|
|
||||||
if blockKind == .attachment, placement != .centered {
|
// Inline attachments (footnote icons, rare-character images) flow with
|
||||||
|
// the surrounding text, so their avoid hint must not lock the line to
|
||||||
|
// the next page. Placement is checked besides blockKind because an
|
||||||
|
// enclosing paragraph's semantics overwrite blockKind on the
|
||||||
|
// attachment's range, while placement survives.
|
||||||
|
if blockKind == .attachment || placement != nil, placement != .centered {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ public final class RDPlainTextBookBuilder {
|
|||||||
pageIndexInChapter: localPageIndex,
|
pageIndexInChapter: localPageIndex,
|
||||||
totalPagesInChapter: effectiveFrames.count,
|
totalPagesInChapter: effectiveFrames.count,
|
||||||
chapterContent: chapterAttributedContent,
|
chapterContent: chapterAttributedContent,
|
||||||
content: content.attributedSubstring(from: range),
|
|
||||||
contentRange: range,
|
contentRange: range,
|
||||||
pageStartOffset: range.location,
|
pageStartOffset: range.location,
|
||||||
pageEndOffset: range.location + max(range.length - 1, 0),
|
pageEndOffset: range.location + max(range.length - 1, 0),
|
||||||
|
|||||||
@@ -136,8 +136,11 @@ struct RDEPUBAttachmentNormalizer {
|
|||||||
private static func isFootnoteAttachment(_ attachment: DTTextAttachment) -> Bool {
|
private static func isFootnoteAttachment(_ attachment: DTTextAttachment) -> Bool {
|
||||||
|
|
||||||
let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased()
|
let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased()
|
||||||
|
if lowercasedClasses.contains("qqreader-footnote") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
let altText = attachment.attributes["alt"] as? String
|
let altText = attachment.attributes["alt"] as? String
|
||||||
return lowercasedClasses.contains("qqreader-footnote") || hasFootnoteAltText(altText)
|
return hasFootnoteAltText(altText) && isFootnoteSizedImage(attachment.originalSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func isCoverAttachment(_ attachment: DTTextAttachment) -> Bool {
|
private static func isCoverAttachment(_ attachment: DTTextAttachment) -> Bool {
|
||||||
@@ -224,10 +227,31 @@ struct RDEPUBAttachmentNormalizer {
|
|||||||
}
|
}
|
||||||
let label = fileAttachment.accessibilityLabel
|
let label = fileAttachment.accessibilityLabel
|
||||||
let lowercasedLabel = (label ?? "").lowercased()
|
let lowercasedLabel = (label ?? "").lowercased()
|
||||||
return lowercasedLabel.contains("qqreader-footnote") || hasFootnoteAltText(label)
|
if lowercasedLabel.contains("qqreader-footnote") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
let imageSize = fileAttachment.image?.size ?? fileAttachment.bounds.size
|
||||||
|
return hasFootnoteAltText(label) && isFootnoteSizedImage(imageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Footnote images without the qqreader-footnote class are recognized by their
|
||||||
|
// alt text carrying the note body. Short alts ("logo", "图1") are ordinary
|
||||||
|
// accessibility descriptions, and note markers are small inline icons, so both
|
||||||
|
// conditions must hold before an image is shrunk to footnote size.
|
||||||
|
private static let minimumFootnoteAltTextLength = 8
|
||||||
|
|
||||||
|
private static let maximumFootnoteImageDimension: CGFloat = 50
|
||||||
|
|
||||||
private static func hasFootnoteAltText(_ text: String?) -> Bool {
|
private static func hasFootnoteAltText(_ text: String?) -> Bool {
|
||||||
text?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
guard let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return trimmed.count >= minimumFootnoteAltTextLength
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isFootnoteSizedImage(_ size: CGSize) -> Bool {
|
||||||
|
guard size.width > 0, size.height > 0 else { return false }
|
||||||
|
return size.width <= maximumFootnoteImageDimension
|
||||||
|
&& size.height <= maximumFootnoteImageDimension
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDRe
|
|||||||
chapterCFIMap: resolvedPage.chapter.chapterOffsetMap.cfiMap,
|
chapterCFIMap: resolvedPage.chapter.chapterOffsetMap.cfiMap,
|
||||||
chapterFragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets,
|
chapterFragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets,
|
||||||
highlights: textHighlights(for: resolvedPage.page),
|
highlights: textHighlights(for: resolvedPage.page),
|
||||||
searchState: searchState(for: resolvedPage.page)
|
searchState: searchState(for: resolvedPage.page),
|
||||||
|
displayCache: textDisplayCache
|
||||||
)
|
)
|
||||||
return contentView
|
return contentView
|
||||||
}
|
}
|
||||||
@@ -108,7 +109,8 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDRe
|
|||||||
chapterCFIMap: textBook.chapterData(for: page.href)?.chapter.cfiMap,
|
chapterCFIMap: textBook.chapterData(for: page.href)?.chapter.cfiMap,
|
||||||
chapterFragmentOffsets: textBook.chapterData(for: page.href)?.chapter.fragmentOffsets ?? [:],
|
chapterFragmentOffsets: textBook.chapterData(for: page.href)?.chapter.fragmentOffsets ?? [:],
|
||||||
highlights: textHighlights(for: page),
|
highlights: textHighlights(for: page),
|
||||||
searchState: searchState(for: page)
|
searchState: searchState(for: page),
|
||||||
|
displayCache: textDisplayCache
|
||||||
)
|
)
|
||||||
return contentView
|
return contentView
|
||||||
}
|
}
|
||||||
@@ -345,6 +347,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func pageNum(readerView: RDReaderView, pageNum: Int) {
|
public func pageNum(readerView: RDReaderView, pageNum: Int) {
|
||||||
|
RDEPUBMemoryProbe.logPageTurn()
|
||||||
readerContext.markUserNavigationActivity()
|
readerContext.markUserNavigationActivity()
|
||||||
updateCurrentSelection(nil)
|
updateCurrentSelection(nil)
|
||||||
reconcileTextPaginationSizeIfNeeded(for: pageNum)
|
reconcileTextPaginationSizeIfNeeded(for: pageNum)
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ extension RDEPUBReaderController {
|
|||||||
).map { $0 + 1 }
|
).map { $0 + 1 }
|
||||||
}
|
}
|
||||||
|
|
||||||
if let textBook, let publication {
|
if let textBook {
|
||||||
let normalizedLocation = publication.resourceResolver.normalizedLocation(
|
let normalizedLocation = publication?.resourceResolver.normalizedLocation(
|
||||||
location,
|
location,
|
||||||
relativeToSpineIndex: nil,
|
relativeToSpineIndex: nil,
|
||||||
bookIdentifier: currentBookIdentifier
|
bookIdentifier: currentBookIdentifier
|
||||||
@@ -42,12 +42,17 @@ extension RDEPUBReaderController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let publication {
|
||||||
return textBook.pageNumber(
|
return textBook.pageNumber(
|
||||||
for: normalizedLocation,
|
for: normalizedLocation,
|
||||||
resolver: publication.resourceResolver,
|
resolver: publication.resourceResolver,
|
||||||
bookIdentifier: currentBookIdentifier
|
bookIdentifier: currentBookIdentifier
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// External text books have no resolver; hrefs match verbatim.
|
||||||
|
return textBook.chapterData(for: normalizedLocation.href)?
|
||||||
|
.pageNumber(for: normalizedLocation)
|
||||||
|
}
|
||||||
|
|
||||||
return readingSession?.queueNavigation(
|
return readingSession?.queueNavigation(
|
||||||
to: location,
|
to: location,
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ public final class RDEPUBReaderController: UIViewController {
|
|||||||
|
|
||||||
let paginationHostView = UIView()
|
let paginationHostView = UIView()
|
||||||
|
|
||||||
|
/// Chapter-level shared display content/layouter for text pages
|
||||||
|
/// (LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md P1-1/P1-3).
|
||||||
|
let textDisplayCache = RDEPUBChapterDisplayContentCache()
|
||||||
|
|
||||||
lazy var readerContext = RDEPUBReaderContext(controller: self)
|
lazy var readerContext = RDEPUBReaderContext(controller: self)
|
||||||
|
|
||||||
var parser: RDEPUBParser? {
|
var parser: RDEPUBParser? {
|
||||||
@@ -281,6 +285,7 @@ public final class RDEPUBReaderController: UIViewController {
|
|||||||
public override func viewDidAppear(_ animated: Bool) {
|
public override func viewDidAppear(_ animated: Bool) {
|
||||||
super.viewDidAppear(animated)
|
super.viewDidAppear(animated)
|
||||||
startInitialLoadIfNeeded()
|
startInitialLoadIfNeeded()
|
||||||
|
RDEPUBSettingsFlipAutomation.startIfNeeded(controller: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
public override func viewDidLayoutSubviews() {
|
public override func viewDidLayoutSubviews() {
|
||||||
@@ -291,11 +296,15 @@ public final class RDEPUBReaderController: UIViewController {
|
|||||||
public override func didReceiveMemoryWarning() {
|
public override func didReceiveMemoryWarning() {
|
||||||
super.didReceiveMemoryWarning()
|
super.didReceiveMemoryWarning()
|
||||||
runtime.handleMemoryWarning()
|
runtime.handleMemoryWarning()
|
||||||
|
textDisplayCache.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
||||||
super.viewWillTransition(to: size, with: coordinator)
|
super.viewWillTransition(to: size, with: coordinator)
|
||||||
runtime.viewportMonitor.viewWillTransition(with: coordinator)
|
runtime.viewportMonitor.viewWillTransition(with: coordinator)
|
||||||
|
coordinator.animate(alongsideTransition: nil) { _ in
|
||||||
|
RDEPUBMemoryProbe.log("orientationTransition size=\(Int(size.width))x\(Int(size.height))")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func showSearchBar() {
|
func showSearchBar() {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
|||||||
|
|
||||||
private let backgroundButton = UIButton(type: .custom)
|
private let backgroundButton = UIButton(type: .custom)
|
||||||
|
|
||||||
private let panelView = UIView()
|
let panelView = UIView()
|
||||||
|
|
||||||
private let grabberView = UIView()
|
private let grabberView = UIView()
|
||||||
|
|
||||||
@@ -93,6 +93,24 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||||
|
// Only claim touches that fall within the panelView or the
|
||||||
|
// accessibility-only navigation buttons. Taps on the dimmed scrim
|
||||||
|
// area pass through to the reader content below, allowing chrome
|
||||||
|
// toggle taps to work.
|
||||||
|
let panelPoint = convert(point, to: panelView)
|
||||||
|
if panelView.point(inside: panelPoint, with: event) {
|
||||||
|
return super.hitTest(point, with: event)
|
||||||
|
}
|
||||||
|
// Also claim touches on the accessibility-only navigation buttons
|
||||||
|
if previousButton.frame.contains(point)
|
||||||
|
|| nextButton.frame.contains(point)
|
||||||
|
|| countLabel.frame.contains(point) {
|
||||||
|
return super.hitTest(point, with: event)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
override func lineFrame(in bounds: CGRect) -> CGRect {
|
override func lineFrame(in bounds: CGRect) -> CGRect {
|
||||||
.zero
|
.zero
|
||||||
}
|
}
|
||||||
@@ -277,9 +295,11 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
|||||||
nextButton.accessibilityIdentifier = "epub.reader.search.next"
|
nextButton.accessibilityIdentifier = "epub.reader.search.next"
|
||||||
countLabel.accessibilityIdentifier = "epub.reader.search.count"
|
countLabel.accessibilityIdentifier = "epub.reader.search.count"
|
||||||
textField.accessibilityIdentifier = "epub.reader.search.field"
|
textField.accessibilityIdentifier = "epub.reader.search.field"
|
||||||
previousButton.alpha = 0.01
|
// 0.02 而非 0.01:CALayer.opacity 为 Float32,0.01 落盘后略小于
|
||||||
nextButton.alpha = 0.01
|
// 0.01,会被 UIKit 命中测试按「透明视图」剔除,导致按钮永远点不中。
|
||||||
countLabel.alpha = 0.01
|
previousButton.alpha = 0.02
|
||||||
|
nextButton.alpha = 0.02
|
||||||
|
countLabel.alpha = 0.02
|
||||||
|
|
||||||
tableView.register(RDEPUBReaderSearchResultCell.self, forCellReuseIdentifier: RDEPUBReaderSearchResultCell.reuseIdentifier)
|
tableView.register(RDEPUBReaderSearchResultCell.self, forCellReuseIdentifier: RDEPUBReaderSearchResultCell.reuseIdentifier)
|
||||||
tableView.dataSource = self
|
tableView.dataSource = self
|
||||||
@@ -342,17 +362,20 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
|||||||
emptyStateLabel.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -32),
|
emptyStateLabel.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -32),
|
||||||
emptyStateLabel.topAnchor.constraint(equalTo: searchRowView.bottomAnchor, constant: 56),
|
emptyStateLabel.topAnchor.constraint(equalTo: searchRowView.bottomAnchor, constant: 56),
|
||||||
|
|
||||||
previousButton.topAnchor.constraint(equalTo: topAnchor),
|
// 无障碍/UI 测试专用的隐形控件:必须放在 panel 顶部而非视图
|
||||||
|
// 左上角——视图左上角落在系统状态栏区域内,合成点击会被状态栏
|
||||||
|
// 窗口拦截,永远到不了这些按钮。
|
||||||
|
previousButton.topAnchor.constraint(equalTo: panelView.topAnchor),
|
||||||
previousButton.leadingAnchor.constraint(equalTo: leadingAnchor),
|
previousButton.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
previousButton.widthAnchor.constraint(equalToConstant: 1),
|
previousButton.widthAnchor.constraint(equalToConstant: 1),
|
||||||
previousButton.heightAnchor.constraint(equalToConstant: 1),
|
previousButton.heightAnchor.constraint(equalToConstant: 1),
|
||||||
|
|
||||||
nextButton.topAnchor.constraint(equalTo: topAnchor),
|
nextButton.topAnchor.constraint(equalTo: panelView.topAnchor),
|
||||||
nextButton.leadingAnchor.constraint(equalTo: previousButton.trailingAnchor),
|
nextButton.leadingAnchor.constraint(equalTo: previousButton.trailingAnchor),
|
||||||
nextButton.widthAnchor.constraint(equalToConstant: 1),
|
nextButton.widthAnchor.constraint(equalToConstant: 1),
|
||||||
nextButton.heightAnchor.constraint(equalToConstant: 1),
|
nextButton.heightAnchor.constraint(equalToConstant: 1),
|
||||||
|
|
||||||
countLabel.topAnchor.constraint(equalTo: topAnchor),
|
countLabel.topAnchor.constraint(equalTo: panelView.topAnchor),
|
||||||
countLabel.leadingAnchor.constraint(equalTo: nextButton.trailingAnchor),
|
countLabel.leadingAnchor.constraint(equalTo: nextButton.trailingAnchor),
|
||||||
countLabel.widthAnchor.constraint(equalToConstant: 1),
|
countLabel.widthAnchor.constraint(equalToConstant: 1),
|
||||||
countLabel.heightAnchor.constraint(equalToConstant: 1)
|
countLabel.heightAnchor.constraint(equalToConstant: 1)
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// Debug-only automation (`--demo-settings-flip`) that replays the user
|
||||||
|
/// gesture sequence suspected of producing stale page tables: open the
|
||||||
|
/// settings panel, change the line height, close the panel, flip pages —
|
||||||
|
/// with several timing variants aimed at the preview-repagination and
|
||||||
|
/// in-flight chapter build races. Combine with
|
||||||
|
/// `--demo-pagination-validate` to detect any resulting metric mismatch.
|
||||||
|
enum RDEPUBSettingsFlipAutomation {
|
||||||
|
|
||||||
|
static let isEnabled = ProcessInfo.processInfo.arguments.contains("--demo-settings-flip")
|
||||||
|
|
||||||
|
private(set) static var hasStarted = false
|
||||||
|
|
||||||
|
static func startIfNeeded(controller: RDEPUBReaderController) {
|
||||||
|
guard isEnabled, !hasStarted else { return }
|
||||||
|
hasStarted = true
|
||||||
|
print("[SETTINGS-FLIP] scheduled")
|
||||||
|
|
||||||
|
// Pass 1 starts while progressive pagination of the freshly opened
|
||||||
|
// book is still running, so prefetch builds are in flight.
|
||||||
|
run(after: 2.0) { [weak controller] in
|
||||||
|
guard let controller else { return }
|
||||||
|
pass(controller: controller, index: 1, lineHeight: 1.8, changeToCloseDelay: 1.2) {
|
||||||
|
pass(controller: controller, index: 2, lineHeight: 1.6, changeToCloseDelay: 0.05) {
|
||||||
|
pass(controller: controller, index: 3, lineHeight: 1.8, changeToCloseDelay: 0.3) {
|
||||||
|
print("[SETTINGS-FLIP] finished all passes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One panel round-trip: present → change line height → close after
|
||||||
|
/// `changeToCloseDelay` → flip pages forward and back.
|
||||||
|
private static func pass(
|
||||||
|
controller: RDEPUBReaderController,
|
||||||
|
index: Int,
|
||||||
|
lineHeight: CGFloat,
|
||||||
|
changeToCloseDelay: TimeInterval,
|
||||||
|
completion: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
print("[SETTINGS-FLIP] pass \(index) present panel")
|
||||||
|
controller.presentSettings()
|
||||||
|
|
||||||
|
run(after: 0.7) { [weak controller] in
|
||||||
|
guard let controller else { return }
|
||||||
|
print("[SETTINGS-FLIP] pass \(index) set lineHeightMultiple=\(lineHeight)")
|
||||||
|
controller.updateConfiguration { $0.lineHeightMultiple = lineHeight }
|
||||||
|
|
||||||
|
run(after: changeToCloseDelay) { [weak controller] in
|
||||||
|
guard let controller else { return }
|
||||||
|
print("[SETTINGS-FLIP] pass \(index) close panel")
|
||||||
|
controller.dismiss(animated: true) { [weak controller] in
|
||||||
|
controller?.runtime.settingsPanelDidDisappear()
|
||||||
|
run(after: 1.0) { [weak controller] in
|
||||||
|
guard let controller else { return }
|
||||||
|
flipPages(controller: controller, index: index, completion: completion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func flipPages(
|
||||||
|
controller: RDEPUBReaderController,
|
||||||
|
index: Int,
|
||||||
|
completion: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
let startPage = controller.readerView.currentPage + 1
|
||||||
|
let offsets = [1, 2, 3, 2, 1, 0]
|
||||||
|
print("[SETTINGS-FLIP] pass \(index) flip pages from \(startPage)")
|
||||||
|
for (step, offset) in offsets.enumerated() {
|
||||||
|
run(after: 0.6 * Double(step + 1)) { [weak controller] in
|
||||||
|
_ = controller?.go(toPageNumber: startPage + offset, animated: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run(after: 0.6 * Double(offsets.count + 1) + 0.5, block: completion)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func run(after delay: TimeInterval, block: @escaping () -> Void) {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: block)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -113,7 +113,7 @@ public final class RDURLReaderController: UIViewController {
|
|||||||
guard let readerController else { return }
|
guard let readerController else { return }
|
||||||
readerController.showSearchBar()
|
readerController.showSearchBar()
|
||||||
if readerController.parser != nil || readerController.textBook != nil {
|
if readerController.parser != nil || readerController.textBook != nil {
|
||||||
submitSearchAfterDelay(keyword: keyword, retries: 5)
|
submitSearchAfterDelay(keyword: keyword, retries: 10)
|
||||||
} else {
|
} else {
|
||||||
pendingSearchKeyword = keyword
|
pendingSearchKeyword = keyword
|
||||||
}
|
}
|
||||||
@@ -125,9 +125,10 @@ public final class RDURLReaderController: UIViewController {
|
|||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
readerController.search(keyword: keyword)
|
readerController.search(keyword: keyword)
|
||||||
readerController.updateSearchCount()
|
readerController.updateSearchCount()
|
||||||
|
// 按需加载的书打开初期搜索引擎可能尚未就绪(bookPageMap 未建立),
|
||||||
|
// 只要还没有命中就继续重试,交由搜索自身的 token 去重。
|
||||||
if retries > 0,
|
if retries > 0,
|
||||||
readerController.searchState?.matches.isEmpty != false,
|
readerController.searchState?.matches.isEmpty != false {
|
||||||
readerController.parser == nil {
|
|
||||||
self.submitSearchAfterDelay(keyword: keyword, retries: retries - 1)
|
self.submitSearchAfterDelay(keyword: keyword, retries: retries - 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,7 +140,7 @@ public final class RDURLReaderController: UIViewController {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
pendingSearchKeyword = nil
|
pendingSearchKeyword = nil
|
||||||
submitSearchAfterDelay(keyword: keyword, retries: 5)
|
submitSearchAfterDelay(keyword: keyword, retries: 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func embedReaderController() {
|
private func embedReaderController() {
|
||||||
@@ -154,7 +155,7 @@ public final class RDURLReaderController: UIViewController {
|
|||||||
let bookTitle = bookURL.deletingPathExtension().lastPathComponent
|
let bookTitle = bookURL.deletingPathExtension().lastPathComponent
|
||||||
let pageSize = currentTextPageSize()
|
let pageSize = currentTextPageSize()
|
||||||
let renderStyle = currentTextRenderStyle()
|
let renderStyle = currentTextRenderStyle()
|
||||||
let safeInsets = view.safeAreaInsets
|
let safeInsets = RDEPUBSafeArea.resolve(view.safeAreaInsets)
|
||||||
let edgeInsets = UIEdgeInsets(
|
let edgeInsets = UIEdgeInsets(
|
||||||
top: max(epubConfiguration.reflowableContentInsets.top, safeInsets.top),
|
top: max(epubConfiguration.reflowableContentInsets.top, safeInsets.top),
|
||||||
left: max(epubConfiguration.reflowableContentInsets.left, safeInsets.left),
|
left: max(epubConfiguration.reflowableContentInsets.left, safeInsets.left),
|
||||||
@@ -367,7 +368,8 @@ public final class RDURLReaderController: UIViewController {
|
|||||||
"externalLinks=\(externalLinkActivationCount)",
|
"externalLinks=\(externalLinkActivationCount)",
|
||||||
"lastExternalURL=\(encodedDemoLocationHref(lastActivatedExternalURL?.absoluteString))",
|
"lastExternalURL=\(encodedDemoLocationHref(lastActivatedExternalURL?.absoluteString))",
|
||||||
"lastError=\(encodedDemoField(lastReaderErrorDescription))",
|
"lastError=\(encodedDemoField(lastReaderErrorDescription))",
|
||||||
"searchMatchText=\(encodedDemoField(currentSearchMatchText()))"
|
"searchMatchText=\(encodedDemoField(currentSearchMatchText()))",
|
||||||
|
"footprintMB=\(String(format: "%.1f", RDEPUBMemoryProbe.footprintMB))"
|
||||||
].joined(separator: " ")
|
].joined(separator: " ")
|
||||||
demoStateLabel.text = state
|
demoStateLabel.text = state
|
||||||
if let logPrefix {
|
if let logPrefix {
|
||||||
@@ -388,18 +390,28 @@ public final class RDURLReaderController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func currentSearchMatchText() -> String {
|
private func currentSearchMatchText() -> String {
|
||||||
guard let match = readerController?.searchState?.currentMatch,
|
guard let match = readerController?.searchState?.currentMatch else {
|
||||||
let rangeLocation = match.rangeLocation,
|
|
||||||
let chapterData = readerController?.textChapterData(forNormalizedHref: match.href) else {
|
|
||||||
return "none"
|
return "none"
|
||||||
}
|
}
|
||||||
|
// Primary path: extract the exact matched text from the chapter content
|
||||||
|
if let rangeLocation = match.rangeLocation,
|
||||||
|
let chapterData = readerController?.textChapterData(forNormalizedHref: match.href) {
|
||||||
let nsRange = NSRange(location: rangeLocation, length: match.rangeLength)
|
let nsRange = NSRange(location: rangeLocation, length: match.rangeLength)
|
||||||
guard nsRange.location >= 0,
|
if nsRange.location >= 0,
|
||||||
nsRange.location + nsRange.length <= chapterData.attributedContent.length else {
|
nsRange.location + nsRange.length <= chapterData.attributedContent.length {
|
||||||
return "none"
|
|
||||||
}
|
|
||||||
return chapterData.attributedContent.attributedSubstring(from: nsRange).string
|
return chapterData.attributedContent.attributedSubstring(from: nsRange).string
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Fallback: chapter data may not be cached yet (on-demand loading).
|
||||||
|
// If the match length equals the keyword length, the keyword itself
|
||||||
|
// is the match text. Otherwise extract from previewText at the known offset.
|
||||||
|
if let keyword = readerController?.searchState?.keyword,
|
||||||
|
match.rangeLength == keyword.count {
|
||||||
|
return keyword
|
||||||
|
}
|
||||||
|
// Final fallback: return "none" to indicate data not yet available
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
|
||||||
private func demoPaginationSnapshot() -> (mode: String, phase: String, knownPages: Int, knownChapters: Int, buildableChapters: Int) {
|
private func demoPaginationSnapshot() -> (mode: String, phase: String, knownPages: Int, knownChapters: Int, buildableChapters: Int) {
|
||||||
guard let readerController else {
|
guard let readerController else {
|
||||||
|
|||||||
+30
-2
@@ -65,6 +65,10 @@ final class RDEPUBChapterLoader {
|
|||||||
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
|
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
|
||||||
) {
|
) {
|
||||||
if let cached = store.chapterData(for: spineIndex) {
|
if let cached = store.chapterData(for: spineIndex) {
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"ChapterLoad",
|
||||||
|
"memory HIT spine=\(spineIndex) pages=\(cached.pages.count) priority=\(priority)"
|
||||||
|
)
|
||||||
if let context {
|
if let context {
|
||||||
scheduleDeferredCFIMapBuildIfNeeded(
|
scheduleDeferredCFIMapBuildIfNeeded(
|
||||||
for: cached,
|
for: cached,
|
||||||
@@ -92,6 +96,10 @@ final class RDEPUBChapterLoader {
|
|||||||
|
|
||||||
switch registration {
|
switch registration {
|
||||||
case .joined(let existingPriority, let effectivePriority):
|
case .joined(let existingPriority, let effectivePriority):
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"ChapterLoad",
|
||||||
|
"join pendingLoad spine=\(spineIndex) existing=\(existingPriority) effective=\(effectivePriority)"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
case .created:
|
case .created:
|
||||||
break
|
break
|
||||||
@@ -120,6 +128,20 @@ final class RDEPUBChapterLoader {
|
|||||||
let diskPageRanges = diskSummary?.pageRanges.map { $0.nsRange }
|
let diskPageRanges = diskSummary?.pageRanges.map { $0.nsRange }
|
||||||
let availablePageRanges = precomputedPageRanges ?? diskPageRanges
|
let availablePageRanges = precomputedPageRanges ?? diskPageRanges
|
||||||
|
|
||||||
|
let pageRangeSource: String
|
||||||
|
if precomputedPageRanges != nil {
|
||||||
|
pageRangeSource = "HIT(memoryPageCount)"
|
||||||
|
} else if diskPageRanges != nil {
|
||||||
|
pageRangeSource = "HIT(diskSummary)"
|
||||||
|
} else {
|
||||||
|
pageRangeSource = "MISS(fullRender)"
|
||||||
|
}
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"ChapterLoad",
|
||||||
|
"build start spine=\(spineIndex) priority=\(queuePriority) pageRanges=\(pageRangeSource)"
|
||||||
|
)
|
||||||
|
let buildStart = CFAbsoluteTimeGetCurrent()
|
||||||
|
|
||||||
do {
|
do {
|
||||||
|
|
||||||
let chapter = try self.buildChapter(
|
let chapter = try self.buildChapter(
|
||||||
@@ -130,6 +152,10 @@ final class RDEPUBChapterLoader {
|
|||||||
layoutSnapshot: layoutSnapshot
|
layoutSnapshot: layoutSnapshot
|
||||||
)
|
)
|
||||||
|
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"ChapterLoad",
|
||||||
|
"build done spine=\(spineIndex) pages=\(chapter.pages.count) elapsedMs=\(Int((CFAbsoluteTimeGetCurrent() - buildStart) * 1000)) pageRanges=\(pageRangeSource)"
|
||||||
|
)
|
||||||
store.insertChapter(chapter)
|
store.insertChapter(chapter)
|
||||||
let pc = RDEPUBRuntimePageCount(
|
let pc = RDEPUBRuntimePageCount(
|
||||||
cacheKey: cacheKey,
|
cacheKey: cacheKey,
|
||||||
@@ -172,6 +198,10 @@ final class RDEPUBChapterLoader {
|
|||||||
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
|
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"ChapterLoad",
|
||||||
|
"build failed spine=\(spineIndex) pageRanges=\(pageRangeSource) error=\(String(describing: error))"
|
||||||
|
)
|
||||||
store.endPendingChapterLoad(for: spineIndex)
|
store.endPendingChapterLoad(for: spineIndex)
|
||||||
store.markBuilding(false)
|
store.markBuilding(false)
|
||||||
self.resolvePendingLoad(spineIndex: spineIndex, result: .failure(error))
|
self.resolvePendingLoad(spineIndex: spineIndex, result: .failure(error))
|
||||||
@@ -461,7 +491,6 @@ final class RDEPUBChapterLoader {
|
|||||||
) -> [RDEPUBTextPage] {
|
) -> [RDEPUBTextPage] {
|
||||||
let totalPageCount = pageRanges.count
|
let totalPageCount = pageRanges.count
|
||||||
return pageRanges.enumerated().map { (pageIndex, range) in
|
return pageRanges.enumerated().map { (pageIndex, range) in
|
||||||
let pageContent = typesetString.attributedSubstring(from: range)
|
|
||||||
let metadata: RDEPUBTextPageMetadata
|
let metadata: RDEPUBTextPageMetadata
|
||||||
if let metaList = metadataSource, pageIndex < metaList.count {
|
if let metaList = metadataSource, pageIndex < metaList.count {
|
||||||
|
|
||||||
@@ -483,7 +512,6 @@ final class RDEPUBChapterLoader {
|
|||||||
pageIndexInChapter: pageIndex,
|
pageIndexInChapter: pageIndex,
|
||||||
totalPagesInChapter: totalPageCount,
|
totalPagesInChapter: totalPageCount,
|
||||||
chapterContent: typesetString,
|
chapterContent: typesetString,
|
||||||
content: pageContent,
|
|
||||||
contentRange: range,
|
contentRange: range,
|
||||||
pageStartOffset: range.location,
|
pageStartOffset: range.location,
|
||||||
pageEndOffset: range.location + range.length - 1,
|
pageEndOffset: range.location + range.length - 1,
|
||||||
|
|||||||
+2
@@ -62,6 +62,7 @@ final class RDEPUBChapterRuntimeStore {
|
|||||||
|
|
||||||
func insertChapter(_ chapter: RDEPUBRuntimeChapter) {
|
func insertChapter(_ chapter: RDEPUBRuntimeChapter) {
|
||||||
chapterDataCache[chapter.spineIndex] = chapter
|
chapterDataCache[chapter.spineIndex] = chapter
|
||||||
|
RDEPUBMemoryProbe.log("chapterLoaded spine=\(chapter.spineIndex) pages=\(chapter.pages.count)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func insertPageCount(_ pc: RDEPUBRuntimePageCount, for key: RDEPUBChapterCacheKey) {
|
func insertPageCount(_ pc: RDEPUBRuntimePageCount, for key: RDEPUBChapterCacheKey) {
|
||||||
@@ -188,6 +189,7 @@ final class RDEPUBChapterRuntimeStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func invalidateAllForSettingsChange() {
|
func invalidateAllForSettingsChange() {
|
||||||
|
RDEPUBMemoryProbe.log("settingsChangeInvalidateAll")
|
||||||
chapterDataCache.removeAll()
|
chapterDataCache.removeAll()
|
||||||
pageCountCache.removeAll()
|
pageCountCache.removeAll()
|
||||||
imageCache.removeAllObjects()
|
imageCache.removeAllObjects()
|
||||||
|
|||||||
+14
-2
@@ -32,6 +32,8 @@ final class RDEPUBChapterWarmupOrchestrator {
|
|||||||
|
|
||||||
private let prepareRequestDebounceInterval: CFTimeInterval = 0.15
|
private let prepareRequestDebounceInterval: CFTimeInterval = 0.15
|
||||||
|
|
||||||
|
private static let upcomingChapterLookaheadCount = 2
|
||||||
|
|
||||||
init(
|
init(
|
||||||
context: RDEPUBReaderContext,
|
context: RDEPUBReaderContext,
|
||||||
store: RDEPUBChapterRuntimeStore,
|
store: RDEPUBChapterRuntimeStore,
|
||||||
@@ -392,7 +394,10 @@ final class RDEPUBChapterWarmupOrchestrator {
|
|||||||
windowRadius: context.configuration.chapterWindowRadius
|
windowRadius: context.configuration.chapterWindowRadius
|
||||||
)
|
)
|
||||||
|
|
||||||
for evictable in store.evictableSpineIndices() {
|
// Keep chapters that maybePrefetchUpcomingChapters is responsible for,
|
||||||
|
// otherwise the two policies evict/rebuild the same chapter in a loop.
|
||||||
|
let retainedLookaheadIndices = upcomingLookaheadSpineIndices(after: spineIndex)
|
||||||
|
for evictable in store.evictableSpineIndices() where !retainedLookaheadIndices.contains(evictable) {
|
||||||
store.evict(spineIndex: evictable)
|
store.evict(spineIndex: evictable)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,11 +415,18 @@ final class RDEPUBChapterWarmupOrchestrator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func upcomingLookaheadSpineIndices(after spineIndex: Int) -> Set<Int> {
|
||||||
|
guard let publication = context.publication else { return [] }
|
||||||
|
let buildableIndices = buildableSpineIndices(in: publication)
|
||||||
|
guard let currentPosition = buildableIndices.firstIndex(of: spineIndex) else { return [] }
|
||||||
|
return Set(buildableIndices.dropFirst(currentPosition + 1).prefix(Self.upcomingChapterLookaheadCount))
|
||||||
|
}
|
||||||
|
|
||||||
private func maybePrefetchUpcomingChapters(
|
private func maybePrefetchUpcomingChapters(
|
||||||
aroundAbsolutePageNumber pageNumber: Int,
|
aroundAbsolutePageNumber pageNumber: Int,
|
||||||
in bookPageMap: RDEPUBBookPageMap,
|
in bookPageMap: RDEPUBBookPageMap,
|
||||||
threshold: Int = 3,
|
threshold: Int = 3,
|
||||||
lookaheadChapterCount: Int = 2
|
lookaheadChapterCount: Int = RDEPUBChapterWarmupOrchestrator.upcomingChapterLookaheadCount
|
||||||
) {
|
) {
|
||||||
guard let publication = context.publication else { return }
|
guard let publication = context.publication else { return }
|
||||||
let absolutePageIndex = pageNumber - 1
|
let absolutePageIndex = pageNumber - 1
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Memory footprint probe for the long-chapter optimization work
|
||||||
|
/// (Doc/LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md, P0-2). Enabled with the
|
||||||
|
/// `--demo-memory-probe` launch argument; logs phys_footprint at chapter
|
||||||
|
/// load, every 20 page turns, settings invalidation, and rotation.
|
||||||
|
enum RDEPUBMemoryProbe {
|
||||||
|
|
||||||
|
static let isEnabled = ProcessInfo.processInfo.arguments.contains("--demo-memory-probe")
|
||||||
|
|
||||||
|
private static let pageTurnLogStride = 20
|
||||||
|
|
||||||
|
/// Main-thread only (page turns are delivered on main).
|
||||||
|
private static var pageTurnCount = 0
|
||||||
|
|
||||||
|
static func logPageTurn() {
|
||||||
|
guard isEnabled else { return }
|
||||||
|
pageTurnCount += 1
|
||||||
|
guard pageTurnCount % pageTurnLogStride == 0 else { return }
|
||||||
|
log("pageTurn count=\(pageTurnCount)")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func log(_ event: String) {
|
||||||
|
guard isEnabled else { return }
|
||||||
|
print(String(format: "[EPUB][MemoryProbe] %@ footprint=%.1fMB", event, footprintMB))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current phys_footprint in MB. Cheap enough (one task_info call) to
|
||||||
|
/// surface in the demo state snapshot for automated memory assertions.
|
||||||
|
static var footprintMB: Double {
|
||||||
|
Double(currentFootprint()) / 1_048_576
|
||||||
|
}
|
||||||
|
|
||||||
|
/// phys_footprint matches the value Xcode's memory gauge and Jetsam use.
|
||||||
|
private static func currentFootprint() -> UInt64 {
|
||||||
|
var info = task_vm_info_data_t()
|
||||||
|
var count = mach_msg_type_number_t(
|
||||||
|
MemoryLayout<task_vm_info_data_t>.size / MemoryLayout<integer_t>.size
|
||||||
|
)
|
||||||
|
let result = withUnsafeMutablePointer(to: &info) { pointer in
|
||||||
|
pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
|
||||||
|
task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard result == KERN_SUCCESS else { return 0 }
|
||||||
|
return info.phys_footprint
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,8 @@ final class RDEPUBMetadataParseWorker {
|
|||||||
|
|
||||||
private let catalog: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)]
|
private let catalog: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)]
|
||||||
|
|
||||||
|
private let progressLogStride = 4
|
||||||
|
|
||||||
init(
|
init(
|
||||||
context: RDEPUBReaderContext,
|
context: RDEPUBReaderContext,
|
||||||
cancellationController: RDEPUBMetadataParseCancellationController,
|
cancellationController: RDEPUBMetadataParseCancellationController,
|
||||||
@@ -116,19 +118,38 @@ final class RDEPUBMetadataParseWorker {
|
|||||||
func start(token: UUID, restoreLocation: RDEPUBLocation?) {
|
func start(token: UUID, restoreLocation: RDEPUBLocation?) {
|
||||||
let cancellationController = self.cancellationController
|
let cancellationController = self.cancellationController
|
||||||
|
|
||||||
DispatchQueue.global(qos: .utility).async { [weak self] in
|
DispatchQueue.global(qos: .utility).async { [self] in
|
||||||
guard let self else { return }
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Metadata",
|
||||||
|
"workerDispatched token=\(token.uuidString)"
|
||||||
|
)
|
||||||
let context = self.context
|
let context = self.context
|
||||||
guard let context,
|
guard let context,
|
||||||
context.controller != nil,
|
context.controller != nil,
|
||||||
!cancellationController.isCancelled,
|
!cancellationController.isCancelled,
|
||||||
context.paginationToken == token else { return }
|
context.paginationToken == token else {
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Metadata",
|
||||||
|
"workerAbortedBeforeStart reason=contextUnavailableOrTokenMismatch"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
defer { context.runtime?.paginationCoordinator.finishMetadataParseCancellationController(cancellationController) }
|
defer { context.runtime?.paginationCoordinator.finishMetadataParseCancellationController(cancellationController) }
|
||||||
guard context.controller != nil,
|
guard context.controller != nil,
|
||||||
!cancellationController.isCancelled,
|
!cancellationController.isCancelled,
|
||||||
context.paginationToken == token else { return }
|
context.paginationToken == token else {
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Metadata",
|
||||||
|
"workerAbortedAfterStart reason=contextUnavailableOrTokenMismatch"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if let restoredPageMap = self.restoreBookPageMapIfPossible() {
|
if let restoredPageMap = self.restoreBookPageMapIfPossible() {
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Metadata",
|
||||||
|
"restoreBookPageMapIfPossible hit totalChapters=\(restoredPageMap.totalChapters) totalPages=\(restoredPageMap.totalPages)"
|
||||||
|
)
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
guard context.paginationToken == token,
|
guard context.paginationToken == token,
|
||||||
context.controller != nil,
|
context.controller != nil,
|
||||||
@@ -175,13 +196,26 @@ final class RDEPUBMetadataParseWorker {
|
|||||||
|
|
||||||
let uncachedSpineIndices = prioritizedSpineIndices
|
let uncachedSpineIndices = prioritizedSpineIndices
|
||||||
|
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Metadata",
|
||||||
|
"waitingForInteractionCooldown elapsedSinceNavigation=\(String(format: "%.2f", context.secondsSinceLastUserNavigation())) uncached=\(uncachedSpineIndices.count)"
|
||||||
|
)
|
||||||
self.waitForReadingInteractionToSettle(cancellationController: cancellationController)
|
self.waitForReadingInteractionToSettle(cancellationController: cancellationController)
|
||||||
guard !cancellationController.isCancelled,
|
guard !cancellationController.isCancelled,
|
||||||
context.controller != nil,
|
context.controller != nil,
|
||||||
context.paginationToken == token else {
|
context.paginationToken == token else {
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Metadata",
|
||||||
|
"workerAbortedDuringCooldown"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Metadata",
|
||||||
|
"start totalBuildable=\(self.allBuildableIndices.count) cached=\(cachedSpineIndices.count) uncached=\(uncachedSpineIndices.count) concurrency=\(self.workerCount) refreshInterval=\(self.pageMapRefreshInterval)"
|
||||||
|
)
|
||||||
|
|
||||||
let wallClockStart = CFAbsoluteTimeGetCurrent()
|
let wallClockStart = CFAbsoluteTimeGetCurrent()
|
||||||
var totalRenderMs: Double = 0
|
var totalRenderMs: Double = 0
|
||||||
var totalWriteMs: Double = 0
|
var totalWriteMs: Double = 0
|
||||||
@@ -283,6 +317,10 @@ final class RDEPUBMetadataParseWorker {
|
|||||||
resultLock.lock()
|
resultLock.lock()
|
||||||
parseState.summariesBySpineIndex[spineIndex] = renderResult
|
parseState.summariesBySpineIndex[spineIndex] = renderResult
|
||||||
parseState.totalResolvedCount += 1
|
parseState.totalResolvedCount += 1
|
||||||
|
let resolvedCount = parseState.totalResolvedCount
|
||||||
|
let shouldLogProgress = resolvedCount == self.allBuildableIndices.count
|
||||||
|
|| resolvedCount == cachedSpineIndices.count + 1
|
||||||
|
|| resolvedCount % self.progressLogStride == 0
|
||||||
if parseState.totalResolvedCount - parseState.lastAppliedCount >= refreshInterval
|
if parseState.totalResolvedCount - parseState.lastAppliedCount >= refreshInterval
|
||||||
|| parseState.totalResolvedCount == self.allBuildableIndices.count {
|
|| parseState.totalResolvedCount == self.allBuildableIndices.count {
|
||||||
parseState.lastAppliedCount = parseState.totalResolvedCount
|
parseState.lastAppliedCount = parseState.totalResolvedCount
|
||||||
@@ -290,6 +328,17 @@ final class RDEPUBMetadataParseWorker {
|
|||||||
}
|
}
|
||||||
resultLock.unlock()
|
resultLock.unlock()
|
||||||
|
|
||||||
|
if shouldLogProgress {
|
||||||
|
let progressPercent = Self.progressPercent(
|
||||||
|
resolved: resolvedCount,
|
||||||
|
total: self.allBuildableIndices.count
|
||||||
|
)
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Metadata",
|
||||||
|
"chapterReady spine=\(spineIndex) resolved=\(resolvedCount)/\(self.allBuildableIndices.count) progress=\(progressPercent)% pageCount=\(renderResult.pageCount)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if let snapshot {
|
if let snapshot {
|
||||||
let mergeStart = CFAbsoluteTimeGetCurrent()
|
let mergeStart = CFAbsoluteTimeGetCurrent()
|
||||||
let partialMap = self.buildPageMap(summaries: snapshot)
|
let partialMap = self.buildPageMap(summaries: snapshot)
|
||||||
@@ -297,6 +346,10 @@ final class RDEPUBMetadataParseWorker {
|
|||||||
timingLock.lock()
|
timingLock.lock()
|
||||||
totalMergeMs += mergeElapsed
|
totalMergeMs += mergeElapsed
|
||||||
timingLock.unlock()
|
timingLock.unlock()
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Metadata",
|
||||||
|
"partialMap resolvedChapters=\(snapshot.count) totalPages=\(partialMap.totalPages) mergeMs=\(Int(mergeElapsed))"
|
||||||
|
)
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
guard context.paginationToken == token,
|
guard context.paginationToken == token,
|
||||||
context.controller != nil,
|
context.controller != nil,
|
||||||
@@ -314,6 +367,10 @@ final class RDEPUBMetadataParseWorker {
|
|||||||
timingLock.lock()
|
timingLock.lock()
|
||||||
failedChapters += 1
|
failedChapters += 1
|
||||||
timingLock.unlock()
|
timingLock.unlock()
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Metadata",
|
||||||
|
"chapterFailed spine=\(spineIndex) retryScheduled=true error=\(String(describing: error))"
|
||||||
|
)
|
||||||
|
|
||||||
self.scheduleRetry(
|
self.scheduleRetry(
|
||||||
spineIndex: spineIndex,
|
spineIndex: spineIndex,
|
||||||
@@ -354,6 +411,10 @@ final class RDEPUBMetadataParseWorker {
|
|||||||
let finalMergeStart = CFAbsoluteTimeGetCurrent()
|
let finalMergeStart = CFAbsoluteTimeGetCurrent()
|
||||||
let pageMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex)
|
let pageMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex)
|
||||||
let finalMergeMs = Int((CFAbsoluteTimeGetCurrent() - finalMergeStart) * 1000)
|
let finalMergeMs = Int((CFAbsoluteTimeGetCurrent() - finalMergeStart) * 1000)
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Metadata",
|
||||||
|
"finish resolved=\(parseState.summariesBySpineIndex.count)/\(self.allBuildableIndices.count) totalPages=\(pageMap.totalPages) elapsedMs=\(wallClockMs) renderMs=\(renderTotal) writeMs=\(writeTotal) mergeMs=\(mergeTotal + finalMergeMs) failed=\(failed)"
|
||||||
|
)
|
||||||
|
|
||||||
if let coverageStore = context.runtime?.backgroundCoverageStore {
|
if let coverageStore = context.runtime?.backgroundCoverageStore {
|
||||||
let resolvedSpineIndices = Set(parseState.summariesBySpineIndex.keys)
|
let resolvedSpineIndices = Set(parseState.summariesBySpineIndex.keys)
|
||||||
@@ -407,10 +468,18 @@ final class RDEPUBMetadataParseWorker {
|
|||||||
cancellationController: RDEPUBMetadataParseCancellationController
|
cancellationController: RDEPUBMetadataParseCancellationController
|
||||||
) {
|
) {
|
||||||
guard retryCount < Self.maxRetryCount else {
|
guard retryCount < Self.maxRetryCount else {
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Metadata",
|
||||||
|
"retryAborted spine=\(spineIndex) retryCount=\(retryCount)"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let delay = Self.retryDelays[min(retryCount, Self.retryDelays.count - 1)]
|
let delay = Self.retryDelays[min(retryCount, Self.retryDelays.count - 1)]
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Metadata",
|
||||||
|
"retryScheduled spine=\(spineIndex) retryCount=\(retryCount + 1) delayMs=\(Int(delay * 1000))"
|
||||||
|
)
|
||||||
|
|
||||||
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay) { [weak self] in
|
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||||
guard let self, let context = self.context else { return }
|
guard let self, let context = self.context else { return }
|
||||||
@@ -474,6 +543,10 @@ final class RDEPUBMetadataParseWorker {
|
|||||||
|
|
||||||
if shouldRefresh {
|
if shouldRefresh {
|
||||||
let partialMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex)
|
let partialMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex)
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Metadata",
|
||||||
|
"retryPartialMap resolvedChapters=\(parseState.summariesBySpineIndex.count) totalPages=\(partialMap.totalPages)"
|
||||||
|
)
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
guard context.paginationToken == self.token,
|
guard context.paginationToken == self.token,
|
||||||
context.controller != nil,
|
context.controller != nil,
|
||||||
@@ -495,6 +568,11 @@ final class RDEPUBMetadataParseWorker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func progressPercent(resolved: Int, total: Int) -> Int {
|
||||||
|
guard total > 0 else { return 0 }
|
||||||
|
return Int((Double(resolved) / Double(total) * 100.0).rounded())
|
||||||
|
}
|
||||||
|
|
||||||
private func restoreBookPageMapIfPossible() -> RDEPUBBookPageMap? {
|
private func restoreBookPageMapIfPossible() -> RDEPUBBookPageMap? {
|
||||||
guard let summaryDiskCache else { return nil }
|
guard let summaryDiskCache else { return nil }
|
||||||
guard summaryDiskCache.isCacheComplete(keys: catalog.map(\.key)) else {
|
guard summaryDiskCache.isCacheComplete(keys: catalog.map(\.key)) else {
|
||||||
|
|||||||
+7
-2
@@ -164,6 +164,7 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
|||||||
lastBuildableSpineIndex: Int
|
lastBuildableSpineIndex: Int
|
||||||
) -> RDEPUBPageMapTakeoverDecision {
|
) -> RDEPUBPageMapTakeoverDecision {
|
||||||
let candidateIndices = Set(candidatePageMap.entries.map { $0.spineIndex })
|
let candidateIndices = Set(candidatePageMap.entries.map { $0.spineIndex })
|
||||||
|
let currentIndices = Set(currentWindow.entries.map { $0.spineIndex })
|
||||||
let currentEntries = currentWindow.entries.count
|
let currentEntries = currentWindow.entries.count
|
||||||
|
|
||||||
if let currentSpineIndex {
|
if let currentSpineIndex {
|
||||||
@@ -189,8 +190,8 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let isComplete = candidateIndices.count >= currentEntries
|
let coversCurrentWindow = currentIndices.isSubset(of: candidateIndices)
|
||||||
if isComplete {
|
if coversCurrentWindow {
|
||||||
RDEPUBBackgroundTrace.log(
|
RDEPUBBackgroundTrace.log(
|
||||||
"Reconciliation",
|
"Reconciliation",
|
||||||
"evaluateFullPageMapTakeover: fullReplace — candidateChapters=\(candidateIndices.count) currentChapters=\(currentEntries) candidatePages=\(candidatePageMap.totalPages) currentPages=\(currentWindow.totalPages)"
|
"evaluateFullPageMapTakeover: fullReplace — candidateChapters=\(candidateIndices.count) currentChapters=\(currentEntries) candidatePages=\(candidatePageMap.totalPages) currentPages=\(currentWindow.totalPages)"
|
||||||
@@ -198,6 +199,10 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
|||||||
return .fullReplace(candidatePageMap)
|
return .fullReplace(candidatePageMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Reconciliation",
|
||||||
|
"evaluateFullPageMapTakeover: keepCurrentWindow — candidate does not cover currentWindow candidateChapters=\(candidateIndices.count) currentChapters=\(currentEntries)"
|
||||||
|
)
|
||||||
return .keepCurrentWindow
|
return .keepCurrentWindow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ final class RDEPUBPresentationRuntime {
|
|||||||
|
|
||||||
private unowned let reconciliationCoordinator: RDEPUBPageMapReconciliationCoordinator
|
private unowned let reconciliationCoordinator: RDEPUBPageMapReconciliationCoordinator
|
||||||
|
|
||||||
|
private var pendingCommitRetryWorkItem: DispatchWorkItem?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
context: RDEPUBReaderContext,
|
context: RDEPUBReaderContext,
|
||||||
locationCoordinator: RDEPUBReaderLocationCoordinator,
|
locationCoordinator: RDEPUBReaderLocationCoordinator,
|
||||||
@@ -62,11 +64,22 @@ final class RDEPUBPresentationRuntime {
|
|||||||
guard let readerView = context.readerView,
|
guard let readerView = context.readerView,
|
||||||
let controller = context.controller else { return }
|
let controller = context.controller else { return }
|
||||||
|
|
||||||
guard !controller.isRepaginating else { return }
|
guard !context.pendingPageMapUpdates.isEmpty else {
|
||||||
guard !readerView.isPageCurlTransitioning else {
|
cancelPendingCommitRetry()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard !controller.isRepaginating else {
|
||||||
|
schedulePendingCommitRetry()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard !readerView.isPageCurlTransitioning else {
|
||||||
|
schedulePendingCommitRetry()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelPendingCommitRetry()
|
||||||
|
|
||||||
let rankedUpdates = rankedPendingPageMapUpdates()
|
let rankedUpdates = rankedPendingPageMapUpdates()
|
||||||
for (index, update) in rankedUpdates {
|
for (index, update) in rankedUpdates {
|
||||||
if commitPendingPageMapUpdate(
|
if commitPendingPageMapUpdate(
|
||||||
@@ -75,9 +88,16 @@ final class RDEPUBPresentationRuntime {
|
|||||||
readerView: readerView,
|
readerView: readerView,
|
||||||
controller: controller
|
controller: controller
|
||||||
) {
|
) {
|
||||||
|
if !context.pendingPageMapUpdates.isEmpty {
|
||||||
|
schedulePendingCommitRetry()
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !context.pendingPageMapUpdates.isEmpty {
|
||||||
|
schedulePendingCommitRetry()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func queueExtendedPartialPageMap(
|
func queueExtendedPartialPageMap(
|
||||||
@@ -139,19 +159,46 @@ final class RDEPUBPresentationRuntime {
|
|||||||
controller: RDEPUBReaderController
|
controller: RDEPUBReaderController
|
||||||
) {
|
) {
|
||||||
let currentLocation = locationCoordinator.currentVisibleLocation()
|
let currentLocation = locationCoordinator.currentVisibleLocation()
|
||||||
|
let livePageBeforeApply = readerView.currentPage + 1
|
||||||
|
// Resolved against the outgoing page map. When it round-trips to the live
|
||||||
|
// page, the location faithfully describes what is on screen, so whatever
|
||||||
|
// page it resolves to in the new map is authoritative even if the two maps
|
||||||
|
// number pages differently (partial-window -> full-book takeover).
|
||||||
|
let oldResolvedPage = currentLocation.flatMap { controller.pageNumber(for: $0) }
|
||||||
|
|
||||||
context.textBook = nil
|
context.textBook = nil
|
||||||
applyPageMapToLiveModel(newPageMap)
|
applyPageMapToLiveModel(newPageMap)
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Reconciliation",
|
||||||
|
"applyFullPageMapReplacement livePageBeforeApply=\(livePageBeforeApply) totalPages=\(newPageMap.totalPages) totalChapters=\(newPageMap.totalChapters)"
|
||||||
|
)
|
||||||
|
|
||||||
if let currentLocation {
|
if let currentLocation {
|
||||||
if rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) == false {
|
let resolvedTargetPage = controller.pageNumber(for: currentLocation)
|
||||||
let newPageNumber = controller.pageNumber(for: currentLocation) ?? (readerView.currentPage + 1)
|
let shouldTrustResolvedLocation = shouldTrustFullReplaceResolvedPage(
|
||||||
let newPage = max(0, newPageNumber - 1)
|
resolvedTargetPage,
|
||||||
|
livePageBeforeApply: livePageBeforeApply,
|
||||||
|
locationMatchesLivePage: oldResolvedPage == livePageBeforeApply
|
||||||
|
)
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Reconciliation",
|
||||||
|
"applyFullPageMapReplacement decision livePage=\(livePageBeforeApply) oldResolvedPage=\(oldResolvedPage ?? -1) resolvedTargetPage=\(resolvedTargetPage ?? -1) trustResolved=\(shouldTrustResolvedLocation) href=\(currentLocation.href)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if shouldTrustResolvedLocation,
|
||||||
|
rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let fallbackPage = max(livePageBeforeApply, 1)
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Reconciliation",
|
||||||
|
"applyFullPageMapReplacement preserveLivePage fallbackPage=\(fallbackPage) currentReaderPage=\(readerView.currentPage + 1)"
|
||||||
|
)
|
||||||
rebindVisiblePage(
|
rebindVisiblePage(
|
||||||
to: newPage,
|
to: fallbackPage - 1,
|
||||||
readerView: readerView
|
readerView: readerView
|
||||||
)
|
)
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
readerView.reloadPageCountOnly()
|
readerView.reloadPageCountOnly()
|
||||||
}
|
}
|
||||||
@@ -223,17 +270,23 @@ final class RDEPUBPresentationRuntime {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
case .extendPartial(_, let currentLocation):
|
case .extendPartial(let capturedPageNumber, let currentLocation):
|
||||||
removePendingPageMapUpdate(at: index)
|
removePendingPageMapUpdate(at: index)
|
||||||
applyPageMapToLiveModel(update.pageMap)
|
applyPageMapToLiveModel(update.pageMap)
|
||||||
if let currentLocation,
|
let livePageNumber = max(readerView.currentPage, 0) + 1
|
||||||
|
let shouldTrustCapturedLocation = livePageNumber == max(capturedPageNumber, 1)
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Reconciliation",
|
||||||
|
"extendPartial commit capturedPage=\(capturedPageNumber) livePage=\(livePageNumber) trustCaptured=\(shouldTrustCapturedLocation) totalPages=\(update.pageMap.totalPages) totalChapters=\(update.pageMap.totalChapters)"
|
||||||
|
)
|
||||||
|
if shouldTrustCapturedLocation,
|
||||||
|
let currentLocation,
|
||||||
rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) {
|
rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// Fallback: prefer the live readerView.currentPage over the stale captured
|
// Prefer the live readerView page when the user has moved since the
|
||||||
// currentPageNumber, which may be outdated by the time this commit runs
|
// extension request was created; otherwise a stale captured location can
|
||||||
// (especially in pageCurl mode where the user may have turned several pages
|
// snap pageCurl back to the previous page after the turn completes.
|
||||||
// since the extension was initiated).
|
|
||||||
let livePageIndex = max(readerView.currentPage, 0)
|
let livePageIndex = max(readerView.currentPage, 0)
|
||||||
rebindVisiblePage(
|
rebindVisiblePage(
|
||||||
to: livePageIndex,
|
to: livePageIndex,
|
||||||
@@ -250,6 +303,19 @@ final class RDEPUBPresentationRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func rebindVisiblePage(to pageIndex: Int, readerView: RDReaderView) {
|
private func rebindVisiblePage(to pageIndex: Int, readerView: RDReaderView) {
|
||||||
|
if pageIndex == readerView.currentPage {
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Reconciliation",
|
||||||
|
"rebindVisiblePage pageUnchanged currentPage=\(readerView.currentPage + 1) displayType=\(readerView.currentDisplayType)"
|
||||||
|
)
|
||||||
|
readerView.reloadPageCountOnly()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Reconciliation",
|
||||||
|
"rebindVisiblePage targetPage=\(pageIndex + 1) currentPage=\(readerView.currentPage + 1) displayType=\(readerView.currentDisplayType) isPageCurlTransitioning=\(readerView.isPageCurlTransitioning)"
|
||||||
|
)
|
||||||
if readerView.currentDisplayType == .pageCurl {
|
if readerView.currentDisplayType == .pageCurl {
|
||||||
if readerView.isPageCurlTransitioning {
|
if readerView.isPageCurlTransitioning {
|
||||||
// Defer the transition until the current page-curl animation completes,
|
// Defer the transition until the current page-curl animation completes,
|
||||||
@@ -257,6 +323,10 @@ final class RDEPUBPresentationRuntime {
|
|||||||
DispatchQueue.main.async { [weak readerView] in
|
DispatchQueue.main.async { [weak readerView] in
|
||||||
guard let readerView, !readerView.isPageCurlTransitioning else { return }
|
guard let readerView, !readerView.isPageCurlTransitioning else { return }
|
||||||
let livePageIndex = max(readerView.currentPage, 0)
|
let livePageIndex = max(readerView.currentPage, 0)
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Reconciliation",
|
||||||
|
"rebindVisiblePage deferredTransition livePage=\(livePageIndex + 1)"
|
||||||
|
)
|
||||||
readerView.transitionToPage(pageNum: livePageIndex, animated: false)
|
readerView.transitionToPage(pageNum: livePageIndex, animated: false)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -276,6 +346,10 @@ final class RDEPUBPresentationRuntime {
|
|||||||
controller: RDEPUBReaderController
|
controller: RDEPUBReaderController
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
guard let targetPageNumber = controller.pageNumber(for: location) else {
|
guard let targetPageNumber = controller.pageNumber(for: location) else {
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Reconciliation",
|
||||||
|
"rebindVisibleLocation failedToResolve locationHref=\(location.href) currentPage=\(readerView.currentPage + 1)"
|
||||||
|
)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,9 +358,18 @@ final class RDEPUBPresentationRuntime {
|
|||||||
forAbsolutePageNumber: targetPageNumber,
|
forAbsolutePageNumber: targetPageNumber,
|
||||||
allowSynchronousLoad: false
|
allowSynchronousLoad: false
|
||||||
) == false {
|
) == false {
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Reconciliation",
|
||||||
|
"rebindVisibleLocation prepareOnDemandBlocked targetPage=\(targetPageNumber) currentPage=\(readerView.currentPage + 1)"
|
||||||
|
)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Reconciliation",
|
||||||
|
"rebindVisibleLocation targetPage=\(targetPageNumber) currentPage=\(readerView.currentPage + 1) href=\(location.href)"
|
||||||
|
)
|
||||||
|
|
||||||
rebindVisiblePage(
|
rebindVisiblePage(
|
||||||
to: max(targetPageNumber - 1, 0),
|
to: max(targetPageNumber - 1, 0),
|
||||||
readerView: readerView
|
readerView: readerView
|
||||||
@@ -353,4 +436,36 @@ final class RDEPUBPresentationRuntime {
|
|||||||
&& candidate.pageMap.totalPages >= existing.pageMap.totalPages
|
&& candidate.pageMap.totalPages >= existing.pageMap.totalPages
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func shouldTrustFullReplaceResolvedPage(
|
||||||
|
_ resolvedTargetPage: Int?,
|
||||||
|
livePageBeforeApply: Int,
|
||||||
|
locationMatchesLivePage: Bool
|
||||||
|
) -> Bool {
|
||||||
|
guard let resolvedTargetPage else { return false }
|
||||||
|
if locationMatchesLivePage {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// The location did not round-trip to the live page in the outgoing map
|
||||||
|
// (stale persisted location or mid-transition), so only follow it when it
|
||||||
|
// stays next to the page the user is actually looking at.
|
||||||
|
return abs(resolvedTargetPage - livePageBeforeApply) <= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private func schedulePendingCommitRetry() {
|
||||||
|
guard pendingCommitRetryWorkItem == nil else { return }
|
||||||
|
|
||||||
|
let workItem = DispatchWorkItem { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.pendingCommitRetryWorkItem = nil
|
||||||
|
self.commitPendingPageMapUpdateIfNeeded()
|
||||||
|
}
|
||||||
|
pendingCommitRetryWorkItem = workItem
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: workItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelPendingCommitRetry() {
|
||||||
|
pendingCommitRetryWorkItem?.cancel()
|
||||||
|
pendingCommitRetryWorkItem = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -163,7 +163,7 @@ final class RDEPUBReaderContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func currentPreferences() -> RDEPUBPreferences {
|
func currentPreferences() -> RDEPUBPreferences {
|
||||||
let safeInsets = controller?.view.safeAreaInsets ?? .zero
|
let safeInsets = RDEPUBSafeArea.resolve(controller?.view.safeAreaInsets)
|
||||||
return configuration.makePreferences(safeAreaInsets: safeInsets)
|
return configuration.makePreferences(safeAreaInsets: safeInsets)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,10 +377,12 @@ final class RDEPUBReaderContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
|
func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
|
||||||
guard let textBook, let publication else { return nil }
|
guard let textBook else { return nil }
|
||||||
let normalizedHref = publication.resourceResolver.normalizedHref(href) ?? href
|
// External text books (e.g. plain .txt) have no publication; their
|
||||||
|
// chapter hrefs are matched verbatim.
|
||||||
|
let normalizedHref = publication?.resourceResolver.normalizedHref(href) ?? href
|
||||||
return textBook.chapters.lazy
|
return textBook.chapters.lazy
|
||||||
.first(where: { (publication.resourceResolver.normalizedHref($0.href) ?? $0.href) == normalizedHref })
|
.first(where: { (publication?.resourceResolver.normalizedHref($0.href) ?? $0.href) == normalizedHref })
|
||||||
.flatMap { textBook.chapterData(for: $0.href) }
|
.flatMap { textBook.chapterData(for: $0.href) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,27 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
enum RDEPUBTextPageLayoutMetrics {
|
||||||
|
|
||||||
|
static let pageNumberTrailingPadding: CGFloat = 4
|
||||||
|
|
||||||
|
static let pageNumberFooterPadding: CGFloat = 8
|
||||||
|
|
||||||
|
static let pageNumberReservedHeight: CGFloat = ceil(UIFont.systemFont(ofSize: 13).lineHeight) + pageNumberFooterPadding
|
||||||
|
|
||||||
|
static func contentInsets(
|
||||||
|
configuration: RDEPUBReaderConfiguration,
|
||||||
|
safeAreaInsets: UIEdgeInsets
|
||||||
|
) -> UIEdgeInsets {
|
||||||
|
let configInsets = configuration.reflowableContentInsets
|
||||||
|
return UIEdgeInsets(
|
||||||
|
top: max(configInsets.top, safeAreaInsets.top),
|
||||||
|
left: max(configInsets.left, safeAreaInsets.left),
|
||||||
|
bottom: max(configInsets.bottom, safeAreaInsets.bottom) + pageNumberReservedHeight,
|
||||||
|
right: max(configInsets.right, safeAreaInsets.right)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final class RDEPUBReaderEnvironment {
|
final class RDEPUBReaderEnvironment {
|
||||||
|
|
||||||
weak var controller: RDEPUBReaderController?
|
weak var controller: RDEPUBReaderController?
|
||||||
@@ -25,7 +47,7 @@ final class RDEPUBReaderEnvironment {
|
|||||||
return RDEPUBNavigatorLayoutContext(
|
return RDEPUBNavigatorLayoutContext(
|
||||||
containerSize: resolvedSize,
|
containerSize: resolvedSize,
|
||||||
pagesPerScreen: readerView?.pagesPerScreen ?? 1,
|
pagesPerScreen: readerView?.pagesPerScreen ?? 1,
|
||||||
safeAreaInsets: controller?.view.safeAreaInsets ?? .zero,
|
safeAreaInsets: RDEPUBSafeArea.resolve(controller?.view.safeAreaInsets),
|
||||||
userInterfaceIdiom: controller?.traitCollection.userInterfaceIdiom ?? .phone,
|
userInterfaceIdiom: controller?.traitCollection.userInterfaceIdiom ?? .phone,
|
||||||
reflowableContentInsets: configuration.reflowableContentInsets
|
reflowableContentInsets: configuration.reflowableContentInsets
|
||||||
)
|
)
|
||||||
@@ -46,11 +68,14 @@ final class RDEPUBReaderEnvironment {
|
|||||||
configuration: RDEPUBReaderConfiguration,
|
configuration: RDEPUBReaderConfiguration,
|
||||||
pageSize: CGSize
|
pageSize: CGSize
|
||||||
) -> RDEPUBTextLayoutConfig {
|
) -> RDEPUBTextLayoutConfig {
|
||||||
let layoutContext = currentLayoutContext(configuration: configuration)
|
let safeAreaInsets = RDEPUBSafeArea.resolve(controller?.view.safeAreaInsets)
|
||||||
return RDEPUBTextLayoutConfig(
|
return RDEPUBTextLayoutConfig(
|
||||||
frameWidth: max(pageSize.width, 1),
|
frameWidth: max(pageSize.width, 1),
|
||||||
frameHeight: max(pageSize.height, 1),
|
frameHeight: max(pageSize.height, 1),
|
||||||
edgeInsets: layoutContext.safeReflowableContentInsets,
|
edgeInsets: RDEPUBTextPageLayoutMetrics.contentInsets(
|
||||||
|
configuration: configuration,
|
||||||
|
safeAreaInsets: safeAreaInsets
|
||||||
|
),
|
||||||
numberOfColumns: configuration.numberOfColumns,
|
numberOfColumns: configuration.numberOfColumns,
|
||||||
columnGap: configuration.columnGap,
|
columnGap: configuration.columnGap,
|
||||||
avoidOrphans: false,
|
avoidOrphans: false,
|
||||||
|
|||||||
@@ -191,12 +191,21 @@ final class RDEPUBReaderPaginationCoordinator {
|
|||||||
runtime: runtime,
|
runtime: runtime,
|
||||||
layoutSnapshot: layoutSnapshot
|
layoutSnapshot: layoutSnapshot
|
||||||
)
|
)
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Pagination",
|
||||||
|
"anchorChapterReady spine=\(runtimeChapter.spineIndex) pages=\(runtimeChapter.pages.count)"
|
||||||
|
)
|
||||||
let initialChapters = self.loadInitialInteractiveRuntimeChapters(
|
let initialChapters = self.loadInitialInteractiveRuntimeChapters(
|
||||||
anchorChapter: runtimeChapter,
|
anchorChapter: runtimeChapter,
|
||||||
publication: publication,
|
publication: publication,
|
||||||
runtime: runtime,
|
runtime: runtime,
|
||||||
layoutSnapshot: layoutSnapshot
|
layoutSnapshot: layoutSnapshot
|
||||||
)
|
)
|
||||||
|
let initialPageCount = initialChapters.reduce(0) { $0 + $1.pages.count }
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Pagination",
|
||||||
|
"initialInteractiveChapters ready count=\(initialChapters.count) pages=\(initialPageCount) spines=\(initialChapters.map(\.spineIndex))"
|
||||||
|
)
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
guard context.paginationToken == token,
|
guard context.paginationToken == token,
|
||||||
@@ -220,9 +229,17 @@ final class RDEPUBReaderPaginationCoordinator {
|
|||||||
parser: parser,
|
parser: parser,
|
||||||
publication: publication
|
publication: publication
|
||||||
)
|
)
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Pagination",
|
||||||
|
"startingMetadataWorker token=\(token.uuidString) anchorSpine=\(runtimeChapter.spineIndex) partialPages=\(partialMap.totalPages) partialChapters=\(partialMap.totalChapters)"
|
||||||
|
)
|
||||||
worker.start(token: token, restoreLocation: restoreLocation)
|
worker.start(token: token, restoreLocation: restoreLocation)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
RDEPUBBackgroundTrace.log(
|
||||||
|
"Pagination",
|
||||||
|
"initialPaginationFailed error=\(String(describing: error)) prioritizedCandidates=\(prioritizedCandidates)"
|
||||||
|
)
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
guard context.paginationToken == token,
|
guard context.paginationToken == token,
|
||||||
context.controller != nil else { return }
|
context.controller != nil else { return }
|
||||||
|
|||||||
@@ -143,6 +143,12 @@ final class RDEPUBReaderSearchCoordinator {
|
|||||||
if let textBook = controller.textBook, let publication = controller.publication {
|
if let textBook = controller.textBook, let publication = controller.publication {
|
||||||
return .textBook(textBook, publication)
|
return .textBook(textBook, publication)
|
||||||
}
|
}
|
||||||
|
// External text books (e.g. plain .txt) carry a textBook but no
|
||||||
|
// publication/parser/bookPageMap; search over the chapter text
|
||||||
|
// directly using hrefs from the textBook itself.
|
||||||
|
if let textBook = controller.textBook {
|
||||||
|
return .externalTextBook(textBook)
|
||||||
|
}
|
||||||
if controller.readerContext.bookPageMap != nil, let publication = controller.publication {
|
if controller.readerContext.bookPageMap != nil, let publication = controller.publication {
|
||||||
return .onDemand(publication)
|
return .onDemand(publication)
|
||||||
}
|
}
|
||||||
@@ -157,6 +163,8 @@ final class RDEPUBReaderSearchCoordinator {
|
|||||||
switch engine {
|
switch engine {
|
||||||
case .textBook(let textBook, let publication):
|
case .textBook(let textBook, let publication):
|
||||||
return RDEPUBTextSearchEngine(textBook: textBook, publication: publication).search(keyword: keyword)
|
return RDEPUBTextSearchEngine(textBook: textBook, publication: publication).search(keyword: keyword)
|
||||||
|
case .externalTextBook(let textBook):
|
||||||
|
return RDEPUBTextSearchEngine.searchWithoutPublication(textBook: textBook, keyword: keyword)
|
||||||
case .onDemand(let publication):
|
case .onDemand(let publication):
|
||||||
return resolvedOnDemandSearchMatches(for: keyword, publication: publication, layoutSnapshot: layoutSnapshot)
|
return resolvedOnDemandSearchMatches(for: keyword, publication: publication, layoutSnapshot: layoutSnapshot)
|
||||||
case .html(let parser, let publication):
|
case .html(let parser, let publication):
|
||||||
@@ -166,6 +174,7 @@ final class RDEPUBReaderSearchCoordinator {
|
|||||||
|
|
||||||
private enum SearchEngineSnapshot {
|
private enum SearchEngineSnapshot {
|
||||||
case textBook(RDEPUBTextBook, RDEPUBPublication)
|
case textBook(RDEPUBTextBook, RDEPUBPublication)
|
||||||
|
case externalTextBook(RDEPUBTextBook)
|
||||||
case onDemand(RDEPUBPublication)
|
case onDemand(RDEPUBPublication)
|
||||||
case html(RDEPUBParser, RDEPUBPublication)
|
case html(RDEPUBParser, RDEPUBPublication)
|
||||||
}
|
}
|
||||||
@@ -350,13 +359,17 @@ final class RDEPUBReaderSearchCoordinator {
|
|||||||
rangeCFI: searchMatch.rangeCFI
|
rangeCFI: searchMatch.rangeCFI
|
||||||
)
|
)
|
||||||
|
|
||||||
if let textBook = controller.textBook, let publication = controller.publication {
|
if let textBook = controller.textBook {
|
||||||
|
if let publication = controller.publication {
|
||||||
return textBook.pageNumber(
|
return textBook.pageNumber(
|
||||||
for: location,
|
for: location,
|
||||||
resolver: publication.resourceResolver,
|
resolver: publication.resourceResolver,
|
||||||
bookIdentifier: controller.currentBookIdentifier
|
bookIdentifier: controller.currentBookIdentifier
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// External text books have no resolver; hrefs match verbatim.
|
||||||
|
return textBook.chapterData(for: location.href)?.pageNumber(for: location)
|
||||||
|
}
|
||||||
|
|
||||||
return controller.readingSession?.pageIndex(
|
return controller.readingSession?.pageIndex(
|
||||||
for: location,
|
for: location,
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
|||||||
showsTableOfContents: Bool = true,
|
showsTableOfContents: Bool = true,
|
||||||
allowsHighlights: Bool = true,
|
allowsHighlights: Bool = true,
|
||||||
showsSettingsPanel: Bool = true,
|
showsSettingsPanel: Bool = true,
|
||||||
reflowableContentInsets: UIEdgeInsets = UIEdgeInsets(top: 40, left: 16, bottom: 40, right: 16),
|
reflowableContentInsets: UIEdgeInsets = RDEPUBSafeArea.defaultReflowableContentInsets(),
|
||||||
fixedContentInset: UIEdgeInsets = .zero,
|
fixedContentInset: UIEdgeInsets = .zero,
|
||||||
theme: RDEPUBReaderTheme = .light,
|
theme: RDEPUBReaderTheme = .light,
|
||||||
darkImageAdjustmentEnabled: Bool = true,
|
darkImageAdjustmentEnabled: Bool = true,
|
||||||
@@ -172,11 +172,13 @@ extension RDEPUBReaderConfiguration {
|
|||||||
func makePreferences(safeAreaInsets: UIEdgeInsets = .zero) -> RDEPUBPreferences {
|
func makePreferences(safeAreaInsets: UIEdgeInsets = .zero) -> RDEPUBPreferences {
|
||||||
// Use the larger of reflowableContentInsets and safeAreaInsets for each edge
|
// Use the larger of reflowableContentInsets and safeAreaInsets for each edge
|
||||||
// to prevent content from being hidden under Dynamic Island / home indicator.
|
// to prevent content from being hidden under Dynamic Island / home indicator.
|
||||||
|
// Falls back to the key-window safe area when the caller has no laid-out view.
|
||||||
|
let resolvedSafeAreaInsets = RDEPUBSafeArea.resolve(safeAreaInsets)
|
||||||
let safeInsets = UIEdgeInsets(
|
let safeInsets = UIEdgeInsets(
|
||||||
top: max(reflowableContentInsets.top, safeAreaInsets.top),
|
top: max(reflowableContentInsets.top, resolvedSafeAreaInsets.top),
|
||||||
left: max(reflowableContentInsets.left, safeAreaInsets.left),
|
left: max(reflowableContentInsets.left, resolvedSafeAreaInsets.left),
|
||||||
bottom: max(reflowableContentInsets.bottom, safeAreaInsets.bottom),
|
bottom: max(reflowableContentInsets.bottom, resolvedSafeAreaInsets.bottom),
|
||||||
right: max(reflowableContentInsets.right, safeAreaInsets.right)
|
right: max(reflowableContentInsets.right, resolvedSafeAreaInsets.right)
|
||||||
)
|
)
|
||||||
return RDEPUBPreferences(
|
return RDEPUBPreferences(
|
||||||
fontSize: fontSize,
|
fontSize: fontSize,
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
#if canImport(DTCoreText)
|
||||||
|
import DTCoreText
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// Chapter-level shared display content and layouter
|
||||||
|
/// (LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md P1-1 / P1-3).
|
||||||
|
///
|
||||||
|
/// One entry holds the chapter-length display string (theme text color and
|
||||||
|
/// dark-image adjustment injected once over the full chapter at build time)
|
||||||
|
/// plus the DTCoreTextLayouter wrapping its framesetter. Page views reference
|
||||||
|
/// the shared string and request per-page layout frames; they must never
|
||||||
|
/// mutate the shared string — the framesetter and any live layout frame hold
|
||||||
|
/// it. Page-level decoration (highlights, search, selection) is drawn by the
|
||||||
|
/// overlay views instead.
|
||||||
|
///
|
||||||
|
/// Main-thread only.
|
||||||
|
final class RDEPUBChapterDisplayContentCache {
|
||||||
|
|
||||||
|
#if canImport(DTCoreText)
|
||||||
|
|
||||||
|
struct Entry {
|
||||||
|
let content: NSAttributedString
|
||||||
|
let layouter: DTCoreTextLayouter?
|
||||||
|
fileprivate let signature: Signature
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate struct Signature: Equatable {
|
||||||
|
let chapterContentID: ObjectIdentifier
|
||||||
|
let contentLength: Int
|
||||||
|
let textColor: UIColor
|
||||||
|
let backgroundColor: UIColor
|
||||||
|
let darkImageAdjustmentEnabled: Bool
|
||||||
|
let darkImageBlendRatio: CGFloat
|
||||||
|
|
||||||
|
init(page: RDEPUBTextPage, configuration: RDEPUBReaderConfiguration) {
|
||||||
|
chapterContentID = ObjectIdentifier(page.chapterContent)
|
||||||
|
contentLength = page.chapterContent.length
|
||||||
|
textColor = configuration.theme.contentTextColor
|
||||||
|
backgroundColor = configuration.theme.contentBackgroundColor
|
||||||
|
darkImageAdjustmentEnabled = configuration.darkImageAdjustmentEnabled
|
||||||
|
darkImageBlendRatio = configuration.darkImageBlendRatio
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current chapter plus one adjacent chapter during page-curl/scroll
|
||||||
|
/// transitions across a chapter boundary.
|
||||||
|
private static let capacity = 2
|
||||||
|
|
||||||
|
private var entries: [String: Entry] = [:]
|
||||||
|
|
||||||
|
private var accessOrder: [String] = []
|
||||||
|
|
||||||
|
func entry(for page: RDEPUBTextPage, configuration: RDEPUBReaderConfiguration) -> Entry {
|
||||||
|
dispatchPrecondition(condition: .onQueue(.main))
|
||||||
|
let signature = Signature(page: page, configuration: configuration)
|
||||||
|
if let cached = entries[page.href], cached.signature == signature {
|
||||||
|
touch(page.href)
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
let entry = Self.buildEntry(page: page, configuration: configuration, signature: signature)
|
||||||
|
entries[page.href] = entry
|
||||||
|
touch(page.href)
|
||||||
|
evictIfNeeded()
|
||||||
|
RDEPUBMemoryProbe.log("displayContentBuilt href=\(page.href) length=\(entry.content.length)")
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeAll() {
|
||||||
|
entries.removeAll()
|
||||||
|
accessOrder.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func touch(_ href: String) {
|
||||||
|
accessOrder.removeAll { $0 == href }
|
||||||
|
accessOrder.append(href)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func evictIfNeeded() {
|
||||||
|
while accessOrder.count > Self.capacity {
|
||||||
|
let evicted = accessOrder.removeFirst()
|
||||||
|
entries.removeValue(forKey: evicted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func buildEntry(
|
||||||
|
page: RDEPUBTextPage,
|
||||||
|
configuration: RDEPUBReaderConfiguration,
|
||||||
|
signature: Signature
|
||||||
|
) -> Entry {
|
||||||
|
let content = NSMutableAttributedString(attributedString: page.chapterContent)
|
||||||
|
let fullRange = NSRange(location: 0, length: content.length)
|
||||||
|
_ = RDEPUBDarkImageAdjuster.adjustIfNeeded(
|
||||||
|
content,
|
||||||
|
in: fullRange,
|
||||||
|
configuration: configuration
|
||||||
|
)
|
||||||
|
content.addAttribute(
|
||||||
|
.foregroundColor,
|
||||||
|
value: configuration.theme.contentTextColor,
|
||||||
|
range: fullRange
|
||||||
|
)
|
||||||
|
let layouter = DTCoreTextLayouter(attributedString: content)
|
||||||
|
layouter?.shouldCacheLayoutFrames = false
|
||||||
|
return Entry(content: content, layouter: layouter, signature: signature)
|
||||||
|
}
|
||||||
|
|
||||||
|
#else
|
||||||
|
|
||||||
|
func removeAll() {}
|
||||||
|
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ enum RDEPUBDarkImageAdjuster {
|
|||||||
|
|
||||||
static func adjustIfNeeded(
|
static func adjustIfNeeded(
|
||||||
_ content: NSMutableAttributedString,
|
_ content: NSMutableAttributedString,
|
||||||
|
in range: NSRange? = nil,
|
||||||
configuration: RDEPUBReaderConfiguration
|
configuration: RDEPUBReaderConfiguration
|
||||||
) -> NSMutableAttributedString {
|
) -> NSMutableAttributedString {
|
||||||
guard configuration.darkImageAdjustmentEnabled,
|
guard configuration.darkImageAdjustmentEnabled,
|
||||||
@@ -34,7 +35,8 @@ enum RDEPUBDarkImageAdjuster {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let fullRange = NSRange(location: 0, length: content.length)
|
let fullRange = NSRange(location: 0, length: content.length)
|
||||||
content.enumerateAttribute(.attachment, in: fullRange) { value, range, _ in
|
let targetRange = range.map { NSIntersectionRange($0, fullRange) } ?? fullRange
|
||||||
|
content.enumerateAttribute(.attachment, in: targetRange) { value, range, _ in
|
||||||
guard let attachment = value as? DTImageTextAttachment,
|
guard let attachment = value as? DTImageTextAttachment,
|
||||||
!isCoverAttachment(attachment),
|
!isCoverAttachment(attachment),
|
||||||
let image = attachment.image,
|
let image = attachment.image,
|
||||||
|
|||||||
@@ -44,10 +44,12 @@ final class RDEPUBPageInteractionController {
|
|||||||
x: point.x - line.baselineOrigin.x,
|
x: point.x - line.baselineOrigin.x,
|
||||||
y: point.y - line.baselineOrigin.y
|
y: point.y - line.baselineOrigin.y
|
||||||
)
|
)
|
||||||
|
// The layout frame is built in chapter context, so DTCoreText string
|
||||||
|
// indices are chapter-absolute already.
|
||||||
let idx = dtLine.stringIndex(forPosition: relativePoint)
|
let idx = dtLine.stringIndex(forPosition: relativePoint)
|
||||||
guard idx != NSNotFound, idx >= 0 else { return nil }
|
guard idx != NSNotFound, idx >= 0 else { return nil }
|
||||||
return normalizedIndex(
|
return normalizedIndex(
|
||||||
idx + pageOffset(for: snapshot),
|
idx,
|
||||||
lineRange: line.stringRange,
|
lineRange: line.stringRange,
|
||||||
pageRange: snapshot.pageContentRange
|
pageRange: snapshot.pageContentRange
|
||||||
)
|
)
|
||||||
@@ -74,10 +76,9 @@ final class RDEPUBPageInteractionController {
|
|||||||
|
|
||||||
#if canImport(DTCoreText)
|
#if canImport(DTCoreText)
|
||||||
guard let dtLine = dtLineContaining(range: line.stringRange) else { continue }
|
guard let dtLine = dtLineContaining(range: line.stringRange) else { continue }
|
||||||
let pageOffset = pageOffset(for: snapshot)
|
let startX = dtLine.offset(forStringIndex: overlap.location)
|
||||||
let startX = dtLine.offset(forStringIndex: overlap.location - pageOffset)
|
|
||||||
let endIdx = overlap.location + overlap.length
|
let endIdx = overlap.location + overlap.length
|
||||||
let endX = dtLine.offset(forStringIndex: endIdx - pageOffset)
|
let endX = dtLine.offset(forStringIndex: endIdx)
|
||||||
#else
|
#else
|
||||||
let startX: CGFloat = 0
|
let startX: CGFloat = 0
|
||||||
let endX: CGFloat = line.frame.width
|
let endX: CGFloat = line.frame.width
|
||||||
@@ -133,7 +134,7 @@ final class RDEPUBPageInteractionController {
|
|||||||
|
|
||||||
#if canImport(DTCoreText)
|
#if canImport(DTCoreText)
|
||||||
guard let dtLine = dtLineContaining(range: line.stringRange) else { return nil }
|
guard let dtLine = dtLineContaining(range: line.stringRange) else { return nil }
|
||||||
let offsetX = dtLine.offset(forStringIndex: index - pageOffset(for: snapshot))
|
let offsetX = dtLine.offset(forStringIndex: index)
|
||||||
#else
|
#else
|
||||||
let offsetX: CGFloat = 0
|
let offsetX: CGFloat = 0
|
||||||
#endif
|
#endif
|
||||||
@@ -183,16 +184,11 @@ final class RDEPUBPageInteractionController {
|
|||||||
#if canImport(DTCoreText)
|
#if canImport(DTCoreText)
|
||||||
|
|
||||||
private func dtLineContaining(range: NSRange) -> DTCoreTextLayoutLine? {
|
private func dtLineContaining(range: NSRange) -> DTCoreTextLayoutLine? {
|
||||||
guard let dtLayoutFrame, let snapshot else { return nil }
|
guard let dtLayoutFrame else { return nil }
|
||||||
let localLocation = max(range.location - pageOffset(for: snapshot), 0)
|
return dtLayoutFrame.lineContaining(UInt(max(range.location, 0)))
|
||||||
return dtLayoutFrame.lineContaining(UInt(localLocation))
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private func pageOffset(for snapshot: RDEPUBPageLayoutSnapshot) -> Int {
|
|
||||||
snapshot.page.pageStartOffset
|
|
||||||
}
|
|
||||||
|
|
||||||
private func mergeAdjacentRects(_ rects: [CGRect]) -> [CGRect] {
|
private func mergeAdjacentRects(_ rects: [CGRect]) -> [CGRect] {
|
||||||
guard rects.count > 1 else { return rects }
|
guard rects.count > 1 else { return rects }
|
||||||
|
|
||||||
|
|||||||
@@ -130,13 +130,14 @@ struct RDEPUBPageLayoutSnapshot {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The layout frame is produced in chapter context, so DTCoreText
|
||||||
|
// string ranges are already chapter-absolute.
|
||||||
var lines: [RDEPUBPageLine] = []
|
var lines: [RDEPUBPageLine] = []
|
||||||
var runs: [RDEPUBPageRun] = []
|
var runs: [RDEPUBPageRun] = []
|
||||||
var attachments: [RDEPUBPageAttachment] = []
|
var attachments: [RDEPUBPageAttachment] = []
|
||||||
let pageOffset = page.pageStartOffset
|
|
||||||
|
|
||||||
for dtLine in dtLines {
|
for dtLine in dtLines {
|
||||||
let lineRange = offset(dtLine.stringRange(), by: pageOffset)
|
let lineRange = dtLine.stringRange()
|
||||||
let line = RDEPUBPageLine(
|
let line = RDEPUBPageLine(
|
||||||
stringRange: lineRange,
|
stringRange: lineRange,
|
||||||
frame: dtLine.frame,
|
frame: dtLine.frame,
|
||||||
@@ -149,7 +150,7 @@ struct RDEPUBPageLayoutSnapshot {
|
|||||||
|
|
||||||
if let glyphRuns = dtLine.glyphRuns as? [DTCoreTextGlyphRun] {
|
if let glyphRuns = dtLine.glyphRuns as? [DTCoreTextGlyphRun] {
|
||||||
for run in glyphRuns {
|
for run in glyphRuns {
|
||||||
let runRange = offset(run.stringRange(), by: pageOffset)
|
let runRange = run.stringRange()
|
||||||
let isAttachment = run.attachment != nil
|
let isAttachment = run.attachment != nil
|
||||||
runs.append(
|
runs.append(
|
||||||
RDEPUBPageRun(
|
RDEPUBPageRun(
|
||||||
@@ -177,7 +178,7 @@ struct RDEPUBPageLayoutSnapshot {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let visibleRange = offset(layoutFrame.visibleStringRange(), by: pageOffset)
|
let visibleRange = layoutFrame.visibleStringRange()
|
||||||
|
|
||||||
return RDEPUBPageLayoutSnapshot(
|
return RDEPUBPageLayoutSnapshot(
|
||||||
page: page,
|
page: page,
|
||||||
@@ -219,11 +220,5 @@ struct RDEPUBPageLayoutSnapshot {
|
|||||||
return RDEPUBAttachmentNormalizer.attachmentKind(for: attributes)
|
return RDEPUBAttachmentNormalizer.attachmentKind(for: attributes)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func offset(_ range: NSRange, by offset: Int) -> NSRange {
|
|
||||||
guard range.location != NSNotFound else {
|
|
||||||
return range
|
|
||||||
}
|
|
||||||
return NSRange(location: range.location + offset, length: range.length)
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,9 +87,17 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
return view
|
return view
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
/// Chapter-level shared display string owned by the display cache.
|
||||||
|
/// Read-only from the page's perspective: theme color and dark-image
|
||||||
|
/// adjustment are baked in at build time; page decoration goes through
|
||||||
|
/// the overlay views.
|
||||||
private var coreTextDisplayContent: NSAttributedString?
|
private var coreTextDisplayContent: NSAttributedString?
|
||||||
|
|
||||||
private var coreTextDisplayRange: NSRange?
|
private var coreTextDisplayRange: NSRange?
|
||||||
|
|
||||||
|
/// Shared chapter-level layouter (owned by the display cache); layout
|
||||||
|
/// frames are recomputed per page range and bounds change.
|
||||||
|
private var coreTextLayouter: DTCoreTextLayouter?
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private let interactionController = RDEPUBPageInteractionController()
|
private let interactionController = RDEPUBPageInteractionController()
|
||||||
@@ -126,6 +134,19 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
return spinner
|
return spinner
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
/// Debug-only outline of the readable content area (bounds inset by contentInsets).
|
||||||
|
private let debugContentAreaBorderView: UIView = {
|
||||||
|
let view = UIView()
|
||||||
|
view.isUserInteractionEnabled = false
|
||||||
|
view.backgroundColor = .clear
|
||||||
|
view.layer.borderColor = UIColor.systemRed.withAlphaComponent(0.6).cgColor
|
||||||
|
view.layer.borderWidth = 1
|
||||||
|
view.accessibilityIdentifier = "epub.reader.debug.contentAreaBorder"
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
#endif
|
||||||
|
|
||||||
private lazy var longPressGestureRecognizer: UILongPressGestureRecognizer = {
|
private lazy var longPressGestureRecognizer: UILongPressGestureRecognizer = {
|
||||||
let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
|
let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
|
||||||
gesture.minimumPressDuration = 0.5
|
gesture.minimumPressDuration = 0.5
|
||||||
@@ -243,6 +264,9 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
addSubview(pageNumberLabel)
|
addSubview(pageNumberLabel)
|
||||||
addSubview(loadingSpinner)
|
addSubview(loadingSpinner)
|
||||||
addSubview(selectionLoupeView)
|
addSubview(selectionLoupeView)
|
||||||
|
#if DEBUG
|
||||||
|
addSubview(debugContentAreaBorderView)
|
||||||
|
#endif
|
||||||
addGestureRecognizer(longPressGestureRecognizer)
|
addGestureRecognizer(longPressGestureRecognizer)
|
||||||
addGestureRecognizer(panGestureRecognizer)
|
addGestureRecognizer(panGestureRecognizer)
|
||||||
addGestureRecognizer(tapGestureRecognizer)
|
addGestureRecognizer(tapGestureRecognizer)
|
||||||
@@ -297,7 +321,21 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
}
|
}
|
||||||
|
|
||||||
var shouldAvoidReaderPageCaching: Bool {
|
var shouldAvoidReaderPageCaching: Bool {
|
||||||
currentPage == nil || loadingSpinner.isAnimating
|
#if canImport(DTCoreText)
|
||||||
|
// In-context text layout keeps a chapter-length attributed copy and
|
||||||
|
// layouter alive for the visible page. Avoid reader-level page
|
||||||
|
// caching so preloading does not retain several full-chapter
|
||||||
|
// display copies at once.
|
||||||
|
// Do NOT relax this until chapter-level shared display content
|
||||||
|
// lands (LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md P1-1): cached
|
||||||
|
// pages would each pin their own full-chapter copy again.
|
||||||
|
return currentPage == nil
|
||||||
|
|| loadingSpinner.isAnimating
|
||||||
|
|| coreTextDisplayContent != nil
|
||||||
|
#else
|
||||||
|
currentPage == nil
|
||||||
|
|| loadingSpinner.isAnimating
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private var hasInteractiveTextContent: Bool {
|
private var hasInteractiveTextContent: Bool {
|
||||||
@@ -338,6 +376,9 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
coverImageView.frame = bounds.inset(by: contentInsets)
|
coverImageView.frame = bounds.inset(by: contentInsets)
|
||||||
|
|
||||||
let contentRect = bounds.inset(by: contentInsets)
|
let contentRect = bounds.inset(by: contentInsets)
|
||||||
|
#if DEBUG
|
||||||
|
debugContentAreaBorderView.frame = contentRect
|
||||||
|
#endif
|
||||||
let labelSize = pageNumberLabel.sizeThatFits(
|
let labelSize = pageNumberLabel.sizeThatFits(
|
||||||
CGSize(width: contentRect.width, height: Self.pageNumberReservedHeight)
|
CGSize(width: contentRect.width, height: Self.pageNumberReservedHeight)
|
||||||
)
|
)
|
||||||
@@ -360,7 +401,8 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
chapterCFIMap: RDEPUBCFIMap? = nil,
|
chapterCFIMap: RDEPUBCFIMap? = nil,
|
||||||
chapterFragmentOffsets: [String: Int] = [:],
|
chapterFragmentOffsets: [String: Int] = [:],
|
||||||
highlights: [RDEPUBHighlight] = [],
|
highlights: [RDEPUBHighlight] = [],
|
||||||
searchState: RDEPUBSearchState? = nil
|
searchState: RDEPUBSearchState? = nil,
|
||||||
|
displayCache: RDEPUBChapterDisplayContentCache? = nil
|
||||||
) {
|
) {
|
||||||
loadingSpinner.stopAnimating()
|
loadingSpinner.stopAnimating()
|
||||||
currentPage = page
|
currentPage = page
|
||||||
@@ -374,7 +416,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
selectionController.clearSelection(renderView: coreTextRenderView)
|
selectionController.clearSelection(renderView: coreTextRenderView)
|
||||||
contentInsets = Self.safeContentInsets(
|
contentInsets = Self.safeContentInsets(
|
||||||
configuration: configuration,
|
configuration: configuration,
|
||||||
safeAreaInsets: safeAreaInsets
|
safeAreaInsets: RDEPUBSafeArea.resolve(safeAreaInsets)
|
||||||
)
|
)
|
||||||
backgroundColor = configuration.theme.contentBackgroundColor
|
backgroundColor = configuration.theme.contentBackgroundColor
|
||||||
pageNumberLabel.textColor = configuration.theme.contentTextColor
|
pageNumberLabel.textColor = configuration.theme.contentTextColor
|
||||||
@@ -386,6 +428,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
coreTextContentView.layoutFrame = nil
|
coreTextContentView.layoutFrame = nil
|
||||||
coreTextDisplayContent = nil
|
coreTextDisplayContent = nil
|
||||||
coreTextDisplayRange = nil
|
coreTextDisplayRange = nil
|
||||||
|
coreTextLayouter = nil
|
||||||
#endif
|
#endif
|
||||||
updateStaticGestureAvailability()
|
updateStaticGestureAvailability()
|
||||||
updateSelectionPanAvailability()
|
updateSelectionPanAvailability()
|
||||||
@@ -399,24 +442,26 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
coverImageView.image = nil
|
coverImageView.image = nil
|
||||||
|
|
||||||
#if canImport(DTCoreText)
|
#if canImport(DTCoreText)
|
||||||
let displayContent = RDEPUBDarkImageAdjuster.adjustIfNeeded(
|
// In-context display: lay out the full chapter string and render only
|
||||||
normalizedPageContent(from: page),
|
// the page's range. CTTypesetter line breaks are context-sensitive, so
|
||||||
configuration: configuration
|
// re-wrapping a page substring can break ±1 character away from the
|
||||||
|
// paginator's line ends (lone characters spilling onto the last line);
|
||||||
|
// laying out the same chapter string the paginator used cannot.
|
||||||
|
// String indices in the layout frame are therefore chapter-absolute.
|
||||||
|
// The chapter string and layouter are shared per chapter via the
|
||||||
|
// display cache; this view must not mutate the shared string.
|
||||||
|
let display = (displayCache ?? RDEPUBChapterDisplayContentCache())
|
||||||
|
.entry(for: page, configuration: configuration)
|
||||||
|
let pageRange = NSIntersectionRange(
|
||||||
|
page.contentRange,
|
||||||
|
NSRange(location: 0, length: display.content.length)
|
||||||
)
|
)
|
||||||
let fullRange = NSRange(location: 0, length: displayContent.length)
|
|
||||||
displayContent.addAttribute(
|
|
||||||
.foregroundColor,
|
|
||||||
value: configuration.theme.contentTextColor,
|
|
||||||
range: fullRange
|
|
||||||
)
|
|
||||||
|
|
||||||
applyHighlightsToContent(displayContent, highlights: highlights, page: page)
|
|
||||||
coreTextContentView.isHidden = false
|
coreTextContentView.isHidden = false
|
||||||
coreTextContentView.backgroundColor = .clear
|
coreTextContentView.backgroundColor = .clear
|
||||||
coreTextContentView.accessibilityIdentifier = "epub.reader.selection.text"
|
coreTextContentView.accessibilityIdentifier = "epub.reader.selection.text"
|
||||||
coreTextDisplayContent = displayContent
|
coreTextDisplayContent = display.content
|
||||||
coreTextDisplayRange = NSRange(location: 0, length: displayContent.length)
|
coreTextDisplayRange = pageRange
|
||||||
coreTextContentView.attributedDisplayContent = displayContent
|
coreTextLayouter = display.layouter
|
||||||
updateCoreTextLayoutFrameIfNeeded()
|
updateCoreTextLayoutFrameIfNeeded()
|
||||||
#else
|
#else
|
||||||
let selectionContent = normalizedPageContent(from: page)
|
let selectionContent = normalizedPageContent(from: page)
|
||||||
@@ -459,7 +504,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
selectionController.clearSelection(renderView: coreTextRenderView)
|
selectionController.clearSelection(renderView: coreTextRenderView)
|
||||||
contentInsets = Self.safeContentInsets(
|
contentInsets = Self.safeContentInsets(
|
||||||
configuration: configuration,
|
configuration: configuration,
|
||||||
safeAreaInsets: safeAreaInsets
|
safeAreaInsets: RDEPUBSafeArea.resolve(safeAreaInsets)
|
||||||
)
|
)
|
||||||
backgroundColor = configuration.theme.contentBackgroundColor
|
backgroundColor = configuration.theme.contentBackgroundColor
|
||||||
pageNumberLabel.textColor = configuration.theme.contentTextColor
|
pageNumberLabel.textColor = configuration.theme.contentTextColor
|
||||||
@@ -470,10 +515,10 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
#if canImport(DTCoreText)
|
#if canImport(DTCoreText)
|
||||||
coreTextContentView.isHidden = false
|
coreTextContentView.isHidden = false
|
||||||
coreTextContentView.backgroundColor = .clear
|
coreTextContentView.backgroundColor = .clear
|
||||||
coreTextContentView.attributedDisplayContent = nil
|
|
||||||
coreTextContentView.layoutFrame = nil
|
coreTextContentView.layoutFrame = nil
|
||||||
coreTextDisplayContent = nil
|
coreTextDisplayContent = nil
|
||||||
coreTextDisplayRange = nil
|
coreTextDisplayRange = nil
|
||||||
|
coreTextLayouter = nil
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
overlayView.clearSelection()
|
overlayView.clearSelection()
|
||||||
@@ -521,6 +566,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
coreTextContentView.layoutFrame = nil
|
coreTextContentView.layoutFrame = nil
|
||||||
coreTextDisplayContent = nil
|
coreTextDisplayContent = nil
|
||||||
coreTextDisplayRange = nil
|
coreTextDisplayRange = nil
|
||||||
|
coreTextLayouter = nil
|
||||||
#endif
|
#endif
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -562,30 +608,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
return image(from: attachmentValue)
|
return image(from: attachmentValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyHighlightsToContent(
|
|
||||||
_ content: NSMutableAttributedString,
|
|
||||||
highlights: [RDEPUBHighlight],
|
|
||||||
page: RDEPUBTextPage
|
|
||||||
) {
|
|
||||||
for highlight in highlights {
|
|
||||||
guard let rangeInfo = highlight.rangeInfo,
|
|
||||||
let info = RDEPUBTextOffsetRangeInfo.decode(from: rangeInfo),
|
|
||||||
let absoluteRange = info.nsRange else { continue }
|
|
||||||
let overlap = NSIntersectionRange(absoluteRange, page.contentRange)
|
|
||||||
guard overlap.length > 0 else { continue }
|
|
||||||
let relativeRange = NSRange(location: overlap.location - page.pageStartOffset, length: overlap.length)
|
|
||||||
guard relativeRange.location >= 0,
|
|
||||||
relativeRange.location + relativeRange.length <= content.length else { continue }
|
|
||||||
|
|
||||||
switch highlight.style {
|
|
||||||
case .highlight:
|
|
||||||
content.addAttribute(kRDEPUBHighlightAttributeName, value: highlight.uiColor, range: relativeRange)
|
|
||||||
case .underline:
|
|
||||||
content.addAttribute(kRDEPUBUnderlineAttributeName, value: highlight.uiColor, range: relativeRange)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString {
|
private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString {
|
||||||
let content = NSMutableAttributedString(attributedString: page.content)
|
let content = NSMutableAttributedString(attributedString: page.content)
|
||||||
guard content.length > 0 else { return content }
|
guard content.length > 0 else { return content }
|
||||||
@@ -679,22 +701,38 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let layouter = DTCoreTextLayouter(attributedString: displayContent) else {
|
guard let layouter = coreTextLayouter else {
|
||||||
coreTextContentView.layoutFrame = nil
|
coreTextContentView.layoutFrame = nil
|
||||||
interactionController.configure(layoutFrame: nil, page: page)
|
interactionController.configure(layoutFrame: nil, page: page)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
layouter.shouldCacheLayoutFrames = false
|
|
||||||
let layoutFrame = layouter.layoutFrame(with: coreTextContentView.bounds, range: displayRange)
|
let layoutFrame = layouter.layoutFrame(with: coreTextContentView.bounds, range: displayRange)
|
||||||
|
if let layoutFrame {
|
||||||
|
RDEPUBTextPageBoundaryValidator.validate(
|
||||||
|
page: page,
|
||||||
|
displayLayoutFrame: layoutFrame,
|
||||||
|
displayContent: displayContent,
|
||||||
|
displayBounds: coreTextContentView.bounds
|
||||||
|
)
|
||||||
|
RDEPUBTextPageVerticalJustifier.justify(
|
||||||
|
layoutFrame,
|
||||||
|
contentHeight: coreTextContentView.bounds.height,
|
||||||
|
isChapterLastPage: page.pageIndexInChapter >= page.totalPagesInChapter - 1,
|
||||||
|
pixelScale: window?.screen.scale ?? UIScreen.main.scale
|
||||||
|
)
|
||||||
|
}
|
||||||
coreTextContentView.layoutFrame = layoutFrame
|
coreTextContentView.layoutFrame = layoutFrame
|
||||||
interactionController.configure(layoutFrame: layoutFrame, page: page)
|
interactionController.configure(layoutFrame: layoutFrame, page: page)
|
||||||
overlayView.updateSnapshot(interactionController.snapshot)
|
overlayView.updateSnapshot(interactionController.snapshot)
|
||||||
backgroundOverlayView.updateSnapshot(interactionController.snapshot)
|
backgroundOverlayView.updateSnapshot(interactionController.snapshot)
|
||||||
|
|
||||||
if let page = currentPage {
|
if let page = currentPage {
|
||||||
|
// Highlights/underlines and search are all drawn as overlay
|
||||||
|
// decorations; the shared chapter display string carries no
|
||||||
|
// page-level annotation attributes.
|
||||||
let (bgDecorations, fgDecorations) = overlayView.buildDecorations(
|
let (bgDecorations, fgDecorations) = overlayView.buildDecorations(
|
||||||
page: page,
|
page: page,
|
||||||
highlights: [],
|
highlights: currentHighlights,
|
||||||
searchState: currentSearchState,
|
searchState: currentSearchState,
|
||||||
interactionController: interactionController
|
interactionController: interactionController
|
||||||
)
|
)
|
||||||
@@ -826,10 +864,15 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
!targetRect.isEmpty else {
|
!targetRect.isEmpty else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let resolvedTargetRect = resolvedSelectionMenuTargetRect(from: targetRect)
|
||||||
if #available(iOS 16.0, *), let interaction = _editMenuInteraction as? UIEditMenuInteraction {
|
if #available(iOS 16.0, *), let interaction = _editMenuInteraction as? UIEditMenuInteraction {
|
||||||
let anchor = CGPoint(x: targetRect.midX, y: targetRect.midY)
|
becomeFirstResponder()
|
||||||
|
let anchor = CGPoint(x: resolvedTargetRect.midX, y: resolvedTargetRect.midY)
|
||||||
let config = UIEditMenuConfiguration(identifier: "SelectionMenu", sourcePoint: anchor)
|
let config = UIEditMenuConfiguration(identifier: "SelectionMenu", sourcePoint: anchor)
|
||||||
interaction.presentEditMenu(with: config)
|
DispatchQueue.main.async { [weak self, weak interaction] in
|
||||||
|
guard let self, self.currentSelection != nil else { return }
|
||||||
|
interaction?.presentEditMenu(with: config)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
becomeFirstResponder()
|
becomeFirstResponder()
|
||||||
let menuController = UIMenuController.shared
|
let menuController = UIMenuController.shared
|
||||||
@@ -838,7 +881,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
UIMenuItem(title: "高亮", action: #selector(rd_highlight(_:))),
|
UIMenuItem(title: "高亮", action: #selector(rd_highlight(_:))),
|
||||||
UIMenuItem(title: "批注", action: #selector(rd_annotate(_:)))
|
UIMenuItem(title: "批注", action: #selector(rd_annotate(_:)))
|
||||||
]
|
]
|
||||||
menuController.setTargetRect(targetRect, in: coreTextRenderView ?? self)
|
menuController.setTargetRect(resolvedTargetRect, in: self)
|
||||||
menuController.setMenuVisible(true, animated: true)
|
menuController.setMenuVisible(true, animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -872,6 +915,11 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func resolvedSelectionMenuTargetRect(from rect: CGRect) -> CGRect {
|
||||||
|
guard let renderView = coreTextRenderView else { return rect }
|
||||||
|
return convert(rect, from: renderView)
|
||||||
|
}
|
||||||
|
|
||||||
private func updateAccessibilityDecorationSummary() {
|
private func updateAccessibilityDecorationSummary() {
|
||||||
#if canImport(DTCoreText)
|
#if canImport(DTCoreText)
|
||||||
coreTextContentView.accessibilityValue = [
|
coreTextContentView.accessibilityValue = [
|
||||||
@@ -882,14 +930,17 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func highlight(at point: CGPoint) -> RDEPUBHighlight? {
|
private func highlight(at point: CGPoint) -> RDEPUBHighlight? {
|
||||||
guard currentPage != nil else { return nil }
|
guard let page = currentPage else { return nil }
|
||||||
let absoluteRange = backgroundOverlayView.absoluteRange(at: point) ?? overlayView.absoluteRange(at: point)
|
|
||||||
guard let absoluteRange else { return nil }
|
|
||||||
let matches = currentHighlights.filter { highlight in
|
let matches = currentHighlights.filter { highlight in
|
||||||
guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else {
|
guard highlight.location.href == page.href,
|
||||||
|
let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return NSIntersectionRange(range, absoluteRange).length > 0
|
let overlap = NSIntersectionRange(range, page.contentRange)
|
||||||
|
guard overlap.length > 0 else { return false }
|
||||||
|
return interactionController.selectionRects(for: overlap).contains {
|
||||||
|
$0.insetBy(dx: -4, dy: -4).contains(point)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return matches.sorted { lhs, rhs in
|
return matches.sorted { lhs, rhs in
|
||||||
let lhsRange = RDEPUBTextOffsetRangeInfo.decode(from: lhs.rangeInfo)?.nsRange?.length ?? .max
|
let lhsRange = RDEPUBTextOffsetRangeInfo.decode(from: lhs.rangeInfo)?.nsRange?.length ?? .max
|
||||||
@@ -1071,8 +1122,9 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
extension RDEPUBTextContentView: UIEditMenuInteractionDelegate {
|
extension RDEPUBTextContentView: UIEditMenuInteractionDelegate {
|
||||||
func editMenuInteraction(
|
func editMenuInteraction(
|
||||||
_ interaction: UIEditMenuInteraction,
|
_ interaction: UIEditMenuInteraction,
|
||||||
menuFor configuration: UIEditMenuConfiguration
|
menuFor configuration: UIEditMenuConfiguration,
|
||||||
) -> UIMenu {
|
suggestedActions: [UIMenuElement]
|
||||||
|
) -> UIMenu? {
|
||||||
UIMenu(children: [
|
UIMenu(children: [
|
||||||
UICommand(title: "拷贝", action: #selector(rd_copy(_:))),
|
UICommand(title: "拷贝", action: #selector(rd_copy(_:))),
|
||||||
UICommand(title: "高亮", action: #selector(rd_highlight(_:))),
|
UICommand(title: "高亮", action: #selector(rd_highlight(_:))),
|
||||||
@@ -1086,7 +1138,7 @@ extension RDEPUBTextContentView: UIEditMenuInteractionDelegate {
|
|||||||
) -> CGRect {
|
) -> CGRect {
|
||||||
if let targetRect = selectionController.menuAnchorRect(interactionController: interactionController),
|
if let targetRect = selectionController.menuAnchorRect(interactionController: interactionController),
|
||||||
!targetRect.isEmpty {
|
!targetRect.isEmpty {
|
||||||
return targetRect
|
return resolvedSelectionMenuTargetRect(from: targetRect)
|
||||||
}
|
}
|
||||||
return bounds
|
return bounds
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
#if canImport(DTCoreText)
|
||||||
|
import DTCoreText
|
||||||
|
|
||||||
|
/// Debug-only detector for pagination/display metric mismatches, enabled by
|
||||||
|
/// the `--demo-pagination-validate` launch argument.
|
||||||
|
///
|
||||||
|
/// For every displayed text page it re-wraps the chapter text from the page
|
||||||
|
/// start at the display width (same CoreText engine the paginator used, in
|
||||||
|
/// chapter context) and compares the resulting line breaks against both the
|
||||||
|
/// page range and the lines actually drawn. Two failure classes:
|
||||||
|
///
|
||||||
|
/// - `STALE-RANGE`: the page range does not end on a line boundary of the
|
||||||
|
/// current metrics — the range was produced under different metrics than
|
||||||
|
/// the ones on screen (stale page table).
|
||||||
|
/// - `DISPLAY-DIVERGE`: the range is fine, but the drawn lines break at
|
||||||
|
/// different offsets than the in-context wrap — the display-side content
|
||||||
|
/// transform (e.g. continuation-paragraph normalization) changed wrapping.
|
||||||
|
enum RDEPUBTextPageBoundaryValidator {
|
||||||
|
|
||||||
|
static let isEnabled = ProcessInfo.processInfo.arguments.contains("--demo-pagination-validate")
|
||||||
|
|
||||||
|
/// Extra characters wrapped past the page end so the probe can see the
|
||||||
|
/// line that a mid-line page boundary cuts through.
|
||||||
|
private static let probeTailLength = 400
|
||||||
|
|
||||||
|
static func validate(
|
||||||
|
page: RDEPUBTextPage,
|
||||||
|
displayLayoutFrame: DTCoreTextLayoutFrame,
|
||||||
|
displayContent: NSAttributedString?,
|
||||||
|
displayBounds: CGRect
|
||||||
|
) {
|
||||||
|
guard isEnabled else { return }
|
||||||
|
let chapter = page.chapterContent
|
||||||
|
let pageStart = page.contentRange.location
|
||||||
|
let pageEnd = page.contentRange.location + page.contentRange.length
|
||||||
|
guard page.contentRange.length > 0,
|
||||||
|
pageStart >= 0,
|
||||||
|
pageEnd <= chapter.length,
|
||||||
|
displayBounds.width > 0 else { return }
|
||||||
|
|
||||||
|
guard let layouter = DTCoreTextLayouter(attributedString: chapter) else { return }
|
||||||
|
layouter.shouldCacheLayoutFrames = false
|
||||||
|
let probeLength = min(chapter.length - pageStart, page.contentRange.length + probeTailLength)
|
||||||
|
let probeRect = CGRect(x: 0, y: 0, width: displayBounds.width, height: 4_000_000)
|
||||||
|
guard let probeFrame = layouter.layoutFrame(
|
||||||
|
with: probeRect,
|
||||||
|
range: NSRange(location: pageStart, length: probeLength)
|
||||||
|
), let probeLines = probeFrame.lines as? [DTCoreTextLayoutLine] else { return }
|
||||||
|
|
||||||
|
let probeRanges = probeLines.map { $0.stringRange() }
|
||||||
|
|
||||||
|
// Class A: the page must end on a line boundary of the current wrap
|
||||||
|
// (unless it is the chapter's last page, which ends at chapter end).
|
||||||
|
let isChapterLastPage = pageEnd >= chapter.length
|
||||||
|
if !isChapterLastPage,
|
||||||
|
!probeRanges.contains(where: { NSMaxRange($0) == pageEnd }),
|
||||||
|
let cutLine = probeRanges.first(where: { NSLocationInRange(pageEnd - 1, $0) }) {
|
||||||
|
let text = chapter.string as NSString
|
||||||
|
let lineText = safeSubstring(text, cutLine)
|
||||||
|
print("[PAGINATION-VALIDATE] STALE-RANGE spine=\(page.spineIndex) page=\(page.pageIndexInChapter + 1)/\(page.totalPagesInChapter) pageEnd=\(pageEnd) cutLine=\(NSStringFromRange(cutLine)) width=\(displayBounds.width) line=\"\(lineText)\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Class B: the drawn lines must break at the same offsets as the
|
||||||
|
// in-context wrap. The display layout frame is built in chapter
|
||||||
|
// context, so its string ranges are chapter-absolute.
|
||||||
|
guard let displayLines = displayLayoutFrame.lines as? [DTCoreTextLayoutLine] else { return }
|
||||||
|
for (index, displayLine) in displayLines.enumerated() {
|
||||||
|
let displayRange = displayLine.stringRange()
|
||||||
|
let displayEndInChapter = NSMaxRange(displayRange)
|
||||||
|
guard displayEndInChapter < pageEnd else { break }
|
||||||
|
guard index < probeRanges.count else { break }
|
||||||
|
let probeEnd = NSMaxRange(probeRanges[index])
|
||||||
|
if probeEnd != displayEndInChapter {
|
||||||
|
let text = chapter.string as NSString
|
||||||
|
let lineStartInChapter = displayRange.location
|
||||||
|
let displayLineRangeInChapter = displayRange
|
||||||
|
let isParagraphStart = lineStartInChapter == 0
|
||||||
|
|| text.character(at: lineStartInChapter - 1) == 0x0A
|
||||||
|
let chapterStyle = chapter.attribute(
|
||||||
|
.paragraphStyle, at: lineStartInChapter, effectiveRange: nil
|
||||||
|
) as? NSParagraphStyle
|
||||||
|
let displayStyle = displayContent?.attribute(
|
||||||
|
.paragraphStyle, at: displayRange.location, effectiveRange: nil
|
||||||
|
) as? NSParagraphStyle
|
||||||
|
let displayLineWidth = displayLine.frame.width
|
||||||
|
let probeLineWidth = index < probeLines.count ? probeLines[index].frame.width : -1
|
||||||
|
print("[PAGINATION-VALIDATE] DISPLAY-DIVERGE spine=\(page.spineIndex) page=\(page.pageIndexInChapter + 1)/\(page.totalPagesInChapter) lineIndex=\(index) displayLineEnd=\(displayEndInChapter) probeLineEnd=\(probeEnd) width=\(displayBounds.width) paraStart=\(isParagraphStart) chapterIndents=(\(chapterStyle?.firstLineHeadIndent ?? -1),\(chapterStyle?.headIndent ?? -1),tail:\(chapterStyle?.tailIndent ?? -1)) displayIndents=(\(displayStyle?.firstLineHeadIndent ?? -1),\(displayStyle?.headIndent ?? -1),tail:\(displayStyle?.tailIndent ?? -1)) displayLineWidth=\(displayLineWidth) probeLineWidth=\(probeLineWidth) displayLine=\"\(safeSubstring(text, displayLineRangeInChapter))\" displayBreak=\"…\(safeSubstring(text, NSRange(location: max(displayEndInChapter - 2, 0), length: min(4, text.length - max(displayEndInChapter - 2, 0)))))\" probeBreak=\"…\(safeSubstring(text, NSRange(location: max(probeEnd - 2, 0), length: min(4, text.length - max(probeEnd - 2, 0)))))\"")
|
||||||
|
diagnoseDivergence(
|
||||||
|
page: page,
|
||||||
|
displayContent: displayContent,
|
||||||
|
lineIndex: index,
|
||||||
|
lineStartInChapter: lineStartInChapter,
|
||||||
|
displayEndInChapter: displayEndInChapter,
|
||||||
|
probeEnd: probeEnd,
|
||||||
|
width: displayBounds.width
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Narrows a display/probe line-break divergence down to its cause by
|
||||||
|
/// re-wrapping controlled variants and diffing attributes over the line.
|
||||||
|
private static func diagnoseDivergence(
|
||||||
|
page: RDEPUBTextPage,
|
||||||
|
displayContent: NSAttributedString?,
|
||||||
|
lineIndex: Int,
|
||||||
|
lineStartInChapter: Int,
|
||||||
|
displayEndInChapter: Int,
|
||||||
|
probeEnd: Int,
|
||||||
|
width: CGFloat
|
||||||
|
) {
|
||||||
|
let chapter = page.chapterContent
|
||||||
|
|
||||||
|
// Variant 1: the raw page substring with no display normalization.
|
||||||
|
let rawSubstring = chapter.attributedSubstring(from: page.contentRange)
|
||||||
|
let rawEnd = lineEnd(
|
||||||
|
wrapping: rawSubstring,
|
||||||
|
lineIndex: lineIndex,
|
||||||
|
width: width
|
||||||
|
).map { $0 + page.pageStartOffset }
|
||||||
|
|
||||||
|
// Variant 2: wrap the chapter from the start of the paragraph that
|
||||||
|
// contains the diverging line (context = current paragraph only).
|
||||||
|
let text = chapter.string as NSString
|
||||||
|
let paragraphRange = text.paragraphRange(
|
||||||
|
for: NSRange(location: lineStartInChapter, length: 0)
|
||||||
|
)
|
||||||
|
let paraString = chapter.attributedSubstring(
|
||||||
|
from: NSRange(
|
||||||
|
location: paragraphRange.location,
|
||||||
|
length: min(chapter.length - paragraphRange.location, paragraphRange.length + probeTailLength)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
var paraEnd: Int?
|
||||||
|
if let layouter = DTCoreTextLayouter(attributedString: paraString) {
|
||||||
|
layouter.shouldCacheLayoutFrames = false
|
||||||
|
let frame = layouter.layoutFrame(
|
||||||
|
with: CGRect(x: 0, y: 0, width: width, height: 4_000_000),
|
||||||
|
range: NSRange(location: 0, length: paraString.length)
|
||||||
|
)
|
||||||
|
if let lines = frame?.lines as? [DTCoreTextLayoutLine] {
|
||||||
|
let target = lineStartInChapter - paragraphRange.location
|
||||||
|
if let matched = lines.first(where: { $0.stringRange().location == target }) {
|
||||||
|
paraEnd = NSMaxRange(matched.stringRange()) + paragraphRange.location
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[PAGINATION-VALIDATE] DIAGNOSE lineStart=\(lineStartInChapter) display=\(displayEndInChapter) probeFullContext=\(probeEnd) rawSubstringWrap=\(rawEnd ?? -1) paragraphContextWrap=\(paraEnd ?? -1)")
|
||||||
|
|
||||||
|
// Attribute diff between chapter text and display content over the
|
||||||
|
// diverging line (through the longer of the two ends). The display
|
||||||
|
// content is a chapter-length copy, so indices are shared.
|
||||||
|
guard let displayContent else { return }
|
||||||
|
let diffEnd = max(displayEndInChapter, probeEnd)
|
||||||
|
var position = lineStartInChapter
|
||||||
|
while position < diffEnd {
|
||||||
|
guard position >= 0, position < displayContent.length, position < chapter.length else { break }
|
||||||
|
var chapterRunRange = NSRange()
|
||||||
|
let chapterAttrs = chapter.attributes(at: position, effectiveRange: &chapterRunRange)
|
||||||
|
var displayRunRange = NSRange()
|
||||||
|
let displayAttrs = displayContent.attributes(at: position, effectiveRange: &displayRunRange)
|
||||||
|
|
||||||
|
let keys = Set(chapterAttrs.keys).union(displayAttrs.keys)
|
||||||
|
for key in keys {
|
||||||
|
let lhs = chapterAttrs[key] as AnyObject?
|
||||||
|
let rhs = displayAttrs[key] as AnyObject?
|
||||||
|
if let lhs, let rhs, lhs.isEqual(rhs) { continue }
|
||||||
|
if lhs == nil, rhs == nil { continue }
|
||||||
|
print("[PAGINATION-VALIDATE] ATTR-DIFF pos=\(position) key=\(key.rawValue) chapter=\(describeAttr(lhs)) display=\(describeAttr(rhs))")
|
||||||
|
}
|
||||||
|
let nextPosition = min(
|
||||||
|
NSMaxRange(chapterRunRange),
|
||||||
|
NSMaxRange(displayRunRange)
|
||||||
|
)
|
||||||
|
guard nextPosition > position else { break }
|
||||||
|
position = nextPosition
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func describeAttr(_ value: AnyObject?) -> String {
|
||||||
|
guard let value else { return "nil" }
|
||||||
|
if let font = value as? UIFont {
|
||||||
|
return "font(\(font.fontName),\(font.pointSize))"
|
||||||
|
}
|
||||||
|
if let style = value as? NSParagraphStyle {
|
||||||
|
return "para(fli:\(style.firstLineHeadIndent),hi:\(style.headIndent),ti:\(style.tailIndent),lbm:\(style.lineBreakMode.rawValue),align:\(style.alignment.rawValue),lhm:\(style.lineHeightMultiple),ls:\(style.lineSpacing),min:\(style.minimumLineHeight),max:\(style.maximumLineHeight))"
|
||||||
|
}
|
||||||
|
if let number = value as? NSNumber {
|
||||||
|
return "num(\(number))"
|
||||||
|
}
|
||||||
|
return String(describing: type(of: value))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps `content` page-locally and returns the chapter-relative end of
|
||||||
|
/// line `lineIndex`, or nil if it cannot be produced.
|
||||||
|
private static func lineEnd(
|
||||||
|
wrapping content: NSAttributedString,
|
||||||
|
lineIndex: Int,
|
||||||
|
width: CGFloat
|
||||||
|
) -> Int? {
|
||||||
|
guard content.length > 0,
|
||||||
|
let layouter = DTCoreTextLayouter(attributedString: content) else { return nil }
|
||||||
|
layouter.shouldCacheLayoutFrames = false
|
||||||
|
let frame = layouter.layoutFrame(
|
||||||
|
with: CGRect(x: 0, y: 0, width: width, height: 4_000_000),
|
||||||
|
range: NSRange(location: 0, length: content.length)
|
||||||
|
)
|
||||||
|
guard let lines = frame?.lines as? [DTCoreTextLayoutLine],
|
||||||
|
lineIndex < lines.count else { return nil }
|
||||||
|
return NSMaxRange(lines[lineIndex].stringRange())
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func safeSubstring(_ text: NSString, _ range: NSRange) -> String {
|
||||||
|
guard range.location >= 0, NSMaxRange(range) <= text.length else { return "" }
|
||||||
|
return text.substring(with: range)
|
||||||
|
.replacingOccurrences(of: "\n", with: "⏎")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -22,12 +22,6 @@ final class RDEPUBTextPageRenderView: UIView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var attributedDisplayContent: NSAttributedString? {
|
|
||||||
didSet {
|
|
||||||
invalidateStaticContent()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var selectionRects: [CGRect] = [] {
|
var selectionRects: [CGRect] = [] {
|
||||||
didSet {
|
didSet {
|
||||||
setNeedsDisplay()
|
setNeedsDisplay()
|
||||||
@@ -78,9 +72,6 @@ final class RDEPUBTextPageRenderView: UIView {
|
|||||||
if let cachedStaticImage {
|
if let cachedStaticImage {
|
||||||
cachedStaticImage.draw(in: bounds)
|
cachedStaticImage.draw(in: bounds)
|
||||||
} else {
|
} else {
|
||||||
if let attributedDisplayContent {
|
|
||||||
drawHighlights(in: context, attributedString: attributedDisplayContent, layoutFrame: layoutFrame)
|
|
||||||
}
|
|
||||||
layoutFrame.draw(in: context, options: drawOptions)
|
layoutFrame.draw(in: context, options: drawOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,59 +87,6 @@ final class RDEPUBTextPageRenderView: UIView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func drawHighlights(
|
|
||||||
in context: CGContext,
|
|
||||||
attributedString: NSAttributedString,
|
|
||||||
layoutFrame: DTCoreTextLayoutFrame
|
|
||||||
) {
|
|
||||||
let fullRange = NSRange(location: 0, length: attributedString.length)
|
|
||||||
|
|
||||||
attributedString.enumerateAttribute(kRDEPUBHighlightAttributeName, in: fullRange) { value, range, _ in
|
|
||||||
guard let color = value as? UIColor else { return }
|
|
||||||
let rects = computeHighlightRects(for: range, layoutFrame: layoutFrame)
|
|
||||||
color.setFill()
|
|
||||||
for rect in rects {
|
|
||||||
context.fill(rect)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
attributedString.enumerateAttribute(kRDEPUBUnderlineAttributeName, in: fullRange) { value, range, _ in
|
|
||||||
guard let color = value as? UIColor else { return }
|
|
||||||
let rects = computeHighlightRects(for: range, layoutFrame: layoutFrame)
|
|
||||||
color.setStroke()
|
|
||||||
context.setLineWidth(2)
|
|
||||||
for rect in rects {
|
|
||||||
let y = rect.maxY - 1
|
|
||||||
context.move(to: CGPoint(x: rect.minX, y: y))
|
|
||||||
context.addLine(to: CGPoint(x: rect.maxX, y: y))
|
|
||||||
context.strokePath()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func computeHighlightRects(for range: NSRange, layoutFrame: DTCoreTextLayoutFrame) -> [CGRect] {
|
|
||||||
var rects: [CGRect] = []
|
|
||||||
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else { return rects }
|
|
||||||
|
|
||||||
for line in lines {
|
|
||||||
let lineRange = line.stringRange()
|
|
||||||
let overlap = NSIntersectionRange(range, lineRange)
|
|
||||||
guard overlap.length > 0 else { continue }
|
|
||||||
|
|
||||||
let startX = line.offset(forStringIndex: overlap.location)
|
|
||||||
let endX = line.offset(forStringIndex: overlap.location + overlap.length)
|
|
||||||
let rect = CGRect(
|
|
||||||
x: line.baselineOrigin.x + startX,
|
|
||||||
y: line.baselineOrigin.y - line.ascent,
|
|
||||||
width: endX - startX,
|
|
||||||
height: line.ascent + line.descent
|
|
||||||
)
|
|
||||||
rects.append(rect)
|
|
||||||
}
|
|
||||||
|
|
||||||
return rects
|
|
||||||
}
|
|
||||||
|
|
||||||
private func drawSelection(in context: CGContext) {
|
private func drawSelection(in context: CGContext) {
|
||||||
guard !selectionRects.isEmpty else { return }
|
guard !selectionRects.isEmpty else { return }
|
||||||
selectionColor.setFill()
|
selectionColor.setFill()
|
||||||
@@ -261,9 +199,6 @@ final class RDEPUBTextPageRenderView: UIView {
|
|||||||
let renderer = UIGraphicsImageRenderer(size: bounds.size, format: format)
|
let renderer = UIGraphicsImageRenderer(size: bounds.size, format: format)
|
||||||
return renderer.image { _ in
|
return renderer.image { _ in
|
||||||
guard let staticContext = UIGraphicsGetCurrentContext() else { return }
|
guard let staticContext = UIGraphicsGetCurrentContext() else { return }
|
||||||
if let attributedDisplayContent {
|
|
||||||
drawHighlights(in: staticContext, attributedString: attributedDisplayContent, layoutFrame: layoutFrame)
|
|
||||||
}
|
|
||||||
layoutFrame.draw(in: staticContext, options: drawOptions)
|
layoutFrame.draw(in: staticContext, options: drawOptions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
#if canImport(DTCoreText)
|
||||||
|
import DTCoreText
|
||||||
|
|
||||||
|
/// Redistributes the leftover space at the bottom of a page into the gaps
|
||||||
|
/// between lines so the last line sits flush with the content bottom edge
|
||||||
|
/// (vertical justification), keeping page character ranges untouched.
|
||||||
|
///
|
||||||
|
/// Mutating each line's `baselineOrigin` is sufficient: DTCoreText derives
|
||||||
|
/// line frames, glyph-run frames and attachment positions from it lazily,
|
||||||
|
/// so drawing, selection, highlights and hit-testing all stay consistent.
|
||||||
|
enum RDEPUBTextPageVerticalJustifier {
|
||||||
|
|
||||||
|
/// Leftover larger than this many typical line advances is kept as
|
||||||
|
/// whitespace instead of being stretched: it usually comes from a whole
|
||||||
|
/// block (image, table) pushed to the next page, and stretching would
|
||||||
|
/// make the line spacing visibly sparse.
|
||||||
|
static let maxStretchLineAdvanceRatio: CGFloat = 1.5
|
||||||
|
|
||||||
|
static func justify(
|
||||||
|
_ layoutFrame: DTCoreTextLayoutFrame,
|
||||||
|
contentHeight: CGFloat,
|
||||||
|
isChapterLastPage: Bool,
|
||||||
|
pixelScale: CGFloat
|
||||||
|
) {
|
||||||
|
guard !isChapterLastPage,
|
||||||
|
contentHeight > 0,
|
||||||
|
let lines = layoutFrame.lines as? [DTCoreTextLayoutLine],
|
||||||
|
lines.count >= 2,
|
||||||
|
let firstLine = lines.first,
|
||||||
|
let lastLine = lines.last else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let leftover = contentHeight - lastLine.frame.maxY
|
||||||
|
guard leftover > 0.5 else { return }
|
||||||
|
|
||||||
|
let gapCount = CGFloat(lines.count - 1)
|
||||||
|
let typicalAdvance = (lastLine.baselineOrigin.y - firstLine.baselineOrigin.y) / gapCount
|
||||||
|
guard typicalAdvance > 0,
|
||||||
|
leftover <= typicalAdvance * maxStretchLineAdvanceRatio else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let scale = max(pixelScale, 1)
|
||||||
|
for (index, line) in lines.enumerated() where index > 0 {
|
||||||
|
// Round each cumulative shift down to the pixel grid so glyphs
|
||||||
|
// stay sharp and the last line never overshoots the bottom edge.
|
||||||
|
let shift = floor(leftover * CGFloat(index) / gapCount * scale) / scale
|
||||||
|
var origin = line.baselineOrigin
|
||||||
|
origin.y += shift
|
||||||
|
line.baselineOrigin = origin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -66,6 +66,10 @@ final class RDReaderPreloadController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func invalidate(environment: Environment) {
|
func invalidate(environment: Environment) {
|
||||||
|
RDReaderTapDebug.log(
|
||||||
|
"PreloadController.invalidate",
|
||||||
|
"clearing pageCurlCached=\(pageCurlCachedViews.keys.sorted()) preloaded=\(preloadedPageViews.keys.sorted())"
|
||||||
|
)
|
||||||
pageCurlCachedViews.values.forEach { $0.removeFromSuperview() }
|
pageCurlCachedViews.values.forEach { $0.removeFromSuperview() }
|
||||||
preloadedPageViews.values.forEach { $0.removeFromSuperview() }
|
preloadedPageViews.values.forEach { $0.removeFromSuperview() }
|
||||||
pageCurlCachedViews.removeAll()
|
pageCurlCachedViews.removeAll()
|
||||||
@@ -78,8 +82,20 @@ final class RDReaderPreloadController {
|
|||||||
environment: Environment,
|
environment: Environment,
|
||||||
contentViewProvider: (Int, UIView?) -> UIView?
|
contentViewProvider: (Int, UIView?) -> UIView?
|
||||||
) -> UIView {
|
) -> UIView {
|
||||||
let reusableView = detachedReusablePageView(for: pageNum)
|
let view: UIView
|
||||||
let view = contentViewProvider(pageNum, reusableView) ?? reusableView ?? UIView()
|
if let reusableView = detachedReusablePageView(for: pageNum) {
|
||||||
|
view = reusableView
|
||||||
|
RDReaderTapDebug.log(
|
||||||
|
"PreloadController.pageViewForDisplay",
|
||||||
|
"cache HIT page=\(pageNum) view=\(RDReaderTapDebug.describe(reusableView))"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
view = contentViewProvider(pageNum, nil) ?? UIView()
|
||||||
|
RDReaderTapDebug.log(
|
||||||
|
"PreloadController.pageViewForDisplay",
|
||||||
|
"cache MISS page=\(pageNum) created=\(RDReaderTapDebug.describe(view))"
|
||||||
|
)
|
||||||
|
}
|
||||||
if shouldCache(view: view, for: pageNum, environment: environment) {
|
if shouldCache(view: view, for: pageNum, environment: environment) {
|
||||||
pageCurlCachedViews[pageNum] = view
|
pageCurlCachedViews[pageNum] = view
|
||||||
} else {
|
} else {
|
||||||
@@ -91,6 +107,10 @@ final class RDReaderPreloadController {
|
|||||||
func takePreloadedView(for pageNum: Int) -> UIView? {
|
func takePreloadedView(for pageNum: Int) -> UIView? {
|
||||||
let preloaded = preloadedPageViews.removeValue(forKey: pageNum)
|
let preloaded = preloadedPageViews.removeValue(forKey: pageNum)
|
||||||
preloaded?.removeFromSuperview()
|
preloaded?.removeFromSuperview()
|
||||||
|
RDReaderTapDebug.log(
|
||||||
|
"PreloadController.takePreloadedView",
|
||||||
|
"cache \(preloaded == nil ? "MISS" : "HIT") page=\(pageNum) view=\(RDReaderTapDebug.describe(preloaded))"
|
||||||
|
)
|
||||||
return preloaded
|
return preloaded
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,8 +131,26 @@ final class RDReaderPreloadController {
|
|||||||
preloadHostView.frame = parentView.bounds
|
preloadHostView.frame = parentView.bounds
|
||||||
|
|
||||||
for targetPage in targets {
|
for targetPage in targets {
|
||||||
let existing = detachedReusablePageView(for: targetPage)
|
let contentView: UIView
|
||||||
let contentView = contentViewProvider(targetPage, existing) ?? existing ?? UIView()
|
if let existing = preloadedPageViews[targetPage], existing.superview === preloadHostView {
|
||||||
|
contentView = existing
|
||||||
|
RDReaderTapDebug.log(
|
||||||
|
"PreloadController.prime",
|
||||||
|
"cache HIT(preloaded) page=\(targetPage) view=\(RDReaderTapDebug.describe(existing))"
|
||||||
|
)
|
||||||
|
} else if let cached = pageCurlCachedViews.removeValue(forKey: targetPage), cached.superview == nil {
|
||||||
|
contentView = cached
|
||||||
|
RDReaderTapDebug.log(
|
||||||
|
"PreloadController.prime",
|
||||||
|
"cache HIT(pageCurl) page=\(targetPage) view=\(RDReaderTapDebug.describe(cached))"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
contentView = contentViewProvider(targetPage, nil) ?? UIView()
|
||||||
|
RDReaderTapDebug.log(
|
||||||
|
"PreloadController.prime",
|
||||||
|
"cache MISS page=\(targetPage) created=\(RDReaderTapDebug.describe(contentView))"
|
||||||
|
)
|
||||||
|
}
|
||||||
let shouldCacheContentView = shouldCache(view: contentView, for: targetPage, environment: environment)
|
let shouldCacheContentView = shouldCache(view: contentView, for: targetPage, environment: environment)
|
||||||
if shouldCacheContentView {
|
if shouldCacheContentView {
|
||||||
preloadedPageViews[targetPage] = contentView
|
preloadedPageViews[targetPage] = contentView
|
||||||
|
|||||||
@@ -57,12 +57,27 @@ extension RDReaderView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isHitView(_ hitView: UIView?, inside toolView: UIView, point: CGPoint) -> Bool {
|
func isHitView(_ hitView: UIView?, inside toolView: UIView, point: CGPoint) -> Bool {
|
||||||
if toolView.frame.contains(point) {
|
if let hitView, hitView === toolView || hitView.isDescendant(of: toolView) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let hitView else { return false }
|
if toolView.frame.contains(point) {
|
||||||
return hitView === toolView || hitView.isDescendant(of: toolView)
|
// For search bar views, only consider the hit inside the visible
|
||||||
|
// panel area. Taps on the dimmed scrim should pass through to
|
||||||
|
// the reader content to allow chrome toggling. Touches that
|
||||||
|
// resolved to a search bar subview (e.g. the accessibility-only
|
||||||
|
// navigation buttons outside the panel) are already claimed by
|
||||||
|
// the descendant check above.
|
||||||
|
if let searchBarView = toolView as? RDEPUBReaderSearchBarView {
|
||||||
|
let panelPoint = convert(point, to: searchBarView.panelView)
|
||||||
|
if !searchBarView.panelView.point(inside: panelPoint, with: nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ToolViewPosition {
|
enum ToolViewPosition {
|
||||||
|
|||||||
@@ -763,6 +763,14 @@ public class RDReaderView: UIView {
|
|||||||
guard let safePageNum = clampedPageNumber(pageNum) else { return }
|
guard let safePageNum = clampedPageNumber(pageNum) else { return }
|
||||||
switch currentDisplayType {
|
switch currentDisplayType {
|
||||||
case .pageCurl:
|
case .pageCurl:
|
||||||
|
if !animated, safePageNum == currentPage, !detectPageViewControllerFault(pageViewController) {
|
||||||
|
RDReaderTapDebug.log(
|
||||||
|
"ReaderView.transitionToPage",
|
||||||
|
"skip same page page=\(safePageNum) animated=\(animated)"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let request = PageTransitionRequest(pageNum: safePageNum, animated: animated)
|
let request = PageTransitionRequest(pageNum: safePageNum, animated: animated)
|
||||||
if shouldQueuePageTransition(request) {
|
if shouldQueuePageTransition(request) {
|
||||||
return
|
return
|
||||||
@@ -823,6 +831,7 @@ public class RDReaderView: UIView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func reloadData() {
|
public func reloadData() {
|
||||||
|
invalidatePageCaches()
|
||||||
switchReaderDisplayType(currentDisplayType)
|
switchReaderDisplayType(currentDisplayType)
|
||||||
topToolView = resolvedTopChromeView()
|
topToolView = resolvedTopChromeView()
|
||||||
bottomToolView = resolvedBottomChromeView()
|
bottomToolView = resolvedBottomChromeView()
|
||||||
@@ -830,8 +839,9 @@ public class RDReaderView: UIView {
|
|||||||
|
|
||||||
public func reloadPageCountOnly() {
|
public func reloadPageCountOnly() {
|
||||||
if currentDisplayType == .pageCurl {
|
if currentDisplayType == .pageCurl {
|
||||||
if !isPageCurlTransitioning, currentPage >= 0, numberOfPages() > 0 {
|
let totalPages = numberOfPages()
|
||||||
transitionToPage(pageNum: currentPage, animated: false)
|
if !isPageCurlTransitioning, currentPage >= totalPages, totalPages > 0 {
|
||||||
|
transitionToPage(pageNum: totalPages - 1, animated: false)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
collectionView.reloadData()
|
collectionView.reloadData()
|
||||||
|
|||||||
Reference in New Issue
Block a user