Technischer Artikel

Diagramme in PDF mit HotPDF-Primitiven zeichnen (Delphi)

Ein als 96-DPI-Bitmap aus einem Bildschirm-Chartcontrol kopiertes Diagramm sieht in einem PDF-Viewer bei 100 % Zoom fein aus. Zoomt man auf 400 % oder schickt die Seite an einen 600-DPI-Laserdrucker, werden die Balkenkanten weich, verschwimmen die Achsenbeschriftungen zu grauen Klecksen, und die Datei trägt ein paar hundert Kilobyte Pixel für das, was in Wahrheit ein Dutzend Rechtecke ist. Als Vektorpfade gezeichnet bleibt dasselbe Diagramm bei jedem Zoomlevel und jeder Druckauflösung scharf, denn Balken, Achsen und Beschriftungen sind PDF-Pfad- und Textoperatoren, die der Viewer in jedem Maßstab neu rendert, den er braucht

Vektorausgabe verlagert die Layoutarbeit in Ihren Code. Etwas muss berechnen, wo jeder Balken, jeder Tick und jedes Label landet, und für Balken-, Linien- und Kreisdiagramme ist diese Berechnung eine Koordinatenabbildung plus ein paar Schleifen über Rechtecke, Liniensegmente und Bögen. Die Abbildung einmal richtig hingestellt, sind die Schleifen kurz

DrawChart oder handgezeichnete Primitive in HotPDF?

HotPDF bringt einen fertigen Diagrammrenderer mit, und für ein Standard-Reportdiagramm ist er das Erste, was man versucht. Einen THPDFChart bauen, Serien mit AddSeries hinzufügen (Arten hckLine, hckBar, hckArea, hckPie und hckScatter), sie mit AddPoint füllen und THPDFPage.DrawChart mit einem Zielrechteck aufrufen. Der Renderer erzeugt Achsenbereich, Gitter, Tick-Labels, Legende und Theme-Farben von selbst, und die Überladung mit einem Structure-Parent schreibt das Diagramm als getaggte Figur mit Alternativtext aus den Daten — wichtig, wenn das Dokument eine Accessibility-Prüfung bestehen muss

var
  Chart: THPDFChart;
  Sales: THPDFChartSeries;
begin
  Chart := THPDFChart.Create;
  try
    Chart.Title := 'Quarterly sales';
    Chart.LegendPosition := hclBottom;
    Sales := Chart.AddSeries(hckBar, 'Sales');
    Sales.AddPoint(1, 420, 'Q1');
    Sales.AddPoint(2, 515, 'Q2');
    Sales.AddPoint(3, 388, 'Q3');
    Sales.AddPoint(4, 610, 'Q4');
    // Rechteck in Seiteneinheiten: left, top, width, height
    HotPDF1.CurrentPage.DrawChart(Chart, 60, 120, 470, 280);
  finally
    Chart.Free;
  end;
end;

Der Renderer hat allerdings einen festen Wortschatz. Mehrere Balkenserien stehen nebeneinander in jeder Kategorie, ein Kreisdiagramm muss die einzige Serie in seinem Diagramm sein, und das Aussehen kommt aus drei Themes plus Farben pro Serie. Gestapelte Balken, ein hinter den Balken schattiertes Zielband, eine eigene Markenfarbe für jeden Balken einer einzelnen Serie, ein Kreisdiagramm, das bei zwölf Uhr beginnt und im Uhrzeigersinn läuft: Sobald ein Design eines davon verlangt, ist es weniger Arbeit, das Diagramm selbst mit dem Page Canvas zu zeichnen, als den Renderer zu verbiegen. Diese Primitive sind Rectangle, MoveTo, LineTo, Pie, Fill, Stroke und TextOut, dieselben Aufrufe wie im Canvas-Zeichenwalkthrough

Warum steht mein Balkendiagramm auf dem Kopf?

HotPDF-Seitenkoordinaten legen den Ursprung in die obere linke Ecke mit nach unten wachsendem Y — derselbe Rahmen wie ein VCL-Canvas. Der PDF-User Space selbst läuft andersherum (Ursprung unten links, Y nach oben, ISO 32000-1 §8.3.2.3), und HotPDF kippt jede Koordinate in diesen Raum, während es den Content-Stream schreibt. Diagrammwerte wachsen dagegen nach oben. Füttert man einen skalierten Wert direkt als Y ein, hängt der höchste Balken wie ein Eiszapfen von der Oberkante des Plots herab

Die Reparatur ist ein einziger Ausdruck. Ein Plotrechteck wählen, null auf seine Unterkante und die Skalenspitze auf seine Oberkante mappen und jeden Wert durch diese Abbildung schicken, bevor er die Seite berührt. Für einen Wert V auf einer Skala von 0 bis MaxValue ist das Seiten-Y Plot.Bottom - (V / Plot.MaxValue) * Plot.Height, wobei Plot.Bottom die Basislinie ist. Der Balkendiagramm-Code weiter unten packt das in einen kleinen ValueToY-Helfer, und jede andere Routine verwendet ihn wieder

HotPDF: Bildschirmkoordinaten und HotPDF-Seitenkoordinaten teilen sich einen Ursprung oben links mit nach unten wachsendem Y, während Diagrammwerte nach oben wachsen, und ValueToY kippt einen Datenwert in diesen Y-nach-unten-Seitenraum
HotPDF bleibt beim vertrauten Bildschirmrahmen, Ursprung oben links und Y nach unten wachsend, sodass ein einziger ValueToY-Ausdruck jeden nach oben wachsenden Datenwert in den Seitenraum kippt

Zwei Entscheidungen verstecken sich in diesem Ausdruck. MaxValue sollte eine runde Zahl über dem größten Datenpunkt sein, das nächste Vielfache von 10 oder 100, damit der höchste Balken die Oberkante nicht berührt und die Gitterlinien-Labels 0, 200, 400 lesen statt 0, 203.5, 407. Und die Skala beginnt absichtlich bei null. Die Balkenlänge ist der Wert, also lässt eine Wertachse, die bei 350 beginnt, einen 388er-Balken kaum höher wirken als einen 420er-Balken, wenn die echte Differenz unter 8 % liegt. Der eingebaute Renderer zieht aus genau diesem Grund null in den Bereich für Balken- und Flächenserien. Dieselbe Regel gilt in handgezeichnetem Code

Einheiten sind die andere Hälfte der Abbildung. Mit der Standard-Resolution von 72 ist eine Seiteneinheit ein PDF-Punkt (1/72 Zoll). Ändert man die Auflösung, wechseln Koordinaten, Strichbreiten und Textmessungen allesamt auf die neue Einheit, was der Artikel zu Resolution und Zeicheneinheiten im Detail behandelt. DrawChart nimmt sein Zielrechteck in Seiteneinheiten, legt aber seine eigenen Ränder, Schriftgrößen und Strichbreiten in Punkten bei jeder Auflösung aus

Ein Balkendiagramm ist eine Schleife über Rechtecke

Steht die Abbildung, ist ein Balkendiagramm eine Schleife. Die Plotbreite in einen Slot pro Kategorie teilen, eine Lücke lassen, damit benachbarte Balken einander nicht berühren, und jeden Balken als gefülltes Rechteck von seinem ValueToY-Top abwärts bis zur Basislinie zeichnen. Rectangle nimmt die obere linke Ecke, eine Breite und eine Höhe, was direkt zu dieser Abbildung passt:

HotPDF: Balkendiagramm-Anatomie: aus der Plotbreite geteilte Slots mit Lücken zwischen Balken, jeder Balken ein gefülltes Rectangle, aufsteigend ab Plot.Bottom, Kategoriebeschriftungen unter der Basislinie, Wertbeschriftungen auf Balkenhöhe, und Achsenticks mit ValueToY
Jeder Balken ist ein gefülltes Rectangle, das in seinem Slot von Plot.Bottom aufwärts wächst, die Viertel-Slot-Lücke skaliert mit der Breite, und Achsenteilstriche verwenden ValueToY wieder, sodass Balken und Gitterlinien übereinstimmen
type
  TPlotArea = record
    Left, Bottom, Width, Height: Single;  // Seiteneinheiten; Bottom ist das Basislinien-Y
    MaxValue: Single;                     // Spitze der Wertskala
  end;

// Eigener Helfer: ein größerer Wert sitzt höher, was auf einer
// Y-nach-unten-Seite ein kleineres Y bedeutet
function ValueToY(const Plot: TPlotArea; V: Single): Single;
begin
  Result := Plot.Bottom - (V / Plot.MaxValue) * Plot.Height;
end;

procedure DrawBarChart(Page: THPDFPage; const Plot: TPlotArea;
  const Values: array of Single; const Labels: array of string;
  const BarColors: array of TColor);
var
  I, Count: Integer;
  SlotW, BarW, BarX, BarTop, Gap, GridV, GridY: Single;
begin
  Count := Length(Values);
  SlotW := Plot.Width / Count;
  Gap := SlotW * 0.25;              // ein Viertel des Slots, links und rechts verteilt
  BarW := SlotW - Gap;

  // Gestrichelte Gitterlinien zuerst, damit die Balken darüber malen
  Page.SetLineWidth(0.5);
  Page.SetRGBStrokeColor(RGB(200, 200, 200));
  Page.SetDash([2, 2], 0);
  GridV := Plot.MaxValue / 4;
  while GridV <= Plot.MaxValue do
  begin
    GridY := ValueToY(Plot, GridV);
    Page.MoveTo(Plot.Left, GridY);
    Page.LineTo(Plot.Left + Plot.Width, GridY);
    Page.Stroke;
    GridV := GridV + Plot.MaxValue / 4;
  end;
  Page.NoDash;

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

    Page.SetRGBFillColor(BarColors[I mod Length(BarColors)]);
    Page.Rectangle(BarX, BarTop, BarW, Plot.Bottom - BarTop);
    Page.Fill;

    // TextOut setzt den oberen Rand des Texts auf Y
    Page.SetRGBFillColor(clBlack);
    Page.TextOut(BarX, Plot.Bottom + 4, 0, Labels[I]);
    Page.TextOut(BarX, BarTop - 12, 0, FormatFloat('0', Values[I]));
  end;

  // Basislinie zuletzt, damit sie über den Balkenunterkanten liegt
  Page.SetLineWidth(1.0);
  Page.SetRGBStrokeColor(clBlack);
  Page.MoveTo(Plot.Left, Plot.Bottom);
  Page.LineTo(Plot.Left + Plot.Width, Plot.Bottom);
  Page.Stroke;
end;

Die Reihenfolge ist hier das gesamte Layering-Modell. PDF verwendet ein Malermodell: Spätere Operatoren im Content-Stream malen über frühere, also kommen die Gitterlinien zuerst, die Balken darüber, die Basislinie zuletzt. Tauscht man den Gitterlinien-Block unter die Schleife, läuft das Gestrichel quer über jeden Balken

Die Lücke ist ein Anteil des Slots statt einer festen Punktzahl. Mit vier Kategorien auf einem 470-Punkte-Plot ist jeder Slot 117,5 Punkte breit, und die Balken bekommen rund 29 Punkte Luft dazwischen; bei vierzig Kategorien lässt derselbe Code unter 3 Punkte, was immer noch als getrennte Balken lesbar ist. Eine feste 12-Punkte-Lücke würde bei vierzig Kategorien 480 der 470 verfügbaren Punkte verschlingen, und die Balkenbreite würde negativ

Achsenbeschriftungen, die nicht kollidieren

Labels sind der Ort, an dem handgezeichnete Diagramme zuerst brechen — und fast immer auf dieselbe Art. Text, der auf dem Bildschirm passte, läuft auf der Seite über sein Feld hinaus, denn der Entwickler hat mit „Sep“ getestet und der deutsche Report druckt „September“ oder „Dezember“ in einer 9-Punkt-Schrift. Die Wertachse hat eine leisere Variante desselben Problems: linksbündige Tick-Labels an festem X-Offset wirken zerrupft, sobald die Zahlen die Länge wechseln, und ein „1.200“-Label läuft in die Achsenlinie, die „800“ noch frei gelassen hatte

Messen statt raten. TextWidth liefert die Breite einer Zeichenkette in der aktuellen Schrift in Seiteneinheiten, TextHeight die Oberlängenhöhe der Schrift bei der aktuellen Größe. Jedes Tick-Label rechtsbündig gegen die Achse setzen und auf seinem Tick zentrieren:

procedure DrawValueAxis(Page: THPDFPage; const Plot: TPlotArea;
  TickCount: Integer);
var
  I: Integer;
  TickV, TickY: Single;
  S: string;
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);        // kurzer Teilstrich außerhalb der Achse
    Page.LineTo(Plot.Left, TickY);
    Page.Stroke;

    S := FormatFloat('#,##0', TickV);
    Page.TextOut(Plot.Left - 7 - Page.TextWidth(AnsiString(S)),
      TickY - Page.TextHeight(AnsiString(S)) / 2, 0, S);
  end;
end;

Für Kategorie-Labels sagt dieselbe Messung, wann horizontaler Text aufzugeben ist. Das breiteste Label gegen die Slotbreite vergleichen, bevor irgendetwas gezeichnet wird. Passt es nicht, die Schrift um ein, zwei Punkte verkleinern; passt es immer noch nicht, 90 als Winkelargument von TextOut übergeben und die Labels aufstellen, dann unter der Basislinie genug Rand für das längste lassen. Testen Sie mit dem breitesten Label, das der Report real erzeugen kann — bei Monatsnamen in einem lokalisierten Build ist das selten das englische

Liniendiagramme verwenden dieselbe Abbildung wieder

Ein Liniendiagramm ändert nur, wie die Punkte verbunden werden. Die Daten einmal durchgehen, jeden Wert mit ValueToY umrechnen und die Punkte zu einem Pfad verketten: MoveTo für den ersten Punkt, LineTo für jeden weiteren, ein einziges Stroke am Ende. Liegen die Punkte schon in einem Array, nimmt Polyline ein array of THPDFCurrPoint, öffnet einen frischen Pfad und lässt den Strich Ihnen; unter zwei Punkten wirft es eine Exception, also braucht eine Serie mit einer einzigen Messung statt der Linie einen Marker

Der Abstand ist der Ort, an dem ein Liniendiagramm von einem über denselben Plot gezeichneten Balkendiagramm abdriftet. Balken teilen die Breite durch die Kategorieanzahl, weil jeder Balken einen Slot besitzt. Eine Linie teilt sie durch die Intervallanzahl, Count - 1, weil erster und letzter Punkt auf den Ploträndern sitzen. Legt man beide übereinander, landen die Linienpunkte auf den Slotgrenzen statt auf den Balkenmitten. Reitet eine Linie auf Balken, setze ihre Punkte auf jede Slotmitte, Plot.Left + I * SlotW + SlotW / 2, und streiche die Count - 1-Abstände ganz. Für einen Marker an jedem Punkt tut ein kleines Circle gefolgt von Fill nach dem Strich des Linienzugs seinen Dienst

Kuchensegmente mit Pie, im Uhrzeigersinn ab zwölf Uhr

Ein Kuchenkeil sind zwei Radien und ein Bogen. Die handgerollte Variante beginnt im Zentrum, setzt kurze LineTo-Segmente ein paar Grad auseinander entlang des Randes ab und schließt zum Zentrum zurück — die Konstruktion in der Abbildung unten. Das funktioniert, aber ein 270-Grad-Segment wird zu weit über hundert Pfadsegmenten, und der Rand bleibt auch unter starker Vergrößerung ein Polygon

HotPDF: Konstruktion eines Kuchensegments: Start mit MoveTo im Kreismittelpunkt, kleine LineTo-Segmente im Abstand von etwa zwei Grad entlang des Bogens über den Winkel des Segments, dann Abschluss zum Mittelpunkt, sodass Fill einen massiven Keil malt
Ein Tortensegment beginnt mit MoveTo im Zentrum, geht kleine LineTo-Schritte im Abstand von etwa zwei Grad entlang seines Kreisausschnitts und schließt zurück, damit Fill es solide malt

Nehmen Sie stattdessen Pie. THPDFPage.Pie folgt der GDI-Konvention: ein umschließendes Rechteck, ein Startwinkel und ein Endwinkel in Grad, 0 zeigt nach Osten, 90 nach Norden, positive Winkel laufen gegen den Uhrzeigersinn. Es baut den Bogen aus Bézier-Segmenten von höchstens 90 Grad, zieht die Linie zum Zentrum und schließt den Pfad, sodass der Rand eine echte Kurve bleibt und ein volles Segment eine Handvoll Operatoren kostet. Geschäftliche Kreisdiagramme beginnen meist bei zwölf Uhr und laufen im Uhrzeigersinn, was in GDI-Terminen heißt: bei 90 starten und den Winkel abwärts laufen lassen:

procedure DrawPieChart(Page: THPDFPage; CX, CY, Radius: Single;
  const Values: array of Double; const Colors: array of TColor);
var
  I: Integer;
  Total, Sweep, Angle: Double;
begin
  Total := 0;
  for I := 0 to High(Values) do
    if Values[I] > 0 then
      Total := Total + Values[I];
  if Total <= 0 then
    Exit;

  Angle := 90;                            // zwölf Uhr
  Page.SetRGBStrokeColor(clWhite);
  Page.SetLineWidth(0.8);
  for I := 0 to High(Values) do
  begin
    if Values[I] <= 0 then
      Continue;                           // siehe unten: nie einen Null- oder negativen Sweep durchreichen
    Sweep := Values[I] / Total * 360;
    Page.SetRGBFillColor(Colors[I mod Length(Colors)]);
    // Gegen den Uhrzeigersinn von Angle - Sweep bis Angle liegt das
    // Segment, das im Uhrzeigersinn hinter der aktuellen Position sitzt
    Page.Pie(CX - Radius, CY - Radius, CX + Radius, CY + Radius,
      Angle - Sweep, Angle);
    Page.FillAndStroke;                   // der weiße Strich trennt die Segmente
    Angle := Angle - Sweep;
  end;
end;

Der Continue-Wächter ist die wichtigste Zeile. Pie behandelt einen Endwinkel unterhalb des Startwinkels als Sweep, der über 360 hinausläuft — die GDI-Lesart, die einem Segment erlaubt, die 0-Grad-Linie zu kreuzen. Eine Erstattungszeile oder ein Korrekturposten in den Quelldaten liefert einen negativen Wert, der berechnete Endwinkel landet unter dem Startwinkel, und das Segment wickelt sich zu einer fast vollen Scheibe, die über alles schon Gezeichnete gemalt wird. Ein Nullwert ist harmloser, aber trotzdem sichtbar: Der geschlossene Pfad kollabiert zu einem Radius, und der weiße Trennstrich zeichnet ihn als verirrte Linie vom Rand zum Zentrum. Filtern Sie beides vor der Schleife, und entscheiden Sie separat, wie der Report negative Beträge zeigt, denn ein Kreisdiagramm kann sie überhaupt nicht zeigen

Wenn das Design keinen Kreis verlangt, zeigt ein einzelner horizontaler gestapelter Balken dieselben Anteile mit nichts als Rectangle-Aufrufen aneinander gereiht, und Leser vergleichen Längen genauer als Winkel. Für ein Teil-Ganzes-Bild mit mehr als fünf, sechs Kategorien ist das das bessere Diagramm, und der Balkendiagramm-Code von oben erledigt schon den größten Teil der Arbeit

Jedes Diagramm in diesem Artikel baut auf den Page-Canvas-Aufrufen der HotPDF Delphi Component auf, mit DrawChart für die Standardlayouts Linie, Balken, Fläche, Kreis und Streuung — und alles läuft aus Delphi oder C++Builder, ohne dass auf der Build-Maschine eine Chart-Bibliothek installiert ist