Technical Article

Wiederverwendbare Seitenstempel über Form XObjects mit PDFium

Das Stempeln eines Wasserzeichens oder Logos auf jede Seite eines Dokuments sieht nach einer Fünf-Minuten-Aufgabe aus, bis Sie das Ergebnis in einem Tool zur Dateigrößen-Inspektion öffnen. Der naheliegende Ansatz besteht darin, die Seiten zu durchlaufen und auf jeder Seite dieselben Text- oder Bildobjekte erneut zu erstellen. Das funktioniert zwar visuell, ist aber in einer Weise verschwenderisch, die sich summiert. Ein diagonales "ENTWURF"-Wasserzeichen, das direkt auf einen hundertseitigen Bericht gezeichnet wird, bedeutet hundert Kopien derselben Pfad- und Textdaten in den Inhaltsströmen – und die gespeicherte Datei trägt jede einzelne davon.

Ein Form XObject ist das Konstrukt, das PDF bereitstellt, um genau dies zu vermeiden. Es kapselt ein wiederverwendbares Inhaltsstück, eine ganze Seite oder ein kleines Template, in ein einziges benanntes Objekt, das oft und an vielen Positionen gezeichnet werden kann. Der Inhalt ist nur einmal in der Datei vorhanden. Jede Seite, die den Stempel benötigt, enthält eine kurze Anweisung, die besagt: "Zeichne XObject N hier mit dieser Transformation." Ein hundertseitiges Wasserzeichen fügt der Datei dann nur ein einziges Inhaltsobjekt hinzu und nicht hundert. Das ist der Unterschied zwischen einem dokument, dessen Größe linear mit der Seitenzahl wächst, und einem, bei dem dies nicht der Fall ist. Wasserzeichen, Logo-Stempel, Seitenzahl-Vorlagen und Siegel sind alle von derselben Natur, und das Form XObject is das richtige Werkzeug für jedes einzelne davon.

Warum ein gespeichertes Objekt hundert Neuzeichnungen schlägt

Die Einsparung ist struktureller und nicht kosmetischer Natur. Eine PDF-Seite wird gerendert, indem ihr Inhaltsstrom – eine Folge von Zeichen-Operatoren – ausgeführt wird. Wenn Sie einen Stempel pro Seite neu zeichnen, hängen Sie die vollständige Operatorsequenz für diesen Stempel an den Datenstrom jeder Seite an. Die Bytes werden also so oft dupliziert, wie Sie Seiten haben. Ein Form XObject verschiebt diese Operatoren in einen einzigen Datenstrom, der einmal im Dokument gespeichert wird. Die Referenz, die eine einzelne Seite behält, ist klein: Sie wendet eine Transformationsmatrix an, ruft das XObject auf und stellt den Zustand wieder her. Die Seitenzahl multipliziert nicht mehr die Kosten des Kunstwerks.

Dies ist besonders wichtig, wenn der Stempel komplex ist. Ein Vektorsiegel mit Hunderten von Pfadsegmenten oder eine Logo-Bitmap ist teuer zu speichern. Einmal gespeichert und referenziert, wird der komplexe Teil nur ein einziges Mal bezahlt, und der Overhead pro Seite beträgt nur wenige Byte für den Aufruf. Das visuale Ergebnis auf der Seite ist identisch mit einer direkten Neuzeichnung, was der eigentliche Punkt ist. Der Leser kann den Unterschied nicht erkennen – die Dateigröße hingegen sehr wohl.

Erfassen einer Seite in einem XObject

PDFium erstellt das wiederverwendbare Objekt aus einer bestehenden Seite. Die Quelle ist eine Seite in einem geöffneten Dokument – ein kleines einseitiges PDF, das nichts als Ihr Wasserzeichen enthält, oder eine bestimmte Seite einer größeren Datei. CreateXObjectFromPage erfasst den Inhalt dieser Quellseite in einem wiederverwendbaren Handle, das dem Zieldokument gehört (dem Dokument, das Sie stempeln).

var
  Dest, Stamp: TPdf;
  XObject: TPdfXObject;
begin
  Dest := TPdf.Create;
  Stamp := TPdf.Create;
  try
    Dest.LoadFromFile('Report.pdf');
    Stamp.LoadFromFile('Watermark.pdf');   // one page of artwork

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

Die Signatur lautet CreateXObjectFromPage(Source: TPdf; SourcePageIndex: Integer): TPdfXObject. Die Methode gibt im Fehlerfall nil zurück, anstatt eine Ausnahme auszulösen, weshalb die explizite Prüfung oben zwingend erforderlich ist. Das zurückgegebene Handle ist ein TPdfXObject, das Sie besitzen, und die beiden damit verbundenen Einschränkungen der Lebensdauer sind der Teil dieser Übung, bei dem Fehler passieren. Daher erhalten sie unten einen eigenen Abschnitt.

Platzieren des Stempels auf einer Seite

Ein erfasstes XObject tut von alleine nichts. Damit es erscheint, fügen Sie mit InsertFormObjectFromXObject eine Kopie davon auf der aktuellen Seite des Dokuments ein. Dieser Aufruf gibt das zugrundeliegende Seitenobjekt, ein FPDF_PAGEOBJECT, zurück, und über das zurückgegebene Handle positionieren Sie die Platzierung. Ohne Transformation landet der Stempel am Ursprung in den Koordinaten der Quellseite selbst, was selten gewünscht ist.

Da InsertFormObjectFromXObject pro Aufruf eine Kopie einfügt und jedes Mal ein neues Seitenobjekt zurückgibt, können Sie dasselbe XObject mehrmals auf einer Seite mit unterschiedlichen Transformationen zeichnen – und der gespeicherte Inhalt wird in der Datei immer noch nur einmal gezählt. Ein Eckenlogo und ein schwaches ganzseitiges Wasserzeichen können aus demselben erfassten Objekt stammen.

var
  PageObj: FPDF_PAGEOBJECT;
  M: TPdfMatrix;
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);
    FPDFPageObj_SetMatrix(PageObj, M.Handle);
  finally
    M.Free;
  end;
  // Dest.SaveLoadedDocument(...) when every page is done.
end;

Ein Detail bezüglich des Besitzes macht das Bereinigen sicher. Nach dem Einfügen gehört das Seitenobjekt zur Seite und nicht zum XObject. Das spätere Freigeben des XObjects macht die bereits vorgenommenen Platzierungen nicht ungültig. Dadurch funktioniert die unten beschriebene Reihenfolge "Erstellen, Platzieren, Freigeben".

Die Regel zur Handle-Lebensdauer, die oft übersehen wird

Zwei Einschränkungen regeln das XObject-Handle, und das Ignorieren einer von beiden führt zu einem Fehler, der scheinbar nichts mit seiner Ursache zu tun hat. Erstens muss das Quelldokument in dem Moment aktiv sein, in dem Sie CreateXObjectFromPage aufrufen. Die Erfassung liest den Inhalt der Quellseite aus dem aktiven Quelldokument, sodass dieses Dokument und seine Seite geöffnet und gültig sein müssen, wenn das Handle erstellt wird. Zweitens – und das überrascht viele –, muss das Handle freigegeben werden, bevor die Quellseite geschlossen wird, und in der Praxis, bevor Sie das Quelldokument, aus dem es stammt, schließen oder freigeben.

Der Grund dafür ist, dass das XObject eine Referenz auf eine Struktur darstellt, die das Quelldokument weiterhin besitzt. Es ist keine eigenständige Kopie, die Sie mit sich herumtragen können, nachdem die Quelle geschlossen wurde. Wenn Sie die Quelle zuerst schließen, zeigt das Handle auf Inhalte, die bereits abgebaut wurden, sodass das spätere Freigeben oder jede andere Verwendung auf Speicher zugreift, der nicht mehr gültig ist. Das Symptom ist das klassische für ein dangelndes Handle: eine Zugriffsverletzung beim Beenden oder eine unregelmäßige Speicherbeschädigung, deren Stack auf Bereinigungscode zeigt. Die Lösung ist die richtige Reihenfolge und nicht defensiver Code. Erstellen Sie das XObject, fügen Sie es auf allen erforderlichen Seiten ein, geben Sie das XObject frei und schließen Sie erst dann das Quelldokument. Der Destruktor von TPdfXObject gibt das zugrundeliegende PDFium-Handle für Sie frei, sodass die Freigabe des Wrappers zur richtigen Zeit ganz in Ihrer Verantwortung liegt.

Die Matrix und die Bedeutung ihrer sechs Zahlen

Die Platzierung ist eine affine 2D-Transformation, dieselbe, die PDF überall zur Positionierung von Inhalten verwendet (ISO 32000-1, Abschnitt 8.3.4). Es handelt sich um sechs Zahlen, geschrieben a, b, c, d, e, f, und PDFium stellt sie als FS_MATRIX-Datensatz dar. Sie bilden einen punkt aus dem Objektraum auf den Seitenraum ab:

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

Sie können diese sechs Werte von Hand füllen, aber das manuelle Zusammensetzen führt oft zu Fehlern bei der Rotation, da Rotation alle vier Werte a, b, c, d miteinander mischt. Der Wrapper TPdfMatrix setzt die üblichen Operationen für Sie zusammen, sodass Translate, Scale und Rotate in der Reihenfolge ihres Aufrufs verkettet werden. Ein diagonales Wasserzeichen ist eine Drehung gefolgt von einer Verschiebung zur Zentrierung; ein Eckenlogo ist eine Skalierung gefolgt von einer Verschiebung. Wenn die Matrix fertig ist, übergeben Sie ihren rohen Wert an FPDFPageObj_SetMatrix(PageObj, M.Handle), wobei M.Handle die zugrundeliegende FS_MATRIX ist. Die Low-Level-Funktion FPDFPageObj_Transform, die die sechs Werte direkt als Double-Werte akzeptiert, ist verfügbar, wenn Sie lieber Zahlen übergeben möchten.

Stempeln jeder Seite in der richtigen Reihenfolge

Das vollständige Muster setzt die Teile in der Reihenfolge zusammen, die die Lebensdauer-Regel verlangt. Öffnen Sie beide Dokumente, erfassen Sie den Stempel einmal, durchlaufen Sie die Zielseiten, fügen Sie jeweils eine Kopie ein und positionieren Sie diese, geben Sie dann das XObject frei, speichern Sie und lassen Sie das Quelldokument als letztes schließen.

procedure StampEveryPage(const ASource, AStamp, AOutput: string);
var
  Dest, Stamp: TPdf;
  XObject: TPdfXObject;
  PageObj: FPDF_PAGEOBJECT;
  M: TPdfMatrix;
  i: Integer;
begin
  Dest := TPdf.Create;
  Stamp := TPdf.Create;
  try
    Dest.LoadFromFile(ASource);
    Stamp.LoadFromFile(AStamp);

    // 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.
      for i := 0 to Dest.PageCount - 1 do
      begin
        Dest.CurrentPageIndex := 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
          FPDFPageObj_SetMatrix(PageObj, M.Handle);
        finally
          M.Free;
        end;
      end;
    finally
      XObject.Free;                          // 3. free BEFORE Stamp closes
    end;

    // 4. Write the result while Dest is still open.
    Dest.SaveLoadedDocument(AOutput);
  finally
    Stamp.Free;                              // source closes last
    Dest.Free;
  end;
end;

Die Struktur der try-Blöcke leistet hier die eigentliche Arbeit. Das innere finally gibt das XObject frei, bevor die Steuerung das äußere finally erreicht, das Stamp freigibt. Dadurch wird das Handle immer freigegeben, während seine Quelle noch aktiv ist, selbst wenn mitten in der Schleife eine Ausnahme auftritt. Wenn Sie diese Verschachtelung einhalten, löst sich die Lebensdauer-Regel von selbst. (Verwenden Sie den aktuellen Seitenselektor, den Ihr Build bereitstellt; der Schleifenkörper bleibt derselbe.)

Das Stempeln ist ein Teilbereich eines größeren Werkzeugsatzes zum Erstellen und Bearbeiten von Seiteninhalten. Wenn Ihr Stempel selbst ein Bild und keine erfasste Seite ist, beschreibt Konvertieren von Bildern in PDF-Dokumente mit PDFium, wie Sie diese Bitmap zuerst in ein Dokument bekommen. Und wenn Sie eine Datei anstelle von Tinte auf der Seite mitführen möchten, zeigt Arbeiten mit PDF-Anhängen in Delphi die Seite der eingebetteten Dateien. All das wird mit der PDFium-Komponente für Delphi und C++Builder ausgeliefert, zusammen mit den an anderer Stelle in diesem Blog behandelten Rendering-, Bearbeitungs- und Dokumenten-APIs.