Analyse approfondie de RenderingNG: fragmentation des blocs LayoutNG

Morten Stenshorne
Morten Stenshorne

La fragmentation de bloc consiste à diviser une zone au niveau du bloc CSS (comme une section ou un paragraphe) en plusieurs fragments lorsqu'elle ne tient pas dans son ensemble dans un conteneur de fragments, appelé fragmentainer. Un fragmenteur n'est pas un élément, mais représente une colonne dans une mise en page multicolonne ou une page dans un contenu multimédia paginé.

Pour que la fragmentation se produise, le contenu doit se trouver dans un contexte de fragmentation. Un contexte de fragmentation est généralement établi par un conteneur multicolonne (le contenu est divisé en colonnes) ou lors de l'impression (le contenu est divisé en pages). Un long paragraphe comportant de nombreuses lignes peut nécessiter d'être divisé en plusieurs fragments, de sorte que les premières lignes soient placées dans le premier fragment et les autres dans les fragments suivants.

Paragraphe de texte divisé en deux colonnes.
Dans cet exemple, un paragraphe a été divisé en deux colonnes à l'aide de la mise en page multicolonne. Chaque colonne est un fragmenteur, qui représente un fragment du flux fragmenté.

La fragmentation de bloc est semblable à un autre type de fragmentation bien connu: la fragmentation de ligne, également appelée "coupure de ligne". Tout élément intégré composé de plusieurs mots (nœud de texte, élément <a>, etc.) et permettant les retours à la ligne peut être divisé en plusieurs fragments. Chaque fragment est placé dans une zone de ligne différente. Une zone de ligne est la fragmentation intégrée équivalente à un fragmenteur pour les colonnes et les pages.

Fragmentation des blocs LayoutNG

LayoutNGBlockFragmentation est une réécriture du moteur de fragmentation pour LayoutNG, initialement publié dans Chrome 102. En termes de structures de données, il a remplacé plusieurs structures de données pré-NG par des fragments NG représentés directement dans l'arborescence des fragments.

Par exemple, nous acceptons désormais la valeur "avoid" pour les propriétés CSS "break-before" et "break-after", qui permettent aux auteurs d'éviter les coupures juste après un en-tête. Il est souvent peu pratique de voir un en-tête en dernier sur une page, alors que le contenu de la section commence sur la page suivante. Il est préférable de faire une coupure avant l'en-tête.

Exemple d&#39;alignement de titres.
Figure 1. Le premier exemple montre un titre en bas de la page, le second en haut de la page suivante avec le contenu associé.

Chrome est également compatible avec le débordement de fragmentation, de sorte que le contenu monolithique (censé être incassable) ne soit pas découpé en plusieurs colonnes, et que les effets de peinture tels que les ombres et les transformations soient appliqués correctement.

La fragmentation des blocs dans LayoutNG est maintenant terminée

La fragmentation de base (conteneurs de blocs, y compris la mise en page de ligne, les flottants et le positionnement hors flux) est disponible dans Chrome 102. La fragmentation Flex et de la grille a été publiée dans Chrome 103, et la fragmentation de tableau dans Chrome 106. Enfin, l'impression est disponible dans Chrome 108. La fragmentation de blocs était la dernière fonctionnalité qui dépendait de l'ancien moteur pour effectuer la mise en page.

À partir de Chrome 108, l'ancien moteur n'est plus utilisé pour effectuer la mise en page.

De plus, les structures de données LayoutNG sont compatibles avec la peinture et les tests de contact, mais nous nous appuyons sur certaines anciennes structures de données pour les API JavaScript qui lisent des informations de mise en page, telles que offsetLeft et offsetTop.

En effectuant la mise en page avec NG, vous pourrez implémenter et déployer de nouvelles fonctionnalités qui n'ont que des implémentations LayoutNG (et pas de contrepartie dans l'ancien moteur), telles que les requêtes de conteneur CSS, le positionnement des ancres, MathML et la mise en page personnalisée (Houdini). Pour les requêtes de conteneur, nous l'avons publié un peu à l'avance, en avertissant les développeurs que l'impression n'était pas encore prise en charge.

Nous avons publié la première partie de LayoutNG en 2019. Elle comprenait une mise en page de conteneur de blocs standard, une mise en page en ligne, des flottants et un positionnement hors flux, mais n'était pas compatible avec flex, grid ni les tableaux, et ne prenait pas du tout en charge la fragmentation de blocs. Nous utiliserons l'ancien moteur de mise en page pour flex, grid, les tableaux, et tout ce qui implique une fragmentation de blocs. Cela était vrai même pour les éléments de bloc, en ligne, flottants et hors flux dans un contenu fragmenté. Comme vous pouvez le constater, la mise à niveau d'un moteur de mise en page aussi complexe en place est une danse très délicate.

De plus, dès la mi-2019, la plupart des fonctionnalités de base de la mise en page de fragmentation de blocs LayoutNG étaient déjà implémentées (derrière un indicateur). Pourquoi la livraison a-t-elle pris autant de temps ? Pour faire court, la fragmentation doit coexister correctement avec les différentes parties obsolètes du système, qui ne peuvent pas être supprimées ni mises à niveau tant que toutes les dépendances ne sont pas mises à niveau.

Interaction avec l'ancien moteur

Les anciennes structures de données sont toujours chargées des API JavaScript qui lisent les informations de mise en page. Nous devons donc réécrire les données dans l'ancien moteur de manière à ce qu'il les comprenne. Cela inclut la mise à jour correcte des anciennes structures de données multicolonnes, telles que LayoutMultiColumnFlowThread.

Détection et gestion du remplacement d'anciens moteurs

Nous avons dû revenir à l'ancien moteur de mise en page lorsque le contenu ne pouvait pas encore être géré par la fragmentation de blocs LayoutNG. Au moment de la fragmentation des blocs de base LayoutNG, elle incluait les éléments flex, grille, tableaux et tout ce qui est imprimé. Cette tâche était particulièrement délicate, car nous devions détecter le besoin d'un ancien fallback avant de créer des objets dans l'arborescence de mise en page. Par exemple, nous devions détecter s'il existait un ancêtre de conteneur multicolonne avant de savoir si des nœuds DOM deviendraient ou non un contexte de mise en forme. Il s'agit d'un problème de poule et d'œuf qui n'a pas de solution parfaite, mais tant que le seul comportement incorrect est les faux positifs (recours à l'ancien lorsque ce n'est pas nécessaire), tout va bien, car les bugs de ce comportement de mise en page sont déjà présents dans Chromium, et non nouveaux.

Promenade dans les arbres avant la peinture

La pré-peinture est une étape que nous effectuons après la mise en page, mais avant la peinture. Le principal défi est que nous devons toujours parcourir l'arborescence des objets de mise en page, mais nous avons maintenant des fragments NG. Comment y faire face ? Nous parcourons simultanément l'objet de mise en page et les arbres de fragments NG. Cette opération est assez complexe, car la mise en correspondance entre les deux arbres n'est pas simple.

Bien que la structure arborescente de l'objet de mise en page ressemble étroitement à celle de l'arborescence DOM, l'arborescence de fragments est une sortie de la mise en page, et non une entrée. En plus de refléter l'effet de toute fragmentation, y compris la fragmentation en ligne (fragments de ligne) et la fragmentation par bloc (fragments de colonne ou de page), l'arborescence des fragments présente également une relation parent-enfant directe entre un bloc contenant et les descendants DOM qui ont ce fragment comme bloc contenant. Par exemple, dans l'arborescence des fragments, un fragment généré par un élément positionné de manière absolue est un enfant direct du fragment de bloc contenant, même s'il existe d'autres nœuds dans la chaîne d'ascendance entre le descendant hors flux et son bloc contenant.

Cela peut être encore plus compliqué lorsqu'un élément positionné en dehors du flux se trouve dans la fragmentation, car les fragments en dehors du flux deviennent alors des enfants directs du fragmenteur (et non un enfant de ce que CSS considère comme le bloc contenant). Ce problème devait être résolu pour coexister avec l'ancien moteur. À l'avenir, nous devrions pouvoir simplifier ce code, car LayoutNG est conçu pour prendre en charge de manière flexible tous les modes de mise en page modernes.

Problèmes liés à l'ancien moteur de fragmentation

L'ancien moteur, conçu à une époque antérieure du Web, ne repose pas vraiment sur le concept de fragmentation, même si la fragmentation existait techniquement à l'époque (pour prendre en charge l'impression). La prise en charge de la fragmentation n'était qu'un élément ajouté en plus (impression) ou rétrofité (multicolonne).

Lors de la mise en page du contenu fragmentable, l'ancien moteur le met en page dans une bande haute dont la largeur correspond à la taille intégrée d'une colonne ou d'une page, et dont la hauteur est aussi haute que nécessaire pour contenir son contenu. Cette bande haute n'est pas affichée sur la page. Imaginez qu'elle soit affichée sur une page virtuelle qui est ensuite réorganisée pour l'affichage final. C'est conceptuellement semblable à l'impression d'un article de journal papier entier dans une seule colonne, puis à la découpe de plusieurs colonnes à l'aide de ciseaux. (À l'époque, certains journaux utilisaient des techniques similaires.)

L'ancien moteur suit une limite de page ou de colonne imaginaire dans la bande. Cela permet de déplacer le contenu qui ne rentre pas dans la limite vers la page ou la colonne suivante. Par exemple, si seule la moitié supérieure d'une ligne peut tenir sur ce que le moteur considère comme la page actuelle, il insère une "entretoise de pagination" pour la pousser vers le bas à la position où le moteur suppose que se trouve le haut de la page suivante. Ensuite, la majeure partie du travail de fragmentation réel (le "découpage avec des ciseaux et le placement") a lieu après la mise en page lors de la pré-peinture et de la peinture, en découpant la longue bande de contenu en pages ou en colonnes (en coupant et en traduisant des parties). Cela rendait certaines choses essentiellement impossibles, comme l'application de transformations et le positionnement relatif après la fragmentation (ce que la spécification exige). De plus, bien que le moteur précédent prenne en charge la fragmentation de table, il n'est pas du tout compatible avec la fragmentation flex ou de grille.

Voici une illustration de la façon dont une mise en page à trois colonnes est représentée en interne dans l'ancien moteur, avant d'utiliser des ciseaux, un emplacement et de la colle (nous avons spécifié une hauteur, de sorte que seules quatre lignes s'adaptent, mais il reste de l'espace en trop en bas):

Représentation interne en une seule colonne avec des poutres de pagination là où le contenu se brise, et représentation à l'écran en trois colonnes

Étant donné que l'ancien moteur de mise en page ne fragmente pas réellement le contenu lors de la mise en page, de nombreux artefacts étranges apparaissent, tels que le positionnement relatif et les transformations qui s'appliquent de manière incorrecte, et les ombres portées qui sont rognées aux bords des colonnes.

Voici un exemple avec text-shadow:

L'ancien moteur ne gère pas bien cela:

Ombres de texte rognées placées dans la deuxième colonne.

Voyez-vous comment l'ombre du texte de la ligne de la première colonne est rognée et placée en haut de la deuxième colonne ? En effet, l'ancien moteur de mise en page ne comprend pas la fragmentation.

Voici le résultat attendu :

Deux colonnes de texte avec les ombres correctement affichées.

Ensuite, rendons les choses un peu plus complexes avec des transformations et une ombre portée. Notez que dans l'ancien moteur, le recadrage et le débordement de colonne sont incorrects. En effet, les transformations sont censées être appliquées en tant qu'effet post-mise en page, post-fragmentation. Avec la fragmentation LayoutNG, les deux fonctionnent correctement. Cela améliore l'interopérabilité avec Firefox, qui bénéficie depuis un certain temps d'une bonne prise en charge de la fragmentation, et la plupart des tests dans ce domaine y réussissent également.

Les cases sont mal réparties sur deux colonnes.

L'ancien moteur rencontre également des problèmes avec les contenus monolithiques volumineux. Un contenu est monolithique s'il ne peut pas être divisé en plusieurs fragments. Les éléments avec défilement en cas de dépassement sont monolithiques, car il n'est pas logique pour les utilisateurs de faire défiler une région non rectangulaire. Les cases de ligne et les images sont d'autres exemples de contenu monolithique. Exemple :

Si le contenu monolithique est trop haut pour tenir dans une colonne, l'ancien moteur le découpe de manière brutale (ce qui entraîne un comportement très "intéressant" lorsque vous essayez de faire défiler le conteneur à faire défiler):

Au lieu de laisser le contenu déborder dans la première colonne (comme c'est le cas avec la fragmentation de blocs LayoutNG):

ALT_TEXT_HERE

L'ancien moteur est compatible avec les pauses forcées. Par exemple, <div style="break-before:page;"> insère un saut de page avant le DIV. Toutefois, il n'est pas compatible avec la recherche de sauts non forcés optimaux. Il est compatible avec break-inside:avoid et les orphelins et les veuves, mais il n'est pas possible d'éviter les coupures entre les blocs, par exemple si elles sont demandées via break-before:avoid. Considérez l'exemple suivant :

Texte divisé en deux colonnes.

Ici, l'élément #multicol peut accueillir cinq lignes dans chaque colonne (car il mesure 100 pixels de haut et que la hauteur de ligne est de 20 pixels). Ainsi, l'ensemble de #firstchild peut tenir dans la première colonne. Toutefois, son frère #secondchild a break-before:avoid, ce qui signifie que le contenu ne souhaite pas qu'une coupure se produise entre eux. Étant donné que la valeur de widows est 2, nous devons insérer deux lignes de #firstchild dans la deuxième colonne pour respecter toutes les demandes d'évitement des coupures. Chromium est le premier moteur de navigateur à prendre entièrement en charge cette combinaison de fonctionnalités.

Fonctionnement de la fragmentation NG

Le moteur de mise en page NG met généralement en page le document en parcourant l'arborescence des boîtes CSS en profondeur. Lorsque tous les descendants d'un nœud sont mis en page, la mise en page de ce nœud peut être finalisée en produisant un NGPhysicalFragment et en revenant à l'algorithme de mise en page parent. Cet algorithme ajoute le fragment à sa liste de fragments enfants et, une fois tous les enfants terminés, génère un fragment pour lui-même avec tous ses fragments enfants à l'intérieur. Cette méthode crée un arbre de fragments pour l'ensemble du document. Il s'agit toutefois d'une simplification excessive: par exemple, les éléments positionnés en dehors du flux doivent remonter de l'endroit où ils se trouvent dans l'arborescence DOM vers leur bloc contenant avant de pouvoir être mis en page. Je vais ignorer ce détail avancé pour plus de simplicité.

En plus de la boîte CSS elle-même, LayoutNG fournit un espace de contraintes à un algorithme de mise en page. Cela fournit à l'algorithme des informations telles que l'espace disponible pour la mise en page, si un nouveau contexte de mise en forme est établi et les résultats de la réduction des marges intermédiaires du contenu précédent. L'espace de contraintes connaît également la taille de bloc mise en page du fragmenteur et le décalage de bloc actuel dans celui-ci. Cela indique où faire un saut de ligne.

Lorsque la fragmentation de bloc est impliquée, la mise en page des descendants doit s'arrêter à une coupure. Les raisons de cette coupure peuvent être diverses : manque d'espace sur la page ou dans la colonne, ou coupure forcée. Nous produisons ensuite des fragments pour les nœuds que nous avons visités, et remontons jusqu'à la racine du contexte de fragmentation (le conteneur multicolonne ou, en cas d'impression, la racine du document). Ensuite, au niveau de la racine du contexte de fragmentation, nous nous préparons à un nouveau fragmenteur et nous descendons à nouveau dans l'arborescence, en reprenant là où nous nous étions arrêtés avant la pause.

La structure de données essentielle pour fournir les moyens de reprendre la mise en page après une pause est appelée NGBlockBreakToken. Il contient toutes les informations nécessaires pour reprendre la mise en page correctement dans le fragmentainer suivant. Un NGBlockBreakToken est associé à un nœud et forme un arbre NGBlockBreakToken, de sorte que chaque nœud à reprendre soit représenté. Un NGBlockBreakToken est associé au NGPhysicalBoxFragment généré pour les nœuds qui se séparent à l'intérieur. Les jetons de rupture sont propagés aux parents, formant un arbre de jetons de rupture. Si nous devons effectuer une coupure avant un nœud (plutôt que dedans), aucun fragment ne sera généré, mais le nœud parent doit toujours créer un jeton de coupure "break-before" pour le nœud, afin que nous puissions commencer à le mettre en page lorsque nous atteignons la même position dans l'arborescence des nœuds du prochain fragmenteur.

Des coupures sont insérées lorsque nous manquons d'espace dans le fragmenteur (coupure non forcée) ou lorsqu'une coupure forcée est demandée.

La spécification contient des règles pour les coupures involontaires optimales. Il n'est pas toujours judicieux d'insérer une coupure exactement là où nous manquons d'espace. Par exemple, diverses propriétés CSS telles que break-before influencent le choix de l'emplacement de la coupure.

Lors de la mise en page, afin d'implémenter correctement la section de spécifications des coupures forcées, nous devons suivre les points de rupture potentiellement appropriés. Cet enregistrement nous permet de revenir en arrière et d'utiliser le dernier point d'arrêt possible trouvé, si nous manquons d'espace à un point où nous ne respecterions pas les requêtes d'évitement des interruptions (par exemple, break-before:avoid ou orphans:7). Chaque point d'arrêt possible reçoit un score, allant de "ne le faire qu'en dernier recours" à "endroit idéal pour l'arrêt", avec des valeurs intermédiaires. Si un emplacement de coupure est marqué comme "parfait", cela signifie qu'aucune règle de coupure ne sera violée si nous faisons une coupure à cet endroit (et si nous obtenons ce score exactement au moment où nous manquons d'espace, il n'est pas nécessaire de revenir en arrière pour trouver quelque chose de mieux). Si le score est "dernier recours", le point d'arrêt n'est même pas valide, mais nous pouvons toujours y interrompre le processus si nous ne trouvons rien de mieux, afin d'éviter tout débordement du fragmenteur.

Les points d'arrêt valides ne se produisent généralement que entre des frères et sœurs (boîtes de ligne ou blocs), et non, par exemple, entre un parent et son premier enfant (les points d'arrêt de classe C constituent une exception, mais nous n'avons pas besoin d'en parler ici). Il existe un point d'arrêt valide pour une instance avant un frère de bloc avec break-before:avoid, mais il se situe quelque part entre "parfait" et "dernier recours".

Lors de la mise en page, nous tenons compte du meilleur point d'arrêt trouvé jusqu'à présent dans une structure appelée NGEarlyBreak. Un "early-break" est un point d'arrêt possible avant ou dans un nœud de bloc, ou avant une ligne (ligne de conteneur de bloc ou ligne flex). Nous pouvons former une chaîne ou un chemin d'objets NGEarlyBreak, au cas où le meilleur point d'arrêt se trouverait dans un élément que nous avons ignoré précédemment lorsque nous avons manqué d'espace. Exemple :

Dans ce cas, nous manquons d'espace juste avant #second, mais il contient "break-before:avoid", qui obtient un score d'emplacement de coupure de "non-respect de l'évitement de coupure". À ce stade, nous avons une chaîne NGEarlyBreak de "inside #outer > inside #middle > inside #inner > before "line 3"', avec "perfect". Nous préférons donc interrompre le processus à ce stade. Nous devons donc revenir en arrière et réexécuter la mise en page depuis le début de #outer (et cette fois transmettre le NGEarlyBreak que nous avons trouvé), afin de pouvoir interrompre avant la "ligne 3" dans #inner. (Nous interrompons avant la ligne 3 afin que les quatre lignes restantes se retrouvent dans le fragmenteur suivant et pour respecter widows:4.)

L'algorithme est conçu pour s'arrêter toujours au meilleur point d'arrêt possible, tel que défini dans les spécifications, en abandonnant les règles dans l'ordre approprié si toutes ne peuvent pas être satisfaites. Notez que nous ne devons effectuer une nouvelle mise en page qu'une seule fois par flux de fragmentation. Lorsque nous entrons dans la deuxième étape de mise en page, le meilleur emplacement de coupure a déjà été transmis aux algorithmes de mise en page. Il s'agit de l'emplacement de coupure découvert lors de la première étape de mise en page et fourni dans la sortie de mise en page de ce cycle. Lors de la deuxième étape de mise en page, nous ne laissons pas la mise en page se terminer tant que nous n'avons pas utilisé tout l'espace disponible. En fait, nous ne devrions pas manquer d'espace (ce serait une erreur), car nous avons un emplacement super pratique (le plus pratique possible) pour insérer une coupure anticipée afin d'éviter de violer inutilement les règles de rupture. Nous nous arrêtons à ce point.

À ce sujet, nous devons parfois ne pas respecter certaines des requêtes d'évitement des coupures si cela permet d'éviter le débordement du fragmenteur. Exemple :

Ici, nous manquons d'espace juste avant #second, mais il comporte "break-before:avoid". Cela se traduit par "non-respect de l'évitement de la coupure", comme dans le dernier exemple. Nous avons également un NGEarlyBreak avec "orphans and widows" (orphelins et veuves non conformes) (dans #first > avant "line 2"), qui n'est toujours pas parfait, mais mieux que "violating break avoid" (non-respect de l'évitement de la coupure). Nous allons donc nous arrêter avant la ligne 2, ce qui ne respecte pas la demande d'orphelins / veuves. La spécification traite de ce point dans la section 4.4. "Unforced Breaks" (Pauses non forcées), qui définit les règles de rupture ignorées en premier si nous ne disposons pas de suffisamment de points d'arrêt pour éviter le débordement du fragmenteur.

Conclusion

L'objectif fonctionnel du projet de fragmentation de blocs LayoutNG était de fournir une implémentation compatible avec l'architecture LayoutNG de tout ce que le moteur précédent prend en charge, et aussi peu que possible, à l'exception des corrections de bugs. La principale exception est une meilleure prise en charge de l'évitement des coupures (break-before:avoid, par exemple), car il s'agit d'un élément essentiel du moteur de fragmentation. Il devait donc être présent dès le départ, car l'ajouter plus tard impliquerait une nouvelle réécriture.

Maintenant que la fragmentation des blocs LayoutNG est terminée, nous pouvons commencer à ajouter de nouvelles fonctionnalités, telles que la prise en charge de tailles de page mixtes lors de l'impression, des marges @page lors de l'impression, box-decoration-break:clone, etc. Comme pour LayoutNG en général, nous nous attendons à ce que le taux de bugs et la charge de maintenance du nouveau système soient considérablement inférieurs au fil du temps.

Remerciements

  • Una Kravets pour la belle "capture d'écran faite à la main".
  • Chris Harrelson pour la relecture, les commentaires et les suggestions.
  • Philip Jägenstedt pour obtenir des commentaires et des suggestions.
  • Rachel Andrew pour la modification et la première figure d'exemple multicolonne.