Articolo tecnico

Metadati, outline e annotazioni PDF spiegati

Lo stesso PDF può riferire due autori diversi. La finestra delle proprietà di un viewer desktop mostra un nome, un sistema di gestione degli asset che indicizza l'XMP ne mostra un altro, ed entrambi i programmi stanno leggendo il file correttamente. Il PDF memorizza i metadati del documento due volte, e tiene segnalibri, collegamenti e note in strutture che non toccano mai il contenuto delle pagine, così uno qualsiasi di loro può essere sbagliato mentre ogni pagina si disegna alla perfezione

Il modello a oggetti sottostante (oggetti indiretti, tabella di riferimento incrociato, trailer) è trattato nella guida alla struttura dei documenti PDF. Qui il focus resta su ciò che pende dal catalogo del documento

Quattro voci nel catalogo, quattro sottosistemi indipendenti

Tutto ciò di cui si parla qui sotto si raggiunge dal catalogo del documento, il dizionario al quale punta la voce /Root del trailer (ISO 32000-1 §7.7.2). Un catalogo che collega tutto quanto appare così:

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
Diagramma PDF di un catalogo di documento PDF collegato all'albero delle pagine, alla radice degli outline, al dizionario dei nomi e allo stream di metadati XMP come quattro sottosistemi opzionali
Il catalogo del documento collega quattro sottosistemi indipendenti, ognuno raggiungibile attraverso la propria voce. Uno qualsiasi di loro può essere assente mentre le pagine si disegnano comunque normalmente

/Pages è il documento visibile, e l'unico dei quattro che il catalogo richiede. /Outlines porta all'albero dei segnalibri, /Metadata allo stream XMP, e /Names al dizionario dei nomi, che contiene i file incorporati sotto /EmbeddedFiles e le destinazioni nominate sotto /Dests. Un reader che non trova nessuno dei tre opzionali mostra comunque ogni pagina, ed è per questo che questo livello si degrada per primo quando un file passa attraverso strumenti che capiscono solo le pagine. Nulla dentro un oggetto pagina punta all'indietro verso l'outline, l'albero /Dests o lo stream XMP. Copiate le sole pagine in un nuovo file e tutti e tre restano indietro

Perché un PDF porta con sé due archivi di metadati?

Un PDF porta due archivi di metadati perché il formato ha fatto crescere un secondo senza ritirare il primo. Il meccanismo originale è il dizionario delle informazioni del documento (ISO 32000-1 §14.3.3, Table 317), referenziato da /Info nel trailer: voci di testo piatte per /Title, /Author, /Subject, /Keywords, /Creator, /Producer, più /CreationDate, /ModDate e /Trapped. ISO 32000-2 depreca l'intero dizionario eccetto le due voci di data, e rimanda chi scrive file allo stream di metadati XMP

Diagramma PDF a confronto fra il dizionario Info del PDF e lo stream di metadati XMP, due archivi che portano campi sovrapposti che devono restare sincronizzati con l'XMP come fonte di verità
Il dizionario Info e lo stream XMP rispondono alle stesse domande in parallelo. Teneteli sincronizzati rigenerandoli entrambi da un unico insieme di valori

XMP (ISO 32000-1 §14.3.2) è un documento XML scritto in RDF, memorizzato in uno stream il cui dizionario porta /Type /Metadata /Subtype /XML. A differenza del dizionario Info, un pacchetto XMP è progettato per essere trovato e analizzato da strumenti che non sanno nulla della sintassi 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

Tre dettagli in quel blocco decidono se il pacchetto sopravvive agli strumenti veri. Le istruzioni di elaborazione xpacket incorniciano il pacchetto così uno scanner può localizzarlo dentro un flusso di byte arbitrario; togliete il <?xpacket end="w"?> di chiusura e il file si apre ancora, ma gli scanner perdono il loro marcatore di fine. Contano anche i tipi dei valori: dc:title è un'alternativa di lingua e richiede rdf:Alt, mentre dc:creator è una lista ordinata e richiede rdf:Seq. Scrivere l'uno o l'altra come nodo di testo nudo è un errore facile da fare. I prefissi però sono solo convenzionali; un parser fa chiave sull'URI del namespace, quindi dc: legato all'URI sbagliato è una proprietà completamente diversa

Le chiavi Info e le loro controparti XMP si abbinano come segue. PDF/A-1 (ISO 19005-1 §6.7.3) dichiara l'abbinamento come regola di conformità: quando entrambi gli archivi sono presenti, i valori devono essere equivalenti

Dizionario InfoProprietà XMP
/Titledc:title (rdf:Alt)
/Authordc:creator (rdf:Seq)
/Subjectdc:description (rdf:Alt)
/Keywordspdf:Keywords
/Creatorxmp:CreatorTool
/Producerpdf:Producer
/CreationDate, /ModDatexmp:CreateDate, xmp:ModifyDate

Le modifiche a mano e le fusioni sono dove i due vanno in derive. Una fusione può ereditare /Info da un input e il pacchetto XMP da un altro. La regola più sicura è trattare l'XMP come fonte di verità, tenere /Info per i consumatori più vecchi, e rigenerare entrambi da un unico insieme di valori ogniqualvolta uno dei due cambia

Scrivere entrambi gli archivi da un unico insieme di proprietà

HotPDF costruisce entrambi gli archivi dalle stesse proprietà del documento. Title, Author, Subject e Keywords vanno in /Info; quando EnableXMPMetadata è True, EndDoc scrive anche un pacchetto XMP da quegli stessi valori, con dc:title come rdf:Alt e dc:creator come rdf:Seq, e lo collega dalla /Metadata del catalogo. EnableXMPMetadata vale False per difetto, e vale la pena attivarlo per tutto ciò che lascia le vostre mani: un file con solo /Info è invisibile a ogni strumento che legge il solo XMP. CustomXMP sostituisce il pacchetto generato con il vostro testo quando vi servono schemi extra

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 unico insieme di valori nutre sia /Info sia il pacchetto 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;   // salvato come /Count positivo

    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;

Modificare un file esistente è diverso. Su un documento caricato, SetLoadedTitle, SetLoadedAuthor, SetLoadedCreator e i loro fratelli scrivono la voce del dizionario Info, e lo stream XMP è un oggetto separato che sostituite con SetLoadedXMPMetadata. Cambiate l'autore con una chiamata e non con l'altra, e SaveLoadedDocument produce esattamente il file dai due autori del paragrafo d'apertura

L'albero di outline dietro il pannello dei segnalibri

Il pannello dei segnalibri viene ricostruito da un albero di dizionari doppiamente collegato, l'outline del documento (ISO 32000-1 §12.3.3, Tables 152 e 153). Nessun array di segnalibri esiste da nessuna parte nel file; il viewer ricostruisce il pannello seguendo i riferimenti dalla /Outlines del catalogo

Diagramma PDF dell'albero di outline PDF che mostra i collegamenti Parent, First, Last, Prev, Next e Count che ricostruiscono il pannello dei segnalibri del viewer
Il pannello dei segnalibri viene ricostruito da un albero di outline doppiamente collegato e filato da Parent, First, Last, Prev, Next e Count. Un collegamento stantio nasconde un intero ramo senza alcun errore
8 0 obj                                    % la radice dell'outline
<< /Type /Outlines /Count 3 /First 9 0 R /Last 9 0 R >>
endobj
9 0 obj                                    % livello più alto: un capitolo
<< /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                                   % primo figlio
<< /Title (Introduction)
   /Parent 9 0 R /Next 15 0 R
   /Dest [4 0 R /XYZ 72 720 0] >>
endobj
15 0 obj                                   % secondo figlio, ultimo fratello
<< /Title (Methodology)
   /Parent 9 0 R /Prev 12 0 R
   /Dest [5 0 R /Fit] >>
endobj

Gli invarianti si leggono direttamente dai collegamenti. Ogni elemento punta all'indietro al suo /Parent. I fratelli si incatenano attraverso /Prev e /Next; il primo figlio non ha /Prev, l'ultimo non ha /Next. Un genitore nomina le sue due estremità attraverso /First e /Last, e tutto ciò che sta in mezzo esiste solo come un passo nella camminata dei fratelli. Rompete un collegamento e i viewer mostrano ciò che riescono ancora a raggiungere: uno /Next stantio accorcia un capitolo, mentre uno /Last in disaccordo con la fine della catena lascia viewer diversi a disegnare alberi diversi

/Count sorprende la gente perché memorizza lo stato del pannello. Sulla radice è il numero di elementi visibili a tutti i livelli. Su un elemento, un valore positivo significa che l'elemento è aperto e dà il numero di discendenti visibili; un valore negativo significa che è chiuso, e il suo modulo è quanti discendenti comparirebbero all'espansione. Nel campione la radice tiene 3: un capitolo più i suoi due figli aperti. Un generatore che scrive ogni /Count come totale positivo riapre in silenzio ogni ramo che l'autore aveva collassato

In HotPDF, OutlineRoot è un THPDFDocOutlineObject, e AddChild(Title, X, Y) accoda un elemento, collega i suoi /Parent, /Prev, /Next, /First e /Last, e lo punta alla pagina corrente come [page /XYZ left top 0]. La proprietà Opened decide il segno, e il writer ricalcola ogni /Count dall'albero finito a EndDoc, così impostare Opened dopo aver aggiunto i figli va bene. InsertBefore, InsertAfter e MoveTo ristrutturano l'albero con la stessa contabilità

Destinazioni: dove atterra davvero un clic

Segnalibri e annotazioni di collegamento si risolvono entrambi in una destinazione (ISO 32000-1 §12.3.2, Table 151), e una destinazione è più di un numero di pagina. È un array che nomina un oggetto pagina, poi un tipo di adattamento che dice al viewer come incorniciarla. Il cavallo di battaglia è [page /XYZ left top zoom]. Qualunque dei tre operandi può essere null, che significa "tieni ciò che il reader aveva", e uno zoom di 0 significa lo stesso di null. Così [page /XYZ null null null] cambia la pagina e nient'altro, che di solito è ciò che un collegamento "vai alla pagina" dovrebbe fare

La famiglia dei fit scambia il posizionamento esatto con la resilienza. [page /Fit] adatta l'intera pagina alla finestra, [page /FitH top] adatta la larghezza della pagina con un bordo superiore dato, e [page /FitR left bottom right top] ingrandisce un rettangolo fino a riempire la vista. Questi calcolano la scala dalla geometria della pagina al momento del clic; uno /XYZ con uno zoom preconfezionato lo conserva anche dopo che la pagina è stata ridimensionata. Per un indice, /FitH al bordo superiore della sezione è il default migliore

Le coordinate sono dove la gente si brucia. I numeri delle destinazioni sono nello spazio utente predefinito, origine in basso a sinistra e y che cresce verso l'alto (§8.3.2.3), mentre le abitudini di layout dello schermo misurano dall'alto. Le chiamate in stile canvas di HotPDF (TextOut, i rettangoli dei collegamenti, la X e la Y di AddChild) prendono coordinate dall'alto verso il basso e le convertono per voi. RegisterNamedDestination scrive i suoi Left e Top nell'array di destinazione così come gli vengono dati, quindi devono essere già nello spazio utente PDF:

// Chiamate fra BeginDoc e EndDoc, una volta che le pagine 0..2 esistono
procedure AddContentsLinks(PDF: THotPDF);
var
  R: TRect;
begin
  // Destinazione nominata 50 pt sotto la cima della pagina 1.
  // Top è spazio utente PDF, misurato in su dal bordo inferiore
  PDF.CurrentPageNumber := 1;
  PDF.RegisterNamedDestination('chapter-1', 1, dfmFitH,
    0, PDF.CurrentPage.Height - 50, 0, 0, 0);

  // Voci dell'indice sulla pagina 0; i rettangoli usano coordinate dall'alto verso il basso
  PDF.CurrentPageNumber := 0;
  R := Rect(50, 175, 545, 209);
  PDF.CurrentPage.AddNamedDestinationLink(R, 'chapter-1', 'Chapter 1: Results');

  // YPos = -1 scrive [page /XYZ null null null]: cambia pagina, tieni la vista
  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;

Passate il valore in stile schermo 50 come Top e il collegamento atterra 50 punti sopra il bordo inferiore, mostrando il piè di pagina invece del titolo. Una destinazione nominata vive nell'albero dei nomi /Dests del catalogo e ogni collegamento si risolve attraverso il nome, quindi con decine di collegamenti verso gli stessi capitoli è la scelta giusta. Spostate una sezione e modificate una voce in /Dests; ogni collegamento che la nomina segue. L'ordine delle pagine da cui quelle destinazioni dipendono è trattato in perché l'ordine dell'albero delle pagine conta

Annotazioni: oggetti interattivi fuori dallo stream di contenuto

Un'annotazione sta su una pagina senza far parte del suo stream di contenuto (ISO 32000-1 §12.5). Collegamenti, note adesive, evidenziazioni, widget di modulo, icone di file allegati e timbri vivono tutti nell'array /Annots della loro pagina. Togliete una voce e l'annotazione sparisce, mentre lo stream di contenuto resta byte per byte lo stesso

Ogni annotazione condivide un piccolo nucleo (Table 164): /Subtype nomina il tipo, /Rect dà il bounding box nello spazio utente predefinito, e /Contents contiene testo che fa anche da descrizione alternativa che la tecnologia assistiva legge. Le annotazioni di collegamento (§12.5.6.5, Table 173) arrivano in due forme:

20 0 obj                                    % collegamento a una destinazione
<< /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                                    % collegamento che esegue un'azione
<< /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] c'è per una ragione: il bordo predefinito è [0 0 1], una cornice di un punto che molti viewer disegnano attorno a ogni collegamento. /Dest e /A si escludono a vicenda, e lo /S dell'azione sceglie il comportamento: /GoTo dentro questo file (§12.6.4.2), /GoToR per una pagina in un altro PDF (§12.6.4.3), /URI per un indirizzo web (§12.6.4.7), /Launch per aprire un'applicazione o un file esterno (§12.6.4.5). Lasciate /Launch fuori dai documenti che distribuite: i viewer di solito lo bloccano o gli mettono davanti una richiesta di sicurezza, e PDF/A lo vieta del tutto (ISO 19005-1 §6.6.1). L'argomento Description delle chiamate di collegamento qui sopra riempie /Contents, e con il PDFUACompliance di HotPDF attivo una descrizione vuota solleva un'eccezione, dato che PDF/UA-1 richiede quella voce su ogni collegamento

Le annotazioni di markup come evidenziazioni e note, e le forme come /Square, aggiungono una svolta: il loro tipo non fissa il loro aspetto. Senza uno stream di appearance (la voce /AP, §12.5.5), ogni viewer disegna la propria versione. ISO 32000-2 rende /AP obbligatorio per la maggior parte dei tipi di annotazione, con Popup e Link fra le eccezioni. Gli allegati file riusano la stessa macchina: uno stream di file incorporato avvolto in una specifica di file (§7.11), portato in superficie come annotazione /FileAttachment su una pagina o attraverso l'albero dei nomi /EmbeddedFiles sotto la /Names del catalogo

Come si controlla questo livello prima che un file parta?

Due controlli beccano la maggior parte dei problemi: cliccate attraverso il pannello dei segnalibri e un campione di collegamenti in un viewer vero, poi rileggete i metadati con un programma, dato che nessuna quantità di clic rivela uno /Info in disaccordo con l'XMP. Le chiamate su documento caricato di HotPDF coprono entrambe le metà. GetLoadedOutlineTitlesDeep percorre la catena /First e /Next in profondità, la stessa camminata di un viewer, quindi un ramo tagliato da un collegamento stantio manca anche dal suo risultato. FindLoadedOutlineByTitle risolve la pagina di destinazione di un segnalibro e riferisce -1 quando il target è una destinazione nominata o non si risolve in una pagina

// StripXmlTags è un helper che aggiungete voi; non è una chiamata della libreria.
// GetLoadedXMPValue restituisce il markup interno dell'elemento, quindi dc:creator
// torna avvolto nei tag rdf:Seq e 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 conteggio dei segnalibri sotto ciò che avete scritto punta a una catena di fratelli rotta. Un -1 su un segnalibro che usa una destinazione nominata è atteso, e FindLoadedOutlineByTitle si ferma alla prima corrispondenza, quindi i titoli duplicati riferiscono il target del primo. GetLoadedXMPValue è una ricerca di testo piana sul pacchetto, e il confronto è byte per byte, quindi con nomi non ASCII guardate come tornano entrambi i valori prima di fidarvi di un disaccordo

Tutto in questo livello è contabilità di riferimenti, e la contabilità è la parte che vale la pena consegnare a una libreria. L'HotPDF Delphi Component per Delphi e C++Builder scrive entrambi gli archivi di metadati da un unico insieme di proprietà, fila i collegamenti dell'outline mentre aggiungete elementi, e attacca le annotazioni di collegamento con le loro azioni e descrizioni; la sua API su documento caricato rilegge le stesse strutture per il controllo