Artigo Técnico

Output PDF byte-idêntico: gravações reproduzíveis em Delphi

O PDFium Component torna o output PDF reproduzível uma única chamada: o TPdf.SaveAsReproducible reescreve o documento de forma que gravar o mesmo conteúdo duas vezes produza ficheiros byte-idênticos. Remove /CreationDate e /ModDate do dicionário de informação do documento, não escreve /ID de trailer nenhum, mantém a ordem dos objetos estável, e reporta o que mudou num TPdfReproducibleReport. Ficheiros encriptados são recusados, e ficheiros assinados precisam de consentimento explícito

A necessidade normalmente aparece num pipeline de build. Alguém submete PDFs gerados a um repositório ou a uma suíte de testes de golden file, e cada corrida produz um diff embora nada em nenhuma página tenha mudado. Ou um arquivo desduplica por SHA-256 e guarda em silêncio cinquenta cópias da mesma fatura, porque cada gravação carimbou um tempo novo no ficheiro. Um escritor de PDF que incorpora o relógio da parede é aceitável para uma aplicação de secretária e hostil a tudo o que trata bytes como identidade

Porque é que gravar o mesmo PDF duas vezes produz bytes diferentes?

Duas gravações de um documento inalterado diferem por exatamente dois tipos de dados: carimbos de data no dicionário de informação do documento e o identificador de ficheiro no trailer. Tudo o resto que um escritor cuidadoso emite, numeração de objetos, ordem de objetos, layout de cross-reference, pode tornar-se determinístico, e o caminho de reescrita completa do PDFium Component já o faz: os objetos são escritos em número de objeto ascendente e a tabela xref segue a mesma ordem

Os carimbos de data são o culpado óbvio. A ISO 32000-1 §14.3.3 define /CreationDate e /ModDate no dicionário /Info, e a gravação nativa do PDFium escreve um /ModDate fresco de cada vez que serializa o documento. O identificador de ficheiro é o subtil. A ISO 32000-1 §14.4 descreve o /ID como um array de dois elementos de byte strings, em que o primeiro elemento identifica o ficheiro original e o segundo muda à medida que o ficheiro é revisto. Escritores tipicamente derivam essas strings a partir do hash de algo sobre o ficheiro, e se esse algo incluir os bytes com carimbo de data, o identificador herda o não determinismo

Usar o TPdf.SaveAsReproducible em Delphi

O TPdf.SaveAsReproducible tem dois overloads, um que escreve para um TStream e um para um nome de ficheiro, e ambos devolvem Boolean com um out Report: TPdfReproducibleReport e um parâmetro opcional AllowSignedDocument que vem com predefinição False. O valor de retorno espelha o Report.Succeeded; quando é False, o Report.ErrorMessage diz porquê. O overload de ficheiro escreve para um ficheiro temporário ao lado do alvo e substitui o alvo só depois de uma gravação bem-sucedida, por isso uma corrida falhada não deixa um PDF meio escrito onde estava o output do seu build anterior

var
  Pdf: TPdf;
  Report: TPdfReproducibleReport;
begin
  Pdf:= TPdf.Create(nil);
  try
    Pdf.FileName:= 'invoice-2026-10.pdf';
    Pdf.Active:= True;
    if Pdf.SaveAsReproducible('build\invoice-2026-10.pdf', Report) then
      Writeln(Format('%d -> %d bytes, %d objects kept, %d pruned, ' +
        '%d timestamps stripped', [Report.SourceByteCount,
        Report.OutputByteCount, Report.KeptObjectCount,
        Report.PrunedObjectCount, Report.StrippedTimestampCount]))
    else
      Writeln('Reproducible save failed: ', Report.ErrorMessage);
  finally
    Pdf.Free;
  end;
end;

Os campos do relatório valem a pena registar no CI. O StrippedTimestampCount é normalmente 2 para um documento cujo /Info traz ambas as datas, e 0 para um sem dicionário /Info de todo. O PrunedObjectCount conta objetos que estavam presentes na gravação nativa mas não eram alcançáveis a partir do catalog ou do dicionário /Info; a reescrita reproduzível deita-os fora, por isso espere que o output tenha o mesmo tamanho ou menos do que uma gravação ordinária

Porque não chegou cortar os carimbos de data

A primeira implementação removia ambos os carimbos de data e ainda produzia dois ficheiros diferentes. As páginas batiam certo, o dicionário /Info batia certo, e os trailers diferiam no segundo elemento do /ID. A causa raiz era a ordenação: a reescrita primeiro pede ao PDFium uma gravação nativa, depois pós-processa esses bytes, e o construtor de trailers calculava o /ID pelo hash do output nativo. Esse output nativo ainda continha o /ModDate fresco, por isso o hash, e portanto o identificador, mudava em cada corrida embora o carimbo de data em si nunca chegasse ao ficheiro final

É um bom exemplo de uma regra geral para builds reproduzíveis: remover um valor não determinístico não chega se algo tiver sido derivado dele primeiro. A correção foi deixar de transportar /ID de todo. O TPdf.SaveAsReproducible escreve um trailer contendo só /Size, /Root e, quando a origem tiver um, /Info. Sem nada dependente do tempo a ficar no trailer, duas gravações consecutivas do mesmo documento comparam-se iguais byte a byte, que é exatamente assim que o comportamento é verificado

function SavesAreIdentical(Pdf: TPdf): Boolean;
var
  A, B: TMemoryStream;
  RA, RB: TPdfReproducibleReport;
begin
  A:= TMemoryStream.Create;
  B:= TMemoryStream.Create;
  try
    Result:= Pdf.SaveAsReproducible(A, RA) and
      Pdf.SaveAsReproducible(B, RB) and
      (A.Size = B.Size) and CompareMem(A.Memory, B.Memory, A.Size);
  finally
    B.Free;
    A.Free;
  end;
end;

Remover chaves de dicionário sem corromper strings

Os carimbos de data são removidos por uma varredura consciente de tokens do corpo do objeto /Info, não por uma procura de texto. Uma substituição de bytes ingénua por /ModDate acertaria também numa string /Subject ou /Keywords que por acaso contenha esse texto, e cortar bytes de uma string literal corrompe os metadados em silêncio em vez de falhar aos berros. O PDFium Component primeiro neutraliza strings literais e comentários numa cópia de varredura do corpo, depois só aceita uma coincidência onde comece um token de nome real, usa a gramática de objetos PDF para encontrar onde acaba o valor dessa chave, e salta todo o par chave-valor

A consequência prática é que todas as outras entradas em /Info, título, autor, produtor, chaves personalizadas, sobrevivem intocadas. Só as duas chaves definidas como datas são removidas, e só do dicionário a que a entrada /Info do trailer realmente aponta

Quanto custa omitir o /ID para PDFs encriptados e assinados?

Omitir o /ID é seguro para output PDF 1.x simples, mas exclui a encriptação e muda o que acontece às assinaturas. Na ISO 32000-1 o /ID do trailer é opcional a menos que uma entrada /Encrypt esteja presente, porque o security handler padrão mistura a primeira string identificadora na chave de encriptação. Um ficheiro sem /ID não pode ser um ficheiro corretamente encriptado, por isso o TPdf.SaveAsReproducible nem tenta: quando o trailer traz /Encrypt, devolve False e o Report.ErrorMessage diz que o documento está encriptado. Desencripte primeiro se o seu pipeline o permitir, ou mantenha as entregas encriptadas fora das verificações de identidade de bytes. Se está a visar PDF 2.0, note que a ISO 32000-2 aperta a regra e exige /ID em todos os ficheiros, por isso o output reproduzível encaixa mais naturalmente em alvos PDF 1.x; o artigo sobre conformidade exata de versão PDF em Delphi cobre como o componente trata alvos de versão

Documentos assinados são um problema diferente. O TPdf.SaveAsReproducible é uma reescrita completa não incremental, por isso os offsets de bytes movem-se e o /ByteRange das assinaturas existentes deixa de cobrir os bytes que foram assinados. O output reproduzível de um ficheiro assinado é por isso uma decisão deliberada de abdicar da validade das assinaturas existentes em troca de bytes estáveis, razão pela qual o método levanta EPdfError quando o SignatureCount é maior que zero a menos que passe AllowSignedDocument = True

if Pdf.SignatureCount > 0 then
  // Opt-in explícito: a reescrita move offsets de bytes, por isso as assinaturas
  // existentes devem esperar falhar a verificação depois
  Ok:= Pdf.SaveAsReproducible('normalized.pdf', Report, True)
else
  Ok:= Pdf.SaveAsReproducible('normalized.pdf', Report);

Se o original assinado é o registo de verdade, mantenha-o como está e use output reproduzível só para cópias derivadas, como uma rendição normalizada que faz diff no CI. Para acrescentar a um ficheiro protegido sem perturbar o que já lá está, veja porque é que um PDF encriptado não pode ser remendado em silêncio em Delphi

Limites a conhecer antes de confiar na identidade de bytes

A identidade de bytes vale para o mesmo documento gravado pelo mesmo build do componente, e cobre exatamente o que o método toca. O TPdf.SaveAsReproducible corta só as duas datas /Info; um stream de metadados XMP com o seu próprio xmp:CreateDate ou xmp:ModifyDate é transportado com os bytes que o documento já tiver. Isso é estável entre gravações, mas significa que as datas XMP e /Info deixam de descrever a mesma coisa, o que importa para perfis PDF/A que exijam que as duas concordem; o artigo sobre equivalência de metadados PDF/A Info e XMP explica essas regras. Um binário PDFium ou uma versão de componente diferente também podem serializar objetos de forma diferente, por isso fixe ambos em qualquer pipeline que compare hashes entre máquinas

Dentro desses limites, o método dá-lhe o que os diffs de CI, os testes de golden file e os arquivos endereçados por conteúdo precisam: mesma entrada, mesmos bytes. O TPdf.SaveAsReproducible e o TPdfReproducibleReport vêm com o PDFium Component for Delphi and C++Builder, ao lado do resto da sua API de gravação, otimização e conformidade

FAQ

O TPdf.SaveAsReproducible muda o conteúdo das páginas?

Não. Remove as chaves /CreationDate e /ModDate do dicionário /Info, omite o /ID do trailer, e deita fora objetos inalcançáveis a partir do catalog ou do /Info; o conteúdo das páginas é escrito inalterado

Dá para produzir output reproduzível a partir de um PDF encriptado?

Não. A encriptação depende do /ID do trailer, que o output reproduzível omite, por isso o método devolve False com um ErrorMessage explicativo para origens encriptadas

Porque é que um PDF assinado precisa de AllowSignedDocument?

A gravação reproduzível é uma reescrita completa que move offsets de bytes, por isso as assinaturas existentes deixam de bater certo com os seus intervalos de bytes assinados. Passar AllowSignedDocument = True confirma que aceita essa troca