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


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 }