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

Morten Stenshorne
Morten Stenshorne

A fragmentação de bloco divide uma caixa no nível do 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 fragmentos, chamado de fragmentainer. Um fragmentador 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. Um 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, de modo que as primeiras linhas sejam colocadas no primeiro fragmento e as linhas restantes sejam colocadas 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 fragmentador, representando um fragmento do fluxo fragmentado.

A fragmentação de blocos é análoga a outro tipo conhecido de fragmentação: a fragmentação de linhas, também conhecida como "quebra de linha". Qualquer elemento inline 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 fragmentador para colunas e páginas.

Fragmentação de bloco do LayoutNG

O LayoutNGBlockFragmentation é uma reescrita do mecanismo de fragmentação para LayoutNG, inicialmente enviado no Chrome 102. Em termos de estruturas de dados, ele substituiu várias estruturas de dados anteriores ao 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", que permitem 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 título.
Figura 1. O primeiro exemplo mostra um título na parte de baixo da página, e o segundo mostra na parte de cima da página seguinte com o conteúdo associado.

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

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

Fragmentação do núcleo (contêineres de blocos, incluindo layout de linha, flutuações e posicionamento fora do fluxo) enviada no Chrome 102. A fragmentação de flex e grade foi lançada no Chrome 103, e a fragmentação de tabela foi lançada no Chrome 106. Por fim, a impressão foi enviada no Chrome 108. A fragmentação de blocos era o último recurso que dependia do mecanismo legada para executar o layout.

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

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

A disposição de tudo com o NG vai permitir implementar e enviar novos recursos que têm apenas implementações do LayoutNG (e nenhuma contraparte de mecanismo legado), como consultas de contêiner CSS, posicionamento de âncora, MathML e layout personalizado (Houdini). Para consultas de contêiner, enviamos com um pouco de antecedência, com um aviso aos desenvolvedores de que a impressão ainda não tinha suporte.

Lançamos a primeira parte do LayoutNG em 2019, que consistia em layout de contêiner de bloco regular, layout inline, flutuações e posicionamento fora do fluxo, mas sem suporte para flex, grade ou tabelas e sem suporte para fragmentação de bloco. Voltaríamos a usar o mecanismo de layout legado para flex, grade, tabelas e qualquer coisa que envolvesse fragmentação de blocos. Isso era verdade mesmo para elementos de bloco, inline, flutuantes e fora do fluxo no 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á estava implementada (atrás de uma flag). Por que demorou tanto para enviar? 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 com o mecanismo legado

As estruturas de dados legadas ainda são responsáveis pelas APIs JavaScript que leem informações de layout. Por isso, 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 processamento de substitutos do mecanismo legados

Tivemos que voltar ao mecanismo de layout legado quando havia conteúdo que ainda não era processado pela fragmentação de blocos do LayoutNG. No momento do lançamento da fragmentação de blocos do LayoutNG principal, isso incluía flex, grade, tabelas e qualquer coisa que fosse impressa. Isso foi particularmente complicado porque precisávamos detectar a necessidade de fallback legados antes de criar objetos na árvore de layout. Por exemplo, precisávamos detectar se havia um contêiner ancestral de várias colunas e se os nós DOM se tornariam um contexto de formatação ou não. É um problema de ovo e galinha que não tem uma solução perfeita, mas, desde que o único comportamento incorreto seja falso positivo (retorno ao legado quando não há necessidade), tudo bem, porque os bugs nesse comportamento de layout são os mesmos que o Chromium já tem, não são novos.

Á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 de layout, mas agora temos fragmentos NG. Como lidar com isso? Analisamos o objeto de layout e as árvores de fragmentos do 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 fragmentação de bloco (fragmentos de coluna ou de página), a árvore de fragmentos também tem uma relação pai-filho direta entre um bloco de contenção e os descendentes do DOM que têm esse fragmento como bloco de contenção. Por exemplo, na árvore de fragmentos, um fragmento gerado por um elemento posicionado de forma absoluta é um 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 do fluxo e o bloco que o contém.

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

Os problemas com o mecanismo de fragmentação legada

O mecanismo legado, projetado em uma era anterior da Web, não tem um conceito de fragmentação, mesmo que a fragmentação tecnicamente existisse naquela época (para oferecer suporte à impressão). O suporte à fragmentação foi apenas algo que foi aparafusado na parte de cima (impressão) ou adaptado (várias colunas).

Ao organizar conteúdo fragmentável, o mecanismo legado organiza tudo em uma faixa alta com largura igual ao tamanho inline de uma coluna ou página, e a altura é tão alta quanto for necessário para conter o conteúdo. Essa faixa alta não é renderizada na página. Pense nela como uma renderização para uma página virtual que é reorganizada para a exibição final. Conceitualmente, é semelhante a imprimir um artigo de jornal inteiro em uma coluna e, em seguida, usar uma tesoura para cortar em várias colunas. (Antigamente, alguns jornais usavam técnicas semelhantes a essa!)

O mecanismo legado rastreia uma página imaginária ou um limite de coluna na faixa. Isso permite que o conteúdo que não se encaixa no limite seja empurrado para a próxima página ou coluna. Por exemplo, se apenas a metade superior de uma linha couber no que o mecanismo considera a página atual, ele vai inserir um "suporte de paginação" para empurrá-la para baixo até a posição em que o mecanismo supõe que a parte de cima da próxima página está. Em seguida, 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, dividindo a faixa alta de conteúdo em páginas ou colunas (com recortes e translação de partes). Isso tornou algumas coisas essencialmente impossíveis, como aplicar transformações e posicionamento relativo após o fragmentação (o que é exigido pela especificação). Além disso, embora haja algum suporte para a fragmentação de tabelas no mecanismo legado, não há suporte para fragmentação flexível ou de grade.

Confira uma ilustração de como um layout de três colunas é representado internamente no mecanismo legado, antes de usar tesoura, posicionamento e cola (definimos uma altura específica para que apenas quatro linhas caibam, mas há um espaço em excesso na parte de baixo):

A representação interna como uma coluna com struts de paginação em que o conteúdo é interrompido 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 transformações aplicadas incorretamente e sombras de caixa sendo cortadas nas bordas da coluna.

Confira um exemplo com text-shadow:

O mecanismo legado não lida bem com isso:

Sombras de texto cortadas colocadas na segunda coluna.

Percebeu como a sombra do texto da linha na primeira coluna é cortada e colocada na parte de cima da segunda coluna? Isso ocorre porque o mecanismo de layout legado não entende a fragmentação.

Ele terá a seguinte aparência:

Duas colunas de texto com as sombras sendo exibidas corretamente.

Agora vamos complicar um pouco mais, com transformações e box-shadow. Observe como, no mecanismo legado, há recorte e sangramento de coluna incorretos. Isso ocorre porque as transformações são especificadas para serem 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 tem um bom suporte à fragmentação há algum tempo, com a maioria dos testes nessa área também sendo aprovados.

As caixas são divididas incorretamente em duas colunas.

O mecanismo legado também tem problemas com conteúdo monolítico alto. O conteúdo é monolítico se não for qualificado para divisão em vários fragmentos. Os elementos com rolagem de overflow são monolíticos, porque não faz sentido para os usuários rolar em uma região não retangular. As caixas de linha e as imagens são outros exemplos de conteúdo monolítico. Veja um exemplo:

Se o conteúdo monolítico for muito alto para caber em uma coluna, o mecanismo legada vai dividi-lo brutalmente, o que leva a um comportamento muito "interessante" ao tentar rolar o contêiner rolável:

Em vez de permitir que ele transborde a primeira coluna (como acontece com a fragmentação de bloco do LayoutNG):

ALT_TEXT_HERE

O mecanismo legada oferece suporte a pausas forçadas. Por exemplo, <div style="break-before:page;"> vai inserir uma quebra de página antes do DIV. No entanto, ele tem suporte limitado para encontrar quebras não forçadas ideais. Ele oferece suporte a break-inside:avoid e órfão e viúva, mas não oferece suporte para evitar quebras 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 pixels de altura e a altura da linha é de 20 pixels), então todo o #firstchild pode caber na primeira coluna. No entanto, o #secondchild irmão tem break-before:avoid, o que significa que o conteúdo não quer que ocorra 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 evitação de pausas. O Chromium é o primeiro mecanismo de navegador com suporte total a essa combinação de recursos.

Como funciona a fragmentação de NG

O mecanismo de layout do NG geralmente organiza o documento percorrendo a árvore de caixas do CSS em profundidade. 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 fragmentos filhos. Com esse método, uma árvore de fragmentos é criada para todo o documento. No entanto, isso é uma simplificação excessiva: por exemplo, elementos posicionados fora do fluxo precisam ser exibidos de onde existem na árvore DOM até o bloco que os contém antes de serem dispostos. Vou ignorar esse detalhe avançado para simplificar.

Além da 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 os resultados de redução de margem intermediária do conteúdo anterior. O espaço de restrição também conhece o tamanho do bloco definido do fragmentador e o deslocamento do bloco atual. Isso indica onde quebrar.

Quando a fragmentação de bloco está envolvida, o layout dos descendentes precisa parar em um intervalo. Os motivos para a quebra 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 de várias colunas ou, no caso de impressão, a raiz do documento). Em seguida, na raiz do contexto de fragmentação, nos preparamos para um novo fragmentador e descemos para a árvore novamente, retomando de onde paramos antes da interrupção.

A estrutura de dados crucial para fornecer o meio 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 fragmentador. 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 se quebram. Os tokens de interrupção são propagados para os pais, formando uma árvore de tokens de interrupção. Se precisarmos interromper antes de um nó (em vez de dentro dele), nenhum fragmento será produzido, mas o nó pai ainda precisa criar um token de interrupção "break-before" para o nó, para que possamos começar a exibi-lo quando chegarmos à mesma posição na árvore de nós no próximo fragmentador.

As pausas são inseridas quando o espaço do fragmentador acaba (uma pausa não forçada) ou quando uma pausa forçada é solicitada.

Há regras na especificação para pausas ideais não forçadas, e inserir uma pausa exatamente onde falta espaço nem sempre é a melhor opção. Por exemplo, há várias propriedades do CSS, como break-before, que influenciam a escolha do local da pausa.

Durante o layout, para implementar corretamente a seção de especificação de quebras não forçadas, precisamos acompanhar os possíveis pontos de interrupção. Esse registro significa que podemos voltar e usar o último ponto de interrupção possível encontrado, se ficarmos sem espaço em um ponto em que violaríamos as solicitações de evitação de pausas (por exemplo, break-before:avoid ou orphans:7). Cada ponto de interrupção possível recebe uma pontuação, que varia de "faça isso apenas como último recurso" a "lugar perfeito para pausar", com alguns valores no meio. Se um local de pausa for "perfeito", isso significa que nenhuma regra será violada se ele for usado. Se a pontuação for exatamente no ponto em que o espaço acabar, não será necessário procurar algo melhor. Se a pontuação for "última alternativa", o ponto de interrupção não é válido, mas ainda podemos fazer uma pausa se não encontrarmos nada melhor para evitar o estouro do fragmentador.

Os pontos de interrupção válidos geralmente 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 da 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 bloco irmão com break-before:avoid, mas ele está entre "perfeito" e "último recurso".

Durante o layout, registramos o melhor ponto de interrupção encontrado até o momento em uma estrutura chamada NGEarlyBreak. Uma interrupção antecipada é um possível ponto de interrupção antes ou dentro de um nó de bloco ou antes de uma linha (uma linha de contêiner de bloco ou uma linha flexível). Podemos formar uma cadeia ou um caminho de objetos NGEarlyBreak, caso o melhor ponto de interrupção esteja em algum lugar dentro de algo que passamos anteriormente quando o espaço acabou. Veja um exemplo:

Nesse caso, o espaço acaba logo antes de #second, mas ele tem "break-before:avoid", que recebe uma pontuação de local de pausa de "violação de pausa de evitação". Nesse ponto, temos uma cadeia NGEarlyBreak de "dentro de #outer > dentro de #middle > dentro de #inner > antes de "linha 3", com "perfeito", então preferimos quebrar aqui. Portanto, precisamos retornar e executar o layout novamente desde o início de #outer (e desta vez transmitir o NGEarlyBreak que encontramos) para que possamos fazer a quebra antes da "linha 3" em #inner. (Fazemos a quebra antes da "linha 3" para que as quatro linhas restantes terminem no próximo fragmentador e para honrar widows:4.)

O algoritmo foi projetado para sempre interromper no melhor ponto de interrupção possível, conforme definido na especificação, descartando regras na ordem correta, se nem todas puderem ser atendidas. Só precisamos refazer o layout uma vez por fluxo de fragmentação. Quando chegamos à segunda passagem de layout, o melhor local de interrupção já foi transmitido aos algoritmos de layout. Esse é o local de interrupção que foi descoberto na primeira passagem de layout e fornecido como parte da saída de layout nessa rodada. Na segunda passagem de layout, não vamos fazer o layout até ficar sem espaço. Na verdade, não esperamos ficar sem espaço (isso seria um erro), porque recebemos um lugar superlegal (ou o melhor possível) para inserir uma quebra antecipada, para evitar violar regras de quebra desnecessárias. Então, vamos até esse ponto e vamos parar.

Às vezes, precisamos violar algumas das solicitações de evitação de pausas, se isso ajudar a evitar o overflow do fragmentador. Exemplo:

Aqui, não há espaço suficiente antes de #second, mas ele tem "break-before:avoid". Isso é traduzido como "violating break avoid", assim como no último exemplo. Também temos um NGEarlyBreak com "violação de órfãos e viúvas" (dentro de #first > antes de "linha 2"), que ainda não é perfeito, mas é melhor do que "violação de evitar pausa". Então vamos fazer uma pausa antes da "linha 2", violando a solicitação de órfãos / viúvas. A especificação trata disso em 4.4. Unforced Breaks, que define quais regras de interrupção são ignoradas primeiro se não tivermos pontos de interrupção suficientes para evitar o transbordamento do fragmentador.

Conclusão

O objetivo funcional do projeto de fragmentação de blocos do LayoutNG era fornecer uma implementação com suporte à arquitetura LayoutNG de tudo o que o mecanismo legado oferece e o mínimo possível além das correções de bugs. A principal exceção é o melhor suporte para evitar interrupções (break-before:avoid, por exemplo), porque essa é uma parte importante do mecanismo de fragmentação. Por isso, ele precisava estar presente desde o início, já que 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 na impressão, caixas de margem @page na impressão, box-decoration-break:clone e muito mais. Assim como o LayoutNG em geral, esperamos que a taxa de bugs e a carga de manutenção do novo sistema sejam substancialmente menores ao longo do tempo.

Agradecimentos