commit dc873df5a9a9f7171ef74b2c8984bb56366bd5f3 truex <truex@equwal.com> 2026-09-26 19:58:47 -0700 Stop the OCR of a page after a time limit or a worker error tesseract.js does not settle a job when its worker crashes. So a crash of the worker in a page made the reading wait for ever. createOcrJob (ocr-job.ts) now gives each page a time limit: 120 s, and more for a page of more than 4 million pixels. The test phone read a page of 1600 x 2263 pixels in about 2 s. When a page fails or takes longer, the job stops the worker and rejects the page. The page counts as not read, and Retry reads it again. The next page starts a new worker, so the reading goes on. createOcr also rejects the page that runs when its Web Worker sends an error event.
README.md | 9 +++- src/main.ts | 36 ++++++------- src/ocr-job.ts | 100 +++++++++++++++++++++++++++++++++-- src/ocr.ts | 20 +++++-- test/ocr-job.test.ts | 143 ++++++++++++++++++++++++++++++++++++++++++++++++++- test/ocr.test.ts | 14 +++++ 6 files changed, 293 insertions(+), 29 deletions(-)
diff --git a/README.md b/README.md index 99eae94..138796e 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,11 @@ tesseract.js first makes a PNG file, and on a test phone (Android WebView) that took about 13 s for each page. The PPM image takes milliseconds and has the same pixels, so OCR gives the same tokens. +The OCR of a page can take 2 minutes, and more for a page of more than 4 +million pixels. When it takes longer, or when the OCR worker fails, the +app stops the worker and counts the page as not read. The next page +starts a new worker, so one bad page does not stop the reading. + Both give the same tokens: one box per character with a line id and a word id. The alignment, the mark, the page turn and the lookup work the same way on a scan and on a text PDF. @@ -465,8 +470,8 @@ in a browser bundle. - `src/token-cache.ts`: IndexedDB store for the page tokens, the copy of the last book and the meta data of each book (thin). - `src/ocr.ts`, `src/pdf.ts`: thin browser wrappers around the libraries. - `src/ocr-job.ts`: the start error and the stop of OCR, without - tesseract.js. + `src/ocr-job.ts`: the start error, the time limit of a page, the new + worker after a failed page and the stop of OCR, without tesseract.js. - `src/view.ts`: the page view: render to fit the width, draw the mark, scroll to it, render again on a resize. - `src/nav.ts`: page turns by the user: the arrows, the keys, the swipe diff --git a/src/main.ts b/src/main.ts index f5090d3..087bb77 100644 --- a/src/main.ts +++ b/src/main.ts @@ -47,8 +47,7 @@ import { type Message, } from './messages'; import { setupNav } from './nav'; -import type { Ocr } from './ocr'; -import { OcrStartError, stopOcr } from './ocr-job'; +import { createOcrJob, OcrStartError, pageTimeLimit, type OcrJob } from './ocr-job'; import { defaultRtl } from './paging'; import type { PdfDoc } from './pdf'; import { nextPage } from './read-order'; @@ -525,7 +524,8 @@ function watchDb(): void { /** * The tokens of one page: from the cache, else the text layer, else OCR. * `force` skips the text layer. Rejects with OcrStartError when the page - * needs OCR and OCR cannot start. + * needs OCR and OCR cannot start, and with OcrTimeoutError when the OCR of + * the page takes too long. */ async function readPage( pdf: PdfDoc, @@ -533,7 +533,7 @@ async function readPage( page: number, lang: string, force: boolean, - ocr: () => Promise<Ocr>, + ocr: OcrJob<HTMLCanvasElement>, ): Promise<PageEntry> { if (!force) { const cached = await getPage(pageKey(file, page, lang, 'text')); @@ -550,16 +550,16 @@ async function readPage( } } // OCR starts before the render: when it cannot start, no page is rendered for nothing. - const engine = await ocr(); + await ocr.start(); const canvas = await pdf.renderPage(page, OCR_WIDTH); - const tokens = await engine.recognize(canvas, page); + const tokens = await ocr.recognize(canvas, page, pageTimeLimit(canvas.width * canvas.height)); const entry: PageEntry = { tokens, width: canvas.width, height: canvas.height, source: 'ocr' }; await putPage(pageKey(file, page, lang, 'ocr'), entry); return entry; } /** The OCR of the reading that runs now. */ -let readingOcr: { started?: Promise<Ocr> } = {}; +let readingOcr: OcrJob<HTMLCanvasElement> | undefined; /** * Reads every page of the book: the current page first, then the pages @@ -576,9 +576,7 @@ async function readBook(retry = false): Promise<void> { const seq = ++state.readSeq; // The OCR of the reading before stops at once. It does not finish its // page: two quick changes of a setting ran two OCR jobs at the same time. - stopOcr(readingOcr.started); - const ocr: { started?: Promise<Ocr> } = {}; - readingOcr = ocr; + readingOcr?.stop(); if (!retry) { state.pages = []; state.tokens = []; @@ -605,16 +603,18 @@ async function readBook(retry = false): Promise<void> { setReading(readingText(read(), total, `page ${page + 1}: OCR ${Math.round(p * 100)}%`)); } }; - // The OCR worker, and tesseract.js itself, load on the first page that needs them. - const getOcr = (): Promise<Ocr> => { - ocr.started ??= import('./ocr').then( + // The OCR worker, and tesseract.js itself, load on the first page that + // needs them. After a page that fails or takes too long, the job stops + // the worker, and the next page starts a new one. + const ocr = createOcrJob<HTMLCanvasElement>(() => + import('./ocr').then( ({ createOcr }) => createOcr(lang, onOcrProgress), (err: unknown) => { throw new OcrStartError(err); }, - ); - return ocr.started; - }; + ), + ); + readingOcr = ocr; try { while (pending.size > 0) { page = nextPage(pending, state.currentPage); @@ -622,7 +622,7 @@ async function readBook(retry = false): Promise<void> { setReading(readingText(read(), total, `page ${page + 1}`)); let entry: PageEntry; try { - entry = await readPage(pdf, file, page, lang, force, getOcr); + entry = await readPage(pdf, file, page, lang, force, ocr); } catch (err) { if (seq !== state.readSeq) return; // Another book or another setting took over. failed++; @@ -655,7 +655,7 @@ async function readBook(retry = false): Promise<void> { } catch (err) { if (seq === state.readSeq) say(`Cannot read the pages: ${String(err)}`); } finally { - stopOcr(ocr.started); + ocr.stop(); if (seq === state.readSeq) setReading(readingEndMessage(failed, startFailed)); } } diff --git a/src/ocr-job.ts b/src/ocr-job.ts index d20f745..11f42c8 100644 --- a/src/ocr-job.ts +++ b/src/ocr-job.ts @@ -1,6 +1,8 @@ -// The OCR of one reading of the book: the start error and the stop. No -// tesseract.js here, so main.ts can use this module and still load -// tesseract.js only when a page needs OCR. +// The OCR of one reading of the book: the start error, the time limit of a +// page and the stop. No tesseract.js here, so main.ts can use this module and +// still load tesseract.js only when a page needs OCR. + +import type { OcrToken } from './align'; /** * OCR could not start: the OCR code, the worker script, the core or the @@ -14,6 +16,98 @@ export class OcrStartError extends Error { } } +/** The OCR of a page took longer than its time limit. */ +export class OcrTimeoutError extends Error { + constructor(pageIndex: number, limitMs: number) { + super(`The OCR of page ${pageIndex + 1} took more than ${Math.round(limitMs / 1000)} s.`); + this.name = 'OcrTimeoutError'; + } +} + +/** The time limit of the OCR of a page of PAGE_LIMIT_PIXELS pixels or less. */ +const PAGE_LIMIT_MS = 120_000; + +/** A page with more pixels than this gets more time, in proportion to its area. */ +const PAGE_LIMIT_PIXELS = 4_000_000; + +/** + * The time limit of the OCR of a page of `pixels` pixels. The test phone read + * a page of 1600 x 2263 pixels in about 2 s, and 120 s is about 60 times that, + * so a slow phone or a dense page is not stopped. The time of the OCR grows + * with the area of the page, so a larger page gets more time. + */ +export function pageTimeLimit(pixels: number): number { + return PAGE_LIMIT_MS * Math.max(1, pixels / PAGE_LIMIT_PIXELS); +} + +/** An OCR worker. In the app, it is tesseract.js (src/ocr.ts). */ +export interface OcrWorker<Image> { + /** Rejects when the worker stops or fails before the page is read. */ + recognize(image: Image, pageIndex: number): Promise<OcrToken[]>; + terminate(): Promise<void>; +} + +/** The OCR of one reading of the book. */ +export interface OcrJob<Image> { + /** Starts a worker when no worker runs. Rejects with the start error. */ + start(): Promise<void>; + /** + * Reads one page. When the page fails or takes longer than `limitMs`, the + * job stops the worker and rejects. The next page starts a new worker. + */ + recognize(image: Image, pageIndex: number, limitMs: number): Promise<OcrToken[]>; + /** Stops the worker, and does not wait. Each page after the stop rejects. */ + stop(): void; +} + +/** + * The OCR of one reading. The job calls `startWorker` for the first page that + * needs OCR, and again for the page after a failed page. tesseract.js does not + * settle the page of a worker that crashed, so the time limit ends such a page, + * and the reading goes on. A worker that did not start is not started again: + * each page gets the same start error. + */ +export function createOcrJob<Image>(startWorker: () => Promise<OcrWorker<Image>>): OcrJob<Image> { + let running: Promise<OcrWorker<Image>> | undefined; + let stopped = false; + const worker = (): Promise<OcrWorker<Image>> => { + if (stopped) return Promise.reject(new Error('OCR stopped.')); + running ??= startWorker(); + return running; + }; + return { + async start() { + await worker(); + }, + async recognize(image, pageIndex, limitMs) { + const started = worker(); + const ocr = await started; + let timer: ReturnType<typeof setTimeout> | undefined; + const late = new Promise<never>((_resolve, reject) => { + timer = setTimeout(() => reject(new OcrTimeoutError(pageIndex, limitMs)), limitMs); + }); + try { + return await Promise.race([ocr.recognize(image, pageIndex), late]); + } catch (err) { + // The worker can be broken, or it still reads the page. The next page + // starts a new worker. After a stop, the worker is stopped already. + if (running === started) { + running = undefined; + stopOcr(started); + } + throw err; + } finally { + clearTimeout(timer); + } + }, + stop() { + stopped = true; + stopOcr(running); + running = undefined; + }, + }; +} + /** * Stops the OCR of a reading, and does not wait. A reading whose OCR did * not start has nothing to stop, and its start error is no new error here. diff --git a/src/ocr.ts b/src/ocr.ts index a5110ba..8ca327f 100644 --- a/src/ocr.ts +++ b/src/ocr.ts @@ -20,7 +20,7 @@ import { imageToPpm } from './ppm'; export { OcrStartError }; export interface Ocr { - /** Rejects when the OCR stops before the page is read. */ + /** Rejects when the OCR stops, or its worker fails, before the page is read. */ recognize(image: HTMLCanvasElement, pageIndex: number): Promise<OcrToken[]>; terminate(): Promise<void>; } @@ -45,15 +45,18 @@ export async function createOcr(lang: string, onProgress: (p: number) => void): throw new OcrStartError(err); } } - // tesseract.js does not settle a job that runs when its worker stops. So - // each page also waits for the stop, and the reading does not wait for - // ever for a page of a stopped worker. + // tesseract.js does not settle a job that runs when its worker stops or + // fails. So each page also waits for the stop and for an error event of + // the worker, and the reading does not wait for ever for such a page. let stop: (reason: Error) => void = () => undefined; const stopped = new Promise<never>((_resolve, reject) => { stop = reject; }); // A stop while no page runs is no error. stopped.catch(() => undefined); + webWorker(worker)?.addEventListener('error', (event) => { + stop(new Error('The OCR worker failed.', { cause: event })); + }); return { async recognize(canvas, pageIndex) { const page = worker @@ -68,6 +71,15 @@ export async function createOcr(lang: string, onProgress: (p: number) => void): }; } +/** + * The Web Worker of a tesseract.js worker, for its error event. tesseract.js + * keeps it in the property `worker`, which its types do not list. + */ +function webWorker(worker: TesseractWorker): EventTarget | undefined { + const inner: unknown = 'worker' in worker ? worker.worker : undefined; + return inner instanceof EventTarget ? inner : undefined; +} + /** * The pixels of a page canvas as a PPM file. tesseract.js makes a PNG file of * a canvas with canvas.toBlob, and in the Android WebView of the test phone diff --git a/test/ocr-job.test.ts b/test/ocr-job.test.ts index 17e9584..1fea666 100644 --- a/test/ocr-job.test.ts +++ b/test/ocr-job.test.ts @@ -1,5 +1,13 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { OcrStartError, stopOcr } from '../src/ocr-job'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { OcrToken } from '../src/align'; +import { + createOcrJob, + OcrStartError, + OcrTimeoutError, + pageTimeLimit, + stopOcr, + type OcrWorker, +} from '../src/ocr-job'; /** The reasons of the unhandled rejections while `run` runs, and 20 ms after it. */ async function unhandledDuring(run: () => void): Promise<unknown[]> { @@ -43,3 +51,134 @@ describe('stopOcr', () => { expect(await unhandledDuring(() => void stopOcr(Promise.resolve({ terminate })))).toEqual([]); }); }); + +describe('pageTimeLimit', () => { + it('gives a page of the app 120 s', () => { + expect(pageTimeLimit(1600 * 2263)).toBe(120_000); + expect(pageTimeLimit(1)).toBe(120_000); + }); + + it('gives a larger page more time, in proportion to its area', () => { + expect(pageTimeLimit(8_000_000)).toBe(240_000); + expect(pageTimeLimit(1600 * 10_000)).toBe(480_000); + }); +}); + +/** A token of page `page`, so that a result shows its page. */ +function token(page: number): OcrToken { + return { text: 'a', page, line: 0, word: 0, bbox: { x0: 0, y0: 0, x1: 1, y1: 1 } }; +} + +/** A fake OCR worker. `read` gives the result of each page. */ +function fakeWorker(read: (page: number) => Promise<OcrToken[]>) { + return { + recognize: vi.fn((_image: string, page: number) => read(page)), + terminate: vi.fn(() => Promise.resolve()), + } satisfies OcrWorker<string>; +} + +/** A page that never ends: tesseract.js after a crash of its worker. */ +const never = (): Promise<OcrToken[]> => new Promise(() => undefined); + +const readPage = (page: number): Promise<OcrToken[]> => Promise.resolve([token(page)]); + +/** A fake worker whose page rejects when the worker stops, as in src/ocr.ts. */ +function stoppableWorker() { + let stop: (reason: Error) => void = () => undefined; + const stopped = new Promise<never>((_resolve, reject) => { + stop = reject; + }); + stopped.catch(() => undefined); + const worker = fakeWorker(() => stopped); + worker.terminate.mockImplementation(() => { + stop(new Error('OCR stopped.')); + return Promise.resolve(); + }); + return worker; +} + +describe('createOcrJob', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('stops a worker whose page runs too long, and reads the next page with a new worker', async () => { + // The finding: a crashed tesseract worker does not settle its page, and + // the reading waited for ever. + const hung = fakeWorker(never); + const next = fakeWorker(readPage); + const start = vi + .fn<() => Promise<OcrWorker<string>>>() + .mockResolvedValueOnce(hung) + .mockResolvedValueOnce(next); + const job = createOcrJob(start); + let settled = false; + const page = job + .recognize('page 1', 0, 1000) + .catch((e: unknown) => e) + .finally(() => { + settled = true; + }); + await vi.advanceTimersByTimeAsync(999); + expect(settled).toBe(false); + expect(hung.terminate).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + const error = await page; + expect(error).toBeInstanceOf(OcrTimeoutError); + expect((error as Error).message).toBe('The OCR of page 1 took more than 1 s.'); + expect(hung.terminate).toHaveBeenCalledTimes(1); + await expect(job.recognize('page 2', 1, 1000)).resolves.toEqual([token(1)]); + expect(start).toHaveBeenCalledTimes(2); + expect(vi.getTimerCount()).toBe(0); + }); + + it('stops a worker whose page fails, and reads the next page with a new worker', async () => { + // An error event of the worker, or an abort of its WebAssembly, rejects the page. + const failed = fakeWorker(() => Promise.reject(new Error('The OCR worker failed.'))); + const next = fakeWorker(readPage); + const start = vi + .fn<() => Promise<OcrWorker<string>>>() + .mockResolvedValueOnce(failed) + .mockResolvedValueOnce(next); + const job = createOcrJob(start); + await expect(job.recognize('page 1', 0, 1000)).rejects.toThrow('The OCR worker failed.'); + expect(failed.terminate).toHaveBeenCalledTimes(1); + await expect(job.recognize('page 2', 1, 1000)).resolves.toEqual([token(1)]); + expect(start).toHaveBeenCalledTimes(2); + expect(vi.getTimerCount()).toBe(0); + }); + + it('keeps the worker for the next page when a page is read in time', async () => { + const worker = fakeWorker(readPage); + const start = vi.fn(() => Promise.resolve(worker)); + const job = createOcrJob(start); + await job.start(); + await expect(job.recognize('page 1', 0, 1000)).resolves.toEqual([token(0)]); + await expect(job.recognize('page 2', 1, 1000)).resolves.toEqual([token(1)]); + expect(start).toHaveBeenCalledTimes(1); + expect(worker.terminate).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('gives each page the start error, and does not start again', async () => { + const error = new OcrStartError('TypeError: Failed to fetch'); + const start = vi.fn(() => Promise.reject(error)); + const job = createOcrJob(start); + await expect(job.start()).rejects.toBe(error); + await expect(job.recognize('page 1', 0, 1000)).rejects.toBe(error); + expect(start).toHaveBeenCalledTimes(1); + }); + + it('rejects the page that runs on stop, stops the worker once, and starts no new worker', async () => { + const worker = stoppableWorker(); + const start = vi.fn(() => Promise.resolve(worker)); + const job = createOcrJob(start); + const page = job.recognize('page 1', 0, 1000).catch((e: unknown) => e); + await vi.advanceTimersByTimeAsync(0); + job.stop(); + expect(await page).toEqual(new Error('OCR stopped.')); + expect(worker.terminate).toHaveBeenCalledTimes(1); + await expect(job.recognize('page 2', 1, 1000)).rejects.toThrow('OCR stopped.'); + expect(start).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/test/ocr.test.ts b/test/ocr.test.ts index 4d25586..dd1c4d2 100644 --- a/test/ocr.test.ts +++ b/test/ocr.test.ts @@ -113,6 +113,20 @@ describe('createOcr', () => { expect(worker.terminate).toHaveBeenCalledTimes(1); }, 1000); + it('rejects the page that runs when the worker fails', async () => { + // tesseract.js does not settle a job when its Web Worker fails. The + // finding: after a crash of the worker, the reading waited for ever. + const worker = { ...fakeWorker(), worker: new EventTarget() }; + worker.recognize.mockImplementation(() => new Promise(() => undefined)); + createWorker.mockResolvedValue(worker); + const ocr = await createOcr('eng', () => undefined); + const page = ocr.recognize(fakeCanvas(), 0); + const event = new Event('error'); + worker.worker.dispatchEvent(event); + await expect(page).rejects.toThrow('The OCR worker failed.'); + await expect(page).rejects.toHaveProperty('cause', event); + }, 1000); + it('gives the error of a page as it is, not as OcrStartError', async () => { createWorker.mockResolvedValue(fakeWorker()); const ocr = await createOcr('eng', () => undefined);