HotPDF compila i moduli XFA dinamici in Delphi attraverso TXFAWidgetRuntime, uno strato di widget neutro rispetto all'host che tratta ogni modifica di campo come una transazione: snapshot, validate, calculate, reflow, poi publish o rollback completo. Gira single-thread dentro il tuo host VCL o FMX, non richiede Acrobat installato e applica ogni budget prima di allocare qualsiasi cosa
Lo scenario è familiare a chiunque abbia spedito software documentale in ambito governativo o assicurativo. Un modulo di sinistro o una dichiarazione fiscale arriva come PDF il cui contenuto di pagina è un singolo avviso "Please wait... if this message is not eventually replaced", e ogni campo vero vive in un pacchetto XFA che solo Adobe Acrobat renderizza. I tuoi utenti vogliono compilarlo dentro la tua applicazione. Non puoi nemmeno uscirne rasterizzando, perché il modulo cresce di righe man mano che i dati vengono inseriti, e il layout dopo la terza riga non è il layout che era nel file
Perché l'XFA dinamico è ancora un problema che vale la pena risolvere
L'XFA dinamico persiste perché i moduli distribuiti sopravvivono al formato che li trasportava. ISO 32000-1 §12.7.8 descrive XFA come una voce /XFA sul dizionario AcroForm che contiene uno stream di pacchetto XDP, e ISO 32000-2 depreca l'intero meccanismo; la deprecazione l'ha tolto dalla roadmap, non dal campo, e i moduli scritti contro la specifica XFA 3.3 vengono ancora emessi e sono ancora legalmente vincolanti. L'XFA statico si può ridurre a normali annotazioni widget, e HotPDF lo fa quando chiami ApplyXFAAsAcroForm, con i compromessi trattati in flattening dei moduli XFA in campi AcroForm. L'XFA dinamico è un animale diverso: i suoi intervalli occur, il testo espandibile e gli script calculate rendono l'insieme dei campi una funzione dei dati, così non esiste una lista di annotazioni fissa su cui appiattire finché l'utente non ha finito di digitare. È il vuoto che TXFAWidgetRuntime riempie, tenendo vivo il DOM XFA, ricalcolando il layout dopo ogni modifica accettata e consegnando al tuo host un array piatto di widget posizionati da disegnare e su cui fare hit-test
Cosa consegna il runtime a un'applicazione host?
Ti consegna geometria e stato, e nulla che presupponga un toolkit UI. TXFAWidgetRuntime espone WidgetCount e Widgets[I] come record TXFAWidgetState che portano ID, Name, Kind, PageIndex, Bounds in punti PDF, Value, EditValue e i flag Focused, Editing, ReadOnly, Valid, mentre il disegno, il caret e il routing della tastiera restano nel tuo codice. L'identità del widget è stabile e ordinale: ogni widget riceve un ID della forma name[n], dove n conta le occorrenze precedenti di quel nome di campo in ordine di layout, così la seconda riga di un subform ripetuto è amount[1]. Quell'identità è ciò che sopravvive a un rebuild, ed è ciò con cui parlano FocusWidget, BeginEdit, DispatchEvent e HitTest. Per un documento già aperto in un'istanza THotPDF, CreateLoadedXFAWidgetRuntime estrae i pacchetti XDP, prende il primo page box come dimensione della pagina di layout e restituisce nil quando il file non contiene proprio XFA
var
Pdf: THotPDF;
Runtime: TXFAWidgetRuntime;
WidgetID: AnsiString;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.LoadFromFile('claim-dynamic.pdf');
Runtime := Pdf.CreateLoadedXFAWidgetRuntime; // nil quando non c'è /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 in spazio pagina, vince il widget più in alto
if Runtime.HitTest(0, 120.0, 96.0, WidgetID) then
Runtime.BeginEdit(WidgetID);
finally
Runtime.Free;
end;
finally
Pdf.Free;
end;
end;
Cosa deve essere atomico quando un campo viene confermato?
Tutto ciò che la modifica può toccare, che è parecchio più del valore del campo. CommitEdit chiama CaptureSnapshot prima di scrivere qualsiasi cosa, e quello snapshot copre quattro elementi: il DOM XFA serializzato da TXFADocument.SaveToBytes, l'intero array dei record di interazione TXFAWidgetState, i contatori LastCalculationPasses e LastReflowPasses, e l'attuale Warnings.Count. Salvare solo i valori dei nodi è la scorciatoia allettante ed è sbagliata, perché uno script calculate o un binding irrisolto può chiamare EnsureValueNode e materializzare nodi dati che non esistevano quando la modifica è iniziata; un ripristino dei soli valori non ha modo di rimuoverli, così una modifica respinta lascerebbe residui strutturali permanenti nel pacchetto datasets. La sequenza di commit in sé è severa — scrive il valore candidato, esegue validate per il campo modificato, esegue calculate fino a un punto fisso, poi reflow finché il layout non è stabile — e qualsiasi fallimento in qualsiasi stadio passa per FailAndRestore, che ricarica i byte dello snapshot in un TXFADocument fresco, ricostruisce la lista dei widget, riapplica gli stati di interazione registrati, azzera i contatori e tronca Warnings alla sua lunghezza snapshot. LastDiagnostic contiene il motivo in caso di fallimento, e contiene la letterale XFA transaction rollback failed nel caso patologico in cui il ripristino stesso sollevi un'eccezione
function EditAmount(Runtime: TXFAWidgetRuntime;
const AWidgetID: AnsiString; const AText: UnicodeString): Boolean;
var
Current: UnicodeString;
begin
Result := False;
if not Runtime.BeginEdit(AWidgetID) then
Exit; // read-only, o widget inesistente
Current := Runtime.Widgets[Runtime.FocusedIndex].EditValue;
if not Runtime.ReplaceSelection(0, Length(Current), AText) then
begin
Runtime.CancelEdit; // intervallo errato, o surrogate diviso
Exit;
end;
Result := Runtime.CommitEdit; // tutto o niente
if not Result then
// documento, widget, contatori e avvisi sono già tornati allo stato
// pre-modifica; il widget con focus è semplicemente marcato invalido
ShowMessage(Runtime.LastDiagnostic);
end;
ReplaceSelection merita una nota a sé, perché è il punto più economico per respingere l'input malformato. Rifiuta una selezione che spezza una coppia surrogate UTF-16, rifiuta testo di sostituzione contenente un high o low surrogate non appaiato, e rifiuta qualsiasi risultato più lungo di MaxValueChars. Accorgersene al livello del tasto premuto significa che la macchina a transazioni non deve mai smontare un carattere fuori dal piano base scritto a metà
Ricostruire in una lista privata, pubblicare con un solo scambio
Un rebuild dei widget non deve mai essere osservabile a metà, così RebuildWidgets costruisce una TObjectList proprietaria completamente separata e la scambia in posizione con un singolo assegnamento alla fine. Il motivo non è estetico: TXFALayoutEngine.ComputeLayout gira mentre il rebuild è in volo e richiama codice host attraverso la funzione MeasureText che hai fornito, e può sollevare EXFAWidgetRuntimeError quando il limite dei widget viene raggiunto. Se il runtime mutasse la sua lista viva sul posto, uno dei due percorsi lascerebbe l'host con una lista fatta in parte del vecchio layout e in parte del nuovo, con puntatori DataNode dentro un documento che sta per essere oggetto di rollback. La convergenza del reflow è poi decisa da LayoutSignature, una stringa costruita dal numero dei widget più ogni ID, indice di pagina e bounding box arrotondato a quattro decimali: CommitEdit ricostruisce, confronta le firme e ripete finché due firme consecutive non coincidono o il budget di passaggi non si esaurisce. Quando la firma non è mai cambiata, LastReflowPasses resta 0, ed è così che distingui una modifica di solo valore da una che ha davvero fatto crescere il modulo, e lo stato di interazione viene portato attraverso ogni rebuild per ID del widget, così focus ed editing in corso sopravvivono a un inserimento di riga
Perché un campo associato leggerebbe il record sbagliato?
Perché lo script è girato senza un contesto dati. Un campo che porta un esplicito <bind match="dataRef" ref="$record.actual"/> e un campo nominato come quello stesso nodo dati sono due widget diversi puntati a un valore, e un subform ripetuto con <occur max="2"/> produce parecchi widget che condividono un nome e differiscono solo per la riga dati a cui appartengono; valuta validazione e calcolo contro la radice del documento e ognuno di loro risolve this nel primo nodo corrispondente dell'intero pacchetto datasets, così la riga due valida silenziosamente la riga uno. HotPDF lo evita memorizzando il DataNode risolto su ogni voce del widget quando il layout lo produce, poi passando quel nodo attraverso entrambe le chiamate HPDFXFAEvaluateFieldScript, per xfskValidate e xfskCalculate allo stesso modo. Lo stesso contesto decide contro quale nodo EnsureValueNode crea quando un calcolo punta a un binding che non esiste ancora, e quando nessun binding può essere risolto il commit fallisce pulito con XFA calculation target is not bound invece di scrivere nella riga sbagliata. La semantica FormCalc dietro quegli script fa eco a ciò che i documenti AcroForm ottengono dalle azioni descritte in script di formato e calcolo AcroForm, ma le regole di risoluzione qui sono scoped XFA anziché per nome di campo
I budget vengono controllati prima degli effetti collaterali, non dopo
Ogni limite nel runtime è una precondizione, perché un budget applicato dopo che l'allocazione è già avvenuta non è un budget. TXFAWidgetRuntimeOptions.Default spedisce MaxWidgets a 10000, MaxValueChars a 1048576, MaxCalculationPasses a 16 e MaxReflowPasses a 4, e le TXFAFormScriptOptions predefinite portano MaxOperations a 100000 con MaxElapsedMilliseconds a 500. Sotto, il DOM XFA applica i suoi TXFADOMLimits: tetti di 128 MB su input e output decompressi, al massimo 1024 pacchetti cuciti insieme, 1000000 di nodi, e una profondità di annidamento di 256. Due dettagli contano più dei numeri stessi. Primo, i budget degli script valgono per l'intera transazione anziché per script: CommitEdit semina un unico contatore di operazioni rimanenti e una sola scadenza monotona, e ogni invocazione di validate e calculate attinge quello stesso contatore e riceve solo i millisecondi rimasti, così un modulo con duecento campi calcolanti non può spendere i 500 ms pieni duecento volte. Secondo, la scadenza arriva da una funzione MonotonicMilliseconds iniettabile, ed è ciò che rende il comportamento temporale riproducibile in una suite di test invece di un lancio di moneta su un build agent affollato
var
Options: TXFAWidgetRuntimeOptions;
Runtime: TXFAWidgetRuntime;
begin
Options := TXFAWidgetRuntimeOptions.Default;
Options.MaxWidgets := 2000; // predefinito 10000
Options.MaxCalculationPasses := 8; // predefinito 16
Options.MaxReflowPasses := 2; // predefinito 4
Options.ScriptOptions.Limits.MaxOperations := 20000; // intera transazione
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; // scatenato solo quando il reflow ha davvero spostato widget
end;
// ... guidi il modulo ...
finally
Runtime.Free;
end;
end;
Dove il runtime si ferma, e perché lo dice ad alta voce
Il runtime deliberatamente non è un motore di scripting XFA generale. DispatchEvent gestisce nativamente le attività enter e exit spostando il focus, e per ogni altra attività che porta uno script rifiuta con un diagnostico specifico e stabile invece di fingere: gli script che menzionano addInstance, removeInstance o instanceManager restituiscono XFA runtime does not support event-driven instance mutation, gli script che toccano .presence restituiscono l'equivalente per presence, e qualsiasi altra cosa restituisce XFA runtime does not support this event script. Un rifiuto prevedibile su cui poter fare branch batte un'emulazione parziale che funziona sul tuo file di esempio e diverge su quello del cliente
Il modello di threading è altrettanto netto: un'istanza del runtime appartiene a un thread, senza locking interno, perché il motore di layout torna indietro nelle callback di misurazione dell'host e un lock attorno a quello è un deadlock in attesa di un repaint. Il contenuto ricco dentro i campi segue la stessa linea conservativa del resto della libreria, dove i payload exData sono gestiti come descritto in rich text e hyperlink exData XFA, e i widget di firma e pulsante tornano come ReadOnly mentre i tipi UI non supportati emergono come xwkUnsupported anziché come una casella di testo modificabile che perde dati in silenzio
Messo insieme, è una risposta praticabile all'XFA dinamico in Delphi: tenere vivo il DOM, fare di ogni modifica una transazione che o atterra completamente o non lascia nulla, limitare ogni passaggio, ed essere espliciti su ciò che è fuori ambito. Se lo stai valutando per un flusso di sinistri, fiscale o di prestazioni, il runtime XFA viene spedito come parte del componente HotPDF Delphi PDF, accanto ai percorsi AcroForm, flattening e rendering che quei progetti di solito finiscono per volere insieme