Artículo técnico

Acciones de ciclo de vida en PDF: Catalog /AA vs Page /AA en Delphi

PDFlibPas, la biblioteca PDF nativa para Delphi y C++Builder, le da a un documento PDF dos lugares separados donde colgar comportamiento automático: acciones de ciclo de vida a nivel de documento como WillClose, WillSave, DidSave, WillPrint y DidPrint, almacenadas en el diccionario /AA del Catálogo, y acciones de ciclo de vida a nivel de página —Open y Close— almacenadas en cambio en el propio diccionario /AA de cada objeto Page. Confundir los dos contenedores es la forma más común, con diferencia, en que una acción de ciclo de vida silenciosamente no hace nada

Los casos que motivan esto son ordinarios. Un equipo de finanzas quiere una plantilla de estado de cuenta que estampe una marca de tiempo de impresión y registre quién imprimió en el momento en que realmente empieza la impresión, no cuando el archivo simplemente abre. Un flujo de trabajo con muchos formularios necesita que los valores de campo se envíen automáticamente a un servidor antes de que se le permita al cliente PDF del lector cerrar la ventana, así que una pestaña cerrada nunca significa una edición perdida. Un informe de varias páginas quiere un banner específico de página que aparezca solo mientras esa página está en pantalla. PDF en realidad ofrece un tercer nivel por debajo del documento y la página para este tipo de comportamiento —acciones adjuntas a la propia entrada /A de un campo de formulario individual o un enlace, el tema de un artículo complementario sobre acciones interactivas de formulario y JavaScript— pero este artículo se queda en los dos niveles por encima de él: todo el documento, y una sola página

¿Qué disparadores viven en el /AA del Catálogo de documento?

Cinco disparadores viven en el diccionario /AA del Catálogo, y cada uno de ellos se dispara para un evento que afecta a todo el documento, no a una sola página. ISO 32000-1 §12.6.3 (Trigger Events) lista las claves a nivel de documento como WC, WS, DS, WP y DP —los nombres literales de dos letras escritos en el diccionario /AA— para WillClose, WillSave, DidSave, WillPrint y DidPrint respectivamente, y PDFlibPas refleja ese conjunto exactamente en la enumeración TPDFlibDocumentActionTrigger: datWillClose, datWillSave, datDidSave, datWillPrint, datDidPrint. SetDocumentAction es el único punto de entrada que adjunta cualquiera de los cinco, y el parámetro ActionKind que toma es una de diez constantes PDF_ACTION_BUILDER_* compartidas a través de cada llamada de constructor de acción en la biblioteca, desde una URI simple hasta un script hasta un salto de destino. Qué hace realmente una acción GoTo, de archivo remoto, de archivo incrustado, o Launch una vez disparada es una pregunta distinta de dónde se adjunta, y ese es el tema de un artículo complementario sobre acciones GoTo, remotas, incrustadas, y de lanzamiento —este se queda con la pregunta del contenedor, Catálogo o Página, en lugar de la pregunta del tipo de acción

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;

¿En qué se diferencia un disparador a nivel de página de uno a nivel de documento?

Un disparador a nivel de página se dispara solo para el único objeto Page al que está adjunto, y PDFlibPas lo almacena en el propio diccionario /AA de esa página en lugar de en el del Catálogo. Solo hay dos disparadores de página, Open y Close, correspondientes a las claves O y C que ISO 32000-1 define para el diccionario de acciones adicionales de una página, y PDFlibPas los expone como patOpen y patClose mediante SetPageAction, que se adjunta a cualquiera que sea la página actualmente seleccionada mediante SelectPage —un detalle que importa la primera vez que se recorre un documento en bucle esperando que una llamada aplique en todas partes, porque nunca lo hace. Adjuntar cualquiera de los dos tipos de disparador también eleva la versión mínima de PDF del archivo, y los dos contenedores piden pisos distintos: PDFlibPas eleva el documento a al menos PDF 1.4 la primera vez que escribe una entrada /AA del Catálogo, y a al menos PDF 1.5 la primera vez que escribe una entrada /AA de Page, sin importar qué tipo de acción esté dentro. Ese es un requisito a nivel de contenedor superpuesto sobre lo que la propia acción necesite por sí sola, así que una acción URI simple que solo requeriría PDF 1.1 por sí sola igual arrastra todo el archivo hasta PDF 1.5 en cuanto se envuelve en un disparador de apertura de página

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

Leer y eliminar acciones de ciclo de vida

GetDocumentActionInfo y GetPageActionInfo ambos devuelven un registro TPDFlibActionInfo, y el campo Kind vuelve como akNone cada vez que ese disparador no tiene nada adjunto, así que compruebe Kind antes de confiar en cualquier otro campo del registro —URI, JavaScript, FileName y el resto solo tienen significado para el único tipo de acción que Kind realmente reporta, ya que la misma forma de registro se reutiliza a través de cada tipo de acción que puede producir el constructor. RemoveDocumentAction y RemovePageAction cada uno limpia un solo disparador y reporta 1 cuando encontró algo que eliminar, 0 cuando el disparador ya estaba vacío; cuando la entrada eliminada era la última que quedaba en el diccionario /AA, PDFlibPas elimina el /AA ahora vacío en sí mismo en lugar de dejar atrás un contenedor colgante y sin sentido en el Catálogo o la página

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;

¿Permite PDF/A acciones de ciclo de vida en absoluto?

No. La conformidad PDF/A rechaza todo el contenedor de acciones adicionales, no solo los tipos de acción que suenan riesgosos, porque ISO 19005 restringe el modelo de acción interactiva de PDF bajo la suposición de que un archivo de archivado debe renderizarse de la misma manera décadas después, sin depender de un motor de scripting o una conexión de red que podría no existir para entonces. SetLifecycleAction, el constructor compartido detrás tanto de SetDocumentAction como de SetPageAction, comprueba PDFAMode antes de siquiera mirar ActionKind, así que una acción URI que solo abre una página web de la empresa o una acción Named que solo significa ir a la siguiente página queda atrapada en la misma red que una peligrosa —nada que un revisor de seguridad normalmente marcaría, bloqueado de todos modos, porque la restricción es estructural en lugar de caso por caso. El peligro práctico es que el rechazo es silencioso: tanto SetDocumentAction como SetPageAction devuelven 0 sin lanzar una excepción, así que un sitio de llamada que nunca comprueba el valor de retorno envía un documento silenciosamente sin el disparador que se suponía debía llevar

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

Una asimetría vale la pena tenerla presente. RemoveDocumentAction y RemovePageAction nunca comprueban PDFAMode, así que cargar un archivo que ya lleva acciones de ciclo de vida no conformes y eliminarlas en el camino hacia un guardado conforme con PDF/A funciona exactamente como se esperaría —solo la ruta de escritura, adjuntar un disparador nuevo, está condicionada al modo de conformidad

¿Dónde encaja imprimir-al-abrir sin un disparador WillOpen?

El diccionario /AA del Catálogo no tiene ninguna entrada WillOpen en absoluto, por diseño —el /AA a nivel de documento en ISO 32000-1 define exactamente cinco claves, WillClose, WillSave, DidSave, WillPrint y DidPrint, y nada en esa lista se dispara puramente porque se abrió un archivo. El gancho de tiempo de apertura vive en una entrada de Catálogo separada, /OpenAction, que PDFlibPas expone a través de su propia familia de llamadas, SetOpenActionJavaScript, SetOpenActionDestination y SetOpenActionNamedDestination entre ellas, ninguna de las cuales toca el diccionario /AA ni la enumeración TPDFlibDocumentActionTrigger en absoluto. Los dos mecanismos sí se componen, sin embargo, y eso es normalmente lo que realmente necesita una plantilla de imprimir-al-abrir: construir la plantilla de modo que su /OpenAction inicie el trabajo de impresión, típicamente una acción JavaScript que llama al propio comando de impresión del visor, y la impresión misma es lo que le da a WillPrint y DidPrint algo contra qué ejecutarse —una marca de tiempo estampada antes de que las páginas se pongan en cola, una entrada de auditoría escrita una vez que terminan

¿Cuán confiables son estos disparadores a través de visores de PDF?

No todos los visores los ejecutan, incluso fuera de PDF/A, así que trate una acción de ciclo de vida como una solicitud en lugar de una garantía. Acrobat y la mayoría de los lectores de escritorio completos ejecutan fielmente todo el conjunto, pero una gran parte del consumo real de PDF nunca toca en absoluto un diccionario de acciones adicionales: los visores incrustados en navegador, la mayoría de los lectores móviles, y casi cada pipeline de renderizado o extracción de texto del lado del servidor o bien ignoran /AA por completo o solo honran una fracción estrecha de él, con WillPrint y DidPrint típicamente saliendo peor parados ya que la conversión sin interfaz no tiene ninguna operación de impresión en la que engancharse. Si una acción de envío de formulario en WillClose es la única ruta que captura datos de formulario, no es una ruta confiable —emparéjela con un botón de envío explícito, y trate el disparador automático como una conveniencia para los lectores que resulten soportarlo

Los disparadores de documento, página, y campo son tres niveles de la misma maquinaria subyacente de diccionario de acciones, y una vez que el contenedor está claro, el resto es elegir la constante ActionKind correcta y comprobar el código de retorno. Estos disparadores de ciclo de vida, junto con la API de constructor de acciones más amplia que toca este artículo, se incluyen como parte de la biblioteca PDF PDFlibPas para Delphi estándar, con la referencia completa de disparadores y tipos de acción en la documentación del producto