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

Атомарное пакетное заполнение PDF-форм на Delphi

PDFium Component for Delphi заполняет AcroForm атомарно через TPdf.SetFormFieldValues: вы передаёте от 1 до 256 типизированных запросов, адресованных страницей и виджетом, и компонент либо применяет каждое значение одним замещением документа, либо не применяет ничего. Когда один запрос невалиден, TPdfFormValuesWriteReport.FailedRequest называет запрос, утопивший батч

Эта гарантия важнее всего в работах, за которыми никто не следит. Ночная загрузка толкает строки CRM в форму претензии, или печатный сервис накладывает данные полиса на предпечатный шаблон. Гоняйте эти поля по одному — и первое плохое значение, код, не являющийся валидным radio-экспортом, строка длиннее MaxLen поля, оставят вас с PDF, наполовину из старых данных и наполовину из новых. Ничего не упало, значит никто не встревожен. Батч-API существует, чтобы провал был чистым отказом, а не наполовину записанным документом

Почему заполнение поля за полем опасно при импорте данных?

Заполнение поле за полем опасно, потому что каждая запись коммитится сама по себе, поэтому ошибка посреди цикла оставляет документ, не совпадающий ни с исходной записью, ни с исходным шаблоном. Классический паттерн — цикл по присваиваниям FormField[Index], каждый из которых идёт через нативную form-fill среду PDFium по одному виджету. Значения с 1 по 17 легли, значение 18 упало, и вызывающему теперь нужно точно знать, какие значения вошли, чтобы отменить их

Большинство вызывающих никогда не пишут тот код отмены, а пишущие обычно делают его тонко неправильно: shared-поле уже изменилось на каждой странице, где оно появляется, и состояния чекбоксов уже перевернулись. Если сохранение идёт в блоке finally, наполовину заполненный файл уходит на диск выглядящим завершённым. В overprint-работе это хуже отсутствия файла: он печатается, и кто-то его подписывает

Другая тихая проблема — фокус. Классический сеттер ведёт интерактивный редактор PDFium: убивает существующий фокус, фокусирует целевой виджет, выделяет весь текст, заменяет и снова убивает фокус. Для headless-работы нормально, но во вьюере это заодно обрывает сессию правки, которую пользователь держал открытым в другом поле. Типизированный путь записи, приехавший в PDFium Component 3.231.0, вообще не проходит через нативный фокус полей, а 3.233.0 расширил его с одиночных полей на межстраничные батчи

Как SetFormFieldValues применяет батч одним замещением

TPdf.SetFormFieldValues этапирует каждый запрос против одного снятого снимка документа, пишет все словари полей одним проходом и подменяет готовый результат единственным замещением. Исходный документ живёт до самого последнего шага, поэтому любой провал до него оставляет его нетронутым

Пятистадийная последовательность атомарного батч-заполнения форм в PDFium Component для Delphi: от префлайта до верификации и подмены с исходами всё-или-ничего
каждый запрос этапируется против одного снимка, и живой документ трогается только в финальной верифицированной подмене

Последовательность объясняет и гарантию, и цену:

  1. Сначала прогоны префлайт-проверок: документ должен быть активен, число запросов 1..256, не открыта транзакция page-object или журнала правок, ни одно нативное поле не держит фокус, а документ должен давать право fill-forms или modify
  2. Каждый запрос валидируется на диапазон страниц и бюджет ввода, и FailedRequest отслеживает проверяемый индекс
  3. Компонент снимает edit-снимок, нормализует AcroForm в той копии и применяет каждый запрос по порядку, записывая /V, /I для choice-выборов и /AS каждому виджету, делящему поле
  4. Изменения дописываются к снимку одним разреженным инкрементным обновлением в смысле ISO 32000-1 §7.5.6, поэтому неродственные объекты переносятся байт в байт
  5. Отдельный экземпляр-верификатор сперва грузит байты-кандидаты; только после чистого открытия компонент выгружает живой документ и грузит новый, откатываясь к исходному снимку, если та загрузка упала

Внутренний шаг замещения — та же механика, что компонент использует для снимков журнала правок, поэтому журнал отмены и crash-recovery и батч-запись не могут пересекаться: сперва завершите или отмените транзакцию журнала. Автоматические действия жизненного цикла подавлены, пока документ подменяется, поэтому скрипт document-open не может переписать поля или захватить фокус посреди замещения

Сборка типизированного батча запросов на Delphi

Батч — динамический массив записей TPdfFormValueRequest, каждая держит Page с 1, page-local FieldIndex с 0 и типизированный TPdfFormValue. Запись значения несёт Kind из fvkText, fvkChoice, fvkCheckBox или fvkRadioButton плюс члены Text, ChoiceIndices и Checked, используемые согласно этому виду

Импортируемые данные обычно приходят с ключами по имени поля, поэтому первая работа — перевод имён в адреса страницы и виджета. FieldIndex считает виджет-аннотации страницы в том же порядке, что FormFieldCount и FormFieldInfo, поэтому простой скан работает. Хелпер берёт первый найденный виджет для имени, что существенно для shared-полей, как объясняет следующий раздел

type
  TFieldAddress = record
    Page, FieldIndex: Integer;
  end;

function FindFieldAddress(Pdf: TPdf; const FieldName: WString;
  out Address: TFieldAddress): Boolean;
var
  P, I: Integer;
begin
  Result := False;
  for P := 1 to Pdf.PageCount do
  begin
    Pdf.PageNumber := P;
    for I := 0 to Pdf.FormFieldCount - 1 do
      if Pdf.FormFieldInfo[I].Name = FieldName then
      begin
        Address.Page := P;
        Address.FieldIndex := I;
        Exit(True);      // только первый виджет: один запрос на поле
      end;
  end;
end;

procedure AddRequest(var Requests: TPdfFormValueRequests;
  const FieldName: WString; Pdf: TPdf; const Value: TPdfFormValue);
var
  Address: TFieldAddress;
  N: Integer;
begin
  if not FindFieldAddress(Pdf, FieldName, Address) then
    raise Exception.CreateFmt('Template has no field "%s"', [FieldName]);
  N := Length(Requests);
  SetLength(Requests, N + 1);
  Requests[N].Page := Address.Page;
  Requests[N].FieldIndex := Address.FieldIndex;
  Requests[N].Value := Value;
end;

Обратите внимание: AddRequest падает на неизвестном имени, а не пропускает его. Батч валидирует адреса, не вашу маппинг-логику, поэтому отсутствующее поле шаблона обязано упасть здесь — иначе не упадёт никогда. С адресами, разрешёнными, сборка значений и коммит коротки

var
  Pdf: TPdf;
  Requests: TPdfFormValueRequests;
  Report: TPdfFormValuesWriteReport;
  V: TPdfFormValue;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'claim-template.pdf';
    Pdf.Active := True;

    V := Default(TPdfFormValue);
    V.Kind := fvkText;
    V.Text := 'Maria Lopez';
    AddRequest(Requests, 'Claimant.Name', Pdf, V);

    V := Default(TPdfFormValue);
    V.Kind := fvkCheckBox;
    V.Checked := True;
    AddRequest(Requests, 'Consent', Pdf, V);

    V := Default(TPdfFormValue);
    V.Kind := fvkRadioButton;
    V.Text := 'Express';                // экспортное значение выбираемой кнопки
    AddRequest(Requests, 'Shipping', Pdf, V);

    V := Default(TPdfFormValue);
    V.Kind := fvkChoice;
    SetLength(V.ChoiceIndices, 1);
    V.ChoiceIndices[0] := 2;            // индекс с 0 в массив Opt поля
    AddRequest(Requests, 'Region', Pdf, V);

    if Pdf.SetFormFieldValues(Requests, Report) then
      Pdf.SaveAs('claim-filled.pdf')
    else
      LogBatchFailure(Requests, Report);
  finally
    Pdf.Free;
  end;
end;

Типизированные виды сверяются с типом поля до любой записи. Текстовое значение, нацеленное на чекбокс, radio-значение, нацеленное на простой чекбокс, или запись в push-кнопку — всё отвергается. Choice-индексы обязаны быть валидными, уникальными и возрастающими; больше одного индекса требует флага MultiSelect, а свободный текст в choice-поле принимает только редактируемый combo box. Текст длиннее MaxLen поля отвергается вместо обрезки, а read-only поля отвергают outright. Для radio-групп с флагом NoToggleToOff пустое экспортное значение — «очистить группу» — тоже отвергается

Почему двойная запись в shared-поле валит весь батч?

TPdf.SetFormFieldValues отвергает два запроса, разрешающихся в одно и то же терминальное поле, потому что оба записали бы одно значение, а компонент отказывается позволить одному запросу молча перезаписать другой. Проверка идёт по владельцу поля, а не по виджету, поэтому два разных виджета одного поля на двух разных страницах тоже считаются дубликатом

Причина — модель поля из ISO 32000-1 §12.7.3.1. Терминальное поле владеет значением; его виджет-аннотации — лишь места, где значение показывается. Поле PolicyNumber, стоящее в шапке каждой страницы, — одно поле с несколькими виджетами, и каждый из них показывает одно /V. Radio-группа устроена так же: каждая кнопка — виджет, группа — поле. Записать PolicyNumber через виджет страницы 1 и снова через виджет страницы 9 — записать одно поле дважды, и любое правило, выбирающее победителя, прячет баг в вашей маппинг-логике

Поэтому батч трактует это как ошибку и называет второй запрос в FailedRequest. На практике починка выше по течению: разрешайте каждое имя поля ровно в один адрес, как делает FindFieldAddress, и позволяйте компоненту распространить значение на остальные виджеты. Он делает это как часть записи, выставляя /AS каждому виджету shared-чекбокса или radio-группы, чтобы состояние вида согласовалось со значением поля, — то же разделение раскрыто в статье о значении поля чекбокса против состояния виджета

Одно терминальное поле владеет значением, виджеты на двух страницах его показывают, и дублирующий запрос валит батч в PDFium Component для Delphi
один запрос на терминальное поле — компонент пишет /V один раз и выставляет /AS каждому виджету, делящему поле

Чтение отчёта об отказе

Упавший батч возвращает False, не поднимает исключение и оставляет исходный документ и каждое значение полей ровно как были. TPdfFormValuesWriteReport затем сообщает Succeeded = False, нули в обоих UpdatedFields и UpdatedWidgets, индекс с 0 провинившегося запроса в FailedRequest и человекочитаемую причину в ErrorMessage

procedure LogBatchFailure(const Requests: TPdfFormValueRequests;
  const Report: TPdfFormValuesWriteReport);
var
  R: Integer;
begin
  R := Report.FailedRequest;
  if R >= 0 then
    Log(Format('Request %d (page %d, widget %d) rejected: %s',
      [R, Requests[R].Page, Requests[R].FieldIndex, Report.ErrorMessage]))
  else
    Log('Batch rejected as a whole: ' + Report.ErrorMessage);
end;

FailedRequest равный -1 означает, что отказ принадлежит транзакции, а не запросу: активен нативный фокус, документ подписан, форма XFA или провалилась проверка прав. Неотрицательное значение указывает на одну запись. При переполнении агрегатного бюджета оно указывает на запрос, дожавший батч за лимит, — не обязательно тот, который неправ. Держите параллельный массив имён столбцов источника рядом с Requests, и индекс 14 превращается в «строка 2087, столбец ShipDate» прямо в логе импорта

При успехе UpdatedFields равно числу запросов, а UpdatedWidgets считает каждый тронутый виджет, включая shared, — дешёвая проверка адекватности против шаблона

Преусловия и лимиты, в которые вы упрётесь

Самый частый отказ во вьюерном приложении — нативный фокус: если пользователь печатает в поле, SetFormFieldValues отказывает, ещё не снимая снимок, и висящий буфер редактора выживает целым. Компонент не сбросит фокус за вашей спиной: это либо закоммитило бы, либо потеряло текст, который пользователь не дописал. Вызывайте ClearFormFieldFocus сознательно — он завершает сессию редактора так же, как клик вне поля, — и затем повторяйте

Бюджеты фиксированы и задокументированы, поэтому импорты можно размерить по ним:

  • От 1 до 256 запросов на батч; делите более крупные записи на несколько батчей и принимайте, что атомарность тогда держится на батч
  • Не более 262 144 единиц UTF-16 текста и 4 096 choice-индексов на запрос
  • Не более 1 048 576 единиц UTF-16 текста и 16 384 choice-индексов на весь батч
  • Обычные бюджеты документа для парсера снимков действуют по-прежнему, включая источник 64 МиБ и 65 536 объектов

Успешное замещение также инвалидирует нативные хендлы, поиски и выделения, полученные до вызова: документ под ними новый. Активация страниц и видов и логические курсоры полей сохраняются; всё прочее закэшированное — скажем, хендлы аннотаций или page-object — надо получить заново

Чем это отличается от импорта XFDF?

TPdf.ImportXFDF и TPdf.SetFormFieldValues оба кладут данные в поля, но импорт XFDF — толерантное слияние, а батч — строгая транзакция. Импорт XFDF матчит значения к полям по имени, пропускает ненайденные, игнорирует дубликаты и возвращает, сколько записей применил. Это ровно верно для слияния комментариев ревьюера, где и частичный результат полезен

Батч поступает противоположно по каждому пункту. Он адресует виджеты явно, отвергает неизвестные или не те по типу цели, трактует дубликаты как ошибки и не имеет частичного исхода. XFDF — когда данные файл обмена из другого инструмента; SetFormFieldValues — когда маппингом владеет ваш собственный код и наполовину применённая запись была бы дефектом

Матрица сравнения импорта XFDF и атомарного батча SetFormFieldValues в PDFium Component для Delphi: адресация, неизвестные и дублирующие цели, исход и лучшее применение
XFDF толерантно сливает файлы обмена, а батч отвергает неизвестные имена, дубликаты и частичные исходы

Чего батч-запись не покрывает

Батч — ограниченный AcroForm-профиль, и вне его он говорит «нет», а не угадывает. XFA-формы отвергаются и требуют своего редактора. Подписанные документы отвергаются: переписывание словарей полей под существующей подписью — подписной конвейер, а не операция заполнения; зашифрованные снимки тоже вне профиля. Кривые или двусмысленные массивы экспортных значений кнопок отвергаются, а radio-экспортные значения, совпадающие более чем с одной кнопкой, отвергаются, если группа не ставит RadiosInUnison. Транзакция также нормализует прямые словари полей перед записью — тот случай, что стоит за значениями полей, кажущимися откатившимися

Видам нужна одна фраза внимания. Для изменённых текстовых и choice-виджетов батч бросает устаревший /AP вместо отгрузки вида, показывающего старое значение, и выключает NeedAppearances, чтобы PDFium не пересобрал каждый неродственный виджет формы. PDFium регенерирует отсутствующие виды при рендеринге. Если заполненный PDF уходит в строгий печатный конвейер, никогда не синтезирующий виды, сперва отрендерьте или отформите его через PDFium и проверьте тот путь на собственных шаблонах

SetFormFieldValues и типизированные записи значений поставляются в PDFium Component for Delphi and Lazarus, где страница продукта перечисляет полный API заполнения форм и AcroForm рядом с рендерингом, редактированием и вьюер-контролами