尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Readest KOSync 同步章节定位修复实录:为什么“百分比重锚定“必须被彻底移除(5980 / 5111)

Readest KOSync 同步章节定位修复实录:为什么“百分比重锚定“必须被彻底移除(5980 / 5111) 桌面应用跨平台前端【免费下载链接】readestReadest is a modern, feature-rich ebook reader designed for avid readers offering seamless cross-platform access, powerful tools, and an intuitive interface to elevate your reading experience.项目地址https://gitcode.com/gh_mirrors/re/readest点击查看免费下载导读本文基于 Readest 仓库内的一份完整技术复盘还原 KOReader SyncKOSync阅读进度同步中一次经典的聪明修复反噬事故——Readest 曾引入一套当服务器上报的阅读百分比超出名义章节的字节占比区间时就按百分比重新锚定章节的漂移校正机制#5111结果导致 iOS/Kindle 上的用户在第 5 章阅读时被错误地同步到第 6 章#5980并在第二本书上再次爆发#6051。文章将完整梳理该机制的由来、失效原理、最终删除而非修补的修复决策、CREngine 与 foliate-js 的章节恒等映射证据以及沉淀下来的可复用判定准则。读完你将理解为什么在跨引擎进度同步中章节索引只能计算、绝不能估计以及如何用源码级证据与回归测试锁定这类定位回归。1. 背景KOSync 协议下的两套坐标体系Readest 与 KOReader 之间通过 KOSync 协议以及兼容该协议的 BookOrbit、Kavita 等服务器同步阅读进度。双方使用的定位格式完全不同KOReaderCREngine 引擎使用XPointer形如/body/DocFragment[14]/body/div/h3[17]/text().0。其中DocFragment[N]是 CREngine 为 EPUBspine中第 N 个条目1 基生成的文档片段编号是自带章节信息的位置坐标。Readestfoliate-js 引擎使用EPUB CFI形如epubcfi(/6/28!/4/2/410/1:0)。两者之间通过XCFI类做双向转换核心入口位于 src/utils/xcfi.tsXCFI.extractSpineIndexsrc/utils/xcfi.ts#L32-L60负责从 CFI 或 XPointer 中提取 0 基章节号XPointer 的DocFragment[N]对应 foliate 章节N - 1CFI 的脊柱步进偶数 2, 4, 6…通过(step - 2) / 2换算xPointerToCFI/cfiToXPointer完成双向路径解析其中parseXPointer支持 KOReader 的三种文本引用格式/text().N、/text()[K].N、/tag[idx].NgetCFIFromXPointersrc/utils/xcfi.ts#L723-L741是本次事故的核心函数负责把远端 XPointer 解析成本地 CFI。此外KOSync 服务器在返回远端进度时还会附带一个percentage字段——这是KOReader 自己按 CREngine 分页计算的阅读百分比它与 Readest 基于字节占比计算的进度百分比并不是同一个量纲。2. Bug 现场#5980——在第 5 章阅读却被同步到第 6 章2.1 现象2026-08-30用户 albertmichaelj 在 Readest 0.12.6iOS KOReaderKindle Grimmory KOSync 服务器 的组合下报告远端同步记录为progress /body/DocFragment[14]/body/div/h3[17]/text().0 percentage 0.3113DocFragment[14]是 1 基编号对应 0 基 spine 索引13 第 5 章并且服务器自身的诊断 CFIepubcfi(/6/28!...)也同意这个结论CFI 脊柱步进 28 (131)×2。然而 Readest 打开的是第 6 章。关键线索该问题在Receive Only只接收模式下即可复现——这排除了同步冲突 / 服务器覆盖的干扰说明问题出在 Readest 本地的远端位置解析逻辑上。2.2 完整错误链复盘文档给出的根因链路非常清晰重锚定触发resolveSpineSectionIndex把percentage 落在名义章节的字节占比区间之外当作 CREngine↔foliate 的DocFragment漂移证据于是按百分比重新锚定章节锚定错误在这本书Harari 的Nexus上spine 各章节按 foliate 的字节占比区间是第 5 章18.419% – 27.803%Part II 分隔页27.803% – 27.840%第 6 章27.840% – 32.642%量纲错位该书的 Notes Index 占了全部 spine XHTML 字节的~44%而 KOReader 的分页百分比31.13%与 foliate 的字节占比表天然错位——31.13% 落在第 6 章的字节区间里于是重锚定把章节 13 改成了 15路径越界章节被改成 15 后resolveXPointerPath在h3[17]上抛出Element index 16 out of bounds for tag h3——因为第 5 章有 17 个h3而第 6 章只有 6 个静默兜底useKOSync.ts的catch捕获异常后navigated保持 false最终落到view.goToFraction(0.3113)——兜底用的恰恰也是同一个百分比于是直接跳进第 6 章。正是这个失败后回退到原始百分比的兜底路径让用户看到的是一个错误的位置而不是一个报错——问题被完美地伪装成了正常行为。3. 溯源#5111 的锚定从诞生起就没有依据复盘文档通过git log -S完成了对锚定机制的完整溯源结论非常尖锐这是一个从未被正当化的投机性修复其存在本身就是回归。3.1 一个 commit 引入全部符号resolveSpineSectionIndex、buildSectionFractionTable、sectionIndexForFraction以及getCFIFromXPointer的percentage参数全部在同一个 commita435d8550PR #51112026-07-15 合并v0.11.20 于 2026-07-20 首次发布中一次性引入。git log -S对每个符号的检索都只返回这一个 commit。而在这之前getCFIFromXPointer的实现只有一句话const xSpineIndex XCFI.extractSpineIndex(xpointer);——直接用 XPointer 自己的DocFragment[N]决定章节仅此而已。3.2 Bug A 假设漂移从未被证实该锚定是 #5111 中 4 个子 commit 里的第 1 个本意是修复一个假设性的 Bug ADocFragment↔spine 索引漂移。然而到第 3 个子 commit 时作者自己就已经在 issue 里写明了#4444 的真正原因是parseXPointer的一个解析缺口tag[idx].N这种格式导致解析失败失败后回退到原始百分比从而移动了读者的位置。不是漂移。更讽刺的是作者在同一 PR 里还加了isReportedByKOReader开关把锚定机制对 Kavita 关掉了——Kavita 恰恰是唯一上报过该 bug 的服务器也就是说锚定机制只对从未被测试过的真实 KOReader 生效。3.3 站不住脚的支撑细节复盘文档逐条拆穿了当时注释里引以为据的证据声称实际真实上报显示DocFragment[326]落在 foliate 章节 274该说法在仓库的任何 issue 中都不存在且方向性本身就不可能见下文第 8 节方向测试旧回归测试能检测漂移不能——每个桩章节都是pSection i/p/body/p在全部 54 个章节里都能解析成功测试根本不敏感注释引用的 #5109那是一个Android 图库图片的 PR与进度同步毫无关系更隐蔽的危害在于扩散这套锚定机制后来经由 #5630 和 #5866 以既定机制的身份被复制进了useProgressSynckoplugin 路径错误半径进一步扩大。4. 修复决策删除而不是修补4.1 chrox 的裁决2026-09-01维护者 chrox 拍板**移除REMOVED而非修补patched**这套锚定机制并留下一句被复盘文档反复引用的原则The SpineIndex should always be calculated and never be estimated, which is always wrong, and we prefer not syncing other than wrong syncing. SpineIndex 只能被计算永远不能被估计——估计总是错的我们宁可不同步也不要错误地同步。4.2 最终形态功能回退到 pre-#5111修复后的getCFIFromXPointer是一次功能性回退章节就是 XPointer 自己声明的DocFragment[N] - 1完事。当前仓库 src/utils/xcfi.ts#L723-L741 中的实现证实了这一点——签名里没有percentage参数函数体只做extractSpineIndex与按需加载对应章节文档两步。同时删除的符号包括resolveSpineSectionIndexbuildSectionFractionTablesectionIndexForFractionSpineSectionInfoisReportedByKOReader它存在的唯一意义就是给锚定机制做开关以及全部 3 个调用点kosyncProgress、useKOSync、useProgressSync上的百分比线程传递。从 #5111 中保留下来的是两处真正的修复parseXPointer对tag[idx].N格式的支持这是 #4444 的真因修复RemoteFractionResolution判别联合类型#5065 的修复见 src/app/reader/hooks/kosyncProgress.ts#L49-L52。4.3 为什么失败时回退到名义章节的补丁方案不够修复过程中的第一次尝试是保留锚定但当 XPointer 的路径在名义章节中结构上不可能存在时才回退到名义章节。在真实 EPUB 上实测这个方案只救得了一部分幸运用例/body/DocFragment[14]/body/div/h3[17]/text().0—— 因为h3[17]只存在于第 5 章能救但在同样的 31.13% 位置上/body/DocFragment[14]/body/div/p[50]/text().0仍然静默解析成epubcfi(/6/32!...) 第 6 章而且产出了一个看起来完全合法的 CFI——因为第 5 章有 215 个p第 6 章也有 105 个p[50]两边都存在。而段落p定位符恰恰是 KOReader 几乎总是发出的形式。也就是说这个补丁会把最常见的情形留在坑里。4.4 为什么先信任名义章节、解析失败再重锚定也不行这是报告者倾向的表述但复盘文档给出了否决理由这会让#5111 自己的参考用例回归——在那个用例里/body/p在每个章节都能解析成功。结构性不可能根本没法区分真漂移和百分比分歧——这正是把锚定删掉而不是让它变得更聪明的决定性论据。5. 真实 EPUB 验证与回归测试5.1 在报告者的真实书籍上离线验证验证对象是报告者提供的真实 EPUBNexusmd5607504f3480755ae37f9434bc1af0371结论与报告精确吻合测量到的章节边界与报告一致到小数点后 4 位Ch5 18.4189–27.8028%分隔页 27.8028–27.8397%Ch6 27.8397–32.6423%修复前重锚定确实触发 13 → 15真实第 5 章有 18 个h3h3[17] Nobodys Perfect真实第 6 章只有 6 个修复前在章节 15 里转换确实抛出Element index 16 out of bounds for tag h3修复后getCFIFromXPointer返回epubcfi(/6/28!/4/2/410/1:0)解析到那个h3同步服务器自己的诊断 CFI 是epubcfi(/6/28!/4/2/410:0)——元素路径完全一致。移除后再验证两个定位符都解析回章节 13——h3[17]→epubcfi(/6/28!/4/2/410/1:0)Nobodys Perfectp[50]→epubcfi(/6/28!/4/2/114/1:0)Strongmen who claim to represent the people...。5.2 回归测试用真实 spine 尺寸 合成章节体由于原书受版权保护测试文件 src/tests/utils/xcfi.kosync-nominal-docfragment.test.ts 采用29 个真实 spine 尺寸 合成章节体的策略REAL_SIZES数组完整保留该书 29 个itemref的未压缩 zip 条目大小即 foliate 的section.size真实复现章节占比第 5 章 → 18.4189%–27.8028%第 6 章 → 27.8397%–32.6423%第 5 章生成 18 个h3 215 个p第 6 章生成 6 个h3 105 个p精准复刻标题只在第 5 章存在、段落两边都存在的判别难点测试用it.each同时钉死两个用例标题用例/body/DocFragment[14]/body/div/h3[17]/text().0解析结果必须以epubcfi(/6/28!开头章节 13段落用例/body/DocFragment[14]/body/div/p[50]/text().0同样必须落在章节 13另有一条专门测试位置传入的第 5 个参数百分比被忽略——防止未来某个调用方把百分比塞回来重新影响章节选择31.13% 本来会落到/6/32!即第 6 章还有一条验证当已渲染文档恰好就是 XPointer 自身章节时直接复用该文档的快路径。作为对比断言旧行为的xcfi.kosync-section-offset.test.ts被删除。5.3 验证结论修复后全量测试 10620 项通过lint format 干净。6. 底层证据CREngine 在源码层面保证 1:1 映射复盘文档专门回答了能否重新实现 #5111这个问题——不能因为根本没有任何可重新实现的东西。这一结论在 crengine 的源码里写得明明白白crengine/src/epubfmt.cpp约第 1846 行注释原文 Create a DocFragment for each and all items in the EPUBsspine只遍历一次 spine为每个条目恰好生成一个 DocFragmentSVG spine 条目变成SpineSvgWrapper无法解析的条目变成SpineItemUnsupported占位符。其注释给出了设计理由大意我们不会在已有 DocFragment 之间插入新的 DocFragment否则所有 XPointer高亮、最后一页都会因为 DocFragment 索引移位而失效。因此DocFragment[N] foliate 章节N - 1是设计使然BY DESIGN计算出的索引就是恒等映射——这正是修复后代码在做的事。6.1 唯一被记录在案的例外存在一个**可精确计算、但未实现因为没有报告**的例外当getDOMVersionRequested() 20240114时relaxed_spine false此时只有 media-type 为application/xhtmlxml的 spine 条目会获得 DocFragmentis_xhtmlepubfmt.cpp:1731——映射变成XHTML-only 子序列的下标。要实现它需要SectionItem暴露mediaType而 foliate没有暴露packages/foliate-js/epub.js 的 spine 映射只拷贝 size/cfi/linear不拷贝 mediaType代价是子模块改动 重新 pin 版本。好在影响窗口是瞬时的KOReader 只对以前见过的书pin 最老的 DOM 版本随后就会提示迁移readerrolling.lua:168-196。7. chrox 规则的连锁后果移动读者的百分比兜底一并消失SpineIndex 只能计算、不能估计这条规则的推论不止于删掉锚定——所有转换失败后用百分比移动读者位置的兜底路径也被一并移除7.1useKOSync.applyRemoteProgress当前实现见 src/app/reader/hooks/useKOSync.ts#L174-L226当远端 progress 是 XPointerisXPointerProgress时它成为唯一可接受的答案如果转换失败派发_(Sync failed)提示并保持原地不动、返回false——不再用百分比猜测对于非 XPointer 服务器如 Kavita百分比仍然是唯一信号继续作为goToFraction的目标固定版式FXL书籍仍走页码路径。7.2useProgressSynckoplugin 路径当前实现见 src/app/reader/hooks/useProgressSync.ts#L226-L401移除了 #5630 引入的、CREngine[page, total]转换失败后goToFraction(remoteFraction)的恢复路径XPointer 转换失败xpointerUnresolved时派发_(Sync failed)提示并保持不动src/app/reader/hooks/useProgressSync.ts#L328-L339复盘明确指出 #5625 的真实伤害是自动推送用本地旧位置覆盖更新的远端位置而这一伤害由拉取流程继续执行来防止——配置合并、proofread 规则合并、参考页码合并都不受 XPointer 转换失败影响对应测试用例名a failed XPointer conversion still lets the rest of the pull run而不是靠移动读者。_(Sync failed)无需任何 i18n 工作——它原本就是所有语言环境都已有的翻译键。8. 方向性测试未来任何DocFragment 漂移主张的判据复盘文档沉淀了一个可复用的方向性判定准则用来快速否决类似的漂移主张foliate为每个itemref构建一个 section不做过滤packages/foliate-js/epub.js 中this.spine $$itemref1:1 映射.filter(s s)只丢弃坏 idrefCREngine每个 spine 条目至多产生一个 DocFragment——ldomDocumentFragmentWriter::OnTagOpen只在!insideTag baseTag tagname时才打开新 DocFragment所以一个文件里的第二个body是嵌套而不是新开片段。由此得出关键不等式CREngine 的片段索引恒 ≤ foliate 的 spine 索引——真实的漂移只可能让真实章节落在名义章节之后绝无可能落在之前。而 #5111 的两个声称案例DocFragment[16]→ 14、DocFragment[326]→ 274方向都是向后的任何 CREngine 规则都不可能产生。复盘还指出其参考夹具是循环论证的当时证明该片段应该在第 9 章的唯一证据就是百分比——而百分比恰恰是被质疑的那个信号于是测试实际上是启发式自己同意自己。9. 第二次报告 #6051同 bug 的最锐利复现2026-09-04问题以几乎相同的方式在Readest 0.12.1iOS Windows KOReader Sync Server上再次出现Reading progress mismatch两个平台上位置都被偏移/回滚。当天即被标记为 #5980 的重复关闭issuecomment-5541704831——因为修复 commit c81bd0bee 已在main上但尚未发布0.12.6 发布于 2026-08-29修复落在 2026-09-01所以 0.12.1 和 0.12.6 都还带着锚定机制。9.1 为什么这本书是最干净的演示复现书是Apple: The First 50 YearsDavid Poguecalibre 制作md50e68389632918567417e1033269cdc8a121 项 spine全部application/xhtmlxml无linearno。其三个索引文件index_split_000/001、index01占 2,149,592 字节 spine XHTML 中的519,455 字节24.2%却只贡献约 6.3% 的文本——因为索引几乎全是a标记。foliate 按section.size未压缩 zip 条目大小加权于是它的字节表和 KOReader 基于页面的百分比在全书中单调背离最大差距达19 个百分点章节foliate 字节区间KOReader 文本区间ch25 (31)34.07–35.77%43.15–45.29%ch34 (41)45.67–48.63%57.98–61.83%ch50 (58)67.47–70.00%86.11–89.44%index_split_000 (116)74.81–81.21%92.76–94.46%因此tol 1e-4的容差检查在第 5 章之后的几乎每个位置都会失败重锚定无处不在。把 v0.12.1 的resolveSpineSectionIndex原样跑在真实 spine 尺寸上KOReader 百分比用文本分数代理31 ch25 koPct44.22% - 39 ch32 DRIFT 8 41 ch34 koPct59.90% - 51 ch43 DRIFT 10 55 ch47 koPct82.67% - 117 index_split_001 DRIFT 62 58 ch50 koPct87.78% - 117 index_split_001 DRIFT 59方向在这里始终向前正文区文本分数 字节分数与 #5111 两个声称案例完全相反——再次印证了第 8 节的方向性测试。9.2 双重失败两条路径落在同一个错误位置重锚定后的章节里转换抛出Element index 4 out of bounds for tag p而 0.12.1 的catch让navigated保持 false回退到goToFraction(remote.percentage)——它用的是同一张字节表于是也落在索引文件里。两条路径殊途同归用户看到的是位置而非报错也正因为是纯位置数学、零平台代码它在 iOS 和 Windows 上完全一致地复现。9.3 修复后在真实书上的验证临时 vitest jsdom验证后即删除无源码改动/body/DocFragment[56]/body/section/p[4]/text().0→epubcfi(/6/112!/4/2/12/1:0) spine 55 ch47/body/DocFragment[32]/body/section/p[5]/text().0→epubcfi(/6/64!/4/2/16/3:0) spine 31 ch25。xcfi.kosync-nominal-docfragmentkosyncProgressuseKOSync共 25 项通过。9.4 给后来者的测试陷阱calibre 书的嵌套结构章节是bodysectionspan/h2/p/...section.../section/section的嵌套结构即使文件里有 107 个p外层section的直接p子节点也只有约 9 个——/body/section/p[10]结构上不可能CREngine 会发出/body/section/section[3]/p[2]这样的路径。断言之前先探测 DOMvitest 里console.log会被吞掉需要把调查结果写入文件再cat。9.5 遗留问题刻意不修即使落在正确段落这本书正文末尾 Readest 仍显示约 70%而 KOReader 显示约 89%——因为SectionProgresspackages/foliate-js/progress.js 的SectionProgress按包含标记的原始文件大小加权。任何带大索引的 calibre 书都有这个长期存在的百分比差距。修复它需要改动 foliate 的加权方式会让每本书存储的进度、位置号和同步比较全部变化波及面是全书的——因此有意未处理。10. 修复过程中 CodeRabbit 抓到的真回归与测试纪律复盘文档记录了一个在 51e9f22ca 中由 CodeRabbitr3906132538发现、确认并修复的真实回归pullProgress原本的逻辑是receive || (silent remoteIsNewer)时调用applyRemoteProgress但没有await它然后无条件setSyncState(synced)而自动推送 effect 在除receive外的所有策略下都监听syncState synced——于是silent路径释放了防抖的 PUT把过期的本地位置写回并覆盖了更新的远端 XPointer——这正是 #5065 的原始伤害resolveWithRemote有完全相同的 bug。修复方式applyRemoteProgress改为返回Promiseboolean两个调用方都setSyncState(applied ? synced : error)。该漏洞在移除前就存在旧代码在getRemoteFraction为 undefined 时也提前返回只是移除百分比兜底把它从罕见提升为常见。由此沉淀出两条测试纪律已写进仓库RULE#5065的回归测试驱动的是prompt策略构造冲突后落在setSyncState(hasConflict ? ...)而silent分支是另一条未被测试的代码路径。任何对 KOSync apply/pull 的改动两条策略都必须覆盖并且需要阳性对照防止测试通过全局禁用推送而假通过。当前 src/tests/hooks/useKOSync.test.tsx 已按此纪律组织测试桩显式构造远端 XPointer、同值百分比#5065 陷阱、更新时间戳、不同device_id以驱动prompt策略的冲突判定与silent策略的状态迁移。11. 上线状态、局限与经验总结11.1 发布状态修复以c81bd0bee 合并PR #60142026-09-01由 51e9f22ca移除 cca243b11review 修复squash 而成issue #5980 关闭复盘明确记录了局限性没有做过真机验证——一切验证都是单元测试 对报告者真实 EPUB 的离线解析报告者也从未被要求在构建版上确认silent路径如今落在syncState error以前落在synced而这个状态迁移只曾在 jsdom 里运行过同一份说明也发布在了 PR #5111 的评论issuecomment-5497301323上向社区解释回退原因。11.2 本次事故的可迁移经验索引与度量要分离章节索引DocFragment[N]是坐标自带的结构化信息阅读百分比是另一引擎按自己的分页规则算出的度量——拿度量去校正索引等于用一个噪声信号覆盖一个确定信号量纲不同不能互相验证CREngine 的分页百分比和 foliate 的字节占比在两个不同的空间里漂移重尾内容如 Notes/Index 会放大这种漂移任何百分比越界 索引漂移的启发式都必然误伤投机性修复要能自证一个假设性 bug 的修复至少要有真实上报、方向合理的证据、能检测出该 bug 的回归测试——#5111 三条全缺反而被注释引用不存在 issue测试恒真方向反了三个事实拆穿删除优先于修补当更聪明的修补无法区分漂移与百分比分歧时结构性失败率最高的做法往往是把启发式整个删掉让不变量恒等映射回归兜底路径要审计这次事故里转换失败回退百分比的兜底把每个错误都伪装成了正常跳转——navigated标志的失败传播、Promiseboolean的应用结果传递都是防止错误被静默吞掉的关键工程细节。如果你正在做跨引擎的阅读位置同步CFI ↔ XPointer或正在设计任何用粗粒度度量校正结构化索引的机制这份复盘值得全文对照阅读。核心实现与回归测试都在仓库中转换器 src/utils/xcfi.ts、同步 hook src/app/reader/hooks/useKOSync.ts 与 src/app/reader/hooks/useProgressSync.ts、判别逻辑 src/app/reader/hooks/kosyncProgress.ts以及回归测试 src/tests/utils/xcfi.kosync-nominal-docfragment.test.ts 和 src/tests/hooks/useKOSync.test.tsx。赞分享桌面应用跨平台前端【免费下载链接】readestReadest is a modern, feature-rich ebook reader designed for avid readers offering seamless cross-platform access, powerful tools, and an intuitive interface to elevate your reading experience.项目地址https://gitcode.com/gh_mirrors/re/readest点击查看免费下载相关推荐PHPExcel与PhpSpreadsheet性能对比为什么必须迁移PHPExcel与PhpSpreadsheet性能对比为什么必须迁移 在PHP的Excel处理领域 PHPExcel 曾经是无可争议的王者但这个项目在2后端数据处理Gazebo海洋波浪模拟器构建高保真水面无人艇仿真环境的技术实践Gazebo海洋波浪模拟器构建高保真水面无人艇仿真环境的技术实践 在无人水面艇的研发和测试过程中 海洋波浪仿真 面临三大核心挑战 实时性要求高 、 物理精自动驾驶彻底搞懂TypeScript类型定义文件为什么.d.ts比.ts更重要彻底搞懂TypeScript类型定义文件为什么.d.ts比.ts更重要 你还在为这些问题头疼吗 引入JavaScript库时TypeScript疯狂报错上一篇async-http-client原生镜像构建指南3个关键步骤详解下一篇Duktape正则表达式引擎内置Unicode支持的强大功能创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表