Artículo técnico

Por qué se revierte el valor de un campo PDF en Delphi

El valor de un campo de formulario PDF «revierte» cuando el campo está guardado como diccionario directo: PDFium promueve la copia del array /Annots de la página a un objeto indirecto, mientras que /Fields conserva una segunda copia directa. PDFium Component para Delphi maneja las escrituras tipadas de campos unificando ambas referencias en un único dueño indirecto antes de escribir

El síntoma suele leerse como una casa encantada. La escritura devuelve True, y la lectura inmediatamente siguiente te entrega el valor viejo. Guardas, reabres, y el valor viejo sigue ahí. Nada lanzó, y el PDF se ve perfectamente corriente en todos los visores que tienes

El síntoma: una escritura con éxito que no se queda

El patrón de fallo es estrecho pero muy repetible: solo asoma en formularios cuyos diccionarios de campo están escritos inline (como objetos directos) en vez de como objetos indirectos numerados. La mayoría de herramientas de autoría emiten campos indirectos, así que el problema se esconde durante meses y luego aparece en archivos de un generador concreto, a menudo un exportador artesanal que escribe el /AcroForm entero inline

Dos observaciones juntas apuntan a la causa: la escritura reporta éxito, y una lectura nativa del campo a través de la capa de formularios devuelve el valor anterior. Cuando veas ambas, deja de sospechar de tu encoding de cadenas y empieza a mirar la propiedad de los objetos

¿Por qué se revierte el valor de un campo de formulario PDF tras una escritura con éxito?

El valor de un campo de formulario PDF se revierte porque dos diccionarios separados reclaman ser el mismo campo, y la escritura actualizó la copia que el árbol de campos no consulta. El valor de un campo vive en su entrada /V, e ISO 32000-1 §12.7.3.1 define dónde esa entrada es autoridad: en el diccionario de campo alcanzado desde /AcroForm /Fields, bajando por /Kids hasta el campo terminal, con las entradas heredables resueltas desde /Parent

La misma cláusula permite una comodidad que causa todo el lío. Cuando un campo tiene exactamente una anotación de widget, el diccionario de campo y el diccionario de la anotación de widget pueden fundirse en un solo diccionario. En un archivo bien formado ese diccionario fusionado es un objeto indirecto, referenciado desde dos sitios: desde /Fields (o los /Kids de un padre) y desde el array /Annots de la página. Dos referencias, un objeto, un /V

Dos copias de un diccionario

ISO 32000-1 §7.3.10 es explícito en que solo un objeto indirecto tiene una identidad compartible. Un diccionario directo no tiene número de objeto, así que cuando un generador escribe el diccionario fusionado campo/widget inline tanto en /Fields como en /Annots, el archivo no contiene un objeto referenciado dos veces. Contiene dos diccionarios de aspecto igual que casualmente tienen las mismas claves y valores

PDFium tolera esto, y al hacerlo vuelve permanente la separación. Cuando necesita un handle a la anotación, PDFium puede promover el diccionario de widget directo del array /Annots de la página a un objeto indirecto, pero la copia directa equivalente se queda donde estaba dentro de /AcroForm /Fields (o dentro de los /Kids del padre). Desde ese momento el documento tiene dos dueños para un campo lógico. Un writer que edita el /V de la anotación de página obtiene un éxito genuino a nivel de diccionario. El árbol de campos, que es lo que la capa de formularios y todo lector posterior tratan como fuente de verdad, jamás ve el cambio

Por eso el valor rancio sobrevive a un guardado y reapertura: el valor nuevo se serializó fielmente en un objeto que la jerarquía de campos no posee

PDFium Component en Delphi: dos diccionarios directos reclaman un campo, la escritura golpea la anotación promovida y el árbol de campos sigue leyendo la copia vieja de /Fields
la escritura se serializó fielmente en un objeto que la jerarquía de campos no posee, así que el valor rancio sobrevive al guardado y la reapertura

Cómo restaura PDFium Component un único dueño de campo

PDFium Component normaliza la propiedad directa de campos antes de cada escritura tipada: promueve los diccionarios /AcroForm y de campo directos a objetos indirectos, ata cada widget directo a su anotación de página correspondiente, y reescribe /Fields, /Kids, /Parent y /Annots de modo que cada referencia apunte al mismo objeto indirecto. Solo entonces cambia el /V, así que el valor del campo, el widget seleccionado y el documento guardado comparten un solo dueño

Lo difícil es la identidad. Dos diccionarios directos con contenido idéntico no prueban que sean el mismo widget; un formulario puede contener legítimamente dos widgets que se parezcan. El normalizador por eso se niega a adivinar, y exige un nombre de anotación /NM explícito y único (ISO 32000-1 §12.5.2) en cada widget directo. Las reglas que aplica la ruta de escritura son estrictas:

  • Un widget directo necesita un /NM no vacío de como mucho 1024 unidades UTF-16, y exactamente una anotación de página puede llevar el mismo nombre
  • La copia directa y la anotación de página deben serializarse idénticas salvo /Parent y el /AP regenerado por PDFium; cualquier otra diferencia rechaza la escritura
  • Ambos enlaces de padre se comprueban por separado, así que un /Parent correcto en la anotación de página no puede enmascarar uno conflictivo en la copia directa original
  • Un /Parent ausente se suministra solo cuando una propiedad única del árbol de campos hace la respuesta inequívoca, y las copias de padres directas equivalentes se reatan a su dueño promovido
  • Los nombres de campo completamente cualificados duplicados, los árboles de campos cíclicos o con dueños múltiples y los campos de firma se rechazan antes de publicar nada
  • Las anotaciones sin relación, como los enlaces de la misma página, y las entradas /AcroForm sin relación se conservan intactas

El normalizador corre además bajo presupuestos duros: 4096 visitas del árbol de campos, páginas o miembros de array, profundidad de anidamiento 64, 256 objetos indirectos nuevos, 1 MiB por diccionario promovido, 4 MiB de bytes promovidos en total, 128 MiB de bytes de diccionario inspeccionados, y 4096 unidades UTF-16 por nombre completo de campo con 262144 en todo el formulario. Un archivo hostil o roto toca un presupuesto y falla con un mensaje de error en vez de convertir un relleno de formularios en una denegación de servicio

Toda la operación es transaccional. TPdf.SetFormFieldValues captura una instantánea, construye una actualización incremental con los objetos normalizados y los valores nuevos, carga ese candidato, y solo reemplaza el documento vivo una vez que el candidato verifica. Si la normalización rechaza el archivo, o una petición posterior del lote falla, el documento original, sus valores y su cursor lógico de campos quedan exactamente como estaban

Cómo unifica PDFium Component para Delphi la propiedad directa de campos AcroForm: instantánea, check de identidad NM, unificación de referencias, escritura del valor y publicación verificada con rutas de rechazo explícitas
los diccionarios de campo directos se promueven y reatan a un único dueño indirecto antes de cambiar el /V, y la propiedad ambigua se rechaza limpiamente

Escribir y verificar un valor de campo directo en Delphi

El patrón seguro es escribir, luego releer a través de la capa nativa de formularios, y tratar un desajuste como fallo incluso cuando la escritura devolvió True. TPdf.SetFormFieldValue direcciona un campo por índice en la página actual, toma un TPdfFormValue tipado y rellena un TPdfFormValueWriteReport; TPdf.FormField[Index] relee el valor a través de la capa de formularios de PDFium, que resuelve el árbol de campos en vez de fiarse del widget

Patrón de escribir y verificar para campos de formulario PDF en Delphi con PDFium Component: SetFormFieldValue tipado, relectura vía FormField, y luego checks de guardado y reapertura
un retorno True por sí solo prueba que el diccionario cambió — relee a través del árbol de campos y prueba un guardado y reapertura
uses
  System.SysUtils, PDFium;

procedure WriteTextField(Pdf: TPdf; PageNo, FieldIndex: Integer;
  const NewText: WString);
var
  Value: TPdfFormValue;
  Report: TPdfFormValueWriteReport;
begin
  Pdf.PageNumber := PageNo;          // SetFormFieldValue apunta a la página actual
  Value := Default(TPdfFormValue);
  Value.Kind := fvkText;
  Value.Text := NewText;

  if not Pdf.SetFormFieldValue(FieldIndex, Value, Report) then
    raise Exception.CreateFmt('Field %d on page %d rejected: %s',
      [FieldIndex, PageNo, Report.ErrorMessage]);

  // Verifica contra el árbol de campos, no contra nuestra propia variable
  if Pdf.FormField[FieldIndex] <> NewText then
    raise Exception.CreateFmt('Field "%s" did not keep the new value',
      [Pdf.FormFieldInfo[FieldIndex].Name]);
end;

En un archivo con propiedad irresoluble, por ejemplo un widget directo sin /NM, SetFormFieldValue devuelve False, Report.UpdatedWidgets es cero y Report.ErrorMessage nombra el problema, tipo «Direct widgets require an explicit unique NM identity». Ese es el comportamiento que quieres: un rechazo claro en vez de una media escritura silenciosa. Una precondición más conviene conocer. La escritura tipada es solo de anotaciones y no adquiere foco nativo, así que si un editor nativo de campos sigue enfocado la transacción entera se rechaza antes de tomar la instantánea; llama a Pdf.ClearFormFieldFocus deliberadamente primero en vez de reintentar en bucle

Para varios campos, sobre todo entre páginas, usa la forma de lote. TPdf.SetFormFieldValues acepta de 1 a 256 peticiones, aplica todas a un candidato, y publica todo o nada. TPdfFormValuesWriteReport.FailedRequest te dice qué petición rompió el lote

procedure FillInvoiceHeader(Pdf: TPdf);
var
  Requests: TPdfFormValueRequests;
  Batch: TPdfFormValuesWriteReport;
begin
  SetLength(Requests, 2);
  Requests[0] := Default(TPdfFormValueRequest);
  Requests[0].Page := 1;
  Requests[0].FieldIndex := 0;
  Requests[0].Value.Kind := fvkText;
  Requests[0].Value.Text := 'ACME Tooling Inc.';

  Requests[1] := Default(TPdfFormValueRequest);
  Requests[1].Page := 2;
  Requests[1].FieldIndex := 0;
  Requests[1].Value.Kind := fvkText;
  Requests[1].Value.Text := '2026-10-27';

  if not Pdf.SetFormFieldValues(Requests, Batch) then
    raise Exception.CreateFmt('Request %d failed: %s',
      [Batch.FailedRequest, Batch.ErrorMessage]);
  // Batch.UpdatedFields = 2; Batch.UpdatedWidgets cuenta cada widget sincronizado
end;

Dos peticiones que direccionan el mismo campo terminal se rechazan como dueño duplicado en vez de dejar que la última escritura gane, y el documento fuente se preserva. Los valores de casillas y radios siguen las mismas reglas; el reparto /V contra /AS para botones queda cubierto en valor de campo de casilla frente a estado de widget en PDFium

Probar que el valor sobrevive al guardado y reapertura

Una relectura en la misma sesión prueba que el documento vivo es coherente. Un guardado y reapertura prueba que el archivo lo es, y el check es barato como para mantenerlo en tus propios tests de integración

procedure SaveAndReopenCheck(Pdf: TPdf; const Target: string;
  PageNo, FieldIndex: Integer; const Expected: WString);
var
  Reopened: TPdf;
begin
  if not Pdf.SaveAs(Target, saNoIncremental, pvUnknown) then
    raise Exception.Create('Save failed');

  Reopened := TPdf.Create(nil);
  try
    Reopened.FileName := Target;
    Reopened.Active := True;
    Reopened.PageNumber := PageNo;
    if Reopened.FormField[FieldIndex] <> Expected then
      raise Exception.Create('Saved file lost the field value');
  finally
    Reopened.Free;
  end;
end;

Dos trampas más a nivel de bytes: Trim y el /ID del trailer

Normalizar campos directos significa releer bytes crudos de diccionario, lo que saca a colación dos trampas menores relevantes para cualquier código Delphi que toque sintaxis PDF

La primera es SysUtils.Trim sobre bytes PDF crudos guardados en un AnsiString. Una cadena literal como una opción de choice puede contener legítimamente texto UTF-16BE con una marca de orden de bytes FE FF y bytes NUL incrustados. En Delphi Unicode, SysUtils.Trim toma un string, así que la llamada convierte en silencio el AnsiString a UTF-16 usando la code page ANSI activa y convierte el resultado de vuelta. Según esa code page, la ida y vuelta puede reescribir bytes altos, dañar el BOM e incluso disturbar el delimitador que sigue. La aproximación segura mantiene el lexer en bytes: salta el whitespace PDF por posición (ISO 32000-1 §7.2.2 lista NUL, tab, LF, FF, CR y espacio) y corta con Copy, decodificando solo cuando un valor se sabe que es una cadena de texto PDF

// Arriesgado sobre bytes PDF crudos: AnsiString -> string -> AnsiString (W1057/W1058)
Token := Trim(Token);

// Seguro a nivel de bytes: salta el whitespace PDF por posición, y luego corta
P := 1;
while (P <= Length(Token)) and (Token[P] in [#0, #9, #10, #12, #13, ' ']) do
  Inc(P);
Token := Copy(Token, P, MaxInt);

La segunda trampa vive en la propia actualización incremental. ISO 32000-1 §7.5.5 (Tabla 15) y §14.4 exigen que el /ID del trailer sea un array de dos cadenas de bytes. El helper interno que compone el identificador de actualización dispersa devuelve solo los contenidos de las dos cadenas, sin la clave /ID ni los corchetes del array, y el trailer debe envolverlo explícitamente como /ID [ ... ]. El loader permisivo de PDFium, y un reguardado completo posterior, esconden ambos un envoltorio ausente, así que el check fiable es parsear los archivos candidato y guardado con un parser estricto independiente y buscar dos cadenas de 16 bytes. Si escribes actualizaciones incrementales por tu cuenta, la misma trampa aplica; el artículo de actualización incremental para PDFs cifrados cubre la cadena del trailer con más profundidad

¿Qué deberías hacer en tu propio código de formularios Delphi?

  • Relee siempre el valor con FormField[Index] tras una escritura, y en tests guarda además, reabre y relee
  • Trata False más ErrorMessage como información: un rechazo que nombra NM, un conflicto de padres o un nombre duplicado significa que la propiedad del archivo es genuinamente ambigua
  • Si generas PDFs, emite campos y widgets como objetos indirectos y dale a cada widget un /NM único; eso elimina la ambigüedad en la fuente
  • Usa SetFormFieldValues para rellenos multi-campo o entre páginas, de modo que un fallo tardío no pueda dejar media forma actualizada
  • Jamás corras Trim ni otros helpers string de la RTL sobre bytes PDF crudos guardados en AnsiString

Sé claro también con las fronteras. Los documentos firmados, los formularios XFA, las instantáneas cifradas y los archivos cuya propiedad de widgets no se puede resolver de forma única se quedan fuera del perfil declarado y se rechazan en vez de forzarse. Si tu flujo mueve campos entre documentos, las preguntas de propiedad se complican aún más; el injerto de campos AcroForm entre documentos PDF trata ese caso

El PDFium Component trae esta escritura tipada y transaccional de formularios a aplicaciones Delphi, C++Builder y Lazarus junto al renderizado, la extracción de texto y la edición de documentos. El historial de versiones, las licencias y las descargas están en la página del producto PDFium Component para Delphi