Readest 从 ReadEra 备份导入书内标注:.bak 格式解析、XPointer 归一化与分级锚定策略
桌面应用跨平台前端【免费下载链接】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阅读器应用为 ReadEra 备份标注导入issue #5982PR #6032的实现为主线系统讲解 ReadEra.bak备份的 zip 内部结构与library.json字段语义、书目匹配的标题/MD5 两级策略、ReadEra 特有的body/bodyXPointer 与autoBoxing伪节点归一化、以及文本搜索 → XPointer → 章节起点的三级锚定顺序。读完本文你将掌握一个跨阅读器标注迁移功能从格式逆向、坐标换算到端到端验证的完整工程思路并能直接对照本仓库源码apps/readest-app/src/utils/readera.ts、apps/readest-app/src/services/annotation/providers/readera.ts、apps/readest-app/src/utils/xcfi.ts复现和扩展这套实现。本实现的技术细节记录在仓库的 readera-import-5982.md 设计笔记中本文以它为骨架并结合源码、测试展开。一、功能背景按书导入明确边界ReadEra 备份导入是 Readest 针对 issue #5982 的按书per-book导入功能PR #6032 以 squash 提交8b8fbe14d合入分支feat/readera-import。它复用阅读器中已有的ImportAnnotationsDialog弹窗与该对话框里已有的 Moon Reader.mrexpt导入共用同一套行式列表 UI——在 ImportAnnotationsDialog.tsx 中可以同时看到三个数据源行Readest 自身.json、Moon Reader.mrexpt、ReadEra.bak。功能边界在设计之初就做了明确约定属于绑定决策导入内容高亮与笔记citations、书签bookmarks、阅读进度reading position明确不做ReadEra 的集合collections映射为分组groups、状态/评分status/rating字段。需要特别说明的是按该设计笔记的记录该功能合入时尚未在真实设备上验证过——从未实际导入过一本真正由 ReadEra 阅读过的书所有格式细节均来自 issue #5982 附带的一份样例备份开发机路径~/Documents/books/issues/5982/ReadEra-Premium_2026-08-31_13.56.bak不属于仓库。因此下面的格式描述均以样例备份中观察到的口吻表述这是逆向实现类代码的常态。二、备份格式解剖ReadEra-plan_date_time.bak其实是一个 zip2.1 文件命名与选择器ReadEra 的导出文件按ReadEra-plan_date_time.bak命名如ReadEra-Premium_2026-08-31_13.56.bak。设计笔记特别提醒最初曾猜测是.dat扩展名那是错误的文件选择器必须接受.bak。在 Annotator.tsx 的importFromReadEra中选择器参数为const result await selectFiles({ type: generic, accept: .bak, extensions: [bak], multiple: false, dialogTitle: _(Select ReadEra Backup File), });2.2 zip 内文件清单备份是一个普通 zip内含library.json以及meta.json、prefs.xml、search-history.xml等辅助文件。关键点备份里没有任何书文件book files——因此导入只能把标注挂到用户 Readest 书库里已有的书上这正是后面书目匹配逻辑存在的根本原因。library.json的根结构为{ docs, colls, words }其中每个doc包含data文档元数据与阅读位置、citations高亮note_type: 3、bookmarks书签note_type: 2已删除的书会留在文件里带doc_delete_time字段——解析时必须跳过见 parseDoc 中对doc_delete_time的判空note_data是一个JSON 字符串不是嵌套对象里面是ratio/page/pagesCount/xPath/xPathEnd等定位字段需要二次JSON.parse见parseReadEraPositionreadera.tsnote_extra是用户手写笔记——样例备份 2788 条 note 中只有 225 条带笔记note_mark取值 0-4是 ReadEra 调色板索引但调色板的顺序备份中并不记录只能做尽力而为的映射。2.3 读取library.json的代码路径extractReadEraLibrary 使用zip.js/zip.js的ZipReader打开 zip在条目中查找library.json兼容根目录或子目录前缀/library.json用TextWriter读出全文随后 parseReadEraBackup 对内容做 JSON 解析校验根对象含docs数组后逐条parseDoc。非 zip、非 ReadEra 备份一律返回null由调用方弹出This is not a ReadEra backup file.提示。三、书目匹配标题/作者为主整文件 MD5 兜底3.1 为什么哈希对不上ReadEra 以整文件的 sha1/md5 作为文档键样例中uri形如sha-1:doc_sha11108/1118 个活跃文档如此aliases还持有size:bytes-mtime-device形式的备用键这让 uri 成为一种内容哈希而 Readest 书库以partialMD5键控——两边的哈希永远不会天然对齐所以主匹配必须走人可读的标题/文件名/作者路线。3.2 标题打分匹配findReadEraDocForBook实现位于 findReadEraDocForBook算法要点归一化标题/作者/文件名先做normalizeText——小写、NFKD 去重音符号、非字母数字折叠为单个空格readera.ts打分标题精确相等记 2 分containsTitle包含关系记 1 分作者精确相等再加 1 分总分必须 ≥ 2 才被接受平局时优先选标注更多的那条候选避免把注释导到空文档上格式不匹配如 EPUB vs PDF直接跳过matchesFormat大小写不敏感比对。containsTitle的包含判定很克制readera.ts较短标题长度必须 ≥ 12 字符且较短长度 / 较长长度 ≥ 0.5。这样允许The Little Prince命中The Little Prince (Illustrated)比例 0.59属于版本/副标题后缀拒绝Dune命中Dune Messiah或Dune 2比例 0.33因为把续作的高亮写进前作会污染另一本书。3.3 整文件 MD5 兜底getReadEraFileMd5findReadEraDocByFileMd5当标题匹配一无所获改名文件、标题太短等才走文件哈希路线getReadEraFileMd5 对正在阅读的书的bookData.fileFile 对象已在内存中计算fullMD5——js-md5 增量实现、按 4 MB 分块读取避免一次性读入大文件结果按书 hash 缓存 Promise同一会话重复导入只算一次findReadEraDocByFileMd5 对doc_md5做精确大小写不敏感比对且先经isMd5校验格式该路径仅在findReadEraDocForBook返回空之后运行见 Annotator.tsx。MD5 兜底还买来一条更严格的标题规则因为改名文件能被哈希抓住标题包含判定才敢要求 ≥ 12 字符避免Dune误中Dune 2。设计笔记诚实记录由于本地书库与样例库无重叠这条路径未对真实 ReadEra 阅读过的文件验证过但一次错误猜测只浪费一次哈希计算随后会落回标题路径代价可接受。四、XPointer 归一化body/body怪癖与autoBoxing伪节点这是整个功能承重墙load-bearing级别的坑值得单独成节。4.1 现象ReadEraCREngine产出的 XPointer 会把源文档自身的body留在 fragment 内部主流形态/body/DocFragment[N]/body/body/...——样例约 626 个流式reflowable定位器中 460 个如此旧版 DOM 形态/body/DocFragment[N]/body/html/body/...——94 个此外 CREngine 会在内联内容片段周围插入合成盒autoBoxing/autoBoxing或/autoBoxing[N]这些节点在源 XHTML 中根本不存在。而 KOReader 的 XPointer 两种形态都没有。查看 resolveXPointerPath 可知XCFI消费的正则锚定在^/body/DocFragment(?:\[\d\])?/body(.*)$把余下路径解析到真实 XHTML 文档的document.body上。因此多余的那一层body必须剥掉否则所有 XPointer 兜底都会落空autoBoxing同理必须剔除。4.2 归一化实现两者都收在 normalizeReadEraXPointer 一个函数里两条正则export const normalizeReadEraXPointer (xpointer: string): string xpointer .replace(/^(\/body\/DocFragment\[\d\]\/body)\/(?:html\/)?body/, $1) .replace(/\/autoBoxing(\[\d\])?/g, );第一条把/body/DocFragment[N]/body/(html/)?body折叠回/body/DocFragment[N]/body同时兼容新旧两种 DOM 形态第二条删除所有autoBoxing步带不带索引号都要删。测试用例覆盖了三种输入形态见 readera.test.ts并确认纯 KOReader 形态的 XPointer 原样通过、不被误伤。五、锚定顺序与unmatched语义文本 → XPointer → 章节起点转换核心是 convertReadEraDocToBookNotes。每个 note 的定位按三级顺序尝试5.1 第一级跨节点文本搜索ReadEra 保存的note_body是当时高亮到的文字用它在 ReadEra 命名的那个 section 文档里做搜索能容忍同一本书的不同版本/排版先把 section 文档的所有文本节点展平成一个空白折叠、小写化的字符串同时保留每个字符的源位置buildHaystackcollectTextNodes跳过script/style这样匹配可以横跨多个内联元素findReadEraTextRange 在折叠后的 haystack 里indexOf定位把命中位置还原为 DOMRange再经CFI.fromRange转成 CFIrequireUnique参数当 note 带 XPointer调用方有兜底时若文本出现第二次就返回null把机会让给 XPointer——歧义短语不足以决定用户高亮了哪一处定位成功的 CFI 同样要rebaseOntoSection挂回真实 spine step。5.2 第二级归一化 XPointer文本找不到不同版本文字已变就回落到cfiFromXPointerproviders/readera.ts仅当xPath.startsWith(/body/DocFragment[)时用XCFI把已归一化的起点/终点 XPointer 转成 CFI。注意它只看DocFragment形态PDF 的/page[...]不走这里。5.3 第三级章节起点unmatched 的计数来源前面都失败时锚到 section 起点sectionStartCfi保证笔记至少落在正确的章节里。unmatched 只统计带 XPointer 的 note 落到第三级的情况——纯页码定位page-only不算 unmatched因为它的精度本来就只有一页。5.4 书签跳过文本搜索书签bookmarks的note_body是用户标签如Bookmark 1从来不是摘录文字对它做文本搜索没有意义因此书签直接从 XPointer 开始锚定XPointer 失败就落到章节/页码起点。5.5 不跨章节重扫spine 索引是算出来的不是猜出来的锚定完全信任XPointer 中的DocFragment[N]即 spine 第N-1节绝不根据阅读百分比去漂移重锚。这一约定与 KOReader 同步链路同源在 xcfi.ts 的注释里有完整论证CREngine 的 EPUB 导入器按spine顺序为每个 item 恰好创建一个DocFragmentSVG spine item 用SpineSvgWrapper包裹、解析失败用SpineItemUnsupported占位所以索引一一对应、绝不漂移而百分比来自 CREngine 自己的分页在背页Notes/Index 占 spine 字节 44% 的书上会整体偏离一个章节——这是历史教训#5980 等总结出的规则。5.6 纯页码定位page-only locators样例中大多数doc_position和 33 条 note全部是 PDF 书签不带 xPath只有page/ratio/pagesCount。处理规则见readEraSectionIndexproviders/readera.ts分页书paged判定条件!sections.some(s s.cfi)——section 无 spine CFI 说明每节就是一页此时页码就是章节号直接落到该页、什么也不丢也不计入 unmatched流式书reflowable页码是 ReadEra 自己的分页结果与 spine 结构无关宁可丢弃定位器也不猜测章节沿用 #5980 的规则这类 note 计为 unmatched。六、两个最容易搞错的坐标换算6.1XCFI的 spine step 必须 rebase 到真实section.cfiXCFI.adjustSpineIndexxcfi.ts总是按(spineItemIndex 1) * 2输出/6/{2(i1)}!前缀这只对spine itemrefs 是 package 文档仅有的相关子节点的书成立。因此转换结果必须 rebaseEPUB 等有 section CFI 的书rebaseOntoSectionproviders/readera.ts剥掉epubcfi(...)外壳按!切分把路径段挂到section.cfi上并防御性去掉 CFI 的间接标记避免拼出!!PDF 等固定版式书section没有 CFI用CFI.fake.fromIndex(index)合成与 foliate-js 一致的/6/{2(i1)}基座sectionBaseCfiproviders/readera.ts。6.2 PDF 页码是 0 基DocFragment是 1 基PDF 定位器形态是/page[N]/block/line/charx:y其中page 索引是 0 基设计笔记确认用ratio × pagesCount做过统计交叉验证而DocFragment[N]是 1 基。所以DocFragment[N]→ section 索引N - 1/page[N]→ 对固定版式书来说 N 本身就已是 section 索引原样使用纯page字段无 xPath在分页书上也直接当 section 索引。readEraSectionIndex正是按这个规则实现的providers/readera.ts。把 PDF 页索引改成 1 基会让两个 PDF 测试全部失败见第八节可见这条约定是经过测试钉死的。七、阅读进度与幂等性设计7.1 进度只在书没有自己的进度时采用ReadEra 文档的doc_position转换出的 location仅当这本书当前没有config.location时才写入——与 Readest 自身导入器的规则一致导入一本你正读到一半的书绝不能被备份里的旧进度挪走。代码在 Annotator.tsxif (updatedConfig conversion.location !config.location) { const position { location: conversion.location }; setConfig(bookKey, position); updatedConfig { ...updatedConfig, ...position }; }流式书的 XPointer 若未能解析成 CFIlocation 返回undefined不降级到章节起点同样是 #5980 规则分页书则可以直接锚到 page 起点。7.2 稳定 note id 保证重复导入是 no-op每个导入 note 的 id 是readera-${note.uri}providers/readera.ts。uri来自备份中 ReadEra 内部生成的note_uriUUID 等稳定且全局唯一——同一备份重复导入时mergeImportedBookNotes按 id 比对会发现内容完全一致等价于 no-op。真书测试里专门有一条is idempotent用例验证两次转换结果逐字段相等readera-import-real-book.test.ts。7.3 只含进度的文档也要导入一个 ReadEra 文档可能没有任何 citations/bookmarks、只有阅读进度——这仍然值得导入。因此空文件提示的判定条件是转换结果既无 notes 也无 location而不是转换前 notes 为空见下一节 CodeRabbit 修复 1。八、端到端验证真书测试与三个关键回归8.1 测试策略测试内构造备份不提交真实备份由于真实 ReadEra 备份包含用户整个个人书库那份 665 KB 样例是用户隐私数据禁止作为 fixture 提交端到端测试 readera-import-real-book.test.ts 在测试内用ZipWriter现造一个ReadEra 形状的 zip每个字段、每种 XPointer 形态body/body、旧版body/html/body、autoBoxing都从 #5982 样例备份抄录然后分别对sample-alice.epub和sample-alice.pdf走完整导入并把产出的 CFI解析回文本做断言anchorText辅助函数。8.2 关键回归改动会立刻炸掉哪些测试撤销body/body剥离→ 8 个 EPUB 测试挂 4 个把 PDF 页索引改成 1 基→ 两个 PDF 测试全挂。这两组断言把第六节、第四节的坐标/归一化约定焊死在回归里。8.3 PDF 测试的特殊前置PDF 套件需要先配置pdfjsLib.GlobalWorkerOptions.workerSrc从pdf-cfi.test.ts借用的 setup指向public/vendor/pdfjs/pdf.worker.min.mjs且 PDF 的BookDoc没有resolveCFI测试需手工用CFI.parseCFI.fake.toIndexCFI.toRange把 CFI 解析回 section 文档再断言文本。这从侧面印证了 PDF 坐标基座是合成 CFI第六节。单元层另有 readera-import.test.ts文本搜索、颜色映射、三级锚定、PDF 定位、幂等与 readera.test.ts解析、归一化、标题匹配、MD5 匹配、哈希缓存覆盖边界如带定位器的高亮文本出现两次时回退 XPointer重排书绝不从页码猜章节等。九、CodeRabbit 评审的四个修复与两个取舍设计笔记记录了评审提交b9e682a2c引发的四处修复进度-only 文档被提前丢弃Annotator 原先在转换前用citations.length 0 bookmarks.length 0提前 return会把只有进度的文档丢掉现改为转换后再判断既无 notes 也无 location才弹空文件提示location 兜底守卫收窄fallback 守卫从任意 xPath改为xPath.startsWith(/body/DocFragment[)使 PDF 的/page[N]/block/...定位仍能落到它的页上歧义短语让位 XPointerfindReadEraTextRange(doc, text, requireUnique)在第二次出现时返回 nullnote 循环以Boolean(note.position?.xPath)传参——带定位器的 note 遇歧义就交给 XPointer无定位器的 note 仍取首次命中标题包含阈值定在 0.5CodeRabbit 建议 0.8但 0.8 会拒绝The Little PrincevsThe Little Prince (Illustrated)0.59已有测试覆盖0.5 仍能拒绝DunevsDune Messiah0.33。评审中被拒绝的两项缓存展平后的 haystack理由每节不足 5 条 note收益甚微以及把真书转换提升到beforeAll理由测试隔离优先于省 ~4 秒。十、完整集成流程Annotator 里的调用链把以上各环节串起来importFromReadEraAnnotator.tsx的完整流水线是校验bookDoc/book就绪否则 toast 提示稍后再试selectFiles弹.bak选择器单选readSelectedFileBytes读取为 ArrayBuffer桌面端走appService.readFile二进制路径extractReadEraLibrary(data)解出library.json→parseReadEraBackup解析文档数组解析失败 toast This is not a ReadEra backup file.findReadEraDocForBook(docs, book)按标题/作者匹配无果且file存在时findReadEraDocByFileMd5(docs, await getReadEraFileMd5(book.hash, file))哈希兜底仍无果 toast This book was not found in the ReadEra backup.convertReadEraDocToBookNotes(readEraDoc, bookDoc)转换转换中每 5 条 note 让出一次事件循环YIELD_EVERY 5保证大书导入时 UI 不卡死结果既无 notes 也无 location → toast No annotations found in the file.mergeImportedBookNotes合并现有config.booknotesupdateBooknotes落库仅当无自有config.location时采纳备份进度逐条把applied的新 note 通过views.forEach(v v.addAnnotation(note))加入各阅读视图实时反映到书页上toast 汇总Imported N annotations若conversion.unmatched 0追加 N not found in this book 提示可据此反查哪些笔记只落到了章节起点。结语这套实现的可复用要点从 ReadEra 导入可以沉淀出三条对任何跨阅读器标注迁移都成立的工程经验格式逆向要以真实样例为准文件扩展名.bak而非.dat、嵌套 JSON 字符串note_data、被删除文档留在备份里doc_delete_time这类细节只有解剖真实备份才能发现坐标系统差异要集中归一化并配回归测试body/body剥离、autoBoxing剔除、1 基 vs 0 基页码、合成 CFI 基座全部收敛在少数几个函数里normalizeReadEraXPointer、readEraSectionIndex、sectionBaseCfi并用改回去就炸测试的方式钉死宁可丢失定位也不猜测流式书不拿页码猜章节、不跨章节重扫、不带 XPointer 的 note 不计 unmatched——精度边界本身就是产品决策写进注释和测试才能长期保持。如需深入建议按顺序阅读 readera.ts格式与匹配、providers/readera.ts锚定与转换、xcfi.tsCFI/XPointer 互转与 spine 索引语义再对照三份测试文件验证上述每一条行为约定。赞分享桌面应用跨平台前端【免费下载链接】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 注解 JSON 导出/导入实战从锚点修复到真实数据回归5400 / 5440Readest 注解 JSON 导出/导入实战从锚点修复到真实数据回归 5400 / 5440 导读 本文围绕 Readest 阅读器新增的注解Ann桌面应用跨平台前端Longformer核心原理解析从传统Transformer到滑动窗口的演进Longformer核心原理解析从传统Transformer到滑动窗口的演进 Longformer作为一款革命性的长文档Transformer模型彻底改变了MiroFish智能预测引擎3分钟掌握未来趋势预测的终极指南MiroFish智能预测引擎3分钟掌握未来趋势预测的终极指南 你是否曾想过如果能够提前预知未来趋势你的决策会有多明智MiroFish正是这样一个让你梦想人工智能大模型AI Agent多智能体Agent 编排上一篇Windows 11终极清理指南用Win11Debloat免费提升51%系统性能下一篇漏洞零容忍用pyenv构建Python版本安全防线终极指南创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考