diff --git a/README.md b/README.md index 2062fbf..69355e1 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # extractkit +![extractkit playground — drop in a document, watch fields stream in, hover a field to highlight its source region on the page](./docs/demo.gif) + **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. > **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). diff --git a/apps/playground/src/client/App.tsx b/apps/playground/src/client/App.tsx index e22e133..0b60097 100644 --- a/apps/playground/src/client/App.tsx +++ b/apps/playground/src/client/App.tsx @@ -104,14 +104,15 @@ export function App() { } }, [file, modelId, schemaId]); - const entries = useMemo( - () => (run.result !== null ? fieldEntries(run.result.fields) : run.live), - [run.result, run.live], - ); - const boxes = useMemo( - () => entries.filter((e) => e.field.bbox !== null && e.field.page !== null), - [entries], - ); + const entries = useMemo(() => { + if (run.result !== null) return fieldEntries(run.result.fields); + // A failed run may still carry a partial extraction worth showing. + if (run.error?.partial !== undefined) return fieldEntries(run.error.partial.fields); + return run.live; + }, [run.result, run.error, run.live]); + // Include fields without model provenance: the PDF viewer can still locate + // their values in the page text layer. Value-less fields have nothing to find. + const boxes = useMemo(() => entries.filter((e) => e.field.value !== null), [entries]); return (
diff --git a/apps/playground/src/client/components/DocumentViewer.tsx b/apps/playground/src/client/components/DocumentViewer.tsx index 3e4f399..1ea12a1 100644 --- a/apps/playground/src/client/components/DocumentViewer.tsx +++ b/apps/playground/src/client/components/DocumentViewer.tsx @@ -1,11 +1,14 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import type { DragEvent } from 'react'; -import { GlobalWorkerOptions, getDocument } from 'pdfjs-dist'; +import { GlobalWorkerOptions, Util, getDocument } from 'pdfjs-dist'; import type { PDFDocumentLoadingTask, PDFPageProxy } from 'pdfjs-dist'; import workerSrc from 'pdfjs-dist/build/pdf.worker.min.mjs?url'; +import type { BBox as BBoxRect } from 'extractkit'; import type { FieldEntry } from '../lib/fields'; import { formatPath, formatValue } from '../lib/fields'; import { bboxToStyle } from '../lib/geometry'; +import type { TextSpan } from '../lib/snap'; +import { locateField } from '../lib/snap'; import { isPdf, pickFile } from '../lib/upload'; GlobalWorkerOptions.workerSrc = workerSrc; @@ -44,40 +47,79 @@ export function DocumentViewer(props: ViewerProps) { {isPdf(file) ? ( ) : ( -
- -
+ )}
); } -interface PageBoxProps { +/** A field placed on a specific page, with the bbox to draw. */ +interface PlacedBox { + entry: FieldEntry; + bbox: BBoxRect; +} + +interface OverlayProps { boxes: FieldEntry[]; - pageIndex: number; activeKey: string | null; onActivate: (key: string | null) => void; } -function ImagePage({ src, ...page }: { src: string } & PageBoxProps) { +function ImagePage({ src, boxes, activeKey, onActivate }: { src: string } & OverlayProps) { + // Images have no text layer to snap to; draw the model's own provenance. + const placed = useMemo( + () => + boxes + .filter((box) => box.field.bbox !== null && (box.field.page ?? 0) === 0) + .map((box) => ({ entry: box, bbox: box.field.bbox! })), + [boxes], + ); return ( -
- Uploaded document - +
+
+ Uploaded document + +
); } -function PdfView({ file, boxes, activeKey, onActivate }: { file: File } & Omit) { - const [pages, setPages] = useState([]); +/** Positioned text runs of a page, normalized 0–1 with a top-left origin. */ +async function pageTextSpans(page: PDFPageProxy): Promise { + const viewport = page.getViewport({ scale: 1 }); + const content = await page.getTextContent(); + const spans: TextSpan[] = []; + for (const item of content.items) { + if (!('str' in item) || item.str.trim() === '') continue; + const tx = Util.transform(viewport.transform, item.transform); + const fontHeight = Math.hypot(tx[2], tx[3]); + spans.push({ + text: item.str, + x0: tx[4] / viewport.width, + y0: (tx[5] - fontHeight) / viewport.height, + x1: (tx[4] + item.width * viewport.scale) / viewport.width, + y1: tx[5] / viewport.height, + }); + } + return spans; +} + +function PdfView({ file, boxes, activeKey, onActivate }: { file: File } & OverlayProps) { + const [pages, setPages] = useState<{ page: PDFPageProxy; spans: TextSpan[] }[]>([]); const [error, setError] = useState(null); + // Place every field: snap to the page text layer, rescuing fields whose + // model-reported bbox or page is missing or wrong. + const placedByPage = useMemo(() => { + const spansByPage = pages.map((p) => p.spans); + const byPage: PlacedBox[][] = pages.map(() => []); + for (const entry of boxes) { + const located = locateField(entry.field.value, entry.field.page, entry.field.bbox, spansByPage); + if (located !== null) byPage[located.page]!.push({ entry, bbox: located.bbox }); + } + return byPage; + }, [boxes, pages]); + useEffect(() => { let cancelled = false; let loadingTask: PDFDocumentLoadingTask | null = null; @@ -90,7 +132,10 @@ function PdfView({ file, boxes, activeKey, onActivate }: { file: File } & Omit

doc.getPage(i + 1)), + Array.from({ length: doc.numPages }, async (_, i) => { + const page = await doc.getPage(i + 1); + return { page, spans: await pageTextSpans(page) }; + }), ); if (cancelled) return; setPages(proxies); @@ -108,10 +153,10 @@ function PdfView({ file, boxes, activeKey, onActivate }: { file: File } & Omit

- {pages.map((page, index) => ( + {pages.map(({ page }, index) => (

- +
))}
@@ -143,12 +188,25 @@ function PdfCanvas({ page }: { page: PDFPageProxy }) { return ; } -function BoxLayer({ boxes, pageIndex, activeKey, onActivate }: PageBoxProps) { - const onPage = boxes.filter((box) => (box.field.page ?? 0) === pageIndex); +function BoxLayer({ + placed, + activeKey, + onActivate, +}: { + placed: PlacedBox[]; + activeKey: string | null; + onActivate: (key: string | null) => void; +}) { return (
- {onPage.map((box) => ( - + {placed.map(({ entry, bbox }) => ( + ))}
); @@ -156,21 +214,21 @@ function BoxLayer({ boxes, pageIndex, activeKey, onActivate }: PageBoxProps) { function BBox({ entry, + bbox, active, onActivate, }: { entry: FieldEntry; + bbox: BBoxRect; active: boolean; onActivate: (key: string | null) => void; }) { const ref = useRef(null); - const { bbox } = entry.field; useEffect(() => { if (active) ref.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); }, [active]); - if (bbox === null) return null; return (
@@ -26,18 +29,18 @@ export function ResultPanel(props: ResultPanelProps) {
{error !== null && } - {error === null && result !== null && ( + {fields !== null && (
- +
)} - {error === null && result === null && phase === 'running' && ( + {error === null && fields === null && phase === 'running' && ( )} - {error === null && result === null && phase !== 'running' && } + {error === null && fields === null && phase !== 'running' && }
- {error === null && result !== null && } + {usage !== null && }
); } @@ -201,8 +204,7 @@ function EmptyHint({ hasFile }: { hasFile: boolean }) { ); } -function ResultFooter({ result }: { result: SerializedResult }) { - const { usage } = result; +function ResultFooter({ usage, issues }: { usage: SerializedResult['usage']; issues: string[] }) { return (
@@ -212,13 +214,13 @@ function ResultFooter({ result }: { result: SerializedResult }) {
- {result.issues.length > 0 && ( + {issues.length > 0 && (
- {result.issues.length} provenance {result.issues.length === 1 ? 'note' : 'notes'} + {issues.length} provenance {issues.length === 1 ? 'note' : 'notes'}
    - {result.issues.map((issue, i) => ( + {issues.map((issue, i) => (
  • {issue}
  • ))}
@@ -250,19 +252,24 @@ const ERROR_TITLES: Record = { function ErrorBanner({ error }: { error: ApiError }) { const title = (error.code !== null && ERROR_TITLES[error.code]) || error.name; + const paths = error.missingPaths ?? []; return (
{title}
-
{error.message}
- {error.missingPaths !== undefined && error.missingPaths.length > 0 && ( + {/* For missing-fields errors the message just repeats the paths list. */} + {paths.length === 0 &&
{error.message}
} + {paths.length > 0 && (
    - {error.missingPaths.map((path) => ( + {paths.map((path) => (
  • {path}
  • ))}
)} + {error.partial !== undefined && ( +
Everything that was extracted is shown below.
+ )}
); } diff --git a/apps/playground/src/client/lib/snap.ts b/apps/playground/src/client/lib/snap.ts new file mode 100644 index 0000000..3359cdd --- /dev/null +++ b/apps/playground/src/client/lib/snap.ts @@ -0,0 +1,188 @@ +import type { BBox } from 'extractkit'; + +/** A positioned text run from the PDF text layer, normalized 0–1, origin top-left. */ +export interface TextSpan { + text: string; + x0: number; + y0: number; + x1: number; + y1: number; +} + +/** + * Textual forms a value may take on the page, most specific first. Numbers get + * plain, two-decimal, and thousands-separated renderings so `6000` can match + * "$6,000.00". + */ +export function candidateStrings(value: unknown): string[] { + if (typeof value === 'string') { + const trimmed = value.replace(/\s+/g, ' ').trim(); + return trimmed === '' ? [] : [trimmed]; + } + if (typeof value === 'number' && Number.isFinite(value)) { + const plain = String(value); + const fixed = value.toFixed(2); + const grouped = (s: string) => + s.replace(/^(-?)(\d+)/, (_, sign: string, int: string) => sign + int.replace(/\B(?=(\d{3})+(?!\d))/g, ',')); + const forms = [grouped(fixed), fixed, grouped(plain), plain]; + return [...new Set(forms)].sort((a, b) => b.length - a.length); + } + if (typeof value === 'boolean') return [String(value)]; + return []; +} + +interface LineChar { + span: TextSpan; + /** Fractional [start, end) position of this character within its span. */ + start: number; + end: number; +} + +interface Line { + text: string; + chars: (LineChar | null)[]; +} + +/** + * Groups spans into visual lines (reading order), concatenating their text. + * A space is inserted between spans separated by a visible horizontal gap; + * inserted spaces map to `null` in `chars`. + */ +export function buildLines(spans: TextSpan[]): Line[] { + const sorted = [...spans].sort((a, b) => (a.y0 + a.y1) / 2 - (b.y0 + b.y1) / 2 || a.x0 - b.x0); + const groups: TextSpan[][] = []; + for (const span of sorted) { + const center = (span.y0 + span.y1) / 2; + const group = groups.find((g) => { + const last = g[g.length - 1]!; + const tolerance = Math.max(span.y1 - span.y0, last.y1 - last.y0) * 0.6; + return Math.abs(center - (last.y0 + last.y1) / 2) <= tolerance; + }); + if (group !== undefined) group.push(span); + else groups.push([span]); + } + + return groups.map((group) => { + group.sort((a, b) => a.x0 - b.x0); + let text = ''; + const chars: (LineChar | null)[] = []; + for (const [i, span] of group.entries()) { + if (i > 0) { + const prev = group[i - 1]!; + const gap = span.x0 - prev.x1; + const charWidth = (span.x1 - span.x0) / Math.max(1, span.text.length); + if (gap > charWidth * 0.35 && !text.endsWith(' ')) { + text += ' '; + chars.push(null); + } + } + const width = span.x1 - span.x0; + for (let c = 0; c < span.text.length; c++) { + text += span.text[c]!; + chars.push({ + span, + start: span.x0 + (width * c) / span.text.length, + end: span.x0 + (width * (c + 1)) / span.text.length, + }); + } + } + return { text, chars }; + }); +} + +const isWordChar = (ch: string | undefined): boolean => ch !== undefined && /[0-9a-z]/i.test(ch); + +interface Match { + bbox: BBox; + candidateLength: number; +} + +function findMatches(candidate: string, lines: Line[]): Match[] { + const needle = candidate.toLowerCase(); + const matches: Match[] = []; + for (const line of lines) { + const haystack = line.text.toLowerCase(); + let from = 0; + for (;;) { + const at = haystack.indexOf(needle, from); + if (at === -1) break; + from = at + 1; + // Reject matches glued to surrounding word characters ("40" inside "$400"). + if (isWordChar(haystack[at - 1]) || isWordChar(haystack[at + needle.length])) continue; + const covered = line.chars.slice(at, at + needle.length).filter((c): c is LineChar => c !== null); + if (covered.length === 0) continue; + matches.push({ + bbox: { + x0: Math.min(...covered.map((c) => c.start)), + y0: Math.min(...covered.map((c) => c.span.y0)), + x1: Math.max(...covered.map((c) => c.end)), + y1: Math.max(...covered.map((c) => c.span.y1)), + }, + candidateLength: candidate.length, + }); + } + } + return matches; +} + +const center = (b: BBox): [number, number] => [(b.x0 + b.x1) / 2, (b.y0 + b.y1) / 2]; + +function distance(a: BBox, b: BBox): number { + const [ax, ay] = center(a); + const [bx, by] = center(b); + return Math.hypot(ax - bx, ay - by); +} + +/** + * Locates a value in a page's text layer: the occurrence of its longest + * matching textual form closest to the model's approximate bbox (or the first + * occurrence when the model gave none). Null when the text isn't on the page. + */ +export function matchBBox(value: unknown, bbox: BBox | null, spans: TextSpan[]): BBox | null { + if (spans.length === 0) return null; + const lines = buildLines(spans); + for (const candidate of candidateStrings(value)) { + const matches = findMatches(candidate, lines); + if (matches.length === 0) continue; + if (bbox === null) return matches[0]!.bbox; + matches.sort((a, b) => distance(a.bbox, bbox) - distance(b.bbox, bbox)); + return matches[0]!.bbox; + } + return null; +} + +/** + * Snaps a model-reported bbox to the actual text on the page; falls back to + * the model bbox when the value can't be located in the text layer. + */ +export function snapBBox(value: unknown, bbox: BBox | null, spans: TextSpan[]): BBox | null { + return matchBBox(value, bbox, spans) ?? bbox; +} + +export interface LocatedField { + page: number; + bbox: BBox; +} + +/** + * Places a field on a document by searching every page's text layer, starting + * from the model-reported page. Rescues fields the model failed to locate + * (null bbox/page) and fields with an out-of-range page index (e.g. 1-based). + * Falls back to the model's own provenance when the text can't be found; + * null when there is nothing usable to show. + */ +export function locateField( + value: unknown, + page: number | null, + bbox: BBox | null, + spansByPage: TextSpan[][], +): LocatedField | null { + const reported = page !== null && page >= 0 && page < spansByPage.length ? page : null; + const order = [...spansByPage.keys()].sort((a, b) => (a === reported ? -1 : b === reported ? 1 : a - b)); + for (const p of order) { + const match = matchBBox(value, p === reported ? bbox : null, spansByPage[p]!); + if (match !== null) return { page: p, bbox: match }; + } + if (bbox !== null) return { page: reported ?? 0, bbox }; + return null; +} diff --git a/apps/playground/src/server/app.ts b/apps/playground/src/server/app.ts index 79c1b8c..91387af 100644 --- a/apps/playground/src/server/app.ts +++ b/apps/playground/src/server/app.ts @@ -30,7 +30,10 @@ function toSerialized(result: ExtractResult): SerializedResult { function toApiError(err: unknown): ApiError { if (err instanceof ExtractKitError) { const mapped: ApiError = { name: err.name, code: err.code, message: err.message }; - if (err instanceof MissingRequiredFieldsError) mapped.missingPaths = err.missingPaths; + if (err instanceof MissingRequiredFieldsError) { + mapped.missingPaths = err.missingPaths; + mapped.partial = { data: err.partial.data, fields: err.partial.fields, usage: err.usage }; + } return mapped; } if (err instanceof Error) return { name: err.name, code: null, message: err.message }; diff --git a/apps/playground/src/server/models.ts b/apps/playground/src/server/models.ts index ff5e02d..0e1f923 100644 --- a/apps/playground/src/server/models.ts +++ b/apps/playground/src/server/models.ts @@ -63,6 +63,14 @@ const CATALOG: CatalogEntry[] = [ create: (id) => openai(id), pricing: { inputPerMTokUSD: 0.75, outputPerMTokUSD: 4.5 }, }, + { + id: 'gemini-3.5-flash', + label: 'Gemini 3.5 Flash', + provider: 'google', + apiKeyEnv: 'GOOGLE_GENERATIVE_AI_API_KEY', + create: (id) => google(id), + pricing: { inputPerMTokUSD: 1.5, outputPerMTokUSD: 9 }, + }, { id: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash', diff --git a/apps/playground/src/shared/api.ts b/apps/playground/src/shared/api.ts index 2e3d4c6..524416c 100644 --- a/apps/playground/src/shared/api.ts +++ b/apps/playground/src/shared/api.ts @@ -40,6 +40,14 @@ export interface SerializedResult { pages: number; } +/** What was extracted despite a failed run, so the client can still show it. */ +export interface PartialResult { + data: unknown; + /** FieldMap of the extracted leaves; missing required fields are absent. */ + fields: unknown; + usage: ExtractUsage; +} + /** A failed extraction, mapped from an extractkit or provider error. */ export interface ApiError { /** Error class name, e.g. "DocumentError" or "MissingRequiredFieldsError". */ @@ -49,6 +57,8 @@ export interface ApiError { message: string; /** Set on MissingRequiredFieldsError. */ missingPaths?: string[]; + /** Set on MissingRequiredFieldsError: the rest of the extraction. */ + partial?: PartialResult; } /** diff --git a/apps/playground/test/app.test.ts b/apps/playground/test/app.test.ts index 0330d4f..4bfddb4 100644 --- a/apps/playground/test/app.test.ts +++ b/apps/playground/test/app.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { ConfigResponse, ExtractEvent } from '../src/shared/api'; import { createApp } from '../src/server/app'; import { readSSE } from '../src/client/lib/sse'; -import { invoiceEnvelope, mockPlaygroundModel, tinyPng, unreadableEnvelope } from './helpers'; +import { invoiceEnvelope, missingTotalEnvelope, mockPlaygroundModel, tinyPng, unreadableEnvelope } from './helpers'; function appWith(envelope: string) { return createApp({ models: [mockPlaygroundModel('mock-model', envelope)] }); @@ -105,6 +105,26 @@ describe('POST /api/extract', () => { expect(terminal.error.code).toBe('DOCUMENT_UNREADABLE'); }); + it('carries the partial extraction on a missing-required-fields error', async () => { + const app = appWith(missingTotalEnvelope()); + const res = await app.request('/api/extract', { + method: 'POST', + body: extractForm({ file: pngFile(), schema: 'invoice', model: 'mock-model' }), + }); + const events = await collectEvents(res.body as ReadableStream); + const terminal = events.at(-1); + if (terminal?.type !== 'error') throw new Error('expected an error event'); + + expect(terminal.error.code).toBe('MISSING_REQUIRED_FIELDS'); + expect(terminal.error.missingPaths).toEqual(['$.total']); + const partial = terminal.error.partial; + expect(partial).toBeDefined(); + expect((partial?.data as { vendorName: string }).vendorName).toBe('Acme Corp'); + const fields = partial?.fields as { vendorName: { bbox: { x0: number } } }; + expect(fields.vendorName.bbox.x0).toBeCloseTo(0.08, 10); + expect(partial?.usage.modelCalls).toBeGreaterThan(0); + }); + it('rejects an unknown schema id with 400', async () => { const app = appWith(invoiceEnvelope()); const res = await app.request('/api/extract', { diff --git a/apps/playground/test/helpers.ts b/apps/playground/test/helpers.ts index b891355..b4320d1 100644 --- a/apps/playground/test/helpers.ts +++ b/apps/playground/test/helpers.ts @@ -84,6 +84,13 @@ export function unreadableEnvelope(): string { return JSON.stringify({ readable: false, issues: ['Page is blank.'], fields: invoiceFields() }); } +/** An invoice envelope where the required `total` was not found. */ +export function missingTotalEnvelope(): string { + const fields = invoiceFields(); + fields.total = wireLeaf(null); + return JSON.stringify({ readable: true, issues: [], fields }); +} + const DEMO_PRICING: Pricing = { inputPerMTokUSD: 3, outputPerMTokUSD: 15 }; /** A PlaygroundModel backed by a mock that streams `envelope`. */ diff --git a/apps/playground/test/snap.test.ts b/apps/playground/test/snap.test.ts new file mode 100644 index 0000000..04276fb --- /dev/null +++ b/apps/playground/test/snap.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest'; +import type { TextSpan } from '../src/client/lib/snap'; +import { candidateStrings, locateField, snapBBox } from '../src/client/lib/snap'; + +const span = (text: string, x0: number, y0: number, x1: number, y1: number): TextSpan => ({ + text, + x0, + y0, + x1, + y1, +}); + +describe('candidateStrings', () => { + it('renders numbers as plain, two-decimal, and thousands-grouped forms', () => { + expect(candidateStrings(6000)).toEqual(['6,000.00', '6000.00', '6,000', '6000']); + expect(candidateStrings(760)).toEqual(['760.00', '760']); + expect(candidateStrings(-1234.5)).toEqual(['-1,234.50', '-1234.50', '-1,234.5', '-1234.5']); + }); + + it('collapses whitespace in strings and skips empty values', () => { + expect(candidateStrings(' ACME CORP ')).toEqual(['ACME CORP']); + expect(candidateStrings('')).toEqual([]); + expect(candidateStrings(null)).toEqual([]); + expect(candidateStrings(Number.NaN)).toEqual([]); + }); +}); + +describe('snapBBox', () => { + const roughBox = { x0: 0.1, y0: 0.12, x1: 0.3, y1: 0.16 }; + + it('snaps to the span containing the value', () => { + const spans = [span('ACME CORPORATION', 0.12, 0.05, 0.4, 0.07)]; + expect(snapBBox('ACME CORPORATION', roughBox, spans)).toEqual({ + x0: 0.12, + y0: 0.05, + x1: 0.4, + y1: 0.07, + }); + }); + + it('matches case-insensitively and inside a longer span, trimming to the substring', () => { + const spans = [span('Currency: USD', 0.5, 0.2, 0.63, 0.22)]; + const snapped = snapBBox('usd', roughBox, spans); + // "USD" is the last 3 of 13 characters: starts 10/13 of the way in. + expect(snapped?.x0).toBeCloseTo(0.5 + 0.13 * (10 / 13), 5); + expect(snapped?.x1).toBeCloseTo(0.63, 5); + expect(snapped?.y0).toBe(0.2); + expect(snapped?.y1).toBe(0.22); + }); + + it('matches formatted currency amounts for numeric values', () => { + const spans = [span('$6,000.00', 0.8, 0.4, 0.9, 0.42)]; + const snapped = snapBBox(6000, roughBox, spans); + expect(snapped?.y0).toBe(0.4); + expect(snapped?.x0).toBeGreaterThan(0.8); // "$" is excluded + expect(snapped?.x1).toBeCloseTo(0.9, 5); + }); + + it('rejects matches glued to other word characters', () => { + const spans = [span('$400.00', 0.1, 0.1, 0.2, 0.12), span('40', 0.5, 0.5, 0.53, 0.52)]; + const snapped = snapBBox(40, { x0: 0.1, y0: 0.1, x1: 0.2, y1: 0.12 }, spans); + // "40" inside "$400.00" is not a valid match even though it is closer. + expect(snapped?.x0).toBe(0.5); + }); + + it('picks the occurrence nearest the model bbox when the value repeats', () => { + const spans = [ + span('$500.00', 0.8, 0.3, 0.9, 0.32), + span('$500.00', 0.8, 0.7, 0.9, 0.72), + ]; + const nearTop = snapBBox(500, { x0: 0.75, y0: 0.25, x1: 0.95, y1: 0.35 }, spans); + expect(nearTop?.y0).toBe(0.3); + const nearBottom = snapBBox(500, { x0: 0.75, y0: 0.65, x1: 0.95, y1: 0.75 }, spans); + expect(nearBottom?.y0).toBe(0.7); + }); + + it('matches values split across adjacent spans on one line', () => { + const spans = [ + span('ACME', 0.1, 0.05, 0.18, 0.07), + span('CORPORATION', 0.19, 0.05, 0.4, 0.07), + ]; + expect(snapBBox('ACME CORPORATION', roughBox, spans)).toEqual({ + x0: 0.1, + y0: 0.05, + x1: 0.4, + y1: 0.07, + }); + }); + + it('falls back to the model bbox when the text is not found', () => { + const spans = [span('Something else', 0.1, 0.1, 0.3, 0.12)]; + expect(snapBBox('missing value', roughBox, spans)).toEqual(roughBox); + expect(snapBBox('missing value', null, spans)).toBeNull(); + }); + + it('returns the model bbox untouched when there is no text layer', () => { + expect(snapBBox('anything', roughBox, [])).toEqual(roughBox); + }); + + it('uses the first occurrence when the model gave no bbox', () => { + const spans = [ + span('$500.00', 0.8, 0.3, 0.9, 0.32), + span('$500.00', 0.8, 0.7, 0.9, 0.72), + ]; + expect(snapBBox(500, null, spans)?.y0).toBe(0.3); + }); +}); + +describe('locateField', () => { + const page0 = [span('INVOICE', 0.4, 0.05, 0.6, 0.08), span('$760.00', 0.7, 0.5, 0.8, 0.52)]; + const page1 = [span('Terms and conditions', 0.1, 0.1, 0.5, 0.12)]; + const doc = [page0, page1]; + + it('rescues a field the model could not locate (null page and bbox)', () => { + expect(locateField(760, null, null, doc)).toEqual({ + page: 0, + bbox: { x0: expect.any(Number) as number, y0: 0.5, x1: 0.8, y1: 0.52 }, + }); + }); + + it('rescues an out-of-range page index (e.g. 1-based reporting)', () => { + const located = locateField('INVOICE', 2, { x0: 0.4, y0: 0.05, x1: 0.6, y1: 0.08 }, doc); + expect(located?.page).toBe(0); + expect(located?.bbox.y0).toBe(0.05); + }); + + it('searches other pages when the reported page has no match', () => { + const located = locateField('Terms and conditions', 0, null, doc); + expect(located?.page).toBe(1); + }); + + it('falls back to the model provenance when the text is not found anywhere', () => { + const bbox = { x0: 0.1, y0: 0.2, x1: 0.3, y1: 0.25 }; + expect(locateField('not on any page', 1, bbox, doc)).toEqual({ page: 1, bbox }); + }); + + it('returns null when nothing can be located and the model gave no bbox', () => { + expect(locateField('not on any page', null, null, doc)).toBeNull(); + expect(locateField(null, null, null, doc)).toBeNull(); + }); +}); diff --git a/docs/demo.gif b/docs/demo.gif new file mode 100644 index 0000000..0ef31f0 Binary files /dev/null and b/docs/demo.gif differ