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

Установка значений полей формы в загруженном PDF на Delphi

HotPDF Delphi Component заполняет существующее поле AcroForm в загруженном PDF через THotPDF.SetFormFieldValue, адресуясь либо по индексу поля с нумерацией от нуля, либо по полному имени поля. Записать новую запись /V — лёгкая часть; надёжным на формах из реального мира этот вызов делает то, что тот же метод ещё и держит согласованными три части состояния, невидимые до тех пор, пока не сломаются: декодированную идентичность поля, чтобы не-ASCII имя вообще можно было найти, состояние внешнего вида /AS у виджетов флажков и переключателей и массив индексов выбора /I у полей выбора. Видимый поток внешнего вида — отдельный, явный шаг через EnsureLoadedFieldAppearanceStream

Сценарий самый обыденный: заказчик присылает вам свою форму — налоговую декларацию, страховое требование, заказ на покупку, собранный кем-то в Acrobat годы назад, — и ваше приложение на Delphi должно заполнить её из базы данных и вернуть файл, который корректно открывается везде. Как форма сделана, вы не контролируете. Имена полей могут быть закодированы в UTF-16, значения экспорта у флажков могут быть 2, а не Yes, а выпадающие списки могут использовать пары опций [export display]. На каждую из этих деталей есть правило в ISO 32000-1, и каждое правило SetFormFieldValue теперь берёт на себя. Эта статья о том, что он делает, почему и где останавливается. Парная задача — создание полей, которых ещё нет, — разобрана в статье про добавление полей AcroForm в загруженный PDF на Delphi

Почему SetFormFieldValue не находит поле с не-ASCII именем?

До v2.752.1 ответ был в кодировке: поле жило в файле под именем в шестнадцатеричном UTF-16BE, а кеш имён хранил шестнадцатеричное написание вместо текста. ISO 32000-1 §12.7.3.1 определяет частичное имя поля /T как текстовую строку, а §7.9.2.2 говорит, что текстовая строка может быть в UTF-16BE с ведущей меткой порядка байтов FE FF. Инструменты создания форм регулярно сериализуют такие имена как hex-строки по §7.3.4.3, так что поле с именем Straße приходит как <FEFF005300740072006100DF0065>. Внутри HotPDF THPDFStringObject.Value держит сырой шестнадцатеричный текст всякий раз, когда выставлен IsHexadecimal, — это ровно то, что нужно для потерьного кругового рейса исходного словаря, и ровно то, что не годится в качестве ключа поиска. HPDFLoadedFormTextName разделяет эти две заботы. Когда строится кеш отношений, каждое значение /T проходит через неё: если строковый объект шестнадцатеричный, HPDFHexToBytes восстанавливает последовательность байтов; если байты начинаются с FE FF и имеют чётную длину, полезная нагрузка декодируется как UTF-16BE и перекодируется в UTF-8; результат затем присоединяется к имени родителя через точку, образуя полное имя, которое описывает §12.7.3.1, так что дочернее поле с именем City под родителем Address регистрируется как Address.City. Ключ кеша нормализуется в нижний регистр, поэтому SetFormFieldValue('address.city', ...) тоже сработает; это удобство сверх стандарта, поскольку спецификация считает имена регистрозависимыми. Принципиально то, что меняется только ключ кеша. Объект /T в словаре поля сохраняет шестнадцатеричную кодировку, так что сохранение документа не переписывает идентичность поля, которое вы всего лишь заполнили

Как HotPDF разрешает не-ASCII имена AcroForm: HPDFHexToBytes восстанавливает нагрузку UTF-16BE за шестнадцатеричной строкой /T, метка порядка байтов FE FF декодируется и перекодируется в UTF-8, а полное имя присоединяет родителя, так что и Applicant.FullName, и поле с именем Straße попадают в кеш поиска
Меняется только ключ кеша: словарь поля сохраняет шестнадцатеричную кодировку, поиск нормализуется в нижний регистр как удобство сверх стандарта, а сохранение документа никогда не переписывает идентичность поля, которое вы всего лишь заполнили
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('claim-form.pdf') <= 0 then Exit;

    // Полные имена декодируются из строк /T в UTF-16BE и
    // склеиваются через точки, поэтому вложенные и не-ASCII имена находятся
    Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');
    Pdf.SetFormFieldValue('Applicant.Straße', 'Hauptstraße 12');

    // Значения вне Latin-1 едут как hex UTF-16BE с префиксом FEFF
    // и записываются шестнадцатеричной строкой PDF
    Pdf.SetFormFieldValue('Applicant.City', 'FEFF004D00FC006E006300680065006E');

    Pdf.SaveLoadedDocument('claim-form-filled.pdf');
  finally
    Pdf.Free;
  end;
end;

Что SetFormFieldValue на самом деле записывает?

Обе перегрузки выполняют одни и те же пять шагов: находят словарь поля, пишут /V через HPDFSetDictFormValue, приводят в порядок индексы выбора у полей выбора, помечают словарь грязным, согласуют состояния внешнего вида кнопок и наконец записывают индекс поля через NoteLoadedFormFieldDirty. Последний шаг важен, если форма несёт скрипты вычислений, потому что множество грязных полей — это то, что потребляет перегрузка RecalculateLoadedFormFieldsIncremental без параметров, чтобы перезапустить только те вычисления, которые транзитивно читают изменённое поле. Сам HPDFSetDictFormValue аккуратен с типом объекта, который заменяет. Если существующее /V — объект-имя, а именно его используют флажки и переключатели для своего значения экспорта, новое значение записывается как имя и никогда как строка, потому что имена PDF по построению только ASCII. Иначе он пишет строковый объект и проверяет переданное вами значение: строка, начинающаяся с FEFF, чётной длины и состоящая только из шестнадцатеричных цифр, трактуется как проводная форма UTF-16BE из §7.9.2.2 и сохраняется с выставленным IsHexadecimal, так что сериализуется как <FEFF...>, а не как литерал (FEFF...). На этом механизме и держится строка City выше; любая другая строка сохраняется как литеральная с теми байтами, что вы дали, так что для обычного латинского текста передавайте обычный текст

Почему флажок сохраняет старую галочку после смены значения?

Потому что для поля-кнопки одно лишь значение не решает, что нарисовано. ISO 32000-1 §12.7.4.2.3 указывает, что виджет флажка несёт состояние внешнего вида /AS, называющее, какой поток в /AP /N показан сейчас, и просмотрщики рисуют по /AS, а не по /V. Если вы смените /V на Yes, но оставите /AS в Off, файл станет внутренне противоречивым, и уплощение с удовольствием впечатает в страницу устаревший снятый внешний вид, пока данные формы говорят, что галочка стоит. ReconcileLoadedButtonAppearanceStates и закрывает этот разрыв: для поля, у которого /FT равно Btn, она посещает сам словарь поля и каждую запись его массива /Kids, читает имя включённого состояния из /AP /N и переписывает /AS в это имя, когда оно совпадает со значением поля, или в Off, когда нет

Почему флажок в HotPDF сохраняет старую галочку при смене только /V: просмотрщики рисуют по состоянию внешнего вида /AS из /AP /N, поэтому ReconcileLoadedButtonAppearanceStates обходит поле и каждого потомка, читает имя включённого состояния как первый ключ, отличный от Off, и переписывает /AS при совпадении или в Off иначе
Группы переключателей сравнивают каждого потомка со значением родителя, которое InheritedButtonValue восстанавливает обходом цепочки /Parent, поэтому установка группы в одно значение экспорта включает ровно этот виджет и выключает всех соседей

Две детали из реальных форм определили исправление в v2.752.3. Во-первых, словарь обычного внешнего вида может содержать только включённое состояние; §12.7.4.2.3 называет выключенный внешний вид Off, но инструменты создания форм часто опускают его поток и позволяют просмотрщику не рисовать ничего. Более ранний код сдавался, когда в словаре было меньше двух записей, поэтому такие односостоянийные флажки молча сохраняли старую галочку. Теперь проверка сводится к тому, что словарь не пуст, а имя включённого состояния берётся как первый ключ, не равный Off. Во-вторых, имя включённого состояния — любое, какое выбрал автор. Реальные формы используют 2, Yes, On или локализованное слово, поэтому сравнение идёт с фактическим ключом и без учёта регистра, никогда с жёстко зашитым Yes. Переключатели добавляют ещё одну морщину, описанную в §12.7.4.2.4: выбор живёт в /V родительского поля, тогда как отдельные потомки владеют виджетами и обычно не имеют собственного /V. Поэтому вложенный хелпер InheritedButtonValue поднимается по цепочке /Parent до 64 уровней, пока не найдёт непустое значение, так что каждый потомок сравнивается со значением группы, к которой принадлежит. Установка родителя в значение экспорта одного потомка включает ровно этого потомка и выключает всех соседей

// Флажок: значение экспорта должно совпасть с ключом включённого
// состояния в /AP /N (часто 'Yes', но формы используют '2', 'On')
Pdf.SetFormFieldValue('Consent', 'Yes');

// Группа переключателей: /V пишется родителю; каждому виджету-потомку
// /AS выставляется в его собственное имя экспорта или в Off
Pdf.SetFormFieldValue('PaymentMethod', 'Card');

// Снятие флажка: значение без совпадений с on-состоянием даёт /AS Off
Pdf.SetFormFieldValue('Newsletter', 'Off');

Поля выбора: держим /I в ногу с /V

Для выпадающего списка или списка /V — не единственное место, где записан выбор. Table 231 в §12.7.4.4 определяет /I как массив индексов с нумерацией от нуля в /Opt, определяющих выбранные элементы, и просмотрщик, обнаруживший /I, указывающий на опцию 0, тогда как /V называет опцию 3, может подсветить не ту строку. Начиная с v2.754.1 HPDFReconcileChoiceSelection выполняется внутри каждого вызова SetFormFieldValue и, когда унаследованное /FT равно Ch, перестраивает /I из нового значения. Порядок операций намеренный. Локальная запись /I удаляется первой и без обращения к её содержимому: если старый массив был косвенным объектом, общим с другим полем, изменение его на месте испортило бы выбор другого поля, поэтому процедура отбрасывает ссылку и создаёт вместо неё свежий прямой массив. Затем она разрешает /Opt по цепочке /Parent, поскольку опции выбора могут наследоваться, и сканирует записи. Голая строка-опция сравнивается напрямую; пара [export display] сравнивается по своему элементу экспорта, а пара с менее чем двумя элементами пропускается. Обе стороны проходят через HPDFLoadedFormTextName, так что опция в hex-UTF-16 совпадает со значением в hex-UTF-16 без того, чтобы вы писали их одинаково. При первом совпадении записывается /I из одного элемента, и сканирование прекращается; скалярное значение всегда заменяет любой предыдущий множественный выбор независимо от флага MultiSelect

Как HotPDF держит поле выбора согласованным: HPDFReconcileChoiceSelection удаляет локальный массив /I до любого обращения к нему, разрешает /Opt по цепочке /Parent, сравнивает половину экспорта каждой опции через HPDFLoadedFormTextName, пишет /I из одного элемента при первом совпадении и не пишет ничего, когда у редактируемого значения списка нет индекса
Голая строка-опция сравнивается напрямую, а пара export display — по своему элементу экспорта, тогда как значение вне /Opt корректно не оставляет индекса: устаревший /I, указывающий на не ту строку, был бы хуже, чем никакого

Когда ничего не совпало, /I не записывается вообще. Для редактируемого выпадающего списка это верный исход: §12.7.4.4 позволяет пользователю ввести значение вне списка опций, у такого значения индекса нет, и устаревший индекс был бы хуже, чем никакого. То же самое вы получите, если передадите отображаемую метку вместо значения экспорта в список парных опций, так что если выпадающий список отказывается показывать ваш выбор, проверьте, какую половину пары вы подали

// /Opt равно [[US United States] [CA Canada] [MX Mexico]]:
// совпадение по значению экспорта, и /I становится [1]
Pdf.SetFormFieldValue('Country', 'CA');

// Редактируемый список со значением вне /Opt: /V записывается,
// /I удаляется, и никакой индекс не выдумывается
Pdf.SetFormFieldValue('Title', 'Principal Engineer');

Значение и внешний вид — две отдельные операции

SetFormFieldValue никогда не трогает поток внешнего вида текстового поля или поля выбора. После вызова /V держит новый текст, тогда как /AP /N всё ещё рисует старый, и что из двух покажет просмотрщик, зависит от того, несёт ли словарь AcroForm /NeedAppearances true по §12.7.3.3 и уважает ли это просмотрщик. Если вам нужно, чтобы файл отрисовал новое значение в каждом читателе, включая уплотнители и генераторы миниатюр, игнорирующие флаг, вызовите EnsureLoadedFieldAppearanceStream с индексом поля. Он строит Form XObject из унаследованной строки /DA, выключки /Q, гребёнчатой раскладки /MaxLen и значения, разрешает именованный шрифт через ресурсы /DR словаря AcroForm, чтобы шрифт Type0 сохранил свой подчинённый шрифт и не выродился в Helvetica, и возвращает True, когда хотя бы один виджет получил поток. Перегрузка SetFormFieldValue по имени не возвращает индекс, так что возьмите его через GetFormField, который возвращает принадлежащий вам THPDFLoadedFormField, и его нужно освободить. Регрессионный набор для изменения в v2.752.1 прямо говорит об этом разделении: он выставляет значение, вызывает EnsureLoadedFieldAppearanceStream, затем рендерит страницу и проверяет, что пиксели внутри прямоугольника виджета изменились, а снаружи — нет. Проверка того, что /V изменился, не доказывает ничего о том, что увидит пользователь

var
  Field: THPDFLoadedFormField;
begin
  Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');
  Field := Pdf.GetFormField('Applicant.FullName');
  try
    // Вписываем новое значение в /AP, чтобы просмотрщики,
    // игнорирующие /NeedAppearances, всё равно его показали
    if not Pdf.EnsureLoadedFieldAppearanceStream(Field.Index) then
      raise Exception.Create('No widget rectangle to paint into');
  finally
    Field.Free;
  end;
  Pdf.SaveLoadedDocument('claim-form-filled.pdf');
end;

Границы, которые стоит знать, прежде чем на это опираться

ReconcileLoadedButtonAppearanceStates проверяет локальное /FT того словаря, к которому вы обратились, поэтому она действует на родителя группы переключателей или на флажок, несущий собственное /FT; виджет-потомок, к которому обратились отдельно, с /FT только у его родителя, через этот путь не согласуется. HPDFReconcileChoiceSelection обрабатывает одно скалярное значение и пишет не более одного индекса; списки с множественным выбором и несколькими выбранными записями вне того, что моделирует SetFormFieldValue. Ни одна из процедур не проверяет переданное вами значение ни по /Opt, ни по ключам включённых состояний, так что опечатка даёт флажок в состоянии Off или список без индекса, а не исключение. А GetFormFieldValue возвращает текст /V таким, каким он лежит в словаре, что для шестнадцатеричного значения означает шестнадцатеричное написание, а не декодированный текст

Когда значения внесены и внешний вид нарисован, два естественных следующих шага стоят по обе стороны от этой операции. Массовый обмен данными полей с внешними системами, а не по одному вызову SetFormFieldValue за раз, покрывает импорт и экспорт XFDF в Delphi. А когда заполненная форма готова и больше не должна редактироваться, уплощение полей AcroForm и XFA в Delphi впечатывает в статическое содержимое страницы ровно те состояния /AS и потоки внешнего вида, о которых здесь речь, — поэтому привести их в согласованный вид до уплощения не опция, а необходимость

API редактирования загруженных форм из этой статьи, включая SetFormFieldValue, EnsureLoadedFieldAppearanceStream и граф инкрементального пересчёта, поставляется в составе HotPDF Delphi Component для Delphi и C++Builder