Technisch artikel

Grafieken tekenen in PDF met HotPDF-primitieven (Delphi)

HotPDF heeft geen grafiekobject. Er is geen TPDFChart, geen AddBarSeries, niets dat een array getallen aanneemt en een gerenderde grafiek teruggeeft. Wat het u in plaats daarvan geeft, is een paginacanvas met dezelfde laagniveauwoordenschat die elk PDF-tekenmodel gebruikt: rechthoeken, lijnen, cirkels, vullingen, lijnstreken en tekst geplaatst op exacte coördinaten. Een grafiek in een HotPDF-document is daarom iets wat u bouwt, niet iets wat u opvraagt. Dat klinkt als meer werk dan het is. Zodra u de coördinatenrekenkunde één keer hebt geschreven, is een staafdiagram een lus over rechthoeken, is een lijndiagram een polylijn en is een taartdiagram een waaier van bogen, en beheerst u elke pixel van het resultaat

Dit doet ertoe omdat het alternatief waar mensen als eerste naar grijpen, een schermgrafiek naar een bitmap rasteren en die afbeelding in de pagina plakken, u een grafiek geeft die vastzit aan schermresolutie, wazig afdrukt en het bestand opblaast. De grafiek met de vectorprimitieven van HotPDF tekenen houdt de uitvoer scherp bij elke zoom en elke afdruk-DPI, omdat de staven en assen echte PDF-padoperatoren zijn en geen pixels. De prijs is dat de opmaak van u is. De mechaniek komt neer op een paar zetten: de ene coördinatenomkering waar iedereen over struikelt, een uitgewerkt staafdiagram, de polylijntruc voor lijndiagrammen, en de boogrekenkunde voor taartpunten

Het enige lastige is het coördinatenstelsel

Schermgraphics leggen de oorsprong linksboven met Y die naar beneden groeit. PDF doet het omgekeerde. De oorsprong ligt in de linkeronderhoek van de pagina en Y groeit omhoog, gemeten in punten (1/72 inch). Elke tekenaanroep in HotPDF, TextOut, Rectangle, MoveTo, LineTo, Circle, gebruikt die conventie van linksonder met Y omhoog. Neemt u uw instincten uit schermgraphics mee, dan tekent uw eerste grafiek ondersteboven en loopt hij van de onderkant van de pagina af

Het echte werk in elke grafiek is dus één afbeelding: een datawaarde omzetten in een Y-coördinaat die Y-omhoog respecteert. Kies een plotrechthoek, vier getallen voor het gebied waarin de grafiek woont, en map dan de kleinste waarde in uw data op de onderrand en de grootste op de bovenrand. Voor een staaf met waarde V op een schaal die van 0 tot MaxValue loopt, is de bovenrand van de staaf PlotBottom + (V / MaxValue) * PlotHeight, en de staaf groeit omhoog vanaf PlotBottom. Krijg die ene uitdrukking goed en al het overige is boekhouding. De helper hieronder houdt de plotgeometrie vast en doet de omzetting, zodat de tekencode nooit twee keer ruwe rekenkunde aanraakt:

HotPDF: schermcoördinaten leggen de oorsprong linksboven met Y die naar beneden groeit terwijl PDF-coördinaten linksonder beginnen met Y die omhoog groeit, en ValueToY mapt een datawaarde in die Y-omhoogruimte
PDF keert het vertrouwde schermkader om: de oorsprong ligt linksonder en Y groeit omhoog, en één ValueToY-uitdrukking mapt elke datawaarde in die ruimte
type
  TPlotArea = record
    Left, Bottom, Width, Height: Single;  // PDF-punten, oorsprong linksonder
    MaxValue: Single;                     // bovenkant van de waardeschaal
  end;

// Map een datawaarde op haar Y-coördinaat in de plot, met Y omhoog groeiend.
function ValueToY(const Plot: TPlotArea; V: Single): Single;
begin
  Result := Plot.Bottom + (V / Plot.MaxValue) * Plot.Height;
end;

Er schuilt één beoordelingskwestie in MaxValue. Zet u die op precies het grootste datapunt, dan raakt de hoogste staaf de bovenrand van de plot en oogt hij afgeknipt. Rond hem af naar een net getal boven het maximum, bijvoorbeeld het eerstvolgende veelvoud van 10 of 100, zodat de grafiek kopruimte heeft en de labels bij de rasterlijnen als ronde getallen lezen in plaats van als de toevallige piek van de data

Een staafdiagram is een lus over rechthoeken

Met de afbeelding vastgelegd schrijft een staafdiagram zichzelf. Verdeel de plotbreedte in één vak per categorie, laat een tussenruimte tussen de staven zodat ze elkaar niet raken, en teken elke staaf als een gevulde rechthoek waarvan de hoogte uit ValueToY komt. De Rectangle van HotPDF neemt de linkeronderhoek plus een breedte en een hoogte, wat precies aansluit bij een staaf die vanaf de basislijn omhoog groeit. Stel eerst de vulkleur in, leg het pad neer, en roep dan Fill aan om het te schilderen. Het categorielabel gaat onder de basislijn, de waarde boven de staaf:

HotPDF: anatomie van een staafdiagram met vakken verdeeld over de plotbreedte met tussenruimten tussen de staven, elke staaf een gevulde Rectangle die omhoog groeit vanaf Plot.Bottom, categorielabels onder de basislijn, waardelabels die met de staafhoogte meegaan, en asstreepjes die ValueToY hergebruiken
Elke staaf is één gevulde Rectangle die vanaf Plot.Bottom omhoog groeit binnen haar vak, de tussenruimte van een kwart vak schaalt mee met de breedte, en asstreepjes hergebruiken ValueToY zodat staven en rasterlijnen overeenstemmen
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;          // tussenruimte van een kwart vak aan elke kant
  BarW := SlotW - Gap;

  // Basislijn (de X-as) langs de onderkant van de 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, breedte, hoogte
    Page.Fill;

    // Categorielabel onder de basislijn, waarde boven de staaf.
    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;

Twee details verdienen hun plek. De Gap is een fractie van het vak in plaats van een vast aantal punten, dus de staven blijven proportioneel gespreid of u nu vier categorieën plot of veertig. En het waardelabel wordt gepositioneerd met dezelfde uit ValueToY afgeleide hoogte die de staaf gebruikt, zodat het altijd net boven zijn eigen staaf zit in plaats van op een geraden offset te zweven. Wilt u horizontale rasterlijnen achter de staven, teken die dan vóór de lus: kies drie of vier ronde waarden, draai ValueToY op elk ervan, en trek een vage lijn dwars over de plot op die Y. Ze eerst tekenen zet ze achter de staven in de schildersmodelstapeling die PDF gebruikt

Assen, streepjes en labels zijn gewoon meer lijnen en tekst

De grafiek is niet af totdat een lezer kan zien wat de staven betekenen, en dat is volledig aswerk. De verticale as is één getrokken lijn langs de linkerrand van de plot met een handvol streepjes en hun waarden. Hergebruik ValueToY zodat de streepjes op dezelfde schaal landen die de staven gebruiken, anders spreken een staaf en zijn rasterlijn elkaar tegen en liegt de grafiek in stilte:

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);   // kort streepje buiten de as
    Page.LineTo(Plot.Left, TickY);
    Page.Stroke;
    Page.TextOut(Plot.Left - 30, TickY - 3, 0, FormatFloat('0', TickV));
  end;
end;

Labels zijn de plek waar grafieken in productie het vaakst stukgaan, en het falen is altijd hetzelfde: tekst die op het scherm paste, loopt in de PDF over haar ruimte heen. Lange categorienamen botsen met hun buren, en gelokaliseerde maandnamen als "septembre" of "Dezember" zijn breder dan het Engelse "Sep" waarmee u hebt getest. Er is hier geen autosize die u redt, dus laat echte marge onder de basislijn, verklein het lettertype een punt of twee voor dichte categoriesets, en draai de namen als ze werkelijk lang zijn. TextOut neemt een hoek als derde argument, dus 90 doorgeven zet het label rechtop en levert u ruimte zonder overlap. Test de opmaak met uw breedste verwachte label, niet met uw kortste, voordat de export de deur uitgaat

Lijndiagrammen: één polylijn door gemapte punten

Een lijndiagram hergebruikt de hele waardeafbeelding en verandert alleen hoe de punten verbinden. In plaats van een rechthoek per categorie loopt u de data één keer door, zet elke waarde met ValueToY om naar haar (X, Y), en rijgt u de punten aaneen met één MoveTo gevolgd door LineTo-aanroepen, aan het eind getrokken. Het eerste punt opent het pad; elk later punt verlengt het:

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 het pad bij het eerste punt
    else
      Page.LineTo(X, Y);       // verleng het door elk later punt
  end;
  Page.Stroke;                 // één streek schildert de hele polylijn
end;

Let op het verschil in verdeling. Een staafdiagram deelt de breedte door het aantal staven, omdat elke staaf een vak bezit. Een lijndiagram deelt door het aantal intervallen, Count - 1, omdat het eerste en het laatste punt op de plotranden liggen en de lijn de tussenruimtes overspant. Die twee door elkaar halen is de gebruikelijke reden dat een lijndiagram een half vak afwijkt van het staafdiagram waarop het moet liggen. Wilt u een markering bij elk datapunt, zet dan na het trekken van de polylijn een kleine Circle met Fill op elke (X, Y)

Taartdiagrammen: bogen, of punten als u het simpel houdt

Taartpunten zijn de ene vorm die trigonometrie vereist, want een punt wordt begrensd door twee stralen en een boog. De eerlijke versie doorloopt de boog door kleine lijnsegmenten langs de omtrek te stappen, wat de kromming zo dicht benadert dat geen lezer het merkt. De veeghoek van elke punt is haar aandeel in het totaal, (Value / Total) * 2π, en u telt de lopende hoek op terwijl u de cirkel rondgaat:

HotPDF: opbouw van een taartpunt die begint met MoveTo in het midden van de cirkel, kleine LineTo-segmenten van zo n twee graden langs de boog stapt over de veeghoek van elke punt, en dan terugsluit naar het midden zodat Fill een massieve punt schildert
Een punt begint met MoveTo in het midden, loopt in kleine LineTo-stappen van ongeveer twee graden langs haar deel van de omtrek, en sluit terug zodat Fill haar massief schildert
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 graden per segment

    Page.SetRGBFillColor(Colors[I]);
    Page.MoveTo(CX, CY);                     // top van de punt in het midden
    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);                      // sluit terug naar het midden
    Page.Fill;

    Start := Start + Sweep;                   // ga door naar de volgende punt
  end;
end;

Het pad van het midden naar buiten, eerst de top, dan de boog, dan terug naar de top, levert een gesloten punt op die Fill massief schildert. Het aantal segmenten ruilt gladheid tegen padgrootte: ongeveer twee graden per stap oogt rond bij elke redelijke straal zonder een enorme contentstream te produceren. Hebt u geen echte cirkel nodig, dan kunt u de trigonometrie helemaal overslaan en dezelfde data renderen als één horizontale gestapelde staaf, waarbij de breedte van elk segment evenredig is aan zijn aandeel. Dat is vaak toch leesbaarder dan een taart, en het is gewoon de staafcode met de rechthoeken kop aan staart gelegd. Grijp alleen naar de boogversie wanneer het ontwerp om een echte cirkel vraagt

Niets hiervan hangt af van een geïnstalleerde grafiekbibliotheek, wat het stille voordeel is van de primitieven rechtstreeks tekenen. Dezelfde canvas-tekenaanroepen die een logo of een handtekeningvak plaatsen, bouwen deze grafieken, en dezelfde TextOut die een formulierveld labelt, labelt een as. Zet de plotgeometrie in een record, map waarden één keer op Y, en een staaf-, lijn- of taartdiagram is een korte routine over Rectangle, LineTo en Circle die u in elk rapport kunt laten vallen. De hier gebruikte aanroepen Rectangle, MoveTo, LineTo, Circle, Fill, Stroke en TextOut maken deel uit van het HotPDF Delphi Component voor Delphi en C++Builder