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
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
¿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
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