From a4809512d8ee4b839414a1395a3d8fc88c7560f2 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:51:10 +0000 Subject: [PATCH] fix(reader): decode character references in read-aloud text Block text was unescaped from a six-entry table, so only &, <, >, ", ' and ever became characters. Every numeric reference survived into the text handed to the TTS provider, and an EPUB that writes its apostrophes as ' had them read out as "hash thirty-nine" while the page showed a normal apostrophe. Which apostrophes broke depended only on how the book was serialized: ' was in the table, ' was not, and both appear in the same sentence often enough. Parse each block instead of stripping tags and unescaping by hand. The parser decodes whatever the reader's own DOM decodes, which is the property that matters here because the narration is read against the page, and it decodes in one pass, so a book that quotes ' is still narrated as '. Only the text conversion changed. Block boundaries still come from the same split on block-element opening tags, so block indices stay aligned with Foliate's highlight blocks and audio cannot drift a paragraph away from the highlight. Do not route this through htmlToPlainText. That helper strips tag-shaped text a second time after decoding, which is right for the scraped metadata it was written for but silences book content: a code sample written <div>hi</div> would narrate as "hi", and "5 < 6" would lose its operator. Tests cover both. Fixes #1486 --- .../tts/tts-text-extractor.service.test.ts | 67 +++++++++++++++++++ .../modules/tts/tts-text-extractor.service.ts | 38 +++++++---- 2 files changed, 92 insertions(+), 13 deletions(-) diff --git a/server/src/modules/tts/tts-text-extractor.service.test.ts b/server/src/modules/tts/tts-text-extractor.service.test.ts index 8b4b1d050..85729fc3a 100644 --- a/server/src/modules/tts/tts-text-extractor.service.test.ts +++ b/server/src/modules/tts/tts-text-extractor.service.test.ts @@ -42,6 +42,73 @@ describe('htmlToBlocks', () => { it('decodes entities and collapses whitespace within a block', () => { expect(htmlToBlocks('
Tom & Jerry say\n"hi".
')).toEqual(['Tom & Jerry say "hi".']); }); + + // Regression: block text used to be unescaped from a six-entry table, so anything + // outside it reached the TTS provider as raw markup. A Kokoro user heard + // "dell hash thirty-nine Arbatskaja" for text the reader displayed as "dell'Arbatskaja". + describe('character references', () => { + it('decodes a decimal numeric reference', () => { + expect(htmlToBlocks('Lui aveva fatto l'abitudine.
')).toEqual(["Lui aveva fatto l'abitudine."]); + }); + + it('decodes a hexadecimal numeric reference in either case', () => { + expect(htmlToBlocks('dell'Arbatskaja — sì.
')).toEqual(["dell'Arbatskaja — sì."]); + }); + + it('decodes named references beyond the five XML ones', () => { + expect(htmlToBlocks('Café à Paris… “c’est bon”
')).toEqual([ + 'Café à Paris… “c’est bon”', + ]); + }); + + it('still decodes the references the old table covered', () => { + expect(htmlToBlocks('Tom & Jerry "said" it's fine.
')).toEqual(['Tom & Jerry "said" it\'s fine.']); + }); + + it('collapses a decoded non-breaking space like any other whitespace', () => { + expect(htmlToBlocks('Chapter One begins.
')).toEqual(['Chapter One begins.']); + }); + + // A second decoding pass would turn this into an apostrophe and silently rewrite a + // book that is quoting the markup itself. + it('decodes once, leaving an escaped reference escaped', () => { + expect(htmlToBlocks('Write ' for an apostrophe.
')).toEqual(['Write ' for an apostrophe.']); + }); + + it('leaves a bare ampersand and an unknown reference alone', () => { + expect(htmlToBlocks('Fish & chips, AT&T, &zzz; too.
')).toEqual(['Fish & chips, AT&T, &zzz; too.']); + }); + + // Decoding before the split would let an escaped block tag open a new block and + // shift every later index away from Foliate's highlight blocks. + it('does not let a decoded reference create a block boundary', () => { + expect(htmlToBlocks('One <p> two.
Next.
')).toEqual(['Onetwo.', 'Next.']); + }); + + // Escaped markup is content the page displays, so it is content the narration reads. + // Discarding it again after decoding would mute a programming book's code samples. + it('keeps escaped markup that the page displays', () => { + expect(htmlToBlocks('
<div class="x">hi</div>')).toEqual(['
Se 5 < 6, allora va bene.
')).toEqual(['Se 5 < 6, allora va bene.']); + }); + + // Block indices are positional, so a paragraph that is nothing but a reference has + // to keep its slot rather than being dropped as empty. + it('keeps a block whose only content is a reference', () => { + expect(htmlToBlocks('Before.
—
After.
')).toEqual(['Before.', '—', 'After.']); + }); + + it('decodes references inside inline markup', () => { + expect(htmlToBlocks('Il tram’s l'ultimo.
')).toEqual(["Il tram’s l'ultimo."]); + }); + + it('decodes references in a table row block', () => { + expect(htmlToBlocks('| L'autore |
| L'anno |