test/e2e/text-layer.test.ts (3827 bytes)
1 // End-to-end check of the text layer: pdf.js in Node (the legacy build) 2 // reads the text-layer fixture PDFs, and text-layer.ts must give one 3 // token per character, in order, with the lines of the fixture. 4 // Run: npm run test:e2e 5 6 import { access, readFile } from 'node:fs/promises'; 7 import path from 'node:path'; 8 import { getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs'; 9 import { describe, expect, it } from 'vitest'; 10 import { alignCuesToTokens, normalizeText, type OcrToken } from '../../src/align'; 11 import { bookText } from '../../src/book-text'; 12 import { parseSubtitles } from '../../src/subtitles'; 13 import { textItems, textItemsToTokens } from '../../src/text-layer'; 14 import { FIXTURES, type Fixture } from '../../fixtures/sample-text'; 15 16 const WIDTH = 1600; 17 18 async function exists(file: string): Promise<boolean> { 19 try { 20 await access(file); 21 return true; 22 } catch { 23 return false; 24 } 25 } 26 27 async function textTokens(file: string): Promise<{ tokens: OcrToken[]; height: number }> { 28 const data = new Uint8Array(await readFile(file)); 29 const task = getDocument({ data }); 30 const doc = await task.promise; 31 const tokens: OcrToken[] = []; 32 let height = 0; 33 for (let p = 0; p < doc.numPages; p++) { 34 const page = await doc.getPage(p + 1); 35 const base = page.getViewport({ scale: 1 }); 36 const viewport = page.getViewport({ scale: WIDTH / base.width }); 37 height = viewport.height; 38 const content = await page.getTextContent(); 39 const items = textItems(content.items); 40 const toPixel = (x: number, y: number) => 41 viewport.convertToViewportPoint(x, y) as [number, number]; 42 tokens.push(...textItemsToTokens(items, p, toPixel)); 43 } 44 await task.destroy(); 45 return { tokens, height }; 46 } 47 48 const withText: Fixture[] = []; 49 for (const fixture of FIXTURES) { 50 if (await exists(path.resolve('fixtures', `${fixture.name}-text.pdf`))) withText.push(fixture); 51 } 52 53 describe.each(withText)('text layer of $name-text.pdf', (fixture) => { 54 it('carries every character in order, line by line', async () => { 55 const { tokens, height } = await textTokens( 56 path.resolve('fixtures', `${fixture.name}-text.pdf`), 57 ); 58 const expected = fixture.pages.map((lines) => lines.join('\n')).join('\n\n') + '\n'; 59 expect(bookText(tokens)).toBe(expected); 60 expect(tokens.map((t) => t.text).join('')).toBe(expected.replace(/\s/g, '')); 61 // Each page: every box inside the page, lines from top to bottom. 62 let lastLine = -1; 63 let lastY = -Infinity; 64 for (const t of tokens) { 65 expect(t.bbox.x0).toBeGreaterThanOrEqual(0); 66 expect(t.bbox.x1).toBeLessThanOrEqual(WIDTH); 67 expect(t.bbox.y0).toBeGreaterThanOrEqual(0); 68 expect(t.bbox.y1).toBeLessThanOrEqual(height); 69 if (t.line !== lastLine) { 70 if (t.line % 100000 !== 0) expect(t.bbox.y0).toBeGreaterThan(lastY); 71 lastLine = t.line; 72 lastY = t.bbox.y0; 73 } 74 } 75 }); 76 77 it('aligns every cue to the text layer', async () => { 78 const { tokens } = await textTokens(path.resolve('fixtures', `${fixture.name}-text.pdf`)); 79 const srt = await readFile(path.resolve('fixtures', `${fixture.name}.srt`), 'utf8'); 80 const spans = alignCuesToTokens( 81 parseSubtitles(srt).map((c) => c.text), 82 tokens, 83 ); 84 expect(spans).toHaveLength(fixture.cues.length); 85 spans.forEach((span, i) => { 86 expect(span.matched, `cue ${i} matched`).toBe(true); 87 const text = tokens 88 .slice(span.start, span.end) 89 .map((t) => t.text) 90 .join(''); 91 // The alignment drops punctuation, so a span can leave out a comma at its edge. 92 expect(normalizeText(text)).toBe(normalizeText(fixture.cues[i]!)); 93 }); 94 }); 95 }); 96 97 it('has at least the English text-layer fixture', () => { 98 expect(withText.map((f) => f.name)).toContain('sample-eng-2p'); 99 });