本次提交围绕 DTCoreText 文本页的分页一致性、交互索引和高亮命中进行了集中修复。 主要改动: 1. 文本页显示改为基于整章 attributed string 的上下文布局,只对当前页 range 进行渲染,避免页面子串重新换行导致的页末断行偏差。 2. 页面布局快照与交互控制器统一改为使用 chapter-absolute 索引,修正点击、选区、菜单锚点与高亮矩形在整章上下文下的定位。 3. 修复跨章节高亮串页问题,并调整文本页高亮命中逻辑:保留 CoreText 层绘制,点击时按高亮真实 rect 精确命中,避免重复绘制和整行误判。 4. 收紧 reader 级页面缓存策略,避免预加载同时持有多份整章显示副本带来的内存放大。 5. 新增分页边界校验器、垂直对齐器和 settings-flip 自动化调试入口,用于复现与诊断页范围/显示度量不一致问题。 6. 放宽 inline attachment 的 avoid-break 处理,并补充相关分页问题调查文档与索引。
10 KiB
分页问题调查记录(2026-07-06)
调查载体:《宝山辽墓材料与释读》(textReflowable / DTCoreText 路径),iPhone 15 Pro Max 尺寸(430×932,内容区 398×815pt)。当日共调查两个独立问题:
问题一:页底留空一行多,下一页首行未上移(已修复)
现象
第 6 页底部留有约 1.7 行高的空白,第 7 页首行"人目为帝羓,信有之也。[注]"本可容纳在第 6 页。截图实测:行距 75px@3x(25pt),第 6 页底部空隙 138px,足够再排一行。
根因链
RDEPUBSemanticMarkerInjector.inferredHints给所有<img>打上avoidPageBreakInside提示——包括行内脚注小图标(<img class="qqreader-footnote">,即"注"字图标)。RDEPUBPageBreakPolicy.shouldTreatAvoidHintAsBlockProtection本有豁免:blockKind == .attachment && placement != .centered → 不保护。但applyPaginationSemantics按结束标记的字符串顺序应用属性:<img>的结束标记先于外层<p>,段落随后把图标位置的.rdPageBlockKind从attachment覆盖为paragraph;而hints与placement因段落不携带这两个属性而幸存。豁免条件因此失效。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插回。
- 这些窗口里可能出现"旧页范围 + 新排版内容"的错配。
待办 / 复现所需
- 确认触发操作:是否在设置面板调整行距/字号后(未关面板或刚关)翻页出现。
- 确认重启 app 后是否自愈(受控实验表明缓存路径本身一致,理应自愈;若不自愈则有持久化的脏状态)。
- 修复方向(待复现后定):设置变更代(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回归验证这两个窗口。