Skip to content

Compress PDF files in C# and .NET

Since 1.0View as Markdown

PdfNetStack makes PDF files smaller without changing a rendered pixel, then proves it before it hands you the result.

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.

compress.cs
#:package PdfNetStack@1.2.0
using 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);
Terminal window
dotnet add package PdfNetStack

The same job on the command line is pdfnetstack optimize in.pdf out.pdf, see Command line.

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}");
PropertyMeaning
DispositionOptimized (a smaller, verified rewrite), OriginalRetained (no worthwhile saving, or verification failed) or UnsupportedRetained (refused by the safety rules)
OriginalBytes, OutputBytes, SavingPercentSizes before and after
SourceSha256, OutputSha256Hashes of the input and of what was written
PageCountPages in the document
ReasonA sentence that explains the disposition and names every lossy change
StatisticsPer 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.

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);
// Streams
await 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.

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
};
OptionDefaultNotes
MinSavingPercent1.0Keep the original unless the rewrite saves at least this much
CompressionMaximumFast 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
UseObjectStreamstrueOff writes a classic xref table
ObjectsPerStream200Larger compresses better, smaller loads single pages faster
DeduplicateObjectstrueMerges identical fonts, images, ICC profiles, resources and arrays; from 1.1 streams compare by decoded content
PruneUnusedResourcestrue1.1. Removes resource entries no content names
CompactContentStreamstrue1.1. Rewrites content streams with minimal white space, same operators and operands
MergeFontSubsetstrue1.1. Merges subsets of one TrueType font that keep the original glyph ids. Needs DeduplicateObjects
Linearizefalse1.1. Fast web view. No object streams, so often larger; kept anyway for input not yet linearized
RecompressStreamstrueRecompresses Flate at zlib level 9, turns LZW and RunLength into Flate
UseImagePredictorstrueTries PNG predictors on raw images
OptimizeJpegImagestrueLossless JPEG re-encoding
ProgressiveJpegfalse1.1. Also tries progressive coding for every JPEG written, kept when smaller. Lossless
JpegQualitynull1 to 100, libjpeg scale. Lossy: requantizes JPEG images, and sets the quality of new JPEGs
MaxImageDpinull1.1. Downsamples images drawn above 1.5 times this resolution. Lossy
MaxBilevelImageDpinull1.2. The same for bilevel images, in place of MaxImageDpi; null uses MaxImageDpi. Lossy
ConvertImagesToJpegfalse1.1. Photographic raw images to JPEG at JpegQuality. Lossy
MinImageSsimnull1.1. Quality gate, 0 to 1: any lossy image change below this SSIM is dropped
VerifytrueDisable only for inputs you already trust
AllowIncrementalUpdatesfalseFlattens edit history into one revision
AllowRepairedFilesfalseRewrites damaged files into clean ones
RemovePageThumbnailsfalseNot lossless
RemovePieceInfofalseNot lossless for the authoring application, invisible to viewers
MaxInputBytes1 GiBLarger inputs are returned unchanged
MaxDecodedStreamBytes256 MiBPer stream decode ceiling, a zip bomb defence
MaxDegreeOfParallelismCPU countLower it to cap memory on image heavy files
PassEffectLossless
Garbage collectionDrops objects unreachable from the catalog, including old revisionsYes
Object streamsPacks non-stream objects into Flate compressed object streams (PDF 1.5)Yes
Xref streamReplaces the text xref table with a predictor encoded, compressed streamYes
Stream recompressionRemoves ASCIIHex and ASCII85 layers, turns LZW and RunLength into Flate, recompresses Flate at level 9, keeps a result only when smallerYes
Image predictionTries PNG predictors on raw images of 8 bits or less per componentYes
JPEG re-encodingRebuilds Huffman tables per image, writes progressive and restart marked JPEGs as baseline, drops segments PDF readers ignore. The DCT coefficients stay identicalYes
DeduplicationMerges identical objects, iterating until merges stop. From 1.1, streams compare by what they decode to, so differently encoded copies mergeYes
Unused resourcesPruneUnusedResources (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 keptYes
Content streamsCompactContentStreams (1.1): page, form, pattern and appearance content rewritten with minimal white space, canonical numbers, shortest strings and no comments; inline image data copied exactlyYes
Font subsetsMergeFontSubsets (1.1): separate subsets of one TrueType font, as merged documents carry them, become one program when no glyph any page shows changesYes
LinearizationLinearize (1.1): first page first, two cross-reference sections and hint tables (Annex F), for fast web viewYes, opt-in
Compact syntaxMinimal separators, canonical numbers, null entries droppedYes
Progressive JPEGProgressiveJpeg: every JPEG written is also tried progressive, kept when smallerYes, opt-in
Exhaustive FlateCompression = Exhaustive: optimal parsing and block splitting in the manner of Zopfli, zlib kept when smallerYes, opt-in
Thumbnails, PieceInfoOptional removal of page thumbnails and application private dataNo, opt-in
JPEG requantizationJpegQuality: coarser quantization, never finer than the image’s own. Soft masks stay losslessNo, opt-in
DownsamplingMaxImageDpi: images drawn above 1.5 times the target resolution are resampled with a Lanczos filterNo, opt-in
Photographs to JPEGConvertImagesToJpeg: raw gray and RGB images that look photographic become baseline JPEGNo, opt-in
Quality gateMinImageSsim: any lossy image change below this luminance SSIM is droppedOpt-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.

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);
  • MaxImageDpi measures 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 at JpegQuality but never finer than it was; raw samples stay lossless after resampling unless ConvertImagesToJpeg is set.
  • ConvertImagesToJpeg re-encodes raw 8 bit gray and RGB images as baseline JPEG at JpegQuality (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 requires JpegQuality.
  • QuantizeImages reduces 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 keeps MinImageSsim (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. With ConvertImagesToJpeg as well, the smaller result wins image by image, so set MinImageSsim if you want JPEG only where it looks right. Images with /Decode, /Interpolate or a /Matte soft mask are left alone.
  • MinImageSsim is a perceptual gate on luminance SSIM. Any image whose lossy change, requantization included, falls below it stays as it was, and ImagesKeptByQualityGate counts those images. Around 0.95 keeps changes hard to see; 0.90 allows visible softening of fine texture.
  • ProgressiveJpeg changes 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”.

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.

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.

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.

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.

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");

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.

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 tool
string? 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.

Measured on a mixed real world corpus of 1,296 files (569 MiB) with default options, lossless, on an 8 core Windows machine:

Optimized1,003 files, 568.9 MiB to 469.0 MiB (17.6% saved)
Returned unchanged38 below the 1% threshold, 130 refused by the safety rules, 125 not PDFs
Verification failures0
Throughputabout 65 s for the whole corpus, 8 to 9 MB/s
With AllowIncrementalUpdates and AllowRepairedFiles1,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).

  • 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.

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.