From 2c88acc5f7974a4137815a457daa99ccb8698564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 15:37:13 +0200 Subject: [PATCH] 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. --- .gitignore | 3 + package.json | 6 +- pnpm-lock.yaml | 16 +++++ scripts/stage-tesseract-assets.mjs | 68 +++++++++++++++++++++ src/lib/labs/__tests__/local-ocr.test.ts | 77 ++++++++++++++++++++++++ src/lib/labs/local-ocr.ts | 26 +++++++- 6 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 scripts/stage-tesseract-assets.mjs create mode 100644 src/lib/labs/__tests__/local-ocr.test.ts diff --git a/.gitignore b/.gitignore index e121cf65a..f446e2043 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,9 @@ next-env.d.ts # the cache-version baseline. /public/sw-version.js /public/i18n/ +# In-browser OCR engine and language data, copied from node_modules by +# scripts/stage-tesseract-assets.mjs at prebuild/predev. +/public/tesseract/ # v1.4.27 B3 — MaxMind GeoLite2 databases are fetched at build time # via `scripts/fetch-geolite2.sh`. Too large for git; license key diff --git a/package.json b/package.json index 131d68ec7..48e77f94d 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,8 @@ "scripts": { "prepare": "node scripts/prepare.mjs", "dev": "next dev", - "predev": "node scripts/generate-i18n-boot.mjs", - "prebuild": "node scripts/generate-sw-version.mjs && node scripts/generate-i18n-boot.mjs", + "predev": "node scripts/generate-i18n-boot.mjs && node scripts/stage-tesseract-assets.mjs", + "prebuild": "node scripts/generate-sw-version.mjs && node scripts/generate-i18n-boot.mjs && node scripts/stage-tesseract-assets.mjs", "build": "NODE_OPTIONS=--max-old-space-size=8192 next build", "analyze": "ANALYZE=1 next build", "bundle-report": "node scripts/check-bundle-budget.mjs", @@ -112,6 +112,8 @@ "@next/bundle-analyzer": "^16.3.3", "@playwright/test": "^1.63.0", "@tailwindcss/postcss": "^4", + "@tesseract.js-data/deu": "1.0.0", + "@tesseract.js-data/eng": "1.0.0", "@testcontainers/postgresql": "^12.1.0", "@types/node": "26.5.1", "@types/pg": "^8.23.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 134544a01..723cc806e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -204,6 +204,12 @@ importers: '@tailwindcss/postcss': specifier: ^4 version: 4.3.3 + '@tesseract.js-data/deu': + specifier: 1.0.0 + version: 1.0.0 + '@tesseract.js-data/eng': + specifier: 1.0.0 + version: 1.0.0 '@testcontainers/postgresql': specifier: ^12.1.0 version: 12.1.0(supports-color@7.2.0) @@ -2819,6 +2825,12 @@ packages: '@tanstack/virtual-core@3.17.10': resolution: {integrity: sha512-eMorKWIi2nekElu+8DgSMJR4iR+sTDeh+W8VjKiMH79yUfEDJ3W9aL/96/wwbxNjyV6EfN39HKszSxjd90zAqQ==} + '@tesseract.js-data/deu@1.0.0': + resolution: {integrity: sha512-3ter1p2gFAiCd5CMC+PodZ9YY2UDomkKj+ytlKeeZqOUmhLx1SDnYPf5zog0g5MBbxYveW+8omI3PUflqHfZ4Q==} + + '@tesseract.js-data/eng@1.0.0': + resolution: {integrity: sha512-mbTumm6KQPUHyzTPQaF3ObXYnx0SqqfV2nabqFVQBwD6Kl7PhGSLSzOlfFTWy0P3BjghaSKA2W9GB19Jk+ZcTg==} + '@testcontainers/postgresql@12.1.0': resolution: {integrity: sha512-Pjf2VSVNirEPfz36nidyrVAnZvc2YhajOznY4VgyEsvfTd5qiMNOuPq96drREvxAUtXl5SFLX7vXj7sSq4aTcA==} @@ -9508,6 +9520,10 @@ snapshots: '@tanstack/virtual-core@3.17.10': {} + '@tesseract.js-data/deu@1.0.0': {} + + '@tesseract.js-data/eng@1.0.0': {} + '@testcontainers/postgresql@12.1.0(supports-color@7.2.0)': dependencies: testcontainers: 12.1.0(supports-color@7.2.0) diff --git a/scripts/stage-tesseract-assets.mjs b/scripts/stage-tesseract-assets.mjs new file mode 100644 index 000000000..22f3b78ac --- /dev/null +++ b/scripts/stage-tesseract-assets.mjs @@ -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`, +); diff --git a/src/lib/labs/__tests__/local-ocr.test.ts b/src/lib/labs/__tests__/local-ocr.test.ts new file mode 100644 index 000000000..2bc72ffbe --- /dev/null +++ b/src/lib/labs/__tests__/local-ocr.test.ts @@ -0,0 +1,77 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * The in-browser OCR engine loads from this origin, never from a CDN. + * + * tesseract.js defaults its worker, WebAssembly core and language data to + * cdn.jsdelivr.net. That sends the browser of anyone scanning a lab report + * to a third party, and the production CSP refuses it, so the feature could + * not load. These tests pin the options `createWorker` receives and hold them + * against the files `scripts/stage-tesseract-assets.mjs` actually stages. + */ +const recognize = vi.fn(); +const terminate = vi.fn().mockResolvedValue(undefined); +const createWorker = vi.fn(); +vi.mock("tesseract.js", () => ({ + createWorker: (...args: unknown[]) => createWorker(...args), +})); + +import { LOCAL_OCR_ASSET_OPTIONS, ocrImageToText } from "../local-ocr"; + +beforeEach(() => { + recognize.mockReset(); + createWorker.mockReset(); + createWorker.mockResolvedValue({ recognize, terminate }); +}); + +describe("local OCR engine assets", () => { + it("starts the worker from same-origin paths, not a CDN or a blob", async () => { + recognize.mockResolvedValue({ data: { text: " Hb 14.2 g/dl " } }); + const file = new File(["x"], "scan.png", { type: "image/png" }); + + await expect(ocrImageToText(file)).resolves.toBe("Hb 14.2 g/dl"); + + expect(createWorker).toHaveBeenCalledTimes(1); + const [langs, oem, options] = createWorker.mock.calls[0] as [ + string, + number, + Record, + ]; + expect(langs).toBe("deu+eng"); + expect(oem).toBe(1); + expect(options.workerBlobURL).toBe(false); + for (const key of ["workerPath", "corePath", "langPath"]) { + const value = String(options[key]); + expect(value.startsWith("/tesseract/"), `${key}: ${value}`).toBe(true); + } + expect(JSON.stringify(options)).not.toMatch(/https?:|jsdelivr/); + }); + + it("points at exactly the files the staging script copies", () => { + const script = readFileSync( + join(process.cwd(), "scripts/stage-tesseract-assets.mjs"), + "utf8", + ); + // Worker, the three LSTM core builds, and gzipped best_int data for + // both languages. The worker resolves `${corePath}/.wasm.js` and + // `${langPath}/.traineddata.gz`. + expect(LOCAL_OCR_ASSET_OPTIONS.workerPath).toBe("/tesseract/worker.min.js"); + expect(script).toContain('"worker.min.js"'); + expect(LOCAL_OCR_ASSET_OPTIONS.corePath).toBe("/tesseract/core"); + expect(script).toContain('join("core", file)'); + for (const build of [ + "tesseract-core-lstm.wasm.js", + "tesseract-core-simd-lstm.wasm.js", + "tesseract-core-relaxedsimd-lstm.wasm.js", + ]) { + expect(script).toContain(build); + } + expect(LOCAL_OCR_ASSET_OPTIONS.langPath).toBe("/tesseract/lang"); + expect(LOCAL_OCR_ASSET_OPTIONS.gzip).toBe(true); + expect(script).toContain('join("lang", `${lang}.traineddata.gz`)'); + expect(script).toContain('"4.0.0_best_int"'); + }); +}); diff --git a/src/lib/labs/local-ocr.ts b/src/lib/labs/local-ocr.ts index 03712de02..7f954dce1 100644 --- a/src/lib/labs/local-ocr.ts +++ b/src/lib/labs/local-ocr.ts @@ -13,12 +13,32 @@ * * Images only — tesseract.js cannot read PDFs, matching the vision path's * image-only constraint for non-Anthropic providers. + * + * Every engine file is served from this origin under `/tesseract/`, staged + * from node_modules by `scripts/stage-tesseract-assets.mjs`. The library's + * defaults fetch the worker, the WebAssembly core and the language data from + * cdn.jsdelivr.net, which both sends the user's browser to a third party and + * is refused by the production CSP. The worker is started from its URL, not + * from a blob, so `worker-src 'self'` admits it. */ /** German + English: a German lab sheet needs `deu` for ä/ö/ü/ß; `eng` covers * the many English analyte names and units that appear on the same sheet. */ const OCR_LANGS = "deu+eng"; +/** OEM 1 (LSTM only), the library default, spelled out because the staged + * core builds and the `best_int` language data are the LSTM-only ones. */ +const OCR_OEM = 1; + +/** Same-origin engine paths; see `scripts/stage-tesseract-assets.mjs`. */ +export const LOCAL_OCR_ASSET_OPTIONS = { + workerPath: "/tesseract/worker.min.js", + corePath: "/tesseract/core", + langPath: "/tesseract/lang", + workerBlobURL: false, + gzip: true, +} as const; + export class LocalOcrError extends Error { constructor(message: string) { super(message); @@ -42,7 +62,11 @@ export async function ocrImageToText(file: File): Promise { throw new LocalOcrError("Failed to load the local OCR engine"); } - const worker = await createWorker(OCR_LANGS); + const worker = await createWorker( + OCR_LANGS, + OCR_OEM, + LOCAL_OCR_ASSET_OPTIONS, + ); try { const { data: { text },