Artigo Técnico

ID Determinístico de PDF em Delphi para Builds Reprodutíveis

A losLab PDF Library consegue produzir output PDF byte a byte idêntico para entradas idênticas assim que chamar SetDeterministicDocumentID(1). Por predefinição, o array /ID do trailer é um digest MD5 do relógio do sistema, pelo que duas execuções do mesmo gerador diferem em pelo menos esses bytes. O modo determinístico deriva /ID a partir de uma semente estável, o que restaura os builds reprodutíveis

O sintoma costuma surgir na integração contínua antes de alguém ir à procura dele. O modelo não mudou, o registo de entrada não mudou, os tipos de letra não mudaram, e o PDF gerado continua a resultar num hash diferente a cada execução do pipeline. As caches de build nunca acertam. O armazenamento com endereçamento por conteúdo acumula um blob novo por build noturno. As diferenças de regressão ao nível do byte acendem-se em ficheiros que ninguém tocou. Persiga a diferença até aos bytes reais e é quase sempre a mesma mão-cheia de dígitos hexadecimais sentados no trailer do ficheiro

Para que serve o array de ID do trailer

O /ID do trailer é um marcador de identidade do ficheiro, não uma soma de verificação do conteúdo. A ISO 32000-1 §14.4 define-o como um array de duas strings de bytes: o primeiro elemento é o identificador permanente atribuído quando o documento é criado e destina-se a sobreviver a cada edição posterior, e o segundo elemento é o identificador de mudança que um escritor atualiza sempre que o ficheiro é modificado. Juntos, permitem a um sistema decidir se dois ficheiros são revisões de um documento ou dois documentos não relacionados. A §7.5.5 torna a entrada efetivamente obrigatória na prática, uma vez que o trailer tem de conter /ID sempre que também contenha /Encrypt

Nada na especificação diz como calcular o valor. A recomendação é um digest de coisas como a hora atual, o caminho do ficheiro, o tamanho do ficheiro e o dicionário de informação do documento, e o relógio do sistema é o ingrediente que torna o resultado único. Essa é exatamente a propriedade que se quer para identidade e exatamente a propriedade que destrói a reprodutibilidade, razão pela qual isto tem de ser um comutador explícito em vez de uma mudança silenciosa de comportamento

Por que produz o mesmo build um PDF diferente de cada vez?

Porque o identificador predefinido é derivado do momento da geração. Historicamente, a losLab PDF Library construía as strings /ID a partir de um MD5 do timestamp atual, pelo que um documento criado duas vezes com um segundo de intervalo transporta dois identificadores permanentes diferentes mesmo quando todos os outros bytes do ficheiro são idênticos. O custo a jusante é real: um sistema de build que indexa artefactos por hash nunca consegue reutilizar um passo de PDF, um armazenamento de objetos com deduplicação mantém uma cópia por build em vez de uma cópia por documento, e um revisor a olhar para uma diferença binária tem de provar que a única alteração é ruído antes de confiar no resto da diferença. A geração determinística de /ID existe para remover esse ruído, no mesmo espírito do trabalho de estabilidade de layout descrito nas notas sobre object streams e cross reference streams

Mudar para um identificador reprodutível

O modo determinístico é opcional, por documento, e desativado por predefinição, pelo que o output existente permanece inalterado até que o peça. SetDeterministicDocumentID aceita 0 ou 1 e devolve 1 quando o valor foi aceite, 0 para qualquer valor fora do intervalo; GetDeterministicDocumentID reporta o estado atual. SetDocumentIDSeed fornece uma string de semente explícita que prevalece sobre tudo o resto, e passar uma semente vazia reverte para a semente derivada. GetDocumentFileID lê de volta /ID[0] após a gravação, para que possa registá-lo ou verificá-lo com uma asserção

var
  Lib: TPDFlib;
  FileID: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetDeterministicDocumentID(1);
    Lib.SetDocumentIDSeed('invoice-4471-rev3');
    Lib.SetOrigin(1);
    Lib.DrawText(100, 700, 'Invoice 4471');
    Lib.SaveToFile('invoice.pdf');
    FileID := Lib.GetDocumentFileID;   // identical on every run
  finally
    Lib.Free;
  end;
end;

A atualização acontece no momento da gravação, não quando ativa a opção, pelo que ativar o modo determinístico tarde na construção de um documento ainda produz efeito. Isso também significa que uma semente alterada chega ao ficheiro na gravação completa seguinte: defina a semente A, grave, defina a semente B, grave, e os dois ficheiros transportam identificadores diferentes, enquanto restaurar a semente A restaura o valor original. Uma semente explícita é a escolha certa sempre que o seu documento tiver uma chave estável natural, como um número de fatura, uma revisão de registo ou um identificador de commit do git, porque desacopla o identificador dos metadados incidentais

De onde vem a semente quando não fornece uma?

Sem uma semente explícita, a losLab PDF Library deriva uma a partir do estado do documento que deve ser invariante entre regenerações idênticas: o cabeçalho de versão do PDF, o número de páginas, e cada entrada no dicionário de informação do documento. Os valores de string e de nome são utilizados literalmente, outros tipos de objeto contribuem com a sua forma serializada, e o conjunto é submetido a hash para as strings /ID. A consequência importante é que CreationDate e ModDate fazem parte do dicionário de informação e, por isso, fazem parte da semente por conceção. Duas execuções só obtêm o mesmo identificador quando produzem genuinamente os mesmos metadados de documento

Lib.SetDeterministicDocumentID(1);
// No SetDocumentIDSeed: the seed is derived from document state,
// so the timestamps in the Info dictionary have to be pinned.
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');

Fixar ModDate com a chave 8 tem uma dupla função, e é essa a parte que apanha as pessoas desprevenidas. Um /ID determinístico por si só não torna o ficheiro byte a byte idêntico, porque o percurso de gravação carimba ModDate com a hora atual a menos que quem chama a tenha definido explicitamente. Definir a chave 8 marca o valor como fornecido por quem chama e suprime esse carimbo. Se quiser um ficheiro reprodutível, e não apenas um identificador reprodutível, trate os timestamps de metadados como entradas de build: derive-os do registo de origem ou de uma época fixa, nunca de Now

Por que quebra reescrever o ID um PDF encriptado?

Porque /ID[0] não é apenas metadados num documento encriptado, é material de chave. O Algoritmo 2 da ISO 32000-1 §7.6.3.3 introduz o primeiro elemento do identificador de ficheiro no cálculo da chave de encriptação para o handler de segurança standard nas revisões 2 a 4, juntamente com a palavra-passe com padding, o valor /O e os bits de permissão. A chave derivada produz então a string de validação /U que um leitor verifica ao abrir, e a chave do ficheiro é derivada e colocada em cache quando chama Encrypt ou quando um documento encriptado é carregado, ambos os casos acontecendo antes da gravação. Reescrever o identificador durante a gravação emitiria por isso um ficheiro estruturalmente válido cuja verificação /U falha ao reabrir: não uma corrupção subtil, mas um documento que ninguém consegue abrir, incluindo você. É por isso que a atualização determinística está restringida a documentos que não transportam estado de encriptação, e por que um documento encriptado mantém o /ID que já tinha, em modo determinístico ou não, e a definição simplesmente não tem efeito nesse percurso. O tratamento de revisões relacionado e a semântica de permissões estão cobertos no percurso sobre auditoria de encriptação e permissões de PDF. Note também que o percurso de restauro de encriptação atualiza apenas /ID[1], o identificador de mudança, exatamente como a §14.4 pretende

Por que mantêm as gravações incrementais o identificador original

A segunda fronteira é o modo de anexação. Uma atualização incremental deixa cada byte anterior do ficheiro intocado e escreve uma nova revisão a seguir, e a permanência de /ID[0] ao longo da §14.4 é o que diz a um consumidor que a nova revisão pertence ao mesmo documento que a antiga. Reescrevê-lo cortaria essa ligação, contradiria as revisões já presentes no ficheiro, e interferiria com a semântica de assinaturas, uma vez que uma assinatura cobre um intervalo de bytes de uma revisão específica de um documento específico. A losLab PDF Library atualiza por isso o identificador determinístico apenas em gravações completas e nunca durante o modo de anexação, o que mantém intacta a garantia descrita no artigo sobre atualizações incrementais de PDF e anexação a stream

Um único ponto de estrangulamento para a geração de identificadores

Toda a geração de /ID na losLab PDF Library passa agora por uma única rotina interna, NewFileIDString, que é o que torna o comutador determinístico digno de confiança em vez de um remendo num único percurso de código. A criação de documento em branco, a criação preguiçosa de um array /ID em falta quando necessário, e o percurso de restauro da impressão digital de encriptação chamam-na todos, pelo que há exatamente um lugar onde o relógio do sistema poderia voltar a infiltrar-se. Também significa que variantes futuras, como um identificador derivado do conteúdo, são uma alteração a uma única função em vez de uma auditoria a todo o serializador

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;

// Regression guard: two independent builds, one byte sequence.
if BuildQuote('quote-8812') = BuildQuote('quote-8812') then
  WriteLn('reproducible')
else
  WriteLn('nondeterminism leaked into the output');

Ligue essa comparação à sua suite de testes antes de confiar em output reprodutível seja onde for, porque falha ruidosamente no momento em que alguma funcionalidade nova reintroduz um timestamp. A reprodutibilidade é uma propriedade que decai silenciosamente de outro modo, e uma única asserção sobre duas gravações em memória custa praticamente nada a correr em cada build

A API de identificador determinístico aqui apresentada é disponibilizada com a losLab PDF Library para Delphi e C++Builder, juntamente com a referência completa de informação de documento, encriptação e gravação incremental