Read OSS

ブラウザ固有の挙動・絵文字補正・多言語スクリプトへの対応

上級

前提知識

  • 第1回:アーキテクチャと二段階モデル
  • 第2回:テキスト解析パイプライン
  • 第3回:改行エンジン
  • Unicodeの基礎知識(bidi、書記素クラスタ、絵文字表示)

ブラウザ固有の挙動・絵文字補正・多言語スクリプトへの対応

第1〜3回では、アーキテクチャから解析、改行処理へと続くパイプライン全体を追いました。ブラウザ間の違いはその都度パラメータ(epsilon値やboolean フラグ)として扱ってきましたが、今回はその違いそのものを掘り下げます。具体的には、各差異がどのように検出されるのか、なぜ存在するのか、そしてChrome・Safari・Firefox間でライン数を一致させるためにPretext が何をしているのかを見ていきます。

また、国際化にまつわる課題にも触れます。pdf.js から受け継いだ bidi 実装、Canvas と DOM の幅の差異を補正する絵文字補正システム、そして CJK・アラビア語・タイ語・ミャンマー語それぞれのスクリプト固有のルールがパイプラインをどう流れるかを解説します。

エンジンプロファイルの検出

Pretext は user agent 文字列を解析してブラウザエンジンを判別し、パイプライン全体に伝播する4つの動作フラグを設定します。

src/measurement.ts#L11-L16

export type EngineProfile = {
  lineFitEpsilon: number
  carryCJKAfterClosingQuote: boolean
  preferPrefixWidthsForBreakableRuns: boolean
  preferEarlySoftHyphenBreak: boolean
}

検出には navigator.userAgentnavigator.vendor を使います。あえてシンプルな実装を選んでいます。

src/measurement.ts#L65-L101

flowchart TD
    A[navigator.userAgent] --> B{vendor = Apple<br/>+ Safari/ present<br/>+ no Chrome/Chromium?}
    B -->|yes| C[Safari/WebKit]
    B -->|no| D{UA contains<br/>Chrome/ or Chromium/<br/>or CriOS/ or Edg/?}
    D -->|yes| E[Chromium]
    D -->|no| F[Gecko/Firefox or fallback]

    C --> G["lineFitEpsilon: 1/64<br/>carryCJK: false<br/>prefixWidths: true<br/>earlySoftHyphen: true"]
    E --> H["lineFitEpsilon: 0.005<br/>carryCJK: true<br/>prefixWidths: false<br/>earlySoftHyphen: false"]
    F --> I["lineFitEpsilon: 0.005<br/>carryCJK: false<br/>prefixWidths: false<br/>earlySoftHyphen: false"]

feature detection ではなく UA スニッフィングを選んだのはなぜでしょうか。それは、これらが「機能」ではなく、各エンジンがテキストレイアウトをどう実装しているかという微妙な動作の違いだからです。ブラウザが行のフィッティングに 1/64 の固定小数点精度を使っているかどうか、閉じ括弧の後で CJK 文字を繰り越すかどうかといった差異は、実際にレイアウトを実行して結果を比較しない限り、信頼できる方法で検出できません。UA スニッフィングは、現実的な選択と言えます。

エンジンプロファイルはモジュールレベルのシングルトンとしてキャッシュされ、getEngineProfile() 経由で取得します。navigator が未定義のサーバーサイド環境では、Gecko のデフォルト値が使われます。

絵文字補正システム

ブラウザ間の差異の中でも特に意外なのが、macOS 上の Chrome と Firefox では、Canvas の measureText() と DOM レイアウトが絵文字に対して異なる幅を返すという問題です。フォントサイズが約 24px 以下の場合、Canvas の幅は絵文字の書記素1つあたり一定量だけ大きく報告されます。

補正の仕組みは3ステップで機能します。

ステップ1:検出。 フォントごとに、Canvas と DOM の両方で 😀 を計測します。Canvas の幅が DOM の幅を 0.5px 以上上回る場合、その差分を記録します。

src/measurement.ts#L123-L151

const canvasW = ctx.measureText('\u{1F600}').width
// ...
const domW = span.getBoundingClientRect().width
if (canvasW - domW > 0.5) {
  correction = canvasW - domW
}

この DOM 読み取りは計測システム全体でここだけで、フォントごとにキャッシュされます。テキストブロックのたびではなく、フォントサイズごとに1回だけ実行されます。

ステップ2:セグメント単位の補正。 絵文字補正が有効なフォントでセグメントを計測する際、補正後の幅は次のように計算されます。

src/measurement.ts#L169-L172

export function getCorrectedSegmentWidth(seg, metrics, emojiCorrection) {
  if (emojiCorrection === 0) return metrics.width
  return metrics.width - getEmojiCount(seg, metrics) * emojiCorrection
}

ステップ3:絵文字のカウント。 セグメントあたりの絵文字数は、書記素の分割を通じて遅延評価され、SegmentMetrics オブジェクトにキャッシュされます。

src/measurement.ts#L152-L167

Safari は補正不要です。Canvas と DOM の絵文字幅が一致しており(どちらも fontSize より大きい)、補正値は 0 になります。

flowchart TD
    A["prepare() called"] --> B{Text may contain emoji?}
    B -->|no| C["emojiCorrection = 0"]
    B -->|yes| D["getEmojiCorrection(font, fontSize)"]
    D --> E{Canvas 😀 width > DOM 😀 width + 0.5?}
    E -->|yes| F["correction = canvasW - domW"]
    E -->|no| G["correction = 0"]
    F --> H["Apply per-segment:<br/>width - emojiCount × correction"]
    G --> I["Use Canvas width directly"]
    C --> I

ヒント: 絵文字検出のファストパスは textMayContainEmoji() を使います。これは絵文字に関連する Unicode プロパティを正規表現でテストするものです。テキスト全体に絵文字の兆候がなければ、補正システムはまるごとスキップされ、DOM 読み取りもセグメント単位のカウントも行われません。

セグメントメトリクスキャッシュとプレフィックス幅

セグメントメトリクスキャッシュは、モジュールスコープに保持される二段階の Map<font, Map<segment, SegmentMetrics>> です。

src/measurement.ts#L19

const segmentMetricCaches = new Map<string, Map<string, SegmentMetrics>>()

SegmentMetrics 型が持つのは幅だけではありません。

src/measurement.ts#L3-L9

export type SegmentMetrics = {
  width: number
  containsCJK: boolean
  emojiCount?: number                    // Lazily populated
  graphemeWidths?: number[] | null       // Lazily populated
  graphemePrefixWidths?: number[] | null // Lazily populated
}

遅延フィールドは初回アクセス時に計算され、同じオブジェクトにキャッシュされます。フィールドレベルのメモ化と言えます。overflow-wrap による改行が必要にならないセグメントについては、書記素幅の計算を省くことができます。

erDiagram
    FONT_STRING ||--o{ SEGMENT_STRING : "outer Map"
    SEGMENT_STRING ||--|| SEGMENT_METRICS : "inner Map"
    SEGMENT_METRICS {
        number width
        boolean containsCJK
        number emojiCount "optional"
        numberArray graphemeWidths "optional"
        numberArray graphemePrefixWidths "optional"
    }

Safari のプレフィックス幅計測(getSegmentGraphemePrefixWidths())は特筆に値します。各書記素を個別に計測して合計するのではなく、累積プレフィックス、つまり "h""he""hel""hell""hello" と順番に計測します。書記素ごとの幅は、連続するプレフィックス幅の差として求められます。

src/measurement.ts#L193-L211

なぜこの方法を取るのでしょうか。テキストのシェーピングでは、文字が隣接するときと単独のときとで幅が変わることがあるからです。リガチャ、カーニング、コンテキスト依存の字形変化はすべて結果に影響します。プレフィックス幅を使うことでこうした効果を正確に捉えられ、Safari での単語内改行の精度が上がります。Chrome と Firefox は一貫性があるため、書記素ごとの個別計測で十分です。

簡略化された Bidi:pdf.js 由来の UAX #9

bidi.ts の bidi 実装は、Unicode 双方向アルゴリズム(UAX #9)を簡略化したものです。もともと pdf.js から派生し、Sebastian Markbage のテキストレイアウト研究を経て取り込まれました。

src/bidi.ts#L1-L6

この実装では、2つのルックアップテーブルを使って文字を bidi タイプ(L・R・AL・AN・EN など)に分類します。1つは Basic Latin 範囲(0x00〜0xFF)用、もう1つはアラビア語(0x0600〜0x06FF)用です。これらの範囲外の文字には、シンプルな範囲チェックを使います。

src/bidi.ts#L57-L63

function classifyChar(charCode) {
  if (charCode <= 0x00ff) return baseTypes[charCode]
  if (0x0590 <= charCode && charCode <= 0x05f4) return 'R'
  if (0x0600 <= charCode && charCode <= 0x06ff) return arabicTypes[charCode & 0xff]
  if (0x0700 <= charCode && charCode <= 0x08AC) return 'AL'
  return 'L'
}

レベル計算では、W1〜W7(弱いタイプの解決)、N1〜N2(ニュートラルタイプの解決)、I1〜I2(レベル割り当て)の各ルールを実装しています。段落方向の判定にはシンプルなヒューリスティックを採用しており、bidi 文字(R、AL、AN)が1つでも存在する場合は開始レベルを 1(RTL)に設定します。bidi 文字が存在しない場合は関数が早期に null を返します。

src/bidi.ts#L65-L162

設計上の重要な判断があります。bidi レベルはあくまでメタデータです。 改行エンジンはこれを読みません。bidi レベルが存在するのは、視覚的な文字順序を正しく描画する必要がある prepareWithSegments() のリッチパスを利用するコンシューマーのためです。シンプルな prepare() パスは bidi 計算をまるごとスキップし、computeSegmentLevels() は文字単位のレベルをセグメント単位のレベルにマッピングする処理をリッチパスでのみ行います。

src/bidi.ts#L164-L173

多言語スクリプトへの対応:CJK・アラビア語・タイ語・ミャンマー語

主要なスクリプトファミリーはそれぞれ異なる形でパイプラインを流れます。スクリプトごとの動作をまとめると次のようになります。

スクリプト 分割 解析時のマージ 計測 改行
CJK Intl.Segmenter で単語単位にグループ化 禁則処理の先頭・末尾マージ、閉じ括弧後の繰り越し(Chromium) 禁則再マージを伴う書記素単位への分割 任意の書記素間で改行可
アラビア語 Intl.Segmenter で単語単位 スペースなし末尾句読点のクラスタリング、スペース+マークの分割 シェーピングを考慮したユニット単位で計測 標準の単語境界での改行
タイ語 Intl.Segmenter(辞書ベース) 特別なマージなし セグメント単位で計測 Segmenter の境界で改行
ミャンマー語 Intl.Segmenter で単語単位 中間グルーのマージ(U+104F) ユニット単位で計測 マージ後のセグメント境界で改行

isCJK() 関数は広範な CJK 関連ブロックをカバーしています。CJK 統合漢字だけでなく、ひらがな、カタカナ、ハングル、CJK互換漢字、全角文字、第3面の補助漢字を含む複数の拡張ブロックも対象です。

src/analysis.ts#L105-L127

禁則文字セットは、行頭禁則文字( などの閉じ括弧類)と行末禁則文字( などの開き括弧類)を正確に定義しています。

src/analysis.ts#L129-L172

アラビア語では、パイプラインが微妙な相互作用を処理しなければなりません。スペースの後に、視覚的に次の単語に付着するコンバイニングマーク(ダイアクリティカル)が続くケースです。buildMergedSegmentation() のマージ後パスは、アラビア語テキストの前にある " " + マーク のパターンを検出し、スペースとマークを分離します。スペースは改行機会として残し、マークは次の単語に移動させます。

src/analysis.ts#L937-L953

ヒント: 特定のロケールで作業する場合は、prepare() を呼ぶ前に setLocale() を使って Intl.Segmenter の対象を切り替えましょう。これにより、タイ語・クメール語・ミャンマー語など辞書ベースの Segmenter が生成する単語境界に影響します。

次回に向けて

これでブラウザ対応に関するインフラの全体像が揃いました。エンジン検出、絵文字補正、メトリクスキャッシュ、bidi レベル、スクリプトごとの処理——これらをアーキテクチャ(第1回)、解析パイプライン(第2回)、行ウォーカー(第3回)と合わせると、生の文字列から行数までのテキスト処理フロー全体が見えてきます。

第5回では視点を変えて、ライブラリ内部からコンシューマー側へと移ります。リッチレイアウト API が実際のアプリケーションをどう支えるかを見ていきましょう。具体的には、二分探索を使ったチャットバブルの収縮ラップ、layoutNextLine() による編集レイアウトの障害物ルーティング、SVG アルファをラスタライズしてポリゴンハルを抽出するテキストフローのためのラップジオメトリシステムを取り上げます。