Skip to content

Nemu-Bridge/pdf

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

9 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@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/pdf

Quick 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:

  1. Measure - each element computes its intrinsic size using PDFKit font metrics.
  2. Layout - positions are computed top-down from the page margin and header zone height.
  3. 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): FooterContainer

Both 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 },
});

About

A modern PDF generation library with theming and vector support

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors