# Document viewer

A document's pages, which the app renders, with labelled regions over them. Candor draws no PDF itself: each page is an image URL, or a canvas or other element the app passes (render it with pdf.js, an image service or anything else), sized by the page's own `width` and `height`, and each region is a box in those same units. One page shows at a time, with Previous and Next page and zoom against the page fitted to the width.

The regions are one stop for Tab. Arrow keys or J and K move between them in the order given, across pages, with a visible focus, and each is read by its label, its kind and, for one an agent proposed, the agent's name; Enter, Space or a click selects one and reports it through `onRegionSelect`. Selecting a region from outside (`selected`) turns to its page. A page still being drawn says so, and one the app couldn't draw says why and can offer Try again; its regions wait for it rather than floating over nothing. The page is not mirrored in right-to-left text, because it is the document's own layout; the controls are. Nothing animates.

- Registry name: `document-viewer` (`@candor/document-viewer`)
- Tier: Pro. Installing it needs a Pro, Premium or Team licence key: the person runs `npx candor-ui key <key>` themselves; never ask for the key in chat or write it to a file.
- Docs: https://candor.themethodic.com/docs/document-viewer

## Install

```bash
npx candor-ui add document-viewer
```

Run `npx candor-ui@latest init` once first (https://candor.themethodic.com/get-started.md). Without the CLI: `npx shadcn@latest add @candor/document-viewer`.
It brings in: button, locale, locked, skeleton, tokens, utils.
npm packages: lucide-react@^1.67.0.

## Example

```tsx
"use client"

import * as React from "react"
import { DocumentViewer, type DocumentPage, type DocumentRegion } from "@/components/candor/document-viewer"

// In an app each page is drawn by its renderer (pdf.js onto a canvas, or an image from its server). Here an SVG stands
// in for the rendered page, in the page's own units: 612 × 792, a US Letter page in PDF points.
function InvoicePage() {
  return (
    <svg viewBox="0 0 612 792" aria-hidden className="bg-white text-neutral-800">
      <text x="48" y="80" fontSize="28" fontWeight="600" fill="currentColor">Invoice</text>
      <text x="400" y="74" fontSize="12" fill="currentColor">Date</text>
      <text x="400" y="96" fontSize="14" fill="currentColor">3 November 2026</text>
      <text x="48" y="150" fontSize="12" fill="currentColor">Bill to</text>
      <text x="48" y="172" fontSize="14" fill="currentColor">Northwind Traders Ltd</text>
      <text x="48" y="192" fontSize="12" fill="currentColor">12 Harbour Road, Dublin</text>
      <rect x="48" y="250" width="516" height="200" fill="none" stroke="currentColor" strokeOpacity="0.3" />
      {[0, 1, 2, 3].map((i) => (
        <rect key={i} x="60" y={276 + i * 40} width={300 - i * 40} height="10" fill="currentColor" fillOpacity="0.15" />
      ))}
      <text x="400" y="560" fontSize="12" fill="currentColor">Total due</text>
      <text x="400" y="590" fontSize="20" fontWeight="600" fill="currentColor">€4,820.00</text>
    </svg>
  )
}

function TermsPage() {
  return (
    <svg viewBox="0 0 612 792" aria-hidden className="bg-white text-neutral-800">
      <text x="48" y="80" fontSize="20" fontWeight="600" fill="currentColor">Terms</text>
      {Array.from({ length: 14 }, (_, i) => (
        <rect key={i} x="48" y={110 + i * 22} width={i % 4 === 3 ? 320 : 516} height="8" fill="currentColor" fillOpacity="0.15" />
      ))}
      <line x1="60" y1="730" x2="260" y2="730" stroke="currentColor" strokeOpacity="0.5" />
      <text x="60" y="748" fontSize="11" fill="currentColor">Signed for Northwind Traders Ltd</text>
    </svg>
  )
}

const pages: DocumentPage[] = [
  { id: "p1", width: 612, height: 792, content: <InvoicePage />, alt: "Invoice INV-2041, page 1" },
  { id: "p2", width: 612, height: 792, content: <TermsPage />, alt: "Invoice INV-2041, page 2" },
  { id: "p3", width: 612, height: 792, state: "error", error: "The renderer stopped on this page: the file is damaged after page 2." },
]

const regions: DocumentRegion[] = [
  { id: "date", page: "p1", box: { x: 392, y: 58, width: 170, height: 48 }, label: "Invoice date", kind: "field", proposed: true },
  { id: "customer", page: "p1", box: { x: 40, y: 134, width: 260, height: 66 }, label: "Customer", kind: "field", proposed: true },
  { id: "lines", page: "p1", box: { x: 48, y: 250, width: 516, height: 200 }, label: "Line items", kind: "table", proposed: true },
  { id: "total", page: "p1", box: { x: 392, y: 544, width: 170, height: 56 }, label: "Total due", kind: "field", proposed: true },
  { id: "signature", page: "p2", box: { x: 52, y: 690, width: 220, height: 66 }, label: "Signature", kind: "signature" },
]

export default function Example() {
  const [selected, setSelected] = React.useState<string | null>(null)
  const label = regions.find((r) => r.id === selected)?.label
  return (
    <div className="flex flex-col gap-2">
      <DocumentViewer
        label="Invoice INV-2041.pdf"
        pages={pages}
        regions={regions}
        agent="Intake agent"
        selected={selected}
        onRegionSelect={(r) => setSelected(r.id)}
        className="h-[32rem] max-w-xl"
      />
      <p className="text-sm text-muted-foreground">{label ? `Selected: ${label}` : "Nothing selected yet."}</p>
    </div>
  )
}
```

## <DocumentViewer>

Props:

- `pages: DocumentPage[]`: The pages, in order.
- `regions?: DocumentRegion[] | undefined` (default `[]`): The labelled regions, in reading order: the keyboard follows it. A region on a page not in `pages` is left out.
- `selected?: string | null | undefined`: The selected region's id, when the app holds the selection (to link it with a list, say). `null` for none.
- `onRegionSelect?: ((region: DocumentRegion) => void) | undefined`: Called with the region the person selected, by click, Enter or Space.
- `defaultPage?: string | undefined`: The id of the page to open on. Defaults to the first.
- `onPageChange?: ((pageId: string) => void) | undefined`: Called with a page's id each time the page shown changes (not for the first one), so the app can draw only the page on screen, as pdf.js advises. Mark a page `state: "loading"` while it draws.
- `onRetry?: ((pageId: string) => void) | undefined`: Adds Try again to a page that couldn't be drawn; called with its id.
- `agent?: string | undefined`: The agent that proposed regions, named by the app ("Intake agent"). Never model text. Left out, they say "an agent".
- `label?: string | undefined`: The document's name, as the viewer's accessible name ("Invoice INV-2041.pdf"). Left out, it reads "Document".
- `className?: string | undefined`

## Strings (`documentViewer`)

The fixed words, English by default. Override any of them under `documentViewer` in `<LocaleProvider strings={{ documentViewer: { … } }}>`; the table is exported as `documentViewerStrings`.

- `label`: Document
- `page`: Page {page} of {count}
- `previousPage`: Previous page
- `nextPage`: Next page
- `firstPage`: This is the first page.
- `lastPage`: This is the last page.
- `zoomIn`: Zoom in
- `zoomOut`: Zoom out
- `fitWidth`: Fit to width
- `zoomLevel`: Zoom {percent}
- `mostZoomed`: Zoomed in as far as it goes.
- `leastZoomed`: Zoomed out as far as it goes.
- `fitted`: The page already fits the width.
- `regions`: Regions on page {page}
- `regionCount`: { zero: "No regions on this page", one: "{count} region on this page", other: "{count} regions on this page" }
- `keys`: Arrow keys or J and K move between regions, Home and End go to the first and last, Enter selects.
- `regionKind`: {label}, {kind}
- `proposedBy`: {name}, proposed by {agent}
- `proposedUnnamed`: {name}, proposed by an agent
- `proposedMark`: {agent}'s
- `proposedMarkUnnamed`: Agent's
- `field`: field
- `table`: table
- `signature`: signature
- `text`: text
- `figure`: figure
- `loading`: Page {page} is loading.
- `error`: Page {page} couldn't be shown.
- `imageError`: The image for page {page} didn't load.
- `retry`: Try again
- `regionsWait`: { one: "Its region shows once the page is drawn.", other: "Its {count} regions show once the page is drawn." }
- `empty`: There are no pages to show.

## Types

### RegionKind

What kind of thing a region marks. Left out, a region is only its label.

```ts
"field" | "table" | "signature" | "text" | "figure"
```

### RegionBox

A rectangle in a page's own coordinates (the same units as the page's `width` and `height`), from its top-left corner.

```ts
{
  /** From the page's left edge. */
  x: number;
  /** From the page's top edge. */
  y: number;
  width: number;
  height: number;
}
```

### DocumentPage

One page of the document, drawn by the app.

```ts
{
  /** Stable for this page; regions name it in `page`. */
  id: string;
  /** The page's width in its own units: PDF points, or the image's pixels. Regions use the same units. */
  width: number;
  /** The page's height in the same units. */
  height: number;
  /** An image of the page the app rendered (a PNG from its server, a data URL from pdf.js). */
  src?: string | undefined;
  /**
   * What the app drew instead of an image: a canvas, an `<img>`, an SVG, any element. It fills the page's box, so size
   * it to 100% of its width and height. Used when `src` is left out.
   */
  content?: React.ReactNode | undefined;
  /** The page's text alternative, e.g. "Invoice INV-2041, page 1". Left out, the page is named by its number. */
  alt?: string | undefined;
  /** "loading" while the app is still drawing it, "error" when it couldn't. Left out, the page is ready. */
  state?: "loading" | "error" | undefined;
  /** Why the page couldn't be drawn, in the app's words ("The file is password-protected."), shown under the error. */
  error?: string | undefined;
}
```

### DocumentRegion

A labelled area of a page.

```ts
{
  /** Stable for this region; `selected` and `onRegionSelect` use it. */
  id: string;
  /** The id of the page it is on. */
  page: string;
  /** Where it is, in the page's own coordinates. */
  box: RegionBox;
  /** What it is, e.g. "Invoice total". Shown as text, never as HTML. */
  label: string;
  /** What kind of thing it marks: a field, a table, a signature, a block of text or a figure. */
  kind?: RegionKind | undefined;
  /**
   * True when an agent proposed the region (an extraction says it found something there), rather than the document
   * or a person marking it. It is drawn dashed and named as the agent's, because it is the agent's claim about the page.
   */
  proposed?: boolean | undefined;
}
```

## Rules for using Candor UI

- Components take plain typed data. Never pass model output as HTML or render it as a link; messages are typed blocks.
- A control that can't be used says why: wrap it in `<Locked reason="…">`, don't just disable it.
- Don't show a value you don't have: leave it out (it renders as —) rather than guess. An estimate is labelled as one.
- Nothing is pre-selected in a decision a person makes, and the agent is named from your app's data, never from model output.
- Your app's words go in `LocaleProvider`: its fixed strings in `strings`, its nouns in `nouns`; entity kinds and agent roles in `EntityKindsProvider` and `AgentRolesProvider`.
