Osy#betaa language · its runtime Osyrin · a hosted platform
Why Osy#Built for agentsAgents as declarationsWorkflows that waitRuns exactly onceSecure by defaultNothing to mockThe editor is the compilerUI in the languageDocuments are dataOne program

Reference / UI

The PDF kit — a document viewer and a page thumbnail

PdfViewer(fileAsset: doc.Pdf.Id) · PdfThumbnail(fileAsset: doc.Pdf.Id, width: 96)

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.

stable1 example compiled by CIuicontrolsdocumentsstorage

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.

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

Signature#

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#

What you get, and what it costs#

SizeWhen it loads
The renderer425 KB + a 1.2 MB workerthe first document
Standard fonts762 KBthe first document that does not embed its own fonts
Scanned-image decoders1.5 MBthe 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#

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#

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:

pinasksfails when
minPlatformdoes this platform have what the kit's source names?the kit composes over something you do not have
contractVersioncan this platform host the kit's control ABI?your client's supported-contract set excludes it

The files, and what each is for#

FileWhat it is
pdf.osyThe control PdfViewer declaration — props, events, commands, the toolbar slot, the chunks. This is what your app compiles.
pdf-thumbnail.osyThe 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.tsThe viewer's shim. Yours to edit.
pdf-thumbnail.tsThe thumbnail's shim — a much shorter one: it draws a page and stops.
pdf-open.tsWhat 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.mjsBuilds the renderer, worker, font and decoder assets from your node_modules.
package.jsonThe build-time dependencies. None of them are runtime dependencies of your app.
package-lock.jsonThe 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.jsonType-checking only — the bundler strips types without looking at them, so this is what makes the shim answer to its generated ABI.
README.mdHow 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#

app CaseFiles {
  use Osysharp.Pdf@1;       // the viewer
  use Osysharp.Storage;     // FileAsset, where the document lives
  model "model/**/*.osy";
}
using Osysharp.Pdf;
using Osysharp.Storage;

What the app passes in — a row, not a URL#

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.

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:

PropWhat it does
fitwidth 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.
zoomA percentage of the fit scale, so 100 means "as fit says" rather than 1:1.
layoutcontinuous 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#

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.

Row(gap: Space.Gutter, align: Align.Center) {
  PdfThumbnail(fileAsset: file.Id, width: 30);
  Stack(gap: 0) { Strong(file.Name); FieldLabel(file.MimeType); }
}
PropWhat it does
fileAssetThe row, as the viewer takes it.
pageWhich page to picture. Almost always the first — but a cover sheet is a real thing, and so is picturing the page a search matched.
widthThe 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#

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#

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#

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#

Row(gap: Space.Gutter) {
  Box(width: "60%") {
    PdfViewer(fileAsset: application.Attachment.Id, layout: "single", fit: "page");
  }
  Box(width: "40%") { DecisionForm(caseFile: caseFile); }
}
PdfViewer(fileAsset: attachment.Pdf.Id, fit: "page");

See also#

Related

control — foreign UI controls (charts, grids, maps)

A `control` block declares the contract of a foreign UI widget — a chart, a data grid, a map — that a small JavaScript…

chunks — assets a control loads on demand

A `chunks { }` block declares assets a control ships but does not need at mount — a maths renderer, a diagram engine, a…

commands — the verbs a control accepts

A `commands { }` block declares the verbs a control accepts — the mirror of its events. An event is the control telling…

probe — what a control says about itself

A `probe { }` block declares the facts a control publishes about its OWN internal state, so an app's tests can ask for…

Cell template (your own content in a control's cell)

A control that paints cells — a grid — writes plain text in each one. A `slot <Field> { row => … }` block on the call…

The markdown editor kit — a rich editor you opt into

A full rich-text markdown editor — sections, partial saves, a block menu, tables, find and replace, maths and diagrams…

Osysharp.Ui (the UI kit)

The bundled UI kit — ready-made styled controls like `Button`, the shared design-system vocabularies (`Tone`, `Size`)…

File.Url

Turns an app-relative file path into the public URL a browser can fetch it from — `File.Url("public/x.png")` returns…

use

Declares a capability your app depends on, written inside the `app { }` manifest block. It provisions the capability…