Die PDFium Component for Delphi (PDFiumPas) schrumpft PDF-Dateien mit drei Rewrite-APIs an TPdf: SaveAsReachable wirft Objects weg, auf die nichts zeigt, SaveAsDeduplicated verschmilzt byte-identische Fonts, Bilder und Form XObjects, und SaveAsFlateRecompressed kodiert vorhandene Flate-Streams mit maximaler Kompression neu. Jede gibt einen Report-Record zurück, sodass Sie sehen, was entfernt wurde und wie viele Bytes es sparte
Der übliche Auslöser ist ein Support-Ticket mit dem Tenor „wir haben zwanzig Seiten gelöscht und die Datei wurde größer“. Jemand hat einen Vertrag geöffnet, einen Anhang entfernt, ein Deckblatt hineinmergegt und gespeichert. Jeder Schritt war ein inkrementelles Update, die alten Objects stecken also weiter in der Datei, und wenn dieses Dokument später geladen und mit FPDF_SaveAsCopy geschrieben wird, kommen sie komplett wieder heraus. PDFium serialisiert, was in seiner In-Memory-Object-Table sitzt; es hat keinen öffentlichen Aufruf, um indirekte Objects aufzulisten, eines zu löschen oder Referenzen umzuschreiben. Alles in diesem Artikel läuft deshalb in der PDFiumPas-Wrapper-Schicht über den Bytes, die PDFium produziert
Warum behält FPDF_SaveAsCopy gelöschte Seiten in der Datei?
FPDF_SaveAsCopy behält Orphan-Objects, weil PDFium jeden lebenden Eintrag der Cross-Reference-Table in sein Dokument lädt, egal ob noch irgendwohin darauf gezeigt wird. ISO 32000-1 §7.5.6 beschreibt inkrementelle Updates als Anfügen neuer Objektversionen und einer neuen xref-Section ans Dateiende. Eine Seite zu entfernen editiert nur den Seitenbaum; das Page-Dictionary, sein Content-Stream und alle Fonts oder Bilder, die nur diese Seite nutzte, stehen weiter als genutzte Objects in der Liste. Ein voller nicht-inkrementeller Save ändert daran nichts, denn aus Sicht der Object-Table sind diese Objects völlig gültig
Dasselbe Überbleibsel taucht nach Seiten-Merges, von anderen Tools erledigtem Form-Flattening und Reparatur-Saves beschädigter Dateien auf. In jedem Viewer unsichtbar, fügen sie nichts hinzu außer Bytes. Bei gescannten Dokumenten kann ein einziges verwaistes Seitenbild mehrere Megabytes groß sein
Mark-and-Sweep mit SaveAsReachable
TPdf.SaveAsReachable entfernt Verwaistes mit einem klassischen Mark-and-Sweep-Durchlauf: Es lässt PDFium eine nicht-inkrementelle Kopie des aktuellen Dokuments schreiben, parst diese Kopie in einen Objektgraphen, markiert alles von den Roots aus Erreichbare und schreibt eine einzige klassische Revision, die nur die markierten Objects enthält. Objects innerhalb von Object Streams werden unterwegs als gewöhnliche indirekte Objects materialisiert, die Ausgabe nutzt also immer eine schlichte xref-Table
Die Roots zu wählen ist der Punkt, an dem eine naive Implementierung schiefgeht. Der naheliegende Seed ist der Catalog, den der Trailer unter /Root nennt. Von dort erreicht der Lauf den Seitenbaum, Seiten-Resources, Fonts, Bilder, Form XObjects, AcroForm, den Strukturbaum, Outlines, Name Trees, Optional Content, XMP-Metadaten, Output Intents und eingebettete Dateien. Das Document-Information-Dictionary aber wird vom Trailer-Eintrag /Info referenziert (ISO 32000-1 §7.5.5), nicht vom Catalog. Nur aus /Root zu säen löscht stillschweigend Titel, Autor und Producer jeder Datei, die Sie verarbeiten. PDFiumPas sä den Lauf mit beiden, /Root und /Info, und der neu gebaute Trailer trägt /Info nur weiter, wenn dieses Object tatsächlich überlebt hat
Der Walker ist eine Tiefensuche mit explizitem Stack und Visited-Set, Referenzzyklen (Parent-Links im Seitenbaum, Annotation-/P-Rückzeiger) können ihn also nicht aufhängen. Eine Referenz auf eine bereits freie Objektnummer wird schlicht nicht verfolgt statt als Fehler behandelt, was den Durchlauf tolerant gegen leicht beschädigte Eingabe hält
Objektnummern bleiben erhalten, sie werden nicht umnummeriert
TPdf.SaveAsReachable lässt jedes überlebende Object an seiner ursprünglichen Objektnummer und Generation. Entfernte Objects werden als freie Einträge in die xref-Table geschrieben (ISO 32000-1 §7.5.4), die Lücken zwischen lebenden Nummern werden mit freien Einträgen gefüllt, und Object 0 bleibt Kopf der Freelist. Weil nichts umnummeriert wird, bleibt jede überlebende Referenz ohne ein einziges umgeschriebenes Byte korrekt, und es gibt kein Alt-zu-Neu-Mapping, das schiefgehen könnte. Der Preis sind ein paar ungenutzte xref-Zeilen, je 20 Bytes – ein Tausch, der sich lohnt
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 trägt SourceByteCount (die Größe der nicht-inkrementellen Kopie, die PDFium erzeugte), OutputByteCount, KeptObjectCount, PrunedObjectCount, Succeeded und ErrorMessage. Der Dateinamen-Overload schreibt in eine temporäre Datei neben dem Ziel und ersetzt das Ziel erst, wenn der Rewrite gelang; ein fehlgeschlagener Lauf hinterlässt also nie eine abgeschnittene PDF
Welche Dateien weisen diese APIs zurück?
Alle drei Rewrite-APIs weisen verschlüsselte Dokumente und, per Default, signierte zurück. Eine verschlüsselte Eingabe failt mit Succeeded = False und einer Fehlermeldung statt einer Exception: Ein Ein-Revision-Rewrite kann die Objektnummern, Byte-Ranges und Per-Object-Keys, von denen das Encryption-Dictionary abhängt, nicht halten, die Ausgabe wäre also unlesbar. Ein Dokument mit digitalen Signaturen wirft EPdfError, außer Sie übergeben AllowSignedDocument = True, denn ein kompletter Rewrite macht die signierte Byte-Range stets ungültig. Dieses Flag zu setzen heißt, Sie akzeptieren eine Datei, deren Signaturen als gebrochen melden werden; die richtige Entscheidung für eine Archivkopie, nie für einen Vertrag, den Sie gleich zurückschicken
Woran erkennt SaveAsDeduplicated, dass zwei Bilder dieselben sind?
TPdf.SaveAsDeduplicated behandelt zwei Resources nur als identisch, wenn ihre Dictionarys, ihre gesamten erreichbaren Subgraphen und ihre rohen Stream-Bytes alle matchen. Er fährt zuerst denselben Reachability-Lauf, fingert dann jeden erreichbaren Font, jedes Image-XObject und Form-XObject ab, verschmilzt jede Gruppe von Treffern auf das Mitglied mit der niedrigsten Objektnummer und schreibt jede Referenz auf diesen Überlebenden um. Verschmolzene Duplikate werden genau wie Verwaiste freie xref-Einträge. Das ist der Fix für den klassischen „Logo 400-mal eingebettet“-Report, den Mail-Merge-Tools produzieren, die dieselben Resources auf jeder Seite neu einbetten
Der Fingerprint ist ein 64-Bit-FNV-1a-Hash, rekursiv berechnet. Für jeden Kandidaten nimmt der Walker den Object-Body, ersetzt jede N G R-Referenz durch den Fingerprint des Objects, auf das sie zeigt, und hasht das Ergebnis. Das macht den Hash unabhängig von der Objektnummerierung: Zwei Kopien desselben Fonts, deren Deskriptoren zufällig die Objects 12 und 87 sind, liefern weiterhin denselben Wert. Ergebnisse werden memoisiert, geteilte Subobjects werden also einmal gehasht, und ein Visiting-Set liefert für Rückkanten ein festes Sentinel, damit zyklische Graphen terminieren
Der subtile Bug sitzt in der Stream-Payload. Um Referenzen sicher zu finden, scannt PDFiumPas eine Kopie jedes Objects, in der Stream-Daten, Strings und Kommentare mit Leerzeichen ausblankiert sind; sonst würde eine Folge binärer Pixel-Daten, die zufällig wie 12 0 R aussieht, als Referenz behandelt. Hasht man nur diese gesäuberte Kopie, fallen zwei Bilder mit identischen Dictionarys (gleiches /Width, /Height, /BitsPerComponent, /Filter, /Length), aber verschiedenen Pixeln auf einen Fingerprint zusammen, und das zweite Foto der Seite wird still durch das erste ersetzt. SaveAsDeduplicated hängt die rohen Bytes zwischen stream und endstream an die gehashten Daten an, die Pixel sind also Teil der Identität. Kandidaten werden zudem vor dem Vergleich nach Kategorie gruppiert, eine seltene Hash-Kollision zwischen einem Font und einem Bild kann sie also nie verschmelzen
Das Referenz-Umschreiben nutzt denselben Sanitized-Scan-Trick umgekehrt: Referenzen werden in der ausblankierten Kopie lokalisiert, aber die Bytes kommen aus dem Original-Body; modifiziert wird also nur Dictionary- und Array-Struktur, binäre Payloads gehen unangetastet durch
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;
Lesen Sie TPdfDedupReport genau. Neben den drei Merged*Count-Feldern hat er KeptObjectCount und PrunedObjectCount, und in diesem Report zählt PrunedObjectCount nur verschmolzene Duplikate. Unerreichbare Objects werden weiterhin verworfen, stehen aber nicht in dieser Zahl. Brauchen Sie die Orphan-Zahl fürs Logging, nehmen Sie sie aus einem separaten SaveAsReachable-Lauf. Die andere Grenze: Das Matchen ist byte-exakt auf dem kodierten Stream; dasselbe Logo, von zwei verschiedenen Producern auf zwei verschiedenen Flate-Leveln komprimiert, wird nicht verschmolzen
Flate-Streams mit maximalem Level neu komprimieren
TPdf.SaveAsFlateRecompressed inflatiert jeden erreichbaren FlateDecode-Stream und deflatiert ihn mit der höchsten Kompressionsstufe neu, behält das Ergebnis aber nur, wenn es kleiner ist und Ihre Schwellen passiert. Viele Producer schreiben Flate auf einem schnellen Default-Level, Content-Streams und eingebettete Font-Programme schrumpfen also oft nochmal. Objektnummern und Generationen bleiben, wo sie sind; nur die Payload und ihr direktes /Length ändern sich
TPdfFlateRecompressOptions hat zwei Felder und keinen Default-Konstruktor, setzen Sie also beide. MinSavingBytes ist die minimale absolute Ersparnis pro Stream, und MinSavingRatio ist ein Bruchteil der Originallänge (0.05 heißt 5%); ein Ratio von 0 schaltet den Ratio-Test ab. Der neu gebaute Stream wird mit stream, CRLF, der neuen Payload, CRLF und endstream geschrieben, das Ergebnis hängt also nicht von den Zeilenenden der Originaldatei ab
procedure RecompressFlate(Pdf: TPdf; const TargetPdf: string);
var
Options: TPdfFlateRecompressOptions;
Report: TPdfFlateRecompressReport;
begin
Options.MinSavingBytes := 256; // Mini-Gewinne ignorieren
Options.MinSavingRatio := 0.05; // und alles unter 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;
Die Randfälle sind bewusst konservativ. Ein Stream, dessen /Length eine indirekte Referenz ist, wird übersprungen, denn der Rewrite muss einen direkten Integer speichern. Eine Payload, die sich nicht inflatien lässt, geht auf FailedCount und wird unverändert kopiert. Ein Flate-Stream, der die Schwellen verfehlt, geht auf SkippedCount. DCT-, JPX-, JBIG2- und CCITT-Bilder kommen nie auf die Kandidatenliste, dieser Durchlauf fasst die JPEG-Qualität also nie an. Für verlustbehaftete Bildeinsparungen nutzen Sie adaptive Image-Resampling in PDFiumPas vor dem finalen Save
Eine Portabilitäts-Falle aus dem Schreiben dieses Schwellentests: Der erste Entwurf verglich Single(Saving)/Single(OrigLen) bei als Int64 deklariertem Saving. Delphi akzeptiert das, aber FPC 3.2.2 bricht ab mit Illegal type conversion: "Int64" to "Single", denn in FPC ist diese Syntax ein Typecast statt einer numerischen Konvertierung. Ein schlichtes Saving / OrigLen ist in beiden Compilern echte Division und vergleicht direkt gegen einen Single; die Casts waren also schlicht überflüssig
In welcher Reihenfolge sollten die drei APIs laufen?
Fahren Sie zuerst SaveAsDeduplicated, laden Sie die Ausgabe neu, und fahren Sie dann SaveAsFlateRecompressed. Die Deduplizierung schließt den Reachability-Sweep bereits ein, ein separater SaveAsReachable-Durchlauf davor ist also redundant; SaveAsReachable heben Sie sich für Jobs auf, wo Sie nur Orphan-Aufräumen und eine exakte Prune-Zahl wollen. Zweites zu rekomprimieren heißt, die teure Inflate-und-Deflate-Arbeit läuft einmal pro überlebendem Stream statt einmal pro Duplikat. Der Rekompessions-Durchlauf geht den Graphen zudem erneut von /Root und /Info aus durch, was Font-Deskriptoren oder Font-Dateien aufräumt, auf die nur noch ein verschmolzener Duplikat-Font gezeigt hat
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); // Prune + Merge
Pdf.Active := False;
Pdf.FileName := TempPdf;
Pdf.Active := True;
RecompressFlate(Pdf, TargetPdf); // Überlebende neu deflaten
finally
Pdf.Free;
DeleteFile(TempPdf);
end;
end;
Wenden Sie zusätzlich Redactions an, dann tun Sie das vor all dem, denn physisch Content zu entfernen ist es ja, was neue Verwaiste erzeugt; der Artikel zu Operator-Level-Redaction und Resource-Pruning deckt die Redaction-Seite ab. Und laden Sie die finale Datei in Ihrer Pipeline immer als Sanity-Check neu: eine mit der Quelle übereinstimmende Seitenzahl ist billige Versicherung, bevor Sie irgendetwas überschreiben, das einem Kunden wichtig ist
FAQ
Nummeriert SaveAsReachable Objects um oder ändert Referenzen?
Nein. TPdf.SaveAsReachable lässt jedes überlebende Object an seiner ursprünglichen Nummer und Generation und markiert entfernte Objects als freie xref-Einträge; keine Referenz muss also umgeschrieben werden
Kann SaveAsDeduplicated versehentlich zwei verschiedene Bilder verschmelzen?
Nicht durch matchende Dictionarys allein. Der Fingerprint schließt die rohen Stream-Bytes und den vollständigen referenzierten Subgraphen ein, und Kandidaten werden nur innerhalb derselben Kategorie verglichen (Font, Bild, Form XObject)
Halten diese APIs meine digitalen Signaturen gültig?
Nein. Jede API fährt einen kompletten Rewrite, der die signierte Byte-Range bricht; signierte Dateien werden also abgelehnt, außer Sie übergeben AllowSignedDocument = True und akzeptieren ungültige Signaturen in der Ausgabe
Alle drei APIs kommen mit der PDFium Component for Delphi and C++Builder, neben den Redaction- und Image-Resampling-Features, die anderswo in diesem Blog behandelt werden