Articolo tecnico

Garbage Collection PDF in Delphi: Mark e Sweep

Eliminare una pagina da un PDF non elimina i suoi font, le immagini o gli stream di contenuto. losLab PDF Library li recupera con un collector mark-sweep che percorre il grafo degli oggetti in avanti a partire dalle radici del trailer e rimuove ogni oggetto indiretto che nessuno raggiunge. Viene eseguito su un salvataggio completo, è disattivato di default, e restituisce il numero di oggetti eliminati

Perché eliminare pagine PDF non riduce la dimensione del file?

Perché la cancellazione di una pagina è una modifica di riferimenti, non un'operazione di storage. DeletePages(StartPage, PageCount) scollega gli oggetti pagina dall'albero delle pagine e ripara le voci di outline che puntavano a essi. Ciò che non può fare è decidere che il programma font, lo stream di contenuto e l'immagine XObject usati da quelle pagine sono ormai morti, perché al momento della cancellazione nulla nel file registra chi altro potrebbe ancora puntare a essi. Quegli oggetti restano nell'elenco degli oggetti del documento, e un salvataggio completo li riscrive tutti. Il risultato è la lamentela che apre la maggior parte di questi thread di supporto: un cliente elimina il novanta percento delle pagine, salva, e il file si riduce del due percento. Peggio ancora, la perdita si accumula. Carica, elimina, salva, carica di nuovo, elimina di nuovo, salva di nuovo, e il file cresce in modo monotono mentre il numero di pagine scende. Questo è un problema diverso da quello risolto dal subsetting dei font e il downsampling delle immagini, che rendono più piccoli gli oggetti attivi. Qui gli oggetti non sono troppo grandi. Semplicemente non fanno più parte del documento

L'insieme radice è il trailer, non l'albero delle pagine

Il grafo degli oggetti PDF non ha alcun campo di riferimento inverso. Il formato non definisce alcun conteggio di riferimenti né alcuna lista di puntatori all'indietro, e le chiavi /Parent che esistono appartengono a strutture specifiche come l'albero delle pagine, non al grafo degli oggetti nel suo insieme. Nulla in un oggetto indiretto indica chi punta a esso, quindi la domanda "qualcuno sta ancora usando l'oggetto 47" ha esattamente una risposta: percorrere in avanti a partire da una radice nota e vedere se si arriva a esso. Ecco perché il collector di losLab PDF Library è un collector mark-sweep e non uno schema a conteggio di riferimenti

Le radici provengono dal trailer del file (ISO 32000-1 §7.5.5). Tre chiavi le trasportano: /Root, il catalogo del documento di §7.7.2 da cui dipendono l'albero delle pagine, i nomi, gli outline, l'AcroForm e i metadati; /Info, il dizionario delle informazioni del documento; e /Encrypt, il dizionario di crittografia. Le due chiavi rimanenti del trailer sono depistaggi. /ID è un array di due stringhe di byte, e /Prev è un offset intero in byte verso la precedente sezione cross-reference. Nessuna delle due è un riferimento indiretto, quindi nessuna contribuisce come radice. losLab PDF Library accoda l'intero dizionario del trailer anziché tre chiavi nominate, il che non costa nulla e mantiene viva qualunque estensione privata del trailer

La percorrenza stessa è iterativa e non ricorsiva. Quando l'attraversamento incontra un riferimento indiretto registra solo il numero di oggetto e la generazione, contrassegna lo slot corrispondente e lo inserisce in una coda FIFO invece di dereferenziarlo subito, il che tiene alberi di pagine profondi e lunghe catene di outline fuori dallo stack delle chiamate e impedisce che lo stesso oggetto venga decodificato due volte. Dizionari diretti, array e dizionari di stream vanno in una seconda coda protetta da un insieme di elementi già visitati, perché i documenti reali contengono cicli veri e propri: una pagina il cui /Parent punta indietro al suo nodo dell'albero delle pagine, e voci di outline che si concatenano tramite /Prev e /Next in entrambe le direzioni. I numeri di generazione fanno parte del confronto, non sono un dettaglio decorativo. Un riferimento si risolve solo quando numero di oggetto e generazione concordano entrambi; un riferimento a un numero che esiste a una generazione diversa viene trattato come l'oggetto nullo richiesto dalla specifica, mai come un arco valido

Come si abilita la garbage collection su un salvataggio?

La garbage collection è opt-in e appartiene al record delle opzioni di salvataggio. Il valore predefinito è False perché il collector è un passaggio distruttivo sul grafo degli oggetti, e nessuna libreria dovrebbe eliminare silenziosamente oggetti che il chiamante non ha mai chiesto di esaminare

var
  Pdf: TPDFlib;
  Opt: TPDFlibSaveOptions;
begin
  Pdf := TPDFlib.Create;
  try
    if Pdf.LoadFromFile('report-500pages.pdf', '') <> 1 then
      Exit;
    Pdf.DeletePages(11, 490);          // keep the first ten pages

    FillChar(Opt, SizeOf(Opt), 0);
    Opt.CompressContent := True;
    Opt.CompressFonts := True;
    Opt.OptimizeContentStreams := True;
    Opt.PackObjectStreams := True;
    Opt.GarbageCollect := True;        // drop everything the pages left behind
    Pdf.SaveToFileOptions('report-10pages.pdf', Opt);
  finally
    Pdf.Free;
  end;
end;

Altri due punti di ingresso raggiungono lo stesso collector. SetGarbageCollect(1) imposta il flag sul documento selezionato, così una normale SaveToFile lo rispetta, e GarbageCollectObjects esegue subito il passaggio e restituisce il numero di oggetti indiretti orfani rimossi. La forma immediata è quella da usare quando si vuole un numero da registrare o verificare, ed è bene controllarlo, perché un valore di ritorno negativo non è un conteggio

var
  Removed: Integer;
begin
  Pdf.DeletePages(11, 490);
  Removed := Pdf.GarbageCollectObjects;
  if Removed < 0 then
    // The graph could not be fully decoded. Nothing was swept and the
    // document is unchanged; save it without GC or reject the input.
    LogWarning('object graph incomplete, GC skipped')
  else
    LogInfo(Format('reclaimed %d orphaned objects', [Removed]));
end;

Questo percorso di fallimento conta più di quanto sembri. Gli oggetti vengono decodificati in modo lazy, e un oggetto mai decodificato non espone alcun riferimento. Se il collector trattasse un oggetto non decodificabile come un nodo vuoto, spazzerebbe via tutto ciò che è raggiungibile solo attraverso di esso. Per questo l'attraversamento forza la decodifica man mano che tocca ogni oggetto, e un singolo errore di decodifica interrompe l'intero passaggio con un risultato negativo e lascia il documento identico byte per byte. Spazzare un grafo che si comprende solo in parte è il modo in cui un collector trasforma un file danneggiato in uno distrutto

Cosa manda in crisi un collector PDF ingenuo?

Due dettagli, ed entrambi falliscono in silenzio anziché in modo evidente. Il primo sono gli stream di oggetti. Dalla versione PDF 1.5 un oggetto non-stream può vivere compresso dentro un contenitore /ObjStm (§7.5.7), e la sua voce cross-reference è una voce di tipo 2 che nomina il contenitore più un indice al suo interno. Un oggetto compresso è quindi raggiungibile solo tramite il suo contenitore. Contrassegna il membro, spazza via il contenitore perché nulla lo referenziava come oggetto del documento, e hai scritto un file il cui xref punta a un oggetto che non esiste più. Il contenitore è storage strutturale, non dato del documento, quindi non appare mai come arco nel grafo degli oggetti che si sta percorrendo. losLab PDF Library gestisce questo scollegando ogni membro compresso sopravvissuto dal suo contenitore di origine prima che i contenitori vengano rimossi, dopodiché il salvataggio riimpacchetta i sopravvissuti in nuovi stream di oggetti. Il secondo dettaglio riguarda cosa referenzia realmente un oggetto stream. I byte non fanno parte del grafo. Uno stream di contenuto che disegna testo con /F1 12 Tf nomina un font tramite il nome di risorsa, e quel nome viene risolto tramite il dizionario /Resources della pagina, quindi l'arco di raggiungibilità va da pagina → /Resources/Font → oggetto font, mai attraverso il payload dello stream. Gli unici riferimenti che uno stream contribuisce provengono dal suo dizionario, dove /Length, /Filter e /DecodeParms possono tutti essere indiretti. Un collector che analizza i byte dello stream cercando riferimenti sta facendo un lavoro costoso per nulla; un collector che salta i dizionari di stream perde l'oggetto length e corrompe il file

Cosa succede ai numeri di oggetto liberati

Diventano voci libere, e non vengono riutilizzate nello stesso salvataggio. Lo sweep percorre l'elenco degli oggetti in ordine decrescente così le cancellazioni restano stabili per indice, ricostruisce l'indice di lookup una sola volta alla fine anziché dopo ogni rimozione, e per ogni oggetto rimosso registra il numero nella free list con la generazione incrementata di uno, esattamente come specifica §7.5.4 per una voce che potrà essere riutilizzata in seguito. Una generazione già a 65535 vi resta, contrassegnando quel numero come ritirato in modo permanente. I numeri di oggetto non vengono deliberatamente compattati. Dopo una collection il file conserva dei buchi: l'oggetto 12 può essere libero mentre 13 e 14 sono in uso, e il trailer /Size continua a riportare il numero più alto più uno anziché il conteggio dei sopravvissuti. Questo è legale e normale. Rinumerare risparmierebbe una manciata di byte nella tabella cross-reference e richiederebbe di riscrivere ogni riferimento nel documento, il tipo di modifica che invalida silenziosamente qualunque cosa detenga numeri di oggetto dall'esterno. La dimensione che si ottiene deriva dai corpi degli oggetti, non dalla tabella xref

Quando non si deve eseguire il collector

Mai su un aggiornamento incrementale. Il collector è vincolato ai salvataggi completi e il flag semplicemente non viene letto quando il documento viene esteso in append, e quel vincolo non è una limitazione da aggirare. Un aggiornamento incrementale (§7.5.6) lascia intatti i byte originali e accoda una nuova sezione cross-reference concatenata alla precedente tramite /Prev. Ogni revisione precedente continua a puntare agli oggetti a cui ha sempre puntato, quindi un oggetto irraggiungibile nella revisione corrente è ben raggiungibile in una più vecchia. Eliminarlo romperebbe ogni revisione tranne l'ultima, e i dettagli del perché sono trattati nell'articolo su aggiornamenti incrementali e salvataggi in modalità append. Lo stesso ragionamento esclude la garbage collection su un documento firmato, perché la riscrittura completa che rende possibile la collection è essa stessa ciò che invalida la firma

Vale anche la pena chiarire cosa la collection non è. Non è un sanitizer. Il collector rimuove gli oggetti che nessuno referenzia; non ha alcuna opinione su se il loro contenuto fosse sensibile, e un oggetto ancora referenziato resta qualunque cosa fosse. Se l'obiettivo è rendere le informazioni irrecuperabili anziché rendere il file più piccolo, il grafo degli oggetti è il livello sbagliato e la redazione a livello di istruzioni e la sanitizzazione del documento è quello giusto. I due si compongono bene in quest'ordine: prima redigere e sanitizzare, poi eseguire la collection, così gli oggetti che la redazione ha scollegato lasciano davvero il file. Lo stesso abbinamento esiste nella API di purge delle risorse, dove passare l'opzione garbage-collect fa eseguire una collection dopo il purge e riporta gli orfani rimossi in OrphanObjectsRemoved

Un'ultima abitudine che vale la pena adottare. Registra il valore di ritorno di GarbageCollectObjects in qualunque job batch esegua le tue cancellazioni di pagine, e osservalo per alcune settimane su documenti reali. Uno zero su un file che hai appena dimezzato significa che qualcosa a monte sta ancora trattenendo un riferimento che non ti aspettavi, di solito una voce di name tree, una destinazione di outline o un campo AcroForm sopravvissuto alla pagina a cui era collegato. Il collector è il debugger di raggiungibilità più economico che avrai mai, perché risponde alla domanda che il formato PDF stesso rifiuta di rispondere

Il garbage collector, il record delle opzioni di salvataggio e la API di purge delle risorse descritti qui fanno parte di losLab PDF Library per Delphi e C++Builder, la cui pagina prodotto riporta il riferimento completo della pipeline di salvataggio, incluso il modo in cui collection, packing degli object stream e linearizzazione interagiscono