# Hyphenation & Justification

> How Postext breaks lines, justifies text, and controls word spacing using the Knuth-Plass algorithm

- HTML version: https://postext.dev/en/docs/justification
- Last updated: 2026-06-11
- Reading time: 14 min
- Other languages: [es](https://postext.dev/es/docs/justification.md)

**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](#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:

| Primitive | Represents | Behavior |
| --- | --- | --- |
| **Box** | A word or text fragment | Has a fixed width. Cannot be stretched or compressed. Cannot be broken. |
| **Glue** | Inter-word space | Has a *natural* width, a *stretch* capacity, and a *shrink* capacity. The engine can adjust glue within these bounds to fill the line. |
| **Penalty** | A potential break point | Has 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.

> **Figure: Box-glue-penalty primitives**
> Visual 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.
>
> *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.

> **Figure: Badness as a function of adjustment ratio**
> Badness 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.
>
> *Badness grows cubically: slightly uneven is tolerable, very uneven is punished hard.*

> **Figure: Fitness classes**
> Every 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.
>
> *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.

When the penalty loses anyway — every alternative break is infeasible — the engine falls back on what a compositor does by hand: it sets the paragraph one line shorter (`tightenRunts`). The word spaces of every line tighten, never past `minWordSpacing`, and when that alone cannot carry the stranded words a little negative tracking joins in, the smallest step that works and never more than `maxRuntTracking` thousandths of an em. Column balancing plays by the same rule in the other direction: a paragraph it would run one line long to fill a short column is left alone when that extra line would end in a runt — filling a column is no reason to strand a syllable.

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 — but only within `maxWordSpacing`: a line stretched beyond that limit carries an extra badness larger than any runt or hyphen penalty, so the breaker prefers a short last line, or a hyphenated last word, over word spacing that overshoots the limit. 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

> **Figure: Greedy first-fit vs Knuth-Plass optimal**
> Side-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.
>
> *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](#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](/en/docs/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](/en/docs/document-format#resources) and [Configuration › Table style](/en/docs/configuration#table-style) / [Caption style](/en/docs/configuration#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.

> **Figure: Justified text with and without hyphenation**
> The 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.
>
> *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 code | Language |
| --- | --- |
| `'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:

| Aspect | Custom heuristic | Hypher (TeX patterns) |
| --- | --- | --- |
| **Accuracy** | Good 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 coverage** | Had 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 standard** | Not a recognized standard. No tooling or community support. | The same patterns used by TeX, LibreOffice, Firefox, Chrome, and virtually every professional typesetting system. |
| **Maintenance** | Every 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). |
| **Performance** | Very 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.

Two break opportunities exist regardless of that setting. A **hard hyphen between two letters** — `enseñanza-aprendizaje`, `físico-química` — is a place the line may end: the word already carries the hyphen, so nothing is added. And a **word wider than its whole measure** (a narrow table cell, a long compound in a narrow column) is never let run past it: the engine divides it at the last syllable that fits, with a hyphen, or at the last character that fits when the dictionary offers no syllable there.

## 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`:

| Property | Default | Description |
| --- | --- | --- |
| `maxWordSpacing` | `1.9` | Upper 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. |
| `minWordSpacing` | `0.6` | Lower 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 2 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.

### Lines the Breaker Cannot Fill

Sometimes no break set stays within the bounds: a long URL breaks only at its own joints, or a list item's last word will not come up and every hyphenation would leave a runt. Knuth-Plass then accepts the least bad line, whose few word spaces would have to stretch to several times their width. Instead of stretching them, the engine sets such a line **ragged** — it is drawn at its natural spacing and the right edge gives, like a paragraph's last line. The threshold is 3× the normal space width, the same one the sandbox's loose-line warning uses, so a line past it is fixed rather than flagged. Lines within the threshold are left as measured.

### 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`:

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `looseLineHighlight.enabled` | `boolean` | `false` | Whether to highlight loose lines. |
| `looseLineHighlight.color` | `ColorValue` | `#ff000040` | Color of the highlight overlay. The default is a semi-transparent red. |
| `looseLineHighlight.threshold` | `number` | `3` | Multiplier of the normal space width above which a line is considered loose. Lower values catch more lines; higher values highlight only the worst offenders. |

```ts
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.

| Cause | Solution |
| --- | --- |
| Column is too narrow for the font size | Increase column width, decrease font size, or switch to a single-column layout. |
| Long words with few hyphenation points | Verify hyphenation is enabled and the correct locale is set. Some technical terms or proper nouns have no valid break points. |
| Hyphenation is disabled | Enable `bodyText.hyphenation.enabled`. Justification without hyphenation is almost always worse. |
| Word spacing bounds are too tight | Increase `maxWordSpacing` slightly (e.g., from 2 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, paragraphs of the column are re-broken one line longer — TeX's `\looseness=+1` — up to `maxLooseParagraphs` of them (two by default), one extra line each. 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 paragraphs 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.

   When word spacing alone cannot gain the line, the paragraph may also take a little positive **tracking** — the compositor's classic fix. The engine tries the smallest amount first (half of `maxTracking`, then `maxTracking`, in thousandths of an em per character; 10 = 0.01 em by default) and keeps the first that gains the line, still under the same word-spacing gate. The tracking is measured into the paragraph's lines and painted by every backend (canvas `letterSpacing`, CSS `letter-spacing`, PDF character spacing). Turn it off with `trackParagraphs: false`.

### 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 8 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, at any tracking, 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.

```js
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
      maxLooseParagraphs: 2,    // loose paragraphs per short column
      trackParagraphs: true,    // let a loose paragraph take a little tracking
      maxTracking: 10,          // ‰ em per character (0.01 em)
    },
  },
});
```

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](/en/docs/configuration#column-balancing) page for the full option reference.

## Complete Example

A full configuration showcasing all hyphenation and justification settings:

```ts
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: 2,     // spaces stretch up to 200%
    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](/en/docs/configuration#body-text) page. For how the layout pipeline uses these settings during text measurement, see the [Architecture](/en/docs/architecture) page.
