@zakkster/lite-text-layout
v1.0.1
Published
Zero-GC bitmap font word wrapper. Computes line breaks, kerning-aware widths, and truncation/ellipsis flags into a Float32Array — feeds directly into @zakkster/lite-bmfont's drawWrapped.
Maintainers
Readme
@zakkster/lite-text-layout
📐 What is lite-text-layout?
@zakkster/lite-text-layout is a zero-allocation word wrapper for ASCII bitmap fonts.
It walks a string once, computes soft/hard line breaks, kerning-aware widths, and an
optional ellipsis-on-overflow flag — then writes everything into a caller-owned
Float32Array.
The output is exactly the layout buffer that @zakkster/lite-bmfont's
BitmapFont.drawWrapped consumes. Compute layout once, re-render every frame for free.
It gives you:
- 📦 Soft-wrap at spaces, hard-break inside long words
- 📏 Per-line pixel widths, kerning-aware (uses the same 64K LUT as the renderer)
- 📃 Multi-line via
\ncharacters - ✂️ Optional truncation with ellipsis flag when content overflows
boxHeight - 🧹 Zero allocation — single pass over the string, all state in primitives
- 🪶 ~0.6 KB gzipped, no dependencies
Supports ASCII characters 0–255. Non-ASCII chars contribute zero advance and reset the kerning context (same convention as
BitmapFont.draw).
Part of the @zakkster/lite-* ecosystem — micro-libraries built for deterministic, cache-friendly game development.
🚀 Install
npm i @zakkster/lite-text-layoutPair with the renderer (separate package):
npm i @zakkster/lite-bmfont @zakkster/lite-text-layout🕹️ Quick Start
import { BitmapFont } from '@zakkster/lite-bmfont';
import { TextLayout } from '@zakkster/lite-text-layout';
const font = new BitmapFont(atlasImage, fontJson);
// Pre-allocate a layout buffer once. 16 lines = 64 floats.
const layout = new Float32Array(64);
// Each frame: layout is virtually free once computed. Re-compute only when
// the text or box dimensions change.
const lineCount = TextLayout.computeWrap(
'Hello there, traveller!\nWelcome to the inn.',
font,
/* boxWidth */ 200,
/* boxHeight */ 80,
/* lineHeight*/ font.lineHeight,
layout,
/* scale */ 1
);
// Hand the layout straight to drawWrapped — no array conversion, no string splitting.
font.drawWrapped(
ctx, text, layout, lineCount,
/* box */ 200, 80, 20, 20,
/* scale */ 1,
/* align */ 1, // center
/* vAlign */ 1 // middle
);🧠 Why a separate package?
lite-bmfont is the renderer. lite-text-layout is the planner. Keeping them split:
- Lets the renderer ship without paying for wrapper code when you don't need it (~0.6 KB saved).
- Lets you swap in a custom layout strategy (RTL, char-break-only, hyphenation, soft-hyphen markers) without forking the renderer.
- Lets you compute layout once and re-render every frame for free — typical in HUDs where the dialogue box doesn't resize but its position animates.
📦 Layout buffer format
Each line is 4 consecutive Float32 values:
| Slot | Meaning |
|------|---------|
| [0] | startIdx — char index in text where this line begins (inclusive) |
| [1] | endIdx — char index in text where this line ends (exclusive) |
| [2] | lineWidth — measured pixel width of this line, including any ellipsis allowance |
| [3] | flags — FLAG_NORMAL (0) or FLAG_TRUNCATED (1, append …) |
The buffer must hold at least lineCount * 4 floats; surplus capacity is ignored, so you
can reuse one fat buffer across many strings. The function returns the number of lines
actually written.
import { FLAG_TRUNCATED } from '@zakkster/lite-text-layout';
if (layout[lineCount * 4 - 1] === FLAG_TRUNCATED) {
// The last line was truncated — content was longer than boxHeight allowed.
}🧩 Wrapping rules
- Soft-break at the last space when adding the next glyph would exceed
boxWidth. The breaking space is excluded from both sides; runs of leading whitespace on the next line are skipped. - Hard-break inside a word when no space is available within the current line. Kerning is reset across the break.
- Explicit
\nstarts a new line and is not rendered. boxWidth === 0disables horizontal wrapping (only\nand truncation apply).boxHeight === 0disables vertical truncation entirely.
✂️ Truncation
When boxHeight > 0 and the wrap would push content past the bottom of the box, the
last fitting line is flagged FLAG_TRUNCATED. The renderer appends … (three
ASCII . glyphs) — no per-frame string allocation.
To make the ellipsis fit cleanly, computeWrap tracks the latest position on each line
where content + ellipsis still fits within boxWidth. The truncated line ends there.
Edge cases worth knowing:
- If the font doesn't include
'.'(code 46),ellipsisWidthis treated as0and no truncation marker is drawn. Content is still truncated to fitboxHeight. - If
boxWidthis so narrow that not even one glyph + ellipsis fits anywhere on the line, the truncated line falls back toFLAG_NORMAL(no ellipsis attempt) so the renderer doesn't draw dots that would themselves overflow the box.
⚙️ API
computeWrap(text, font, boxWidth, boxHeight, lineHeight, outBuffer, scale?) → number
Computes the layout. Writes 4-tuples into outBuffer and returns the line count.
text— source stringfont— anything withglyphs: Int16Arrayandkerning: Int16Array; aBitmapFontinstance works directlyboxWidth— px,0for no horizontal limitboxHeight— px,0for no vertical limitlineHeight— px atscale=1, usuallyfont.lineHeightoutBuffer— pre-allocatedFloat32Array, capacity caps line count atfloor(length / 4)scale— applied to all widths and the height-fit check (default1)
Constants
FLAG_NORMAL = 0— normal lineFLAG_TRUNCATED = 1— renderer should append…
🧪 Benchmark
Word-wrapping a 50-word paragraph each frame at 60 fps:
ctx.measureText + manual line-build: allocates strings/arrays every frame
TextLayout.computeWrap(buffer): zero allocation — output buffer is reused
Re-rendering an already-laid-out paragraph at 60 fps:
any text engine: re-runs layout per frame
drawWrapped(layout): zero work — layout is just an array of indices📦 TypeScript
Full TypeScript declarations included in TextLayout.d.ts. Exports:
TextLayout.computeWrap(...)FLAG_NORMAL,FLAG_TRUNCATED- Types:
BitmapFontData,LayoutLine,LineFlag
📚 LLM-Friendly Documentation
See llms.txt for AI-optimized metadata and usage examples.
🗒️ Changelog
1.0.0
- Initial release.
computeWrapwith soft-break, hard-break, explicit-newline, truncation with ellipsis flag, and full kerning support.
License
MIT
