Article technique

Valeurs de champs AcroForm héritées et resets en Delphi

Un formulaire d'enquête montre deux listes déroulantes dans Acrobat, group.a et group.b. Parcourez /AcroForm /Fields avec du code qui traite tout nœud portant son propre /FT comme un champ fini, et le compte revient à 1 : un unique champ nommé group avec deux widgets. Demandez group.b par nom et la recherche échoue. Ouvrez le dictionnaire de l'enfant : il n'y a aucun /FT dedans, aucun /Ff, aucune liste d'options. Rien n'est cassé. Le type, les drapeaux et les options se trouvent un niveau plus haut sur le parent, et la spécification PDF les autorise explicitement à vivre là

La disposition est légale et économique. Un outil d'auteur écrit un nœud groupe avec /FT /Ch, /Ff 131078 et un tableau /Opt une seule fois, puis suspend deux enfants nommés dessous, chacun un dictionnaire fusionné champ-plus-widget ne portant que /T, /Parent, /Rect et son propre /V. Lire correctement un tel formulaire demande trois choses : suivre /Parent pour chaque clé héritable, décider où l'arbre de champs se termine réellement, et s'assurer qu'un reset écrit une valeur du bon type PDF. Cette pièce reprend là où définir des valeurs de champs de formulaire dans un PDF chargé avec Delphi s'arrête

Quelles entrées AcroForm un champ peut-il hériter de son parent ?

L'ISO 32000-1 §12.7.3.1 (Table 220) marque /FT, /Ff, /V et /DV comme héritables, et la Table 229 en §12.7.4.3 en fait autant pour le /MaxLen d'un champ texte. Les tableaux d'options reçoivent ici le même traitement, car les producteurs qui partagent une liste de choix entre enfants stockent /Opt sur le groupe. Un lecteur qui ne vérifie que le dictionnaire local rapportera donc le mauvais type, des drapeaux à 0 et une valeur vide pour un enfant parfaitement valide

HotPDF Delphi Component répond à chacune de ces lectures de la même façon. Il cherche la clé dans le dictionnaire propre du champ et, si la clé y est, résout la référence indirecte et s'arrête. Sinon il suit /Parent et réessaie, pour au plus 128 niveaux. Ce plafond n'est pas décoratif. Des pointeurs /Parent dans des fichiers endommagés peuvent former une boucle qui n'a rien à voir avec /Kids, et un lecteur qui leur fait confiance sans condition se bloque sur le premier fichier de ce genre qu'il rencontre

Une règle de cette marche est facile à prendre à l'envers : le premier dictionnaire qui contient la clé gagne, même quand la valeur stockée y est une chaîne vide. Un /V () local sur group.b est l'auteur qui dit « cet enfant est vide », et la marche s'arrête là au lieu de grimper vers une valeur que le parent porterait peut-être. Traiter vide comme « absent » ferait afficher à un enfant effacé la sélection de son parent, l'exact contraire de ce que l'auteur a écrit

Schéma des attributs AcroForm hérités dans HotPDF : un nœud groupe porte /FT, /Ff et /Opt une seule fois tandis que les enfants nommés group.a et group.b ne détiennent que /T, /Parent, /Rect et un /V local, montrant HPDFLoadedInheritedFieldObject remonter /Parent jusqu'à 128 niveaux où le premier dictionnaire détenant une clé gagne et une valeur locale vide masque le parent
HotPDF résout /FT, /Ff, /V, /DV et /Opt par un seul résolveur qui remonte les parents, si bien qu'un enfant nommé reste adressable tandis qu'une valeur locale vide surcharge délibérément tout ce que porte le groupe au-dessus

Les getters publics s'appuient tous sur cette recherche : GetFormFieldType, GetFormFieldValue, GetLoadedFormFieldFlags, IsFormFieldRequired, IsFormFieldNoExport, GetLoadedFormFieldMaxLength, GetLoadedFormFieldDefaultValue, et les helpers d'options GetLoadedFormFieldOptionCount et GetLoadedFormFieldOptions. Lire l'enfant du groupe d'enquête ne demande donc aucun code particulier :

var
  Pdf: THotPDF;
  Field: THPDFLoadedFormField;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('survey.pdf') <= 0 then Exit;
    // 'group' porte /FT /Ch, /Ff 131078 et /Opt ; l'enfant
    // 'group.b' ne porte que /T, /Parent, /Rect et son propre /V
    Field := Pdf.GetFormField('group.b');
    try
      if Pdf.GetFormFieldType(Field.Index) = lfftChoice then
      begin
        // 131078 = Combo (bit 18) + NoExport (bit 3) + Required (bit 2)
        Writeln(Pdf.GetLoadedFormFieldFlags(Field.Index));
        Writeln(Pdf.IsFormFieldRequired(Field.Index));    // TRUE
        Writeln(Pdf.GetLoadedFormFieldOptionCount(Field.Index));
        Writeln(Pdf.GetFormFieldValue(Field.Index));       // le /V local
      end;
    finally
      Field.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

GetFormField renvoie un nouveau wrapper THPDFLoadedFormField à chaque appel et lève une exception quand le nom est inconnu : gardez l'appel hors du bloc try du wrapper et libérez le wrapper vous-même

Trouver où l'arbre de champs se termine

Un /FT local est le mauvais test pour un champ terminal, car un parent peut fournir le type et posséder quand même des champs enfants nommés. Le parcours tentant cesse de descendre dès qu'un nœud a son propre /FT ou n'a pas de /Kids. Dans le formulaire d'enquête, group a à la fois /FT /Ch et /Kids, donc ce parcours l'enregistre comme un champ unique à deux annotations widget. Les noms group.a et group.b disparaissent. Le compte de champs vaut 1. Une recherche par nom d'enfant échoue, et une écriture de valeur ne peut atterrir que sur le parent partagé

HotPDF regarde les kids à la place. Un kid compte comme champ enfant s'il a son propre /T, a son propre /Kids, ou n'est pas un dictionnaire /Subtype /Widget. Seulement quand aucun kid ne qualifie, le nœud est terminal, et ses kids sont alors traités comme ses widgets. Les deux cas limites derrière cette règle viennent des dictionnaires fusionnés, que §12.7.3.1 autorise quand un champ a exactement un widget :

  • Un dictionnaire fusionné nommé porte /Subtype /Widget et reste un champ enfant. Le sous-type à lui seul ne peut pas l'envoyer dans la liste de widgets anonymes du parent ; c'est le /T qui décide
  • Certains producteurs répètent le /FT du parent sur chaque widget anonyme. Donc un /FT sur un widget ne peut pas se lire « un nouveau champ commence ici » non plus

La même classification pilote l'index de champs, FormFieldExists et RemoveFormField. Chacun de ces parcours enregistre aussi les dictionnaires déjà visités et abandonne au-delà de 128 niveaux. Supposons que le groupe est l'objet 5 et se liste deux fois parmi ses propres kids : /Kids [5 0 R 5 0 R 6 0 R 7 0 R]. Il rapporte quand même exactement deux champs, les enfants 6 et 7. Pas de récursion infinie, et pas de troisième champ fantôme issu de l'auto-référence

Comment RemoveFormField évite-t-il de supprimer les champs frères ?

RemoveFormField ne supprime que l'enfant que vous nommez parce que découverte et suppression partagent une seule définition du champ terminal. La surcharge par nom résout un index de champ depuis la liste des champs, puis parcourt /AcroForm /Fields une seconde fois en comptant les champs terminaux pour trouver le dictionnaire à supprimer. Si le second parcours utilisait la règle /FT tentante pendant que le premier utilisait la règle par kids, les index se contrediraient : le parcours de suppression verrait group comme champ 0, et retirer group.a emporterait le parent, le frère et les deux widgets

Le parcours de suppression applique donc le même test de champ enfant et le même ensemble de visités que la découverte. Il collecte les annotations widget appartenant à l'enfant retiré, ne les ôte que des /Annots de chaque page, et ne retire le parent que quand son tableau /Kids finit vide. Avec group.b toujours listé, le parent reste, et le type, les drapeaux et les options que group.b hérite restent avec lui

Schéma de survie des frères dans RemoveFormField de HotPDF : le parcours de suppression réutilise HPDFLoadedFieldHasChildFields et l'ensemble de visités de la découverte, ne retire que l'enfant nommé group.a des /Fields AcroForm et des /Annots de la page, et garde le parent partagé tant que son tableau /Kids contient encore le group.b survivant
Découverte et suppression s'accordent enfin sur ce qu'est un champ terminal, donc retirer un enfant nommé laisse la valeur et l'apparence de son frère intactes après une réécriture complète ou une mise à jour incrémentale
// Retirer un enfant nommé ; son frère et le parent partagé survivent
Pdf.RemoveFormField('group.a');

// Vérifications que vous ajouteriez vous-même, pas une partie de la bibliothèque
Assert(Pdf.GetFormFieldCount = 1);
Assert(Pdf.FormFieldExists('group.b'));
// Type, drapeaux et options restent résolus via le parent
Assert(Pdf.GetFormFieldType('group.b') = lfftChoice);

Pdf.SaveLoadedDocument('survey-trimmed.pdf');

Quand vous vérifiez un retrait sur vos propres formulaires, regardez à trois endroits, car chacun attrape une erreur différente : les /Kids du parent, les /Annots de la page, et la valeur et l'apparence du frère survivant. Faites-le une fois après SaveLoadedDocument et une fois après SaveIncrementalUpdate, car une sauvegarde incrémentale n'ajoute que les objets changés et les /Kids du parent doivent en faire partie

Ce que ResetLoadedFormField copie d'un /DV hérité

ResetLoadedFormField écrit un /V local qui est une copie neuve du /DV hérité, avec le même type d'objet PDF, et il valide le défaut complet avant de toucher au champ. Un défaut de case à cocher est un nom tel que /Yes. Un défaut de list box multi-sélection est un tableau de chaînes. Un défaut texte peut être une chaîne UTF-16 hexadécimale comme <FEFF...>. Faites passer l'un d'eux par GetLoadedFormFieldDefaultValue et SetFormFieldValue et vous obtenez une chaîne là où un nom devait être, une chaîne vide là où il y avait un tableau, ou des chiffres hex littéraux

Le reset se branche donc sur ce que le /DV hérité est réellement :

  • Les champs texte et choix avec un défaut chaîne reçoivent un nouvel objet chaîne qui garde le drapeau hexadécimal de l'original
  • Les champs de choix avec un défaut tableau reçoivent un nouveau tableau de nouvelles chaînes ; un tableau contenant autre chose que des chaînes lève une exception
  • Les cases à cocher et boutons radio reçoivent un nouvel objet nom
  • Un défaut de tout autre type lève une exception, et /V et /I restent exactement comme ils étaient
Schéma du reset typé HotPDF : ResetLoadedFormField se branche sur le type d'objet du /DV hérité, écrivant un objet nom neuf pour une case à cocher, un nouveau tableau de nouvelles chaînes pour un choix multi-sélection, une chaîne qui garde IsHexadecimal pour du texte hexadécimal, une chaîne vide ou /Off quand aucun /DV n'existe, et levant une exception sans toucher à /V ni /I en cas de type qui ne colle pas
Copier plutôt que pointer vers les objets du parent empêche une édition de valeur ultérieure de changer le défaut en silence, et les pushbuttons comme les champs de signature retombent sur l'ancien chemin à chaîne seule

Copier au lieu de référencer est délibéré, et c'est la seule option sûre. Un /V enfant qui pointerait vers le tableau /DV du parent partagerait un objet entre « valeur courante » et « valeur par défaut » ; le prochain SetFormFieldValue sur cet enfant réécrirait en silence le défaut de chaque frère. Quelques octets de plus par champ remis à zéro est un prix bon marché pour garder les deux séparés. Les pushbuttons, qui n'ont pas de valeur (Table 226, bit 17), et les champs de signature restent sur un chemin simple qui écrit une copie chaîne de leur propre /DV

Quand aucun /DV n'existe nulle part dans la chaîne, la méthode écrit une chaîne vide locale, ou /Off pour un champ case à cocher ou radio. Supprimer le /V de l'enfant semblerait plus propre et donnerait la mauvaise réponse. Si le parent porte une valeur courante, retirer la surcharge de l'enfant ramène cette valeur en droite ligne, et le champ « remis à zéro » affiche quelque chose que l'utilisateur n'a jamais saisi

var
  Field: THPDFLoadedFormField;
begin
  // Le parent 'palette' est une list box MultiSelect avec
  // /Opt [(b) (g) (r)] et /DV [(b) (r)] ; l'enfant ne porte que /T et /V
  Field := Pdf.GetFormField('palette.primary');
  try
    try
      Pdf.ResetLoadedFormField(Field.Index);
      // L'enfant a maintenant son propre /V [(b) (r)] et /I [0 2] ;
      // le tableau /DV du parent reste intact
    except
      on E: Exception do
        // Défaut malformé (mauvais type, item de tableau non chaîne) :
        // /V et /I sont restés tels quels
        Writeln('Reset skipped: ', E.Message);
    end;
    // Le getter scalaire ne peut pas représenter un défaut tableau
    Writeln(Pdf.GetLoadedFormFieldDefaultValue(Field.Index)); // vide
  finally
    Field.Free;
  end;
  Pdf.SaveLoadedDocument('survey-reset.pdf');
end;

Un reset d'un seul champ est aussi autre chose que l'action ResetForm du §12.7.5.3, qu'une visionneuse exécute sur un ensemble de champs quand l'utilisateur clique un bouton ; cette action est traitée dans construire des champs et actions AcroForm avec HotPDF. ResetLoadedFormField édite un seul champ chargé, suit sa propre règle pour le cas sans défaut, et marque le champ comme changé pour que le recalcul incrémental le reprenne

Garder /V, /I et /AS d'accord

Un reset n'est correct que si l'index de sélection et l'état d'apparence suivent la valeur, et ResetLoadedFormField finit avec les deux mêmes pas de suivi que SetFormFieldValue. Pour les champs de choix, il retire le /I local de l'enfant, confronte chaque valeur à la moitié export de chaque entrée /Opt, et écrit un nouveau /I trié. Remettre à [(b) (r)] contre les options b, g, r donne /I [0 2]. Le /I hérité du parent, s'il existe, reste intact

Les boutons travaillent depuis le type hérité. Une case à cocher enfant dont le /FT /Btn vit sur le parent reçoit quand même sa mise à jour de /AS. Côté écriture, SetFormFieldValue et SetLoadedFormFieldDefaultValue stockent un objet nom pour un bouton non pushbutton hérité même quand l'enfant n'a aucune entrée locale d'où copier le type. Et quand EnsureLoadedFieldAppearanceStream reconstruit les apparences de boutons, il écrit /AS /Off sauf si la valeur correspond à l'état actif, et donne à chaque flux d'état /Type /XObject, /Subtype /Form et un /BBox. Sans ce contrôle /Off, régénérer les apparences après un reset recocherait la case avant même que le fichier soit sauvegardé

Getters scalaires et autres angles à anticiper

Les getters chaînes restent scalaires, et le code qui compare leur sortie doit savoir ce qu'ils aplatissent. GetFormFieldValue et GetLoadedFormFieldDefaultValue renvoient une chaîne vide pour une valeur tableau, transforment nombres et booléens en 42 ou true, et rapportent une chaîne encodée hex sous son orthographe hexadécimale. Si vous devez savoir si un défaut de list box porte deux sélections ou aucune, le getter de défaut ne peut pas le dire : les deux reviennent comme chaîne vide

Un cycle /Parent termine la recherche sans lever, donc un champ dont le type est perdu dans un cycle rapporte lfftUnknown et des drapeaux à 0. Traitez lfftUnknown sur un champ nommé comme le signe d'un fichier endommagé qui mérite d'être journalisé. SetFormFieldValue et ResetLoadedFormField écrivent toujours l'enfant que vous adressez et ne promeuvent jamais une valeur vers le parent partagé. C'est juste pour des enfants indépendants comme les deux listes déroulantes de l'enquête. Les groupes radio sont différents : la sélection appartient au champ qui possède le groupe, donc adressez ce champ plutôt que l'un de ses kids

Chaque appel engage aussi un champ à son compte. Une boucle de resets sur vingt champs, c'est vingt éditions indépendantes ; si la cinquième lève sur un défaut malformé, les quatre premières sont déjà appliquées. Pour du travail par lots, lisez d'abord le type et le défaut de chaque cible, décidez quels champs vous toucherez, et seulement alors commencez à écrire

La recherche d'attributs hérités, la classification de l'arbre de champs par kids et le reset typé appartiennent tous à l'API de formulaires chargés du HotPDF Delphi Component, qui couvre aussi les projets Delphi et C++Builder devant ajouter de nouveaux champs à un document existant, comme montré dans l'ajout de champs AcroForm à un PDF chargé en Delphi