RenderingNG の詳細: LayoutNG ブロックの断片化

LayoutNG でのブロックの断片化が完了しました。この記事では、その仕組みとその重要性について説明します。

Morten Stenshorne
Morten Stenshorne

私は Morten Stenshorne です。Google の Blink レンダリング チームのレイアウト エンジニアです。私は 2000 年代初頭からブラウザ エンジンの開発に関わっており、Presto エンジン(Opera 12 以前)で acid2 テストに合格するのを手伝ったり、他のブラウザをリバース エンジニアリングして Presto のテーブル レイアウトを修正したりするなど、たくさんのことを楽しんできました。私は、ブロックの断片化、特に Presto、WebKit、Blink の multicol にも、この数年間を費やしてきました。この数年間、私は主に LayoutNG にブロックの断片化のサポートを追加する作業の主導に注力してきました。それでは、ブロックの断片化の実装について、詳しく見ていきましょう。ブロックの断片化を実装するのは、これが最後かもしれません。:)

ブロックの断片化とは

ブロックの断片化とは、CSS のブロックレベルのボックス(セクションや段落など)が、フラグメント コンテナと呼ばれる 1 つのフラグメント コンテナに全体として収まらない場合に、複数のフラグメントに分割することです。フラグメントは要素ではなく、マルチカラム レイアウトでは列、ページング メディアではページを表します。断片化を発生させるには、コンテンツが断片化コンテキスト内にある必要があります。断片化のコンテキストは、多くの場合、複数列のコンテナ(コンテンツが列に分割される)や印刷時(コンテンツがページに分割される)で発生します。多数の行を含む長い段落は、複数のフラグメントに分割する必要がある場合があります。これにより、最初の行は最初のフラグメントに配置され、残りの行は後続のフラグメントに配置されます。

2 列に分割されたテキストの段落。
この例では、マルチ列レイアウトを使用して 1 つの段落が 2 列に分割されています。各列はフラグメントであり、フラグメント化されたフローのフラグメントを表します。

ブロックの断片化は、よく知られているもう一つの断片化である行の断片化(「改行」とも呼ばれます)に似ています。複数の単語(任意のテキストノード、任意の <a> 要素など)で構成され、改行が可能なインライン要素は、複数のフラグメントに分割できます。各フラグメントはそれぞれ異なる行ボックスに配置されます。ラインボックスは、列とページの fragmentainer に相当するインラインの断片化です。

LayoutNG ブロックの断片化とは

LayoutNGBlockFragmentation は、LayoutNG のフラグメンテーション エンジンを書き換えたものです。長年にわたる取り組みの末、今年初めにようやく最初の部分が Chrome 102 でリリースされました。これにより、「従来の」エンジンでは基本的に修正できなかった問題が以前から解消されました。データ構造については、NG 以前の複数のデータ構造を、フラグメント ツリーに直接表される NG フラグメントに置き換えます。

たとえば、CSS プロパティの「break-before」と「break-after」の後方で「avoid」値がサポートされるようになりました。これにより、作成者はヘッダーの直後の挿入を回避できます。一般的に、ページの最後に配置されたものがヘッダーであるのに対し、セクションのコンテンツは次のページから始まる場合、見栄えが良くありません。代わりに、ヘッダーの前で中断することをおすすめします。下の画像の例をご覧ください。

1 つ目の例ではページの下部に見出しを表示し、2 つ目の例では関連するコンテンツとともに後続のページの上部に見出しを表示します。

Chrome 102 ではフラグメンテーション オーバーフローもサポートされているため、モノリシック(壊せないと想定されている)コンテンツが複数の列にスライスされず、シャドウや変換などのペイント エフェクトが正しく適用されます。

LayoutNG でのブロックの断片化が完了しました

この記事の執筆時点で、LayoutNG におけるブロックの断片化の完全なサポートは完了しています。Chrome 102 で導入されたコアの断片化(ライン レイアウト、浮動小数点数、フロー外の配置を含むブロック コンテナ)。Flex とグリッドのフラグメンテーションは Chrome 103 で、テーブルのフラグメンテーションは Chrome 106 でリリースされました。最後に、印刷機能は Chrome 108 でリリースされました。ブロックの断片化は、レイアウトの実行において以前のエンジンに依存する最後の機能でした。つまり、Chrome 108 以降、従来のエンジンはレイアウトの実行に使用されなくなります。

LayoutNG データ構造は、コンテンツの実際のレイアウトだけでなく、ペイントとヒットテストもサポートしますが、レイアウト情報を読み取る JavaScript API には、offsetLeftoffsetTop などの従来のデータ構造も引き続き利用しています。

すべてを NG でレイアウトすると、CSS コンテナクエリ、アンカーの配置、MathMLカスタム レイアウト(Houdini)など、LayoutNG の実装のみを使用する(従来のエンジンに対応するものは存在しない)新機能の実装と提供が可能になります。コンテナクエリについては、出力がまだサポートされていないことをデベロッパーに警告するとともに、少し前にリリースしました。

LayoutNG の最初の部分は 2019 年にリリースされました。これは、通常のブロック コンテナ レイアウト、インライン レイアウト、浮動小数点数、フロー外のポジショニングで構成されていますが、flex、grid、Table のサポートはなく、ブロックの断片化はまったくサポートされていません。フレックス、グリッド、テーブルのほか、ブロックの断片化を伴うものすべてには、従来のレイアウト エンジンを使用することになります。これは、断片化されたコンテンツ内のブロック要素、インライン要素、フローティング要素、フロー外の要素にも当てはまります。このように複雑なレイアウト エンジンをインプレースでアップグレードするのは、非常に繊細な作業です。

さらに、2019 年半ばまでに、LayoutNG のブロック・フラグメンテーション・レイアウトのコア機能の大部分はすでに(フラグの背後に)実装されています。では、なぜ発送にこんなに時間がかかったのでしょうか?断片化はシステムのさまざまなレガシー部分と正しく共存する必要があり、すべての依存関係がアップグレードされるまで削除やアップグレードはできません。長い回答については、以下をご覧ください。

以前のエンジンのインタラクション

以前のデータ構造は、レイアウト情報を読み取る JavaScript API を今でも担当しているため、以前のエンジンに理解できる方法でデータを書き戻す必要があります。これには、LayoutMultiColumnFlowThread などの従来の複数列データ構造を正しく更新することが含まれます。

レガシー エンジンのフォールバックの検出と処理

LayoutNG ブロックの断片化でまだ処理できないコンテンツが内部にある場合、従来のレイアウト エンジンにフォールバックする必要がありました。コア LayoutNG ブロックの断片化(2022 年春)リリース時点では、Flex、grid、Tables、および印刷されたすべてのものが含まれていました。これは特に厄介でした。レイアウト ツリーのオブジェクトを作成する前に、従来のフォールバックの必要性を検出する必要がありました。たとえば、複数列のコンテナの祖先の有無や、どの DOM ノードがフォーマット コンテキストになるかを知る前に、検出する必要がありました。これはニワトリの問題であり、完璧な解決策はありませんが、誤動作が誤検出(実際には必要のないのにレガシーにフォールバック)しかない限り、そのレイアウト動作のバグは新しいものではなく、すでに Chromium にあるものであるため問題ありません。

プレペイント ツリー ウォーク

プリペイントは、レイアウトの後、ペイントの前に行います。主な課題は、引き続きレイアウト オブジェクト ツリーをたどる必要があることですが、今は NG フラグメントがあります。これにどのように対処すればよいでしょうか。レイアウト オブジェクトと NG フラグメント ツリーの両方を同時に調べます。2 つのツリー間のマッピングが簡単ではないため、これはかなり複雑です。レイアウト オブジェクトのツリー構造は DOM ツリーの構造によく似ていますが、フラグメント ツリーはレイアウトの入力ではなく出力です。フラグメント ツリーでは、インラインの断片化(行フラグメント)やブロックの断片化(列またはページ フラグメント)などの断片化の影響を実際に反映するだけでなく、包含ブロックと、そのフラグメントを包含ブロックとして持つ DOM 子孫の間にも直接的な親子関係があります。たとえば、フラグメント ツリーでは、フロー外に配置された子孫とその親ブロックとの間の祖先チェーンに他のノードがあっても、絶対的に配置された要素によって生成されたフラグメントは、その要素を含むブロック フラグメントの直接の子になります。

フラグメントの内側にフロー外に配置された要素がある場合、フロー外のフラグメントは fragmentainer の直接の子になる(CSS が包含ブロックとみなす子ではない)ため、さらに複雑になります。残念ながら、この問題は従来のエンジンと問題なく共存するために解決する必要がありました。将来的には、LayoutNG は最新のすべてのレイアウト モードを柔軟にサポートするように設計されているため、このコードの多くを簡素化できるようになるはずです。

従来の断片化エンジンの問題点

かつてのウェブ時代に設計された旧式のエンジンには、断片化という概念がありません。たとえ、その当時(印刷に対応するために)断片化が技術的に存在していたとしても、この点は変わりません。断片化のサポートは、上部に追加(印刷)または後付け(複数列)されたものでした。

フラグメント可能なコンテンツをレイアウトする際、従来のエンジンでは、すべてを縦長のストリップに配置していました。幅は列またはページのインライン サイズであり、高さはコンテンツを含めるのに必要な高さになります。縦長のストリップは、ページにレンダリングされるのではなく、仮想ページにレンダリングされ、最終的な表示用に再配置されると考えることができます。これは概念的には、紙の新聞記事全体を 1 列に印刷し、第 2 ステップとしてはさみを使用して複数に切り分けるのに似ています。(以前、一部の新聞社は、実際にこれと似た手法を使用していました)。

従来のエンジンは、ストリップ内の架空のページまたは列の境界をトラッキングします。これにより、境界を超えてはならないコンテンツを次のページまたは列に移動できます。たとえば、エンジンが現在のページと見なす位置に線の上半分しか収まらない場合は、「ページ分けストラット」を挿入して、エンジンが次のページの先頭であると想定する位置に押し下げます。実際の断片化作業のほとんど(はさみで切り抜いて配置する作業)は、レイアウト後に、コンテンツのプリペイントとペイントで行を切り取ります。このため、断片化の後(仕様では必須)の変換の適用や相対位置決めなど、いくつかのことが基本的に不可能になっていました。さらに、従来のエンジンでは、テーブルの断片化は一部サポートされますが、Flex またはグリッドの断片化はまったくサポートされていません。

次の図は、3 列のレイアウトが従来のエンジンでどのように内部的に表現されているかを示しています。はさみ、配置、接着剤を使用します(ここでは 4 行しか収まるように高さを指定していませんが、下部に余分なスペースがあります)。

コンテンツが分割されるページ設定ストラットを含む 1 つの列としての内部表現と、3 つの列としての画面表現。

従来のレイアウト エンジンでは、実際にはレイアウト中にコンテンツが断片化されないため、相対位置や変換が正しく適用されていない、ボックス シャドウが列の端でクリップされるなど、不自然なアーティファクトが多数あります。

テキスト シャドウの簡単な例を次に示します。

をご覧ください。

従来のエンジンはこれをうまく処理できません。

2 列目にクリップされたテキスト シャドウ。

1 列目の行のテキストの影が切り取られ、2 列目の一番上に配置されているのがわかりますか?これは、以前のレイアウト エンジンが断片化を認識しないためです。

次のようになります(NG の場合は次のようになります)。

シャドウが正しく表示された 2 列のテキスト。

次に、変換とボックス シャドウを使用して、もう少し複雑にしてみましょう。従来のエンジンでは、誤ったクリッピングや列ブリードが発生します。これは、変換が、レイアウト後、断片化後の効果として適用されることが想定されている仕様であるためです。LayoutNG の断片化により、どちらも正しく動作します。これにより、断片化を適切にサポートしてきた Firefox との相互運用性が高まりました。この分野のほとんどのテストでも合格しています。

ボックスが 2 つの列に誤って分割されています。

従来のエンジンには、縦長のモノリシック コンテンツにも問題があります。複数のフラグメントに分割できないコンテンツは、モノリシックです。オーバーフロー スクロールの要素は、ユーザーが長方形以外の領域でスクロールしても意味がないため、モノリシックです。モノリシック コンテンツの例としては、ラインボックスや画像などがあります。次の例をご覧ください。

モノリシック コンテンツの高さが大きすぎて列に収まらない場合、レガシー エンジンはそれを残してスライスします(スクロール可能なコンテナをスクロールしようとすると、非常に「興味深い」動作が発生します)。

(LayoutNG ブロックの断片化のように)最初の列をオーバーフローさせるのではなく、次のようにします。

ALT_TEXT_HERE

従来のエンジンでは強制挿入がサポートされています。たとえば、<div style="break-before:page;"> は DIV の前に改ページを挿入しますが、最適な非強制挿入点を見つけるためのサポートは限定的です。break-inside:avoid孤児とウィドウはサポートされますが、break-before:avoid などでリクエストされた場合、ブロック間の区切りの回避はサポートされません。次の例を考えてみましょう。

2 列に分割されたテキスト。

ここでは、#multicol 要素の各列に 5 行分のスペースがあるため(高さが 100 ピクセル、行の高さが 20 ピクセルであるため)、#firstchild をすべて最初の列に格納できます。しかし、その兄弟の #secondchild には break-before:avoid があります。これは、コンテンツ間で中断が発生しないようにしたいことを意味します。widows の値は 2 であるため、すべての中断回避リクエストを受け入れるには、2 行の #firstchild を 2 列目にプッシュする必要があります。Chromium は、この組み合わせに完全に対応した初めてのブラウザ エンジンです。

NG の断片化の仕組み

NG レイアウト エンジンは通常、深さ優先で CSS ボックス ツリーを走査してドキュメントをレイアウトします。ノードのすべての子孫がレイアウトされたら、NGPhysicalFragment を生成して親レイアウト アルゴリズムに戻ることで、そのノードのレイアウトが完成します。このアルゴリズムは、フラグメントを子フラグメントのリストに追加し、すべての子が完成すると、そのすべての子フラグメントを内部に含むフラグメントを自身のフラグメントを生成します。このメソッドにより、ドキュメント全体のフラグメント ツリーが作成されます。ただし、これは単純すぎると言えます。たとえば、フロー外に配置された要素は、配置する前に、DOM ツリー内の存在する場所からその含まれるブロックにバブルアップする必要があります。わかりやすくするために、ここでは省略します。

CSS ボックス自体とともに、LayoutNG はレイアウト アルゴリズムに制約空間を提供します。これにより、アルゴリズムに、レイアウト用の利用可能なスペース、新しい書式設定のコンテキストが確立されているかどうか、中間マージンが折りたたまれた前のコンテンツの結果などの情報が得られます。制約空間では、フラグメントのレイアウト ブロックサイズと、そのフラグメントの現在のブロック オフセットも認識されます。中断する箇所を示します。

ブロックの断片化が関係している場合、子孫のレイアウトは中断で停止する必要があります。分割の理由としては、ページや列のスペースが不足した場合や、強制的に改行されることがあります。次に、アクセスしたノードのフラグメントを生成し、断片化コンテキストのルート(マルチコル コンテナ、または印刷の場合はドキュメント ルート)まで返します。次に、断片化コンテキストのルートで、新しい fragmentainer を準備し、もう一度ツリーに降下して、中断前に中断したところから再開します。

中断後にレイアウトを再開する手段を提供するための重要なデータ構造は NGBlockBreakToken です。これには、次のフラグメントでレイアウトを正しく再開するために必要なすべての情報が含まれています。NGBlockBreakToken はノードに関連付けられており、再開が必要な各ノードを表すために NGBlockBreakToken ツリーを形成します。内部で障害が発生したノード用に生成された NGPhysicalBoxFragment に NGBlockBreakToken がアタッチされます。ブレーク トークンは親に反映され、ブレーク トークンのツリーを形成します。ノードの内部ではなく前でブレークする必要がある場合、フラグメントは作成されませんが、親ノードでノードの「break-before」ブレーク トークンを作成する必要があります。これにより、次のフラグメントでノードツリーの同じ位置に到達したときにレイアウトを開始できるようになります。

ブレークは、Fragmentainer のスペースが不足したとき(強制されていないブレーク)、または強制ブレークがリクエストされたときに挿入されます。

強制適用されない挿入点の仕様には、最適な挿入点に関するルールがありますが、スペースが足りない場所に改行を入れることが必ずしも正しいとは限りません。たとえば、挿入点の選択に影響を与える break-before などのさまざまな CSS プロパティがあります。したがって、レイアウト中に「非強制挿入」の仕様セクションを正しく実装するには、適切と思われるブレークポイントを追跡する必要があります。このレコードは、ブレーク回避リクエストに違反するポイント(たとえば、break-before:avoidorphans:7)でスペースが足りなくなった場合、最後に見つかった最適なブレークポイントを使用することができることを意味します。考えられる各ブレークポイントには、「最後の手段としてのみ実行する」から「最適な中断場所」まで、間にいくつかの値を含むスコアが与えられます。休憩場所のスコアが「完璧」であれば、その場所に違反しても違反ルールに違反しません(スペースが足りなくなった時点でこのスコアを獲得すれば、もっと良いものを探し出す必要はありません)。スコアが「最後の手段」の場合、ブレークポイントは有効ではありませんが、より良いものが見つからなければ、Fragmentainer のオーバーフローを避けるために、そこで中断することがあります。

通常、有効なブレークポイントは兄弟(行ボックスまたはブロック)の間のみに発生し、親とその最初の子の間には配置されません(クラス C ブレークポイントは例外ですが、ここでは説明しません)。たとえば、break-before:avoid が設定されたブロックの兄弟の前に、有効なブレークポイントがありますが、「完璧」と「最終手段」の間のどこかにあります。

レイアウト中に、NGEarlyBreak と呼ばれる構造でこれまでに見つかった最適なブレークポイントを追跡します。早期ブレークは、ブロックノードの前、内部、または行(ブロック コンテナ行、Flex 行のいずれか)の前にあるブレークポイントです。最適なブレークポイントが、スペースが足りなくなったときに通り過ぎたものの深いところにある場合、NGEarlyBreak オブジェクトのチェーンまたはパスが形成される場合があります。次の例をご覧ください。

この例では、#second の直前にスペースが不足していますが、「break-before:avoid」があるため、休憩ロケーション スコアは「違反のブレーク回避」になります。この時点で、「#outer 内 > #middle 内 > #inner 内 > 3 行目」の前に「perfect」という NGEarlyBreak チェーンがあるため、そこで中断します。そこで、#outer の先頭からレイアウトを戻して再実行し(そして今回は見つかった NGEarlyBreak を渡します)、#inner の「3 行目」の前で中断できるようにする必要があります。(widows:4 を尊重するために、残りの 4 行が次のフラグメントに行き着くように、「3 行目」で改行します)。

このアルゴリズムは、specで定義されているように、正しい順序でルールを削除することで、すべてのルールを満たすことができない場合に、常に最適なブレークポイントでブレークするように設計されています。再レイアウトが必要なのは、フラグメンテーション フローごとに 1 回だけです。2 番目のレイアウトパスになるまでに、最適なブレーク位置がすでにレイアウト アルゴリズムに渡されています。これは、最初のレイアウトパスで検出され、そのラウンドでレイアウト出力の一部として提供されるブレーク位置です。2 番目のレイアウトパスでは、スペースがなくなるまでレイアウトを行いません。実際には、スペースが不足することはありません(実際にはエラーになります)。これは、不必要に違反ルールに違反しないように、早期の中断を挿入できる、非常にスイートな場所(利用可能な範囲)が用意されているためです。そういう点までレイアウトして、中断します。

なお、フラグメント オーバーフローを回避するのに役立つのであれば、場合によっては、違反回避リクエストの一部について違反する必要があります。例:

ここでは、#second の直前にスペースが足りませんが、「break-before:avoid」が設定されています。これは、前の例と同様に、「違反の回避」に翻訳されます。また、「違反する孤児と未亡人」を含む NGEarlyBreak もあります(#first 内 > 「2 行目」の前)。これはまだ完璧ではありませんが、「違反の回避」より優れています。したがって、孤児 / 未亡人の要求に違反して、「2 行目」で改行します。この点については、4.4. Unforced Breaks: フラグメント オーバーフローを回避するのに十分なブレークポイントがない場合に、どの互換性を破るルールを最初に無視するかを定義します。

概要

LayoutNG ブロックの断片化プロジェクトの主な機能目標は、バグ修正を除き、レガシー エンジンがサポートするすべてのものを LayoutNG アーキテクチャをサポートする実装を提供することでした。主な例外は、ブレーク回避のサポートの改善(break-before:avoid など)です。これは断片化エンジンの中核部分であり、最初から組み込まれる必要がありました。後で追加すると別の書き換えが必要になるためです。

LayoutNG ブロックの断片化が完了したので、印刷時の混合ページサイズ、印刷時の @page 余白ボックス、box-decoration-break:clone など、新しい機能の追加に取り掛かることができます。また、LayoutNG 全般と同様、新しいシステムのバグ発生率とメンテナンスの負担は時間の経過とともに大幅に軽減されることが予想されます。

今後ともよろしくお願いいたします。

謝辞