Detalhes de renderização de RenderNG: fragmentação de blocos do LayoutNG

Morten Stenshorne
Morten Stenshorne

A fragmentação de blocos divide uma caixa de nível de bloco do CSS (como uma seção ou parágrafo) em vários fragmentos quando ela não se encaixa como um todo dentro de um contêiner de fragmentos, chamado de fragmentador. Um fragmentainer não é um elemento, mas representa uma coluna em um layout de várias colunas ou uma página em mídia paginada.

Para que a fragmentação aconteça, o conteúdo precisa estar dentro de um contexto de fragmentação. O contexto de fragmentação geralmente é estabelecido por um contêiner de várias colunas (o conteúdo é dividido em colunas) ou ao imprimir (o conteúdo é dividido em páginas). Um parágrafo longo com muitas linhas pode precisar ser dividido em vários fragmentos para que as primeiras linhas sejam colocadas no primeiro fragmento, e as restantes, em fragmentos subsequentes.

Um parágrafo de texto dividido em duas colunas.
Neste exemplo, um parágrafo foi dividido em duas colunas usando o layout de várias colunas. Cada coluna é um fragmento que representa um fragmento do fluxo.

A fragmentação de blocos é análoga a outro tipo conhecido de fragmentação: a fragmentação de linha, também conhecida como "quebra de linha". Qualquer elemento in-line que consista em mais de uma palavra (qualquer nó de texto, qualquer elemento <a> e assim por diante) e que permita quebras de linha pode ser dividido em vários fragmentos. Cada fragmento é colocado em uma caixa de linha diferente. Uma caixa de linha é a fragmentação in-line equivalente a um fragmentador para colunas e páginas.

Fragmentação de blocos do LayoutNG

O LayoutNGBlockFragmentation é uma regravação do mecanismo de fragmentação do LayoutNG, inicialmente enviado no Chrome 102. Em termos de estruturas de dados, ele substituiu várias estruturas de dados pré-NG por fragmentos NG representados diretamente na árvore de fragmentos.

Por exemplo, agora oferecemos suporte ao valor "avoid" para as propriedades CSS "break-before" e "break-after", o que permite que os autores evitem quebras logo após um cabeçalho. Muitas vezes, parece estranho quando a última coisa em uma página é um cabeçalho, enquanto o conteúdo da seção começa na próxima página. É melhor quebrar antes do cabeçalho.

Exemplo de alinhamento de cabeçalho.
Figura 1. O primeiro exemplo mostra um cabeçalho na parte de baixo da página, e o segundo mostra o cabeçalho na parte de cima da página seguinte, com o conteúdo associado.

O Chrome também é compatível com estouro de fragmentação. Assim, o conteúdo monolítico (que é considerado inviolável) não é dividido em várias colunas e os efeitos de pintura, como sombras e transformações, são aplicados corretamente.

A fragmentação de blocos no LayoutNG foi concluída

Fragmentação de núcleo (contêiner em blocos, incluindo layout de linha, pontos flutuantes e posicionamento fora do fluxo) enviada no Chrome 102. A fragmentação de grade e flexibilidade foi lançada no Chrome 103, e a fragmentação de tabelas foi lançada no Chrome 106. Por fim, o recurso de impressão incluído no Chrome 108. A fragmentação de blocos era o último recurso que dependia do mecanismo legado para executar o layout.

A partir do Chrome 108, o mecanismo legado não é mais usado para executar o layout.

Além disso, as estruturas de dados LayoutNG oferecem suporte a pintura e testes de hit, mas dependemos de algumas estruturas de dados legadas para APIs JavaScript que leem informações de layout, como offsetLeft e offsetTop.

A disposição de tudo com o NG possibilita a implementação e o envio de novos recursos que têm apenas implementações do LayoutNG (e não há contrapartida de mecanismo legado), como consultas de contêiner CSS, posicionamento de âncoras, MathML e layout personalizado (Houdini). Para consultas de contêiner, nós a enviamos com alguma antecedência, com um aviso aos desenvolvedores de que ainda não havia suporte para impressão.

Enviamos a primeira parte do LayoutNG em 2019, que consistia em um layout regular de contêiner de blocos, layout em linha, flutuantes e posicionamento fora do fluxo, mas sem suporte para flexibilidade, grade ou tabelas, e nenhum suporte à fragmentação de blocos. Voltaríamos a usar o mecanismo de layout legado para tabelas flexíveis, de grade, além de tudo que envolvia fragmentação de blocos. Isso foi verdade até mesmo para elementos em bloco, inline, flutuantes e fora de fluxo em conteúdo fragmentado. Como você pode ver, atualizar um mecanismo de layout tão complexo no local é uma dança muito delicada.

Além disso, em meados de 2019, a maioria das funcionalidades principais do layout de fragmentação de blocos do LayoutNG já tinha sido implementada (por trás de uma sinalização). Por que demorou tanto para o envio? A resposta curta é: a fragmentação precisa coexistir corretamente com várias partes legadas do sistema, que não podem ser removidas ou atualizadas até que todas as dependências sejam atualizadas.

Interação do mecanismo legado

As estruturas de dados legadas ainda são responsáveis pelas APIs JavaScript que leem as informações do layout. Por isso, precisamos gravar os dados no mecanismo legado de maneira que ele entenda. Isso inclui a atualização correta das estruturas de dados de várias colunas legadas, como LayoutMultiColumnFlowThread.

Detecção e tratamento de substitutos de mecanismos legados

Tivemos que voltar ao mecanismo de layout legado quando havia conteúdo dentro que ainda não podia ser tratado pela fragmentação de bloco do LayoutNG. No momento em que enviamos a fragmentação de blocos do núcleo LayoutNG, que incluía flexibilidade, grade, tabelas e tudo o que foi impresso. Isso foi particularmente complicado, porque precisávamos detectar a necessidade de substituto legado antes de criar objetos na árvore de layout. Por exemplo, precisávamos detectar antes de sabermos se havia um ancestral de contêiner de várias colunas e antes de sabermos quais nós DOM se tornariam um contexto de formatação ou não. É um problema complexo que não tem uma solução perfeita, mas, desde que o único erro de comportamento seja falsos positivos (substituto do legado quando realmente não há necessidade), tudo bem, porque os bugs no comportamento do layout são os que o Chromium já tem, não os novos.

Caminhada pela árvore antes da pintura

A pré-pintura é algo que fazemos depois do layout, mas antes da pintura. O principal desafio é que ainda precisamos percorrer a árvore de objetos de layout, mas temos fragmentos NG agora. Então, como lidar com isso? Percorremos o objeto de layout e as árvores de fragmentos NG ao mesmo tempo! Isso é bastante complicado, porque o mapeamento entre as duas árvores não é trivial.

Embora a estrutura da árvore de objetos de layout se assemelha à da árvore DOM, a árvore de fragmentos é uma saída do layout, não uma entrada para ela. Além de realmente refletir o efeito de qualquer fragmentação, incluindo a fragmentação in-line (fragmentos de linha) e a fragmentação de blocos (fragmentos de colunas ou páginas), a árvore de fragmentos também tem uma relação direta pai-filho entre um bloco que contém e os descendentes do DOM que têm esse fragmento como bloco. Por exemplo, na árvore de fragmentos, um fragmento gerado por um elemento posicionado de forma absoluta é filho direto do fragmento de bloco que o contém, mesmo que haja outros nós na cadeia de ancestralidade entre o descendente posicionado fora de fluxo e o bloco que o contém.

Isso pode ser ainda mais complicado quando há um elemento posicionado fora de fluxo dentro da fragmentação, porque os fragmentos que saem do fluxo se tornam filhos diretos do fragmentainer (e não um filho do que o CSS acredita ser o bloco que o contém). Esse era um problema que precisava ser resolvido para coexistir com o mecanismo legado. No futuro, poderemos simplificar esse código, porque o LayoutNG foi projetado para oferecer suporte de maneira flexível a todos os modos de layout modernos.

Os problemas com o mecanismo de fragmentação legado

O mecanismo legado, projetado em uma era anterior da Web, não tem, de fato, um conceito de fragmentação, mesmo que ela já existisse tecnicamente naquela época também (para dar suporte à impressão). O suporte à fragmentação era algo que era fixado na parte superior (impressão) ou reajustado (várias colunas).

Ao distribuir conteúdo fragmentado, o mecanismo legado posiciona tudo em uma faixa alta cuja largura é o tamanho em linha de uma coluna ou página e a altura é a mesma que precisa para conter o conteúdo. Essa faixa alta não é renderizada para a página. Pense nela como uma renderização para uma página virtual que é reorganizada para exibição final. Conceitualmente, ela é semelhante a imprimir um artigo inteiro de jornal em uma coluna e usar uma tesoura para dividi-lo em várias colunas. Antigamente, alguns jornais usavam técnicas semelhantes a essa.

O mecanismo herdado acompanha uma página ou limite de coluna imaginário na faixa. Isso permite que ele desloque o conteúdo que não cabe além do limite para a próxima página ou coluna. Por exemplo, se apenas a metade superior de uma linha couber no que o mecanismo acha que é a página atual, ele vai inserir um "brinque de paginação" para empurrá-lo para a posição em que o mecanismo presume que está o topo da próxima página. Então, a maior parte do trabalho de fragmentação real (o "corte com tesoura e posicionamento") ocorre após o layout durante a pré-pintura e a pintura, cortando as colunas ou tiras de conteúdo, recortando as colunas ou tiras de conteúdo. Isso tornou algumas coisas essencialmente impossíveis, como a aplicação de transformações e posicionamento relativo após a fragmentação, que é o que a especificação exige. Além disso, embora haja suporte à fragmentação de tabelas no mecanismo legado, não há suporte à fragmentação de grade ou flexibilidade.

Esta é uma ilustração de como um layout de três colunas é representado internamente no mecanismo legado, antes de usar tesouras, posicionamento e cola (temos uma altura especificada, para que caibam apenas quatro linhas, mas há um pouco de espaço na parte de baixo):

A representação interna como uma coluna com traços de paginação em que o conteúdo é corrompido e a representação na tela como três colunas

Como o mecanismo de layout legado não fragmenta o conteúdo durante o layout, há muitos artefatos estranhos, como posicionamento incorreto e aplicação de transformações, e sombras de caixas sendo recortadas nas bordas das colunas.

Aqui está um exemplo com text-shadow:

O mecanismo legado não lida muito bem com isso:

Sombras de texto recortadas colocadas na segunda coluna.

Você consegue ver como a sombra do texto da linha na primeira coluna é recortada e colocada no topo da segunda coluna? Isso porque o mecanismo de layout legado não entende fragmentação.

Ele terá a seguinte aparência:

Duas colunas de texto com as sombras aparecendo corretamente.

Em seguida, vamos complicar isso um pouco mais, com transformações e box-shadow. Observe como há recortes e sangria de coluna no mecanismo legado. Isso porque, por especificação, as transformações devem ser aplicadas como um efeito pós-layout e pós-fragmentação. Com a fragmentação do LayoutNG, os dois funcionam corretamente. Isso aumenta a interoperabilidade com o Firefox, que, por algum tempo, já ofereceu um bom suporte à fragmentação, sendo que a maioria dos testes nessa área também passou por lá.

As caixas estão quebradas incorretamente em duas colunas.

O mecanismo legado também tem problemas com conteúdo monolítico muito alto. O conteúdo é monolítico quando não pode ser dividido em vários fragmentos. Os elementos com rolagem flutuante são monolíticos, porque não faz sentido para os usuários rolarem em uma região não retangular. Caixas de linha e imagens são outros exemplos de conteúdo monolítico. Veja um exemplo:

(link em inglês)

Se a parte do conteúdo monolítico for muito alta para caber em uma coluna, o mecanismo legado o cortará brutalmente, levando a um comportamento muito "interessante" ao tentar rolar o contêiner rolável:

Em vez de permitir que ela estoure a primeira coluna (como acontece com a fragmentação de blocos do LayoutNG):

ALT_TEXT_HERE

O mecanismo legado é compatível com quebras forçadas. Por exemplo, <div style="break-before:page;"> insere uma quebra de página antes do DIV. No entanto, há suporte limitado para encontrar quebras não forçadas ideais. Ele não oferece suporte a break-inside:avoid e órfãos e viúvas, mas não há como evitar intervalos entre blocos, se solicitado via break-before:avoid, por exemplo. Por exemplo,

Texto dividido em duas colunas.

Aqui, o elemento #multicol tem espaço para 5 linhas em cada coluna (porque tem 100 px de altura e a altura da linha é 20 px), então todo o #firstchild pode caber na primeira coluna. No entanto, a #secondchild tem a função break-before:avoid, o que significa que o conteúdo quer que não haja uma pausa entre eles. Como o valor de widows é 2, precisamos enviar duas linhas de #firstchild para a segunda coluna para atender a todas as solicitações de prevenção de intervalos. O Chromium é o primeiro mecanismo de navegador que oferece suporte total a essa combinação de recursos.

Como funciona a fragmentação de NG

O mecanismo de layout NG geralmente apresenta o documento atravessando a profundidade da árvore de caixas CSS em primeiro lugar. Quando todos os descendentes de um nó estão dispostos, o layout desse nó pode ser concluído, produzindo um NGPhysicalFragment e retornando ao algoritmo de layout pai. Esse algoritmo adiciona o fragmento à lista de fragmentos filhos e, quando todos os filhos forem concluídos, ele vai gerar um fragmento para si mesmo com todos os fragmentos filhos dentro dele. Com esse método, ele cria uma árvore de fragmentos para o documento inteiro. No entanto, essa é uma simplificação excessiva: por exemplo, os elementos posicionados fora do fluxo terão que surgir de onde estão na árvore DOM para o bloco que os contém antes de serem dispostos. Para simplificar, estou ignorando esse detalhe avançado aqui.

Juntamente com a própria caixa CSS, o LayoutNG fornece um espaço de restrição para um algoritmo de layout. Isso fornece ao algoritmo informações como o espaço disponível para layout, se um novo contexto de formatação foi estabelecido e se um novo contexto de formatação foi estabelecido e resultados de recolhimento da margem intermediária do conteúdo anterior. O espaço de restrição também sabe o tamanho do bloco disposto do fragmento e o deslocamento do bloco atual nele. Isso indica onde quebrar.

Quando a fragmentação de blocos está envolvida, o layout dos descendentes precisa parar com uma pausa. Os motivos para isso incluem falta de espaço na página ou coluna ou uma quebra forçada. Em seguida, produzimos fragmentos para os nós visitados e retornamos até a raiz do contexto de fragmentação (o contêiner multicol ou, no caso de impressão, a raiz do documento). Depois, na raiz do contexto de fragmentação, preparamos uma nova fragmentação e descendemos à árvore novamente, retomando de onde paramos antes do intervalo.

A estrutura de dados crucial para fornecer os meios de retomar o layout após um intervalo é chamada de NGBlockBreakToken. Ele contém todas as informações necessárias para retomar o layout corretamente no próximo fragmento. Um NGBlockBreakToken é associado a um nó e forma uma árvore NGBlockBreakToken, de modo que cada nó que precisa ser retomado seja representado. Um NGBlockBreakToken é anexado ao NGPhysicalBoxFragment gerado para nós que são interrompidos dentro dele. Os tokens de intervalo são propagados para os pais, formando uma árvore de tokens de intervalo. Se for necessário quebrar antes de um nó (em vez de dentro dele), nenhum fragmento será produzido. No entanto, o nó pai ainda precisará criar um token de interrupção "break-before" para o nó, para que possamos começar a posicioná-lo quando chegarmos à mesma posição na árvore de nós no próximo fragmento.

As quebras são inseridas quando ficamos sem espaço fragmentado (uma interrupção não forçada) ou quando uma quebra forçada é solicitada.

Existem regras na especificação para intervalos não forçados ideais, e inserir o intervalo exatamente onde ficamos sem espaço nem sempre é a coisa certa a fazer. Por exemplo, há várias propriedades CSS, como break-before, que influenciam a escolha do local do intervalo.

Durante o layout, para implementar corretamente a seção de especificação de intervalos não forçados, precisamos controlar possíveis pontos de interrupção. Esse registro significa que podemos voltar e usar o último melhor ponto de interrupção encontrado se ficarmos sem espaço em um ponto em que violaríamos solicitações para evitar quebras (por exemplo, break-before:avoid ou orphans:7). Cada ponto de interrupção possível recebe uma pontuação, que varia de "fazer isso apenas como último recurso" a "lugar perfeito para quebrar", com alguns valores intermediários. Se um local de quebra for classificado como "perfeito", isso significa que nenhuma regra de violação será violada se quebrarmos nela. Se recebermos essa pontuação exatamente no ponto em que ficarmos sem espaço, não haverá necessidade de procurar algo melhor. Se a pontuação for "last-resort", o ponto de interrupção nem mesmo é válido. No entanto, ainda é possível que ele seja interrompido se não encontrarmos nada melhor, para evitar o estouro de fragmentação.

Os pontos de interrupção válidos geralmente só ocorrem entre irmãos (caixas de linha ou blocos) e não, por exemplo, entre um pai e o primeiro filho. Os pontos de interrupção de classe C são uma exceção, mas não precisamos discutir isso aqui. um ponto de interrupção válido, por exemplo, antes de um irmão de bloco com break-before:avoid, mas está entre "perfeito" e "último recurso".

Durante o layout, acompanhamos o melhor ponto de interrupção encontrado até o momento em uma estrutura chamada NGEarlyBreak. Um intervalo antecipado é um possível ponto de interrupção antes ou dentro de um nó de bloco ou antes de uma linha (seja uma linha de contêiner de bloco ou uma linha flexível). Podemos formar uma cadeia ou caminho de objetos NGEarlyBreak, caso o melhor ponto de interrupção esteja em algum lugar dentro de algo pelo qual já passamos anteriormente quando ficamos sem espaço. Veja um exemplo:

Nesse caso, ficamos sem espaço antes de #second, mas há "break-before:avoid", que recebe uma pontuação de local de intervalo de "evitação de intervalo com violação". Nesse ponto, temos uma cadeia de NGEarlyBreak de "dentro de #outer > dentro de #middle > dentro de #inner > antes da "linha 3", com "perfeito", então preferimos quebrar lá. Portanto, precisamos retornar e executar novamente o layout do início de #outer (e, desta vez, passar o NGEarlyBreak que encontramos), para que possamos quebrar antes da "linha 3" em #inner. Quebramos antes da "linha 3", para que as quatro linhas restantes acabem no próximo fragmento e para respeitar widows:4.

O algoritmo foi projetado para sempre quebrar no melhor ponto de interrupção possível, conforme definido na especificação, soltando as regras na ordem correta, se nem todas elas puderem ser atendidas. Observe que só precisamos reorganizar o layout no máximo uma vez por fluxo de fragmentação. No momento da segunda transmissão de layout, o melhor local de intervalo já foi transmitido aos algoritmos de layout. Esse é o local do intervalo que foi descoberto na primeira transmissão de layout e fornecido como parte da saída do layout nessa rodada. Na segunda transmissão de layout, não fazemos o layout até ficar sem espaço. Na verdade, não esperamos que fique sem espaço, o que seria um erro, porque recebemos um local muito doce (bem, tão bom quanto houve disponível) para inserir uma pausa antecipada, para evitar violar regras de violação desnecessariamente. Então, acabamos de definir esse ponto e quebramos.

Às vezes, precisamos violar algumas das solicitações de evasão de intervalos, se isso ajudar a evitar o estouro de fragmentos. Exemplo:

(link em inglês)

Aqui, ficamos sem espaço logo antes de #second, mas há "break-before:avoid". Isso é traduzido como "evitar quebra de violação", como no último exemplo. Também temos um NGEarlyBreak com "violando órfãos e viúvas" (dentro de #first > antes da "linha 2"), o que ainda não é perfeito, mas é melhor do que "violando intervalos". Portanto, quebraremos antes da "linha 2", violando a solicitação de órfãos / viúvas. A especificação lida com isso na seção 4.4. Unforced Breaks, em que define quais regras de violação serão ignoradas primeiro se não tivermos pontos de interrupção suficientes para evitar o estouro de fragmentação.

Conclusão

O objetivo funcional do projeto de fragmentação de blocos do LayoutNG era fornecer implementação com suporte à arquitetura do LayoutNG de tudo o que o mecanismo legado aceita, e o mínimo possível, além das correções de bugs. A principal exceção é o melhor suporte à prevenção de interrupções (break-before:avoid, por exemplo), porque essa é uma parte central do mecanismo de fragmentação. Portanto, ela precisa estar lá desde o início, porque adicioná-lo mais tarde significaria outra reescrita.

Agora que a fragmentação de blocos do LayoutNG foi concluída, podemos começar a adicionar novas funcionalidades, como suporte a tamanhos de página mistos ao imprimir, caixas de margem @page ao imprimir, box-decoration-break:clone e muito mais. Assim como no LayoutNG em geral, esperamos que a taxa de bugs e a carga de manutenção do novo sistema sejam significativamente menores ao longo do tempo.

Agradecimentos