Home  ›  Guides  ›  Typography

Typography — page breaks, justification, kerning & OpenType features

New in v1.8.0. Typeset-quality text with zero dependencies: paragraphs that break across pages under widow and orphan rules, headings kept with what follows, justification and optical margin alignment, soft hyphens and a hyphenation seam, number–unit binding and punctuation spacing, exact base-14 metrics, pair kerning, and OpenType single-substitution features such as tabular figures. Every option is opt-in under layout.typography — output is byte-identical when it is unset.

TL;DR#

import { buildDocumentPDFBytes } from 'pdfnative';

const pdf = buildDocumentPDFBytes(
  {
    title: 'Annual report',
    fontEntries, // a registered font: kerning and features read its tables
    blocks: [
      { type: 'heading', text: 'Results', level: 1 },
      { type: 'paragraph', text: longText, align: 'justify' },
    ],
  },
  {
    typography: {
      splitParagraphs: true,        // break long paragraphs at line boundaries
      orphans: 2, widows: 2,        // …never leaving a single stranded line
      keepHeadingsWithNext: true,   // no heading alone at the foot of a page
      opticalMargins: true,         // hang punctuation past the measure
      unitBinding: true,            // "150 €" and "12 kg" never break
      kerning: true,                // AV, To, Yo set with the font's pair kerning
      fontFeatures: ['onum'],       // old-style figures in running text
    },
  },
);

Why opt-in: each option moves glyphs or line breaks, so turning one on changes an existing document's output. Nothing changes until you ask.

Page breaking#

Option Default Effect
splitParagraphs false A paragraph that does not fit breaks at a line boundary instead of moving to the next page whole. A paragraph taller than a page continues instead of running off the bottom.
orphans 2 Minimum lines left at the foot of a page for a break to be allowed there. Requires splitParagraphs.
widows 2 Minimum lines carried to the next page; the break is pulled earlier otherwise. Requires splitParagraphs.
keepHeadingsWithNext false A heading that would end a page moves to the next one with its content. Works without splitParagraphs. Pass true, or { minLines: N } to reserve at least N lines of what follows (default 2; three is a common house style) — the reservation never drops below orphans, and a paragraph that cannot spare N lines and still carry widows over moves whole.

Blocks override the document setting: a heading or paragraph accepts keepWithNext, and a paragraph accepts splittable (its own split permission, false to keep one paragraph atomic while splitParagraphs is on). Under a PDF/A claim a paragraph split across pages remains one /P structure element.

Justification and optical margins#

align: 'justify' on a paragraph spans every line except the last across the full measure. Each line is written as one TJ array: every word keeps its space glyph and a negative adjustment opens the gap, so the line stays a single text object and viewers, search and extractText() still see word boundaries. (Tw, the word-spacing operator, is never used: it applies only to single-byte code 32 and does nothing for the Identity-H CID fonts every registered font uses.) A line that would need an implausible stretch is left ragged rather than opened into rivers of white space.

opticalMargins: true lets punctuation hang past the edge it sits against. An opening quote at the start of a line, or a full stop at the end, is mostly white space inside its own box, so a column whose glyph origins sit exactly on the margin still looks indented. Hanging the mark back out makes the optical edge straight. It applies to the leading mark of left-aligned and justified lines and the trailing mark of justified and right-aligned ones, with conservative offsets.

Hyphenation#

Two sources of break opportunities inside a word:

import { setHyphenationProvider } from 'pdfnative';

// Return the indices inside `word` where a hyphen may go. `lang` is the
// document's typography.hyphenationLanguage, or undefined.
setHyphenationProvider((word, lang) => myHyphenator.positions(word, lang ?? 'en'));

buildDocumentPDFBytes(params, { typography: { hyphenationLanguage: 'fr' } });

The provider receives the bare word (no surrounding space) and must be synchronous and pure: it may be called many times for the same word during layout, and a changing answer would make output irreproducible. A word that already contains soft hyphens keeps the author's choice, and a provider that throws is ignored for that word. Pass null to remove it. The sample typography/hyphenation-provider.pdf installs a small rule-based English syllable provider for one document (with hyphenationLanguage: 'en').

Spacing rules#

Unit bindingunitBinding: true replaces the space between a number and the unit symbol that follows with a no-break space, so 150 €, 12 kg or 30 % never split across lines. ISO 80000-1 asks for this in every language, so it carries no locale assumption. Only an existing plain space before a recognised symbol standing on its own is converted — "150 personnes" stays breakable. Pass { units: [...] } to replace the built-in symbol list.

Punctuation spacing — conventions differ by language, so this is never automatic. punctuationSpacing takes a preset the library can state precisely, or a list of rules for any other convention. 'fr' sets a narrow no-break space (U+202F) before ; ! ? and a no-break space (U+00A0) before : and inside guillemets; 'fr-CA' keeps only the colon and guillemet spaces, as Canadian French usage does. A registered font that carries both glyphs (Noto Sans does) renders them as written, narrow space included; the base-14 faces have no narrow no-break space, so on that path U+202F degrades to an ordinary space and 'fr' looks like 'fr-CA'.

typography: {
  punctuationSpacing: [
    { char: ':', side: 'before', space: 'nbsp' },
    { char: '?', side: 'before', space: 'narrow' },
  ],
}

Short-word bindingbindShortWords: true replaces the space after a one-letter word with a no-break space, so a line never ends on "a", "w" or "I". It is opt-in because it is a house style, not a typographic law: orthography in Polish, Czech, Slovak, Russian, Ukrainian and Hungarian, whose one-letter prepositions and conjunctions must not close a line, and a preference elsewhere — Chicago and Bringhurst do not forbid an English line ending on an article. { maxLength: 2 } widens the rule to words of up to two letters (three at most); { words: ['w', 'z', 'i', 'a', 'o', 'u'] } restricts it to an explicit list, which is the right form for a language whose short words are a closed set. Words are letters only and matched case-insensitively; a digit is never a word, so 2 m stays with unit binding. A short word at the end of the text or before punctuation keeps its space, and a unit already bound to its number is not taken for a lone short word. Two side effects to know about: the no-break space is visible to text extraction, and every binding removes a break opportunity, which a narrow column can feel.

Only existing spaces are converted; nothing is inserted where the author wrote none. Each rule applies to headings, paragraphs, lists, link labels and table content. bindUnits(), bindShortWords() and applyPunctuationSpacing() expose the same transforms for text you lay out yourself.

Metrics#

Text set in the non-embedded base-14 faces is measured with a historical estimate that buckets digits at 556 units, capitals at 680 and lowercase at 500 — and every accented letter and most punctuation at 556. French lines and currency columns therefore wrap and align slightly wrong. metrics: 'exact' reads the Adobe Core 14 AFM advances instead. It is opt-in because correct measurement moves line breaks. Registered fonts always measure from their own hmtx table.

Kerning#

kerning: true applies the font's pair kerning, so "AV", "To" and "Yo" are set as the type designer intended. Adjustments are written as TJ arrays, so a kerned run stays one text-showing operator and selection and extraction are unaffected. It needs a registered font that carries kerning data; the base-14 faces have none.

OpenType features#

fontFeatures applies OpenType features by tag. Only single substitutions are supported — one glyph in, one glyph out — which is what makes them safe without a full shaping engine:

Tag Effect
tnum / pnum Tabular (equal-width) / proportional figures
lnum / onum Lining / old-style figures
zero Slashed zero
ordn, sups, subs Ordinals, superiors, inferiors
smcp, c2sc Small capitals from lowercase / from capitals
case Case-sensitive forms

Which tags change anything depends on the font. Noto Sans, the bundled Latin face, sets lining, tabular figures by default: tnum and lnum therefore substitute nothing there, while pnum (proportional) and onum (old-style) do. tnum earns its keep with a font whose default figures are proportional, where 1 narrower than 8 makes a column of amounts ragged. A requested tag that no registered font declares, or that the font declares but that substitutes no glyph in the document, raises the TYPOGRAPHY_FEATURE_INEFFECTIVE diagnostic (console.warn by default, onDiagnostic to capture it, a thrown error under strict) — the same document may be built with different fonts, so it is a warning, not an error. Later tags win where two touch the same glyph. Ligatures and contextual features (liga, calt, frac) are out of scope. Features need a registered font.

Limits & scope#

See the typography samples: each pairs the default behaviour with the typographic option on identical content, in English except for the sentences that demonstrate a French convention — typography/breaks-atomic.pdf against breaks-split.pdf, keep-with-next-off.pdf against keep-with-next-on.pdf, align-ragged.pdf against align-justified.pdf and align-optical.pdf (which differs from the justified file by opticalMargins alone), text-plain.pdf against text-refined.pdf, kerning-off.pdf against kerning-on.pdf, features-default.pdf against features-proportional.pdf and features-oldstyle-smallcaps.pdf, metrics-approximate.pdf against metrics-exact.pdf, and hyphenation-provider.pdf. The regression gate refuses two samples of a pair that come out byte-identical.

See also#