Artykuł techniczny

Dynamiczne formularze XFA w Delphi: transakcje HotPDF

HotPDF wypełnia dynamiczne formularze XFA w Delphi przez TXFAWidgetRuntime, warstwę widżetów neutralną wobec hosta, która traktuje każdą edycję pola jako jedną transakcję: snapshot, walidacja, calculate, reflow, a potem publikacja albo wycofanie w całości. Działa jednowątkowo wewnątrz własnego hosta VCL albo FMX, nie wymaga zainstalowanego Acrobata i egzekwuje każdy budżet, zanim cokolwiek zaalokuje

Scenariusz zna każdy, kto wydał oprogramowanie dokumentowe do pracy dla rządu albo ubezpieczycieli. Formularz roszczenia albo zeznania podatkowego przychodzi jako PDF, którego treść strony to pojedynczy komunikat "Please wait... if this message is not eventually replaced", a każde prawdziwe pole żyje w pakiecie XFA, który renderuje wyłącznie Adobe Acrobat. Twoi użytkownicy chcą go wypełnić wewnątrz twojej aplikacji. Nie uciekniesz też w rastrowanie, bo formularz dokłada wiersze w miarę wpisywania danych, a układ po trzecim wierszu to nie ten układ, który przyjechał w pliku

Dlaczego dynamiczne XFA to wciąż problem wart rozwiązania

Dynamiczne XFA przetrwało, bo wdrożone formularze żyją dłużej niż format, który je niósł. ISO 32000-1 §12.7.8 opisuje XFA jako wpis /XFA w słowniku AcroForm trzymający strumień pakietu XDP, a ISO 32000-2 oznacza cały mechanizm jako przestarzały; deprecacja usunęła go z mapy drogowej, ale nie z terenu, a formularze tworzone według specyfikacji XFA 3.3 są wciąż wystawiane i wciąż prawnie wiążące. Statyczne XFA da się zredukować do zwykłych adnotacji widżetów i HotPDF to robi, gdy wywołasz ApplyXFAAsAcroForm, z kompromisami opisanymi w spłaszczaniu formularzy XFA do pól AcroForm. Dynamiczne XFA to inne zwierzę: jego zakresy occur, rozrastalny tekst i skrypty calculate czynią zbiór pól funkcją danych, więc nie ma stałej listy adnotacji, do której można spłaszczyć, dopóki użytkownik nie skończy pisać. To jest luka, którą wypełnia TXFAWidgetRuntime: trzyma XFA DOM żywy, przelicza układ po każdej przyjętej edycji i wręcza twojemu hostowi płaską tablicę wypozycjonowanych widżetów do rysowania i testowania trafień

Co runtime wręcza aplikacji hosta?

Wręcza ci geometrię i stan, i nic, co zakładałoby toolkit UI. TXFAWidgetRuntime wystawia WidgetCount i Widgets[I] jako rekordy TXFAWidgetState niosące ID, Name, Kind, PageIndex, Bounds w punktach PDF, Value, EditValue oraz flagi Focused, Editing, ReadOnly, Valid, podczas gdy malowanie, rysowanie karetki i routing klawiatury zostają w twoim kodzie. Tożsamość widżetu jest stabilna i porządkowa: każdy widżet dostaje ID postaci name[n], gdzie n liczy wcześniejsze wystąpienia tej nazwy pola w kolejności układu, więc drugi wiersz powtarzalnego subformularza to amount[1]. Ta tożsamość przeżywa przebudowę i nią mówią FocusWidget, BeginEdit, DispatchEvent i HitTest. Dla dokumentu już otwartego w instancji THotPDF CreateLoadedXFAWidgetRuntime wyciąga pakiety XDP, bierze pole pierwszej strony jako rozmiar strony układu i zwraca nil, gdy plik w ogóle nie niesie 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, gdy nie ma /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 w przestrzeni strony, wygrywa widżet najwyżej
      if Runtime.HitTest(0, 120.0, 96.0, WidgetID) then
        Runtime.BeginEdit(WidgetID);
    finally
      Runtime.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

Co musi być atomowe przy zatwierdzeniu pola?

Wszystko, czego edycja może dotknąć, a to znacznie więcej niż wartość pola. CommitEdit wywołuje CaptureSnapshot, zanim cokolwiek zapisze, a ten snapshot obejmuje cztery rzeczy: zserializowany XFA DOM z TXFADocument.SaveToBytes, pełną tablicę interakcyjnych rekordów TXFAWidgetState, liczniki LastCalculationPasses i LastReflowPasses oraz bieżące Warnings.Count. Zapisywanie samych wartości węzłów to kuszący skrót i jest błędny, bo skrypt calculate albo nierozwiązane powiązanie może wywołać EnsureValueNode i zmaterializować węzły danych, które nie istniały, gdy edycja się zaczęła; odtworzenie samych wartości nie ma jak ich usunąć, więc odrzucona edycja zostawiłaby trwały strukturalny osad w pakiecie datasets. Sama sekwencja zatwierdzenia jest surowa — zapisz wartość kandydata, wykonaj validate dla edytowanego pola, wykonaj calculate do punktu stałego, potem reflow aż układ się ustabilizuje — a każde niepowodzenie na każdym etapie przechodzi przez FailAndRestore, które wczytuje bajty snapshotu do świeżego TXFADocument, przebudowuje listę widżetów, nanosi zapisane stany interakcji, zeruje liczniki i skraca Warnings z powrotem do długości ze snapshotu. LastDiagnostic trzyma powód niepowodzenia, a w patologicznym przypadku, gdy sama procedura odtwarzania rzuci wyjątek, trzyma literał XFA transaction rollback failed

HotPDF traktuje zatwierdzenie pola XFA jako jedną transakcję: przechwytuje zserializowany DOM, stan każdego widżetu, liczniki przebiegów i liczbę ostrzeżeń przed walidacją, calculate i reflow, a potem publikuje albo odtwarza wszystkie cztery razem
CommitEdit robi snapshot czterech rodzajów stanu, zanim cokolwiek zapisze, więc nieudana walidacja, calculate albo reflow nie zostawia strukturalnego osadu
function EditAmount(Runtime: TXFAWidgetRuntime;
  const AWidgetID: AnsiString; const AText: UnicodeString): Boolean;
var
  Current: UnicodeString;
begin
  Result := False;
  if not Runtime.BeginEdit(AWidgetID) then
    Exit;                                   // tylko do odczytu albo brak takiego widżetu
  Current := Runtime.Widgets[Runtime.FocusedIndex].EditValue;
  if not Runtime.ReplaceSelection(0, Length(Current), AText) then
  begin
    Runtime.CancelEdit;                     // zły zakres albo rozcięty surogat
    Exit;
  end;
  Result := Runtime.CommitEdit;             // wszystko albo nic
  if not Result then
    // dokument, widżety, liczniki i ostrzeżenia są już z powrotem
    // w stanie sprzed edycji; fokusowy widżet jest po prostu nieprawidłowy
    ShowMessage(Runtime.LastDiagnostic);
end;

ReplaceSelection zasługuje na własną wzmiankę, bo to najtańsze miejsce na odrzucenie zniekształconego wejścia. Odmawia zaznaczenia rozcinającego parę surogatów UTF-16, odmawia tekstu zastępującego zawierającego niesparowany surogat wysoki albo niski i odmawia wyniku dłuższego niż MaxValueChars. Wyłapanie tego na warstwie klawisza oznacza, że mechanizm transakcji nigdy nie musi odwijać półwpisanego znaku z płaszczyzny astralnej

Przebudowa do prywatnej listy, publikacja jednym swapem

Przebudowa widżetów nigdy nie może być obserwowalna w połowie gotowa, więc RebuildWidgets buduje zupełnie osobną, będącą na własność listę TObjectList i wstawia ją na miejsce jednym przypisaniem na końcu. Powód nie jest estetyczny: TXFALayoutEngine.ComputeLayout działa, dopóki przebudowa jest w locie, i wywołuje kod hosta przez dostarczoną przez ciebie funkcję MeasureText, a może rzucić EXFAWidgetRuntimeError, gdy zostanie trafiony limit widżetów. Gdyby runtime mutował swoją żywą listę w miejscu, każda z tych ścieżek zostawiłaby hosta z listą częściowo starą, częściowo nową, ze wskaźnikami DataNode w dokument, który zaraz zostanie wycofany. Zbieżność reflow rozstrzyga potem LayoutSignature, napis zbudowany z liczby widżetów plus każdego ID, indeksu strony i ramki zaokrąglonej do czterech miejsc: CommitEdit przebudowuje, porównuje sygnatury i powtarza, aż dwie kolejne sygnatury się zgodzą albo budżet przebiegów się wyczerpie. Gdy sygnatura w ogóle się nie zmieniła, LastReflowPasses zostaje 0, po czym poznajesz edycję samych wartości od takiej, która faktycznie rozrosła formularz, a stan interakcji jest przenoszony przez każdą przebudowę po ID widżetu, więc fokus i edycja w toku przeżywają wstawienie wiersza

Runtime XFA w HotPDF przebudowuje listę widżetów do osobnej listy na własność, podczas gdy układ działa i wywołuje kod pomiaru hosta, a potem publikuje gotową listę jednym przypisaniem, którego host nie może zaobserwować w połowie zrobionego
Przebudowa dzieje się w prywatnej liście, bo ComputeLayout może rzucić wyjątek w pół lotu, a LayoutSignature rozstrzyga, kiedy dwa kolejne reflow się zbiegły

Dlaczego powiązane pole mogłoby odczytać zły rekord?

Bo skrypt działał bez kontekstu danych. Pole niosące jawne <bind match="dataRef" ref="$record.actual"/> i pole nazwane jak ten sam węzeł danych to dwa różne widżety wskazujące na jedną wartość, a powtarzalny subformularz z <occur max="2"/> produkuje kilka widżetów, które dzielą nazwę i różnią się tylko tym, do którego wiersza danych należą; oceń walidację i wyliczanie względem korzenia dokumentu, a każdy z nich rozwiąże this na pierwszy pasujący węzeł w całym pakiecie datasets, więc wiersz drugi po cichu waliduje wiersz pierwszy. HotPDF unika tego, przechowując rozwiązany DataNode na każdym wpisie widżetu, gdy tylko układ go wyprodukuje, a potem przepuszczając ten węzeł przez oba wywołania HPDFXFAEvaluateFieldScript, dla xfskValidate i xfskCalculate jednakowo. Ten sam kontekst rozstrzyga, względem którego węzła EnsureValueNode tworzy, gdy wyliczanie celuje w powiązanie, które jeszcze nie istnieje, a gdy żadnego powiązania nie da się rozwiązać, zatwierdzenie kończy się czysto z XFA calculation target is not bound zamiast pisać w zły wiersz. Semantyka FormCalc za tymi skryptami odpowiada temu, co dokumenty AcroForm dostają z akcji opisanych w skryptach format i calculate AcroForm, ale reguły rozwiązywania tutaj są zakresem XFA, a nie nazwy pola

Budżety są sprawdzane przed efektami ubocznymi, nie po nich

Każdy limit w runtime jest warunkiem wstępnym, bo budżet egzekwowany po tym, jak alokacja już się wydarzyła, nie jest budżetem. TXFAWidgetRuntimeOptions.Default przychodzi z MaxWidgets 10000, MaxValueChars 1048576, MaxCalculationPasses 16 i MaxReflowPasses 4, a domyślne TXFAFormScriptOptions niosą MaxOperations 100000 i MaxElapsedMilliseconds 500. Pod spodem XFA DOM nakłada własne TXFADOMLimits: sufity 128 MB na rozpakowane wejście i wyjście, najwyżej 1024 zszyte pakiety, 1000000 węzłów i głębokość zagnieżdżenia 256. Dwa detale ważą więcej niż same liczby. Po pierwsze, budżety skryptów są transakcyjne, a nie na skrypt: CommitEdit zasiewa jeden licznik pozostałych operacji i jeden monotoniczny deadline, a każde wywołanie validate i calculate dobiera z tego samego licznika i dostaje tylko te milisekundy, które jeszcze zostały, więc formularz z dwustoma polami wyliczającymi nie może wydać pełnych 500 ms dwaset razy. Po drugie, deadline pochodzi z wstrzykiwalnej funkcji MonotonicMilliseconds, co czyni zachowanie czasowe odtwarzalnym w teście zamiast rzutem monetą na zajęty agent budowania

Warstwy budżetów w runtime XFA HotPDF, od limitów widżetów i wartości przez limity operacji i czasu skryptów aż po sufity XFA DOM, z jednym licznikiem operacji i jednym deadlinem współdzielonym przez każde wywołanie w transakcji
Budżety skryptów są transakcyjne, a nie na skrypt, więc dwieście pól wyliczających nie może każde zgłosić po świeże 500 ms
var
  Options: TXFAWidgetRuntimeOptions;
  Runtime: TXFAWidgetRuntime;
begin
  Options := TXFAWidgetRuntimeOptions.Default;
  Options.MaxWidgets := 2000;                              // domyślnie 10000
  Options.MaxCalculationPasses := 8;                       // domyślnie 16
  Options.MaxReflowPasses := 2;                            // domyślnie 4
  Options.ScriptOptions.Limits.MaxOperations := 20000;     // cała transakcja
  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;   // odpalane tylko, gdy reflow faktycznie ruszył widżety
      end;
    // ... obsługa formularza ...
  finally
    Runtime.Free;
  end;
end;

Gdzie runtime się zatrzymuje i dlaczego mówi to wprost

Runtime celowo nie jest ogólnym silnikiem skryptowym XFA. DispatchEvent obsługuje natywnie aktywności enter i exit przez przesunięcie fokusa, a dla każdej innej aktywności niosącej skrypt odmawia ze specyficzną, stabilną diagnostyką zamiast udawać: skrypty wspominające addInstance, removeInstance albo instanceManager zwracają XFA runtime does not support event-driven instance mutation, skrypty dotykające .presence zwracają odpowiednik dla presence, a wszystko inne zwraca XFA runtime does not support this event script. Przewidywalna odmowa, na której można się rozgałęzić, bije częściową emulację, która działa na twoim pliku próbnym i rozjeżdża się u klienta

Model wątków jest równie bezceremonialny: jedna instancja runtime należy do jednego wątku, bez wewnętrznych blokad, bo silnik układu sięga z powrotem do wywołań zwrotnych pomiaru hosta, a blokada wokół tego to czekający na przemalowanie deadlock. Rich content wewnątrz pól idzie tą samą konserwatywną linią co gdzie indziej w bibliotece, gdzie ładunki exData są obsługiwane tak, jak opisano w tekstach sformatowanych i hiperłączach XFA exData, a widżety podpisu i przycisku wracają jako ReadOnly, podczas gdy nieobsługiwane rodzaje UI wynurzają się jako xwkUnsupported, a nie jako edytowalne pole tekstowe po cichu gubiące dane

Złożone razem, to wykonalna odpowiedź na dynamiczne XFA w Delphi: trzymaj DOM żywy, zrób z każdej edycji transakcję, która albo ląduje w całości, albo nie zostawia niczego, ogranicz każdy przebieg i mów wprost, co jest poza zakresem. Jeśli oceniasz to dla przepływu roszczeń, podatków albo świadczeń, runtime XFA przychodzi jako część komponentu HotPDF Delphi PDF, obok ścieżek AcroForm, spłaszczania i renderowania, których te projekty zwykle i tak potrzebują razem