@xtract-bot/pdf-editor
v0.2.0
Published
A complete PDF editor as a single React component. Annotations, forms, redaction, signing, OCR and page ops — MuPDF WebAssembly, entirely client-side, nothing uploaded.
Maintainers
Readme
@xtract-bot/pdf-editor
A complete PDF editor as a single React component. Annotations that survive in Acrobat, form filling, real redaction, digital signing, OCR, page manipulation, and import from Word / Markdown / spreadsheets — running entirely in the browser on MuPDF compiled to WebAssembly. Nothing is uploaded.
import { PdfEditor } from "@xtract-bot/pdf-editor";
import "@xtract-bot/pdf-editor/styles.css";
export default function App() {
return <PdfEditor />;
}That is the whole editor, exactly as it ships at pdf.xtract.bot — that page renders this component with no props.
Licence — read this first
AGPL-3.0-or-later. This package links MuPDF, which is AGPL, and ships its WebAssembly build to the browser. Delivering the editor to a user is conveying the program, so anything you build on it inherits the obligation to offer complete corresponding source under the AGPL — including your own application code, if it forms one program with the editor.
Embedding
Every prop narrows the editor. Omitted keys keep the full application, so gating is opt-out: a feature added in a later version never silently vanishes from an integration that hasn't heard of it.
One document inside your own interface
The common case: your app already knows which PDF the user is working on, has its own Save button, and wants the editor to behave like a widget rather than an application.
import { useRef, useState } from "react";
import { PdfEditor, type PdfEditorHandle } from "@xtract-bot/pdf-editor";
import "@xtract-bot/pdf-editor/styles.css";
export function ContractEditor({ id }: { id: string }) {
const editor = useRef<PdfEditorHandle>(null);
const [dirty, setDirty] = useState(false);
async function save() {
const bytes = await editor.current!.save();
await fetch(`/api/documents/${id}`, { method: "PUT", body: bytes });
}
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<header>
Contract {id}
<button onClick={save} disabled={!dirty}>Save</button>
</header>
<PdfEditor
ref={editor}
assetPath="/pdf-editor"
initialDocument={{ url: `/api/documents/${id}.pdf`, name: "contract.pdf" }}
onDirtyChange={setDirty}
style={{ flex: 1, minHeight: 0 }}
features={{
tabs: false, // one document — the host owns it
open: false, // no Open command, no "+", no drag-and-drop of other files
session: false, // don't keep a copy in IndexedDB
download: false, // saving happens through the host's button only
statusBar: false,
tools: ["select", "highlight", "ink", "text"],
panels: ["comments"],
}}
/>
</div>
);
}Alternatively keep the editor's own Save button and receive the bytes
through onSave — the two are independent, and you can use both.
Props
| Prop | Effect |
| --- | --- |
| assetPath | Directory the worker and wasm are served from (see Installing). Required for the published build; unnecessary when consuming source. |
| initialDocument | { bytes } (ArrayBuffer, Uint8Array, Blob or File) or { url }, plus an optional name. Opens on mount instead of the picker, and takes precedence over any restored session. |
| onSave | Receives the fully-applied PDF (annotations burnt in, forms filled, security applied) instead of triggering a browser download. |
| onDirtyChange | (dirty: boolean) => void — fires as the document gains or loses unsaved changes. |
| ref | A PdfEditorHandle: save(opts?) returns the fully-applied bytes, isDirty(), getDocumentName(). |
| className, style | Applied to the editor's root element. The root fills its container's height, so give the container a definite height or pass one here (style={{ height: 600 }}). |
| theme | "light", "dark" or "system" (default). System follows the OS, and honours data-theme="dark" or a dark class on <html> if your app sets one. |
| loadIcons | Default true: adds the Remixicon stylesheet from jsDelivr if the page doesn't already have it. Set false and include it yourself if you self-host fonts or your CSP forbids the CDN. |
| features.tools | Tools offered in the toolbar. "select" always survives — without a pointer there is no way to reach what you drew. |
| features.commandGroups | file · edit · view · panel · document · help. Filtered once, centrally, so the palette, the menus and the keyboard map all agree. |
| features.panels | Side panels kept. |
| features.tabs | Multi-document tab bar. Default true. |
| features.open | Bringing in other documents: the Open command, the new-tab +, dropping files onto the editor, and the Markdown / Word / spreadsheet / image imports that create a new document. Default true. |
| features.session | Autosave open documents to IndexedDB. Default true — turn it off when the host owns the document, or the editor keeps its own copy of someone else's file indefinitely. |
| features.download | The download / export affordances. Default true. |
| features.statusBar | The page / zoom / annotation-count bar along the bottom. Default true. |
Keyboard shortcuts are bound on the document, so they work without
anything focused. In an embed they stop the moment the user clicks or
tabs outside the editor and resume on the next interaction inside it,
so Ctrl+F and Ctrl+S go back to your app when the user leaves the
editor.
Installing
npm install @xtract-bot/pdf-editor
npx xtract-pdf-editor copy-assets ./public/pdf-editorThe second line is not optional, and it is worth understanding why.
The engine runs in a Web Worker with a 10 MB wasm beside it. Those two
files are referenced by a URL resolved at run time, not by an import
your bundler can follow — so no bundler copies them, and equally none of
them needs to transpile this package for it to work. That is the same
bargain pdf.js, Apryse and PSPDFKit make, and it is what lets the
package work in build setups that would never look inside
node_modules. The cost is one copy step; the benefit is that you serve
the 10 MB artefact from your own origin with your own cache headers.
Then tell the editor where you put them:
import { PdfEditor } from "@xtract-bot/pdf-editor";
import "@xtract-bot/pdf-editor/styles.css";
<PdfEditor assetPath="/pdf-editor" />Re-run copy-assets whenever you upgrade — the worker and the UI are
versioned together and a mismatch is not supported. A postinstall
script is the usual way to make that automatic.
Consuming the source instead
In a monorepo you can skip all of that and take the TypeScript source
directly; that is how this repo's own site renders the editor, which is
what keeps the path exercised. Your bundler then emits the worker itself
and assetPath is unnecessary. Vite needs:
export default defineConfig({
worker: { format: "es" },
optimizeDeps: { exclude: ["mupdf"] },
ssr: { noExternal: ["@xtract-bot/pdf-editor"] }, // SSR frameworks only
});Serving from another origin
You can't. new Worker() rejects a cross-origin script URL, so a CDN
copy of the JS cannot start the engine. To host the editor centrally,
serve a page from your own origin and embed that page in an iframe.
Optional features
Five features load their dependency lazily. Install only what you enable — they are optional peer dependencies, so npm will not pull them in for you:
| Feature | Package |
| --- | --- |
| OCR (document group) | tesseract.js |
| Spreadsheet import | xlsx |
| Word import | mammoth |
| Markdown export | turndown |
| Export pages as a zip | jszip |
Styles
@xtract-bot/pdf-editor/styles.css is complete and self-contained: the
editor's own rules, the Tailwind utilities it is written in, its design
tokens, and a copy of Tailwind's base reset scoped to the editor's
root element (.pe-root). Nothing in it targets your document — no
global reset, no :root variables — so it drops into a page with or
without Tailwind of its own.
Theming
Every colour is a CSS variable on .pe-root, with light and dark
values built in and switched by the theme prop. Override any of them
to retheme; an unlayered rule in your stylesheet always wins:
.pe-root {
--color-accent: oklch(0.6 0.2 30); /* buttons, selection, focus */
--color-bg: white;
--color-bg-subtle: #f5f5f5;
--color-fg: #111;
--color-fg-muted: #666;
--color-border: #e5e5e5;
--color-border-strong: #ccc;
--color-accent-fg: white;
--color-danger: #c0392b;
font-size: 14px; /* the whole UI scales with it */
}Go further by targeting the editor's own classes (.pe-main, .pe-page,
.pe-widget, …) or its ARIA roles ([role="toolbar"], [role="dialog"])
under .pe-root. The Tailwind utilities are unprefixed, so if you also
run Tailwind with a customised theme, your .p-2 and ours are the same
selector and load order decides — load this stylesheet before your own.
Sizing
The root is a flex column with height: 100%. Give it a container with
a definite height (a flex child with min-height: 0, a fixed height, a
100dvh page) and the toolbar, page area and status bar lay themselves
out inside it.
Icons
Icons come from Remixicon. The component adds
its stylesheet from jsDelivr on mount unless one is already present;
pass loadIcons={false} and include it yourself to self-host:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/fonts/remixicon.css" />Beyond the UI
The worker RPC client is exported on its own for hosts that want the engine without the interface — rendering a thumbnail, extracting text, applying annotations headlessly:
import { PdfEngine } from "@xtract-bot/pdf-editor/engine";