Artigo Técnico

Saída PDF reprodutível no Delphi: gravações idênticas

O HotPDF Delphi Component produz saída PDF byte a byte idêntica entre gravações quando a propriedade ReproducibleOutput é True: fixa o /CreationDate e o /ModDate do Info numa data fixa, substitui o identificador de documento baseado no relógio por um hash semeado ou derivado do conteúdo, troca constantes por cada byte aleatório que as vias de encriptação AES de outro modo tirariam, e ordena todos os dicionários que serializa. O flag existe para suites de regressão e comparação de artefactos de build, não para documentos de produção, e as razões para essa fronteira são a parte interessante. O cenário que conduz a funcionalidade é um teste de golden file. Desenha uma fatura, faz commit do PDF, e afirma que o build de amanhã produz os mesmos bytes. Nunca produz. O ficheiro abre bem em qualquer visualizador, o texto é idêntico, a árvore de páginas é idêntica, e o diff acende na mesma em quatro ou cinco sítios. Quem já tentou pôr um gerador de PDF sob um teste de regressão ao nível do byte bateu nesta parede, e a correção não é «remover os carimbos temporais» mas sim uma contabilização precisa de cada sítio onde o writer consulta algo que não é o próprio documento

Porque é que duas gravações do mesmo PDF diferem?

Duas gravações 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 das páginas: o relógio de parede, o identificador de documento, o gerador de números aleatórios criptográfico, e a ordem em memória das entradas de dicionário. Cada uma é legítima por si. A ISO 32000-1 quer-as lá. Simplesmente fazem com que o ficheiro seja uma função de quando e onde foi escrito, e não daquilo que contém

  • O relógio. O dicionário Info transporta /CreationDate e /ModDate (ISO 32000-1 §14.3.3, Tabela 317) como strings D:YYYYMMDDHHmmSS com um sufixo de fuso horário (§7.9.4), e o pacote XMP repete o mesmo instante como xmp:CreateDate e xmp:ModifyDate. O HotPDF carimba ambos a partir de FCreationDate, que o construtor inicializa a Now, pelo que as duas gravações diferem no segundo em que foram escritas
  • O identificador. O array /ID do trailer (ISO 32000-1 §14.4) contém um identificador permanente e um identificador de modificação. A receita predefinida do HotPDF faz o hash do nome do ficheiro juntamente com a hora atual até ao milissegundo para o primeiro elemento, e o hash disso mais GetTickCount para o segundo. Dois identificadores, dois valores frescos em cada execução
  • Os bytes aleatórios. A segurança padrão depende do identificador e de aleatoriedade genuína. No AES-256 a chave de encriptação do ficheiro, os sais de validação e de chave, e cada vetor de inicialização CBC são tirados da fonte aleatória do sistema (a ISO 32000-2 §7.6.4.4.7 exige sais aleatórios). Como /U, /UE, /O e /OE são todos calculados a partir desses bytes, um documento encriptado muda na sua totalidade mesmo quando o texto simples não muda. Os algoritmos mais antigos dobram o primeiro elemento /ID na chave (ISO 32000-1 §7.6.3.3, §7.6.3.4), pelo que um identificador novo chega por si só para re-chavear o ficheiro
  • A ordem. Um dicionário PDF é um mapeamento não ordenado, e um writer que percorra a sua lista em memória emite as chaves por ordem de inserção. Qualquer caminho de código que construa um dicionário de recursos numa sequência diferente, ou um documento carregado que tenha sido lido de um layout diferente, produz um ficheiro legal mas textualmente diferente
As quatro fontes de entropia que fazem duas gravações do HotPDF do mesmo documento diferirem: o FCreationDate carimbado a partir de Now alimenta as datas D: e o pacote XMP, o /ID do trailer faz o hash do nome do ficheiro, do relógio e do GetTickCount, o AES tira material de chave da fonte aleatória do sistema, e os dicionários serializam por ordem de inserção em memória
Cada fonte é legítima por si e a ISO 32000-1 quer-as lá, mas juntas transformam o ficheiro numa função de quando e onde foi escrito em vez daquilo que contém

O que é 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 fá-lo nos mesmos caminhos de código que de outro modo iriam buscar o relógio ou o gerador aleatório, pelo que não é preciso nenhuma passagem de limpeza separada. Repare no que falta na lista acima: o conteúdo. Os tipos de letra, os streams de página, os dados de imagem e a tabela de cross-reference já são determinísticos para a mesma entrada; o ruído vive inteiramente nos metadados e na camada de segurança, e é por isso que uma propriedade bem dirigida o consegue remover. A propriedade é False por defeito e nada na biblioteca a liga por si

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 reprodutível atribui FCreationDate := EncodeDate(2026, 1, 1) e semeia o identificador de documento com MD5CalcString('HotPDF-reproducible-seed') em vez do digest do nome de ficheiro 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 geradas a partir do mesmo campo. Quando o ficheiro é finalmente escrito, o BuildDocumentIdentifiers pede ao ComputeCanonicalDocumentIdentifier o identificador do trailer: exporta o grafo de objetos inteiro em ordem canónica, põe a zero os dígitos de qualquer string de data D: que encontre, para que os timestamps não possam voltar a entrar pelo hash, e tira o MD5 do resultado. Ambos os elementos de /ID recebem esse valor. O mesmo identificador derivado do conteúdo é usado quando um documento carregado é encriptado sem passar pelo BeginDoc, que é o caso do ActivateProtection num ficheiro que abriu com o LoadFromFile

Os bytes aleatórios são a substituição menos óbvia. A rotina da chave AES-256 envolve a sua fonte aleatória num helper local que, sob o flag, chama FillChar(P^, Count, $5A) para a chave de encriptação do ficheiro de 32 bytes e para cada sal de 8 bytes, e os encriptadores de strings e streams AES-128 e AES-256 passam de AESGenerateRandomIV para AESGenerateStaticIV, que preenche o vetor de inicialização com 14 * (1 + I) para a posição I. Com a chave, os sais e os vetores todos fixos, /U, /UE, /O, /OE e cada stream encriptado saem idênticos na segunda execução. Por fim, o SaveToStream liga o DeterministicDictionaryOrder sempre que o flag reprodutível está definido, e o serializador faz então uma ordenação por inserção de cada dicionário pelos bytes em bruto dos seus nomes de chave, prefixo mais curto primeiro, com o índice original como critério de desempate. É a mesma ordenação que o writer de diagnóstico usa, descrita em o artigo sobre editar um PDF à mão e repará-lo depois; o flag reprodutível só pede emprestada a ordenação, não o resto do layout em texto simples desse writer

O que o ReproducibleOutput fixa no HotPDF: a data de criação passa a EncodeDate 2026, 1, 1, o identificador do trailer vem do ComputeCanonicalDocumentIdentifier sobre o grafo canónico com os dígitos D: postos a zero, as chaves e sais do AES preenchem-se com bytes $5A e o AESGenerateStaticIV preenche cada posição, e o DeterministicDictionaryOrder ordena todos os dicionários
As substituições correm nos mesmos caminhos de código que de outro modo iriam buscar o relógio ou o gerador aleatório, pelo que não é precisa nenhuma passagem de limpeza separada e ambos os elementos de /ID recebem o mesmo valor derivado do conteúdo

Porque é que a data fixa ainda deixava escapar o relógio de parede?

A correção da v2.752.2 existe porque a data de criação fixa era originalmente decidida no construtor, e o construtor não consegue conhecer uma propriedade que o chamador ainda não definiu. A sequência de chamadas normal é Create, depois ReproducibleOutput := True, depois BeginDoc. No momento da construção o FReproducibleOutput ainda é False, por isso o FCreationDate recebeu Now e ficou com ele. O identificador e os bytes aleatórios estavam corretamente fixados, pelo que os dois ficheiros coincidiam quase em todo o lado e discordavam exatamente em duas strings de data e dois campos XMP. Passar a atribuição para o ramo reprodutível do BeginDoc, ao lado do identificador semeado, pôs a decisão no ponto em que a propriedade tem o seu valor final

O teste de regressão que falhou isto vale mais do que a correção. Duas gravações que correm ambas dentro do mesmo segundo de relógio de parede escrevem a mesma string D: por acidente, e a comparação de bytes passa por um bug que falha em qualquer máquina mais lenta. O teste corrigido dorme 1100 ms entre as duas gravações, para que o timestamp do PDF atravesse garantidamente uma fronteira de segundo, corre o caso para saída simples, AES-128 e AES-256 com palavras-passe reais nas duas variantes encriptadas, e compara os dois buffers com CompareMem, reportando o primeiro offset diferente em caso de falha, para que o diff aponte a um objeto específico em vez de a um ficheiro inteiro. Uma comparação de bytes prova determinismo e mais nada, por isso mantenha uma asserção separada que recarregue a saída encriptada com a palavra-passe de utilizador e leia uma contagem de páginas; uma alteração que torne o ficheiro estável e ilegível ao mesmo tempo não pode passar à tangente à custa 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çar um segundo diferente no timestamp do PDF
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 encriptado reprodutível continua a ser seguro?

Não. Um documento encriptado sob ReproducibleOutput não está protegido em nenhum sentido com significado, e o flag tem de estar desligado para tudo o que saia do diretório de testes. A chave de encriptação do ficheiro AES-256 são trinta e dois bytes de $5A, os sais são oito bytes de $5A, e os vetores de inicialização seguem um padrão aritmético publicado. A palavra-passe continua a fechar os wrappers /UE e /OE, mas a chave envolvida é uma constante, pelo que qualquer pessoa que conheça a constante consegue desencriptar todos os content streams sem palavra-passe nenhuma. Os sais fixos também removem a unicidade por documento com que a ISO 32000-2 §7.6.4.4.7 conta para impedir que palavras-passe idênticas produzam strings /U idênticas entre ficheiros. Leia o artigo sobre a configuração do AES-256 para saber o que as propriedades de encriptação prometem quando a fonte aleatória está intacta; sob o flag reprodutível essas promessas ficam suspensas

O compromisso do identificador é mais subtil. A ISO 32000-1 §14.4 pretende que o segundo elemento /ID mude em cada modificação, para que as ferramentas distingam um ficheiro atualizado do seu antecessor, e uma gravação reprodutível escreve o mesmo valor nas duas posições. Como esse valor é um hash do grafo de objetos canónico, dois documentos com conteúdo diferente recebem na mesma identificadores diferentes, o que é melhor do que uma constante. Mas a semente que o BeginDoc usa para a derivação de chave é a mesma string para todos os documentos em todas as máquinas, e um leitor que se guie pelo /ID para distinguir ficheiros, uma cache de anotações ou um ficheiro lateral de dados de formulário por exemplo, vai confundir todos os ficheiros reprodutíveis que por acaso dêem o mesmo hash

O que é que o flag não cobre?

O ReproducibleOutput remove a entropia que o writer introduz por si; não consegue remover entropia que entra pelo ambiente ou por caminhos de código que não controla, e três desses são fáceis de acionar por engano

  • O sufixo de fuso horário. O _DateTimeToPdfDate acrescenta o offset UTC local, por isso D:20260101000000+08'00' num agente de build e D:20260101000000-05'00' noutro são bytes diferentes para a mesma data fixa. A reprodutibilidade mantém-se entre execuções numa máquina, ou entre máquinas que partilhem um fuso horário; fixe o fuso do agente se os seus golden files viajarem
  • As atualizações incrementais. O SaveIncrementalUpdate calcula o seu identificador de modificação a partir do caminho alvo, do GetTickCount e da hora atual sem qualquer ramo reprodutível, porque uma secção incremental é por definição uma modificação nova. Compare reescritas completas, não deltas acrescentados
  • O atalho de passagem direta. O SaveLoadedDocument copia normalmente um ficheiro de origem não modificado e não encriptado byte a byte em vez de o re-serializar. O flag reprodutível desativa esse atalho e força uma reescrita completa, para que as regras de ordenação e de identificador se apliquem, o que significa que a gravação reprodutível de um ficheiro carregado é mais lenta que a predefinida e nunca é uma cópia da entrada. Compare-a com uma gravação reprodutível anterior, nunca com o original
Onde param as gravações reprodutíveis do HotPDF: o _DateTimeToPdfDate continua a acrescentar o offset UTC local, por isso os golden files diferem entre fusos horários, o SaveIncrementalUpdate não tem ramo reprodutível porque um delta é uma modificação nova, e o atalho de passagem direta é desativado para que um ficheiro carregado seja sempre reescrito por completo
A reprodutibilidade mantém-se entre execuções numa máquina ou entre máquinas que partilhem um fuso, e uma gravação reprodutível deve ser comparada com uma gravação reprodutível anterior, nunca com a entrada original

Mais uma lição da mesma versão, sobre o que uma verificação que passa prova e não prova. Um fixture de teste PDF/X-6 chamava CharProcs.DeleteValue('A'), que libertava um stream de glifo detido diretamente, e depois voltava a inserir o mesmo ponteiro, e, em separado, entregava um objeto ExtGState direto tanto a um dicionário de recursos como a um pattern. O validador de conformidade passava de forma intermitente naquele use-after-free e naquela dupla posse porque estava a ler o que quer que a memória libertada contivesse naquele momento. Quando uma verificação estrutural oscila, olhe para a posse da entrada de teste antes de olhar para o validador. A saída reprodutível torna essa disciplina mais barata: quando duas gravações são byte a byte idênticas, a única fonte restante de oscilação é o próprio grafo de objetos, e um diff estrutural a partir do catálogo para baixo vai encontrá-la

As propriedades ReproducibleOutput, DeterministicDictionaryOrder e de encriptação aqui descritas saem no HotPDF Delphi Component padrão para Delphi e C++Builder, e o mesmo flag conduz o próprio corpus de regressão da biblioteca, pelo que o comportamento que obtém numa suite de testes é o comportamento com que o componente é testado