Teknisk artikel

Genanvendelige sidestempler via Form XObjects med PDFium

At stemple et vandmærke eller et logo på hver side i et dokument ligner et fem-minutters job, indtil du åbner resultatet i en filstørrelses-inspektør (file-size inspector). Den åbenlyse tilgang er at gennemgå siderne og på hver af dem bygge de samme tekst- eller billedobjekter igen. Det fungerer visuelt, og det er spild (wasteful) på en måde, der forstærkes (compounds). Et diagonalt "UDKAST" (DRAFT) vandmærke tegnet direkte på en hundredesiders rapport er hundrede kopier af de samme sti- og tekstdata (path and text data), der sidder i indholdsstrømmene (the content streams), og den gemte fil bærer hver og en af dem

Et Form XObject er den konstruktion, PDF leverer for at undgå præcis dette. Den indpakker et stykke genanvendeligt indhold, en hel side eller en lille skabelon (template), i et enkelt navngivet objekt, der kan males (painted) mange gange på mange positioner. Indholdet lever i filen én gang. Hver side, der ønsker stemplet, rummer en kort instruktion, der siger "mal XObject N her, med denne transformation" (paint XObject N here, with this transform). Et hundrede-siders vandmærke tilføjer derefter ét indholdsobjekt til filen snarere end hundrede, og det er forskellen mellem et dokument, der vokser lineært med sit sideantal, og et, der ikke gør. Vandmærker, logo-stempler, sidetal-skabeloner og segl (seals) er alle den samme form for problem, og Form XObject er det rigtige værktøj til hver og en af dem

Diagram, der kontrasterer at tegne vandmærkeoperatorer på hver PDF-side med at gemme dem én gang i en Form XObject med PDFium
At genskrive stemplet på hver side duplikerer dets bytes på tværs af hver content stream, mens et Form XObject gemmer kunsten én gang og lader hver side referere den

Hvorfor ét gemt objekt slår hundrede gentegninger (redraws)

Besparelsen er strukturel, ikke kosmetisk. En PDF-side rendereres ved at udføre dens indholdsstrøm, en sekvens af tegningsoperatører (drawing operators). Når du gentegner et stempel pr. side, tilføjer (appending) du den fulde operatørsekvens for det stempel til hver sides strøm, og bytene (the bytes) kopieres lige så mange gange, som du har sider. Et Form XObject flytter disse operatører over i én strøm gemt én gang i dokumentet. Referencen en individuel side beholder er lille: den skubber (pushes) en transformationsmatrix (transformation matrix), påkalder (invokes) XObject'et og gendanner (restores) tilstanden. Sideantallet multiplicerer ikke længere omkostningerne ved kunstværket (the artwork)

Dette betyder mest, når stemplet er tungt. Et vektorsegl (vector seal) med hundredvis af stisegmenter, eller en logo-bitmap, er dyrt at gemme. Gemt én gang og refereret (referenced), betales den tunge del for en enkelt gang, og pr.-side overheadet (per-page overhead) er nogle få bytes af påkaldelse. Det visuelle resultat på siden er identisk med en direkte gentegning, hvilket er pointen. Læseren kan ikke se forskel; filstørrelsen kan i høj grad

Indfangning (Capturing) af en side i et XObject

PDFium bygger det genanvendelige objekt fra en eksisterende side. Kilden er en side i et eller andet dokument, du har åbent, en lille én-sides PDF, der ikke indeholder andet end dit vandmærkearbejde (watermark artwork), eller en bestemt side af en større fil. CreateXObjectFromPage indfanger denne kildesides indhold i et genanvendeligt handle, der tilhører destinationsdokumentet, det du stempler på

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';   // én side med grafik
    Stamp.Active := True;
    if not (Dest.Active and Stamp.Active) then
      raise Exception.Create('Could not open the input documents');

    // Fang side 0 i stempeldokumentet ind i et genbrugeligt håndtag, der
    // ejes af Dest. Source skal være aktiv; indekset er nul-baseret.
    XObject := Dest.CreateXObjectFromPage(Stamp, 0);
    if XObject = nil then
      raise Exception.Create('Could not build the stamp XObject');
    // ... placér det, og frigør det dernæst, før Stamp lukkes (se nedenfor) ...

Signaturen er CreateXObjectFromPage(Source: TPdf; SourcePageIndex: Integer): TPdfXObject. Metoden kaster en undtagelse (raises), hvis kildedokumentet ikke er Active, og den returnerer nil snarere end at kaste (raising), når PDFium ikke kan bygge objektet, så det eksplicitte tjek (check) ovenfor er ikke valgfrit. Det handle, der kommer tilbage, er et TPdfXObject du ejer, og de to livstidsbegrænsninger (lifetime constraints) knyttet til det er den del af hele denne øvelse, der overrasker folk (catches people out), så de får deres egen sektion nedenfor

Placering af stemplet på en side

Et indfanget XObject gør intet af sig selv. For at få det til at fremkomme, indsætter du en kopi af det på dokumentets aktuelle side, den der er valgt af den 1-baserede PageNumber egenskab, med InsertFormObjectFromXObject. Det kald returnerer det underliggende sideobjekt (page object), en FPDF_PAGEOBJECT, og det returnerede handle er, hvordan du positionerer placeringen. Uden en transformation (transform) lander stemplet ved origo (the origin) i kildesidens egne koordinater, hvilket sjældent er, hvor du ønsker det

Fordi InsertFormObjectFromXObject indsætter én kopi pr. kald og giver et friskt sideobjekt tilbage hver gang, kan du male det samme XObject adskillige gange på én side med forskellige transformationer, og det lagrede indhold tælles stadig én gang i filen. Et hjørnelogo og et svagt fuldsides vandmærke kan komme fra det samme indfangede objekt

var
  PageObj: FPDF_PAGEOBJECT;
  M: TPdfMatrix;
  RawM: FS_MATRIX;
begin
  // Den aktuelle side i Dest modtager én kopi af XObject'en.
  PageObj := Dest.InsertFormObjectFromXObject(XObject);
  if PageObj = nil then
    raise Exception.Create('Insert failed on this page');

  // Placér den: flyt 200 enheder til højre, 500 op, med 70 % skala.
  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;   // bekræft denne sides redigeringer i dens indholdsstrøm
  // hvis ikke Dest.SaveAs(...) så ... når hver side er færdig.
end;

To husholdningsdetaljer (housekeeping details) gør dette sikkert. For det første, når det er indsat, tilhører sideobjektet siden, ikke XObject'et. At frigøre XObject'et senere ugyldiggør ikke de placeringer, du allerede har lavet. Det er det, der lader den skab-placer-frigør (create-place-free) rækkefølge beskrevet nedenfor virke. For det andet ændrer indsættelse og positionering kun sidens objektliste i hukommelsen; UpdatePage er det, der serialiserer (serialises) den liste tilbage i sidens indholdsstrøm, så en side, du redigerer uden at kalde den, gemmes, som om stemplet aldrig blev placeret

Handle-livstidsreglen, der bider folk

To begrænsninger (constraints) styrer XObject-handle'et, og at ignorere en af dem producerer en fejl, der ser urelateret ud til dens årsag. For det første skal kildedokumentet være aktivt på det tidspunkt, du kalder CreateXObjectFromPage. Indfangningen læser kildesidens indhold fra det levende (live) kildedokument, så det dokument og dets side skal være åbne og gyldige, når handle'et bygges. For det andet, og det er den, der overrasker folk, skal handle'et frigøres, før kildesiden lukkes, og i praksis før du lukker eller frigør det kildedokument, det kom fra

Grunden er, at XObject'et er en reference ind i en struktur, som kildedokumentet stadig ejer. Det er ikke en løsrevet (detached), selvstændig (self-contained) kopi, du kan bære rundt på, efter kilden er væk. Luk kilden først, og handle'et efterlades pegende på indhold, der er blevet revet ned (torn down), så at frigøre det senere, eller enhver anden brug af det, opererer på hukommelse, der ikke længere er gyldig. Symptomet er det klassiske for et dinglende handle (dangling handle): en adgangskrænkelse (access violation) ved nedlukning (shutdown), eller intermitterende korruption (intermittent corruption), der flytter sig rundt afhængigt af allokeringsrækkefølgen (allocation order), med en stak (stack), der peger på oprydningskode i stedet for på den linje, der faktisk forårsagede problemet. Løsningen er rækkefølge (ordering), ikke defensiv kodning. Byg XObject'et, indsæt det på hver side, der har brug for det, frigør XObject'et, og luk først derefter kildedokumentet. TPdfXObject destruktoren frigiver det underliggende PDFium-handle for dig, så at frigøre indpakningen (the wrapper) på det rigtige tidspunkt er hele dit ansvar

Ordnet livscyklusdiagram for PDFium sidestempler, der viser fangst, placering, frigørelse af TPdfXObject-handle og lukning af stempeldokumentet sidst
Optag stemplet én gang, placér det på hver side, frigør XObjectet, mens stempeldokumentet stadig er åbent, og gem og luk derefter kilden sidst

Matrixen, og hvad dens seks tal betyder

Placering er en 2D affin transformation (affine transform), den samme som PDF bruger overalt til positionering af indhold (ISO 32000-1, afsnit 8.3.4). Det er seks tal, skrevet a, b, c, d, e, f, og PDFium udstiller dem som FS_MATRIX posten (record). De afbilder (map) et punkt fra objektets eget rum (space) til siderum (page space):

// x' = a*x + c*y + e
// y' = b*x + d*y + f
//
// a, d : horisontal og vertikal skala
// b, c : forskydnings- / rotationsleddene
// e, f : translation (hvor origo lander på siden)

Du kan udfylde de seks værdier i hånden, men at sammensætte (composing) dem i hånden er dér, hvor rotation går galt, fordi rotation blander alle fire af a, b, c, d sammen. TPdfMatrix indpakningen (the wrapper), fra FPdfMatrix enheden (unit), sammensætter de almindelige operationer for dig og post-multiplicerer undervejs, så Translate, Scale og Rotate kæder sig i den rækkefølge, du kalder dem. Et diagonalt vandmærke er en rotation efterfulgt af en forskydning (translate) for at gencentrere det; et hjørnelogo er en skalering (scale) efterfulgt af en forskydning. Når matrixen er klar, kopier dens rå værdi, Handle egenskaben af typen FS_MATRIX, ind i en lokal variabel og send den til FPDFPageObj_SetMatrix; importen (the import) deklarerer matrixen som en var parameter, så en egenskab (property) kan ikke gives direkte til den, og dens resultat er 0 ved fejl. Det lavere niveau FPDFPageObj_Transform, som tager de seks værdier direkte som doubles (doubles), er tilgængeligt, når du hellere vil sende tal end at bygge en indpakning

Stempling af hver side, i den rigtige rækkefølge

Det fulde mønster (pattern) sætter brikkerne sammen med den rækkefølge, livstidsreglen (the lifetime rule) kræver. Åbn begge dokumenter, indfang (capture) stemplet én gang, gennemgå (walk) destinationssiderne ved at indstille den 1-baserede PageNumber efter tur og indsætte samt positionere en kopi, overgive (committing) hver side med UpdatePage, frigør (free) derefter XObject'et, gem derefter med SaveAs, og lad kildedokumentet lukke til sidst

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. Fang grafikken én gang. Stamp er aktiv her.
    XObject := Dest.CreateXObjectFromPage(Stamp, 0);
    if XObject = nil then
      raise Exception.Create('Could not capture the stamp page');
    try
      // 2. Placér en kopi på hver side i Dest. PageNumber er 1-baseret.
      for I := 1 to Dest.PageCount do
      begin
        Dest.PageNumber := I;                // gør side I aktuel
        PageObj := Dest.InsertFormObjectFromXObject(XObject);
        if PageObj = nil then
          Continue;

        M := TPdfMatrix.Create;
        try
          M.Rotate(45);                      // diagonalt vandmærke
          M.Translate(150, 100);             // justér på plads
          RawM := M.Handle;
          FPDFPageObj_SetMatrix(PageObj, RawM);
        finally
          M.Free;
        end;
        Dest.UpdatePage;                     // bekræft denne sides redigeringer
      end;
    finally
      XObject.Free;                          // 3. frigør FØR Stamp lukkes
    end;

    // 4. Skriv resultatet, mens Dest stadig er åben.
    if not Dest.SaveAs(AOutput) then
      raise Exception.Create('Could not save ' + AOutput);
  finally
    Stamp.Free;                              // kilden lukkes sidst
    Dest.Free;
  end;
end;

Formen på try blokkene udfører det virkelige arbejde. Den indre finally frigør XObject'et, før kontrollen (control) nogensinde kan nå den ydre finally, der frigør Stamp, så handle'et bliver altid frigivet, mens dets kilde stadig er i live, selv hvis en undtagelse udløses (fires) midt i løkken. Få den indlejring (nesting) rigtig, og livstidsreglen tager sig af sig selv

FS_MATRIX-anatomi, der viser de seks affine koefficienter, PDFium bruger til at skalere, rotere og translere et stemplet Form XObject på siden
Seks tal afbilder stempelkoordinater ind i siderum, og TPdfMatrix komponerer Scale, Rotate og Translate i kald-rækkefølge for at anbringe et diagonalt vandmærke

Stempling er ét hjørne af et større værktøjssæt (toolkit) til at bygge og redigere sideindhold. Hvis dit stempel i sig selv er et billede snarere end en indfanget side, dækker konvertering af billeder til PDF-dokumenter med PDFium at få den bitmap ind i et dokument først. Og når det, du ønsker at bære ved siden af det synlige stempel, er en fil snarere end blæk på siden, viser arbejde med PDF-vedhæftninger i Delphi siden med indlejrede filer (the embedded-file side). Det hele leveres med PDFium-komponenten til Delphi og C++Builder, sammen med renderings-, redigerings- og dokument-API'erne dækket andre steder på denne blog