Reduce PDF File Size with Python

The report job produces site-inspection-2026-09.pdf at 41.7 MB, and the mail server rejects it: 552 5.3.4 Message size exceeds fixed maximum message size. Opening the file shows twelve pages of text and photos — nothing that should need forty megabytes. Re-saving with deflate=True brings it to 41.2 MB, which confirms that stream compression is not where the problem lives.

Root Cause

Photos from phones and cameras arrive at 4000×3000 pixels or more, and report generators embed them as-is, scaled down only at display time. A photo shown five centimetres wide on the page still stores all twelve million pixels, which works out to well over 1000 DPI at its displayed size. Those pixels are already JPEG-compressed, so generic deflate compression cannot shrink them further. The file is large because image resolution exceeds anything a screen or printer can show; the only effective fix is to resample each image to the resolution it is actually displayed at and re-encode it.

Minimal Diagnostic

List every image with its pixel dimensions, stored size and effective DPI at the size it is drawn on the page. Anything above about 300 DPI is wasted.

# pip install pymupdf
from pathlib import Path
import pymupdf

SOURCE = Path("in/site-inspection-2026-09.pdf")

def image_report(pdf_path: Path) -> list[dict]:
    rows = []
    try:
        doc = pymupdf.open(pdf_path)
    except (pymupdf.FileDataError, RuntimeError) as exc:
        raise SystemExit(f"cannot open {pdf_path}: {exc}")
    with doc:
        seen = set()
        for page in doc:
            for xref, smask, width, height, bpc, cs, *_ in page.get_images(full=True):
                for rect in page.get_image_rects(xref):
                    if rect.width <= 0 or (xref, page.number) in seen:
                        continue
                    seen.add((xref, page.number))
                    dpi = width / (rect.width / 72)            # pixels per inch at displayed width
                    rows.append({
                        "page": page.number + 1, "xref": xref,
                        "pixels": f"{width}x{height}",
                        "stored_mb": len(doc.xref_stream_raw(xref)) / 1e6,
                        "shown_cm": round(rect.width / 72 * 2.54, 1),
                        "dpi": round(dpi),
                    })
    return rows

if __name__ == "__main__":
    rows = image_report(SOURCE)
    for r in sorted(rows, key=lambda r: -r["stored_mb"])[:8]:
        print(f"p{r['page']:<3} xref {r['xref']:<5} {r['pixels']:>11}  "
              f"{r['stored_mb']:5.2f} MB  shown {r['shown_cm']:>5} cm  {r['dpi']:>5} dpi")
    print(f"total image bytes: {sum(r['stored_mb'] for r in rows):.1f} MB")
p3   xref 41     4032x3024   3.61 MB  shown   8.0 cm   1280 dpi
p3   xref 44     4032x3024   3.52 MB  shown   8.0 cm   1280 dpi
p5   xref 58     4032x3024   3.47 MB  shown   8.0 cm   1280 dpi
...
total image bytes: 39.8 MB

Twelve phone photos at 1280 DPI account for 39.8 of the 41.7 MB. Resampling them to 150 DPI at their displayed size reduces each to roughly 470×350 pixels.

Effective DPI of the report's photos The embedded photos are displayed at 1280 DPI effective resolution. Commercial print needs about 300 DPI, office printing about 200 DPI and on-screen reading about 150 DPI. Everything above the print requirement is stored but never visible. Resolution stored versus resolution needed Phone photo as embedded 1280 dpi Commercial print 300 dpi Office printer 200 dpi Screen reading 150 dpi Pixels above the destination's need are pure file size

Fix: Resample Each Image to Its Displayed Size

Resize each oversized image with Pillow to the pixel size that gives the target DPI at its largest displayed rectangle, re-encode it as JPEG, and swap it into the PDF with page.replace_image. Every changed line carries a comment.

# pip install pymupdf pillow
import io
from pathlib import Path
import pymupdf
from PIL import Image

SOURCE = Path("in/site-inspection-2026-09.pdf")
DEST = Path("out/site-inspection-2026-09.pdf")
TARGET_DPI = 150
THRESHOLD_DPI = 225                     # only touch images at 1.5x the target or more
QUALITY = 80

def largest_display_width(doc: pymupdf.Document, xref: int) -> float:
    """Widest rectangle (in points) this image is drawn at anywhere in the document."""
    widest = 0.0
    for page in doc:
        for rect in page.get_image_rects(xref):
            widest = max(widest, rect.width)
    return widest

def shrink_images(src: Path, dest: Path) -> tuple[int, int]:
    before = src.stat().st_size
    done = set()
    with pymupdf.open(src) as doc:
        for page in doc:
            for xref, smask, width, height, *_ in page.get_images(full=True):
                if xref in done or smask:                         # changed: skip images with alpha masks
                    continue
                done.add(xref)
                shown = largest_display_width(doc, xref)          # changed: size by largest use
                if shown <= 0:
                    continue
                dpi = width / (shown / 72)
                if dpi < THRESHOLD_DPI:                           # changed: leave sensible images alone
                    continue
                new_w = max(1, round(shown / 72 * TARGET_DPI))    # changed: pixels for target DPI
                new_h = max(1, round(height * new_w / width))
                info = doc.extract_image(xref)
                image = Image.open(io.BytesIO(info["image"]))
                if image.mode not in ("RGB", "L"):
                    image = image.convert("RGB")                  # changed: JPEG needs RGB or L
                image = image.resize((new_w, new_h), Image.Resampling.LANCZOS)
                buf = io.BytesIO()
                image.save(buf, format="JPEG", quality=QUALITY, optimize=True, progressive=True)
                page.replace_image(xref, stream=buf.getvalue())   # changed: swap in the smaller image
        dest.parent.mkdir(parents=True, exist_ok=True)
        doc.save(dest, garbage=4, deflate=True)                   # changed: drop the old image objects
    return before, dest.stat().st_size

if __name__ == "__main__":
    try:
        b, a = shrink_images(SOURCE, DEST)
        print(f"{b / 1e6:.1f} MB -> {a / 1e6:.1f} MB")
    except Exception as exc:
        raise SystemExit(f"compression failed: {exc}")
41.7 MB -> 2.3 MB

Sizing by the largest rectangle an image is drawn at matters when the same image appears twice — a thumbnail on the summary page and full width in an appendix. Sizing by the thumbnail would leave the appendix copy blurred. Skipping images that have a soft mask (smask) avoids flattening transparency into black backgrounds; those are usually logos and small, so skipping costs little.

garbage=4 on save is not optional here: replace_image points the page at a new image object, and without garbage collection the original 3.6 MB streams stay in the file.

Per-image resampling loop For each unique image, the loop finds the largest rectangle it is displayed at, computes effective DPI and skips images below the threshold or with transparency. Qualifying images are extracted, resized with Lanczos resampling to the target DPI, re-encoded as JPEG at quality 80 and swapped in with replace_image. A final save with garbage collection removes the original image streams. Largest rect per unique xref DPI check skip below 225 Resize Lanczos to 150 dpi Re-encode JPEG q80 Replace and save garbage=4 Has alpha mask skip to keep transparency

Variant Fix 1: Pure Python with pypdf

Where PyMuPDF is not available, pypdf can replace images in place and merge identical objects. It does not report display rectangles as directly, so this variant caps the longest pixel edge instead of computing DPI:

# pip install "pypdf[image]>=4.0" pillow
from pathlib import Path
from pypdf import PdfReader, PdfWriter
from PIL import Image

MAX_EDGE = 1600            # pixels on the longest side; ~200 dpi for a full-width A4 image

def pypdf_shrink(src: Path, dest: Path, quality: int = 80) -> None:
    try:
        writer = PdfWriter(clone_from=PdfReader(src))
    except Exception as exc:
        raise RuntimeError(f"pypdf could not read {src}: {exc}") from exc
    for page in writer.pages:
        for img in page.images:
            pil = img.image
            if max(pil.size) <= MAX_EDGE:
                continue
            pil = pil.convert("RGB")
            pil.thumbnail((MAX_EDGE, MAX_EDGE), Image.Resampling.LANCZOS)
            img.replace(pil, quality=quality)             # re-encodes as JPEG
    writer.compress_identical_objects(remove_identicals=True, remove_orphans=True)
    dest.parent.mkdir(parents=True, exist_ok=True)
    with dest.open("wb") as fh:
        writer.write(fh)

A pixel cap is cruder than per-use DPI — a small inline photo stays at 1600 pixels — but it removes the extreme cases that cause almost all of the bloat, with no compiled dependencies.

Variant Fix 2: Scans of Black-and-White Paperwork

Colour scans of printed forms are a different case: the images are displayed full-page, so their DPI is not extreme, but storing black text on white paper in 24-bit colour wastes two thirds of the bytes before compression even starts. Convert to grayscale — or to 1-bit for pure text — before re-encoding:

# pip install pillow
from PIL import Image, ImageOps

def paperwork_mode(image: Image.Image, bitonal: bool = False) -> Image.Image:
    gray = ImageOps.grayscale(image)
    if not bitonal:
        return gray                                  # save as JPEG, quality 70-80
    return gray.point(lambda v: 255 if v > 170 else 0, mode="1")   # save as PNG or CCITT

Replace the image.convert("RGB") step in the main fix with paperwork_mode(image) for documents classified as scans. Grayscale JPEG typically takes a third of the space of colour at the same quality; bitonal images compress smaller still but lose stamps, signatures in blue ink and highlighter marks, so reserve them for documents where colour carries no meaning. The full scanned-document route, including OCR, is covered under Compressing and Optimizing PDFs.

Colour mode choices for scanned pages Colour JPEG gives the largest files but keeps stamps and coloured ink and gives good OCR. Grayscale JPEG gives about a third of the size, keeps stamps as gray tones and gives good OCR. Bitonal encoding gives the smallest files, loses light stamps and all colour information, and gives good OCR on clean prints but poor results on faint scans. Mode Relative size Stamps and ink OCR quality Colour JPEG q80 100% kept good Grayscale JPEG q75 about 33% as gray good Bitonal CCITT or PNG about 8% often lost faint scans suffer

Verification

Confirm three things: the target size was met, text and page count did not change, and no image was degraded below the target resolution.

# pip install pymupdf
from pathlib import Path
import pymupdf

def verify(original: Path, compressed: Path, max_bytes: int, min_dpi: int = 140) -> None:
    size = compressed.stat().st_size
    assert size <= max_bytes, f"still {size / 1e6:.1f} MB (limit {max_bytes / 1e6:.1f} MB)"
    with pymupdf.open(original) as a, pymupdf.open(compressed) as b:
        assert a.page_count == b.page_count, "page count changed"
        for pa, pb in zip(a, b):
            assert pa.get_text() == pb.get_text(), f"page {pa.number + 1}: text changed"
            for xref, _s, width, *_ in pb.get_images(full=True):
                for rect in pb.get_image_rects(xref):
                    if rect.width > 36:                          # ignore tiny icons
                        dpi = width / (rect.width / 72)
                        assert dpi >= min_dpi, f"page {pb.number + 1}: image at {dpi:.0f} dpi"
    print(f"ok: {size / 1e6:.2f} MB, text unchanged, images >= {min_dpi} dpi")

if __name__ == "__main__":
    verify(Path("in/site-inspection-2026-09.pdf"), Path("out/site-inspection-2026-09.pdf"),
           max_bytes=10_000_000)

Open two or three compressed pages at 200% zoom as a final human check the first time the settings are used. JPEG artefacts around fine text in photos — a meter reading, a serial plate — are the one quality loss the automated checks cannot judge, and those details are often why the photo is in the report.

Wiring It into the Report Job

Run the shrink step between generation and delivery, and make delivery conditional on the size check rather than on the shrink step having run. That ordering means an unusually large report — forty photos instead of twelve — fails loudly with a clear message instead of bouncing at the mail server hours later.

# pip install pymupdf pillow
import logging
from pathlib import Path

from shrink import shrink_images, verify     # the fix and verification functions above

log = logging.getLogger("report")
EMAIL_LIMIT = 7_000_000                      # 10 MB gateway minus base64 overhead (see below)

def prepare_attachment(generated: Path, outbox: Path) -> Path:
    dest = outbox / generated.name
    before, after = shrink_images(generated, dest)
    log.info("%s: %.1f MB -> %.1f MB", generated.name, before / 1e6, after / 1e6)
    try:
        verify(generated, dest, max_bytes=EMAIL_LIMIT)
    except AssertionError as exc:
        dest.unlink(missing_ok=True)
        raise RuntimeError(f"{generated.name} not deliverable by email: {exc}") from exc
    return dest

Mind the encoding overhead when the limit is an email limit: attachments are base64-encoded, which inflates them by about 37 percent. A 10 MB server limit therefore allows roughly a 7 MB PDF, which is why EMAIL_LIMIT is set well below the gateway's advertised figure. The delivery side of the job, including splitting across messages, is in email generated reports with Python.

FAQ

Why not just lower JPEG quality instead of resizing? Quality reduction on a 12-megapixel image gives modest savings and visible blocking. Removing pixels that are never displayed gives ten to twenty times the saving with no visible loss.

Will this work on PNG screenshots? Screenshots of UI and charts compress badly as JPEG — edges ring. Resize them, but save as PNG, or leave them alone if their DPI is already reasonable.

Can I fix it at the source instead? Yes, and you should for recurring reports: resize photos before inserting them into the document generator. The same resizing helper works on the input files, and the PDF never gets large in the first place.

What about images inside Word documents that are converted to PDF? Shrink them in the .docx first — see fix image too large in python-docx — so both the Word and PDF outputs benefit.

Part of Compressing and Optimizing PDFs with Python.