Artículo técnico

Relleno atómico de formularios PDF por lotes en Delphi

PDFium Component para Delphi rellena un AcroForm atómicamente con TPdf.SetFormFieldValues: le pasas de 1 a 256 peticiones tipadas direccionadas 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 jobs que nadie vigila. Una importación nocturna mete filas de CRM en un formulario de siniestro, o un servicio de impresión superpone datos de póliza sobre una plantilla preimpresa. Ejecuta esos campo a 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, te deja con un PDF que es mitad datos viejos y mitad nuevos. Nada crasheó, así que nada alertó a nadie. La API de lotes existe para que un fallo sea una negativa limpia en vez de un documento a medio escribir

¿Por qué el relleno de formulario campo a campo es peligroso para la importación de datos?

El relleno campo a campo es peligroso porque cada escritura se confirma 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 relleno de formularios de PDFium un widget cada vez. Los valores 1 a 17 aterrizan, el 18 lanza, y quien llama ahora tiene que saber exactamente qué valores entraron para deshacerlos

La mayoría de quienes llaman jamás escriben ese código de deshacer, y los que lo escriben suelen equivocarse de forma sutil: un campo compartido ya cambió en todas las páginas donde aparece, y los estados de casillas ya voltearon. Si el guardado corre en un bloque finally, el archivo a medio rellenar va al disco pareciendo completo. En un job de sobreimpresión eso es peor que no haber archivo, porque se imprime y alguien lo firma

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

Cómo aplica SetFormFieldValues un lote en un solo reemplazo

TPdf.SetFormFieldValues monta cada petición contra una única 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 relleno por lotes atómico de cinco etapas en PDFium Component para Delphi, desde el preflight hasta verificar y cambiar con resultados todo o nada
cada petición se monta contra una única instantánea y el documento vivo solo se toca en el canje final verificado

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

  1. Primero corren los checks de preflight: el documento debe estar activo, el recuento de peticiones debe ser 1..256, no puede haber abierta ninguna transacción de page-object o de diario de edición, ningún campo nativo puede tener el foco, y el documento debe conceder permiso de rellenar formularios o de modificación
  2. Cada petición se valida por rango de páginas y presupuesto de entrada, y FailedRequest sigue el índice que se comprueba
  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 de choice y /AS para cada widget que comparte el campo
  4. Los cambios se añaden a la instantánea como una única actualización incremental dispersa en el sentido de ISO 32000-1 §7.5.6, de modo que los objetos no relacionados se arrastran byte a byte
  5. Una instancia verificadora aparte carga primero los bytes candidatos; solo después de abrirse limpiamente 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 diario de edición, y por eso el diario de undo y recuperación tras crashes y una escritura por lotes no pueden solaparse: termina o cancela primero la transacción del diario. Las acciones automáticas de ciclo de vida se suprimen mientras el documento se cambia, así que un script de apertura de documento no puede reescribir campos ni agarrar el foco a mitad del reemplazo

Construir un lote de peticiones tipadas en Delphi

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

Los datos de importación suelen llegar indexados por nombre de campo, así que el primer trabajo es traducir nombres a direcciones de página y widget. FieldIndex cuenta las anotaciones de widget de una página en el mismo orden que FormFieldCount y FormFieldInfo, así que un escaneo sencillo 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;

Observa que AddRequest lanza ante un nombre desconocido en vez de saltárselo. El lote valida direcciones, no tu mapeo, así que un campo de plantilla ausente tiene que fallar aquí o jamás fallará. Con las direcciones resueltas, construir los valores y confirmarlos 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 array 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 tipos tipados se comprueban contra el tipo de campo antes de escribir nada. Un valor de texto dirigido a una casilla, un valor de radio dirigido a una casilla plana, o una escritura a un push button, todo se rechaza. Los índices de choice deben ser válidos, únicos y ascendentes; más de un índice exige la bandera MultiSelect, y el texto libre en un campo choice solo lo acepta 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 read-only se rechazan de plano. Para grupos de radio con la bandera NoToggleToOff, un valor de export vacío, que significa «vaciar el grupo», también se rechaza

¿Por qué escribir un campo compartido dos veces fracasa el lote entero?

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

La razón es el modelo de campos de ISO 32000-1 §12.7.3.1. Un campo terminal posee el valor; sus anotaciones de widget son solo sitios donde ese valor se muestra. Un campo llamado PolicyNumber que aparece en la cabecera 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 vía el widget de la página 1 y otra vez vía el de la página 9 es un campo escrito dos veces, y cualquier regla que elija un ganador esconde un bug en tu mapeo

Así que el lote lo trata como error y nombra la segunda petición en FailedRequest. En la práctica el arreglo está aguas arriba: resuelve cada nombre de campo a exactamente una dirección, como hace FindFieldAddress, y deja que el componente propague el valor a los demás widgets. Lo hace como parte de la escritura, poniendo /AS en cada widget de una casilla compartida o grupo de radio para que el estado de apariencia coincida con el valor del campo, el mismo reparto cubierto en el artículo sobre valores de campo de casillas frente a estados de widget

Un campo terminal posee el valor, widgets de dos páginas lo muestran, y una petición duplicada fracasa 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 reporta entonces Succeeded = False, cero tanto en UpdatedFields como en UpdatedWidgets, el índice base-0 de la petición ofensora 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 más que a una petición: había foco nativo activo, el documento está firmado, el formulario es XFA, o un check de permisos falló. Un valor no negativo apunta a una entrada. Para un desborde del presupuesto agregado apunta a la petición que empujó el lote por encima del límite, que no es necesariamente la que está mal. Lleva un array paralelo de nombres de columnas fuente junto a Requests y podrás convertir el índice 14 directo en «fila 2087, columna ShipDate» en el log de importación

En éxito, UpdatedFields iguala el recuento de peticiones y UpdatedWidgets cuenta cada widget tocado, compartidos incluidos, un sanity check barato contra la plantilla

Precondiciones y límites con los que te toparás

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 pendiente del editor sobrevive intacto. El componente no va a soltar el foco a tus espaldas, porque eso o confirmaría o perdería texto que el usuario no ha terminado. Llama a ClearFormFieldFocus deliberadamente, que termina la sesión del editor como lo haría un clic fuera del campo, y luego reintenta

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

  • De 1 a 256 peticiones por lote; parte los registros mayores en varios lotes y asume que la atomicidad entonces se mantiene por lote
  • Como mucho 262.144 unidades UTF-16 de texto y 4.096 índices de choice por petición
  • Como mucho 1.048.576 unidades UTF-16 de texto y 16.384 índices de choice en todo el lote
  • Los presupuestos habituales de documento para el parser de instantáneas siguen aplicando, incluida una fuente de 64 MiB y 65.536 objetos

Un reemplazo con éxito también invalida los handles nativos, las búsquedas y las selecciones obtenidos antes de la llamada, porque el documento de debajo es nuevo. La activación de página y vista y los cursores lógicos de campos se conservan; cualquier otra cosa que hayas cacheado, como handles de anotaciones o page-objects, hay que volver a pedirlos

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

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

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

Matriz de decisión que compara la importación XFDF con el lote atómico de SetFormFieldValues en PDFium Component para Delphi: direccionamiento, objetivos desconocidos y duplicados, resultado y mejor encaje
XFDF funde 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 relleno; las instantáneas cifradas también están fuera del perfil. Los arrays 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 deniegan 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 revertir

Las apariencias merecen una frase de cuidado. Para widgets de texto y choice cambiados, el lote suelta el /AP rancio en vez de distribuir una apariencia que muestre el valor viejo, y apaga NeedAppearances para que PDFium no reconstruya cada widget no relacionado del formulario. PDFium regenera las apariencias ausentes cuando renderiza. Si el PDF rellenado va a una pipeline de impresión estricta que jamás sintetiza apariencias, renderízalo o aplánalo vía PDFium primero, y prueba esa ruta con tus propias plantillas

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