Articolo tecnico

PDF ID deterministico in Delphi per build riproducibili

losLab PDF Library può produrre output PDF byte identico per input identico una volta chiamato SetDeterministicDocumentID(1). Per impostazione predefinita l'array /ID del trailer è un digest MD5 dell'orologio di sistema, quindi due esecuzioni dello stesso generatore differiscono almeno in quei byte. La modalità deterministica deriva invece /ID da un seed stabile, il che ripristina la riproducibilità delle build

Il sintomo di solito emerge in CI prima che qualcuno lo cerchi apposta. Il template non è cambiato, il record di input non è cambiato, i font non sono cambiati, e il PDF generato produce comunque un hash diverso a ogni esecuzione della pipeline. Le build cache non vengono mai colpite. Il content addressable storage accumula un nuovo blob per ogni build notturna. I diff di regressione a livello di byte si accendono su file che nessuno ha toccato. Se insegui il diff fino ai byte effettivi, quasi sempre trovi la stessa manciata di cifre esadecimali seduta nel trailer del file

A cosa serve l'array ID del trailer

Il /ID del trailer è un marcatore di identità del file, non un checksum del contenuto. ISO 32000-1 §14.4 lo definisce come un array di due byte string: il primo elemento è l'identificatore permanente assegnato alla creazione del documento e destinato a sopravvivere a ogni modifica successiva, e il secondo elemento è l'identificatore mutevole che uno scrittore aggiorna ogni volta che il file viene modificato. Insieme permettono a un sistema di decidere se due file sono revisioni di un unico documento o due documenti non correlati. §7.5.5 rende la voce effettivamente obbligatoria in pratica, poiché il trailer deve portare /ID ogni volta che porta anche /Encrypt

Niente nella specifica dice come calcolare il valore. La raccomandazione è un digest di elementi come l'ora corrente, il percorso del file, la dimensione del file e il dizionario delle informazioni del documento, e l'orologio di sistema è l'ingrediente che rende il risultato unico. Questa è esattamente la proprietà che vuoi per l'identità e esattamente la proprietà che distrugge la riproducibilità, motivo per cui deve essere un interruttore esplicito piuttosto che un cambiamento di comportamento silenzioso

Perché la stessa build produce un PDF diverso ogni volta?

Perché l'identificatore predefinito viene derivato dal momento della generazione. Storicamente losLab PDF Library costruiva le stringhe /ID da un MD5 del timestamp corrente, quindi un documento creato due volte a un secondo di distanza porta due identificatori permanenti diversi anche quando ogni altro byte nel file è identico. Il costo a valle è reale: un build system che indicizza gli artefatti per hash non può mai riutilizzare uno step PDF, uno store di oggetti deduplicante mantiene una copia per build invece di una copia per documento, e un revisore che guarda un diff binario deve dimostrare che l'unico cambiamento è rumore prima di fidarsi del resto del diff. La generazione deterministica di /ID esiste per rimuovere quel rumore, nello stesso spirito del lavoro sulla stabilità del layout descritto nelle note su object stream e cross reference stream

Passare a un identificatore riproducibile

La modalità deterministica è opt in, per documento, e disattivata per impostazione predefinita, quindi l'output esistente resta invariato finché non la richiedi. SetDeterministicDocumentID accetta 0 o 1 e restituisce 1 quando il valore è stato accettato, 0 per qualsiasi valore fuori intervallo; GetDeterministicDocumentID riporta lo stato corrente. SetDocumentIDSeed fornisce una stringa di seed esplicita che ha la precedenza su tutto il resto, e passare un seed vuoto ripristina il seed derivato. GetDocumentFileID rilegge /ID[0] dopo il salvataggio così puoi loggarlo o asserirlo

var
  Lib: TPDFlib;
  FileID: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetDeterministicDocumentID(1);
    Lib.SetDocumentIDSeed('invoice-4471-rev3');
    Lib.SetOrigin(1);
    Lib.DrawText(100, 700, 'Invoice 4471');
    Lib.SaveToFile('invoice.pdf');
    FileID := Lib.GetDocumentFileID;   // identical on every run
  finally
    Lib.Free;
  end;
end;

L'aggiornamento avviene al momento del salvataggio, non quando attivi il flag, quindi abilitare la modalità deterministica tardi nella costruzione di un documento ha comunque effetto. Questo significa anche che un seed cambiato raggiunge il file al salvataggio completo successivo: imposta il seed A, salva, imposta il seed B, salva, e i due file portano identificatori diversi, mentre ripristinare il seed A ripristina il valore originale. Un seed esplicito è la scelta giusta ogni volta che il tuo documento ha una chiave stabile naturale come un numero di fattura, una revisione di record o un identificatore di commit git, perché disaccoppia l'identificatore dai metadati incidentali

Da dove viene il seed quando non ne fornisci uno?

Senza un seed esplicito, losLab PDF Library ne deriva uno dallo stato del documento che dovrebbe essere invariante attraverso rigenerazioni identiche: l'header di versione PDF, il numero di pagine, e ogni voce nel dizionario delle informazioni del documento. I valori stringa e nome vengono presi alla lettera, altri tipi di oggetto contribuiscono la loro forma serializzata, e il tutto viene sottoposto a hash nelle stringhe /ID. La conseguenza importante è che CreationDate e ModDate fanno parte del dizionario delle informazioni e quindi, per progetto, fanno parte del seed. Due esecuzioni ottengono lo stesso identificatore solo quando producono genuinamente gli stessi metadati del documento

Lib.SetDeterministicDocumentID(1);
// No SetDocumentIDSeed: the seed is derived from document state,
// so the timestamps in the Info dictionary have to be pinned.
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');

Fissare ModDate con la chiave 8 svolge un doppio ruolo, ed è questa la parte che coglie le persone di sorpresa. Un /ID deterministico da solo non rende il file byte identico, perché il percorso di salvataggio timbra ModDate con l'ora corrente a meno che il chiamante non l'abbia impostata esplicitamente. Impostare la chiave 8 marca il valore come fornito dal chiamante e sopprime quel timbro. Se vuoi un file riproducibile e non semplicemente un identificatore riproducibile, tratta i timestamp dei metadati come input della build: derivali dal record sorgente o da un'epoca fissa, mai da Now

Perché riscrivere l'ID rompe un PDF cifrato?

Perché /ID[0] non è solo metadato in un documento cifrato, è materiale chiave. ISO 32000-1 §7.6.3.3 Algoritmo 2 fa confluire il primo elemento dell'identificatore del file nel calcolo della chiave di cifratura per l'handler di sicurezza standard alle revisioni da 2 a 4, insieme alla password imbottita, al valore /O e ai bit di permesso. La chiave derivata produce poi la stringa di validazione /U che un reader verifica all'apertura, e la chiave del file viene derivata e memorizzata quando chiami Encrypt o quando un documento cifrato viene caricato, entrambi eventi che avvengono prima del salvataggio. Riscrivere l'identificatore durante il salvataggio emetterebbe quindi un file strutturalmente valido il cui controllo /U fallisce alla riapertura: non una corruzione sottile ma un documento che nessuno può aprire, te compreso. Ecco perché l'aggiornamento deterministico è limitato ai documenti che non portano stato di cifratura, e perché un documento cifrato mantiene qualsiasi /ID avesse già, modalità deterministica o no, e l'impostazione semplicemente non ha effetto su quel percorso. La gestione delle revisioni correlate e la semantica dei permessi sono trattate nella panoramica su audit di cifratura e permessi PDF. Nota anche che il percorso di ripristino della cifratura aggiorna solo /ID[1], l'identificatore di modifica, esattamente come previsto da §14.4

Perché i salvataggi incrementali mantengono l'identificatore originale

Il secondo confine è la modalità append. Un aggiornamento incrementale lascia intatto ogni byte precedente del file e scrive una nuova revisione dopo di esso, e la permanenza di /ID[0] secondo §14.4 è ciò che dice a un consumatore che la nuova revisione appartiene allo stesso documento della precedente. Riscriverla spezzerebbe quel legame, contraddirebbe le revisioni già presenti nel file, e interferirebbe con la semantica delle firme, poiché una firma copre un intervallo di byte di una revisione specifica di un documento specifico. losLab PDF Library quindi aggiorna l'identificatore deterministico solo nei salvataggi completi e mai durante la modalità append, il che mantiene intatta la garanzia descritta nell'articolo su aggiornamenti incrementali PDF e append allo stream

Un unico punto di passaggio per la generazione dell'identificatore

Tutta la generazione di /ID in losLab PDF Library ora confluisce attraverso un'unica routine interna, NewFileIDString, ed è questo che rende l'interruttore deterministico affidabile invece di una toppa su un solo percorso di codice. La creazione di documenti vuoti, la creazione lazy di un array /ID mancante su richiesta, e il percorso di ripristino dell'impronta di cifratura la chiamano tutti, quindi esiste esattamente un punto dove l'orologio di sistema potrebbe rientrare. Significa anche che varianti future, come un identificatore derivato dal contenuto, sono un cambiamento a una sola funzione invece di un audit dell'intero serializzatore

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;

// Regression guard: two independent builds, one byte sequence.
if BuildQuote('quote-8812') = BuildQuote('quote-8812') then
  WriteLn('reproducible')
else
  WriteLn('nondeterminism leaked into the output');

Collega questo confronto alla tua test suite prima di fare affidamento sull'output riproducibile altrove, perché fallisce rumorosamente nel momento in cui una nuova funzionalità reintroduce un timestamp. La riproducibilità è altrimenti una proprietà che decade silenziosamente, e una singola asserzione su due salvataggi in memoria costa quasi nulla da eseguire a ogni build

L'API dell'identificatore deterministico mostrata qui è distribuita con losLab PDF Library per Delphi e C++Builder, insieme al riferimento completo su informazioni documento, cifratura e salvataggio incrementale