@nemu-ai/pdf
v1.1.1
Published
A modern PDF generation library with theming and vector support
Maintainers
Readme
@nemu-ai/pdf
A TypeScript library for generating PDFs programmatically. It builds on PDFKit and adds a structured element tree, a three-pass layout engine (measure, layout, render), composable flex and flow containers, header and footer zones, parent-child element relationships, z-index layering, theming, and inline markdown and LaTeX parsing.
Installation
bun add @nemu-ai/pdf
# or
npm install @nemu-ai/pdfQuick start
import { Document } from "@nemu-ai/pdf";
const doc = new Document({ page_size: "A4", margin: 50 });
const page = doc.create_page();
page.add(
page.text({ content: "Hello, world!", style: { font_size: 24 } }),
page.text({ content: "Generated with @nemu-ai/pdf." }),
);
await doc.build("output.pdf");Design overview
The library follows a three-pass pipeline:
- Measure - each element computes its intrinsic size using PDFKit font metrics.
- Layout - positions are computed top-down from the page margin and header zone height.
- Render - elements draw themselves to PDFKit in z-index order.
You build the element tree before calling build(). Factory methods on Page create elements; page.add() places them in the page's flow. Container elements have their own add() method for nesting children. Any element can also own overlay children created via element.text(), element.rect(), etc., which render on top of the parent positioned relative to its top-left corner.
Classes
Document
The root object. Holds page dimensions, margin, font registrations, and the list of pages.
new Document(options?: DocumentOptions)Options
| Property | Type | Default | Description |
|---|---|---|---|
| page_size | PageSize | "A4" | Named page size. |
| custom_dimensions | PageDimensions | - | Explicit { width, height } in points. Takes precedence over page_size. |
| margin | number \| MarginValues | 72 | Page margin in points. A single number sets all four sides equally. |
| parse_markdown | boolean | false | Enables inline markdown parsing globally for all text elements. |
Methods
create_page(theme?: Theme): Page
Creates a new page and appends it to the document. The optional theme overrides the document's default theme for this page.
add_page(): Page
Alias for create_page() with no theme argument.
set_theme(theme: Theme): void
Sets the default theme applied to every page unless overridden at page creation.
load_font(name: string, path: string): Promise<void>
load_font_sync(name: string, path: string): void
Registers a custom TTF or OTF font under name. The name is then used in style.font_family. Custom fonts must be loaded before calling build().
await doc.load_font("NotoSans", "./fonts/NotoSans-Regular.ttf");
await doc.load_font("NotoSans-Bold", "./fonts/NotoSans-Bold.ttf");load_image(name: string, path: string): Promise<void>
load_image_sync(name: string, path: string): void
Registers an image under name for use with page.image({ name }).
build(file_path: string): Promise<void>
Runs the full measure-layout-render pipeline for every page and writes the PDF to file_path.
Properties
| Property | Type | Description |
|---|---|---|
| page_width | number | Page width in points. |
| page_height | number | Page height in points. |
| margin | MarginValues | Resolved margin with top, right, bottom, left fields. |
| pages | Page[] | All pages in declaration order. |
Page
Represents one page. You obtain it from doc.create_page(). Do not construct it directly.
Header and footer zones
page.header_container(options?: CreateContainerOptions): HeaderContainer
page.footer_container(options?: CreateContainerOptions): FooterContainerBoth methods are idempotent - calling them more than once returns the existing zone. The header sits at the top margin; the footer sits at the bottom margin. Their heights are determined by their content at measure time, and the body content area is inset accordingly.
const header = page.header_container({ style: { padding_bottom: 12 } });
header.add(
page.text({ content: "My report", style: { font_size: 10 } }),
);Element factories
All factory methods return an element reference. The element is not placed on the page until you call page.add() or include it inside a container via container.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
Placing elements
page.add(...elements: BaseElement[]): this
Appends elements to the page's root flow. Returns the page for chaining.
Dimension helpers
| Method | Returns |
|---|---|
| get_width() | Page width in points |
| get_height() | Page height in points |
| get_content_width() | Page width minus left and right margin |
| get_content_height() | Page height minus top and bottom margin |
| get_margin_left() | Left margin |
| get_margin_right() | Right margin |
| get_margin_top() | Top margin |
| get_margin_bottom() | Bottom margin |
BaseElement
Abstract base class for all element types. You never construct this directly.
Properties
| Property | Type | Description |
|---|---|---|
| id | symbol | Unique identity for this element instance. |
| layout_mode | LayoutMode | "flow" or "explicit". Set automatically based on whether position is provided. |
| z_index | number | Draw order within the same parent. Higher values render on top. Default is 0. |
| measured_size | MeasuredSize \| null | Set after the measure pass. |
| computed_position | Vector \| null | Set after the layout pass. |
| computed_size | Vector \| null | Set after the layout pass. |
Introspection methods (available after build())
get_position(): Vector - throws if called before build().
get_size(): Vector - throws if called before build().
get_bbox(): BoundingBox - returns { x, y, width, height }.
Child factory methods
Any element can own overlay children. These children are positioned relative to the parent element's top-left corner and rendered on top of it, sorted by z-index.
element.text(options: CreateTextOptions): TextElement
element.rect(options: CreateRectOptions): RectElement
element.image(options: CreateImageOptions): ImageElement
element.create_container(options?: CreateContainerOptions): ContainerElement
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);Children with position are offset from the parent's top-left corner. Children without position render at the parent's top-left corner.
box.text({
content: "Bottom right label",
position: { x: 200, y: 56 },
z_index: 1,
style: { font_size: 9 },
});TextElement
Renders a string of text. Supports inline markdown and LaTeX when enabled.
Options: CreateTextOptions
| Property | Type | Description |
|---|---|---|
| content | string | The text to render. Required. |
| style | StyleProperties | Visual styling. |
| classname | string | Theme class name to merge. |
| position | VectorLike | If set, the element uses explicit layout mode and is placed at this offset from the content origin (or parent top-left). |
| width | number | Wrap width in points. Defaults to the available width from the parent. |
| parse_markdown | boolean | Overrides the document-level parse_markdown flag for this element. |
| z_index | number | Draw order. Default 0. |
Layout modes
When position is omitted, the element participates in flow layout and the parent places it. When position is provided, the parent positions it at that offset and does not advance the cursor past it.
const label = page.text({
content: "Absolute label",
position: { x: 100, y: 200 },
});
page.add(label);RectElement
Draws a filled and/or stroked rectangle.
Options: CreateRectOptions
| Property | Type | Description |
|---|---|---|
| style | StyleProperties | General styling (padding, background color). |
| shape_style | ShapeStyle | Fill color, stroke color, stroke width, border radius. |
| position | VectorLike | Explicit position offset if needed. |
| width | number | Width in points. Defaults to available width. |
| height | number | Height in points. Defaults to 0. |
| z_index | number | Draw order. Default 0. |
const card = page.rect({
width: 400,
height: 120,
shape_style: {
fill_color: "#1a365d",
border_radius: 10,
},
});
page.add(card);ImageElement
Embeds a registered image.
Options: CreateImageOptions
| Property | Type | Description |
|---|---|---|
| name | string | The name used when calling doc.load_image(). Required. |
| position | VectorLike | Explicit position offset if needed. |
| width | number | Rendered width in points. |
| height | number | Rendered height in points. |
| z_index | number | Draw order. Default 0. |
await doc.load_image("logo", "./assets/logo.png");
const img = page.image({ name: "logo", width: 120, height: 40 });
page.add(img);ContainerElement
Groups child elements and controls how they are arranged. The two layout strategies are flow (vertical stacking) and flex (row or column with alignment).
Options: CreateContainerOptions
| Property | Type | Description |
|---|---|---|
| layout | ContainerLayout | Flow or flex layout configuration. Defaults to { type: "flow" }. |
| style | StyleProperties | Padding, background color. |
| classname | string | Theme class name to merge. |
| position | VectorLike | Explicit position offset if needed. |
| width | number | Width in points. Defaults to available width. |
| height | number | Height in points. If omitted, height is derived from children. |
| z_index | number | Draw order. Default 0. |
Methods
add(...elements: BaseElement[]): this
Adds layout children to this container. Children flow or flex inside the container's padded area.
get_elements(): BaseElement[]
Returns a copy of the layout children array.
Flow layout
Children stack vertically with an optional gap.
const stack = page.create_container({
layout: { type: "flow", gap: 8 },
style: { padding: 16, background_color: "#f7fafc" },
width: 400,
});
stack.add(
page.text({ content: "First item" }),
page.text({ content: "Second item" }),
page.text({ content: "Third item" }),
);
page.add(stack);Flex layout
Children are distributed along a row or column.
const row = page.create_container({
layout: {
type: "flex",
direction: "row",
justify: "space-between",
align: "center",
gap: 12,
},
width: 500,
height: 48,
});
row.add(
page.text({ content: "**Label**" }),
page.text({ content: "$42,000" }),
);
page.add(row);FlexLayoutOptions fields:
| Property | Type | Default | Description |
|---|---|---|---|
| type | "flex" | required | |
| direction | FlexDirection | required | "row" or "column". |
| justify | FlexJustify | "flex-start" | Main-axis alignment. |
| align | FlexAlign | "flex-start" | Cross-axis alignment. |
| gap | number | 0 | Gap between children in points. |
justify values: "flex-start", "flex-end", "center", "space-between", "space-around", "space-evenly".
align values: "flex-start", "flex-end", "center", "stretch", "baseline".
TableElement
Renders a grid with optional header row styling, cell padding, and borders.
Options: CreateTableOptions
| Property | Type | Description |
|---|---|---|
| columns | number \| number[] | Number of equal columns, or an array of column widths in points. Use 0 for auto-width columns. |
| style | StyleProperties | Base text style applied to all cells. |
| width | number | Total table width in points. Defaults to available width. |
| border_color | string | Hex color for cell borders. Default "#e2e8f0". |
| border_width | number | Border stroke width. Default 1. Set to 0 to hide borders. |
| cell_padding | number | Inner padding for each cell in points. Default 8. |
| header_style | StyleProperties | Style applied to the first row. |
| cell_style | StyleProperties | Style applied to all non-header cells. |
| position | VectorLike | Explicit position if needed. |
| z_index | number | Draw order. Default 0. |
Methods
add_row(cells: Array<string | TableCellInput>): this
Appends a row. The first row receives header_style. Each cell can be a plain string or { content: string, style: StyleProperties } for per-cell overrides.
const table = page.table({
columns: [200, 0, 100],
border_color: "#cbd5e0",
border_width: 0.5,
cell_padding: 8,
header_style: { background_color: "#ebf4ff", font_size: 11 },
cell_style: { font_size: 11 },
});
table.add_row(["Name", "Role", "Status"]);
table.add_row(["Alice", "Engineer", "Active"]);
table.add_row([
{ content: "Bob", style: { color: "#718096" } },
"Designer",
{ content: "Inactive", style: { color: "#c53030" } },
]);
page.add(table);HeaderContainer / FooterContainer
Extend ContainerElement. Use page.header_container() and page.footer_container() - do not construct directly.
Their height (zone_height) is determined during the measure pass and is used by the layout engine to push the body content area down (header) or constrain it from the bottom (footer).
Styling
The StyleProperties object controls typography and spacing. All properties are optional.
| Property | Type | Description |
|---|---|---|
| font_family | string | Font name. Defaults to "Helvetica". |
| font_size | number | Font size in points. Default 12. |
| font_weight | string | "bold" or "normal". |
| font_style | string | "italic" or "normal". |
| color | string | Text color as a hex string, e.g. "#1a365d". |
| background_color | string | Background fill color. |
| text_align | string | "left", "center", "right", "justify". |
| line_height | number | Line height multiplier. Default 1.2. |
| padding | number | Shorthand for all four sides. |
| padding_top | number | Top padding in points. |
| padding_right | number | Right padding in points. |
| padding_bottom | number | Bottom padding in points. |
| padding_left | number | Left padding in points. |
Padding affects both measured size and text render position. A text element with padding: 12 starts its text 12 points from its computed top-left corner.
ShapeStyle
Controls the visual appearance of RectElement.
| Property | Type | Description |
|---|---|---|
| fill_color | string | Fill color as a hex string. |
| stroke_color | string | Stroke color. |
| stroke_width | number | Stroke line width in points. |
| border_radius | number | Corner radius in points. |
Theming
Themes centralize colors and element styles. You create a theme once and apply it at the document or page level.
import { Document, create_theme } from "@nemu-ai/pdf";
const theme = create_theme("brand", {
colors: {
primary: "#1a365d",
muted: "#718096",
},
});
const doc = new Document({ page_size: "A4", margin: 50 });
doc.set_theme(theme);create_theme(name: string, config: ThemeConfig): Theme
Creates and returns a Theme instance.
theme.get_color(name: string): string
Returns the hex color registered under name. Throws if not found.
theme.merge_classname(style: StyleProperties, classname: string): StyleProperties
Returns a new style that is the base merged with the class definition for classname.
theme.apply_to_style(style: StyleProperties, element_type: string): StyleProperties
Returns a style with default element-level properties applied.
Markdown parsing
Inline markdown is supported when parse_markdown: true is set on the document or on an individual text element.
| Syntax | Result |
|---|---|
| **text** or __text__ | Bold |
| *text* or _text_ | Italic |
| ***text*** or ___text___ | Bold and italic |
| ~~text~~ | Strikethrough |
| $formula$ | Inline LaTeX, converted to readable text |
| $$formula$$ | Block LaTeX, converted to readable text |
const doc = new Document({ parse_markdown: true });
const page = doc.create_page();
page.add(
page.text({
content: "The **Q4 revenue** grew by *32%*, with ***record margins*** in all segments. ~~Previous guidance~~ is revised.",
}),
);You can override the document setting per element:
page.add(
page.text({
content: "Raw **asterisks** not parsed.",
parse_markdown: false,
}),
);LaTeX support
Math expressions inside $...$ (inline) or $$...$$ (display) are parsed and converted to readable text using a symbol table. The output is rendered in italic.
page.add(
page.text({ content: "Pythagorean theorem: $a^2 + b^2 = c^2$" }),
page.text({ content: "Quadratic formula: $x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$" }),
);Font support
Standard built-in PDF fonts (Helvetica, Times-Roman, Courier) only cover the Latin-1 character set (U+0000 to U+00FF). Operators such as \times (x), \div (div), and \pm (+/-) map to Latin-1 characters and render correctly. Greek letters (\alpha, \beta, etc.) and most math symbols fall outside Latin-1 and are rendered as ASCII text approximations (e.g. alpha, beta) unless you register a Unicode font.
To render proper Greek and math symbols, register a Unicode-capable font such as NotoSans:
await doc.load_font("NotoSans", "./fonts/NotoSans-Regular.ttf");
await doc.load_font("NotoSans-Bold", "./fonts/NotoSans-Bold.ttf");
page.add(
page.text({
content: "Euler's identity: $e^{i\\pi} + 1 = 0$",
style: { font_family: "NotoSans" },
}),
);Z-index and layering
Every element has a z_index property. Within the same parent, elements are rendered in ascending z-index order regardless of declaration order.
Root page elements use z-index to sort the final render pass. Container children are sorted within their container. Overlay children (created via element.text(), element.rect(), etc.) are sorted separately and always render after the parent.
const base = page.rect({ width: 400, height: 80, shape_style: { fill_color: "#1a365d" }, z_index: 0 });
const top = page.rect({ shape_style: { fill_color: "#3182ce" }, position: { x: 20, y: 10 }, z_index: 2 });
const mid = page.rect({ shape_style: { fill_color: "#63b3ed" }, position: { x: 40, y: 20 }, z_index: 1 });
page.add(base, top, mid);Note that explicit-positioned root elements are placed relative to the page content origin, not the current cursor. To make layered explicit elements relative to each other, put them inside a container:
const layer = page.create_container({ width: 400, height: 80 });
layer.add(base, top, mid);
page.add(layer);Explicit positioning
Set position on an element to place it at a fixed offset rather than in the automatic flow. For root elements, the offset is relative to the top-left of the content area. For elements inside a container, the offset is relative to the container's inner top-left corner (after padding).
const info = page.rect({ width: 300, height: 80, shape_style: { fill_color: "#faf5ff" } });
info.text({ content: "Top left", position: { x: 8, y: 8 }, z_index: 1, style: { font_size: 9 } });
info.text({ content: "Bottom right", position: { x: 208, y: 60 }, z_index: 1, style: { font_size: 9 } });
info.text({ content: "Center", position: { x: 120, y: 34 }, z_index: 1, style: { font_size: 11 } });
page.add(info);Multi-page documents
Call doc.create_page() once per page. Each page is processed independently by the layout engine.
const doc = new Document({ page_size: "A4", margin: 50 });
for (let i = 1; i <= 5; i++) {
const page = doc.create_page();
page.add(
page.text({ content: `Page ${i}`, style: { font_size: 24 } }),
);
}
await doc.build("multi.pdf");Complete example
import { Document, create_theme } from "@nemu-ai/pdf";
const doc = new Document({ page_size: "A4", margin: 50, parse_markdown: true });
const theme = create_theme("report", {
colors: { primary: "#1a365d", muted: "#718096", accent: "#3182ce" },
});
doc.set_theme(theme);
const page = doc.create_page(theme);
const header = page.header_container({ style: { padding_bottom: 12 } });
header.add(
page.text({ content: "Q4 2025 Report", style: { font_size: 9, color: theme.get_color("muted") } }),
);
const footer = page.footer_container({ style: { padding_top: 10 } });
footer.add(
page.text({ content: "Confidential", style: { font_size: 9, color: theme.get_color("muted"), text_align: "right" } }),
);
page.add(
page.text({ content: "Revenue Summary", style: { font_size: 22, color: theme.get_color("primary") } }),
page.text({
content: "**Total revenue** for Q4 was *$128,400*, representing a ***14.3% increase*** year-over-year.",
style: { padding_top: 8 },
}),
);
const row = page.create_container({
layout: { type: "flex", direction: "row", justify: "space-between", gap: 16 },
style: { background_color: "#ebf8ff", padding: 16 },
width: page.get_content_width(),
});
row.add(
page.text({ content: "**Revenue**\n$128,400" }),
page.text({ content: "**Growth**\n+14.3%" }),
page.text({ content: "**Margin**\n38.2%" }),
);
page.add(row);
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(["Bob Chen", "Designer", "Active"]);
page.add(table);
const card = page.rect({
width: page.get_content_width(),
height: 80,
shape_style: { fill_color: "#276749", border_radius: 10 },
});
card.text({
content: "**Key takeaway** - Q4 performance exceeded projections in all segments.",
style: { color: "#ffffff", padding: 16 },
z_index: 1,
});
page.add(card);
await doc.build("report.pdf");Type reference
type PageSize = "A4" | "Letter" | "Legal";
interface PageDimensions {
width: number;
height: number;
}
interface DocumentOptions {
page_size?: PageSize;
custom_dimensions?: PageDimensions;
margin?: number | MarginValues;
parse_markdown?: boolean;
}
interface MarginValues {
top: number;
right: number;
bottom: number;
left: number;
}
interface StyleProperties {
font_family?: string;
font_size?: number;
font_weight?: string;
font_style?: string;
color?: string;
background_color?: string;
text_align?: string;
line_height?: number;
padding?: number;
padding_top?: number;
padding_right?: number;
padding_bottom?: number;
padding_left?: number;
}
interface ShapeStyle {
fill_color?: string;
stroke_color?: string;
stroke_width?: number;
border_radius?: number;
}
type LayoutMode = "flow" | "explicit";
interface MeasuredSize {
width: number;
height: number;
}
interface BoundingBox {
x: number;
y: number;
width: number;
height: number;
}
type FlexDirection = "row" | "column";
type FlexJustify = "flex-start" | "flex-end" | "center" | "space-between" | "space-around" | "space-evenly";
type FlexAlign = "flex-start" | "flex-end" | "center" | "stretch" | "baseline";
interface FlexLayoutOptions {
type: "flex";
direction: FlexDirection;
justify?: FlexJustify;
align?: FlexAlign;
gap?: number;
}
interface FlowLayoutOptions {
type: "flow";
gap?: number;
}
type ContainerLayout = FlexLayoutOptions | FlowLayoutOptions;
interface CreateTextOptions {
content: string;
style?: StyleProperties;
classname?: string;
position?: VectorLike;
width?: number;
parse_markdown?: boolean;
z_index?: number;
}
interface CreateRectOptions {
style?: StyleProperties;
shape_style?: ShapeStyle;
position?: VectorLike;
width?: number;
height?: number;
z_index?: number;
}
interface CreateImageOptions {
name: string;
position?: VectorLike;
width?: number;
height?: number;
z_index?: number;
}
interface CreateContainerOptions {
layout?: ContainerLayout;
style?: StyleProperties;
classname?: string;
position?: VectorLike;
width?: number;
height?: number;
z_index?: number;
}
interface TableCellInput {
content: string;
style?: StyleProperties;
}
interface CreateTableOptions {
columns: number | number[];
style?: StyleProperties;
position?: VectorLike;
width?: number;
border_color?: string;
border_width?: number;
cell_padding?: number;
header_style?: StyleProperties;
cell_style?: StyleProperties;
z_index?: number;
}Vector
Vector is the spatial primitive used throughout the library for positions and sizes.
import { Vector, vector } from "@nemu-ai/pdf";
const v = vector(100, 200);
v.x; // 100
v.y; // 200
v.copy(); // new Vector(100, 200)vector(x: number, y: number): Vector is a convenience constructor equivalent to new Vector(x, y).
Elements expose get_position() and get_size() after build(), both returning Vector.
await doc.build("output.pdf");
console.log(element.get_position()); // Vector { x: 50, y: 82 }
console.log(element.get_bbox()); // { x: 50, y: 82, width: 300, height: 24 }Standard 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 doc = new Document({
custom_dimensions: { width: 400, height: 600 },
});