У PDFium Component, VCL/LCL-компоненті на основі PDFium для Delphi, C++Builder та Lazarus, індекс поля форми — не індекс анотації. Сторінка несе анотації Link, Text та Ink поряд зі своїми віджетами, тож перерахування полів мусить фільтрувати за FPDFAnnot_GetSubtype і виставляти логічний індекс з нумерацією від нуля, відображений назад на реальну позицію анотації лише на нативному виклику
Баг, що це розкриває, безпомилковий, щойно його бачив. Тестувальник натискає Tab у заповненій формі рахунку-фактури, і курсор зникає, бо фокус перейшов на гіперпосилання в підвалі. Або гірше, взагалі нічого не стається: твій код записує поле 3 як сфокусоване, панель інтерфейсу оновлюється, а FORM_SetFocusedAnnot тихо повертав false увесь цей час. Обидва симптоми походять з тієї самої помилки дизайну, і один з них має другу першопричину, приховану під ним
Два простори індексів, які тобі дає PDFium
PDFium виставляє дві схеми нумерації над тією самою сторінкою, і вони збігаються лише на документах, що випадково містять нічого, крім віджетів форми. Перша — індекс анотації: позиція в масиві /Annots сторінки, що і те, що рахує FPDFPage_GetAnnotCount і що приймає FPDFPage_GetAnnot (ISO 32000-1 §12.5.2). Друга — логічний індекс поля, який API на рівні застосунку має пропонувати, з нумерацією від нуля над інтерактивними полями, до яких користувач справді може дістатися. ISO 32000-1 §12.5.6.19 визначає анотації віджетів як візуальне представлення інтерактивних полів форми, а §12.7 визначає саму форму. Усе інше на сторінці — інший підтип з іншою семантикою: анотація Link має ціль призначення, анотація Ink має список штрихів, анотація Text — липка нотатка. Жодна з них не належить у підрахунок полів, і жодна з них не може прийняти фокус форми. Проте в масиві /Annots вони сидять переплетені з віджетами в будь-якому порядку, в якому їх написав застосунок-виробник, що часто не той порядок, який будь-що інше в документі підказує
Чому Tab приземляється на гіперпосилання замість наступного поля?
Тому що підрахунок полів насправді був підрахунком анотацій. Оригінальна реалізація повертала FPDFPage_GetAnnotCount напряму з FormFieldCount, тоді як аксесор інформації про поле, хелпер порядку табуляції та хелпер фокуса всі трактували те саме ціле число як позицію віджета. На чистій сторінці AcroForm з шістьма віджетами й нічим більше, шість дорівнює шести, і кожен тест проходить. Додай гіперпосилання в підвалі й коментар рецензента на полях, і підрахунок звітує вісім полів, індекси 6 і 7 розв'язуються в об'єкти, що не є формою, і Tab заходить прямо в них
Виправлення на боці перерахування — рахувати підтипи, а не анотації. Відкрий кожну анотацію, запитай її підтип, збережи віджети й закрий дескриптор у блоці finally, бо FPDFPage_GetAnnot повертає дескриптор, яким володіють, і який має повернутися через FPDFPage_CloseAnnot
function WidgetCountForPage(Page: FPDF_PAGE): Integer;
var
Count, I: Integer;
Annot: FPDF_ANNOTATION;
begin
Result := 0;
if Page = nil then
Exit;
Count := FPDFPage_GetAnnotCount(Page); // every annotation, not just fields
for I := 0 to Count - 1 do
begin
Annot := FPDFPage_GetAnnot(Page, I);
if Annot = nil then
Continue;
try
if FPDFAnnot_GetSubtype(Annot) = FPDF_ANNOT_WIDGET then
Inc(Result);
finally
FPDFPage_CloseAnnot(Annot);
end;
end;
end;
Зверни увагу, чого це навмисно не робить. Воно нічого не питає в середовища заповнення форми і не потребує дескриптора форми, бо підтип живе в словнику анотації й читабельний з самої лише сторінки. Це має значення для впорядкування: підрахунок доступний до того, як ти вирішив, чи документ взагалі заслуговує на середовище заповнення форми, що стаття про JavaScript AcroForm та події хоста розглядає як рішення безпеки, а не зручності
Відображення логічного індексу назад на нативній межі
Правило, що не дає двом просторам просочуватися один в інший, просте: логічний індекс — єдине число, що перетинає твій публічний API, і воно перетворюється на індекс анотації в останній функції перед нативним викликом. Один хелпер відображення, використаний і інформацією про поле, і фокусом, і сеттерами прапорців, і порядком табуляції однаково, — те, що робить це правило виконуваним
function AnnotationIndexForField(Page: FPDF_PAGE;
FieldIndex: Integer): Integer;
var
Count, I, Current: Integer;
Annot: FPDF_ANNOTATION;
begin
Result := -1;
if (Page = nil) or (FieldIndex < 0) then
Exit;
Count := FPDFPage_GetAnnotCount(Page);
Current := 0;
for I := 0 to Count - 1 do
begin
Annot := FPDFPage_GetAnnot(Page, I);
if Annot = nil then
Continue;
try
if FPDFAnnot_GetSubtype(Annot) = FPDF_ANNOT_WIDGET then
begin
if Current = FieldIndex then
Exit(I); // real /Annots position: native calls only
Inc(Current);
end;
finally
FPDFPage_CloseAnnot(Annot);
end;
end;
end;
Дві властивості цього хелпера варто заявити прямо. Це лінійне сканування, тож наївний цикл по кожному полю коштує квадратичну кількість відкриттів анотацій на сторінці з сотнями віджетів; якщо ти перераховуєш усю сторінку, обійди анотації один раз і збери дескриптори віджетів по ходу замість виклику мапера на кожне поле. І воно повертає -1 замість того, щоб піднімати виняток, що дозволяє викликачу вирішити, чи застарілий індекс — помилка програмування, варта винятку, чи гонка, варта ігнорування, наприклад, після редагування, що видалило анотацію, на яку кешований список інтерфейсу все ще посилається
Чому FORM_SetFocusedAnnot провалюється на сторінці без інтерфейсу?
Тому що PDFium відмовляється фокусувати віджет, чий вигляд сторінки ніколи не був позначений валідним. FORM_SetFocusedAnnot розв'язує анотацію до вигляду сторінки всередині середовища заповнення форми, і якщо цей вигляд сторінки не існує, повертає false без жодної діагностики. Виправлення лише відображення індексу тому виправляє приземлення Tab на гіперпосилання, але лишає другий симптом недоторканим: твій запис логічного фокусу каже поле 3, нативний сфокусований віджет усе ще нічого, і кожен аксесор, побудований на нативному фокусі, сфокусованому тексті, сфокусованому значенні, стані вибору варіанта, продовжує повертати порожнечу. Вигляд сторінки створюється FORM_OnAfterLoadPage і знищується FORM_OnBeforeClosePage. У переглядачі, побудованому навколо візуального елемента керування, ці виклики стаються як частина показу сторінки, що і чому збій так часто виглядає як баг лише без інтерфейсу: той самий код, що працює в GUI-демо, провалюється в пакетному інструменті. Життєвий цикл належить об'єкту документа, не переглядачу, тож PDFium Component тепер видає обидва виклики щоразу, коли сторінка завантажується чи вивантажується з наявним дескриптором форми. Сигнатура C приймає сторінку першою, а дескриптор форми другим, що легко переплутати, пишучи прив'язку вручну
procedure ReportFirstField(const FileName: string);
var
Pdf: TPdf;
Idx: Integer;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FormFill := True; // form-fill environment, before Active
Pdf.FileName := FileName;
Pdf.Active := True;
Pdf.PageNumber := 1; // page load also runs FORM_OnAfterLoadPage
Idx := Pdf.FocusNextFormField; // logical index, 0-based over widgets
if Idx < 0 then
Exit; // page holds no widget annotations
Writeln(string(Pdf.FormFieldInfo[Idx].Name), ' = ',
string(Pdf.FocusedFormFieldValue)); // reads the native focused widget
finally
Pdf.Free; // page unload runs FORM_OnBeforeClosePage
end;
end;
Перевірка, що доводить виправлення, — та, що порівнює обидва боки. Виклич FocusFormField з логічним індексом, потім прочитай значення через аксесор, що йде через нативний сфокусований віджет, а не через твій власний запис, такий як FocusedFormFieldValue чи FocusedFormOptionSelected. Якщо логічний індекс проходить туди й назад, а нативний аксесор повертається порожнім, вигляд сторінки відсутній, не мапування
Чого логічний індекс поля не обіцяє
Індекс поля з нумерацією від нуля — зручність, не семантична ідентичність, і з цього випливають чотири обмеження. Він на сторінку, не на документ, тож індекс 0 на сторінці 2 — інший віджет, ніж індекс 0 на сторінці 1, і порівнювати їх безглуздо. Він позиційний, тож вставка чи видалення анотації робить недійсним кожен кешований індекс вище зміни; трактуй збережений індекс як валідний лише доти, доки сторінка лишається завантаженою й невідредагованою
Третє обмеження — те, що дивує людей, що переглядають список полів. Індекс перераховує віджети, не поля. Радіогрупа — одне поле з кількома дітьми-віджетами, тож група з трьох кнопок дає три послідовних індекси, що всі звітують ту саму Name. Запис TPdfFormFieldInfo несе GroupCount та GroupIndex рівно для цього випадку, і інтерфейс списку, що їх ігнорує, показує те саме поле тричі. Четверте обмеження стосується порядку обходу: порядок табуляції, виставлений тут, — порядок перерахування віджетів, що слідує масиву /Annots, не запису сторінки /Tabs (ISO 32000-1 §7.7.3.3) і не дереву полів AcroForm. Для більшості виробників вони збігаються; для форми, розкладеної у два стовпці генератором, що видав правий стовпець першим, — ні, і шлях клавіатури, описаний у статті про навігацію полями форми, здаватиметься неправильним, навіть якщо кожен індекс коректний. Коли файл клієнта поводиться дивно, виведи обидва простори індексів поруч, перш ніж теоретизувати: вигляд анотацій та вигляд полів тієї самої сторінки, надруковані разом, зазвичай роблять причину очевидною з одного погляду
procedure DumpIndexSpaces(Pdf: TPdf);
var
I: Integer;
Info: TPdfFormFieldInfo;
begin
for I := 0 to Pdf.AnnotationCount - 1 do
Writeln('annot ', I, ': subtype ', Ord(Pdf.Annotation[I].Subtype));
for I := 0 to Pdf.FormFieldCount - 1 do
begin
Info := Pdf.FormFieldInfo[I];
Writeln('field ', I, ': ', string(Info.Name),
' widget ', Info.GroupIndex, ' of ', Info.GroupCount);
end;
end;
Кількість анотацій, що значно перевищує кількість полів, означає, що сторінка змішує підтипи, що нормально в рецензованих документах і рівно та ситуація, для якої відображення й існує; стаття про робочий процес рецензування анотацій дивиться на ту саму сторінку з боку розмітки. Однакові підрахунки на кожному тестовому файлі, з іншого боку, означають, що твої фікстури взагалі не можуть виявити цей клас багу, і чесна відповідь — додати фікстуру форми, що несе посилання й липку нотатку
Перерахування полів, фокус та API анотацій, описані тут, постачаються з PDFium Component для Delphi, C++Builder та Lazarus, чия сторінка продукту містить повний довідник поля форми, включно з записом інформації про поле та аксесорами фокуса