Technical Article

Formularios PDF interactivos en Delphi: acciones y JavaScript

Un campo de formulario PDF por sí solo es solo una casilla que contiene un valor. Lo que hace que un formulario se comporte como una pequeña aplicación es la acción que tiene asociada: un clic que oculta una sección, recupera valores guardados desde un archivo, salta a la última página o ejecuta un script que suma una columna. Nada de eso reside en el campo. Reside en un diccionario de acciones, e ISO 32000-1 organiza toda la familia en la sección §12.6. Este artículo recorre las acciones a las que un programa de Delphi recurre con más frecuencia y muestra cómo PDFlibPas vincula cada una a un campo o enlace.

El modelo mental que conviene mantener es que un campo y una acción son objetos separados unidos por una referencia. Una anotación de widget o una anotación de enlace lleva una acción en su entrada /A. La acción identifica al campo sobre el que opera por su título, no por su índice, por lo que el título que le asigne a un campo es el identificador que usará cualquier acción posterior para encontrarlo. Una vez clara esa separación, la API deja de parecer un conjunto desordenado de llamadas y comienza a verse como un único patrón aplicado a cuatro tipos de verbos.

Acciones nombradas: navegación sin número de página

Las acciones más simples no llevan ningún parámetro. ISO 32000-1 §12.6.4.11, Tabla 194, define las acciones nombradas: el visor interpreta un nombre simbólico en tiempo de ejecución en lugar de seguir un destino almacenado. Hay cuatro nombres admitidos universalmente, y son exactamente los que un lector espera de una barra de herramientas: NextPage, PrevPage, FirstPage y LastPage. Debido a que el destino es relativo a la página que el visor está mostrando actualmente, un botón Siguiente construido de esta manera funciona en todas las páginas sin necesidad de calcular un destino.

En PDFlibPas, una acción nombrada se adjunta a un rectángulo de zona interactiva en la página actual. El cuarto y quinto argumento entero seleccionan el verbo y la apariencia.

// NamedActionType: 0 = NextPage, 1 = PrevPage, 2 = FirstPage, 3 = LastPage
// Options bit 0 (value 1) draws a border around the hotspot
Pdf.AddLinkToNamedAction(500, 560, 60, 18, 0, 1);   // Next
Pdf.AddLinkToNamedAction(40, 560, 60, 18, 1, 1);    // Previous
Pdf.AddLinkToNamedAction(110, 560, 60, 18, 3, 1);   // jump to last page

No hay ningún destino que mantener sincronizado, lo cual es la clave. Una acción nombrada sobrevive a la inserción y eliminación de páginas porque nunca especifica una página en primer lugar. Contraste esto con un enlace directo de tipo go-to, que almacena un índice de página de destino que debe volver a numerar en cuanto el documento crezca.

La acción Hide y el problema del arreglo

La acción Hide, ISO 32000-1 §12.6.4.10, Tabla 196, alterna la visibilidad de uno o más campos. Es la forma más limpia de construir un comportamiento de mostrar y ocultar sin usar scripts, y es lo que se necesita para un enlace de Mostrar detalles o para dos paneles mutuamente excluyentes donde revelar uno oculta el otro. La acción lleva un destino en su entrada /T y un booleano /H que determina la dirección: ocultar si es true, mostrar si es false.

La sutileza radica completamente en cómo se codifica ese destino, y es el tipo de detalle que genera un formulario que funciona en su máquina de desarrollo pero falla en la del cliente. Cuando la acción nombra a un solo campo, /T se escribe como una única cadena de texto. Cuando nombra a varios, /T se escribe como un arreglo de cadenas de texto. Los visores más antiguos no tratan un arreglo de un solo elemento de la misma manera que tratan una cadena simple, por lo que la codificación debe ramificarse según la cantidad: un nombre único debe emitirse como una cadena, no como un arreglo de longitud uno, para que la mayor variedad de lectores lo procese correctamente. PDFlibPas toma esa decisión por usted. Solo pasa los nombres de los campos separados por comas, puntos y comas, o saltos de línea, y el escritor emite una cadena única para un solo nombre y un arreglo para dos o más.

// HideFlag non-zero hides the listed fields (/H true); zero shows them.
// One name -> /T is a text string. Two or more -> /T is an array of strings.
Pdf.AddLinkToHideField(40, 700, 90, 18, 'ShippingAddress', 1, 1);
Pdf.AddLinkToHideField(140, 700, 90, 18,
  'ShippingName,ShippingAddress,ShippingZip', 1, 1);

Debido a que la acción no hace referencia a ningún recurso externo, sigue siendo compatible con PDF/A. Los nombres que se pasan son títulos de campo completamente calificados, razón por la cual un campo secundario dentro de un grupo debe direccionarse a través de su ruta completa separada por puntos en lugar de su nombre simple final.

ImportData: prellenado desde FDF

Mientras que la acción Hide reorganiza lo que ya está en la página, la acción de importación de datos trae valores desde fuera de ella. ISO 32000-1 §12.6.4.8, Tabla 198, la define como una acción que puebla el AcroForm desde un archivo de formato de datos de formularios (FDF) en el disco. Esta es la acción detrás de los controles Cargar datos de ejemplo o Restablecer valores predeterminados, donde un archivo FDF se distribuye junto al PDF y contiene los valores de campo canónicos. La llamada es similar a las demás, recibiendo el rectángulo de la zona interactiva, la ruta al FDF y una máscara de bits de apariencia: Pdf.AddLinkToImportData(40, 660, 120, 18, 'defaults.fdf', 1). No es necesario que el archivo exista cuando se crea el PDF, pero debe estar presente cuando el usuario hace clic, y cualquier barra invertida en la ruta se reescribe automáticamente a la forma de barra diagonal canónica de PDF.

Vale la pena mencionar claramente una restricción porque suele causar sorpresas. Una acción de importación de datos apunta a un archivo externo, por lo que no está permitida en PDF/A. Cuando el documento está en modo PDF/A, la llamada devuelve cero y no agrega nada en lugar de producir un archivo que falle la validación. Si su flujo de trabajo tiene como objetivo la salida para archivo histórico, el prellenado debe realizarse en el momento de la generación escribiendo los valores de los campos directamente, sin delegarlos a un clic.

JavaScript: paquetes globales y scripts por acción

Para lógica que va más allá de mostrar, ocultar e importar, la familia de acciones recurre al JavaScript a nivel de documento. Hay dos lugares distintos donde puede residir un script, y la diferencia es importante. Un paquete de JavaScript a nivel de documento se almacena una vez para todo el archivo y se ejecuta cuando se abre el documento, lo que lo convierte en el lugar adecuado para definiciones de funciones y estado compartido. Un script por acción se adjunta a un enlace o campo específico y se ejecuta solo cuando ese objeto se activa, lo que lo hace ideal para la línea única que llama a una función que el paquete ya definió.

PDFlibPas expone ambos. AddGlobalJavaScript almacena un paquete nombrado a nivel de documento; reutilizar un nombre reemplaza cualquier contenido guardado con ese nombre. AddLinkToJavaScript adjunta un script a una zona interactiva para que un clic lo ejecute.

// Document-level package: define a reusable function once.
Pdf.AddGlobalJavaScript('Totals',
  'function recalcTotal() {' +
  '  var net = this.getField(\"Net\").value;' +
  '  var tax = this.getField(\"Tax\").value;' +
  '  this.getField(\"Gross\").value = Number(net) + Number(tax);' +
  '}');

// Per-action script on a link: just call the shared function.
Pdf.AddLinkToJavaScript(40, 620, 100, 18, 'recalcTotal();', 1);

Mantener la función en el paquete global y la llamada en el enlace no es una preferencia de estilo. Evita duplicar el mismo código en cada control que lo necesite, y significa que un visor con el scripting deshabilitado simplemente no hará nada al hacer clic, en lugar de fallar debido a un bloque en línea mal formado. También mantiene pequeñas las entradas por acción, lo que facilita la lectura del archivo cuando lo inspeccione más tarde.

Campos, campos secundarios y congelar el resultado

Las acciones necesitan campos sobre los cuales actuar, por lo que ayuda ver cómo nace un campo. NewFormField crea un campo en la página actual y devuelve su índice; el tipo entero selecciona la clase, donde 1 es Text, 2 es Pushbutton, 3 es Checkbox, 4 es Radiobutton, 5 es Choice, 6 es Signature y 7 es un Parent que posee elementos secundarios pero no dibuja nada por sí mismo. El título que pase no puede contener un punto, porque el punto es el separador en los nombres completamente calificados que las acciones usan para dirigirse a los campos secundarios.

Los grupos de radio y los formularios jerárquicos se construyen asignando elementos secundarios a un campo principal. NewChildFormField agrega un campo secundario bajo un principal nombrado, y para los casos de radio y selección, AddFormFieldSub agrega las opciones individuales y devuelve un índice temporal que se usa para posicionar cada una. Cuando la fase interactiva termina y desea congelar un campo para que su apariencia actual se convierta en contenido permanente de la página, FlattenFormField dibuja el campo sobre la página y lo elimina del formulario. Después de una operación de aplanado, los índices de los campos posteriores disminuyen en uno, lo cual es el único detalle a recordar si aplana varios campos en un bucle.

var
  Pdf: TPDFlib;
  FldShip: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    Pdf.SetOrigin(1);          // top-left origin
    Pdf.SetPageSize('A4');
    Pdf.NewPage;

    // A text field the Hide action will target by its title.
    FldShip := Pdf.NewFormField('ShippingAddress', 1);
    Pdf.SetFormFieldBounds(FldShip, 40, 120, 240, 20);
    Pdf.SetFormFieldValue(FldShip, '');

    // Wire a Hide link and a navigation link to this page.
    Pdf.DrawText(40, 110, 'Toggle shipping block:');
    Pdf.AddLinkToHideField(220, 100, 70, 16, 'ShippingAddress', 1, 1);
    Pdf.AddLinkToNamedAction(500, 800, 60, 18, 3, 1);  // Last page

    // A document-level script available to every event in the file.
    Pdf.AddGlobalJavaScript('OnOpen',
      'app.alert(\"Form ready\", 3);');

    // Freeze the field if the output should no longer be editable.
    // Pdf.FlattenFormField(FldShip);

    if Pdf.SaveToFile('form_actions.pdf') <> 1 then
      raise Exception.Create('Save failed');
  finally
    Pdf.Free;
  end;
end;

La llamada a aplanar está comentada a propósito. Déjela fuera y el documento se distribuirá como un formulario activo cuyas acciones se ejecutan en el lector. Habilítela y el campo se renderizará como marcas estáticas, lo cual es lo que se desea cuando el formulario se ha completado y el resultado debe enviarse como un registro fijo. El mismo campo, el mismo código, dos documentos muy diferentes dependiendo de si lo congela.

Elegir el verbo correcto

Las cuatro acciones se dividen claramente por lo que tocan. Una acción nombrada mueve la vista y no necesita ningún campo. Una acción Hide cambia la visibilidad y necesita títulos de campo, encargándose el sistema de la codificación de cadena versus arreglo. Una acción de importación de datos accede a un archivo en disco y, por lo tanto, está prohibida en PDF/A. Una acción de JavaScript ejecuta lógica arbitraria y es mejor dividirla entre un paquete global de funciones y pequeñas llamadas por acción. Utilice la opción más sencilla que resuelva el problema: una acción Hide es más portátil que un script que establece una bandera oculta, y una acción nombrada es más duradera que un destino de página guardado porque no hay que mantener ningún número.

A partir de aquí, dos temas relacionados completan el panorama. Si el formulario es parte de un documento accesible, el árbol de estructura que recorren los lectores de pantalla se cubre en nuestro artículo sobre PDF etiquetado y estructura de accesibilidad. Cuando el formulario completado debe bloquearse y firmarse, el flujo de trabajo se describe en la guía práctica del banco de trabajo de firma y conformidad. Los tres se basan en el mismo motor, que se distribuye como la biblioteca PDF para Delphi junto con las API de creación, formularios y firmas cubiertas en otras secciones de este blog.