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

Morten Stenshorne
Morten Stenshorne

La fragmentación de bloques divide un cuadro a nivel de bloque de CSS (como una sección o un párrafo) en varios fragmentos cuando no cabe en su totalidad dentro de un contenedor de fragmentos, llamado fragmentainer. Un fragmentador 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 se produzca la fragmentación, el contenido debe estar dentro de un contexto de fragmentación. Por lo general, un contenedor de varias columnas (el contenido se divide en columnas) o la impresión (el contenido se divide en páginas) establecen un contexto de fragmentación. 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 restantes en los fragmentos posteriores.

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

La fragmentación de bloques es análoga a otro tipo de fragmentación conocido: la fragmentación de líneas, también conocida como “corte de línea”. Cualquier elemento intercalado que conste de más de una palabra (cualquier nodo de texto, cualquier elemento <a>, etcétera) y 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 de LayoutNG

LayoutNGBlockFragmentation es una nueva versión 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 por fragmentos de NG representados directamente en el árbol de fragmentos.

Por ejemplo, ahora admitimos el valor "avoid" para las propiedades CSS "break-before" y "break-after", que permiten a los autores evitar las pausas justo después de un encabezado. A menudo, se ve poco atractivo cuando el último elemento de una página es un encabezado, mientras que el contenido de la sección comienza en la página siguiente. Es mejor hacer una pausa 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 (que 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 apliquen correctamente.

Se completó la fragmentación de bloques en LayoutNG

La fragmentación principal (contenedores de bloques, incluido el diseño de línea, los números de punto flotante y el posicionamiento fuera del flujo) se envió en Chrome 102. La fragmentación de flex y 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, ya no se usa el motor heredado para realizar el diseño.

Además, las estructuras de datos de LayoutNG admiten la pintura y las pruebas de hit, 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 contrapartes de motores heredados), como consultas de contenedor de CSS, posicionamiento de ancla, MathML y diseño personalizado (Houdini). Para las consultas en 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. Volveríamos a usar el motor de diseño heredado para flex, cuadrícula, tablas y todo lo que involucre la 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, ya se había implementado la mayoría de la funcionalidad principal del diseño de fragmentación de bloques de LayoutNG (detrás de una marca). Entonces, ¿por qué tardó tanto en enviarse? La respuesta breve es que la fragmentación debe 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 el motor heredado

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 actualizar correctamente las estructuras de datos heredadas de varias columnas, como LayoutMultiColumnFlowThread.

Detección y manejo de resguardo de motor heredado

Tuvimos que recurrir al motor de diseño heredado cuando había contenido que aún no podía controlarse 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 fue 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 había un ancestro de contenedor de varias columnas y antes de saber qué nodos DOM se convertirían en un contexto de formato. Es un problema de "huevo y gallina" que no tiene una solución perfecta, pero, siempre y cuando su único comportamiento incorrecto sea de falsos positivos (reemplazo a la versión heredada cuando en realidad no es necesario), está bien, ya que los errores en ese comportamiento de diseño son los que Chromium ya tiene, no los nuevos.

Recorrido por los árboles antes de la pintura

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? Exploramos 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 de superior-subordinado entre un bloque contenedor y los descendientes del DOM que tienen ese fragmento como su bloque contenedor. Por ejemplo, en el árbol de fragmentos, un fragmento generado por un elemento con posición absoluta es un elemento secundario directo de su fragmento de bloque contenedor, incluso si hay otros nodos en la cadena de ascendencia entre el descendiente con posición fuera del flujo y su bloque contenedor.

Puede ser aún más complicado cuando hay un elemento posicionado fuera del flujo dentro de la fragmentación, ya que, en ese caso, los fragmentos fuera del flujo se convierten en elementos secundarios directos del fragmentador (y no en elementos secundarios de lo que CSS considera el bloque contenedor). Este era un problema que se debía resolver para coexistir con el motor heredado. En el futuro, deberíamos poder simplificar este código, ya que LayoutNG está diseñado para admitir de forma 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 en la parte superior (impresión) o se adaptaba (multicolumna).

Cuando se diseña contenido fragmentable, el motor heredado lo diseña 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 sea necesario para contener 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. Conceptualmente, es similar a imprimir un artículo de periódico en papel completo en una columna y, luego, usar tijeras para cortarlo en varias como segundo paso. (En el pasado, algunos periódicos usaban técnicas similares).

El motor heredado realiza un seguimiento de un límite imaginario de página o columna en la tira. Esto permite que el contenido que no se ajusta al límite se traslade a la siguiente página o columna. 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 a 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 aplicar transformaciones y 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 no fragmenta el contenido durante el diseño, hay muchos artefactos extraños, como el posicionamiento relativo y las transformaciones que se aplican de forma incorrecta, y las sombras de cuadro que se recortan en los bordes de las columnas.

Este es un ejemplo con text-shadow:

El motor heredado no controla bien lo siguiente:

Sombras de texto recortadas colocadas 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, hagamos que sea un poco más complicado, con transformaciones y sombras de cuadro. Observa cómo, en el motor heredado, hay recortes incorrectos y sangrado de columnas. Esto se debe a que, según las especificaciones, las transformaciones se deben aplicar 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 tiene una buena compatibilidad con la fragmentación desde hace tiempo, y la mayoría de las pruebas en esta área también se realizan allí.

Los cuadros se dividen de forma incorrecta 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 de desbordamiento son monolíticos, ya que no tiene sentido para los usuarios desplazarse en una región no rectangular. Las imágenes y los cuadros de línea 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 cortará de forma brutal (lo que genera un comportamiento muy "interesante" cuando se intenta desplazar el contenedor desplazable):

En lugar de permitir que se desborde la primera columna (como lo hace con la fragmentación de bloques de LayoutNG), hace lo siguiente:

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 admite evitar 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 evitación de pausas. Chromium es el primer motor de navegador que admite por completo esta combinación de funciones.

Cómo funciona la fragmentación de NG

El motor de diseño NG generalmente presenta el documento recorriendo primero la profundidad del árbol de cuadros de CSS. Cuando se presentan todos los elementos subordinados de un nodo, se puede completar el diseño de ese nodo. 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, se crea un árbol de fragmentos para todo el documento. Sin embargo, esto es una simplificación excesiva: por ejemplo, los elementos posicionados fuera del flujo tendrán que subir desde donde existen en el árbol del DOM hasta su bloque contenedor antes de que se puedan distribuir. Ignoraré este detalle avanzado por motivos de simplicidad.

Junto con el cuadro CSS, LayoutNG proporciona un espacio de restricción a un algoritmo de diseño. Esto le proporciona al algoritmo información como el espacio disponible para el diseño, si se establece un nuevo contexto de formato y los resultados de la reducción de márgenes intermedios del contenido anterior. El espacio de restricciones también conoce el tamaño de bloque del fragmentador y el desplazamiento del bloque actual en él. Esto indica dónde se debe realizar el ajuste de línea.

Cuando se incluye la fragmentación de bloques, el diseño de los subordinados debe detenerse en una pausa. Entre los motivos para que se produzcan, se incluyen quedarse sin espacio en la página o la columna, o bien 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 al árbol de nuevo, reanudando desde donde lo dejamos antes de la pausa.

La estructura de datos fundamental 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 está asociado con un nodo y forma un árbol de NGBlockBreakToken, de modo que se represente cada nodo que se deba reanudar. Se adjunta un NGBlockBreakToken al NGPhysicalBoxFragment generado para los nodos que se rompen dentro. Los tokens de pausa se propagan a los elementos superiores y forman un árbol de tokens de pausa. Si necesitamos hacer una pausa antes de un nodo (en lugar de dentro de él), no se producirá ningún fragmento, pero el nodo superior aún debe crear un token de pausa "break-before" para el nodo, de modo que podamos comenzar a diseñarlo cuando lleguemos a la misma posición en el árbol de nodos en el siguiente fragmentador.

Las pausas se insertan cuando se acaba el espacio del fragmentador (una pausa no forzada) o cuando se solicita una pausa forzada.

En la especificación, hay reglas para las pausas óptimas no forzadas, y no siempre es correcto insertar una pausa exactamente donde se acaba el espacio. 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 especificaciones de pausas no forzadas, debemos hacer un seguimiento de los posibles puntos de interrupción adecuados. Este registro significa que podemos volver y usar el último punto de interrupción posible que se encontró, si se nos acaba el espacio en un punto en el que incumplimos las solicitudes de evitación de pausas (por ejemplo, break-before:avoid o orphans:7). Cada punto de interrupción posible recibe una puntuación, que va desde "solo haz esto como último recurso" hasta "lugar perfecto para hacer una pausa", con algunos valores intermedios. Si una ubicación de pausa obtiene una puntuación "perfecta", significa que no se incumplirá ninguna regla de pausa si hacemos la pausa allí (y si obtenemos esta puntuación exactamente en el punto en el que se acaba el espacio, no es necesario buscar algo mejor). Si la puntuación es "último recurso", el punto de interrupción ni siquiera es válido, pero es posible que aún lo hagamos si no encontramos nada mejor para evitar el desbordamiento del fragmentador.

Por lo general, los puntos de interrupción válidos solo se producen entre elementos hermanos (cajas de línea 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 es necesario que los analicemos aquí). Hay una pausa válida, por ejemplo, antes de un bloque hermano con break-before:avoid, pero está entre "perfecto" y "último recurso".

Durante el diseño, hacemos un seguimiento del mejor punto de interrupción encontrado hasta el momento en una estructura llamada NGEarlyBreak. Una pausa 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 pasamos cuando nos quedamos sin espacio. Por ejemplo:

En este caso, se acaba el espacio justo antes de #second, pero tiene "break-before:avoid", que obtiene una puntuación de ubicación de pausa de "violating break avoid". En ese punto, tenemos una cadena de NGEarlyBreak de "inside #outer > inside #middle > inside #inner > before "line 3"', con "perfect", por lo que preferimos hacer una pausa allí. Por lo tanto, debemos volver y volver a ejecutar el diseño desde el principio de #outer (y esta vez pasar el NGEarlyBreak que encontramos) para que podamos hacer una pausa antes de la "línea 3" en #inner. (Hacemos una pausa antes de la "línea 3" para que las 4 líneas restantes terminen en el siguiente fragmentador y para respetar widows:4).

El algoritmo está diseñado para detenerse siempre en el mejor punto de interrupción posible, como se define en las especificaciones, descartando las reglas en el orden correcto, si no se pueden satisfacer todas. Ten en cuenta que solo tenemos que volver a aplicar el diseño una vez como máximo por flujo de fragmentación. Cuando llegamos al segundo pase de diseño, la mejor ubicación de la pausa ya se pasó a los algoritmos de diseño, que es la ubicación de la pausa que se descubrió en el primer pase de diseño y se proporcionó como parte del resultado del diseño en esa ronda. En el segundo pase de diseño, no generamos el diseño hasta que se agota el espacio. De hecho, no se espera que se agote el espacio (eso sería un error), ya que se nos proporcionó un lugar muy bueno (bueno, lo más bueno que había disponible) para insertar una pausa anticipada, para evitar infringir las reglas de pausas innecesariamente. Así que solo diseñamos hasta ese punto y hacemos una pausa.

En ese sentido, a veces debemos incumplir algunas de las solicitudes de evitación de pausas si eso ayuda a evitar el desbordamiento del fragmentador. Por ejemplo:

Aquí, se acaba el espacio justo antes de #second, pero tiene "break-before:avoid". Eso se traduce como "evitar la pausa de incumplimiento", al igual que en el último ejemplo. También tenemos un NGEarlyBreak con "violating orphans and widows" (dentro de #first > antes de "line 2"), que aún no es perfecto, pero es mejor que "violating break avoid". Por lo tanto, haremos una pausa antes de la “línea 2”, lo que incumple la solicitud de huérfanos o viudas. La especificación trata sobre esto en 4.4. Pausas no forzadas, en las que se define qué reglas de pausa se ignoran primero si no tenemos suficientes puntos de interrupción para evitar el desbordamiento del fragmentador.

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 con la evitación de pausas (break-before:avoid, por ejemplo), ya que esta es una parte fundamental del motor de fragmentación, por lo que debía estar allí desde el principio, ya que agregarla más tarde implicaría otra reescritura.

Ahora que se terminó la fragmentación de bloques de LayoutNG, podemos comenzar a trabajar en la adición de nuevas funciones, como la compatibilidad con tamaños de página mixtos cuando se imprime, cuadros de margen @page cuando se imprime, box-decoration-break:clone y mucho más. 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