{"id":50167353,"url":"https://github.com/earonesty/boxpdf","last_synced_at":"2026-05-25T16:06:42.485Z","repository":{"id":357800285,"uuid":"1238523120","full_name":"earonesty/boxpdf","owner":"earonesty","description":"Tiny box-layout DSL over pdf-lib. Flexbox-lite for server-side PDF generation in Node, Cloudflare Workers, Deno, and browsers.","archived":false,"fork":false,"pushed_at":"2026-05-14T09:25:37.000Z","size":1466,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-14T11:34:49.568Z","etag":null,"topics":["cloudflare-workers","flexbox","layout","pdf","pdf-lib","typescript"],"latest_commit_sha":null,"homepage":"https://earonesty.github.io/boxpdf/","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/earonesty.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-14T07:39:28.000Z","updated_at":"2026-05-14T09:25:41.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/earonesty/boxpdf","commit_stats":null,"previous_names":["earonesty/boxpdf"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/earonesty/boxpdf","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/earonesty%2Fboxpdf","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/earonesty%2Fboxpdf/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/earonesty%2Fboxpdf/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/earonesty%2Fboxpdf/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/earonesty","download_url":"https://codeload.github.com/earonesty/boxpdf/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/earonesty%2Fboxpdf/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33482481,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-25T14:31:05.219Z","status":"ssl_error","status_checked_at":"2026-05-25T14:31:02.878Z","response_time":57,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["cloudflare-workers","flexbox","layout","pdf","pdf-lib","typescript"],"created_at":"2026-05-24T22:00:24.047Z","updated_at":"2026-05-25T16:06:42.477Z","avatar_url":"https://github.com/earonesty.png","language":"TypeScript","funding_links":[],"categories":["Libraries"],"sub_categories":["JavaScript"],"readme":"# boxpdf\n\nA box-layout DSL over [pdf-lib](https://pdf-lib.js.org/). Runs in Node 18+, Cloudflare Workers, Deno, and browsers. No native dependencies, no WASM, no headless browser.\n\nLive gallery: \u003chttps://earonesty.github.io/boxpdf/\u003e\n\n```ts\nimport { PDFDocument, StandardFonts } from \"pdf-lib\";\nimport { cleanTheme, hline, hstack, renderFlow, text, vstack } from \"boxpdf\";\n\nconst pdf  = await PDFDocument.create();\nconst font = await pdf.embedFont(StandardFonts.Helvetica);\nconst bold = await pdf.embedFont(StandardFonts.HelveticaBold);\nconst theme = cleanTheme(font, bold);\n\nawait renderFlow(pdf, [\n  vstack({ gap: 8 },\n    text(\"Receipt #18472\", theme.type.h1),\n    text(\"May 14, 2026\", theme.type.caption)\n  ),\n  hline(theme.hr),\n  hstack({ gap: 16, justify: \"between\", width: 515 },\n    text(\"Wool socks\", theme.type.body),\n    text(\"$28.00\", { ...theme.type.body, font: bold, align: \"right\", width: 80 })\n  )\n]);\n\nconst bytes = await pdf.save();\n```\n\n## Install\n\n```sh\nnpm install boxpdf pdf-lib\n```\n\n`pdf-lib` is a peer dependency.\n\n## What it does\n\n- Declarative layout primitives: `vstack`, `hstack`, `text`, `image`, `hline`, `vline`, `spacer`, `flex`, `keepTogether`, `link`, `svgPath`, `table`.\n- Padding, margin, background, background images, borders, borderRadius, overflow clipping, flex-grow, flex-shrink, justify, align.\n- Rich paragraphs with mixed inline runs, inline replaced nodes, hard breaks, hanging indents, and optional paragraph floats.\n- Word wrapping with `maxLines` truncation, optional `breakWords`, and no-wrap control.\n- Themes: `cleanTheme`, `stripeTheme`, `editorialTheme`, `brutalistTheme`.\n- Multi-page flow with per-page headers and footers, stack fragmentation, and table row fragmentation.\n- Streaming generation for memory-bounded output.\n- PDF link annotations, text decorations, document metadata.\n- ~7 KB minified core. Custom fonts pull in `@pdf-lib/fontkit` only when you call `loadFont` or `embedInter`.\n\n## Templates\n\nFiles in [`templates/`](./templates) cover receipts, boarding passes, resumes, order confirmations, and certificates. Each is a single file.\n\nScaffold one into your app with the CLI:\n\n```sh\nnpx boxpdf init receipt --out src/pdf/receipt.ts\nnpx boxpdf list\n```\n\nThe CLI also ships a resource-only MCP server for agents:\n\n```sh\nclaude mcp add boxpdf -- npx -y boxpdf mcp\n```\n\n## Themes\n\n```ts\nimport { cleanTheme, stripeTheme, editorialTheme, brutalistTheme } from \"boxpdf\";\n\nconst theme = cleanTheme(font, bold);\n// stripeTheme(font, bold)\n// editorialTheme(font, bold, italic)\n// brutalistTheme(courier, courierBold)\n```\n\nEvery theme exposes the same shape: `colors`, `spacing`, `radii`, `type`, `card`, `hr`.\n\n## API\n\n### Containers\n\n- `vstack(style, ...children)`. Vertical layout.\n- `hstack(style, ...children)`. Horizontal layout.\n- `keepTogether({ gap?, margin? }, ...children)`. Paginates atomically.\n\nContainer `style`:\n\n| Field | Type | Notes |\n| --- | --- | --- |\n| `width` / `height` | number | Fixed dimensions; otherwise size to content. |\n| `padding` / `margin` | number \\| `{ top, right, bottom, left }` | Shorthand or per-side. |\n| `background` | RGB | Solid fill. |\n| `backgroundImage` | `{ image, width, height, offsetX?, offsetY?, repeat? }` | Image painted behind children and clipped to the box. |\n| `border` | `{ color, width }` | 1pt+ stroke around the box. |\n| `borderSides` | `{ top?, right?, bottom?, left? }` | Per-side strokes using `{ color, width }`. |\n| `borderRadius` | number | Corner radius. |\n| `overflow` | `\"visible\"` \\| `\"hidden\"` | Clips stack children and absolute descendants to the box rectangle. |\n| `position` | `\"relative\"` \\| `\"absolute\"` | CSS-like positioning for boxes. |\n| `top` / `right` / `bottom` / `left` | number | Absolute offsets in points. |\n| `zIndex` | number | Paint order for positioned boxes; higher values render later. |\n| `grow` | number | Flex grow weight along the parent's main axis. |\n| `shrink` | number | Flex shrink weight. |\n| `breakInside` | `\"auto\"` \\| `\"avoid\"` | Fragmentation hint under `renderFlow`; `avoid` keeps the box atomic. |\n| `gap` | number | Spacing between children. |\n| `justify` | `\"start\"` \\| `\"center\"` \\| `\"end\"` \\| `\"between\"` \\| `\"around\"` \\| `\"evenly\"` | Main-axis distribution. |\n| `align` | `\"start\"` \\| `\"center\"` \\| `\"end\"` \\| `\"stretch\"` \\| `\"baseline\"` | Cross-axis alignment. `baseline` is intended for `hstack` rows. |\n\n### Leaves\n\n- `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.\n- `paragraph({ width?, align?, lineHeight?, margin?, paddingLeft?, textIndent?, wrap?, floats? }, ...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? })`. Newlines in runs create hard breaks; `wrap: false` disables soft wrapping.\n- `image(pdfImage, { width, height, margin? })`. Takes an already-embedded `PDFImage`.\n- `imageFit(pdfImage, { width, height, fit?, margin? })`. Draws an image centered in a fixed rectangle, scaled to contain (default) or cover with clipping.\n- `spacer(size, { grow? })` / `flex(weight = 1)`. Fixed or growing gap.\n- `hline({ color, thickness?, width?, margin? })`.\n- `vline({ color, thickness?, height?, margin? })`.\n- `link({ href }, child)`. Wraps a child and registers a PDF Link annotation over its rendered bounding box.\n- `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? }`.\n\n### Rendering\n\n- `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?`, `profile?`. 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`.\n- `streamFlow(pdf, writable, asyncIterable, options)`. Incremental page-by-page rendering. Memory stays bounded regardless of page count. Writes PDF bytes to a `WritableStream\u003cUint8Array\u003e` as each page closes. See the Streaming section below for the contract.\n- `renderToPdf(node, options)`. One-page convenience.\n- `pageInner(size, margin)` / `pageContent(size, margin)`. Compute the inner content width or rectangle of a page.\n- `render(node, page, x, yTop, parentWidth)`. Draws a subtree at a known position on an existing `PDFPage`.\n- `measure(node, parentWidth)`. Intrinsic size without drawing.\n\nPass `{ debug: true }` to outline content boxes in red and margin boxes in orange.\n\n### Helpers\n\n- `loadFont(pdf, source, options?)`. Embed a TTF from URL, bytes, base64, or data URL.\n- `loadImage(pdf, source)`. Embed a PNG or JPEG (auto-detected).\n- `aspectRatio(ratio, { width })` / `aspectRatio(ratio, { height })`. Derive the missing dimension for fixed-ratio boxes or images.\n- `formatCurrency(n, { currency, locale })`. `Intl.NumberFormat` wrapper.\n- `defineStyles({ ... })`. Typed identity for reusable style bundles.\n- `hex(\"#1f8a4d\")` / `rgb255(31, 138, 77)`. Color builders.\n\n## Loading fonts\n\nThree options.\n\n**Bundled bytes via the CLI.** Recommended for production.\n\n```sh\nnpx boxpdf font add ./Acme-Regular.ttf=regular ./Acme-Bold.ttf=bold \\\n  --out src/fonts/acme.ts\n```\n\nGenerates `src/fonts/acme.ts` with `export const` base64 strings. Then:\n\n```ts\nimport { loadFont } from \"boxpdf\";\nimport { regular, bold } from \"./fonts/acme.js\";\n\nconst font = await loadFont(pdf, regular);\nconst acmeBold = await loadFont(pdf, bold);\n```\n\nBytes ship inside your bundle. No network round-trip.\n\n**The built-in Inter weights.**\n\n```ts\nimport { loadFont } from \"boxpdf\";\nimport { inter, interBold } from \"boxpdf/inter\";\n\nconst font = await loadFont(pdf, inter);\nconst bold = await loadFont(pdf, interBold);\n```\n\n`boxpdf/inter` re-exports the same Inter subset as raw base64 strings (`inter`, `interBold`, `interItalic`) and as `embedInter(pdf, { italic?, tabularFigures? })`.\n\nImporting `boxpdf/inter` loads ~325 KB of font bytes plus `@pdf-lib/fontkit`. The subpath isn't loaded otherwise.\n\n```ts\nimport { embedInter } from \"boxpdf/inter\";\n\nconst { font, bold } = await embedInter(pdf);\nconst theme = cleanTheme(font, bold);\n```\n\nPass `{ tabularFigures: true }` to also get tabular-numeral variants for money columns:\n\n```ts\nconst { font, bold, tabularFont, tabularBold } = await embedInter(pdf, {\n  tabularFigures: true\n});\n\ntext(formatCurrency(amount), { size: 12, font: tabularBold, align: \"right\" });\n```\n\n**Fetch from a URL.**\n\n```ts\nconst brand = await loadFont(pdf, \"https://example.com/Acme-Regular.ttf\");\n```\n\nThe 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.\n\n`loadFont` accepts the same `{ subset?: boolean; features?: { tnum: true } }` options regardless of the source. Use `features: { tnum: true }` to enable tabular numerals.\n\n## Streaming output\n\nFor long-running document generation, use `streamFlow` instead of `renderFlow`. It emits PDF bytes to a `WritableStream\u003cUint8Array\u003e` as each page closes. Peak heap is bounded at `O(shared resources + one page in flight)` regardless of total page count.\n\n```ts\nimport { PDFDocument, StandardFonts } from \"pdf-lib\";\nimport { streamFlow, text, cleanTheme } from \"boxpdf\";\n\nconst pdf = await PDFDocument.create();\nconst font = await pdf.embedFont(StandardFonts.Helvetica);\nconst bold = await pdf.embedFont(StandardFonts.HelveticaBold);\n\nconst { readable, writable } = new TransformStream\u003cUint8Array, Uint8Array\u003e();\nstreamFlow(pdf, writable, generate(font, bold)).catch(console.error);\n\nreturn new Response(readable, {\n  headers: { \"content-type\": \"application/pdf\" }\n});\n\nasync function* generate(font, bold) {\n  for await (const order of fetchOrders()) {\n    yield buildOrderRow(font, bold, order);\n  }\n}\n```\n\nFor Node, adapt a `stream.Writable`:\n\n```ts\nimport { createWriteStream } from \"node:fs\";\nimport { streamFlow, nodeAdapter } from \"boxpdf\";\n\nconst out = nodeAdapter(createWriteStream(\"./report.pdf\"));\nawait streamFlow(pdf, out, nodes);\n```\n\n### Contract\n\n1. All `embedFont` / `embedJpg` / `embedPng` calls must complete before `streamFlow`. Embedding mid-stream throws.\n2. The iterable is consumed one node at a time. Pass a generator.\n3. `streamFlow` closes the writable on success and aborts it on failure. Don't write to it concurrently.\n4. `ctx.totalPages` is not available in headers and footers. Accessing it throws. Use `renderFlow` if you need \"Page X of Y\".\n5. Output is 0-5% larger than `renderFlow`'s default `save()`.\n\n### Memory bench\n\nPeak heap during render. Each measurement runs in its own subprocess. 50 lines of text per page. `@react-pdf/renderer` included for shape comparison.\n\n| Pages | streamFlow peak | renderFlow peak | @react-pdf peak | Output |\n| ---:  | ---:            | ---:            | ---:            | ---:   |\n|    50 |     12.8 MB     |     31.7 MB     |    160.8 MB     |  70 KB |\n|   250 |     15.4 MB     |     91.1 MB     |    643.1 MB     | 347 KB |\n|   500 |     18.7 MB     |    120.8 MB     |  1,219.9 MB     | 693 KB |\n|  1000 |     25.4 MB     |    219.6 MB     |  2,292.6 MB     | 1.4 MB |\n\nstreamFlow 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.\n\n## Cloudflare Workers\n\nBoth the core and the `boxpdf/inter` subpath run on Workers without `nodejs_compat`.\n\n```ts\nimport { Hono } from \"hono\";\nimport { PDFDocument, StandardFonts } from \"pdf-lib\";\nimport { cleanTheme, renderFlow, text } from \"boxpdf\";\n\nconst app = new Hono();\n\napp.get(\"/receipt.pdf\", async (c) =\u003e {\n  const pdf  = await PDFDocument.create();\n  const font = await pdf.embedFont(StandardFonts.Helvetica);\n  const bold = await pdf.embedFont(StandardFonts.HelveticaBold);\n  const t    = cleanTheme(font, bold);\n  await renderFlow(pdf, [\n    text(\"Thanks!\", t.type.h1),\n    text(\"This PDF was generated at the edge.\", t.type.body)\n  ]);\n  const bytes = await pdf.save();\n  return new Response(bytes, { headers: { \"content-type\": \"application/pdf\" } });\n});\n\nexport default app;\n```\n\n## Examples\n\nRunnable scripts in [`examples/`](./examples):\n\n- `receipt.ts`. Single-page receipt with totals.\n- `itinerary.ts`. Two-band travel itinerary.\n- `invoice.ts`. Multi-page invoice with running header and footer plus `keepTogether`.\n- `debug.ts`. Layout with `{ debug: true }`.\n- `themes-showcase.ts`. The same receipt rendered in all four themes.\n- `inter-showcase.ts`. Clean theme rendered with Inter.\n- `flex-shrink.ts`. Three URL-overflow behaviors side by side.\n- `hanging-indent.ts`. Paragraph `paddingLeft` plus negative `textIndent` for list markers.\n- `overflow-clipping.ts`. Clipped cards with absolute overlays and background images.\n\n## Flex-shrink\n\nOpt-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 \u003e 0` give up shares proportional to `shrink × baseSize`. Items with `shrink = 0` (the default) are frozen.\n\n```ts\nhstack(\n  { width: 360, gap: 16 },\n  text(\"Customer:\", { size: 11, font: bold }),\n  text(\"Mr. Algernon Hephaestus Constantine Pemberton-Smythe III\", {\n    size: 11, font, shrink: 1\n  })\n)\n```\n\nBehavior:\n\n- A text child won't shrink below the width of its widest whitespace-separated word. Wrapping breaks on whitespace, not mid-word.\n- A single-token string (URL, hash, slug) won't shrink at all and overflows its slot visibly. Two opt-ins lower the floor:\n  - `maxLines: N`. The engine ellipsizes overflow. The text shrinks to its slot and trims with `…`.\n  - `breakWords: true`. CSS `overflow-wrap: break-word`. Hard-breaks at character boundaries.\n- When shrunk text rewraps to more lines, the container's intrinsic height grows accordingly.\n- When one item hits its min-word floor, its remaining shrink weight redistributes to siblings.\n- Works on `vstack` too when the parent has a fixed `height` smaller than the sum of children.\n- `link` forwards its child's shrink weight, so linked text shrinks and re-wraps like bare text.\n\nSee `examples/flex-shrink.ts`.\n\n## Absolute positioning\n\nBoxes can use a small CSS-like positioning model:\n\n```ts\nvstack(\n  { width: 240, height: 120, position: \"relative\", padding: 16 },\n  text(\"Receipt\", { size: 18, font: bold }),\n  hstack(\n    { position: \"absolute\", top: 12, right: 12, width: 70 },\n    text(\"PAID\", { size: 14, font: bold, align: \"center\", width: 70 })\n  )\n)\n```\n\nBehavior:\n\n- Any positioned box establishes the containing block for absolute descendant boxes.\n- `position: \"absolute\"` removes a `vstack` or `hstack` from normal stack flow.\n- Absolute boxes render after normal children, so they can be used for stamps, badges, overlays, and watermarks.\n- `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.\n- 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.\n- Absolute siblings render by `zIndex` from low to high. Boxes with the same `zIndex` keep document order.\n- 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.\n\n## Limitations\n\n- Positioning supports relative containing boxes, out-of-flow absolute boxes, point offsets, `zIndex`, and stretch from paired edges.\n- 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.\n- 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.\n\n## License\n\nMIT © Erik Aronesty\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fearonesty%2Fboxpdf","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fearonesty%2Fboxpdf","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fearonesty%2Fboxpdf/lists"}