A small, dependency-free paragraph justification engine for the web, published
as @kitlangton/justice.
The solver is a total-fit line breaker in the tradition of Knuth and Plass's algorithm from TeX: every break in a paragraph is chosen together to minimize a paragraph-wide cost, rather than greedily line by line. Justice keeps the ideas that matter for screen typography — cubic strain, four line-fitness classes with an adjacency penalty, hyphen penalties, and an emergency-stretch second pass — and drops the parts that only make sense for a batch typesetter. The result is a core of about 4.5 KB gzipped.
bun add @kitlangton/justiceimport { prepare, solve, lineText } from "@kitlangton/justice"
const paragraph = prepare(text, measureTextWidth) // measure once
const layout = solve(paragraph, 620) // solve at any width
for (const line of layout.lines) {
lineText(paragraph, line)
line.wordSpacing // CSS word-spacing delta in px
line.tracking // CSS letter-spacing in px
line.hanging // optical margin allowance in px
line.opening // left quote allowance; render at -line.opening
line.residual // positive: underfilled; negative: overfull
}- Measured input, numerical output. You supply word widths for the exact font and shaping you render with; the engine returns line boundaries and CSS spacing values. No DOM access, no bundled fonts or dictionaries.
- Exact optimum. A prefix-sum dynamic program over word boundaries finds the minimum-cost layout, with admissible pruning to skip hopeless candidates.
- Bounded adjustments. Word spacing stretches and shrinks within configured limits; letter spacing is capped per grapheme. Balanced mode can relax word spacing to complete a line; strict mode keeps the limits hard.
- Optical margins. Trailing punctuation hangs past the right margin and
opening quotes receive a leading allowance by default.
withOpticalMarginsaccepts font-aware edge measurements from an adapter. - Per-line measures. Pass one width per line (
[w - indent, w]) for a first-line indent or a drop cap; the last entry repeats. This is TeX's\parshape, and the paragraph is still fitted as a whole. - Styled runs.
preparetakes plain text or runs like{ text, style }, measures each piece of a word in its own style, and reports each word's source offset so breaks map back onto your markup. - Line count.
lines: "ragged"(or a number) keeps a paragraph to the lines it takes set ragged, so justifying it never moves the page. - Optional hyphenation.
withHyphenationtakes dictionary-supplied breaks, measures each fragment as a shaped unit including its hyphen, and prices discretionary, consecutive, and final-line hyphens. Words like well-known may break after their existing hyphen.@kitlangton/justice/englishsupplies TeX's American English patterns as a separate entry. - Fitness-aware. Each break retains tight/decent/loose/very-loose histories so abrupt spacing changes between neighbouring lines cost extra.
- Static output.
@kitlangton/justice/staticprecomputes break plans across a range of measures and coalesces them into bands for build-time rendering.
The full API — options, hyphenation, optical margins, the rendering contract,
and static compilation — is documented in
packages/justice/README.md.
Horizontal LTR, space-delimited text, plain or as styled runs. Pass each
paragraph and hard-break segment separately. Markup parsing, CJK break rules,
bidi layout, and variable-font expansion are out of scope. Requires an ECMAScript
2022 runtime with Intl.Segmenter.
bun install
bun run typecheck
bun run test # exhaustive-partition oracles for whole-word and hyphenated layouts
bun run bench # solver timing on synthetic advances
bun run build # compiles packages/justice/dist
bun run site # the site, at http://127.0.0.1:5173src/engine.ts is the solver; src/static.ts is the build-time band compiler;
src/english.ts is the optional English hyphenator, with patterns generated by
bun scripts/english-patterns.ts. All are packaged from packages/justice.
site/ is justice.kitlangton.com: a Vite
app that imports the engine source directly and sets each example paragraph
three ways, side by side. Its build also generates the agent-readable documents
(llms.txt, docs.md, types.d.ts, changelog.md) from the package, and
bun run site:deploy publishes site/dist to Cloudflare Pages.
Releases publish from GitHub Actions with npm trusted publishing (OIDC); no
token or one-time password is involved. Update the version in
packages/justice/package.json and CHANGELOG.md, commit, then:
bun run check:release
npm pack ./packages/justice --dry-run # inspect the tarball
git tag v0.4.0 && git push origin main v0.4.0.github/workflows/release.yml runs the checks, verifies the tag matches the
package version, and publishes. Only the compiled core, declarations, package
README, and manifest are included in the tarball.
MIT