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 }