Technischer Artikel

Dynamische XFA-Formulare in Delphi: HotPDF-Transaktionen

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

HotPDF behandelt einen XFA-Feld-Commit als eine Transaktion und sichert das serialisierte DOM, jeden Widget-Zustand, die Durchlaufzähler und die Warnanzahl, bevor es validiert, berechnet und Reflow betreibt, um dann alle vier gemeinsam zu veröffentlichen oder wiederherzustellen
CommitEdit sichert vier Arten von Zustand, bevor es etwas schreibt, sodass ein gescheitertes Validate, Calculate oder Reflow keinen strukturellen Rest hinterlässt
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

Die HotPDF-XFA-Laufzeit baut ihre Widget-Liste in eine separate besitzende Liste um, während das Layout läuft und in Host-Messcode zurückruft, und veröffentlicht die fertige Liste mit einer einzigen Zuweisung, die der Host nicht halb fertig beobachten kann
Der Neuaufbau passiert in einer privaten Liste, weil ComputeLayout mittendrin eine Exception werfen kann, und LayoutSignature entscheidet, wann zwei aufeinanderfolgende Reflows konvergiert sind

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

Budgetschichten in der HotPDF-XFA-Laufzeit, von Widget- und Wertlimits über Skript-Operations- und Zeitlimits hinunter zu den XFA-DOM-Obergrenzen, mit einem Operationszähler und einer Frist, die sich alle Aufrufe einer Transaktion teilen
Skriptbudgets gelten transaktionsweit statt pro Skript, sodass zweihundert berechnende Felder sich nicht jeweils frische 500 ms sichern können
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