Go back
All posts
EngineeringJul 02, 2026·10 min read

Generating PDFs That Don't Look Generated, with @nemu-ai/pdf

D
Dragos
Full-stack Developer · Romania
Generating PDFs That Don't Look Generated, with @nemu-ai/pdf

Generating PDFs from code is one of those tasks that looks trivial until you try it. You either reach for a heavy headless browser to print HTML, which is slow and fragile, or you drop down to a low-level drawing library and hand-place every coordinate. I wanted a third option, so I built one. @nemu-ai/pdf is a modern PDF library for Node and Bun with two APIs over one engine: a declarative one that lays out content for you, and an imperative one for when you need exact control. It ships its own fonts, renders real charts as vectors, and typesets LaTeX. This is the tour. The source is at github.com/Nemu-Bridge/pdf.

Two APIs, one engine

The core idea is that PDF generation has two very different moods. Most of the time you have content, a report, an invoice, a document, and you want the library to handle flow and pagination so you can just say "here is a heading, here is a paragraph, here is a table." Occasionally you need to place a thing at exactly x and y, and you want total control.

Trying to serve both with one API always ends badly, so @nemu-ai/pdf offers two. Doc is the declarative, content-focused API that does layout and page breaks for you. Document is the imperative API that gives you coordinates and containers. They share the same rendering engine, the same fonts, the same colors, so you never relearn anything switching between them.

Installation is one line, and there is nothing else to set up. The fonts and dependencies are bundled, so you do not chase font files or install anything extra.

terminal
bun add @nemu-ai/pdf

Your first document

Here is a complete document with the declarative Doc API. You create a doc, optionally set some role-based styles, add content blocks to a page, and build to a file. That is the entire lifecycle.

report.ts
import { Doc } from "@nemu-ai/pdf";
 
const doc = new Doc({ page_size: "A4", margin: 54, padding: 10 });
 
doc.set_style({
  heading: { font_family: "inter", color: "#111827" },
  paragraph: { font_family: "source-serif-4", font_size: 11.5, line_height: 1.6 },
});
 
doc.page().content(
  { type: "heading", text: "Quarterly Report", level: 1 },
  { type: "paragraph", text: "Revenue grew across every region this quarter." },
  { type: "note", variant: "info", title: "Note", text: "Figures are unaudited." },
);
 
await doc.build("report.pdf");

Notice how little there is. No manual y-cursor, no "did this overflow the page" bookkeeping, no font registration. You describe the content and build writes the PDF. If the content runs past the page, it paginates on its own.

The building blocks

Everything in the Doc API is a content block, a small plain object with a type. Once you know the vocabulary, you can assemble almost any document. Here is the full set at a glance.

content blocks
{ type: "heading", text: "Title", level: 2 }
{ type: "paragraph", text: "Body text." }
{ type: "code", text: "const x = 1;", language: "ts" }
{ type: "formula", text: "E = mc^2" }
{ type: "list", items: ["First", "Second"], ordered: true }
{ type: "image", src: "logo", width: 120, height: 40 }
{ type: "divider" }
{ type: "spacer", size: 16 }
{ type: "note", variant: "warn", title: "Careful", text: "Provisional value." }
{ type: "table", headers: ["A", "B"], rows: [["1", "2"]] }
{ type: "chart", chart: "bar", data: { labels: ["Q1"], series: [{ values: [10] }] } }
{ type: "group", children: [] }

Two of these are worth pausing on. A note gives you the callout boxes you always end up needing, with variants like info and warn. And a group keeps its children together, so a heading and the paragraph under it will never be split across a page break. That "keep together" behavior is exactly the kind of thing you do not want to hand-manage, and here it is one block.

Text is not limited to plain strings either. A paragraph can be an array of inline nodes, so you can mix bold, italic, links, inline code and even inline formulas inside a single run of text.

inline content
{
  type: "paragraph",
  text: [
    "See the ",
    { type: "link", href: "https://example.com", text: { type: "strong", text: "spec" } },
    " and the identity ",
    { type: "formula", text: "e^{i\\pi} + 1 = 0" },
    ".",
  ],
}

Tables and real charts

Tables are a first-class block, and cells can be more than strings. A cell can carry its own alignment and rich text, so a number can be right-aligned and bold without you leaving the data structure.

a table
{
  type: "table",
  headers: ["Metric", "Q3", "Q4"],
  rows: [
    ["Revenue", "1.2M", { text: { type: "strong", text: "1.6M" }, align: "right" }],
    ["Margin", "18%", { text: "24%", align: "right" }],
  ],
}

Charts are the part I am proudest of. They render as native vector graphics drawn straight into the PDF, not as rasterized images pasted in, so they stay crisp at any zoom and keep the file small. You hand over labels and series, and you get a real chart.

a chart
{
  type: "chart",
  chart: "bar",
  title: "Revenue by quarter",
  legend: true,
  height: 220,
  data: {
    labels: ["Q1", "Q2", "Q3", "Q4"],
    series: [
      { name: "2024", values: [12, 19, 14, 23], color: "#111827" },
      { name: "2025", values: [16, 22, 20, 28], color: "#9ca3af" },
    ],
  },
}

The same shape works for the other chart types by changing one field: bar, line, area, pie and donut all read the same data structure. That consistency means you can swap a bar chart for a line chart by editing a single word.

Real math with LaTeX

If your documents involve any math, this is where the library earns its keep. A formula block is LaTeX, typeset by MathJax into vector paths, so equations come out as sharp as the text around them rather than as blurry screenshots.

latex math
doc.set_style({ formula: { font: "termes", font_size: 13, color: "#111827" } });
 
doc.page().content(
  { type: "formula", text: "\\int_0^\\infty e^{-x^2}\\,dx = \\frac{\\sqrt{\\pi}}{2}" },
);

You can pick the math font too, from a set that includes termes (the default), newcm, modern, pagella, stix2 and fira. For a document that mixes prose and mathematics, having the equations rendered as true vectors in a matching font is the difference between something that looks typeset and something that looks pasted together.

Fonts, weights and styling

Six variable fonts are bundled and registered for you, covering sans, serif and monospace, so a good-looking document needs zero font setup.

NameStyle
intersans serif
geistsans serif
geist-monomonospace
nunito-sanssans serif
robotosans serif
source-serif-4serif

Because these are real variable fonts, weight is a genuine axis, not a fake bolding trick. You can ask for a named weight or a numeric one, anywhere from thin to black.

font weights
{ type: "paragraph", text: "Heavy", style: { font_family: "inter", font_weight: "black" } }
{ type: "paragraph", text: "Light", style: { font_family: "inter", font_weight: 300 } }

Styling itself is just plain objects keyed by role. set_style sets a default for each role, and any block can pass its own style that merges on top. Roles cover headings, paragraphs, code, formulas, notes, tables and more, so you theme the whole document in one place and override per block when you need to.

role-based styles
doc.set_style({
  heading: { font_family: "inter", font_weight: "bold", color: "#0f172a" },
  paragraph: { font_family: "source-serif-4", font_size: 12, line_height: 1.6 },
  code: { font_family: "geist-mono", background_color: "#f6f8fa" },
  note: { color: "#1f2937" },
});

Precise control with the Document API

When you need to place things exactly, you switch to Document. It gives you pages you draw onto directly, with text, rect, image and table primitives, and an add method to place them.

invoice.ts
import { Document } from "@nemu-ai/pdf";
 
const pdf = new Document({ page_size: "A4", margin: 50 });
const page = pdf.create_page();
 
page.add(
  page.text({ content: "Invoice", style: { font_size: 28, font_weight: "bold" } }),
  page.text({ content: "Thank you for your business.", style: { color: "#6b7280" } }),
);
 
await pdf.build("invoice.pdf");

Even in imperative mode you are not forced to compute every coordinate. There are containers with real layout, both a flow layout that stacks vertically with a gap, and a flex layout with the justify and align options you already know from CSS.

flex layout
const row = page.create_container({
  layout: {
    type: "flex",
    direction: "row",
    justify: "space-between",
    align: "center",
    gap: 12,
  },
  width: page.get_content_width(),
});
 
row.add(page.text({ content: "Left" }), page.text({ content: "Right" }));
page.add(row);

For anything genuinely absolute, an element can carry a position, and everything without one simply flows down the page. And when you want brand consistency across a document, you can define a theme with named colors and pull them out by name.

theming
import { Document, create_theme } from "@nemu-ai/pdf";
 
const theme = create_theme("brand", {
  colors: { primary: "#1a365d", muted: "#718096", accent: "#3182ce" },
});
 
const pdf = new Document({ page_size: "A4", margin: 50 });
pdf.set_theme(theme);

Headers, footers and the finishing touches

Real documents need running headers and footers, and here they are functions of the page context, so page numbers just work. A footer can be a content block that reads the current page number and count.

a page footer
doc.set_footer((ctx) => ({
  type: "paragraph",
  text: `Page ${ctx.page_number} of ${ctx.page_count}`,
  style: { font_size: 9, color: "#9ca3af", text_align: "right" },
}));

If you want to draw the header by hand, a raw callback hands you the drawing surface and the area to work in, so you can render a rule, a logo, or anything else. There is also a proper color utility, Color, that parses hex, rgb and hsl and gives you lighten, darken, mix, alpha and an is_dark check, which is exactly what you want when generating themed documents where contrast has to stay readable.

color utility
import { Color } from "@nemu-ai/pdf";
 
const brand = Color({ hex: "#2563eb" });
brand.alpha(0.5);
brand.darken(0.1);
brand.is_dark();

Conclusion

That is @nemu-ai/pdf end to end: install it with one command, reach for Doc when you have content and want layout handled for you, and drop to Document when you need coordinates and containers. Along the way you get bundled variable fonts with real weights, vector charts, LaTeX math typeset by MathJax, role-based theming, and running headers and footers, all in a single dependency.

The reason I built two APIs instead of one is the same reason I keep coming back to in almost everything I make: the easy path and the precise path should not be different tools, they should be the same tool at two altitudes. Describe the document when you can, place it exactly when you must, and never rewrite anything to move between them. It is all open at github.com/Nemu-Bridge/pdf, fonts included.