Technický článek

Dynamický XFA runtime v Delphi: transakce HotPDF

HotPDF vyplňuje dynamické XFA formuláře v Delphi přes TXFAWidgetRuntime, vrstvu widgetů neutrální k hostu, která bere každou úpravu pole jako jednu transakci: snapshot, validate, calculate, reflow, pak publikace nebo vrácení celku. Běží jednovlákně uvnitř vašeho vlastního hosta VCL nebo FMX, nevyžaduje nainstalovaný Acrobat a vymáhá každý rozpočet dřív, než cokoli alokuje

Scénář zná každý, kdo expedoval dokumentní software do státní správy nebo pojišťovnictví. Přihláška škody nebo daňové přiznání přijde jako PDF, jehož obsah stránky je jediné oznámení „Please wait... if this message is not eventually replaced", a každé skutečné pole žije v paketu XFA, který vykresluje jen Adobe Acrobat. Vaši uživatelé ho chtějí vyplňovat uvnitř vaší aplikace. Ani rasterizací se z toho nedostanete, protože formulář roste o řádky podle zadávaných dat a rozvržení po třetím řádku není rozvržení, které v souboru dorazilo

Proč je dynamické XFA pořád problém za vyřešení

Dynamické XFA přetrvává, protože nasazené formuláře přežijí formát, který je nesl. ISO 32000-1 §12.7.8 popisuje XFA jako položku /XFA ve slovníku AcroForm držící XDP paketový proud a ISO 32000-2 celý mechanismus označuje za zastaralý; deprecace ho sundala z plánu, ne z terénu, a formuláře psané proti specifikaci XFA 3.3 se stále vydávají a jsou stále právně závazné. Statické XFA lze zredukovat na obyčejné widgetové anotace a HotPDF to udělá, zavoláte-li ApplyXFAAsAcroForm, s kompromisy popsanými v zploštění XFA formulářů do polí AcroForm. Dynamické XFA je jiná bestie: jeho rozsahy occur, rostoucí text a skripty calculate dělají ze sady polí funkci dat, takže pevný seznam anotací, na který by se dalo zploštit, neexistuje, dokud uživatel nedopíše. Tu mezeru zaplňuje TXFAWidgetRuntime, který drží XFA DOM naživu, přepočítává rozvržení po každé přijaté úpravě a hostu podává ploché pole umístěných widgetů k vykreslení a hit-testu

Co runtime předává hostitelské aplikaci?

Předává geometrii a stav a nic, co by předpokládalo UI toolkit. TXFAWidgetRuntime vystavuje WidgetCount a Widgets[I] jako záznamy TXFAWidgetState nesoucí ID, Name, Kind, PageIndex, Bounds v PDF bodech, Value, EditValue a příznaky Focused, Editing, ReadOnly, Valid, zatímco malování, kreslení caretu a směrování klávesnice zůstávají ve vašem kódu. Identita widgetu je stabilní a pořadová: každý widget dostane ID ve tvaru name[n], kde n počítá předchozí výskyty toho pole v pořadí rozvržení, takže druhý řádek opakovaného subformu je amount[1]. Ta identita je to, co přežije rebuild, a tím mluví FocusWidget, BeginEdit, DispatchEvent i HitTest. Pro dokument už otevřený v instanci THotPDF extrahuje CreateLoadedXFAWidgetRuntime XDP pakety, bere první page box jako velikost stránky rozvržení a vrací nil, když soubor nenese žádné 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, když chybí /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 v prostoru stránky, vítězí nejvrchnější widget
      if Runtime.HitTest(0, 120.0, 96.0, WidgetID) then
        Runtime.BeginEdit(WidgetID);
    finally
      Runtime.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

Co musí být atomické, když se pole zapisuje?

Všechno, čeho se úprava může dotknout, což je značně víc než hodnota pole. CommitEdit volá CaptureSnapshot, než cokoli zapíše, a ten snapshot pokrývá čtyři věci: serializovaný XFA DOM z TXFADocument.SaveToBytes, celé pole interakčních záznamů TXFAWidgetState, počítadla LastCalculationPasses a LastReflowPasses a aktuální Warnings.Count. Ukládat samotné hodnoty uzlů je lákavá zkratka a je to špatně, protože skript calculate nebo nevyřešená vazba může zavolat EnsureValueNode a hmotnit datové uzly, které v době začátku úpravy neexistovaly; obnova jen hodnot je nemá jak odstranit, takže zamítnutá úprava by zanechala trvalý strukturální sediment v paketu datasets. Sama sekvence zápisu je přísná — zapíše kandidátní hodnotu, spustí validate pro upravené pole, spustí calculate k pevnému bodu, pak reflow, dokud rozvržení není stabilní — a jakékoli selhání v jakékoli fázi míří přes FailAndRestore, které nahraje snapshot bajty do čerstvého TXFADocument, znovu postaví seznam widgetů, znovu aplikuje zaznamenané interakční stavy, vynuluje počítadla a zkrátí Warnings zpět na délku snapshotu. LastDiagnostic drží důvod při selhání a drží literál XFA transaction rollback failed v patologickém případě, kdy samotná obnova vyhodí výjimku

HotPDF bere zápis pole XFA jako jednu transakci a před validací, kalkulací a reflow zachytí serializovaný DOM, stav každého widgetu, počítadla průchodů i počet varování, pak publikuje nebo obnoví všech čtyři dohromady
CommitEdit zachytí čtyři druhy stavu, než cokoli zapíše, takže selhaná validate, calculate nebo reflow nezanechá strukturální sediment
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, nebo žádný takový widget
  Current := Runtime.Widgets[Runtime.FocusedIndex].EditValue;
  if not Runtime.ReplaceSelection(0, Length(Current), AText) then
  begin
    Runtime.CancelEdit;                     // špatný rozsah, nebo rozdělený surrogate
    Exit;
  end;
  Result := Runtime.CommitEdit;             // všechno nebo nic
  if not Result then
    // dokument, widgety, počítadla a varování jsou už zpět v
    // stavu před úpravou; zaměřený widget je prostě označen jako nevalidní
    ShowMessage(Runtime.LastDiagnostic);
end;

ReplaceSelection si zaslouží vlastní poznámku, protože právě tady se špatný vstup odmítá nejlevněji. Odmítá výběr, který rozdělí dvojici UTF-16 surrogate, odmítá náhradní text obsahující nepárový vysoký nebo nízký surrogate a odmítá jakýkoli výsledek delší než MaxValueChars. Chytit to ve vrstvě stisků kláves znamená, že transakční mašinérie už nemusí rozebírat napůl zapsaný znak astrální roviny

Rebuild do soukromého seznamu, publikace jediným swapem

Rebuild widgetů nesmí být nikdy pozorovatelný napůl hotový, takže RebuildWidgets staví zcela samostatný vlastnící TObjectList a vkládá ho na místo jediným přiřazením na konci. Důvod není estetika: TXFALayoutEngine.ComputeLayout běží, zatímco rebuild letí, a volá zpět do kódu hosta přes funkci MeasureText, kterou jste dodali, a může vyhodit EXFAWidgetRuntimeError, když se narazí na limit widgetů. Kdyby runtime měnil svůj živý seznam na místě, nechala by každá z cest hosta držet seznam, který je z části starým a z části novým rozvržením, s ukazateli DataNode do dokumentu, který se zrovna chystá k rollbacku. Konvergence reflow se pak rozhoduje podle LayoutSignature, řetězce složeného z počtu widgetů plus každé ID, page indexu a ohraničujícího rámečku zaokrouhleného na čtyři desetinná místa: CommitEdit rebuilduje, porovnává signatury a opakuje, dokud se dvě po sobě jdoucí signatury nevyrovnají nebo dokud se nevyčerpá rozpočet průchodů. Když se signatura nikdy nezměnila, zůstává LastReflowPasses 0, což je způsob, jak rozeznat úpravu jen hodnoty od úpravy, která formulář skutečně zvětšila, a interakční stav přechází každým rebuildem podle widget ID, takže fokus a rozjetá editace přežijí vložení řádku

XFA runtime HotPDF rebuilduje seznam widgetů do samostatného vlastnícího seznamu, zatímco rozvržení běží a volá zpět do měřicího kódu hosta, pak publikuje hotový seznam jediným přiřazením, které host nemůže pozorovat napůl udělané
Rebuild probíhá v soukromém seznamu, protože ComputeLayout může v letu vyhodit výjimku, a LayoutSignature rozhoduje, kdy se dva po sobě jdoucí reflow konvergovaly

Proč by pole s vazbou četlo špatný záznam?

Protože skript běžel bez datového kontextu. Pole nesoucí explicitní <bind match="dataRef" ref="$record.actual"/> a pole pojmenované po tomtéž datovém uzlu jsou dva různé widgety mířící na jednu hodnotu a opakovaný subform s <occur max="2"/> vyprodukuje několik widgetů sdílejících jméno a lišících se jen tím, ke kterému datovému řádku patří; hodnoťte-li validaci a kalkulaci proti kořenu dokumentu, rozezná si každý z nich this na první odpovídající uzel celého paketu datasets, takže řádek dva tiše validuje řádek jedna. HotPDF tomu brání tím, že ukládá vyřešený DataNode do každé položky widgetu, jakmile ho vyprodukuje rozvržení, a protahuje ten uzel oběma voláními HPDFXFAEvaluateFieldScript, pro xfskValidate i xfskCalculate. Týž kontext rozhoduje, proti kterému uzlu EnsureValueNode vytvoří, když kalkulace míří na vazbu, která ještě neexistuje, a když se vazba nedá vyřešit, zápis selže čistě s XFA calculation target is not bound místo zápisu do špatného řádku. Sémantika FormCalc za těmito skripty zrcadlí, co AcroForm dokumenty dostávají z akcí popsaných v skriptech AcroForm format a calculate, ale pravidla rozlišování tady jsou scopovaná podle XFA, ne podle názvu pole

Rozpočty se kontrolují před vedlejšími účinky, ne po nich

Každý limit v runtime je podmínka, protože rozpočet vymáhaný poté, co alokace už proběhla, není rozpočet. TXFAWidgetRuntimeOptions.Default expeduje MaxWidgets na 10000, MaxValueChars na 1048576, MaxCalculationPasses na 16 a MaxReflowPasses na 4 a výchozí TXFAFormScriptOptions nesou MaxOperations na 100000 s MaxElapsedMilliseconds na 500. Pod tím si XFA DOM vymáhá vlastní TXFADOMLimits: stropy 128 MB na dekomprimovaný vstup i výstup, nejvýše 1024 paketů sešitých dohromady, 1000000 uzlů a hloubka zanoření 256. Dva detaily mají větší váhu než samotná čísla. Prvně, rozpočty skriptů jsou celotransakční, ne na skript: CommitEdit zasetuje jedno počítadlo zbývajících operací a jeden monotónní deadline a každé volání validate a calculate z toho téhož počítadla čerpá a dostává jen zbývající milisekundy, takže formulář se dvěma sty kalkulujícími poli nemůže utratit plných 500 ms dvakrát stokrát. Za druhé, deadline přichází z vstřikovatelné funkce MonotonicMilliseconds, což dělá chování uplynulého času reprodukovatelným v testovací sadě místo hodu mincí na zaneprázdněném build agentovi

Vrstvy rozpočtů v XFA runtime HotPDF, od limitů widgetů a hodnot přes limity operací a času skriptů až po stropy XFA DOM, s jedním počítadlem operací a jedním deadlinem sdíleným každým voláním transakce
Rozpočty skriptů jsou celotransakční, ne na skript, takže dvě stě kalkulujících polí si nemůže každé nárokovat čerstvých 500 ms
var
  Options: TXFAWidgetRuntimeOptions;
  Runtime: TXFAWidgetRuntime;
begin
  Options := TXFAWidgetRuntimeOptions.Default;
  Options.MaxWidgets := 2000;                              // výchozí 10000
  Options.MaxCalculationPasses := 8;                       // výchozí 16
  Options.MaxReflowPasses := 2;                            // výchozí 4
  Options.ScriptOptions.Limits.MaxOperations := 20000;     // celá transakce
  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;   // spuštěno jen když reflow widgety skutečně pohnul
      end;
    // ... ovládání formuláře ...
  finally
    Runtime.Free;
  end;
end;

Kde runtime končí a proč to říká nahlas

Runtime záměrně není všeobecný XFA skriptovací engine. DispatchEvent zpracovává aktivity enter a exit nativně přesunutím fokusu a u každé jiné aktivity nesoucí skript odmítá s konkrétní, stabilní diagnostikou místo předstírání: skripty zmiňující addInstance, removeInstance nebo instanceManager vrací XFA runtime does not support event-driven instance mutation, skripty sající na .presence vrací presence ekvivalent a cokoli jiného vrací XFA runtime does not support this event script. Předvídatelné odmítnutí, na kterém se dá větvit, bije částečnou emulaci, která funguje na vašem vzorku a vybočí u zákazníka

Model vláken je stejně přímočarý: jedna instance runtime patří jednomu vláknu, bez vnitřních zámků, protože rozvrhovací engine sahá zpět do měřicích callbacků hosta a zámek kolem toho je deadlock čekající na překreslení. Rich obsah uvnitř polí jde konzervativní linií jako jinde v knihovně, kde se payloady exData zpracovávají, jak popisuje XFA exData rich text a hyperodkazy, a widgety podpisu a tlačítek přicházejí zpět jako ReadOnly zatímco nepodporované druhy UI povrchují jako xwkUnsupported místo editovatelného textového pole, které tiše ztrácí data

Dohromady je to použitelná odpověď na dynamické XFA v Delphi: držte DOM naživu, udělejte z každé úpravy transakci, která buď přistane celá, nebo nezanechá nic, ohraničte každý průchod a buďte explicitní ohledně toho, co je mimo záběr. Pokud toto hodnotíte pro workflow přihlášek, daní nebo dávek, XFA runtime putuje jako součást komponenty HotPDF Delphi PDF spolu s cestami AcroForm, zplošťování a vykreslování, které tyto projekty obvykle nakonec potřebují společně