Technischer Artikel

PDF-Lebenszyklus-Aktionen: Catalog /AA vs. Page /AA in Delphi

PDFlibPas, die native Delphi- und C++Builder-PDF-Bibliothek, gibt einem PDF-Dokument zwei separate Orte, um automatisches Verhalten aufzuhängen: dokumentweite Lebenszyklus-Aktionen wie WillClose, WillSave, DidSave, WillPrint und DidPrint, gespeichert im /AA-Wörterbuch des Catalogs, und seitenweise Lebenszyklus-Aktionen — Open und Close — stattdessen gespeichert im eigenen /AA-Wörterbuch jedes Page-Objekts. Die beiden Container zu verwechseln ist der mit Abstand häufigste Weg, wie eine Lebenszyklus-Aktion still nichts tut

Die motivierenden Fälle sind gewöhnlich. Ein Finanzteam möchte eine Kontoauszugs-Vorlage, die einen Druck-Zeitstempel setzt und protokolliert, wer gedruckt hat, in dem Moment, in dem das Drucken tatsächlich beginnt, nicht wenn die Datei nur öffnet. Ein formularlastiger Workflow braucht Feldwerte, die automatisch an einen Server gepusht werden, bevor der PDF-Client des Readers das Fenster schließen darf, sodass ein geschlossener Tab nie eine verlorene Bearbeitung bedeutet. Ein mehrseitiger Report möchte ein seitenspezifisches Banner, das nur erscheint, während diese Seite auf dem Bildschirm ist. PDF bietet für diese Art von Verhalten tatsächlich eine dritte Stufe unter Dokument und Seite — Aktionen, die an das eigene /A eines einzelnen Formularfelds oder Links angehängt sind, das Thema eines begleitenden Artikels zu interaktiven Formularaktionen und JavaScript — aber dieser Artikel bleibt bei den zwei Stufen darüber: dem gesamten Dokument und einer einzelnen Seite

Welche Auslöser leben am /AA des Dokument-Catalogs?

Fünf Auslöser leben am Catalog-/AA-Wörterbuch, und jeder von ihnen feuert für ein Ereignis, das das gesamte Dokument betrifft, nicht eine einzelne Seite. ISO 32000-1 §12.6.3 (Trigger Events) listet die dokumentweiten Schlüssel als WC, WS, DS, WP und DP — die wörtlichen Zwei-Buchstaben-Namen, die in das /AA-Wörterbuch geschrieben werden — für WillClose, WillSave, DidSave, WillPrint bzw. DidPrint, und PDFlibPas spiegelt diese Menge exakt in der Enumeration TPDFlibDocumentActionTrigger: datWillClose, datWillSave, datDidSave, datWillPrint, datDidPrint. SetDocumentAction ist der einzige Einstiegspunkt, der eine der fünf anhängt, und der ActionKind-Parameter, den es entgegennimmt, ist eine von zehn PDF_ACTION_BUILDER_*-Konstanten, geteilt über jeden Action-Builder-Aufruf in der Bibliothek, von einer schlichten URI bis zu einem Skript bis zu einem Ziel-Sprung. Was eine GoTo-, Remote-Datei-, Eingebettete-Datei- oder Launch-Aktion tatsächlich tut, sobald sie ausgelöst wird, ist eine andere Frage als wo sie angehängt wird, und das ist das Thema eines begleitenden Artikels zu GoTo-, Remote-, Embedded- und Launch-Aktionen — dieser hier bleibt bei der Container-Frage, Catalog oder Page, statt der Aktionsart-Frage

var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.AddStandardFont(4);
    Lib.DrawText(40, 700, 'Quarterly statement');
    Lib.SetDocumentAction(datWillSave, PDF_ACTION_BUILDER_WEB,
      'https://example.com/audit/will-save', '', 0, 0);
    Lib.SetDocumentAction(datWillClose, PDF_ACTION_BUILDER_SUBMIT,
      'https://example.com/forms/submit', 'CustomerName;OrderTotal', 0, 0);
    Lib.SaveToFile('statement.pdf');
  finally
    Lib.Free;
  end;
end;

Wie unterscheidet sich ein seitenweiser Auslöser von einem dokumentweiten?

Ein seitenweiser Auslöser feuert nur für das eine Page-Objekt, an das er angehängt ist, und PDFlibPas speichert ihn im eigenen /AA-Wörterbuch dieser Seite statt im des Catalogs. Es gibt nur zwei Seiten-Auslöser, Open und Close, entsprechend den Schlüsseln O und C, die ISO 32000-1 für das Additional-Actions-Wörterbuch einer Seite definiert, und PDFlibPas legt sie als patOpen und patClose über SetPageAction frei, das an die aktuell über SelectPage ausgewählte Seite anhängt — ein Detail, das beim ersten Mal zählt, wenn man über ein Dokument schleift und erwartet, ein Aufruf gelte überall, was nie der Fall ist. Das Anhängen beider Auslöserarten erhöht auch die PDF-Mindestversion der Datei, und die beiden Container verlangen unterschiedliche Untergrenzen: PDFlibPas hebt das Dokument beim ersten Schreiben eines Catalog-/AA-Eintrags auf mindestens PDF 1.4, und beim ersten Schreiben eines Page-/AA-Eintrags auf mindestens PDF 1.5, unabhängig davon, welche Aktionsart darin sitzt. Das ist eine Container-Ebenen-Anforderung, die über das geschichtet wird, was die Aktion selbst für sich braucht, sodass eine bloße URI-Aktion, die allein nur PDF 1.1 verlangen würde, die gesamte Datei trotzdem auf PDF 1.5 zieht, sobald sie in einen Seiten-Open-Auslöser gewickelt wird

Lib.SelectPage(3);
Lib.SetPageAction(patOpen, PDF_ACTION_BUILDER_JAVASCRIPT,
  'app.alert("Section 3: internal review only");', '', 0, 0);
Lib.SetPageAction(patClose, PDF_ACTION_BUILDER_WEB,
  'https://example.com/analytics/page-3-closed', '', 0, 0);

Lebenszyklus-Aktionen lesen und entfernen

GetDocumentActionInfo und GetPageActionInfo geben beide einen TPDFlibActionInfo-Datensatz zurück, und das Kind-Feld kommt als akNone zurück, wann immer diesem Auslöser nichts angehängt ist, also prüfen Sie Kind, bevor Sie irgendeinem anderen Feld des Datensatzes vertrauen — URI, JavaScript, FileName und der Rest sind nur für die eine Aktionsart bedeutsam, die Kind tatsächlich meldet, da dieselbe Datensatzform über jede Aktionsart wiederverwendet wird, die der Builder erzeugen kann. RemoveDocumentAction und RemovePageAction löschen jeweils einen einzelnen Auslöser und melden 1, wenn sie etwas zu entfernen fanden, 0, wenn der Auslöser bereits leer war; war der entfernte Eintrag der letzte, der im /AA-Wörterbuch übrig war, löscht PDFlibPas das jetzt leere /AA selbst, statt einen baumelnden, bedeutungslosen Container am Catalog oder an der Seite zurückzulassen

var
  Info: TPDFlibActionInfo;
begin
  Info := Lib.GetDocumentActionInfo(datWillSave);
  if Info.Kind = akURI then
    WriteLn('WillSave calls out to: ', string(Info.URI));

  if Lib.RemoveDocumentAction(datWillSave) = 1 then
    Lib.SetDocumentAction(datWillSave, PDF_ACTION_BUILDER_WEB,
      'https://example.com/audit/will-save-v2', '', 0, 0);
end;

Erlaubt PDF/A überhaupt Lebenszyklus-Aktionen?

Nein. PDF/A-Konformität weist den gesamten Additional-Actions-Container zurück, nicht nur die riskant klingenden Aktionsarten, weil ISO 19005 PDFs interaktives Aktionsmodell unter der Annahme einschränkt, dass eine Archivdatei noch in Jahrzehnten gleich rendern muss, ohne von einer Skript-Engine oder einer Netzwerkverbindung abzuhängen, die dann vielleicht nicht mehr existiert. SetLifecycleAction, der gemeinsame Builder hinter sowohl SetDocumentAction als auch SetPageAction, prüft PDFAMode, bevor er überhaupt ActionKind ansieht, sodass eine URI-Aktion, die nur eine Firmen-Webseite öffnet, oder eine Named-Aktion, die nur "zur nächsten Seite" bedeutet, in demselben Netz gefangen wird wie eine gefährliche — nichts, was ein Sicherheitsprüfer normalerweise markieren würde, trotzdem blockiert, weil die Einschränkung strukturell ist, nicht fallweise. Die praktische Gefahr ist, dass die Ablehnung still ist: SetDocumentAction und SetPageAction geben beide 0 zurück, ohne eine Exception zu werfen, sodass eine Aufrufstelle, die den Rückgabewert nie prüft, ein Dokument ausliefert, dem still der Auslöser fehlt, den es tragen sollte

Lib.SetPDFAMode(2); // PDF/A-1b
if Lib.SetDocumentAction(datWillClose, PDF_ACTION_BUILDER_NAMED,
     '', '', 0, 0) = 0 then
  // rejected: PDF/A-1b forbids Catalog /AA, even a plain Named action
  WriteLn('lifecycle action not attached');

Eine Asymmetrie lohnt es sich im Kopf zu behalten. RemoveDocumentAction und RemovePageAction prüfen nie PDFAMode, sodass das Laden einer Datei, die bereits nicht konforme Lebenszyklus-Aktionen trägt, und das Entfernen auf dem Weg zu einem PDF/A-konformen Speichern genau wie erwartet funktioniert — nur der Schreibpfad, das Anhängen eines neuen Auslösers, ist an den Konformitätsmodus gebunden

Wo passt Drucken-beim-Öffnen ohne einen WillOpen-Auslöser hin?

Das Catalog-/AA-Wörterbuch hat absichtlich überhaupt keinen WillOpen-Eintrag — dokumentweites /AA in ISO 32000-1 definiert genau fünf Schlüssel, WillClose, WillSave, DidSave, WillPrint und DidPrint, und nichts in dieser Liste feuert rein deshalb, weil eine Datei geöffnet wurde. Der Öffnungszeit-Haken lebt in einem separaten Catalog-Eintrag, /OpenAction, den PDFlibPas über seine eigene Familie von Aufrufen freilegt, darunter SetOpenActionJavaScript, SetOpenActionDestination und SetOpenActionNamedDestination, von denen keiner das /AA-Wörterbuch oder die Enumeration TPDFlibDocumentActionTrigger überhaupt berührt. Die beiden Mechanismen komponieren sich jedoch, und das ist meist das, was eine Drucken-beim-Öffnen-Vorlage tatsächlich braucht: Die Vorlage so bauen, dass ihre /OpenAction den Druckjob startet, typischerweise eine JavaScript-Aktion, die den eigenen Druckbefehl des Viewers aufruft, und der Druck selbst ist das, was WillPrint und DidPrint etwas gibt, wogegen sie laufen können — ein Zeitstempel, gesetzt bevor die Seiten gespoolt werden, ein Audit-Eintrag, geschrieben sobald sie fertig sind

Wie zuverlässig sind diese Auslöser über PDF-Viewer hinweg?

Nicht jeder Viewer führt sie aus, selbst außerhalb von PDF/A, also behandeln Sie eine Lebenszyklus-Aktion als Anfrage, nicht als Garantie. Acrobat und die meisten vollständigen Desktop-Reader führen die gesamte Menge treu aus, aber ein großer Anteil des realen PDF-Konsums berührt ein Additional-Actions-Wörterbuch überhaupt nie: browser-eingebettete Viewer, die meisten mobilen Reader, und fast jede serverseitige Rendering- oder Textextraktions-Pipeline ignoriert /AA entweder rundweg oder respektiert nur einen schmalen Ausschnitt davon, mit WillPrint und DidPrint typischerweise am schlechtesten abschneidend, da Headless-Konvertierung keine Druckoperation hat, in die sie sich einhängen könnten. Falls eine WillClose-Submit-Form-Aktion der einzige Pfad ist, der Formulardaten erfasst, ist das kein zuverlässiger Pfad — paaren Sie ihn mit einem expliziten Submit-Button, und behandeln Sie den automatischen Auslöser als Annehmlichkeit für die Reader, die ihn zufällig unterstützen

Dokument-, Seiten- und Feld-Auslöser sind drei Stufen derselben zugrunde liegenden Aktionswörterbuch-Maschinerie, und sobald der Container klar ist, bleibt nur, die richtige ActionKind-Konstante zu wählen und den Rückgabecode zu prüfen. Diese Lebenszyklus-Auslöser, zusammen mit der breiteren Action-Builder-API, die dieser Artikel anschneidet, sind Teil der Standard-PDFlibPas-Delphi-PDF-Bibliothek, mit der vollständigen Auslöser- und Aktionsart-Referenz in der Produktdokumentation