From 028b0d8685978c373838ebc3af56977b6e17abc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 16:50:16 +0200 Subject: [PATCH] feat(labs): unify the add action into a scan-or-manual choice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the separate, capability-gated scan button with a single Add menu: "Scan a document" (OCR) or "Add a value manually". The scan entry appears only when the capability probe reports a usable mode, and routes to native vision or local in-browser OCR (tesseract.js, lazy-loaded, deu+eng, images only) per the resolved mode. Add the local-OCR opt-in toggle to the Labs settings page with honest "less accurate — review every value" copy. Surface range confidence prominently on the OCR review screen for new numeric biomarkers, where the extracted range becomes the catalog range: flag low range-confidence or a missing range so the user verifies it. --- messages/de.json | 10 ++++ messages/en.json | 10 ++++ messages/es.json | 10 ++++ messages/fr.json | 10 ++++ messages/it.json | 10 ++++ messages/pl.json | 10 ++++ src/app/labs/page.tsx | 65 +++++++++++++++-------- src/components/labs/labs-settings.tsx | 26 +++++++++ src/components/labs/ocr-review-dialog.tsx | 33 ++++++++++-- src/components/labs/ocr-row-editor.tsx | 33 +++++++++++- src/components/labs/use-ocr-extract.ts | 54 ++++++++++++++++++- src/lib/labs/local-ocr.ts | 62 +++++++++++++++++++++ 12 files changed, 304 insertions(+), 29 deletions(-) create mode 100644 src/lib/labs/local-ocr.ts diff --git a/messages/de.json b/messages/de.json index 40d7ddbd8..6c7734983 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6489,6 +6489,10 @@ "deleteError": "Wert konnte nicht gelöscht werden", "loadError": "Laborwerte konnten nicht geladen werden", "customize": "Laborwerte anpassen", + "localOcr": { + "heading": "Scans auf diesem Gerät lesen", + "description": "Ermöglicht das Scannen eines Laborbefunds, auch wenn dein KI-Anbieter keine Bilder lesen kann: Das Foto wird auf deinem Gerät gelesen und nur der erkannte Text an deinen Anbieter gesendet. Weniger genau – prüfe jeden Wert vor dem Speichern." + }, "sort": { "heading": "Sortierung", "label": "Reihenfolge der Werte", @@ -6640,10 +6644,16 @@ "showingLatestOf": "Die neuesten {shown} von {total} Messwerten werden angezeigt", "ocr": { "scanButton": "Befund scannen", + "addScan": "Dokument scannen", + "addManual": "Wert manuell hinzufügen", "uploadHint": "Foto oder PDF eines Laborbefunds hochladen", "acceptWithPdf": "JPEG, PNG, WebP oder PDF", "acceptImageOnly": "JPEG, PNG oder WebP", "extracting": "Befund wird gelesen…", + "readingOnDevice": "Wird auf deinem Gerät gelesen…", + "localModeHint": "Wird auf deinem Gerät gelesen und als Text gesendet. Weniger genau – prüfe jeden Wert.", + "rangeFromScan": "Referenzbereich aus dem Scan übernommen", + "rangeVerify": "Referenzbereich prüfen", "reviewTitle": "Vor dem Speichern prüfen", "reviewHint": "Prüfe jeden Wert und speichere die gewünschten Einträge.", "foundCount": "{count} Werte gefunden", diff --git a/messages/en.json b/messages/en.json index 54241ad76..a48b7b867 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6489,6 +6489,10 @@ "deleteError": "Could not delete the result", "loadError": "Could not load lab results", "customize": "Customize labs", + "localOcr": { + "heading": "Read scans on this device", + "description": "Lets you scan a lab report even when your AI provider can't read images: the photo is read on your device and only the extracted text is sent to your provider. Less accurate — review every value before saving." + }, "sort": { "heading": "Sort order", "label": "Order of values", @@ -6640,10 +6644,16 @@ "showingLatestOf": "Showing the latest {shown} of {total} readings", "ocr": { "scanButton": "Scan a report", + "addScan": "Scan a document", + "addManual": "Add a value manually", "uploadHint": "Upload a photo or PDF of a lab report", "acceptWithPdf": "JPEG, PNG, WebP, or PDF", "acceptImageOnly": "JPEG, PNG, or WebP", "extracting": "Reading your report…", + "readingOnDevice": "Reading on your device…", + "localModeHint": "Read on your device, then sent as text. Less accurate — review every value.", + "rangeFromScan": "Reference range taken from the scan", + "rangeVerify": "Verify the reference range", "reviewTitle": "Review before saving", "reviewHint": "Check each reading, then save the ones you want to keep.", "foundCount": "{count} readings found", diff --git a/messages/es.json b/messages/es.json index 4b8561ed3..8df7ca0b3 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6489,6 +6489,10 @@ "deleteError": "No se pudo eliminar el resultado", "loadError": "No se pudieron cargar los resultados", "customize": "Personalizar laboratorio", + "localOcr": { + "heading": "Leer escaneos en este dispositivo", + "description": "Permite escanear un informe de laboratorio aunque tu proveedor de IA no pueda leer imágenes: la foto se lee en tu dispositivo y solo se envía el texto extraído a tu proveedor. Menos preciso: revisa cada valor antes de guardar." + }, "sort": { "heading": "Orden", "label": "Orden de los valores", @@ -6640,10 +6644,16 @@ "showingLatestOf": "Mostrando los {shown} resultados más recientes de {total}", "ocr": { "scanButton": "Escanear un informe", + "addScan": "Escanear un documento", + "addManual": "Añadir un valor manualmente", "uploadHint": "Sube una foto o un PDF de un informe de laboratorio", "acceptWithPdf": "JPEG, PNG, WebP o PDF", "acceptImageOnly": "JPEG, PNG o WebP", "extracting": "Leyendo tu informe…", + "readingOnDevice": "Leyendo en tu dispositivo…", + "localModeHint": "Se lee en tu dispositivo y se envía como texto. Menos preciso: revisa cada valor.", + "rangeFromScan": "Intervalo de referencia tomado del escaneo", + "rangeVerify": "Verifica el intervalo de referencia", "reviewTitle": "Revisar antes de guardar", "reviewHint": "Revisa cada valor y guarda los que quieras conservar.", "foundCount": "{count} valores encontrados", diff --git a/messages/fr.json b/messages/fr.json index 71ec8f395..8a0465152 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6489,6 +6489,10 @@ "deleteError": "Impossible de supprimer le résultat", "loadError": "Impossible de charger les résultats", "customize": "Personnaliser les analyses", + "localOcr": { + "heading": "Lire les scans sur cet appareil", + "description": "Permet de scanner un compte rendu de laboratoire même si votre fournisseur d'IA ne peut pas lire les images : la photo est lue sur votre appareil et seul le texte extrait est envoyé à votre fournisseur. Moins précis — vérifiez chaque valeur avant d'enregistrer." + }, "sort": { "heading": "Tri", "label": "Ordre des valeurs", @@ -6640,10 +6644,16 @@ "showingLatestOf": "Affichage des {shown} derniers résultats sur {total}", "ocr": { "scanButton": "Scanner un compte rendu", + "addScan": "Scanner un document", + "addManual": "Ajouter une valeur manuellement", "uploadHint": "Importez une photo ou un PDF d'un compte rendu de laboratoire", "acceptWithPdf": "JPEG, PNG, WebP ou PDF", "acceptImageOnly": "JPEG, PNG ou WebP", "extracting": "Lecture de votre compte rendu…", + "readingOnDevice": "Lecture sur votre appareil…", + "localModeHint": "Lu sur votre appareil, puis envoyé sous forme de texte. Moins précis — vérifiez chaque valeur.", + "rangeFromScan": "Valeurs de référence issues du scan", + "rangeVerify": "Vérifiez les valeurs de référence", "reviewTitle": "Vérifier avant d'enregistrer", "reviewHint": "Vérifiez chaque valeur, puis enregistrez celles à conserver.", "foundCount": "{count} valeurs trouvées", diff --git a/messages/it.json b/messages/it.json index 712b5c259..de1cf4a6e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6489,6 +6489,10 @@ "deleteError": "Impossibile eliminare il risultato", "loadError": "Impossibile caricare i risultati", "customize": "Personalizza esami", + "localOcr": { + "heading": "Leggi le scansioni su questo dispositivo", + "description": "Consente di scansionare un referto di laboratorio anche se il tuo provider di IA non può leggere le immagini: la foto viene letta sul tuo dispositivo e al provider viene inviato solo il testo estratto. Meno preciso: controlla ogni valore prima di salvare." + }, "sort": { "heading": "Ordinamento", "label": "Ordine dei valori", @@ -6640,10 +6644,16 @@ "showingLatestOf": "Visualizzazione degli ultimi {shown} risultati su {total}", "ocr": { "scanButton": "Scansiona un referto", + "addScan": "Scansiona un documento", + "addManual": "Aggiungi un valore manualmente", "uploadHint": "Carica una foto o un PDF di un referto di laboratorio", "acceptWithPdf": "JPEG, PNG, WebP o PDF", "acceptImageOnly": "JPEG, PNG o WebP", "extracting": "Lettura del referto…", + "readingOnDevice": "Lettura sul tuo dispositivo…", + "localModeHint": "Letto sul tuo dispositivo e inviato come testo. Meno preciso: controlla ogni valore.", + "rangeFromScan": "Intervallo di riferimento ricavato dalla scansione", + "rangeVerify": "Verifica l'intervallo di riferimento", "reviewTitle": "Controlla prima di salvare", "reviewHint": "Controlla ogni valore, poi salva quelli da conservare.", "foundCount": "{count} valori trovati", diff --git a/messages/pl.json b/messages/pl.json index 832a4d5d2..08ddaf306 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6489,6 +6489,10 @@ "deleteError": "Nie udało się usunąć wyniku", "loadError": "Nie udało się wczytać wyników", "customize": "Dostosuj wyniki badań", + "localOcr": { + "heading": "Odczytuj skany na tym urządzeniu", + "description": "Pozwala zeskanować wynik laboratoryjny nawet wtedy, gdy Twój dostawca AI nie potrafi odczytywać obrazów: zdjęcie jest odczytywane na Twoim urządzeniu, a do dostawcy wysyłany jest tylko wyodrębniony tekst. Mniej dokładne — sprawdź każdą wartość przed zapisaniem." + }, "sort": { "heading": "Sortowanie", "label": "Kolejność wartości", @@ -6640,10 +6644,16 @@ "showingLatestOf": "Wyświetlanie najnowszych {shown} z {total} wyników", "ocr": { "scanButton": "Zeskanuj wynik", + "addScan": "Zeskanuj dokument", + "addManual": "Dodaj wartość ręcznie", "uploadHint": "Prześlij zdjęcie lub PDF wyniku laboratoryjnego", "acceptWithPdf": "JPEG, PNG, WebP lub PDF", "acceptImageOnly": "JPEG, PNG lub WebP", "extracting": "Odczytywanie wyniku…", + "readingOnDevice": "Odczytywanie na Twoim urządzeniu…", + "localModeHint": "Odczytywane na Twoim urządzeniu i wysyłane jako tekst. Mniej dokładne — sprawdź każdą wartość.", + "rangeFromScan": "Zakres referencyjny pobrany ze skanu", + "rangeVerify": "Zweryfikuj zakres referencyjny", "reviewTitle": "Sprawdź przed zapisaniem", "reviewHint": "Sprawdź każdy wynik, a następnie zapisz te, które chcesz zachować.", "foundCount": "Znaleziono {count} wyników", diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx index 5dd27f3ac..b10436be5 100644 --- a/src/app/labs/page.tsx +++ b/src/app/labs/page.tsx @@ -4,13 +4,19 @@ import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; -import { Loader2, Plus, ScanLine, Wrench } from "lucide-react"; +import { Loader2, Pencil, Plus, ScanLine, Wrench } from "lucide-react"; import { LabForm } from "@/components/labs/lab-form"; import { LabList } from "@/components/labs/lab-list"; import { OcrReviewDialog } from "@/components/labs/ocr-review-dialog"; import { useOcrCapability } from "@/components/labs/use-ocr-extract"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { useAuth } from "@/hooks/use-auth"; @@ -73,19 +79,6 @@ export default function LabsPage() {

- {/* v1.18.9 — "Scan a report" Lab-OCR entry. Shown only when the - capability probe reports a vision-capable provider is configured, - so the surface stays dark for codex-only / text-only-model users. */} - {ocrCapability.data?.available ? ( - - ) : null} {/* v1.18.6 (MOD-01) — the wrench is the module's customize entry point, left of the primary Add and linking to the Labs settings page (view, sort order, biomarker CRUD + reorder). Mirrors the @@ -104,13 +97,40 @@ export default function LabsPage() {
@@ -135,10 +155,11 @@ export default function LabsPage() { /> - {ocrCapability.data?.available ? ( + {ocrCapability.data?.available && ocrCapability.data.mode ? ( { queryClient.invalidateQueries({ diff --git a/src/components/labs/labs-settings.tsx b/src/components/labs/labs-settings.tsx index 104bed28d..8c83743e7 100644 --- a/src/components/labs/labs-settings.tsx +++ b/src/components/labs/labs-settings.tsx @@ -19,6 +19,7 @@ import { FlaskConical, LayoutGrid, ListOrdered, + ScanText, } from "lucide-react"; import { SettingsCard } from "@/components/settings/settings-card"; @@ -26,6 +27,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header"; import { NativeSelect } from "@/components/ui/native-select"; import { Label } from "@/components/ui/label"; import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; import { apiGet } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -42,11 +44,18 @@ import { import { BiomarkerManager } from "./biomarker-manager"; import type { BiomarkerDto, BiomarkerListResponse } from "./types"; +import { useLabsLocalOcr, useUpdateLabsLocalOcr } from "./use-ocr-extract"; export function LabsSettings() { const { t } = useTranslations(); const { prefs, setView, setOrder, setSortDir } = useModuleListPrefs("labs"); + // v1.18.10 — local (in-browser) OCR opt-in. Lets a user whose AI provider + // can't read images still scan a report: the image is OCR'd on-device and + // only the extracted text is sent to the text-only provider. + const localOcr = useLabsLocalOcr(); + const updateLocalOcr = useUpdateLabsLocalOcr(); + const { data, isLoading } = useQuery({ queryKey: queryKeys.biomarkers(), queryFn: () => apiGet("/api/biomarkers"), @@ -120,6 +129,23 @@ export function LabsSettings() { ) : null} + {/* v1.18.10 — local (in-browser) OCR opt-in for lab scans. */} + + updateLocalOcr.mutate(checked)} + aria-label={t("labs.localOcr.heading")} + /> + } + /> + + {/* Biomarker catalog — define / edit / delete. */} string): string { export function OcrReviewDialog({ open, onOpenChange, + mode, pdfSupported, onCommitted, }: { open: boolean; onOpenChange: (open: boolean) => void; + /** Native vision vs in-browser local OCR. Text mode is image-only. */ + mode: OcrMode; pdfSupported: boolean; onCommitted: () => void; }) { @@ -101,8 +109,14 @@ export function OcrReviewDialog({ const [stage, setStage] = useState("pick"); const [rows, setRows] = useState([]); - const extract = useOcrExtract(); + // Text mode OCR's the image in the browser then POSTs the text; vision mode + // uploads the image. Both resolve with the same proposed-rows DTO. + const visionExtract = useOcrExtract(); + const textExtract = useOcrTextExtract(); + const extract = mode === "text" ? textExtract : visionExtract; const commit = useOcrCommit(); + // Text mode never accepts PDFs (tesseract.js can't read them). + const allowPdf = mode === "vision" && pdfSupported; const confirmedCount = useMemo( () => rows.filter((r) => r.confirmed).length, @@ -197,7 +211,7 @@ export function OcrReviewDialog({ { const file = e.target.files?.[0]; @@ -216,7 +230,12 @@ export function OcrReviewDialog({ <> - {t("labs.ocr.extracting")} + {/* Text mode runs OCR on-device first, then structures it — + the first run also downloads the OCR engine, so the copy + sets the "this is reading on your device" expectation. */} + {mode === "text" + ? t("labs.ocr.readingOnDevice") + : t("labs.ocr.extracting")} ) : ( @@ -230,10 +249,16 @@ export function OcrReviewDialog({ - {pdfSupported + {allowPdf ? t("labs.ocr.acceptWithPdf") : t("labs.ocr.acceptImageOnly")} + {/* Honest accuracy caveat for the local-OCR fallback. */} + {mode === "text" ? ( + + {t("labs.ocr.localModeHint")} + + ) : null} )} diff --git a/src/components/labs/ocr-row-editor.tsx b/src/components/labs/ocr-row-editor.tsx index 926a02f94..eed49fe60 100644 --- a/src/components/labs/ocr-row-editor.tsx +++ b/src/components/labs/ocr-row-editor.tsx @@ -15,7 +15,13 @@ */ import { useId } from "react"; -import { AlertCircle, FilePlus2, Link2, TriangleAlert } from "lucide-react"; +import { + AlertCircle, + FilePlus2, + Link2, + Ruler, + TriangleAlert, +} from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; @@ -43,6 +49,16 @@ export function OcrRowEditor({ !isQualitative && row.confidence.value < CONFIDENCE_THRESHOLD; const valueUnreadable = !isQualitative && row.value === null; + // v1.18.10 (#5) — a NEW numeric biomarker mints its catalog reference range + // from THIS extracted row. The range is therefore load-bearing and worth a + // second look. Surface the range confidence prominently for new numeric + // markers, and call it out explicitly when the model was unsure (or set no + // bounds at all) so the user verifies the range it will be judged against. + const isNewNumericMarker = !isQualitative && row.biomarkerMatch === "new"; + const lowRangeConfidence = row.confidence.range < CONFIDENCE_THRESHOLD; + const hasBounds = row.referenceLow !== null || row.referenceHigh !== null; + const flagRange = isNewNumericMarker && (lowRangeConfidence || !hasBounds); + return (
@@ -156,6 +172,21 @@ export function OcrRowEditor({
+ {/* v1.18.10 (#5) — for a NEW numeric biomarker the reference range below + becomes the marker's catalog range. Make that consequence visible, and + flag low range-confidence / a missing range so the user verifies it + before saving. A calm `secondary` badge, not an alarm. */} + {isNewNumericMarker ? ( +
+ + + {flagRange + ? t("labs.ocr.rangeVerify") + : t("labs.ocr.rangeFromScan")} + +
+ ) : null} + {!isQualitative ? (
diff --git a/src/components/labs/use-ocr-extract.ts b/src/components/labs/use-ocr-extract.ts index 8ed9307d4..60cd70c9a 100644 --- a/src/components/labs/use-ocr-extract.ts +++ b/src/components/labs/use-ocr-extract.ts @@ -13,7 +13,8 @@ */ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { apiFetch, apiPost } from "@/lib/api/api-fetch"; +import { apiFetch, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { ocrImageToText } from "@/lib/labs/local-ocr"; import { queryKeys } from "@/lib/query-keys"; import type { OcrCapabilityDto, @@ -49,7 +50,7 @@ export function useOcrCapability(enabled: boolean) { }); } -/** Upload + extract. Resolves with the proposed rows for the review screen. */ +/** Upload + extract (VISION mode). Resolves with the proposed review rows. */ export function useOcrExtract() { return useMutation({ mutationFn: (file: File) => { @@ -65,6 +66,55 @@ export function useOcrExtract() { }); } +/** + * TEXT mode (v1.18.10) — OCR the image IN THE BROWSER (tesseract.js), then POST + * only the extracted text. The raw image never leaves the device. Resolves with + * the same proposed-rows DTO the vision path returns, so the review/commit flow + * is shared verbatim. + */ +export function useOcrTextExtract() { + return useMutation({ + mutationFn: async (file: File) => { + const text = await ocrImageToText(file); + return apiPost("/api/labs/ocr/extract", { + mode: "text", + text, + }); + }, + }); +} + +/** The local-OCR opt-in preference (read + toggle). */ +export interface LabsLocalOcrPref { + labsLocalOcrEnabled: boolean; +} + +/** Read the current local-OCR opt-in flag. */ +export function useLabsLocalOcr(enabled = true) { + return useQuery({ + queryKey: queryKeys.labsLocalOcr(), + queryFn: () => apiGet("/api/auth/me/labs-local-ocr"), + enabled, + staleTime: 60_000, + }); +} + +/** Toggle the local-OCR opt-in; invalidates the flag + the capability probe. */ +export function useUpdateLabsLocalOcr() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (labsLocalOcrEnabled: boolean) => + apiPatch("/api/auth/me/labs-local-ocr", { + labsLocalOcrEnabled, + }), + onSuccess: (data) => { + queryClient.setQueryData(queryKeys.labsLocalOcr(), data); + // The toggle changes whether text-mode scanning is available. + queryClient.invalidateQueries({ queryKey: queryKeys.ocrCapability() }); + }, + }); +} + /** Commit the confirmed rows and invalidate the dependent reads. */ export function useOcrCommit() { const queryClient = useQueryClient(); diff --git a/src/lib/labs/local-ocr.ts b/src/lib/labs/local-ocr.ts new file mode 100644 index 000000000..03712de02 --- /dev/null +++ b/src/lib/labs/local-ocr.ts @@ -0,0 +1,62 @@ +/** + * v1.18.10 — in-browser (local) OCR for lab-report scans. + * + * Used by the TEXT-mode Lab-OCR path: a user whose AI provider cannot read + * images (ChatGPT-OAuth/Codex, a text-only model) OCR's the photo HERE, in the + * browser, and only the extracted TEXT is POSTed to `/api/labs/ocr/extract`. + * The raw image never leaves the device. + * + * tesseract.js (WASM) is LAZY-loaded via `await import(...)` so the multi-MB + * worker + WASM core + `deu+eng` traineddata land in the browser only when the + * user actually scans in text mode — never at app boot, and never for the + * higher-accuracy vision path. This mirrors the chart-defer discipline. + * + * Images only — tesseract.js cannot read PDFs, matching the vision path's + * image-only constraint for non-Anthropic providers. + */ + +/** 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"; + +export class LocalOcrError extends Error { + constructor(message: string) { + super(message); + this.name = "LocalOcrError"; + } +} + +/** + * OCR an image file entirely in the browser and return the extracted plain + * text. Throws `LocalOcrError` when the engine fails to load or recognise. + * + * A fresh worker is created and terminated per call. The traineddata is + * browser-cached after the first run, so the cost is paid once. + */ +export async function ocrImageToText(file: File): Promise { + // Lazy import — keeps tesseract.js out of the main bundle. + let createWorker: typeof import("tesseract.js").createWorker; + try { + ({ createWorker } = await import("tesseract.js")); + } catch { + throw new LocalOcrError("Failed to load the local OCR engine"); + } + + const worker = await createWorker(OCR_LANGS); + try { + const { + data: { text }, + } = await worker.recognize(file); + const trimmed = text.trim(); + if (!trimmed) { + throw new LocalOcrError("No text could be read from the image"); + } + return trimmed; + } catch (err) { + if (err instanceof LocalOcrError) throw err; + throw new LocalOcrError("Local OCR failed to read the image"); + } finally { + // Free the WASM worker; the traineddata stays browser-cached. + await worker.terminate().catch(() => {}); + } +}