pretext-text-measurement
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChinesePretext 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 and (which trigger expensive layout reflows) by implementing its own measurement logic using the browser's font engine as ground truth.
getBoundingClientRectoffsetHeightSkill 由 ara.so 提供 — 2026年度日常技能合集。
Pretext是纯JavaScript/TypeScript库,可实现快速、精准、无需DOM的多行文本测量与布局。它基于浏览器字体引擎作为基准自研测量逻辑,避免使用和(这两个API会触发开销极高的布局重排)。
getBoundingClientRectoffsetHeightInstallation
安装
sh
npm install @chenglou/pretextsh
npm install @chenglou/pretextCore Concepts
核心概念
- /
prepare()— one-time analysis: normalize whitespace, segment text, measure via canvas. Cache and reuse this result.prepareWithSegments() - /
layout()etc. — cheap hot path: pure arithmetic over cached widths. Call this on every resize, notlayoutWithLines().prepare() - Font string format — same as , e.g.
CanvasRenderingContext2D.font,'16px Inter'.'700 18px "Helvetica Neue"'
- /
prepare()— 一次性分析:标准化空白字符、拆分文本段、通过canvas测量。可缓存并复用该结果。prepareWithSegments() - /
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 linests
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 sizingts
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(仅需高度)
| Function | Description |
|---|---|
| One-time analysis, returns |
| Returns |
| 函数 | 描述 |
|---|---|
| 一次性分析,返回 |
| 返回 |
Use Case 2 (Manual Layout)
场景2(手动布局)
| Function | Description |
|---|---|
| One-time analysis, returns |
| Returns |
| Calls |
| Returns |
| Width if text never wraps |
| Iterator — one range at a time, variable width |
| Iterator — one line + text at a time |
| Range → |
| 函数 | 描述 |
|---|---|
| 一次性分析,返回 |
| 返回 |
| 每行调用 |
| 仅返回 |
| 文本无换行时的宽度 |
| 迭代器 — 每次返回一个行范围,支持可变宽度 |
| 迭代器 — 每次返回一行数据+文本 |
| 将行范围转换为带文本的 |
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 string exactly matches your CSS
fontshorthand (weight, style, size, family all matter).font - Ensure matches your CSS
lineHeightin pixels.line-height - Font must be loaded before calling — use
prepare()ordocument.fonts.ready.FontFace.load()
prepare()- Only call when text or font changes. For resizes, only call
prepare()or equivalent.layout()
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 for Korean/Chinese text that should not break mid-word.
{ wordBreak: 'keep-all' }
Rich inline items breaking when they should be atomic
- Add to the
break: 'never'for chips, mentions, badges.RichInlineItem
Getting widest line for shrinkwrap containers
- Use or walk with
measureLineStats(prepared, maxWidth).maxLineWidthand track the maxwalkLineRanges.line.width
文本高度错误 / 和浏览器渲染结果不匹配
- 确保字符串和CSS的
font简写完全一致(字重、样式、大小、字体族都需要匹配)。font - 确保和CSS的
lineHeight像素值一致。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