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 7043b7150f7c408df63d271b8de9a15c171b966b
truex <truex@equwal.com>
2026-09-26 14:30:02 -0700

Serve the OCR worker and core from the app

tesseract.js loaded its worker script and its WebAssembly core from
jsDelivr at each start. With no network, OCR failed also when the
language data was in the cache: "Uncaught NetworkError: Failed to
execute 'importScripts' ... failed to load."

createOcr now gives workerPath and corePath from Vite "?url" imports:
tesseract.js/dist/worker.min.js and
tesseract.js-core/tesseract-core-simd-lstm.wasm.js. The core file holds
the WebAssembly, and the app uses OEM 1 (LSTM only), so one core is
sufficient. The language data still comes from jsDelivr at the first run
of each language. The build grows by 4.0 MB.

tesseract.js-core is now a dependency, because the app imports its file.

createOcr rejects with OcrStartError when the worker, the core or the
language data does not load, so the UI can tell this error from other
errors. When the language data does not load, tesseract.js calls
errorHandler and its promise does not settle. So the first error of
errorHandler also rejects the start. Before, the start waited forever.

 package-lock.json |   3 +-
 package.json      |   3 +-
 src/ocr.ts        |  69 +++++++++++++++++++++++++++++++------
 test/ocr.test.ts  | 100 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 162 insertions(+), 13 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 9276eb6..71577dd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,7 +13,8 @@
         "fast-diff": "^1.3.0",
         "idb": "^8.0.3",
         "pdfjs-dist": "^6.3.289",
-        "tesseract.js": "^7.0.0"
+        "tesseract.js": "^7.0.0",
+        "tesseract.js-core": "^7.0.0"
       },
       "devDependencies": {
         "@capacitor/cli": "^8.5.2",
diff --git a/package.json b/package.json
index 8b911f8..4525023 100644
--- a/package.json
+++ b/package.json
@@ -22,7 +22,8 @@
     "fast-diff": "^1.3.0",
     "idb": "^8.0.3",
     "pdfjs-dist": "^6.3.289",
-    "tesseract.js": "^7.0.0"
+    "tesseract.js": "^7.0.0",
+    "tesseract.js-core": "^7.0.0"
   },
   "devDependencies": {
     "@capacitor/cli": "^8.5.2",
diff --git a/src/ocr.ts b/src/ocr.ts
index 09c3f85..dd427ff 100644
--- a/src/ocr.ts
+++ b/src/ocr.ts
@@ -1,10 +1,17 @@
 // tesseract.js wrapper for the browser.
 //
-// tesseract.js loads its worker script, its WebAssembly core and the
-// language data from jsDelivr by default, so the first OCR run needs
-// network access. Language data is cached in IndexedDB after that.
+// The app serves the worker script of tesseract.js and its WebAssembly core
+// from its own files, so the code of OCR needs no network. The language data
+// comes from jsDelivr at the first OCR run of each language. tesseract.js
+// keeps it in IndexedDB after that.
 
-import { createWorker, PSM } from 'tesseract.js';
+import { createWorker, PSM, type Worker as TesseractWorker } from 'tesseract.js';
+// Vite copies the two files into the build and gives their URLs.
+import workerPath from 'tesseract.js/dist/worker.min.js?url';
+// One core file that holds the WebAssembly, so the worker makes one request.
+// The app uses OEM 1 (LSTM only). Android WebView 91 and later and all
+// current browsers have WebAssembly SIMD.
+import corePath from 'tesseract.js-core/tesseract-core-simd-lstm.wasm.js?url';
 import type { OcrToken } from './align';
 import { pageToTokens } from './ocr-tokens';
 
@@ -13,18 +20,37 @@ export interface Ocr {
   terminate(): Promise<void>;
 }
 
+/**
+ * OCR could not start: the worker script, the core or the language data did
+ * not load. `cause` holds the error of tesseract.js. The first OCR run of a
+ * language needs the network to download the language data.
+ */
+export class OcrStartError extends Error {
+  constructor(cause: unknown) {
+    super(`OCR could not start: ${String(cause)}`, { cause });
+    this.name = 'OcrStartError';
+  }
+}
+
 /**
  * Start an OCR worker for `lang` ("eng", "jpn", "jpn_vert", "jpn+eng", ...).
- * `onProgress` gets values in [0, 1] while a page is recognized.
+ * `onProgress` gets values in [0, 1] while a page is recognized. Rejects with
+ * OcrStartError when the worker cannot start.
  */
 export async function createOcr(lang: string, onProgress: (p: number) => void): Promise<Ocr> {
-  const worker = await createWorker(lang, 1, {
-    logger: (m) => {
-      if (m.status === 'recognizing text') onProgress(m.progress);
-    },
-  });
+  let worker: TesseractWorker;
+  try {
+    worker = await startWorker(lang, onProgress);
+  } catch (err) {
+    throw new OcrStartError(err);
+  }
   if (lang.includes('vert')) {
-    await worker.setParameters({ tessedit_pageseg_mode: PSM.SINGLE_BLOCK_VERT_TEXT });
+    try {
+      await worker.setParameters({ tessedit_pageseg_mode: PSM.SINGLE_BLOCK_VERT_TEXT });
+    } catch (err) {
+      await worker.terminate().catch(() => undefined);
+      throw new OcrStartError(err);
+    }
   }
   return {
     async recognize(image, pageIndex) {
@@ -36,3 +62,24 @@ export async function createOcr(lang: string, onProgress: (p: number) => void):
     },
   };
 }
+
+/**
+ * Starts the worker of tesseract.js. When the language data does not load,
+ * tesseract.js gives the error to errorHandler, and the promise of
+ * createWorker does not settle. So the first error rejects the start. That
+ * worker cannot be stopped: tesseract.js gives no handle to it.
+ */
+function startWorker(lang: string, onProgress: (p: number) => void): Promise<TesseractWorker> {
+  return new Promise((resolve, reject) => {
+    createWorker(lang, 1, {
+      workerPath,
+      corePath,
+      logger: (m) => {
+        if (m.status === 'recognizing text') onProgress(m.progress);
+      },
+      // Also after the start: without an errorHandler, tesseract.js throws
+      // each error of a job in the message handler of the worker.
+      errorHandler: reject,
+    }).then(resolve, reject);
+  });
+}
diff --git a/test/ocr.test.ts b/test/ocr.test.ts
new file mode 100644
index 0000000..1e3db9d
--- /dev/null
+++ b/test/ocr.test.ts
@@ -0,0 +1,100 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { createOcr, OcrStartError } from '../src/ocr';
+
+const { createWorker } = vi.hoisted(() => ({ createWorker: vi.fn() }));
+vi.mock('tesseract.js', () => ({ createWorker, PSM: { SINGLE_BLOCK_VERT_TEXT: '5' } }));
+
+/** The options that createOcr gave to createWorker. */
+interface Options {
+  workerPath?: string;
+  corePath?: string;
+  langPath?: string;
+  logger?: (m: { status: string; progress: number }) => void;
+  errorHandler?: (error: unknown) => void;
+}
+
+function options(): Options {
+  return createWorker.mock.calls[0]?.[2] as Options;
+}
+
+function fakeWorker() {
+  return {
+    setParameters: vi.fn(() => Promise.resolve({})),
+    recognize: vi.fn(() => Promise.reject(new Error('The image is empty.'))),
+    terminate: vi.fn(() => Promise.resolve({})),
+  };
+}
+
+/** A path on the origin of the app, for example "/assets/worker.min-abc.js". */
+const LOCAL = /^\/(?!\/)/;
+
+describe('createOcr', () => {
+  afterEach(() => {
+    createWorker.mockReset();
+  });
+
+  it('loads the worker script and the core from the app, not from jsDelivr', async () => {
+    createWorker.mockResolvedValue(fakeWorker());
+    const progress: number[] = [];
+    await createOcr('jpn', (p) => progress.push(p));
+
+    expect(createWorker).toHaveBeenCalledTimes(1);
+    expect(createWorker.mock.calls[0]?.slice(0, 2)).toEqual(['jpn', 1]);
+    const { workerPath, corePath, langPath, logger } = options();
+    expect(workerPath).toMatch(LOCAL);
+    expect(workerPath).toMatch(/\/worker\.min\.js$/);
+    expect(corePath).toMatch(LOCAL);
+    expect(corePath).toMatch(/\/tesseract-core-simd-lstm\.wasm\.js$/);
+    expect(`${workerPath} ${corePath}`).not.toContain('jsdelivr');
+    // The language data still comes from the default place of tesseract.js.
+    expect(langPath).toBeUndefined();
+
+    logger?.({ status: 'loading language traineddata', progress: 0.5 });
+    logger?.({ status: 'recognizing text', progress: 0.25 });
+    expect(progress).toEqual([0.25]);
+  });
+
+  it('sets the page mode of a vertical language', async () => {
+    const worker = fakeWorker();
+    createWorker.mockResolvedValue(worker);
+    await createOcr('jpn_vert', () => undefined);
+    expect(worker.setParameters).toHaveBeenCalledWith({ tessedit_pageseg_mode: '5' });
+  });
+
+  it('gives OcrStartError when the worker script does not load', async () => {
+    const cause =
+      "Uncaught NetworkError: Failed to execute 'importScripts' on 'WorkerGlobalScope': failed to load.";
+    createWorker.mockRejectedValue(cause);
+    const error = await createOcr('eng', () => undefined).catch((e: unknown) => e);
+    expect(error).toBeInstanceOf(OcrStartError);
+    expect((error as OcrStartError).cause).toBe(cause);
+    expect((error as OcrStartError).message).toContain(cause);
+  });
+
+  it('gives OcrStartError when the language data does not load', async () => {
+    // tesseract.js gives the error to errorHandler, and its promise does not settle.
+    createWorker.mockImplementation((_lang: string, _oem: number, opts: Options) => {
+      opts.errorHandler?.('TypeError: Failed to fetch');
+      return new Promise(() => undefined);
+    });
+    const error = await createOcr('jpn', () => undefined).catch((e: unknown) => e);
+    expect(error).toBeInstanceOf(OcrStartError);
+    expect((error as OcrStartError).cause).toBe('TypeError: Failed to fetch');
+  });
+
+  it('gives OcrStartError and stops the worker when the page mode cannot be set', async () => {
+    const worker = fakeWorker();
+    worker.setParameters.mockRejectedValue('The worker stopped.');
+    createWorker.mockResolvedValue(worker);
+    await expect(createOcr('jpn_vert', () => undefined)).rejects.toBeInstanceOf(OcrStartError);
+    expect(worker.terminate).toHaveBeenCalledTimes(1);
+  });
+
+  it('gives the error of a page as it is, not as OcrStartError', async () => {
+    createWorker.mockResolvedValue(fakeWorker());
+    const ocr = await createOcr('eng', () => undefined);
+    const error = await ocr.recognize({} as HTMLCanvasElement, 0).catch((e: unknown) => e);
+    expect(error).toEqual(new Error('The image is empty.'));
+    expect(error).not.toBeInstanceOf(OcrStartError);
+  });
+});