Artículo técnico

XFA en PDFium Component: newlines, emoji y restoreState

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 reabrirCausaCorregido en
Un field vacío contiene un line feed; los valores ganan un newline por guardadoAmbos writers XFA insertaban newlines de layout después de start tagsv3.125.2, pdfium.v8.dll
U+1F642 vuelve como U+F642, o el emoji desaparece del form packetTruncamiento de wchar_t de 16 bits al decodificar; filtrado de surrogates en el serializador del formulariov3.125.2, pdfium.v8.dll
Las ediciones en un documento XFA de stream único simplemente se pierdenEl guardado nativo rechazaba el layout 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 escritura final buferada fallaba después de que el writer ya había retornado éxitoruntime V8 v3.125.2; pdfium.dll ordinario v3.125.3
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 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

Diagrama del ciclo de guardado XFA de PDFium Component donde el writer agrega un newline después de los start tags, el parser al reabrir lee el LF entre los tags Comments como el valor del field, y cada guardado adicional agrega otro line feed hasta que v3.125.2 elimina solo el espacio en blanco sintetizado por el serializador
Un ciclo de guardado y reapertura siembra el primer line feed y cada ronda siguiente agrega otro, razón por la cual la deriva mostró 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 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 &#x1F642; 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

Diagrama de manejo de surrogates de PDFium Component donde U+1F642 llega como el par UTF-16 D83D DE42 y dos rutas defectuosas lo corrompen: los decoders wchar_t de 16 bits truncan el escalar a U+F642 en el private use area, mientras que el serializador del formulario filtra surrogates solitarios y descarta el emoji por completo
El wchar_t de Windows es de 16 bits, así que un escalar que necesita un surrogate pair perdía su mitad alta o desaparecía del paquete hasta que ambas rutas aprendieron a mantener los pares juntos

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 datasets o form, 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
Pipeline de guardado XFA de stream único de PDFium Component donde los paquetes vivos datasets y form se exportan a staging, se validan, y luego se reemplazan dentro del XDP original con comentarios conservados por medio de marcadores, mientras que los fallos de staging e inputs como DTD o XMLDSig rechazan el guardado explícitamente
El export a staging se valida antes de que algo se reemplace, así que un guardado fallido deja el stream XFA persistente intacto 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 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, '&', '&amp;', [rfReplaceAll]);
  Result := StringReplace(Result, '<', '&lt;', [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.dll de v3.125.2 o posterior para formularios XFA, y v3.125.3 o posterior para el pdfium.dll ordinario, de modo que la corrección de la escritura final esté en ambos
  • Apunte LibraryName a una ruta completa y ponga EnableV8Engine en True; una ruta faltante falla en vez de cargar otra copia
  • Confirme TPdf.XFA y TPdf.XfaRuntimeAvailable después de abrir el documento
  • Llame a ClearFormFieldFocus antes de SaveAs para 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 TPdf nuevo y leyendo GetXfaDatasets, con fallback a GetXfaFormPackets para 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