Technisch artikel

Herbruikbare paginastempels via Form XObjects met PDFium

Een watermerk of een logo op elke pagina van een document stempelen lijkt een klusje van vijf minuten, totdat u het resultaat opent in een bestandsgrootte-inspecteur. De voor de hand liggende benadering is om door de pagina's te lopen en op elke pagina dezelfde tekst- of afbeeldingsobjecten opnieuw op te bouwen. Dat werkt visueel, maar het is verspillend op een manier die zich opstapelt. Een diagonaal "CONCEPT" watermerk dat rechtstreeks op een rapport van honderd pagina's is getekend, betekent honderd kopieën van dezelfde pad- en tekstgegevens in de inhoudsstromen, en het opgeslagen bestand draagt ze allemaal mee

Een Form XObject is de constructie die PDF biedt om precies dit te voorkomen. Het verpakt een stukje herbruikbare inhoud, een hele pagina of een kleine sjabloon, in één genoemd object dat vele malen op vele posities kan worden getekend. De inhoud leeft eenmalig in het bestand. Elke pagina die de stempel wil hebben, bevat een korte instructie die luidt: "teken XObject N hier, met deze transformatie." Een watermerk op honderd pagina's voegt dan één inhoudsobject toe aan het bestand in plaats van honderd, en dat is het verschil tussen een document dat lineair groeit met het aantal pagina's en een document waarbij dat niet het geval is. Watermerken, logostempels, paginanummersjablonen en zegels zijn allemaal hetzelfde soort probleem, en het Form XObject is het juiste gereedschap voor elk van deze

Waarom één opgeslagen object wint van honderd keer opnieuw tekenen

De besparing is structureel, niet cosmetisch. Een PDF-pagina wordt gerenderd door zijn inhoudsstroom, een reeks tekenoperatoren, uit te voeren. Wanneer u een stempel per pagina opnieuw tekent, voegt u de volledige operatorreeks voor die stempel toe aan de stroom van elke pagina, en de bytes worden even vaak gedupliceerd als het aantal pagina's dat u hebt. Een Form XObject verplaatst die operatoren naar één stroom die eenmalig in het document is opgeslagen. De verwijzing die een individuele pagina bewaart is klein: deze duwt een transformatiematrix, roept het XObject aan en herstelt de status. Het aantal pagina's vermenigvuldigt niet langer de kosten van het artwork

Dit is vooral van belang wanneer de stempel zwaar is. Een vectorzegel met honderden padsegmenten, of een logo-bitmap, is duur om op te slaan. Eenmaal opgeslagen en waarnaar wordt verwezen, is het zware deel eenmalig betaald en is de overhead per pagina een paar bytes aan aanroep. Het visuele resultaat op de pagina is identiek aan een directe nieuwe tekening, en dat is precies de bedoeling. De lezer merkt het verschil niet; de bestandsgrootte zeker wel

Een pagina vastleggen in een XObject

PDFium bouwt het herbruikbare object uit een bestaande pagina. De bron is een pagina in een document dat u open heeft, een kleine een-pagina PDF die niets anders bevat dan uw watermerk-artwork, of een specifieke pagina van een groter bestand. CreateXObjectFromPage legt de inhoud van die bronpagina vast in een herbruikbare handle die behoort tot het doeldocument, het document dat u aan het stempelen bent

var
  Dest, Stamp: TPdf;
  XObject: TPdfXObject;
begin
  Dest := TPdf.Create(nil);
  Stamp := TPdf.Create(nil);
  try
    Dest.FileName := 'Report.pdf';
    Dest.Active := True;
    Stamp.FileName := 'Watermark.pdf';   // one page of artwork
    Stamp.Active := True;
    if not (Dest.Active and Stamp.Active) then
      raise Exception.Create('Could not open the input documents');

    // Capture page 0 of the stamp document into a reusable handle that
    // is owned by Dest. Source must be Active; the index is zero-based.
    XObject := Dest.CreateXObjectFromPage(Stamp, 0);
    if XObject = nil then
      raise Exception.Create('Could not build the stamp XObject');
    // ... place it, then free it before closing Stamp (see below) ...

De handtekening is CreateXObjectFromPage(Source: TPdf; SourcePageIndex: Integer): TPdfXObject. De methode werpt een uitzondering op als het brondocument niet Active is, en retourneert nil in plaats van een uitzondering op te werpen wanneer PDFium het object niet kan bouwen, dus de expliciete controle hierboven is niet optioneel. De handle die terugkomt is een TPdfXObject waarvan u de eigenaar bent, en de twee levensduurbeperkingen die eraan verbonden zijn, vormen het deel van deze hele oefening waar mensen door worden verrast, dus ze krijgen hieronder hun eigen sectie

De stempel op een pagina plaatsen

Een vastgelegd XObject doet op zichzelf niets. Om het te laten verschijnen, voegt u er een kopie van in op de huidige pagina van het document, de pagina geselecteerd door de op 1 gebaseerde PageNumber-eigenschap, met InsertFormObjectFromXObject. Die aanroep retourneert het onderliggende pagina-object, een FPDF_PAGEOBJECT, en de geretourneerde handle bepaalt hoe u de plaatsing positioneert. Zonder een transformatie belandt de stempel op de oorsprong in de eigen coördinaten van de bronpagina, wat zelden is waar u hem wilt hebben

Omdat InsertFormObjectFromXObject één kopie per aanroep invoegt en telkens een vers pagina-object teruggeeft, kunt u hetzelfde XObject meerdere keren op één pagina tekenen met verschillende transformaties, en wordt de opgeslagen inhoud nog steeds maar één keer in het bestand geteld. Een hoeklogo en een vaag watermerk over de hele pagina kunnen afkomstig zijn van hetzelfde vastgelegde object

var
  PageObj: FPDF_PAGEOBJECT;
  M: TPdfMatrix;
  RawM: FS_MATRIX;
begin
  // The current page of Dest receives one copy of the XObject.
  PageObj := Dest.InsertFormObjectFromXObject(XObject);
  if PageObj = nil then
    raise Exception.Create('Insert failed on this page');

  // Position it: move 200 units right, 500 up, at 70% scale.
  M := TPdfMatrix.Create;
  try
    M.Scale(0.7, 0.7);
    M.Translate(200, 500);
    RawM := M.Handle;
    if FPDFPageObj_SetMatrix(PageObj, RawM) = 0 then
      raise Exception.Create('Cannot assign the stamp matrix');
  finally
    M.Free;
  end;
  Dest.UpdatePage;   // commit this page's edits to its content stream
  // if not Dest.SaveAs(...) then ... when every page is done.
end;

Twee huishoudelijke details maken dit veilig. Ten eerste, eenmaal ingevoegd, behoort het pagina-object toe aan de pagina, niet aan het XObject. Het later vrijmaken van het XObject maakt de plaatsingen die u al hebt gedaan niet ongeldig. Dat is de reden waarom de volgorde van aanmaken-plaatsen-vrijmaken die hieronder wordt beschreven, werkt. Ten tweede, het invoegen en positioneren verandert alleen de objectlijst van de pagina in het geheugen; UpdatePage is wat die lijst terug serialiseert naar de inhoudsstroom van de pagina, dus een pagina die u bewerkt zonder deze aan te roepen, wordt opgeslagen alsof de stempel nooit is geplaatst

De regel voor de levensduur van de handle waar mensen zich in verslikken

Twee beperkingen bepalen de XObject-handle, en het negeren van één van beide levert een fout op die geen verband lijkt te houden met de oorzaak ervan. Ten eerste moet het brondocument actief zijn op het moment dat u CreateXObjectFromPage aanroept. Het vastleggen leest de inhoud van de bronpagina uit het live brondocument, dus dat document en de bijbehorende pagina moeten open en geldig zijn wanneer de handle wordt gebouwd. Ten tweede, en dit is degene die mensen verrast, moet de handle worden vrijgemaakt voordat de bronpagina wordt gesloten, en in de praktijk voordat u het brondocument waaruit deze afkomstig is, sluit of vrijmaakt

De reden hiervoor is dat het XObject een referentie is in een structuur die nog steeds eigendom is van het brondocument. Het is geen losstaande, op zichzelf staande kopie die u met u mee kunt dragen nadat de bron verdwenen is. Sluit u eerst de bron, dan blijft de handle wijzen naar inhoud die is afgebroken, dus het later vrijmaken ervan, of elk ander gebruik ervan, werkt op geheugen dat niet langer geldig is. Het symptoom is de klassieke fout voor een bungelende handle (dangling handle): een toegangsovertreding (access violation) bij het afsluiten, of intermitterende corruptie die verschuift afhankelijk van de toewijzingsvolgorde, met een stack die naar opschooncode wijst in plaats van naar de regel die het probleem daadwerkelijk veroorzaakte. De oplossing is de volgorde, niet defensief coderen. Bouw het XObject, voeg het in op elke pagina die het nodig heeft, maak het XObject vrij en sluit pas dan het brondocument. De destructor van TPdfXObject geeft de onderliggende PDFium-handle voor u vrij, dus het op het juiste moment vrijmaken van de wrapper is uw enige verantwoordelijkheid

De matrix, en wat de zes getallen betekenen

Plaatsing is een 2D affiene transformatie, dezelfde die PDF overal gebruikt voor het positioneren van inhoud (ISO 32000-1, sectie 8.3.4). Het bestaat uit zes getallen, geschreven als a, b, c, d, e, f, en PDFium stelt ze bloot als het FS_MATRIX-record. Ze brengen een punt in de eigen ruimte van het object in kaart met de paginaruimte:

// x' = a*x + c*y + e
// y' = b*x + d*y + f
//
// a, d : horizontal and vertical scale
// b, c : the shear / rotation terms
// e, f : translation (where the origin lands on the page)

U kunt die zes waarden handmatig invullen, maar het handmatig samenstellen ervan is waar rotatie fout gaat, omdat rotatie alle vier de waarden van a, b, c, d door elkaar mengt. De TPdfMatrix-wrapper, uit de unit FPdfMatrix, stelt de veelvoorkomende bewerkingen voor u samen en vermenigvuldigt achteraf terwijl hij bezig is, dus Translate, Scale en Rotate schakelen aaneen in de volgorde waarin u ze aanroept. Een diagonaal watermerk is een rotatie gevolgd door een translatie om het opnieuw te centreren; een hoeklogo is een schaling gevolgd door een translatie. Wanneer de matrix gereed is, kopieert u de ruwe waarde ervan, de Handle-eigenschap van het type FS_MATRIX, naar een lokale variabele en geeft u die door aan FPDFPageObj_SetMatrix; de import declareert de matrix als een var parameter, dus een eigenschap kan niet rechtstreeks aan hem worden overhandigd, en het resultaat ervan is 0 bij falen. De op een lager niveau werkende FPDFPageObj_Transform, die de zes waarden rechtstreeks als doubles aanneemt, is beschikbaar wanneer u liever getallen doorgeeft dan een wrapper te bouwen

Elke pagina stempelen, in de juiste volgorde

Het volledige patroon voegt de stukjes samen met de volgorde die de levensduurregel vereist. Open beide documenten, leg de stempel eenmalig vast, loop door de bestemmingspagina's door om beurten het op 1 gebaseerde PageNumber in te stellen en een kopie in te voegen plus te positioneren, leg elke pagina vast met UpdatePage, maak dan het XObject vrij, sla dan op met SaveAs, en laat het brondocument als laatste sluiten

procedure StampEveryPage(const ASource, AStamp, AOutput: string);
var
  Dest, Stamp: TPdf;
  XObject: TPdfXObject;
  PageObj: FPDF_PAGEOBJECT;
  M: TPdfMatrix;
  RawM: FS_MATRIX;
  I: Integer;
begin
  Dest := TPdf.Create(nil);
  Stamp := TPdf.Create(nil);
  try
    Dest.FileName := ASource;
    Dest.Active := True;
    Stamp.FileName := AStamp;
    Stamp.Active := True;
    if not (Dest.Active and Stamp.Active) then
      raise Exception.Create('Could not open the input documents');

    // 1. Capture the artwork once. Stamp is Active here.
    XObject := Dest.CreateXObjectFromPage(Stamp, 0);
    if XObject = nil then
      raise Exception.Create('Could not capture the stamp page');
    try
      // 2. Place a copy on every page of Dest. PageNumber is 1-based.
      for I := 1 to Dest.PageCount do
      begin
        Dest.PageNumber := I;                // make page I current
        PageObj := Dest.InsertFormObjectFromXObject(XObject);
        if PageObj = nil then
          Continue;

        M := TPdfMatrix.Create;
        try
          M.Rotate(45);                      // diagonal watermark
          M.Translate(150, 100);             // nudge into position
          RawM := M.Handle;
          FPDFPageObj_SetMatrix(PageObj, RawM);
        finally
          M.Free;
        end;
        Dest.UpdatePage;                     // commit this page's edits
      end;
    finally
      XObject.Free;                          // 3. free BEFORE Stamp closes
    end;

    // 4. Write the result while Dest is still open.
    if not Dest.SaveAs(AOutput) then
      raise Exception.Create('Could not save ' + AOutput);
  finally
    Stamp.Free;                              // source closes last
    Dest.Free;
  end;
end;

De vorm van de try blokken doet het echte werk. De binnenste finally maakt het XObject vrij voordat controle ooit de buitenste finally kan bereiken die Stamp vrijmaakt, zodat de handle altijd wordt vrijgegeven terwijl zijn bron nog in leven is, zelfs als een uitzondering halverwege de lus optreedt. Zorg dat die nesteling klopt en de levensduurregel zorgt voor zichzelf

Stempelen is één hoek van een grotere toolkit voor het opbouwen en bewerken van paginainhoud. Als uw stempel zelf een afbeelding is in plaats van een vastgelegde pagina, dan dekt het converteren van afbeeldingen naar PDF-documenten met PDFium het eerst in een document krijgen van die bitmap. En wanneer hetgeen u naast de zichtbare stempel wilt meedragen een bestand is in plaats van inkt op de pagina, toont werken met PDF-bijlagen in Delphi de kant van het ingesloten bestand. Dit alles wordt geleverd met de PDFium-component voor Delphi en C++Builder, naast de API's voor weergave, bewerking en documenten die elders op deze blog worden behandeld