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
- Knuth-Plass line breaking produces dramatically more even word spacing than the browser’s built-in justification — roughly 1,000× better with all optimizations enabled.
- CSS glyph scaling and letter spacing — techniques borrowed from professional print typesetting — give the algorithm much more room to distribute adjustments invisibly, keeping spacing consistent regardless of column width.
- These adjustments currently require measuring the rendered output, but newer canvas APIs may be able to predict the result without touching the DOM — 71–91% faster at scale. Variable font width is not yet predictable this way.
- Canvas-based text measurement (via pretext) is ~4.5× faster than measuring through the DOM, making the whole pipeline fast enough for real-time responsive layout.
- With these techniques combined, web-based justified text can rival the quality of professional typesetting systems like TeX and InDesign.
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:
- Pretext (
@chenglou/pretext) — canvas-based text measurement. Segments text viaIntl.Segmenter, measures each segment viacanvas.measureText(), caches results. - tex-linebreak (
@robertknight/tex-linebreak) — JavaScript implementation of the Knuth-Plass algorithm. Takes box/glue/penalty items with pre-measured widths, returns optimal breakpoints. - CSS rendering — lines are rendered as HTML
<div>elements with computedword-spacing, plus optional CSS adjustments (glyph scaling viafont-variation-settings: "wdth", letter spacing vialetter-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:
- Word spacing — glue stretch/shrink on space items. Tuned to the Pangram Pangram recipe: 85%–130% of natural space width (shrink 15%, stretch 30%).
- Glyph scaling —
font-variation-settings: "wdth"at ±2%. Nearly invisible to readers but provides significant flex for the optimizer. - Letter spacing —
letter-spacingat ±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
- Dataset: “The Crystal Goblet” by Beatrice Warde — 9 paragraphs, ~2000 words
- Scenario: Simulated responsive resize — re-justify at 10 different widths (400–580px), 20 iterations each (200 total runs)
- Font: Roboto Serif 18px with OpenType features (
kern,liga,calt,onum) - Machine: macOS (Apple Silicon)
Three Approaches Tested
- Approach A: Pretext (canvas) + Knuth-Plass — base justification with word-spacing only, no CSS adjustments, no DOM correction
- Approach B: Pretext (canvas) + Knuth-Plass + glyph scaling (±2%) + letter spacing (±0.0075em) + DOM correction
- Approach C: DOM measurement (Range API) + Knuth-Plass + direct render — no correction needed since measurement and rendering share the same coordinate system
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
- Approach B (KP + CSS levers) produces 841–1,971× fewer demerits than CSS across all tested widths — a consistent three-orders-of-magnitude quality improvement.
- Approach A (KP with word-spacing only) is 3–70× better than CSS — still a significant improvement, but with more variance depending on column width.
- CSS demerits are highly width-sensitive — ranging from ~5,800 at 550px to ~85,789 at 350px. The greedy algorithm suffers badly at narrow widths.
- KP + CSS levers flatten the quality curve. Approach B’s demerits stay stable at 7–49 regardless of width, compared to CSS’s wild swings. The levers give the optimizer enough slack to find consistently excellent solutions.
6. The Role of Pretext
Pretext’s Value
Pretext provides two distinct benefits:
- 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.
- 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 |
|---|---|---|---|---|
| 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. But for letter-spacing and scaleX-based glyph scaling,
the results are promising.
9. Conclusions
- 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.
- 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.
- 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 fontwdthaxis scaling is not yet predictable from canvas, so full replacement remains an open problem. - 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.
- 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.