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

Індекс віджета проти індексу анотації у формах PDFium Delphi

У 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, чия сторінка продукту містить повний довідник поля форми, включно з записом інформації про поле та аксесорами фокуса