Skip to main content
Technology & EngineeringDocument Generation Services373 lines

PDF Lib

"pdf-lib: create and modify PDFs in JavaScript, form filling, page manipulation, embedding images/fonts, digital signatures"

Quick Summary18 lines
pdf-lib operates at the PDF specification level, giving you direct control over the document structure without a rendering engine. It creates and modifies PDFs purely in JavaScript with zero native dependencies, making it the lightest option for PDF manipulation. Choose pdf-lib when you need to fill forms, merge documents, add watermarks, manipulate existing PDFs, or build documents programmatically without HTML. It runs identically in Node and the browser. The trade-off is that you handle all layout math yourself; there is no CSS or flexbox engine.

## Key Points

- Use `StandardFonts` when custom typography is not required; they are built into the PDF spec and add zero bytes to file size.
- Register fontkit only when embedding custom fonts; it is not needed for standard fonts.
- Call `form.flatten()` after filling forms to prevent end users from editing the filled values.
- Use `doc.copyPages()` to move pages between documents; never try to add a page object from one document directly to another.
- Set document metadata (`setTitle`, `setAuthor`, `setCreationDate`) for accessibility and organization.
- For large merge operations, process documents sequentially to keep memory usage predictable.
- Use `PDFDocument.load(bytes, { ignoreEncryption: true })` only when you have the legal right to process the encrypted document.
- Prefer `Uint8Array` over `Buffer` for cross-platform compatibility between Node and browser.
- **Manual text wrapping with hardcoded widths.** Use `font.widthOfTextAtSize()` to measure text and compute line breaks dynamically. Hardcoded positions break when content length varies.
- **Embedding the same font multiple times.** Embed once and reuse the `PDFFont` reference across all pages. Duplicate embedding bloats file size.
- **Loading entire large PDFs into memory for single-page extraction.** For very large files, consider streaming-capable libraries or processing page ranges.
- **Using pdf-lib for complex HTML layouts.** pdf-lib has no layout engine. If you need to render HTML or complex flowing text, use Puppeteer or React-PDF instead.
skilldb get document-generation-services-skills/PDF LibFull skill: 373 lines
Paste into your CLAUDE.md or agent config

pdf-lib Document Generation

Core Philosophy

pdf-lib operates at the PDF specification level, giving you direct control over the document structure without a rendering engine. It creates and modifies PDFs purely in JavaScript with zero native dependencies, making it the lightest option for PDF manipulation. Choose pdf-lib when you need to fill forms, merge documents, add watermarks, manipulate existing PDFs, or build documents programmatically without HTML. It runs identically in Node and the browser. The trade-off is that you handle all layout math yourself; there is no CSS or flexbox engine.

Setup

// package.json dependencies
// "pdf-lib": "^1.17.1"
// "@pdf-lib/fontkit": "^1.1.1"  (for custom font embedding)

import {
  PDFDocument,
  PDFPage,
  PDFFont,
  StandardFonts,
  rgb,
  degrees,
  PageSizes,
  PDFTextField,
  PDFCheckBox,
} from "pdf-lib";
import fontkit from "@pdf-lib/fontkit";
import { readFile, writeFile } from "fs/promises";

// Helper: load a custom font into a document
async function embedCustomFont(
  doc: PDFDocument,
  fontPath: string
): Promise<PDFFont> {
  doc.registerFontkit(fontkit);
  const fontBytes = await readFile(fontPath);
  return doc.embedFont(fontBytes);
}

Key Techniques

Creating a Document from Scratch

async function createInvoicePdf(data: InvoiceData): Promise<Uint8Array> {
  const doc = await PDFDocument.create();
  doc.setTitle(`Invoice ${data.number}`);
  doc.setAuthor(data.companyName);
  doc.setCreationDate(new Date());

  const font = await doc.embedFont(StandardFonts.Helvetica);
  const fontBold = await doc.embedFont(StandardFonts.HelveticaBold);
  const page = doc.addPage(PageSizes.A4);
  const { width, height } = page.getSize();

  let y = height - 50;

  // Header
  page.drawText(`INVOICE #${data.number}`, {
    x: 50,
    y,
    size: 24,
    font: fontBold,
    color: rgb(0.06, 0.09, 0.16),
  });
  y -= 30;

  page.drawText(`Date: ${data.date}`, { x: 50, y, size: 11, font });
  y -= 16;
  page.drawText(`Customer: ${data.customer.name}`, { x: 50, y, size: 11, font });
  y -= 30;

  // Divider line
  page.drawLine({
    start: { x: 50, y },
    end: { x: width - 50, y },
    thickness: 1,
    color: rgb(0.88, 0.91, 0.96),
  });
  y -= 20;

  // Table header
  const cols = { desc: 50, qty: 340, price: 410, total: 480 };
  for (const [label, x] of Object.entries({
    Description: cols.desc,
    Qty: cols.qty,
    Price: cols.price,
    Total: cols.total,
  })) {
    page.drawText(label, { x, y, size: 10, font: fontBold });
  }
  y -= 18;

  // Table rows
  for (const item of data.items) {
    page.drawText(item.description, { x: cols.desc, y, size: 10, font });
    page.drawText(String(item.qty), { x: cols.qty, y, size: 10, font });
    page.drawText(`$${item.price.toFixed(2)}`, { x: cols.price, y, size: 10, font });
    page.drawText(`$${(item.qty * item.price).toFixed(2)}`, {
      x: cols.total,
      y,
      size: 10,
      font,
    });
    y -= 16;
  }

  // Total
  y -= 10;
  const total = data.items.reduce((s, i) => s + i.qty * i.price, 0);
  page.drawText(`Total: $${total.toFixed(2)}`, {
    x: cols.total - 30,
    y,
    size: 14,
    font: fontBold,
  });

  return doc.save();
}

interface InvoiceData {
  number: string;
  date: string;
  companyName: string;
  customer: { name: string };
  items: Array<{ description: string; qty: number; price: number }>;
}

Form Filling

async function fillTaxForm(
  templatePath: string,
  data: Record<string, string | boolean>
): Promise<Uint8Array> {
  const templateBytes = await readFile(templatePath);
  const doc = await PDFDocument.load(templateBytes);
  const form = doc.getForm();

  for (const [fieldName, value] of Object.entries(data)) {
    if (typeof value === "boolean") {
      const checkbox = form.getCheckBox(fieldName);
      value ? checkbox.check() : checkbox.uncheck();
    } else {
      const textField = form.getTextField(fieldName);
      textField.setText(value);
    }
  }

  // Flatten form fields so they become static content
  form.flatten();

  return doc.save();
}

// Usage
const filled = await fillTaxForm("./templates/w9.pdf", {
  name: "Acme Corporation",
  business_name: "Acme Corp LLC",
  address: "123 Main St",
  city_state_zip: "San Francisco, CA 94102",
  ein: "12-3456789",
  is_llc: true,
});
await writeFile("./output/w9-filled.pdf", filled);

Merging PDFs

async function mergePdfs(pdfPaths: string[]): Promise<Uint8Array> {
  const merged = await PDFDocument.create();

  for (const path of pdfPaths) {
    const bytes = await readFile(path);
    const source = await PDFDocument.load(bytes);
    const copiedPages = await merged.copyPages(
      source,
      source.getPageIndices()
    );
    for (const page of copiedPages) {
      merged.addPage(page);
    }
  }

  return merged.save();
}

// Merge with selective page extraction
async function extractPages(
  sourcePath: string,
  pageNumbers: number[]
): Promise<Uint8Array> {
  const sourceBytes = await readFile(sourcePath);
  const source = await PDFDocument.load(sourceBytes);
  const output = await PDFDocument.create();

  // pageNumbers are 1-based for the caller, convert to 0-based
  const indices = pageNumbers.map((n) => n - 1);
  const copiedPages = await output.copyPages(source, indices);
  for (const page of copiedPages) {
    output.addPage(page);
  }

  return output.save();
}

Adding Watermarks

async function addWatermark(
  pdfBytes: Uint8Array,
  watermarkText: string
): Promise<Uint8Array> {
  const doc = await PDFDocument.load(pdfBytes);
  const font = await doc.embedFont(StandardFonts.HelveticaBold);
  const pages = doc.getPages();

  for (const page of pages) {
    const { width, height } = page.getSize();
    const textWidth = font.widthOfTextAtSize(watermarkText, 60);

    page.drawText(watermarkText, {
      x: (width - textWidth) / 2,
      y: height / 2,
      size: 60,
      font,
      color: rgb(0.85, 0.85, 0.85),
      rotate: degrees(-45),
      opacity: 0.3,
    });
  }

  return doc.save();
}

Embedding Images

async function addLetterhead(
  pdfBytes: Uint8Array,
  logoPath: string
): Promise<Uint8Array> {
  const doc = await PDFDocument.load(pdfBytes);
  const logoBytes = await readFile(logoPath);

  // Detect format and embed accordingly
  const logo = logoPath.endsWith(".png")
    ? await doc.embedPng(logoBytes)
    : await doc.embedJpg(logoBytes);

  const logoDims = logo.scale(0.25);
  const pages = doc.getPages();

  for (const page of pages) {
    const { width } = page.getSize();
    page.drawImage(logo, {
      x: width - logoDims.width - 40,
      y: page.getHeight() - logoDims.height - 20,
      width: logoDims.width,
      height: logoDims.height,
    });
  }

  return doc.save();
}

Custom Fonts

async function createStyledDocument(): Promise<Uint8Array> {
  const doc = await PDFDocument.create();
  doc.registerFontkit(fontkit);

  const interBytes = await readFile("./fonts/Inter-Regular.ttf");
  const interBoldBytes = await readFile("./fonts/Inter-Bold.ttf");
  const inter = await doc.embedFont(interBytes);
  const interBold = await doc.embedFont(interBoldBytes);

  const page = doc.addPage(PageSizes.A4);
  let y = page.getHeight() - 60;

  page.drawText("Custom Font Document", {
    x: 50,
    y,
    size: 22,
    font: interBold,
    color: rgb(0.06, 0.09, 0.16),
  });
  y -= 30;

  page.drawText("This body text uses the Inter typeface for clean rendering.", {
    x: 50,
    y,
    size: 11,
    font: inter,
    color: rgb(0.1, 0.1, 0.1),
    maxWidth: 500,
    lineHeight: 16,
  });

  return doc.save();
}

Page Manipulation Utilities

async function rotatePagesLandscape(
  pdfBytes: Uint8Array
): Promise<Uint8Array> {
  const doc = await PDFDocument.load(pdfBytes);

  for (const page of doc.getPages()) {
    const currentRotation = page.getRotation().angle;
    page.setRotation(degrees(currentRotation + 90));
  }

  return doc.save();
}

async function addPageNumbers(pdfBytes: Uint8Array): Promise<Uint8Array> {
  const doc = await PDFDocument.load(pdfBytes);
  const font = await doc.embedFont(StandardFonts.Helvetica);
  const pages = doc.getPages();
  const total = pages.length;

  pages.forEach((page, index) => {
    const { width } = page.getSize();
    const text = `${index + 1} / ${total}`;
    const textWidth = font.widthOfTextAtSize(text, 9);

    page.drawText(text, {
      x: (width - textWidth) / 2,
      y: 20,
      size: 9,
      font,
      color: rgb(0.6, 0.6, 0.6),
    });
  });

  return doc.save();
}

Best Practices

  • Use StandardFonts when custom typography is not required; they are built into the PDF spec and add zero bytes to file size.
  • Register fontkit only when embedding custom fonts; it is not needed for standard fonts.
  • Call form.flatten() after filling forms to prevent end users from editing the filled values.
  • Use doc.copyPages() to move pages between documents; never try to add a page object from one document directly to another.
  • Set document metadata (setTitle, setAuthor, setCreationDate) for accessibility and organization.
  • For large merge operations, process documents sequentially to keep memory usage predictable.
  • Use PDFDocument.load(bytes, { ignoreEncryption: true }) only when you have the legal right to process the encrypted document.
  • Prefer Uint8Array over Buffer for cross-platform compatibility between Node and browser.

Anti-Patterns

  • Manual text wrapping with hardcoded widths. Use font.widthOfTextAtSize() to measure text and compute line breaks dynamically. Hardcoded positions break when content length varies.
  • Embedding the same font multiple times. Embed once and reuse the PDFFont reference across all pages. Duplicate embedding bloats file size.
  • Modifying page content without understanding coordinate origin. PDF coordinates start at the bottom-left, not top-left. Placing elements with Y values counting from the top requires subtracting from page.getHeight().
  • Loading entire large PDFs into memory for single-page extraction. For very large files, consider streaming-capable libraries or processing page ranges.
  • Using pdf-lib for complex HTML layouts. pdf-lib has no layout engine. If you need to render HTML or complex flowing text, use Puppeteer or React-PDF instead.
  • Forgetting to call doc.save(). All modifications are in memory until save() serializes them. Returning the original bytes instead of the save result is a common bug.

Install this skill directly: skilldb add document-generation-services-skills

Get CLI access →