# Spyde > Declarative PDF layout for Node on top of PDFKit. Describe a document as a tree of functions; Spyde lays out the pages, breaks them, and draws them. No browser, one runtime dependency. Install: `npm install @grandbusta/spyde`. Node 20 or newer. TypeScript types included. Repository: https://github.com/Grandbusta/spyde --- # Getting started ## Install ::: code-group ```sh [npm] npm install @grandbusta/spyde ``` ```sh [yarn] yarn add @grandbusta/spyde ``` ::: Node 20 or newer. PDFKit comes with it, and it is the only runtime dependency. TypeScript types are included. ## A first document ```ts import { render, column, row, text, fill, table } from "@grandbusta/spyde"; import { writeFile } from "node:fs/promises"; const money = (n: number) => `€${n.toFixed(2)}`; const entries = [ { item: "LG Speakers", qty: 1, total: 227.99 }, { item: "Apple iPhone", qty: 2, total: 1999.99 }, ]; const doc = column([ row([ fill(text("ACME Ltd", { size: 20, font: "Helvetica-Bold" })), fill(text("Invoice #1042", { align: "right" })), ]), table(entries, { columns: [ { label: "Item", key: "item", share: 2 }, { label: "Qty", key: "qty", align: "right" }, { label: "Total", key: "total", align: "right", format: money }, ], rowPadding: { x: 12, y: 8 }, header: { background: "#f2f2f2" }, }), row([text("Total due"), text(money(2227.98))], { justify: "between", margin: { top: 16 } }), ], { gap: 8 }); const pdf = await render(doc); // Uint8Array await writeFile("invoice.pdf", pdf); ``` That is a complete, one-page invoice. Nothing in it is a coordinate. ## What you get back `render` returns a `Uint8Array`, the standard byte type every runtime has. Node's `writeFile`, an HTTP response, and an upload all accept it directly. A Node `Buffer` is a `Uint8Array`, so bytes you read with `readFileSync` go straight in as fonts or images, and if you need a Node-only method on the result, `Buffer.from(pdf)` wraps it without copying. ## The one rule Every function takes its content first and its settings second: ```ts text("Hi", { size: 14 }) // content, then style padding(text("Hi"), 8) // content, then insets row([a, b], { gap: 8 }) // children, then options ``` Read any call aloud and it describes itself. `padding(text("Hi"), 8)` pads the text by 8. ## Where to go next - [Pages](https://grandbusta.github.io/spyde/guide/pages): how documents break, `keep`, `pageBreak`, box options, alignment. - [Tables](https://grandbusta.github.io/spyde/guide/tables): data in, columns as recipes. - [Live preview](https://grandbusta.github.io/spyde/guide/live-preview): the same layout as HTML. - [API](https://grandbusta.github.io/spyde/api): every function and its settings. --- # Pages A `column` that does not fit continues on the next page, breaking between its children. Everything else moves whole: a `row`, a `padding`, a `background`, a `text`. ## The rules 1. Only a `column` or a `table` splits, and only between children or rows. 2. A `pageBreak` ends the page where it stands. 3. A child that does not fit moves to the top of the next page. `keep` is this rule applied to a wrapper. 4. A table's header repeats on every page it runs onto and is never left alone at the bottom of one. 5. A child that fits nowhere is placed on its own page and clipped. Nothing ever throws for layout reasons. 6. A split column with padding, background, or margin carries the full box on every page fragment. ## keep and pageBreak ```ts column([ heading, ...rows, keep(column([ text("Total due"), text("€280.00") ])), // never straddles a page pageBreak(), text("Terms and conditions"), // always starts a fresh page ]) ``` `keep` also exists as an option on `column` and `table`: `{ keep: true }`. ## Box options Wrapping a block in `padding`, then `background`, then `padding` again for space above, then `keep`, is four nested calls for one idea. Containers carry those as options instead: ```ts column([ row([text("Opening balance"), text("€1200.00")], { justify: "between" }), row([text("Closing balance"), text("€1585.00")], { justify: "between" }), ], { gap: 6, padding: 12, background: "#f2f2f2", margin: { top: 24 }, keep: true }) ``` - `padding` is space inside, `background` covers the padded box, `margin` is space outside it. Applied in that order, inside to out, the way CSS does. - These are the same words as the standalone `padding`, `background`, and `keep`. Use the words when you need a different order, for instance a background that does not include the padding. - `row`, `column`, and `table` take them. `table` takes `background`, `margin`, and `keep` but not `padding`, which on a table is `rowPadding`. Leaves like `text` and `image` do not; wrap those. ## Alignment `row` and `column` take `align` for the cross axis and `justify` for the main axis, with the same words and values as CSS `align-items` and `justify-content`. ```ts row(children, { align: "start" | "center" | "end" | "stretch" }) // vertical, in a row row(children, { justify: "start" | "center" | "end" | "between" }) // horizontal, in a row ``` For a `column` the axes swap. `justify` is ignored when any child is a `fill` or `spacer`, since they already decide the distribution. ## Left and right ```ts // Two texts at the far edges: split in half, right-align the second. row([ fill(text("ACME Ltd")), fill(text("Invoice #1042", { align: "right" })) ]) // Both hug their content, a spacer pushes them apart. Better for long text. row([ text("Date: 14 Sep 2026"), spacer(), text("Due: 14 Oct 2026") ]) // Logo left, address right, bottom edges lined up. row([ image("./logo.png", { width: 120 }), spacer(), column([ text("ACME Ltd"), text("1 Example Street") ], { align: "end" }), ], { align: "end" }) ``` ## How fill works A row asks each child how wide it wants to be and places them left to right. `fill` changes the question for that child: instead of asking, the row tells it to take a share of whatever is left after the other children are placed. [diagram omitted] The share is a weight, not a size. Only the ratio matters: shares of 1, 1, 2 give the same layout as 5, 5, 10. A share of 0 keeps the child in the row but gives it no width. Fill is also what gives text a width to work in. Text inside a `fill` wraps at the fill's edge and can be aligned within it; text sitting directly in a row hugs its words on one line. `fill` and `spacer` only work as a **direct child** of a `row` or `column`. Wrapped in anything else they do nothing. Put the wrapper inside the fill, not around it: `fill(padding(x, 8))`, not `padding(fill(x), 8)`. Text inside a `fill` takes the fill's width and wraps; text as a direct child of a `row` hugs its content on one line. --- # Tables Rows are your data. Each column says which field to show and how. ```ts table(rows, { columns: [ { label: "Date", key: "date", width: 80 }, { label: "Description", key: "merchant" }, { label: "Amount", key: "amount", width: 80, align: "right", format: money }, ], header: { background: "#f2f2f2" }, row: (r, i) => ({ background: i % 2 ? "#f7f7f7" : undefined }), // zebra stripes }) ``` ## Columns - `key` reads a field of the row. Strings and numbers become text; a node is used as-is. - `format` turns the value into what is shown: `(value, row) => string | number | Node`. Return a node for anything richer than text, like a coloured box or an image. With `format` alone and no `key`, the column is computed from the whole row. - `label` is the text shown in the header row. No `label` on any column means no header row. - `width` is exact points; `share` is a share of the leftover (default 1). `align` applies to the header and to text cells. - `style` is a text style for the column's body cells; `background` paints behind the column, header included. ## Three appearance groups and a grid ```ts table(entries, { columns: [...], header: { style: { color: "#333" }, background: "#f2f2f2" }, // the header row row: (entry, i) => ({ // each body row, from the data background: i % 2 ? "#f7f7f7" : undefined, style: entry.overdue ? { color: "#c00" } : undefined, }), cell: { style: { size: 10 }, padding: { y: 5 } }, // defaults for every cell rowPadding: { x: 12 }, // the grid all rows share gap: 8, rowGap: 0, }) ``` `header`, `row`, and `cell` say how things look. `row` is a function because there are many rows. `rowPadding`, `gap`, and `rowGap` define the grid every row shares, so columns can never drift. **Precedence.** Text style resolves most specific first: a node from `format`, then the column's `style`, then the row's, then `cell.style`. Backgrounds layer: the row's is painted across the row, the column's over its cells, then the cell's own. ## Row padding and cell padding - `rowPadding` is space **around each row**. The header band and any row background extend to the full width, and the cells sit as a block inside, inset from the edge. - `cell.padding` is space **inside each cell**. Column and cell backgrounds fill the padded cell, so a coloured column runs top to bottom instead of hugging its text. Most tables want both: `rowPadding: { x: 12 }` for the inset and `cell: { padding: { y: 6 } }` for room inside the cells. ## Pages The header repeats at the top of every page the table runs onto, and is never left alone at the bottom of a page. Rows never split; a row taller than a page gets its own page. ## What a table is not There is no colspan, rowspan, or nested table, and there never will be. A table is sugar over `column`, `row`, and `fill`. Anything it cannot express, write with those. --- # Text, fonts, images ## Text ```ts text("Hello", { font: "Helvetica-Bold", size: 14, color: "#333", lineHeight: 1.3, align: "center" }) ``` | Setting | Meaning | Default | |---|---|---| | `font` | A built-in name or a name registered in render options | `Helvetica` | | `size` | Points | 12 | | `color` | Any colour string PDFKit accepts | `#000000` | | `lineHeight` | Multiplier of size | 1.2 | | `align` | `left`, `center`, `right` | `left` | | `field` | Names the data field this text shows, for the live preview | none | Text in a column takes the full width and wraps, like a paragraph, so `align` works. Text in a row that is not inside a `fill` hugs its content on one line. Newlines in the string start new lines. ## Fonts PDFKit's fourteen built-in fonts need no font file: Helvetica, Times-Roman, and Courier, each in regular, bold, italic and bold-italic, plus Symbol and ZapfDingbats. Bold and italic are separate names: `Helvetica-Bold`, `Times-Italic`. To use your own, register it once at render time and refer to it by name: ```ts await render(doc, { fonts: { Inter: "./fonts/Inter-Regular.ttf" }, // path or bytes, TTF or OTF defaultStyle: { font: "Inter", size: 11 }, // applied to every text node }); ``` Style resolves field by field: the node's own style, then `defaultStyle`, then the library defaults. An unknown font name is an error with the fix in the message. The built-in fonts cover Latin-1. Anything beyond that, including most symbols and non-Latin scripts, needs a registered font file. ## Images PNG or JPEG, from a path or bytes. Give a `width`, a `height`, both, or neither; the aspect ratio is always kept. ```ts image("./logo.png", { width: 120 }) // height follows image(bytes, { height: 40 }) // width follows image("./photo.jpg", { width: 200, height: 200 }) // fits inside the box, top-left image("./photo.jpg") // natural size, shrunk to fit the column ``` ## Render options ```ts render(tree, { size: "A4", // or "LETTER", any PDFKit preset, or [width, height] in points margins: 40, // or { x, y } or { top, right, bottom, left } fonts: { ... }, defaultStyle: { ... }, }) ``` All numbers are PDF points, 1/72 inch. ## Overflow Spyde never throws for content that does not fit. A box that needs more room than it has takes what it has and clips the rest. You see the cut-off and adjust. --- # Live preview The same document can be painted as HTML for a page instead of as a PDF. Layout runs once, with PDFKit's measurements, so what the page shows is where the PDF puts things: the same lines, the same breaks, the same pages. ```ts import { renderHtml } from "@grandbusta/spyde"; const html = renderHtml(invoiceDocument(data)); // a fragment: one