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