Documentation

Pages

A Document holds one or more Pages. Each page sets its own size, orientation and margins, and when its content is taller than the page, jasy flows it onto as many pages as it needs, on its own.

Page({ size: "A4", orientation: "portrait", margin: 56 }, [Text("One page.")]);
PropTypeWhat it does
sizePageSize or a name like "A4", "letter"the page format (default A4)
orientation"portrait" | "landscape"rotates the format (default portrait)
marginInsetsthe content box inset (default 56pt all sides)
headeran elementdrawn at the top of every page
footeran elementdrawn at the bottom of every page

A Page also takes the Column options (gap, justify, align), since its children are stacked in a column.

Many pages

Hand Document a list of pages. Each can differ, mixing sizes and orientations freely.

Document([
  Page({ size: "A4" }, [Text("Cover")]),
  Page({ size: "A5", orientation: "landscape" }, [Text("A landscape insert")]),
]);

Headers, footers and pagination

A header and footer are laid out once and reprinted on every page the content spills onto, so a long table or a long stack of paragraphs keeps its letterhead and page furniture throughout. You write the content once; jasy paginates.

import { writeFileSync } from "node:fs";
import { Document, Page, Column, Row, Text, renderToBytes } from "@jasy/pdf";

const header = Row({ justify: "between" }, [
  Text("Acme Inc.", { bold: true, color: "#1450aa" }),
  Text("Report"),
]);
const footer = Row({ justify: "between" }, [
  Text("Confidential", { size: 9, color: "gray" }),
  Text("2026", { size: 9, color: "gray" }),
]);

async function build() {
  const body = Array.from({ length: 60 }, (_, i) =>
    Text(`Paragraph ${i + 1} - long body text that flows across pages.`, { size: 12 }),
  );

  const doc = Document([
    Page({ size: "A4", margin: 56, header, footer }, [Column({ gap: 6 }, body)]),
  ]);

  writeFileSync("report.pdf", await renderToBytes(doc));
}

build();

The 60 paragraphs are taller than one page, so the result is several pages, each carrying the same header and footer.

Page breaks

jasy paginates on its own, but sometimes you want to decide where a break falls - start a section on a fresh page, or stop a block from being cut in half at the boundary. These mirror the CSS break-before / break-after / break-inside properties.

Force a new page

PageBreak() sends everything after it to a fresh page, no matter how much room is left.

import { Document, Page, Column, Text, PageBreak } from "@jasy/pdf";

Column([Text("Chapter one ends here."), PageBreak(), Text("Chapter two starts on a new page.")]);

Break before or after an element

The same thing as a prop - put breakBefore or breakAfter on any Box, Column or Row (CSS break-before / break-after: page):

Column([
  Text("Summary"),
  Box({ breakBefore: true, padding: 12 }, [Text("Appendix - always starts its own page")]),
]);

A breakBefore at the very top of a page is ignored, so you never get a stray blank page.

Keep a block together

keepTogether refuses to split a group across a page boundary (CSS break-inside: avoid). If the group does not fit in the space left but would fit on a fresh page, it moves there whole; if it is taller than a whole page, it splits anyway, so pagination always finishes. Nesting works - an inner keepTogether still holds even when an outer one has to give.

import { keepTogether } from "@jasy/pdf";

keepTogether([
  Text("Invoice total", { bold: true }),
  Divider(),
  Text("Subtotal    100.00"),
  Text("Tax          19.00"),
  Text("Total       119.00", { bold: true }),
]);
// or as a prop: Box({ keepTogether: true, padding: 12 }, [ /* ... */ ])

All four are available in @jasy/vue and @jasy/nuxt as well: <PageBreak />, <KeepTogether>, and the break-before / break-after / keep-together props.

Page numbers

PageNumber() and PageCount() print the current page and the document total. Drop them wherever you like - a footer is the usual home, but nothing stops you putting them in the body, in a table cell, or five times on one page.

Page(
  { footer: Row({ justify: "center" }, [Text("Page "), PageNumber(), Text(" of "), PageCount()]) },
  [Column(body)],
);

Both take the usual Text options plus an offset, which is added to the number. Use it when a cover page should not count:

PageNumber({ offset: -1, size: 9, color: "gray" });

Building your own

Behind both sits one primitive: PageBuilder. It hands you the page's facts and draws whatever you return, so formatting and conditions are plain JavaScript.

import { Document, Page, Column, Row, Text, PageBuilder } from "@jasy/pdf";

const doc = Document([
  Page(
    {
      // A full title on page 1, a quiet continuation line afterwards.
      header: PageBuilder(({ pageNumber }) =>
        pageNumber === 1
          ? Text("Acme Inc. - Annual Report", { size: 16, bold: true })
          : Text("Annual Report (continued)", { size: 9, color: "gray" }),
      ),
      footer: PageBuilder(({ pageNumber, pageCount }) =>
        Text(`Page ${pageNumber} of ${pageCount}`, { size: 9, align: "center" }),
      ),
    },
    [Column(body)],
  ),
]);

The closure receives pageNumber (1-based, counting physical pages, so a Page that overflows contributes several), pageCount (the document total) and pageSize (the media box in points).

Getting pageCount right is why jasy paginates the whole document before it draws anything: the total has to exist before page 1 is painted. That ordering leaves two things worth knowing, and they follow from the same chicken-and-egg - the content decides the count that the content displays.

A conditional header may shrink on later pages, never grow. The body band is measured against the first build, so a taller header on page 3 would squeeze the body it was already sized for. Shrinking only frees up room, which is harmless.

Inside the flowing body the box is reserved before the total is known, so a much wider final string ("9 of 10" where "1 of 1" was measured) can paint slightly past it. Keep dynamic body content roughly constant in width. In a header or footer the numbers are always exact.

jasypdf

Declarative PDFs in pure TypeScript. ZUGFeRD & XRechnung compliant, with no headless browser and no Java.

Resources

© 2026 Florian Heuberger · MIT License

Built with Nuxt · self-hosted fonts · no trackers