Artículo técnico

Campos PDF directos: por qué el valor se revierte

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

El síntoma suele leerse como una casa embrujada. La escritura devuelve True, y la lectura siguiente le devuelve el valor viejo. Guarde, reabra, y el valor viejo sigue allí. Nada lanzó, y el PDF se ve perfectamente ordinario en cada visor que usted tiene

El síntoma: una escritura exitosa que no se queda

El patrón de fallo es angosto pero muy repetible: solo aparece en formularios cuyos diccionarios de campos están escritos inline (como objetos directos) en vez de como objetos indirectos numerados. La mayoría de las herramientas de autoría emiten campos indirectos, así que el problema se esconde por meses y luego aparece en archivos de un generador en particular, muchas veces un exportador artesanal que escribe el /AcroForm completo 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 vea ambas, deje de sospechar de su encoding de cadenas y empiece a mirar la propiedad de objetos

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

El valor de un campo PDF se revierte porque dos diccionarios separados ambos 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 autoritativa: 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 conveniencia que causa todo el problema. Cuando un campo tiene exactamente una anotación widget, el diccionario de campo y el diccionario de la anotación widget pueden fusionarse en un único diccionario. En un archivo bien formado ese diccionario fusionado es un objeto indirecto, referenciado desde dos lugares: desde /Fields (o los /Kids de un padre) y desde el arreglo /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 división. Cuando necesita un handle a la anotación, PDFium puede promover el diccionario widget directo en el arreglo /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 /V en la anotación de la página obtiene un éxito genuino a nivel de diccionario. El árbol de campos, que es lo que la capa de formularios y cada lector posterior tratan como fuente de verdad, nunca ve el cambio

Por eso el valor obsoleto sobrevive a un guardado y reapertura: el nuevo valor 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 /Fields vieja
la escritura se serializó fielmente en un objeto que la jerarquía de campos no posee, así que el valor obsoleto sobrevive al guardado y a la reapertura

Cómo PDFium Component restablece 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 campos directos a objetos indirectos, ata cada widget directo a su anotación de página correspondiente, y reescribe /Fields, /Kids, /Parent y /Annots para que cada referencia apunte al mismo objeto indirecto. Solo entonces cambia /V, así que el valor del campo, el widget seleccionado y el documento guardado comparten un dueño

La parte difícil es la identidad. Dos diccionarios directos con contenido idéntico no prueban que son el mismo widget; un formulario puede legítimamente contener dos widgets que se parezcan. El normalizador por tanto 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 a lo más 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énticamente aparte de /Parent y el /AP regenerado por PDFium; cualquier otra diferencia rechaza la escritura
  • Ambos enlaces de padre se verifican 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 faltante se provee solo cuando una propiedad única en el árbol de campos hace la respuesta inequívoca, y las copias directas de padre equivalentes se reatan a su dueño promovido
  • Nombres de campo totalmente calificados duplicados, árboles de campos cíclicos o con múltiples dueños, y campos de firma se rechazan antes de publicar nada
  • Las anotaciones no relacionadas, como enlaces en la misma página, y las entradas /AcroForm no relacionadas se conservan intactas

El normalizador además corre bajo presupuestos duros: 4096 visitas de árbol de campos, páginas o miembros de arreglos, 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 diccionarios inspeccionados, y 4096 unidades UTF-16 por nombre completo de campo con 262144 en todo el formulario. Un archivo hostil o roto golpea un presupuesto y falla con un mensaje de error en vez de convertir un llenado de formulario en una denegación de servicio

La operación entera es transaccional. TPdf.SetFormFieldValues captura una instantánea, construye una actualización incremental que contiene los objetos normalizados y los valores nuevos, carga esa candidata, y solo reemplaza el documento vivo una vez que la candidata 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 PDFium Component para Delphi unifica la propiedad directa de los campos AcroForm: instantánea, verificación de identidad NM, unificación de referencias, escritura del valor y publicación verificada con rutas de rechazo explícitas
los diccionarios de campos directos se promueven y se reatan a un solo dueño indirecto antes de cambiar /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 una discrepancia como fallo incluso cuando la escritura devolvió True. TPdf.SetFormFieldValue dirige un campo por índice en la página actual, recibe un TPdfFormValue tipado y llena un TPdfFormValueWriteReport; TPdf.FormField[Index] relee el valor por la capa de formularios de PDFium, que resuelve el árbol de campos en vez de confiar en el widget

Patrón de escribir y verificar para los campos de formulario PDF en Delphi con PDFium Component: SetFormFieldValue tipado, relectura por FormField, y luego pruebas de guardado y reapertura
un retorno True por sí solo prueba que el diccionario cambió — relea por el árbol de campos y pruebe 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]);

  // Verifique 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;

Sobre 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, como "Direct widgets require an explicit unique NM identity". Ese es el comportamiento que usted quiere: un rechazo claro en vez de una media escritura silenciosa. Una precondición más vale conocer. La escritura tipada es solo de anotaciones y no adquiere foco nativo, así que si un editor nativo de campos sigue enfocado toda la transacción se rechaza antes de tomar la instantánea; llame Pdf.ClearFormFieldFocus deliberadamente primero en vez de reintentar en un bucle

Para varios campos, especialmente entre páginas, use la forma de lote. TPdf.SetFormFieldValues acepta de 1 a 256 peticiones, aplica todas a una candidata, y publica todo o nada. TPdfFormValuesWriteReport.FailedRequest le 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 dirigen 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 checkbox y radio siguen las mismas reglas; la división /V contra /AS para botones está cubierta en valor de campo checkbox contra 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 la verificación es suficientemente barata como para mantenerla en sus propias pruebas 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 diccionarios, lo que trae 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 choice puede legítimamente contener texto UTF-16BE con una marca de orden de bytes FE FF y bytes NUL incrustados. En Delphi Unicode, SysUtils.Trim recibe un string, así que la llamada convierte silenciosamente el AnsiString a UTF-16 usando la página de códigos ANSI activa y convierte el resultado de vuelta. Dependiendo de esa página de códigos, el viaje de ida y vuelta puede reescribir bytes altos, dañar el BOM e incluso perturbar el delimitador que sigue. El enfoque seguro mantiene el lexer sobre bytes: salte el whitespace PDF por posición (ISO 32000-1 §7.2.2 lista NUL, tab, LF, FF, CR y espacio) y corte 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 por bytes: salte el whitespace PDF por posición, luego corte
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 actualización incremental misma. ISO 32000-1 §7.5.5 (Tabla 15) y §14.4 exigen que el /ID del trailer sea un arreglo de dos cadenas de bytes. El helper interno que compone el identificador de la actualización dispersa devuelve solo el contenido de las dos cadenas, sin la clave /ID ni los corchetes del arreglo, y el trailer debe envolverlo explícitamente como /ID [ ... ]. El loader indulgente de PDFium, y una re-guardada completa posterior, ambos esconden un wrapper faltante, así que la verificación confiable es parsear los archivos candidato y guardado con un parser estricto independiente y buscar dos cadenas de 16 bytes. Si usted escribe actualizaciones incrementales por su 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ía hacer en su propio código de formularios Delphi?

  • Releer siempre el valor con FormField[Index] tras una escritura, y en pruebas además guardar, reabrir y releer
  • Tratar False más ErrorMessage como información: un rechazo que nombra NM, un conflicto de padre o un nombre duplicado significa que la propiedad del archivo es genuinamente ambigua
  • Si genera PDFs, emita campos y widgets como objetos indirectos y dele a cada widget un /NM único; eso elimina la ambigüedad en la fuente
  • Usar SetFormFieldValues para llenados multi-campo o entre páginas para que un fallo tardío no pueda dejar media forma actualizada
  • Nunca correr Trim ni otros helpers string de la RTL sobre bytes PDF crudos guardados en AnsiString

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

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