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

Ambos podem guardar um título. Nada na especificação obriga que concordem entre si. Visualizadores modernos e a maioria dos validadores PDF/A preferem o pacote XMP quando ele está presente e recorrem ao dicionário Info quando não está. Então, se você atualizar só o /Info — que é o que a grande maioria dos códigos de "definir metadados de PDF" faz — um leitor que confia no XMP vai continuar mostrando o valor obsoleto, e um verificador PDF/A vai sinalizar a divergência. A operação correta em qualquer arquivo que já tenha um pacote XMP é uma gravação dupla: mudar a entrada do Info e regenerar o XMP, para que os dois permaneçam consistentes. O HotPDF dá a você as duas metades; a disciplina de usá-las juntas é sua

HotPDF: diagrama de fluxo mostrando viewers e validadores PDF/A preferindo o pacote XMP ao dicionário Info, de modo que apenas uma gravação dupla com SetLoadedTitle e SetLoadedXMPMetadata mantém os metadados do PDF consistentes
Leitores modernos e validadores PDF/A consultam o XMP primeiro, então uma gravação apenas em Info deixa o título desatualizado em exibição. Espelhar cada valor em ambos os repositórios impede que as duas visões se contradigam

Editando o dicionário Info

Os auxiliares do lado Info são simples e previsíveis. SetLoadedTitle, SetLoadedAuthor, SetLoadedSubject, SetLoadedKeywords, SetLoadedCreator e SetLoadedProducer recebem cada um uma única AnsiString e gravam a chave correspondente no dicionário Info carregado, substituindo o valor se a chave existir e adicionando-a se não existir. Para remover uma chave por completo — digamos, um /Creator revelador que nomeia sua ferramenta interna — chame RemoveLoadedInfoKey com o nome puro da chave. Nenhum desses métodos toca no XMP; eles operam puramente sobre o objeto /Info que LoadFromFile localizou ao analisar o arquivo

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');  // remove o nome da ferramenta de origem
      Pdf.SaveLoadedDocument('contract-out.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

Um detalhe para manter a honestidade: esses métodos recebem AnsiString. Para títulos em ASCII isso não é problema, mas strings de texto PDF que precisam de caracteres não latinos devem ser codificadas como a especificação exige — UTF-16BE com marca de ordem de bytes, ou PDFDocEncoding — antes de você repassá-las. A biblioteca grava os bytes que você fornece dentro de um objeto string; ela não adivinha uma codificação por você. Se seus títulos são em inglês simples, ignore isso. Se carregam caracteres acentuados ou CJK, codifique deliberadamente e teste em um visualizador real

Reescrevendo o pacote XMP

SetLoadedXMPMetadata é a outra metade da gravação dupla. Passe a ela o pacote XMP completo como uma AnsiString e ela faz uma de duas coisas: se o Catalog já referencia um stream /Metadata, ela substitui o conteúdo desse stream no lugar, mantendo o mesmo número de objeto; se não houver stream de metadados, ela cria um, marca-o como /Type /Metadata e /Subtype /XML, aloca um número de objeto e o vincula a partir do Catalog. De qualquer forma, você termina com um objeto de metadados válido que os visualizadores vão ler

Você fornece o XML, o que significa que você controla o esquema — dc:title, dc:creator, xmp:CreatorTool, e assim por diante. Isso é poder e responsabilidade em uma coisa só: a biblioteca não analisa nem valida seu pacote, e grava os bytes sem compressão, sem nenhum filtro de stream aplicado. Um pacote malformado vai passar direto pela chamada e aparecer como uma reclamação de metadados quebrados mais tarde. Construa o XML com cuidado, e espelhe exatamente os valores que você gravou no dicionário Info para que as duas visões nunca se contradigam

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
  // Depois de definir o dicionário Info, espelhe os mesmos valores no 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

Diagrama mostrando um dicionário Info de PDF e um fluxo de metadados XMP ambos contendo título e autor, editados in-loco junto à árvore de outline de marcadores
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, um name
  Pdf.SetLoadedPageLayout('TwoColumnLeft'); // /PageLayout, um name
  Pdf.SetLoadedLanguage('en-US');           // /Lang, uma 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

Diagrama do HotPDF do grafo de objetos carregado em que auxiliares de metadados editam entradas Catalog, Info, Metadata e Outlines enquanto o salvamento regrava os números de objeto originais em vez de remontar o documento do zero
Os auxiliares mutam objetos individuais do grafo interpretado em memória, e SaveLoadedDocument grava esses mesmos números de volta sem alteração. Um re-layout completo renumeraria tudo, descartaria o histórico incremental e invalidaria assinaturas

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 Delphi Component padrão para Delphi e C++Builder, junto com o conjunto completo de métodos de edição de metadados, outline e Catalog