Технічна стаття

Рантайм динамічних XFA-форм у Delphi: транзакції HotPDF

HotPDF заповнює динамічні XFA-форми в Delphi через TXFAWidgetRuntime, нейтральний до хоста шар віджетів, що трактує кожне редагування поля як одну транзакцію: знімок, валідація, обчислення, reflow, а потім публікація або повний відкат. Він працює в одну нитку всередині вашого власного хоста VCL чи FMX, не потребує встановленого Acrobat і застосовує кожен бюджет до того, як щось виділить

Сценарій знайомий кожному, хто відгружав документний софт у державні чи страхові проєкти. Форма претензії чи податкова декларація приходить як PDF, чиїй сторінковий вміст — одиночне повідомлення «Please wait... if this message is not eventually replaced», а всі справжні поля живуть у XFA-пакеті, який рендерить лише Adobe Acrobat. Ваші користувачі хочуть заповнювати його всередині вашої програми. Ви не можете вийти з ситуації растеризацією, бо форма вирощує рядки в міру введення даних, і компонування після третього рядка — не те, що було відвантажене у файлі

Чому динамічний XFA досі проблема, варта розвʼязання

Динамічний XFA виживає, бо розгорнуті форми переживають формат, що їх ніс. ISO 32000-1 §12.7.8 описує XFA як запис /XFA у словнику AcroForm, що тримає пакетний потік XDP, а ISO 32000-2 виводить увесь механізм з ужитку; deprecation прибрав його з дорожньої карти, а не з поля, і форми, створені за специфікацією XFA 3.3, досі випускаються і досі мають юридичну силу. Статичний XFA можна згорнути до звичайних анотацій-віджетів, і HotPDF це робить, коли ви викликаєте ApplyXFAAsAcroForm, з компромісами, покритими в згортанні XFA-форм у поля AcroForm. Динамічний XFA — звір інший: його діапазони occur, текст, що росте, і скрипти calculate роблять набір полів функцією даних, тож фіксованого списку анотацій, до якого можна згорнути, не існує, доки користувач не скінчив друкувати. Саме ту прогалину заповнює TXFAWidgetRuntime, тримаючи XFA DOM живим, перераховуючи компонування після кожного прийнятого редагування і віддаючи вашому хосту плоский масив позиціонованих віджетів для малювання та перевірки влучань

Що рантайм віддає хост-застосунку?

Він віддає вам геометрію і стан, і ніщо, що припускає UI-тулкіт. TXFAWidgetRuntime відкриває WidgetCount і Widgets[I] як записи TXFAWidgetState, що несуть ID, Name, Kind, PageIndex, Bounds у PDF-пунктах, Value, EditValue і прапорці Focused, Editing, ReadOnly, Valid, тоді як малювання, каретка і маршрутизація клавіатури лишаються у вашому коді. Ідентичність віджета стабільна й ординальна: кожен віджет отримує ID форми name[n], де n рахує попередні входження того імені поля в порядку компонування, тож другий рядок повторюваного субформу — це amount[1]. Та ідентичність переживає перебудову, і нею говорять FocusWidget, BeginEdit, DispatchEvent і HitTest. Для документа, вже відкритого в екземплярі THotPDF, CreateLoadedXFAWidgetRuntime витягує XDP-пакети, бере перший сторінковий бокс як розмір сторінки компонування і повертає nil, коли файл узагалі не несе 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, коли /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)]));
      // перевірка влучання у просторі сторінки, найвищий віджет виграє
      if Runtime.HitTest(0, 120.0, 96.0, WidgetID) then
        Runtime.BeginEdit(WidgetID);
    finally
      Runtime.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

Що має бути атомарним, коли поле комітується?

Усе, чого може торкнутися редагування, а це помітно більше за значення поля. CommitEdit викликає CaptureSnapshot перед будь-яким записом, і той знімок покриває чотири речі: серіалізований XFA DOM із TXFADocument.SaveToBytes, повний масив інтеракційних записів TXFAWidgetState, лічильники LastCalculationPasses і LastReflowPasses та поточний Warnings.Count. Зберігати самі значення вузлів — спокусливий обхідний шлях, і він неправильний, бо скрипт calculate чи невирішена привʼязка може викликати EnsureValueNode і матеріалізувати вузли даних, яких не існувало, коли редагування починалося; відновлення лише значень не має способу їх прибрати, тож відхилене редагування лишило б постійний структурний осад у пакеті datasets. Сама послідовність коміту строга — записати кандидата-значення, прогнати validate для редагованого поля, прогнати calculate до нерухомої точки, потім reflow, доки компонування не стабілізується, — і будь-яка невдача на будь-якій стадії йде через FailAndRestore, який перезавантажує байти знімка в свіжий TXFADocument, перебудовує список віджетів, повторно застосовує записані інтеракційні стани, скидає лічильники і обрізає Warnings назад до довжини знімка. LastDiagnostic тримає причину при невдачі і тримає літеральний XFA transaction rollback failed у патологічному випадку, коли саме відновлення кидає

HotPDF трактує коміт XFA-поля як одну транзакцію, захоплюючи серіалізований DOM, кожен стан віджета, лічильники проходів і кількість попереджень перед валідацією, обчисленням і reflow, а потім публікуючи або відновлюючи всі чотири разом
CommitEdit робить знімок чотирьох видів стану перед будь-яким записом, тож невдала валідація, calculate чи reflow не лишають структурного осаду
function EditAmount(Runtime: TXFAWidgetRuntime;
  const AWidgetID: AnsiString; const AText: UnicodeString): Boolean;
var
  Current: UnicodeString;
begin
  Result := False;
  if not Runtime.BeginEdit(AWidgetID) then
    Exit;                                   // лише для читання, або такого віджета немає
  Current := Runtime.Widgets[Runtime.FocusedIndex].EditValue;
  if not Runtime.ReplaceSelection(0, Length(Current), AText) then
  begin
    Runtime.CancelEdit;                     // поганий діапазон, або розрізаний сурогат
    Exit;
  end;
  Result := Runtime.CommitEdit;             // усе-або-нічого
  if not Result then
    // документ, віджети, лічильники і попередження вже повернулися
    // до передредаційного стану; фокусний віджет просто позначений невалідним
    ShowMessage(Runtime.LastDiagnostic);
end;

ReplaceSelection заслуговує власної нотатки, бо саме там пошкоджений вхід найдешевше відхилити. Вона відмовляє вибору, що розрізає сурогатну пару UTF-16, відмовляє тексту заміни з непарним старшим чи молодшим сурогатом і відмовляє будь-якому результату довжиною понад MaxValueChars. Впіймати це на шарі клавіш означає, що транзакційній машині ніколи не доведеться розмотувати напівзаписаний символ астральної площини

Перебудова в приватний список, публікація одним свопом

Перебудова віджетів ніколи не повинна бути спостережуваною напівзавершеною, тож RebuildWidgets будує цілком окремий власний TObjectList і підміняє його на місце одним присвоєнням у кінці. Причина не в естетиці: TXFALayoutEngine.ComputeLayout працює, поки перебудова в польоті, і телефонує в хост-код через надану вами функцію MeasureText, і вона може кинути EXFAWidgetRuntimeError, коли досягнуто ліміту віджетів. Якби рантайм мутував свій живий список на місці, будь-який із шляхів лишив би хост зі списком, частково старого компонування і частково нового, з вказівниками DataNode у документ, що ось-ось відкотиться. Збіжність reflow тоді вирішується LayoutSignature, рядком, зібраним з кількості віджетів плюс кожен ID,pageIndex і обмежувальний бокс, округлені до чотирьох знаків: CommitEdit перебудовує, порівнює підписи і повторює, доки два послідовні підписи не збігаються або не вичерпано бюджет проходів. Коли підпис узагалі не змінився, LastReflowPasses лишається 0 — так ви відрізняєте редагування лише значення від того, що справді виростило форму, — а інтеракційний стан переноситься крізь кожну перебудову за ID віджета, тож фокус і редагування, що триває, переживають вставлення рядка

Рантайм XFA у HotPDF перебудовує свій список віджетів в окремий власний список, поки компонування працює і телефонує в хост-код вимірювання, а потім публікує готовий список одним присвоєнням, яке хост не може спостерігати напівзавершеним
Перебудова відбувається в приватному списку, бо ComputeLayout може кинути на півльоті, а LayoutSignature вирішує, коли два послідовні reflow збіглися

Чому привʼязане поле читає неправильний запис?

Бо скрипт виконався без контексту даних. Поле з явним <bind match="dataRef" ref="$record.actual"/> і поле, назване на честь того самого вузла даних, — два різні віджети, вказані на одне значення, а повторюваний субформ із <occur max="2"/> породжує кілька віджетів, що поділяють імʼя і відрізняються лише тим, до якого рядка даних належать; оцініть валідацію та обчислення від кореня документа — і кожен із них розвʼяже this в перший збіжний вузол усього пакету datasets, тож рядок два тихо валідує рядок один. HotPDF уникає цього, зберігаючи розвʼязаний DataNode у кожному записі віджета, коли компонування його породжує, а потім протягуючи той вузол крізь обидва виклики HPDFXFAEvaluateFieldScript, для xfskValidate і xfskCalculate однаково. Той самий контекст вирішує, проти якого вузла EnsureValueNode створює, коли обчислення цілиться в привʼязку, що ще не існує, а коли жодну привʼязку розвʼязати неможливо, коміт чисто провалюється з XFA calculation target is not bound, замість писати в неправильний рядок. Семантика FormCalc за тими скриптами віддзеркалює те, що AcroForm-документи отримують від дій, описаних у форматі AcroForm і скриптах calculate, але правила розвʼязання тут мають сферу XFA, а не імені поля

Бюджети перевіряються до побічних ефектів, а не після

Кожен ліміт у рантаймі — передумова, бо бюджет, застосований після того, як виділення вже сталося, — не бюджет. TXFAWidgetRuntimeOptions.Default постачає MaxWidgets у 10000, MaxValueChars у 1048576, MaxCalculationPasses у 16 і MaxReflowPasses у 4, а типові TXFAFormScriptOptions несуть MaxOperations у 100000 з MaxElapsedMilliseconds у 500. Знизу XFA DOM застосовує власні TXFADOMLimits: стелі 128 МБ на декомпресований вхід і вихід, щонайбільше 1024 зшитих пакетів, 1000000 вузлів і глибина вкладеності 256. Дві деталі важать більше за самі числа. По-перше, скриптові бюджети — на всю транзакцію, а не на скрипт: CommitEdit засіває один лічильник залишкових операцій і один монотонний дедлайн, і кожен виклик validate і calculate знімає з того самого лічильника і отримує лише мілісекунди, що ще лишилися, тож форма з двома сотнями обчислювальних полів не може витратити повні 500 мс двісті разів. По-друге, дедлайн походить з інʼєктовної функції MonotonicMilliseconds, і саме це робить поведінку часу відтворюваною в тестовому наборі, а не кидком монети на завантаженому build-агенті

Шари бюджетів у рантаймі XFA HotPDF, від лімітів віджетів і значень крізь скриптові ліміти операцій і часу до стель XFA DOM, з одним лічильником операцій і одним дедлайном, спільними для кожного виклику в транзакції
Скриптові бюджети — на всю транзакцію, а не на скрипт, тож дві сотні обчислювальних полів не можуть кожне вимагати свіжі 500 мс
var
  Options: TXFAWidgetRuntimeOptions;
  Runtime: TXFAWidgetRuntime;
begin
  Options := TXFAWidgetRuntimeOptions.Default;
  Options.MaxWidgets := 2000;                              // типово 10000
  Options.MaxCalculationPasses := 8;                       // типово 16
  Options.MaxReflowPasses := 2;                            // типово 4
  Options.ScriptOptions.Limits.MaxOperations := 20000;     // вся транзакція
  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;   // вогонь лише коли reflow справді рушив віджети
      end;
    // ... ведіть форму ...
  finally
    Runtime.Free;
  end;
end;

Де рантайм зупиняється і чому каже це вголос

Рантайм навмисно не є універсальним скриптовим рушієм XFA. DispatchEvent обробляє активності enter і exit нативно переміщенням фокуса, а для кожної іншої активності зі скриптом відмовляє специфічною, стабільною діагностикою, замість прикидатися: скрипти, що згадують addInstance, removeInstance чи instanceManager, повертають XFA runtime does not support event-driven instance mutation, скрипти, що торкаються .presence, повертають presence-еквівалент, а все інше повертає XFA runtime does not support this event script. Передбачувана відмова, на якій можна розгілкуватися, краща за часткову емуляцію, що працює на вашому прикладному файлі і розходиться на файлі клієнта

Ниткова модель так само пряма: один екземпляр рантайма належить одній нитці, без внутрішнього блокування, бо механізм компонування тягнеться назад у хост-колбеки вимірювання, а блокування навколо того — дедлок у очікуванні перемальовки. Багатий вміст усередині полів іде тією самою консервативною лінією, що й решта бібліотеки, де навантаження exData обробляються, як описано в XFA exData, збагаченому тексті та гіперпосиланнях, а віджети підпису і кнопки повертаються як ReadOnly, тоді як непідтримувані UI-види виринають як xwkUnsupported, а не як текстовий бокс для редагування, що тихо втрачає дані

Разом узяте, це робоча відповідь на динамічний XFA в Delphi: тримати DOM живим, робити кожне редагування транзакцією, що або приземляється повністю, або не лишає нічого, обмежувати кожен прохід і бути явним щодо того, що поза сферою. Якщо ви оцінюєте це для робочого потоку претензій, податків чи допомоги, XFA-рантайм постачається як частина HotPDF Delphi PDF component, поруч зі шляхами AcroForm, згортання та рендерингу, які ті проєкти зазвичай кінчають потребувати разом