- 桌面应用
- 跨平台
- 前端
【免费下载链接】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 仓库中 apps/readest-app/.claude/memory/fixed-layout-rtl-spread-5591.md 这份项目记忆文档,完整还原了一次针对固定版式(fixed-layout)书籍 RTL 页面顺序问题的修复过程:日本摄影画册 PDF 的跨页按从左到右配对、阅读方向错误。该问题最终通过 ViewMenu 新增 "Right-to-Left Pages" 开关、复用既有writingMode设置、PDF ViewerPreferences R2L 自动检测以及 Shift+F 快捷键设置对话框的 bug 修复共同解决。读完本文,你将掌握固定版式书籍方向控制的可达性设计、RTL 机制的完整链路(writingMode→book.dir→viewSettings.rtl)、PDF R2L 自动检测的原理与测试夹具生成方法,以及书库快捷键与设置对话框 bookKey 的关联陷阱。
1. 问题背景:固定版式书籍的 RTL 需求从何而来
Readest 是一款支持多平台(Windows、macOS、Linux、iOS、Android 等)的现代电子书阅读器。对于pre-paginated(固定版式)书籍——例如日本摄影画册的 PDF、漫画 CBZ、固定版式 EPUB——其页面排版与翻页顺序通常与书籍语言的方向性(dir,即 LTR 或 RTL)强相关。
Issue #5591 报告的核心现象是:日本摄影画册 PDF 的跨页(spread)被按从左到右配对、翻页顺序错误。日文书籍的阅读方向传统上是纵向从右到左,即使是横向排版的日文书籍,其跨页顺序也常常遵循从右到左的装订方向。而 Readest 在未做任何处理时,将这类书籍当作 LTR 书籍处理,导致跨页配对和翻页方向与物理书籍相反。
关键点在于:RTL 的基础机制在 Readest 中早已存在(即fixed-layout.js中 honorsbook.dir === 'rtl'的跨页配对与顺序、FoliateViewer打开时把writingMode映射到bookDoc.dir、viewSettings.rtl在分页中翻转点击/滑动方向等),真正缺失的是可达性(reachability):唯一的控制入口藏在设置 > 布局 > 书写模式(Writing Mode)里,且被MIGHT_BE_RTL_LANGS条件门控,位置远离跨页控制。用户很难找到它,更不用说把它和"跨页配对顺序"关联起来。
因此这次修复(app PR #5712、folioe#75 合并于 2026-08-15)的思路不是新建一套方向机制,而是让既有机制变得可达。
2. 既有 RTL 机制链路:writingMode → book.dir → viewSettings.rtl
在深入修复方案之前,先梳理 Readest 中已存在的 RTL 机制链路,这是理解本次修复为何"只加开关、不发明新设置"的前提。
2.1 设置层:writingMode的四个取值
Readest 的WritingMode类型定义在 apps/readest-app/src/types/settings.ts,包含auto、horizontal-tb、horizontal-rl、vertical-rl四种取值。其中:
horizontal-tb:横向排版、块级流向从上到下(即常规的从左到右阅读);horizontal-rl:横向排版、从右到左阅读;vertical-rl:纵向排版、从右到左阅读(日文传统版式);auto:交给文档自身决定。
在 apps/readest-app/src/components/settings/LayoutPanel.tsx 中,设置面板以分段按钮的形式呈现这四个选项,并且只有在MIGHT_BE_RTL_LANGS.includes(langCode) || isCJKEnv()时才展示(该文件第 526 行)。
MIGHT_BE_RTL_LANGS定义于 apps/readest-app/src/services/constants.ts 第 1045-1058 行:
export const MIGHT_BE_RTL_LANGS = [ 'zh', 'ja', 'ko', 'ar', 'he', 'fa', 'ur', 'dv', 'ps', 'sd', 'yi', '', ];注意列表中的'':空字符串代表语言未知的书籍也会被纳入——这保证了没有语言元数据的书也能使用书写模式控制。对语言与方向关系的验证见 apps/readest-app/src/tests/services/constants.test.ts(MIGHT_BE_RTL_LANGS包含ar、he、fa等 RTL 语言的断言)。
2.2 映射层:getBookDirFromWritingMode
writingMode与book.dir之间的映射由 apps/readest-app/src/utils/book.ts 第 424-434 行实现:
export const getBookDirFromWritingMode = (writingMode: WritingMode) => { switch (writingMode) { case 'horizontal-tb': return 'ltr'; case 'horizontal-rl': case 'vertical-rl': return 'rtl'; default: return 'auto'; } };这是整个修复方案的核心依据:只要把writingMode写成horizontal-rl,getBookDirFromWritingMode就会返回rtl,从而让既有的固定版式跨页逻辑(fixed-layout.js中 honorsbook.dir === 'rtl')自动生效。
此外,apps/readest-app/src/utils/rtl.ts 提供了语言 → 方向的推导:
export const getDirFromLanguage = (lang: string) => { if (!lang) return 'auto'; const rtlLanguages = new Set(['ar', 'he', 'fa', 'ur', 'dv', 'ps', 'sd', 'yi']); const primaryLang = lang.split('-')[0]!.toLowerCase(); return rtlLanguages.has(primaryLang) ? 'rtl' : 'auto'; };配合 apps/readest-app/src/utils/book.ts 的getBookDirFromLanguage使用。
2.3 消费层:getPageProgressionRTL的优先级裁决
方向真正影响翻页动作的位置在 apps/readest-app/src/libs/document.ts 第 624-635 行的getPageProgressionRTL:
export const getPageProgressionRTL = ( writingMode: string, bookDir: string | undefined, documentRtl: boolean, ) => writingMode.includes('rl') ? true : bookDir === 'rtl' ? true : bookDir === 'ltr' ? false : documentRtl;这段代码体现了清晰的优先级设计(源码注释中也明确说明):
- 用户的书写模式设置优先级最高:只要
writingMode包含rl(即horizontal-rl或vertical-rl),无条件判定为 RTL——这是用户显式指令,无论书籍 spine 怎么说; - 其次看书籍方向
bookDir:rtl为 RTL,ltr为 LTR; - 最后兜底看文档自身的
documentRtl(从文档 DOM 解析出的方向)。
对auto模式下各类组合的单元测试见 apps/readest-app/src/tests/utils/page-progression-direction.test.ts,例如getPageProgressionRTL('auto', 'rtl', false)为true、getPageProgressionRTL('horizontal-tb', 'rtl', false)为false——即显式horizontal-tb可以覆盖文档的 RTL 方向。
2.4 消费点:docLoadHandler中 viewSettings.rtl 的推导
apps/readest-app/src/app/reader/components/FoliateViewer.tsx 的docLoadHandler(第 360-388 行)在文档加载时推导viewSettings.vertical与viewSettings.rtl:
const writingDir = renderer?.setStyles && getDirection(detail.doc); // ... const documentRtl = writingDir?.rtl || getDirFromUILanguage() === 'rtl' || false; const newRtl = getPageProgressionRTL(viewSettings.writingMode, bookDoc.dir, documentRtl); if (viewSettings.vertical !== newVertical || viewSettings.rtl !== newRtl) { viewSettings.vertical = newVertical; viewSettings.rtl = newRtl; setViewSettings(bookKey, { ...viewSettings }); }注释明确指出:固定版式书籍本身不携带书写模式(writing mode),其方向可能来自文档自身(PDF ViewerPreferences/Direction /R2L);UI 语言是最后兜底。getDirFromUILanguage定义在 apps/readest-app/src/utils/rtl.ts 第 10-13 行。
viewSettings.rtl随后被分页逻辑消费:翻转点击/滑动方向。这一整条链路(设置 →book.dir→viewSettings.rtl→ 翻页方向)是修复方案"复用而非发明"的根本原因。
3. 修复方案:在 ViewMenu 固定版式区新增 "Right-to-Left Pages" 开关
3.1 核心设计原则:不要发明平行的 rtl 设置
项目记忆文档中明确强调了一条架构纪律:
任何不写入
writingMode的新方向控制,都会与 Layout 面板以及docLoadHandler中的viewSettings.rtl推导打架。
这意味着:如果新增一个独立的rtl布尔设置而不写writingMode,那么用户在 ViewMenu 切换方向后,docLoadHandler在文档重载时会根据旧的writingMode重新推导viewSettings.rtl,将用户的选择覆盖回去;同时 Layout 面板的书写模式按钮状态也会与 ViewMenu 的开关不一致。因此修复采用骑乘(ride)既有 per-bookwritingMode设置的方式——从 ViewMenu 的固定版式区新增一个 "Right-to-Left Pages" 菜单项,切换horizontal-rl/horizontal-tb。
3.2 开关实现:ViewMenu.tsx
实现位于 apps/readest-app/src/app/reader/components/ViewMenu.tsx:
状态初始化(第 91 行)——以当前书籍方向为初值:
const [rtlSpread, setRtlSpread] = useState(bookData?.bookDoc?.dir === 'rtl');核心副作用(第 293-307 行)——切换时写入writingMode并重建查看器:
useEffect(() => { const bookDoc = bookData?.bookDoc; if (!bookDoc || rtlSpread === (bookDoc.dir === 'rtl')) return; // Writing mode is per-book only (no global fallback), and horizontal-rl is // what flips both the spread order and the page progression, so the toggle // rides the same setting the Layout panel edits instead of a new one. const writingMode = rtlSpread ? 'horizontal-rl' : 'horizontal-tb'; viewSettings.vertical = false; saveViewSettings(envConfig, bookKey, 'writingMode', writingMode, true).then(() => { const view = getView(bookKey); if (view) view.book.dir = rtlSpread ? 'rtl' : 'ltr'; recreateViewer(envConfig, bookKey); }); }, [rtlSpread]);这里有几个值得注意的细节:
- per-book 设置:
writingMode是仅针对单本书的设置(没有全局回退),这保证了切换 RTL 只影响当前书籍; - 同时重置
vertical:切换时显式viewSettings.vertical = false,避免从纵向版式切过来时遗留垂直状态; - 双写策略:先
saveViewSettings持久化writingMode,再在内存中直接设置view.book.dir,最后recreateViewer重建查看器——FoliateViewer打开时会根据新的writingMode重新计算一切(正如记忆文档所说:"recreateViewer + FoliateViewer open recompute everything")。
菜单项渲染(第 477-481 行),位于固定版式区、紧邻 "Separate Cover Page":
<MenuItem label={_('Right-to-Left Pages')} Icon={rtlSpread ? MdCheck : undefined} onClick={() => setRtlSpread(!rtlSpread)} />3.3 测试验证:ViewMenu.test.tsx
配套测试见 apps/readest-app/src/tests/components/ViewMenu.test.tsx,覆盖三条关键行为:
- 对 reflowable(流式)书籍隐藏开关(第 122-128 行):
mockBookData.isFixedLayout = false时断言 "Right-to-Left Pages" 不渲染; - LTR 书切换为 RTL 并重建查看器(第 130-146 行):点击后断言
saveViewSettings收到writingMode: 'horizontal-rl'、view.book.dir变为'rtl'、recreateViewer被调用; - 原生 RTL 书切回 LTR(第 148 行起):
mockBookData.bookDoc.dir = 'rtl'时点击开关,断言写入horizontal-tb并恢复ltr。
4. PDF R2L 自动检测:ViewerPreferences /Direction /R2L
4.1 检测位置:foliate 的 makePDF
记忆文档指出,PDF R2L 自动检测位于 foliate 的pdf.js makePDF:
pdf.getViewerPreferences()→Direction === 'R2L'→book.dir = 'rtl'
即当 PDF 的 Catalog 字典中带有/ViewerPreferences << /Direction /R2L >>时,foliate 在解析 PDF 元数据阶段就将book.dir置为'rtl',随后FoliateViewer的docLoadHandler会读取bookDoc.dir,让getPageProgressionRTL判定为 RTL。这个检测不需要用户做任何操作——这类 PDF 打开即正确。
4.2 FoliateViewer 对 loader dir 的覆盖规则
记忆文档还强调了一个覆盖规则:
FoliateViewer only overrides loader dir when settings/language dir is non-auto.
对应 apps/readest-app/src/app/reader/components/FoliateViewer.tsx 第 718-720 行附近:
const writingMode = viewSettings.writingMode; if (writingMode) { const settingsDir = getBookDirFromWritingMode(writingMode); // ... 非 auto 时覆盖 loader 的方向 }即:只有当用户显式设置了书写模式(非auto)时,Readest 才用设置覆盖 PDF 元数据自动检测出的方向;auto时尊重文档自身(R2L 检测结果、文档语言等)。
4.3 自动检测的局限与测试夹具生成
记忆文档特别指出一个边界:该 issue 的原始 PDF 既没有 ViewerPreferences 也没有 Lang——自动检测对它无能为力,这正是需要手动开关的原因(针对这类扫描版画册)。
对于需要验证 R2L 自动检测的场景,记忆文档给出了测试夹具生成方法:用pypdf在 Catalog 上设置/ViewerPreferences << /Direction /R2L >>:
from pypdf import PdfReader, PdfWriter reader = PdfReader("source.pdf") writer = PdfWriter() for page in reader.pages: writer.add_page(page) writer.add_metadata(reader.metadata or {}) writer._root_object.update({ "/ViewerPreferences": writer._root_object.get("/ViewerPreferences") or {} }) writer._root_object["/ViewerPreferences"]["/Direction"] = "/R2L" with open("r2l-fixture.pdf", "wb") as f: writer.write(f)这类夹具配合 dev-web 环境验证自动检测路径(bookDoc.dir === 'rtl'时 ViewMenu 开关默认勾选)。
5. Shift+F 设置对话框 bug:bookKey 丢失导致空 key 保存
5.1 Bug 现象
本次 PR 顺带修复了一个与设置对话框相关的 bug:
useBookShortcuts的onOpenFontLayoutSettings打开 SettingsDialog 时没有调用setSettingsDialogBookKey,导致对话框以bookKey ''(无书状态)运行:保存操作写入了一个幽灵 key,且recreateViewer('')抛出 "Book not found in library (size=N)"(id 为空)。
这个错误的根因是:设置对话框是"无状态"的——它需要外部显式告知当前操作的是哪本书。ViewMenu 的openSettingsDialog(apps/readest-app/src/app/reader/components/ViewMenu.tsx 第 110-114 行)正确做了三件事:
const openSettingsDialog = () => { setIsDropdownOpen?.(false); setSettingsDialogBookKey(bookKey); // 先设置 bookKey setSettingsDialogOpen(true); // 再打开对话框 };而快捷键路径此前遗漏了第一行。修复后的 apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts 第 487-490 行:
onOpenFontLayoutSettings: () => { if (sideBarBookKey) setSettingsDialogBookKey(sideBarBookKey); setSettingsDialogOpen(true); },这里还额外做了守卫:仅当sideBarBookKey存在时才设置 bookKey,避免在无书场景下误设。
5.2 快捷键定义
onOpenFontLayoutSettings的快捷键定义在 apps/readest-app/src/helpers/shortcuts.ts 第 168-172 行:
onOpenFontLayoutSettings: { keys: ['shift+f', 'ctrl+,', 'cmd+,'], description: _('Open Settings'), section: 'General', },即Shift+F(Windows/Linux)或Ctrl+,/Cmd+,(macOS)触发。这个路径与 ViewMenu 的openSettingsDialog共享同一个useSettingsStore中的settingsDialogBookKey状态(apps/readest-app/src/store/settingsStore.ts 第 44、58 行:初始值为'',setSettingsDialogBookKey直接 set)。
修复原则(记忆文档原话):"任何设置界面在打开前都必须先设置对话框的 bookKey。"相关测试见 apps/readest-app/src/tests/store/settings-store.test.ts 的setSettingsDialogBookKey用例,以及 apps/readest-app/src/tests/components/useBookShortcuts.test.tsx(mock 了setSettingsDialogBookKey并在第 280 行触发onOpenFontLayoutSettings动作)。
6. 测试环境注意事项:browser 测试与 pdfjs 别名
6.1 @pdfjs 别名问题
记忆文档记录了一个真实的测试工程坑:
Browser tests can load real
foliate-js/pdf.js+ vendored pdfjs, butvitest.browser.config.mtsneeded the same explicit@pdfjsaliasvitest.config.mtsalready had (tsconfigPaths doesn't cover files outside the app tree).
原因:tsconfigPaths插件基于 tsconfig 的 paths 解析别名,只能覆盖 app 源码树内的文件;而foliate-js/pdf.js位于 app 树之外,Vite 无法通过 tsconfig 路径解析到 vendored pdfjs,因此必须在 apps/readest-app/vitest.browser.config.mts 第 16-23 行显式配置:
resolve: { conditions: ['development'], alias: { // The @pdfjs alias from tsconfig only resolves within the app's own // source files. foliate-js/pdf.js lives outside that scope, so Vite // needs an explicit alias to find the vendored pdfjs build. '@pdfjs': resolve(import.meta.dirname, 'public/vendor/pdfjs'), }, },同时第 42 行将该模块加入optimizeDeps.exclude('@pdfjs/pdf.min.mjs'),避免预打包干扰。
6.2 jsdom PDF 测试需要扩展 stub
另一个坑:jsdom 环境下的 PDF 测试会对 pdf 代理(proxy)打桩(stub)。由于makePDF新增了pdf.getViewerPreferences()调用,原有 stub 必须同步扩展,否则测试会因方法不存在而失败。这提醒:每当 foliate 的 PDF 解析代码新增 pdfjs API 调用时,需要同步更新 jsdom 侧的 stub 集合。
7. i18n 与遗留验证项
7.1 未翻译 key 的处理纪律
本次 PR 还顺带翻译了此前漏译的 "Send Document Metadata" key。记忆文档记录了一条重要的工程纪律:
i18n extraction adds placeholders for ALL missing keys, so never commit extraction output without translating everything it added.
即:i18n 提取工具会为所有缺失的 key 生成占位符,如果直接提交提取产物而不翻译,会把大量未翻译占位符混入代码库。任何时候提交 i18n 提取输出前,必须把新增的占位符全部翻译完毕。
7.2 验证遗留
修复验证时使用了两个测试书籍,留在开发环境的书库中:
issue5591:原始的 Nagisa 摄影画册;r2l-autodetect:空白 2 页 R2L 自动检测夹具。
它们被保留的原因是:书库删除操作可能触碰到同步(sync)逻辑,故不自动清理,由用户自行删除。这提醒在涉及库级数据(非 per-book 设置)的清理操作上要谨慎评估同步影响。
此外,记忆文档还关联了两条相关上下文(作为开发者的记忆索引,读者可作背景了解):
reader-header-footer-dedup-5652-5634:ViewMenu 已成为桌面端唯一设置入口;browser-verify-readest-web-recipe:浏览器端验证配方。
8. 总结:一套可复用的"机制已存在、可达性缺失"修复范式
从 #5591 的修复中,可以提炼出一套在大型阅读器项目中反复出现的工程范式:
- 先盘点既有机制:方向控制(
writingMode→bookDir→viewSettings.rtl→ 翻页方向)的完整链路早已存在且经过测试(page-progression-direction.test.ts),问题不在机制本身; - 识别真正的缺口:入口被语言条件(
MIGHT_BE_RTL_LANGS)门控、位置远离跨页控制,用户不可达; - 复用而非发明:新开关直接写入既有的
writingMode设置(horizontal-rl/horizontal-tb),并利用recreateViewer+FoliateViewer打开时的全量重算,避免引入平行的方向状态导致状态漂移; - 顺带修复同类入口缺陷:任何打开设置对话框的路径都必须先
setSettingsDialogBookKey,快捷键路径是重灾区; - 重视测试环境的边界:跨 app 树的模块(如 foliate-js 与 vendored pdfjs)需要显式别名,pdfjs 新增 API 需要同步扩展 jsdom stub;
- 守住 i18n 纪律:提取输出永远要翻译完全再提交。
对于需要处理日文摄影画册、扫描版 PDF、漫画等固定版式书籍的阅读器开发者而言,这条从"检测不到方向"到"手动开关 + 自动检测双通道"的解决路径,以及"任何方向控制必须写writingMode"的架构约束,都具有直接的参考价值。相关实现文件可进一步深入阅读:ViewMenu.tsx、FoliateViewer.tsx、document.ts、book.ts。
- 桌面应用
- 跨平台
- 前端
【免费下载链接】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.
相关推荐
如何用Mermaid.js快速绘制专业图表:从入门到精通的完整指南
如何用Mermaid.js快速绘制专业图表:从入门到精通的完整指南 你是否曾为制作流程图、类图或甘特图而头疼?想象一下,你需要在技术文档中插入一个清晰的系统架构
图表库前端数据可视化提示词工程实战:3 条命令从 awesome-prompts 做出你自己的角色 Prompt,377 个模板即取即用
提示词工程实战:3 条命令从 awesome prompts 做出你自己的角色 Prompt,377 个模板即取即用 awesome prompts 是一个收录
提示工程文档人工智能三步搞定黑苹果:OpCore-Simplify如何让OpenCore配置变得如此简单
三步搞定黑苹果:OpCore Simplify如何让OpenCore配置变得如此简单 黑苹果安装一直被认为是技术高手的专属领域,复杂繁琐的OpenCore配置让
开发工具CLI
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考