Artículo técnico

Rellenar campos de formulario en un PDF cargado con Delphi

HotPDF Delphi Component rellena un campo AcroForm existente de un PDF cargado a través de THotPDF.SetFormFieldValue, dirigido o bien por índice de campo basado en cero o bien por nombre de campo completamente cualificado. Escribir la nueva entrada /V es la parte fácil; lo que hace fiable la llamada en formularios del mundo real es que el mismo método mantiene además consistentes tres piezas de estado que son invisibles hasta que se rompen: la identidad decodificada del campo, para que un nombre no ASCII se pueda encontrar siquiera, el estado de apariencia /AS en los widgets de checkbox y radio, y el array de índices de selección /I en los campos de elección. El appearance stream visible es un paso aparte y explícito a través de EnsureLoadedFieldAppearanceStream

El escenario es el más mundano: un cliente te manda su propio formulario, una declaración de la renta, un parte de seguro, un pedido que alguien montó en Acrobat hace años, y tu aplicación Delphi tiene que rellenarlo desde una base de datos y devolver un archivo que abra bien en todas partes. No tienes ningún control sobre cómo se creó el formulario. Los nombres de campo pueden estar en UTF-16, los valores de exportación de un checkbox pueden ser 2 en lugar de Yes, y los combo boxes pueden usar pares de opción [export display]. Cada uno de esos detalles tiene una regla en la ISO 32000-1, y cada regla es algo que SetFormFieldValue ahora resuelve por ti. Este artículo va de qué hace, por qué, y dónde se detiene. Para el problema hermano de crear campos que todavía no existen, mira añadir campos AcroForm a un PDF cargado en Delphi

¿Por qué SetFormFieldValue no encuentra un campo con nombre no ASCII?

Antes de la v2.752.1 la respuesta era la codificación: el campo vivía en el archivo bajo un nombre UTF-16BE hexadecimal, y la caché de nombres guardaba la grafía hex en lugar del texto. La ISO 32000-1 §12.7.3.1 define el nombre parcial de campo /T como una cadena de texto, y la §7.9.2.2 dice que una cadena de texto puede ser UTF-16BE con un byte order mark FE FF delante. Las herramientas de autor serializan esos nombres como cadenas hex según la §7.3.4.3 casi por costumbre, así que un campo llamado Straße llega como <FEFF005300740072006100DF0065>. Dentro de HotPDF, THPDFStringObject.Value guarda el texto hexadecimal crudo siempre que IsHexadecimal esté puesto, que es exactamente lo que quieres para un ida y vuelta sin pérdida del diccionario original y exactamente lo que no quieres como clave de búsqueda. HPDFLoadedFormTextName separa las dos preocupaciones. Cuando se construye la caché de relaciones, cada valor /T pasa por ella: si el objeto string es hexadecimal, HPDFHexToBytes restaura la secuencia de bytes; si los bytes empiezan por FE FF y tienen longitud par, el payload se decodifica como UTF-16BE y se recodifica como UTF-8; el resultado se une después al nombre de su padre con un punto para formar el nombre completamente cualificado que describe la §12.7.3.1, así que un hijo llamado City bajo un padre llamado Address queda registrado como Address.City. La clave de caché se normaliza a minúsculas, lo que hace que SetFormFieldValue('address.city', ...) también funcione; eso es una comodidad más allá del estándar, ya que la especificación trata los nombres como sensibles a mayúsculas. Y lo crucial: solo cambia la clave de caché. El objeto /T del diccionario de campo conserva su codificación hexadecimal, así que guardar el documento no reescribe la identidad de un campo que te limitaste a rellenar

Cómo resuelve HotPDF los nombres AcroForm no ASCII: HPDFHexToBytes restaura el payload UTF-16BE detrás de una cadena /T hexadecimal, el byte order mark FE FF se decodifica y se recodifica como UTF-8, y el nombre cualificado se une a su padre, así que tanto Applicant.FullName como un campo llamado Straße acaban en la caché de búsqueda
Solo cambia la clave de caché: el diccionario de campo conserva su codificación hexadecimal, las búsquedas se normalizan a minúsculas como comodidad más allá del estándar, y guardar el documento nunca reescribe la identidad de un campo que te limitaste a rellenar
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('claim-form.pdf') <= 0 then Exit;

    // Los nombres cualificados se decodifican de cadenas /T UTF-16BE y
    // se unen con puntos, así que los nombres anidados y no ASCII resuelven
    Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');
    Pdf.SetFormFieldValue('Applicant.Straße', 'Hauptstraße 12');

    // Los valores que no son Latin-1 viajan como hex UTF-16BE con prefijo FEFF
    // y se escriben como una cadena hexadecimal de PDF
    Pdf.SetFormFieldValue('Applicant.City', 'FEFF004D00FC006E006300680065006E');

    Pdf.SaveLoadedDocument('claim-form-filled.pdf');
  finally
    Pdf.Free;
  end;
end;

¿Qué escribe exactamente SetFormFieldValue?

Las dos sobrecargas ejecutan los mismos cinco pasos: localizar el diccionario del campo, escribir /V a través de HPDFSetDictFormValue, reconciliar los índices de selección de elección, marcar el diccionario como sucio, reconciliar los estados de apariencia de botones, y por último registrar el índice del campo a través de NoteLoadedFormFieldDirty. Ese último paso importa si el formulario lleva scripts de cálculo, porque el conjunto de sucios es lo que consume la sobrecarga sin parámetros de RecalculateLoadedFormFieldsIncremental para reejecutar solo los cálculos que leen de forma transitiva un campo modificado. HPDFSetDictFormValue es cuidadosa con el tipo de objeto que reemplaza. Si el /V existente es un objeto name, que es lo que usan los campos checkbox y radio para su valor de exportación, el valor nuevo se escribe como name, nunca como string, porque los nombres PDF son solo ASCII por construcción. En caso contrario escribe un objeto string e inspecciona el valor que le pasaste: una cadena que empieza por FEFF, tiene longitud par y se compone solo de dígitos hex se trata como la forma de cable UTF-16BE de la §7.9.2.2 y se guarda con IsHexadecimal puesto, así que serializa como <FEFF...> en lugar de como un literal (FEFF...). Ese es el mecanismo en el que se apoya la línea de City de arriba; cualquier otra cadena se guarda como string literal con los bytes que le diste, así que para texto latino corriente pasas texto corriente

¿Por qué un checkbox conserva su marca antigua tras cambiar el valor?

Porque en un campo de botón el valor por sí solo no decide lo que se dibuja. La ISO 32000-1 §12.7.4.2.3 especifica que un widget de checkbox lleva un estado de apariencia /AS que nombra qué stream de /AP /N se muestra en ese momento, y los visores pintan desde /AS, no desde /V. Si cambias /V a Yes pero dejas /AS en Off, el archivo es internamente contradictorio, y el flattening se encargará alegremente de hornear en la página la apariencia obsoleta de no marcado mientras los datos del formulario dicen marcado. ReconcileLoadedButtonAppearanceStates existe para cerrar esa brecha: para un campo cuyo /FT es Btn, visita el diccionario del campo en sí y cada entrada de su array /Kids, lee el nombre del estado on de /AP /N, y reescribe /AS a ese nombre cuando casa con el valor del campo o a Off cuando no

Por qué un checkbox de HotPDF conserva su marca antigua cuando solo cambia /V: los visores pintan desde el estado de apariencia /AS hacia /AP /N, así que ReconcileLoadedButtonAppearanceStates visita el campo y cada hijo, lee el nombre del estado on como la primera clave distinta de Off, y reescribe /AS si casa o a Off en caso contrario
Los grupos de radio comparan cada hijo contra el valor del padre que InheritedButtonValue recupera recorriendo la cadena /Parent, así que poner el grupo a un valor de exportación enciende exactamente ese widget y apaga todos sus hermanos

Dos detalles de formularios reales dieron forma al fix de la v2.752.3. Primero, a un diccionario de apariencia normal se le permite contener solo el estado on; la §12.7.4.2.3 llama Off a la apariencia de apagado, pero las herramientas de autor omiten con frecuencia su stream y dejan que el visor no dibuje nada. El código anterior se rendía cuando el diccionario tenía menos de dos entradas, así que esos checkboxes de un solo estado conservaban su marca antigua en silencio. La comprobación ahora es simplemente que el diccionario no esté vacío, y el nombre del estado on se toma como la primera clave que no sea Off. Segundo, el nombre del estado on es el que eligió el autor. Los formularios reales usan 2, Yes, On o una palabra localizada, así que la comparación es contra la clave real, sin distinguir mayúsculas, nunca contra un Yes hardcodeado. Los radio buttons añaden una arruga más, descrita en la §12.7.4.2.4: la selección vive en el /V del campo padre, mientras que los hijos individuales son dueños de los widgets y normalmente no tienen /V propio. El helper anidado InheritedButtonValue recorre por tanto la cadena /Parent hacia arriba, hasta 64 niveles, hasta encontrar un valor no vacío, así que cada hijo se compara contra el valor del grupo al que pertenece. Poner el padre al valor de exportación de un hijo enciende exactamente ese hijo y apaga todos sus hermanos

// Checkbox: el valor de exportación debe casar con la clave del estado on en /AP /N
// (a menudo 'Yes', pero los formularios reales usan '2', 'On' o cualquier otra cosa)
Pdf.SetFormFieldValue('Consent', 'Yes');

// Grupo de radio: /V se escribe en el padre; cada widget hijo recibe
// /AS puesto a su propio nombre de exportación o a Off
Pdf.SetFormFieldValue('PaymentMethod', 'Card');

// Desmarcar un checkbox: cualquier valor que no case con ningún estado on da /AS Off
Pdf.SetFormFieldValue('Newsletter', 'Off');

Campos de elección: mantener /I en sintonía con /V

En un combo box o un list box, /V no es el único sitio donde se registra una selección. La Tabla 231 de la §12.7.4.4 define /I como un array de índices basados en cero dentro de /Opt que identifica los elementos seleccionados, y un visor que encuentre /I apuntando a la opción 0 mientras /V nombra la opción 3 puede resaltar la fila equivocada. Desde la v2.754.1, HPDFReconcileChoiceSelection corre dentro de cada llamada a SetFormFieldValue y, cuando el /FT heredado es Ch, reconstruye /I a partir del valor nuevo. El orden de las operaciones es deliberado. La entrada local /I se borra primero, sin tocar su contenido: si el array viejo era un objeto indirecto compartido con otro campo, mutarlo in situ corrompería la selección del otro campo, así que la rutina suelta la referencia y crea un array directo nuevo. Luego resuelve /Opt a través de la cadena /Parent, ya que las opciones de elección pueden heredarse, y escanea las entradas. Una opción de cadena pelada se compara directamente; un par [export display] se compara por su elemento de exportación, y un par con menos de dos elementos se salta. Los dos lados pasan por HPDFLoadedFormTextName, así que una opción hex UTF-16 casa con un valor hex UTF-16 sin que tengas que escribirlos idénticos. En la primera coincidencia se escribe un /I de un elemento y el escaneo se detiene; un valor escalar reemplaza siempre cualquier multiselección anterior, independientemente del flag MultiSelect

Cómo mantiene HotPDF consistente un campo de elección: HPDFReconcileChoiceSelection borra el array /I local antes de tocarlo, resuelve /Opt a través de la cadena /Parent, compara la mitad de exportación de cada opción con HPDFLoadedFormTextName, escribe un /I de un elemento en la primera coincidencia y no escribe nada cuando un valor de combo editable no tiene índice
Una opción de cadena pelada se compara directamente y un par export display por su elemento de exportación, mientras que un valor fuera de /Opt correctamente no deja índice — un /I obsoleto apuntando a la fila equivocada sería peor que ninguno

Cuando no casa nada, no se escribe /I en absoluto. Ese es el resultado correcto para un combo box editable, donde la §12.7.4.4 permite al usuario teclear un valor fuera de la lista de opciones; ese valor no tiene índice, y un índice obsoleto sería peor que ninguno. También es lo que obtienes si le pasas una etiqueta de visualización en lugar de un valor de exportación a una lista de opciones emparejadas, así que cuando un combo box se niegue a mostrar tu selección, comprueba qué mitad del par le has dado

// /Opt es [[US United States] [CA Canada] [MX Mexico]]:
// casar por el valor de exportación, y /I pasa a ser [1]
Pdf.SetFormFieldValue('Country', 'CA');

// Combo editable con un valor fuera de /Opt: se escribe /V,
// se elimina /I y no se fabrica ningún índice
Pdf.SetFormFieldValue('Title', 'Principal Engineer');

Valor y apariencia son dos operaciones distintas

SetFormFieldValue nunca toca el appearance stream de un campo de texto o de elección. Después de la llamada, /V contiene el texto nuevo mientras /AP /N sigue pintando el viejo, y cuál de los dos muestra un visor depende de si el diccionario AcroForm lleva /NeedAppearances true según la §12.7.3.3 y de si el visor lo respeta. Si necesitas que el archivo renderice el valor nuevo en todos los lectores, incluidos flatteners y generadores de miniaturas que ignoran el flag, llama a EnsureLoadedFieldAppearanceStream con el índice del campo. Construye un Form XObject a partir de la cadena /DA heredada, el quadding /Q, la disposición comb de /MaxLen y el valor, resuelve la fuente nombrada a través de los recursos /DR del AcroForm para que una fuente Type0 conserve su propia fuente descendiente en lugar de degradarse a Helvetica, y devuelve True cuando al menos un widget recibió un stream. La sobrecarga por nombre de SetFormFieldValue no te devuelve ningún índice, así que consigue uno con GetFormField, que devuelve un THPDFLoadedFormField que es tuyo y que tienes que liberar. La suite de regresión del cambio de la v2.752.1 es explícita sobre esta separación: asigna un valor, llama a EnsureLoadedFieldAppearanceStream, y luego renderiza la página y comprueba que los píxeles dentro del rectángulo del widget cambiaron mientras los de fuera no. Verificar que /V cambió no demuestra nada sobre lo que verá un usuario

var
  Field: THPDFLoadedFormField;
begin
  Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');
  Field := Pdf.GetFormField('Applicant.FullName');
  try
    // Pintar el valor nuevo en /AP para que los visores que ignoran
    // /NeedAppearances lo sigan mostrando
    if not Pdf.EnsureLoadedFieldAppearanceStream(Field.Index) then
      raise Exception.Create('No widget rectangle to paint into');
  finally
    Field.Free;
  end;
  Pdf.SaveLoadedDocument('claim-form-filled.pdf');
end;

Límites que conviene conocer antes de construir sobre esto

ReconcileLoadedButtonAppearanceStates comprueba el /FT local del diccionario al que dirigiste, así que actúa sobre el padre de un radio o sobre un checkbox que lleve su propio /FT; un widget hijo dirigido por sí solo, con el /FT solo en su padre, no se reconcilia por ese camino. HPDFReconcileChoiceSelection maneja un único valor escalar y escribe como mucho un índice; los list boxes de multiselección con varios elementos elegidos quedan fuera de lo que modela SetFormFieldValue. Ninguna de las dos rutinas valida el valor que pasas contra /Opt ni contra las claves de estado on, así que una errata produce un checkbox Off o un combo sin índice en lugar de una excepción. Y GetFormFieldValue devuelve el texto /V guardado tal como está en el diccionario, que para un valor codificado en hex significa la grafía hexadecimal, no el texto decodificado

Una vez que los valores están dentro y las apariencias pintadas, los dos siguientes pasos naturales se sientan a cada lado de esta operación. Intercambiar datos de campo con sistemas externos en bloque, en lugar de una llamada a SetFormFieldValue cada vez, es lo que cubre la importación y exportación XFDF en Delphi. Y cuando el formulario relleno es definitivo y ya no debe ser editable, el flattening de campos AcroForm y XFA en Delphi hornea exactamente los estados /AS y los appearance streams descritos aquí en contenido de página estático, y por eso dejarlos consistentes antes del flattening no es opcional

La API de edición de formularios cargados de este artículo, incluidos SetFormFieldValue, EnsureLoadedFieldAppearanceStream y el grafo de recálculo incremental, viene como parte del HotPDF Delphi Component para Delphi y C++Builder