Skip to main content

Hyphenation & Justification

Updated: 2026-06-11|14 min|enes

The difference between amateur and professional typesetting lives in the spaces between words.

Open any paperback novel. The text is justified — both edges of every paragraph are perfectly aligned. But look closer. The spaces between words are nearly uniform, line after line. No rivers of white space running down the page. No lines where two words sit marooned with a vast ocean between them. Achieving this is far harder than it looks, and it is the single problem that has consumed more typographic engineering effort than any other.

Postext solves it with the same algorithm that TeX has used since 1981: Knuth-Plass optimal line breaking. Combined with TeX-quality hyphenation patterns, configurable word-spacing bounds, and a visual debug system for identifying problem lines, the engine produces justified text that meets publication standards.

#The Problem

When text is set with textAlign: 'justify', every line except the last must be stretched or compressed to fill the exact column width. The engine distributes the difference between the natural content width and the column width across the inter-word spaces on that line. (The last line is left ragged at its natural width — with one exception covered in The Final Line.)

If a line has many words, each space absorbs a tiny adjustment — invisible to the reader. But if a line has few words (because one long word forced an early break), each space must stretch dramatically. The result is a loose line: a line where the word spacing is so wide that it disrupts reading rhythm and creates ugly visual gaps.

The opposite problem exists too. If the engine packs too many words onto a line, the spaces shrink below their natural width, producing a tight line where words feel cramped together.

A naive line-breaking algorithm — the kind CSS uses — makes decisions one line at a time. It fills the current line with as many words as possible, breaks, and moves on. This greedy first-fit approach has a fundamental weakness: it cannot see the future. A decision that looks optimal for line 5 might force line 6 into a terrible break. By the time the algorithm reaches line 6, it is too late — line 5 is already committed.

#Knuth-Plass: Seeing the Whole Paragraph

The Knuth-Plass algorithm, published by Donald Knuth and Michael Plass in 1981, takes a radically different approach. Instead of breaking one line at a time, it considers every possible way to break the entire paragraph and picks the combination that minimizes the total "badness" across all lines. It is the algorithm that powers TeX, and it is why TeX-typeset documents have been the gold standard for justified text for over four decades.

#The Box-Glue-Penalty Model

Knuth-Plass does not think in terms of words and spaces. It models text as a sequence of three primitives:

PrimitiveRepresentsBehavior
BoxA word or text fragmentHas a fixed width. Cannot be stretched or compressed. Cannot be broken.
GlueInter-word spaceHas a natural width, a stretch capacity, and a shrink capacity. The engine can adjust glue within these bounds to fill the line.
PenaltyA potential break pointHas a cost. Low penalty = cheap break. High penalty = expensive break. A flagged penalty means a hyphenation point (adds a visible hyphen if used).

A paragraph becomes a sequence like:

[box "The"] [glue] [box "quick"] [glue] [box "brown"] [glue] [box "fox"]
[penalty -∞]  ← forced break at paragraph end

When hyphenation is active, long words are split into fragments separated by penalties:

[box "ty"] [penalty 50, flagged] [box "pog"] [penalty 50, flagged] [box "raphy"]

Each flagged penalty carries a cost of 50 — not free, but cheaper than producing a loose line.

Box-glue-penalty primitivesVisual key for the three primitives: box for a word fragment, glue for inter-word space, and penalty for a potential break point. The example paragraph shows how a hyphenation point becomes a flagged penalty.Boxfixed wordGluestretchable spacePenaltypossible breakExample: “The art of typography is old.”Theartoftypo50graphyisold.−∞
Every paragraph becomes a sequence of boxes, glues, and penalties.

#How It Finds the Optimum

The algorithm uses dynamic programming. It maintains a set of active nodes — potential breakpoints that could start new lines — and evaluates every feasible break from each active node. For each candidate break, it computes:

  1. Adjustment ratio (r): how much the glue on this line needs to stretch or shrink. r = 0 means the line fits perfectly. r > 0 means stretching (loose). r < 0 means shrinking (tight).

  2. Badness: a measure of how uneven the spacing is, computed as 100 × |r|³. The cubic growth means that a slightly loose line is tolerable, but a very loose line is severely penalized. A line with r = 2 has badness 800; a line with r = 0.5 has badness 12.

  3. Fitness class: each line is classified as tight (r < -0.5), normal (-0.5 ≤ r < 0.5), loose (0.5 ≤ r < 1.0), or very loose (r ≥ 1.0). If a tight line sits next to a very loose line, the contrast is jarring — so adjacent lines whose classes are more than one step apart are penalized.

  4. Demerits: the total cost of breaking here. Following TeX, badness and the break-point penalty are combined in a squared formula — demerits = (1 + badness + penalty)² when the penalty is non-negative; a negative penalty (a desirable break) instead subtracts its square: (1 + badness)² − penalty². Two flat demerits are then added on top:

    • Consecutive hyphen demerit (default 3000): penalizes two hyphenated lines in a row, because stacked hyphens are visually distracting.
    • Fitness class demerit (default 100): added when this line's fitness class is more than one step away from the previous line's.

    The runt penalty described below also enters this formula, injected as extra badness; the orphan and widow penalties act at a later stage, when a paragraph is split across columns.

The algorithm picks the sequence of breaks with the lowest total demerits across the whole paragraph — that is the entire advantage over greedy.

Badness as a function of adjustment ratioBadness grows as 100 times the absolute value of r cubed. Small stretches or compressions are cheap, but extreme stretches become extremely costly, which is why the algorithm avoids them.200400600800-2-1012rbadnesstight (r < 0)loose (r > 0)12100800badness(r) = 100 · |r|³
Badness grows cubically: slightly uneven is tolerable, very uneven is punished hard.
Fitness classesEvery line is classified as tight, normal, loose, or very loose based on its adjustment ratio. Adjacent lines whose classes differ by more than one step are penalized.r-0.50.51.0Tightr < -0.5squeezedNormal-0.5 ≤ r < 0.5just rightLoose0.5 ≤ r < 1.0a bit airyVery looser ≥ 1.0very airyΔ = 1 ✓Δ = 2 ✕Fitness demerit applies when neighbouring lines are more than one class apart.
Adjacent lines more than one class apart incur the fitness demerit.

The algorithm traces back through the active nodes to find the path with the lowest total demerits — the globally optimal set of breakpoints for the entire paragraph.

Orphan, widow, and runt penalties

Postext extends the standard cost model with three editorial penalties that steer the engine away from layouts with visually poor paragraph and column ends:

  • Orphan penalty applies when a candidate split would leave fewer than orphanMinLines lines at the top of the next column. Default orphanPenalty is 1000.
  • Widow penalty applies when the split would leave fewer than widowMinLines lines at the bottom of the current column. Default widowPenalty is 1000.
  • Runt penalty applies when the paragraph's final line would be shorter than roughly runtMinCharacters × normalSpaceWidth pixels. Default runtPenalty is 1000. Unlike orphan/widow (which add linearly to the split demerit), runt is injected as equivalent badness inside the Knuth–Plass squared formula so it competes on the same scale as line badness (which saturates at 10000) rather than being dwarfed by it.

The runt penalty is added to the candidate node's demerits inside the line breaker, so the solver is free to trade a slightly looser line for a longer final one. Orphan and widow penalties enter a separate optimisation that runs when a paragraph crosses a column boundary: every candidate split is scored — slack, orphan, and widow demerits summed — and the cheapest split wins, so the engine will naturally prefer splits that avoid both whenever possible. Tune the trade-off by adjusting orphanPenalty, widowPenalty, or runtPenalty; set any of them to 0 to disable that rule entirely. List items opt in via avoidOrphansInLists, avoidWidowsInLists, and avoidRuntsInLists (all true by default).

#Why It Matters

Greedy first-fit vs Knuth-Plass optimalSide-by-side illustration: the greedy algorithm takes the first line that fits and poisons later lines, producing ragged paragraphs with uneven spacing. Knuth-Plass evaluates the whole paragraph globally and produces even lines.Greedy first-fitDecides line by line, no look-aheadOne bad break poisons the next lineUneven spacing across the paragraphRivers of white spaceNo cost model for adjacent lines= What CSS doesKnuth-Plass optimalEvaluates every possible break setGlobally minimal demeritsEven spacing throughoutConsecutive-hyphen penaltyFitness-class smoothness= What Postext does
A local-greedy decision loses where a global-optimal plan wins.

The practical difference is visible. In a greedy layout, you will find paragraphs where one line is noticeably looser than its neighbors — and if you look carefully, you will see it happened because the previous line grabbed one word too many. Knuth-Plass avoids this by trading a slightly worse current line for a much better next one, because it can see the consequences.

#Postext's Implementation

Postext implements the full Knuth-Plass algorithm in the core package's knuthPlass/ module: a dynamic-programming core (active nodes, demerits, traceback) plus two adapter paths that convert text into the box-glue-penalty model:

  • Plain text path: uses @chenglou/pretext for DOM-free text measurement. Pretext provides segment widths and discretionary-hyphen widths; Postext converts them to KP items.
  • Rich text path: handles bold and italic spans using Canvas-based measurement. Each styled token becomes one or more boxes, with hyphenation break points inserted as penalties.

Both paths compute per-line justifiedSpaceRatio — the actual space width divided by the natural space width — which feeds into the loose-line debug system described below. The ratio is only computed for non-last lines; how the final line is set is covered in The Final Line.

Around the line breaker, text measurement lives in the measure/ module — the plain and rich measurement paths, Canvas glyph metrics, and font-string handling — behind an explicit measurement cache. clearMeasurementCache also clears the underlying text-width cache, so measurements stay accurate after web fonts finish loading. Parsing, the stage that turns source text into blocks and inline spans, lives in the parse/ module. See the Architecture page for the file-level layout of these modules.

Line breaking also interacts with the newer content kinds. Resource floats claim the top or bottom band of a column or page, shortening the column extents that the orphan, widow, and slack penalties react to. Captions and table cells are measured as wrapped rich-text runs using the caption and table fonts. Display math lines are centred and atomic — never justified or broken. See Document format › Resources and Configuration › Table style / Caption style for details.

Fallback behavior: if Knuth-Plass produces no valid breaks (which can happen with extremely narrow columns or very long words), the engine falls back to Pretext's greedy layoutNextLine(). This ensures the layout always completes.

#Hyphenation

Hyphenation and justification are inseparable. Without hyphenation, the engine's only way to avoid a loose line is to move a word to the next line — which often just shifts the problem. Hyphenation gives the engine a much larger set of break points to consider, dramatically improving the quality of justified text.

Justified text with and without hyphenationThe left column has no hyphenation enabled: line widths vary wildly because the engine can only move whole words. The right column has hyphenation enabled: line widths are nearly uniform and one word is broken with a hyphen.Without hyphenationUneven lines, long gaps, visible rivers.With hyphenationEven lines, one hyphen absorbs the variance.
Hyphenation dramatically reduces spacing variance in justified text.

#TeX-Quality Patterns

Postext uses Hypher (hypher v0.2.5) for hyphenation, powered by TeX/Liang hyphenation patterns. These are the same patterns that TeX has used since 1983 — a compact representation of syllable-boundary rules derived by Frank Liang's pattern-generation algorithm from large word corpora.

The patterns encode a set of numbered rules that, when overlaid on a word, indicate where breaks are allowed (odd numbers) and forbidden (even numbers). The leftmin and rightmin parameters in each language's pattern file ensure a minimum number of characters before and after any break point. For English (en-us), these are typically 2 and 3 respectively — so a word must have at least 2 characters before the hyphen and 3 after.

#Supported Locales

Locale codeLanguage
'en-us'English (US)
'es'Spanish
'fr'French
'de'German
'it'Italian
'pt'Portuguese
'ca'Catalan
'nl'Dutch

Each locale loads its own pattern set. Hypher instances are lazily created and cached — the first call for a given locale pays the initialization cost; subsequent calls are instant. If an unknown locale is passed, the engine falls back to en-us.

#Why Hypher (and Not a Custom Algorithm)

The original Postext hyphenation system used a custom vowel-based heuristic: it detected syllable boundaries by finding vowel clusters, common prefixes (over-, under-, inter-), and common suffixes (-tion, -ment, -sion). It was simple and fast, but fundamentally limited:

AspectCustom heuristicHypher (TeX patterns)
AccuracyGood for common words, unreliable for unusual ones. Vowel detection misses many valid break points and creates invalid ones.Near-perfect. Patterns are generated from large corpora and have been refined for over 40 years.
Language coverageHad to manually define vowel sets, prefixes, and suffixes for each language — tedious and error-prone.Pattern files exist for 50+ languages, maintained by the TeX community. Adding a language means adding one import.
Industry standardNot a recognized standard. No tooling or community support.The same patterns used by TeX, LibreOffice, Firefox, Chrome, and virtually every professional typesetting system.
MaintenanceEvery edge case is a bug to fix manually.Community-maintained pattern files. Bug fixes come from upstream.
Bundle size~170 lines, no dependencies.Hypher core is ~3 KB. Each language pattern file adds 20–80 KB (gzipped: 5–20 KB). All eight bundled locales add roughly 300 KB (gzipped: ~80 KB).
PerformanceVery fast (simple string scanning).Fast (trie lookup per character). Negligible in practice — hyphenation is never the bottleneck.

The trade-off is clear: a larger bundle in exchange for dramatically better correctness and zero maintenance burden. For a layout engine targeting publication-grade output, correctness wins. A single bad hyphenation in a printed book is more expensive than a few extra kilobytes of patterns.

#How Hyphenation Integrates with Knuth-Plass

Before text enters the Knuth-Plass algorithm, the engine pre-processes it with Hypher, inserting soft hyphens (Unicode \u00AD) at every legal break point. These invisible characters are then mapped to KP penalties with a cost of 50 and flagged: true.

The algorithm treats hyphenation breaks as just another option to evaluate alongside natural word boundaries (where glue allows a break). If using a hyphen produces a lower total demerits than leaving the line loose, the algorithm takes the hyphen. If not, it leaves the word intact.

The consecutive-hyphen demerit (default 3000) ensures the algorithm strongly avoids placing hyphens on two adjacent lines — a typographic convention that virtually all style guides enforce.

Hyphenation is only applied when both bodyText.hyphenation.enabled is true and bodyText.textAlign is 'justify'. Left-aligned text does not benefit from hyphenation because the right edge is intentionally ragged.

#Word Spacing Bounds

The glue model gives the engine explicit bounds on how much inter-word spaces can stretch or shrink. These bounds are controlled by two configuration properties on bodyText:

PropertyDefaultDescription
maxWordSpacing1.9Upper bound for word spacing, as a multiplier of the normal space width. At the default value, spaces can stretch up to 190% of their natural width.
minWordSpacing0.6Lower bound for word spacing, as a multiplier of the normal space width. At the default value, spaces can shrink down to 60% of their natural width.

These multipliers translate directly to the glue's stretch and shrink values in the Knuth-Plass model:

stretchPerSpace = normalSpaceWidth × (maxWordSpacing - 1)
shrinkPerSpace  = normalSpaceWidth × (1 - minWordSpacing)

At the defaults (1.9 / 0.6), if the normal space width is 4 px:

  • Each space can stretch by 3.6 px (from 4 px to 7.6 px)
  • Each space can shrink by 1.6 px (from 4 px to 2.4 px)

Tighter bounds (e.g., maxWordSpacing: 1.2) produce more uniform spacing but give the algorithm less room to maneuver, which may result in more hyphenation or, in extreme cases, overflow. Looser bounds (e.g., maxWordSpacing: 2.5) give the algorithm more flexibility but allow visibly uneven spacing on some lines.

The defaults of 1.9 and 0.6 favor the algorithm's flexibility — giving Knuth-Plass enough room to avoid overflow, hyphenation, and runts across narrow columns, while still being well within ranges typographic literature considers acceptable.

#The Final Line

In the box-glue-penalty model, every paragraph ends with a width-0, infinitely stretchable glue followed by a forced break. That trailing glue absorbs whatever space remains on the last line at zero cost, so last lines come out naturally ragged: they are rendered at their natural width, and justifiedSpaceRatio is only computed for non-last lines.

There is one exception. Knuth-Plass may accept a final line whose natural content is wider than the measure, on the assumption that its inter-word glue will shrink — this is standard TeX glue-setting semantics. All three backends detect this case (the line's natural content width exceeds the effective measure) and compress the inter-word spaces of that last line so it fits the measure exactly instead of overflowing. The check is applied identically in the canvas, PDF, and HTML backends.

#Optimal vs. Greedy Line Breaking

The bodyText.optimalLineBreaking property (default: true) controls which line-breaking algorithm the engine uses:

  • true: Knuth-Plass dynamic-programming algorithm. Evaluates all possible break sets and picks the globally optimal one. This is the recommended setting for any justified text.
  • false: Greedy first-fit, powered by Pretext's layoutNextLine(). Faster but produces lower-quality results. Use this only when performance matters more than typographic quality (e.g., real-time preview at very high character counts).

When Knuth-Plass is active and produces no valid breaks (which can happen with extremely narrow columns or words longer than the column width), the engine automatically falls back to greedy breaking for that paragraph.

#Loose-Line Debugging

Even with Knuth-Plass and hyphenation, some lines will be looser than ideal — especially in narrow columns with long words, or in languages with few hyphenation opportunities. The loose-line highlight debug feature helps you find these problem lines instantly.

#How It Works

Every line in the Virtual Document Tree carries a justifiedSpaceRatio — the ratio of the actual justified space width to the font's natural space width. A value of 1.0 means the spaces are at their natural width. A value of 2.5 means the spaces are 2.5 times wider than normal.

When debug.looseLineHighlight.enabled is true, the renderer paints a semi-transparent overlay on every line whose justifiedSpaceRatio exceeds the configured threshold. The default threshold is 3.0 — meaning only lines with spaces three times wider than normal are highlighted. This is a deliberately high bar; lines this loose are genuine typographic problems.

#Configuration

The loose-line highlight is part of the debug section of PostextConfig:

PropertyTypeDefaultDescription
looseLineHighlight.enabledbooleanfalseWhether to highlight loose lines.
looseLineHighlight.colorColorValue#ff000040Color of the highlight overlay. The default is a semi-transparent red.
looseLineHighlight.thresholdnumber3Multiplier of the normal space width above which a line is considered loose. Lower values catch more lines; higher values highlight only the worst offenders.
debug: {
  looseLineHighlight: {
    enabled: true,
    threshold: 2.5,
    color: { hex: '#ff660040', model: 'hex' },
  },
}

#Interpreting the Results

When you enable loose-line highlighting and see red bands across certain lines, it means the engine could not find a way to set those lines without excessive word spacing. Start at the top: causes are ordered from most to least likely.

CauseSolution
Column is too narrow for the font sizeIncrease column width, decrease font size, or switch to a single-column layout.
Long words with few hyphenation pointsVerify hyphenation is enabled and the correct locale is set. Some technical terms or proper nouns have no valid break points.
Hyphenation is disabledEnable bodyText.hyphenation.enabled. Justification without hyphenation is almost always worse.
Word spacing bounds are too tightIncrease maxWordSpacing slightly (e.g., from 1.9 to 2.4). This gives the algorithm more room.
Language has long compound words (e.g., German)Ensure the correct locale is set. German hyphenation patterns handle compound words well, but only if the engine knows it is German.

#Vertical Justification: Column Balancing

Knuth-Plass solves the horizontal problem — where each line breaks. Column balancing solves its vertical counterpart: where each column ends. Publishers expect every column of a page to start at the top and end flush with the page bottom, line for line across the whole spread. The very rules that protect text quality work against this: orphan/widow protection, keepWithNext headings, and unsplittable figures all push content to the next column before the current one is completely full, leaving one or more empty baseline-grid lines at its bottom.

When headings.balancing is enabled (the default), the engine closes those gaps the way a human compositor would, applying three levers in strict editorial priority order — each lever only acts on what the previous one could not absorb:

  1. Space above headings. Whole baseline-grid lines are added to the top margin of the headings inside the short column. When several lines are needed and the column holds several headings, the lines are distributed round-robin in importance order, so the most important heading always receives the largest share — 3 lines over an h2 and an h3 become +2 above the h2 and +1 above the h3. Headings at the very top of a column never receive extra space (columns keep starting at the page top), and a trailing heading is never pushed toward the column bottom.
  2. Space after list ends. A grid line is added where a list or enumeration ends — air after a list reads naturally. Capped per list end (maxLinesAfterList, default 1).
  3. Loose paragraphs. As a last resort, one paragraph of the column is re-broken one line longer — TeX's \looseness=+1. The breaker re-runs Knuth-Plass asking for exactly one extra line and accepts the result only when every line of the loose solution stays below maxWordSpacing: type colour never exceeds the limit you already configured. The engine prefers the longest paragraph in the column, where the extra space dilutes across the most inter-word glue and becomes invisible. Requires optimalLineBreaking (the default); paragraphs split across columns are excluded.

#Why the fix is local

Column breaks are element-bound: the element that opens the next column is there because it did not fit in the gap. Pushing a column's tail down by at most its own gap therefore never moves content into the next column or page — each column is fixed in place, without cascading reflows. The engine still verifies this empirically: it re-runs placement with the proposed adjustments (up to 7 passes), measures the total leftover gap, and always keeps the best layout found. A loose paragraph that fails to gain its line within the spacing limit is blacklisted and the next candidate is tried.

#When a short column is left alone

A short column is sometimes the correct output, and balancing knows to step aside:

  • The last column of a page is only balanced when the page flows naturally into the next one. Pages ended by :::pagebreak, a heading's breakBefore, or a chapter opener keep their short last column — a chapter legitimately ends mid-page.
  • The last page of the document is never balanced.
  • A column with no usable stretch point (no eligible heading, list end, or loosenable paragraph) keeps its gap rather than degrade the typography.
const layout = createLayout({
  headings: {
    balancing: {
      enabled: true,            // default
      maxLinesPerHeading: 4,    // cap per heading
      stretchAfterLists: true,  // lever 2
      maxLinesAfterList: 1,     // cap per list end
      looseParagraphs: true,    // lever 3 — bounded by bodyText.maxWordSpacing
    },
  },
});

In the sandbox these live in the Headings section ("Balance Columns", with "Stretch After Lists" and "Loose Paragraphs" nested under it). Turn on the baseline grid in the Canvas view to see the effect: with balancing off, short columns end above the last grid line; with it on, every balanceable column closes on the same line. See the Configuration page for the full option reference.

#Complete Example

A full configuration showcasing all hyphenation and justification settings:

import { buildDocument } from 'postext';
 
const vdt = buildDocument(content, {
  bodyText: {
    fontFamily: 'EB Garamond',
    fontSize: { value: 9, unit: 'pt' },
    textAlign: 'justify',
 
    // Knuth-Plass optimal line breaking (default: true)
    optimalLineBreaking: true,
 
    // Hyphenation
    hyphenation: {
      enabled: true,
      locale: 'es',
    },
 
    // Word spacing bounds (multipliers of normal space width)
    maxWordSpacing: 1.9,   // spaces stretch up to 190%
    minWordSpacing: 0.6,   // spaces shrink down to 60%
  },
 
  // Debug: highlight lines with excessive spacing
  debug: {
    looseLineHighlight: {
      enabled: true,
      threshold: 2.5,
      color: { hex: '#ff000040', model: 'hex' },
    },
  },
});

For the complete list of body text configuration options, see the Configuration page. For how the layout pipeline uses these settings during text measurement, see the Architecture page.