chenglou/pretextPublic

Fast, accurate & comprehensive text measurement & layout

AI summary: Pure JS/TS library for extremely fast, DOM-free multiline text measurement and layout.

Stars
50.7K
+19 today
Forks
2.8K
Watchers
144
Open issues
36
Open PRs
20
Contributors
~9
Commits
1.5K
Branches
80

TypeScriptMITCreated Mar 7, 2026Last push today+110 stars this week+513 this month

Quick answers

What is pretext?
Pure JS/TS library for extremely fast, DOM-free multiline text measurement and layout.
What does pretext do?
Pretext is a highly optimized JavaScript/TypeScript library specifically designed to handle complex multiline text measurement and layout entirely outside the DOM. By completely avoiding expensive DOM operations like `getBoundingClientRect`, which famously trigger massive layout reflows, Pretext offers dramatically faster performance. It ingeniously uses the browser's own font engine via the Canvas API as the absolute ground truth to accurately measure text segments, smoothly handling complex scripts and whitespace normalization. This highly predictable approach allows developers to pre-calculate layouts for DOM, SVG, Canvas, or even server-side rendering with pure arithmetic speed.
Who is pretext for?
Frontend engineers and creative coders building high-performance UIs, complex data visualizations, or Canvas/WebGL applications. It is particularly crucial for projects where DOM reflow costs cause unacceptable frame drops.
How do I get started with pretext?
npm install @chenglou/pretext
How popular is pretext on GitHub?
chenglou/pretext has 50,689 stars and 2,750 forks on GitHub, and gained 110 stars in the last 7 days.
What license does pretext use?
chenglou/pretext is released under the MIT license.

Star history

since Jul 29, 2026
020K40KJul 2026Aug 2026Sep 2026Oct 2026
50.7K stars as of Oct 4, 2026. Measured daily since Jul 29, 2026; GitHub no longer exposes earlier star timestamps.

Contribution activity

commits per day, last 52 weeks
OctNovDecJanFebMarAprMayJunJulAugSepOctMonWedFri2025-10-11: 0 commits2025-10-12: 0 commits2025-10-13: 0 commits2025-10-14: 0 commits2025-10-15: 0 commits2025-10-16: 0 commits2025-10-17: 0 commits2025-10-18: 0 commits2025-10-19: 0 commits2025-10-20: 0 commits2025-10-21: 0 commits2025-10-22: 0 commits2025-10-23: 0 commits2025-10-24: 0 commits2025-10-25: 0 commits2025-10-26: 0 commits2025-10-27: 0 commits2025-10-28: 0 commits2025-10-29: 0 commits2025-10-30: 0 commits2025-10-31: 0 commits2025-11-01: 0 commits2025-11-02: 0 commits2025-11-03: 0 commits2025-11-04: 0 commits2025-11-05: 0 commits2025-11-06: 0 commits2025-11-07: 0 commits2025-11-09: 0 commits2025-11-10: 0 commits2025-11-11: 0 commits2025-11-12: 0 commits2025-11-13: 0 commits2025-11-14: 0 commits2025-11-15: 0 commits2025-11-16: 0 commits2025-11-17: 0 commits2025-11-18: 0 commits2025-11-19: 0 commits2025-11-20: 0 commits2025-11-21: 0 commits2025-11-22: 0 commits2025-11-23: 0 commits2025-11-24: 0 commits2025-11-25: 0 commits2025-11-26: 0 commits2025-11-27: 0 commits2025-11-28: 0 commits2025-11-29: 0 commits2025-11-30: 0 commits2025-12-01: 0 commits2025-12-02: 0 commits2025-12-03: 0 commits2025-12-04: 0 commits2025-12-05: 0 commits2025-12-06: 0 commits2025-12-07: 0 commits2025-12-08: 0 commits2025-12-09: 0 commits2025-12-10: 0 commits2025-12-11: 0 commits2025-12-12: 0 commits2025-12-13: 0 commits2025-12-14: 0 commits2025-12-15: 0 commits2025-12-16: 0 commits2025-12-17: 0 commits2025-12-18: 0 commits2025-12-19: 0 commits2025-12-20: 0 commits2025-12-21: 0 commits2025-12-22: 0 commits2025-12-23: 0 commits2025-12-24: 0 commits2025-12-25: 0 commits2025-12-26: 0 commits2025-12-27: 0 commits2025-12-28: 0 commits2025-12-29: 0 commits2025-12-30: 0 commits2025-12-31: 0 commits2026-01-01: 0 commits2026-01-02: 0 commits2026-01-03: 0 commits2026-01-04: 0 commits2026-01-05: 0 commits2026-01-06: 0 commits2026-01-07: 0 commits2026-01-08: 0 commits2026-01-09: 0 commits2026-01-10: 0 commits2026-01-11: 0 commits2026-01-12: 0 commits2026-01-13: 0 commits2026-01-14: 0 commits2026-01-15: 0 commits2026-01-16: 0 commits2026-01-17: 0 commits2026-01-18: 0 commits2026-01-19: 0 commits2026-01-20: 0 commits2026-01-21: 0 commits2026-01-22: 0 commits2026-01-23: 0 commits2026-01-24: 0 commits2026-01-25: 0 commits2026-01-26: 0 commits2026-01-27: 0 commits2026-01-28: 0 commits2026-01-29: 0 commits2026-01-30: 0 commits2026-01-31: 0 commits2026-02-01: 0 commits2026-02-02: 0 commits2026-02-03: 0 commits2026-02-04: 0 commits2026-02-05: 0 commits2026-02-06: 0 commits2026-02-07: 0 commits2026-02-08: 0 commits2026-02-09: 0 commits2026-02-10: 0 commits2026-02-11: 0 commits2026-02-12: 0 commits2026-02-13: 0 commits2026-02-14: 0 commits2026-02-15: 0 commits2026-02-16: 0 commits2026-02-17: 0 commits2026-02-18: 0 commits2026-02-19: 0 commits2026-02-20: 0 commits2026-02-21: 0 commits2026-02-22: 0 commits2026-02-23: 0 commits2026-02-24: 0 commits2026-02-25: 0 commits2026-02-26: 0 commits2026-02-27: 0 commits2026-02-28: 0 commits2026-03-01: 0 commits2026-03-02: 0 commits2026-03-03: 25 commits2026-03-04: 14 commits2026-03-05: 0 commits2026-03-06: 5 commits2026-03-07: 14 commits2026-03-08: 15 commits2026-03-09: 14 commits2026-03-10: 38 commits2026-03-11: 21 commits2026-03-12: 20 commits2026-03-13: 0 commits2026-03-14: 0 commits2026-03-15: 0 commits2026-03-16: 0 commits2026-03-17: 4 commits2026-03-18: 0 commits2026-03-19: 0 commits2026-03-20: 2 commits2026-03-21: 0 commits2026-03-22: 3 commits2026-03-23: 6 commits2026-03-24: 0 commits2026-03-25: 0 commits2026-03-26: 9 commits2026-03-27: 27 commits2026-03-28: 22 commits2026-03-29: 21 commits2026-03-30: 5 commits2026-03-31: 8 commits2026-04-01: 2 commits2026-04-02: 8 commits2026-04-03: 1 commit2026-04-04: 9 commits2026-04-05: 19 commits2026-04-06: 19 commits2026-04-07: 0 commits2026-04-08: 11 commits2026-04-09: 5 commits2026-04-10: 0 commits2026-04-11: 0 commits2026-04-12: 0 commits2026-04-13: 0 commits2026-04-14: 0 commits2026-04-15: 1 commit2026-04-16: 0 commits2026-04-17: 0 commits2026-04-18: 12 commits2026-04-19: 1 commit2026-04-20: 0 commits2026-04-21: 4 commits2026-04-22: 5 commits2026-04-23: 0 commits2026-04-24: 0 commits2026-04-25: 0 commits2026-04-26: 0 commits2026-04-27: 0 commits2026-04-28: 0 commits2026-04-29: 0 commits2026-04-30: 0 commits2026-05-01: 0 commits2026-05-02: 0 commits2026-05-03: 0 commits2026-05-04: 0 commits2026-05-05: 0 commits2026-05-06: 0 commits2026-05-07: 8 commits2026-05-08: 3 commits2026-05-09: 3 commits2026-05-10: 3 commits2026-05-11: 0 commits2026-05-12: 0 commits2026-05-13: 0 commits2026-05-14: 0 commits2026-05-15: 0 commits2026-05-16: 0 commits2026-05-17: 0 commits2026-05-18: 0 commits2026-05-19: 0 commits2026-05-20: 0 commits2026-05-21: 0 commits2026-05-22: 1 commit2026-05-23: 0 commits2026-05-24: 0 commits2026-05-25: 0 commits2026-05-26: 0 commits2026-05-27: 0 commits2026-05-28: 0 commits2026-05-29: 0 commits2026-05-30: 0 commits2026-05-31: 0 commits2026-06-01: 0 commits2026-06-02: 0 commits2026-06-03: 0 commits2026-06-04: 0 commits2026-06-05: 0 commits2026-06-06: 0 commits2026-06-07: 0 commits2026-06-08: 0 commits2026-06-09: 0 commits2026-06-10: 0 commits2026-06-11: 2 commits2026-06-12: 0 commits2026-06-13: 0 commits2026-06-14: 0 commits2026-06-15: 0 commits2026-06-16: 0 commits2026-06-17: 0 commits2026-06-18: 0 commits2026-06-19: 0 commits2026-06-20: 0 commits2026-06-21: 0 commits2026-06-22: 12 commits2026-06-23: 0 commits2026-06-24: 0 commits2026-06-25: 0 commits2026-06-26: 0 commits2026-06-27: 0 commits2026-06-28: 0 commits2026-06-29: 0 commits2026-06-30: 0 commits2026-07-01: 0 commits2026-07-02: 0 commits2026-07-03: 0 commits2026-07-04: 0 commits2026-07-05: 0 commits2026-07-06: 0 commits2026-07-07: 0 commits2026-07-08: 0 commits2026-07-09: 0 commits2026-07-10: 0 commits2026-07-11: 0 commits2026-07-12: 0 commits2026-07-13: 0 commits2026-07-14: 3 commits2026-07-15: 0 commits2026-07-16: 0 commits2026-07-17: 0 commits2026-07-18: 0 commits2026-07-19: 0 commits2026-07-20: 0 commits2026-07-21: 0 commits2026-07-22: 0 commits2026-07-23: 0 commits2026-07-24: 0 commits2026-07-25: 0 commits2026-07-26: 0 commits2026-07-27: 0 commits2026-07-28: 0 commits2026-07-29: 0 commits2026-07-30: 0 commits2026-07-31: 0 commits2026-08-01: 0 commits2026-08-02: 0 commits2026-08-03: 0 commits2026-08-04: 0 commits2026-08-05: 0 commits2026-08-06: 0 commits2026-08-07: 0 commits2026-08-08: 0 commits2026-08-09: 0 commits2026-08-10: 0 commits2026-08-11: 0 commits2026-08-12: 0 commits2026-08-13: 0 commits2026-08-14: 0 commits2026-08-15: 0 commits2026-08-16: 0 commits2026-08-17: 0 commits2026-08-18: 0 commits2026-08-19: 0 commits2026-08-20: 0 commits2026-08-21: 0 commits2026-08-22: 0 commits2026-08-23: 0 commits2026-08-24: 0 commits2026-08-25: 0 commits2026-08-26: 0 commits2026-08-27: 0 commits2026-08-28: 0 commits2026-08-29: 0 commits2026-08-30: 0 commits2026-08-31: 0 commits2026-09-01: 0 commits2026-09-02: 0 commits2026-09-03: 6 commits2026-09-04: 1 commit2026-09-05: 3 commits2026-09-06: 5 commits2026-09-07: 4 commits2026-09-08: 2 commits2026-09-09: 1 commit2026-09-10: 2 commits2026-09-11: 13 commits2026-09-12: 80 commits2026-09-13: 54 commits2026-09-14: 35 commits2026-09-15: 75 commits2026-09-16: 6 commits2026-09-17: 0 commits2026-09-18: 0 commits2026-09-19: 0 commits2026-09-20: 0 commits2026-09-21: 0 commits2026-09-22: 0 commits2026-09-23: 62 commits2026-09-24: 60 commits2026-09-25: 26 commits2026-09-26: 43 commits2026-09-27: 31 commits2026-09-28: 18 commits2026-09-29: 26 commits2026-09-30: 51 commits2026-10-01: 167 commits2026-10-02: 30 commits2026-10-03: 15 commits2026-10-04: 4 commits2026-10-05: 0 commits2026-10-06: 0 commits2026-10-07: 0 commits2026-10-08: 0 commits2026-10-09: 0 commits2026-10-10: 0 commits
1,225 commits in the last yearLessMore

Signals and awards

derived from tracked data
  • Landmark project

    50,689 stars

  • Very active

    1,225 commits in 52 weeks

  • Permissive license

    MIT

  • Continuous integration

    Automated checks passing

  • Repeat trending

    10 trending appearances

What pretext does

Pretext is a highly optimized JavaScript/TypeScript library specifically designed to handle complex multiline text measurement and layout entirely outside the DOM. By completely avoiding expensive DOM operations like `getBoundingClientRect`, which famously trigger massive layout reflows, Pretext offers dramatically faster performance. It ingeniously uses the browser's own font engine via the Canvas API as the absolute ground truth to accurately measure text segments, smoothly handling complex scripts and whitespace normalization. This highly predictable approach allows developers to pre-calculate layouts for DOM, SVG, Canvas, or even server-side rendering with pure arithmetic speed.

Frontend engineers and creative coders building high-performance UIs, complex data visualizations, or Canvas/WebGL applications. It is particularly crucial for projects where DOM reflow costs cause unacceptable frame drops.

  • DOM-free measurement: Calculates visual dimensions purely in memory, completely eliminating expensive layout reflows.
  • High rendering performance: Utilizes a remarkably fast hot-path consisting of pure arithmetic over carefully cached segment widths.
  • Universal language support: Accurately and consistently handles complex scripts and all languages officially supported by the modern browser.
  • Multi-target rendering: Provides calculated layouts perfectly ready for DOM, Canvas, SVG, or upcoming server-side environments.
  • AI-friendly iteration: Reliably uses standard browser font engines as ground truth, making the visual output highly predictable for generative tools.

Where teams use it

Virtual list optimization

Calculate exact row heights instantly for a massive, dynamically sized list before ever rendering any elements directly to the DOM.

Canvas text rendering

Implement complex, multiline text wrapping smoothly in WebGL or Canvas applications where native DOM layout simply isn't available.

Performance-critical UIs

Eliminate jank entirely in highly interactive applications by safely moving all text measurement off the main rendering path.

Server-side layout prediction

Predict the visual layout of complex text blocks directly on the server to send pre-calculated dimensions seamlessly to the client.

Getting started: npm install @chenglou/pretext

README

main branch

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 and SVG.

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

The demos are exemplary API usage patterns we encourage you to read. They don't ship in the npm package, so clone the repo, run bun install, then bun start, and open http://localhost:3000/demos 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 Building a chat or another long list? pages/demos/markdown-chat.md walks through the Markdown chat demo's patterns and when you can skip each.

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 at its break opportunities, 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, font and options; 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)

// Long text edited live: prepare each paragraph apart, keeping its \n, and re-prepare only the one an edit touches
const paragraphs = textareaValue.split(/(?<=\n)/).map(p => prepare(p, '16px Inter', { whiteSpace: 'pre-wrap' }))
const lineCount = paragraphs.reduce((n, p) => n + layout(p, textareaWidth, 20).lineCount, 0)

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:

  • 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)
  • measureLineStats() and walkLineRanges() give you line counts, widths and cursors without building the text strings:
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

Size an element to Math.ceil(maxW), as the /demos/bubbles demo does: at the exact fractional width, the browser can wrap the widest line.

  • layoutNextLineRange() lets you route text one row at a time when width changes as you go:
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
}

See the /demos/dynamic-layout demo for a richer example, and the /demos/ellipsis demo for a paragraph clamped to a number of lines with an ellipsis, as CSS -webkit-line-clamp does, and a path cut in its middle.

For hyphenation, insert soft hyphens before calling prepare() or prepareWithSegments(). They stay invisible unless the line breaks there, in which case it ends with -. For mixed-language or user-generated app text, prefer conservative, locale-aware insertion over aggressive pattern hyphenation.

To lay out text with mixed fonts, code spans, mentions, chips or images, use @chenglou/pretext/rich-inline:

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' },
  { width: 20 }, // a custom emoji
])

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

Pass a flat list of items. For an image, a custom emoji, a formula or a badge inside a line, pass a box, { width }: its element's margin box, padding and border included, in whole or quarter pixels, since browsers round other widths to their layout unit. A line can break on either side of a box, as at an <img>, and its fragment has no text; a box is an item with no text. For a size not known yet, prepare with a placeholder and again when it arrives; for an image capped at max-width: 100%, pass min(its width, the paragraph's width) and prepare again when that changes. Heights are yours: give each box vertical-align: top, and each line is as tall as the paragraph's line height or its tallest box, whichever is taller.

For white-space: pre-wrap or word-break: keep-all on the paragraph, pass { whiteSpace: 'pre-wrap' } or { wordBreak: 'keep-all' } as the second argument; it applies to every item. In pre-wrap every item but an atomic one keeps its spaces, tabs and newlines: spaces at a line's end hang past it whichever items hold them, tab stops count from the line's start, and a newline ends its line. Paint each line with white-space: pre: a line painted alone in pre-wrap is its paragraph's last line, where spaces at its end hang only if they don't fit and a padded item's end after them can wrap. This is not a general CSS inline formatting engine.

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, and must be finite.
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 width of the widest line when only explicit line breaks apply.
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 PreparedTextWithSegments = PreparedText & {
  segments: string[] // The text split into segments, e.g. ['hello', ' ', 'world']
  kinds: SegmentBreakKind[] // Break behavior per segment, e.g. ['text', 'space', 'text']
}
type SegmentBreakKind = 'text' | 'space' | 'preserved-space' | 'tab' | 'zero-width-break' | 'soft-hyphen' | 'zero-width-glue' | 'hard-break' | 'control' // 'space': a collapsible space; 'preserved-space', 'tab' and 'hard-break': a space, tab or newline kept by `pre-wrap`; 'zero-width-break': a zero-width space the line can break after; 'soft-hyphen': a soft hyphen (U+00AD); 'zero-width-glue': a zero-width space or soft hyphen the browser doesn't break after; 'control': in Safari, a next-line character (U+0085), which takes letter spacing of its own
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, leaving out spaces and tabs that hang past its end
  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, leaving out spaces and tabs that hang past its end
  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 `segments`
  graphemeIndex: number // Grapheme index within that segment; `0` at segment boundaries
}

Helper for rich-text inline flow:

prepareRichInline(items: Array<RichInlineItem | RichInlineBox>, options?: { whiteSpace?: 'normal' | 'pre-wrap', wordBreak?: 'normal' | 'keep-all' }): PreparedRichInline // prepares the items for layout and, in `white-space: normal`, collapses spaces between them. `whiteSpace` and `wordBreak` are the paragraph's, as in `prepare()`
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 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 (aka on one line), like a chip
  extraWidth?: number // extra width around the text, e.g. padding and borders
}
type RichInlineBox = {
  width: number // the room an object inside the line takes, e.g. an image, in CSS px: its element's margin box. Finite and at least 0
}
type RichInlineCursor = {
  itemIndex: number // Which source item 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 items prepareRichInline() took
  text: string // Text slice for this fragment
  gapBefore: number // collapsed space before this fragment, in pixels; 0 when there's none, and negative under letter spacing more negative than the space is wide
  gapItemIndex: number // index of the item whose collapsed space gapBefore measures, or -1 when no space precedes this fragment on this line
  occupiedWidth: number // text width plus extraWidth, or a box's width
  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 items prepareRichInline() took
  gapBefore: number // collapsed space before this fragment, in pixels; 0 when there's none, and negative under letter spacing more negative than the space is wide
  gapItemIndex: number // index of the item whose collapsed space gapBefore measures, or -1 when no space precedes this fragment on this line
  occupiedWidth: number // text width plus extraWidth, or a box's width
  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(), prepareWithSegments() and prepareRichInline(). Useful if your app cycles through many different fonts or text variants and you want to release the accumulated cache. After a web font loads, call it and prepare that font's text again: widths measured earlier are the fallback font's
setLocale(locale?: string): void // optional (by default we use the page language, from `<html lang>`). Sets locale for future prepare(), prepareWithSegments() and prepareRichInline(). Internally, it also calls clearCache(). Setting a new locale doesn't affect existing prepared states (no mutations to them). A worker has no `<html lang>`, so call it there with the page's `document.documentElement.lang` to give the worker the page's language. Pretext corrects emoji widths by measuring one DOM element, which a worker doesn't have, so in a worker small emoji measure too wide in Chrome and Firefox on macOS

Notes:

  • LayoutCursor is a segment/grapheme cursor, not a raw string offset.
  • Browsers let the spaces at a line's end run past it without counting toward its width, which CSS calls hanging. A line's width leaves out what hangs: all of it where the line wraps, and in pre-wrap, before a newline or at the end of the text, only the part that doesn't fit in maxWidth. Chrome and Safari hang tabs the same way; Firefox counts them in the width. measureNaturalWidth() still counts spaces before a newline, like CSS max-content.
  • layout() with an empty string returns { lineCount: 0, height: 0 }. Browsers still size an empty block to one line-height, so clamp with Math.max(1, lineCount) * lineHeight if you need that behavior.
  • Pretext doesn't give bidi levels or a visual order. If you're drawing mixed bidi text, like English and Arabic, render each paragraph as one DOM element with its direction set, and the browser orders every line. If you draw lines separately, such as with Canvas fillText(), each line is ordered as its own paragraph, so numbers or punctuation next to a line break, or bidi controls that span lines (invisible direction characters such as U+202A-U+202E or U+2066-U+2069), can come out in a different order.
  • A rich-inline fragment's gapBefore is a space in the font and letter spacing of item gapItemIndex: the fragment's own item, the previous fragment's item, or an item holding only whitespace, which gets no fragment. An atomic item's own leading and trailing white space makes no gap, as browsers trim it inside the item's inline-block. Draw the space inside that item's element so it paints at that width.
  • In pre-wrap, rich-inline fragments have no gaps: a fragment's text keeps its spaces, and its occupiedWidth, like the line's width, leaves out what hangs at the line's end. An atomic item's own spaces collapse, as in a chip's white-space: nowrap inline-block, so its cursors index its text prepared without whiteSpace. A tab counts eight spaces of its own item's font, as Safari does; Chrome and Firefox count the paragraph's, so a tab inside an item in another font, such as inline code in prose, can land on another stop there.
  • A rich-inline line is as tall as the paragraph's line height while its text keeps the paragraph font's size, ascent and descent. Text in another size, or in a face whose ascent and descent differ (Helvetica Neue's bold on macOS), makes a line taller, with or without boxes; line-height: 1 on each fragment's element keeps it inside the line, as the rich-note demo does.
  • Segment widths are browser-canvas widths for line breaking, not enough to position individual characters in Arabic or mixed bidi text.

Caveats

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

  • white-space: normal and pre-wrap
  • word-break: normal and keep-all
  • overflow-wrap: break-word, which isn't CSS's default: set it on the text you paint. When a word, a run of symbols or a keep-all group doesn't fit a line, Pretext breaks it between graphemes, as overflow-wrap: break-word does, where CSS's default would let it overflow.
  • line-break: auto
  • letter-spacing as a numeric pixel value passed to prepare() / prepareWithSegments()
  • Tabs follow the default browser-style tab-size: 8
  • In pre-wrap, Pretext treats a lone \r as a line break, but browsers don't. Normalize \r to \n in the text you render, not just the text you measure.
  • system-ui and -apple-system are unsafe for layout() accuracy on macOS. Use a named font, by its English family name ("Hiragino Kaku Gothic ProN", not "ヒラギノ角ゴ ProN" or a face name like "Avenir Next Demi Bold"): Firefox resolves localized family names and face names only seconds after it starts, and text measured before then can stay in a fallback font. See the platform bug ledger for the Chrome and Firefox issues.
  • Page language changes fonts and line breaks, and without lang Chrome and Firefox use the browser's or system's language. A generic font like sans-serif, or a character missing from every named font, may use a different font from the one Pretext measures, and curly quotes can wrap differently per language. Set lang on <html>, list a named font for each script your text uses (16px "Helvetica Neue", "PingFang SC", "Geeza Pro", sans-serif), and check in your browser that a painted element's height matches layout()'s. Pretext doesn't read an element's own lang. If you change <html lang>, prepare your text again: existing handles keep the old widths and line-break rules.
  • Runtime requires Canvas 2D text measurement and Unicode property escapes (\p{...}), and Intl.Segmenter for text in Thai, Lao, Khmer, Myanmar and the other Southeast Asian scripts written without spaces. Browsers without these features aren't supported. Without Unicode property escapes, Pretext can't load and throws a SyntaxError; without Intl.Segmenter, preparing such text throws.
  • Pretext uses the canvas font string. Separate CSS settings such as font-optical-sizing, font-feature-settings, and font-variation-settings aren't supported. Variable-font settings only apply when expressed through that string, such as font weight.
  • Pass font sizes in px. If your CSS sizes text in rem or em, resolve them to px once, higher up in your app (for example, when the root font size changes), and pass that string to Pretext. Firefox measures canvas text at a rounded font size, so a fractional size like 13.33px can wrap differently there; prefer whole-pixel sizes.
  • Pretext assumes default font kerning and word spacing. Text painted with a different font-kerning or word-spacing, including word spacing inherited from the page, can wrap differently.
  • Chrome and Firefox let people set a minimum font size. Text below it paints at the minimum, while Pretext measures the size you pass. If your app uses small sizes, measure the height of an element with font-size: 1px; line-height: 1 once and pass Pretext the larger size.

Develop

See DEVELOPMENT.md for the demo server, engine data and releases.

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.

View on GitHub

Recent activity

commits and pull requests

Code frequency

additions and deletions
+338.7K-338.7KWeek of 2026-03-01: +9,148 linesWeek of 2026-03-01: -1,151 linesWeek of 2026-03-08: +26,960 linesWeek of 2026-03-08: -10,103 linesWeek of 2026-03-15: +323 linesWeek of 2026-03-15: -202 linesWeek of 2026-03-22: +243,551 linesWeek of 2026-03-22: -7,594 linesWeek of 2026-03-29: +18,414 linesWeek of 2026-03-29: -9,909 linesWeek of 2026-04-05: +12,148 linesWeek of 2026-04-05: -8,694 linesWeek of 2026-04-12: +119 linesWeek of 2026-04-12: -55 linesWeek of 2026-04-19: +3,544 linesWeek of 2026-04-19: -1,332 linesWeek of 2026-04-26: +0 linesWeek of 2026-04-26: -0 linesWeek of 2026-05-03: +2,655 linesWeek of 2026-05-03: -2,401 linesWeek of 2026-05-10: +1,126 linesWeek of 2026-05-10: -645 linesWeek of 2026-05-17: +168 linesWeek of 2026-05-17: -54 linesWeek of 2026-05-24: +0 linesWeek of 2026-05-24: -0 linesWeek of 2026-05-31: +0 linesWeek of 2026-05-31: -0 linesWeek of 2026-06-07: +16 linesWeek of 2026-06-07: -5 linesWeek of 2026-06-14: +0 linesWeek of 2026-06-14: -0 linesWeek of 2026-06-21: +1,729 linesWeek of 2026-06-21: -2,632 linesWeek of 2026-06-28: +0 linesWeek of 2026-06-28: -0 linesWeek of 2026-07-05: +0 linesWeek of 2026-07-05: -0 linesWeek of 2026-07-12: +3 linesWeek of 2026-07-12: -29 linesWeek of 2026-07-19: +0 linesWeek of 2026-07-19: -0 linesWeek of 2026-07-26: +0 linesWeek of 2026-07-26: -0 linesWeek of 2026-08-02: +0 linesWeek of 2026-08-02: -0 linesWeek of 2026-08-09: +0 linesWeek of 2026-08-09: -0 linesWeek of 2026-08-16: +0 linesWeek of 2026-08-16: -0 linesWeek of 2026-08-23: +0 linesWeek of 2026-08-23: -0 linesWeek of 2026-08-30: +16,079 linesWeek of 2026-08-30: -236,416 linesWeek of 2026-09-06: +50,958 linesWeek of 2026-09-06: -34,487 linesWeek of 2026-09-13: +30,364 linesWeek of 2026-09-13: -35,386 linesWeek of 2026-09-20: +338,718 linesWeek of 2026-09-20: -137,436 linesWeek of 2026-09-27: +39,419 linesWeek of 2026-09-27: -20,809 linesWeek of 2026-10-04: +12 linesWeek of 2026-10-04: -164 linesMar 1, 2026Oct 4, 2026
+795.5K lines added, -509.5K removed over the last year.

Commits per week

last 52 weeks
3380Week of 2025-10-11: 0 commitsWeek of 2025-10-18: 0 commitsWeek of 2025-10-25: 0 commitsWeek of 2025-11-01: 0 commitsWeek of 2025-11-09: 0 commitsWeek of 2025-11-16: 0 commitsWeek of 2025-11-23: 0 commitsWeek of 2025-11-30: 0 commitsWeek of 2025-12-07: 0 commitsWeek of 2025-12-14: 0 commitsWeek of 2025-12-21: 0 commitsWeek of 2025-12-28: 0 commitsWeek of 2026-01-04: 0 commitsWeek of 2026-01-11: 0 commitsWeek of 2026-01-18: 0 commitsWeek of 2026-01-25: 0 commitsWeek of 2026-02-01: 0 commitsWeek of 2026-02-08: 0 commitsWeek of 2026-02-15: 0 commitsWeek of 2026-02-22: 0 commitsWeek of 2026-03-01: 58 commitsWeek of 2026-03-08: 108 commitsWeek of 2026-03-15: 6 commitsWeek of 2026-03-22: 67 commitsWeek of 2026-03-29: 54 commitsWeek of 2026-04-05: 54 commitsWeek of 2026-04-12: 13 commitsWeek of 2026-04-19: 10 commitsWeek of 2026-04-26: 0 commitsWeek of 2026-05-03: 14 commitsWeek of 2026-05-10: 3 commitsWeek of 2026-05-17: 1 commitsWeek of 2026-05-24: 0 commitsWeek of 2026-05-31: 0 commitsWeek of 2026-06-07: 2 commitsWeek of 2026-06-14: 0 commitsWeek of 2026-06-21: 12 commitsWeek of 2026-06-28: 0 commitsWeek of 2026-07-05: 0 commitsWeek of 2026-07-12: 3 commitsWeek of 2026-07-19: 0 commitsWeek of 2026-07-26: 0 commitsWeek of 2026-08-02: 0 commitsWeek of 2026-08-09: 0 commitsWeek of 2026-08-16: 0 commitsWeek of 2026-08-23: 0 commitsWeek of 2026-08-30: 10 commitsWeek of 2026-09-06: 107 commitsWeek of 2026-09-13: 170 commitsWeek of 2026-09-20: 191 commitsWeek of 2026-09-27: 338 commitsWeek of 2026-10-04: 4 commitsOct 11, 2025Oct 4, 2026
1.2K commits in the last 52 weeks.

When work happens

weekday and hour
SunMonTueWedThuFriSat036912151821Sun 0:00 — 11 commitsSun 1:00 — 14 commitsSun 2:00 — 18 commitsSun 3:00 — 10 commitsSun 4:00 — 5 commitsSun 5:00 — 1 commitsSun 6:00 — 1 commitsSun 7:00 — 3 commitsSun 8:00 — 2 commitsSun 9:00 — 1 commitsSun 10:00 — 3 commitsSun 11:00 — 0 commitsSun 12:00 — 2 commitsSun 13:00 — 4 commitsSun 14:00 — 4 commitsSun 15:00 — 3 commitsSun 16:00 — 8 commitsSun 17:00 — 8 commitsSun 18:00 — 6 commitsSun 19:00 — 10 commitsSun 20:00 — 12 commitsSun 21:00 — 15 commitsSun 22:00 — 9 commitsSun 23:00 — 6 commitsMon 0:00 — 16 commitsMon 1:00 — 7 commitsMon 2:00 — 19 commitsMon 3:00 — 3 commitsMon 4:00 — 2 commitsMon 5:00 — 7 commitsMon 6:00 — 4 commitsMon 7:00 — 5 commitsMon 8:00 — 0 commitsMon 9:00 — 4 commitsMon 10:00 — 3 commitsMon 11:00 — 1 commitsMon 12:00 — 6 commitsMon 13:00 — 4 commitsMon 14:00 — 1 commitsMon 15:00 — 1 commitsMon 16:00 — 3 commitsMon 17:00 — 2 commitsMon 18:00 — 8 commitsMon 19:00 — 4 commitsMon 20:00 — 3 commitsMon 21:00 — 6 commitsMon 22:00 — 2 commitsMon 23:00 — 2 commitsTue 0:00 — 6 commitsTue 1:00 — 9 commitsTue 2:00 — 16 commitsTue 3:00 — 7 commitsTue 4:00 — 3 commitsTue 5:00 — 4 commitsTue 6:00 — 10 commitsTue 7:00 — 9 commitsTue 8:00 — 0 commitsTue 9:00 — 9 commitsTue 10:00 — 4 commitsTue 11:00 — 8 commitsTue 12:00 — 7 commitsTue 13:00 — 8 commitsTue 14:00 — 7 commitsTue 15:00 — 4 commitsTue 16:00 — 6 commitsTue 17:00 — 2 commitsTue 18:00 — 13 commitsTue 19:00 — 7 commitsTue 20:00 — 7 commitsTue 21:00 — 11 commitsTue 22:00 — 16 commitsTue 23:00 — 12 commitsWed 0:00 — 12 commitsWed 1:00 — 8 commitsWed 2:00 — 9 commitsWed 3:00 — 3 commitsWed 4:00 — 6 commitsWed 5:00 — 4 commitsWed 6:00 — 3 commitsWed 7:00 — 4 commitsWed 8:00 — 1 commitsWed 9:00 — 3 commitsWed 10:00 — 6 commitsWed 11:00 — 8 commitsWed 12:00 — 9 commitsWed 13:00 — 3 commitsWed 14:00 — 1 commitsWed 15:00 — 3 commitsWed 16:00 — 0 commitsWed 17:00 — 0 commitsWed 18:00 — 2 commitsWed 19:00 — 20 commitsWed 20:00 — 11 commitsWed 21:00 — 9 commitsWed 22:00 — 33 commitsWed 23:00 — 16 commitsThu 0:00 — 10 commitsThu 1:00 — 22 commitsThu 2:00 — 11 commitsThu 3:00 — 12 commitsThu 4:00 — 16 commitsThu 5:00 — 7 commitsThu 6:00 — 12 commitsThu 7:00 — 1 commitsThu 8:00 — 10 commitsThu 9:00 — 12 commitsThu 10:00 — 2 commitsThu 11:00 — 3 commitsThu 12:00 — 4 commitsThu 13:00 — 5 commitsThu 14:00 — 8 commitsThu 15:00 — 32 commitsThu 16:00 — 39 commitsThu 17:00 — 10 commitsThu 18:00 — 10 commitsThu 19:00 — 10 commitsThu 20:00 — 4 commitsThu 21:00 — 22 commitsThu 22:00 — 13 commitsThu 23:00 — 12 commitsFri 0:00 — 3 commitsFri 1:00 — 4 commitsFri 2:00 — 16 commitsFri 3:00 — 9 commitsFri 4:00 — 13 commitsFri 5:00 — 14 commitsFri 6:00 — 5 commitsFri 7:00 — 1 commitsFri 8:00 — 0 commitsFri 9:00 — 0 commitsFri 10:00 — 1 commitsFri 11:00 — 1 commitsFri 12:00 — 1 commitsFri 13:00 — 0 commitsFri 14:00 — 5 commitsFri 15:00 — 5 commitsFri 16:00 — 1 commitsFri 17:00 — 9 commitsFri 18:00 — 3 commitsFri 19:00 — 3 commitsFri 20:00 — 5 commitsFri 21:00 — 3 commitsFri 22:00 — 4 commitsFri 23:00 — 3 commitsSat 0:00 — 19 commitsSat 1:00 — 12 commitsSat 2:00 — 11 commitsSat 3:00 — 6 commitsSat 4:00 — 11 commitsSat 5:00 — 4 commitsSat 6:00 — 2 commitsSat 7:00 — 8 commitsSat 8:00 — 8 commitsSat 9:00 — 4 commitsSat 10:00 — 6 commitsSat 11:00 — 3 commitsSat 12:00 — 4 commitsSat 13:00 — 8 commitsSat 14:00 — 15 commitsSat 15:00 — 12 commitsSat 16:00 — 17 commitsSat 17:00 — 7 commitsSat 18:00 — 13 commitsSat 19:00 — 3 commitsSat 20:00 — 1 commitsSat 21:00 — 7 commitsSat 22:00 — 9 commitsSat 23:00 — 11 commits
Commit volume by weekday and hour (UTC). Larger dots mean more commits.

Who is committing

last 52 weeks
Maintainer commits1,505 (99%)
Community commits9 (1%)

1,514 commits in total over the last year.

DateListRankStars gained
Apr 7, 2026daily#21+173
Apr 5, 2026daily#25+219
Apr 4, 2026daily#8+382
Apr 3, 2026daily#3+739
Apr 2, 2026daily#4+560
Apr 1, 2026daily#7+455
Mar 31, 2026daily#3+782
Mar 30, 2026daily#1+922
Mar 29, 2026daily#1+1,344
Mar 28, 2026daily#1+468
  • freeCodeCamp/freeCodeCamp

    freeCodeCamp.org's open-source codebase and curriculum. Learn math, programming, and computer science for free.

    456.7K stars · TypeScript

  • openclaw/openclaw

    The AI that really does things. Any OS. Any Platform. The lobster way. 🦞

    391.3K stars · TypeScript

  • anomalyco/opencode

    The open source coding agent.

    211.7K stars · TypeScript

  • n8n-io/n8n

    Fair-code workflow automation platform with native AI capabilities. Combine visual building with custom code, self-host or cloud, 400+ integrations.

    206.7K stars · TypeScript

  • microsoft/vscode

    Visual Studio Code

    193.5K stars · TypeScript

  • firecrawl/firecrawl

    Supercharge your AI agents with data from the web and beyond. Building the library for superintelligence. 🔥

    188.6K stars · TypeScript