Artículo técnico

Llenado atómico de formularios PDF en Delphi con PDFium

PDFium Component para Delphi llena un AcroForm atómicamente con TPdf.SetFormFieldValues: usted pasa de 1 a 256 peticiones tipadas dirigidas por página y widget, y el componente o aplica cada valor en un solo reemplazo de documento o no aplica ninguno. Cuando una petición es inválida, TPdfFormValuesWriteReport.FailedRequest nombra la petición que hundió el lote

Esa garantía importa más en los trabajos que nadie vigila. Una importación nocturna mete filas de CRM en un formulario de reclamos, o un servicio de impresión superpone datos de póliza sobre una plantilla preimpresa. Ejecútelos campo por campo y el primer valor malo, un código que no es un export de radio válido, una cadena más larga que el MaxLen del campo, lo deja con un PDF que es medio datos viejos y medio nuevos. Nada hizo crash, así que nada alertó a nadie. La API de lotes existe para que un fallo sea una negativa limpia en vez de un documento medio escrito

¿Por qué el llenado campo por campo es peligroso para la importación de datos?

El llenado campo por campo es peligroso porque cada escritura se compromete por su cuenta, así que un error a mitad del bucle deja un documento que no coincide ni con el registro fuente ni con la plantilla original. El patrón clásico es un bucle de asignaciones FormField[Index], cada una pasando por el entorno nativo de llenado de formularios de PDFium un widget a la vez. Los valores 1 a 17 aterrizan, el 18 lanza, y quien llama ahora tiene que saber exactamente qué valores entraron para poder deshacerlos

La mayoría de quienes llama nunca escriben ese código de deshacer, y quienes lo hacen normalmente lo llevan sutilmente mal: un campo compartido ya cambió en cada página donde aparece, y los estados de checkboxes ya se voltearon. Si el guardado corre en un bloque finally, el archivo medio lleno va al disco pareciendo completo. En un trabajo de sobreimpresión eso es peor que ningún archivo, porque se imprime y alguien lo firma

El otro problema silencioso es el foco. El setter clásico maneja el editor interactivo de PDFium: mata cualquier foco existente, enfoca el widget objetivo, selecciona todo el texto, lo reemplaza y mata el foco otra vez. Eso está bien para un trabajo headless, pero en un visor además termina la sesión de edición que el usuario tenía abierta en otro campo. La ruta de escritura tipada que llegó en PDFium Component 3.231.0 no pasa por el foco nativo de campos para nada, y 3.233.0 la extendió de campos individuales a lotes entre páginas

Cómo SetFormFieldValues aplica un lote en un solo reemplazo

TPdf.SetFormFieldValues prepara cada petición contra una sola instantánea capturada del documento, escribe todos los diccionarios de campos en una pasada, y cambia el resultado terminado con un único reemplazo. El documento original sigue vivo hasta el último paso, así que cualquier fallo antes de ese paso lo deja intacto

La secuencia de llenado de formularios por lotes atómico de cinco etapas en PDFium Component para Delphi, desde el preflight hasta verificar e intercambiar con resultados todo o nada
cada petición se prepara contra una sola instantánea y el documento vivo solo se toca en el intercambio final verificado

La secuencia explica tanto la garantía como el costo:

  1. Las verificaciones de preflight corren primero: el documento debe estar activo, el conteo de peticiones debe ser 1..256, no puede haber transacción de page-object o edit-journal abierta, ningún campo nativo puede tener el foco, y el documento debe conceder permiso de llenar formularios o modificar
  2. Cada petición se valida por rango de página y presupuesto de entrada, y FailedRequest sigue el índice que se está verificando
  3. El componente captura una instantánea de edición, normaliza el AcroForm en esa copia, y aplica cada petición en orden, escribiendo /V, /I para selecciones choice y /AS para cada widget que comparte el campo
  4. Los cambios se agregan a la instantánea como una actualización incremental dispersa en el sentido de ISO 32000-1 §7.5.6, así que los objetos no relacionados se transportan byte por byte
  5. Una instancia verificadora aparte carga primero los bytes candidatos; solo después de que abre limpio el componente descarga el documento vivo y carga el nuevo, recurriendo a la instantánea original si esa carga falla

El paso de reemplazo interno es la misma maquinaria que el componente usa para sus instantáneas del edit-journal, razón por la que el journal de undo y recuperación ante crashes y una escritura por lotes no pueden solaparse: termine o cancele la transacción del journal primero. Las acciones automáticas de ciclo de vida se suprimen mientras el documento se intercambia, así que un script de apertura de documento no puede reescribir campos ni tomar el foco a mitad del reemplazo

Construir un lote de peticiones tipadas en Delphi

Un lote es un arreglo dinámico de registros TPdfFormValueRequest, cada uno con un Page base 1, un FieldIndex local a la página base 0 y un TPdfFormValue tipado. El registro de valor lleva un Kind de fvkText, fvkChoice, fvkCheckBox o fvkRadioButton, más los miembros Text, ChoiceIndices y Checked usados según ese kind

Los datos de importación suelen llegar con claves de nombre de campo, así que el primer trabajo es traducir nombres a direcciones de página y widget. FieldIndex cuenta las anotaciones widget de una página en el mismo orden que FormFieldCount y FormFieldInfo, así que un escaneo simple funciona. El helper toma el primer widget que encuentra para un nombre, lo que importa para campos compartidos, como explica la siguiente sección

type
  TFieldAddress = record
    Page, FieldIndex: Integer;
  end;

function FindFieldAddress(Pdf: TPdf; const FieldName: WString;
  out Address: TFieldAddress): Boolean;
var
  P, I: Integer;
begin
  Result := False;
  for P := 1 to Pdf.PageCount do
  begin
    Pdf.PageNumber := P;
    for I := 0 to Pdf.FormFieldCount - 1 do
      if Pdf.FormFieldInfo[I].Name = FieldName then
      begin
        Address.Page := P;
        Address.FieldIndex := I;
        Exit(True);      // solo el primer widget: una petición por campo
      end;
  end;
end;

procedure AddRequest(var Requests: TPdfFormValueRequests;
  const FieldName: WString; Pdf: TPdf; const Value: TPdfFormValue);
var
  Address: TFieldAddress;
  N: Integer;
begin
  if not FindFieldAddress(Pdf, FieldName, Address) then
    raise Exception.CreateFmt('Template has no field "%s"', [FieldName]);
  N := Length(Requests);
  SetLength(Requests, N + 1);
  Requests[N].Page := Address.Page;
  Requests[N].FieldIndex := Address.FieldIndex;
  Requests[N].Value := Value;
end;

Note que AddRequest lanza ante un nombre desconocido en vez de saltárselo. El lote valida direcciones, no su mapeo, así que un campo de plantilla faltante tiene que fallar aquí o nunca va a fallar. Con las direcciones resueltas, construir los valores y comprometerlos es corto

var
  Pdf: TPdf;
  Requests: TPdfFormValueRequests;
  Report: TPdfFormValuesWriteReport;
  V: TPdfFormValue;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'claim-template.pdf';
    Pdf.Active := True;

    V := Default(TPdfFormValue);
    V.Kind := fvkText;
    V.Text := 'Maria Lopez';
    AddRequest(Requests, 'Claimant.Name', Pdf, V);

    V := Default(TPdfFormValue);
    V.Kind := fvkCheckBox;
    V.Checked := True;
    AddRequest(Requests, 'Consent', Pdf, V);

    V := Default(TPdfFormValue);
    V.Kind := fvkRadioButton;
    V.Text := 'Express';                // valor de export del botón a seleccionar
    AddRequest(Requests, 'Shipping', Pdf, V);

    V := Default(TPdfFormValue);
    V.Kind := fvkChoice;
    SetLength(V.ChoiceIndices, 1);
    V.ChoiceIndices[0] := 2;            // índice base 0 dentro del arreglo Opt del campo
    AddRequest(Requests, 'Region', Pdf, V);

    if Pdf.SetFormFieldValues(Requests, Report) then
      Pdf.SaveAs('claim-filled.pdf')
    else
      LogBatchFailure(Requests, Report);
  finally
    Pdf.Free;
  end;
end;

Los kinds tipados se verifican contra el tipo del campo antes de escribir algo. Un valor de texto dirigido a un checkbox, un valor de radio dirigido a un checkbox simple, o una escritura a un push button, todos se rechazan. Los índices choice deben ser válidos, únicos y ascendentes; más de un índice requiere la bandera MultiSelect, y el texto libre en un campo choice lo acepta solo un combo box editable. Un valor de texto más largo que el MaxLen del campo se rechaza en vez de truncarse, y los campos de solo lectura se rechazan sin más. Para grupos de radio con la bandera NoToggleToOff, un valor de export vacío, que significa "limpiar el grupo", también se rechaza

¿Por qué escribir un campo compartido dos veces falla todo el lote?

TPdf.SetFormFieldValues rechaza dos peticiones que resuelven al mismo campo terminal porque ambas escribirían un único valor, y el componente se niega a dejar que una petición pise silenciosamente a otra. La verificación corre sobre el dueño del campo, no sobre el widget, así que dos widgets distintos de un campo en dos páginas distintas igual cuentan como duplicado

La razón es el modelo de campos de ISO 32000-1 §12.7.3.1. Un campo terminal es dueño del valor; sus anotaciones widget son solo lugares donde ese valor se muestra. Un campo llamado PolicyNumber que aparece en el encabezado de cada página es un campo con varios widgets, y cada uno de esos widgets muestra el mismo /V. Un grupo de radio funciona igual: cada botón es un widget, el grupo es el campo. Escribir PolicyNumber por el widget de la página 1 y otra vez por el de la página 9 es un campo escrito dos veces, y cualquier regla que elija un ganador esconde un bug en su mapeo

Así que el lote lo trata como error y nombra la segunda petición en FailedRequest. En la práctica el arreglo es aguas arriba: resuelva cada nombre de campo a exactamente una dirección, como hace FindFieldAddress, y deje que el componente propague el valor a los otros widgets. Lo hace como parte de la escritura, poniendo /AS en cada widget de un checkbox compartido o grupo de radio para que el estado de apariencia concuerde con el valor del campo, la misma división cubierta en el artículo sobre valores de campo checkbox contra estados de widget

Un campo terminal es dueño del valor, widgets de dos páginas lo muestran, y una petición duplicada falla el lote en PDFium Component para Delphi
una petición por campo terminal — el componente escribe /V una vez y pone /AS en cada widget que lo comparte

Leer el informe de fallo

Un lote fallido devuelve False, no lanza, y deja el documento original y cada valor de campo exactamente como estaban. TPdfFormValuesWriteReport entonces reporta Succeeded = False, cero tanto para UpdatedFields como para UpdatedWidgets, el índice base 0 de la petición infractora en FailedRequest, y una razón legible por humanos en ErrorMessage

procedure LogBatchFailure(const Requests: TPdfFormValueRequests;
  const Report: TPdfFormValuesWriteReport);
var
  R: Integer;
begin
  R := Report.FailedRequest;
  if R >= 0 then
    Log(Format('Request %d (page %d, widget %d) rejected: %s',
      [R, Requests[R].Page, Requests[R].FieldIndex, Report.ErrorMessage]))
  else
    Log('Batch rejected as a whole: ' + Report.ErrorMessage);
end;

Un FailedRequest de -1 significa que el rechazo pertenece a la transacción y no a una petición: el foco nativo estaba activo, el documento está firmado, el formulario es XFA, o una verificación de permisos falló. Un valor no negativo apunta a una entrada. Para un exceso del presupuesto agregado apunta a la petición que empujó al lote sobre el límite, que no necesariamente es la petición equivocada. Mantenga un arreglo paralelo de nombres de columnas fuente junto a Requests y puede convertir el índice 14 directo en "fila 2087, columna ShipDate" en el log de importación

En éxito, UpdatedFields iguala el conteo de peticiones y UpdatedWidgets cuenta cada widget tocado, compartidos incluidos, una verificación sanitaria barata contra la plantilla

Precondiciones y límites que va a encontrar

El rechazo más común en una aplicación visor es el foco nativo: si un usuario está tecleando en un campo, SetFormFieldValues se niega antes incluso de capturar la instantánea, y el buffer del editor pendiente sobrevive intacto. El componente no va a soltar el foco a sus espaldas, porque eso comprometería o perdería texto que el usuario no terminó. Llame ClearFormFieldFocus deliberadamente, que termina la sesión del editor como lo haría hacer clic fuera del campo, y luego reintente

Los presupuestos son fijos y están documentados, así que puede dimensionar las importaciones contra ellos:

  • De 1 a 256 peticiones por lote; parta registros mayores en varios lotes y acepte que la atomicidad entonces se mantiene por lote
  • Como máximo 262,144 unidades UTF-16 de texto y 4,096 índices choice por petición
  • Como máximo 1,048,576 unidades UTF-16 de texto y 16,384 índices choice en todo el lote
  • Los presupuestos habituales de documento para el parser de instantáneas siguen aplicando, incluidos una fuente de 64 MiB y 65,536 objetos

Un reemplazo exitoso también invalida los handles nativos, búsquedas y selecciones obtenidos antes de la llamada, porque el documento de abajo es nuevo. La activación de páginas y vistas y los cursores lógicos de campos se conservan; cualquier otra cosa que tenga en caché, como handles de anotaciones o page-objects, hay que volver a traerla

¿En qué se diferencia de la importación XFDF?

TPdf.ImportXFDF y TPdf.SetFormFieldValues ambos meten datos en campos, pero la importación XFDF es una fusión tolerante mientras el lote es una transacción estricta. La importación XFDF empareja valores con campos por nombre, se salta los nombres que no encuentra, ignora duplicados, y devuelve cuántas entradas aplicó. Eso es exactamente correcto para fusionar los comentarios de un revisor, donde un resultado parcial sigue siendo útil

El lote hace lo contrario en cada punto. Dirige widgets explícitamente, rechaza objetivos desconocidos o mal tipados, trata duplicados como errores, y no tiene resultado parcial. Use XFDF cuando los datos son un archivo de intercambio de otra herramienta; use SetFormFieldValues cuando su propio código es dueño del mapeo y un registro medio aplicado sería un defecto

Matriz de decisión que compara la importación XFDF con el lote atómico SetFormFieldValues en PDFium Component para Delphi: dirigimiento, objetivos desconocidos y duplicados, resultado y mejor ajuste
XFDF fusiona archivos de intercambio con tolerancia mientras el lote rechaza nombres desconocidos, duplicados y resultados parciales

Lo que la escritura por lotes no cubre

El lote es un perfil AcroForm acotado, y dice que no en vez de adivinar fuera de ese perfil. Los formularios XFA se rechazan y necesitan su editor dedicado. Los documentos firmados se rechazan, porque reescribir diccionarios de campos bajo una firma existente es un flujo de firmas, no una operación de llenado; las instantáneas cifradas también quedan fuera del perfil. Los arreglos de export de botones malformados o ambiguos se rechazan, y los valores de export de radio que coinciden con más de un botón se niegan salvo que el grupo ponga RadiosInUnison. La transacción además normaliza los diccionarios de campos directos antes de escribir, el caso detrás de los valores de campo que parecen revertirse

Las apariencias merecen una frase de cuidado. Para widgets de texto y choice cambiados, el lote descarta el /AP obsoleto en vez de distribuir una apariencia que muestra el valor viejo, y apaga NeedAppearances para que PDFium no reconstruya cada widget no relacionado del formulario. PDFium regenera las apariencias faltantes cuando renderiza. Si el PDF lleno va a un pipeline de impresión estricto que nunca sintetiza apariencias, renderícelo o achátelo por PDFium primero, y pruebe esa ruta con sus propias plantillas

SetFormFieldValues y los registros de valores tipados se distribuyen en el PDFium Component para Delphi y Lazarus, donde la página del producto lista la API completa de llenado de formularios y AcroForm junto a controles de renderizado, edición y visor