Readest 固定版式书籍的 RTL 页面顺序修复:从 5591 看横向从右到左(RTL)阅读的实现与调试

Readest 固定版式书籍的 RTL 页面顺序修复:从 5591 看横向从右到左(RTL)阅读的实现与调试 桌面应用跨平台前端【免费下载链接】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 仓库中 apps/readest-app/.claude/memory/fixed-layout-rtl-spread-5591.md 这份项目记忆文档完整还原了一次针对固定版式fixed-layout书籍 RTL 页面顺序问题的修复过程日本摄影画册 PDF 的跨页按从左到右配对、阅读方向错误。该问题最终通过 ViewMenu 新增 Right-to-Left Pages 开关、复用既有writingMode设置、PDF ViewerPreferences R2L 自动检测以及 ShiftF 快捷键设置对话框的 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.tsMIGHT_BE_RTL_LANGS包含ar、he、fa等 RTL 语言的断言。2.2 映射层getBookDirFromWritingModewritingMode与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-rlgetBookDirFromWritingMode就会返回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 行的getPageProgressionRTLexport 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 怎么说其次看书籍方向bookDirrtl为 RTLltr为 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.rtlconst 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 /R2LUI 语言是最后兜底。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 PageMenuItem 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 /R2L4.1 检测位置foliate 的 makePDF记忆文档指出PDF R2L 自动检测位于 foliate 的pdf.js makePDFpdf.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. ShiftF 设置对话框 bugbookKey 丢失导致空 key 保存5.1 Bug 现象本次 PR 顺带修复了一个与设置对话框相关的 buguseBookShortcuts的onOpenFontLayoutSettings打开 SettingsDialog 时没有调用setSettingsDialogBookKey导致对话框以bookKey 无书状态运行保存操作写入了一个幽灵 key且recreateViewer()抛出 Book not found in library (sizeN)id 为空。这个错误的根因是设置对话框是无状态的——它需要外部显式告知当前操作的是哪本书。ViewMenu 的openSettingsDialogapps/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: [shiftf, ctrl,, cmd,], description: _(Open Settings), section: General, },即ShiftFWindows/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.tsxmock 了setSettingsDialogBookKey并在第 280 行触发onOpenFontLayoutSettings动作。6. 测试环境注意事项browser 测试与 pdfjs 别名6.1 pdfjs 别名问题记忆文档记录了一个真实的测试工程坑Browser tests can load realfoliate-js/pdf.js vendored pdfjs, butvitest.browser.config.mtsneeded the same explicitpdfjsaliasvitest.config.mtsalready had (tsconfigPaths doesnt 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 apps 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.excludepdfjs/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-5634ViewMenu 已成为桌面端唯一设置入口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并利用recreateViewerFoliateViewer打开时的全量重算避免引入平行的方向状态导致状态漂移顺带修复同类入口缺陷任何打开设置对话框的路径都必须先setSettingsDialogBookKey快捷键路径是重灾区重视测试环境的边界跨 app 树的模块如 foliate-js 与 vendored pdfjs需要显式别名pdfjs 新增 API 需要同步扩展 jsdom stub守住 i18n 纪律提取输出永远要翻译完全再提交。对于需要处理日文摄影画册、扫描版 PDF、漫画等固定版式书籍的阅读器开发者而言这条从检测不到方向到手动开关 自动检测双通道的解决路径以及任何方向控制必须写writingMode的架构约束都具有直接的参考价值。相关实现文件可进一步深入阅读ViewMenu.tsx、FoliateViewer.tsx、document.ts、book.ts。赞分享桌面应用跨平台前端【免费下载链接】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点击查看免费下载相关推荐如何用Mermaid.js快速绘制专业图表从入门到精通的完整指南如何用Mermaid.js快速绘制专业图表从入门到精通的完整指南 你是否曾为制作流程图、类图或甘特图而头疼想象一下你需要在技术文档中插入一个清晰的系统架构图表库前端数据可视化提示词工程实战3 条命令从 awesome-prompts 做出你自己的角色 Prompt377 个模板即取即用提示词工程实战3 条命令从 awesome prompts 做出你自己的角色 Prompt377 个模板即取即用 awesome prompts 是一个收录提示工程文档人工智能三步搞定黑苹果OpCore-Simplify如何让OpenCore配置变得如此简单三步搞定黑苹果OpCore Simplify如何让OpenCore配置变得如此简单 黑苹果安装一直被认为是技术高手的专属领域复杂繁琐的OpenCore配置让开发工具CLI上一篇自托管PaaS备份策略piku数据定期归档自动化下一篇Wand-Enhancer本地化客户端增强架构深度解析创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考