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