2026/9/21 14:42:59

Readest TTS 日语 ruby 注音朗读改造:基于节点过滤器实现「读假名、不读汉字」

Readest TTS 日语 ruby 注音朗读改造:基于节点过滤器实现「读假名、不读汉字」 桌面应用跨平台前端【免费下载链接】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文本朗读工程问题朗读日文 EPUB 时引擎会照着ruby的基底汉字逐个发音导致「数多」被读成「すうた」而不是「あまた」——而rt注音振り仮名才是作者真正想让你听到的读音。解决方案的核心不是改写文本而是把「朗读哪一侧」的决策下沉到 DOM 节点过滤器NodeFilter中同时为 foliate-js 打了两个关键补丁。读完本文你将掌握为什么必须在过滤器层做替换而不能动文本、如何用「假名判定」做自门控、以及如何让朗读高亮与朗读内容精确对齐。一、问题背景引擎猜读音而rt才是唯一真相日本小说的汉字读音几乎无法从字形推导大部分汉字存在多个读音需要靠上下文选择专有名词完全不遵循规则轻小说作者还会故意使用「義訓」给汉字标注非常规读音例如把「神聖文字」标注为「ヒエログリフ」老式 EPUB 甚至用img渲染生僻汉字基底根本没有任何文本。因此在 apps/readest-app/src/utils/ruby.ts 的头部注释中明确写下了这条设计准则Thertis the only ground truth, so TTS speaks it and mutes the base run it annotates (#5539).即rt注音是唯一可信的发音来源TTS 朗读它并静音mute它所标注的基底。这就是 #5539 的全部语义让朗读引擎不再去猜汉字读音而是直接读出作者标注的假名。二、核心设计替换发生在过滤器而不是改写文本一个容易踩的坑是「直接把rt文本替换进基底文本」。文档明确警告替换必须发生在节点过滤器node filter层面绝不能通过改写文本实现。原因有两点都源于 Readest 的文档一致性约束#ttsDoc通常是「正在渲染的实时文档」在 apps/readest-app/src/services/tts/TTSController.ts 中#getLiveSectionDoc()会从view.renderer.getContents()中取回所有存活视图multiview paginator 会保留预加载的相邻章节里对应 section 的真实渲染文档而不只是主视图。即使是后台章节文档也必须与渲染内容逐字一致这是 #5406 的规则。Readest 的显示变换管线proofread 校对规则、simplecc 简繁转换、标点规范化等会在书籍加载器的data事件中重写文本如果 TTS 用未经变换的原始文档朗读语音会与屏幕显示脱节且按原始文档计算的 CFI 高亮会在变换后的实时文档上锚定到错误位置。为此 apps/readest-app/src/services/tts/transformDoc.ts 中的transformTTSSectionDocument()会把原始 section 文档重新序列化、重新派发同一条data事件、再重新解析从而保证 TTS 文档与展示文档内容完全一致#createSectionDoc中还会做 TTS 语音所需的 lang 修复见 TTSController.ts。如果在这个前提下直接改写文本高亮 CFI 就会漂移。所以正确做法是过滤器决定 walker 在每组 ruby 对中丢弃哪一侧实时范围的遍历和克隆片段cloned-fragment的遍历共用同一个过滤器marks 才能始终对齐。这一职责落在 apps/readest-app/src/utils/ruby.ts 的isMutedRubyNode()上它对一个节点给出「这是不是该静音的那一侧」的判定rpruby fallback 括号和rtc双层注音的第二层永远静音位于rt内的节点仅当该rt是「会被朗读的假名注音」时才不静音否则静音例如旁点注音、拼音注音、被注入的 WordLens 词条ruby的直接子节点基底当其后继rt是假名注音时静音因为注音将代替它朗读否则保持朗读与 ruby 无关的节点永不静音。rtForBaseRun()ruby.ts实现「基底对应哪个注音」的配对ruby 内容按「基底/注音」交替排列所以直接取下一个rt兄弟节点即可若遇到rtc说明这是双层注音结构注音不与基底交错此时放行基底。这个过滤器通过 apps/readest-app/src/services/tts/nodeFilter.ts 的createTTSNodeFilter()组装成完整的 TTS 节点过滤器——它先通过createRejectFilter排除画布、换行、脚注等常规内容再把 ruby 决策委托给isMutedRubyNode。live TTS 实例与时间线timeline枚举必须共用同一个过滤器否则时间线句子会与 marks 漂移——这是文档强调的「MUST segment identically」不变量。三、门控条件以rt是否假名为准而非书籍语言替换的开关不是书籍语言而是rt内容是否像假名。isKanaReading()ruby.ts的判定是至少含一个真正的假名字母且除此之外只允许假名、长音符「ー」、分隔符「・」、浊音/半浊音符号和空白。该判定是逐「基底/注音」对进行的这带来两个关键行为空rt不满足「至少一个假名」因此不触发替换其对应的送假名okurigana保持朗读。测试用例 ruby.test.ts 验证了ruby振rtふ/rtりrt/rt仮名rtがな/rt/ruby应朗读为ふりがな而不是错误的ふがな。判定自动把替换范围自门控在日语上旁点傍点・﹅●、、拼音、注音符号、拉丁文/汉字注疏以及 Readest 自己注入的带[cfi-inert]属性的 WordLens 词条全部保持原有行为继续读基底、静音注音。实现上有两个容易被忽略的细节「・」U30FB在 Unicode Script Extensions 中属于 Katakana但它既是片假名外来语内的分隔符又是书籍标记旁点的字符。因此「至少一个假名字母」不能写成\p{scxKatakana}必须使用显式字母区间KANA_LETTER /[ぁ-ゖゝ-ゟァ-ヺヽ-ヿ]/见 ruby.ts。判定用的两套正则KANA_LETTER至少一个假名与KANA_TEXT整体只允许假名/浊音点/长音符/分隔符/空白。测试用例 ruby.test.ts 对二者分别做了完整覆盖接受あまた、ヒエログリフ、コーヒー、ヴィクトリア・アルベルト拒绝・、・・・、﹅、●、、、空串、纯空白、hieroglyph、shēng、ㄕㄥ、神聖、かん字。四、两个 foliate-js 补丁SSML 生成与高亮绘制的配套修复仅靠节点过滤器并不够实际验证暴露出 foliate-js 的两处遗漏每一处都由一个失败测试证明补丁 1fragmentToSSML只对元素应用过滤器文本节点漏掉了在 packages/foliate-js/tts.js 的fragmentToSSML()中原本nodeFilter只对元素节点生效。而群组 rubygroup ruby的基底是ruby下的裸文本子节点没有rb包裹于是基底文本「泄漏」进了语音读出来变成「数多あまたの星」。修复后的代码tts.js在注释中说明了理由生成 marks 的文本 walker 已经遵守过滤器如果 SSML 转换对文本节点不遵守两者对「正在读什么」的认知就会不一致。现在文本节点nodeType 3和 CDATAnodeType 4都会先经过nodeFilter被FILTER_REJECT的文本直接跳过。补丁 2Overlayer.#splitRange收集了rt/rp/rtc/[cfi-inert]的矩形在 packages/foliate-js/overlayer.js 的#splitRange()中TreeWalker 的acceptNode原本会把注音节点也当作可绘制的文本节点导致在假名上方画出一个脱离基底的独立高亮框furigana 悬浮在基底上方高亮框也跟着悬浮。修复后overlayer.js// Ruby annotations sit on their own line above (or beside) // the base, so their rects would draw a second detached box // over the furigana. Never paint them — not the books own // ruby, not injected glosses. if (el?.closest?.(rt, rp, rtc, [cfi-inert])) return NodeFilter.FILTER_REJECTrt注音、rpfallback 括号、rtc第二层注音、[cfi-inert]注入内容如 WordLens 词条的矩形一律不收集。这个补丁同时修复了另一个长期问题标注annotation高亮时rt注音不应被高亮。五、expandRangeOverRuby朗读高亮的收尾拼图两个补丁与 TTSController 的expandRangeOverRuby是互补关系。在 TTSController.ts 的#getHighlighter()中高亮回调拿到的是朗读文本对应的 Range——对日语书而言这个 Range 锚定在rt假名内部。但 overlayer 现在拒绝绘制rt的矩形如果不做扩展就什么都画不出来。expandRangeOverRuby()ruby.ts的职责是当 Range 的起点或终点落在「会被朗读的假名注音」内部时把它向外扩展到整个ruby元素让高亮框画在读者目光所及的基底汉字上当起点终点都不在注音内时原样返回。它有一个刻意的对称行为对注疏型 rubyWordLens、拼音、旁点绝不扩展因为此时朗读的是基底、Range 已经覆盖基底扩展反而是破坏性的——而且对注入的 WordLens 词条尤其危险它带cfi-skip属性不贡献 CFI 步进setStartBefore/setEndAfter会解析到同一位置把 Range 塌缩为空例如/4/2/4,/1:13,/1:20塌缩为/4/2,/4,/4overlayer 会画出一个空高亮。这正是「词粒度 TTS 跳过所有被注释的词、而句子粒度却正常」的根因句子 Range 很少起止于 ruby 内部永远碰不到扩展逻辑。六、词级高亮的一致性isInertText委托给isMutedRubyNodeEdge TTS 会返回词边界word-boundary时间戳Readest 据此实现逐词高亮。在 apps/readest-app/src/services/tts/wordHighlight.ts 中isInertText()现在把「是否静音」委托给isMutedRubyNode()ruby 两侧中在 TTS 过滤器里被静音的一侧在这里同样视为 inert对假名注音即基底任何带cfi-inert属性的祖先子树注入的 WordLens 词条同样视为 inert。这样做的原因是词偏移匹配的基准串必须与朗读内容一致rangeTextExcludingInert()在生成匹配基线时会剔除这些 inert 文本wordHighlight.ts否则 Edge 返回的无注音边界词与 walker 走出的文本对不上偏移会错位。getTextSubRange()wordHighlight.ts在把词偏移映射回子 Range 时同样跳过 inert 文本节点。七、测试与验证8 变体合成 EPUB 逐项核对单测覆盖apps/readest-app/src/tests/utils/ruby.test.ts对isKanaReading/isMutedRubyNode做单元级验证覆盖群组 ruby裸文本基底、显式rb基底、交错单注音对漢rtかん/rt字rtじ/rt→かんじ、空rt送假名、gaiji 图片基底img alt喰rtく/rt→く、rp括号剔除、旁点保持基底、拼音/拉丁注疏保持基底、WordLens 注入词条保持基底、rtc静音、游离rt静音、无 ruby 文本不动、ruby元素自身不静音保证子节点可被遍历、rb元素节点也要静音。apps/readest-app/src/tests/document/tts.test.ts在真实TTS实例上验证speak(pruby数多rtあまた/rt/rubyの星/p) あまたの星以及高亮 Range 锚定在朗读的注音上range.toString()不含被静音的汉字、startContainer的父元素是rt。apps/readest-app/src/tests/document/overlayer-highlight-blocks.test.ts验证经expandRangeOverRuby扩展后overlayer 把高亮画在基底上。Chrome 实机验证文档记录了用专门构造的 8 变体日文 EPUB 在 Chrome 中验证的结果fv.tts.start()/next()依次吐出あまた / ヒエログリフ / かんじ / ふりがな / あまた / 本当(dots kept) / すみだ / ひとけ与预期完全一致fv.tts.setMark(0)走控制器真实高亮器高亮框贴合基底行、假名悬浮在框外。两个实战注意事项来自文档的验证经验本地开发时 Edge TTS 的 WebSocket 会失败所以验证要直接驱动view.tts而不是等待音频该分支的验证对象是合成的 8 变体 EPUB尚未在真实商业轻小说上复验——报告者 He1lscythe 手头有含 1695 处 ruby 注音的单行本需在下一个发布版本上回归测试。八、同一分支的附带修复播放器封面占位同一次改造还顺带修了一个不相关的小问题TTSMiniPlayer / TTSPlayerSheet 中一个加载失败的coverImageUrl会在面板里预留一条 128px 的空白带。该修复随本分支一起合并与 ruby 朗读无直接关系但说明这次改动还清理了播放器界面的一个视觉瑕疵。九、小结一次完整的「过滤层替换」实践回顾整个 #5539 的改造可以提炼出几条可复用的工程原则能用过滤器表达的替换就不要改写文本——尤其在文档与渲染必须逐字一致#5406的约束下改写文本会破坏 CFI 对齐门控条件要贴近语义而非语言标签——用「是否假名」而非「是否日文书」判定天然兼容旁点、拼音、注音符号和注入词条替换与高亮必须配套修改——SSML 文本泄漏、高亮矩形收集、Range 扩展三处缺一不可任何一处遗漏都会表现为语音或高亮异常让测试直接驱动真实过滤器——createTTSNodeFilter从 TTSController 中抽离到 apps/readest-app/src/services/tts/nodeFilter.ts就是为了让单测直接验证真实配置而非重复一份配置「MUST segment identically」不变量避免时间线句子与 marks 漂移。关键代码路径汇总供继续深入阅读判定与替换核心apps/readest-app/src/utils/ruby.tsTTS 节点过滤器apps/readest-app/src/services/tts/nodeFilter.ts词级高亮一致性apps/readest-app/src/services/tts/wordHighlight.ts控制器中的实时文档与高亮扩展apps/readest-app/src/services/tts/TTSController.ts文档变换管线#5406 规则apps/readest-app/src/services/tts/transformDoc.tsfoliate-js 补丁一SSML 文本节点过滤packages/foliate-js/tts.jsfoliate-js 补丁二overlayer 拒绝注音矩形packages/foliate-js/overlayer.js单元测试apps/readest-app/src/tests/utils/ruby.test.ts、apps/readest-app/src/tests/document/tts.test.ts、apps/readest-app/src/tests/document/overlayer-highlight-blocks.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点击查看免费下载相关推荐Readest TTS引擎解析多语言文本朗读技术实现Readest TTS引擎解析多语言文本朗读技术实现 Readest作为一款现代化电子书阅读器其文本朗读TTS功能是提升阅读体验的核心模块之一。本文将深桌面应用跨平台前端Readest 词典弹窗单词发音功能解析基于 Edge TTS 的独立单词朗读器实现Readest 词典弹窗单词发音功能解析基于 Edge TTS 的独立单词朗读器实现 词典查词后如何让生词开口说话Readest 通过 Issue 48桌面应用跨平台前端突破阅读边界newsnow全平台TTS语音朗读方案实现终极指南突破阅读边界newsnow全平台TTS语音朗读方案实现终极指南 newsnow是一款专注于实时热点新闻优雅阅读的开源项目通过聚合多平台资讯为用户提供一站式阅前端后端网页爬虫创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考