Patterns / Document review
Document review: extracted values beside the page
An extraction agent reads a document, such as an invoice, a claim form or a contract, and returns values. Before they go anywhere, a person checks them against the page, and the check is only as good as their view of where each value came from. Candor draws the review. Your app renders the pages and your server keeps the record.
Render the pages with pdf.js in your app. getDocument loads the file, getPage takes a page, and getViewport({ scale: 1 }) gives its size in PDF points, which is what DocumentViewer's width and height take. Draw the page on a canvas at a larger scale so it stays sharp, and pass the result as the page's src (an image URL) or the canvas itself as content. DocumentViewer shows one page at a time and calls onPageChange when another is shown, so you can draw only the page on screen, as pdf.js's FAQ advises. Mark a page state: "loading" while it draws, and "error" with your reason when it can't be drawn.
ExtractionReview puts the fields beside the viewer. Each has the agent's value, a ConfidenceMark with what the confidence rests on, and the region the extraction recorded: selecting the field selects its box on the page, and selecting a box picks out its field. Nothing starts accepted. The person accepts a value or changes it; a changed value is marked "Changed by you" and the agent's value stays beside it. Send review passes onSubmit only the fields the person accepted or changed.
Hash what was accepted. changeHash gives the sha256 of a value in a canonical form that is the same in the browser and on the server. Send the reviews with their hash. Your server recomputes it from what arrived, refuses a mismatch, and stores the reviews and the hash with the document's id, so the record says exactly what the person accepted and a later edit to it no longer matches.
The components
- Document viewerProThe pages your app rendered, one at a time, with labelled boxes the keyboard moves between; boxes an agent proposed are named as the agent's (Pro).
- Extraction reviewProThe extracted fields beside the pages: value, confidence, where it came from, Accept or Change, and Send review with exactly what was decided (Pro).
- Confidence markProHow sure the extraction is, with what it rests on; unknown draws no bars (Pro).
- Change hashProThe sha256 of the reviews, the same in the browser and on the server (Pro).
- LockedFreeA decision the person can't make says why, through ExtractionReview's `locked`.
See it working
The code
Opening a PDF, drawing a page as an image URL, and turning an extraction's PDF rectangle into a DocumentViewer box.
import * as pdfjs from "pdfjs-dist"
import type { PDFDocumentProxy } from "pdfjs-dist"
import type { DocumentPage, RegionBox } from "@/components/candor/document-viewer"
// The worker must be exactly the same version as pdfjs-dist. How you point at it depends on your bundler.
pdfjs.GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/build/pdf.worker.mjs", import.meta.url).toString()
/** Opens a PDF and reads each page's size in PDF points, which DocumentViewer's width and height take. */
export async function openPdf(url: string): Promise<{ doc: PDFDocumentProxy; pages: DocumentPage[] }> {
const doc = await pdfjs.getDocument(url).promise
const pages: DocumentPage[] = []
for (let n = 1; n <= doc.numPages; n++) {
const { width, height } = (await doc.getPage(n)).getViewport({ scale: 1 })
pages.push({ id: String(n), width, height, state: "loading" })
}
return { doc, pages }
}
/** Draws one page on a canvas at twice its size, for high-density screens, and returns it as an image URL. */
export async function drawPage(doc: PDFDocumentProxy, n: number): Promise<string> {
const page = await doc.getPage(n)
const viewport = page.getViewport({ scale: 2 })
const canvas = document.createElement("canvas")
canvas.width = Math.floor(viewport.width)
canvas.height = Math.floor(viewport.height)
await page.render({ canvasContext: canvas.getContext("2d")!, viewport }).promise
const blob = await new Promise<Blob | null>((done) => canvas.toBlob(done))
if (!blob) throw new Error("The page couldn't be drawn.")
return URL.createObjectURL(blob)
}
/**
* A rectangle in PDF space ([x1, y1, x2, y2], from the bottom-left corner, as extraction tools report it) as a
* DocumentViewer box (from the top-left corner, in PDF points). The viewport at scale 1 also accounts for the page's
* rotation.
*/
export async function boxOf(doc: PDFDocumentProxy, n: number, rect: [number, number, number, number]): Promise<RegionBox> {
const [x1, y1, x2, y2] = (await doc.getPage(n)).getViewport({ scale: 1 }).convertToViewportRectangle(rect)
return { x: Math.min(x1, x2), y: Math.min(y1, y2), width: Math.abs(x2 - x1), height: Math.abs(y2 - y1) }
}The review: pages drawn as they're shown, a failure kept on its page, and the reviews sent with their hash.
"use client"
import * as React from "react"
import type { PDFDocumentProxy } from "pdfjs-dist"
import type { DocumentPage } from "@/components/candor/document-viewer"
import { ExtractionReview, type ExtractedField, type FieldReview } from "@/components/candor/extraction-review"
import { changeHash } from "@/lib/candor/change-hash"
import { drawPage, openPdf } from "@/lib/pdf-pages"
export function InvoiceReview({ documentId, url, title, fields }: { documentId: string; url: string; title: string; fields: ExtractedField[] }) {
const [pdf, setPdf] = React.useState<PDFDocumentProxy | null>(null)
const [pages, setPages] = React.useState<DocumentPage[]>([])
const [failed, setFailed] = React.useState<string | null>(null)
// Draws a page when it is shown, once; a failure is kept on the page with its reason.
const draw = (doc: PDFDocumentProxy, id: string) => {
setPages((all) => all.map((p) => (p.id === id ? { ...p, state: "loading", error: undefined } : p)))
drawPage(doc, Number(id)).then(
(src) => setPages((all) => all.map((p) => (p.id === id ? { ...p, src, state: undefined } : p))),
(e: unknown) => setPages((all) => all.map((p) => (p.id === id ? { ...p, state: "error", error: e instanceof Error ? e.message : undefined } : p)))
)
}
React.useEffect(() => {
let live = true
openPdf(url).then(
({ doc, pages }) => {
if (!live) return
setPdf(doc)
setPages(pages)
if (pages[0]) draw(doc, pages[0].id)
},
() => live && setFailed("This file couldn't be opened as a PDF.")
)
return () => {
live = false
}
}, [url])
const submit = async (reviews: FieldReview[]) => {
const hash = await changeHash({ documentId, reviews })
const res = await fetch(`/api/documents/${documentId}/review`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ reviews, hash }),
})
if (!res.ok) throw new Error(await res.text())
}
if (failed) return <p role="alert">{failed}</p>
if (!pdf) return <p role="status">Opening {title}…</p>
return (
<ExtractionReview
title={title}
pages={pages}
fields={fields}
agent="Intake agent"
onSubmit={submit}
onPageChange={(id) => {
if (!pages.find((p) => p.id === id)?.src) draw(pdf, id)
}}
onRetry={(id) => draw(pdf, id)}
/>
)
}The server recomputes the hash from what arrived before it stores the review.
import { changeHash } from "@/lib/candor/change-hash"
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const user = await currentUser() // your auth: who is asking, and may they review this document?
const { reviews, hash } = await request.json() // check the shape with zod before trusting it
// The same canonical form as the browser's, so the hash names exactly the reviews that arrived.
if ((await changeHash({ documentId: id, reviews })) !== hash) {
return new Response("The review doesn't match its hash. Reload and review again.", { status: 409 })
}
await saveReview({ documentId: id, reviews, hash, reviewer: user.id, at: new Date() }) // your own data layer
return new Response(null, { status: 204 })
}Install
Each component brings in what it depends on. The free ones install as they are; Pro and Premium ones need a licence key.
Good to know
- PDF space starts at the bottom-left corner of the page, and DocumentViewer's boxes at the top-left. Convert an extraction's rectangle with the page's viewport at scale 1:
convertToViewportRectangle([x1, y1, x2, y2])returns two corners in the viewport's coordinates, which also accounts for the page's rotation. Take the smaller x and y as the box's corner. - A box says where the extraction says it found the value. Candor doesn't compute or check it. When your extraction recorded no location, leave the field's
regionout and the field says "No location recorded"; don't draw a plausible box. - Pass confidence and its basis from your extraction. Leave them out when you have none: the field shows unknown confidence, never low or high.
- Left to the app: loading and bundling pdf.js and its worker (the same version for both), fetching the file from your own origin, the text layer, password-protected and damaged files, who may review a document (pass
lockedwith the reason when the person can't), saving the reviews, and what happens to the values afterwards. - The pdf.js calls here are the ones the PDF rendering pattern read from pdf.js's hello-world example on 9 November 2026, with
convertToViewportRectanglefrom its page viewport.page.render's parameters have changed between major versions, so check them, and the worker setup, in the version you install.