Articolo tecnico

Perché un salvataggio PDF no-op può corrompere Info e XMP

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

Mutazione della stringa Info condivisa in PDFlibPas: /CreationDate e /ModDate riferiscono legalmente a un solo oggetto stringa 2728 0 R, un SetTo in place su qualunque cosa la chiave risolva riscriverebbe entrambe le date con l'ora del salvataggio, così SetRawInfo aggiunge una stringa nuova sotto la chiave preservando la modalità hex
Aggiornare una voce del dizionario sostituisce il riferimento di quell'entry invece di mutare l'oggetto condiviso, così una scrittura automatica di ModDate non può cambiare CreationDate, e l'oggetto superato resta conservato per gli altri riferimenti
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

Ordine di inizializzazione lazy di XMP in PDFlibPas: creare l'oggetto XMP prima di chiamare GetMetadata fa sì che il fast path serializzi un pacchetto di default e perda dc:creator, i namespace custom e l'identificazione di standard, mentre catturare Source prima di TPDFlibXMP.Create carica lo stream /Metadata originale dal catalog
Qualunque salvataggio può far scattare lo scambio perché SetInfo inizializza XMP per tenere xmp:ModifyDate allineato a /ModDate, quindi ogni inizializzazione lazy nel documento passa per un unico EnsureXMP che cattura il pacchetto esistente prima di creare l'oggetto
// 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

Verifica semantica non visiva per i salvataggi di PDFlibPas: un parser indipendente senza codice in comune fotografa il dizionario Info meno /ModDate, pagine di outline e destinazioni, valori dei campi, hash degli allegati e l'albero XMP, poi confronta sorgente e file salvato scartando /ModDate, xmp:ModifyDate e xmp:MetadataDate come cambi attesi
Pixel e hash degli stream restano identici byte per byte attraverso entrambe le trappole, quindi il confronto lavora sulla semantica risolta invece che sui numeri di oggetto, e i metadati sopravvissuti vengono riportati onestamente come conservati, non come schema-valid o conformi PDF/UA e PDF/A

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