Technischer Artikel

Rendern einer Datentabelle in PDF in Delphi mit HotPDF

Ein Datensatz besteht aus Zeilen und Spalten; eine PDF-Seite ist ein leeres Koordinatennetz, das von beidem nichts weiß. Diese Lücke zu schließen, ist hier die eigentliche Aufgabe. Es gibt keinen DrawTable-Aufruf in HotPDF, der einen Datensatz entgegennimmt und Ihnen ein formatiertes Raster liefert. Was Sie stattdessen erhalten, sind die Grundelemente, aus denen ein Raster besteht: TextOut, um eine Zeichenfolge an einem Punkt zu platzieren, SetFont, um ihre Schriftart auszuwählen, Rectangle und Fill, um ein Band zu schattieren, sowie MoveTo / LineTo / Stroke, um Linien zu zeichnen. Ein funktionierender Tabellenexporteur erfordert die Disziplin, das Zeilen-und-Spalten-Denken in explizite X- und Y-Koordinaten zu übersetzen und diese Koordinaten dann beizubehalten, wenn die Daten über den unteren Rand der Seite hinausgehen

Das folgende Beispiel gibt Kundendatensätze aus, aber nichts im Zeichencode weiß oder interessiert sich dafür, woher die Zeilen kommen. Das Original verwendete ein veraltetes TTable; eine FireDAC-Abfrage, ein In-Memory-Datensatz oder ein einfaches Array von Datensätzen versorgt dieselben Routinen unverändert. Wichtig ist, dass Sie die Daten zeilenweise durchlaufen und vier Zeichenfolgenfelder aus jeder lesen können. Halten Sie das Rendern von der Datenquelle getrennt, und Sie können beide Seiten ändern, ohne die andere zu stören

Die Spaltengeometrie kommt zuerst

Bevor ein einziges Zeichen gezeichnet wird, legen Sie fest, wo sich jede Spalte befindet. Eine Tabelle hat hier vier Spalten, benötigt also vier linke Ränder und einen bekannten rechten Rand. Eine magische Zahl bei jedem TextOut-Aufruf fest zu kodieren, wie es bei schnellen Beispielen oft der Fall ist, ist genau das, was es so schmerzhaft macht, eine Tabelle später zu verbreitern. Benennen Sie die Ränder einmalig in Punkten vom Ursprung unten links, und jeder Zeichenaufruf bezieht sich auf sie mit ihrem Namen:

const
  ColNo   = 70;    // left edge of the "No." column
  ColName = 110;   // company name
  ColAddr = 300;   // street address
  ColCity = 480;   // city
  RowLeft = 50;    // table frame: left rule
  RowRight = 570;  // table frame: right rule
  RowStep = 20;    // vertical distance between baselines

procedure PrintRow(Page: THPDFPage; Y: Single;
  const ANo, AName, AAddr, ACity: string; Shaded: boolean);
begin
  if Shaded then
  begin
    // A shaded band behind the row. Rectangle takes X, Y, Width, Height.
    Page.SetRGBFillColor($00FFF3DD);
    Page.Rectangle(RowLeft, Y - 4, RowRight - RowLeft, RowStep);
    Page.Fill;
    Page.SetRGBFillColor(clBlack);
  end;
  Page.TextOut(ColNo,   Y, 0, ANo);
  Page.TextOut(ColName, Y, 0, AName);
  Page.TextOut(ColAddr, Y, 0, AAddr);
  Page.TextOut(ColCity, Y, 0, ACity);
end;

Zwei Details zahlen sich hier aus. Das schattierte Band wird zuerst gezeichnet, dann der Text darüber, da die Malreihenfolge in PDF die Z-Reihenfolge ist: Wenn Sie das Rechteck nach dem Text füllen, verdecken Sie die Zeile. Und der abwechselnde Farbton ist keine bloße Dekoration. Bei einem dichten Bericht ist es der kostengünstigste Weg, um zu verhindern, dass das Auge in die falsche Zeile rutscht, weshalb die Schleife später bei jeder Zeile einen booleschen Wert umschaltet und ihn direkt in Shaded übergibt

Die obigen Spaltenpositionen sind fest, was für einen Bericht, dessen Schema Sie kontrollieren, ehrlich ist. Wenn die Daten variabel sind, messen Sie, anstatt zu raten. HotPDF stellt die Textbreitenmessung für das Seitenobjekt bereit, sodass die Produktionsversion von PrintRow den längsten erwarteten Wert in jeder Spalte verwenden, ihn einmal in der gewählten Schriftgröße messen und die linken Ränder aus diesen Breiten plus einem Zwischenraum ableiten kann. Die Form der Routine ändert sich nicht; nur die Quelle der Konstanten tut dies

Die Kopfzeile, die Regeln und ein einziger Ort, der sie besitzt

Eine Tabelle, die von einer Seite rollt und auf der nächsten ohne Spaltenbeschriftungen fortgesetzt wird, ist unlesbar. Die Lösung besteht darin, die Kopfzeile als etwas zu behandeln, das Sie neu zeichnen, und nicht als etwas, das Sie einmal zeichnen. Fassen Sie die Spaltentitel und die horizontalen Linien, die sie umrahmen, in einer einzigen Routine zusammen und rufen Sie diese Routine sowohl am Anfang als auch jedes Mal auf, wenn Sie eine neue Seite öffnen. Da Kopfzeile und Textkörper dieselben Spaltenkonstanten aufweisen, richten sie sich konstruktionsbedingt aneinander aus

procedure DrawHeader(Page: THPDFPage; var Y: Single; PageNo: Integer);
begin
  // Left: source label and page number. Right: generation time.
  Page.SetFont('Arial', [fsItalic], 10);
  Page.TextOut(RowLeft, Y, 0, 'customer.db   Page ' + IntToStr(PageNo));
  Page.TextOut(ColCity, Y, 0, DateTimeToStr(Now));

  // Two horizontal rules that box the column titles.
  Page.MoveTo(RowLeft, Y + 15);
  Page.LineTo(RowRight, Y + 15);
  Page.MoveTo(RowLeft, Y + 45);
  Page.LineTo(RowRight, Y + 45);
  Page.Stroke;

  // The column titles, in a heavier face so they read as headings.
  Page.SetFont('Times New Roman', [fsBold], 12);
  Page.SetRGBFillColor(clNavy);
  PrintRow(Page, Y + 25, 'No.', 'Company', 'Address', 'City', False);
  Page.SetRGBFillColor(clBlack);

  Y := Y + RowStep + 45;  // advance past the boxed header before the first body row
end;

Beachten Sie, dass DrawHeader Y als Referenz akzeptiert und es nach vorne bewegt. Der Aufrufer muss sich nie merken, wie hoch der Header ist; die Routine, die ihn zeichnet, weiß es. Diese einzelne Besitzregel verhindert, dass das Layout abdriftet, wenn Sie später ein Logo oder eine Filterzusammenfassung zum Header-Band hinzufügen. Die Textkörperschleife bleibt unwissend. Sie zeichnet einfach weiterhin Zeilen von der Stelle aus, auf die Y gerade zeigt

Die Linien selbst sind der Unterschied zwischen einer Liste und einer Tabelle. Vertikale Spaltentrennzeichen sind die gleiche Idee, die auf die X-Achse angewendet wird: ein MoveTo / LineTo / Stroke an jedem Spaltenrand, der von der obersten Linie zum unteren Rand der letzten Zeile auf der Seite verläuft. Das Beispiel beschränkt sich auf horizontale Linien, um lesbar zu bleiben, aber der Produktionsschritt ist mechanisch, sobald die Spaltenkonstanten existieren

Die Cursorschleife besitzt den Seitenumbruch

Zeichnen ist die einfache Hälfte. Die Hälfte, die ein Spielzeug von einem Bericht trennt, ist die Paginierung: Zu wissen, bevor Sie eine Zeile zeichnen, ob sie noch passt, und eine neue Seite mit einer neuen Kopfzeile zu beginnen, wenn dies nicht der Fall ist. Diese Entscheidung gehört an genau eine Stelle, nämlich in die Schleife, die die Daten durchläuft, und nirgendwo sonst

var
  Pdf: THotPDF;
  Page: THPDFPage;
  Y: Single;
  PageNo: Integer;
  Shaded: boolean;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'CustomerReport.pdf';
    Pdf.BeginDoc;
    Page := Pdf.CurrentPage;

    // Report title, once, at the top of the first page.
    Page.SetFont('Arial', [fsBold], 24);
    Page.TextOut(200, 800, 0, 'Customer Report');

    PageNo := 1;
    Y := 760;
    DrawHeader(Page, Y, PageNo);
    Shaded := False;

    CustomerTable.First;
    while not CustomerTable.Eof do
    begin
      // Out of room? Open a new page and repeat the header there.
      if Y < 60 then
      begin
        Pdf.AddPage;
        Page := Pdf.CurrentPage;   // AddPage moves CurrentPage forward
        Inc(PageNo);
        Y := 760;
        DrawHeader(Page, Y, PageNo);
      end;

      Shaded := not Shaded;
      Page.SetFont('Arial', [], 10);   // SetFont must be reissued on every new page
      PrintRow(Page, Y,
        VarToStr(CustomerTable['CustNo']),
        VarToStr(CustomerTable['Company']),
        VarToStr(CustomerTable['Addr1']),
        VarToStr(CustomerTable['City']),
        Shaded);

      Y := Y - RowStep;
      CustomerTable.Next;
    end;

    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Zwei Koordinatenfakten treiben die gesamte Schleife an. PDF misst Y von der linken unteren Ecke nach oben, sodass die Zeilen nach unten auf der Seite wandern, indem jedes Mal RowStep von Y subtrahiert wird, und der Seitenfülltest wird ausgelöst, wenn Y unter den unteren Rand fällt und nicht über einen oberen Rand. Machen Sie die Richtung rückgängig, und Ihre erste Zeile wird am unteren Rand gedruckt, während die Schleife davon ausgeht, dass sie eine ganze Seite Platz hat

Die andere Tatsache erwischt fast jeden einmal. AddPage erstellt eine neue Seite und leitet CurrentPage darauf um, übernimmt aber nichts: weder die Schriftart noch die Füllfarbe noch die Position. Aus diesem Grund wird Page nach jedem AddPage erneut von CurrentPage gelesen, und aus diesem Grund wird SetFont vor den Textzeilen erneut ausgegeben. Überspringen Sie das erneute Lesen, und Sie zeichnen weiter auf der Seite, die Sie gerade verlassen haben; Überspringen Sie die Schriftart, und die neue Seite wird in der Standardeinstellung gerendert, auf die der Viewer zurückfällt

Die Fälle, die einen Tabellenexporteur zerstören

Die meisten Tabellenfehler treten nicht auf dem glücklichen Weg von ein paar Dutzend aufgeräumten Zeilen auf. Sie leben an den Rändern, und die Ränder sind billig zu testen, wenn man weiß, wo sie sind

  • Leere Datensätze. Eine Schleife über null Zeilen erzeugt eine Seite mit einer Kopfzeile und nichts darunter, was zumindest beabsichtigt aussieht. Eine leere Seite ohne Kopfzeile sieht wie ein Fehler aus. Entscheiden Sie, was Sie möchten, bevor Sie ausliefern
  • Die Zeile, die genau auf der Grenze landet. Generieren Sie einen Bericht, dessen letzte Zeile einen Schritt über dem Rand liegt, und dann einen Bericht, dessen nächste Zeile einen Schritt darunter liegt. Eine Off-by-One-Paginierung verbirgt sich, bis die Daten genau die falsche Länge haben
  • Zu lange Werte. Ein Firmenname, der breiter ist als seine Spalte, läuft in die nächste über. Messen Sie das Feld und entscheiden Sie sich für eine Richtlinie: Umbruch in eine zweite Zeile, Abschneiden oder Abschneiden mit Auslassungspunkten. Schweigen ist keine Richtlinie
  • Nullfelder. Das Lesen eines Nullwerts direkt in TextOut kann als wörtlicher Text Null oder als Leerzeichen angezeigt werden, je nachdem, wie Sie ihn konvertieren. Wählen Sie das Rendering bewusst, anstatt zuzulassen, dass die Variantenkonvertierung für Sie auswählt

Führen Sie das Ergebnis durch mehr als einen Viewer, bevor Sie es als fertig bezeichnen. Schriftartersetzung und Clipping verhalten sich bei verschiedenen Renderern unterschiedlich, und eine Tabelle, die in einem PDF-Reader quadratisch aussieht, kann in einem anderen eine falsch ausgerichtete Spalte oder eine abgeschnittene Stadt anzeigen. Vergewissern Sie sich, dass die wiederholte Kopfzeile, die Zeilenschattierung und die Ränder die Verschiebung überstehen und dass die Seitenzahlen kontinuierlich bleiben, nachdem die Daten eine Grenze überschritten haben

Das Zeichnen des Rasters selbst, anstatt sich auf einen visuellen Berichtsdesigner zu stützen, erfordert mehr Code, und der Kompromiss ist es wert, klar benannt zu werden: Ihnen gehört jede Koordinate, was genau das ist, was Sie für serverseitige Batchjobs, Rechnungen und Audit-Exporte wollen, die auf jedem Rechner identisch gerendert werden müssen, und genau der Overhead, den Sie für eine einmalige interne Auflistung lieber vermeiden würden. Für Ersteres macht sich die Kontrolle das erste Mal bezahlt, wenn ein Bericht in der Produktion genauso aussehen muss wie auf Ihrem Schreibtisch

Die obigen Regeln und schattierten Bänder stützen sich auf dieselben Vektor- und Farbprimitiven, die in der Schritt-für-Schritt-Anleitung zum Zeichnen auf der Leinwand behandelt werden, falls Sie die Aufrufe Rectangle, MoveTo und LineTo zunächst einzeln behandeln möchten. Die hier verwendeten Zeichengrundelemente sind Teil der HotPDF Component für Delphi und C++Builder