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

toolfiler

v0.3.2

Published

PDF toolkit — merge, split, rotate, watermark, and more.

Readme

toolfiler

PDF toolkit. Merge, split, rotate, watermark, compress, and annotate through PdfTools.

Install

npm install toolfiler

Quick 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-as

Tools

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, optional highlighter
  • 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 autosave

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