embed-pdf-viewer Documentation

A PDF viewer that seamlessly integrates with any JavaScript project

RAW Doc

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 the v2 branch.

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:

πŸ‘‰ https://www.embedpdf.com

πŸš€ Live Demo

Try it now β€” load your own PDF or use the sample:

πŸ‘‰ https://app.embedpdf.com


πŸ’– 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 through
ctx.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

  1. A capability is a plugin's only public surface. Other plugins and adapters
    read through it, never through a plugin's state.
  2. 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.
  3. A mirror changes only by its fold and by loads. Its fold is pure and the
    same for every origin. Verbs never write it.
  4. Session state changes only through named pure transitions applied with
    ctx.state.update. A transition that returns the same object changes
    nothing.
  5. A verb calls the engine and returns. The engine publishes the confirmed
    event before the call resolves, so the verb's await already sees its own
    write: no refetch, no waiting, no hand-made events.
  6. Every event fires from exactly one place. Fact events carry
    event.origin as it is; no plugin builds an origin by hand. Loads announce
    onResynced.
  7. Reads are synchronous, pure and identity-stable (memo, memoByKey).
    They never start engine work.
  8. A plugin declares every token it resolves in requires or optional, and
    imports sibling tokens from their /contract entries.
  9. Registrations with siblings and subscriptions happen in connect, and
    everything acquired is owned: ctx.cleanup, ctx.listen, ctx.acquire.
  10. A capability rejects with PluginError only, and every verb that can
    refuse has a can* twin.
  11. Names and comments follow naming.md and
    comments.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 in
tooling/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.

ts
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();
ts
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

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:

ts
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 , and nothing appears on screen unless you put
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.
  • HooksComposablesFunctionsServices 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, and component stays the same.

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.

ts
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:

ts
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 page

A 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.

ts
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:

ts
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:

ts
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:

ts
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 old
rect onto the new one, each axis on its own. A rect of the same size moves the drawing, and a
bigger one stretches it:

ts
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 rect with a new shape (box, points or quads) fails with InvalidArg: send one or the
    other.
  • A turned drawing can only move. A rect of another size fails with InvalidArg; resize its
    box or its points instead. A box at a quarter turn is the exception: it stands upright on the
    page, so a rect resizes it along its own sides.
  • A rect that would squash a drawing to no width or no height fails with InvalidArg.

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:

ts
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

ts
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 another

Deleting 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 with
doc.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.

ts
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


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.

ts
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:

ts
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), and
calculationOrder (see Calculations).

Values

Each family has its own value fields. Check family first, and TypeScript knows which ones the
field has:

ts
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:

ts
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 in
widgets: 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:

ts
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:

ts
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 with
NotFound.

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:

ts
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:

ts
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: true also takes
    free text. More than one value needs a list with multiSelect.
  • A radio group's value must be the export value of one of its buttons.
  • A radio group with noToggleToOff can'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:

ts
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:

ts
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:

ts
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:

ts
doc.events.on('forms.valueSet', ({ field }) => {
  updateSummary(field);
});

When one call changes several fields, such as reset() or importValues(), their events share
origin.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

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 with
origin: '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.

  • pointer selects: click an annotation to select it, then move, resize or turn it.
  • square draws a rectangle: drag, or just click for a default-size one.
  • ink draws freehand.
  • highlight highlights the text you select.
  • note drops 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:

ts
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, use
inject(EpdfViewer).updateSettings({ identity }). Documents you open afterwards use the new one. To
follow your app's login:

ts
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 when a value changes:

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:

ts
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'] }); // list

It 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 Angular
FormControl: 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:

ts
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:

ts
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$ tells you why, so you can show a message.

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

ts
await form.reset(); // every field the user may fill in, back to its default
await form.reset([name.ref, email.ref]); // some fields

Move form data in and out

For your own backend, plain values are simplest:

ts
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:

ts
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$ fires. You rarely need it: the plugin follows every change, whoever made it.
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) only when that field's value changes.

Settings

Set them when you register the plugin, and change them while the app runs with
form.updateSettings({ … }); form.resetSettings() goes back to what you registered, and
useFormSettings() 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() and
importValues(), and canFill(ref) doesn't change:

ts
formPlugin({ fillable: false }); // show the values, take no input
form.updateSettings({ fillable: ['text', 'checkbox'] }); // only these families
form.updateSettings({ fillable: true }); // every field again

The 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 the
pointer 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:

js
{ 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 or
documents.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:

js
{ 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:

js
{
  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.

ts
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();
ts
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

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:

ts
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 , and nothing appears on screen unless you put
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.
  • HooksComposablesFunctionsServices 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, and component stays the same.

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.

The current API and SDK line is a preview. Endpoint pages display the exact canonical version, and your SDK language is remembered across the whole reference β€” pick it once below.

Install

Seven official SDKs, all generated from the contract below. Your choice here is
remembered for every example in the reference.

Get access

On managed CloudPDF **your account is a tenant** β€” tenants and deployment configuration are operated by the platform, so the sections marked *Self-hosted* below don't apply to you.
text
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.
On a self-hosted deployment **you are the operator**. Configure the root credential on the server:
text
```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.

  1. Initialize an upload β€” request upload
    access, transfer the file using the returned instructions, then
    commit the upload.
  2. Issue a document token β€” a short-lived JWT
    pinned to one document, carrying exactly the capabilities that user should have.
  3. 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.

ts
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();
ts
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

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:

ts
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 , and nothing appears on screen unless you put
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.
  • HooksComposablesFunctionsServices 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, and component stays the same.

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.

ts
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:

ts
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 page

A 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.

ts
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:

ts
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:

ts
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:

ts
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 old
rect onto the new one, each axis on its own. A rect of the same size moves the drawing, and a
bigger one stretches it:

ts
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 rect with a new shape (box, points or quads) fails with InvalidArg: send one or the
    other.
  • A turned drawing can only move. A rect of another size fails with InvalidArg; resize its
    box or its points instead. A box at a quarter turn is the exception: it stands upright on the
    page, so a rect resizes it along its own sides.
  • A rect that would squash a drawing to no width or no height fails with InvalidArg.

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:

ts
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

ts
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 another

Deleting 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 with
doc.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.

ts
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


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.

ts
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:

ts
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), and
calculationOrder (see Calculations).

Values

Each family has its own value fields. Check family first, and TypeScript knows which ones the
field has:

ts
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:

ts
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 in
widgets: 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:

ts
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:

ts
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 with
NotFound.

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:

ts
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:

ts
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: true also takes
    free text. More than one value needs a list with multiSelect.
  • A radio group's value must be the export value of one of its buttons.
  • A radio group with noToggleToOff can'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:

ts
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:

ts
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:

ts
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:

ts
doc.events.on('forms.valueSet', ({ field }) => {
  updateSummary(field);
});

When one call changes several fields, such as reset() or importValues(), their events share
origin.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

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 with
origin: '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.

  • pointer selects: click an annotation to select it, then move, resize or turn it.
  • square draws a rectangle: drag, or just click for a default-size one.
  • ink draws freehand.
  • highlight highlights the text you select.
  • note drops 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:

ts
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, use
inject(EpdfViewer).updateSettings({ identity }). Documents you open afterwards use the new one. To
follow your app's login:

ts
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 when a value changes:

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:

ts
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'] }); // list

It 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 Angular
FormControl: 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:

ts
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:

ts
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$ tells you why, so you can show a message.

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

ts
await form.reset(); // every field the user may fill in, back to its default
await form.reset([name.ref, email.ref]); // some fields

Move form data in and out

For your own backend, plain values are simplest:

ts
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:

ts
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$ fires. You rarely need it: the plugin follows every change, whoever made it.
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) only when that field's value changes.

Settings

Set them when you register the plugin, and change them while the app runs with
form.updateSettings({ … }); form.resetSettings() goes back to what you registered, and
useFormSettings() 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() and
importValues(), and canFill(ref) doesn't change:

ts
formPlugin({ fillable: false }); // show the values, take no input
form.updateSettings({ fillable: ['text', 'checkbox'] }); // only these families
form.updateSettings({ fillable: true }); // every field again

The 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 the
pointer 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:

js
{ 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 or
documents.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:

js
{ 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:

js
{
  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.

ts
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:

ts
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 page

A 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.

ts
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:

ts
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:

ts
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:

ts
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 old
rect onto the new one, each axis on its own. A rect of the same size moves the drawing, and a
bigger one stretches it:

ts
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 rect with a new shape (box, points or quads) fails with InvalidArg: send one or the
    other.
  • A turned drawing can only move. A rect of another size fails with InvalidArg; resize its
    box or its points instead. A box at a quarter turn is the exception: it stands upright on the
    page, so a rect resizes it along its own sides.
  • A rect that would squash a drawing to no width or no height fails with InvalidArg.

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:

ts
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

ts
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 another

Deleting 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 with
doc.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.

ts
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


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.

ts
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:

ts
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), and
calculationOrder (see Calculations).

Values

Each family has its own value fields. Check family first, and TypeScript knows which ones the
field has:

ts
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:

ts
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 in
widgets: 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:

ts
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:

ts
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 with
NotFound.

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:

ts
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:

ts
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: true also takes
    free text. More than one value needs a list with multiSelect.
  • A radio group's value must be the export value of one of its buttons.
  • A radio group with noToggleToOff can'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:

ts
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:

ts
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:

ts
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:

ts
doc.events.on('forms.valueSet', ({ field }) => {
  updateSummary(field);
});

When one call changes several fields, such as reset() or importValues(), their events share
origin.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

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 with
origin: '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.

  • pointer selects: click an annotation to select it, then move, resize or turn it.
  • square draws a rectangle: drag, or just click for a default-size one.
  • ink draws freehand.
  • highlight highlights the text you select.
  • note drops 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:

ts
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, use
inject(EpdfViewer).updateSettings({ identity }). Documents you open afterwards use the new one. To
follow your app's login:

ts
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 when a value changes:

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:

ts
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'] }); // list

It 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 Angular
FormControl: 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:

ts
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:

ts
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$ tells you why, so you can show a message.

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

ts
await form.reset(); // every field the user may fill in, back to its default
await form.reset([name.ref, email.ref]); // some fields

Move form data in and out

For your own backend, plain values are simplest:

ts
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:

ts
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$ fires. You rarely need it: the plugin follows every change, whoever made it.
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) only when that field's value changes.

Settings

Set them when you register the plugin, and change them while the app runs with
form.updateSettings({ … }); form.resetSettings() goes back to what you registered, and
useFormSettings() 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() and
importValues(), and canFill(ref) doesn't change:

ts
formPlugin({ fillable: false }); // show the values, take no input
form.updateSettings({ fillable: ['text', 'checkbox'] }); // only these families
form.updateSettings({ fillable: true }); // every field again

The 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 the
pointer 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.

Only one variable is strictly required: **`CLOUDPDF_LICENSE_KEY`** (see [Licensing](/docs/server/configuration/licensing)). With a development key, everything else has a sensible default β€” a minimal server runs with just that. Any other license kind also requires `CLOUDPDF_JWT_SECRET` and `CLOUDPDF_PASSWORD_VERIFICATION_HMAC_SECRET` (plus `CLOUDPDF_PASSWORD_SESSION_SERVER_SECRET` when a KMS is configured) to be real random values of at least 32 bytes β€” the server fails closed at boot and names the variable it needs.

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.

The CLI prints this whole surface at any time: `cloudpdf-server --help`.

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
You can always start small and grow. Because storage is configured, not compiled in, moving from SQLite + disk to Postgres + S3 β€” or from Docker to Kubernetes β€” is a configuration change, not a rewrite.

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:

  1. A signing secret β€” CLOUDPDF_JWT_SECRET, shared with the backend that
    mints tokens. See Authentication.
  2. A database β€” SQLite (default) or Postgres. See
    Database.
  3. An object store β€” local disk (default) or a cloud bucket. See
    Storage.
  4. Persistent volumes for /data when you use SQLite, local storage, or the
    local cache.
For anything customer-facing: set `CLOUDPDF_ENGINE_ISOLATION=host` so a native PDF crash costs a sub-second engine respawn instead of the instance, and run more than one replica (Compose or Helm) so a single failure can't take you offline. Details in [Health & scaling](/docs/server/operations/health-and-scaling).

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.

In a hurry? The [Quick start](/docs/server/getting-started/quick-start) gets a server answering requests in about a minute with a single `docker run`.

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.

Don't want to host anything? **CloudPDF Cloud** runs this same server for you as a managed SaaS. The API is identical, so you can start on the cloud and move to self-hosted later β€” or the other way around β€” without changing a line of frontend code. [See plans and start a free trial](/pricing).

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:

js
{ 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 or
documents.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:

js
{ 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:

js
{
  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's
create(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.
    Their connect runs in kernel.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's
    create, then its connect, 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 and
documents.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 (pending
ctx.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 or
    Sets in ctx.state or a mirror. Anything else is a resource.
  • No state exists only to wake the UI. A counter bumped so that selectors
    re-run is a ctx.notify() call instead.

state-and-sync.md describes each kind's API.

Data flow

text
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 ─► UI

Both engines publish the event for a session's own mutation before the
mutation's promise settles (see DocumentEvent in
packages/engine/core/src/events/DocumentEvent.ts, checked by
runDocumentEventsConformance). 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 with Object.is. Build such reads with memo and
    memoByKey from @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, …) inside connect;
  • 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 in
    core-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 second
    core/ folder.

Plugin anatomy

text
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 in connect.ts and runs inside connect.
  • create returns { api, connect? }, and controller.ts is where it is
    built.
  • Every registration's Unsubscribe goes to ctx.cleanup, even when both
    sides close together.
  • Tests mirror src/ under test/.

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

  1. 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.
  2. 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}.
  3. 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.
  4. No roadmap. Future work is TODO(#123): … pointing at a GitHub issue,
    or it is not in the code.
  5. One owner per explanation. Explain an invariant once, where it is
    enforced. Elsewhere, link to that symbol instead of repeating it.
  6. Public API is documented with TSDoc. Every member of contract.ts and
    host-contract.ts gets:
    • a one-sentence summary;
    • the PluginError codes it rejects with and the events it fires, when it has any;
    • @param/@returns only when the names do not already say it.
  7. 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.
  8. 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.
  9. No commented-out code, no names of people, and no dates, unless the date
    is part of a specification or protocol.
  10. 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 .md files 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

text
/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:

text
src/content/docs/headless/annotation.mdx
  β†’ /docs/headless/react/annotation
  β†’ /docs/headless/vue/annotation
  β†’ /docs/headless/svelte/annotation
  β†’ /docs/headless/angular/annotation

Viewer pages use the same fan-out model:

text
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-start

Why 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, and
generateStaticParams 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:

text
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.ts
  • pnpm --filter @embedpdf/website-samples typecheck runs 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-kit mdx/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 (everything
use() 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.mjs says which pages document which capability.
    docs/content/scripts/reference.mjs reads 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 in docs:check.
  • The pages describe the API the code is moving to. Where they're ahead, the
    name is pending in 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's Field column with its
    defineState() fields, a Settings table's Setting column with its settings
    defaults (a row may name a group), and every From CSS column with
    EPDF_VARIABLES in @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.mjs compiles each framework's snippets against
    the packages in the same commit (React with tsc, Angular with ngc and
    strictTemplates, Vue with vue-tsc, Svelte with svelte-check) and writes
    generated/snippet-status.json. It never fails the build.
  • docs/content/scripts/publish-gate.mjs writes generated/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 is pending.

The viewer's pages go through the same gate, for five integrations: the four frameworks and
vanilla, 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 in
docs/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:

sh
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's

The 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's
check: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

text
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 uses

React, Vue and Svelte import the stylesheet (import './<name>.css', '../<name>.css' from a
directory). Angular names it in styleUrl: './<name>.css' with
encapsulation: 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

text
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 it

The 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's
CodeExampleCard 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 under
types, 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. With
DOCS_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.ts redirects() β€” written once at launch, SEO preserved.
  • Docs version switcher: just a link to the archive. No in-tree versioning.

Rollout order

  1. /docs/viewer β€” launch the ready-made Viewer integrations first.
  2. /docs/headless/react/* β€” the complete vertical set, proving the
    author-once machinery.
  3. 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; use
CLOUDPDF_ENGINE_SHARDS to partition documents across multiple supervised
hosts. The thread-ownership rules in
runtime-confinement.md still apply inside every
host.

The implementation lives in cloudpdf/server/src/runtime/EngineHostClient.ts,
cloudpdf/server/src/runtime/ShardedEnginePool.ts, and
cloudpdf/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.
    hostEnvWhitelist applies the allowlist, integration tests assert the child
    environment, and NODE_OPTIONS is 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//environ depends on the kernel's ptrace policy
    (kernel.yama.ptrace_scope, a node-level sysctl in Kubernetes). HOME and
    the container filesystem remain shared. Marking the API process non-dumpable
    (PR_SET_DUMPABLE=0) would close same-UID /proc access 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 through runtimeClassName, 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 in
cloudpdf/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 to
packages/engine/services/ unless they start at the repository root.

Top-level structure

txt
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:

txt
runtime/shared -> document-session -> features -> worker-host

document-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:

txt
feature-name/
  FeatureReader.ts
  FeatureMutator.ts      # only when the feature changes document state
  internal/
    ...
  index.ts

Rules:

  • Use Reader for read-only orchestration.
  • Use Mutator for business-level document changes.
  • Put helper functions, registries, per-subtype codecs, and low-level runtime details in
    internal/.
  • index.ts exports 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
    under internal/.

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, or registry.ts unless they live under an
    already-specific folder and still read clearly.
  • Use runtime or @embedpdf/engine-runtime terminology. 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 in
packages/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 write or contentWrite runs as one layer transaction, begun and ended
    in WorkerHost.run (a signing's signatures.complete is the exception: it replaces the
    document's bytes instead). The transaction commits in finishMutation, 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) calls promoteInlineAnnotations first, 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 say applied or unchanged.
  • Writes bump cacheSeq (derived caches) as they go; finishMutation bumps editsSeq (unsaved
    edits, the signing version) once per committed write.

Adding a feature

When adding a new worker capability:

  1. Add the wire request/response types in @embedpdf/engine-core.
  2. Add a Reader or Mutator under features/<name>/.
  3. Put implementation details under features/<name>/internal/.
  4. Add a small handler path in worker-host/.
  5. Add local/server/cloud adapter changes only where the public engine API requires them.
  6. 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 changed callback, 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.

ts
// 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, so
onProgressChanged 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.

ts
// 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 readonly fields.

Creating events

A plugin creates each event with ctx.events.source<T>():

ts
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, typed EventHook<T>. emit and
    dispose stay 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 createEventHook directly; it exists for the kernel
    and the adapters.

Delivery (packages/core/main/src/event-hook.ts):

  • emit calls 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 an AbortSignal, and
    returns an Unsubscribe.
  • 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 own
origin, passed on as it is. No plugin builds or translates an origin
(scripts/check-architecture.mjs flags an object literal with sessionId
and sub).

ts
// @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 as
    remote.
  • Ask kind or sessionId for "is this my own action?" (undo, reconciling
    optimistic state). Ask sub for attribution.
  • Handlers that update state ignore origin (see
    state-and-sync.md); origin is for the few
    features that care about provenance.
  • stream.desynced carries no origin: it is a transport notice, not a
    mutation. A mirror turns it into a reload and an onResynced.
  • An event that a verb can cause as well as a document event carries
    origin: EventOrigin | null, with null for the verb: render's
    onInvalidated after invalidate().

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), where source is an
    EventHook (annotation.onCreated) or anything with subscribe
    (ctx.doc.events). The kernel owns the unsubscribe; subscriptions to
    siblings are made in connect.
  • In React: useCapabilityEvent(Token, (capability) => capability.onX, handler),
    or the per-plugin helper (useMetadataEvent, useSearchEvent, …).
    Angular has injectCapabilityEvent. 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

  1. A name says what the value is in the domain, not its type or storage:
    annotation, field, pane, hit. Not a, f, obj, data.
  2. Spell words out. Abbreviations are allowed only when the glossary below
    lists them.
  3. One name per concept, everywhere. If the engine calls it a PageRef,
    nothing calls it a page id, pointer or address.
  4. 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 interface for object shapes in contracts, and type for unions,
    aliases and mapped types.
  • Contract fields and state fields are readonly.
  • Getters return T | null for "absent". Optional inputs and optional data
    fields use ?:. One API never uses both undefined and null for the same
    meaning.

Discriminated unions

  • type tags things that happen: messages, events, effects.
  • kind tags 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/ under test/. Test names are sentences that describe behavior.

Enforcement

ESLint enforces the mechanical parts:

  • id-length with the single-letter exceptions above;
  • id-denylist for the spelled-out words above;
  • @typescript-eslint/naming-convention for 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, and
tooling/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

  1. 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.
  2. packages/<group>/main is the group's namesake: @embedpdf/<group>.
    core/main is the kernel (@embedpdf/core); engine/main is the default
    local-wasm engine (@embedpdf/engine). Groups without a principal package
    simply have no main/.
  3. 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 why
cloudpdf/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 --json yields name + path);
    the release workflows do.
  • Cross-scope names collide on their last segment. @embedpdf/viewer-react
    and @cloudpdf/viewer-react are 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 fixed group in .changeset/config.json enumerates 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

text
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 only

Each layer consumes only the layer below. A UI never touches scope strings or
allows() 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 can twin: 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, /F states), 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 β†’ can prefix β†’ 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, because
doc.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 same
PermissionDenied 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:

text
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

ts
// 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 from
settings.defaults. A manifest passes no type arguments. create receives a
PluginContext<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 has
NoSettings: 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:

ts
// 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.

ts
// 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:

ts
// 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>/contract is 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/host is optional. It exposes the host
    lens over the same token object.
  • Named entries such as /destination, /authoring or /scripting expose a
    deliberate pure helper feature that is neither a contract nor the full
    implementation.

The source rules are mechanical:

  1. Plugin source never imports another plugin's bare package root, not even
    for types. Use /contract, /contract/host, or a named helper entry.
  2. 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.
  3. 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.
  4. requires and optional describe kernel relationships. They do not
    create a JavaScript module boundary and do not replace contract imports.
  5. The token is defined once. The root, /contract, /contract/host and
    /internal re-export or widen the same token; createCapabilityToken is
    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's
create. It returns { api, connect? }:

  • api is the capability. It is built once and never replaced.
  • connect runs once, after this plugin and its dependencies are created (see
    architecture.md). The first loads
    of the plugin's mirrors start right after it.

Inside create a controller creates its event sources, its mirrors and its
ctx.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:

ts
// 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 by
createServices(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> in
services/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.

ts
// 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 in optional. Its own token and DocumentsToken need no
    declaration. The host token of a plugin is the same object as its public
    token, so declaring either covers both.

  • In development (NODE_ENV is not production), 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 sets
    resolvesAnyCapability: true in its manifest; the kernel then allows any
    token and neither orders nor validates those dependencies.

  • createKernel validates 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's hint), 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 when create runs.

  • A workspace plugin reaches document plugins with ctx.forDocument and
    declares their tokens as optional.

  • A workspace capability resolved for a document (ctx.get in a document
    plugin, useCommands() inside a <DocumentScope>) is its inScope view:
    commands.execute(id) runs for that document, documents.download()
    downloads it.

The document

  • ctx.doc is the guarded engine handle of the plugin's document. Every call
    through it rejects instance-closed once the instance closed, is aborted
    when the instance closes, and rejects with PluginError instead 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 a PageRef or an index, looked up two
    ways. Reads use ctx.getPage(page), which returns the page or null; verbs
    use ctx.pageOf(page), which returns the page or throws not-found.
    ctx.assertPageRef(ref) throws not-found for a foreign page.
  • A read given a page that no longer exists returns empty ([], 0 or
    null); a verb refuses it. Reads run while rendering, and a page deleted
    mid-render must not break a layer: Search's listHits({ page }) and
    getHitCount(page) answer [] and 0 for it, and search({ from })
    rejects not-found.
  • ctx.allows(permission) is what every can* twin reads, and
    ctx.assertAllowed(permission, operation) is how the verb refuses: it
    throws permission-denied with error.permission set. Both read the bound
    document, so a workspace plugin asks the can* 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 local
    createSerialQueue().
  • Time comes from ctx.clock, never from the environment: no setTimeout,
    setInterval or requestAnimationFrame in a plugin or a *-core package
    (lint enforces it). The host decides what a timer and a frame are when it
    builds the kernel (createKernel({ clock })): the framework viewers pass
    browserClock() from @embedpdf/web, and without one the kernel uses
    timerClock, timers and no frames. A helper an app calls with no context,
    such as persistStampLibraries, 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.

ts
// 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, so updateSettings() reaches them all, and the ones opened later.
  • Nested objects merge, arrays and values replace, undefined is ignored, and
    resetSettings() goes back to what the app registered. onSettingsChanged
    carries { settings, changed }, changed naming the top-level settings
    whose value changed.
  • A controller reads settings.get() where it uses a setting, never a copy
    made in create, 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 forgets settings while the controller reads them is a
    type error, and so is ctx.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 one ctx.state.onChange listener, even when
    it reads like progress: Search's onProgressChanged fires when progress
    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) and
    export const useSearchSettings = settingsHook(SearchToken)). Getters in
    read return 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 of useSearch()'s
    stand-in.
  • A color setting whose default follows the accent defaults to null in the
    plugin's defaults (selection's color, handles.color): the adapter paints
    it with paint() and, for a translucent one, mixAccent() from
    @embedpdf/web, so --epdf-accent and the viewer's accent reach it. A
    color with its own default (Search's highlight.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:

ts
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.doc already reject with PluginError: the guarded
    handle maps engine errors with toPluginError(capability, error).
    Cancellation becomes operation-cancelled, never operation-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 reading ctx.allows(permission),
    and refuses with ctx.assertAllowed(permission, operation), never a
    hand-built PluginError (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
    without doc.text.search, or from a page that isn't there, before its
    session is touched, so getStatus() is what it was and onFailed stays
    quiet. onFailed is 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.

ts
// 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",
});
text
/* Detailed source-code truncated for AI context efficiency. */
ts
// 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;
text
/* Detailed source-code truncated for AI context efficiency. */
ts
// 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,
  });
ts
// 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 through create.
  • One createCapabilityToken call, in token.ts; a host lens uses
    createHostToken.
  • Every token the plugin resolves is in requires or optional, and is
    imported from a /contract entry.
  • 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 from ctx.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 with not-found (ctx.getPage for reads, ctx.pageOf for
    verbs).
  • A refusal before the work starts only rejects: no state change, no event.
  • Sibling registrations and subscriptions are in connect, and every
    Unsubscribe goes to ctx.cleanup.
  • Every rejection is a PluginError, and the contract's TSDoc lists its codes.
  • Tests under test/ mirror src/ (see testing.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

  1. Each worker thread calls EPDF_InitThread before using PDFium and
    EPDF_ShutdownThread after closing every handle it created
    (wired in packages/engine/services/src/runtime/lifecycle/bootstrap.ts).
  2. No document, page, bitmap, annotation, text page, content parser, or font
    object may cross workers.
  3. The pool enforces this: a doc is pinned to one worker via sticky-by-docId
    routing in cloudpdf/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_GLOBALS define in
    packages/engine/runtime/runtime-src/BUILD.gn, consumed by the EPDF_TLS macro
    (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).

bash
# 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. 8

Keep 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.

  1. 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=200

    Use ICC-heavy PDFs to cover the LCMS default-context path (see the gate note
    in packages/engine/runtime/runtime-src/core/fxcodec/icc/icc_transform.cpp).

  2. Single-thread regression β€” run the embeddertests to confirm the
    thread_local variant is still correct single-threaded:

    bash
    packages/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. See
    engine-host-isolation.md.
  • Keep pdf_use_partition_alloc=false (or make g_allocators thread_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 to
cloudpdf/server/src/; test paths are relative to cloudpdf/server/.


File layout

Every family lives at the same shape:

text
<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>.ts

Examples: cloudpdf/server/src/security/kms/adapters/AwsKmsKeyring.ts,
cloudpdf/server/src/storage/adapters/S3ObjectStore.ts, and
cloudpdf/server/src/cdn/adapters/BunnyCdnSigner.ts.


Interface convention

typescript
interface <Family> {
  readonly info: { kind: <FamilyKind>; ...public_diagnostic_fields };
  // family-specific methods
}

Rules:

  • kind is the discriminator and matches the config Zod schema.
  • Other info fields 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 }, not kind: '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

typescript
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:

typescript
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 install doesn'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-optional to 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:

typescript
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:

typescript
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:

typescript
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 β€” selector
  • CLOUDPDF_<FAMILY>_<KIND>_<FIELD> β€” variant-specific fields
  • Secret values accept the secret:///<name>?jsonKey=...&encoding=... URI form (parsed to SecretRef) 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:

typescript
import { redactConfig } from '../../config/secrets/redact';

logger.info({ config: redactConfig(myConfig) }, 'starting');
  • SecretRef-shaped values become <SecretRef provider/name>
  • Caller can pass additionalSensitiveKeys to 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:

typescript
// 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

  1. Interface β€” already exists for the family; you don't touch it.
  2. Zod schema variant β€” add one entry to <family>/config/<Family>ConfigSchema.ts's discriminated union.
  3. Adapter class β€” one file in <family>/adapters/<Kind><Family>.ts. Implements the interface; exposes info: { kind, ... }; uses the lazy-load house pattern if it wraps a cloud SDK.
  4. Factory case β€” one new case in <family>/create<Family>.ts's switch. TypeScript fails the build if you forget.
  5. Env loader case β€” one new branch in <family>/config/load<Family>ConfigFromEnv.ts's kind-specific field reader.
  6. Optional dependency β€” if the adapter wraps a cloud SDK, add the package to cloudpdf/server/package.json's optionalDependencies.
  7. Test β€” one file at cloudpdf/server/test/<family>-<kind>.test.ts covering construction, key operations, and that info exposes 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 in
src/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

sh
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 gate

migrate down selection:

  • --steps N β€” roll back the N highest applied migrations (default 1).
  • --to NNN β€” roll back every applied migration newer than NNN,
    leaving NNN and everything below it applied.
  • --all β€” roll back everything (down to an empty schema; the
    runner-owned schema_migrations table is preserved).

Safety:

  • migrate down is destructive, so a real run requires --yes
    (containers are non-interactive). Use --dry-run to 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-empty down
    SQL. If any target is irreversible the command aborts without touching
    the database.
  • down restores 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:

sh
# 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 --yes

Ordering 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.
  • buildApp validates 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

  1. Add NNN_name.sql to both
    cloudpdf/server/src/db/migrations/sqlite/ and
    cloudpdf/server/src/db/migrations/postgres/.
  2. Add the matching NNN_name.down.sql to both dialects (reverse the up
    in child-before-parent FK order).
  3. Register both in the dialect's index.ts
    ({ version, name, sql, down }).
  4. Run pnpm --filter @cloudpdf/server test β€” the round-trip test
    (cloudpdf/server/test/migrator-down.test.ts) asserts
    up β†’ down --all β†’ up works on
    both dialects.

SQLite `DROP COLUMN` caveat

SQLite refuses ALTER TABLE ... DROP COLUMN for a column referenced by a
CHECK constraint (or index/PK/FK/generated expression). When a down
needs to remove such a column, rebuild the table instead (see
cloudpdf/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`

ts
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: () => S builds the initial value, fresh for every
    instance. A plugin without session state omits it and its context is
    PluginContext<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 in model.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 no onChange. Write transitions so they return state
    itself when nothing changes.
  • onChange fires synchronously, before update returns, with the previous
    and next value. It is where a plugin derives its state-change events (see
    events.md).
  • After the instance closed, update drops the write and reports
    instance-closed once.

From packages/plugin/shell:

ts
// 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.

ts
// 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 with
ctx.state.onChange instead. The subscription ends when the instance closes;
the returned Unsubscribe ends it earlier.

ts
// 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, with
instance-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):

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):

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

  1. Subscribe first. The mirror subscribes to ctx.doc.events when it is
    created, inside create.
  2. Load after connect. The first load starts right after the plugin's
    connect has run, so capabilities that readable or load use exist.
    A spec with onDemand: true waits instead: the mirror stays idle,
    folding confirmed events, until ensureLoaded() or refresh() asks for
    its first load, for data nothing needs when a document opens. A resync or a
    fold's reload() before then reads nothing; the first load reads it all.
  3. 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.
  4. Degrade on failure. A failed load keeps the previous value, applies the
    queued events to it, and reports forbidden (a permission-denied
    rejection) or error through getStatus(). 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.
  5. Resync. stream.desynced and document.versioned start a full reload.
    fold never sees them.
  6. Throwing folds. A fold that throws is reported and starts a full
    reload.
  7. Coalescing. A reload requested while one runs schedules exactly one
    more after it.
  8. 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:

ts
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

ts
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.deleted drops the deleted pages (cause: 'drop');
  • stream.desynced and document.versioned re-read every loaded page;
  • a failed read keeps the page's previous value and sets its status.

From packages/plugin/link/src/controller.ts:

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:

ts
// 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 stage returns, every mirror's view() shows the
    change: the mirror replays the pending changes over its value, op by op,
    through its spec's predict.
  • Sent in order. The queue sends a change after everything staged before
    it; the engine keeps that order. stage returns the PendingChange: its
    opId (what its events carry as origin.tx.id) and result, the engine's
    answer.
  • label names it in history and refusals: an i18n key and its values.
    undo says what undoing it looks like, so a history can draw it; it is
    never sent. history: false keeps 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's
predictRecords, 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 from view(). 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, from
ctx.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 later
    stage sends the open holds first, and send() sends the holds staged
    before it too, so send order is always staging order. cancel() drops it.
    open says whether it can still be amended: once sent, what comes after
    goes into a new hold. change is the change it is once it has ops (its
    opId, 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 run runs, by any plugin, one
    change: ctx.changes.group(label, () => { … }). Only what is staged before
    run returns joins; a verb that awaits first stages its own change. Nested
    groups join the outer one, a hold inside a group is refused, and when run
    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's
undo) at once, and the engine applies the reverse it recorded. A redo is the
undo of that undo.

  • What a plugin gives the history: undo on every change it stages, so an
    undo is drawn before the engine answers, and history: false on a change
    that isn't the user's action. Holds that make up one step pass the same
    merge key: hold(label, { merge: 'annotation.text:' }).
  • What a plugin may do around it: listen to onStaged for changes whose
    undoOf is 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 (conflict with the engine's
    UndoUnavailable), 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,
    and ctx.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 with
    operation-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 with
    ctx.cleanup(dispose) on the line after acquiring it (see
    plugins.md).
  • When it changes in a way reads can see, call ctx.notify().
  • Never put it into ctx.state or 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:

ts
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

  1. A mirror changes only by fold and by loads. Verbs never write it.
  2. fold is 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,
    vector in its state).
  3. fold applies the data the event carries. It asks for a reload only when
    the event does not carry enough (forms.repaired, or redaction.applied
    for annotations).
  4. 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.
  5. A verb's await sees 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.
  6. Fact events come from changed (or from the plugin's one listener for that
    event) with event.origin. Loads announce onResynced.
  7. Reactions subscribe with ctx.listen in connect.
  8. refresh() exists for recovery and for callers that want a fresh read. No
    write path calls it.
  9. 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 to src/, in the same structure:
    src/sync/records.ts is tested in test/sync/records.test.ts. Tests that
    run several areas together are named *.integration.test.ts; shared
    fixtures and helpers go in test/fixtures/ and test/helpers/.
  • Test names are sentences that describe behavior.
  • Tests run with Vitest: pnpm --filter test, or pnpm test for
    the whole repository.
  • Vitest does not typecheck. pnpm typecheck checks a package's tests when
    its tsconfig.json lists test in include, 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 through
createKernel does.

The smallest harness (packages/plugin/shell/test/shell.test.ts):

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 from
plugins.md:

text
/* 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.

ts
// 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.ts is 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, and
test/interleavings.test.ts plays seeded random sequences of the same.

  • Events. A write fires its fact event once, with the right origin; a load
    fires onResynced and no per-item events.

The kernel tests the mirror protocol itself (loading after connect, queueing
and cursor replay, failures, resyncs, coalescing, closing) in
packages/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:

ts
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 in
packages/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, and
tooling/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 to
packages/viewer/main/src/.

The five laws

  1. 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.

  2. The kernel is door-blind. Nothing under kernel/ may import from
    doors/ or local/, and the word "local" may not appear there. Same rule
    one level up: a framework wrapper's component.* imports the viewer for
    types only (import type), so it holds no runtime edge to any engine.

  3. A door declares its own truth. A config type may only promise what that
    door's imports actually deliver. The local door's engine is optional and
    accepts the built-in engine's options bag; the /core door's is required and
    takes only a real Engine | EngineFactory. Both are called ViewerConfig β€”
    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
    (LocalEngineConfig in viewer/main/src/local, CloudEngineOptions in
    @cloudpdf/engine), never in shared code.

  4. Doors never import doors. Two deliveries that share a surface share a
    module, not each other β€” that is why local/surface.ts exists and the npm,
    portable and snippet doors all re-export it, differing only in the engine
    entry and the wasm default they register.

  5. 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. Hence initViewer and
    `` are typed with the kernel's ElementConfig (engine seam
    unknown), and each door re-types them by assignment:

    ts
    const init: (options: InitOptions) => EmbedPdfViewerElement = initViewer;

    If you find yourself writing as here, 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 at
init() and ``, which are per-door.

The shape

text
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.ts

Every framework wrapper is the same four files, in this order:

text
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

  1. packages/viewer/<framework> β€” the name falls out of packages.md
    (@embedpdf/viewer-<framework>).
  2. component.*: types only from @embedpdf/viewer/core; props are
    ElementConfig & <Framework>Extras. Set .config before the element's
    deferred mount, so the viewer boots exactly once β€” make that an acceptance
    test.
  3. 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's ViewerConfig.
  4. package.json: add "./core": "./src/core.*" to exports (the build
    derives entries from it β€” see tooling/build/README.md) and list both door
    entries in sideEffects. A pure re-export barrel with sideEffects: false
    can be dropped by a bundler, taking the element registration with it.
  5. Children pass through as light DOM. There is no slot bridge: a slot is a
    child.
  6. Prove both doors. A probe of @ts-expect-error lines is self-validating β€”
    if a door stops rejecting a bad shape, tsc fails with "unused directive".
    Assert the bundle too: the /core door must emit no FPDF_ 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 root
tsconfig.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:

ts
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:

ts
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.

A share link (shr_…) comes from the CloudPDF dashboard. It needs no backend at all, which makes
it right for public embeds:

ts
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 with
SharePasswordRequired, so you can ask the user and try again:

ts
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:

ts
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:

ts
doc.security.allows('doc.download'); // may this user download the file?
doc.security.identity?.displayName; // who they are

Permissions lists every permission and how to build UI that
matches them.

Create tokens on your server, never in the browser, and keep them short-lived. A document token opens one document only, so a leaked token can't be used for any other.

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

sh npm2yarn
npm install @embedpdf/engine
sh npm2yarn
npm install @cloudpdf/engine

2. Create the engine

Create one engine for your whole app and reuse it.

ts
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.

ts
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()).

ts
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.

ts
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:

ts
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

ts
const { text } = await firstPage.text.get();
console.log(text);

Search the whole document. Each match says which page it's on and where it is:

ts
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:

ts
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:

ts
const bytes = await doc.download();

To let the user save it, wrap the bytes in a Blob:

ts
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.

ts
await doc.close();
Every call returns a promise you can cancel: keep it and call `.abort()`, for example when the user scrolls past a page before its image arrives. See [Errors & cancelling](/docs/engine/concepts/errors-and-cancelling).

Next steps


Content Engine Setup.Cloud (`docs/content/engine/setup.cloud.mdx`)


title: Setup

import { Callout } from '@/components/docs/callout';

Setup

sh npm2yarn
npm install @cloudpdf/engine

The 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:

ts
// 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:

ts
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:

ts
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.

Building a viewer interface? The [headless documentation](/docs/headless/quick-start) wires the engine to pages, selection and annotation tools for you.

Content Engine Setup.Local (`docs/content/engine/setup.local.mdx`)


title: Setup

import { Callout } from '@/components/docs/callout';

Setup

sh npm2yarn
npm install @embedpdf/engine

Create the engine once

Create one engine when your app starts and share it everywhere:

ts
// 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:

ts
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:

ts
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.
Building your own viewer interface? The [headless documentation](/docs/headless/quick-start) wires the engine to pages, selection and annotation tools for you.

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 , a component used outside the viewer throws an error that says so; see
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)), or
afterNextRender() 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.

```sh npm2yarn npm install @embedpdf/react @embedpdf/engine ``` ```sh npm2yarn npm install @embedpdf/react @cloudpdf/engine ``` ```sh npm2yarn npm install @embedpdf/vue @embedpdf/engine ``` ```sh npm2yarn npm install @embedpdf/vue @cloudpdf/engine ``` ```sh npm2yarn npm install @embedpdf/svelte @embedpdf/engine ``` ```sh npm2yarn npm install @embedpdf/svelte @cloudpdf/engine ``` ```sh npm2yarn npm install @embedpdf/angular @embedpdf/engine ``` ```sh npm2yarn npm install @embedpdf/angular @cloudpdf/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 for its API and one for its state. 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 has
EpdfStageRef. 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 when the zoom
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. The component that provides the viewer can inject its services
too, so the button needs no component of its own.
See Annotations for every
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>.
Put <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

```sh npm2yarn npm install @embedpdf/react @embedpdf/engine ``` ```sh npm2yarn npm install @embedpdf/react @cloudpdf/engine ``` ```sh npm2yarn npm install @embedpdf/vue @embedpdf/engine ``` ```sh npm2yarn npm install @embedpdf/vue @cloudpdf/engine ``` ```sh npm2yarn npm install @embedpdf/svelte @embedpdf/engine ``` ```sh npm2yarn npm install @embedpdf/svelte @cloudpdf/engine ``` ```sh npm2yarn npm install @embedpdf/angular @embedpdf/engine ``` ```sh npm2yarn npm install @embedpdf/angular @cloudpdf/engine ```

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:

ts
// 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.

ts
// 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} with
browser 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