Compress PDF files in C# and .NET
PdfNetStack makes PDF files smaller without changing a rendered pixel, then proves it before it hands you the result.
Short answer
Section titled “Short answer”Call PdfOptimizer.OptimizeFileAsync with an input and an output path. The optimizer rewrites the
file with compressed object streams and a compressed cross-reference stream, drops unreachable
objects and unused resources, rewrites content streams compactly, recompresses streams,
re-encodes JPEGs losslessly and merges duplicates. It then reads its
own output back and compares it to the source object by object. When anything differs, or the
saving is under 1%, the output is your original bytes.
#:package PdfNetStack@1.2.0using PdfNetStack;
PdfOptimizationResult result = await PdfOptimizer.OptimizeFileAsync("manual.pdf", "manual.optimized.pdf");Console.WriteLine($"{result.Disposition}: {result.OriginalBytes:N0} to {result.OutputBytes:N0} bytes, saved {result.SavingPercent:F1}%");Console.WriteLine(result.Reason);dotnet add package PdfNetStackThe same job on the command line is pdfnetstack optimize in.pdf out.pdf, see
Command line.
What the result tells you
Section titled “What the result tells you”PdfOptimizationResult reports what was written and why.
Console.WriteLine($"Disposition: {inMemory.Disposition}");Console.WriteLine($"Size: {inMemory.OriginalBytes:N0} to {inMemory.OutputBytes:N0} bytes ({inMemory.SavingPercent:F1}%)");Console.WriteLine($"Pages: {inMemory.PageCount}, output SHA-256: {inMemory.OutputSha256}");Console.WriteLine($"Reason: {inMemory.Reason}");Console.WriteLine($"JPEG images requantized: {inMemory.Statistics.JpegImagesRequantized}");| Property | Meaning |
|---|---|
Disposition | Optimized (a smaller, verified rewrite), OriginalRetained (no worthwhile saving, or verification failed) or UnsupportedRetained (refused by the safety rules) |
OriginalBytes, OutputBytes, SavingPercent | Sizes before and after |
SourceSha256, OutputSha256 | Hashes of the input and of what was written |
PageCount | Pages in the document |
Reason | A sentence that explains the disposition and names every lossy change |
Statistics | Per pass counters and timings, such as objects merged, JPEG images re-encoded and images downsampled |
The output is always a valid PDF: either the verified rewrite or the input unchanged.
Bytes, streams and files
Section titled “Bytes, streams and files”Three entry points cover the usual cases. All of them take the same options and return the same result.
// In memory(byte[] output, PdfOptimizationResult inMemory) = PdfOptimizer.Optimize(File.ReadAllBytes("manual.pdf"), options);File.WriteAllBytes("manual.small.pdf", output);
// Streamsawait using (FileStream input = File.OpenRead("manual.pdf"))await using (FileStream destination = File.Create("manual.stream.pdf")){ PdfOptimizationResult streamed = await PdfOptimizer.OptimizeAsync(input, destination); Console.WriteLine($"Streams: {streamed.Disposition}, {streamed.SavingPercent:F1}% saved");}OptimizeFileAsync writes the output file. OptimizeAsync reads a stream and writes another one,
which suits blob storage and HTTP bodies. Optimize works on a byte array and returns the bytes.
Options
Section titled “Options”Pass a PdfOptimizerOptions to change the defaults.
var options = new PdfOptimizerOptions{ Compression = CompressionEffort.Balanced, // faster, slightly larger AllowIncrementalUpdates = true, // flatten edit history into one revision MaxDegreeOfParallelism = 2, // cap CPU and memory per document JpegQuality = 60, // opt-in, lossy: requantize JPEG images};| Option | Default | Notes |
|---|---|---|
MinSavingPercent | 1.0 | Keep the original unless the rewrite saves at least this much |
Compression | Maximum | Fast and Balanced trade size for speed: Balanced cut corpus time by about a third for about 2% larger output. Exhaustive (1.1) searches harder, see below |
UseObjectStreams | true | Off writes a classic xref table |
ObjectsPerStream | 200 | Larger compresses better, smaller loads single pages faster |
DeduplicateObjects | true | Merges identical fonts, images, ICC profiles, resources and arrays; from 1.1 streams compare by decoded content |
PruneUnusedResources | true | 1.1. Removes resource entries no content names |
CompactContentStreams | true | 1.1. Rewrites content streams with minimal white space, same operators and operands |
MergeFontSubsets | true | 1.1. Merges subsets of one TrueType font that keep the original glyph ids. Needs DeduplicateObjects |
Linearize | false | 1.1. Fast web view. No object streams, so often larger; kept anyway for input not yet linearized |
RecompressStreams | true | Recompresses Flate at zlib level 9, turns LZW and RunLength into Flate |
UseImagePredictors | true | Tries PNG predictors on raw images |
OptimizeJpegImages | true | Lossless JPEG re-encoding |
ProgressiveJpeg | false | 1.1. Also tries progressive coding for every JPEG written, kept when smaller. Lossless |
JpegQuality | null | 1 to 100, libjpeg scale. Lossy: requantizes JPEG images, and sets the quality of new JPEGs |
MaxImageDpi | null | 1.1. Downsamples images drawn above 1.5 times this resolution. Lossy |
MaxBilevelImageDpi | null | 1.2. The same for bilevel images, in place of MaxImageDpi; null uses MaxImageDpi. Lossy |
ConvertImagesToJpeg | false | 1.1. Photographic raw images to JPEG at JpegQuality. Lossy |
MinImageSsim | null | 1.1. Quality gate, 0 to 1: any lossy image change below this SSIM is dropped |
Verify | true | Disable only for inputs you already trust |
AllowIncrementalUpdates | false | Flattens edit history into one revision |
AllowRepairedFiles | false | Rewrites damaged files into clean ones |
RemovePageThumbnails | false | Not lossless |
RemovePieceInfo | false | Not lossless for the authoring application, invisible to viewers |
MaxInputBytes | 1 GiB | Larger inputs are returned unchanged |
MaxDecodedStreamBytes | 256 MiB | Per stream decode ceiling, a zip bomb defence |
MaxDegreeOfParallelism | CPU count | Lower it to cap memory on image heavy files |
What the optimizer does
Section titled “What the optimizer does”| Pass | Effect | Lossless |
|---|---|---|
| Garbage collection | Drops objects unreachable from the catalog, including old revisions | Yes |
| Object streams | Packs non-stream objects into Flate compressed object streams (PDF 1.5) | Yes |
| Xref stream | Replaces the text xref table with a predictor encoded, compressed stream | Yes |
| Stream recompression | Removes ASCIIHex and ASCII85 layers, turns LZW and RunLength into Flate, recompresses Flate at level 9, keeps a result only when smaller | Yes |
| Image prediction | Tries PNG predictors on raw images of 8 bits or less per component | Yes |
| JPEG re-encoding | Rebuilds Huffman tables per image, writes progressive and restart marked JPEGs as baseline, drops segments PDF readers ignore. The DCT coefficients stay identical | Yes |
| Deduplication | Merges identical objects, iterating until merges stop. From 1.1, streams compare by what they decode to, so differently encoded copies merge | Yes |
| Unused resources | PruneUnusedResources (1.1): fonts, XObjects, graphics states, patterns, shadings, color spaces and property lists that no content names are removed. Anything that cannot be read for certain is kept | Yes |
| Content streams | CompactContentStreams (1.1): page, form, pattern and appearance content rewritten with minimal white space, canonical numbers, shortest strings and no comments; inline image data copied exactly | Yes |
| Font subsets | MergeFontSubsets (1.1): separate subsets of one TrueType font, as merged documents carry them, become one program when no glyph any page shows changes | Yes |
| Linearization | Linearize (1.1): first page first, two cross-reference sections and hint tables (Annex F), for fast web view | Yes, opt-in |
| Compact syntax | Minimal separators, canonical numbers, null entries dropped | Yes |
| Progressive JPEG | ProgressiveJpeg: every JPEG written is also tried progressive, kept when smaller | Yes, opt-in |
| Exhaustive Flate | Compression = Exhaustive: optimal parsing and block splitting in the manner of Zopfli, zlib kept when smaller | Yes, opt-in |
| Thumbnails, PieceInfo | Optional removal of page thumbnails and application private data | No, opt-in |
| JPEG requantization | JpegQuality: coarser quantization, never finer than the image’s own. Soft masks stay lossless | No, opt-in |
| Downsampling | MaxImageDpi: images drawn above 1.5 times the target resolution are resampled with a Lanczos filter | No, opt-in |
| Photographs to JPEG | ConvertImagesToJpeg: raw gray and RGB images that look photographic become baseline JPEG | No, opt-in |
| Quality gate | MinImageSsim: any lossy image change below this luminance SSIM is dropped | Opt-in |
Without the lossy image options, JPEG data is decoded to DCT coefficients and never to pixels, so components, chroma sampling and the color model pass through untouched: a CMYK image stays CMYK. JPEG 2000, JBIG2 and CCITT data is never decoded or touched by the optimizer.
Lossy image options
Section titled “Lossy image options”All of them are off by default. For most documents a preset is all you need:
PdfOptimizerOptions.Balanced gives much smaller files with no change most readers can see, and
PdfOptimizerOptions.Smallest goes further for reading on screen, with images at 150 dpi and
bilevel scans at 300, where text stays clean. To choose for yourself, turn the
options on together for the biggest saving on image heavy files:
var options = new PdfOptimizerOptions{ MaxImageDpi = 150, // downsample images drawn above 225 dpi to 150 dpi ConvertImagesToJpeg = true, // photographs stored raw become JPEG JpegQuality = 90, // new JPEGs, and existing ones finer than this are requantized MinImageSsim = 0.95, // keep any image a change would visibly hurt ProgressiveJpeg = true, // lossless: progressive coding when smaller};PdfOptimizationResult result = await PdfOptimizer.OptimizeFileAsync("brochure.pdf", "brochure-small.pdf", options);MaxImageDpimeasures each image at the largest size it is drawn anywhere: on pages, in forms, tiling patterns, soft mask groups and annotation appearances. An image drawn above 1.5 times the target is resampled to the target with a three lobe Lanczos filter. A downsampled JPEG stays a JPEG, encoded atJpegQualitybut never finer than it was; raw samples stay lossless after resampling unlessConvertImagesToJpegis set.ConvertImagesToJpegre-encodes raw 8 bit gray and RGB images as baseline JPEG atJpegQuality(4:2:0 below quality 90, Huffman tables optimized per image). A classifier leaves line art, screenshots, charts, palette and bilevel images lossless, and a JPEG is kept only when it is at least 10% smaller than the best lossless encoding. It requiresJpegQuality.QuantizeImagesreduces raw 8 bit RGB images to a palette of at most 256 colors, in their own color space. This is the option for technical drawings, charts and screenshots: JPEG rings around their fine labels and often saves little, while a palette keeps edges sharp and Flate then compresses the flat areas well. Each image gets the smallest palette, of 256, 64, 16 or 4 colors, that keepsMinImageSsim(0.97 without a gate), changes pixels by at most 8 sample steps on average, and changes at most 1% of them by more than 32. Colors that dominate a cluster stay exact, so a white background stays white. WithConvertImagesToJpegas well, the smaller result wins image by image, so setMinImageSsimif you want JPEG only where it looks right. Images with/Decode,/Interpolateor a/Mattesoft mask are left alone.MinImageSsimis a perceptual gate on luminance SSIM. Any image whose lossy change, requantization included, falls below it stays as it was, andImagesKeptByQualityGatecounts those images. Around 0.95 keeps changes hard to see; 0.90 allows visible softening of fine texture.ProgressiveJpegchanges the coding, not the coefficients, so it is lossless.
The result says what changed:
OptimizationStatistics stats = result.Statistics;Console.WriteLine($"{result.OriginalBytes:N0} to {result.OutputBytes:N0} bytes ({result.SavingPercent:F1}% saved)");Console.WriteLine($"Downsampled: {stats.ImagesDownsampled}, converted to JPEG: {stats.ImagesConvertedToJpeg}, " + $"requantized: {stats.JpegImagesRequantized}, kept by the quality gate: {stats.ImagesKeptByQualityGate}");Console.WriteLine(result.Reason);OptimizationStatistics reports ImagesDownsampled, ImagesConvertedToJpeg, ImagesQuantized,
JpegImagesRequantized and ImagesKeptByQualityGate, and Reason names every lossy change, for
example “1 image(s) downsampled to 150 dpi, 1 image(s) converted to JPEG at quality 90”.
What the proof checks for changed images
Section titled “What the proof checks for changed images”Verification still runs on every image the optimizer changed, but for pixels that changed it is a
bound, not an identity. A downsampled or converted image must keep its components and aspect
ratio, must not grow, and is decoded and compared with the source resampled to its new size: each
channel’s mean, a coarse thumbnail that catches mirrored, shifted or swapped pictures, and
luminance SSIM of at least MinImageSsim (0.5 when no gate is set). Soft masks, masks, stencil
masks and color keyed images must match exactly, and every other part of the document must be
identical as before. The method is on the fidelity page.
What is left alone
Section titled “What is left alone”Masks, images with a color key mask or a /Matte soft mask, bilevel, indexed, 16 bit, Lab, Separation and DeviceN images keep their size, and so do images reachable from anything the measurement does not walk, such as Type 3 glyphs and thumbnails. JPEG 2000, JBIG2 and CCITT images are never changed. A JPEG with a color key mask is never requantized, since the key matches exact sample values.
These options were measured on synthetic files made by tools/make_lossy_corpus.py, not on a real
corpus. The corpus results below are for the lossless passes only.
Structural passes
Section titled “Structural passes”Three lossless passes added in 1.1 work on the document’s structure rather than on its streams’ compression, and all three are on by default. Unused resources go: merged and edited files often list fonts and images in a page’s resources that no content on it uses. Content streams are written compactly. Subsets of one TrueType font that merging tools embedded once per document are merged into one program when every glyph stays the same. The statistics count each of them.
PdfOptimizationResult result = await PdfOptimizer.OptimizeFileAsync("merged.pdf", "merged.optimized.pdf");OptimizationStatistics stats = result.Statistics;Console.WriteLine($"{result.Disposition}, saved {result.SavingPercent:F1}%");Console.WriteLine($"Resources pruned: {stats.ResourcesPruned}");Console.WriteLine($"Content streams compacted: {stats.ContentStreamsCompacted}");None of them is taken on trust. The verifier runs its own analysis of the source and accepts a
missing resource only where no content can name it, a rewritten content stream only when it reads
as the same tokens, and a merged font only when every glyph shown keeps its outline and metrics.
The rules are on the fidelity page. Turn a pass off with its option, or
--no-prune, --no-compact and --no-font-merge on the command line.
Fast web view
Section titled “Fast web view”Linearize = true writes a linearized file (ISO 32000-1, Annex F): the first page and what it
needs come first, with hint tables, so a viewer can show it before the whole file has arrived.
Inherited page attributes move onto the pages, and every object is written on its own, without
object streams, so the output can be larger than the input. A linearized rewrite of a file that
is not linearized yet is kept even then, provided it verifies. Statistics.Linearized says
whether the output is linearized; a page tree that cannot be laid out page by page, such as one
that lists a page twice, is written as usual and the reason says why.
var webView = new PdfOptimizerOptions { Linearize = true };PdfOptimizationResult linear = await PdfOptimizer.OptimizeFileAsync("manual.pdf", "manual.web.pdf", webView);Console.WriteLine($"Linearized: {linear.Statistics.Linearized}, {linear.OriginalBytes:N0} to {linear.OutputBytes:N0} bytes");The tests read every linearized file back and check the linearization dictionary, both
cross-reference sections and every hint table entry against the real offsets.
tools/measure_optimizer.py --linearize also runs qpdf’s linearization check when qpdf is
installed.
Exhaustive Flate
Section titled “Exhaustive Flate”CompressionEffort.Exhaustive is an original Deflate encoder in the manner of Zopfli: optimal
parsing over every match length with costs refined over several passes, block splitting, and code
lengths limited by package merge. Output stays standard Flate, and zlib’s result is kept whenever
it is smaller. On the synthetic files of tools/make_lossy_corpus.py it saved 3 to 5% more than
level 9 on text, scans and photographs, and nothing on line art and screenshots, for 2 to 150
times the CPU time. It suits archives written once and read often.
// Lossless, for archives: a slower Deflate search, kept only where it beats zlib.var archival = new PdfOptimizerOptions { Compression = CompressionEffort.Exhaustive };PdfOptimizationResult archived = await PdfOptimizer.OptimizeFileAsync("manual.pdf", "manual-archive.pdf", archival);Console.WriteLine($"Exhaustive: {archived.OriginalBytes:N0} to {archived.OutputBytes:N0} bytes");Files the optimizer will not rewrite
Section titled “Files the optimizer will not rewrite”The optimizer returns the input unchanged, with UnsupportedRetained and a reason, when the file
is encrypted, digitally signed or carries usage rights, incrementally updated (unless
AllowIncrementalUpdates is set), damaged and repaired on load (unless AllowRepairedFiles is
set), or larger than MaxInputBytes. The safety model explains each
rule.
Check another tool’s output
Section titled “Check another tool’s output”The equivalence check the optimizer runs on itself is public. Use it on the output of any PDF tool:
byte[] original = File.ReadAllBytes("manual.pdf");byte[] candidate = File.ReadAllBytes("manual.checked.pdf"); // written by any toolstring? difference = PdfEquivalence.FindDifference(original, candidate);Console.WriteLine(difference ?? "Equivalent: same objects, same decoded content.");FindDifference returns null when the two documents are equivalent, or a description of the
first difference. An overload takes allowJpegRequantization and minImageSsim, and then also
accepts requantized, downsampled and converted images within the bounds above:
// The same check on any tool's output: accept downsampled and converted images that keep SSIM 0.95.string? difference = PdfEquivalence.FindDifference( File.ReadAllBytes("brochure.pdf"), File.ReadAllBytes("brochure-small.pdf"), maxDecodedStreamBytes: 256L << 20, allowJpegRequantization: true, minImageSsim: 0.95);Console.WriteLine(difference ?? "Within the bounds of the lossy proof.");The command line equivalent is pdfnetstack verify original.pdf other.pdf --min-ssim 0.95. The
method and its limits are on the fidelity page.
Results
Section titled “Results”Measured on a mixed real world corpus of 1,296 files (569 MiB) with default options, lossless, on an 8 core Windows machine:
| Optimized | 1,003 files, 568.9 MiB to 469.0 MiB (17.6% saved) |
| Returned unchanged | 38 below the 1% threshold, 130 refused by the safety rules, 125 not PDFs |
| Verification failures | 0 |
| Throughput | about 65 s for the whole corpus, 8 to 9 MB/s |
With AllowIncrementalUpdates and AllowRepairedFiles | 1,118 files optimized, 20.1% saved |
Every optimized file was rendered with PDFium at 150 DPI and compared with its original: 2,161
pages, zero differing pixels. See Benchmarks for the method. These figures were
measured with version 1.0, before the structural passes of 1.1, which have so far been measured
on synthetic files (tools/make_optimizer_samples.py and tools/measure_optimizer.py).
Limits
Section titled “Limits”- Lossy image options handle 8 bit gray, RGB and CMYK images, raw or JPEG. JPEG 2000, JBIG2, CCITT, 16 bit, indexed, Lab, Separation and DeviceN images keep their resolution, and images inside Type 3 glyphs are never downsampled. The quality gate measures luminance only.
- Arithmetic coded, 12 bit, lossless and hierarchical JPEGs, JPEGs with more than 64 scans, and damaged JPEG data are carried over untouched.
- The optimizer does not subset fonts. It merges subsets of one font only when they keep the original glyph ids.
- Linearization is opt-in. Without
Linearize, the linearization of an input is dropped. - The optimizer holds the whole document in memory: plan for about 10 times the file size, see Memory and performance. To read or edit a large file without loading it, see Large files.
Related
Section titled “Related”- Command line:
optimize,batch,inspectandverify - Large files: open on demand and save incremental updates
- Linearize a PDF for fast web view
- Reduce PDF size for email and Downsample images in a PDF
- Edit pages: saving an edited document runs the optimizer too
- Fidelity and verification
- API: PdfOptimizer, PdfOptimizerOptions, PdfOptimizationResult, OptimizationStatistics, PdfEquivalence
Questions
- Is PdfNetStack compression lossless?
- Yes, by default. Text, vectors, fonts, images, forms, tags and metadata are carried over, and every output is compared to its source before it is returned. The lossy image options (JPEG requantization, downsampling, JPEG conversion) are opt-in.
- How much smaller do PDFs get?
- On a mixed corpus of 1,296 real world files the optimized ones went from 568.9 MiB to 469.0 MiB, 17.6% saved, losslessly. Image heavy files often gain 20 to 40%. Files from producers that already use object streams gain little.
- Can it downsample images?
- Yes, from version 1.1, with MaxImageDpi. Images drawn above 1.5 times the target resolution are resampled with a Lanczos filter, and MinImageSsim keeps any image the change would visibly hurt.
- Can PdfNetStack write linearized PDFs for fast web view?
- Yes, from version 1.1, with Linearize = true or pdfnetstack optimize --linearize. The file is laid out first page first with hint tables, as ISO 32000-1 Annex F describes, and verified like any other rewrite.
- Does the optimizer need Ghostscript or another native tool?
- No. It is one managed assembly with no native code and no external process, on Windows, Linux and macOS.