Articolo tecnico

Rimpicciolire i PDF in Delphi: orfani e dedup delle risorse

Il PDFium Component for Delphi (PDFiumPas) rimpiciolisce i file PDF con tre API di riscrittura su TPdf: SaveAsReachable butta gli oggetti che nulla referenzia, SaveAsDeduplicated fonde font, immagini e Form XObject byte-identici, e SaveAsFlateRecompressed ricodifica gli stream Flate esistenti alla massima compressione. Ognuna restituisce un record di report così vedete cosa è stato rimosso e quanti byte ha risparmiato

Il fattore scatenante consueto è un ticket di assistenza che legge «abbiamo cancellato venti pagine e il file è diventato più grande». Qualcuno ha aperto un contratto, rimosso un allegato, fuso dentro una copertina e salvato. Ogni passo è stato un aggiornamento incrementale, così i vecchi oggetti sono ancora nel file, e quando quel documento viene in seguito caricato e scritto con FPDF_SaveAsCopy ne escono direttamente fuori. PDFium serializza ciò che sta nella sua tabella di oggetti in memoria; non ha una chiamata pubblica per elencare gli oggetti indiretti, cancellarne uno o riscrivere i riferimenti. Tutto ciò che sta in questo articolo gira quindi nel livello wrapper PDFiumPas sopra i byte che PDFium produce

Perché FPDF_SaveAsCopy tiene le pagine cancellate nel file?

FPDF_SaveAsCopy conserva gli oggetti orfani perché PDFium carica nel proprio documento ogni voce viva della tabella cross-reference, che qualcosa la punti ancora o no. ISO 32000-1 §7.5.6 descrive gli aggiornamenti incrementali come apposizione di nuove versioni di oggetti e di una nuova sezione xref in fondo al file. Rimuovere una pagina modifica solo l'albero delle pagine; il dizionario di pagina, il suo content stream, e qualunque font o immagine usati solo da quella pagina restano elencati come oggetti in uso. Un salvataggio completo non incrementale non cambia questo, perché dal punto di vista della tabella di oggetti quegli oggetti sono perfettamente validi

Gli stessi avanzi compaiono dopo fusioni di pagine, appiattimento di moduli fatto da altri strumenti, e salvataggi di riparazione di file danneggiati. Sono invisibili in ogni visualizzatore e non aggiungono niente tranne byte. Su documenti scansionati una singola immagine di pagina orfana può pesare parecchi megabyte

Mark-and-sweep con SaveAsReachable

TPdf.SaveAsReachable rimuove gli orfani con un classico passaggio mark-and-sweep: lascia che PDFium scriva una copia non incrementale del documento corrente, parsa quella copia in un grafo di oggetti, marca tutto ciò che è raggiungibile dalle radici, e scrive un'unica revisione classica che contiene solo gli oggetti marcati. Gli oggetti dentro gli object stream vengono materializzati come ordinari oggetti indiretti lungo la strada, così l'output usa sempre una tabella xref semplice

Scegliere le radici è dove un'implementazione ingenua va storta. Il seme ovvio è il Catalog nominato dal /Root del trailer. Da lì il giro raggiunge l'albero delle pagine, le risorse di pagina, i font, le immagini, i Form XObject, l'AcroForm, l'albero della struttura, gli outline, gli alberi dei nomi, l'optional content, i metadati XMP, gli output intent e i file incorporati. Ma il dizionario informativo del documento è referenziato dalla voce /Info del trailer (ISO 32000-1 §7.5.5), non dal Catalog. Seminate solo da /Root e cancellate silenziosamente il Title, l'Author e il Producer di ogni file elaborato. PDFiumPas semina il giro con sia /Root sia /Info, e il trailer ricostruito porta /Info avanti solo quando quell'oggetto è effettivamente sopravvissuto

Il walker è un attraversamento depth-first con stack esplicito e insieme dei visitati, così i cicli di riferimenti (collegamenti genitore nell'albero delle pagine, back-pointer /P delle annotazioni) non possono appenderselo. Un riferimento a un numero di oggetto già libero semplicemente non viene seguito invece di essere trattato come errore, il che tiene il passaggio tollerante verso input leggermente danneggiati

I numeri di oggetto si conservano, non si rinumerano

TPdf.SaveAsReachable tiene ogni oggetto superstite al suo numero di oggetto e generazione originali. Gli oggetti spazzati vengono scritti come voci libere nella tabella xref (ISO 32000-1 §7.5.4), i buchi fra i numeri vivi vengono riempiti con voci libere, e l'oggetto 0 resta la testa della free list. Poiché nulla viene rinumerato, ogni riferimento che sopravvive resta corretto senza un solo byte di riscrittura, e non c'è una mappa vecchio-a-nuovo da sbagliare. Il costo è qualche riga xref inutilizzata, 20 byte ciascuna, uno scambio che vale la pena fare

uses
  PDFium, FPdfCompress;

procedure PruneOrphans(const SourcePdf, TargetPdf: string);
var
  Pdf: TPdf;
  Report: TPdfPruneReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := SourcePdf;
    Pdf.Active := True;
    if not Pdf.SaveAsReachable(TargetPdf, Report) then
      raise Exception.Create(Report.ErrorMessage);
    Writeln(Format('%d bytes -> %d bytes, kept %d object(s), pruned %d',
      [Report.SourceByteCount, Report.OutputByteCount,
       Report.KeptObjectCount, Report.PrunedObjectCount]));
  finally
    Pdf.Free;
  end;
end;

TPdfPruneReport porta SourceByteCount (la dimensione della copia non incrementale che PDFium ha prodotto), OutputByteCount, KeptObjectCount, PrunedObjectCount, Succeeded e ErrorMessage. L'overload con nome file scrive su un file temporaneo accanto al bersaglio e sostituisce il bersaglio solo quando la riscrittura è riuscita, così un giro fallito non lascia mai dietro un PDF troncato

Quali file rifiuteranno queste API?

Tutte e tre le API di riscrittura rifiutano i documenti cifrati e, per predefinito, quelli firmati. Un input cifrato fallisce con Succeeded = False e un messaggio di errore invece di un'eccezione: una riscrittura a revisione singola non può conservare i numeri di oggetto, gli intervalli di byte e le chiavi per oggetto da cui dipende il dizionario di cifratura, quindi l'output sarebbe illeggibile. Un documento con firme digitali solleva EPdfError salvo che passiate AllowSignedDocument = True, perché una riscrittura completa invalida sempre l'intervallo di byte firmato. Impostare quel flag significa accettare un file le cui firme verranno riportate come rotte; è la scelta giusta per una copia d'archivio, mai per un contratto che state per rispedire

Come decide SaveAsDeduplicated che due immagini sono la stessa?

TPdf.SaveAsDeduplicated tratta due risorse come identiche solo quando i loro dizionari, i loro interi sottografi raggiungibili e i loro byte grezzi di stream corrispondono tutti. Esegue prima lo stesso giro di raggiungibilità, poi fa il fingerprint di ogni font raggiungibile, image XObject e Form XObject, fonde ogni gruppo di corrispondenze sul membro col numero di oggetto più basso, e riscrive ogni riferimento perché punti a quel superstite. I duplicati fusi diventano voci xref libere proprio come gli orfani. È la correzione per il classico report «logo incorporato 400 volte» prodotto dagli strumenti di mail merge che re-incorporano le stesse risorse su ogni pagina

Il fingerprint è un hash FNV-1a a 64 bit calcolato ricorsivamente. Per ogni candidato il walker prende il corpo dell'oggetto, sostituisce ogni riferimento N G R con il fingerprint dell'oggetto al quale punta, e fa l'hash del risultato. Questo rende l'hash indipendente dalla numerazione degli oggetti: due copie dello stesso font i cui descriptor sono per caso gli oggetti 12 e 87 producono comunque lo stesso valore. I risultati vengono memoizzati così i sottooggetti condivisi vengono hashati una volta sola, e un insieme dei visitati restituisce una sentinella fissa per i back edge così i grafi ciclici terminano

Il bug sottile sta nel payload dello stream. Per trovare i riferimenti in sicurezza, PDFiumPas scansiona una copia di ogni oggetto in cui i dati stream, le stringhe e i commenti sono stati cancellati con spazi; altrimenti una sequenza di dati pixel binari che per caso assomiglia a 12 0 R verrebbe trattata come riferimento. Fare l'hash solo di quella copia sanificata e due immagini con dizionari identici (stessi /Width, /Height, /BitsPerComponent, /Filter, /Length) ma pixel diversi collassano in un solo fingerprint, e la seconda foto della pagina viene silenziosamente sostituita dalla prima. SaveAsDeduplicated appende i byte grezzi fra stream e endstream ai dati hashati, così i pixel fanno parte dell'identità. I candidati vengono anche raggruppati per categoria prima del confronto, così una rara collisione di hash fra un font e un'immagine non li fonde mai

La riscrittura dei riferimenti riusa lo stesso trucco della scansione sanificata al contrario: i riferimenti vengono individuati nella copia cancellata ma i byte vengono copiati dal corpo originale, così solo la struttura di dizionario e array viene mai modificata e i payload binari passano intatti

procedure MergeDuplicateResources(Pdf: TPdf; const TargetPdf: string);
var
  Report: TPdfDedupReport;
begin
  if not Pdf.SaveAsDeduplicated(TargetPdf, Report) then
    raise Exception.Create(Report.ErrorMessage);
  Writeln(Format('fonts %d, images %d, forms %d merged; %d -> %d bytes',
    [Report.MergedFontCount, Report.MergedImageCount,
     Report.MergedFormXObjectCount,
     Report.SourceByteCount, Report.OutputByteCount]));
end;

Leggete TPdfDedupReport con attenzione. Accanto ai tre campi Merged*Count ha KeptObjectCount e PrunedObjectCount, e in questo report PrunedObjectCount conta solo i duplicati fusi. Gli oggetti non raggiungibili vengono comunque buttati, ma non sono inclusi in quel numero. Se vi serve il conteggio degli orfani per il log, prendetelo da un giro SaveAsReachable separato. L'altro limite è che il matching è byte-esatto sullo stream codificato: lo stesso logo compresso da due produttori diversi a due livelli Flate diversi non verrà fuso

Ricomprimere gli stream Flate al massimo livello

TPdf.SaveAsFlateRecompressed sgonfia ogni stream FlateDecode raggiungibile e lo ricomprime di nuovo al livello di compressione massimo, conservando il risultato solo quando è più piccolo e supera le vostre soglie. Molti produttori scrivono Flate a un livello predefinito veloce, quindi i content stream e i programmi font incorporati spesso si restringono ulteriormente. Numeri di oggetto e generazioni restano al loro posto; cambiano solo il payload e il suo /Length diretto

TPdfFlateRecompressOptions ha due campi e nessun costruttore predefinito, quindi impostate entrambi. MinSavingBytes è il risparmio assoluto minimo per stream, e MinSavingRatio è una frazione della lunghezza originale (0.05 significa 5%); un rapporto di 0 disabilita il test del rapporto. Lo stream ricostruito viene scritto con stream, CRLF, il nuovo payload, CRLF e endstream, così il risultato non dipende dai fine riga del file originale

procedure RecompressFlate(Pdf: TPdf; const TargetPdf: string);
var
  Options: TPdfFlateRecompressOptions;
  Report: TPdfFlateRecompressReport;
begin
  Options.MinSavingBytes := 256;    // ignorate i guadagni minuscoli
  Options.MinSavingRatio := 0.05;   // e qualunque cosa sotto il 5%
  if not Pdf.SaveAsFlateRecompressed(TargetPdf, Options, Report) then
    raise Exception.Create(Report.ErrorMessage);
  Writeln(Format('recompressed %d, skipped %d, failed %d; streams %d -> %d bytes',
    [Report.RecompressedCount, Report.SkippedCount, Report.FailedCount,
     Report.OriginalStreamBytes, Report.RecompressedStreamBytes]));
end;

I casi limite sono conservativi per progetto. Uno stream il cui /Length è un riferimento indiretto viene saltato, perché la riscrittura deve conservare un intero diretto. Un payload che non riesce a sgonfiarsi conta in FailedCount e viene copiato invariato. Uno stream Flate che non supera le soglie conta in SkippedCount. Le immagini DCT, JPX, JBIG2 e CCITT non entrano mai nell'elenco dei candidati, quindi questo passaggio non tocca mai la qualità JPEG. Per risparmi sulle immagini con perdita usate il resampling adattivo delle immagini in PDFiumPas prima del salvataggio finale

Una trappola di portabilità nata scrivendo quel test di soglia: la prima bozza confrontava Single(Saving)/Single(OrigLen) con Saving dichiarato come Int64. Delphi lo accetta, ma FPC 3.2.2 si ferma con Illegal type conversion: "Int64" to "Single", perché in FPC quella sintassi è un typecast invece che una conversione numerica. Il semplice Saving / OrigLen è una divisione vera in entrambi i compilatori e si confronta direttamente con un Single, quindi i cast erano semplicemente superflui

In che ordine dovrebbero girare le tre API?

Eseguite prima SaveAsDeduplicated, ricaricate l'output, poi eseguite SaveAsFlateRecompressed. La deduplicazione include già la spazzata di raggiungibilità, quindi un passaggio SaveAsReachable separato davanti è ridondante; tenete SaveAsReachable per i lavori dove volete solo la pulizia degli orfani e un conteggio di potatura esatto. Ricomprimere per secondo significa che il costoso lavoro gonfia-e-sgonfia gira una volta per stream superstite invece che una volta per duplicato. Il passaggio di ricompressione percorre anche il grafo da /Root e /Info di nuovo, il che ripulisce qualunque descriptor di font o file font al quale puntava solo un font duplicato fuso

procedure ShrinkPdf(const SourcePdf, TargetPdf: string);
var
  Pdf: TPdf;
  TempPdf: string;
begin
  TempPdf := ChangeFileExt(TargetPdf, '.dedup.pdf');
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := SourcePdf;
    Pdf.Active := True;
    MergeDuplicateResources(Pdf, TempPdf);   // potatura + fusione

    Pdf.Active := False;
    Pdf.FileName := TempPdf;
    Pdf.Active := True;
    RecompressFlate(Pdf, TargetPdf);         // ri-sgonfia i superstiti
  finally
    Pdf.Free;
    DeleteFile(TempPdf);
  end;
end;

Se applicate anche redaction, fatelo prima di tutto questo, dato che rimuovere fisicamente il contenuto è ciò che crea nuovi orfani in partenza; l'articolo sulla redaction a livello di operatore e potatura delle risorse copre il lato redaction. E ricaricate sempre il file finale come controllo di sanità nella vostra pipeline: un conteggio di pagine che corrisponde alla sorgente è un'assicurazione economica prima di sovrascrivere qualcosa a cui un cliente tiene

FAQ

SaveAsReachable rinumera gli oggetti o cambia i riferimenti?

No. TPdf.SaveAsReachable tiene ogni oggetto superstite al suo numero e generazione originali e marca gli oggetti rimossi come voci xref libere, così nessun riferimento va riscritto

SaveAsDeduplicated può fondere per sbaglio due immagini diverse?

Non attraverso i soli dizionari corrispondenti. Il fingerprint include i byte grezzi dello stream e l'intero sottografo referenziato, e i candidati vengono confrontati solo dentro la stessa categoria (font, immagine, Form XObject)

Queste API manterranno valide le mie firme digitali?

No. Ogni API esegue una riscrittura completa, che rompe l'intervallo di byte firmato, quindi i file firmati vengono rifiutati salvo che passiate AllowSignedDocument = True e accettiate firme non valide nell'output

Tutte e tre le API vengono spedite con il PDFium Component for Delphi and C++Builder, accanto alle funzionalità di redaction e resampling delle immagini trattate altrove su questo blog