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

Рантайм динамических XFA-форм в Delphi: транзакции HotPDF

HotPDF заполняет динамические XFA-формы в Delphi через TXFAWidgetRuntime — слой виджетов, нейтральный к хосту, который трактует каждую правку поля как одну транзакцию: снимок, валидация, calculate, 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 объявляет весь механизм устаревшим; устаревание убрало его с дорожной карты, но не из реального поля, и формы, созданные по спецификации 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, состояния всех виджетов, счётчики проходов и число предупреждений до валидации, calculate и reflow, затем публикует или восстанавливает все четыре вместе
CommitEdit снимает четыре вида состояния, прежде чем что-то записать, поэтому неудавшийся 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;                     // плохой диапазон либо разорванный суррогат
    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, индекса страницы и ограничивающего бокса, округлённых до четырёх знаков: 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 получают от действий, описанных в статье о скриптах format и calculate AcroForm, но правила разрешения здесь масштаба XFA, а не имени поля

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

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

Слои бюджетов в рантайме 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. Предсказуемый отказ, на который можно ветвиться, лучше частичной эмуляции, работающей на вашем сэмпл-файле и расходящейся на файле клиента

Модель потоков столь же прямолинейна: один экземпляр рантайма принадлежит одному потоку и не имеет внутренней блокировки, потому что движок раскладки возвращается в хостовые колбэки измерения, а блокировка вокруг этого — тупик, ожидающий перерисовки. Rich-содержимое внутри полей следует той же консервативной линии, что и везде в библиотеке, где полезные нагрузки exData обрабатываются так, как описано в статье о rich text и гиперссылках XFA exData, а виджеты подписи и кнопки возвращаются как ReadOnly, тогда как неподдерживаемые kinds интерфейса выходят как xwkUnsupported, а не как редактируемое текстовое поле, молча теряющее данные

Сложенное вместе, это работоспособный ответ на динамический XFA в Delphi: держать DOM живым, делать каждую правку транзакцией, которая либо приземляется целиком, либо не оставляет ничего, ограничивать каждый проход и прямо называть то, что вне охвата. Если вы оцениваете это для потока претензий, налогов или пособий, рантайм XFA поставляется в составе HotPDF Delphi PDF component — рядом с путями AcroForm, сведения и рендеринга, которые таким проектам обычно требуются вместе