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