A client of mine documents site work with a phone. Every Friday I get a folder -- sometimes a literal zip over email -- with 40 to 80 photos: receipts, signed delivery notes, whiteboard shots, the occasional crooked picture of a screen. My job is to turn that into one PDF the bookkeeper can file, in capture order, without spending my afternoon in a file dialog.
This is the batch image workflow I landed on. It has two directions, because the reverse job shows up just as often: a PDF arrives and somebody needs the images out of it, one file per image, right now.
Direction 1: Many images, one PDF
The scripting route takes about fifteen lines with Pillow:
from PIL import Image from pathlib import Path photos = sorted(Path("site-photos").glob("*.jpg")) pages = [] for p in photos: img = Image.open(p).convert("RGB") img.thumbnail((1654, 2339)) # ~A4 at 200 DPI, plenty for receipts pages.append(img) pages[0].save( "site-week-41.pdf", save_all=True, append_images=pages[1:], resolution=200.0, ) Three details that matter more than the code:
- Sort order is the document. Phone filenames (
IMG_2041.jpg) sort fine until someone AirDrops from a second phone and the names interleave wrong. If order matters -- and for receipts it does -- rename by timestamp first (exiftool "-FileName<DateTimeOriginal"does it in one pass) or sort by modification time and eyeball the first and last page before sending. - Downscale before you bind. A 48MP phone photo at full size makes a 200MB PDF nobody can email. 200 DPI at A4-ish dimensions is more than legible for a receipt and cuts the file by an order of magnitude.
-
.convert("RGB")is not optional. One PNG with an alpha channel or one HEIC-converted CMYK JPEG will kill the whole save at page 37. Ask me how I know.
Direction 2: One PDF, many images
Extracting with PyMuPDF gets you the embedded images at their stored resolution, which is what you want for re-use:
import fitz # PyMuPDF doc = fitz.open("catalog.pdf") for page_no, page in enumerate(doc): for i, img in enumerate(page.get_images(full=True)): xref = img[0] pix = fitz.Pixmap(doc, xref) if pix.n > 4: # CMYK etc. pix = fitz.Pixmap(fitz.csRGB, pix) pix.save(f"p{page_no + 1:02d}_{i + 1}.png") Know what you're getting: this pulls the raw image objects. If the "image" on a page is actually a rendered chart or a scanned page assembled from tiles, you'll get tiles and charts, not the screenshot you were imagining. For a full-page render instead, page.get_pixmap(dpi=200) is the honest tool -- different job, different output.
Where I stop scripting
I keep the scripts for the Friday folder, because it arrives in the same shape every week. For the one-off jobs -- a client sends three PNGs and wants one PDF back now, or I need a quick batch conversion on a machine where I haven't set anything up -- I use the image batch tool on PDFLean in the browser instead of touching a terminal. Same operations, drag, drop, done. Files I process there without an account are deleted immediately after processing, which is the property I check first whenever a client's documents are involved; a free account raises the per-file limit to 50MB for the weeks the photos arrive huge.
The rule of thumb after a year of this: if the job repeats weekly in the same shape, script it once and never think again. If it happens twice a quarter, a batch tool in the browser is faster than finding the script, remembering its arguments, and updating it because Pillow changed something. Both directions live in my runbook now -- the expensive version of this task was always doing it one file at a time.

