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.
// 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]);
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.
// 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 }),
]),
),
),
]),
]),
]);
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.
// 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))),
]),
]),
]);
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-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,
),
]),
]);
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.
// 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)),
]);
zugferd-invoice.ts
ZUGFeRD e-invoice
A conformant ZUGFeRD PDF/A-3 with EN-16931 XML embedded - for humans and tax offices both.
// 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/zugferd (not renderToBytes);
// the harness writes both the .pdf and the .xml.
import type { Invoice } from "@jasy/zugferd";
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/zugferd."],
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.
// 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;
cover.ts
Cover page
A full-bleed cover - colour to the edge, out-of-flow positioned shapes, and overlaid display type.
// 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 }),
),
]),
),
]),
]);
datasheet.ts
Datasheet
A product datasheet - stat cards, a spec table, and a bar chart drawn from primitives.
// 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]);
article.ts
Article
Flowing body copy - headings, paragraphs and inherited line-height breaking across two pages.
// 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]);
letter.ts
Letter
A business letter - body type set once on the Document so every line inherits it; muted blocks use DefaultTextStyle.
// 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.",
),
]),
]),
]);
banner.ts
Banner
An A5-landscape banner - a full-bleed image with type composited over it.
// 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 })),
]),
),
]),
]);
label.ts
Product label
A 50x65 mm label via mm() - brand, price, and a barcode drawn from rectangles.
// 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" }),
]),
]),
]);