Il PDFium Component rende l'output PDF riproducibile una chiamata sola: TPdf.SaveAsReproducible riscrive il documento così che salvare lo stesso contenuto due volte dia file byte-identici. Rimuove /CreationDate e /ModDate dal dizionario informativo del documento, non scrive alcun /ID nel trailer, tiene stabile l'ordine degli oggetti, e riporta cosa ha cambiato in un TPdfReproducibleReport. I file cifrati vengono rifiutati, e quelli firmati richiedono consenso esplicito
Il bisogno di solito si manifesta in una pipeline di build. Qualcuno fa il commit dei PDF generati in un repository o in una suite di test golden-file, e ogni esecuzione produce un diff anche se nulla su nessuna pagina è cambiato. Oppure un archivio fa dedup per SHA-256 e conserva silenziosamente cinquanta copie della stessa fattura, perché ogni salvataggio timbrava un nuovo orario nel file. Uno scrittore PDF che incapsula l'orologio da parete va bene per una app desktop ed è ostile a qualunque cosa che tratti i byte come identità
Perché salvare due volte lo stesso PDF produce byte diversi?
Due salvataggi di un documento invariato differiscono per esattamente due generi di dati: i timestamp nel dizionario informativo del documento e l'identificatore di file nel trailer. Tutto il resto che uno scrittore curato emette, numerazione degli oggetti, ordine degli oggetti, layout cross-reference, può essere reso deterministico, e il percorso di riscrittura completa del PDFium Component lo fa già: gli oggetti vengono scritti in numero di oggetto crescente e la tabella xref segue lo stesso ordine
I timestamp sono il colpevole ovvio. ISO 32000-1 §14.3.3 definisce /CreationDate e /ModDate nel dizionario /Info, e il salvataggio nativo di PDFium scrive una /ModDate fresca ogni volta che serializza il documento. L'identificatore di file è quello sottile. ISO 32000-1 §14.4 descrive /ID come un array a due elementi di stringhe di byte, dove il primo elemento identifica il file originale e il secondo cambia quando il file viene revisionato. Gli scrittori di solito ricavano quelle stringhe facendo l'hash di qualcosa sul file, e se quel qualcosa include i byte timbrati, l'identificatore eredita il non determinismo
Usare TPdf.SaveAsReproducible in Delphi
TPdf.SaveAsReproducible ha due overload, uno che scrive su un TStream e uno su un nome file, ed entrambi restituiscono Boolean con un out Report: TPdfReproducibleReport e un parametro opzionale AllowSignedDocument che vale False per predefinito. Il valore di ritorno rispecchia Report.Succeeded; quando è False, Report.ErrorMessage dice perché. L'overload su file scrive su un file temporaneo accanto al bersaglio e sostituisce il bersaglio solo dopo un salvataggio riuscito, così un giro fallito non lascia un PDF scritto a metà dove prima stava l'output della build precedente
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;
I campi del report meritano di finire nel log della CI. StrippedTimestampCount vale di norma 2 per un documento il cui /Info porta entrambe le date, e 0 per uno senza affatto un dizionario /Info. PrunedObjectCount conta gli oggetti che erano presenti nel salvataggio nativo ma non raggiungibili dal catalogo o dal dizionario /Info; la riscrittura riproducibile li butta, quindi aspettatevi un output della stessa dimensione o più piccolo di un salvataggio ordinario
Perché togliere i timestamp non bastava
La prima implementazione rimuoveva entrambi i timestamp e produceva comunque due file diversi. Le pagine corrispondevano, il dizionario /Info corrispondeva, e i trailer differivano nel secondo elemento di /ID. La causa radice era l'ordine: la riscrittura prima chiede a PDFium un salvataggio nativo, poi post-processa quei byte, e il costruttore del trailer calcolava /ID facendo l'hash dell'output nativo. Quell'output nativo conteneva ancora la /ModDate fresca, così l'hash, e quindi l'identificatore, cambiava a ogni esecuzione anche se il timestamp stesso non raggiungeva mai il file finale
È un buon esempio di una regola generale per le build riproducibili: togliere un valore non deterministico non basta se qualcosa è stato prima derivato da esso. La correzione fu smettere del tutto di portare /ID. TPdf.SaveAsReproducible scrive un trailer contenente solo /Size, /Root, e, quando la sorgente ne ha uno, /Info. Senza niente di dipendente dal tempo rimasto nel trailer, due salvataggi consecutivi dello stesso documento risultano uguali byte per byte, che è esattamente come il comportamento viene verificato
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;
Togliere chiavi di dizionario senza corrompere le stringhe
I timestamp vengono rimossi con una scansione consapevole dei token del corpo dell'oggetto /Info, non con una ricerca di testo. Un replace di byte ingenuo per /ModDate beccherebbe anche una stringa /Subject o /Keywords che per caso contiene quel testo, e tagliare byte da una stringa letterale corrompe in silenzio i metadati invece di fallire rumorosamente. Il PDFium Component prima neutralizza stringhe letterali e commenti in una copia di scansione del corpo, poi accetta una corrispondenza solo dove inizia un vero token di nome, usa la grammatica degli oggetti PDF per trovare dove finisce il valore di quella chiave, e salta l'intera coppia chiave-valore
La conseguenza pratica è che ogni altra voce in /Info, titolo, autore, produttore, chiavi personalizzate, sopravvive intatta. Solo le due chiavi definite come date vengono rimosse, e solo dal dizionario al quale la voce /Info del trailer punta davvero
Cosa costa omettere /ID per i PDF cifrati e firmati?
Omettere /ID è sicuro per output PDF 1.x semplice, ma esclude la cifratura e cambia ciò che accade alle firme. In ISO 32000-1 il /ID del trailer è opzionale salvo che sia presente una voce /Encrypt, perché il security handler standard mescola la prima stringa identificatore nella chiave di cifratura. Un file senza /ID non può essere un file correttamente cifrato, così TPdf.SaveAsReproducible non ci prova: quando il trailer porta /Encrypt, restituisce False e Report.ErrorMessage dice che il documento è cifrato. Decifrate prima se la vostra pipeline lo consente, o tenete i deliverable cifrati fuori dai controlli di identità byte. Se puntate a PDF 2.0, notate che ISO 32000-2 stringe la regola e richiede /ID in ogni file, quindi l'output riproducibile si sposa più naturalmente con bersagli PDF 1.x; l'articolo sulla conformità esatta alla versione PDF in Delphi copre come il componente gestisce i bersagli di versione
I documenti firmati sono un problema diverso. TPdf.SaveAsReproducible è una riscrittura completa non incrementale, così gli offset di byte si spostano e il /ByteRange delle firme esistenti non copre più i byte che erano stati firmati. L'output riproducibile di un file firmato è quindi una decisione deliberata di rinunciare alla validità delle firme esistenti in cambio di byte stabili, ed è per questo che il metodo solleva EPdfError quando SignatureCount è maggiore di zero salvo che passiate AllowSignedDocument = True
if Pdf.SignatureCount > 0 then
// Opt-in esplicito: la riscrittura sposta gli offset di byte, quindi le firme
// esistenti devono aspettarsi di fallire la verifica in seguito
Ok:= Pdf.SaveAsReproducible('normalized.pdf', Report, True)
else
Ok:= Pdf.SaveAsReproducible('normalized.pdf', Report);
Se l'originale firmato è il registro della verità, tenetelo com'è e usate l'output riproducibile solo per copie derivate, come una resa normalizzata da confrontare in CI. Per apporre modifiche a un file protetto senza disturbare ciò che c'è già, vedere perché un PDF cifrato non può essere patchato in silenzio in Delphi
Confini da conoscere prima di fidarsi dell'identità byte
L'identità byte vale per lo stesso documento salvato dalla stessa build del componente, e copre esattamente ciò che il metodo tocca. TPdf.SaveAsReproducible toglie solo le due date /Info; uno stream di metadati XMP con il suo xmp:CreateDate o xmp:ModifyDate viene portato attraverso come i byte che il documento già contiene. Questo è stabile attraverso i salvataggi, ma significa che le date XMP e /Info non descrivono più la stessa cosa, il che conta per i profili PDF/A che richiedono che le due concordino; l'articolo sull'equivalenza dei metadati Info e XMP in PDF/A spiega quelle regole. Un binario PDFium o una versione del componente diversi possono anche serializzare gli oggetti diversamente, quindi pinnate entrambi in qualunque pipeline che confronti hash fra macchine
Dentro quei limiti, il metodo vi dà ciò che i diff della CI, i test golden-file e gli archivi a contenuto indirizzato devono avere: stesso input, stessi byte. TPdf.SaveAsReproducible e TPdfReproducibleReport vengono spediti con il PDFium Component for Delphi and C++Builder, accanto al resto della sua API di salvataggio, ottimizzazione e conformità
FAQ
TPdf.SaveAsReproducible cambia il contenuto delle pagine?
No. Rimuove le chiavi /CreationDate e /ModDate dal dizionario /Info, omette il /ID del trailer, e butta gli oggetti non raggiungibili dal catalogo o da /Info; il contenuto delle pagine viene scritto invariato
Si può produrre output riproducibile da un PDF cifrato?
No. La cifratura dipende dal /ID del trailer, che l'output riproducibile omette, quindi il metodo restituisce False con un ErrorMessage esplicativo per le sorgenti cifrate
Perché un PDF firmato ha bisogno di AllowSignedDocument?
Il salvataggio riproducibile è una riscrittura completa che sposta gli offset di byte, così le firme esistenti smettono di corrispondere ai loro intervalli di byte firmati. Passare AllowSignedDocument = True conferma che accettate quel compromesso