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 fragmentador não é um elemento, mas representa uma coluna no 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 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 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 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 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 legado 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 do 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.

Enviamos a primeira parte do LayoutNG em 2019, que consistia em um layout regular de contêiner de blocos, layout em linha, pontos flutuantes e posicionamento fora do fluxo, mas sem suporte a flexibilidade, grade ou tabelas, e nenhum suporte à fragmentação de blocos. Voltaríamos a usar o mecanismo de layout legado para flex, grade, tabelas e qualquer coisa que envolvesse fragmentação de bloco. 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á 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 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 legados com várias colunas, como LayoutMultiColumnFlowThread.

Detecção e tratamento de substitutos de mecanismos 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 ancestral de contêiner de várias colunas e se os nós DOM se tornariam um contexto de formatação ou não. É um problema de causa e efeito 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 após o 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? Processamos as árvores de fragmentos do layout e do objeto de layout 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 ser ainda mais complicado quando há um elemento posicionado fora de fluxo na fragmentação, porque os fragmentos que saem do fluxo se tornam filhos diretos do fragmentainer (e não um filho do que o CSS considera que é o bloco que o contém). 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, 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 de cima (impressão) ou adaptado (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 na página. Pense nela como uma renderização para uma página virtual que é reorganizada para a exibição final. Conceitualmente, ela é semelhante a imprimir um artigo de jornal de papel inteiro em uma coluna e usar uma tesoura para cortá-lo em várias colunas como uma segunda etapa. (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 coubesse no que o mecanismo considera a página atual, ele inseriria 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á. Depois, 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 (cortando e traduzindo partes). 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 alguma compatibilidade com a fragmentação de tabelas no mecanismo legado, não há suporte à fragmentação de grade ou flexibilidade.

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 cortadas nas bordas da coluna.

Confira um exemplo com text-shadow:

O mecanismo legada não lida 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, 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, e a maioria dos testes nessa área também é aprovada.

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 estiver qualificado para ser dividido em vários fragmentos. Os elementos com rolagem de overflow são monolíticos, porque não faz sentido 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:

(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 ele transborde a primeira coluna (como faz com a fragmentação de bloco 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 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, 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 cumprir todas as solicitações de prevenção de intervalos. 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ó 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 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, 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.

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 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 quebrados. Os tokens de interrupção são propagados para os pais, formando uma árvore de tokens de interrupção. Se for necessário 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 "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 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 interrupções 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 você fizer a pausa ali. Se você receber essa pontuação exatamente no ponto em que o espaço acabar, não será necessário voltar para 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). Há 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 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, o espaço acabou 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. (Fizemos uma pausa 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 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 quebrar.

À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