Convert Images to a Single PDF with Python
An expense tool needs one PDF per claim, built from whatever the employee uploaded: eleven phone photos of receipts, a PNG screenshot of an online invoice, and a scanned hotel bill. The first version using Pillow's save_all produces a 58 MB file in which page 2 comes after page 10, three receipts are sideways, the screenshot page has a black background, and every page is a different size. Each of those is a separate, predictable failure.
from PIL import Image
images = [Image.open(p) for p in paths]
images[0].save("claim.pdf", save_all=True, append_images=images[1:])
# ValueError: cannot save mode RGBA (or a huge, messy PDF when it does work)
Root Cause
The naive approach inherits five independent problems from its inputs. File-name sorting is lexicographic, so IMG_10.jpg sorts before IMG_2.jpg. Phone cameras store pixels in sensor orientation and record the intended rotation in an EXIF Orientation tag, which Pillow does not apply on open. PNG screenshots carry an alpha channel; PDF pages built from RGBA images either fail or composite transparency onto black. Pillow's PDF writer re-encodes every image at its full resolution, so twelve-megapixel photos stay twelve megapixels and gain a second round of JPEG loss. And without a layout, each page takes the pixel size of its image at 72 DPI, so a phone photo becomes a 1.4-metre-wide page while a scan becomes A4. None of these is visible when testing with two neat PNGs.
Minimal Diagnostic
Print the properties that cause each failure for every input file, in the order the naive code would use.
# pip install pillow
from pathlib import Path
from PIL import Image, ExifTags
SOURCE = Path("in/claim-4471")
ORIENTATION = next(k for k, v in ExifTags.TAGS.items() if v == "Orientation")
EXTS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp"}
def diagnose(folder: Path) -> None:
files = sorted(p for p in folder.iterdir() if p.suffix.lower() in EXTS)
if not files:
raise SystemExit(f"no images in {folder}")
for index, path in enumerate(files, 1):
try:
with Image.open(path) as im:
orient = im.getexif().get(ORIENTATION, 1)
page_in = (im.width / 72, im.height / 72)
print(f"{index:>2}. {path.name:<16} {im.mode:<5} {im.width}x{im.height} "
f"orient={orient} alpha={'A' in im.getbands()} "
f"naive page {page_in[0]:.0f}x{page_in[1]:.0f} in, "
f"{path.stat().st_size / 1e6:.1f} MB")
except OSError as exc:
print(f"{index:>2}. {path.name}: cannot open ({exc})")
if __name__ == "__main__":
diagnose(SOURCE)
1. IMG_1.jpg RGB 4032x3024 orient=6 alpha=False naive page 56x42 in, 3.8 MB
2. IMG_10.jpg RGB 4032x3024 orient=1 alpha=False naive page 56x42 in, 3.6 MB
3. IMG_11.jpg RGB 4032x3024 orient=6 alpha=False naive page 56x42 in, 3.9 MB
4. IMG_2.jpg RGB 4032x3024 orient=6 alpha=False naive page 56x42 in, 3.7 MB
...
13. invoice.png RGBA 1440x2560 orient=1 alpha=True naive page 20x36 in, 1.1 MB
14. hotel-scan.tif L 2480x3508 orient=1 alpha=False naive page 34x49 in, 0.9 MB
Every failure is visible: order 1, 10, 11, 2; orientation 6 on several photos; an RGBA screenshot; page sizes measured in feet.
Fix: Sort, Normalise, Then Embed Losslessly
The fix handles each cause in turn and keeps the final embedding step lossless. Changed lines are commented.
# pip install img2pdf pillow
import re
from pathlib import Path
import img2pdf
from PIL import Image, ImageOps
SOURCE = Path("in/claim-4471")
DEST = Path("out/claim-4471.pdf")
WORK = Path("out/.work/claim-4471")
EXTS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp"}
MAX_EDGE = 2200 # ~270 dpi across an A4 page width
A4 = (img2pdf.mm_to_pt(210), img2pdf.mm_to_pt(297))
def natural_key(path: Path):
return [int(t) if t.isdigit() else t.lower() for t in re.split(r"(\d+)", path.name)] # changed
def normalise(src: Path, work: Path) -> Path:
with Image.open(src) as im:
im = ImageOps.exif_transpose(im) # changed: apply camera rotation
is_photo = src.suffix.lower() in {".jpg", ".jpeg", ".webp"}
if "A" in im.getbands() or im.mode == "P": # changed: flatten transparency
rgba = im.convert("RGBA")
flat = Image.new("RGB", rgba.size, "white")
flat.paste(rgba, mask=rgba.getchannel("A"))
im = flat
elif im.mode not in ("RGB", "L"):
im = im.convert("RGB") # changed: CMYK and friends
if max(im.size) > MAX_EDGE:
im.thumbnail((MAX_EDGE, MAX_EDGE), Image.Resampling.LANCZOS) # changed: drop unseen pixels
work.mkdir(parents=True, exist_ok=True)
if is_photo:
dest = work / f"{src.stem}.jpg"
im.save(dest, "JPEG", quality=85, optimize=True) # changed: photos stay JPEG
else:
dest = work / f"{src.stem}.png"
im.save(dest, "PNG", optimize=True) # changed: text stays sharp
return dest
def build_pdf(folder: Path, dest: Path) -> Path:
files = sorted((p for p in folder.iterdir() if p.suffix.lower() in EXTS), key=natural_key)
if not files:
raise FileNotFoundError(f"no images in {folder}")
prepared = []
for path in files:
try:
prepared.append(normalise(path, WORK))
except OSError as exc:
raise RuntimeError(f"cannot read {path.name}: {exc}") from exc
layout = img2pdf.get_layout_fun(A4, fit=img2pdf.FitMode.into) # changed: uniform A4 pages
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(img2pdf.convert([str(p) for p in prepared], layout_fun=layout)) # changed: lossless
return dest
if __name__ == "__main__":
out = build_pdf(SOURCE, DEST)
print(f"{out} {out.stat().st_size / 1e6:.1f} MB")
out/claim-4471.pdf 6.4 MB
The split between JPEG and PNG output matters: photos compress well as JPEG, but screenshots of invoices contain sharp text that JPEG smears. img2pdf then copies those bytes into the PDF as they are — no second encoding — which is why the output is roughly the size of the prepared images combined.
FitMode.into scales each image to fit entirely inside A4 while keeping its aspect ratio, leaving white margins on one axis. Landscape images are placed on portrait pages at a smaller scale; to rotate the page instead, use img2pdf.get_layout_fun(A4, fit=img2pdf.FitMode.into, auto_orient=True), which swaps page width and height for landscape images.
Variant Fix 1: Uploads in HEIC, WebP or Multi-Page TIFF
iPhones upload HEIC, browsers produce WebP, and scanners write multi-page TIFFs with several receipts in one file. Register the HEIF opener and expand TIFF frames into separate pages before normalising:
# pip install pillow pillow-heif
from pathlib import Path
from PIL import Image, ImageSequence
import pillow_heif
pillow_heif.register_heif_opener() # lets Image.open read .heic/.heif
def expand_frames(src: Path, work: Path) -> list[Path]:
"""Split multi-frame images (TIFF, animated WebP) into single-frame PNG files."""
work.mkdir(parents=True, exist_ok=True)
out = []
try:
with Image.open(src) as im:
frames = list(ImageSequence.Iterator(im))
if len(frames) == 1:
return [src]
for n, frame in enumerate(frames, 1):
dest = work / f"{src.stem}-f{n:02d}.png"
frame.copy().save(dest)
out.append(dest)
except OSError as exc:
raise RuntimeError(f"unsupported image {src.name}: {exc}") from exc
return out
Add .heic and .heif to the extension set, run expand_frames before normalise, and the rest of the pipeline is unchanged. Treat HEIC files as photos (JPEG output) in the format split.
Variant Fix 2: Every Page Must Show the File Name
Auditors reviewing expense claims often want each page labelled with the original upload name. img2pdf cannot draw text, so stamp labels onto the finished PDF with PyMuPDF, which adds a real text layer rather than burning text into pixels:
# pip install pymupdf
from pathlib import Path
import pymupdf
def label_pages(pdf_path: Path, labels: list[str]) -> None:
tmp = pdf_path.with_suffix(".labelled.pdf")
with pymupdf.open(pdf_path) as doc:
if len(labels) != doc.page_count:
raise ValueError(f"{len(labels)} labels for {doc.page_count} pages")
for page, label in zip(doc, labels):
box = pymupdf.Rect(20, page.rect.height - 24, page.rect.width - 20, page.rect.height - 8)
page.insert_textbox(box, f"{page.number + 1}/{doc.page_count} {label}",
fontsize=8, color=(0.28, 0.33, 0.41), align=pymupdf.TEXT_ALIGN_RIGHT)
doc.save(tmp, garbage=3, deflate=True)
tmp.replace(pdf_path)
Because FitMode.into leaves margins, the footer normally falls on white space. If images fill the page edge to edge, reduce the layout size slightly — for example A4 minus ten millimetres in each dimension — so the label never overlaps a receipt total. Stamping more complex content, such as a claim number watermark, follows add a text watermark to every PDF page.
Verification
Assert order, orientation, page size and a size budget against the prepared inputs. Orientation can be checked without looking at pixels: a portrait image fitted into a portrait page leaves horizontal margins, so compare aspect ratios.
# pip install pymupdf pillow
from pathlib import Path
import pymupdf
from PIL import Image
def verify_pdf(prepared: list[Path], pdf_path: Path, max_mb: float = 10.0) -> None:
size_mb = pdf_path.stat().st_size / 1e6
assert size_mb <= max_mb, f"{size_mb:.1f} MB exceeds {max_mb} MB"
with pymupdf.open(pdf_path) as doc:
assert doc.page_count == len(prepared), f"{doc.page_count} pages, {len(prepared)} images"
for page, img_path in zip(doc, prepared):
w_mm, h_mm = page.rect.width / 72 * 25.4, page.rect.height / 72 * 25.4
assert abs(w_mm - 210) < 1 and abs(h_mm - 297) < 1, f"page {page.number + 1} not A4"
placed = page.get_images(full=True)
assert len(placed) == 1, f"page {page.number + 1}: expected one image"
rect = page.get_image_rects(placed[0][0])[0]
with Image.open(img_path) as im:
img_ratio = im.width / im.height
placed_ratio = rect.width / rect.height
assert abs(img_ratio - placed_ratio) < 0.02, f"page {page.number + 1}: image distorted"
print(f"{pdf_path.name}: {len(prepared)} upright A4 pages, {size_mb:.1f} MB")
The ratio check catches both distortion and a lost rotation, because a transposed image has an inverted aspect ratio compared with its un-rotated original on disk. For the page-order check, compare the prepared file list with what the user saw in the upload interface — natural sort matches most people's expectations, but some tools number uploads by time; if yours do, sort by the upload timestamp from your database instead of by file name.
FAQ
Why not use Image.save(..., save_all=True) after normalising?
It works, but re-encodes every image a second time and cannot embed PNG screenshots losslessly next to JPEG photos as efficiently. img2pdf keeps the prepared bytes intact.
Can I keep the original resolution for legal evidence? Yes — skip the downsizing step and accept the larger file. Keep EXIF transpose and alpha flattening; they change presentation, not information.
How do I add OCR so the PDF is searchable?
Run OCRmyPDF on the finished file with --skip-text; see make scanned PDFs searchable with OCRmyPDF.
What about Word documents with photos? Convert them to PDF first and merge, as described in Converting DOCX to PDF with Python and batch merge PDFs with a Python script.
Related
- Converting PDFs to Images and Back with Python — both conversion directions and tool choice
- Reduce PDF File Size with Python — shrinking PDFs that were built with full-size images
- Fix Python-docx Image Rotated After Insert — the same EXIF orientation problem in Word output
- Process Invoice PDFs from Email into Excel — where uploaded receipts often end up