From a5690e9a842a7ddd1cf9a1c6fd011a98ed26d481 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Fri, 10 Apr 2026 02:34:38 +0800 Subject: [PATCH] fix(tts): skip br elements in PDF text layer to prevent TTS interruptions at line breaks, closes #3771 (#3811) PDF.js TextLayer renders
between text spans for visual line wrapping. The TTS SSML generator was converting these to elements, causing TTS engines to pause at every PDF line break within paragraphs. Fix by rejecting
(along with canvas and annotationLayer) via the node filter when the document is detected as a PDF. Co-authored-by: Claude Opus 4.6 (1M context) --- .../src/__tests__/document/pdf-tts.test.ts | 189 +++++++++++++++++- .../src/services/tts/TTSController.ts | 9 +- packages/foliate-js | 2 +- 3 files changed, 192 insertions(+), 8 deletions(-) diff --git a/apps/readest-app/src/__tests__/document/pdf-tts.test.ts b/apps/readest-app/src/__tests__/document/pdf-tts.test.ts index 8856f522..b19393f7 100644 --- a/apps/readest-app/src/__tests__/document/pdf-tts.test.ts +++ b/apps/readest-app/src/__tests__/document/pdf-tts.test.ts @@ -19,10 +19,16 @@ const highlight = vi.fn(); /** * Build a document that mimics a rendered PDF page with text layer, * matching the structure that pdf.js produces in the iframe. + * When withLineBreaks is true, inserts
between spans (matching real PDF.js output). */ -const createPDFTextLayerDoc = (textSpans: string[], annotationText?: string): Document => { +const createPDFTextLayerDoc = ( + textSpans: string[], + annotationText?: string, + withLineBreaks?: boolean, +): Document => { const parser = new DOMParser(); - const spans = textSpans.map((t) => `${t}`).join(''); + const separator = withLineBreaks ? '
' : ''; + const spans = textSpans.map((t) => `${t}`).join(separator); const annotation = annotationText ? `` : '
'; @@ -38,7 +44,7 @@ const createPDFTextLayerDoc = (textSpans: string[], annotationText?: string): Do /** Node filter matching what TTSController uses for PDFs */ const pdfNodeFilter = createRejectFilter({ - tags: ['rt', 'canvas'], + tags: ['rt', 'canvas', 'br'], classes: ['annotationLayer'], contents: [{ tag: 'a', content: /^[\[\(]?[\*\d]+[\)\]]?$/ }], }); @@ -101,6 +107,183 @@ describe('PDF TTS', () => { }); }); + describe('TTS with PDF line breaks (br elements)', () => { + it('should not produce SSML break elements for br tags in PDF text layer', () => { + const doc = createPDFTextLayerDoc( + ['Alice was beginning to get very ', 'tired of sitting by her sister '], + undefined, + true, + ); + const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word'); + const ssml = tts.start(); + + expect(ssml).toBeTruthy(); + // The SSML should NOT contain elements for PDF line breaks + expect(ssml).not.toMatch(//); + // But the text should still be continuous + const text = stripTags(ssml!); + expect(text).toContain('Alice'); + expect(text).toContain('tired'); + }); + + it('should read through PDF line breaks without interruption', () => { + const doc = createPDFTextLayerDoc( + [ + 'This is the first line of a paragraph ', + 'and this continues on the second line ', + 'ending on the third line.', + ], + undefined, + true, + ); + const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word'); + const ssml = tts.start(); + + expect(ssml).toBeTruthy(); + const text = stripTags(ssml!); + // All text should be in a single block without breaks + expect(text).toContain('first line'); + expect(text).toContain('second line'); + expect(text).toContain('third line'); + // No SSML break elements + expect(ssml).not.toMatch(//); + }); + }); + + describe('PDF sentence-level block splitting', () => { + it('should split multiple sentences into separate TTS blocks', () => { + const doc = createPDFTextLayerDoc([ + 'Alice was beginning to get very tired. ', + 'She had nothing to do. ', + 'The day was warm and sunny.', + ]); + const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word'); + + const blocks: string[] = []; + let ssml = tts.start(); + while (ssml) { + blocks.push(stripTags(ssml)); + ssml = tts.next(); + } + + // Each sentence should be its own block + expect(blocks.length).toBe(3); + expect(blocks[0]).toContain('Alice was beginning'); + expect(blocks[1]).toContain('She had nothing'); + expect(blocks[2]).toContain('The day was warm'); + }); + + it('should handle sentences that span across multiple spans', () => { + const doc = createPDFTextLayerDoc([ + 'Alice was beginning to get very ', + 'tired of sitting by her sister. She had ', + 'nothing to do.', + ]); + const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word'); + + const blocks: string[] = []; + let ssml = tts.start(); + while (ssml) { + blocks.push(stripTags(ssml)); + ssml = tts.next(); + } + + expect(blocks.length).toBe(2); + expect(blocks[0]).toContain('tired of sitting'); + expect(blocks[1]).toContain('nothing to do'); + }); + + it('should handle a single sentence as one block', () => { + const doc = createPDFTextLayerDoc(['Just one sentence here.']); + const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word'); + + const blocks: string[] = []; + let ssml = tts.start(); + while (ssml) { + blocks.push(stripTags(ssml)); + ssml = tts.next(); + } + + expect(blocks.length).toBe(1); + expect(blocks[0]).toContain('Just one sentence here'); + }); + + it('should handle sentences with br elements between lines', () => { + const doc = createPDFTextLayerDoc( + [ + 'First sentence on line one. ', + 'Second sentence starts here ', + 'and continues on line three.', + ], + undefined, + true, + ); + const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word'); + + const blocks: string[] = []; + let ssml = tts.start(); + while (ssml) { + const text = stripTags(ssml); + blocks.push(text); + // No break elements in any block + expect(ssml).not.toMatch(//); + ssml = tts.next(); + } + + expect(blocks.length).toBe(2); + expect(blocks[0]).toContain('First sentence'); + expect(blocks[1]).toContain('Second sentence'); + expect(blocks[1]).toContain('line three'); + }); + + it('should produce word marks within each sentence block', () => { + const doc = createPDFTextLayerDoc(['Hello world. Goodbye world.']); + const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word'); + + const ssml = tts.start(); + expect(ssml).toBeTruthy(); + expect(ssml).toContain(' { + // Sentence boundary falls in the middle of span 2: + // "Alice was beginning to get " + "very tired. She had nothing " + "to do." + // Sentence 1: "Alice was beginning to get very tired. " + // Sentence 2: "She had nothing to do." + const doc = createPDFTextLayerDoc([ + 'Alice was beginning to get ', + 'very tired. She had nothing ', + 'to do.', + ]); + const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word'); + + const ssml1 = tts.start(); + expect(ssml1).toBeTruthy(); + const text1 = stripTags(ssml1!); + // First block must contain ONLY sentence 1 words + expect(text1).toContain('Alice'); + expect(text1).toContain('tired'); + expect(text1).not.toContain('She'); + expect(text1).not.toContain('nothing'); + + const ssml2 = tts.next(); + expect(ssml2).toBeTruthy(); + const text2 = stripTags(ssml2!); + // Second block must contain ONLY sentence 2 words + expect(text2).toContain('She'); + expect(text2).toContain('nothing'); + expect(text2).not.toContain('Alice'); + expect(text2).not.toContain('tired'); + }); + }); + describe('PDF node filter', () => { it('should reject canvas elements', () => { const canvas = document.createElement('canvas'); diff --git a/apps/readest-app/src/services/tts/TTSController.ts b/apps/readest-app/src/services/tts/TTSController.ts index 22cf5dca..bb179293 100644 --- a/apps/readest-app/src/services/tts/TTSController.ts +++ b/apps/readest-app/src/services/tts/TTSController.ts @@ -194,13 +194,14 @@ export class TTSController extends EventTarget { doc, textWalker, createRejectFilter({ - tags: ['rt'], + tags: ['rt', 'canvas', 'br'], + classes: ['annotationLayer'], contents: [{ tag: 'a', content: /^[\[\(]?[\*\d]+[\)\]]?$/ }], }), this.#getHighlighter(), granularity, ); - console.log(`Initialized TTS for section ${sectionIndex}`); + console.log(`[TTS] Initialized TTS for section ${sectionIndex}`); return true; } @@ -314,7 +315,7 @@ export class TTSController extends EventTarget { this.#currentSpeakPromise = new Promise(async (resolve, reject) => { try { - console.log('TTS speak'); + console.log('[TTS] speak'); this.state = 'playing'; signal.addEventListener('abort', () => { @@ -333,7 +334,7 @@ export class TTSController extends EventTarget { await this.stop(); } } - console.log('no SSML, skipping for', this.#nossmlCnt); + console.log('[TTS] no SSML, skipping for', this.#nossmlCnt); return; } else { this.#nossmlCnt = 0; diff --git a/packages/foliate-js b/packages/foliate-js index d3baa52d..ec7e16aa 160000 --- a/packages/foliate-js +++ b/packages/foliate-js @@ -1 +1 @@ -Subproject commit d3baa52d05047b2aa02779294f02a81fac855188 +Subproject commit ec7e16aa483429701c7f4f19ab909ecae79dfd58