Technisch artikel

Dynamic XFA-formulierruntime in Delphi: HotPDF-transacties

HotPDF vult dynamische XFA-formulieren in Delphi via TXFAWidgetRuntime, een hostneutrale widgetlaag die elke veldbewerking als één transactie behandelt: snapshot, validate, calculate, reflow en dan publiceren of volledig teruggedraaien. De laag draait single-threaded binnen uw eigen VCL- of FMX-host, heeft geen Acrobat-installatie nodig en handhaaft elk budget voordat er ook maar iets wordt toegewezen

Het scenario is bekend voor iedereen die documentsoftware heeft geleverd voor overheden of verzekeraars. Een schadeformulier of belastingaangifte komt binnen als PDF waarvan de pagina-inhoud één enkele "Please wait... if this message is not eventually replaced"-melding is, en elk echt veld ligt in een XFA-pakket dat alleen Adobe Acrobat rendert. Uw gebruikers willen het in uw eigen applicatie invullen. Uit rasteriseren komt u ook niet, want het formulier groeit rijen aan wanneer data wordt ingevoerd, en de lay-out na de derde rij is niet de lay-out die in het bestand zat

Waarom dynamische XFA nog steeds het oplossen waard is

Dynamische XFA blijft bestaan omdat uitgerolde formulieren het formaat dat ze droeg overleven. ISO 32000-1 §12.7.8 beschrijft XFA als een /XFA-entry in het AcroForm-dictionary met een XDP-pakketstream, en ISO 32000-2 markeert het hele mechanisme als verouderd; die depreciatie haalde het van de roadmap, niet uit het veld, en formulieren op basis van de XFA 3.3-specificatie worden nog steeds uitgegeven en zijn nog steeds juridisch bindend. Statische XFA is te reduceren tot gewone widgetannotaties, en HotPDF doet dat wanneer u ApplyXFAAsAcroForm aanroept, met de afwegingen die in XFA-formulieren afvlakken naar AcroForm-velden worden behandeld. Dynamische XFA is een ander beestje: de occur-bereiken, meegroeiende tekst en calculate-scripts maken de veldset een functie van de data, dus er is geen vaste annotatielijst om naar af te vlakken zolang de gebruiker nog aan het typen is. Precies dat gat vult TXFAWidgetRuntime, door de XFA-DOM levend te houden, na elke geaccepteerde bewerking de lay-out opnieuw te berekenen en uw host een platte array van gepositioneerde widgets te geven om te tekenen en te hit-testen

Wat geeft de runtime een hostapplicatie?

U krijgt geometrie en toestand, en niets dat een UI-toolkit veronderstelt. TXFAWidgetRuntime legt WidgetCount en Widgets[I] bloot als TXFAWidgetState-records met ID, Name, Kind, PageIndex, Bounds in PDF-punten, Value, EditValue en de vlaggen Focused, Editing, ReadOnly en Valid, terwijl tekenen, caret-tekening en toetsenbordroutering in uw eigen code blijven. Widgetidentiteit is stabiel en ordinaal: elke widget krijgt een ID van de vorm name[n], waarin n eerdere voorkomens van die veldnaam in lay-outvolgorde telt, dus de tweede rij van een herhalende subform is amount[1]. Die identiteit overleeft een rebuild, en het is ook wat FocusWidget, BeginEdit, DispatchEvent en HitTest allemaal spreken. Voor een document dat al open ligt in een THotPDF-instantie extraheert CreateLoadedXFAWidgetRuntime de XDP-pakketten, neemt de eerste page box als lay-outpaginaformaat en geeft nil terug wanneer het bestand helemaal geen XFA bevat

var
  Pdf: THotPDF;
  Runtime: TXFAWidgetRuntime;
  WidgetID: AnsiString;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('claim-dynamic.pdf');
    Runtime := Pdf.CreateLoadedXFAWidgetRuntime;   // nil wanneer er geen /XFA is
    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 paginaruimte, de bovenste widget wint
      if Runtime.HitTest(0, 120.0, 96.0, WidgetID) then
        Runtime.BeginEdit(WidgetID);
    finally
      Runtime.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

Wat moet atomisch zijn wanneer een veld wordt gecommit?

Alles wat de bewerking kan raken, en dat is aanzienlijk meer dan de veldwaarde. CommitEdit roept CaptureSnapshot aan voordat er iets wordt weggeschreven, en die snapshot omvat vier dingen: de geserialiseerde XFA-DOM uit TXFADocument.SaveToBytes, de volledige array met TXFAWidgetState-interactierecords, de tellers LastCalculationPasses en LastReflowPasses, en de huidige Warnings.Count. Alleen knooppuntwaarden opslaan is de verleidelijke shortcut en die is fout, want een calculate-script of een onopgeloste binding kan EnsureValueNode aanroepen en datanodes materialiseren die nog niet bestonden toen de bewerking begon; een herstel van alleen waarden kan ze niet weghalen, dus een afgewezen bewerking zou blijvende structurele restanten in het datasets-pakket achterlaten. De commitvolgorde zelf is streng — de kandidaatwaarde schrijven, validate draaien voor het bewerkte veld, calculate draaien tot een vast punt, en dan reflowen tot de lay-out stabiel is — en elke mislukking in elke fase loopt via FailAndRestore, dat de snapshotbytes in een verse TXFADocument laadt, de widgetlijst herbouwt, de vastgelegde interactietoestanden opnieuw toepast, de tellers reset en Warnings terugknipt tot zijn snapshotlengte. LastDiagnostic bevat bij mislukking de reden, en bevat de letterlijke tekst XFA transaction rollback failed in het pathologische geval waarin het herstel zelf een exceptie gooit

HotPDF behandelt een XFA-veldcommit als één transactie, legt de geserialiseerde DOM, elke widgettoestand, de passtellers en het aantal waarschuwingen vast voordat er wordt gevalideerd, berekend en gereflowd, en publiceert of herstelt daarna alle vier tegelijk
CommitEdit legt vier soorten toestand vast voordat er iets wordt weggeschreven, zodat een mislukte validate, calculate of reflow geen structurele restanten achterlaat
function EditAmount(Runtime: TXFAWidgetRuntime;
  const AWidgetID: AnsiString; const AText: UnicodeString): Boolean;
var
  Current: UnicodeString;
begin
  Result := False;
  if not Runtime.BeginEdit(AWidgetID) then
    Exit;                                   // alleen-lezen, of widget bestaat niet
  Current := Runtime.Widgets[Runtime.FocusedIndex].EditValue;
  if not Runtime.ReplaceSelection(0, Length(Current), AText) then
  begin
    Runtime.CancelEdit;                     // ongeldige range, of gesplitst surrogaat
    Exit;
  end;
  Result := Runtime.CommitEdit;             // alles-of-niets
  if not Result then
    // document, widgets, tellers en waarschuwingen staan alweer op de
    // toestand van vóór de bewerking; de gefocuste widget is alleen
    // als ongeldig gemarkeerd
    ShowMessage(Runtime.LastDiagnostic);
end;

ReplaceSelection verdient een eigen noot, want hier is misvormde input het goedkoopst af te wijzen. Het weigert een selectie die een UTF-16-surrogaatpaar splitst, weigert vervangende tekst met een ongepaarde hoge of lage surrogaat, en weigert elk resultaat langer dan MaxValueChars. Dat op toetsniveau afvangen betekent dat de transactiemachinerie nooit een halfgeschreven teken van het astrale vlak hoeft terug te draaien

Herbouwen in een privélijst, publiceren in één swap

Een widget-rebuild mag nooit half af waarneembaar zijn, dus RebuildWidgets bouwt een volledig aparte owning TObjectList en zet die op het einde met één toewijzing op zijn plek. De reden is geen esthetiek: TXFALayoutEngine.ComputeLayout draait terwijl de rebuild nog loopt en roept hostcode terug via de door u geleverde functie MeasureText, en kan een EXFAWidgetRuntimeError gooien zodra de widgetlimiet is bereikt. Als de runtime zijn live lijst ter plekke zou muteren, zou elk van beide paden de host met een lijst achterlaten die deels de oude en deels de nieuwe lay-out is, met DataNode-pointers naar een document dat op het punt staat teruggerold te worden. Reflow-convergentie wordt daarna beslist door LayoutSignature, een string opgebouwd uit het widgetaantal plus elke ID, pagina-index en bounding box afgerond op vier decimalen: CommitEdit herbouwt, vergelijkt signaturen en herhaalt tot twee opeenvolgende signaturen overeenkomen of het passbudget uitgeput is. Als de signature helemaal nooit veranderde, blijft LastReflowPasses op 0 staan, waaraan u een bewerking die alleen een waarde wijzigt onderscheidt van één die het formulier echt laat groeien, en interactietoestand wordt bij elke rebuild meegevoerd via de widget-ID, zodat focus en lopende bewerking een rij-insertie overleven

De HotPDF XFA-runtime herbouwt zijn widgetlijst in een aparte owning lijst terwijl de lay-out draait en hostmeetcallbacks aanroept, en publiceert de voltooide lijst daarna met één toewijzing die de host nooit half af kan waarnemen
De rebuild gebeurt in een privélijst omdat ComputeLayout halverwege een exceptie kan gooien, en LayoutSignature bepaalt wanneer twee opeenvolgende reflows zijn geconvergeerd

Waarom zou een gebonden veld de verkeerde record lezen?

Omdat het script zonder datacontext draaide. Een veld met een expliciete <bind match="dataRef" ref="$record.actual"/> en een veld dat naar diezelfde datanode is genoemd zijn twee verschillende widgets die naar één waarde wijzen, en een herhalende subform met <occur max="2"/> levert meerdere widgets die een naam delen en alleen verschillen in de datarij waartoe ze behoren; valideert en berekent u tegen de documentroot, dan lost elk van hen this op naar de eerste matchende node in het hele datasets-pakket, dus rij twee valideert stilletjes rij één. HotPDF voorkomt dat door de opgeloste DataNode op elke widgetentry op te slaan zodra de lay-out die oplevert, en die node daarna door beide HPDFXFAEvaluateFieldScript-aanroepen te voeren, voor xfskValidate en xfskCalculate eveneens. Dezelfde context bepaalt tegen welke node EnsureValueNode aanmaakt wanneer een berekening een binding richt die nog niet bestaat, en wanneer geen enkele binding op te lossen is, faalt de commit netjes met XFA calculation target is not bound in plaats van in de verkeerde rij te schrijven. De FormCalc-semantiek achter die scripts klinkt als wat AcroForm-documenten krijgen uit de actions die in AcroForm-format- en calculate-scripts worden beschreven, maar de resolutieregels hier zijn XFA-scoped en niet veldnaam-scoped

Budgets worden vóór bijwerkingen gecontroleerd, niet erna

Elke limiet in de runtime is een preconditie, want een budget dat pas wordt gehandhaafd nadat de toewijzing al is gebeurd is geen budget. TXFAWidgetRuntimeOptions.Default levert MaxWidgets op 10000, MaxValueChars op 1048576, MaxCalculationPasses op 16 en MaxReflowPasses op 4, en de standaard TXFAFormScriptOptions hebben MaxOperations op 100000 met MaxElapsedMilliseconds op 500. Daaronder hanteert de XFA-DOM zijn eigen TXFADOMLimits: plafonds van 128 MB op gedecomprimeerde in- en uitvoer, hooguit 1024 aan elkaar genaaide pakketten, 1000000 nodes en een nestingsdiepte van 256. Twee details wegen zwaarder dan de getallen zelf. Ten eerste zijn de scriptbudgets transactiebreed in plaats van per script: CommitEdit zaait één resterende-operaties-teller en één monotone deadline, en elke validate- en calculate-aanroep trekt aan diezelfde teller en krijgt alleen de nog resterende milliseconden, dus een formulier met tweehonderd rekenvelden kan de volledige 500 ms niet tweehonderd keer opmaken. Ten tweede komt de deadline uit een injecteerbare functie MonotonicMilliseconds, wat verstreken-tijdgedrag reproduceerbaar maakt in een testsuite in plaats van een muntgooi op een drukke build-agent

Budgetlagen in de HotPDF XFA-runtime, van widget- en waardelimieten via scriptoperatie- en tijdlimieten tot de XFA-DOM-plafonds, met één operatieteller en één deadline die elke aanroep in een transactie deelt
Scriptbudgets gelden transactiebreed in plaats van per script, dus tweehonderd rekenvelden kunnen niet elk een verse 500 ms opeisen
var
  Options: TXFAWidgetRuntimeOptions;
  Runtime: TXFAWidgetRuntime;
begin
  Options := TXFAWidgetRuntimeOptions.Default;
  Options.MaxWidgets := 2000;                              // standaard 10000
  Options.MaxCalculationPasses := 8;                       // standaard 16
  Options.MaxReflowPasses := 2;                            // standaard 4
  Options.ScriptOptions.Limits.MaxOperations := 20000;     // hele transactie
  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;   // wordt alleen gegeven wanneer reflow widgets echt verplaatst
      end;
    // ... het formulier bedienen ...
  finally
    Runtime.Free;
  end;
end;

Waar de runtime stopt, en waarom het dat luidop zegt

De runtime is met opzet geen algemene XFA-scriptingengine. DispatchEvent behandelt de enter- en exit-activiteiten native door de focus te verplaatsen, en voor elke andere activiteit met een script weigert het met een specifieke, stabiele diagnostic in plaats van te doen alsof: scripts die addInstance, removeInstance of instanceManager noemen geven XFA runtime does not support event-driven instance mutation terug, scripts die .presence raken geven het presence-equivalent, en al het restant geeft XFA runtime does not support this event script terug. Een voorspelbare weigering waar u in code op kunt inspelen verslaat een partiële emulatie die op uw voorbeeldbestand werkt en bij de klant uit de pas loopt

Het threadingmodel is even bot: één runtime-instantie hoort bij één thread, zonder interne locking, want de lay-outengine reikt terug naar hostmeetcallbacks en een lock daaromheen is een deadlock die op een repaint wacht. Rich content in velden volgt dezelfde conservatieve lijn als elders in de bibliotheek, waar exData-payloads worden afgehandeld zoals beschreven in XFA exData rich text en hyperlinks, en signature- en button-widgets komen als ReadOnly terug terwijl niet-ondersteunde UI-soorten als xwkUnsupported naar boven komen in plaats van als een bewerkbaar tekstvak dat stilletjes data verliest

Alles bij elkaar is dat een werkbaar antwoord op dynamische XFA in Delphi: houd de DOM levend, maak van elke bewerking een transactie die of volledig landt of niets achterlaat, zet elke pass op een budget, en wees expliciet over wat buiten scope ligt. Wie dit evalueert voor een workflow rond schadeclaims, belastingen of uitkeringen: de XFA-runtime wordt geleverd als onderdeel van de HotPDF Delphi PDF-component, samen met de AcroForm-, afvlakkings- en renderingpaden die zulke projecten doorgaans uiteindelijk gezamenlijk nodig hebben