Articolo tecnico

Disegnare grafici in PDF con le primitive di HotPDF (Delphi)

Un grafico copiato da un controllo grafico a schermo come bitmap a 96 DPI sembra a posto al 100% di zoom in un visualizzatore PDF. Zoom al 400%, o mandate la pagina a una stampante laser a 600 DPI, e i bordi delle barre diventano morbidi, le etichette degli assi sfocano in sbavature grigie, e il file porta qualche centinaio di kilobyte di pixel per quella che in realtà è una dozzina di rettangoli. Disegnato invece come percorsi vettoriali, lo stesso grafico resta nitido a ogni livello di zoom e risoluzione di stampa, perché barre, assi ed etichette sono operatori di percorso e testo PDF che il visualizzatore ri-renderizza alla scala che gli serve

L'output vettoriale sposta il lavoro di layout nel vostro codice. Qualcosa deve calcolare dove atterra ogni barra, tacca ed etichetta, e per grafici a barre, linee e torta quel calcolo è una mappatura di coordinate più qualche ciclo su rettangoli, segmenti di linea e archi. Azzeccata la mappatura una volta, i cicli sono corti

DrawChart o primitive disegnate a mano in HotPDF?

HotPDF include un renderer di grafici già fatto, e per un grafico da report standard è la prima cosa da provare. Costruite un THPDFChart, aggiungete serie con AddSeries (tipi hckLine, hckBar, hckArea, hckPie e hckScatter), riempitele con AddPoint, e chiamate THPDFPage.DrawChart con un rettangolo di destinazione. Il renderer ricava da solo intervallo degli assi, griglia, etichette delle tacche, legenda e colori del tema, e l'overload che prende un structure parent scrive il grafico come figura taggata con testo alternativo costruito dai dati, cosa che conta se il documento deve passare un controllo di 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');
    // Rettangolo in unità di pagina: left, top, width, height
    HotPDF1.CurrentPage.DrawChart(Chart, 60, 120, 470, 280);
  finally
    Chart.Free;
  end;
end;

Il renderer però ha un vocabolario fisso. Diverse serie a barre stanno fianco a fianco in ogni categoria, un grafico a torta deve essere l'unica serie del suo grafico, e l'aspetto viene da tre temi più i colori per serie. Barre impilate, una banda obiettivo sfumata dietro le barre, un colore di marca diverso per ogni barra di una stessa serie, una torta che parte dalle dodici e gira in senso orario: appena un design chiede una di queste cose, disegnare il grafico da soli con il canvas di pagina è meno lavoro che piegare il renderer. Quelle primitive sono Rectangle, MoveTo, LineTo, Pie, Fill, Stroke e TextOut, le stesse chiamate trattate nella procedura guidata del disegno su canvas

Perché il mio grafico a barre esce sottosopra?

Le coordinate di pagina HotPDF mettono l'origine nell'angolo in alto a sinistra con la Y che cresce verso il basso, lo stesso sistema di riferimento di un canvas VCL. Lo spazio utente PDF in sé corre dall'altra parte (origine in basso a sinistra, Y verso l'alto, ISO 32000-1 §8.3.2.3), e HotPDF capovolge ogni coordinata in quello spazio mentre scrive il content stream. I valori di un grafico, d'altra parte, crescono verso l'alto. Passate un valore scalato dritto come Y e la barra più alta pende dalla cima del tracciato come un ghiacciolo

La correzione è un'espressione. Scegliete un rettangolo di tracciato, mappate lo zero sul suo bordo inferiore e la cima della scala sul suo bordo superiore, e fate passare ogni valore per quella mappatura prima che tocchi la pagina. Per un valore V su una scala da 0 a MaxValue, la Y di pagina è Plot.Bottom - (V / Plot.MaxValue) * Plot.Height, dove Plot.Bottom è la baseline. Il codice del grafico a barre più avanti avvolge quella formula in un piccolo helper ValueToY e ogni altra routine lo riusa

HotPDF: le coordinate schermo e le coordinate pagina di HotPDF condividono un'origine in alto a sinistra con Y crescente verso il basso, mentre i valori del grafico crescono verso l'alto, e ValueToY capovolge un valore dei dati in quello spazio pagina con Y verso il basso
HotPDF conserva il consueto sistema di riferimento dello schermo, origine in alto a sinistra e Y crescente verso il basso, così una sola espressione ValueToY capovolge ogni valore dei dati in crescita verso l'alto nello spazio pagina

In quella formula si nascondono due scelte. MaxValue dovrebbe essere un numero tondo sopra il punto dati più grande, il successivo multiplo di 10 o 100, così la barra più alta non tocca il bordo superiore e le etichette delle linee di griglia leggono 0, 200, 400 anziché 0, 203.5, 407. E la scala parte da zero apposta. La lunghezza della barra è il valore, quindi un asse dei valori che parte da 350 fa sembrare una barra da 388 a malapena più della metà di una da 420 quando il divario vero è sotto l'8%. Il renderer integrato tira lo zero nell'intervallo per le serie a barre e area esattamente per questo motivo. Tenete la stessa regola nel codice disegnato a mano

Le unità sono l'altra metà della mappatura. Con il Resolution predefinito di 72, un'unità di pagina è un punto PDF (1/72 di pollice). Cambiate la risoluzione e coordinate, larghezze di linea e misure del testo passano tutti alla nuova unità, cosa che l'articolo su Resolution e unità di disegno tratta nel dettaglio. DrawChart prende il proprio rettangolo di destinazione in unità di pagina ma impagina margini, dimensioni dei font e larghezze di linea in punti a qualunque risoluzione

Un grafico a barre è un ciclo sui rettangoli

Con la mappatura al posto, un grafico a barre è un ciclo. Dividete la larghezza del tracciato in uno slot per categoria, lasciate un varco così le barre vicine non si toccano, e disegnate ogni barra come rettangolo riempito dalla sua cima ValueToY fino alla baseline. Rectangle prende l'angolo in alto a sinistra, una larghezza e un'altezza, il che combacia direttamente con questa mappatura:

HotPDF: anatomia di un grafico a barre con suddivisioni ricavate dalla larghezza del tracciato e spazi tra le barre, ogni barra un Rectangle pieno che cresce verso l'alto da Plot.Bottom, etichette di categoria sotto la base, etichette dei valori all'altezza della barra e tacche degli assi che riusano ValueToY
Ogni barra è un Rectangle riempito che cresce verso l'alto da Plot.Bottom dentro il proprio slot, il varco di un quarto di slot scala con la larghezza, e i tick dell'asse riusano ValueToY così barre e linee di griglia concordano
type
  TPlotArea = record
    Left, Bottom, Width, Height: Single;  // unità di pagina; Bottom è la Y della baseline
    MaxValue: Single;                     // cima della scala dei valori
  end;

// Un vostro helper: un valore più grande sta più in alto, il che su una
// pagina con Y verso il basso significa una Y più piccola
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;              // un quarto dello slot, diviso a sinistra e a destra
  BarW := SlotW - Gap;

  // Prima le linee di griglia tratteggiate, così le barre dipingono sopra
  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 piazza la cima del testo a Y
    Page.SetRGBFillColor(clBlack);
    Page.TextOut(BarX, Plot.Bottom + 4, 0, Labels[I]);
    Page.TextOut(BarX, BarTop - 12, 0, FormatFloat('0', Values[I]));
  end;

  // La baseline per ultima, così sta sopra i fondi delle barre
  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'ordine è tutto il modello di stratificazione qui. PDF usa un modello del pittore, in cui gli operatori successivi nel content stream dipingono sopra i precedenti, quindi le linee di griglia vanno per prime, le barre sopra, la baseline per ultima. Scambiate il blocco delle linee di griglia sotto il ciclo e le linee tratteggiate corrono dritte attraverso ogni barra

Il varco è una frazione dello slot invece di un numero fisso di punti. Con quattro categorie su un tracciato da 470 punti ogni slot è largo 117.5 punti e le barre prendono circa 29 punti d'aria tra loro; con quaranta categorie lo stesso codice lascia meno di 3 punti, che si leggono ancora come barre separate. Un varco fisso da 12 punti inghiottirebbe 480 dei 470 punti disponibili a quaranta categorie e la larghezza della barra diventerebbe negativa

Etichette degli assi che non collidono

Le etichette sono dove i grafici disegnati a mano si rompono per primi, e quasi sempre allo stesso modo. Un testo che stava a schermo sfora il suo spazio sulla pagina, perché lo sviluppatore ha provato con "Sep" e il report tedesco stampa "September" o "Dezember" in un font da 9 punti. L'asse dei valori ha una variante più silenziosa dello stesso problema: etichette delle tacche allineate a sinistra a un offset X fisso sembrano arruffiate appena i numeri cambiano lunghezza, e un'etichetta "1,200" corre contro la linea dell'asse che "800" rispettava

Misurate invece di tirare a indovinare. TextWidth restituisce la larghezza di una stringa nel font corrente, in unità di pagina, e TextHeight restituisce l'ascent del font alla dimensione corrente. Allineate a destra ogni etichetta di tacca contro l'asse e centratala sulla sua tacca:

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);        // breve tacca fuori dall'asse
    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;

Per le etichette di categoria la stessa misura vi dice quando arrendersi sul testo orizzontale. Confrontate l'etichetta più larga con la larghezza dello slot prima di disegnare qualcosa. Se non ci sta, calate il font di un punto o due; se ancora non ci sta, passate 90 come argomento angolo di TextOut e mettete le etichette in verticale, poi lasciate margine a sufficienza sotto la baseline per la più lunga. Provate con l'etichetta più larga che il report può davvero produrre, che per i nomi dei mesi in una build localizzata è raramente quella inglese

I grafici a linee riusano la stessa mappatura

Un grafico a linee cambia solo il modo in cui i punti si collegano. Percorrete i dati una volta, convertite ogni valore con ValueToY, e cucite i punti in un solo percorso: MoveTo per il primo punto, LineTo per ogni successivo, un singolo Stroke alla fine. Se avete già i punti in un array, Polyline prende un array of THPDFCurrPoint, apre un percorso fresco e lascia lo stroke a voi; solleva un'eccezione con meno di due punti, quindi una serie con una singola lettura vuole un marcatore invece di una linea

La spaziatura è dove un grafico a linee devia da un grafico a barre disegnato sullo stesso tracciato. Le barre dividono la larghezza per il numero di categorie perché ogni barra possiede uno slot. Una linea lo divide per il numero di intervalli, Count - 1, perché il primo e l'ultimo punto stanno sui bordi del tracciato. Sovrapponete i due e i punti della linea atterrano sui confini degli slot invece che sui centri delle barre. Quando una linea cavalca le barre, piazzate i suoi punti al centro di ogni slot, Plot.Left + I * SlotW + SlotW / 2, e eliminate del tutto la spaziatura a Count - 1. Per un marcatore su ogni punto, un piccolo Circle seguito da Fill dopo che la linea è tracciata fa il lavoro

Spicchi di torta con Pie, in senso orario dalle dodici

Uno spicchio di torta è due raggi e un arco. Il modo artigianale di costruirne uno parte dal centro, avanza con brevi segmenti LineTo a un paio di gradi di distanza lungo il bordo, e richiude verso il centro, che è la costruzione nella figura sotto. Funziona, ma uno spicchio di 270 gradi diventa ben oltre un centinaio di segmenti di percorso e il bordo resta comunque un poligono sotto un forte zoom

HotPDF: costruzione di uno spicchio di torta partendo con MoveTo dal centro del cerchio, avanzando con piccoli segmenti LineTo a circa due gradi di distanza lungo l'arco dell'angolo di ogni spicchio, quindi chiudendo verso il centro così che Fill dipinga un settore pieno
Uno spicchio parte con MoveTo al centro, avanza a piccoli passi LineTo distanti circa due gradi lungo la propria porzione di circonferenza, e si chiude così che Fill lo dipinga in tinta unita

Usate invece Pie. THPDFPage.Pie segue la convenzione GDI: un rettangolo di delimitazione, un angolo iniziale e uno finale in gradi, 0 che punta a est, 90 a nord, angoli positivi in senso antiorario. Costruisce l'arco da segmenti Bézier di al più 90 gradi ciascuno, disegna la linea verso il centro e chiude il percorso, così il bordo resta una curva vera e uno spicchio intero costa una manciata di operatori. Le torte di business di solito partono dalle dodici e girano in senso orario, che in termini GDI significa partire da 90 e scendere con l'angolo:

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;                            // le dodici
  Page.SetRGBStrokeColor(clWhite);
  Page.SetLineWidth(0.8);
  for I := 0 to High(Values) do
  begin
    if Values[I] <= 0 then
      Continue;                           // vedi sotto: non passare mai uno sweep zero o negativo
    Sweep := Values[I] / Total * 360;
    Page.SetRGBFillColor(Colors[I mod Length(Colors)]);
    // In senso antiorario da Angle - Sweep ad Angle c'è la fetta che
    // sta in senso orario rispetto alla posizione corrente
    Page.Pie(CX - Radius, CY - Radius, CX + Radius, CY + Radius,
      Angle - Sweep, Angle);
    Page.FillAndStroke;                   // il tratto bianco separa le fette
    Angle := Angle - Sweep;
  end;
end;

La guardia Continue è la riga che conta di più. Pie tratta un angolo finale più piccolo di quello iniziale come uno sweep che avvolge oltre i 360, la lettura GDI, ed è ciò che permette a uno spicchio di attraversare la linea degli 0 gradi. Una riga di rimborso o una voce di correzione nei dati sorgente dà un valore negativo, l'angolo finale calcolato atterra sotto quello iniziale, e lo spicchio avvolge in un disco quasi pieno dipinto sopra tutto ciò che era stato disegnato prima. Un valore zero è più mite ma pur sempre visibile: il percorso chiuso collassa in un raggio, e il tratto bianco di separazione lo disegna come una linea dispersa dal bordo al centro. Filtrate entrambi prima del ciclo, e decidete a parte come il report mostra gli importi negativi, perché una torta non li può mostrare affatto

Quando il design non insiste sul cerchio, una singola barra impilata orizzontale mostra le stesse quote con nient'altro che chiamate Rectangle disposte una dietro l'altra, e i lettori confrontano le lunghezze più accuratamente degli angoli. Per una figura parte-del-tutto con più di cinque o sei categorie è il grafico migliore, e il codice del grafico a barre qui sopra fa già la maggior parte del lavoro

Ogni grafico in questo articolo è costruito con le chiamate del canvas di pagina dello HotPDF Delphi Component, con DrawChart disponibile per i layout standard a linea, barre, area, torta e scatter, e il tutto gira da Delphi o C++Builder senza una libreria di grafici installata sulla macchina di build