HotPDF Delphi Component produce output PDF identico byte per byte tra un salvataggio e l'altro quando la proprietà ReproducibleOutput è True: blocca /CreationDate e /ModDate dell'Info su una data fissa, sostituisce l'identificatore di documento preso dall'orologio con un hash seedato o derivato dal contenuto, rimpiazza con costanti ogni byte casuale che i percorsi di cifratura AES altrimenti pescherebbero, e ordina ogni dizionario che serializza. Il flag esiste per le suite di regressione e il confronto di artefatti di build, non per i documenti di produzione, e le ragioni di quel confine sono la parte interessante. Lo scenario che ha guidato la funzione è un golden-file test. Renderizzi una fattura, committi il PDF, e verifichi che la build di domani produca gli stessi byte. Non succede mai. Il file si apre benissimo in ogni viewer, il testo è identico, l'albero delle pagine è identico, e il diff si accende comunque in quattro o cinque punti. Chiunque abbia provato a mettere un generatore di PDF sotto un test di regressione a livello di byte ha sbattuto contro questo muro, e la soluzione non è "togliere i timestamp" ma una contabilità precisa di ogni punto in cui il writer consulta qualcosa che non è il documento stesso
Perché due salvataggi dello stesso PDF differiscono?
Due salvataggi dello stesso documento differiscono perché un writer PDF, HotPDF incluso, consulta quattro fonti di entropia che non hanno niente a che vedere con il contenuto delle pagine: l'orologio di sistema, l'identificatore di documento, il generatore di numeri casuali crittografico, e l'ordine in memoria delle entry dei dizionari. Ognuna è legittima per conto suo. ISO 32000-1 le vuole lì. Semplicemente rendono il file una funzione di quando e dove è stato scritto invece che di ciò che contiene
- L'orologio. Il dizionario Info porta
/CreationDatee/ModDate(ISO 32000-1 §14.3.3, Table 317) come stringheD:YYYYMMDDHHmmSScon un suffisso di fuso orario (§7.9.4), e il pacchetto XMP ripete lo stesso istante comexmp:CreateDateexmp:ModifyDate. HotPDF li timbra entrambi daFCreationDate, che il costruttore inizializza aNow, quindi i due salvataggi differiscono nel secondo in cui sono stati scritti - L'identificatore. L'array
/IDdel trailer (ISO 32000-1 §14.4) contiene un identificatore permanente e un identificatore di modifica. La ricetta di default di HotPDF fa l'hash del nome del file insieme all'ora corrente al millisecondo per il primo elemento, e l'hash di quello piùGetTickCountper il secondo. Due identificatori, due valori nuovi a ogni esecuzione - I byte casuali. La sicurezza standard dipende dall'identificatore e da casualità genuina. Per AES-256 la file encryption key, i salt di validazione e di chiave, e ogni vettore di inizializzazione CBC vengono pescati dalla sorgente casuale di sistema (ISO 32000-2 §7.6.4.4.7 richiede salt casuali). Dato che
/U,/UE,/Oe/OEsono tutti calcolati da quei byte, un documento cifrato cambia nella sua interezza anche quando il plaintext non cambia. Gli algoritmi più vecchi ripiegano il primo elemento di/IDdentro la chiave (ISO 32000-1 §7.6.3.3, §7.6.3.4), quindi un identificatore nuovo basta da solo a ri-generare la chiave del file - L'ordine. Un dizionario PDF è una mappatura non ordinata, e un writer che percorre la sua lista in memoria emette le chiavi in ordine di inserimento. Qualunque percorso di codice che costruisca un dizionario di risorse in una sequenza diversa, o un documento caricato che è stato analizzato da un layout diverso, produce un file legale ma testualmente diverso
Cosa blocca ReproducibleOutput?
Impostare ReproducibleOutput := True prima di BeginDoc o prima di SaveLoadedDocument sostituisce ognuna delle quattro fonti con un valore fisso, e lo fa negli stessi percorsi di codice che altrimenti andrebbero a prendere l'orologio o il generatore casuale, quindi non serve nessuna passata di pulizia separata. Nota cosa manca dalla lista sopra: il contenuto. Font, stream di pagina, dati immagine e tabella di cross-reference sono già deterministici per lo stesso input; il rumore vive interamente nei metadati e nel layer di sicurezza, ed è per questo che una sola proprietà mirata può rimuoverlo. La proprietà è a False per default e niente nella libreria la attiva al posto tuo
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.FileName := 'golden-invoice.pdf';
Pdf.ReproducibleOutput := True; // prima di 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 BeginDoc il ramo riproducibile assegna FCreationDate := EncodeDate(2026, 1, 1) e seeda l'identificatore di documento con MD5CalcString('HotPDF-reproducible-seed') invece del digest di nome file più orologio. Quella singola assegnazione copre entrambe le date di Info ed entrambe le date XMP, perché tutte e quattro vengono renderizzate dallo stesso campo. Quando il file viene infine scritto, BuildDocumentIdentifiers chiede a ComputeCanonicalDocumentIdentifier l'identificatore del trailer: esporta l'intero object graph in ordine canonico, azzera le cifre di ogni stringa di data D: che trova, così i timestamp non possono rientrare attraverso l'hash, e prende l'MD5 del risultato. Entrambi gli elementi di /ID ricevono quel valore. Lo stesso identificatore derivato dal contenuto viene usato quando un documento caricato viene cifrato senza passare mai da BeginDoc, che è il caso di ActivateProtection su un file che hai aperto con LoadFromFile
I byte casuali sono la sostituzione meno ovvia. La routine della chiave AES-256 avvolge la sua sorgente casuale in un helper locale che, sotto il flag, chiama FillChar(P^, Count, $5A) per la file encryption key di 32 byte e per ogni salt da 8 byte, e gli encryptor di stringhe e stream AES-128 e AES-256 passano da AESGenerateRandomIV a AESGenerateStaticIV, che riempie il vettore di inizializzazione con 14 * (1 + I) per lo slot I. Con chiave, salt e vettori tutti fissi, /U, /UE, /O, /OE e ogni stream cifrato escono identici alla seconda esecuzione. Infine, SaveToStream attiva DeterministicDictionaryOrder ogni volta che il flag riproducibile è impostato, e il serializer ordina poi ogni dizionario con un insertion sort sui byte grezzi dei nomi delle chiavi, prefisso più corto prima, con l'indice originale come spareggio. È lo stesso ordinamento usato dal writer diagnostico, descritto in l'articolo sulla modifica manuale di un PDF e la sua riparazione; il flag riproducibile prende in prestito solo l'ordinamento, non il resto del layout in testo semplice di quel writer
Perché la data fissa lasciava comunque filtrare l'orologio?
La correzione della v2.752.2 esiste perché la data di creazione fissa era originariamente decisa nel costruttore, e il costruttore non può conoscere una proprietà che chi chiama non ha ancora impostato. La sequenza di chiamate normale è Create, poi ReproducibleOutput := True, poi BeginDoc. Al momento della costruzione FReproducibleOutput è ancora False, quindi FCreationDate riceveva Now e se lo teneva. L'identificatore e i byte casuali erano bloccati correttamente, quindi i due file concordavano quasi ovunque e non concordavano esattamente in due stringhe di data e due campi XMP. Spostare l'assegnazione nel ramo riproducibile di BeginDoc, accanto all'identificatore seedato, ha messo la decisione nel punto in cui la proprietà ha il suo valore definitivo
Il test di regressione che non ha colto questo vale più della correzione. Due salvataggi che girano entrambi dentro lo stesso secondo di orologio scrivono per caso la stessa stringa D:, e il confronto sui byte passa per un bug che fallisce su qualunque macchina più lenta. Il test corretto dorme 1100 ms tra i due salvataggi, così il timestamp PDF attraversa sicuramente un confine di secondo, esegue il caso per output plain, AES-128 e AES-256 con password reali sulle due varianti cifrate, e confronta i due buffer con CompareMem, riportando il primo offset diverso in caso di fallimento così il diff punta a un oggetto specifico invece che a un file intero. Un confronto sui byte prova il determinismo e nient'altro, quindi tieni un'asserzione separata che ricarichi l'output cifrato con la user password e legga un conteggio di pagine; una modifica che rende il file stabile e illeggibile allo stesso tempo non deve passare sulla forza di un 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;
// nel corpo del test
A := SaveOnce(PathA);
TThread.Sleep(1100); // forza un secondo diverso nel timestamp 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');
Un PDF cifrato riproducibile è ancora sicuro?
No. Un documento cifrato sotto ReproducibleOutput non è protetto in nessun senso significativo, e il flag deve essere spento per qualunque cosa esca dalla directory di test. La file encryption key AES-256 è trentadue byte di $5A, i salt sono otto byte di $5A, e i vettori di inizializzazione seguono uno schema aritmetico pubblicato. La password controlla ancora i wrapper /UE e /OE, ma la chiave incapsulata è una costante, quindi chiunque conosca la costante può decifrare ogni content stream senza alcuna password. Il fatto che i salt siano fissi rimuove anche l'unicità per documento su cui ISO 32000-2 §7.6.4.4.7 conta per impedire che password identiche producano stringhe /U identiche tra file diversi. Leggi l'articolo sulla configurazione di AES-256 per capire cosa promettono le proprietà di cifratura quando la sorgente casuale è intatta; sotto il flag riproducibile quelle promesse sono sospese
Il compromesso sull'identificatore è più sottile. ISO 32000-1 §14.4 vuole che il secondo elemento di /ID cambi a ogni modifica, così gli strumenti possono distinguere un file aggiornato dal suo antenato, e un salvataggio riproducibile scrive lo stesso valore in entrambi gli slot. Dato che quel valore è un hash dell'object graph canonico, due documenti con contenuto diverso ottengono comunque identificatori diversi, il che è meglio di una costante. Ma il seed che BeginDoc usa per la derivazione della chiave è la stessa stringa per ogni documento su ogni macchina, e un lettore che si basa su /ID per distinguere i file, per esempio una cache di annotazioni o un sidecar di dati di form, confonderà tra loro tutti i file riproducibili che per caso producono lo stesso hash
Cosa non copre il flag?
ReproducibleOutput rimuove l'entropia che il writer introduce da solo; non può rimuovere l'entropia che entra dall'ambiente o da percorsi di codice che non controlla, e tre di quelli sono facili da inciampare
- Il suffisso di fuso orario.
_DateTimeToPdfDateaggiunge l'offset UTC locale, quindiD:20260101000000+08'00'su un agente di build eD:20260101000000-05'00'su un altro sono byte diversi per la stessa data fissa. La riproducibilità vale tra esecuzioni su una macchina, o tra macchine che condividono un fuso orario; blocca il fuso dell'agente se i tuoi golden file viaggiano - Gli aggiornamenti incrementali.
SaveIncrementalUpdatecalcola il suo identificatore di modifica dal percorso target, daGetTickCounte dall'ora corrente senza nessun ramo riproducibile, perché una sezione incrementale è per definizione una modifica nuova. Confronta riscritture complete, non delta aggiunti - La scorciatoia passthrough.
SaveLoadedDocumentnormalmente copia un file sorgente non modificato e non cifrato byte per byte invece di riserializzarlo. Il flag riproducibile disabilita quella scorciatoia e forza una riscrittura completa perché le regole su ordinamento e identificatore si applichino, il che significa che il salvataggio riproducibile di un file caricato è più lento del default e non è mai una copia dell'input. Fagli il diff contro un salvataggio riproducibile precedente, mai contro l'originale
Un'altra lezione dalla stessa release, su cosa un controllo superato prova e cosa no. Una fixture di test PDF/X-6 chiamava CharProcs.DeleteValue('A'), che liberava uno stream di glifo tenuto direttamente, poi reinseriva lo stesso puntatore, e separatamente passava lo stesso oggetto ExtGState diretto sia a un dizionario di risorse sia a un pattern. Il validatore di conformità passava a intermittenza su quel use-after-free e quella doppia proprietà perché leggeva quello che la memoria liberata per caso conteneva. Quando un controllo strutturale fa i capricci, guarda la proprietà dell'input di test prima di guardare il validatore. L'output riproducibile rende quella disciplina più economica: una volta che due salvataggi sono identici byte per byte, l'unica fonte di sfarfallio che resta è l'object graph stesso, e un diff strutturale dal catalog in giù lo troverà
Le proprietà ReproducibleOutput, DeterministicDictionaryOrder e di cifratura qui descritte sono distribuite nella HotPDF Delphi Component standard per Delphi e C++Builder, e lo stesso flag pilota il corpus di regressione della libreria stessa, quindi il comportamento che ottieni in una suite di test è il comportamento con cui il componente viene testato