Deleting a page from a PDF does not delete its fonts, images or content streams. losLab PDF Library reclaims them with a mark-sweep collector that walks the object graph forward from the trailer roots and removes every indirect object nothing reaches. It runs on a full save, it is off by default, and it returns the number of objects it dropped
Why does deleting PDF pages not shrink the file?
Because page deletion is a reference edit, not a storage operation. DeletePages(StartPage, PageCount) unlinks page objects from the page tree and repairs the outline entries that pointed at them. What it cannot do is decide that the font program, the content stream and the image XObject those pages used are now dead, because at the moment of deletion nothing in the file records who else might still be pointing at them. Those objects stay in the document object list, and a full save writes every one of them back out. The result is the complaint that starts most of these support threads: a customer deletes ninety percent of the pages, saves, and the file shrinks by two percent. Worse, the leak compounds. Load, delete, save, load again, delete again, save again, and the file grows monotonically while the page count falls. This is a different problem from the one solved by font subsetting and image downsampling, which make live objects smaller. Here the objects are not too big. They are simply no longer part of the document
The root set is the trailer, not the page tree
The PDF object graph has no reverse-reference field. The format defines no reference count and no back-pointer list, and the /Parent keys that do exist belong to specific structures such as the page tree, not to the object graph as a whole. Nothing in an indirect object tells you who points at it, so the question "is anyone still using object 47" has exactly one answer: traverse forward from a known root and see whether you arrive. That is why the collector in losLab PDF Library is a mark-sweep collector and not a refcount scheme
The roots come from the file trailer (ISO 32000-1 §7.5.5). Three keys carry them: /Root, the document catalogue of §7.7.2 from which the page tree, names, outlines, AcroForm and metadata all hang; /Info, the document information dictionary; and /Encrypt, the encryption dictionary. The two remaining trailer keys are decoys. /ID is an array of two byte strings, and /Prev is an integer byte offset to the previous cross-reference section. Neither is an indirect reference, so neither contributes a root. losLab PDF Library enqueues the entire trailer dictionary rather than three named keys, which costs nothing and keeps any private trailer extension alive
The walk itself is iterative rather than recursive. When the traversal meets an indirect reference it records only the object number and generation, marks the corresponding slot and pushes it onto a FIFO queue instead of dereferencing immediately, which keeps deep page trees and long outline chains off the call stack and stops the same object being decoded twice. Direct dictionaries, arrays and stream dictionaries go on a second queue guarded by a visited set, because real documents contain genuine cycles: a page /Parent points back at its page tree node, and outline items chain through /Prev and /Next in both directions. Generation numbers are part of the match, not decoration. A reference resolves only when object number and generation both agree; a reference to a number that exists at a different generation is treated as the null object the specification requires, never as a live edge
How do you enable garbage collection on a save?
Garbage collection is opt-in and belongs to the save options record. It defaults to False because the collector is a destructive pass over the object graph and no library should quietly delete objects a caller never asked it to examine
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;
Two other entry points reach the same collector. SetGarbageCollect(1) sets the flag on the selected document so an ordinary SaveToFile honours it, and GarbageCollectObjects runs the pass immediately and returns the number of orphaned indirect objects removed. The immediate form is the one to use when you want a number to log or assert on, and it is worth checking, because a negative return is not a count
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;
That failure path matters more than it looks. Objects are decoded lazily, and an object that has never been decoded exposes no references at all. If the collector treated an undecodable object as an empty node it would sweep away everything reachable only through it. So the traversal forces decoding as it touches each object, and a single decode error aborts the whole pass with a negative result and leaves the document byte-identical. Sweeping a graph you only partly understand is how a collector turns a damaged file into a destroyed one
What breaks a naive PDF collector?
Two details, and both of them fail silently rather than loudly. The first is object streams. Since PDF 1.5 a non-stream object may live compressed inside an /ObjStm container (§7.5.7), and its cross-reference entry is a type 2 entry naming the container plus an index within it. A compressed object is therefore reachable only through its container. Mark the member, sweep the container because nothing referenced it as a document object, and you have written a file whose xref points into an object that no longer exists. The container is structural storage, not document data, so it never appears as an edge in the object graph you are walking. losLab PDF Library handles this by detaching every surviving compressed member from its source container before the containers go away, after which the save re-packs the survivors into fresh object streams. The second detail is what a stream object actually references. The bytes are not part of the graph. A content stream that draws text with /F1 12 Tf names a font by resource name, and that name is resolved through the page /Resources dictionary, so the reachability edge runs page → /Resources → /Font → font object, never through the stream payload. The only references a stream contributes come from its dictionary, where /Length, /Filter and /DecodeParms are all permitted to be indirect. A collector that parses stream bytes looking for references is doing expensive work for nothing; a collector that skips stream dictionaries loses the length object and corrupts the file
What happens to the object numbers you free
They become free entries, and they are not reused in the same save. The sweep walks the object list in descending order so deletions stay index-stable, rebuilds the lookup index once at the end instead of after every removal, and for each removed object records the number in the free list with its generation incremented by one, exactly as §7.5.4 specifies for an entry that may later be reused. A generation already at 65535 stays there, marking that number as permanently retired. Object numbers are deliberately not compacted. After a collection the file keeps holes: object 12 may be free while 13 and 14 are in use, and the trailer /Size still reports the highest number plus one rather than the surviving count. That is legal and normal. Renumbering would save a handful of bytes in the cross-reference table and would require rewriting every reference in the document, which is the kind of change that quietly invalidates anything holding object numbers from outside. The size you get back comes from the object bodies, not from the xref table
When you must not run the collector
Never on an incremental update. The collector is gated to full saves and the flag is simply not read when the document is being appended to, and that gate is not a limitation to work around. An incremental update (§7.5.6) leaves the original bytes untouched and appends a new cross-reference section chained to the previous one through /Prev. Every earlier revision still points at the objects it always pointed at, so an object that is unreachable in the current revision is very much reachable in an older one. Deleting it would break every revision but the last, and the mechanics of why are covered in the article on incremental updates and append-mode saves. The same reasoning rules out garbage collection on a signed document, because the full rewrite that makes collection possible is itself what invalidates the signature
It is also worth being clear about what collection is not. It is not a sanitiser. The collector removes objects that nothing references; it has no opinion about whether their contents were sensitive, and an object that is still referenced stays whatever it was. If the goal is to make information unrecoverable rather than to make the file smaller, the object graph is the wrong layer and instruction-level redaction and document sanitising is the right one. The two do compose well in that order: redact and sanitise first, then collect, so the objects that redaction detached actually leave the file. The same pairing exists in the resource purge API, where passing the garbage-collect option makes the purge run a collection afterwards and report the orphans it removed in OrphanObjectsRemoved
One last habit worth adopting. Log the return value of GarbageCollectObjects in whatever batch job does your page deletions, and watch it over a few weeks of real documents. A zero on a file you just cut in half means something upstream is still holding a reference you did not expect, usually a name tree entry, an outline destination or an AcroForm field that survived the page it was attached to. The collector is the cheapest reachability debugger you will ever have, because it answers the question the PDF format itself refuses to answer
The garbage collector, the save-options record and the resource purge API described here are part of losLab PDF Library for Delphi and C++Builder, whose product page carries the full save-pipeline reference including the interaction between collection, object-stream packing and linearisation