Technisch artikel

Grafieken tekenen in PDF met HotPDF-primitieven (Delphi)

Een grafiek die als 96-DPI-bitmap uit een schermgrafiekcontrol wordt gekopieerd, oogt prima bij 100% zoom in een PDF-viewer. Zoom naar 400% of stuur de pagina naar een 600-DPI-laserprinter, en de staafranden worden zacht, de aslabels vervagen tot grijze vlekken en het bestand sleept een paar honderd kilobyte pixels mee voor wat in werkelijkheid een dozijn rechthoeken is. Als vectorpaden getekend blijft dezelfde grafiek op elke zoomfactor en elke printresolutie scherp, want de staven, assen en labels zijn PDF-pad- en tekstoperatoren die de viewer op elke gewenste schaal opnieuw rendert

Vectoruitvoer schuift het opmaakwerk naar uw code. Iets moet berekenen waar elke staaf, tick en label landt, en voor staaf-, lijn- en taartdiagrammen is die berekening één coördinaatmapping plus een paar lussen over rechthoeken, lijnsegmenten en bogen. Krijgt u de mapping één keer goed, dan zijn de lussen kort

DrawChart of zelf getekende primitieven in HotPDF?

HotPDF levert een kant-en-klare grafiekrenderer mee, en voor een standaardrapportgrafiek is dat het eerste om te proberen. Bouw een THPDFChart, voeg series toe met AddSeries (soorten hckLine, hckBar, hckArea, hckPie en hckScatter), vul ze met AddPoint en roep THPDFPage.DrawChart aan met een doelrechthoek. De renderer bedenkt zelf het asbereik, het raster, de ticklabels, de legenda en de themakleuren, en de overload met een structure-parent schrijft de grafiek als getagd figuur met alternatieve tekst uit de data, wat telt als het document een toegankelijkheidscontrole moet doorstaan

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 in pagina-units: left, top, width, height
    HotPDF1.CurrentPage.DrawChart(Chart, 60, 120, 470, 280);
  finally
    Chart.Free;
  end;
end;

De renderer heeft wel een vast vocabulaire. Verschillende staafseries staan naast elkaar in elke categorie, een taartdiagram moet de enige serie in zijn grafiek zijn, en de look komt uit drie thema's plus per-series kleuren. Gestapelde staven, een doelband achter de staven, per staaf in één serie een andere merkkleur, een taartpunt die om twaalf uur begint en met de klok mee loopt: zodra het ontwerp iets van dit alles vraagt, is de grafiek zelf tekenen met de pagina-canvas minder werk dan de renderer buigen. Die primitieven zijn Rectangle, MoveTo, LineTo, Pie, Fill, Stroke en TextOut, dezelfde aanroepen als behandeld in de canvas-tekenwalkthrough

Waarom staat mijn staafdiagram ondersteboven?

HotPDF-paginacoördinaten zetten de oorsprong in de linkerbovenhoek met Y die naar beneden groeit, hetzelfde kader als een VCL-canvas. PDF user space zelf loopt andersom (oorsprong linksonder, Y omhoog, ISO 32000-1 §8.3.2.3), en HotPDF draait elke coördinaat bij het schrijven van de contentstream in die ruimte om. Grafiekwaarden groeien daarentegen omhoog. Voert u een geschaalde waarde rechtstreeks in als Y, dan hangt de langste staaf als een ijskegel vanaf de bovenkant van de plot

De oplossing is één uitdrukking. Kies een plotrechthoek, map nul op de onderrand en de top van de schaal op de bovenrand, en haal elke waarde door die mapping voordat hij de pagina raakt. Voor een waarde V op een schaal van 0 tot MaxValue is de pagina-Y Plot.Bottom - (V / Plot.MaxValue) * Plot.Height, waarbij Plot.Bottom de basislijn is. De staafdiagramcode hieronder wikkelt dat in een kleine ValueToY-hulpfunctie en elke andere routine hergebruikt hem

HotPDF: schermcoördinaten en HotPDF-paginacoördinaten delen een oorsprong linksboven met Y die naar beneden groeit terwijl grafiekwaarden omhoog groeien, en ValueToY keert een datawaarde om in die Y-omlaag-paginaruimte
HotPDF houdt het vertrouwde schermkader aan, oorsprong linksboven en Y die naar beneden groeit, dus één ValueToY-uitdrukking keert elke omhoog groeiende datawaarde om naar paginaruimte

In die uitdrukking schuilen twee keuzes. MaxValue hoort een rond getal boven het grootste datapunt te zijn, het volgende veelvoud van 10 of 100, zodat de langste staaf de bovenrand niet raakt en de rasterlijnlabels 0, 200, 400 lezen in plaats van 0, 203,5, 407. En de schaal begint met opzet bij nul. Staaflengte is de waarde, dus een waardeas die bij 350 begint laat een staaf van 388 nauwelijks hoger lijken dan de helft van een staaf van 420, terwijl het echte verschil onder 8% zit. De ingebouwde renderer trekt nul om precies deze reden in het bereik voor staaf- en vlakseries. Houd dezelfde regel aan in zelfgetekende code

Units zijn de andere helft van de mapping. Met de standaard Resolution van 72 is één pagina-unit één PDF-punt (1/72 inch). Wijzigt u de resolutie, dan schakelen uw coördinaten, lijnbreedtes en tekstmaten allemaal naar de nieuwe unit, wat het artikel over Resolution en tekenunits in detail behandelt. DrawChart neemt zijn doelrechthoek in pagina-units maar zet zijn eigen marges, fontgroottes en lijnbreedtes in punten neer, bij elke resolutie

Een staafdiagram is een lus over rechthoeken

Met de mapping op zijn plek is een staafdiagram een lus. Verdeel de plotbreedte in één vak per categorie, laat wat lucht zodat naburige staven elkaar niet raken, en teken elke staaf als gevulde rechthoek van zijn ValueToY-top tot op de basislijn. Rectangle neemt de linkerbovenhoek, een breedte en een hoogte, wat precies op deze mapping past:

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
type
  TPlotArea = record
    Left, Bottom, Width, Height: Single;  // pagina-units; Bottom is de basislijn-Y
    MaxValue: Single;                     // top van de waardeschaal
  end;

// Uw eigen helper: een grotere waarde zit hoger, wat op een Y-omlaag-pagina
// een kleinere Y betekent
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;              // een kwart van het vak, links en rechts gesplitst
  BarW := SlotW - Gap;

  // Eerst de gestreepte rasterlijnen, zodat de staven eroverheen schilderen
  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 zet de bovenkant van de tekst op Y
    Page.SetRGBFillColor(clBlack);
    Page.TextOut(BarX, Plot.Bottom + 4, 0, Labels[I]);
    Page.TextOut(BarX, BarTop - 12, 0, FormatFloat('0', Values[I]));
  end;

  // De basislijn als laatste, zodat hij boven op de staafvoeten ligt
  Page.SetLineWidth(1.0);
  Page.SetRGBStrokeColor(clBlack);
  Page.MoveTo(Plot.Left, Plot.Bottom);
  Page.LineTo(Plot.Left + Plot.Width, Plot.Bottom);
  Page.Stroke;
end;

Volgorde is hier het hele laagmodel. PDF gebruikt een schildersmodel, waarbij latere operatoren in de contentstream over eerdere schilderen, dus de rasterlijnen eerst, de staven eroverheen, de basislijn als laatste. Wisselt u het rasterlijnblok naar onder de lus, dan lopen de streepjes dwars door elke staaf

De tussenruimte is een fractie van het vak in plaats van een vast aantal punten. Met vier categorieën op een plot van 470 punten is elk vak 117,5 punten breed en krijgen de staven ongeveer 29 punten lucht ertussen; met veertig categorieën laat dezelfde code minder dan 3 punten over, wat nog steeds als aparte staven leest. Een vaste tussenruimte van 12 punten zou bij veertig categorieën 480 van de 470 beschikbare punten opslokken en de staafbreedte zou negatief worden

Aslabels die elkaar niet raken

Labels zijn waar handgetekende grafieken het eerst breken, en bijna altijd op dezelfde manier. Tekst die op het scherm paste loopt op de pagina uit zijn ruimte, omdat de ontwikkelaar met "Sep" testte en het Duitse rapport "September" of "Dezember" print in een font van 9 punten. De waardeas heeft een stillere variant van hetzelfde probleem: links uitgelijnde ticklabels op een vaste X-offset ogen gehavend zodra de getallen van lengte veranderen, en een label "1.200" loopt tegen de aslijn aan die "800" nog wist te vermijden

Meet in plaats van te gokken. TextWidth geeft de breedte van een string in het actieve font terug, in pagina-units, en TextHeight geeft de ascent van het font op de actieve grootte. Lijn elke ticklabel rechts uit tegen de as en centreer hem op zijn tick:

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);        // kort streepje buiten de as
    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;

Voor categorielabels vertelt dezelfde meting wanneer u horizontale tekst moet opgeven. Vergelijk het breedste label met de vakbreedte voordat u iets tekent. Past het niet, laat het font dan een punt of twee zakken; past het nog niet, geef dan 90 als hoekargument van TextOut en zet de labels op hun punt, en houd dan onder de basislijn genoeg marge voor het langste. Test met het breedste label dat het rapport werkelijk kan opleveren, wat bij maandnamen in een gelokaliseerde build zelden het Engelse is

Lijndiagrammen hergebruiken dezelfde mapping

Een lijndiagram verandert alleen hoe de punten verbonden worden. Loop de data één keer door, zet elke waarde om met ValueToY en rijg de punten aan één pad: MoveTo voor het eerste punt, LineTo voor elk volgend, één Stroke op het eind. Heeft u de punten al in een array, dan neemt Polyline een array of THPDFCurrPoint, opent een vers pad en laat het stroke aan u; onder twee punten gooit hij een exception, dus een serie met één meting heeft een marker nodig in plaats van een lijn

De onderlinge afstand is waar een lijndiagram afwijkt van een staafdiagram over dezelfde plot. Staven delen de breedte door het aantal categorieën omdat elke staaf een vak bezit. Een lijn deelt door het aantal intervallen, Count - 1, want het eerste en laatste punt zitten op de plotranden. Legt u de twee over elkaar, dan landen de lijnpunten op de vakgrenzen in plaats van op de staafcentra. Rijdt een lijn over de staven, plaats zijn punten dan op elk vacentrum, Plot.Left + I * SlotW + SlotW / 2, en laat de Count - 1-afstand helemaal vallen. Voor een marker op elk punt doet een kleine Circle gevolgd door Fill na het strijken van de lijn het werk

Taartpunten met Pie, met de klok mee vanaf twaalf uur

Een taartpunt bestaat uit twee stralen en een boog. De eigenzinnige manier om er een te bouwen begint in het midden, stapt korte LineTo-segmenten van een paar graden uit elkaar langs de rand en sluit terug naar het midden, precies de constructie in de figuur hieronder. Het werkt, maar een taartpunt van 270 graden verandert in ruim honderd padsegmenten en de rand blijft onder sterke zoom een veelhoek

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

Gebruik liever Pie. THPDFPage.Pie volgt de GDI-conventie: een bounding-rechthoek, een start- en een eindhoek in graden, 0 wijst naar het oosten, 90 naar het noorden, positieve hoeken tegen de klok in. Hij bouwt de boog uit Bézier-segmenten van hoogstens 90 graden elk, tekent de lijn naar het midden en sluit het pad, dus de rand blijft een echte curve en een volledige punt kost een handjevol operatoren. Zakelijke taarten beginnen meestal om twaalf uur en lopen met de klok mee, wat in GDI-termen betekent: beginnen op 90 en de hoek naar beneden laten lopen:

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;                            // twaalf uur
  Page.SetRGBStrokeColor(clWhite);
  Page.SetLineWidth(0.8);
  for I := 0 to High(Values) do
  begin
    if Values[I] <= 0 then
      Continue;                           // zie hieronder: geef nooit een nul of negatieve veeg door
    Sweep := Values[I] / Total * 360;
    Page.SetRGBFillColor(Colors[I mod Length(Colors)]);
    // Tegen de klok in van Angle - Sweep tot Angle is de punt die
    // met de klok mee van de huidige positie zit
    Page.Pie(CX - Radius, CY - Radius, CX + Radius, CY + Radius,
      Angle - Sweep, Angle);
    Page.FillAndStroke;                   // de witte omlijning scheidt de punten
    Angle := Angle - Sweep;
  end;
end;

De Continue-bewaking is de regel die er het meest toe doet. Pie behandelt een eindhoek onder de starthoek als een veeg die voorbij 360 loopt, de GDI-lezing, en dat is juist wat een punt over de 0-gradenlijn laat gaan. Een terugbetaalregel of een correctieregel in de brongegevens geeft een negatieve waarde, de berekende eindhoek belandt onder de start, en de punt wikkelt zich als bijna volledige schijf over alles wat daarvoor is getekend. Een nulwaarde is milder maar wel zichtbaar: het gesloten pad krimpt tot een straal, en de witte scheidingslijn tekent hem als een verdwaalde lijn van rand naar midden. Filter beide weg vóór de lus en besluit apart hoe het rapport negatieve bedragen toont, want een taart kan ze helemaal niet tonen

Als het ontwerp geen cirkel eist, laat één horizontale gestapelde staaf dezelfde verhoudingen zien met niets anders dan Rectangle-aanroepen achter elkaar, en lezers vergelijken lengtes nauwkeuriger dan hoeken. Voor een deel-van-het-geheel-figuur met meer dan vijf of zes categorieën is dat het betere diagram, en de staafdiagramcode hierboven doet al het meeste werk

Elke grafiek in dit artikel is opgebouwd uit de pagina-canvas-aanroepen van het HotPDF Delphi Component, met DrawChart beschikbaar voor de standaardlijn-, staaf-, vlak-, taart- en spreidingslay-outs, en alles draait vanuit Delphi of C++Builder zonder een grafiekbibliotheek op de buildmachine