Articolo tecnico

Rendering di una tabella dati in PDF in Delphi con HotPDF

Un dataset è composto da righe e colonne; una pagina PDF è una griglia di coordinate vuota senza alcuna nozione di entrambe. Colmare questo divario è l'intero lavoro da svolgere. In HotPDF non esiste una chiamata DrawTable che accetti un dataset e restituisca una griglia formattata. Ciò che si ottiene invece sono le primitive con cui è costruita una griglia: TextOut per posizionare una stringa in un punto, SetFont per scegliere il font, Rectangle e Fill per sfumare una fascia, e MoveTo / LineTo / Stroke per disegnare le linee di griglia. Un esportatore di tabelle funzionante richiede la disciplina di trasformare il pensiero basato su righe e colonne in coordinate x e y esplicite, mantenendo tali coordinate coerenti quando i dati superano il fondo della pagina.

L'esempio seguente riporta i record dei clienti, ma nulla nel codice di disegno sa o si preoccupa di dove provengano le righe. L'originale utilizzava un componente legacy TTable; una query FireDAC, un dataset in memoria o un semplice array di record alimentano le stesse routine senza modifiche. Ciò che conta è poter scorrere i dati una riga alla volta e leggere quattro campi stringa da ciascuna di esse. Mantenendo il rendering separato dalla sorgente dati, è possibile modificare uno dei due lati senza influenzare l'altro.

La geometria delle colonne viene prima di tutto

Prima di disegnare anche un solo carattere, decidi dove si trova ciascuna colonna. In questo esempio, la tabella ha quattro colonne, quindi ha bisogno di quattro bordi sinistri e di un margine destro noto. Cablare un numero magico in ogni chiamata TextOut, come tendono a fare i rapidi esempi, è esattamente ciò che rende difficile allargare la tabella in seguito. Definisci i bordi una sola volta, in punti rispetto all'origine in basso a sinistra, e ogni chiamata di disegno farà riferimento ad essi per nome:

const
  ColNo   = 70;    // left edge of the "No." column
  ColName = 110;   // company name
  ColAddr = 300;   // street address
  ColCity = 480;   // city
  RowLeft = 50;    // table frame: left rule
  RowRight = 570;  // table frame: right rule
  RowStep = 20;    // vertical distance between baselines

procedure PrintRow(Page: THPDFPage; Y: Single;
  const ANo, AName, AAddr, ACity: string; Shaded: boolean);
begin
  if Shaded then
  begin
    // A shaded band behind the row. Rectangle takes X, Y, Width, Height.
    Page.SetRGBFillColor($00FFF3DD);
    Page.Rectangle(RowLeft, Y - 4, RowRight - RowLeft, RowStep);
    Page.Fill;
    Page.SetRGBFillColor(clBlack);
  end;
  Page.TextOut(ColNo,   Y, 0, ANo);
  Page.TextOut(ColName, Y, 0, AName);
  Page.TextOut(ColAddr, Y, 0, AAddr);
  Page.TextOut(ColCity, Y, 0, ACity);
end;

Due dettagli meritano attenzione in questo caso. La fascia sfumata viene disegnata prima, poi il testo sopra, poiché l'ordine di disegno è l'ordine Z in PDF: se riempi il rettangolo dopo il testo, finirai per coprire la riga. Inoltre, la sfumatura alternata non è un semplice elemento decorativo. In un report denso di dati, rappresenta il modo più efficace per evitare che l'occhio scivoli sulla riga sbagliata, motivo per cui il ciclo successivo inverte un valore booleano a ogni riga e lo passa direttamente al parametro Shaded.

Le posizioni delle colonne sopra indicate sono fisse, il che è corretto per un report di cui si controlla lo schema. Quando i dati sono variabili, misura anziché indovinare. HotPDF espone la misurazione della larghezza del testo sull'oggetto pagina, quindi la versione di produzione di PrintRow può prendere il valore massimo atteso per ciascuna colonna, misurarlo una volta alla dimensione del font scelta e derivare i bordi sinistri da tali larghezze più uno spazio di spaziatura. La struttura della routine non cambia; cambia solo la sorgente delle costanti.

L'intestazione, le linee di griglia e l'unico punto che le gestisce

Una tabella che esce dalla pagina e riprende in quella successiva senza intestazioni di colonna è illeggibile. La soluzione consiste nel trattare l'intestazione come qualcosa da ridisegnare, non come qualcosa da disegnare una volta sola. Inserisci i titoli delle colonne e le linee orizzontali che li racchiudono in un'unica routine, e chiama questa routine sia all'inizio sia ogni volta che apri una nuova pagina. Poiché l'intestazione e il corpo condividono le stesse costanti di colonna, si allineano automaticamente per costruzione.

procedure DrawHeader(Page: THPDFPage; var Y: Single; PageNo: Integer);
begin
  // Left: source label and page number. Right: generation time.
  Page.SetFont('Arial', [fsItalic], 10);
  Page.TextOut(RowLeft, Y, 0, 'customer.db   Page ' + IntToStr(PageNo));
  Page.TextOut(ColCity, Y, 0, DateTimeToStr(Now));

  // Two horizontal rules that box the column titles.
  Page.MoveTo(RowLeft, Y + 15);
  Page.LineTo(RowRight, Y + 15);
  Page.MoveTo(RowLeft, Y + 45);
  Page.LineTo(RowRight, Y + 45);
  Page.Stroke;

  // The column titles, in a heavier face so they read as headings.
  Page.SetFont('Times New Roman', [fsBold], 12);
  Page.SetRGBFillColor(clNavy);
  PrintRow(Page, Y + 25, 'No.', 'Company', 'Address', 'City', False);
  Page.SetRGBFillColor(clBlack);

  Y := Y + RowStep + 45;  // advance past the boxed header before the first body row
end;

Si noti che DrawHeader accetta Y per riferimento e la sposta in avanti. Chi chiama non ha bisogno di ricordare l'altezza dell'intestazione; la routine che la disegna è l'unica a saperlo. Questa regola di gestione centralizzata evita che il layout si sposti se in seguito si aggiunge un logo o un riepilogo del filtro all'intestazione. Il ciclo del corpo rimane indipendente: continua semplicemente a disegnare righe a partire dal punto indicato da Y in quel momento.

Le linee stesse fanno la differenza tra un elenco e una tabella. I separatori verticali delle colonne seguono la stessa logica applicata all'asse x: una sequenza di MoveTo / LineTo / Stroke su ciascun bordo della colonna, tracciata dalla linea superiore fino al fondo dell'ultima riga della pagina. Questo esempio si limita alle linee orizzontali per rimanere leggibile, ma il passaggio alla produzione è meccanico una volta definite le costanti di colonna.

Il ciclo del cursore gestisce l'interruzione di pagina

Il disegno è la parte più semplice. Ciò che distingue un esempio di base da un report reale è la paginazione: sapere, prima di disegnare una riga, se c'è ancora spazio sufficiente e avviare una nuova pagina con una nuova intestazione in caso contrario. Questa decisione spetta esclusivamente a un unico punto, ovvero il ciclo che scorre i dati.

var
  Pdf: THotPDF;
  Page: THPDFPage;
  Y: Single;
  PageNo: Integer;
  Shaded: boolean;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'CustomerReport.pdf';
    Pdf.BeginDoc;
    Page := Pdf.CurrentPage;

    // Report title, once, at the top of the first page.
    Page.SetFont('Arial', [fsBold], 24);
    Page.TextOut(200, 800, 0, 'Customer Report');

    PageNo := 1;
    Y := 760;
    DrawHeader(Page, Y, PageNo);
    Shaded := False;

    CustomerTable.First;
    while not CustomerTable.Eof do
    begin
      // Out of room? Open a new page and repeat the header there.
      if Y < 60 then
      begin
        Pdf.AddPage;
        Page := Pdf.CurrentPage;   // AddPage moves CurrentPage forward
        Inc(PageNo);
        Y := 760;
        DrawHeader(Page, Y, PageNo);
      end;

      Shaded := not Shaded;
      Page.SetFont('Arial', [], 10);   // SetFont must be reissued on every new page
      PrintRow(Page, Y,
        VarToStr(CustomerTable['CustNo']),
        VarToStr(CustomerTable['Company']),
        VarToStr(CustomerTable['Addr1']),
        VarToStr(CustomerTable['City']),
        Shaded);

      Y := Y - RowStep;
      CustomerTable.Next;
    end;

    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Due fatti relativi alle coordinate guidano l'intero ciclo. Il formato PDF misura la coordinata y verso l'alto a partire dall'angolo in basso a sinistra, quindi le righe procedono verso il basso della pagina sottraendo RowStep da Y a ogni passaggio, e il controllo di pagina piena si attiva quando Y scende al di sotto del margine inferiore anziché al di sopra del limite superiore. Se inverti la direzione, la prima riga verrà stampata oltre il bordo inferiore mentre il ciclo crederà di avere ancora un'intera pagina a disposizione.

L'altro aspetto trae in inganno quasi tutti la prima volta. Il metodo AddPage crea una nuova pagina e imposta CurrentPage su di essa, ma non conserva alcuna impostazione precedente: né il font, né il colore di riempimento, né la posizione. Per questo motivo Page viene riletto da CurrentPage dopo ogni chiamata ad AddPage, e SetFont viene richiamato prima delle righe del corpo. Se salti la rilettura, continuerai a disegnare sulla pagina che hai appena lasciato; se salti l'impostazione del font, la nuova pagina verrà renderizzata con il font predefinito utilizzato dal visualizzatore.

I casi critici che possono compromettere un esportatore di tabelle

La maggior parte dei bug relativi alle tabelle non si presenta nello scenario ideale di poche decine di righe ordinate. Si verificano invece nei casi limite, che sono facili da testare una volta individuati.

  • Dataset vuoti. Un ciclo su zero righe produce una pagina con un'intestazione e nulla sotto di essa, il che almeno appare intenzionale. Una pagina completamente vuota senza intestazione sembra invece un errore. Decidi quale comportamento preferisci prima del rilascio.
  • La riga che cade esattamente sul limite. Genera un report in cui l'ultima riga si trova un passo sopra il margine, e poi uno in cui la riga successiva si trova un passo sotto. Gli errori di paginazione sfasati di uno si nascondono finché i dati non raggiungono esattamente la lunghezza critica.
  • Valori troppo lunghi. Un nome aziendale più largo della sua colonna si sovrapporrà a quella successiva. Misura il campo e decidi una strategia: mandare a capo su una seconda riga, ritagliare il testo o troncarlo con i tre puntini di sospensione. Non gestire il caso non è una strategia accettabile.
  • Campi Null. La lettura di un valore null direttamente in TextOut può tradursi nel testo letterale Null o in uno spazio vuoto, a seconda di come viene convertito. Scegli il rendering in modo deliberato anziché lasciare che sia la conversione di tipo variant a decidere per te.

Verifica il risultato con più visualizzatori PDF prima di considerare finito il lavoro. La sostituzione dei font e il ritaglio del testo si comportano in modo diverso a seconda dei motori di rendering; una tabella che appare allineata in un lettore PDF può mostrare una colonna disallineata o una città troncata in un altro. Conferma che l'intestazione ripetuta, la sfumatura delle righe e i margini vengano conservati correttamente e che la numerazione delle pagine rimanga continua quando i dati superano il limite della pagina.

Disegnare la griglia manualmente anziché affidarsi a un designer di report visivo richiede più codice, ed è bene definirne chiaramente i vantaggi: hai il controllo completo su ogni singola coordinata, il che è esattamente ciò che serve per elaborazioni batch lato server, fatture ed esportazioni di audit che devono essere renderizzate in modo identico su ogni macchina, ma rappresenta un sovraccarico di lavoro che si preferirebbe evitare per un semplice elenco interno. Nel primo caso, il controllo preciso ripaga lo sforzo fin dalla prima volta in cui il report deve apparire in produzione esattamente come sul proprio monitor di sviluppo.

Le linee e le fasce sfumate sopra menzionate si basano sulle stesse primitive vettoriali e di colore trattate nella guida al disegno sul canvas, se si desidera esaminare prima le chiamate Rectangle, MoveTo e LineTo in modo indipendente. Le primitive di disegno utilizzate qui fanno parte di HotPDF Component per Delphi e C++Builder.