Pretext

Pure JS/TS · DOM リフロー不要 · 多言語テキスト計測 & レイアウトライブラリ

1 — 概要

Pretext は、getBoundingClientRectoffsetHeight などの DOM 計測を一切使わずに、テキストの折り返し後の高さと行数を計算する Pure JS/TypeScript ライブラリ。DOM 計測はブラウザの同期レイアウトリフローを引き起こし、500 テキストブロックを個別に計測すると 1 フレームで 30ms 以上失う原因になる。Pretext はブラウザの font エンジンを正解として canvas の measureText で字幅をキャッシュし、以降の高さ計算を純粋な算術演算で行う。

19ms
prepare() / 500テキスト
0.09ms
layout() / 同バッチ
CJK / Bidi
多言語 + 絵文字対応

DOM に触れないことで得られるメリット: 仮想化・サーバーサイドレンダリング・WebGL テキスト計測など、通常 DOM が使えない文脈でもレイアウト計算が可能になる。

2 — 仕組み(二段階処理)

処理を「一度だけ走る重い前処理(prepare)」と「リサイズのたびに走る軽い計算(layout)」の二段階に分けることで、ホットパスのコストを最小化する。

prepare()
テキスト正規化 · Intl.Segmenter でセグメント分割 · canvas measureText で字幅計測 · 結果をキャッシュ
PreparedText
不透明なキャッシュオブジェクト(再利用可能)
layout()
キャッシュ済み幅 + maxWidth + lineHeight で純粋算術 → { height, lineCount }
重要: 同じテキスト・フォント設定に対して prepare() を何度も呼ばないこと。リサイズ時は layout() だけ再実行するのが正しいパターン。
  • CJK: Intl.Segmenter により文字単位で改行処理(日本語・中国語・韓国語)
  • Bidi (双方向テキスト): Arabic/Hebrew など RTL 混在テキストのメタデータを付与
  • 絵文字補正: Chrome/Firefox は macOS で小さいフォントサイズのとき canvas が絵文字を DOM より広く計測する。Pretext は DOM との差分を 1 回だけ測定してキャッシュし、自動補正する
  • 禁則処理: 句読点が行頭/行末に来ないよう kinsoku ルールを適用
  • overflow-wrap: 長い単語はグラフィーム境界で折り返す
3 — API : ユースケース 1(高さ計測のみ)

最もシンプルな使い方。prepare() で前処理してから layout() で高さを計算する。

typescript
import { prepare, layout } from '@chenglou/pretext'

// ① テキストを事前計測(フォントが確定したら一度だけ実行)
const prepared = prepare('AGI 春天到了. بدأت الرحلة 🚀', '16px Inter')

// ② リサイズのたびに高さを計算(DOM アクセスなし)
const { height, lineCount } = layout(prepared, textWidth, 20)

// textarea スタイルの改行・タブを維持したい場合
const prepared2 = prepare(textareaValue, '16px Inter', { whiteSpace: 'pre-wrap' })
const { height: h2 } = layout(prepared2, textareaWidth, 20)
関数 引数 戻り値 説明
prepare() text: string
font: string
options?: { whiteSpace? }
PreparedText 正規化・セグメント・計測を一括処理。font は CSS font 短縮形と同じ形式(例: "16px Inter"
layout() prepared: PreparedText
maxWidth: number
lineHeight: number
{ height, lineCount } キャッシュ済み幅から純粋算術で計算。DOM アクセスなし。~0.0002ms/テキスト
4 — API : ユースケース 2(手動レイアウト)

prepareWithSegments() を使うと、各行のテキスト文字列・幅・カーソル情報を取得できる。Canvas / SVG / サーバーサイド描画など、DOM を使えない環境での実際の描画に使う。

typescript
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext'

const prepared = prepareWithSegments('AGI 春天到了. بدأت الرحلة 🚀', '18px "Helvetica Neue"')
const { lines } = layoutWithLines(prepared, 320, 26)  // 320px 幅、26px 行高
for (let i = 0; i < lines.length; i++) {
  ctx.fillText(lines[i].text, 0, i * 26)
}
typescript
// 最も幅の広い行を調べる("shrink-wrap" レイアウト)
let maxW = 0
walkLineRanges(prepared, 320, line => {
  if (line.width > maxW) maxW = line.width
})
// maxW = テキストがちょうど収まる最小コンテナ幅
typescript
// 画像の横にテキストを流し込む(行によって幅が異なる)
let cursor = { segmentIndex: 0, graphemeIndex: 0 }
let y = 0
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
}
関数 / 型概要
prepareWithSegments(text, font, opts?) prepare() と同じ前処理を行い、手動レイアウト用の豊富な構造を返す
layoutWithLines(prepared, maxWidth, lineHeight) 全行の { text, width, start, end } 配列を返す。固定幅向け
walkLineRanges(prepared, maxWidth, onLine) 行テキストを生成せず幅とカーソルだけ返す。バイナリサーチ等の投機的探索に最適
layoutNextLine(prepared, start, maxWidth) イテレータ型 API。1 行ずつ取得し、行ごとに異なる幅を指定できる
LayoutLine { text: string; width: number; start: LayoutCursor; end: LayoutCursor }
LayoutCursor { segmentIndex: number; graphemeIndex: number }
clearCache() 内部キャッシュを全クリア。フォントを多種切り替えるアプリでメモリ管理に使用
setLocale(locale?) ロケールを変更(呼び出し時に clearCache() も実行)。既存の prepared 値には影響しない
5 — 注意事項

Pretext はフル機能のフォントエンジンではなく、以下の CSS 設定を前提としている。

CSS プロパティ想定値備考
white-spacenormal or pre-wrappre-wrap はオプションで有効化。スペース・タブ・改行を保持
word-breaknormal
overflow-wrapbreak-word非常に狭い幅ではグラフィーム境界で単語内折り返し可能
line-breakauto
macOS の system-ui に注意: canvas は DOM と異なる optical variant に解決されるため計測精度が落ちる。"Inter""Helvetica" など名前付きフォントを使うこと。