New:Socket for Asana Is Now Available.Learn more
Get Started

boxpdf

Package Overview
Dependencies
Maintainers
1
Versions
21
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

boxpdf

Tiny box-layout DSL over pdf-lib. Flexbox-lite for server-side PDF generation in any JS runtime (Node, Cloudflare Workers, Deno, browsers).

Source
npmnpm
Version
1.6.1
Version published
Weekly downloads
447
5.18%
Maintainers
1
Weekly downloads
 
Created
Source

boxpdf

A box-layout DSL over pdf-lib. Runs in Node 18+, Cloudflare Workers, Deno, and browsers. No native dependencies, no WASM, no headless browser.

Live gallery: https://earonesty.github.io/boxpdf/

import { PDFDocument, StandardFonts } from "pdf-lib";
import { cleanTheme, hline, hstack, renderFlow, text, vstack } from "boxpdf";

const pdf  = await PDFDocument.create();
const font = await pdf.embedFont(StandardFonts.Helvetica);
const bold = await pdf.embedFont(StandardFonts.HelveticaBold);
const theme = cleanTheme(font, bold);

await renderFlow(pdf, [
  vstack({ gap: 8 },
    text("Receipt #18472", theme.type.h1),
    text("May 14, 2026", theme.type.caption)
  ),
  hline(theme.hr),
  hstack({ gap: 16, justify: "between", width: 515 },
    text("Wool socks", theme.type.body),
    text("$28.00", { ...theme.type.body, font: bold, align: "right", width: 80 })
  )
]);

const bytes = await pdf.save();

Install

npm install boxpdf pdf-lib

pdf-lib is a peer dependency.

What it does

  • Declarative layout primitives: vstack, hstack, text, image, hline, vline, spacer, flex, keepTogether, link, svgPath, table.
  • Padding, margin, background, borders, borderRadius, flex-grow, flex-shrink, justify, align.
  • Word wrapping with maxLines truncation and optional breakWords.
  • Themes: cleanTheme, stripeTheme, editorialTheme, brutalistTheme.
  • Multi-page flow with per-page headers and footers.
  • Streaming generation for memory-bounded output.
  • PDF link annotations, text decorations, document metadata.
  • ~7 KB minified core. Custom fonts pull in @pdf-lib/fontkit only when you call loadFont or embedInter.

Templates

Files in templates/ cover receipts, boarding passes, resumes, order confirmations, and certificates. Each is a single file.

Scaffold one into your app with the CLI:

npx boxpdf init receipt --out src/pdf/receipt.ts
npx boxpdf list

The CLI also ships a resource-only MCP server for agents:

claude mcp add boxpdf -- npx -y boxpdf mcp

Themes

import { cleanTheme, stripeTheme, editorialTheme, brutalistTheme } from "boxpdf";

const theme = cleanTheme(font, bold);
// stripeTheme(font, bold)
// editorialTheme(font, bold, italic)
// brutalistTheme(courier, courierBold)

Every theme exposes the same shape: colors, spacing, radii, type, card, hr.

API

Containers

  • vstack(style, ...children). Vertical layout.
  • hstack(style, ...children). Horizontal layout.
  • keepTogether({ gap?, margin? }, ...children). Paginates atomically.

Container style:

FieldTypeNotes
width / heightnumberFixed dimensions; otherwise size to content.
padding / marginnumber | { top, right, bottom, left }Shorthand or per-side.
backgroundRGBSolid fill.
border{ color, width }1pt+ stroke around the box.
borderSides{ top?, right?, bottom?, left? }Per-side strokes using { color, width }.
borderRadiusnumberCorner radius.
position"relative" | "absolute"CSS-like positioning for boxes.
top / right / bottom / leftnumberAbsolute offsets in points.
zIndexnumberPaint order for positioned boxes; higher values render later.
grownumberFlex grow weight along the parent's main axis.
shrinknumberFlex shrink weight.
gapnumberSpacing between children.
justify"start" | "center" | "end" | "between" | "around" | "evenly"Main-axis distribution.
align"start" | "center" | "end" | "stretch" | "baseline"Cross-axis alignment. baseline is intended for hstack rows.

Leaves

  • text(content, { size, font, color?, align?, width?, lineHeight?, maxLines?, underline?, strikethrough?, margin? }). Word-wraps when width is set. Truncates with ellipsis when maxLines is set. Default lineHeight uses the font's full height, including descenders.
  • paragraph({ width?, align?, lineHeight?, margin? }, ...runs). Mixed inline text runs and atomic inline nodes that wrap together as one paragraph. Use run(text, style), linkRun(text, style, href), and inlineNode(node, { verticalAlign?, href? }).
  • image(pdfImage, { width, height, margin? }). Takes an already-embedded PDFImage.
  • imageFit(pdfImage, { width, height, fit?, margin? }). Draws an image centered in a fixed rectangle, scaled to contain (default) or cover with clipping.
  • spacer(size, { grow? }) / flex(weight = 1). Fixed or growing gap.
  • hline({ color, thickness?, width?, margin? }).
  • vline({ color, thickness?, height?, margin? }).
  • link({ href }, child). Wraps a child and registers a PDF Link annotation over its rendered bounding box.
  • table({ columns, rows, ... }). Fixed / auto / fractional columns with header/footer rows, dividers, styled cells, and row-level page fragmentation under renderFlow. Cells can be plain nodes or { content, colSpan?, padding?, background?, border?, borderSides?, borderRadius?, align?, valign? }.

Rendering

  • renderFlow(pdf, nodes[], options). Paginates a sequence of top-level children. Top-level vstack nodes may fragment between children; table() fragments between rows and repeats headers on continuation pages. Use keepTogether() or breakInside: "avoid" for atomic blocks. Options: size, margin, header?, footer?, reserveBottom?, title?, author?, subject?, keywords?, creator?, producer?, debug?, warnings?. Headers and footers receive { pageNumber, totalPages }. Defaults to LETTER (612×792). Pass { size: PageSizes.A4 } for A4. When a top-level child's measured width exceeds the page content area, boxpdf emits a console.warn. Suppress with warnings: false.
  • streamFlow(pdf, writable, asyncIterable, options). Incremental page-by-page rendering. Memory stays bounded regardless of page count. Writes PDF bytes to a WritableStream<Uint8Array> as each page closes. See the Streaming section below for the contract.
  • renderToPdf(node, options). One-page convenience.
  • pageInner(size, margin) / pageContent(size, margin). Compute the inner content width or rectangle of a page.
  • render(node, page, x, yTop, parentWidth). Draws a subtree at a known position on an existing PDFPage.
  • measure(node, parentWidth). Intrinsic size without drawing.

Pass { debug: true } to outline content boxes in red and margin boxes in orange.

Helpers

  • loadFont(pdf, source, options?). Embed a TTF from URL, bytes, base64, or data URL.
  • loadImage(pdf, source). Embed a PNG or JPEG (auto-detected).
  • aspectRatio(ratio, { width }) / aspectRatio(ratio, { height }). Derive the missing dimension for fixed-ratio boxes or images.
  • formatCurrency(n, { currency, locale }). Intl.NumberFormat wrapper.
  • defineStyles({ ... }). Typed identity for reusable style bundles.
  • hex("#1f8a4d") / rgb255(31, 138, 77). Color builders.

Loading fonts

Three options.

Bundled bytes via the CLI. Recommended for production.

npx boxpdf font add ./Acme-Regular.ttf=regular ./Acme-Bold.ttf=bold \
  --out src/fonts/acme.ts

Generates src/fonts/acme.ts with export const base64 strings. Then:

import { loadFont } from "boxpdf";
import { regular, bold } from "./fonts/acme.js";

const font = await loadFont(pdf, regular);
const acmeBold = await loadFont(pdf, bold);

Bytes ship inside your bundle. No network round-trip.

The built-in Inter weights.

import { loadFont } from "boxpdf";
import { inter, interBold } from "boxpdf/inter";

const font = await loadFont(pdf, inter);
const bold = await loadFont(pdf, interBold);

boxpdf/inter re-exports the same Inter subset as raw base64 strings (inter, interBold, interItalic) and as embedInter(pdf, { italic?, tabularFigures? }).

Importing boxpdf/inter loads ~325 KB of font bytes plus @pdf-lib/fontkit. The subpath isn't loaded otherwise.

import { embedInter } from "boxpdf/inter";

const { font, bold } = await embedInter(pdf);
const theme = cleanTheme(font, bold);

Pass { tabularFigures: true } to also get tabular-numeral variants for money columns:

const { font, bold, tabularFont, tabularBold } = await embedInter(pdf, {
  tabularFigures: true
});

text(formatCurrency(amount), { size: 12, font: tabularBold, align: "right" });

Fetch from a URL.

const brand = await loadFont(pdf, "https://example.com/Acme-Regular.ttf");

The full TTF gets fetched and subsetted at embed time. On Cloudflare Workers with a warm cache this is fast (~5-15 ms). On a cold cache or in Node you pay the full fetch each time.

loadFont accepts the same { subset?: boolean; features?: { tnum: true } } options regardless of the source. Use features: { tnum: true } to enable tabular numerals.

Streaming output

For long-running document generation, use streamFlow instead of renderFlow. It emits PDF bytes to a WritableStream<Uint8Array> as each page closes. Peak heap is bounded at O(shared resources + one page in flight) regardless of total page count.

import { PDFDocument, StandardFonts } from "pdf-lib";
import { streamFlow, text, cleanTheme } from "boxpdf";

const pdf = await PDFDocument.create();
const font = await pdf.embedFont(StandardFonts.Helvetica);
const bold = await pdf.embedFont(StandardFonts.HelveticaBold);

const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>();
streamFlow(pdf, writable, generate(font, bold)).catch(console.error);

return new Response(readable, {
  headers: { "content-type": "application/pdf" }
});

async function* generate(font, bold) {
  for await (const order of fetchOrders()) {
    yield buildOrderRow(font, bold, order);
  }
}

For Node, adapt a stream.Writable:

import { createWriteStream } from "node:fs";
import { streamFlow, nodeAdapter } from "boxpdf";

const out = nodeAdapter(createWriteStream("./report.pdf"));
await streamFlow(pdf, out, nodes);

Contract

  • All embedFont / embedJpg / embedPng calls must complete before streamFlow. Embedding mid-stream throws.
  • The iterable is consumed one node at a time. Pass a generator.
  • streamFlow closes the writable on success and aborts it on failure. Don't write to it concurrently.
  • ctx.totalPages is not available in headers and footers. Accessing it throws. Use renderFlow if you need "Page X of Y".
  • Output is 0-5% larger than renderFlow's default save().

Memory bench

Peak heap during render. Each measurement runs in its own subprocess. 50 lines of text per page. @react-pdf/renderer included for shape comparison.

PagesstreamFlow peakrenderFlow peak@react-pdf peakOutput
5012.8 MB31.7 MB160.8 MB70 KB
25015.4 MB91.1 MB643.1 MB347 KB
50018.7 MB120.8 MB1,219.9 MB693 KB
100025.4 MB219.6 MB2,292.6 MB1.4 MB

streamFlow holds peak heap roughly flat (12 → 25 MB across a 100× workload increase). renderFlow scales roughly linearly with page count. @react-pdf/renderer adds ~2.3 MB per page in this workload and peaks at 2.3 GB by 1000 pages. See docs/design/streaming.md for the design and the chart.

Cloudflare Workers

Both the core and the boxpdf/inter subpath run on Workers without nodejs_compat.

import { Hono } from "hono";
import { PDFDocument, StandardFonts } from "pdf-lib";
import { cleanTheme, renderFlow, text } from "boxpdf";

const app = new Hono();

app.get("/receipt.pdf", async (c) => {
  const pdf  = await PDFDocument.create();
  const font = await pdf.embedFont(StandardFonts.Helvetica);
  const bold = await pdf.embedFont(StandardFonts.HelveticaBold);
  const t    = cleanTheme(font, bold);
  await renderFlow(pdf, [
    text("Thanks!", t.type.h1),
    text("This PDF was generated at the edge.", t.type.body)
  ]);
  const bytes = await pdf.save();
  return new Response(bytes, { headers: { "content-type": "application/pdf" } });
});

export default app;

Examples

Runnable scripts in examples/:

  • receipt.ts. Single-page receipt with totals.
  • itinerary.ts. Two-band travel itinerary.
  • invoice.ts. Multi-page invoice with running header and footer plus keepTogether.
  • debug.ts. Layout with { debug: true }.
  • themes-showcase.ts. The same receipt rendered in all four themes.
  • inter-showcase.ts. Clean theme rendered with Inter.
  • flex-shrink.ts. Three URL-overflow behaviors side by side.

Flex-shrink

Opt-in via shrink: number on any child of an hstack or vstack. When the sum of children's intrinsic main-axis sizes exceeds the parent's available space, items with shrink > 0 give up shares proportional to shrink × baseSize. Items with shrink = 0 (the default) are frozen.

hstack(
  { width: 360, gap: 16 },
  text("Customer:", { size: 11, font: bold }),
  text("Mr. Algernon Hephaestus Constantine Pemberton-Smythe III", {
    size: 11, font, shrink: 1
  })
)

Behavior:

  • A text child won't shrink below the width of its widest whitespace-separated word. Wrapping breaks on whitespace, not mid-word.
  • A single-token string (URL, hash, slug) won't shrink at all and overflows its slot visibly. Two opt-ins lower the floor:
    • maxLines: N. The engine ellipsizes overflow. The text shrinks to its slot and trims with .
    • breakWords: true. CSS overflow-wrap: break-word. Hard-breaks at character boundaries.
  • When shrunk text rewraps to more lines, the container's intrinsic height grows accordingly.
  • When one item hits its min-word floor, its remaining shrink weight redistributes to siblings.
  • Works on vstack too when the parent has a fixed height smaller than the sum of children.
  • link forwards its child's shrink weight, so linked text shrinks and re-wraps like bare text.

See examples/flex-shrink.ts.

Absolute positioning

Boxes can use a small CSS-like positioning model:

vstack(
  { width: 240, height: 120, position: "relative", padding: 16 },
  text("Receipt", { size: 18, font: bold }),
  hstack(
    { position: "absolute", top: 12, right: 12, width: 70 },
    text("PAID", { size: 14, font: bold, align: "center", width: 70 })
  )
)

Behavior:

  • Any positioned box establishes the containing block for absolute descendant boxes.
  • position: "absolute" removes a vstack or hstack from normal stack flow.
  • Absolute boxes render after normal children, so they can be used for stamps, badges, overlays, and watermarks.
  • top, right, bottom, and left are point offsets from the nearest positioned ancestor. If there is no positioned ancestor, they resolve against the current render() root.
  • If both left and right are set and width is omitted, the box stretches to the remaining width. top plus bottom does the same for height.
  • Absolute siblings render by zIndex from low to high. Boxes with the same zIndex keep document order.
  • Absolute boxes do not affect parent measurement, gaps, flex grow/shrink, or pagination. Give the containing box a fixed width and height when you need stable placement.

Limitations

  • Positioning supports relative containing boxes, out-of-flow absolute boxes, point offsets, zIndex, and stretch from paired edges.
  • Font shaping is whatever pdf-lib and fontkit support. Complex Indic, Arabic, and Thai shaping isn't here. Full HarfBuzz requires a different stack, none of which run on Cloudflare Workers today.
  • PDF linearization (reordering the byte stream so byte 1 is page 1) is not done. Streaming generation is supported via streamFlow. Linearization is a separate post-process and out of scope.

License

MIT © Erik Aronesty

Keywords

pdf

FAQs

Package last updated on 16 May 2026

Related posts