Fachartikel

Diagramme in einem PDF mit HotPDF-Grundelementen zeichnen

HotPDF hat kein Diagrammobjekt. Es gibt kein TPDFChart, kein AddBarSeries, nichts, das ein Array von Zahlen nimmt und ein gerendertes Diagramm zurückgibt. Was es stattdessen bietet, ist ein Seiten-Canvas mit demselben niedrigstufigen Vokabular, das jedes PDF-Zeichenmodell verwendet: Rechtecke, Linien, Kreise, Füllungen, Striche und Text, der an exakten Koordinaten platziert wird. Ein Diagramm in einem HotPDF-Dokument ist daher etwas, das man selbst aufbaut, nicht etwas, das man anfordert. Das klingt nach mehr Arbeit als es ist. Einmal hat man die Koordinatenrechnung geschrieben, und ein Balkendiagramm ist eine Schleife über Rechtecke, ein Liniendiagramm ist eine Polylinie, und ein Tortendiagramm ist ein Fächer aus Bögen, und man kontrolliert jedes Pixel des Ergebnisses.

Das ist wichtig, weil die Alternative, zu der man zuerst greift, nämlich ein Bildschirm-Diagramm-Steuerelement als Bitmap zu rastern und das Bild in die Seite einzufügen, einem ein Diagramm gibt, das an die Bildschirmauflösung gebunden ist, beim Drucken verschwommen wirkt und die Datei aufbläht. Das Diagramm mit HotPDFs Vektorgrundformen zu zeichnen hält die Ausgabe bei jedem Zoom und jeder Druckauflösung scharf, weil die Balken und Achsen echte PDF-Pfadoperatoren sind, keine Pixel. Der Preis ist, dass man das Layout selbst verantwortet. Die Mechanik reduziert sich auf wenige Bewegungen: den einen Koordinaten-Flip, der jeden überrascht, ein ausgearbeitetes Balkendiagramm, den Polylinie-Trick für Liniendiagramme und die Bogenrechnung für Tortenscheiben.

Das Einzige, was schwierig ist, ist das Koordinatensystem

Bildschirmgrafik legt den Ursprung oben links mit Y wachsend nach unten. PDF macht das Gegenteil. Der Ursprung sitzt in der unteren linken Ecke der Seite und Y wächst nach oben, gemessen in Punkten (1/72 Zoll). Jeder Zeichenaufruf in HotPDF, TextOut, Rectangle, MoveTo, LineTo, Circle, verwendet diese Konvention mit Ursprung unten links und Y nach oben. Überträgt man Bildschirmgrafik-Instinkte, zeichnet das erste Diagramm verkehrt herum und läuft am unteren Seitenrand heraus.

Die eigentliche Arbeit in jedem Diagramm ist daher eine einzige Abbildung: einen Datenwert in eine Y-Koordinate umwandeln, die Y-aufwärts respektiert. Ein Plot-Rechteck festlegen, vier Zahlen für den Bereich, in dem das Diagramm lebt, dann den kleinsten Wert in den Daten auf die Unterkante und den größten auf die Oberkante abbilden. Für einen Balken mit Wert V auf einer Skala von 0 bis MaxValue ist die Oberkante des Balkens PlotBottom + (V / MaxValue) * PlotHeight, und der Balken wächst von PlotBottom nach oben. Diesen einen Ausdruck korrekt hinzubekommen, und alles andere ist Buchführung. Der Helfer unten hält die Plot-Geometrie und führt die Umrechnung durch, sodass der Zeichencode nie zweimal rohe Arithmetik anfasst:

type
  TPlotArea = record
    Left, Bottom, Width, Height: Single;  // PDF-Punkte, Ursprung unten links
    MaxValue: Single;                     // Oberkante der Werteskala
  end;

// Einen Datenwert auf seine Y-Koordinate innerhalb des Plots abbilden, Y wächst nach oben.
function ValueToY(const Plot: TPlotArea; V: Single): Single;
begin
  Result := Plot.Bottom + (V / Plot.MaxValue) * Plot.Height;
end;

Eine Ermessensentscheidung steckt in MaxValue. Setzt man es auf den exakten größten Datenpunkt, berührt der höchste Balken die Oberkante des Plots und sieht abgeschnitten aus. Auf eine runde Zahl oberhalb des Maximums aufrunden, etwa das nächste Vielfache von 10 oder 100, sodass das Diagramm Luft nach oben hat und die Gitterlinienbeschriftungen als runde Zahlen lesbar sind statt als das zufällige Spitzenwert der Daten.

Ein Balkendiagramm ist eine Schleife über Rechtecke

Mit der Abbildung festgelegt schreibt sich ein Balkendiagramm fast von selbst. Die Plotbreite in einen Slot pro Kategorie aufteilen, eine Lücke zwischen den Balken lassen, damit sie sich nicht berühren, und jeden Balken als gefülltes Rechteck zeichnen, dessen Höhe aus ValueToY kommt. HotPDFs Rectangle nimmt die untere linke Ecke plus Breite und Höhe, was genau zu einem Balken passt, der von der Grundlinie nach oben wächst. Zuerst die Füllfarbe setzen, den Pfad ablegen, dann Fill aufrufen, um ihn zu malen. Die Kategoriebeschriftung gehört unterhalb der Grundlinie, der Wert über den Balken:

procedure DrawBarChart(Page: THPDFPage; const Plot: TPlotArea;
  const Values: array of Single; const Labels: array of string);
var
  I, Count: Integer;
  SlotW, BarW, BarX, BarH, Gap: Single;
begin
  Count := Length(Values);
  SlotW := Plot.Width / Count;
  Gap := SlotW * 0.25;          // Viertel-Slot-Abstand auf jeder Seite
  BarW := SlotW - Gap;

  // Grundlinie (die X-Achse) entlang der Unterseite des Plots.
  Page.SetLineWidth(1.0);
  Page.MoveTo(Plot.Left, Plot.Bottom);
  Page.LineTo(Plot.Left + Plot.Width, Plot.Bottom);
  Page.Stroke;

  Page.SetFont('Arial', [], 9);
  for I := 0 to Count - 1 do
  begin
    BarX := Plot.Left + I * SlotW + Gap / 2;
    BarH := ValueToY(Plot, Values[I]) - Plot.Bottom;

    Page.SetRGBFillColor(RGB(56, 110, 219));
    Page.Rectangle(BarX, Plot.Bottom, BarW, BarH);  // X, Y, Breite, Höhe
    Page.Fill;

    // Kategoriebeschriftung unter der Grundlinie, Wert über dem Balken.
    Page.SetRGBFillColor(clBlack);
    Page.TextOut(BarX, Plot.Bottom - 14, 0, Labels[I]);
    Page.TextOut(BarX, Plot.Bottom + BarH + 4, 0,
      FormatFloat('0', Values[I]));
  end;
end;

Zwei Details verdienen sich ihren Platz. Der Gap ist ein Bruchteil des Slots statt einer festen Punktanzahl, sodass die Balken proportional beabstandet bleiben, egal ob man vier oder vierzig Kategorien plottet. Und die Wertbeschriftung ist mit derselben ValueToY-abgeleiteten Höhe positioniert, die der Balken verwendet, sodass sie immer knapp über dem eigenen Balken sitzt statt auf einem geratenem Abstand zu schweben. Wenn man horizontale Gitterlinien hinter den Balken haben möchte, diese vor der Schleife zeichnen: drei oder vier runde Werte nehmen, ValueToY auf jeden anwenden und eine schwache Linie über den Plot bei diesem Y zeichnen. Sie zuerst zu zeichnen platziert sie hinter den Balken in der Malermodell-Stapelreihenfolge, die PDF verwendet.

Achsen, Markierungen und Beschriftungen sind nur mehr Linien und Text

Das Diagramm ist erst fertig, wenn ein Leser erkennen kann, was die Balken bedeuten, und das ist reine Achsenarbeit. Die vertikale Achse ist eine gestrichene Linie am linken Rand des Plots mit einigen Markierungen und ihren Werten. ValueToY wiederverwenden, damit die Markierungen auf derselben Skala landen, die die Balken verwenden, sonst widerspricht ein Balken seiner Gitterlinie und das Diagramm lügt leise:

procedure DrawValueAxis(Page: THPDFPage; const Plot: TPlotArea;
  TickCount: Integer);
var
  I: Integer;
  TickV, TickY: Single;
begin
  Page.SetLineWidth(1.0);
  Page.MoveTo(Plot.Left, Plot.Bottom);
  Page.LineTo(Plot.Left, Plot.Bottom + Plot.Height);
  Page.Stroke;

  Page.SetFont('Arial', [], 8);
  for I := 0 to TickCount do
  begin
    TickV := (Plot.MaxValue / TickCount) * I;
    TickY := ValueToY(Plot, TickV);
    Page.MoveTo(Plot.Left - 4, TickY);   // kurze Markierung außerhalb der Achse
    Page.LineTo(Plot.Left, TickY);
    Page.Stroke;
    Page.TextOut(Plot.Left - 30, TickY - 3, 0, FormatFloat('0', TickV));
  end;
end;

Beschriftungen sind der Ort, an dem Diagramme in der Produktion am häufigsten brechen, und der Fehler ist immer derselbe: Text, der auf dem Bildschirm passte, überschreitet seinen Platz im PDF. Lange Kategorienamen kollidieren mit ihren Nachbarn, und lokalisierte Monatsnamen wie „septembre" oder „Dezember" sind breiter als das englische „Sep", mit dem man getestet hat. Es gibt hier keinen Autogröße-Retter, also echten Rand unter der Grundlinie lassen, die Schrift bei dichten Kategoriemengen um einen oder zwei Punkte verkleinern, und bei wirklich langen Namen rotieren. TextOut nimmt einen Winkel als drittes Argument, sodass 90 übergeben die Beschriftung hochkant stellt und Platz schafft ohne Überlappung. Das Layout mit der breitesten erwarteten Beschriftung testen, nicht mit der kürzesten, bevor der Export ausgeliefert wird.

Liniendiagramme: eine Polylinie durch abgebildete Punkte

Ein Liniendiagramm verwendet die gesamte Wertabbildung wieder und ändert nur, wie die Punkte verbunden werden. Statt eines Rechtecks pro Kategorie durchläuft man die Daten einmal, wandelt jeden Wert mit ValueToY in sein (X, Y) um und verknüpft die Punkte mit einem einzigen MoveTo gefolgt von LineTo-Aufrufen, die am Ende gestrichen werden. Der erste Punkt öffnet den Pfad; jeder spätere Punkt verlängert ihn:

procedure DrawLineChart(Page: THPDFPage; const Plot: TPlotArea;
  const Values: array of Single);
var
  I, Count: Integer;
  StepX, X, Y: Single;
begin
  Count := Length(Values);
  if Count < 2 then Exit;
  StepX := Plot.Width / (Count - 1);

  Page.SetLineWidth(1.5);
  Page.SetRGBStrokeColor(RGB(214, 92, 36));
  for I := 0 to Count - 1 do
  begin
    X := Plot.Left + I * StepX;
    Y := ValueToY(Plot, Values[I]);
    if I = 0 then
      Page.MoveTo(X, Y)        // Pfad am ersten Punkt öffnen
    else
      Page.LineTo(X, Y);       // durch jeden späteren Punkt verlängern
  end;
  Page.Stroke;                 // ein Strich malt die gesamte Polylinie
end;

Den Abstandsunterschied beachten. Ein Balkendiagramm teilt die Breite durch die Anzahl der Balken, weil jeder Balken einen Slot besitzt. Ein Liniendiagramm teilt durch die Anzahl der Intervalle, Count - 1, weil der erste und letzte Punkt auf den Plotkanten sitzen und die Linie die Lücken zwischen ihnen überspannt. Diese beiden zu verwechseln ist der übliche Grund, warum ein Liniendiagramm einen halben Slot von dem Balkendiagramm abweicht, dem es überlagert werden soll. Wenn man einen Marker an jedem Datenpunkt möchte, nach dem Zeichnen der Polylinie an jedem (X, Y) einen kleinen Circle ablegen und Fill aufrufen.

Tortendiagramme: Bögen, oder Keile wenn man es einfach hält

Tortenstücke sind die eine Form, die Trigonometrie benötigt, weil ein Keil von zwei Radien und einem Bogen begrenzt wird. Die ehrliche Version streicht den Bogen durch kleine Liniensegmente entlang des Umfangs, was die Kurve nah genug annähert, dass kein Leser es bemerkt. Der Schwenkwinkel jedes Stücks ist sein Anteil am Gesamten, (Value / Total) * 2π, und man akkumuliert den laufenden Winkel beim Runden:

procedure DrawPieChart(Page: THPDFPage; CX, CY, Radius: Single;
  const Values: array of Single; const Colors: array of TColor);
var
  I, Step, Steps: Integer;
  Total, Start, Sweep, A: Single;
begin
  Total := 0;
  for I := 0 to High(Values) do Total := Total + Values[I];
  Start := 0;

  for I := 0 to High(Values) do
  begin
    Sweep := (Values[I] / Total) * 2 * Pi;
    Steps := Round(Sweep / (Pi / 90)) + 1;  // ca. 2 Grad pro Segment

    Page.SetRGBFillColor(Colors[I]);
    Page.MoveTo(CX, CY);                     // Keil-Spitze im Mittelpunkt
    for Step := 0 to Steps do
    begin
      A := Start + Sweep * (Step / Steps);
      Page.LineTo(CX + Radius * Cos(A), CY + Radius * Sin(A));
    end;
    Page.LineTo(CX, CY);                      // zurück zum Mittelpunkt schließen
    Page.Fill;

    Start := Start + Sweep;                   // zum nächsten Stück vorrücken
  end;
end;

Der Mittelpunkt-nach-außen-Pfad, zuerst Spitze, dann Bogen, dann zurück zur Spitze, ergibt einen geschlossenen Keil, den Fill vollflächig malt. Die Segmentanzahl tauscht Glattheit gegen Pfadgröße: ungefähr zwei Grad pro Schritt sieht bei jedem vernünftigen Radius rund aus ohne einen enormen Content-Stream zu erzeugen. Wenn man keinen echten Kreis braucht, kann man die Trigonometrie ganz überspringen und dieselben Daten als einzelnen horizontalen gestapelten Balken rendern, die Breite jedes Segments proportional zu seinem Anteil. Das ist oft besser lesbar als ein Tortendiagramm, und es ist nur der Balkencode mit nebeneinander gelegten Rechtecken. Die Bogenversion nur verwenden, wenn das Design einen echten Kreis erfordert.

Nichts davon hängt von einer installierten Diagrammbibliothek ab, was der stille Vorteil des direkten Zeichnens der Grundelemente ist. Dieselben Canvas-Zeichenaufrufe, die ein Logo oder einen Unterschriftenrahmen platzieren, bauen diese Diagramme, und dasselbe TextOut, das ein Formularfeld beschriftet, beschriftet eine Achse. Die Plot-Geometrie in einem Record halten, Werte einmal auf Y abbilden, und ein Balken-, Linien- oder Tortendiagramm ist eine kurze Routine über Rectangle, LineTo und Circle, die man in jeden Bericht einsetzen kann. Die hier verwendeten Aufrufe Rectangle, MoveTo, LineTo, Circle, Fill, Stroke und TextOut sind Teil des HotPDF Component für Delphi und C++Builder.