修复 EPUB 文本分页显示错位并补充分页调试能力
本次提交围绕 DTCoreText 文本页的分页一致性、交互索引和高亮命中进行了集中修复。 主要改动: 1. 文本页显示改为基于整章 attributed string 的上下文布局,只对当前页 range 进行渲染,避免页面子串重新换行导致的页末断行偏差。 2. 页面布局快照与交互控制器统一改为使用 chapter-absolute 索引,修正点击、选区、菜单锚点与高亮矩形在整章上下文下的定位。 3. 修复跨章节高亮串页问题,并调整文本页高亮命中逻辑:保留 CoreText 层绘制,点击时按高亮真实 rect 精确命中,避免重复绘制和整行误判。 4. 收紧 reader 级页面缓存策略,避免预加载同时持有多份整章显示副本带来的内存放大。 5. 新增分页边界校验器、垂直对齐器和 settings-flip 自动化调试入口,用于复现与诊断页范围/显示度量不一致问题。 6. 放宽 inline attachment 的 avoid-break 处理,并补充相关分页问题调查文档与索引。
This commit is contained in:
parent
d0efe3f2cc
commit
5a41066b66
123
Doc/PAGINATION_ISSUES_2026-07-06.md
Normal file
123
Doc/PAGINATION_ISSUES_2026-07-06.md
Normal file
@ -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,7 @@
|
|||||||
| [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 裁行(已修复)+页范围/显示度量错配行中断页(待复现),含调试命令备忘 |
|
||||||
| [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) | 面向大书按需分页模式的完整优化方案:远距目录跳转、后台补全优先级、页图接管协议与分阶段实施路径 |
|
||||||
|
|||||||
3126
ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj
generated
3126
ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj
generated
File diff suppressed because it is too large
Load Diff
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -281,6 +281,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() {
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -90,6 +90,10 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
private var coreTextDisplayContent: NSAttributedString?
|
private var coreTextDisplayContent: NSAttributedString?
|
||||||
|
|
||||||
private var coreTextDisplayRange: NSRange?
|
private var coreTextDisplayRange: NSRange?
|
||||||
|
|
||||||
|
/// Framesetter over the full chapter copy, rebuilt when the display
|
||||||
|
/// content changes; layout frames are recomputed per bounds change.
|
||||||
|
private var coreTextLayouter: DTCoreTextLayouter?
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private let interactionController = RDEPUBPageInteractionController()
|
private let interactionController = RDEPUBPageInteractionController()
|
||||||
@ -313,7 +317,18 @@ 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.
|
||||||
|
return currentPage == nil
|
||||||
|
|| loadingSpinner.isAnimating
|
||||||
|
|| coreTextDisplayContent != nil
|
||||||
|
#else
|
||||||
|
currentPage == nil
|
||||||
|
|| loadingSpinner.isAnimating
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private var hasInteractiveTextContent: Bool {
|
private var hasInteractiveTextContent: Bool {
|
||||||
@ -405,6 +420,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()
|
||||||
@ -418,15 +434,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: keep the full chapter string and lay out only the
|
||||||
normalizedPageContent(from: page),
|
// page's range. CTTypesetter line breaks are context-sensitive, so
|
||||||
|
// 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.
|
||||||
|
let displayContent = NSMutableAttributedString(attributedString: page.chapterContent)
|
||||||
|
let pageRange = NSIntersectionRange(
|
||||||
|
page.contentRange,
|
||||||
|
NSRange(location: 0, length: displayContent.length)
|
||||||
|
)
|
||||||
|
_ = RDEPUBDarkImageAdjuster.adjustIfNeeded(
|
||||||
|
displayContent,
|
||||||
|
in: pageRange,
|
||||||
configuration: configuration
|
configuration: configuration
|
||||||
)
|
)
|
||||||
let fullRange = NSRange(location: 0, length: displayContent.length)
|
|
||||||
displayContent.addAttribute(
|
displayContent.addAttribute(
|
||||||
.foregroundColor,
|
.foregroundColor,
|
||||||
value: configuration.theme.contentTextColor,
|
value: configuration.theme.contentTextColor,
|
||||||
range: fullRange
|
range: pageRange
|
||||||
)
|
)
|
||||||
|
|
||||||
applyHighlightsToContent(displayContent, highlights: highlights, page: page)
|
applyHighlightsToContent(displayContent, highlights: highlights, page: page)
|
||||||
@ -434,7 +461,9 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
coreTextContentView.backgroundColor = .clear
|
coreTextContentView.backgroundColor = .clear
|
||||||
coreTextContentView.accessibilityIdentifier = "epub.reader.selection.text"
|
coreTextContentView.accessibilityIdentifier = "epub.reader.selection.text"
|
||||||
coreTextDisplayContent = displayContent
|
coreTextDisplayContent = displayContent
|
||||||
coreTextDisplayRange = NSRange(location: 0, length: displayContent.length)
|
coreTextDisplayRange = pageRange
|
||||||
|
coreTextLayouter = DTCoreTextLayouter(attributedString: displayContent)
|
||||||
|
coreTextLayouter?.shouldCacheLayoutFrames = false
|
||||||
coreTextContentView.attributedDisplayContent = displayContent
|
coreTextContentView.attributedDisplayContent = displayContent
|
||||||
updateCoreTextLayoutFrameIfNeeded()
|
updateCoreTextLayoutFrameIfNeeded()
|
||||||
#else
|
#else
|
||||||
@ -493,6 +522,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
|
||||||
|
|
||||||
overlayView.clearSelection()
|
overlayView.clearSelection()
|
||||||
@ -540,6 +570,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
|
||||||
}
|
}
|
||||||
@ -581,26 +612,27 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
return image(from: attachmentValue)
|
return image(from: attachmentValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Marks highlight ranges on the chapter-length display copy. Ranges stay
|
||||||
|
/// chapter-absolute — the in-context layout frame uses the same indices.
|
||||||
private func applyHighlightsToContent(
|
private func applyHighlightsToContent(
|
||||||
_ content: NSMutableAttributedString,
|
_ content: NSMutableAttributedString,
|
||||||
highlights: [RDEPUBHighlight],
|
highlights: [RDEPUBHighlight],
|
||||||
page: RDEPUBTextPage
|
page: RDEPUBTextPage
|
||||||
) {
|
) {
|
||||||
for highlight in highlights {
|
for highlight in highlights {
|
||||||
|
guard highlight.location.href == page.href else { continue }
|
||||||
guard let rangeInfo = highlight.rangeInfo,
|
guard let rangeInfo = highlight.rangeInfo,
|
||||||
let info = RDEPUBTextOffsetRangeInfo.decode(from: rangeInfo),
|
let info = RDEPUBTextOffsetRangeInfo.decode(from: rangeInfo),
|
||||||
let absoluteRange = info.nsRange else { continue }
|
let absoluteRange = info.nsRange else { continue }
|
||||||
let overlap = NSIntersectionRange(absoluteRange, page.contentRange)
|
let overlap = NSIntersectionRange(absoluteRange, page.contentRange)
|
||||||
guard overlap.length > 0 else { continue }
|
guard overlap.length > 0,
|
||||||
let relativeRange = NSRange(location: overlap.location - page.pageStartOffset, length: overlap.length)
|
overlap.location + overlap.length <= content.length else { continue }
|
||||||
guard relativeRange.location >= 0,
|
|
||||||
relativeRange.location + relativeRange.length <= content.length else { continue }
|
|
||||||
|
|
||||||
switch highlight.style {
|
switch highlight.style {
|
||||||
case .highlight:
|
case .highlight:
|
||||||
content.addAttribute(kRDEPUBHighlightAttributeName, value: highlight.uiColor, range: relativeRange)
|
content.addAttribute(kRDEPUBHighlightAttributeName, value: highlight.uiColor, range: overlap)
|
||||||
case .underline:
|
case .underline:
|
||||||
content.addAttribute(kRDEPUBUnderlineAttributeName, value: highlight.uiColor, range: relativeRange)
|
content.addAttribute(kRDEPUBUnderlineAttributeName, value: highlight.uiColor, range: overlap)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -698,13 +730,26 @@ 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)
|
||||||
@ -713,6 +758,9 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
if let page = currentPage {
|
if let page = currentPage {
|
||||||
let (bgDecorations, fgDecorations) = overlayView.buildDecorations(
|
let (bgDecorations, fgDecorations) = overlayView.buildDecorations(
|
||||||
page: page,
|
page: page,
|
||||||
|
// Text-page highlights/underlines are already painted by the
|
||||||
|
// CoreText render view via display-content attributes. Keep
|
||||||
|
// overlay decorations for search only to avoid double drawing.
|
||||||
highlights: [],
|
highlights: [],
|
||||||
searchState: currentSearchState,
|
searchState: currentSearchState,
|
||||||
interactionController: interactionController
|
interactionController: interactionController
|
||||||
@ -845,10 +893,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
|
||||||
@ -857,7 +910,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -891,6 +944,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 = [
|
||||||
@ -901,14 +959,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
|
||||||
@ -1090,8 +1151,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(_:))),
|
||||||
@ -1105,7 +1167,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
|
||||||
@ -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
|
||||||
Loading…
Reference in New Issue
Block a user