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
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
/NMno 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
/Parenty el/APregenerado por PDFium; cualquier otra diferencia rechaza la escritura - Ambos enlaces de padre se comprueban por separado, así que un
/Parentcorrecto en la anotación de página no puede enmascarar uno conflictivo en la copia directa original - Un
/Parentausente 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
/AcroFormsin 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
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
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
FalsemásErrorMessagecomo información: un rechazo que nombraNM, 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
SetFormFieldValuespara rellenos multi-campo o entre páginas, de modo que un fallo tardío no pueda dejar media forma actualizada - Jamás corras
Trimni otros helpersstringde la RTL sobre bytes PDF crudos guardados enAnsiString
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