Articolo tecnico

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

PDF Library for Delphi v3.539.18 e v3.539.20 correggono due modi in cui un salvataggio PDF che non cambia niente poteva comunque corrompere i metadati del documento: quando /CreationDate e /ModDate riferivano lo stesso oggetto stringa, l'aggiornamento automatico di ModDate riscriveva entrambi, e quando l'oggetto XMP veniva creato prima che fosse letto lo stream /Metadata originale, un pacchetto di default sostituiva l'originale. Le correzioni sostituiscono i riferimenti nel dizionario invece di mutare oggetti condivisi, e catturano il pacchetto esistente prima dell'inizializzazione lazy di XMP

Il salvataggio è 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 renderizzavano identiche prima e dopo. Gli hash dei content stream corrispondevano. Il file passava ogni controllo che avevamo, ed era comunque sbagliato in due punti che nessun renderer ti mostrerebbe mai. Entrambi i difetti stavano nel percorso read-modify-write da cui passa ogni modifica reale, quindi qualunque salvataggio bastava a farli scattare, ed entrambi sono saltati fuori solo quando un secondo parser indipendente ha confrontato la semantica non visiva dei due file

Perché salvare un PDF ne cambia la CreationDate?

Perché il document information dictionary può riferire un unico oggetto stringa indiretto da due chiavi, e la libreria aggiornava l'oggetto invece della chiave. 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 al momento della creazione ha scritto due volte lo stesso timestamp può, del tutto legalmente, puntare entrambe le chiavi su un solo 2728 0 R, che è esattamente quello che faceva un documento di design CJK nel nostro corpus locale

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. Il vecchio SetRawInfo cercava l'oggetto sotto la chiave e, se trovava un TPDFString, ci chiamava SetTo sopra. Quella è una scrittura in place su qualunque oggetto la chiave risolva in quel momento, e quando quell'oggetto è condiviso, adesso anche /CreationDate riporta l'ora del salvataggio. Il documento si apre, si stampa e si renderizza comunque pixel per pixel come prima, 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, il vecchio SetRawInfo chiamava SetTo su qualunque cosa la chiave risolvesse e riscriveva entrambe le date con l'ora del salvataggio, mentre il nuovo SetRawInfo aggiunge una stringa nuova sotto la chiave preservando la modalità hex
Aggiornare una entry del dizionario ora sostituisce il riferimento di quella entry invece di mutare l'oggetto condiviso, quindi una scrittura automatica di ModDate non può più cambiare CreationDate, e l'oggetto superato viene 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 correzione 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 quella entry per caso risolveva. Il nuovo 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 il cambio principale. Il vecchio ramo per una entry con valore di stream azzerava lo stream con SetTo('') prima di sostituirlo, il che avrebbe svuotato il valore per ogni altra chiave che puntava ancora a quello stream, quindi quell'azzeramento è sparito. E l'oggetto superato non viene cancellato, perché è la struttura a possederlo e altri riferimenti potrebbero ancora servirsene

// Prima: mutava qualunque oggetto la chiave risolvesse al momento
if Obj is TPDFString then
  TPDFString(Obj).SetTo(Value);

// Dopo: 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));

La regressione in Tests\SharedInfoSemantics.inc costruisce l'aliasing di proposito invece di affidarsi a un file del corpus: 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 ora 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. Diversi call site si inizializzavano in modo lazy con XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata);, che si legge in modo naturale ed è sbagliato: 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 farlo 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 questo difetto: il confronto del dizionario Info del primo bug 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 faceva scattare lo scambio perché SetInfo inizializza XMP per tenere xmp:ModifyDate allineato a /ModDate, quindi ogni inizializzazione lazy nel documento ora 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);

La correzione fa due cose. TPDFDocument.EnsureXMP ora cattura Source := GetMetadata prima di TPDFlibXMP.Create, e ogni inizializzazione lazy nel documento è stata sostituita da una chiamata a esso: 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. Gli entry point pubblici come SetXMPProperty passavano già da EnsureXMP, e GetXMPProperty legge attraverso GetDocumentMetadata, quindi tutta la superficie condivide un solo ordine di inizializzazione. Una copia corretta di una sequenza di tre righe vale più di dieci copie che oggi per caso concordano

Due trappole più piccole trovate 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. Il vecchio codice la eliminava cancellando caratteri finché non raggiungeva <?xpacket. 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 cancellava l'intero documento valido. Il serializer ora individua il ?> di chiusura della dichiarazione e rimuove solo quello. Tests\XMPRetentionSemantics.inc esegue il suo controllo di retention 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 era un simbolo del preprocessore: la sincronizzazione Info-to-XMP in SetInfo era protetta da NOVCL, che è 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. Una build Windows con Lazarus aveva quindi un oggetto XMP funzionante e un SetInfo che si saltava in silenzio il suo aggiornamento. La guardia ora è 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 questo. 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 ripara retroattivamente una libreria che muta oggetti condivisi; evita soltanto l'unica scrittura che esponeva il difetto. Sono le due correzioni sopra a rendere sicuro un salvataggio ordinario, e 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é entrambi i difetti lasciano ogni pagina e ogni content stream identici byte per byte. Il controllo che li ha intercettati è 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 entrambi i difetti, 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 il primo bug è rimasto nascosto così a lungo. Sul lato libreria le due regressioni ora girano a ogni passata mirata su Delphi Win32 e Win64 e su Free Pascal Win32 e Win64, e il confronto semantico è una condizione di passaggio per il benchmark sul corpus di documenti reali

Se lavori al livello sotto queste correzioni, 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