fix(tts): skip br elements in PDF text layer to prevent TTS interruptions at line breaks, closes #3771 (#3811)
PDF.js TextLayer renders <br> between text spans for visual line wrapping. The TTS SSML generator was converting these to <break> elements, causing TTS engines to pause at every PDF line break within paragraphs. Fix by rejecting <br> (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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 <br> 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) => `<span>${t}</span>`).join('');
|
||||
const separator = withLineBreaks ? '<br>' : '';
|
||||
const spans = textSpans.map((t) => `<span>${t}</span>`).join(separator);
|
||||
const annotation = annotationText
|
||||
? `<div class="annotationLayer"><a href="#">${annotationText}</a></div>`
|
||||
: '<div class="annotationLayer"></div>';
|
||||
@@ -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 <break> elements for PDF line breaks
|
||||
expect(ssml).not.toMatch(/<break\s*\/?\s*>/);
|
||||
// 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(/<break\s*\/?\s*>/);
|
||||
});
|
||||
});
|
||||
|
||||
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(/<break\s*\/?\s*>/);
|
||||
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('<mark');
|
||||
expect(stripTags(ssml!)).toContain('Hello');
|
||||
expect(stripTags(ssml!)).not.toContain('Goodbye');
|
||||
|
||||
const ssml2 = tts.next();
|
||||
expect(ssml2).toBeTruthy();
|
||||
expect(ssml2).toContain('<mark');
|
||||
expect(stripTags(ssml2!)).toContain('Goodbye');
|
||||
});
|
||||
|
||||
it('should align marks with sentence text when sentence spans multiple spans', () => {
|
||||
// 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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1
-1
Submodule packages/foliate-js updated: d3baa52d05...ec7e16aa48
Reference in New Issue
Block a user