Artigo Técnico

Metadados PDF, Outlines e Anotações Explicados

O mesmo PDF pode reportar dois autores diferentes. O diálogo de propriedades de um viewer de desktop mostra um nome, um sistema de gestão de ativos que indexa XMP mostra outro, e os dois programas estão lendo o arquivo corretamente. O PDF guarda metadados do documento duas vezes, e mantém marcadores, links e notas em estruturas que nunca tocam o conteúdo da página, então qualquer um deles pode estar errado enquanto toda página renderiza perfeitamente

O modelo de objetos por baixo (objetos indiretos, a tabela de referência cruzada, o trailer) está coberto no walkthrough da estrutura de documentos PDF. Aqui o foco fica no que pende do catálogo do documento

Quatro entradas no catálogo, quatro subsistemas independentes

Tudo que se discute abaixo é alcançado a partir do catálogo do documento, o dicionário para o qual a entrada /Root do trailer aponta (ISO 32000-1 §7.7.2). Um catálogo que liga tudo isso se parece com:

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
Diagrama PDF de um catálogo de documento PDF ligando à árvore de páginas, raiz de outline, dicionário de nomes e stream de metadados XMP como quatro subsistemas opcionais
O catálogo do documento liga quatro subsistemas independentes, cada um alcançável pela própria entrada dele. Qualquer um pode estar ausente enquanto as páginas ainda renderizam normalmente

/Pages é o documento visível, e o único dos quatro que o catálogo exige. /Outlines leva à árvore de marcadores, /Metadata ao stream XMP, e /Names ao dicionário de nomes, que guarda arquivos embutidos sob /EmbeddedFiles e destinos nomeados sob /Dests. Um leitor que não encontra nenhum dos três opcionais ainda mostra toda página, e é por isso que essa camada degrada primeiro quando um arquivo passa por ferramentas que só entendem páginas. Nada dentro de um objeto de página aponta de volta para o outline, a árvore /Dests ou o stream XMP. Copie só as páginas para um arquivo novo e os três ficam para trás

Por que um PDF carrega dois armazenamentos de metadados?

Um PDF carrega dois armazenamentos de metadados porque o formato ganhou um segundo sem aposar o primeiro. O mecanismo original é o dicionário de informações do documento (ISO 32000-1 §14.3.3, Table 317), referenciado por /Info no trailer: entradas de texto planas para /Title, /Author, /Subject, /Keywords, /Creator, /Producer, mais /CreationDate, /ModDate e /Trapped. A ISO 32000-2 deprecia o dicionário inteiro exceto as duas entradas de data e aponta os escritores para o stream de metadados XMP

Diagrama PDF comparando o dicionário Info do PDF e o stream de metadados XMP, dois armazenamentos com campos sobrepostos que precisam ficar sincronizados com o XMP como fonte da verdade
O dicionário Info e o stream XMP respondem às mesmas perguntas em paralelo. Mantenha-os sincronizados regenerando ambos a partir de um único conjunto de valores

O XMP (ISO 32000-1 §14.3.2) é um documento XML escrito em RDF, guardado num stream cujo dicionário carrega /Type /Metadata /Subtype /XML. Ao contrário do dicionário Info, um packet XMP é projetado para ser encontrado e parseado por ferramentas que não sabem nada de sintaxe 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

Três detalhes desse bloco decidem se o packet sobrevive a ferramentas reais. As processing instructions xpacket emolduram o packet para um scanner localizá-lo dentro de um stream de bytes arbitrário; derrube o <?xpacket end="w"?> de fechamento e o arquivo ainda abre, mas os scanners perdem o marcador de fim. Os tipos de valor também importam: dc:title é uma alternativa de idioma e precisa de rdf:Alt, enquanto dc:creator é uma lista ordenada e precisa de rdf:Seq. Gravar qualquer um como nó de texto solto é um erro fácil de cometer. Prefixos, porém, são só convenção; um parser se guia pela URI de namespace, então dc: ligado à URI errada é outra propriedade completamente

As chaves Info e as contrapartes XMP delas se emparelham como segue. O PDF/A-1 (ISO 19005-1 §6.7.3) declara o emparelhamento como regra de conformidade: quando os dois armazenamentos estão presentes, os valores precisam ser equivalentes

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

Edições à mão e mesclagens são onde os dois divergem. Uma mesclagem pode herdar o /Info de uma entrada e o packet XMP de outra. A regra mais segura é tratar o XMP como a fonte da verdade, manter o /Info para consumidores mais velhos, e regenerar ambos a partir de um único conjunto de valores sempre que um deles mudar

Gravando os dois armazenamentos a partir de um conjunto de propriedades

O HotPDF constrói os dois armazenamentos a partir das mesmas propriedades do documento. Title, Author, Subject e Keywords vão para o /Info; quando EnableXMPMetadata é True, o EndDoc também grava um packet XMP a partir desses mesmos valores, com dc:title como rdf:Alt e dc:creator como rdf:Seq, e o liga do /Metadata do catálogo. EnableXMPMetadata tem padrão False, e vale ligá-lo para qualquer coisa que saia das suas mãos: um arquivo com só /Info é invisível para toda ferramenta que lê apenas XMP. CustomXMP substitui o packet gerado pelo seu próprio texto quando você precisa de schemas extras

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;
    // Um único conjunto de valores alimenta /Info e o packet 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;   // salvo como /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;

Editar um arquivo existente é diferente. Num documento carregado, SetLoadedTitle, SetLoadedAuthor, SetLoadedCreator e os irmãos deles gravam a entrada do dicionário Info, e o stream XMP é um objeto separado que você substitui com SetLoadedXMPMetadata. Mude o autor com uma chamada e não com a outra, e o SaveLoadedDocument produz exatamente o arquivo de dois autores do parágrafo de abertura

A árvore de tópicos (outline) por trás do painel de marcadores

O painel de marcadores é reconstruído a partir de uma árvore duplamente encadeada de dicionários, o outline do documento (ISO 32000-1 §12.3.3, Tables 152 e 153). Não existe nenhum array de marcadores em lugar nenhum do arquivo; o viewer reconstrói o painel seguindo referências do /Outlines do catálogo

Diagrama PDF da árvore de outline do PDF mostrando os links Parent, First, Last, Prev, Next e Count que reconstróem o painel de marcadores do visualizador
O painel de marcadores é reconstruído a partir de uma árvore de outline duplamente encadeada costurada por Parent, First, Last, Prev, Next e Count. Um link desatualizado esconde um ramo inteiro sem erro nenhum
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

As invariantes se leem direto dos links. Todo item aponta de volta para o /Parent dele. Irmãos se encadeiam por /Prev e /Next; o primeiro filho não tem /Prev, o último não tem /Next. Um pai nomeia as duas pontas dele por /First e /Last, e tudo no meio existe só como um passo na caminhada de irmãos. Quebre um link e os viewers mostram o que ainda conseguem alcançar: um /Next velho corta um capítulo curto, enquanto um /Last que diverge do fim da cadeia deixa viewers diferentes desenhando árvores diferentes

/Count surpreende as pessoas porque guarda estado do painel. Na raiz ele é o número de itens visíveis em todos os níveis. Num item, valor positivo significa que o item está aberto e dá o número de descendentes visíveis; valor negativo significa que está fechado, e a magnitude dele é quantos descendentes apareceriam ao expandir. No exemplo a raiz guarda 3: um capítulo mais os dois filhos abertos dele. Um gerador que grava todo /Count como total positivo reabre em silêncio cada ramo que o autor colapsou

No HotPDF, OutlineRoot é um THPDFDocOutlineObject, e AddChild(Title, X, Y) anexa um item, liga os links /Parent, /Prev, /Next, /First e /Last dele, e o aponta para a página atual como [page /XYZ left top 0]. A propriedade Opened decide o sinal, e o escritor recalcula todo /Count a partir da árvore pronta no EndDoc, então definir Opened depois de adicionar os filhos é ok. InsertBefore, InsertAfter e MoveTo reestruturam a árvore com a mesma contabilidade

Destinos: onde um clique de fato cai

Marcadores e anotações de link resolvem ambos para um destino (ISO 32000-1 §12.3.2, Table 151), e um destino é mais que um número de página. É um array que nomeia um objeto de página, depois um tipo de ajuste que diz ao viewer como enquadrá-lo. O cavalo de batalha é [page /XYZ left top zoom]. Qualquer um dos três operandos pode ser null, significando "mantenha o que o leitor tinha", e um zoom de 0 significa o mesmo que null. Então [page /XYZ null null null] muda a página e nada mais, que é geralmente o que um link "ir para a página" deve fazer

A família fit troca posicionamento exato por resiliência. [page /Fit] encaixa a página inteira na janela, [page /FitH top] encaixa a largura da página com uma borda superior dada, e [page /FitR left bottom right top] dá zoom num retângulo para encher a vista. Estes calculam a escala da geometria da página na hora do clique; um /XYZ com zoom gravado o mantém mesmo depois da página ser redimensionada. Para um sumário, /FitH na borda superior da seção é o padrão melhor

As coordenadas são onde as pessoas se queimam. Números de destino estão no espaço de usuário padrão, origem no canto inferior esquerdo e y crescendo para cima (§8.3.2.3), enquanto hábitos de layout de tela medem do topo. As chamadas estilo canvas do HotPDF (TextOut, retângulos de link, o X e o Y de AddChild) aceitam coordenadas de cima para baixo e convertem para você. RegisterNamedDestination grava o Left e o Top dele no array de destino como dados, então eles já precisam estar no espaço de usuário PDF:

// Call between BeginDoc and EndDoc, once pages 0..2 exist
procedure AddContentsLinks(PDF: THotPDF);
var
  R: TRect;
begin
  // Named destination 50 pt below the top of page 1.
  // Top is PDF user space, measured up from the bottom edge
  PDF.CurrentPageNumber := 1;
  PDF.RegisterNamedDestination('chapter-1', 1, dfmFitH,
    0, PDF.CurrentPage.Height - 50, 0, 0, 0);

  // Contents entries on page 0; rectangles use top-down coordinates
  PDF.CurrentPageNumber := 0;
  R := Rect(50, 175, 545, 209);
  PDF.CurrentPage.AddNamedDestinationLink(R, 'chapter-1', 'Chapter 1: Results');

  // YPos = -1 writes [page /XYZ null null null]: change page, keep the 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;

Passe o valor estilo tela 50 como Top e o link cai 50 pontos acima da borda inferior, mostrando o rodapé em vez do título. Um destino nomeado vive na árvore de nomes /Dests do catálogo e todo link resolve pelo nome, então com dezenas de links para os mesmos capítulos é a escolha certa. Mova uma seção e você edita uma entrada em /Dests; todo link que a nomeia segue. A ordem de páginas de que esses destinos dependem está coberta em por que a ordem da árvore de páginas importa

Anotações: objetos interativos fora do content stream

Uma anotação fica numa página sem pertencer ao content stream dela (ISO 32000-1 §12.5). Links, sticky notes, realces, widgets de formulário, ícones de anexo de arquivo e carimbos vivem todos no array /Annots da página deles. Remova uma entrada e a anotação desaparece, enquanto o content stream segue byte a byte o mesmo

Toda anotação divide um núcleo pequeno (Table 164): /Subtype nomeia o tipo, /Rect dá a bounding box no espaço de usuário padrão, e /Contents guarda texto que serve dobrado como a descrição alternativa que tecnologia assistiva lê em voz alta. Anotações de link (§12.5.6.5, Table 173) vêm em duas formas:

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] está ali por um motivo: a borda padrão é [0 0 1], uma moldura de um ponto que muitos viewers desenham em volta de todo link. /Dest e /A são mutuamente exclusivos, e o /S da ação escolhe o comportamento: /GoTo dentro deste arquivo (§12.6.4.2), /GoToR para uma página noutro PDF (§12.6.4.3), /URI para um endereço web (§12.6.4.7), /Launch para abrir uma aplicação ou arquivo externo (§12.6.4.5). Deixe /Launch de fora de documentos que você distribui: viewers comumente o bloqueiam ou põem um prompt de segurança na frente dele, e o PDF/A o proíbe de cara (ISO 19005-1 §6.6.1). O argumento Description das chamadas de link acima preenche o /Contents, e com o PDFUACompliance do HotPDF ligado uma descrição vazia levanta exceção, já que o PDF/UA-1 exige essa entrada em todo link

Anotações de marcação como realces e notas, e shapes como /Square, adicionam uma reviravolta: o tipo delas não fixa a aparência delas. Sem um appearance stream (a entrada /AP, §12.5.5), cada viewer desenha a própria rendição. A ISO 32000-2 torna o /AP obrigatório para a maioria dos tipos de anotação, com Popup e Link entre as exceções. Anexos de arquivo reutilizam a mesma maquinaria: um stream de arquivo embutido embrulhado numa file specification (§7.11), exposto ou como anotação /FileAttachment numa página ou pela árvore de nomes /EmbeddedFiles sob o /Names do catálogo

Como conferir essa camada antes de um arquivo ir para produção?

Duas conferências pegam a maior parte: clique pelo painel de marcadores e por uma amostra de links num viewer de verdade, depois leia os metadados de volta com um programa, já que nada de clicar revela um /Info que diverge do XMP. As chamadas de documento carregado do HotPDF cobrem as duas metades. GetLoadedOutlineTitlesDeep percorre a cadeia /First e /Next em profundidade, a mesma caminhada que um viewer faz, então um ramo cortado por um link velho também falta no resultado dela. FindLoadedOutlineByTitle resolve a página alvo de um marcador e reporta -1 quando o alvo é um destino nomeado ou não resolve para uma página

// StripXmlTags is a helper you add yourself; it is not a library call.
// GetLoadedXMPValue returns the element's inner markup, so dc:creator
// comes back wrapped in rdf:Seq and 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;

Uma contagem de marcadores abaixo do que você escreveu aponta para uma cadeia de irmãos quebrada. Um -1 num marcador que usa destino nomeado é esperado, e FindLoadedOutlineByTitle para na primeira casagem, então títulos duplicados reportam o alvo do primeiro. GetLoadedXMPValue é uma busca de texto simples no packet, e a comparação é byte a byte, então com nomes não ASCII olhe como os dois valores voltam antes de confiar numa divergência

Tudo nessa camada é contabilidade de referências, e contabilidade é a parte que vale entregar a uma biblioteca. O HotPDF Delphi Component para Delphi e C++Builder grava os dois armazenamentos de metadados a partir de um único conjunto de propriedades, costura os links do outline conforme você adiciona itens, e anexa anotações de link com as ações e descrições delas; a API de documento carregado dele lê as mesmas estruturas de volta para conferência