Read OSS

改行エンジンの内側:高速パスの算術処理とブラウザ互換性

上級

前提知識

  • 第1回:アーキテクチャと二相モデル
  • 第2回:テキスト解析パイプライン
  • CSS の改行動作(overflow-wrap、末尾の空白)

改行エンジンの内側:高速パスの算術処理とブラウザ互換性

前回までの2本の記事では、Pretext の「遅い側」、すなわち prepare() が一度だけ実行する解析と計測処理を取り上げました。今回はいよいよ「速い側」へ踏み込みます。line-break.ts に実装された改行エンジンは、layout() が呼ばれるたびに実行されます。テキストブロック1件あたり約0.0002msというパフォーマンスを実現しているコードであり、そのすべての設計判断がホットパスを限界まで絞り込むことへの執念を体現しています。

このエンジンが実装するのは、CSS white-space: normal + overflow-wrap: break-word の改行セマンティクスです。具体的には「はみ出す非スペースセグメントの直前で折り返す」「末尾の空白は行の端からはみ出してもぶら下げる」「コンテナ幅より広い単語はグラフェム単位で強制分割する」という挙動です。さらにソフトハイフン・タブ・強制改行・ブラウザ固有の振る舞いの違いまで、複雑なケースをすべて処理します。

シンプル/フルパスの振り分け

ホットパスのエントリーポイントは countPreparedLines() で、prepare 時にセットされたフラグを見て2種類のウォーカーへ振り分けます。

src/line-break.ts#L166-L171

export function countPreparedLines(prepared, maxWidth) {
  if (prepared.simpleLineWalkFastPath) {
    return countPreparedLinesSimple(prepared, maxWidth)
  }
  return walkPreparedLines(prepared, maxWidth)
}

simpleLineWalkFastPath フラグが true になるのは、テキストが textspacezero-width-break セグメントのみで構成されている場合です。強制改行・ソフトハイフン・タブ・グルー・保持空白は含まれません。第2回で触れたように、このフラグは計測フェーズで非シンプルなセグメント種が現れた時点で false に切り替わります。

flowchart TD
    A["countPreparedLines(prepared, maxWidth)"] --> B{simpleLineWalkFastPath?}
    B -->|true| C["walkPreparedLinesSimple()<br/>Handles: text, space, ZWSP"]
    B -->|false| D["walkPreparedLines()<br/>Handles: all 8 segment kinds"]
    C --> E[Return line count]
    D --> E

この振り分けは見た目以上に重要です。シンプルウォーカーはチャンクのイテレーション、ソフトハイフンロジック、タブストップ計算、そして lineEndFitAdvancelineEndPaintAdvance の区別を完全にスキップできます。一般的なアプリケーションに含まれるテキスト——英語の本文、チャットメッセージ、SNS投稿——の大半はシンプルパスで処理されます。

シンプルラインウォーカー

walkPreparedLinesSimple() がホットパスのコアループです。処理の流れを追ってみましょう。

src/line-break.ts#L177-L351

ウォーカーが管理する可変ステート変数は少数で、すべてプリミティブな数値とbooleanです。

let lineCount = 0
let lineW = 0                    // Accumulated width of current line
let hasContent = false            // Whether the current line has any content
let pendingBreakSegmentIndex = -1 // Where to break if overflow occurs
let pendingBreakPaintWidth = 0    // Width to report for the pending break point

メインループはセグメントのインデックスを順に処理し、各セグメントを3つのケースに振り分けます。

ケース1:新しい行の開始。 先頭の空白とゼロ幅ブレークをスキップし、最初の非スペースセグメントから行を開始します。その先頭セグメントが maxWidth より幅広く、かつ breakableWidths を持つ場合はグラフェム単位で分割します。

ケース2:セグメントが収まる場合。 セグメントの幅を lineW に加算し、末尾カーソルを進めます。そのセグメントが折り返し機会(スペースまたはZWSP)であれば pendingBreak として記録します。

ケース3:セグメントがはみ出す場合。 ここが最も興味深いロジックです。ウォーカーはどこで折り返すかを決定しなければなりません。

stateDiagram-v2
    state "Segment overflows (newW > maxWidth + ε)" as Overflow
    state "Is current segment breakable?" as CanBreak
    state "Pending break exists?" as HasPending
    state "Segment wider than maxWidth with grapheme widths?" as WideBreakable

    Overflow --> CanBreak
    CanBreak --> EmitWithTrailing: yes (space/ZWSP)
    CanBreak --> HasPending: no
    HasPending --> EmitAtPending: yes
    HasPending --> WideBreakable: no
    WideBreakable --> GraphemeBreak: yes
    WideBreakable --> EmitBeforeCurrent: no

    EmitWithTrailing: Append segment, emit line\nwithout trailing space width
    EmitAtPending: Emit line at pending break point
    GraphemeBreak: Emit current line, break\nsegment at grapheme boundaries
    EmitBeforeCurrent: Emit line, retry\ncurrent segment on next line

末尾の空白処理には CSS 仕様に忠実な繊細さがあります。スペースセグメントがはみ出す場合、そのスペースは現在の行に追加されますが、出力される行の幅からはスペースの幅が除外されます。これがいわゆる「ぶら下げ空白」の挙動で、末尾のスペースは行の端からはみ出してもレイアウト上は改行を引き起こしません。

ペンディングブレークの仕組みは、複数のテキストセグメントが前回の折り返し機会を越えてはみ出す場合に機能します。セグメント ilineWmaxWidth を超えても、セグメント j < i のスペースが最後の折り返し機会として記録されていれば、ウォーカーはセグメント j までを1行として出力し、j+1 からやり直します。

フルラインウォーカー:チャンク・ソフトハイフン・タブ

simpleLineWalkFastPathfalse のとき、フルウォーカー walkPreparedLines() が動作します。基本構造はシンプルウォーカーと同じですが、3つの大きな機能が加わります。

src/line-break.ts#L353-L648

チャンク単位のイテレーション: 強制改行はテキストをチャンクに分割します(解析フェーズで確定)。フルウォーカーは外側のループでチャンクを、内側のループでチャンク内のセグメントをイテレートします。空のチャンク(強制改行が連続する場合)は即座に空行として出力されます。

src/line-break.ts#L539-L544

ソフトハイフンのロジック: ソフトハイフンは通常は非表示ですが、折り返し機会を生み出します。ソフトハイフンを検出すると、ウォーカーは discretionaryHyphenWidth- の幅)を含む幅でペンディングブレークを記録します。はみ出しが発生してペンディングブレークがソフトハイフンだった場合、まず continueSoftHyphenBreakableSegment() を試みます——次の単語のグラフェムをできるだけ多く収め、ハイフン+残りのグラフェムがはみ出す直前で切るというものです。Safari にはさらなる特殊化があり、preferEarlySoftHyphenBreak が有効な場合、より多くのグラフェムを詰め込もうとせず早めにソフトハイフン位置で折り返します。

src/line-break.ts#L489-L525

タブストップ: タブセグメントの幅は getTabAdvance() で動的に計算され、現在の行位置をもとに次のタブストップまでの距離を求めます。タブストップは 8 × spaceWidth 間隔で配置されます。

src/line-break.ts#L61-L67

フィット幅とペイント幅:微妙な幅の使い分け

このエンジンの最も繊細な設計のひとつが、lineEndFitAdvancelineEndPaintAdvance の分離です。各セグメントは両方の値を持ち、それぞれ異なる意味を持ちます。

lineEndFitAdvance: 行の収まり判定に使う幅の寄与値です。行がはみ出すかどうかを判断するときに参照されます。末尾の空白についてはゼロ——空白は収まり判定に影響しません。

lineEndPaintAdvance: 呼び出し元に可視行幅として返す幅です。折り畳み可能な空白はゼロ(行末では非表示になる)、pre-wrap の保持空白は実際の幅(表示されたまま)です。

src/layout.ts#L322-L329

const lineEndFitAdvance =
  segKind === 'space' || segKind === 'preserved-space' || segKind === 'zero-width-break'
    ? 0  // Don't count for fitting
    : w
const lineEndPaintAdvance =
  segKind === 'space' || segKind === 'zero-width-break'
    ? 0  // Invisible at line end
    : w  // preserved-space: still visible

この区別はフルウォーカーのペンディングブレーク機構でも重要です。ペンディングブレークを記録する際、その時点のフィット幅とペイント幅の両方を保存します。

pendingBreakFitWidth = lineW - segmentWidth + fitAdvance
pendingBreakPaintWidth = lineW - segmentWidth + paintAdvance

フィット幅は maxWidth + lineFitEpsilon と比較してブレークの妥当性を判断するために使い、ペイント幅は出力行として報告する値になります。これは CSS が末尾の空白をレンダリングする挙動——コンテナの端からはみ出してもレイアウトには影響しない——と一致します。

ヒント: layoutWithLines() を使ってカスタムレンダリングを実装する場合、line.width の値はコンテナ幅ではなくペイント幅です。末尾にスペースがある行のペイント幅は最大幅より小さくなることがありますが、そのスペースは視覚的に端の外側にぶら下がっています。

layoutNextLine():イテレータ形式の可変幅レイアウト

layout()layoutWithLines() がすべての行に固定の maxWidth を使うのに対して、layoutNextLine() は行ごとに異なる幅を指定できる1行ずつのイテレーション形式を提供します。障害物を避けながら流れるテキストのような可変幅レイアウトを実現するためのAPIです。

src/line-break.ts#L651-L662

sequenceDiagram
    participant App as Application
    participant LNL as layoutNextLine()
    participant State as Cursor State

    App->>LNL: start={seg:0, grapheme:0}, width=400
    LNL->>State: Normalize start, walk one line
    LNL-->>App: {text: "Hello world", end: {seg:3, grapheme:0}}

    App->>LNL: start={seg:3, grapheme:0}, width=250
    LNL->>State: Resume from cursor, walk one line
    LNL-->>App: {text: "foo bar", end: {seg:6, grapheme:0}}

    App->>LNL: start={seg:6, grapheme:0}, width=400
    LNL->>State: Resume, walk one line
    LNL-->>App: null (end of text)

設計のポイントはカーソルの受け渡しです。各呼び出しが返す LayoutLineend カーソルを、次の呼び出しの start として渡す仕組みになっています。これにより、マルチカラムレイアウト(左カラムがテキストを消費し、右カラムがカーソルから再開)、障害物ルーティング(行ごとに幅が変わる)、プログレッシブレンダリングといった用途に対応できます。

内部実装の layoutNextLineRange() は、開始カーソルを正規化して先頭の空白をスキップし、対応するチャンクを特定して、テキスト全体を処理し続けるのではなく1行分だけで停止するシングルパス版のフルウォーカーを実行します。

ブラウザシム:lineFitEpsilon とエンジン固有の挙動

ラインウォーカーは、累積幅と maxWidth を比較する際に lineFitEpsilon の許容誤差を使います。

if (newW > maxWidth + lineFitEpsilon) { ... }

この epsilon は、Pretext が採用する「セグメント幅の総和」方式とブラウザネイティブのレイアウトエンジンとの浮動小数点演算の差異を吸収するためのものです。値はブラウザによって異なります。

ブラウザエンジン lineFitEpsilon carryCJKAfterClosingQuote preferPrefixWidthsForBreakableRuns preferEarlySoftHyphenBreak
Chromium 0.005 true false false
Gecko (Firefox) 0.005 false false false
WebKit (Safari) 1/64 (≈0.0156) false true true
Server (no navigator) 0.005 false false false

src/measurement.ts#L65-L101

Safari の epsilon が大きいのは、内部で固定小数点演算(1/64 精度)を使っているためです。carryCJKAfterClosingQuote フラグは、閉じ引用符の後ろに来る CJK 文字を同じ行に留める Chromium 固有のマージ処理を有効にします。preferPrefixWidthsForBreakableRuns は、サブワード分割時の計測で個々のグラフェム幅の合計よりも累積プレフィックス幅の方が精度が高い Safari の代替計測方式を有効にします。preferEarlySoftHyphenBreak は、より多くのグラフェムを詰め込もうとせず早めにソフトハイフン位置で折り返すという Safari の傾向に合わせたものです。

ヒント: エッジケースでブラウザのネイティブレイアウトと行数が一致しない場合、まず lineFitEpsilon を確認しましょう。これは「早すぎる折り返し(false positive)」と「折り返すべき場所で折り返さない(false negative)」のバランスを調整するチューニングパラメータです。

次回予告

これで改行エンジンの全体像を追い終えました。シンプル/フルの振り分けから、ペンディングブレーク機構と末尾空白の処理、フィット幅/ペイント幅の使い分け、ブラウザ固有のチューニングまでを一通り見てきました。このエンジンは第2回で解説した解析・計測パイプラインが構築した並列配列を消費し、マイクロ秒単位で行数を算出します。

第4回では、ラインウォーカーから一歩引いてクロスブラウザ対応と国際化の課題を掘り下げます。エンジンプロファイルの検出方法、Canvas/DOM の不一致を補正する絵文字幅補正の仕組み、セグメントメトリクスキャッシュの構造、そして pdf.js 由来の bidi 実装が RTL/LTR 混在テキストをどう扱うかを取り上げます。