A salvare un PDF senza cambiare nulla può comunque corrompere i metadati del documento in due modi: quando /CreationDate e /ModDate riferiscono lo stesso oggetto stringa, un aggiornamento automatico di ModDate che scrive dentro quell'oggetto riscrive entrambe, e quando l'oggetto XMP viene creato prima che sia letto lo stream /Metadata originale, un pacchetto di default lo sostituisce. PDF Library for Delphi sostituisce i riferimenti nel dizionario invece di mutare gli oggetti condivisi, e cattura il pacchetto esistente prima dell'inizializzazione lazy di XMP
Salvare è l'operazione meno interessante che una libreria PDF compia: carica un file, salvalo con un altro nome, non toccare niente nel mezzo. Le pagine si renderizzano identiche prima e dopo, e gli hash dei content stream corrispondono. Un file può passare ogni controllo del genere ed essere comunque sbagliato in due punti che nessun renderer ti mostrerebbe mai. Entrambe le trappole stanno nel percorso read-modify-write da cui passa ogni modifica reale, quindi qualunque salvataggio basta a farle scattare, e solo un secondo parser indipendente che confronti la semantica non visiva dei due file le rivela
Perché salvare un PDF ne cambia la CreationDate?
Perché il document information dictionary può riferire un unico oggetto stringa indiretto da due chiavi, e uno writer che aggiorna l'oggetto invece della chiave cambia entrambe. ISO 32000-1 §7.3.10 consente a qualunque valore di dizionario di essere un riferimento indiretto, e niente nella §14.3.3 Table 317 dice che il valore sotto /CreationDate debba essere un oggetto diverso dal valore sotto /ModDate. Un producer che alla creazione ha scritto due volte lo stesso timestamp può, del tutto legalmente, puntare entrambe le chiavi su un solo 2728 0 R, e i file reali fanno esattamente questo
Il grilletto è la data di modifica automatica. A meno che UserModDate sia impostato, SaveToFile chiama SetInfo('ModDate', ...) con l'ora corrente prima di scrivere, e la chiamata arriva in SetRawInfo. Un SetRawInfo ingenuo cerca l'oggetto sotto la chiave e, se trova un TPDFString, ci chiama SetTo sopra. Quella è una scrittura in place su qualunque oggetto la chiave risolva in quel momento, e quando quell'oggetto è condiviso anche /CreationDate riporta l'ora del salvataggio. Il documento si apre, si stampa e si renderizza come prima pixel per pixel, quindi una suite di regressione visiva passa senza battere ciglio
var
Lib: TPDFlib;
Before, After: WideString;
begin
Lib := TPDFlib.Create;
try
Lib.LoadFromFile('design.pdf', '');
Before := Lib.GetInformation(7); // 7 = CreationDate, 8 = ModDate
Lib.SaveToFile('design-resaved.pdf');
Lib.LoadFromFile('design-resaved.pdf', '');
After := Lib.GetInformation(7);
if Before <> After then
Log('a save that changed nothing rewrote CreationDate');
finally
Lib.Free;
end;
end;
La regola in TPDFDocument.SetRawInfo è piccola e il principio che ci sta dietro è generale: aggiornare una entry di un dizionario sostituisce il riferimento di quella entry, mai l'oggetto che per caso risolveva. Il codice legge il TPDFStringMode esistente, così una stringa hex resta hex e una literal resta literal, poi aggiunge una stringa nuova da FStructure.NewString(Value, StringMode) sotto la chiave. Altri due dettagli contano quanto questo. Una entry con valore di stream viene sostituita senza azzerare prima il vecchio stream, perché un SetTo('') su di esso svuoterebbe il valore per ogni altra chiave che punta ancora a quello stream. E l'oggetto superato non viene cancellato, perché è la struttura a possederlo e altri riferimenti potrebbero ancora servirsene
// Naive: muta qualunque oggetto la chiave risolva al momento
if Obj is TPDFString then
TPDFString(Obj).SetTo(Value);
// Corretto: conserva la rappresentazione, sostituisce solo il riferimento di questa chiave
StringMode := smLiteral;
if Obj is TPDFString then
StringMode := TPDFString(Obj).StringMode;
ID.Add(Key, FStructure.NewString(Value, StringMode));
Un test per questo dovrebbe costruire l'aliasing di proposito invece di affidarsi a un file che per caso condivide oggetti: una stringa hex riferita da entrambe le chiavi di data, una stringa diretta condivisa da /Title e /Subject, uno stream condiviso da /Author e /Keywords. Dopo aver aggiornato una chiave di ogni coppia, l'altra deve continuare a leggere il suo valore originale e la stringa aggiornata deve restare hex. Il riferimento pubblico di SetInformation enuncia la garanzia in una frase: aggiornare un campo di Info sostituisce solo quel campo, anche quando altri campi riferiscono lo stesso oggetto
Perché un pacchetto XMP esistente viene sostituito dai default?
Per l'ordine di due righe. TPDFDocument.GetMetadata ha un fast path: quando il campo XMP è già assegnato, restituisce XMP.SaveToString invece di decodificare lo stream /Metadata dal catalog. Un'inizializzazione lazy scritta come XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata); si legge in modo naturale ed è sbagliata: quando GetMetadata gira, XMP è assegnato, quindi la "sorgente" che viene caricata è il pacchetto di default serializzato di un oggetto creato una riga prima. Il pacchetto originale, con il suo dc:creator, i namespace custom e qualunque identificazione di standard, non arriva mai all'oggetto e viene sovrascritto al salvataggio. La stessa data di modifica automatica basta a farla scattare, perché SetInfo inizializza XMP prima di toccare il dizionario Info, in modo che xmp:ModifyDate resti allineato a /ModDate. Nota dietro cosa si nasconde questa trappola: il confronto del dizionario Info del primo caso passa, dato che /Author e /Title dentro /Info sono intatti. È cambiato solo l'albero XMP, e solo un controllo che analizza e confronta quell'albero se ne accorge
// Sbagliato: GetMetadata ora serializza l'oggetto creato alla riga precedente
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(GetMetadata);
// Giusto: prima cattura lo stream /Metadata, poi crea e carica
Source := GetMetadata;
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(Source);
PDF Library for Delphi tiene quell'ordine in un solo posto. TPDFDocument.EnsureXMP cattura Source := GetMetadata prima di TPDFlibXMP.Create, e ogni inizializzazione lazy nel documento passa da lì: SetInfo, SetXMPInformation, GetXMPInformation, i setter delle modalità PDF/A, PDF/X, PDF/E, PDF/VT, PDF/VCR e PDF/UA, e il percorso di riparazione dei metadati. Anche gli entry point pubblici come SetXMPProperty passano da EnsureXMP, e GetXMPProperty legge attraverso GetDocumentMetadata, quindi tutta la superficie condivide un solo ordine di inizializzazione. Una sola copia corretta di una sequenza di tre righe vale più di dieci copie che oggi per caso concordano
Due trappole più piccole sullo stesso percorso
Il serializer XMP su Windows usa il writer XML della piattaforma, che emette una dichiarazione XML che il pacchetto non deve portare. La via ovvia per eliminarla, cancellare caratteri finché non si raggiunge <?xpacket, fallisce su input validi: ISO 16684-1 §7.3.2 rende il wrapper xpacket opzionale, e un producer che scrive un elemento <x:xmpmeta> nudo è dentro lo standard, quindi su un pacchetto così il ciclo cancellerebbe l'intero documento valido. Il serializer individua il ?> di chiusura della dichiarazione e rimuove solo quello. Un controllo di retention degno di nota gira due volte, una con il wrapper e una con il wrapper tagliato via, e verifica che un marker di namespace custom e l'autore originale sopravvivano a SetInfo, GetMetadata, SaveToString e a un ricaricamento. La seconda trappola è un simbolo del preprocessore. NOVCL è definito per le build Free Pascal, ma il backend XMP è condizionato dal sistema operativo, non dal framework, dato che PDFlibXMP.pas definisce NO_XMP solo quando OS_WINDOWS è assente. Proteggere con NOVCL la sincronizzazione Info-to-XMP in SetInfo lascerebbe a una build Windows con Lazarus un oggetto XMP funzionante e un SetInfo che ne salta l'aggiornamento in silenzio. La guardia è NO_XMP, così un'applicazione Free Pascal su Windows ottiene la stessa sincronizzazione di Delphi
Come si conserva la ModDate originale in un salvataggio pass-through?
Impostando KeepModDate in TPDFlibSaveOptions e salvando tramite SaveToFileOptions. L'opzione imposta UserModDate per la durata della chiamata, e SaveToFile salta così il timestamp automatico, che è anche il passo che inizializza in modo lazy l'oggetto XMP. Un documento i cui metadati non hai mai toccato, e per cui non è stata abilitata nessuna modalità di conformità, mantiene sia il dizionario Info sia lo stream /Metadata come caricati. Chiamare SetInformation(8, ...) ha lo stesso effetto in modo permanente, perché impostando tu la data di modifica la marchi come controllata dall'utente
var
Options: TPDFlibSaveOptions;
begin
FillChar(Options, SizeOf(Options), 0);
Options.OptimizeContentStreams := True;
Options.PackObjectStreams := True;
Options.KeepModDate := True; // nessun /ModDate automatico, nessuna init XMP lazy
if Lib.SaveToFileOptions('design-resaved.pdf', Options) <> 1 then
Log(Format('save failed, LastErrorCode=%d', [Lib.LastErrorCode]));
end;
Sii onesto su cosa ti compra questa opzione. KeepModDate è la scelta giusta per un passaggio pass-through il cui output deve descrivere la stessa revisione del suo input, ed è la scelta sbagliata per qualunque cosa modifichi davvero il contenuto, perché la §14.3.3 si aspetta che /ModDate rifletta la modifica più recente. E non sostituisce una gestione corretta dei riferimenti, dato che evita soltanto l'unica scrittura che fa scattare l'aggiornamento automatico. Sono sostituire i riferimenti invece di mutare gli oggetti condivisi, e catturare il pacchetto prima dell'inizializzazione, a rendere sicuro un salvataggio ordinario; l'opzione è ciò che rende onesto un no-op voluto
Come verifichi che un salvataggio non ha cambiato altro che la ModDate?
Non con i pixel e non con gli hash degli stream, perché entrambe le trappole lasciano ogni pagina e ogni content stream identici byte per byte. Il controllo che le intercetta è uno snapshot semantico non visivo preso da un parser indipendente, che non condivide codice con la libreria sotto test, dal file sorgente e dal file salvato, seguito da un confronto strutturale. Lo snapshot copre il dizionario Info con /ModDate escluso, l'albero dell'outline con ogni bookmark risolto a un numero di pagina invece che a un numero di oggetto, le named destination e le destinazioni dei link risolte allo stesso modo, i valori dei campi modulo, i byte degli allegati come hash, e il pacchetto XMP analizzato come albero invece che confrontato come testo. I numeri di oggetto di proposito non ne fanno parte, dato che una riscrittura completa rinumera tutto e un confronto basato su di essi riporterebbe solo rumore
Le esclusioni contano quanto le inclusioni. /ModDate, xmp:ModifyDate e xmp:MetadataDate devono cambiare e vengono scartati prima del confronto; un file la cui sorgente non portava nessun XMP non viene penalizzato per aver guadagnato un pacchetto. Quello che il controllo non afferma è altrettanto esplicito: conservare un pacchetto esistente non dice niente sul fatto che quel pacchetto sia schema-valid o che il documento soddisfi PDF/UA o qualsiasi parte di PDF/A. Sono domande separate con strumenti separati, e confondere "i metadati sono sopravvissuti" con "i metadati sono conformi" è il modo in cui una riscrittura a data condivisa può restare nascosta a lungo
Se lavori al livello sotto queste regole, la meccanica di come un salvataggio riscrive gli oggetti è coperta in aggiornamenti incrementali e salvataggio append-only, che è l'unica modalità di salvataggio in cui un oggetto condiviso viene semplicemente lasciato dov'era, e in livelli di modifica e diff tra revisioni, che è l'altro punto in cui una data vecchia o riscritta inganna chi legge. La vista lato riparazione della stessa coppia Info e XMP, dove le due metà vengono fatte concordare invece che solo preservate, è in conversione a PDF/A e riparazione dei metadati
PDF Library for Delphi è una libreria PDF nativa in Pascal per Delphi, C++Builder e Lazarus, e il percorso read-modify-write descritto qui è lo stesso da cui passa ogni modifica nel tuo processo, quindi le garanzie sopra valgono sia che tu salvi una volta sia che salvi mille volte al giorno — vedi la pagina prodotto di PDF Library for Delphi per i compilatori e le piattaforme supportate