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

Индекс виджета vs индекс аннотации в формах Delphi PDFium

В PDFium Component, PDFium-компоненте VCL/LCL для Delphi, C++Builder и Lazarus, индекс поля формы — не индекс аннотации. Страница несёт аннотации Link, Text и Ink рядом со своими виджетами, так что перечисление полей обязано фильтровать по FPDFAnnot_GetSubtype и выставлять нуль-базированный логический индекс, отображаемый обратно на реальную позицию аннотации только в нативном вызове

Баг, обнажающий это, безошибочен, стоит его однажды увидеть. Тестировщик нажимает Tab в заполненной форме счёта-фактуры, и каретка исчезает, потому что фокус ушёл на гиперссылку в подвале. Или хуже, вообще ничего не происходит: ваш код записывает поле 3 как сфокусированное, панель UI обновляется, а 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, а не выбрасывает исключение, что позволяет вызывающей стороне решить, является ли устаревший индекс ошибкой программирования, заслуживающей исключения, или гонкой, которую можно игнорировать, например после того как правка удалила аннотацию, на которую всё ещё ссылается кэшированный список UI

Почему FORM_SetFocusedAnnot отказывает на headless-странице?

Потому что PDFium отказывается фокусировать виджет, чей просмотр страницы никогда не был помечен валидным. FORM_SetFocusedAnnot разрешает аннотацию в просмотр страницы внутри окружения заполнения формы, и если этот просмотр страницы не существует, она возвращает false без какой-либо диагностики. Исправление одного только отображения индекса поэтому исправляет попадание Tab на гиперссылку, но оставляет второй симптом нетронутым: ваша запись логического фокуса говорит поле 3, нативный сфокусированный виджет всё ещё ничто, и каждый аксессор, построенный на нативном фокусе, — сфокусированный текст, сфокусированное значение, состояние выбора списка — продолжает возвращать пусто. Просмотр страницы создаётся FORM_OnAfterLoadPage и уничтожается FORM_OnBeforeClosePage. В просмотрщике, построенном вокруг визуального элемента управления, эти вызовы происходят как часть отображения страницы, что и есть причина, почему сбой так часто выглядит как баг только headless-режима: тот же код, что работает в 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 именно для этого случая, и UI списка, игнорирующий их, показывает одно и то же поле трижды. Четвёртое ограничение касается порядка обхода: порядок табуляции, выставленный здесь, — это порядок перечисления виджетов, следующий массиву /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, чья страница продукта содержит полный справочник по полям форм, включая запись информации о поле и аксессоры фокуса