From f4483643f42fc495ed69a9de86dcbd79f1e5d1f9 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sun, 17 May 2026 03:23:54 +0800 Subject: [PATCH] fix(tts): skip hidden footnotes in TTS, closes #4135 (#4193) Footnotes/endnotes are hidden in the rendered page via `display: none`, but TTS builds its blocks from its own document. For background sections that document is raw XHTML loaded via `section.createDocument()` without the page layout styles, so the footnotes were read aloud. - `createRejectFilter` gains an `attributeTokens` option to match `aside[epub:type~="footnote|endnote|note|rearnote"]` (value-token match, like CSS `[attr~="x"]`), so footnotes are detectable on raw documents that lack the `epubtype-footnote` class. - `TTSController` adds the footnote selectors to its reject filter. - `getBlocks()` (foliate-js) skips the subtree of any block-level element the node filter rejects, ending the preceding block before it so footnote text doesn't leak in. Co-authored-by: Claude Opus 4.7 (1M context) --- .../src/__tests__/document/tts.test.ts | 169 ++++++++++++++++++ .../src/services/tts/TTSController.ts | 18 +- apps/readest-app/src/utils/node.ts | 18 ++ packages/foliate-js | 2 +- 4 files changed, 205 insertions(+), 2 deletions(-) diff --git a/apps/readest-app/src/__tests__/document/tts.test.ts b/apps/readest-app/src/__tests__/document/tts.test.ts index 448e7be1..4844d937 100644 --- a/apps/readest-app/src/__tests__/document/tts.test.ts +++ b/apps/readest-app/src/__tests__/document/tts.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { textWalker } from 'foliate-js/text-walker.js'; import { TTS } from 'foliate-js/tts.js'; +import { createRejectFilter } from '@/utils/node'; const createHTMLDoc = (bodyHTML: string, attrs: Record = {}): Document => { const parser = new DOMParser(); @@ -32,6 +33,21 @@ const stripTags = (ssml: string): string => ssml.replace(/<[^>]+\/?>/g, '').trim const highlight = vi.fn(); +/** Node filter mirroring the footnote rules TTSController passes to TTS. */ +const ttsNodeFilter = createRejectFilter({ + tags: ['rt', 'canvas', 'br'], + classes: [ + 'annotationLayer', + 'epubtype-footnote', + 'duokan-footnote-content', + 'duokan-footnote-item', + ], + attributeTokens: [ + { tag: 'aside', attribute: 'epub:type', tokens: ['footnote', 'endnote', 'note', 'rearnote'] }, + ], + contents: [{ tag: 'a', content: /^[\[\(]?[\*\d]+[\)\]]?$/ }], +}); + describe('TTS', () => { describe('plain HTML document', () => { it('should init and generate SSML for a plain HTML doc without doctype or lang', () => { @@ -190,6 +206,159 @@ describe('TTS', () => { }); }); + describe('footnotes', () => { + /** Collect plain text of every TTS block by walking start()/next(). */ + const collectBlocks = (tts: InstanceType): string[] => { + const blocks: string[] = []; + let ssml = tts.start(); + while (ssml) { + blocks.push(stripTags(ssml)); + ssml = tts.next(); + } + return blocks; + }; + + it('should not read aside footnotes (epub:type) at the end of a chapter', () => { + const doc = createHTMLDoc( + '

First paragraph of the chapter.

' + + '

Second paragraph of the chapter.

' + + '', + ); + const tts = new TTS(doc, textWalker, ttsNodeFilter, highlight, 'word'); + const combined = collectBlocks(tts).join(' '); + + expect(combined).toContain('First paragraph'); + expect(combined).toContain('Second paragraph'); + expect(combined).not.toContain('Hidden footnote content'); + }); + + it('should not read aside footnotes in a namespaced XHTML doc', () => { + const doc = createXHTMLDoc( + '

Body text here.

' + + '', + { + xmlns: 'http://www.w3.org/1999/xhtml', + 'xmlns:epub': 'http://www.idpf.org/2007/ops', + }, + ); + const tts = new TTS(doc, textWalker, ttsNodeFilter, highlight, 'word'); + const combined = collectBlocks(tts).join(' '); + + expect(combined).toContain('Body text here'); + expect(combined).not.toContain('Namespaced footnote'); + }); + + it('should not read endnote, note, or rearnote asides', () => { + for (const type of ['endnote', 'note', 'rearnote']) { + const doc = createHTMLDoc( + '

Visible paragraph.

' + + ``, + ); + const tts = new TTS(doc, textWalker, ttsNodeFilter, highlight, 'word'); + const combined = collectBlocks(tts).join(' '); + + expect(combined).toContain('Visible paragraph'); + expect(combined).not.toContain(`Hidden ${type} text`); + } + }); + + it('should not read elements with the epubtype-footnote class', () => { + const doc = createHTMLDoc( + '

Chapter body.

' + + '', + ); + const tts = new TTS(doc, textWalker, ttsNodeFilter, highlight, 'word'); + const combined = collectBlocks(tts).join(' '); + + expect(combined).toContain('Chapter body'); + expect(combined).not.toContain('Transformed footnote'); + }); + + it('should not read duokan footnote content', () => { + const doc = createHTMLDoc( + '

Real text.

' + '

Duokan footnote.

', + ); + const tts = new TTS(doc, textWalker, ttsNodeFilter, highlight, 'word'); + const combined = collectBlocks(tts).join(' '); + + expect(combined).toContain('Real text'); + expect(combined).not.toContain('Duokan footnote'); + }); + + it('should not leak footnote text into the preceding block', () => { + const doc = createHTMLDoc( + '

Paragraph before footnote.

' + + '' + + '

Paragraph after footnote.

', + ); + const tts = new TTS(doc, textWalker, ttsNodeFilter, highlight, 'word'); + const blocks = collectBlocks(tts); + + expect(blocks.some((b) => b.includes('Paragraph before footnote'))).toBe(true); + expect(blocks.some((b) => b.includes('Paragraph after footnote'))).toBe(true); + for (const block of blocks) { + expect(block).not.toContain('Inline footnote text'); + } + }); + + it('should still read non-footnote aside elements', () => { + const doc = createHTMLDoc( + '

Main content.

' + '', + ); + const tts = new TTS(doc, textWalker, ttsNodeFilter, highlight, 'word'); + const combined = collectBlocks(tts).join(' '); + + expect(combined).toContain('Main content'); + expect(combined).toContain('Sidebar content stays'); + }); + + it('should keep reading footnote text when no node filter is given', () => { + // getBlocks only skips a block when the filter rejects it; without a + // filter, behaviour is unchanged (footnote handling lives in the filter). + const doc = createHTMLDoc( + '

Body paragraph.

' + + '', + ); + const tts = new TTS(doc, textWalker, undefined, highlight, 'word'); + const combined = collectBlocks(tts).join(' '); + + expect(combined).toContain('Body paragraph'); + expect(combined).toContain('Unfiltered footnote'); + }); + }); + + describe('createRejectFilter attributeTokens', () => { + it('should reject an element whose attribute value contains a token', () => { + const aside = document.createElement('aside'); + aside.setAttribute('epub:type', 'footnote'); + expect(ttsNodeFilter(aside)).toBe(NodeFilter.FILTER_REJECT); + }); + + it('should match a single token within a space-separated value', () => { + const aside = document.createElement('aside'); + aside.setAttribute('epub:type', 'bodymatter rearnote'); + expect(ttsNodeFilter(aside)).toBe(NodeFilter.FILTER_REJECT); + }); + + it('should not reject when no token matches', () => { + const aside = document.createElement('aside'); + aside.setAttribute('epub:type', 'sidebar'); + expect(ttsNodeFilter(aside)).toBe(NodeFilter.FILTER_SKIP); + }); + + it('should not reject when the tag does not match', () => { + const section = document.createElement('section'); + section.setAttribute('epub:type', 'footnote'); + expect(ttsNodeFilter(section)).toBe(NodeFilter.FILTER_SKIP); + }); + + it('should not reject an element missing the attribute', () => { + const aside = document.createElement('aside'); + expect(ttsNodeFilter(aside)).toBe(NodeFilter.FILTER_SKIP); + }); + }); + describe('SSML output correctness', () => { it('should produce valid SSML with speak root element', () => { const doc = createHTMLDoc('

Test content

', { lang: 'en' }); diff --git a/apps/readest-app/src/services/tts/TTSController.ts b/apps/readest-app/src/services/tts/TTSController.ts index 446b4ce0..b760b6fd 100644 --- a/apps/readest-app/src/services/tts/TTSController.ts +++ b/apps/readest-app/src/services/tts/TTSController.ts @@ -195,7 +195,23 @@ export class TTSController extends EventTarget { textWalker, createRejectFilter({ tags: ['rt', 'canvas', 'br'], - classes: ['annotationLayer'], + // Footnotes/endnotes are hidden in the rendered page (see the + // `.epubtype-footnote`/`aside[epub|type]` rules in getPageLayoutStyles); + // skip them in TTS too, including for background sections whose + // documents are loaded without those styles. + classes: [ + 'annotationLayer', + 'epubtype-footnote', + 'duokan-footnote-content', + 'duokan-footnote-item', + ], + attributeTokens: [ + { + tag: 'aside', + attribute: 'epub:type', + tokens: ['footnote', 'endnote', 'note', 'rearnote'], + }, + ], contents: [{ tag: 'a', content: /^[\[\(]?[\*\d]+[\)\]]?$/ }], }), this.#getHighlighter(), diff --git a/apps/readest-app/src/utils/node.ts b/apps/readest-app/src/utils/node.ts index 536c2f22..3213907c 100644 --- a/apps/readest-app/src/utils/node.ts +++ b/apps/readest-app/src/utils/node.ts @@ -2,11 +2,18 @@ export const createRejectFilter = ({ tags = [], classes = [], attributes = [], + attributeTokens = [], contents = [], }: { tags?: string[]; classes?: string[]; attributes?: string[]; + /** + * Reject elements whose space-separated attribute value contains any of the + * given tokens, optionally constrained to a tag name. Matches the way CSS + * `[attr~="token"]` selectors work — e.g. `aside[epub:type~="footnote"]`. + */ + attributeTokens?: { tag?: string; attribute: string; tokens: string[] }[]; contents?: { tag: string; content: RegExp }[]; }) => { return (node: Node): number => { @@ -24,6 +31,17 @@ export const createRejectFilter = ({ if (attributes.some((attr) => (node as Element).hasAttribute(attr))) { return NodeFilter.FILTER_REJECT; } + if ( + attributeTokens.some(({ tag, attribute, tokens }) => { + if (tag && name !== tag) return false; + const value = (node as Element).getAttribute(attribute); + if (!value) return false; + const valueTokens = value.split(/\s+/); + return tokens.some((token) => valueTokens.includes(token)); + }) + ) { + return NodeFilter.FILTER_REJECT; + } if ( contents.some(({ tag, content }) => { return name === tag && content.test((node as Element).textContent || ''); diff --git a/packages/foliate-js b/packages/foliate-js index 4361f29b..3c597a6d 160000 --- a/packages/foliate-js +++ b/packages/foliate-js @@ -1 +1 @@ -Subproject commit 4361f29b5a23c4593cf32e781a37b0f3ef910e91 +Subproject commit 3c597a6dc5632b157cebd363e03f8ae33df516a1