リッチレイアウト API の実践: シュリンクラップ、障害物ルーティング、エディトリアルスプレッド
前提知識
- ›Article 1: アーキテクチャと二相モデル
- ›Article 3: 改行エンジン(特に layoutNextLine())
- ›Canvas と SVG の基礎知識
リッチレイアウト API の実践: シュリンクラップ、障害物ルーティング、エディトリアルスプレッド
これまでの 4 記事では、Pretext の内部実装を追ってきました。二相アーキテクチャから始まり、解析パイプライン、改行エンジン、ブラウザ shim まで順を追って見てきましたが、ライブラリの本当の価値はそれが何を可能にするかにあります。本記事では、DOM ベースの計測では実現困難な 3 つのデモアプリケーションを通じて、Pretext の rich tier API を掘り下げます。具体的には、バイナリサーチによるチャットバブルのシュリンクラップ、障害物を考慮したエディトリアルレイアウト、そして任意の形状にテキストを回り込ませるための SVG ポリゴンハル抽出です。
API ティアの違い: Opaque と Segment-Aware
Part 1 で説明したとおり、Pretext には 2 つの API ティアがあります。opaque パス(prepare() + layout())はリサイズのホットパスで、{ lineCount, height } だけを返します。rich パスはこれに加えて 4 つの機能を提供します。
prepareWithSegments()でセグメントのテキストと bidi レベルを公開するlayoutWithLines()でテキスト内容と各行の幅を持つLayoutLine[]を生成するwalkLineRanges()で文字列を生成せずに行のジオメトリを取得するlayoutNextLine()でイテレーター形式の可変幅レイアウトを実現する
flowchart LR
subgraph "Opaque Path"
PP[prepare] --> LL[layout]
LL --> LC["lineCount + height"]
end
subgraph "Rich Path"
PWS[prepareWithSegments] --> LWL[layoutWithLines]
PWS --> WLR[walkLineRanges]
PWS --> LNL[layoutNextLine]
LWL --> Lines["LayoutLine[] with text"]
WLR --> Ranges["LayoutLineRange[] no text"]
LNL --> OneLine["One LayoutLine at a time"]
end
この 2 つのティアが存在するのは、それぞれ異なるパフォーマンス要件に応えるためです。opaque パスは layout() の実行中に一切アロケーションを行いません。オブジェクトも、文字列も、配列も生成しません。一方、rich パスは行オブジェクトのアロケーションやテキストの生成が必要です。レンダリング用途には十分許容できますが、「この幅なら何行になるか?」といった投機的なレイアウト問い合わせには重すぎます。
walkLineRanges() とバイナリサーチによるバブルのシュリンクラップ
bubbles デモは、UI でよく見かける問題を解決します。チャットのメッセージバブルが必要以上に横幅を取ってしまう問題です。メッセージが最大幅で折り返された場合、最後の行が大きく短くなり、無駄な余白が生まれます。
pages/demos/bubbles-shared.ts#L49-L77
シュリンクラップアルゴリズムは、最大幅のときと同じ行数を保ちつつ、最小のコンテナ幅を見つけます。
export function findTightWrapMetrics(prepared, maxWidth) {
const initial = collectWrapMetrics(prepared, maxWidth)
let lo = 1
let hi = Math.max(1, Math.ceil(maxWidth))
while (lo < hi) {
const mid = Math.floor((lo + hi) / 2)
const midLineCount = layout(prepared, mid, LINE_HEIGHT).lineCount
if (midLineCount <= initial.lineCount) {
hi = mid
} else {
lo = mid + 1
}
}
return collectWrapMetrics(prepared, lo)
}
sequenceDiagram
participant App as Bubble Renderer
participant Layout as layout()
participant Walk as walkLineRanges()
App->>Walk: collectWrapMetrics(prepared, maxWidth)
Walk-->>App: {lineCount: 3, maxLineWidth: 342}
loop Binary Search
App->>Layout: layout(prepared, mid, LINE_HEIGHT)
Layout-->>App: {lineCount: N}
Note over App: if N <= 3: hi = mid<br/>else: lo = mid + 1
end
App->>Walk: collectWrapMetrics(prepared, lo)
Walk-->>App: {lineCount: 3, maxLineWidth: 289}
Note over App: Tight width = 289 (saved 53px)
これが成り立つのは、layout() の実行が非常に高速(約 0.0002ms)で、バイナリサーチで 10 回程度呼び出してもオーバーヘッドがほぼゼロだからです。collectWrapMetrics() では walkLineRanges() を使い、指定したコンテナ幅における実際の最大行幅を求めます。これは文字列オブジェクトを生成せずに行幅を計算する、非マテリアライズ型のジオメトリパスです。
pages/demos/bubbles-shared.ts#L49-L59
export function collectWrapMetrics(prepared, maxWidth) {
let maxLineWidth = 0
const lineCount = walkLineRanges(prepared, maxWidth, line => {
if (line.width > maxLineWidth) maxLineWidth = line.width
})
return { lineCount, height: lineCount * LINE_HEIGHT, maxLineWidth }
}
layout.ts の walkLineRanges() は、layout() と同じ walkPreparedLines() エンジンに処理を委譲しつつ、各行のジオメトリを onLine コールバックで通知します。
ヒント: シュリンクラップには文字列の生成は不要です。
walkLineRanges()を使えば、行テキストをアロケーションせずに各行の幅とカーソルを取得できます。layoutWithLines()はレンダリングのために実際のテキスト内容が必要な場合にのみ使いましょう。
layoutNextLine() による可変幅フロー
dynamic-layout デモでは、2 カラムにわたって回転可能な SVG ロゴの周りに本文テキストが回り込むエディトリアル誌面のレイアウトを実現しています。これには可変幅レイアウトが必要です。各行の利用可能な幅が、その垂直バンドにどの障害物が存在するかによって変わるためです。
pages/demos/dynamic-layout.ts#L1-L24
処理の流れは次のとおりです。
- 本文テキストを一度だけ prepare する
- 各行について、カラム幅から障害物のインターバルを引いた利用可能幅を計算する
- その幅で
layoutNextLine()を呼び出す - 返ってきた end カーソルを次の行の start として渡す
sequenceDiagram
participant App as Editorial Layout
participant Geo as Wrap Geometry
participant LNL as layoutNextLine()
Note over App: Prepared body text once
loop For each line slot
App->>Geo: carveTextLineSlots(column, obstacles)
Geo-->>App: Available intervals [80..290, 340..500]
App->>LNL: layoutNextLine(prepared, cursor, 210)
LNL-->>App: {text: "line content", end: cursor2}
App->>LNL: layoutNextLine(prepared, cursor2, 160)
LNL-->>App: {text: "more content", end: cursor3}
end
左カラムが先にテキストを消費し、右カラムは同じカーソルの続きから再開します。テキストの重複も再セグメンテーションも発生しません。layout.ts の layoutNextLine() API がこの仕組みをすべて担っています。
export function layoutNextLine(prepared, start, maxWidth) {
const line = stepLineRange(prepared, start, maxWidth)
if (line === null) return null
return materializeLine(prepared, line)
}
内部的には、stepPreparedLineRange()(1 行分を走査してカーソルを返す)と materializeLine()(セグメントデータから文字列を構築する)の 2 つの処理に分かれています。Part 3 で説明したとおり、カーソルの正規化は先頭の空白をスキップし、ハードブレークのチャンク境界を適切に処理します。
layoutWithLines() によるバッチマテリアライゼーション
すべての行が同じ幅を共有するシンプルなケースでは、layoutWithLines() でまとめて行をマテリアライズできます。
export function layoutWithLines(prepared, maxWidth, lineHeight) {
const lines = []
const graphemeCache = getLineTextCache(prepared)
const lineCount = walkPreparedLines(getInternalPrepared(prepared), maxWidth, line => {
lines.push(materializeLayoutLine(prepared, graphemeCache, line))
})
return { lineCount, height: lineCount * lineHeight, lines }
}
buildLineTextFromRange() は行テキストのマテリアライズにおける難しい部分、特にオプショナルハイフンの挿入を担います。
行がソフトハイフンの位置で終わる場合、この関数はマテリアライズされたテキストにリテラルの - を挿入します。行ウォーカーの改行判断が元のテキストに存在しない文字を表示上生み出す、唯一の箇所です。
グラフェムキャッシュについても触れておきましょう。getLineTextCache() は Map<number, string[]> を返し、その実体は prepared ハンドルをキーとする WeakMap に格納されています。
let sharedLineTextCaches = new WeakMap<PreparedTextWithSegments, Map<number, string[]>>()
WeakMap を使うことで、PreparedTextWithSegments ハンドルがガベージコレクトされると、そのグラフェムキャッシュも自動的に解放されます。手動でのクリーンアップは不要です。
flowchart TD
A[layoutWithLines call] --> B[walkPreparedLines]
B --> C[onLine callback per line]
C --> D[materializeLayoutLine]
D --> E[buildLineTextFromRange]
E --> F{Line ends with soft-hyphen?}
F -->|yes| G["Insert '-' in text"]
F -->|no| H[Concatenate segment text]
G --> I[Create LayoutLine]
H --> I
I --> J[Push to lines array]
J --> B
B --> K["Return { lineCount, height, lines }"]
ラップジオメトリ: SVG のアルファチャンネルからポリゴンハルへ
デモの中でもとりわけ視覚的に印象的な機能が、SVG ロゴへのテキスト回り込みです。wrap-geometry.ts モジュールは、SVG 画像を障害物回避に使えるポリゴンハルに変換するパイプラインを実装しています。
pages/demos/wrap-geometry.ts#L157-L216
処理の流れは次のとおりです。
- SVG 画像を読み込んでデコードする
- 扱いやすい解像度(最大 320px)で
OffscreenCanvasにラスタライズする getImageData()からアルファチャンネルを取り出す- 各行について、最も左と右にある不透明ピクセルを探す
- バウンディングボックスを計算し、座標を 0〜1 に正規化する
- 必要に応じてローリングウィンドウで輪郭を平滑化する
- 最終的なポリゴンハルを
Point[]として構築する
flowchart TD
A[SVG URL] --> B[Image decode]
B --> C[Rasterize to OffscreenCanvas]
C --> D[getImageData — extract alpha]
D --> E[Per-row: find leftmost/rightmost opaque pixel]
E --> F[Normalize to 0..1 coordinates]
F --> G[Optional smoothing]
G --> H["Point[] polygon hull"]
H --> I[Cache by src + options]
ハルはレンダリング時に transformWrapPoints() で変換されます。この関数が正規化座標をスクリーン座標にマッピングし、回転を適用します。
pages/demos/wrap-geometry.ts#L37-L58
carveTextLineSlots() 関数は、全幅の行インターバルから障害物が占有するインターバルを引き算し、残りの利用可能なテキストスロットを返します。
pages/demos/wrap-geometry.ts#L136-L155
export function carveTextLineSlots(base, blocked) {
let slots = [base]
for (const interval of blocked) {
const next = []
for (const slot of slots) {
if (interval.right <= slot.left || interval.left >= slot.right) {
next.push(slot)
continue
}
if (interval.left > slot.left) next.push({ left: slot.left, right: interval.left })
if (interval.right < slot.right) next.push({ left: interval.right, right: slot.right })
}
slots = next
}
return slots.filter(slot => slot.right - slot.left >= 24) // discard tiny slivers
}
24px の最小幅フィルターは実用上の判断です。2 文字分にも満たない幅のテキストスロットは見た目が悪く、レイアウト処理も無駄になります。
ヒント: ポリゴンハルの計算は SVG ごとに一度だけ行われ、結果は
Map<string, Promise<Point[]>>にキャッシュされます。回転やスケーリングはレンダリング時にアフィン変換で適用されるため、ドラッグや回転操作中の障害物再計算を低コストに保てます。
次回予告
rich API ティアの全体像を見てきました。バイナリサーチによるシュリンクラップのシンプルな優雅さから、障害物ルーティングを使ったエディトリアルレイアウトの複雑な仕組みまで紹介しました。これらのデモは Pretext の二相アーキテクチャが単にリサイズを高速化するだけでなく、同期的な DOM 計測では実現不可能なまったく新しいカテゴリのテキストレイアウトを可能にすることを示しています。
最終回となる Part 6 では、3 つのブラウザと十数種類の言語スクリプトにわたって Pretext の正確性を担保するテストと検証のインフラを取り上げます。決定論的なフェイク Canvas テスト、自動化されたブラウザ精度スイープ、多言語コーパスの検証、そして組織的な知見を蓄積する RESEARCH.md について詳しく見ていきます。