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.

Five ways a naive image-to-PDF conversion goes wrong Five cards. Wrong page order, caused by lexicographic file name sorting and fixed with natural sort. Sideways receipts, caused by an ignored EXIF orientation tag and fixed with exif_transpose. Black backgrounds, caused by alpha channels and fixed by flattening onto white. Huge file, caused by full-resolution re-encoding and fixed by downsizing and img2pdf. Inconsistent page sizes, caused by pixel-based page dimensions and fixed with an A4 layout function. Wrong order IMG_10 sorts before IMG_2. Natural sort on digits. Sideways receipts EXIF Orientation ignored. ImageOps.exif_transpose. Black backgrounds RGBA screenshots. Flatten alpha onto white. 58 MB output Full-size re-encode. Downsize, then img2pdf. Pages in odd sizes Pixels at 72 dpi. Fit every image into A4.

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.

Normalisation order for each image Files are first sorted naturally so numbers sort by value. For each file, EXIF orientation is applied to the pixels, transparency is flattened onto white, unusual colour modes are converted to RGB, images larger than 2200 pixels are downsized, photos are saved as JPEG and screenshots as PNG. Finally img2pdf embeds all prepared files on A4 pages without recompression. Natural sort split names into text and numbers so IMG_2 comes before IMG_10 exif_transpose rotate pixels to match the camera tag, then drop the tag Flatten alpha paste RGBA onto a white RGB canvas using the alpha as mask Downsize cap the long edge at 2200 px; receipts stay legible JPEG or PNG photos as JPEG q85, screenshots and scans as PNG img2pdf A4 layout embed bytes unchanged, fit each image into the page

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.

Output size by method for the same 14 uploads Pillow save_all on the original full-resolution images produced 58.3 megabytes. img2pdf on the original files without normalisation produced 49.9 megabytes, close to the sum of the uploads, but with sideways pages and a failure on the transparent PNG. Normalised and downsized images embedded with img2pdf produced 6.4 megabytes with upright, uniform A4 pages. 14 uploads for one expense claim Pillow save_all (originals) 58.3 MB img2pdf on originals 49.9 MB Normalised + img2pdf 6.4 MB

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.

Part of Converting PDFs to Images and Back with Python.