Trending-skills pretext-text-measurement

Fast, accurate, DOM-free text measurement and layout library for JavaScript/TypeScript supporting multiline, rich-text, and variable-width layouts.

install
source · Clone the upstream repo
git clone https://github.com/Aradotso/trending-skills
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/Aradotso/trending-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/pretext-text-measurement" ~/.claude/skills/aradotso-trending-skills-pretext-text-measurement && rm -rf "$T"
manifest: skills/pretext-text-measurement/SKILL.md
source content

Pretext Text Measurement & Layout

Skill by ara.so — Daily 2026 Skills collection.

Pretext is a pure JavaScript/TypeScript library for fast, accurate, DOM-free multiline text measurement and layout. It avoids

getBoundingClientRect
and
offsetHeight
(which trigger expensive layout reflows) by implementing its own measurement logic using the browser's font engine as ground truth.

Installation

npm install @chenglou/pretext

Core Concepts

  • prepare()
    /
    prepareWithSegments()
    — one-time analysis: normalize whitespace, segment text, measure via canvas. Cache and reuse this result.
  • layout()
    /
    layoutWithLines()
    etc.
    — cheap hot path: pure arithmetic over cached widths. Call this on every resize, not
    prepare()
    .
  • Font string format — same as
    CanvasRenderingContext2D.font
    , e.g.
    '16px Inter'
    ,
    '700 18px "Helvetica Neue"'
    .

Use Case 1: Measure Paragraph Height (No DOM)

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

// One-time per unique (text, font) combination
const prepared = prepare('AGI 春天到了. بدأت الرحلة 🚀', '16px Inter')

// Cheap — call on every resize
const { height, lineCount } = layout(prepared, containerWidth, 20)
// height: total pixel height; lineCount: number of wrapped lines

With Pre-wrap (textarea-like)

const prepared = prepare(textareaValue, '16px Inter', { whiteSpace: 'pre-wrap' })
const { height } = layout(prepared, textareaWidth, 24)

With CJK keep-all

const prepared = prepare(cjkText, '16px NotoSansCJK', { wordBreak: 'keep-all' })
const { height, lineCount } = layout(prepared, 300, 22)

Use Case 2: Manual Line Layout

Get All Lines at Fixed Width

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

const prepared = prepareWithSegments('Hello world, this is Pretext!', '18px "Helvetica Neue"')
const { lines, height, lineCount } = layoutWithLines(prepared, 320, 26)

// Render to canvas
lines.forEach((line, i) => {
  ctx.fillText(line.text, 0, i * 26)
})
// line shape: { text: string, width: number, start: LayoutCursor, end: LayoutCursor }

Line Stats Without Building Strings

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

const prepared = prepareWithSegments(article, '16px Inter')

// Just counts and widths — no string allocations
const { lineCount, maxLineWidth } = measureLineStats(prepared, 320)

// Walk line ranges for custom logic
let widest = 0
walkLineRanges(prepared, 320, line => {
  if (line.width > widest) widest = line.width
})
// widest is now the tightest container that still fits the text (shrinkwrap!)

Natural Width (No Wrap Constraint)

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

const prepared = prepareWithSegments('Short label', '14px Inter')
const naturalWidth = measureNaturalWidth(prepared)
// Width if text never wraps — useful for button sizing

Variable-Width Layout (Text Around Floated Image)

import {
  prepareWithSegments,
  layoutNextLineRange,
  materializeLineRange,
  type LayoutCursor
} from '@chenglou/pretext'

const prepared = prepareWithSegments(article, '16px Inter')
let cursor: LayoutCursor = { segmentIndex: 0, graphemeIndex: 0 }
let y = 0
const lineHeight = 24
const image = { bottom: 200, width: 120 }
const columnWidth = 600

while (true) {
  // Lines beside the image are narrower
  const width = y < image.bottom ? columnWidth - image.width : columnWidth
  const range = layoutNextLineRange(prepared, cursor, width)
  if (range === null) break

  const line = materializeLineRange(prepared, range)
  ctx.fillText(line.text, 0, y)
  cursor = range.end
  y += lineHeight
}

Iterator API (Fixed Width, With Text Strings)

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

const prepared = prepareWithSegments(text, '16px Inter')
let cursor: LayoutCursor = { segmentIndex: 0, graphemeIndex: 0 }

let line = layoutNextLine(prepared, cursor, 400)
while (line !== null) {
  console.log(line.text, line.width)
  cursor = line.end
  line = layoutNextLine(prepared, cursor, 400)
}

Use Case 3: Rich Inline Text Flow

For mixed fonts, chips, @mentions, and inline code spans:

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 feature", font: '500 17px Inter' },
  { text: 'urgent', font: '600 12px Inter', break: 'never', extraWidth: 16 },
])

walkRichInlineLineRanges(prepared, 320, range => {
  const line = materializeRichInlineLineRange(prepared, range)
  line.fragments.forEach(frag => {
    // frag: { itemIndex, text, gapBefore, occupiedWidth, start, end }
    const item = items[frag.itemIndex]
    ctx.font = item.font
    ctx.fillText(frag.text, x + frag.gapBefore, y)
  })
})

Rich Inline Stats

import { prepareRichInline, measureRichInlineStats } from '@chenglou/pretext/rich-inline'

const prepared = prepareRichInline(items)
const { lineCount, maxLineWidth } = measureRichInlineStats(prepared, containerWidth)

Common Patterns

Virtualized List Row Heights

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

// Pre-measure all items before virtualization
const rowHeights = items.map(item => {
  const prepared = prepare(item.text, '14px Inter')
  const { height } = layout(prepared, LIST_WIDTH, 20)
  return height
})

Binary Search for Balanced Text Width

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

function findBalancedWidth(text: string, font: string, maxWidth: number): number {
  const prepared = prepareWithSegments(text, font)
  const { lineCount: targetLines } = measureLineStats(prepared, maxWidth)

  let lo = 1, hi = maxWidth
  while (hi - lo > 1) {
    const mid = (lo + hi) / 2
    const { lineCount } = measureLineStats(prepared, mid)
    if (lineCount <= targetLines) hi = mid
    else lo = mid
  }
  return hi
}

Resize Handler Pattern

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

// Prepare ONCE per text/font change
let prepared = prepareWithSegments(text, '16px Inter')

function onResize(containerWidth: number) {
  // layout() is cheap — safe to call on every resize event
  const { lines } = layoutWithLines(prepared, containerWidth, 24)
  renderLines(lines)
}

// Only re-prepare when text or font changes
function onTextChange(newText: string) {
  prepared = prepareWithSegments(newText, '16px Inter')
  onResize(currentWidth)
}

Prevent Layout Shift on Dynamic Content

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

async function loadAndRender(containerId: string, width: number) {
  const container = document.getElementById(containerId)!
  const text = await fetchText()

  // Measure BEFORE inserting into DOM — no reflow needed
  const prepared = prepare(text, '16px Inter')
  const { height } = layout(prepared, width, 24)

  // Reserve space first to prevent layout shift
  container.style.height = `${height}px`
  container.textContent = text
}

API Quick Reference

Use Case 1 (Height Only)

FunctionDescription
prepare(text, font, opts?)
One-time analysis, returns
PreparedText
layout(prepared, maxWidth, lineHeight)
Returns
{ height, lineCount }

Use Case 2 (Manual Layout)

FunctionDescription
prepareWithSegments(text, font, opts?)
One-time analysis, returns
PreparedTextWithSegments
layoutWithLines(prepared, maxWidth, lineHeight)
Returns
{ height, lineCount, lines[] }
walkLineRanges(prepared, maxWidth, onLine)
Calls
onLine
per line, no string allocs
measureLineStats(prepared, maxWidth)
Returns
{ lineCount, maxLineWidth }
only
measureNaturalWidth(prepared)
Width if text never wraps
layoutNextLineRange(prepared, cursor, maxWidth)
Iterator — one range at a time, variable width
layoutNextLine(prepared, cursor, maxWidth)
Iterator — one line + text at a time
materializeLineRange(prepared, range)
Range →
LayoutLine
with text string

Options

{
  whiteSpace?: 'normal' | 'pre-wrap'  // default: 'normal'
  wordBreak?: 'normal' | 'keep-all'   // default: 'normal'
}

Troubleshooting

Text height is wrong / doesn't match browser rendering

  • Ensure
    font
    string exactly matches your CSS
    font
    shorthand (weight, style, size, family all matter).
  • Ensure
    lineHeight
    matches your CSS
    line-height
    in pixels.
  • Font must be loaded before calling
    prepare()
    — use
    document.fonts.ready
    or
    FontFace.load()
    .

prepare()
is slow on every resize

  • Only call
    prepare()
    when text or font changes. For resizes, only call
    layout()
    or equivalent.

Canvas not available (SSR / Node)

  • Server-side support is listed as "coming soon". For now, this library requires a browser environment (canvas API).

CJK text not wrapping correctly

  • Try
    { wordBreak: 'keep-all' }
    for Korean/Chinese text that should not break mid-word.

Rich inline items breaking when they should be atomic

  • Add
    break: 'never'
    to the
    RichInlineItem
    for chips, mentions, badges.

Getting widest line for shrinkwrap containers

  • Use
    measureLineStats(prepared, maxWidth).maxLineWidth
    or walk with
    walkLineRanges
    and track the max
    line.width
    .