Technischer Artikel

Erklärt: PDF-Metadaten, Outlines und Anmerkungen

Dasselbe PDF kann zwei verschiedene Autoren melden. Der Eigenschaften-Dialog eines Desktop-Viewers zeigt einen Namen, ein Asset-Management-System, das XMP indexiert, einen anderen, und beide Programme lesen die Datei korrekt. PDF speichert Dokument-Metadaten doppelt, und es hält Lesezeichen, Links und Notizen in Strukturen, die den Seiteninhalt nie anfassen, also kann jedes davon falsch sein, während jede Seite perfekt rendert

Das Objektmodell darunter (indirekte Objekte, die Cross-Reference-Tabelle, der Trailer) ist im Rundgang durch die PDF-Dokumentstruktur behandelt. Hier bleibt der Fokus auf dem, was am Dokumentkatalog hängt

Vier Einträge im Katalog, vier unabhängige Subsysteme

Alles, was unten besprochen wird, ist vom Dokumentkatalog aus erreichbar, dem Dictionary, auf das der /Root-Eintrag des Trailers zeigt (ISO 32000-1 §7.7.2). Ein Katalog, der alles davon verdrahtet, sieht so aus:

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
PDF-Diagramm eines PDF-Dokumentkatalogs mit Verknüpfungen zu Seitenbaum, Outline-Wurzel, Namenswörterbuch und XMP-Metadatenstream als vier optionalen Subsystemen
Der Dokument-Catalog verdrahtet vier unabhängige Subsysteme, jedes erreichbar über seinen eigenen Eintrag. Jedes darf fehlen, während die Seiten trotzdem normal rendern

/Pages ist das sichtbare Dokument, und das einzige der vier, das der Katalog verlangt. /Outlines führt zum Lesezeichenbaum, /Metadata zum XMP-Stream und /Names zum Namens-Dictionary, das eingebettete Dateien unter /EmbeddedFiles und benannte Ziele unter /Dests hält. Ein Reader, der keine der optionalen drei findet, zeigt trotzdem jede Seite, weshalb diese Schicht zuerst verrottet, wenn eine Datei durch Tools läuft, die nur Seiten verstehen. Nichts in einem Seitenobjekt zeigt zurück auf den Outline, den /Dests-Baum oder den XMP-Stream. Kopiert man allein die Seiten in eine neue Datei, bleiben alle drei zurück

Warum trägt ein PDF zwei Metadatenspeicher?

Ein PDF trägt zwei Metadatenspeicher, weil das Format einen zweiten dazubekam, ohne den ersten einzustellen. Der ursprüngliche Mechanismus ist das Dokumentinformations-Dictionary (ISO 32000-1 §14.3.3, Tabelle 317), referenziert über /Info im Trailer: flache Texteinträge für /Title, /Author, /Subject, /Keywords, /Creator, /Producer, dazu /CreationDate, /ModDate und /Trapped. ISO 32000-2 missbilligt das ganze Dictionary außer den beiden Datumseinträgen und verweist Writer auf den XMP-Metadaten-Stream

PDF-Diagramm: das PDF-Info-Wörterbuch gegenüber dem XMP-Metadatenstream, zwei Speicher mit überlappenden Feldern, die synchron bleiben müssen, mit XMP als Quelle der Wahrheit
Das Info-Dictionary und der XMP-Stream beantworten dieselben Fragen parallel. Halten Sie sie synchron, indem Sie beide aus einem Wertesatz neu erzeugen

XMP (ISO 32000-1 §14.3.2) ist ein in RDF geschriebenes XML-Dokument, gespeichert in einem Stream, dessen Dictionary /Type /Metadata /Subtype /XML trägt. Anders als das Info-Dictionary ist ein XMP-Paket darauf ausgelegt, von Tools gefunden und geparst zu werden, die nichts über PDF-Syntax wissen:

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

Drei Details in diesem Block entscheiden, ob das Paket echte Tools überlebt. Die xpacket-Processing Instructions rahmen das Paket ein, damit ein Scanner es innerhalb eines beliebigen Bytestreams lokalisieren kann; das schließende <?xpacket end="w"?> weg zulassen und die Datei öffnet noch, aber Scanner verlieren ihre Endmarkierung. Auch die Wertetypen zählen: dc:title ist eine Sprachalternative und braucht rdf:Alt, während dc:creator eine geordnete Liste ist und rdf:Seq braucht. Eines davon als nackten Textknoten zu schreiben ist ein leichter Fehler. Präfixe allerdings sind nur Konvention; ein Parser keyed auf den Namespace-URI, also ist dc:, gebunden an den falschen URI, eine ganz andere Eigenschaft

Die Info-Schlüssel und ihre XMP-Gegenstücke paaren sich wie folgt. PDF/A-1 (ISO 19005-1 §6.7.3) stellt die Paarung als Konformitätsregel auf: Sind beide Speicher vorhanden, müssen die Werte äquivalent sein

Info-DictionaryXMP-Eigenschaft
/Titledc:title (rdf:Alt)
/Authordc:creator (rdf:Seq)
/Subjectdc:description (rdf:Alt)
/Keywordspdf:Keywords
/Creatorxmp:CreatorTool
/Producerpdf:Producer
/CreationDate, /ModDatexmp:CreateDate, xmp:ModifyDate

Hand-Edits und Merges sind der Ort, an dem die beiden driften. Ein Merge kann /Info von der einen Eingabe und das XMP-Paket von der anderen erben. Die sicherere Regel ist, XMP als Source of Truth zu behandeln, /Info für ältere Konsumenten zu behalten und beide aus einem Wertesatz neu zu erzeugen, wann immer sich eines ändert

Beide Speicher aus einem Wertesatz schreiben

HotPDF baut beide Speicher aus denselben Dokument-Properties. Title, Author, Subject und Keywords gehen nach /Info; ist EnableXMPMetadata True, schreibt EndDoc zusätzlich ein XMP-Paket aus denselben Werten, mit dc:title als rdf:Alt und dc:creator als rdf:Seq, und verlinkt es über das /Metadata des Katalogs. EnableXMPMetadata defaulted auf False, und es lohnt sich, es für alles einzuschalten, was Ihre Hände verlässt: Eine Datei mit nur /Info ist unsichtbar für jedes Tool, das allein XMP liest. CustomXMP ersetzt das generierte Paket durch Ihren eigenen Text, wenn Sie zusätzliche Schemata brauchen

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;
    // Ein Wertesatz speist sowohl /Info als auch das XMP-Paket
    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;   // gespeichert als positives /Count

    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;

Eine bestehende Datei zu editieren ist etwas anderes. Auf einem geladenen Dokument schreiben SetLoadedTitle, SetLoadedAuthor, SetLoadedCreator und ihre Geschwister den Info-Dictionary-Eintrag, und der XMP-Stream ist ein separates Objekt, das Sie mit SetLoadedXMPMetadata ersetzen. Den Autor mit einem Aufruf zu ändern und nicht mit dem anderen, und SaveLoadedDocument produziert exakt die Zwei-Autoren-Datei aus dem einleitenden Absatz

Der Outline-Baum hinter dem Lesezeichen-Panel

Das Lesezeichen-Panel wird aus einem doppelt verketteten Baum von Dictionarys neu aufgebaut, dem Dokument-Outline (ISO 32000-1 §12.3.3, Tabellen 152 und 153). Nirgendwo in der Datei existiert ein Array von Lesezeichen; der Viewer rekonstruiert das Panel, indem er Referenzen ab dem /Outlines des Katalogs verfolgt

PDF-Diagramm des PDF-Outline-Baums mit Parent-, First-, Last-, Prev-, Next- und Count-Verknüpfungen, die das Lesezeichen-Panel des Viewers rekonstruieren
Das Lesezeichen-Panel wird aus einem doppelt verketteten Gliederungsbaum neu aufgebaut, durchfädelt über Parent, First, Last, Prev, Next und Count. Ein einziger veralteter Link versteckt einen ganzen Zweig, ohne jeden Fehler
8 0 obj                                    % the outline root
<< /Type /Outlines /Count 3 /First 9 0 R /Last 9 0 R >>
endobj
9 0 obj                                    % top-level: a chapter
<< /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                                   % first child
<< /Title (Introduction)
   /Parent 9 0 R /Next 15 0 R
   /Dest [4 0 R /XYZ 72 720 0] >>
endobj
15 0 obj                                   % second child, last sibling
<< /Title (Methodology)
   /Parent 9 0 R /Prev 12 0 R
   /Dest [5 0 R /Fit] >>
endobj

Die Invarianten lesen sich direkt von den Links ab. Jedes Element zeigt zurück auf sein /Parent. Geschwister ketten über /Prev und /Next; das erste Kind hat kein /Prev, das letzte kein /Next. Ein Parent benennt seine beiden Enden über /First und /Last, und alles dazwischen existiert nur als Schritt im Geschwister-Durchlauf. Bricht man einen Link, zeigen Viewer, was sie noch erreichen: Ein veraltetes /Next kappt ein Kapitel, während ein /Last, der nicht mit dem Ende der Kette übereinstimmt, verschiedene Viewer verschiedene Bäume zeichnen lässt

/Count überrascht Leute, weil es Panel-Zustand speichert. Auf dem Root ist es die Anzahl der auf allen Ebenen sichtbaren Items. An einem Item heißt ein positiver Wert, dass das Item offen ist, und gibt die Anzahl sichtbarer Nachkommen an; ein negativer Wert heißt, es ist geschlossen, und sein Betrag ist, wie viele Nachkommen beim Aufklappen erscheinen würden. Im Beispiel hält der Root 3: ein Kapitel plus seine zwei offenen Kinder. Ein Generator, der jedes /Count als positive Gesamtzahl schreibt, öffnet stillschweigend jeden Zweig wieder, den der Autor zugeklappt hat

In HotPDF ist OutlineRoot ein THPDFDocOutlineObject, und AddChild(Title, X, Y) hängt ein Item an, verdrahtet seine /Parent-, /Prev-, /Next-, /First- und /Last-Links und zeigt es auf die aktuelle Seite als [page /XYZ left top 0]. Die Opened-Property entscheidet das Vorzeichen, und der Writer berechnet jedes /Count aus dem fertigen Baum bei EndDoc neu, also ist es in Ordnung, Opened zu setzen, nachdem die Kinder ergänzt wurden. InsertBefore, InsertAfter und MoveTo restrukturieren den Baum mit derselben Buchhaltung

Ziele: Wo ein Klick tatsächlich landet

Lesezeichen und Link-Annotationen lösen beide zu einer Destination auf (ISO 32000-1 §12.3.2, Tabelle 151), und eine Destination ist mehr als eine Seitenzahl. Sie ist ein Array, das ein Seitenobjekt nennt, dann einen Fit-Typ, der dem Viewer sagt, wie er sie einrahmen soll. Das Arbeitstier ist [page /XYZ left top zoom]. Jeder der drei Operanden darf null sein, was „behalte, was der Reader hatte“ bedeutet, und ein Zoom von 0 bedeutet dasselbe wie null. Also ändert [page /XYZ null null null] die Seite und nichts sonst, was meist das ist, was ein „Gehe zu Seite“-Link tun sollte

Die Fit-Familie tauscht exakte Positionierung gegen Resilienz. [page /Fit] skaliert die ganze Seite ins Fenster, [page /FitH top] passt die Seitenbreite mit gegebener Oberkante an, und [page /FitR left bottom right top] zoomt ein Rechteck, um die Ansicht zu füllen. Diese berechnen die Skalierung zur Klickzeit aus der Seitengeometrie; ein /XYZ mit eingebranntem Zoom hält ihn, selbst nachdem die Seite skaliert wurde. Für ein Inhaltsverzeichnis ist /FitH an der Oberkante des Abschnitts der bessere Default

Die Koordinaten sind der Ort, an dem Leute brennen. Destination-Zahlen sind im Default User Space, Ursprung unten links und y nach oben wachsend (§8.3.2.3), während Gewohnheiten aus dem Bildschirm-Layout von oben messen. HotPDFs Canvas-artige Aufrufe (TextOut, Link-Rechtecke, das X und Y von AddChild) nehmen Top-down-Koordinaten und konvertieren sie für Sie. RegisterNamedDestination schreibt sein Left und Top unverändert ins Destination-Array, sie müssen also bereits im PDF User Space sein:

// Zwischen BeginDoc und EndDoc aufrufen, sobald die Seiten 0..2 existieren
procedure AddContentsLinks(PDF: THotPDF);
var
  R: TRect;
begin
  // Benannte Destination 50 pt unter der Oberkante von Seite 1.
  // Top ist PDF User Space, gemessen von der Unterkante aufwärts
  PDF.CurrentPageNumber := 1;
  PDF.RegisterNamedDestination('chapter-1', 1, dfmFitH,
    0, PDF.CurrentPage.Height - 50, 0, 0, 0);

  // Inhalts-Einträge auf Seite 0; Rechtecke benutzen Top-down-Koordinaten
  PDF.CurrentPageNumber := 0;
  R := Rect(50, 175, 545, 209);
  PDF.CurrentPage.AddNamedDestinationLink(R, 'chapter-1', 'Chapter 1: Results');

  // YPos = -1 schreibt [page /XYZ null null null]: Seite wechseln, Ansicht behalten
  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;

Den Bildschirm-artigen Wert 50 als Top zu reichen lässt den Link 50 Punkte über der Unterkante landen und zeigt die Fußzeile statt der Überschrift. Eine benannte Destination lebt im /Dests-Namensbaum des Katalogs, und jeder Link löst über den Namen auf, also ist sie bei Dutzenden Links in dieselben Kapitel die richtige Wahl. Verschiebt man einen Abschnitt, editiert man einen Eintrag in /Dests; jeder Link, der ihn nennt, folgt. Die Seitenreihenfolge, von der diese Destinations abhängen, ist in warum die Reihenfolge des Seitenbaums zählt behandelt

Annotationen: interaktive Objekte außerhalb des Content-Streams

Eine Annotation sitzt auf einer Seite, ohne Teil ihres Content-Streams zu sein (ISO 32000-1 §12.5). Links, Notizzettel, Hervorhebungen, Formular-Widgets, Dateianhang-Icons und Stempel leben alle im /Annots-Array ihrer Seite. Entfernt man einen Eintrag, verschwindet die Annotation, während der Content-Stream Byte für Byte derselbe bleibt

Jede Annotation teilt einen kleinen Kern (Tabelle 164): /Subtype benennt die Art, /Rect gibt die Bounding Box im Default User Space, und /Contents hält Text, der nebenbei als alternative Beschreibung dient, die assistive Technologie vorliest. Link-Annotationen (§12.5.6.5, Tabelle 173) kommen in zwei Gestalten:

20 0 obj                                    % link to a 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                                    % link that runs an 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

/Border [0 0 0] ist nicht umsonst da: Der Default-Rand ist [0 0 1], ein Ein-Punkt-Rahmen, den viele Viewer um jeden Link zeichnen. /Dest und /A schließen sich gegenseitig aus, und das /S der Action wählt das Verhalten: /GoTo innerhalb dieser Datei (§12.6.4.2), /GoToR für eine Seite in einem anderen PDF (§12.6.4.3), /URI für eine Webadresse (§12.6.4.7), /Launch zum Öffnen einer externen Anwendung oder Datei (§12.6.4.5). Lassen Sie /Launch aus Dokumenten, die Sie verteilen: Viewer blocken es üblicherweise oder schieben einen Sicherheits-Prompt davor, und PDF/A verbietet es rundheraus (ISO 19005-1 §6.6.1). Das Description-Argument der Link-Aufrufe oben füllt /Contents, und mit HotPDFs PDFUACompliance eingeschaltet wirft eine leere Beschreibung eine Exception, denn PDF/UA-1 verlangt diesen Eintrag an jedem Link

Markup-Annotationen wie Hervorhebungen und Notizen und Formen wie /Square fügen eine Wendung hinzu: Ihr Typ fixiert ihr Aussehen nicht. Ohne Appearance Stream (den /AP-Eintrag, §12.5.5) zeichnet jeder Viewer seine eigene Version. ISO 32000-2 macht /AP für die meisten Annotationstypen verpflichtend, mit Popup und Link unter den Ausnahmen. Dateianhänge recyceln dieselbe Maschinerie: ein eingebetteter Datei-Stream, gehüllt in eine Dateispezifikation (§7.11), sichtbar entweder als /FileAttachment-Annotation auf einer Seite oder über den /EmbeddedFiles-Namensbaum unter dem /Names des Katalogs

Wie prüft man diese Schicht, bevor eine Datei rausgeht?

Zwei Checks fangen das meiste: Klicken Sie sich in einem echten Viewer durch das Lesezeichen-Panel und eine Stichprobe der Links, dann lesen Sie die Metadaten mit einem Programm zurück, denn noch so viel Klicken enthüllt kein /Info, das XMP widerspricht. HotPDFs Geladen-Dokument-Aufrufe decken beide Hälften. GetLoadedOutlineTitlesDeep läuft die /First- und /Next-Kette depth-first durch, derselbe Walk wie bei einem Viewer, also fehlt auch dort ein Zweig, den ein veralteter Link abgeschnitten hat. FindLoadedOutlineByTitle löst die Zielseite eines Lesezeichens auf und meldet -1, wenn das Ziel eine benannte Destination ist oder nicht zu einer Seite auflöst

// StripXmlTags ist ein Helfer, den Sie selbst ergänzen; er ist kein Bibliotheksaufruf.
// GetLoadedXMPValue liefert das innere Markup des Elements, also kommt dc:creator
// in rdf:Seq- und rdf:li-Tags gewickelt zurück
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;

Eine Lesezeichenanzahl unter dem, was Sie geschrieben haben, zeigt auf eine gebrochene Geschwisterkette. Ein -1 bei einem Lesezeichen, das eine benannte Destination benutzt, ist zu erwarten, und FindLoadedOutlineByTitle stoppt beim ersten Match, also melden doppelte Titel das Ziel des ersten. GetLoadedXMPValue ist eine schlichte Textsuche im Paket, und der Vergleich ist Byte für Byte, schauen Sie bei Nicht-ASCII-Namen also darauf, wie beide Werte zurückkommen, bevor Sie einem Mismatch trauen

Alles in dieser Schicht ist Referenz-Buchhaltung, und Buchhaltung ist der Teil, den man einer Bibliothek überlassen sollte. Die HotPDF Delphi Component für Delphi und C++Builder schreibt beide Metadatenspeicher aus einem Wertesatz, fädelt die Outline-Links beim Ergänzen von Items und hängt Link-Annotationen mit ihren Actions und Beschreibungen an; ihre Geladen-Dokument-API liest dieselben Strukturen zum Prüfen zurück