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.
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.
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.
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.
Related
- Compressing and Optimizing PDFs with Python — measuring bytes by type and choosing a technique
- Fix PDF Size Grows After Merging — duplicates and orphans rather than resolution
- Embed Charts in PDF Reports with Matplotlib — generate right-sized images from the start
- Email Generated Reports with Python — the size limits you are compressing