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

Ambas podem conter um título. Nada na especificação obriga que concordem. Os 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á. Por isso, se atualizar apenas /Info — que é o que faz a grande maioria do código de definição de metadados PDF — um leitor que confie em XMP continuará a mostrar o valor antigo, e um verificador PDF/A assinalará a divergência. A operação correcta em qualquer ficheiro que já tenha um pacote XMP é uma escrita dupla: alterar a entrada Info e regenerar o XMP, para que os dois fiquem consistentes. O HotPDF disponibiliza-lhe as duas metades; a disciplina de as usar em conjunto é consigo

Editando o dicionário Info

Os auxiliares do lado Info são leves e previsíveis. SetLoadedTitle, SetLoadedAuthor, SetLoadedSubject, SetLoadedKeywords, SetLoadedCreator, e SetLoadedProducercada um recebe uma única AnsiString e escreve a chave correspondente no dicionário Info carregado, substituindo o valor se a chave existir e acrescentando-o se não existir. Para remover uma chave por completo — por exemplo, um /Creator que revele a sua ferramenta interna — chame RemoveLoadedInfoKey com o nome cru da chave. Nenhum destes métodos toca no XMP; actuam apenas sobre o /Info objecto que LoadFromFile localizou quando analisou o ficheiro

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;

Um pormenor importante: estes métodos recebem AnsiString. Para títulos ASCII isso não é problema, mas as strings de texto PDF que necessitem de caracteres não latinos têm de ser codificadas conforme a especificação exige — UTF-16BE com byte-order mark, ou PDFDocEncoding — antes de lhas passar. A biblioteca escreve os bytes que lhe entregar num objecto de string; não adivinha a codificação por si. Se os seus títulos forem apenas em inglês, ignore isto. Se incluírem caracteres acentuados ou CJK, codifique de forma explícita e teste num visualizador real

Reescrevendo o pacote XMP

SetLoadedXMPMetadataÉ a outra metade da escrita dupla. Passe-lhe o pacote XMP completo como AnsiString e ele faz uma de duas coisas: se o Catalog já referir um stream /Metadatastream, substitui o conteúdo desse stream no lugar, mantendo o mesmo número de objecto; se não existir stream de metadados, cria um, marca-o /Type /Metadata e /Subtype /XML, atribui-lhe um número de objecto e liga-o a partir do Catalog. Em qualquer dos casos, acaba com um objecto de metadados válido que os visualizadores irão ler

Você fornece o XML, o que significa que controla o esquema — dc:title, dc:creator, xmp:CreatorTool e assim por diante. Isso junta poder e responsabilidade: a biblioteca não analisa nem valida o seu pacote e grava os bytes sem compressão, sem aplicar filtro de stream. Um pacote mal formado passará pela chamada e surgirá mais tarde como uma reclamação de metadados corrompidos. Construa o XML com cuidado e replique exactamente os valores que escreveu no dicionário Info, para que as duas vistas 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
  // 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 ficheiro que já tem pacote XMP, volta para o bug de valor silenciosamente obsoleto que esta secçã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 ao 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. SetLoadedPageModegrava /PageMode como um objecto 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). SetLoadedPageLayoutgrava /PageLayout do mesmo modo - 'SinglePage', 'OneColumn', 'TwoColumnLeft' e o restante. Ambos recebem o nome sem barra inicial; a biblioteca acrescenta-a na saída

SetLoadedLanguagegrava a entrada /Lang do Catalog, a etiqueta de língua natural do documento como um todo - 'en-US', 'de-DE' uma tag BCP 47. Repare na diferença de tipo que costuma confundir as pessoas: /PageMode e /PageLayout são objectos PDF do tipo name, enquanto /Lang é uma string. O HotPDF trata disto correctamente por dentro, mas se alguma vez inspeccionar a saída verá /PageMode /UseOutlines diante de /Lang (en-US), e agora já sabe porquê. A entrada /Lang importa mais do que parece: é o que a tecnologia de apoio lê para escolher a pronúncia e é um requisito obrigatório 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 — uma gralha num cabeçalho, um capítulo renumerado depois de o outline ter sido gerado. SetLoadedOutlineTitlerecebe 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. Só altera o título; o destino, o estado aberto/fechado e a estrutura dos filhos ficam 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 precisamente porque nunca toca nos contadores estruturais. Apagar uma entrada de outline é o caso que costuma correr mal, e vale a pena entendê-lo mesmo quando só está a renomear, porque isso mostra o que não deve editar à mão. Cada nó de outline traz um /Count e, segundo a ISO 32000-1 §12.3.3, esse contador não é o número de filhos imediatos. É o número total de descendentes visíveis: um /Count positivo de N significa que N descendentes estão actualmente 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 ser simplesmente decrementada em um; tem de ser recalculada somando, para cada nó de nível superior que permaneça, "um pelo próprio nó mais o seu /Count positivo", ignorando os descendentes de qualquer nó recolhido (com contagem negativa). Se errar isto, o total de bookmarks mostrado ao leitor deriva — salta mais do que um por cada eliminação. Renomear contorna tudo isto, o que é mais um motivo para preferir o auxiliar direccionado em vez de mexer no dicionário por si

Como a gravação permanece no lugar

Toda a edição acima altera objectos em memória; nada chega ao disco até SaveLoadedDocument ser executado. A razão pela qual esta abordagem é barata é que a gravação não regenera o documento — preserva os números de objecto existentes e a estrutura que o HotPDF analisou ao carregar, escrevendo de volta o mesmo grafo com o pequeno conjunto de objectos alterados e recém-atribuídos. É isso que impede uma passagem de metadados de reescrever o ficheiro inteiro, e é o mesmo mecanismo de actualização in-place que faz funcionar object streams e incremental updates. Se os seus ficheiros de origem vêm do Word ou de outra suite de escritório, a disposição dos objectos tem as suas próprias particularidades, que vale a pena conhecer antes de editar; o artigo sobre hybrid-reference cross-reference streams em PDFs do Office explica como esses ficheiros estão estruturados e o que sobrevive a uma ida e volta

Há dois limites a respeitar. Primeiro, este é um modelo de edição no local, não uma ferramenta de redacção ou saneamento: remover uma chave do Info remove essa chave, mas não limpa valores antigos que possam persistir numa geração anterior de actualização incremental do mesmo ficheiro. Se o seu requisito for a remoção real de metadados sensíveis, isso é uma operação diferente e mais pesada. Segundo, a escrita do XMP é literal — a biblioteca confia no seu XML e não o valida — por isso, para qualquer coisa destinada a PDF/A ou a um validador rigoroso, gere o pacote a partir de um modelo fiável e verifique a saída. Usado dentro destes limites, a edição de metadados no local é a ferramenta certa: corrige os poucos bytes errados e deixa exactamente como o produtor original escreveu os noventa e nove por cento do ficheiro que já estavam correctos

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