PreTeXt 开源推荐(应用demo)

Pretext 是一个纯 JavaScript/TypeScript 库,专门用于多行文本的测量(measurement)和布局(layout)。

repo:https://github.com/chenglou/pretext

demo

https://pretext-playground.builderz.dev/


https://chenglou.me/pretext/


Illustrated Manuscript


Pretext

Pure JavaScript/TypeScript multiline text measurement and layout for browser-grounded typography.

Call prepare() once, then keep relayout cheap with layout(): no DOM reads, no reflow, no canvas calls in the hot path. It handles mixed scripts, emoji, bidi-heavy app text, and the browser's annoying little line-breaking habits much better than "count chars and pray".

Pretext side-steps DOM measurement APIs like getBoundingClientRect() and offsetHeight, which can force synchronous layout. It treats the browser's own font engine as ground truth during prepare time, then keeps resize work arithmetic-only. That's the missing piece for things like virtualization, scroll anchoring, shrinkwrap bubbles, editor-ish textareas, and custom editorial layouts.

Installation

复制代码
npm install @chenglou/pretext

Demos

Clone the repo, run bun install, then bun start, and open /demos/index locally.

Live demos:

Useful stops:

  • /demos/bubbles for multiline shrinkwrap with walkLineRanges()
  • /demos/dynamic-layout for obstacle-aware streaming layout with layoutNextLine()
  • /demos/markdown-chat for virtualized rich chat layout
  • /demos/rich-note for mixed inline runs, chips, and the inline-flow sidecar
  • /accuracy and /benchmark for the browser-oracle and performance pages

Mental Model

  • prepare() and prepareWithSegments() do the one-time work: normalize whitespace, segment text, apply glue rules, and measure with canvas.
  • layout() and the rich line APIs consume that prepared handle at whatever width and lineHeight you need.
  • prepare() is the opaque fast path. prepareWithSegments() is the richer escape hatch for custom rendering, cursor-based reflow, geometry-only line walking, and bidi-aware manual layout.
  • If width changed but text/font/options did not, rerun layout(), not prepare().

1. Fast Height Prediction

复制代码
import { prepare, layout } from '@chenglou/pretext'

const prepared = prepare('AGI 春天到了. بدأت الرحلة 🚀‎', '16px Inter')
const { height, lineCount } = layout(prepared, textWidth, 20)

prepare() is width-independent. Do it once when the text or font changes. layout() is the cheap resize-time pass after that.

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

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

If you want CSS-style word-break: keep-all behavior for CJK/Hangul text and CJK-leading no-space mixed-script runs, pass { wordBreak: 'keep-all' }:

复制代码
const prepared = prepare(headline, '16px Inter', { wordBreak: 'keep-all' })
const { height } = layout(prepared, columnWidth, 20)

This is the path for:

  • virtualization without DOM measuring loops
  • scroll anchoring when late text arrives
  • browser-free overflow checks during development
  • UI layout systems that need text height as an input instead of a side effect

Current accuracy and benchmark snapshots live in STATUS.md and status/dashboard.json. Keep numeric claims there, not hard-coded into app code or old README prose.

2. Rich Line Layout And Manual Reflow

When you need actual lines, stable cursors, or variable-width flow, switch to prepareWithSegments().

Fixed-width layout:

复制代码
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext'

const prepared = prepareWithSegments('AGI 春天到了. بدأت الرحلة 🚀', '18px "Helvetica Neue"')
const { lines } = layoutWithLines(prepared, 320, 26)

for (let i = 0; i < lines.length; i++) {
  ctx.fillText(lines[i].text, 0, i * 26)
}

Geometry-only layout, no line strings:

复制代码
import { measureLineGeometry, walkLineRanges } from '@chenglou/pretext'

const { lineCount, maxLineWidth } = measureLineGeometry(prepared, 320)
let maxWidth = 0

walkLineRanges(prepared, 320, line => {
  if (line.width > maxWidth) maxWidth = line.width
})

Streaming layout 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

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
}

The rich APIs all carry line boundary cursors (start / end), so you can keep flowing text through columns, obstacles, or custom renderers without falling back to string offsets.

3. Experimental Inline-Flow Sidecar

If your "rich text" problem is really "a few inline runs with different fonts, plus some atomic chips and browser-like boundary whitespace collapse", there is a deliberately small sidecar at @chenglou/pretext/inline-flow.

复制代码
import { prepareInlineFlow, walkInlineFlowLines } from '@chenglou/pretext/inline-flow'

const prepared = prepareInlineFlow([
  { text: 'Ship ', font: '500 17px Inter' },
  { text: '@maya', font: '700 12px Inter', break: 'never', extraWidth: 22 },
  { text: "'s rich-note", font: '500 17px Inter' },
])

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

It is intentionally narrow:

  • raw inline text in, including boundary spaces
  • caller-owned extraWidth for pill chrome
  • break: 'never' for atomic items like chips and mentions
  • white-space: normal only
  • not a nested markup tree and not a general CSS inline formatting engine

API Reference

Core Fast Path

复制代码
prepare(
  text: string,
  font: string,
  options?: { whiteSpace?: 'normal' | 'pre-wrap', wordBreak?: 'normal' | 'keep-all' },
): PreparedText

layout(
  prepared: PreparedText,
  maxWidth: number,
  lineHeight: number,
): { height: number, lineCount: number }

Rich Layout

复制代码
prepareWithSegments(
  text: string,
  font: string,
  options?: { whiteSpace?: 'normal' | 'pre-wrap', wordBreak?: 'normal' | 'keep-all' },
): PreparedTextWithSegments

layoutWithLines(
  prepared: PreparedTextWithSegments,
  maxWidth: number,
  lineHeight: number,
): { height: number, lineCount: number, lines: LayoutLine[] }

walkLineRanges(
  prepared: PreparedTextWithSegments,
  maxWidth: number,
  onLine: (line: LayoutLineRange) => void,
): number

measureLineGeometry(
  prepared: PreparedTextWithSegments,
  maxWidth: number,
): { lineCount: number, maxLineWidth: number }

measureNaturalWidth(
  prepared: PreparedTextWithSegments,
): number

layoutNextLineRange(
  prepared: PreparedTextWithSegments,
  start: LayoutCursor,
  maxWidth: number,
): LayoutLineRange | null

materializeLineRange(
  prepared: PreparedTextWithSegments,
  line: LayoutLineRange,
): LayoutLine

layoutNextLine(
  prepared: PreparedTextWithSegments,
  start: LayoutCursor,
  maxWidth: number,
): LayoutLine | null

Inline-Flow Sidecar

复制代码
prepareInlineFlow(items: InlineFlowItem[]): PreparedInlineFlow

layoutNextInlineFlowLineRange(
  prepared: PreparedInlineFlow,
  maxWidth: number,
  start?: InlineFlowCursor,
): InlineFlowLineRange | null

layoutNextInlineFlowLine(
  prepared: PreparedInlineFlow,
  maxWidth: number,
  start?: InlineFlowCursor,
): InlineFlowLine | null

walkInlineFlowLineRanges(
  prepared: PreparedInlineFlow,
  maxWidth: number,
  onLine: (line: InlineFlowLineRange) => void,
): number

walkInlineFlowLines(
  prepared: PreparedInlineFlow,
  maxWidth: number,
  onLine: (line: InlineFlowLine) => void,
): number

measureInlineFlowGeometry(
  prepared: PreparedInlineFlow,
  maxWidth: number,
): { lineCount: number, maxLineWidth: number }

measureInlineFlow(
  prepared: PreparedInlineFlow,
  maxWidth: number,
  lineHeight: number,
): { height: number, lineCount: number }

Diagnostics And Maintenance

复制代码
profilePrepare(
  text: string,
  font: string,
  options?: { whiteSpace?: 'normal' | 'pre-wrap', wordBreak?: 'normal' | 'keep-all' },
): {
  analysisMs: number
  measureMs: number
  totalMs: number
  analysisSegments: number
  preparedSegments: number
  breakableSegments: number
}

clearCache(): void

setLocale(locale?: string): void

profilePrepare() is mainly for benchmark and diagnostic work. It splits prepare() into analysis and measurement phases without changing the public data model.

Useful Public Types

复制代码
type LayoutCursor = {
  segmentIndex: number
  graphemeIndex: number
}
type LayoutLine = {
  text: string
  width: number
  start: LayoutCursor
  end: LayoutCursor
}

type LayoutLineRange = {
  width: number
  start: LayoutCursor
  end: LayoutCursor
}

type InlineFlowItem = {
  text: string
  font: string
  break?: 'normal' | 'never'
  extraWidth?: number
}

type InlineFlowCursor = {
  itemIndex: number
  segmentIndex: number
  graphemeIndex: number
}

type InlineFlowFragment = {
  itemIndex: number
  text: string
  gapBefore: number
  occupiedWidth: number
  start: LayoutCursor
  end: LayoutCursor
}

type InlineFlowLine = {
  fragments: InlineFlowFragment[]
  width: number
  end: InlineFlowCursor
}

type InlineFlowFragmentRange = {
  itemIndex: number
  gapBefore: number
  occupiedWidth: number
  start: LayoutCursor
  end: LayoutCursor
}

type InlineFlowLineRange = {
  fragments: InlineFlowFragmentRange[]
  width: number
  end: InlineFlowCursor
}

Notes:

  • PreparedText is the opaque fast-path handle. PreparedTextWithSegments is the richer manual-layout handle.
  • LayoutCursor is a segment/grapheme cursor, not a raw string offset.
  • If a soft hyphen wins the break, rich line.text materialization includes the visible trailing -.
  • measureNaturalWidth() returns the widest forced line. Hard breaks still count.
  • prepare() and prepareWithSegments() do horizontal-only work. lineHeight stays a layout-time input.

Caveats

Pretext is not trying to be a full browser inline formatting engine. The current target is:

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

If you pass { wordBreak: 'keep-all' }, Pretext suppresses ordinary CJK/Hangul intra-word breaks and keeps CJK-leading no-space mixed-script runs cohesive, while keeping the same overflow-wrap: break-word fallback for overlong runs.

Other important caveats:

  • system-ui is unsafe for accuracy on macOS. Canvas and DOM can resolve different optical variants.
  • Very narrow widths can still break inside words, but only at grapheme boundaries. That's the overflow-wrap: break-word part.
  • The inline-flow sidecar is intentionally white-space: normal-only.
  • Browser environments are the supported target today. Server canvas is still "maybe later", not a documented promise.

Develop

See DEVELOPMENT.md for commands and STATUS.md for the checked-in browser-accuracy and benchmark snapshots.

Credits

Sebastian Markbage first planted the seed with text-layout last decade. Canvas measureText for shaping, bidi from pdf.js, and streaming line breaking were the original spark. Pretext kept the basic instinct, then pushed much harder on browser-oracle accuracy, preprocessing, and userland layout APIs.

相关推荐
程序员大阳2 小时前
HTML+CSS+JS对话式实战(5)-活动管理页之查看报名名单的实现
javascript·css·html·查看报名名单
曦樂~2 小时前
【Web Development】02 CSS
前端·css
风骏时光牛马2 小时前
后端服务接口开发与数据逻辑处理
前端
东鹏特饮2 小时前
经典打砖块(Breakout)游戏
前端·css·css3
IT_陈寒3 小时前
Vite的HMR把我整不会了:一个生产环境的热更新坑
前端·人工智能·后端
Hone3 小时前
juejin-skills 系列 ②:我把「看掘金热榜」也做成了 Agent 技能
开源·node.js·ai编程
Mh3 小时前
如何使用双缓冲优化视频切换
前端
risenav3 小时前
低头蓄力,抬头起跳:《萤虫跳跳》的 Rokid AIUI 开发实战
前端
ZhengEnCi3 小时前
如何看待 Fable 5.2 用纯代码生成 1.4MB 视觉作品:光追、水面、倒影、火光的程序化生成原理
前端
fthux4 小时前
总想着“忙完再休息”?让 Tick Rest 提醒你,先歇一会儿
开源·github·mac·swift·提醒·消息提醒·tick rest·tick rest for mac·消息提醒app