TLDR: Pillow's multipage PDF save crashes if your Python build has no JPEG codec. img2pdf + get_fixed_dpi_layout_fun is the fix. And every build that hits a real wall should produce a reference document, not just a solved problem.

The Thing I Was Actually Building

My kids needed a chore chart.

Not a boring one — a real one. A printable booklet where each page shows the job in cute flat-cartoon pictures: Stuffies → Bed, Crayons → Basket, Yoto Cards → Case. Big star to color when the job's done. Cover counts the jobs. US Letter, punch a couple holes, done.

I generated the art with gpt-image-1 (OpenAI's image model — great for clean flat illustrations with transparent backgrounds). Composed each 2550×3300 px page with Pillow. Seemed straightforward…

It wasn't.

The Wall (And Everything That Failed)

pages[0].save("out.pdf", save_all=True, append_images=pages[1:])

Exception. No JPEG codec.

My Python 3.13 framework build's Pillow ships without it. The "obvious" multipage PDF save just dies. No fallback — it crashes.

I looked at reportlab. Not installed. WeasyPrint, cairosvg — also not installed, and pulling in C dependencies for a personal printable felt absurd. Chrome headless was overkill for what's essentially a stack of composed PNGs.

The Fix

img2pdf. Clean install, no native deps, losslessly embeds PNGs as FlateDecode.

import img2pdf

pngs = [f"pages/page_{i}.png" for i in range(13)]
layout = img2pdf.get_fixed_dpi_layout_fun((300, 300))
open("chore-chart.pdf", "wb").write(img2pdf.convert(pngs, layout_fun=layout))

get_fixed_dpi_layout_fun((300, 300)) is the key line. Without it, img2pdf assumes ~96dpi and the page comes out the wrong physical size. At 300dpi, 2550×3300 px maps exactly to US Letter 8.5×11 in.

When There's No Image Model Available

When I went back to add five more jobs, I was in a session where gpt-image-1 isn't callable.

So Apollo (my AI agent) hand-drew the missing assets in pure PIL vector — thick black outline, flat fill, white background. Sampled colors and geometry from the existing PNGs with numpy to match the style. Supersample at 2×, then LANCZOS-downscale for smooth edges.

The crayons, the sink, the dollhouse — they PASSED. You'd have to squint hard to tell.

The Generator That Changed Everything

The bigger lesson wasn't the format at all.

When I started, the chore chart project directory was just loose PNGs and a PDF. No generator. Adding a job meant manual page editing — a nightmare.

Apollo built generate.py + draw_assets.py. The JOBS list holds (obj_png, dst_png, left_label, right_label). It re-renders all pages, patches the cover's "N jobs" footer, and rebuilds the full PDF. Adding a job is one line. python3 generate.py and you're done.

I encoded every one of these learnings into a reference document on converting images to PDF with Pillow, saved to Apollo's memory — so the next time I have an "images → print PDF" task, I pull the ref and I'm done in five minutes. Not rediscovering the JPEG codec crash.

Every build that hits a real wall should produce two things: the thing itself, and the document that explains how you got unstuck.

The second one is worth more.