A PDF made from phone photos is usually not big because of the text. It is big because every page is a 3000-pixel JPEG drawn on a sheet of paper that only needs a fraction of that. Shrinking it means finding those images, working out how densely they are drawn, resampling them to something sensible and writing them back. The PDF Editor in the osFoundry desktop app does this in two clicks (All tools, Compress a PDF, Advanced optimization). Below is the same idea in about 30 lines of Python with PyMuPDF and Pillow, measured on a test file, and a comparison with what the app saved from the same input.
The test file is a 6-page lease built for this post: each page is a 3024×4032 JPEG with paper grain and uneven lighting, placed on a 612×816 pt page. It is 32,227,412 bytes (30.7 MiB). The grain matters: noise is what makes a JPEG large, so the absolute sizes below are for this file, and a cleaner scan will start smaller and shrink less.
Find out what is taking the space
Before changing anything, add the file up by kind of content. For a scan, one line is enough:
import os, pymupdf doc = pymupdf.open("scan.pdf") img = sum(len(doc.xref_stream_raw(i[0])) for p in doc for i in p.get_images(full=True)) print(img, "of", os.path.getsize("scan.pdf")) 32221241 of 32227412 The images are 99.98% of the file. The text, the page tree and all the PDF bookkeeping together are 6,171 bytes. If the number had come out at 20%, resampling images would not have been the right tool, and no setting in any compressor would have saved much. The app has this check built in (Audit space usage); its table for the same file reads Images 32,225,071 bytes (99.99%), Content Streams 714, Document Overhead 1,627. It doesn't count exactly the way the one-liner above does, so the two numbers differ by a few kilobytes.
An image has no resolution until it is drawn
A JPEG inside a PDF is only a grid of pixels. Its density in pixels per inch is decided by how large the page draws it. PyMuPDF gives both halves:
page = doc[0] xref, w, h = page.get_images(full=True)[0][0], *page.get_images(full=True)[0][2:4] rect = page.get_image_rects(xref)[0] # where the page draws it, in points ppi = w / (rect.width / 72) print(w, h, round(rect.width / 72, 2), round(ppi)) 3024 4032 8.5 356 The image is drawn 8.5 inches wide, so 3024 pixels over 8.5 inches is 356 ppi. Reading a printed page needs about 150, and a screen is happy with less. The same image drawn at half the size would be 712 ppi, which is why the ppi has to be computed per image per placement, not read from a metadata field.
What resolution and quality cost
To pick numbers, here is a sweep on the first two pages' images: five target densities times four JPEG qualities, with the average size of one image and PSNR against a reference. The reference is the original resized to 1275×1700 (150 ppi) with Lanczos; every variant is resized to that grid before comparing, so the 100 and 72 ppi rows include the blur of being enlarged back. PSNR is a rough number, and I did not run OCR here. It is only there to show where quality starts falling quickly.
| target ppi | q90 | q75 | q60 | q40 |
|---|---|---|---|---|
| 356 (original size) | 3713 KiB / 44.0 dB | 1392 KiB / 41.7 dB | 941 KiB / 40.3 dB | 590 KiB / 39.5 dB |
| 225 | 985 KiB / 41.5 dB | 491 KiB / 39.6 dB | 333 KiB / 38.5 dB | 244 KiB / 37.7 dB |
| 150 | 431 KiB / 38.8 dB | 225 KiB / 36.6 dB | 167 KiB / 35.1 dB | 135 KiB / 33.8 dB |
| 100 | 210 KiB / 33.1 dB | 119 KiB / 31.7 dB | 94 KiB / 30.6 dB | 76 KiB / 29.4 dB |
| 72 | 118 KiB / 28.3 dB | 72 KiB / 27.6 dB | 57 KiB / 26.9 dB | 45 KiB / 26.0 dB |
Two things stand out. Going from 356 to 150 ppi at the same quality removes about 88% of the bytes (q75: 1392 to 225 KiB), while dropping only the JPEG quality at full size, from q90 to q40, still leaves 590 KiB, more than twice the 225 KiB of 150 ppi at q75. And below 150 the decibels fall off a cliff: 100 ppi at q90 is below 150 ppi at q40. For a page of printed text, 150 ppi at a middling quality is the knee of the curve in this data.
The script
The rules are the ones the app states in its dialog: downsample to 150 ppi when an image is drawn above 225 ppi, JPEG at a medium quality, and replace an image only if the result is smaller.
import io, os, sys, pymupdf from PIL import Image def shrink(src, dst, target=150, above=225, quality=60): doc = pymupdf.open(src) seen = set() for page in doc: for info in page.get_images(full=True): xref, w, h = info[0], info[2], info[3] if xref in seen: continue seen.add(xref) rect = page.get_image_rects(xref)[0] ppi = w / (rect.width / 72) if ppi <= above: continue raw = doc.xref_stream_raw(xref) im = Image.open(io.BytesIO(raw)).convert("RGB") im = im.resize((round(w * target / ppi), round(h * target / ppi)), Image.LANCZOS) out = io.BytesIO() im.save(out, "JPEG", quality=quality) if out.tell() >= len(raw): continue # not smaller: keep the original doc.update_stream(xref, out.getvalue(), compress=False) doc.xref_set_key(xref, "Width", str(im.width)) doc.xref_set_key(xref, "Height", str(im.height)) doc.xref_set_key(xref, "Filter", "/DCTDecode") doc.xref_set_key(xref, "ColorSpace", "/DeviceRGB") doc.save(dst, garbage=4, deflate=True) shrink(sys.argv[1], sys.argv[2]) print(os.path.getsize(sys.argv[1]), "->", os.path.getsize(sys.argv[2])) On the test file, with PyMuPDF and Pillow from uvx, it took 2.8 seconds on this Mac:
30.7 MiB -> 0.92 MiB (97% smaller), 6 images replaced One trap cost me a wrong first measurement. My first version used page.replace_image(xref, stream=...), which looked like the obvious call, and the output came out at 1.83 MiB, twice what it should be. It added the new image as a new object and the old objects stayed in the file, so it held both versions of every page: a scan of the file's streams showed 12 big ones, each new one with a twin of identical size. Writing the stream into the existing object with update_stream and fixing its Width, Height, Filter and ColorSpace keys gives 0.92 MiB, and six streams of 107 to 172 KB.
How close is the app
The app saved the same input as a copy, en (optimized).pdf, at 1,097,087 bytes (1.0 MiB), 97% smaller, with six images optimized. Reading its images back with PyMuPDF shows 1275×1700 pixels each, 150 ppi on the page, 130,834 to 194,130 bytes. The script above gives 0.92 MiB, a little smaller.
The difference is in the encoder. Pillow can report a JPEG's quantization table, and the first row of the app's table is [13, 9, 8, 13, 19, 32, 41, 49]. Pillow writes exactly that row at quality=60, so "Medium" in the app's dialog is libjpeg quality 60 here, the same as the default in the script. The app's images also keep full-resolution color (Pillow reads their chroma sampling as 4:4:4, while Pillow's default is 4:2:0). For page 1, Pillow at 4:4:4 with optimize=True gives 178,447 bytes and the default gives 171,678, against the app's 194,130, so the app probably also skips optimized Huffman tables. It is a few percent of size, and the full color detail is worth having on a scan of a colored form.
Only replace it if it is smaller
The check out.tell() >= len(raw) is not decoration. Take the app's output and encode its page-1 image again at quality 90: it comes out at 284,133 bytes, against 194,130 for the image already in the file. Re-encoding a JPEG at a quality higher than the one that produced it makes it bigger and no sharper. Without the check, running a compressor twice, or on a file that was already compressed, can grow the file.
The ppi check does the rest of the work. Running the script on its own output, and on the app's output, replaced 0 images both times: every image is already at 150 ppi, below the 225 threshold, and the file is only saved again (the app's 1.0 MiB copy came out at 1.05 MiB, from the rewrite alone, not from touching an image).
Where it stops working
This only helps when the audit says images are the bulk of the file. A PDF exported from a word processor has no page images to downsample. And the result is lossy: the quality loss in the table above is real, and the original has to stay as it is. The app writes a new copy next to the file for that reason, and so does the script, which needs a different dst than src.
There is also a number that explains why this matters beyond the size shown on screen. Mail attaches files as base64, which is 4 bytes of text for every 3 bytes of file, plus line breaks. Encoding the test file that way with Python's base64.encodebytes gives 41.5 MiB for the 30.7 MiB PDF, so a limit of 25 MB can be missed by more than the file size suggests. The same arithmetic puts a file to be sent under about 18 MB.
The PDF Editor is part of the osFoundry desktop app, which runs on your computer and is free from the osFoundry download page. How it keeps files on the device is described on the local-first page.