Technisch artikel

Uitleg over PDF metadata, outlines en annotaties

Dezelfde PDF kan twee verschillende auteurs rapporteren. Het eigenschappendialoog van een desktopviewer toont de ene naam, een asset-managementsysteem dat XMP indexeert een andere, en beide programma's lezen het bestand correct. PDF slaat documentmetadata twee keer op, en hij bewaart bladwijzers, links en notities in structuren die de paginacontent nooit aanraken, dus elk ervan kan verkeerd zijn terwijl elke pagina perfect rendert

Het objectmodel daaronder (indirecte objecten, de kruisverwijzingstabel, de trailer) wordt behandeld in de walkthrough van de PDF-documentstructuur. Hier blijft de nadruk op wat aan de documentcatalog hangt

Vier entries in de catalog, vier onafhankelijke subsystemen

Alles wat hieronder wordt besproken, is bereikbaar vanuit de documentcatalog, het dictionary waarnaar de /Root-entry van de trailer wijst (ISO 32000-1 §7.7.2). Een catalogus die alles bedraadt, ziet er zo uit:

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-diagram van een PDF document catalog die linkt naar de paginaboom, outline root, name dictionary en XMP metadata stream als vier optionele subsystemen
De documentcatalogus bedraad vier onafhankelijke subsystemen, elk bereikbaar via zijn eigen entry. Elk van hen kan afwezig zijn terwijl de pagina's nog steeds normaal renderen

/Pages is het zichtbare document, en het enige van de vier dat de catalogus vereist. /Outlines leidt naar de bladwijzerboom, /Metadata naar de XMP-stream, en /Names naar de naamdictionary, die ingebedde bestanden onder /EmbeddedFiles en benoemde destinations onder /Dests bevat. Een lezer die geen van de optionele drie aantreft, toont nog steeds elke pagina, en daarom slijt deze laag het eerst wanneer een bestand door tools gaat die alleen pagina's begrijpen. Niets in een paginaobject wijst terug naar de outline, de /Dests-boom of de XMP-stream. Kopieer alleen de pagina's naar een nieuw bestand en alle drie blijven achter

Waarom draagt een PDF twee metadata-opslagen?

Een PDF draagt twee metadata-opslagen omdat het formaat een tweede kreeg zonder de eerste te pensioneren. Het oorspronkelijke mechanisme is de document information dictionary (ISO 32000-1 §14.3.3, Table 317), gerefereerd door /Info in de trailer: platte tekst-entries voor /Title, /Author, /Subject, /Keywords, /Creator, /Producer, plus /CreationDate, /ModDate en /Trapped. ISO 32000-2 heeft het hele dictionary afgekeurd, op de twee datum-entries na, en verwijst schrijvers door naar de XMP-metadatastream

PDF-diagram dat het PDF Info-woordenboek en de XMP metadata stream vergelijkt, twee opslagplaatsen met overlappende velden die gesynchroniseerd moeten blijven met XMP als bron van waarheid
De Info dictionary en de XMP-stream beantwoorden dezelfde vragen parallel. Houd ze synchroon door beide te regenereren uit één verzameling waarden

XMP (ISO 32000-1 §14.3.2) is een XML-document geschreven in RDF, opgeslagen in een stream waarvan het dictionary /Type /Metadata /Subtype /XML draagt. Anders dan het Info-dictionary is een XMP-pakket ontworpen om gevonden en geparsed te worden door tools die niets van PDF-syntaxis weten:

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

Drie details in dat blok bepalen of het pakket echte tooling overleeft. De xpacket-processing instructions kaderen het pakket in zodat een scanner hem in een willekeurige bytestroom kan vinden; de sluitende <?xpacket end="w"?> weglaten en het bestand opent nog, maar scanners verliezen hun eindmarkering. Ook de waardetypes doen ertoe: dc:title is een taalalternatief en nodig rdf:Alt, terwijl dc:creator een geordende lijst is en rdf:Seq nodig heeft. Beide als kale tekstknoop wegschrijven is een makkelijke fout. Prefixen zijn echter slechts conventie; een parser keyed op de namespace-URI, dus dc: gebonden aan de verkeerde URI is een heel andere property

De Info-sleutels en hun XMP-tegenhangers paren als volgt. PDF/A-1 (ISO 19005-1 §6.7.3) formuleert het paar als conformantieregel: zijn beide opslagen aanwezig, dan moeten de waarden equivalent zijn

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

Handmatige bewerkingen en merges zijn waar de twee uit elkaar drijven. Een merge kan /Info van de ene input erven en het XMP-pakket van de andere. De veiligere regel is XMP als bron van waarheid te behandelen, /Info voor oudere consumenten te houden, en beide te regenereren uit een set waarden zodra een van beide verandert

Beide opslagen schrijven uit een set properties

HotPDF bouwt beide opslagen uit dezelfde documentproperties. Title, Author, Subject en Keywords gaan naar /Info; is EnableXMPMetadata True, dan schrijft EndDoc ook een XMP-pakket vanuit diezelfde waarden, met dc:title als rdf:Alt en dc:creator als rdf:Seq, en linkt het vanuit de /Metadata van de catalog. EnableXMPMetadata defaultt op False, en het is het inschakelen waard voor alles wat je handen verlaat: een bestand met alleen /Info is onzichtbaar voor elke tool die alleen XMP leest. CustomXMP vervangt het gegenereerde pakket door je eigen tekst wanneer je extra schema's nodig hebt

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;
    // Een set waarden voedt zowel /Info als het XMP-pakket
    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;   // opgeslagen als een positieve /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;

Een bestaand bestand bewerken is ander werk. Op een geladen document schrijven SetLoadedTitle, SetLoadedAuthor, SetLoadedCreator en hun broers de Info-dictionary-entry, en de XMP-stream is een apart object die je met SetLoadedXMPMetadata vervangt. Verander de auteur met de ene aanroep en niet met de andere, en SaveLoadedDocument produceert precies het bestand-met-twee-auteurs uit de openingsparagraaf

De outline-boom achter het bladwijzerpaneel

Het bladwijzerpaneel wordt opnieuw opgebouwd uit een dubbel gelinkte boom van dictionaries, de document outline (ISO 32000-1 §12.3.3, Tables 152 en 153). Nergens in het bestand bestaat een array van bladwijzers; de viewer reconstrueert het paneel door verwijzingen vanuit de /Outlines van de catalog te volgen

PDF-diagram van de PDF outline tree met Parent-, First-, Last-, Prev-, Next- en Count-links die het bladwijzerpaneel van de viewer reconstrueren
Het bladwijzerpaneel wordt herbouwd uit een dubbel gelinkte outline-boom, doorthread met Parent, First, Last, Prev, Next en Count. Eén verouderde link verbergt een hele tak zonder foutmelding
8 0 obj                                    % de outline-root
<< /Type /Outlines /Count 3 /First 9 0 R /Last 9 0 R >>
endobj
9 0 obj                                    % topniveau: een hoofdstuk
<< /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)% eerste kind
   /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)% tweede kind, laatste sibling
   /Parent 9 0 R /Prev 12 0 R
   /Dest [5 0 R /Fit] >>
endobj

De invarianten lees je rechtstreeks van de links af. Elk item wijst terug naar zijn /Parent. Siblings ketenen via /Prev en /Next; het eerste kind heeft geen /Prev, het laatste geen /Next. Een ouder benoemt zijn twee uiteinden via /First en /Last, en alles daartussen bestaat alleen als stap in de sibling-wandeling. Breek een link en viewers tonen wat ze nog kunnen bereiken: een verouderde /Next kappt een hoofdstuk kort, terwijl een /Last die niet matcht met het einde van de keten verschillende viewers verschillende bomen laat tekenen

/Count verrast mensen omdat hij paneelstatus bewaart. Op de root is het het aantal items dat op alle niveaus zichtbaar is. Op een item betekent een positieve waarde dat het item open is en geeft het aantal zichtbare afstammelingen; een negatieve waarde betekent dat hij dicht is, en zijn grootte is hoeveel afstammelingen bij uitklappen zouden verschijnen. In het voorbeeld houdt de root 3 vast: een hoofdstuk plus zijn twee open kinderen. Een generator die elke /Count als positief totaal schrijft, klapt stilletjes elke tak open die de auteur dichtvouwde

In HotPDF is OutlineRoot een THPDFDocOutlineObject, en AddChild(Title, X, Y) plakt een item aan, bedraadt zijn /Parent-, /Prev-, /Next-, /First- en /Last-links, en wijst hem naar de huidige pagina als [page /XYZ left top 0]. De property Opened bepaalt het teken, en de writer hersent elke /Count vanuit de voltooide boom bij EndDoc, dus Opened zetten nadat de kinderen zijn toegevoegd is prima. InsertBefore, InsertAfter en MoveTo herstructureren de boom met dezelfde boekhouding

Destinations: waar een klik echt belandt

Bladwijzers en link-annotaties lossen allebei op naar een destination (ISO 32000-1 §12.3.2, Table 151), en een destination is meer dan een paginanummer. Het is een array die een paginaobject benoemt, daarna een fit-type dat de viewer laat weten hoe hij het kadert. Het werkpaard is [page /XYZ left top zoom]. Elk van de drie operanden mag null zijn, wat "houd wat de lezer had" betekent, en een zoom van 0 betekent hetzelfde als null. Dus [page /XYZ null null null] wisselt van pagina en verder niets, wat meestal is wat een "ga naar pagina"-link hoort te doen

De fit-familie ruilt exacte positionering in voor robuustheid. [page /Fit] schaalt de hele pagina in het venster, [page /FitH top] past de paginabreedte toe met een gegeven bovenrand, en [page /FitR left bottom right top] zoomt een rechthoek tot hij de view vult. Deze rekenen de schaal bij de klik uit vanuit de pagina-geometrie; een /XYZ met ingebakken zoom houdt die vast zelfs nadat de pagina van formaat verandert. Voor een inhoudsopgave is /FitH op de bovenrand van de sectie de betere default

De coordinaten zijn waar mensen zich verbranden. Destination-getallen staan in default user space, oorsprong linksonder en y omhoog groeiend (§8.3.2.3), terwijl screen-layout-gewoonten vanaf de bovenkant meten. De canvas-stijl-aanroepen van HotPDF (TextOut, link-rechthoeken, de X en Y van AddChild) nemen top-down-coordinaten aan en rekenen ze voor je om. RegisterNamedDestination schrijft zijn Left en Top ongewijzigd in de destination-array, dus die moeten al in PDF user space staan:

// Aanroepen tussen BeginDoc en EndDoc, zodra pagina's 0..2 bestaan
procedure AddContentsLinks(PDF: THotPDF);
var
  R: TRect;
begin
  // Named destination 50 pt onder de bovenkant van pagina 1.
  // Top is PDF user space, gemeten vanaf de onderrand omhoog
  PDF.CurrentPageNumber := 1;
  PDF.RegisterNamedDestination('chapter-1', 1, dfmFitH,
    0, PDF.CurrentPage.Height - 50, 0, 0, 0);

  // Contents-entries op pagina 0; rechthoeken gebruiken top-down-coordinaten
  PDF.CurrentPageNumber := 0;
  R := Rect(50, 175, 545, 209);
  PDF.CurrentPage.AddNamedDestinationLink(R, 'chapter-1', 'Chapter 1: Results');

  // YPos = -1 schrijft [page /XYZ null null null]: wissel van pagina, houd de view
  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;

Geef de schermstijl-waarde 50 als Top mee en de link belandt 50 points boven de onderrand, met de voettekst in beeld in plaats van de kop. Een named destination woont in de /Dests-naamboom van de catalog en elke link resolved via de naam, dus met tientallen links naar dezelfde hoofdstukken is dat de juiste keuze. Verplaats een sectie en je bewerkt een entry in /Dests; elke link die hem benoemt, volgt. De paginavolgorde waar die destinations van afhangen, staat in waarom de volgorde van de page tree ertoe doet

Annotaties: interactieve objecten buiten de content stream

Een annotatie staat op een pagina zonder deel te zijn van zijn content stream (ISO 32000-1 §12.5). Links, notitieblaadjes, highlights, formulierwidgets, bijlage-iconen en stempels wonen allemaal in de /Annots-array van hun pagina. Haal een entry weg en de annotatie verdwijnt, terwijl de content stream byte voor byte gelijk blijft

Elke annotatie deelt een kleine kern (Table 164): /Subtype benoemt de soort, /Rect geeft de bounding box in default user space, en /Contents bevat tekst die tevens dient als de alternatieve beschrijving die assisterende technologie voorleest. Link-annotaties (§12.5.6.5, Table 173) komen in twee gedaanten:

20 0 obj                                    % link naar een 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 die een action uitvoert
<< /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] staat er met een reden: de default rand is [0 0 1], een lijst van een point die veel viewers om elke link tekenen. /Dest en /A sluiten elkaar uit, en de /S van de action kiest het gedrag: /GoTo binnen dit bestand (§12.6.4.2), /GoToR voor een pagina in een andere PDF (§12.6.4.3), /URI voor een webadres (§12.6.4.7), /Launch om een externe toepassing of bestand te openen (§12.6.4.5). Laat /Launch weg uit documenten die je verspreidt: viewers blokkeren hem vaak of zetten er een beveiligingsprompt voor, en PDF/A verbiedt hem ronduit (ISO 19005-1 §6.6.1). Het Description-argument van de link-aanroepen hierboven vult /Contents, en met de PDFUACompliance van HotPDF aan geeft een lege beschrijving een exception, want PDF/UA-1 eist die entry op elke link

Markup-annotaties zoals highlights en notities, en vormen zoals /Square, voegen een draai toe: hun type legt hun uiterlijk niet vast. Zonder appearance stream (de /AP-entry, §12.5.5) tekent elke viewer zijn eigen versie. ISO 32000-2 maakt /AP verplicht voor de meeste annotatiesoorten, met Popup en Link als uitzonderingen. Bestandsbijlagen hergebruiken hetzelfde mechanisme: een ingebedde bestandsstream gewikkeld in een file specification (§7.11), zichtbaar als /FileAttachment-annotatie op een pagina of via de /EmbeddedFiles-naamboom onder de /Names van de catalog

Hoe controleer je deze laag voordat een bestand de deur uit gaat?

Twee controles vangen het meeste: klik door het bladwijzerpaneel en een steekproef van links in een echte viewer, en lees daarna de metadata terug met een programma, want geen geklik onthult een /Info die XMP tegenspreekt. De geladen-document-aanroepen van HotPDF dekken beide helften. GetLoadedOutlineTitlesDeep wandelt de /First- en /Next-keten diepte-eerst af, dezelfde wandeling die een viewer maakt, dus een tak die door een verouderde link is afgesneden, ontbreekt ook in zijn resultaat. FindLoadedOutlineByTitle resolved de doelpagina van een bladwijzer en meldt -1 wanneer de target een named destination is of niet naar een pagina resolved

// StripXmlTags is een hulpfunctie die je zelf toevoegt; hij is geen bibliotheekaanroep.
// GetLoadedXMPValue geeft de innerlijke markup van het element terug, dus dc:creator
// komt terug gewikkeld in rdf:Seq- en rdf:li-tags
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;

Een bladwijzertelling lager dan wat je schreef wijst op een gebroken sibling-keten. Een -1 bij een bladwijzer die een named destination gebruikt, is verwacht, en FindLoadedOutlineByTitle stopt bij de eerste match, dus dubbele titels melden de target van de eerste. GetLoadedXMPValue is een platte tekstzoektocht door het pakket, en de vergelijking is byte voor byte, dus kijk bij niet-ASCII-namen hoe beide waarden terugkomen voordat je een mismatch gelooft

Alles in deze laag is verwijzingsboekhouding, en boekhouding is het deel dat je liever aan een bibliotheek overlaat. De HotPDF Delphi Component voor Delphi en C++Builder schrijft beide metadata-opslagen uit een set properties, bedraadt de outline-links terwijl je items toevoegt, en koppelt link-annotaties met hun actions en beschrijvingen; zijn geladen-document-API leest dezelfde structuren terug voor controle