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
/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
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 dictionary | XMP property |
|---|---|
/Title | dc:title (rdf:Alt) |
/Author | dc:creator (rdf:Seq) |
/Subject | dc:description (rdf:Alt) |
/Keywords | pdf:Keywords |
/Creator | xmp:CreatorTool |
/Producer | pdf:Producer |
/CreationDate, /ModDate | xmp: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
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