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 itSignature#
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#
| 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#
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:
| 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#
| 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#
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:
| 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#
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); }
}| 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#
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#
- control — foreign UI controls (charts, grids, maps) — how a control is called, and what a slot is
- commands — the verbs a control accepts — the verbs a control publishes, and how a toolbar drives them
- chunks — assets a control loads on demand — why the renderer is not paid for at mount
- File.Url — the capability the document lives in
- Cell template (your own content in a control's cell) — putting a thumbnail (or anything else) inside a grid's cell
- The markdown editor kit — a rich editor you opt into — the other control-bearing kit
- use — what
usedoes, and why the version pin lives there