Article technique

Champs AcroForm d'un PDF chargé : /V, /AS et /I en Delphi

HotPDF Delphi Component remplit un champ AcroForm existant sur un PDF chargé via THotPDF.SetFormFieldValue, adressé soit par index de champ à base zéro, soit par nom de champ pleinement qualifié. Écrire la nouvelle entrée /V est la partie facile ; ce qui rend l'appel fiable sur les formulaires du monde réel, c'est que la même méthode maintient aussi cohérents trois morceaux d'état invisibles jusqu'à ce qu'ils se cassent : l'identité décodée du champ, pour qu'un nom non ASCII puisse être trouvé du tout, l'état d'apparence /AS sur les widgets de case à cocher et de bouton radio, et le tableau d'indices de sélection /I sur les champs de choix. Le flux d'apparence visible est une étape séparée et explicite, via EnsureLoadedFieldAppearanceStream

Le scénario est banal : un client vous envoie son propre formulaire, une déclaration fiscale, une demande d'indemnisation, un bon de commande que quelqu'un a construit dans Acrobat il y a des années, et votre application Delphi doit le remplir depuis une base de données et rendre un fichier qui s'ouvre correctement partout. Vous ne contrôlez pas la façon dont le formulaire a été conçu. Les noms de champ peuvent être encodés en UTF-16, les valeurs d'export des cases à cocher peuvent être 2 plutôt que Yes, et les combos peuvent utiliser des paires d'options [export display]. Chacun de ces détails a une règle dans ISO 32000-1, et chaque règle est désormais prise en charge par SetFormFieldValue pour vous. Cet article décrit ce qu'il fait, pourquoi, et où il s'arrête. Pour le problème voisin de la création de champs qui n'existent pas encore, voyez ajouter des champs AcroForm à un PDF chargé en Delphi

Pourquoi SetFormFieldValue ne trouve-t-il pas un champ au nom non ASCII ?

Avant la v2.752.1, la réponse était l'encodage : le champ vivait dans le fichier sous un nom UTF-16BE hexadécimal, et le cache de noms stockait l'orthographe hexadécimale au lieu du texte. ISO 32000-1 §12.7.3.1 définit le nom de champ partiel /T comme une chaîne de texte, et §7.9.2.2 dit qu'une chaîne de texte peut être de l'UTF-16BE avec un BOM FE FF en tête. Les outils de conception sérialisent couramment ces noms en chaînes hexadécimales selon §7.3.4.3, donc un champ appelé Straße arrive sous la forme <FEFF005300740072006100DF0065>. Dans HotPDF, THPDFStringObject.Value détient le texte hexadécimal brut dès que IsHexadecimal est positionné, ce qui est exactement ce que vous voulez pour un aller-retour sans perte du dictionnaire d'origine et exactement ce que vous ne voulez pas comme clé de recherche. HPDFLoadedFormTextName sépare les deux préoccupations. Quand le cache de relations est construit, chaque valeur /T y passe : si l'objet chaîne est hexadécimal, HPDFHexToBytes restaure la séquence d'octets ; si les octets commencent par FE FF et ont une longueur paire, la charge est décodée en UTF-16BE et réencodée en UTF-8 ; le résultat est ensuite joint à son nom parent avec un point pour former le nom pleinement qualifié que décrit §12.7.3.1, donc un enfant nommé City sous un parent nommé Address est enregistré comme Address.City. La clé de cache est normalisée en minuscules, ce qui fait aussi réussir SetFormFieldValue('address.city', ...) ; c'est une commodité au-delà de la norme, puisque la spécification traite les noms comme sensibles à la casse. Détail crucial, seule la clé de cache change. L'objet /T du dictionnaire de champ garde son encodage hexadécimal, donc enregistrer le document ne réécrit pas l'identité d'un champ que vous avez simplement rempli

Comment HotPDF résout les noms AcroForm non ASCII : HPDFHexToBytes restaure la charge UTF-16BE derrière une chaîne /T hexadécimale, le BOM FE FF est décodé puis réencodé en UTF-8, et le nom qualifié rejoint son parent si bien que Applicant.FullName et un champ nommé Straße atterrissent tous deux dans le cache de recherche
Seule la clé de cache change : le dictionnaire de champ garde son encodage hexadécimal, les recherches normalisent en minuscules comme commodité au-delà de la norme, et enregistrer le document ne réécrit jamais l'identité d'un champ que vous avez simplement rempli
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('claim-form.pdf') <= 0 then Exit;

    // Les noms qualifiés sont décodés depuis les chaînes /T UTF-16BE et
    // joints par des points, donc les noms imbriqués et non ASCII résolvent
    Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');
    Pdf.SetFormFieldValue('Applicant.Straße', 'Hauptstraße 12');

    // Les valeurs hors Latin-1 voyagent en hex UTF-16BE préfixé FEFF
    // et sont écrites comme chaîne hexadécimale PDF
    Pdf.SetFormFieldValue('Applicant.City', 'FEFF004D00FC006E006300680065006E');

    Pdf.SaveLoadedDocument('claim-form-filled.pdf');
  finally
    Pdf.Free;
  end;
end;

Qu'écrit réellement SetFormFieldValue ?

Les deux surcharges exécutent les mêmes cinq étapes : localiser le dictionnaire de champ, écrire /V via HPDFSetDictFormValue, réconcilier les indices de sélection des champs de choix, marquer le dictionnaire sale, réconcilier les états d'apparence des boutons, et enfin enregistrer l'index du champ via NoteLoadedFormFieldDirty. Cette dernière étape compte si le formulaire porte des scripts de calcul, parce que l'ensemble des champs sales est ce que consomme la surcharge sans paramètre de RecalculateLoadedFormFieldsIncremental pour ne relancer que les calculs qui lisent de façon transitive un champ modifié. HPDFSetDictFormValue lui-même fait attention au type d'objet qu'il remplace. Si le /V existant est un objet nom, ce qu'utilisent les cases à cocher et les boutons radio pour leur valeur d'export, la nouvelle valeur est écrite comme un nom, jamais comme une chaîne, parce que les noms PDF sont ASCII par construction. Sinon il écrit un objet chaîne et inspecte la valeur que vous avez passée : une chaîne qui commence par FEFF, a une longueur paire et n'est faite que de chiffres hexadécimaux est traitée comme la forme filaire UTF-16BE de §7.9.2.2 et stockée avec IsHexadecimal positionné, donc elle se sérialise en <FEFF...> plutôt qu'en (FEFF...) littéral. C'est le mécanisme sur lequel s'appuie la ligne City ci-dessus ; toute autre chaîne est stockée comme chaîne littérale avec les octets que vous avez donnés, donc pour du texte latin simple vous passez du texte simple

Pourquoi une case à cocher garde-t-elle son ancienne coche après le changement de valeur ?

Parce que pour un champ bouton, la valeur seule ne décide pas de ce qui est dessiné. ISO 32000-1 §12.7.4.2.3 spécifie qu'un widget de case à cocher porte un état d'apparence /AS nommant le flux de /AP /N actuellement affiché, et les visionneuses peignent depuis /AS, pas depuis /V. Si vous changez /V en Yes mais laissez /AS à Off, le fichier est contradictoire en interne, et l'aplatissement enfournera volontiers l'apparence décochée périmée dans la page pendant que les données de formulaire disent cochée. ReconcileLoadedButtonAppearanceStates existe pour combler cet écart : pour un champ dont le /FT est Btn, il visite le dictionnaire de champ lui-même et chaque entrée de son tableau /Kids, lit le nom d'état actif dans /AP /N, et réécrit /AS avec ce nom quand il correspond à la valeur du champ, ou avec Off quand il ne correspond pas

Pourquoi une case à cocher HotPDF garde son ancienne coche quand seul /V change : les visionneuses peignent depuis l'état d'apparence /AS dans /AP /N, donc ReconcileLoadedButtonAppearanceStates visite le champ et chaque enfant, lit le nom d'état actif comme première clé autre que Off, et réécrit /AS en cas de correspondance ou à Off sinon
Les groupes radio comparent chaque enfant à la valeur du parent que InheritedButtonValue retrouve en remontant la chaîne /Parent, donc régler le groupe sur une valeur d'export active exactement ce widget et désactive tous ses frères

Deux détails de formulaires réels ont façonné le correctif de la v2.752.3. D'abord, un dictionnaire d'apparence normal a le droit de ne contenir que l'état actif ; §12.7.4.2.3 nomme l'apparence inactive Off mais les outils de conception omettent fréquemment son flux et laissent la visionneuse ne rien dessiner. Le code antérieur abandonnait quand le dictionnaire contenait moins de deux entrées, donc ces cases à cocher à état unique gardaient silencieusement leur ancienne coche. Le contrôle se limite désormais à vérifier que le dictionnaire est non vide, et le nom d'état actif est pris comme la première clé qui n'est pas Off. Ensuite, le nom d'état actif est celui qu'a choisi l'auteur. Les vrais formulaires utilisent 2, Yes, On ou un mot localisé, donc la comparaison se fait contre la clé réelle, sans tenir compte de la casse, jamais contre un Yes codé en dur. Les boutons radio ajoutent une complication de plus, décrite en §12.7.4.2.4 : la sélection vit dans /V sur le champ parent, tandis que les enfants individuels possèdent les widgets et n'ont généralement pas de /V propre. Le helper imbriqué InheritedButtonValue remonte donc la chaîne /Parent, jusqu'à 64 niveaux, jusqu'à trouver une valeur non vide, pour que chaque enfant soit comparé à la valeur du groupe auquel il appartient. Régler le parent sur la valeur d'export d'un enfant active exactement cet enfant et désactive tous ses frères

// Checkbox : la valeur d'export doit correspondre à la clé d'état actif dans /AP /N
// (souvent 'Yes', mais les vrais formulaires utilisent '2', 'On' ou autre chose)
Pdf.SetFormFieldValue('Consent', 'Yes');

// Groupe radio : /V est écrit sur le parent ; chaque widget enfant reçoit
// /AS réglé sur son propre nom d'export ou sur Off
Pdf.SetFormFieldValue('PaymentMethod', 'Card');

// Effacer une checkbox : toute valeur sans état actif correspondant donne /AS Off
Pdf.SetFormFieldValue('Newsletter', 'Off');

Champs de choix : garder /I en phase avec /V

Pour une combo box ou une list box, /V n'est pas le seul endroit où une sélection est enregistrée. La Table 231 de §12.7.4.4 définit /I comme un tableau d'indices à base zéro dans /Opt qui identifie les éléments sélectionnés, et une visionneuse qui trouve /I pointant sur l'option 0 alors que /V nomme l'option 3 peut surligner la mauvaise ligne. Depuis la v2.754.1, HPDFReconcileChoiceSelection s'exécute dans chaque appel à SetFormFieldValue et, quand le /FT hérité est Ch, reconstruit /I à partir de la nouvelle valeur. L'ordre des opérations est délibéré. L'entrée locale /I est d'abord supprimée, sans toucher à son contenu : si l'ancien tableau était un objet indirect partagé avec un autre champ, le muter en place corromprait la sélection de l'autre champ, donc la routine abandonne la référence et crée un tableau direct tout neuf. Elle résout ensuite /Opt à travers la chaîne /Parent, puisque les options de choix peuvent être héritées, et parcourt les entrées. Une option sous forme de chaîne nue est comparée directement ; une paire [export display] est comparée sur son élément d'export, et une paire de moins de deux éléments est sautée. Les deux côtés passent par HPDFLoadedFormTextName, donc une option UTF-16 hexadécimale correspond à une valeur UTF-16 hexadécimale sans que vous ayez à les écrire à l'identique. À la première correspondance, un /I à un élément est écrit et le parcours s'arrête ; une valeur scalaire remplace toujours une éventuelle multi-sélection précédente, quel que soit le drapeau MultiSelect

Comment HotPDF garde un champ de choix cohérent : HPDFReconcileChoiceSelection supprime le tableau /I local avant d'y toucher, résout /Opt à travers la chaîne /Parent, compare la moitié export de chaque option via HPDFLoadedFormTextName, écrit un /I à un élément à la première correspondance et n'écrit rien quand une valeur de combo éditable n'a pas d'indice
Une option en chaîne nue est comparée directement et une paire export display sur son élément export, tandis qu'une valeur hors /Opt ne laisse correctement aucun indice — un /I périmé pointant sur la mauvaise ligne serait pire que rien

Quand rien ne correspond, aucun /I n'est écrit du tout. C'est le bon résultat pour une combo box éditable, où §12.7.4.4 autorise l'utilisateur à taper une valeur hors de la liste d'options ; une telle valeur n'a pas d'indice, et un indice périmé serait pire que rien. C'est aussi ce que vous obtenez si vous passez un libellé d'affichage au lieu d'une valeur d'export à une liste d'options appariée, donc quand une combo box refuse d'afficher votre sélection, vérifiez quelle moitié de la paire vous avez fournie

// /Opt vaut [[US United States] [CA Canada] [MX Mexico]] :
// la correspondance se fait sur la valeur d'export, et /I devient [1]
Pdf.SetFormFieldValue('Country', 'CA');

// Combo éditable avec une valeur hors /Opt : /V est écrit,
// /I est retiré, et aucun index n'est fabriqué
Pdf.SetFormFieldValue('Title', 'Principal Engineer');

Valeur et apparence sont deux opérations distinctes

SetFormFieldValue ne touche jamais au flux d'apparence d'un champ texte ou de choix. Après l'appel, /V contient le nouveau texte pendant que /AP /N affiche encore l'ancien, et celle des deux qu'une visionneuse montre dépend de la présence de /NeedAppearances true dans le dictionnaire AcroForm selon §12.7.3.3 et du fait que la visionneuse l'honore ou non. Si vous avez besoin que le fichier rende la nouvelle valeur dans tous les lecteurs, y compris les aplatisseurs et les générateurs de vignettes qui ignorent le drapeau, appelez EnsureLoadedFieldAppearanceStream avec l'index du champ. Il construit un Form XObject à partir de la chaîne /DA héritée, du quadding /Q, de la disposition en peigne /MaxLen et de la valeur, résout la police nommée à travers les ressources /DR de l'AcroForm pour qu'une police Type0 garde sa propre police descendante au lieu de dégénérer en Helvetica, et renvoie True quand au moins un widget a reçu un flux. La surcharge par nom de SetFormFieldValue ne vous rend aucun index, donc récupérez-en un via GetFormField, qui renvoie un THPDFLoadedFormField qui vous appartient et que vous devez libérer. La suite de régression du changement v2.752.1 est explicite sur ce découpage : elle définit une valeur, appelle EnsureLoadedFieldAppearanceStream, puis rend la page et vérifie que les pixels à l'intérieur du rectangle du widget ont changé pendant que ceux à l'extérieur n'ont pas changé. Vérifier que /V a changé ne prouve rien sur ce qu'un utilisateur verra

var
  Field: THPDFLoadedFormField;
begin
  Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');
  Field := Pdf.GetFormField('Applicant.FullName');
  try
    // Peindre la nouvelle valeur dans /AP pour que les visionneuses
    // qui ignorent /NeedAppearances l'affichent quand même
    if not Pdf.EnsureLoadedFieldAppearanceStream(Field.Index) then
      raise Exception.Create('No widget rectangle to paint into');
  finally
    Field.Free;
  end;
  Pdf.SaveLoadedDocument('claim-form-filled.pdf');
end;

Des limites à connaître avant de construire là-dessus

ReconcileLoadedButtonAppearanceStates teste le /FT local du dictionnaire que vous avez adressé, donc il agit sur le parent radio ou sur une case à cocher qui porte son propre /FT ; un widget enfant adressé seul, avec le /FT uniquement sur son parent, n'est pas réconcilié par ce chemin. HPDFReconcileChoiceSelection traite une valeur scalaire unique et écrit au plus un indice ; les list boxes à sélection multiple avec plusieurs entrées choisies sortent de ce que modélise SetFormFieldValue. Aucune des deux routines ne valide la valeur que vous passez contre /Opt ou contre les clés d'état actif, donc une faute de frappe produit une case à cocher à Off ou une combo sans indice plutôt qu'une exception. Et GetFormFieldValue renvoie le texte /V stocké tel qu'il se trouve dans le dictionnaire, ce qui pour une valeur encodée en hexadécimal signifie l'orthographe hexadécimale, pas le texte décodé

Une fois les valeurs posées et les apparences peintes, les deux suites naturelles se situent de part et d'autre de cette opération. Échanger des données de formulaire en masse avec des systèmes externes, plutôt qu'un appel SetFormFieldValue à la fois, est ce que couvre l'import et l'export XFDF en Delphi. Et quand le formulaire rempli est définitif et ne doit plus être éditable, l'aplatissement des champs AcroForm et XFA en Delphi enfourne exactement les états /AS et les flux d'apparence décrits ici dans le contenu statique de la page, et c'est pourquoi les rendre cohérents avant l'aplatissement n'est pas optionnel

L'API d'édition de formulaires chargés de cet article, y compris SetFormFieldValue, EnsureLoadedFieldAppearanceStream et le graphe de recalcul incrémental, est livrée avec le HotPDF Delphi Component pour Delphi et C++Builder