Pretext Optimal Line Breaking — Design Spec
Date: 2026-03-29 Status: Approved
Problem
The portfolio site uses CSS text-wrap: pretty with justified text and hyphenation. CSS line breaking is greedy (breaks line-by-line without considering the paragraph as a whole), which produces suboptimal results — uneven spacing, poor break choices, and visible “rivers” of whitespace in justified text.
Solution
Replace CSS-driven line breaking with JavaScript-powered Knuth-Plass optimal paragraph layout, using @chenglou/pretext for text measurement. This produces globally optimal break points that minimize total “badness” across all lines in a paragraph.
Scope
All prose paragraphs site-wide: #quarto-document-content p elements. Does not apply to card text, publication metadata, or other non-prose elements.
Architecture
Components
Single file: scripts/pretext-layout.js
Contains three logical parts:
Measurement layer — Uses
@chenglou/pretext’sprepare()andwalkLineRanges()APIs to measure text segments via canvas. Handles mixed-width characters (Japanese text in teaching/research sections, emoji, etc.) correctly.Knuth-Plass breaking algorithm — Takes measured segments and container width, returns optimal break points:
- Tokenizes paragraph into boxes (word segments), glue (stretchable/shrinkable spaces), and penalties (possible break points)
- For each feasible break point, computes adjustment ratio (how much glue must stretch/compress)
- Scores breaks with demerits function:
(1 + badness + penalty)^2 - Dynamic programming finds the break sequence minimizing total demerits
- Final line is left-aligned (standard typographic practice)
DOM integration — Selects paragraphs, applies computed breaks, handles resize:
- On
DOMContentLoaded: processes all target paragraphs - Stores original text content for re-layout
- Inserts
<br>tags at computed break points - Wraps justified lines in spans with
text-align: justify - Last line of each paragraph left-aligned
- Debounced resize handler (~150ms) re-runs breaking when container width changes
- On
Knuth-Plass Parameters
| Parameter | Default | Purpose |
|---|---|---|
tolerance |
3 | Max acceptable badness before forced break |
hyphenPenalty |
50 | Cost of hyphenating (discourages but allows) |
linePenalty |
10 | Base cost per line break |
Measurement Details
The site uses Inconsolata (monospace), but Japanese text and potential emoji break the equal-width assumption. pretext’s canvas-based measurement handles all of these correctly via prepare(text, font).
Measurement results are cached per paragraph. On resize, only the breaking algorithm re-runs with the new width — prepare() is not called again.
FOUC Prevention
- CSS rule:
#quarto-document-content p { visibility: hidden } - After JS layout completes: flip to
visibility: visible - No-JS fallback via
<noscript><style>block restores visibility and keeps existing CSS wrapping
CSS Changes
Remove from styles.css: - text-wrap: pretty from #quarto-document-content p - text-align: justify and text-justify: inter-word from the desktop media query - Hyphenation rules (hyphens: auto, hyphenate-limit-chars) — JS handles breaking
Add to styles.css: - #quarto-document-content p { visibility: hidden } for FOUC prevention
Keep unchanged: - Font, size, line-height, letter-spacing - All card, publication, and talk styling - Responsive layout and media queries (other than the justify rules)
No-JS fallback (in page template):
<noscript><style>
#quarto-document-content p {
visibility: visible !important;
text-wrap: pretty;
text-align: justify;
hyphens: auto;
}
</style></noscript>Quarto Integration
In _quarto.yml, add the script via include-after-body so it executes after page content is rendered.
Dependency
@chenglou/pretext — bundled directly into scripts/pretext-layout.js (no CDN, no runtime dependency). This keeps the static site self-contained.
Performance
prepare(): ~19ms for 500 texts (one-time on page load)- Knuth-Plass per paragraph: negligible (pure arithmetic over cached measurements)
- Total page load impact: <50ms for all paragraphs
- Resize re-layout: cheaper since measurement is cached, only breaking re-runs
Resize Handling
- Debounce resize events at ~150ms
- Skip re-layout if container width unchanged (height-only changes)
- Re-run Knuth-Plass on all paragraphs with new width
- Re-render break points in DOM
Text Remains Selectable
pretext is used only for measurement. Text stays as normal HTML — <br> tags for line breaks, <span> wrappers for justification. Fully selectable, copyable, and accessible.
Files Changed
| File | Change |
|---|---|
scripts/pretext-layout.js |
New — measurement, Knuth-Plass, DOM integration |
styles.css |
Remove CSS wrapping/justify rules, add FOUC prevention |
_quarto.yml |
Add script include |