Artículo técnico

Guardado XFA en PDFium Component: LF, emoji y restoreState

PDFium Component guarda los valores editados de formularios XFA exactos, a través de guardar y reabrir, cuando corre el runtime Windows V8 pdfium.v8.dll que viene en v3.125.2 o posterior. Los runtimes anteriores añadían saltos de línea a los valores de campo, recortaban los emoji a un carácter BMP sin relación, se saltaban en silencio los guardados XFA de stream único y podían tragarse una escritura final fallida. Un síntoma de reapertura no es defecto de la biblioteca en absoluto: un formulario dinámico cuyo subform raíz carece de restoreState="auto" reconstruye su layout desde la plantilla

Los reportes de bug de esto se parecían todos. Un cliente rellena un formulario XFA de reclamación en un visor Delphi, guarda, reabre, y algo está ligeramente torcido. Un cuadro de comentarios vacío ahora guarda una línea en blanco, y tras un segundo guardado guarda dos. Un nombre tecleado con un emoji vuelve con un glifo de área de uso privado. Nadie recibe un error, que es justo lo que hace caros estos bugs: la desviación aparece semanas después en la exportación de otro

¿Qué sale mal cuando un formulario XFA se guarda y se reabre?

Cuatro defectos separados en el camino de guardado XFA nativo causaban desviación de valores, y cada uno se escondía detrás de un guardado con apariencia de éxito. Dos venían de la serialización, uno de la disposición de almacenamiento de stream único, y uno del propio escritor PDF. La tabla mapea cada síntoma a su causa y a la versión donde PDFium Component lo arregló

Síntoma tras reabrirCausaArreglado en
Un campo vacío guarda un salto de línea; los valores ganan un newline por guardadoAmbos escritores XFA insertaban newlines de layout tras las etiquetas de aperturav3.125.2, pdfium.v8.dll
U+1F642 vuelve como U+F642, o el emoji desaparece del paquete formTruncamiento a wchar_t de 16 bits al decodificar; filtrado de surrogates en el serializador de formulariosv3.125.2, pdfium.v8.dll
Las ediciones en un documento XFA de stream único sencillamente se han idoEl guardado nativo rechazaba la disposición de stream, pero el valor de retorno se ignorabav3.125.2; comentarios e instrucciones de procesamiento conservados desde v3.126.0
Archivo truncado aunque el guardado reportó éxitoLa última escritura en búfer fallaba después de que el escritor ya hubiera devuelto éxitov3.125.2 runtime V8; v3.125.3 pdfium.dll ordinaria
Un formulario dinámico de tres páginas reabre como dos páginasEl subform raíz no pide restoreState="auto"Autoría del formulario, no un defecto de la biblioteca

Escritos anteriores concluían que las ediciones de campos XFA no podían persistirse con PDFium en absoluto, 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 paquete datasets guardado sin cirugía de paquetes por 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 la primera comprobación es qué runtime cargó de verdad su proceso. PDFium Component trae dos compilaciones Windows por arquitectura: la pdfium.dll ordinaria, compilada sin V8 ni XFA, y pdfium.v8.dll, que lleva el motor JavaScript y el runtime de formularios XFA. Solo pdfium.v8.dll puede ejecutar un formulario XFA, así que todo arreglo XFA descrito aquí vive allí, empezando por las bibliotecas V8 Win32 y Win64 reconstruidas en v3.125.2

El arreglo de la escritura final es código genérico del escritor PDF, así que importa también para documentos ordinarios. v3.125.3 reconstruyó las bibliotecas pdfium.dll ordinarias para llevar esa misma reparación. Fuente compartido no es prueba de comportamiento compartido: hasta que el binario se reconstruye, la DLL vieja conserva el bug viejo

Una segunda trampa estaba en el loader. Antes de v3.125.2, poner EnableV8Engine a 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 apuntaba 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 motor, y una ruta ausente falla en lugar de caer a otra biblioteca incluida

uses
  System.SysUtils, PDFium;

procedure SelectXfaRuntime;
begin
  // Un directorio en LibraryName fija este archivo exacto (v3.125.2 y posterior);
  // si el archivo falta, la carga lanza en lugar de caer a otro
{$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;  // falle al arrancar, no en el primer guardado
end;

Tras 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 campos XFA guardados ganan saltos de línea extra?

Los campos XFA guardados ganaban saltos de línea porque ambos escritores XFA nativos, el escritor genérico de elementos XML y el serializador del paquete form, maquetaban su salida con un newline tras las etiquetas de apertura. En la mayoría del XML ese espacio en blanco es cosmético. En datos XFA no lo es: cuando el paquete datasets se parsea de nuevo, el texto entre <Comments> y </Comments> es el valor del campo, newline incluido. Un campo vacío reabría por tanto guardando un solo LF, y cada ciclo ulterior de guardar y reabrir podía añadir otro

Diagrama del ciclo de guardado XFA de PDFium Component donde el escritor añade un newline tras las etiquetas de apertura, el parser de la reapertura lee el LF entre las etiquetas Comments como valor del campo, y cada guardado ulterior añade otro salto de línea hasta que v3.125.2 elimina solo el espacio en blanco sintetizado por el serializador
Un ciclo de guardado y reapertura planta el primer salto de línea y cada ronda ulterior añade otro, por eso la desviación enseñó su forma completa solo en la segunda generación

La reparación obvia, recortar los valores al cargar, estaría mal. Los usuarios teclean espacios delante, espacios al final y texto multilínea deliberado en campos XFA, y un bloque de dirección o un código de ancho fijo debe sobrevivir byte a byte. El arreglo de v3.125.2 elimina por tanto solo el espacio en blanco que el propio serializador sintetizó alrededor de las etiquetas. Los valores del usuario, los nodos de texto existentes y las secciones CDATA pasan intactos, así que " indented" sigue indentado y un campo vacío a propósito sigue vacío

¿Por qué un emoji vuelve como otro carácter?

Un emoji volvía mal porque el wchar_t de Windows mide 16 bits, y dos caminos de decodificación guardaban un valor escalar Unicode completo en un único wchar_t. El decoder de streams UTF-8 y el parser de referencias de carácter numéricas como &#x1F642; lo hacían ambos. U+1F642, la carita ligeramente sonriente, no cabe en 16 bits, así que los bits altos se caían y aparecía U+F642 en su lugar: un code point en el Private Use Area que la mayoría de las fuentes renderizan como un cuadro o nada

El serializador de formularios tenía el problema contrario. Filtraba caracteres de un wchar_t en cada pasada, veía dos unidades de código surrogate que son inválidas aisladas, y las descartaba ambas, así que el emoji desaparecía del paquete form por completo. En v3.125.2 el decoder consume cada valor escalar por completo y emite un par surrogate correcto. Cuando solo queda una casilla de salida, mantiene pendiente el surrogate bajo y no reporta fin de stream mientras esa unidad siga en búfer. Una secuencia UTF-8 partida entre bloques de lectura se arrastra a la siguiente lectura en lugar de descartarse. El exportador de formularios ahora mantiene juntos los pares surrogate válidos, y las referencias de carácter numéricas también producen pares correctos

Diagrama de manejo de surrogates de PDFium Component donde U+1F642 llega como el par UTF-16 D83D DE42 y dos caminos con defectos lo corrompen: los decoders wchar_t de 16 bits truncan el escalar a U+F642 en el área de uso privado, mientras el serializador de formularios filtra surrogates solitarios y descarta el emoji por completo
El wchar_t de Windows mide 16 bits, así que un escalar que necesita un par surrogate o perdía su mitad alta o desaparecía del paquete hasta que ambos caminos aprendieron a mantener los pares juntos

Los datos de prueba Latin-1 jamás muestran nada de esto, así que toda prueba de ida y vuelta 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 de guardado nativo rechazaba esa disposición de almacenamiento y su llamador 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 único stream que guarde el documento XDP entero. Los arrays de paquetes son el caso común, pero los streams únicos son perfectamente legales, y el guardado del PDF terminaba 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 ensayo 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 ensayo 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 necesitaban cuidado extra porque el DOM XML interno los suelta. En v3.125.2 su presencia hacía que el guardado fallara directamente en lugar 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 sitio del texto original. Tras reemplazarse los paquetes vivos, cada marcador debe aparecer exactamente una vez antes de restaurar el token original y escribir el stream. Los tokens fuera de los paquetes reemplazados conservan por tanto su texto y su orden, incluidos los tokens del prólogo, la plantilla y otros paquetes

Algunas entradas siguen rechazándose a propósito, y cada rechazo es un fallo de guardado explícito:

  • Comentarios o instrucciones de procesamiento dentro de los paquetes vivos datasets o form, ya que sus posiciones originales no pueden mapearse 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, etiquetas incompletas, referencias de carácter inválidas, entidades desconocidas e instrucciones de procesamiento malformadas, que se rechazan en lugar de repararse en silencio
Pipeline de guardado XFA de stream único de PDFium Component donde los paquetes vivos datasets y form se exportan a ensayo, se validan, y luego se reemplazan dentro del XDP original con comentarios conservados por marcadores, mientras fallos de ensayo y entradas como DTD o XMLDSig rechazan el guardado explícitamente
La exportación en ensayo se valida antes de reemplazar nada, así que un guardado fallido deja intacto el stream XFA persistente y el documento conserva su marca de modificación

La salida de stream único es UTF-8 y preserva el modelo de contenido XML, no la disposición de bytes original ni la declaración de codificación

El último defecto estaba por debajo del XFA. El escritor de archivos nativo guardaba la salida en bloques de 32 KB y vaciaba el bloque parcial final solo en su destructor, después de que el escritor del documento ya hubiera reportado éxito. Un disco lleno o un error de E/S en ese último bloque era invisible para el llamador. Desde v3.125.2 en el runtime V8 y v3.125.3 en el runtime ordinario, ese vaciado final es parte del resultado del guardado, y la marca de modificación XFA solo se limpia tras un éxito real. Por el 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 sitio solo cuando el guardado devuelve 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 más que un defecto de PDFium Component. En XFA 3.3, restoreState en el subform raíz vale manual por defecto. Bajo manual, el procesador XFA restaura solo estado limitado desde el paquete form guardado y deja el resto a los scripts del autor. Los valores de campo guardados y los recuentos de instancias de subforms repetitivos siguen volviendo, pero las propiedades geométricas fijadas en ejecución no

El caso que destapó esto era un formulario de tres páginas cuyo script agrandaba un subform a h="450pt". El paquete form guardado llevaba la altura nueva, los valores y los recuentos de instancias. Al reabrir, sin embargo, el layout se reconstruía desde las alturas de la plantilla y el formulario refluyó a dos páginas. El runtime estaba en lo cierto: la plantilla 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">
      <!-- campos; los scripts pueden cambiar h o añadir instancias en ejecución -->
    </subform>
  </subform>
</template>

Si la plantilla no es suya, no la parchee por los lados en el visor: un formulario que se apoya en el modo manual espera que sus propios scripts reconstruyan el estado. La repaginación en vivo mientras el usuario teclea es un tema aparte, tratado en cómo sigue PDFium Component los recuentos de páginas XFA dinámicos y los campos mudados

¿Cómo verifica un guardado XFA en Delphi?

La única comprobación fiable de un guardado XFA es reabrir el archivo guardado en una instancia TPdf nueva y leer de vuelta los datos almacenados. TPdf.GetXfaDatasets devuelve el paquete datasets tal como está almacenado 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 con nombre separado: PDFium reporta el XDP entero como un paquete con nombre vacío, así que GetXfaPacketByName('datasets') y GetXfaDatasets no devuelven nada, y el fallback lee el stream completo por 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;          // disposición 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 compromete entonces la edición pendiente, comprueba el resultado de SaveAs y compara el valor reabierto. TPdf.ClearFormFieldFocus mata el foco del formulario, que es el momento en que PDFium compromete el búfer de edición del campo enfocado. TPdf.SetFocusedFormFieldText(const Value: WString): Boolean rellena el campo enfocado por programación, pero se apoya en un foco que el wrapper sigue por medio de FocusFormField, que recorre las anotaciones widget. Una página XFA dinámica normalmente no tiene ninguna, así que allí el texto suele llegar por entrada de teclado en TPdfView, y la función devuelve False cuando ningún campo seguido tiene el foco

function XmlText(const S: string): string;
begin
  Result := StringReplace(S, '&', '&amp;', [rfReplaceAll]);
  Result := StringReplace(Result, '<', '&lt;', [rfReplaceAll]);
end;

procedure SaveXfaAndVerify(Pdf: TPdf; const FileName, FieldTag,
  Expected: string);
var
  Saved: string;
begin
  // Relleno scriptado opcional; False significa que ningún campo seguido tiene el foco
  if (Pdf.FocusedFormFieldIndex >= 0) and
     not Pdf.SetFocusedFormFieldText(Expected) then
    raise EPdfError.Create('Could not write the focused field');

  Pdf.ClearFormFieldFocus;              // compromete el búfer de edición
  if not Pdf.SaveAs(FileName) then      // incluye el vaciado 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 subcadena 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 decisión del serializador. Para comprobaciones de producción, cargue el XML reabierto con un parser XML de verdad y compare el nodo de texto del elemento de datos enlazado. Ejecute la comprobación dos veces seguidas también, porque el defecto de newline solo enseñó su forma completa en la segunda generación

Referencia rápida: lista de comprobación de fidelidad de guardado XFA

  • Despliegue pdfium.v8.dll de v3.125.2 o posterior para formularios XFA, y v3.125.3 o posterior para la pdfium.dll ordinaria, así que el arreglo de la escritura final está en ambas
  • Apunte LibraryName a una ruta completa y ponga EnableV8Engine a True; una ruta ausente falla en lugar de cargar otra copia
  • Confirme TPdf.XFA y TPdf.XfaRuntimeAvailable tras abrir el documento
  • Llame a ClearFormFieldFocus antes de SaveAs para que el campo enfocado se comprometa
  • Jamás ignore el resultado booleano de SaveAs; un resultado False deja el archivo anterior en su sitio
  • Verifique reabriendo en un TPdf nuevo y leyendo GetXfaDatasets, cayendo a GetXfaFormPackets para XFA de stream único
  • Pruebe con valores vacíos, espacios delante, texto multilínea, & y un carácter del plano suplementario, a lo largo de dos generaciones de guardado
  • Espere fallos de guardado explícitos para DTD, XMLDSig y comentarios dentro de los paquetes vivos del XFA de stream único
  • Si un formulario dinámico pierde geometría de ejecución al reabrir, compruebe el subform raíz por restoreState="auto" antes de sospechar de la biblioteca

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