mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
94ac863bdf
commit
2c88acc5f7
6 files changed
+193
-3
No files matched your search
@@ -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
|
||||
|
||||
+4
-2
@@ -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",
|
||||
|
||||
Generated
+16
@@ -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)
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
@@ -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<string, unknown>,
|
||||
];
|
||||
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}/<build>.wasm.js` and
|
||||
// `${langPath}/<lang>.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"');
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
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 },
|
||||
|
||||
Reference in new issue
Block a user