@nemu-ai/pdf
v1.5.5
Published
PDF generation for Node and Bun with a declarative document API and a low level element API, bundled variable fonts with real weights, LaTeX math, native charts, and theming.
Maintainers
Readme
@nemu-ai/pdf
A modern PDF generation library for Node and Bun. It ships two APIs from one package: a
declarative Doc for writing documents as content blocks, and a low level Document for
placing elements with full control. Both render through a single measure, layout, render
engine that embeds fonts, draws vector graphics, and typesets LaTeX.
Features
- Two APIs that share one engine: declarative
Docand imperativeDocument. - Six variable fonts bundled and auto registered, with real weights from thin to black.
- LaTeX math typeset by MathJax into crisp vector paths.
- Native vector charts: bar, line, area, pie, donut.
- Tables with rich cells, notes, lists, code blocks, dividers, and images.
- Flex and flow containers, z index layering, and explicit positioning.
- Headers and footers as blocks, functions of page context, or raw drawing callbacks.
- Themes, role styles, and a small
Colorutility. - Works on plain Node 18+ and Bun, in both ESM and CommonJS.
Install
npm install @nemu-ai/pdf
# or
bun add @nemu-ai/pdfFonts and the font instancer are bundled, so nothing else is required. The default math font for LaTeX is included; additional math fonts are optional installs.
Choosing an API
Use Doc for documents: reports, articles, invoices, anything that is content first. You
describe blocks and the library handles layout, pagination, headers, and footers.
Use Document when you need exact coordinates, custom layouts, overlapping elements, or
fine control over every element. Doc itself compiles down to Document, so you can mix
mental models freely.
Doc: the declarative API
A Doc is a sequence of pages, and each page is a list of content blocks. Set role styles
once, add content, and call build.
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 },
link: { color: "#2563eb" },
});
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");Doc methods
new Doc(options?: DocOptions)
doc.set_style(styles: RoleStyles): this
doc.set_header(header: HeaderFooter): this
doc.set_footer(footer: HeaderFooter): this
doc.load_font(name: string, path: string, variable?: boolean): this
doc.load_image(name: string, path: string): this
doc.page(config?: PageConfig): DocPage
await doc.build(file_path: string): Promise<void>
page.content(...items: Array<Block | Block[]>): thisnew Doc(options) turns markdown parsing on for plain strings by default. content
flattens arrays, so a component that returns Block[] can be spread inline. build writes
the file and is always awaited. margin plus padding form the page content inset, and
auto_paginate is on unless you set it to false.
Blocks
Every block is a tagged object. A block level style overrides the role style for that one
block.
{ 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: [/* blocks kept together across page breaks */] }group prevents its children from splitting across a page break, which is the natural way
to keep a heading with its first paragraph. spacer adds vertical space, divider draws a
rule, and image references a path or a name registered with load_image.
Inline content
Wherever a block takes text, it accepts a string, a single inline node, or an array
mixing both. Inline nodes nest, so a link can wrap a formula and a strong run can wrap a
link.
{
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" },
".",
],
}Inline node types are strong, em, strike, link with href, formula, and code.
When markdown is enabled, plain strings also honor **bold**, *italic*, ~~strike~~,
and $inline math$. For generated content, structured inline nodes are the reliable path.
Styling and roles
Styles are plain objects. set_style sets a default per role, and a block style merges
on top. The roles are heading, paragraph, code, formula, list, link, group,
divider, note, table, and chart.
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" },
});The style fields used most in documents are color, background_color, font_family,
font_size, font_weight, line_height, text_align, margin, and padding. Colors
accept a hex string or a Color. See Fonts and weights for font_weight.
Fonts and weights
Six variable fonts are bundled and registered automatically. Use them by name:
| Name | Style |
| --- | --- |
| inter | sans serif |
| geist | sans serif |
| geist-mono | monospace |
| nunito-sans | sans serif |
| roboto | sans serif |
| source-serif-4 | serif |
font_weight selects a real weight by instancing the variable font. Names map to the
usual axis values and numbers pass straight through.
{ type: "paragraph", text: "Heavy", style: { font_family: "inter", font_weight: "black" } }
{ type: "paragraph", text: "Light", style: { font_family: "inter", font_weight: 300 } }Weight names: thin 100, extralight 200, light 300, regular 400, medium 500,
semibold 600, bold 700, extrabold 800, black 900. Markdown **bold** and the
strong inline node also produce a real bold weight. The bundled fonts are upright only,
so italic renders upright. To use a custom variable font with weights, register it with
doc.load_font(name, path, true).
Formulas
Block and inline formula text is LaTeX, typeset by MathJax into vector paths. The default
math font is termes. Other fonts are optional installs.
import { available_formula_fonts, default_formula_font } from "@nemu-ai/pdf";
// available_formula_fonts -> ["termes", "newcm", "modern", "pagella", "stix2", "fira"]
// default_formula_font -> "termes"
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}" },
);A non default font needs its package, for example npm i @mathjax/mathjax-pagella-font. A
missing font logs a warning and falls back to termes.
Tables
{
type: "table",
headers: ["Metric", "Q3", "Q4"],
rows: [
["Revenue", "1.2M", { text: { type: "strong", text: "1.6M" }, align: "right" }],
["Margin", "18%", { text: "24%", align: "right" }],
],
}A cell is a string or { text, align }, where text is inline content. Bold, links, and
inline formulas all work inside cells. columns is optional; omit it to size columns
equally from the header or first row. Set it to a count for equal columns, or to an array
of absolute widths in points where 0 means auto.
Charts
Charts render as native vector graphics with no image step.
{
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" },
],
},
}bar, line, and area read labels and series. pie and donut read slices,
where each slice is { label, value, color? }.
Headers, footers, and raw drawing
A header or footer is a block, an array of blocks, a function of page context, or a raw draw callback. Functions run per page at build time.
doc.set_footer((ctx) => ({
type: "paragraph",
text: `Page ${ctx.page_number} of ${ctx.page_count}`,
style: { font_size: 9, color: "#9ca3af", text_align: "right" },
}));
doc.set_header({
height: 40,
draw: (pdoc, area) => {
pdoc.rect(area.x, area.y + area.height - 1, area.width, 1).fill("#e5e7eb");
},
});The page context is { page_number, page_count, date }. The raw draw callback receives the
live pdfkit document and the zone rectangle { x, y, width, height } plus the page context.
Set per page headers and footers through doc.page({ header, footer }).
Reusable components
A component is a function that returns a block or an array of blocks. Spread it into
content.
const section = (title: string, body: InlineContent): Block[] => [
{ type: "heading", text: title, level: 2 },
{ type: "paragraph", text: body },
];
doc.page().content(
...section("Overview", "What this document covers."),
{ type: "note", variant: "info", text: "Spread arrays of blocks directly." },
);See examples/simplified for a full multi page Doc reference document.
Document: the low level API
Document places elements directly. You create pages, build elements with the page
factories, add them to the page or to containers, and call build. Elements flow top to
bottom by default, or sit at explicit coordinates when given a position.
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");The pipeline runs in three passes. Measure computes each element's intrinsic size from font metrics. Layout assigns positions from the page margin and header zone. Render draws every element in z index order.
Document and Page
new Document(options?: DocumentOptions)
pdf.create_page(theme?: Theme): Page
pdf.add_page(): Page
pdf.set_theme(theme: Theme): void
pdf.load_font_sync(name: string, file_path: string, variable?: boolean): void
pdf.load_image_sync(name: string, file_path: string): void
await pdf.build(file_path: string): Promise<void>DocumentOptions are page_size ("A4" | "Letter" | "Legal" | "Custom"),
custom_dimensions ({ width, height } in points), margin (a number or per side values),
and parse_markdown. build is the full pipeline; it is split internally into
prepare_pdf and render_to so a caller can measure before rendering.
The page factories build elements but do not place them. Add what you build with page.add
or a container's add.
page.text(options: CreateTextOptions): TextElement
page.rect(options: CreateRectOptions): RectElement
page.image(options: CreateImageOptions): ImageElement
page.table(options: CreateTableOptions): TableElement
page.create_container(options?: CreateContainerOptions): ContainerElement
page.header_container(options?: CreateContainerOptions): HeaderContainer
page.footer_container(options?: CreateContainerOptions): FooterContainer
page.add(...elements: BaseElement[]): thisheader_container and footer_container are idempotent and return the existing zone if
called again. The page also exposes get_content_width() and get_content_height() along
with margin helpers.
Text, rectangles, and images
const label = page.text({
content: "Absolute label",
position: { x: 100, y: 200 },
style: { font_size: 11 },
});
const card = page.rect({
width: 400,
height: 120,
shape_style: { fill_color: "#1a365d", border_radius: 10 },
});
pdf.load_image_sync("logo", "./assets/logo.png");
const img = page.image({ name: "logo", width: 120, height: 40 });
page.add(label, card, img);A position makes an element explicit and absolute. Without one it joins the flow.
Rectangle colors come from style.background_color or a shape_style with fill_color,
stroke_color, stroke_width, and border_radius. Images reference a name registered with
load_image_sync.
Overlay children and z index
Any element can own overlay children built with element.text, element.rect,
element.image, or element.create_container. Children are positioned relative to the
parent's top left corner and render on top of it, sorted by z index.
const box = page.rect({
width: 300,
height: 80,
shape_style: { fill_color: "#2b6cb0", border_radius: 8 },
});
box.text({
content: "Rendered on top of the box.",
style: { color: "#ffffff", padding: 12 },
z_index: 1,
});
page.add(box);Within the same parent, elements render in ascending z index order regardless of declaration order.
Containers and layout
A container groups children with a flow or 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);Flow stacks children vertically with an optional gap. Flex distributes them along a row or
column. justify is one of flex-start, flex-end, center, space-between,
space-around, space-evenly. align is one of flex-start, flex-end, center,
stretch, baseline. Containers also accept style, width, height, position, and
classname.
Tables
const table = page.table({
columns: [180, 0, 100],
border_width: 0.5,
cell_padding: 8,
header_style: { background_color: "#ebf4ff" },
width: page.get_content_width(),
});
table.add_row(["Name", "Role", "Status"]);
table.add_row(["Alice Nguyen", "Lead Engineer", "Active"]);
table.add_row([
{ content: "Bob Chen", style: { color: "#718096" } },
"Designer",
"Active",
]);
page.add(table);columns is an equal count or an array of widths, where 0 means auto width. Each cell is
a string or { content, style }. Borders are controlled by border_color, border_width,
and cell_padding.
Themes
A theme centralizes named colors and applies default styles by element type and classname.
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);
const page = pdf.create_page(theme);
theme.get_color("primary");See examples/showcase for a full multi page Document built with a theme.
Color
Color builds a color from one input form and converts to any other. Construct it as a
call or with new; both work, and you cannot mix input forms.
import { Color } from "@nemu-ai/pdf";
const a = Color({ hex: "#2563eb" });
const b = new Color({ rgb: [37, 99, 235] });
const c = Color({ hsl: [217, 91, 60] });
a.to_hex(); // "#2563eb"
a.to_rgba(); // "rgba(37, 99, 235, 1)"
a.alpha(0.5); // a Color at 50% opacity
a.lighten(0.1);
a.darken(0.1);
a.mix(b, 0.5);
a.is_dark(); // trueInput forms are { hex }, { rgb }, { rgba }, { hsl }, and { hsla }. A Color is
accepted anywhere a color is, so any color or background_color field takes a hex string
or a Color.
Reference
Page sizes
| Name | Width (pt) | Height (pt) | | --- | --- | --- | | A4 | 595.28 | 841.89 | | Letter | 612 | 792 | | Legal | 612 | 1008 |
Use custom_dimensions for any other size.
const pdf = new Document({ custom_dimensions: { width: 400, height: 600 } });Vector
Vector is the spatial primitive for positions and sizes. vector(x, y) is a convenience
constructor. After build, elements expose get_position(), get_size(), and
get_bbox().
import { vector } from "@nemu-ai/pdf";
const v = vector(100, 200); // v.x === 100, v.y === 200Types
All of the following are exported as types: Block, Inline, InlineContent, DocOptions,
PageConfig, PageContext, HeaderFooter, RawDraw, DrawArea, RoleStyles,
StyleRole, TableCell, TableRow, ChartKind, ChartData, ChartSeries, ChartSlice,
StyleProperties, DocumentOptions, PageSize, PageDimensions, MarginValues,
ColorLike, ColorInput, ColorValue, ContainerLayout, FlexLayoutOptions,
FlowLayoutOptions, CreateTextOptions, CreateRectOptions, CreateImageOptions,
CreateTableOptions, and CreateContainerOptions.
For an API map aimed at language models, see llms.txt.
License
MIT. Bundled fonts are licensed under the SIL Open Font License; see dist/fonts/OFL.txt.
