kb-text-layout
Multiline text measurement & layout on top of kb-text-shape, inspired by pretext.
Prepare a text once, then lay it out at any width.
import Data.Text.IO qualified as Text
import KB.Text.Layout.Break qualified as Break
import KB.Text.Layout.Measure qualified as Measure
import KB.Text.Shape qualified as KBTS
main :: IO ()
main =
KBTS.withContext \shape -> do
font <- KBTS.pushFontFromFile shape "demos/assets/Ubuntu-R.ttf" 0
ctx <- Measure.createLayoutContext shape
style <- Measure.newStyle ctx font 1.0
prepared <- Measure.prepare ctx style "Soft hy\173phen\173ation and non\160breaking\160spaces."
let maxWidth = 24
let ranges = Break.layoutGreedy prepared maxWidth
putStrLn $ "Stats: " <> show (Break.layoutStats ranges)
putStrLn ""
-- Stats: LayoutStats {lineCount = 2, maxLineWidth = 13.909091}
putStrLn "Ranges:"
mapM_ print ranges
-- LineRange {from = Cursor {segment = 0, grapheme = 0}, to = Cursor {segment = 9, grapheme = 0}, width = 13.909091, ended = Wrapped}
-- LineRange {from = Cursor {segment = 10, grapheme = 0}, to = Cursor {segment = 15, grapheme = 0}, width = 13.470421, ended = Finished}
putStrLn ""
putStrLn "Lines:"
let laidout = map (Break.materializeLineRange prepared) ranges
-- Soft hyphenation and
-- non breaking spaces.
mapM_ Text.putStrLn laidout
Everything after prepare is pure: relayout at another width is a fold over cached widths, with no shaper calls.
Units
To make sizes comparable across different fonts layout space is cap-height-normalized.
newStyle ctx font 1.0 scales the font so a capital H is exactly 1.0 layout units tall.
Style.em carries the em size in the same units for renderers that need CSS or pixel sizes.
Line height is a caller-chosen number of cap units.
Ascenders and descenders overhang the fixed line box, so vertical font metrics never enter layout.
⚠️ Fonts without a cap-height metric fail at load time.
Demos
The demo executables live in demos/ behind the demos package flag (off by default).
demo: basic layout showing a ragged vs justified comparison.
masonry: packs a card corpus by shortest column.
justify: five columns of the same text at 300px.
- Browser's own
text-align: justify.
- Greedy (fastest).
- Greedy with soft hyphens from the
hyphenation package.
layoutOptimal (slowest, for extra nice).
layoutOptimal plus hyphenation.
obstacles: Routes justified text around exclusion shapes.
Use make demos to run everything and rebuild the HTML pages.
Use stack bench to see the relative cost of each layout.
Limitations
- Grapheme clusters come from kb-text-shape's
KB.Text.Shape.Segmentation, so combining marks, Hangul jamo, flag pairs, and ZWJ emoji stay whole through emergency breaks and slicing. kbts skips UAX #29 GB11 and LB8a, so the wrapper refuses to cut, or break, adjacent to a ZWJ.
- No dictionary-based segmenter for Thai/Lao/Khmer/Myanmar; no algorithmic breaker provides one.
- Spans are shaped whole, so widths reflect joined and kerned forms. At a chosen break, a line that ends mid-join renders letterforms whose widths differ slightly from the joined measurement; a re-shape refinement pass does not exist yet.
- Pretext's preprocessing rules are largely subsumed by kbts line breaking.
Analysis adds the two missing tailorings: URL query-separator splits scoped to slash-containing tokens, and en/em-dash digit-range suppression.
prepare needs the open TextShape.Context; the PreparedText does not. Prepared texts can be laid out, sliced, and emergency-broken after the context closes.
pushFontFromFile and pushFontFromMemory reject missing files and fonts without unitsPerEm or a cap-height metric at load time, so bad fonts fail fast.