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

1 import type Tesseract from 'tesseract.js';
2 import { afterEach, describe, expect, it, vi } from 'vitest';
3 import { createOcr, OcrStartError } from '../src/ocr';
4 
5 const { createWorker } = vi.hoisted(() => ({ createWorker: vi.fn() }));
6 vi.mock('tesseract.js', () => ({ createWorker, PSM: { SINGLE_BLOCK_VERT_TEXT: '5' } }));
7 
8 /** The options that createOcr gave to createWorker. */
9 interface Options {
10   workerPath?: string;
11   corePath?: string;
12   langPath?: string;
13   logger?: (m: { status: string; progress: number }) => void;
14   errorHandler?: (error: unknown) => void;
15 }
16 
17 function options(): Options {
18   return createWorker.mock.calls[0]?.[2] as Options;
19 }
20 
21 function fakeWorker() {
22   return {
23     setParameters: vi.fn(() => Promise.resolve({})),
24     recognize: vi.fn((_image: unknown): Promise<{ data: Pick<Tesseract.Page, 'blocks'> }> =>
25       Promise.reject(new Error('The image is empty.')),
26     ),
27     terminate: vi.fn(() => Promise.resolve({})),
28   };
29 }
30 
31 /** A page canvas of 2 x 1 pixels: red, then blue with half alpha. */
32 function fakeCanvas(): HTMLCanvasElement {
33   const data = new Uint8ClampedArray([255, 0, 0, 255, 0, 0, 255, 128]);
34   const context = { getImageData: () => ({ width: 2, height: 1, data }) };
35   return { width: 2, height: 1, getContext: () => context } as unknown as HTMLCanvasElement;
36 }
37 
38 /** A path on the origin of the app, for example "/assets/worker.min-abc.js". */
39 const LOCAL = /^\/(?!\/)/;
40 
41 /** The Web Worker of the browser, for the tests. It keeps each worker that it makes. */
42 class FakeWebWorker extends EventTarget {
43   static made: FakeWebWorker[] = [];
44   terminate = vi.fn();
45   constructor(readonly url: string | URL) {
46     super();
47     FakeWebWorker.made.push(this);
48   }
49 }
50 
51 /**
52  * createWorker as tesseract.js makes it in the browser: it makes its Web
53  * Worker at once (src/worker/browser/spawnWorker.js). `start` gives the
54  * promise of the start.
55  */
56 function spawningCreateWorker(start: (opts: Options) => Promise<unknown>) {
57   return (_lang: string, _oem: number, opts: Options): Promise<unknown> => {
58     new Worker('blob:tesseract-worker');
59     return start(opts);
60   };
61 }
62 
63 describe('createOcr', () => {
64   afterEach(() => {
65     createWorker.mockReset();
66     vi.unstubAllGlobals();
67     FakeWebWorker.made = [];
68   });
69 
70   it('loads the worker script and the core from the app, not from jsDelivr', async () => {
71     createWorker.mockResolvedValue(fakeWorker());
72     const progress: number[] = [];
73     await createOcr('jpn', (p) => progress.push(p));
74 
75     expect(createWorker).toHaveBeenCalledTimes(1);
76     expect(createWorker.mock.calls[0]?.slice(0, 2)).toEqual(['jpn', 1]);
77     const { workerPath, corePath, langPath, logger } = options();
78     expect(workerPath).toMatch(LOCAL);
79     expect(workerPath).toMatch(/\/worker\.min\.js$/);
80     expect(corePath).toMatch(LOCAL);
81     expect(corePath).toMatch(/\/tesseract-core-simd-lstm\.wasm\.js$/);
82     expect(`${workerPath} ${corePath}`).not.toContain('jsdelivr');
83     // The language data still comes from the default place of tesseract.js.
84     expect(langPath).toBeUndefined();
85 
86     logger?.({ status: 'loading language traineddata', progress: 0.5 });
87     logger?.({ status: 'recognizing text', progress: 0.25 });
88     expect(progress).toEqual([0.25]);
89   });
90 
91   it('sets the page mode of a vertical language', async () => {
92     const worker = fakeWorker();
93     createWorker.mockResolvedValue(worker);
94     await createOcr('jpn_vert', () => undefined);
95     expect(worker.setParameters).toHaveBeenCalledWith({ tessedit_pageseg_mode: '5' });
96   });
97 
98   it('gives OcrStartError when the worker script does not load', async () => {
99     const cause =
100       "Uncaught NetworkError: Failed to execute 'importScripts' on 'WorkerGlobalScope': failed to load.";
101     createWorker.mockRejectedValue(cause);
102     const error = await createOcr('eng', () => undefined).catch((e: unknown) => e);
103     expect(error).toBeInstanceOf(OcrStartError);
104     expect((error as OcrStartError).cause).toBe(cause);
105     expect((error as OcrStartError).message).toContain(cause);
106   });
107 
108   it('gives OcrStartError when the language data does not load', async () => {
109     // tesseract.js gives the error to errorHandler, and its promise does not settle.
110     createWorker.mockImplementation((_lang: string, _oem: number, opts: Options) => {
111       opts.errorHandler?.('TypeError: Failed to fetch');
112       return new Promise(() => undefined);
113     });
114     const error = await createOcr('jpn', () => undefined).catch((e: unknown) => e);
115     expect(error).toBeInstanceOf(OcrStartError);
116     expect((error as OcrStartError).cause).toBe('TypeError: Failed to fetch');
117   });
118 
119   it('stops the Web Worker when the language data does not load', async () => {
120     // The finding: tesseract.js gives no handle to this worker, and it does
121     // not stop it. Each Retry, book or setting left one more worker.
122     vi.stubGlobal('Worker', FakeWebWorker);
123     createWorker.mockImplementation(
124       spawningCreateWorker((opts) => {
125         setTimeout(() => opts.errorHandler?.('TypeError: Failed to fetch'), 0);
126         return new Promise(() => undefined);
127       }),
128     );
129     const error = await createOcr('jpn', () => undefined).catch((e: unknown) => e);
130     expect(error).toBeInstanceOf(OcrStartError);
131     expect(FakeWebWorker.made).toHaveLength(1);
132     expect(FakeWebWorker.made[0]!.terminate).toHaveBeenCalledTimes(1);
133     // The browser gets its own Worker constructor back.
134     expect(globalThis.Worker).toBe(FakeWebWorker);
135   });
136 
137   it('stops the Web Worker when the start fails', async () => {
138     // tesseract.js rejects the start when its worker script does not load,
139     // and it does not stop the Web Worker.
140     vi.stubGlobal('Worker', FakeWebWorker);
141     createWorker.mockImplementation(
142       spawningCreateWorker(() => Promise.reject('Uncaught NetworkError: failed to load.')),
143     );
144     await expect(createOcr('eng', () => undefined)).rejects.toBeInstanceOf(OcrStartError);
145     expect(FakeWebWorker.made[0]!.terminate).toHaveBeenCalledTimes(1);
146   });
147 
148   it('stops the Web Worker when the start is cut off', async () => {
149     // OcrJob cuts off a start that takes too long, for example a download
150     // of the language data that stalls.
151     vi.stubGlobal('Worker', FakeWebWorker);
152     createWorker.mockImplementation(spawningCreateWorker(() => new Promise(() => undefined)));
153     const controller = new AbortController();
154     const start = createOcr('jpn', () => undefined, controller.signal);
155     const reason = new OcrStartError(new Error('The start took more than 120 s.'));
156     controller.abort(reason);
157     await expect(start).rejects.toBe(reason);
158     expect(FakeWebWorker.made[0]!.terminate).toHaveBeenCalledTimes(1);
159   }, 1000);
160 
161   it('starts no worker when the start is cut off before it begins', async () => {
162     const controller = new AbortController();
163     controller.abort(new Error('OCR stopped.'));
164     const error = await createOcr('eng', () => undefined, controller.signal).catch(
165       (e: unknown) => e,
166     );
167     expect(error).toBeInstanceOf(OcrStartError);
168     expect((error as OcrStartError).cause).toEqual(new Error('OCR stopped.'));
169     expect(createWorker).not.toHaveBeenCalled();
170   });
171 
172   it('does not stop the Web Worker for an error after the start', async () => {
173     // After the start, an error belongs to a page. OcrJob stops the worker then.
174     vi.stubGlobal('Worker', FakeWebWorker);
175     let handler: ((error: unknown) => void) | undefined;
176     createWorker.mockImplementation(
177       spawningCreateWorker((opts) => {
178         handler = opts.errorHandler;
179         return Promise.resolve(fakeWorker());
180       }),
181     );
182     await createOcr('eng', () => undefined);
183     handler?.('The image is empty.');
184     expect(FakeWebWorker.made[0]!.terminate).not.toHaveBeenCalled();
185   });
186 
187   it('gives OcrStartError and stops the worker when the page mode cannot be set', async () => {
188     const worker = fakeWorker();
189     worker.setParameters.mockRejectedValue('The worker stopped.');
190     createWorker.mockResolvedValue(worker);
191     await expect(createOcr('jpn_vert', () => undefined)).rejects.toBeInstanceOf(OcrStartError);
192     expect(worker.terminate).toHaveBeenCalledTimes(1);
193   });
194 
195   it('rejects the page that runs when the OCR stops', async () => {
196     // tesseract.js does not settle a job when its worker stops. The web
197     // finding: a new reading started while the old job still read its page.
198     const worker = fakeWorker();
199     worker.recognize.mockImplementation(() => new Promise(() => undefined));
200     createWorker.mockResolvedValue(worker);
201     const ocr = await createOcr('eng', () => undefined);
202     const page = ocr.recognize(fakeCanvas(), 0);
203     await ocr.terminate();
204     await expect(page).rejects.toThrow('OCR stopped.');
205     expect(worker.terminate).toHaveBeenCalledTimes(1);
206   }, 1000);
207 
208   it('rejects the page that runs when the worker fails', async () => {
209     // tesseract.js does not settle a job when its Web Worker fails. The
210     // finding: after a crash of the worker, the reading waited for ever.
211     const worker = { ...fakeWorker(), worker: new EventTarget() };
212     worker.recognize.mockImplementation(() => new Promise(() => undefined));
213     createWorker.mockResolvedValue(worker);
214     const ocr = await createOcr('eng', () => undefined);
215     const page = ocr.recognize(fakeCanvas(), 0);
216     const event = new Event('error');
217     worker.worker.dispatchEvent(event);
218     await expect(page).rejects.toThrow('The OCR worker failed.');
219     await expect(page).rejects.toHaveProperty('cause', event);
220   }, 1000);
221 
222   it('gives the error of a page as it is, not as OcrStartError', async () => {
223     createWorker.mockResolvedValue(fakeWorker());
224     const ocr = await createOcr('eng', () => undefined);
225     const error = await ocr.recognize(fakeCanvas(), 0).catch((e: unknown) => e);
226     expect(error).toEqual(new Error('The image is empty.'));
227     expect(error).not.toBeInstanceOf(OcrStartError);
228   });
229 
230   it('gives tesseract.js a PPM file of the canvas pixels, not the canvas', async () => {
231     // tesseract.js makes a PNG file of a canvas with canvas.toBlob. The phone
232     // finding: that took about 13 s for each page in the Android WebView.
233     const worker = fakeWorker();
234     worker.recognize.mockResolvedValue({ data: { blocks: [] } });
235     createWorker.mockResolvedValue(worker);
236     const ocr = await createOcr('eng', () => undefined);
237     await expect(ocr.recognize(fakeCanvas(), 0)).resolves.toEqual([]);
238     const image = worker.recognize.mock.calls[0]?.[0];
239     expect(image).toBeInstanceOf(Uint8Array);
240     // The header, then red and blue. The alpha is dropped.
241     const header = [...Buffer.from('P6\n2 1\n255\n', 'latin1')];
242     expect(Array.from(image as Uint8Array)).toEqual([...header, 255, 0, 0, 0, 0, 255]);
243   });
244 });