Technisch artikel

PDF-documenten helemaal opnieuw maken met PDFium VCL in Delphi

PDFium heeft een reputatie als weergave-engine (de renderer achter het PDF-tabblad van Chrome), dus het eerste dat verduidelijkt moet worden, is dat PDFium VCL ook een document kan opbouwen dat nog nooit eerder heeft bestaan. De creatieve kant omhult de page-object API van PDFium: u maakt een leeg document aan, voegt pagina's toe met expliciete dimensies en plaatst tekst, vectorpaden en afbeeldingen op elke pagina op de door u gekozen coördinaten. Er is geen paginabeschrijvingstaal die u moet leren en er is geen printerstuurprogramma bij betrokken. U roept methoden aan, de bibliotheek stelt de PDF-objecten samen en SaveAs serialiseert het resultaat.

Wat u niet krijgt, is een lay-out-engine. Dit is belangrijk genoeg om vooraf te vermelden, omdat het elk onderstaand voorbeeld vormgeeft. PDFium VCL plaatst inhoud waar u dat aangeeft, in absolute coördinaten en nergens anders. Het zal een alinea niet automatisch afbreken, tekst niet laten doorlopen over een pagina-einde, en geen tabel berekenen op basis van rijen en kolommen. Dat is uw taak. Als u had verwacht dat de tekst zou doorlopen zoals in een tekstverwerker, stel uw verwachtingen dan nu bij: dit is een nauwkeurige, low-level plaatsings-API, die dichter bij tekenen op een canvas ligt dan bij het zetten van een document. Voor het genereren van facturen, certificaten, labels en rapportpagina's waarvan u al weet waar elk element hoort, is die precisie precies wat u zoekt.

Het minimum om een bestand te genereren

Er zijn slechts drie aanroepen nodig om van een lege TPdf till een opgeslagen PDF te komen: maak het document aan, voeg een pagina toe en sla het bestand op. Al het andere is inhoud die u daartussen toevoegt.

uses
  Vcl.Graphics,   // for clBlack and TColor
  PDFium;         // TPdf lives here

procedure CreateBlankPdf(const FileName: string);
var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.CreateDocument;                 // empty in-memory document
    Pdf.AddPage(0, 595, 842);           // A4 portrait, in points
    Pdf.AddText('First page', 'Arial', 18, 50, 780);
    Pdf.SaveAs(FileName);               // serialize to disk
  finally
    Pdf.Active := False;
    Pdf.Free;
  end;
end;

Eén detail brengt mensen die oudere codevoorbeelden hebben gezien in verwarring: u hoeft Pdf.Active := True niet toe te wijzen na CreateDocument. De eigenschap Active geeft aan of er een document-handle bestaat, en CreateDocument heeft er al een aangemaakt, dus de eigenschap is True zodra die aanroep klaar is. Het opnieuw instellen is in het beste geval overbodig en in het slechtste geval misleidend voor de volgende lezer. Active bewijst zijn nut bij het afsluiten: door False toe te wijzen wordt het onderliggende document vrijgegeven vóór Free, wat de juiste volgorde van afbreken is. Behandel CreateDocument en het openen van een bestaand bestand als wederzijds uitsluitend. De bibliotheek weigert een nieuw document te maken op een TPdf die al een document geopend heeft, dus hergebruik betekent dat u eerst het huidige document moet sluiten.

Coördinaten beginnen linksonder

Het tweede paar argumenten voor AddText, en voor elke plaatsingsaanroep, is een punt in de PDF-gebruikersruimte. De oorsprong bevindt zich in de linkerbenedenhoek van de pagina, X loopt naar rechts en Y loopt naar boven. Eén eenheid is één punt (1/72 inch), dus een A4-pagina is 595 bij 842 eenheden en US Letter is 612 bij 792. Die omhooglopende Y-as is de meest voorkomende oorzaak van de verwarring waarbij tekst buiten de pagina valt, omdat scherm- en bitmapcoördinaten de oorsprong bovenaan leggen waarbij Y naar beneden toe groeit. Op een pagina van 842 punten hoog bevindt een koptekst bovenaan de pagina zich rond Y 780, niet Y 60. Wanneer tekst op een onverwachte plaats terechtkomt, is de paginahogte minus uw Y-waarde vrijwel altijd het getal dat u eigenlijk bedoelde.

AddPage gebruikt een invoegpositie als eerste argument, gebaseerd op 1-indexering, met 0 als een handige afkorting voor "begin van het document". Geef 0 of 1 op voor de eerste pagina om deze vooraan in te voegen; geef de waarde op die overeenkomt met het huidige aantal pagina's om achteraan toe te voegen. De nieuw toegevoegde pagina wordt ook direct de huidige pagina waarop volgende tekenopdrachten worden uitgevoerd, dus er is geen aparte stap nodig om die pagina te selecteren. Als u meerdere pagina's toevoegt en later op een eerdere pagina wilt tekenen, stelt u PageNumber in om de cursor te verplaatsen. Zolang u pagina's op volgorde vult tijdens het maken, hoeft u hier niets aan te veranderen.

Tekst schrijven, en de lettertyperegel die stilzwijgend problemen geeft

De declaratie van AddText bevat alles wat een enkele tekstregel nodig heeft: de tekenreeks, een lettertypenaam, een grootte in punten, het X- en Y-ankerpunt, en optioneel de kleur, een alfa-byte voor transparantie en een rotatiehoek in graden.

procedure WriteHeader(Pdf: TPdf; const Title, Author: string);
begin
  // Title in black, default opacity, no rotation
  Pdf.AddText(Title, 'Arial', 20, 50, 780);
  // A lighter byline 24 points below it
  Pdf.AddText('By ' + Author, 'Arial', 11, 50, 756, clGray);
  // A faint diagonal draft stamp across the page
  Pdf.AddText('DRAFT', 'Arial', 64, 180, 380, clGray, $30, 45.0);
end;

De alfa-byte loopt van $00 (onzichtbaar) tot $FF (dekkend), wat ervoor zorgt dat de conceptstempel een watermerk is in plaats van een massief blok: $30 is ongeveer negentien procent dekking, genoeg om er doorheen te kunnen lezen. De hoek roteert de tekst tegen de klok in rond zijn ankerpunt, dus 45 graden geeft de klassieke stempel van hoek tot hoek. Niets hiervan vereist een aparte watermerkfunctie. Een watermerk is gewoon een grote, semi-transparante, geroteerde AddText-aanroep, en het moment van tekenen (vóór of na de hoofdtekst) bepaalt of het achter of bovenop de inhoud ligt.

Lettertypen verdienen extra aandacht, omdat de foutmodus stilzwijgend is. Wanneer u een lettertypenaam doorgeeft, vraagt PDFium VCL het besturingssysteem naar de TrueType-gegevens van dat lettertype en sluit deze in het document in. Hierdoor wordt een bestand dat op uw machine is gebouwd identiek weergegeven op een computer waarop het lettertype nooit is geïnstalleerd. Het addertje onder het gras is wat er gebeurt als de naam niet kan worden gevonden: een typefout, of een lettertype dat simpelweg niet aanwezig is op de build-machine. Er wordt geen uitzondering gegenereerd. De bibliotheek valt terug op het maken van een tekstobject dat de naam alleen als label draagt, zonder ingebedde gegevens, en laat het aan de PDF-lezer over om iets te selecteren dat er op lijkt. De tekst verschijnt in uw tests, ziet er aannemelijk uit, maar verschuift qua lay-out of tekens zodra het bestand ergens wordt geopend waar andere lettertypen zijn geïnstalleerd. Gebruik namen waarvan u weet dat ze aanwezig zijn op de genererende machine, behandel de lettertypenlijst als een implementatie-afhankelijkheid en open een voorbeeldbestand in een PDF-lezer op een schoon systeem voordat u de uitvoer vertrouwt.

Vectorvormen: bouw een pad en leg het vast

Lijnen, rechthoeken en gevulde vlakken worden getekend via een pad. U opent er een met CreatePath, die in één keer het startpunt en alle opmaak instelt: vulmodus, vul- en lijnkleuren met hun eigen alfa-bytes, lijndikte, lijnuiteinden en lijnverbindingen. Vervolgens breidt u het uit met LineTo, BezierTo en ClosePath, en ten slotte legt AddPath het voltooide pad vast op de pagina. De vastlegstap (commit) is gemakkelijk te vergeten en produceert niets als u deze overslaat.

procedure DrawDivider(Pdf: TPdf; X, Y, Width: Single);
begin
  // A thin horizontal rule. The rectangle overload sets a box directly:
  // X, Y, Width, Height, then fill mode and colors.
  Pdf.CreatePath(X, Y, Width, 0.5, fmNone, clBlack, $FF,
    True, clBlack, $FF, 1.0);
  Pdf.AddPath;
end;

procedure DrawTriangle(Pdf: TPdf);
begin
  // Point overload: start at the first vertex, line to the rest, close.
  Pdf.CreatePath(200, 300, fmWinding, clBlue, $80, True, clNavy, $FF, 2.0);
  Pdf.LineTo(300, 300);
  Pdf.LineTo(250, 400);
  Pdf.ClosePath;
  Pdf.AddPath;          // nothing is drawn until this runs
end;

Twee overloads dekken de meest voorkomende situaties af. De vorm met vier coördinaten accepteert X, Y, breedte en hoogte en levert in één aanroep een uitgelijnde rechthoek op, wat handig is voor het tekenen van een lijn, een celrand of een gevuld achtergrondpaneel. De vorm met twee coördinaten stelt alleen een startpunt in, waarna u de rest van de omtrek zelf uittekent met LineTo en BezierTo. De vulmodus bepaalt hoe overlappende gebieden worden ingekleurd: fmWinding (nonzero winding) is geschikt voor de meeste massieve vormen, fmAlternate (even-odd) verwerkt uitsparingen en zichzelf kruisende omtrekken, en fmNone zorgt voor een pad dat alleen omlijnd is zonder vulling, wat in de bovenstaande scheidingslijn is gebruikt.

Tabellen zijn handmatig samengestelde paden en tekst

Omdat er geen tabel-primitief is, is een tabel in feite een lus. U bepaalt de X-posities van de kolommen en de rijhoogte, schrijft elke cel met AddText en tekent de lijnen met rechthoekige paden. U moet zelf de berekeningen uitvoeren, maar dit is eenvoudig en eenmaal geschreven is het toepasbaar op elk raster dat u nodig heeft.

procedure DrawTable(Pdf: TPdf; Left, Top: Double);
const
  ColX: array[0..2] of Double = (0, 110, 210);  // column offsets
  RowH = 20;
var
  Y: Double;
  Row: Integer;
begin
  // Header row
  Pdf.AddText('Item', 'Arial', 10, Left + ColX[0], Top);
  Pdf.AddText('Qty', 'Arial', 10, Left + ColX[1], Top);
  Pdf.AddText('Price', 'Arial', 10, Left + ColX[2], Top);

  // Rule under the header
  Pdf.CreatePath(Left, Top - 5, 260, 0.5, fmNone, clBlack, $FF);
  Pdf.AddPath;

  // Data rows, stepping Y downward each iteration
  Y := Top;
  for Row := 1 to 3 do
  begin
    Y := Y - RowH;
    Pdf.AddText('Item ' + IntToStr(Row), 'Arial', 9, Left + ColX[0], Y);
    Pdf.AddText(IntToStr(Row * 2), 'Arial', 9, Left + ColX[1], Y);
    Pdf.AddText('$' + IntToStr(Row * 10) + '.00', 'Arial', 9, Left + ColX[2], Y);
  end;
end;

Let op dat Y bij elke stap afneemt met de rijhoogte, omdat de Y-as omhoog positief is. Dit is ook waar het gebrek aan tekstmeting duidelijk wordt: niets verhindert dat een lange artikelnaam doorloopt in de volgende kolom, omdat de bibliotheek niet weet hoe breed uw tekenreeks is weergegeven. Voor uitvoer met een vaste indeling waarbij u de controle hebt over de gegevens, kiest u ruime kolombreedtes. Voor echt variabele inhoud moet u ofwel de invoer beperken, ofwel zelf de breedte van de tekens meten voordat u ze plaatst. Dat is het punt waarop een gespecialiseerde lay-outbibliotheek zichzelf begint terug te verdienen.

Afbeeldingen en meerdere pagina's

Rasterafbeeldingen worden geladen via de afbeeldingshelpers. AddPicture accepteert een geladen TPicture en plaatst deze op een punt, met een optionele breedte en hoogte om te schalen; AddImage accepteert rechtstreeks een bestandspad of een TBitmap, en AddJpegImage streamt JPEG-bytes zonder een tussenstap via een bitmap. Net als bij al het andere zijn de plaatsingscoördinaten de linkerbenedenhoek van de afbeelding in de gebruikersruimte, en zijn de breedte en hoogte de afmetingen op de pagina in punten, niet de pixeldimensies van de bron.

procedure CreateMultiPageReport(const FileName: string; PageCount: Integer);
var
  Pdf: TPdf;
  P: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.CreateDocument;
    for P := 1 to PageCount do
    begin
      Pdf.AddPage(P, 595, 842);     // append; the new page becomes current
      Pdf.AddText('Page ' + IntToStr(P) + ' of ' + IntToStr(PageCount),
        'Arial', 10, 50, 30);       // footer near the bottom edge
      // ... draw this page's body here ...
    end;
    Pdf.SaveAs(FileName);
  finally
    Pdf.Active := False;
    Pdf.Free;
  end;
end;

Een document met meerdere pagina's is het patroon van een enkele pagina in een lus. Elke AddPage voegt een pagina toe en maakt deze actueel, zodat de hoofdtekst en de voettekst die u daarna tekent terechtkomen op de pagina die u zojuist hebt toegevoegd. U hoeft PageNumber binnen deze lus niet opnieuw toe te wijzen, omdat het toevoegen van een pagina de cursor daar al heen heeft verplaatst; u hebt PageNumber alleen nodig als u teruggaat naar een pagina buiten de aanmaakvolgorde. Roep SaveAs eenmalig aan het einde aan, nadat de laatste pagina is gevuld. Als u een archiefprofiel nodig heeft in plaats van een gewoon bestand, biedt hetzelfde documentobject SaveAsPdfA en andere conformiteitsvarianten aan, zodat de keuze van de uitvoerstandaard een andere opslagaanroep is en geen ander bouwtraject.

Waar dit past

De eerlijke conclusie is dat de creatie-API van PDFium VCL een getrouwe, dunne laag is over het page-object model van PDFium: echte documentcreatie, echt ingebedde lettertypen, echte vector- en rasterinhoud, geserialiseerd naar een bestand dat aan de standaarden voldoet. Het is geen document-engine met automatische tekstdoorloop, en pretendeert dat ook niet te zijn. De scheidslijn is de tekstlay-out. Als uw uitvoer bestaat uit sjablonen, facturen, certificaten, labels of dashboards die op een vast raster worden getekend, dan is het model met absolute coördinaten direct en snel en blijft de code leesbaar. Als uw uitvoer bestaat uit lange teksten die zelf moeten worden afgebroken en verdeeld over pagina's, dan zult u zelf een lay-out-engine moeten bouwen bovenop deze aanroepen, en dat maakt het de verkeerde tool voor de taak. Weten aan welke kant van die lijn u zich bevindt, is het belangrijkste deel van de beslissing.

De hier beschreven creatiemethoden maken deel uit van de PDFium VCL-component voor Delphi, die dit creatietraject combineert met de functies voor weergave en tekstextractie waar PDFium bekender om staat.