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


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);