fix(labs): serve the in-browser OCR engine from this origin

Local lab OCR reads a report photo on the device with tesseract.js, whose
defaults load the worker, the WebAssembly core and the language data
from cdn.jsdelivr.net. The production CSP allows workers and fetches only
from this origin, so the engine could not load; where it did load (dev),
the browser of anyone scanning a report contacted a third-party CDN.

The worker, the three LSTM core builds and the deu and eng best_int
language data (about 17 MB) are now copied from pinned packages into
public/tesseract/ at prebuild and predev, and createWorker is pointed at
them with the worker started from its URL rather than a blob. A test
holds the options against the staged file list.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 15:37:13 +02:00
1 parent 94ac863bdf
commit 2c88acc5f7
6 files changed
+193 -3

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env node
/**
* Stage the in-browser OCR engine under `public/tesseract/`.
*
* `src/lib/labs/local-ocr.ts` reads a lab-report photo on the device with
* tesseract.js. Left at its defaults, tesseract.js fetches its worker, its
* WebAssembly core and the language data from cdn.jsdelivr.net: the browser
* of every user who scans a report contacts a third-party CDN, and the
* production CSP (`worker-src 'self'`, `connect-src 'self'`) refuses those
* requests, so the feature could not load at all. Serving the same files
* from the app's own origin removes both problems.
*
* What is copied, all from pinned packages in node_modules:
* - tesseract.js `dist/worker.min.js`
* - the three LSTM-only core builds of tesseract.js-core (plain, SIMD,
* relaxed SIMD); the worker picks one by feature detection. LSTM-only
* because `createWorker` defaults to OEM 1.
* - `deu` and `eng` `4.0.0_best_int` language data, gzipped, which is what
* OEM 1 loads.
*
* Runs as a `prebuild`/`predev` step; the output is gitignored.
*/
import { copyFileSync, mkdirSync, rmSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const require = createRequire(import.meta.url);
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
const outDir = join(repoRoot, "public", "tesseract");
const pkgDir = (name) => dirname(require.resolve(`${name}/package.json`));
// tesseract.js-core is a dependency of tesseract.js, not of this project;
// resolve it from tesseract.js's own location so pnpm's strict layout finds it.
const tesseractDir = pkgDir("tesseract.js");
const coreDir = dirname(
createRequire(join(tesseractDir, "package.json")).resolve(
"tesseract.js-core/package.json",
),
);
const files = [
[join(tesseractDir, "dist", "worker.min.js"), "worker.min.js"],
...[
"tesseract-core-lstm.wasm.js",
"tesseract-core-simd-lstm.wasm.js",
"tesseract-core-relaxedsimd-lstm.wasm.js",
].map((file) => [join(coreDir, file), join("core", file)]),
...["deu", "eng"].map((lang) => [
join(
pkgDir(`@tesseract.js-data/${lang}`),
"4.0.0_best_int",
`${lang}.traineddata.gz`,
),
join("lang", `${lang}.traineddata.gz`),
]),
];
rmSync(outDir, { recursive: true, force: true });
for (const [from, to] of files) {
const target = join(outDir, to);
mkdirSync(dirname(target), { recursive: true });
copyFileSync(from, target);
}
console.log(
`stage-tesseract-assets: ${files.length} files -> public/tesseract`,
);