Web
The engine runs in the page as WebAssembly and draws into a <canvas> you provide. PDFEditor is the whole API: open bytes, let the user edit, take bytes back. Nothing is uploaded — the document never leaves the tab.
1. Install
The package, @pdfsdk/editor, comes with your license as an npm tarball. Install it like any local package:
npm install ./pdfsdk-editor-2.4.1.tgzIt contains the TypeScript API (dist/, with type declarations) and the engine (public/wasm/pdfeditor.js and pdfeditor.wasm, about 9.6 MB). Copy the wasm/ folder to a path your site serves — by default the editor looks for it at ./wasm/ relative to the page — or pass wasmUrl to point elsewhere.
2. Serve it cross-origin isolated
The engine runs its rendering work on real threads, which needs SharedArrayBuffer. Browsers only allow that on a page that is cross-origin isolated: served over HTTPS (or localhost) with both of these headers on the page and its subresources.
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corpWithout them PDFEditor.create rejects with an error that says so. Scope the headers to the page that hosts the editor if the rest of your site loads third-party resources — require-corp blocks any cross-origin resource that does not opt in.
| Host | How |
|---|---|
| Netlify, Cloudflare Pages | a _headers file |
| Vercel | headers in vercel.json |
| nginx | add_header in the location that serves the editor |
| GitHub Pages | not possible — it cannot set response headers |
# _headers (Netlify / Cloudflare Pages) — isolate only the editor's path
/editor/*
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corpServe .wasm as application/wasm, compressed: brotli cuts the download to about a third, gzip to about 40%.
3. Create an editor and open a PDF
import { PDFEditor, PDFOpenError, PDFLicenseError } from '@pdfsdk/editor';
const canvas = document.querySelector('canvas')!;
const editor = await PDFEditor.create(canvas, {
licenseKey: 'PE1.eyJ2Ijox…', // issued for this site's domain
wasmUrl: '/static/wasm/', // where pdfeditor.js / .wasm are served (default './wasm/')
minZoom: 0.5,
});
const bytes = new Uint8Array(await file.arrayBuffer());
try {
editor.open(bytes);
} catch (err) {
if (err instanceof PDFOpenError && err.reason === 'password') {
editor.open(bytes, 0, await askForPassword());
} else throw err;
}Size the canvas with CSS; the editor follows it (a ResizeObserver rebuilds the drawing buffer when the element settles). Pointer, wheel and pinch input are handled for you, and holding Space pans. The rest of the keyboard is yours to route, because only your page knows what has focus: call typeText() and backspace() while isEditing, formKey() while a form field has focus, and bind your own shortcuts to undo(), redo(), deleteSelection() and the rest.
4. Tools and properties
The editor is a canvas, not a toolbar — you build the chrome and drive it through the API.
editor.tool = 'highlight'; // arm a tool: 'select', 'text', 'image', 'freehand', 'rect', …
editor.addEventListener('selectionchange', (e) => {
const { selected, props } = (e as CustomEvent).detail;
// props: kind, colours, font, size, opacity… — enough to render a properties panel
});
editor.setFillColor(0x0a84ffff); // colours are 0xRRGGBBAA
editor.setTextStyle(true, null); // bold on; leave italic as each run has it
editor.undo();Events the editor dispatches:
| Event | When |
|---|---|
selectionchange | the selection or its properties changed |
historychange | undo / redo availability changed |
pageschange | the page count or the active page changed — on open(), a page turn, or pages added, removed, moved or rotated; detail is { count, active } |
zoomchange | the zoom level changed |
toolchange | the armed tool changed |
editingchange | an inline text-editing session began or ended |
formfocuschange, fieldselectionchange | form-field focus and selection |
markupchange, inkchange, placementchange | markup, drawing and placement previews |
toolrequest | the user tapped or dragged out a spot for a tool that needs something from you first (image, note, sound, signature, stamp, link, page); detail is { tool, at, rect } — supply it, for example with insertImage() |
placementrefused | a new text box was refused because the spot is too close to existing text; the tool stays armed |
hoverchange | the pointer moved onto or off a selectable object; detail is its box, or null |
readonlychange | viewer mode was switched |
licensechange | a license key was applied while editors exist (setLicenseKey, or create with a licenseKey) |
Look and feel
Because the chrome is yours, so is its styling, language and layout. What the engine itself draws is set through these calls. Each one holds for the editor's lifetime, across every open():
| Call | What it changes |
|---|---|
canvasColor option · setCanvasColor('#rrggbb') | the surround behind the page |
minZoom, maxZoom options | the zoom range, as multiples of the fitted page: default 0.5–6. maxZoom is clamped to 1–6 and covers pinch, ctrl+wheel and zoomBy; minZoom: 1, maxZoom: 1 turns zoom off. editor.minZoom / editor.maxZoom report the range in force, for greying out your zoom buttons |
setDrawPageShadow(on) | the drop shadow under the page |
setShowAllLayerOutlines(on) | dashed hint boxes around every selectable layer |
setCornerHandlesOnly(on) | corner handles only on a selection |
setReadOnly(on) | viewer mode: render, zoom, page and search, no editing |
setSelectionStyle({ color, handleFill, handleShape, handleScale, borderWidth }) | the selection box, handles, rotation knob, caret, text-selection band and page guide; handleShape is 'rounded', 'circle' or 'square', handleScale 0.75–1.5, borderWidth 1–3 px |
setRotateIcon(imageData | null) | the glyph in the rotation knob, drawn as a template in the selection colour (at most 512×512) |
setSearchHighlightColors({ match, current }) | the search highlights' colours; their translucency stays the engine's, so the text under them reads |
setNewTextFont(bytes | null, postScriptName) | the font of every text box the user places; its size and colour still follow the text around it |
Coming from iOS or Android
The iOS and Android editors ship their own toolbars, so they take a configuration that narrows and restyles them. On the web the toolbar is yours, so most of those options are simply how you build it. The ones that reach what the engine draws are the calls above:
| iOS / Android option | On the web |
|---|---|
modes, hiddenTools, toolOrder, toolTitles, toolIcons, modeTitles, modeIcons | your toolbar: offer the tools you want, in your order, with your names and icons, and arm one with editor.tool = … |
hiddenMenuItems, menuOrder, menuItemTitles, menuItemIcons, headerIcons | your menu and header; each built-in item is one call (searchStart, save, …) |
customTools, customMenuItems | your own buttons; editor.save() returns the document as it stands, every edit included, without writing it anywhere |
colorScheme | your CSS; pass the theme's surround to setCanvasColor when it changes (and setSelectionStyle / setSearchHighlightColors if those differ by theme) |
toolDefaults | setMarkupColor / setMarkupOpacity before arming a markup tool, setInkStyle(rgba, width) before the pen; remembering a user's choice is up to you |
zoomRange / minZoom, maxZoom | the minZoom and maxZoom options |
interfaceFontFamily | your CSS |
fonts, fontsTitle, showsDocumentFonts, showsDeviceFonts, allowsFontImport | your font picker: list your fonts and apply one with setFontData(bytes, postScriptName); embeddedFonts() lists the fonts already embedded on the current page |
newTextFont | setNewTextFont |
selectionColor, handleFillColor, handleShape, handleScale, selectionBorderWidth | setSelectionStyle |
rotateIcon | setRotateIcon |
searchHighlightColor, searchCurrentHighlightColor | setSearchHighlightColors |
signaturePlacement | the placement option of signDocument ('bottom-right' when left out); your sign sheet's picker starts wherever you like |
haptics, showsUsageHints | nothing to turn off: the web engine neither vibrates nor shows hints of its own |
5. Save
const out = editor.save(); // Uint8Array — a complete PDF, or null on failure
if (out) await upload(new Blob([out], { type: 'application/pdf' }));Edits are written as real PDF objects. An encrypted document is saved encrypted with the password it was opened with. save() always writes a complete new file, which invalidates an existing digital signature even if nothing was edited — if the user made no changes, keep the original bytes. signDocument appends an incremental update instead, so earlier signatures survive it.
License key
Pass licenseKey to create, or await PDFEditor.setLicenseKey(key, { wasmUrl }) once before the first editor — it loads the engine, so give it the same wasmUrl. Without a valid key for the page's domain, create and open throw PDFLicenseError, whose license.reason says why.
try {
await PDFEditor.create(canvas, { licenseKey });
} catch (err) {
if (err instanceof PDFLicenseError) console.error(err.license.status, err.license.reason);
}Keys, domains, wildcards and development hosts: License keys.
Edit documents from code
Everything a user can do in the editor can be done from code, with no UI: read every layer of a page and its properties, replace text in any script (the layer keeps its font, size, colour and position, and the system's fonts fill in letters its font lacks), restyle, move, resize, rotate and restack layers, add text, images and shapes, mark text up, fill and author forms, edit metadata and pages, search, render a page, and save.
A layer is addressed by its page and an id that keeps naming it while other layers change. Geometry is in page points from the page's top-left corner. Each change is one undo step.
import { PDFDocumentEditor } from '@pdfsdk/editor';
const doc = await PDFDocumentEditor.open(bytes);
for (const block of doc.textBlocks(0)) {
const text = await translate(block.text ?? '');
doc.setText(block, text, { shrinkToFit: true });
}
const edited = doc.save();editor.document is the document an editor is showing: changes appear at once and the user can undo them. Failures throw PDFDocumentError; notFound means the layer is gone, so read the layers again.
The web SDK draws no words of its own (your page builds the toolbar), apart from a new text box's starting text: editor.setNewTextPlaceholder('पाठ').
Digital signatures
import { createDigitalId, loadDigitalId, signDocument, verifySignatures } from '@pdfsdk/editor';
const id = await loadDigitalId() ?? await createDigitalId({ name: 'Ada Lovelace', organization: 'Acme' });
const { bytes } = await signDocument(editor, pdf, id, {
reason: 'I approve this document',
placement: 'bottom-right',
pageIndex: editor.activePage,
pageSize: editor.pageSizePoints(editor.activePage)!,
});
for (const s of await verifySignatures(editor, bytes!)) console.log(s.summary);The Digital ID is RSA-2048 with a non-extractable private key kept in IndexedDB. Signatures prove the document was not modified after signing; they are self-signed, not a qualified electronic signature.
Browser support
The engine needs WebAssembly threads, WebGL2 and SharedArrayBuffer on an isolated page. The automated test suites run in current Chrome at desktop and phone sizes, and Safari is used in development; other browsers with those features should work but are not part of the test matrix yet. If your font picker offers the user's installed fonts through the browser's queryLocalFonts, note that only Chromium has it; elsewhere, let the user upload a TTF or OTF and pass its bytes to setFontData.
Troubleshooting
| Symptom | Cause |
|---|---|
| "the page is not crossOriginIsolated" | COOP/COEP missing, or the page is not HTTPS — check self.crossOriginIsolated in the console |
PDFLicenseError | no key, or the key does not list this domain — err.license.reason names the host it saw |
404 for pdfeditor.js or pdfeditor.wasm | wasmUrl does not point at the served wasm/ folder — or setLicenseKey ran before create without the same wasmUrl |
| A cross-origin image or font stops loading on the editor page | COEP require-corp: the resource must send Cross-Origin-Resource-Policy or be same-origin |
Every method: Web API reference.