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

A fragmentação de blocos no LayoutNG foi concluída. Saiba como ele funciona e por que é importante neste artigo.

Morten Stenshorne
Morten Stenshorne

Sou Morten Stenshorne, engenheiro de layout da equipe de renderização Blink do Google. Estou envolvido no desenvolvimento de mecanismos de navegador desde o início dos anos 2000 e me divirto muito, como ajudar a fazer o teste do acid2 passar no mecanismo Presto (Opera 12 e anteriores) e fazer engenharia reversa de outros navegadores para corrigir o layout da tabela no Presto. Além disso, passei mais desses anos do que gostaria de admitir na fragmentação de blocos e, em especial, no multicol no Presto, WebKit e Blink. Nos últimos anos no Google, meu foco foi liderar principalmente o trabalho de adicionar suporte à fragmentação de blocos ao LayoutNG. Confira mais detalhes sobre a implementação da fragmentação de blocos, já que essa pode muito bem ser a última vez que implementamos esse recurso. :)

O que é a fragmentação de blocos?

A fragmentação de blocos envolve dividir uma caixa de nível de bloco do CSS (como uma seção ou um parágrafo) em vários fragmentos quando ela não cabe como um todo dentro de um contêiner de fragmento chamado fragmentainer. Um fragmentador não é um elemento, mas representa uma coluna em um layout de várias colunas ou uma página em uma mídia paginada. Para que a fragmentação ocorra, o conteúdo precisa estar dentro de um contexto de fragmentação. Um contexto de fragmentação é estabelecido mais comumente por um contêiner de várias colunas (o conteúdo é dividido em colunas) ou durante a impressão (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 nos 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 fragmentador que representa um fragmento do fluxo fragmentado.

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 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 inline equivalente a um fragmentainer para colunas e páginas.

O que é a fragmentação de blocos do LayoutNG?

O LayoutNGBlockFragmentation é uma reescrita do mecanismo de fragmentação do LayoutNG e, após muitos anos de trabalho, as primeiras partes finalmente foram enviadas no Chrome 102 no início deste ano. Isso corrigiu problemas de longa data que não podiam ser corrigidos no mecanismo "legado". Em termos de estruturas de dados, ele substitui várias estruturas de dados pré-NG por fragmentos NG representados diretamente na árvore de fragmentos.

Por exemplo, agora aceitamos o valor "avoid" para as propriedades CSS "break-before" e "break-after", o que permite que os autores evitem quebras logo depois de um cabeçalho. Geralmente, uma página não tem uma boa aparência se a última coisa colocada em uma página é um cabeçalho, enquanto o conteúdo da seção começa na página seguinte. Em vez disso, é melhor dividir antes do cabeçalho. Consulte a figura abaixo.

O primeiro exemplo mostra um título na parte inferior da página, o segundo o mostra na parte superior da página seguinte com o conteúdo associado.

O Chrome 102 também oferece suporte ao estouro de fragmentação para que o conteúdo monolítico (que seja inquebrável) não seja dividido em várias colunas e efeitos de pintura, como sombras e transformações, sejam aplicados corretamente.

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

No momento em que este artigo foi escrito, concluímos o suporte completo à fragmentação de blocos no LayoutNG. Fragmentação principal (contêineres de blocos, incluindo layout de linhas, pontos flutuantes e posicionamento fora do fluxo) enviada no Chrome 102. A fragmentação de grade e de grade foi lançada no Chrome 103 e a fragmentação de tabelas foi lançada no Chrome 106. Por fim, o recurso impressão, enviado no Chrome 108. A fragmentação de blocos era o último recurso que dependia do mecanismo legado para executar o layout. Isso significa que, a partir do Chrome 108, o mecanismo legado não será mais usado para executar o layout.

Além de apresentar o conteúdo, as estruturas de dados do LayoutNG oferecem suporte a pintura e teste de hit, mas ainda contamos com 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 só têm implementações do LayoutNG (e nenhuma versão do mecanismo legado), como consultas de contêiner CSS, posicionamento de âncora, MathML e layout personalizado (Houdini). Para consultas de contêiner, enviamos um pouco de antecedência, com um aviso aos desenvolvedores de que a impressão ainda não era compatível.

Lançamos a primeira parte do LayoutNG em 2019, que consistia em layout regular de contêineres de blocos, layout em linha, flutuações e posicionamento fora do fluxo, mas nenhum suporte para flex, grade ou tabelas, e nenhum suporte à fragmentação de blocos. Podemos voltar a usar o mecanismo de layout legado para flexibilidade, grade, tabelas e qualquer outro que envolvia fragmentação de blocos. Isso foi válido até mesmo para elementos de 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, acredite ou não, 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. Para uma resposta longa, confira os detalhes a seguir.

Interação com o mecanismo legado

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

Detecção e tratamento de substitutos do mecanismo legado

Tivemos que voltar ao mecanismo de layout legado quando havia conteúdo dentro que ainda não podia ser processado pela fragmentação de blocos do LayoutNG. No lançamento da fragmentação de blocos do LayoutNG principal (março de 2022), isso incluía flex, grid, tabelas e tudo o que foi impresso. Isso foi especialmente complicado, porque precisávamos detectar a necessidade de um substituto legado antes de criar objetos na árvore de layout. Por exemplo, precisávamos detectar esses dados antes de saber se havia um ancestral de contêiner de várias colunas e antes de saber quais nós DOM se tornariam um contexto de formatação ou não. É um problema de ovos e frangos que não tem uma solução perfeita, mas desde que o único comportamento inadequado dele seja falsos positivos (substitutos para o legado quando não é necessário), tudo bem, porque todos os bugs no comportamento do layout são os que o Chromium já tem, não os novos.

Caminhada pela árvore de pré-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 do layout, mas temos fragmentos NG agora. Como lidar com isso? Analisamos 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 pareça muito com a da árvore DOM, a árvore de fragmentos é uma saída de layout, não uma entrada. Além de refletir o efeito de qualquer fragmentação, incluindo fragmentação inline (fragmentos de linha) e do bloco (fragmentos de coluna ou página), a árvore de fragmentos também tem uma relação direta de pai-filho entre um bloco contendo e os descendentes do DOM que têm esse fragmento como bloco contêiner. Por exemplo, na árvore de fragmentos, um fragmento gerado por um elemento posicionado absolutamente é um filho direto do fragmento de bloco que o contém, mesmo se houver outros nós na cadeia de ancestralidade entre o descendente posicionado fora de fluxo e o bloco que o contém.

Isso fica ainda mais complicado quando há um elemento posicionado fora de fluxo dentro da fragmentação, porque os fragmentos que estão fora de fluxo se tornam filhos diretos do fragmentainer (e não um filho do que o CSS acredita ser o bloco contêiner). Infelizmente, esse foi um problema que teve que ser resolvido para coexistir com o mecanismo legado sem muitos problemas. No futuro, poderemos simplificar muito desse código, porque o LayoutNG foi projetado para oferecer suporte de forma 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 realmente um conceito de fragmentação, mesmo que ela também existisse tecnicamente naquela época (para dar suporte à impressão). O suporte a fragmentação era algo que era fixado na parte de cima (impressão) ou adaptado (várias colunas).

Ao criar o layout de conteúdo fragmentável, o mecanismo legado mostra tudo em uma faixa alta cuja largura é o tamanho inline de uma coluna ou página, e a altura é a mesma necessária para conter o conteúdo. Essa faixa alta não é renderizada para a página. Pense nela como a renderização em uma página virtual que é reorganizada para exibição final. Conceitualmente, é semelhante a imprimir um artigo inteiro de jornal em uma coluna e usar uma tesoura para cortá-lo em vários como uma segunda etapa. Na época, alguns jornais usavam técnicas semelhantes a essa.

O mecanismo legado registra um limite imaginário de página ou coluna na faixa. Isso permite que ele direcione o conteúdo que não ultrapassar o limite para a próxima página ou coluna. Por exemplo, se apenas a metade superior de uma linha couber no que o mecanismo acredita ser a página atual, ele vai inserir uma "barra de paginação" para empurrá-la para a posição em que o mecanismo presume que o topo da próxima página está. Então, a maior parte do trabalho de fragmentação (o "cortar com tesoura e posicionamento") ocorre após o layout durante a pré-pintura e a pintura, recortando as partes altas das páginas, recortando as partes altas da página. Isso tornou algumas coisas essencialmente impossíveis, como a aplicação de transformações e o posicionamento relativo após a fragmentação, que é o que a especificação exige. Além disso, embora haja suporte para a fragmentação de tabelas no mecanismo legado, não há compatibilidade com fragmentação de grade ou flexível.

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

A representação interna como uma coluna com estruturas de paginação onde o conteúdo é quebrado 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 relativo e aplicação incorreta de transformações, e sombras de caixa sendo cortadas nas bordas das colunas.

Confira um exemplo simples com sombra de texto:

O mecanismo legado não lida bem com isso:

Sombras de texto cortadas colocadas na segunda coluna.

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

Deve ter a seguinte aparência (e é assim que aparece com NG):

Duas colunas de texto com as sombras exibidas corretamente.

Agora, vamos complicar um pouco mais, com transformações e box-shadow. Observe como no mecanismo legado há recortes incorretos e sangramento de coluna. Isso ocorre porque as transformações são, por especificação, aplicadas como um efeito pós-layout e pós-fragmentação. Com a fragmentação do LayoutNG, ambos funcionam corretamente. Isso aumenta a interoperabilidade com o Firefox, que possui um bom suporte à fragmentação por algum tempo, sendo que a maioria dos testes nessa área também apresenta bons resultados.

.

As caixas estão divididas incorretamente em duas colunas.

O mecanismo legado também tem problemas com conteúdo monolítico alto. O conteúdo será monolítico se não puder 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:

.

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

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

ALT_TEXT_HERE

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

.

Texto dividido em duas colunas.

Aqui, o elemento #multicol tem espaço para cinco 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, sua irmã #secondchild tem break-before:avoid, o que significa que o conteúdo quer que uma pausa não ocorra entre eles. Como o valor de widows é 2, precisamos enviar duas linhas de #firstchild para a segunda coluna a fim de honrar todas as solicitações de prevenção de quebra. 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 faz o layout do documento passando pela profundidade da árvore de caixas CSS primeiro. Quando todos os descendentes de um nó sã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 são concluídos, gera um fragmento para si mesmo com todos os filhos inseridos. Com esse método, ele cria uma árvore de fragmentos para todo o documento. No entanto, essa é uma simplificação excessiva: por exemplo, elementos posicionados fora do fluxo terão que brotar de onde existem na árvore DOM para o bloco que os contém antes de serem dispostos. Vou ignorar esse detalhe avançado aqui para simplificar.

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 a redução da margem intermediária nos resultados do conteúdo anterior. O espaço de restrição também sabe o tamanho do bloco disposto do fragmentador 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 em uma pausa. Os motivos para a interrupção incluem falta de espaço na página ou coluna ou uma quebra forçada. Em seguida, produzimos fragmentos para os nós que visitamos e retornamos até a raiz do contexto de fragmentação (o contêiner multicol ou, no caso da impressão, a raiz do documento). Em seguida, na raiz do contexto de fragmentação, nós nos preparamos para um novo fragmentador e descemos à árvore novamente, retomando de onde paramos antes da interrupção.

A estrutura de dados crucial para fornecer meios de retomar o layout após uma pausa é 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 é representado. Um NGBlockBreakToken é anexado ao NGPhysicalBoxFragment gerado para nós que quebram dentro dele. Os tokens de intervalo são propagados para os pais, formando uma árvore de tokens de intervalo. Se precisarmos quebrar antes de um nó (em vez de dentro dele), nenhum fragmento será produzido, mas o nó pai ainda precisará criar um token de interrupção "antes" para o nó, para que possamos começar a colocá-lo quando chegarmos à mesma posição na árvore de nós no próximo fragmentador.

As interrupções 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 pausas não forçadas ideais, e simplesmente inserir uma pausa 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 de interrupção. Portanto, durante o layout, para implementar corretamente a seção de especificação pausas não forçadas, precisamos acompanhar possíveis pontos de interrupção que sejam bons. Com esse registro, podemos voltar e usar o último melhor ponto de interrupção possível encontrado se ficarmos sem espaço em um ponto em que violaríamos solicitações para evitar uma pausa (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" até "o lugar perfeito para quebrar", com alguns valores no meio. Se um local de intervalo pontua como "perfeito", isso significa que nenhuma regra de violação será violada se a quebrarmos. Se chegarmos a essa pontuação exatamente no ponto em que ficamos sem espaço, não há necessidade de olhar para trás e encontrar algo melhor. Se a pontuação for "last-resort", o ponto de interrupção não é nem mesmo válido, mas ainda podemos interromper se não encontrarmos algo melhor para evitar um 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 discuti-los 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 "last-resort".

Durante o layout, monitoramos 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 profundo dentro de algo que passamos anteriormente no momento em que ficamos sem espaço. Veja um exemplo:

Nesse caso, ficamos sem espaço logo antes de #second, mas ele tem "break-before:avoid", que recebe a pontuação de local de intervalo "violando break prevent". Nesse ponto, temos uma cadeia NGEarlyBreak de "dentro de #outer > dentro de #middle > dentro de #inner > antes da "linha 3"', com "perfeito". Então, preferimos quebrar nesse ponto. Portanto, precisamos retornar e executar novamente o layout desde o início de #outer (e desta vez passar o NGEarlyBreak que encontramos), para que possamos fazer a quebra antes da "linha 3" em #inner. Vamos quebrar antes da "linha 3", para que as quatro linhas restantes acabem no próximo fragmentador e para respeitar widows:4.

O algoritmo foi projetado para quebrar sempre no melhor ponto de interrupção possível, conforme definido na spec, descartando as regras na ordem correta, se nem todas possam ser atendidas. Só é necessário recriar o layout no máximo uma vez por fluxo de fragmentação. Na segunda transmissão de layout, o melhor local de intervalo já foi transmitido aos algoritmos de layout. Esse é o local de intervalo descoberto na primeira transmissão de layout e fornecido como parte da saída do layout nessa rodada. Na segunda passagem de layout, não fazemos o layout até ficar sem espaço. Na verdade, não esperamos que ele fique, o que seria um erro, porque recebemos um lugar muito legal (bem, tão bom quanto havia disponível) para inserir uma pausa antecipada e evitar violações desnecessárias das regras. Basta seguirmos esse ponto e dividir.

Nessa observação, às vezes precisamos violar algumas das solicitações de prevenção de interrupção, se isso ajudar a evitar um estouro fragmentado. Exemplo:

.

Aqui, ficamos sem espaço pouco antes de #second, mas ele tem "break-before:avoid". Isso é traduzido como "violando quebra de intervalo", como no último exemplo. Também temos um NGEarlyBreak com "violando órfãos e viúvas" (dentro de #first > antes da "linha 2"), que ainda não é perfeito, mas é melhor do que "violando pausas". Portanto, vamos interromper antes da "linha 2", violando o pedido de órfãos / viúvas. A especificação trata disso em 4.4. Quebras não forçadas, em que definem quais regras interruptivas são ignoradas primeiro se não tivermos pontos de interrupção suficientes para evitar um estouro fragmentado.

Resumo

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

Agora que a fragmentação de blocos do LayoutNG foi concluída, podemos começar a trabalhar na adição de 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. E assim como acontece com o LayoutNG em geral, esperamos que a taxa de bugs e a carga de manutenção do novo sistema sejam significativamente menores com o tempo.

Agradecemos por ler.

Agradecimentos