chenglou/pretext
GitHub: chenglou/pretext
纯 JavaScript/TypeScript 的多行文本测量与布局库,通过绕过 DOM 回流实现高性能文本高度计算和逐行排版。
Stars: 27610 | Forks: 1256
# Pretext
纯 JavaScript/TypeScript 多行文本测量与布局库。快速、准确,并支持你甚至都没听说过的语言。支持渲染到 DOM、Canvas、SVG,以及即将支持的服务端。
Pretext 避免了对 DOM 测量(例如 `getBoundingClientRect`、`offsetHeight`)的需求,这些操作会触发布局回流(layout reflow)——浏览器中最耗性能的操作之一。它实现了自己的文本测量逻辑,使用浏览器自身的字体引擎作为真实基准(对 AI 迭代非常友好的方法)。
## 安装
```
npm install @chenglou/pretext
```
## 演示
克隆仓库,运行 `bun install`,然后运行 `bun start`,并在浏览器中打开 `/demos`(末尾不要加斜杠。Bun 开发服务器在此有 bug)。
或者,访问 [chenglou.me/pretext](https://chenglou.me/pretext/) 查看在线演示。更多演示请见 [somnai-dreams.github.io/pretext-demos](https://somnai-dreams.github.io/pretext-demos/)
## API
Pretext 支持以下两种使用场景:
### 1. 在完全无需触碰 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()` 执行一次性工作:规范化空白字符、对文本进行分词、应用弹性空白(glue)规则、使用 canvas 测量文本片段,并返回一个不透明的句柄。在此之后,`layout()` 就是开销极低的热路径(hot path):纯粹是对缓存宽度进行算术运算。不要对相同的文本和配置重复运行 `prepare()`;这会使其预计算失去意义。例如,在窗口大小改变(resize)时,只需重新运行 `layout()` 即可。
如果你想要类似 textarea 的文本效果,即普通空格、`\t` 制表符和 `\n` 硬换行都能保持可见,请在 `prepare()` 中传入 `{ whiteSpace: 'pre-wrap' }`:
```
const prepared = prepare(textareaValue, '16px Inter', { whiteSpace: 'pre-wrap' })
const { height } = layout(prepared, textareaWidth, 20)
```
在当前提交的基准测试快照中:
- 对于共享的 500 条文本批处理,`prepare()` 耗时约 `19ms`
- 对于相同的批处理,`layout()` 耗时约 `0.09ms`
我们支持你能想到的所有语言,包括 emoji 和混合双向文本(mixed-bidi),并针对特定的浏览器怪癖进行了处理。
返回的高度是解锁 Web UI 的关键最后一步:
- 无需猜测与缓存,实现真正的虚拟化/遮挡
- 高级的用户层(userland)布局:瀑布流、JS 驱动的类 flexbox 实现、无需 CSS hack 即可微调部分布局值(想象一下)等。
- _开发阶段_ 的验证(特别是现在的 AI),可以在脱离浏览器的情况下验证例如按钮上的标签是否溢出到下一行
- 在加载新文本并希望重新锚定滚动位置时,防止布局偏移(layout shift)
### 2. 手动布局段落文本行
将 `prepare` 替换为 `prepareWithSegments`,然后:
- `layoutWithLines()` 为你提供在固定宽度下的所有行:
```
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()` 为你提供行宽和光标位置,而无需构建文本字符串:
```
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()` 允许你在宽度动态变化时,逐行排布文本:
```
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
}
```
这种用法允许渲染到 canvas、SVG、WebGL 以及(最终的)服务端。
### API 术语表
用例 1 API:
```
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.
```
用例 2 API:
```
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
}
```
其他辅助函数:
```
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 并不试图成为一个完整的字体渲染引擎(至少目前不是?)。它目前针对的是常见的文本设置:
- `white-space: normal`
- `word-break: normal`
- `overflow-wrap: break-word`
- `line-break: auto`
- 如果你传入 `{ whiteSpace: 'pre-wrap' }`,普通空格、`\t` 制表符和 `\n` 硬换行将被保留而不是被折叠。制表符遵循浏览器默认的 `tab-size: 8` 样式。其他换行默认设置保持不变:`word-break: normal`、`overflow-wrap: break-word` 和 `line-break: auto`。
- `system-ui` 在 macOS 上对于 `layout()` 的准确性是不安全的。请使用命名字体。
- 因为默认目标包含 `overflow-wrap: break-word`,在非常窄的宽度下,文本仍可能在单词内部断行,但仅限于字素(grapheme)边界处。
## 开发
有关开发设置和命令,请参见 [DEVELOPMENT.md](DEVELOPMENT.md)。
## 致谢
Sebastian Markbage 在上个十年用 [text-layout](https://github.com/chenglou/text-layout) 首次播下了种子。他的设计——使用 canvas `measureText` 进行整形(shaping)、从 pdf.js 引入 bidi(双向文本)、流式断行——构成了我们在此不断推进的架构基础。
标签:Canvas, CMS安全, DOM渲染, JavaScript, npm包, SVG, TypeScript, 前端UI, 多行文本, 多语言支持, 字体引擎, 安全插件, 安全测试框架, 布局引擎, 开源库, 性能优化, 搜索引擎爬虫, 数据可视化, 文本分段, 文本测量, 暗色界面, 服务器端渲染(SSR), 检测绕过, 纯算术计算, 自动化攻击, 重排优化