Repository navigation
Hyphenation and Justification
How Postext breaks lines, justifies text, and controls word spacing using the Knuth-Plass algorithm
Generated from docs/justification-en.mdx (updated 2026-09-29). Read it on postext.dev, where it is also available in Spanish, Catalan, Portuguese, French, German, Italian, Chinese, Japanese and Arabic. To change this page, edit the MDX and run pnpm wiki in apps/web.
This page explains how Postext spreads words along each line. When both edges of a paragraph are straight, the gaps between words can grow too wide and look bad. Postext looks at the whole paragraph before it decides where each line ends, so the gaps stay even. It can also split long words with a hyphen in the right place. You can set how wide the gaps may grow and mark the lines that still look loose. The page also shows how Postext makes columns end at the same height.
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.
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.
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.
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.
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:
-
Adjustment ratio (r): how much the glue on this line needs to stretch or shrink.
r = 0means the line fits perfectly.r > 0means stretching (loose).r < 0means shrinking (tight). -
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 withr = 2has badness 800; a line withr = 0.5has badness 12. -
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. -
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.
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
orphanMinLineslines at the top of the next column. DefaultorphanPenaltyis1000. -
Widow penalty applies when the split would leave fewer than
widowMinLineslines at the bottom of the current column. DefaultwidowPenaltyis1000. -
Runt penalty applies when the paragraph's final line would be shorter than roughly
runtMinCharacters × normalSpaceWidthpixels — a count of word spaces, not letters: the default 20 is a last line of roughly 8 to 12 letters. DefaultruntPenaltyis1000, the same for every runt; withgradedRuntPenaltyit is scaled by the line's shortfall,runtPenalty × (1 − width / threshold), so a two-word ending costs less than a one-word one and wins when a line above can give a word up. 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. The shorter setting has to respect maxWordSpacing too. Fitting the words into one line fewer tightens most lines, but it can move a break so that another line has to stretch far. A setting that would stretch a line past maxWordSpacing or, when the paragraph already has a looser line, past that line, is no fix, and the runt stays. Only lines that stay justified count: a line the paragraph sets ragged because it would stretch past 3× the normal space (see Lines the Breaker Cannot Fill) does not raise the bar, and the shorter setting may not set more lines ragged than the paragraph had. Nor does the fix trade a ragged line for justified lines past that bar: the paragraph keeps its ragged line and its runt. 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.
A block set shorter this way says so: VDTBlock.runtFix holds { tracking }, the negative tracking used in thousandths of an em, 0 when the word spaces alone carried the line. No renderer reads it; it is there for whoever fits the copy, since a paragraph at the cap reads tight beside its neighbours and a few words less would have set it without tracking.
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). Those rules keep the lines of one item together; shortListSplit keeps the items of a short list together (off by default, see Body text).
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 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/pretextfor 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 width every space of the line is painted at, divided by the space of the regular face. A line's spaces are all painted alike, whatever face each comes from. The limits bound something a little different, the change of each space from its own width, counted in regular spaces, and an italic or a bold space is narrower or wider than the regular one. So a line that holds one is painted a little under minWordSpacing or over maxWordSpacing though it was broken within them, and it also carries justifiedSpaceLimitRatio, the number the limits bound: 1 plus the line's slack over its spaces, each at its own width. lineWordSpacing(line) returns it, or justifiedSpaceRatio on a line whose spaces are all regular, where the two are one number. It feeds the loose-line debug system described below; the layout itself (the ragged fallback, the runt fix, the loose-paragraph lever) goes on reading the painted ratio. The ratios are 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. When the faces of a family arrive or leave, the engine drops what it measured in that family, in its width caches and in the measurement cache a host keeps, so measurements stay accurate after web fonts finish loading without the host clearing anything (see Loading fonts before layout). 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 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.
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.
| 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. A region-tagged locale uses its language's patterns ('es-ES' hyphenates with 'es', 'en-GB' with 'en-us'). A language with no patterns here falls back to en-us, and the engine warns once on the console (see Hyphenation in the configuration reference). Chinese, Japanese and Korean are the exception: they are set without hyphenation and without the warning, and their lines break between characters (see Chinese, Japanese and Korean below).
Catalan follows the rules of the Institut d'Estudis Catalans (Llibre d'estil, chapter VI). This applies since postext 1.14:
- A break leaves at least two letters on each side (
ter-ra,cai-xa,plu-ja). - The ela geminada breaks between its two l, and the hyphen takes the place of the middle dot: il·lusió becomes
il-|lusió. The line is measured without the dot, so justification and Knuth–Plass see its real width. - Two vowels in hiatus stay together (
cièn-cia,ca-mions). An i or u between vowels counts as a consonant and opens its syllable (fe-ia,ve-ient). - No break follows an apostrophe (
s'ha-via, nevers'-havia). - Common prefixed words break at the prefix (
vos-altres,cel-obert,ben-estar). - To keep words with enclitic pronouns whole except at their own hyphen (
portar-|lo), as the IEC prefers, sethyphenation.compoundstofalse.
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.
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.
A hyphen on the last line of a column or a page sends the reader to the next column mid-word. bodyText.hyphenateAcrossColumns: false keeps it off those lines: when the line that closes a column ends on a hyphen, the paragraph is broken again with a hyphen there priced like a runt, so the breaker ends that line on a whole word whenever the word spaces of the lines above can take the difference within maxWordSpacing and minWordSpacing. Where they cannot, the hyphen stays. It covers the first column break of a paragraph, and the later ones that fall where a full column ends. A later break that falls elsewhere — a column cut short to keep a widow off it, a closing band cut level — gets a second re-break from that column: the lines already set in the columns before keep their breaks, and only the rest of the paragraph is broken again. So a hyphen stays only where no setting within the limits avoids it, in practice at a paragraph's first column break. hyphenateAcrossColumns: 'withinPage' keeps the guard for the lines that close a page: the reader who turns the page, or crosses to the facing one, meets a whole word, while a word divided between two columns of the same page is let be. In three columns that leaves two column ends in three alone, so fewer paragraphs are broken again and fewer lines take wider or narrower spaces to pay for it. The breaker tells apart the ways of reaching a line only up to the last line it guards, so a retry costs about as much as the first measure: one more measure for every paragraph it breaks again.
By default, hyphenation is only applied when both bodyText.hyphenation.enabled is true and bodyText.textAlign is 'justify': a ragged edge is meant to vary. Ragged text can be hyphenated too, with bodyText.hyphenation.ragged: true. A ragged line has no word spacing to even out, so a hyphenation zone decides instead: a word that does not fit is divided only when sending it whole to the next line would leave a gap wider than hyphenation.zone (3 em by default). Knuth-Plass sets ragged running text too (bodyText.optimalRagged, on by default): its word spaces keep their width, each line pays for how far it falls short of the measure, and the zone decides which syllables it may take; two syllable ends in a row cost the consecutive-hyphen demerit. Set line by line (optimalRagged: false), no more than two lines in a row end on a syllable. See Ragged text.
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. Knuth-Plass takes it in every paragraph with bodyText.breakAfterHyphens (on by default), priced like a syllable; in a justified paragraph without inline formatting, only with two letters on each side of the hyphen, so no line ends on the e- of e-mail. With it off, as up to postext 1.4, a justified paragraph without inline formatting does not break right after such a hyphen, while the same paragraph with one italic word anywhere does; books saved before postext 1.5 read with it off. The line ends on the text's own hyphen, which the line records as hardHyphen beside hyphenated, so a reader that drops an added hyphen (the running heads, the Sandbox's caret) keeps this one. An em or en dash set closed between words — say—that’s, riddles.—I, Hamburg–Berlin — is a place the line may end too, on both breakers, with bodyText.breakAfterDashes (on by default): the line ends on the dash, nothing is added and nothing is charged. So is a dash that ends a run of one style before a word in another (see—*and*). The line is hyphenated then, without hardHyphen. Never after a dash that opens an aside or a line of dialogue (—dijo, said "—Hola; a quote that closes a word may stand before the dash, as in "no"—and or German „nein“—und), before punctuation, before a quotation mark or a bracket (thinking—" and, says—“no”), inside a run of dashes or inside a range of numbers (1914–1918). 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; a cut next to a hyphen the word carries falls after that hyphen and adds none, so the word never shows two. The rest of the word keeps its own break points: a compound goes on breaking at its hyphens and a web address at its joints, where up to postext 1.4 the rest was divided at the dictionary's syllables. A paragraph without inline formatting is cut at the last character that fits, without a hyphen, unless it is hyphenated. No set of breaks around such a word is feasible for Knuth-Plass, so a justified paragraph that holds one is set line by line instead.
A compound joined by a hyphen (after-dinner, teórico-práctico) can break in two kinds of place: after its own hyphen, and at the dictionary's syllables of either part (af-ter-dinner). TeX, and the Chicago Manual of Style, keep the parts whole and break a compound only at its hyphen. bodyText.hyphenation.compounds: false does the same: the dictionary leaves every word with a hyphen between two letters alone, so it breaks only after that hyphen. A soft hyphen typed between two letters of the word still breaks, and a compound wider than the whole line is still divided where it must be. The default, true, keeps dividing the parts, which gives a justified narrow column more ways to fill its lines.
Portuguese spelling asks for the hyphen of a compound broken at the end of a line to be repeated at the start of the next one (vencer- · -se), and so do the Spanish Academy's rules since 2010 (léxico- · -semántico), so the reader knows the hyphen belongs to the word. bodyText.repeatHyphen: true does it. The repeated hyphen is measured with its line, so the breaker leaves room for it, and it is painted like the rest of the text; in the PDF it carries a replacement text that leaves it out, so text copied or extracted from the PDF reads the word once (vencer-se). The line records it as repeatedHyphen, and its plainStart and sourceStart point past it, so the source mapping, links and running heads read the word as written. A web address never gets one. It applies to the running text, headings, lists, blockquotes and boxes; a paragraph without formatting that holds a compound is then broken by the breaker that sets formatted text.
The dictionary can be told to leave words alone. bodyText.hyphenation.exceptions names them one by one: 'Alice' is never divided, 'You-Tube' only at the hyphen of its entry, as in TeX's \hyphenation{…}. bodyText.hyphenation.capitalized: false keeps every word that starts with a capital whole, and citations.hyphenateNames: false the family names of the authors the document cites. The breaker then has fewer places to end a line: where a name stood divided, the line before takes more or less space between its words instead, within minWordSpacing and maxWordSpacing. A word wider than its whole measure is still divided. See Words kept whole for how an entry is matched.
Some places are never break opportunities, on either breaker:
- A no-break space. U+00A0, the narrow no-break space U+202F and the figure space U+2007 glue the words on either side: a number and its unit, a page reference, a group of thousands. The same holds in captions, table cells, boxes and design text (running heads, openers). Each keeps its own width, as it is measured; justification stretches the word spaces only. A face with no glyph for U+202F or U+2007 gets the width browsers give them, half a word space and a digit, in every output, the PDF included; practically every face has U+00A0. A group glued this way that is wider than the whole line is the exception, with or without inline formatting: it breaks at its last no-break space, rather than being cut inside a word. The word joiner U+2060 glues without taking any room, in Chinese text too: two characters with one between them stay on one line. (A circled number such as ① needs none: Chinese text keeps it with the character after it since postext 1.25.) So does U+FEFF, the zero-width no-break space, though the word joiner is the character meant for it.
-
A reference label with a number. An inline reference that prints a number or a page (
section 3.2,p. 112,Fig. 1.7,Theorem 2) is one word: no line breaks between its word and its number, whatever space the template holds, and its space does not stretch. A reference that prints words (an anchor's text, a title, atext="…"override) is different since postext 1.28: it breaks at its own spaces, which stretch with the line (crossRefs.breakLabels); its words are still never hyphenated. -
Text that touches. A bold or italic word and the punctuation after it (
**osmosis**.), the brackets around an inline reference ((:ref{id="fig-3"})), a word set in two runs: text with no space in between is one unit, as if it were one word. When such a unit does not fit what is left of a line, it goes down whole. When it opens a line and still does not fit, its last word is hyphenated at a syllable so the punctuation goes down with the word's tail; only a unit with no syllable to break at leaves its punctuation to open the next line. -
At a soft hyphen that has no letter on one side. A soft hyphen (U+00AD) typed in the text is a break only between two letters or digits: typed between
toandgether, it lets a line end onto-. Typed at the edge of a word, after an opening bracket or quotation mark, or before closing punctuation, it breaks nothing and prints nothing, so no line ends on a hyphen after a whole word. And since the dictionary leaves alone a word that already carries a soft hyphen, one typed before the first letter keeps that occurrence of the word whole, in every language: with one before itsA,Aliceis never divided. InDesign writes its discretionary hyphen that way, and text exported from it keeps the meaning. Up to postext 1.27 such a soft hyphen was a break like any other (said Alice-), and under Catalan the rest of the word was still divided. -
Inside a word of a joining script. An Arabic word (or a Syriac, N'Ko or Mongolian one) is never hyphenated, not even at a soft hyphen typed in it, and never cut between its letters: its letters join, and a piece set alone would take other letter forms and another width. Arabic and the other right-to-left languages default to hyphenation off; with it on (for the Latin words of an Arabic book, or in an English book that quotes Arabic), the dictionary skips such words. A word set in two styles (
كتا**ب**) stays one word, measured whole. One wider than the whole line runs past the measure, and the build reports anunbreakableWordOverflowcontent warning.
Up to postext 1.4, a paragraph with inline formatting or a :ref could break at a no-break space, and a ragged line could end on the ( before a reference or open with the full stop after a bold word.
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 |
2 |
Upper bound for word spacing, as a multiplier of the normal space width. At the default value, spaces can stretch up to 200% 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 (2 / 0.6), if the normal space width is 4 px:
- Each space can stretch by 4 px (from 4 px to 8 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.
Knuth-Plass does not stretch a line past maxWordSpacing when it has a choice, and neither does the runt fix (see Orphan, widow, and runt penalties). A line beyond the bound costs more than any other defect it weighs — a hyphen, a short last line, a line a little looser than its neighbours — from the moment it passes the bound, and the cost then grows with the square of its stretch ratio, so the breaker hyphenates a word, spreads the slack over the lines around it, or sets two lines slightly loose rather than one far too loose.
Sometimes no break set stays within the bounds. The line that cannot take the next word has too few spaces to share the slack: the first line under a paragraph indent, followed by a word too long to come up and too short to hyphenate; a line of long unbreakable terms; a long URL that breaks only at its own joints; a list item whose last word will not come up while every hyphenation would leave a runt. How often it happens depends on the measure, the face and the language. In an 83 mm column of English at 9.3 pt, 2 % to 8 % of the lines of a chapter went past maxWordSpacing, depending on the face (the widest the most); the same chapter in Spanish, whose hyphenation offers many more breaks, had none. Knuth-Plass then accepts the least bad line, and its spaces stretch past the bound. When they would stretch to more than 3× the normal space width, the engine sets the line ragged instead — it is drawn at its natural spacing and the right edge gives, like a paragraph's last line. The threshold is the same one the sandbox's loose-line warning uses. The line is set ragged and reported: a raggedLine content warning carries its text, its page and the ratio it would have needed (in the Sandbox, Justified line set ragged, among the typesetting warnings; a click selects the line), and VDTLine.raggedSpaceRatio keeps the ratio on the line. Reword the copy or give the line a place to break. Lines within the threshold are left as measured. The same holds inside callout boxes, whose narrow measures are where it happens most; up to postext 1.4 a box kept such a line justified however far its spaces stretched.
To get fewer of these lines, in this order: check that hyphenation is on and set to the language of the text; widen the measure or reduce the text size; let the lines take a little tracking (next section); or raise maxWordSpacing, which lets the same lines count as within the bound without setting them any differently.
bodyText.maxJustifyTracking lets such a line take a little tracking — letter spacing — instead of more word space, up to that many thousandths of an em a character (10 = 0.01 em, the InDesign unit). It works both ways: a line whose spaces would stretch past maxWordSpacing spreads its letters until its spaces are back at the bound, and a line that would fit only with its spaces below minWordSpacing tightens its letters instead of sending a word down. Knuth-Plass weighs it when it chooses the breaks: a tracked line costs a little more than one within the bounds and much less than one past them, so tracking is taken only where word spacing alone would fail. Lines within the bounds, a paragraph's last line (unless it runs over), a line of one word and a line holding a chip never take any. The line records it as VDTLine.letterSpacing, on top of its block's own tracking, and the canvas, HTML and PDF paint it. Words of a joining script (Arabic…) never take tracking, this one or any other (a style's letterSpacing, column balancing, the runt fix): spacing their letters apart breaks the joins. A line counts none of their letters, and the renderers paint them untracked; a style that sets letterSpacing on such text gets a joiningScriptLetterSpacing content warning.
bodyText: {
maxWordSpacing: 2,
maxJustifyTracking: 10, // at most 0.01 em a letter, either way
}It is off by default, so documents keep their lines unless they ask for it. In the English chapter above, 10‰ brought the lines past maxWordSpacing down to one or none per face, and 20‰ to none in three faces out of four. Keep it small: past 20‰ or so the tracking starts to show as a lighter or darker line.
A justified line of Arabic is not letter-spaced: its letters join, and spacing them apart breaks the joins. It is stretched in two places instead, its word spaces and its kashidas, the elongated joins a calligrapher draws between two connected letters (كتاب → كتـاب). Postext inserts them as tatweels (U+0640, ـ), whole ones, which the font draws as one stroke; Amiri turns a run of one to seven into a curved kashida.
bodyText.kashida is 'auto' by default in a document whose language is written in the Arabic script (ar, fa, ur…) and 'none' in any other; set 'auto' to elongate the Arabic words quoted in another language's book. Each line but a paragraph's last gets its word spaces opened up to a quarter of their width first, then kashidas for the rest of its slack, one tatweel at a time at the best places first and round after round, each word measured again whole with its tatweels; what is left, less than one tatweel, goes back to the spaces. Knuth-Plass counts each word's possible elongation as stretch, so it chooses the breaks knowing a line of Arabic words can widen there.
Where a kashida may go comes from the rules of raqim-kashida (MIT): only between two letters that join, never after a letter that does not join onward (ا د ذ ر ز و ة), never at the end of a word, never inside lām-alif, after the marks of the letter before it. kashidaPatterns: 'naskh' follows the classical Naskh rules (Benatia's matrix with Afifi's prohibitions: nothing after kāf or lām, nothing before ṣād, ʿayn or wāw, a final pronoun hāʾ stretched just before it); 'simple' the Microsoft priorities for simple modern faces; 'nastaliq' the Naskh rules tailored for Nastaʿlīq. The default 'auto' reads the body font: no kashida in a Ruqʿa or Dīwānī face such as Aref Ruqaa, Nastaʿlīq rules in a Nastaʿlīq face, Naskh otherwise. A word takes kashidaPerWord elongations (1 by default) at most kashidaMaxLength em long (0.6, three tatweels of Amiri). A word that holds a tatweel the author typed is elongated there. Latin words, digits, headings, ragged lines and a paragraph's last line never take any.
bodyText: {
kashida: 'auto', // the default in an Arabic book
kashidaPatterns: 'naskh', // 'auto' picks it from the font
kashidaMaxLength: 0.6, // em
}The tatweels are part of the painted text (each segment lists them in VDTLineSegment.kashida, and the line counts them in VDTLine.kashida); the plain text, the source map, links and the text copied from the PDF leave them out.
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.
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. Ragged running text takes it too withbodyText.optimalRagged(on by default): the word spaces keep their width and the breaker weighs how far each line falls short of the measure instead, a line 3 em short costing what a justified line atmaxWordSpacingdoes, so the ragged edge comes out more even and the runt rules apply. -
false: Greedy first-fit, powered by Pretext'slayoutNextLine(). 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.
A paragraph that holds a tab (see Document format › Tabs and tab stops) is set line by line, as word processors and InDesign set tabbed text, never with Knuth–Plass, whatever optimalLineBreaking says. Where a tab lands depends on where its line has got to, and the optimal search adds up the widths of each candidate line as if they did not. The same holds for Chinese and Japanese text: a paragraph with a tab is broken between characters under the document's line-breaking rules, without punctuation compression or justification between characters.
In a justified paragraph, a line that holds a tab is not stretched before its last tab, since the stops fix where each part of it starts. Only the word spaces after the last tab take the slack, and only when the line is not the paragraph's last; a line with no word space after its last tab is set as it falls. Ragged lines are not affected. The line records that it holds a tab (VDTLine.tabbed); each tab is a space segment that carries its stop (VDTLineSegment.tab), and each leader a segment of its own (VDTLineSegment.leader, 'text' or 'rule').
Column balancing never re-breaks such a paragraph looser and never tracks it: it stays as measured, so its stops do not move.
A paragraph that holds more CJK characters than word spaces is set by the CJK composer, not by Knuth-Plass. Every gap between two characters is a place a line may break (except where the line-break rules of cjk.lineBreak forbid it), so there is nothing for an optimal search to weigh; the lines are filled one after the other. When a character does not fit, the line first tries to take it in by giving up punctuation blank and space (push-in: word spaces down to a quarter em, then interpuncts, brackets, pause marks, Han–Latin spaces down to an eighth of an em, and stop marks, as cjk.punctuationWidth allows); only when that is not enough does a character that may not open a line take the one before it down with it. See East Asian typography for the rules. Chinese layout covers the rest of Chinese composition: punctuation widths by region, the character grid and vertical text. A Japanese document follows JLReq instead (see Japanese layout): its kinsoku levels, a line that gives space back in the order of JLReq §3.8.3 (the half em after the mark ending the line first, all or nothing, the space after 。 never inside the line), and no space added after an opening bracket, before a closing one or next to 、。・:;?! when a line is spread.
A justified line of such a paragraph, except its last, is spread to the measure in the order clreq sets (§6.2.2.4):
- The spaces between Western words, equally, up to half an em each.
- The spaces between Han and Latin (
cjk.latinSpacing), equally, up to half an em each. - Every gap between characters, equally, the Han–Latin spaces too: between Han characters, between Han and punctuation, between Han and a Latin word. Never inside a Latin word, a number or a two-em dash or ellipsis, and never next to a connector (~, –, a single —) or a solidus. A mark hung past the end of the line (
cjk.hangingPunctuation) stays out of the measure.
The last line of a paragraph is set solid. The spacing is recorded per segment of the line (VDTLineSegment.tracking), so the canvas, HTML and PDF paint the same positions, and the sandbox places the cursor on the character clicked.
A line that would need more than half an em between its characters — or more than bodyText.maxJustifyTracking, when that is set — is set with that much, short of the measure, and reported as a cjkLooseLine content warning (in the Sandbox, CJK line set short). The usual cause is a long Latin word or web address that could not come up to it. A line with no CJK character on it, such as the head of a long web address, is set ragged without the warning.
For the loose-line highlight, a CJK line counts as loose by its spacing: its ratio is 1 plus its spacing in eighths of an em, so at the default threshold of 3 a line whose characters are spread by more than a quarter of an em is flagged (lineLooseness(line, fontSizePx) gives the ratio). Column balancing can run a CJK paragraph one line longer: it breaks the lines a little short of the measure, an eighth of an em at a time up to two ems, and spreads them back to it. On a character grid it may spread them no more than headings.balancing.maxTracking between two characters, so the characters keep their cells (see Vertical Justification: Column Balancing).
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.
Every justified line in the Virtual Document Tree carries a justifiedSpaceRatio: the width its spaces are painted at over the regular face's space. A value of 1.0 means the regular space; 2.5 means spaces 2.5 times as wide. A line with spaces from another face (an italic phrase, a bold word) carries a second number, justifiedSpaceLimitRatio, its word spacing as minWordSpacing and maxWordSpacing bound it. lineWordSpacing(line) gives the number to compare with the limits: the second when the line has one, else the first.
When debug.looseLineHighlight.enabled is true, the Sandbox paints a semi-transparent overlay over its Canvas preview on every line whose word spacing (lineWordSpacing) exceeds the configured threshold. A line set right at maxWordSpacing that holds a bold space is painted a few hundredths of a space past it and is not counted: the breaker kept the limit. 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. It is also the width past which the engine sets a justified line ragged instead of stretching it (see Lines the Breaker Cannot Fill), so at the default the highlight and the looseLines warning find almost nothing in running text: such a line is set ragged and reported as a raggedLine warning instead. Lower the threshold, to 1.5 or 2, to find the lines that are loose but still justified.
The overlay is not part of the page, so exported pages never show it. To draw it on a canvas of your own, call drawLooseLines(ctx, page, doc, { threshold }) right after renderPageToCanvas; findLooseLines(doc, { threshold }) returns the same lines as data (see Loose lines in your own canvas).
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. At 3 or above, running text shows next to nothing, since lines past 3× are set ragged. |
debug: {
looseLineHighlight: {
enabled: true,
threshold: 2.5,
color: { hex: '#ff660040', model: 'hex' },
},
}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. |
| A few lines stay loose whatever the face | Let those lines take a little tracking: bodyText.maxJustifyTracking: 10. See Tracking as a last resort. |
| 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. |
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, except in vertical text and on a character grid), the engine closes those gaps the way a human compositor would, applying its levers in strict editorial priority order — each lever only acts on what the previous one could not absorb:
-
A box closing the column. A callout box that ends a short column moves down by the exact room under its foot, so its bottom edge meets the last grid line, level with the column beside it; the room may be a fraction of a line. It runs first and takes the whole gap unless
closingBoxsays otherwise:'last'lets the levers below take the whole lines first and gives the box only what they leave, so a box that annotates the paragraph above it stays close to it;'off'never moves the box. -
Pictures with a safe area. A picture whose resource marks a safe area (
Resource.safeArea, the part that must always show; see Document format › Safe area) grows by whole grid lines when it is set inline in the short column, or floated at its head or its foot over that column alone: the engine crops what lies outside the safe area to make it taller, at its full width. A taller picture leaves no hole anywhere on the page, so it goes before any space is added. A float heading a column on a closing page or band, whose column heads stay level, does not grow. -
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
h2and anh3become +2 above theh2and +1 above theh3. 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. A heading takes at mostmaxLinesBeforeLooselines this way (two by default) before the levers below are tried, and its further lines, up tomaxLinesPerHeading, only when they have nothing left to give. -
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). Then a grid line under a display formula or a box the text resumes after (stretchAfterDisplays), and one under a figure or table that heads the column (stretchAfterFloats,maxLinesAfterFloat). The text under the figure may be the rest of a paragraph begun on the page before; it moves down all the same, by the line the break rules left free at the foot of the column (a line the widow rule kept empty, a paragraph space with no room for text after it). Under a figure across several columns the line is added only when every column under the figure takes it, so the heads stay level, or when it ends its column flush (stretchAfterWideFloats). -
Loose paragraphs. As a last resort, paragraphs of the column are re-broken one line longer — TeX's
\looseness=+1— up tomaxLooseParagraphsof 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 belowmaxWordSpacing: 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. RequiresoptimalLineBreaking(the default); paragraphs split across columns are excluded. A paragraph the runt fix set a line shorter counts its extra line from that setting: it may win back the line the fix took out, but only at a setting with no runt and no line pastmaxWordSpacing(up to postext 1.4 it could come back with a line far past the limit).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, thenmaxTracking, 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 (canvasletterSpacing, CSSletter-spacing, PDF character spacing). Turn it off withtrackParagraphs: false.
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.
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'sbreakBefore, 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.
- A paragraph that holds a tab is never loosened or tracked (see Tabbed lines).
const doc = buildDocument(content, {
headings: {
balancing: {
enabled: true, // default
maxLinesPerHeading: 4, // cap per heading
maxLinesBeforeLoose: 2, // lines before the other levers
stretchAfterLists: true, // lever 4
maxLinesAfterList: 1, // cap per list end
stretchAfterDisplays: true, // under a formula or a box
looseParagraphs: true, // lever 5 — 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)
closingBox: 'first', // lever 1: 'first' | 'last' | 'off'
},
},
});In the Sandbox these live in the Headings section of Design → Headings & contents ("Balance Columns", with "Stretch After Lists", "Stretch Under Figures", "Box Closing a Column" and "Loose Paragraphs" nested under it). Turn on the baseline grid (Design → Advanced → On-screen aids) and look at 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.
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: 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 page. For how the layout pipeline uses these settings during text measurement, see the Architecture page.
Postext is MIT-licensed. Maintained by Ignacio Ferro and community contributors. See the live documentation for the most up-to-date reference.
Overview
Writing
- Document Format
- Hyphenation and Justification
- Chinese Layout
- Arabic Layout
- Japanese Layout
- Comics
- Composed Pages
- Sandbox
- Agent Skill
- Command Line
- WebMCP
Configuration
- Overview and index
- Page and layout
- Headers and footers
- Body text
- Hyphenation and language
- Headings
- Widows, orphans and balancing
- Lists and maths
- Notes and references
- East Asian typography
- Resources
- Styles and parts
- Callout boxes
- Comics
- Composed pages
- Fonts, colours, viewers
Code
Internals
External