# The PDF kit — a document viewer and a page thumbnail

> Two controls over a stored PDF: a reader with fit, zoom and a real text layer, and a thumbnail that draws one page as a picture. Shipped as an optional KIT you depend on with one line, not as a platform feature every app carries. This page says what you get, what it costs, which of the two you want, and the two lines that get them running.

<!-- id: ui-pdf-kit · area: ui · stability: stable · html: https://osysharp.com/reference/ui/pdf-kit/ -->

## Summary        {#summary}

`PdfViewer` puts a stored PDF on the screen: pages laid out to scroll or one at a time, a fit-to-width default, zoom
steps, and every page carrying transparent, selectable text over the rendering. You give it the **row** — a stored
file — and dress its toolbar with your own controls; it does the loading, the scaling and the painting.

`PdfThumbnail` is the other half: one page, at a width you choose, with no chrome and nothing to press — what a file
list wants beside a filename. The two share their renderer, so a page showing a list of thumbnails and then one open
document downloads it once.

It is a **kit**, so an app that never shows a document pays nothing for it. One line in the manifest and one `using`
turn it on.

```osy syntax
use Osysharp.Pdf@1;    // in app.osy — takes the dependency and pins the version
using Osysharp.Pdf;    // in the file that renders it
```

## Signature      {#signature}

```osy syntax
PdfViewer(
  fileAsset: doc.Pdf.Id,        // the stored file's row
  page: page,                   // which page is showing
  fit: "width",                 // width | page | actual
  zoom: 100,                    // a percentage of the fit scale
  layout: "continuous"          // continuous | single
)

PdfThumbnail(
  fileAsset: doc.Pdf.Id,        // the same row
  page: 1,                      // which page to picture
  width: 128                    // the drawn width in CSS pixels; the height follows the page
)
```

## Description    {#description}

### What you get, and what it costs    {#what-you-get}

| | Size | When it loads |
|---|---|---|
| The renderer | 425 KB + a 1.2 MB worker | the first document |
| Standard fonts | 762 KB | the first document that does not embed its own fonts |
| Scanned-image decoders | 1.5 MB | the first page holding a JBIG2 or JPEG 2000 image |

Nothing above is paid at mount, and nothing at all is paid by an app that does not depend on the kit.

The decoders are the entry worth reading twice. A **scanned** page — most of what an archive of correspondence holds
— is usually stored as JBIG2 or JPEG 2000, and the renderer degrades quietly when it cannot decode one: the page comes
out **blank** rather than failing. That is why they ship with the kit rather than being left as an option.

### Why it is a kit rather than a platform feature    {#why-a-kit}

Because most apps never show a document, and the ones that do should not make every other app carry a renderer.

There is a second reason, and it is the more interesting one. The browser will show a PDF on its own if you frame the
file — no kit, no bytes. What you get for free is *the browser's* viewer: chrome you cannot theme, behaviour that
differs between browsers, whatever accessibility that browser happens to give it, and nothing a test can read. A
canvas renderer draws the same everywhere, carries real text, and can be driven and asserted from your app's own
tests. For a document a citizen may need read aloud to them, that difference is the whole point.

### Where it lives, and how it is pinned    {#where-it-lives}

The kit travels with the platform: `use Osysharp.Pdf@1;` needs no network and no package install. Two pins guard it,
and they answer different questions:

| pin | asks | fails when |
|---|---|---|
| `minPlatform` | does this platform have what the kit's source names? | the kit composes over something you do not have |
| `contractVersion` | can this platform host the kit's control ABI? | your client's supported-contract set excludes it |

### The files, and what each is for    {#the-files}

| File | What it is |
|---|---|
| `pdf.osy` | The `control PdfViewer` declaration — props, events, commands, the toolbar slot, the chunks. This is what your app compiles. |
| `pdf-thumbnail.osy` | The `control PdfThumbnail` declaration, beside it. Two files rather than one because they are two controls, and a reader looking for either should find a file named after it. |
| `pdf.ts` | The viewer's shim. Yours to edit. |
| `pdf-thumbnail.ts` | The thumbnail's shim — a much shorter one: it draws a page and stops. |
| `pdf-open.ts` | What both shims need before they can draw: the renderer, and an open document. ONE copy of the signing round trip and the asset roots, because those are the parts that are security-shaped and two copies would drift on exactly them. |
| `scripts/build-chunks.mjs` | Builds the renderer, worker, font and decoder assets from your `node_modules`. |
| `package.json` | The build-time dependencies. None of them are runtime dependencies of your app. |
| `package-lock.json` | The EXACT versions those dependencies resolved to. Copy it with `package.json`: a kit that keeps its lock rebuilds the same bundle, and one that drops it picks up whatever the registry offers that day — a different bundle from the one that was tested, with nothing to say so. |
| `tsconfig.json` | Type-checking only — the bundler strips types without looking at them, so this is what makes the shim answer to its generated ABI. |
| `README.md` | How to build the kit and what each part is, for whoever picks it up next. |

### From an empty app to a working viewer — the sequence   {#sequence}

```osy title="the app declares the pinned dependency" syntax
app CaseFiles {
  use Osysharp.Pdf@1;       // the viewer
  use Osysharp.Storage;     // FileAsset, where the document lives
  model "model/**/*.osy";
}
```

```osy title="the page that uses it opens both namespaces" syntax
using Osysharp.Pdf;
using Osysharp.Storage;
```

### What the app passes in — a row, not a URL    {#app-side}

**Give it `fileAsset`, never a link.** A URL that lets a browser fetch a private file is a short-lived capability,
minted at the moment someone's authority is checked. Passed in as a prop it is minted when the *page* renders, which
is not when the document opens and not when the reader moves to a different one — so a viewer handed a link can be
holding one that has already lapsed. Handed the row instead, it asks for what it needs when it needs it, and the
answer is authorized by reading that file under the signed-in user's own authority. Your `FileAsset` security is the
whole access rule; the kit adds none of its own.

```osy title="a case file with its attachment on screen" test app=ui-pdf-kit
app CaseFiles {
  use Osysharp.Pdf@1;
  use Osysharp.Storage;
}

using Osysharp.Pdf;
using Osysharp.Storage;
using Osysharp.Ui;

entity CaseDocument {
  [MaxLength(200)] string Title;
  FileAsset Pdf;
}

[Page("/documents/{id}")] [AllowAnonymous]
component DocumentPage(Guid id) {
  live var doc = CaseDocument.Single(x => x.Id == id);
  int page = 1;
  int pages = 0;

  action OnPage(int n) { page = n; }
  action OnLoaded(int count) { pages = count; }

  render {
    Stack(gap: Space.SectionGap) {
      PageTitle(doc.Title);
      PdfViewer(fileAsset: doc.Pdf.Id, page: page, fit: "width",
                pageChanged: OnPage, loaded: OnLoaded) {
        // The viewer ships no toolbar. This is yours — your controls, your words, your order.
        slot Toolbar { c =>
          Row(gap: Space.Gutter, align: Align.Center) {
            Button("Previous", c.PreviousPage);
            Text($"Page {page} of {pages}");
            Button("Next", c.NextPage);
            Button("Zoom in", c.ZoomIn);
            Button("Zoom out", c.ZoomOut);
          }
        }
      }
    }
  }
}
```

Props worth knowing:

| Prop | What it does |
|---|---|
| `fit` | `width` fits the page across the space it was given — the reading default. `page` fits the whole page, which is unreadable in any panel narrower than a screen. `actual` is 1:1. |
| `zoom` | A percentage **of the fit scale**, so 100 means "as `fit` says" rather than 1:1. |
| `layout` | `continuous` scrolls the document; `single` shows one page, which is what a viewer beside a form wants, so a scroll gesture belongs to the page and not to the document. |

Events: `loaded(pageCount)` when the document opens, `pageChanged(page)` whenever the visible page changes — however
it changed, including by scrolling — and `failed(reason)` in words, because a document that will not open and a
document whose first page is genuinely white look identical.

Commands, for the toolbar you write: `NextPage`, `PreviousPage`, `ZoomIn`, `ZoomOut`, `ResetZoom`.

### The thumbnail, and when to reach for it instead    {#thumbnail}

`PdfThumbnail` draws one page and stops. No scroller, no paging, no zoom, no toolbar, no text layer — everything the
reader needs in order to be READ is exactly what a picture does not.

```osy title="a file row with its first page beside the name" syntax
Row(gap: Space.Gutter, align: Align.Center) {
  PdfThumbnail(fileAsset: file.Id, width: 30);
  Stack(gap: 0) { Strong(file.Name); FieldLabel(file.MimeType); }
}
```

| Prop | What it does |
|---|---|
| `fileAsset` | The row, as the viewer takes it. |
| `page` | Which page to picture. Almost always the first — but a cover sheet is a real thing, and so is picturing the page a search matched. |
| `width` | The drawn width in CSS pixels; the height follows the page's own aspect. A number rather than a size token because this is a raster: the value decides how many pixels are actually painted. |

It raises `loaded(pageCount)` — enough for a "3 pages" caption beside the picture — and `failed(reason)`.

**⚠ It downloads the document to make the picture.** There is no server-side rendering behind this: the bytes come
to the browser and page one is drawn there. That is right for a handful on screen and wrong for a list of five
hundred, for which the answer is a stored thumbnail — and the platform does not render one for a PDF yet. (It does
for an image: `Image.Thumbnail` produces a real stored file.)

**A failed thumbnail looks failed.** It keeps a page-shaped placeholder and carries the reason as its accessible
name, rather than collapsing to nothing — a 0×0 canvas and a document whose first page is blank are the same
picture, and a file list is exactly where that confusion costs someone an afternoon.

**Why two controls rather than one with a flag.** A `thumbnail: true` mode on the viewer would leave five of its
props, all five of its commands and two of its three events meaningless — a control where most of the surface
silently does nothing. The two share what is genuinely shared: one copy of the signing round trip, and the same
content-addressed renderer, so the second control costs a few kilobytes rather than a second copy of pdf.js.

### The theme tokens it reads    {#theme-tokens}

Both controls paint their surround from your theme, so they belong to your app rather than sitting in it as foreign
panels. They read `Colors.Bg`, `Colors.OnBg`, `Colors.TextMuted`, `Colors.Muted` (the thumbnail's placeholder when a
document will not open), `Shadow.Raised`, `Space.Gutter` and `Space.PagePad`. The page itself is the document's own
pixels and is not themed — a document is not yours to restyle.

### Forking it to change how it behaves   {#forking}

Declare a `control PdfViewer` of your own and it shadows the kit's, exactly as forking any kit control does. That is
the supported way to change the layout algorithm, add an annotation layer, or drop the decoders you do not need.

### Traps that cost real time    {#traps}

**A blank page is usually a missing decoder, not a broken document.** If a scan shows as an empty white page while a
text PDF renders, the file holds a JBIG2 or JPEG 2000 image and the decoders did not load — check that the kit's
asset chunks travelled with your build.

**`zoom` is relative to `fit`.** Setting `zoom: 100` does not mean 1:1; it means "whatever `fit` decided". For 1:1,
set `fit: "actual"`.

**Bind `page` to what the viewer reports, not to what you asked for.** Scrolling changes the visible page without
anyone pressing anything, so an app that only ever writes `page` and never listens to `pageChanged` will show a page
number that disagrees with the screen.

**Text you can see is not always text you can select.** A scanned document has no text layer of its own — the
renderer can only expose what the file contains. If selection and screen readers matter for scans, the documents need
to be run through text recognition before they are stored.

## Examples       {#examples}

```osy title="a compact viewer beside a decision form" syntax
Row(gap: Space.Gutter) {
  Box(width: "60%") {
    PdfViewer(fileAsset: application.Attachment.Id, layout: "single", fit: "page");
  }
  Box(width: "40%") { DecisionForm(caseFile: caseFile); }
}
```

```osy title="read-only, no toolbar at all — a preview in a list row" syntax
PdfViewer(fileAsset: attachment.Pdf.Id, fit: "page");
```

## See also       {#see-also}

- [control — foreign UI controls (charts, grids, maps)](https://osysharp.com/reference/ui/controls/) — how a control is called, and what a slot is
- [commands — the verbs a control accepts](https://osysharp.com/reference/ui/control-commands/) — the verbs a control publishes, and how a toolbar drives them
- [chunks — assets a control loads on demand](https://osysharp.com/reference/ui/control-chunks/) — why the renderer is not paid for at mount
- [File.Url](https://osysharp.com/reference/storage/file-url/) — the capability the document lives in
- [Cell template (your own content in a control's cell)](https://osysharp.com/reference/ui/cell-template/) — putting a thumbnail (or anything else) inside a grid's cell
- [The markdown editor kit — a rich editor you opt into](https://osysharp.com/reference/ui/markdown-editor-kit/) — the other control-bearing kit
- [use](https://osysharp.com/reference/types/use/) — what `use` does, and why the version pin lives there
