You receive an 80MB scanned contract PDF. It bounces back from email, gets rejected by chat apps, and trips upload limits on every system you try. Sound familiar? PDF files get large for a reason, and shrinking them safely to a tenth of their size is not magic — it is a matter of understanding what is inside the container and applying the right technique to each type of content. This article dissects five core compression techniques from the PDF specification, then walks through a real 80MB scanned contract all the way down to 8.2MB.
1. Where PDF Size Comes From
A PDF is a container format. Internally it is a collection of objects: a page tree, resource dictionaries, content streams, image XObjects, fonts, and metadata. Size bloat almost always traces back to three categories.
Embedded images. This is the dominant cost in scanned documents. A scanner defaulting to 600DPI produces an A4 page bitmap of roughly 4960×7016 pixels. At 24-bit color depth the raw data for a single page is around 100MB; even after FlateDecode (zlib) lossless compression, each page still weighs 2 to 5MB. A 30-page contract lands at 60 to 150MB.
Embedded fonts. To guarantee identical rendering across devices, PDFs embed full font files. A complete CJK font (for example, a Source Han typeface) carries 20,000-plus glyphs and the TTF file alone is 10 to 20MB; OTF can be larger. Embed multiple weights or multiple families and this category compounds quickly.
Vector art and redundant objects. Vectors themselves are small, but complex CAD exports, nested form fields, resources from deleted revisions, and duplicated metadata streams accumulate silently. PDF's incremental update mechanism is a notorious offender: every save appends a new revision without removing the old objects, so the file grows with every edit.
Once you know where the bytes are, the solution follows. The five-step method below covers the vast majority of real-world compression scenarios.
2. The Five-Step Method at a Glance
Step 1: Image Resampling
This is the single most effective step for scanned documents. A 600DPI scan is indistinguishable from 150DPI on any normal screen, yet the pixel count differs by a factor of 16 (DPI is linear; pixels are quadratic, so (600/150)² ≈ 16).
The key decision is the target DPI. Document content (contracts, reports, invoices) looks fine at 150DPI. For printing, raise it to 200–300DPI. High-fidelity archival scans can keep 400DPI and above. For the resampling algorithm, Lanczos is the recommended choice — it preserves text edge sharpness when downscaling better than bilinear interpolation and avoids the blockiness of nearest-neighbor.
In implementation, you read the image XObject's /Width, /Height, and DPI information from /DecodeParms, scale proportionally, and write back. Be sure to update /Width, /Height, and /Intent together, otherwise rendering will be misaligned.
Step 2: Image Format Conversion
PDF marks image encoding with the /Filter field. Scanned documents typically use /FlateDecode (PNG-style lossless), which is far less efficient than /DCTDecode (JPEG) for real-world imagery like photos or scanned pages.
The same A4 scan that takes 4MB under FlateDecode can drop to 300–500KB as a quality-72 JPEG — an 85% reduction with no perceptible difference on screen. Two things to watch: convert black-and-white documents to grayscale (/ColorSpace /DeviceGray) before encoding to halve the size again, and bump JPEG quality to 80+ on pages containing signatures so the pen strokes stay intact.
/DCTDecode is not the only option. For charts with large flat color areas, /JPXDecode (JPEG 2000) is smaller at equivalent quality, though compatibility with older readers is worse.
Step 3: Font Subsetting
Embedding a full CJK font costs 10MB or more, but a 30-page contract may actually use only 800 to 1,500 distinct characters. Font subsetting keeps only the glyphs that appear in the document and discards everything else.
A subsetted font usually shrinks to 100–300KB — a reduction of over 95%. The approach: walk every page's content stream, extract the characters from Tj and TJ operators, resolve them through the /ToUnicode map to get the set of used code points, then use a font tool (such as fontTools) to build a new font file containing only those glyphs.
Two caveats: CID fonts require handling the /CIDToGIDMap to avoid glyph misalignment, and subsetted fonts are conventionally renamed with a prefix (such as ABCDEF+) to mark them as subsets, which does not affect display.
Step 4: Remove Redundant Objects
After repeated editing, merging, and incremental saves, PDFs accumulate "orphan" objects — resources overwritten by newer revisions but never removed from the cross-reference table. Typical culprits: old images no longer referenced by any page, superseded font copies, empty resource dictionaries, and duplicated metadata streams.
The cleanup logic: start from the document catalog (/Root), do a depth-first traversal of the page tree and resource reference chain, mark every reachable object, then rebuild the cross-reference table and discard the unreachable ones. This step is especially effective on documents that have been edited many times: a single pass typically removes 5%–15% of the size, and in extreme cases of accumulated incremental updates, over 30%.
Step 5: Linearization
Linearization does not reduce the total byte count, but it reorganizes the file structure to enable Fast Web View. A linearized PDF places the objects needed for the first page at the head of the file and inserts hint tables, so a reader can render page one before the download completes.
Although linearization does not shrink the file directly, it is the natural closing step of a compression pipeline. Paired with object stream compression and cross-reference streams, the overall restructuring still yields a small size drop (1%–3%). More importantly, linearized PDFs perform noticeably better in web embedding and cloud preview scenarios.
3. Before-and-After Comparison
Here is how the 80MB contract changes after each step:
Step 1 (resampling) delivers the biggest drop, from 80MB straight to 15.2MB. Step 2 (format conversion) removes another 4MB. The remaining three steps together shave off about 7MB. The overall compression ratio is roughly 9.8:1.
4. Python Pseudocode
The pseudocode below shows the complete five-step logic. In production you would combine libraries such as pikepdf, PyMuPDF, and reportlab.
import io
from PIL import Image
def compress_pdf(input_path, output_path,
target_dpi=150, jpeg_quality=72, grayscale=True):
"""
Five-step PDF compression entry point.
:param input_path: input PDF path
:param output_path: output PDF path
:param target_dpi: target DPI (default 150, good for screen)
:param jpeg_quality: JPEG quality (default 72)
:param grayscale: convert to grayscale (recommended for B/W docs)
:return: (original_size, compressed_size)
"""
pdf = open_pdf(input_path)
original_size = file_size(input_path)
# Step 1: image resampling (600DPI -> target DPI)
for page in pdf.pages:
for image in page.images:
resampled = resample_image(image, target_dpi)
image.replace(resampled)
# Step 2: format conversion (FlateDecode -> DCTDecode/JPEG)
for page in pdf.pages:
for image in page.images:
if image.filter == "FlateDecode":
jpeg_data = encode_jpeg(image, jpeg_quality, grayscale)
image.replace(jpeg_data, filter="DCTDecode")
# Step 3: font subsetting (keep only used glyphs)
used_chars = collect_used_chars(pdf)
for font in pdf.fonts:
subset = build_subset(font, used_chars)
font.replace(subset)
# Step 4: remove redundant objects
pdf.remove_unreferenced_resources()
pdf.compact_xref() # rebuild cross-reference table
# Step 5: linearization (Fast Web View)
pdf.save(output_path, linearize=True,
object_streams=True, compress_streams=True)
pdf.close()
compressed_size = file_size(output_path)
return original_size, compressed_size
def resample_image(image, target_dpi):
"""Resample an image to the target DPI."""
src_dpi = image.dpi
if src_dpi <= target_dpi:
return image # never upscale, skip
scale = target_dpi / src_dpi
new_w = int(image.width * scale)
new_h = int(image.height * scale)
pil_img = Image.open(io.BytesIO(image.data))
return pil_img.resize((new_w, new_h), Image.LANCZOS)
def encode_jpeg(image, quality, grayscale):
"""Convert to grayscale and encode as JPEG."""
pil_img = Image.open(io.BytesIO(image.data))
if grayscale and pil_img.mode != "L":
pil_img = pil_img.convert("L") # to grayscale
buf = io.BytesIO()
pil_img.save(buf, format="JPEG", quality=quality, optimize=True)
return buf.getvalue()
def collect_used_chars(pdf):
"""Walk content streams to collect every code point actually used."""
used = set()
for page in pdf.pages:
for text_op in page.content_stream.text_ops:
used.update(extract_codepoints(text_op))
return used
def build_subset(font, used_chars):
"""Build a subset font containing only the used glyphs."""
subset = font.subset(glyphs=used_chars)
subset.name = prefix + "+" + font.name # subset naming convention
return subset
This is pseudocode — error handling, CID font mapping, and ToUnicode reconstruction are omitted — but the spine is clear: resample, convert, subset, clean, linearize, in that order.
5. Case Study: 80MB Scanned Contract to 8.2MB
The source document is a real 32-page Chinese contract scanned at 600DPI, original size 80.4MB.
Document profile:
- 32 pages, A4, 600DPI black-and-white scan
- 32 embedded images, all FlateDecode, averaging 2.4MB per page
- 2 embedded fonts (Source Han Serif Regular + Bold), 28MB combined
- Saved incrementally 3 times, with redundant objects present
Steps and parameters:
| Step | Operation | Key parameters | Size |
|---|---|---|---|
| 1 | Image resampling | 600DPI → 150DPI, Lanczos | 80.4 → 15.2MB |
| 2 | Format conversion | FlateDecode → JPEG, quality 72, grayscale | 15.2 → 11.0MB |
| 3 | Font subsetting | keep 1,287 used glyphs | 11.0 → 10.5MB |
| 4 | Remove redundancy | rebuild cross-reference table | 10.5 → 9.8MB |
| 5 | Linearization | Object Stream + Fast Web View | 9.8 → 8.2MB |
Result: 8.2MB, a 9.8:1 ratio. Text is crisp and legible, signatures are intact, table borders show no breakage, and the file prints cleanly.
6. DPI Recommendations by Scenario
The target DPI is the main lever balancing compression against legibility. The table below lists recommended values for common use cases.
| Use case | Recommended DPI | Per-page image size | Notes |
|---|---|---|---|
| Screen reading / web preview | 150 | 150–300KB | Crisp text, fast loading, good for email |
| Standard print (A4 laser/inkjet) | 200–300 | 400–800KB | Print-sharp; 300 for contracts |
| Archival backup (high fidelity) | 400–600 | 1–3MB | Preserves scan detail for long-term storage |
| Black-and-white text-only | 150–200 | 80–200KB | Extremely small after grayscale, good for batch archiving |
| Color document with photos | 200–300 | 500KB–1.2MB | JPEG quality 75–85, balancing color and size |
A practical rule: default to 150DPI for screen, reach for 300DPI only when printing, and keep a separate high-DPI master for archival. Do not try to make one 600DPI file serve every purpose.
7. FAQ
Q1: The compressed file came out larger than the original. Why?
This usually happens when you re-compress a PDF that already contains small JPEG-encoded images. Resampling and re-encoding have overhead: if an image is already 150DPI JPEG, "resampling" to 150DPI does not shrink it, and the new JPEG encoder may be less efficient than the original. Add the metadata overhead from object stream restructuring and the file can grow slightly. The fix: inspect each image's current DPI and encoding before compressing, and skip images that already meet the target (≤150DPI and JPEG). Process only the objects that genuinely exceed the threshold.
Q2: Will compression degrade image quality?
Yes, but it can be kept visually imperceptible. Lossy compression comes mainly from Step 2 (JPEG encoding). At quality 72 the difference is invisible on screen; in print you may see slight ringing on high-frequency detail such as thin lines or small fonts. If quality matters more, raise JPEG quality to 85 or switch to JPEG 2000 lossless mode (/JPXDecode with reversible transform). Font subsetting, redundancy removal, and linearization are all lossless and have zero effect on display.
Q3: Can an already-compressed PDF be compressed again?
Yes, but with diminishing returns. The first pass harvests the low-hanging fruit (high DPI, FlateDecode encoding, full fonts). The second pass has little left to optimize. To judge: check whether images still exceed the target DPI, whether any non-JPEG encoding remains, and whether fonts are still full sets. If all three are already optimal, further compression relies mainly on redundancy removal and linearization, typically saving only 5%–10%. Do not repeatedly compress an already-optimized PDF — each lossy encode accumulates quality loss.
8. Parameter Quick Reference
| Parameter | Recommended value | Notes |
|---|---|---|
| Target DPI (screen) | 150 | Standard for document content |
| Target DPI (print) | 300 | Keeps A4 print sharp |
| JPEG quality (screen) | 70–75 | Size first, legibility sufficient |
| JPEG quality (print) | 80–90 | Quality first, size secondary |
| Color mode (B/W document) | DeviceGray | Halves size versus color |
| Font subsetting | Enabled | Mandatory for CJK fonts, 95%+ reduction |
| Linearization | Enabled | Improves web preview experience |
| Object Stream | Enabled | Compresses object metadata |
| Redundancy cleanup | Enabled | Especially effective on repeatedly edited files |
Conclusion
PDF compression is not a black art — it is targeted treatment for each type of object inside the file. The five-step method follows a clear logic: handle the largest contributor first (images, via resampling and format conversion), then fonts (subsetting), then structure (redundancy removal and linearization). Run this pipeline on a typical 80MB scanned contract and you will reliably land at 8–10MB with full readability and printability intact.
Three checks to remember: for scans, look at DPI first; for documents, check whether fonts are full sets; for old files, inspect for redundant objects. Find the dominant cost, and the compression ratio takes care of itself.
Related Reading: