Article technique

Détails des métadonnées, des contours et des annotations PDF

Le même PDF peut déclarer deux auteurs différents. La boîte de propriétés d'un visualiseur de bureau montre un nom, un système de gestion d'actifs qui indexe le XMP en montre un autre, et les deux programmes lisent le fichier correctement. Le PDF stocke les métadonnées du document deux fois, et il garde les signets, liens et notes dans des structures qui ne touchent jamais le contenu des pages, si bien que n'importe lequel peut être faux pendant que chaque page se rend parfaitement

Le modèle objet sous-jacent (objets indirects, table de références croisées, trailer) est couvert dans la visite guidée de la structure des documents PDF. Ici l'accent reste sur ce qui pend au catalog du document

Quatre entrées du catalog, quatre sous-systèmes indépendants

Tout ce dont il est question ci-dessous se rejoint depuis le catalog du document, le dictionnaire vers lequel l'entrée /Root du trailer pointe (ISO 32000-1 §7.7.2). Un catalog qui câble tout cela ressemble à ceci :

1 0 obj
<< /Type /Catalog
   /Pages 2 0 R
   /Outlines 3 0 R
   /Names << /EmbeddedFiles 4 0 R /Dests 6 0 R >>
   /Metadata 5 0 R
>>
endobj
Schéma PDF du catalog d'un document PDF reliant l'arbre de pages, la racine des contours, le dictionnaire de noms et le flux de métadonnées XMP comme quatre sous-systèmes optionnels
Le catalog du document câble quatre sous-systèmes indépendants, chacun atteignable par sa propre entrée. N'importe lequel peut être absent pendant que les pages se rendent normalement

/Pages est le document visible, et le seul des quatre que le catalog exige. /Outlines mène à l'arbre des signets, /Metadata au flux XMP, et /Names au dictionnaire de noms, qui porte les fichiers embarqués sous /EmbeddedFiles et les destinations nommées sous /Dests. Un lecteur qui ne trouve aucun des trois optionnels affiche quand même chaque page, voilà pourquoi cette couche se dégrade en premier quand un fichier passe par des outils qui ne connaissent que les pages. Rien à l'intérieur d'un objet page ne pointe en retour vers les contours, l'arbre /Dests ou le flux XMP. Copiez les seules pages dans un nouveau fichier et les trois restent derrière

Pourquoi un PDF porte-t-il deux magasins de métadonnées ?

Un PDF porte deux magasins de métadonnées parce que le format en a fait pousser un second sans retirer le premier. Le mécanisme d'origine est le dictionnaire d'informations du document (ISO 32000-1 §14.3.3, Table 317), référencé par /Info dans le trailer : des entrées texte plates pour /Title, /Author, /Subject, /Keywords, /Creator, /Producer, plus /CreationDate, /ModDate et /Trapped. ISO 32000-2 déprécie tout le dictionnaire sauf les deux entrées de dates et renvoie les writers vers le flux de métadonnées XMP à la place

Schéma PDF comparant le dictionnaire Info PDF et le flux de métadonnées XMP, deux magasins portant des champs qui se chevauchent et qui doivent rester synchronisés avec le XMP comme source de vérité
Le dictionnaire Info et le flux XMP répondent aux mêmes questions en parallèle. Gardez-les synchronisés en régénérant les deux depuis un seul jeu de valeurs

XMP (ISO 32000-1 §14.3.2) est un document XML écrit en RDF, stocké dans un flux dont le dictionnaire porte /Type /Metadata /Subtype /XML. À la différence du dictionnaire Info, un paquet XMP est conçu pour être trouvé et analysé par des outils qui ne savent rien de la syntaxe PDF :

5 0 obj
<< /Type /Metadata /Subtype /XML /Length 1235 >>
stream
<?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/"
        xmlns:xmp="http://ns.adobe.com/xap/1.0/"
        xmlns:pdf="http://ns.adobe.com/pdf/1.3/">
      <dc:title><rdf:Alt><rdf:li xml:lang="x-default">Quarterly Report</rdf:li></rdf:Alt></dc:title>
      <dc:creator><rdf:Seq><rdf:li>A. Author</rdf:li></rdf:Seq></dc:creator>
      <xmp:CreateDate>2026-06-16T10:46:27+08:00</xmp:CreateDate>
      <xmp:CreatorTool>Reporting Service 4.2</xmp:CreatorTool>
      <pdf:Producer>losLab PDF Library</pdf:Producer>
    </rdf:Description>
  </rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>
endstream
endobj

Trois détails de ce bloc décident si le paquet survit à l'outillage réel. Les instructions de traitement xpacket encadrent le paquet pour qu'un scanner puisse le localiser dans un flux d'octets arbitraire ; perdez le <?xpacket end="w"?> fermant et le fichier s'ouvre encore, mais les scanners perdent leur marque de fin. Les types de valeurs comptent aussi : dc:title est une alternative de langue et demande rdf:Alt, tandis que dc:creator est une liste ordonnée et demande rdf:Seq. Écrire l'un ou l'autre en nœud texte nu est une erreur facile. Les préfixes, eux, ne sont que conventionnels ; un analyseur se cale sur l'URI d'espace de noms, donc un dc: lié à la mauvaise URI est une propriété tout à fait différente

Les clés Info et leurs équivalents XMP s'apparient comme suit. PDF/A-1 (ISO 19005-1 §6.7.3) énonce l'appariement comme règle de conformité : quand les deux magasins sont présents, les valeurs doivent être équivalentes

Dictionnaire InfoPropriété XMP
/Titledc:title (rdf:Alt)
/Authordc:creator (rdf:Seq)
/Subjectdc:description (rdf:Alt)
/Keywordspdf:Keywords
/Creatorxmp:CreatorTool
/Producerpdf:Producer
/CreationDate, /ModDatexmp:CreateDate, xmp:ModifyDate

Les éditions à la main et les fusions sont l'endroit où les deux dérivent. Une fusion peut hériter le /Info d'une entrée et le paquet XMP d'une autre. La règle plus sûre est de traiter le XMP comme source de vérité, de garder le /Info pour les consommateurs plus anciens, et de régénérer les deux depuis un seul jeu de valeurs chaque fois que l'un change

Écrire les deux magasins depuis un seul jeu de propriétés

HotPDF construit les deux magasins depuis les mêmes propriétés du document. Title, Author, Subject et Keywords vont dans /Info ; quand EnableXMPMetadata vaut True, EndDoc écrit aussi un paquet XMP depuis ces mêmes valeurs, avec dc:title en rdf:Alt et dc:creator en rdf:Seq, et le lie depuis le /Metadata du catalog. EnableXMPMetadata vaut False par défaut, et cela vaut la peine de l'activer pour tout ce qui quitte vos mains : un fichier avec seulement /Info est invisible à tout outil qui ne lit que le XMP. CustomXMP remplace le paquet généré par votre propre texte quand vous avez besoin de schémas supplémentaires

uses
  System.SysUtils, Vcl.Graphics, HPDFDoc;

procedure BuildReport(const FileName: string);
var
  PDF: THotPDF;
  Chapter: THPDFDocOutlineObject;
begin
  PDF := THotPDF.Create(nil);
  try
    PDF.AutoLaunch := False;
    PDF.FileName := FileName;
    // Un seul jeu de valeurs alimente à la fois /Info et le paquet XMP
    PDF.Title := 'Quarterly Report';
    PDF.Author := 'A. Author';
    PDF.Subject := 'Results for the third quarter';
    PDF.Keywords := 'quarterly, results';
    PDF.EnableXMPMetadata := True;
    PDF.BeginDoc;

    PDF.CurrentPage.SetFont('Arial', [fsBold], 20);
    PDF.CurrentPage.TextOut(50, 50, 0, 'Chapter 1: Results');
    Chapter := PDF.OutlineRoot.AddChild('Chapter 1: Results', 0, 50);
    Chapter.Opened := True;   // sauvegardé comme un /Count positif

    PDF.AddPage;
    PDF.CurrentPage.SetFont('Arial', [fsBold], 16);
    PDF.CurrentPage.TextOut(50, 50, 0, 'Introduction');
    Chapter.AddChild('Introduction', 0, 50);

    PDF.AddPage;
    PDF.CurrentPage.SetFont('Arial', [fsBold], 16);
    PDF.CurrentPage.TextOut(50, 50, 0, 'Methodology');
    Chapter.AddChild('Methodology', 0, 50);

    PDF.EndDoc;
  finally
    PDF.Free;
  end;
end;

Éditer un fichier existant est différent. Sur un document chargé, SetLoadedTitle, SetLoadedAuthor, SetLoadedCreator et leurs frères écrivent l'entrée du dictionnaire Info, et le flux XMP est un objet séparé que vous remplacez avec SetLoadedXMPMetadata. Changez l'auteur par un appel et pas l'autre, et SaveLoadedDocument produit exactement le fichier aux deux auteurs du paragraphe d'ouverture

L'arbre de contours derrière le panneau des signets

Le panneau des signets est reconstruit depuis un arbre de dictionnaires doublement chaîné, le plan du document (ISO 32000-1 §12.3.3, Tables 152 et 153). Aucun tableau de signets n'existe nulle part dans le fichier ; le visualiseur reconstruit le panneau en suivant les références depuis le /Outlines du catalog

Schéma PDF de l'arbre de contours PDF montrant les liens Parent, First, Last, Prev, Next et Count qui reconstruisent le panneau de signets du visualiseur
Le panneau des signets est reconstruit depuis un arbre de contours doublement chaîné tissé par Parent, First, Last, Prev, Next et Count. Un seul lien périmé cache une branche entière sans aucune erreur
8 0 obj                                    % la racine des contours
<< /Type /Outlines /Count 3 /First 9 0 R /Last 9 0 R >>
endobj
9 0 obj                                    % premier niveau : un chapitre
<< /Title (Chapter 1: Results)
   /Parent 8 0 R /Count 2
   /First 12 0 R /Last 15 0 R
   /Dest [3 0 R /XYZ 0 792 0] >>
endobj
12 0 obj                                   % premier enfant
<< /Title (Introduction)
   /Parent 9 0 R /Next 15 0 R
   /Dest [4 0 R /XYZ 72 720 0] >>
endobj
15 0 obj                                   % second enfant, dernier frère
<< /Title (Methodology)
   /Parent 9 0 R /Prev 12 0 R
   /Dest [5 0 R /Fit] >>
endobj

Les invariants se lisent directement sur les liens. Chaque item pointe en retour vers son /Parent. Les frères se chaînent par /Prev et /Next ; le premier enfant n'a pas de /Prev, le dernier n'a pas de /Next. Un parent nomme ses deux bouts par /First et /Last, et tout ce qui est entre les deux n'existe que comme étape de la marche des frères. Cassez un lien et les visualiseurs montrent ce qu'ils peuvent encore atteindre : un /Next périmé raccourcit un chapitre, tandis qu'un /Last en désaccord avec la fin de la chaîne laisse différents visualiseurs dessiner différents arbres

Le /Count surprend les gens parce qu'il stocke l'état du panneau. Sur la racine c'est le nombre d'items visibles à tous les niveaux. Sur un item, une valeur positive signifie que l'item est ouvert et donne le nombre de descendants visibles ; une valeur négative signifie qu'il est fermé, et sa magnitude dit combien de descendants apparaîtraient au déploiement. Dans l'échantillon la racine porte 3 : un chapitre plus ses deux enfants ouverts. Un générateur qui écrit chaque /Count comme un total positif rouvre silencieusement chaque branche que l'auteur avait repliée

Dans HotPDF, OutlineRoot est un THPDFDocOutlineObject, et AddChild(Title, X, Y) ajoute un item, câble ses liens /Parent, /Prev, /Next, /First et /Last, et le pointe vers la page courante en [page /XYZ left top 0]. La propriété Opened décide du signe, et le writer recalcule chaque /Count depuis l'arbre achevé à EndDoc, si bien que positionner Opened après l'ajout des enfants va très bien. InsertBefore, InsertAfter et MoveTo restructurent l'arbre avec la même comptabilité

Destinations : où un clic atterrit réellement

Signets et annotations de lien se résolvent tous deux vers une destination (ISO 32000-1 §12.3.2, Table 151), et une destination est plus qu'un numéro de page. C'est un tableau nommant un objet page, puis un type d'ajustement qui dit au visualiseur comment l'encadrer. Le cheval de bataille est [page /XYZ left top zoom]. N'importe lequel des trois opérandes peut être null, signifiant « gardez ce que le lecteur avait », et un zoom de 0 signifie la même chose que null. Ainsi [page /XYZ null null null] change la page et rien d'autre, ce qui est en général ce que devrait faire un lien « aller à la page »

La famille des fit échange le positionnement exact contre la résilience. [page /Fit] met la page entière à l'échelle de la fenêtre, [page /FitH top] ajuste la largeur de page avec un bord supérieur donné, et [page /FitR left bottom right top] zoome un rectangle pour remplir la vue. Ceux-ci calculent l'échelle depuis la géométrie de la page au moment du clic ; un /XYZ avec un zoom figé le garde même après redimensionnement de la page. Pour une table des matières, /FitH au bord supérieur de la section est le meilleur défaut

Les coordonnées sont l'endroit où les gens se brûlent. Les nombres de destination sont en espace utilisateur par défaut, origine en bas à gauche et y croissant vers le haut (§8.3.2.3), tandis que les habitudes de mise en page écran mesurent depuis le haut. Les appels style canvas de HotPDF (TextOut, rectangles de liens, le X et le Y de AddChild) prennent des coordonnées descendantes et les convertissent pour vous. RegisterNamedDestination écrit ses Left et Top dans le tableau de destination tels quels, donc ils doivent déjà être en espace utilisateur PDF :

// Appelez entre BeginDoc et EndDoc, une fois les pages 0..2 existantes
procedure AddContentsLinks(PDF: THotPDF);
var
  R: TRect;
begin
  // Destination nommée 50 pt sous le haut de la page 1.
  // Top est en espace utilisateur PDF, mesuré vers le haut depuis le bord inférieur
  PDF.CurrentPageNumber := 1;
  PDF.RegisterNamedDestination('chapter-1', 1, dfmFitH,
    0, PDF.CurrentPage.Height - 50, 0, 0, 0);

  // Entrées de la table des matières en page 0 ; les rectangles emploient des coordonnées descendantes
  PDF.CurrentPageNumber := 0;
  R := Rect(50, 175, 545, 209);
  PDF.CurrentPage.AddNamedDestinationLink(R, 'chapter-1', 'Chapter 1: Results');

  // YPos = -1 écrit [page /XYZ null null null] : changez de page, gardez la vue
  R := Rect(50, 225, 545, 259);
  PDF.CurrentPage.AddGoToLink(R, 2, -1, 'Methodology');

  R := Rect(50, 275, 545, 309);
  PDF.CurrentPage.AddURILink(R, 'https://www.example.com', 'Project website');
end;

Passez la valeur style écran 50 comme Top et le lien atterrit 50 points au-dessus du bord inférieur, montrant le pied de page au lieu du titre. Une destination nommée vit dans l'arbre de noms /Dests du catalog et chaque lien se résout au travers du nom, si bien qu'avec des dizaines de liens vers les mêmes chapitres c'est le bon choix. Déplacez une section et vous éditez une entrée dans /Dests ; chaque lien qui la nomme suit. L'ordre des pages dont ces destinations dépendent est couvert dans pourquoi l'ordre de l'arbre des pages compte

Annotations : objets interactifs hors du flux de contenu

Une annotation se pose sur une page sans faire partie de son flux de contenu (ISO 32000-1 §12.5). Liens, notes adhésives, surlignages, widgets de formulaire, icônes de pièce jointe et tampons vivent tous dans le tableau /Annots de leur page. Retirez une entrée et l'annotation disparaît, pendant que le flux de contenu reste octet pour octet le même

Chaque annotation partage un petit noyau (Table 164) : /Subtype nomme la sorte, /Rect donne la boîte englobante en espace utilisateur par défaut, et /Contents porte un texte qui sert aussi de description alternative que les technologies d'assistance lisent à voix haute. Les annotations de lien (§12.5.6.5, Table 173) existent en deux formes :

20 0 obj                                    % lien vers une destination
<< /Type /Annot /Subtype /Link
   /Rect [100 200 300 250]
   /Border [0 0 0]
   /Dest [5 0 R /XYZ null null null] >>
endobj
21 0 obj                                    % lien qui exécute une action
<< /Type /Annot /Subtype /Link
   /Rect [50 50 200 100]
   /Border [0 0 0]
   /Contents (Project website)
   /A << /Type /Action /S /URI /URI (https://www.example.com) >> >>
endobj

Le /Border [0 0 0] est là pour une raison : le bord par défaut est [0 0 1], un cadre d'un point que beaucoup de visualiseurs dessinent autour de chaque lien. /Dest et /A sont mutuellement exclusifs, et le /S de l'action choisit le comportement : /GoTo dans ce fichier (§12.6.4.2), /GoToR pour une page d'un autre PDF (§12.6.4.3), /URI pour une adresse web (§12.6.4.7), /Launch pour ouvrir une application ou un fichier externe (§12.6.4.5). Laissez /Launch hors des documents que vous distribuez : les visualiseurs le bloquent communément ou y mettent une invite de sécurité, et le PDF/A l'interdit carrément (ISO 19005-1 §6.6.1). L'argument Description des appels de lien ci-dessus remplit /Contents, et avec le PDFUACompliance de HotPDF activé une description vide lève une exception, puisque PDF/UA-1 exige cette entrée sur chaque lien

Les annotations de balisage telles que surlignages et notes, et les formes telles que /Square, ajoutent une torsion : leur type ne fixe pas leur look. Sans flux d'apparence (l'entrée /AP, §12.5.5), chaque visualiseur dessine sa propre interprétation. ISO 32000-2 rend /AP obligatoire pour la plupart des types d'annotations, Popup et Link étant parmi les exceptions. Les pièces jointes réemploient la même machinerie : un flux de fichier embarqué enveloppé dans une spécification de fichier (§7.11), présenté soit comme annotation /FileAttachment sur une page, soit au travers de l'arbre de noms /EmbeddedFiles sous le /Names du catalog

Comment contrôler cette couche avant qu'un fichier ne parte ?

Deux contrôles attrapent l'essentiel : cliquez au travers du panneau des signets et d'un échantillon de liens dans un vrai visualiseur, puis relisez les métadonnées avec un programme, puisque nul clic ne révèle un /Info en désaccord avec le XMP. Les appels document-chargé de HotPDF couvrent les deux moitiés. GetLoadedOutlineTitlesDeep parcourt la chaîne /First et /Next en profondeur, la même marche qu'un visualiseur, si bien qu'une branche coupée par un lien périmé manque aussi de son résultat. FindLoadedOutlineByTitle résout la page cible d'un signet et rapporte -1 quand la cible est une destination nommée ou ne se résout vers aucune page

// StripXmlTags est une fonction que vous ajoutez vous-même ; ce n'est pas un appel de bibliothèque.
// GetLoadedXMPValue renvoie le balisage interne de l'élément, donc dc:creator
// revient enveloppé dans des balises rdf:Seq et rdf:li
function StripXmlTags(const S: AnsiString): AnsiString;
var
  I: Integer;
  InTag: Boolean;
begin
  Result := '';
  InTag := False;
  for I := 1 to Length(S) do
    if S[I] = '<' then InTag := True
    else if S[I] = '>' then InTag := False
    else if not InTag then Result := Result + S[I];
  Result := AnsiString(Trim(string(Result)));
end;

procedure CheckNavigationLayer(const FileName: string);
var
  PDF: THotPDF;
  Title, Author, Subject, Keywords, Creator, Producer, XmpAuthor: AnsiString;
  Titles: THPDFAnsiStringArray;
  I, PageIndex: Integer;
begin
  PDF := THotPDF.Create(nil);
  try
    PDF.AutoLaunch := False;
    if PDF.LoadFromFile(FileName) <= 0 then
      raise Exception.Create('No readable pages');

    if PDF.GetLoadedDocumentInfo(Title, Author, Subject, Keywords,
      Creator, Producer) then
    begin
      XmpAuthor := StripXmlTags(PDF.GetLoadedXMPValue('dc', 'creator'));
      if (XmpAuthor <> '') and (XmpAuthor <> Author) then
        Writeln('Author mismatch: /Info="', Author, '" XMP="', XmpAuthor, '"');
    end;

    Titles := PDF.GetLoadedOutlineTitlesDeep;
    Writeln(Length(Titles), ' bookmarks reachable');
    for I := 0 to High(Titles) do
      if PDF.FindLoadedOutlineByTitle(Titles[I], PageIndex, False, True)
        and (PageIndex < 0) then
        Writeln('No direct page target: ', Titles[I]);
  finally
    PDF.Free;
  end;
end;

Un nombre de signets inférieur à ce que vous avez écrit pointe vers une chaîne de frères cassée. Un -1 sur un signet qui emploie une destination nommée est attendu, et FindLoadedOutlineByTitle s'arrête à la première correspondance, si bien que des titres dupliqués rapportent la cible du premier. GetLoadedXMPValue est une recherche texte simple sur le paquet, et la comparaison est octet pour octet, donc avec des noms non ASCII regardez comment les deux valeurs reviennent avant de faire confiance à un écart

Tout dans cette couche est de la comptabilité de références, et la comptabilité est la partie qui vaut d'être confiée à une bibliothèque. Le HotPDF Delphi Component pour Delphi et C++Builder écrit les deux magasins de métadonnées depuis un seul jeu de propriétés, tisse les liens de contours à mesure que vous ajoutez des items, et attache des annotations de lien avec leurs actions et descriptions ; son API document-chargé relit les mêmes structures pour contrôle