资讯动态

Readest TTS 与校对替换规则同步修复解析:transformTarget 数据管线回放机制(5406 / PR 5416)

发布时间:2026/9/22 4:11:51 来源:尧图企业网站定制
Readest TTS 与校对替换规则同步修复解析transformTarget 数据管线回放机制#5406 / PR #5416【免费下载链接】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 仓库中的项目记忆文档 tts-proofread-doc-sync-5406.md深入解析 Issue #5406 及其修复 PR #5416当章节自动翻页auto-advance或重挂载视图后TTS朗读朗读的文本与屏幕上显示的文本不一致导致TTS 不同步原文记录的症状为 TTS不同步。核心原因是section.createDocument()直接解析原始资源绕过了承载全部显示内容变换器proofread 校对、simplecc 简繁转换、标点、空白等的book.transformTargetdata事件管线。读完本文你将掌握问题根因的完整链路、transformTTSSectionDocument的管线回放实现原理、TTSController 的优先使用实时渲染文档策略、Markdown 书籍接入同一变换管线的方案以及这套机制背后必须遵守的隐藏契约。背景问题现象与修复时间线Issue #5406功能请求FR——TTS 朗读应与校对替换规则含正则规则保持同步。PR #5416已于 2026-07-31 合并分支fix/tts-proofread-doc-sync-5406worktree 已移除。从功能上看Readest 的校对proofread规则允许用户定义把 A 替换为 B的文本替换包括正则表达式形式。这些规则在显示侧通过内容变换器对章节文档生效但 TTS 由于从原始资源取文读出来的仍是未经替换的文本一旦规则增删了文字按内容一致假设计算的朗读高亮 CFI 也会发生偏移最终表现为朗读与屏幕文字对不上。根因createDocument 绕过了 transformTarget 数据管线文档记录了一个非显而易见non-obvious的根因section.createDocument()epub.js 的loadDocument解析的是原始资源绕过了 Loader 在book.transformTarget上派发的data事件——而所有显示内容变换器都在这个事件上运行FoliateViewer 的getDocTransformHandlerproofread、simplecc、标点、空白、nbsp 等。也就是说显示侧和 TTS 侧读取章节内容走的是两条不同的路径路径数据来源是否经过变换器显示侧章节经 Loaderdata事件 →transformContent变换器链是TTS 侧出 bug 时section.createDocument()直接解析原始资源否TTSController 在三条路径上使用了原始文档raw doc#initTTSForSection自动翻页currentSection在await onSectionChange之前被捕获因此导航完成后总是落入 raw-doc 分支——每一个自动翻页的章节都以未变换文本被朗读。attachView回退当重挂载的视图的 primary 内容不是 TTS 正在朗读的章节时走createDocument回退。Downloader 的enumerateSection其缓存键与播放路径分叉cache keys diverged from playback导致预合成下载的音频与真实朗读不一致。高亮机制放大了问题TTS 通过view.getCFIresolveCFI().anchor()把 TTS 文档中的 range 映射回实时文档其前提是两份文档内容完全一致content-identical assumption。一旦校对规则增删了文字原始文档的偏移量在变换后的实时文档中就会错位——这正是用户报告的TTS不同步症状。修复方案总览回放同一条显示管线修复的核心思想非常简洁让 TTS 读取的文档与屏幕显示的文档走同一条变换管线零重复的变换器清单。修复分两部分新增transformTTSSectionDocument(book, sectionId, doc)把原始章节文档序列化在book.transformTarget上派发一个合成的dataCustomEventdetail为{data, type, name}处理器会把detail.data替换为一个 Promise空字符串代表处理器出错回退然后重新解析变换后的字符串。由于直接复用显示管线的处理器handler 内部闭包持有 viewSettings变换器清单不会重复维护。新增#getLiveSectionDoc在onSectionChange之后重新查询已渲染内容并且扫描所有contents多视图分页器预加载的相邻章节而不仅是 primary。transformTTSSectionDocument 实现深读核心实现位于 src/services/tts/transformDoc.tsexport const transformTTSSectionDocument async ( book: { transformTarget?: EventTarget }, sectionId: string | undefined, doc: Document, ): PromiseDocument { const target book.transformTarget; if (!target) return doc; try { const type doc.contentType || application/xhtmlxml; const data new XMLSerializer().serializeToString(doc); const detail: { data: string | Promisestring; type: string } { data, type }; // Readonly镜像 foliate Loader.createURL 的派发方式阅读器的变换处理器 // 依赖该 name 匹配作用域限定的校对规则。 Object.defineProperty(detail, name, { value: typeof sectionId string ? sectionId : , }); target.dispatchEvent(new CustomEvent(data, { detail })); const transformed await detail.data; // 是变换处理器的错误回退输出与输入一致说明没有变换器改动内容 // 可以跳过重新解析。 if (typeof transformed ! string || !transformed || transformed data) return doc; const newDoc new DOMParser().parseFromString(transformed, type as DOMParserSupportedType); if (!newDoc.documentElement || newDoc.querySelector(parsererror)) return doc; return newDoc; } catch { return doc; } };关键细节逐一说明无变换管线的书直接透传book.transformTarget不存在时显示侧同样不做变换原样返回输入文档保证零开销且行为一致。name是只读属性通过Object.defineProperty定义而非直接赋值镜像 foliateLoader.createURL的派发方式。这个name必须等于章节 IDEPUB 场景即item.href因为作用域限定的校对规则依赖detail.name做匹配。空串即错误回退FoliateViewer 的变换处理器出错时会 resolve 为此时回退到原始文档避免朗读崩溃。幂等短路变换结果与输入完全相同没有变换器改动内容时跳过重新解析避免无谓的 DOM 重建开销。解析失败回退parsererror或缺少documentElement时返回原始文档保证健壮性。该函数在 src/tests/services/tts-proofread-doc-sync.test.ts 中有四个专项用例无transformTarget时原样返回输入文档正确应用显示变换管线proofread me please→corrected text并断言name sec.xhtml、type application/xhtmlxml变换产出为空串时回退到输入文档没有任何监听器修改数据时返回输入文档。测试文件中的attachDisplayTransform辅助函数忠实复刻了 FoliateViewer 的处理器契约把detail.data替换为变换后 markup 的 Promise出错则——这正是 TTS 侧能复用该事件的原因。TTSController 集成优先使用实时渲染文档修复在 TTSController.ts 中引入了两级取文策略第一级#getLiveSectionDoc —— 直接使用屏幕上那份文档// 任意实时视图中的章节渲染文档多视图分页器会保活预加载的相邻章节 // 而不仅是 primary。 #getLiveSectionDoc(sectionIndex: number): Document | undefined { if (!this.#attached) return undefined; const contents this.view.renderer.getContents() as { doc?: Document; index?: number }[]; return contents.find((x) x.index sectionIndex x.doc)?.doc; }这段代码在 src/services/tts/TTSController.ts。它扫描全部 contents 而非只看 primary覆盖了多视图分页器multiview预加载相邻章节的场景——相邻章节虽然在幕后但它们的文档是经过变换的实时文档。第二级#createSectionDoc —— 新鲜文档回放变换管线async #createSectionDoc(section: SectionItem): PromiseDocument { const raw await section.createDocument(); const doc await transformTTSSectionDocument(this.view.book, section.id, raw); const html doc.querySelector(html); const lang html?.getAttribute(lang) || html?.getAttribute(xml:lang) || ; if (html !isValidLang(lang) this.ttsLang) { html.setAttribute(lang, this.ttsLang); html.setAttribute(xml:lang, this.ttsLang); } return doc; }见 src/services/tts/TTSController.ts。在管线回放之外还做了 TTS 语音所需的lang修正若文档未声明有效语言且 TTS 有目标语言则把lang/xml:lang补齐保证合成语音语言正确。关键顺序#initTTSForSection 中的修复const currentSection this.#getPrimaryContent(); if (currentSection?.index ! sectionIndex) { await this.onSectionChange?.(sectionIndex); } // 在导航之后重新查询已渲染文档自动翻页在此落地时捕获的 primary 是导航前的 // 所以只检查 currentSection 总会漏掉它…… const doc this.#getLiveSectionDoc(sectionIndex) ?? (await this.#createSectionDoc(section));见 src/services/tts/TTSController.ts。旧代码在await onSectionChange前捕获currentSection自动翻页后 section 已经变了捕获值始终不是目标章节于是必然落到 raw-doc 分支。修复后先完成导航await onSectionChange之后再通过#getLiveSectionDoc重新查询目标章节的实时渲染文档查不到才回退到#createSectionDocfresh 文档 管线回放。这样朗读的文档与屏幕显示的文档在内容上严格一致高亮 range 才能按内容一致假设正确锚定。测试 tts-proofread-doc-sync.test.ts 覆盖了三个 TTSController 集成场景未渲染章节走变换管线section 1 未渲染primary 是 section 0通过initViewTTS(1)朗读断言 TTS 构造时拿到的文档包含corrected text而非proofread me please导航后优先使用实时文档onSectionChange模拟导航渲染出变换后的liveDoc1断言 TTS 拿到的就是那份文档本身toBe(liveDoc1)引用相等且createDocument未被调用无变换管线的书保留原始文档withTransformTarget: false时 TTS 读到的是原始内容。attachView 回退同样修复attachView在重挂载时若新视图的 primary 内容不是 TTS 章节primary.index ! sectionIndex原实现直接取 raw 文档修复后改为doc section?.createDocument ? await transformTTSSectionDocument(view.book, section.id, await section.createDocument()) : undefined;见 src/services/tts/TTSController.ts并在随后的同步交换swap中通过view.getCFIresolveCFI().anchor(doc)从旧实例的光标重新定位迭代器——同样依赖旧文档与新文档内容一致这一修复后成立的假设。Downloader 枚举器同步下载器getTTSDownloader返回的enumerateSection也改为复用#createSectionDocenumerateSection: async (sectionIndex: number) { const sections this.view.book.sections; const section sections?.[sectionIndex]; if (!section?.createDocument) return null; try { // 与实时播放相同的变换文档否则合成文本及其缓存键会与朗读内容分叉。 const doc await this.#createSectionDoc(section); // …对每个可朗读段落生成与播放完全一致的 SSML、标记、缓存键见 src/services/tts/TTSController.ts。修复后预合成下载与实时播放共享同一份变换后文档与 SSML 预处理缓存键完全对齐下载的音频与播放一致。Markdown 书籍补上缺失的 transformTarget同一次 PR 修复了一个更隐蔽的问题Markdown 书籍此前根本没有transformTarget显示变换从未作用于 MD 书籍。修复前的utils/md.ts生成的 book 没有transformTargetproofread、simplecc 等变换器对 MD 内容完全不生效。修复后现实现位于 src/utils/htmlBook.ts即makeMarkdownBookbook 暴露transformTargethtmlBook.tssections 增加loadContent()分页器在定义该函数时通过 srcdoc 渲染与 EPUB 一致内部走transformSection派发同一data事件htmlBook.ts按章节缓存变换结果由unload()失效htmlBook.ts分页器在视图销毁时调用unload所以修改校对规则触发的视图重建recreateViewer会自动触发重新变换。transformSection的实现与 TTS 侧同构序列化 →transformTarget.dispatchEvent→ await 变换结果 → 缓存。其detail.name也使用Object.defineProperty设为裸章节索引String(index)测试断言了name 1、type application/xhtmlxml。对应的测试位于 src/tests/utils/md.test.ts覆盖三点book 暴露EventTarget类型的transformTargetloadContent路由经过 data 监听器且 name 是裸章节索引变换结果按章节缓存unload()后才重新变换监听器被调用次数 1 → 2。必须遵守的两条非显然契约文档特别强调了两个容易踩坑的契约createDocument()必须保持 RAWTTS 的transformTTSSectionDocument自己派发完整管线如果createDocument预先返回变换后的文档TTS 再回放一次就会双重应用变换double-apply。这正是 MD 书籍实现中createDocument仍直接parseFromString(str)htmlBook.ts的原因。派发的name必须是裸章节索引作用域限定的校对规则按 TOC 风格sectionHref形如index#anchor比较通过split(#)[0]取索引与detail.name匹配因此 name 不能带#anchor后缀。已知隐患与相关事实FoliateViewer 的 data 监听器叠加#5277文档记录了一个已知隐患FoliateViewer从不移除transformTarget上的data监听器见 FoliateViewer.tsx 的注册处因此视图重建recreateViewer会叠加处理器known #5277 hazard。由于变换器事实上是幂等的de facto idempotent叠加不会造成功能错误但这是刻意接受的权衡与本修复无关。相关上下文可参考 proofread-rule-change-font-loss-5277.md 与 tts-fixes.md 两份记忆文档。显示变换器链路的全貌FoliateViewer 的getDocTransformHandlerFoliateViewer.tsx定义了对data事件的处理HTML 内容经过transformContent变换器链为epubSwitch、style、punctuation、footnote、whitespace、language、sanitizer、simplecc、nbsp、proofread、warichu出错时 catch 并 resolve即 TTS 侧识别的错误回退CSS 内容则走transformStylesheet。TTS 回放的正是这条完整链因此朗读文本与显示文本严格一致。mobi 书籍的 name 语义差异mobi 章节的id是数字且 mobi 自己的data派发不设置name而作用域限定的校对规则依赖detail.nameEPUB 场景为item.href做匹配。这意味着同样一套 TTS 同步机制在不同格式书籍上对章节标识的语义不同实现时需要区分对待。onlyForTTS 规则是独立路径onlyForTTS仅朗读应用的规则走另一条路径它们不作用于显示文档而是直接作用于 SSML在 useTTSControl.ts 的preprocessSSMLForTTS中通过proofreadTransformer.transform(transformCtx, { docType: text/xml, onlyForTTS: true })应用且仅限book/library作用域。普通显示规则含正则与纯文本两种则通过本文所述的文档管线到达 TTS。两条路径互补显示规则保证朗读与所见一致onlyForTTS 规则保证朗读有自己专属的发音替换。总结Issue #5406 的修复完成了一次架构收敛TTS 不再维护自己的取文逻辑而是复用显示侧的 transformTarget 数据管线。通过transformTTSSectionDocument回放同一套变换器链、#getLiveSectionDoc优先复用实时渲染文档、以及 Markdown 书籍补齐transformTargetloadContentReadest 保证了朗读文本、显示文本、高亮锚点三者严格一致。这条修复链路也留下了清晰的可测试证据——tts-proofread-doc-sync.test.ts 和 md.test.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创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价