
Pretext is a JavaScript library for measuring multiline text height and calculating line breaks before rendering.
It handles fixed and variable line widths, preserved whitespace, CJK keep-all behavior, letter spacing, and rich inline items such as mentions and chips.
The library prepares text with Canvas measurements and reuses the cached metrics at different widths.
It works well for virtualized lists, Canvas or SVG rendering, shrink-wrapped message bubbles, and layouts that need text geometry before paint.
Features
- DOM-free height and line-count calculations in the
layout()hot path. - Cached Canvas text metrics for repeated width calculations.
- Unicode-aware wrapping across mixed scripts, emoji, CJK text, punctuation, and symbol runs.
white-space: normalandpre-wrapbehavior.word-break: normalandkeep-allbehavior.- Numeric CSS pixel
letterSpacingvalues. - Fixed-width and variable-width line layout APIs.
- Line ranges and statistics that avoid building line strings.
- Rich inline layout for mixed fonts, mentions, chips, and inline code.
How to Use Pretext
1. Install the package with NPM
npm install @chenglou/pretext
2. Measure multiline text height
Call prepare() when the text, font, or preparation options change. Reuse the prepared value with layout() when only the available width or line height changes.
import { prepare, layout } from '@chenglou/pretext'
const prepared = prepare(
'Your product description wraps at the container edge.',
'16px Inter'
)
const { height, lineCount } = layout(prepared, 320, 22)
console.log(height)
console.log(lineCount)3. Configure whitespace, word breaking, and letter spacing
These preparation options work with prepare() and prepareWithSegments().
| Option | Values / Default | Description |
|---|---|---|
whiteSpace | 'normal', 'pre-wrap' / 'normal' | Controls collapsed whitespace or preserved spaces, tabs, and hard line breaks. |
wordBreak | 'normal', 'keep-all' / 'normal' | Controls normal word breaking or CSS-like keep-all behavior for CJK and Hangul text. |
letterSpacing | number / 0 | Sets extra horizontal spacing between graphemes in CSS pixels. |
Textarea-like content
Use pre-wrap when spaces, tabs, and hard line breaks must stay visible.
const prepared = prepare(
'Line one\nLine two\thas a tab\nLine three',
'14px "Courier New"',
{ whiteSpace: 'pre-wrap' }
)
const { height } = layout(prepared, 480, 20)4. Get wrapped lines for Canvas, SVG, or custom rendering
Use prepareWithSegments() when you need line text, widths, cursors, or manual layout data.
import {
prepareWithSegments,
layoutWithLines
} from '@chenglou/pretext'
const prepared = prepareWithSegments(
'Multilingual text: 春天到了, بدأت الرحلة, and a rocket 🚀',
'18px Arial'
)
const { lines, lineCount, height } = layoutWithLines(
prepared,
320,
26
)
for (const line of lines) {
console.log(line.text, line.width)
}5. Measure wrapped line statistics
measureLineStats() returns the line count and widest wrapped line. measureNaturalWidth() returns the widest forced line when explicit line breaks are the only source of wrapping.
import {
prepareWithSegments,
measureLineStats,
measureNaturalWidth
} from '@chenglou/pretext'
const prepared = prepareWithSegments(
'A compact message that can wrap across several lines.',
'15px Georgia'
)
const stats = measureLineStats(prepared, 240)
const naturalWidth = measureNaturalWidth(prepared)
console.log(stats.lineCount)
console.log(stats.maxLineWidth)
console.log(naturalWidth)6. Walk line ranges without materializing text
Use walkLineRanges() when you need line widths and cursors but do not need the line strings.
import {
prepareWithSegments,
walkLineRanges
} from '@chenglou/pretext'
const prepared = prepareWithSegments(
'Measure each wrapped line without building its text string.',
'16px Inter'
)
walkLineRanges(prepared, 300, line => {
console.log(line.width, line.start, line.end)
})7. Lay out one line at a time
layoutNextLine() returns a materialized line. The range version avoids string allocation until you call materializeLineRange(). Both forms accept a different width for each line.
import {
prepareWithSegments,
layoutNextLineRange,
materializeLineRange
} from '@chenglou/pretext'
const prepared = prepareWithSegments(
'This paragraph flows around a fixed object in the first few rows.',
'16px "Open Sans"'
)
let cursor = { segmentIndex: 0, graphemeIndex: 0 }
let row = 0
while (true) {
const maxWidth = row < 3 ? 260 : 420
const range = layoutNextLineRange(prepared, cursor, maxWidth)
if (range === null) break
const line = materializeLineRange(prepared, range)
console.log(line.text, line.width)
cursor = range.end
row++
}8. Lay out rich inline text
The @chenglou/pretext/rich-inline entry point handles flat inline items with different fonts, letter spacing, atomic items, and extra horizontal width. The helper is limited to white-space: normal and is not a complete CSS inline formatting engine.
import {
prepareRichInline,
walkRichInlineLineRanges,
materializeRichInlineLineRange
} from '@chenglou/pretext/rich-inline'
const prepared = prepareRichInline([
{ text: 'Ship ', font: '500 17px Inter' },
{
text: '@maya',
font: '700 12px Inter',
break: 'never',
extraWidth: 22
},
{ text: "'s rich note", font: '500 17px Inter' }
])
walkRichInlineLineRanges(prepared, 320, range => {
const line = materializeRichInlineLineRange(prepared, range)
console.log(line.fragments, line.width)
})Core API Reference
| API | Description |
|---|---|
prepare(text, font, options?) | Analyzes and measures text once, then returns an opaque PreparedText value for layout(). |
layout(prepared, maxWidth, lineHeight) | Returns height and lineCount for the requested width and line height. |
prepareWithSegments(text, font, options?) | Prepares text for line-level APIs and returns PreparedTextWithSegments. |
layoutWithLines(prepared, maxWidth, lineHeight) | Returns height, line count, and materialized LayoutLine[] data for a fixed width. |
walkLineRanges(prepared, maxWidth, onLine) | Calls onLine for each LayoutLineRange and returns the line count. |
measureLineStats(prepared, maxWidth) | Returns lineCount and maxLineWidth without building line strings. |
measureNaturalWidth(prepared) | Returns the width of the widest forced line. Explicit hard breaks count. |
layoutNextLine(prepared, start, maxWidth) | Returns the next materialized LayoutLine from a cursor, or null at the end. |
layoutNextLineRange(prepared, start, maxWidth) | Returns the next LayoutLineRange without building the line string, or null at the end. |
materializeLineRange(prepared, line) | Converts a previously computed line range into a materialized LayoutLine. |
clearCache() | Clears Pretext’s shared internal caches. |
setLocale(locale?) | Sets the locale used by future preparation calls and clears shared caches. |
Core Public Types
| Type | Fields / Purpose |
|---|---|
PrepareOptions | whiteSpace?, wordBreak?, and letterSpacing?. |
WordBreakMode | 'normal' or 'keep-all'. |
PreparedText | Opaque handle returned by prepare(). |
PreparedTextWithSegments | Manual-layout handle returned by prepareWithSegments(). It exposes segments: string[] and approximate segLevels bidi metadata for custom rendering. |
LayoutResult | lineCount: number, height: number. |
LayoutLinesResult | LayoutResult plus lines: LayoutLine[]. |
LineStats | lineCount: number, maxLineWidth: number. |
LayoutCursor | segmentIndex: number, graphemeIndex: number. |
LayoutLine | text, width, start, and end. |
LayoutLineRange | width, start, and end. |
Rich Inline API Reference
| API | Description |
|---|---|
prepareRichInline(items) | Prepares a flat array of RichInlineItem objects and returns PreparedRichInline. |
layoutNextRichInlineLineRange(prepared, maxWidth, start?) | Returns one non-materialized rich inline line range at a time. |
walkRichInlineLineRanges(prepared, maxWidth, onLine) | Walks rich inline line ranges and returns the line count. |
materializeRichInlineLineRange(prepared, line) | Builds a RichInlineLine from a previously computed line range. |
measureRichInlineStats(prepared, maxWidth) | Returns the line count and widest rich inline line without materializing fragment text. |
RichInlineItem Properties
| Property | Type / Default | Description |
|---|---|---|
text | string / required | Raw inline text, including leading or trailing collapsible spaces. |
font | string / required | Canvas font shorthand for this item. |
letterSpacing | number / 0 | Extra horizontal spacing between graphemes in CSS pixels. |
break | 'normal', 'never' / 'normal' | Keeps the item atomic when set to 'never'. |
extraWidth | number / 0 | Reserves horizontal width for padding, borders, or other caller-owned chrome. |
Rich Inline Public Types
| Type | Fields / Purpose |
|---|---|
PreparedRichInline | Opaque handle returned by prepareRichInline(). |
RichInlineCursor | itemIndex, segmentIndex, and graphemeIndex. |
RichInlineFragment | itemIndex, text, gapBefore, occupiedWidth, start, and end. |
RichInlineFragmentRange | itemIndex, gapBefore, occupiedWidth, start, and end. |
RichInlineLine | fragments: RichInlineFragment[], width, and end. |
RichInlineLineRange | fragments: RichInlineFragmentRange[], width, and end. |
RichInlineStats | lineCount: number, maxLineWidth: number. |
FAQs
Q: Should I call prepare() after every resize?
A: No. Reuse the prepared value when the text, font, and preparation options have not changed. Pass the new width to layout().
Q: Why do Pretext measurements differ from DOM text?
A: Check the Canvas font shorthand, line height, font fallback, and CSS text settings outside the Canvas font shorthand. On macOS, use a specific font family when accurate matching is important.
Q: Does Pretext handle right-to-left and mixed-direction text?
A: Pretext uses browser-oriented line breaking and exposes approximate bidi metadata for custom rendering. The metadata is not a complete Unicode Bidirectional Algorithm implementation, and measured segment widths do not provide exact per-character positions for Arabic or mixed-direction text.
Q: Can Pretext lay out mentions, chips, and mixed-font inline text?
A: Yes. Use @chenglou/pretext/rich-inline. The helper works with flat inline items under white-space: normal.
Q: My app cycles through many fonts and the cache keeps growing. What should I do?
A: Call clearCache() when you need to release accumulated internal caches. Future text preparation rebuilds the required measurements.
Changelog
v0.0.9 – September 7, 2026
- Fixed streaming line layout and line statistics around soft hyphens.
- Fixed terminal soft-hyphen behavior and letter spacing in rich line APIs.
- Reset rich bidi metadata independently at paragraph boundaries.
- Improved rich-inline handling for empty and zero-width items.
- Reduced repeated work in long rich-inline, hyphenated, and font-size inputs.
- Improved wrapping for long symbol runs, numeric minus signs, and CJK plus ASCII hyphen cases.
v0.0.8 – June 11, 2026
- Added declaration maps to the published package.
- Improved wrapping for Unicode symbol runs and long hyphenated text.
v0.0.7 – May 10, 2026
- Marked the package as side-effect-free for bundler tree shaking.
- Reduced redundant chunk lookup in
layoutNextLine()andlayoutNextLineRange(). - Improved
keep-all, punctuation, numeric affix, soft-hyphen, letter-spacing, and rich-inline wrapping behavior.
v0.0.6 – April 22, 2026
- Added numeric CSS pixel
letterSpacingto the main and rich-inline preparation APIs. - Improved CJK wrapping before opening bracket annotations.
v0.0.5 – April 9, 2026
- Added
measureLineStats(),measureNaturalWidth(),layoutNextLineRange(), andmaterializeLineRange(). - Added the
@chenglou/pretext/rich-inlineentry point. - Added
wordBreak: 'keep-all'for CJK and Hangul text.
v0.0.4 – April 2, 2026
- Added a justification comparison demo.
- Improved rich layout performance and narrow-width line-breaking behavior.
Alternatives & Related Resources
- High-Performance Text Wrapping for Virtualized UIs – uWrap
- Render Plain/Rich Text On HTML Canvas – text-to-canvas
- Enhance Text Readability with Balance Text JavaScript Library







