Article technique

Pourquoi les valeurs de champs PDF reviennent en arrière

Une valeur de champ de formulaire PDF « revient en arrière » quand le champ est stocké comme dictionnaire direct : PDFium promeut la copie dans le tableau /Annots de la page en objet indirect, tandis que /Fields garde une seconde copie directe. PDFium Component pour Delphi gère les écritures de champs typées en unifiant les deux références en un seul propriétaire indirect avant d'écrire

Le symptôme a d'ordinaire des airs de maison hantée. L'écriture renvoie True, et la lecture suivante très exactement rend l'ancienne valeur. Sauvegardez, rouvrez, et l'ancienne valeur est toujours là. Rien n'a levé, et le PDF a l'air parfaitement ordinaire dans chaque viewer que vous possédez

Le symptôme : une écriture réussie qui ne tient pas

Le schéma d'échec est étroit mais très reproductible : il ne surgit que sur des formulaires dont les dictionnaires de champs sont écrits inline (comme objets directs) plutôt que comme objets indirects numérotés. La plupart des outils d'auteurs émettent des champs indirects, donc le problème se cache pendant des mois puis apparaît sur des fichiers d'un générateur particulier, souvent un exporteur artisanal qui écrit tout le /AcroForm inline

Deux observations ensemble pointent la cause : l'écriture rapporte un succès, et une lecture native du champ à travers la couche de formulaire renvoie la valeur précédente. Quand vous voyez les deux, arrêtez de soupçonner votre encodage de chaînes et commencez à regarder la possession d'objets

Pourquoi une valeur de champ de formulaire PDF revient-elle après une écriture réussie ?

Une valeur de champ de formulaire PDF revient en arrière parce que deux dictionnaires séparés prétendent tous deux être le même champ, et l'écriture a mis à jour la copie que l'arbre de champs ne consulte pas. La valeur d'un champ vit dans son entrée /V, et l'ISO 32000-1 §12.7.3.1 définit où cette entrée fait autorité : sur le dictionnaire de champ atteint depuis /AcroForm /Fields, en descendant par les /Kids vers le champ terminal, avec les entrées héritables résolues depuis /Parent

La même clause autorise une commodité qui cause tout le trouble. Quand un champ a exactement une annotation widget, le dictionnaire de champ et le dictionnaire d'annotation widget peuvent être fusionnés en un seul dictionnaire. Dans un fichier bien formé, ce dictionnaire fusionné est un objet indirect, référencé depuis deux endroits : depuis /Fields (ou les /Kids d'un parent) et depuis le tableau /Annots de la page. Deux références, un objet, un /V

Deux copies d'un même dictionnaire

L'ISO 32000-1 §7.3.10 est explicite : seul un objet indirect possède une identité partageable. Un dictionnaire direct n'a pas de numéro d'objet, donc quand un générateur écrit le dictionnaire champ/widget fusionné inline à la fois dans /Fields et dans /Annots, le fichier ne contient pas un objet référencé deux fois. Il contient deux dictionnaires d'aspect identique qui ont par hasard les mêmes clés et valeurs

PDFium tolère ça, et ce faisant rend la scission permanente. Quand il a besoin d'un handle vers l'annotation, PDFium peut promouvoir le dictionnaire widget direct du tableau /Annots de la page en objet indirect, mais la copie directe équivalente reste où elle était, dans /AcroForm /Fields (ou dans les /Kids du parent). À partir de ce moment, le document détient deux propriétaires pour un champ logique. Un writer qui édite le /V de l'annotation de page obtient un succès authentique au niveau du dictionnaire. L'arbre de champs, que la couche de formulaire et tout lecteur ultérieur traitent comme la source de vérité, ne voit jamais le changement

Voilà pourquoi la valeur périmée survit à une sauvegarde et réouverture : la nouvelle valeur a été sérialisée fidèlement dans un objet que la hiérarchie de champs ne possède pas

PDFium Component en Delphi : deux dictionnaires directs revendiquent un champ, l'écriture touche l'annotation promue et l'arbre de champs lit encore la vieille copie /Fields
l'écriture s'est sérialisée fidèlement dans un objet que la hiérarchie de champs ne possède pas, donc la valeur périmée survit à la sauvegarde et à la réouverture

Comment PDFium Component restaure un propriétaire de champ unique

PDFium Component normalise la possession des champs directs avant chaque écriture typée : il promeut les dictionnaires /AcroForm et de champs directs en objets indirects, lie chaque widget direct à son annotation de page correspondante, et réécrit /Fields, /Kids, /Parent et /Annots pour que chaque référence pointe le même objet indirect. Ce n'est qu'alors qu'il change le /V, pour que la valeur du champ, le widget sélectionné et le document sauvegardé partagent un seul propriétaire

La partie dure est l'identité. Deux dictionnaires directs de contenu identique ne prouvent pas qu'ils sont le même widget ; un formulaire peut légitimement contenir deux widgets qui se ressemblent. Le normalisateur refuse donc de deviner, et exige un nom d'annotation /NM explicite et unique (ISO 32000-1 §12.5.2) sur chaque widget direct. Les règles appliquées par le chemin d'écriture sont strictes :

  • Un widget direct exige un /NM non vide d'au plus 1024 unités UTF-16, et exactement une annotation de page peut porter le même nom
  • La copie directe et l'annotation de page doivent se sérialiser à l'identique sauf /Parent et le /AP régénéré par PDFium ; toute autre différence refuse l'écriture
  • Les deux liens parents sont vérifiés séparément, pour qu'un /Parent correct sur l'annotation de page ne puisse pas masquer un conflictuel sur la copie directe d'origine
  • Un /Parent manquant est fourni seulement quand une possession unique de l'arbre de champs rend la réponse non ambiguë, et les copies parentes directes équivalentes sont re-liées à leur propriétaire promu
  • Les noms de champs complets en doublon, les arbres de champs cycliques ou à possession multiple et les champs de signature se refusent avant que quoi que ce soit ne soit publié
  • Les annotations sans rapport, comme les liens sur la même page, et les entrées /AcroForm sans rapport sont conservées intactes

Le normalisateur tourne aussi sous budgets durs : 4096 visites d'arbre de champs, pages ou membres de tableau, profondeur d'imbrication 64, 256 nouveaux objets indirects, 1 MiB par dictionnaire promu, 4 MiB d'octets promus au total, 128 MiB d'octets de dictionnaires inspectés, et 4096 unités UTF-16 par nom de champ complet avec 262144 sur tout le formulaire. Un fichier hostile ou cassé bute sur un budget et échoue avec un message d'erreur au lieu de transformer un remplissage en déni de service

Toute l'opération est transactionnelle. TPdf.SetFormFieldValues capture un instantané, construit une mise à jour incrémentale contenant les objets normalisés et les nouvelles valeurs, charge ce candidat, et ne remplace le document vivant qu'une fois le candidat vérifié. Si la normalisation refuse le fichier, ou qu'une requête ultérieure d'un lot échoue, le document d'origine, ses valeurs et son curseur logique de champs restent exactement tels quels

Comment PDFium Component pour Delphi unifie la possession des champs AcroForm directs : instantané, contrôle d'identité NM, unification des références, écriture de valeur et publication vérifiée avec chemins de refus explicites
les dictionnaires de champs directs sont promus et re-liés à un seul propriétaire indirect avant que le /V ne change, et une possession ambiguë refuse proprement

Écrire et vérifier une valeur de champ direct en Delphi

Le schéma sûr est écrire, puis relire à travers la couche de formulaire native, et traiter une discordance comme un échec même quand l'écriture a renvoyé True. TPdf.SetFormFieldValue adresse un champ par index sur la page courante, prend une TPdfFormValue typée et remplit un TPdfFormValueWriteReport ; TPdf.FormField[Index] relit la valeur à travers la couche de formulaire de PDFium, qui résout l'arbre de champs au lieu de faire confiance au widget

Schéma écrire et vérifier pour les champs de formulaire PDF en Delphi avec PDFium Component : SetFormFieldValue typé, relecture via FormField, puis contrôles de sauvegarde et réouverture
un retour True prouve seul que le dictionnaire a changé — relisez à travers l'arbre de champs et testez une sauvegarde et réouverture
uses
  System.SysUtils, PDFium;

procedure WriteTextField(Pdf: TPdf; PageNo, FieldIndex: Integer;
  const NewText: WString);
var
  Value: TPdfFormValue;
  Report: TPdfFormValueWriteReport;
begin
  Pdf.PageNumber := PageNo;          // SetFormFieldValue vise la page courante
  Value := Default(TPdfFormValue);
  Value.Kind := fvkText;
  Value.Text := NewText;

  if not Pdf.SetFormFieldValue(FieldIndex, Value, Report) then
    raise Exception.CreateFmt('Field %d on page %d rejected: %s',
      [FieldIndex, PageNo, Report.ErrorMessage]);

  // Vérifiez contre l'arbre de champs, pas contre notre propre variable
  if Pdf.FormField[FieldIndex] <> NewText then
    raise Exception.CreateFmt('Field "%s" did not keep the new value',
      [Pdf.FormFieldInfo[FieldIndex].Name]);
end;

Sur un fichier à possession insoluble, par exemple un widget direct sans /NM, SetFormFieldValue renvoie False, Report.UpdatedWidgets vaut zéro et Report.ErrorMessage nomme le problème, comme « Direct widgets require an explicit unique NM identity ». C'est le comportement voulu : un refus clair au lieu d'une demi-écriture silencieuse. Une précondition de plus vaut d'être connue. L'écriture typée est annotation-only et n'acquiert pas de focus natif, donc si un éditeur de champ natif est encore focusé, toute la transaction se refuse avant que l'instantané soit pris ; appelez Pdf.ClearFormFieldFocus délibérément d'abord plutôt que de retenter en boucle

Pour plusieurs champs, surtout entre pages, utilisez la forme par lot. TPdf.SetFormFieldValues accepte de 1 à 256 requêtes, les applique toutes à un seul candidat, et publie tout ou rien. TPdfFormValuesWriteReport.FailedRequest vous dit quelle requête a cassé le lot

procedure FillInvoiceHeader(Pdf: TPdf);
var
  Requests: TPdfFormValueRequests;
  Batch: TPdfFormValuesWriteReport;
begin
  SetLength(Requests, 2);
  Requests[0] := Default(TPdfFormValueRequest);
  Requests[0].Page := 1;
  Requests[0].FieldIndex := 0;
  Requests[0].Value.Kind := fvkText;
  Requests[0].Value.Text := 'ACME Tooling Inc.';

  Requests[1] := Default(TPdfFormValueRequest);
  Requests[1].Page := 2;
  Requests[1].FieldIndex := 0;
  Requests[1].Value.Kind := fvkText;
  Requests[1].Value.Text := '2026-10-27';

  if not Pdf.SetFormFieldValues(Requests, Batch) then
    raise Exception.CreateFmt('Request %d failed: %s',
      [Batch.FailedRequest, Batch.ErrorMessage]);
  // Batch.UpdatedFields = 2 ; Batch.UpdatedWidgets compte chaque widget synchronisé
end;

Deux requêtes adressant le même champ terminal sont rejetées comme propriétaire en doublon au lieu de laisser la dernière écriture gagner, et le document source est préservé. Les valeurs case à cocher et radio suivent les mêmes règles ; le découpage /V face à /AS pour les boutons est couvert dans valeur de champ case à cocher face à état de widget dans PDFium

Prouver que la valeur survit à la sauvegarde et à la réouverture

Une relecture dans la même session prouve que le document vivant est cohérent. Une sauvegarde et réouverture prouve que le fichier l'est, et le contrôle est assez bon marché pour rester dans vos propres tests d'intégration

procedure SaveAndReopenCheck(Pdf: TPdf; const Target: string;
  PageNo, FieldIndex: Integer; const Expected: WString);
var
  Reopened: TPdf;
begin
  if not Pdf.SaveAs(Target, saNoIncremental, pvUnknown) then
    raise Exception.Create('Save failed');

  Reopened := TPdf.Create(nil);
  try
    Reopened.FileName := Target;
    Reopened.Active := True;
    Reopened.PageNumber := PageNo;
    if Reopened.FormField[FieldIndex] <> Expected then
      raise Exception.Create('Saved file lost the field value');
  finally
    Reopened.Free;
  end;
end;

Deux autres pièges au niveau octet : Trim et le /ID du trailer

Normaliser les champs directs signifie relire des octets de dictionnaires bruts, ce qui fait remonter deux pièges plus petits, pertinents pour tout code Delphi qui touche la syntaxe PDF

Le premier est SysUtils.Trim sur des octets PDF bruts portés par un AnsiString. Une chaîne literal comme une option de choix peut légitimement contenir du texte UTF-16BE avec un marqueur d'ordre des octets FE FF et des octets NUL embarqués. En Delphi Unicode, SysUtils.Trim prend un string, donc l'appel convertit silencieusement l'AnsiString en UTF-16 avec la page de code ANSI active et reconvertit le résultat en sens inverse. Selon cette page de code, l'aller-retour peut réécrire les octets hauts, abîmer le BOM et même déranger le délimiteur qui suit. L'approche sûre garde le lexer sur les octets : sautez les blancs PDF par position (l'ISO 32000-1 §7.2.2 liste NUL, tab, LF, FF, CR et espace) et tranchez avec Copy, en ne décodant qu'une fois la valeur avérée être une chaîne de texte PDF

// Risqué sur des octets PDF bruts : AnsiString -> string -> AnsiString (W1057/W1058)
Token := Trim(Token);

// Sûr au niveau octet : sauter les blancs PDF par position, puis trancher
P := 1;
while (P <= Length(Token)) and (Token[P] in [#0, #9, #10, #12, #13, ' ']) do
  Inc(P);
Token := Copy(Token, P, MaxInt);

Le second piège se cache dans la mise à jour incrémentale elle-même. L'ISO 32000-1 §7.5.5 (table 15) et §14.4 exigent que le /ID du trailer soit un tableau de deux chaînes d'octets. Le helper interne qui compose l'identifiant de mise à jour éparse ne renvoie que le contenu des deux chaînes, sans la clé /ID ni les crochets du tableau, et le trailer doit l'envelopper explicitement en /ID [ ... ]. Le loader laxiste de PDFium, comme une resauvegarde complète ultérieure, cachent tous les deux un wrapper manquant, donc le contrôle fiable est de parser les fichiers candidat et sauvegardé avec un parseur strict indépendant et de chercher deux chaînes de 16 octets. Si vous écrivez des mises à jour incrémentales vous-même, le même piège s'applique ; l'article sur les mises à jour incrémentales pour PDF chiffrés couvre la chaîne du trailer plus en profondeur

Que faire dans votre propre code de formulaires Delphi ?

  • Relisez toujours la valeur avec FormField[Index] après une écriture, et dans les tests sauvegardez aussi, rouvrez et relisez
  • Traitez False plus ErrorMessage comme de l'information : un refus nommant NM, un conflit de parent ou un nom en doublon signifie que la possession du fichier est réellement ambiguë
  • Si vous générez des PDF, émettez champs et widgets comme objets indirects et donnez à chaque widget un /NM unique ; cela supprime l'ambiguïté à la source
  • Utilisez SetFormFieldValues pour les remplissages multi-champs ou entre pages, pour qu'un échec tardif ne puisse pas laisser la moitié du formulaire mise à jour
  • Ne lancez jamais Trim ou d'autres helpers RTL string sur des octets PDF bruts portés par un AnsiString

Soyez clair sur les frontières aussi. Documents signés, formulaires XFA, instantanés chiffrés et fichiers dont la possession des widgets ne peut pas être résolue de façon unique restent en dehors du profil déclaré et se refusent au lieu d'être forcés. Si votre workflow déplace des champs entre documents, les questions de possession se corsent encore ; le greffage de champs AcroForm entre documents PDF traite ce cas

Le PDFium Component apporte cette écriture de formulaires typée et transactionnelle aux applications Delphi, C++Builder et Lazarus, aux côtés du rendu, de l'extraction de texte et de l'édition de documents. Historique des versions, licences et téléchargements sont sur la page produit du PDFium Component pour Delphi