Artigo Técnico

PDF ID Determinístico em Delphi para Builds Reproduzíveis

Gere a mesma fatura duas vezes do mesmo template e dados, então faça o diff dos dois PDFs. Dois lugares diferem: as strings hex no array /ID do trailer e a string ModDate no dicionário de informações do documento, ambos tirados do relógio. Na losLab PDF Library, SetDeterministicDocumentID(1) mais um ModDate fixo tornam os arquivos byte-idênticos, para um documento sem UUIDs XMP por execução ou anexos

A primeira tentativa óbvia é travar as datas e rodar de novo. CreationDate e ModDate agora batem, e os hashes ainda diferem, agora só em duas strings hex de 32 dígitos perto do fim absoluto do arquivo, dentro de /ID [<…> <…>]. Esse identificador é computado separado dos metadados, e até que ele pare de depender da hora do save, um cache de build indexado pelo hash do arquivo nunca acerta e um store endereçável por conteúdo guarda um blob por execução em vez de um por documento

Para que serve o array de ID do trailer

O /ID do trailer identifica um arquivo; ele não faz checksum do conteúdo. A ISO 32000-1 §14.4 o define como um array de duas byte strings. A primeira é o identificador permanente, atribuído quando o documento é criado e destinado a sobreviver a toda edição posterior. A segunda é o identificador mutável, que um escritor atualiza sempre que modifica o arquivo. Um sistema de gerenciamento de documentos compara o par para decidir se dois arquivos são revisões de um documento ou dois documentos sem relação. A Tabela 15 na §7.5.5 torna a entrada obrigatória quando o trailer carrega /Encrypt e opcional caso contrário, e a §14.4 nota que deixá-la de fora pode atrapalhar workflows que dependem da identidade do arquivo

A especificação nunca diz como computar o valor. Ela sugere um digest MD5 sobre coisas como a hora atual, o caminho do arquivo, o tamanho do arquivo e os valores no dicionário de informações, e o relógio é o ingrediente que torna o resultado único. Unicidade é exatamente o que você quer de um marcador de identidade e exatamente o que destrói a reprodutibilidade. Por padrão a losLab PDF Library segue essa sugestão na sua forma mais simples, um MD5 do timestamp atual, então dois documentos criados com um segundo de diferença recebem identificadores permanentes diferentes mesmo quando todo outro byte bate

Diagrama comparando execuções de build Delphi idênticas cujos IDs de trailer PDF padrão baseados no relógio diferem, contra saída determinística byte-idêntica
O id de trailer padrão dobra o relógio da parede na identidade do arquivo enquanto o modo determinístico o deriva de uma semente estável

Ligando um identificador reprodutível

O modo determinístico é um switch por documento e vem desligado por padrão, então a saída existente não muda até você pedir. O SetDeterministicDocumentID recebe 0 ou 1 e devolve 1 quando o valor foi aceito, 0 para qualquer outra coisa; o GetDeterministicDocumentID reporta a configuração atual. O SetDocumentIDSeed fornece uma string de semente explícita, e com uma semente no lugar o identificador é um MD5 dessa string, gravado nos dois elementos do array. O GetDocumentFileID lê o /ID[0] de volta depois do save, o que serve para log ou para um assert de teste

var
  Lib: TPDFlib;
  FileID: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetDeterministicDocumentID(1);        // logo depois do Create
    Lib.SetDocumentIDSeed('invoice-4471-rev3');
    Lib.SetInformation(7, 'D:20260101000000Z');   // CreationDate
    Lib.SetInformation(8, 'D:20260101000000Z');   // ModDate
    Lib.SetOrigin(1);
    Lib.DrawText(100, 700, 'Invoice 4471');
    Lib.SaveToFile('invoice.pdf');
    FileID := Lib.GetDocumentFileID;          // mesmo valor em toda execução
  finally
    Lib.Free;
  end;
end;

O identificador é computado na hora do save. Ligar o switch no fim da construção do documento ainda funciona, e uma semente alterada chega ao arquivo no próximo save completo: salve com a semente A, salve de novo com a semente B, e as duas saídas carregam identificadores diferentes, enquanto voltar para a semente A devolve o valor original

Ligue o switch imediatamente depois de criar o objeto, porém. Fontes, imagens, graphics states e outros recursos de página recebem nomes no dicionário de recursos da página, e enquanto o modo determinístico está ligado esses nomes vêm de um contador. Qualquer coisa adicionada antes do switch mantém um nome gerado aleatoriamente, o que deixa o identificador estável mas muda alguns bytes nos content streams a cada execução

Por que o identificador ainda muda quando nenhuma semente é definida?

Porque o dicionário de informações faz parte do hash. Deixe a semente vazia, ou passe uma string vazia ao SetDocumentIDSeed para limpar uma, e a losLab PDF Library deriva o identificador do próprio arquivo: ela roda o corpo serializado por um SHA-256 enquanto o grava e usa os primeiros 16 bytes do digest para os dois elementos do /ID. Qualquer mudança no conteúdo da página muda o identificador, que é o ponto inteiro. A pegadinha é que CreationDate e ModDate vivem nesse corpo também, e as rotinas de save estampam o ModDate com a hora atual a menos que o chamador a tenha definido

Fluxo de decisão da PDF Library for Delphi mostrando o SetDocumentIDSeed tendo prioridade sobre a semente derivada do estado do documento usada para IDs de PDF determinísticos
Uma semente explícita sobrepuja todo o resto enquanto a rota derivada hasheia entradas do dicionário de informações incluindo CreationDate e ModDate
Lib.SetDeterministicDocumentID(1);
Lib.SetDocumentIDSeed('');   // deriva o /ID dos bytes salvos
// O hash do corpo cobre o dicionário Info, então as datas devem ser
// entradas do build. A chave 8 também impede o save de estampar o ModDate.
Lib.SetInformation(2, 'Quarterly Report');        // Title
Lib.SetInformation(5, 'reporting-service 4.2');   // Creator
Lib.SetInformation(7, 'D:20260101000000Z');       // CreationDate
Lib.SetInformation(8, 'D:20260101000000Z');       // ModDate
Lib.SaveToFile('report.pdf');

A chave 8 faz papel duplo aqui, e é a linha que as pessoas mais deixam de fora. Definir o ModDate por meio do SetInformation marca a data como fornecida pelo chamador, e a partir daí o SaveToFile e o SaveToString a deixam em paz. Sem isso todo save grava um timestamp fresco, o corpo hasheado muda, e o identificador derivado do conteúdo muda com ele. Tire as datas do registro de origem ou de um epoch fixo. Now não tem lugar em um build reprodutível

Algumas coisas ficam de fora do hash de propósito. Nomes de recursos gerados aleatoriamente e o UUID em uma entrada XMP xmpMM:InstanceID são normalizados antes do hash, então duas execuções que diferem só nesses tokens recebem o mesmo /ID. Os bytes do arquivo ainda diferem nesses lugares, então um documento estilo PDF/A com um UUID de instância fresco por execução te dá um identificador reprodutível mas não um arquivo byte-idêntico. Anexos embutidos são outra fonte de drift: o AddAssociatedFileFromString estampa o /ModDate do próprio anexo com a hora atual, e essa data é hasheada como qualquer outro byte do corpo

Semente explícita ou hash do conteúdo

Para artefatos de build deixaríamos a semente vazia. Um identificador derivado do conteúdo muda exatamente quando os bytes mudam, então um cache indexado por ele nunca serve um arquivo obsoleto, e você não ganha uma segunda chave para manter em sincronia com os dados. Uma semente explícita serve a um trabalho diferente: um acervo downstream que trata o /ID[0] como identidade do documento e espera o mesmo identificador entre regenerações da mesma fatura, mesmo depois de o template mudar. Isso casa melhor com a intenção da §14.4, ao preço de que dois arquivos visivelmente diferentes podem compartilhar um identificador

Uma consequência da rota de conteúdo merece uma decisão deliberada. Quando você carrega um PDF existente e o salva por completo com o modo determinístico ligado, o /ID de entrada é descartado e o novo depende só do que for gravado. Dois arquivos de origem que diferem só em seus identificadores de trailer saem com o mesmo /ID. Isso é o que um passo de build normalizador quer. Se você está editando o documento de um cliente e a identidade dele tem que sobreviver, deixe o modo determinístico desligado para esse trabalho ou salve-o incrementalmente

Por que reescrever o ID quebra um PDF criptografado?

Porque em um documento criptografado o /ID[0] é material de chave. O Algoritmo 2 da ISO 32000-1 §7.6.3.3 alimenta o primeiro elemento do identificador de arquivo na computação da chave de criptografia para o manipulador de segurança padrão nas revisões 2 a 4, junto com a senha com padding, o valor /O e os bits de permissão. Essa chave então produz a string /U que um leitor confere ao abrir. A chave é derivada quando você chama Encrypt ou carrega um arquivo criptografado, antes de qualquer save, então substituir o identificador durante o save produziria um arquivo bem formado cuja checagem de senha falha para todo mundo, incluindo você

A losLab PDF Library portanto atualiza o identificador determinístico apenas em saves completos não criptografados. Um documento criptografado mantém o /ID que já tinha, e o switch não tem efeito nessa rota. Reabrir tal arquivo com a senha de usuário funciona como sempre. Não coloque saída criptografada em uma comparação de bytes também: criptografia AES sob a §7.6.2 começa toda string e stream criptografados com um vetor de inicialização aleatório de 16 bytes, então duas criptografias do mesmo documento nunca batem byte a byte. Se você precisa de uma verificação de regressão ali, descriptografe ambos e compare o que volta. Tratamento de revisões e bits de permissão são cobertos com mais profundidade na caminhada de auditoria de criptografia e permissões PDF

PDF Library for Delphi: mapa de guard rails do ID de PDF determinístico cobrindo saves completos, documentos criptografados, atualizações incrementais e a rota de restauração de criptografia
Só saves completos não criptografados podem atualizar o id, já que arquivos criptografados tratam o /ID[0] como material de chave e revisões anexadas têm que continuar vinculadas

Saves incrementais mantêm o identificador original

Uma atualização incremental deixa todo byte anterior do arquivo no lugar e anexa uma nova revisão depois dele. A permanência do /ID[0] é o que diz a um consumidor que a revisão anexada pertence ao mesmo documento que as anteriores. Reescrevê-lo contradiria as revisões já no arquivo e socaria workflows de assinatura, já que uma assinatura cobre um intervalo de bytes de uma revisão específica. O modo append nunca toca no identificador no modo determinístico, o que mantém intacto o comportamento descrito no artigo sobre atualizações incrementais de PDF e append to stream

Mantendo a saída reprodutível ao longo do tempo

Toda mudança posterior no gerador é uma chance de colocar um valor de relógio de volta no arquivo: um watermark que imprime a hora de geração, um caminho de código que anexa um arquivo, uma opção de save diferente. Opções de save importam tanto quanto conteúdo: empacotar objetos em compressed object streams produz um layout de arquivo completamente diferente da tabela de referências cruzadas clássica, como explicam as notas sobre object streams e cross reference streams, então mantenha essas configurações fixas por pipeline. A defesa mais barata é um teste que constrói o mesmo documento duas vezes em memória e compara os bytes

function BuildQuote(const Seed: WideString): AnsiString;
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetDeterministicDocumentID(1);
    Lib.SetDocumentIDSeed(Seed);
    Lib.SetInformation(7, 'D:20260101000000Z');
    Lib.SetInformation(8, 'D:20260101000000Z');
    Lib.SetOrigin(1);
    Lib.DrawText(100, 700, 'Quote 8812');
    Result := Lib.SaveToString;
  finally
    Lib.Free;
  end;
end;

// Duas builds independentes devem dar uma única sequência de bytes
if BuildQuote('quote-8812') = BuildQuote('quote-8812') then
  WriteLn('reproducible')
else
  WriteLn('nondeterminism leaked into the output');

O mesmo harness pode comparar uma execução serial e uma paralela do RecompressStreams sobre a mesma origem: carregue-a em duas instâncias de TPDFlib, habilite IDs determinísticos e trave o ModDate nas duas, defina SetOptimizationWorkers(1) em uma e uma contagem maior de workers na outra, e faça o diff dos dois resultados de SaveToString. Com o relógio fora dos dois arquivos, qualquer diferença que reste vem da própria recompressão

Identificadores de trailer reprodutíveis, controle de metadados e salvamento incremental fazem todos parte da losLab PDF Library para Delphi e C++Builder, cuja referência também lista as opções de criptografia mencionadas aqui