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 encabezados.
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). En el caso de las consultas de contenedores, lo enviamos con un poco de anticipación, con una advertencia para los desarrolladores de 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 bloques normal, un diseño intercalado, elementos flotantes y posicionamiento fuera del flujo, pero no era compatible con flex, cuadrícula ni tablas, y no admitía 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 del contenido fragmentado. Como puedes ver, actualizar un motor de diseño tan complejo sin cambiarlo es un baile 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 siguen a cargo de las APIs de JavaScript que leen información de diseño, por lo que debemos volver a escribir los datos en el motor heredado de una manera que lo comprenda. 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, se incluyeron flex, cuadrícula, tablas y todo lo que se imprime. 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 cualquier error en ese comportamiento de diseño es uno que Chromium ya tiene, no uno nuevo.

Recorrido por los árboles antes de la pintura

El proceso previo a la pintura es algo que hacemos después del diseño, pero antes de pintar. El principal desafío es que aún debemos 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, ya que la asignación 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 para él. 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 del fragmento de bloque que lo contiene, 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 de la Web, no tiene un concepto de fragmentación, incluso si la fragmentación también existía técnicamente en ese momento (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. Piensa en ella como una renderización en una página virtual que luego se reorganiza para la 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 partes 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 encaja en lo que el motor considera la página actual, insertará un “elemento de paginación” para empujarla hacia abajo hasta la posición en la que el motor supone que está la parte superior de la siguiente página. Luego, la mayor parte del trabajo de fragmentación real (“cortar con tijeras y colocar”) se realiza después del diseño durante la pintura previa y la pintura, ya que corta la tira alta de contenido en páginas o columnas (a través de recortes y traducciones de partes). Esto hizo que algunas cosas fueran esencialmente 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 hay cierta compatibilidad con la fragmentación de tablas en el motor heredado, no hay compatibilidad con la fragmentación de cuadrículas ni de elementos flexibles.

Esta es una ilustración de cómo se representa internamente un diseño de tres columnas en el motor heredado, antes de usar tijeras, colocación y pegamento (tenemos una altura especificada, de modo que solo caben cuatro líneas, pero hay un espacio adicional en la parte inferior):

La representación interna como una columna con puntales 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.

A continuación, se muestra 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 se recorta la sombra de texto de la línea de la primera columna 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 comprende 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 DIV. Sin embargo, solo tiene compatibilidad limitada para encontrar las pausas no forzadas óptimas. 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 tiene 100 px de altura 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 hermano #secondchild tiene break-before:avoid, lo que significa que el contenido desea que no haya 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

Por lo general, el motor de diseño de NG organiza el documento a través de la exploración del árbol de cuadros de CSS de profundidad primero. Cuando se organizan todos los elementos secundarios de un nodo, se puede completar el diseño de ese nodo. Para ello, se produce un NGPhysicalFragment y se vuelve 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 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 del colapso 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 involucra 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 regresamos hasta la raíz del contexto de fragmentación (el contenedor de varias columnas 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 descendemos al árbol nuevamente, reanudando desde donde nos quedamos 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 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 la última pausa posible que se encontró, si se agota 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 un punto de interrupción válido, 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 la mejor pausa esté en algún lugar dentro de algo que pasamos antes cuando se nos acabó el 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 diseñaremos hasta que se acabe el espacio. De hecho, no se espera que se acabe 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 pausa 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 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 de LayoutNG era proporcionar una implementación compatible con la arquitectura de LayoutNG de todo lo que admite el motor heredado y lo menos posible, además de las correcciones 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