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(['One

two.', '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(['
hi
']); + }); + + it('keeps an escaped comparison operator', () => { + expect(htmlToBlocks('

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
')).toEqual(["L'autore", "L'anno"]); + }); + }); }); describe('normalizePath', () => { diff --git a/server/src/modules/tts/tts-text-extractor.service.ts b/server/src/modules/tts/tts-text-extractor.service.ts index b500ec37b..79e7e2f23 100644 --- a/server/src/modules/tts/tts-text-extractor.service.ts +++ b/server/src/modules/tts/tts-text-extractor.service.ts @@ -1,4 +1,5 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { load } from 'cheerio'; import * as unzipper from 'unzipper'; import { XMLParser } from 'fast-xml-parser'; @@ -35,9 +36,6 @@ export function htmlToBlocks(html: string): string[] { // server-side audio blocks and client-side highlight blocks stay in sync. const BLOCK_SPLIT_RE = /<(?:article|aside|audio|blockquote|caption|details|dialog|div|dl|dt|dd|figure|footer|form|figcaption|h[1-6]|header|hgroup|hr|li|main|math|nav|ol|p|pre|section|tr)[^>]*>/gi; - const TAG_RE = /<[^>]+>/g; - const ENTITY_RE = /&(?:amp|lt|gt|quot|apos|nbsp);/g; - const ENTITY_MAP: Record = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ' ': ' ' }; // Foliate's getBlocks() starts the first block at the first block-level // element and discards any content before it. Anchor extraction at the same @@ -52,16 +50,30 @@ export function htmlToBlocks(html: string): string[] { // inside a paragraph (common in pretty-printed EPUB HTML) never splits one // paragraph into several blocks and desyncs the indices. const chunks = fromFirstBlock.split(BLOCK_SPLIT_RE); - return chunks - .map((chunk) => { - const text = chunk - .replace(TAG_RE, '') - .replace(ENTITY_RE, (m) => ENTITY_MAP[m] ?? m) - .replace(/\s+/g, ' ') - .trim(); - return text; - }) - .filter((t) => t.length > 0); + + // Only the text conversion below changed: the split above still decides block + // boundaries, so block indices stay aligned with Foliate's highlight blocks. + return chunks.map((chunk) => blockText(chunk)).filter((t) => t.length > 0); +} + +/** + * A block's spoken text, which has to be the text the reader displays for that block, + * because the narration is read against the page. + * + * Each chunk is still an HTML fragment, so it is parsed rather than stripped and unescaped + * by hand. The hand-written table this replaced knew six named entities and no numeric + * ones, so a reference like `'` survived into the block text and providers read it out + * verbatim ("hash thirty-nine") where the page showed a normal apostrophe. Parsing decodes + * whatever the reader's own DOM decodes, and decodes once, so a book that quotes `&#39;` + * is still narrated as `'`. + * + * `htmlToPlainText` is deliberately not used here. It strips tag-shaped text a second time + * after decoding, which is right for scraped metadata that gets displayed but would silence + * content in a book: a code sample written `<div>hi</div>` would narrate as + * "hi", and "5 < 6" would lose its operator. + */ +function blockText(chunk: string): string { + return load(chunk, undefined, false).root().text().replace(/\s+/g, ' ').trim(); } @Injectable()