TLDR: There's no single "make PDF" command. Match the pipeline to the content shape — image-composed pages get Pillow + img2pdf, designed HTML docs get headless Chrome. One gotcha each will make or break you.

The Week I Built Two Completely Different PDFs

Same week, same day, actually — June 22nd.

PDF #1: a kids' room-cleanup checklist for my daughter, with gpt-image-1 (OpenAI's image generation model) cartoon art for every chore.

PDF #2: a real estate investment client vs. a competing real estate data provider head-to-head sales leave-behind for my client — a clean, branded, two-page report I needed to hand him on a call.

Same output format. Completely different pipelines. I learned the hard way they shouldn't share one.

Recipe 1: Image Generation → Pillow → img2pdf

My first instinct was "save from Pillow." Nope. reportlab, cairosvg, weasyprint — none of them were installed. You compose your page in Pillow (Python's PIL image library), you need a bridge to PDF.

That bridge is img2pdf — lossless, no JPEG recompression, no Chrome.

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

The one line that matters: get_fixed_dpi_layout_fun((300,300)).

Without it, img2pdf assumes ~96 dpi and your page comes out the wrong physical size. Letter at 300 dpi = 2550×3300 px — compose to that, pass the layout function, and it lands exactly 8.5×11 in.

One more thing: when you paste transparent gpt-image-1 cutouts onto the canvas, pass the image as its own mask or the alpha composites to black:

canvas.paste(im, (x, y), im)

The image-gen wrinkle: gpt-image-1 isn't callable in a plain Claude Code session. For the checklist assets nobody had drawn yet — crayons, dishes, a dollhouse — I hand-built them in pure PIL vector. Supersample at 2×, then LANCZOS-downscale. Thick black outline + flat fill. Passed the eye test right next to the real gpt-image-1 art.

Recipe 2: HTML → Headless Chrome CLI → PDF

For the real estate investment client report, I wanted design control — layout, typography, brand color. That lives in HTML/CSS, not in Pillow.

The tool is headless Chrome's built-in --print-to-pdf flag — no CDP websocket needed for a static multi-page doc:

"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
  --headless=new --disable-gpu --no-pdf-header-footer \
  --run-all-compositor-stages-before-draw --virtual-time-budget=8000 \
  --print-to-pdf=out.pdf "file:///abs/path/final.html"

Page size and margins come from CSS @page { size: Letter; margin: 1in; }. Page breaks from break-before: page. Add -webkit-print-color-adjust: exact so backgrounds actually print.

The one gotcha that will silently destroy you: do not link to Google Fonts. Embed them.

Headless Chrome renders and prints before CDN webfonts finish loading. It falls back to Times. Silently. And your "match the brand" requirement is gone without a warning.

Fix: base64-encode the .woff2 into a @font-face data URI. The font is baked into the HTML, and Chrome never needs to reach out.

Verify Before You Call It Done

Both recipes need the same sanity check:

pdfinfo out.pdf                      # page count + physical size
pdftoppm -png -r 120 out.pdf pg     # rasterize → eyeball each page

Open those PNGs. Confirm real fonts rendered (not Times), nothing overflows, the QR code (if you have one) is crisp. "The file exists" is not the same as "the file is right."

Why This Matters to Me

I used to think "generate a PDF" was a one-liner away. It's not — and chasing the wrong pipeline costs you an afternoon.

Now I ask first: is this an image-composed page or a designed document? Image layout → Pillow + img2pdf. HTML design → headless Chrome. Each one has exactly one load-bearing gotcha. Know it going in and the build is almost boring.

P.S. The generator script is the real asset. Re-render all pages from the source list. Don't hand-edit rasterized pages — you'll regret it the moment the page count changes.