Article technique

Dessiner des graphiques dans un PDF avec les primitives HotPDF

HotPDF ne dispose d'aucun objet graphique. Il n'existe ni TPDFChart, ni AddBarSeries, rien qui accepte un tableau de nombres pour restituer un graphique rendu. Ce qu'il offre a la place, c'est un canevas de page dote du meme vocabulaire bas niveau que tout modele de dessin PDF : rectangles, lignes, cercles, remplissages, traits et texte places a des coordonnees precises. Un graphique dans un document HotPDF est donc quelque chose que vous construisez, non quelque chose que vous demandez. Cela semble plus laborieux que ca ne l'est. Une fois le calcul des coordonnees ecrit une premiere fois, un graphique en barres est une boucle sur des rectangles, un graphique en courbes est une polyligne, et un graphique en secteurs est un eventail d'arcs ; vous controlez chaque pixel du resultat.

C'est important car l'alternative vers laquelle on se tourne en premier, pixeliser un controle graphique ecran en bitmap puis coller l'image dans la page, produit un graphique verrouille a la resolution de l'ecran, qui s'imprime de facon floue et alourdit le fichier. Dessiner le graphique avec les primitives vectorielles de HotPDF conserve un rendu net a n'importe quel niveau de zoom et a n'importe quelle resolution d'impression, car les barres et les axes sont de veritables operateurs de trace PDF, non des pixels. Le prix a payer est que vous prenez en charge la mise en page. La mecanique se resume a quelques operations : la seule inversion de coordonnees qui surprend tout le monde, un graphique en barres illustre, l'astuce de la polyligne pour les courbes, et le calcul d'arc pour les secteurs.

La seule partie difficile est le systeme de coordonnees

Les graphiques ecran placent l'origine en haut a gauche avec Y croissant vers le bas. PDF fait l'inverse. L'origine se situe dans le coin inferieur gauche de la page et Y croit vers le haut, mesure en points (1/72 de pouce). Chaque appel de dessin dans HotPDF, TextOut, Rectangle, MoveTo, LineTo, Circle, utilise cette convention bas-gauche, Y vers le haut. Si vous conservez les reflexes du graphisme ecran, votre premier graphique se dessine a l'envers et deborde en bas de la page.

Le vrai travail dans tout graphique est donc une seule correspondance : transformer une valeur de donnees en coordonnee Y respectant Y vers le haut. Definissez un rectangle de trace, quatre nombres pour la zone dans laquelle vit le graphique, puis faites correspondre la plus petite valeur de vos donnees au bord inferieur et la plus grande au bord superieur. Pour une barre de valeur V sur une echelle allant de 0 a MaxValue, le bord superieur de la barre est PlotBottom + (V / MaxValue) * PlotHeight, et la barre monte a partir de PlotBottom. Obtenez cette seule expression correcte et tout le reste n'est que comptabilite. La fonction auxiliaire ci-dessous contient la geometrie du trace et effectue la conversion, de sorte que le code de dessin ne touche jamais deux fois l'arithmetique brute :

type
  TPlotArea = record
    Left, Bottom, Width, Height: Single;  // PDF points, bottom-left origin
    MaxValue: Single;                     // top of the value scale
  end;

// Map a data value to its Y coordinate inside the plot, Y growing upward.
function ValueToY(const Plot: TPlotArea; V: Single): Single;
begin
  Result := Plot.Bottom + (V / Plot.MaxValue) * Plot.Height;
end;

Un jugement est dissimule dans MaxValue. Si vous le definissez exactement au point de donnees le plus eleve, la barre la plus haute touche le bord superieur du trace et parait rognee. Arrondissez-le a un chiffre rond au-dessus du maximum, par exemple le multiple de 10 ou de 100 suivant, afin que le graphique ait de l'espace en haut et que les etiquettes des lignes de grille affichent des chiffres ronds plutot que le pic accidentel des donnees.

Un graphique en barres est une boucle sur des rectangles

Une fois la correspondance etablie, un graphique en barres s'ecrit de lui-meme. Divisez la largeur du trace en un emplacement par categorie, laissez un ecart entre les barres pour qu'elles ne se touchent pas, et dessinez chaque barre comme un rectangle rempli dont la hauteur vient de ValueToY. Le Rectangle de HotPDF prend le coin inferieur gauche plus une largeur et une hauteur, ce qui correspond exactement a une barre qui monte depuis la ligne de base. Definissez la couleur de remplissage en premier, tracez le chemin, puis appelez Fill pour le peindre. L'etiquette de categorie se place sous la ligne de base, la valeur au-dessus de la barre :

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;          // quarter-slot gap on each side
  BarW := SlotW - Gap;

  // Baseline (the X axis) along the bottom of the plot.
  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, Width, Height
    Page.Fill;

    // Category label below the baseline, value above the bar.
    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;

Deux details meritent leur place. L'ecart est une fraction de l'emplacement plutot qu'un nombre fixe de points, de sorte que les barres restent proportionnellement espacees que vous traciez quatre categories ou quarante. Et l'etiquette de valeur est positionnee avec la meme hauteur derivee de ValueToY qu'utilise la barre, si bien qu'elle se situe toujours juste au-dessus de sa propre barre au lieu de flotter a un decalage estime. Si vous souhaitez des lignes de grille horizontales derriere les barres, dessinez-les avant la boucle : choisissez trois ou quatre valeurs rondes, executez ValueToY sur chacune, et tracez une ligne legere a travers le trace a ce Y. Les dessiner en premier les place derriere les barres dans l'ordre de peinture que PDF utilise.

Les axes, les graduations et les etiquettes ne sont que des lignes et du texte supplementaires

Le graphique n'est pas termine tant qu'un lecteur ne peut pas comprendre ce que signifient les barres, et cela releve entierement du travail sur les axes. L'axe vertical est une ligne tracee le long du bord gauche du trace, avec quelques marques de graduation et leurs valeurs. Reutilisez ValueToY pour que les graduations atterrissent sur la meme echelle que les barres, sinon une barre et sa ligne de grille se contredisent et le graphique ment silencieusement :

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);   // short tick outside the axis
    Page.LineTo(Plot.Left, TickY);
    Page.Stroke;
    Page.TextOut(Plot.Left - 30, TickY - 3, 0, FormatFloat('0', TickV));
  end;
end;

Les etiquettes sont l'endroit ou les graphiques echouent le plus souvent en production, et la cause est toujours la meme : du texte qui tenait dans l'ecran deborde dans le PDF. De longs noms de categories entrent en collision avec leurs voisins, et les noms de mois localises comme "septembre" ou "Dezember" sont plus larges que l'anglais "Sep" utilise pour les tests. Il n'y a pas de redimensionnement automatique pour vous sauver, alors laissez une vraie marge sous la ligne de base, reduisez la police d'un point ou deux pour les ensembles de categories denses, et si les noms sont vraiment longs, faites-les pivoter. TextOut prend un angle comme troisieme argument, donc passer 90 place l'etiquette verticalement et vous donne de l'espace sans chevauchement. Testez la mise en page avec votre etiquette la plus large attendue, pas la plus courte, avant de livrer l'export.

Graphiques en courbes : une polyligne reliant les points correspondants

Un graphique en courbes reutilise l'integralite de la correspondance de valeurs et modifie uniquement la facon dont les points se connectent. Au lieu d'un rectangle par categorie, vous parcourez les donnees une fois, convertissez chaque valeur en son (X, Y) avec ValueToY, et reliez les points avec un seul MoveTo suivi d'appels LineTo, traces a la fin. Le premier point ouvre le chemin ; chaque point ulterieur l'etend :

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)        // open the path at the first point
    else
      Page.LineTo(X, Y);       // extend it through every later point
  end;
  Page.Stroke;                 // one stroke paints the whole polyline
end;

Notez la difference d'espacement. Un graphique en barres divise la largeur par le nombre de barres, car chaque barre occupe un emplacement. Un graphique en courbes divise par le nombre d'intervalles, Count - 1, car les premier et dernier points se trouvent sur les bords du trace et la ligne couvre les ecarts entre eux. Confondre ces deux valeurs est la raison habituelle pour laquelle un graphique en courbes derive d'un demi-emplacement par rapport au graphique en barres qu'il est cense superposer. Si vous voulez un marqueur a chaque point de donnees, placez un petit Circle et Fill a chaque (X, Y) apres que la polyligne est tracee.

Graphiques en secteurs : arcs, ou coins si vous restez simple

Les secteurs sont la seule forme qui necessite de la trigonometrie, car un coin est delimite par deux rayons et un arc. La version honnete balaie l'arc en avancant par petits segments de ligne le long de la circonference, ce qui approxime la courbe suffisamment pour qu'aucun lecteur ne puisse faire la difference. L'angle de balayage de chaque secteur correspond a sa part du total, (Value / Total) * 2*Pi, et vous accumulez l'angle courant en faisant le tour :

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;  // ~2 degrees per segment

    Page.SetRGBFillColor(Colors[I]);
    Page.MoveTo(CX, CY);                     // wedge apex at the center
    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);                      // close back to the center
    Page.Fill;

    Start := Start + Sweep;                   // advance to the next slice
  end;
end;

Le chemin partant du centre, sommet en premier, puis l'arc, puis retour au sommet, donne un coin ferme que Fill peint solidement. Le nombre de segments echange la fluidite contre la taille du chemin : environ deux degres par pas donne un aspect arrondi a n'importe quel rayon raisonnable sans produire un flux de contenu enorme. Si vous n'avez pas besoin d'un vrai cercle, vous pouvez ignorer la trigonometrie et representer les memes donnees sous forme d'une seule barre empilee horizontale, la largeur de chaque segment etant proportionnelle a sa part. C'est souvent plus lisible qu'un camembert de toute facon, et il suffit du code de barre avec les rectangles poses bout a bout. N'utilisez la version avec arc que lorsque la conception exige un vrai cercle.

Rien de tout cela ne depend d'une bibliotheque graphique installee, ce qui est l'avantage discret de dessiner les primitives directement. Les memes appels de dessin sur canevas qui placent un logo ou une zone de signature construisent ces graphiques, et le meme TextOut qui etiquette un champ de formulaire etiquette un axe. Placez la geometrie du trace dans un enregistrement, faites correspondre les valeurs a Y une fois, et un graphique en barres, en courbes ou en secteurs est une courte routine sur Rectangle, LineTo et Circle que vous pouvez integrer dans n'importe quel rapport. Les appels Rectangle, MoveTo, LineTo, Circle, Fill, Stroke et TextOut utilises ici font partie du composant HotPDF pour Delphi et C++Builder.