Approfondimento su RenderingNG: frammentazione dei blocchi LayoutNG

Morten Stenshorne
Morten Stenshorne

La frammentazione dei blocchi consiste nel suddividere una casella a livello di blocco CSS (ad esempio una sezione o un paragrafo) in più frammenti quando non si adatta come un'unità all'interno di un contenitore di frammenti, chiamato fragmentainer. Un frammentatore non è un elemento, ma rappresenta una colonna in un layout a più colonne o una pagina nei contenuti multimediali paginati.

Affinché si verifichi la frammentazione, i contenuti devono trovarsi in un contesto di frammentazione. Un contesto di frammentazione viene stabilito più comunemente da un contenitore con più colonne (i contenuti sono suddivisi in colonne) o durante la stampa (i contenuti sono suddivisi in pagine). Un paragrafo lungo con molte righe potrebbe dover essere suddiviso in più frammenti, in modo che le prime righe vengano inserite nel primo frammento e le righe rimanenti nei frammenti successivi.

Un paragrafo di testo suddiviso in due colonne.
In questo esempio, un paragrafo è stato suddiviso in due colonne utilizzando il layout multicolonna. Ogni colonna è un frammentatore che rappresenta un frammento del flusso frammentato.

La frammentazione dei blocchi è analoga a un altro tipo di frammentazione ben noto: la frammentazione delle righe, nota anche come "interruzione riga". Qualsiasi elemento in linea composto da più di una parola (qualsiasi nodo di testo, qualsiasi elemento <a> e così via) e che consenta interruzioni di riga può essere suddiviso in più frammenti. Ogni frammento viene inserito in una casella di riga diversa. Un riquadro di riga è la frammentazione in linea equivalente a un riquadro di frammentazione per colonne e pagine.

Frammentazione dei blocchi LayoutNG

LayoutNGBlockFragmentation è una riscrittura del motore di frammentazione per LayoutNG, inizialmente distribuito in Chrome 102. In termini di strutture di dati, ha sostituito più strutture di dati precedenti a NG con frammenti NG rappresentati direttamente nella struttura ad albero dei frammenti.

Ad esempio, ora supportiamo il valore "avoid" per le proprietà CSS "break-before" e "break-after", che consentono agli autori di evitare interruzioni subito dopo un'intestazione. Spesso sembra strano quando l'ultima cosa in una pagina è un'intestazione, mentre i contenuti della sezione iniziano nella pagina successiva. È meglio inserire un a capo prima dell'intestazione.

Esempio di allineamento delle intestazioni.
Figura 1. Il primo esempio mostra un'intestazione nella parte inferiore della pagina, il secondo nella parte superiore della pagina successiva con i relativi contenuti associati.

Chrome supporta anche il sovraccarico della frammentazione, in modo che i contenuti monolitici (che dovrebbero essere indivisibili) non vengano suddivisi in più colonne e gli effetti di pittura come ombre e trasformazioni vengano applicati correttamente.

La frammentazione dei blocchi in LayoutNG è stata completata

La frammentazione di base (contenitori di blocco, inclusi layout di riga, elementi in primo piano e posizionamento fuori flusso) è stata rilasciata in Chrome 102. La frammentazione di Flex e della griglia è stata rilasciata in Chrome 103 e la frammentazione della tabella in Chrome 106. Infine, la stampa è stata rilasciata in Chrome 108. La frammentazione dei blocchi era l'ultima funzionalità che dipendeva dal motore precedente per l'esecuzione del layout.

A partire da Chrome 108, il motore precedente non viene più utilizzato per eseguire il layout.

Inoltre, le strutture di dati di LayoutNG supportano la pittura e i test di hit, ma ci basiamo su alcune strutture di dati precedenti per le API JavaScript che leggono le informazioni sul layout, come offsetLeft e offsetTop.

La disposizione di tutto con NG consentirà di implementare e rilasciare nuove funzionalità che hanno solo implementazioni di LayoutNG (e nessuna controparte del motore precedente), come le query dei contenitori CSS, il posizionamento delle ancore, MathML e il layout personalizzato (Houdini). Per le query sui contenitori, l'abbiamo rilasciato un po' in anticipo, con un avviso per gli sviluppatori che la stampa non era ancora supportata.

Abbiamo rilasciato la prima parte di LayoutNG nel 2019, che consisteva in un layout contenitore di blocchi regolare, layout in linea, elementi in primo piano e posizionamento fuori flusso, ma senza supporto per flex, griglie o tabelle e senza alcun supporto per la frammentazione dei blocchi. Torneremo a utilizzare il motore di layout precedente per flex, griglie, tabelle e qualsiasi elemento che implichi la frammentazione dei blocchi. Questo valeva anche per gli elementi in blocco, in linea, indipendenti e fuori flusso all'interno di contenuti frammentati. Come puoi vedere, l'upgrade in situ di un motore di layout così complesso è un'operazione molto delicata.

Inoltre, entro la metà del 2019 la maggior parte delle funzionalità di base del layout di frammentazione dei blocchi di LayoutNG era già stata implementata (dietro un flag). Perché ci è voluto così tanto tempo per la spedizione? La risposta breve è: la frammentazione deve coesistere correttamente con varie parti legacy del sistema, che non possono essere rimosse o sottoposte ad upgrade finché non viene eseguito l'upgrade di tutte le dipendenze.

Interazione con il motore legacy

Le strutture di dati legacy sono ancora responsabili delle API JavaScript che leggono le informazioni sul layout, quindi dobbiamo riscrivere i dati nel motore legacy in un formato comprensibile. Ciò include l'aggiornamento corretto delle strutture di dati multicolonna precedenti, come LayoutMultiColumnFlowThread.

Rilevamento e gestione del fallback del motore legacy

Abbiamo dovuto fare ricorso al motore di layout precedente quando i contenuti non potevano ancora essere gestiti dalla frammentazione dei blocchi di LayoutNG. Al momento dell'invio, la frammentazione dei blocchi di LayoutNG di base includeva flex, griglie, tabelle e tutto ciò che viene stampato. Questo è stato particolarmente complicato perché dovevamo rilevare la necessità di un fallback legacy prima di creare oggetti nella struttura ad albero del layout. Ad esempio, dovevamo rilevare prima di sapere se esisteva un contenitore multicolonna e prima di sapere quali nodi DOM sarebbero diventati o meno un contesto di formattazione. Si tratta di un problema di causa ed effetto che non ha una soluzione perfetta, ma finché l'unico comportamento indesiderato sono i falsi positivi (ritorno al precedente quando non è effettivamente necessario), non c'è problema, perché eventuali bug in questo comportamento di layout sono quelli già presenti in Chromium, non nuovi.

Passeggiata tra gli alberi prima della verniciatura

La pre-verniciatura è un'operazione che eseguiamo dopo il layout, ma prima della verniciatura. La sfida principale è che dobbiamo ancora esaminare la struttura ad albero degli oggetti di layout, ma ora abbiamo i frammenti NG. Come possiamo risolvere il problema? Esaminiamo contemporaneamente l'oggetto layout e le strutture ad albero dei frammenti NG. È piuttosto complicato, perché la mappatura tra i due alberi non è banale.

Sebbene la struttura ad albero dell'oggetto layout assomigli molto a quella dell'albero DOM, l'albero dei frammenti è un'uscita del layout, non un input. Oltre a riflettere l'effetto di qualsiasi frammentazione, inclusa la frammentazione in linea (frammenti di riga) e la frammentazione in blocchi (colonne o frammenti di pagina), l'albero dei frammenti ha anche una relazione diretta genitore-figlio tra un blocco contenente e i discendenti DOM che hanno quel frammento come blocco contenente. Ad esempio, nell'albero dei frammenti, un frammento generato da un elemento con posizionamento assoluto è un elemento secondario diretto del frammento del blocco contenente, anche se nella catena di ascendenza sono presenti altri nodi tra il discendente posizionato fuori dal flusso e il blocco contenente.

Può essere ancora più complicato quando all'interno della frammentazione è presente un elemento posizionato out-of-flow, perché in questo caso i frammenti out-of-flow diventano elementi secondari diretti del frammentatore (e non un elemento secondario di quello che CSS ritiene essere il blocco contenente). Si trattava di un problema che doveva essere risolto per poter coesistere con il motore precedente. In futuro, dovremmo essere in grado di semplificare questo codice, perché LayoutNG è progettato per supportare in modo flessibile tutte le modalità di layout moderne.

I problemi con il motore di frammentazione precedente

Il motore precedente, progettato in una versione precedente del web, non ha un vero e proprio concetto di frammentazione, anche se tecnicamente la frammentazione esisteva anche allora (per supportare la stampa). Il supporto della frammentazione è stato semplicemente aggiunto (stampa) o adattato (più colonne).

Quando impagina i contenuti frazionabili, il motore precedente li dispone in una striscia alta la cui larghezza corrisponde alle dimensioni in linea di una colonna o di una pagina e l'altezza è sufficiente per contenere i contenuti. Questa striscia alta non viene visualizzata nella pagina, ma in una pagina virtuale che viene poi riorganizzata per la visualizzazione finale. È concettualmente simile alla stampa di un intero articolo di giornale su carta in una colonna e poi all'utilizzo di forbici per tagliarlo in più parti come secondo passaggio. (In passato, alcuni giornali utilizzavano effettivamente tecniche simili a queste).

Il motore precedente tiene traccia di un confine immaginario di pagina o colonna nella striscia. In questo modo, i contenuti che non rientrano nel limite vengono spostati nella pagina o nella colonna successiva. Ad esempio, se solo la metà superiore di una riga si adatta a quella che il motore ritiene essere la pagina corrente, viene inserita una "struttura di paginazione" per spingerla verso il basso fino alla posizione in cui il motore presume che si trovi la parte superiore della pagina successiva. Poi, la maggior parte del lavoro di frammentazione effettivo ("taglio con forbici e posizionamento") avviene dopo il layout durante la pre-pittura e la pittura, dividendo la striscia di contenuti in pagine o colonne (tagliando e traducendo le parti). Ciò ha reso alcune cose sostanzialmente impossibili, come l'applicazione di trasformazioni e posizionamento relativo dopo la frammentazione (come richiesto dalle specifiche). Inoltre, sebbene il motore precedente supporti in parte la frammentazione delle tabelle, non supporta affatto la frammentazione delle griglie o dei layout flessibili.

Ecco un'illustrazione di come un layout a tre colonne viene rappresentato internamente nell'engine precedente, prima di utilizzare forbici, posizionamento e colla (abbiamo un'altezza specificata, quindi si adattano solo quattro righe, ma c'è un po' di spazio in eccesso in basso):

La rappresentazione interna come una colonna con elementi di paginazione in cui i contenuti si interrompono e la rappresentazione sullo schermo come tre colonne

Poiché il motore di layout precedente non frammenta effettivamente i contenuti durante il layout, si verificano molti strani artefatti, come l'applicazione errata di trasformazioni e posizionamenti relativi e l'accorciamento delle ombre delle caselle agli angoli delle colonne.

Ecco un esempio con text-shadow:

Il motore precedente non gestisce bene questo problema:

Ombre del testo tagliate posizionate nella seconda colonna.

Hai notato che l'ombra del testo della riga nella prima colonna viene tagliata e posizionata nella parte superiore della seconda colonna? Questo accade perché il motore di layout precedente non comprende la frammentazione.

Dovrebbe avere il seguente aspetto:

Due colonne di testo con le ombre visualizzate correttamente.

Ora rendiamo la situazione un po' più complicata con le trasformazioni e l'ombra della casella. Nota che nell'engine precedente sono presenti ritagli e fuoriuscite di colonne errati. Questo perché, secondo le specifiche, le trasformazioni dovrebbero essere applicate come effetto post-layout e post-frammentazione. Con la frammentazione di LayoutNG, entrambi funzionano correttamente. Ciò aumenta l'interoperabilità con Firefox, che supporta bene la frammentazione da un po' di tempo e supera anche la maggior parte dei test in questo ambito.

Le caselle sono suddivise in modo errato in due colonne.

Il motore precedente presenta anche problemi con i contenuti monolitici di grandi dimensioni. I contenuti sono monolitici se non sono idonei alla suddivisione in più frammenti. Gli elementi con scorrimento con overflow sono monolitici, perché non ha senso per gli utenti scorrere in una regione non rettangolare. Le caselle di testo e le immagini sono altri esempi di contenuti monolitici. Ecco un esempio:

Se il blocco di contenuti monolitici è troppo alto per adattarsi a una colonna, il motore precedente lo taglia brutalmente (generando un comportamento molto "interessante" quando si tenta di scorrere il contenitore scorrevole):

Invece di andare oltre la prima colonna (come accade con la frammentazione dei blocchi di LayoutNG):

ALT_TEXT_HERE

Il motore precedente supporta le interruzioni forzate. Ad esempio, <div style="break-before:page;"> inserirà un'interruzione di pagina prima del DIV. Tuttavia, il supporto per trovare interruzioni non forzate ottimali è limitato. Supporta break-inside:avoid e paragrafi orfani e vedove, ma non è possibile evitare interruzioni tra i blocchi, ad esempio se richieste tramite break-before:avoid. Considera questo esempio:

Testo suddiviso in due colonne.

In questo caso, l'elemento #multicol ha spazio per 5 righe in ogni colonna (perché è alto 100 pixel e l'altezza riga è 20 pixel), quindi tutto #firstchild potrebbe adattarsi alla prima colonna. Tuttavia, il suo elemento gemello #secondchild ha break-before:avoid, il che significa che i contenuti non vogliono che tra di loro si verifichi un'interruzione. Poiché il valore di widows è 2, dobbiamo inviare 2 righe di #firstchild alla seconda colonna per soddisfare tutte le richieste di evitamento delle interruzioni. Chromium è il primo motore del browser che supporta completamente questa combinazione di funzionalità.

Come funziona la frammentazione NG

In genere, il motore di layout NG esegue il layout del documento attraversando l'albero delle caselle CSS in ordine di profondità. Quando tutti i discendenti di un nodo sono disposti, il layout del nodo può essere completato producendo un NGPhysicalFragment e tornando all'algoritmo di layout principale. L'algoritmo aggiunge il frammento al proprio elenco di frammenti secondari e, una volta completati tutti i frammenti secondari, genera un frammento per sé con tutti i frammenti secondari al suo interno. Con questo metodo viene creata una struttura ad albero di frammenti per l'intero documento. Tuttavia, si tratta di una semplificazione eccessiva: ad esempio, gli elementi posizionati fuori dal flusso dovranno risalire dalla posizione in cui si trovano nella struttura a albero DOM al blocco contenente prima di poter essere disposti. Per semplicità, ignoro questo dettaglio avanzato.

Oltre alla casella CSS stessa, LayoutNG fornisce uno spazio di vincolo a un algoritmo di layout. In questo modo, l'algoritmo riceve informazioni quali lo spazio disponibile per il layout, se è stato stabilito un nuovo contesto di formattazione e i risultati del collasso dei margini intermedi dai contenuti precedenti. Lo spazio dei vincoli conosce anche le dimensioni del blocco del frammentatore e l'offset del blocco corrente al suo interno. Indica dove interrompere.

Quando è coinvolta la frammentazione dei blocchi, il layout dei discendenti deve interrompersi a un'interruzione. I motivi della rottura includono lo spazio insufficiente nella pagina o nella colonna o una rottura forzata. Produciamo quindi frammenti per i nodi che abbiamo visitato e torniamo fino alla radice del contesto di frammentazione (il contenitore multicolonna o, in caso di stampa, la radice del documento). Poi, nel contesto principale della frammentazione, ci prepariamo per un nuovo frammentatore e scendiamo di nuovo nell'albero, riprendendo da dove avevamo interrotto prima dell'interruzione.

La struttura di dati fondamentale per fornire i mezzi per riprendere il layout dopo un'interruzione è chiamata NGBlockBreakToken. Contiene tutte le informazioni necessarie per riprendere correttamente il layout nel successivo frammentatore. Un NGBlockBreakToken è associato a un nodo e forma un albero di NGBlockBreakToken, in modo che ogni nodo che deve essere ripreso sia rappresentato. Un token NGBlockBreak viene associato al NGPhysicalBoxFragment generato per i nodi che si interrompono all'interno. I token di interruzione vengono propagati agli elementi principali, formando una struttura ad albero di token di interruzione. Se dobbiamo inserire un a capo prima di un nodo (anziché all'interno), non verrà prodotto alcun frammento, ma il nodo principale deve comunque creare un token di interruzione "break-before" per il nodo, in modo da poter iniziare a eseguire il layout quando raggiungiamo la stessa posizione nella struttura ad albero dei nodi nel successivo contenitore di frammenti.

Le interruzioni vengono inserite quando non è più disponibile spazio nel frammentatore (interruzione non forzata) o quando viene richiesta un'interruzione forzata.

Le specifiche prevedono regole per interruzioni ottimali non forzate e non è sempre opportuno inserire un'interruzione esattamente dove non c'è spazio. Ad esempio, esistono varie proprietà CSS come break-before che influiscono sulla scelta della posizione dell'interruzione.

Durante il layout, per implementare correttamente la sezione delle specifiche relative alle interruzioni forzate, dobbiamo tenere traccia delle potenziali interruzioni. Questo record significa che possiamo tornare indietro e utilizzare l'ultimo punto di interruzione migliore trovato, se non abbiamo più spazio in un punto in cui violeremmo le richieste di evitamento delle interruzioni (ad esempio break-before:avoid o orphans:7). A ogni possibile punto di interruzione viene assegnato un punteggio, che va da "esegui questa operazione solo come ultima risorsa" a "luogo perfetto per l'interruzione", con alcuni valori intermedi. Se la posizione dell'interruzione ottiene il punteggio "perfetto", significa che non verranno violate regole di interruzione se inseriamo l'interruzione in quel punto (e se otteniamo questo punteggio esattamente nel punto in cui non abbiamo più spazio, non c'è bisogno di cercare qualcosa di meglio). Se il punteggio è "ultima risorsa", il punto di interruzione non è nemmeno valido, ma potremmo comunque interrompere lì se non troviamo niente di meglio per evitare il sovraccarico del frammentatore.

In genere, i punti di interruzione validi si verificano solo tra elementi fratelli (caselle di riga o blocchi) e non, ad esempio, tra un elemento principale e il suo primo elemento figlio (i punti di interruzione di classe C sono un'eccezione, ma non è necessario discuterne qui). Esiste un punto di interruzione valido, ad esempio prima di un blocco fratello con break-before:avoid, ma è compreso tra "perfetto" e "ultima risorsa".

Durante il layout, teniamo traccia del breakpoint migliore trovato finora in una struttura chiamata NGEarlyBreak. Un'interruzione anticipata è un possibile punto di interruzione prima o all'interno di un nodo di blocco o prima di una riga (una riga del contenitore di blocchi o una riga flessibile). Potremmo formare una catena o un percorso di oggetti NGEarlyBreak, nel caso in cui il punto di interruzione migliore si trovi in un punto molto lontano di qualcosa che abbiamo esaminato in precedenza quando abbiamo esaurito lo spazio. Ecco un esempio:

In questo caso, lo spazio finisce subito prima di #second, ma è presente "break-before:avoid", che ottiene un punteggio di posizione dell'interruzione di "violating break avoid". A quel punto abbiamo una catena NGEarlyBreak di "all'interno di #outer > all'interno di #middle > all'interno di #inner > prima di "riga 3"", con "perfetto", quindi preferiamo interrompere lì. Quindi dobbiamo tornare ed eseguire nuovamente il layout dall'inizio di #outer (e questa volta passare il NGEarlyBreak che abbiamo trovato), in modo da poter interrompere prima della "riga 3" in #inner. Interrompiamo prima della "riga 3", in modo che le 4 righe rimanenti finiscano nel successivo frammentatore e per rispettare widows:4.

L'algoritmo è progettato per interrompersi sempre nel punto di interruzione migliore possibile, come definito nelle specifiche, eliminando le regole nell'ordine corretto, se non tutte possono essere soddisfatte. Tieni presente che dobbiamo eseguire il nuovo layout solo una volta per flusso di frammentazione. Quando siamo nella seconda elaborazione del layout, la posizione dell'interruzione migliore è già stata passata agli algoritmi di layout, ovvero la posizione dell'interruzione rilevata nella prima elaborazione del layout e fornita nell'output del layout in quel round. Nella seconda passata di layout, non eseguiamo il layout finché non esauriamo lo spazio, in realtà non è previsto che lo spazio finisca (infatti sarebbe un errore), perché ci è stato fornito un posto super-perfetto (beh, il più perfetto possibile) per inserire un'interruzione anticipata, per evitare di violare inutilmente le regole di interruzione. Quindi, li impostiamo fino a quel punto e li interrompiamo.

A tal proposito, a volte dobbiamo violare alcune delle richieste di evitamento delle interruzioni, se ciò aiuta a evitare l'overflow del frammentatore. Ad esempio:

Qui lo spazio finisce proprio prima di #second, ma è presente "break-before:avoid". Questo viene tradotto come "violazione dell'evitamento dell'interruzione", proprio come nell'ultimo esempio. Abbiamo anche un NGEarlyBreak con "violating orphans and widows" (all'interno di #first > prima di"line 2"), che non è ancora perfetto, ma è migliore di "violating break avoid". Pertanto, faremo un a capo prima di "riga 2", violando la richiesta di righe orfane / vedove. Le specifiche trattano questo argomento nella sezione 4.4. Interruzioni forzate, che definisce quali regole di interruzione vengono ignorate per prime se non disponiamo di breakpoint sufficienti per evitare l'overflow del frammentatore.

Conclusione

L'obiettivo funzionale del progetto di frammentazione dei blocchi di LayoutNG era fornire un'implementazione dell'architettura LayoutNG di tutto ciò che è supportato dal motore precedente e il meno possibile, a parte le correzioni di bug. L'eccezione principale è il supporto migliorato per l'evitamento delle interruzioni (ad esempio break-before:avoid), perché si tratta di un componente fondamentale del motore di frammentazione, quindi doveva essere presente fin dall'inizio, poiché l'aggiunta in un secondo momento avrebbe comportato un'altra riscrittura.

Ora che la frammentazione dei blocchi di LayoutNG è terminata, possiamo iniziare a lavorare all'aggiunta di nuove funzionalità, come il supporto di dimensioni di pagina miste durante la stampa, le caselle dei margini @page durante la stampa, box-decoration-break:clone e altro ancora. Come per LayoutNG in generale, prevediamo che la percentuale di bug e il carico di manutenzione del nuovo sistema diminuiranno notevolmente nel tempo.

Ringraziamenti