Ein Datensatz besteht aus Zeilen und Spalten; eine PDF-Seite ist ein leeres Koordinatengitter ohne jedes Konzept von beidem. Diese Lücke zu überbrücken ist die gesamte Aufgabe hier. Es gibt keinen DrawTable-Aufruf in HotPDF, der einen Datensatz nimmt und ein formatiertes Gitter zurückgibt. Was man stattdessen bekommt, sind die Grundelemente, aus denen ein Gitter besteht: TextOut, um eine Zeichenkette an einen Punkt zu setzen, SetFont, um die Schriftart zu wählen, Rectangle und Fill, um einen Streifen zu schattieren, und MoveTo / LineTo / Stroke, um Linien zu zeichnen. Ein funktionierender Tabellenexporter ist die Disziplin, Zeilen-und-Spalten-Denken in explizite x- und y-Koordinaten umzuwandeln, und diese Koordinaten dann ehrlich zu halten, wenn die Daten den unteren Seitenrand übersteigen.
Das folgende Beispiel erstellt einen Kundenbericht, aber nichts im Zeichencode weiß oder kümmert sich darum, woher die Zeilen kommen. Das Original verwendete ein altes TTable; eine FireDAC-Abfrage, ein In-Memory-Dataset oder ein einfaches Array von Records speist unverändert dieselben Routinen. Was zählt, ist, dass man die Daten eine Zeile nach der anderen durchlaufen und vier Zeichenkettenfelder aus jeder lesen kann. Rendering von der Datenquelle zu trennen ermöglicht es, eine Seite zu ändern, ohne die andere zu stören.
Spaltengeometrie kommt zuerst
Bevor ein einziges Zeichen gezeichnet wird, entscheiden, wo jede Spalte sitzt. Eine Tabelle hat hier vier Spalten, also braucht sie vier linke Kanten und einen bekannten rechten Rand. Eine magische Zahl hart zu kodieren bei jedem TextOut-Aufruf, wie schnelle Beispiele es tun, ist genau das, was eine Tabelle schmerzhaft macht, sie später zu verbreitern. Die Kanten einmal benennen, in Punkten vom Ursprung unten links, und jeder Zeichenaufruf bezieht sich namentlich auf sie:
const
ColNo = 70; // linke Kante der "Nr."-Spalte
ColName = 110; // Firmenname
ColAddr = 300; // Straßenanschrift
ColCity = 480; // Stadt
RowLeft = 50; // Tabellenrahmen: linke Linie
RowRight = 570; // Tabellenrahmen: rechte Linie
RowStep = 20; // vertikaler Abstand zwischen Grundlinien
procedure PrintRow(Page: THPDFPage; Y: Single;
const ANo, AName, AAddr, ACity: string; Shaded: boolean);
begin
if Shaded then
begin
// Ein schattierter Streifen hinter der Zeile. Rectangle nimmt X, Y, Breite, Höhe.
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 verdienen sich ihren Platz. Der schattierte Streifen wird zuerst gezeichnet, dann der Text darüber, weil die Malreihenfolge die Z-Reihenfolge in PDF ist: das Rechteck nach dem Text zu füllen vergräbt die Zeile. Und die abwechselnde Schattierung ist keine Dekoration um ihrer selbst willen. In einem dichten Bericht ist es die günstigste Methode, das Auge davon abzuhalten, auf die falsche Zeile zu rutschen, weshalb die Schleife später bei jeder Zeile einen Boolean umdreht und ihn direkt in Shaded übergibt.
Die oben stehenden Spaltenpositionen sind fest, was für einen Bericht, dessen Schema man kontrolliert, ehrlich ist. Wenn die Daten variabel sind, messen statt raten. HotPDF stellt Textbreitenmessung am Seitenobjekt bereit, sodass die Produktionsversion von PrintRow den längsten erwarteten Wert jeder Spalte nehmen, ihn einmal bei der gewählten Schriftgröße messen und die linken Kanten aus diesen Breiten plus einem Abstand ableiten kann. Die Form der Routine ändert sich nicht; nur die Quelle der Konstanten ändert sich.
Die Kopfzeile, die Linien und ein einziger Ort, der sie verantwortet
Eine Tabelle, die auf einer Seite endet und auf der nächsten ohne Spaltenbeschriftungen fortgesetzt wird, ist unlesbar. Die Lösung besteht darin, die Kopfzeile als etwas zu behandeln, das man neu zeichnet, nicht als etwas, das man einmal zeichnet. Die Spaltentitel und die horizontalen Linien, die sie einrahmen, in eine einzige Routine stecken und diese Routine sowohl am Anfang als auch jedes Mal aufrufen, wenn eine neue Seite geöffnet wird. Weil Kopfzeile und Körper dieselben Spaltenkonstanten teilen, fluchten sie konstruktionsbedingt.
procedure DrawHeader(Page: THPDFPage; var Y: Single; PageNo: Integer);
begin
// Links: Quellenbezeichnung und Seitennummer. Rechts: Erstellungszeit.
Page.SetFont('Arial', [fsItalic], 10);
Page.TextOut(RowLeft, Y, 0, 'customer.db Seite ' + IntToStr(PageNo));
Page.TextOut(ColCity, Y, 0, DateTimeToStr(Now));
// Zwei horizontale Linien, die die Spaltentitel einrahmen.
Page.MoveTo(RowLeft, Y + 15);
Page.LineTo(RowRight, Y + 15);
Page.MoveTo(RowLeft, Y + 45);
Page.LineTo(RowRight, Y + 45);
Page.Stroke;
// Die Spaltentitel in einer fetten Schrift, damit sie als Überschriften lesbar sind.
Page.SetFont('Times New Roman', [fsBold], 12);
Page.SetRGBFillColor(clNavy);
PrintRow(Page, Y + 25, 'Nr.', 'Firma', 'Adresse', 'Stadt', False);
Page.SetRGBFillColor(clBlack);
Y := Y + RowStep + 45; // vor der ersten Körperzeile über die eingerahmte Kopfzeile vorrücken
end;
Man beachte, dass DrawHeader Y als Referenz nimmt und es vorwärts bewegt. Der Aufrufer muss sich nie merken, wie hoch die Kopfzeile ist; die Routine, die sie zeichnet, ist die Routine, die es weiß. Diese einzelne Besitzregel hält das Layout davon ab zu driften, wenn man später ein Logo oder eine Filtersummary in das Kopfzeilenband einfügt. Die Körperschleife bleibt ahnungslos. Sie zeichnet einfach weiter Zeilen von dort, wo Y gerade zeigt.
Die Linien selbst sind der Unterschied zwischen einer Liste und einer Tabelle. Vertikale Spaltentrenner sind dieselbe Idee, auf die X-Achse angewendet: ein MoveTo / LineTo / Stroke an jeder Spaltenkante, vom oberen Rahmen bis zum unteren Ende der letzten Zeile auf der Seite. Das Beispiel beschränkt sich auf horizontale Linien, um lesbar zu bleiben, aber der Produktionsschritt ist mechanisch, sobald die Spaltenkonstanten existieren.
Die Cursor-Schleife verantwortet den Seitenumbruch
Zeichnen ist die einfache Hälfte. Die Hälfte, die ein Spielzeug von einem Bericht trennt, ist die Paginierung: zu wissen, bevor man eine Zeile zeichnet, ob sie noch passt, und eine neue Seite mit einer neuen Kopfzeile zu beginnen, wenn sie es nicht tut. Diese Entscheidung gehört an genau einen Ort, 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;
// Berichtstitel, einmal, oben auf der ersten Seite.
Page.SetFont('Arial', [fsBold], 24);
Page.TextOut(200, 800, 0, 'Kundenbericht');
PageNo := 1;
Y := 760;
DrawHeader(Page, Y, PageNo);
Shaded := False;
CustomerTable.First;
while not CustomerTable.Eof do
begin
// Kein Platz mehr? Neue Seite öffnen und Kopfzeile dort wiederholen.
if Y < 60 then
begin
Pdf.AddPage;
Page := Pdf.CurrentPage; // AddPage setzt CurrentPage weiter
Inc(PageNo);
Y := 760;
DrawHeader(Page, Y, PageNo);
end;
Shaded := not Shaded;
Page.SetFont('Arial', [], 10); // SetFont muss auf jeder neuen Seite erneut gesetzt werden
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 Koordinatentatsachen treiben die gesamte Schleife an. PDF misst Y von unten links nach oben, sodass die Zeilen die Seite hinuntermarschieren, indem bei jeder Zeile RowStep von Y subtrahiert wird, und der Seitenrand-Test ausgelöst wird, wenn Y unter den unteren Rand fällt statt über einen oberen. Die Richtung falsch herum zu haben lässt die erste Zeile am unteren Rand herausfallen, während die Schleife glaubt, eine volle Seite Raum zu haben.
Die andere Tatsache überrascht fast jeden einmal. AddPage erstellt eine neue Seite und zeigt CurrentPage auf sie, aber trägt nichts über: nicht die Schrift, nicht die Füllfarbe, nicht die Position. Deshalb wird Page nach jedem AddPage aus CurrentPage neu gelesen, und deshalb wird SetFont vor den Körperzeilen erneut gesetzt. Das Neu-Lesen weglassen und man zeichnet weiter auf die Seite, die man gerade verlassen hat; die Schrift weglassen und die neue Seite rendert in welchem Standardwert der Betrachter auf zurückfällt.
Die Fälle, die einen Tabellenexporter brechen
Die meisten Tabellenfehler tauchen nicht beim glücklichen Pfad von einigen Dutzend ordentlichen Zeilen auf. Sie leben an den Rändern, und die Ränder sind günstig zu testen, sobald 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, welches man vor dem Ausliefern haben möchte.
- Die Zeile, die genau auf der Grenze landet. Einen Bericht erstellen, dessen letzte Zeile einen Schritt über dem Rand sitzt, dann einen, dessen nächste Zeile einen Schritt darunter ist. Off-by-one-Paginierung versteckt sich, bis die Daten genau die falsche Länge haben.
- Zu lange Werte. Ein Firmenname, der breiter als seine Spalte ist, läuft in die nächste hinein. Das Feld messen und eine Richtlinie festlegen: in eine zweite Zeile umbrechen, abschneiden oder mit einer Auslassung kürzen. Schweigen ist keine Richtlinie.
- Null-Felder. Ein Null direkt in
TextOutzu lesen kann als der wörtliche TextNulloder als Leerzeichen erscheinen, je nachdem wie man ihn konvertiert. Das Rendering bewusst wählen statt die Variant-Konvertierung für einen entscheiden zu lassen.
Das Ergebnis durch mehr als einen Betrachter laufen lassen, bevor man es als fertig betrachtet. Schriftartenersatz und Beschneidung verhalten sich unterschiedlich bei verschiedenen Renderern, und eine Tabelle, die in einem PDF-Reader ordentlich aussieht, kann in einem anderen eine falsch ausgerichtete Spalte oder eine abgeschnittene Stadt zeigen. Bestätigen, dass die wiederholte Kopfzeile, die Zeilenschattierung und die Ränder den Wechsel überstehen, und dass Seitenzahlen nach einem Datengrenzwechsel durchgehend bleiben.
Das Gitter selbst zu zeichnen statt auf einen visuellen Berichtsdesigner zu setzen ist mehr Code, und der Kompromiss lohnt es sich, klar zu benennen: man verantwortet jede Koordinate, was genau das ist, was man für serverseitige Stapeljobs, Rechnungen und Prüfungsexporte möchte, die auf jedem Rechner identisch rendern müssen, und genau der Overhead, den man lieber vermeiden würde für eine einmalige interne Auflisting. Für ersteres zahlt die Kontrolle sich aus beim ersten Mal, wenn ein Bericht in der Produktion genauso aussehen muss wie auf dem eigenen Schreibtisch.
Die obigen Linien und schattierten Streifen stützen sich auf dieselben Vektor- und Farbgrundformen, die in der Canvas-Zeichnungsübersicht behandelt werden, wenn man die Aufrufe Rectangle, MoveTo und LineTo zuerst für sich allein behandeln möchte. Die hier verwendeten Zeichengrundformen sind Teil des HotPDF Component für Delphi und C++Builder.