Artigo Técnico

Editar metadados de um PDF carregado em Delphi sem reescrever

Você tem dez mil PDFs de contratos vindos de uma dúzia de geradores diferentes, e o jurídico quer que todos tragam o Author correto, uma string Producer ajustada e um modo de leitura que abra o painel de bookmarks ao iniciar. A correção ingênua é carregar cada arquivo, reorganizar as páginas e gravar um documento novo. Faça isso e você jogou fora todo número de objeto existente, o histórico de atualização incremental, qualquer assinatura digital e o xref cuidadosamente ajustado que a ferramenta original emitiu. As páginas parecem idênticas e o arquivo, estruturalmente, virou outro. Para uma edição de metadados, esse é o tipo de troca errada

O caminho certo é tratar o documento carregado como um grafo de objetos que você altera no lugar: acesse o dicionário Info, o stream /Metadata e o Catalog, mude as poucas entradas que interessam e grave o resultado de volta. O HotPDF, o componente PDF nativo VCL para Delphi e C++Builder, expõe exatamente essa superfície por meio da API de gravação de documento carregado. Este artigo mostra como usar isso direito e também o erro que quase todo mundo comete: editar o dicionário Info e esquecer que existe uma segunda cópia dos mesmos metadados no XMP

Dois lugares guardam os mesmos metadados, e eles discordam

O PDF armazena as informações do documento em dois pontos paralelos, e isso está na raiz da maioria dos chamados do tipo "mudei o título, mas o Acrobat ainda mostra o antigo". O primeiro é o dicionário de informações do documento, o clássico objeto /Info com as chaves /Title, /Author, /Subject, /Keywords, /Creator e /Producer, definido em ISO 32000-1 §14.3.3. O segundo é um pacote XMP, um documento XML armazenado como um stream ligado ao Catalog em /Metadata, definido em §14.3.2 e baseado no modelo de dados XMP da Adobe

Both can hold a title. Nothing in the spec forces them to agree. Modern viewers and most PDF/A validators prefer the XMP packet when it is present and fall back to the Info dictionary when it is not. So if you update only /Info — which is what the great majority of "set PDF metadata" code does — a reader that trusts XMP will keep showing the stale value, and a PDF/A checker will flag the mismatch. The correct operation on any file that already has an XMP packet is a double write: change the Info entry and regenerate the XMP, so the two stay consistent. HotPDF gives you both halves; the discipline of using them together is on you

Editando o dicionário Info

The Info-side helpers are thin and predictable. SetLoadedTitle, SetLoadedAuthor, SetLoadedSubject, SetLoadedKeywords, SetLoadedCreator, and SetLoadedProducer each take a single AnsiString and write the corresponding key into the loaded Info dictionary, replacing the value if the key exists and adding it if it does not. To strip a key entirely — say a leaky /Creator that names your internal tooling — call RemoveLoadedInfoKey with the bare key name. None of these touch XMP; they operate purely on the /Info object that LoadFromFile located when it parsed the file

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('contract-in.pdf', '') > 0 then
    begin
      Pdf.SetLoadedTitle('Master Services Agreement 2026');
      Pdf.SetLoadedAuthor('Legal Department');
      Pdf.SetLoadedSubject('Executed contract, retention 7 years');
      Pdf.SetLoadedKeywords('contract; MSA; 2026; executed');
      Pdf.SetLoadedProducer('Acme Document Pipeline');
      Pdf.RemoveLoadedInfoKey('Creator');  // drop the originating tool name
      Pdf.SaveLoadedDocument('contract-out.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

One detail to keep honest: these take AnsiString. For ASCII titles that is a non-issue, but PDF text strings that need non-Latin characters must be encoded as the spec requires — UTF-16BE with a byte-order mark, or PDFDocEncoding — before you hand them over. The library writes the bytes you give it into a string object; it does not guess an encoding for you. If your titles are plain English, ignore this. If they carry accented or CJK characters, encode deliberately and test in a real viewer

Reescrevendo o pacote XMP

SetLoadedXMPMetadata is the other half of the double write. Pass it the full XMP packet as an AnsiString and it does one of two things: if the Catalog already references a /Metadata stream, it replaces that stream's content in place, keeping the same object number; if there is no metadata stream, it creates one, marks it /Type /Metadata and /Subtype /XML, allocates an object number, and links it from the Catalog. Either way you end up with a valid metadata object that viewers will read

You supply the XML, which means you control the schema — dc:title, dc:creator, xmp:CreatorTool, and so on. That is power and responsibility in one: the library does not parse or validate your packet, and it writes the bytes uncompressed, with no stream filter applied. A malformed packet will sail through the call and surface as a broken-metadata complaint later. Build the XML carefully, and mirror exactly the values you wrote into the Info dictionary so the two views never contradict each other

const
  XMP_TEMPLATE =
    '<?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/">' +
    '<dc:title><rdf:Alt><rdf:li xml:lang="x-default">%s</rdf:li></rdf:Alt></dc:title>' +
    '<dc:creator><rdf:Seq><rdf:li>%s</rdf:li></rdf:Seq></dc:creator>' +
    '</rdf:Description></rdf:RDF></x:xmpmeta><?xpacket end="w"?>';
begin
  // After setting the Info dictionary, mirror the same values into XMP:
  Pdf.SetLoadedTitle('Master Services Agreement 2026');
  Pdf.SetLoadedAuthor('Legal Department');
  Pdf.SetLoadedXMPMetadata(
    AnsiString(Format(XMP_TEMPLATE,
      ['Master Services Agreement 2026', 'Legal Department'])));
  Pdf.SaveLoadedDocument('contract-out.pdf');
end;

Essa ordem - Info primeiro, XMP depois e, por fim, salvar - é o padrão que vale a pena internalizar. As duas chamadas são independentes; a consistência só existe porque você passou as mesmas strings para ambas. Se você pular a chamada de XMP em um arquivo que já tem pacote XMP, volta para o bug de valor silenciosamente obsoleto que esta seção existe justamente para evitar

Diagram showing a PDF Info dictionary and an XMP metadata stream both holding title and author, edited in place alongside the bookmark outline tree
Os metadados vivem em dois lugares - o dicionário Info e o stream XMP - além das dicas de abertura no nível do Catalog e da árvore de bookmarks. Uma edição no lugar toca cada um deles sem reconstruir o documento.

Controlando como o visualizador abre o arquivo

Três entradas do Catalog decidem o que o leitor vê no instante em que o documento abre, e as três são edições de uma linha no grafo carregado. SetLoadedPageMode grava /PageMode como um objeto nome: passe 'UseOutlines' para abrir o painel de bookmarks, 'UseThumbs' para a faixa de miniaturas, 'FullScreen' para o modo de apresentação ou 'UseAttachments' para mostrar o painel de anexos (ISO 32000-1 §7.7.3.1, Tabela 28). SetLoadedPageLayout grava /PageLayout do mesmo jeito - 'SinglePage', 'OneColumn', 'TwoColumnLeft' e o restante. Ambos recebem o nome sem barra inicial; a biblioteca adiciona isso na saída

SetLoadedLanguage grava a entrada /Lang do Catalog, a marca de idioma natural do documento como um todo - 'en-US', 'de-DE', uma tag BCP 47. Observe a diferença de tipo que costuma confundir as pessoas: /PageMode e /PageLayout são objetos PDF de tipo name, enquanto /Lang é uma string. O HotPDF faz isso corretamente internamente, mas se você inspecionar a saída verá /PageMode /UseOutlines diante de /Lang (en-US), e agora já sabe o motivo. A entrada /Lang importa mais do que parece: é o que a tecnologia assistiva lê para escolher a pronúncia, e é um requisito rígido para conformidade de acessibilidade PDF/UA

if Pdf.LoadFromFile('handbook.pdf', '') > 0 then
begin
  Pdf.SetLoadedPageMode('UseOutlines');     // /PageMode, a name
  Pdf.SetLoadedPageLayout('TwoColumnLeft'); // /PageLayout, a name
  Pdf.SetLoadedLanguage('en-US');           // /Lang, a string
  Pdf.SaveLoadedDocument('handbook-tagged.pdf');
end;

Renomeando bookmarks sem mexer na árvore

Os títulos dos bookmarks são limpeza rotineira - um erro de digitação em um cabeçalho, um capítulo renumerado depois que o outline foi montado. SetLoadedOutlineTitle recebe um índice baseado em zero nas entradas de outline de nível superior e um novo título, percorre a cadeia Catalog → /Outlines/First/Next até essa posição e substitui a string /Title da entrada. Ele altera apenas o título; o destino, o estado aberto/fechado e a estrutura de filhos permanecem intactos

if Pdf.LoadFromFile('report.pdf', '') > 0 then
begin
  Pdf.SetLoadedOutlineTitle(0, 'Executive Summary');
  Pdf.SetLoadedOutlineTitle(1, 'Financial Results');
  Pdf.SaveLoadedDocument('report-renamed.pdf');
end;

Renomear é seguro justamente porque nunca toca nos contadores estruturais. Excluir uma entrada de outline é o caso que costuma pegar, e vale entender isso mesmo quando você só está renomeando, porque isso mostra o que não editar manualmente. Cada nó de outline carrega um /Count e, conforme ISO 32000-1 §12.3.3, esse contador não é o número de filhos imediatos. Ele é o número total de descendentes visíveis: um /Count positivo de N significa que N descendentes estão atualmente expostos, enquanto um valor negativo significa que o nó tem descendentes, mas está recolhido. Quando uma entrada de nível superior é removida, a contagem raiz de /Outlines não pode simplesmente ser decrementada em um; ela precisa ser recalculada somando, para cada nó de nível superior sobrevivente, "um pelo próprio nó mais o seu /Count positivo", ignorando os descendentes de qualquer nó recolhido com contagem negativa. Errar isso faz o total de bookmarks mostrado ao leitor derivar - ele salta mais de um a cada exclusão. Renomear evita tudo isso, o que é mais um motivo para preferir o auxiliar direcionado em vez de mexer no dicionário na mão

Como a gravação permanece no lugar

Toda edição acima altera objetos em memória; nada chega ao disco até SaveLoadedDocument ser executado. O motivo de essa abordagem ser barata é que a gravação não regenera o documento - ela preserva os números de objeto existentes e a estrutura que o HotPDF analisou na carga, gravando de volta o mesmo grafo com o pequeno conjunto de objetos alterados e recém-alocados. É isso que impede uma passagem de metadados de reescrever o arquivo inteiro, e é o mesmo mecanismo de atualização no lugar que faz object streams e incremental updates funcionarem. Se seus arquivos de origem vêm do Word ou de outra suíte de escritório, o layout de objetos deles tem particularidades próprias que valem conhecer antes de editar; o artigo sobre hybrid-reference cross-reference streams em PDFs do Office explica como esses arquivos são estruturados e o que sobrevive a uma ida e volta

Há dois limites a respeitar. Primeiro, este é um modelo de edição no lugar, não uma ferramenta de redação ou saneamento: remover uma chave do Info remove aquela chave, mas não apaga valores antigos que possam persistir em uma geração anterior de atualização incremental do mesmo arquivo. Se o seu requisito for remoção real de metadados sensíveis, essa é uma operação diferente e mais pesada. Segundo, a gravação do XMP é literal - a biblioteca confia no seu XML e não o valida - então, para qualquer coisa destinada a PDF/A ou a um validador rígido, gere o pacote a partir de um modelo conhecido e verifique a saída. Usado dentro desses limites, editar metadados no lugar é a ferramenta certa: corrige os poucos bytes errados e deixa exatamente como o produtor original escreveu o noventa e nove por cento do arquivo que já estava correto

A API de gravação de documento carregado mostrada aqui acompanha o HotPDF Component padrão para Delphi e C++Builder, junto com o conjunto completo de métodos de edição de metadados, outline e Catalog