Технічна стаття

Навігація полями PDF-форми в Delphi (компонент PDFium)

Натисніть 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 документа. У переглядачі для введення даних це саме те, що треба, бо скрипти обчислень — це те, що тримає проміжний підсумок актуальним, поки хтось друкує. У вікні попереднього перегляду для файлів невідомого походження це саме те, чого не треба. Стаття про безпечний попередній перегляд PDF розглядає бік цього компромісу з FormFill := False

Навігація клавішею Tab, яка приземляється там, де очікують користувачі

Повернімося до проблеми клавіатури, з якої почали. Спокуса — підробити Tab, синтезуючи клік мишею по прямокутнику наступного віджета, що ламається в ту ж мить, коли поле прокручується за межі екрана або два віджети перекриваються. Натомість API фокуса переміщує власний фокус форми напряму, без жодних здогадок щодо геометрії. Це покривають п'ять викликів: FocusFormField за індексом, FocusNextFormField та FocusPreviousFormField для покрокового переходу, FocusedFormFieldIndex для читання поточної позиції, і ClearFormFieldFocus для повного скидання фокуса

Діаграма обходу фокусу клавішею Tab у переглядачі PDFium Component для Delphi: FocusNextFormField загортається в межах таб-порядку однієї сторінки, а п'ять focus-API покривають клавіатурну навігацію
Обхід циклиться в межах tab-порядку однієї сторінки, тож перехід до наступної сторінки лишається турботою переглядача
procedure TFormViewer.HandleTabKey(Shift: TShiftState);
begin
  if ssShift in Shift then
    PdfView.FocusPreviousFormField
  else
    PdfView.FocusNextFormField;
  UpdateFieldStatus;  // напр. "Поле 4 з 17: InvoiceDate"
end;

Одна поведінка, яка людей спантеличує, — це замикання по колу. Навігація працює через порядок табуляції поточної сторінки і зациклюється в його межах: крокуючи за останнє поле, ви повертаєтеся до першого. Обидві функції покрокового переходу повертають новий індекс поля або -1, коли на сторінці взагалі немає полів. Це зациклення відбувається на рівні сторінки, а не документа, а це означає, що перехід на наступну сторінку — ваша робота, а не бібліотеки. Порівнюйте повернутий індекс із тим, з якого почали, помічайте, коли відбулося замикання, і самостійно просувайте PageNumber, якщо форма має читатися як одна безперервна послідовність. Пропустіть цю перевірку — і дворінкова форма тихо замкне курсор на першій сторінці, що є власним різновидом скарги на зламаний Tab

Навігація стає корисною, щойно решта інтерфейсу починає на неї реагувати. Подія OnFormFieldEnter спрацьовує, коли приходить фокус, а в переглядачі OnFormFieldFocusChange повідомляє новий індекс поля, тож бічна панель може крокувати в такт із тим, що щойно обрала клавіатура. Коли вам потрібне зворотне зіставлення, від позиції на екрані до поля, індексована властивість FormFieldAt виконує hit-testing для попереднього перегляду підказок і панелей «клік для редагування». У всьому цьому є тихий бонус для доступності: оскільки фокус іде за власним порядком полів документа, шлях, який ви прокладаєте для клавіші Tab, — той самий шлях, який озвучує програма читання з екрана, без жодної додаткової роботи

Показ імен полів замість сирих номерів індексів потребує ще однієї властивості. FormFieldInfo[] повертає запис TPdfFormFieldInfo на кожен індекс, що несе ім'я поля, тип, розмір шрифту, стан позначення, експортне значення та належність до групи, — саме те, що список навігації повинен показувати («Поле 4 з 17: InvoiceDate», а не «4»). Групи радіокнопок — це той випадок, який заслуговує на окремий тестовий файл. Кілька віджетів можуть ділити одне ім'я поля, тож список, наївно зібраний із віджетів, показує ту саму групу кілька разів і плутає кожного, хто його читає

Чому заповнені значення виходять порожніми, і виклик, який це виправляє

Інша скарга, яка заповнює черги підтримки, тривожніша за неправильну поведінку Tab: форму заповнюють програмно, клієнт відкриває її в Acrobat, і кожне поле виглядає порожнім. Клацніть у поле — і його значення миттю з'являється. Дані весь цей час були у файлі. Чого бракує — це картинки даних, і причину варто зрозуміти один раз, бо вона пояснює цілу родину багів

Текстове поле AcroForm зберігає своє значення в записі /V словника поля (ISO 32000-1 §12.7.3.3). А те, що переглядач насправді малює, — це щось окреме: потік вигляду віджета під /AP (§12.5.5), маленький заздалегідь відрендерений фрагмент вмісту. Запишіть /V і залиште /AP недоторканим — і ці двоє розійдуться. Значення там є; його відрендерена версія застаріла або відсутня. Так склалося, що Acrobat перебудовує вигляд поля, коли воно отримує фокус, і це все пояснення значень, які з'являються лише при кліку. Старий прапорець NeedAppearances, який просив переглядачі регенерувати вигляд за вас, ніколи не працював однорідно і є застарілим у PDF 2.0, а сервери друку й генератори мініатюр повністю його ігнорують. Вони малюють /AP і нічого більше, тож якщо /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];   // записує лише /V

  // Перебудувати потоки вигляду /AP; без цього форма
  // виглядає порожньою в Acrobat, доки не клацнути кожне поле
  Pdf.GenerateFormAppearances;

  Pdf.SaveAs(OutputPath);
end;

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

Регенерація вигляду — це також момент, коли заявляють про себе шрифти й вирівнювання, що є джерелом сюрпризу другого порядку. Новий потік розкладає кожне значення всередині прямокутника віджета, використовуючи шрифт, розмір і вирівнювання поля. Значення, яке комфортно вміщується у вашій тестовій формі, може обрізатися чи стискатися в копії клієнта, де те саме поле вужче. Поля з автопідбором розміру (розмір шрифту нуль) стискають текст, щоб він вмістився; поля фіксованого розміру просто його обрізають. Обидва варіанти легальні, і єдиний чесний спосіб дізнатися, який саме робить конкретна форма, — подивитися на регенерований результат, а не на рядок, який ви записали. Коли хтось повідомляє про текст, обрізаний на краю поля, причина майже завжди саме в цьому

Ставтеся до перевірки як до частини завершення роботи, а не як до післядумки. Відкрийте збережений файл в Acrobat і підтвердьте, що значення видимі, перш ніж чіпати будь-яке поле. Потім надрукуйте його у PDF або в зображення з іншого переглядача, такого, що повністю ігнорує логіку форм, і підтвердьте, що значення переживають і цей шлях. Разом ці дві перевірки ловлять кожен варіант розбіжності /V проти /AP

Конфігурації полів, які проходять демо і провалюються в бою

Чисті демо-форми ховають набір крайових випадків, які файли клієнтів не приховують. Чотири з них пояснюють більшість звітів на кшталт «на моїй машині все працювало»

  • Експортні значення чекбоксів. Стан «увімкнено» не завжди дорівнює Yes. Форма вільна визначати власне експортне значення, і запис неправильного рядка залишає прапорець візуально знятим, поки ваш код упевнений, що встановив його. Читайте експортне значення з FormFieldInfo[], а не припускайте його
  • Групи радіокнопок зі спільним іменем. Одне поле, кілька віджетів. Значення, яке ви присвоюєте, вирішує, який віджет читається як обраний, тож код UI, що припускає відповідність одного імені одному прямокутнику, врешті малює кільце фокуса не на тій кнопці
  • Обчислювані поля. Підсумки, що підтримуються JavaScript документа, оновлюються у відповідь на події полів. Програмне заповнення, яке обходить ці події, має або запустити перерахунок, або переписати обчислювані поля напряму. Форма, в якій позиції та підсумок розходяться, гірша за будь-який із цих варіантів виправлення
  • Приховані обов'язкові поля. Умовні форми ховають поля, які все ще позначені як обов'язкові. Заздалегідь вирішіть, чи ваша валідація зважає на видимість, чи на сирий прапорець обов'язковості, а тоді запишіть це рішення там, де підтримка зможе його знайти

Одну відмінність варто з'ясувати, перш ніж вона вас вкусить: генерація вигляду — це не сплощення. GenerateFormAppearances робить значення видимими всюди, залишаючи поля редагованими. Сплощення запікає вигляд у статичний вміст сторінки й назавжди прибирає інтерактивність, що правильно для архівної копії й неправильно для форми, яку ще має заповнити наступна людина. Якщо FormType повідомляє ftXfaFull замість ftAcroForm, жодна з можливостей редагування тут усе одно не застосовується коректно, оскільки документ рендериться зі свого власного XML-шаблону; виявляйте цей випадок і повідомляйте користувача, а не дозволяйте йому знаходити це обмеження самостійно

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