TypeScript 库,用于多行文本测量和布局。
Pretext: TypeScript library for multiline text measurement and layout

原始链接: https://github.com/chenglou/pretext

## Pretext:JavaScript/TypeScript 中的精准快速多行文本布局 Pretext 是一个纯 JavaScript/TypeScript 库,专为精准高效的多行文本测量和布局而设计,支持多种语言和字符集(包括表情符号和双向文本)。它通过实现自己的文本测量逻辑,利用浏览器字体引擎以确保准确性,避免了代价高昂的 DOM 重排。 **主要特性:** * **无 DOM 测量:** 在无需 DOM 交互的情况下计算文本尺寸,提高性能。 * **多功能渲染:** 支持渲染到 DOM、Canvas、SVG,并最终支持服务器端环境。 * **两个主要用例:** 1. **高度测量:** 快速确定段落高度,无需 DOM 操作。 2. **手动布局:** 提供工具来手动控制换行和定位,以实现自定义布局(例如,砌体布局、Flexbox 实现)。 * **快速性能:** `prepare()` 处理 500 文本批次耗时约 19 毫秒,而 `layout()` 处理相同批次耗时约 0.09 毫秒。 * **API 灵活性:** 提供 `prepare`、`layout`、`prepareWithSegments`、`layoutWithLines` 和 `layoutNextLine` 等函数,以提供不同级别的控制。 Pretext 解锁了虚拟化、遮挡和动态布局调整等高级 Web UI 功能,而无需依赖 CSS 技巧或估算。它非常适合开发时验证和防止布局偏移。演示地址:[chenglou.me/pretext](chenglou.me/pretext) 和 [somnai-dreams.github.io/pretext-demos](somnai-dreams.github.io/pretext-demos)。

Hacker News 新闻 | 过去 | 评论 | 提问 | 展示 | 招聘 | 提交 登录 Pretext: TypeScript 库,用于多行文本测量和布局 (github.com/chenglou) 24 分,emersonmacro 发表于 2 小时前 | 隐藏 | 过去 | 收藏 | 1 条评论 https://x.com/_chenglou/status/2037713766205608234, https://xcancel.com/_chenglou/status/2037713766205608234 演示:https://chenglou.me/pretext/, https://somnai-dreams.github.io/pretext-demos/ 帮助 rattray 3 分钟前 [–] 无论主题如何,宣布此项目的推文都是演示架构/平台改进如何产生影响的典范。回复 指南 | 常见问题 | 列表 | API | 安全 | 法律 | 申请 YC | 联系 搜索:
相关文章

原文

Pure JavaScript/TypeScript library for multiline text measurement & layout. Fast, accurate & supports all the languages you didn't even know about. Allows rendering to DOM, Canvas, SVG and soon, server-side.

Pretext side-steps the need for DOM measurements (e.g. getBoundingClientRect, offsetHeight), which trigger layout reflow, one of the most expensive operations in the browser. It implements its own text measurement logic, using the browsers' own font engine as ground truth (very AI-friendly iteration method).

npm install @chenglou/pretext

Clone the repo, run bun install, then bun start, and open the /demos in your browser (no trailing slash. Bun devserver bugs on those) Alternatively, see them live at chenglou.me/pretext. Some more at somnai-dreams.github.io/pretext-demos

Pretext serves 2 use cases:

1. Measure a paragraph's height without ever touching DOM

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

const prepared = prepare('AGI 春天到了. بدأت الرحلة 🚀', '16px Inter')
const { height, lineCount } = layout(prepared, textWidth, 20) // pure arithmetics. No DOM layout & reflow!

prepare() does the one-time work: normalize whitespace, segment the text, apply glue rules, measure the segments with canvas, and return an opaque handle. layout() is the cheap hot path after that: pure arithmetic over cached widths.

If you want textarea-like text where ordinary spaces, \t tabs, and \n hard breaks stay visible, pass { whiteSpace: 'pre-wrap' } to prepare() / prepareWithSegments().

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

On the current checked-in benchmark snapshot:

  • prepare() is about 19ms for the shared 500-text batch
  • layout() is about 0.09ms for that same batch

We support all the languages you can imagine, including emojis and mixed-bidi, and caters to specific browser quirks

The returned height is the crucial last piece for unlocking web UI's:

  • proper virtualization/occlusion without guesstimates & caching
  • fancy userland layouts: masonry, JS-driven flexbox-like implementations, nudging a few layout values without CSS hacks (imagine that), etc.
  • development time verification (especially now with AI) that labels on e.g. buttons don't overflow to the next line, browser-free
  • prevent layout shift when new text loads and you wanna re-anchor the scroll position

2. Lay out the paragraph lines manually yourself

Switch out prepare with prepareWithSegments, then:

  • layoutWithLines() gives you all the lines at a fixed width:
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext'

const prepared = prepareWithSegments('AGI 春天到了. بدأت الرحلة 🚀', '18px "Helvetica Neue"')
const { lines } = layoutWithLines(prepared, 320, 26) // 320px max width, 26px line height
for (let i = 0; i < lines.length; i++) ctx.fillText(lines[i].text, 0, i * 26)
  • walkLineRanges() gives you line widths and cursors without building the text strings:
let maxW = 0
walkLineRanges(prepared, 320, line => { if (line.width > maxW) maxW = line.width })
// maxW is now the widest line — the tightest container width that still fits the text! This multiline "shrink wrap" has been missing from web
  • layoutNextLine() lets you route text one row at a time when width changes as you go:
let cursor = { segmentIndex: 0, graphemeIndex: 0 }
let y = 0

// Flow text around a floated image: lines beside the image are narrower
while (true) {
  const width = y < image.bottom ? columnWidth - image.width : columnWidth
  const line = layoutNextLine(prepared, cursor, width)
  if (line === null) break
  ctx.fillText(line.text, 0, y)
  cursor = line.end
  y += 26
}

This usage allows rendering to canvas, SVG, WebGL and (eventually) server-side.

Use-case 1 APIs:

prepare(text: string, font: string, options?: { whiteSpace?: 'normal' | 'pre-wrap' }): PreparedText // one-time text analysis + measurement pass, returns an opaque value to pass to `layout()`. Make sure `font` is synced with your css `font` declaration shorthand (e.g. size, weight, style, family) for the text you're measuring. `font` is the same format as what you'd use for `myCanvasContext.font = ...`, e.g. `16px Inter`.
layout(prepared: PreparedText, maxWidth: number, lineHeight: number): { height: number, lineCount: number } // calculates text height given a max width and lineHeight. Make sure `lineHeight` is synced with your css `line-height` declaration for the text you're measuring.

Use-case 2 APIs:

prepareWithSegments(text: string, font: string, options?: { whiteSpace?: 'normal' | 'pre-wrap' }): PreparedTextWithSegments // same as `prepare()`, but returns a richer structure for manual line layouts needs
layoutWithLines(prepared: PreparedTextWithSegments, maxWidth: number, lineHeight: number): { height: number, lineCount: number, lines: LayoutLine[] } // high-level api for manual layout needs. Accepts a fixed max width for all lines. Similar to `layout()`'s return, but additionally returns the lines info
walkLineRanges(prepared: PreparedTextWithSegments, maxWidth: number, onLine: (line: LayoutLineRange) => void): number // low-level api for manual layout needs. Accepts a fixed max width for all lines. Calls `onLine` once per line with its actual calculated line width and start/end cursors, without building line text strings. Very useful for certain cases where you wanna speculatively test a few width and height boundaries (e.g. binary search a nice width value by repeatedly calling walkLineRanges and checking the line count, and therefore height, is "nice" too. You can have text messages shrinkwrap and balanced text layout this way). After walkLineRanges calls, you'd call layoutWithLines once, with your satisfying max width, to get the actual lines info.
layoutNextLine(prepared: PreparedTextWithSegments, start: LayoutCursor, maxWidth: number): LayoutLine | null // iterator-like api for laying out each line with a different width! Returns the LayoutLine starting from `start`, or `null` when the paragraph's exhausted. Pass the previous line's `end` cursor as the next `start`.
type LayoutLine = {
  text: string // Full text content of this line, e.g. 'hello world'
  width: number // Measured width of this line, e.g. 87.5
  start: LayoutCursor // Inclusive start cursor in prepared segments/graphemes
  end: LayoutCursor // Exclusive end cursor in prepared segments/graphemes
}
type LayoutLineRange = {
  width: number // Measured width of this line, e.g. 87.5
  start: LayoutCursor // Inclusive start cursor in prepared segments/graphemes
  end: LayoutCursor // Exclusive end cursor in prepared segments/graphemes
}
type LayoutCursor = {
  segmentIndex: number // Segment index in prepareWithSegments' prepared rich segment stream
  graphemeIndex: number // Grapheme index within that segment; `0` at segment boundaries
}

Other helpers:

clearCache(): void // clears Pretext's shared internal caches used by prepare() and prepareWithSegments(). Useful if your app cycles through many different fonts or text variants and you want to release the accumulated cache
setLocale(locale?: string): void // optional (by default we use the current locale). Sets locale for future prepare() and prepareWithSegments(). Internally, it also calls clearCache(). Setting a new locale doesn't affect existing prepare() and prepareWithSegments() states (no mutations to them)

Pretext doesn't try to be a full font rendering engine (yet?). It currently targets the common text setup:

  • white-space: normal
  • word-break: normal
  • overflow-wrap: break-word
  • line-break: auto
  • If you pass { whiteSpace: 'pre-wrap' }, ordinary spaces, \t tabs, and \n hard breaks are preserved instead of collapsed. Tabs follow the default browser-style tab-size: 8. The other wrapping defaults stay the same: word-break: normal, overflow-wrap: break-word, and line-break: auto.
  • system-ui is unsafe for layout() accuracy on macOS. Use a named font.
  • Because the default target includes overflow-wrap: break-word, very narrow widths can still break inside words, but only at grapheme boundaries.

See DEVELOPMENT.md for the dev setup and commands.

Sebastian Markbage first planted the seed with text-layout last decade. His design — canvas measureText for shaping, bidi from pdf.js, streaming line breaking — informed the architecture we kept pushing forward here.

联系我们 contact @ memedata.com