pretext-text-measurement

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Pretext Text Measurement & Layout

Pretext 文本测量与布局

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.
Skill 由 ara.so 提供 — 2026年度日常技能合集。
Pretext是纯JavaScript/TypeScript库,可实现快速、精准、无需DOM的多行文本测量与布局。它基于浏览器字体引擎作为基准自研测量逻辑,避免使用
getBoundingClientRect
offsetHeight
(这两个API会触发开销极高的布局重排)。

Installation

安装

sh
npm install @chenglou/pretext
sh
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"'
    .
  • prepare()
    /
    prepareWithSegments()
    — 一次性分析:标准化空白字符、拆分文本段、通过canvas测量。可缓存并复用该结果。
  • layout()
    /
    layoutWithLines()
    — 低开销热路径:基于缓存的宽度做纯算术计算。可在每次容器缩放时调用,不要每次都调用
    prepare()
  • 字体字符串格式 — 和
    CanvasRenderingContext2D.font
    的格式完全一致,例如
    '16px Inter'
    '700 18px "Helvetica Neue"'

Use Case 1: Measure Paragraph Height (No DOM)

场景1:无需DOM测量段落高度

ts
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
ts
import { prepare, layout } from '@chenglou/pretext'

// 每个唯一的(文本,字体)组合只需执行一次
const prepared = prepare('AGI 春天到了. بدأت الرحلة 🚀', '16px Inter')

// 低开销 — 可在每次容器缩放时调用
const { height, lineCount } = layout(prepared, containerWidth, 20)
// height: 总像素高度; lineCount: 换行后的行数

With Pre-wrap (textarea-like)

保留换行(类似textarea)

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

With CJK keep-all

CJK 整词不拆分

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

Use Case 2: Manual Line Layout

场景2:手动行布局

Get All Lines at Fixed Width

获取固定宽度下的所有行内容

ts
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 }
ts
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)

// 渲染到canvas
lines.forEach((line, i) => {
  ctx.fillText(line.text, 0, i * 26)
})
// 行结构: { text: string, width: number, start: LayoutCursor, end: LayoutCursor }

Line Stats Without Building Strings

不生成字符串获取行统计数据

ts
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!)
ts
import { prepareWithSegments, measureLineStats, walkLineRanges } from '@chenglou/pretext'

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

// 仅统计行数和宽度 — 无字符串内存分配
const { lineCount, maxLineWidth } = measureLineStats(prepared, 320)

// 遍历行范围实现自定义逻辑
let widest = 0
walkLineRanges(prepared, 320, line => {
  if (line.width > widest) widest = line.width
})
// widest 现在是可容纳所有文本的最小容器宽度(自适应收缩!)

Natural Width (No Wrap Constraint)

自然宽度(无换行约束)

ts
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
ts
import { prepareWithSegments, measureNaturalWidth } from '@chenglou/pretext'

const prepared = prepareWithSegments('Short label', '14px Inter')
const naturalWidth = measureNaturalWidth(prepared)
// 文本不换行时的宽度 — 适用于按钮尺寸计算

Variable-Width Layout (Text Around Floated Image)

可变宽度布局(文本环绕浮动图片)

ts
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
}
ts
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) {
  // 图片旁的行宽度更窄
  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)

迭代器API(固定宽度,返回文本字符串)

ts
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)
}
ts
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

场景3:富文本内联流

For mixed fonts, chips, @mentions, and inline code spans:
ts
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)
  })
})
适用于混合字体、标签、@提及和内联代码片段:
ts
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

富文本内联统计

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

const prepared = prepareRichInline(items)
const { lineCount, maxLineWidth } = measureRichInlineStats(prepared, containerWidth)
ts
import { prepareRichInline, measureRichInlineStats } from '@chenglou/pretext/rich-inline'

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

Common Patterns

常用模式

Virtualized List Row Heights

虚拟列表行高计算

ts
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
})
ts
import { prepare, layout } from '@chenglou/pretext'

// 虚拟渲染前预测量所有元素高度
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

二分查找平衡文本宽度

ts
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
}
ts
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

容器缩放处理模式

ts
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)
}
ts
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext'

// 仅在文本/字体变更时执行一次prepare
let prepared = prepareWithSegments(text, '16px Inter')

function onResize(containerWidth: number) {
  // layout()开销极低 — 可安全在每次缩放事件中调用
  const { lines } = layoutWithLines(prepared, containerWidth, 24)
  renderLines(lines)
}

// 仅当文本或字体变更时重新执行prepare
function onTextChange(newText: string) {
  prepared = prepareWithSegments(newText, '16px Inter')
  onResize(currentWidth)
}

Prevent Layout Shift on Dynamic Content

避免动态内容布局偏移

ts
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
}
ts
import { prepare, layout } from '@chenglou/pretext'

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

  // 插入DOM前先测量 — 无需重排
  const prepared = prepare(text, '16px Inter')
  const { height } = layout(prepared, width, 24)

  // 先预留空间避免布局偏移
  container.style.height = `${height}px`
  container.textContent = text
}

API Quick Reference

API 速查

Use Case 1 (Height Only)

场景1(仅需高度)

FunctionDescription
prepare(text, font, opts?)
One-time analysis, returns
PreparedText
layout(prepared, maxWidth, lineHeight)
Returns
{ height, lineCount }
函数描述
prepare(text, font, opts?)
一次性分析,返回
PreparedText
layout(prepared, maxWidth, lineHeight)
返回
{ height, lineCount }

Use Case 2 (Manual Layout)

场景2(手动布局)

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
函数描述
prepareWithSegments(text, font, opts?)
一次性分析,返回
PreparedTextWithSegments
layoutWithLines(prepared, maxWidth, lineHeight)
返回
{ height, lineCount, lines[] }
walkLineRanges(prepared, maxWidth, onLine)
每行调用
onLine
,无字符串分配
measureLineStats(prepared, maxWidth)
仅返回
{ lineCount, maxLineWidth }
measureNaturalWidth(prepared)
文本无换行时的宽度
layoutNextLineRange(prepared, cursor, maxWidth)
迭代器 — 每次返回一个行范围,支持可变宽度
layoutNextLine(prepared, cursor, maxWidth)
迭代器 — 每次返回一行数据+文本
materializeLineRange(prepared, range)
将行范围转换为带文本的
LayoutLine
对象

Options

配置项

ts
{
  whiteSpace?: 'normal' | 'pre-wrap'  // default: 'normal'
  wordBreak?: 'normal' | 'keep-all'   // default: 'normal'
}
ts
{
  whiteSpace?: 'normal' | 'pre-wrap'  // 默认: 'normal'
  wordBreak?: 'normal' | 'keep-all'   // 默认: '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
    .
文本高度错误 / 和浏览器渲染结果不匹配
  • 确保
    font
    字符串和CSS的
    font
    简写完全一致(字重、样式、大小、字体族都需要匹配)。
  • 确保
    lineHeight
    和CSS的
    line-height
    像素值一致。
  • 调用
    prepare()
    前字体必须加载完成 — 可使用
    document.fonts.ready
    FontFace.load()
每次缩放时
prepare()
执行过慢
  • 仅在文本或字体变更时调用
    prepare()
    ,缩放时仅调用
    layout()
    或对应方法即可。
无可用Canvas(SSR / Node环境)
  • 服务端支持标注为「即将上线」,当前版本该库需要浏览器环境(canvas API)。
CJK文本换行不符合预期
  • 对于需要整词不拆分的中韩语文本,可尝试添加
    { wordBreak: 'keep-all' }
    配置。
富文本内联元素本应是原子块但被拆分
  • 给标签、提及、徽章等
    RichInlineItem
    添加
    break: 'never'
    属性。
获取自适应收缩容器的最宽行宽度
  • 使用
    measureLineStats(prepared, maxWidth).maxLineWidth
    ,或通过
    walkLineRanges
    遍历并追踪最大
    line.width