Skip to content

Repository files navigation

pdfstudio

npm license

A client-side PDF toolkit for the browser. Lock, unlock, change or remove passwords, merge, split, extract, and rotate PDFs — with nothing leaving the device. Powered by qpdf compiled to WebAssembly, wrapped in a small, fully typed TypeScript API.

import { createPdfToolkit } from 'pdfstudio';

const pdf = await createPdfToolkit();

const locked   = await pdf.lock(file, { userPassword: 'hunter2' });
const unlocked = await pdf.unlock(locked, { password: 'hunter2' });
const merged   = await pdf.merge([a, b, c]);
const pages    = await pdf.split(merged);            // one Uint8Array per page
const rotated  = await pdf.rotate(file, { angle: 90 });

Why

Every "unlock PDF" / "merge PDF" site on the internet uploads your documents to someone's server. That's a strange default for what is fundamentally a local file transformation — often of documents (contracts, statements, IDs) you'd least want to upload. The web platform is perfectly capable of doing this work itself; what was missing was a proper library.

pdfstudio is qpdf — the same battle-tested C++ engine Linux distributions have shipped for 15+ years — compiled to a 2.1 MB WebAssembly binary, with a typesafe API in front of it. Everything runs in the page (or a worker, or Node). No uploads, no server, no telemetry.

Features

🔒 Lock AES-256 encryption (also 128-bit AES and legacy 40-bit RC4), user + owner passwords, granular permissions (printing, modification, extraction)
🔓 Unlock Decrypt with the user or owner password, removing all restrictions
🔁 Change password Re-encrypt with a new password in a single pass
🧹 Remove password Produce an unencrypted copy
Merge Combine whole documents or page selections, mixing encrypted and plain sources
✂️ Split One document per page, or N pages per document
🎯 Extract pages Pull any page selection into a new document
🔄 Rotate Relative or absolute rotation, per page range
🗑 Delete pages Remove a selection, keep the rest — plus reverse page order
🂠 Collate Interleave pages from multiple documents (fronts + backs of a scan)
💧 Watermark Overlay or underlay pages from another PDF — stamps, letterheads
🗜 Compress Lossless stream recompression + object streams; linearize for fast web view
🩹 Repair Reconstruct damaged cross-reference tables and recoverable corruption
📎 Attachments Add, list, extract, and remove embedded files
🫓 Flatten Bake annotations & form fields into page content
🔍 Inspect getInfo(): PDF version, page count, encryption scheme & permissions, attachments
🖼 Images → PDF Build a PDF from JPEGs in pure TypeScript (no recompression)
🛠 Escape hatch Run any qpdf CLI invocation via raw()

Works in browsers (main thread or Web Worker), in Node.js ≥ 18, and on Cloudflare Workers — same API everywhere.

Install

npm install pdfstudio

The package ships ESM + type declarations + qpdf.wasm. Modern bundlers (Vite, webpack 5, Rollup, esbuild) pick up the wasm asset automatically via import.meta.url. If yours doesn't, or you serve the wasm from a CDN, pass the location explicitly:

const pdf = await createPdfToolkit({ wasmUrl: '/assets/qpdf.wasm' });

Usage

Setup

import { createPdfToolkit } from 'pdfstudio';

// Loads + compiles the wasm once (~2 MB). Create one and reuse it.
const pdf = await createPdfToolkit();

Every operation accepts a Uint8Array, ArrayBuffer, or Blob/File (straight from <input type="file"> or drag-and-drop) and resolves to a Uint8Array of the resulting document:

const input = document.querySelector<HTMLInputElement>('#file');
const bytes = await pdf.rotate(input.files[0], { angle: 90 });

// Download it:
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));

Lock (encrypt)

const locked = await pdf.lock(doc, {
  userPassword: 'open-me',        // required to open the file
  ownerPassword: 'admin-only',    // full access; defaults to userPassword
  keyLength: 256,                 // 256 (default) | 128 | 40
  permissions: {
    print: 'low',                 // 'full' | 'low' | 'none'
    modify: 'none',               // 'all' | 'annotate' | 'form' | 'assembly' | 'none'
    extract: false,               // allow copying text/images
  },
});

An empty userPassword: '' with a real ownerPassword creates a file that opens without a prompt but still enforces the permission restrictions.

Unlock / remove password / change password

const open = await pdf.unlock(locked, { password: 'open-me' });
// removePassword() is an alias of unlock()

const rekeyed = await pdf.changePassword(locked, {
  currentPassword: 'open-me',
  newPassword: 'new-secret',
});

A wrong password rejects with PdfPasswordError (a subclass of PdfError), so you can distinguish "bad password" from "corrupt file":

import { PdfPasswordError } from 'pdfstudio';

try {
  await pdf.unlock(doc, { password: guess });
} catch (e) {
  if (e instanceof PdfPasswordError) askAgain();
  else throw e;
}

Merge

// Whole documents, in order:
const merged = await pdf.merge([a, b, c]);

// Page selections, including encrypted sources:
const report = await pdf.merge([
  { data: cover },
  { data: body, pages: '2-9' },
  { data: appendix, password: 'pw', pages: ['1', 'z'] }, // z = last page
]);

Split & extract

const singlePages = await pdf.split(doc);                    // Uint8Array[]
const chunks      = await pdf.split(doc, { pagesPerFile: 10 });
const excerpt     = await pdf.extractPages(doc, { pages: '2-5,9' });

Rotate

await pdf.rotate(doc, { angle: 90 });                  // all pages, clockwise
await pdf.rotate(doc, { angle: -90, pages: '1-3' });   // counter-clockwise
await pdf.rotate(doc, { angle: 180, absolute: true }); // set exact rotation

Delete, reverse, collate

const trimmed  = await pdf.deletePages(doc, { pages: '2-3' });
const backward = await pdf.reversePages(doc);

// Interleave: page 1 of A, page 1 of B, page 2 of A, … Great for
// combining separately scanned fronts and backs:
const combined = await pdf.collate([fronts, { data: backs, pages: 'z-1' }]);

Watermark / stamp

Overlay (or underlay) pages from another PDF. repeat: 1 tiles a single-page stamp across the whole document:

const stamped = await pdf.watermark(doc, confidentialStamp, { repeat: 1 });
const letterheaded = await pdf.watermark(doc, letterhead, {
  mode: 'underlay',   // draw behind the page content
  to: '1',            // first page only
});

Compress, linearize, repair

const smaller = await pdf.compress(doc);                    // lossless
const fast    = await pdf.linearize(doc);                   // fast web view
const fixed   = await pdf.repair(brokenDoc);                // rebuild xref

Attachments

const withFile = await pdf.addAttachment(doc, {
  data: jsonBytes,
  name: 'invoice.json',
  mimeType: 'application/json',
});
await pdf.listAttachments(withFile);                        // [{ name: 'invoice.json', … }]
const bytes = await pdf.getAttachment(withFile, { name: 'invoice.json' });
const clean = await pdf.removeAttachment(withFile, { name: 'invoice.json' });

Flatten

Bake annotations and form-field appearances into the page content — useful before printing, splitting, or sharing:

const flat = await pdf.flatten(doc);

Inspect

await pdf.pageCount(doc);          // number
await pdf.isEncrypted(doc);        // boolean
await pdf.requiresPassword(doc);   // false for empty-user-password files

const info = await pdf.getInfo(locked, { password: 'pw' });
// {
//   pdfVersion: '2.0', pageCount: 12, encrypted: true,
//   encryption: {
//     bits: 256, method: 'AESv3',
//     userPasswordMatched: true, ownerPasswordMatched: false,
//     permissions: { print: false, extract: false, modify: false, … },
//   },
//   attachments: [],
// }

Images → PDF

imagesToPdf needs no wasm at all — JPEG data is embedded verbatim (no recompression, no quality loss), one page per image:

import { imagesToPdf } from 'pdfstudio';

const album = await imagesToPdf([scan1, scan2, photo], { dpi: 300 });

Escape hatch

Anything else qpdf can do is reachable through raw(). Inputs are staged as $in0, $in1, …; write output to $out:

// Two-up page layout? n-up is about the only thing qpdf can't do —
// but e.g. splitting into groups of pages after each bookmark, etc.:
const out = await pdf.raw([doc], ['--pages', '$in0', '1-z:odd', '--', '--empty', '$out']);

Page selections

Anywhere a pages option appears, use qpdf's page range syntax:

Selection Meaning
5 page 5
'1-5' pages 1–5
'1,3,5-9' union, in order
'z' last page
'r2' second-to-last
'z-1' all pages, reversed
'1-9:odd' odd positions within the range
'1-z,x3-4' everything except pages 3–4
[1, '4-8', 'z'] arrays mix numbers and ranges

Web Workers

Operations are synchronous inside the wasm and run on the calling thread. For large documents, load the toolkit inside a Worker to keep the UI responsive — the API works there unchanged, and Uint8Array results transfer cheaply via postMessage.

Cloudflare Workers

Works on the edge too. Workers forbid runtime wasm compilation, so import the wasm as a module (compiled at deploy time) and pass it in:

import { createPdfToolkit } from 'pdfstudio';
import qpdfWasm from 'pdfstudio/qpdf.wasm';

const pdf = await createPdfToolkit({ wasmModule: qpdfWasm });

A runnable example (self-test route + a POST /unlock endpoint) lives in examples/cloudflare-worker:

cd examples/cloudflare-worker
npm install
npm run dev     # wrangler dev → http://localhost:8787

Mind the platform limits: PDF work needs real CPU time (the paid tier's budget is comfortable, the free tier's ~10 ms is not), and MEMFS lives in the wasm heap, so very large documents press against the 128 MB memory cap.

Demo

npm install
npm run demo

Opens a small UI exercising every operation: drop PDFs in, lock/unlock, merge, split, rotate, download results. All local.

Building the wasm from source

The published package includes the compiled qpdf.wasm; you only need this to upgrade qpdf or change build flags.

Requirements: Emscripten and CMake (brew install emscripten cmake).

npm run build:wasm   # downloads qpdf sources, compiles → src/wasm/
npm test             # 43 end-to-end tests through the real wasm
npm run build        # emits dist/ (ESM + d.ts + wasm)

Build details, for the curious:

  • qpdf 12.3.2 with its built-in native crypto provider — no OpenSSL in the binary; AES/SHA2/MD5/RC4 are qpdf's own implementations.
  • zlib and libjpeg come from Emscripten's ports.
  • Compiled with wasm-native exception handling (-fwasm-exceptions) since qpdf uses C++ exceptions for all error reporting.
  • Modularized ES6 output (-sMODULARIZE -sEXPORT_ES6) with an in-memory filesystem (MEMFS); each operation stages files in a scratch directory, invokes qpdf's CLI main(), and reads the result back.
  • Random data comes from the platform's CSPRNG (crypto.getRandomValues via Emscripten).

License

Apache-2.0, same as qpdf itself. The wasm binary also contains zlib (zlib license) and libjpeg (IJG license).

About

Client-side PDF toolkit — lock, unlock, merge, split & rotate PDFs in the browser. qpdf compiled to WebAssembly, fully typed, files never leave the device.

Topics

Resources

Stars

93 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages