Analyse approfondie de RenderingNG: fragmentation des blocs LayoutNG

Morten Stenshorne
Morten Stenshorne

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

Pour que la fragmentation se produise, le contenu doit se trouver dans un contexte de fragmentation. Le contexte de fragmentation est le plus souvent établi par un conteneur à plusieurs colonnes (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 avoir besoin d'être divisé en plusieurs fragments, de sorte que les premières lignes soient placées dans le premier fragment et les lignes restantes dans les fragments suivants.

Paragraphe de texte divisé en deux colonnes.
Dans cet exemple, un paragraphe a été divisé en deux colonnes à l'aide d'une mise en page multicolonne. Chaque colonne est un fragmentainer, représentant un fragment du flux fragmenté.

La fragmentation par bloc est analogue à un autre type bien connu de fragmentation: la fragmentation de ligne, également appelée "saut de ligne". Tout élément intégré comportant plusieurs mots (tout nœud de texte, n'importe quel élément <a>, etc.) et autorisant les sauts de ligne peut être divisé en plusieurs fragments. Chaque fragment est placé dans une zone de ligne différente. Une zone de ligne est une fragmentation intégrée qui équivaut à un fragment pour les colonnes et les pages.

Fragmentation du bloc LayoutNG

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

Par exemple, nous acceptons désormais la valeur "avoid" pour les propriétés CSS 'break-before' et 'break-after', ce qui permet aux auteurs d'éviter les sauts juste après un en-tête. Cela semble souvent gênant lorsque la dernière chose d'une page est un en-tête, alors que le contenu de la section commence sur la page suivante. Il est préférable de placer un saut de ligne avant l'en-tête.

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

Chrome est également compatible avec le débordement de fragmentation, de sorte que le contenu monolithique (supposé comme insécable) n'est pas divisé en plusieurs colonnes et que les effets de peinture tels que les ombres et les transformations sont appliqués correctement.

La fragmentation des blocs dans LayoutNG est maintenant terminée

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

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

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

En mettant tout en page avec NG, il sera possible d'implémenter et de fournir de nouvelles fonctionnalités qui ne comportent que des implémentations de LayoutNG (et aucun équivalent dans un 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 expédiée un peu à l'avance, avec un avertissement aux développeurs indiquant que l'impression n'était pas encore disponible.

Nous avons lancé la première partie de LayoutNG en 2019, qui consistait en une mise en page standard de conteneurs de blocs, une mise en page intégrée, des éléments flottants et un positionnement hors flux, mais n'est pas compatible avec le format Flex, la grille ou les tables, et ne prend pas en charge la fragmentation des blocs. Nous recourrons à l'ancien moteur de mise en page pour les éléments Flex, les grilles, les tables et tout ce qui implique la fragmentation des blocs. Cela s'applique également aux éléments de bloc, intégrés, flottants et hors flux au sein d'un contenu fragmenté. Comme vous pouvez le voir, la mise à niveau sur place d'un moteur de mise en page aussi complexe est une affaire délicate.

Par ailleurs, à la mi-2019, la majorité des fonctionnalités de base de la mise en page de fragmentation des blocs LayoutNG étaient déjà implémentées (derrière un indicateur). Pourquoi a-t-il fallu autant de temps pour être expédié ? La réponse courte est la suivante: la fragmentation doit coexister correctement avec différentes anciennes parties 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 responsables des API JavaScript qui lisent les informations de mise en page. Nous devons donc écrire les données dans l'ancien moteur de manière qu'il puisse les comprendre. Cela inclut la mise à jour correcte des anciennes structures de données multicolonnes, telles que LayoutMultiColumnFlowThread.

Détection et gestion des remplacements de moteurs existants

Nous avons dû revenir à l'ancien moteur de mise en page lorsque du contenu ne pouvait pas encore être géré par la fragmentation du bloc LayoutNG. Au moment de l'expédition de la fragmentation des blocs LayoutNG principale, qui incluait le flex, la grille, les tables et tout ce qui est imprimé. Cela était particulièrement délicat, car nous devions détecter la nécessité d'anciennes créations de remplacement avant de créer des objets dans l'arborescence de mise en page. Par exemple, nous devions détecter avant de savoir s'il existait un ancêtre de conteneur multicolonne, et avant de savoir quels nœuds DOM deviendront 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. Cependant, tant que son seul comportement incorrect est de faux positifs (remplacement à l'ancienne version alors que cela n'est pas nécessaire), ce n'est pas grave, car tous les bugs de ce comportement de mise en page sont déjà en cause dans Chromium, et non de nouveaux.

Peindre une balade dans les arbres avant de peindre

Nous effectuons cette opération après la mise en page, mais avant de peindre. La principale difficulté est que nous devons encore parcourir l'arborescence des objets de mise en page, mais que nous disposons maintenant de fragments NG. Comment gérer cela ? Nous parcourons à la fois l'objet de mise en page et les arborescences de fragments qui ne sont pas opérationnelles. C'est assez compliqué, car le mappage entre les deux arbres n'est pas simple.

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

Cela peut s'avérer encore plus compliqué lorsqu'un élément est positionné hors de la fragmentation, car les fragments hors flux deviennent alors des enfants directs du fragmentainer (et non un enfant de ce que le CSS considère comme le bloc conteneur). Il fallait résoudre ce problème pour pouvoir coexister avec l'ancien moteur. À l'avenir, nous devrions être en mesure de 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, n'a pas vraiment de concept de fragmentation, même si cette fragmentation existait également d'un point de vue technique (afin de permettre l'impression). La prise en charge de la fragmentation était simplement quelque chose qui était vissé sur le dessus (impression) ou réajusté (sur plusieurs colonnes).

Lorsque le contenu est fragmenté, l'ancien moteur présente tout le contenu dans une longue bande dont la largeur correspond à la taille intégrée d'une colonne ou d'une page, et dont la hauteur est aussi élevée que nécessaire pour contenir le contenu. Cette grande bande ne s'affiche pas sur la page. Considérez-la comme un rendu sur une page virtuelle qui est ensuite réorganisée pour l'affichage final. Ce processus est conceptuellement similaire à l'impression d'un article de journal papier entier dans une colonne, puis à l'utilisation de ciseaux pour le découper en plusieurs éléments lors de la deuxième étape. (Auparavant, certains journaux utilisaient des techniques semblables à celle-ci !)

L'ancien moteur effectue le suivi d'une limite de page ou de colonne imaginaire dans la bande. Cela lui permet de pousser le contenu qui ne dépasse pas la limite sur la page ou la colonne suivante. Par exemple, si seule la moitié supérieure d'une ligne correspond à ce que le moteur considère comme la page actuelle, il insérera une "patination de pagination" pour la pousser vers le bas jusqu'à l'endroit où le moteur suppose que le haut de la page suivante se trouve. Ensuite, la majeure partie du travail de fragmentation (la "découpage avec ciseaux et placement") s'effectue après la mise en page lors de la pré-peinture et de la mise en page du contenu, en découpant les pages et en les écrasant. Cela rendait certaines choses essentiellement impossibles, comme l'application de transformations et le positionnement relatif après la fragmentation (ce qui est requis par la spécification). De plus, bien que l'ancien moteur prenne en charge la fragmentation des tables d'une manière ou d'une autre, il n'est pas du tout compatible avec la fragmentation flexible ou en grille.

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

Représentation interne sous la forme d'une colonne avec des jambes de pagination où le contenu se rompt, et la représentation à l'écran sous la forme de trois colonnes

Étant donné que l'ancien moteur de mise en page ne fragmente pas le contenu lors de la mise en page, il existe de nombreux artefacts étranges, comme un positionnement relatif et des transformations qui ne sont pas correctement appliquées, et des ombres en forme de rectangle rognées au bord des colonnes.

Voici un exemple avec text-shadow:

L'ancien moteur ne gère pas correctement ces opérations:

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 affichées correctement.

Maintenant, rendons les choses un peu plus compliquées, avec des transformations et un effet d'ombre case. Comme vous pouvez le constater, l'ancien moteur présente des erreurs de rognage et de "fond perdu des colonnes". En effet, selon les spécifications, les transformations sont censées être appliquées en tant qu'effet post-mise en page et post-fragmentation. La fragmentation de LayoutNG fonctionne correctement. Cela augmente l'interopérabilité avec Firefox, qui a déjà bien pris en charge la fragmentation depuis un certain temps, la plupart des tests dans ce domaine étant également passés par là.

Les cases sont mal divisées en deux colonnes.

L'ancien moteur rencontre également des problèmes avec le contenu monolithique de grande taille. Le contenu est monolithique s'il n'est pas éligible à la division en plusieurs fragments. Les éléments avec défilement de dépassement sont monolithiques, car il n'est pas logique pour les utilisateurs de faire défiler dans une région non rectangulaire. Les zones de ligne et les images sont d'autres exemples de contenu monolithique. Exemple :

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

Plutôt que de le laisser dépasser de la première colonne (comme c'est le cas avec la fragmentation du bloc LayoutNG):

ALT_TEXT_HERE

L'ancien moteur accepte les coupures forcées. Par exemple, <div style="break-before:page;"> insère un saut de page avant l'élément DIV. Cependant, sa compatibilité avec la recherche de sauts de page non forcés optimaux est limitée. Il est compatible avec break-inside:avoid et les orphelins et veuves, mais il n'est pas possible d'éviter les coupures entre les blocs, si vous y êtes invité via break-before:avoid, par exemple. Prenons l'exemple suivant :

Texte divisé en deux colonnes.

Ici, l'élément #multicol peut contenir cinq lignes dans chaque colonne (car sa hauteur est de 100 pixels et sa hauteur de 20 pixels). Ainsi, tout #firstchild peut tenir dans la première colonne. Cependant, son élément sœur #secondchild utilise "break-before:avoid", ce qui signifie que le contenu souhaite qu'aucune pause ne se produise entre les deux. Comme la valeur de widows est 2, nous devons transmettre deux lignes de #firstchild dans la deuxième colonne afin de répondre à toutes les demandes d'évitement des coupures. Chromium est le premier moteur de navigateur entièrement compatible avec 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 d'abord la profondeur de l'arborescence de la zone CSS. Lorsque tous les descendants d'un nœud sont disposés, la disposition de ce nœud peut être complété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 que tous les enfants sont terminés, génère un fragment pour lui-même avec tous ses fragments enfants à l'intérieur. Cette méthode crée une arborescence de fragments pour l'ensemble du document. Il s'agit toutefois d'une simplification à l'excès: par exemple, les éléments positionnés hors flux devront remonter à l'endroit où ils se trouvent dans l'arborescence DOM jusqu'au bloc qui les contient avant de pouvoir être disposés. Par souci de simplicité, j'ignore ce détail avancé ici.

En plus de la zone CSS elle-même, LayoutNG fournit un espace de contrainte à 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 a été établi et les résultats de réduction de la marge intermédiaire issus du contenu précédent. L'espace de contrainte connaît également la taille de bloc disposée du fragmentainer et le décalage de bloc actuel dans celui-ci. Cela indique où le saut de route.

En cas de fragmentation des blocs, la mise en page des descendants doit s'arrêter à une pause. Cela peut être dû à un manque d'espace sur la page ou à la colonne, ou à un saut forcé. Nous produisons ensuite des fragments pour les nœuds que nous avons visités et nous renvoyons jusqu'à la racine du contexte de fragmentation (le conteneur multicol ou, en cas d'impression, la racine du document). Ensuite, à la racine du contexte de fragmentation, nous nous préparons pour un nouveau fragmentainer, puis nous redescendons dans l'arborescence, en reprenant là où nous nous sommes arrêtés avant la pause.

La structure de données essentielle permettant de reprendre la mise en page après une pause est appelée NGBlockBreakToken. Il contient toutes les informations nécessaires pour reprendre correctement la mise en page dans le fragmentainer suivant. Un NGBlockBreakToken est associé à un nœud et forme une arborescence NGBlockBreakToken, de sorte que chaque nœud devant être réactivé est représenté. Un NGBlockBreakToken est associé au NGPhysicalBoxFragment généré pour les nœuds qui sont intrusifs. Les jetons de rupture sont propagés aux parents, formant une arborescence de jetons de rupture. Si nous devons casser un nœud avant (et non à l'intérieur), aucun fragment n'est produit, mais le nœud parent doit toujours créer un jeton de rupture de type "avant" pour le nœud, afin de pouvoir commencer à le positionner lorsque nous arriverons à la même position dans l'arborescence du nœud dans le fragmentainer suivant.

Des sauts sont insérés lorsque l'espace fragmenté est insuffisant (arrêt non forcé) ou lorsqu'une coupure forcée est demandée.

La spécification contient des règles permettant d'optimiser les coupures non forcées. Il n'est pas toujours judicieux d'insérer une coupure là où l'espace est insuffisant. Par exemple, plusieurs 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écification des sauts non forcés, nous devons suivre les points d'arrêt éventuellement performants. Cet enregistrement signifie que nous pouvons revenir en arrière et utiliser le meilleur point d'arrêt possible si nous manquions d'espace au moment où nous ne respections pas les demandes d'évitement des pannes (par exemple, break-before:avoid ou orphans:7). Chaque point d'arrêt possible reçoit un score, allant de "Ne faire ceci qu'en dernier recours" à "l'endroit idéal pour sauter", avec quelques valeurs entre les deux. Si un emplacement est marqué comme "Parfait", cela signifie qu'aucune règle contraire ne sera enfreinte en cas de non-respect (et si nous obtenons ce score exactement au moment où nous manquons d'espace, il n'est pas nécessaire de chercher quelque chose de mieux). Si le score est de "dernier recours", le point d'arrêt n'est même pas valide, mais il est possible que nous arrivions toujours à atteindre cet objectif si nous ne trouvons rien de mieux, afin d'éviter un débordement de fragmentainer.

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

Lors de la mise en page, nous enregistrons le meilleur point d'arrêt trouvé jusqu'à présent dans une structure appelée NGEarlyBreak. Une rupture précoce est un point d'arrêt possible avant ou à l'intérieur d'un nœud de bloc, ou avant une ligne (une ligne de conteneur de bloc ou une ligne flexible). Nous pouvons former une chaîne ou un chemin d'objets NGEarlyBreak, au cas où le meilleur point d'arrêt se trouverait au fond d'un élément que nous avons passé plus tôt, au moment où l'espace devient insuffisant. Exemple :

Dans le cas présent, nous manquons d'espace juste avant #second, mais la valeur "break-before:avoid" est définie sur "break-before:avoid", qui obtient un score d'emplacement de pause "ne pas respecter la pause évitée". À ce stade, nous avons une chaîne NGEarlyBreak "inside #outer > à l'intérieur de #middle > à l'intérieur de #inner > avant la "ligne 3"', avec la valeur "perfect". Nous préférons donc la casser. Nous devons donc renvoyer et réexécuter la mise en page à partir du début de #outer (et cette fois transmettre le NGEarlyBreak que nous avons trouvé), afin de pouvoir interrompre la "ligne 3" dans #inner. (Nous interrompons la ligne 3 avant que les quatre lignes restantes se retrouvent dans le fragmentainer suivant, et afin de respecter widows:4.)

L'algorithme est conçu pour toujours atteindre le meilleur point d'arrêt possible, tel que défini dans les spécifications, en supprimant les règles dans le bon ordre, si elles ne peuvent pas toutes être satisfaites. Notez que nous n'avons besoin d'une nouvelle mise en page qu'une seule fois par flux de fragmentation. Au moment où nous nous trouvons dans la deuxième 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 qui a été découvert lors de la première mise en page et fourni dans le cadre de la sortie de mise en page dans cette phase. Dans la deuxième mise en page, la mise en page ne s'effectue que lorsque l'espace est insuffisant. Nous ne sommes pas censés manquer d'espace (ce serait en fait une erreur), car nous avons reçu un emplacement très sucré (aussi bon qu'il était disponible) pour insérer une pause précoce, afin d'éviter d'enfreindre inutilement les règles. Nous allons donc faire une mise en page jusqu'à ce point,

À ce titre, nous devons parfois ignorer certaines demandes d'évitement de rupture, si cela permet d'éviter un débordement de fragmentainer. Exemple :

Ici, nous manquons d'espace juste avant #second, mais la valeur "break-before:avoid" est spécifiée. Cela se traduit par « éviter les pauses non respectées », comme dans le dernier exemple. Nous avons également un élément NGEarlyBreak avec "infractions orphelines et veuves" (dans #first > avant "ligne 2"), ce qui n'est pas parfait, mais c'est mieux que "ne pas respecter les pauses évitées". Nous allons donc faire une pause avant "ligne 2", ce qui enfreint la requête "orphelines / veuves". La spécification aborde ce point à la section 4.4. Cas de rupture non forcés, qui définit les règles destructives à ignorer en premier si le nombre de points d'arrêt est insuffisant pour éviter un débordement de fragmentainer.

Conclusion

L'objectif fonctionnel du projet de fragmentation de blocs LayoutNG était de fournir une implémentation compatible avec l'architecture LayoutNG, et le moins possible d'éléments compatibles avec l'ancien moteur, hormis les corrections de bugs. L'exception principale est une meilleure prise en charge de l'évitement des pannes (break-before:avoid, par exemple), car il s'agit d'un élément essentiel du moteur de fragmentation. Il doit donc être présent dès le début, car l'ajouter plus tard signifierait une autre 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, les zones de marge @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 diminuent au fil du temps.

Remerciements