Artículo técnico

Convertir XFA estático a campos AcroForm en Delphi

PDFium Component convierte formularios XFA estáticos en campos AcroForm ordinarios desde Delphi mediante FlattenPdfXfaToAcroForm en la unidad FPdfDocumentXfaFlatten. La función lee los packets de plantilla y datasets de XFA, mapea cada campo posicionado de forma absoluta a un campo terminal AcroForm con su propio appearance stream, y añade el resultado como una revisión incremental, así que el PDF convertido se abre como un formulario normal en visores que nunca soportaron XFA

La motivación suele ser un ticket de soporte más que un punto de hoja de ruta. Un formulario de gobierno o de seguros se maquetó en un diseñador que lo guardó como XFA, funciona en un lector de escritorio, y en todas partes se abre como una página en blanco que dice que el documento requiere un visor más nuevo. XFA quedó obsoleto en PDF 2.0 (ISO 32000-2), los navegadores y visores móviles nunca lo implementaron, y el propio PDFium solo renderiza XFA con la build con V8. Los formularios XFA estáticos, los que tienen el layout fijo en tiempo de diseño, llevan geometría suficiente en la plantilla como para reconstruirse como campos AcroForm, y esa es exactamente la porción que maneja este conversor

Qué produce realmente FlattenPdfXfaToAcroForm

FlattenPdfXfaToAcroForm produce una revisión AcroForm a nivel de bytes añadida a una copia sin cambios del origen, nunca una reescritura del fichero original. PDFium no expone ninguna API pública para crear campos de formulario, así que el componente no pasa por PDFium para este trabajo: lee la revisión activa con su propio lector de objetos, construye diccionarios de campo, widget annotations, appearance XObjects y un recurso de fuente /Helv como objetos PDF en crudo, y los serializa mediante una actualización incremental dispersa en el sentido de ISO 32000-1 §7.5.6

El nuevo diccionario /AcroForm lleva /Fields, /DA y /DR y ninguna entrada /XFA. Cuando el origen conserva su AcroForm como objeto indirecto, ese objeto se reescribe en su sitio; un diccionario inline se sustituye dentro del catálogo. En cualquier caso la revisión activa deja de apuntar a los packets XFA, así que un visor que entiende ambos modelos abre los campos AcroForm en lugar de intentar maquetar el formulario XFA. Los bytes XFA originales siguen en la revisión anterior, que es lo que significa una actualización incremental, pero nada en la revisión actual los referencia

Pipeline de conversión de XFA estático a AcroForm en PDFium Component para Delphi: FlattenPdfXfaToAcroForm lee los packets de plantilla y datasets, mapea los campos posicionados absolutamente a diccionarios de campo y widgets, y añade el AcroForm como una revisión incremental dispersa sin entrada /XFA
FlattenPdfXfaToAcroForm nunca toca los bytes originales: los campos convertidos, widgets y appearance streams se añaden como una revisión cuyo /AcroForm simplemente deja de apuntar a los packets XFA
uses
  System.SysUtils, System.Classes, System.IOUtils, FPdfDocumentXfaFlatten;

procedure ConvertXfaForm(const SourceFile, TargetFile: string);
var
  Source: TFileStream;
  Options: TPdfXfaFlattenOptions;
  OutputPdf: TBytes;
  Report: TPdfXfaFlattenReport;
  Fields: TPdfXfaFlattenFields;
begin
  Options := TPdfXfaFlattenOptions.Default;
  Options.MaxFields := 1024;   // el valor por defecto es 512, el rango permitido es 1..4096

  Source := TFileStream.Create(SourceFile, fmOpenRead or fmShareDenyWrite);
  try
    if not FlattenPdfXfaToAcroForm(Source, Options, OutputPdf, Report, Fields) then
      raise Exception.Create('XFA conversion failed: ' + Report.ErrorText);
  finally
    Source.Free;
  end;

  TFile.WriteAllBytes(TargetFile, OutputPdf);
  Writeln(Format('%d template fields, %d bound to datasets, %d written on %d pages',
    [Report.TemplateFields, Report.DatasetBound, Report.FieldsWritten, Report.PagesWritten]));
end;

La función devuelve False y rellena Report.ErrorText para cualquier entrada rechazada o malformada, y restaura la posición del stream de origen al salir. La cancelación es la única ruta de excepción que se propaga: si Options.CancellationToken se dispara, EPdfOperationCancelled se relanza después de rellenar el informe. TPdfXfaFlattenReport lleva además SourceSha256 y OutputSha256, útil cuando un trabajo por lotes tiene que probar qué entrada produjo qué fichero convertido. TPdfXfaFlattenOptions.Default fija un presupuesto de packets de 8 MiB, 512 campos y un techo de salida de 512 MiB; el origen no debe estar cifrado

Leer los packets de plantilla y datasets

PDFium Component acepta ambos layouts que ISO 32000-1 §12.7.8 permite para la entrada /XFA: la forma de array que alterna nombres de packet y referencias a streams, y la forma XDP de stream único donde un stream contiene todo el documento <xdp:xdp>. Los streams de packet pueden estar sin comprimir o con /FlateDecode sin predictor, y la descompresión está acotada por MaxPacketBytes

El caso de stream único es donde el manejo XML ingenuo se rompe. Los diseñadores escriben habitualmente <xfa:datasets> con prefijo de namespace mientras la plantilla va sin prefijo, así que trocear por nombre de tag literal encuentra un packet y pierde el otro. El conversor empareja nombres de elemento y atributo por nombre local, tras una comprobación aparte de que las tags de apertura y cierre se emparejen por su nombre con prefijo completo. Si solo necesitáis mirar los packets en lugar de convertirlos, la guía para detectar formularios XFA y extraer sus packets con PDFium VCL cubre esa ruta de solo lectura

Los valores de campo vienen de dos sitios. Un elemento <value> en la plantilla suministra el valor por defecto, y el packet de datasets suministra los datos rellenados, ligados por nombre de campo. Los nombres de campo se repiten en formularios reales, así que el ligador compara la cadena de ancestros <subform> nombrados con la ruta de ancestros en los datos y toma la coincidencia más larga sin conflictos. Cuando dos campos hoja comparten nombre bajo subforms distintos, el /T del AcroForm se convierte en SubformPath.Name para que los dos campos sigan siendo distintos tras la conversión

Por qué choiceList necesita un flag Combo explícito

XFA y PDF tienen valores por defecto opuestos para los campos de elección, así que un conversor que no copia ningún flag convierte cada desplegable en una list box. Un <choiceList> de XFA con el open="userControl" por defecto o con open="onEntry" es un desplegable; un campo /FT /Ch de PDF sin flags es una list box con scroll, y solo el flag Combo (posición de bit 18 en la Tabla 230 de ISO 32000-1, valor 131072, el mismo bit que PDFium llama FPDF_FORMFLAG_CHOICE_COMBO) lo convierte en combo box. El conversor pone por tanto el flag Combo para los casos de desplegable y deja los flags vacíos solo cuando la plantilla dice open="always" o open="multiSelect", que mantienen la lista permanentemente abierta. Las builds anteriores a 3.181.1 intercambiaban los valores de Combo y Pushbutton y buscaban un open="on" no estándar, así que los formularios aplanados con ellas merecen reprocesarse

Decisión de flags de campos de elección en la conversión de XFA estático a AcroForm de PDFium Component: un atributo open de userControl u onEntry activa el flag Combo, bit 18 valor 131072, para producir un combo box desplegable, mientras open always o multiSelect deja los flags vacíos para una list box permanentemente abierta
Sin el flag Combo un desplegable XFA se aplana en una list box con scroll, así que el conversor lee el atributo open y activa el bit 18 solo para los casos de desplegable

El resto del mapeo de UI sigue las tablas de flags de campo de ISO 32000-1 §12.7.4:

  • textEdit, numericEdit y dateTimeEdit se convierten en /FT /Tx; un campo de texto multilínea recibe el flag Multiline (/Ff 4096, Tabla 228) y un appearance stream partido en líneas
  • passwordEdit se convierte en /FT /Tx con el flag Password (/Ff 8192) y un appearance que dibuja un asterisco por escalar Unicode, no por unidad de código UTF-16
  • checkButton se convierte en una casilla /FT /Btn cuyo valor es /Yes cuando el dato ligado es 1 y /Off en caso contrario
  • <exclGroup> se convierte en un padre de radio /FT /Btn /Ff 32768 (Tabla 226) con /V, /DV, /Opt y /Kids, más un widget hijo por miembro con su propio /AS y /AP de dos estados
  • imageEdit se convierte en un pushbutton cuyo appearance pinta la imagen; un JPEG de línea base se incrusta verbatim bajo /DCTDecode, mientras que el PNG se decodifica y recodifica como píxeles /FlateDecode con un /SMask extra cuando el PNG lleva alfa o transparencia tRNS

Los valores que no son ASCII imprimible plano, incluidos saltos de línea, se escriben como strings <FEFF...> UTF-16BE, así que un nombre japonés o una dirección de dos líneas sobreviven al viaje. La salida Fields refleja lo escrito, lo que la hace una pista de auditoría práctica

procedure DumpConvertedFields(const Fields: TPdfXfaFlattenFields);
const
  KindNames: array[TPdfXfaFlattenFieldKind] of string = (
    'text', 'check', 'choice', 'image', 'numeric', 'dateTime', 'password', 'radio');
var
  I: Integer;
  F: TPdfXfaFlattenField;
begin
  for I := 0 to High(Fields) do
  begin
    F := Fields[I];
    Writeln(Format('%s.%s [%s] page %d at %.1f,%.1f size %.1f x %.1f',
      [F.SubformPath, F.Name, KindNames[F.Kind], F.PageIndex + 1, F.X, F.Y, F.W, F.H]));
    case F.Kind of
      fxfkChoice:
        Writeln(Format('  %d items, list box: %s', [Length(F.Choices), BoolToStr(F.ListBox, True)]));
      fxfkRadio:
        Writeln(Format('  %d radio widgets, value "%s"', [Length(F.RadioWidgets), F.Value]));
      fxfkImage:
        Writeln(Format('  %d image bytes', [Length(F.ImageData)]));
    else
      Writeln('  value "' + F.Value + '"');
    end;
  end;
end;

Geometría, unidades de medida y enrutado de páginas

La geometría XFA se convierte al espacio de usuario PDF volteando cada campo contra la altura del MediaBox de su propia página, porque XFA mide hacia abajo desde la parte superior de la página y PDF mide hacia arriba desde abajo. Los offsets de los elementos <subform> anidados se acumulan al bajar por la plantilla, y cada campo debe acabar con anchura y altura positivas o la conversión se rechaza

Conversión de geometría de XFA a PDF en PDFium Component para Delphi: un rectángulo de campo medido hacia abajo desde el borde superior en la plantilla XFA se voltea contra la altura del MediaBox de su propia página al espacio de usuario PDF, donde la coordenada y crece hacia arriba desde abajo
La plantilla XFA hacia abajo y el espacio de usuario PDF hacia arriba se encuentran en una resta contra la altura del MediaBox, que es lo que coloca cada widget convertido en su posición diseñada

Las medidas aceptan las unidades que los diseñadores reales emiten: números pelados y pt, más in (72 puntos), mm (72 / 25.4), cm (72 / 2.54), pc (12), px (0.75) y mp (0.001, milipuntos). El parseo es independiente de la locale, así que una instalación de Windows en alemán no convierte 8.5in en nonsense. Un sufijo de unidad desconocido se rechaza en lugar de leerse como puntos, lo que colocaría el campo mal en silencio

El enrutado de páginas sigue el <pageSet>: los elementos <pageArea> hermanos se numeran desde uno en orden de declaración y reclaman los campos anidados bajo ellos, mientras que los campos sin ancestro <pageArea> se quedan en la página uno. Cada página recibe su propia actualización de /Annots. Un campo enrutado más allá de la última página del PDF es un error en lugar de un widget soltado en la página uno

Qué formularios XFA rechaza el conversor

El conversor rechaza cualquier cosa cuyo layout no se pueda conocer sin ejecutar el motor de layout XFA. Eso significa un atributo layout distinto de position en un <subform> o <exclGroup> (los fluidos tb, lr-tb y amigos, con los que se construyen los formularios dinámicos), cualquier elemento <script>, y cualquier tipo de UI fuera de los siete listados arriba. Los formularios dinámicos que crecen páginas en tiempo de ejecución pertenecen a otra cadena de herramientas; el artículo sobre la repaginación XFA dinámica y los recuentos de páginas en PDFium Component muestra qué implica ese layout en vivo

El rechazo es deliberado. Un subform fluido no tiene coordenadas fijas, así que cualquier «conversión» tendría que inventar posiciones, y un formulario con scripts suele calcular totales o conmutar visibilidad que un AcroForm estático no puede reproducir. Producir un PDF que parece completo pero que perdió silenciosamente un total calculado es peor que un error claro en Report.ErrorText. La especificación XFA 3.3 define mucho más que este subconjunto, y el conversor no pretende lo contrario

Trampas al escribir y verificar la revisión AcroForm

Dos problemas aparecieron mientras se construía el writer, y ambos aplican a cualquiera que produzca actualizaciones incrementales a mano. El primero: el catálogo debe formar siempre parte del conjunto de actualización, aunque sus bytes no cambien porque el AcroForm es un objeto indirecto reescrito en otra parte. El writer de actualización dispersa deriva el /Root del trailer de los objetos que lleva, y una actualización sin el objeto raíz produce una revisión que los visores no pueden anclar

El segundo concierne a la verificación. Cargar el fichero convertido a través del envoltorio de PDFium dispara la inicialización del entorno de formularios en cuanto el documento tiene un /AcroForm, y el pdfium.dll anfitrión en la configuración de pruebas carecía del export FORM_GetTextDirection, así que la inicialización fallaba antes de que se pudiera comprobar un solo campo. Las comprobaciones de lectura de vuelta usan por tanto el lector de objetos del componente, que parsea la revisión activa sin tocar la capa de formularios

uses
  System.SysUtils, System.Classes, FPdfCompress;

function HasActiveAcroForm(const Pdf: TBytes): Boolean;
var
  Stream: TBytesStream;
  Objects: TPdfActiveObjects;
  Revision: TPdfSparseRevisionInfo;
  ErrorText: string;
begin
  Stream := TBytesStream.Create(Pdf);
  try
    if not ReadPdfActiveObjects(Stream, TPdfActiveObjectReadOptions.Default,
      Objects, Revision, ErrorText) then
      raise Exception.Create(ErrorText);
    // el catálogo activo debe referenciar un AcroForm; un AcroForm inline
    // escrito por el conversor no lleva entrada /XFA
    Result := Pos('/AcroForm', string(Revision.CatalogDictionary)) > 0;
  finally
    Stream.Free;
  end;
end;

Los appearance streams son deliberadamente simples: Helvetica a 10 puntos para texto y valores de elección, un cuadrado con trazo para las casillas, un cuadrado interior relleno para el estado de radio seleccionado. Hacen el formulario convertido legible en todas partes, pero no son una copia al píxel del renderizado XFA original, así que las fuentes, colores y bordes definidos en la plantilla no se trasladan. Si un formulario tiene que verse idéntico a la salida del diseñador, renderizadlo una vez con la build XFA y comparad antes de cambiar un pipeline de producción

FAQ

¿Necesita FlattenPdfXfaToAcroForm la build V8 o XFA de PDFium?

No. FlattenPdfXfaToAcroForm parsea los packets y escribe la revisión con código de PDFium Component, así que funciona con el pdfium.dll estándar y no ejecuta ningún script XFA

¿Puede el conversor manejar documentos XFA cifrados?

No directamente. Un origen cifrado se rechaza con un error, así que descifrad primero el documento y convertid la copia descifrada

¿Los campos convertidos siguen siendo editables?

Sí. La salida contiene campos AcroForm reales de texto, casilla, radio, elección y pushbutton con valores y appearance streams, así que los usuarios pueden seguir rellenando el formulario en cualquier visor con soporte AcroForm

La conversión de XFA estático, la extracción de packets XFA y el runtime XFA con V8 son todos parte del PDFium Component para Delphi y C++Builder, que envuelve PDFium para proyectos Delphi, C++Builder y Lazarus