PDFium Component guarda los valores editados de formularios XFA exactamente, entre guardado y reapertura, cuando corre el runtime Windows V8 pdfium.v8.dll que viene en v3.125.2 o posterior. Los runtimes anteriores agregaban line feeds a los valores de los fields, recortaban los emoji a un carácter BMP sin relación, se saltaban en silencio los guardados de XFA de stream único y podían tragarse una escritura final fallida. Un síntoma de reapertura no es un defecto de la librería para nada: un formulario dinámico cuyo subform raíz carece de restoreState="auto" reconstruye su layout desde el template
Los reportes de bug de esto se parecían todos. Un cliente llena un formulario de reclamo XFA en un visor Delphi, guarda, reabre, y algo está levemente raro. Un cuadro de comentarios vacío ahora contiene una línea en blanco, y tras un segundo guardado contiene dos. Un nombre tecleado con un emoji vuelve con un glifo de private-use. Nadie recibe un error, que es lo que vuelve caros estos bugs: la deriva asoma semanas después en el export de otra persona
¿Qué sale mal cuando un formulario XFA se guarda y se reabre?
Cuatro defectos separados en la ruta de guardado XFA nativa causaban deriva de valores, y cada uno se escondía detrás de un guardado de apariencia exitosa. Dos salían de la serialización, uno del layout de almacenamiento de stream único, y uno del writer PDF en sí. La tabla mapea cada síntoma a su causa y a la versión donde PDFium Component lo corrigió
| Síntoma tras reabrir | Causa | Corregido en |
|---|---|---|
| Un field vacío contiene un line feed; los valores ganan un newline por guardado | Ambos writers XFA insertaban newlines de layout después de start tags | v3.125.2, pdfium.v8.dll |
| U+1F642 vuelve como U+F642, o el emoji desaparece del form packet | Truncamiento de wchar_t de 16 bits al decodificar; filtrado de surrogates en el serializador del formulario | v3.125.2, pdfium.v8.dll |
| Las ediciones en un documento XFA de stream único simplemente se pierden | El guardado nativo rechazaba el layout de stream, pero el valor de retorno se ignoraba | v3.125.2; comentarios e instrucciones de procesamiento conservados desde v3.126.0 |
| Archivo truncado aunque el guardado reportó éxito | La escritura final buferada fallaba después de que el writer ya había retornado éxito | runtime V8 v3.125.2; pdfium.dll ordinario v3.125.3 |
| Formulario dinámico de tres páginas reabre como dos páginas | El subform raíz no pide restoreState="auto" | Autoría del formulario, no un defecto de la librería |
Escritos anteriores concluyeron que las ediciones de fields XFA no se podían persistir con PDFium para nada, lo cual era exacto para los runtimes de entonces. El runtime V8 más nuevo guarda los valores XFA de forma nativa, así que una edición hecha en el formulario vivo llega al datasets packet guardado sin cirugía de paquetes de su parte
¿Qué runtime de PDFium guarda los valores XFA?
La fidelidad del guardado XFA depende de la DLL nativa, no del wrapper Delphi, así que el primer chequeo es qué runtime cargó realmente su proceso. PDFium Component trae dos builds Windows por arquitectura: el pdfium.dll ordinario, compilado sin V8 ni XFA, y pdfium.v8.dll, que carga el engine JavaScript y el runtime de formularios XFA. Solo pdfium.v8.dll puede correr un formulario XFA, así que cada corrección XFA descrita aquí vive ahí, empezando por las librerías V8 Win32 y Win64 reconstruidas en v3.125.2
La corrección de la escritura final es código genérico del writer PDF, así que también importa para documentos ordinarios. v3.125.3 reconstruyó las librerías del pdfium.dll ordinario para llevar esa misma reparación. El código fuente compartido no es prueba de comportamiento compartido: hasta que el binario se reconstruya, la DLL vieja conserva el bug viejo
Una segunda trampa estaba en el loader. Antes de v3.125.2, poner EnableV8Engine en True hacía que el binding escogiera el nombre por defecto pdfium.v8.dll e ignorara una ruta completa en LibraryName. Una aplicación que apuntara a un runtime recién desplegado podía seguir cargando una copia más vieja desde otra carpeta. Desde v3.125.2, un LibraryName que contiene un directorio selecciona exactamente ese archivo en cualquiera de los dos modos de engine, y una ruta faltante falla en vez de caer a otra librería incluida
uses
System.SysUtils, PDFium;
procedure SelectXfaRuntime;
begin
// Un directorio en LibraryName fija este archivo exacto (v3.125.2 y posteriores);
// si el archivo falta, la carga lanza en vez de hacer fallback
{$IFDEF WIN64}
PDFium.LibraryName := ExtractFilePath(ParamStr(0)) + 'DLLs\Win64\pdfium.v8.dll';
{$ELSE}
PDFium.LibraryName := ExtractFilePath(ParamStr(0)) + 'DLLs\Win32\pdfium.v8.dll';
{$ENDIF}
PDFium.EnableV8Engine := True;
PDFium.LoadLibrary; // fallar al arranque, no en el primer guardado
end;
Después de abrir un documento, TPdf.XFA le dice que el archivo contiene XFA y TPdf.XfaRuntimeAvailable le dice que la DLL cargada puede de verdad ejecutarlo. Si además necesita distinguir formularios estáticos de dinámicos, TPdf.FormType devuelve ftXfaFull o ftXfaForeground; el artículo sobre detectar formularios XFA y extraer paquetes XFA en Delphi cubre ese sondeo en detalle
¿Por qué los fields XFA guardados ganan line feeds extra?
Los fields XFA guardados ganaban line feeds porque ambos writers XFA nativos, el writer genérico de elementos XML y el serializador del form packet, hacían pretty-print de su salida con un newline después de los start tags. En la mayoría del XML ese espacio en blanco es cosmético. En datos XFA no lo es: cuando el datasets packet se parsea de nuevo, el texto entre <Comments> y </Comments> es el valor del field, newline incluido. Un field vacío por lo tanto reabría conteniendo un solo LF, y cada ciclo de guardado y reapertura siguiente podía agregar otro
La reparación obvia, recortar los valores al cargar, estaría mal. Los usuarios teclean espacios al inicio, espacios al final y texto multilínea deliberado en los fields XFA, y un bloque de dirección o un código de ancho fijo debe sobrevivir byte por byte. La corrección de v3.125.2 por lo tanto elimina solo el espacio en blanco que el propio serializador sintetizó alrededor de los tags. Los valores del usuario, los text nodes existentes y las secciones CDATA pasan intactos, así que " indented" sigue indentado y un field intencionalmente vacío sigue vacío
¿Por qué un emoji vuelve como un carácter distinto?
Un emoji volvía mal porque el wchar_t de Windows es de 16 bits, y dos rutas de decodificación guardaban un valor escalar Unicode completo en un solo wchar_t. El decoder del stream UTF-8 y el parser de referencias de caracteres numéricas como 🙂 ambos lo hacían. U+1F642, la carita sonriente ligeramente, no cabe en 16 bits, así que los bits altos se caían y aparecía U+F642 en cambio: un code point en el Private Use Area que la mayoría de las fuentes renderizan como un cuadro o nada
El serializador del formulario tenía el problema opuesto. Filtraba caracteres de a un wchar_t por vez, veía dos code units surrogate inválidas en aislamiento, y descartaba ambas, así que el emoji desaparecía del form packet por completo. En v3.125.2 el decoder consume cada valor escalar por completo y emite un surrogate pair apropiado. Cuando solo queda un slot de salida, conserva el surrogate bajo pendiente y no reporta fin de stream mientras esa unidad siga buferada. Una secuencia UTF-8 partida entre bloques de lectura se arrastra a la siguiente lectura en vez de descartarse. El exportador del formulario ahora mantiene juntos los surrogate pairs válidos, y las referencias de caracteres numéricas también producen pares correctos
Los datos de prueba Latin-1 jamás muestran nada de esto, así que toda prueba de round-trip XFA necesita al menos un carácter del plano suplementario
XFA de stream único y fallos de guardado que nadie veía
Un documento XFA de stream único perdía sus ediciones porque el helper nativo de guardado rechazaba ese layout de almacenamiento y quien lo llamaba ignoraba el fallo. ISO 32000-1 §12.7.8 permite que la entrada /XFA del diccionario de formulario interactivo sea tanto un array de nombres de paquetes y streams como un stream único que contenga el documento XDP completo. Los arrays de paquetes son el caso común, pero los streams únicos son perfectamente legales, y el guardado del PDF se completaba como si nada hubiera pasado mientras los datos del formulario se quedaban en sus valores viejos
Desde v3.125.2, el runtime V8 maneja el subconjunto de stream único soportado. Primero exporta ambos paquetes vivos, datasets y form, a un área de staging y los valida, y solo entonces reemplaza los paquetes correspondientes en el XDP original. Los demás paquetes y las declaraciones de namespace de la raíz se conservan. Si el staging falla, el stream XFA persistente jamás se toca y el documento conserva su marca de modificación
Los comentarios XML y las instrucciones de procesamiento necesitaron cuidado extra porque el DOM XML interno los descarta. En v3.125.2 su presencia hacía que el guardado fallara de plano en vez de perder contenido en silencio. v3.126.0 los conserva: antes de parsear, cada comentario o instrucción de procesamiento se cambia por un marcador construido a partir de un prefijo que no ocurre en ningún lugar del texto original. Después de que los paquetes vivos se reemplazan, cada marcador debe aparecer exactamente una vez antes de restaurar el token original y escribir el stream. Los tokens fuera de los paquetes reemplazados por lo tanto conservan su texto y su orden, incluidos los tokens del prolog, el template y otros paquetes
Algunos inputs todavía se rechazan a propósito, y cada rechazo es un fallo de guardado explícito:
- Comentarios o instrucciones de procesamiento dentro de los paquetes vivos
datasetsoform, ya que sus posiciones originales no se pueden mapear a contenido recién exportado - Declaraciones DTD y firmas XMLDSig, ya que reescribir el XDP no puede mantener válida una firma XML
- Codificación UTF-8 o UTF-16 inválida, tags incompletos, referencias de caracteres inválidas, entidades desconocidas e instrucciones de procesamiento mal formadas, que se rechazan en vez de repararse en silencio
La salida de stream único es UTF-8 y preserva el modelo de contenido XML, no el layout de bytes original ni la declaración de codificación
El último defecto estaba por debajo del XFA. El writer nativo de archivos bufera la salida en bloques de 32 KB y hacía flush del bloque parcial final solo en su destructor, después de que el writer del documento ya había reportado éxito. Un disco lleno o un error de I/O en ese último bloque era invisible para quien llamaba. Desde v3.125.2 en el runtime V8 y v3.125.3 en el runtime ordinario, ese flush final es parte del resultado del guardado, y la marca de modificación XFA se limpia solo después de un éxito real. Del lado Delphi, TPdf.SaveAs(const FileName: string; Option: TSaveOption = saNone; PdfVersion: TPdfVersion = pvUnknown): Boolean escribe a un archivo temporal junto al destino y lo mueve a su lugar solo cuando el guardado retorna True, así que un guardado fallido deja el archivo anterior intacto
¿Por qué un formulario XFA dinámico reabre con menos páginas?
Un formulario XFA dinámico reabre con menos páginas cuando su subform raíz no declara restoreState="auto", y eso es una decisión de autoría del formulario y no un defecto de PDFium Component. En XFA 3.3, restoreState en el subform raíz queda en manual por defecto. Bajo manual, el procesador XFA restaura solo un estado limitado desde el form packet guardado y deja el resto a los scripts del autor. Los valores de fields guardados y los conteos de instancias de subforms repetitivos igual vuelven, pero las propiedades geométricas fijadas en runtime no
El caso que expuso esto fue un formulario de tres páginas cuyo script crecía un subform a h="450pt". El form packet guardado contenía la altura nueva, los valores y los conteos de instancias. Al reabrir, sin embargo, el layout se reconstruía desde las alturas del template y el formulario reflujaba a dos páginas. El runtime tenía razón: el template jamás había pedido restauración automática. Declararlo en el subform raíz arregla la reapertura:
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form1" layout="tb" restoreState="auto">
<pageSet>
<pageArea name="Page1">
<contentArea x="0.25in" y="0.25in" w="8in" h="10.5in"/>
<medium stock="letter"/>
</pageArea>
</pageSet>
<subform name="Details" layout="tb" w="7.5in">
<!-- fields; los scripts pueden cambiar h o agregar instancias en runtime -->
</subform>
</subform>
</template>
Si el template no es suyo, no le ponga parches desde el visor: un formulario que depende del modo manual espera que sus propios scripts reconstruyan el estado. La repaginación viva mientras el usuario teclea es un tema aparte, cubierto en cómo rastrea PDFium Component los conteos de páginas XFA dinámico y los fields movidos
¿Cómo verifica un guardado XFA en Delphi?
El único chequeo confiable de un guardado XFA es reabrir el archivo guardado en una instancia TPdf fresca y leer los datos guardados de vuelta. TPdf.GetXfaDatasets devuelve el datasets packet tal como está guardado en el documento, no el modelo de datos XFA vivo, así que llamarlo antes de guardar muestra los valores viejos. Tras reabrir, muestra exactamente lo que se escribió. Un documento de stream único no tiene paquetes nombrados por separado: PDFium reporta el XDP completo como un paquete con nombre vacío, así que GetXfaPacketByName('datasets') y GetXfaDatasets no devuelven nada, y el fallback lee el stream completo por medio de GetXfaFormPackets
uses
System.SysUtils, PDFium, FPdfXfa;
function ReadSavedXfaData(const FileName: string): string;
var
Pdf: TPdf;
Packets: TXfaPacketList;
Bytes: TBytes;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := FileName;
Pdf.Active := True;
Bytes := Pdf.GetXfaDatasets; // layout de array de paquetes
if Length(Bytes) = 0 then
begin
Packets := Pdf.GetXfaFormPackets; // stream único: un paquete sin nombre
if Length(Packets) = 1 then
begin
SetLength(Bytes, Length(Packets[0].Content));
if Length(Bytes) > 0 then
Move(Packets[0].Content[0], Bytes[0], Length(Bytes));
end;
end;
Result := TEncoding.UTF8.GetString(Bytes); // la salida XDP guardada es UTF-8
finally
Pdf.Free;
end;
end;
La rutina de guardado después hace commit de la edición pendiente, verifica el resultado de SaveAs y compara el valor reabierto. TPdf.ClearFormFieldFocus mata el foco del formulario, que es el momento en que PDFium hace commit del edit buffer del field con foco. TPdf.SetFocusedFormFieldText(const Value: WString): Boolean llena el field con foco programáticamente, pero se apoya en un foco que el wrapper rastrea por medio de FocusFormField, que recorre las anotaciones de widget. Una página XFA dinámica normalmente no tiene ninguna, así que ahí el texto normalmente llega por input de teclado en TPdfView, y la función devuelve False cuando ningún field rastreado tiene el foco
function XmlText(const S: string): string;
begin
Result := StringReplace(S, '&', '&', [rfReplaceAll]);
Result := StringReplace(Result, '<', '<', [rfReplaceAll]);
end;
procedure SaveXfaAndVerify(Pdf: TPdf; const FileName, FieldTag,
Expected: string);
var
Saved: string;
begin
// Llenado scriptado opcional; False significa que ningún field rastreado tiene foco
if (Pdf.FocusedFormFieldIndex >= 0) and
not Pdf.SetFocusedFormFieldText(Expected) then
raise EPdfError.Create('Could not write the focused field');
Pdf.ClearFormFieldFocus; // commit del edit buffer
if not Pdf.SaveAs(FileName) then // incluye el flush final (v3.125.2+)
raise EPdfError.CreateFmt('Saving %s failed', [FileName]);
Saved := ReadSavedXfaData(FileName);
if Pos('<' + FieldTag + '>' + XmlText(Expected) + '</' + FieldTag + '>',
Saved) = 0 then
raise EPdfError.CreateFmt('%s did not survive the round trip', [FieldTag]);
end;
Trate la prueba de substring como una prueba de humo. Un elemento vacío puede serializarse como <Tag/>, los atributos pueden aparecer en elementos de datos, y el escapado más allá de & y < es una elección del serializador. Para chequeos de producción, cargue el XML reabierto con un parser XML de verdad y compare el text node del elemento de datos enlazado. Corra el chequeo dos veces seguidas también, porque el defecto del newline solo mostró su forma completa en la segunda generación
Referencia rápida: checklist de fidelidad del guardado XFA
- Despliegue
pdfium.v8.dllde v3.125.2 o posterior para formularios XFA, y v3.125.3 o posterior para elpdfium.dllordinario, de modo que la corrección de la escritura final esté en ambos - Apunte
LibraryNamea una ruta completa y pongaEnableV8Engineen True; una ruta faltante falla en vez de cargar otra copia - Confirme
TPdf.XFAyTPdf.XfaRuntimeAvailabledespués de abrir el documento - Llame a
ClearFormFieldFocusantes deSaveAspara que el field con foco haga commit - Jamás ignore el resultado Boolean de
SaveAs; un resultado False deja el archivo anterior en su lugar - Verifique reabriendo en un
TPdfnuevo y leyendoGetXfaDatasets, con fallback aGetXfaFormPacketspara XFA de stream único - Pruebe con valores vacíos, espacios al inicio, texto multilínea,
&y un carácter del plano suplementario, a lo largo de dos generaciones de guardado - Espere fallos de guardado explícitos por DTD, XMLDSig y comentarios dentro de los paquetes vivos del XFA de stream único
- Si un formulario dinámico pierde geometría de runtime al reabrir, verifique el subform raíz buscando
restoreState="auto"antes de sospechar de la librería
Para la estructura de callbacks que el runtime XFA espera de una aplicación anfitriona, vea FPDF_FORMFILLINFO versión 2 y el ABI XFA en Delphi. El runtime V8, el wrapper Delphi y C++Builder y el control de visor son todos parte de PDFium Component para Delphi y C++Builder, que incluye ambos runtimes Windows para Win32 y Win64