Fast DOM-Free Text Height Measurement – Pretext

Category: Javascript , Layout , Recommended , Text | September 13, 2026
Authorchenglou
Last UpdateSeptember 13, 2026
LicenseMIT
Views41 views
Fast DOM-Free Text Height Measurement – Pretext

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: normal and pre-wrap behavior.
  • word-break: normal and keep-all behavior.
  • Numeric CSS pixel letterSpacing values.
  • 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().

OptionValues / DefaultDescription
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.
letterSpacingnumber / 0Sets 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

APIDescription
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

TypeFields / Purpose
PrepareOptionswhiteSpace?, wordBreak?, and letterSpacing?.
WordBreakMode'normal' or 'keep-all'.
PreparedTextOpaque handle returned by prepare().
PreparedTextWithSegmentsManual-layout handle returned by prepareWithSegments(). It exposes segments: string[] and approximate segLevels bidi metadata for custom rendering.
LayoutResultlineCount: number, height: number.
LayoutLinesResultLayoutResult plus lines: LayoutLine[].
LineStatslineCount: number, maxLineWidth: number.
LayoutCursorsegmentIndex: number, graphemeIndex: number.
LayoutLinetext, width, start, and end.
LayoutLineRangewidth, start, and end.

Rich Inline API Reference

APIDescription
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

PropertyType / DefaultDescription
textstring / requiredRaw inline text, including leading or trailing collapsible spaces.
fontstring / requiredCanvas font shorthand for this item.
letterSpacingnumber / 0Extra horizontal spacing between graphemes in CSS pixels.
break'normal', 'never' / 'normal'Keeps the item atomic when set to 'never'.
extraWidthnumber / 0Reserves horizontal width for padding, borders, or other caller-owned chrome.

Rich Inline Public Types

TypeFields / Purpose
PreparedRichInlineOpaque handle returned by prepareRichInline().
RichInlineCursoritemIndex, segmentIndex, and graphemeIndex.
RichInlineFragmentitemIndex, text, gapBefore, occupiedWidth, start, and end.
RichInlineFragmentRangeitemIndex, gapBefore, occupiedWidth, start, and end.
RichInlineLinefragments: RichInlineFragment[], width, and end.
RichInlineLineRangefragments: RichInlineFragmentRange[], width, and end.
RichInlineStatslineCount: 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() and layoutNextLineRange().
  • 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 letterSpacing to the main and rich-inline preparation APIs.
  • Improved CJK wrapping before opening bracket annotations.

v0.0.5 – April 9, 2026

  • Added measureLineStats(), measureNaturalWidth(), layoutNextLineRange(), and materializeLineRange().
  • Added the @chenglou/pretext/rich-inline entry 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

You Might Be Interested In:


Leave a Reply