Getting started
Install the engine, load a document, run an operation, and get bytes back.
Pagecraft is a PDF engine that runs where you call it — in a browser tab, in a Node process, in a worker. It opens no sockets and reads no filesystem. If you have bytes, it can work on them; what it hands back is bytes.
Install
Not on npm yet. Until it is, build it from source and depend on
packages/core by path:
git clone https://github.com/amanawasthi2025/pagecraft
cd pagecraft
pnpm install && pnpm build
Node 20.11 or newer. In a browser, bundle it like any other dependency — there is no build step, no WebAssembly to copy by hand for the common operations, and no configuration.
The three things there are
A Document is a file you have opened: a PDF, or an image, with its name, its size and its pages. Loading one parses enough to answer questions about it and no more.
An Operation is something you can do to Documents. There are 48 of them and
they all have the same shape, because an operation is a record in a registry
rather than a function you import: merge, split, redact, ocr,
pdf-to-docx.
An Artifact is a file an operation produced — a name, a media type, and the bytes. Ready to download, write to disk, or hand to the next operation.
Your first operation
import { loadDocument, runOperation } from '@pagecraft/core';
const cover = await loadDocument({ bytes: first, name: 'cover.pdf' });
const body = await loadDocument({ bytes: second, name: 'body.pdf' });
const [report] = await runOperation('merge', [cover, body], { filename: 'report.pdf' });
console.log(report.name, report.bytes.byteLength);
bytes is a Uint8Array. Where it comes from is your business — a fetch, a
file input, readFile — and the engine deliberately has no opinion about it,
because the moment it had one it would need a filesystem or a network.
In Node:
import { readFile, writeFile } from 'node:fs/promises';
const source = await loadDocument({
bytes: new Uint8Array(await readFile('report.pdf')),
name: 'report.pdf',
});
const [smaller] = await runOperation('compress', [source], { mode: 'quality' });
await writeFile('report-small.pdf', smaller.bytes);
In a browser:
const file = input.files[0];
const document = await loadDocument({
bytes: new Uint8Array(await file.arrayBuffer()),
name: file.name,
});
Finding the operation you want
import { listOperations, describeOperation } from '@pagecraft/core';
for (const operation of listOperations()) {
console.log(operation.id, '—', operation.summary);
}
describeOperation('split');
// { id, title, summary, category, slug, keywords, inputs, outputs, options, limits }
describeOperation returns everything about an operation except the code that
performs it, which is what the reference pages, the CLI’s help and the web app’s
forms are all built from. The reference page for each operation lists its
options, their defaults, and a worked example that the test suite runs.
Options are typed, and validated
The options an operation takes are declared once, as a schema. That single
declaration is the runtime validator and the TypeScript type you see, so your
editor knows --mode may be every, after or parts before anything runs,
and a wrong value fails with a sentence rather than a stack trace.
const parts = await runOperation('split', [source], { mode: 'every', size: 2 });
// parts.length === 3, for a six-page document
Strings are coerced by default, because a form and a command line both produce
them. Pass coerce: false if you would rather insist a caller has already
typed its input:
await runOperation('split', [source], { size: '2' }, { coerce: false });
// InvalidOptionsError: 'size' must be a number, but received a string.
Progress and cancellation
Operations on a large document take time. Both of the things you need for that live in the fourth argument:
const controller = new AbortController();
cancelButton.onclick = () => controller.abort();
const [searchable] = await runOperation(
'ocr',
[scan],
{ languages: ['eng'] },
{
signal: controller.signal,
onProgress: ({ value, message }) => {
bar.value = value; // 0 to 1, never backwards, always ends at 1
label.textContent = message; // 'Read page 4 of 12'
},
capabilities: { recognizer, rasterizer },
},
);
Progress an operation reports is advisory; progress you receive is clamped and monotonic, so nothing you write has to defend against a bar that goes backwards.
Capabilities: what the engine will not do for itself
Some work needs something the engine deliberately does not carry: turning a page into a picture, reading text out of a scan, embedding a font for a script the standard fourteen cannot set, or telling pdf.js where its character maps are. Those arrive as capabilities, from the host:
await runOperation(
'pdf-to-image',
[source],
{ dpi: 150 },
{
capabilities: { rasterizer },
},
);
This is not ceremony. A rasterizer is a canvas in a browser and a native library in Node; a recognizer is several megabytes somebody has to agree to download. Wiring them in as capabilities is what lets one package be honest in both places, and what lets an operation that needs one say so rather than failing halfway.
Which capabilities an operation needs is on its reference page. Most need none.
Errors say what to do
import { PagecraftError } from '@pagecraft/core';
try {
await runOperation('unlock', [locked], { password });
} catch (error) {
if (error instanceof PagecraftError) {
console.error(error.message); // what happened
console.error(error.remedy); // what to do about it
}
}
Every error the engine raises carries a code and, where there is one, a remedy — because an error message that only says what went wrong leaves the reader exactly where they were.
Where to go next
- The command line — the same engine, in a shell script.
- Recipes — several operations in one pass, over many files.
- The AI layer — with a key you supply, or a model on your own machine.
- The reference — every operation, its options, and an example that is executed.