DEV Community

Marcus Hale
Marcus Hale

Posted on

Batch-Converting 60 Receipt Photos into One PDF (and Back Again)

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,
)
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)