Technischer Artikel

Rowspan-Raster und wiederholte Tabellen-Header in HotPDF

HotPDF rendert HTML-Tabellen über sein HTML5-Paged-Media-Profil mit einem echten Belegungsraster für rowspan und colspan, gemessenen Zeilenhöhen statt Schätzungen nach Zeichenzahl und Headerzeilen, die auf jeder Fortsetzungsseite wiederholt werden. In zwei Situationen verweigert es die Wiederholung eines Headers, und die vorab zu kennen ist billiger, als später eine duplizierte Zelle zu debuggen

Die Dokumentenklasse, die das erzwingt, ist die, die jedes Reporting-Team irgendwann ausliefert: eine Rechnung oder ein Compliance-Bericht, wo die HTML-Datei die maßgebliche Quelle ist, die Tabelle über vier Seiten läuft und der Header auf jeder davon lesbar sein muss. Alles unter einem echten Tabellenlayout erzeugt die zwei Fehler, die Leser sofort bemerken: ein Header, der einmal auf Seite eins auftaucht, und Zeilen, deren Höhen aus Zeichenzahlen geraten wurden

Warum zog die Tabellenfähigkeit in den HTML-Renderer?

Weil die Alternative Rich Text verliert, und Rich Text ist ja der Grund, warum der Inhalt überhaupt HTML ist. Der naheliegende Plan sieht nach Wiederverwendung aus: HotPDF hat bereits ein Layout-DOM-Tabellenobjekt mit ordentlichem Raster, also brücke man den HTML-Parser hinein und bekomme Spanning geschenkt. Das Problem ist, womit dieses Tabellenobjekt zeichnet. Seine Zellen tragen Text und einen Style, und sein Zeichnungspfad gibt schlichten Text aus — alles, was das HTML tatsächlich außer Schrift und Farbe enthielt, Links, Hochstellungen, inline Größenänderungen, Farben pro Run, ist weg, bis es die Seite erreicht

Die Richtung, die den Kontakt mit echten Dokumenten überlebt, ist die umgekehrte. Verlagert man die Fähigkeiten der Tabellen-Engine — Belegungsraster, echte Messung, Header-Wiederholung und Spaltengewichtung — in den HTML-Renderer und lässt das Rich-Text-Rendering dort, wo es bereits funktioniert. Das ist eine größere Änderung als die Brücke, und es ist die Änderung, die einen Hyperlink in einer Tabellenzelle einen Hyperlink sein lässt

Rowspan ohne Union-Find

Spanning-Zellen erzeugen atomare Zeilengruppen, aber der Abschluss über diese Gruppen braucht keine allgemeine Disjoint-Set-Struktur, denn die Belegung ist immer ein zusammenhängendes Intervall. Eine Zelle mit rowspan="3", die in Zeile K beginnt, belegt die Zeilen K bis K+2 und nichts sonst, also reduziert sich die Gruppeninformation auf eine Endmarkierung pro Zeile

Der Algorithmus ist Intention in zwei Zeilen. Wenn man eine Spanning-Zelle platziert, die bei K beginnt und bei E endet, notiert man GroupEnd[K] := Max(GroupEnd[K], E). Dann läuft man die Zeilen einmal rückwärts ab und wendet G[R] := G[G[R]] an, was jedes Zeilenende rückwärts durch überlappende Spans propagiert und den transitiven Abschluss in einem einzigen Durchgang liefert. Heraus kommt für jede Zeile die letzte Zeile, die mit ihr auf derselben Seite bleiben muss — genau das, was der Paginierungsschritt braucht, um zu entscheiden, wo ein Umbruch fallen darf

Die Höhenverteilung ist die andere Hälfte. Braucht eine Spanning-Zelle mehr vertikalen Raum, als die Zeilen, die sie abdeckt, derzeit liefern, geht der Überschuss an die letzte Zeile des Spans, nicht gleichmäßig verteilt auf alle. Spanning-Zellen verarbeitet man, nachdem die gewöhnlichen Zeilenhöhen feststehen, und stockt dann die letzte Zeile jedes Spans auf. Den Überschuss gleichmäßig zu verteilen klingt fairer und erzeugt sichtbar falsche Ausgabe: Zeilen mit nur kurzen Einzeiler-Zellen werden aufgebläht, weil drei Zeilen weiter oben zufällig eine hohe Zelle stand, die damit nichts zu tun hat

Ein HotPDF-HTML-Tabellenraster, in dem eine Zelle mit rowspan 3 ab Zeile 2 die Zeilen 2 bis 4 als ein einziges atomares Rechteck belegt, daneben die Gruppenendwerte G(R) pro Zeile aus einem einzigen Rückwärtslauf, der zeigt, dass die Zeilen 2, 3 und 4 an dieselbe Seite gebunden sind
Spanning-Belegung ist immer ein zusammenhängendes Intervall, also ersetzen Endmarkierungen pro Zeile und ein einziger Rückwärtslauf die Union-Find-Struktur und verraten der Paginierung exakt, wo ein Umbruch fallen darf
var
  Pdf: THotPDF;
  Importer: THPDFHTMLImporter;
  Stats: THPDFHTMLImportStatistics;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'audit-report.pdf';
    Pdf.BeginDoc;
    Importer := THPDFHTMLImporter.Create(Pdf);
    try
      Importer.Margin := 48;
      Importer.BaseFontName := 'Arial';
      Importer.BaseFontSize := 10;
      Importer.MaxDOMNodes := 200000;
      Importer.MaxLayoutOperations := 2000000;
      if Importer.RenderHTML5(SourceHtml, PrintStyleSheet) then
      begin
        Stats := Importer.Statistics;
        Writeln('tables ', Stats.TableCount,
                '  page breaks ', Stats.PageBreakCount);
      end;
    finally
      Importer.Free;
    end;
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

RenderHTML5 nimmt als zweites Argument ein optionales Author-Style-Sheet entgegen, und dorthin gehören die Print-Regeln. Das Screen-Style-Sheet gehört nicht hinein. Das Profil ist versioniert, und HTML5ProfileMilestones meldet, welche Capability-Gruppen der aktuelle Build implementiert — himParserCascade, himPagedLayout, himTablesForms und himBoundedResources — sodass eine Anwendung bewusst degradieren kann, statt eine Lücke erst in Produktion zu entdecken

Messung und Zeichnung müssen exakt übereinstimmen

Eine Zeilenhöhe ist nur korrekt, wenn der Code, der umbrochene Zeilen misst, sie nach derselben Regel umbricht wie der Code, der sie zeichnet. Das klingt selbstverständlich und ist die mit Abstand häufigste Quelle von Tabellen, deren Rahmen nicht mit ihrem Inhalt abschließen. HotPDF misst mit einem Greedy-Zeilenzähler, und dieser Zähler muss in drei bestimmten Punkten zur Wrap-Semantik des Rich-Text-Ausgabepfads passen: Er bricht nur an Leerzeichen, er teilt nie ein Wort, und ein Wort, das breiter als die Spalte ist, bekommt eine eigene Zeile

Die zweite Anforderung ist der Font. Die Messung muss mit dem Font der Zelle laufen, über SetFont mit dem tatsächlichen Namen, dem gesetzten Style und der Größe vor dem Aufruf der Breitenfunktion — nicht mit dem Font, der gerade zufällig aktiv war. Fetter Text ist bei gleicher Größe routinemäßig mehr als zehn Prozent breiter als normaler, und das reicht, um aus einer Dreizeilen-Zelle eine Vierzeilen-Zelle zu machen. Eine Tabelle, deren Headerzellen fett sind und deren Body-Zellen nicht, gemessen mit einem einzigen Font, liegt genau in den Zeilen falsch, die Leser zuerst ansehen

Das richtig hinzubekommen ändert, was man in einem Test behaupten kann. Der beobachtbare Effekt genauer Messung ist der Zeilenabstand, nicht die Glyphenzahl: Eine Einzeiler-Zeile ist rund 20 Points hoch, während eine Schätzung nach Zeichenzahl für denselben Inhalt zwei Zeilen und etwa 35 vorhersagt. Prüfen Sie den vertikalen Abstand zwischen Zeilen. Und denken Sie daran, dass Y im PDF-User-Space nach oben wächst: Ein Header über einer Body-Zeile bedeutet also, dass der Header-Y-Wert der größere ist — das Gegenteil von dem, was der Bildschirmkoordinaten-Instinkt hinschreibt

Wann verweigert HotPDF die Header-Wiederholung?

In zwei Fällen, die beide sichtbar falsche Ausgabe erzeugen würden, liefe es weiter. Der erste ist ein Headerblock mit einer Spanning-Zelle, die über den Header hinaus in Body-Zeilen reicht. Der Header würde diese Zellinhalte ein zweites Mal an einer Position zeichnen, wo sie nicht mehr hingehören, also wird der Header einmal gezeichnet und die Tabelle läuft ohne ihn weiter. Der zweite ist ein Header, der höher als 90 Prozent der nutzbaren Seitenhöhe ist — Wiederholung ließe fast keinen Raum für Daten, und die Tabelle käme nicht voran

Der HotPDF-Entscheidungsfluss für die Wiederholung von HTML-Tabellen-Headern über Seitenumbrüche: Ein Header, dessen rowspan in Body-Zeilen übergreift, wird einmal gezeichnet, ein Header höher als 90 Prozent der nutzbaren Seitenhöhe wird einmal gezeichnet, jeder andere Header wiederholt auf jeder Fortsetzungsseite
Zwei Verweigerungen sind Absicht: Einen Header zu wiederholen, der eine übergreifende Body-Zelle besitzt oder die meiste Seite füllt, würde Inhalte dorthin zeichnen, wo sie nicht mehr hingehören, oder keinen Raum für Daten lassen

Beide Verweigerungen sind bewusst und designbedingt still, weil die Alternative schlimmer wäre. Wiederholt sich Ihr Header nicht, obwohl Sie es erwarteten, prüfen Sie das Markup auf ein rowspan, das die thead-Grenze überschreitet, bevor Sie der Engine misstrauen. Dieses eine Markup-Muster erklärt den Großteil der Überraschungen

// Spaltengewichte stammen aus dem Markup, das Print-Style-Sheet ist also
// der Ort, sie zu steuern. Breiten gelten als Gewichte, nicht als Pixel
const
  PrintStyleSheet =
    'table { width: 100%; }' +
    'thead th { font-weight: bold; background: #eee; }' +
    'td.amount { text-align: right; }';

// Eine Headerzeile mit einem rowspan, der in den Body übergreift,
// unterdrückt die Header-Wiederholung. Spans in einer Section halten:
//   <thead><tr><th rowspan="2">Item</th>...</tr></thead>  ok
//   <tr><th rowspan="3">Item</th>...  übergreift in tbody, keine Wiederholung

Spaltenbreiten verhalten sich wie Gewichte statt absoluter Maße, und genau dieses Verhalten hält eine Tabelle benutzbar, wenn der Inhalt nicht zur Schätzung des Autors passt. Eine mit 30 Prozent deklarierte Spalte bekommt rund 30 Prozent der verfügbaren Breite, aber die Verteilung respektiert die Mindestbreite, die jede Spalte tatsächlich braucht, sodass eine schmale Spalte mit einem langen, unteilbaren Token die Tabellenbox nicht lautlos überläuft

Wo das in einer Dokumenten-Pipeline passt

Die Tabellenarbeit sitzt im breiteren Paged-Media-Profil, und die in dem HTML5-Paged-Media-Importpfad beschriebenen Paginierungsregeln, Ressourcenbudgets und die CSS-Behandlung gelten unverändert für Dokumente mit Tabellen. Beginnen Ihre Daten nicht als HTML, umgeht der Weg des direkten Aufbaus in Tabellen direkt ins PDF bauen die Parsing-Schicht komplett und gibt dasselbe Rasterverhalten über eine API. Und weil die Zeilenhöhe letztlich davon abhängt, wo Zeilen umbrechen, ist die Messdiskussion in Textjustierung und Zeilenumbruch das Begleitstück für alle, die dichte tabellarische Ausgabe justieren

Die wiederverwendbare Lektion hier handelt gar nicht von Tabellen. Wenn ein neues Subsystem eine Fähigkeit braucht, die ein altes bereits hat, fragt man, welche der beiden das Ding besitzt, das am schwersten neu zu implementieren ist. Rasterarithmetik sind ein paar Dutzend Zeilen und zieht leicht um. Rich-Text-Rendering mit inline Links, Hochstellungen und Styling pro Run nicht — also zog das Raster um und der Text blieb. HotPDF liefert beide Wege als Teil der HotPDF Delphi PDF component aus, sodass die Wahl zwischen HTML-Eingabe und direktem Aufbau eine Projektentscheidung ist, keine Bibliotheksentscheidung