Начиная с версии 3.223.0, PDFium Component for Delphi упорядочивает навигацию по полям форм по заявленной политике /Tabs каждой страницы: R для порядка по строкам, C — по столбцам, S — по структуре, плюс значения A и W из PDF 2.0. TPdf.FormFieldTabOrder возвращает тот порядок, а его перегрузка со статусом сообщает, была ли применена заявленная политика или сработал фолбэк
Жалоба, мотивирующая всё это, всегда одна. Пользователь заполняет двухколоночную страховую форму, жмёт Tab после поля имени — и курсор прыгает в низ правой колонки. В индексах ничего не сломано. Производитель просто вписал правую колонку в /Annots первой и объявил /Tabs /R на странице, чтобы исправить визуальный порядок, поэтому вьюер, идущий по /Annots и игнорирующий объявление, попадает не туда. Статья о виджет-индексе против индекса аннотации разбирает, как порядковые номера виджетов соотносятся с массивом /Annots. В PDFium Component заявленная политика побеждает всюду, где её удаётся установить, а где не удаётся — компонент говорит, почему
Что запись /Tabs на самом деле объявляет?
/Tabs — опциональное имя в объекте страницы (ISO 32000-2 §7.7.3.3; Table 30 в ISO 32000-1), сообщающее процессору, в каком порядке посещать аннотации этой страницы, и его семантика расписана под аннотациями в §12.5.1. PDF 1.5 ввёл три значения, PDF 2.0 добавил ещё два:
/R, порядок по строкам: строки сверху вниз, а внутри строки — в направлении чтения вьюера/C, порядок по столбцам: столбцы в направлении чтения, а сверху вниз внутри каждого столбца/S, порядок по структуре: порядок, в котором дерево логической структуры ссылается на аннотации/A, порядок массива аннотаций: сам порядок/Annots(PDF 2.0)/W, порядок виджетов: сперва виджеты в порядке массива, затем остальные аннотации (PDF 2.0)
Деталь, валящая рукописные реализации, — наследование. /Tabs принадлежит только словарю страницы. ISO 32000-2 §7.7.3.4 ограничивает наследуемые атрибуты страницы списком Resources, MediaBox, CropBox и Rotate, поэтому ключ /Tabs на узле /Pages бессмыслен, а читатель, поднимающийся по родительской цепочке в его поисках, выдумывает политику, которую автор не ставил. PDFium Component читает /Tabs с самой страницы и нигде больше
Чтение заявленного порядка через FormFieldTabOrder
TPdf.FormFieldTabOrder возвращает поля формы текущей страницы как порядковые номера виджетов с 0 в заявленном порядке табуляции, а перегрузка с параметром out Status: TPdfFormTabOrderStatus объясняет, как этот порядок получен. Та же пара есть на TPdfView, и простая перегрузка использует идентичную политику, отбрасывая статус
uses
PDFium;
const
ModeNames: array[TPdfFormTabOrderMode] of string =
('unspecified', 'R', 'C', 'S', 'A', 'W', 'unsupported');
procedure DumpTabOrder(const FileName: string; PageNo: Integer);
var
Pdf: TPdf;
Order: TPdfIntegerArray;
Status: TPdfFormTabOrderStatus;
I: Integer;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := FileName;
Pdf.FormFill := True; // уже умолчание; обязательно для полей
Pdf.Active := True;
if not Pdf.Active or (Pdf.FormType <> ftAcroForm) then
Exit;
Pdf.PageNumber := PageNo;
Order := Pdf.FormFieldTabOrder(Status);
Writeln('requested ', ModeNames[Status.RequestedMode],
', applied ', ModeNames[Status.EffectiveMode]);
if Status.UsedFallback then
Writeln('fallback: ', Status.ErrorMessage);
if Status.UnstructuredWidgetCount > 0 then
Writeln(Status.UnstructuredWidgetCount, ' widgets not in the structure tree');
for I := 0 to High(Order) do
Writeln(I, ': field ', Order[I], ' ',
string(Pdf.FormFieldInfo[Order[I]].Name));
finally
Pdf.Free;
end;
end;
Запись статуса — то, что превращает молчаливое решение об упорядочивании в логируемое. RequestedMode — что объявила страница, EffectiveMode — что применено, а UsedFallback истинен, когда заявленной политике не удалось следовать и вернулся порядок массива аннотаций, с краткой причиной в ErrorMessage. SnapshotAvailable говорит, смог ли компонент вообще прочитать метаданные документа. Отсутствующий /Tabs сообщается как фолбэк к порядку аннотаций, а не выдаётся за заявленную политику, поэтому лог поддержки отличит «автор попросил этот порядок» от «страница не попросила ничего»
Почему порядки по строкам и столбцам не сортируют значения /Rect?
Порядки по строкам и столбцам обязаны считаться в геометрии отображения, а не в сырых координатах аннотаций, потому что /Rotate страницы и направление чтения документа оба меняют смысл «строки». Отсортируйте массивы /Rect страницы с /Rotate 90 по координате y — получите то, что вьюер показывает как столбцы. Поэтому компонент пропускает каждый прямоугольник виджета через трансформацию отображения страницы, включая внутренний поворот, с разрешением 1/64 пункта, и группирует оттуда
Направление чтения берётся из словаря ViewerPreferences каталога (ISO 32000-2 §12.2). /Direction /R2L разворачивает порядок внутри строки и порядок столбцов — то, чего ждёт арабская или еврейская форма. Когда /Direction отсутствует, действует L2R. Когда в нём что-то иное, чем L2R или R2L, порядки по строкам и столбцам откатываются к порядку массива аннотаций с причиной в статусе: угадывать направление хуже, чем признать его отсутствие
Группировка — место, где процессоры законно расходятся, поэтому точное правило стоит знать. Для порядка по строкам якорем становится самый верхний непосещённый виджет, и каждый виджет, чей вертикальный центр попадает между верхом и низом якоря, вступает в его строку. Порядок по столбцам зеркалит это по горизонтали. Ничьи разрешаются детерминированно по порядковому номеру виджета. На чистой сетке это совпадает с любым разумным вьюером. На раскладке с сильно перекрывающимися или каскадными полями другой процессор с иной толерантностью может сгруппировать иначе, и ничто в ISO 32000 не делает неверным ни один из ответов
Как порядок по структуре решает последовательность?
Порядок по структуре следует словарям объектных ссылок (OBJR) в дереве структуры — в том порядке, в котором обход в глубину по массивам /K реально до них добирается. Он не следует порядку ключей /ParentTree и не следует тому порядку, который случайно выдаёт перечисление StructTreeRoot. Эти порядки часто совпадают в файлах от одного производителя и расходятся после того, как редактор перетасовал дерево, — ровно тогда, когда порядок чтения тегированной формы важнее всего. Если вы поддерживаете то дерево сами, гид о синхронизации /ParentTree и /StructParents покрывает обратное отображение, обязанное оставаться согласованным с ним
Виджеты, на которые дерево структуры никогда не ссылается, спецификация оставляет процессору. PDFium Component дописывает их после структурных виджетов в порядке массива аннотаций и сообщает, сколько дописал, в UnstructuredWidgetCount. Страница, объявляющая /S в документе вообще без дерева структуры, трактуется как пустой обход: каждый виджет попадает в тот хвост, счётчик об этом говорит, и результат не помечается как полный фолбэк. Дерево структуры с циклом, OBJR, на который ссылаются дважды, или OBJR, чей /Pg противоречит странице, владеющей аннотацией, валит порядок по структуре в фолбэк. Эти дефекты делают S непригодным, но не трогают R и C на других страницах
Почему значения порядка табуляции — не индексы аннотаций?
Значения в массиве, который возвращает FormFieldTabOrder, считают только виджеты, тогда как OBJR идентифицирует объект аннотации, живущий в полном массиве /Annots. Смешать два индексных пространства — самый вероятный баг в любом коде, потребляющем порядок по структуре. Возьмите страницу с /Annots равным [Link, Widget A, Widget B]. OBJR для Widget B указывает на индекс аннотации 2. Widget B — порядковый номер поля 1. Скормите 2 методу FocusFormField — вы сфокусируете не то поле или ничего, в зависимости от того, сколько виджетов на странице
Компонент выполняет ту конверсию внутренне, поэтому его результат можно отдавать напрямую в FocusFormField и FormFieldInfo. Если ваш собственный код читает цели OBJR или другие индексы аннотаций, конвертируйте их явно, прежде чем трактовать как поля:
// Конвертирует индекс аннотации страницы в порядковый номер виджета,
// используемый FocusFormField, FormFieldInfo и FormFieldTabOrder; -1 если не виджет
function AnnotationToFieldOrdinal(Pdf: TPdf; AnnotIndex: Integer): Integer;
var
I: Integer;
begin
Result := -1;
if (AnnotIndex < 0) or (AnnotIndex >= Pdf.AnnotationCount) then
Exit;
if Pdf.Annotation[AnnotIndex].Subtype <> anWidget then
Exit; // ссылки, заметки и попапы — не поля
Result := 0;
for I := 0 to AnnotIndex - 1 do
if Pdf.Annotation[I].Subtype = anWidget then
Inc(Result);
end;
Трактуйте результат как валидный, только пока страница остаётся загруженной и неотредактированной. Добавление или удаление любой аннотации перед виджетом сдвигает оба индексных пространства, а закэшированное отображение до правки — ровно тот сорт off-by-one, который воспроизводится только на файлах клиентов
Межстраничная навигация с FocusNextFormField
FocusNextFormField(AcrossPages: Boolean; WrapDocument: Boolean = True; MaxPagesToScan: Integer = 256) идёт по порядку табуляции текущей страницы, затем продолжается на последующие страницы, и с 3.223.0 каждая посещённая страница применяет собственную заявленную политику. Межстраничная перегрузка приехала в версии 3.220.0, поэтому совет из статьи о навигации по полям форм — детектить заворачивание и менять PageNumber самому — теперь касается только перегрузки без аргументов, которая по-прежнему циклит внутри одной страницы
procedure TFormViewer.HandleTabKey(Shift: TShiftState);
var
Field: Integer;
begin
if ssShift in Shift then
Field := PdfView.FocusPreviousFormField(True) // между страницами, с заворотом
else
Field := PdfView.FocusNextFormField(True);
if Field < 0 then
Beep // ничего подходящего в скане; страница и фокус не изменились
else
UpdateFieldStatus(PdfView.PageNumber, Field);
end;
Возвращаемое значение — порядковый номер поля в той странице, которая теперь держит фокус, поэтому читайте PageNumber рядом с ним. Межстраничный обход пропускает виджеты, в которые пользователь не может осмысленно табарить: скрытые, невидимые, NoView, read-only, подпись, неизвестного типа и с пустым прямоугольником. Передача WrapDocument = False останавливает на границе документа с -1. MaxPagesToScan обязан лежать между 1 и 4096, и каждая осмотренная страница считается, включая текущую. Когда скан не находит ничего или нативный движок отказывает в фокусе цели, вызов возвращает -1 и оставляет предыдущие страницу и фокус ровно как были
Где заявленный порядок перестаёт применяться?
Заявленная политика чтится, только когда компонент может надёжно прочитать метаданные страницы, и каждый случай, когда не может, откатывается к порядку виджетов по массиву аннотаций с явной причиной. Границы конкретны и перечислимы:
- Метаданные приходят из ограниченной приватной сериализации текущего нативного документа, а не из байтов исходного файла: перемещения и правки страниц в памяти делают файл на диске устаревшим. Снимок кэшируется на ревизию контента, поэтому оповещённые правки его инвалидируют, а неудавшиеся снимки тоже кэшируются
- Зашифрованные документы и снимки, запрошенные во время правки page-object, не имеют снимка и всегда откатываются
- Снимок ограничен 64 МиБ, 65 536 активными объектами, 4096 страницами, 4096 аннотациями на страницу, 65 536 аннотациями суммарно и глубиной структуры 64; документы крупнее откатываются, а не застаивают навигацию
- Покрыты только документы AcroForm. XFA-формы остаются вне этого профиля
- Порядки по строкам и столбцам требуют конечных, непустых прямоугольников виджетов и вменяемых размеров страницы; вырожденная геометрия триггерит фолбэк
- Эти API ведут навигацию из вашего кода. Собственная обработка клавиши Tab, переданной движку, не заменяется, поэтому маршрутизируйте клавишу через
FocusNextFormField, если хотите заявленный порядок
Запросы сохраняют текущую страницу, нативный фокус и любой висящий текст правки в сфокусированном поле и не выполняют никаких действий жизненного цикла документа или страницы. Вызов FormFieldTabOrder на каждое нажатие клавиши поэтому безопасен, а после первого вызова на данной ревизии контента — дёшев
Заявленный порядок табуляции, диагностики статуса и межстраничная навигация фокуса — часть PDFium Component for Delphi and C++Builder; страница продукта линкует полную справку API, включая записи TPdfFormTabOrderMode и TPdfFormTabOrderStatus