pretext README

The project's README from github.com/chenglou/pretext. As one markdown file: README.md.

Pretext

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).

Installation

npm install @chenglou/pretext

Demos

Clone the repo, run bun install, then bun start, and open /demos/index in your browser. On Windows, use bun run start:windows. Alternatively, see them live at chenglou.me/pretext. Some more at somnai-dreams.github.io/pretext-demos

API

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, 320, 20) // pure arithmetic. 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. Do not rerun prepare() for the same text and configs; that'd defeat its precomputation. For example, on resize, only rerun layout().

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

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

Other prepare() options are { wordBreak: 'keep-all' } for CSS-like word-break: keep-all, and { letterSpacing: n } to match CSS letter-spacing (n is treated as a px value).

The returned height is the crucial last piece for unlocking web UIs:

2. Lay out the paragraph lines manually yourself

Switch out prepare with prepareWithSegments, then:

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

const { lineCount, maxLineWidth } = measureLineStats(prepared, 320)
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
import { layoutNextLineRange, materializeLineRange, prepareWithSegments, type LayoutCursor } from '@chenglou/pretext'

const prepared = prepareWithSegments(article, BODY_FONT)
let cursor: LayoutCursor = { 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 range = layoutNextLineRange(prepared, cursor, width)
  if (range === null) break

  const line = materializeLineRange(prepared, range)
  ctx.fillText(line.text, 0, y)
  cursor = range.end
  y += 26
}

This usage allows rendering to canvas, SVG, WebGL and (eventually) server-side. See the /demos/dynamic-layout demo for a richer example.

For hyphenation in manual layout, insert soft hyphens before prepare() / prepareWithSegments(). Pretext treats them as optional break points: unchosen soft hyphens stay invisible, while chosen breaks materialize as a trailing -. A soft hyphen at the end of the paragraph is consumed without painting a hyphen. For mixed-language or user-generated app text, prefer conservative, locale-aware insertion over aggressive pattern hyphenation. Automatic hyphenation is not built in today.

Very narrow SHY fallback remains approximate: Safari can overflow a prefix plus hyphen, while Chromium and Gecko may move part of the prefix to another line.

If your manual layout needs a small helper for rich-text inline flow, code spans, mentions, chips, and browser-like boundary whitespace collapse, there is a helper at @chenglou/pretext/rich-inline. It stays inline-only and white-space: normal-only on purpose:

import { materializeRichInlineLineRange, prepareRichInline, walkRichInlineLineRanges } 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 rich-note", font: '500 17px Inter' },
])

walkRichInlineLineRanges(prepared, 320, range => {
  const line = materializeRichInlineLineRange(prepared, range)
  // each fragment keeps its source item index, text slice, gapBefore, and cursors
})

It is intentionally narrow:

Fragment and cursor itemIndex values refer to that original list, including when it contains empty items. A collapsed boundary space uses the first space's font and letter spacing; gapBefore can be zero or negative. Zero-width content can still occupy a line and carry a break opportunity.

API Glossary

Use-case 1 APIs:

prepare(text: string, font: string, options?: { whiteSpace?: 'normal' | 'pre-wrap', wordBreak?: 'normal' | 'keep-all', letterSpacing?: number }): PreparedText // one-time text analysis + measurement pass, returns an opaque value to pass to `layout()`. Make sure `font` and `letterSpacing` are synced with your CSS for the text you're measuring. `font` is the same format as what you'd use for `myCanvasContext.font = ...`, e.g. `16px Inter`; `letterSpacing` is a CSS pixel value.
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', wordBreak?: 'normal' | 'keep-all', letterSpacing?: number }): PreparedTextWithSegments // same as `prepare()`, but returns a richer structure for manual line layout 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.
measureLineStats(prepared: PreparedTextWithSegments, maxWidth: number): { lineCount: number, maxLineWidth: number } // returns only how many lines this width produces, and how wide the widest one is. Avoids line/string allocations.
measureNaturalWidth(prepared: PreparedTextWithSegments): number // returns the widest forced line when width itself is not the thing causing wraps
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`.
layoutNextLineRange(prepared: PreparedTextWithSegments, start: LayoutCursor, maxWidth: number): LayoutLineRange | null // same as layoutNextLine(), but without allocating line text strings. Useful for variable-width manual layout, occlusion, and virtualization measurements.
materializeLineRange(prepared: PreparedTextWithSegments, line: LayoutLineRange): LayoutLine // turns a LayoutLineRange from layoutNextLineRange() or walkLineRanges() into a full line with text
type LineStats = {
  lineCount: number // Number of wrapped lines, e.g. 3
  maxLineWidth: number // Widest wrapped line, e.g. 192.5
}
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
}

Helper for rich-text inline flow:

prepareRichInline(items: RichInlineItem[]): PreparedRichInline // compile raw inline items with their original text. The compiler owns cross-item collapsed whitespace and caches each item's natural width
layoutNextRichInlineLineRange(prepared: PreparedRichInline, maxWidth: number, start?: RichInlineCursor): RichInlineLineRange | null // stream one line of rich-text inline flow at a time without building fragment text strings
walkRichInlineLineRanges(prepared: PreparedRichInline, maxWidth: number, onLine: (line: RichInlineLineRange) => void): number // non-materializing line walker for rich-text inline flow shrinkwrap/stats work
materializeRichInlineLineRange(prepared: PreparedRichInline, line: RichInlineLineRange): RichInlineLine // turns one previously computed rich-inline line range back into full fragment text
measureRichInlineStats(prepared: PreparedRichInline, maxWidth: number): { lineCount: number, maxLineWidth: number } // returns only how many lines this width produces, and how wide the widest one is. Avoids fragment-text allocations.
type RichInlineItem = {
  text: string // raw author text, including leading/trailing collapsible spaces
  font: string // canvas font shorthand for this item
  letterSpacing?: number // extra horizontal spacing between graphemes, in CSS px
  break?: 'normal' | 'never' // `never` keeps the item atomic, like a chip
  extraWidth?: number // caller-owned horizontal chrome, e.g. padding + border width
}
type RichInlineCursor = {
  itemIndex: number // Which source RichInlineItem this cursor is currently in
  segmentIndex: number // Segment index within that item's prepared text
  graphemeIndex: number // Grapheme index within that segment; `0` at segment boundaries
}
type RichInlineFragment = {
  itemIndex: number // index back into the original RichInlineItem array
  text: string // Text slice for this fragment
  gapBefore: number // collapsed boundary gap paid before this fragment on this line
  occupiedWidth: number // text width plus extraWidth
  start: LayoutCursor // Start cursor within the item's prepared text
  end: LayoutCursor // End cursor within the item's prepared text
}
type RichInlineLine = {
  fragments: RichInlineFragment[] // Materialized fragments on this line
  width: number // Measured width of this line, including gapBefore/extraWidth
  end: RichInlineCursor // Exclusive end cursor for continuing the next line
}
type RichInlineFragmentRange = {
  itemIndex: number // index back into the original RichInlineItem array
  gapBefore: number // collapsed boundary gap paid before this fragment on this line
  occupiedWidth: number // text width plus extraWidth
  start: LayoutCursor // Start cursor within the item's prepared text
  end: LayoutCursor // End cursor within the item's prepared text
}
type RichInlineLineRange = {
  fragments: RichInlineFragmentRange[] // Non-materialized fragment ownership/ranges on this line
  width: number // Measured width of this line, including gapBefore/extraWidth
  end: RichInlineCursor // Exclusive end cursor for continuing the next line
}
type RichInlineStats = {
  lineCount: number // Number of wrapped lines, e.g. 3
  maxLineWidth: number // Widest wrapped line, e.g. 192.5
}

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)

Notes:

Caveats

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

Develop

See DEVELOPMENT.md for the dev setup and commands.

Credits

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.

pretext (chenglou/pretext)

Searching Database…

50.8K ·GitHub
这是什么?Pretext 是一个纯 JavaScript/TypeScript 库,用于多行文本的测量和排版——又快又准,甚至支持一些你可能都没听说过的语言。
它是引入到我代码里的库,还是一个独立的应用?它是一个库,需要导入到你的 JavaScript 或 TypeScript 代码里,不是拿来直接运行的应用。它的输出可以渲染到 DOM、Canvas、SVG,而且很快还会支持服务端渲染。
我在本地怎么跑起来看看效果?克隆仓库,先运行 bun install 再运行 bun start,然后在浏览器里打开 /demos/index(Windows 下用 bun run start:windows)。在线演示也可以直接访问 chenglou.me/pretext。
哪些浏览器和运行时能真正跑起来?任何支持 Intl.Segmenter 和 Canvas 2D 文本测量的环境都可以;目前不支持没有 Intl.Segmenter 的浏览器或运行时,所以老浏览器就用不了了。
它的换行位置会和浏览器实际渲染的完全一致吗?不一定。在 macOS 上,system-ui 和 -apple-system 会影响 layout() 的准确性,所以要改用具体的字体名;标点旁边的 emoji 也可能出现不同的换行位置。
它不碰 DOM 是怎么测量文本的?它绕开了 getBoundingClientRect、offsetHeight 这类会触发布局重排的 DOM 测量方式,而是运行自己的测量逻辑,并用浏览器自带的字体引擎作为基准。
它只能测高度,还是也能自己排出行来?两者都可以。prepare() 负责一次性的准备工作,layout() 是开销很小的热路径,直接返回段落高度;如果想自己逐行排版,可以换用 prepareWithSegments(),配合 layoutWithLines() 或 layoutNextLineRange()。
开发者拿到这个测出来的高度,一般拿来做什么?有了它就能不用瞎猜地做虚拟滚动和遮挡剔除、瀑布流以及用 JS 驱动的类似 flexbox 的布局,还能脱离浏览器检查按钮文字有没有溢出,以及在文本加载时重新锚定滚动位置,避免布局偏移。
我能自定义它的换行方式吗,比如 CJK 或字间距?可以:prepare() 接受 { whiteSpace: 'pre-wrap' } 来保留空格、制表符和强制换行,{ wordBreak: 'keep-all' } 实现 CJK 风格的不拆词,{ letterSpacing: n } 则可以对齐 CSS 的 letter-spacing(以 px 为单位)。
What are similar projects?kugiri, GSAP, anime, material, MathJax, velocity, source-han-sans, pixijs
Creator
chenglou
Size
98.3 MB
Stars
50.8K
Age
7mo
Commits · 90 Days