Technický článek

CSS multi-column layout v HTML-to-PDF s HotPDF Delphi

HotPDF Component vysází CSS multi-column text, když THPDFHTMLImporter.RenderHTML5 konvertuje HTML na PDF v Delphi. Paged HTML engine řeší column-count a column-width vůči content boxu, vyvažuje poslední fragment každého multicol kontejneru binárním hledáním, plní dřívější stránky sloupec po sloupci až na dno stránky a podporuje column-fill, column-span: all a column-rule

Obvyklý důvod to chtít je nudný a skutečný: dvousloupcový produktový bulletin, příloha s obchodními podmínkami sázená do tří úzkých sloupců, nebo PDF s release notes, které má vypadat jako tištěný newsletter, který marketing už nakreslil v HTML. Bez podpory sloupců stackuje importér každý odstavec v jedné široké šířce řádku, délka řádku se zdvojnásobí a dokument se čte jako log soubor. S ní řídí tu samou sadu stylů, která řídí náhled v prohlížeči, i PDF a těžkou částí se stane stránkování, o kterém se prohlížeče na obrazovce skoro nikdy nemusí zamýšlet

Jak HotPDF rozhoduje, kolik sloupců použít?

HotPDF řeší počet sloupců stejnými pravidly jako pseudoalgoritmus v CSS Multi-column Layout Module Level 1 §3.4, aplikovaný na šířku content boxu W po odečtení paddingu a okrajů. Deklarované column-count se bere, jak je. Když je dané jen column-width, počítá engine N = Trunc((W + gap) / (column-width + gap)) s podlahou jednoho sloupce. Když jsou přítomny obojí, výsledkem je menší z obou, takže deklarovaný počet může odvozený počet zastropovat, ale nikdy ho nenafouknout

Konkrétní případ: content box 483 pt (A4 s postranními okraji 56 pt, zaokrouhleno) a column-width: 140pt; column-gap: 12pt dává Trunc(495 / 152) = 3 sloupce, každý široký (483 - 2 * 12) / 3 = 153pt. Přidání column-count: 2 do téhož pravidla ho sešlápne na dva sloupce, protože vyhrává minimum. Klíčové slovo gap normal se řeší na 1em velikosti fontu kontejneru a tohle řešení probíhá v čase layoutu, ne při parsování, takže font-size deklarované po column-gap ve stejném pravidle pořád dá správnou mezeru

Řešení počtu CSS multi-column v HotPDF pro HTML na PDF v Delphi: column-count se bere jako deklarované, column-width odvozuje N = Trunc((W + gap) / (column-width + gap)), menší vyhrává, když jsou nastaveny oba, a jakýkoli sloupec pod 12pt se sbalí na jediný sloupec
Engine aplikuje CSS pseudoalgoritmus na content box, zastropuje odvozený počet deklarovaným a přidává pojistku podlahy 12pt, aby nedůvěryhodné šablony nemohly vytvořit tisíce mikroskopických sloupců

Jedna pojistka ve specifikaci není a je tam úmyslně. Po spočtení šířky sloupce se jakákoli hodnota pod 12pt sbalí zpět na jediný sloupec. Sada stylů říkající column-count: 9999, ať omylem, nebo proto, že přišla z nedůvěryhodné šablony, by jinak vytvořila tisíce sloupců se zápornými nebo mikroskopickými šířkami a lámáčem řádků, který se snaží vměstnat jeden glyf na řádek. Fallback na jeden sloupec udrží výstup čitelný a cenu layoutu ohraničenou. Nula ani záporné počty se tak daleko nedostanou: validátor přijímá pro column-count jen auto nebo kladný integer a column-width a column-gap berou auto respektive normal, nebo délku v bodech nebo em

Vykreslení multi-column HTML sekce do PDF v Delphi

Multi-column výstup nepotřebuje žádný přepínač na importéru. THPDFHTMLImporter se vytváří s dokumentem THotPDF, do kterého kreslí, BeginDoc už musel proběhnout a column vlastnosti přichází normální kaskádou, buď v markupu, nebo v autorské sadě stylů předané jako druhý argument 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;

Všimněte si zdvojeného escapování v posledním odstavci: &amp; uvnitř Pascal řetězce je HTML entita pro ampersand, kterou importér dekóduje zpět na jediný znak. Zbytek kaskády, rozpočty a resource callbacky se chovají přesně tak, jak popisuje přehled importu paged media HotPDF HTML5; sloupce jsou jen další layout režim uvnitř téhož profilu

Vyvažování sloupců binárním hledáním

HotPDF vyvažuje sloupce hledáním nejmenší výšky sloupce, při které chamtivé plnění pořád umístí každý zbývající řádek. Chamtivý průchod je prostý: jděte řádky do sloupce jedna, dokud by další řádek nepřekročil kandidátní výšku, pak pokračujte ve sloupci dva a tak dále. Hledání začíná s nejvyšším jednotlivým řádkem jako spodní hranicí a součtem výšek všech řádků jako horní hranicí, každé kolo interval rozpůlí a skončí, jakmile jsou hranice v 0,05 pt

Vyvažování multi-column v HotPDF v Delphi: binární hledání mezi nejvyšším jednotlivým řádkem a součtem výšek všech řádků najde nejmenší výšku sloupce, jejíž chamtivé plnění umístí každý řádek, s hranicemi sbíhajícími v 0,05 pt
Predikát všechno-se-vejde je ve výšce sloupce monotónní, takže půlení intervalu sbíhá k nejkratšímu vyváženému pásu a finální chamtivé plnění spotřebuje každý řádek bez sirotků

Binární hledání funguje jen tehdy, když je predikát monotónní, a tady je: vyšší sloupec nemůže nikdy držet méně řádků než nižší, takže „všechny řádky se vejdou" se překlápí z nepravdy na pravdu přesně jednou, jakmile výška roste. Ta vlastnost garantuje sběhnutí i to, že finální chamtivé plnění na nalezené výšce spotřebuje každý řádek, místo aby nechalo sirotčí řádek, který by spustil falešnou extra stránku. Vysvětluje to i detail výsledku: vyvážený pás je tak krátký, jak dovolují line boxy, ne nutně rovné sloupce, protože řádky jsou nedělitelné a poslední sloupec může skončit o řádek či dva výš než ostatní

Co se stane, když sloupce přecházejí přes zlom stránky?

Sloupce, které se nevejdou na aktuální stránku, se plní postupně až na dno stránky a jen poslední fragment stránky se vyváží. To je chování, které CSS Multi-column Layout Level 1 §7.1 předepisuje pro column-fill: balance ve fragmentovaných kontextech, a přesně tak vypadá tištěný newsletter: plné sloupce na každé stránce kromě poslední, kde se zbývající text rozdělí rovnoměrně místo toho, aby zůstal jeden vysoký sloupec vedle prázdného

Každý fragment stránky projde stejným fragmentérem nezávisle. Když se zbývající řádky vejdou do N sloupců dostupné výšky, fragment se vyváží; jinak se každý sloupec naplní na dno stránky a smyčka začne novou stránku. Přirozená výška celého kontejneru, kterou engine používá pro rozhodnutí break-inside: avoid a měření rozsahu, je výška vyváženého pásu s neomezenou kapacitou, takže kontejner, který by se vešel na čerstvou stránku jako vyvážený blok, se tam přemístí vcelku místo naříznutí. Když je jediný řádek vyšší než celá stránka, povolí engine omezení, umístí řádek stejně nahoře na stránce a nahlásí diagnostiku hdkPaginationConstraint s subjektem multi-column

Stránkování multi-column v HotPDF pro HTML na PDF v Delphi: fragmenty před poslední stránkou naplní každý sloupec na dno stránky, finální fragment se vyváží napříč sloupci a column-span all rozdělí kontejner na samostatně vyvážené pásy kolem elementu přes celou šířku
Vyvažuje se jen poslední fragment multicol kontejneru; dřívější stránky se plní postupně a každý fragment opakuje vyvážení, pravidla a stránkování samostatně

column-fill: auto a boxy s pevnou výškou

S column-fill: auto a definitivní height plní HotPDF sloupce v pořadí a výška každého sloupce je menší ze zbývajícího prostoru stránky a výšky kontejneru mínus jeho padding a okraje. To minimum záleží víc, než vypadá. Použití jen zbývajícího prostoru stránky by dovolilo prvnímu sloupci spolknout každý řádek, kdykoli má stránka víc místa, než box potřebuje. Bez definitivní výšky sáhne column-fill: auto po vyvažování, jako prohlížeče, protože neomezený multicol kontejner nemá žádnou výšku sloupce, do které by se plnilo; první implementace ten fallback přehlédla a jedna z 27 regresních kontrol ho chytla

column-span: all a vnořené odstavce

HotPDF podporuje column-span: all na přímých dětech multi-column kontejneru, podle CSS Multi-column Layout Level 1 §6.1. Spanující dítě rozdělí kontejner na segmenty: děti před ním tvoří jeden sloupcový pás, spanující element se vykreslí přes celou šířku a děti za ním zahájí nový pás. Každý pás se vyváží, naplní, ohrání a stránkuje samostatně, takže nadpis uprostřed třísloupcové sekce vytvoří dva vyvážené pásy s nadpisem přes celou šířku mezi nimi, což je layout, který lidé skutečně kreslí v mockupech

Dostat se tam vyžadoval nejdřív opravit méně viditelný problém. Kontejner jako <div style="column-count:2"> s několika dětmi <p> vykresloval každý odstavec v jednom stackovaném sloupci, protože každý odstavec se vysázel jako vlastní textový blok a column deklarace na rodiči se nikdy neaplikovala. HotPDF teď sbírá text vnořených blokových dětí do jednoho toku pro kontejner. Sbírač musí při tom vložit tvrdý zlom na každé blokové hranici; vynechá-li ho, slepí poslední slovo jednoho odstavce s prvním slovem dalšího na téže řádce, bug snadno přehlédnutelný, když testovací text zrovna končí interpunkcí

Ten sloučený tok má záměrný rozsah. Používá se jen tehdy, když je celý podstrom prostým textovým tokem. Kontejner, který drží formulářové ovládací prvky, obrázky, tabulky, seznamy nebo <hr> kdekoli uvnitř, zůstává u obyčejné blokové cesty a vykresluje se v jednom sloupci, protože sloučený textový sběrač nedokáže vyjádřit jejich geometrii. Tabulky mají obzvlášť vlastní stránkovací pravidla, popsané v článku o obsluze rowspan a opakovaných hlavičkách tabulek v HotPDF, a smíchání do sloupcového pásu by ta garantace rozbilo

Kam jde column rule a jak ho testovat

HotPDF kreslí column-rule na střední linii každé mezery, na pravé hraně sloupce plus polovinu mezery, od vršku po spodek pásu na každém fragmentu stránky, jak popisuje CSS Multi-column Layout Level 1 §4. Šířka se defaultně bere 1 pt. Barva se bere jako barva textu elementu, když není deklarovaná nebo je currentColor. Zkratka column-rule bez klíčového slova stylu se bere jako solid, na shodu s tím, jak se běžně čte zkratka border, zatímco none a hidden pravidlo potlačí

Geometrii sloupců lze pozorovat bez parsování PDF. Událost OnLineLayout na THPDFHTMLImporter odpálí jednou za nakreslenou řádku se záznamem THPDFHTMLLineLayout a protože engine kreslí každý sloupec odděleně, pole X každé řádky je levá hrana jejího sloupce. Sousední sloupce se liší přesně o šířku sloupce plus mezeru, což usnadní rozlišení mezery 12 pt od mezery 20 pt v regresním testu

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 má nulový základ; X je levá hrana sloupce řádky
  Lines.Add(Format('page=%d x=%.1f y=%.1f %s',
    [Line.PageIndex, Line.X, Line.Y, string(Line.Text)]));
end;

// Zapojení, před RenderHTML5:
//   Probe := TColumnProbe.Create;
//   Importer.OnLineLayout := Probe.LineDrawn;

Dvě praktické poznámky z psaní testů proti této události. PageIndex má nulový základ, což je první věc, kterou ručně psaný assertion sblbně. A X zahrnuje posuny zarovnání, takže pro centrovaný nebo vpravo zarovnaný text srovnávejte pozice sloupců na left-aligned fixture. Samotné pravidlo se v události neobjeví; pro assert pozice scanujte nekomprimovaný content stream na vertikální operátory m a l

Limity a pasti, které stojí za znalost

Pár chování je aproximací a lépe je znát před layoutovou revizí než po ní. Odstavcové prostranství uvnitř sloučeného sloupcového toku se aproximuje jednou řádkou výšky řádku kontejneru na každé blokové hranici, takže marže po odstavcích uvnitř sloupcového pásu se nereprodukuje přesně. Když column-span: all rozdělí kontejner, každý segment opakuje padding kontejneru a background box, což udrží segmenty vizuálně plynulé, ale není to jediný box ve striktním smyslu CSS. Každá hodnota column-rule-style jiná než none a hidden projde validací, ale pravidlo se šrafuje jako plná linie

Nepodporované hodnoty neselhávají potichu. Nastavení StrictCSS na True změní každou diagnostiku na výjimku EHPDFHTMLUnsupportedCSS, zatímco OnDiagnostic nechá dávkovou úlohu je zalogovat a pokračovat. Pozor, striktní režim platí i pro stránkovací diagnostiku, takže dokument s řádkou vyšší než stránka pod StrictCSS vyhodí výjimku místo tichého povolení omezení

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;

// Autoring šablon: selhej rychle na čemkoli, co by engine ignoroval
Importer.StrictCSS := FValidatingTemplate;
Importer.OnDiagnostic := HtmlDiagnostic;

Striktní režim je správné nastavení, dokud se šablona píše, protože překlep jako column-span: both se odmítne hned místo tichého nechání nadpisu téct uvnitř sloupců. V produkci bývá lepší obchodem logovat přes OnDiagnostic, protože jedna zvláštní deklarace v HTML od zákazníka nemá stát celý dokument. Identické diagnostiky (stejný druh, subjekt a hodnota) se hlásí jednou, dokud se vejdou do seznamu MaxDiagnostics, který má defaultně 256 položek, takže deklarace, kterou kaskáda navštíví u stovek elementů, nezahlcí log

FAQ

Podporuje HotPDF zkratku columns?

Ano. Zkratka columns přijme nejvýše jednu kladnou celočíselnou hodnotu počtu a jednu délku sloupce v libovolném pořadí a auto resetuje obě longhand vlastnosti. Vyřešené hodnoty následují stejná pravidla počtu a šířky jako jednotlivé vlastnosti

Může multi-column kontejner držet obrázky nebo tabulky?

Držet je může, ale HotPDF pak ten kontejner vykreslí v jednom sloupci. Sloupcový tok se aplikuje jen tehdy, když je každý potomek prostým textovým tokem, protože obrázky, tabulky, seznamy a formulářové ovládací prvky potřebují vlastní geometrii boxů a stránkovací pravidla

Proč poslední stránka ukazuje vyvážené sloupce, zatímco dřívější jsou plné?

To je CSS pravidlo pro column-fill: balance v paged media: vyvažuje se jen poslední fragment. Dřívější stránky naplní každý sloupec na dno stránky, takže se nesplýtvá prostorem uprostřed dokumentu

Multi-column layout, profil HTML5 paged media i zbytek pipeline HTML-na-PDF jsou součástí HotPDF Component for Delphi and C++Builder, který pokrývá i přímé kreslení PDF, formuláře, šifrování a extrakci textu