ReadViewSDK/Doc/PAGINATION_ISSUES_2026-07-06.md
shenlei 5a41066b66 修复 EPUB 文本分页显示错位并补充分页调试能力
本次提交围绕 DTCoreText 文本页的分页一致性、交互索引和高亮命中进行了集中修复。

主要改动:

1. 文本页显示改为基于整章 attributed string 的上下文布局,只对当前页 range 进行渲染,避免页面子串重新换行导致的页末断行偏差。

2. 页面布局快照与交互控制器统一改为使用 chapter-absolute 索引,修正点击、选区、菜单锚点与高亮矩形在整章上下文下的定位。

3. 修复跨章节高亮串页问题,并调整文本页高亮命中逻辑:保留 CoreText 层绘制,点击时按高亮真实 rect 精确命中,避免重复绘制和整行误判。

4. 收紧 reader 级页面缓存策略,避免预加载同时持有多份整章显示副本带来的内存放大。

5. 新增分页边界校验器、垂直对齐器和 settings-flip 自动化调试入口,用于复现与诊断页范围/显示度量不一致问题。

6. 放宽 inline attachment 的 avoid-break 处理,并补充相关分页问题调查文档与索引。
2026-07-08 14:27:36 +09:00

10 KiB
Raw Blame History

分页问题调查记录2026-07-06

调查载体《宝山辽墓材料与释读》textReflowable / DTCoreText 路径iPhone 15 Pro Max 尺寸430×932内容区 398×815pt。当日共调查两个独立问题


问题一:页底留空一行多,下一页首行未上移(已修复)

现象

第 6 页底部留有约 1.7 行高的空白,第 7 页首行"人目为帝羓,信有之也。[注]"本可容纳在第 6 页。截图实测:行距 75px@3x25pt第 6 页底部空隙 138px足够再排一行。

根因链

  1. RDEPUBSemanticMarkerInjector.inferredHints所有 <img> 打上 avoidPageBreakInside 提示——包括行内脚注小图标(<img class="qqreader-footnote">,即"注"字图标)。
  2. RDEPUBPageBreakPolicy.shouldTreatAvoidHintAsBlockProtection 本有豁免:blockKind == .attachment && placement != .centered → 不保护。但 applyPaginationSemantics结束标记的字符串顺序应用属性:<img> 的结束标记先于外层 <p>,段落随后把图标位置的 .rdPageBlockKindattachment 覆盖为 paragraph;而 hintsplacement 因段落不携带这两个属性而幸存。豁免条件因此失效。
  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标记贯穿构建流程飞行中的旧代构建完成后丢弃、不得插回 storechapterDataCache 键加入 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 Max430×932@3x 与问题截图同尺寸)
页边界/换行一致性校验 启动参数 --demo-pagination-validateRDEPUBTextPageBoundaryValidator逐页比对显示换行与全章上下文换行输出 STALE-RANGE / DISPLAY-DIVERGE / DIAGNOSE / ATTR-DIFF
设置面板自动翻转 启动参数 --demo-settings-flipRDEPUBSettingsFlipAutomation自动开面板→改行距→关面板→翻页三种时序

问题二根因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 断行对字符串上下文(跨段落!)敏感。

修复(方案 A2026-07-07 已实施)

显示端改为全章上下文排版RDEPUBTextContentView.configurepage.chapterContent 的整章副本作为显示内容,DTCoreTextLayouter(章节副本).layoutFrame(bounds, range: page.contentRange) 只排当页范围。断行与分页器按构造一致(同串、同起点、同宽)。

配套改动:

  • layoutFrame 的 stringRange 因此为章节绝对坐标RDEPUBPageLayoutSnapshot.build 去掉 +pageStartOffset 归一(本就输出绝对坐标给消费方),RDEPUBPageInteractionController 去掉全部 -pageOffset 反换算;选区/高亮/点击测试/装饰层的消费接口本来就是绝对坐标,无需变动。
  • 主题前景色、暗色图片调整(RDEPUBDarkImageAdjuster 新增 in range: 参数)、高亮标记只施加在章节副本的当页范围上;applyHighlightsToContent 改用绝对 overlap 范围。
  • 删除 DTCoreText 路径对 normalizedPageContent 的调用——续段首行缩进/段前距归一化 hack 由上下文排版天然取代(非 DTCoreText 回退路径仍保留)。
  • layouterframesetter随显示内容缓存于 cellcoreTextLayouterbounds 变化只重建 layoutFrame避免每次 layout pass 对整章重建 framesetter。
  • RDEPUBTextPageBoundaryValidator 适配绝对坐标,保留为回归警报。

验证:--demo-pagination-validate 基线(原先 spine 3 前 8 页 5 处分叉)修复后 0 命中;第 10 页首行断点与分页一致「…使用了更多」Selection/Annotation UI 测试回归通过(见下)。

遗留(独立的潜在缺陷,本次未触发)

  • RDEPUBChapterRuntimeStore.chapterDataCache 仅按 spineIndex 键控;设置变更时飞行中的旧构建完成后仍会插回(RDEPUBChapterLoader.loadChapterinsertChapter 无代际校验)。
  • RDEPUBChapterLoader.pendingLoads 按 spineIndex 合流,跨设置变更合流会把旧样式章节交给新请求。
  • 建议修复问题二后用 --demo-settings-flip + --demo-pagination-validate 回归验证这两个窗口。