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