Articolo tecnico

PDF ID deterministico in Delphi per build riproducibili

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

Diagramma che confronta esecuzioni di build Delphi identiche i cui ID trailer PDF basati sull'orologio di default differiscono contro un output deterministico byte identico
Il trailer id di default ripiega l'orologio dentro l'identità del file mentre la modalità deterministica lo deriva da un seed stabile

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

Flusso di decisione di PDF Library for Delphi che mostra SetDocumentIDSeed con priorità sul seed derivato dallo stato documento usato per gli ID PDF deterministici
Un seed esplicito scavalca tutto il resto mentre il percorso derivato fa l'hash delle voci del dizionario info comprese CreationDate e ModDate
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

PDF Library for Delphi: mappa dei guard rail dell'ID PDF deterministico che copre salvataggi completi, documenti cifrati, aggiornamenti incrementali e il percorso di ripristino della cifratura
Solo i salvataggi completi non cifrati possono rinfrescare l'id poiché i file cifrati trattano /ID[0] come materiale della chiave e le revisioni in coda devono restare collegate

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