Wird eine Seite aus einem PDF gelöscht, verschwinden damit weder ihre Schriften noch ihre Bilder oder Content-Streams. Die losLab PDF Library holt diese Objekte mit einem Mark-and-Sweep-Collector zurück, der den Objektgraphen ausgehend von den Trailer-Roots vorwärts durchläuft und jedes indirekte Objekt entfernt, das nichts mehr erreicht. Er läuft bei einem vollständigen Save, ist standardmäßig deaktiviert und liefert die Anzahl der entfernten Objekte zurück
Warum verkleinert das Löschen von PDF-Seiten die Datei nicht?
Weil das Löschen einer Seite eine Referenzänderung ist, keine Speicheroperation. DeletePages(StartPage, PageCount) löst Seitenobjekte aus dem Seitenbaum und repariert die Gliederungseinträge, die auf sie verwiesen haben. Was es nicht leisten kann: zu entscheiden, dass das Schrift-Programm, der Content-Stream und das Bild-XObject, die diese Seiten genutzt haben, nun tot sind, denn zum Zeitpunkt des Löschens verzeichnet nichts in der Datei, wer sonst noch auf sie zeigen könnte. Diese Objekte bleiben in der Objektliste des Dokuments, und ein vollständiger Save schreibt jedes einzelne davon wieder heraus. Das Ergebnis ist die Beschwerde, mit der die meisten Support-Threads beginnen: Ein Kunde löscht neunzig Prozent der Seiten, speichert, und die Datei schrumpft um zwei Prozent. Schlimmer noch, das Leck summiert sich. Laden, löschen, speichern, erneut laden, erneut löschen, erneut speichern – die Datei wächst monoton, während die Seitenzahl sinkt. Das ist ein anderes Problem als das, das durch Font-Subsetting und Bild-Downsampling gelöst wird, die lebende Objekte verkleinern. Hier sind die Objekte nicht zu groß. Sie gehören einfach nicht mehr zum Dokument
Das Root-Set ist der Trailer, nicht der Seitenbaum
Der PDF-Objektgraph besitzt kein Feld für Rückreferenzen. Das Format definiert weder einen Referenzzähler noch eine Liste von Rückwärtszeigern, und die vorhandenen /Parent-Schlüssel gehören zu bestimmten Strukturen wie dem Seitenbaum, nicht zum Objektgraphen als Ganzes. Kein indirektes Objekt verrät, wer auf es zeigt, also hat die Frage "nutzt noch jemand Objekt 47" genau eine Antwort: vorwärts von einer bekannten Root aus durchlaufen und schauen, ob man ankommt. Deshalb ist der Collector in der losLab PDF Library ein Mark-and-Sweep-Collector und kein Refcount-Schema
Die Roots stammen aus dem Datei-Trailer (ISO 32000-1 §7.5.5). Drei Schlüssel tragen sie: /Root, der Dokumentkatalog aus §7.7.2, an dem Seitenbaum, Namen, Gliederung, AcroForm und Metadaten hängen; /Info, das Dokumentinformations-Dictionary; und /Encrypt, das Verschlüsselungs-Dictionary. Die beiden übrigen Trailer-Schlüssel sind Attrappen. /ID ist ein Array aus zwei Byte-Strings, und /Prev ist ein ganzzahliger Byte-Offset zum vorangehenden Cross-Reference-Abschnitt. Keiner von beiden ist eine indirekte Referenz, also liefert keiner eine Root. Die losLab PDF Library reiht das gesamte Trailer-Dictionary ein statt drei benannter Schlüssel, was nichts kostet und jede private Trailer-Erweiterung am Leben erhält
Der Durchlauf selbst ist iterativ, nicht rekursiv. Trifft die Traversierung auf eine indirekte Referenz, merkt sie sich nur Objektnummer und Generation, markiert den entsprechenden Slot und schiebt ihn in eine FIFO-Queue, statt sofort zu dereferenzieren – das hält tiefe Seitenbäume und lange Gliederungsketten aus dem Aufrufstapel heraus und verhindert, dass dasselbe Objekt zweimal dekodiert wird. Direkte Dictionaries, Arrays und Stream-Dictionaries kommen in eine zweite, durch eine Besuchsmenge abgesicherte Queue, denn reale Dokumente enthalten echte Zyklen: Der /Parent einer Seite zeigt zurück auf ihren Seitenbaum-Knoten, und Gliederungseinträge verketten sich über /Prev und /Next in beide Richtungen. Generationsnummern sind Teil des Abgleichs, nicht Dekoration. Eine Referenz löst sich nur auf, wenn Objektnummer und Generation übereinstimmen; eine Referenz auf eine Nummer, die in einer anderen Generation existiert, wird als das von der Spezifikation geforderte Null-Objekt behandelt, niemals als lebende Kante
Wie aktiviert man Garbage Collection beim Speichern?
Garbage Collection ist Opt-in und gehört zum Save-Options-Record. Standardmäßig ist sie False, denn der Collector ist ein destruktiver Durchlauf über den Objektgraphen, und keine Bibliothek sollte stillschweigend Objekte löschen, die ein Aufrufer nie zur Prüfung freigegeben hat
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;
Zwei weitere Einstiegspunkte erreichen denselben Collector. SetGarbageCollect(1) setzt das Flag auf dem gewählten Dokument, sodass ein gewöhnliches SaveToFile es beachtet, und GarbageCollectObjects führt den Durchlauf sofort aus und liefert die Anzahl der entfernten verwaisten indirekten Objekte zurück. Die sofortige Form ist die, die man nutzt, wenn man eine Zahl zum Loggen oder Prüfen möchte, und es lohnt sich, sie zu prüfen, denn ein negativer Rückgabewert ist keine Zählung
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;
Dieser Fehlerpfad ist wichtiger, als es aussieht. Objekte werden lazy dekodiert, und ein Objekt, das nie dekodiert wurde, zeigt überhaupt keine Referenzen. Würde der Collector ein nicht dekodierbares Objekt als leeren Knoten behandeln, würde er alles hinwegfegen, was nur über dieses Objekt erreichbar ist. Deshalb erzwingt die Traversierung die Dekodierung, während sie jedes Objekt berührt, und ein einziger Dekodierfehler bricht den gesamten Durchlauf mit einem negativen Ergebnis ab und lässt das Dokument byte-identisch zurück. Einen Graphen zu fegen, den man nur teilweise versteht, ist der Weg, auf dem ein Collector aus einer beschädigten Datei eine zerstörte macht
Was bringt einen naiven PDF-Collector zu Fall?
Zwei Details, und beide versagen still statt laut. Das erste sind Object Streams. Seit PDF 1.5 kann ein Nicht-Stream-Objekt komprimiert innerhalb eines /ObjStm-Containers leben (§7.5.7), und sein Cross-Reference-Eintrag ist ein Typ-2-Eintrag, der den Container samt Index darin benennt. Ein komprimiertes Objekt ist daher nur über seinen Container erreichbar. Markiert man das Mitglied und fegt den Container weg, weil ihn nichts als Dokumentobjekt referenziert hat, hat man eine Datei geschrieben, deren Xref auf ein Objekt zeigt, das nicht mehr existiert. Der Container ist strukturelle Ablage, keine Dokumentdaten, und taucht deshalb nie als Kante im durchlaufenen Objektgraphen auf. Die losLab PDF Library löst das, indem sie jedes überlebende komprimierte Mitglied von seinem Quellcontainer trennt, bevor die Container verschwinden; danach packt der Save die Überlebenden in frische Object Streams um. Das zweite Detail ist, was ein Stream-Objekt tatsächlich referenziert. Die Bytes sind nicht Teil des Graphen. Ein Content-Stream, der mit /F1 12 Tf Text zeichnet, benennt eine Schrift über einen Ressourcennamen, und dieser Name wird über das /Resources-Dictionary der Seite aufgelöst, sodass die Erreichbarkeitskante über Seite → /Resources → /Font → Schriftobjekt läuft, niemals über die Nutzlast des Streams. Die einzigen Referenzen, die ein Stream beiträgt, kommen aus seinem Dictionary, wo /Length, /Filter und /DecodeParms allesamt indirekt sein dürfen. Ein Collector, der Stream-Bytes nach Referenzen durchsucht, leistet teure Arbeit für nichts; ein Collector, der Stream-Dictionaries überspringt, verliert das Length-Objekt und beschädigt die Datei
Was passiert mit den freigegebenen Objektnummern
Sie werden zu freien Einträgen und in demselben Save nicht wiederverwendet. Der Sweep durchläuft die Objektliste in absteigender Reihenfolge, damit Löschungen index-stabil bleiben, baut den Lookup-Index einmal am Ende neu auf statt nach jeder Entfernung, und vermerkt für jedes entfernte Objekt die Nummer in der Free-Liste mit um eins erhöhter Generation, genau wie §7.5.4 es für einen Eintrag vorschreibt, der später wiederverwendet werden könnte. Eine bereits auf 65535 stehende Generation bleibt dort und markiert diese Nummer als endgültig ausgemustert. Objektnummern werden absichtlich nicht komprimiert. Nach einer Collection behält die Datei Löcher: Objekt 12 kann frei sein, während 13 und 14 in Gebrauch sind, und der Trailer-/Size-Wert meldet weiterhin die höchste Nummer plus eins statt der überlebenden Anzahl. Das ist legal und normal. Umnummerieren würde in der Cross-Reference-Tabelle eine Handvoll Bytes sparen und erforderte, jede Referenz im Dokument neu zu schreiben – die Art von Änderung, die still alles ungültig macht, was Objektnummern von außerhalb festhält. Die zurückgewonnene Größe stammt aus den Objektkörpern, nicht aus der Xref-Tabelle
Wann man den Collector nicht laufen lassen darf
Niemals bei einem Incremental Update. Der Collector ist auf vollständige Saves beschränkt, und das Flag wird schlicht nicht gelesen, wenn an das Dokument angehängt wird – und diese Sperre ist keine Einschränkung, die man umgehen sollte. Ein Incremental Update (§7.5.6) lässt die ursprünglichen Bytes unangetastet und hängt einen neuen, über /Prev mit dem vorherigen verketteten Cross-Reference-Abschnitt an. Jede frühere Revision zeigt weiterhin auf die Objekte, auf die sie immer gezeigt hat, sodass ein in der aktuellen Revision unerreichbares Objekt in einer älteren durchaus erreichbar ist. Es zu löschen würde jede Revision außer der letzten zerstören, und die Mechanik dahinter behandelt der Artikel zu Incremental Updates und Append-Mode-Saves. Dieselbe Überlegung schließt Garbage Collection bei einem signierten Dokument aus, denn genau das vollständige Neuschreiben, das die Collection erst möglich macht, ist auch das, was die Signatur ungültig macht
Es lohnt sich auch klarzustellen, was Collection nicht ist. Sie ist kein Sanitizer. Der Collector entfernt Objekte, die nichts referenziert; er hat keine Meinung dazu, ob deren Inhalt sensibel war, und ein noch referenziertes Objekt bleibt, was es war. Geht es darum, Informationen unwiederbringlich zu machen statt die Datei zu verkleinern, ist der Objektgraph die falsche Ebene, und Redaktion auf Anweisungsebene und Dokument-Sanitizing ist die richtige. Beide lassen sich gut in dieser Reihenfolge kombinieren: erst redigieren und bereinigen, dann sammeln, damit die von der Redaktion abgetrennten Objekte die Datei tatsächlich verlassen. Dieselbe Paarung findet sich in der API zur Ressourcenbereinigung, wo die Garbage-Collect-Option dafür sorgt, dass die Bereinigung anschließend eine Collection ausführt und die entfernten Waisen in OrphanObjectsRemoved meldet
Eine letzte Gewohnheit ist es wert, sich angeeignet zu werden. Loggen Sie den Rückgabewert von GarbageCollectObjects in dem Batch-Job, der Ihre Seitenlöschungen erledigt, und beobachten Sie ihn über einige Wochen echter Dokumente. Eine Null bei einer Datei, die man gerade halbiert hat, bedeutet, dass etwas weiter vorne noch eine Referenz hält, die man nicht erwartet hat – meist ein Eintrag im Namensbaum, ein Gliederungsziel oder ein AcroForm-Feld, das die Seite überlebt hat, an der es hing. Der Collector ist der günstigste Erreichbarkeits-Debugger, den man je haben wird, denn er beantwortet die Frage, die das PDF-Format selbst zu beantworten verweigert
Der hier beschriebene Garbage Collector, der Save-Options-Record und die API zur Ressourcenbereinigung sind Teil der losLab PDF Library für Delphi und C++Builder, deren Produktseite die vollständige Referenz der Save-Pipeline führt, einschließlich des Zusammenspiels von Collection, Object-Stream-Packing und Linearisierung