Artykuł techniczny

PDFium Component: kolejność Tab formularzy i /Tabs w Delphi

Od wersji 3.223.0 PDFium Component dla Delphi porządkuje nawigację pól formularzy według zadeklarowanej polityki /Tabs każdej strony: R dla kolejności wierszowej, C dla kolumnowej, S dla porządku struktury, plus wartości A i W z PDF 2.0. TPdf.FormFieldTabOrder zwraca tę kolejność, a jego overload ze statusem raportuje, czy zadeklarowana polityka została zastosowana, czy użyto trybu awaryjnego

Reklamacja, która za tym stoi, jest zawsze ta sama. Użytkownik wypełnia dwukolumnowy formularz ubezpieczeniowy, wciska Tab po polu imienia, a kursor skacze na dół prawej kolumny. Nic nie jest zepsute w indeksach. Producent po prostu zapisał prawą kolumnę do /Annots jako pierwszą i zadeklarował na stronie /Tabs /R, żeby naprawić porządek wizualny, więc przeglądarka idąca po /Annots i ignorująca deklarację ląduje w złym miejscu. Artykuł o indeksie widgeta kontra indeksie adnotacji opisuje, jak numery porządkowe widgetów relują do tablicy /Annots. W PDFium Component zadeklarowana polityka wygrywa, ilekroć da się ją ustalić, a gdy się nie da, komponent mówi ci dlaczego

Co wpis /Tabs faktycznie deklaruje?

/Tabs to opcjonalna nazwa w obiekcie strony (ISO 32000-2 §7.7.3.3; Table 30 w ISO 32000-1), która mówi procesorowi, w jakiej kolejności odwiedzać adnotacje na tej stronie, a jej semantyka jest rozpisana pod adnotacjami w §12.5.1. PDF 1.5 wprowadził trzy wartości, a PDF 2.0 dorzucił dwie:

  • /R, kolejność wierszowa: wiersze od góry do dołu, a wewnątrz każdego wiersza kierunek czytania przeglądarki
  • /C, kolejność kolumnowa: kolumny w kierunku czytania, a od góry do dołu wewnątrz każdej kolumny
  • /S, porządek struktury: kolejność, w jakiej drzewo struktury logicznej referuje adnotacje
  • /A, kolejność tablicy adnotacji: kolejność samego /Annots (PDF 2.0)
  • /W, kolejność widgetowa: widgety najpierw w kolejności tablicy, potem pozostałe adnotacje (PDF 2.0)

Detalem, na którym wywracają się ręcznie pisane implementacje, jest dziedziczenie. /Tabs należy wyłącznie do słownika strony. ISO 32000-2 §7.7.3.4 ogranicza dziedziczne atrybuty strony do Resources, MediaBox, CropBox i Rotate, więc klucz /Tabs siedzący na węźle /Pages jest bez znaczenia, a czytnik wspierający w górę łańcucha rodziców wymyśla politykę, której autor nigdy nie ustawił. PDFium Component czyta /Tabs ze samej strony i z nikąd indziej

Czytanie zadeklarowanej kolejności przez FormFieldTabOrder

TPdf.FormFieldTabOrder zwraca pola formularzy bieżącej strony jako zerobazowe numery porządkowe widgetów w zadeklarowanej kolejności tabulacji, a overload z parametrem out Status: TPdfFormTabOrderStatus wyjaśnia, jak ta kolejność powstała. Ta sama para istnieje na TPdfView, a goły overload używa identycznej polityki, wyrzucając status

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;          // i tak domyślne; wymagane dla pól
    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;

Rekord statusu to właśnie to, co zamienia cichą decyzję porządkującą w coś, co da się zalogować. RequestedMode to to, co zadeklarowała strona, EffectiveMode to to, co zostało zastosowane, a UsedFallback jest prawdziwe, gdy zadeklarowanej polityki nie dało się zrealizować i zamiast niej wróciła kolejność tablicy adnotacji, z krótką przyczyną w ErrorMessage. SnapshotAvailable mówi, czy komponent w ogóle potrafił przeczytać metadane dokumentu. Brakujące /Tabs jest raportowane jako fallback na kolejność tablicy adnotacji, zamiast być podane za zadeklarowaną politykę, więc log wsparcia potrafi odróżnić „autor zażądał tej kolejności" od „strona niczego nie zażądała"

Wartości polityki /Tabs strony R, C, S, A i W z polami statusu, które PDFium Component dla Delphi raportuje, gdy zadeklarowana kolejność jest stosowana albo spada na tryb awaryjny
brakujące /Tabs to raportowany fallback na kolejność tablicy adnotacji, nie zadeklarowana polityka podana za takową

Dlaczego kolejność wierszowa i kolumnowa nie może po prostu posortować wartości /Rect?

Kolejność wierszowa i kolumnowa muszą być liczone w geometrii wyświetlania, nie w surowych współrzędnych adnotacji, bo /Rotate strony i kierunek czytania dokumentu oba zmieniają, czym jest „wiersz". Posortuj tablice /Rect strony z /Rotate 90 po współrzędnej y, a dostaniesz to, co przeglądarka pokazuje jako kolumny. Komponent mapuje więc każdy prostokąt widgeta przez transformację wyświetlania strony, z wbudowanym obrotem włącznie, w rozdzielczości 1/64 punkta, i grupuje dopiero od tego miejsca

Kierunek czytania pochodzi ze słownika ViewerPreferences katalogu (ISO 32000-2 §12.2). /Direction /R2L odwraca kolejność wewnątrz wiersza i odwraca kolejność kolumn, czego oczekuje formularz arabski albo hebrajski. Gdy /Direction jest nieobecne, obowiązuje L2R. Gdy trzyma cokolwiek innego niż L2R albo R2L, kolejność wierszowa i kolumnowa spadają na kolejność tablicy adnotacji z przyczyną w statusie, bo zgadywanie kierunku jest gorsze niż przyznanie, że go nie ma

Grupowanie to miejsce, gdzie procesorzy legalnie się różnią, więc dokładna reguła jest warta znajomości. Dla kolejności wierszowej najwyżej położony nieodwiedzony widget staje się kotwicą, a każdy widget, którego pionowy środek ląduje między górą a dołem kotwicy, dołącza do jej wiersza. Kolejność kolumnowa robi to samo w poziomie. Remisy rozstrzygają się deterministycznie po numerze porządkowym widgeta. Na czystej siatce pasuje to do każdej sensownej przeglądarki. Na layoucie z mocno nachodzącymi na siebie albo schodkowymi polami inny procesor używający innej tolerancji może grupować inaczej i żadna część ISO 32000 nie czyni żadnej z odpowiedzi błędną

PDFium Component w Delphi: kolejność tabulacji po tablicy adnotacji skacze w dół prawej kolumny, podczas gdy zadeklarowana kolejność wierszowa /Tabs R odwiedza dwukolumnowy formularz wiersz po wierszu
kolejność wierszowa jest liczona w geometrii wyświetlania po /Rotate i kierunku czytania, nie w surowej kolejności /Annots

Jak porządek struktury decyduje o sekwencji?

Porządek struktury idzie za słownikami referencji obiektów (OBJR) w drzewie struktury, w kolejności, w jakiej dociera do nich głębokie przejście w głąb tablic /K. Nie idzie za kolejnością kluczy /ParentTree i nie idzie za jakąkolwiek kolejnością, jaką akurat wyprodukuje enumeracja StructTreeRoot. Te kolejności często się pokrywają w plikach od jednego producenta i rozjeżdżają, gdy edytor przestawia drzewo — czyli dokładnie wtedy, gdy porządek czytania formularza oznakowanego znaczy najwięcej. Jeśli sam utrzymujesz to drzewo, przewodnik po trzymaniu /ParentTree i /StructParents w zgodzie opisuje mapowanie odwrotne, które musi z nim zostać spójne

Widgety, których drzewo struktury nigdy nie referuje, specyfikacja zostawia procesorowi. PDFium Component dokleja je za widgetami ustrukturyzowanymi w kolejności tablicy adnotacji i raportuje, ile ich dokleił, w UnstructuredWidgetCount. Strona deklarująca /S w dokumencie bez jakiegokolwiek drzewa struktury jest traktowana jako puste przejście: każdy widget ląduje w tym ogonie, licznik tak mówi, a wynik nie jest flagowany jako pełny fallback. Drzewo struktury z cyklem, OBJR referowanym dwa razy albo OBJR, którego /Pg przeczy stronie posiadającej adnotację, sprawia, że porządek struktury spada na tryb awaryjny. Te defekty czynią S nieużytecznym, ale nie ruszają R ani C na innych stronach

Dlaczego wartości kolejności tabulacji nie są indeksami adnotacji?

Wartości w tablicy zwróconej przez FormFieldTabOrder liczą wyłącznie widgety, podczas gdy OBJR identyfikuje obiekt adnotacji żyjący w pełnej tablicy /Annots. Pomieszanie tych dwóch przestrzeni indeksów to pojedynczy najprawdopodobniejszy bug w każdym kodzie konsumującym porządek struktury. Weź stronę, której /Annots to [Link, Widget A, Widget B]. OBJR dla Widget B wskazuje indeks adnotacji 2. Widget B to numer porządkowy pola 1. Podaj 2 do FocusFormField, a focusujesz złe pole albo nic, zależnie od tego, ile widgetów ma strona

Struktura OBJR celuje w indeks adnotacji 2, podczas gdy API formularzy PDFium Component w Delphi liczą widgety, więc Widget B to numer porządkowy pola 1
indeks adnotacji 2 to nie numer porządkowy pola 1 — konwertuj jawnie, zanim wołasz FocusFormField

Komponent wykonuje tę konwersję wewnętrznie, dlatego jego wynik może polecieć wprost do FocusFormField i FormFieldInfo. Jeśli twój własny kod czyta cele OBJR albo inne indeksy adnotacji, konwertuj je jawnie, zanim potraktujesz je jako pola:

// Konwertuje indeks adnotacji strony na numer porządkowy widgetu używany przez
// FocusFormField, FormFieldInfo i FormFieldTabOrder; -1 gdy to nie widget
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;                          // linki, noty i popupy nie są polami
  Result := 0;
  for I := 0 to AnnotIndex - 1 do
    if Pdf.Annotation[I].Subtype = anWidget then
      Inc(Result);
end;

Traktuj wynik jako ważny tylko, dopóki strona pozostaje wczytana i nieedytowana. Dodanie albo usunięcie jakiejkolwiek adnotacji przed widgetem przesuwa obie przestrzenie indeksów, a zcache'owane mapowanie sprzed edycji to dokładnie ten rodzaj błędu off-by-one, który reprodukuje się wyłącznie na plikach klientów

Nawigacja międzystronicowa z FocusNextFormField

FocusNextFormField(AcrossPages: Boolean; WrapDocument: Boolean = True; MaxPagesToScan: Integer = 256) przechodzi kolejność tabulacji bieżącej strony, potem kontynuuje na dalsze strony, a od 3.223.0 każda odwiedzana strona stosuje własną zadeklarowaną politykę. Overload międzystronicowy przyszedł w wersji 3.220.0, więc rada z artykułu o nawigacji pól formularzy — wykrywaj zawijanie i zmieniaj PageNumber sam — dotyczy teraz wyłącznie overloadu bez argumentów, który nadal cykluje w obrębie jednej strony

procedure TFormViewer.HandleTabKey(Shift: TShiftState);
var
  Field: Integer;
begin
  if ssShift in Shift then
    Field := PdfView.FocusPreviousFormField(True)  // przez strony, z zawijaniem
  else
    Field := PdfView.FocusNextFormField(True);
  if Field < 0 then
    Beep   // nic nadającego się w skanie; strona i fokus bez zmian
  else
    UpdateFieldStatus(PdfView.PageNumber, Field);
end;

Wartość zwrotna to lokalny dla strony numer porządkowy pola na stronie, na której teraz siedzi fokus, więc czytaj PageNumber obok niego. Przejście międzystronicowe pomija widgety, do których użytkownik nie ma sensu tabować: ukryte, niewidzialne, NoView, read-only, podpisy, nieznanego typu i o pustym prostokącie. Podanie WrapDocument = False zatrzymuje na granicy dokumentu z -1. MaxPagesToScan musi leżeć między 1 a 4096 i liczy się każda inspekcjonowana strona, łącznie z bieżącą. Gdy skan nie znajdzie niczego albo natywny silnik odmawia fokusu na celu, wywołanie zwraca -1 i zostawia poprzednią stronę i fokus dokładnie takie, jakie były

Gdzie zadeklarowana kolejność przestaje obowiązywać?

Zadeklarowana polityka jest honorowana tylko, gdy komponent potrafi niezawodnie przeczytać metadane strony, a każdy przypadek, gdy nie potrafi, spada na kolejność widgetów z tablicy adnotacji z jawną przyczyną. Granice są na tyle konkretne, że da się je wyliczyć:

  • Metadane pochodzą z ograniczonej prywatnej serializacji bieżącego natywnego dokumentu, nie z bajtów oryginalnego pliku, bo przenosiny stron i edycje w pamięci czynią plik na dysku nieaktualnym. Snapshot jest cache'owany per rewizja zawartości, więc zanotowane edycje go unieważniają, a nieudane snapshoty też są cache'owane
  • Dokumenty zaszyfrowane i snapshoty żądane podczas edycji page-object nie mają snapshotu i zawsze spadają na tryb awaryjny
  • Snapshot ma limity 64 MiB, 65536 aktywnych obiektów, 4096 stron, 4096 adnotacji na stronie, 65536 adnotacji łącznie i głębokość struktury 64; większe dokumenty spadają na tryb awaryjny zamiast zawieszać nawigację
  • Pokryte są wyłącznie dokumenty AcroForm. Formularze XFA zostają poza tym profilem
  • Kolejność wierszowa i kolumnowa potrzebują skończonych, niepustych prostokątów widgetów i sensownych wymiarów strony; zdegenerowana geometria uruchamia tryb awaryjny
  • Te API prowadzą nawigację z twojego kodu. Własna obsługa klawisza Tab przez PDFium przekazanego silnikowi nie jest zastępowana, więc kieruj klawisz przez FocusNextFormField, jeśli chcesz zadeklarowanej kolejności

Zapytania zachowują bieżącą stronę, natywny fokus i oczekujący tekst edycji w fokusoowanym polu i nie puszczają żadnych akcji cyklu życia dokumentu ani strony. Wołanie FormFieldTabOrder przy każdym klawiszu jest więc bezpieczne, a po pierwszym wywołaniu na danej rewizji zawartości jest tanie

Zadeklarowana kolejność tabulacji, diagnostyka statusu i międzystronicowa nawigacja fokusem są częścią PDFium Component dla Delphi i C++Builder; strona produktu linkuje pełną referencję API, łącznie z wpisami TPdfFormTabOrderMode i TPdfFormTabOrderStatus