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 },