Read OSS

テキストレイアウトの検証:コーパス、ブラウザスイープ、そして大規模な精度保証

中級

前提知識

  • 第1回:アーキテクチャと2フェーズモデル
  • テストの基礎概念とCI/CDの理解
  • クロスブラウザの差異に関する基本的な知識

テキストレイアウトの検証:コーパス、ブラウザスイープ、そして大規模な精度保証

テキストレイアウトライブラリが正しい結果を返しているかどうか、どうすれば確かめられるでしょうか。Pretextにとって「正しい」とは「ブラウザのネイティブレイアウトと一致する」ことを意味します。つまり、行数・高さ・改行位置がすべてのコンテナ幅で一致しなければなりません。対象ブラウザはChrome、Safari、Firefox。対応言語は英語、アラビア語、中国語、日本語、韓国語、クメール語、ミャンマー語、ウルドゥー語、ヘブライ語、そして複数スクリプトの混在テキスト。フォントサイズは10pxから28px。コンテナ幅は1pxから900pxの整数値すべて。

これは非常に高い正確性の目標ですが、Pretextはそれを維持するための徹底した検証インフラを構築しています。この最終回では、そのインフラを支える3つの柱を掘り下げます。決定論的なフェイクキャンバスを使ったユニットテスト、自動化されたブラウザ精度スイープ、そしてミスマッチの分類体系を備えた多言語コーパス検証です。

ユニットテスト:決定論的なフェイクキャンバス

layout.test.ts のユニットテストスイートは、Canvasに対するテストという根本的な課題を、現実的なアプローチで解決しています。measureText() を決定論的な幅計算関数に差し替えるのです。

src/layout.test.ts#L1-L8

冒頭のコメントがその思想をよく表しています。

// Keep the permanent suite small and durable. These tests exercise the shipped
// prepare/layout exports with a deterministic fake canvas backend.

フェイクの measureWidth() 関数は、文字の種類に応じて決定論的な幅を割り当てます。

src/layout.test.ts#L71-L92

function measureWidth(text, font) {
  const fontSize = parseFontSize(font)
  let width = 0
  for (const ch of text) {
    if (ch === ' ')           width += fontSize * 0.33
    else if (ch === '\t')     width += fontSize * 1.32
    else if (isEmoji(ch))     width += fontSize
    else if (isWideChar(ch))  width += fontSize  // CJK
    else if (isPunctuation(ch)) width += fontSize * 0.4
    else                      width += fontSize * 0.6
  }
  return width
}

これにより、フォントエンジンへの依存なしに再現性のある幅を計算できます。テキスト解析には本物の Intl.Segmenter をそのまま使っており、差し替えるのは計測バックエンドだけです。つまり、セグメンテーション・マージカスケード・改行種別の分類といった解析パイプライン全体は実際のコードが動き、改行の振る舞いだけが決定論的に制御されます。

flowchart TD
    A[Test input text] --> B[Real Intl.Segmenter]
    B --> C[Real analysis pipeline]
    C --> D[Fake Canvas measureText]
    D --> E[Real line walker]
    E --> F[Deterministic results]
    style D fill:#ff9,stroke:#333

テストスイートでは、いくつかのカテゴリの不変条件を検証しています。

  • 再構築: 3つのリッチAPI(layoutWithLineswalkLineRangeslayoutNextLine)が生成した行から、正規化済みの元テキストを復元できること
  • カーソルの単調性: 各行の末尾カーソルが厳密に増加すること
  • API間の一貫性: layout()layoutWithLines() の行数が一致すること
  • ストリーミングの等価性: 固定幅入力に対して layoutNextLine()layoutWithLines() と同一の行を生成すること
  • 可変幅ストリーミング: 行ごとの幅配列を渡した layoutNextLine() が、有効かつ復元可能な出力を生成すること

reconstructFromLineBoundaries()collectStreamedLines() は、これらの検証を支える中心的なヘルパーです。

src/layout.test.ts#L136-L159

ヒント: 「小さく、堅牢に」というテスト哲学のもと、ブラウザ固有のエッジケースは使い捨てのプローブやブラウザチェッカースクリプトで調査し、永続的なテストスイートには追加しません。安定した不変条件だけが、永続テストに昇格します。

ブラウザ精度スイープ

ユニットテストが内部的な一貫性を検証するのに対し、ブラウザ精度スイープは外部的な正確性を検証します。つまり、Pretextの出力が実際のブラウザのレイアウトと一致するかどうかを確かめます。

accuracy-check.ts スクリプトがこの比較を自動化します。

scripts/accuracy-check.ts#L1-L14

スイープの流れは次のとおりです。

  1. 精度確認用ページを配信するローカルのBunサーバーを起動する
  2. 自動化経由でブラウザ(Chrome、Safari、またはFirefox)を起動する
  3. 各テストケースについて、さまざまなコンテナ幅でDOMのテキスト高さを計測する
  4. Pretextが予測した行数と比較する
  5. 行ごとの診断情報とともにミスマッチを報告する

結果はJSONスナップショットとしてリポジトリにチェックインされます。

ファイル 内容
accuracy/chrome.json Chromeの全精度データ
accuracy/safari.json Safariの全精度データ
accuracy/firefox.json Firefoxの全精度データ
status/dashboard.json 機械可読な集計ダッシュボード
flowchart TD
    A["accuracy-check.ts"] --> B["Start Bun server"]
    B --> C["Launch browser via automation"]
    C --> D["Navigate to accuracy page"]
    D --> E["For each test case × width"]
    E --> F["DOM: measure actual height"]
    E --> G["Pretext: layout() predicted height"]
    F --> H{Match?}
    G --> H
    H -->|yes| I[Record match]
    H -->|no| J[Record mismatch with diagnostics]
    I --> K["Write accuracy/*.json"]
    J --> K
    K --> L["Update status/dashboard.json"]

ミスマッチレポートの行ごとの診断情報は、デバッグに欠かせません。ブラウザがPretextと異なる改行を選択した正確な行が分かるため、推測に頼らずピンポイントで調査できます。

スイープは各ブラウザで個別に実行します。第4回で見たように、ブラウザ固有のフラグによって結果が意図的に異なるためです。Chromeでのミスマッチは carryCJKAfterClosingQuote の問題かもしれませんし、Safariでのミスマッチは preferPrefixWidthsForBreakableRuns の問題かもしれません。

多言語コーパス検証

厳選された精度テストケースに加え、Pretextはコーパスシステムを使ってリアルな多言語テキストに対しても検証を行います。

scripts/corpus-sweep.ts#L1-L11

コーパスシステムの構成要素は次のとおりです。

  • ソース: アラビア語、中国語、日本語、韓国語、クメール語、ミャンマー語、ウルドゥー語、ヘブライ語、および混在スクリプトの実テキスト
  • 代表カナリア: スクリプト固有のエッジケースを確実に踏むことが分かっているテキストを厳選したサブセット(corpora/representative.json
  • スイープスナップショット: サンプリングされた幅と10pxステップの細粒度幅でのチェックイン済み結果

方法論は「スイープは安く広く、診断は狭く深く」です。

  1. スイープ: 全コーパステキストをさまざまなコンテナ幅(例:300〜900pxを10pxステップ)で実行します。layout() は高速なので、このステップは素早く終わります。
  2. 特定: 予測行数と実際の行数が一致しない幅を見つけます。
  3. 診断: ミスマッチが発生した幅についてのみ、コストのかかる行ごとの診断比較を実行して、改行の差異を正確に特定します。

RESEARCH.mdには、各スクリプトの現在の調査状況がまとめられています。

RESEARCH.md#L9-L19

- Japanese: two real canaries (羅生門, 蜘蛛の糸), both clean at anchor widths
- Chinese: two long-form canaries (祝福, 故鄉) with real font sensitivity
- Myanmar: two canaries with residual Chrome/Safari disagreement
- Urdu: Nastaliq/Naskh canary with narrow-width negative field
- Arabic: coarse corpora are clean; remaining work is fine-width edge-fit

タクソノミシステム(scripts/corpus-taxonomy.ts)はミスマッチをカテゴリに分類し、原因の切り分けを助けます。

  • 前処理の問題: 解析パイプラインがブラウザと異なるセグメント分割を行っている
  • エッジフィットの問題: 浮動小数点の累積によってセグメントが行端をわずかに超えてしまう
  • フォント感度の問題: 同じテキスト・同じ幅でも、フォントが異なると結果が変わる
  • ブラウザ固有の挙動: あるブラウザだけが他と異なる動作をする

ビルド・リリース・ステータスダッシュボード

Pretextは最小限のビルド設定で、tsc を通してESMとして配布されます。

tsconfig.build.json#L1-L13

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "noEmit": false,
    "allowImportingTsExtensions": false,
    "rootDir": "./src",
    "outDir": "./dist",
    "declaration": true
  },
  "include": ["src/**/*.ts"],
  "exclude": ["src/layout.test.ts", "src/test-data.ts"]
}

ソースのimport文で .js 拡張子を使う規約(import { analyzeText } from './analysis.js')により、tsc の出力だけで正しいJavaScriptと .d.ts ファイルが生成され、宣言ファイルの書き換えは不要です。これは意図的な設計であり、ビルドチェーンにWebpack、Rollup、Viteは一切使いません。

package.json#L1-L20

package.json のexportsマップは "."./dist/layout.js に向け、TypeScriptの型は ./dist/layout.d.ts に配置されます。スモークテストスクリプト(scripts/package-smoke-test.ts)は、生成されたtarballがJSとTSの両方の消費者に対して正しく機能するかを検証します。型定義の欠落やexportパスの誤りなど、公開前に問題を検出するためのものです。

ステータスダッシュボード(status/dashboard.json)は、チェックイン済みの精度スナップショットとベンチマークスナップショットを機械可読な形式に集約します。ライブラリの現在の精度とパフォーマンスの状態を一箇所で確認できる、信頼できる情報源として機能します。

ヒント: Pretextにコントリビュートする際は、変更の前後で accuracy/corpora/ のチェックイン済みスナップショットを必ず確認してください。スナップショットの再生成タイミングとミスマッチの解釈方法については、AGENTS.md に正式な手順が記載されています。

RESEARCH.md:組織の記憶として

リポジトリの中でもひときわ異彩を放つのが RESEARCH.md です。ライブラリを構築する過程で試みたこと、計測したこと、学んだことをすべて記録したリサーチログです。

RESEARCH.md#L1-L8

# Research Log
Everything we tried, measured, and learned while building this library.

記録されている内容は多岐にわたります。

  • 却下されたアプローチ: ホットパスでのDOM計測、SVGの getComputedTextLength()、レイアウト中の文字列再構築——いずれも試した上で、明確な理由とともに却下されています
  • 確立された知見: macOS上でCanvasとDOMの間に生じる system-ui フォントの解決ミスマッチ、Canvas measureText() における単語ごとの合計幅の精度特性
  • 設計上の決定: layout() が算術演算のみでなければならない理由、bidiレベルがリッチパスではメタデータ専用である理由
  • スクリプト別の注記: 各スクリプトファミリーの現在の精度状況と未解決の課題

これは、最も実用的な形の組織の記憶です。RESEARCH.mdを読んだ新しいコントリビューターは、何が作られたかだけでなく、何が試されて捨てられたかも知ることができます。何ヶ月もかけて同じ行き止まりを再発見するという無駄を省けるのです。

AGENTS.md はコントリビューター向けの実装レベルの注記として、これを補完します。

AGENTS.md#L35-L60

主なルールは次のとおりです。

  • layout() は高速かつアロケーションを最小限に保つ
  • スクリプト固有の修正は前処理に留め、ラインウォーカーには持ち込まない
  • 精度ページは3つのブラウザすべてでクリーンな状態を維持する
  • 永続テストスイートを肥大化させるより、使い捨てのプローブを優先する
  • まず幅を安く広くスイープし、ミスマッチした幅を詳細に診断する

シリーズを終えるにあたって

6回にわたって、Pretextを発端となった問題(DOMレイアウトスラッシング)から追ってきました。アーキテクチャ上の解答(2フェーズのprepare/layout)、深部の内部構造(マージカスケード・改行エンジン・ブラウザシム)、消費者向けAPI(シュリンクラップ・障害物ルーティング・エディトリアルレイアウト)、そして最後に検証インフラ(フェイクキャンバステスト・ブラウザスイープ・多言語コーパス)まで、一通り辿り着きました。

この設計には明確な主張があります。計測は一度だけ、レイアウトは純粋な算術演算、公開ハンドルは不透明、そしてブラウザ間の差異はフィーチャー検出ではなく明示的なフラグで処理する。これらの主張は、広範な実証的検証によって裏打ちされています。3つのブラウザと十数種のスクリプトにわたるチェックイン済みの精度スナップショットが、このアプローチが理論だけでなく実践でも機能することを示しています。

約3,200行のライブラリ(テストやデモを除く)でありながら、Pretextは驚くほど多くのテキストレイアウトの知見を凝縮しています。コードベースは丁寧に読む価値があります。マージカスケードアーキテクチャを知るなら analysis.ts、シンプル/フルウォーカーのディスパッチを理解するなら line-break.ts、クロスブラウザの絵文字幅に対するエレガントなキャッシュ&補正アプローチを学ぶなら measurement.ts から読み始めるのがよいでしょう。