Articolo tecnico

Layout multi-colonna CSS in HTML-to-PDF con HotPDF Delphi

HotPDF Component impagina testo CSS multi-colonna quando THPDFHTMLImporter.RenderHTML5 converte HTML in PDF in Delphi. Il motore HTML impaginato risolve column-count e column-width contro il content box, bilancia l'ultimo frammento di ogni contenitore multicol con una ricerca binaria, riempie le pagine precedenti colonna per colonna fino al fondo pagina, e supporta column-fill, column-span: all e column-rule

Il motivo usuale per volerlo è noioso e reale: un bollettino di prodotto a due colonne, un allegato di termini e condizioni composto in tre colonne strette, o un PDF di release notes che deve sembrare la newsletter stampata che il team marketing ha già disegnato in HTML. Senza supporto alle colonne l'importatore impila ogni paragrafo in una misura unica larga, la lunghezza di riga raddoppia, e il documento si legge come un file di log. Con il supporto, lo stesso stylesheet che guida l'anteprima nel browser guida il PDF, e la parte difficile diventa la paginazione, di cui i browser quasi mai devono occuparsi sullo schermo

Come decide HotPDF quante colonne usare?

HotPDF risolve il numero di colonne con le stesse regole dello pseudo-algoritmo in CSS Multi-column Layout Module Level 1 §3.4, applicato alla larghezza W del content box dopo aver tolto padding e bordi. Un column-count dichiarato si prende così com'è. Quando è dato solo column-width, il motore calcola N = Trunc((W + gap) / (column-width + gap)) con un minimo di una colonna. Quando sono presenti entrambi, il risultato è il minore dei due, quindi il conteggio dichiarato può porre un tetto al conteggio derivato ma mai gonfiarlo

Un caso concreto: un content box da 483pt (A4 con margini laterali di 56pt, arrotondati) e column-width: 140pt; column-gap: 12pt dà Trunc(495 / 152) = 3 colonne, ognuna larga (483 - 2 * 12) / 3 = 153pt. Aggiungere column-count: 2 alla stessa regola la fa scendere a due colonne, perché vince il minimo. La parola chiave normal per il gap si risolve a 1em della dimensione del font del contenitore, e quella risoluzione avviene al momento del layout e non del parse, quindi un font-size dichiarato dopo column-gap nella stessa regola produce comunque il gap giusto

Risoluzione del numero di colonne CSS multi-colonna di HotPDF per HTML a PDF in Delphi: column-count si usa come dichiarato, column-width deriva N = Trunc((W + gap) / (column-width + gap)), vince il minore quando sono impostati entrambi, e qualunque colonna sotto 12pt collassa a una singola colonna
Il motore applica lo pseudo-algoritmo CSS al content box, pone un tetto al conteggio derivato con uno dichiarato, e aggiunge una guardia di minimo a 12pt così i template non fidati non possono produrre migliaia di colonne microscopiche

Una guardia non è nella specifica, e c'è di proposito. Dopo che la larghezza di colonna è calcolata, qualunque valore sotto 12pt collassa il contenitore di nuovo a una singola colonna. Uno stylesheet che dice column-count: 9999, per sbaglio o perché venuto da un template non fidato, produrrebbe altrimenti migliaia di colonne con larghezze negative o microscopiche e un line breaker che prova a far entrare un glifo per riga. Ripiegare su una colonna tiene l'output leggibile e il costo di layout limitato. Conteggi zero o negativi non arrivano mai fin lì: il validatore accetta solo auto o un intero positivo per column-count, e column-width e column-gap prendono auto o normal rispettivamente, o una lunghezza in punti o em

Renderizzare una sezione HTML multi-colonna in PDF in Delphi

L'output multi-colonna non richiede nessun interruttore sull'importatore. THPDFHTMLImporter viene creato con il documento THotPDF in cui disegna, BeginDoc deve essere già partito, e le proprietà di colonna arrivano attraverso il normale cascade, nel markup o nello stylesheet dell'autore passato come secondo argomento di RenderHTML5

uses
  SysUtils, HPDFDoc, HPDFHTMLImport;

procedure BuildBulletin(const FileName: string);
var
  Pdf: THotPDF;
  Importer: THPDFHTMLImporter;
  Html, Css: WideString;
begin
  Pdf := THotPDF.Create(nil);
  Importer := THPDFHTMLImporter.Create(Pdf);
  try
    Pdf.FileName := FileName;
    Pdf.BeginDoc;
    Importer.Margin := 56;
    Importer.BaseFontName := 'Arial';
    Importer.BaseFontSize := 10;

    Css := 'section.news { column-width: 140pt; column-gap: 12pt; ' +
           '  column-rule: 0.5pt solid #999999 } ' +
           'section.news h2 { column-span: all }';

    Html := '<section class="news">' +
            '<p>Release 4.2 ships the new export pipeline...</p>' +
            '<p>Licensing changes take effect on 1 March...</p>' +
            '<h2>Known issues</h2>' +
            '<p>Import of legacy archives &amp; templates...</p>' +
            '</section>';

    if not Importer.RenderHTML5(Html, Css) then
      raise Exception.Create('Nothing was emitted');
    Pdf.EndDoc;
  finally
    Importer.Free;
    Pdf.Free;
  end;
end;

Notate l'escaping raddoppiato nell'ultimo paragrafo: &amp; dentro la stringa Pascal è l'entità HTML per una e commerciale, che l'importatore riporta a un singolo carattere. Il resto del cascade, i budget e le callback delle risorse si comportano esattamente come descritto nella panoramica dell'import paged-media HTML5 di HotPDF; le colonne sono solo una modalità di layout in più dentro lo stesso profilo

Bilanciare le colonne con una ricerca binaria

HotPDF bilancia le colonne cercando la più piccola altezza di colonna alla quale un riempimento greedy colloca comunque ogni riga rimanente. Il passaggio greedy è semplice: si cammina nelle righe della colonna uno finché la riga successiva supererebbe l'altezza candidata, poi si continua nella colonna due, e così via. La ricerca parte con la riga singola più alta come limite inferiore e la somma di tutte le altezze di riga come limite superiore, dimezza l'intervallo a ogni round, e si ferma quando i limiti sono entro 0,05pt

Bilanciamento multi-colonna di HotPDF in Delphi: una ricerca binaria tra la riga singola più alta e la somma di tutte le altezze di riga trova la più piccola altezza di colonna il cui riempimento greedy colloca ogni riga, con limiti che convergono entro 0.05pt
Il predicato ogni-riga-entra è monotono rispetto all'altezza di colonna, quindi dimezzare l'intervallo converge sulla banda bilanciata più corta e il riempimento greedy finale consuma ogni riga senza orfani

Una ricerca binaria funziona solo se il predicato è monotono, e qui lo è: una colonna più alta non può mai contenere meno righe di una più bassa, quindi "ogni riga entra" si commuta da falso a vero esattamente una volta mentre l'altezza cresce. Quella proprietà garantisce la convergenza e garantisce che il riempimento greedy finale all'altezza trovata consumi ogni riga, invece di lasciare una riga orfana che innescherebbe una pagina extra spuria. Spiega anche un dettaglio del risultato: la banda bilanciata è corta quanto lo permettono i line box, non necessariamente a colonne uguali, perché le righe sono indivisibili e l'ultima colonna può chiudere una riga o due più in alto delle altre

Cosa accade quando le colonne attraversano un'interruzione di pagina?

Le colonne che non entrano nella pagina corrente vengono riempite in sequenza fino al fondo pagina, e solo l'ultimo frammento di pagina viene bilanciato. Questo è il comportamento che CSS Multi-column Layout Level 1 §7.1 prescrive per column-fill: balance nei contesti frammentati, ed è l'aspetto di una newsletter stampata: colonne piene su ogni pagina tranne l'ultima, dove il testo rimanente è diviso equamente invece di lasciare una colonna alta accanto a una vuota

Ogni frammento di pagina passa per lo stesso fragmenter in modo indipendente. Se le righe rimanenti entrano in N colonne dell'altezza disponibile, il frammento viene bilanciato; altrimenti ogni colonna si riempie fino al fondo pagina e il ciclo apre una nuova pagina. L'altezza naturale dell'intero contenitore, che il motore usa per le decisioni di break-inside: avoid e per la misura dell'estensione, è l'altezza della banda bilanciata con capacità illimitata, così un contenitore che starebbe in una pagina fresca come blocco bilanciato viene spostato lì intatto invece che affettato. Quando una singola riga è più alta di un'intera pagina, il motore rilassa il vincolo, colloca comunque la riga in cima a una pagina, e riporta un diagnostico hdkPaginationConstraint con soggetto multi-column

Paginazione multi-colonna di HotPDF per HTML a PDF in Delphi: i frammenti prima dell'ultima pagina riempiono ogni colonna fino al fondo pagina, l'ultimo frammento si bilancia attraverso le colonne, e column-span all divide un contenitore in bande bilanciate separatamente attorno a un elemento a tutta larghezza
Solo l'ultimo frammento di un contenitore multicol viene bilanciato; le pagine precedenti si riempiono in sequenza, e ogni frammento ripete bilanciamento, regole e paginazione in modo indipendente

column-fill: auto e box ad altezza fissa

Con column-fill: auto e un'height definita, HotPDF riempie le colonne in ordine, e ogni altezza di colonna è il minore tra lo spazio di pagina rimanente e l'altezza del contenitore meno il suo padding e i suoi bordi. Il minimo conta più di quanto sembri. Usare solo lo spazio di pagina rimanente lascerebbe alla prima colonna ingoiare ogni riga ogni volta che la pagina ha più spazio rimasto del box. Senza altezza definita, column-fill: auto ripiega sul bilanciamento, come fanno i browser, poiché un contenitore multicol senza vincoli non ha un'altezza di colonna da raggiungere; la prima implementazione perse quel fallback, e uno dei 27 controlli di regressione lo beccò

column-span: all e paragrafi annidati

HotPDF supporta column-span: all sui figli diretti di un contenitore multi-colonna, seguendo CSS Multi-column Layout Level 1 §6.1. Un figlio span di tutto divide il contenitore in segmenti: i figli prima di lui formano una banda di colonne, l'elemento span si renderizza su tutta la larghezza, e i figli dopo di lui aprono una nuova banda. Ogni banda si bilancia, riempie, righella e impagina da sé, così un'intestazione in mezzo a una sezione a tre colonne produce due bande bilanciate con un'intestazione a tutta larghezza in mezzo, che è il layout che la gente disegna davvero nei mockup

Arrivarci ha richiesto prima di correggere un problema meno visibile. Un contenitore come <div style="column-count:2"> con parecchi figli <p> renderizzava un tempo ogni paragrafo in una singola colonna impilata, perché ogni paragrafo veniva impaginato come proprio blocco di testo e la dichiarazione di colonna sul padre non si applicava mai. HotPDF ora raccoglie il testo dei figli blocco annidati in un solo flusso per il contenitore. Il raccoglitore deve inserire un'interruzione dura a ogni confine di blocco mentre lo fa; saltarla concatena l'ultima parola di un paragrafo con la prima parola del successivo sulla stessa riga, un bug facile da perdere quando il testo di test capita di finire con la punteggiatura

Quel flusso unito ha un perimetro deliberato. Si usa solo quando l'intero sottoalbero è flusso di testo semplice. Un contenitore che contiene controlli di form, immagini, tabelle, liste o <hr> in qualunque punto al suo interno mantiene il percorso blocco ordinario e si renderizza in una singola colonna, perché il raccoglitore di testo unito non può esprimere la loro geometria. Le tabelle in particolare hanno regole di paginazione proprie, trattate nell'articolo sulla gestione del rowspan e delle intestazioni di tabella ripetute in HotPDF, e mescolarle in una banda di colonne romperebbe quelle garanzie

Dove va la column rule, e come testarla

HotPDF disegna column-rule sulla mezzeria di ogni gap, al bordo destro di una colonna più metà del gap, dalla cima al fondo della banda su ogni frammento di pagina, come descrive CSS Multi-column Layout Level 1 §4. La larghezza predefinita è 1pt. Il colore predefinito è il colore del testo dell'elemento quando non è dichiarato o è currentColor. La forma breve column-rule senza parola chiave di stile viene trattata come solid, come comunemente si legge la forma breve border, mentre none e hidden sopprimono la riga

La geometria delle colonne è osservabile senza fare il parsing del PDF. L'evento OnLineLayout su THPDFHTMLImporter scatta una volta per riga disegnata con un record THPDFHTMLLineLayout, e poiché il motore disegna ogni colonna separatamente, il campo X di ogni riga è il bordo sinistro della sua colonna. Colonne adiacenti differiscono esattamente della larghezza di colonna più il gap, il che rende facile distinguere un gap di 12pt da uno di 20pt in un test di regressione

type
  TColumnProbe = class
  public
    Lines: TStringList;
    constructor Create;
    destructor Destroy; override;
    procedure LineDrawn(Sender: TObject; const Line: THPDFHTMLLineLayout);
  end;

constructor TColumnProbe.Create;
begin
  inherited Create;
  Lines := TStringList.Create;
end;

destructor TColumnProbe.Destroy;
begin
  Lines.Free;
  inherited;
end;

procedure TColumnProbe.LineDrawn(Sender: TObject;
  const Line: THPDFHTMLLineLayout);
begin
  // PageIndex è in base zero; X è il bordo sinistro della colonna della riga
  Lines.Add(Format('page=%d x=%.1f y=%.1f %s',
    [Line.PageIndex, Line.X, Line.Y, string(Line.Text)]));
end;

// Cablaggio, prima di RenderHTML5:
//   Probe := TColumnProbe.Create;
//   Importer.OnLineLayout := Probe.LineDrawn;

Due note pratiche dallo scrivere test contro questo evento. PageIndex è in base zero, che è la prima cosa che un'asserzione scritta a mano tende a sbagliare. E X include gli scostamenti di allineamento, quindi per testo centrato o allineato a destra confrontate le posizioni di colonna su fixture allineati a sinistra. La riga stessa non compare nell'evento; per asserirne la posizione, scannerizzate il content stream non compresso in cerca degli operatori verticali m e l

Limiti e trappole da conoscere

Qualche comportamento è un'approssimazione, e meglio conoscerlo prima di una revisione del layout che dopo. La spaziatura dei paragrafi dentro un flusso di colonna unito è approssimata da una riga dell'altezza di riga del contenitore a ogni confine di blocco, quindi i margini per paragrafo dentro una banda di colonne non si riproducono esattamente. Quando column-span: all divide un contenitore, ogni segmento ripete il padding del contenitore e il box di sfondo, il che tiene i segmenti visivamente continui ma non è un box unico nel senso CSS stretto. Ogni valore di column-rule-style diverso da none e hidden passa la validazione, ma la riga viene tracciata come linea solida

I valori non supportati non falliscono in silenzio. Impostare StrictCSS a True trasforma ogni diagnostico in un'eccezione EHPDFHTMLUnsupportedCSS, mentre OnDiagnostic lascia a un job batch scriverli nel log e continuare. Notate che la modalità strict si applica anche al diagnostico di paginazione, quindi un documento con una riga più alta di una pagina solleverà sotto StrictCSS invece di rilassare silenziosamente il vincolo

procedure TReportJob.HtmlDiagnostic(Sender: TObject;
  const Diagnostic: THPDFHTMLDiagnostic);
begin
  case Diagnostic.Kind of
    hdkUnsupportedProperty, hdkUnsupportedValue:
      FLog.Add('CSS ' + Diagnostic.Subject + ': ' + Diagnostic.MessageText);
    hdkPaginationConstraint:
      FLog.Add('Layout relaxed (' + Diagnostic.Subject + '): ' +
        Diagnostic.MessageText);
  else
    FLog.Add(Diagnostic.MessageText);
  end;
end;

// Scrittura template: fallite subito su tutto ciò che il motore ignorerebbe
Importer.StrictCSS := FValidatingTemplate;
Importer.OnDiagnostic := HtmlDiagnostic;

La modalità strict è l'impostazione giusta mentre un template è in scrittura, dato che un refuso come column-span: both viene rifiutato all'istante invece di lasciare in silenzio l'intestazione fluire dentro le colonne. In produzione, il log attraverso OnDiagnostic di solito è il compromesso migliore, perché una dichiarazione strana in HTML fornito dal cliente non dovrebbe costare l'intero documento. I diagnostici identici (stesso tipo, soggetto e valore) vengono riportati una volta finché stanno nell'elenco MaxDiagnostics, che vale 256 voci predefinite, così una dichiarazione che il cascade visita per centinaia di elementi non inonda il log

FAQ

HotPDF supporta la forma breve columns?

Sì. La forma breve columns accetta al massimo un conteggio intero positivo e una lunghezza di colonna in entrambi gli ordini, e auto azzera entrambe le longhand. I valori risolti seguono le stesse regole di conteggio e larghezza delle proprietà individuali

Un contenitore multi-colonna può contenere immagini o tabelle?

Può contenerle, ma HotPDF allora renderizza quel contenitore in una singola colonna. Il flusso a colonne si applica solo quando ogni discendente è flusso di testo semplice, perché immagini, tabelle, liste e controlli di form richiedono la propria geometria di box e regole di paginazione

Perché l'ultima pagina mostra colonne bilanciate mentre le precedenti sono piene?

È la regola CSS per column-fill: balance nei media impaginati: solo l'ultimo frammento viene bilanciato. Le pagine precedenti riempiono ogni colonna fino al fondo pagina così nessuno spazio si spreca a metà documento

Il layout multi-colonna, il profilo paged-media HTML5 e il resto della pipeline HTML-to-PDF fanno parte di HotPDF Component per Delphi e C++Builder, che copre anche disegno PDF diretto, form, cifratura ed estrazione di testo