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
+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 || '');