Technical Article

PDF Lifecycle Actions: Catalog /AA vs Page /AA in Delphi

PDFlibPas, the native Delphi and C++Builder PDF library, gives a PDF document two separate places to hang automatic behavior: document-level lifecycle actions such as WillClose, WillSave, DidSave, WillPrint and DidPrint, stored in the Catalog's /AA dictionary, and page-level lifecycle actions — Open and Close — stored in each Page object's own /AA dictionary instead. Confusing the two containers is the single most common way a lifecycle action silently does nothing

The motivating cases are ordinary ones. A finance team wants a statement template that stamps a print timestamp and logs who printed it the moment printing actually starts, not when the file merely opens. A forms-heavy workflow needs field values pushed to a server automatically before the reader's PDF client is allowed to close the window, so a closed tab never means a lost edit. A multi-page report wants a page-specific banner that appears only while that page is on screen. PDF actually offers a third tier below document and page for this kind of behavior — actions attached to an individual form field or link's own /A entry, the subject of a companion article on interactive form actions and JavaScript — but this article stays at the two tiers above it: the whole document, and a single page

What triggers live on the document Catalog's /AA?

Five triggers live on the Catalog /AA dictionary, and every one of them fires for an event that affects the whole document, not a single page. ISO 32000-1 §12.6.3 (Trigger Events) lists the document-level keys as WC, WS, DS, WP and DP — the literal two-letter names written into the /AA dictionary — for WillClose, WillSave, DidSave, WillPrint and DidPrint respectively, and PDFlibPas mirrors that set exactly in the TPDFlibDocumentActionTrigger enumeration: datWillClose, datWillSave, datDidSave, datWillPrint, datDidPrint. SetDocumentAction is the single entry point that attaches any of the five, and the ActionKind parameter it takes is one of ten PDF_ACTION_BUILDER_* constants shared across every action-builder call in the library, from a plain URI to a script to a destination jump. What a GoTo, remote-file, embedded-file or Launch action actually does once triggered is a different question from where it gets attached, and that is the subject of a companion article on GoTo, remote, embedded and launch actions — this one stays with the container question, Catalog or Page, rather than the action-kind question

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;

How is a page-level trigger different from a document-level one?

A page-level trigger fires only for the single Page object it is attached to, and PDFlibPas stores it in that page's own /AA dictionary rather than the Catalog's. There are only two page triggers, Open and Close, corresponding to the O and C keys ISO 32000-1 defines for a page's additional-actions dictionary, and PDFlibPas exposes them as patOpen and patClose through SetPageAction, which attaches to whichever page is currently selected via SelectPage — a detail that matters the first time you loop across a document expecting one call to apply everywhere, because it never does. Attaching either kind of trigger also raises the file's minimum PDF version, and the two containers ask for different floors: PDFlibPas raises the document to at least PDF 1.4 the first time it writes a Catalog /AA entry, and to at least PDF 1.5 the first time it writes a Page /AA entry, regardless of which action kind sits inside. That is a container-level requirement layered on top of whatever the action itself needs on its own, so a bare URI action that would only require PDF 1.1 standing alone still pulls the whole file up to PDF 1.5 once it is wrapped in a page-open trigger

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

Reading and removing lifecycle actions

GetDocumentActionInfo and GetPageActionInfo both return a TPDFlibActionInfo record, and the Kind field comes back akNone whenever that trigger has nothing attached, so check Kind before trusting any other field on the record — URI, JavaScript, FileName and the rest are only meaningful for the one action kind Kind actually reports, since the same record shape is reused across every action type the builder can produce. RemoveDocumentAction and RemovePageAction each clear a single trigger and report 1 when they found something to remove, 0 when the trigger was already empty; when the removed entry was the last one left in the /AA dictionary, PDFlibPas deletes the now-empty /AA itself rather than leaving a dangling, meaningless container behind on the Catalog or the page

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;

Does PDF/A allow lifecycle actions at all?

No. PDF/A conformance rejects the entire additional-actions container, not just the action kinds that sound risky, because ISO 19005 restricts PDF's interactive-action model on the assumption that an archival file must render the same way decades from now, without depending on a scripting engine or a network connection that might not exist by then. SetLifecycleAction, the shared builder behind both SetDocumentAction and SetPageAction, checks PDFAMode before it ever looks at ActionKind, so a URI action that just opens a company web page or a Named action that only means go to the next page gets caught in the same net as a dangerous one — nothing a security reviewer would normally flag, blocked anyway, because the restriction is structural rather than case-by-case. The practical danger is that the rejection is silent: SetDocumentAction and SetPageAction both return 0 without raising an exception, so a call site that never checks the return value ships a document quietly missing the trigger it was supposed to carry

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');

One asymmetry is worth keeping in mind. RemoveDocumentAction and RemovePageAction never check PDFAMode, so loading a file that already carries non-conformant lifecycle actions and stripping them out on the way to a PDF/A-conformant save works exactly as expected — only the write path, attaching a new trigger, is gated on the compliance mode

Where does print-on-open fit without a WillOpen trigger?

The Catalog /AA dictionary has no WillOpen entry at all, by design — document-level /AA in ISO 32000-1 defines exactly five keys, WillClose, WillSave, DidSave, WillPrint and DidPrint, and nothing in that list fires purely because a file was opened. The open-time hook lives in a separate Catalog entry, /OpenAction, which PDFlibPas exposes through its own family of calls, SetOpenActionJavaScript, SetOpenActionDestination and SetOpenActionNamedDestination among them, none of which touch the /AA dictionary or the TPDFlibDocumentActionTrigger enumeration at all. The two mechanisms do compose, though, and that is usually what a print-on-open template actually needs: build the template so its /OpenAction starts the print job, typically a JavaScript action calling the viewer's own print command, and the print itself is what gives WillPrint and DidPrint something to run against — a timestamp stamped in before the pages spool, an audit entry written once they are done

How reliable are these triggers across PDF viewers?

Not every viewer runs them, even outside PDF/A, so treat a lifecycle action as a request rather than a guarantee. Acrobat and most full desktop readers execute the whole set faithfully, but a large share of real-world PDF consumption never touches an additional-actions dictionary at all: browser-embedded viewers, most mobile readers, and nearly every server-side rendering or text-extraction pipeline either ignore /AA outright or honor only a narrow slice of it, with WillPrint and DidPrint typically faring worst since headless conversion has no print operation for them to hook into. If a WillClose submit-form action is the only path that captures form data, it is not a reliable path — pair it with an explicit submit button, and treat the automatic trigger as a convenience for the readers that happen to support it

Document, page and field triggers are three tiers of the same underlying action-dictionary machinery, and once the container is clear, the rest is picking the right ActionKind constant and checking the return code. These lifecycle triggers, along with the wider action-builder API this article touches on, ship as part of the standard PDFlibPas Delphi PDF library, with the full trigger and action-kind reference in the product documentation