Artigo Técnico

PDF reproduzível no Delphi: saves byte a byte idênticos

Dois PDFs escritos pelo mesmo código de fatura com um segundo de intervalo quase nunca são o mesmo arquivo. Um compare binário primeiro tropeça dentro do Info dictionary, onde /CreationDate andou um segundo, depois no pacote XMP, e depois no /ID do trailer. Ligue AES-256 e a comparação desaba em quase todo stream, porque a file encryption key em si foi recém-sortiada. Mascarar as datas antes do compare conserta as duas primeiras diferenças e nenhuma das outras. Um teste de golden file só fica confiável quando todo valor que o writer toma de algum lugar que não é o documento é fixado no ponto em que é lido

Quatro valores que nunca vieram do documento

O HotPDF Delphi Component, como qualquer writer de PDF em conformidade, preenche quatro tipos de valor de fora do conteúdo da página: o relógio, o identificador de documento, a fonte aleatória criptográfica e a ordem em memória das entradas de dicionário. A ISO 32000-1 tem um motivo para cada um deles

  • O relógio. O Info dictionary carrega /CreationDate e /ModDate (ISO 32000-1 §14.3.3, Tabela 317) como strings D:YYYYMMDDHHmmSS com sufixo de fuso horário (§7.9.4), e o pacote XMP repete o mesmo instante como xmp:CreateDate e xmp:ModifyDate. Todos são renderizados a partir de um timestamp de criação que o componente toma de Now no momento em que é construído
  • O identificador. O array /ID do trailer (§14.4) guarda um identificador permanente e um identificador de modificação. Por padrão, o primeiro é um MD5 do nome do arquivo mais a hora atual até o milissegundo, e o segundo faz hash desse valor de novo junto com o tick count do sistema
  • Os bytes aleatórios. Um documento AES-256 precisa de uma file encryption key de 32 bytes, um validation salt de 8 bytes e um key salt de 8 bytes para /U, outro par para /O, quatro bytes de preenchimento dentro de /Perms e um vetor de inicialização de 16 bytes na frente de toda string e stream criptografados. A ISO 32000-2 §7.6.4.4.7 quer os salts aleatórios. RC4 e AES-128 derivam a chave das senhas e do primeiro elemento de /ID (ISO 32000-1 §7.6.3.3), então um identificador novo, sozinho, já basta para trocar a chave do arquivo inteiro
  • A ordem. Um dicionário PDF é um mapeamento sem ordem (§7.3.7). Um writer que percorre a lista em memória emite as chaves na ordem em que o código acidentalmente as adicionou, então dois caminhos de código que montem o mesmo dicionário de recursos em sequências diferentes produzem um arquivo legal, mas textualmente diferente
As quatro fontes de entropia que fazem dois saves do HotPDF de um documento diferirem: o timestamp de criação tomado de Now alimenta as datas D: e o pacote XMP, o /ID do trailer faz hash de nome de arquivo, relógio e tick count, o AES sorteia material de chave da fonte aleatória do sistema, e os dicionários serializam na ordem de inserção em memória
Cada fonte é legítima por conta própria e a ISO 32000-1 quer que elas estejam lá, mas juntas elas fazem o arquivo virar função de quando e onde foi escrito, e não do que ele contém

Todo o resto já decorre da entrada: content streams, dados de imagem comprimidos e offsets de cross-reference se repetem para as mesmas chamadas de desenho. Até o único campo que parece aleatório não é: o prefixo de seis letras de um subset de fonte embutida (ISO 32000-1 §9.6.4, a parte ABCDEF+ de ABCDEF+Arial) é calculado a partir do conjunto de glifos que o subset guarda, então o mesmo texto rende o mesmo prefixo em toda execução. O ruído mora nos metadados e na camada de segurança, e é por isso que uma única propriedade consegue removê-lo

O que o ReproducibleOutput fixa

Definir ReproducibleOutput := True antes do BeginDoc substitui cada uma das quatro fontes por um valor fixo, nos mesmos caminhos de código que de outro modo recorreriam ao relógio ou ao gerador aleatório. Não existe passada de limpeza depois. A propriedade vem com False por padrão, e nada na biblioteca a liga por você

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'golden-invoice.pdf';
    Pdf.ReproducibleOutput := True;     // precisa ser definido antes do BeginDoc
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(40, 40, 0, 'Invoice 2026-0042');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Sob a flag, o BeginDoc define o timestamp de criação como 1 de janeiro de 2026, 00:00 no fuso local, e como as datas do Info e as datas do XMP são renderizadas a partir desse único valor, uma única atribuição cobre todas. O mesmo ramo semeia o identificador interno de documento a partir de uma string constante em vez do nome do arquivo e do relógio. Quando o arquivo é escrito, o /ID do trailer é calculado como um MD5 sobre uma exportação canônica do grafo de objetos inteiro em que os dígitos de toda string de data D: são zerados primeiro, para que um timestamp não esgueire de volta pelo hash. Os dois elementos de /ID recebem esse valor. Um documento carregado que você criptografe sem nunca chamar BeginDoc (defina ActivateProtection depois do LoadFromFile) recebe o mesmo identificador derivado do conteúdo

Os bytes aleatórios são trocados por constantes. A file encryption key do AES-256, os quatro salts e o preenchimento de /Perms são preenchidos com $5A, e todo vetor de inicialização CBC, tanto para strings e streams AES-128 quanto AES-256, vira o padrão 14 * (1 + I) para o byte I. Esse padrão não é zero de propósito. Um IV todo em zero é a escolha fraca de livro didático para CBC, e uma suíte de regressão não tem nada a ver normalizando isso. Com chave, salts e vetores fixos, /U, /UE, /O, /OE, /Perms e todo stream criptografado saem idênticos na segunda execução

Por fim, o SaveToStream liga DeterministicDictionaryOrder sempre que a flag reproduzível está setada, e faz isso antes de escolher entre os layouts linearizado, object streams e clássico, então saída com LinearizeOutput ou UseObjectStreams também sai ordenada. As chaves são ordenadas pelos bytes crus, uma chave que é prefixo de outra vem primeiro, e a posição original quebra empates. Essa é a ordenação que o writer de diagnóstico usa, descrita no artigo sobre editar um PDF à mão e repará-lo depois; a flag reproduzível empresta a ordenação e nenhum do layout de texto puro daquele writer. Ligar DeterministicDictionaryOrder sozinho é meia medida. As chaves se alinham, mas as datas do Info e as duas strings /ID do trailer ainda carregam o relógio, então dois saves continuam diferindo nos mesmos quatro lugares

O que o ReproducibleOutput fixa no HotPDF: a data de criação vira 1 de janeiro de 2026, o identificador do trailer é um MD5 do grafo de objetos canônico com os dígitos de D: zerados, chaves e salts do AES são preenchidos com bytes $5A e cada byte de IV segue 14 vezes (1 + i), e o DeterministicDictionaryOrder ordena todo dicionário
As substituições rodam nos mesmos caminhos de código que de outro modo recorreriam ao relógio ou ao gerador aleatório, então nenhuma passada de limpeza separada é necessária e os dois elementos de /ID recebem o mesmo valor derivado do conteúdo

Um teste que não pode passar de sorte

A data é fixada no BeginDoc porque o construtor roda antes de o chamador ter qualquer chance de definir a propriedade. A sequência normal é Create, depois ReproducibleOutput := True, depois BeginDoc, e só a última dessas vê a flag com o valor final. Essa ordem tem uma consequência direta para o design do teste. Dois saves que caem dentro do mesmo segundo de relógio escrevem a mesma string D: por acidente, então um teste rápido passa tanto faz a data estar fixada, e meses depois falha num agente de build mais lento

A correção é barata: espere 1100 ms entre os dois saves para garantir que o timestamp cruze uma fronteira de segundo, rode o caso para saída plain, AES-128 e AES-256 com senhas reais nas variantes criptografadas, e reporte o primeiro offset de byte diferente em vez de um mero true ou false. Um offset aponta para um objeto. Um boolean aponta para o arquivo inteiro

uses
  System.SysUtils, System.IOUtils, HPDFDoc;

function SaveOnce(const Target: string; Encrypt: Boolean;
  KeyType: THPDFKeyType): TBytes;
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := Target;
    Pdf.ReproducibleOutput := True;
    Pdf.VerifySavedOutput := True;   // reload rigoroso logo após o EndDoc
    if Encrypt then
    begin
      Pdf.OwnerPassword := 'owner';
      Pdf.UserPassword := 'user';
      Pdf.CryptKeyLength := KeyType; // aes128 ou aes256
      Pdf.ActivateProtection := True;
    end;
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(40, 40, 0, 'reproducible save');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
  Result := TFile.ReadAllBytes(Target);
end;

// Auxiliar que você mesmo escreve: -1 quando iguais, senão o primeiro offset diferente
function FirstDifference(const A, B: TBytes): Integer;
var
  I, N: Integer;
begin
  N := Length(A);
  if Length(B) < N then
    N := Length(B);
  for I := 0 to N - 1 do
    if A[I] <> B[I] then
      Exit(I);
  if Length(A) <> Length(B) then
    Result := N
  else
    Result := -1;
end;

procedure CheckReproducible(Encrypt: Boolean; KeyType: THPDFKeyType);
var
  A, B: TBytes;
  Offset: Integer;
begin
  A := SaveOnce('run-a.pdf', Encrypt, KeyType);
  Sleep(1100);                     // força um segundo de timestamp diferente
  B := SaveOnce('run-b.pdf', Encrypt, KeyType);
  Offset := FirstDifference(A, B);
  if Offset >= 0 then
    raise Exception.CreateFmt('Saves differ at byte offset %d', [Offset]);
end;

Chame CheckReproducible(False, aes128), CheckReproducible(True, aes128) e CheckReproducible(True, aes256); o tipo de chave é ignorado na rodada plain. O VerifySavedOutput merece o lugar dele nesse teste. Com ele ligado, o EndDoc recarrega o arquivo que acabou de escrever numa instância nova de THotPDF com a recuperação de cross-reference desligada, usando a senha de usuário quando o documento está protegido, e levanta exceção se esse load falhar. Uma mudança que torna a saída estável e ilegível ao mesmo tempo passaria limpa num diff verde; o reload barra ela

Um PDF criptografado reproduzível ainda é seguro?

Não. Um documento criptografado sob ReproducibleOutput não tem proteção real, e a flag precisa estar desligada para qualquer coisa que saia do diretório de teste. A file encryption key do AES-256 são trinta e dois bytes de $5A. A senha ainda barra os wrappers /UE e /OE, mas o que eles empacotam é uma constante publicada, então quem a conhece consegue decriptar todo content stream sem senha nenhuma. Os salts fixos também removem a unicidade por arquivo de que a ISO 32000-2 §7.6.4.4.7 precisa para impedir que a mesma senha gere a mesma string /U em dois arquivos. O artigo sobre a configuração do AES-256 descreve o que as propriedades de criptografia entregam com a fonte aleatória intacta; sob a flag reproduzível nada disso vale. Tranque a propriedade atrás de um valor de configuração só de teste, não numa configuração que um caminho de código de produção alcance

O AES-GCM é tratado de forma diferente. Com CryptKeyLength := aesgcm (só PDF 2.0), o componente sorteia o prefixo de nonce de 8 bytes da fonte aleatória do sistema, diga o que disser o ReproducibleOutput, e depois conta para cima a cada string ou stream criptografado. Esse é o comportamento certo. O GCM falha feio quando um nonce se repete sob a mesma chave: dois ciphertexts vazam o XOR dos plaintexts, e um atacante consegue recuperar o suficiente para forjar tags de autenticação. Uma chave fixa mais um nonce fixo tornariam essa repetição certa em todo arquivo que a suíte produz. Golden files criptografados usam portanto aes128 ou aes256, que rodam em modo CBC

O identificador é um trade-off mais suave. A ISO 32000-1 §14.4 pretende que o segundo elemento de /ID mude a cada modificação, e um save reproduzível escreve o mesmo valor nos dois slots. Como esse valor faz hash do grafo de objetos, conteúdos diferentes ainda recebem identificadores diferentes. Um leitor que se baseie em /ID para distinguir arquivos — um cache, um repositório de anotações ou um sidecar de form data, por exemplo — vai continuar tratando dois saves reproduzíveis do mesmo conteúdo como um arquivo só. Numa suíte de testes, é exatamente isso que você quer

Entradas que ficam de fora do writer

O ReproducibleOutput cobre a entropia que o writer introduz por conta própria. Mais algumas entradas vêm da máquina de build ou de operações que, por definição, registram um momento novo

Onde os saves reproduzíveis do HotPDF param: as strings de data PDF ainda carregam o offset UTC local, então golden files diferem entre fusos horários, o SaveIncrementalUpdate constrói um identificador de modificação novo porque um delta é uma modificação nova, e o atalho de passthrough é desabilitado para que um arquivo carregado seja sempre reescrito por completo
A reprodutibilidade vale entre execuções numa máquina ou entre máquinas que compartilhem o fuso, e um save reproduzível deve ser comparado com um save reproduzível anterior, nunca com a entrada original
  • O sufixo de fuso horário. Toda string de data PDF termina no offset UTC local, então a mesma data fixa se lê D:20260101000000+08'00' num agente de build e D:20260101000000-05'00' em outro. Golden files se mantêm entre execuções numa mesma máquina, ou entre máquinas que compartilham o fuso. Se os arquivos viajam entre escritórios, fixe o fuso dos agentes
  • Atualizações incrementais. O SaveIncrementalUpdate mantém o identificador permanente da origem e constrói um identificador de modificação novo a partir do anterior, do caminho de destino, do tick count e da hora atual. Uma seção anexada é por definição uma modificação nova. Compare reescritas completas, não deltas anexados
  • O atalho de passthrough. O SaveLoadedDocument normalmente copia uma origem não modificada e não criptografada byte a byte em vez de serializá-la de novo. Sob a flag, o arquivo é sempre reescrito para que as regras de ordenação e de identificador valham, o que torna o save mais lento e nunca uma cópia da entrada. Compare com um save reproduzível anterior, nunca com o original
  • Timestamps que você mesmo adiciona. O AddDocumentAttachment carimba /CreationDate e /ModDate de /Params do arquivo embutido (ISO 32000-1 §7.11.4) com o momento da chamada, e uma assinatura registra a hora de assinatura em /M. Essas datas descrevem eventos que o chamador pediu, então a flag as deixa em paz. Mantenha anexos e assinaturas fora dos casos de nível de byte, ou mascare essas strings no compare
  • Fontes instaladas. Um subset TrueType é cortado do arquivo de fonte instalado na máquina que roda o teste. Dois agentes com releases diferentes da Arial embutem programas de glifos diferentes para o mesmo texto, e o prefixo do subset pode mudar junto. Envie os arquivos de fonte junto com a imagem de build

Documentos carregados seguem as mesmas regras. Defina a propriedade antes de carregar, faça as edições e salve pelo SaveLoadedDocument; a saída é uma reescrita normalizada que você pode fazer commit como referência para a próxima execução

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.ReproducibleOutput := True;   // definido antes de carregar
    if Pdf.LoadFromFile('incoming.pdf') <= 0 then
      raise Exception.Create('incoming.pdf did not load');
    // ... edições sob teste ...
    Pdf.SaveLoadedDocument('normalized.pdf');  // reescrita completa, chaves ordenadas
  finally
    Pdf.Free;
  end;
end;

Saves byte a byte idênticos também encurralam uma checagem de conformidade que pisca. Uma vez que o writer está estável, uma checagem que passa numa execução e falha na seguinte está reagindo ao grafo de objetos que o teste constrói, como um dicionário direto só entregue a dois pais, então inspecione a entrada do teste antes do validador. Um diff estrutural do catálogo para baixo mostra qual objeto se moveu entre as duas execuções

As propriedades ReproducibleOutput, DeterministicDictionaryOrder, VerifySavedOutput e as de criptografia usadas acima fazem parte do HotPDF Delphi Component, que vem para Delphi e C++Builder