pdfme: A PDF Generator With a CLI Built for AI Agents

July 10, 2026

|repo-review

by Florian Narr

pdfme: A PDF Generator With a CLI Built for AI Agents

pdfme turns a JSON template into a PDF. You describe fields — text, tables, barcodes, images — with positions and a base PDF or blank page, hand it a data object, and it fills the template and returns bytes. It runs in Node and in the browser, and it ships a WYSIWYG designer for building the templates visually.

Why I starred it

Every PDF generation library eventually forces a choice: HTML-to-PDF (Puppeteer, wkhtmltopdf) or a positioned-field template system (pdf-lib, PDFKit). pdfme picked the second and then built real tooling around it — a designer UI, a viewer, and, as of the CLI package, a diagnostics command that's explicitly aimed at non-interactive callers.

That last part is what caught my eye. The packages/cli README says it's "for agentic workflows, local verification, or JSON-first template iteration." Most PDF libraries assume a human is looking at the rendered output. pdfme's CLI assumes an LLM generated the template JSON and nobody's going to eyeball it before it hits a customer.

How it works

The core render loop lives in packages/generator/src/generate.ts. generate() clones the template, resolves the base PDF pages, then for every input object it walks every page and every schema (field), looks up a renderer by schema.type in a plugin map, and calls it:

const render = renderObj[schema.type];
if (!render) continue;
const value = schema.readOnly
  ? replacePlaceholders({ content: schema.content || '', variables, schemas })
  : (input[name] || '');
await render({ value, schema: adjustedSchema, basePdf, pdfLib, pdfDoc, page, options, _cache });

That renderObj comes from a plugin registry (packages/common/src/pluginRegistry.ts) — a thin wrapper around Record<string, Plugin> with findByType/findWithLabelByType helpers. A Plugin (packages/common/src/types.ts:214) is just { pdf, ui, propPanel, icon }: a pdf-lib renderer, a DOM renderer for the designer, a schema for the property panel, and an SVG icon. Text, tables, barcodes, checkboxes, images — every field type in packages/schemas/src implements that same four-function contract. It's a clean seam: adding a new field type means writing one file that satisfies the interface, not touching the generator.

What's notable is what's not included by default. packages/schemas/src/builtins.ts is four lines:

const builtInPlugins = { Text: text };
export { builtInPlugins };

Tables, barcodes, images — you import and register those explicitly. The generator's own package.json dependencies are just @pdfme/pdf-lib (their pdf-lib fork) and fontkit. Nothing else. If you only render text fields, that's your entire dependency tree.

The other piece worth reading is packages/schemas/src/dynamicLayout.ts. Tables and expandable text don't have a fixed height — a table with 3 rows and one with 30 rows can't share a schema height. isDynamicLayoutSchema() flags table/list/expandable-text schemas, and getDynamicLayoutForSchema() dispatches to a per-type height calculator that measures the actual rendered content and returns split ranges so pdfme can paginate a table across pages without cutting a row in half. It's a small file, but it's the piece that turns "position a box on a page" into "lay out variable-length content and let it flow."

Using it

The CLI (@pdfme/cli, v6.1.12) is where the agent-focused design shows up. validate checks a template without touching pdf-lib:

$ npx @pdfme/cli validate template.json
 Template is valid (1 page(s), 2 field(s))

doctor goes further — it simulates the actual generate call and reports on things that only fail at runtime: unwritable output paths, missing fonts, CJK text that needs a font it doesn't have cached:

$ npx @pdfme/cli doctor template.json --verbose
Target: input
Mode: template
Template pages: 1
Fields: 2
Estimated pages: 1
Output: output.pdf
Healthy: yes
Issues: 0
Warnings: 0

I opened packages/cli/src/diagnostics.ts expecting a couple hundred lines of validation glue. It's 1,352 lines. It inspects base PDF resolution, required fonts per schema, CJK detection (packages/cli/src/cjk-detect.ts — a hand-rolled Unicode range scanner, not a library), output path collisions, and unknown template keys. That's a lot of surface area dedicated to "tell the caller exactly what's wrong before it wastes a generate call" — which makes sense if your caller is a model that can't visually inspect a PDF.

Generating is the same shape as any CLI tool:

$ npx @pdfme/cli generate -t template.json -i inputs.json -o out.pdf --force
 Output: out.pdf (4.0KB)

Rough edges

The monorepo is large — nine packages, and the schemas package alone covers text, tables, barcodes, checkboxes, radio groups, images, shapes, and more, each with its own dynamicTemplate.ts, pdfRender.ts, and uiRender.ts. Finding the one file you need takes a minute the first time. The CLI's --font flag documentation assumes you already understand pdfme's font registration model from the core library docs — it's not self-contained.

Commit activity is real: weekly releases, dependency bumps merged same-week, active issue triage. Test coverage backs that up — 134 test files against roughly 295 source files, including font fixtures for CJK rendering and dedicated CLI contract tests (packages/cli/__tests__/contract.test.ts).

Bottom line

If you're generating PDFs from structured data — invoices, certificates, reports — and want field-level control without an HTML-to-PDF pipeline, pdfme's plugin model is worth it. If you're generating templates programmatically with an LLM in the loop, the CLI's doctor command is the reason to pick this over pdf-lib directly: it turns silent runtime failures into a list of issues before you spend a generate call finding out the hard way.

pdfme/pdfme on GitHub
pdfme/pdfme