HotPDF füllt dynamische XFA-Formulare in Delphi über TXFAWidgetRuntime, eine hostneutrale Widget-Schicht, die jede Feldbearbeitung als eine Transaktion behandelt: Snapshot, Validate, Calculate, Reflow, dann ganz veröffentlichen oder ganz zurückrollen. Sie läuft single-threaded in Ihrem eigenen VCL- oder FMX-Host, braucht kein installiertes Acrobat und erzwingt jedes Budget, bevor sie irgendetwas allokiert
Das Szenario kennt jeder, der Dokumentensoftware in Behörden- oder Versicherungsprojekte ausgeliefert hat. Ein Schadensformular oder eine Steuererklärung kommt als PDF, dessen Seiteninhalt ein einziger Hinweis „Please wait... if this message is not eventually replaced" ist, und jedes echte Feld steckt in einem XFA-Paket, das nur Adobe Acrobat rendert. Ihre Nutzer wollen es in Ihrer Anwendung ausfüllen. Rasterisieren rettet Sie auch nicht, denn das Formular wächst um Zeilen, während Daten eingegeben werden, und das Layout nach der dritten Zeile ist nicht das Layout, das in der Datei steckte
Warum dynamisches XFA noch immer ein lösenswertes Problem ist
Dynamisches XFA hält sich, weil ausgelieferte Formulare das Format überleben, das sie trug. ISO 32000-1 §12.7.8 beschreibt XFA als einen /XFA-Eintrag im AcroForm-Dictionary, der einen XDP-Paketstream hält, und ISO 32000-2 veraltet den ganzen Mechanismus; die Veraltung nahm ihn aus der Roadmap, nicht aus dem Feld, und Formulare nach der XFA-3.3-Spezifikation werden weiterhin ausgestellt und sind weiterhin rechtsverbindlich. Statisches XFA lässt sich auf gewöhnliche Widget-Annotationen reduzieren, und HotPDF tut das, wenn Sie ApplyXFAAsAcroForm aufrufen; die Abwägungen stehen im Artikel zum Abflachen von XFA-Formularen in AcroForm-Felder. Dynamisches XFA ist ein anderes Tier: Seine occur-Bereiche, wachsfähigen Texte und calculate-Skripte machen die Feldmenge zu einer Funktion der Daten, daher gibt es keine feste Annotationsliste, auf die man abflachen könnte, bevor der Nutzer fertig getippt hat. Diese Lücke füllt TXFAWidgetRuntime, indem es das XFA-DOM am Leben hält, nach jeder akzeptierten Bearbeitung das Layout neu berechnet und Ihrem Host ein flaches Array positionierter Widgets zum Zeichnen und Hit-Testen übergibt
Was übergibt die Laufzeit einer Host-Anwendung?
Sie bekommen Geometrie und Zustand, und nichts, was ein UI-Toolkit voraussetzt. TXFAWidgetRuntime legt WidgetCount und Widgets[I] als TXFAWidgetState-Records offen, die ID, Name, Kind, PageIndex, Bounds in PDF-Punkten, Value, EditValue und die Flags Focused, Editing, ReadOnly, Valid tragen, während Zeichnen, Caret-Rendering und Tastatur-Routing in Ihrem Code bleiben. Die Widget-Identität ist stabil und ordinal: Jedes Widget bekommt eine ID der Form name[n], wobei n die bisherigen Vorkommen dieses Feldnamens in Layoutreihenfolge zählt, sodass die zweite Zeile eines wiederholten Subforms amount[1] ist. Diese Identität überlebt einen Neuaufbau, und sie ist die Sprache, die FocusWidget, BeginEdit, DispatchEvent und HitTest alle sprechen. Für ein in einer THotPDF-Instanz bereits offenes Dokument extrahiert CreateLoadedXFAWidgetRuntime die XDP-Pakete, nimmt die erste Page Box als Layout-Seitengröße und liefert nil, wenn die Datei gar kein XFA trägt
var
Pdf: THotPDF;
Runtime: TXFAWidgetRuntime;
WidgetID: AnsiString;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.LoadFromFile('claim-dynamic.pdf');
Runtime := Pdf.CreateLoadedXFAWidgetRuntime; // nil, wenn kein /XFA da ist
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 im Seitenraum, das oberste Widget gewinnt
if Runtime.HitTest(0, 120.0, 96.0, WidgetID) then
Runtime.BeginEdit(WidgetID);
finally
Runtime.Free;
end;
finally
Pdf.Free;
end;
end;
Was muss atomar sein, wenn ein Feld festgeschrieben wird?
Alles, was die Bearbeitung anfassen kann, und das ist deutlich mehr als der Feldwert. CommitEdit ruft CaptureSnapshot auf, bevor es irgendetwas schreibt, und dieser Snapshot deckt vier Dinge ab: das serialisierte XFA-DOM aus TXFADocument.SaveToBytes, das vollständige Array der TXFAWidgetState-Interaktionsdatensätze, die Zähler LastCalculationPasses und LastReflowPasses sowie das aktuelle Warnings.Count. Nur Knotenwerte zu sichern ist der verlockende Kurzweg, und er ist falsch, denn ein calculate-Skript oder eine unaufgelöste Bindung kann EnsureValueNode aufrufen und Datenknoten materialisieren, die es beim Beginn der Bearbeitung nicht gab; eine reine Wert-Wiederherstellung hat keine Möglichkeit, sie zu entfernen, also würde eine abgelehnte Bearbeitung dauerhaften strukturellen Rest im Datasets-Paket hinterlassen. Die Commit-Folge selbst ist streng — Kandidatenwert schreiben, validate für das bearbeitete Feld ausführen, calculate bis zum Fixpunkt laufen lassen, dann Reflow, bis das Layout stabil ist — und jeder Fehler auf jeder Stufe läuft über FailAndRestore, das die Snapshot-Bytes in ein frisches TXFADocument lädt, die Widget-Liste neu aufbaut, die aufgezeichneten Interaktionszustände erneut anwendet, die Zähler zurücksetzt und Warnings auf seine Snapshot-Länge stutzt. LastDiagnostic hält bei Fehlschlag den Grund und im pathologischen Fall, dass die Wiederherstellung selbst eine Exception wirft, das Literal XFA transaction rollback failed
function EditAmount(Runtime: TXFAWidgetRuntime;
const AWidgetID: AnsiString; const AText: UnicodeString): Boolean;
var
Current: UnicodeString;
begin
Result := False;
if not Runtime.BeginEdit(AWidgetID) then
Exit; // nur-lesend, oder kein solches Widget
Current := Runtime.Widgets[Runtime.FocusedIndex].EditValue;
if not Runtime.ReplaceSelection(0, Length(Current), AText) then
begin
Runtime.CancelEdit; // schlechter Bereich, oder gespaltene Surrogate
Exit;
end;
Result := Runtime.CommitEdit; // alles oder nichts
if not Result then
// Dokument, Widgets, Zähler und Warnungen sind bereits wieder im
// Zustand vor der Bearbeitung; das fokussierte Widget ist schlicht ungültig markiert
ShowMessage(Runtime.LastDiagnostic);
end;
ReplaceSelection verdient eine eigene Anmerkung, denn hier ist fehlerhafte Eingabe am billigsten zurückzuweisen. Es lehnt eine Auswahl ab, die ein UTF-16-Surrogatpaar spaltet, lehnt Ersatztext mit einem ungepaarten High- oder Low-Surrogat ab und lehnt jedes Ergebnis länger als MaxValueChars ab. Das auf der Tastenanschlagsebene abzufangen bedeutet, dass die Transaktionsmechanik nie ein halb geschriebenes Zeichen außerhalb der BMP zurückrollen muss
In eine private Liste umbauen, mit einem Swap veröffentlichen
Ein Widget-Neuaufbau darf nie halb fertig beobachtbar sein, darum baut RebuildWidgets eine völlig separate besitzende TObjectList und tauscht sie am Ende mit einer einzigen Zuweisung an die Stelle. Der Grund ist nicht Ästhetik: TXFALayoutEngine.ComputeLayout läuft, während der Neuaufbau unterwegs ist, ruft über die von Ihnen gelieferte Funktion MeasureText in Host-Code zurück und kann beim Erreichen des Widget-Limits eine EXFAWidgetRuntimeError werfen. Würde die Laufzeit ihre lebende Liste an Ort und Stelle verändern, würde jeder der beiden Pfade den Host mit einer Liste zurücklassen, die teils das alte und teils das neue Layout ist, mit DataNode-Zeigern in ein Dokument, das gleich zurückgerollt wird. Die Reflow-Konvergenz entscheidet dann LayoutSignature, ein String aus der Widget-Anzahl plus jeder ID, jedem Seitenindex und jeder auf vier Nachkommastellen gerundeten Bounding Box: CommitEdit baut neu, vergleicht Signaturen und wiederholt, bis zwei aufeinanderfolgende Signaturen übereinstimmen oder das Durchlaufbudget erschöpft ist. Hat sich die Signatur nie verändert, bleibt LastReflowPasses bei 0, daran erkennen Sie eine reine Wertbearbeitung von einer, die das Formular tatsächlich wachsen ließ, und der Interaktionszustand wandert bei jedem Neuaufbau über die Widget-ID mit, sodass Fokus und laufende Bearbeitung eine Zeileneinfügung überleben
Warum liest ein gebundenes Feld den falschen Datensatz?
Weil das Skript ohne Datenkontext lief. Ein Feld mit einer expliziten <bind match="dataRef" ref="$record.actual"/> und ein Feld, das nach eben jenem Datenknoten benannt ist, sind zwei verschiedene Widgets, die auf denselben Wert zeigen, und ein wiederholtes Subform mit <occur max="2"/> erzeugt mehrere Widgets, die sich einen Namen teilen und sich nur darin unterscheiden, zu welcher Datenzeile sie gehören; bewertet man Validierung und Berechnung gegen den Dokumentwurzelknoten, löst jedes davon this zum ersten passenden Knoten im ganzen Datasets-Paket auf, also validiert Zeile zwei stillschweigend Zeile eins. HotPDF vermeidet das, indem es den aufgelösten DataNode beim Erzeugen im Widget-Eintrag speichert und diesen Knoten dann durch beide HPDFXFAEvaluateFieldScript-Aufrufe reicht, für xfskValidate wie für xfskCalculate. Derselbe Kontext entscheidet, gegen welchen Knoten EnsureValueNode erzeugt, wenn eine Berechnung auf eine noch nicht existierende Bindung zielt, und wenn sich keine Bindung auflösen lässt, scheitert der Commit sauber mit XFA calculation target is not bound, statt in die falsche Zeile zu schreiben. Die FormCalc-Semantik hinter diesen Skripten spiegelt, was AcroForm-Dokumente aus den im Artikel zu AcroForm-Format- und Calculate-Skripten beschriebenen Aktionen bekommen, aber die Auflösungsregeln sind hier am XFA-Scope ausgerichtet statt am Feldnamen
Budgets werden vor Nebenwirkungen geprüft, nicht danach
Jedes Limit in der Laufzeit ist eine Vorbedingung, denn ein Budget, das erst nach geschehener Allokation durchgesetzt wird, ist kein Budget. TXFAWidgetRuntimeOptions.Default liefert MaxWidgets mit 10000, MaxValueChars mit 1048576, MaxCalculationPasses mit 16 und MaxReflowPasses mit 4, und die Standard-TXFAFormScriptOptions tragen MaxOperations mit 100000 und MaxElapsedMilliseconds mit 500. Darunter setzt das XFA-DOM seine eigenen TXFADOMLimits an: 128-MB-Obergrenzen für dekomprimierte Ein- und Ausgabe, höchstens 1024 zusammengesetzte Pakete, 1000000 Knoten und eine Verschachtelungstiefe von 256. Zwei Details wiegen schwerer als die Zahlen selbst. Erstens sind die Skriptbudgets transaktionsweit statt pro Skript: CommitEdit sät einen einzigen Restoperationszähler und eine monotone Frist, und jeder Validate- und Calculate-Aufruf zählt denselben Zähler herunter und bekommt nur die noch übrigen Millisekunden, sodass ein Formular mit zweihundert berechnenden Feldern die vollen 500 ms nicht zweihundertfach verbrauchen kann. Zweitens kommt die Frist aus einer injizierbaren Funktion MonotonicMilliseconds, was das Verhalten bei verstrichener Zeit in einer Testsuite reproduzierbar macht statt zu einem Münzwurf auf einem ausgelasteten Build-Agenten
var
Options: TXFAWidgetRuntimeOptions;
Runtime: TXFAWidgetRuntime;
begin
Options := TXFAWidgetRuntimeOptions.Default;
Options.MaxWidgets := 2000; // Standard 10000
Options.MaxCalculationPasses := 8; // Standard 16
Options.MaxReflowPasses := 2; // Standard 4
Options.ScriptOptions.Limits.MaxOperations := 20000; // ganze Transaktion
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; // feuert nur, wenn Reflow Widgets wirklich verschoben hat
end;
// ... das Formular bedienen ...
finally
Runtime.Free;
end;
end;
Wo die Laufzeit endet, und warum sie das laut sagt
Die Laufzeit ist absichtlich keine allgemeine XFA-Skript-Engine. DispatchEvent behandelt die enter- und exit-Aktivitäten nativ durch Fokusverschiebung, und für jede andere Aktivität mit Skript verweigert sie sich mit einer spezifischen, stabilen Diagnose, statt zu simulieren: Skripte, die addInstance, removeInstance oder instanceManager erwähnen, liefern XFA runtime does not support event-driven instance mutation, Skripte, die .presence anfassen, das entsprechende Presence-Äquivalent, und alles andere liefert XFA runtime does not support this event script. Eine vorhersagbare Verweigerung, auf die man verzweigen kann, schlägt eine partielle Emulation, die auf Ihrer Beispieldatei funktioniert und beim Kunden abweicht
Das Threading-Modell ist ebenso ungeschminkt: Eine Laufzeitinstanz gehört zu einem Thread, ohne interne Sperren, denn die Layout-Engine greift in Host-Mess-Callbacks zurück, und eine Sperre darum ist ein Deadlock, der auf einen Repaint wartet. Rich Content in Feldern folgt derselben konservativen Linie wie sonst in der Bibliothek, wo exData-Payloads wie im Artikel zu XFA-exData-Rich-Text und Hyperlinks beschrieben behandelt werden, und Signatur- und Button-Widgets kommen als ReadOnly zurück, während nicht unterstützte UI-Arten als xwkUnsupported erscheinen, nicht als editierbares Textfeld, das still Daten verliert
Zusammengenommen ist das eine arbeitsfähige Antwort auf dynamisches XFA in Delphi: das DOM am Leben halten, jede Bearbeitung zu einer Transaktion machen, die entweder ganz ankommt oder nichts hinterlässt, jeden Durchlauf begrenzen und klar sagen, was außerhalb des Rahmens liegt. Wenn Sie das für einen Schadens-, Steuer- oder Leistungs-Workflow evaluieren, kommt die XFA-Laufzeit als Teil der HotPDF Delphi PDF component, neben den AcroForm-, Abflachungs- und Rendering-Pfaden, die solche Projekte meist am Ende zusammen brauchen