Technischer Artikel

CSS Multi-Column Layout in HTML-zu-PDF mit HotPDF Delphi

HotPDF Component setzt CSS-Multi-Column-Text, wenn THPDFHTMLImporter.RenderHTML5 HTML in Delphi zu PDF konvertiert. Die gepagte HTML-Engine löst column-count und column-width gegen die Content-Box auf, balanciert das letzte Fragment jedes Multicol-Containers per binärer Suche aus, füllt frühere Seiten Spalte für Spalte bis zum Seitenende und unterstützt column-fill, column-span: all und column-rule

Der übliche Grund für dieses Bedürfnis ist langweilig und real: ein zweispaltiges Produktbulletin, ein AGB-Anhang in drei schmalen Spalten oder ein Release-Notes-PDF, das aussehen soll wie der gedruckte Newsletter, den das Marketing-Team bereits in HTML entworfen hat. Ohne Spaltenunterstützung stapelt der Importer jeden Absatz in einem breiten Maß, die Zeilenlänge verdoppelt sich, und das Dokument liest sich wie eine Log-Datei. Mit ihm treibt dasselbe Stylesheet, das die Browser-Vorschau antreibt, das PDF an, und der schwere Teil wird die Paginierung — worüber Browser auf dem Bildschirm meist nie nachdenken müssen

Wie entscheidet HotPDF, wie viele Spalten verwendet werden?

HotPDF löst die Spaltenzahl mit denselben Regeln wie der Pseudoalgorithmus in CSS Multi-column Layout Module Level 1 §3.4, angewendet auf die Content-Box-Breite W nach Abzug von Padding und Borders. Ein deklarierter column-count wird übernommen, wie er ist. Wenn nur column-width gegeben ist, berechnet die Engine N = Trunc((W + gap) / (column-width + gap)) mit einem Minimum von einer Spalte. Sind beide vorhanden, ist das Ergebnis das kleinere der beiden, die deklarierte Zahl kann die abgeleitete Zahl also begrenzen, aber nie aufblähen

Ein konkreter Fall: eine 483-pt-Content-Box (A4 mit 56-pt-Seitenrändern, gerundet) und column-width: 140pt; column-gap: 12pt ergeben Trunc(495 / 152) = 3 Spalten, jede (483 - 2 * 12) / 3 = 153pt breit. Ergänzt man column-count: 2 in derselben Regel, fällt es auf zwei Spalten, denn das Minimum gewinnt. Das Gap-Schlüsselwort normal löst zu 1em der Container-Schriftgröße auf, und diese Auflösung passiert zur Layout-Zeit statt zur Parse-Zeit, also erzeugt auch ein font-size, das in derselben Regel nach column-gap deklariert wird, das richtige Gap

HotPDF-Auflösung der CSS-Multi-Column-Spaltenzahl für HTML zu PDF in Delphi: column-count wird wie deklariert verwendet, column-width leitet N = Trunc((W + gap) / (column-width + gap)) ab, das Kleinere gewinnt, wenn beide gesetzt sind, und jede Spalte unter 12pt kollabiert zu einer einzelnen Spalte
Die Engine wendet den CSS-Pseudoalgorithmus auf die Content-Box an, begrenzt die abgeleitete Zahl mit einer deklarierten und ergänzt eine 12-pt-Mindestgrenze, sodass nicht vertrauenswürdige Templates keine Tausenden mikroskopischer Spalten erzeugen können

Eine Schutzschwelle steht nicht in der Spezifikation, und sie ist mit Absicht da. Nachdem die Spaltenbreite berechnet ist, kollabiert jeder Wert unter 12pt den Container zurück auf eine einzelne Spalte. Ein Stylesheet, das column-count: 9999 sagt — ob aus Versehen oder weil es aus einem nicht vertrauenswürdigen Template stammt —, würde sonst Tausende von Spalten mit negativen oder mikroskopischen Breiten und einen Zeilenumbruch erzeugen, der versucht, eine Glyphe pro Zeile unterzubringen. Der Fallback auf eine Spalte hält die Ausgabe lesbar und die Layoutkosten begrenzt. Null oder negative Zahlen kommen gar nicht so weit: Der Validator akzeptiert für column-count nur auto oder eine positive Ganzzahl, und column-width und column-gap nehmen auto bzw. normal oder eine Länge in Punkten oder em

Eine Multi-Column-HTML-Section nach PDF in Delphi rendern

Multi-Column-Ausgabe braucht keinen Schalter am Importer. THPDFHTMLImporter wird mit dem THotPDF-Dokument erzeugt, in das er zeichnet, BeginDoc muss bereits gelaufen sein, und die Spalteneigenschaften kommen über den normalen Cascade herein — entweder im Markup oder im Author-Stylesheet, das als zweites Argument von RenderHTML5 übergeben wird

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;

Beachten Sie die doppelte Escapierung im letzten Absatz: &amp; im Pascal-String ist die HTML-Entity für ein kaufmännisches Und, die der Importer zurück zu einem einzelnen Zeichen dekodiert. Der Rest des Cascade, der Budgets und der Ressourcen-Callbacks verhält sich exakt wie im Überblick über den HotPDF-HTML5-Paged-Media-Import beschrieben; Spalten sind nur ein weiterer Layout-Modus innerhalb desselben Profils

Spalten per binärer Suche ausbalancieren

HotPDF balanciert Spalten aus, indem es nach der kleinsten Spaltenhöhe sucht, bei der eine gierige Füllung noch jede verbleibende Zeile unterbringt. Der gierige Durchlauf ist simpel: Die Zeilen wandern in Spalte eins, bis die nächste Zeile die Kandidatenhöhe überschreiten würde, dann geht es in Spalte zwei weiter, und so fort. Die Suche startet mit der höchsten einzelnen Zeile als unterer Grenze und der Summe aller Zeilenhöhen als oberer Grenze, halbiert das Intervall in jeder Runde und stoppt, sobald die Grenzen innerhalb von 0,05pt liegen

HotPDF-Multi-Column-Balancing in Delphi: eine binäre Suche zwischen der höchsten einzelnen Zeile und der Summe aller Zeilenhöhen findet die kleinste Spaltenhöhe, deren gierige Füllung jede Zeile unterbringt, mit Grenzen, die innerhalb von 0,05pt konvergieren
Das Prädikat Jede-Zeile-passt ist monoton in der Spaltenhöhe, also konvergiert das Halbieren des Intervalls auf das kürzeste ausgewogene Band, und die finale gierige Füllung verbraucht jede Zeile ohne Waisen

Eine binäre Suche funktioniert nur, wenn das Prädikat monoton ist, und das ist es hier: Eine höhere Spalte kann nie weniger Zeilen halten als eine niedrigere, also kippt "jede Zeile passt" genau einmal von falsch zu wahr, während die Höhe wächst. Diese Eigenschaft garantiert die Konvergenz und garantiert, dass die finale gierige Füllung bei der gefundenen Höhe jede Zeile verbraucht, statt eine verwaiste Zeile zu lassen, die eine Schein-Extraseite auslösen würde. Sie erklärt auch ein Detail des Ergebnisses: Das ausgewogene Band ist so kurz, wie es die Zeilenboxen erlauben, nicht notwendigerweise gleich hohe Spalten, denn Zeilen sind unteilbar, und die letzte Spalte darf ein bis zwei Zeilen höher enden als die anderen

Was passiert, wenn Spalten einen Seitenumbruch kreuzen?

Spalten, die nicht auf die aktuelle Seite passen, werden sequenziell bis zum Seitenende gefüllt, und nur das finale Seitenfragment wird ausbalanciert. Das ist das Verhalten, das CSS Multi-column Layout Level 1 §7.1 für column-fill: balance in fragmentierten Kontexten vorschreibt, und so sieht ein gedruckter Newsletter aus: volle Spalten auf jeder Seite außer der letzten, wo der verbleibende Text gleichmäßig verteilt wird, statt eine hohe Spalte neben einer leeren zu lassen

Jedes Seitenfragment durchläuft denselben Fragmentierer unabhängig. Passen die verbleibenden Zeilen in N Spalten der verfügbaren Höhe, wird das Fragment ausbalanciert; andernfalls füllt sich jede Spalte bis zum Seitenende, und die Schleife beginnt eine neue Seite. Die natürliche Höhe des gesamten Containers, die die Engine für break-inside: avoid-Entscheidungen und Extent-Messung verwendet, ist die Bandhöhe des Ausgleichs bei unbegrenzter Kapazität, also wird ein Container, der auf einer frischen Seite als ausgewogener Block passen würde, intakt dorthin verschoben statt geschnitten. Ist eine einzelne Zeile höher als eine ganze Seite, lockert die Engine die Einschränkung, platziert die Zeile trotzdem am Seitenanfang und meldet eine hdkPaginationConstraint-Diagnose mit dem Subjekt multi-column

HotPDF-Multi-Column-Paginierung für HTML zu PDF in Delphi: Fragmente vor der letzten Seite füllen jede Spalte bis zum Seitenende, das finale Fragment balanciert über die Spalten aus, und column-span all teilt einen Container in getrennt ausbalancierte Bänder um ein Element in voller Breite
Nur das finale Fragment eines Multicol-Containers wird ausbalanciert; frühere Seiten füllen sequenziell, und jedes Fragment wiederholt Ausgleich, Regeln und Paginierung unabhängig

column-fill: auto und Boxen mit fester Höhe

Mit column-fill: auto und einer bestimmten height füllt HotPDF die Spalten der Reihe nach, und jede Spaltenhöhe ist das Kleinere von verbleibendem Seitenraum und Containerhöhe abzüglich Padding und Borders. Das Minimum zählt mehr, als es aussieht. Nur den verbleibenden Seitenraum zu verwenden, ließe die erste Spalte jede Zeile schlucken, sobald die Seite mehr Raum übrig hat als die Box. Ohne bestimmte Höhe fällt column-fill: auto auf den Ausgleich zurück, wie Browser es tun, denn ein unbeschränkter Multicol-Container hat keine Spaltenhöhe, auf die man füllen könnte; die erste Implementierung verpasste diesen Fallback, und eine der 27 Regressionsprüfungen erwischte ihn

column-span: all und verschachtelte Absätze

HotPDF unterstützt column-span: all bei direkten Kindern eines Multi-Column-Containers, gemäß CSS Multi-column Layout Level 1 §6.1. Ein spannenendes Kind teilt den Container in Segmente: Die Kinder davor bilden ein Spaltenband, das spannenende Element rendert über die volle Breite, und die Kinder danach starten ein neues Band. Jedes Band wird auf eigene Faust ausbalanciert, gefüllt, mit Regeln versehen und gepagt, also erzeugt eine Überschrift mitten in einer Dreispalten-Section zwei ausgewogene Bänder mit einer Überschrift in voller Breite dazwischen — das Layout, das Leute tatsächlich in Mockups zeichnen

Dorthin zu kommen erforderte zuerst die Behebung eines weniger sichtbaren Problems. Ein Container wie <div style="column-count:2"> mit mehreren <p>-Kindern renderte früher jeden Absatz in einer einzigen gestapelten Spalte, weil jeder Absatz als eigener Textblock gesetzt wurde und die Spaltendeklaration am Parent nie griff. HotPDF sammelt jetzt den Text verschachtelter Blockkinder in einen Fluss für den Container. Der Sammler muss dabei an jeder Blockgrenze einen harten Umbruch einfügen; überspringt man das, verkettet sich das letzte Wort des einen Absatzes mit dem ersten Wort des nächsten in derselben Zeile — ein Bug, der leicht entgeht, wenn der Testtext zufällig mit Satzzeichen endet

Dieser zusammengeführte Fluss hat einen bewussten Geltungsbereich. Er wird nur verwendet, wenn der ganze Teilbaum reiner Textfluss ist. Ein Container, der irgendwo in ihm Formularsteuerelemente, Bilder, Tabellen, Listen oder <hr> hält, behält die gewöhnliche Blockroute und rendert in einer einzelnen Spalte, denn der zusammengeführte Textsammler kann ihre Geometrie nicht ausdrücken. Tabellen haben insbesondere eigene Paginierungsregeln, behandelt im Artikel zu Rowspan-Behandlung und wiederholten Tabellenköpfen in HotPDF, und sie in ein Spaltenband zu vermischen würde diese Garantien brechen

Wohin die Spaltenregel kommt, und wie man sie testet

HotPDF zeichnet column-rule auf der Mittellinie jedes Gaps, an der rechten Kante einer Spalte plus halbem Gap, vom oberen bis zum unteren Rand des Bands auf jedem Seitenfragment, wie CSS Multi-column Layout Level 1 §4 es beschreibt. Die Breite hat 1pt als Voreinstellung. Die Farbe fällt auf die Element-Textfarbe zurück, wenn sie nicht deklariert ist oder currentColor lautet. Die column-rule-Kurzschreibweise ohne Stil-Schlüsselwort wird als solid behandelt, passend dazu, wie die border-Kurzschreibweise üblicherweise gelesen wird, während none und hidden die Regel unterdrücken

Die Spaltengeometrie ist beobachtbar, ohne das PDF zu parsen. Das OnLineLayout-Event an THPDFHTMLImporter feuert einmal pro gezeichneter Zeile mit einem THPDFHTMLLineLayout-Record, und weil die Engine jede Spalte getrennt zeichnet, ist das X-Feld jeder Zeile die linke Kante ihrer Spalte. Benachbarte Spalten unterscheiden sich exakt um Spaltenbreite plus Gap, womit sich ein 12-pt-Gap leicht von einem 20-pt-Gap in einem Regressionstest unterscheiden lässt

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 ist nullbasiert; X ist die linke Kante der Spalte der Zeile
  Lines.Add(Format('page=%d x=%.1f y=%.1f %s',
    [Line.PageIndex, Line.X, Line.Y, string(Line.Text)]));
end;

// Verdrahtung, vor RenderHTML5:
//   Probe := TColumnProbe.Create;
//   Importer.OnLineLayout := Probe.LineDrawn;

Zwei praktische Hinweise aus dem Schreiben von Tests gegen dieses Event. PageIndex ist nullbasiert — das erste, was eine handgeschriebene Assertion falsch zu machen pflegt. Und X schließt Ausrichtungsverschiebungen ein, vergleichen Sie also Spaltenpositionen bei zentriertem oder rechtsbündigem Text an linksbündigen Fixtures. Die Regel selbst erscheint nicht im Event; um ihre Position zu prüfen, durchsuchen Sie den unkomprimierten Content-Stream nach den vertikalen m- und l-Operatoren

Grenzen und Fallstricke, die man kennen sollte

Ein paar Verhaltensweisen sind Näherungen, und die kennt man vor einem Layout-Review besser als danach. Absatzabstände in einem zusammengeführten Spaltenfluss werden durch eine Zeile der Container-Zeilenhöhe an jeder Blockgrenze genähert, also werden Absatzabstände innerhalb eines Spaltenbands nicht exakt reproduziert. Wenn column-span: all einen Container teilt, wiederholt jedes Segment Container-Padding und Hintergrundbox, was die Segmente visuell kontinuierlich hält, aber streng im CSS-Sinn keine einzelne Box ist. Jeder column-rule-style-Wert außer none und hidden passiert die Validierung, aber die Regel wird als durchgezogene Linie gestrichelt

Nicht unterstützte Werte scheitern nicht still. StrictCSS auf True zu setzen, verwandelt jede Diagnose in eine EHPDFHTMLUnsupportedCSS-Exception, während OnDiagnostic einen Batch-Job sie loggen und weiterlaufen lässt. Beachten Sie, dass der strikte Modus auch für die Paginierungsdiagnose gilt, also wirft ein Dokument mit einer Zeile, die höher als eine Seite ist, unter StrictCSS, statt die Einschränkung still zu lockern

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;

// Template-Authoring: bei allem, was die Engine ignorieren würde, früh scheitern
Importer.StrictCSS := FValidatingTemplate;
Importer.OnDiagnostic := HtmlDiagnostic;

Der strikte Modus ist die richtige Einstellung, während ein Template geschrieben wird, denn ein Tippfehler wie column-span: both wird sofort abgewiesen, statt still zu lassen, dass die Überschrift innerhalb der Spalten durchläuft. In Produktion ist das Loggen über OnDiagnostic meist der bessere Deal, denn eine seltsame Deklaration in vom Kunden geliefertem HTML soll nicht das ganze Dokument kosten. Identische Diagnosen (gleiche Art, Subjekt und Wert) werden einmal gemeldet, solange sie in die MaxDiagnostics-Liste passen, die auf 256 Einträge voreingestellt ist, sodass eine Deklaration, die der Cascade für hunderte Elemente besucht, das Log nicht flutet

FAQ

Unterstützt HotPDF die columns-Kurzschreibweise?

Ja. Die columns-Kurzschreibweise akzeptiert höchstens eine positive Ganzzahl als Zahl und eine Spaltenlänge, in beliebiger Reihenfolge, und auto setzt beide Longhands zurück. Die aufgelösten Werte folgen denselben Zahlen- und Breitenregeln wie die einzelnen Eigenschaften

Kann ein Multi-Column-Container Bilder oder Tabellen halten?

Halten kann er sie, aber HotPDF rendert diesen Container dann in einer einzelnen Spalte. Der Spaltenfluss wird nur angewendet, wenn jeder Nachfahre reiner Textfluss ist, denn Bilder, Tabellen, Listen und Formularsteuerelemente brauchen ihre eigene Boxgeometrie und eigene Paginierungsregeln

Warum zeigt die letzte Seite ausgewogene Spalten, während frühere Seiten voll sind?

Das ist die CSS-Regel für column-fill: balance in gepagten Medien: Nur das finale Fragment wird ausbalanciert. Frühere Seiten füllen jede Spalte bis zum Seitenende, damit mitten im Dokument kein Platz verschenkt wird

Multi-Column-Layout, das HTML5-Paged-Media-Profil und der Rest der HTML-zu-PDF-Pipeline sind Teil des HotPDF Component for Delphi and C++Builder, das auch direktes PDF-Zeichnen, Formulare, Verschlüsselung und Textextraktion abdeckt