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.

相关推荐
Hommy882 小时前
【开源剪映小助手-客户端】目录扫描功能
python·开源·aigc·剪映小助手
九狼JIULANG2 小时前
【无标题】
android·flutter·开源·github
摸鱼仙人~2 小时前
前端开发中“共享预览链接”场景-企业级最简方案:Vue + 极简后端(2 接口 1 张表)
前端·javascript·vue.js
七夜zippoe2 小时前
应用安全实践(一):常见Web漏洞(OWASP Top 10)与防护
java·前端·网络·安全·owasp
reasonsummer2 小时前
【白板类-03-01】20260402画板01(html+希沃白板)
前端·html
"Wild dream"2 小时前
NodeJs内置的Npm
前端·npm·node.js
光影少年2 小时前
vite 8 发布,双引擎时代结束
前端·javascript·前端框架
kyriewen1111 小时前
你点的“刷新”是假刷新?前端路由的瞒天过海术
开发语言·前端·javascript·ecmascript·html5
skywalk816313 小时前
Kotti Next的tinyfrontend前端模仿Kotti 首页布局还是不太好看,感觉比Kotti差一点
前端