The PDFium Component for Delphi (PDFiumPas) shrinks PDF files with three rewrite APIs on TPdf: SaveAsReachable drops objects that nothing references, SaveAsDeduplicated merges byte-identical fonts, images and Form XObjects, and SaveAsFlateRecompressed re-encodes existing Flate streams at maximum compression. Each one returns a report record so you can see what was removed and how many bytes it saved
The usual trigger is a support ticket that reads "we deleted twenty pages and the file got bigger". Somebody opened a contract, removed an appendix, merged in a cover sheet and saved. Every step was an incremental update, so the old objects are still in the file, and when that document is later loaded and written with FPDF_SaveAsCopy they come right back out. PDFium serialises whatever sits in its in-memory object table; it has no public call for listing indirect objects, deleting one, or rewriting references. Everything in this article therefore runs in the PDFiumPas wrapper layer on top of the bytes PDFium produces
Why does FPDF_SaveAsCopy keep deleted pages in the file?
FPDF_SaveAsCopy keeps orphan objects because PDFium loads every live entry in the cross-reference table into its document, whether or not anything still points at it. ISO 32000-1 §7.5.6 describes incremental updates as appending new object versions and a new xref section to the end of the file. Removing a page only edits the page tree; the page dictionary, its content stream, and any fonts or images used only by that page are still listed as in-use objects. A full non-incremental save does not change that, because from the object table's point of view those objects are perfectly valid
The same leftovers show up after page merges, form flattening done by other tools, and repair saves of damaged files. They are invisible in every viewer and add nothing but bytes. On scanned documents a single orphaned page image can be several megabytes
Mark-and-sweep with SaveAsReachable
TPdf.SaveAsReachable removes orphans with a classic mark-and-sweep pass: it lets PDFium write a non-incremental copy of the current document, parses that copy into an object graph, marks everything reachable from the roots, and writes a single classic revision that contains only the marked objects. Objects inside object streams are materialized as ordinary indirect objects along the way, so the output always uses a plain xref table
Picking the roots is where a naive implementation goes wrong. The obvious seed is the Catalog named by the trailer /Root. From there the walk reaches the page tree, page resources, fonts, images, Form XObjects, AcroForm, the structure tree, outlines, name trees, optional content, XMP metadata, output intents and embedded files. But the document information dictionary is referenced by the trailer /Info entry (ISO 32000-1 §7.5.5), not by the Catalog. Seed only from /Root and you silently delete the Title, Author and Producer of every file you process. PDFiumPas seeds the walk with both /Root and /Info, and the rebuilt trailer only carries /Info forward when that object actually survived
The walker is a depth-first traversal with an explicit stack and a visited set, so reference cycles (parent links in the page tree, annotation /P back-pointers) cannot hang it. A reference to an object number that is already free is simply not followed rather than treated as an error, which keeps the pass tolerant of slightly damaged input
Object numbers are preserved, not renumbered
TPdf.SaveAsReachable keeps every surviving object at its original object number and generation. Swept objects are written as free entries in the xref table (ISO 32000-1 §7.5.4), the gaps between live numbers are filled with free entries, and object 0 remains the head of the free list. Because nothing is renumbered, every reference that survives is still correct without a single byte of rewriting, and there is no old-to-new mapping to get wrong. The cost is a few unused xref lines, 20 bytes each, which is a trade worth making
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 carries SourceByteCount (the size of the non-incremental copy PDFium produced), OutputByteCount, KeptObjectCount, PrunedObjectCount, Succeeded and ErrorMessage. The file name overload writes to a temporary file next to the target and only replaces the target when the rewrite succeeded, so a failed run never leaves a truncated PDF behind
Which files will these APIs refuse?
All three rewrite APIs refuse encrypted documents and, by default, signed ones. An encrypted input fails with Succeeded = False and an error message instead of an exception: a single-revision rewrite cannot keep the object numbers, byte ranges and per-object keys that the encryption dictionary depends on, so the output would be unreadable. A document with digital signatures raises EPdfError unless you pass AllowSignedDocument = True, because a full rewrite always invalidates the signed byte range. Setting that flag means you accept a file whose signatures will report as broken; it is the right call for an archive copy, never for a contract you are about to send back
How does SaveAsDeduplicated decide two images are the same?
TPdf.SaveAsDeduplicated treats two resources as identical only when their dictionaries, their whole reachable subgraphs and their raw stream bytes all match. It runs the same reachability walk first, then fingerprints every reachable font, image XObject and Form XObject, merges each group of matches onto the member with the lowest object number, and rewrites every reference to point at that survivor. Merged duplicates become free xref entries just like orphans. This is the fix for the classic "logo embedded 400 times" report produced by mail-merge tools that re-embed the same resources on every page
The fingerprint is a 64-bit FNV-1a hash computed recursively. For each candidate the walker takes the object body, replaces every N G R reference with the fingerprint of the object it points to, and hashes the result. That makes the hash independent of object numbering: two copies of the same font whose descriptors happen to be objects 12 and 87 still produce the same value. Results are memoized so shared subobjects are hashed once, and a visiting set returns a fixed sentinel for back edges so cyclic graphs terminate
The subtle bug sits in the stream payload. To find references safely, PDFiumPas scans a copy of each object in which stream data, strings and comments have been blanked out with spaces; otherwise a run of binary pixel data that happens to look like 12 0 R would be treated as a reference. Hash only that sanitized copy and two images with identical dictionaries (same /Width, /Height, /BitsPerComponent, /Filter, /Length) but different pixels collapse to one fingerprint, and the second photo on the page is silently replaced by the first. SaveAsDeduplicated appends the raw bytes between stream and endstream to the hashed data, so the pixels are part of the identity. Candidates are also grouped by category before comparison, so a rare hash collision between a font and an image can never merge them
Reference rewriting reuses the same sanitized-scan trick in reverse: references are located in the blanked copy but the bytes are copied from the original body, so only dictionary and array structure is ever modified and binary payloads pass through untouched
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;
Read TPdfDedupReport carefully. Next to the three Merged*Count fields it has KeptObjectCount and PrunedObjectCount, and in this report PrunedObjectCount counts merged duplicates only. Unreachable objects are still dropped, but they are not included in that number. If you need the orphan count for logging, take it from a separate SaveAsReachable run. The other limit is that matching is byte-exact on the encoded stream: the same logo compressed by two different producers at two different Flate levels will not be merged
Recompressing Flate streams at maximum level
TPdf.SaveAsFlateRecompressed inflates each reachable FlateDecode stream and deflates it again at the highest compression level, keeping the result only when it is smaller and clears your thresholds. Many producers write Flate at a fast default level, so content streams and embedded font programs often shrink further. Object numbers and generations stay put; only the payload and its direct /Length change
TPdfFlateRecompressOptions has two fields and no default constructor, so set both. MinSavingBytes is the minimum absolute saving per stream, and MinSavingRatio is a fraction of the original length (0.05 means 5%); a ratio of 0 disables the ratio test. The rebuilt stream is written with stream, CRLF, the new payload, CRLF and endstream, so the result does not depend on the original file's line endings
procedure RecompressFlate(Pdf: TPdf; const TargetPdf: string);
var
Options: TPdfFlateRecompressOptions;
Report: TPdfFlateRecompressReport;
begin
Options.MinSavingBytes := 256; // ignore tiny wins
Options.MinSavingRatio := 0.05; // and anything under 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;
The edge cases are conservative by design. A stream whose /Length is an indirect reference is skipped, because the rewrite must store a direct integer. A payload that fails to inflate counts toward FailedCount and is copied unchanged. A Flate stream that does not meet the thresholds counts toward SkippedCount. DCT, JPX, JBIG2 and CCITT images never enter the candidate list, so this pass never touches JPEG quality. For lossy image savings use adaptive image resampling in PDFiumPas before the final save
One portability trap from writing that threshold test: the first draft compared Single(Saving)/Single(OrigLen) with Saving declared as Int64. Delphi accepts it, but FPC 3.2.2 stops with Illegal type conversion: "Int64" to "Single", because in FPC that syntax is a typecast rather than a numeric conversion. Plain Saving / OrigLen is real division in both compilers and compares directly against a Single, so the casts were simply unnecessary
What order should the three APIs run in?
Run SaveAsDeduplicated first, reload the output, then run SaveAsFlateRecompressed. Deduplication already includes the reachability sweep, so a separate SaveAsReachable pass in front of it is redundant; keep SaveAsReachable for jobs where you only want orphan cleanup and an exact pruned count. Recompressing second means the expensive inflate-and-deflate work runs once per surviving stream instead of once per duplicate. The recompression pass also walks the graph from /Root and /Info again, which cleans up any font descriptors or font files that only a merged duplicate font was still pointing at
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); // re-deflate survivors
finally
Pdf.Free;
DeleteFile(TempPdf);
end;
end;
If you also apply redactions, do that before any of this, since physically removing content is what creates new orphans in the first place; the article on operator-level redaction and resource pruning covers the redaction side. And always reload the final file as a sanity check in your pipeline: a page count that matches the source is cheap insurance before you overwrite anything a customer cares about
FAQ
Does SaveAsReachable renumber objects or change references?
No. TPdf.SaveAsReachable keeps every surviving object at its original number and generation and marks removed objects as free xref entries, so no reference has to be rewritten
Can SaveAsDeduplicated merge two different images by mistake?
Not through matching dictionaries alone. The fingerprint includes the raw stream bytes and the full referenced subgraph, and candidates are compared only within the same category (font, image, Form XObject)
Will these APIs keep my digital signatures valid?
No. Each API performs a full rewrite, which breaks the signed byte range, so signed files are rejected unless you pass AllowSignedDocument = True and accept invalid signatures in the output
All three APIs ship with the PDFium Component for Delphi and C++Builder, alongside the redaction and image resampling features covered elsewhere on this blog