Documentation

Text

Text puts words on the page. Pass a string and a few options, and the engine wraps it to fit the width it is given, breaking on word boundaries using the real font metrics. You never measure anything.

When does it wrap?

A Text wraps to fit the width it is given - the page content width in a Page or Column, or the width of a Box({ width }), an Expanded or a table cell when it sits inside one. With no width limit at all (a plain child of a Row), it stays on a single line, because there is nothing to wrap against.

Styling

The second argument sets the style. Everything is optional and has a sensible default.

Text("Big and bold", { size: 26, bold: true, color: "#1450aa" });
Text("A quiet note", { size: 12, italic: true, color: "gray" });
PropTypeWhat it does
sizenumberfont size in points (default 12)
boldbooleanuse the bold weight
italicbooleanuse the italic style
colorColorInputtext color (any of the color forms)
fontstringfont family (default "Helvetica"; see Fonts)
align"left" | "center" | "right" | "justify"alignment within the text block (default "left")
lineHeightnumberline-height multiplier; unset = the font's natural line height
underlinebooleandraw an underline at the font's own position and thickness
strikethroughbooleandraw a line through the text at half its x-height
skipInkbooleanlet the underline step around descenders (needs an embedded font)
letterSpacingnumberextra space after every glyph, in points (negative tightens)
wordSpacingnumberextra advance at every space, in points (CSS word-spacing)
textIndentnumberhow far the FIRST line starts in, in points
textTransform"uppercase" | "lowercase" | "capitalize"recase the text (CSS text-transform)
ligaturesbooleanthe font's own fi / fl / ffi ligatures (default on)
breakWordbooleansplit a word wider than its box, anywhere, no hyphen
hyphenate(word) => string[]split at valid points and draw the hyphen - bring your own
direction"ltr" | "rtl"base writing direction; rtl starts the line on the right
maxLinesnumbercap the number of lines (default: unlimited)
overflow"clip" | "ellipsis"what to do past maxLines (default "clip")
orphansnumberminimum lines left behind at a page break (default 2)
widowsnumberminimum lines carried over to the next page (default 2)

Alignment

align positions the text inside its own block, independent of any surrounding layout.

Text("Centered", { align: "center" });
Text("Right aligned", { align: "right" });
Text(longParagraph, { align: "justify" });

justify spreads a line's slack over its spaces so both edges line up. The last line of a paragraph keeps its natural spacing, as it does in print and in CSS. The engine may also squeeze a line slightly - up to a quarter of a space - if that keeps one more word on it.

Line height

lineHeight is a multiplier on the line spacing. Leave it unset and each line gets the font's own natural line height - the same thing CSS calls line-height: normal, so text is comfortable to read out of the box and capitals sit optically centred in their line. Set a number to override it: 1 is one em (tight, capitals may just touch the edges), 1.3 to 1.5 opens body copy up further. Whatever the value, the extra space is split evenly above and below each line.

Text("Comfortable by default - the font's natural line height.");
Text("Airier still, set once.", { lineHeight: 1.5 });

Underline, strikethrough and letter spacing

Decoration and tracking read their geometry from the font itself, never a hard-coded guess.

Text("Underlined", { underline: true });
Text("Struck through", { strikethrough: true });
Text("S P A C E D  H E A D E R", { letterSpacing: 3, bold: true });
Text("tighter", { letterSpacing: -0.5 });

An underline sits at the position and thickness the font declares, one stroke per wrapped line, and a struck-through line crosses at half the x-height - where a browser puts it. letterSpacing adds a fixed gap after every glyph (a negative value tightens); it is counted in wrapping and alignment, so spaced text still breaks and aligns correctly. All of these also work on a single span and cascade from Document / DefaultTextStyle.

skipInk lets the underline break around descenders like a browser does - it needs an embedded font (the standard PDF fonts keep their outlines in the viewer, not in the document), and asking for it with a standard font is an error rather than a silent no-op.

Kerning and ligatures

Both are on by default, because the font's designer drew them and nobody should need to know the words to get text set properly.

Kerning applies the font's own pair adjustments, tightening combinations like AV, To and Wa so headings read the way they do on the web instead of falling into loose slabs. It works for the standard fonts and for embedded ones (the legacy kern table and modern OpenType GPOS alike).

Ligatures merge fi, fl and ffi into the single glyph the font provides, read from its GSUB table. Embedded fonts only - the standard 14 have no such table.

Text("office affluent film"); // ffi and fl become one glyph each
Text("office affluent film", { ligatures: false }); // opt out for this text

await renderToBytes(doc, { kerning: false }); // or turn kerning off document-wide

ligatures is an inheritable text style, so it can be set on Document, on a DefaultTextStyle subtree, or on one Text.

A word that does not fit

By default a word wider than its box overflows, exactly as it does in CSS. Two layers stack on top of that, and neither is on unless you ask:

Text(email, { breakWord: true }); // split where the box ends, no hyphen
Text(german, { hyphenate }); // split at valid points, with a hyphen

breakWord is the floor: an e-mail address, an IBAN or an invoice number has no valid hyphenation point anywhere, and it is what stops a mandatory field on an invoice from being drawn across its own label.

hyphenate is a hook, not bundled data. Give it a function that returns a word's parts, and jasy draws the hyphen at the break. Any pattern library will do:

import { hyphenateSync } from "hyphen/de";
const hyphenate = (word: string) => hyphenateSync(word).split("\u00AD");

Text("Rechtsschutzversicherungsgesellschaften", { hyphenate, breakWord: true });

No pattern data ships in @jasy/pdf: German alone is 732 KB, and hyphenating without being told the language is worse than not hyphenating at all. The default is a position, not an omission - we do not hyphenate until you name the language.

Right to left, and Arabic

Set direction: "rtl" and the line starts on the right. Mixed text is reordered per Unicode UAX #9 either way, so Hebrew inside an English paragraph already comes out right without the flag - what the flag decides is where the LINE begins.

Text("שלום world", { direction: "rtl" });
Text("مرحبا بالعالم", { font: "Amiri", direction: "rtl" });

Arabic letters join, read from the font's own GSUB, and lam-alef collapses into one glyph. That needs an embedded Arabic face; copied text comes back as real Arabic, not as presentation forms.

Line breaks

A \n in your string is a hard break: it breaks the line even where there is room, because it is an instruction rather than wrapping. \r\n and a lone \r mean the same thing, a tab becomes a space, and invisible control characters are dropped - except the ones that do work, like the zero-width joiner Arabic shaping needs.

Inheriting text styles

Every option above has a built-in default, but you rarely want to repeat size or color on every line. Set them once and let them cascade - exactly like CSS, or Flutter's DefaultTextStyle.

Document-wide defaults

Pass the text options to Document and every Text inherits them unless it sets its own. This is the cleanest way to give a whole document one consistent typeface, size, colour and rhythm.

Document({ font: "Times-Roman", size: 11, color: "#1f2a37", lineHeight: 1.5 }, [
  Page({ size: "A4" }, [
    Text("Inherits the serif, 11pt, dark ink and 1.5 line-height."),
    Text("Sets nothing either - identical to the line above."),
    Text("Overrides just the colour; size, font and line-height still inherit.", {
      color: "#c0392b",
    }),
  ]),
]);

A font that covers what the first one cannot

font also takes a list. Each code point is drawn by the first family in it that has a glyph for that character, so mixed-script text works without splitting it by hand:

Text("Hello 你好 مرحبا", { font: ["Inter", "NotoSansCJK", "Amiri"] });

A code point no family in the stack can draw is removed and reported rather than emitted as a .notdef box - PDF/A forbids referencing one. renderToBytes(doc, { onMissingGlyphs }) tells you exactly which characters went missing, so a lost tick mark never silently stops an invoice.

A default for one section - DefaultTextStyle

To re-default a section instead of the whole document, wrap it in DefaultTextStyle. Its children inherit the new values, layered onto whatever was already inherited from above.

DefaultTextStyle({ size: 9, color: "gray" }, [
  Text("Small and grey - a footnote, a caption, a contact block."),
  Text("Same here, nothing set on it."),
  Text("Still 9pt, but overrides the colour.", { color: "#1450aa" }),
]);

To style one whole page, make it the page's child: Page([ DefaultTextStyle({ … }, [ …content ]) ]).

How a value is resolved

For each property the most specific source wins - and only that property; the rest keep inheriting (a per-property merge, like Flutter):

explicit on the Text  >  nearest DefaultTextStyle  >  Document defaults  >  built-in default

Only text properties inherit - font, size, color, bold, italic, align, lineHeight, the same set CSS inherits. Box and layout properties (padding, border, width, background) never do.

Truncating long text

By default a Text wraps onto as many lines as it needs. Cap it with maxLines, and choose what happens to the overflow with overflow: "clip" cuts hard, "ellipsis" ends the last line with .... Truncation needs a bounded width - a Column, a Box({ width }), an Expanded or a table cell.

Box({ width: 220 }, [
  Text("Premium Wireless Noise-Cancelling Over-Ear Headphones with 40 Hour Battery Life", {
    maxLines: 2,
    overflow: "ellipsis",
  }),
]);

maxLines: 1 clamps to a single line - handy for a long product name in a narrow table cell.

Mixed styles in one line

To change style partway through a line, pass an array of span(...) runs instead of a string. Each span overrides only the fields it sets, inheriting the rest from the Text options.

import { Text, span } from "@jasy/pdf";

Text(
  [
    span("This line mixes "),
    span("bold", { bold: true }),
    span(", "),
    span("italic", { italic: true }),
    span(" and "),
    span("color", { color: "#1450aa", bold: true }),
    span(" in one run."),
  ],
  { size: 14 },
);

span takes the same TextStyle as Text - size, font, weight, colour, decoration, spacing, direction - just not the ones that belong to the whole block (align, lineHeight, maxLines). It adds two of its own:

Text([span("Total 1.190,00 EUR"), span("1", { verticalAlign: "super", size: 7 })]);
Text([span("Read the "), span("terms", { href: "https://example.com/terms" }), span(".")]);

verticalAlign: "super" | "sub" raises a footnote marker or lowers an index - it shifts the baseline only, so pass a smaller size too if you want the browser's <sup> look. And href / to link just that run rather than the whole text.

breakWord and hyphenate are read per Text, not per span - a known difference from CSS.

Paragraphs

Paragraph is Text with a different name. It behaves identically, but reads as intent when you mean a block of body copy rather than a label or a heading.

Paragraph(
  "Long text wraps automatically to fit its width, breaking on word boundaries using the real " +
    "font metrics, so you never have to measure anything yourself.",
  { size: 12 },
);

See it all in one file

Copy this, run it with npx tsx text-demo.ts, and open text.pdf.

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

async function build() {
  const doc = Document([
    Page({ size: "A4", margin: 56 }, [
      Column({ gap: 12 }, [
        Text("Big and bold", { size: 26, bold: true, color: "#1450aa" }),
        Text("Italic and gray", { size: 14, italic: true, color: "gray" }),
        Text("Centered", { size: 14, align: "center" }),
        Text("Right aligned", { size: 14, align: "right" }),
        Divider({ color: "steelblue" }),
        Text(
          [
            span("This line mixes "),
            span("bold", { bold: true }),
            span(", "),
            span("italic", { italic: true }),
            span(" and "),
            span("color", { color: "#1450aa", bold: true }),
            span(" in one run."),
          ],
          { size: 14 },
        ),
        Divider({ color: "steelblue" }),
        Paragraph(
          "Paragraph is Text with a name that reads as intent. Long text wraps automatically to fit " +
            "its width, breaking on word boundaries using the real font metrics, so you never measure " +
            "anything yourself.",
          { size: 12 },
        ),
      ]),
    ]),
  ]);

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

build();
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