O HotPDF Delphi Component produz saída PDF byte a byte idêntica entre saves quando a propriedade ReproducibleOutput é True: ela fixa /CreationDate e /ModDate do Info numa data fixa, troca o identificador de documento baseado no relógio por um hash semeado ou derivado do conteúdo, substitui por constantes todo byte aleatório que os caminhos de criptografia AES sorteariam, e ordena todo dicionário que serializa. A flag existe para suítes de regressão e comparação de artefatos de build, não para documentos de produção, e as razões dessa fronteira são a parte interessante. O cenário que motivou o recurso é um teste de golden file. Você renderiza uma fatura, faz commit do PDF e garante que o build de amanhã produz os mesmos bytes. Nunca produz. O arquivo abre bem em qualquer viewer, o texto é idêntico, a page tree é idêntica, e o diff ainda acende em quatro ou cinco lugares. Quem já tentou colocar um gerador de PDF sob um teste de regressão em nível de byte bateu nesse muro, e a solução não é "tirar os timestamps", mas uma contabilidade precisa de todo lugar em que o writer consulta algo que não é o próprio documento
Por que dois saves do mesmo PDF diferem?
Dois saves do mesmo documento diferem porque um writer de PDF, o HotPDF incluído, consulta quatro fontes de entropia que não têm nada a ver com o conteúdo da página: o relógio de parede, o identificador de documento, o gerador de números aleatórios criptográficos e a ordem em memória das entradas de dicionário. Cada uma é legítima por conta própria. A ISO 32000-1 quer que elas estejam lá. Elas simplesmente fazem o arquivo ser função de quando e onde foi escrito, e não do que ele contém
- O relógio. O Info dictionary carrega
/CreationDatee/ModDate(ISO 32000-1 §14.3.3, Tabela 317) como stringsD:YYYYMMDDHHmmSScom sufixo de fuso horário (§7.9.4), e o pacote XMP repete o mesmo instante comoxmp:CreateDateexmp:ModifyDate. O HotPDF carimba os dois a partir deFCreationDate, que o construtor inicializa comNow, então os dois saves diferem no segundo em que foram escritos - O identificador. O array
/IDdo trailer (ISO 32000-1 §14.4) guarda um identificador permanente e um identificador de modificação. A receita padrão do HotPDF faz hash do nome do arquivo junto com a hora atual até o milissegundo para o primeiro elemento, e faz hash disso maisGetTickCountpara o segundo. Dois identificadores, dois valores novos a cada execução - Os bytes aleatórios. O standard security depende do identificador e de aleatoriedade de verdade. Para AES-256 a file encryption key, os salts de validação e de chave e cada vetor de inicialização CBC são sorteados da fonte aleatória do sistema (a ISO 32000-2 §7.6.4.4.7 exige salts aleatórios). Como
/U,/UE,/Oe/OEsão todos calculados a partir desses bytes, um documento criptografado muda por inteiro mesmo quando o texto puro não muda. Os algoritmos mais antigos dobram o primeiro elemento de/IDdentro da chave (ISO 32000-1 §7.6.3.3, §7.6.3.4), então um identificador novo já basta para trocar a chave do arquivo - A ordem. Um dicionário PDF é um mapeamento sem ordem, e um writer que percorre sua lista em memória emite as chaves na ordem de inserção. Qualquer caminho de código que monte um dicionário de recursos numa sequência diferente, ou um documento carregado que foi lido de um layout diferente, produz um arquivo legal, mas textualmente diferente
O que o ReproducibleOutput fixa?
Definir ReproducibleOutput := True antes do BeginDoc ou antes do SaveLoadedDocument substitui cada uma das quatro fontes por um valor fixo, e faz isso 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. Repare no que falta na lista acima: o conteúdo. Fontes, page streams, dados de imagem e a tabela de cross-reference já são determinísticos para a mesma entrada; o ruído mora inteiramente nos metadados e na camada de segurança, e é por isso que uma única propriedade bem direcionada consegue removê-lo. 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; // 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;
Dentro do BeginDoc o ramo reproduzível atribui FCreationDate := EncodeDate(2026, 1, 1) e semeia o identificador de documento com MD5CalcString('HotPDF-reproducible-seed') em vez do digest de nome de arquivo mais relógio. Essa única atribuição cobre as duas datas do Info e as duas datas do XMP, porque todas as quatro são renderizadas a partir do mesmo campo. Quando o arquivo é enfim escrito, o BuildDocumentIdentifiers pede ao ComputeCanonicalDocumentIdentifier o identificador do trailer: ele exporta o grafo de objetos inteiro em ordem canônica, zera os dígitos de toda string de data D: que encontra, para que os timestamps não vazem de volta pelo hash, e tira o MD5 do resultado. Os dois elementos de /ID recebem esse valor. O mesmo identificador derivado do conteúdo é usado quando um documento carregado é criptografado sem nunca passar pelo BeginDoc, que é o caso do ActivateProtection num arquivo que você abriu com LoadFromFile
Os bytes aleatórios são a substituição menos óbvia. A rotina de chave do AES-256 envolve sua fonte aleatória num helper local que, sob a flag, chama FillChar(P^, Count, $5A) para a file encryption key de 32 bytes e para cada salt de 8 bytes, e os encryptors de string e de stream do AES-128 e do AES-256 trocam AESGenerateRandomIV por AESGenerateStaticIV, que preenche o vetor de inicialização com 14 * (1 + I) para o slot I. Com a chave, os salts e os vetores todos fixos, /U, /UE, /O, /OE 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 o serializador então faz insertion sort de cada dicionário pelos bytes crus dos nomes das chaves, prefixo mais curto primeiro, com o índice original como desempate. Essa é a mesma 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 apenas a ordenação, não o resto do layout de texto puro daquele writer
Por que a data fixa ainda vazava o relógio de parede?
A correção da v2.752.2 existe porque a data de criação fixa era decidida originalmente no construtor, e o construtor não consegue saber uma propriedade que o chamador ainda não definiu. A sequência normal de chamadas é Create, depois ReproducibleOutput := True, depois BeginDoc. No momento da construção FReproducibleOutput ainda é False, então FCreationDate recebeu Now e ficou com ele. O identificador e os bytes aleatórios estavam fixados corretamente, então os dois arquivos concordavam em quase tudo e discordavam exatamente em duas strings de data e dois campos XMP. Mover a atribuição para o ramo reproduzível do BeginDoc, ao lado do identificador semeado, colocou a decisão no ponto em que a propriedade tem seu valor final
O teste de regressão que deixou isso passar vale mais que a correção. Dois saves que rodam dentro do mesmo segundo de relógio escrevem a mesma string D: por acidente, e a comparação de bytes passa por causa de um bug que falha em qualquer máquina mais lenta. O teste corrigido dorme 1100 ms entre os dois saves para garantir que o timestamp do PDF cruze uma fronteira de segundo, roda o caso para saída plain, AES-128 e AES-256 com senhas de verdade nas duas variantes criptografadas, e compara os dois buffers com CompareMem, reportando o primeiro offset divergente em caso de falha, para que o diff aponte para um objeto específico em vez de um arquivo inteiro. Uma comparação de bytes prova determinismo e nada mais, então mantenha uma asserção separada que recarregue a saída criptografada com a senha de usuário e leia uma contagem de páginas; uma mudança que deixe o arquivo estável e ilegível ao mesmo tempo não pode passar na força de um diff verde
function SaveOnce(const Target: string): TBytes;
var
Pdf: THotPDF;
Stream: TFileStream;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.FileName := Target;
Pdf.ReproducibleOutput := True;
Pdf.OwnerPassword := 'owner';
Pdf.UserPassword := 'user';
Pdf.CryptKeyLength := aes256;
Pdf.ActivateProtection := True;
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, 'reproducible save');
Pdf.EndDoc;
finally
Pdf.Free;
end;
Stream := TFileStream.Create(Target, fmOpenRead or fmShareDenyWrite);
try
SetLength(Result, Stream.Size);
if Stream.Size > 0 then
Stream.ReadBuffer(Result[0], Stream.Size);
finally
Stream.Free;
end;
end;
// no corpo do teste
A := SaveOnce(PathA);
TThread.Sleep(1100); // força um segundo de timestamp de PDF diferente
B := SaveOnce(PathB);
Assert.AreEqual<Integer>(Length(A), Length(B));
Assert.IsTrue(CompareMem(@A[0], @B[0], Length(A)),
'two saves under ReproducibleOutput must be byte-identical');
Um PDF criptografado reproduzível ainda é seguro?
Não. Um documento criptografado sob ReproducibleOutput não está protegido em nenhum sentido que importe, 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, os salts são oito bytes de $5A, e os vetores de inicialização seguem um padrão aritmético publicado. A senha ainda barra os wrappers /UE e /OE, mas a chave empacotada é uma constante, então quem conhece a constante consegue decriptar todo content stream sem senha nenhuma. Os salts fixos também removem a unicidade por documento com que a ISO 32000-2 §7.6.4.4.7 conta para impedir que senhas idênticas gerem strings /U idênticas entre arquivos. Leia o artigo sobre a configuração do AES-256 para saber o que as propriedades de criptografia prometem quando a fonte aleatória está intacta; sob a flag reproduzível essas promessas ficam suspensas
O trade-off do identificador é mais sutil. A ISO 32000-1 §14.4 pretende que o segundo elemento de /ID mude a cada modificação, para que ferramentas distingam um arquivo atualizado do seu ancestral, e um save reproduzível escreve o mesmo valor nos dois slots. Como esse valor é um hash do grafo canônico de objetos, dois documentos com conteúdos diferentes ainda recebem identificadores diferentes, o que é melhor que uma constante. Mas a semente que o BeginDoc usa para derivar chaves é a mesma string para todo documento em toda máquina, e um leitor que se baseie em /ID para distinguir arquivos — um cache de anotações ou um sidecar de form data, por exemplo — vai confundir todo arquivo reproduzível que por acaso gere o mesmo hash
O que a flag não cobre?
O ReproducibleOutput remove a entropia que o writer introduz por conta própria; ele não consegue remover a entropia que entra pelo ambiente ou por caminhos de código que ele não controla, e é fácil tropeçar em três deles
- O sufixo de fuso horário. O
_DateTimeToPdfDateanexa o offset UTC local, entãoD:20260101000000+08'00'num agente de build eD:20260101000000-05'00'em outro são bytes diferentes para a mesma data fixa. A reprodutibilidade vale entre execuções numa mesma máquina, ou entre máquinas que compartilhem o fuso; fixe o fuso do agente se seus golden files viajam - Atualizações incrementais. O
SaveIncrementalUpdatecalcula seu identificador de modificação a partir do caminho de destino, deGetTickCounte da hora atual, sem ramo reproduzível, porque uma seção incremental é por definição uma modificação nova. Compare reescritas completas, não deltas anexados - O atalho de passthrough. O
SaveLoadedDocumentnormalmente copia um arquivo de origem não modificado e não criptografado byte a byte, em vez de reserializá-lo. A flag reproduzível desabilita esse atalho e força uma reescrita completa para que as regras de ordenação e de identificador valham, o que significa que o save reproduzível de um arquivo carregado é mais lento que o padrão e nunca é uma cópia da entrada. Compare-o com um save reproduzível anterior, nunca com o original
Mais uma lição da mesma release, sobre o que uma checagem que passa prova e o que não prova. Um fixture de teste de PDF/X-6 chamava CharProcs.DeleteValue('A'), que liberava um glyph stream mantido diretamente, e depois reinseria o mesmo ponteiro, além de entregar um mesmo objeto ExtGState direto tanto a um dicionário de recursos quanto a um pattern. O validador de conformidade passava de forma intermitente naquele use-after-free e naquela posse dupla porque estava lendo o que quer que a memória liberada por acaso contivesse. Quando uma checagem estrutural pisca, olhe a propriedade da entrada de teste antes de olhar o validador. Saída reproduzível torna essa disciplina mais barata: uma vez que dois saves são byte a byte idênticos, a única fonte restante de oscilação é o próprio grafo de objetos, e um diff estrutural do catalog para baixo vai encontrá-la
As propriedades ReproducibleOutput, DeterministicDictionaryOrder e as de criptografia descritas aqui vêm no HotPDF Delphi Component padrão para Delphi e C++Builder, e a mesma flag guia o próprio corpus de regressão da biblioteca, então o comportamento que você obtém numa suíte de testes é o comportamento com que o componente é testado