From Pretext Usage Skill
How to use @chenglou/pretext for DOM-free multiline text measurement and line layout. Use when measuring paragraph height without touching the DOM (avoiding getBoundingClientRect/offsetHeight reflow), wrapping or laying out text to canvas/SVG/WebGL/server-side, computing line counts or a multiline shrink-wrap width, virtualizing long text lists, flowing text around obstacles, or laying out inline rich text with chips/mentions. Triggers on `prepare`/`layout`/`prepareWithSegments`/`layoutWithLines`/`walkLineRanges`, the `@chenglou/pretext` or `@chenglou/pretext/rich-inline` imports, or any "measure text height in JS without the DOM" task.
How this skill is triggered — by the user, by Claude, or both
Slash command
/pretext-skill:pretextThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Pure JS/TS library for **multiline text measurement and layout** with no DOM
Pure JS/TS library for multiline text measurement and layout with no DOM
reflow. It does its own line breaking and uses the browser's canvas measureText
as the ground-truth font engine. Output is the wrapped height, line count,
per-line widths/cursors, and (on request) the line text.
Use it to predict text height/line-count before paint, virtualize long lists, shrink-wrap a container to its text, or render text to canvas/SVG/WebGL.
Everything flows from a two-phase split. Internalize this before anything else.
| Phase | Function | Cost | When |
|---|---|---|---|
| Analyze + measure | prepare() / prepareWithSegments() | Expensive: normalize, segment, glue rules, canvas measurement | Once per (text, font, options) |
| Fit to width | layout(), layoutWithLines(), walkLineRanges(), … | Cheap: pure arithmetic over cached widths | Every resize / every width you test |
The golden rule: never re-run prepare() for the same text + font + options.
On resize, re-run only layout(). Re-preparing throws away the entire point of
the library (the precomputed measurement pass). Cache the prepared handle keyed
on (text, font, options).
import { prepare, layout } from '@chenglou/pretext'
const prepared = prepare('AGI 春天到了. بدأت الرحلة 🚀', '16px Inter') // once
const { height, lineCount } = layout(prepared, 320, 20) // every resize
Pick the smallest API that answers your question. Three families:
Fast path — you only need height/line-count. prepare() → layout().
The handle is opaque; it deliberately carries no segment data so the hot path
stays allocation-light. This is the default. Use it for: predicting block
height, occlusion/virtualization math, dev-time "does this label overflow?"
checks, preventing layout shift.
Rich manual layout — you render the lines yourself (canvas/SVG/WebGL/server).
prepareWithSegments() → then one of:
layoutWithLines(p, w, h) — all lines at one fixed width, with text. The
high-level choice when width is constant.walkLineRanges(p, w, onLine) — line widths + cursors, no string
allocation. Use for stats, shrink-wrap, and speculative width probing.measureLineStats(p, w) → { lineCount, maxLineWidth } — counts only, no
line/string allocation.measureNaturalWidth(p) — widest forced line (hard breaks still count),
when width is not what's causing wraps.layoutNextLineRange(p, cursor, w) / layoutNextLine(p, cursor, w) —
variable width per line (flow around a float, ragged columns). Feed the
previous range's end cursor as the next start. null = paragraph done.materializeLineRange(p, range) — turn a range (from walkLineRanges or
layoutNextLineRange) back into { text, width, start, end } when you
finally need the string.Inline rich text — chips, mentions, code spans, mixed fonts on one flowing
line. Import from @chenglou/pretext/rich-inline: prepareRichInline() →
walkRichInlineLineRanges() / layoutNextRichInlineLineRange() /
measureRichInlineStats() → materializeRichInlineLineRange(). Intentionally
narrow: inline-only, white-space: normal only, not a markup tree.
Decision shortcut:
prepare + layout.prepareWithSegments + layoutWithLines.walkLineRanges or measureLineStats (no strings).layoutNextLineRange in a loop.rich-inline helper.Pretext matches the browser only if you feed it the same inputs the browser uses. These are the most common ways to get wrong numbers:
font must match your CSS exactly, in canvas-shorthand form (same string
you'd assign to ctx.font), e.g. '600 16px Inter'. A mismatched weight or
size silently shifts every width.lineHeight passed to layout() must match your CSS line-height. It's a
layout-time input on purpose — prepare() does horizontal-only work.letterSpacing must match CSS letter-spacing as a numeric px value, set in
prepare() options (not at layout time).system-ui. Canvas and DOM resolve it to different fonts on
macOS, so layout() accuracy breaks. Use a named font (Inter,
'Helvetica Neue', …).prepare(), or canvas measures
a fallback. await document.fonts.ready (or document.fonts.load(font)) first.layout() returns { lineCount: 0, height: 0 }. Browsers
still size an empty block to one line. If you need that, clamp:
Math.max(1, lineCount) * lineHeight.Supported CSS surface — the common app-text setup:
white-space: normal (default) and pre-wrap ({ whiteSpace: 'pre-wrap' },
editor/textarea-oriented: keeps spaces, \t tabs at tab-size: 8, and \n).word-break: normal and keep-all ({ wordBreak: 'keep-all' }, for CJK/Hangul
and CJK-leading no-space mixed runs).overflow-wrap: break-word — overlong words still break at narrow widths, but
only at grapheme boundaries.line-break: auto. before prepare(); a
chosen break materializes a trailing -, an unchosen one stays invisible).Not modeled / out of scope:
font shorthand: font-optical-sizing,
font-feature-settings, standalone font-variation-settings. Variable-font
axes only count when reflected in the font string (e.g. via weight).Intl.Segmenter or Canvas 2D measureText are unsupported.const prepared = prepare(comment.body, '16px Inter')
const { height } = layout(prepared, containerWidth, 24)
reserveSpace(height) // anchor scroll, avoid jump when text paints
const prepared = prepare(text, font, opts) // ONCE — cache this
function onResize(width: number) {
return layout(prepared, width, lineHeight) // cheap arithmetic, call freely
}
walkLineRanges gives widths without building strings, so it's cheap to probe
many widths (e.g. binary-search a "balanced" width), then call layoutWithLines
once at the width you like.
import { prepareWithSegments, walkLineRanges } from '@chenglou/pretext'
const p = prepareWithSegments(label, '14px Inter')
let widest = 0
walkLineRanges(p, maxWidth, line => { if (line.width > widest) widest = line.width })
// `widest` is the narrowest container that still fits — long missing from the web
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext'
const p = prepareWithSegments(text, '18px "Helvetica Neue"')
const { lines } = layoutWithLines(p, 320, 26)
lines.forEach((l, i) => ctx.fillText(l.text, 0, i * 26))
import { layoutNextLineRange, materializeLineRange, prepareWithSegments, type LayoutCursor } from '@chenglou/pretext'
const p = prepareWithSegments(article, BODY_FONT)
let cursor: LayoutCursor = { segmentIndex: 0, graphemeIndex: 0 }
let y = 0
while (true) {
const width = y < image.bottom ? columnWidth - image.width : columnWidth
const range = layoutNextLineRange(p, cursor, width)
if (range === null) break
ctx.fillText(materializeLineRange(p, range).text, 0, y)
cursor = range.end
y += 26
}
Use measureLineStats (no strings, no line objects) to compute each item's height
for the scroll offsets, and only layoutWithLines the items actually on screen.
import { measureLineStats } from '@chenglou/pretext'
const { lineCount } = measureLineStats(prepared, width)
const itemHeight = Math.max(1, lineCount) * lineHeight
import { prepareRichInline, walkRichInlineLineRanges, materializeRichInlineLineRange } from '@chenglou/pretext/rich-inline'
const p = prepareRichInline([
{ text: 'Ship ', font: '500 17px Inter' },
{ text: '@maya', font: '700 12px Inter', break: 'never', extraWidth: 22 }, // chip: never splits, owns its padding/border px
{ text: "'s note", font: '500 17px Inter' },
])
walkRichInlineLineRanges(p, 320, range => {
const line = materializeRichInlineLineRange(p, range)
// each fragment keeps its source itemIndex, text slice, gapBefore, cursors
})
font, lineHeight, letterSpacing with the actual CSS of the text.document.fonts.ready) before prepare().walkLineRanges, measureLineStats,
layoutNextLineRange) whenever you don't need the line strings — they skip
string allocation, which matters in virtualization and width-probing loops.LayoutCursor is a segment/grapheme cursor, not a string offset. Don't
reconstruct line offsets from line.text.length; thread cursors instead.Math.max(1, lineCount) if you want browser-like
one-line minimum height.clearCache() if your app cycles through many fonts/text variants and you
want to release accumulated cache. setLocale(locale?) before preparing new
text if you need a specific Intl.Segmenter locale; it also clears caches and
does not mutate already-prepared handles.system-ui
accuracy — those are explicitly out of scope.The authoritative API glossary, type definitions, and caveats live in the
package README.md (the project treats it as the public source of truth). When
in doubt about a signature or an edge case, read README.md rather than guessing,
and prefer it over older blog/example snippets that may predate the current
0.0.x API.
Creates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub guynachshon/pretext-claude-skill --plugin pretext-skill