Article technique

Rendu d'un tableau de données en PDF avec HotPDF dans Delphi

Un dataset, c'est des lignes et des colonnes ; une page PDF, c'est une grille de coordonnées vierge qui n'a aucune notion ni des unes ni des autres. Combler cet écart, voilà tout l'enjeu ici. Il n'existe pas d'appel DrawTable dans HotPDF qui prendrait un dataset et vous rendrait une grille formatée. Ce que vous obtenez à la place, ce sont les primitives dont une grille est faite : TextOut pour placer une chaîne à un point, SetFont pour choisir sa police, Rectangle et Fill pour ombrer une bande, et MoveTo / LineTo / Stroke pour tracer des règles. Un exportateur de tableau fonctionnel, c'est la discipline qui consiste à traduire la logique lignes-colonnes en coordonnées x et y explicites, puis à maintenir ces coordonnées cohérentes lorsque les données dépassent le bas de la page.

L'exemple qui suit affiche des fiches clients, mais rien dans le code de dessin ne sait ni ne se soucie de l'origine des lignes. L'original utilisait un TTable hérité ; une requête FireDAC, un dataset en mémoire ou un simple tableau d'enregistrements alimente les mêmes routines sans modification. Ce qui compte, c'est de pouvoir parcourir les données ligne par ligne et de lire quatre champs de type chaîne dans chacune. Séparez le rendu de la source de données et vous pourrez modifier l'un ou l'autre sans perturber l'ensemble.

La géométrie des colonnes passe en premier

Avant de tracer le moindre caractère, définissez l'emplacement de chaque colonne. Le tableau comporte ici quatre colonnes, il faut donc quatre bords gauches et une marge droite connue. Coder en dur un nombre magique à chaque appel TextOut, comme le font les exemples rapides, c'est exactement ce qui rend un tableau pénible à élargir par la suite. Nommez les bords une seule fois, en points depuis l'origine en bas à gauche, et chaque appel de dessin y fait référence par son nom :

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;

Deux détails méritent d'être soulignés. La bande ombrée est dessinée en premier, puis le texte par-dessus, car l'ordre de dessin correspond à l'ordre z en PDF : remplir le rectangle après le texte revient à enterrer la ligne. Et l'ombrage alterné n'est pas de la décoration pour la décoration. Sur un rapport dense, c'est le moyen le moins coûteux d'empêcher l'oeil de glisser sur la mauvaise ligne, raison pour laquelle la boucle inverse un booléen à chaque ligne et le passe directement dans Shaded.

Les positions de colonnes ci-dessus sont fixes, ce qui est honnête pour un rapport dont vous maîtrisez le schéma. Quand les données sont variables, mesurez plutôt que devinez. HotPDF expose la mesure de la largeur du texte sur l'objet page, de sorte que la version de production de PrintRow peut prendre la valeur la plus longue attendue dans chaque colonne, la mesurer une fois à la taille de police choisie, et dériver les bords gauches de ces largeurs plus une gouttière. La forme de la routine ne change pas ; seule la source des constantes change.

L'en-tête, les règles et un seul endroit qui les possède

Un tableau qui dépasse sur une autre page et reprend sans libellés de colonnes est illisible. La solution consiste à traiter l'en-tête comme quelque chose que l'on redessine, et non comme quelque chose que l'on dessine une seule fois. Placez les titres de colonnes et les règles horizontales qui les encadrent dans une seule routine, et appelez cette routine au démarrage et à chaque ouverture de nouvelle page. Comme l'en-tête et le corps partagent les mêmes constantes de colonnes, ils s'alignent par construction.

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;

Remarquez que DrawHeader prend Y par référence et le fait avancer. L'appelant n'a jamais à se souvenir de la hauteur de l'en-tête ; la routine qui le dessine est celle qui le sait. Cette règle de propriété unique est ce qui empêche la mise en page de dériver lorsque vous ajoutez ultérieurement un logo ou un résumé de filtre dans la bande d'en-tête. La boucle du corps reste ignorante de tout cela. Elle continue simplement à dessiner des lignes depuis l'endroit où Y pointe actuellement.

Les règles elles-mêmes font la différence entre une liste et un tableau. Les séparateurs verticaux de colonnes suivent la même logique appliquée à l'axe x : un MoveTo / LineTo / Stroke à chaque bord de colonne, du trait supérieur jusqu'au bas de la dernière ligne de la page. L'exemple se limite aux règles horizontales pour rester lisible, mais l'étape de production est mécanique une fois que les constantes de colonnes existent.

La boucle curseur gère le saut de page

Dessiner est la moitié facile. La moitié qui distingue un jouet d'un vrai rapport, c'est la pagination : savoir, avant de tracer une ligne, si elle tient encore, et démarrer une nouvelle page avec un nouvel en-tête si ce n'est pas le cas. Cette décision appartient exactement à un seul endroit, la boucle qui parcourt les données, et nulle part ailleurs.

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;

Deux faits de coordonnées gouvernent toute la boucle. PDF mesure y vers le haut depuis le coin inférieur gauche, de sorte que les lignes descendent la page en soustrayant RowStep à Y à chaque fois, et le test de page pleine se déclenche quand Y passe sous la marge inférieure plutôt qu'au-dessus d'un plafond. Inversez la direction et votre première ligne s'imprime sous le bord inférieur tandis que la boucle croit disposer d'une page entière.

L'autre fait surprend presque tout le monde une fois. AddPage crée une nouvelle page et redirige CurrentPage vers elle, mais n'y transporte rien : ni la police, ni la couleur de remplissage, ni la position. C'est pourquoi Page est relu depuis CurrentPage après chaque AddPage, et pourquoi SetFont est réémis avant les lignes du corps. Omettez la relecture et vous continuez à dessiner sur la page que vous venez de quitter ; omettez la police et la nouvelle page s'affiche dans la police par défaut que la visionneuse choisira à sa place.

Les cas qui cassent un exportateur de tableau

La plupart des bogues de tableau n'apparaissent pas sur le chemin nominal de quelques dizaines de lignes bien rangées. Ils vivent aux limites, et ces limites sont bon marché à tester une fois qu'on sait où elles se trouvent.

  • Datasets vides. Une boucle sur zéro ligne produit une page avec un en-tête et rien dessous, ce qui a au moins l'air intentionnel. Une page blanche sans en-tête ressemble à un échec. Décidez ce que vous voulez avant de livrer.
  • La ligne qui tombe exactement sur la limite. Générez un rapport dont la dernière ligne se situe un pas au-dessus de la marge, puis un autre dont la ligne suivante se situe un pas en dessous. Un bogue de pagination d'un-de-trop ne se révèle que lorsque les données ont exactement la mauvaise longueur.
  • Valeurs trop longues. Un nom d'entreprise plus large que sa colonne déborde dans la suivante. Mesurez le champ et choisissez une politique : retour à la ligne, coupure ou troncature avec une ellipse. Le silence n'est pas une politique.
  • Champs nuls. Lire un null directement dans TextOut peut produire le texte littéral Null ou une chaîne vide, selon la façon dont vous le convertissez. Choisissez le rendu délibérément plutôt que de laisser la conversion de variant décider à votre place.

Faites passer le résultat dans plusieurs visionneuses avant de déclarer le travail terminé. La substitution de police et le rognage se comportent différemment selon les moteurs de rendu, et un tableau qui semble impeccable dans un lecteur PDF peut afficher une colonne désalignée ou une ville tronquée dans un autre. Vérifiez que l'en-tête répété, l'ombrage des lignes et les marges survivent au passage, et que les numéros de page restent continus après que les données ont franchi une limite.

Dessiner la grille soi-même plutôt que de s'appuyer sur un concepteur de rapports visuel représente davantage de code, et le compromis mérite d'être nommé clairement : vous possédez chaque coordonnée, ce qui est exactement ce qu'il faut pour les traitements batch côté serveur, les factures et les exports d'audit devant s'afficher de façon identique sur toutes les machines, et exactement la charge dont on préférerait se passer pour un listing interne ponctuel. Pour les premiers, le contrôle se rentabilise dès la première fois qu'un rapport doit avoir le même aspect en production que sur votre poste.

Les règles et les bandes ombrées ci-dessus s'appuient sur les mêmes primitives vectorielles et de couleur traitées dans le guide de dessin sur canvas, si vous souhaitez d'abord aborder les appels Rectangle, MoveTo et LineTo isolément. Les primitives de dessin utilisées ici font partie du composant HotPDF pour Delphi et C++Builder.