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

Morten Stenshorne
Morten Stenshorne

ブロックの断片化とは、1 つのフラグメント コンテナ(フラグメント コンテナ)内に全体が収まらない場合に、CSS ブロックレベルのボックス(セクションや段落など)を複数のフラグメントに分割する機能です。Fragmentainer は要素ではなく、複数列レイアウトの列、またはページング メディアのページを表します。

断片化が起こるには、コンテンツが断片化コンテキスト内にある必要があります。断片化のコンテキストは、通常、複数列のコンテナ(コンテンツが列に分割される)または印刷(コンテンツがページに分割される)によって確立されます。多くの行がある長い段落は、複数のフラグメントに分割する必要があるかもしれません。そうすれば、最初の行は最初のフラグメントに配置し、残りの行は後続のフラグメントに配置できます。

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

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

LayoutNG ブロックの断片化

LayoutNGBlockFragmentation は、LayoutNG 用の断片化エンジンを書き換えたもので、最初に Chrome 102 でリリースされます。データ構造については、NG 以前の複数のデータ構造を、フラグメント ツリー内で直接表現される NG フラグメントに置き換えました。

たとえば、「break-before」と「break-after」の CSS プロパティの「avoid」値がサポートされるようになったため、作成者はヘッダーの直後の中断を回避できます。ページの最後がヘッダーで、そのセクションのコンテンツが次のページから始まっている場合、見た目が悪くなることがよくあります。ヘッダーの前で分割することをおすすめします。

見出しの配置の例。
図 1. 1 つ目の例ではページの下部に見出しを表示し、2 つ目の例では次の関連するコンテンツを含むページの上部に見出しを表示します。

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

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

Chrome 102 に導入されたコア断片化(行レイアウト、浮動小数点数、フロー外配置などのブロック コンテナ)。Flex とグリッドの断片化は Chrome 103 でリリースされ、テーブルの断片化は Chrome 106 でリリースされました。最後に、Chrome 108 では印刷機能がリリースされます。ブロックの断片化は、レイアウトの実行で以前のエンジンに依存する最後の機能です。

Chrome 108 以降、以前のエンジンはレイアウトの実行に使用されなくなります。

さらに、LayoutNG データ構造はペイントテストとヒットテストをサポートしていますが、レイアウト情報を読み取る JavaScript API(offsetLeftoffsetTop など)については、いくつかの以前のデータ構造に依存しています。

すべてを NG でレイアウトすることで、CSS コンテナクエリ、アンカーの位置指定、MathMLカスタム レイアウト(Houdini)など、LayoutNG の実装のみを持つ(以前のエンジンに対応するものがない)新機能を実装してリリースできるようになります。コンテナクエリについては、印刷にはまだ対応していないことをデベロッパーに警告するとともに、少し前にリリースしました。

LayoutNG の第 1 部は 2019 年にリリースされました。これは、通常のブロック コンテナ レイアウト、インライン レイアウト、浮動小数点数、フロー外配置で構成されていましたが、Flex、グリッド、テーブルはサポートしておらず、ブロックの断片化もまったくサポートしていません。フレックス、グリッド、テーブルなど、ブロックの断片化に関連するものには、従来のレイアウト エンジンを使用します。これは、断片化されたコンテンツ内のブロック、インライン、フローティング、フロー外といった要素にも当てはまります。ご覧のとおり、このような複雑なレイアウト エンジンをインプレースでアップグレードすることは、非常に繊細なダンスです。

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

以前のエンジンの操作

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

以前のエンジンのフォールバックの検出と処理

LayoutNG ブロックの断片化で処理できないコンテンツが内部にあった場合、従来のレイアウト エンジンにフォールバックする必要がありました。コア LayoutNG の出荷時点では、Flex、Grid、Tables など、出力されたあらゆるものが含まれていた、ブロックの断片化が発生していました。これは特に、レイアウト ツリー内のオブジェクトを作成する前に以前のフォールバックの必要性を検出する必要があったため、対処が困難でした。たとえば、複数列のコンテナの祖先の有無を知る前に、またどの DOM ノードが書式設定コンテキストになるかを知る前に、検出する必要がありました。これはニワトリと卵が関係する問題であり、完璧な解決策はありませんが、その唯一の誤動作が誤検出(実際には不要であればレガシーにフォールバックする)である限りは問題ありません。そのレイアウト動作のバグは、Chromium にすでにあるバグであって新しいバグではありません。

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

プリペインティングは、レイアウトの後、ペイントの前に行います。主な課題は、引き続きレイアウト オブジェクト ツリーをウォークする必要があることですが、今回は NG フラグメントになりました。では、どのように対処すればよいでしょうか。レイアウト オブジェクトと NG フラグメント ツリーの両方を同時に歩きます。2 つのツリー間のマッピングは簡単ではないため、これはかなり複雑です。

レイアウト オブジェクトのツリー構造は DOM ツリーによく似ていますが、フラグメント ツリーはレイアウトへの入力ではなく、レイアウトの出力です。フラグメント ツリーは、インライン フラグメント(行フラグメント)やブロック フラグメント(列またはページ フラグメント)などの断片化の影響を実際に反映するだけでなく、包含ブロックと、そのフラグメントを包含ブロックとして持つ DOM 子孫の間に直接の親子関係を持ちます。たとえば、フラグメント ツリーでは、フロー外に配置された子孫とそれを含むブロックの間の系図チェーンに他のノードがあっても、絶対位置にある要素によって生成されたフラグメントは、それを含むブロック フラグメントの直接の子になります。

フラグメント内部にフロー外に配置された要素があると、さらに複雑になる可能性があります。フロー外フラグメントは、フラグメントはフラグメント生成ツールの直接の子になります(CSS が包含ブロックと認識している子ではないため)。これは、以前のエンジンと共存するために解決しなければならなかった問題でした。将来的には、このコードを簡素化できるはずです。LayoutNG は、すべての最新レイアウト モードを柔軟にサポートするように設計されているためです。

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

ウェブの以前の時代に設計された従来のエンジンには断片化の概念がありませんでした。印刷をサポートするために、当時は断片化が技術的にあったとしてもです。断片化のサポートは追加(印刷)または後付け(複数カラム)されたにすぎません。

断片化可能なコンテンツをレイアウトする場合、以前のエンジンでは、すべてを縦長のストリップとしてレイアウトしていました。幅は列またはページのインライン サイズで、高さはコンテンツを格納するために必要な高さです。この縦長のストリップはページにはレンダリングされません。仮想ページにレンダリングされた後、最終的な表示のために再配置されるようなものです。これは概念的には、紙の新聞記事全体を 1 列に印刷し、その後、ステップとしてハサミを使って複数に切り分けるのに似ています。(当時、一部の新聞では実際にこれと似た手法が使われていました)。

以前のエンジンは、ストリップ内の架空のページまたは列の境界を追跡します。これにより、境界を越えて収まらないコンテンツを次のページまたは列に移動できます。たとえば、線の上半分のみが現在のページであると判断した位置に線の上半分しか収まらない場合は、「ページネーションストラット」を挿入して、エンジンが次のページの最上部であると想定する位置に押し下げます。その後、実際の断片化作業(「ハサミと配置によるカット」)のほとんどは、レイアウトの後に、ペイント前およびペイント時にコンテンツを切り取ることにより、コンテンツを切り取ることによって行われます。そのため、断片化後の変換や相対位置設定(仕様で規定されている要件)の適用など、いくつかの処理が基本的に不可能になっていました。さらに、以前のエンジンでもテーブルの断片化はサポートされていますが、Flex やグリッドの断片化はサポートされていません。

次の図は、ハサミ、配置、接着剤を使用する前に、3 列のレイアウトが以前のエンジンの内部でどのように表されているかを示しています(4 本の線のみが収まるように高さを指定していますが、下部に余白があります)。

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

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

text-shadow を設定した例を次に示します。

以前のエンジンではこの処理がうまくいきません。

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

最初の列の行からのテキスト シャドウが切り詰められ、代わりに 2 番目の列の先頭に配置されているのがわかりますか?これは、以前のレイアウト エンジンは断片化を認識しないためです。

これは次のように表示されます。

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

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

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

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

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

最初の列をオーバーフローさせるのではなく(LayoutNG ブロックの断片化の場合のように):

ALT_TEXT_HERE

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

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

ここで、#multicol 要素には各列に 5 行分のスペースがあるため(高さが 100 ピクセル、line-height が 20 ピクセルであるため)、#firstchild のすべてが最初の列に収まる可能性があります。ただし、兄弟 #secondchild には break-before:avoid があるため、コンテンツが 2 つのコンテンツの間に挿入されないようにする必要があります。widows の値は 2 であるため、#firstchild の 2 行を 2 番目の列にプッシュして、すべてのブレーク回避リクエストに対応する必要があります。Chromium は、これらの機能の組み合わせを完全にサポートする最初のブラウザ エンジンです。

NG の断片化の仕組み

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

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

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

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

フラグメントは、フラグメント スペースを使い切ったとき(強制適用されないブレーク)か、強制ブレークがリクエストされたときに挿入されます。

仕様には、最適な非強制挿入のためのルールがありますが、スペースがなくなった場所にブレークを正確に挿入することは必ずしも正しいこととは限りません。たとえば、break-before などのさまざまな CSS プロパティが、ブレークの場所の選択に影響を与えます。

レイアウト時に、強制適用されないブレークの仕様セクションを正しく実装するために、おそらく適切なブレークポイントを追跡する必要があります。このレコードにより、ブレーク回避リクエストに違反するポイント(break-before:avoidorphans:7 など)でスペースが不足した場合に、最後に見つかった最良のブレークポイントを使用できます。可能性のある各ブレークポイントには、「最後の手段としてのみ行う」から「休憩に最適な場所」まで、いくつかの値を含むスコアが付与されます。休憩位置のスコアが「完璧」である場合、その場所で違反しても違反ルールに違反しないことを意味します(また、スペースがなくなった時点でこのスコアを取得すれば、もっと良い場所を探す必要はありません)。スコアが「最後の手段」の場合、ブレークポイントは有効なものでさえありませんが、より良いものが見つからなかった場合は、フラグメント オーバーフローを回避するために、ブレークポイントで中断する場合があります。

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

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

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

このアルゴリズムは、仕様で定義されているとおり、すべてのルールが満たされない場合にルールを正しい順序で破棄することで、可能な限り最適なブレークポイントでブレークするように設計されています。再レイアウトが必要になるのは、断片化フローごとに 1 回だけです。2 番目のレイアウトパスになるまでには、最適なブレーク位置がすでにレイアウト アルゴリズムに渡されています。これは、最初のレイアウトパスで検出され、そのラウンドのレイアウト出力の一部として提供されたブレーク位置です。2 つ目のレイアウトパスでは、スペースが不足するまでレイアウトしません。実際、スペースが不足することはないと予想されます(実際にエラーになるわけではありません)。これは、不必要に違反ルールに違反しないように、早めの休憩を挿入する非常に甘い場所(利用可能な場合もある)が提供されているためです。そこまでレイアウトして、中断します。

なお、フラグメントのオーバーフローを回避するため、一部のブレーク回避リクエストに違反しなければならない場合もあります。次に例を示します。

ここでは、#second の直前にスペースがなくなりましたが、「break-before:avoid」が指定されています。これは、前の例と同様に「違反による休憩の回避」と翻訳されます。また、NGEarlyBreak に「違反している孤児とウィドウ」(#first 内の > 「2 行目」の前)もありますが、まだ完璧ではありませんが、「違反による休憩の回避」よりも優れています。そのため、「2 行目」の前で中断します。これは孤児 / 未亡人のリクエストに違反しています。これについては、4.4. 非強制ブレーク: フラグメントのオーバーフローを回避するのに十分なブレークポイントがない場合に、どの違反ルールを最初に無視するかを定義します。

おわりに

LayoutNG ブロックの断片化プロジェクトの機能的な目標は、以前のエンジンがサポートするすべてのものを LayoutNG アーキテクチャをサポートする実装を提供し、バグ修正を除き、それ以外はできる限り少なくすることでした。主な例外は、より優れたブレーク回避のサポート(break-before:avoid など)です。これは断片化エンジンのコア部分であり、後から追加すると別の書き換えを意味するため、最初から存在している必要があるためです。

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

謝辞

  • Una Kravets: 手作りのスクリーンショットを撮ります。
  • Chris Harrelson に、校正、フィードバック、提案を依頼します。
  • Philip Jägenstedt にフィードバックやご提案をお寄せください。
  • Rachel Andrew(編集担当)。最初の複数列の例の図。