src/ocr.ts (7388 bytes)
1 // tesseract.js wrapper for the browser. 2 // 3 // The app serves the worker script of tesseract.js and its WebAssembly core 4 // from its own files, so the code of OCR needs no network. The language data 5 // comes from jsDelivr at the first OCR run of each language. tesseract.js 6 // keeps it in IndexedDB after that. 7 8 import { createWorker, PSM, type ImageLike, type Worker as TesseractWorker } from 'tesseract.js'; 9 // Vite copies the two files into the build and gives their URLs. 10 import workerPath from 'tesseract.js/dist/worker.min.js?url'; 11 // One core file that holds the WebAssembly, so the worker makes one request. 12 // The app uses OEM 1 (LSTM only). Android WebView 91 and later and all 13 // current browsers have WebAssembly SIMD. 14 import corePath from 'tesseract.js-core/tesseract-core-simd-lstm.wasm.js?url'; 15 import type { OcrToken } from './align'; 16 import { OcrStartError } from './ocr-job'; 17 import { pageToTokens } from './ocr-tokens'; 18 import { imageToPpm } from './ppm'; 19 20 export { OcrStartError }; 21 22 export interface Ocr { 23 /** Rejects when the OCR stops, or its worker fails, before the page is read. */ 24 recognize(image: HTMLCanvasElement, pageIndex: number): Promise<OcrToken[]>; 25 terminate(): Promise<void>; 26 } 27 28 /** 29 * Start an OCR worker for `lang` ("eng", "jpn", "jpn_vert", "jpn+eng", ...). 30 * `onProgress` gets values in [0, 1] while a page is recognized. Rejects with 31 * OcrStartError when the worker cannot start, and then the worker stops. An 32 * abort of `signal` before the worker started stops the start in the same 33 * way: see createOcrJob. 34 */ 35 export async function createOcr( 36 lang: string, 37 onProgress: (p: number) => void, 38 signal?: AbortSignal, 39 ): Promise<Ocr> { 40 let worker: TesseractWorker; 41 try { 42 worker = await startWorker(lang, onProgress, signal); 43 } catch (err) { 44 throw err instanceof OcrStartError ? err : new OcrStartError(err); 45 } 46 if (lang.includes('vert')) { 47 try { 48 await worker.setParameters({ tessedit_pageseg_mode: PSM.SINGLE_BLOCK_VERT_TEXT }); 49 } catch (err) { 50 await worker.terminate().catch(() => undefined); 51 throw new OcrStartError(err); 52 } 53 } 54 // tesseract.js does not settle a job that runs when its worker stops or 55 // fails. So each page also waits for the stop and for an error event of 56 // the worker, and the reading does not wait for ever for such a page. 57 let stop: (reason: Error) => void = () => undefined; 58 const stopped = new Promise<never>((_resolve, reject) => { 59 stop = reject; 60 }); 61 // A stop while no page runs is no error. 62 stopped.catch(() => undefined); 63 webWorker(worker)?.addEventListener('error', (event) => { 64 stop(new Error('The OCR worker failed.', { cause: event })); 65 }); 66 return { 67 async recognize(canvas, pageIndex) { 68 const page = worker 69 .recognize(ppmOf(canvas), {}, { blocks: true, text: false }) 70 .then(({ data }) => pageToTokens(data, pageIndex)); 71 return Promise.race([page, stopped]); 72 }, 73 async terminate() { 74 stop(new Error('OCR stopped.')); 75 await worker.terminate(); 76 }, 77 }; 78 } 79 80 /** 81 * The Web Worker of a tesseract.js worker, for its error event. tesseract.js 82 * keeps it in the property `worker`, which its types do not list. 83 */ 84 function webWorker(worker: TesseractWorker): EventTarget | undefined { 85 const inner: unknown = 'worker' in worker ? worker.worker : undefined; 86 return inner instanceof EventTarget ? inner : undefined; 87 } 88 89 /** 90 * The pixels of a page canvas as a PPM file. tesseract.js makes a PNG file of 91 * a canvas with canvas.toBlob, and in the Android WebView of the test phone 92 * that took about 13 s for each page. The PPM file has the same pixels, so the 93 * OCR gives the same tokens (test/e2e/ocr-ppm.test.ts). The ImageData is not 94 * kept after the conversion. 95 */ 96 function ppmOf(canvas: HTMLCanvasElement): ImageLike { 97 // pdf.js made the context with willReadFrequently (its default without 98 // enableHWA), so getImageData reads memory and not the GPU. 99 const context = canvas.getContext('2d', { willReadFrequently: true }); 100 if (!context) throw new Error('The page canvas has no 2D context.'); 101 const ppm = imageToPpm(context.getImageData(0, 0, canvas.width, canvas.height)); 102 // tesseract.js uses the bytes of an image file as they are: its loadImage 103 // returns `new Uint8Array(image)` for an input that is not a string, an 104 // element, a canvas or a Blob (src/worker/browser/loadImage.js). Its type 105 // ImageLike does not list Uint8Array. 106 return ppm as unknown as ImageLike; 107 } 108 109 /** 110 * Starts the worker of tesseract.js. When the language data does not load, 111 * tesseract.js gives the error to errorHandler, and the promise of 112 * createWorker does not settle (src/createWorker.js rejects it only when 113 * the core does not load). So the first error rejects the start. 114 * 115 * tesseract.js does not stop the Web Worker of a start that failed, and it 116 * gives the app the worker only after the start. So the start keeps the Web 117 * Worker (see spawnedBy) and stops it when the start fails. Without this, 118 * each Retry, book or setting change left one more worker, each with the 119 * WebAssembly core in its memory. An abort of `signal` during the start 120 * fails the start with the reason of the abort. 121 */ 122 function startWorker( 123 lang: string, 124 onProgress: (p: number) => void, 125 signal?: AbortSignal, 126 ): Promise<TesseractWorker> { 127 return new Promise((resolve, reject) => { 128 if (signal?.aborted) { 129 reject(signal.reason); 130 return; 131 } 132 let settled = false; 133 let spawned: Worker | undefined; 134 const fail = (err: unknown): void => { 135 // After the start, an error belongs to a page: OcrJob stops the worker. 136 if (settled) return; 137 settled = true; 138 spawned?.terminate(); 139 reject(err); 140 }; 141 const made = spawnedBy(() => 142 createWorker(lang, 1, { 143 workerPath, 144 corePath, 145 logger: (m) => { 146 if (m.status === 'recognizing text') onProgress(m.progress); 147 }, 148 // Also after the start: without an errorHandler, tesseract.js throws 149 // each error of a job in the message handler of the worker. 150 errorHandler: fail, 151 }), 152 ); 153 spawned = made.worker; 154 signal?.addEventListener('abort', () => fail(signal.reason), { once: true }); 155 made.result.then((worker) => { 156 settled = true; 157 resolve(worker); 158 }, fail); 159 }); 160 } 161 162 /** 163 * Runs `create`, and gives its result and the Web Worker that it made, if 164 * any. createWorker of tesseract.js makes its Web Worker in the synchronous 165 * part of the call (src/createWorker.js calls spawnWorker before it 166 * returns). So during the call, a Worker class that keeps each new worker 167 * stands in for the Worker class of the browser. The class of the browser 168 * comes back before any other code runs. 169 */ 170 function spawnedBy<T>(create: () => T): { result: T; worker: Worker | undefined } { 171 const Native = globalThis.Worker; 172 // Node has no Worker: tesseract.js uses worker_threads there. 173 if (typeof Native !== 'function') return { result: create(), worker: undefined }; 174 let worker: Worker | undefined; 175 globalThis.Worker = class extends Native { 176 constructor(url: string | URL, options?: WorkerOptions) { 177 super(url, options); 178 worker = this; 179 } 180 }; 181 try { 182 const result = create(); 183 return { result, worker }; 184 } finally { 185 globalThis.Worker = Native; 186 } 187 }