Article technique

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

HotPDF Delphi Component traite /FT, /Ff, /V et /DV sur un champ AcroForm chargé comme des attributs héritables, résolus en remontant la chaîne /Parent. Depuis les v2.754.3 et v2.754.4, un enfant nommé dont le type vient de son parent reste individuellement adressable, RemoveFormField laisse ses frères tranquilles, et ResetLoadedFormField copie le défaut hérité avec son type d'objet PDF d'origine. Avant, un nombre surprenant de formulaires banals étaient mal lus

Le formulaire qui expose tout cela n'a rien d'exotique. Un outil d'auteur construit un nœud groupe group qui porte /FT /Ch, les drapeaux de champ et la liste d'options une seule fois, et suspend dessous deux enfants nommés a et b, chacun un dictionnaire fusionné champ plus widget qui n'a que /T, /Parent, /Rect et son propre /V. C'est une façon parfaitement légale de partager des attributs, et c'est exactement le cas que la section Limits de définir des valeurs de champs de formulaire dans un PDF chargé avec Delphi signalait comme non géré : la réconciliation des boutons ne regardait que le /FT local. Cet article reprend là où celui-là s'est arrêté, avec la classification de l'arbre de champs, la lecture des valeurs héritées et ce qu'un reset d'un seul champ a le droit d'écrire

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

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 fait de même pour le /MaxLen d'un champ texte, donc tout lecteur qui ne regarde que le dictionnaire local rapportera le mauvais type, les mauvais drapeaux et une valeur vide pour un enfant parfaitement valide. HotPDF fait passer toutes ces lectures par un seul résolveur interne, HPDFLoadedInheritedFieldObject, qui vérifie la présence de la clé dans le dictionnaire, résout une référence indirecte s'il en trouve une, et sinon suit /Parent pendant 128 niveaux au plus, parce que des fichiers mal formés peuvent construire des cycles /Parent qui n'ont rien à voir avec /Kids. Les getters publics s'appuient dessus : GetFormFieldType, GetFormFieldValue, GetLoadedFormFieldFlags, IsFormFieldRequired, IsFormFieldNoExport, GetLoadedFormFieldMaxLength, GetLoadedFormFieldDefaultValue et les helpers d'options GetLoadedFormFieldOptionCount et GetLoadedFormFieldOptions, qui ramassent aussi un tableau /Opt stocké sur le parent. Une règle du résolveur est facile à rater : la marche s'arrête au premier dictionnaire qui contient la clé, même si la valeur y est une chaîne vide. Un /V () local est une surcharge délibérée qui masque le parent, pas un trou à combler depuis plus haut dans l'arbre

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
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;

Pourquoi un /FT local est-il le mauvais test pour un champ terminal ?

Parce qu'un parent peut fournir le type et posséder quand même des champs enfants nommés, donc la présence d'un /FT ne dit rien de l'endroit où l'arbre de champs se termine. L'ancien parcours déclarait terminal tout nœud ayant son propre /FT ou pas de /Kids. Dans le formulaire ci-dessus, group a à la fois /FT /Ch et /Kids, donc il était enregistré comme un unique champ nommé group avec deux widgets, et les noms pleinement qualifiés group.a et group.b disparaissaient purement et simplement. GetFormFieldCount renvoyait 1, une recherche par nom d'enfant échouait, et SetFormFieldValue ne pouvait écrire que le parent partagé. Le test de remplacement, HPDFLoadedFieldHasChildFields, regarde les enfants au lieu du parent : un enfant est un champ enfant s'il a son propre /T, a son propre /Kids, ou n'est pas du tout un dictionnaire /Subtype /Widget. Seulement quand aucun enfant ne qualifie, le nœud est terminal, ses enfants étant traités comme ses annotations widget

Les deux cas limites qui ont façonné cette règle viennent tous deux des dictionnaires fusionnés, que §12.7.3.1 autorise quand un champ n'a qu'un seul widget. Un dictionnaire fusionné nommé porte /Subtype /Widget et reste un champ enfant, donc le sous-type à lui seul ne peut pas l'envoyer dans la liste de widgets anonymes du parent ; le /T gagne. L'inverse arrive aussi : certains producteurs répètent le /FT du parent sur chaque widget anonyme, donc le /FT ne peut pas servir de preuve qu'un widget démarre un nouveau champ non plus. La classification est partagée par le cache de relations, FormFieldExists et RemoveFormField, et chacun de ces parcours enregistre désormais les dictionnaires déjà visités et s'arrête au-delà de 128 niveaux. Un fichier de régression dont le groupe se liste deux fois, /Kids [5 0 R 5 0 R 6 0 R 7 0 R], rapporte toujours exactement deux champs au lieu de récurser à l'infini ou de compter deux fois le même nœud

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

RemoveFormField ne supprime plus que l'enfant que vous nommez, parce que la découverte et la suppression s'accordent enfin sur ce qu'est un champ terminal. Cet accord compte plus qu'il n'y paraît. La surcharge par nom résout un index via le cache de relations puis compte les champs terminaux dans un second parcours de /AcroForm /Fields. Une fois le cache corrigé pour voir group.a et group.b, un parcours de suppression non corrigé aurait quand même traité group comme un unique champ terminal, et l'index 0 aurait enlevé le parent avec chaque frère et tous leurs widgets. Le parcours de suppression utilise maintenant le même test HPDFLoadedFieldHasChildFields et le même ensemble de visités, collecte les annotations widget du seul enfant retiré, les retire des /Annots de chaque page, et ne retire le parent que quand son tableau /Kids finit vide. La régression vérifie les trois endroits où une erreur se verrait : les /Kids du parent, les /Annots de la page, et la valeur et l'apparence du frère survivant, après une réécriture complète comme après une mise à jour incrémentale

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');

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');

Que ResetLoadedFormField écrit-il quand le défaut est 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 tout le défaut avant de toucher au champ. Le type d'objet compte parce que les getters scalaires aplatissent tout en texte. 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, et un défaut texte peut être une chaîne UTF-16 hexadécimale ; copier l'un d'eux à travers GetLoadedFormFieldDefaultValue transformerait le nom en chaîne, le tableau en chaîne vide et la chaîne hexadécimale en ses chiffres littéraux. Le reset se branche donc sur le type hérité : les champs texte et choix reçoivent un nouvel objet chaîne qui garde le drapeau IsHexadecimal, les champs de choix avec un défaut tableau reçoivent un nouveau tableau de nouvelles chaînes, et les boutons non pushbutton reçoivent un nouvel objet nom. Copier, plutôt que pointer vers les objets du parent, est délibéré : un /V qui partagerait le tableau /DV du parent ou son numéro d'objet changerait le défaut la prochaine fois que quelqu'un édite la valeur. Un défaut du mauvais type, ou un tableau de choix contenant autre chose que des chaînes, lève une exception et laisse /V et /I exactement comme ils étaient. Les pushbuttons, qui n'ont pas de valeur (Table 226, bit 17), et les champs de signature retombent sur l'ancien chemin à chaîne seule

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

Quand aucun /DV n'existe nulle part dans la chaîne, la méthode garde son contrat d'effacement en écrivant une chaîne vide locale, ou /Off pour un champ case à cocher ou radio. Supprimer le /V local semblerait plus propre et serait faux : le parent peut détenir une valeur courante, et retirer la surcharge de l'enfant ramènerait cette valeur en silence. C'est aussi pourquoi un reset d'un seul champ n'est pas l'action ResetForm du §12.7.5.3, qu'un lecteur exécute sur un ensemble de champs quand l'utilisateur clique un bouton, comme décrit dans construire des champs et actions AcroForm avec HotPDF. ResetLoadedFormField est une opération d'édition sur un seul champ chargé, avec sa propre règle pour le cas sans défaut, et il enregistre le champ via NoteLoadedFormFieldDirty pour que le recalcul incrémental voie le changement

var
  Field: THPDFLoadedFormField;
begin
  Field := Pdf.GetFormField('group.a');
  try
    // Le parent détient /DV [(b) (r)] sur une list box MultiSelect : group.a reçoit
    // son propre /V [(b) (r)] et un /I [0 2] neuf ; le parent reste intact
    Pdf.ResetLoadedFormField(Field.Index);
    // Les getters scalaires ne peuvent pas représenter le défaut tableau
    Writeln(Pdf.GetLoadedFormFieldDefaultValue(Field.Index)); // vide
  finally
    Field.Free;
  end;
  Pdf.SaveLoadedDocument('survey-reset.pdf');
end;

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, donc ResetLoadedFormField se termine avec les deux mêmes réconciliateurs que SetFormFieldValue. HPDFReconcileChoiceSelection accepte maintenant une valeur tableau : il supprime le /I local sans le muter, confronte chaque valeur à la moitié export de chaque entrée /Opt, et écrit un nouveau /I trié, donc un reset vers [(b) (r)] contre les options b, g, r donne /I [0 2]. ReconcileLoadedButtonAppearanceStates demande maintenant le type hérité, si bien qu'une case à cocher enfant dont le /FT /Btn vit sur le parent reçoit enfin son /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 un /Type /XObject, un /Subtype /Form et un /BBox corrects ; avant la v2.754.4, régénérer l'apparence après un reset pouvait recocher la case avant même que le fichier soit sauvegardé

Limites à connaître avant de bâtir là-dessus

Les getters scalaires restent scalaires. GetFormFieldValue et GetLoadedFormFieldDefaultValue renvoient une chaîne vide pour une valeur tableau, sérialisent nombres et booléens en 42 ou true, et rapportent une chaîne encodée en hexadécimal sous son orthographe hexadécimale. Un cycle /Parent termine la marche sans exception, donc un champ dont le type est perdu dans un cycle rapporte lfftUnknown et des drapeaux à 0 plutôt que d'échouer. SetFormFieldValue et ResetLoadedFormField écrivent toujours l'enfant que vous adressez et ne promeuvent jamais une valeur vers le parent partagé, ce qui est juste pour des enfants indépendants mais veut dire que les groupes radio doivent être adressés via le champ qui détient la sélection. Et chaque appel engage un champ à son compte ; rien de tout cela ne rend un lot de resets transactionnel

La résolution des attributs hérités, la classification unifiée de l'arbre de champs et le reset typé décrits ici font partie de l'API de formulaires chargés du HotPDF Delphi Component pour Delphi et C++Builder, aux côtés de la création de champs couverte par ajouter des champs AcroForm à un PDF chargé en Delphi