Knuth-Plass Justified Text on the Web

Achieving professional-grade justified text in the browser using Knuth-Plass line breaking via tex-linebreak, canvas-based measurement via Pretext, and CSS typographic levers.

TL;DR

1. The Problem

Browser-native CSS text-align: justify uses a greedy, line-by-line algorithm: it fills each line independently without considering how that choice affects subsequent lines. This produces visibly uneven word spacing, especially at narrow column widths where the greedy approach has few good options.

The Knuth-Plass algorithm (from “Breaking Paragraphs into Lines,” Knuth & Plass, 1981) solves this via global optimization: it evaluates all possible sets of line breaks simultaneously and picks the combination that minimizes total “demerits,” a penalty function of spacing deviation. The result is dramatically more even word spacing.

The reason this hasn’t happened on the web is cost. Knuth-Plass requires measuring every word in a paragraph before line-breaking can begin, and on the web, the standard way to measure text is through the DOM, which triggers synchronous layout reflow. That cost, combined with running the optimization itself in JavaScript, has kept browser justification stuck with the greedy algorithm.

2. Architecture

This project builds on two existing open-source libraries, tex-linebreak for line-breaking and Pretext for measurement, and adds a CSS rendering layer that translates optimal breakpoints into professionally justified HTML. The three components form a pipeline:

  1. tex-linebreak (@robertknight/tex-linebreak)—a faithful JavaScript implementation of the Knuth-Plass algorithm by Robert Knight. It models text as a sequence of box (word), glue (space), and penalty (hyphenation) items, each with pre-measured widths and stretch/shrink tolerances. Given these items and a target line width, it evaluates all possible sets of breakpoints and returns the combination that minimizes total demerits.
  2. Pretext (@chenglou/pretext)—a canvas-based text measurement library that solves the cost problem described above. Instead of inserting text into the DOM and triggering layout reflow, Pretext segments text via Intl.Segmenter and measures each segment via canvas.measureText(), caching the results. This avoids synchronous reflow entirely, producing widths that agree with the DOM to within ~0.2px (~0.04% discrepancy), accurate enough for pixel-perfect justification.
  3. CSS rendering—the rendering layer that takes Knuth-Plass breakpoints and produces justified HTML. Each line becomes a <span> with a computed word-spacing value. Optionally, the renderer applies two additional CSS adjustments, glyph scaling (via font-variation-settings: "wdth" or transform: scaleX()) and per-line letter-spacing, to further improve spacing evenness. The integration of these CSS typographic levers into the Knuth-Plass optimization is this project’s primary contribution.

3. CSS Typographic Levers

Professional typesetting systems like Adobe InDesign and TeX’s microtype package use multiple levers beyond word spacing to achieve even justification. Word spacing is already integral to the Knuth-Plass algorithm: it’s the “glue” between words whose stretch and shrink capacity the optimizer adjusts. This project’s contribution is adding two more levers, glyph scaling and letter spacing, as additional stretch/shrink capacity fed into the optimizer, following industry best practices for acceptable value ranges.

The three levers:

  1. Word spacing—the primary lever, built into tex-linebreak’s glue model. The professional consensus on acceptable variation is roughly −0.05em to +0.08em around the font’s natural space width (Bringhurst, Adobe InDesign defaults, and the Pangram Pangram foundry recipe all converge on this range). This project uses the Pangram Pangram recipe: 85–130% of natural space width.
  2. Glyph scaling—horizontal scaling of glyphs at ±2%. Bringhurst recommends ≤2% glyph scaling as the permissible limit for justification, and Adobe, pdfTeX/microtype, and most practitioners converge on the same ±2–3% range. At this scale, the adjustment is invisible to readers but provides significant flex for the optimizer. This project supports two CSS implementations: the clean approach uses a variable font’s wdth axis (font-variation-settings: "wdth"), which scales at the outline level and preserves stroke weight; the fallback uses transform: scaleX(), which works with any font but distorts stroke weight. The variable font approach was inspired by Bram Stein’s work on linebreaking with variable fonts and finaltype.de’s “Better Justification for the Web”.
  3. Letter spacingletter-spacing at ±0.0075em. Bringhurst recommends ≤3% as the permissible limit for justification use. The modern professional consensus (Pangram Pangram, Speakipedia) settles on ±0.005–0.0075em per line, which this project adopts.

Without glyph scaling and letter spacing, the optimizer’s only flexibility is word spacing, and when word spacing alone cannot achieve a good adjustment ratio, the result is lines with visibly uneven gaps. With all three levers enabled, each word gains additional stretch/shrink capacity, allowing the optimizer to distribute adjustments across many small, imperceptible changes rather than concentrating them in word gaps. The quality comparison in Section 5 quantifies this: spacing evenness improves from σ≈11.8% (CSS greedy) to σ≈4.2% (KP + levers), roughly 3× more even word spacing across lines.

DOM Measurement as Correction

DOM correction (measuring the rendered line width via getBoundingClientRect() and distributing the exact remainder into word-spacing) is needed for CSS adjustments because they are applied at render time. These CSS properties change the rendered width from what canvas predicted, so a measure-and-fill pass is required to achieve a flush right edge. Without CSS adjustments, Pretext’s canvas widths are accurate enough for pixel-perfect justification on their own.

To minimize the cost of this correction, the implementation batches all DOM interaction into three phases: first, it creates all line elements and applies CSS (transforms, font-variation-settings, letter-spacing) without measuring anything; then it batch-reads all line widths via getBoundingClientRect() in a single pass, triggering one reflow; finally, it computes and applies per-line word-spacing from the measured remainders. This read-then-write batching avoids layout thrashing: what would otherwise be N separate reflows (one per line) becomes a single reflow for the entire paragraph.

4. Performance Benchmark

Setup

Three Approaches Tested

Performance Results

Phase A: Pretext + KP B: Pretext + KP + CSS C: DOM + KP
Measurement 0.92 ms (canvas) 0.87 ms (canvas) 4.12 ms (DOM)
Knuth-Plass 1.37 ms 1.27 ms 0.92 ms
Render 0.16 ms 2.35 ms (+ DOM corr.) 0.16 ms
Total per re-justify 2.45 ms 4.49 ms 5.21 ms

Performance Budget Context

Budget Threshold A fits? B fits? C fits?
60fps frame (16.7ms) ~10ms for JS Yes Yes Yes
Debounced resize (150ms) ~100ms Yes Yes Yes
Perceived instant (100ms) ~80ms Yes Yes Yes

Key Findings

Pretext provides two distinct benefits:

  1. Speed: Canvas measurement is ~4.5× faster than DOM measurement on the benchmark text (~2,000 words, flat DOM). The gap is primarily DOM’s synchronous layout cost, so it grows with DOM depth and complexity (see Section 6). This makes Approach A (Pretext + KP, no CSS adjustments) the fastest path at 2.45ms, roughly half the time of the next fastest approach.
  2. Accuracy: Pretext’s canvas widths are accurate enough that base justification (word-spacing only) produces a flush right edge without any DOM correction.

For the highest-quality path (Approach B with CSS levers), Pretext still provides the fast initial measurement, but a DOM correction pass is needed to account for CSS-applied adjustments. The correction is not fixing Pretext’s inaccuracy; it’s accounting for the CSS properties that change widths at render time. Section 6 explores whether newer canvas APIs can eliminate this correction pass entirely.

5. Quality Comparison

Spacing Evenness (σ)

The most perceptually honest quality metric is σ, the standard deviation of word-spacing ratios across all lines in a paragraph. Lower σ means more even line-to-line spacing; if every line had the same spacing, σ would be 0.

Approach Typical σ Interpretation
CSS text-align: justify ~11.8% Noticeable line-to-line spacing variation
A: Pretext + KP (word-spacing only) ~6–8% Visibly more even
B: Pretext + KP + CSS levers ~4.2% ~3× more even than CSS, consistently tight spacing

KP with CSS levers produces roughly 3× more even word spacing than the browser’s greedy algorithm. This is a significant, visible improvement. Paragraphs look noticeably more professionally set, without needing to overstate the magnitude.

Demerits (Technical Detail)

Demerits use the TeX formula (1 + badness)² where badness = 100 × |r|³. Because the ratio is cubed then squared, small spacing differences produce enormous demerit ratios. This is useful for comparing optimizer configurations but not proportional to perceptual difference. CSS demerits below are measured from the browser’s actual rendered word-spacing via the Range API.

Width A: Pretext + KP B: + GS/LS/DOM CSS justify A vs CSS B vs CSS
350px ~569 ~6 ~63,342 ~111× ~10,083×
400px ~7,384 ~7 ~9,001 ~1.2× ~1,327×
450px ~347 ~2 ~2,460 ~7× ~1,268×
500px ~43 ~2 ~1,110 ~26× ~751×
550px ~155 ~2 ~482 ~3× ~301×
600px ~13 ~1 ~673 ~50× ~470×

A/B vs CSS = ratio of CSS demerits to KP demerits (higher = KP is better). These large ratios reflect the cubic/squared formula, not proportional perceptual differences.

Key Findings

6. Canvas as a DOM Replacement

DOM correction triggers synchronous reflow, whose cost scales with DOM complexity. Modern canvas APIs (ctx.letterSpacing, ctx.wordSpacing, available in Chrome 99+ and Safari 17.5+) can predict rendered line widths including CSS adjustments, potentially replacing the reflow pass entirely. The experiment page compares both pipelines side by side.

At-Scale Benchmark

100 paragraphs rendered at three column widths and three levels of flexbox nesting. Knuth-Plass pre-computed; only the rendering phase was timed (3 runs averaged).

Width Nesting DOM (ms) Canvas (ms) Speedup
400px 0 69.9 20.1 71%
550px 0 63.9 7.8 88%
700px 0 72.0 10.6 85%
400px 15 92.9 13.9 85%
550px 15 101.3 13.3 87%
700px 15 79.7 10.9 86%
400px 30 106.5 12.2 89%
550px 30 24.1 2.6 89%
700px 30 27.0 2.4 91%

Canvas rendering is 71–91% faster across all configurations, and the advantage grows with DOM complexity. At 30 levels of flexbox nesting, DOM correction costs up to 106ms while canvas stays at ~12ms. Canvas time is nearly constant regardless of nesting because it never triggers reflow. The small-scale experiment (9 paragraphs) understated this gap because a shallow DOM makes batched reflow cheap.

A caveat: canvas APIs cannot yet predict width changes from variable font wdth axis scaling, so full replacement of DOM correction remains an open problem. Users who prioritize performance at scale may consider transform: scaleX() for glyph scaling instead, which canvas can handle entirely without DOM involvement, though variable font wdth remains the highest typographic quality option. For letter-spacing and scaleX-based glyph scaling, the canvas results are promising.

7. Conclusions

  1. Knuth-Plass produces significantly more even justification than CSS. With CSS typographic levers enabled, spacing evenness improves from σ≈11.8% to σ≈4.2%, roughly 3× more even word spacing across lines. (In the TeX demerit metric this maps to ~1,000× fewer demerits, but the cubic/squared formula amplifies small ratio differences, so the σ number is the more honest perceptual measure.) Even without levers, KP measurably improves spacing evenness over the browser’s greedy algorithm.
  2. CSS typographic levers dramatically improve quality. Glyph scaling (±2%) and letter spacing (±0.0075em) give the optimizer enough flex to find consistently excellent solutions across all column widths. Without them, KP still beats CSS, but quality varies more.
  3. CSS levers currently require DOM correction, but canvas APIs are a promising alternative. Canvas measureText() alone is accurate to ~0.2px, enough for pixel-perfect word-spacing-only justification. But glyph scaling and letter spacing change rendered widths in ways canvas cannot predict without help, requiring a DOM reflow pass. Modern canvas APIs (ctx.letterSpacing, ctx.wordSpacing) show promise for predicting these CSS-adjusted widths directly, which would eliminate the reflow bottleneck. A limitation: variable font wdth axis scaling is not yet predictable from canvas, so full replacement remains an open problem.
  4. Pretext makes real-time justification practical. Because canvas measurement avoids synchronous DOM reflow, the full Pretext + KP pipeline completes in ~2.45ms, well within a single animation frame. This means justification can run on every responsive resize without perceptible delay, something DOM-based measurement makes difficult in complex pages. On the benchmark text (~2,000 words, flat DOM) canvas is ~4.5× faster than DOM, and the gap grows with DOM depth and complexity (Section 6 shows 71–91% faster at scale).
  5. The web can achieve professional-grade justification. By combining canvas measurement, Knuth-Plass optimization, professional typographic lever values, and DOM correction for CSS adjustments, web justification quality rivals dedicated typesetting systems—a result that was not achievable with CSS alone.