showroom · real code · real pdfs

Code in. Document out.

Every example is real @jasy/pdf code on the left and the exact PDF it renders on the right. Scroll it, page through it, download it - nothing is pre-baked.

invoice.ts

Invoice

A two-page commercial invoice - line-item table, totals, and a footer that paginate cleanly.

invoice.ts
// A clean, designed invoice that flows across two pages - the line-item table breaks, its header
// repeats on page two, and the page footer repeats on both. Pure layout, standard-14 fonts.
import { Document, Page, Column, Row, Box, Padding, Text, Divider, Table } from "@jasy/pdf";

const ink = "#1b2433";
const muted = "#6b7280";
const brand = "#1450aa";
const hair = "#e6eaf2";
const paper = "#f5f8fd";

const eur = (n: number) =>
  n.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " €";

type Item = { qty: number; name: string; price: number };
const items: Item[] = [
  { qty: 2, name: "Brand identity workshop", price: 680 },
  { qty: 1, name: "Logo design, primary and variants", price: 1450 },
  { qty: 1, name: "Visual identity guidelines", price: 920 },
  { qty: 3, name: "UI design, key screens", price: 540 },
  { qty: 1, name: "Design system in Figma", price: 1680 },
  { qty: 2, name: "Interactive prototype", price: 460 },
  { qty: 1, name: "Frontend setup (Nuxt)", price: 780 },
  { qty: 6, name: "Component implementation", price: 220 },
  { qty: 1, name: "Responsive pass", price: 640 },
  { qty: 1, name: "Accessibility audit", price: 540 },
  { qty: 2, name: "Content modelling", price: 380 },
  { qty: 1, name: "CMS integration", price: 1120 },
  { qty: 3, name: "Page templates", price: 360 },
  { qty: 1, name: "Animation polish", price: 480 },
  { qty: 1, name: "Performance tuning", price: 560 },
  { qty: 2, name: "QA and bugfixing", price: 340 },
  { qty: 1, name: "Deployment and CI", price: 420 },
  { qty: 1, name: "Documentation", price: 380 },
  { qty: 2, name: "Stakeholder review", price: 260 },
  { qty: 1, name: "Project management", price: 1200 },
];

const net = items.reduce((s, it) => s + it.qty * it.price, 0);
const vat = net * 0.19;
const gross = net + vat;

const hcell = (t: string, align: "left" | "right" = "left") =>
  Text(t, { size: 8.5, bold: true, color: muted, align });
const cell = (t: string, align: "left" | "right" = "left", bold = false) =>
  Text(t, { size: 10.5, color: ink, align, bold });

const rows = items.map((it) => [
  cell(String(it.qty)),
  cell(it.name),
  cell(eur(it.price), "right"),
  cell(eur(it.qty * it.price), "right", true),
]);

const detail = (label: string, value: string) =>
  Row({ justify: "between", gap: 18 }, [
    Text(label, { size: 10, color: muted }),
    Text(value, { size: 10, color: ink, bold: true, align: "right" }),
  ]);

const totalRow = (
  label: string,
  value: string,
  o: { bold?: boolean; size?: number; color?: string } = {},
) =>
  Row({ justify: "between" }, [
    Text(label, { size: o.size ?? 10.5, color: o.color ?? muted, bold: o.bold }),
    Text(value, { size: o.size ?? 10.5, color: o.color ?? ink, bold: o.bold, align: "right" }),
  ]);

const footer = Column({ gap: 6 }, [
  Divider({ color: hair }),
  Row({ justify: "between" }, [
    Text("Muster Studio GmbH · Hauptstraße 1 · 10115 Berlin", { size: 8, color: muted }),
    Text("VAT DE123456789 · hello@muster.studio", { size: 8, color: muted, align: "right" }),
  ]),
]);

const page = Page({ gap: 0, margin: 48, footer }, [
  // header band
  Row({ justify: "between", align: "start" }, [
    Column({ gap: 1 }, [
      Text("Muster Studio", { size: 19, bold: true, color: brand }),
      Text("Design & Development", { size: 10, color: muted }),
    ]),
    Column({ gap: 1, align: "end" }, [
      Text("INVOICE", { size: 26, bold: true, color: ink }),
      Text("RE-2026-0142", { size: 11, color: muted }),
    ]),
  ]),

  // billed-to + meta card
  Padding(
    { top: 22 },
    Row({ justify: "between", align: "start", gap: 40 }, [
      Column({ gap: 2 }, [
        Text("BILLED TO", { size: 8, bold: true, color: brand }),
        Padding({ top: 2 }, Text("Beispiel Kunde AG", { size: 12, bold: true, color: ink })),
        Text("Marienplatz 1", { size: 10.5, color: ink }),
        Text("80331 München", { size: 10.5, color: ink }),
        Text("Germany", { size: 10.5, color: muted }),
      ]),
      Box({ width: 230, bg: paper, padding: { x: 16, y: 14 }, radius: 8 }, [
        Column({ gap: 7 }, [
          detail("Invoice no.", "RE-2026-0142"),
          detail("Issue date", "20 Jun 2026"),
          detail("Due date", "04 Jul 2026"),
          detail("Reference", "PO-99213"),
        ]),
      ]),
    ]),
  ),

  // line items
  Padding(
    { top: 26 },
    Table(
      {
        columns: ["auto", "1fr", 92, 92],
        header: [
          hcell("QTY"),
          hcell("DESCRIPTION"),
          hcell("UNIT", "right"),
          hcell("AMOUNT", "right"),
        ],
        rowGap: 9,
        colGap: 14,
        cellPadding: { y: 4 },
        rule: hair,
      },
      rows,
    ),
  ),

  // totals
  Padding(
    { top: 18 },
    Row({ justify: "end" }, [
      Box({ width: 250 }, [
        Column({ gap: 8 }, [
          totalRow("Subtotal", eur(net)),
          totalRow("VAT 19%", eur(vat)),
          Divider({ color: hair, margin: { y: 2 } }),
          totalRow("Total due", eur(gross), { bold: true, size: 13, color: brand }),
        ]),
      ]),
    ]),
  ),

  // payment note
  Padding(
    { top: 22 },
    Box({ border: hair, bg: paper, padding: { x: 14, y: 12 }, radius: 8 }, [
      Column({ gap: 3 }, [
        Text("Payment", { size: 9, bold: true, color: brand }),
        Text(
          "Please transfer the total to IBAN DE02 1203 0000 0000 2020 51 within 14 days, quoting the invoice number.",
          { size: 10, color: ink },
        ),
      ]),
    ]),
  ),
]);

export default Document([page]);
PDF/A · 1 page

stamped-invoice.ts

Stamped invoice

A diagonal PAID stamp laid over an invoice - Rotated spins it, Positioned centers it, and the layout underneath stays untouched.

stamped-invoice.ts
// An invoice with a diagonal "PAID" stamp laid across it. The stamp is a rotated box placed
// out of flow: `Rotated` spins it around its center at any angle, and `Positioned` centers it on the
// card. `Rotated` is paint-only, so the stamp never disturbs the invoice layout underneath it.
import {
  Document,
  Page,
  Column,
  Row,
  Box,
  Text,
  Divider,
  Table,
  Spacer,
  Positioned,
  Rotated,
} from "@jasy/pdf";

const ink = "#1b2433";
const muted = "#6b7280";
const brand = "#1450aa";
const hair = "#e6eaf2";
const paper = "#f5f8fd";
const red = "#c0392b";

const eur = (n: number) =>
  n.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " €";

type Item = { qty: number; name: string; price: number };
const items: Item[] = [
  { qty: 2, name: "Brand identity workshop", price: 680 },
  { qty: 1, name: "Logo design, primary and variants", price: 1450 },
  { qty: 1, name: "Visual identity guidelines", price: 920 },
  { qty: 3, name: "UI design, key screens", price: 540 },
  { qty: 1, name: "Design system in Figma", price: 1680 },
];

const net = items.reduce((s, it) => s + it.qty * it.price, 0);
const vat = net * 0.19;
const gross = net + vat;

const rows = items.map((it) => [it.name, String(it.qty), eur(it.price), eur(it.qty * it.price)]);

export const stampedInvoice = Document({ font: "Helvetica", color: ink }, [
  Page({ size: "A4", margin: 48 }, [
    // A relative frame so the stamp can be positioned over the whole invoice.
    Box({ relative: true, borderWidth: 0 }, [
      Column({ gap: 18 }, [
        Row({ align: "start" }, [
          Column({ gap: 2 }, [
            Text("STUDIO NORD", { size: 20, bold: true, color: brand }),
            Text("Design & Frontend", { size: 10, color: muted }),
          ]),
          Spacer(),
          Column({ gap: 2, align: "end" }, [
            Text("INVOICE", { size: 20, bold: true }),
            Text("No. 2026-014", { size: 10, color: muted }),
            Text("2026-07-08", { size: 10, color: muted }),
          ]),
        ]),
        Box({ bg: paper, radius: 8, padding: 14 }, [
          Row({}, [
            Column({ gap: 1 }, [
              Text("Billed to", { size: 9, color: muted }),
              Text("Meridian GmbH", { size: 12, bold: true }),
              Text("Hafenstrasse 12, 20095 Hamburg", { size: 10, color: muted }),
            ]),
            Spacer(),
            Column({ gap: 1, align: "end" }, [
              Text("Due", { size: 9, color: muted }),
              Text("2026-07-22", { size: 12, bold: true }),
              Text("Net 14 days", { size: 10, color: muted }),
            ]),
          ]),
        ]),
        Table(
          {
            columns: ["3fr", "0.6fr", "1fr", "1fr"],
            header: ["Description", "Qty", "Unit", "Amount"],
            cellPadding: { x: 4, y: 6 },
            rule: hair,
          },
          rows,
        ),
        Row({}, [
          Spacer(),
          Column({ gap: 4, width: "42%" }, [
            Row({}, [
              Text("Net", { size: 11, color: muted }),
              Spacer(),
              Text(eur(net), { size: 11 }),
            ]),
            Row({}, [
              Text("VAT 19%", { size: 11, color: muted }),
              Spacer(),
              Text(eur(vat), { size: 11 }),
            ]),
            Divider({ color: hair }),
            Row({}, [
              Text("Total", { size: 13, bold: true, color: brand }),
              Spacer(),
              Text(eur(gross), { size: 13, bold: true, color: brand }),
            ]),
          ]),
        ]),
        Text("Thank you for your business.", { size: 10, color: muted }),
      ]),
      // The stamp: out of flow, centered on the invoice and rotated -18 degrees.
      Positioned(
        { h: "center", v: "center" },
        Rotated(
          { angle: -18 },
          Box({ border: red, borderWidth: 4, radius: 12, padding: { x: 26, y: 12 } }, [
            Text("PAID", { size: 40, bold: true, color: red }),
          ]),
        ),
      ),
    ]),
  ]),
]);
PDF/A · 1 page

vector-logo.ts

SVG letterhead

A logo and a seal straight from SVG markup - gradients, transforms, dashes, an even-odd clip path. Svg() takes the source, and Image() recognises a .svg file on its own.

vector-logo.ts
// A letterhead whose marks are all VECTORS: the logo comes from SVG markup, the seal and the rule
// from the same vector layer. Nothing on this page is a bitmap, so it stays sharp at any zoom and the
// file stays small - the reason a logo belongs in a PDF as a drawing, not as a PNG.
import { Document, Page, Column, Row, Box, Text, Svg, Image, Divider, Spacer } from "@jasy/pdf";

const ink = "#0a2348";
const brand = "#1450aa";
const muted = "#6b7280";
const hair = "#e4e8ee";

// An ordinary SVG - the kind an agency hands over. Groups, transforms, a gradient, a clip path and
// even-odd fill are all resolved; what jasy cannot draw it names, rather than skipping it silently.
const LOGO = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
  <defs>
    <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
      <stop offset="0" stop-color="#1450aa"/>
      <stop offset="1" stop-color="#4d84d6"/>
    </linearGradient>
    <clipPath id="ring">
      <path d="M32 4a28 28 0 1 1-.1 0z M32 14a18 18 0 1 0 .1 0z" clip-rule="evenodd"/>
    </clipPath>
  </defs>
  <circle cx="32" cy="32" r="28" fill="url(#g)" clip-path="url(#ring)"/>
  <path d="M22 32 l7 8 14-16" fill="none" stroke="#0a2348" stroke-width="5"
        stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;

// A stamp, also SVG - dashed ring, rotated wordmark path, no font needed.
const SEAL = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120">
  <circle cx="60" cy="60" r="52" fill="none" stroke="#e2483d" stroke-width="4"
          stroke-dasharray="10 6"/>
  <circle cx="60" cy="60" r="42" fill="none" stroke="#e2483d" stroke-width="2"/>
  <g transform="translate(60 60) rotate(-16)">
    <path d="M-34 -13 h68 v26 h-68 z" fill="#e2483d" opacity="0.10"/>
    <path d="M-22 2 l9 10 22 -24" fill="none" stroke="#e2483d" stroke-width="7"
          stroke-linecap="round" stroke-linejoin="round"/>
  </g>
</svg>`;

const line = (label: string, value: string) =>
  Row({ gap: 6 }, [
    Text(label, { size: 9, color: muted, width: 74 }),
    Text(value, { size: 9, color: ink }),
  ]);

export default Document({ size: 10, color: ink }, [
  Page({ size: "A4", margin: 52, gap: 20 }, [
    Row({ align: "center", gap: 12 }, [
      // Svg() names it explicitly - Image("logo.svg") would do the same for a file on disk.
      Svg(LOGO, { width: 52, alt: "Northwind" }),
      Column({ gap: 1 }, [
        Text("NORTHWIND", { size: 17, bold: true, letterSpacing: 2.4 }),
        Text("studio for brand and print", { size: 8, color: muted, letterSpacing: 0.6 }),
      ]),
      Spacer(),
      Column({ gap: 2, align: "end" }, [
        Text("Northwind Studio GmbH", { size: 9, bold: true }),
        Text("Chausseestrasse 12, 10115 Berlin", { size: 9, color: muted }),
        Text("hello@northwind.example", { size: 9, color: muted }),
      ]),
    ]),

    Divider({ color: hair }),

    Column({ gap: 4 }, [
      Text("Certificate of completion", { size: 20, bold: true }),
      Text("Issued 17 June 2026 - reference NW-2026-0184", { size: 10, color: muted }),
    ]),

    Row({ gap: 28, align: "start" }, [
      Column({ gap: 8, width: 300 }, [
        Text(
          "This document confirms that the work described below was carried out and accepted. " +
            "Every mark on this page - the logo, the seal, the rule - is a vector drawn into the " +
            "PDF, so it prints at any size without a trace of a bitmap.",
          { size: 10, lineHeight: 1.5 },
        ),
        Box({ bg: "#f4f6f9", radius: 8, padding: 14 }, [
          Column({ gap: 5 }, [
            line("Project", "Brand identity"),
            line("Delivered", "12 June 2026"),
            line("Accepted by", "M. Schellenberg"),
          ]),
        ]),
      ]),
      Spacer(),
      // The same source through Image(): an SVG is recognised by its content, not by a flag.
      Image(SEAL, { width: 120 }),
    ]),

    Spacer(),

    Box({ border: hair, radius: 8, padding: 14 }, [
      Row({ gap: 12, align: "center" }, [
        Svg(
          `<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="7" fill="none" stroke="${brand}"
             stroke-width="2"/><path d="M4.5 8.5 L7 11 L11.5 5.5" fill="none" stroke="${brand}"
             stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
          { width: 16 },
        ),
        Text(
          "10,819 real SVG files were run through this reader, and the result compared with " +
            "headless Chrome pixel for pixel.",
          { size: 9, color: muted },
        ),
      ]),
    ]),
  ]),
]);
PDF/A · 1 page

chart.ts

Chart

A bar chart with a dashed target line and a donut, both drawn with Canvas - the imperative pen. No chart library, no image: every mark is a vector in the PDF, and the axis labels stay real, selectable text.

chart.ts
// A quarterly report page whose figures are DRAWN: a bar chart with a target line, and a donut, both
// from Canvas - the imperative escape hatch. The axes are ordinary Text in the layout, so they stay
// selectable and searchable; only the graphics come from the pen.
import {
  Document,
  Page,
  Column,
  Row,
  Box,
  Text,
  Canvas,
  Expanded,
  Spacer,
  Divider,
  linearGradient,
  type CanvasPainter,
} from "@jasy/pdf";

const ink = "#0a2348";
const brand = "#1450aa";
const muted = "#8a8f98";
const hair = "#e4e8ee";

const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const REVENUE = [42, 51, 47, 66, 58, 74, 69, 88, 81, 96, 87, 112];
const TARGET = [45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 95, 100];
const TICKS = [0, 30, 60, 90, 120];
const MAX = 120;

const SPLIT: [string, number, string][] = [
  ["Licences", 52, brand],
  ["Services", 27, "#4d84d6"],
  ["Support", 14, "#f3dc29"],
  ["Other", 7, "#e2483d"],
];

// Bars, a dashed grid and the target line - written against the size the layout hands over, so the
// same function fits a full-width block and a narrow column.
const barChart = (c: CanvasPainter, { width, height }: { width: number; height: number }) => {
  for (const t of TICKS) {
    const y = height - (t / MAX) * height;
    c.move(0, y)
      .line(width, y)
      .stroke(t === 0 ? "#9aa4b2" : hair, {
        width: t === 0 ? 1 : 0.75,
        dash: t === 0 ? undefined : [2, 3],
      });
  }

  const slot = width / REVENUE.length;
  REVENUE.forEach((v, i) => {
    const h = (v / MAX) * height;
    c.rect(i * slot + slot * 0.18, height - h, slot * 0.64, h).fill(
      linearGradient({ angle: 180, stops: [brand, "#7aa5e8"] }),
    );
  });

  const x = (i: number) => i * slot + slot / 2;
  const y = (v: number) => height - (v / MAX) * height;
  c.move(x(0), y(TARGET[0]!));
  TARGET.slice(1).forEach((v, i) => c.line(x(i + 1), y(v)));
  c.stroke(ink, { width: 1.4, cap: "round", join: "round", dash: [5, 3] });
  TARGET.forEach((v, i) => c.circle(x(i), y(v), 1.8).fill(ink));
};

// One arc per slice, drawn as a thick STROKE - that is where the hole comes from, for free.
const donut = (c: CanvasPainter, { width }: { width: number }) => {
  const r = width / 2 - 14;
  const [cx, cy] = [width / 2, width / 2];
  let angle = -90;
  for (const [, value, colour] of SPLIT) {
    const sweep = (value / 100) * 360;
    const a0 = (angle * Math.PI) / 180;
    const a1 = ((angle + sweep) * Math.PI) / 180;
    c.path(
      `M ${cx + r * Math.cos(a0)} ${cy + r * Math.sin(a0)} ` +
        `A ${r} ${r} 0 ${sweep > 180 ? 1 : 0} 1 ${cx + r * Math.cos(a1)} ${cy + r * Math.sin(a1)}`,
    ).stroke(colour, { width: 18, cap: "butt" });
    angle += sweep;
  }
};

const label = (t: string) => Text(t, { size: 7.5, color: muted });

const legend = ([name, value, colour]: [string, number, string]) =>
  Row({ gap: 6, align: "center" }, [
    Canvas({ width: 8, height: 8 }, (c) => void c.circle(4, 4, 4).fill(colour)),
    Text(name, { size: 9 }),
    Spacer(),
    Text(`${value}%`, { size: 9, bold: true }),
  ]);

export default Document({ size: 10, color: "#1a1a1a" }, [
  Page({ size: "A4", margin: 48, gap: 18 }, [
    Text("Revenue 2026", { size: 22, bold: true, color: ink }),
    label("Monthly revenue against target, in thousands of euro"),

    Row({ gap: 8, align: "stretch" }, [
      // The y axis is TEXT in the tree, not drawn - it stays copyable and screen-reader friendly.
      Column(
        { justify: "between", width: 26 },
        [...TICKS].reverse().map((t) => label(String(t))),
      ),
      Expanded(
        { flex: 1 },
        Column({ gap: 4 }, [
          Canvas({ height: 170, alt: "Monthly revenue against target" }, barChart),
          Row({ justify: "between" }, MONTHS.map(label)),
        ]),
      ),
    ]),

    Divider({ color: hair }),

    Row({ gap: 24, align: "center" }, [
      Canvas({ width: 130, height: 130, alt: "Revenue by product line" }, donut),
      Column({ gap: 7, width: 200 }, [
        Text("By product line", { size: 12, bold: true, color: ink }),
        ...SPLIT.map(legend),
      ]),
    ]),

    Box({ bg: "#f4f6f9", radius: 8, padding: 14 }, [
      Column({ gap: 5 }, [
        Text("Drawn, not embedded", { size: 11, bold: true, color: ink }),
        Text(
          "Every mark on this page is a vector in the PDF - no bitmap, no chart image, no second " +
            "library. Canvas hands the callback the size the layout resolved, so one function fits " +
            "a full-width block and a narrow column alike.",
          { size: 9, color: muted, lineHeight: 1.45 },
        ),
      ]),
    ]),
  ]),
]);
PDF/A · 1 page

paginated-report.ts

Paginated report

A report that runs over four pages - PageBuilder prints Page X of Y in the footer, drops the masthead after page one, and the same numbers appear inline in the body.

paginated-report.ts
// A report that runs over several pages - the showcase for page numbers. `PageBuilder` hands you the
// page's facts and draws whatever you return, so the header can change after page 1 and the footer can
// print "Page X of Y". `PageNumber` and `PageCount` are one-word sugar over the same primitive, and they
// work anywhere - including inline in the flowing body, as the summary box below shows.
import {
  Document,
  Page,
  Column,
  Row,
  Box,
  Divider,
  Text,
  Paragraph,
  PageBuilder,
  PageNumber,
  PageCount,
} from "@jasy/pdf";

const ink = "#1f2a37";
const brand = "#1450aa";
const muted = "#6b7280";
const hair = "#dfe4ee";

const body =
  "Revenue grew across every region, with the strongest contribution from the enterprise segment. " +
  "Gross margin improved by two points as the platform migration retired the last of the legacy " +
  "infrastructure. Operating expenses stayed flat despite headcount growth, and free cash flow turned " +
  "positive in the second half. ";

// The full masthead on page one, a quiet continuation line afterwards. A conditional header may shrink
// on later pages, never grow - the body band is measured against the first build.
const header = PageBuilder(({ pageNumber }) =>
  pageNumber === 1
    ? Row({ justify: "between", align: "end" }, [
        Text("Acme Inc.", { size: 20, bold: true, color: brand }),
        Text("Annual Report 2026", { size: 10, color: muted }),
      ])
    : Text("Annual Report 2026 (continued)", { size: 9, color: muted }),
);

// The classic page furniture: a rule, the confidentiality note, and "Page X of Y".
const footer = PageBuilder(({ pageNumber, pageCount }) =>
  Column([
    Divider({ color: hair }),
    Box({ height: 6 }, []),
    Row({ justify: "between" }, [
      Text("Confidential", { size: 8.5, color: muted }),
      Text(`Page ${pageNumber} of ${pageCount}`, { size: 8.5, color: muted }),
    ]),
  ]),
);

export default Document({ size: 11, color: ink, lineHeight: 1.5 }, [
  Page({ size: "A4", margin: 56, header, footer }, [
    Column({ gap: 10 }, [
      // The same numbers, inline in the body - and once more with a cover page discounted.
      Box({ bg: "#f4f6fb", padding: 10, radius: 6 }, [
        Row({ gap: 5, align: "center" }, [
          Text("You are reading page", { size: 10, color: muted }),
          PageNumber({ size: 10, bold: true, color: brand }),
          Text("of", { size: 10, color: muted }),
          PageCount({ size: 10, bold: true, color: brand }),
          Text("- excluding the cover:", { size: 10, color: muted }),
          PageNumber({ size: 10, bold: true, color: brand, offset: -1 }),
        ]),
      ]),

      Text("Financial review", { size: 14, bold: true, color: ink }),
      ...Array.from({ length: 16 }, () => Paragraph(body.repeat(2))),
    ]),
  ]),
]);
PDF/A · 1 page

page-breaks.ts

Page-break control

Three short notes, each pinned to its own page - breakBefore forces the breaks, keepTogether holds the sign-off box whole. Remove the props and it all fits on one page.

page-breaks.ts
// Page-break control. jasy paginates on its own, but you can override it: PageBreak() and the
// breakBefore / breakAfter props force a new page, and keepTogether does the opposite - it stops a block
// from being split across the boundary. Here three short notes are each pinned to their own page, and the
// sign-off box is kept whole. Remove the props and the whole thing collapses onto a single page.
import {
  Document,
  Page,
  Column,
  Row,
  Box,
  Divider,
  Text,
  Paragraph,
  keepTogether,
} from "@jasy/pdf";

const ink = "#1f2a37";
const brand = "#1450aa";
const muted = "#6b7280";
const hair = "#dfe4ee";

const header = Row({ justify: "between", align: "end" }, [
  Text("Jasy Field Notes", { size: 18, bold: true, color: brand }),
  Text("Release 1.0.0-alpha.7", { size: 9, color: muted }),
]);

// `fresh: true` puts breakBefore on the note, so it starts on a new page.
const note = (title: string, body: string, fresh?: boolean) =>
  Box({ breakBefore: fresh, padding: { y: 4 } }, [
    Column({ gap: 6 }, [Text(title, { size: 14, bold: true }), Paragraph(body)]),
  ]);

// A sign-off block wrapped in keepTogether, so a page break can never cut it in half.
const signoff = keepTogether([
  Box({ border: hair, bg: "#f6f8fc", padding: 12, radius: 6 }, [
    Column({ gap: 6 }, [
      Text("Sign-off", { size: 12, bold: true, color: brand }),
      Text("Reviewed by ...................................."),
      Text("Approved by ..................................."),
      Text("Date .............................................."),
    ]),
  ]),
]);

export default Document({ size: 11, color: ink, lineHeight: 1.5 }, [
  Page({ size: "A4", margin: 56, header, gap: 12 }, [
    Text("Page-break control", { size: 24, bold: true }),
    Paragraph(
      "Every release note below is short, but each one is pinned to its own page on purpose. jasy " +
        "paginates automatically - here we override it, the way you would in a real report.",
      { color: muted },
    ),
    Divider({ color: hair }),

    note(
      "1. Overview",
      "This first note stays on page one. It is deliberately short - there is plenty of room left below " +
        "it, which is exactly what makes the next break visible.",
    ),

    note(
      "2. Forced onto a fresh page",
      "This note carries breakBefore, so it starts on page two even though note one left most of page " +
        "one empty. Page one ends early - that gap is the point.",
      true,
    ),

    signoff,

    note(
      "3. Kept together",
      "PageBreak() and the breakBefore / breakAfter props force a break; keepTogether does the opposite " +
        "- it refuses to let a block be split across a page boundary, so the sign-off box on page two is " +
        "never cut in half, wherever it lands.",
      true,
    ),
  ]),
]);
PDF/A · 1 page

handbook.ts

Handbook

A handbook that knows its way around itself - a clickable contents page, a bookmark tree in the viewer sidebar, and links back to the top.

handbook.ts
// A handbook that knows its way around itself - the showcase for navigation. Three mechanisms, one page:
// `Bookmark` fills the viewer's sidebar with a collapsible outline, `Anchor` marks a jump target and
// `Link({ to })` jumps to it, so the contents page is genuinely clickable. `Link({ href })` and an inline
// `span({ href })` open the web. Open the bookmarks panel in your viewer to see the tree.
import {
  Document,
  Page,
  Column,
  Row,
  Box,
  Divider,
  Spacer,
  Text,
  Paragraph,
  span,
  Link,
  Anchor,
  Bookmark,
  PageBuilder,
} from "@jasy/pdf";

const ink = "#1f2a37";
const brand = "#1450aa";
const muted = "#6b7280";
const hair = "#dfe4ee";

const chapters = [
  {
    id: "installation",
    title: "Installation",
    body:
      "Install the package with your usual package manager. jasy ships as pure ESM and needs no headless " +
      "browser, no Java runtime and no native binary, so a cold install is measured in seconds.",
  },
  {
    id: "authoring",
    title: "Authoring a document",
    body:
      "A document is a tree of components. Columns and rows stack, boxes frame, and text flows. When the " +
      "content outgrows a page, jasy paginates it for you and reprints the header and footer.",
  },
  {
    id: "shipping",
    title: "Shipping to production",
    body:
      "Render on a server, in a serverless function, or entirely in the browser - the engine is isomorphic. " +
      "Attach a ZUGFeRD invoice, tag the document for accessibility, or encrypt it with AES-256.",
  },
];

// One clickable row of the table of contents. The whole row is the hit area.
const tocRow = (n: number, id: string, title: string) =>
  Link(
    { to: id },
    Box({ padding: 8, borderBottom: hair }, [
      Row({ justify: "between" }, [
        Text(`${n}. ${title}`, { color: brand }),
        Text("jump", { color: muted, size: 10 }),
      ]),
    ]),
  );

const footer = PageBuilder(({ pageNumber, pageCount }) =>
  Column([
    Divider({ color: hair }),
    Box({ height: 6 }, []),
    Row({ justify: "between" }, [
      Text("jasy handbook", { size: 8.5, color: muted }),
      Text(`Page ${pageNumber} of ${pageCount}`, { size: 8.5, color: muted }),
    ]),
  ]),
);

// A chapter page: the heading is a Bookmark (sidebar) AND an Anchor (jump target) at once.
const chapter = (n: number, c: (typeof chapters)[number]) =>
  Page({ size: "A4", margin: 56, footer }, [
    Column({ gap: 12 }, [
      Bookmark(
        { title: `${n}. ${c.title}`, level: 1 },
        Anchor({ name: c.id }, Text(`${n}. ${c.title}`, { size: 22, bold: true, color: ink })),
      ),
      Paragraph(c.body),
      Paragraph([
        span("Read the full documentation at "),
        span("jasy.dev", { href: "https://jasy.dev", color: brand, bold: true }),
        span(", or return to the "),
        span("table of contents", { to: "contents", color: brand }),
        span("."),
      ]),
      Spacer(),
    ]),
  ]);

export default Document({ size: 11, color: ink, lineHeight: 1.5 }, [
  Page({ size: "A4", margin: 56, footer }, [
    Column({ gap: 16 }, [
      Bookmark(
        { title: "Contents", level: 1 },
        Anchor({ name: "contents" }, Text("Handbook", { size: 26, bold: true, color: brand })),
      ),
      Text(
        "Every row below jumps to its chapter. The viewer's bookmark panel shows the same tree.",
        {
          color: muted,
        },
      ),
      Box({ border: hair, radius: 6 }, [
        Column(chapters.map((c, i) => tocRow(i + 1, c.id, c.title))),
      ]),
      Spacer(),
      Link(
        { href: "https://github.com/jasy-pdf/jasy" },
        Box({ bg: "#f4f6fb", padding: 12, radius: 6 }, [
          Text("The whole box is a link - opens the repository on GitHub.", {
            size: 10,
            color: brand,
          }),
        ]),
      ),
    ]),
  ]),
  ...chapters.map((c, i) => chapter(i + 1, c)),
]);
PDF/A · 1 page

form.ts

Fillable form

Seven kinds of AcroForm field laid out like any other element - type in it, tick it, pick from the list. @jasy/pdf/edit reads the same file back and fills it from a plain object.

form.ts
// A form you can actually fill in the reader: seven kinds of AcroForm field, laid out like any other
// element. Open the PDF, type in it, tick a box, pick from the list - the values are stored in the
// file. The same library reads a form back and fills it programmatically.
import {
  Document,
  Page,
  Column,
  Row,
  Box,
  Text,
  Divider,
  Spacer,
  TextField,
  Checkbox,
  RadioGroup,
  Dropdown,
  ListBox,
  PushButton,
  SignatureField,
} from "@jasy/pdf";

const ink = "#0a2348";
const muted = "#6b7280";
const hair = "#dfe4ee";
const boxed = { border: "#aab3c2", background: "#fbfcfe" } as const;

// A label above its field - the pattern repeats, so it is a function rather than eleven copies.
const field = (label: string, control: ReturnType<typeof TextField>) =>
  Column({ gap: 4 }, [Text(label, { size: 8, color: muted }), control]);

export default Document({ size: 10, color: ink }, [
  Page({ size: "A4", margin: 52, gap: 16 }, [
    Row({ align: "center" }, [
      Column({ gap: 2 }, [
        Text("Membership application", { size: 20, bold: true }),
        Text("Fill this in on screen - no printer involved", { size: 9, color: muted }),
      ]),
      Spacer(),
      Text("F-2026-11", { size: 9, color: muted }),
    ]),

    Divider({ color: hair }),

    Row({ gap: 14 }, [
      field("Full name", TextField({ name: "fullName", width: 210, height: 22, ...boxed })),
      field("Date of birth", TextField({ name: "born", width: 110, height: 22, ...boxed })),
    ]),

    Row({ gap: 14 }, [
      field("Email", TextField({ name: "email", width: 210, height: 22, ...boxed })),
      field(
        "Country",
        Dropdown({ name: "country", width: 110, height: 22, fontSize: 10, ...boxed }, [
          "DE",
          "AT",
          "CH",
        ]),
      ),
    ]),

    field(
      "Anything we should know?",
      TextField({
        name: "notes",
        width: 334,
        height: 56,
        multiline: true,
        maxLength: 500,
        ...boxed,
      }),
    ),

    Row({ gap: 28, align: "start" }, [
      Column({ gap: 6 }, [
        Text("Membership", { size: 8, color: muted }),
        RadioGroup({ name: "plan", gap: 6, labelSize: 10 }, [
          { value: "basic", label: "Basic - 4 EUR a month" },
          { value: "pro", label: "Pro - 9 EUR a month" },
          { value: "team", label: "Team - 29 EUR a month" },
        ]),
      ]),
      Column({ gap: 6 }, [
        Text("Interests", { size: 8, color: muted }),
        ListBox({ name: "topics", width: 150, height: 60, fontSize: 10, ...boxed }, [
          "Invoicing",
          "Reports",
          "Typography",
          "Accessibility",
        ]),
      ]),
    ]),

    Checkbox({ name: "agree", label: "I have read the statutes", labelSize: 10 }),
    Checkbox({ name: "newsletter", label: "Send me the monthly letter", labelSize: 10 }),

    Divider({ color: hair }),

    Row({ gap: 20, align: "end" }, [
      field("Signature", SignatureField({ name: "signature", width: 210, height: 46, ...boxed })),
      Spacer(),
      PushButton({ name: "clear", label: "Reset form", action: "reset", width: 92, height: 26 }),
    ]),

    Spacer(),

    Box({ bg: "#f4f6f9", radius: 8, padding: 14 }, [
      Column({ gap: 5 }, [
        Text("And it reads them back", { size: 11, bold: true }),
        Text(
          "@jasy/pdf/edit opens a form somebody else made, reports its fields, fills them from a " +
            "plain object and can flatten the result. The save is an incremental update, so the " +
            "original file stays a literal prefix of the new one - nothing is rewritten behind " +
            "your back.",
          { size: 9, color: muted, lineHeight: 1.45 },
        ),
      ]),
    ]),
  ]),
]);
PDF/A · 1 page

zugferd-invoice.ts

ZUGFeRD e-invoice

A conformant ZUGFeRD PDF/A-3 with EN-16931 XML embedded - for humans and tax offices both.

zugferd-invoice.ts
// A ZUGFeRD / Factur-X e-invoice: a typed EN-16931 Invoice in -> a conformant PDF/A-3 with the
// CII XML embedded out. Totals + the VAT breakdown are computed by the generator, so the business
// rules that check the arithmetic hold by construction. Rendered via @jasy/e-invoice (not renderToBytes);
// the harness writes both the .pdf and the .xml.
import type { Invoice } from "@jasy/e-invoice";

const invoice: Invoice = {
  number: "RE-2026-0142",
  issueDate: "2026-06-20",
  dueDate: "2026-07-04",
  currency: "EUR",
  buyerReference: "PO-99213",
  notes: [
    "Thank you for your business. Conformant ZUGFeRD invoice generated with @jasy/e-invoice.",
  ],

  seller: {
    name: "Muster Studio GmbH",
    vatId: "DE123456789",
    legalRegistrationId: "HRB 12345 B",
    electronicAddress: "rechnung@muster-studio.de",
    address: { line1: "Hauptstraße 1", postCode: "10115", city: "Berlin", country: "DE" },
    contact: { name: "Erika Muster", phone: "+49 30 1234567", email: "kontakt@muster-studio.de" },
  },

  buyer: {
    name: "Beispiel Kunde AG",
    vatId: "DE987654321",
    address: { line1: "Marienplatz 1", postCode: "80331", city: "München", country: "DE" },
  },

  lines: [
    {
      id: "1",
      name: "Brand identity workshop",
      quantity: 2,
      unit: "HUR",
      netUnitPrice: 680,
      vat: { category: "S", ratePercent: 19 },
    },
    {
      id: "2",
      name: "Logo design, primary and variants",
      quantity: 1,
      unit: "C62",
      netUnitPrice: 1450,
      vat: { category: "S", ratePercent: 19 },
    },
    {
      id: "3",
      name: "Design system in Figma",
      quantity: 1,
      unit: "C62",
      netUnitPrice: 1680,
      vat: { category: "S", ratePercent: 19 },
    },
    {
      id: "4",
      name: "Frontend implementation",
      quantity: 8,
      unit: "HUR",
      netUnitPrice: 95,
      vat: { category: "S", ratePercent: 19 },
    },
    {
      id: "5",
      name: "Deployment and CI setup",
      quantity: 1,
      unit: "C62",
      netUnitPrice: 420,
      vat: { category: "S", ratePercent: 19 },
    },
  ],

  payment: {
    meansCode: "58", // SEPA credit transfer
    iban: "DE02120300000000202051",
    bic: "BYLADEM1001",
    accountName: "Muster Studio GmbH",
    terms: "Zahlbar innerhalb 14 Tagen netto.",
  },
};

export const zugferd = { invoice, options: { profile: "en16931" as const } };

certificate.ts

Certificate

An A4-landscape certificate - the recipient name set in an embedded TrueType script font.

certificate.ts
// A certificate - the showcase for custom TrueType embedding. The recipient name is set in Great
// Vibes (an OFL calligraphy font, subsetted + embedded as Type0/Identity-H); everything else uses the
// standard fonts. doc.addFont(name, ttfPath) registers it; Text({ font: name }) uses it.
// The gold frame is an out-of-flow Positioned border filling the page content box (so it keeps an
// equal margin on all four sides), and the content is centered inside it.
import { Document, Page, Column, Row, Box, Padding, Positioned, Text } from "@jasy/pdf";

const ink = "#1b2433";
const muted = "#6b7280";
const brand = "#1450aa";
const gold = "#a8842c";

const rule = (w: number) => Box({ width: w, height: 1.4, bg: gold }, []);
const spaced = (s: string) => s.split("").join(String.fromCharCode(32)); // letter-spaced title

const doc = Document([
  Page({ size: "A4", orientation: "landscape", margin: 46, justify: "center", align: "center" }, [
    // the frame: a border filling the content box, out of flow so it never shifts the content
    Positioned(
      { top: 0, left: 0, right: 0, bottom: 0 },
      Box({ border: gold, borderWidth: 1.5, radius: 6 }, []),
    ),

    // the content, centered in the frame
    Padding(
      { x: 56 },
      Column({ align: "center", gap: 0 }, [
        Text(spaced("CERTIFICATE"), {
          font: "Times-Roman",
          bold: true,
          size: 26,
          color: ink,
          align: "center",
        }),
        Box({ height: 5 }, []),
        Text(spaced("OF ACHIEVEMENT"), {
          font: "Times-Roman",
          size: 11,
          color: gold,
          align: "center",
        }),

        Box({ height: 34 }, []),
        Text("This certificate is proudly presented to", {
          font: "Times-Roman",
          size: 13,
          color: muted,
          align: "center",
        }),
        Box({ height: 8 }, []),
        Text("Florian Heuberger", { font: "Great Vibes", size: 60, color: brand, align: "center" }),
        Box({ height: 8 }, []),
        rule(160),

        Box({ height: 26 }, []),
        Text("in recognition of building jasy, a declarative PDF engine in pure TypeScript,", {
          font: "Times-Roman",
          size: 12,
          color: ink,
          align: "center",
        }),
        Text("ZUGFeRD-conformant, with no headless browser and no Java underneath.", {
          font: "Times-Roman",
          size: 12,
          color: ink,
          align: "center",
        }),

        Box({ height: 46 }, []),
        Row({ justify: "between", align: "end" }, [
          Column({ align: "center", gap: 3 }, [
            rule(160),
            Text("Erika Muster · Founder", { font: "Times-Roman", size: 9, color: muted }),
          ]),
          Box({ width: 18, height: 18, radius: 9, border: gold, borderWidth: 1.5 }, []),
          Column({ align: "center", gap: 3 }, [
            rule(160),
            Text("Berlin · 24 June 2026", { font: "Times-Roman", size: 9, color: muted }),
          ]),
        ]),
      ]),
    ),
  ]),
]);

doc.addFont("Great Vibes", "examples/assets/fonts/GreatVibes-Regular.ttf");

export default doc;
PDF/A · 1 page

cover.ts

Cover page

A full-bleed cover - colour to the edge, out-of-flow positioned shapes, and overlaid display type.

cover.ts
// A full-bleed cover page: the background runs edge to edge (no page margin), a bold accent disc pokes
// off the corner and is cropped by overflow:hidden, and the text sits on top - all driven by the
// positioning layer (a relative Box frame + out-of-flow Positioned children).
import {
  Document,
  Page,
  Box,
  Expanded,
  Positioned,
  Column,
  Text,
  Paragraph,
  Divider,
} from "@jasy/pdf";

const navy = "#0a2348";
const navy2 = "#13315f";
const accent = "#f3dc29";
const white = "#ffffff";
const soft = "#9db8e0";

const LEAD =
  "You describe a document as a tree of components and jasy lays it out and writes the PDF bytes " +
  "itself, with no headless browser and no Java. Text breaks at real font metrics, images move as a " +
  "whole across a page break, and the background you see here bleeds past the page margin because a " +
  "Positioned child is allowed to poke right out to the paper's edge.";

export default Document([
  Page({ margin: 0 }, [
    Expanded(
      Box({ bg: navy, relative: true, overflow: "hidden" }, [
        // subtle disc for depth (bottom-left), then the bold accent disc cropped at the top-right corner
        Positioned(
          { bottom: -170, left: -130 },
          Box({ width: 380, height: 380, radius: 190, bg: navy2 }, []),
        ),
        Positioned(
          { top: -130, right: -120 },
          Box({ width: 300, height: 300, radius: 150, bg: accent }, []),
        ),

        // content, inset from the edges
        Positioned(
          { top: 220, left: 80, right: 80 },
          Column({ gap: 16 }, [
            Text("PURE TYPESCRIPT · ZERO DEPENDENCIES", { size: 11, bold: true, color: accent }),
            Text("Documents that flow, by design.", { size: 40, bold: true, color: white }),
            Divider({ color: navy2 }),
            Paragraph(LEAD, { size: 13, color: soft, font: "Times-Roman" }),
          ]),
        ),

        // a corner mark, anchored to the frame
        Positioned(
          { bottom: 48, right: 56 },
          Text("jasy · 01", { size: 11, bold: true, color: accent }),
        ),
      ]),
    ),
  ]),
]);
PDF/A · 1 page

datasheet.ts

Datasheet

A product datasheet - stat cards, a spec table, and a bar chart drawn from primitives.

datasheet.ts
// A datasheet page built from the drawing primitives: stat cards, a bar chart drawn as rectangles on
// a baseline, and a spec strip. Shows boxes, fills, rules and color driving a data-dense layout.
import { Document, Page, Column, Row, Box, Padding, Text, Divider } from "@jasy/pdf";

const ink = "#1b2433";
const muted = "#6b7280";
const brand = "#1450aa";
const accent = "#e3b505";
const hair = "#e6eaf2";
const paper = "#f5f8fd";

const stat = (value: string, label: string, sub: string) =>
  Box({ bg: paper, padding: { x: 16, y: 14 }, radius: 8 }, [
    Column({ gap: 3 }, [
      Text(value, { size: 25, bold: true, color: brand, lineHeight: 1.3 }),
      Text(label, { size: 10.5, bold: true, color: ink, lineHeight: 1.3 }),
      Text(sub, { size: 9, color: muted, lineHeight: 1.3 }),
    ]),
  ]);

// bar chart data: [label, value]; the peak gets the accent fill
const data: [string, number][] = [
  ["W1", 62],
  ["W2", 95],
  ["W3", 78],
  ["W4", 128],
  ["W5", 112],
  ["W6", 168],
  ["W7", 141],
  ["W8", 190],
];
const MAXV = 200;
const CHART_H = 168;
const BAR_W = 34;
const peak = Math.max(...data.map(([, v]) => v));

const bars = Row(
  { justify: "between", align: "end" },
  data.map(([, v]) =>
    Box(
      { width: BAR_W, height: (CHART_H * v) / MAXV, bg: v === peak ? accent : brand, radius: 3 },
      [],
    ),
  ),
);

const labels = Row(
  { justify: "between" },
  data.map(([lbl]) =>
    Box({ width: BAR_W }, [Text(lbl, { size: 9, color: muted, align: "center" })]),
  ),
);

const spec = (k: string, v: string) =>
  Row({ justify: "between", gap: 12 }, [
    Text(k, { size: 9.5, color: muted, lineHeight: 1.3 }),
    Text(v, { size: 9.5, color: ink, bold: true, align: "right", lineHeight: 1.3 }),
  ]);

const page = Page({ margin: 56, gap: 0 }, [
  // header
  Text("DATASHEET", { size: 10, bold: true, color: accent }),
  Padding(
    { top: 3 },
    Text("Render performance", { size: 26, bold: true, color: ink, lineHeight: 1.3 }),
  ),
  Text("Pure-TypeScript PDF engine, measured on a laptop, single thread", {
    size: 11.5,
    color: muted,
    lineHeight: 1.3,
  }),

  // stat cards
  Padding(
    { top: 22 },
    Row({ gap: 14, align: "stretch" }, [
      stat("2.4s", "1,000 invoices", "rendered end to end"),
      stat("0", "native deps", "no browser, no JVM"),
      stat("~97%", "font subset", "embedded TrueType"),
      stat("13/13", "PDF/A-3 checks", "ZUGFeRD conformant"),
    ]),
  ),

  // bar chart
  Padding(
    { top: 30 },
    Text("Throughput, pages per second over eight builds", { size: 11, bold: true, color: ink }),
  ),
  Padding({ top: 12 }, Column({ gap: 8 }, [bars, Divider({ color: ink }), labels])),

  // spec strip
  Padding(
    { top: 30 },
    Box({ border: hair, padding: { x: 16, y: 14 }, radius: 8 }, [
      Row({ gap: 40, align: "start" }, [
        Box({ width: 200 }, [
          Column({ gap: 8 }, [
            spec("Page sizes", "A6 to A3, Letter, custom"),
            spec("Fonts", "standard-14 + TrueType"),
            spec("Output", "PDF 1.7, PDF/A-3"),
          ]),
        ]),
        Box({ width: 200 }, [
          Column({ gap: 8 }, [
            spec("Images", "JPEG, PNG, BoxFit"),
            spec("Pagination", "text, tables, images"),
            spec("E-invoice", "EN 16931 CII + UBL"),
          ]),
        ]),
      ]),
    ]),
  ),
]);

export default Document([page]);
PDF/A · 1 page

article.ts

Article

Flowing body copy - headings, paragraphs and inherited line-height breaking across two pages.

article.ts
// A written article that flows across pages: a display title, serif body, section headings and a
// pull-quote, with a footer that repeats on every page. The point is typographic text flow - the body
// breaks at real font metrics and paginates on its own.
import { Document, Page, Column, Row, Box, Padding, Text, Paragraph, Divider } from "@jasy/pdf";

const ink = "#1b2433";
const muted = "#6b7280";
const brand = "#1450aa";
const gold = "#9c720c";
const hair = "#e6eaf2";

const LEAD =
  "Every tool that makes a PDF has to answer the same question sooner or later: what happens when the " +
  "content does not fit on one page. Most answer it badly. They draw until they run out of room, then " +
  "start a fresh page wherever the cursor happened to land, splitting a heading from its first " +
  "paragraph or slicing a table row clean in half. A document engine earns its name by answering that " +
  "question well.";

const P1 =
  "Laying a single page out is the easy part. You measure each element, stack them, and stop. The " +
  "trouble starts at the page boundary. A paragraph has to break between lines, never between a line " +
  "and its own descenders. An image has to move as a whole rather than tear across the seam. A table " +
  "has to carry its header onto the next page so the reader does not lose the column meanings. Each of " +
  "these is simple in isolation and unforgiving in combination, and together they are the fifteen " +
  "percent of the work that takes eighty-five percent of the time.";

const P2 =
  "The naive approach mutates positions as it goes, nudging elements up and down until they happen to " +
  "fit. It works until two elements disagree about who moves, and then it produces the subtle, " +
  "maddening bugs that only surface on the third page of a long report. The durable approach computes " +
  "the fragment of content that fits, hands back the remainder, and repeats. Constraints flow down " +
  "once, sizes flow up once, and nothing is nudged after the fact.";

const P3 =
  "Wrapping text correctly is impossible without knowing how wide each glyph is. Browsers know because " +
  "they ship a font stack and a shaping engine. A library that wants to avoid a headless browser has " +
  "to bring its own metrics. jasy parses the Adobe font-metric files for the standard fonts and reads " +
  "the same tables straight out of any TrueType file you embed, so the width of a line is computed " +
  "from the true advance of every character rather than estimated from an average.";

const P4 =
  "This is also why the same text wraps identically whether it is being measured, drawn, or split " +
  "across a page. One line breaker feeds all three. When the measure and the draw disagree by even a " +
  "fraction of a point, text that was sized to fit suddenly does not, and a word drops to a line of " +
  "its own for no visible reason. Keeping a single source of truth is less a nicety than a requirement.";

const P5 =
  "The payoff for treating flow as a first-class idea, rather than a patch bolted onto a single-page " +
  "layout, is that complex documents stop being special cases. An invoice whose line items run long " +
  "simply continues onto a second page with its header intact. A report with a chapter of dense prose " +
  "paginates without anyone thinking about it. The author describes the shape of the document, and the " +
  "engine decides where the paper ends.";

const P6 =
  "None of this requires a browser, a virtual machine, or a network call. It is a tree of components, " +
  "a set of real font metrics, and a fragmentation pass that knows how to say: this much fits here, " +
  "the rest goes next. That is the whole trick, and getting it right is the difference between a PDF " +
  "you fight with and one you forget about.";

const heading = (t: string) =>
  Padding({ top: 20, bottom: 2 }, Text(t, { size: 15, bold: true, color: ink }));
const body = (t: string) =>
  Paragraph(t, { font: "Times-Roman", size: 11.5, color: ink, lineHeight: 1.45 });

const footer = Column({ gap: 6 }, [
  Divider({ color: hair }),
  Row({ justify: "between" }, [
    Text("jasy", { size: 8.5, bold: true, color: brand }),
    Text("On document engineering", { size: 8.5, color: muted }),
  ]),
]);

const page = Page({ margin: 64, gap: 5, footer }, [
  Text("ON DOCUMENT ENGINEERING", { size: 10, bold: true, color: gold }),
  Padding(
    { top: 4 },
    Text("Why pagination is the hard part", { size: 29, bold: true, color: ink }),
  ),
  Text("And what it takes to get right in pure TypeScript", { size: 13, color: muted }),
  Padding({ top: 12, bottom: 6 }, Divider({ color: hair })),

  Paragraph(LEAD, { font: "Times-Roman", size: 13, color: ink, lineHeight: 1.5 }),

  heading("The last fifteen percent"),
  body(P1),
  body(P2),

  Padding(
    { top: 14, bottom: 14 },
    Box({ borderLeft: brand, padding: { left: 18, y: 4 } }, [
      Text(
        "A table that breaks mid-row, a heading stranded at the foot of a page: these are the details that separate a layout engine from a drawing API.",
        { size: 16, italic: true, font: "Times-Roman", color: brand, lineHeight: 1.35 },
      ),
    ]),
  ),

  heading("Metrics, not guesses"),
  body(P3),
  body(P4),

  heading("Flow as a first-class idea"),
  body(P5),
  body(P6),
]);

export default Document([page]);
PDF/A · 1 page

letter.ts

Letter

A business letter - body type set once on the Document so every line inherits it; muted blocks use DefaultTextStyle.

letter.ts
// A business letter - the showcase for inherited text styles. The Document sets the body typeface,
// size, colour and line-height ONCE; every paragraph below just inherits them (no per-Text styling).
// Blocks that need their own look (the contact line, the signature title, the confidentiality note)
// wrap a DefaultTextStyle that re-defaults only that subtree, still inheriting the font.
import {
  Document,
  Page,
  Column,
  Row,
  Box,
  Divider,
  Spacer,
  DefaultTextStyle,
  Text,
} from "@jasy/pdf";

const ink = "#1f2a37";
const brand = "#1450aa";
const muted = "#6b7280";
const hair = "#dfe4ee";

const gap = (h: number) => Box({ height: h }, []);

export default Document({ font: "Times-Roman", size: 11, color: ink, lineHeight: 1.5 }, [
  Page({ size: "A4", margin: 64 }, [
    // letterhead: brand wordmark + a muted contact block (its own default style)
    Row({ justify: "between", align: "end" }, [
      Text("Muster Studio", { size: 20, bold: true, color: brand }),
      DefaultTextStyle({ size: 9, color: muted }, [
        Column({ align: "end", gap: 1 }, [
          Text("Hauptstraße 1 · 10115 Berlin"),
          Text("kontakt@muster-studio.de"),
        ]),
      ]),
    ]),
    gap(10),
    Divider({ color: hair }),
    gap(26),

    Row({ justify: "end" }, [Text("Berlin, 24 June 2026")]),
    gap(20),

    Column({ gap: 2 }, [
      Text("Beispiel Kunde AG", { bold: true }),
      Text("Marienplatz 1"),
      Text("80331 München"),
    ]),
    gap(24),

    Text("Re: Your brand refresh - milestone two", { bold: true }),
    gap(16),

    Text("Dear Ms. Example,"),
    gap(10),
    Text(
      "thank you for the trust you have placed in our studio. The first phase of your brand " +
        "refresh is complete, and we are delighted with how the new identity has come together.",
    ),
    gap(8),
    Text(
      "Over the coming weeks we will prepare the design system and the component library, and " +
        "share a working preview with your team well ahead of the launch date. Should anything " +
        "need adjusting, there is ample room in the schedule to accommodate it.",
    ),
    gap(8),
    Text("We look forward to the next milestone and remain at your disposal for any questions."),
    gap(20),

    Text("Sincerely,"),
    gap(6),
    Text("Erika Muster", { bold: true }),
    DefaultTextStyle({ size: 9, color: muted }, [Text("Founder · Muster Studio")]),

    Spacer(),
    Divider({ color: hair }),
    gap(8),
    DefaultTextStyle({ size: 8.5, color: muted, lineHeight: 1.4 }, [
      Text(
        "This letter is confidential and intended solely for the addressee. If you have received " +
          "it in error, please notify the sender and delete it.",
      ),
    ]),
  ]),
]);
PDF/A · 1 page

banner.ts

Banner

An A5-landscape banner - a full-bleed image with type composited over it.

banner.ts
// A promo banner in a different format (A5 landscape): a raster image fills the page edge to edge
// (BoxFit cover, cropped by overflow:hidden), with the message positioned on top. Shows the image
// layer + a non-A4 format. Everything is out-of-flow (Positioned) inside an Expanded relative frame,
// so the frame fills the page cleanly - the same pattern as the cover page.
import { Document, Page, Box, Expanded, Positioned, Column, Text, Image } from "@jasy/pdf";

const white = "#ffffff";
const soft = "#cdddf5";
const accent = "#f3dc29";

// A5 landscape = 595.28 x 419.53 pt (148 x 210 mm)

export default Document([
  Page({ size: "A5", orientation: "landscape", margin: 0 }, [
    Expanded(
      Box({ relative: true, overflow: "hidden" }, [
        // full-bleed background image: sized a touch over the page and cropped to a clean bleed by
        // overflow:hidden. A Positioned child is out of flow, so even oversized it never paginates.
        Positioned(
          { top: 0, left: 0 },
          Image("examples/assets/banner.png", { width: 600, height: 425, fit: "cover" }),
        ),

        // message
        Positioned(
          { top: 64, left: 56, right: 56 },
          Column({ gap: 12 }, [
            Text("PURE TYPESCRIPT  ·  ZERO DEPENDENCIES", { size: 11, bold: true, color: accent }),
            Column({ gap: 2 }, [
              Text("Make beautiful PDFs", { size: 34, bold: true, color: white }),
              Text("straight from components.", { size: 34, bold: true, color: white }),
            ]),
            Text("No headless browser, no Java. Just a tree of components and real font metrics.", {
              size: 13,
              color: soft,
              lineHeight: 1.35,
            }),
          ]),
        ),

        // footer marks, anchored to the frame corners
        Positioned(
          { bottom: 36, left: 56 },
          Text("@jasy/pdf", { size: 12, bold: true, color: accent }),
        ),
        Positioned({ bottom: 36, right: 56 }, Text("v1.0  ·  MIT", { size: 11, color: soft })),
      ]),
    ),
  ]),
]);
PDF/A · 1 page

label.ts

Product label

A 50x65 mm label via mm() - brand, price, and a barcode drawn from rectangles.

label.ts
// A 50x65mm product label - a custom (non-A/B) page format via the mm() helper. Compact layout:
// brand, product, price, and a barcode drawn from rectangles.
import { Document, Page, Column, Row, Box, Text, Divider, Spacer, mm } from "@jasy/pdf";

const ink = "#1b2433";
const muted = "#6b7280";
const brand = "#1450aa";
const hair = "#dfe4ee";

// a barcode from bars of varying width (deterministic pattern)
const WIDTHS = [1, 2, 1, 1, 3, 1, 2, 1, 1, 2, 3, 1, 1, 2, 1, 3, 1, 1, 2, 1, 2, 1, 3, 1, 1, 2, 1];
const barcode = Row(
  { gap: 1.4, align: "end", justify: "center" },
  WIDTHS.map((w) => Box({ width: w, height: 28, bg: ink }, [])),
);

export default Document([
  Page({ size: mm(50, 65), margin: 10 }, [
    Row({ justify: "between", align: "center" }, [
      Text("MUSTER", { size: 11, bold: true, color: brand }),
      Text("ROASTERS", { size: 6.5, bold: true, color: muted }),
    ]),
    Divider({ color: hair, margin: { y: 7 } }),

    Text("SINGLE ORIGIN", { size: 7, bold: true, color: muted }),
    Text("Ethiopia Yirgacheffe", { size: 13, bold: true, color: ink, lineHeight: 1.15 }),
    Text("Washed · floral, citrus, tea-like", { size: 7.5, color: muted, lineHeight: 1.2 }),

    Spacer(),

    Row({ justify: "between", align: "end" }, [
      Column({ gap: 1 }, [
        Text("250 g", { size: 8.5, bold: true, color: ink }),
        Text("Whole bean", { size: 7, color: muted }),
      ]),
      Text("12,90 €", { size: 17, bold: true, color: ink }),
    ]),

    Divider({ color: hair, margin: { y: 7 } }),

    Column({ gap: 4 }, [
      barcode,
      Text("4 006381 332149", { size: 6.5, color: muted, align: "center" }),
    ]),
  ]),
]);
PDF/A · 1 page
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