Skip to content

Commit f3d66bb

Browse files
DylanMerigaudclaude
andcommitted
Harden PDF rendering and extraction after audit
- pdf-document.tsx: re-render on container resize (ResizeObserver) so the canvas stays sharp at any width, serialising renders so pdf.js never runs two at once; fix a stale comment. - extract.ts: raise max_tokens to 4096 so an invoice with many line items isn't truncated into invalid JSON. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c523c42 commit f3d66bb

2 files changed

Lines changed: 53 additions & 25 deletions

File tree

components/pdf-document.tsx

Lines changed: 50 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
"use client";
22

33
import { useEffect, useRef, useState } from "react";
4+
import type { PDFPageProxy } from "pdfjs-dist";
45

56
/**
6-
* Renders the real invoice PDF (the bytes the vision model actually reads) to a
7-
* canvas with pdf.js. This is the document shown in the extraction reveal and the
8-
* queue hover preview — an actual PDF, not an HTML mock.
7+
* Renders the real invoice PDF (the same bytes the vision model reads) to a
8+
* canvas with pdf.jsthe document shown in the right pane's extraction reveal.
9+
* An actual PDF, not an HTML mock.
910
*
1011
* pdf.js is loaded dynamically (client-only) and its worker is resolved through
1112
* the bundler via `new URL(..., import.meta.url)`, so there's no CDN dependency.
@@ -29,44 +30,69 @@ export function PdfDocument({ src, dim }: { src: string; dim: boolean }) {
2930
setReady(false);
3031
setError(false);
3132

33+
// Keep the loaded page around so a container resize can re-render at the new
34+
// width (sharp at any size) without re-fetching the PDF.
35+
let page: PDFPageProxy | null = null;
36+
let rendering: Promise<unknown> | null = null;
37+
38+
async function loadPage(buf: ArrayBuffer) {
39+
const pdfjs = await import("pdfjs-dist");
40+
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
41+
"pdfjs-dist/build/pdf.worker.min.mjs",
42+
import.meta.url,
43+
).toString();
44+
return pdfjs.getDocument({ data: buf }).promise;
45+
}
46+
47+
async function renderAtCurrentWidth() {
48+
if (!page || !canvas) return;
49+
const cssWidth = canvas.parentElement?.clientWidth ?? 360;
50+
if (cssWidth === 0) return;
51+
const base = page.getViewport({ scale: 1 });
52+
const dpr = Math.min(globalThis.devicePixelRatio || 1, 2);
53+
const viewport = page.getViewport({
54+
scale: (cssWidth / base.width) * dpr,
55+
});
56+
const ctx = canvas.getContext("2d");
57+
if (!ctx) throw new Error("no 2d context");
58+
canvas.width = viewport.width;
59+
canvas.height = viewport.height;
60+
await page.render({ canvasContext: ctx, viewport }).promise;
61+
}
62+
63+
const ro = new ResizeObserver(() => {
64+
// Serialise renders — pdf.js throws if a render starts while one is live.
65+
rendering = (rendering ?? Promise.resolve())
66+
.catch(() => {})
67+
.then(renderAtCurrentWidth)
68+
.catch(() => {});
69+
});
70+
3271
(async () => {
3372
try {
34-
const pdfjs = await import("pdfjs-dist");
35-
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
36-
"pdfjs-dist/build/pdf.worker.min.mjs",
37-
import.meta.url,
38-
).toString();
39-
4073
const buf = await fetch(src).then((r) => {
4174
if (!r.ok) throw new Error(`pdf fetch ${r.status}`);
4275
return r.arrayBuffer();
4376
});
4477
if (cancelled) return;
4578

46-
const pdf = await pdfjs.getDocument({ data: buf }).promise;
47-
const page = await pdf.getPage(1);
79+
const pdf = await loadPage(buf);
80+
if (cancelled) return;
81+
page = await pdf.getPage(1);
4882
if (cancelled) return;
4983

50-
const cssWidth = canvas.parentElement?.clientWidth ?? 360;
51-
const base = page.getViewport({ scale: 1 });
52-
const dpr = Math.min(globalThis.devicePixelRatio || 1, 2);
53-
const scale = (cssWidth / base.width) * dpr;
54-
const viewport = page.getViewport({ scale });
55-
56-
const ctx = canvas.getContext("2d");
57-
if (!ctx) throw new Error("no 2d context");
58-
canvas.width = viewport.width;
59-
canvas.height = viewport.height;
60-
61-
await page.render({ canvasContext: ctx, viewport }).promise;
62-
if (!cancelled) setReady(true);
84+
await renderAtCurrentWidth();
85+
if (cancelled) return;
86+
setReady(true);
87+
if (canvas.parentElement) ro.observe(canvas.parentElement);
6388
} catch {
6489
if (!cancelled) setError(true);
6590
}
6691
})();
6792

6893
return () => {
6994
cancelled = true;
95+
ro.disconnect();
7096
};
7197
}, [src]);
7298

lib/extract.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,9 @@ export async function extractInvoice(
6767
try {
6868
message = await getClient().messages.create({
6969
model: EXTRACTION_MODEL,
70-
max_tokens: 2048,
70+
// Headroom for invoices with many line items — a truncated response would
71+
// be invalid JSON (caught below, but better to not truncate in the first place).
72+
max_tokens: 4096,
7173
system: SYSTEM_PROMPT,
7274
output_config: {
7375
format: { type: "json_schema", schema: INVOICE_JSON_SCHEMA },

0 commit comments

Comments
 (0)