Generate la stessa fattura due volte dallo stesso template e dagli stessi dati, poi fate il diff dei due PDF. Due punti differiscono: le stringhe esadecimali nell'array /ID del trailer e la stringa ModDate nel dizionario di informazioni del documento, entrambe prese dall'orologio. In losLab PDF Library, SetDeterministicDocumentID(1) più un ModDate fisso rende i file byte identici, per un documento senza UUID XMP per esecuzione o allegati
Il primo tentativo ovvio è bloccare le date e rieseguire. CreationDate e ModDate ora coincidono, e gli hash differiscono ancora, ora solo in due stringhe esadecimali di 32 cifre proprio verso la fine del file, dentro /ID [<…> <…>]. Quell'identificatore viene calcolato separatamente dai metadati, e finché non smette di dipendere dal tempo del salvataggio, una cache di build indicizzata sull'hash del file non colpisce mai e uno store addressable per contenuto conserva un blob per esecuzione invece di uno per documento
A cosa serve l'array ID del trailer
Il /ID del trailer identifica un file; non fa checksum del suo contenuto. ISO 32000-1 §14.4 lo definisce come array di due stringhe di byte. La prima è l'identificatore permanente, assegnato quando il documento viene creato e pensato per sopravvivere a ogni modifica successiva. La seconda è l'identificatore variabile, che uno writer rinfresca ogni volta che modifica il file. Un sistema di gestione documentale confronta la coppia per decidere se due file sono revisioni di un documento o due documenti senza relazione. La Tabella 15 del §7.5.5 rende la voce obbligatoria quando il trailer porta /Encrypt e opzionale altrimenti, e il §14.4 nota che ometterla può intralciare i flussi di lavoro che contano sull'identità del file
La specifica non dice mai come calcolare il valore. Suggerisce un digest MD5 su cose come il tempo corrente, il percorso del file, la dimensione del file e i valori nel dizionario di informazioni, e l'orologio è l'ingrediente che rende il risultato unico. L'unicità è esattamente ciò che volete da un marcatore di identità ed esattamente ciò che distrugge la riproducibilità. Per default losLab PDF Library segue quel suggerimento nella sua forma più semplice, un MD5 del timestamp corrente, così due documenti creati a un secondo di distanza ricevono identificatori permanenti diversi anche quando ogni altro byte coincide
Attivare un identificatore riproducibile
La modalità deterministica è un interruttore per documento ed è spenta per default, così l'output esistente non cambia finché non lo chiedete. SetDeterministicDocumentID prende 0 o 1 e restituisce 1 quando il valore è stato accettato, 0 per qualsiasi altra cosa; GetDeterministicDocumentID riporta l'impostazione corrente. SetDocumentIDSeed fornisce una stringa di seed esplicita, e con un seed in posto l'identificatore è un MD5 di quella stringa, scritto in entrambi gli elementi dell'array. GetDocumentFileID rilegge /ID[0] dopo il salvataggio, utile per il logging o per un assert di test
var
Lib: TPDFlib;
FileID: WideString;
begin
Lib := TPDFlib.Create;
try
Lib.SetDeterministicDocumentID(1); // subito dopo Create
Lib.SetDocumentIDSeed('invoice-4471-rev3');
Lib.SetInformation(7, 'D:20260101000000Z'); // CreationDate
Lib.SetInformation(8, 'D:20260101000000Z'); // ModDate
Lib.SetOrigin(1);
Lib.DrawText(100, 700, 'Invoice 4471');
Lib.SaveToFile('invoice.pdf');
FileID := Lib.GetDocumentFileID; // stesso valore a ogni esecuzione
finally
Lib.Free;
end;
end;
L'identificatore viene calcolato al momento del salvataggio. Girare l'interruttore tardi in una build di documento funziona comunque, e un seed cambiato arriva al file al prossimo salvataggio completo: salvate con seed A, salvate di nuovo con seed B, e i due output portano identificatori diversi, mentre tornando al seed A vi riprendete il valore originale
Attivate però l'interruttore subito dopo aver creato l'oggetto. Font, immagini, stati grafici e altre risorse di pagina ricevono nomi nel dizionario risorse della pagina, e mentre la modalità deterministica è attiva quei nomi vengono da un contatore. Qualsiasi cosa aggiunta prima dell'interruttore conserva un nome generato casualmente, il che lascia l'identificatore stabile ma cambia qualche byte negli stream di contenuto a ogni esecuzione
Perché l'identificatore cambia ancora quando nessun seed è impostato?
Perché il dizionario di informazioni fa parte dell'hash. Lasciate il seed vuoto, o passate una stringa vuota a SetDocumentIDSeed per pulirne uno, e losLab PDF Library deriva l'identificatore dal file stesso: fa girare il body serializzato attraverso SHA-256 mentre lo scrive e usa i primi 16 byte del digest per entrambi gli elementi di /ID. Qualsiasi modifica al contenuto della pagina cambia l'identificatore, che è proprio il punto. La presa è che CreationDate e ModDate vivono anche loro in quel body, e le routine di salvataggio timbrano ModDate con l'ora corrente a meno che il chiamante l'abbia impostata
Lib.SetDeterministicDocumentID(1);
Lib.SetDocumentIDSeed(''); // deriva /ID dai byte salvati
// L'hash del body copre il dizionario Info, quindi le date devono essere
// input di build. La chiave 8 impedisce anche al salvataggio di timbrare ModDate.
Lib.SetInformation(2, 'Quarterly Report'); // Title
Lib.SetInformation(5, 'reporting-service 4.2'); // Creator
Lib.SetInformation(7, 'D:20260101000000Z'); // CreationDate
Lib.SetInformation(8, 'D:20260101000000Z'); // ModDate
Lib.SaveToFile('report.pdf');
La chiave 8 qui fa doppio lavoro, ed è la riga che la gente omette più spesso. Impostare ModDate tramite SetInformation marca la data come fornita dal chiamante, e da quel momento SaveToFile e SaveToString la lasciano in pace. Senza di essa ogni salvataggio scrive un timestamp fresco, il body con hash cambia, e l'identificatore derivato dal contenuto cambia con lui. Prendete le date dal record sorgente o da un'epoca fissa. Now non c'entra niente in una build riproducibile
Alcune cose sono tenute fuori dall'hash di proposito. I nomi delle risorse generati casualmente e l'UUID in una voce XMP xmpMM:InstanceID vengono normalizzati prima dell'hash, così due esecuzioni che differiscono solo in quei token ricevono lo stesso /ID. I byte del file differiscono comunque in quei punti, quindi un documento in stile PDF/A con un UUID di istanza fresco per esecuzione vi dà un identificatore riproducibile ma non un file byte identico. Gli allegati incorporati sono un'altra sorgente di deriva: AddAssociatedFileFromString timbra il /ModDate proprio dell'allegato con l'ora corrente, e quella data viene hashata come qualsiasi altro byte del body
Seed esplicito o hash del contenuto
Per gli artefatti di build lasceremmo il seed vuoto. Un identificatore derivato dal contenuto cambia esattamente quando i byte cambiano, così una cache indicizzata su di esso non può mai servire un file stantio, e non vi ritrovate una seconda chiave da tenere sincronizzata con i dati. Un seed esplicito sta bene per un lavoro diverso: un archivio a valle che tratta /ID[0] come identità del documento e si aspetta lo stesso identificatore attraverso le rigenerazioni della stessa fattura, anche dopo che il template cambia. Questo rispecchia meglio l'intento del §14.4, al prezzo che due file visibilmente diversi possono condividere un identificatore
Una conseguenza del percorso del contenuto merita una decisione deliberata. Quando caricate un PDF esistente e lo salvate per intero con la modalità deterministica attiva, il /ID in arrivo viene scartato e quello nuovo dipende solo da ciò che viene scritto. Due file sorgente che differiscono solo nei loro identificatori di trailer escono con lo stesso /ID. È ciò che un passo di build normalizzante vuole. Se state modificando il documento di un cliente e la sua identità deve sopravvivere, lasciate la modalità deterministica spenta per quel lavoro o salvatelo incrementalmente
Perché riscrivere l'ID rompe un PDF cifrato?
Perché in un documento cifrato /ID[0] è materiale della chiave. ISO 32000-1 §7.6.3.3 Algoritmo 2 alimenta il primo elemento dell'identificatore di file nel calcolo della chiave di cifratura per il gestore di sicurezza standard alle revisioni da 2 a 4, insieme alla password con padding, al valore /O e ai bit di permesso. Quella chiave poi produce la stringa /U che un reader controlla all'apertura. La chiave viene derivata quando chiamate Encrypt o caricate un file cifrato, prima di ogni salvataggio, quindi sostituire l'identificatore durante il salvataggio produrrebbe un file ben formato il cui controllo della password fallisce per tutti, voi inclusi
losLab PDF Library quindi rinfresca l'identificatore deterministico solo sui salvataggi completi non cifrati. Un documento cifrato conserva qualunque /ID avesse già, e l'interruttore non ha effetto su quel percorso. Riaprire un tale file con la password utente funziona come al solito. Non mettete output cifrati in un confronto byte, nemmeno: la cifratura AES sotto il §7.6.2 comincia ogni stringa e stream cifrato con un vettore di inizializzazione casuale di 16 byte, così due cifrature dello stesso documento non coincidono mai byte per byte. Se lì vi serve un controllo di regressione, decifrate entrambi e confrontate ciò che vi torna indietro. La gestione delle revisioni e i bit di permesso sono trattati più a fondo nella passeggiata di audit della cifratura e dei permessi PDF
I salvataggi incrementali conservano l'identificatore originale
Un aggiornamento incrementale lascia ogni byte precedente del file al suo posto e accoda una nuova revisione dopo. La permanenza di /ID[0] è ciò che dice a un consumatore che la revisione in coda appartiene allo stesso documento di quelle prima. Riscriverla contraddirebbe le revisioni già nel file e minerebbe i flussi di firma, dato che una firma copre un intervallo di byte di una revisione specifica. La modalità append non tocca mai l'identificatore in modalità deterministica, il che mantiene intatto il comportamento descritto nell'articolo sugli aggiornamenti incrementali PDF e append to stream
Mantenere l'output riproducibile nel tempo
Ogni modifica successiva al generatore è una possibilità di rimettere un valore di orologio nel file: un watermark che stampa l'ora di generazione, un percorso di codice che allega un file, una diversa opzione di salvataggio. Le opzioni di salvataggio contano quanto il contenuto: impacchettare gli oggetti in object stream compressi produce un layout di file completamente diverso dalla classica tabella dei riferimenti incrociati, come spiegato nelle note su object stream e cross reference stream, quindi tenete quelle impostazioni fisse per pipeline. La difesa più economica è un test che costruisce lo stesso documento due volte in memoria e confronta i byte
function BuildQuote(const Seed: WideString): AnsiString;
var
Lib: TPDFlib;
begin
Lib := TPDFlib.Create;
try
Lib.SetDeterministicDocumentID(1);
Lib.SetDocumentIDSeed(Seed);
Lib.SetInformation(7, 'D:20260101000000Z');
Lib.SetInformation(8, 'D:20260101000000Z');
Lib.SetOrigin(1);
Lib.DrawText(100, 700, 'Quote 8812');
Result := Lib.SaveToString;
finally
Lib.Free;
end;
end;
// Due build indipendenti devono dare una sola sequenza di byte
if BuildQuote('quote-8812') = BuildQuote('quote-8812') then
WriteLn('reproducible')
else
WriteLn('nondeterminism leaked into the output');
Lo stesso harness può confrontare un'esecuzione seriale e una parallela di RecompressStreams sulla stessa sorgente: caricatela in due istanze TPDFlib, attivate gli ID deterministici e bloccate ModDate su entrambe, impostate SetOptimizationWorkers(1) su una e un numero di worker più alto sull'altra, e fate il diff dei due risultati SaveToString. Con l'orologio tolto da entrambi i file, ogni differenza che resta viene dalla ricompressione stessa
Identificatori di trailer riproducibili, controllo dei metadati e salvataggio incrementale fanno tutti parte di losLab PDF Library for Delphi e C++Builder, il cui reference elenca anche le opzioni di cifratura menzionate qui