Análisis detallado de RenderingNG: Fragmentación de bloques de LayoutNG

Morten Stenshorne
Morten Stenshorne

La fragmentación de bloques divide un cuadro de nivel de bloque de CSS (como una sección o un párrafo) en varios fragmentos cuando no cabe por completo dentro de un contenedor de fragmentos, llamado fragmentador. Un fragmentainer no es un elemento, sino que representa una columna en un diseño de varias columnas o una página en contenido multimedia paginado.

Para que ocurra la fragmentación, el contenido debe estar dentro de un contexto de fragmentación. Un contexto de fragmentación generalmente se establece mediante un contenedor de varias columnas (el contenido se divide en columnas) o cuando se imprime (el contenido se divide en páginas). Es posible que un párrafo largo con muchas líneas deba dividirse en varios fragmentos, de modo que las primeras líneas se coloquen en el primer fragmento y las líneas restantes en fragmentos posteriores.

Un párrafo de texto dividido en dos columnas.
En este ejemplo, se dividió un párrafo en dos columnas con un diseño de varias columnas. Cada columna es un fragmentador y representa un fragmento del flujo fragmentado.

La fragmentación de bloques es análoga a otro tipo conocido de fragmentación: la fragmentación de línea, también conocida como "salto de línea". Cualquier elemento intercalado que tenga más de una palabra (cualquier nodo de texto, cualquier elemento <a>, etc.) y que permita saltos de línea se puede dividir en varios fragmentos. Cada fragmento se coloca en un cuadro de línea diferente. Un cuadro de línea es la fragmentación intercalada equivalente a un fragmentador para columnas y páginas.

Fragmentación de bloques LayoutNG

LayoutNGBlockFragmentation es una reescritura del motor de fragmentación para LayoutNG, que se envió inicialmente en Chrome 102. En términos de estructuras de datos, reemplazó varias estructuras de datos anteriores a la NG con fragmentos de NG representados directamente en el árbol de fragmentos.

Por ejemplo, ahora admitimos el valor "evitar" para las propiedades de CSS "separar antes" y "separar después", que permiten a los autores evitar las pausas justo después de un encabezado. Suele parecer incómodo cuando lo último en una página es un encabezado, mientras que el contenido de la sección comienza en la página siguiente. Es mejor realizar un desglose antes del encabezado.

Ejemplo de alineación de encabezado.
Figura 1: En el primer ejemplo, se muestra un encabezado en la parte inferior de la página y, en el segundo, en la parte superior de la página siguiente con su contenido asociado.

Chrome también admite el desbordamiento de fragmentación, de modo que el contenido monolítico (se supone que no se puede romper) no se divida en varias columnas, y los efectos de pintura, como las sombras y las transformaciones, se aplican correctamente.

Se completó la fragmentación de bloques en LayoutNG

Fragmentación principal (contenedores de bloque, incluidos el diseño de línea, los números de punto flotante y el posicionamiento fuera de flujo) que se envía en Chrome 102 La fragmentación de Flex y de cuadrícula se envió en Chrome 103 y la fragmentación de tablas se envió en Chrome 106. Por último, la impresión se envió en Chrome 108. La fragmentación de bloques fue la última función que dependía del motor heredado para realizar el diseño.

A partir de Chrome 108, el motor heredado ya no se usa para realizar el diseño.

Además, las estructuras de datos de LayoutNG admiten la pintura y la prueba de posicionamiento, pero dependemos de algunas estructuras de datos heredadas para las APIs de JavaScript que leen información de diseño, como offsetLeft y offsetTop.

Diseñar todo con NG permitirá implementar y enviar funciones nuevas que solo tengan implementaciones de LayoutNG (y no tengan un equivalente de motor heredado), como las consultas de contenedores de CSS, el posicionamiento de los anclajes, MathML y el diseño personalizado (Houdini). Para las consultas sobre contenedores, lo enviamos con un poco de anticipación y se advirtió a los desarrolladores que aún no se admitía la impresión.

Lanzamos la primera parte de LayoutNG en 2019, que consistía en un diseño de contenedor de bloque regular, un diseño en línea, elementos flotantes y un posicionamiento fuera de flujo, pero no era compatible con Flex, Cuadrícula o tablas, y no era compatible con la fragmentación de bloques. Volveremos a usar el motor de diseño heredado para flex, cuadrículas, tablas y todo lo que implicara fragmentación de bloques. Esto era así incluso para los elementos de bloque, intercalados, flotantes y fuera de flujo dentro de contenido fragmentado. Como puedes ver, actualizar un motor de diseño tan complejo en su ubicación es un proceso muy delicado.

Además, a mediados de 2019, la mayor parte de la funcionalidad principal del diseño de fragmentación de bloques de LayoutNG ya se había implementado (detrás de un indicador). Entonces, ¿por qué tardó tanto tiempo en enviarse? La respuesta corta es: la fragmentación tiene que coexistir correctamente con varias partes heredadas del sistema, que no se pueden quitar ni actualizar hasta que se actualicen todas las dependencias.

Interacción con motores heredados

Las estructuras de datos heredadas aún están a cargo de las APIs de JavaScript que leen información de diseño, por lo que debemos reescribir los datos en el motor heredado de una manera que los entienda. Esto incluye la actualización correcta de las estructuras heredadas de datos de varias columnas, como LayoutMultiColumnFlowThread.

Detección y manejo de resguardos de motores heredados

Tuvimos que recurrir al motor de diseño heredado cuando había contenido en el interior que aún no se podía controlar con la fragmentación de bloques de LayoutNG. En el momento del envío de la fragmentación de bloques principales de LayoutNG, incluidos los elementos flex, Grid, tablas y todo lo que se imprimiera. Esto era particularmente complicado porque necesitábamos detectar la necesidad de un resguardo heredado antes de crear objetos en el árbol de diseño. Por ejemplo, necesitábamos detectar antes de saber si existía un principal de contenedor de varias columnas y antes de saber qué nodos del DOM se convertirían en un contexto de formato o no. Es un problema común que no tiene una solución perfecta, pero siempre que su único comportamiento inadecuado sean falsos positivos (se recurre a la herencia cuando en realidad no hay necesidad), está bien, porque los errores en ese comportamiento de diseño son los que ya tiene Chromium, no los nuevos.

Antes de pintar el árbol

La pintura previa es algo que hacemos después del diseño, pero antes antes de pintar. El desafío principal es que todavía tenemos que recorrer el árbol de objetos de diseño, pero ahora tenemos fragmentos de NG. ¿Cómo lidiamos con eso? Recorrimos el objeto de diseño y los árboles de fragmentos de NG al mismo tiempo. Esto es bastante complicado, porque el mapeo entre los dos árboles no es trivial.

Si bien la estructura del árbol de objetos de diseño se asemeja mucho a la del árbol del DOM, el árbol de fragmentos es un resultado del diseño, no una entrada. Además de reflejar el efecto de cualquier fragmentación, incluida la fragmentación intercalada (fragmentos de línea) y la fragmentación de bloques (fragmentos de columna o página), el árbol de fragmentos también tiene una relación directa superior-secundario entre un bloque contenedor y los subordinados del DOM que tienen ese fragmento como su bloque contenedor. Por ejemplo, en el árbol de fragmentos, un fragmento generado por un elemento posicionado absolutamente es un elemento secundario directo del fragmento de bloque que lo contiene, incluso si existen otros nodos en la cadena principal entre el subordinado posicionado fuera del flujo y el bloque que lo contiene.

Esto puede resultar aún más complicado cuando hay un elemento posicionado fuera del flujo dentro de la fragmentación, ya que los fragmentos fuera de flujo se convierten en elementos secundarios directos del fragmentainer (y no en un elemento secundario de lo que CSS cree que es el bloque contenedor). Este era un problema que debía resolverse para que coexistiera con el motor heredado. En el futuro, deberíamos poder simplificar este código, ya que LayoutNG está diseñado para admitir de manera flexible todos los modos de diseño modernos.

Los problemas con el motor de fragmentación heredado

El motor heredado, diseñado en una era anterior a la Web, en realidad no tiene un concepto de fragmentación, incluso si la fragmentación existía técnicamente en ese entonces también (para admitir la impresión). La compatibilidad con la fragmentación era algo que se agregaba encima (impresión) o se adaptaba (varias columnas).

Cuando se diseña contenido fragmentable, el motor heredado dispone todo en una franja alta cuyo ancho es el tamaño intercalado de una columna o página, y la altura es tan alta como debe ser para incluir su contenido. Esta franja alta no se renderiza en la página. Considérala como una renderización en una página virtual que luego se reorganiza para su visualización final. Es conceptualmente similar a imprimir un artículo completo de un periódico en papel en una columna y, luego, usar tijeras para cortarlo en varios como un segundo paso. (En el pasado, algunos periódicos en realidad usaban técnicas similares a esta).

El motor heredado realiza un seguimiento de una página o un límite de columna imaginario en la franja. Eso le permite desplazar el contenido que no cabe más allá del límite a la página o columna siguiente. Por ejemplo, si solo la mitad superior de una línea cabe en lo que el motor considera que es la página actual, insertará un "paseo de paginación" para empujarla hacia abajo hasta la posición en la que el motor supone que está la parte superior de la página siguiente. Luego, la mayor parte del trabajo de fragmentación real (el "cortar con tijeras y colocación") se lleva a cabo después del diseño durante la pintura previa y la pintura, o dividiendo porciones altas de contenido en columnas (o recortarlas en columnas altas). Esto hizo que algunas cosas fueran prácticamente imposibles, como la aplicación de transformaciones y el posicionamiento relativo después de la fragmentación (que es lo que requiere la especificación). Además, si bien el motor heredado admite cierta compatibilidad con la fragmentación de tablas, no se admite en absoluto la fragmentación de Flex o de cuadrícula.

Aquí se muestra una ilustración de cómo se representa internamente un diseño de tres columnas en el motor heredado, antes de usar las tijeras, la colocación y el pegado (se especifica una altura, de manera que solo caben cuatro líneas, pero hay un poco de espacio excedente en la parte inferior):

La representación interna como una columna con tramos de paginación donde se divide el contenido y la representación en pantalla como tres columnas

Debido a que el motor de diseño heredado en realidad no fragmenta el contenido durante el diseño, hay muchos artefactos extraños, como el posicionamiento relativo y la aplicación incorrecta de las transformaciones, y el recorte de sombras de cuadro en los bordes de las columnas.

Este es un ejemplo con text-shadow:

El motor heredado no lo hace bien:

Sombras de texto recortadas que se colocan en la segunda columna

¿Ves cómo la sombra del texto de la línea en la primera columna se recorta y, en su lugar, se coloca en la parte superior de la segunda columna? Esto se debe a que el motor de diseño heredado no entiende la fragmentación.

Debería tener el siguiente aspecto:

Dos columnas de texto con las sombras que se muestran correctamente.

A continuación, hagámoslo un poco más complicado con las transformaciones y la sombra de cuadro. Observa que, en el motor heredado, hay recortes incorrectos y sangrado de columna. Esto se debe a que las transformaciones se suponen, según las especificaciones, que se aplican como un efecto posterior al diseño y a la fragmentación. Con la fragmentación de LayoutNG, ambos funcionan correctamente. Esto aumenta la interoperabilidad con Firefox, que ha tenido una buena compatibilidad con la fragmentación durante un tiempo y la mayoría de las pruebas en esta área también pasan allí.

Los cuadros están divididos incorrectamente en dos columnas.

El motor heredado también tiene problemas con el contenido monolítico alto. El contenido es monolítico si no es apto para dividirse en varios fragmentos. Los elementos con desplazamiento desbordado son monolíticos, porque no tiene sentido para los usuarios desplazarse en una región no rectangular. Los cuadros de línea y las imágenes son otros ejemplos de contenido monolítico. Por ejemplo:

Si el contenido monolítico es demasiado alto para caber en una columna, el motor heredado lo dividirá de manera brutal (lo que generará un comportamiento muy "interesante" cuando se intente desplazar el contenedor):

En lugar de permitir que desborde la primera columna (como sucede con la fragmentación de bloques de LayoutNG):

ALT_TEXT_HERE

El motor heredado admite pausas forzadas. Por ejemplo, <div style="break-before:page;"> insertará un salto de página antes del elemento DIV. Sin embargo, solo tiene compatibilidad limitada para encontrar saltos no forzados óptimos. Admite break-inside:avoid y huérfanos y viudas, pero no se admiten las pausas entre bloques, por ejemplo, si se solicita a través de break-before:avoid. Considera el siguiente ejemplo:

Texto dividido en dos columnas.

Aquí, el elemento #multicol tiene espacio para 5 líneas en cada columna (porque mide 100 px de alto y la altura de línea es de 20 px), por lo que todo #firstchild podría caber en la primera columna. Sin embargo, su elemento secundario #secondchild tiene "break-before:avoid", lo que significa que el contenido desea que no se produzca una pausa entre ellos. Dado que el valor de widows es 2, debemos enviar 2 líneas de #firstchild a la segunda columna para cumplir con todas las solicitudes de elusión de interrupciones. Chromium es el primer motor de navegador que es totalmente compatible con esta combinación de funciones.

Cómo funciona la fragmentación de NG

El motor de diseño NG generalmente diseña el documento recorriendo primero la profundidad del árbol de cuadros de CSS. Cuando se presentan todos los elementos subordinados de un nodo, el diseño de ese nodo se puede completar. Para ello, se debe producir un NGPhysicalFragment y volver al algoritmo de diseño superior. Ese algoritmo agrega el fragmento a su lista de fragmentos secundarios y, una vez que se completan todos los elementos secundarios, genera un fragmento para sí mismo con todos sus fragmentos secundarios dentro. Con este método, crea un árbol de fragmentos para todo el documento. Sin embargo, esta es una simplificación excesiva: por ejemplo, los elementos posicionados fuera del flujo deberán surgir desde donde existen en el árbol del DOM hasta su bloque que los contiene antes de que se puedan distribuir. Ignoré este detalle avanzado para mayor simplicidad.

Junto con el cuadro CSS en sí, LayoutNG proporciona un espacio de restricción a un algoritmo de diseño. Esto le brinda al algoritmo información como el espacio disponible para el diseño, si se establece un nuevo contexto de formato y el margen intermedio que contrae los resultados del contenido anterior. El espacio de restricción también conoce el tamaño de bloque establecido del fragmentador y el desplazamiento del bloque actual en él. Esto indica dónde realizar la interrupción.

Cuando se implementa la fragmentación de bloques, el diseño de los elementos subordinados debe detenerse en una pausa. Los motivos de la interrupción incluyen quedarse sin espacio en la página o columna, o una pausa forzada. Luego, producimos fragmentos para los nodos que visitamos y devolvemos todo hasta la raíz del contexto de fragmentación (el contenedor multicol o, en el caso de la impresión, la raíz del documento). Luego, en la raíz del contexto de fragmentación, nos preparamos para un nuevo fragmentador y bajamos de nuevo al árbol, reanudando donde lo dejamos antes de la pausa.

La estructura de datos crucial para proporcionar los medios para reanudar el diseño después de una pausa se denomina NGBlockBreakToken. Contiene toda la información necesaria para reanudar el diseño correctamente en el siguiente fragmentador. Un NGBlockBreakToken se asocia con un nodo y forma un árbol de NGBlockBreakToken, por lo que se representa cada nodo que se debe reanudar. Un NGBlockBreakToken se adjunta al NGPhysicalBoxFragment generado para los nodos que se rompen dentro de él. Los tokens de pausa se propagan a los elementos superiores y forman un árbol de tokens de pausa. Si necesitamos realizar una división antes de un nodo (en lugar de dentro de él), no se producirá ningún fragmento, pero el nodo superior igualmente deberá crear un token de pausa de “separación previa” para el nodo, de modo que podamos comenzar a colocarlo cuando lleguemos a la misma posición en el árbol de nodos en el siguiente fragmentador.

Las pausas se insertan cuando nos quedamos sin espacio fragmentado (una pausa no forzada) o cuando se solicita una pausa forzada.

En la especificación, hay reglas para las pausas no forzadas óptimas, y insertar una pausa exactamente donde nos quedamos sin espacio no siempre es lo correcto. Por ejemplo, hay varias propiedades de CSS, como break-before, que influyen en la elección de la ubicación de la pausa.

Durante el diseño, para implementar correctamente la sección de especificación de las pausas no forzadas, debemos realizar un seguimiento de los puntos de interrupción que pueden ser buenos. Este registro significa que podemos volver y usar el último punto de interrupción más reciente que se haya encontrado si nos quedamos sin espacio en un punto en el que infringiríamos las solicitudes para evitar interrupciones (por ejemplo, break-before:avoid o orphans:7). A cada punto de interrupción posible se le asigna una puntuación que va desde “hacer esto solo como último recurso” hasta “lugar perfecto para descansar”, con algunos valores intermedios. Si la puntuación de una ubicación de descanso es "perfecta", significa que no se infringirán reglas de incumplimiento si fallamos allí (y si obtenemos esta puntuación exactamente en el punto en que nos quedamos sin espacio, no hay necesidad de mirar hacia atrás para encontrar algo mejor). Si la puntuación es “último recurso”, el punto de interrupción ni siquiera es válido, pero podemos interrumpirlo si no encontramos nada mejor para evitar el desbordamiento de fragmentainer.

Los puntos de interrupción válidos generalmente solo ocurren entre elementos del mismo nivel (cuadros de líneas o bloques), y no, por ejemplo, entre un elemento superior y su primer elemento secundario (los puntos de interrupción de clase C son una excepción, pero no necesitamos discutirlos aquí). Hay un punto de interrupción válido, por ejemplo, antes de un bloque del mismo nivel con "break-before:avoid", pero está entre "perfect" y "last-resort".

Durante el diseño, realizamos un seguimiento del mejor punto de interrupción que se encontró hasta el momento en una estructura llamada NGEarlyBreak. Una interrupción anticipada es un posible punto de interrupción antes o dentro de un nodo de bloque, o antes de una línea (ya sea una línea de contenedor de bloque o una línea flexible). Podemos formar una cadena o ruta de objetos NGEarlyBreak, en caso de que el mejor punto de interrupción se encuentre en algún lugar profundo de algo que ya vimos cuando nos quedamos sin espacio. Por ejemplo:

En este caso, nos quedamos sin espacio justo antes de #second, pero tiene "break-before:avoid", que obtiene una puntuación de ubicación de pausa de "evitación de descanso infractora". En ese punto, tenemos una cadena NGEarlyBreak de "inside #outer > dentro de #middle > dentro de #inner > antes de la "line 3"', con "perfect", por lo que preferimos desglosarla allí. Por lo tanto, debemos mostrar y volver a ejecutar el diseño desde el principio de #outer (y esta vez pasar el NGEarlyBreak que encontramos) para realizar un desglose antes de la "línea 3" en #inner. (Se realiza una división antes de la "línea 3" para que las 4 líneas restantes terminen en el siguiente fragmentainer y para cumplir con widows:4).

El algoritmo está diseñado para fallar siempre en el mejor punto de interrupción posible, según se define en la especificación, descartando las reglas en el orden correcto, si no se pueden cumplir todas. Ten en cuenta que solo debemos volver a diseñar el diseño una vez por flujo de fragmentación, como máximo. Para cuando estemos en el segundo pase de diseño, la mejor ubicación de pausa ya pasó a los algoritmos de diseño. Esta es la ubicación de pausa que se descubrió en el primer pase de diseño y se proporcionó como parte del resultado de diseño en esa ronda. En el segundo pase de diseño, no realizaremos el aprovisionamiento hasta que nos quedemos sin espacio. De hecho, no se espera que nos quedemos sin espacio (eso sería un error), porque se nos proporcionó un lugar muy dulce (bueno, tan dulce como haya disponible) para insertar una pausa anticipada, a fin de evitar infringir cualquier regla innecesariamente. Así que lo hacemos en ese punto y lo dividimos.

En esa nota, a veces es necesario infringir algunas de las solicitudes para evitar interrupciones si eso ayuda a evitar el desbordamiento de fragmentainer. Por ejemplo:

En este ejemplo, nos quedamos sin espacio justo antes de #second, pero tiene "break-before:avoid". Esto se traduce como “incumplimiento de políticas para evitar interrupciones”, como en el último ejemplo. También tenemos un NGEarlyBreak con “violación de huérfanos y viudas” (dentro de #first > antes de la “línea 2”), que sigue siendo un problema, pero es mejor que “no incumplir las rupturas”. Por lo tanto, se produce un salto antes de la “línea 2”, lo que infringe la solicitud de huérfanos / viudas. Esto se aborda en la especificación en la sección 4.4. No forzadas Breaks, donde define qué reglas de interrupción se ignoran primero si no tenemos suficientes puntos de interrupción para evitar el desbordamiento de fragmentainer.

Conclusión

El objetivo funcional del proyecto de fragmentación de bloques LayoutNG era proporcionar una implementación que respaldara la arquitectura de LayoutNG de todo lo que admite el motor heredado, y lo menos posible, además de la corrección de errores. La excepción principal es una mejor compatibilidad para evitar interrupciones (break-before:avoid, por ejemplo), porque esta es una parte central del motor de fragmentación, por lo que tuvo que estar ahí desde el principio, ya que agregarla luego implicaría otra reescritura.

Ahora que finalizó la fragmentación de bloques de LayoutNG, podemos comenzar a trabajar para agregar nuevas funcionalidades, como la compatibilidad con distintos tamaños de página al imprimir, los cuadros de margen @page al imprimir y box-decoration-break:clone, entre otras. Y al igual que con LayoutNG en general, esperamos que la tasa de errores y la carga de mantenimiento del nuevo sistema sean sustancialmente más bajas con el tiempo.

Agradecimientos