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

Навигация по полям форм PDF в Delphi (PDFium Component)

Нажмите клавишу Tab в созданной вашим кодом форме PDF, и курсор перескочит на два поля вперед, пропустит второй столбец или вернется в начало после третьего поля вместо четвертого. Пользователь, заполняющий счет в окне просмотра, ожидает, что клавиатура переключает поля формы точно так же, как в любом веб-приложении. Если этого не происходит, пользователь берет мышь, ищет нужное поле и делает вывод о незавершенности вашего решения. Предсказуемый обход полей — разница между формой ввода, с которой пользователи мирятся, и той, которой доверяют. И это полностью зависит от использования правильного API фокуса, а не от имитации кликов мыши

Приведенные ниже примеры используют PDFium Component — VCL/LCL-компонент на базе PDFium для Delphi, C++Builder и Lazarus. Навигация — одна из трех задач, которые средство просмотра форм должно решать корректно; две другие (правильное открытие форм и сохранение заполненных значений так, чтобы они действительно отображались) таят больше сюрпризов, поэтому мы рассмотрим все три

Открытие формы: FormFill, FormType и архитектура XFA

Доступ к полям требует активации подсистемы заполнения форм (управляемой свойством FormFill) до открытия документа. После активации свойство FormType сообщает тип формы, определяя набор доступных функций:

Диаграмма ветвей настройки FormFill и распознавания FormType в просмотрщике PDFium Component в Delphi: разделение обработки ftNone, ftAcroForm и ftXfaFull
FormType ветвится, как только включён FormFill, и каждая ветка обещает разный набор возможностей
Pdf.FileName := FormPath;
Pdf.FormFill := True;   // включить до Active; требуется для доступа к любому полю
Pdf.Active := True;

case Pdf.FormType of
  ftNone:
    DisableFormPanel('This document has no interactive form');
  ftAcroForm:
    BuildFieldList;     // доступна полная навигация и редактирование полей
  ftXfaFull:
    ShowXfaNotice;      // XFA визуализируется из собственного XML-шаблона
                        // считать редактирование полей ограниченным
end;

Из этого переключения следуют два практических замечания. AcroForm — стандартная модель форм ISO 32000, и именно на неё нацелены все API здесь. Документы XFA несут собственную XML-архитектуру форм, поэтому пообещать клиенту полноценное редактирование XFA после быстрой демонстрации на AcroForm — обязательство, о котором вы пожалеете. Второе замечание о побочных эффектах: установка FormFill в True также инициализирует JavaScript документа. В средстве ввода данных это ровно то, что нужно, потому что расчётные скрипты держат бегущий итог актуальным, пока кто-то печатает. В окне предпросмотра файлов неизвестного происхождения это ровно то, чего не нужно. Сторона этого компромисса с FormFill := False разобрана в статье о безопасном предпросмотре PDF

Переход по клавише Tab, предсказуемый для пользователей

Вернемся к проблеме навигации с клавиатуры. Часто возникает желание сымитировать клавишу Tab путем симуляции клика мыши по прямоугольнику следующего интерактивного элемента. Это решение ломается, как только поле прокручивается за экран или элементы перекрывают друг друга. API фокуса перемещает фокус самой формы напрямую, без расчета геометрии. За это отвечают пять методов: FocusFormField по индексу, FocusNextFormField и FocusPreviousFormField для перемещения, FocusedFormFieldIndex для чтения текущей позиции и ClearFormFieldFocus для сброса фокуса

Диаграмма обхода фокуса по клавише Tab в просмотрщике PDFium Component в Delphi: FocusNextFormField замыкается в пределах tab-порядка одной страницы, а пять API фокуса покрывают навигацию с клавиатуры
Обход крутится в пределах tab-порядка одной страницы, поэтому переход к следующей странице остаётся работой зрителя
procedure TFormViewer.HandleTabKey(Shift: TShiftState);
begin
  // AcrossPages = True: продолжаем на следующей странице, где есть поля (3.220.0+)
  if ssShift in Shift then
    PdfView.FocusPreviousFormField(True)
  else
    PdfView.FocusNextFormField(True);
  UpdateFieldStatus;  // например, «Поле 4 из 17: InvoiceDate»
end;

Единственная черта поведения, на которой люди спотыкаются, — заворачивание. Обход работает по tab-порядку текущей страницы и крутится в его пределах: шаг за последнее поле возвращает вас к первому. Обе функции шага возвращают индекс нового поля или -1, когда полей на странице нет вообще. В вызовах без параметров это заворачивание по странице, не по документу. Начиная с PDFium Component 3.220.0, и TPdf, и TPdfView предлагают также FocusNextFormField(AcrossPages, WrapDocument, MaxPagesToScan) и соответствующую перегрузку FocusPreviousFormField, которые переходят на следующую или предыдущую страницу с подходящим полем, опционально заворачивают на концах документа и останавливаются после ограниченного числа страниц. Передавайте True в AcrossPages, когда форма задумана читаться как одна непрерывная последовательность; иначе двухстраничная форма молча держит курсор на первой странице — ещё одна разновидность жалобы на сломанный Tab. С 3.223.0 обход каждой страницы следует и её объявленному порядку /Tabs, описанному в tab-порядке PDF-форм и /Tabs

Навигация становится удобной, когда остальной интерфейс реагирует на нее. Событие OnFormFieldEnter срабатывает при получении фокуса, а событие OnFormFieldFocusChange сообщает новый индекс поля в средстве просмотра, позволяя боковой панели синхронизировать свое состояние с выбранным полем. Если вам нужно обратное сопоставление (координат экрана с полем), индексируемое свойство FormFieldAt выполняет проверку попадания (hit-testing) для подсказок и панелей редактирования кликом. Это дает важный плюс для доступности: поскольку фокус следует внутреннему порядку полей документа, путь обхода Tab совпадает с путем чтения экранного диктора без дополнительных усилий

Отображение имен полей вместо индексов требует еще одного свойства. FormFieldInfo[] возвращает запись TPdfFormFieldInfo для индекса, содержащую имя поля, тип, размер шрифта, состояние выбора, экспортируемое значение и принадлежность к группе. Это именно то, что нужно выводить в списке навигации (например, «Поле 4 из 17: InvoiceDate» вместо «4»). Группы переключателей (Radio groups) заслуживают отдельного тестирования. Несколько элементов управления могут использовать одинаковое имя поля, поэтому список, собранный напрямую из элементов управления, покажет одну группу несколько раз, запутывая пользователя

Почему заполненные значения отображаются пустыми, и спасительный метод

Вторая популярная проблема критичнее неверного порядка Tab: форма заполняется программно, клиент открывает ее в Acrobat, а все поля выглядят пустыми. Но стоит кликнуть в поле, как значение мгновенно появляется. Сами данные присутствуют в файле все время. Отсутствует графическое представление данных, и причину этого важно понять

Текстовое поле AcroForm сохраняет значение в параметре /V словаря поля (ISO 32000-1 §12.7.3.3). Но на экране программа просмотра отрисовывает другой объект: поток внешнего вида (/AP) под ключом §12.5.5 — небольшой предварительно срендеренный фрагмент содержимого. Если записать значение в /V и не обновить /AP, они начнут расходиться. Данные есть, но их визуальное представление отсутствует. Acrobat перестраивает внешний вид поля при получении фокуса, что и объясняет появление данных при клике. Устаревший флаг NeedAppearances, запрашивающий перерисовку у программ просмотра, никогда не работал стабильно и объявлен устаревшим в PDF 2.0. Серверы печати и генераторы миниатюр полностью игнорируют его: они выводят только поток /AP, поэтому при его отсутствии отображают пустые поля

Запись значения через FormField[i] изменяет только /V. Заполнение формы должно быть трехэтапным процессом, но разработчики часто забывают второй шаг:

Диаграмма расхождения значения /V и внешнего вида /AP в полях AcroForm и трёхшаговой последовательности заполнения в Delphi, построенной вокруг GenerateFormAppearances
Присваивание значений пишет лишь /V, а средний шаг — тот, что перекрашивает то, что реально отображают серверы печати
procedure TFormViewer.FillAndSave(const Values: array of WString;
  const OutputPath: string);
var
  i: Integer;
begin
  for i := 0 to Pdf.FormFieldCount - 1 do
    Pdf.FormField[i] := Values[i];   // writes /V only

  // Пересобрать потоки appearance /AP; без этого форма
  // выглядит пустой в Acrobat, пока не щёлкнуть каждое поле
  Pdf.GenerateFormAppearances;

  Pdf.SaveAs(OutputPath);
end;

Метод GenerateFormAppearances решает проблему. Он перестраивает поток внешнего вида каждого элемента управления на основе текущих значений, шрифтов и выравнивания, благодаря чему серверы печати или средства генерации миниатюр отображают заполненное состояние сразу. Вызывайте этот метод один раз после заполнения всех полей, а не после каждого поля отдельно. Генерация внешнего вида выполняет реальную работу по верстке текста, и вызов после каждого поля создаст лишнюю нагрузку на больших формах

Генерация внешнего вида также задействует шрифты и выравнивание, что может преподнести сюрпризы. Новый поток верстает текст внутри прямоугольника поля с использованием заданного шрифта, размера и выравнивания. Текст, который свободно умещался на этапе проектирования, может обрезаться или сильно уменьшиться в файле клиента, если границы поля окажутся уже. Поля с автоподбором размера (размер шрифта равен нулю) сжимают текст; поля фиксированного размера просто обрезают его. Оба варианта соответствуют стандарту, и единственный способ проверить верстку — посмотреть на сгенерированный результат, а не на записываемую строку. В этом кроется причина большинства жалоб на обрезанный текст

Считайте проверку частью процесса разработки, а не постобработки. Откройте сохраненный файл в Acrobat и убедитесь, что значения видны до клика по полям. Затем экспортируйте его в изображение или отправьте на печать из средства просмотра, полностью игнорирующего логику форм, и убедитесь, что данные сохранились. Эти две проверки выявляют любые расхождения между /V и /AP

Нюансы настройки полей на практике

Реальные файлы клиентов содержат много нюансов, с которыми вы не столкнетесь на тестовых формах. Вот четыре основные причины жалоб:

  • Экспортируемые значения флажков. Состояние «включено» не всегда равно строке Yes. Форма может задавать собственное значение экспорта, и запись неверного значения оставит флажок пустым, хотя ваш код будет уверен в его установке. Считывайте значение экспорта из FormFieldInfo[] вместо предположений
  • Группы переключателей с общим именем. Одно поле — несколько элементов управления. Записываемое значение определяет, какой переключатель будет выбран, поэтому логика UI, предполагающая однозначное соответствие имени прямоугольнику, может нарисовать рамку фокуса не на той кнопке
  • Вычисляемые поля. Формулы расчетов на JavaScript обновляются в ответ на события полей. Программное заполнение в обход событий интерфейса требует либо вызова пересчета, либо ручной записи значений в расчетные поля. Форма, в которой сумма по позициям не сходится с итогом, неприемлема
  • Скрытые обязательные поля. Интерактивные формы часто скрывают поля, сохраняя у них флаг обязательного заполнения. Решите заранее, учитывает ли ваша валидация видимость полей или опирается только на флаг обязательности, и задокументируйте это решение

Важно помнить разницу: генерация внешнего вида не является слиянием (flattening). Метод GenerateFormAppearances делает значения видимыми везде, сохраняя интерактивность полей. Слияние же встраивает изображение полей в статичное содержимое страницы и навсегда удаляет интерактивность, что подходит для архивной копии, но делает форму непригодной для дальнейшего заполнения. Если свойство FormType возвращает ftXfaFull вместо ftAcroForm, описанная логика редактирования не применима, так как документ рендерится из своего XML-шаблона; зафиксируйте этот случай в кодовой базе и сообщите об этом пользователю

Подсистема заполнения форм, навигация фокуса и генерация внешнего вида входят в состав PDFium Component для Delphi, C++Builder и Lazarus/FPC. Если ваше приложение также работает с пометками рецензентов наряду с данными форм, статья о рецензировании аннотаций подробно описывает эту смежную модель