Article technique

Modifier les métadonnées PDF d’un document chargé dans Delphi sans réécriture

Vous avez dix mille PDF de contrats provenant d'une douzaine de générateurs différents, et le service juridique veut que chacun porte le bon Author, un Producer corrigé, et un mode d'ouverture qui affiche le panneau des signets au lancement. La solution naïve consiste à charger chaque fichier, à recomposer les pages et à écrire un document neuf. Faites cela, et vous jetez tous les numéros d'objet existants, l'historique de mise à jour incrémentielle, toute signature numérique et la xref soigneusement réglée que l'outil d'origine a produite. Les pages paraissent identiques, mais structurellement le fichier est un autre. Pour une modification de métadonnées, c'est le mauvais compromis

La bonne approche consiste à traiter le document chargé comme un graphe d'objets que vous modifiez en place : allez dans le dictionnaire Info, le /Metadata stream, et le Catalog, changez les quelques entrées qui vous intéressent, puis écrivez le résultat. HotPDF, le composant PDF VCL natif pour Delphi et C++Builder, expose exactement cette surface via son API d'écriture sur document chargé. Cet article explique comment l'utiliser correctement, et rappelle l'erreur que presque tout le monde commet : modifier le dictionnaire Info en oubliant qu'une seconde copie des mêmes métadonnées vit dans XMP

Deux emplacements stockent les mêmes métadonnées, et ils ne sont pas d'accord

Le PDF stocke les informations du document à deux endroits parallèles, et c'est la cause de la plupart des tickets du type « j'ai changé le titre, mais Acrobat affiche encore l'ancien ». Le premier est le dictionnaire d'information du document, l'objet classique /Info avec /Title, /Author, /Subject, /Keywords, /Creator, et /Producer clés, défini dans ISO 32000-1 §14.3.3. Le second est un paquet XMP, un document XML stocké comme un flux rattaché au Catalog sous /Metadata, défini dans §14.3.2 et bâti sur le modèle de données XMP d'Adobe

Les deux peuvent contenir un titre. Rien dans la spécification ne les oblige à coïncider. Les visionneuses modernes et la plupart des validateurs PDF/A préfèrent le paquet XMP lorsqu'il est présent et retombent sur le dictionnaire Info lorsqu'il ne l'est pas. Donc si vous ne mettez à jour que /Info - ce que fait la grande majorité du code de « définition des métadonnées PDF » - un lecteur qui fait confiance à XMP continuera d'afficher la valeur périmée, et un vérificateur PDF/A signalera l'écart. L'opération correcte sur tout fichier qui possède déjà un paquet XMP est une double écriture : modifier l'entrée Info et régénérer XMP, afin que les deux restent cohérents. HotPDF vous fournit les deux moitiés ; la discipline qui consiste à les utiliser ensemble vous revient

Modifier le dictionnaire Info

Les utilitaires côté Info sont simples et prévisibles. SetLoadedTitle, SetLoadedAuthor, SetLoadedSubject, SetLoadedKeywords, SetLoadedCreator, et SetLoadedProducer prennent chacun une seule valeur de type AnsiString et écrivent la clé correspondante dans le dictionnaire Info chargé, en remplaçant la valeur si la clé existe et en l'ajoutant sinon. Pour supprimer complètement une clé - par exemple un /Creator qui révèle le nom de vos outils internes - appelez RemoveLoadedInfoKey avec le nom brut de la clé. Rien de tout cela ne touche XMP ; ces appels opèrent uniquement sur l'objet /Info que LoadFromFile a localisé lors de l'analyse du fichier

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('contract-in.pdf', '') > 0 then
    begin
      Pdf.SetLoadedTitle('Master Services Agreement 2026');
      Pdf.SetLoadedAuthor('Legal Department');
      Pdf.SetLoadedSubject('Executed contract, retention 7 years');
      Pdf.SetLoadedKeywords('contract; MSA; 2026; executed');
      Pdf.SetLoadedProducer('Acme Document Pipeline');
      Pdf.RemoveLoadedInfoKey('Creator');  // drop the originating tool name
      Pdf.SaveLoadedDocument('contract-out.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

Un détail à respecter : ces méthodes prennent AnsiString. Pour des titres ASCII, cela ne pose pas de problème, mais les chaînes de texte PDF qui doivent contenir des caractères non latins doivent être encodées comme l'exige la spécification - UTF-16BE avec marque d'ordre des octets, ou PDFDocEncoding - avant de les transmettre. La bibliothèque écrit dans un objet chaîne les octets que vous lui donnez ; elle ne devine pas l'encodage pour vous. Si vos titres sont en anglais simple, ignorez ce point. S'ils contiennent des caractères accentués ou CJK, encodez-les volontairement et testez dans une vraie visionneuse

Réécrire le paquet XMP

SetLoadedXMPMetadata est l'autre moitié de la double écriture. Passez-lui le paquet XMP complet sous forme de AnsiString et il agit de deux façons : si le Catalog référence déjà un flux /Metadata existant, il remplace le contenu de ce flux en place, en conservant le même numéro d'objet ; s'il n'y a pas de flux de métadonnées, il en crée un, le marque /Type /Metadata et /Subtype /XML, lui attribue un numéro d'objet et le lie depuis le Catalog. Dans tous les cas, vous obtenez un objet de métadonnées valide que les visionneuses liront

Vous fournissez le XML, ce qui veut dire que vous contrôlez le schéma - dc:title, dc:creator, xmp:CreatorTool et ainsi de suite. C'est à la fois un pouvoir et une responsabilité : la bibliothèque ne parse pas et ne valide pas votre paquet, et elle écrit les octets sans compression, sans appliquer de filtre de flux. Un paquet mal formé passera l'appel sans incident et réapparaîtra plus tard sous forme de plainte pour métadonnées cassées. Construisez le XML avec soin et recopiez exactement les valeurs que vous avez écrites dans le dictionnaire Info, afin que les deux vues ne se contredisent jamais

const
  XMP_TEMPLATE =
    '<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>' +
    '<x:xmpmeta xmlns:x="adobe:ns:meta/">' +
    '<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">' +
    '<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/">' +
    '<dc:title><rdf:Alt><rdf:li xml:lang="x-default">%s</rdf:li></rdf:Alt></dc:title>' +
    '<dc:creator><rdf:Seq><rdf:li>%s</rdf:li></rdf:Seq></dc:creator>' +
    '</rdf:Description></rdf:RDF></x:xmpmeta><?xpacket end="w"?>';
begin
  // After setting the Info dictionary, mirror the same values into XMP:
  Pdf.SetLoadedTitle('Master Services Agreement 2026');
  Pdf.SetLoadedAuthor('Legal Department');
  Pdf.SetLoadedXMPMetadata(
    AnsiString(Format(XMP_TEMPLATE,
      ['Master Services Agreement 2026', 'Legal Department'])));
  Pdf.SaveLoadedDocument('contract-out.pdf');
end;

Cet ordre - Info d'abord, XMP ensuite, puis enregistrement - est le schéma à retenir. Les deux appels sont indépendants ; la cohérence n'existe que parce que vous leur avez transmis les mêmes chaînes. Omettre l'appel XMP sur un fichier qui contient un paquet XMP vous ramène au bogue de staleness silencieuse que cette section veut justement empêcher

Diagram showing a PDF Info dictionary and an XMP metadata stream both holding title and author, edited in place alongside the bookmark outline tree
Les métadonnées vivent à deux endroits - le dictionnaire Info et le flux XMP - plus les indications de lecture au niveau du Catalog et l'arborescence des signets. Une modification en place touche chacun de ces éléments sans reconstruire le document.

Piloter l'ouverture du lecteur

Trois entrées du Catalog décident de ce que voit un lecteur dès l'ouverture du document, et les trois se modifient en une ligne sur le graphe chargé. SetLoadedPageMode écrit /PageMode sous forme d'objet nom : passez 'UseOutlines' pour ouvrir le panneau des signets, 'UseThumbs' pour la bande des vignettes, 'FullScreen' pour le mode présentation, ou 'UseAttachments' pour afficher le volet des pièces jointes (ISO 32000-1 §7.7.3.1, Tableau 28). SetLoadedPageLayout écrit /PageLayout de la même façon - 'SinglePage', 'OneColumn', 'TwoColumnLeft', et le reste. Dans les deux cas, on passe le nom sans barre oblique initiale ; la bibliothèque l'ajoute à la sortie

SetLoadedLanguage écrit l'entrée /Lang du Catalog, l'étiquette de langue naturelle de l'ensemble du document - 'en-US', 'de-DE', une balise BCP 47. Notez la différence de type qui piège beaucoup de gens : /PageMode et /PageLayout sont des objets PDF de type name , alors que /Lang est une string. HotPDF gère cela correctement en interne, mais si vous inspectez la sortie vous verrez /PageMode /UseOutlines face à /Lang (en-US), et vous comprenez maintenant pourquoi. L'entrée /Lang compte plus qu'il n'y paraît : c'est elle que les technologies d'assistance lisent pour choisir la prononciation, et c'est une exigence stricte pour la conformité d'accessibilité PDF/UA

if Pdf.LoadFromFile('handbook.pdf', '') > 0 then
begin
  Pdf.SetLoadedPageMode('UseOutlines');     // /PageMode, a name
  Pdf.SetLoadedPageLayout('TwoColumnLeft'); // /PageLayout, a name
  Pdf.SetLoadedLanguage('en-US');           // /Lang, a string
  Pdf.SaveLoadedDocument('handbook-tagged.pdf');
end;

Renommer les signets sans perturber l'arborescence

Les titres des signets relèvent du nettoyage courant - une faute de frappe dans un en-tête, un chapitre renuméroté après la création de l'outline. SetLoadedOutlineTitle prend un index de base zéro dans les entrées de premier niveau de l'outline et un nouveau titre, parcourt la chaîne Catalog → /Outlines/First/Next jusqu'à cette position, puis remplace la /Title de l'entrée. Il ne change que le titre ; la destination, l'état ouvert/fermé et la structure des enfants restent intacts

if Pdf.LoadFromFile('report.pdf', '') > 0 then
begin
  Pdf.SetLoadedOutlineTitle(0, 'Executive Summary');
  Pdf.SetLoadedOutlineTitle(1, 'Financial Results');
  Pdf.SaveLoadedDocument('report-renamed.pdf');
end;

Le renommage est sûr précisément parce qu'il ne touche jamais aux compteurs structurels. La suppression d'une entrée d'outline est le cas qui piège, et il vaut la peine de le comprendre même lorsque vous ne faites que renommer, car cela vous dit ce qu'il ne faut pas modifier à la main. Chaque nœud d'outline porte un pas à la main. Chaque nœud d'outline porte un /Count, et - selon ISO 32000-1 §12.3.3 - ce compte n'est pas le nombre d'enfants immédiats. C'est le nombre total de descendants visibles: un /Count positif de N signifie que N descendants sont actuellement affichés, tandis qu'une valeur négative signifie que le nœud a des descendants mais qu'il est replié. Quand on supprime une entrée de premier niveau, le compte racine de /Outlines ne peut pas simplement être décrémenté de un ; il faut le recalculer en sommant, pour chaque nœud de premier niveau restant, « un pour le nœud lui-même plus son /Count positif », tout en ignorant les descendants de tout nœud replié (compte négatif). Si vous vous trompez, le total des signets affiché par un lecteur dérive - il saute de plus d'une unité par suppression. Le renommage évite tout cela, ce qui constitue une raison supplémentaire de préférer l'utilitaire ciblé plutôt que de bricoler le dictionnaire vous-même

Comment l'enregistrement reste sur place

Toutes les modifications ci-dessus mutent des objets en mémoire ; rien n'écrit sur disque avant l'exécution de SaveLoadedDocument. La raison pour laquelle cette approche est légère est que l'enregistrement ne régénère pas le document - il conserve les numéros d'objet existants et la structure que HotPDF a analysée au chargement, puis réécrit le même graphe avec votre poignée d'objets modifiés et nouvellement alloués. C'est ce qui évite qu'un passage de métadonnées réécrive tout le fichier, et c'est le même mécanisme de mise à jour en place qui permet à object streams and incremental updates de fonctionner. Si vos fichiers sources viennent de Word ou d'une autre suite bureautique, leur disposition d'objets a aussi ses particularités, qu'il vaut la peine de connaître avant de les modifier ; l'article sur hybrid-reference cross-reference streams in Office PDFs décrit la structure de ces fichiers et ce qui survit à un aller-retour

Deux limites à respecter. Premièrement, il s'agit d'un modèle de modification en place, pas d'un outil de censure ou d'assainissement : supprimer une clé Info supprime cette clé, mais n'efface pas les valeurs plus anciennes qui pourraient persister dans une génération de mise à jour incrémentielle antérieure du même fichier. Si votre besoin est une vraie suppression de métadonnées sensibles, c'est une opération différente et plus lourde. Deuxièmement, l'écriture XMP est littérale - la bibliothèque fait confiance à votre XML et ne le valide pas - donc pour tout ce qui est destiné à PDF/A ou à un validateur strict, générez le paquet à partir d'un modèle éprouvé et vérifiez la sortie. Utilisée dans ce cadre, la modification de métadonnées en place est l'outil à la bonne taille : elle corrige les quelques octets faux et laisse exactement tels quels les quatre-vingt-dix-neuf pour cent du fichier que le producteur d'origine avait déjà écrits correctement

L'API d'écriture sur document chargé présentée ici est fournie avec le HotPDF Component standard pour Delphi et C++Builder, ainsi qu'avec l'ensemble complet des méthodes de modification des métadonnées, des outlines et du Catalog