À partir de la version 3.223.0, PDFium Component pour Delphi ordonne la navigation des champs de formulaire selon la politique /Tabs déclarée de chaque page : R pour l'ordre par lignes, C pour l'ordre par colonnes, S pour l'ordre de structure, plus les valeurs A et W du PDF 2.0. TPdf.FormFieldTabOrder renvoie cet ordre, et sa surcharge à statut rapporte si la politique déclarée a été appliquée ou si un repli a été utilisé
La plainte qui motive tout ça est toujours la même. Un utilisateur remplit un formulaire d'assurance à deux colonnes, appuie sur Tab après le champ de prénom, et le curseur saute en bas de la colonne de droite. Rien n'est cassé dans les index. Le producteur a simplement écrit la colonne de droite dans le /Annots en premier et déclaré /Tabs /R sur la page pour fixer l'ordre visuel, donc un viewer qui parcourt le /Annots en ignorant la déclaration atterrit au mauvais endroit. L'article sur l'index de widget face à l'index d'annotation couvre le lien entre les ordinaux de widgets et le tableau /Annots. Dans PDFium Component, la politique déclarée gagne dès qu'elle peut être établie, et quand elle ne le peut pas, le composant vous dit pourquoi
Que déclare réellement l'entrée /Tabs ?
/Tabs est un nom optionnel dans l'objet page (ISO 32000-2 §7.7.3.3 ; table 30 de l'ISO 32000-1) qui dit à un processeur dans quel ordre visiter les annotations de cette page, et sa sémantique est détaillée sous les annotations en §12.5.1. Le PDF 1.5 a introduit trois valeurs, et le PDF 2.0 en a ajouté deux :
/R, ordre par lignes : les lignes de haut en bas, et dans chaque ligne le sens de lecture du viewer/C, ordre par colonnes : les colonnes dans le sens de lecture, et de haut en bas dans chaque colonne/S, ordre de structure : l'ordre dans lequel l'arbre de structure logique référence les annotations/A, ordre du tableau d'annotations : l'ordre du/Annotslui-même (PDF 2.0)/W, ordre des widgets : les widgets d'abord en ordre de tableau, puis les autres annotations (PDF 2.0)
Le détail qui fait trébucher les implémentations écrites à la main est l'héritage. /Tabs n'appartient qu'au dictionnaire de page. L'ISO 32000-2 §7.7.3.4 limite les attributs de page héritables à Resources, MediaBox, CropBox et Rotate, donc une clé /Tabs posée sur un nœud /Pages ne veut rien dire, et un lecteur qui remonte la chaîne des parents pour en trouver une invente une politique que l'auteur n'a jamais posée. PDFium Component lit /Tabs sur la page elle-même et nulle part ailleurs
Lire l'ordre déclaré avec FormFieldTabOrder
TPdf.FormFieldTabOrder renvoie les champs de formulaire de la page courante comme ordinaux de widgets à base 0 dans l'ordre de tabulation déclaré, et la surcharge avec un paramètre out Status: TPdfFormTabOrderStatus explique comment cet ordre a été produit. La même paire existe sur TPdfView, et la surcharge simple utilise la politique identique en jetant le statut
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; // déjà le défaut ; requis pour les champs
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;
L'enregistrement de statut est ce qui transforme une décision d'ordonnancement silencieuse en quelque chose que vous pouvez logger. RequestedMode est ce que la page a déclaré, EffectiveMode est ce qui a été appliqué, et UsedFallback est true quand la politique déclarée n'a pas pu être honorée et que l'ordre du tableau d'annotations est revenu à la place, avec une raison courte dans ErrorMessage. SnapshotAvailable dit si le composant a pu lire les métadonnées du document. Un /Tabs absent est rapporté comme repli vers l'ordre du tableau d'annotations plutôt que présenté comme une politique déclarée, pour qu'un log de support puisse distinguer « l'auteur a demandé cet ordre » de « la page n'a rien demandé »
Pourquoi l'ordre par lignes et colonnes ne peut-il pas juste trier les valeurs /Rect ?
L'ordre par lignes et colonnes doit être calculé en géométrie d'affichage, pas en coordonnées brutes d'annotations, car le /Rotate d'une page et le sens de lecture du document changent tous deux ce que « ligne » veut dire. Triez les tableaux /Rect d'une page en /Rotate 90 par leur coordonnée y et vous obtenez ce que le viewer montre comme colonnes. Le composant mappe donc chaque rectangle de widget à travers la transformation d'affichage de la page, rotation intrinsèque comprise, à la résolution 1/64 de point, et regroupe à partir de là
Le sens de lecture vient du dictionnaire ViewerPreferences du catalogue (ISO 32000-2 §12.2). /Direction /R2L inverse l'ordre dans une ligne et inverse l'ordre des colonnes, ce à quoi s'attend un formulaire arabe ou hébreu. Quand /Direction est absent, L2R s'applique. Quand il porte autre chose que L2R ou R2L, l'ordre par lignes et colonnes retombe en ordre du tableau d'annotations avec la raison dans le statut, parce que deviner un sens est pire que d'admettre qu'il n'y en a pas
Le regroupement est là où les processeurs diffèrent légitimement, donc ça vaut de connaître la règle exacte. Pour l'ordre par lignes, le widget non visité le plus haut devient l'ancre, et chaque widget dont le centre vertical tombe dans le haut et le bas de l'ancre rejoint sa ligne. L'ordre par colonnes reflète ça horizontalement. Les égalités se tranchent de façon déterministe sur l'ordinal du widget. Sur une grille propre, ça colle avec n'importe quel viewer sensé. Sur un layout aux champs très chevauchants ou décalés, un autre processeur avec une tolérance différente peut regrouper autrement, et rien dans l'ISO 32000 ne rend l'une des deux réponses fausse
Comment l'ordre de structure décide-t-il de la séquence ?
L'ordre de structure suit les dictionnaires de référence d'objet (OBJR) dans l'arbre de structure, dans l'ordre où un parcours en profondeur des tableaux /K les atteint réellement. Il ne suit pas l'ordre des clés du /ParentTree, et il ne suit pas l'ordre que produit par hasard une énumération du StructTreeRoot. Ces ordres coïncident souvent dans les fichiers d'un seul producteur et divergent après qu'un éditeur a réorganisé l'arbre, ce qui est précisément quand l'ordre de lecture d'un formulaire étiqueté compte le plus. Si vous maintenez cet arbre vous-même, le guide de synchronisation de /ParentTree et /StructParents couvre le mappage inverse qui doit rester cohérent avec lui
Les widgets que l'arbre de structure ne référence jamais sont laissés au processeur par la spécification. PDFium Component les ajoute après les widgets structurés en ordre du tableau d'annotations et rapporte combien il en a ajoutés dans UnstructuredWidgetCount. Une page qui déclare /S sur un document sans arbre de structure du tout est traitée comme un parcours vide : chaque widget atterrit dans cette queue, le compte le dit, et le résultat n'est pas marqué comme repli complet. Un arbre de structure avec un cycle, un OBJR référencé deux fois, ou un OBJR dont le /Pg contredit la page qui possède l'annotation fait retomber l'ordre de structure en repli. Ces défauts rendent S inutilisable, mais ils ne dérangent pas R ni C sur les autres pages
Pourquoi les valeurs d'ordre de tabulation ne sont-elles pas des index d'annotations ?
Les valeurs du tableau renvoyé par FormFieldTabOrder ne comptent que les widgets, alors qu'un OBJR identifie un objet d'annotation qui vit dans le tableau /Annots complet. Mélanger les deux espaces d'index est le bug le plus probable de tout code qui consomme l'ordre de structure. Prenons une page dont le /Annots est [Link, Widget A, Widget B]. L'OBJR du Widget B pointe l'index d'annotation 2. Le Widget B est l'ordinal de champ 1. Donnez 2 à FocusFormField et vous focussez soit le mauvais champ, soit rien du tout, selon le nombre de widgets de la page
Le composant effectue cette conversion en interne, voilà pourquoi son résultat peut aller directement dans FocusFormField et FormFieldInfo. Si votre propre code lit des cibles OBJR ou d'autres index d'annotations, convertissez-les explicitement avant de les traiter comme des champs :
// Convertit un index d'annotation de page vers l'ordinal de widget utilisé par
// FocusFormField, FormFieldInfo et FormFieldTabOrder ; -1 si pas un 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; // liens, notes et popups ne sont pas des champs
Result := 0;
for I := 0 to AnnotIndex - 1 do
if Pdf.Annotation[I].Subtype = anWidget then
Inc(Result);
end;
Traitez le résultat comme valide seulement tant que la page reste chargée et non éditée. Ajouter ou retirer une annotation devant un widget décale les deux espaces d'index, et un mappage en cache d'avant l'édition est exactement le genre de décalage d'un qui ne se reproduit que sur les fichiers de clients
Navigation entre pages avec FocusNextFormField
FocusNextFormField(AcrossPages: Boolean; WrapDocument: Boolean = True; MaxPagesToScan: Integer = 256) parcourt l'ordre de tabulation de la page courante, puis continue vers les pages suivantes, et depuis 3.223.0 chaque page visitée applique sa propre politique déclarée. La surcharge entre pages est arrivée en version 3.220.0, donc le conseil de l'article sur la navigation entre champs de formulaire, détecter le bouclage et changer PageNumber vous-même, ne s'applique désormais qu'à la surcharge sans argument, qui boucle toujours dans une seule page
procedure TFormViewer.HandleTabKey(Shift: TShiftState);
var
Field: Integer;
begin
if ssShift in Shift then
Field := PdfView.FocusPreviousFormField(True) // entre pages, boucle
else
Field := PdfView.FocusNextFormField(True);
if Field < 0 then
Beep // rien d'éligible dans le scan ; page et focus inchangés
else
UpdateFieldStatus(PdfView.PageNumber, Field);
end;
La valeur de retour est l'ordinal de champ local à la page sur la page qui détient désormais le focus, donc lisez PageNumber à côté. Le parcours entre pages saute les widgets dans lesquels un utilisateur ne peut pas tabuler utilement : widgets cachés, invisibles, NoView, read-only, signature, de type inconnu et à rectangle vide. Passer WrapDocument = False s'arrête à la frontière du document avec -1. MaxPagesToScan doit se situer entre 1 et 4096, et chaque page inspectée compte, courante comprise. Quand le scan ne trouve rien ou que le moteur natif refuse le focus sur la cible, l'appel renvoie -1 et laisse la page et le focus précédents exactement tels quels
Où l'ordre déclaré cesse-t-il de s'appliquer ?
La politique déclarée n'est honorée que quand le composant peut lire les métadonnées de page de façon fiable, et chaque cas où il ne le peut retombe en ordre de widgets du tableau d'annotations avec une raison explicite. Les frontières sont assez précises pour être listées :
- Les métadonnées viennent d'une sérialisation privée bornée du document natif courant, pas des octets du fichier d'origine, car les déplacements et éditions de pages en mémoire rendent le fichier sur disque périmé. L'instantané est mis en cache par révision de contenu, donc les éditions notifiées l'invalident, et les instantanés échoués sont mis en cache aussi
- Les documents chiffrés, et les instantanés demandés pendant une édition d'objet de page, n'ont pas d'instantané et retombent toujours en repli
- L'instantané est plafonné à 64 MiB, 65536 objets actifs, 4096 pages, 4096 annotations par page, 65536 annotations au total et une profondeur de structure de 64 ; les documents plus gros retombent en repli au lieu de bloquer la navigation
- Seuls les documents AcroForm sont couverts. Les formulaires XFA restent en dehors de ce profil
- L'ordre par lignes et colonnes exige des rectangles de widgets finis et non vides et des dimensions de page saines ; une géométrie dégénérée déclenche le repli
- Ces API pilotent la navigation depuis votre code. La gestion propre de PDFium d'une touche Tab transmise au moteur n'est pas remplacée, donc routez la touche via
FocusNextFormFieldsi vous voulez l'ordre déclaré
Les requêtes préservent la page courante, le focus natif et tout texte d'édition en attente dans le champ focusé, et elles n'exécutent aucune action de cycle de vie de document ou de page. Appeler FormFieldTabOrder à chaque frappe est donc sûr, et après le premier appel sur une révision de contenu donnée c'est bon marché
L'ordre de tabulation déclaré, les diagnostics de statut et la navigation par focus entre pages font partie du PDFium Component pour Delphi et C++Builder ; la page produit lie la référence API complète, dont les entrées TPdfFormTabOrderMode et TPdfFormTabOrderStatus