Teknisk artikel

Rendera en datatabell till PDF i Delphi med HotPDF

Ett dataset består av rader och kolumner; en PDF-sida är ett tomt koordinatraster som inte känner till någotdera. Att överbrygga denna klyfta är hela uppgiften här. Det finns inget DrawTable-anrop i HotPDF som tar ett dataset och ger dig ett formaterat rutnät. Vad du istället får är primitiverna som ett rutnät består av: TextOut för att placera en sträng vid en punkt, SetFont för att välja dess typsnitt, Rectangle och Fill för att skugga ett band, samt MoveTo / LineTo / Stroke för att rita linjer. En fungerande tabellexportör är disciplinen att förvandla rad-och-kolumn-tänkande till uttryckliga x- och y-koordinater, och sedan hålla dessa koordinater ärliga när data sträcker sig förbi sidans nederkant

Följande exempel rapporterar kundposter, men ingenting i ritkoden vet eller bryr sig om var raderna kommer ifrån. Originalet använde en föråldrad TTable; en FireDAC-fråga, ett dataset i minnet eller en vanlig array med poster matar samma rutiner oförändrat. Det viktiga är att du kan gå igenom datan en rad i taget och läsa ut fyra strängfält från varje. Håll renderingen separerad från datakällan så kan du ändra endera sidan utan att störa den andra

Kolumngeometri kommer först

Innan ett enda tecken ritas, bestäm var varje kolumn är belägen. En tabell har här fyra kolumner, så den behöver fyra vänsterkanter och en känd högermarginal. Att hårdkoda en magisk siffra vid varje TextOut-anrop, på det sätt som snabba exempel ofta gör, är precis det som gör en tabell smärtsam att bredda senare. Namnge kanterna en gång, i punkter från origo nere till vänster, så att varje ritanrop hänvisar till dem med namn:

HotPDF-tabellkolumngeometri i Delphi: namngivna x-kanter vid 70, 110, 300 och 480 punkter mellan ramstreck vid 50 och 570 punkter
Fyra namngivna vänsterkanter och en känd högermarginal låser hela tabellgeometrin innan första TextOut-anropet
const
  ColNo   = 70;    // vänsterkant på kolumnen "No."
  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;    // vertikalt avstånd mellan baslinjerna

procedure PrintRow(Page: THPDFPage; Y: Single;
  const ANo, AName, AAddr, ACity: string; Shaded: boolean);
begin
  if Shaded then
  begin
    // Ett tonat band bakom raden. Rectangle tar 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;

Två detaljer förtjänar sitt uppehälle här. Det skuggade bandet ritas först, sedan texten ovanpå, eftersom ritordningen är z-ordningen i PDF: fyller du rektangeln efter texten så begraver du raden. Och den alternerande skuggningen är inte dekoration för sin egen skull. På en tät rapport är det det billigaste sättet att hindra ögat från att glida till fel rad, vilket är anledningen till att loopen senare vänder ett booleskt värde för varje rad och skickar det direkt till Shaded

Kolumnpositionerna ovan är fasta, vilket är ärligt för en rapport vars schema du kontrollerar. När datan är variabel, mät istället för att gissa. HotPDF exponerar mätning av textbredd på sidobjektet, så produktionsversionen av PrintRow kan ta det längsta förväntade värdet i varje kolumn, mäta det en gång vid den valda teckenstorleken och härleda vänsterkanterna från dessa bredder plus en rännsten. Rutinens form ändras inte; bara källan till konstanterna gör det

Rubriken, linjerna och en plats som äger dem

En tabell som rullar av en sida och återupptas på nästa utan kolumnetiketter är oläslig. Lösningen är att behandla rubriken som något du ritar om, inte något du ritar en gång. Lägg kolumnrubrikerna och de horisontella linjerna som ramar in dem i en enda rutin, och anropa den rutinen både i början och igen varje gång du öppnar en ny sida. Eftersom rubriken och kroppen delar samma kolumnkonstanter hamnar de i linje per konstruktion

HotPDF ritar om DrawHeader-titlar och -streck på sida ett och igen efter varje AddPage så att båda PDF-sidorna öppnar med en identisk rubrik
Rubrikrutinen körs igen på varje ny sida, så rubriker och linjer landar på samma ställe genom konstruktionen
procedure DrawHeader(Page: THPDFPage; var Y: Single; PageNo: Integer);
begin
  // Vänster: källetikett och sidnummer. Höger: genereringstid
  Page.SetFont('Arial', [fsItalic], 10);
  Page.TextOut(RowLeft, Y, 0, 'customer.db   Page ' + IntToStr(PageNo));
  Page.TextOut(ColCity, Y, 0, DateTimeToStr(Now));

  // Två horisontella linjer som ramar in kolumnrubrikerna
  Page.MoveTo(RowLeft, Y + 15);
  Page.LineTo(RowRight, Y + 15);
  Page.MoveTo(RowLeft, Y + 45);
  Page.LineTo(RowRight, Y + 45);
  Page.Stroke;

  // Kolumnrubrikerna, i ett fetare snitt så de läses som rubriker
  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;  // hoppa förbi den inramade rubriken före första brödraden
end;

Lägg märke till att DrawHeader tar Y som referens och flyttar det framåt. Anroparen behöver aldrig komma ihåg hur hög rubriken är; rutinen som ritar den är rutinen som vet. Denna enda äganderegel är det som hindrar layouten från att glida när du senare lägger till en logotyp eller en filtersammanfattning i rubrikbandet. Kroppsloopen förblir omedveten. Den fortsätter bara att rita rader från var Y för närvarande pekar

Själva linjerna utgör skillnaden mellan en lista och en tabell. Vertikala kolumnavskiljare är samma idé tillämpad på x-axeln: en MoveTo / LineTo / Stroke vid varje kolumnkant, dragen från den översta linjen till botten av den sista raden på sidan. Exemplet håller sig till horisontella linjer för att förbli läsbart, men produktionssteget är mekaniskt när kolumnkonstanterna väl existerar

Markörloopen äger sidbrytningen

Att rita är den enkla hälften. Den hälft som skiljer en leksak från en rapport är sidindelningen: att veta, innan du ritar en rad, om den fortfarande får plats, och att påbörja en ny sida med en ny rubrik när den inte gör det. Det beslutet hör hemma på exakt en plats, loopen som går igenom datan, och ingen annanstans

Flödesschema över Delphi-cursorloop där ett Y under 60 utlöser AddPage, en återläsning av CurrentPage, en återutfärdad SetFont och en upprepad rubrik före nästa tabellrad
Cursor-loopen är den enda platsen som öppnar en ny sida och initierar om den när Y sjunker under nedre marginalen
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;

    // Rapportens titel, en gång, överst på första sidan
    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
      // Slut på plats? Öppna en ny sida och upprepa rubriken där
      if Y < 60 then
      begin
        Pdf.AddPage;
        Page := Pdf.CurrentPage;   // AddPage flyttar CurrentPage framåt
        Inc(PageNo);
        Y := 760;
        DrawHeader(Page, Y, PageNo);
      end;

      Shaded := not Shaded;
      Page.SetFont('Arial', [], 10);   // SetFont måste anropas om på varje ny sida
      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;

Två koordinatfakta driver hela loopen. PDF mäter y uppåt från det nedre vänstra hörnet, så raderna marscherar nedåt på sidan genom att subtrahera RowStep från Y varje gång, och testet för full sida utlöses när Y sjunker under den nedre marginalen snarare än över någon topp. Får du riktningen baklänges skrivs din första rad ut utanför den nedre kanten medan loopen tror att den har en hel sida med utrymme

Det andra faktumet fångar nästan alla en gång. AddPage skapar en ny sida och pekar om CurrentPage till den, men den överför ingenting: inte typsnittet, inte fyllnadsfärgen, inte positionen. Det är därför Page läses in på nytt från CurrentPage efter varje AddPage, och varför SetFont utfärdas igen före kroppens rader. Hoppar du över omläsningen fortsätter du att rita på sidan du precis lämnade bakom dig; hoppar du över typsnittet renderas den nya sidan i den standard som visningsprogrammet faller tillbaka på

Fallen som knäcker en tabellexportör

De flesta tabellbuggar dyker inte upp på den lyckliga vägen av några dussin prydliga rader. De lever vid kanterna, och kanterna är billiga att testa när du väl vet var de är

  • Tomma dataset. En loop över noll rader producerar en sida med en rubrik och ingenting under den, vilket åtminstone ser avsiktligt ut. En blank sida utan rubrik ser ut som ett misslyckande. Bestäm vilket du vill ha innan du levererar
  • Raden som landar exakt på gränsen. Generera en rapport vars sista rad sitter ett steg ovanför marginalen, därefter en vars nästa rad är ett steg under den. Ett-fel-sidindelning gömmer sig tills datan är exakt av fel längd
  • För långa värden. Ett företagsnamn bredare än sin kolumn kommer att springa in i nästa. Mät fältet och besluta om en policy: bryt till en andra rad, klipp av, eller trunkera med uteslutningstecken. Tystnad är inte en policy
  • Null-fält. Att läsa ett null direkt in i TextOut kan dyka upp som bokstavstexten Null eller som tomt, beroende på hur du konverterar det. Välj renderingen medvetet istället för att låta variantkonverteringen välja åt dig

Kör resultatet genom mer än ett visningsprogram innan du kallar det klart. Typsnittsersättning och beskärning beter sig olika över renderare, och en tabell som ser fyrkantig ut i en PDF-läsare kan visa en feljusterad kolumn eller en avklippt stad i en annan. Bekräfta att den upprepade rubriken, radskuggningen och marginalerna överlever flytten, och att sidnumren förblir kontinuerliga efter att datan korsat en gräns

Att rita rutnätet själv istället för att luta sig mot en visuell rapportdesigner innebär mer kod, och kompromissen är värd att nämna tydligt: du äger varje koordinat, vilket är exakt vad du vill ha för serverbaserade batchjobb, fakturor och revisionsexporter som måste renderas identiskt på varje maskin, och exakt den omkostnad du hellre vill undvika för en engångs intern lista. För det förstnämnda betalar kontrollen för sig själv första gången en rapport måste se likadan ut i produktion som den gjorde på ditt skrivbord

Linjerna och de skuggade banden ovan stöder sig på samma vektor- och färgprimitiver som täcks i genomgången av canvas-ritning, ifall du vill att Rectangle, MoveTo och LineTo-anropen behandlas separat först. Ritprimitiverna som används här är en del av HotPDF-Delphi-komponenten för Delphi och C++Builder