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) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-17 03:23:54 +08:00
committed by GitHub
parent 8dfc0e945e
commit f4483643f4
4 changed files with 205 additions and 2 deletions
@@ -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<string, string> = {}): 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<typeof TTS>): 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(
'<p>First paragraph of the chapter.</p>' +
'<p>Second paragraph of the chapter.</p>' +
'<aside epub:type="footnote" id="fn1"><p>Hidden footnote content.</p></aside>',
);
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(
'<p>Body text here.</p>' +
'<aside epub:type="footnote"><p>Namespaced footnote.</p></aside>',
{
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(
'<p>Visible paragraph.</p>' +
`<aside epub:type="${type}"><p>Hidden ${type} text.</p></aside>`,
);
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(
'<p>Chapter body.</p>' +
'<aside class="epubtype-footnote" epub:type="footnote">' +
'<p>Transformed footnote.</p></aside>',
);
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(
'<p>Real text.</p>' + '<div class="duokan-footnote-content"><p>Duokan footnote.</p></div>',
);
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(
'<p>Paragraph before footnote.</p>' +
'<aside epub:type="footnote"><p>Inline footnote text.</p></aside>' +
'<p>Paragraph after footnote.</p>',
);
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(
'<p>Main content.</p>' + '<aside epub:type="sidebar"><p>Sidebar content stays.</p></aside>',
);
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(
'<p>Body paragraph.</p>' +
'<aside epub:type="footnote"><p>Unfiltered footnote.</p></aside>',
);
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('<p>Test content</p>', { lang: 'en' });
@@ -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(),
+18
View File
@@ -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 || '');