Teknisk artikel

Återanvändbara sidstämplar via Form XObjects med PDFium

Att stämpla en vattenstämpel eller en logotyp på varje sida i ett dokument ser ut som ett fem-minutersjobb tills du öppnar resultatet i en filstorleksinspektör. Det uppenbara tillvägagångssättet är att gå igenom sidorna och, på var och en, bygga samma text eller bildobjekt igen. Det fungerar visuellt, och det är slösaktigt på ett sätt som förvärras. En diagonal "UTKAST"-vattenstämpel ritad direkt på en hundrasidig rapport är hundra kopior av samma sök- och textdata som sitter i innehållsströmmarna, och den sparade filen bär var och en av dem

Ett Form XObject är den konstruktion PDF tillhandahåller för att undvika exakt detta. Det slår in en bit återanvändbart innehåll, en hel sida eller en liten mall, till ett enda namngivet objekt som kan målas många gånger på många positioner. Innehållet lever i filen en gång. Varje sida som vill ha stämpeln innehåller en kort instruktion som säger "måla XObject N här, med denna transformation." En hundrasidig vattenstämpel lägger då till ett innehållsobjekt i filen istället för hundra, och det är skillnaden mellan ett dokument som växer linjärt med sitt sidantal och ett som inte gör det. Vattenstämplar, logotypstämplar, sidnummermallar och sigill är alla samma form av problem, och Form XObject är rätt verktyg för vart och ett av dem

Varför ett lagrat objekt slår hundra omritningar

Besparingen är strukturell, inte kosmetisk. En PDF-sida renderas genom att exekvera dess innehållsström, en sekvens av ritoperatorer. När du ritar om en stämpel per sida bifogar du hela operatorsekvensen för den stämpeln till varje sidas ström, och byten dupliceras lika många gånger som du har sidor. Ett Form XObject flyttar dessa operatorer till en ström som lagras en gång i dokumentet. Den referens en enskild sida behåller är liten: den trycker en transformationsmatris, anropar XObject, och återställer tillståndet. Sidantalet multiplicerar inte längre kostnaden för grafiken

Detta spelar störst roll när stämpeln är tung. Ett vektorsigill med hundratals segment, eller en logotypbitmapp, är dyrt att lagra. Lagrad en gång och refererad, är den tunga delen betald en enda gång och per-sida-overheaden är ett par byte av anrop. Det visuella resultatet på sidan är identiskt med en direkt omritning, vilket är poängen. Läsaren kan inte se någon skillnad; filstorleken kan mycket väl göra det

Att fånga en sida in i ett XObject

PDFium bygger det återanvändbara objektet från en befintlig sida. Källan är en sida i något dokument du har öppet, en liten ensidig PDF som inte innehåller något annat än din vattenstämpelgrafik, eller en specifik sida i en större fil. CreateXObjectFromPage fångar källsidans innehåll in i ett återanvändbart handtag som tillhör destinationsdokumentet, det du stämplar

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) ...

Signaturen är CreateXObjectFromPage(Source: TPdf; SourcePageIndex: Integer): TPdfXObject. Metoden kastar ett fel om källdokumentet inte är Active, och den returnerar nil snarare än att kasta ett fel när PDFium inte kan bygga objektet, så den explicita kontrollen ovan är inte valfri. Handtaget som kommer tillbaka är ett TPdfXObject som du äger, och de två livslängdsbegränsningarna kopplade till det är den del av hela denna övning som överraskar folk, så de får ett eget avsnitt nedan

Placera stämpeln på en sida

Ett fångat XObject gör ingenting på egen hand. För att få det att visas sätter du in en kopia av det på dokumentets aktuella sida, den som valts av den 1-baserade egenskapen PageNumber, med InsertFormObjectFromXObject. Det anropet returnerar det underliggande sidobjektet, ett FPDF_PAGEOBJECT, och det returnerade handtaget är hur du positionerar placeringen. Utan en transformation landar stämpeln i origo i källsidans egna koordinater, vilket sällan är där du vill ha den

Eftersom InsertFormObjectFromXObject sätter in en kopia per anrop och lämnar tillbaka ett nytt sidobjekt varje gång, kan du måla samma XObject flera gånger på en sida vid olika transformationer, och det lagrade innehållet räknas fortfarande en gång i filen. En hörnlogotyp och en svag helsidesvattenstämpel kan komma från samma infångade objekt

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;

Två städningsdetaljer gör detta säkert. För det första, när det väl är insatt tillhör sidobjektet sidan, inte XObject. Att frigöra XObject senare ogiltigförklarar inte de placeringar du redan gjort. Det är det som låter skapa-placera-frigör-ordningen som beskrivs nedan fungera. För det andra ändrar införande och positionering bara sidans objektlista i minnet; UpdatePage är det som serialiserar den listan tillbaka till sidans innehållsström, så en sida du redigerar utan att anropa den sparas som om stämpeln aldrig hade placerats

Regeln för handtags livslängd som biter folk

Två begränsningar styr XObject-handtaget, och att ignorera någon av dem leder till ett misslyckande som ser orelaterat ut till sin orsak. För det första måste källdokumentet vara aktivt i det ögonblick du anropar CreateXObjectFromPage. Infångningen läser källsidans innehåll från det levande källdokumentet, så det dokumentet och dess sida måste vara öppna och giltiga när handtaget byggs. För det andra, och det är den här som överraskar folk, måste handtaget frigöras innan källsidan stängs, och i praktiken innan du stänger eller frigör källdokumentet det kom ifrån

Anledningen är att XObject är en referens in i strukturen som källdokumentet fortfarande äger. Det är inte en frikopplad, fristående kopia som du kan bära runt på efter att källan är borta. Stäng källan först och handtaget lämnas pekandes på innehåll som har rivits ner, så att frigöra det senare, eller all annan användning av det, opererar på minne som inte längre är giltigt. Symtomet är det klassiska för ett hängande handtag: en åtkomstöverträdelse vid avslutning, eller intermittent korruption som flyttar runt beroende på allokeringsordning, med en stack som pekar på städningskod snarare än på raden som faktiskt orsakade problemet. Lösningen är ordning, inte defensiv kodning. Bygg XObject, sätt in det på varje sida som behöver det, frigör XObject, och först därefter stänger du källdokumentet. Destruktorn TPdfXObject släpper det underliggande PDFium-handtaget åt dig, så att frigöra omslaget vid rätt tidpunkt är hela ditt ansvar

Matrisen, och vad dess sex siffror betyder

Placering är en 2D affin transformation, densamma som PDF använder överallt för positionering av innehåll (ISO 32000-1, avsnitt 8.3.4). Det är sex siffror, skrivna a, b, c, d, e, f, och PDFium exponerar dem som posten FS_MATRIX. De mappar en punkt från objektets egen rymd till sidans rymd:

// 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)

Du kan fylla dessa sex värden för hand, men att komponera dem för hand är där rotation går fel, eftersom rotation blandar alla fyra av a, b, c, d tillsammans. Omslaget TPdfMatrix, från FPdfMatrix-enheten, komponerar de vanliga operationerna åt dig och efter-multiplicerar vartefter den går, så Translate, Scale och Rotate kedjas i den ordning du anropar dem. En diagonal vattenstämpel är en rotering följt av en förskjutning för att centrera den igen; en hörnlogotyp är en skalning följt av en förskjutning. När matrisen är klar, kopierar du dess råa värde, egenskapen Handle av typen FS_MATRIX, in i en lokal variabel och skickar den till FPDFPageObj_SetMatrix; importen deklarerar matrisen som en var-parameter, så en egenskap kan inte överlämnas till den direkt, och dess resultat är 0 vid fel. Den lägre nivån FPDFPageObj_Transform, som tar de sex värdena direkt som flyttal, är tillgänglig när du hellre skickar siffror än bygger ett omslag

Stämpla varje sida, i rätt ordning

Hela mönstret sätter ihop bitarna med den ordning livslängdsregeln kräver. Öppna båda dokumenten, fånga stämpeln en gång, gå igenom destinationssidorna genom att ställa in det 1-baserade PageNumber i tur och ordning och sätta in plus positionera en kopia, checka in varje sida med UpdatePage, frigör sedan XObject, spara sedan med SaveAs, och låt källdokumentet stängas sist

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;

Formen på try-blocken gör det riktiga arbetet. Det inre finally frigör XObject innan kontrollen någonsin kan nå det yttre finally som frigör Stamp, så handtaget släpps alltid medan dess källa fortfarande lever, även om ett undantag kastas mitt i loopen. Få till den nästlingen rätt och livslängdsregeln tar hand om sig själv

Att stämpla är ett hörn av en större verktygslåda för att bygga och redigera sidinnehåll. Om din stämpel i sig är en bild snarare än en infångad sida, täcker konvertering av bilder till PDF-dokument med PDFium att få in den bitmappen i ett dokument först. Och när det du vill bära med dig jämte den synliga stämpeln är en fil snarare än bläck på sidan, visar att arbeta med PDF-bilagor i Delphi den inbäddade filsidan. Allt detta levereras med PDFium Component för Delphi och C++Builder, bredvid renderaren, redigeraren och de dokument-API:er som beskrivs på annat håll i den här bloggen