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/ocr-align.test.ts (4115 bytes)

1 // End-to-end check without a browser: real tesseract.js OCR on the
2 // synthetic page images, then alignment against the fixture cues.
3 // Needs network on the first run to fetch language data into .tessdata/.
4 // Run: npm run test:e2e
5 
6 import { mkdir, readFile } from 'node:fs/promises';
7 import path from 'node:path';
8 import { createWorker } from 'tesseract.js';
9 import { afterAll, beforeAll, describe, expect, it } from 'vitest';
10 import { alignCuesToTokens, type OcrToken } from '../../src/align';
11 import { pageToTokens } from '../../src/ocr-tokens';
12 import { parseSubtitles } from '../../src/subtitles';
13 import { FIXTURES, lineBand, type Fixture } from '../../fixtures/sample-text';
14 
15 const TIMEOUT = 300_000;
16 const CACHE = '.tessdata';
17 
18 /** The image file of page `p`: `<name>.png` for one page, `<name>-<p+1>.png` for more. */
19 function pngName(fixture: Fixture, p: number): string {
20   return fixture.pages.length === 1 ? `${fixture.name}.png` : `${fixture.name}-${p + 1}.png`;
21 }
22 
23 async function ocrFixture(fixture: Fixture): Promise<OcrToken[]> {
24   // tesseract.js writes the cache file only when the directory exists.
25   await mkdir(CACHE, { recursive: true });
26   const worker = await createWorker(fixture.lang, 1, { cachePath: CACHE });
27   try {
28     const tokens: OcrToken[] = [];
29     for (let p = 0; p < fixture.pages.length; p++) {
30       const image = await readFile(path.resolve('fixtures', pngName(fixture, p)));
31       const { data } = await worker.recognize(image, {}, { blocks: true });
32       tokens.push(...pageToTokens(data, p));
33     }
34     return tokens;
35   } finally {
36     await worker.terminate();
37   }
38 }
39 
40 /** The page and the line on that page of cue `i`, or null for a cue that crosses pages. */
41 function placeOf(fixture: Fixture, i: number): { page: number; line: number } | null {
42   let cue = 0;
43   for (const [page, lines] of fixture.pages.entries()) {
44     const first = page > 0 ? 1 : 0;
45     const last = page < fixture.pages.length - 1 ? lines.length - 1 : lines.length;
46     for (let line = first; line < last; line++, cue++) if (cue === i) return { page, line };
47     if (page < fixture.pages.length - 1) {
48       if (cue === i) return null;
49       cue++;
50     }
51   }
52   throw new Error(`no cue ${i}`);
53 }
54 
55 describe.each(FIXTURES)('OCR + alignment on $name', (fixture) => {
56   let tokens: OcrToken[] = [];
57 
58   beforeAll(async () => {
59     tokens = await ocrFixture(fixture);
60   }, TIMEOUT);
61 
62   afterAll(() => {
63     const text = tokens.map((t) => t.text).join('');
64     console.log(`[${fixture.name}] OCR read ${tokens.length} symbols: ${text.slice(0, 120)}...`);
65   });
66 
67   it('highlights the line of each cue', async () => {
68     const srt = await readFile(path.resolve('fixtures', `${fixture.name}.srt`), 'utf8');
69     const cues = parseSubtitles(srt);
70     expect(cues).toHaveLength(fixture.cues.length);
71     expect(tokens.length).toBeGreaterThan(0);
72 
73     const spans = alignCuesToTokens(
74       cues.map((c) => c.text),
75       tokens,
76     );
77 
78     spans.forEach((span, i) => {
79       expect(span.matched, `cue ${i} matched`).toBe(true);
80       expect(
81         span.end - span.start,
82         `cue ${i} covers most of its characters`,
83       ).toBeGreaterThanOrEqual(Math.floor(0.6 * fixture.cues[i]!.length));
84       const place = placeOf(fixture, i);
85       if (!place) {
86         // The cue that crosses the page boundary: its tokens lie on both pages.
87         const pages = new Set<number>();
88         for (let t = span.start; t < span.end; t++) pages.add(tokens[t]!.page);
89         expect([...pages].sort(), `cue ${i} crosses the pages`).toEqual([0, 1]);
90         return;
91       }
92       const band = lineBand(place.line);
93       for (let t = span.start; t < span.end; t++) {
94         const token = tokens[t]!;
95         expect(token.page, `cue ${i} token ${t} "${token.text}" on page ${place.page}`).toBe(
96           place.page,
97         );
98         const yMid = (token.bbox.y0 + token.bbox.y1) / 2;
99         expect(
100           yMid,
101           `cue ${i} token ${t} "${token.text}" on line ${place.line}`,
102         ).toBeGreaterThanOrEqual(band.top);
103         expect(yMid).toBeLessThanOrEqual(band.bottom);
104       }
105     });
106   });
107 });