sempdf
Guide

Examples

These are complete TypeScript examples that complement the docs playground. Each one is a complete, self-contained program: it imports from @crstnmac/sempdf, builds a document, and export defaults it. Paste any of them into the playground (or a local script) and run it as-is.

The snippets below are embedded directly from the repository's sample files.

Run these

Open the playground to experiment with Document JSON, or copy a TypeScript snippet into your own project. For non-browser usage, swap export default doc; for await writeFile("out.pdf", doc.toUint8Array());.

Rich text & layout

Document-wide text defaults, registered font families, mm() units, inline rich text with underline/strike, justified alignment, vertical alignment, shape helpers (rect, circle), named colors, and richParagraph template blocks. The flowing portion is rendered with renderTemplate().

import { createDocument, rgb, mm } from "@crstnmac/sempdf";

const TABLE_BORDER = rgb(0.72, 0.76, 0.82);
const TABLE_HEADER = rgb(0.92, 0.94, 0.98);

// Document-wide text defaults are inherited by every text call.
const doc = createDocument({
  title: "Rich Text & Layout",
  language: "en-US",
  defaults: { font: "Helvetica", fontSize: 12, color: rgb(0.15, 0.15, 0.2) }
});

// Register a font family so bold/italic resolve by flag. Faces may be built-in
// names (shown here), embedded font handles, or raw font bytes.
const sans = doc.registerFontFamily("Sans", {
  regular: "Helvetica",
  bold: "Helvetica-Bold",
  italic: "Helvetica-Oblique",
  boldItalic: "Helvetica-BoldOblique"
});

const page = doc.addPage({ size: "A4" });

// mm() units, chained calls, bold/italic flags, and underline/strike decoration.
page
  .text("Rich text & layout", { x: mm(20), y: mm(272), font: sans, bold: true, fontSize: 22 })
  .text("Authored with mm() units and chained calls.", { x: mm(20), y: mm(264), italic: true, color: "navy" })
  .text("Underlined and struck-through runs.", { x: mm(20), y: mm(257), underline: true, strike: true, fontSize: 11 });

// Inline rich text - mixed styles and an inline link wrapping together.
page.richText(
  [
    { text: "Inline rich text mixes " },
    { text: "bold", bold: true },
    { text: ", " },
    { text: "italic", italic: true },
    { text: ", " },
    { text: "color", color: rgb(0.80, 0.10, 0.10) },
    { text: ", " },
    { text: "underline", underline: true },
    { text: ", and a " },
    { text: "clickable link", color: rgb(0.10, 0.30, 0.80), link: "https://sempdf.criston.dev" },
    { text: " on the same wrapping lines." }
  ],
  { x: mm(20), y: mm(247), width: mm(170), fontSize: 13, lineHeight: 18 }
);

// Justified alignment for rich text.
page.richText(
  [{ text: "Justify spreads words across the full measure so both edges of the column line up cleanly along its width." }],
  { x: mm(20), y: mm(227), width: mm(170), fontSize: 12, align: "justify" }
);

// Vertical alignment inside a fixed-height box (framed with a 1.3 shape helper).
page.rect(mm(20), mm(165), mm(80), mm(40), { stroke: rgb(0.7, 0.7, 0.7), lineWidth: 0.5 });
page.textBlock("Centered in a 40mm box (verticalAlign: middle).", {
  x: mm(22), y: mm(203), width: mm(76), height: mm(36),
  verticalAlign: "middle", align: "center", fontSize: 12, lineHeight: 16
});

// Shape helpers: a labelled circle alongside the box.
page.circle(mm(140), mm(185), mm(18), { fill: "teal" });
page.text("circle()", { x: mm(130), y: mm(150), fontSize: 11, color: rgb(0.45, 0.45, 0.45) });

// Per-cell vertical alignment in a table row.
page.table(
  [[
    { text: "A long cell that wraps onto a few lines inside its column to make the row tall." },
    { text: "top", verticalAlign: "top", header: true },
    { text: "middle", verticalAlign: "middle", header: true },
    { text: "bottom", verticalAlign: "bottom", header: true }
  ]],
  {
    x: mm(20),
    y: mm(120),
    width: mm(170),
    columnWidths: [mm(85), mm(28), mm(28), mm(29)],
    fontSize: 11,
    cellPadding: 4,
    borders: "all",
    borderColor: TABLE_BORDER,
    headerBackground: TABLE_HEADER
  }
);

// richParagraph template block (rendered on a second page).
doc.renderTemplate({
  page: { size: "A4", margin: 56 },
  styles: { heading1: { fontSize: 22, color: rgb(0.11, 0.22, 0.45) } },
  blocks: [
    { type: "heading", text: "richParagraph template block", options: { level: 1 } },
    {
      type: "richParagraph",
      runs: [
        { text: "Status: " },
        { text: "PASSED", bold: true, color: rgb(0, 0.5, 0) },
        { text: " - see the " },
        { text: "report", link: "https://sempdf.criston.dev", color: rgb(0.1, 0.3, 0.8) },
        { text: " for details." }
      ]
    }
  ]
});

export default doc;

Text formatting

Superscript/subscript and baseline rise, render modes (stroke outline, fillStroke, invisible), horizontalScaling to condense/expand glyphs, and inline highlight — shown on template paragraphs and rich-text runs. See Text and Fonts.

import { createDocument, rgb } from "@crstnmac/sempdf";

// Superscript/subscript, baseline rise, render modes (outline/invisible),
// horizontal scaling, and inline highlight — on both template paragraphs and
// per-run richParagraph content.
const doc = createDocument({
  title: "Text Formatting",
  language: "en-US",
  defaults: { font: "Helvetica", fontSize: 13, color: rgb(0.15, 0.15, 0.2) }
});

doc.renderTemplate({
  page: { size: "A4", margin: 56 },
  styles: {
    heading2: { fontSize: 11, color: "navy", marginTop: 12, marginBottom: 8 }
  },
  blocks: [
    { type: "heading", text: "Superscript & subscript", options: { level: 1 } },
    {
      type: "richParagraph",
      runs: [
        { text: "Einstein: E = mc" },
        { text: "2", superscript: true },
        { text: "      Water: H" },
        { text: "2", subscript: true },
        { text: "O" },
        { text: "      Footnote" },
        { text: "12", superscript: true }
      ],
      options: { fontSize: 15, marginBottom: 14 }
    },
    { type: "heading", text: "Render modes (fill / stroke / fillStroke)", options: { level: 2 } },
    {
      type: "richParagraph",
      runs: [
        { text: "Filled     ", fontSize: 26, bold: true },
        {
          text: "Outline     ",
          fontSize: 26,
          bold: true,
          renderMode: "stroke",
          strokeWidth: 0.6,
          strokeColor: rgb(0.8, 0.1, 0.1)
        },
        {
          text: "Both",
          fontSize: 26,
          bold: true,
          renderMode: "fillStroke",
          strokeWidth: 0.5,
          strokeColor: "navy",
          color: rgb(0.85, 0.9, 1)
        }
      ],
      options: { marginBottom: 14 }
    },
    { type: "heading", text: "Horizontal scaling", options: { level: 2 } },
    { type: "paragraph", text: "CONDENSED 70%", options: { fontSize: 16, horizontalScaling: 70, marginBottom: 6 } },
    { type: "paragraph", text: "NORMAL 100%", options: { fontSize: 16, marginBottom: 6 } },
    { type: "paragraph", text: "EXPANDED 140%", options: { fontSize: 16, horizontalScaling: 140, marginBottom: 14 } },
    { type: "heading", text: "Highlight", options: { level: 2 } },
    {
      type: "paragraph",
      text: "Whole line highlighted like a marker.",
      options: { highlight: "yellow", marginBottom: 10 }
    },
    {
      type: "richParagraph",
      runs: [
        { text: "Review status: " },
        { text: "APPROVED", bold: true, highlight: rgb(0.8, 1, 0.8) },
        { text: "  then  " },
        { text: "REJECTED", bold: true, color: "white", highlight: rgb(0.85, 0.2, 0.2) }
      ]
    }
  ]
});

export default doc;

Google Fonts

Fetch several Google Fonts (Inter, Geist, Instrument Serif with an italic face, and Bitcount Grid Single) and embed them as font families. In Node, use loadGoogleFont("Inter", { weight: 700 }); this browser sample fetches ready-made .ttf files from the CORS-enabled @expo-google-fonts packages on jsDelivr instead, because the Google Fonts API serves woff2 to browsers, which the engine cannot decode. See Text and Fonts → Google Fonts.

import { createDocument, rgb } from "@crstnmac/sempdf";

// In Node, call loadGoogleFont("Geist", { weight: 700 }) and embed the bytes.
// Browsers receive woff2 from the Google Fonts API (which the engine cannot
// decode), so this sample fetches ready-made .ttf files from the CORS-enabled
// @expo-google-fonts packages on jsDelivr instead.
const cdn = "https://cdn.jsdelivr.net/npm/@expo-google-fonts";
const FONTS = {
  inter400: `${cdn}/inter/400Regular/Inter_400Regular.ttf`,
  inter700: `${cdn}/inter/700Bold/Inter_700Bold.ttf`,
  geist400: `${cdn}/geist/400Regular/Geist_400Regular.ttf`,
  geist700: `${cdn}/geist/700Bold/Geist_700Bold.ttf`,
  instrumentRegular: `${cdn}/instrument-serif/400Regular/InstrumentSerif_400Regular.ttf`,
  instrumentItalic: `${cdn}/instrument-serif/400Regular_Italic/InstrumentSerif_400Regular_Italic.ttf`,
  bitcount: `${cdn}/bitcount-grid-single/400Regular/BitcountGridSingle_400Regular.ttf`
};

async function ttf(url: string): Promise<Uint8Array> {
  return new Uint8Array(await (await fetch(url)).arrayBuffer());
}

const doc = createDocument({ title: "Google Fonts", language: "en-US" });

// Fetch every face in parallel. Multiple faces under one template font family
// let bold/italic resolve by flag.
const [inter400, inter700, geist400, geist700, instReg, instItal, bitcount] = await Promise.all([
  ttf(FONTS.inter400),
  ttf(FONTS.inter700),
  ttf(FONTS.geist400),
  ttf(FONTS.geist700),
  ttf(FONTS.instrumentRegular),
  ttf(FONTS.instrumentItalic),
  ttf(FONTS.bitcount)
]);

const specimen = "The quick brown fox jumps 0123456789";

doc.renderTemplate({
  fonts: {
    Inter: { regular: inter400, bold: inter700 },
    Geist: { regular: geist400, bold: geist700 },
    "Instrument Serif": { regular: instReg, italic: instItal },
    "Bitcount Grid Single": { regular: bitcount }
  },
  page: { size: "A4", margin: 56 },
  blocks: [
    {
      type: "heading",
      text: "Google Fonts, embedded",
      options: { level: 1, font: "Geist", bold: true, fontSize: 24 }
    },
    {
      type: "paragraph",
      text: "Fetched as .ttf and registered through renderTemplate().",
      options: { font: "Geist", fontSize: 11, color: rgb(0.4, 0.4, 0.45), marginBottom: 24 }
    },
    { type: "paragraph", text: "Inter", options: { font: "Inter", fontSize: 9, color: rgb(0.5, 0.5, 0.55), marginBottom: 12 } },
    { type: "paragraph", text: specimen, options: { font: "Inter", fontSize: 20, marginBottom: 18 } },
    { type: "paragraph", text: "Inter Bold", options: { font: "Inter", fontSize: 9, color: rgb(0.5, 0.5, 0.55), marginBottom: 12 } },
    { type: "paragraph", text: specimen, options: { font: "Inter", bold: true, fontSize: 20, marginBottom: 18 } },
    { type: "paragraph", text: "Geist", options: { font: "Geist", fontSize: 9, color: rgb(0.5, 0.5, 0.55), marginBottom: 12 } },
    { type: "paragraph", text: specimen, options: { font: "Geist", fontSize: 20, marginBottom: 18 } },
    { type: "paragraph", text: "Instrument Serif", options: { font: "Instrument Serif", fontSize: 9, color: rgb(0.5, 0.5, 0.55), marginBottom: 12 } },
    { type: "paragraph", text: specimen, options: { font: "Instrument Serif", fontSize: 20, marginBottom: 18 } },
    { type: "paragraph", text: "Instrument Serif Italic", options: { font: "Instrument Serif", fontSize: 9, color: rgb(0.5, 0.5, 0.55), marginBottom: 12 } },
    { type: "paragraph", text: specimen, options: { font: "Instrument Serif", italic: true, fontSize: 20, marginBottom: 18 } },
    { type: "paragraph", text: "Bitcount Grid Single", options: { font: "Bitcount Grid Single", fontSize: 9, color: rgb(0.5, 0.5, 0.55), marginBottom: 12 } },
    { type: "paragraph", text: specimen, options: { font: "Bitcount Grid Single", fontSize: 20 } }
  ]
});

export default doc;

Stylesheets & tables

Define reusable styles once and apply them by block type or class, then render a header-row table — all through renderTemplate().

import { createDocument, rgb } from "@crstnmac/sempdf";

// Build a PDF and `export default` the document.
const doc = createDocument({
  title: "Stylesheets and Tables Demo",
  language: "en-US",
  xmpMetadata: {
    creator: "sempdf playground"
  }
});

doc.renderTemplate({
  page: { size: "A4", margin: 56 },
  styles: {
    heading1: { fontSize: 28, color: rgb(0.11, 0.22, 0.45) },
    heading2: { fontSize: 16, color: rgb(0.11, 0.22, 0.45) },
    paragraph: { fontSize: 12, lineHeight: 18 },
    muted: { color: rgb(0.45, 0.45, 0.45) }
  },
  blocks: [
    { type: "heading", text: "Hello from sempdf", options: { level: 1 } },
    { type: "paragraph", text: "Generated in the browser - no server, no dependencies.", class: "muted" },

    { type: "heading", text: "Stylesheets", options: { level: 2 } },
    { type: "paragraph", text: "Define reusable styles once and apply them by block type or via a class." },
    { type: "paragraph", text: "Edit this code on the left and hit Run.", class: "muted" },

    { type: "heading", text: "Tables", options: { level: 2 } },
    { type: "paragraph", text: "Pass `borders` to control which lines draw: \"all\", \"horizontal\", \"outer\", or \"none\". Set `borderColor`/`borderWidth` to style them.", class: "muted" },

    { type: "paragraph", text: "borders: \"all\" - full grid" },
    {
      type: "table",
      rows: [
        [{ text: "Item", header: true }, { text: "Qty", header: true }, { text: "Price", header: true }],
        ["Notebook", "2", "$11.00"],
        ["Pen", "5", "$7.50"]
      ],
      options: { headerRows: 1, borders: "all", borderColor: rgb(0.6, 0.6, 0.6), headerBackground: rgb(0.92, 0.94, 0.98) }
    },

    { type: "paragraph", text: "borders: \"horizontal\" - row rules only" },
    {
      type: "table",
      rows: [
        [{ text: "Item", header: true }, { text: "Qty", header: true }, { text: "Price", header: true }],
        ["Notebook", "2", "$11.00"],
        ["Pen", "5", "$7.50"]
      ],
      options: { headerRows: 1, borders: "horizontal", borderColor: rgb(0.2, 0.3, 0.5), borderWidth: 1, headerBackground: rgb(0.92, 0.94, 0.98) }
    },

    { type: "paragraph", text: "borders: \"outer\" - frame only, with zebra stripes" },
    {
      type: "table",
      rows: [
        [{ text: "Item", header: true }, { text: "Qty", header: true }, { text: "Price", header: true }],
        ["Notebook", "2", "$11.00"],
        ["Pen", "5", "$7.50"]
      ],
      options: { headerRows: 1, borders: "outer", borderColor: rgb(0.4, 0.4, 0.4), headerBackground: rgb(0.92, 0.94, 0.98), zebra: true }
    }
  ]
});

export default doc;

Import image (browser)

Load an image without node:fs by fetching it as an ArrayBuffer, then let page.image() detect its format and embed it in the PDF.

import { createDocument, rgb } from "@crstnmac/sempdf";

// The playground runs in the browser, so load the image with fetch instead of
// node:fs. page.image() detects the image format from its bytes.
const response = await fetch("/examples/assets/company-logo.png");
if (!response.ok) {
  throw new Error(`Could not load image: ${response.status} ${response.statusText}`);
}
const imageBytes = await response.arrayBuffer();

const doc = createDocument({
  title: "Imported Image",
  language: "en-US"
});
const page = doc.addPage({ size: "A4" });

page.text("Import an image in the browser", {
  x: 56,
  y: 790,
  fontSize: 24,
  color: rgb(0.11, 0.22, 0.45)
});
page.text("Fetch the image as an ArrayBuffer, then pass its bytes to page.image().", {
  x: 56,
  y: 760,
  fontSize: 11
});
page.image(imageBytes, {
  x: 56,
  y: 640,
  width: 360,
  height: 81,
  altText: "Brightforge Systems logo"
});

export default doc;

Single page template

A minimal single-page document rendered from declarative template blocks.

import { createDocument, rgb } from "@crstnmac/sempdf";

const doc = createDocument({
  title: "Single Page Demo",
  language: "en-US",
  xmpMetadata: {
    creator: "sempdf playground"
  }
});

doc.renderTemplate({
  page: { size: "A4", margin: 56 },
  blocks: [
    {
      type: "heading",
      text: "Hello from sempdf",
      options: { level: 1, fontSize: 28, color: rgb(0.11, 0.22, 0.45) }
    },
    {
      type: "paragraph",
      text: "Rendered from a declarative template on a single page.",
      options: { fontSize: 12 }
    }
  ]
});

export default doc;

Hello world (template)

The renderTemplate() starting point, with document info and XMP metadata.

import { createDocument, hex } from "@crstnmac/sempdf";

const doc = createDocument({
  language: "en-US",
  info: {
    title: "Hello World",
    author: "sempdf",
    creationDate: "D:20260413213000Z",
    modificationDate: "D:20260413213000Z"
  }
});

doc.setXmpMetadata({
  title: "Hello World",
  creator: "sempdf",
  creatorTool: "sempdf",
  createDate: "2026-04-13T21:30:00Z",
  modifyDate: "2026-04-13T21:30:00Z",
  metadataDate: "2026-04-13T21:30:00Z"
});

doc.renderTemplate({
  info: { title: "Hello World", author: "sempdf" },
  page: { size: "A4" },
  blocks: [
    {
      type: "heading",
      text: "Hello from sempdf",
      options: { level: 1, color: hex("1c2f6b") }
    },
    {
      type: "paragraph",
      text: "This PDF was generated using the renderTemplate API, which provides a production-grade template system with automatic page breaks, headings, tables, and images.",
      options: { fontSize: 12 }
    }
  ]
});

export default doc;

Invoice

A compact invoice built from a styled heading, a muted subtitle, and a totals table.

import { createDocument, rgb } from "@crstnmac/sempdf";

const TABLE_BORDER = rgb(0.72, 0.76, 0.82);
const TABLE_HEADER = rgb(0.92, 0.94, 0.98);

const doc = createDocument({
  title: "Invoice 1042",
  language: "en-US",
  xmpMetadata: {
    creator: "sempdf playground"
  }
});

doc.renderTemplate({
  page: { size: "A4", margin: 56 },
  styles: {
    heading1: { fontSize: 26, color: rgb(0.1, 0.1, 0.12) },
    muted: { color: rgb(0.5, 0.5, 0.5), fontSize: 10 },
    paragraph: { fontSize: 11, lineHeight: 16 }
  },
  blocks: [
    { type: "heading", text: "Invoice #1042", options: { level: 1 } },
    { type: "paragraph", text: "Acme Corp  -  Issued 2026-05-29", class: "muted" },
    {
      type: "table",
      rows: [
        [{ text: "Description", header: true }, { text: "Qty", header: true }, { text: "Amount", header: true }],
        ["Design work", "10", "$1,000.00"],
        ["Development", "20", "$3,000.00"],
        [{ text: "Total", header: true }, "-", "$4,000.00"]
      ],
      options: {
        headerRows: 1,
        borders: "all",
        borderColor: TABLE_BORDER,
        headerBackground: TABLE_HEADER
      }
    }
  ]
});

export default doc;

JSON to PDF

An entire document — metadata, a footer with page tokens, a table, and absolute-positioned blocks — described as one PdfDocumentJson value and rendered with renderDocumentJson().

/**
 * Declarative JSON -> PDF, the full surface in one value.
 *
 * A tagged (accessible) two-page report described entirely as `PdfDocumentJson`:
 * document metadata and language, a tagged structure tree, an auto-generated
 * bookmark outline, artifact header/footer with page tokens, headings, rich
 * inline text, ordered/unordered lists, a header-row table, a sectioned figure
 * with alternate text, a note annotation, a hyperlink, and absolute-positioned
 * blocks (an approval stamp and a signature field) placed at exact coordinates.
 *
 *   npm run build && node dist/examples/json-to-pdf.js
 */
import { writeFile } from "node:fs/promises";

import { renderDocumentJson, type PdfDocumentJson } from "../src/index.js";

/** A 1x1 solid-color 24-bit BMP, base64-encoded — embedded inline as a figure. */
function solidBmpBase64(r: number, g: number, b: number): string {
  const bmp = Buffer.alloc(58);
  bmp.write("BM", 0, "ascii");
  bmp.writeUInt32LE(58, 2); // file size
  bmp.writeUInt32LE(54, 10); // pixel-data offset
  bmp.writeUInt32LE(40, 14); // DIB header size
  bmp.writeInt32LE(1, 18); // width
  bmp.writeInt32LE(1, 22); // height
  bmp.writeUInt16LE(1, 26); // planes
  bmp.writeUInt16LE(24, 28); // bits per pixel
  bmp.writeUInt32LE(4, 34); // image size
  bmp[54] = b; // BMP stores BGR
  bmp[55] = g;
  bmp[56] = r;
  return bmp.toString("base64");
}

const report: PdfDocumentJson = {
  // ── Document options ──────────────────────────────────────────────────────
  title: "Accessibility Conformance Report",
  info: { author: "sempdf", subject: "WCAG 2.2 audit", keywords: "accessibility, pdf, tagged" },
  language: "en-US",
  tagged: true,
  compress: true,
  autoOutline: { maxLevel: 3 },

  // ── Page, header, footer ──────────────────────────────────────────────────
  page: { size: "A4", margin: 56, headerHeight: 40, footerHeight: 40 },
  header: [
    { type: "paragraph", text: "{{title}}", options: { fontSize: 8, color: "gray", tag: "Artifact" } },
  ],
  footer: [
    {
      type: "paragraph",
      text: "Page {{pageNumber}} of {{pageCount}}",
      options: { fontSize: 8, color: "gray", align: "center", tag: "Artifact" },
    },
  ],

  // ── Body ──────────────────────────────────────────────────────────────────
  blocks: [
    { type: "heading", text: "Accessibility Conformance Report", options: { level: 1 } },
    {
      type: "paragraph",
      text: "This report records the results of a tagged-PDF audit and is itself produced as a tagged, PDF/UA-style document.",
    },
    {
      type: "richParagraph",
      runs: [
        { text: "Overall status: " },
        { text: "PASS", bold: true, color: "green" },
        { text: " - reviewed against " },
        { text: "WCAG 2.2 AA", italic: true },
        { text: "." },
      ],
    },

    { type: "heading", text: "Findings", options: { level: 2 } },
    {
      type: "list",
      items: [
        "The document language is declared (en-US).",
        "Headings follow a logical, gap-free hierarchy.",
        { body: "Images carry alternate text", children: ["Decorative images are marked as artifacts."] },
      ],
      options: { ordered: false, width: 483 },
    },

    { type: "heading", text: "Scores", options: { level: 2 } },
    {
      type: "table",
      rows: [
        [
          { text: "Criterion", header: true },
          { text: "Result", header: true },
          { text: "Notes", header: true },
        ],
        ["Tagged structure", "Pass", "Complete structure tree"],
        ["Color contrast", "Pass", "4.8:1 body text"],
        ["Reading order", "Pass", "Matches visual order"],
      ],
      options: { headerRows: 1 },
    },

    {
      type: "section",
      role: "Sect",
      blocks: [
        { type: "heading", text: "Evidence", options: { level: 3 } },
        { type: "paragraph", text: "The figure below summarizes the four checks; all passed." },
      ],
    },
    // Image blocks use `src` at the top level of `blocks`. A 1x1 BMP is stretched
    // into a bar; `altText` + `tag: "Figure"` make it accessible.
    {
      type: "bmp",
      src: { base64: solidBmpBase64(34, 139, 87) },
      options: {
        width: 160,
        height: 36,
        altText: "Green bar indicating all four accessibility checks passed",
        tag: "Figure",
      },
    },

    { type: "note", options: { contents: "Re-audit scheduled for the next quarter." } },
    { type: "link", text: "Read the WCAG 2.2 specification", url: "https://www.w3.org/TR/WCAG22/" },

    { type: "pageBreak" },

    { type: "heading", text: "Sign-off", options: { level: 2 } },
    { type: "paragraph", text: "Reviewed and approved by the accessibility team." },

    // ── Absolute-positioned blocks (coordinate system) ──────────────────────
    {
      type: "rect",
      width: 150,
      height: 54,
      style: { paint: "stroke", stroke: { color: "green", width: 2 } },
      position: { x: 360, y: 660 },
    },
    {
      type: "paragraph",
      text: "APPROVED",
      options: { fontSize: 22, color: "green" },
      position: { x: 378, y: 678 },
    },
    {
      type: "textField",
      name: "reviewerSignature",
      options: { description: "Reviewer signature", width: 240, height: 16 },
      position: { x: 56, y: 640 },
    },
  ],
};

const bytes = await renderDocumentJson(report);
await writeFile("examples/json-to-pdf.pdf", bytes);
console.log(`Wrote examples/json-to-pdf.pdf (${bytes.length} bytes)`);

Multi-column

A two-column newspaper layout that flows paragraphs across columns.

import { createDocument } from "@crstnmac/sempdf";

const lorem = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(8);

const doc = createDocument({
  title: "Newspaper Layout Demo",
  language: "en-US",
  xmpMetadata: {
    creator: "sempdf playground"
  }
});

doc.renderTemplate({
  page: { size: "A4", margin: 48, columns: { count: 2, gap: 24 } },
  styles: {
    heading1: { fontSize: 22 },
    paragraph: { fontSize: 11, lineHeight: 16 }
  },
  blocks: [
    { type: "heading", text: "Newspaper layout", options: { level: 1 } },
    { type: "paragraph", text: lorem },
    { type: "paragraph", text: lorem }
  ]
});

export default doc;

Color spaces

Every supported color model: hex, rgb, gray, cmyk, plus separation spot colors and DeviceN multi-ink.

import { createDocument, hex, rgb, gray, cmyk } from "@crstnmac/sempdf";

const doc = createDocument({
  language: "en-US",
  info: {
    title: "Color Spaces Demo",
    author: "sempdf"
  }
});

doc.renderTemplate({
  info: { title: "Color Spaces Demo", author: "sempdf" },
  page: { size: "A4" },
  blocks: [
    {
      type: "heading",
      text: "PDF Color Spaces",
      options: { level: 1, color: hex("#1c2f6b") }
    },
    {
      type: "paragraph",
      text: "hex('#1565c0') - DeviceRGB hex color. Useful for web-originated palettes and design tokens.",
      options: { color: hex("#1565c0"), fontSize: 12, marginTop: 8 }
    },
    {
      type: "paragraph",
      text: "rgb(0.08, 0.40, 0.75) - DeviceRGB float. Programmatic color mixing with 0-1 range per channel.",
      options: { color: rgb(0.08, 0.4, 0.75), fontSize: 12, marginTop: 4 }
    },
    {
      type: "paragraph",
      text: "rgb(0.09, 0.67, 0.36) - Another RGB example, demonstrating a green tone.",
      options: { color: rgb(0.09, 0.67, 0.36), fontSize: 12, marginTop: 4 }
    },
    {
      type: "paragraph",
      text: "gray(0.5) - DeviceGray mid-tone. Single-channel lightness, ideal for monochrome content.",
      options: { color: gray(0.5), fontSize: 12, marginTop: 8 }
    },
    {
      type: "paragraph",
      text: "gray(0.2) - DeviceGray dark. Lower values produce deeper shades.",
      options: { color: gray(0.2), fontSize: 12, marginTop: 4 }
    },
    {
      type: "paragraph",
      text: "gray(0.8) - DeviceGray light. Higher values produce lighter shades.",
      options: { color: gray(0.8), fontSize: 12, marginTop: 4 }
    },
    {
      type: "paragraph",
      text: "cmyk(0.80, 0.20, 0.00, 0.15) - DeviceCMYK. Process color for professional print workflows.",
      options: { color: cmyk(0.8, 0.2, 0, 0.15), fontSize: 12, marginTop: 8 }
    },
    {
      type: "paragraph",
      text: "cmyk(0.00, 1.00, 1.00, 0.00) - Bright red in CMYK process.",
      options: { color: cmyk(0, 1, 1, 0), fontSize: 12, marginTop: 4 }
    },
    {
      type: "paragraph",
      text: "cmyk(0.00, 0.00, 1.00, 0.00) - Bright yellow in CMYK process.",
      options: { color: cmyk(0, 0, 1, 0), fontSize: 12, marginTop: 4 }
    },
    {
      type: "heading",
      text: "Advanced Color Spaces",
      options: { level: 2, color: hex("#1565c0"), marginTop: 16 }
    },
    {
      type: "paragraph",
      text: "Separation spot color - Pantone 286 C. Named ink with a tint value (0-1) and CMYK fallback for on-screen previews and composite printing.",
      options: {
        fontSize: 12,
        color: {
          kind: "separation",
          name: "Pantone 286 C",
          tint: 1,
          alternate: cmyk(1, 0.66, 0, 0.04)
        }
      }
    },
    {
      type: "paragraph",
      text: "DeviceN multi-ink - Pantone 286 C + Black. Combines multiple named colorants into a single color specification. Each ink has its own tint, with a CMYK alternate for fallback.",
      options: {
        fontSize: 12,
        marginTop: 4,
        color: {
          kind: "deviceN",
          names: ["Pantone 286 C", "Black"],
          tints: [1, 0.8],
          alternate: cmyk(1, 0.66, 0, 0.04)
        }
      }
    }
  ]
});

export default doc;

Graphics state & transforms

Low-level vector graphics: saveState/restoreState, translate, scale, rotate, arbitrary transform, alpha transparency, and blend modes.

import { createDocument, hex } from "@crstnmac/sempdf";

const doc = createDocument({
  info: {
    title: "Graphics State Demo",
    author: "sempdf"
  }
});

doc.renderTemplate({
  page: { size: "A4", margin: 72 },
  blocks: [
    {
      type: "heading",
      text: "Graphics State",
      options: { level: 1, color: hex("#1a1a2e") }
    },
    {
      type: "paragraph",
      text: "The document shell is rendered as a template. The following page uses the low-level graphics-state API because transforms and blend modes operate directly on a page canvas."
    }
  ]
});

const page = doc.addPage({ size: "A4" });
page.text("Graphics State: Transforms, Alpha & Blend Modes", {
  x: 72, y: 800, fontSize: 16
});

// Save / restore state
page.text("saveState / restoreState", { x: 72, y: 770, fontSize: 12 });
page.saveState();
page.text("Blue text inside saved state", {
  x: 72, y: 740, fontSize: 14, color: hex("#1565c0")
});
page.path(
  [{ type: "rect", x: 72, y: 718, width: 200, height: 16 }],
  { paint: "stroke", stroke: { width: 1, color: hex("#1565c0") } }
);
page.restoreState();
page.text("Back to default graphics state", {
  x: 72, y: 690, fontSize: 11
});

// Translate
page.text("translate(100, 0) - two rectangles side by side", { x: 72, y: 660, fontSize: 12 });
page.saveState();
page.translate(100, 0);
page.path(
  [{ type: "rect", x: 72, y: 630, width: 80, height: 20 }],
  { paint: "fill", fill: { color: hex("#e3f2fd") } }
);
page.restoreState();

page.saveState();
page.translate(188, 0);
page.path(
  [{ type: "rect", x: 72, y: 630, width: 80, height: 20 }],
  { paint: "fill", fill: { color: hex("#bbdefb") } }
);
page.restoreState();

// Scale
page.text("scale(1.5, 1.5)", { x: 72, y: 600, fontSize: 12 });
page.saveState();
page.scale(1.5, 1.5);
page.text("SCALED TEXT", { x: 72, y: 380, fontSize: 16, color: hex("#c62828") });
page.restoreState();

// Rotate
page.text("rotate(15 degrees)", { x: 72, y: 570, fontSize: 12 });
page.saveState();
page.rotate(15);
page.text("Rotated text at 15 degrees", { x: 72, y: 530, fontSize: 14, color: hex("#2e7d32") });
page.restoreState();

// Alpha transparency with setExtGState
page.text("Transparency (fillAlpha = 0.3)", { x: 72, y: 500, fontSize: 12 });
page.saveState();
page.setExtGState({ fillAlpha: 0.3 });
page.path(
  [{ type: "rect", x: 72, y: 460, width: 100, height: 30 }],
  { paint: "fill", fill: { color: hex("#ff5722") } }
);
page.text("Semi-transparent overlay", { x: 66, y: 472, fontSize: 10, color: hex("#ffffff") });
page.restoreState();

// Blend modes
page.text("Blend mode: 'Multiply' on overlapping rects", { x: 72, y: 440, fontSize: 12 });
page.saveState();
page.setExtGState({ blendMode: "Multiply" });
page.path(
  [{ type: "rect", x: 72, y: 390, width: 60, height: 30 }],
  { paint: "fill", fill: { color: hex("#ff0000") } }
);
page.path(
  [{ type: "rect", x: 86, y: 380, width: 60, height: 30 }],
  { paint: "fill", fill: { color: hex("#0000ff") } }
);
page.restoreState();

// Transform (skew)
page.text("transform() - skewed parallelogram", { x: 72, y: 360, fontSize: 12 });
page.saveState();
page.transform({ a: 1, b: 0, c: 0.3, d: 1, e: 0, f: 0 });
page.path(
  [{ type: "rect", x: 72, y: 320, width: 120, height: 25 }],
  { paint: "fill", fill: { color: hex("#7b1fa2") } }
);
page.restoreState();

export default doc;

Outlines (bookmarks)

Build a nested outline (bookmarks) tree automatically from template headings.

import { createDocument, hex } from "@crstnmac/sempdf";

const doc = createDocument({
  info: {
    title: "PDF Outlines (Bookmarks) Demo",
    author: "sempdf"
  }
});

doc.renderTemplate({
  page: { size: "A4", margin: 72 },
  autoOutline: { maxLevel: 2 },
  styles: {
    heading1: { fontSize: 20, color: hex("#1a1a2e") },
    heading2: { fontSize: 14 }
  },
  blocks: [
    { type: "heading", text: "Chapter 1: Introduction", options: { level: 1 } },
    { type: "heading", text: "Overview", options: { level: 2 } },
    { type: "paragraph", text: "This is the first chapter covering basic concepts.", options: { fontSize: 12 } },
    { type: "pageBreak" },
    { type: "heading", text: "Chapter 2: Advanced Topics", options: { level: 1 } },
    { type: "heading", text: "Section 2.1 - Deep Dive", options: { level: 2 } },
    { type: "heading", text: "Section 2.2 - Examples", options: { level: 2 } },
    { type: "pageBreak" },
    { type: "heading", text: "Appendix A: Reference", options: { level: 1 } }
  ]
});

export default doc;

Annotations

Highlight, sticky note, free text, URI link, and page-to-page link annotations.

import { createDocument, hex } from "@crstnmac/sempdf";

const doc = createDocument({
  language: "en-US",
  info: {
    title: "PDF Annotations Demo",
    author: "sempdf"
  }
});

doc.renderTemplate({
  info: { title: "PDF Annotations Demo", author: "sempdf" },
  page: { size: "A4" },
  blocks: [
    {
      type: "heading",
      text: "PDF Annotations",
      options: { level: 1, marginBottom: 6 }
    },
    {
      type: "paragraph",
      text: "This document demonstrates PDF annotation types. The document layout uses renderTemplate, while annotations are placed with the low-level page API for precise positioning.",
      options: { fontSize: 11, marginBottom: 14 }
    },
    {
      type: "paragraph",
      text: "Annotations below include highlight, sticky note, free text, URI link, and page-to-page link.",
      options: { fontSize: 11 }
    }
  ]
});

const page = doc.addPage({ size: "A4" });

page.text("PDF Annotations", { x: 72, y: 800, fontSize: 20 });

// Highlight annotation
page.text("This sentence has a highlight annotation on it.", { x: 72, y: 760, fontSize: 12 });
page.highlight({
  x: 72,
  y: 760,
  width: 350,
  height: 14,
  color: hex("#ffeb3b")
});

// Note annotation (sticky note)
page.text("Click the note icon to read the comment.", { x: 72, y: 720, fontSize: 12 });
page.note({
  x: 72,
  y: 720,
  width: 20,
  height: 20,
  contents: "This is a sticky note annotation with reviewer comments.",
  open: true
});

// FreeText annotation
page.freeText({
  x: 300,
  y: 680,
  width: 200,
  height: 60,
  text: "Approved by reviewer on May 8, 2026",
  fontSize: 10
});

// URI link
page.text("Visit our website for documentation", { x: 72, y: 640, fontSize: 12 });
page.link("https://example.com/docs", {
  x: 72,
  y: 640,
  width: 220,
  height: 14
});

// Internal page link
const page2 = doc.addPage({ size: "A4" });
page2.text("You arrived via the 'Go to Page 2' link on Page 1.", {
  x: 72,
  y: 800,
  fontSize: 14
});

page.text("Go to Page 2", { x: 72, y: 610, fontSize: 12 });
page.linkToPage(page2, {
  x: 72,
  y: 610,
  width: 80,
  height: 14
});

export default doc;

Form fields

Interactive AcroForm fields — text, combo/list choice, checkbox, push button, and signature — laid out with renderTemplate() so the labels and fields flow down the page without overlapping (the field blocks omit x/y).

import { createDocument, hex } from "@crstnmac/sempdf";

const doc = createDocument({
  language: "en-US",
  info: {
    title: "Registration Form",
    author: "sempdf"
  }
});

// renderTemplate auto-flows each block down the page, so the labels and form
// fields stack without overlapping. Field blocks (textField, choiceField,
// checkBox, pushButton, signatureField) omit x/y - the template positions them.
//
// Each field sets a `description`, emitted as /TU - the field's accessible name
// (read by screen readers, and required by the "form fields have a description"
// accessibility check). renderTemplate tags the field widgets into the
// structure tree automatically, so no per-field tag is needed.
doc.renderTemplate({
  info: { title: "Registration Form", author: "sempdf" },
  page: { size: "A4" },
  pageNumber: { region: "footer", align: "center" },
  blocks: [
    {
      type: "heading",
      text: "Registration Form",
      options: { level: 1, color: hex("#1c2f6b"), marginBottom: 8 }
    },
    {
      type: "paragraph",
      text: "Please fill out the following form to register for the event. All fields marked with an asterisk (*) are required.",
      options: { fontSize: 11, marginBottom: 14 }
    },
    {
      type: "paragraph",
      text: "Full Name *",
      options: { fontSize: 10, marginBottom: 4 }
    },
    {
      type: "textField",
      name: "name",
      options: { width: 300, height: 24, value: "John Doe", fontSize: 11, required: true, description: "Full name" }
    },
    {
      type: "paragraph",
      text: "Email Address *",
      options: { fontSize: 10, marginBottom: 4, marginTop: 10 }
    },
    {
      type: "textField",
      name: "email",
      options: { width: 300, height: 24, fontSize: 11, required: true, description: "Email address" }
    },
    {
      type: "paragraph",
      text: "Country",
      options: { fontSize: 10, marginBottom: 4, marginTop: 10 }
    },
    {
      type: "choiceField",
      name: "country",
      options: {
        width: 250,
        height: 24,
        options: ["United States", "Canada", "United Kingdom", "Germany", "Japan", "Australia"],
        value: "United States",
        mode: "combo",
        fontSize: 10,
        description: "Country"
      }
    },
    {
      type: "paragraph",
      text: "Skills (list box)",
      options: { fontSize: 10, marginBottom: 4, marginTop: 10 }
    },
    {
      type: "choiceField",
      name: "skills",
      options: {
        width: 250,
        height: 60,
        options: ["TypeScript", "JavaScript", "Python", "Rust", "Go"],
        mode: "list",
        fontSize: 10,
        description: "Skills"
      }
    },
    {
      type: "checkBox",
      name: "subscribe",
      options: { width: 16, height: 16, checked: true, description: "Subscribe to newsletter" }
    },
    {
      type: "paragraph",
      text: "Subscribe to newsletter",
      options: { fontSize: 10, marginTop: 8, marginBottom: 16 }
    },
    {
      type: "pushButton",
      name: "submit",
      options: { width: 140, height: 32, label: "Submit", fontSize: 12, color: hex("#ffffff"), description: "Submit form" }
    },
    {
      type: "paragraph",
      text: "Signature *",
      options: { fontSize: 10, marginTop: 16, marginBottom: 4 }
    },
    {
      type: "signatureField",
      name: "signature",
      options: { width: 300, height: 60, required: true, description: "Signature" }
    }
  ]
});

export default doc;

Incremental editing

Re-open template-generated bytes with editDocument(), append a page, update a field value, and patch document info — all as an incremental update.

import { createDocument, editDocument } from "@crstnmac/sempdf";

// Step 1: Create a base document with form fields.
const baseDoc = createDocument({
  info: { title: "Base Document", author: "sempdf" }
});

baseDoc.renderTemplate({
  page: { size: "A4", margin: 72 },
  blocks: [
    {
      type: "heading",
      text: "Original document",
      options: { level: 1, fontSize: 20 }
    },
    {
      type: "paragraph",
      text: "Original content - this document will be edited incrementally.",
      options: { fontSize: 14 }
    },
    {
      type: "textField",
      name: "name",
      options: { width: 300, height: 22, value: "Original", fontSize: 11 }
    },
    {
      type: "checkBox",
      name: "agree",
      options: { width: 16, height: 16 }
    }
  ]
});

const baseBytes = baseDoc.toUint8Array();

// Step 2: Re-open the saved bytes and edit them as an incremental update.
const editable = editDocument(baseBytes);

// Add a new page.
const newPage = editable.addPage({ size: "A4" });
newPage.text("Added via incremental editing", { x: 72, y: 780, fontSize: 16 });
newPage.text("This page did not exist in the original document.", { x: 72, y: 750, fontSize: 12 });

// Update a form field value.
editable.setFieldValue("name", "Updated via incremental edit");

// Update document info.
editable.updateInfo({ title: "Edited Document", subject: "Demonstrates incremental editing" });

// The edited document is what we render.
export default editable;

Browser AES encryption

AES-256 encrypt a document entirely client-side — no Node runtime. The library uses a built-in AES/SHA-2 fallback when node:crypto is unavailable, so the output opens with the same password in any reader. See Encryption and Signatures → Browser support.

import { createDocument, rgb } from "@crstnmac/sempdf";

// AES encryption runs entirely in the browser — no server round-trip. When
// node:crypto is unavailable the library uses a built-in, zero-dependency
// AES/SHA-2 implementation whose output matches Node byte-for-byte, so the
// password works in any reader.
//
// This document is encrypted as the playground serializes it. Open the
// downloaded PDF and you will be prompted for the password below.
const doc = createDocument({
  title: "Encrypted in the Browser",
  language: "en-US",
  encryption: {
    algorithm: "aes-256-r6", // PDF 2.0 hardened key derivation (recommended)
    userPassword: "reader",   // password required to open
    ownerPassword: "owner-secret",
    permissions: {
      printing: true,
      copying: false,
      modifying: false
    }
  }
});

doc.renderTemplate({
  page: { size: "A4", margin: 56 },
  blocks: [
    {
      type: "heading",
      text: "Confidential",
      options: { level: 1, fontSize: 28, color: rgb(0.6, 0.1, 0.1) }
    },
    {
      type: "paragraph",
      text: "This PDF was AES-256 encrypted client-side.",
      options: { fontSize: 12 }
    },
    {
      type: "paragraph",
      text: "Open password: reader",
      options: { fontSize: 12 }
    }
  ]
});

export default doc;

On this page