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() {
-
+ {/* v1.18.10 — the "Add" action is a CHOICE when scanning is
+ available: scan a document (OCR) or add a value by hand. The scan
+ option only appears when the capability probe reports a usable
+ mode (vision, or local OCR opted-in for text-only providers), so
+ the surface stays simple for everyone else. When scanning is
+ unavailable, Add opens the manual form directly. */}
+ {ocrCapability.data?.available ? (
+
+
+
+
+
+ setScanOpen(true)}>
+
+ {t("labs.ocr.addScan")}
+
+ setDialogOpen(true)}>
+
+ {t("labs.ocr.addManual")}
+
+
+
+ ) : (
+
+ )}
@@ -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 ? (
+
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(() => {});
+ }
+}