npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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-editor

The 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";