PDF Compression in 5 Steps: 80MB to 8MB Case Study

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

流程图 1

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:

流程图 2

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:

StepOperationKey parametersSize
1Image resampling600DPI → 150DPI, Lanczos80.4 → 15.2MB
2Format conversionFlateDecode → JPEG, quality 72, grayscale15.2 → 11.0MB
3Font subsettingkeep 1,287 used glyphs11.0 → 10.5MB
4Remove redundancyrebuild cross-reference table10.5 → 9.8MB
5LinearizationObject Stream + Fast Web View9.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 caseRecommended DPIPer-page image sizeNotes
Screen reading / web preview150150–300KBCrisp text, fast loading, good for email
Standard print (A4 laser/inkjet)200–300400–800KBPrint-sharp; 300 for contracts
Archival backup (high fidelity)400–6001–3MBPreserves scan detail for long-term storage
Black-and-white text-only150–20080–200KBExtremely small after grayscale, good for batch archiving
Color document with photos200–300500KB–1.2MBJPEG 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

ParameterRecommended valueNotes
Target DPI (screen)150Standard for document content
Target DPI (print)300Keeps A4 print sharp
JPEG quality (screen)70–75Size first, legibility sufficient
JPEG quality (print)80–90Quality first, size secondary
Color mode (B/W document)DeviceGrayHalves size versus color
Font subsettingEnabledMandatory for CJK fonts, 95%+ reduction
LinearizationEnabledImproves web preview experience
Object StreamEnabledCompresses object metadata
Redundancy cleanupEnabledEspecially 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: