- 桌面应用
- 跨平台
- 前端
【免费下载链接】readest
Readest 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.
本文以 Readest(阅读器应用)为 ReadEra 备份标注导入(issue #5982,PR #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其实是一个 zip
2.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(见parseReadEraPosition,readera.ts);note_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_sha1>,1108/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 兜底:getReadEraFileMd5+findReadEraDocByFileMd5
当标题匹配一无所获(改名文件、标题太短等),才走文件哈希路线:
- getReadEraFileMd5 对正在阅读的书的
bookData.file(File 对象已在内存中)计算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 现象
ReadEra(CREngine)产出的 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 文档的所有文本节点展平成一个空白折叠、小写化的字符串,同时保留每个字符的源位置(
buildHaystack+collectTextNodes,跳过script/style),这样匹配可以横跨多个内联元素; - findReadEraTextRange 在折叠后的 haystack 里
indexOf定位,把命中位置还原为 DOMRange,再经CFI.fromRange转成 CFI; requireUnique参数:当 note 带 XPointer(调用方有兜底)时,若文本出现第二次就返回null,把机会让给 XPointer——歧义短语不足以决定用户高亮了哪一处;- 定位成功的 CFI 同样要
rebaseOntoSection挂回真实 spine step。
5.2 第二级:归一化 XPointer
文本找不到(不同版本文字已变)就回落到cfiFromXPointer(providers/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 恰好创建一个DocFragment(SVG spine item 用SpineSvgWrapper包裹、解析失败用SpineItemUnsupported占位),所以索引一一对应、绝不漂移;而百分比来自 CREngine 自己的分页,在背页(Notes/Index 占 spine 字节 44% 的书)上会整体偏离一个章节——这是历史教训(#5980 等)总结出的规则。
5.6 纯页码定位(page-only locators)
样例中大多数doc_position和 33 条 note(全部是 PDF 书签)不带 xPath,只有page/ratio/pagesCount。处理规则(见readEraSectionIndex,providers/readera.ts):
- 分页书(paged):判定条件
!sections.some(s => s.cfi)——section 无 spine CFI 说明每节就是一页,此时页码就是章节号,直接落到该页、什么也不丢,也不计入 unmatched; - 流式书(reflowable):页码是 ReadEra 自己的分页结果,与 spine 结构无关,宁可丢弃定位器也不猜测章节(沿用 #5980 的规则),这类 note 计为 unmatched。
六、两个最容易搞错的坐标换算
6.1XCFI的 spine step 必须 rebase 到真实section.cfi
XCFI.adjustSpineIndex(xcfi.ts)总是按(spineItemIndex + 1) * 2输出/6/{2(i+1)}!前缀,这只对"spine itemrefs 是 package 文档仅有的相关子节点"的书成立。因此转换结果必须 rebase:
- EPUB 等有 section CFI 的书:
rebaseOntoSection(providers/readera.ts)剥掉epubcfi(...)外壳,按!切分,把路径段挂到section.cfi上(并防御性去掉 CFI 的间接标记,避免拼出!!); - PDF 等固定版式书:section没有 CFI,用
CFI.fake.fromIndex(index)合成与 foliate-js 一致的/6/{2(i+1)}基座(sectionBaseCfi,providers/readera.ts)。
6.2 PDF 页码是 0 基,DocFragment是 1 基
PDF 定位器形态是/page[N]/block/line/char@x: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.tsx:
if (updatedConfig && conversion.location && !config.location) { const position = { location: conversion.location }; setConfig(bookKey, position); updatedConfig = { ...updatedConfig, ...position }; }流式书的 XPointer 若未能解析成 CFI,location 返回undefined,不降级到章节起点(同样是 #5980 规则);分页书则可以直接锚到 page 起点。
7.2 稳定 note id 保证重复导入是 no-op
每个导入 note 的 id 是`readera-${note.uri}`(providers/readera.ts)。uri来自备份中 ReadEra 内部生成的note_uri(UUID 等),稳定且全局唯一——同一备份重复导入时,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.parse+CFI.fake.toIndex+CFI.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/...定位仍能落到它的页上; - 歧义短语让位 XPointer:
findReadEraTextRange(doc, text, requireUnique)在第二次出现时返回 null,note 循环以Boolean(note.position?.xPath)传参——带定位器的 note 遇歧义就交给 XPointer,无定位器的 note 仍取首次命中; - 标题包含阈值定在 0.5:CodeRabbit 建议 0.8,但 0.8 会拒绝
The Little PrincevsThe Little Prince (Illustrated)(0.59,已有测试覆盖);0.5 仍能拒绝DunevsDune Messiah(0.33)。
评审中被拒绝的两项:缓存展平后的 haystack(理由:每节不足 5 条 note,收益甚微)以及把真书转换提升到beforeAll(理由:测试隔离优先于省 ~4 秒)。
十、完整集成流程:Annotator 里的调用链
把以上各环节串起来,importFromReadEra(Annotator.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.booknotes,updateBooknotes落库;仅当无自有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.ts(CFI/XPointer 互转与 spine 索引语义),再对照三份测试文件验证上述每一条行为约定。
- 桌面应用
- 跨平台
- 前端
【免费下载链接】readest
Readest 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.
相关推荐
Readest 注解 JSON 导出/导入实战:从锚点修复到真实数据回归(5400 / 5440)
Readest 注解 JSON 导出/导入实战:从锚点修复到真实数据回归( 5400 / 5440) 导读 本文围绕 Readest 阅读器新增的"注解(Ann
桌面应用跨平台前端Longformer核心原理解析:从传统Transformer到滑动窗口的演进
Longformer核心原理解析:从传统Transformer到滑动窗口的演进 Longformer作为一款革命性的长文档Transformer模型,彻底改变了
MiroFish智能预测引擎:3分钟掌握未来趋势预测的终极指南
MiroFish智能预测引擎:3分钟掌握未来趋势预测的终极指南 你是否曾想过,如果能够提前预知未来趋势,你的决策会有多明智?MiroFish正是这样一个让你梦想
人工智能大模型AI Agent多智能体Agent 编排
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考