Read OSS

テキスト解析パイプラインの内側:生の文字列から計測済みセグメントへ

上級

前提知識

  • 第1回:アーキテクチャと二フェーズモデル
  • Unicodeの基礎知識(CJK範囲、結合文字)
  • Intl.Segmenter APIの概念

テキスト解析パイプラインの内側:生の文字列から計測済みセグメントへ

第1回では、Pretextの prepare() が重い処理を一度だけ実行することで、layout() を純粋な算術演算に留められることを確認しました。では、prepare() の内部では具体的に何が起きているのでしょうか。その答えは、想像以上に深いパイプラインです。空白の正規化、Intl.Segmenter による単語分割、各セグメントの8種類の改行種別への分類、日本語の禁則処理からURL検出まであらゆるケースに対処する多段階マージカスケード、計測時のCJKグラフェム分割、そして最終的な並列配列の組み立て、これらすべてが連なっています。

本記事では、実際のコードパスを追いながら、このパイプラインを生の文字列から準備済みハンドルまで一通り辿っていきます。

オーケストレーター:analyzeText()

テキスト解析フェーズは計測とは内部的に分離されています。layout.tsprepareInternal() 関数は、2つの関数を順番に呼び出します。

src/layout.ts#L424-L432

function prepareInternal(text, font, includeSegments, options) {
  const analysis = analyzeText(text, getEngineProfile(), options?.whiteSpace)
  return measureAnalysis(analysis, font, includeSegments)
}

analyzeText()TextAnalysis を返します。これは正規化された文字列、セグメントテキストの配列、改行種別、ハードブレークのチャンクを格納した構造体です。続く計測フェーズがこれらを反復処理して、最終的な並列配列を生成します。まずは解析パスを追っていきましょう。

src/analysis.ts#L993-L1019

flowchart TD
    A[Raw text string] --> B{WhiteSpace mode?}
    B -->|normal| C[normalizeWhitespaceNormal]
    B -->|pre-wrap| D[normalizeWhitespacePreWrap]
    C --> E[buildMergedSegmentation]
    D --> E
    E --> F[compileAnalysisChunks]
    F --> G[TextAnalysis]

空白の正規化:normal と pre-wrap

分割処理に入る前に、CSSのレンダリング挙動に合わせた空白の正規化が必要です。Pretextは2つのモードをサポートしています。

normal モード(デフォルト):スペース、タブ、改行、フォームフィードといった空白の連続をすべて1つのスペースに畳み込み、先頭と末尾のスペースをトリムします。CSSの white-space: normal に対応する挙動です。

src/analysis.ts#L56-L67

実装は防御的な設計になっています。正規化が必要かどうかを先にregexで素早くチェックし、すでにクリーンなテキストに対して余分な文字列アロケーションが発生しないようになっています。

export function normalizeWhitespaceNormal(text: string): string {
  if (!needsWhitespaceNormalizationRe.test(text)) return text
  let normalized = text.replace(collapsibleWhitespaceRunRe, ' ')
  // trim leading/trailing space
  ...
}

pre-wrap モード:通常のスペース、タブ、ハードブレークをそのまま保持します。正規化されるのは行末コードのみです(\r\n\n、単独の \r\f\n)。

src/analysis.ts#L69-L74

メモ: pre-wrap モードはエディタや入力フィールドなど、スペースの保持が重要なユースケースを想定しています。CSSの pre-wrap のすべての仕様をカバーするわけではなく、通常スペース・\t タブ・\n ハードブレークをブラウザ準拠のタブストップで処理します。

分割と改行種別の分類

正規化が終わると、テキストは単語粒度に設定した Intl.Segmenter に渡されます。

src/analysis.ts#L79-L84

Segmenterから返された各セグメントは、さらに改行種別ごとに分割されます。splitSegmentByBreakKind() は各文字を8種類の SegmentBreakKind のいずれかに分類します。

src/analysis.ts#L1-L11

SegmentBreakKind 対象文字 改行時の挙動
text 通常の文字 オーバーフロー時に前で改行
space 折り畳み可能スペース 後で改行;行端をはみ出してもぶら下がる
preserved-space pre-wrap内のスペース 後で改行;幅として計算される
tab pre-wrap内のタブ 後で改行;タブストップから幅を算出
glue NBSP、NNBSP、WJ、ZWNBSP 改行不可;可視コンテンツとして計測
zero-width-break ZWSP(U+200B) 幅ゼロの改行機会
soft-hyphen SHY(U+00AD) 改行機会;選択時に - を表示
hard-break pre-wrap内の \n 強制改行

分類関数 classifySegmentBreakChar() はモード依存の挙動を処理します。normal モードではスペースが space になり、pre-wrap では preserved-space になります。

src/analysis.ts#L321-L334

flowchart TD
    A["Intl.Segmenter output"] --> B[splitSegmentByBreakKind]
    B --> C{Character type?}
    C -->|"U+0020"| D{pre-wrap?}
    D -->|yes| E[preserved-space]
    D -->|no| F[space]
    C -->|"NBSP/NNBSP"| G[glue]
    C -->|"U+200B"| H[zero-width-break]
    C -->|"U+00AD"| I[soft-hyphen]
    C -->|"tab"| J{pre-wrap?}
    J -->|yes| K[tab]
    C -->|other| L[text]

buildMergedSegmentation() のマージカスケード

解析パイプラインの核心は buildMergedSegmentation() です。この関数は分割されたピースを受け取り、ブラウザのCSSによるセグメントのクラスタリング挙動に合わせた一連のマージを適用します。

src/analysis.ts#L795-L956

メインのマージループは Intl.Segmenter から取り出した各ピースを処理し、直前のセグメントとのマージを試みます。このループ内では、6つのマージルールが優先順位に従って順番に適用されます。

  1. CJK閉じ引用符の引き継ぎ(Chromium固有):直前のCJKセグメントが閉じ引用符で終わり、次のセグメントもCJKの場合にマージします。」東 をひとまとまりとして扱うChromiumの特定の挙動に対応しています。

  2. 禁則処理(行頭禁則) など行頭に置けない文字は、直前のCJKセグメントとマージされます。これにより句読点が行頭に取り残されることを防ぎます。

  3. ミャンマー語のmedial-glue:直前のセグメントがミャンマー語のmedial文字で終わる場合、次のセグメントをマージします。

  4. アラビア語の空白なしクラスター:直前のセグメントがアラビア語の後置句読点(:.،؛)で終わり、次の単語にアラビア文字が含まれる場合にマージします。

  5. 同一文字の連続:ハイフンやemダッシュを除く1文字が連続するケース(...=== など)は1つのユニットにまとめます。

  6. 左付き句読点.,!)"、閉じ引用符など、直前の単語に付属する句読点は前のテキストとマージされます。これが "better." を1つのユニットとして計測できる理由です。

メインループの後、前方付き挙動を処理する2つの後処理パスが続きます。

  • エスケープ引用符クラスターのマージ\" の並びを隣接要素に付加する前方パス。
  • 前方付き引き継ぎ:開き括弧・引用符((")を次のテキストセグメントの先頭へ移動させる逆順パス。

どちらのパスも、空エントリを除去するコンパクション処理で締めくくられます。

マージ後のパス:URL、数値、句読点チェーン

メインのマージカスケードの後、さらに一連のパスがセグメントを精緻化します。

const compacted = mergeGlueConnectedTextRuns(...)
const withMergedUrls = carryTrailingForwardStickyAcrossCJKBoundary(
  mergeAsciiPunctuationChains(
    splitHyphenatedNumericRuns(
      mergeNumericRuns(
        mergeUrlQueryRuns(
          mergeUrlLikeRuns(compacted))))))

src/analysis.ts#L924-L935

flowchart TD
    A[Primary merge output] --> B[mergeGlueConnectedTextRuns]
    B --> C[mergeUrlLikeRuns]
    C --> D[mergeUrlQueryRuns]
    D --> E[mergeNumericRuns]
    E --> F[splitHyphenatedNumericRuns]
    F --> G[mergeAsciiPunctuationChains]
    G --> H[carryTrailingForwardStickyAcrossCJKBoundary]
    H --> I[Arabic space+mark splitting]
    I --> J[Final MergedSegmentation]

glue接続テキストランのマージ:テキストセグメント間に挟まれたNBSP文字を吸収し、改行不可の1つのユニットにまとめます。CSSにおける word NBSP word の挙動と一致します。

src/analysis.ts#L691-L765

URLライクなランのマージ:URLスキーム(https:)や www. で始まるセグメントは、最初の ? が来るまでの非空白セグメントをすべて吸収します。2つ目のパス(mergeUrlQueryRuns)がクエリ部分を処理します。これにより https://example.com/path?q=1 はスラッシュで分断されることなく、ひとつの改行可能なセグメントとして扱われます。

src/analysis.ts#L417-L465

数値ランのマージ7:003/4 のように数字が結合文字(:-/×,.+)でつながれたセグメントは1つのユニットにまとめます。ただし 2024-01-15 のようにハイフンを含む数値ランは、日付セパレーターでの改行を許容するため、直後にハイフン位置で再分割されます。

src/analysis.ts#L541-L689

ASCII句読点チェーンitem1,item2,item3 のように末尾のカンマやコロンでつながる単語ライクなセグメントは、ブラウザの挙動に合わせて1つのユニットにまとめます。

最後にアラビア語固有のパスが、先頭の 空白+結合文字 を分割します。これによりスペースは改行機会として残りつつ、結合文字は後続のアラビア語単語に付加されます。

計測:Canvas、CJK分割、キャッシュ

analyzeText()TextAnalysis を返すと、layout.tsmeasureAnalysis() がセグメントを反復処理し、ラインウォーカーが消費する並列配列を組み立てます。

src/layout.ts#L191-L392

計測ループは各セグメントの種類に応じて異なる処理を行います。

ソフトハイフン:通常は幅ゼロですが、ラインウォーカーがここで改行を選択した場合に視覚的なハイフンを表示できるよう、fitアドバンスとpaintアドバンスの両方に discretionaryHyphenWidth- の幅)を格納します。

ハードブレークとタブ:幅ゼロ。特殊な挙動はラインウォーカーが担います。

CJKテキストセグメント:最も複雑な分割が行われます。テキストセグメントにCJK文字が含まれる場合、グラフェム粒度の Intl.Segmenter を使って個々のグラフェムに分割します。ただしこの分割は禁則ルールを尊重します。開き括弧は次のグラフェムと結合し、閉じ句読点は直前のグラフェムに留まります。

src/layout.ts#L279-L319

if (
  kinsokuEnd.has(unitText) ||         // Opening bracket stays with next
  kinsokuStart.has(grapheme) ||       // Closing punct stays with prev
  leftStickyPunctuation.has(grapheme) // Period/comma stays with prev
) {
  unitText += grapheme
  continue
}

通常テキスト:共有Canvasコンテキストを使う getSegmentMetrics() で計測されます。複数グラフェムを持つ単語ライクなセグメントは、overflow-wrap: break-word のサポートのためグラフェムごとの幅を事前計算します。Safariでは個々のグラフェム幅の合計と実際のテキストシェーピング結果が異なるため、累積プレフィックス幅も別途計算されます。

lineEndFitAdvance と lineEndPaintAdvance:スペースや幅ゼロブレークでは、fitアドバンスはゼロ(行末の空白は行の収まり判定に影響しない)ですが、paintアドバンスは種類によって異なります(折り畳み可能スペースは不可視、保持スペースは可視)。この重要な区別については第3回で詳しく解説します。

flowchart TD
    A[TextAnalysis segment] --> B{Segment kind?}
    B -->|soft-hyphen| C["width=0, fitAdvance=hyphenWidth"]
    B -->|hard-break/tab| D["width=0, advance=0"]
    B -->|"text (CJK)"| E[Split into graphemes]
    E --> F[Apply kinsoku merging]
    F --> G[Measure each unit]
    B -->|"text (other)"| H[Measure whole segment]
    H --> I{word-like + multi-grapheme?}
    I -->|yes| J[Pre-compute grapheme widths]
    I -->|no| K[Store null for breakableWidths]
    G --> L[Push to parallel arrays]
    J --> L
    K --> L
    C --> L
    D --> L

計測済みの各セグメントは pushMeasuredSegment() によってすべての並列配列へ同時に追加されます。この関数は非シンプルなセグメントが含まれる場合に simpleLineWalkFastPath フラグを false に切り替える役割も担っています。

src/layout.ts#L220-L241

メモ: セグメントメトリクスのキャッシュは (font, segment_text) をキーとし、同じフォントを使うすべての prepare() 呼び出しで共有されます。同一フォントで1,000件のチャットメッセージをレンダリングする場合、"the" のような頻出単語はCanvasで一度だけ計測されます。clearCache() の呼び出しはフォント自体を切り替えるときだけにしましょう。

解析から準備済みハンドルへ

最後のステップでは、解析レベルのチャンク境界を準備済みレベルのセグメントインデックスに対応付けます(CJK分割によって1つの解析セグメントが複数の準備済みセグメントに展開されることがあるため)。オプションでbidiレベルの計算も行われます。

src/layout.ts#L361-L392

チャンクのマッピングは mapAnalysisChunksToPreparedChunks() が担います。この関数は計測ループ中に構築されたルックアップ配列を使い、ハードブレーク境界を解析空間のインデックスから準備済み空間のインデックスへ変換します。

bidiレベルはリッチパス(prepareWithSegments)でのみ計算され、さらにテキストに実際に右から左への文字が含まれる場合に限られます。computeBidiLevels() はR、AL、ANの文字が見つからなければ早期に null を返し、純粋なLTRテキストに対して余計な処理が走らないようになっています。

次回予告

生の文字列から並列配列までの全工程を追いました。正規化 → 分割 → 改行種別分類 → マージカスケード → マージ後パス → Canvas計測 → CJK分割 → 配列の組み立て。この結果として得られる PreparedText ハンドルには、改行エンジンが必要とするすべての情報が揃っています。

第3回では、このエンジン自体、すなわち line-break.ts のホットパスコードに踏み込みます。これらの配列を純粋な算術演算で走査し、マイクロ秒単位で行数を算出する仕組みを見ていきます。シンプルウォーカーとフルウォーカーの分岐がなぜ重要なのか、末尾空白が行端をどのようにはみ出すのか、ソフトハイフンが overflow-wrap の改行とどう絡み合うのかも明らかになります。