From 5f548d331f5e50a6d3f270fdc6da7a5ccc710084 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 1 Oct 2026 10:05:35 +0200
Subject: [PATCH] fix(labs): say when a scanned PDF was read from its first
pages only
A PDF longer than the pages a scan renders was read from its first ten
pages, and the cut was recorded only in the wide event. The person checking
the extracted rows had no way to know that values on later pages were never
read.
The rasterizer now returns the PDF's page count, the extract response
carries pageCoverage { read, total } when the cap applied, and the review
screen says how many of the pages were read.
---
docs/api/openapi.yaml | 17 ++++++++++
messages/de.json | 1 +
messages/en.json | 1 +
messages/es.json | 1 +
messages/fr.json | 1 +
messages/it.json | 1 +
messages/ko.json | 1 +
messages/pl.json | 1 +
.../labs/ocr/extract/__tests__/route.test.ts | 24 ++++++++++++++
src/app/api/labs/ocr/extract/route.ts | 17 +++++++++-
.../labs/__tests__/ocr-review-dialog.test.tsx | 22 +++++++++++++
src/components/labs/ocr-review-dialog.tsx | 32 +++++++++++++++++++
.../documents/__tests__/rasterize-pdf.test.ts | 3 ++
src/lib/documents/rasterize-pdf.ts | 13 ++++++--
src/lib/openapi/routes/ocr.ts | 9 ++++++
src/lib/validations/labs-ocr.ts | 6 ++++
16 files changed, 147 insertions(+), 3 deletions(-)
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index e6e09540c..3271e670b 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -63012,6 +63012,23 @@ components:
type: array
items:
$ref: "#/components/schemas/OcrExtractedRow"
+ pageCoverage:
+ description: "Present only when a PDF ran past the pages a scan reads: the rows come from the first `read` of `total`
+ pages. Absent when the whole document was read."
+ type: object
+ properties:
+ read:
+ type: integer
+ exclusiveMinimum: 0
+ maximum: 9007199254740991
+ total:
+ type: integer
+ exclusiveMinimum: 0
+ maximum: 9007199254740991
+ required:
+ - read
+ - total
+ additionalProperties: false
required:
- reportDate
- providerType
diff --git a/messages/de.json b/messages/de.json
index 553d63b6c..6bf4db0c6 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -8992,6 +8992,7 @@
"reviewTitle": "Vor dem Speichern prüfen",
"reviewHint": "Prüfe jeden Wert und speichere die gewünschten Einträge.",
"foundCount": "{count} Werte gefunden",
+ "pagesCapped": "Nur die ersten {read} von {total} Seiten wurden gelesen. Werte auf späteren Seiten fehlen in dieser Liste.",
"confirmRow": "Diesen Wert übernehmen",
"analyteLabel": "Bezeichnung",
"resultLabel": "Ergebnis",
diff --git a/messages/en.json b/messages/en.json
index 55d0718d6..04555344a 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -8992,6 +8992,7 @@
"reviewTitle": "Review before saving",
"reviewHint": "Check each reading, then save the ones you want to keep.",
"foundCount": "{count} readings found",
+ "pagesCapped": "Only the first {read} of {total} pages were read. Values on later pages are not in this list.",
"confirmRow": "Include this reading",
"analyteLabel": "Test name",
"resultLabel": "Result",
diff --git a/messages/es.json b/messages/es.json
index 1718c1acc..c90525051 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -8992,6 +8992,7 @@
"reviewTitle": "Revisar antes de guardar",
"reviewHint": "Revisa cada valor y guarda los que quieras conservar.",
"foundCount": "{count} valores encontrados",
+ "pagesCapped": "Solo se leyeron las primeras {read} de {total} páginas. Los valores de las páginas siguientes no están en esta lista.",
"confirmRow": "Incluir este valor",
"analyteLabel": "Nombre de la prueba",
"resultLabel": "Resultado",
diff --git a/messages/fr.json b/messages/fr.json
index 0afad1da7..1ebea9c37 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -8992,6 +8992,7 @@
"reviewTitle": "Vérifier avant d'enregistrer",
"reviewHint": "Vérifiez chaque valeur, puis enregistrez celles à conserver.",
"foundCount": "{count} valeurs trouvées",
+ "pagesCapped": "Seules les {read} premières pages sur {total} ont été lues. Les valeurs des pages suivantes ne figurent pas dans cette liste.",
"confirmRow": "Inclure cette valeur",
"analyteLabel": "Nom de l'analyse",
"resultLabel": "Résultat",
diff --git a/messages/it.json b/messages/it.json
index 883d3c4a1..c163f1bef 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -8992,6 +8992,7 @@
"reviewTitle": "Controlla prima di salvare",
"reviewHint": "Controlla ogni valore, poi salva quelli da conservare.",
"foundCount": "{count} valori trovati",
+ "pagesCapped": "Sono state lette solo le prime {read} pagine su {total}. I valori delle pagine successive non sono in questo elenco.",
"confirmRow": "Includi questo valore",
"analyteLabel": "Nome dell'esame",
"resultLabel": "Risultato",
diff --git a/messages/ko.json b/messages/ko.json
index f814ab916..08156e094 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -8992,6 +8992,7 @@
"reviewTitle": "저장 전에 확인하기",
"reviewHint": "측정값을 하나씩 확인하고, 남길 항목만 저장하세요.",
"foundCount": "측정값 {count}개를 찾았어요",
+ "pagesCapped": "전체 {total}쪽 중 처음 {read}쪽만 읽었습니다. 이후 쪽의 값은 이 목록에 없습니다.",
"confirmRow": "이 측정값 포함",
"analyteLabel": "검사 항목",
"resultLabel": "결과",
diff --git a/messages/pl.json b/messages/pl.json
index 7e45f4587..c0ecb344a 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -8992,6 +8992,7 @@
"reviewTitle": "Sprawdź przed zapisaniem",
"reviewHint": "Sprawdź każdy wynik, a następnie zapisz te, które chcesz zachować.",
"foundCount": "Znaleziono {count} wyników",
+ "pagesCapped": "Odczytano tylko pierwsze {read} z {total} stron. Wartości z dalszych stron nie ma na tej liście.",
"confirmRow": "Uwzględnij ten wynik",
"analyteLabel": "Nazwa badania",
"resultLabel": "Wynik",
diff --git a/src/app/api/labs/ocr/extract/__tests__/route.test.ts b/src/app/api/labs/ocr/extract/__tests__/route.test.ts
index 4dcd20385..742700fb7 100644
--- a/src/app/api/labs/ocr/extract/__tests__/route.test.ts
+++ b/src/app/api/labs/ocr/extract/__tests__/route.test.ts
@@ -229,11 +229,15 @@ describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => {
vi.mocked(rasterizePdf).mockResolvedValue({
ok: true,
images: [{ mediaType: "image/jpeg", dataBase64: "cGFnZQ==" }],
+ pageCount: 1,
});
vi.mocked(runOcrExtraction).mockResolvedValue({ rows: [] } as never);
const res = await POST(pdfReq());
expect(res.status).toBe(200);
+ // The whole PDF was read: no coverage note on the response.
+ const whole = (await res.json()) as { data: Record };
+ expect(whole.data).not.toHaveProperty("pageCoverage");
expect(rasterizePdf).toHaveBeenCalledOnce();
// The rendered page images flow through as `input_image`s; no native PDF
// document block is sent for a non-Anthropic provider.
@@ -245,6 +249,26 @@ describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => {
);
});
+ it("says on the response when only the first pages of a long PDF were read", async () => {
+ const tenPages = Array.from({ length: 10 }, () => ({
+ mediaType: "image/jpeg" as const,
+ dataBase64: "cGFnZQ==",
+ }));
+ vi.mocked(rasterizePdf).mockResolvedValue({
+ ok: true,
+ images: tenPages,
+ pageCount: 23,
+ });
+ vi.mocked(runOcrExtraction).mockResolvedValue({ rows: [] } as never);
+
+ const res = await POST(pdfReq());
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ data: { pageCoverage?: { read: number; total: number } };
+ };
+ expect(body.data.pageCoverage).toEqual({ read: 10, total: 23 });
+ });
+
it("hands the slot back when the upload is not an image or PDF", async () => {
const form = new FormData();
form.append(
diff --git a/src/app/api/labs/ocr/extract/route.ts b/src/app/api/labs/ocr/extract/route.ts
index 16ca28416..bb770f3b4 100644
--- a/src/app/api/labs/ocr/extract/route.ts
+++ b/src/app/api/labs/ocr/extract/route.ts
@@ -374,6 +374,7 @@ async function handleVisionExtract(
dataBase64: string;
}[];
let documents: { mediaType: "application/pdf"; dataBase64: string }[];
+ let pageCoverage: { read: number; total: number } | undefined;
if (mime === "application/pdf") {
if (pick.pdfSupported) {
@@ -404,6 +405,20 @@ async function handleVisionExtract(
}
images = raster.images;
documents = [];
+ // A PDF longer than the page cap is read from its first pages only.
+ // That used to live in the wide event alone; the person reviewing the
+ // rows is the one who needs to know the rest of the report was not
+ // read.
+ if (raster.pageCount > raster.images.length) {
+ pageCoverage = {
+ read: raster.images.length,
+ total: raster.pageCount,
+ };
+ annotate({
+ action: { name: "labs.ocr.pagesCapped" },
+ meta: { read: raster.images.length, total: raster.pageCount },
+ });
+ }
}
} else {
images = [{ mediaType: mime, dataBase64: buffer.toString("base64") }];
@@ -431,7 +446,7 @@ async function handleVisionExtract(
0,
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
);
- return apiSuccess(result);
+ return apiSuccess(pageCoverage ? { ...result, pageCoverage } : result);
} catch (err) {
// Provider/extraction failure — the call may still have burned tokens, so
// reconcile against the reserved estimate rather than refunding in full.
diff --git a/src/components/labs/__tests__/ocr-review-dialog.test.tsx b/src/components/labs/__tests__/ocr-review-dialog.test.tsx
index 1ef88b1c9..852746337 100644
--- a/src/components/labs/__tests__/ocr-review-dialog.test.tsx
+++ b/src/components/labs/__tests__/ocr-review-dialog.test.tsx
@@ -10,6 +10,7 @@ import { ApiError } from "@/lib/api/api-fetch";
import {
extractErrorMessage,
handleFilePickerChange,
+ OcrPageCoverageNote,
OcrReviewDialog,
} from "../ocr-review-dialog";
@@ -201,3 +202,24 @@ describe("extractErrorMessage", () => {
);
});
});
+
+describe(" — a long PDF read from its first pages", () => {
+ it("says how many of the pages were read", () => {
+ const html = renderToStaticMarkup(
+
+
+ ,
+ );
+ expect(html).toContain('data-slot="ocr-page-coverage"');
+ expect(html).toContain("Only the first 10 of 23 pages were read.");
+ });
+
+ it("renders nothing when the whole document was read", () => {
+ const html = renderToStaticMarkup(
+
+
+ ,
+ );
+ expect(html).toBe("");
+ });
+});
diff --git a/src/components/labs/ocr-review-dialog.tsx b/src/components/labs/ocr-review-dialog.tsx
index 0a0b0a17c..707da5efa 100644
--- a/src/components/labs/ocr-review-dialog.tsx
+++ b/src/components/labs/ocr-review-dialog.tsx
@@ -138,6 +138,11 @@ export function OcrReviewDialog({
// because the person has not filled it in yet. Once shown they follow the
// edits, so fixing a field clears its mark at once.
const [showErrors, setShowErrors] = useState(false);
+ // Set when the server read only the first pages of a long PDF.
+ const [pageCoverage, setPageCoverage] = useState<{
+ read: number;
+ total: number;
+ } | null>(null);
const [focusRequest, setFocusRequest] = useState(0);
const reviewListRef = useRef(null);
@@ -184,6 +189,7 @@ export function OcrReviewDialog({
function reset() {
setStage("pick");
setShowErrors(false);
+ setPageCoverage(null);
setRows([]);
setPickedFile(null);
setVisitId(null);
@@ -207,6 +213,7 @@ export function OcrReviewDialog({
return;
}
setRows(seeded);
+ setPageCoverage(data.pageCoverage ?? null);
setStage("review");
},
onError: (err) => toast.error(extractErrorMessage(err, t)),
@@ -353,6 +360,8 @@ export function OcrReviewDialog({
{t("labs.ocr.foundCount", { count: rows.length })}
+
+
{showErrors && rowErrors.size > 0 ? (
);
}
+
+/**
+ * The server read only the first pages of a long PDF (`pageCoverage` on the
+ * extract response). Said on the review screen, beside the rows, because the
+ * person checking the list is the one who would otherwise assume the whole
+ * report was read. Renders nothing when the whole document was read.
+ */
+export function OcrPageCoverageNote({
+ coverage,
+}: {
+ coverage: { read: number; total: number } | null;
+}) {
+ const { t } = useTranslations();
+ if (!coverage) return null;
+ return (
+
+ {t("labs.ocr.pagesCapped", {
+ read: coverage.read,
+ total: coverage.total,
+ })}
+
+ );
+}
diff --git a/src/lib/documents/__tests__/rasterize-pdf.test.ts b/src/lib/documents/__tests__/rasterize-pdf.test.ts
index a00340e8a..cb124491b 100644
--- a/src/lib/documents/__tests__/rasterize-pdf.test.ts
+++ b/src/lib/documents/__tests__/rasterize-pdf.test.ts
@@ -90,6 +90,9 @@ describe("rasterizePdf", () => {
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.images).toHaveLength(RASTER_MAX_PAGES);
+ // The page count travels with the result, so a caller can say the
+ // reading stopped short.
+ expect(result.pageCount).toBe(RASTER_MAX_PAGES + 4);
});
it("honours a caller maxPages bound (thumbnail path renders page 1 only)", async () => {
diff --git a/src/lib/documents/rasterize-pdf.ts b/src/lib/documents/rasterize-pdf.ts
index 402f61695..f61e74fc3 100644
--- a/src/lib/documents/rasterize-pdf.ts
+++ b/src/lib/documents/rasterize-pdf.ts
@@ -74,7 +74,16 @@ export interface RasterImage {
* would not render" (`render-failed` — malformed / encrypted / zero pages).
*/
export type RasterResult =
- | { ok: true; images: RasterImage[] }
+ | {
+ ok: true;
+ images: RasterImage[];
+ /**
+ * Pages in the PDF. Above `images.length` when the page cap cut the
+ * render short, which a caller showing the result has to say: the
+ * reading covers the first pages only.
+ */
+ pageCount: number;
+ }
| { ok: false; reason: "unsupported" | "render-failed" };
// Minimal structural types for the slice of the pdfjs API this module uses. A
@@ -212,7 +221,7 @@ export async function rasterizePdf(
action: { name: "documents.rasterize.ok" },
meta: { pages: images.length, cappedFrom: doc.numPages },
});
- return { ok: true, images };
+ return { ok: true, images, pageCount: doc.numPages };
} catch (err) {
annotate({
action: { name: "documents.rasterize.failed" },
diff --git a/src/lib/openapi/routes/ocr.ts b/src/lib/openapi/routes/ocr.ts
index 82ded9d09..f894cf8aa 100644
--- a/src/lib/openapi/routes/ocr.ts
+++ b/src/lib/openapi/routes/ocr.ts
@@ -85,6 +85,15 @@ const extractResponse = z
reportDate: z.string().nullable(),
providerType: z.string(),
rows: z.array(extractedRow),
+ pageCoverage: z
+ .object({
+ read: z.number().int().positive(),
+ total: z.number().int().positive(),
+ })
+ .optional()
+ .describe(
+ "Present only when a PDF ran past the pages a scan reads: the rows come from the first `read` of `total` pages. Absent when the whole document was read.",
+ ),
})
.meta({
id: "OcrExtractResponse",
diff --git a/src/lib/validations/labs-ocr.ts b/src/lib/validations/labs-ocr.ts
index aa18dd7e6..000a78ab5 100644
--- a/src/lib/validations/labs-ocr.ts
+++ b/src/lib/validations/labs-ocr.ts
@@ -132,6 +132,12 @@ export interface OcrExtractResponseDto {
reportDate: string | null;
providerType: string;
rows: OcrExtractedRowDto[];
+ /**
+ * Present only when a PDF was longer than the pages the scan reads
+ * (`RASTER_MAX_PAGES`): the rows come from the first `read` of `total`
+ * pages, and the review screen says so.
+ */
+ pageCoverage?: { read: number; total: number };
}
/**