Article technique

Index de widget vs index d annotation en formulaires PDFium

Dans PDFium Component, le composant VCL/LCL basé sur PDFium pour Delphi, C++Builder et Lazarus, un index de champ de formulaire n'est pas un index d'annotation. Une page porte des annotations Link, Text et Ink à côté de ses widgets, si bien que l'énumération des champs doit filtrer sur FPDFAnnot_GetSubtype et exposer un index logique en base zéro, réassocié à une véritable position d'annotation seulement au moment de l'appel natif

Le bogue qui révèle ceci est sans équivoque une fois qu'on l'a vu. Un testeur appuie sur Tab dans un formulaire de facture rempli et le curseur disparaît, car le focus est allé vers un hyperlien dans le pied de page. Ou pire, rien ne se passe du tout : votre code enregistre le champ 3 comme focalisé, le panneau d'interface se met à jour, et FORM_SetFocusedAnnot a silencieusement retourné false pendant tout ce temps. Les deux symptômes proviennent de la même erreur de conception, et l'un d'eux cache une seconde cause profonde en dessous

Les deux espaces d'index que PDFium vous transmet

PDFium expose deux systèmes de numérotation sur la même page, et ils ne coïncident que sur les documents qui se trouvent ne contenir rien d'autre que des widgets de formulaire. Le premier est l'index d'annotation : une position dans le tableau /Annots de la page, ce que FPDFPage_GetAnnotCount compte et ce que FPDFPage_GetAnnot prend (ISO 32000-1 §12.5.2). Le second est l'index de champ logique qu'une API au niveau applicatif devrait offrir, allant de zéro sur les champs interactifs qu'un utilisateur peut réellement atteindre. ISO 32000-1 §12.5.6.19 définit les annotations widget comme la représentation visuelle des champs de formulaire interactifs, et §12.7 définit le formulaire lui-même. Tout le reste sur la page est un sous-type différent avec une sémantique différente : une annotation Link a une destination, une annotation Ink a une liste de traits, une annotation Text est une note autocollante. Aucune d'elles n'appartient à un compte de champs, et aucune ne peut accepter le focus de formulaire. Pourtant, dans le tableau /Annots, elles siègent entrelacées avec les widgets dans l'ordre où l'application productrice les a écrites, ce qui n'est fréquemment pas l'ordre que quoi que ce soit d'autre dans le document suggère

Pourquoi Tab atterrit-il sur un hyperlien au lieu du champ suivant ?

Parce que le compte de champs était en réalité un compte d'annotations. L'implémentation d'origine retournait directement FPDFPage_GetAnnotCount depuis FormFieldCount, tandis que l'accesseur d'information de champ, l'assistant d'ordre de tabulation et l'assistant de focus traitaient tous ce même entier comme une position de widget. Sur une page AcroForm propre avec six widgets et rien d'autre, six égale six et chaque test passe. Ajoutez un hyperlien dans le pied de page et un commentaire de relecteur dans la marge, et le compte rapporte huit champs, les index 6 et 7 se résolvent en objets non-formulaire, et Tab s'y engouffre directement

La correction du côté énumération consiste à compter les sous-types plutôt que les annotations. Ouvrez chaque annotation, demandez son sous-type, gardez les widgets, et fermez le handle dans un bloc finally, car FPDFPage_GetAnnot retourne un handle possédé qui doit repasser par 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;

Notez ce que cela ne fait délibérément pas. Cela ne demande rien à l'environnement de remplissage de formulaire, et cela n'a pas besoin d'un handle de formulaire, car le sous-type vit dans le dictionnaire d'annotation et est lisible depuis la page seule. Cela compte pour l'ordonnancement : le compte est disponible avant même que vous ayez décidé si le document mérite un environnement de remplissage de formulaire, ce que l'article sur JavaScript AcroForm et les événements hôtes traite comme une décision de sécurité plutôt qu'une simple commodité

Réassocier l'index logique à la frontière native

La règle qui empêche les deux espaces de se contaminer mutuellement est simple : l'index logique est le seul nombre qui traverse votre API publique, et il est converti en index d'annotation dans la dernière fonction avant l'appel natif. Un assistant de correspondance unique, utilisé aussi bien par l'information de champ, le focus, les définisseurs d'indicateurs, et l'ordre de tabulation, est ce qui rend cette règle applicable

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;

Deux propriétés de cet assistant méritent d'être énoncées clairement. C'est un balayage linéaire, si bien qu'une boucle naïve sur chaque champ coûte un nombre quadratique d'ouvertures d'annotation sur une page avec des centaines de widgets ; si vous énumérez la page entière, parcourez les annotations une fois et collectez les handles de widget au passage plutôt que d'appeler le mappeur par champ. Et il retourne -1 plutôt que de lever une exception, ce qui laisse l'appelant décider si un index périmé est une erreur de programmation méritant une exception ou une course à ignorer, par exemple après qu'une modification a supprimé une annotation à laquelle une liste d'interface mise en cache fait encore référence

Pourquoi FORM_SetFocusedAnnot échoue-t-il sur une page headless ?

Parce que PDFium refuse de focaliser un widget dont la vue de page n'a jamais été marquée valide. FORM_SetFocusedAnnot résout l'annotation en une vue de page à l'intérieur de l'environnement de remplissage de formulaire, et si cette vue de page n'existe pas, il retourne false sans aucun diagnostic. Corriger seulement le mappage d'index répare donc Tab atterrissant sur un hyperlien mais laisse le second symptôme intact : votre enregistrement de focus logique dit champ 3, le widget natif focalisé n'est toujours rien, et chaque accesseur construit sur le focus natif, le texte focalisé, la valeur focalisée, l'état de sélection de choix, continue de retourner du vide. La vue de page est créée par FORM_OnAfterLoadPage et détruite par FORM_OnBeforeClosePage. Dans un visualiseur construit autour d'un contrôle visuel, ces appels se produisent dans le cadre de l'affichage d'une page, ce qui explique pourquoi l'échec ressemble si souvent à un bogue exclusivement headless : le même code qui fonctionne dans la démo GUI échoue dans l'outil par lots. Le cycle de vie appartient à l'objet document, pas au visualiseur, si bien que PDFium Component émet désormais les deux appels chaque fois qu'une page est chargée ou déchargée avec un handle de formulaire présent. La signature C prend la page en premier et le handle de formulaire en second, ce qui est facile à inverser en écrivant la liaison à la main

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;

La vérification qui prouve que la correction fonctionne est celle qui compare les deux côtés. Appelez FocusFormField avec un index logique, puis lisez une valeur via un accesseur qui passe par le widget natif focalisé plutôt que par votre propre enregistrement, tel que FocusedFormFieldValue ou FocusedFormOptionSelected. Si l'index logique fait l'aller-retour correctement mais que l'accesseur natif revient vide, c'est la vue de page qui manque, pas le mappage

Ce que l'index de champ logique ne promet pas

Un index de champ en base zéro est une commodité, pas une identité sémantique, et quatre limites en découlent. Il est par page, pas par document, si bien que l'index 0 sur la page 2 est un widget différent de l'index 0 sur la page 1, et les comparer n'a aucun sens. Il est positionnel, si bien qu'insérer ou supprimer une annotation invalide chaque index mis en cache au-dessus du changement ; traitez un index stocké comme valide seulement tant que la page reste chargée et non modifiée

La troisième limite est celle qui surprend les gens qui relisent une liste de champs. L'index énumère des widgets, pas des champs. Un groupe de boutons radio est un seul champ avec plusieurs widgets enfants, si bien qu'un groupe à trois boutons contribue trois index consécutifs qui rapportent tous le même Name. L'enregistrement TPdfFormFieldInfo porte GroupCount et GroupIndex exactement pour ce cas, et une interface de liste qui les ignore affiche le même champ trois fois. La quatrième limite concerne l'ordre de parcours : l'ordre de tabulation exposé ici est l'ordre d'énumération des widgets, qui suit le tableau /Annots, pas l'entrée /Tabs de la page (ISO 32000-1 §7.7.3.3) et pas l'arbre de champs AcroForm. Pour la plupart des producteurs, les deux s'accordent ; pour un formulaire disposé en deux colonnes par un générateur qui a émis la colonne de droite en premier, ils ne s'accordent pas, et le chemin clavier décrit dans l'article sur la navigation des champs de formulaire paraîtra faux même si chaque index est correct. Quand un fichier client se comporte étrangement, imprimez les deux espaces d'index côte à côte avant de théoriser : la vue annotation et la vue champ de la même page, imprimées ensemble, rendent généralement la cause évidente d'un seul coup d'œil

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;

Un compte d'annotations largement supérieur au compte de champs signifie que la page mélange des sous-types, ce qui est normal dans des documents relus, et c'est exactement la situation pour laquelle le mappage existe ; l'article sur le flux de relecture d'annotations regarde la même page du côté des annotations. Des comptes égaux sur chaque fichier de test, en revanche, signifient que vos fixtures ne peuvent absolument pas détecter cette classe de bogue, et la réponse honnête est d'ajouter une fixture de formulaire portant un lien et une note autocollante

Les API d'énumération de champ, de focus et d'annotation décrites ici sont livrées avec PDFium Component pour Delphi, C++Builder et Lazarus, dont la page produit propose la référence complète des champs de formulaire, y compris l'enregistrement d'information de champ et les accesseurs de focus