Article technique

Greffer des champs AcroForm entre PDF en Delphi

Transférer un bloc de champs de formulaire du gabarit de l’année dernière vers la mise en page de cette année, c’est là que les allers-retours FDF et XFDF ne suffisent plus : les valeurs arrivent, mais les flux d’apparence, les actions de calcul et les ressources par défaut n’arrivent pas. PDFiumPas répond à ce cas avec GraftPdfAcroForm, qui clone le graphe d’objets entier des champs hors d’un PDF pour l’écrire dans un autre

La raison pour laquelle un export au niveau des données ne peut pas faire cela est structurelle. Un champ n’est pas un enregistrement, c’est un sous-graphe. L’ISO 32000-1 §12.7 définit le dictionnaire de formulaire interactif qui contient /Fields, /CO, /DR et /DA, le §12.7.3 définit les dictionnaires de champs suspendus au-dessous, et le §12.5.6.19 définit les annotations de widget qui donnent à ces champs une boîte visible sur une page. XFDF transporte les feuilles de cette structure. Le greffage transporte la structure elle-même

Pourquoi copier le tableau /Fields ne suffit jamais

Copier /Fields d’un document vers un autre produit un formulaire cassé de toutes les manières intéressantes, parce que le tableau ne contient que des références indirectes. L’ISO 32000-1 §7.3.10 rend un objet indirect adressable par numéro d’objet plus génération, et ces numéros ne sont significatifs qu’à l’intérieur du fichier d’où ils viennent. Collez le tableau ailleurs et chaque référence qu’il contient soit se met à flotter, soit, pire, se résout silencieusement vers un objet sans rapport qui occupe par hasard cet emplacement dans la destination. Sous chaque référence se trouve un graphe à la fois partagé et cyclique. Un dictionnaire de champ pointe vers ses enfants, chaque enfant pointe en retour vers son /Parent, un widget pointe vers ses flux d’apparence et vers la page qui le porte via /P, les flux d’apparence pointent vers des polices du dictionnaire de ressources par défaut du formulaire, et les dictionnaires d’action supplémentaires sous /AA pointent vers encore d’autres objets. Deux widgets sur des pages différentes partagent couramment une police et un XObject d’apparence. Un greffage correct doit donc parcourir ce graphe, cloner chaque objet atteignable exactement une fois, rediriger le /P de chaque widget vers la page de destination mappée, et ajouter le widget cloné au tableau /Annots de cette page — sinon le champ existe dans le formulaire et reste invisible sur la page. Si vous avez déjà traqué la différence entre un champ, son widget et l’annotation de page qui l’affiche, notre note sur l’index de widget contre l’index d’annotation couvre exactement cette séparation

Le graphe d’objets derrière un champ de formulaire PDF tel que PDFiumPas le greffe en Delphi : le dictionnaire de formulaire, le champ, les annotations de widget, les tableaux d’annotations de la page de destination et le flux d’apparence et la police que les deux widgets partagent, plus la référence retour au parent qui ferme le cycle
Un champ est un sous-graphe partagé et cyclique, voilà pourquoi copier le tableau /Fields entre documents laisse chaque référence flotter

De quoi GraftPdfAcroForm a-t-il besoin de votre part ?

Il a besoin de trois flux distincts et d’un mappage de pages explicite. GraftPdfAcroForm prend Source, Destination et Output comme instances TStream séparées, un tableau TPdfGraftPageMappings, un enregistrement TPdfAcroFormGraftOptions, un TPdfCrossDocumentGraftMap optionnel, et un TPdfAcroFormGraftReport en paramètre de sortie. Il renvoie Boolean au lieu de lever, et en cas d’échec le rapport porte la raison dans ErrorMessage. Le mappage de pages est en base un des deux côtés et n’est pas déduit : chaque page source qui porte un widget que vous comptez greffer doit y figurer. Passer nil pour la carte de greffe est légitime — la fonction crée et libère alors une carte privée pour la durée de l’appel — et TPdfAcroFormGraftOptions.Default vous donne CollisionPolicy réglé sur pagcpReject, RenamePrefix réglé sur Imported_, MaxObjects à 100000, MaxDepth à 128 et AllowSignedDestination réglé sur False. Ces trois derniers sont des budgets, et ils existent parce que le graphe d’objets que vous vous apprêtez à parcourir provient d’un fichier que vous n’avez pas écrit

uses
  Classes, SysUtils, FPdfCompress;

var
  Source, Destination, Output: TMemoryStream;
  Options: TPdfAcroFormGraftOptions;
  Mappings: TPdfGraftPageMappings;
  Report: TPdfAcroFormGraftReport;
begin
  Source := TMemoryStream.Create;
  Destination := TMemoryStream.Create;
  Output := TMemoryStream.Create;
  try
    Source.LoadFromFile('claim-template-2025.pdf');
    Destination.LoadFromFile('claim-layout-2026.pdf');
    Source.Position := 0;
    Destination.Position := 0;

    Options := TPdfAcroFormGraftOptions.Default;

    SetLength(Mappings, 2);
    Mappings[0].SourcePageNumber := 1;
    Mappings[0].DestinationPageNumber := 1;
    Mappings[1].SourcePageNumber := 2;
    Mappings[1].DestinationPageNumber := 3;

    if GraftPdfAcroForm(Source, Destination, Output, Mappings,
      Options, nil, Report) then
      Output.SaveToFile('claim-2026-with-fields.pdf')
    else
      raise Exception.Create(Report.ErrorMessage);
  finally
    Output.Free;
    Destination.Free;
    Source.Free;
  end;
end;

Comment la carte de greffe évite-t-elle de cloner deux fois une police partagée ?

TPdfCrossDocumentGraftMap détient une table de correspondance source vers destination dont les clés portent à la fois numéro d’objet et génération, et le cloner récursif la consulte avant de descendre. L’ordre des opérations est ce qui rend les cycles sûrs : le cloner alloue le numéro d’objet de destination et enregistre le mappage d’abord, puis parcourt les références enfants de l’objet source. Un parent qui atteint un enfant pointant en retour vers lui trouve le parent déjà enregistré et renvoie la référence de destination existante au lieu de récurser. La même consultation fait qu’une police, un flux d’apparence ou une action partagés par six widgets sont clonés une fois et référencés six fois. La carte est liée au document source par un hachage SHA-256 des octets source, exposé comme SourceIdentity. Si vous remettez à GraftPdfAcroForm une carte dont l’identité ne correspond pas à la source passée, il refuse l’appel au lieu de réutiliser des références qui n’ont jamais été valables pour ce fichier. Les mappages de pages sont semés dans la même carte avant que le clonage ne commence, et c’est précisément ainsi que le /P d’un widget finit par pointer vers la page de destination : l’objet page source se résout déjà vers l’objet page de destination mappé, donc la passe ordinaire de réécriture des références le traite sans cas particulier

La carte de greffe inter-documents de PDFiumPas en Delphi clé chaque référence source par numéro d’objet et génération, enregistre le mappage de destination avant de descendre afin qu’une référence retour au parent se termine, et renvoie l’entrée existante pour qu’une police partagée ne soit clonée qu’une fois
Enregistrer le mappage avant de parcourir les enfants est ce qui rend un graphe cyclique sûr et un objet partagé cloné exactement une fois
uses
  Classes, SysUtils, FPdfCompress, FPdfSha256;

var
  GraftMap: TPdfCrossDocumentGraftMap;
  SourceBytes: TBytes;
  EntriesBefore: Integer;
begin
  SetLength(SourceBytes, Source.Size);
  Source.Position := 0;
  if Length(SourceBytes) > 0 then
    Source.ReadBuffer(SourceBytes[0], Length(SourceBytes));

  GraftMap := TPdfCrossDocumentGraftMap.Create(
    AnsiString(SHA256Hex(SHA256Bytes(SourceBytes))));
  try
    EntriesBefore := GraftMap.Count;
    Source.Position := 0;
    if not GraftPdfAcroForm(Source, Destination, Output, Mappings,
      Options, GraftMap, Report) then
    begin
      // Entries added by this call have been rolled back;
      // anything registered before it is still intact.
      Assert(GraftMap.Count = EntriesBefore);
      WriteLn('graft refused: ', Report.ErrorMessage);
    end;
  finally
    GraftMap.Free;
  end;
end;

Ce retour arrière est tout l’intérêt de posséder la carte vous-même. PDFiumPas traite une carte fournie par l’appelant de façon transactionnelle : un greffage échoué rejette les entrées que cet appel a ajoutées et conserve chaque mappage préexistant, si bien qu’un refus ne laisse jamais derrière lui un cache de références vers des objets qui n’ont jamais été écrits. Gardez une carte par document de destination toutefois — le côté destination de chaque entrée est un numéro d’objet dans ce fichier précis, et il ne signifie rien dans un autre

Collisions de noms de champs : rejeter ou renommer

Les noms de champs entièrement qualifiés doivent rester uniques à l’intérieur d’un formulaire, et PDFiumPas ne devinera pas ce que vous vouliez dire quand ils entrent en collision. TPdfAcroFormCollisionPolicy propose exactement deux réponses. Sous pagcpReject, la valeur par défaut, le premier champ source dont le titre existe déjà dans la destination avorte tout le greffage avec une erreur et laisse le flux de sortie vide. Sous pagcpRename, le champ source en collision est renommé par préfixation de RenamePrefix et le greffage continue, avec Report.RenamedFieldCount qui vous dit combien de fois cela s’est produit

Options := TPdfAcroFormGraftOptions.Default;
Options.CollisionPolicy := pagcpRename;
Options.RenamePrefix := 'Y2025_';
Options.MaxObjects := 20000;
Options.MaxDepth := 64;

if GraftPdfAcroForm(Source, Destination, Output, Mappings,
  Options, nil, Report) then
begin
  WriteLn('source fields  : ', Report.SourceFieldCount);
  WriteLn('existing fields: ', Report.DestinationFieldCount);
  WriteLn('grafted fields : ', Report.GraftedFieldCount);
  WriteLn('renamed fields : ', Report.RenamedFieldCount);
  WriteLn('cloned objects : ', Report.GraftedObjectCount);
  WriteLn('reused objects : ', Report.ReusedObjectCount);
  WriteLn('mapped pages   : ', Report.MappedPageCount);
  WriteLn('output bytes   : ', Report.OutputByteCount);
end
else
  WriteLn('graft refused  : ', Report.ErrorMessage);

Renommer n’est pas gratuit, et vous devriez décider délibérément plutôt que d’y recourir pour faire disparaître une erreur. Un champ renommé est un autre champ : tout JavaScript dans la destination qui l’adresse par son nom, toute entrée de calcul dans /CO écrite par un humain contre l’ancien nom, et tout consommateur en aval qui s’appuie sur le nom du champ devront connaître le préfixe. Si les deux documents décrivent vraiment le même champ, la correction honnête consiste d’ordinaire à réconcilier les noms en amont, pas au moment du greffage. Une fois le greffage posé, parcourir le formulaire fusionné pour confirmer ce que vous avez réellement obtenu est l’étape naturelle suivante, et la navigation dans les champs de formulaire de PDFiumPas couvre ce parcours

Là où le greffage refuse délibérément plutôt que d’approximer

Chaque condition ambiguë est une erreur, jamais un résultat au mieux, et c’est une décision de conception qu’il vaut la peine de comprendre avant qu’elle ne vous surprenne en production. GraftPdfAcroForm renvoie False, réinitialise le flux de sortie et signale la raison quand il rencontre l’une de ces situations

  • Le formulaire source porte une entrée /XFA — les paquets XFA sont un modèle de formulaire parallèle qui ne peut pas être réduit à des dictionnaires de champs AcroForm
  • Un widget vit sur une page source qui n’a aucune entrée dans le mappage de pages, ce qui laisserait autrement tomber le champ en silence ou l’attacher à la mauvaise page
  • Les mappages de pages sont hors limites, ou deux mappages réutilisent la même page source ou destination
  • Les deux formulaires définissent un dictionnaire de ressources par défaut /DR, car fusionner deux espaces de noms de ressources risquerait de repointer un nom existant vers une police différente
  • Le graphe d’objets dépasse MaxObjects ou la récursion dépasse MaxDepth
  • La destination contient une signature et AllowSignedDestination est False
  • La carte de greffe fournie appartient à un autre document source, ou une référence source flotte

Le chemin d’écriture est tout aussi conservateur. PDFiumPas émet le résultat comme une révision incrémentielle éparse ajoutée à la destination, puis matérialise à nouveau la sortie écrite et relit son formulaire : si le nombre de champs du résultat n’égal pas le nombre de champs d’origine de la destination plus celui de la source, tout le greffage est rejeté et la sortie est effacée. Vous n’obtenez jamais un fichier partiellement greffé. Le coût de cette politique est réel — une collision /DR ou une destination signée vous arrête net, et vous devez le résoudre vous-même au lieu d’accepter une approximation fusionnée — mais l’alternative est un formulaire qui s’ouvre bien et calcule faux

Comment PDFiumPas GraftPdfAcroForm refuse en mode fail closed en Delphi : la révision écrite est relue et son nombre de champs vérifié, toute condition ambiguë telle que XFA ou une page non mappée refuse l’appel, et un refus rejette uniquement les entrées de carte que cet appel a ajoutées
Le chemin d’écriture vérifié et la carte transactionnelle font qu’un greffage refusé ne laisse jamais derrière lui un fichier partiellement fusionné

Quand le greffage est le mauvais outil

Le greffage déplace de la structure, alors utilisez-le quand la structure est ce qui vous manque. Si les deux documents portent déjà le même ensemble de champs et que vous avez seulement besoin de déplacer des valeurs et des annotations entre eux, le chemin d’export et d’import de l’article sur les données de formulaire XFDF est plus léger, standard et réversible. Tournez-vous vers GraftPdfAcroForm quand la destination n’a aucun champ, ou en a un ensemble différent, et que vous avez besoin que les widgets, les flux d’apparence, les actions et l’ordre de calcul passent intacts. Une dernière note pratique sur l’identité : comme la carte de greffe se clé sur numéro d’objet plus génération et est liée à un SHA-256 des octets source, re-enregistrer ou optimiser la source entre deux exécutions produit une identité différente et une carte qui ne s’applique plus. Prenez un instantané de la source depuis laquelle vous greffez et gardez-la stable pour le lot ; traitez-la comme un artefact d’entrée, pas comme quelque chose qu’un travail nocturne peut réécrire librement

GraftPdfAcroForm, TPdfCrossDocumentGraftMap et la boîte à outils PDF au niveau des flux qui les entoure sont livrés avec le PDFiumPas Delphi PDFium Component pour Delphi, C++Builder et Lazarus, où la page produit porte la référence API complète des options de greffe, des champs de rapport et du reste de la surface d’édition de documents