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-job.ts (6466 bytes)

1 // The OCR of one reading of the book: the start error, the time limit of a
2 // page and the stop. No tesseract.js here, so main.ts can use this module and
3 // still load tesseract.js only when a page needs OCR.
4 
5 import type { OcrToken } from './align';
6 
7 /**
8  * OCR could not start: the OCR code, the worker script, the core or the
9  * language data did not load. `cause` holds the error. The first OCR run of
10  * a language needs the network to download the language data.
11  */
12 export class OcrStartError extends Error {
13   constructor(cause: unknown) {
14     super(`OCR could not start: ${String(cause)}`, { cause });
15     this.name = 'OcrStartError';
16   }
17 }
18 
19 /** The OCR of a page took longer than its time limit. */
20 export class OcrTimeoutError extends Error {
21   constructor(pageIndex: number, limitMs: number) {
22     super(`The OCR of page ${pageIndex + 1} took more than ${Math.round(limitMs / 1000)} s.`);
23     this.name = 'OcrTimeoutError';
24   }
25 }
26 
27 /** The time limit of the OCR of a page of PAGE_LIMIT_PIXELS pixels or less. */
28 const PAGE_LIMIT_MS = 120_000;
29 
30 /** A page with more pixels than this gets more time, in proportion to its area. */
31 const PAGE_LIMIT_PIXELS = 4_000_000;
32 
33 /**
34  * The time limit of the OCR of a page of `pixels` pixels. The test phone read
35  * a page of 1600 x 2263 pixels in about 2 s, and 120 s is about 60 times that,
36  * so a slow phone or a dense page is not stopped. The time of the OCR grows
37  * with the area of the page, so a larger page gets more time.
38  */
39 export function pageTimeLimit(pixels: number): number {
40   return PAGE_LIMIT_MS * Math.max(1, pixels / PAGE_LIMIT_PIXELS);
41 }
42 
43 /**
44  * The time limit of the start of OCR: the worker and its core load from the
45  * app, and at the first run of a language its data loads from the network
46  * (a few MB). A download that stalls, for example behind the login page of a
47  * public Wi-Fi, does not end by itself. The limit is the same as for a page.
48  */
49 export const START_LIMIT_MS = PAGE_LIMIT_MS;
50 
51 /** An OCR worker. In the app, it is tesseract.js (src/ocr.ts). */
52 export interface OcrWorker<Image> {
53   /** Rejects when the worker stops or fails before the page is read. */
54   recognize(image: Image, pageIndex: number): Promise<OcrToken[]>;
55   terminate(): Promise<void>;
56 }
57 
58 /** The OCR of one reading of the book. */
59 export interface OcrJob<Image> {
60   /**
61    * Starts a worker when no worker runs. Rejects with the start error, also
62    * with OcrStartError when the start takes longer than its time limit.
63    */
64   start(): Promise<void>;
65   /**
66    * Reads one page. When the page fails or takes longer than `limitMs`, the
67    * job stops the worker and rejects. The next page starts a new worker.
68    */
69   recognize(image: Image, pageIndex: number, limitMs: number): Promise<OcrToken[]>;
70   /** Stops the worker, and does not wait. Each page after the stop rejects. */
71   stop(): void;
72 }
73 
74 /**
75  * The OCR of one reading. The job calls `startWorker` for the first page that
76  * needs OCR, and again for the page after a failed page. tesseract.js does not
77  * settle the page of a worker that crashed, so the time limit ends such a page,
78  * and the reading goes on. A worker that did not start is not started again:
79  * each page gets the same start error.
80  *
81  * A start that takes longer than `startLimitMs` rejects with OcrStartError.
82  * The signal that `startWorker` gets then aborts, with that error as its
83  * reason, so that the start can stop its worker. A stop of the job during a
84  * start aborts the signal too. A worker that starts after the limit stops.
85  */
86 export function createOcrJob<Image>(
87   startWorker: (signal: AbortSignal) => Promise<OcrWorker<Image>>,
88   startLimitMs = START_LIMIT_MS,
89 ): OcrJob<Image> {
90   let running: Promise<OcrWorker<Image>> | undefined;
91   /** Cuts off the start that runs now, if any. */
92   let cutStart: ((reason: Error) => void) | undefined;
93   let stopped = false;
94 
95   function startInTime(): Promise<OcrWorker<Image>> {
96     const control = new AbortController();
97     const started = startWorker(control.signal);
98     // The start rejects with the reason itself: AbortSignal.reason needs
99     // WebView 98, and the app runs on WebView 91 and later.
100     let cutOff: (reason: Error) => void = () => undefined;
101     const cut = new Promise<never>((_resolve, reject) => {
102       cutOff = (reason) => {
103         reject(reason);
104         control.abort(reason);
105       };
106     });
107     cutStart = cutOff;
108     const s = Math.round(startLimitMs / 1000);
109     const timer = setTimeout(
110       () => cutOff(new OcrStartError(new Error(`The start took more than ${s} s.`))),
111       startLimitMs,
112     );
113     const end = (): void => {
114       clearTimeout(timer);
115       if (cutStart === cutOff) cutStart = undefined;
116     };
117     return Promise.race([started, cut]).then(
118       (ocr) => {
119         end();
120         return ocr;
121       },
122       (err: unknown) => {
123         end();
124         // A worker that starts after the cut stops at once.
125         stopOcr(started);
126         throw err;
127       },
128     );
129   }
130 
131   const worker = (): Promise<OcrWorker<Image>> => {
132     if (stopped) return Promise.reject(new Error('OCR stopped.'));
133     running ??= startInTime();
134     return running;
135   };
136   return {
137     async start() {
138       await worker();
139     },
140     async recognize(image, pageIndex, limitMs) {
141       const started = worker();
142       const ocr = await started;
143       let timer: ReturnType<typeof setTimeout> | undefined;
144       const late = new Promise<never>((_resolve, reject) => {
145         timer = setTimeout(() => reject(new OcrTimeoutError(pageIndex, limitMs)), limitMs);
146       });
147       try {
148         return await Promise.race([ocr.recognize(image, pageIndex), late]);
149       } catch (err) {
150         // The worker can be broken, or it still reads the page. The next page
151         // starts a new worker. After a stop, the worker is stopped already.
152         if (running === started) {
153           running = undefined;
154           stopOcr(started);
155         }
156         throw err;
157       } finally {
158         clearTimeout(timer);
159       }
160     },
161     stop() {
162       stopped = true;
163       cutStart?.(new Error('OCR stopped.'));
164       stopOcr(running);
165       running = undefined;
166     },
167   };
168 }
169 
170 /**
171  * Stops the OCR of a reading, and does not wait. A reading whose OCR did
172  * not start has nothing to stop, and its start error is no new error here.
173  */
174 export function stopOcr(started: Promise<{ terminate(): Promise<void> }> | undefined): void {
175   started?.then((ocr) => ocr.terminate()).catch(() => undefined);
176 }