A paragraph is not a sequence of lines. It is a single object that has been cut, and the cuts are not independent. Every typesetter knows this in the hand; Knuth was the one who wrote it down as an objective function.
The browser breaks lines greedily. It fills the current line until the next word will not fit, then it breaks, and it never reconsiders.2 This is true of every major engine. text-wrap: pretty softens it by reconsidering the last few lines, but the algorithm underneath is still fundamentally local. This is fast, it is simple, and it is why justified text on the web has rivers running down it β a greedy choice early in the paragraph forces a terrible one later, and nothing goes back to fix it.
Badness as an objective#
Assign each line a badness measuring how far its spaces are stretched or compressed from natural width. If a line needs ratio r of adjustment,
so that mild adjustment is nearly free and severe adjustment is punished sharply. The total cost of a paragraph is then the sum over its lines, plus penalties for hyphenation and for adjacent lines whose looseness differs visibly:
Minimising EquationΒ 6 over all legal break sequences is what TeX does, and what Typst does. The crucial observation is that it is not exponential: the optimal break at position j depends only on the best cost of reaching each feasible predecessor, so it falls to dynamic programming in O(nw) where w bounds the number of feasible breaks per line.
The shape of the fix#
The algorithm is small enough to state completely:
fn optimal_breaks(items: &[Item], widths: &LineWidths) -> Vec<usize> {
// active nodes: feasible breakpoints still under consideration
let mut active = vec![Node::start()];
let mut best: Vec<Option<Node>> = vec![None; items.len()];
for (i, item) in items.iter().enumerate() {
if !item.is_legal_break() { continue; }
for node in &active {
let r = adjustment_ratio(node, i, widths);
if r < -1.0 { continue; } // line cannot compress this far
let cost = node.total + badness(r) + item.penalty();
if best[i].map_or(true, |b| cost < b.total) {
best[i] = Some(Node { total: cost, prev: node.index });
}
}
active.retain(|n| still_feasible(n, i, widths));
if let Some(n) = best[i] { active.push(n); }
}
trace_back(&best)
}The pruning in active.retain is what keeps it linear in practice: once a breakpoint can no longer reach the current position without over-stretching, it is dead and never revives.
FigureΒ 1 is where a real CeTZ drawing goes; the build renders it to inline SVG with currentColor strokes so it inverts with the theme rather than sitting in a bright rectangle on a dark page.
What this costs on a phone#
Running the full optimisation client-side is only worth it if it is imperceptible. Measured on a mid-range phone, a 900-word article breaks in under 40 ms, and only paragraphs actually in the viewport are processed on first paint.3 The remainder are done during idle time, and re-run on resize behind a debounce. If the font has not loaded, the measurement would be wrong, so it waits on document.fonts.ready before touching anything. Below roughly a 40-character measure the question is moot: a narrow column cannot be justified well by any algorithm, so the stylesheet rags it right instead of pretending.