Technical Documentation: embedpdf/embed-pdf-viewer
βΉοΈ Provenance: Multi-Tier Ingestion Β· CodeWiki Reference
Overview (README.md)
[!WARNING]
EmbedPDF v3 is under active development and is not yet recommended for production use. For the current stable release, use thev2branch.
OpenβSource JavaScript PDF Viewer
EmbedPDF is a frameworkβagnostic, Apacheβ2.0βlicensed PDF viewer that drops into any JavaScript project. Whether you build with React, Vue, Svelte, Preact, or vanilla JS, EmbedPDF delivers a smooth, modern reading experience and a clean developer API.
π Documentation
Full docs, installation guides, API reference, and examples:
π Live Demo
Try it now β load your own PDF or use the sample:
π Sponsors
We are grateful for the support of our sponsors!
Silver Sponsors
Bronze Sponsors
β¨ Features
- Annotations (highlight, sticky notes, free text, ink)
- True redaction (content is actually removed)
- Search, text selection, zoom, rotation
- Smooth, virtualized scrolling
- Pluggable architecture & tree-shakable plugins
π€ Contributing
We love contributions! To get started, read our contributing guide and jump into the GitHub discussions.
π License
Everything in this repository is licensed under the Apache License, Version 2.0, with one exception: cloudpdf/server β the self-hostable CloudPDF server β is Fair Source, under the Fair Core License, FCL-1.0-ALv2. Its source may be inspected, copied, and modified for purposes permitted by the FCL. That does not make the server free to run: while a release remains under the FCL, running or self-hosting it requires a valid CloudPDF license (a license key for a connected deployment or a signed certificate for an air-gapped deployment). You may not move, change, disable, or circumvent the license-key functionality; enable protected functionality without a valid license; or remove protected functionality. Competing Uses, as defined in the FCL, are also prohibited. Each release automatically becomes Apache-2.0 two years after publication. See LICENSING.md for the full licensing map, including website content.
Third-Party Licenses
The engine is powered by EmbedPDF Runtime, our fork of PDFium, which is licensed under the Apache License, Version 2.0. PDFium is a Google project; EmbedPDF is not affiliated with or endorsed by Google.
Conventions README (`docs/conventions/README.md`)
Conventions
How the EmbedPDF client stack is built, and the rules every change follows.
These documents describe the code as it is. When the code and a document
disagree, fix one of them in the same pull request.
Start with this page, then read architecture.md.
The model
A plugin provides one capability per scope (the workspace, or each open
document). The capability is its only public surface: synchronous reads,
verbs that do work, and events. A plugin talks to the engine only throughctx.doc, and to other plugins only through their capabilities.
Four kinds of state
| Kind | What it is | Home | How it changes |
|---|---|---|---|
| Mirror | A local copy of data the engine owns | ctx.mirror(spec), ctx.pageMirror(spec) |
Only by folding confirmed engine events (every origin) and by loads |
| Pending change | A change this session staged that the engine hasn't answered | the document's change queue (ctx.changes), shown by mirrors' view() |
Staged by a verb or gesture; leaves the views once answered and held, or refused |
| Session | State the client owns that is not in the PDF | ctx.state |
Only through named pure transitions in model.ts |
| Resource | Handles, rasters, workers, registries of functions, caches | closures, owned through ctx.cleanup and ctx.acquire |
Any way; readers are woken with ctx.notify() |
Three kinds of events
| Kind | Meaning | Fired from |
|---|---|---|
| Fact | Something changed in the document | Where the confirmed document event is applied, with event.origin |
| State change | A session value changed | The plugin's one ctx.state.onChange listener |
| Occurrence | An operation ran, finished or failed | Where the operation reaches that point |
Core rules
- A capability is a plugin's only public surface. Other plugins and adapters
read through it, never through a plugin's state. - Every piece of state has one kind and one home: mirror, pending change,
session or resource. State is plain data; everything else is a resource. - A mirror changes only by its fold and by loads. Its fold is pure and the
same for every origin. Verbs never write it. - Session state changes only through named pure transitions applied with
ctx.state.update. A transition that returns the same object changes
nothing. - A verb calls the engine and returns. The engine publishes the confirmed
event before the call resolves, so the verb'sawaitalready sees its own
write: no refetch, no waiting, no hand-made events. - Every event fires from exactly one place. Fact events carry
event.originas it is; no plugin builds an origin by hand. Loads announceonResynced. - Reads are synchronous, pure and identity-stable (
memo,memoByKey).
They never start engine work. - A plugin declares every token it resolves in
requiresoroptional, and
imports sibling tokens from their/contractentries. - Registrations with siblings and subscriptions happen in
connect, and
everything acquired is owned:ctx.cleanup,ctx.listen,ctx.acquire. - A capability rejects with
PluginErroronly, and every verb that can
refuse has acan*twin. - Names and comments follow
naming.mdandcomments.md.
Map
| File | Covers |
|---|---|
architecture.md |
Layers, scopes and bring-up, the four kinds of state, data flow, reads, reactions, pure cores, plugin anatomy |
state-and-sync.md |
ctx.state, notify, watch, waitFor, mirrors, page mirrors, pending changes, resources, reactions, the sync rules |
events.md |
Fact, state-change and occurrence events, event sources, naming, EventOrigin, onResynced, subscribing |
plugins.md |
Plugin files, manifest, tokens and contracts, package entries, controller, connect, dependencies, lifetime, errors, a complete example |
testing.md |
Where tests live, the test context, fake engines, mirror tests, kernel tests, conformance suites, type tests |
naming.md |
Identifiers, the glossary, type suffixes, the capability vocabulary |
comments.md |
What a comment may say, and how it is checked |
packages.md |
Where a package lives and what it is called |
permissions.md |
Authorization-aware plugins: can* twins, gates, per-record authority |
viewer-doors.md |
One viewer, several deliveries: the entry points that choose the engine |
engine-services.md |
Layout of @embedpdf/engine-services: readers, mutators, the worker host |
runtime-confinement.md |
Thread-confined PDFium: per-thread state, the worker pool, the threading gate |
engine-host-isolation.md |
Running engines in supervised child processes, and the threat model |
server-adapters.md |
The CloudPDF server's adapter families: secrets, KMS, storage, CDN, import |
server-migrations.md |
Forward and rollback database migrations for the CloudPDF server |
docs-architecture.md |
How the documentation site renders one source per framework |
Package build and export rules are intooling/build/README.md. Changesets are
described in .agents/skills/embedpdf-changesets/SKILL.md.
Content Engine Index (`docs/content/engine/index.mdx`)
title: Engine
import { Cards, Card } from '@/components/docs/cards';
The PDF engine
The EmbedPDF engine opens PDF files in the browser and lets you render their pages, read and
search their text, add annotations, fill forms, edit pages and save the result. It runs PDFium,
the PDF library inside Chrome, in a Web Worker, so your page stays responsive. Nothing is
uploaded: the file never leaves the device.
The ready-made viewer and the headless components are built on this engine. Use it directly
when you want PDF features without a viewer: a thumbnail job, a form filler, an export script.
The CloudPDF engine gives your app a full PDF engine over HTTPS. Your files stay on your
CloudPDF server. The engine renders their pages, reads and searches their text, stores
annotations and form values, and checks every call against the permissions in the user's
token.
The CloudPDF viewer and the headless components are built on this engine. Use it directly when
you want PDF features without a viewer: a thumbnail job, a form filler, an export script.
import { localEngine } from '@embedpdf/engine';
const engine = localEngine();
const doc = await engine.open({ kind: 'bytes', bytes });
const { pages } = await doc.pages.list();
const image = await doc.page(pages[0].ref).render.image();import { cloudEngine } from '@cloudpdf/engine';
const engine = cloudEngine();
const doc = await engine.open({ kind: 'token', token: () => fetchDocumentToken(documentId) });
const { pages } = await doc.pages.list();
const image = await doc.page(pages[0].ref).render.image();What you can do
- Render pages as images, at any size.
- Read, search and
select the text on a page, and find out where
each character is. - Add and edit annotations: highlights, notes, shapes, stamps,
measurements. Copy them to another document with
import & export. - Fill forms, create form fields and
sign documents. - Change pages: rotate, reorder, delete, insert, merge and
split. - Redact content for good, and
save the result. - Group edits into changes that apply all or none, and undo
or redo any of them.
Every call is async, can be cancelled, and fails with a typed error you can branch on. The same
code runs on the local engine and on the CloudPDF engine: only how you create the engine and
open a file differs.
A few things exist only on the local engine: its fonts, warmup(), raw pixels, and saving a
document's layer or to a file. A document opened with localEngine() has them. Code that takes
any engine checks first:
import { isLocalDocument } from '@embedpdf/engine';
if (isLocalDocument(doc)) {
const layer = await doc.downloadLayer();
}isLocalEngine(engine) does the same for an engine.
Local or cloud
This engine runs in the browser: no server, no upload, and it works offline. That makes it the
right choice when the user already has the file, or when the file may not leave their device.
<CloudPdfCallout
title="Keep the file on your server"
href="https://www.cloudpdf.com/docs/engine"
cta="Read about the CloudPDF engine"
When the file lives on your server, the CloudPDF engine runs the same API there and checks every
call. A user sees the first page as an image, without downloading the whole PDF or the engine
(about 3 MB compressed), and a user who may not download the file never receives it. Your code
stays the same: swap localEngine() for cloudEngine().
Next steps
Content Headless Index (`docs/content/headless/index.mdx`)
title: Headless
import { Cards, Card } from '@/components/docs/cards';
{/* Served at β¦/headless/<framework> with NO trailing slash: a relative href
resolves against β¦/headless/, so pages are quick-start,
viewing/stage, concepts/how-it-works. */}
Build your own PDF viewer
The headless packages give you everything a PDF viewer does, without a look of their own: pages
that scroll and zoom, crisp rendering, text selection and search, annotations, forms and
signatures. You build the interface in
it there.
That's a complete viewer: the pages scroll, zoom with a pinch or Ctrl and the wheel, and stay sharp
at every zoom. Everything else is a plugin you add, and a few lines of your own UI.
Headless or the ready-made Viewer?
EmbedPDF comes in two forms, built on the same engine and plugins:
- The Viewer is finished: a toolbar, sidebars, annotation tools and search,
ready in minutes. You configure it and theme it. - Headless is the parts: you decide what the toolbar looks like, where the search box goes, and
what happens when someone clicks an annotation.
Choose headless when the viewer is part of your product's own interface. You can start with the
Viewer and move to headless later: the plugins and their options are the same.
How it's put together
A headless viewer has five parts. You meet them on every page:
- The engine opens and reads PDF files. It runs in the browser, or on your server with
CloudPDF. <Viewer>starts the engine, opens documents, and holds the plugins.- Plugins add one feature each: search, annotations, forms. Register the ones you need; the
rest never reach your bundle. - Layers draw inside each page: the page itself, the text selection, the annotations.
Hooks Composables Functions Services connect your own UI: <Fw only={['react', 'vue', 'svelte']}>useSearch()
to search,useSearchState()to show the results,useSearchEvent()to react when something
happens.inject(EpdfSearch)gives you the plugin's methods to search,
its values as signals to show the results (search.hitCount()), and its events as streams to
react when something happens (search.completed$).
How it works explains each in a few minutes.
Every plugin
Each plugin is one import line. Registering a plugin and importing its components come from the
same place:
| Plugin | What it gives you | Import from |
|---|---|---|
| Stage | Scrolling, zoom, layouts, page navigation | @embedpdf/react/stage |
| Render | Sharp pages at every zoom, deep-zoom tiles | @embedpdf/react/render |
| Links | Clickable links to pages and websites | @embedpdf/react/link |
| Selection | Selecting and copying text | @embedpdf/react/selection |
| Search | Finding text, and showing every match | @embedpdf/react/search |
| Annotations | Highlights, notes, shapes, free text, and your own tools | @embedpdf/react/annotation |
| Stamps | Libraries of stamps, placed by click or by code | @embedpdf/react/stamp |
| Measurements | Distances, perimeters and areas at a real scale | @embedpdf/react/measurement |
| Redaction | Removing content for good | @embedpdf/react/redaction |
| Forms | Filling and building PDF forms | @embedpdf/react/form |
| Signatures | Signing documents and checking signatures | @embedpdf/react/signature |
| Page edit | Rotating, reordering, inserting and deleting pages | @embedpdf/react/page-edit |
| Metadata | A document's title, author and your own fields | @embedpdf/react/metadata |
| Actions | What a PDF does by itself: buttons, scripts, printing | @embedpdf/react/actions |
| View manager | Several documents side by side | @embedpdf/react/view-manager |
| Interaction | The active tool, cursors and touch | @embedpdf/react/interaction |
| Commands | Named actions with labels, icons and shortcuts | @embedpdf/react/commands |
| Shell | Which panels, dialogs and menus are open | @embedpdf/react/shell |
| Translations | Your UI in the user's language | @embedpdf/react/i18n |
Local or cloud
The same viewer runs on either engine. Only how you create the engine and open a document differs;
every plugin,
The local engine runs in the browser: no server, no upload, and it works offline. It's the right
choice when the user already has the file, or when the file may not leave their device.
<CloudPdfCallout
title="Show a 500-page PDF before it downloads"
href="https://www.cloudpdf.com/docs/headless"
cta="Build your viewer on CloudPDF"
With the CloudPDF engine, the file stays on your server and pages arrive as images, so the first
page shows at once, without downloading the whole PDF or the engine. Every call is checked on the
server, several people can review one document together, and every change is stored as it's made.
Your viewer stays the same: swap localEngine() for cloudEngine().
The CloudPDF engine keeps the file on your server. Pages arrive as images, so the first page
shows at once; every call is checked against the user's token; and annotations and form values are
stored as people make them, with everyone who has the document open seeing each other's changes.
Next steps
Content Viewer Index (`docs/content/viewer/index.mdx`)
title: Viewer
import { Cards, Card } from '@/components/docs/cards';
{/* Served at β¦/viewer/<framework> with NO trailing slash: a relative href
resolves against β¦/viewer/, so pages are quick-start,
customize/layout, concepts/how-it-works. */}
A complete PDF viewer, ready to embed
The viewer is everything people expect from a PDF app, in one tag: a toolbar, thumbnails, search,
annotations, comments, forms, signatures and stamps. It fits a phone and a wide screen, speaks the
user's language, and works with a keyboard and a screen reader.
That's the whole integration. The rest of these docs are about making it yours.
Make it yours
Everything you change is a few lines of config, the same in every framework. Start at the top and
stop when it's enough:
| You want to | Change | Page |
|---|---|---|
| Match your brand | Your accent color, fonts, light or dark | Theming |
| Leave features out, or make it read-only | disable: ['forms', 'redaction'], readOnly: true |
Turn features off |
| Move or remove buttons | One line per change | Layout |
| Put a toolbar at the bottom, on the side, or several | Where each bar goes | Layout |
| Add your own button | A command: a label, an icon, what it does | Buttons & commands |
| Add your own menu or panel | A list of items, or your component | Menus, Panels |
| Put your own component in the toolbar | Your React, Vue, Svelte or Angular component | Your own components |
| Control the viewer from your app | The viewer object |
The viewer in code |
You never write layout code for phones, menus that open the right way, or keyboard focus. You say
what goes where, and the viewer takes care of how it behaves. A menu in a bottom toolbar opens
upward; the thumbnails slide up from the bottom on a phone; a button that doesn't fit moves into
the "More" menu.
The viewer or headless?
EmbedPDF comes in two forms, built on the same engine and the same plugins:
- The viewer is finished. You configure it, theme it, and add your own buttons, menus, panels
and components. - Headless is the parts. You build the whole interface yourself, and
nothing appears on screen unless you put it there.
Start with the viewer. Every feature is a headless plugin with the same settings,
and inside the viewer your own components can use the headless hooks. If you outgrow the viewer,
what you learned still applies.
Local or cloud
The viewer runs on the local engine by default: the PDF opens in the browser, with no server and no
upload, and it works offline.
<CloudPdfCallout
title="Open a 500-page PDF at once"
href="https://www.cloudpdf.com/docs/viewer"
cta="Use the viewer with CloudPDF"
With CloudPDF, the file stays on your server and pages arrive as images, so the first page shows
at once, without downloading the whole PDF or the engine. Every action is checked on the server,
and annotations and comments are shared live between everyone who has the document open. The
viewer and your config stay the same.
The CloudPDF viewer
The file stays on your server. Pages arrive as images, so the first page shows at once; every
action is checked against the user's token; and annotations, comments and form values are stored
as people make them, with everyone who has the document open seeing each other's changes.
Next steps
Content Engine Annotations Index (`docs/content/engine/annotations/index.mdx`)
title: Annotations
import { Callout } from '@/components/docs/callout';
import { CardGrid, GridCard } from '@/components/docs/cards';
Annotations
Annotations are what people add on top of a page: highlights, sticky notes, shapes, stamps,
free text, links. They're stored in the PDF, so every PDF app shows them, and they stay separate
from the page's own content, so you can change or remove them later.
const page = doc.page(pageRef);
const { annotation } = await page.annotations.create({
subtype: 'square',
box: { x: 72, y: 92, width: 200, height: 100 },
color: '#0078ff',
});
await page.annotations.update(annotation.ref, { contents: 'Check this table' });
await page.annotations.delete(annotation.ref);Kinds
Each kind has its own page with an example and its fields:
List annotations
Every annotation in the document, or the ones on one page:
const { annotations } = await doc.annotations.list(); // every page
const { annotations } = await doc.annotations.list({ pages: [pageA, pageB] }); // some pages
const { annotations } = await doc.page(pageRef).annotations.list(); // one pageA list is cheap even for a whole document: the engine reads the annotations without loading the
pages. Each annotation names its page, and the list's pages has one entry per page it read.
Each annotation has a subtype (its kind), a rect (where it is on its page) and a ref (its
address). Keep the ref: it's how you change or delete the annotation later.
Positions are in points from the top-left corner of the page, with y going down, like every
position in the engine: a box is { x, y, width, height } and a point { x, y }. See
Coordinates.
for (const annotation of annotations) {
console.log(annotation.subtype, annotation.rect, annotation.contents);
}A list includes kinds the engine can't create yet: they come back as subtype: 'unsupported',
with their position and common fields. They stay in the PDF untouched.
Form widgets are annotations too, of kind 'widget', but they're the form's: a list here never
holds one, and doc.forms.list() returns them with the fields. You place and change them through
the form; see Creating form fields.
Fields every annotation has
Besides subtype, rect and ref, every kind has these fields:
| Field | What it is |
|---|---|
contents |
A comment, the text PDF apps show for the annotation. |
subject |
A short title PDF apps show with the comment, such as 'Question'. |
nm |
A unique name on its page. See Refs & identity. |
reply |
The annotation it answers, or null. See Notes & replies. |
popup |
The ref of its popup window, or null. See Popups. |
groupId |
The group it belongs to, when you work with groups. |
Each annotation also has the flags and the fields that say who wrote it.
Add an annotation
create() takes the kind and its fields, and returns the new annotation:
const { annotation } = await page.annotations.create({
subtype: 'circle',
box: { x: 60, y: 392, width: 120, height: 100 },
interiorColor: '#1e90ff',
});Fields you leave out get the kind's default. The engine draws the annotation's appearance for
you, so it shows the same in every PDF app.
Stamps and file attachments also carry bytes (an image, a file). Pass those in a second
argument; see Stamps and
File attachments.
Change an annotation
update() takes the ref and only the fields you want to change:
await page.annotations.update(annotation.ref, {
color: '#dc143c',
contents: 'Reviewed',
});A field you leave out keeps its value. A field set to null is removed, for example{ interiorColor: null } for no fill. A field's value is replaced whole (a file, a list of
points), except rich text's body style, which merges.
You can also pass an annotation you read straight back: every field it has is one update()
accepts. That makes "change a copy, then save it" code simple:
await page.annotations.update(annotation.ref, { ...annotation, opacity: 0.5 });Fields the engine writes (author, the dates, userId, refβ¦) are accepted and ignored. For
free text, send contents or richText, not both with different text.
Move or resize by `rect`
An update takes a new rect for every annotation, as a script's annot.rect does in Acrobat. A
note, a file, a link, a popup and a redaction take it as it is.
Every other annotation is drawn from its shape: rectangles, ellipses, text boxes, stamps, carets
and form fields' widgets from their box, lines, polylines, polygons and ink from their points,
and text markup from its quads. For these, a new rect puts the shape there: each point is mapped from the oldrect onto the new one, each axis on its own. A rect of the same size moves the drawing, and a
bigger one stretches it:
const { rect } = line;
// 40 points right, 20 down
await page.annotations.update(line.ref, {
rect: { ...rect, x: rect.x + 40, y: rect.y + 20 },
});The engine then works out rect again from the shape. After a resize it can differ a little from
the one you sent: a line keeps its width, and its endings keep their size. The rect you read,
sent back, changes nothing, so a read passed straight back works.
- A new
rectwith a new shape (box, points or quads) fails withInvalidArg: send one or the
other. - A turned drawing can only move. A
rectof another size fails withInvalidArg; resize itsboxor its points instead. A box at a quarter turn is the exception: it stands upright on the
page, so arectresizes it along its own sides. - A
rectthat would squash a drawing to no width or no height fails withInvalidArg.
shapeForRect(annotation, rect) gives the fields that put an annotation at a rect, by the same
rule. Use it to place a copy somewhere else:
import { shapeForRect } from '@embedpdf/engine';
await page.annotations.create({ ...copy, ...shapeForRect(copy, target) });A create works out a drawn annotation's rect from its shape, whatever rect you send, so a
copy of another app's drawing keeps its shape exactly. A box turned a quarter can be created by
its rect instead of its box; see Turned shapes.
Delete and reorder
await page.annotations.delete(annotation.ref);
await page.annotations.reorder([annotation.ref], 'start'); // to the back
await page.annotations.reorder([annotation.ref], { after: other.ref }); // just above anotherDeleting an annotation also deletes its popup, if it has one, and its replies; see
Delete a note.
Annotations on a page are drawn in order, so the last one is on top. reorder(refs, position)
moves annotations together, in the order you pass them, right after or before another annotation
of the page, or to the back ('start') or the front ('end'). It resolves the page's new order,order, as refs, and fires annotations.reordered.
Widgets are drawn above every annotation, in an order of their own, so a widget is never a
neighbour here: reorder() refuses one with InvalidArg. Restack widgets withdoc.forms.reorderWidgets().
Style
Most kinds share the same style fields:
| Field | What it is | Default |
|---|---|---|
color |
Line color, such as '#1e90ff' |
per kind |
interiorColor |
Fill color of closed shapes, or null for no fill |
no fill |
opacity |
From 0 (invisible) to 1 (solid) |
1 |
strokeWidth |
Line width in points | 1 |
borderStyle |
'solid' or 'dashed' |
'solid' |
dashArray |
The pattern a 'dashed' border draws, such as [3, 2] |
[3] |
A pattern alone doesn't make a border dashed: a 'solid' border keeps its dashArray and draws
solid. dashArray: null on a dashed border draws the default [3], 3 points on and 3 off. A
border from another app can read 'beveled' or 'inset'; only form widgets draw those, so a
write takes them only sent back unchanged.
Colors are '#rrggbb', as in CSS: the engine gives them in lowercase and takes either case. They
never include transparency: that's what opacity is for. The default color depends on the kind
(yellow for a highlight, red for a shape, black for free text); each kind's page lists its
defaults.
A PDF can store a color as gray or CMYK. It reads as the same color in '#rrggbb', and sent back
unchanged it stays as the file has it, so passing a read straight back rewrites nothing.
Who wrote it
The engine records who created and last changed each annotation, from the user the document was
opened for. You read these fields; you don't set them:
| Field | What it is |
|---|---|
author |
The display name shown in PDF apps. |
createdAt |
When it was created, as an ISO date. |
modifiedAt |
When it was last changed. |
userId |
The id of the user who owns it. |
createdBy |
The id of the user who created it. |
modifiedBy |
The id of the user who last changed it. |
groupId |
The group it belongs to, when you work with groups. |
The user comes from the identity you pass when you open the document; see
Opening documents.
The user comes from the identity in their token; see
Authentication.
Flags
Every annotation has the standard PDF flags as true/false fields. Set the ones you need; the
rest stay off (on create) or keep their value (on update). The exception is print: a new
annotation prints unless you set print: false, as in Acrobat. A popup doesn't print.
await page.annotations.update(annotation.ref, { print: true, locked: true });| Flag | What it does |
|---|---|
print |
Printed with the page. Off: shown on screen only. |
hidden |
Not shown and not printed. |
noView |
Not shown on screen (can still print). |
locked |
Can't be moved, resized or deleted in PDF apps. |
lockedContents |
Its text can't be changed in PDF apps. |
readOnly |
Can't be changed at all in PDF apps. |
noZoom |
Keeps its size when the page is zoomed (notes, icons). |
noRotate |
Stays upright when the page is rotated. |
invisible |
Hidden when the app doesn't know the annotation's kind. |
toggleNoView |
Flips noView when the pointer is over it. |
Flags are instructions for PDF apps. The engine itself still lets you change a locked
annotation; the viewer's annotation tools respect the flags in their interface.
Permissions
Reading annotations needs doc.annotate.read. Creating, changing and deleting them needs a
permission for that action, which can be limited to the user's own annotations:annotations:update:self lets a user edit what they created and nothing else. See
Permissions.
<CloudPdfCallout
title="Let many people annotate one document"
href="https://www.cloudpdf.com/docs/engine"
cta="Collaborate with CloudPDF"
With CloudPDF, annotations are stored on your server as people make them. Everyone with the
document open gets each other's changes as events, and the server
makes sure people only change the annotations they may change.
More
- Import & export: copy annotations to another
document, or store them apart from the PDF. - Drawing annotations yourself: render annotations as
separate images, bake them into the page, or export them as a PDF. - Refs & identity: what a
refcontains, and why you never
build one. - Changes & undo: make several annotation edits as one action,
and undo them.
Content Engine Forms Index (`docs/content/engine/forms/index.mdx`)
title: Forms
Forms
A PDF form is a set of fields: text boxes, checkboxes, radio buttons, dropdowns and lists. The
engine reads every field with its value, fills fields in, and redraws them so the new value shows
in every PDF app.
const { fields } = await doc.forms.list();
const name = fields.find((field) => field.name === 'billing.name')!;
await doc.forms.setValue(name.ref, { value: 'Ada Lovelace' });Read the form
list() returns every field in the document:
const { fields } = await doc.forms.list();
for (const field of fields) {
console.log(field.name, field.family);
}Every field has these:
| Field | What it is |
|---|---|
name |
The full name, with the groups it's in: billing.name. |
family |
'text', 'checkbox', 'radio', 'combobox', 'listbox', 'pushbutton', 'signature' or 'unknown'. |
ref |
Its address. Keep it: it's how you fill, reset or change the field. |
readOnly |
true when PDF apps don't let the user change the value. |
required |
true when PDF apps ask for a value before the form is submitted. |
noExport |
true when the field is left out when the form is submitted. |
alternateName |
The tooltip PDF apps show, also read by screen readers, or null. |
groupId |
The group the field belongs to: who fills it in, such as 'buyer', or null. See Signers and groups. |
widgets |
Where the field shows on the pages (see Widgets). |
A document without a form returns an empty fields. The list's formKind says what the document
has: 'none', 'acroform' (a regular PDF form) or 'xfa' (see Good to know).
The list also has needsAppearances, true when the form asks PDF apps to draw its fields
themselves (repair() can clear it), andcalculationOrder (see Calculations).
Values
Each family has its own value fields. Check family first, and TypeScript knows which ones the
field has:
if (field.family === 'text') console.log(field.value); // string
if (field.family === 'checkbox') console.log(field.checked); // boolean
if (field.family === 'listbox') console.log(field.selectedValues); // string[]| Family | Its value | Also |
|---|---|---|
text |
value, a string |
defaultValue, maxLength (null: no limit), multiline, password, comb |
checkbox |
checked |
exportValue: what the form data holds while it's checked |
radio |
value: the export value of the chosen button, or 'Off' |
noToggleToOff, radiosInUnison |
combobox |
value |
options, edit (free text allowed), defaultValue |
listbox |
selectedValues, in option order |
options, multiSelect, defaultValue (option values), topIndex (the option in its top row) |
pushbutton |
none: a button only runs an action | Each widget's caption, on its row in widgets |
signature |
none here: see Digital signatures |
Each option of a dropdown (combobox) or list is { label, value, selected }. label is what the
user sees; value is what the form stores, and what you pass when you fill the field in.
Who created and filled it in
A field records who created it and who last filled it in, from the identity the document was opened
with:
field.createdBy; // 'user-17': who created the field, or null
field.filledBy; // 'user-42': whose fill last changed the value
field.filledByName; // 'Ann de Vries'
field.filledAt; // '2026-10-08T10:42:00Z'| Field | What it is |
|---|---|
createdBy, createdAt |
Who created the field, and when. null for a field another app made. |
filledBy, filledByName, filledAt |
The user whose fill last changed the value, their name, and when. A calculated value counts as filled in by whoever's fill calculated it. null after a reset, or an anonymous fill. |
importedBy |
Who imported the field with its attribution kept. |
The engine stamps these; a write can't set them. A fill that leaves the value as it was changes
none of them, and undoing a fill puts the previous ones back. A widget has no attribution of its own:
its field's is the one.
Widgets
A field shows on a page through a widget: the box you see and click. Most fields have one. A
radio group is one field with a widget per button, and a field repeated on every page (a
reference number in the header, say) has a widget per page, all showing the same value.
Each widget in field.widgets has its page and its ref. The form also lists every widget inwidgets: where it is (rect, in points from the page's top-left), how it looks, its state,
what it does when clicked (actions), and the ref of the field it belongs to (field). So one read is enough to draw your own form layer
over a page:
const { widgets } = await doc.forms.list();
for (const widget of widgets) drawBox(widget.page, widget.rect, widget.field);Checkbox and radio widgets in field.widgets also have their exportValue (what the form data
holds when this widget is checked) and checked.
A form field's widgets come with the form, never with the annotations: page.annotations.list()
doesn't return them, and reading them takes doc.forms.read, not doc.annotate.read. Each page's
widget images, every look and state, come from page.forms.renderAppearances().
Find a field
The ref from list() is the best address: it keeps working when the field is renamed. When you
know a field by its name instead (from your backend, or from data you stored), make a ref from the name:
import { toFieldRef } from '@embedpdf/engine';
const field = await doc.forms.get(toFieldRef('billing.name'));The name is the full name, with dots between the groups. A field that isn't there fails withNotFound.
Fill in a field
setValue() takes the field's ref and its new value, in the same fields a read returns. What
you read, you can write back:
await doc.forms.setValue(name.ref, { value: 'Ada Lovelace' }); // text
await doc.forms.setValue(agree.ref, { checked: true }); // checkbox
await doc.forms.setValue(plan.ref, { value: 'yearly' }); // radio group
await doc.forms.setValue(country.ref, { value: 'NL' }); // dropdown
await doc.forms.setValue(toppings.ref, { selectedValues: ['cheese', 'olives'] }); // list| Family | Value |
|---|---|
text |
{ value }; null empties it |
checkbox |
{ checked } |
radio |
{ value }: the export value of the button to choose; 'Off' or null clears |
combobox |
{ value }: an option value; null clears |
listbox |
{ selectedValues }: option values; [] clears |
setValue() returns the field as it is now:
const { field } = await doc.forms.setValue(name.ref, { value: 'Ada' });The engine redraws the field's widgets, so the page shows the new value in every PDF app. Render
the page again to see it.
What the engine checks
The engine checks a value before it writes anything. A call that fails with InvalidArg leaves
the field as it was:
- The value must be the family's own:
{ checked: true }on a text field fails. - A dropdown or list takes option values, not labels. A dropdown with
edit: truealso takes
free text. More than one value needs a list withmultiSelect. - A radio group's
valuemust be the export value of one of its buttons. - A radio group with
noToggleToOffcan't be cleared once a button is chosen. - Push buttons and signature fields hold no value you can set.
Some things are fixed up rather than refused: text longer than maxLength is cut to fit, and
in a radio group with radiosInUnison, the buttons that share an export value check together.
readOnly is an instruction for PDF apps: they don't let the user change the field. The engine
still accepts your own writes, for example a total your code calculates.
Reset
reset() puts back each field's defaultValue, or empties it when it has none. Without an
argument it resets the whole form; pass a ref, or a list of refs, to reset only those fields:
await doc.forms.reset(); // every field
await doc.forms.reset(name.ref);
await doc.forms.reset([name.ref, email.ref]);It returns the fields it changed, as they are now. A field that already holds its default is
left alone and isn't in the list:
const { fields } = await doc.forms.reset();
console.log(`${fields.length} fields reset`);Fields that hold no value, such as push buttons and signature fields, are skipped. Resetting the
whole form also skips the fields a signature locked and fields stored inline;
name one of those yourself and the call fails, the same as a setValue() would.
Move fields and values between documents
export() takes fields out as one bundle, each with its value. importValues() fills another
document's fields of the same name from it, and import() copies the fields themselves:
const bundle = await doc.forms.export();
const { fields, dropped } = await other.forms.importValues(bundle);Each value is checked like a setValue(). A field the document doesn't have, a value it can't
take, or a field a signature locked is left out and listed in dropped; the rest still land, as
one change you can undo. See Import & export.
Events
setValue(), reset() and importValues() fire forms.valueSet, one per field that changed.
Each carries what the call returned for that field:
doc.events.on('forms.valueSet', ({ field }) => {
updateSummary(field);
});When one call changes several fields, such as reset() or importValues(), their events shareorigin.tx, like an annotation import.
Each value is saved on the server as you set it. Everyone with the document open gets the event,
with origin.kind: 'remote' for other people's changes. See
Events.
Permissions
Reading the form needs doc.forms.read, and exporting it doc.download too. Filling in,
resetting and importing values needs doc.forms.fill, which includes doc.forms.read. See
Permissions.
A signed document can take filling away, for the whole form or for single fields. The call then
fails with ProtectedDocument; see
Digital signatures.
More
- Creating form fields: add fields and widgets, change and delete
them, repair a broken form. - Import & export: copy fields from one document to another,
or fill a form in from a bundle. - Digital signatures: sign a signature field and check the
signatures in a document. - Changes & undo: fill in several fields as one action, and
undo it.
Good to know
Calculations, formatting and validation
The engine doesn't run the form's JavaScript. setValue() stores the value you give it: it
doesn't recalculate a total, format a date or an amount, or run a validation script. PDF apps run
those scripts while a user types.
A field's scripts are in field.actions (keystroke, format, validate and calculate), and
the list's calculationOrder holds the fields whose totals are calculated, in order. To write them,
see Scripts and calculations.
The viewer's form plugin runs these scripts and writes their results as one
change: forms.setValue, forms.reset, forms.setDisplay
(whether the field shows and prints) and forms.setAppearanceText (the formatted text the field
shows, while its value stays the same) ops, in the order the scripts asked for them. All of them
are written, or none is: a field a signature locked refuses the whole change. You only need these
ops when you run form scripts yourself.
Recovered fields
Some PDFs have fields that show on the page but are missing from the form's list of fields, a
bug in the tool that made them. The engine finds them anyway: they come back withorigin: 'recovered' and fill in like any other field. Other PDF apps may not see them;repair() fixes the file.
A field on several pages
A field's widgets can be on different pages. The meta of a setValue() or reset() result
lists every widget whose look changed, with its page (meta.changedWidgets). For a radio group
that's the button switched off and the one switched on. Use it to render only the pages that
changed.
meta.redrawnWidgets lists those whose picture the engine drew again, such as a text field's
widgets after a new value. A checkbox or radio button that switches shows another picture it
already has, so it isn't in the list.
Checkboxes with several widgets
A checkbox can have several widgets, each with its own exportValue: a "Yes / No" pair, for
example. { checked: true } checks the first widget; { value: 'No' } checks the widget with
that export value.
In the file, each checkbox and radio widget also has an onState: the name of its checked look
(PDF's appearance state). It's usually the same as the export value, and widgets the engine
creates always use their exportValue for it. You never need it to fill a field in.
The field's value in the file (valueEntry) is the checked widget's onState, or 'Off', as the
PDF standard says. Some tools write the widget's position instead ('0', '1'); such a value
still resets to that widget.
XFA forms
formKind is 'xfa' when the PDF also carries an XFA form, an older XML form format. The
engine works with the document's regular fields and never runs XFA.
Fields stored inline
A field that the file stores inside another object, rather than as an object of its own, can be
read but not changed: writes fail with InvalidArg. PDF tools rarely write fields this way.
Content Headless Annotations Index (`docs/content/headless/annotations/index.mdx`)
title: Annotations
import { Cards, Card } from '@/components/docs/cards';
{/* This page is served at β¦/headless/<framework>/annotations with NO trailing
slash, so a relative href resolves against β¦/headless/<framework>/.
Sibling pages are annotations/tools, other sections text/selection. */}
Annotations
Annotations are what people add on top of a page: highlights, sticky notes, shapes, stamps and
text boxes. The annotation plugin shows the ones a PDF already has, lets people draw and edit new
ones, and gives your code the same abilities. Every annotation is saved into the PDF, so it looks
the same in every other PDF app.
Show the annotations in a PDF
Register interactionPlugin() and annotationPlugin(), then put an <AnnotationLayer> above the
rendered page:
Try it: click the rectangle and drag it, or a corner to resize it. Double-click the text box
to type in it.
While the annotation layer is on the page, the render layer leaves the annotations out of the page
picture by itself. The annotation layer draws them instead, so they can be selected, moved and
changed.
interactionPlugin() is the shared input system. Every tool, from text selection to drawing,
speaks through it; see Selecting & copying for how it works.
Let people draw
A tool says what a click or a drag on the page creates. Activate one and people can draw:
Try it: drag on the page to draw a rectangle, then pick another tool. Highlight marks the
text you select.
pointerselects: click an annotation to select it, then move, resize or turn it.squaredraws a rectangle: drag, or just click for a default-size one.inkdraws freehand.highlighthighlights the text you select.notedrops a sticky note where you click.
After you draw, the new annotation is selected and the tool stays active, so you can draw another.
You can change both; see What happens after drawing.
How it fits together
Everything on these pages is one of five things:
| Thing | What it is | Where you use it |
|---|---|---|
| Annotation | A record in the PDF: its kind, position, colors and text. | annotation.list() |
| Tool | What a click or drag creates, with which defaults, cursor and preview. | annotationPlugin({ tools }) |
| Layer | What each page draws: the annotations, the selection box and handles, the preview. | <AnnotationLayer> |
| Overlay | Your own UI attached to an annotation, such as a menu or a hover card. | <AnnotationMenu> |
| API | Reading, creating, changing and deleting annotations, and listening for changes. | useAnnotation() |
An annotation here is the same record the engine reads and writes: the same kinds, the same fields
and the same names. What you learn in the engine's annotation pages
holds here too.
The built-in tools
| Tool | Makes | How |
|---|---|---|
square, circle |
A rectangle or an ellipse | Drag, or click for a default size |
line |
A line | Drag, or click for a default length |
polygon, polyline |
A closed or open shape of points | Click each point, double-click to end |
ink, ink-highlight |
Freehand drawing, or a see-through marker | Draw |
free-text, free-text-callout |
A text box, or one with a line pointing out | Click or drag, then type |
highlight, underline, strikeout, squiggly |
A mark over text | Select text |
insert-text, replace-text |
A suggested insertion or replacement | Select text |
note |
A sticky note | Click |
stamp |
An image or a PDF drawing | Click, then pick a file |
attachment |
A file pinned to a spot | Click, then pick a file |
link |
An area that opens a page or a website | Drag |
Measurement tools (distance, perimeter, area) and redaction (redact) are annotation tools
too. Their plugins add the rest of what they need.
You can change any tool, or add your own; see Tools & defaults.
Who wrote it
Every annotation records who made it and who last changed it (author, createdAt,modifiedAt). You don't set these on the annotation: they come from the user the document was
opened for.
Tell the viewer who the user is, once. Every document it opens uses it:
Try it: draw a rectangle. The list says Dana Smith made it, and when.
displayName becomes the author other PDF apps show, and userId records who owns the
annotation. The same identity decides which annotations count as the user's own when
permissions say "only their own".
To open one document as someone else, give its entry in initialDocuments its own identity. It
replaces the viewer's for that document:
const initialDocuments = [
{ source: contract, identity: { userId: 'bot', displayName: 'Import job' } },
];A document keeps the identity it was opened with. <Fw only={['react', 'vue', 'svelte']}>If you change the viewer's identity, documents
you open afterwards use the new one.
To change the viewer's identity, for example when someone else signs in, useinject(EpdfViewer).updateSettings({ identity }). Documents you open afterwards use the new one. To
follow your app's login:
effect(() => viewer.updateSettings({ identity: auth.identity() }));It comes from the identity in the user's token; see
Authentication.
Settings
Set them when you register the plugin. snap, chrome, afterCreate and editable can change
while the app runs, with annotation.updateSettings({ β¦ }); annotation.resetSettings() goes back to what you
registered, and useAnnotationSettings() gives you the current settings.
| Setting | What it does | Page |
|---|---|---|
tools |
Change the built-in tools, or add your own | Tools & defaults |
afterCreate |
Select a new annotation? Keep the tool active? | What happens after drawing |
chrome |
The selection's outline, handles and rotation handle, and their colors | Change the handles |
snap |
Snapping to other annotations and to angles | Snapping |
editable |
Which annotations people may change by hand: true, false, or kinds |
What people may change |
Every color in chrome can also come from CSS, for dark mode or a different look per viewer: see
Colors & theming.
Next steps
Content Headless Forms Index (`docs/content/headless/forms/index.mdx`)
title: Filling forms
Filling forms
Many PDFs are forms: text boxes, checkboxes, dropdowns, signature fields. Register formPlugin() and
put <FormLayer> in each page, and people can fill them in:
Each field becomes a real HTML control, placed and styled like the field in the PDF: people type in text
boxes, tick checkboxes, pick from lists, and move between fields with Tab. A value is saved into the
document as it's entered, so it's there when the PDF is downloaded, and every PDF app shows it. The
form layer paints the fields as the engine draws them, so while it's on the page, the render layer
leaves them out of the page picture.
Read the form
<Fw only={['react', 'vue', 'svelte']}>
useFormState() gives you every field, and
form.fields() is every field, as a signal: your view updates when a value changes.
Each field has its full name (such as billing.name), its family ('text', 'checkbox','radio', 'combobox', 'listbox', 'pushbutton' or 'signature'), its value, and a ref: its
address for everything else. The fields and their values are the engine's; see
Forms for every family's value.
For one field, such as a total you show in your own UI, use useFormValue():
toFieldRef(name) makes a ref from a field's full name, for when you know a field by name.
Fill in a field
setValue() takes the same values a field reads:
await form.setValue(name.ref, { value: 'Ada Lovelace' }); // text
await form.setValue(agree.ref, { checked: true }); // checkbox
await form.setValue(plan.ref, { value: 'yearly' }); // radio group: the button's export value
await form.setValue(country.ref, { value: 'NL' }); // dropdown: an option's value
await form.setValue(toppings.ref, { selectedValues: ['cheese', 'olives'] }); // listIt runs the same checks and scripts as typing does, and resolves with { field, status }: status
is 'applied', 'unchanged', or 'rejected' when the form's own validation refused the value.
The field shows the new value at once, before the engine answers, and the promise resolves once it
has. What the form's scripts calculate from it goes with it, as one change: one request, and one
step to undo. The field stays editable meanwhile; filling it in again is the next change. A value
the engine refuses goes back to the one the field had.
Your own inputs can fill in fields too. form.controlOf(ref) gives you a field as an AngularFormControl: it holds the field's value, and fills in the field when the control changes, with
the same checks and scripts:
To fill many fields at once, such as from your backend, pass them by name:
await form.importValues({ 'billing.name': 'Ada Lovelace', 'billing.country': 'NL', agree: true });importValues() and setValues() write every value as one change, with the calculations run once
after them. Each field's own keystroke and validation scripts don't run, as when a script sets a
field's value in Acrobat.
Check required fields
A form marks some fields as required. Check them before you submit or download:
const { valid, missing } = form.validate();
const widget = !valid && form.getWidget(missing[0].widgets[0]);
if (widget) stage.reveal(widget.page, { rect: widget.rect });missing lists the required fields that are empty, in page order, so you can take the user to the
first one. Only the fields the user may fill in count: another signer's are theirs to fill.
Calculations, formatting and validation
Many forms add up totals, format dates and check values with scripts. With JavaScript on in the
actions plugin, they run as people type, as in Acrobat:
When a script refuses a value, such as a date in the wrong format, the field keeps its old value,
the box keeps what was typed so it can be corrected, as in Acrobat, and
<Fw only={['react', 'vue', 'svelte']}>onValidationRejectedvalidationRejected$
The scripts read the form as the user sees it, values still on their way included, so nothing is
read from the engine for them. Their engine starts when the first field of a form with scripts takes
focus, so the first value doesn't wait for it; a form without scripts never starts it.
Reset the form
await form.reset(); // every field the user may fill in, back to its default
await form.reset([name.ref, email.ref]); // some fieldsMove form data in and out
For your own backend, plain values are simplest:
const values = form.exportValues(); // { 'billing.name': 'Ada Lovelace', agree: true, β¦ }
await form.importValues(values);Fields are matched by their full name. A field the form doesn't have, or a value it can't take, is
skipped; the rest still land.
To copy the fields themselves to another document, with their boxes, values and scripts, take
them out as a bundle and import it there. FormTransfer turns a bundle into one JSON text to
store, and back:
const bundle = await form.export(); // every field; or form.export({ fields }), ({ pages })
const text = FormTransfer.stringify(bundle);
const { fields, dropped } = await otherForm.import(await FormTransfer.parse(text));The import adds the fields as one change you can undo. A field whose name the form has already is
left out and listed in dropped. By default each field keeps who created and filled it in, which
needs canRestoreAttribution(); pass { attribution: 'stamp' } to make them yours. See
Import & export.
Methods
What useForm() gives you for reading and filling. The verbs that change the form itself are
on Building forms.
| Method | What it does |
|---|---|
get(ref) |
One field, with its value, or null when the form has no such field. Read the form |
getValue(ref) |
One field's value, in the shape setValue() takes, or null. In a component, use useFormValue(ref). |
getFieldForWidget(widget) |
The field a box belongs to, from the box's ref |
getWidget(widget) |
A box's row: its page, its place (rect), its look and its state, or null when no page shows it |
getWidgets(ref) |
Every box a field shows in, as rows in the field's order: a radio group's buttons, or a field repeated on several pages |
listWidgets(page) |
Each field box on a page, with its place, its control, its value and whether it can be filled. For drawing the fields yourself. |
getWidgetAt(page, point) |
The field box under a point on a page, with its field, or null |
setValue(ref, value) |
Fills in a field with { value }, { checked } or { selectedValues }, running the form's checks and scripts. Resolves { field, status }. Fill in a field |
setValues(entries) |
Fills in several fields, each { ref, value }, as one change with one recalculation; a field you may not fill in, or a value it can't take, fails on its own. Resolves { applied, skipped, failed }. |
importValues(values) |
Fills in fields by full name from a plain object, as one change with one recalculation. Resolves { applied, skipped, failed }. Move form data in and out |
exportValues() |
Every value, keyed by full name, as a plain object |
export(selection?) |
Whole fields with their boxes, values and scripts, as a bundle: every field, or { fields } or { pages }. Move form data in and out |
import(bundle, options?) |
Adds a bundle's fields, as one change. Resolves { fields, refMap, dropped }. Needs canDesign(). Move form data in and out |
reset(refs?) |
Puts back each field's default, for every field the user may fill in or the fields you pass. Resolves { fields }, the fields it changed. Reset the form |
validate() |
Checks the required fields the user may fill in. Returns { valid, missing }, the empty ones in page order. Check required fields |
activateWidget(widget) |
Does what a click on a field box does, such as running a button's action |
getSnapshot() |
Everything at once, as the user sees it: what the engine's list() returns, with the values still on their way. fields, widgets, formKind, needsAppearances and calculationOrder |
refresh() |
Reads the whole form again, and <Fw only={['react', 'vue', 'svelte']}>onResyncedresynced$ |
canRead(), canFill(ref) |
Whether reading the form, and filling in a field, are allowed. Permissions |
getSettings(), updateSettings(changes), resetSettings() |
Reads the settings, changes them while the app runs, or goes back to what you registered. In a component, use useFormSettings(). Settings |
Every method that returns a promise also takes a signal in its options, to cancel it.
State
| Field | What it is | Read once |
|---|---|---|
fields |
Every field, with its value | list(), or list({ family, page, name }) for some |
status |
Whether the form is read: 'idle', 'loading', 'ready', 'forbidden' or 'error' |
getStatus() |
formKind |
What the document has: 'none', 'acroform' (a regular PDF form) or 'xfa' |
getFormKind() |
selectedField |
In design mode, the field of the selected box, or null; see Building forms |
getSelectedField() |
For one field, useFormValue(ref)
Settings
Set them when you register the plugin, and change them while the app runs withform.updateSettings({ β¦ }); form.resetSettings() goes back to what you registered, anduseFormSettings() gives you the current settings.
| Setting | What it does | Default | From CSS |
|---|---|---|---|
validation |
Run the form's own checks ('scripts'), or accept every value ('none') |
'scripts' |
β |
focus.color |
The ring around the field being filled in | the viewer's accent | --epdf-form-focus |
fields.border |
The edge of a field that has no border of its own | the viewer's accent at 55% | --epdf-form-field-border |
fields.background |
Inside a field that has no background of its own | 'rgb(255 255 255 / 0.92)' |
--epdf-form-field-background |
fields.color |
The check mark and text of a field that has no color of its own | '#1f2a44' |
--epdf-form-field-color |
fillable |
Which fields people fill in by hand: true, false, or field families |
true |
β |
Scripts need JavaScript on in the actions plugin; without it, 'scripts' accepts every value too,
and a form with scripts logs a hint once.
fillable is for the people using your app. A field it leaves out draws as a read-only field
does, and takes no typing, clicks or picks; your code still fills it in with setValue() andimportValues(), and canFill(ref) doesn't change:
formPlugin({ fillable: false }); // show the values, take no input
form.updateSettings({ fillable: ['text', 'checkbox'] }); // only these families
form.updateSettings({ fillable: true }); // every field againThe families are 'text', 'checkbox', 'radio', 'combobox', 'listbox', 'pushbutton' and'signature' (signing a field is filling it in). To stop your code too, leave doc.forms.fill out
of the scope (see Permissions).
The colors are what the viewer draws around fields; a field's own colors are saved in the PDF (see
Building forms). A CSS variable wins over the setting: see
Colors & theming.
Events
| Event | Fires when | Carries |
|---|---|---|
onValueChanged |
A field's value changed | field, origin |
onValidationRejected |
The form's validation refused a value | field, issues |
onResynced |
The whole form was read, as when it opens, after refresh() or a repair |
snapshot |
onSettingsChanged |
The settings changed | settings, changed |
Fields that are added, changed or removed have their own events; see
Building forms.
Permissions
| Permission | What it allows | Check |
|---|---|---|
doc.forms.read |
Reading the form | form.canRead() |
doc.forms.fill |
Filling it in | form.canFill(ref) |
Without it, the fields show their values but can't be changed. A user with a fields:fill
permission for one group fills in that group's fields only: form.canFill(ref) answers per field,
the other fields can't be changed and Tab skips them, validate() leaves them out, and reset()
leaves them as they are. See Signers and groups.
Good to know
Forms and annotations
A field's widgets are the form's: the annotation plugin never lists them. With the
annotation plugin registered, the <FormLayer> and the annotation layer share the page: fields stay fillable with thepointer tool, and become movable boxes in design mode.
Keyboard
Tab moves to the next field in the page's order. In a text box, Enter confirms and Escape puts back
the value it had.
Collecting responses
On the local engine, the values live in the document in this tab until it's downloaded.
<CloudPdfCallout
title="Collect form responses on your server"
href="https://www.cloudpdf.com/docs/headless/forms"
cta="Collect forms with CloudPDF"
With CloudPDF, each value is stored on your server as it's entered, so a half-filled form survives a
closed tab, and your backend reads the answers without asking the user to download or upload a
file.
Each value is stored on your server as it's entered, so a half-filled form survives a closed tab.
XFA forms
Some PDFs also carry a form in the older XFA format. The viewer works with the document's regular
fields and never runs XFA; see Forms.
Signed documents
A signature can lock the whole form, or some fields. Locked fields show their values and can't be
changed; see Signatures.
Content Viewer Features Index (`docs/content/viewer/features/index.mdx`)
title: All features
All features
Every feature is on from the start. Leave out the ones you don't need with disable, by the name
in the first column:
{ disable: ['forms', 'redaction', 'measurement'] }| Feature | What people can do | Settings |
|---|---|---|
annotations |
Highlight, underline and strike out text; draw; add notes, text boxes and callouts | Annotations |
shapes |
Draw rectangles, circles, lines, arrows and polygons | Annotations |
comments |
Comment on annotations and reply, in a panel with every thread | Annotations |
stamps |
Place stamps: the standard ones, their own, or images | Annotations |
signatures |
Draw, type or upload a signature; sign fields; check signatures | Forms & signatures |
forms |
Fill in forms; build them in the Form mode | Forms & signatures |
redaction |
Mark text and areas, then remove them from the file for good | Annotations |
measurement |
Measure distances, perimeters and areas at the drawing's scale | Annotations |
pages |
Rotate, move, insert, duplicate and delete pages in the thumbnails panel, each step undoable | Panels |
attachments |
See, save, attach and remove files inside the PDF | Panels |
search |
Find text, with every match highlighted and listed | plugins.search |
text |
Select and copy text | plugins.selection |
thumbnails |
A picture of every page, to jump around | Panels |
outline |
The document's table of contents | Panels |
documents |
Open other files (with the local engine), download, print, several documents in tabs | Documents |
Leave features out
A feature in disable is gone everywhere at once: its buttons, its mode tab, its panel, its menu
rows and its shortcuts. Annotations already in the document still show; people just can't add new
ones. Most features have parts you can turn off on their own, such as forms.building ordocuments.download, and one button goes by its command's name, such as tool:highlight.
disable follows its prop, so a feature can come and go while the viewer runs, for example when
a user's plan changes.
Turn features off has every name, read-only, and what to use when: the
layout for where something goes, disable for whether the viewer has it, and the scope for what a
user may do.
Who the user is
The user's name becomes the author of the annotations and comments they make:
{ identity: { displayName: 'Ada Lovelace' } }The user's name becomes the author of the annotations and comments they make. On CloudPDF, who the
user is and what they may do come from the document's token, so your server decides.
Each feature's settings
Every feature is a headless plugin, and plugins takes its settings by the plugin's name, exactly
as the plugin's page lists them:
{
plugins: {
stage: { spread: 'odd', zoom: { mode: 'fit-width' } },
search: { highlight: { color: '#a7f3d0' } },
annotation: { tools: [{ id: 'highlight', defaults: { color: '#fde047' } }] },
},
}That opens documents as two-page spreads at the page's width, with green search matches and
yellow highlights. See Plugins for every plugin and how to change its settings
while the viewer runs.
Good to know
What loads when
A feature you disable is still in the viewer's code: disable changes what people see, not what
you ship. The first load stays small another way. Each feature's panels and dialogs are files of
their own, which the viewer fetches in the background once the document shows, so they open at
once. The heavier parts wait until they're used: the signature checks until a signature is
checked, the standard stamps until Stamps opens, the handwriting fonts until someone types a
signature, and each language until it's chosen. How it works
has the whole order.
Website Src Content Docs Index (`website/src/content/docs/index.mdx`)
title: Documentation searchable: false
Cloudpdf Website Src Content Docs Index (`cloudpdf/website/src/content/docs/index.mdx`)
title: Introduction searchable: false
import { DocsLanding } from '@/components/site/docs-landing';
Website Src Content Docs Engine Index (`website/src/content/docs/engine/index.mdx`)
title: Engine
{/* Generated from docs/content β edit there, then pnpm docs:sync. */}
import { Cards, Card } from '@/components/docs/cards';
The PDF engine
The EmbedPDF engine opens PDF files in the browser and lets you render their pages, read and
search their text, add annotations, fill forms, edit pages and save the result. It runs PDFium,
the PDF library inside Chrome, in a Web Worker, so your page stays responsive. Nothing is
uploaded: the file never leaves the device.
The ready-made viewer and the headless components are built on this engine. Use it directly
when you want PDF features without a viewer: a thumbnail job, a form filler, an export script.
The CloudPDF engine gives your app a full PDF engine over HTTPS. Your files stay on your
CloudPDF server. The engine renders their pages, reads and searches their text, stores
annotations and form values, and checks every call against the permissions in the user's
token.
The CloudPDF viewer and the headless components are built on this engine. Use it directly when
you want PDF features without a viewer: a thumbnail job, a form filler, an export script.
import { localEngine } from '@embedpdf/engine';
const engine = localEngine();
const doc = await engine.open({ kind: 'bytes', bytes });
const { pages } = await doc.pages.list();
const image = await doc.page(pages[0].ref).render.image();import { cloudEngine } from '@cloudpdf/engine';
const engine = cloudEngine();
const doc = await engine.open({ kind: 'token', token: () => fetchDocumentToken(documentId) });
const { pages } = await doc.pages.list();
const image = await doc.page(pages[0].ref).render.image();What you can do
- Render pages as images, at any size.
- Read, search and
select the text on a page, and find out where
each character is. - Add and edit annotations: highlights, notes, shapes, stamps,
measurements. Copy them to another document with
import & export. - Fill forms, create form fields and
sign documents. - Change pages: rotate, reorder, delete, insert, merge and
split. - Redact content for good, and
save the result. - Group edits into changes that apply all or none, and undo
or redo any of them.
Every call is async, can be cancelled, and fails with a typed error you can branch on. The same
code runs on the local engine and on the CloudPDF engine: only how you create the engine and
open a file differs.
A few things exist only on the local engine: its fonts, warmup(), raw pixels, and saving a
document's layer or to a file. A document opened with localEngine() has them. Code that takes
any engine checks first:
import { isLocalDocument } from '@embedpdf/engine';
if (isLocalDocument(doc)) {
const layer = await doc.downloadLayer();
}isLocalEngine(engine) does the same for an engine.
Local or cloud
This engine runs in the browser: no server, no upload, and it works offline. That makes it the
right choice when the user already has the file, or when the file may not leave their device.
<CloudPdfCallout
title="Keep the file on your server"
href="https://www.cloudpdf.com/docs/engine"
cta="Read about the CloudPDF engine"
When the file lives on your server, the CloudPDF engine runs the same API there and checks every
call. A user sees the first page as an image, without downloading the whole PDF or the engine
(about 3 MB compressed), and a user who may not download the file never receives it. Your code
stays the same: swap localEngine() for cloudEngine().
Next steps
Website Src Content Docs Headless Index (`website/src/content/docs/headless/index.mdx`)
title: Headless
{/* Generated from docs/content β edit there, then pnpm docs:sync. */}
import { Cards, Card } from '@/components/docs/cards';
{/* Served at β¦/headless/<framework> with NO trailing slash: a relative href
resolves against β¦/headless/, so pages are quick-start,
viewing/stage, concepts/how-it-works. */}
Build your own PDF viewer
The headless packages give you everything a PDF viewer does, without a look of their own: pages
that scroll and zoom, crisp rendering, text selection and search, annotations, forms and
signatures. You build the interface in
it there.
That's a complete viewer: the pages scroll, zoom with a pinch or Ctrl and the wheel, and stay sharp
at every zoom. Everything else is a plugin you add, and a few lines of your own UI.
Headless or the ready-made Viewer?
EmbedPDF comes in two forms, built on the same engine and plugins:
- The Viewer is finished: a toolbar, sidebars, annotation tools and search,
ready in minutes. You configure it and theme it. - Headless is the parts: you decide what the toolbar looks like, where the search box goes, and
what happens when someone clicks an annotation.
Choose headless when the viewer is part of your product's own interface. You can start with the
Viewer and move to headless later: the plugins and their options are the same.
How it's put together
A headless viewer has five parts. You meet them on every page:
- The engine opens and reads PDF files. It runs in the browser, or on your server with
CloudPDF. <Viewer>starts the engine, opens documents, and holds the plugins.- Plugins add one feature each: search, annotations, forms. Register the ones you need; the
rest never reach your bundle. - Layers draw inside each page: the page itself, the text selection, the annotations.
Hooks Composables Functions Services connect your own UI: <Fw only={['react', 'vue', 'svelte']}>useSearch()
to search,useSearchState()to show the results,useSearchEvent()to react when something
happens.inject(EpdfSearch)gives you the plugin's methods to search,
its values as signals to show the results (search.hitCount()), and its events as streams to
react when something happens (search.completed$).
How it works explains each in a few minutes.
Every plugin
Each plugin is one import line. Registering a plugin and importing its components come from the
same place:
| Plugin | What it gives you | Import from |
|---|---|---|
| Stage | Scrolling, zoom, layouts, page navigation | @embedpdf/react/stage |
| Render | Sharp pages at every zoom, deep-zoom tiles | @embedpdf/react/render |
| Links | Clickable links to pages and websites | @embedpdf/react/link |
| Selection | Selecting and copying text | @embedpdf/react/selection |
| Search | Finding text, and showing every match | @embedpdf/react/search |
| Annotations | Highlights, notes, shapes, free text, and your own tools | @embedpdf/react/annotation |
| Stamps | Libraries of stamps, placed by click or by code | @embedpdf/react/stamp |
| Measurements | Distances, perimeters and areas at a real scale | @embedpdf/react/measurement |
| Redaction | Removing content for good | @embedpdf/react/redaction |
| Forms | Filling and building PDF forms | @embedpdf/react/form |
| Signatures | Signing documents and checking signatures | @embedpdf/react/signature |
| Page edit | Rotating, reordering, inserting and deleting pages | @embedpdf/react/page-edit |
| Metadata | A document's title, author and your own fields | @embedpdf/react/metadata |
| Actions | What a PDF does by itself: buttons, scripts, printing | @embedpdf/react/actions |
| View manager | Several documents side by side | @embedpdf/react/view-manager |
| Interaction | The active tool, cursors and touch | @embedpdf/react/interaction |
| Commands | Named actions with labels, icons and shortcuts | @embedpdf/react/commands |
| Shell | Which panels, dialogs and menus are open | @embedpdf/react/shell |
| Translations | Your UI in the user's language | @embedpdf/react/i18n |
Local or cloud
The same viewer runs on either engine. Only how you create the engine and open a document differs;
every plugin,
The local engine runs in the browser: no server, no upload, and it works offline. It's the right
choice when the user already has the file, or when the file may not leave their device.
<CloudPdfCallout
title="Show a 500-page PDF before it downloads"
href="https://www.cloudpdf.com/docs/headless"
cta="Build your viewer on CloudPDF"
With the CloudPDF engine, the file stays on your server and pages arrive as images, so the first
page shows at once, without downloading the whole PDF or the engine. Every call is checked on the
server, several people can review one document together, and every change is stored as it's made.
Your viewer stays the same: swap localEngine() for cloudEngine().
The CloudPDF engine keeps the file on your server. Pages arrive as images, so the first page
shows at once; every call is checked against the user's token; and annotations and form values are
stored as people make them, with everyone who has the document open seeing each other's changes.
Next steps
Website Src Content Docs Viewer Index (`website/src/content/docs/viewer/index.mdx`)
title: Viewer
{/* Generated from docs/content β edit there, then pnpm docs:sync. */}
import { Cards, Card } from '@/components/docs/cards';
{/* Served at β¦/viewer/<framework> with NO trailing slash: a relative href
resolves against β¦/viewer/, so pages are quick-start,
customize/layout, concepts/how-it-works. */}
A complete PDF viewer, ready to embed
The viewer is everything people expect from a PDF app, in one tag: a toolbar, thumbnails, search,
annotations, comments, forms, signatures and stamps. It fits a phone and a wide screen, speaks the
user's language, and works with a keyboard and a screen reader.
That's the whole integration. The rest of these docs are about making it yours.
Make it yours
Everything you change is a few lines of config, the same in every framework. Start at the top and
stop when it's enough:
| You want to | Change | Page |
|---|---|---|
| Match your brand | Your accent color, fonts, light or dark | Theming |
| Leave features out, or make it read-only | disable: ['forms', 'redaction'], readOnly: true |
Turn features off |
| Move or remove buttons | One line per change | Layout |
| Put a toolbar at the bottom, on the side, or several | Where each bar goes | Layout |
| Add your own button | A command: a label, an icon, what it does | Buttons & commands |
| Add your own menu or panel | A list of items, or your component | Menus, Panels |
| Put your own component in the toolbar | Your React, Vue, Svelte or Angular component | Your own components |
| Control the viewer from your app | The viewer object |
The viewer in code |
You never write layout code for phones, menus that open the right way, or keyboard focus. You say
what goes where, and the viewer takes care of how it behaves. A menu in a bottom toolbar opens
upward; the thumbnails slide up from the bottom on a phone; a button that doesn't fit moves into
the "More" menu.
The viewer or headless?
EmbedPDF comes in two forms, built on the same engine and the same plugins:
- The viewer is finished. You configure it, theme it, and add your own buttons, menus, panels
and components. - Headless is the parts. You build the whole interface yourself, and
nothing appears on screen unless you put it there.
Start with the viewer. Every feature is a headless plugin with the same settings,
and inside the viewer your own components can use the headless hooks. If you outgrow the viewer,
what you learned still applies.
Local or cloud
The viewer runs on the local engine by default: the PDF opens in the browser, with no server and no
upload, and it works offline.
<CloudPdfCallout
title="Open a 500-page PDF at once"
href="https://www.cloudpdf.com/docs/viewer"
cta="Use the viewer with CloudPDF"
With CloudPDF, the file stays on your server and pages arrive as images, so the first page shows
at once, without downloading the whole PDF or the engine. Every action is checked on the server,
and annotations and comments are shared live between everyone who has the document open. The
viewer and your config stay the same.
The CloudPDF viewer
The file stays on your server. Pages arrive as images, so the first page shows at once; every
action is checked against the user's token; and annotations, comments and form values are stored
as people make them, with everyone who has the document open seeing each other's changes.
Next steps
Cloudpdf Website Src Content Docs Api Reference Index (`cloudpdf/website/src/content/docs/api-reference/index.mdx`)
title: CloudPDF API reference
import { Callout } from '@/components/docs/callout';
CloudPDF API reference
The CloudPDF server API is what your backend calls: it gets documents in and
mints the short-lived tokens your viewer opens with. The browser never talks to
this API directly.
Every operation below is generated from the same OpenAPI contract that generates
the official SDKs, so the reference cannot drift from the wire.
Install
Seven official SDKs, all generated from the contract below. Your choice here is
remembered for every example in the reference.
Get access
Your API key from the [dashboard](https://app.cloudpdf.com) is a **tenant token**: it can manage
documents and mint document tokens under your account, and nothing else.
Use it as the `token` in every example in this reference.```sh
CLOUDPDF_API_AUTH_TOKENS=<a-long-random-secret>
```
The API token is valid on every surface β including creating the
[tenants](/docs/api-reference/tenants/create) that namespace your
customers' documents. Use it as the `token` in every example, or mint
scoped [tenant tokens](/docs/api-reference/authentication#tenant-tokens)
for your services.Getting a document on screen
Three steps, and only the last one runs in a browser.
- Initialize an upload β request upload
access, transfer the file using the returned instructions, then
commit the upload. - Issue a document token β a short-lived JWT
pinned to one document, carrying exactly the capabilities that user should have. - Open it β hand that token to the viewer or headless components. See
Engine: getting started.
Steps 1β2 are the API surface documented here. Step 3 is the client SDK.
Authentication
Three credentials, and authority mints only downward. Each rung is weaker
than the one above it, and only the last one ever reaches a browser.
How authentication works β β minting,
signing modes, revocation, and the full capability tables.
Resources
Cloudpdf Website Src Content Docs Engine Index (`cloudpdf/website/src/content/docs/engine/index.mdx`)
title: Engine
{/* Generated from docs/content β edit there, then pnpm docs:sync. */}
import { Cards, Card } from '@/components/docs/cards';
The PDF engine
The EmbedPDF engine opens PDF files in the browser and lets you render their pages, read and
search their text, add annotations, fill forms, edit pages and save the result. It runs PDFium,
the PDF library inside Chrome, in a Web Worker, so your page stays responsive. Nothing is
uploaded: the file never leaves the device.
The ready-made viewer and the headless components are built on this engine. Use it directly
when you want PDF features without a viewer: a thumbnail job, a form filler, an export script.
The CloudPDF engine gives your app a full PDF engine over HTTPS. Your files stay on your
CloudPDF server. The engine renders their pages, reads and searches their text, stores
annotations and form values, and checks every call against the permissions in the user's
token.
The CloudPDF viewer and the headless components are built on this engine. Use it directly when
you want PDF features without a viewer: a thumbnail job, a form filler, an export script.
import { localEngine } from '@cloudpdf/engine';
const engine = localEngine();
const doc = await engine.open({ kind: 'bytes', bytes });
const { pages } = await doc.pages.list();
const image = await doc.page(pages[0].ref).render.image();import { cloudEngine } from '@cloudpdf/engine';
const engine = cloudEngine();
const doc = await engine.open({ kind: 'token', token: () => fetchDocumentToken(documentId) });
const { pages } = await doc.pages.list();
const image = await doc.page(pages[0].ref).render.image();What you can do
- Render pages as images, at any size.
- Read, search and
select the text on a page, and find out where
each character is. - Add and edit annotations: highlights, notes, shapes, stamps,
measurements. Copy them to another document with
import & export. - Fill forms, create form fields and
sign documents. - Change pages: rotate, reorder, delete, insert, merge and
split. - Redact content for good, and
save the result. - Group edits into changes that apply all or none, and undo
or redo any of them.
Every call is async, can be cancelled, and fails with a typed error you can branch on. The same
code runs on the local engine and on the CloudPDF engine: only how you create the engine and
open a file differs.
A few things exist only on the local engine: its fonts, warmup(), raw pixels, and saving a
document's layer or to a file. A document opened with localEngine() has them. Code that takes
any engine checks first:
import { isLocalDocument } from '@cloudpdf/engine';
if (isLocalDocument(doc)) {
const layer = await doc.downloadLayer();
}isLocalEngine(engine) does the same for an engine.
Local or cloud
This engine runs in the browser: no server, no upload, and it works offline. That makes it the
right choice when the user already has the file, or when the file may not leave their device.
<CloudPdfCallout
title="Keep the file on your server"
href="https://www.cloudpdf.com/docs/engine"
cta="Read about the CloudPDF engine"
When the file lives on your server, the CloudPDF engine runs the same API there and checks every
call. A user sees the first page as an image, without downloading the whole PDF or the engine
(about 3 MB compressed), and a user who may not download the file never receives it. Your code
stays the same: swap localEngine() for cloudEngine().
Next steps
Cloudpdf Website Src Content Docs Headless Index (`cloudpdf/website/src/content/docs/headless/index.mdx`)
title: Headless
{/* Generated from docs/content β edit there, then pnpm docs:sync. */}
import { Cards, Card } from '@/components/docs/cards';
{/* Served at β¦/headless/<framework> with NO trailing slash: a relative href
resolves against β¦/headless/, so pages are quick-start,
viewing/stage, concepts/how-it-works. */}
Build your own PDF viewer
The headless packages give you everything a PDF viewer does, without a look of their own: pages
that scroll and zoom, crisp rendering, text selection and search, annotations, forms and
signatures. You build the interface in
it there.
That's a complete viewer: the pages scroll, zoom with a pinch or Ctrl and the wheel, and stay sharp
at every zoom. Everything else is a plugin you add, and a few lines of your own UI.
Headless or the ready-made Viewer?
EmbedPDF comes in two forms, built on the same engine and plugins:
- The Viewer is finished: a toolbar, sidebars, annotation tools and search,
ready in minutes. You configure it and theme it. - Headless is the parts: you decide what the toolbar looks like, where the search box goes, and
what happens when someone clicks an annotation.
Choose headless when the viewer is part of your product's own interface. You can start with the
Viewer and move to headless later: the plugins and their options are the same.
How it's put together
A headless viewer has five parts. You meet them on every page:
- The engine opens and reads PDF files. It runs in the browser, or on your server with
CloudPDF. <Viewer>starts the engine, opens documents, and holds the plugins.- Plugins add one feature each: search, annotations, forms. Register the ones you need; the
rest never reach your bundle. - Layers draw inside each page: the page itself, the text selection, the annotations.
Hooks Composables Functions Services connect your own UI: <Fw only={['react', 'vue', 'svelte']}>useSearch()
to search,useSearchState()to show the results,useSearchEvent()to react when something
happens.inject(EpdfSearch)gives you the plugin's methods to search,
its values as signals to show the results (search.hitCount()), and its events as streams to
react when something happens (search.completed$).
How it works explains each in a few minutes.
Every plugin
Each plugin is one import line. Registering a plugin and importing its components come from the
same place:
| Plugin | What it gives you | Import from |
|---|---|---|
| Stage | Scrolling, zoom, layouts, page navigation | @embedpdf/react/stage |
| Render | Sharp pages at every zoom, deep-zoom tiles | @embedpdf/react/render |
| Links | Clickable links to pages and websites | @embedpdf/react/link |
| Selection | Selecting and copying text | @embedpdf/react/selection |
| Search | Finding text, and showing every match | @embedpdf/react/search |
| Annotations | Highlights, notes, shapes, free text, and your own tools | @embedpdf/react/annotation |
| Stamps | Libraries of stamps, placed by click or by code | @embedpdf/react/stamp |
| Measurements | Distances, perimeters and areas at a real scale | @embedpdf/react/measurement |
| Redaction | Removing content for good | @embedpdf/react/redaction |
| Forms | Filling and building PDF forms | @embedpdf/react/form |
| Signatures | Signing documents and checking signatures | @embedpdf/react/signature |
| Page edit | Rotating, reordering, inserting and deleting pages | @embedpdf/react/page-edit |
| Metadata | A document's title, author and your own fields | @embedpdf/react/metadata |
| Actions | What a PDF does by itself: buttons, scripts, printing | @embedpdf/react/actions |
| View manager | Several documents side by side | @embedpdf/react/view-manager |
| Interaction | The active tool, cursors and touch | @embedpdf/react/interaction |
| Commands | Named actions with labels, icons and shortcuts | @embedpdf/react/commands |
| Shell | Which panels, dialogs and menus are open | @embedpdf/react/shell |
| Translations | Your UI in the user's language | @embedpdf/react/i18n |
Local or cloud
The same viewer runs on either engine. Only how you create the engine and open a document differs;
every plugin,
The local engine runs in the browser: no server, no upload, and it works offline. It's the right
choice when the user already has the file, or when the file may not leave their device.
<CloudPdfCallout
title="Show a 500-page PDF before it downloads"
href="https://www.cloudpdf.com/docs/headless"
cta="Build your viewer on CloudPDF"
With the CloudPDF engine, the file stays on your server and pages arrive as images, so the first
page shows at once, without downloading the whole PDF or the engine. Every call is checked on the
server, several people can review one document together, and every change is stored as it's made.
Your viewer stays the same: swap localEngine() for cloudEngine().
The CloudPDF engine keeps the file on your server. Pages arrive as images, so the first page
shows at once; every call is checked against the user's token; and annotations and form values are
stored as people make them, with everyone who has the document open seeing each other's changes.
Next steps
Cloudpdf Website Src Content Docs React Index (`cloudpdf/website/src/content/docs/react/index.mdx`)
title: React
React
Integrate CloudPDF into your React application. This is a placeholder page;
section content will be added here.
Getting started
This section will cover installation, the viewer component, and headless usage
in React.
Cloudpdf Website Src Content Docs Svelte Index (`cloudpdf/website/src/content/docs/svelte/index.mdx`)
title: Svelte
Svelte
Integrate CloudPDF into your Svelte application. This is a placeholder page;
section content will be added here.
Getting started
This section will cover installation, the viewer component, and headless usage
in Svelte.
Cloudpdf Website Src Content Docs Viewer Index (`cloudpdf/website/src/content/docs/viewer/index.mdx`)
title: Viewer
{/* Generated from docs/content β edit there, then pnpm docs:sync. */}
import { Cards, Card } from '@/components/docs/cards';
{/* Served at β¦/viewer/<framework> with NO trailing slash: a relative href
resolves against β¦/viewer/, so pages are quick-start,
customize/layout, concepts/how-it-works. */}
A complete PDF viewer, ready to embed
The viewer is everything people expect from a PDF app, in one tag: a toolbar, thumbnails, search,
annotations, comments, forms, signatures and stamps. It fits a phone and a wide screen, speaks the
user's language, and works with a keyboard and a screen reader.
That's the whole integration. The rest of these docs are about making it yours.
Make it yours
Everything you change is a few lines of config, the same in every framework. Start at the top and
stop when it's enough:
| You want to | Change | Page |
|---|---|---|
| Match your brand | Your accent color, fonts, light or dark | Theming |
| Leave features out, or make it read-only | disable: ['forms', 'redaction'], readOnly: true |
Turn features off |
| Move or remove buttons | One line per change | Layout |
| Put a toolbar at the bottom, on the side, or several | Where each bar goes | Layout |
| Add your own button | A command: a label, an icon, what it does | Buttons & commands |
| Add your own menu or panel | A list of items, or your component | Menus, Panels |
| Put your own component in the toolbar | Your React, Vue, Svelte or Angular component | Your own components |
| Control the viewer from your app | The viewer object |
The viewer in code |
You never write layout code for phones, menus that open the right way, or keyboard focus. You say
what goes where, and the viewer takes care of how it behaves. A menu in a bottom toolbar opens
upward; the thumbnails slide up from the bottom on a phone; a button that doesn't fit moves into
the "More" menu.
The viewer or headless?
EmbedPDF comes in two forms, built on the same engine and the same plugins:
- The viewer is finished. You configure it, theme it, and add your own buttons, menus, panels
and components. - Headless is the parts. You build the whole interface yourself, and
nothing appears on screen unless you put it there.
Start with the viewer. Every feature is a headless plugin with the same settings,
and inside the viewer your own components can use the headless hooks. If you outgrow the viewer,
what you learned still applies.
Local or cloud
The viewer runs on the local engine by default: the PDF opens in the browser, with no server and no
upload, and it works offline.
<CloudPdfCallout
title="Open a 500-page PDF at once"
href="https://www.cloudpdf.com/docs/viewer"
cta="Use the viewer with CloudPDF"
With CloudPDF, the file stays on your server and pages arrive as images, so the first page shows
at once, without downloading the whole PDF or the engine. Every action is checked on the server,
and annotations and comments are shared live between everyone who has the document open. The
viewer and your config stay the same.
The CloudPDF viewer
The file stays on your server. Pages arrive as images, so the first page shows at once; every
action is checked against the user's token; and annotations, comments and form values are stored
as people make them, with everyone who has the document open seeing each other's changes.
Next steps
Cloudpdf Website Src Content Docs Vue Index (`cloudpdf/website/src/content/docs/vue/index.mdx`)
title: Vue
Vue
Integrate CloudPDF into your Vue application. This is a placeholder page;
section content will be added here.
Getting started
This section will cover installation, the viewer component, and headless usage
in Vue.
Website Src Content Docs Engine Annotations Index (`website/src/content/docs/engine/annotations/index.mdx`)
title: Annotations
{/* Generated from docs/content β edit there, then pnpm docs:sync. */}
import { Callout } from '@/components/docs/callout';
import { CardGrid, GridCard } from '@/components/docs/cards';
Annotations
Annotations are what people add on top of a page: highlights, sticky notes, shapes, stamps,
free text, links. They're stored in the PDF, so every PDF app shows them, and they stay separate
from the page's own content, so you can change or remove them later.
const page = doc.page(pageRef);
const { annotation } = await page.annotations.create({
subtype: 'square',
box: { x: 72, y: 92, width: 200, height: 100 },
color: '#0078ff',
});
await page.annotations.update(annotation.ref, { contents: 'Check this table' });
await page.annotations.delete(annotation.ref);Kinds
Each kind has its own page with an example and its fields:
List annotations
Every annotation in the document, or the ones on one page:
const { annotations } = await doc.annotations.list(); // every page
const { annotations } = await doc.annotations.list({ pages: [pageA, pageB] }); // some pages
const { annotations } = await doc.page(pageRef).annotations.list(); // one pageA list is cheap even for a whole document: the engine reads the annotations without loading the
pages. Each annotation names its page, and the list's pages has one entry per page it read.
Each annotation has a subtype (its kind), a rect (where it is on its page) and a ref (its
address). Keep the ref: it's how you change or delete the annotation later.
Positions are in points from the top-left corner of the page, with y going down, like every
position in the engine: a box is { x, y, width, height } and a point { x, y }. See
Coordinates.
for (const annotation of annotations) {
console.log(annotation.subtype, annotation.rect, annotation.contents);
}A list includes kinds the engine can't create yet: they come back as subtype: 'unsupported',
with their position and common fields. They stay in the PDF untouched.
Form widgets are annotations too, of kind 'widget', but they're the form's: a list here never
holds one, and doc.forms.list() returns them with the fields. You place and change them through
the form; see Creating form fields.
Fields every annotation has
Besides subtype, rect and ref, every kind has these fields:
| Field | What it is |
|---|---|
contents |
A comment, the text PDF apps show for the annotation. |
subject |
A short title PDF apps show with the comment, such as 'Question'. |
nm |
A unique name on its page. See Refs & identity. |
reply |
The annotation it answers, or null. See Notes & replies. |
popup |
The ref of its popup window, or null. See Popups. |
groupId |
The group it belongs to, when you work with groups. |
Each annotation also has the flags and the fields that say who wrote it.
Add an annotation
create() takes the kind and its fields, and returns the new annotation:
const { annotation } = await page.annotations.create({
subtype: 'circle',
box: { x: 60, y: 392, width: 120, height: 100 },
interiorColor: '#1e90ff',
});Fields you leave out get the kind's default. The engine draws the annotation's appearance for
you, so it shows the same in every PDF app.
Stamps and file attachments also carry bytes (an image, a file). Pass those in a second
argument; see Stamps and
File attachments.
Change an annotation
update() takes the ref and only the fields you want to change:
await page.annotations.update(annotation.ref, {
color: '#dc143c',
contents: 'Reviewed',
});A field you leave out keeps its value. A field set to null is removed, for example{ interiorColor: null } for no fill. A field's value is replaced whole (a file, a list of
points), except rich text's body style, which merges.
You can also pass an annotation you read straight back: every field it has is one update()
accepts. That makes "change a copy, then save it" code simple:
await page.annotations.update(annotation.ref, { ...annotation, opacity: 0.5 });Fields the engine writes (author, the dates, userId, refβ¦) are accepted and ignored. For
free text, send contents or richText, not both with different text.
Move or resize by `rect`
An update takes a new rect for every annotation, as a script's annot.rect does in Acrobat. A
note, a file, a link, a popup and a redaction take it as it is.
Every other annotation is drawn from its shape: rectangles, ellipses, text boxes, stamps, carets
and form fields' widgets from their box, lines, polylines, polygons and ink from their points,
and text markup from its quads. For these, a new rect puts the shape there: each point is mapped from the oldrect onto the new one, each axis on its own. A rect of the same size moves the drawing, and a
bigger one stretches it:
const { rect } = line;
// 40 points right, 20 down
await page.annotations.update(line.ref, {
rect: { ...rect, x: rect.x + 40, y: rect.y + 20 },
});The engine then works out rect again from the shape. After a resize it can differ a little from
the one you sent: a line keeps its width, and its endings keep their size. The rect you read,
sent back, changes nothing, so a read passed straight back works.
- A new
rectwith a new shape (box, points or quads) fails withInvalidArg: send one or the
other. - A turned drawing can only move. A
rectof another size fails withInvalidArg; resize itsboxor its points instead. A box at a quarter turn is the exception: it stands upright on the
page, so arectresizes it along its own sides. - A
rectthat would squash a drawing to no width or no height fails withInvalidArg.
shapeForRect(annotation, rect) gives the fields that put an annotation at a rect, by the same
rule. Use it to place a copy somewhere else:
import { shapeForRect } from '@embedpdf/engine';
await page.annotations.create({ ...copy, ...shapeForRect(copy, target) });A create works out a drawn annotation's rect from its shape, whatever rect you send, so a
copy of another app's drawing keeps its shape exactly. A box turned a quarter can be created by
its rect instead of its box; see Turned shapes.
Delete and reorder
await page.annotations.delete(annotation.ref);
await page.annotations.reorder([annotation.ref], 'start'); // to the back
await page.annotations.reorder([annotation.ref], { after: other.ref }); // just above anotherDeleting an annotation also deletes its popup, if it has one, and its replies; see
Delete a note.
Annotations on a page are drawn in order, so the last one is on top. reorder(refs, position)
moves annotations together, in the order you pass them, right after or before another annotation
of the page, or to the back ('start') or the front ('end'). It resolves the page's new order,order, as refs, and fires annotations.reordered.
Widgets are drawn above every annotation, in an order of their own, so a widget is never a
neighbour here: reorder() refuses one with InvalidArg. Restack widgets withdoc.forms.reorderWidgets().
Style
Most kinds share the same style fields:
| Field | What it is | Default |
|---|---|---|
color |
Line color, such as '#1e90ff' |
per kind |
interiorColor |
Fill color of closed shapes, or null for no fill |
no fill |
opacity |
From 0 (invisible) to 1 (solid) |
1 |
strokeWidth |
Line width in points | 1 |
borderStyle |
'solid' or 'dashed' |
'solid' |
dashArray |
The pattern a 'dashed' border draws, such as [3, 2] |
[3] |
A pattern alone doesn't make a border dashed: a 'solid' border keeps its dashArray and draws
solid. dashArray: null on a dashed border draws the default [3], 3 points on and 3 off. A
border from another app can read 'beveled' or 'inset'; only form widgets draw those, so a
write takes them only sent back unchanged.
Colors are '#rrggbb', as in CSS: the engine gives them in lowercase and takes either case. They
never include transparency: that's what opacity is for. The default color depends on the kind
(yellow for a highlight, red for a shape, black for free text); each kind's page lists its
defaults.
A PDF can store a color as gray or CMYK. It reads as the same color in '#rrggbb', and sent back
unchanged it stays as the file has it, so passing a read straight back rewrites nothing.
Who wrote it
The engine records who created and last changed each annotation, from the user the document was
opened for. You read these fields; you don't set them:
| Field | What it is |
|---|---|
author |
The display name shown in PDF apps. |
createdAt |
When it was created, as an ISO date. |
modifiedAt |
When it was last changed. |
userId |
The id of the user who owns it. |
createdBy |
The id of the user who created it. |
modifiedBy |
The id of the user who last changed it. |
groupId |
The group it belongs to, when you work with groups. |
The user comes from the identity you pass when you open the document; see
Opening documents.
The user comes from the identity in their token; see
Authentication.
Flags
Every annotation has the standard PDF flags as true/false fields. Set the ones you need; the
rest stay off (on create) or keep their value (on update). The exception is print: a new
annotation prints unless you set print: false, as in Acrobat. A popup doesn't print.
await page.annotations.update(annotation.ref, { print: true, locked: true });| Flag | What it does |
|---|---|
print |
Printed with the page. Off: shown on screen only. |
hidden |
Not shown and not printed. |
noView |
Not shown on screen (can still print). |
locked |
Can't be moved, resized or deleted in PDF apps. |
lockedContents |
Its text can't be changed in PDF apps. |
readOnly |
Can't be changed at all in PDF apps. |
noZoom |
Keeps its size when the page is zoomed (notes, icons). |
noRotate |
Stays upright when the page is rotated. |
invisible |
Hidden when the app doesn't know the annotation's kind. |
toggleNoView |
Flips noView when the pointer is over it. |
Flags are instructions for PDF apps. The engine itself still lets you change a locked
annotation; the viewer's annotation tools respect the flags in their interface.
Permissions
Reading annotations needs doc.annotate.read. Creating, changing and deleting them needs a
permission for that action, which can be limited to the user's own annotations:annotations:update:self lets a user edit what they created and nothing else. See
Permissions.
<CloudPdfCallout
title="Let many people annotate one document"
href="https://www.cloudpdf.com/docs/engine"
cta="Collaborate with CloudPDF"
With CloudPDF, annotations are stored on your server as people make them. Everyone with the
document open gets each other's changes as events, and the server
makes sure people only change the annotations they may change.
More
- Import & export: copy annotations to another
document, or store them apart from the PDF. - Drawing annotations yourself: render annotations as
separate images, bake them into the page, or export them as a PDF. - Refs & identity: what a
refcontains, and why you never
build one. - Changes & undo: make several annotation edits as one action,
and undo them.
Website Src Content Docs Engine Forms Index (`website/src/content/docs/engine/forms/index.mdx`)
title: Forms
{/* Generated from docs/content β edit there, then pnpm docs:sync. */}
Forms
A PDF form is a set of fields: text boxes, checkboxes, radio buttons, dropdowns and lists. The
engine reads every field with its value, fills fields in, and redraws them so the new value shows
in every PDF app.
const { fields } = await doc.forms.list();
const name = fields.find((field) => field.name === 'billing.name')!;
await doc.forms.setValue(name.ref, { value: 'Ada Lovelace' });Read the form
list() returns every field in the document:
const { fields } = await doc.forms.list();
for (const field of fields) {
console.log(field.name, field.family);
}Every field has these:
| Field | What it is |
|---|---|
name |
The full name, with the groups it's in: billing.name. |
family |
'text', 'checkbox', 'radio', 'combobox', 'listbox', 'pushbutton', 'signature' or 'unknown'. |
ref |
Its address. Keep it: it's how you fill, reset or change the field. |
readOnly |
true when PDF apps don't let the user change the value. |
required |
true when PDF apps ask for a value before the form is submitted. |
noExport |
true when the field is left out when the form is submitted. |
alternateName |
The tooltip PDF apps show, also read by screen readers, or null. |
groupId |
The group the field belongs to: who fills it in, such as 'buyer', or null. See Signers and groups. |
widgets |
Where the field shows on the pages (see Widgets). |
A document without a form returns an empty fields. The list's formKind says what the document
has: 'none', 'acroform' (a regular PDF form) or 'xfa' (see Good to know).
The list also has needsAppearances, true when the form asks PDF apps to draw its fields
themselves (repair() can clear it), andcalculationOrder (see Calculations).
Values
Each family has its own value fields. Check family first, and TypeScript knows which ones the
field has:
if (field.family === 'text') console.log(field.value); // string
if (field.family === 'checkbox') console.log(field.checked); // boolean
if (field.family === 'listbox') console.log(field.selectedValues); // string[]| Family | Its value | Also |
|---|---|---|
text |
value, a string |
defaultValue, maxLength (null: no limit), multiline, password, comb |
checkbox |
checked |
exportValue: what the form data holds while it's checked |
radio |
value: the export value of the chosen button, or 'Off' |
noToggleToOff, radiosInUnison |
combobox |
value |
options, edit (free text allowed), defaultValue |
listbox |
selectedValues, in option order |
options, multiSelect, defaultValue (option values), topIndex (the option in its top row) |
pushbutton |
none: a button only runs an action | Each widget's caption, on its row in widgets |
signature |
none here: see Digital signatures |
Each option of a dropdown (combobox) or list is { label, value, selected }. label is what the
user sees; value is what the form stores, and what you pass when you fill the field in.
Who created and filled it in
A field records who created it and who last filled it in, from the identity the document was opened
with:
field.createdBy; // 'user-17': who created the field, or null
field.filledBy; // 'user-42': whose fill last changed the value
field.filledByName; // 'Ann de Vries'
field.filledAt; // '2026-10-08T10:42:00Z'| Field | What it is |
|---|---|
createdBy, createdAt |
Who created the field, and when. null for a field another app made. |
filledBy, filledByName, filledAt |
The user whose fill last changed the value, their name, and when. A calculated value counts as filled in by whoever's fill calculated it. null after a reset, or an anonymous fill. |
importedBy |
Who imported the field with its attribution kept. |
The engine stamps these; a write can't set them. A fill that leaves the value as it was changes
none of them, and undoing a fill puts the previous ones back. A widget has no attribution of its own:
its field's is the one.
Widgets
A field shows on a page through a widget: the box you see and click. Most fields have one. A
radio group is one field with a widget per button, and a field repeated on every page (a
reference number in the header, say) has a widget per page, all showing the same value.
Each widget in field.widgets has its page and its ref. The form also lists every widget inwidgets: where it is (rect, in points from the page's top-left), how it looks, its state,
what it does when clicked (actions), and the ref of the field it belongs to (field). So one read is enough to draw your own form layer
over a page:
const { widgets } = await doc.forms.list();
for (const widget of widgets) drawBox(widget.page, widget.rect, widget.field);Checkbox and radio widgets in field.widgets also have their exportValue (what the form data
holds when this widget is checked) and checked.
A form field's widgets come with the form, never with the annotations: page.annotations.list()
doesn't return them, and reading them takes doc.forms.read, not doc.annotate.read. Each page's
widget images, every look and state, come from page.forms.renderAppearances().
Find a field
The ref from list() is the best address: it keeps working when the field is renamed. When you
know a field by its name instead (from your backend, or from data you stored), make a ref from the name:
import { toFieldRef } from '@embedpdf/engine';
const field = await doc.forms.get(toFieldRef('billing.name'));The name is the full name, with dots between the groups. A field that isn't there fails withNotFound.
Fill in a field
setValue() takes the field's ref and its new value, in the same fields a read returns. What
you read, you can write back:
await doc.forms.setValue(name.ref, { value: 'Ada Lovelace' }); // text
await doc.forms.setValue(agree.ref, { checked: true }); // checkbox
await doc.forms.setValue(plan.ref, { value: 'yearly' }); // radio group
await doc.forms.setValue(country.ref, { value: 'NL' }); // dropdown
await doc.forms.setValue(toppings.ref, { selectedValues: ['cheese', 'olives'] }); // list| Family | Value |
|---|---|
text |
{ value }; null empties it |
checkbox |
{ checked } |
radio |
{ value }: the export value of the button to choose; 'Off' or null clears |
combobox |
{ value }: an option value; null clears |
listbox |
{ selectedValues }: option values; [] clears |
setValue() returns the field as it is now:
const { field } = await doc.forms.setValue(name.ref, { value: 'Ada' });The engine redraws the field's widgets, so the page shows the new value in every PDF app. Render
the page again to see it.
What the engine checks
The engine checks a value before it writes anything. A call that fails with InvalidArg leaves
the field as it was:
- The value must be the family's own:
{ checked: true }on a text field fails. - A dropdown or list takes option values, not labels. A dropdown with
edit: truealso takes
free text. More than one value needs a list withmultiSelect. - A radio group's
valuemust be the export value of one of its buttons. - A radio group with
noToggleToOffcan't be cleared once a button is chosen. - Push buttons and signature fields hold no value you can set.
Some things are fixed up rather than refused: text longer than maxLength is cut to fit, and
in a radio group with radiosInUnison, the buttons that share an export value check together.
readOnly is an instruction for PDF apps: they don't let the user change the field. The engine
still accepts your own writes, for example a total your code calculates.
Reset
reset() puts back each field's defaultValue, or empties it when it has none. Without an
argument it resets the whole form; pass a ref, or a list of refs, to reset only those fields:
await doc.forms.reset(); // every field
await doc.forms.reset(name.ref);
await doc.forms.reset([name.ref, email.ref]);It returns the fields it changed, as they are now. A field that already holds its default is
left alone and isn't in the list:
const { fields } = await doc.forms.reset();
console.log(`${fields.length} fields reset`);Fields that hold no value, such as push buttons and signature fields, are skipped. Resetting the
whole form also skips the fields a signature locked and fields stored inline;
name one of those yourself and the call fails, the same as a setValue() would.
Move fields and values between documents
export() takes fields out as one bundle, each with its value. importValues() fills another
document's fields of the same name from it, and import() copies the fields themselves:
const bundle = await doc.forms.export();
const { fields, dropped } = await other.forms.importValues(bundle);Each value is checked like a setValue(). A field the document doesn't have, a value it can't
take, or a field a signature locked is left out and listed in dropped; the rest still land, as
one change you can undo. See Import & export.
Events
setValue(), reset() and importValues() fire forms.valueSet, one per field that changed.
Each carries what the call returned for that field:
doc.events.on('forms.valueSet', ({ field }) => {
updateSummary(field);
});When one call changes several fields, such as reset() or importValues(), their events shareorigin.tx, like an annotation import.
Each value is saved on the server as you set it. Everyone with the document open gets the event,
with origin.kind: 'remote' for other people's changes. See
Events.
Permissions
Reading the form needs doc.forms.read, and exporting it doc.download too. Filling in,
resetting and importing values needs doc.forms.fill, which includes doc.forms.read. See
Permissions.
A signed document can take filling away, for the whole form or for single fields. The call then
fails with ProtectedDocument; see
Digital signatures.
More
- Creating form fields: add fields and widgets, change and delete
them, repair a broken form. - Import & export: copy fields from one document to another,
or fill a form in from a bundle. - Digital signatures: sign a signature field and check the
signatures in a document. - Changes & undo: fill in several fields as one action, and
undo it.
Good to know
Calculations, formatting and validation
The engine doesn't run the form's JavaScript. setValue() stores the value you give it: it
doesn't recalculate a total, format a date or an amount, or run a validation script. PDF apps run
those scripts while a user types.
A field's scripts are in field.actions (keystroke, format, validate and calculate), and
the list's calculationOrder holds the fields whose totals are calculated, in order. To write them,
see Scripts and calculations.
The viewer's form plugin runs these scripts and writes their results as one
change: forms.setValue, forms.reset, forms.setDisplay
(whether the field shows and prints) and forms.setAppearanceText (the formatted text the field
shows, while its value stays the same) ops, in the order the scripts asked for them. All of them
are written, or none is: a field a signature locked refuses the whole change. You only need these
ops when you run form scripts yourself.
Recovered fields
Some PDFs have fields that show on the page but are missing from the form's list of fields, a
bug in the tool that made them. The engine finds them anyway: they come back withorigin: 'recovered' and fill in like any other field. Other PDF apps may not see them;repair() fixes the file.
A field on several pages
A field's widgets can be on different pages. The meta of a setValue() or reset() result
lists every widget whose look changed, with its page (meta.changedWidgets). For a radio group
that's the button switched off and the one switched on. Use it to render only the pages that
changed.
meta.redrawnWidgets lists those whose picture the engine drew again, such as a text field's
widgets after a new value. A checkbox or radio button that switches shows another picture it
already has, so it isn't in the list.
Checkboxes with several widgets
A checkbox can have several widgets, each with its own exportValue: a "Yes / No" pair, for
example. { checked: true } checks the first widget; { value: 'No' } checks the widget with
that export value.
In the file, each checkbox and radio widget also has an onState: the name of its checked look
(PDF's appearance state). It's usually the same as the export value, and widgets the engine
creates always use their exportValue for it. You never need it to fill a field in.
The field's value in the file (valueEntry) is the checked widget's onState, or 'Off', as the
PDF standard says. Some tools write the widget's position instead ('0', '1'); such a value
still resets to that widget.
XFA forms
formKind is 'xfa' when the PDF also carries an XFA form, an older XML form format. The
engine works with the document's regular fields and never runs XFA.
Fields stored inline
A field that the file stores inside another object, rather than as an object of its own, can be
read but not changed: writes fail with InvalidArg. PDF tools rarely write fields this way.
Website Src Content Docs Headless Annotations Index (`website/src/content/docs/headless/annotations/index.mdx`)
title: Annotations
{/* Generated from docs/content β edit there, then pnpm docs:sync. */}
import { Cards, Card } from '@/components/docs/cards';
{/* This page is served at β¦/headless/<framework>/annotations with NO trailing
slash, so a relative href resolves against β¦/headless/<framework>/.
Sibling pages are annotations/tools, other sections text/selection. */}
Annotations
Annotations are what people add on top of a page: highlights, sticky notes, shapes, stamps and
text boxes. The annotation plugin shows the ones a PDF already has, lets people draw and edit new
ones, and gives your code the same abilities. Every annotation is saved into the PDF, so it looks
the same in every other PDF app.
Show the annotations in a PDF
Register interactionPlugin() and annotationPlugin(), then put an <AnnotationLayer> above the
rendered page:
Try it: click the rectangle and drag it, or a corner to resize it. Double-click the text box
to type in it.
While the annotation layer is on the page, the render layer leaves the annotations out of the page
picture by itself. The annotation layer draws them instead, so they can be selected, moved and
changed.
interactionPlugin() is the shared input system. Every tool, from text selection to drawing,
speaks through it; see Selecting & copying for how it works.
Let people draw
A tool says what a click or a drag on the page creates. Activate one and people can draw:
Try it: drag on the page to draw a rectangle, then pick another tool. Highlight marks the
text you select.
pointerselects: click an annotation to select it, then move, resize or turn it.squaredraws a rectangle: drag, or just click for a default-size one.inkdraws freehand.highlighthighlights the text you select.notedrops a sticky note where you click.
After you draw, the new annotation is selected and the tool stays active, so you can draw another.
You can change both; see What happens after drawing.
How it fits together
Everything on these pages is one of five things:
| Thing | What it is | Where you use it |
|---|---|---|
| Annotation | A record in the PDF: its kind, position, colors and text. | annotation.list() |
| Tool | What a click or drag creates, with which defaults, cursor and preview. | annotationPlugin({ tools }) |
| Layer | What each page draws: the annotations, the selection box and handles, the preview. | <AnnotationLayer> |
| Overlay | Your own UI attached to an annotation, such as a menu or a hover card. | <AnnotationMenu> |
| API | Reading, creating, changing and deleting annotations, and listening for changes. | useAnnotation() |
An annotation here is the same record the engine reads and writes: the same kinds, the same fields
and the same names. What you learn in the engine's annotation pages
holds here too.
The built-in tools
| Tool | Makes | How |
|---|---|---|
square, circle |
A rectangle or an ellipse | Drag, or click for a default size |
line |
A line | Drag, or click for a default length |
polygon, polyline |
A closed or open shape of points | Click each point, double-click to end |
ink, ink-highlight |
Freehand drawing, or a see-through marker | Draw |
free-text, free-text-callout |
A text box, or one with a line pointing out | Click or drag, then type |
highlight, underline, strikeout, squiggly |
A mark over text | Select text |
insert-text, replace-text |
A suggested insertion or replacement | Select text |
note |
A sticky note | Click |
stamp |
An image or a PDF drawing | Click, then pick a file |
attachment |
A file pinned to a spot | Click, then pick a file |
link |
An area that opens a page or a website | Drag |
Measurement tools (distance, perimeter, area) and redaction (redact) are annotation tools
too. Their plugins add the rest of what they need.
You can change any tool, or add your own; see Tools & defaults.
Who wrote it
Every annotation records who made it and who last changed it (author, createdAt,modifiedAt). You don't set these on the annotation: they come from the user the document was
opened for.
Tell the viewer who the user is, once. Every document it opens uses it:
Try it: draw a rectangle. The list says Dana Smith made it, and when.
displayName becomes the author other PDF apps show, and userId records who owns the
annotation. The same identity decides which annotations count as the user's own when
permissions say "only their own".
To open one document as someone else, give its entry in initialDocuments its own identity. It
replaces the viewer's for that document:
const initialDocuments = [
{ source: contract, identity: { userId: 'bot', displayName: 'Import job' } },
];A document keeps the identity it was opened with. <Fw only={['react', 'vue', 'svelte']}>If you change the viewer's identity, documents
you open afterwards use the new one.
To change the viewer's identity, for example when someone else signs in, useinject(EpdfViewer).updateSettings({ identity }). Documents you open afterwards use the new one. To
follow your app's login:
effect(() => viewer.updateSettings({ identity: auth.identity() }));It comes from the identity in the user's token; see
Authentication.
Settings
Set them when you register the plugin. snap, chrome, afterCreate and editable can change
while the app runs, with annotation.updateSettings({ β¦ }); annotation.resetSettings() goes back to what you
registered, and useAnnotationSettings() gives you the current settings.
| Setting | What it does | Page |
|---|---|---|
tools |
Change the built-in tools, or add your own | Tools & defaults |
afterCreate |
Select a new annotation? Keep the tool active? | What happens after drawing |
chrome |
The selection's outline, handles and rotation handle, and their colors | Change the handles |
snap |
Snapping to other annotations and to angles | Snapping |
editable |
Which annotations people may change by hand: true, false, or kinds |
What people may change |
Every color in chrome can also come from CSS, for dark mode or a different look per viewer: see
Colors & theming.
Next steps
Website Src Content Docs Headless Forms Index (`website/src/content/docs/headless/forms/index.mdx`)
title: Filling forms
{/* Generated from docs/content β edit there, then pnpm docs:sync. */}
Filling forms
Many PDFs are forms: text boxes, checkboxes, dropdowns, signature fields. Register formPlugin() and
put <FormLayer> in each page, and people can fill them in:
Each field becomes a real HTML control, placed and styled like the field in the PDF: people type in text
boxes, tick checkboxes, pick from lists, and move between fields with Tab. A value is saved into the
document as it's entered, so it's there when the PDF is downloaded, and every PDF app shows it. The
form layer paints the fields as the engine draws them, so while it's on the page, the render layer
leaves them out of the page picture.
Read the form
<Fw only={['react', 'vue', 'svelte']}>
useFormState() gives you every field, and
form.fields() is every field, as a signal: your view updates when a value changes.
Each field has its full name (such as billing.name), its family ('text', 'checkbox','radio', 'combobox', 'listbox', 'pushbutton' or 'signature'), its value, and a ref: its
address for everything else. The fields and their values are the engine's; see
Forms for every family's value.
For one field, such as a total you show in your own UI, use useFormValue():
toFieldRef(name) makes a ref from a field's full name, for when you know a field by name.
Fill in a field
setValue() takes the same values a field reads:
await form.setValue(name.ref, { value: 'Ada Lovelace' }); // text
await form.setValue(agree.ref, { checked: true }); // checkbox
await form.setValue(plan.ref, { value: 'yearly' }); // radio group: the button's export value
await form.setValue(country.ref, { value: 'NL' }); // dropdown: an option's value
await form.setValue(toppings.ref, { selectedValues: ['cheese', 'olives'] }); // listIt runs the same checks and scripts as typing does, and resolves with { field, status }: status
is 'applied', 'unchanged', or 'rejected' when the form's own validation refused the value.
The field shows the new value at once, before the engine answers, and the promise resolves once it
has. What the form's scripts calculate from it goes with it, as one change: one request, and one
step to undo. The field stays editable meanwhile; filling it in again is the next change. A value
the engine refuses goes back to the one the field had.
Your own inputs can fill in fields too. form.controlOf(ref) gives you a field as an AngularFormControl: it holds the field's value, and fills in the field when the control changes, with
the same checks and scripts:
To fill many fields at once, such as from your backend, pass them by name:
await form.importValues({ 'billing.name': 'Ada Lovelace', 'billing.country': 'NL', agree: true });importValues() and setValues() write every value as one change, with the calculations run once
after them. Each field's own keystroke and validation scripts don't run, as when a script sets a
field's value in Acrobat.
Check required fields
A form marks some fields as required. Check them before you submit or download:
const { valid, missing } = form.validate();
const widget = !valid && form.getWidget(missing[0].widgets[0]);
if (widget) stage.reveal(widget.page, { rect: widget.rect });missing lists the required fields that are empty, in page order, so you can take the user to the
first one. Only the fields the user may fill in count: another signer's are theirs to fill.
Calculations, formatting and validation
Many forms add up totals, format dates and check values with scripts. With JavaScript on in the
actions plugin, they run as people type, as in Acrobat:
When a script refuses a value, such as a date in the wrong format, the field keeps its old value,
the box keeps what was typed so it can be corrected, as in Acrobat, and
<Fw only={['react', 'vue', 'svelte']}>onValidationRejectedvalidationRejected$
The scripts read the form as the user sees it, values still on their way included, so nothing is
read from the engine for them. Their engine starts when the first field of a form with scripts takes
focus, so the first value doesn't wait for it; a form without scripts never starts it.
Reset the form
await form.reset(); // every field the user may fill in, back to its default
await form.reset([name.ref, email.ref]); // some fieldsMove form data in and out
For your own backend, plain values are simplest:
const values = form.exportValues(); // { 'billing.name': 'Ada Lovelace', agree: true, β¦ }
await form.importValues(values);Fields are matched by their full name. A field the form doesn't have, or a value it can't take, is
skipped; the rest still land.
To copy the fields themselves to another document, with their boxes, values and scripts, take
them out as a bundle and import it there. FormTransfer turns a bundle into one JSON text to
store, and back:
const bundle = await form.export(); // every field; or form.export({ fields }), ({ pages })
const text = FormTransfer.stringify(bundle);
const { fields, dropped } = await otherForm.import(await FormTransfer.parse(text));The import adds the fields as one change you can undo. A field whose name the form has already is
left out and listed in dropped. By default each field keeps who created and filled it in, which
needs canRestoreAttribution(); pass { attribution: 'stamp' } to make them yours. See
Import & export.
Methods
What useForm() gives you for reading and filling. The verbs that change the form itself are
on Building forms.
| Method | What it does |
|---|---|
get(ref) |
One field, with its value, or null when the form has no such field. Read the form |
getValue(ref) |
One field's value, in the shape setValue() takes, or null. In a component, use useFormValue(ref). |
getFieldForWidget(widget) |
The field a box belongs to, from the box's ref |
getWidget(widget) |
A box's row: its page, its place (rect), its look and its state, or null when no page shows it |
getWidgets(ref) |
Every box a field shows in, as rows in the field's order: a radio group's buttons, or a field repeated on several pages |
listWidgets(page) |
Each field box on a page, with its place, its control, its value and whether it can be filled. For drawing the fields yourself. |
getWidgetAt(page, point) |
The field box under a point on a page, with its field, or null |
setValue(ref, value) |
Fills in a field with { value }, { checked } or { selectedValues }, running the form's checks and scripts. Resolves { field, status }. Fill in a field |
setValues(entries) |
Fills in several fields, each { ref, value }, as one change with one recalculation; a field you may not fill in, or a value it can't take, fails on its own. Resolves { applied, skipped, failed }. |
importValues(values) |
Fills in fields by full name from a plain object, as one change with one recalculation. Resolves { applied, skipped, failed }. Move form data in and out |
exportValues() |
Every value, keyed by full name, as a plain object |
export(selection?) |
Whole fields with their boxes, values and scripts, as a bundle: every field, or { fields } or { pages }. Move form data in and out |
import(bundle, options?) |
Adds a bundle's fields, as one change. Resolves { fields, refMap, dropped }. Needs canDesign(). Move form data in and out |
reset(refs?) |
Puts back each field's default, for every field the user may fill in or the fields you pass. Resolves { fields }, the fields it changed. Reset the form |
validate() |
Checks the required fields the user may fill in. Returns { valid, missing }, the empty ones in page order. Check required fields |
activateWidget(widget) |
Does what a click on a field box does, such as running a button's action |
getSnapshot() |
Everything at once, as the user sees it: what the engine's list() returns, with the values still on their way. fields, widgets, formKind, needsAppearances and calculationOrder |
refresh() |
Reads the whole form again, and <Fw only={['react', 'vue', 'svelte']}>onResyncedresynced$ |
canRead(), canFill(ref) |
Whether reading the form, and filling in a field, are allowed. Permissions |
getSettings(), updateSettings(changes), resetSettings() |
Reads the settings, changes them while the app runs, or goes back to what you registered. In a component, use useFormSettings(). Settings |
Every method that returns a promise also takes a signal in its options, to cancel it.
State
| Field | What it is | Read once |
|---|---|---|
fields |
Every field, with its value | list(), or list({ family, page, name }) for some |
status |
Whether the form is read: 'idle', 'loading', 'ready', 'forbidden' or 'error' |
getStatus() |
formKind |
What the document has: 'none', 'acroform' (a regular PDF form) or 'xfa' |
getFormKind() |
selectedField |
In design mode, the field of the selected box, or null; see Building forms |
getSelectedField() |
For one field, useFormValue(ref)
Settings
Set them when you register the plugin, and change them while the app runs withform.updateSettings({ β¦ }); form.resetSettings() goes back to what you registered, anduseFormSettings() gives you the current settings.
| Setting | What it does | Default | From CSS |
|---|---|---|---|
validation |
Run the form's own checks ('scripts'), or accept every value ('none') |
'scripts' |
β |
focus.color |
The ring around the field being filled in | the viewer's accent | --epdf-form-focus |
fields.border |
The edge of a field that has no border of its own | the viewer's accent at 55% | --epdf-form-field-border |
fields.background |
Inside a field that has no background of its own | 'rgb(255 255 255 / 0.92)' |
--epdf-form-field-background |
fields.color |
The check mark and text of a field that has no color of its own | '#1f2a44' |
--epdf-form-field-color |
fillable |
Which fields people fill in by hand: true, false, or field families |
true |
β |
Scripts need JavaScript on in the actions plugin; without it, 'scripts' accepts every value too,
and a form with scripts logs a hint once.
fillable is for the people using your app. A field it leaves out draws as a read-only field
does, and takes no typing, clicks or picks; your code still fills it in with setValue() andimportValues(), and canFill(ref) doesn't change:
formPlugin({ fillable: false }); // show the values, take no input
form.updateSettings({ fillable: ['text', 'checkbox'] }); // only these families
form.updateSettings({ fillable: true }); // every field againThe families are 'text', 'checkbox', 'radio', 'combobox', 'listbox', 'pushbutton' and'signature' (signing a field is filling it in). To stop your code too, leave doc.forms.fill out
of the scope (see Permissions).
The colors are what the viewer draws around fields; a field's own colors are saved in the PDF (see
Building forms). A CSS variable wins over the setting: see
Colors & theming.
Events
| Event | Fires when | Carries |
|---|---|---|
onValueChanged |
A field's value changed | field, origin |
onValidationRejected |
The form's validation refused a value | field, issues |
onResynced |
The whole form was read, as when it opens, after refresh() or a repair |
snapshot |
onSettingsChanged |
The settings changed | settings, changed |
Fields that are added, changed or removed have their own events; see
Building forms.
Permissions
| Permission | What it allows | Check |
|---|---|---|
doc.forms.read |
Reading the form | form.canRead() |
doc.forms.fill |
Filling it in | form.canFill(ref) |
Without it, the fields show their values but can't be changed. A user with a fields:fill
permission for one group fills in that group's fields only: form.canFill(ref) answers per field,
the other fields can't be changed and Tab skips them, validate() leaves them out, and reset()
leaves them as they are. See Signers and groups.
Good to know
Forms and annotations
A field's widgets are the form's: the annotation plugin never lists them. With the
annotation plugin registered, the <FormLayer> and the annotation layer share the page: fields stay fillable with thepointer tool, and become movable boxes in design mode.
Keyboard
Tab moves to the next field in the page's order. In a text box, Enter confirms and Escape puts back
the value it had.
Collecting responses
On the local engine, the values live in the document in this tab until it's downloaded.
<CloudPdfCallout
title="Collect form responses on your server"
href="https://www.cloudpdf.com/docs/headless/forms"
cta="Collect forms with CloudPDF"
With CloudPDF, each value is stored on your server as it's entered, so a half-filled form survives a
closed tab, and your backend reads the answers without asking the user to download or upload a
file.
Each value is stored on your server as it's entered, so a half-filled form survives a closed tab.
XFA forms
Some PDFs also carry a form in the older XFA format. The viewer works with the document's regular
fields and never runs XFA; see Forms.
Signed documents
A signature can lock the whole form, or some fields. Locked fields show their values and can't be
changed; see Signatures.
Website Src Content Docs Viewer Features Index (`website/src/content/docs/viewer/features/index.mdx`)
title: All features
{/* Generated from docs/content β edit there, then pnpm docs:sync. */}
All features
Every feature is on from the start. Leave out the ones you don't need with disable, by the name
in the first column:
{ disable: ['forms', 'redaction', 'measurement'] }| Feature | What people can do | Settings |
|---|---|---|
annotations |
Highlight, underline and strike out text; draw; add notes, text boxes and callouts | Annotations |
shapes |
Draw rectangles, circles, lines, arrows and polygons | Annotations |
comments |
Comment on annotations and reply, in a panel with every thread | Annotations |
stamps |
Place stamps: the standard ones, their own, or images | Annotations |
signatures |
Draw, type or upload a signature; sign fields; check signatures | Forms & signatures |
forms |
Fill in forms; build them in the Form mode | Forms & signatures |
redaction |
Mark text and areas, then remove them from the file for good | Annotations |
measurement |
Measure distances, perimeters and areas at the drawing's scale | Annotations |
pages |
Rotate, move, insert, duplicate and delete pages in the thumbnails panel, each step undoable | Panels |
attachments |
See, save, attach and remove files inside the PDF | Panels |
search |
Find text, with every match highlighted and listed | plugins.search |
text |
Select and copy text | plugins.selection |
thumbnails |
A picture of every page, to jump around | Panels |
outline |
The document's table of contents | Panels |
documents |
Open other files (with the local engine), download, print, several documents in tabs | Documents |
Leave features out
A feature in disable is gone everywhere at once: its buttons, its mode tab, its panel, its menu
rows and its shortcuts. Annotations already in the document still show; people just can't add new
ones. Most features have parts you can turn off on their own, such as forms.building ordocuments.download, and one button goes by its command's name, such as tool:highlight.
disable follows its prop, so a feature can come and go while the viewer runs, for example when
a user's plan changes.
Turn features off has every name, read-only, and what to use when: the
layout for where something goes, disable for whether the viewer has it, and the scope for what a
user may do.
Who the user is
The user's name becomes the author of the annotations and comments they make:
{ identity: { displayName: 'Ada Lovelace' } }The user's name becomes the author of the annotations and comments they make. On CloudPDF, who the
user is and what they may do come from the document's token, so your server decides.
Each feature's settings
Every feature is a headless plugin, and plugins takes its settings by the plugin's name, exactly
as the plugin's page lists them:
{
plugins: {
stage: { spread: 'odd', zoom: { mode: 'fit-width' } },
search: { highlight: { color: '#a7f3d0' } },
annotation: { tools: [{ id: 'highlight', defaults: { color: '#fde047' } }] },
},
}That opens documents as two-page spreads at the page's width, with green search matches and
yellow highlights. See Plugins for every plugin and how to change its settings
while the viewer runs.
Good to know
What loads when
A feature you disable is still in the viewer's code: disable changes what people see, not what
you ship. The first load stays small another way. Each feature's panels and dialogs are files of
their own, which the viewer fetches in the background once the document shows, so they open at
once. The heavier parts wait until they're used: the signature checks until a signature is
checked, the standard stamps until Stamps opens, the handwriting fonts until someone types a
signature, and each language until it's chosen. How it works
has the whole order.
Cloudpdf Website Src Content Docs Engine Annotations Index (`cloudpdf/website/src/content/docs/engine/annotations/index.mdx`)
title: Annotations
{/* Generated from docs/content β edit there, then pnpm docs:sync. */}
import { Callout } from '@/components/docs/callout';
import { CardGrid, GridCard } from '@/components/docs/cards';
Annotations
Annotations are what people add on top of a page: highlights, sticky notes, shapes, stamps,
free text, links. They're stored in the PDF, so every PDF app shows them, and they stay separate
from the page's own content, so you can change or remove them later.
const page = doc.page(pageRef);
const { annotation } = await page.annotations.create({
subtype: 'square',
box: { x: 72, y: 92, width: 200, height: 100 },
color: '#0078ff',
});
await page.annotations.update(annotation.ref, { contents: 'Check this table' });
await page.annotations.delete(annotation.ref);Kinds
Each kind has its own page with an example and its fields:
List annotations
Every annotation in the document, or the ones on one page:
const { annotations } = await doc.annotations.list(); // every page
const { annotations } = await doc.annotations.list({ pages: [pageA, pageB] }); // some pages
const { annotations } = await doc.page(pageRef).annotations.list(); // one pageA list is cheap even for a whole document: the engine reads the annotations without loading the
pages. Each annotation names its page, and the list's pages has one entry per page it read.
Each annotation has a subtype (its kind), a rect (where it is on its page) and a ref (its
address). Keep the ref: it's how you change or delete the annotation later.
Positions are in points from the top-left corner of the page, with y going down, like every
position in the engine: a box is { x, y, width, height } and a point { x, y }. See
Coordinates.
for (const annotation of annotations) {
console.log(annotation.subtype, annotation.rect, annotation.contents);
}A list includes kinds the engine can't create yet: they come back as subtype: 'unsupported',
with their position and common fields. They stay in the PDF untouched.
Form widgets are annotations too, of kind 'widget', but they're the form's: a list here never
holds one, and doc.forms.list() returns them with the fields. You place and change them through
the form; see Creating form fields.
Fields every annotation has
Besides subtype, rect and ref, every kind has these fields:
| Field | What it is |
|---|---|
contents |
A comment, the text PDF apps show for the annotation. |
subject |
A short title PDF apps show with the comment, such as 'Question'. |
nm |
A unique name on its page. See Refs & identity. |
reply |
The annotation it answers, or null. See Notes & replies. |
popup |
The ref of its popup window, or null. See Popups. |
groupId |
The group it belongs to, when you work with groups. |
Each annotation also has the flags and the fields that say who wrote it.
Add an annotation
create() takes the kind and its fields, and returns the new annotation:
const { annotation } = await page.annotations.create({
subtype: 'circle',
box: { x: 60, y: 392, width: 120, height: 100 },
interiorColor: '#1e90ff',
});Fields you leave out get the kind's default. The engine draws the annotation's appearance for
you, so it shows the same in every PDF app.
Stamps and file attachments also carry bytes (an image, a file). Pass those in a second
argument; see Stamps and
File attachments.
Change an annotation
update() takes the ref and only the fields you want to change:
await page.annotations.update(annotation.ref, {
color: '#dc143c',
contents: 'Reviewed',
});A field you leave out keeps its value. A field set to null is removed, for example{ interiorColor: null } for no fill. A field's value is replaced whole (a file, a list of
points), except rich text's body style, which merges.
You can also pass an annotation you read straight back: every field it has is one update()
accepts. That makes "change a copy, then save it" code simple:
await page.annotations.update(annotation.ref, { ...annotation, opacity: 0.5 });Fields the engine writes (author, the dates, userId, refβ¦) are accepted and ignored. For
free text, send contents or richText, not both with different text.
Move or resize by `rect`
An update takes a new rect for every annotation, as a script's annot.rect does in Acrobat. A
note, a file, a link, a popup and a redaction take it as it is.
Every other annotation is drawn from its shape: rectangles, ellipses, text boxes, stamps, carets
and form fields' widgets from their box, lines, polylines, polygons and ink from their points,
and text markup from its quads. For these, a new rect puts the shape there: each point is mapped from the oldrect onto the new one, each axis on its own. A rect of the same size moves the drawing, and a
bigger one stretches it:
const { rect } = line;
// 40 points right, 20 down
await page.annotations.update(line.ref, {
rect: { ...rect, x: rect.x + 40, y: rect.y + 20 },
});The engine then works out rect again from the shape. After a resize it can differ a little from
the one you sent: a line keeps its width, and its endings keep their size. The rect you read,
sent back, changes nothing, so a read passed straight back works.
- A new
rectwith a new shape (box, points or quads) fails withInvalidArg: send one or the
other. - A turned drawing can only move. A
rectof another size fails withInvalidArg; resize itsboxor its points instead. A box at a quarter turn is the exception: it stands upright on the
page, so arectresizes it along its own sides. - A
rectthat would squash a drawing to no width or no height fails withInvalidArg.
shapeForRect(annotation, rect) gives the fields that put an annotation at a rect, by the same
rule. Use it to place a copy somewhere else:
import { shapeForRect } from '@cloudpdf/engine';
await page.annotations.create({ ...copy, ...shapeForRect(copy, target) });A create works out a drawn annotation's rect from its shape, whatever rect you send, so a
copy of another app's drawing keeps its shape exactly. A box turned a quarter can be created by
its rect instead of its box; see Turned shapes.
Delete and reorder
await page.annotations.delete(annotation.ref);
await page.annotations.reorder([annotation.ref], 'start'); // to the back
await page.annotations.reorder([annotation.ref], { after: other.ref }); // just above anotherDeleting an annotation also deletes its popup, if it has one, and its replies; see
Delete a note.
Annotations on a page are drawn in order, so the last one is on top. reorder(refs, position)
moves annotations together, in the order you pass them, right after or before another annotation
of the page, or to the back ('start') or the front ('end'). It resolves the page's new order,order, as refs, and fires annotations.reordered.
Widgets are drawn above every annotation, in an order of their own, so a widget is never a
neighbour here: reorder() refuses one with InvalidArg. Restack widgets withdoc.forms.reorderWidgets().
Style
Most kinds share the same style fields:
| Field | What it is | Default |
|---|---|---|
color |
Line color, such as '#1e90ff' |
per kind |
interiorColor |
Fill color of closed shapes, or null for no fill |
no fill |
opacity |
From 0 (invisible) to 1 (solid) |
1 |
strokeWidth |
Line width in points | 1 |
borderStyle |
'solid' or 'dashed' |
'solid' |
dashArray |
The pattern a 'dashed' border draws, such as [3, 2] |
[3] |
A pattern alone doesn't make a border dashed: a 'solid' border keeps its dashArray and draws
solid. dashArray: null on a dashed border draws the default [3], 3 points on and 3 off. A
border from another app can read 'beveled' or 'inset'; only form widgets draw those, so a
write takes them only sent back unchanged.
Colors are '#rrggbb', as in CSS: the engine gives them in lowercase and takes either case. They
never include transparency: that's what opacity is for. The default color depends on the kind
(yellow for a highlight, red for a shape, black for free text); each kind's page lists its
defaults.
A PDF can store a color as gray or CMYK. It reads as the same color in '#rrggbb', and sent back
unchanged it stays as the file has it, so passing a read straight back rewrites nothing.
Who wrote it
The engine records who created and last changed each annotation, from the user the document was
opened for. You read these fields; you don't set them:
| Field | What it is |
|---|---|
author |
The display name shown in PDF apps. |
createdAt |
When it was created, as an ISO date. |
modifiedAt |
When it was last changed. |
userId |
The id of the user who owns it. |
createdBy |
The id of the user who created it. |
modifiedBy |
The id of the user who last changed it. |
groupId |
The group it belongs to, when you work with groups. |
The user comes from the identity you pass when you open the document; see
Opening documents.
The user comes from the identity in their token; see
Authentication.
Flags
Every annotation has the standard PDF flags as true/false fields. Set the ones you need; the
rest stay off (on create) or keep their value (on update). The exception is print: a new
annotation prints unless you set print: false, as in Acrobat. A popup doesn't print.
await page.annotations.update(annotation.ref, { print: true, locked: true });| Flag | What it does |
|---|---|
print |
Printed with the page. Off: shown on screen only. |
hidden |
Not shown and not printed. |
noView |
Not shown on screen (can still print). |
locked |
Can't be moved, resized or deleted in PDF apps. |
lockedContents |
Its text can't be changed in PDF apps. |
readOnly |
Can't be changed at all in PDF apps. |
noZoom |
Keeps its size when the page is zoomed (notes, icons). |
noRotate |
Stays upright when the page is rotated. |
invisible |
Hidden when the app doesn't know the annotation's kind. |
toggleNoView |
Flips noView when the pointer is over it. |
Flags are instructions for PDF apps. The engine itself still lets you change a locked
annotation; the viewer's annotation tools respect the flags in their interface.
Permissions
Reading annotations needs doc.annotate.read. Creating, changing and deleting them needs a
permission for that action, which can be limited to the user's own annotations:annotations:update:self lets a user edit what they created and nothing else. See
Permissions.
<CloudPdfCallout
title="Let many people annotate one document"
href="https://www.cloudpdf.com/docs/engine"
cta="Collaborate with CloudPDF"
With CloudPDF, annotations are stored on your server as people make them. Everyone with the
document open gets each other's changes as events, and the server
makes sure people only change the annotations they may change.
More
- Import & export: copy annotations to another
document, or store them apart from the PDF. - Drawing annotations yourself: render annotations as
separate images, bake them into the page, or export them as a PDF. - Refs & identity: what a
refcontains, and why you never
build one. - Changes & undo: make several annotation edits as one action,
and undo them.
Cloudpdf Website Src Content Docs Engine Forms Index (`cloudpdf/website/src/content/docs/engine/forms/index.mdx`)
title: Forms
{/* Generated from docs/content β edit there, then pnpm docs:sync. */}
Forms
A PDF form is a set of fields: text boxes, checkboxes, radio buttons, dropdowns and lists. The
engine reads every field with its value, fills fields in, and redraws them so the new value shows
in every PDF app.
const { fields } = await doc.forms.list();
const name = fields.find((field) => field.name === 'billing.name')!;
await doc.forms.setValue(name.ref, { value: 'Ada Lovelace' });Read the form
list() returns every field in the document:
const { fields } = await doc.forms.list();
for (const field of fields) {
console.log(field.name, field.family);
}Every field has these:
| Field | What it is |
|---|---|
name |
The full name, with the groups it's in: billing.name. |
family |
'text', 'checkbox', 'radio', 'combobox', 'listbox', 'pushbutton', 'signature' or 'unknown'. |
ref |
Its address. Keep it: it's how you fill, reset or change the field. |
readOnly |
true when PDF apps don't let the user change the value. |
required |
true when PDF apps ask for a value before the form is submitted. |
noExport |
true when the field is left out when the form is submitted. |
alternateName |
The tooltip PDF apps show, also read by screen readers, or null. |
groupId |
The group the field belongs to: who fills it in, such as 'buyer', or null. See Signers and groups. |
widgets |
Where the field shows on the pages (see Widgets). |
A document without a form returns an empty fields. The list's formKind says what the document
has: 'none', 'acroform' (a regular PDF form) or 'xfa' (see Good to know).
The list also has needsAppearances, true when the form asks PDF apps to draw its fields
themselves (repair() can clear it), andcalculationOrder (see Calculations).
Values
Each family has its own value fields. Check family first, and TypeScript knows which ones the
field has:
if (field.family === 'text') console.log(field.value); // string
if (field.family === 'checkbox') console.log(field.checked); // boolean
if (field.family === 'listbox') console.log(field.selectedValues); // string[]| Family | Its value | Also |
|---|---|---|
text |
value, a string |
defaultValue, maxLength (null: no limit), multiline, password, comb |
checkbox |
checked |
exportValue: what the form data holds while it's checked |
radio |
value: the export value of the chosen button, or 'Off' |
noToggleToOff, radiosInUnison |
combobox |
value |
options, edit (free text allowed), defaultValue |
listbox |
selectedValues, in option order |
options, multiSelect, defaultValue (option values), topIndex (the option in its top row) |
pushbutton |
none: a button only runs an action | Each widget's caption, on its row in widgets |
signature |
none here: see Digital signatures |
Each option of a dropdown (combobox) or list is { label, value, selected }. label is what the
user sees; value is what the form stores, and what you pass when you fill the field in.
Who created and filled it in
A field records who created it and who last filled it in, from the identity the document was opened
with:
field.createdBy; // 'user-17': who created the field, or null
field.filledBy; // 'user-42': whose fill last changed the value
field.filledByName; // 'Ann de Vries'
field.filledAt; // '2026-10-08T10:42:00Z'| Field | What it is |
|---|---|
createdBy, createdAt |
Who created the field, and when. null for a field another app made. |
filledBy, filledByName, filledAt |
The user whose fill last changed the value, their name, and when. A calculated value counts as filled in by whoever's fill calculated it. null after a reset, or an anonymous fill. |
importedBy |
Who imported the field with its attribution kept. |
The engine stamps these; a write can't set them. A fill that leaves the value as it was changes
none of them, and undoing a fill puts the previous ones back. A widget has no attribution of its own:
its field's is the one.
Widgets
A field shows on a page through a widget: the box you see and click. Most fields have one. A
radio group is one field with a widget per button, and a field repeated on every page (a
reference number in the header, say) has a widget per page, all showing the same value.
Each widget in field.widgets has its page and its ref. The form also lists every widget inwidgets: where it is (rect, in points from the page's top-left), how it looks, its state,
what it does when clicked (actions), and the ref of the field it belongs to (field). So one read is enough to draw your own form layer
over a page:
const { widgets } = await doc.forms.list();
for (const widget of widgets) drawBox(widget.page, widget.rect, widget.field);Checkbox and radio widgets in field.widgets also have their exportValue (what the form data
holds when this widget is checked) and checked.
A form field's widgets come with the form, never with the annotations: page.annotations.list()
doesn't return them, and reading them takes doc.forms.read, not doc.annotate.read. Each page's
widget images, every look and state, come from page.forms.renderAppearances().
Find a field
The ref from list() is the best address: it keeps working when the field is renamed. When you
know a field by its name instead (from your backend, or from data you stored), make a ref from the name:
import { toFieldRef } from '@cloudpdf/engine';
const field = await doc.forms.get(toFieldRef('billing.name'));The name is the full name, with dots between the groups. A field that isn't there fails withNotFound.
Fill in a field
setValue() takes the field's ref and its new value, in the same fields a read returns. What
you read, you can write back:
await doc.forms.setValue(name.ref, { value: 'Ada Lovelace' }); // text
await doc.forms.setValue(agree.ref, { checked: true }); // checkbox
await doc.forms.setValue(plan.ref, { value: 'yearly' }); // radio group
await doc.forms.setValue(country.ref, { value: 'NL' }); // dropdown
await doc.forms.setValue(toppings.ref, { selectedValues: ['cheese', 'olives'] }); // list| Family | Value |
|---|---|
text |
{ value }; null empties it |
checkbox |
{ checked } |
radio |
{ value }: the export value of the button to choose; 'Off' or null clears |
combobox |
{ value }: an option value; null clears |
listbox |
{ selectedValues }: option values; [] clears |
setValue() returns the field as it is now:
const { field } = await doc.forms.setValue(name.ref, { value: 'Ada' });The engine redraws the field's widgets, so the page shows the new value in every PDF app. Render
the page again to see it.
What the engine checks
The engine checks a value before it writes anything. A call that fails with InvalidArg leaves
the field as it was:
- The value must be the family's own:
{ checked: true }on a text field fails. - A dropdown or list takes option values, not labels. A dropdown with
edit: truealso takes
free text. More than one value needs a list withmultiSelect. - A radio group's
valuemust be the export value of one of its buttons. - A radio group with
noToggleToOffcan't be cleared once a button is chosen. - Push buttons and signature fields hold no value you can set.
Some things are fixed up rather than refused: text longer than maxLength is cut to fit, and
in a radio group with radiosInUnison, the buttons that share an export value check together.
readOnly is an instruction for PDF apps: they don't let the user change the field. The engine
still accepts your own writes, for example a total your code calculates.
Reset
reset() puts back each field's defaultValue, or empties it when it has none. Without an
argument it resets the whole form; pass a ref, or a list of refs, to reset only those fields:
await doc.forms.reset(); // every field
await doc.forms.reset(name.ref);
await doc.forms.reset([name.ref, email.ref]);It returns the fields it changed, as they are now. A field that already holds its default is
left alone and isn't in the list:
const { fields } = await doc.forms.reset();
console.log(`${fields.length} fields reset`);Fields that hold no value, such as push buttons and signature fields, are skipped. Resetting the
whole form also skips the fields a signature locked and fields stored inline;
name one of those yourself and the call fails, the same as a setValue() would.
Move fields and values between documents
export() takes fields out as one bundle, each with its value. importValues() fills another
document's fields of the same name from it, and import() copies the fields themselves:
const bundle = await doc.forms.export();
const { fields, dropped } = await other.forms.importValues(bundle);Each value is checked like a setValue(). A field the document doesn't have, a value it can't
take, or a field a signature locked is left out and listed in dropped; the rest still land, as
one change you can undo. See Import & export.
Events
setValue(), reset() and importValues() fire forms.valueSet, one per field that changed.
Each carries what the call returned for that field:
doc.events.on('forms.valueSet', ({ field }) => {
updateSummary(field);
});When one call changes several fields, such as reset() or importValues(), their events shareorigin.tx, like an annotation import.
Each value is saved on the server as you set it. Everyone with the document open gets the event,
with origin.kind: 'remote' for other people's changes. See
Events.
Permissions
Reading the form needs doc.forms.read, and exporting it doc.download too. Filling in,
resetting and importing values needs doc.forms.fill, which includes doc.forms.read. See
Permissions.
A signed document can take filling away, for the whole form or for single fields. The call then
fails with ProtectedDocument; see
Digital signatures.
More
- Creating form fields: add fields and widgets, change and delete
them, repair a broken form. - Import & export: copy fields from one document to another,
or fill a form in from a bundle. - Digital signatures: sign a signature field and check the
signatures in a document. - Changes & undo: fill in several fields as one action, and
undo it.
Good to know
Calculations, formatting and validation
The engine doesn't run the form's JavaScript. setValue() stores the value you give it: it
doesn't recalculate a total, format a date or an amount, or run a validation script. PDF apps run
those scripts while a user types.
A field's scripts are in field.actions (keystroke, format, validate and calculate), and
the list's calculationOrder holds the fields whose totals are calculated, in order. To write them,
see Scripts and calculations.
The viewer's form plugin runs these scripts and writes their results as one
change: forms.setValue, forms.reset, forms.setDisplay
(whether the field shows and prints) and forms.setAppearanceText (the formatted text the field
shows, while its value stays the same) ops, in the order the scripts asked for them. All of them
are written, or none is: a field a signature locked refuses the whole change. You only need these
ops when you run form scripts yourself.
Recovered fields
Some PDFs have fields that show on the page but are missing from the form's list of fields, a
bug in the tool that made them. The engine finds them anyway: they come back withorigin: 'recovered' and fill in like any other field. Other PDF apps may not see them;repair() fixes the file.
A field on several pages
A field's widgets can be on different pages. The meta of a setValue() or reset() result
lists every widget whose look changed, with its page (meta.changedWidgets). For a radio group
that's the button switched off and the one switched on. Use it to render only the pages that
changed.
meta.redrawnWidgets lists those whose picture the engine drew again, such as a text field's
widgets after a new value. A checkbox or radio button that switches shows another picture it
already has, so it isn't in the list.
Checkboxes with several widgets
A checkbox can have several widgets, each with its own exportValue: a "Yes / No" pair, for
example. { checked: true } checks the first widget; { value: 'No' } checks the widget with
that export value.
In the file, each checkbox and radio widget also has an onState: the name of its checked look
(PDF's appearance state). It's usually the same as the export value, and widgets the engine
creates always use their exportValue for it. You never need it to fill a field in.
The field's value in the file (valueEntry) is the checked widget's onState, or 'Off', as the
PDF standard says. Some tools write the widget's position instead ('0', '1'); such a value
still resets to that widget.
XFA forms
formKind is 'xfa' when the PDF also carries an XFA form, an older XML form format. The
engine works with the document's regular fields and never runs XFA.
Fields stored inline
A field that the file stores inside another object, rather than as an object of its own, can be
read but not changed: writes fail with InvalidArg. PDF tools rarely write fields this way.
Cloudpdf Website Src Content Docs Headless Annotations Index (`cloudpdf/website/src/content/docs/headless/annotations/index.mdx`)
title: Annotations
{/* Generated from docs/content β edit there, then pnpm docs:sync. */}
import { Cards, Card } from '@/components/docs/cards';
{/* This page is served at β¦/headless/<framework>/annotations with NO trailing
slash, so a relative href resolves against β¦/headless/<framework>/.
Sibling pages are annotations/tools, other sections text/selection. */}
Annotations
Annotations are what people add on top of a page: highlights, sticky notes, shapes, stamps and
text boxes. The annotation plugin shows the ones a PDF already has, lets people draw and edit new
ones, and gives your code the same abilities. Every annotation is saved into the PDF, so it looks
the same in every other PDF app.
Show the annotations in a PDF
Register interactionPlugin() and annotationPlugin(), then put an <AnnotationLayer> above the
rendered page:
Try it: click the rectangle and drag it, or a corner to resize it. Double-click the text box
to type in it.
While the annotation layer is on the page, the render layer leaves the annotations out of the page
picture by itself. The annotation layer draws them instead, so they can be selected, moved and
changed.
interactionPlugin() is the shared input system. Every tool, from text selection to drawing,
speaks through it; see Selecting & copying for how it works.
Let people draw
A tool says what a click or a drag on the page creates. Activate one and people can draw:
Try it: drag on the page to draw a rectangle, then pick another tool. Highlight marks the
text you select.
pointerselects: click an annotation to select it, then move, resize or turn it.squaredraws a rectangle: drag, or just click for a default-size one.inkdraws freehand.highlighthighlights the text you select.notedrops a sticky note where you click.
After you draw, the new annotation is selected and the tool stays active, so you can draw another.
You can change both; see What happens after drawing.
How it fits together
Everything on these pages is one of five things:
| Thing | What it is | Where you use it |
|---|---|---|
| Annotation | A record in the PDF: its kind, position, colors and text. | annotation.list() |
| Tool | What a click or drag creates, with which defaults, cursor and preview. | annotationPlugin({ tools }) |
| Layer | What each page draws: the annotations, the selection box and handles, the preview. | <AnnotationLayer> |
| Overlay | Your own UI attached to an annotation, such as a menu or a hover card. | <AnnotationMenu> |
| API | Reading, creating, changing and deleting annotations, and listening for changes. | useAnnotation() |
An annotation here is the same record the engine reads and writes: the same kinds, the same fields
and the same names. What you learn in the engine's annotation pages
holds here too.
The built-in tools
| Tool | Makes | How |
|---|---|---|
square, circle |
A rectangle or an ellipse | Drag, or click for a default size |
line |
A line | Drag, or click for a default length |
polygon, polyline |
A closed or open shape of points | Click each point, double-click to end |
ink, ink-highlight |
Freehand drawing, or a see-through marker | Draw |
free-text, free-text-callout |
A text box, or one with a line pointing out | Click or drag, then type |
highlight, underline, strikeout, squiggly |
A mark over text | Select text |
insert-text, replace-text |
A suggested insertion or replacement | Select text |
note |
A sticky note | Click |
stamp |
An image or a PDF drawing | Click, then pick a file |
attachment |
A file pinned to a spot | Click, then pick a file |
link |
An area that opens a page or a website | Drag |
Measurement tools (distance, perimeter, area) and redaction (redact) are annotation tools
too. Their plugins add the rest of what they need.
You can change any tool, or add your own; see Tools & defaults.
Who wrote it
Every annotation records who made it and who last changed it (author, createdAt,modifiedAt). You don't set these on the annotation: they come from the user the document was
opened for.
Tell the viewer who the user is, once. Every document it opens uses it:
Try it: draw a rectangle. The list says Dana Smith made it, and when.
displayName becomes the author other PDF apps show, and userId records who owns the
annotation. The same identity decides which annotations count as the user's own when
permissions say "only their own".
To open one document as someone else, give its entry in initialDocuments its own identity. It
replaces the viewer's for that document:
const initialDocuments = [
{ source: contract, identity: { userId: 'bot', displayName: 'Import job' } },
];A document keeps the identity it was opened with. <Fw only={['react', 'vue', 'svelte']}>If you change the viewer's identity, documents
you open afterwards use the new one.
To change the viewer's identity, for example when someone else signs in, useinject(EpdfViewer).updateSettings({ identity }). Documents you open afterwards use the new one. To
follow your app's login:
effect(() => viewer.updateSettings({ identity: auth.identity() }));It comes from the identity in the user's token; see
Authentication.
Settings
Set them when you register the plugin. snap, chrome, afterCreate and editable can change
while the app runs, with annotation.updateSettings({ β¦ }); annotation.resetSettings() goes back to what you
registered, and useAnnotationSettings() gives you the current settings.
| Setting | What it does | Page |
|---|---|---|
tools |
Change the built-in tools, or add your own | Tools & defaults |
afterCreate |
Select a new annotation? Keep the tool active? | What happens after drawing |
chrome |
The selection's outline, handles and rotation handle, and their colors | Change the handles |
snap |
Snapping to other annotations and to angles | Snapping |
editable |
Which annotations people may change by hand: true, false, or kinds |
What people may change |
Every color in chrome can also come from CSS, for dark mode or a different look per viewer: see
Colors & theming.
Next steps
Cloudpdf Website Src Content Docs Headless Forms Index (`cloudpdf/website/src/content/docs/headless/forms/index.mdx`)
title: Filling forms
{/* Generated from docs/content β edit there, then pnpm docs:sync. */}
Filling forms
Many PDFs are forms: text boxes, checkboxes, dropdowns, signature fields. Register formPlugin() and
put <FormLayer> in each page, and people can fill them in:
Each field becomes a real HTML control, placed and styled like the field in the PDF: people type in text
boxes, tick checkboxes, pick from lists, and move between fields with Tab. A value is saved into the
document as it's entered, so it's there when the PDF is downloaded, and every PDF app shows it. The
form layer paints the fields as the engine draws them, so while it's on the page, the render layer
leaves them out of the page picture.
Read the form
<Fw only={['react', 'vue', 'svelte']}>
useFormState() gives you every field, and
form.fields() is every field, as a signal: your view updates when a value changes.
Each field has its full name (such as billing.name), its family ('text', 'checkbox','radio', 'combobox', 'listbox', 'pushbutton' or 'signature'), its value, and a ref: its
address for everything else. The fields and their values are the engine's; see
Forms for every family's value.
For one field, such as a total you show in your own UI, use useFormValue():
toFieldRef(name) makes a ref from a field's full name, for when you know a field by name.
Fill in a field
setValue() takes the same values a field reads:
await form.setValue(name.ref, { value: 'Ada Lovelace' }); // text
await form.setValue(agree.ref, { checked: true }); // checkbox
await form.setValue(plan.ref, { value: 'yearly' }); // radio group: the button's export value
await form.setValue(country.ref, { value: 'NL' }); // dropdown: an option's value
await form.setValue(toppings.ref, { selectedValues: ['cheese', 'olives'] }); // listIt runs the same checks and scripts as typing does, and resolves with { field, status }: status
is 'applied', 'unchanged', or 'rejected' when the form's own validation refused the value.
The field shows the new value at once, before the engine answers, and the promise resolves once it
has. What the form's scripts calculate from it goes with it, as one change: one request, and one
step to undo. The field stays editable meanwhile; filling it in again is the next change. A value
the engine refuses goes back to the one the field had.
Your own inputs can fill in fields too. form.controlOf(ref) gives you a field as an AngularFormControl: it holds the field's value, and fills in the field when the control changes, with
the same checks and scripts:
To fill many fields at once, such as from your backend, pass them by name:
await form.importValues({ 'billing.name': 'Ada Lovelace', 'billing.country': 'NL', agree: true });importValues() and setValues() write every value as one change, with the calculations run once
after them. Each field's own keystroke and validation scripts don't run, as when a script sets a
field's value in Acrobat.
Check required fields
A form marks some fields as required. Check them before you submit or download:
const { valid, missing } = form.validate();
const widget = !valid && form.getWidget(missing[0].widgets[0]);
if (widget) stage.reveal(widget.page, { rect: widget.rect });missing lists the required fields that are empty, in page order, so you can take the user to the
first one. Only the fields the user may fill in count: another signer's are theirs to fill.
Calculations, formatting and validation
Many forms add up totals, format dates and check values with scripts. With JavaScript on in the
actions plugin, they run as people type, as in Acrobat:
When a script refuses a value, such as a date in the wrong format, the field keeps its old value,
the box keeps what was typed so it can be corrected, as in Acrobat, and
<Fw only={['react', 'vue', 'svelte']}>onValidationRejectedvalidationRejected$
The scripts read the form as the user sees it, values still on their way included, so nothing is
read from the engine for them. Their engine starts when the first field of a form with scripts takes
focus, so the first value doesn't wait for it; a form without scripts never starts it.
Reset the form
await form.reset(); // every field the user may fill in, back to its default
await form.reset([name.ref, email.ref]); // some fieldsMove form data in and out
For your own backend, plain values are simplest:
const values = form.exportValues(); // { 'billing.name': 'Ada Lovelace', agree: true, β¦ }
await form.importValues(values);Fields are matched by their full name. A field the form doesn't have, or a value it can't take, is
skipped; the rest still land.
To copy the fields themselves to another document, with their boxes, values and scripts, take
them out as a bundle and import it there. FormTransfer turns a bundle into one JSON text to
store, and back:
const bundle = await form.export(); // every field; or form.export({ fields }), ({ pages })
const text = FormTransfer.stringify(bundle);
const { fields, dropped } = await otherForm.import(await FormTransfer.parse(text));The import adds the fields as one change you can undo. A field whose name the form has already is
left out and listed in dropped. By default each field keeps who created and filled it in, which
needs canRestoreAttribution(); pass { attribution: 'stamp' } to make them yours. See
Import & export.
Methods
What useForm() gives you for reading and filling. The verbs that change the form itself are
on Building forms.
| Method | What it does |
|---|---|
get(ref) |
One field, with its value, or null when the form has no such field. Read the form |
getValue(ref) |
One field's value, in the shape setValue() takes, or null. In a component, use useFormValue(ref). |
getFieldForWidget(widget) |
The field a box belongs to, from the box's ref |
getWidget(widget) |
A box's row: its page, its place (rect), its look and its state, or null when no page shows it |
getWidgets(ref) |
Every box a field shows in, as rows in the field's order: a radio group's buttons, or a field repeated on several pages |
listWidgets(page) |
Each field box on a page, with its place, its control, its value and whether it can be filled. For drawing the fields yourself. |
getWidgetAt(page, point) |
The field box under a point on a page, with its field, or null |
setValue(ref, value) |
Fills in a field with { value }, { checked } or { selectedValues }, running the form's checks and scripts. Resolves { field, status }. Fill in a field |
setValues(entries) |
Fills in several fields, each { ref, value }, as one change with one recalculation; a field you may not fill in, or a value it can't take, fails on its own. Resolves { applied, skipped, failed }. |
importValues(values) |
Fills in fields by full name from a plain object, as one change with one recalculation. Resolves { applied, skipped, failed }. Move form data in and out |
exportValues() |
Every value, keyed by full name, as a plain object |
export(selection?) |
Whole fields with their boxes, values and scripts, as a bundle: every field, or { fields } or { pages }. Move form data in and out |
import(bundle, options?) |
Adds a bundle's fields, as one change. Resolves { fields, refMap, dropped }. Needs canDesign(). Move form data in and out |
reset(refs?) |
Puts back each field's default, for every field the user may fill in or the fields you pass. Resolves { fields }, the fields it changed. Reset the form |
validate() |
Checks the required fields the user may fill in. Returns { valid, missing }, the empty ones in page order. Check required fields |
activateWidget(widget) |
Does what a click on a field box does, such as running a button's action |
getSnapshot() |
Everything at once, as the user sees it: what the engine's list() returns, with the values still on their way. fields, widgets, formKind, needsAppearances and calculationOrder |
refresh() |
Reads the whole form again, and <Fw only={['react', 'vue', 'svelte']}>onResyncedresynced$ |
canRead(), canFill(ref) |
Whether reading the form, and filling in a field, are allowed. Permissions |
getSettings(), updateSettings(changes), resetSettings() |
Reads the settings, changes them while the app runs, or goes back to what you registered. In a component, use useFormSettings(). Settings |
Every method that returns a promise also takes a signal in its options, to cancel it.
State
| Field | What it is | Read once |
|---|---|---|
fields |
Every field, with its value | list(), or list({ family, page, name }) for some |
status |
Whether the form is read: 'idle', 'loading', 'ready', 'forbidden' or 'error' |
getStatus() |
formKind |
What the document has: 'none', 'acroform' (a regular PDF form) or 'xfa' |
getFormKind() |
selectedField |
In design mode, the field of the selected box, or null; see Building forms |
getSelectedField() |
For one field, useFormValue(ref)
Settings
Set them when you register the plugin, and change them while the app runs withform.updateSettings({ β¦ }); form.resetSettings() goes back to what you registered, anduseFormSettings() gives you the current settings.
| Setting | What it does | Default | From CSS |
|---|---|---|---|
validation |
Run the form's own checks ('scripts'), or accept every value ('none') |
'scripts' |
β |
focus.color |
The ring around the field being filled in | the viewer's accent | --epdf-form-focus |
fields.border |
The edge of a field that has no border of its own | the viewer's accent at 55% | --epdf-form-field-border |
fields.background |
Inside a field that has no background of its own | 'rgb(255 255 255 / 0.92)' |
--epdf-form-field-background |
fields.color |
The check mark and text of a field that has no color of its own | '#1f2a44' |
--epdf-form-field-color |
fillable |
Which fields people fill in by hand: true, false, or field families |
true |
β |
Scripts need JavaScript on in the actions plugin; without it, 'scripts' accepts every value too,
and a form with scripts logs a hint once.
fillable is for the people using your app. A field it leaves out draws as a read-only field
does, and takes no typing, clicks or picks; your code still fills it in with setValue() andimportValues(), and canFill(ref) doesn't change:
formPlugin({ fillable: false }); // show the values, take no input
form.updateSettings({ fillable: ['text', 'checkbox'] }); // only these families
form.updateSettings({ fillable: true }); // every field againThe families are 'text', 'checkbox', 'radio', 'combobox', 'listbox', 'pushbutton' and'signature' (signing a field is filling it in). To stop your code too, leave doc.forms.fill out
of the scope (see Permissions).
The colors are what the viewer draws around fields; a field's own colors are saved in the PDF (see
Building forms). A CSS variable wins over the setting: see
Colors & theming.
Events
| Event | Fires when | Carries |
|---|---|---|
onValueChanged |
A field's value changed | field, origin |
onValidationRejected |
The form's validation refused a value | field, issues |
onResynced |
The whole form was read, as when it opens, after refresh() or a repair |
snapshot |
onSettingsChanged |
The settings changed | settings, changed |
Fields that are added, changed or removed have their own events; see
Building forms.
Permissions
| Permission | What it allows | Check |
|---|---|---|
doc.forms.read |
Reading the form | form.canRead() |
doc.forms.fill |
Filling it in | form.canFill(ref) |
Without it, the fields show their values but can't be changed. A user with a fields:fill
permission for one group fills in that group's fields only: form.canFill(ref) answers per field,
the other fields can't be changed and Tab skips them, validate() leaves them out, and reset()
leaves them as they are. See Signers and groups.
Good to know
Forms and annotations
A field's widgets are the form's: the annotation plugin never lists them. With the
annotation plugin registered, the <FormLayer> and the annotation layer share the page: fields stay fillable with thepointer tool, and become movable boxes in design mode.
Keyboard
Tab moves to the next field in the page's order. In a text box, Enter confirms and Escape puts back
the value it had.
Collecting responses
On the local engine, the values live in the document in this tab until it's downloaded.
<CloudPdfCallout
title="Collect form responses on your server"
href="https://www.cloudpdf.com/docs/headless/forms"
cta="Collect forms with CloudPDF"
With CloudPDF, each value is stored on your server as it's entered, so a half-filled form survives a
closed tab, and your backend reads the answers without asking the user to download or upload a
file.
Each value is stored on your server as it's entered, so a half-filled form survives a closed tab.
XFA forms
Some PDFs also carry a form in the older XFA format. The viewer works with the document's regular
fields and never runs XFA; see Forms.
Signed documents
A signature can lock the whole form, or some fields. Locked fields show their values and can't be
changed; see Signatures.
Cloudpdf Website Src Content Docs Server Configuration Index (`cloudpdf/website/src/content/docs/server/configuration/index.mdx`)
title: Configuration reference
import { Callout } from '@/components/docs/callout';
import { Cards, Card } from '@/components/docs/cards';
Configuration reference
The server is configured entirely through environment variables. The same
variables work whether you run it via npm,
Docker,
Compose, or
Helm β only how you set them differs.
Licensing
| Variable | Default | Description |
|---|---|---|
CLOUDPDF_LICENSE_KEY |
β | Required. Your license key; secret:// references resolve through the secrets providers. |
CLOUDPDF_LICENSE_MODE |
connected when a key is set |
connected or air-gapped. |
See Licensing.
Process
| Variable | Default | Description |
|---|---|---|
PORT |
3000 |
Port the HTTP server listens on. |
HOST |
0.0.0.0 |
Interface to bind. |
CLOUDPDF_WORKER_POOL_SIZE |
min(2, cpus) |
Native PDFium worker threads. Integer, or max to use all cores. See Health & scaling. |
Engine plane
| Variable | Default | Description |
|---|---|---|
CLOUDPDF_ENGINE_ISOLATION |
inline |
host runs PDFium in a supervised child process: a native crash costs a sub-second engine respawn instead of the instance. See Health & scaling. |
CLOUDPDF_ENGINE_SHARDS |
1 |
Host mode: split the engine into K child processes (worker total must divide evenly). One crash costs 1/K of open documents. |
CLOUDPDF_ENGINE_MAX_IN_FLIGHT |
workers Γ 2 | Admission cap on concurrently dispatched engine jobs; beyond the bounded queue the server sheds with 503 + Retry-After. |
CLOUDPDF_ENGINE_BG_MAX_IN_FLIGHT |
βworkers / 2β | Of the cap, how much background work (thumbnail warming) may occupy. 0 disables the background lane entirely. |
CLOUDPDF_ENGINE_RECYCLE |
off | 1 opts into memory-watermark engine recycling (host mode). Companion knobs: _RECYCLE_SOFT_PCT (70), _RECYCLE_HARD_PCT (85), CLOUDPDF_ENGINE_MAX_RSS_MB, CLOUDPDF_ENGINE_MAX_LIFETIME_HOURS. |
CLOUDPDF_ENCODE_IN_ENGINE |
on | 0 reverts image encoding to the API process (one-release escape hatch; rasters cross the engine boundary raw). |
Authentication
| Variable | Default | Description |
|---|---|---|
CLOUDPDF_JWT_SECRET |
dev fallback | HS256 secret used to verify document and admin tokens. Must match the secret your backend signs with. Required (β₯32 bytes) under a production license; development keys fall back to a public dev value with a warning. |
CLOUDPDF_API_AUTH_TOKENS |
β | Comma-separated static API tokens for trusted backends and tooling (root credential for the admin plane). β₯32 bytes each under a production license. |
CLOUDPDF_PASSWORD_VERIFICATION_HMAC_SECRET |
dev fallback | HMAC pepper for the encrypted-PDF password-verification cache β it keeps stored password proofs non-reversible. Required (β₯32 bytes) under a production license, whether or not you open encrypted PDFs. |
CLOUDPDF_PASSWORD_SESSION_SERVER_SECRET |
dev fallback | Server key material for encrypted-PDF unlock sessions. Required (β₯32 bytes) under a production license when a KMS is configured; unused otherwise. |
CLOUDPDF_PASSWORD_SESSION_SERVER_SECRET_ID |
dev-v1 |
Label for the active session secret, recorded on each session. Changing it (or the secret) invalidates existing sessions. |
See Authentication, and
Encrypted-PDF secrets
for what the last three protect.
Database
| Variable | Default | Description |
|---|---|---|
CLOUDPDF_DB_DRIVER |
sqlite |
sqlite or postgres. |
CLOUDPDF_DB_SQLITE_PATH |
./data/cloudpdf.db |
SQLite file path (when driver is sqlite). |
CLOUDPDF_DB_URL |
β | Postgres connection string (required when driver is postgres). |
See Database.
Storage (object store)
| Variable | Default | Description |
|---|---|---|
CLOUDPDF_STORAGE_KIND |
fs |
fs, s3, gcs, or azure-blob. |
CLOUDPDF_STORAGE_FS_ROOT |
./data/objects |
Directory for stored objects (when kind is fs). |
CLOUDPDF_STORAGE_S3_BUCKET |
β | Bucket name (S3). |
CLOUDPDF_STORAGE_S3_REGION |
β | Bucket region (S3). |
CLOUDPDF_STORAGE_S3_ENDPOINT |
β | Custom endpoint for S3-compatible stores (e.g. MinIO). |
See Storage.
Imports (server-side pull)
On by default with a safe posture (HTTPS-only, public networks, 128 MiB cap).
Registered connections let the server pull from your own buckets without
presigning.
| Variable | Default | Description |
|---|---|---|
CLOUDPDF_IMPORT_ENABLED |
on | Set 0 to disable the import endpoint. |
CLOUDPDF_IMPORT_MAX_BYTES |
128 MiB |
Per-document transfer ceiling. |
CLOUDPDF_IMPORT_TIMEOUT_MS |
120000 |
Wall-clock budget per transfer attempt. |
CLOUDPDF_IMPORT_MAX_CONCURRENT |
4 |
Concurrent transfers per replica (sync + async). |
CLOUDPDF_IMPORT_ALLOW_HTTP |
off | Permit http:// URL sources (dev / MinIO). |
CLOUDPDF_IMPORT_ALLOW_PRIVATE_NETWORKS |
off | Permit private-address URL sources (dev / in-VPC). |
CLOUDPDF_IMPORT_CONNECTIONS |
β | Comma-separated registry of named pull sources (S3, GCS, Azure Blob, filesystem), each configured via CLOUDPDF_IMPORT_CONNECTION_* variables. |
See Imports.
Engine cache
The cache holds recently used documents and renders so repeat requests are fast.
It is disposable β the server rebuilds it from the object store.
| Variable | Default | Description |
|---|---|---|
CLOUDPDF_CACHE_ROOT |
./data/cache |
Cache directory. |
CLOUDPDF_CACHE_MAX_BYTES |
4 GiB |
Maximum cache size before eviction. |
Fonts
| Variable | Default | Description |
|---|---|---|
CLOUDPDF_FALLBACK_FONTS |
β | JSON array of fallback fonts ({ key, path, β¦ }) for glyphs a document's own fonts are missing. |
See Fallback fonts.
Lifecycle & migrations
| Variable | Default | Description |
|---|---|---|
CLOUDPDF_AUTO_MIGRATE |
on for SQLite, off for Postgres | Apply pending migrations on boot. |
CLOUDPDF_FAIL_ON_PENDING |
off | Refuse to start if migrations are pending. Recommended for multi-replica deploys. |
CLOUDPDF_AUTO_PROVISION_TENANT |
off | Lazily create tenant rows. Dev convenience β leave off in production. |
See Migrations.
Shutdown & observability
| Variable | Default | Description |
|---|---|---|
CLOUDPDF_SHUTDOWN_TIMEOUT_MS |
30000 |
Budget for closing in-flight HTTP during shutdown before teardown proceeds anyway. Keep it below your orchestrator's kill deadline. |
CLOUDPDF_SHUTDOWN_DRAIN_MS |
0 |
Settle window after /readyz flips to 503 (and SSE streams end) before the listener closes β for probe-driven load balancers. |
CLOUDPDF_METRICS |
off | 1 exposes an unauthenticated Prometheus /metrics endpoint (scrape it inside your private network). |
Optional adapters
These are opt-in. You only need them for the corresponding feature.
| Variable | Default | Description |
|---|---|---|
CLOUDPDF_CDN_KIND |
none |
Sign delivery URLs via a CDN: bunny, cloud-cdn, cloudfront, azure-fd, custom-hmac. |
CLOUDPDF_KMS_KIND |
static |
Key management for encrypted-PDF sessions: aws-kms, gcp-kms, azure-kv. |
CLOUDPDF_SECRETS_PROVIDERS |
env |
Comma-separated registry of secret sources. |
See CDN, KMS & secrets.
Where to set these
Cloudpdf Website Src Content Docs Server Deployment Index (`cloudpdf/website/src/content/docs/server/deployment/index.mdx`)
title: Deployment overview
import { Cards, Card } from '@/components/docs/cards';
import { Callout } from '@/components/docs/callout';
Choosing a deployment
The server is one application, packaged four ways. They run the same binary and
read the same CLOUDPDF_* configuration, so the
choice is purely about how you want to operate it β not what it can do.
| Method | Best for | Typical database | Typical storage | Scaling |
|---|---|---|---|---|
| Node / npm | Local dev, existing Node platforms, custom hosts | SQLite or Postgres | Disk or cloud | Manual |
| Docker | Single host, simple self-hosting, CI | SQLite or Postgres | Disk or cloud | Manual / your orchestrator |
| Docker Compose | A full self-hosted stack on one box | Postgres (included) | MinIO / S3 (included) | A few replicas, one host |
| Helm / Kubernetes | Production, startup β enterprise | Postgres (managed) | S3 / GCS / Azure (managed) | Autoscaling, multi-replica, HA |
A quick decision guide
- Just exploring, or developing locally? Use npm
or the Quick start Docker one-liner. - Self-hosting on a single server or VM? Use
Docker for the server alone, or
Docker Compose for the server plus
Postgres and object storage in one file. - Running in production, or need high availability? Use
Helm. You get multiple replicas, autoscaling,
managed schema migrations, and a clean separation of secrets β the things that
matter when downtime is expensive.
What every method needs
No matter how you deploy, the server needs:
- A signing secret β
CLOUDPDF_JWT_SECRET, shared with the backend that
mints tokens. See Authentication. - A database β SQLite (default) or Postgres. See
Database. - An object store β local disk (default) or a cloud bucket. See
Storage. - Persistent volumes for
/datawhen you use SQLite, local storage, or the
local cache.
Pick your path
Cloudpdf Website Src Content Docs Server Getting Started Index (`cloudpdf/website/src/content/docs/server/getting-started/index.mdx`)
title: Introduction
import { Cards, Card } from '@/components/docs/cards';
import { Callout } from '@/components/docs/callout';
The server
@cloudpdf/server is the backend that powers CloudPDF. It runs native PDFium on
your own infrastructure and exposes it as a clean, document-scoped HTTP API:
open a document, render pages, extract text, read and write annotations, inspect
metadata, and enforce access β all behind short-lived JWTs.
It is the same engine that runs locally in the browser with EmbedPDF, lifted
onto the server. Your frontend keeps talking to one Engine
interface; the server does the heavy PDF work, holds your
documents, and never hands storage credentials to the client.
Why run your own server
- Your data stays yours. Documents live in your database and object store β
SQLite and the local disk to start, Postgres and S3/GCS/Azure when you grow. - Real PDF rendering. Native PDFium renders pages to PNG/WebP and extracts
text and geometry β the same fidelity as the desktop, on the server. - Secure by construction. Every document route requires a short-lived,
document-scoped JWT minted by your backend. The browser never sees your
tenant secret or storage keys. - Runs anywhere. A single Node process, a Docker container, or a
Kubernetes deployment that scales from a side project to enterprise traffic.
Three ways to run it
The server is a single, self-contained application. Pick the packaging that fits
your team β they all run the exact same binary and read the exact same
configuration.
Next steps
Cloudpdf Website Src Content Docs Viewer Features Index (`cloudpdf/website/src/content/docs/viewer/features/index.mdx`)
title: All features
{/* Generated from docs/content β edit there, then pnpm docs:sync. */}
All features
Every feature is on from the start. Leave out the ones you don't need with disable, by the name
in the first column:
{ disable: ['forms', 'redaction', 'measurement'] }| Feature | What people can do | Settings |
|---|---|---|
annotations |
Highlight, underline and strike out text; draw; add notes, text boxes and callouts | Annotations |
shapes |
Draw rectangles, circles, lines, arrows and polygons | Annotations |
comments |
Comment on annotations and reply, in a panel with every thread | Annotations |
stamps |
Place stamps: the standard ones, their own, or images | Annotations |
signatures |
Draw, type or upload a signature; sign fields; check signatures | Forms & signatures |
forms |
Fill in forms; build them in the Form mode | Forms & signatures |
redaction |
Mark text and areas, then remove them from the file for good | Annotations |
measurement |
Measure distances, perimeters and areas at the drawing's scale | Annotations |
pages |
Rotate, move, insert, duplicate and delete pages in the thumbnails panel, each step undoable | Panels |
attachments |
See, save, attach and remove files inside the PDF | Panels |
search |
Find text, with every match highlighted and listed | plugins.search |
text |
Select and copy text | plugins.selection |
thumbnails |
A picture of every page, to jump around | Panels |
outline |
The document's table of contents | Panels |
documents |
Open other files (with the local engine), download, print, several documents in tabs | Documents |
Leave features out
A feature in disable is gone everywhere at once: its buttons, its mode tab, its panel, its menu
rows and its shortcuts. Annotations already in the document still show; people just can't add new
ones. Most features have parts you can turn off on their own, such as forms.building ordocuments.download, and one button goes by its command's name, such as tool:highlight.
disable follows its prop, so a feature can come and go while the viewer runs, for example when
a user's plan changes.
Turn features off has every name, read-only, and what to use when: the
layout for where something goes, disable for whether the viewer has it, and the scope for what a
user may do.
Who the user is
The user's name becomes the author of the annotations and comments they make:
{ identity: { displayName: 'Ada Lovelace' } }The user's name becomes the author of the annotations and comments they make. On CloudPDF, who the
user is and what they may do come from the document's token, so your server decides.
Each feature's settings
Every feature is a headless plugin, and plugins takes its settings by the plugin's name, exactly
as the plugin's page lists them:
{
plugins: {
stage: { spread: 'odd', zoom: { mode: 'fit-width' } },
search: { highlight: { color: '#a7f3d0' } },
annotation: { tools: [{ id: 'highlight', defaults: { color: '#fde047' } }] },
},
}That opens documents as two-page spreads at the page's width, with green search matches and
yellow highlights. See Plugins for every plugin and how to change its settings
while the viewer runs.
Good to know
What loads when
A feature you disable is still in the viewer's code: disable changes what people see, not what
you ship. The first load stays small another way. Each feature's panels and dialogs are files of
their own, which the viewer fetches in the background once the document shows, so they open at
once. The heavier parts wait until they're used: the signature checks until a signature is
checked, the standard stamps until Stamps opens, the handwriting fonts until someone types a
signature, and each language until it's chosen. How it works
has the whole order.
Conventions Architecture (`docs/conventions/architecture.md`)
Architecture
How the client stack is put together: what a plugin is, where its state lives,
how engine data reaches the UI, and how a plugin's files are laid out. The
details live in state-and-sync.md,events.md and plugins.md.
A plugin in one paragraph
A plugin provides one capability per scope: one for the workspace, or one for
each open document. The kernel builds it by calling the plugin'screate(ctx). The capability is the plugin's only public surface: synchronous
reads, verbs that do work, and events. Inside, a plugin holds up to four kinds
of state, each with one home and one way to change. It talks to the engine
only through ctx.doc, and to other plugins only through their capabilities.
Layers
| Layer | Packages | Responsibility |
|---|---|---|
| Engine | @embedpdf/engine-core (the contract), @embedpdf/engine (local), @cloudpdf/engine (cloud) |
Reads and writes PDF documents; publishes one confirmed event per mutation. |
| Kernel | @embedpdf/core |
Scopes, plugin lifetime, the store, capability resolution, mirrors. |
| Pure cores | @embedpdf/core-* |
One domain's logic, testable without a kernel, engine or DOM. |
| Plugins | @embedpdf/plugin-* |
One capability each, built on the kernel. |
| Adapters | @embedpdf/react, @embedpdf/angular, @embedpdf/web |
Render capabilities in one framework; read only through capabilities. |
| Viewer | @embedpdf/viewer, @embedpdf/viewer-* |
The finished viewer, built on the adapters. |
Where each package lives and what it is called: packages.md.
An adapter paints from what a plugin hands out, and decides no geometry. When a
picture needs a placement, the plugin's read carries it resolved: an
annotation render item comes with its frame and raster, and the adapter
only converts page units to pixels with @embedpdf/web (frameInPixels,rasterInPixels). The one core function adapters call is the annotation
core's scene(), the drawing vocabulary every painter maps onto its own
elements.
Scopes and bring-up
A plugin declares scope: 'workspace' (the default: one instance for the
kernel) or scope: 'document' (one instance per open document).
- Workspace plugins are created by
createKernel, in dependency order.
Theirconnectruns inkernel.start(). - Document plugins are created when a document opens. After the engine has
opened the document and listed its pages, the kernel gives every document
plugin a fresh state cell, then, in dependency order, runs each plugin'screate, then itsconnect, then starts the first load of the mirrors that
plugin created.
A document open is transactional. Everything above runs before the document is
published; if a create or connect throws, every teardown registered so far
runs, the document never becomes ready, and its tab shows error. Only after
bring-up does the document appear in documents.list() as ready anddocuments.onOpened fire.
Closing a document removes its tab at once, revokes the write access of every
instance's state and mirror cells, aborts the instance lifetime (pendingctx.doc calls reject with instance-closed), runs every registered teardown
in reverse order, and then fires documents.onClosed.
Four kinds of state
| Kind | What it is | Where it lives | How it changes | Examples |
|---|---|---|---|---|
| Mirror | A local copy of data the engine owns | ctx.mirror(spec) or ctx.pageMirror(spec): a store cell of its own |
Only by folding confirmed engine events (every origin) and by loads. Verbs never write it. | form fields, annotation records, metadata, signatures, links per page, text geometry per page |
| Pending change | A change this session staged that the engine hasn't answered | The document's change queue (ctx.changes), shared by its plugins; mirrors show it in view() |
Staged by a verb or gesture; leaves the views once the engine answered and each mirror holds the answer, or at once when refused | three annotations the user moved, shown at their new places until the engine answers |
| Session | State the client owns that is not in the PDF | ctx.state |
Only through named pure transitions in model.ts |
camera, open surfaces, active tool, selection, search session, in-flight flags |
| Resource | Anything that is not plain data: handles, rasters, workers, registries of functions, caches | Closures in the controller, released through ctx.cleanup or ctx.acquire |
However the owner likes; readers are woken with ctx.notify() |
raster store, tile manager, command and tool registries, script sandboxes |
What the UI sees is each mirror's view() (the confirmed value with the
pending changes replayed on top) and session state, combined by pure,
memoized reads.
Rules:
- A mirror always holds what the engine confirmed. Nothing optimistic enters
it. - A pending change leaves the views when the engine answered: an applied one
once each mirror holds the answer, a refused one at once. Rollback is
leaving: the views show the mirror again, never a copy taken before the
change. - A new object is named at birth: its create takes a reserved object number
(ctx.changes.takeObjectNumber()), so everything that holds it before the
engine answers holds its final ref. - State is plain data: no functions, class instances, handles,
Maps orSets inctx.stateor a mirror. Anything else is a resource. - No state exists only to wake the UI. A counter bumped so that selectors
re-run is actx.notify()call instead.
state-and-sync.md describes each kind's API.
Data flow
user gesture or API call
β
βΌ
capability verb βββΊ ctx.changes.stage(change) βββΊ every mirror's view() shows it
β
βΌ
doc.apply(change), after everything staged before it
β another session's mutation
βΌ β
engine confirms ββΊ document event (origin: local) βββββ (origin: remote)
β
βΌ
mirror fold(value, event) ββ the same code for every origin
β
βββΊ new mirror value (its store cell)
βββΊ the answered change leaves the view
βββΊ the plugin's fact events fire (origin = event.origin)
β
βΌ
store change ββΊ adapters re-run selectors ββΊ pure memoized reads ββΊ UIBoth engines publish the event for a session's own mutation before the
mutation's promise settles (see DocumentEvent inpackages/engine/core/src/events/DocumentEvent.ts, checked byrunDocumentEventsConformance). So when a verb's await returns, the fold has
already run: a verb never needs waitFor, a revision check or a refetch to see
its own write.
Events
A capability exposes three kinds of events:
| Kind | Meaning | Fired from | Origin |
|---|---|---|---|
| Fact | Something changed in the document | Where the confirmed document event is applied: a mirror's changed callback, or the plugin's one listener for that event |
event.origin |
| State change | A session value changed | The plugin's ctx.state.onChange listener, comparing previous and next |
none |
| Occurrence | An operation ran, finished or failed | Where the operation reaches that point | a domain field when one is needed |
An event fires from exactly one place. Events report what happened; what
something is, is read through a getter. Details: events.md.
Reads
- Reads are synchronous and pure:
get*,list*,is*,has*,can*. - A read returns the same object until its inputs change, so adapter
selectors compare withObject.is. Build such reads withmemoandmemoByKeyfrom@embedpdf/core, not with hand-written caches. - Reads never start engine work. Loading starts with
ensureLoaded(page), or
by the mirror itself.
Reactions
Some plugins do not copy engine data but must respond to it: render
invalidates rasters, search re-runs its query, signature re-judges its
verdicts, actions clears its trigger cache. A reaction:
- subscribes with
ctx.listen(ctx.doc.events, β¦)insideconnect; - ignores origin for what it does;
- only invalidates its own caches or re-runs its own operations. It never
writes another plugin's data.
Pure cores
A core-* package exists when a domain needs logic worth testing without a
kernel, engine or DOM: page geometry (core-geometry), the camera and scene
(core-stage), annotation gestures and hit-testing (core-annotation), the
toolbar solver (core-ui).
- Inputs in, decision out: plain functions, or
update(model, message)
returning the next session, the records it changed and the effects, as incore-annotation. No I/O, no clock, no randomness. - A core never stores engine data. It reads the records it works on from its
input and returns what it changed; the plugin decides where that lives. - The plugin's controller performs the effects. A core does not call the
engine. - Cores know nothing about mirrors, overlays or events. They receive the data
they need as arguments. - A plugin's own pure functions live in its
model.ts, not in a secondcore/folder.
Plugin anatomy
packages/plugin/<name>/src/
<name>.plugin.ts the manifest: definePlugin({ id, token, scope, requires, optional, state, create })
token.ts createCapabilityToken(...), called once per package
contract.ts the public lens: capability interface, public types, the token re-exported
host-contract.ts optional: the host lens, the same token widened with createHostToken
internal.ts optional: framework-only helpers (a visibility boundary, not a bundle boundary)
index.ts the plugin factory and `export * from './contract'`
model.ts state shape, initial state, pure transitions, pure projections and folds
controller.ts create(ctx): builds the capability and returns { api, connect? }
connect.ts optional: registrations with sibling plugins, called from connect
# larger plugins split the controller into areas:
services/ plugin-private helpers shared by the areas
read/ write/ sync/ tools/ one area per file, composed with composeApi
packages/plugin/<name>/test/ mirrors src/Rules:
- The manifest holds no logic. Everything that registers with a sibling
lives inconnect.tsand runs insideconnect. createreturns{ api, connect? }, andcontroller.tsis where it is
built.- Every registration's
Unsubscribegoes toctx.cleanup, even when both
sides close together. - Tests mirror
src/undertest/.
Each file's role, the manifest fields, contracts and lifetime helpers:plugins.md.
Conventions Comments (`docs/conventions/comments.md`)
Comments
A comment explains what the code cannot say: why it is this way, what must
stay true, what a specification requires, or what surprising input looks like.
It is written for a developer who has only this repository in front of them.
Rules
- Self-contained. A comment never depends on anything outside the
repository. Never cite plans, decision numbers, phases, gates, work
packages, internal section numbers, reviews, chats or tickets. If a rule
matters, state it in the comment, or in a committed document and link that
document by its repository path. - Allowed references:
- external specifications with a section:
ISO 32000-2 Β§12.5.6.3,RFC 3161; - the observable behavior of external products:
matches Acrobat: β¦; - committed documents by path:
docs/conventions/state-and-sync.md; - code symbols with
{@link Symbol}.
- external specifications with a section:
- Present tense, current behavior. No history: not "v2 behaviour",
"matches v2", "the old mistake", "used to", "as before", "legacy",
"interim" or "for now". The engine is "the engine", not "the v3 engine".
Migration notes belong in changesets and the migration guide. - No roadmap. Future work is
TODO(#123): β¦pointing at a GitHub issue,
or it is not in the code. - One owner per explanation. Explain an invariant once, where it is
enforced. Elsewhere, link to that symbol instead of repeating it. - Public API is documented with TSDoc. Every member of
contract.tsandhost-contract.tsgets:- a one-sentence summary;
- the
PluginErrorcodes it rejects with and the events it fires, when it has any; @param/@returnsonly when the names do not already say it.
- Length:
- inline comments: one to four lines;
- a module header: what the module is responsible for, in at most about ten lines;
- longer design explanations go into
docs/conventions/or a package README, linked from the code.
- Tone. No ALL-CAPS for emphasis; acronyms are fine. If something must
never happen, write "must not", and back it with a type or a test. - No commented-out code, no names of people, and no dates, unless the date
is part of a specification or protocol. - Section dividers (
// ββ reads ββ) are allowed in files over roughly
150 lines. Always use this format.
Examples
| Instead of | Write |
|---|---|
// the snapshot can never settle stale (G5). |
// Queue one more read, so the snapshot never settles on data older than the last invalidation. |
// the nine members the authoring pattern needs (see the road-to-3.0 plan, Β§5.10) |
// The guarded document handle, page geometry, mirrors, events and lifetime helpers, all bound to one instance. |
// Faint reference cross, prominent live indicator (v2 feel). |
// A faint reference cross and a prominent live indicator. |
// feeding it a units conversion (the old mistake) shrank bodies |
// A px-per-point scale here would shrink screen-anchored bodies at 100% zoom; this is a zoom ratio. |
// Phase 3's shared ScriptHost swaps in by re-registering |
Describe what the code does now, and nothing else. |
// The ONE place the value changes |
// The only place the value changes |
Enforcement
pnpm check:comments runs in CI. It reads comments with the TypeScript
scanner and rejects:
- plan and phase vocabulary, including numbered requirement labels;
- internal section numbers (
Β§without an ISO, PDF or RFC citation); - links to
.mdfiles that do not exist; - version-history references;
TODOs without an issue number;- ALL-CAPS emphasis.
Justified exceptions go in scripts/check-comments.allow, one per line with a reason.
Conventions Docs Architecture (`docs/conventions/docs-architecture.md`)
Docs architecture β author once, render per integration
The law for how documentation is structured so that five integrations never
mean five copies. Framework parity makes this possible: "one page of prose +
per-framework code" is rendering, not aspiration.
Website source paths below are relative to website/; paths beginning with/docs are public documentation URLs.
The two halves
/docs
/viewer β the ready-made viewer (snippet + wrappers)
/headless β the adapter packages (@embedpdf/react|vue|svelte|angular)
/engine β engine choice (local wasm vs cloud), runtime, server- Viewer docs carry the shared integration switcher for Vanilla JS, React, Vue,
Svelte, and Angular. The config API and prose remain shared; installation and
code examples resolve from the integration in the URL. - Headless docs carry the same integration switcher, limited to React, Vue,
Svelte, and Angular. There is one source page per feature: stage, render,
selection, annotation, form, search, and so on. Prose is shared; code and API
names render per framework.
Viewer and Headless share one persisted integration preference. Switching
products carries React, Vue, Svelte, or Angular with you. Vanilla JS falls back
to React when entering Headless because no Vanilla headless adapter exists.
The concrete URL remains the source of truth; the preference only resolves
variant-less courtesy routes.
Author once, render per integration
One MDX file per topic. Integration-specific URLs are generated from it:
src/content/docs/headless/annotation.mdx
β /docs/headless/react/annotation
β /docs/headless/vue/annotation
β /docs/headless/svelte/annotation
β /docs/headless/angular/annotationViewer pages use the same fan-out model:
src/content/docs/viewer/quick-start.mdx
β /docs/viewer/vanilla/quick-start
β /docs/viewer/react/quick-start
β /docs/viewer/vue/quick-start
β /docs/viewer/svelte/quick-start
β /docs/viewer/angular/quick-startWhy per-integration URLs instead of one URL + client switcher: SEO indexes
integration-specific content, links can pin an integration, analytics see per-
integration readership. The catch-all route strips the integration segment,
renders the shared MDX with the integration in context, andgenerateStaticParams emits the page Γ integration matrix. The switcher in the
docs header just navigates to the sibling route (choice persisted, deep links
win over persistence).
Inside a page:
<Example name="annotation/quickstart" />β renders the active integration's sample.<Fw react>β¦</Fw>β rare prose branches. If a page needs many of these,
it's a smell: either the wording should be framework-neutral (see
terminology map) or the page belongs in the per-framework fork set.- The fork set is explicit and small: installation/scaffolding and SSR
integration (Next/Nuxt/SvelteKit/Angular) are separate per-framework pages.
Terminology map (one page, linked from every headless page): hook =
composable = store = inject function; <Viewer> = <EpdfViewer>; etc.
Prose says "the selection hook" and means all four. Writing style guide:
never narrate JSX composition in shared prose.
Code samples are real code, compiled in CI
The cure for docs rot: samples never live inline in MDX. They live as real
files, type-checked against the actual packages:
website/src/samples/
package.json β depends on @embedpdf/react (etc.), workspace:*
react/annotation/quickstart.tsx
vue/annotation/quickstart.vue
svelte/annotation/quickstart.svelte
angular/annotation/quickstart.tspnpm --filter @embedpdf/website-samples typecheckruns in CI: an API
change fails the build until the docs move with it. This is the docs
equivalent of the consume gate.- The MDX pipeline (each site's remark/rehype code-example plugins) reads the
files at compile time and highlights them with shiki. An<Example>or<Snippet>keeps its code out of the compiled page: each framework's files go
to a JSON file in.next/cache/docs-code/, keyed by the sample's name and a
hash of the files, and the page only names the key and the frameworks that
have a version. The page's server component (RouteExample) reads the
route's framework's files at render (docs-kitmdx/code-panels). Inlined, four
frameworks made page modules megabytes each, and the docs route, which
compiles every page, ran the dev server out of memory. - A missing sample for a framework renders an honest "not yet ported for
{framework}" callout β driven by file presence, not hand-maintained flags.
The reference is complete, and checked
Every headless plugin page ends with its reference: ## Methods (everythinguse() gives you), ## State (everything useState() gives
you, with the getter that reads each value once), then## Settings, ## Events and ## Permissions. The rows are written by hand,
in plain words, each linking to the section that shows it.
- Every public member of a plugin's capability is on exactly one page: a
Methods row, a State row's getter, or an Events row. A plugin spread over
several pages splits by its nouns (annotation.selection.*on Selecting). docs/content/reference.mjssays which pages document which capability.docs/content/scripts/reference.mjsreads each capability interface from its
TypeScript source and fails on a member no page lists, a member on two pages,
or a listed name the capability doesn't have. It runs indocs:check.- The pages describe the API the code is moving to. Where they're ahead, the
name ispendingin the manifest, with why; the check fails once a pending
entry is no longer needed, so the list only shrinks. - The same check compares the tables with the declarations next to each
contract, once a plugin has them: a State table'sFieldcolumn with itsdefineState()fields, a Settings table'sSettingcolumn with its settings
defaults (a row may name a group), and everyFrom CSScolumn withEPDF_VARIABLESin@embedpdf/web, both ways.
A page goes live when its code does
The pages describe the API the code is moving to, so a page isn't shown as
released until its code exists. Two machine checks decide it, per page and per
framework, and run in docs:check:
docs/content/scripts/snippets.mjscompiles each framework's snippets against
the packages in the same commit (React withtsc, Angular withngcandstrictTemplates, Vue withvue-tsc, Svelte withsvelte-check) and writesgenerated/snippet-status.json. It never fails the build.docs/content/scripts/publish-gate.mjswritesgenerated/live-pages.json: a
page is live for a framework when its snippets compile for it and none of the
names its Methods, State or Events tables list ispending.
The viewer's pages go through the same gate, for five integrations: the four frameworks andvanilla, a plain HTML page. Their snippets compile against @embedpdf/viewer at its source.
A .vanilla.html snippet's module scripts are taken out of the page and type-checked as
JavaScript, at the page's own line numbers. The reader's own DOM code (event.target.value) is
left alone, and the CDN script resolves to the viewer. The page's elements are checked too:
every and `<epdf-*>` element must be declared in the viewer's types, every attribute must be a config key, a slot must name one of the viewer's, on a
child of the viewer, and a <script src> must be the CDN script or a path. The js and ts
blocks a viewer page shows are checked against ViewerConfig for every integration, as the
config is the same in each. docs/content/scripts/viewer.mjs holds these checks. A viewer page
whose prose promises what the viewer doesn't do yet lists those gaps indocs/content/viewer-pending.mjs, each with why, and stays coming for every integration until
they're removed, however little code it has. In CI, publish-gate.mjs --strict (the docs gate
job in .github/workflows/packages.yml) fails when a check crashes or stops checking, or a
headless page's snippets don't compile; a viewer page that's coming fails nothing.
Live examples (docs/content/samples/) are complete apps, so every one must compile.docs/content/scripts/samples.mjs checks them straight from docs/content, without syncing them
into a site or starting one, reports per framework (React with tsc, Angular with ngc, Vue
with vue-tsc, Svelte with svelte-check; Angular and Vue with strictTemplates) and fails on
an error:
pnpm --filter @embedpdf/docs-content samples # every example
pnpm --filter @embedpdf/docs-content samples search # one area's examples
pnpm --filter @embedpdf/docs-content samples viewer # the ready-made viewer'sThe ready-made viewer's examples (samples/viewer/) compile as its snippets do: against the
viewer at its source, for the four frameworks and vanilla, whose page's module scripts and
elements are checked as a .vanilla.html snippet's are (viewer.mjs).
Both checks share scripts/compile.mjs: each framework's compiler and options, and a run
directory of their own in the system's temporary directory, whose node_modules links to the
embedpdf.com site's, so several can run at the same time. (Never under a node_modules path:svelte-check reports nothing for a file there.) Every run also compiles a canary per framework,
with a missing export, a missing module and a template naming nothing: a compiler that doesn't
report all three isn't checking, and the framework fails. An Angular file that declares a component,
directive or pipe without exporting it fails too: ngc can't type-check a template that uses it the
usual way, and then stops reporting template errors in every file. Vue snippets allow components they
don't import (a snippet may use a global or a Nuxt auto-import); examples don't. Each site'scheck:samples runs the same example check on what it ships (samples.mjs --root src/samples):
the synced copy, in every framework, the cloud form included on cloudpdf.com.
An example in each framework
samples/<area>/<name>.react.tsx one file per frameworkβ¦
samples/<area>/<name>.vue.vue
samples/<area>/<name>.svelte.svelte
samples/<area>/<name>.angular.ts (root component `export class App`, selector `demo-root`)
samples/<area>/<name>.vue/App.vue β¦or a directory, entry first (`App.svelte`,
samples/<area>/<name>.vue/Toolbar.vue `App.tsx`; Angular's is `app.ts`)
samples/<area>/<name>.css the one stylesheet every framework's version usesReact, Vue and Svelte import the stylesheet (import './<name>.css', '../<name>.css' from a
directory). Angular names it in styleUrl: './<name>.css' withencapsulation: ViewEncapsulation.None, and writes its template inline. The code view shows each
framework's files with the stylesheet after the entry. In the demo builds both forms become the
same stylesheet, scoped to the example's class (src/lib/sample-stylesheets.ts in each site);
the Angular pass compiles in the browser (JIT), so it first applies Angular's JIT transform,
without which input(), output(), model() and viewChild() do nothing
(src/lib/angular-demo-samples.ts).
A viewer example
samples/viewer/<page>/<name>.vanilla.html a plain HTML page: markup and module scripts
samples/viewer/<page>/<name>.react.tsx β¦and the four frameworks, as above
samples/viewer/<page>/<name>.cloud.react.tsx cloudpdf.com's own version, where a swap can't say itThe page shows one with <Example name="viewer//<name>" />, like any other. The viewer fills
its box, so a viewer example gives it height: 100% and the docs give the box: a frame that suits
the docs column, or a bigger one with size="tall", or size="wide", as tall and on a wide screen
over the table of contents' column too (src/components/docs/example.tsx, the kit'sCodeExampleCard frame). A page may show several: each starts its own viewer, when it scrolls
near. Its document is a [!doc-source] block holding a constant (const ebook = 'β¦';, in a plain
HTML page inside its module script), which cloudpdf.com's copy gives in the cloud viewer's form.
The plain HTML page loads the viewer from its CDN script, as a reader's does. Its demo
(src/lib/vanilla-demo-samples.ts) is the page as a module: the markup goes into the demo's
element, each script's imports move to the module's top and the rest runs on every mount, finding
elements in its own demo only, and the CDN script is the site's own build of it
(@embedpdf/viewer/snippet on embedpdf.com, @cloudpdf/viewer/snippet on cloudpdf.com). Nothing
else may load from another origin. The viewer's demos use its packages' builds, as a reader's app
does: rebuild a viewer package you changed before build:demos.
The checks and the demos read the adapters' source through their workspace exports: Vue's
point at ./src/<entry>.ts as plain strings, like React's; Svelte's name the same file undertypes, svelte and default. Never only under development, which the demo builds don't use.
The checks map @embedpdf/angular/* to its source (compile.mjs); the Angular demos use its
build.
A page that isn't live shows its title and a notice that it describes 3.0 for
that framework, linking to the React version when that one is live; it stays out
of llms.txt, that framework's search index and search engines. WithDOCS_PREVIEW=1 (the preview site, and next dev unless DOCS_PREVIEW=0),
every page is live under a banner. The sites read the gate through@embedpdf/docs-kit/publish. Both generated files are built, never committed.
The support matrix is generated, not written
During the rollout (React complete β Vue/Svelte/Angular incremental), every
vertical's page shows its framework support honestly. The matrix derives from
two machine sources: the adapter's exports map (does the vertical exist?) and
sample presence (is it documented?). Angular's check-parity.mjs PENDING set
is the same data β one source of truth, surfaced in docs.
v2 docs afterlife
- Current v2 site: frozen static build at
v2.embedpdf.com, banner linking
to current docs. Never rots, never maintained. - 301 map from the old 200-page URL space into the new tree lives in
next.config.tsredirects()β written once at launch, SEO preserved. - Docs version switcher: just a link to the archive. No in-tree versioning.
Rollout order
/docs/viewerβ launch the ready-made Viewer integrations first./docs/headless/react/*β the complete vertical set, proving the
author-once machinery.- Vue/Svelte/Angular routes go live per vertical as adapters land β the
generated matrix keeps the gaps honest instead of hidden.
Conventions Engine Host Isolation (`docs/conventions/engine-host-isolation.md`)
Engine-host isolation
Thread confinement makes parallel PDFium use correct; it does not make native
crashes safe. Host isolation moves the thread-confined WorkerThreadPool into
one or more supervised child processes so an engine crash does not kill the API
process. Enable it with CLOUDPDF_ENGINE_ISOLATION=host; useCLOUDPDF_ENGINE_SHARDS to partition documents across multiple supervised
hosts. The thread-ownership rules inruntime-confinement.md still apply inside every
host.
The implementation lives in cloudpdf/server/src/runtime/EngineHostClient.ts,cloudpdf/server/src/runtime/ShardedEnginePool.ts, andcloudpdf/server/src/runtime/engine-host-entry.ts.
Threat model: what a compromised engine can reach
PDFium parses attacker-controlled bytes; assume an eventual RCE in the engine
process and design for containment, honestly labelled.
Credential-exposure reduction
- The host child is forked with a whitelisted environment: no
CLOUDPDF_DB_URL, JWT secret, license key, or object-store credentials.hostEnvWhitelistapplies the allowlist, integration tests assert the child
environment, andNODE_OPTIONSis stripped so an inherited--inspect
cannot open a debug port in the engine process. - This is exposure reduction, not a hard security boundary. The engine is a
same-container, same-UID child. Whether it can read the API process's memory
or/proc//environdepends on the kernel's ptrace policy
(kernel.yama.ptrace_scope, a node-level sysctl in Kubernetes).HOMEand
the container filesystem remain shared. Marking the API process non-dumpable
(PR_SET_DUMPABLE=0) would close same-UID/procaccess independently of
Yama, but is not currently part of this boundary. - Durable state integrity is a hard property: a lying engine can corrupt its
own outputs, but the write pipeline's generation fence and version CAS keep
it from silently blessing stale state as committed.
Reachable surfaces
- The pod network. NetworkPolicy can narrow pod egress, but the engine can
still reach API-process ports inside the pod. - The base and layer cache files it renders from, plus its IPC channel to the
parent. Treat rendered output as untrusted content. - The kernel syscall surface, minus seccomp
RuntimeDefault. A sandbox runtime
such as gVisor or Kata, configured throughruntimeClassName, strengthens
pod-to-node isolation at syscall-emulation cost. It applies to the whole pod;
it does not isolate the engine from the API process inside that pod.
Non-boundaries
- In-process Node sandboxing, including the permission model and frozen
intrinsics, is not a security boundary against native code. - A sidecar split does not by itself create an intra-pod network boundary:
ordinary Kubernetes NetworkPolicy cannot distinguish containers in the same
pod. Re-evaluate a separate-pod engine tier if a hard network and identity
boundary becomes a requirement.
Crash and overload blast radius
- A host crash rejects in-flight work retryably and triggers a backoff-capped,
sub-second respawn. - Multiple shards reduce the resident-document blast radius because each host
owns only its rendezvous-hashed partition. Any persistently unavailable shard
makes the pod unready; requests do not fail over to a different shard inside
the same pod. - Repeated sole-suspect crashes are journaled and quarantined by
(base_sha, engine_build). - Admission control sheds overload with retryable 503 responses instead of
queueing without a bound.
Operational rollout, sizing, recycling, and metrics live incloudpdf/server/deploy/helm/DEPLOY.md.
Conventions Engine Services (`docs/conventions/engine-services.md`)
engine-services conventions
@embedpdf/engine-services contains the synchronous, runtime-agnostic engine logic shared by@embedpdf/engine and @cloudpdf/server.
The package should read like a small framework: a developer should know what a file does from its
directory before opening it. Paths below are relative topackages/engine/services/ unless they start at the repository root.
Top-level structure
src/
runtime/ Low-level helpers for @embedpdf/engine-runtime calls.
shared/ Generic helpers used across layers.
document-session/ Lifetime and identity state for one open document.
features/ Business-level document capabilities.
worker-host/ Worker wire dispatch and request orchestration.Dependencies flow downward:
runtime/shared -> document-session -> features -> worker-hostdocument-session/ must not import from features/. Feature code may use a DocumentSession;
the session layer should stay focused on open-document state, page identity, page handles, and
revision bookkeeping.
Feature modules
Each feature follows the same shape:
feature-name/
FeatureReader.ts
FeatureMutator.ts # only when the feature changes document state
internal/
...
index.tsRules:
- Use
Readerfor read-only orchestration. - Use
Mutatorfor business-level document changes. - Put helper functions, registries, per-subtype codecs, and low-level runtime details in
internal/. index.tsexports only the feature's public orchestration surface.- Avoid root-level helper files beside a reader or mutator; if it is not an entry point, it belongs
underinternal/.
Naming
- Public orchestration classes use
PascalCase.ts:MetadataReader.ts,PagesMutator.ts. - Internal helper modules use descriptive verb phrases:
readMetadataText.ts,writeTextMarkupAnnotation.ts,computeMutationImpact.ts. - Avoid generic names like
util.ts,helpers.ts, orregistry.tsunless they live under an
already-specific folder and still read clearly. - Use
runtimeor@embedpdf/engine-runtimeterminology. Do not use upstream project names as
directory names.
Worker boundary
worker-host/WorkerHost.ts is the public host entry used by local workers, inline transports, and
server worker threads. It should own:
- request envelopes;
- abort controller tracking;
- resolve/reject serialization;
- session lookup and close/shutdown routing.
Feature behavior belongs in features/, not in worker adapters.
This package runs where PDFium runs: in the engine's worker, the server's worker threads, or the
page itself when there is no Worker. A page's main thread never imports it otherwise. The
local engine imports it, with @embedpdf/engine-runtime, only inpackages/engine/main/src/transport/InlineTransport.ts, loaded with import() when an engine
boots in the page (packages/engine/main/test/entry-graph.test.ts checks it). What both engines
need beside a document handle, such as EventHub, lives in @embedpdf/engine-core/runtime.
The checks it runs before a write (a write's values against its kind's schema, the import
planners) come from @embedpdf/engine-core/validation, the entry built with zod; the page's
entries reach no zod (packages/engine/core/test/entry-graph.test.ts). A write is prepared,
checked and derived; a viewer predicts it with the same preparation and derivation without
schema validation, and the check only accepts or refuses, so this package writes what the viewer
predicted or refuses it.
Mutation safety
- Every job whose effect is
writeorcontentWriteruns as one layer transaction, begun and ended
inWorkerHost.run(a signing'ssignatures.completeis the exception: it replaces the
document's bytes instead). The transaction commits infinishMutation, before the layer is saved; any throw before
that aborts it, and the document and the derived caches are as they were. A
writer never begins, commits or rolls back on its own. - A commit or an abort that fails leaves the session unusable: every later job is refused with
DocNotOpen, and the document has to be opened again. - Mutators still validate caller input and check cancellation before the first native write: a
refusal costs no copies, and the error names the caller's mistake. - A check that depends on the document runs inside the write, against what the write reads: the
caller's authority over an annotation's owner (AnnotationAuthority), the members of a thread a
delete takes. No read job runs before a write to check it. - Writers find annotations from the page's dictionaries (raw handles), so a write never parses a
page's content unless it writes that content (flatten, redaction). - A write that moves entries of a page's
/Annots(a delete, a reorder, a flatten, a redaction, a
merged field's split) callspromoteInlineAnnotationsfirst, before it loads the page: an inline
annotation is named by its position only while no entry has moved. - A batch is all or nothing too: redaction apply and page flatten throw when a page fails or the
call is cancelled, and their per-page results only sayappliedorunchanged. - Writes bump
cacheSeq(derived caches) as they go;finishMutationbumpseditsSeq(unsaved
edits, the signing version) once per committed write.
Adding a feature
When adding a new worker capability:
- Add the wire request/response types in
@embedpdf/engine-core. - Add a
ReaderorMutatorunderfeatures/<name>/. - Put implementation details under
features/<name>/internal/. - Add a small handler path in
worker-host/. - Add local/server/cloud adapter changes only where the public engine API requires them.
- Cover the behavior with conformance tests or focused package tests.
Conventions Events (`docs/conventions/events.md`)
Events
A capability's events report what happened. What something is, is read
through a getter. This document covers the three kinds of events, where each
one fires, how events are created and named, and how a fact event says where
its change came from.
Three kinds
| Kind | Meaning | Fired from | Origin | Examples |
|---|---|---|---|---|
| Fact | Something changed in the document | Where the confirmed document event is applied | event.origin, as the engine sent it |
metadata onUpdated; form onValueChanged, onFieldCreated; annotation onCreated, onDeleted; signature onSigned; redaction onApplied |
| State change | A session value changed | The plugin's one ctx.state.onChange listener |
none: session state has no origin | shell onSurfaceOpened; stage onZoomChanged, onPageChanged; search onActiveHitChanged, onProgressChanged |
| Occurrence | An operation ran, was requested, finished or failed | Where the operation reaches that point | a domain field when one is needed | search onStarted, onCompleted, onFailed; stage onMotionEnded; form onValidationRejected; commands onExecuted; annotation onWriteFailed |
Fact events
A fact event fires where the plugin applies the confirmed document event:
- in a mirror's
changedcallback, for data the plugin mirrors
(packages/plugin/form/src/sync/fields.ts,packages/plugin/metadata/src/controller.ts); - in the plugin's one
ctx.listen(ctx.doc.events, β¦)handler for that event
type, for data it does not mirror (packages/plugin/redaction/src/sync/document-events.ts).
Because the confirmed event is the only source, the same event fires for this
session's writes, the document's scripts and other sessions, and it fires
once. A verb never emits a fact event itself.
// packages/plugin/form/src/sync/fields.ts, inside the mirror spec
changed: ({ cause, event, previous, next }) => {
if (cause === 'load') {
if (next.snapshot) events.resynced.emit({ snapshot: next.snapshot });
return;
}
if (!event || !('origin' in event)) return;
const { origin } = event;
switch (event.type) {
case 'forms.valueSet':
events.valueChanged.emit({ field: event.field, origin });
return;
// β¦
}
},State-change events
A plugin derives all of its state-change events in one ctx.state.onChange
listener, from the value before and after each committed change. A new verb
therefore cannot forget to announce, and a verb never emits a state-change
event itself.
A state-change event is derived from state even when it reads like progress.
If a value is state (a getter returns it, and it is in the page's State
table), its event fires from the listener when the value changes, never from
the operation that moves it. Search's progress is state, soonProgressChanged fires when progress changes: once for each slice of
pages a search gets through, and back to none when a new search starts or
the session is cleared. The event and getProgress() then never disagree,
whichever verb or reset changed the value.
// packages/plugin/shell/src/controller.ts
ctx.state.onChange(({ previous, next }) => {
if (previous.openMenus !== next.openMenus) {
for (const id of previous.openMenus) {
if (!next.openMenus.includes(id)) menuClosed.emit({ id });
}
for (const id of next.openMenus) {
if (!previous.openMenus.includes(id)) menuOpened.emit({ id });
}
}
});
// packages/plugin/search/src/controller.ts
ctx.state.onChange(({ previous, next }) => {
// A slice moves it forward; a new search or clear() puts it back to none.
if (previous.progress !== next.progress) {
progressChanged.emit({ ...next.progress, hitCount: next.hits.length });
}
// β¦
});Occurrence events
An occurrence fires where the operation reaches the point it reports: the
search scan emits onStarted when it starts and onCompleted when it is
complete; the stage's camera animation emits onMotionEnded where a tween or
fling ends; the form write path emits onValidationRejected when the
document's scripts refuse a value. An occurrence that needs to say who asked
carries a domain field for it (the actions plugin's ActionContext.origin),
not an EventOrigin.
Rules
- An event fires from exactly one place.
- A verb never emits a fact or state-change event.
- When a session value a getter returns is announced, it's by a state-change
event, never by an occurrence, even when the value is an operation's
progress. Not every value needs an event: the state hook follows them all. - A refusal before an operation started fires nothing: it only rejects. An
operation's failure event (onFailed) is for work that started. - Loads and reloads are announced once, as
onResynced. They never produce
per-item fact events. - Events are for things that happened. A UI never keeps its own copy of state
by listening to events; it reads through a getter and a selector. A late
subscriber that needs the current value calls the getter. - Payloads are plain, serializable objects with
readonlyfields.
Creating events
A plugin creates each event with ctx.events.source<T>():
const updated = ctx.events.source<MetadataUpdatedEvent>();
// { on: EventHook<T>, emit(event: T): void, dispose(): void }
const api: MetadataCapability = {
// β¦
onUpdated: updated.on,
};- The capability exposes only
.on, typedEventHook<T>.emitanddisposestay inside the plugin, so the emitting half cannot be reached
through the contract. - The kernel disposes every source when the instance closes.
- Plugins do not call
createEventHookdirectly; it exists for the kernel
and the adapters.
Delivery (packages/core/main/src/event-hook.ts):
emitcalls the listeners synchronously, over a snapshot of the listener
set taken at the start of the emit.- A throwing listener is reported and never breaks the emitting operation or
the other listeners. on(listener, { signal })ties the subscription to anAbortSignal, and
returns anUnsubscribe.- There is no replay and no cached value.
Naming
- A capability event is
on<Subject?>:onFieldCreated,onZoomChanged,onSurfaceOpened,onApplied. - A value that changes is announced as
on<Value>Changed:onZoomChanged,onLocaleChanged,onActiveHitChanged. - A load or reload is
onResynced. - The payload type is named after its event:
onFieldCreatedβFieldCreatedEvent(usually prefixed with the plugin:MetadataUpdatedEvent,SearchCompletedEvent).
The full vocabulary is in naming.md.
Origin
Every fact event carries origin: EventOrigin: the engine event's ownorigin, passed on as it is. No plugin builds or translates an origin
(scripts/check-architecture.mjs flags an object literal with sessionId
and sub).
// @embedpdf/engine-core, re-exported from @embedpdf/core
interface EventOrigin {
kind: 'local' | 'remote'; // this engine instance, or another session
sessionId: string; // the engine session that made the change
sub: string | null; // the authenticated user behind it (cloud); null locally
ts: number; // when it happened
serverId: number | null; // the server's audit row, the resume cursor
tx?: { id: string; index: number; count: number }; // one of several committed together
}kind: 'local'means this engine instance, not this user: the same user in
two tabs is two sessions, and each tab sees the other's changes asremote.- Ask
kindorsessionIdfor "is this my own action?" (undo, reconciling
optimistic state). Asksubfor attribution. - Handlers that update state ignore origin (see
state-and-sync.md); origin is for the few
features that care about provenance. stream.desyncedcarries no origin: it is a transport notice, not a
mutation. A mirror turns it into a reload and anonResynced.- An event that a verb can cause as well as a document event carries
origin: EventOrigin | null, withnullfor the verb: render'sonInvalidatedafterinvalidate().
Resynced
onResynced fires when a mirror's load lands: the first load, a refresh(),
and every reload after stream.desynced, document.versioned or a fold that
asked for one. Its payload says what was read, never what changed:
| Plugin | Payload |
|---|---|
| metadata | { metadata } |
| form | { snapshot } |
| annotation | { pages: 'all' | PageRef[] } |
A subscriber that cares about the new content reads it through the getters.
Subscribing
- Inside a plugin:
ctx.listen(source, listener), wheresourceis anEventHook(annotation.onCreated) or anything withsubscribe
(ctx.doc.events). The kernel owns the unsubscribe; subscriptions to
siblings are made inconnect. - In React:
useCapabilityEvent(Token, (capability) => capability.onX, handler),
or the per-plugin helper (useMetadataEvent,useSearchEvent, β¦).
Angular hasinjectCapabilityEvent. Both subscribe for the component's
lifetime. - Anywhere else:
const off = capability.onX(handler), or pass{ signal }and abort it.
The kernel's own document lifecycle events live on the documents capability
(DocumentsToken): onOpened, onOpenFailed, onLocked, onClosed,onActiveChanged, onPagesChanged and onUnsavedChangesChanged.
Conventions Naming (`docs/conventions/naming.md`)
Naming
Names are the first documentation a reader sees. These rules exist so that any
file in the repository reads the same way, and so that a name can be trusted
without opening its definition.
Package names follow a separate law: packages.md.
Principles
- A name says what the value is in the domain, not its type or storage:
annotation,field,pane,hit. Nota,f,obj,data. - Spell words out. Abbreviations are allowed only when the glossary below
lists them. - One name per concept, everywhere. If the engine calls it a
PageRef,
nothing calls it a page id, pointer or address. - A name's length follows its reach. Exported and module-level names must
stand alone. A two-line callback may use a short word (page,hit), never
a letter.
Identifiers
| Kind | Rule | Example |
|---|---|---|
| Single letters | Only i/j as numeric loop counters, x/y as coordinates, and _ for an ignored parameter. |
for (let i = 0; β¦), { x, y } |
| Comparators and equality helpers | left/right, never a/b. |
(left, right) => left.index - right.index |
| Callback parameters | Name what the callback receives. | useSelector(SearchToken, (search) => search.getActiveHit()) |
| Boolean values | Adjectives or participles for state: open, placed, resting, readOnly. |
pane.open |
| Boolean functions | is*, has*, can*, should*, would*. |
canEdit() |
| Permissions | Always can*, as a capability method; a component that needs it reactively reads it with a selector into a local of the same name. Never a renamed field. |
const canEdit = useSelector(PageEditToken, (pageEdit) => pageEdit.canEdit()) |
| Functions | Start with a verb. create* builds an instance, to* converts, *Of(value) is a pure lookup or projection. |
createPane, toPageRef, regionsOf(mark) |
| Constants | UPPER_SNAKE only for module-level constants holding primitives or frozen data. |
DEFAULT_CHROME, RERUN_DELAY_MS |
| Units | Put the unit in the name when it is not obvious. | delayMs, thresholdPx, sizePt, angleDeg |
| Unused parameters | Prefix with _. Never use _ for "private". |
(_event, page) => β¦ |
| Declarations | const unless reassigned; let otherwise; never var. |
|
| Generic parameters | T, K, V only in fully generic helpers; name them elsewhere. |
memo, PluginDef<State, Capability> |
Glossary
These are the only abbreviations, and the exact meaning of words that are easy
to mix up.
| Word | Means exactly | Never used for |
|---|---|---|
ctx |
the plugin's PluginContext |
action, command or canvas contexts (actionContext, commandContext, canvasContext) |
doc |
a DocumentHandle |
document metadata (documentInfo), DOM documents (ownerDocument) |
api |
the capability object a controller is assembling | a capability received from elsewhere (name it after its plugin: annotation, stage) |
id / ids |
an opaque string identifier | object numbers, refs, keys |
ref |
a typed engine reference (PageRef, AnnotationRef, FormFieldRef) |
DOM refs (elementRef), ids |
key |
a string derived from a ref for map lookups (annotationKey, encodePageKey, FieldKey) |
refs, ids |
dto |
an engine DTO, only in code that converts DTOs | model records |
page |
a PageRef |
a display index (pageIndex), an object number (pageObjectNumber), layout (pageInfo) |
pageIndex |
a zero-based display position | identity |
pageObjectNumber |
the scalar object-number foreign key (type PageObjectNumber) |
|
objectNumber |
the number inside a ref of kind 'objectNumber' (PageRef, AnnotationRef, FormFieldRef), and on a record that is the object itself (a form widget); the type says which object |
a loose number whose object isn't obvious (name it: pageObjectNumber) |
record |
an entry of a mirror (engine data held by a plugin) | DTOs, overlay entries |
event |
a document or capability event | a DOM event in code that also handles document events (domEvent) |
dx, dy |
coordinate deltas | |
url, uri, dpr, min, max, pdf |
themselves |
Spell out everything else: error (not err, e), options (not opts),config (not cfg), previous (not prev), current (not cur), index
(not idx), result (not res), callback (not cb), element (not el),message (not msg), definition (not def), selection (not sel).
Types
| Suffix | Use |
|---|---|
XState |
a plugin's session state |
XCapability / XHostCapability |
the public / sibling-plugin capability |
XConfig |
registration configuration (xPlugin(config)) |
XOptions |
per-call options; extends OperationOptions when the call does work |
XInput |
the input of a create verb |
XPatch |
a partial update |
XInfo |
a public read shape that is not the internal record |
XRef / XKey / XId |
as in the glossary |
XEvent |
an event payload, named after its event: onFieldCreated β FieldCreatedEvent |
XRecord |
a mirror entry |
- Use
interfacefor object shapes in contracts, andtypefor unions,
aliases and mapped types. - Contract fields and state fields are
readonly. - Getters return
T | nullfor "absent". Optional inputs and optional data
fields use?:. One API never uses bothundefinedandnullfor the same
meaning.
Discriminated unions
typetags things that happen: messages, events, effects.kindtags things that are: geometry, refs, borders, drafts.- Tag values use one style per union (kebab-case or camelCase), never both.
Capability vocabulary
| Verb | Meaning |
|---|---|
get*, list*, is*, has*, can* |
Read current state. Always synchronous. |
read*, load* |
Read from the engine. Always returns a promise. |
ensureLoaded(page) |
Start loading if needed. Returns Promise<void>. |
refresh() |
Reload engine data. Returns Promise<void>. |
set* / update* |
Change a value: set replaces, update merges. |
create* / delete* |
Make or destroy a document object. |
add* / remove* |
Put into or take out of a collection. |
open / close / toggle |
Surfaces, menus, documents. |
begin* / end* |
Bracket a gesture or an edit session. |
register* |
Add an extension. Always returns Unsubscribe. |
on* on a capability |
An EventHook, named on<Subject?>: onFieldCreated, onZoomChanged. |
on* on a registration object |
A callback the capability calls, such as InteractionHandler.onDown. |
i18n.t is the one single-letter member, following the universal i18n convention.
Files and folders
- Files are kebab-case and hold one area each, named for the area:
write/values.ts,read/widgets.ts. - Plugins follow the anatomy in
plugins.md. - Tests mirror
src/undertest/. Test names are sentences that describe behavior.
Enforcement
ESLint enforces the mechanical parts:
id-lengthwith the single-letter exceptions above;id-denylistfor the spelled-out words above;@typescript-eslint/naming-conventionfor casing.
Review covers the rest.
Conventions Packages (`docs/conventions/packages.md`)
Package naming β the law
plugins.md is the law for plugin authors,naming.md for identifiers inside the code, andtooling/build/README.md for
how packages build. This file is the law for where a package lives and what it
is called. Every name derives from the tree; if you have to debate a name, the
tree is wrong.
The three clauses
- A package's npm name is its repo path with dashes.
packages/plugin/stageβ@embedpdf/plugin-stage,packages/core/geometryβ@embedpdf/core-geometry,packages/engine/servicesβ@embedpdf/engine-services. packages/<group>/mainis the group's namesake:@embedpdf/<group>.core/mainis the kernel (@embedpdf/core);engine/mainis the default
local-wasm engine (@embedpdf/engine). Groups without a principal package
simply have nomain/.framework/packages are bare-named β@embedpdf/react,/vue,/svelte,/angular,/web. They are the marquee surface an app installs;
the group prefix would only be noise on npm.
The groups
| Group | What belongs there |
|---|---|
core/ |
Framework-free, DOM-free client logic (the Rust-portable layer): kernel, geometry, feature cores, acrojs, js-sandbox. Narrow lib in tsconfig is the purity guard β never widen it to accommodate a dependency. |
engine/ |
The PDF engine family: contract (core), default engine (main), services, wasm/napi runtime (runtime + its npm/* target sidecars). |
plugin/ |
Kernel plugins, framework-free. Directory drops the plugin- prefix (the group says it); npm names keep it. |
framework/ |
All framework-specific code. Nothing framework-flavored exists outside this group β where "framework-flavored" means adapter code (hooks/components for composing viewers); see viewer/. |
viewer/ |
The full-viewer product line: the one React chrome (chrome, private), its Preact-compiled delivery (main β @embedpdf/viewer: the `` element + EmbedPDF.init), and thin per-framework wrappers (react, vue, svelte, angular). Products built on framework/, not adapters: a viewer package exports the finished viewer, never composition primitives. |
Examples (examples/*) and internal tooling (tooling/*) are not part of the
law's namespace: examples are @embedpdf/example-<dir> and private; tooling is@embedpdf/tooling-<dir> and private.
`cloudpdf/` β the same law, the other scope
cloudpdf/ holds the CloudPDF (@cloudpdf/*) tree, and the directory name is
the scope. The three clauses apply verbatim with the scope swapped:cloudpdf/viewer/react β @cloudpdf/viewer-react, and cloudpdf/viewer/main
is the group's namesake @cloudpdf/viewer. One law, two scopes β a CloudPDF
package is never named by a separate rule.
cloudpdf/ is a scope boundary, not a license boundary. The license line is
per-package and runs by what a package is, not where it lives: libraries are
Apache-2.0 like the rest of the repo; the deployable server product
(cloudpdf/server) is Fair Source (FCL-1.0-ALv2), the repository's only
non-Apache package. See LICENSING.md. Do not infer a package's license from
its directory; read its own LICENSE.
What decides whether a path gets a group level is one line:
A directory level exists to hold siblings.
That is why every packages/ group is grouped (each has siblings) and whycloudpdf/engine, cloudpdf/server, cloudpdf/sdk, cloudpdf/contract
sit flat: a group level over a lone package carries no information.cloudpdf/viewer/ is grouped because the viewer product line has five members
(main plus one wrapper per framework). When a flat CloudPDF package gains a
sibling it regroups then β repo paths are internal, so unlike npm names a move
costs nothing but the churn.
Two consequences worth stating, because both have already caught us:
- Nothing may reconstruct a package's directory from its npm name. Clause 1
maps path β name, and that is not invertible without knowing the group list.
Ask the workspace instead (pnpm ls -r --depth -1 --jsonyields name + path);
the release workflows do. - Cross-scope names collide on their last segment.
@embedpdf/viewer-react
and@cloudpdf/viewer-reactare the two doors of one product β deliberately
parallel β so anything keyed on the short name (release assets, cache keys,
directories) must carry the scope.
Viewer wrappers keep the viewer- prefix in both scopes; clause 3's bare
naming is for framework/ adapters only. @cloudpdf/react is free on npm but
would read as the cloud twin of @embedpdf/react β a composition adapter β
when it is in fact a finished viewer.
Corollaries
- Adding a package = choosing its group; the name falls out. A package that
fits no group is a design conversation, not a naming one. - Renames after 3.0.0 ships are breaking changes; before that they are free.
This law was locked while everything was at 0.0.0 β keep it locked. - The changeset
fixedgroup in.changeset/config.jsonenumerates every@embedpdf/*publishable β one version for the whole SDK, engine included.
Conventions Permissions (`docs/conventions/permissions.md`)
Permissions β the law for authorization-aware plugins
plugins.md is the law for a plugin's shape; this file is the law for how a
plugin answers "may this session do that?" β so every viewer built on the
plugins, ours or a host's, renders a truthful UI instead of offering actions
the engine will refuse.
The four layers, one direction
1. Engine / server enforces scope + collab grammar + PDF bits (ScopeGuard, routes,
and per-record checks inside the worker's write)
2. Security service mirrors doc.security.allows(cap) + the per-record collab mirrors
3. Plugin capability translates verbs β capabilities; gates; exposes twins
4. Chrome / host UI renders from the plugin's twins onlyEach layer consumes only the layer below. A UI never touches scope strings orallows() directly β what a verb requires is the owning plugin's knowledge
(search 'full' needs doc.text.search and doc.text.copy; only the
search plugin should know that). Download and print have no plugin of their
own: their verbs live on the kernel's documents capability, and so do their
twins, documents.canDownload() and documents.canPrint(). How sharp a print
comes out (doc.print.high) isn't a yes or no: documents.getPrintQuality()
says it.
The twin law
Every verb that can refuse ships a
cantwin: same name, same arguments,
boolean, answering "would this verb succeed right now for this session?"The twin composes everything the verb itself checks β authority (scope),
document flags (locked,lockedContents,/Fstates), and structural
preconditions (selection composition) β and excludes the active tool: twins
are facts; mode gates whether affordances render at all, orthogonally.
Verbs that cannot refuse get no twin.
Why twins and not a permissions() bag or a generic can('verb'):
- Arity fidelity. Questions have the same argument shape as their
operations (canDelete(ref),canSearch(mode)). A bag cannot hold
argument-taking questions; twins cover every case with one rule. - Drift resistance. The twin lives next to its verb and changes in the
same diff; the 1:1 name/signature mapping admits a mechanical conformance
test: calling a gated verb while its twin is false must refuse. - Zero-judgment derivation. The convention is a function, not a style
guide: verb name βcanprefix β same signature. Nothing to decide for
any future plugin β which is how shape drift is prevented, not policed.
Bags may exist only as derived conveniences bundling twins for
one-subscription UIs (comments.permissionsFor(ref) is the precedent). They
never define answers of their own.
The collapse rule. Twins mirror answers, not method names: when every
verb in a family would compute the identical expression (one authority, no
flags, no argument variance), the family ships one twin named for the family
β pageEdit.canEdit() covers rotate/move/delete/insert/addBlank, becausedoc.pages.assemble is their one shared answer and PDF itself has one
assemble bit. Two twins may never compute the same expression (two names for
one fact is drift surface); the moment an answer diverges β a capability
split, per-argument structure β the twin splits with it, in the same diff.
Gate the optimistic, hide the rest
A client-side check inside the verb is required only where the verb creates
local/optimistic state or drives a gesture (annotation paint, form fill,
page reorder, redaction marks, render tile fetches): there, a late engine 403
leaves a lying UI or a zombie record, so the verb self-refuses at the
capability entry β gestures go inert; imperative calls reject with the samePermissionDenied shape the engine throws. Pure-request verbs (search, text
read) need no client gate: hiding the affordance is the UX and the engine is
the enforcement β a duplicate check is drift surface.
Failed engine writes must still roll the optimistic state back. The gate makes
refusals rare (races, stale access); rollback keeps the model honest when they
happen anyway.
Per-record authority (the ownership extension)
Where records carry owners (annotations via the collab grammar β future
entities slot into the same grammar), the doc-level booleans are not enough.
The owning plugin projects this session's authority onto each record at
ingest (authority: { update, delete }, asked of the security mirrors with
the record's stamped owner β the same inputs the server checks) and fuses it
into the same predicate that already gates flags (annotTransformable).
Presentation and behavior then agree by construction: a record you may not
edit renders the bare outline locked already renders β no handles, no knob,
no drag β and other people's records under an annotations:update:self grant
are selectable and commentable but immovable, per record, with no special
code.
Authority is model-owned derived state (like apVersion), session-stable, and
re-stamped when security state changes (owner unlock, /access refresh).
Reactivity
Twins are pure reads, cheap enough to derive per call; UIs subscribe through
selectors (useSelector(Token, c => c.canFoo()) β boolean equality is free).
Twins with structural inputs flip on selection changes, not just security
changes: subscribe, don't cache. A security-state change must tick the store
so every twin's subscribers re-render.
Current twin registry (as built)
| Capability | Twins | Composes from |
|---|---|---|
| annotation | canRead() Β· canCreate() Β· canEdit(ref) Β· canDelete(ref) Β· canGroup() Β· canUngroup() |
doc.annotate.read / collab mirrors + flags + structure |
| comments | permissionsFor(ref) (convenience bag of twins) |
the annotation twins |
| selection | canSelect() Β· canCopy() |
doc.text.select / doc.text.copy |
| search | canSearch(mode?) |
doc.text.search; 'full' also doc.text.copy (a snippet reproduces text). No verb gate β pure request. |
| render | canRender() |
doc.render; gate case: renderPage and tile fetches refuse locally (else a denied viewport 403s per tile, forever) |
| page-edit | canEdit() (the collapse rule) Β· canExtract() |
doc.pages.assemble (rotate/move/delete/insert are one answer; PDF has one assemble bit); doc.download for extract and duplicate |
| metadata | canUpdate() |
doc.metadata.modify (the standard fields and your own) |
| outline | canEdit() |
doc.outline.modify (add, change, move and delete items; reading needs only doc.open) |
| form | canRead() Β· canFill() Β· canDesign() |
doc.forms.read (hydration gate) / doc.forms.fill (fused into FillItem.disabled + write/reset gates) / doc.forms.modify (place/update/delete/detach; the draw-to-place handler's gate) |
| redaction | canMark() Β· canApply() |
marks are annotations β canMark is annotation.canCreate(); apply mirrors all three engine assertions: doc.redact β§ doc.pages.modify β§ doc.annotate.modify (+ engine support) |
| kernel documents | canDownload(id?) Β· canPrint(id?) |
doc.download / doc.print: the verbs (download(), downloadLayer(), preparePrint(), print()) live on the kernel's documents capability, so their twins do too. canPrint also needs the host's printer (createKernel({ printer })); getPrintQuality(id?) reads doc.print.high (the PDF copy with doc.download or doc.download.flattened and a printer that prints PDFs; else 300 DPI pictures; 150 without it) |
| kernel documents | canOpenFiles() |
the engine's own fact, engine.canOpenFiles: the local engine opens a file's bytes (or a URL the kernel downloads); the cloud engine only the documents your server holds |
Conventions Plugins (`docs/conventions/plugins.md`)
Plugins
How to write a plugin: its files, its manifest, its controller and
contracts, how it declares and reaches its dependencies, how it owns what it
acquires, and how it fails. Where a plugin fits in the system:architecture.md. Its state and events:state-and-sync.md and events.md. How
it answers "may this session do that?": permissions.md.
Anatomy
Every plugin has the same layout, so any file can be found without searching:
packages/plugin/<name>/
src/
<name>.plugin.ts the manifest
token.ts the capability token
contract.ts the public lens
host-contract.ts optional: the host lens
internal.ts optional: framework-only helpers
index.ts the implementation entry
model.ts state, transitions, projections, folds
controller.ts create(ctx) β { api, connect? }
connect.ts optional: wiring to sibling plugins
services/ optional: plugin-private helpers shared by areas
read/ write/ sync/ tools/ optional: one area per file
test/ mirrors src/| File | Holds | Never holds |
|---|---|---|
<name>.plugin.ts |
definePlugin(...) inside the plugin factory |
logic, subscriptions, registrations |
token.ts |
the one createCapabilityToken call |
anything else |
contract.ts |
the capability interface, its public types, the token re-exported | implementation |
host-contract.ts |
the wider interface for siblings and adapters, and the same token widened with createHostToken |
implementation |
internal.ts |
helpers the framework adapters need and applications must not use | public API |
index.ts |
the plugin factory and export * from './contract' |
anything else |
model.ts |
the state type, initial<Name>State(), pure transitions, pure projections, folds |
I/O, engine calls, the context |
controller.ts |
create<Name>Controller(ctx, config): builds the capability |
sibling registrations |
connect.ts |
connect<Name>(ctx, api): registrations with siblings |
verbs, reads |
The manifest
// packages/plugin/metadata/src/metadata.plugin.ts
export const metadataPlugin = () =>
definePlugin({
id: 'metadata',
token: MetadataToken,
scope: 'document',
create: createMetadataController,
});definePlugin(definition) returns its argument, and reads the plugin's types
from it: the capability from token, the state from state, the settings fromsettings.defaults. A manifest passes no type arguments. create receives aPluginContext<State, Settings> and must return the capability; it is checked
against those types and never names them, so a controller that returns less
than the capability is a type error, not a narrower capability. A plugin
without state is stateless (void), and one without settings hasNoSettings: ctx.settings() throws there.
| Field | Meaning |
|---|---|
id |
Unique in the plugin list. Names the state slice and appears in errors. |
token |
The capability token this plugin provides. Omit for a plugin without a capability. |
scope |
'workspace' (the default: one instance) or 'document' (one instance per open document). |
requires |
Tokens that must be installed. Validated when the kernel is created. |
optional |
Tokens this plugin uses when they are installed. |
state |
() => State: the initial session state, built for every instance. Omit for a stateless plugin. |
settings |
{ defaults, registered }: the plugin's settings, what the app registered over the defaults. The kernel builds their store when it plans the plugin list; ctx.settings() reads it. See Settings and state. |
create |
(ctx) => { api, connect? }: builds one instance. |
inScope |
Workspace plugins that take a document: (api, documentId) => api, the capability as seen from inside that document's scope, where calls that leave out the document use it. The kernel builds it once per document. |
Configuration is an argument of the factory: its settings go into the
definition (see Settings and state), and anything else
is passed on to the controller through create:
// packages/plugin/form/src/form.plugin.ts
export const formPlugin = (config?: FormConfig) =>
definePlugin({
id: 'form',
token: FormToken,
scope: 'document',
requires: [InteractionToken],
optional: [AnnotationToken, ActionsToken],
state: initialFormState,
settings: { defaults: FORM_DEFAULTS, registered: config },
create: createFormController,
});A definition is an immutable recipe that two kernels may share. Nothing that
belongs to an instance (registries, caches, timers) lives in the manifest or
in module scope; it lives in the controller.
Tokens and contracts
The token. token.ts calls createCapabilityToken once for the package.
The hint is appended to the error a user sees when a plugin requires this
token and it is not installed, so the error carries its own fix.
// packages/plugin/form/src/token.ts
export const FormToken = createCapabilityToken<FormCapability>('form', {
hint: `add formPlugin() from '@embedpdf/plugin-form' to your plugins list`,
});The public lens. contract.ts declares the capability interface
(<Name>Capability) and every type it mentions, and re-exports the token.
Application code, other plugins and adapters depend on this file. Every member
gets TSDoc as described in comments.md: a one-sentence
summary, the PluginError codes it rejects with, and the events it fires.
The host lens. Some members exist only for sibling plugins and framework
adapters: registration seams, render feeds, sinks. They go into<Name>HostCapability extends <Name>Capability in host-contract.ts, typed
over the same runtime token:
// packages/plugin/form/src/host-contract.ts
export interface FormHostCapability extends FormCapability {
ensureLoaded(page: PageRef): Promise<void>;
notifyWidgetEvent(
field: FormFieldRef,
widget: AnnotationRef,
event: PdfAnnotationEventKind,
): void;
// β¦
}
export const FormToken = createHostToken<FormHostCapability>(PublicFormToken);createHostToken returns the same object typed wider, so both lenses resolve
the same instance. A plugin with a host lens registers the host token in its
manifest, so definePlugin reads the host capability from it.
Framework-only helpers. internal.ts holds what adapters need and
applications must not use. It is a visibility boundary, not a bundle
boundary.
Package entries and bundle boundaries
Every plugin package publishes deliberate dependency doors:
@embedpdf/plugin-<name>is the implementation opt-in: import it for<name>Plugin(), or from the framework feature entry that installs and
re-exports that plugin. The root re-exports the public contract.@embedpdf/plugin-<name>/contractis the public capability protocol: the
one runtime token, the types, and small protocol helpers. Its runtime
import graph must not reach the implementation (the manifest or the
controller).@embedpdf/plugin-<name>/contract/hostis optional. It exposes the host
lens over the same token object.- Named entries such as
/destination,/authoringor/scriptingexpose a
deliberate pure helper feature that is neither a contract nor the full
implementation.
The source rules are mechanical:
- Plugin source never imports another plugin's bare package root, not even
for types. Use/contract,/contract/host, or a named helper entry. - Framework source follows the same rule for sibling plugins. A framework
feature may import its own plugin's root only when that feature entry
re-exports the root and therefore opts users into the implementation. - Application and composition source may import a bare plugin root in a
module that imports that plugin's factory. Elsewhere, even type-only and
token imports use the contract entry. requiresandoptionaldescribe kernel relationships. They do not
create a JavaScript module boundary and do not replace contract imports.- The token is defined once. The root,
/contract,/contract/hostand/internalre-export or widen the same token;createCapabilityTokenis
never called twice.
pnpm check:plugin-boundaries checks these rules locally and in CI.
The controller
controller.ts exports create<Name>Controller(ctx, config?), the manifest'screate. It returns { api, connect? }:
apiis the capability. It is built once and never replaced.connectruns once, after this plugin and its dependencies are created (seearchitecture.md). The first loads
of the plugin's mirrors start right after it.
Inside create a controller creates its event sources, its mirrors and itsctx.state.onChange listener, and assembles the reads and verbs. It may
resolve siblings it needs for its reads and verbs. It does not register
anything with a sibling, and does not subscribe to sibling events or to
document events (a mirror subscribes by itself); that is connect's job.
A controller that grows past a few hundred lines splits into areas, one per
file under read/, write/, sync/ and tools/. Each area is a function
that receives the context and the services it needs, and returns its slice of
the capability with satisfies Partial<Capability>. composeApi joins the
slices and throws if two slices define the same member:
// packages/plugin/form/src/controller.ts
export function createFormController(ctx: FormContext) {
const services = createServices(ctx);
const { events } = services;
const fields = createFieldReads(ctx, services);
const widgets = createWidgetReads(ctx, services);
const values = createValueWrites(ctx, services);
// β¦
const api = composeApi('form', [
ctx.settings().api,
fields.api,
widgets.api,
values.api,
// β¦
{
canFill: () => ctx.allows('doc.forms.fill'),
onValueChanged: events.valueChanged.on,
// β¦
},
]) satisfies FormHostCapability;
return {
api,
connect: () => connectForm(ctx, api),
};
}services/ holds what several areas share, built once bycreateServices(ctx): the mirrors, the event sources, resolved siblings,
queues. A plugin that uses the context in many files names its type once:export type FormContext = PluginContext<FormState, FormSettings> inservices/context.ts.
`connect.ts`
Everything that registers with a sibling runs in connect: tools and
handlers on the interaction hub, behaviors on the annotation plugin, executors
and sinks on the actions plugin, subscriptions to sibling events, reactions
to document events, ctx.watch on state the plugin does not own. Every
registration's Unsubscribe goes to ctx.cleanup, even when both sides
close together.
// packages/plugin/link/src/connect.ts
export function connectLink(ctx: PluginContext<void>): void {
const annotation = ctx.tryGet(AnnotationToken);
if (!annotation) return;
const interaction = ctx.get(InteractionToken);
ctx.cleanup(
annotation.registerBehavior({
id: 'link-nav',
matches: (target) => target.subtype === 'link',
engaged: () => interaction.activeToolEnables('link-nav'),
}),
);
}Dependencies
| Member | Behavior |
|---|---|
ctx.get(token) |
The capability for this plugin's scope (this document, or the workspace). Throws when it cannot resolve. |
ctx.tryGet(token) |
The same, or null when the plugin is not installed or not available. |
ctx.forDocument(token, documentId) |
The capability for another document. For workspace plugins. |
ctx.tryForDocument(token, documentId) |
The same, or null. |
A plugin declares every token it resolves: required ones in
requires,
optional ones inoptional. Its own token andDocumentsTokenneed no
declaration. The host token of a plugin is the same object as its public
token, so declaring either covers both.In development (
NODE_ENVis notproduction), resolving an undeclared
token throws:text[kernel] plugin "x" resolved capability "y" without declaring it; add the token to its `requires` or `optional` list.The one exception is a plugin that resolves capabilities for code the host
supplies, such as the commands plugin running command definitions. It setsresolvesAnyCapability: truein its manifest; the kernel then allows any
token and neither orders nor validates those dependencies.createKernelvalidates the plugin list before building anything, and
rejects: the same plugin installed twice, two plugins with one id, two
plugins providing one token, a required token no plugin provides (with the
token'shint), a workspace plugin that requires a document-scoped token,
and dependency cycles.Plugins are created and connected in dependency order, so a required
sibling exists whencreateruns.A workspace plugin reaches document plugins with
ctx.forDocumentand
declares their tokens asoptional.A workspace capability resolved for a document (
ctx.getin a document
plugin,useCommands()inside a<DocumentScope>) is itsinScopeview:commands.execute(id)runs for that document,documents.download()
downloads it.
The document
ctx.docis the guarded engine handle of the plugin's document. Every call
through it rejectsinstance-closedonce the instance closed, is aborted
when the instance closes, and rejects withPluginErrorinstead of raw
engine errors. It is never null in a document plugin, so it is never
null-checked. Reading it in a workspace plugin throws.ctx.document()is the page registry (DocumentMeta: pages, revision,
render policy). A page argument is aPageRefor an index, looked up two
ways. Reads usectx.getPage(page), which returns the page ornull; verbs
usectx.pageOf(page), which returns the page or throwsnot-found.ctx.assertPageRef(ref)throwsnot-foundfor a foreign page.- A read given a page that no longer exists returns empty (
[],0ornull); a verb refuses it. Reads run while rendering, and a page deleted
mid-render must not break a layer: Search'slistHits({ page })andgetHitCount(page)answer[]and0for it, andsearch({ from })
rejectsnot-found. ctx.allows(permission)is what everycan*twin reads, andctx.assertAllowed(permission, operation)is how the verb refuses: it
throwspermission-deniedwitherror.permissionset. Both read the bound
document, so a workspace plugin asks thecan*twin of the document plugin
it acts through (ctx.forDocument(token, documentId)).- Engine values are in page space, the space plugins work in, so nothing
converts them.
Lifetime and async
Everything a plugin acquires is owned by its instance and released when the
instance closes (a document plugin) or the kernel is destroyed (a workspace
plugin).
| Member | Behavior |
|---|---|
ctx.cleanup(teardown) |
Run teardown at close. Asynchronous teardowns are awaited. Registering after the owner closed runs the teardown immediately. |
ctx.listen(source, listener) |
Subscribe to an EventHook or anything with subscribe for the instance's lifetime. The kernel owns the unsubscribe. |
ctx.acquire(get, dispose) |
get(lifetime) a resource and register dispose. A resource that arrives after close is disposed and the call rejects instance-closed. |
ctx.latest(key) |
A newest-wins lane: lane.run(async (run) => β¦, options). Starting a run aborts the previous one; a superseded run cannot publish (run.commit(fn) returns false) and rejects operation-cancelled. |
ctx.serialQueue(key?) |
A per-key queue: operations run one at a time, in submission order, and a failure does not affect later operations. queue.idle() resolves once everything queued so far has finished. queue(operation, { signal }) skips an operation whose signal fired before it started. |
ctx.onSettle(flush) |
Run flush before the document's file is read, and wait for it: send what the plugin holds back from the engine. Document plugins only. See state-and-sync.md. |
ctx.aroundDownload(wrap) |
Run something of the document's own around every read of its file: wrap(read) returns the bytes read() gives, doing what comes before and after (the actions plugin's save actions). Document plugins only; the first registered is outermost. |
ctx.aroundPrint(wrap) |
Run something of the document's own around every print dialog: wrap(print) calls print(), doing what comes before and after (the actions plugin's print actions). A print that starts inside the wrap (a document script's print()) is started, never awaited: awaiting it would wait for itself. Document plugins only; the first registered is outermost. |
ctx.cancellable(signal, task) |
Run an engine call the caller can cancel: it is aborted when signal fires, and rejects operation-cancelled. Every async verb passes its options.signal through it. |
ctx.events.source<T>() |
An event source, disposed at close. |
ctx.clock |
Time, from the host: after(ms, run) and nextFrame(run) return their cancel; hasFrames is false where nothing paints (Node, most tests). What the instance scheduled is cancelled at close. |
- Queues come from
ctx.serialQueue(key), never from a localcreateSerialQueue(). - Time comes from
ctx.clock, never from the environment: nosetTimeout,setIntervalorrequestAnimationFramein a plugin or a*-corepackage
(lint enforces it). The host decides what a timer and a frame are when it
builds the kernel (createKernel({ clock })): the framework viewers passbrowserClock()from@embedpdf/web, and without one the kernel usestimerClock, timers and no frames. A helper an app calls with no context,
such aspersistStampLibraries, coalesces with promise steps instead of a
timer. - An operation on queue A may enqueue into queue B and await it, but never
the other way round on the same pair: that deadlocks. - The search plugin runs every search on
ctx.latest('session'), so a new
query cancels the previous scan and a stale scan can never write its hits.
Settings and state
Every plugin with settings declares them in its definition, and declares the
state a UI shows once, next to its contract.
// search.plugin.ts: the settings, what the app registered over the defaults.
// SearchConfig is DeepPartial<SearchSettings>. No type arguments: the settings
// type comes from SEARCH_DEFAULTS, the state from initialSearchState, the
// capability from SearchToken.
export const searchPlugin = (config?: SearchConfig) =>
definePlugin({
id: 'search',
token: SearchToken,
// β¦
state: initialSearchState,
settings: { defaults: SEARCH_DEFAULTS, registered: config },
create: createSearchController,
});
// controller.ts: the context type is the controller's parameter, and the
// definition is checked against it. The capability spreads the settings calls;
// the controller reads get() where it uses a setting, so a change is live.
export function createSearchController(
ctx: PluginContext<SearchState, SearchSettings>,
) {
const settings = ctx.settings();
// β¦
const arrival = { ...settings.get().reveal, ...options };
// β¦
return {
api: {
...settings.api /* getSettings, updateSettings, resetSettings, onSettingsChanged */,
search,
},
};
}
// state.ts: the page's State table, as code
export const searchState = defineState(SearchToken, {
read: (search) => ({
hitCount: search.getHitCount(),
status: search.getStatus(),
}),
empty: { hitCount: 0, status: 'idle' }, // with no document
});- There is one settings store per plugin registration. The kernel builds it
when it plans the plugin list, so the settings exist before any document
opens:kernel.settingsOf(token)reads and changes them with no document,
and an app may set them before opening one. Every open document shares the
store, soupdateSettings()reaches them all, and the ones opened later. - Nested objects merge, arrays and values replace,
undefinedis ignored, andresetSettings()goes back to what the app registered.onSettingsChanged
carries{ settings, changed },changednaming the top-level settings
whose value changed. - A controller reads
settings.get()where it uses a setting, never a copy
made increate, so a change applies at once. The Stage keeps its own
per-view settings. - A controller names its context type in its parameter,
PluginContext<State, Settings>, and the definition must match it: a
definition that forgetssettingswhile the controller reads them is a
type error, and so isctx.settings()in a plugin that declares none. - Every value in the State table is state, and its event is a state-change
event derived in the plugin's onectx.state.onChangelistener, even when
it reads like progress: Search'sonProgressChangedfires whenprogress
changes, including its reset when a new search starts
(events.md). - An adapter turns a state declaration into its framework's form with one
generic function, and a plugin's settings with another (React:export const useSearchState = stateHook(searchState)andexport const useSearchSettings = settingsHook(SearchToken)). Getters inreadreturn the same value while nothing changed, so readers re-render
only when a field does. The settings hook reads through the kernel, so it
works without a document, and so do the settings calls ofuseSearch()'s
stand-in. - A color setting whose default follows the accent defaults to
nullin the
plugin's defaults (selection'scolor,handles.color): the adapter paints
it withpaint()and, for a translucent one,mixAccent()from@embedpdf/web, so--epdf-accentand the viewer'saccentreach it. A
color with its own default (Search'shighlight.color) is a plain value. - The docs' Settings and State tables are checked against these declarations.
Errors
A capability rejects (or throws) with PluginError only:
new PluginError(code, capability, message, { cause?, details?, permission? });| Code | Meaning |
|---|---|
permission-denied |
The session may not do this. error.permission names the missing permission. |
unsupported |
The engine or document cannot do this. |
not-found |
A ref names nothing in this document. |
not-ready |
The resource is not loaded or the document not open yet. |
invalid-input |
The arguments are wrong. |
conflict |
A concurrent change or a version conflict. |
instance-closed |
The instance closed before the call finished. |
operation-cancelled |
The caller cancelled, or a newer call superseded this one. |
operation-failed |
Anything else. |
- Calls through
ctx.docalready reject withPluginError: the guarded
handle maps engine errors withtoPluginError(capability, error).
Cancellation becomesoperation-cancelled, neveroperation-failed. - Code that catches something else converts it with
toPluginError. - State and event payloads carry the serializable summary
toPluginErrorInfo(error), never the error object. - Callers match on
code(isPluginError(error, 'conflict')), never on the
message. - A verb that can refuse has a
can*twin readingctx.allows(permission),
and refuses withctx.assertAllowed(permission, operation), never a
hand-builtPluginError(permissions.md). - A refusal before anything started only rejects: no state changes and no
event fires, not even the verb's failure event. Search refuses a search
withoutdoc.text.search, or from a page that isn't there, before its
session is touched, sogetStatus()is what it was andonFailedstays
quiet.onFailedis for work that started and then failed. - A best-effort batch verb resolves with
BatchResult<T, R>
(applied,skipped,failed) instead of rejecting on the first failure.
A minimal complete plugin
A document-scoped plugin that mirrors the document title and remembers
whether a title editor is open. It shows a mirror with a fold, fact events
with their origin, onResynced, session state with transitions and a derived
state-change event, a verb with a permission gate, and the manifest.
// token.ts
import { createCapabilityToken } from '@embedpdf/core';
import type { TitleCapability } from './contract';
export const TitleToken = createCapabilityToken<TitleCapability>('title', {
hint: "add titlePlugin() from '@embedpdf/plugin-title' to your plugins list",
});/* Detailed source-code truncated for AI context efficiency. */// model.ts
import type { DocumentEvent } from '@embedpdf/core';
/** The session state: whether the title editor is open. */
export interface TitleState {
readonly editorOpen: boolean;
}
export const initialTitleState = (): TitleState => ({ editorOpen: false });
export const openEditor = (state: TitleState): TitleState =>
state.editorOpen ? state : { ...state, editorOpen: true };
export const closeEditor = (state: TitleState): TitleState =>
state.editorOpen ? { ...state, editorOpen: false } : state;
/** Apply one confirmed event to the mirrored title. The same for every origin. */
export const foldTitle = (
title: string | null,
event: DocumentEvent,
): string | null =>
event.type === 'metadata.updated' ? event.metadata.title : title;/* Detailed source-code truncated for AI context efficiency. */// title.plugin.ts
import { definePlugin } from '@embedpdf/core';
import { TitleToken } from './contract';
import { createTitleController } from './controller';
import { initialTitleState } from './model';
/** The document title, mirrored from the engine, and whether its editor is open. */
export const titlePlugin = () =>
definePlugin({
id: 'title',
token: TitleToken,
scope: 'document',
state: initialTitleState,
create: createTitleController,
});// index.ts
export { titlePlugin } from './title.plugin';
export * from './contract';Its tests are in testing.md.
Checklist
- The manifest only calls
definePlugin, with no type arguments; settings
are declared in it (settings: { defaults, registered: config }), and other
configuration reaches the controller throughcreate. - One
createCapabilityTokencall, intoken.ts; a host lens usescreateHostToken. - Every token the plugin resolves is in
requiresoroptional, and is
imported from a/contractentry. - Session state changes only through transitions in
model.ts. - Engine data lives in a mirror or page mirror; verbs never write it.
- Events come from
ctx.events.source(); fact events from where the
confirmed event is applied, state-change events fromctx.state.onChange.
A value in the State table is announced only from there. - A getter given a page that isn't in the document returns empty; a verb
refuses it withnot-found(ctx.getPagefor reads,ctx.pageOffor
verbs). - A refusal before the work starts only rejects: no state change, no event.
- Sibling registrations and subscriptions are in
connect, and everyUnsubscribegoes toctx.cleanup. - Every rejection is a
PluginError, and the contract's TSDoc lists its codes. - Tests under
test/mirrorsrc/(seetesting.md).
Conventions Runtime Confinement (`docs/conventions/runtime-confinement.md`)
Thread-confined PDFium runtime
This server can render PDFs on N worker threads in a single process, in
parallel, with zero-copy postMessage transfer. This is not "thread-safe
PDFium" (upstream is explicit that its APIs are not thread-safe). Instead the
native runtime is built so that each worker thread owns its own PDFium state
and never shares PDFium handles across threads β a thread-confined (shared-
nothing) contract under which N workers run safely in parallel.
The contract
- Each worker thread calls
EPDF_InitThreadbefore using PDFium andEPDF_ShutdownThreadafter closing every handle it created
(wired inpackages/engine/services/src/runtime/lifecycle/bootstrap.ts). - No document, page, bitmap, annotation, text page, content parser, or font
object may cross workers. - The pool enforces this: a doc is pinned to one worker via sticky-by-docId
routing incloudpdf/server/src/runtime/WorkerThreadPool.ts.
Two things are private-per-thread, which together make parallel rendering safe:
the handles we create (pinned per worker) and PDFium's internal globals
(made thread_local in the native build).
How it's built (per-target opt-in)
Per-thread globals are gated behind a real GN arg, default off:
- Declared in
packages/engine/runtime/runtime-src/pdfium.gni
(embedpdf_thread_local_globals = false). - Translated to the
EPDF_THREAD_LOCAL_GLOBALSdefine inpackages/engine/runtime/runtime-src/BUILD.gn, consumed by theEPDF_TLSmacro
(core/fxcrt/epdf_tls.h). - Turned on per native target (and left off for wasm) by
packages/engine/runtime/runtime-src/scripts/embedpdf-runtime/build-target.sh.
wasm needs nothing β each instance already isolates globals via its own
linear memory.
Flag-on is a superset: the build still runs correctly single-threaded. Default
off keeps every other target and upstream rebases byte-for-byte unchanged.
Configuring the worker pool
Worker count resolves as: explicit size option β CLOUDPDF_WORKER_POOL_SIZE
env β conservative default min(2, cpus).
# one worker per CPU (after the gate below is green for your native build)
CLOUDPDF_WORKER_POOL_SIZE=max # or an explicit integer, e.g. 8Keep the conservative default until you have validated the thread-confined
native build with the gate below.
The gate (run before scaling in production)
Fix-then-verify order β the two known addon/runtime data races are already fixed
(g_napi_lossless made a per-call local; default localtime made thread-safe),
so a TSAN run surfaces PDFium findings rather than masking them.
Threaded soak (
testing/tools:epdf_thread_soak) β multi-thread
init/open/render/encrypted-save/close. The encrypted-save path exercises
the now doc-owned pending-security state across threads.bash# plain build, native target (defaults EMBEDPDF_TLS_GLOBALS=true) packages/engine/runtime/runtime-src/scripts/embedpdf-runtime/thread-soak-target.sh \ darwin-arm64 path/to/icc-and-font-heavy.pdf -- --threads=8 --iterations=200 # under ThreadSanitizer (the gate) EMBEDPDF_TSAN=1 \ packages/engine/runtime/runtime-src/scripts/embedpdf-runtime/thread-soak-target.sh \ linux-x64 path/to/icc-and-font-heavy.pdf -- --threads=8 --iterations=200Use ICC-heavy PDFs to cover the LCMS default-context path (see the gate note
inpackages/engine/runtime/runtime-src/core/fxcodec/icc/icc_transform.cpp).Single-thread regression β run the embeddertests to confirm the
thread_localvariant is still correct single-threaded:bashpackages/engine/runtime/runtime-src/scripts/embedpdf-runtime/test-target.sh linux-x64 # baseline (process-global) comparison: EMBEDPDF_TLS_GLOBALS=false \ packages/engine/runtime/runtime-src/scripts/embedpdf-runtime/test-target.sh linux-x64
Only after TSAN + soak are green should you raise the pool size in production.
Load-test methodology
Compare throughput/latency across three configurations on representative
(ICC-heavy, font-heavy, encrypted-save) corpora:
CLOUDPDF_WORKER_POOL_SIZE=1β single worker baseline.CLOUDPDF_WORKER_POOL_SIZE=maxβ one thread-confined worker per CPU.- A process-pool deployment (multiple single-thread pods/processes) β the
crash-isolated comparison point.
Track p50/p95/p99 latency, sustained throughput, and RSS (per-thread memory
matters β see below).
Tradeoffs
- Per-thread memory: each thread duplicates the GEModule (FreeType + font
caches), CMaps, stock colorspaces, and the opcode table. N threads β NΓ that
font/cmap footprint. Size the pool (and pod memory) accordingly. - Thread confinement is not crash isolation: a malformed-PDF segfault kills
the process that owns the worker threads. Inline mode therefore loses the API
process and all workers. Host mode moves the worker pool into supervised child
processes, but all workers within one host still share its crash domain. Seeengine-host-isolation.md. - Keep
pdf_use_partition_alloc=false(or makeg_allocatorsthread_local if
it is ever re-enabled).
Conventions Server Adapters (`docs/conventions/server-adapters.md`)
Server Adapter House Style
The server has five adapter families: Secrets, KMS, Storage, CDN, Import (read-only pull sources for documents.importFrom). They differ in what they do but share how they're structured. This document is the contract a new adapter must follow.
Unless stated otherwise, adapter source paths below are relative tocloudpdf/server/src/; test paths are relative to cloudpdf/server/.
File layout
Every family lives at the same shape:
<family>/
βββ <Family>.ts interface (role-noun, includes `info`)
βββ create<Family>.ts factory function (switch on kind)
βββ config/
β βββ <Family>ConfigSchema.ts Zod discriminated union over `kind`
β βββ load<Family>ConfigFromEnv.ts
βββ adapters/
βββ <Kind1><Family>.ts one file per provider
βββ <Kind2><Family>.tsExamples: cloudpdf/server/src/security/kms/adapters/AwsKmsKeyring.ts,cloudpdf/server/src/storage/adapters/S3ObjectStore.ts, andcloudpdf/server/src/cdn/adapters/BunnyCdnSigner.ts.
Interface convention
interface <Family> {
readonly info: { kind: <FamilyKind>; ...public_diagnostic_fields };
// family-specific methods
}Rules:
kindis the discriminator and matches the config Zod schema.- Other
infofields are public identifiers only β bucket names, distribution domains, key IDs, hostnames. Never secrets. - Decorators (caching wrappers, etc.) add fields rather than mutating
kind. Example:info: { kind: 'aws-sm', cached: true }, notkind: 'aws-sm+cache'.
Lazy-load house pattern for cloud-SDK adapters
Every cloud-SDK adapter follows this exact shape. The pattern is non-negotiable so installs don't need every cloud SDK and so server boot stays fast.
Canonical form
import type { /* type-only imports for SDK shapes */ } from '@scope/package';
// NB: type-only imports don't trigger the lazy load β only `await import(...)` does.
type Module = typeof import('@scope/package');
type Client = InstanceType<Module['Client']>;
export class XxxAdapter implements YyyInterface {
readonly info = { kind: 'xxx' as const, /* public diagnostics */ };
// Promise-typed field β stored, not awaited at construction
private readonly clientPromise: Promise<{ client: Client; /* commands */ }>;
constructor(private readonly opts: XxxConfig) {
this.clientPromise = this.createClient();
}
private async createClient(): Promise<{ client: Client; /* commands */ }> {
const mod = (await import('@scope/package')) as Module;
return {
client: new mod.Client(/* ... */),
// Capture command constructors here too β the SDK uses
// command objects you instantiate per call.
};
}
async someOperation(...) {
const { client, /* commands */ } = await this.clientPromise;
return client.send(new commands.SomeCommand(/* ... */));
}
}Why this exact shape
| Decision | Why |
|---|---|
await import('@scope/package') in a private async method |
Dynamic import doesn't run at module load. The SDK package never resolves unless an instance is constructed. |
clientPromise stored at construction (this.clientPromise = this.createClient()) |
Single shared promise. First operation awaits it; subsequent operations reuse the same resolved client. No reconnection storms. |
| Stored, not awaited at construction | Constructor stays synchronous. new XxxAdapter(opts) returns immediately. The connection happens on first use. |
| Command constructors returned alongside the client | AWS SDK v3 / GCP / Azure use command objects. Capturing them in createClient avoids per-call await import calls. |
| Type-only imports for SDK shapes | TypeScript can infer types from the SDK without forcing a runtime require. Pair import type with typeof import(...). |
Azure variant β parallel imports
Azure adapters typically need two packages (@azure/identity for auth + a service SDK). Parallelize the imports:
private async createClient(): Promise<Client> {
const [identity, keys] = await Promise.all([
import('@azure/identity') as Promise<typeof import('@azure/identity')>,
import('@azure/keyvault-keys') as Promise<typeof import('@azure/keyvault-keys')>,
]);
return new keys.CryptographyClient(this.keyId, new identity.DefaultAzureCredential());
}`optionalDependencies`
Every cloud-SDK package must be listed in cloudpdf/server/package.json
under optionalDependencies, never dependencies. This way:
npm installdoesn't pull SDKs the user isn't using.- Errors at adapter construction time are clear: "Cannot find module '@scope/package' β install it to use the X adapter."
- CI can install with
--no-optionalto verify the core server works without any cloud SDK.
Current cloud-SDK adapters using the pattern (all match exactly):
| Adapter | SDK package |
|---|---|
S3ObjectStore |
@aws-sdk/client-s3 + s3-request-presigner |
AwsKmsKeyring |
@aws-sdk/client-kms |
GcpKmsKeyring |
@google-cloud/kms |
AzureKeyVaultKeyring |
@azure/identity + @azure/keyvault-keys |
AwsSecretsManagerProvider |
@aws-sdk/client-secrets-manager |
GcpSecretManagerProvider |
@google-cloud/secret-manager |
AzureKeyVaultSecretsProvider |
@azure/identity + @azure/keyvault-secrets |
GcsObjectStore |
@google-cloud/storage |
AzureBlobObjectStore |
@azure/storage-blob + @azure/identity |
Any new cloud-SDK adapter (CDN CloudFront / CloudCDN / AzureFD purge) follows the same shape.
Config schema convention
<family>/config/<Family>ConfigSchema.ts:
export const <Family>ConfigSchema = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('a'), ...fields }),
z.object({ kind: z.literal('b'), ...fields }),
]);
export type <Family>Config = z.infer<typeof <Family>ConfigSchema>;Fields that may hold secrets: z.union([SecretRefSchema, z.string()]). The string form is for local-dev convenience (literal value); the SecretRef form is for production. The factory's resolver injection (see below) handles both.
Factory convention
<family>/create<Family>.ts:
export async function create<Family>(
config: <Family>Config,
opts: { resolver?: SecretResolver } = {},
): Promise<<Family>> {
switch (config.kind) {
case 'a': return new A<Family>(config, opts);
case 'b': return new B<Family>(config, opts);
// exhaustive β TypeScript enforces no missing cases
}
}The { resolver } opt is the shared mechanism for resolving SecretRef fields in config. Each adapter that needs it pulls the resolver out of opts and resolves at construction (or lazily, depending on the adapter).
Env-var loader convention
<family>/config/load<Family>ConfigFromEnv.ts:
export function load<Family>ConfigFromEnv(env: NodeJS.ProcessEnv): <Family>Config {
const kind = env['CLOUDPDF_<FAMILY>_KIND'] ?? '<default>';
const raw = { kind, ...kindSpecificFields(env, kind) };
return <Family>ConfigSchema.parse(raw); // throws with helpful message
}Env-var conventions:
CLOUDPDF_<FAMILY>_KINDβ selectorCLOUDPDF_<FAMILY>_<KIND>_<FIELD>β variant-specific fields- Secret values accept the
secret:///<name>?jsonKey=...&encoding=...URI form (parsed toSecretRef) or plain strings (used literally)
Redaction
When a config is logged or surfaced through an admin endpoint, run it through redactConfig from config/secrets/redact.ts:
import { redactConfig } from '../../config/secrets/redact';
logger.info({ config: redactConfig(myConfig) }, 'starting');SecretRef-shaped values become<SecretRef provider/name>- Caller can pass
additionalSensitiveKeysto redact literal-string secret values by field name
A test (cloudpdf/server/test/config-redact.test.ts) pins that secret material
never leaks through JSON.stringify of a redacted config.
The bootstrap recipe
In cloudpdf/server/src/bin/cloudpdf-server.ts:
// 1. Secrets β primary user-facing utility.
// Registry takes the full SecretsConfig and applies
// CachingSecretsProvider automatically when config.cache is set.
const secrets = createSecretsProviderRegistry(loadSecretsConfigFromEnv(env));
const resolver = createSecretResolver(secrets);
// 2. Each family loads its config, takes the resolver
const [kms, objectStore, cdnSigner] = await Promise.all([
createKmsKeyring(loadKmsConfigFromEnv(env), { resolver }),
createObjectStore(loadObjectStoreConfigFromEnv(env), { resolver }),
createCdnSigner(loadCdnConfigFromEnv(env), { resolver }),
]);
// 3. buildApp β Secrets is not here, stays in scope for user code
await buildApp({ kms, objectStore, cdnSigner /* ... */ });No createServerRuntime or other bundler. Five lines of factory composition is the standard, and it keeps secrets and resolver first-class for user code that needs to fetch its own secrets (JWT signing keys, DB credentials, third-party integration tokens).
Caching control
createSecretsProviderRegistry(config) applies CachingSecretsProvider to every provider when config.cache is present. Three tiers of control:
| Goal | How |
|---|---|
| Default deployment (caching on) | config.cache: { ttlSec: 3600 } β loadSecretsConfigFromEnv sets this by default |
| Custom TTL | config.cache: { ttlSec: 300 } (or CLOUDPDF_SECRETS_CACHE_TTL_SEC=300) |
| No caching | Omit cache from the programmatic config; or CLOUDPDF_SECRETS_CACHE_TTL_SEC=0 |
| Per-provider caching, custom decorators, mocked providers | Skip the registry helper β build providers manually via createSecretsProvider, compose into a Map<string, SecretsProvider>, pass to createSecretResolver |
Adding a new adapter β checklist
- Interface β already exists for the family; you don't touch it.
- Zod schema variant β add one entry to
<family>/config/<Family>ConfigSchema.ts's discriminated union. - Adapter class β one file in
<family>/adapters/<Kind><Family>.ts. Implements the interface; exposesinfo: { kind, ... }; uses the lazy-load house pattern if it wraps a cloud SDK. - Factory case β one new
casein<family>/create<Family>.ts's switch. TypeScript fails the build if you forget. - Env loader case β one new branch in
<family>/config/load<Family>ConfigFromEnv.ts's kind-specific field reader. - Optional dependency β if the adapter wraps a cloud SDK, add the package to
cloudpdf/server/package.json'soptionalDependencies. - Test β one file at
cloudpdf/server/test/<family>-<kind>.test.tscovering construction, key operations, and thatinfoexposes only public identifiers.
That's the entire surface. If a step doesn't apply (e.g., no cloud SDK β skip step 6), skip it explicitly in the PR description.
Conventions Server Migrations (`docs/conventions/server-migrations.md`)
Database migrations & rollback
@cloudpdf/server ships its schema as an ordered set of SQL migrations
(NNN_name.sql) for both SQLite and Postgres, applied by the migrator insrc/db/migrator/runner.ts. Each applied
migration is recorded in schema_migrations with a SHA-256 checksum of
its up SQL, so editing an already-applied migration is detected as
drift and refused at boot.
Every migration also ships an inverse NNN_name.down.sql. Forward
(up) is routine; backward (down) is a manual break-glass tool.
Commands
cloudpdf-server migrate status # applied / pending / drift
cloudpdf-server migrate up # apply pending (forward)
cloudpdf-server migrate up --dry-run # preview forward plan
cloudpdf-server migrate down --dry-run # preview rollback plan
cloudpdf-server migrate down --steps 1 --yes # roll back the newest migration
cloudpdf-server migrate down --to 011 --yes # roll back everything newer than 011
cloudpdf-server migrate down --all --yes # roll back the entire history
cloudpdf-server migrate validate [--strict] # CI / boot drift gatemigrate down selection:
--steps Nβ roll back theNhighest applied migrations (default 1).--to NNNβ roll back every applied migration newer thanNNN,
leavingNNNand everything below it applied.--allβ roll back everything (down to an empty schema; the
runner-ownedschema_migrationstable is preserved).
Safety:
migrate downis destructive, so a real run requires--yes
(containers are non-interactive). Use--dry-runto print the exact
versions and order first.- The whole revert set is validated before any DB change: each
target must exist in code, its stored up-checksum must still match the
code (otherwise pass--force), and it must declare non-emptydown
SQL. If any target is irreversible the command aborts without touching
the database. downrestores structure, not data. Re-creating a dropped column
or table cannot bring back the rows/values that were in it. This is the
standard rollback caveat and is harmless pre-launch.
Rollback runbook (Docker / Helm)
Rollback is operator-initiated and runs as a one-shot, not via a Helm
hook:
# Preview first.
docker run --rm \
-e CLOUDPDF_DB_DRIVER=postgres \
-e CLOUDPDF_DB_URL=postgres://user:pass@host:5432/cloudpdf \
ghcr.io/<org>/cloudpdf-server:<tag> \
migrate down --to 011 --dry-run
# Then execute.
docker run --rm \
-e CLOUDPDF_DB_DRIVER=postgres \
-e CLOUDPDF_DB_URL=postgres://user:pass@host:5432/cloudpdf \
ghcr.io/<org>/cloudpdf-server:<tag> \
migrate down --to 011 --yesOrdering rule: roll the application image back to a version that is
compatible with the target schema first, then run migrate down.
Going down before the app is compatible can break live requests against
the older schema.
Prefer backward-compatible expand/contract migrations (add the new
column/table, deploy code that tolerates both shapes, only later drop the
old shape) so down stays a rare break-glass action rather than part of
the routine deploy.
What never rolls back automatically
- Auto-migrate on boot (
CLOUDPDF_AUTO_MIGRATE, default on for SQLite,
off for Postgres) only ever applies forward migrations. buildAppvalidates for drift and can refuse to start
(CLOUDPDF_FAIL_ON_PENDING=1) but never rolls anything back.- There is no Helm pre-upgrade/rollback hook that runs
migrate down.
Down is exclusively the operator-run command above.
Adding a migration
- Add
NNN_name.sqlto bothcloudpdf/server/src/db/migrations/sqlite/andcloudpdf/server/src/db/migrations/postgres/. - Add the matching
NNN_name.down.sqlto both dialects (reverse the up
in child-before-parent FK order). - Register both in the dialect's
index.ts
({ version, name, sql, down }). - Run
pnpm --filter @cloudpdf/server testβ the round-trip test
(cloudpdf/server/test/migrator-down.test.ts) assertsup β down --all β upworks on
both dialects.
SQLite `DROP COLUMN` caveat
SQLite refuses ALTER TABLE ... DROP COLUMN for a column referenced by aCHECK constraint (or index/PK/FK/generated expression). When a down
needs to remove such a column, rebuild the table instead (seecloudpdf/server/src/db/migrations/sqlite/007_document_security.down.sql):
mark the file-- pragma: no-transaction, PRAGMA foreign_keys=OFF, create the
table's prior shape, copy, drop, rename, recreate indexes,PRAGMA foreign_keys=ON. Postgres has no such limitation and drops the
columns directly.
Conventions State And Sync (`docs/conventions/state-and-sync.md`)
State and sync
Where a plugin keeps each piece of state, how that state changes, and how a
plugin's copy of engine data stays equal to what the engine confirmed. The
kinds of state are introduced in architecture.md; every
API below is a member of the PluginContext a plugin's create(ctx) receives
(packages/core/main/src/types.ts).
| Kind | API |
|---|---|
| Session | ctx.state (get, update, onChange) |
| Mirror | ctx.mirror(spec), ctx.pageMirror(spec) |
| Pending changes | ctx.changes (stage, hold, group), a mirror's predict and view() |
| Resource | closures, ctx.notify(), ctx.cleanup, ctx.acquire |
Session state: `ctx.state`
interface StateCell<S> {
get(): S;
update<Args extends unknown[]>(
transition: (state: S, ...args: Args) => S,
...args: Args
): void;
readonly onChange: EventHook<StateChange<S>>; // { previous, next }
}- The manifest's
state: () => Sbuilds the initial value, fresh for every
instance. A plugin without session state omits it and its context isPluginContext<void>. - The value is replaced, never mutated. It is plain data (see the rules in
architecture.md). - It changes only through
update(transition, ...args). A transition is a
named, exported, pure function inmodel.ts:(state, ...args) => state.
Its name says what happens; there are no action objects or reducers. - A transition that returns the same object is a no-op: no store
notification and noonChange. Write transitions so they returnstate
itself when nothing changes. onChangefires synchronously, beforeupdatereturns, with the previous
and next value. It is where a plugin derives its state-change events (seeevents.md).- After the instance closed,
updatedrops the write and reportsinstance-closedonce.
From packages/plugin/shell:
// model.ts
export function openMenu(state: ShellState, id: string): ShellState {
if (state.openMenus.includes(id)) return state;
return { ...state, openMenus: [...state.openMenus, id] };
}
export function closeMenu(state: ShellState, id: string): ShellState {
if (!state.openMenus.includes(id)) return state;
return { ...state, openMenus: state.openMenus.filter((menu) => menu !== id) };
}
// controller.ts
openMenu: (id) => ctx.state.update(openMenu, id),
closeMenu: (id) => ctx.state.update(closeMenu, id),`ctx.notify()`
Wakes every subscriber of the store's change stream without changing state.
Call it when a resource changed and reads through the capability now answer
differently: a registry gained an entry, a raster arrived, a policy changed.
It is a no-op once the instance closed.
// packages/plugin/commands/src/controller.ts: definitions hold functions,
// so the registry is a closure map; every change wakes the readers.
ctx.notify();`ctx.watch(select, handler, isEqual?)`
Runs handler(value, previous) whenever select() answers differently after a
store change (Object.is unless isEqual is given). It never runs for the
initial value. Use it to react to state this plugin does not own: the page
registry, a sibling's getter. A plugin observes its own state withctx.state.onChange instead. The subscription ends when the instance closes;
the returned Unsubscribe ends it earlier.
// packages/plugin/stage/src/connect.ts
ctx.watch(
() => ctx.document()?.revision ?? 0,
() => stage.refit(),
);`ctx.waitFor(predicate, options?)`
Resolves once predicate() holds, checked immediately and after every store
change. Rejects with operation-cancelled when options.signal aborts, withinstance-closed when the instance closes, and with the predicate's own error
if it throws. A verb never uses it to see its own engine write (see
the rules).
Mirrors: `ctx.mirror(spec)`
A mirror is a local copy of document-wide data the engine owns. It changes in
two ways only: a load (the whole value, or some pages of it) and a fold (one
confirmed event applied by a pure function). Mirrors exist in
document-scoped plugins only.
| Spec member | Meaning |
|---|---|
name |
Unique within the plugin; names the store cell and appears in errors. |
initial() |
The value before the first load lands. |
readable?() |
Asked before every load. false: status forbidden, no engine read. |
load(doc, signal) |
Read the whole value: { value, cursor? }. cursor is the newest server event the snapshot already contains (cloud engines); omit it otherwise. |
fold(value, event) |
Apply one confirmed event. Pure, the same for every origin. Returns value itself when the event does not apply, or reload(...) when the event does not carry enough to apply. |
predict?(value, op) |
Apply one op of this session's pending changes, as the engine will. Pure and absolute (see Pending changes). Returns value itself for an op it doesn't concern. |
loadPages?(doc, pages, signal) |
Read some pages and return (value) => value merging them in. Needed when fold returns reload({ pages }). |
changed?(change) |
Runs after every load (even one that changed nothing) and after every event that changed the value. The one place the plugin emits the events this data drives. |
The metadata plugin is a complete mirror plugin in one file
(packages/plugin/metadata/src/controller.ts):
const metadata = ctx.mirror<DocumentMetadata | null>({
name: 'metadata',
initial: () => null,
load: async (doc) => ({ value: await doc.metadata.get() }),
fold: (value, event) =>
event.type === 'metadata.updated' ? event.metadata : value,
changed: ({ cause, event, previous, next }) => {
if (!next) return;
if (cause === 'load') {
resynced.emit({ metadata: next });
} else if (event && 'origin' in event) {
updated.emit({
metadata: next,
previous,
changedKeys: changedKeys(previous, next),
origin: event.origin,
});
}
},
});A fold with several cases lives in model.ts as a named function, so it can
be tested without a kernel (packages/plugin/form/src/model.ts):
export function foldFormEvent(
index: FieldIndex,
event: DocumentEvent,
): FieldIndex | MirrorReload {
switch (event.type) {
case 'forms.valueSet':
case 'forms.updated':
case 'forms.widgetAdded':
case 'forms.widgetRemoved':
return upsertFields(index, [event.field]);
case 'forms.deleted':
return removeField(index, event.deletedFieldObjectNumber);
case 'forms.imported':
return indexFields(event.snapshot);
case 'forms.repaired':
return reload();
// β¦
default:
return index;
}
}The mirror's life
- Subscribe first. The mirror subscribes to
ctx.doc.eventswhen it is
created, insidecreate. - Load after connect. The first load starts right after the plugin's
connecthas run, so capabilities thatreadableorloaduse exist.
A spec withonDemand: truewaits instead: the mirror staysidle,
folding confirmed events, untilensureLoaded()orrefresh()asks for
its first load, for data nothing needs when a document opens. A resync or a
fold'sreload()before then reads nothing; the first load reads it all. - Queue during a load. Events that arrive while a full load runs are
queued. When the load lands, the queued events are replayed in arrival
order, skipping those the snapshot already contains
(origin.serverId <= cursor). Replaying confirmed records in order is
idempotent, so engines without a cursor replay everything. - Degrade on failure. A failed load keeps the previous value, applies the
queued events to it, and reportsforbidden(apermission-denied
rejection) orerrorthroughgetStatus(). The mirror stays live. A
failed page reload does the same: the value is stale for those pages, and
says so, until a full load succeeds. - Resync.
stream.desyncedanddocument.versionedstart a full reload.foldnever sees them. - Throwing folds. A
foldthat throws is reported and starts a full
reload. - Coalescing. A reload requested while one runs schedules exactly one
more after it. - Storage. The value lives in a store cell of its own
(::<document id>/mirror/<name>), revoked when the instance
closes. It is reactive through the ordinary change stream, and a closed
instance cannot write it.
The kernel tests each step in packages/core/main/test/mirror.test.ts.
Partial reloads
fold returns reload() to read everything again, or reload({ pages }) to
read only some pages through loadPages. Without loadPages, a page-scoped
request reloads everything. The annotation records mirror
(packages/plugin/annotation/src/sync/records.ts) re-reads only the pages a
redaction or a flatten touched:
case 'redaction.applied':
case 'pages.flattened': {
const pages = event.results
.filter((result) => result.status === 'applied')
.map((result) => result.page);
return pages.length ? reload({ pages }) : records;
}What `changed` receives
interface MirrorChange<V> {
readonly cause: 'load' | 'event';
readonly event: DocumentEvent | null; // null for loads
readonly pages: 'all' | readonly PageRef[] | null; // loads only: what was read
readonly previous: V;
readonly next: V;
}Status and control
| Member | Behavior |
|---|---|
get() |
The value the engine confirmed. |
view() |
What the user sees: get() with this session's pending changes replayed on top through predict. get() when the spec has no predict. |
getStatus() |
idle until the first load starts, loading while it runs, then ready. Stays ready while a reload runs. forbidden or error after a failed load or page reload. |
ensureLoaded() |
Load unless loaded or loading: shares a load in flight, resolves at once when loaded, tries again after a failed load. Rejects when the load fails. |
refresh() |
Reload everything. Joins a running load; rejects when the load fails. |
settled() |
Resolves once no load or page reload runs, including reloads started while it waits. Never rejects. |
A capability usually passes these through: getStatus: metadata.getStatus,refresh: () => metadata.refresh().
Page mirrors: `ctx.pageMirror(spec)`
A page mirror holds engine data that is read page by page, on demand. A page
is idle until something calls ensureLoaded(page); once loaded, it stays
current.
| Spec member | Meaning |
|---|---|
name |
Unique within the plugin. |
load(doc, page, signal) |
Read one page's value. |
affected(event) |
Which pages an event makes stale: a list, 'all' (every loaded page), or null. Only loaded pages are touched. |
fold?(value, event, page) |
Apply an event to an affected page instead of re-reading it; return 'reload' to re-read. Without fold, affected pages are re-read. |
predict?(value, op, page) |
Apply one op of this session's pending changes to a loaded page's value, as a mirror's predict does. |
changed?(change) |
Runs after every load of a page (even one that changed nothing) and every applied change. change is { page, cause: 'load' | 'event' | 'drop', event, previous, next }. |
| Member | Behavior |
|---|---|
get(page) |
The page's value, or undefined before it loaded. |
view(page) |
get(page) with the pending changes on top. |
getStatus(page) |
idle, loading, ready, forbidden or error. |
ensureLoaded(page) |
Load the page unless it is loaded or loading. |
refresh(page?) |
Re-read one page, or every loaded page. |
Built in:
- a load for a page that is already loading shares the running read;
- every read and fold bumps a per-page epoch, so an older read never
overwrites a newer value; pages.deleteddrops the deleted pages (cause: 'drop');stream.desyncedanddocument.versionedre-read every loaded page;- a failed read keeps the page's previous value and sets its status.
From packages/plugin/link/src/controller.ts:
const pages = ctx.pageMirror<readonly Link[]>({
name: 'links',
load: async (doc, page) => {
const layout = ctx.getPage(page);
if (!layout) {
throw new PluginError(
'not-found',
'link',
`page ${page.objectNumber} is not in this document`,
);
}
const snapshot = await doc.page(page).annotations.list();
return linksOf(snapshot.annotations, page, layout.boxes.crop);
},
affected: pagesChangedBy,
changed: ({ page, cause }) => {
if (cause === 'load') loaded.emit({ page });
},
});Pending changes
A user action is one change: a list of the engine's ops (ChangeOp),
applied all or nothing by doc.apply, sent as one request, undone as one
step. A plugin describes its writes as ops and stages them on the document's
change queue, ctx.changes, which every plugin of the document shares:
// Moving three selected annotations.
ctx.changes.stage({
label: { key: 'annotation.move', count: 3 },
ops: moved.map(({ ref, rect }) => ({
type: 'annotations.update',
ref,
patch: { rect },
})),
undo: before.map(({ ref, rect }) => ({
type: 'annotations.update',
ref,
patch: { rect },
})),
});- Shown at once. Before
stagereturns, every mirror'sview()shows the
change: the mirror replays the pending changes over its value, op by op,
through its spec'spredict. - Sent in order. The queue sends a change after everything staged before
it; the engine keeps that order.stagereturns thePendingChange: itsopId(what its events carry asorigin.tx.id) andresult, the engine's
answer. labelnames it in history and refusals: an i18n key and its values.undosays what undoing it looks like, so a history can draw it; it is
never sent.history: falsekeeps a change out of the history.
Predict
predict is the engine's own rule for an op, from the engine-core functions
the engine follows, next to the mirror's fold (the annotation plugin'spredictRecords, in sync/records.ts).
- Pure and absolute. Ops say what to set, never by how much, so replaying
an op over a value that already holds its answer changes nothing. Both
engines publish a change's events before they answer, so for a moment the
mirror holds the answer and the change is still pending; with absolute ops
that moment looks exactly like the answer. - Keep what an op doesn't touch. Return the same object for every record
an op leaves alone, so readers of the other records don't run again.view()is memoized on the value and the pending list. - Reads show the view. What a plugin's reads hand out (
list,get, its
render items) comes fromview().get()stays the confirmed truth, for
code that must not see a prediction.
When a change leaves the view
| The engine⦠| The change leaves⦠|
|---|---|
| applied it | each mirror's view once that mirror holds the answer: once it folded the change's last event, even before the answer arrives; after the read a fold asked for, when it asked; and a mirror that couldn't read keeps it until a load lands |
| refused it | every view, at once, together with its dependents |
| the document closed | nothing is shown any more; its result rejects instance-closed |
Dependents. A create can name its object (objectNumber, fromctx.changes.takeObjectNumber()), so later changes can refer to it before the
engine answers. When that create is refused, the changes that refer to what
only it would have created can't apply: the queue refuses them too (conflict,details.reason: 'dependency-refused') and aborts their engine calls.onSettled reports every answer, refusals with their label.
Holds and groups
- A hold is a change that can still be amended before it is sent: typing,
or a form commit waiting for its scripts.ctx.changes.hold(label)returns it;set(ops, undo?)replaces its ops, and the views show the latest. Any laterstagesends the open holds first, andsend()sends the holds staged
before it too, so send order is always staging order.cancel()drops it.opensays whether it can still be amended: once sent, what comes after
goes into a new hold.changeis the change it is once it has ops (itsopId, its answer). The plugin decides when it's done (a pause in typing,ctx.clock.after); the kernel sends every open hold before a download. - A group makes everything staged while its
runruns, by any plugin, one
change:ctx.changes.group(label, () => { β¦ }). Only what is staged beforerunreturns joins; a verb that awaits first stages its own change. Nested
groups join the outer one, a hold inside a group is refused, and whenrun
throws nothing of it is sent.
One change, one store update
Both engines publish a change's events in one burst that shares origin.tx.
The kernel holds the store's notifications while a burst arrives, so readers
see a change land in one update, whoever made it.
The kernel tests each rule in packages/core/main/test/changes.test.ts.
The annotation plugin is the reference: one change per message
(services/store.ts), its records mirror predicts (sync/records.ts), new
records are named by the object numbers they take, and typing is a hold
(write/text-editing.ts).
Undo and redo
The history (@embedpdf/plugin-history) is built on the queue and keeps no
copy of the document. Every change staged on the queue is a step, recorded
when it is staged (onStaged), so an undo right after an action undoes it
while it is still on its way. An undo is a change too:stage({ label, undoOf: opId, shows }). The views show shows (the change'sundo) at once, and the engine applies the reverse it recorded. A redo is the
undo of that undo.
- What a plugin gives the history:
undoon every change it stages, so an
undo is drawn before the engine answers, andhistory: falseon a change
that isn't the user's action. Holds that make up one step pass the samemergekey:hold(label, { merge: 'annotation.text:' }). - What a plugin may do around it: listen to
onStagedfor changes whoseundoOfis set, as the annotation plugin does to put the selection back
(services/selection-history.ts). It never stages undos itself. - What the engine decides: a part someone changed since is left alone
(skipped), a final change (redaction, flattening, signing, form repair)
or a new version ends undo before it (conflictwith the engine'sUndoUnavailable), and an undo of a refused change applies nothing.
Held-back writes
Some writes wait on purpose: the annotation plugin sends typed text once
typing pauses (a hold), the ink tool waits for the next stroke of a drawing, a
form field is written on blur. A hold (see Pending changes) is
how a change waits, and the kernel sends every open hold before a download
itself. A write queue holds writes that are on their way.
Before anything reads the whole file (documents.download(), downloadLayer()), or draws
its pages to print them (documents.preparePrint(), print()), the kernel settles the document (packages/core/main/src/settle.ts): it runs every
flush plugins registered with ctx.onSettle(flush) and waits for them, so the
file has everything the user sees.
- A flush sends what the plugin holds back and resolves once the engine has
it: the ink draft finished between strokes (never a stroke still being
drawn), the field being typed in committed (matches Acrobat: the field being
edited is committed before a save). Holds need no flush: the kernel sends
them. - A plugin whose queue carries document writes registers it:
ctx.onSettle(() => queue.idle()). A queue whose operations read the file
themselves is never registered: the actions plugin runs every download
inside its queue (ctx.aroundDownload, for the document's save actions,
andctx.aroundPrint, for its print actions), and waiting for that queue
would wait for itself. - A failed flush is reported and does not stop the read: the file is then what
the engine has. - The caller's
signal, or the document closing, cancels the wait withoperation-cancelled.
Resources
Anything that is not plain data is a resource: engine handles, rasters,
workers, script realms, registries of functions, caches. A resource lives in a
closure inside the controller.
- Acquire it with
ctx.acquire(get, dispose)or pair it withctx.cleanup(dispose)on the line after acquiring it (seeplugins.md). - When it changes in a way reads can see, call
ctx.notify(). - Never put it into
ctx.stateor a mirror.
Reactions
A reaction responds to confirmed document events without keeping a copy of
the data. It subscribes in connect, ignores origin for what it does, and
touches only its own caches and operations. The render plugin maps each event
to the pixels it changes (packages/plugin/render/src/invalidation.ts) and
invalidates them:
connect() {
ctx.listen(ctx.doc.events, (event) => {
const change = pixelChangeOf(event, allPageObjectNumbers);
if (!change) return;
publishInvalidation(change.pages, change.scope, 'origin' in event ? event.origin : null);
});
},Other reactions: search re-runs its query after any confirmed mutation, the
signature plugin re-judges its verdicts after edits of the working copy, and
the actions plugin clears its per-page trigger cache.
Rules
- A mirror changes only by
foldand by loads. Verbs never write it. foldis pure and ignores origin. Origin may shape presentation, never
data, and only outside the fold: the annotation plugin renders another
session's change from the engine's baked appearance, and records this
session edited or created from their description (a session preference,vectorin its state).foldapplies the data the event carries. It asks for a reload only when
the event does not carry enough (forms.repaired, orredaction.applied
for annotations).- Verbs stage a change (or call the engine) and return. They do not
refetch, do not wait for revisions, and do not emit fact events. - A verb's
awaitsees its own write. Both engines publish the event for a
session's own mutation before the mutation's promise settles, so the fold
and the fact events have run by the time the verb resumes. - Fact events come from
changed(or from the plugin's one listener for that
event) withevent.origin. Loads announceonResynced. - Reactions subscribe with
ctx.listeninconnect. refresh()exists for recovery and for callers that want a fresh read. No
write path calls it.- What the user sees is
view(): the truth with this session's pending
changes on top. Nothing else keeps a copy of a pending change.
Conventions Testing (`docs/conventions/testing.md`)
Testing
Where tests live, which layer to test at, and the tools the kernel and the
engine provide for it.
Where tests live
- A package's tests live in
test/, next tosrc/, in the same structure:src/sync/records.tsis tested intest/sync/records.test.ts. Tests that
run several areas together are named*.integration.test.ts; shared
fixtures and helpers go intest/fixtures/andtest/helpers/. - Test names are sentences that describe behavior.
- Tests run with Vitest:
pnpm --filter test, orpnpm testfor
the whole repository. - Vitest does not typecheck.
pnpm typecheckchecks a package's tests when
itstsconfig.jsonliststestininclude, as most plugins do.
Pick the layer
| What | How |
|---|---|
| Transitions, projections, folds | Call the pure functions in model.ts (or sync/) directly. |
| A controller: reads, verbs, events, mirrors | createTestContext from @embedpdf/core/testing. |
| Bring-up, dependencies, document lifecycle | createKernel with a fake engine. |
| Engine behavior plugins rely on | A conformance suite from @embedpdf/engine-core/conformance. |
The test context
createTestContext(options) returns a real PluginContext: the kernel's own
store, state cell, mirrors, page mirrors and event sources, page geometry
built from a page list, capabilities from a token map, and a document handle
shaped by the test.
| Option | Meaning |
|---|---|
id |
The plugin id (ctx.id, error messages). Default 'test'. |
state |
The initial session state; omit for a stateless plugin. |
settings |
The plugin's settings as its definition declares them, { defaults, registered? }; ctx.settings() reads them. Without them, ctx.settings() doesn't compile. |
pages |
The document's pages: { ref, size?, crop?, rotation?, userUnit?, label? }. document(), getPage and geometry derive from them. |
capabilities |
[token, capability] pairs that get and tryGet resolve. |
doc |
The document handle members the test uses (metadata, forms, page, β¦). The default has an event stream and security.allows answering true. null for a workspace plugin. |
documentId |
Default 'doc'. |
engine |
The ctx.engine members the test uses. |
clock |
The host's time behind ctx.clock. Default timerClock: the environment's timers (so vi.useFakeTimers() applies) and no frames. Pass manualClock().clock to step time. |
The returned context adds:
| Member | Behavior |
|---|---|
connect(instance) |
Does what the kernel does after create: runs connect, then starts the mirrors' first loads. Returns the api. |
emitDocumentEvent(event) |
Delivers a confirmed document event through the default doc.events. |
subscribe(listener) |
Observes the change stream (state updates and notify). |
capabilities |
The token map, to add or replace capabilities during a test. |
settle(signal?) |
Runs every ctx.onSettle flush and waits for them, as documents.download() does before it reads the file. |
download(read) |
Runs read inside every ctx.aroundDownload wrap, as documents.download() does after settling. |
print(print) |
Runs print inside every ctx.aroundPrint wrap, as a prepared print does around the print dialog. |
dispose() |
Aborts the lifetime and runs every registered cleanup. |
manualClock(options?) from @embedpdf/core/testing is time that moves only
when the test moves it: advance(ms) runs the timers that fall due, earliest
first; frame(timeMs?) moves to that time and runs the callbacks waiting for a
frame; pending counts what waits. manualClock({ frames: false }) is a host
that never paints. The same clock goes to createKernel({ clock }) for a test
through the kernel.
Unless the test passes its own, DocumentsToken resolves to a read-only
registry holding the one document; its mutating verbs throw unsupported. The
test context does not check declared dependencies; a test throughcreateKernel does.
The smallest harness (packages/plugin/shell/test/shell.test.ts):
const harness = () => {
const ctx = createTestContext({ id: 'shell', state: initialShellState() });
return ctx.connect(createShellController(ctx));
};A harness for a plugin with a mirror, here the title plugin fromplugins.md:
/* Detailed source-code truncated for AI context efficiency. */Fake engines publish before they resolve
Both engines publish the event for a session's own mutation before the
mutation's promise settles, and plugins depend on it: a verb's await sees
its own write. A fake document handle does the same. A fake that resolves
first, or never publishes, tests a system that does not exist.
// packages/plugin/metadata/test/metadata.test.ts
update: vi.fn(async (patch: { title?: string | null }) => {
const metadata = META({ title: patch.title ?? null });
const result = { metadata, cache: null };
// Like both real engines: the confirmed event is published before the promise settles.
emit({ type: 'metadata.updated', origin, ...result });
return result;
}),Mirror tests
- Fold tests. A fold is a pure function; test each event case directly,
including the ones that return the value unchanged and the ones that ask for
a reload (expect(fold(value, event)).toEqual(reload({ pages }))).packages/plugin/annotation/test/sync/records.test.tsis the reference. - Parity tests. The same event with
origin.kind: 'local'and with'remote'produces the same mirror value. Origin may change presentation,
never data. - No refetch. Count the engine's reads: a mirror reads once when it loads,
not again after a write (packages/plugin/form/test/fields-mirror.test.ts).
Pending change tests
Test what the user sees while a change is on its way and after the engine
answered, through the capability, with a fake engine that answers changes in
the order they were sent (as both engines do), can refuse one, hold it in
flight, and deliver another session's event meanwhile. A refused change must
show the mirror's record, including that other session's change; an answer
must never replace a newer change still on its way.packages/plugin/annotation/test/pending.test.ts is the reference, andtest/interleavings.test.ts plays seeded random sequences of the same.
- Events. A write fires its fact event once, with the right origin; a load
firesonResyncedand no per-item events.
The kernel tests the mirror protocol itself (loading after connect, queueing
and cursor replay, failures, resyncs, coalescing, closing) inpackages/core/main/test/mirror.test.ts and page-mirror.test.ts; a plugin
does not repeat those tests.
Kernel tests
A test that needs bring-up, dependency resolution or the document lifecycle
creates a real kernel with a fake engine and opens a document:
const kernel = createKernel({
engine,
plugins: [interactionPlugin(), formPlugin()],
});
await kernel.start();
await kernel.documents.open({
kind: 'bytes',
id: 'd',
bytes: new Uint8Array(),
});
const form = kernel.capability(FormToken, 'd');Destroy the kernel at the end of the test (await kernel.destroy()).
Engine conformance suites
@embedpdf/engine-core/conformance exports one suite per engine feature
(runDocumentEventsConformance, runFormConformance,runAnnotationMutationConformance, β¦). Each takes a test runner and options,
and every engine runs every suite: the local engine inpackages/engine/main/test/, the cloud engine in cloudpdf/engine/test/, both
in the engine conformance workflow in CI. Behavior a plugin relies on is
pinned by a conformance test, so both engines must provide it.runDocumentEventsConformance checks that every confirmed mutation publishes
exactly one event with the caller's result, that failed mutations publish
nothing, that own mutations are local with a stable session id, that
unsubscribing stops delivery, and that own mutations are published before
their promise settles.
Type tests
A test file may assert that a shape does not compile with// @ts-expect-error. If the shape starts compiling, pnpm typecheck fails
with an unused-directive error, so the assertion cannot rot
(packages/plugin/annotation/test/tools/definitions.test.ts). This works in
packages whose tsconfig.json includes test.
Conventions Viewer Doors (`docs/conventions/viewer-doors.md`)
Doors β the law for the viewer product line
packages.md is the law for where a package lives, andtooling/build/README.md for how packages build. This file is the law for
how one viewer serves several deliveries: EmbedPDF with the built-in
engine, CloudPDF with a remote one, a CDN snippet, and one wrapper per
framework β without forking the viewer once.
A door is an entry point that decides one thing: which engine is in the
bundle. Everything else is shared, names included: the CloudPDF viewer is the
same , `init()`, and <epdf-pdf-viewer> with
the cloud engine as the default, as @embedpdf/viewer is with the built-in one.
Unqualified kernel/, local/, and doors/ paths below are relative topackages/viewer/main/src/.
The five laws
One artifact, many doors. There is a single viewer implementation. A
door is an entry file, not a copy β if you are about to duplicate component
or element code per delivery, stop; the difference belongs in a door.The kernel is door-blind. Nothing under
kernel/may import fromdoors/orlocal/, and the word "local" may not appear there. Same rule
one level up: a framework wrapper'scomponent.*imports the viewer for
types only (import type), so it holds no runtime edge to any engine.A door declares its own truth. A config type may only promise what that
door's imports actually deliver. The local door'sengineis optional and
accepts the built-in engine's options bag; the/coredoor's is required and
takes only a realEngine | EngineFactory. Both are calledViewerConfigβ
one name per door, so samples and docs never pick a door-specific type name.
An engine's OPTIONS type lives next to the code that interprets it
(LocalEngineConfiginviewer/main/src/local,CloudEngineOptionsin@cloudpdf/engine), never in shared code.Doors never import doors. Two deliveries that share a surface share a
module, not each other β that is whylocal/surface.tsexists and the npm,
portable and snippet doors all re-export it, differing only in the engine
entry and the wasm default they register.The implementation sits at the widest type; doors narrow by assignment.
Parameter types are contravariant, so a function taking the wider config
satisfies a narrower signature β never the reverse. HenceinitViewerand
`` are typed with the kernel'sElementConfig(engine seamunknown), and each door re-types them by assignment:tsconst init: (options: InitOptions) => EmbedPdfViewerElement = initViewer;If you find yourself writing
ashere, you have it backwards.
Why the element's engine seam is `unknown`
EmbedPdfViewerElement is one compiled class serving every door, so its.config must carry a value only the sending door understands. It opens the
envelope far enough to recognise an Engine or a factory, and otherwise hands
it to the provider its door registered (EmbedPdfViewerElement.defaultEngineProvider,
set by registerLocalEngine() or @cloudpdf/viewer's registerCloudEngine();
the /core door sets none, which is exactly what makes its engine required).
What the provider returns (DoorEngine) is the engine, and what the element
can't know about it: the fonts its settings name, how a src in that engine's
words opens (sourceOf: the cloud's { token } is the engine's{ kind: 'token' }; the element reads only URLs, files, bytes and open inputs
itself), and plugin settings the engine needs under the config's own
(plugins: the cloud's stamp libraries open in a local engine). The element
has one default engine, so an app imports one door.
Typing that seam narrower would either forbid a legal door vocabulary or
advertise one a different door cannot honour. So the imperative el.config =
path stays opaque and runtime-checked on purpose; typed config belongs atinit() and ``, which are per-door.
The shape
packages/viewer/main/src/
kernel/ door-blind: element, config base, init, public surface
local/ the built-in engine's vocabulary + registration (not an entry)
doors/ entries only β local.ts, portable.ts, core.ts, snippet.tsEvery framework wrapper is the same four files, in this order:
src/component.ts the wrapper, engine-blind, `import type` only
src/parts.ts `./component`: the component at the widest config, and the
components and hooks every door shares
src/index.ts the local door: import '@embedpdf/viewer'; + parts + narrowed props
src/core.ts the core door: import '@embedpdf/viewer/core'; + parts + narrowed props(Angular's are entry folders: component/, core/, and src/ for the local door.)
Read one wrapper and you have read them all.
Doors in another package
@cloudpdf/viewer and its wrappers are doors too, the same shape across a
package line: @cloudpdf/viewer registers the cloud engine and is otherwise@embedpdf/viewer/core's surface with the cloud's ViewerConfig; each@cloudpdf/viewer-<framework> imports it, re-exports@embedpdf/viewer-<framework>/component, and narrows the component to the
cloud's config. Its npm build leaves every @embedpdf/* package to the app's
bundler, so the app has one element class, the one the engine is registered on.
Checklist: adding a framework wrapper
packages/viewer/<framework>β the name falls out ofpackages.md
(@embedpdf/viewer-<framework>).component.*: types only from@embedpdf/viewer/core; props areElementConfig & <Framework>Extras. Set.configbefore the element's
deferred mount, so the viewer boots exactly once β make that an acceptance
test.parts.*(./component): the component and every framework part, for
doors in this package and others.index.*/core.*: one side-effect
import each, re-export the parts, then the component narrowed to that
door'sViewerConfig.package.json: add"./core": "./src/core.*"toexports(the build
derives entries from it β seetooling/build/README.md) and list both door
entries insideEffects. A pure re-export barrel withsideEffects: false
can be dropped by a bundler, taking the element registration with it.- Children pass through as light DOM. There is no slot bridge: a slot is a
child. - Prove both doors. A probe of
@ts-expect-errorlines is self-validating β
if a door stops rejecting a bad shape, tsc fails with "unused directive".
Assert the bundle too: the/coredoor must emit noFPDF_symbols and no
wasm asset.
Enforcement
eslint.config.js carries the import-boundary rules for laws 2 and 3
(component.* is import type only; cloudpdf/** may not import the local doors).
They are latent until lint is wired up in CI β there is no roottsconfig.json and no lint script today, so treat the tree and the type
system as the real guardrails until that lands.
Content Engine Authentication (`docs/content/engine/authentication.mdx`)
title: Authentication engines: [cloud]
import { Callout } from '@/components/docs/callout';
Authentication
Every request the engine makes carries a token. Your backend creates the token and signs it with
your secret key; the engine only passes it along, and the server checks it. The token says which
document the user may open, what they may do with it, and who they are.
Three ways to open a document
Pick the one that matches where the credential comes from.
With a document token
Your backend creates a token for one user and one document. This is the most common way:
const doc = await engine.open({
kind: 'token',
token: () => fetchDocumentToken(documentId),
});The token names the document, so you don't pass an id. Pass a function rather than a string: the
engine calls it for every request, so a short-lived token never runs out mid-session.
One engine can hold many documents opened this way, each with its own token.
By id
When your app already has a token for your whole account (an admin tool, a backend job), open any
of its documents by id:
const engine = cloudEngine({ token: () => fetchAccountToken() });
const doc = await engine.open({ kind: 'id', id: 'doc_proposal_2026' });The engine's token is used for every document opened by id. To use a different one for a
single document, pass token in the input as well.
With a share link
A share link (shr_β¦) comes from the CloudPDF dashboard. It needs no backend at all, which makes
it right for public embeds:
const doc = await engine.open({ kind: 'share', shareToken: 'shr_9f2kβ¦' });A share link isn't a credential itself: the engine exchanges it for a short-lived token, and
renews that token before it expires. Editing or revoking the share in the dashboard changes every
embed at the next renewal. The server only accepts the exchange from the websites the share
allows.
If the share has a passphrase, pass it as sharePassword. Without it, open() fails withSharePasswordRequired, so you can ask the user and try again:
const doc = await engine.open({ kind: 'share', shareToken, sharePassword: 'open-sesame' });Password-protected PDFs
A PDF can have its own password, separate from any token or share passphrase. Pass it in the
options, whichever way you open:
const doc = await engine.open({ kind: 'token', token }, { password: 'pdf-password' });Opening documents shows how to ask
for it only when a file needs one.
What a token carries
A token carries the user's permissions (for example doc.render, doc.text.copy,annotations:update:self) and their identity (a user id, a display name, a group). The
server enforces the permissions on every request; the identity becomes the author of the
annotations the user creates.
Both come from the token, so the engine has no options for them. Read what the user was given
from doc.security:
doc.security.allows('doc.download'); // may this user download the file?
doc.security.identity?.displayName; // who they arePermissions lists every permission and how to build UI that
matches them.
When something is wrong
A missing or expired token fails with Unauthenticated; a token without the permission a call
needs fails with Forbidden. See Errors & cancelling.
Content Engine Quick Start (`docs/content/engine/quick-start.mdx`)
title: Quick start
import { Cards, Card } from '@/components/docs/cards';
import { Callout } from '@/components/docs/callout';
Quick start
This guide opens a PDF, shows its first page, searches it, highlights a match and saves the
result.
1. Install
npm install @embedpdf/enginenpm install @cloudpdf/engine2. Create the engine
Create one engine for your whole app and reuse it.
import { localEngine } from '@embedpdf/engine';
const engine = localEngine();Creating it is instant. The engine starts PDFium in a Web Worker the first time you use it, so
you don't have to wait for anything here.
import { cloudEngine } from '@cloudpdf/engine';
const engine = cloudEngine();It connects to CloudPDF SaaS. Your own server names itself:cloudEngine({ baseUrl: 'https://pdf.example.com' }). Creating the engine makes no request yet.
3. Open a PDF
Give the engine the file's bytes. Here we fetch them from a URL; a file the user picked works
the same way (await file.arrayBuffer()).
const response = await fetch('/invoice.pdf');
const doc = await engine.open({ kind: 'bytes', bytes: await response.arrayBuffer() });Open the document with a token your backend created for this user and this document. The token
also says what the user may do with it.
const doc = await engine.open({ kind: 'token', token: () => fetchDocumentToken('invoice') });Authentication shows how your backend creates the token, and the
other ways to open a document.
doc is your handle to the open document. Everything else starts from it.
4. Show the first page
List the pages, then render the first one as an image:
const { pages } = await doc.pages.list();
const firstPage = doc.page(pages[0].ref);
const image = await firstPage.render.image({ viewport: { kind: 'width', width: 800 } });
const { url } = await image.objectUrl();
document.querySelector('img')!.src = url;Each page has a ref: its address in the document. doc.page(ref) gives you a handle for that
page, and you pass the same ref to anything that works on pages.
5. Read the text
const { text } = await firstPage.text.get();
console.log(text);6. Search
Search the whole document. Each match says which page it's on and where it is:
const { matches } = await doc.search.query({ text: 'total' });
console.log(`${matches.length} matches`);7. Highlight a match
A match carries one segment per line it covers, each with the four corners of that piece of
text. A highlight takes exactly those corners:
const match = matches[0];
await doc.page(match.page).annotations.create({
subtype: 'highlight',
quadPoints: match.segments.map((segment) => segment.quad),
color: '#ffd700',
});Render the page again and the highlight is there. See
Highlights & text markup for underlines, strikeouts and
the other fields.
8. Save
download() returns the PDF with your changes, as bytes:
const bytes = await doc.download();To let the user save it, wrap the bytes in a Blob:
const link = document.createElement('a');
link.href = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
link.download = 'invoice-highlighted.pdf';
link.click();You don't need to save to keep the highlight: the server stored it when you created it.download() is for when you want the file itself.
9. Clean up
Close the document when you're done with it. Keep the engine for the next one.
await doc.close();Next steps
Content Engine Setup.Cloud (`docs/content/engine/setup.cloud.mdx`)
title: Setup
import { Callout } from '@/components/docs/callout';
Setup
npm install @cloudpdf/engineThe engine talks to a CloudPDF server over HTTPS: the managed CloudPDF service, or
your own server. It runs in every modern browser and in Node 18 or newer.
Create the engine once
Create one engine when your app starts and share it everywhere:
// pdf.ts
import { cloudEngine } from '@cloudpdf/engine';
export const engine = cloudEngine();It connects to CloudPDF SaaS; your own server goes in baseUrl (see Options). Creating the engine
makes no request. One engine holds any number of open documents, each with
its own token. Close each document when you're done with it (doc.close()), and keep the engine
for the life of your app.
Tokens
Every request carries a token that says who the user is and what they may do. Your backend
creates it; the browser never sees your secret key. Most apps pass the token when they open a
document:
const doc = await engine.open({ kind: 'token', token: () => fetchDocumentToken(documentId) });Pass a function rather than a string: the engine calls it for every request, so when your
backend hands out short-lived tokens, a long session keeps working. Authentication covers the three ways
to open a document and what goes in a token.
Options
| Option | What it does |
|---|---|
baseUrl |
Your own CloudPDF server. CloudPDF SaaS when left out. |
token |
A token for every document this engine opens, when you don't pass one to open(). |
fetch |
Your own fetch, for example to add headers or to test. |
onRetry |
Called when the engine retries a request the server was too busy for (see below). |
Busy servers
When the server is busy or restarting, it tells the engine to try again. The engine waits as
long as the server asks and retries, up to twice, before the call fails. You don't need to do
anything; onRetry lets you see it happen:
const engine = cloudEngine({
onRetry: ({ path, code, attempt, waitMs }) => {
console.debug(`retrying ${path} after ${code} (attempt ${attempt}, waited ${waitMs} ms)`);
},
});Only requests the server didn't apply are retried, so a retry never makes a change twice.
Content Engine Setup.Local (`docs/content/engine/setup.local.mdx`)
title: Setup
import { Callout } from '@/components/docs/callout';
Setup
npm install @embedpdf/engineCreate the engine once
Create one engine when your app starts and share it everywhere:
// pdf.ts
import { localEngine } from '@embedpdf/engine';
export const engine = localEngine();localEngine() returns right away and does no work yet. The first open() starts PDFium in a
Web Worker; calls you make while it starts wait for it, so you never check whether it's ready.
To start it early, for example while the rest of your app loads, call engine.warmup().
One engine holds any number of open documents. Close each document when you're done with it
(doc.close()), and keep the engine for the life of your app.
Bundlers
The engine's worker and its WebAssembly file ship inside the package. Vite, webpack 5 (and
Next.js), Rollup and Parcel pick them up without any configuration: there's nothing to copy and
nothing is fetched from a CDN.
If your setup can't bundle a worker (a strict Content Security Policy, an older bundler), host
the files yourself: Self-hosting lists every option.
Server-side rendering
A module-level localEngine() is safe in Next.js, Nuxt and SvelteKit: it does nothing until you
open a document, and that only happens in the browser. In the Next.js App Router, mark the
component that opens documents with 'use client'.
Node.js
The same call works in Node. There the engine runs PDFium in the same process, natively when
your platform has a build and as WebAssembly otherwise:
import { readFile, writeFile } from 'node:fs/promises';
import { localEngine } from '@embedpdf/engine';
const engine = localEngine();
const doc = await engine.open({ kind: 'bytes', bytes: await readFile('in.pdf') });
// β¦change the documentβ¦
await writeFile('out.pdf', await doc.download());
await doc.close();
await engine.destroy();In a script, destroy the engine at the end so the process can exit.
Rendering works in Node without a canvas: render.image() gives you PNG or BMP bytes. WebP needs a
canvas, so in Node pass your own imageEncoder (for example one built on sharp) if you need it.
To pick the runtime yourself, pass runtime: { prefer: 'wasm' } or { prefer: 'native' }.
Fonts
Text the engine draws (a free text annotation, a filled form field) needs a font that has its
characters. For languages the built-in fonts don't cover, give the engine a fallback font when you
create it:
const engine = localEngine({
fallbackFonts: [{ key: 'noto-cjk', familyName: 'Noto Sans CJK', url: '/fonts/NotoSansCJK.ttf' }],
});The font is loaded while the engine starts, so it's ready before the first document opens.
Fonts covers registering fonts later and using them by name.
With a viewer
The viewer can use your engine or create its own:
<Viewer engine={engine} />uses your engine and never destroys it. Share one engine across
every viewer in your app.<Viewer engine={() => localEngine()} />lets the viewer create an engine when it mounts and
destroy it when it unmounts.
Options
| Option | What it does |
|---|---|
fallbackFonts |
Fonts used for characters the document's own fonts don't have. |
fonts |
Fonts you use by name only (a free text fontFamily), not as fallback. |
worker |
Your own worker: a Worker, a function returning one, a URL, or 'inline'. |
wasmUrl |
Where to load the WebAssembly file from, when you host it yourself. |
renderPolicy |
The render sizes documents answer with; see Render policy. |
imageEncoder |
Your own image encoder, for formats the engine can't encode where it runs (WebP in Node). |
signedDocumentPolicy |
'permit' stops the engine refusing what signatures forbid; see Turning protection off. |
runtime |
In Node: { prefer: 'native' } or { prefer: 'wasm' }. The engine picks native when it can. |
Content Headless Errors (`docs/content/headless/errors.mdx`)
title: Errors
Errors
<Fw only={['react', 'vue', 'svelte']}>
This page lists the numbered errors of the Angular package; switch the docs to Angular to read it.
In
Setup for where the viewer goes.
When the viewer is set up in a way that can't work, the Angular package throws an error with a
number, such as EPDF-101, says how to fix it, and links here. Each section below is one number:
what happened, and the fix.
A document that isn't open yet, or a permission the user doesn't have, isn't a setup mistake: a
plugin refuses those with a PluginError (not-ready, permission-denied), the same in every
framework. See Permissions.
EPDF-101
No viewer above it. A service of the viewer (inject(EpdfDocuments), inject(EpdfViewer))
or one of its components (<epdf-stage>, *epdfDocumentGate) found no provideEmbedPdf() in
the component that uses it or in any component above it.
Add provideEmbedPdf() to the providers of the component that shows the viewer. That component
and everything in its template share the viewer, and the component can inject it itself:
For one viewer in the whole app, put the same call in app.config.ts; for one per route, in the
route's providers. A component outside the one that provides the viewer, such as a sibling in
your layout, can't reach it: move provideEmbedPdf() up to a component both are inside.
EPDF-102
No viewer with this plugin. A plugin's service (inject(EpdfRender), inject(EpdfSearch))
found no viewer above it that has the plugin. Either there's no viewer above it at all, or the
viewer's provideEmbedPdf() doesn't have the plugin's feature.
Add the feature the error names to provideEmbedPdf(), in the component that provides the viewer
or in your app config. withRender() gives EpdfRender, withSearch() gives EpdfSearch, and
so on:
EPDF-103
A layer outside a page. Something that draws on a page, such as <epdf-render-layer>, isn't
inside a page. A layer needs the page it draws on, and only the Stage gives one.
Put it inside <ng-template epdfPage> in an <epdf-stage>. The Stage draws that template once
for every page on screen:
EPDF-104
Part of a Stage outside one. UI that belongs to a Stage, such as <epdf-scrollbar> or<epdf-anchored>, isn't inside an <epdf-stage>. It scrolls that Stage, or follows its pages,
so it needs to be in it.
Put it between the Stage's tags, next to the page template. It sits over the pages, in the Stage's
own box:
EPDF-105
An engine call on the server. Code called a method that needs the engine (open(), a page's
picture, a zoom) while Angular rendered the page on the server. The engine only runs in the
browser, so on the server the viewer renders its empty state and every such call throws.
Call it from something that only happens in the browser: an event handler ((click)), orafterNextRender() for work when the component first shows:
To keep the viewer out of the server render altogether, put it in a @defer (on viewport)
block. See Setup.
Content Headless Quick Start (`docs/content/headless/quick-start.mdx`)
title: Quick start
import { Cards, Card } from '@/components/docs/cards';
Quick start
This guide builds a small PDF viewer: the pages, text people can select, zoom buttons, a
highlighter and a save button. Each step adds one plugin or one control, so you can stop at any
step and have a working viewer.
1. Install
You need two packages: the one for your framework, and an engine.
Every feature is a separate import from the framework package, such as @embedpdf/react/stage,
so what you don't use never reaches your bundle.
2. Show a PDF
Create the engine once, then give it to <Viewer> with the plugins you want and the document to
open. <Stage> shows the pages, and <RenderLayer> draws each one:
<Fw only={['react', 'vue', 'svelte']}>
localEngine() costs nothing until it's used, so creating it at the top of a module is safe, even
with server-side rendering. The viewer starts it when it mounts: PDFium boots in a Web Worker in
the background, and the first page shows as soon as the document is open. The engine's
WebAssembly is part of your build and loads from your own site; see Setup.
localEngine() costs nothing until it's used, so it's safe in your component, even with
server-side rendering. The viewer starts it in the browser: PDFium boots in a Web Worker in the
background, and the first page shows as soon as the document is open. The engine's WebAssembly is
part of your build and loads from your own site; see Setup.
cloudEngine() holds an HTTP client and nothing else, so creating it at the top of a
module is safe, even with server-side rendering. Every page, text and annotation call goes to your
CloudPDF server; see Setup.
<DocumentGate> shows its fallback until the document is open, so the Stage never renders
without one.
3. Let people select text
Add the interaction plugin, which handles the pointer for every feature, and the selection plugin.
Then put <SelectionLayer> above the page:
Drag over text to select it, double-click a word, or triple-click a line. On a phone, long-press a
word. See Selecting & copying for copying and a selection menu.
4. Add zoom buttons
<Fw only={['react', 'vue', 'svelte']}>
Every plugin has a useStage() does things,
and useStageState() gives you what to show:
Every plugin has a service with its methods and its state as signals, and the Stage hasEpdfStageRef. Next to the Stage in a template, its template reference, #stage="epdfStage",
gives you both:
<Fw only={['react', 'vue', 'svelte']}>
Put <ZoomControls /> anywhere inside <DocumentGate>. It
changes, whether a button, a pinch or code changed it.
The buttons go inside *epdfDocumentGate, next to the stage they name. The percentage updates when
the zoom changes, whether a button, a pinch or code changed it.
5. Add a highlighter
The annotation plugin shows the annotations a PDF already has, and lets people add their own. Add
it with its layer, and a button that turns on the highlight tool:
With the tool on, select text and it's highlighted. Click a highlight with the pointer tool to
select it. The page picture leaves the annotations out by itself, because the annotation layer
draws them now.
too, so the button needs no component of its own.
tool.
6. Save
documents.download() gives you the PDF with every change, as bytes. saveFile hands them to the
browser as a download:
<Fw only={['react', 'vue', 'svelte']}>Put <SaveButton /> anywhere inside <Viewer>.<app-save-button /> anywhere in the template of the component that provides
the viewer: it injects the service from there.
You don't need to save to keep the highlights: the server stored each one as it was made.download() is for when you want the file itself.
Next steps
Content Headless Setup (`docs/content/headless/setup.mdx`)
title: Setup
import { Callout } from '@/components/docs/callout';
Setup
A headless viewer needs two packages: the one for your framework, and an engine. This page
installs them, creates the engine for your whole app, and covers server-side rendering, bundlers
and fonts.
Install
The framework package holds every plugin, one import path each (@embedpdf/react/search,@embedpdf/react/annotation). A plugin you don't import isn't in your bundle.
It works with React 18 and later.
It's written for Vue 3: components for your templates and composables for <script setup>.
It's written for Svelte 5: components that take snippets, and functions that give you reactive
state for your runes.
It needs Angular 20.2 or later. Everything is standalone, OnPush and built on signals, so it works
in zoneless apps (provideZonelessChangeDetection()), the default for new ones.
Create the engine once
Create one engine when your app starts, and share it with every viewer:
// pdf.ts
import { localEngine } from '@embedpdf/engine';
export const engine = localEngine();Creating the engine is instant and allocates nothing. The first viewer that uses it starts it, and
PDFium boots in a Web Worker in the background.
// pdf.ts
import { cloudEngine } from '@cloudpdf/engine';
export const engine = cloudEngine();It connects to CloudPDF SaaS. Your own server names itself:cloudEngine({ baseUrl: 'https://pdf.example.com' }). Creating the engine makes no request.
<Fw only={['react', 'vue', 'svelte']}>
<Viewer> can also own the engine. Pass a function, and the viewer creates the engine when it
mounts and destroys it when it unmounts:
provideEmbedPdf() can also own the engine. Pass a function: the viewer calls it in the browser
when it starts, and destroys the engine when the component or route that provides it goes away:
The function may return a promise. With import(), as here, the engine loads in its own chunk the
first time a viewer needs it, instead of with your page.
An engine you pass in yourself is never destroyed by the viewer, so one engine can serve every
viewer in your app.
Register the plugins
<Fw only={['react', 'vue', 'svelte']}>
Plugins are a list you create once, next to the engine:
Each plugin is a feature of provideEmbedPdf(), given with the engine. Put it in the providers
of the component that shows the viewer:
The viewer reads engine and plugins once, when it mounts. Creating the list inside a component
would make a new list on every render; the viewer ignores it and warns you while you develop.
<Fw only={['vue', 'svelte']}>
The viewer reads engine and plugins once, when it mounts, and ignores later changes to them.
That component and everything in its template share one viewer, and the component can inject()
the plugins' services itself. In app.config.ts or a route's providers, the same call gives the
whole app or route one viewer, so a toolbar in your header and the pages in your outlet work
together. The viewer reads the engine and the features once, when it starts.
A setup that can't work, such as a service with no viewer above it, throws an error with a
number and its fix, like Angular's own. Errors lists them.
Order doesn't matter, and a plugin that needs another says so when the app starts: the annotation
plugin, for example, needs the interaction plugin.
Server-side rendering
The headless packages render nothing on the server that needs the engine: the engine only starts in
the browser. So a module-level engine is safe everywhere.
In the Next.js App Router, mark the component that renders <Viewer> as a client component:
In Nuxt, render the viewer on the client only, inside <ClientOnly>. The server renders its#fallback slot instead:
In SvelteKit, render the viewer in the browser only, for example behind {#if browser} withbrowser from $app/environment:
With Angular SSR, the viewer works as it is, and the server renders the page around it. To keep its
code, the engine included, out of the first download, put it in a @defer (on viewport) block: it
loads when it scrolls into view.
A component named DocumentViewer.client.vue renders on the client only wherever you use it, with
no <ClientOnly> around it.
To leave a whole page out of server rendering, add export const ssr = false to its +page.ts.
The server renders the @placeholder, and the deferred component loads in its own chunk with
everything it imports. on viewport watches the placeholder, so give it the viewer's height, and
nothing moves when the viewer takes its place.
Bundlers and self-hosting
The engine's worker and WebAssembly ship inside the package, and your build picks them up.
Vite, webpack 5 and Rspack need no configuration, and neither does Next.js, with webpack or
Turbop