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
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:
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
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