Техническа статия

Динамичен XFA runtime за форми в Delphi: HotPDF транзакции

HotPDF попълва динамични XFA формуляри в Delphi чрез TXFAWidgetRuntime — неутрален към хоста слой от уиджети, който третира всяка редакция на поле като една транзакция: snapshot, validate, calculate, reflow, а после публикуване или връщане назад на цялото. Работи еднонишково във вашия VCL или FMX хост, не изисква инсталиран Acrobat и налага всеки бюджет, преди да задели каквото и да е

Сценарият е познат на всеки, който е доставял софтуер за документи към държавна администрация или застрахователи. Формуляр за обезщетение или данъчна декларация пристига като PDF, чийто page content е само съобщението „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 отхвърля целия механизъм; отхвърлянето го махна от пътната карта, не от терена, а формулярите, създадени по спецификацията XFA 3.3, продължават да се издават и остават юридически обвързващи. Статичният XFA може да се сведе до обикновени уиджет анотации и HotPDF прави точно това, когато извикате ApplyXFAAsAcroForm, с компромисите, разгледани в статията за преобразуване на XFA формуляри в AcroForm полета. Динамичният XFA е съвсем друго животно: диапазоните occur, растящият текст и скриптовете calculate правят множеството полета функция на данните, така че няма фиксиран списък от анотации, към който да се сведе, докато потребителят не спре да пише. Именно за тази празнина е създаден TXFAWidgetRuntime, който държи XFA DOM жив, преизчислява подредбата след всяка приета редакция и подава на хоста ви плосък масив от позиционирани уиджети за рисуване и hit-test

Какво предава runtime на хост приложението?

Предава ви геометрия и състояние, и нищо, което предполага UI инструментариум. TXFAWidgetRuntime излага WidgetCount и Widgets[I] като записи TXFAWidgetState, носещи ID, Name, Kind, PageIndex, Bounds в PDF точки, Value, EditValue и флаговете Focused, Editing, ReadOnly, Valid, докато рисуването, каретата и клавиатурното маршрутизиране остават във вашия код. Идентичността на уиджета е стабилна и порядкова: всеки уиджет получава ID от вида name[n], където n брои предишните срещания на това име на поле в реда на подредбата, така че вторият ред на повтарящ се subform е 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)]));
      // hit test в пространството на страницата, печели най-горният уиджет
      if Runtime.HitTest(0, 120.0, 96.0, WidgetID) then
        Runtime.BeginEdit(WidgetID);
    finally
      Runtime.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

Какво трябва да е атомарно при потвърждаване на поле?

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

HotPDF третира потвърждаването на XFA поле като една транзакция, запазвайки сериализирания DOM, състоянието на всеки уиджет, броячите на проходите и броя предупреждения, преди validate, calculate и reflow, а после публикува или възстановява и четирите заедно
CommitEdit прави snapshot на четири вида състояние, преди да запише каквото и да е, така че провален validate, 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;                     // невалиден диапазон, или разцепен surrogate
    Exit;
  end;
  Result := Runtime.CommitEdit;             // всичко или нищо
  if not Result then
    // документът, уиджетите, броячите и предупрежденията вече са обратно
    // в състоянието преди редакцията; фокусираният уиджет просто е маркиран като невалиден
    ShowMessage(Runtime.LastDiagnostic);
end;

ReplaceSelection заслужава собствена бележка, защото именно там лошо оформленият вход е най-евтино да се отхвърли. Тя отказва селекция, която разцепва UTF-16 surrogate двойка, отказва заместващ текст, съдържащ непарен high или low surrogate, и отказва всеки резултат, по-дълъг от MaxValueChars. Хващането на това на ниво клавиш означава, че транзакционният механизъм никога не се налага да разгръща обратно наполовина написан символ от астралната равнина

Преизграждане в частен списък, публикуване с едно разменяне

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

XFA runtime на HotPDF преизгражда списъка си с уиджети в отделен притежаван списък, докато подредбата работи и извиква измервателния код на хоста, а после публикува готовия списък с едно присвояване, което хостът не може да наблюдава наполовина
Преизграждането става в частен списък, защото ComputeLayout може да даде грешка по средата, а LayoutSignature решава кога два последователни reflow прохода са се съшли

Защо свързано поле би прочело грешния запис?

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

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

Всеки лимит в runtime е предусловие, защото бюджет, наложен след като заделянето вече се е случило, не е бюджет. TXFAWidgetRuntimeOptions.Default доставя MaxWidgets 10000, MaxValueChars 1048576, MaxCalculationPasses 16 и MaxReflowPasses 4, а настройките по подразбиране TXFAFormScriptOptions носят MaxOperations 100000 с MaxElapsedMilliseconds 500. Отдолу XFA DOM налага собствени TXFADOMLimits: тавани от 128 MB за декомпресирани вход и изход, най-много 1024 слепени пакета, 1000000 възела и дълбочина на влагане 256. Две подробности тежат повече от самите числа. Първо, скриптовите бюджети са за цялата транзакция, а не на скрипт: CommitEdit зарежда един-единствен брояч на оставащи операции и един монотонен краен срок, и всяко извикване на validate и calculate черпи от същия брояч и получава само милисекундите, които още са останали, така че формуляр с двеста изчисляващи полета не може да похарчи пълните 500 ms двеста пъти. Второ, крайният срок идва от инжектируемата функция MonotonicMilliseconds, което прави поведението по изминало време възпроизводимо в тестов пакет, вместо да е хвърляне на монета на зает build агент

Слоеве бюджети в XFA runtime на HotPDF — от лимитите за уиджети и стойности през лимитите за операции и време на скриптовете до таваните на XFA DOM, с един брояч операции и един краен срок, споделени от всяко извикване в транзакцията
Скриптовите бюджети са за цялата транзакция, а не на скрипт, така че двеста изчисляващи полета не могат да заявят по свежи 500 ms всяко
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;

Къде runtime спира и защо го казва на глас

Runtime умишлено не е универсален 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. Предвидим отказ, по който можете да разклоните логиката, бие частична емулация, която работи на вашия примерен файл и се отклонява на файла на клиента

Моделът на нишки е еднакво директен: един екземпляр runtime принадлежи на една нишка, без вътрешно заключване, защото двигателът за подредба стига обратно до измервателните обратно извиквания на хоста, а ключалка около това е задънена улица, чакаща repaint. Богатото съдържание вътре в полетата следва същата консервативна линия като другаде в библиотеката, където payload-ите exData се обработват както е описано в статията за XFA exData rich text и хипервръзки, а уиджетите за подпис и бутон се връщат като ReadOnly, докато неподдържаните UI видове излизат като xwkUnsupported, а не като редактируемо текстово поле, което мълчаливо губи данни

Взето заедно, това е работещ отговор на динамичния XFA в Delphi: дръжте DOM жив, направете всяка редакция транзакция, която или се приземява изцяло, или не оставя нищо, оградете всеки проход с лимит и бъдете изрични за това, което е извън обхвата. Ако го оценявате за работен процес с обезщетения, данъци или помощи, XFA runtime идва като част от HotPDF Delphi PDF component, заедно с пътищата за AcroForm, преобразуване и рендериране, от които тези проекти обикновено се нуждаят в комплект