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 challenge: implementing Knuth-Plass on the web, where JavaScript measurement APIs and the CSS rendering engine are separate systems.

2. Architecture

The Pipeline

The system combines three components:

  1. Pretext (@chenglou/pretext) — canvas-based text measurement. Segments text via Intl.Segmenter, measures each segment via canvas.measureText(), caches results.
  2. tex-linebreak (@robertknight/tex-linebreak) — JavaScript implementation of the Knuth-Plass algorithm. Takes box/glue/penalty items with pre-measured widths, returns optimal breakpoints.
  3. CSS rendering — lines are rendered as HTML <div> elements with computed word-spacing, plus optional CSS adjustments (glyph scaling via font-variation-settings: "wdth", letter spacing via letter-spacing).

Canvas vs DOM Measurement Accuracy

Canvas measureText() and the browser’s DOM layout engine produce line widths that agree to within ~0.2px (~0.04% discrepancy). The small difference stems from subpixel accumulation and context-dependent shaping — negligible for practical justification. Base justification (word-spacing only) achieves a flush right edge from canvas widths alone, with no DOM correction required.

When DOM Correction Is Needed

DOM correction (measuring the rendered line width via getBoundingClientRect() and distributing the exact remainder into word-spacing) is needed only when CSS adjustments are applied at render time — specifically glyph scaling and letter spacing. These CSS properties change the rendered width from what canvas measured, 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.

3. CSS Typographic Levers

The Three Levers

Professional typesetting systems (Adobe InDesign, the Pangram Pangram foundry recipe) use multiple levers beyond word spacing to achieve even justification. Following industry best practices for value ranges, this system exposes three levers to the Knuth-Plass optimizer:

  1. Word spacing — glue stretch/shrink on space items. Tuned to the Pangram Pangram recipe: 85%–130% of natural space width (shrink 15%, stretch 30%).
  2. Glyph scalingfont-variation-settings: "wdth" at ±2%. Nearly invisible to readers but provides significant flex for the optimizer.
  3. Letter spacingletter-spacing at ±0.0075em. The most conservative setting from professional recommendations.

Note: unlike InDesign, which applies these in a strict priority order, this implementation feeds all three as simultaneous stretch/shrink capacity into the Knuth-Plass optimizer. The algorithm finds the globally optimal combination rather than exhausting one lever before engaging the next.

Why Levers Matter for Quality

The CSS levers significantly improve Knuth-Plass output quality. Without them, the optimizer’s only flexibility is word spacing. When word spacing alone cannot achieve a good adjustment ratio, the algorithm produces lines with uneven spacing.

With glyph scaling and letter spacing enabled, each box item 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: with levers, demerits drop by ~1,000× compared to CSS justification, versus 3–70× without them.

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

Pretext cache-warm path (same width, no re-measurement): 2.15 ms average (Approach A, KP + render only).

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

5. Quality Comparison

Demerits: Knuth-Plass vs CSS Justification

Demerits measure justification quality using the TeX formula: (1 + badness)², where badness = 100 × |r|³ and r is the word-spacing ratio (extra spacing per gap / natural space width). Lower is better. CSS demerits are measured from the browser’s actual rendered word-spacing ratios via the Range API.

Width A: Pretext + KP B: + GS/LS/DOM CSS justify A vs CSS B vs CSS
350px ~24,741 ~49 ~85,789 ~3× ~1,757×
400px ~3,780 ~38 ~69,549 ~18× ~1,816×
450px ~4,149 ~30 ~28,972 ~7× ~973×
500px ~647 ~23 ~45,342 ~70× ~1,971×
550px ~847 ~7 ~5,800 ~7× ~841×
600px ~270 ~7 ~7,286 ~27× ~1,068×

A/B vs CSS = ratio of CSS demerits to KP demerits (higher = KP is better, e.g. 10× means KP has 10× fewer demerits)

Key Findings

6. The Role of Pretext

Pretext’s Value

Pretext provides two distinct benefits:

  1. Speed: Canvas measurement is ~4.5× faster than DOM measurement. 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.

Other Uses

Beyond justification, pretext’s layout() function operates at ~0.0002ms on cached widths — pure arithmetic with no DOM access. This enables instant line-count estimation, height prediction, and other layout computations useful independent of justification accuracy.

7. How Other Systems Handle This

Survey of Implementations

System Measurement Source Rendering Engine Agreement
TeX TFM font metric files DVI output (same TFM) Perfect
Typst OpenType tables via HarfBuzz PDF output (same metrics) Perfect
Adobe InDesign CoolType shaper CoolType renderer Perfect
Android LineBreaker Minikin + HarfBuzz Minikin + HarfBuzz Perfect
Chromium text-wrap: pretty HarfBuzz (Blink) HarfBuzz (Blink) Perfect
tex-linebreak (DOM mode) DOM Range API CSS rendering Near-perfect
This project (pretext) Canvas measureText DOM + CSS word-spacing Near-perfect

Every system that controls its own rendering pipeline has perfect measurement-rendering agreement. The web is the outlier — JavaScript must measure through one API and render through another. However, this project demonstrates that the discrepancy is small enough (~0.04%) to be negligible for practical justification.

text-wrap: pretty

Chromium’s text-wrap: pretty uses an optimization algorithm influenced by Knuth-Plass, implemented in C++ within Blink’s layout pipeline. It measures and renders through the same HarfBuzz-based engine, so there is no discrepancy. However, it does not support justification — it only optimizes ragged-right line breaks. For justified text in the browser, a JavaScript solution remains necessary.

8. Canvas as a DOM Replacement

DOM correction triggers synchronous browser reflow via getBoundingClientRect(), and reflow cost scales with DOM complexity. For a shallow DOM with a few paragraphs, this is negligible. But in production contexts — long articles, deeply nested component trees — it becomes the dominant bottleneck.

This project’s use of canvas for text measurement was inspired by pretext, which demonstrated that canvas.measureText() is both fast and accurate enough for layout work. Modern canvas APIs (ctx.letterSpacing, ctx.wordSpacing, available in Chrome 99+ and Safari 17.5+) extend this idea: they can predict rendered line widths including CSS adjustments, potentially replacing the DOM 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
400px069.920.171%
550px063.97.888%
700px072.010.685%
400px1592.913.985%
550px15101.313.387%
700px1579.710.986%
400px30106.512.289%
550px3024.12.689%
700px3027.02.491%

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. But for letter-spacing and scaleX-based glyph scaling, the results are promising.

9. Conclusions

  1. Knuth-Plass produces dramatically better justification than CSS. With CSS typographic levers enabled, the quality improvement is ~1,000× fewer demerits. Even without levers, KP is 3–70× better than 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 provides real speed advantage. Canvas measurement is ~4.5× faster than DOM, making the base pretext + KP approach (2.45ms) the fastest path by a wide margin.
  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.