Technický článek

Pořadí procházení Tab a politika /Tabs v Delphi

Od verze 3.223.0 řadí PDFium Component for Delphi navigaci polí formuláře podle deklarované politiky /Tabs každé stránky: R pro pořadí po řádcích, C pro pořadí po sloupcích, S pro pořadí podle struktury, plus hodnoty A a W z PDF 2.0. TPdf.FormFieldTabOrder vrátí to pořadí a jeho status overload ohlásí, jestli se deklarovaná politika aplikovala, nebo se použil fallback

Stížnost, která tohle všechno motivuje, je pořád tatáž. Uživatel vyplňuje dvousloupcový pojistný formulář, zmáčkne Tab za polem jména a kurzor skočí na spodek pravého sloupce. Na indexech nic není rozbité. Producent prostě zapsal pravý sloupec do /Annots jako první a deklaroval na stránce /Tabs /R, aby opravil vizuální pořadí, takže prohlížeč, který projde /Annots a deklaraci ignoruje, dopadne špatně. Článek o widget indexu verzus anotačním indexu popisuje, jak se widget ordinals vztahují k poli /Annots. V PDFium Component vyhrává deklarovaná politika, kdykoli se dá ustavit, a když se nedá, komponenta řekne proč

Co položka /Tabs doopravdy deklaruje?

/Tabs je volitelný název v page objektu (ISO 32000-2 §7.7.3.3; Table 30 v ISO 32000-1), který říká procesoru, v jakém pořadí navštěvovat anotace na té stránce, a jeho sémantika je rozepsaná u anotací v §12.5.1. PDF 1.5 přineslo tři hodnoty a PDF 2.0 přidalo dvě další:

  • /R, pořadí po řádcích: řádky shora dolů a v každém řádku čtecí směr prohlížeče
  • /C, pořadí po sloupcích: sloupce v čtecím směru a shora dolů v každém sloupci
  • /S, pořadí podle struktury: pořadí, ve kterém strom logické struktury odkazuje anotace
  • /A, pořadí podle pole anotací: pořadí samotného /Annots (PDF 2.0)
  • /W, pořadí podle widgetů: nejdřív widgety v pořadí pole, pak ostatní anotace (PDF 2.0)

Detail, na kterém padají ručně psané implementace, je dědičnost. /Tabs patří jen slovníku stránky. ISO 32000-2 §7.7.3.4 omezuje dědičné atributy stránek na Resources, MediaBox, CropBox a Rotate, takže klíč /Tabs sedící na uzlu /Pages je bezvýznamný a čtečka, která hledá jeden takový jdem řetězcem rodičů, si vymýšlí politiku, kterou autor nikdy nenastavil. PDFium Component čte /Tabs ze samotné stránky a nikde jinde

Čtení deklarovaného pořadí přes FormFieldTabOrder

TPdf.FormFieldTabOrder vrací pole formuláře aktuální stránky jako widget ordinals od nuly v deklarovaném pořadí procházení a overload s parametrem out Status: TPdfFormTabOrderStatus vysvětluje, jak se to pořadí vyrobilo. Tatáž dvojice existuje na TPdfView a holý overload používá identickou politiku, jen status zahodí

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;          // už je default; vyžadováno pro pole
    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;

Status záznam je to, co z tichého rozhodnutí o pořadí udělá něco, co se dá logovat. RequestedMode je to, co stránka deklarovala, EffectiveMode je to, co se aplikovalo, a UsedFallback je true, když se deklarovaná politika nedala uctít a vrátilo se pořadí podle pole anotací, s krátkým důvodem v ErrorMessage. SnapshotAvailable říká, zda komponenta vůbec dokázala číst metadata dokumentu. Chybějící /Tabs se hlásí jako fallback na pořadí podle pole anotací místo aby se vydával za deklarovanou politiku, takže support log umí rozlišit „autor si tohle pořadí přál" od „stránka si nepřála nic"

Hodnoty politiky /Tabs stránky R, C, S, A a W se statusovými poli, která PDFium Component pro Delphi hlásí, když se deklarované pořadí aplikuje, nebo přejde na fallback
chybějící /Tabs je hlášený fallback na pořadí podle pole anotací, ne deklarovaná politika vydávaná za jednu

Proč nemohou pořadí po řádcích a sloupcích jen setřídit hodnoty /Rect?

Pořadí po řádcích a sloupcích se musí počítat v zobrazovací geometrii, ne v surových anotačních souřadnicích, protože /Rotate stránky i čtecí směr dokumentu mění obě, co „řádek" znamená. Setřídíte-li pole /Rect stránky s /Rotate 90 podle souřadnice y, dostanete to, co prohlížeč ukazuje jako sloupce. Komponenta proto mapuje obdélník každého widgetu přes zobrazovací transformaci stránky, včetně vlastní rotace, v rozlišení 1/64 bodu, a teprve odsud seskupuje

Čtecí směr pochází ze slovníku ViewerPreferences katalogu (ISO 32000-2 §12.2). /Direction /R2L obrací pořadí v řádku i pořadí sloupců, co čeká arabský nebo hebrejský formulář. Když /Direction chybí, platí L2R. Když drží cokoliv jiného než L2R nebo R2L, pořadí po řádcích a sloupcích se uchýlí k pořadí podle pole anotací s důvodem ve statusu, protože hádat směr je horší než přiznat, že žádný není

Seskupování je místo, kde se procesory legitimně liší, takže stojí za to znát přesné pravidlo. U pořadí po řádcích se anchor stane nejvýše položený nenavštívený widget a ke jeho řádku se přidá každý widget, jehož vertikální střed padá do rozpětí mezi horní a dolní hranou anchoru. Pořadí po sloupcích to zrcadlí horizontálně. Remízy se rozhraní deterministicky podle widget ordinalu. Na čisté mřížce se trefí do každého rozumného prohlížeče. Na layoutu s těžce se překrývajícími nebo přeházenými poli může jiný procesor s jinou tolerancí seskupit jinak a žádná část ISO 32000 nedělá ani jednu odpověď špatnou

PDFium Component v Delphi: pořadí podle pole anotací skáče dolů pravým sloupcem, zatímco deklarované pořadí /Tabs R navštěvuje dvousloupcový formulář řádek za řádkem
pořadí po řádcích se počítá v zobrazovací geometrii po /Rotate a čtecím směru, ne v surovém pořadí /Annots

Jak pořadí podle struktury rozhoduje sekvenci?

Pořadí podle struktury následuje slovníky objektových referencí (OBJR) ve stromu struktury, v pořadí, ve kterém je hloubkový průchod polí /K skutečně dosáhne. Nenásleduje pořadí klíčů /ParentTree a nenásleduje ani to, jaké pořadí zrovna vyprodukuje enumerace StructTreeRoot. V souborech od jednoho producenta se ta pořadí často kryjí a rozejdou se poté, co editor strom přeskládá, což je přesně ten okamžik, kdy záleží nejvíc na čtecím pořadí tagovaného formuláře. Pokud ten strom udržujete sami, průvodce držením /ParentTree a /StructParents v souladu popisuje zpětné mapování, které s ním musí zůstat konzistentní

Widgety, na které strom struktury nikdy neodkazuje, specifikace nechává na procesoru. PDFium Component je přidá za strukturované widgety v pořadí podle pole anotací a hlásí, kolik jich přidal, v UnstructuredWidgetCount. Stránka, která deklaruje /S v dokumentu bez jakéhokoli stromu struktury, se bere jako prázdný průchod: každý widget dopadne do té ocasy, count to říká a výsledek se neoznačí jako plný fallback. Strom struktury s cyklem, OBJR odkazovaným dvakrát nebo OBJR, jehož /Pg odporuje stránce vlastnící anotaci, donutí pořadí podle struktury k fallbacku. Ty defekty učiní S nepoužitelným, ale R ani C na jiných stránkách neruší

Proč hodnoty pořadí Tab nejsou anotační indexy?

Hodnoty v poli vráceném z FormFieldTabOrder počítají jen widgety, zatímco OBJR identifikuje objekt anotace, který žije v plném poli /Annots. Míchání těch dvou indexových prostorů je jediná nejpravděpodobnější buga v jakémkoli kódu konzumujícím pořadí podle struktury. Vezměte stránku, jejíž /Annots je [Link, Widget A, Widget B]. OBJR pro Widget B míří na anotační index 2. Widget B je field ordinal 1. Pošlete-li 2 do FocusFormField, zameríte buď špatné pole, nebo vůbec nic, podle toho, kolik widgetů stránka má

Strukturní OBJR míří na anotační index 2, zatímco form API PDFium Component v Delphi počítají widgety, takže Widget B je field ordinal 1
anotační index 2 není field ordinal 1 — konvertujte explicitně, než zavoláte FocusFormField

Komponenta tu konverzi dělá interně, a proto její výsledek může jít rovnou do FocusFormField a FormFieldInfo. Pokud váš vlastní kód čte OBJR cíle nebo jiné anotační indexy, konvertujte je explicitně, než je začnete brát jako pole:

// Konvertuje anotační index stránky na widget ordinal používaný
// FocusFormField, FormFieldInfo a FormFieldTabOrder; -1, když to není 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;                          // odkazy, poznámky a popupy nejsou pole
  Result := 0;
  for I := 0 to AnnotIndex - 1 do
    if Pdf.Annotation[I].Subtype = anWidget then
      Inc(Result);
end;

Výsledek berte za platný jen poté, co zůstane stránka načtená a needitovaná. Přidání nebo odebrání jakékoli anotace před widgetem posune oba indexové prostory a cacheované mapování z doby před editací je přesně ten druh off-by-one, který se reprodukuje jen u souborů zákazníků

Navigace napříč stránkami přes FocusNextFormField

FocusNextFormField(AcrossPages: Boolean; WrapDocument: Boolean = True; MaxPagesToScan: Integer = 256) projde pořadí procházení aktuální stránky, pak pokračuje do pozdějších stránek a od 3.223.0 každá navštívená stránka aplikuje svou vlastní deklarovanou politiku. Overload napříč stránkami přišel ve verzi 3.220.0, takže rada z článku o navigaci polí formulářů detekovat wrap a měnit PageNumber samostatně teď platí jen pro overload bez argumentů, který se pořád cyklí v jedné stránce

procedure TFormViewer.HandleTabKey(Shift: TShiftState);
var
  Field: Integer;
begin
  if ssShift in Shift then
    Field := PdfView.FocusPreviousFormField(True)  // napříč stránkami, s wrapem
  else
    Field := PdfView.FocusNextFormField(True);
  if Field < 0 then
    Beep   // v rámci skenu nic způsobilého; stránka i focus beze změny
  else
    UpdateFieldStatus(PdfView.PageNumber, Field);
end;

Návratová hodnota je page-local field ordinal na jakékoli stránce, která teď drží focus, takže vedle ní čtěte PageNumber. Průchod napříč stránkami přeskočí widgety, do kterých uživatel nemá smysluplně tabovat: skryté, neviditelné, NoView, read-only, podpisové, widgety neznámého typu a s prázdným obdélníkem. Předání WrapDocument = False se zastaví na hranici dokumentu s -1. MaxPagesToScan musí ležet mezi 1 a 4096 a počítá se každá prohlížená stránka, včetně aktuální. Když sken nic nenajde nebo nativní engine odmítne focus na cíli, volání vrátí -1 a nechá předchozí stránku i focus přesně tak, jak byly

Kde deklarované pořadí přestává platit?

Deklarovaná politika se uctívá jen tehdy, když komponenta dokáže spolehlivě číst metadata stránky, a každý případ, kdy to nejde, se uchýlí k pořadí widgetů podle pole anotací s explicitním důvodem. Hranice jsou dost konkrétní na to, aby se daly vyjmenovat:

  • Metadata pocházejí z ohraničené soukromé serializace aktuálního nativního dokumentu, ne z původních bajtů souboru, protože in-memory přesuny a editace stránek dělají soubor na disku zastaralým. Snapshot se cacheuje na content revizi, takže oznámené editace ho zneplatní a neúspěšné snapshoty se cacheují taky
  • Šifrované dokumenty a snapshoty žádané během editace page-objectu nemají žádný snapshot a vždy se uchýlí k fallbacku
  • Snapshot má strop 64 MiB, 65536 aktivních objektů, 4096 stránek, 4096 anotací na stránku, 65536 anotací celkem a hloubku struktury 64; větší dokumenty se uchýlí k fallbacku místo zastavení navigace
  • Pokryté jsou jen AcroForm dokumenty. XFA formuláře zůstávají mimo tenhle profil
  • Pořadí po řádcích a sloupcích potřebuje konečné, neprázdné widgetové obdélníky a smysluplné rozměry stránky; degenerovaná geometrie spustí fallback
  • Tyhle API řídí navigaci z vašeho kódu. Obsluha klávesy Tab v samotném PDFium přeposlaná enginu se nenahrazuje, takže chcete-li deklarované pořadí, směrujte klávesu přes FocusNextFormField

Dotazy zachovávají aktuální stránku, nativní focus i jakýkoli čekající editovaný text v zamereném poli a nespouštějí žádné dokumentové ani stránkové lifecycle akce. Volat FormFieldTabOrder při každém stisku klávesy je proto bezpečné a po prvním volání na dané content revizi je to levné

Deklarované pořadí procházení, status diagnostiky i navigace focusem napříč stránkami jsou součástí PDFium Component for Delphi and C++Builder; produktová stránka odkazuje kompletní API referenci, včetně položek TPdfFormTabOrderMode a TPdfFormTabOrderStatus