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

Se completó la fragmentación de bloques en LayoutNG. Aprende cómo funciona y por qué es importante en este artículo.

Morten Stenshorne
Morten Stenshorne

Soy Morten Stenshorne, ingeniero de diseño del equipo de renderización de Blink en Google. Participé en el desarrollo de motores de navegadores desde principios de la década de 2000 y me divertí mucho, como ayudar a que la prueba acid2 pasara en el motor de Presto (Opera 12 y versiones anteriores) y aplicar ingeniería inversa en otros navegadores para corregir el diseño de la tabla en Presto. También pasé más de esos años de lo que me gustaría admitir en la fragmentación de bloques y, en particular, en el multicol en Presto, WebKit y Blink. Durante los últimos años en Google, me he enfocado principalmente en liderar el trabajo de agregar compatibilidad con la fragmentación de bloques a LayoutNG. Este es un análisis detallado de la implementación de la fragmentación de bloques, ya que puede ser la última vez que implemente la fragmentación de bloques. :)

¿Qué es la fragmentación de bloques?

La fragmentación de bloques consiste en dividir un cuadro de nivel de bloque de CSS (como una sección o un párrafo) en varios fragmentos cuando no encaja como un todo dentro de un contenedor de fragmentos llamado fragmentainer. Un fragmentainer no es un elemento, pero representa una columna en un diseño de varias columnas o una página en medios paginados. Para que se produzca la fragmentación, el contenido debe estar dentro de un contexto de fragmentación. Por lo general, un contexto de fragmentación se establece a través de un contenedor de varias columnas (el contenido se dividirá en columnas) o durante la impresión (el contenido se dividirá en páginas). Es posible que un párrafo largo con muchas líneas se deba dividir en varios fragmentos, de modo que las primeras líneas se coloquen en el primer fragmento y las líneas restantes se coloquen 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 fragmentainer, 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ínea (también conocida como “interrupción de línea”). Cualquier elemento intercalado que consta de más de una palabra (cualquier nodo de texto, cualquier elemento <a>, etc.) y permite 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 fragmentainer para columnas y páginas.

¿Qué es la fragmentación de bloques LayoutNG?

LayoutNGBlockFragmentation es una reescritura del motor de fragmentación de LayoutNG y, después de muchos años de trabajo, las primeras partes finalmente se enviaron en Chrome 102 a principios de este año. Esto solucionó problemas de larga data que no se podían solucionar en nuestro motor "heredado". En términos de estructuras de datos, reemplaza varias estructuras de datos anteriores a NG por fragmentos de NG representados directamente en el árbol de fragmentos.

Por ejemplo, ahora admitimos el valor "avoid" para las propiedades de CSS "break-before" y "break-after", lo que permite a los autores evitar las pausas justo después de un encabezado. En general, no se ve bien si lo último que se coloca en una página es un encabezado, mientras que el contenido de la sección comienza en la página siguiente. Es mejor dividir antes del encabezado. Consulta la siguiente imagen para ver un ejemplo.

El primer ejemplo muestra un encabezado en la parte inferior de la página y el segundo lo muestra en la parte superior de la página siguiente con su contenido asociado.

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

Se completó la fragmentación de bloques en LayoutNG.

Al momento de escribir esto, completamos la compatibilidad con la fragmentación de bloques completo 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 incluye en Chrome 102. La fragmentación de cuadrícula y Flex se envió en Chrome 103, y la fragmentación de tablas, en Chrome 106. Por último, se incluye la impresión en Chrome 108. La fragmentación de bloques fue el último atributo que dependía del motor heredado para realizar el diseño. Esto significa que, a partir de Chrome 108, el motor heredado ya no se usará para realizar diseños.

Además de diseñar el contenido, las estructuras de datos de LayoutNG admiten la pintura y la prueba de posicionamiento, pero aún nos basamos en algunas estructuras de datos heredadas para las APIs de JavaScript que leen información de diseño, como offsetLeft y offsetTop.

Si diseñas todo con NG, podrás implementar y enviar funciones nuevas que solo tengan implementaciones de LayoutNG (y ninguna equivalente de motor heredado), como consultas de contenedores de CSS, posicionamiento de anclas, MathML y diseño personalizado (Houdini). Para las consultas sobre contenedores, lo enviamos con un poco de anticipación y advertíamos a los desarrolladores que la impresión aún no era compatible.

Lanzamos la primera parte de LayoutNG en 2019, que consistía en un diseño regular del contenedor de bloques, el diseño intercalado, los números de punto flotante y el posicionamiento fuera de flujo, pero sin compatibilidad con Flex, Grid ni tablas, ni compatibilidad 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 involucrara la fragmentación de bloques. Eso era cierto 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 en la implementación es un proceso muy delicado.

Además, lo creas o no, a mediados de 2019 ya se había implementado la mayor parte de la funcionalidad principal del diseño de fragmentación de bloques de LayoutNG (detrás de una bandera). ¿Por qué tardó tanto en enviarse? La respuesta breve es la siguiente: 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. Para ver la respuesta larga, consulta los siguientes detalles.

Interacción con motores heredados

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

Detección y manejo de resguardo de motores heredados

Tuvimos que recurrir al motor de diseño heredado cuando había contenido en su interior que aún no podía controlarse mediante la fragmentación de bloques LayoutNG. En el momento de la fragmentación de bloques LayoutNG del núcleo de envío (segundo trimestre de 2022), se incluían flexibilidad, cuadrícula, tablas y todo lo que se imprima. Esto fue particularmente complicado, ya que necesitábamos detectar la necesidad de un resguardo heredado antes de crear objetos en el árbol de diseño. Por ejemplo, necesitábamos detectar si había un contenedor principal de varias columnas y saber qué nodos del DOM se convertirían en contexto de formato o no. Es un problema de pollo y huevo que no tiene una solución perfecta, pero siempre que su único comportamiento erróneo sean falsos positivos (recurrir a lo heredado cuando en realidad no hay necesidad), está bien, porque cualquier error en ese comportamiento de diseño es uno que Chromium ya tiene, no uno nuevo.

Recorrido por los árboles que pintan previamente

La pintura previa es algo que hacemos después del diseño, pero antes de pintar. El desafío principal es que aún debemos recorrer el árbol de objetos de diseño, pero ahora tenemos fragmentos de NG, ¿cómo lo hacemos? Recorrimos los árboles de fragmentos de NG y de objeto de diseño 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. 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-secundario entre un bloque contenedor y los subordinados del DOM que tienen ese fragmento como el bloque contenedor. Por ejemplo, en el árbol de fragmentos, un fragmento generado por un elemento de posicionamiento absoluto es un elemento secundario directo del fragmento de bloque que lo contiene, incluso si hay otros nodos en la cadena principal entre el subordinado posicionado de salida de flujo y el bloque que lo contiene.

Se vuelve aún más complicado cuando hay un elemento posicionado de salida de flujo dentro de la fragmentación, porque los fragmentos de ese tipo se convierten en elementos secundarios directos del fragmentainer (y no en un elemento secundario de lo que CSS cree que es el bloque contenedor). Lamentablemente, este fue un problema que tuvo que resolverse para que coexistiera con el motor heredado sin demasiados inconvenientes. En el futuro, deberíamos poder simplificar gran parte de este código, porque 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 de la Web, en realidad no tiene un concepto de fragmentación, incluso si técnicamente la fragmentación existía en esa época también (para admitir la impresión). El soporte de fragmentación era simplemente algo que se colocó en la parte superior (impresión) o se adaptó (varias columnas).

Cuando distribuye el 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 lo más alta posible para incluir su contenido. Esta franja larga no se renderiza en la página; considérala como una página virtual que luego se reorganiza para su visualización final. Conceptualmente, es similar a imprimir un artículo completo de periódico en papel en una columna y, luego, usar tijeras para cortarlo en varios elementos como un segundo paso. (En el pasado, algunos periódicos en realidad utilizaban técnicas similares a esta).

El motor heredado realiza un seguimiento del límite de una página o columna imaginaria en la franja. Esto te permite desplazar el contenido que no entra más allá del límite hacia la siguiente página o columna. Por ejemplo, si solo la mitad superior de una línea cabe en lo que el motor cree que es la página actual, insertará un "pase 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 "corte con tijeras y la colocación") se lleva a cabo después del diseño durante la pintura previa y la pintura, recortando las columnas y recortando las columnas. Esto hizo que algunas cosas fueran esencialmente imposibles, como aplicar transformaciones y el 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 se admite la fragmentación de cuadrícula o de Flex en absoluto.

A continuación, se muestra 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 específica, de modo que solo caben cuatro líneas, pero hay un exceso de espacio en la parte inferior):

La representación interna como una columna con pasos de paginación en donde se rompe 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 manera incorrecta, y las sombras de cuadros se recortan en los bordes de las columnas.

A continuación, se muestra un ejemplo simple con text-shadow:

.

El motor heredado no controla esto bien:

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 comprende la fragmentación.

Debería tener el siguiente aspecto (y se ve así con NG):

Dos columnas de texto con las sombras que se muestran correctamente

A continuación, hagamos que sea un poco más complicado, con las transformaciones y las sombras de cuadros. Observa cómo en el motor heredado hay recortes incorrectos y sangrado de columna. Esto se debe a que las transformaciones se deben aplicar, según la especificación, como un efecto posterior al diseño y a la fragmentación. Con la fragmentación de LayoutNG, ambas funcionan correctamente. Esto aumenta la interoperabilidad con Firefox, que ha tenido una buena compatibilidad de fragmentación durante algún tiempo y la mayoría de las pruebas en esta área también están aprobadas allí.

.

Las casillas están divididas 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 de desbordamiento son monolíticos, ya que 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 dentro de una columna, el motor heredado lo dividirá de forma bruta (lo que genera un comportamiento muy "interesante" cuando se intenta desplazar el contenedor desplazable):

En lugar de permitir que sobrepase la primera columna (como lo hace con la fragmentación de bloques 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 DIV. Sin embargo, solo tiene compatibilidad limitada para encontrar saltos no forzados óptimos. Sí admite break-inside:avoid y huérfanos y viudas, pero no se admite evitar las pausas entre los 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 es de 100 px de alto y la altura de línea es de 20 px), por lo que todos los elementos #firstchild podrían caber en la primera columna. Sin embargo, el elemento #secondchild del mismo nivel tiene "break-before:avoid", lo que significa que el contenido no desea que ocurra 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 prevención de interrupciones. Chromium es el primer motor del navegador compatible por completo con esta combinación de funciones.

Cómo funciona la fragmentación de NG

El motor de diseño NG generalmente distribuye el documento desviando 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 produce un NGPhysicalFragment y se muestra el algoritmo de diseño de nivel 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, 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 el bloque que los contiene antes de que se puedan distribuir. Ignoro este detalle avanzado para simplificar.

Junto con el cuadro CSS en sí, 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 el margen intermedio que contrae los resultados del contenido anterior. El espacio de restricción también conoce el tamaño de bloque distribuido del fragmentainer y el desplazamiento del bloque actual hacia él. Esto indica dónde realizar el ajuste de línea.

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

La estructura fundamental de datos 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 fragmentainer. Un NGBlockBreakToken se asocia con un nodo y forma un árbol NGBlockBreakToken, de modo que se representa cada nodo que debe reanudarse. Se adjunta un NGBlockBreakToken al NGPhysicalBoxFragment generado para los nodos que se rompen en su interior. Los tokens de pausa se propagan a los elementos superiores y forman un árbol de tokens de pausa. Si necesitamos realizar la separación antes de un nodo (en lugar de dentro de este), no se producirá ningún fragmento, pero el nodo superior debe crear un token de pausa de “salida previa” 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 próximo fragmentainer.

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

Hay reglas en la especificación para las pausas óptimas no forzadas, y el simple hecho de insertar una pausa exactamente en la que 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. Por lo tanto, durante el diseño, para implementar correctamente la sección de especificación de roturas no forzadas, debemos realizar un seguimiento de los posibles puntos de interrupción. Este registro significa que podemos volver y usar el último punto de interrupción posible que se haya encontrado si nos quedamos sin espacio en un punto en el que infringiríamos las solicitudes de prevención de rupturas (por ejemplo, break-before:avoid o orphans:7). A cada punto de interrupción posible se le asigna una puntuación, que va desde "solo hacer esto como último recurso" hasta "lugar perfecto para romper", con algunos valores intermedios. Si una ubicación de pausa puntúa como "perfecta", significa que no se infringirán reglas de incumplimiento si se sale de esta (y si obtenemos esta puntuación exactamente cuando nos quedamos sin espacio, no hay necesidad de mirar hacia atrás para nada mejor). Si la puntuación es “last-resort”, el punto de interrupción ni siquiera es válido, pero aún podemos interrumpirlo si no encontramos nada mejor para evitar el desbordamiento de fragmentainer.

Por lo general, los puntos de interrupción válidos solo ocurren entre elementos del mismo nivel (cuadros 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 analizarlos aquí). Hay un punto de interrupción válido, por ejemplo, antes de un bloque del mismo nivel conbreak-before:avoid, pero está entre "perfecto" y "last-resort".

Durante el diseño, realizamos un seguimiento del mejor punto de interrupción que se encontró hasta ahora en una estructura llamada NGEarlyBreak. Un salto anticipado 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 dentro de algo que caminamos anteriormente en el momento en que nos quedamos sin espacio. Por ejemplo:

En este caso, nos quedamos sin espacio justo antes de #second, pero tiene "break-before:avoid", lo que obtiene una puntuación de ubicación de pausa de "incumplimiento de la prevención de pausa". En ese punto, tenemos una cadena de NGEarlyBreak de "dentro de #outer > dentro de #middle > dentro de #inner > antes de "line 3"', con "perfect", por lo que preferimos romperla allí. Por lo tanto, debemos devolver y volver a ejecutar el diseño desde el principio de #outer (y esta vez pasar el NGEarlyBreak que encontramos) para que podamos romper antes de la "line 3" en #inner. (Se divide antes de la "línea 3", de modo que las 4 líneas restantes terminen en el siguiente fragmentainer y con el fin de respetar widows:4).

El algoritmo está diseñado para interrumpir siempre en el mejor punto de interrupción posible, como se define en la spec, mediante la omisión de las reglas en el orden correcto en caso de que no se puedan cumplir todas. Ten en cuenta que solo tenemos que volver a diseñar el diseño una vez por flujo de fragmentación. Para cuando estamos en el segundo pase de diseño, la mejor ubicación de pausa ya se 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 del diseño en esa ronda. En el segundo pase de diseño, no diseñaremos hasta que nos quedemos sin espacio; de hecho, no se espera que nos quedemos sin espacio (de hecho, sería un error), porque se nos proporcionó un lugar superdulce (tan útil como existía) para insertar una pausa anticipada y evitar infringir cualquier regla de infracción innecesariamente. Así que llegamos a ese punto y dividimos.

En ese sentido, a veces necesitamos infringir algunas de las solicitudes de prevención de fallas, si eso ayuda a evitar el desbordamiento de fragmentainer. Ejemplo:

Aquí, nos quedamos sin espacio justo antes de #second, pero tiene "break-before:avoid". Eso se traduce como “incumplimiento de la prevención de pausas”, tal como en el último ejemplo. También tenemos un NGEarlyBreak con "incumplimiento de huérfanos y viudas" (dentro de #first > antes de "línea 2"), que aún no es perfecto, pero es mejor que "incumplimiento de la ruptura evita". Así que interrumpiremos antes de la "línea 2", incumpliendo la solicitud de huérfanos o viudas. La especificación se ocupa de esto en la sección 4.4. Unforced Breaks, donde define qué reglas de incumplimiento se ignoran primero si no tenemos suficientes puntos de interrupción para evitar el desbordamiento de fragmentainer

Resumen

El objetivo funcional principal del proyecto de fragmentación de bloques LayoutNG era proporcionar implementación de soporte de la arquitectura 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 aquí es una mejor compatibilidad con la prevención de rupturas (por ejemplo, break-before:avoid), porque esta es una parte central del motor de fragmentación, por lo que tenía que estar allí desde el principio, ya que agregarla más tarde significarí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 tamaños de página mixtos cuando se imprimen, cuadros de margen de @page para la impresión, 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 mucho menores con el tiempo.

¡Gracias por leer esta información!

Agradecimientos