Artículo técnico

Runtime de formularios XFA dinámicos en Delphi: HotPDF

HotPDF llena formularios XFA dinámicos en Delphi a través de TXFAWidgetRuntime, una capa de widgets neutral al host que trata cada edición de campo como una transacción: snapshot, validate, calculate, reflow y luego publicar o revertir todo. Corre en un solo hilo dentro de su propio host VCL o FMX, no necesita Acrobat instalado y hace cumplir cada presupuesto antes de asignar nada

El escenario les resulta familiar a quienes han entregado software de documentos al trabajo gubernamental o de seguros. Un formulario de reclamos o una declaración de impuestos llega como un PDF cuyo contenido de página es un único aviso de "Please wait... if this message is not eventually replaced", y cada campo real vive en un paquete XFA que solo Adobe Acrobat renderiza. Sus usuarios quieren llenarlo dentro de su aplicación. Tampoco pueden salir del paso rasterizando, porque el formulario crece en filas a medida que se ingresan datos, y la disposición después de la tercera fila no es la que venía en el archivo

Por qué XFA dinámico sigue siendo un problema que vale la pena resolver

XFA dinámico persiste porque los formularios desplegados sobreviven al formato que los cargó. ISO 32000-1 §12.7.8 describe XFA como una entrada /XFA en el diccionario AcroForm que contiene un stream de paquete XDP, e ISO 32000-2 desaprueba todo el mecanismo; la desaprobación lo quitó de la hoja de ruta, no del terreno, y los formularios creados contra la especificación XFA 3.3 se siguen emitiendo y siguen siendo legalmente vinculantes. XFA estático se puede reducir a anotaciones widget ordinarias, y HotPDF lo hace cuando ustedes llaman a ApplyXFAAsAcroForm, con las contrapartes cubiertas en aplanar formularios XFA en campos AcroForm. XFA dinámico es otra bestia: sus rangos occur, su texto que crece y sus scripts calculate hacen del conjunto de campos una función de los datos, así que no hay una lista fija de anotaciones a la que aplanar hasta que el usuario termine de escribir. Ese es el vacío que llena TXFAWidgetRuntime, manteniendo vivo el DOM XFA, recalculando la disposición después de cada edición aceptada y entregándole a su host un arreglo plano de widgets posicionados para dibujar y hacer hit-test

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

Les entrega geometría y estado, y nada que asuma un toolkit de UI. TXFAWidgetRuntime expone WidgetCount y Widgets[I] como records TXFAWidgetState que llevan ID, Name, Kind, PageIndex, Bounds en puntos PDF, Value, EditValue y los flags Focused, Editing, ReadOnly, Valid, mientras que el pintado, el dibujo del cursor y el enrutamiento del teclado quedan en su código. La identidad del widget es estable y ordinal: cada widget recibe un ID de la forma name[n], donde n cuenta las ocurrencias previas de ese nombre de campo en el orden de la disposición, así que la segunda fila de un subformulario repetible es amount[1]. Esa identidad es lo que sobrevive a una reconstrucción, y es lo que hablan por igual FocusWidget, BeginEdit, DispatchEvent y HitTest. Para un documento ya abierto en una instancia de THotPDF, CreateLoadedXFAWidgetRuntime extrae los paquetes XDP, toma el primer page box como tamaño de página de la disposición y devuelve nil cuando el archivo no trae XFA en absoluto

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 arreglo completo de records de interacción TXFAWidgetState, los contadores LastCalculationPasses y LastReflowPasses, y el Warnings.Count actual. Guardar solo los valores de nodos 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 commit en sí es estricta — escribir el valor candidato, correr validate para el campo editado, correr calculate hasta un punto fijo, y luego reflow hasta que la disposición sea estable — y cualquier falla 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 grabados, reinicia los contadores y trunca Warnings de vuelta a su longitud del snapshot. LastDiagnostic guarda la razón en caso de falla, y guarda el literal XFA transaction rollback failed en el caso patológico en que la restauración misma lance una excepción

HotPDF trata el commit de un campo XFA como una transacción, capturando el DOM serializado, cada estado de widget, los contadores de pasadas y el conteo de avisos antes de validar, calcular y hacer reflow, para luego publicar o restaurar los cuatro juntos
CommitEdit toma snapshot de cuatro tipos 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;                                   // de solo lectura, o no existe ese 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
    // documento, widgets, contadores y avisos ya volvieron al
    // estado previo a la edición; el widget enfocado queda marcado como inválido
    ShowMessage(Runtime.LastDiagnostic);
end;

ReplaceSelection merece una nota propia, porque es donde rechazar la entrada malformada sale más barato. Se niega a una selección que parta un par surrogate UTF-16, se niega a un texto de reemplazo que contenga un surrogate alto o bajo sin pareja, y se niega a cualquier resultado más largo que MaxValueChars. Detectar eso en la capa 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 medio terminar, así que RebuildWidgets construye un TObjectList propietario completamente separado y lo intercambia al lugar con una sola asignación al final. La razón no es estética: TXFALayoutEngine.ComputeLayout corre mientras la reconstrucción está en vuelo y llama de vuelta al código del host a través de la función MeasureText que ustedes suministraron, y puede lanzar EXFAWidgetRuntimeError cuando se alcanza el límite de widgets. Si el runtime mutara su lista viva en el sitio, cualquiera de los dos caminos dejaría al host sosteniendo una lista que es en parte la disposición vieja y en parte la nueva, con punteros DataNode hacia un documento que está a punto de revertirse. La convergencia del reflow la decide LayoutSignature, una cadena construida del conteo de widgets más cada ID, índice de página y bounding box redondeado a cuatro decimales: CommitEdit reconstruye, compara firmas y repite hasta que dos firmas consecutivas coincidan o el presupuesto de pasadas se agote. Cuando la firma nunca cambió, LastReflowPasses queda en 0, que es cómo distinguen una edición de solo valores de una que realmente creció el formulario, y el estado de interacción se transfiere 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 de vuelta al código de medición del host, y luego publica la lista terminada con una sola asignación que el host no puede observar a medias
La reconstrucción ocurre en una lista privada porque ComputeLayout puede lanzar una excepción a mitad de vuelo, y LayoutSignature decide cuándo dos reflows consecutivos han convergido

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

Porque el script corrió sin un contexto de datos. Un campo que lleva un <bind match="dataRef" ref="$record.actual"/> explícito y un campo nombrado como ese mismo nodo de datos son dos widgets distintos apuntados a un valor, y un subformulario repetible con <occur max="2"/> produce varios widgets que comparten nombre y difieren solo en la fila de datos a la que pertenecen; si evalúan validación y cálculo contra la raíz del documento, cada uno de ellos resuelve this al primer nodo coincidente 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 luego pasando ese nodo a través de ambas llamadas a HPDFXFAEvaluateFieldScript, tanto para xfskValidate como para xfskCalculate. El mismo contexto decide contra qué nodo crea EnsureValueNode cuando un cálculo apunta a un binding que todavía no existe, y cuando ningún binding se puede resolver, el commit 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 hace eco de lo que los documentos AcroForm reciben de las acciones descritas en scripts de formato y cálculo de AcroForm, pero las reglas de resolución aquí tienen alcance XFA y no de nombre de campo

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

Cada límite en el runtime es una precondición, porque un presupuesto que se hace cumplir después de que la asignación ya ocurrió no es un presupuesto. TXFAWidgetRuntimeOptions.Default trae 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. Debajo, el DOM XFA aplica sus propios TXFADOMLimits: topes de 128 MB en entrada y salida descomprimidas, como máximo 1024 paquetes cosidos juntos, 1000000 de nodos y una profundidad de anidamiento de 256. Dos detalles importan más que los números en sí. Primero, los presupuestos de scripts son de toda la transacción y no por script: CommitEdit siembra un único contador de operaciones restantes y una fecha límite monótonica, y cada invocación de validate y calculate descuenta de 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 de tiempo transcurrido 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 widgets y valores pasando por los límites de operaciones y tiempo de scripts hasta los topes del DOM XFA, con un contador de operaciones y una fecha límite compartidos por cada llamada de una transacción
Los presupuestos de scripts 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 realmente movió widgets
      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 general de scripts XFA. DispatchEvent maneja nativamente las actividades enter y exit moviendo el foco, y para cualquier 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. Un rechazo predecible sobre el que pueden ramificar vale más que una emulación parcial que funciona con su archivo de muestra y diverge con el del cliente

El modelo de subprocesos es igual de franco: una instancia del runtime pertenece a un hilo, sin bloqueos internos, porque el motor de disposición llega de vuelta a los callbacks de medición del host y un candado alrededor de eso es un deadlock esperando un repaint. El contenido enriquecido dentro de los campos sigue la misma línea conservadora que en el resto de la biblioteca, donde los payloads exData se manejan como se describe en texto enriquecido e hipervínculos de XFA exData, y los widgets de firma y de botón vuelven como ReadOnly mientras que los tipos de UI no soportados aparecen como xwkUnsupported en lugar de como un cuadro de texto editable que pierde datos en silencio

Puesto junto, esa es una respuesta manejable para XFA dinámico en Delphi: mantener vivo el DOM, hacer de cada edición una transacción que aterriza por completo o no deja nada atrás, acotar cada pasada y ser explícitos sobre lo que está fuera de alcance. Si lo están evaluando para un flujo de reclamos, impuestos o beneficios, el runtime XFA se entrega como parte del componente PDF HotPDF para Delphi, junto con los caminos de AcroForm, aplanado y renderizado que esos proyectos suelen terminar necesitando juntos