Les cases à cocher et les boutons radio s'aplatissent comme décochés parce que l'état d'apparence /AS n'était jamais synchronisé avec la valeur de champ /V. PDFium Component, le composant VCL et LCL basé sur PDFium pour Delphi, C++Builder et Lazarus, lit désormais cette valeur avec FPDFAnnot_GetFormFieldValue, qui résout le dictionnaire de champ parent plutôt que l'annotation widget
Le rapport de bogue qui a mené ici est du genre que l'on se méfie d'abord. Un client aplatit un formulaire de consentement signé, ouvre le résultat, et chaque case à cocher est vide. Ouvrez le fichier source dans Acrobat et les cases sont visiblement cochées. Relisez le fichier source via le même composant et les valeurs de champ sont correctes. Seule la sortie aplatie les perd, et seulement pour les cases à cocher et les boutons radio : les champs texte de la même page ressortent corrects
Pourquoi les cases à cocher sont-elles décochées après aplatissement ?
Parce que l'aplatissement ne regarde jamais /V. FPDFPage_Flatten cuit le flux d'apparence du widget dans le contenu de la page, et l'apparence qu'il choisit est celle nommée par /AS. Si /AS dit encore /Off alors que la valeur de champ dit que la case est cochée, l'aplatissement cuit fidèlement l'apparence décochée. La valeur n'a jamais été perdue ; elle n'a jamais été consultée
ISO 32000-1 §12.5.5 définit le dictionnaire d'apparence /AP avec trois entrées possibles, /N, /R et /D. Pour une case à cocher ou un bouton radio, l'entrée /N n'est pas un flux mais un sous-dictionnaire dont les clés sont des noms d'état d'apparence, et §12.5.2 fait de /AS le sélecteur requis quand /N est un sous-dictionnaire. Une case à cocher porte donc deux apparences préconstruites et un pointeur. Se tromper sur le pointeur rend le rendu faux d'une façon qu'aucune quantité de /V correct ne réparera. C'est aussi pourquoi le mode d'échec diffère des champs texte, qui n'ont aucune apparence préconstruite à sélectionner du tout : un /N de champ texte est un flux unique qui doit être régénéré depuis zéro après changement de valeur, si bien que GenerateFormAppearances traite les deux cas via des chemins de code entièrement séparés, et seul le chemin des boutons était cassé
Où vit réellement la valeur de la case à cocher ?
Sur le dictionnaire de champ, pas sur le widget. ISO 32000-1 §12.7.5.2 décrit les cases à cocher et les boutons radio comme des champs bouton dont /V est un objet nom désignant l'état d'apparence courant, et §12.7.3.1 place /V parmi les entrées communes à tous les dictionnaires de champ. L'annotation widget définie au §12.5.6.19 contribue /AS et /AP. Rien dans la spécification n'oblige un widget à porter /V
// Wrong: reads the widget annotation dictionary directly
buflen := FPDFAnnot_GetStringValue(Annot, 'V', nil, 0);
// For most real forms buflen comes back as 2 (an empty UTF-16 string),
// so /AS is never written and the box flattens as Off
{ What the two objects look like when the field has several widgets:
12 0 obj % field dictionary (the parent)
<< /FT /Btn /T (Consent) /V /On
/Kids [ 13 0 R 14 0 R ] >>
endobj
13 0 obj % widget annotation (a kid)
<< /Type /Annot /Subtype /Widget /Parent 12 0 R
/AS /Off
/AP << /N << /On 20 0 R /Off 21 0 R >> >> >>
endobj }
FPDFAnnot_GetStringValue n'est pas défectueuse. Son contrat est exactement ce que son nom indique : récupérer une entrée de chaîne du dictionnaire d'annotation qu'on lui a transmis. Lui demander /V sur l'objet 13 ne retourne rien parce que l'objet 13 n'a réellement pas de /V. Le défaut était dans l'appelant, qui supposait un modèle d'objet plat qu'ISO 32000-1 n'a jamais promis
Quand le champ et le widget partagent-ils un seul dictionnaire ?
Chaque fois qu'un champ a exactement un widget. §12.5.6.19 permet au dictionnaire de champ et à son unique annotation widget de fusionner en un seul objet, et la plupart des outils de création prennent ce raccourci. Dans un objet fusionné, /FT, /T, /V, /AS et /AP siègent tous côte à côte, si bien qu'une lecture de /V au niveau widget réussit et que tout le bogue reste invisible
Dès qu'un champ possède deux widgets ou plus, la fusion devient impossible, et §12.7.3.1 exige que les widgets deviennent des /Kids d'un dictionnaire de champ séparé. Chaque groupe de boutons radio a cette forme par construction. De même pour les cases de consentement répétées dans un en-tête et un pied de page, et tout champ qu'un outil de création a copié vers une seconde page. C'est toute l'explication de pourquoi le défaut a survécu à une suite de non-régression : le corpus de tests était plein de formulaires à widget unique, et les fichiers du client ne l'étaient pas. Si vous parcourez vous-même les widgets plutôt que de vous fier au composant, la même asymétrie apparaît dans l'ordre d'énumération, et les notes sur la navigation des champs de formulaire PDF avec PDFium Component couvrent comment un parcours d'annotation au niveau page se rapporte à l'arbre de champs au niveau document
Lire la valeur comme PDFium l'entend
FPDFAnnot_GetFormFieldValue est l'API correcte, et elle était liée dans le composant depuis un certain temps sans que le chemin des cases à cocher ne l'utilise. Elle prend le handle de formulaire en plus de l'annotation, ce qui est le signal important : avec l'environnement de remplissage de formulaire disponible, PDFium résout l'annotation vers son contrôle de formulaire et lit la valeur depuis l'objet champ, si bien qu'elle retourne la bonne réponse aussi bien pour les dispositions fusionnées que scindées
FPDF_FORMFIELD_CHECKBOX, FPDF_FORMFIELD_RADIOBUTTON:
begin
// /AP is prebuilt per state; only /AS has to be synchronised with /V.
// FPDFAnnot_GetFormFieldValue resolves the parent field dictionary,
// which is where ISO 32000-1 12.7.5.2 keeps the value.
buflen := FPDFAnnot_GetFormFieldValue(FFormHandle, Annot, nil, 0);
if buflen >= 4 then
begin
SetLength(OrigVal, buflen div 2 - 1);
FPDFAnnot_GetFormFieldValue(FFormHandle, Annot, PWideChar(OrigVal), buflen);
FPDFAnnot_SetStringValue(Annot, 'AS', Pointer(OrigVal));
end;
end;
Deux détails dans cet extrait sont faciles à mal comprendre. La longueur retournée est un nombre d'octets pour du texte UTF-16 incluant le terminateur, si bien que le nombre de caractères est buflen div 2 - 1, et une valeur de 2 signifie une chaîne vide. La garde buflen >= 4 signifie donc au moins un vrai caractère, ce qui empêche qu'un champ sans aucun /V ne voie son /AS écrasé par un nom vide
Sur quoi /AS et /AP /N s'accordent-ils réellement ?
Ils s'accordent sur un nom, et le nom est choisi par quiconque a produit le fichier. §12.7.5.2 exige que l'état décoché s'appelle /Off, et laisse l'état coché entièrement au producteur. /Yes est une convention, pas une règle. Acrobat écrit /Yes, mais de nombreux générateurs écrivent /On, /1, /Choice1, ou un mot localisé, et un groupe de boutons radio donne normalement à chaque enfant un nom d'état coché distinct afin que le groupe puisse exprimer quel bouton est sélectionné. C'est précisément pourquoi copier /V verbatim dans /AS est l'opération correcte plutôt qu'un bricolage : pour un contrôle coché, PDFium rapporte le nom d'état coché que le fichier lui-même définit, et pour un contrôle décoché il rapporte Off, si bien que la valeur que vous écrivez dans /AS est garantie d'être une clé qui existe dans le sous-dictionnaire /AP /N de ce widget. Coder en dur /Yes fonctionnerait sur une sortie Acrobat et casserait silencieusement partout ailleurs
Ordre des opérations, et où il faut encore faire attention
La séquence est fixe et sans pitié : activer le remplissage de formulaire, assigner les valeurs, régénérer les apparences, aplatir, puis enregistrer. Sautez l'étape de régénération et FPDFPage_Flatten trouve des flux d'apparence vides ou périmés et les cuit sans se plaindre, ce qui est une perte de données silencieuse plutôt qu'un code d'erreur retourné
Pdf.FileName := FormPath;
Pdf.FormFill := True; // required: FormHandle must exist
Pdf.Active := True;
Pdf.FormField[0] := 'On'; // writes /V only
Pdf.GenerateFormAppearances; // syncs /AS for buttons, rebuilds /AP for text
if Pdf.FlattenAllPages(FLAT_PRINT) then
Pdf.SaveAs('consent-flat.pdf');
Deux limites honnêtes demeurent. Premièrement, la synchronisation écrit la valeur de champ dans le /AS de chaque widget de ce champ, ce qui est correct pour les cases à cocher mais approximatif pour les groupes de boutons radio dont chaque enfant définit son propre nom d'état coché ; un enfant dont le /AP /N n'a aucune entrée correspondant au /AS écrit n'a aucune apparence à sélectionner selon §12.5.5, si bien qu'un bouton non sélectionné peut s'aplatir en rien plutôt qu'en un cercle vide. Auditer un groupe de boutons radio avec FPDFAnnot_GetFormControlIndex avant l'aplatissement vaut les quelques lignes que cela coûte. Deuxièmement, rien de tout cela ne s'applique à XFA, où la valeur vit dans un paquet de données XML plutôt que dans les dictionnaires AcroForm, une séparation couverte dans les notes sur les modifications de champ XFA qui ne sont pas persistées. La leçon générale mérite d'être retenue au-delà de cette seule correction : chaque fois qu'une API prend le handle de formulaire en plus de l'annotation, elle vous indique qu'elle résoudra la hiérarchie de champs pour vous, et chaque fois qu'elle ne prend que l'annotation, elle lira exactement l'objet que vous lui avez transmis. Cette distinction régit aussi l'échange de données, puisque l'export et l'import de données de formulaire XFDF fonctionnent avec des noms de champ pleinement qualifiés, jamais avec des positions de widget
L'aplatissement de formulaire est l'une de ces fonctionnalités qui ressemblent à un simple appel d'API et qui se révèlent être un contrat entre trois dictionnaires. Si vous préférez travailler avec un composant qui encode déjà ce contrat, le PDFium Component pour Delphi et C++Builder livre la régénération d'apparence, l'aplatissement et l'accès aux champs de formulaire décrits ici sous forme de propriétés et méthodes ordinaires