Skip to content

Commit 074ed6a

Browse files
authored
Merge pull request #5 from RATCHAW/RATCHAW/fix-hover-bbox-accuracy
Playground: snap hover bboxes to the PDF text layer + show partial extractions
2 parents 1974457 + 37f34d5 commit 074ed6a

12 files changed

Lines changed: 496 additions & 51 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# extractkit
22

3+
![extractkit playground — drop in a document, watch fields stream in, hover a field to highlight its source region on the page](./docs/demo.gif)
4+
35
**Extraction you can audit.** Define a Zod schema, feed it a PDF or image, get back schema-validated JSON where every field carries provenance — the page and bounding box it came from — plus a confidence score.
46

57
> **Status: in development.** The core library ([`packages/core`](./packages/core)), the eval harness ([`packages/evals`](./packages/evals)), and the playground ([`apps/playground`](./apps/playground)) are implemented and tested. The first live provider run is in — the OpenAI lineup across the CORD-v2 receipt set — validating core's live path and publishing the [benchmark](#benchmark) below. Still open: the Anthropic and Gemini lineups, the DocILE invoice half (blocked on a dataset token), and the demo GIF. See [ROADMAP.md](./ROADMAP.md).

apps/playground/src/client/App.tsx

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -104,14 +104,15 @@ export function App() {
104104
}
105105
}, [file, modelId, schemaId]);
106106

107-
const entries = useMemo<FieldEntry[]>(
108-
() => (run.result !== null ? fieldEntries(run.result.fields) : run.live),
109-
[run.result, run.live],
110-
);
111-
const boxes = useMemo(
112-
() => entries.filter((e) => e.field.bbox !== null && e.field.page !== null),
113-
[entries],
114-
);
107+
const entries = useMemo<FieldEntry[]>(() => {
108+
if (run.result !== null) return fieldEntries(run.result.fields);
109+
// A failed run may still carry a partial extraction worth showing.
110+
if (run.error?.partial !== undefined) return fieldEntries(run.error.partial.fields);
111+
return run.live;
112+
}, [run.result, run.error, run.live]);
113+
// Include fields without model provenance: the PDF viewer can still locate
114+
// their values in the page text layer. Value-less fields have nothing to find.
115+
const boxes = useMemo(() => entries.filter((e) => e.field.value !== null), [entries]);
115116

116117
return (
117118
<div className="app">

apps/playground/src/client/components/DocumentViewer.tsx

Lines changed: 86 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
import { useEffect, useRef, useState } from 'react';
1+
import { useEffect, useMemo, useRef, useState } from 'react';
22
import type { DragEvent } from 'react';
3-
import { GlobalWorkerOptions, getDocument } from 'pdfjs-dist';
3+
import { GlobalWorkerOptions, Util, getDocument } from 'pdfjs-dist';
44
import type { PDFDocumentLoadingTask, PDFPageProxy } from 'pdfjs-dist';
55
import workerSrc from 'pdfjs-dist/build/pdf.worker.min.mjs?url';
6+
import type { BBox as BBoxRect } from 'extractkit';
67
import type { FieldEntry } from '../lib/fields';
78
import { formatPath, formatValue } from '../lib/fields';
89
import { bboxToStyle } from '../lib/geometry';
10+
import type { TextSpan } from '../lib/snap';
11+
import { locateField } from '../lib/snap';
912
import { isPdf, pickFile } from '../lib/upload';
1013

1114
GlobalWorkerOptions.workerSrc = workerSrc;
@@ -44,40 +47,79 @@ export function DocumentViewer(props: ViewerProps) {
4447
{isPdf(file) ? (
4548
<PdfView file={file} boxes={boxes} activeKey={activeKey} onActivate={onActivate} />
4649
) : (
47-
<div className="page-stack">
48-
<ImagePage
49-
src={docUrl}
50-
pageIndex={0}
51-
boxes={boxes}
52-
activeKey={activeKey}
53-
onActivate={onActivate}
54-
/>
55-
</div>
50+
<ImagePage src={docUrl} boxes={boxes} activeKey={activeKey} onActivate={onActivate} />
5651
)}
5752
</div>
5853
);
5954
}
6055

61-
interface PageBoxProps {
56+
/** A field placed on a specific page, with the bbox to draw. */
57+
interface PlacedBox {
58+
entry: FieldEntry;
59+
bbox: BBoxRect;
60+
}
61+
62+
interface OverlayProps {
6263
boxes: FieldEntry[];
63-
pageIndex: number;
6464
activeKey: string | null;
6565
onActivate: (key: string | null) => void;
6666
}
6767

68-
function ImagePage({ src, ...page }: { src: string } & PageBoxProps) {
68+
function ImagePage({ src, boxes, activeKey, onActivate }: { src: string } & OverlayProps) {
69+
// Images have no text layer to snap to; draw the model's own provenance.
70+
const placed = useMemo(
71+
() =>
72+
boxes
73+
.filter((box) => box.field.bbox !== null && (box.field.page ?? 0) === 0)
74+
.map((box) => ({ entry: box, bbox: box.field.bbox! })),
75+
[boxes],
76+
);
6977
return (
70-
<div className="page">
71-
<img className="page-image" src={src} alt="Uploaded document" />
72-
<BoxLayer {...page} />
78+
<div className="page-stack">
79+
<div className="page">
80+
<img className="page-image" src={src} alt="Uploaded document" />
81+
<BoxLayer placed={placed} activeKey={activeKey} onActivate={onActivate} />
82+
</div>
7383
</div>
7484
);
7585
}
7686

77-
function PdfView({ file, boxes, activeKey, onActivate }: { file: File } & Omit<PageBoxProps, 'pageIndex'>) {
78-
const [pages, setPages] = useState<PDFPageProxy[]>([]);
87+
/** Positioned text runs of a page, normalized 0–1 with a top-left origin. */
88+
async function pageTextSpans(page: PDFPageProxy): Promise<TextSpan[]> {
89+
const viewport = page.getViewport({ scale: 1 });
90+
const content = await page.getTextContent();
91+
const spans: TextSpan[] = [];
92+
for (const item of content.items) {
93+
if (!('str' in item) || item.str.trim() === '') continue;
94+
const tx = Util.transform(viewport.transform, item.transform);
95+
const fontHeight = Math.hypot(tx[2], tx[3]);
96+
spans.push({
97+
text: item.str,
98+
x0: tx[4] / viewport.width,
99+
y0: (tx[5] - fontHeight) / viewport.height,
100+
x1: (tx[4] + item.width * viewport.scale) / viewport.width,
101+
y1: tx[5] / viewport.height,
102+
});
103+
}
104+
return spans;
105+
}
106+
107+
function PdfView({ file, boxes, activeKey, onActivate }: { file: File } & OverlayProps) {
108+
const [pages, setPages] = useState<{ page: PDFPageProxy; spans: TextSpan[] }[]>([]);
79109
const [error, setError] = useState<string | null>(null);
80110

111+
// Place every field: snap to the page text layer, rescuing fields whose
112+
// model-reported bbox or page is missing or wrong.
113+
const placedByPage = useMemo<PlacedBox[][]>(() => {
114+
const spansByPage = pages.map((p) => p.spans);
115+
const byPage: PlacedBox[][] = pages.map(() => []);
116+
for (const entry of boxes) {
117+
const located = locateField(entry.field.value, entry.field.page, entry.field.bbox, spansByPage);
118+
if (located !== null) byPage[located.page]!.push({ entry, bbox: located.bbox });
119+
}
120+
return byPage;
121+
}, [boxes, pages]);
122+
81123
useEffect(() => {
82124
let cancelled = false;
83125
let loadingTask: PDFDocumentLoadingTask | null = null;
@@ -90,7 +132,10 @@ function PdfView({ file, boxes, activeKey, onActivate }: { file: File } & Omit<P
90132
loadingTask = getDocument({ data: new Uint8Array(buffer) });
91133
const doc = await loadingTask.promise;
92134
const proxies = await Promise.all(
93-
Array.from({ length: doc.numPages }, (_, i) => doc.getPage(i + 1)),
135+
Array.from({ length: doc.numPages }, async (_, i) => {
136+
const page = await doc.getPage(i + 1);
137+
return { page, spans: await pageTextSpans(page) };
138+
}),
94139
);
95140
if (cancelled) return;
96141
setPages(proxies);
@@ -108,10 +153,10 @@ function PdfView({ file, boxes, activeKey, onActivate }: { file: File } & Omit<P
108153

109154
return (
110155
<div className="page-stack">
111-
{pages.map((page, index) => (
156+
{pages.map(({ page }, index) => (
112157
<div className="page" key={index}>
113158
<PdfCanvas page={page} />
114-
<BoxLayer boxes={boxes} pageIndex={index} activeKey={activeKey} onActivate={onActivate} />
159+
<BoxLayer placed={placedByPage[index]!} activeKey={activeKey} onActivate={onActivate} />
115160
</div>
116161
))}
117162
</div>
@@ -143,34 +188,47 @@ function PdfCanvas({ page }: { page: PDFPageProxy }) {
143188
return <canvas ref={canvasRef} className="page-canvas" />;
144189
}
145190

146-
function BoxLayer({ boxes, pageIndex, activeKey, onActivate }: PageBoxProps) {
147-
const onPage = boxes.filter((box) => (box.field.page ?? 0) === pageIndex);
191+
function BoxLayer({
192+
placed,
193+
activeKey,
194+
onActivate,
195+
}: {
196+
placed: PlacedBox[];
197+
activeKey: string | null;
198+
onActivate: (key: string | null) => void;
199+
}) {
148200
return (
149201
<div className="box-layer">
150-
{onPage.map((box) => (
151-
<BBox key={box.key} entry={box} active={activeKey === box.key} onActivate={onActivate} />
202+
{placed.map(({ entry, bbox }) => (
203+
<BBox
204+
key={entry.key}
205+
entry={entry}
206+
bbox={bbox}
207+
active={activeKey === entry.key}
208+
onActivate={onActivate}
209+
/>
152210
))}
153211
</div>
154212
);
155213
}
156214

157215
function BBox({
158216
entry,
217+
bbox,
159218
active,
160219
onActivate,
161220
}: {
162221
entry: FieldEntry;
222+
bbox: BBoxRect;
163223
active: boolean;
164224
onActivate: (key: string | null) => void;
165225
}) {
166226
const ref = useRef<HTMLDivElement>(null);
167-
const { bbox } = entry.field;
168227

169228
useEffect(() => {
170229
if (active) ref.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
171230
}, [active]);
172231

173-
if (bbox === null) return null;
174232
return (
175233
<div
176234
ref={ref}

apps/playground/src/client/components/ResultPanel.tsx

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ interface ResultPanelProps {
1616

1717
export function ResultPanel(props: ResultPanelProps) {
1818
const { phase, live, result, error, hasFile, activeKey, onActivate } = props;
19+
// A failed run may still carry a partial extraction — show it under the error.
20+
const fields = result !== null ? result.fields : (error?.partial?.fields ?? null);
21+
const usage = result !== null ? result.usage : (error?.partial?.usage ?? null);
1922

2023
return (
2124
<div className="result-panel">
@@ -26,18 +29,18 @@ export function ResultPanel(props: ResultPanelProps) {
2629

2730
<div className="result-body">
2831
{error !== null && <ErrorBanner error={error} />}
29-
{error === null && result !== null && (
32+
{fields !== null && (
3033
<div className="tree">
31-
<FieldNode node={result.fields} path={[]} activeKey={activeKey} onActivate={onActivate} />
34+
<FieldNode node={fields} path={[]} activeKey={activeKey} onActivate={onActivate} />
3235
</div>
3336
)}
34-
{error === null && result === null && phase === 'running' && (
37+
{error === null && fields === null && phase === 'running' && (
3538
<LiveList entries={live} activeKey={activeKey} onActivate={onActivate} />
3639
)}
37-
{error === null && result === null && phase !== 'running' && <EmptyHint hasFile={hasFile} />}
40+
{error === null && fields === null && phase !== 'running' && <EmptyHint hasFile={hasFile} />}
3841
</div>
3942

40-
{error === null && result !== null && <ResultFooter result={result} />}
43+
{usage !== null && <ResultFooter usage={usage} issues={result?.issues ?? []} />}
4144
</div>
4245
);
4346
}
@@ -201,8 +204,7 @@ function EmptyHint({ hasFile }: { hasFile: boolean }) {
201204
);
202205
}
203206

204-
function ResultFooter({ result }: { result: SerializedResult }) {
205-
const { usage } = result;
207+
function ResultFooter({ usage, issues }: { usage: SerializedResult['usage']; issues: string[] }) {
206208
return (
207209
<div className="result-footer">
208210
<div className="stats">
@@ -212,13 +214,13 @@ function ResultFooter({ result }: { result: SerializedResult }) {
212214
<Stat label="Cost" value={usage.costUSD !== null ? formatUSD(usage.costUSD) : '—'} />
213215
<Stat label="Est. / 1k docs" value={usage.costUSD !== null ? formatUSD(usage.costUSD * 1000) : '—'} />
214216
</div>
215-
{result.issues.length > 0 && (
217+
{issues.length > 0 && (
216218
<details className="issues">
217219
<summary>
218-
{result.issues.length} provenance {result.issues.length === 1 ? 'note' : 'notes'}
220+
{issues.length} provenance {issues.length === 1 ? 'note' : 'notes'}
219221
</summary>
220222
<ul>
221-
{result.issues.map((issue, i) => (
223+
{issues.map((issue, i) => (
222224
<li key={i}>{issue}</li>
223225
))}
224226
</ul>
@@ -250,19 +252,24 @@ const ERROR_TITLES: Record<string, string> = {
250252

251253
function ErrorBanner({ error }: { error: ApiError }) {
252254
const title = (error.code !== null && ERROR_TITLES[error.code]) || error.name;
255+
const paths = error.missingPaths ?? [];
253256
return (
254257
<div className="error-banner">
255258
<div className="error-title">{title}</div>
256-
<div className="error-message">{error.message}</div>
257-
{error.missingPaths !== undefined && error.missingPaths.length > 0 && (
259+
{/* For missing-fields errors the message just repeats the paths list. */}
260+
{paths.length === 0 && <div className="error-message">{error.message}</div>}
261+
{paths.length > 0 && (
258262
<ul className="error-paths">
259-
{error.missingPaths.map((path) => (
263+
{paths.map((path) => (
260264
<li key={path}>
261265
<code>{path}</code>
262266
</li>
263267
))}
264268
</ul>
265269
)}
270+
{error.partial !== undefined && (
271+
<div className="error-message">Everything that was extracted is shown below.</div>
272+
)}
266273
</div>
267274
);
268275
}

0 commit comments

Comments
 (0)