Recently Written · git

scan-subread

PDF reader that follows an audiobook with the SubRead suite

git clone https://github.com/equwal/scan-subread

Log | Files | Refs


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 });