Fast DOM-Free Text Height Measurement – Pretext

Fast DOM-Free Text Height Measurement – Pretext
Fast DOM-Free Text Height Measurement – Pretext
Pretext is a JavaScript library that measures multiline text height and computes per-line layout data before rendering. It runs its own text segmentation, Unicode line-breaking, and glyph measurement logic through the browser’s Canvas API, using the native font engine as its measurement source.

The library enables you to handle auto-growing UI, virtualization, custom Canvas rendering, and shape-aware text flow with far less layout churn than repeated DOM reads. Ideal for layout patterns that still feel awkward or incomplete in CSS alone, such as shrink-wrapped chat bubbles, streaming rendering in AI Assistants, dynamic magazine spreads, obstacle-aware text flow, or text blocks routed through Canvas and SVG.

More Features:

  • Cache text metrics for fast repeated layout.
  • Support mixed scripts, emoji, and bidi text.
  • Preserve tabs, spaces, and hard breaks in pre-wrap mode.
  • Return wrapped lines for manual rendering.
  • Return line ranges for low-level width probing.
  • Route text one line at a time across changing widths.
  • Clear shared caches when font usage grows.
  • Switch locale for future text preparation.
  • Render the output in DOM, Canvas, SVG, and custom engines.

How to Use It:

1. Install Pretext with NPM.

npm install @chenglou/pretext

2. Measure paragraph height. Call prepare() once per unique text-and-font combination, then call layout() on every resize.

import { prepare, layout } from '@chenglou/pretext'

// One-time pass: segments text, applies line-break rules, measures glyph widths via canvas
const prepared = prepare('Your product description goes here. It wraps at the container edge.', '16px Roboto')

// Pure arithmetic: sums cached widths and counts lines at 300px wide with a 22px line height
const { height, lineCount } = layout(prepared, 300, 22)

console.log(`Paragraph height: ${height}px — ${lineCount} lines`)

// On a resize event, only call layout() again — prepare() is already done
window.addEventListener('resize', () => {
  const containerWidth = document.getElementById('card')!.clientWidth
  const { height: newHeight } = layout(prepared, containerWidth, 22)
  console.log(`Updated height: ${newHeight}px`)
})

3. Pre-wrap mode for textarea content. Pass { whiteSpace: 'pre-wrap' } when tabs and hard newlines must be preserved, such as in a live Markdown editor or a comment input.

import { prepare, layout } from '@chenglou/pretext'

const rawInput = 'Line onenLine twothas a tab stopnLine three'

// pre-wrap mode: spaces, tabs (t), and newlines (n) are preserved, not collapsed
const prepared = prepare(rawInput, '14px "Courier New"', { whiteSpace: 'pre-wrap' })

const { height } = layout(prepared, 480, 20)
console.log(`Textarea height: ${height}px`)

4. Render to canvas with layoutWithLines. Switch to prepareWithSegments when you need the actual text string and measured width of each line. This path suits canvas, SVG, and WebGL rendering.

import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext'

const paragraph = 'Multilingual text: 春天到了, بدأت الرحلة, and a rocket 🚀'

// prepareWithSegments returns a richer data structure needed for line-level APIs
const prepared = prepareWithSegments(paragraph, '18px Arial')

// layoutWithLines gives you height, lineCount, and each line's text string and width
const { lines, lineCount } = layoutWithLines(prepared, 320, 26)

const canvas = document.getElementById('output') as HTMLCanvasElement
const ctx = canvas.getContext('2d')!
ctx.font = '18px Arial'

for (let i = 0; i < lines.length; i++) {
  // lines[i].text is the full string for that line; lines[i].width is its measured pixel width
  ctx.fillText(lines[i].text, 10, (i + 1) * 26)
}

5. Shrink-wrap a container to its text with walkLineRanges. It fires a callback per line with width and cursor data, but never builds the line text strings.

import { prepareWithSegments, walkLineRanges, layoutWithLines } from '@chenglou/pretext'

const prepared = prepareWithSegments('A short chat bubble that should shrink to its content.', '15px Georgia')

let maxLineWidth = 0

// Speculative pass at 600px: collects the widest measured line width
walkLineRanges(prepared, 600, line => {
  if (line.width > maxLineWidth) maxLineWidth = line.width
})

// maxLineWidth is now the tightest container width that still fits every line
console.log(`Shrink-wrap width: ${maxLineWidth}px`)

// Final pass at the confirmed width to retrieve line strings
const { lines } = layoutWithLines(prepared, maxLineWidth, 22)

6. Float-aware text flow with layoutNextLine. Pass a different maxWidth on each call. This is great for text that flows around a floated image or any column where the available width changes by row.

import { prepareWithSegments, layoutNextLine } from '@chenglou/pretext'

const prepared = prepareWithSegments(
  'This article body wraps around a thumbnail image on the left side of the column.',
  '16px "Open Sans"'
)

const columnWidth = 500
const floatWidth = 140  // image occupies 140px of horizontal space
const floatBottom = 100 // image extends 100px down from the top

// Initialize the cursor at the very start of the prepared text
let cursor = { segmentIndex: 0, graphemeIndex: 0 }
let y = 0

const canvas = document.getElementById('article') as HTMLCanvasElement
const ctx = canvas.getContext('2d')!
ctx.font = '16px "Open Sans"'

while (true) {
  // Use a narrower width while rendering beside the floated image
  const lineWidth = y < floatBottom ? columnWidth - floatWidth : columnWidth
  const xOffset = y < floatBottom ? floatWidth : 0

  const line = layoutNextLine(prepared, cursor, lineWidth)
  if (line === null) break // paragraph is fully exhausted

  ctx.fillText(line.text, xOffset, y + 16)

  cursor = line.end // advance to the next line's start position
  y += 22
}

7. API methods.

// Analyze text once and return an opaque prepared handle for fast height layout
prepare(text, font, options)

// Calculate total height and line count from a prepared value
layout(prepared, maxWidth, lineHeight)

// Analyze text and keep richer segment data for manual line layout
prepareWithSegments(text, font, options)

// Calculate height, line count, and full wrapped lines for a fixed width
layoutWithLines(prepared, maxWidth, lineHeight)

// Walk each line range for a fixed width without building text strings
walkLineRanges(prepared, maxWidth, onLine)

// Return the next wrapped line from a cursor position, or null at the end
layoutNextLine(prepared, start, maxWidth)

// Clear Pretext's shared internal caches
clearCache()

// Set the locale for future prepare calls and clear the shared cache
setLocale(locale)

8. Return types:

type LayoutLine = {
  text: string
  width: number
  start: LayoutCursor
  end: LayoutCursor
}

type LayoutLineRange = {
  width: number
  start: LayoutCursor
  end: LayoutCursor
}

type LayoutCursor = {
  segmentIndex: number
  graphemeIndex: number
}

FAQs:

Q: Should I call prepare() on every resize?
A: No. Call prepare() once for the same text and config. Re-run layout() on resize. That is where the performance model pays off.

Q: Can I use Pretext for Canvas and SVG rendering?
A: Yes. layoutWithLines() works well for fixed-width blocks, and layoutNextLine() works well for variable-width flows.

Q: Why do my measured results drift from the DOM?
A: Check your font shorthand first. Then check your lineHeight. Both values need to match your real rendered text. On macOS, avoid system-ui if you want stable results.

Q: When should I call prepare() again vs. calling only layout() again?
A: Call prepare() once per unique combination of text content and font string. On viewport resize, call only layout() with the updated maxWidth. Calling prepare() again on unchanged text repeats the canvas measurement work and throws away the cached glyph data.

Q: My app cycles through many fonts and memory keeps climbing. How do I fix it?
A: Call clearCache() periodically. Pretext accumulates glyph width measurements keyed by font, and a long-running app that cycles through many font variants will grow that cache over time. clearCache() resets it fully.

Q: How does Pretext handle right-to-left and mixed-direction text?
A: Bidirectional text runs through the library’s internal bidi algorithm and Unicode segmentation. You pass the text as a plain string. Direction, script detection, and correct line widths are all handled internally.

The post Fast DOM-Free Text Height Measurement – Pretext appeared first on CSS Script.


Discover more from RSS Feeds Cloud

Subscribe to get the latest posts sent to your email.

Leave a Reply

Your email address will not be published. Required fields are marked *

Discover more from RSS Feeds Cloud

Subscribe now to keep reading and get access to the full archive.

Continue reading