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

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

В списке полей почти любого PDF-вьюера поле улицы на немецкой форме заявления читается как Straße. Байты за его записью /T рассказывают другую историю: <FEFF005300740072006100DF0065>, шестнадцатеричная строка UTF-16BE с меткой порядка байтов впереди. Код, матчащий имена полей против этих сырых байтов, доложит поле отсутствующим, хотя пользователь может кликнуть прямо в него. Та же форма может хранить on-состояние флажка как 2 вместо Yes, а список стран — как пары [export display], что даёт три отдельных способа, которыми записанное вами значение и нарисованное вьюером расходятся

Все три вариации из одного места: форма, построенная годы назад в том инструменте авторинга, что был у клиента, — форма, которую ваше Delphi-приложение теперь должно заполнить из базы данных и отдать файлом, одинаково рендерящимся в каждом ридере. Переизобрести форму нельзя, значит код заполнения обязан следовать правилам, под которыми форма писалась. У ISO 32000-1 на каждый из трёх случаев есть правило: §7.9.2.2 для текстовых строк, §12.7.4.2.3 для состояний внешнего вида кнопок, таблица 231 в §12.7.4.4 для выбора в списках. Создание ещё не существующих полей — другая работа, описанная в добавлении полей AcroForm в загруженный PDF на Delphi

Найти поле: HotPDF декодирует /T, прежде чем матчить имена

THotPDF.SetFormFieldValue имеет две перегрузки — одну с нулевым индексом поля, другую с полностью квалифицированным именем, — и обе разрешаются против таблицы имён, которую компонент строит из дерева AcroForm при первом использовании. Пока таблица строится, каждое частичное имя проходит через декодер. Шестнадцатеричная строка /T разворачивается обратно в байты; если те начинаются с FE FF и имеют чётную длину, остаток читается как UTF-16BE и перекодируется в UTF-8. Затем каждое декодированное частичное имя присоединяется к родителю через точку, ровно как описывает §12.7.3.1, так что поле-потомок City под родителем Address регистрируется как Address.City. Декодируется только ключ поиска. Объект /T в файле сохраняет шестнадцатеричное написание, и сохранение документа не трогает идентичность поля, которое вы просто заполнили

Ключи приводятся к нижнему регистру перед попаданием в таблицу, поэтому 'address.city' находит Address.City. Это за пределами стандарта, трактующего имена полей как регистрозависимые, и означает, что два поля, различающиеся лишь регистром, столкнутся в поиске. Для кода, читающего имена полей из конфиг-файла, набитого вручную, пропущенная заглавная буква — куда более частый отказ, чем два поля в одной форме, различающиеся только регистром

Как HotPDF разрешает не-ASCII имена AcroForm: HPDFHexToBytes восстанавливает нагрузку UTF-16BE за шестнадцатеричной строкой /T, метка порядка байтов FE FF декодируется и перекодируется в UTF-8, а полное имя присоединяет родителя, так что и Applicant.FullName, и поле с именем Straße попадают в кеш поиска
Меняется только ключ кеша: словарь поля сохраняет шестнадцатеричную кодировку, поиск нормализуется в нижний регистр как удобство сверх стандарта, а сохранение документа никогда не переписывает идентичность поля, которое вы всего лишь заполнили

Декодированные имена — байты UTF-8, носимые в AnsiString, и вот тут собственные строковые преобразования Delphi встают поперёк. Литерал вроде 'Applicant.Straße', переданный параметру AnsiString, конвертируется через системную ANSI-кодовую страницу, поэтому ß приходит как одиночный байт DF вместо пары UTF-8 C3 9F, и поиск промахивается. ASCII-имена не задеты. Для всего остального перечислите поля однажды, декодируйте каждое имя через UTF8ToString и пишите через перегрузку по индексу:

// FindFieldIndex — помощник, который вы добавляете сами: он сравнивает
// декодированные UTF-8 имена со строкой Unicode, так что никакая ANSI
// кодовая страница не стоит между вашим литералом и поиском
function FindFieldIndex(Pdf: THotPDF; const Name: string): Integer;
var
  I: Integer;
begin
  for I := 0 to Pdf.GetFormFieldCount - 1 do
    if SameText(UTF8ToString(Pdf.GetFormFieldName(I)), Name) then
      Exit(I);
  Result := -1;
end;

var
  Pdf: THotPDF;
  Street: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('claim-form.pdf') <= 0 then Exit;

    // ASCII-имена могут идти напрямую в перегрузку по имени
    Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');

    Street := FindFieldIndex(Pdf, 'Applicant.Straße');
    // Значение тоже AnsiString, поэтому литерал 'ß' зависел бы от системной
    // кодовой страницы; FEFF hex (Hauptstraße 12) этого избегает
    if Street >= 0 then
      Pdf.SetFormFieldValue(Street,
        'FEFF00480061007500700074007300740072006100DF0065002000310032');

    // Тот же FEFF-префиксованный UTF-16BE hex работает и с перегрузкой по имени
    // (здесь это München); пишется как hex-строка
    Pdf.SetFormFieldValue('Applicant.City', 'FEFF004D00FC006E006300680065006E');

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

Что SetFormFieldValue пишет, шаг за шагом

Обе перегрузки после нахождения поля выполняют одну последовательность: записать /V, пересобрать индекс выбора, пометить поле изменённым, переключить состояния внешнего вида кнопок, регенерировать внешний вид текста или списка и записать поле в dirty-набор. Последний шаг питает RecalculateLoadedFormFieldsIncremental без аргументов, который перезапускает только скрипты расчёта, читающие изменённое поле — напрямую или через цепочку вроде позиция строки, подытог, итог

Тип объекта нового /V зависит от поля. Для флажка или радиокнопки SetFormFieldValue всегда пишет name-объект: §12.7.4.2.3 хранит выбранное состояние как имя, а PDF-имена — простые байты без кодирования текстовых строк. Любое другое поле получает string-объект, и передаваемое значение сначала инспектируется. Строка, начинающаяся с FEFF, чётной длины и из одних шестнадцатеричных цифр, считается UTF-16BE-формой из §7.9.2.2 и сериализуется как <FEFF...>. На это полагается строка City выше. Всё прочее хранится как literal-строка с ровно теми байтами, что вы подали, так что простой латинский текст входит простым текстом

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

Для поля-кнопки значение не решает, что рисуется. Виджет флажка несёт запись /AS, называющую, какой поток из /AP /N сейчас показывается, и вьюеры красят из /AS, а не из /V. Смените /V на Yes, пока /AS всё ещё говорит Off, — и файл противоречит сам себе: флэттенер запечёт незаполненный бокс в страницу, тогда как данные формы утверждают, что он заполнен. Поэтому SetFormFieldValue посещает поле-кнопку и каждую запись его массива /Kids, читает имя on-состояния из /AP /N каждого виджета и выставляет /AS в то имя при совпадении со значением поля или в Off при несовпадении

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

Реальные формы гнут это правило двумя способами. Внешний вид выключенного состояния назван Off в стандарте, но инструменты авторинга часто вовсе не включают его поток и позволяют вьюеру не рисовать ничего, так что словарь нормального внешнего вида с единственной записью совершенно легален. Имя on-состояния — что автор выбрал: Yes, On, 2, слово на языке самой формы. Поэтому on-состояние берётся как первый ключ в /AP /N, не равный Off, а сравнение с вашим значением игнорирует регистр. Радиокнопки добавляют устройство из §12.7.4.2.4: выбранное значение живёт в /V родительского поля, тогда как каждый виджет-потомок обычно не имеет собственного /V. Каждый потомок сравнивается со значением, унаследованным через его цепочку /Parent

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

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

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

Оборотная сторона: опечатка не порождает исключения. 'yes ' с висящим пробелом не совпадает ни с чем, поэтому бокс кончается Off, а файл по-прежнему совершенно валиден. Если ваши значения приходят из колонки базы данных, подстригайте их до вызова

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

Combo box или list box записывает выбор в двух местах. Рядом с /V таблица 231 определяет /I как массив нулевых индексов в /Opt, и вьюер, обнаруживший /I, указывающий на опцию 0, при /V, называющем опцию 3, может подсветить не ту строку. SetFormFieldValue пересобирает /I при каждом вызове к полю выбора. Она удаляет собственную запись /I поля прежде всего остального и никогда не правит старый массив на месте: indirect-массив может быть разделён с другим полем, и мутация сменила бы и его выбор. Затем она разрешает /Opt через цепочку /Parent, поскольку опции могут наследоваться, и идёт по записям. Голая строка-опция сравнивается напрямую, пара [export display] — по своему элементу экспорта, а пара короче двух элементов пропускается

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

Сравнение идёт по декодированному тексту, поэтому опция, хранимая в PDFDocEncoding, совпадает со значением, записанным UTF-16BE hex, и наоборот. Скалярное значение даёт максимум один индекс; какой бы мультиселект поле ни держало прежде, он заменяется. Когда не совпало ничего, /I вообще не пишется. Для редактируемого combo box это корректный исход: §12.7.4.4 позволяет пользователю впечатать значение вне списка, и такому значению не в какой индекс указывать

За тем же правилом стоит самая частая жалоба на combo box: вызов успешен, /V держит новый текст, а вьюер не показывает ничего выбранного. Обычно вызывающий передал отображаемую метку (Canada) списку, построенному из пар [CA Canada]. Компонент не может трактовать это как ошибку, не сломав редактируемые комбо, так что проверка — забота вашего кода. GetFormFieldOptionValue возвращает элемент экспорта каждой опции, что делает страховку короткой:

// SetChoiceByExport — ваша собственная страховка, не часть компонента:
// она отказывает значениям, не совпавшим ни с каким элементом экспорта в /Opt
procedure SetChoiceByExport(Pdf: THotPDF; const FieldName,
  ExportValue: AnsiString);
var
  I: Integer;
begin
  for I := 0 to Pdf.GetFormFieldOptionCount(FieldName) - 1 do
    if Pdf.GetFormFieldOptionValue(FieldName, I) = ExportValue then
    begin
      // /Opt — это [[US United States] [CA Canada] [MX Mexico]]:
      // 'CA' совпадает с элементом экспорта, и /I становится [1]
      Pdf.SetFormFieldValue(FieldName, ExportValue);
      Exit;
    end;
  raise Exception.CreateFmt('%s is not an export value of %s',
    [string(ExportValue), string(FieldName)]);
end;

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

Показывается ли новое значение без /NeedAppearances?

Да, для текстовых полей и списков. Вьюер рисует сохранённый поток /AP и не смотрит в /V, так что новое значение со старым внешним видом оставляет старый текст на экране и в печати, если словарь AcroForm не несёт /NeedAppearances true (§12.7.3.3) и вьюер не соизволит его уважить. Флэттенеры и генераторы миниатюр часто игнорируют тот флаг. SetFormFieldValue регенерирует поток внешнего вида для текстовых полей и списков как часть вызова, тем же билдером, что и публичный EnsureLoadedFieldAppearanceStream. Поток строится из унаследованной строки /DA, выравнивания /Q, /MaxLen для comb-полей и самого значения. Шрифт, названный в /DA, разрешается через ресурсы AcroForm /DR, поэтому Type0-шрифт сохраняет собственные глифы вместо отката к Helvetica. Кнопки сохраняют нарисованные дизайнером внешние виды состояний; у них двигается только /AS

SetFormFieldValue — процедура и не говорит, было ли что-то нарисовано. Виджет без пригодного /Rect потока не получает, а сломанные формы с осиротевшими виджетами или виджетами без прямоугольника существуют. Когда отрисованный результат важен, вызывайте EnsureLoadedFieldAppearanceStream сами после установки значения. Она возвращает True, когда хотя бы один виджет получил поток. GetFormField отдаёт THPDFLoadedFormField, чьё свойство Index и ожидает вызов; объектом владеете вы и обязаны его освободить

var
  Field: THPDFLoadedFormField;
begin
  Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');
  Field := Pdf.GetFormField('Applicant.FullName');
  try
    // False значит, что ни у одного виджета поля не было прямоугольника
    // для рисования; значение сохранено, но показывать его нечему
    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;

Проверка, что /V изменился, доказывает об увиденном пользователем очень мало. Тест, который что-то значит, рендерит страницу до и после и подтверждает, что пиксели внутри прямоугольника виджета изменились, а снаружи — нет. Мы держим эту проверку, потому что одни лишь байты внешнего вида нас уже подводили: поток держал каждый правильный байт, а вьюер, уважающий /NeedAppearances true, пересобрал отображение из /V и не показал из него ничего

Чтение значений обратно и что обычно идёт следом

GetFormFieldValue возвращает унаследованный /V поля. Значение, хранимое как FEFF-префиксованный hex, возвращается декодированным в UTF-8, поэтому поле City выше читается обратно как байты UTF-8 строки München, и UTF8ToString превращает их в Delphi-строку. Literal-строка возвращается с теми байтами, с какими хранилась. Для round-trip-проверок сравнивайте декодированный текст по обе стороны, а не написание, которое вы передали

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

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