Articolo tecnico

Runtime XFA dinamico in Delphi: transazioni HotPDF

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

HotPDF tratta il commit di un campo XFA come una transazione, catturando il DOM serializzato, ogni stato dei widget, i contatori dei passaggi e il conteggio degli avvisi prima di validare, calcolare e rifare il layout, poi pubblicando o ripristinando tutti e quattro insieme
CommitEdit fa snapshot di quattro tipi di stato prima di scrivere qualsiasi cosa, così un validate, calculate o reflow fallito non lascia residui strutturali
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

Il runtime XFA di HotPDF ricostruisce la sua lista di widget in una lista proprietaria separata mentre il layout gira e richiama il codice di misurazione dell'host, poi pubblica la lista finita con un singolo assegnamento che l'host non può osservare a metà
Il rebuild avviene in una lista privata perché ComputeLayout può sollevare eccezioni a metà, e LayoutSignature decide quando due reflow consecutivi sono convergenti

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

Strati di budget nel runtime XFA di HotPDF, dai limiti di widget e valore ai limiti di operazioni e tempo degli script fino ai tetti del DOM XFA, con un contatore di operazioni e una scadenza condivisi da ogni chiamata in una transazione
I budget degli script valgono per la transazione intera anziché per script, così duecento campi calcolanti non possono ognuno rivendicare 500 ms freschi
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