Artículo técnico

Runtime XFA dinámico en Delphi: transacciones de HotPDF

HotPDF rellena formularios XFA dinámicos en Delphi mediante TXFAWidgetRuntime, una capa de widgets neutral al anfitrión que trata cada edición de campo como una transacción: snapshot, validate, calculate, reflow y luego publicar todo o deshacer todo. Se ejecuta en un único hilo dentro de tu propio anfitrión VCL o FMX, no necesita Acrobat instalado y comprueba cada presupuesto antes de asignar nada

El escenario le resulta familiar a quien haya enviado software de documentos a organismos públicos o aseguradoras. Un formulario de siniestro o una declaración fiscal llega como un PDF cuyo contenido de página es un único aviso de «Please wait... if this message is not eventually replaced», y todos los campos reales viven en un paquete XFA que solo Adobe Acrobat renderiza. Tus usuarios quieren rellenarlo dentro de tu aplicación. Y no puedes salir del paso rasterizando, porque el formulario crece en filas a medida que se introducen datos, y la disposición tras la tercera fila no es la que venía en el archivo

Por qué el XFA dinámico sigue siendo un problema que merece resolverse

El XFA dinámico persiste porque los formularios desplegados sobreviven al formato que los transportó. ISO 32000-1 §12.7.8 describe XFA como una entrada /XFA en el diccionario AcroForm que contiene un flujo de paquete XDP, e ISO 32000-2 desaconseja todo el mecanismo; la desaprobación lo sacó de la hoja de ruta, no del campo, y los formularios escritos contra la especificación XFA 3.3 siguen emitiéndose y siguen siendo legalmente vinculantes. El XFA estático se puede reducir a anotaciones de widget ordinarias, y HotPDF lo hace cuando llamas a ApplyXFAAsAcroForm, con las contrapartidas tratadas en aplanar formularios XFA en campos AcroForm. El XFA dinámico es otra bestia: sus rangos occur, su texto creciente y sus scripts calculate hacen que el conjunto de campos sea una función de los datos, de modo que no hay lista fija de anotaciones a la que aplanar hasta que el usuario termina de teclear. Ese es el hueco que llena TXFAWidgetRuntime, manteniendo vivo el DOM XFA, recalculando la disposición tras cada edición aceptada y entregando a tu anfitrión un array plano de widgets posicionados para dibujar y hacer hit-test

¿Qué le entrega el runtime a una aplicación anfitriona?

Te entrega geometría y estado, y nada que presuponga un toolkit de interfaz. TXFAWidgetRuntime expone WidgetCount y Widgets[I] como registros TXFAWidgetState que llevan ID, Name, Kind, PageIndex, Bounds en puntos PDF, Value, EditValue y los flags Focused, Editing, ReadOnly y Valid, mientras que el pintado, el dibujo del cursor y el enrutado del teclado se quedan en tu código. La identidad del widget es estable y ordinal: cada widget recibe un ID de la forma name[n], donde n cuenta las apariciones previas de ese nombre de campo en orden de disposición, así que la segunda fila de un subformulario repetido es amount[1]. Esa identidad es lo que sobrevive a una reconstrucción, y es lo que hablan FocusWidget, BeginEdit, DispatchEvent y HitTest. Para un documento ya abierto en una instancia THotPDF, CreateLoadedXFAWidgetRuntime extrae los paquetes XDP, toma la primera caja de página como tamaño de página de la disposición y devuelve nil cuando el archivo no lleva XFA alguno

var
  Pdf: THotPDF;
  Runtime: TXFAWidgetRuntime;
  WidgetID: AnsiString;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('claim-dynamic.pdf');
    Runtime := Pdf.CreateLoadedXFAWidgetRuntime;   // nil cuando no hay /XFA
    if Runtime = nil then
      Exit;
    try
      for I := 0 to Runtime.WidgetCount - 1 do
        Memo1.Lines.Add(Format('%s p%d [%.1f %.1f %.1f %.1f] = %s',
          [string(Runtime.Widgets[I].ID), Runtime.Widgets[I].PageIndex,
           Runtime.Widgets[I].Bounds.Left, Runtime.Widgets[I].Bounds.Top,
           Runtime.Widgets[I].Bounds.Right, Runtime.Widgets[I].Bounds.Bottom,
           string(Runtime.Widgets[I].Value)]));
      // hit test en espacio de página, gana el widget más alto
      if Runtime.HitTest(0, 120.0, 96.0, WidgetID) then
        Runtime.BeginEdit(WidgetID);
    finally
      Runtime.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

¿Qué tiene que ser atómico al confirmar un campo?

Todo lo que la edición puede tocar, que es considerablemente más que el valor del campo. CommitEdit llama a CaptureSnapshot antes de escribir nada, y ese snapshot cubre cuatro cosas: el DOM XFA serializado de TXFADocument.SaveToBytes, el array completo de registros de interacción TXFAWidgetState, los contadores LastCalculationPasses y LastReflowPasses, y el Warnings.Count actual. Guardar solo los valores de nodo es el atajo tentador y está mal, porque un script calculate o un binding sin resolver puede llamar a EnsureValueNode y materializar nodos de datos que no existían cuando empezó la edición; una restauración de solo valores no tiene forma de eliminarlos, así que una edición rechazada dejaría residuo estructural permanente en el paquete datasets. La secuencia de confirmación en sí es estricta — escribir el valor candidato, ejecutar validate para el campo editado, ejecutar calculate hasta un punto fijo y después reflow hasta que la disposición sea estable — y cualquier fallo en cualquier etapa pasa por FailAndRestore, que recarga los bytes del snapshot en un TXFADocument nuevo, reconstruye la lista de widgets, reaplica los estados de interacción registrados, reinicia los contadores y trunca Warnings a su longitud del snapshot. LastDiagnostic guarda el motivo en caso de fallo, y guarda el literal XFA transaction rollback failed en el caso patológico en que la propia restauración lance una excepción

HotPDF trata la confirmación de un campo XFA como una transacción, capturando el DOM serializado, el estado de cada widget, los contadores de pasadas y el recuento de avisos antes de validar, calcular y reflow, para luego publicar o restaurar los cuatro juntos
CommitEdit toma snapshot de cuatro clases de estado antes de escribir nada, así que un validate, calculate o reflow fallido no deja residuo estructural
function EditAmount(Runtime: TXFAWidgetRuntime;
  const AWidgetID: AnsiString; const AText: UnicodeString): Boolean;
var
  Current: UnicodeString;
begin
  Result := False;
  if not Runtime.BeginEdit(AWidgetID) then
    Exit;                                   // solo lectura, o no existe el widget
  Current := Runtime.Widgets[Runtime.FocusedIndex].EditValue;
  if not Runtime.ReplaceSelection(0, Length(Current), AText) then
  begin
    Runtime.CancelEdit;                     // rango malo, o surrogate partido
    Exit;
  end;
  Result := Runtime.CommitEdit;             // todo o nada
  if not Result then
    // el documento, los widgets, los contadores y los avisos ya vuelven al
    // estado previo a la edición; el widget enfocado solo se marca inválido
    ShowMessage(Runtime.LastDiagnostic);
end;

ReplaceSelection merece nota propia, porque es donde más barato sale rechazar la entrada malformada. Rechaza una selección que parta un par surrogate UTF-16, rechaza texto de reemplazo que contenga un surrogate alto o bajo sin pareja, y rechaza cualquier resultado más largo que MaxValueChars. Cazarlo en la capa de pulsación de teclas significa que la maquinaria de transacciones nunca tiene que deshacer un carácter a medio escribir del plano astral

Reconstruir en una lista privada, publicar en un solo intercambio

Una reconstrucción de widgets nunca debe observarse a medias, así que RebuildWidgets construye un TObjectList propietario completamente separado y lo intercambia con una única asignación al final. El motivo no es estética: TXFALayoutEngine.ComputeLayout corre mientras la reconstrucción está en vuelo y llama de vuelta al código anfitrión a través de la función MeasureText que suministraste, y puede lanzar EXFAWidgetRuntimeError al alcanzar el límite de widgets. Si el runtime mutara su lista viva en el sitio, cualquiera de las dos rutas dejaría al anfitrión con una lista que es en parte la disposición vieja y en parte la nueva, con punteros DataNode hacia un documento a punto de deshacerse. La convergencia del reflow la decide entonces LayoutSignature, una cadena construida con el recuento de widgets más cada ID, índice de página y caja envolvente redondeada a cuatro decimales: CommitEdit reconstruye, compara firmas y repite hasta que dos firmas consecutivas coinciden o el presupuesto de pasadas se agota. Cuando la firma no cambió en absoluto, LastReflowPasses se queda en 0, que es cómo distingues una edición de solo valores de una que de verdad creció el formulario, y el estado de interacción se arrastra entre reconstrucciones por el ID del widget, así que el foco y la edición en curso sobreviven a una inserción de fila

El runtime XFA de HotPDF reconstruye su lista de widgets en una lista propietaria separada mientras la disposición corre y llama al código de medición del anfitrión, y luego publica la lista terminada con una única asignación que el anfitrión no puede observar a medias
La reconstrucción ocurre en una lista privada porque ComputeLayout puede lanzar en pleno vuelo, y LayoutSignature decide cuándo dos reflows consecutivos han convergido

¿Por qué un campo vinculado leería el registro equivocado?

Porque el script corrió sin contexto de datos. Un campo con un <bind match="dataRef" ref="$record.actual"/> explícito y un campo nombrado como ese mismo nodo de datos son dos widgets distintos apuntando a un solo valor, y un subformulario repetido con <occur max="2"/> produce varios widgets que comparten nombre y solo difieren en la fila de datos a la que pertenecen; evalúa validación y cálculo contra la raíz del documento y todos resuelven this al primer nodo que encaje en todo el paquete datasets, así que la fila dos valida en silencio la fila uno. HotPDF lo evita guardando el DataNode resuelto en cada entrada de widget cuando la disposición lo produce, y pasando luego ese nodo por ambas llamadas a HPDFXFAEvaluateFieldScript, para xfskValidate y xfskCalculate por igual. El mismo contexto decide contra qué nodo crea EnsureValueNode cuando un cálculo apunta a un binding que aún no existe, y cuando no se puede resolver ningún binding la confirmación falla limpiamente con XFA calculation target is not bound en lugar de escribir en la fila equivocada. La semántica FormCalc detrás de esos scripts recuerda a lo que los documentos AcroForm obtienen de las acciones descritas en scripts de formato y calculate de AcroForm, pero las reglas de resolución aquí tienen scope XFA y no scope de nombre de campo

Los presupuestos se comprueban antes de los efectos secundarios, no después

Cada límite del runtime es una precondición, porque un presupuesto aplicado después de que la asignación ya ocurrió no es un presupuesto. TXFAWidgetRuntimeOptions.Default entrega MaxWidgets en 10000, MaxValueChars en 1048576, MaxCalculationPasses en 16 y MaxReflowPasses en 4, y los TXFAFormScriptOptions por defecto llevan MaxOperations en 100000 con MaxElapsedMilliseconds en 500. Por debajo, el DOM XFA aplica sus propias TXFADOMLimits: techos de 128 MB en entrada y salida descomprimidas, como mucho 1024 paquetes cosidos juntos, 1000000 de nodos y una profundidad de anidamiento de 256. Dos detalles importan más que las cifras mismas. Primero, los presupuestos de script son de toda la transacción y no por script: CommitEdit siembra un único contador de operaciones restantes y una fecha límite monótona, y cada invocación de validate y calculate consume ese mismo contador y recibe solo los milisegundos que quedan, así que un formulario con doscientos campos calculadores no puede gastar los 500 ms completos doscientas veces. Segundo, la fecha límite viene de una función MonotonicMilliseconds inyectable, que es lo que hace el comportamiento temporal reproducible en una suite de pruebas en lugar de una moneda al aire en un agente de build ocupado

Capas de presupuesto en el runtime XFA de HotPDF, desde los límites de widget y valor pasando por los límites de operaciones y tiempo de script hasta los techos del DOM XFA, con un contador de operaciones y una fecha límite compartidos por cada llamada de una transacción
Los presupuestos de script son de toda la transacción y no por script, así que doscientos campos calculadores no pueden reclamar cada uno 500 ms frescos
var
  Options: TXFAWidgetRuntimeOptions;
  Runtime: TXFAWidgetRuntime;
begin
  Options := TXFAWidgetRuntimeOptions.Default;
  Options.MaxWidgets := 2000;                              // por defecto 10000
  Options.MaxCalculationPasses := 8;                       // por defecto 16
  Options.MaxReflowPasses := 2;                            // por defecto 4
  Options.ScriptOptions.Limits.MaxOperations := 20000;     // toda la transacción
  Options.ScriptOptions.Limits.MaxElapsedMilliseconds := 200;
  Options.MeasureText :=
    function(const AText: UnicodeString; const AFont: TXFAFontSpec;
      AMaxWidth: Double): TXFATextExtent
    begin
      Result := MeasureWithHostCanvas(AText, AFont, AMaxWidth);
    end;
  Runtime := TXFAWidgetRuntime.Create(XDPBytes, 612, 792, Options);
  try
    Runtime.OnLayoutChanged :=
      procedure
      begin
        RepaintAllPages;   // disparado solo cuando el reflow movió widgets de verdad
      end;
    // ... manejar el formulario ...
  finally
    Runtime.Free;
  end;
end;

Dónde se detiene el runtime, y por qué lo dice en voz alta

El runtime deliberadamente no es un motor de scripting XFA general. DispatchEvent maneja nativamente las actividades enter y exit moviendo el foco, y para toda otra actividad que lleve un script se niega con un diagnóstico específico y estable en lugar de fingir: los scripts que mencionan addInstance, removeInstance o instanceManager devuelven XFA runtime does not support event-driven instance mutation, los scripts que tocan .presence devuelven el equivalente de presence, y todo lo demás devuelve XFA runtime does not support this event script. Una negación predecible sobre la que puedes ramificar vale más que una emulación parcial que funciona con tu archivo de muestra y diverge con el del cliente

El modelo de hilos es igual de directo: una instancia del runtime pertenece a un hilo, sin bloqueo interno, porque el motor de disposición llega hasta los callbacks de medición del anfitrión y un bloqueo alrededor de eso es un interbloqueo esperando un repintado. El contenido enriquecido dentro de los campos sigue la misma línea conservadora que en el resto de la biblioteca, donde las cargas exData se manejan como se describe en rich text e hipervínculos exData de XFA, y los widgets de firma y botón vuelven como ReadOnly mientras que los tipos de interfaz no soportados aparecen como xwkUnsupported y no como un cuadro de texto editable que pierde datos en silencio

Puesto junto, es una respuesta manejable al XFA dinámico en Delphi: mantener el DOM vivo, hacer de cada edición una transacción que o aterriza completa o no deja nada, acotar cada pasada y ser explícito con lo que queda fuera del alcance. Si lo estás evaluando para un flujo de siniestros, fiscal o de prestaciones, el runtime XFA se entrega como parte del componente PDF HotPDF para Delphi, junto a las rutas AcroForm, de aplanado y de renderizado que esos proyectos suelen acabar necesitando juntas