knuth-plass-wrap
v2.0.0
Published
Unicode-aware Knuth-Plass line breaking for the web, powered by HarfBuzz WASM
Maintainers
Readme
knuth-plass-wrap
TeX-quality Knuth-Plass line breaking for the web, powered by HarfBuzz WASM.
Produces optimally justified paragraphs by considering the entire text at once — the same algorithm TeX has used since 1981 — instead of the greedy line-at-a-time approach browsers use. Word measurement runs through HarfBuzz (via the harfrust Rust port) compiled to WebAssembly, so glyph widths match the browser's rendering with sub-pixel accuracy.
Features
- Optimal line breaking — Knuth-Plass dynamic programming minimises total paragraph demerits
- HarfBuzz-accurate measurement — glyph shaping in WASM matches browser rendering
- Unicode-aware breaks — ICU4X/UAX #14 line break opportunities for multilingual text
- Hyphenation — automatic hyphenation (17 languages) via language-specific tries
- Hz justification — micro-adjusts the font's
wdthaxis per-line for tighter composition - Variable font support —
wght,opsz,ital/slnt,wdthaxes - React 19 —
use()+ Suspense for zero-effect async loading - Headless hook —
useKnuthPlassWrapreturns lines for custom rendering - Vanilla JS —
init()+layoutParagraph()works without React - Tree-shakeable — core APIs at the root /
knuth-plass-wrap/core, React atknuth-plass-wrap/react - Reusable font handles — register a font once for repeated layout calls
Installation
npm install knuth-plass-wrap
# or
pnpm add knuth-plass-wrapQuick Start
React Component
import { Suspense } from "react";
import { KnuthPlassWrap } from "knuth-plass-wrap/react";
function Article() {
return (
<Suspense fallback={<p>Loading…</p>}>
<KnuthPlassWrap
text="The problem of breaking a paragraph into lines of approximately equal length has been important since the invention of movable type."
fontUrl="/fonts/Literata[opsz,wght].ttf"
fontSize={17}
lineWidth={400}
/>
</Suspense>
);
}The component suspends while the WASM module and font binary load, then renders justified lines as plain <div> elements with text-align: justify.
Headless Hook
Use useKnuthPlassWrap when you need custom rendering (canvas, SVG, etc.):
import { useKnuthPlassWrap } from "knuth-plass-wrap/react";
function CustomRenderer({ fontData }: { fontData: ArrayBuffer }) {
const { lines } = useKnuthPlassWrap({
text: "Your paragraph text here…",
fontData,
fontSize: 16,
lineWidth: 500,
});
return (
<div>
{lines.map((line, i) => (
<div key={i}>{line.text}</div>
))}
</div>
);
}Vanilla JS (No React)
import { init, layoutParagraph } from "knuth-plass-wrap/core";
await init();
const fontData = await fetch("/fonts/Inter[opsz,wght].ttf").then(r => r.arrayBuffer());
const lines = layoutParagraph(fontData, {
text: "Your paragraph text…",
fontSize: 16,
lineWidth: 400,
});
for (const line of lines) {
console.log(line.text);
}API Reference
init(input?): Promise<void>
Initialise the WASM module. Must be called before layoutParagraph or measureWord. The component and hook call this automatically.
// Default — loads .wasm from the package directory
await init();
// Custom URL (self-hosted, CDN, etc.)
await init("https://cdn.example.com/kp_break_wasm_bg.wasm");
// Fetch response
await init(fetch("/my-path/kp_break_wasm_bg.wasm"));layoutParagraph(fontData, options): Line[] | HzLine[]
Lay out a paragraph. The entire pipeline — measurement, tokenisation, hyphenation, optimal breaking, and line construction — runs in a single WASM call.
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| text | string | required | Paragraph text |
| fontSize | number | required | Font size in CSS px |
| lineWidth | number | required | Target line width in CSS px |
| fontWeight | number | 400 | Font weight (variable font axis) |
| liga | boolean | true | Standard ligatures |
| opsz | number | fontSize | Optical sizing axis value (0 = disabled) |
| ital | number | 0 | Italic/slant axis (e.g. 12 for 12° slant) |
| hyphenate | boolean | false | Automatic hyphenation |
| lang | string | "en" | BCP 47 / ISO language metadata for hyphenation and shaping |
| dir | "auto" \| "ltr" \| "rtl" | "auto" | Text direction metadata |
| writingMode | "horizontal-tb" \| "vertical-rl" \| "vertical-lr" | "horizontal-tb" | Writing-mode metadata. Optimization is currently horizontal-width based |
| similarityDemerits | number | 2000 | Penalty for adjacent lines with different tightness (0 = disabled) |
| hz | { min, max } | — | Hz justification wdth axis range |
registerLayoutFont(fontData): number
Register font bytes once in the WASM engine and reuse the returned handle for repeated layouts. This avoids reparsing the same font and lets the shaping cache persist across calls.
import {
init,
registerLayoutFont,
layoutParagraphWithFont,
unregisterLayoutFont,
} from "knuth-plass-wrap/core";
await init();
const handle = registerLayoutFont(fontData);
const lines = layoutParagraphWithFont(handle, {
text: "Repeated layout work…",
fontSize: 16,
lineWidth: 400,
});
unregisterLayoutFont(handle);measureWord(fontData, fontSize, word, ...): number
Measure the advance width of a single word using HarfBuzz shaping. Useful for debugging or building custom layout logic.
KnuthPlassWrap (React Component)
Must be wrapped in a <Suspense> boundary.
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| text | string | required | Paragraph text |
| fontSize | number | required | Font size in CSS px |
| lineWidth | number | required | Target line width in CSS px |
| fontData | ArrayBuffer | — | Raw TTF/OTF font binary |
| fontUrl | string | — | URL to font file (fetched and cached) |
| fontDataMap | Record<number, ArrayBuffer> | — | Weight-keyed binaries for static font families |
| fontFamily | string | — | CSS font-family (when managing @font-face yourself) |
| fontWeight | number | 400 | CSS font-weight / variable font axis |
| fontStyle | string | "normal" | CSS font-style |
| fontStretch | string | "100%" | CSS font-stretch |
| lineHeight | number | 1.6 | Line height multiplier |
| color | string | "#2a2623" | Text colour |
| liga | boolean | true | Standard ligatures |
| opticalSizing | "auto" \| "none" \| number | "auto" | Optical sizing |
| hyphenate | boolean | false | Automatic hyphenation |
| lang | string | "en" | Language metadata |
| dir | "auto" \| "ltr" \| "rtl" | "auto" | Text direction metadata |
| writingMode | "horizontal-tb" \| "vertical-rl" \| "vertical-lr" | "horizontal-tb" | Writing-mode metadata |
| similarity | boolean | true | Similarity demerits |
| hz | { min, max } | — | Hz justification wdth range |
| className | string | — | Container CSS class |
| style | CSSProperties | — | Container inline styles |
| fallback | ReactNode | — | Content shown while loading |
useKnuthPlassWrap(options): { lines, isLoading }
Headless hook for custom rendering. Same WASM engine, you control the DOM.
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| text | string | required | Paragraph text |
| fontData | ArrayBuffer \| null | required | Font binary (null while loading) |
| fontSize | number | required | Font size in CSS px |
| lineWidth | number | required | Target line width in CSS px |
| fontWeight | number | 400 | Font weight |
| liga | boolean | true | Ligatures |
| opsz | number | fontSize | Optical sizing (0 = disabled) |
| hyphenate | boolean | false | Hyphenation |
| lang | string | "en" | Language metadata |
| dir | "auto" \| "ltr" \| "rtl" | "auto" | Text direction metadata |
| writingMode | "horizontal-tb" \| "vertical-rl" \| "vertical-lr" | "horizontal-tb" | Writing-mode metadata |
| similarity | boolean | true | Similarity demerits |
| hz | { min, max } | — | Hz justification range |
Font Registration Utilities
When you provide fontData or fontUrl without a fontFamily, the component automatically registers the font binary as a scoped @font-face so the browser renders with the exact same bytes HarfBuzz shaped.
import { registerFontBinary, registerFontBinaryMap } from "knuth-plass-wrap/core";
// Single variable font
const { name, ready } = registerFontBinary("MyFont", arrayBuffer);
await ready;
// Static font family (multiple weights)
const { name, ready } = registerFontBinaryMap("MyFont", [
{ binary: regular, weight: 400 },
{ binary: bold, weight: 700 },
]);
await ready;Line Data
Each line returned by layoutParagraph or useKnuthPlassWrap has:
interface Line {
text: string; // Exact text to render for this line
segments: string[]; // Text segments used by the layout engine
words: string[]; // Whitespace-split compatibility field
widths: number[]; // Per-segment widths in px
boxW: number; // Total segment width (excluding inter-segment glue)
spaceWidth: number; // Natural inter-word space width
last: boolean; // True for the last line (left-aligned)
}
interface HzLine extends Line {
wdth: number; // CSS font-variation-settings 'wdth' value (100 = normal)
}Hz Justification
When the font supports a wdth variation axis, Hz justification micro-adjusts the font width per-line to achieve tighter composition — the same technique used by Adobe InDesign and Hermann Zapf's Hz-program.
<KnuthPlassWrap
text={text}
fontUrl="/fonts/RobotoFlex-VariableFont.ttf"
fontSize={16}
lineWidth={400}
hz={{ min: 95, max: 105 }}
/>Browser Support
Requires WebAssembly, FontFace API, and fetch. Works in all modern browsers (Chrome 57+, Firefox 52+, Safari 11+, Edge 79+).
CDN Usage
<script type="module">
import { init, layoutParagraph } from "https://esm.sh/knuth-plass-wrap/core";
await init("https://esm.sh/knuth-plass-wrap/wasm/kp_break_wasm_bg.wasm");
const fontData = await fetch("/fonts/MyFont.ttf").then(r => r.arrayBuffer());
const lines = layoutParagraph(fontData, {
text: "Your text here…",
fontSize: 16,
lineWidth: 400,
});
</script>Development
pnpm install
pnpm build:wasm # Build WASM (requires Rust + wasm-pack)
pnpm dev # Start Vite dev server
pnpm build # Build library (WASM + ESM + CJS + types)
pnpm build:demo # Build demo app
pnpm lint # Run ESLint
pnpm test # Run Rust testsLicense
MIT
