Technisch artikel

PDF-levenscyclusacties: Catalog /AA versus Page /AA in Delphi

PDFlibPas, de native Delphi- en C++Builder-PDF-bibliotheek, geeft een PDF-document twee aparte plekken om automatisch gedrag aan op te hangen: documentniveau-levenscyclusacties zoals WillClose, WillSave, DidSave, WillPrint en DidPrint, opgeslagen in de /AA-dictionary van de Catalog, en paginaniveau-levenscyclusacties — Open en Close — in plaats daarvan opgeslagen in de eigen /AA-dictionary van elk Page-object. De twee containers door elkaar halen is de meest voorkomende manier waarop een levenscyclusactie stilzwijgend niets doet

De motiverende gevallen zijn gewoon. Een financiënteam wil een afschriftsjabloon dat een afdruktijdstempel stempelt en logt wie het afdrukte op het moment dat het afdrukken daadwerkelijk begint, niet wanneer het bestand simpelweg opent. Een formulierzware workflow moet veldwaarden automatisch naar een server pushen voordat de PDF-client van de lezer het venster mag sluiten, zodat een gesloten tabblad nooit een verloren bewerking betekent. Een meerpaginarapport wil een pagina-specifieke banner die alleen verschijnt terwijl die pagina op het scherm staat. PDF biedt in feite een derde niveau onder document en pagina voor dit soort gedrag — acties gekoppeld aan de eigen /A-item van een individueel formulierveld of link, het onderwerp van een begeleidend artikel over interactieve formulieracties en JavaScript — maar dit artikel blijft bij de twee niveaus erboven: het hele document, en een enkele pagina

Welke triggers leven op de /AA van de Catalog van het document?

Vijf triggers leven op de /AA-dictionary van de Catalog, en elk daarvan gaat af voor een gebeurtenis die het hele document raakt, niet één pagina. ISO 32000-1 §12.6.3 (Trigger Events) noemt de sleutels op documentniveau als WC, WS, DS, WP en DP — de letterlijke tweeletternamen geschreven in de /AA-dictionary — voor respectievelijk WillClose, WillSave, DidSave, WillPrint en DidPrint, en PDFlibPas weerspiegelt die verzameling precies in de enumeratie TPDFlibDocumentActionTrigger: datWillClose, datWillSave, datDidSave, datWillPrint, datDidPrint. SetDocumentAction is het enige toegangspunt dat een van de vijf koppelt, en de parameter ActionKind die het neemt, is een van tien PDF_ACTION_BUILDER_*-constanten die worden gedeeld over elke actiebouweraanroep in de bibliotheek, van een gewone URI tot een script tot een bestemmingssprong. Wat een GoTo-, remote-bestand-, ingesloten-bestand-, of Launch-actie eenmaal getriggerd daadwerkelijk doet, is een andere vraag dan waar deze wordt gekoppeld, en dat is het onderwerp van een begeleidend artikel over GoTo-, remote-, ingesloten-, en launch-acties — dit artikel blijft bij de containervraag, Catalog of Pagina, in plaats van de actiesoort-vraag

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;

Hoe verschilt een trigger op paginaniveau van een op documentniveau?

Een trigger op paginaniveau gaat alleen af voor het enkele Page-object waaraan hij is gekoppeld, en PDFlibPas slaat hem op in de eigen /AA-dictionary van die pagina in plaats van die van de Catalog. Er zijn slechts twee paginatriggers, Open en Close, overeenkomend met de sleutels O en C die ISO 32000-1 definieert voor de dictionary met aanvullende acties van een pagina, en PDFlibPas stelt ze bloot als patOpen en patClose via SetPageAction, dat koppelt aan welke pagina ook momenteel is geselecteerd via SelectPage — een detail dat ertoe doet de eerste keer dat u door een document loopt in de verwachting dat één aanroep overal van toepassing is, want dat is nooit het geval. Een van beide soorten trigger koppelen verhoogt ook de minimale PDF-versie van het bestand, en de twee containers vragen om verschillende ondergrenzen: PDFlibPas verhoogt het document naar minstens PDF 1.4 de eerste keer dat het een Catalog-/AA-item schrijft, en naar minstens PDF 1.5 de eerste keer dat het een Page-/AA-item schrijft, ongeacht welke actiesoort erin zit. Dat is een vereiste op containerniveau die bovenop wordt gelegd op wat de actie zelf op zichzelf al nodig heeft, dus een kale URI-actie die op zichzelf slechts PDF 1.1 zou vereisen, trekt het hele bestand toch op naar PDF 1.5 zodra deze is gewikkeld in een paginaopeningstrigger

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

Levenscyclusacties lezen en verwijderen

GetDocumentActionInfo en GetPageActionInfo geven beide een TPDFlibActionInfo-record terug, en het veld Kind komt terug als akNone telkens wanneer die trigger niets gekoppeld heeft, dus controleer Kind voordat u enig ander veld op het record vertrouwt — URI, JavaScript, FileName en de rest zijn alleen betekenisvol voor de ene actiesoort die Kind daadwerkelijk rapporteert, aangezien dezelfde recordvorm wordt hergebruikt over elk actietype dat de bouwer kan produceren. RemoveDocumentAction en RemovePageAction wissen elk één trigger en rapporteren 1 wanneer ze iets vonden om te verwijderen, 0 wanneer de trigger al leeg was; wanneer het verwijderde item het laatste was dat overbleef in de /AA-dictionary, verwijdert PDFlibPas de nu lege /AA zelf in plaats van een bungelende, betekenisloze container achter te laten op de Catalog of de pagina

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;

Staat PDF/A levenscyclusacties überhaupt toe?

Nee. PDF/A-conformiteit wijst de hele container met aanvullende acties af, niet alleen de actiesoorten die risicovol klinken, omdat ISO 19005 het interactieve actiemodel van PDF beperkt op de aanname dat een archiefbestand tientallen jaren later nog steeds hetzelfde moet renderen, zonder afhankelijk te zijn van een scriptengine of een netwerkverbinding die dan misschien niet meer bestaat. SetLifecycleAction, de gedeelde bouwer achter zowel SetDocumentAction als SetPageAction, controleert PDFAMode voordat het ooit naar ActionKind kijkt, dus een URI-actie die gewoon een bedrijfswebpagina opent, of een Named-actie die alleen "ga naar de volgende pagina" betekent, wordt in hetzelfde net gevangen als een gevaarlijke — niets waar een beveiligingsbeoordelaar normaal gesproken een vlag bij zou zetten, toch geblokkeerd, omdat de beperking structureel is in plaats van geval-per-geval. Het praktische gevaar is dat de afwijzing stil is: SetDocumentAction en SetPageAction geven beide 0 terug zonder een uitzondering op te werpen, dus een aanroepplek die de retourwaarde nooit controleert, levert een document uit dat stilzwijgend de trigger mist die het verondersteld werd te dragen

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

Eén asymmetrie is het waard om in gedachten te houden. RemoveDocumentAction en RemovePageAction controleren nooit PDFAMode, dus een bestand laden dat al niet-conforme levenscyclusacties draagt en deze eruit strippen onderweg naar een PDF/A-conforme opslag werkt precies zoals verwacht — alleen het schrijfpad, het koppelen van een nieuwe trigger, wordt door de conformiteitsmodus gepoort

Waar past afdrukken-bij-openen zonder een WillOpen-trigger?

De /AA-dictionary van de Catalog heeft helemaal geen WillOpen-item, doelbewust — /AA op documentniveau in ISO 32000-1 definieert precies vijf sleutels, WillClose, WillSave, DidSave, WillPrint en DidPrint, en niets in die lijst gaat af puur omdat een bestand is geopend. De openingstijd-hook leeft in een apart Catalog-item, /OpenAction, dat PDFlibPas blootstelt via zijn eigen familie aanroepen, waaronder SetOpenActionJavaScript, SetOpenActionDestination en SetOpenActionNamedDestination, waarvan geen enkele de /AA-dictionary of de enumeratie TPDFlibDocumentActionTrigger ook maar aanraakt. De twee mechanismen combineren echter wel, en dat is meestal wat een afdrukken-bij-openen-sjabloon daadwerkelijk nodig heeft: bouw het sjabloon zo dat zijn /OpenAction de afdruktaak start, typisch een JavaScript-actie die het eigen afdrukcommando van de viewer aanroept, en het afdrukken zelf is wat WillPrint en DidPrint iets geeft om tegen te draaien — een tijdstempel gestempeld voordat de pagina's spoolen, een auditvermelding geschreven zodra ze klaar zijn

Hoe betrouwbaar zijn deze triggers over PDF-viewers heen?

Niet elke viewer draait ze, zelfs buiten PDF/A, dus behandel een levenscyclusactie als een verzoek in plaats van een garantie. Acrobat en de meeste volwaardige desktop-lezers voeren de hele verzameling trouw uit, maar een groot deel van de PDF-consumptie in de praktijk raakt een dictionary met aanvullende acties helemaal nooit aan: in browsers ingebedde viewers, de meeste mobiele lezers, en bijna elke server-side rendering- of tekstextractiepipeline negeren /AA ofwel volledig ofwel eerbiedigen slechts een smal deel ervan, met WillPrint en DidPrint die typisch het slechtst presteren aangezien headless conversie geen afdrukbewerking heeft om zich aan te haken. Als een WillClose-submit-form-actie het enige pad is dat formulierdata vastlegt, is het geen betrouwbaar pad — koppel het aan een expliciete verzendknop, en behandel de automatische trigger als een gemak voor de lezers die het toevallig ondersteunen

Document-, pagina- en veldtriggers zijn drie niveaus van dezelfde onderliggende actiedictionary-machinerie, en zodra de container duidelijk is, is de rest het kiezen van de juiste ActionKind-constante en het controleren van de retourcode. Deze levenscyclustriggers, samen met de bredere actiebouwer-API waar dit artikel op ingaat, worden geleverd als onderdeel van de standaard PDFlibPas Delphi-PDF-bibliotheek, met de volledige trigger- en actiesoort-referentie in de productdocumentatie