toolfiler
v0.3.2
Published
PDF toolkit — merge, split, rotate, watermark, and more.
Maintainers
Readme
toolfiler
PDF toolkit. Merge, split, rotate, watermark, compress, and annotate
through PdfTools.
Install
npm install toolfilerQuick start
import { PdfTools } from "toolfiler";
const pdf = new PdfTools({
apiKey: "your-api-key",
});
await pdf.merge([
"./documents/one.pdf",
"./documents/two.pdf",
]);
await pdf.rotate("./input.pdf", {
pages: [1, 2],
degrees: 90,
});
await pdf.watermark("./input.pdf", {
text: "CONFIDENTIAL",
position: "diagonal",
});Sources, auth, and download
PdfSource is string | ArrayBuffer | Uint8Array | Blob (File is a Blob).
| Environment | What to pass |
|---|---|
| Node | File path ("./input.pdf") or bytes |
| Browser | URL (https:, blob:, data:), File/Blob, or bytes |
Page numbers are 1-indexed everywhere.
Every method except annotate() construction calls authorize() first:
the key is hashed and verified against ToolFiler. Invalid keys throw
UnauthorizedError and block the action. Verification is cached on the
PdfTools instance after the first success. annotate() only checks that
an apiKey is present; annotator.load() verifies the key.
Returns PDF bytes (Uint8Array) unless noted.
const bytes = await pdf.merge([fileA, fileB]);
await pdf.download(bytes, "merged.pdf"); // browser save-asTools
Merge
Concatenates PDFs in the order given.
const merged = await pdf.merge([
"./documents/one.pdf",
"./documents/two.pdf",
]);| | |
|---|---|
| Signature | merge(files: PdfSource[]): Promise<Uint8Array> |
| Throws | If files is empty |
Split
Three ways to cut a PDF. Each returns an array of PDF byte arrays.
const byRange = await pdf.split("./input.pdf", [
{ from: 1, to: 3 },
{ from: 4, to: 6 },
]);
const everyTwo = await pdf.splitEvery("./input.pdf", 2);
const oneEach = await pdf.splitPages("./input.pdf");| Method | |
|---|---|
| split(file, ranges) | One output PDF per { from, to } (inclusive) |
| splitEvery(file, pagesPerFile) | Chunks of N pages (last chunk may be smaller) |
| splitPages(file) | One single-page PDF per page |
Invalid ranges (from < 1, to past the last page, from > to) throw.
pagesPerFile must be at least 1.
Rotate
Relative rotation. Values must be a multiple of 90.
await pdf.rotate("./input.pdf", { degrees: 90 });
await pdf.rotate("./input.pdf", { pages: [1, 2], degrees: 180 });| Option | Type | Default | Notes |
|---|---|---|---|
| degrees | 90 \| 180 \| 270 \| -90 \| -180 \| -270 | required | Added to each page's current rotation |
| pages | number[] | all pages | 1-indexed |
Reorder
order is 1-indexed. Those pages are rearranged among the positions they
already occupy. Unlisted pages stay put. A full permutation still works.
// Pages 1,2,3 become 3,1,2; page 4+ unchanged
const reordered = await pdf.reorder("./input.pdf", [3, 1, 2]);Throws if order is empty, contains duplicates, or lists a page that
does not exist.
Delete pages
const trimmed = await pdf.deletePages("./input.pdf", [2, 4]);Duplicates are ignored. Throws if you would delete every page.
Watermark
Draws Helvetica-Bold text on each target page.
await pdf.watermark("./input.pdf", {
text: "CONFIDENTIAL",
position: "diagonal",
opacity: 0.3,
fontSize: 48,
color: "#888888",
});
await pdf.watermark("./input.pdf", {
text: "DRAFT",
layout: "tile",
pages: [1],
});| Option | Type | Default | Notes |
|---|---|---|---|
| text | string | required | |
| opacity | number | 0.3 | 0–1 |
| fontSize | number | 48 | PDF points |
| color | string | "#888888" | Hex |
| rotationDegrees | number | -45 | |
| layout | "center" \| "tile" | "center" | One diagonal mark, or a repeating grid |
| position | "diagonal" \| "center" \| "tile" | | Alias: "diagonal" → "center" |
| pages | number[] | all | 1-indexed |
Headers, footers, page numbers
{page} and {pages} are replaced per page.
await pdf.headerFooter("./input.pdf", {
header: "CONFIDENTIAL DRAFT",
footer: "Page {page} of {pages}",
align: "center",
fontSize: 10,
color: "#000000",
margin: 24,
});
await pdf.pageNumbers("./input.pdf");
await pdf.pageNumbers("./input.pdf", {
format: "{page}/{pages}",
align: "right",
});headerFooter throws if both header and footer are omitted.
| Option | Type | Default | Notes |
|---|---|---|---|
| header / footer | string | | At least one required |
| align | "left" \| "center" \| "right" | "center" | |
| fontSize | number | 10 | |
| color | string | "#000000" | Hex |
| margin | number | 24 | Distance from the page edge, PDF points |
| pages | number[] | all | 1-indexed |
pageNumbers is a shortcut: footer only, default format
"Page {page} of {pages}".
Compress
Structural / lossless only. Rebuilds the object graph, drops orphaned objects, writes object streams. Does not recompress or downsample embedded images.
const { bytes, originalSize, compressedSize, reduction } =
await pdf.compress("./input.pdf");reduction is 0–1 (how much smaller). Can be ~0 or slightly negative
on an already-optimized file.
Metadata
const meta = await pdf.metadata("./input.pdf");
const updated = await pdf.setMetadata("./input.pdf", {
title: "New Title",
author: "ToolFiler",
keywords: ["pdf", "tools"],
});| Field | Type |
|---|---|
| title, author, subject, creator, producer | string |
| keywords | string[] |
| creationDate, modificationDate | Date |
setMetadata changes only the fields you pass. Pass "" to clear a
string field. If you omit modificationDate, it is set to now.
Keywords round-trip as a space-joined string in the PDF spec, so
"machine learning" becomes two keywords.
Annotate
Browser-only. There is no React (or Vue, or Angular) component in this package. The engine is a DOM class: you give it a host element, it renders the PDF with pdf.js, and it draws markup on an SVG overlay. Any UI can wrap it.
const annotator = pdf.annotate(host, options);
await annotator.load();pdf.annotate uses the same apiKey as PdfTools. load() verifies that
key, fetches the PDF, renders every page, and restores any annotations from
saveAdapter.
Mount
<div id="viewer"></div>import { PdfTools } from "toolfiler";
const pdf = new PdfTools({ apiKey: "your-api-key" });
const host = document.querySelector<HTMLElement>("#viewer");
if (!host) throw new Error("Missing #viewer");
const annotator = pdf.annotate(host, {
source: pdfBytes, // URL string, ArrayBuffer, or Uint8Array
documentId: "doc-1", // key used by saveAdapter
initialTool: "pen",
initialColor: "#ef4444",
initialStrokeWidth: 3,
renderScale: 1.5,
autoSaveDebounceMs: 1500, // 0 disables autosave
});
annotator.on("loaded", ({ pageCount }) => {
console.log(`${pageCount} pages`);
});
annotator.on("invalid", ({ message }) => {
console.error(message);
});
annotator.on("error", ({ error }) => {
console.error(error);
});
await annotator.load();Pass a copy of the PDF bytes if you still need the original buffer elsewhere. pdf.js may transfer or detach the array you give it.
In React, Vue, or Angular, pass the native element (ref.current,
$el, ElementRef.nativeElement). Do not import a React annotator from
this package — there isn't one.
// React (host app owns the component)
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const annotator = pdf.annotate(host, {
source,
documentId,
initialTool: "pen",
});
annotator.load().catch(onError);
return () => annotator.destroy();
}, [pdf, source, documentId]);Options (pdf.annotate)
apiKey is taken from PdfTools. Do not pass it here.
| Option | Type | Default | Notes |
|---|---|---|---|
| source | string \| ArrayBuffer \| Uint8Array | required | URL to fetch, or raw PDF bytes |
| documentId | string | required | Passed to saveAdapter.load / save |
| saveAdapter | SaveAdapter | in-memory | Persist markup yourself; see below |
| renderScale | number | 1.5 | pdf.js canvas scale (display only) |
| autoSaveDebounceMs | number | 1500 | 0 or less skips autosave |
| initialTool | ToolName | "pen" | Active tool before the first setTool |
| initialColor | string | "#ef4444" | CSS color for new markup |
| initialStrokeWidth | number | 3 | Stroke in unscaled PDF points |
Tools
| Tool | setTool(...) | Interaction |
|---|---|---|
| Pen | "pen" | Drag to draw a freehand stroke |
| Highlighter | "highlighter" | Same as pen, wider and 35% opacity |
| Rectangle | "rect" | Drag a box |
| Text | "text" | Click to place a text box, then type |
| Eraser | "eraser" | Click existing markup to remove it |
| Select | "select" | No drawing (reserved) |
annotator.setTool("highlighter");
annotator.getTool(); // "highlighter"
annotator.setColor("#f59e0b");
annotator.setStrokeWidth(4);setTool sets data-ToolFiler-tool on the host so you can style the
cursor. It also emits toolChanged.
Markup
Coordinates are stored in unscaled PDF point space (pdf.js viewport
scale 1), not CSS pixels.
type ToolName = "select" | "pen" | "highlighter" | "text" | "rect" | "eraser";
type Annotation = DrawAnnotation | TextAnnotation | RectAnnotation;- draw —
points[],strokeWidth, optionalhighlighter - text —
x,y,width,text,fontSize - rect —
x,y,width,height,strokeWidth
Every annotation also has id, page (1-based), color, createdAt,
updatedAt, and optional meta.
const all = annotator.exportAnnotations();
annotator.importAnnotations(all);
annotator.removeAnnotation(all[0].id);
annotator.clearPage(1);
annotator.clearAll();
annotator.undo();
annotator.redo();importAnnotations replaces the current set. History is a full snapshot
stack; undo / redo restore those snapshots.
Export and download
Markup is an overlay until you flatten it. Flattening draws every annotation onto the PDF pages (a real PDF edit, not a screenshot).
const original = await annotator.exportPdfBytes(false);
const withChanges = await annotator.exportPdfBytes(true);
await annotator.downloadOriginal("document.pdf");
await annotator.downloadAnnotated("document-with-changes.pdf");Call load() before export. pdf.download(bytes, "out.pdf") also works
on the bytes you export.
Persist annotations (SaveAdapter)
By default, markup lives in memory for this annotator instance. To keep it
across reloads, pass saveAdapter:
const annotator = pdf.annotate(host, {
source: pdfBytes,
documentId: "invoice-42",
saveAdapter: {
async load(documentId) {
const res = await fetch(`/api/annotations/${documentId}`);
if (!res.ok) return [];
return res.json();
},
async save(documentId, annotations) {
await fetch(`/api/annotations/${documentId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(annotations),
});
},
},
});load() is called once during annotator.load(). save() is called after
edits, debounced by autoSaveDebounceMs, and immediately from
annotator.save().
The hosted ToolFiler dashboard does not store annotation JSON. It keeps
markup in the browser and, if the user chooses, saves flattened PDF bytes.
Use saveAdapter only when your app should persist the overlay.
Events
annotator.on("loaded", ({ pageCount }) => { /* pages rendered */ });
annotator.on("invalid", ({ message }) => { /* bad API key */ });
annotator.on("error", ({ error }) => { /* load/render/adapter failure */ });
annotator.on("annotationAdded", ({ annotation }) => {});
annotator.on("annotationRemoved", ({ id }) => {});
annotator.on("annotationUpdated", ({ annotation }) => {});
annotator.on("toolChanged", ({ tool }) => {});
annotator.on("saving", () => {});
annotator.on("saved", () => {});
annotator.on("saveError", ({ error }) => {});
annotator.off("loaded", handler);CSS hooks
The engine adds markup to your host. Style it from the embedding app:
| Hook | Where |
|---|---|
| .ToolFiler-root | Host element |
| [data-ToolFiler-tool] | Current tool (pen, highlighter, …) |
| .ToolFiler-page | One page wrapper (canvas + SVG overlay) |
| [data-ToolFiler-hit] | Invisible full-page hit target on the SVG |
Suggested styles so empty pages still receive strokes:
.ToolFiler-root {
position: relative;
}
.ToolFiler-page {
position: relative;
cursor: crosshair;
touch-action: none;
}
.ToolFiler-page canvas {
pointer-events: none;
}
.ToolFiler-page svg {
pointer-events: bounding-box;
}
.ToolFiler-root[data-ToolFiler-tool="text"] .ToolFiler-page {
cursor: text;
}
.ToolFiler-root[data-ToolFiler-tool="eraser"] .ToolFiler-page {
cursor: pointer;
}Do not put pointer-events: none on descendant svg elements of the
viewer (some UI kits do this on buttons). The overlay must receive
pointer events or drawing never starts.
Lifecycle
await annotator.load();
// … tools, export, save …
annotator.destroy(); // clears the host, drops listeners, stops autosaveCreate one annotator per document. If the PDF bytes change, destroy() the
old instance and call pdf.annotate again.
new PdfTools(options)
| Option | Type | Notes |
|---|---|---|
| apiKey | string | required — missing or invalid keys throw Unauthorized and block every action |
TypeScript
import {
PdfTools,
PdfAnnotator,
UnauthorizedError,
flattenAnnotationsOntoPdf,
downloadPdfBytes,
} from "toolfiler";
import type {
PdfToolsOptions,
PdfSource,
PageRange,
RotateOptions,
WatermarkOptions,
HeaderFooterOptions,
CompressResult,
PdfMetadata,
PdfAnnotatorOptions,
PdfAnnotatorEvent,
SaveAdapter,
ToolName,
Annotation,
DrawAnnotation,
TextAnnotation,
RectAnnotation,
Point,
} from "toolfiler";