Un graphique copié depuis un contrôle de graphique d'écran en bitmap 96 DPI se porte bien à un zoom de 100% dans une visionneuse PDF. Passez à 400%, ou envoyez la page à une imprimante laser 600 DPI, et les bords des barres deviennent mous, les étiquettes d'axes se brouillent en taches grises, et le fichier trimbale quelques centaines de kilooctets de pixels pour ce qui est en réalité une douzaine de rectangles. Dessiné en tracés vectoriels à la place, le même graphique reste net à chaque niveau de zoom et à chaque résolution d'impression, parce que les barres, axes et étiquettes sont des opérateurs PDF de chemin et de texte que la visionneuse rejoue à l'échelle dont elle a besoin
La sortie vectorielle déplace le travail de mise en page dans votre code. Quelque chose doit calculer où atterrissent chaque barre, graduation et étiquette, et pour des graphiques à barres, linéaires et camemberts, ce calcul est un mappage de coordonnées plus quelques boucles sur des rectangles, des segments et des arcs. Ayez le mappage juste une fois et les boucles sont courtes
DrawChart ou primitives dessinées à la main dans HotPDF ?
HotPDF livre un moteur de graphiques prêt à l'emploi, et pour un graphique de rapport standard c'est la première chose à essayer. Construisez un THPDFChart, ajoutez des séries avec AddSeries (types hckLine, hckBar, hckArea, hckPie et hckScatter), remplissez-les avec AddPoint, et appelez THPDFPage.DrawChart avec un rectangle cible. Le moteur calcule tout seul la plage des axes, la grille, les étiquettes de graduations, la légende et les couleurs du thème, et la surcharge qui prend un parent structure écrit le graphique comme figure étiquetée avec un texte alternatif construit depuis les données, ce qui compte si le document doit passer un contrôle d'accessibilité
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');
// Rectangle en unités de page : left, top, width, height
HotPDF1.CurrentPage.DrawChart(Chart, 60, 120, 470, 280);
finally
Chart.Free;
end;
end;
Le moteur a toutefois un vocabulaire figé. Plusieurs séries à barres siègent côte à côte dans chaque catégorie, un camembert doit être la seule série de son graphique, et le look vient de trois thèmes plus les couleurs par série. Barres empilées, une bande cible ombrée derrière les barres, une couleur de marque différente pour chaque barre d'une même série, un camembert qui démarre à midi et tourne dans le sens horaire : dès qu'un design demande l'un de ces effets, dessiner le graphique vous-même avec le canevas de page est moins de travail que de tordre le moteur. Ces primitives sont Rectangle, MoveTo, LineTo, Pie, Fill, Stroke et TextOut, les mêmes appels que couvre le pas à pas du dessin sur canevas
Pourquoi mon histogramme sort-il à l'envers ?
Les coordonnées de page de HotPDF placent l'origine au coin supérieur gauche avec Y croissant vers le bas, le même repère qu'un canevas VCL. L'espace utilisateur PDF, lui, court dans l'autre sens (origine en bas à gauche, Y vers le haut, ISO 32000-1 §8.3.2.3), et HotPDF retourne chaque coordonnée vers cet espace en écrivant le flux de contenu. Les valeurs d'un graphique, en revanche, croissent vers le haut. Injectez une valeur mise à l'échelle directement comme Y et la barre la plus haute pend depuis le haut de la zone de tracé comme un stalactite
Le remède tient en une expression. Choisissez un rectangle de tracé, mappez zéro à son bord inférieur et le haut de l'échelle à son bord supérieur, et faites passer chaque valeur par ce mappage avant qu'elle ne touche la page. Pour une valeur V sur une échelle de 0 à MaxValue, le Y de page est Plot.Bottom - (V / Plot.MaxValue) * Plot.Height, où Plot.Bottom est la ligne de base. Le code de l'histogramme plus bas enveloppe cela dans un petit helper ValueToY et chaque autre routine le réutilise
Deux choix se cachent dans cette expression. MaxValue devrait être un chiffre rond au-dessus du plus grand point de donnée, le multiple suivant de 10 ou de 100, pour que la barre la plus haute ne touche pas le bord supérieur et que les étiquettes du quadrillage lisent 0, 200, 400 plutôt que 0, 203.5, 407. Et l'échelle démarre à zéro volontairement. La longueur de barre est la valeur, donc un axe de valeurs qui démarre à 350 fait qu'une barre de 388 paraît à peine plus haute que la moitié d'une barre de 420 quand l'écart réel est sous 8%. Le moteur intégré ramène zéro dans la plage pour les séries à barres et à aires exactement pour cette raison. Gardez la même règle dans le code dessiné à la main
Les unités sont l'autre moitié du mappage. Avec le Resolution par défaut de 72, une unité de page est un point PDF (1/72 de pouce). Changez la résolution et vos coordonnées, largeurs de trait et mesures de texte basculent tous vers la nouvelle unité, ce que détaille l'article Resolution et unités de dessin. DrawChart prend son rectangle cible en unités de page mais dispose ses propres marges, tailles de police et largeurs de trait en points à toute résolution
Un histogramme est une boucle sur des rectangles
Le mappage en place, un histogramme est une boucle. Divisez la largeur du tracé en un emplacement par catégorie, laissez un écart pour que les barres voisines ne se touchent pas, et dessinez chaque barre comme un rectangle rempli de son sommet ValueToY jusqu'à la ligne de base. Rectangle prend le coin supérieur gauche, une largeur et une hauteur, ce qui colle directement à ce mappage :
type
TPlotArea = record
Left, Bottom, Width, Height: Single; // unités de page ; Bottom est le Y de la ligne de base
MaxValue: Single; // haut de l'échelle des valeurs
end;
// Votre propre helper : une valeur plus grande siège plus haut, ce qui sur
// une page Y vers le bas signifie un Y plus petit
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; // quart de l'emplacement, réparti à gauche et à droite
BarW := SlotW - Gap;
// Quadrillage en pointillés d'abord, pour que les barres peignent par-dessus
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 place le haut du texte à Y
Page.SetRGBFillColor(clBlack);
Page.TextOut(BarX, Plot.Bottom + 4, 0, Labels[I]);
Page.TextOut(BarX, BarTop - 12, 0, FormatFloat('0', Values[I]));
end;
// Ligne de base en dernier, pour qu'elle siège sur le bas des barres
Page.SetLineWidth(1.0);
Page.SetRGBStrokeColor(clBlack);
Page.MoveTo(Plot.Left, Plot.Bottom);
Page.LineTo(Plot.Left + Plot.Width, Plot.Bottom);
Page.Stroke;
end;
L'ordre est tout le modèle d'empilement ici. Le PDF utilise un modèle de peintre, où les opérateurs tardifs du flux de contenu peignent par-dessus les précoces, si bien que le quadrillage passe d'abord, les barres par-dessus, la ligne de base en dernier. Déplacez le bloc de quadrillage sous la boucle et les pointillés traversent chaque barre en plein milieu
L'écart est une fraction de l'emplacement plutôt qu'un nombre de points fixe. Avec quatre catégories sur un tracé de 470 points, chaque emplacement fait 117.5 points de large et les barres reçoivent environ 29 points d'air entre elles ; avec quarante catégories, le même code laisse moins de 3 points, ce qui se lit encore comme des barres séparées. Un écart fixe de 12 points avalerait 480 des 470 points disponibles à quarante catégories et la largeur de barre deviendrait négative
Des étiquettes d'axes qui ne se percutent pas
Les étiquettes sont là où les graphiques dessinés à la main cassent en premier, et presque toujours de la même façon. Un texte qui tenait à l'écran déborde de son espace sur la page, parce que le développeur a testé avec « Sep » et que le rapport allemand imprime « September » ou « Dezember » dans une police de 9 points. L'axe des valeurs a une variante plus discrète du même problème : des étiquettes de graduations alignées à gauche à un décalage X fixe paraissent en dents de scie dès que les nombres changent de longueur, et une étiquette « 1,200 » rejoint la ligne d'axe que « 800 » évitait
Mesurez au lieu de deviner. TextWidth retourne la largeur d'une chaîne dans la police courante, en unités de page, et TextHeight retourne l'ascendant de la police à la taille courante. Alignez chaque étiquette de graduation à droite contre l'axe et centrez-la sur sa graduation :
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); // petite graduation à l'extérieur de l'axe
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;
Pour les étiquettes de catégories, la même mesure vous dit quand renoncer au texte horizontal. Comparez l'étiquette la plus large à la largeur de l'emplacement avant de dessiner quoi que ce soit. Si elle ne tient pas, baissez la police d'un ou deux points ; si elle ne tient toujours pas, passez 90 comme argument d'angle de TextOut et dressez les étiquettes debout, puis laissez assez de marge sous la ligne de base pour la plus longue. Testez avec la plus large étiquette que le rapport peut réellement produire, ce qui, pour des noms de mois dans un build localisé, est rarement l'anglaise
Les graphiques linéaires réutilisent le même mappage
Un graphique linéaire ne change que la façon dont les points se connectent. Parcourez les données une fois, convertissez chaque valeur avec ValueToY, et cousez les points en un seul chemin : MoveTo pour le premier point, LineTo pour chaque suivant, un unique Stroke à la fin. Si vous avez déjà les points dans un tableau, Polyline prend un array of THPDFCurrPoint, ouvre un chemin neuf et vous laisse le trait ; elle lève une exception pour moins de deux points, si bien qu'une série à lecture unique demande un marqueur plutôt qu'une ligne
L'espacement est là où un graphique linéaire dérive d'un histogramme dessiné sur le même tracé. Les barres divisent la largeur par le nombre de catégories parce que chaque barre possède un emplacement. Une ligne le divise par le nombre d'intervalles, Count - 1, parce que les premier et dernier points siègent sur les bords du tracé. Superposez les deux et les points de la ligne atterrissent sur les frontières d'emplacement au lieu des centres de barres. Quand une ligne chevauche des barres, placez ses points au centre de chaque emplacement, Plot.Left + I * SlotW + SlotW / 2, et abandonnez l'espacement Count - 1 entièrement. Pour un marqueur à chaque point, un petit Circle suivi d'un Fill après le trait de la ligne fait le travail
Des parts de camembert avec Pie, dans le sens horaire depuis midi
Une part de camembert est deux rayons et un arc. La façon artisanale d'en construire une part du centre, avance par petits segments LineTo espacés de quelques degrés le long du pourtour, et se referme vers le centre, qui est la construction de la figure ci-dessous. Cela marche, mais une part de 270 degrés devient bien plus d'une centaine de segments de chemin et le pourtour reste un polygone sous fort zoom
Utilisez Pie à la place. THPDFPage.Pie suit la convention GDI : un rectangle englobant, un angle de départ et un angle de fin en degrés, 0 pointant vers l'est, 90 vers le nord, angles positifs en sens antihoraire. Il construit l'arc à partir de segments de Bézier d'au plus 90 degrés chacun, trace la ligne vers le centre et referme le chemin, si bien que le pourtour reste une vraie courbe et qu'une part pleine coûte une poignée d'opérateurs. Les camemberts d'affaires démarrent en général à midi et tournent dans le sens horaire, ce qui en termes GDI signifie partir de 90 et descendre l'angle :
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; // midi
Page.SetRGBStrokeColor(clWhite);
Page.SetLineWidth(0.8);
for I := 0 to High(Values) do
begin
if Values[I] <= 0 then
Continue; // voir plus bas : ne passez jamais une ouverture nulle ou négative
Sweep := Values[I] / Total * 360;
Page.SetRGBFillColor(Colors[I mod Length(Colors)]);
// De Angle - Sweep à Angle en sens antihoraire se trouve la part qui
// siège dans le sens horaire de la position courante
Page.Pie(CX - Radius, CY - Radius, CX + Radius, CY + Radius,
Angle - Sweep, Angle);
Page.FillAndStroke; // le trait blanc sépare les parts
Angle := Angle - Sweep;
end;
end;
La garde Continue est la ligne qui compte le plus. Pie traite un angle de fin plus petit que l'angle de départ comme une ouverture qui boucle au-delà de 360, la lecture GDI, c'est ce qui permet à une part de franchir la ligne des 0 degré. Une ligne de remboursement ou une écriture de correction dans les données source donne une valeur négative, l'angle de fin calculé atterrit sous le départ, et la part s'enveloppe en un disque presque complet peint par-dessus tout ce qui était dessiné avant. Une valeur zéro est plus douce mais reste visible : le chemin fermé s'effondre en un rayon, et le trait séparateur blanc le dessine comme une ligne égarée du pourtour au centre. Filtrez les deux avant la boucle, et décidez séparément de comment le rapport montre les montants négatifs, parce qu'un camembert ne peut pas les montrer du tout
Quand le design n'exige pas un cercle, une unique barre empilée horizontale montre les mêmes parts avec rien que des appels Rectangle posés bout à bout, et les lecteurs comparent les longueurs plus justement que les angles. Pour une figure de parties d'un tout avec plus de cinq ou six catégories, c'est le meilleur graphique, et le code d'histogramme ci-dessus fait déjà l'essentiel du travail
Chaque graphique de cet article est construit depuis les appels de canevas de page du HotPDF Delphi Component, avec DrawChart disponible pour les mises en page standard linéaire, barres, aires, camembert et nuage de points, et tout tourne depuis Delphi ou C++Builder sans bibliothèque de graphiques installée sur la machine de build