Converting PDFs to Images and Back with Python

Two conversions sit underneath a surprising number of document workflows. PDF to image feeds OCR engines, machine-learning classifiers, thumbnail galleries, e-signature previews and systems that only accept image uploads. Image to PDF turns phone photos of receipts, scanner output and screenshots into a single document an accounts system or case file will accept. Both look like one-liners, and both break in production in ways that are hard to spot on a laptop: 600-DPI renders that exhaust a container's memory, receipts that come out sideways because the camera's orientation flag was ignored, black backgrounds where a PNG had transparency, colour shifts from CMYK JPEGs, and a 12-photo expense claim that becomes a 60 MB PDF.

Generic advice fails because each direction has several correct tools with different trade-offs. Rendering can go through MuPDF (in-process, fast) or Poppler (external binaries, used by pdf2image). Assembling PDFs can re-encode images (Pillow) or embed them losslessly byte-for-byte (img2pdf). This guide picks a default for each direction, explains when to switch, and covers the resolution, orientation, colour and memory details that decide whether the output is usable.

Prerequisites

python -m venv .venv && source .venv/bin/activate
pip install pymupdf img2pdf pillow
# only for the pdf2image variant (needs Poppler binaries)
pip install pdf2image
sudo apt-get install -y poppler-utils        # macOS: brew install poppler
mkdir -p in out

PyMuPDF is the default renderer: no external binaries, fast, and it exposes exact page geometry. pdf2image is common in existing code and in OCR tutorials; it shells out to Poppler's pdftoppm, which is why it fails with a missing-binary error on fresh servers — see fix pdf2image Poppler not installed. img2pdf builds PDFs from JPEG and PNG files without recompressing them, which keeps both quality and size under control.

Diagnostic: Inspect Pages and Images Before Converting

For PDF to image, the useful facts are page sizes (a mixed A4/A3 document renders to different pixel dimensions at the same DPI), rotation, and whether pages are scans with a native resolution you should match. For image to PDF, check orientation flags, alpha channels, colour mode and pixel size.

# pip install pymupdf pillow
from pathlib import Path
import pymupdf
from PIL import Image, ExifTags

PDF_SOURCE = Path("in/claim-pack.pdf")
IMAGE_DIR = Path("in/receipts")
ORIENTATION = next(k for k, v in ExifTags.TAGS.items() if v == "Orientation")

def inspect_pdf(pdf_path: Path) -> None:
    try:
        doc = pymupdf.open(pdf_path)
    except (pymupdf.FileDataError, RuntimeError) as exc:
        raise SystemExit(f"cannot open {pdf_path}: {exc}")
    with doc:
        for page in doc:
            w_in, h_in = page.rect.width / 72, page.rect.height / 72
            native = None
            for xref, _s, iw, ih, *_ in page.get_images(full=True):
                rects = page.get_image_rects(xref)
                if rects and rects[0].width > page.rect.width * 0.8:   # full-page image: a scan
                    native = round(iw / (rects[0].width / 72))
            print(f"page {page.number + 1}: {w_in:.2f}x{h_in:.2f} in, rotate={page.rotation}, "
                  f"scan dpi={native or '-'}")

def inspect_images(folder: Path) -> None:
    for path in sorted(folder.iterdir()):
        if path.suffix.lower() not in {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".heic"}:
            continue
        try:
            with Image.open(path) as im:
                exif = im.getexif()
                print(f"{path.name}: {im.size[0]}x{im.size[1]} mode={im.mode} "
                      f"exif_orientation={exif.get(ORIENTATION, 1)} "
                      f"alpha={'A' in im.getbands()} dpi={im.info.get('dpi')}")
        except OSError as exc:
            print(f"{path.name}: unreadable ({exc})")

if __name__ == "__main__":
    inspect_pdf(PDF_SOURCE)
    inspect_images(IMAGE_DIR)
page 1: 8.27x11.69 in, rotate=0, scan dpi=-
page 2: 8.27x11.69 in, rotate=90, scan dpi=300
IMG_2231.jpg: 4032x3024 mode=RGB exif_orientation=6 alpha=False dpi=(72, 72)
scan-004.png: 2480x3508 mode=RGBA exif_orientation=1 alpha=True dpi=(300, 300)

Page 2 is a 300-DPI scan stored with a 90-degree rotation; rendering it above 300 DPI adds pixels but no detail. The phone photo has EXIF orientation 6, meaning "rotate 90° clockwise to display" — ignore it and the receipt lands sideways in the PDF. The PNG has an alpha channel, which some tools refuse and others turn black.

Choosing a conversion path The root asks which direction the conversion goes. PDF to image uses PyMuPDF get_pixmap by default, or pdf2image when existing code or Poppler-specific rendering is required. Image to PDF uses img2pdf for JPEG and PNG inputs without alpha, preserving bytes, and Pillow when images need rotation fixes, alpha flattening or resizing first. Which direction? and what are the inputs like? PDF to image PyMuPDF in-process PDF to image, legacy pdf2image Poppler binaries images, clean img2pdf lossless embedding images, messy Pillow first rotate get_pixmap dpi match scan DPI convert_from_path paths_only one PDF no recompression then img2pdf clean inputs

Core Implementation: PDF to Images

Step 1: Pick the Resolution from the Consumer

Rendering DPI should come from what consumes the image, not from a habit of "300 to be safe". Pixel count grows with the square of DPI, so 300 DPI is four times the memory and roughly four times the time of 150 DPI.

ConsumerDPIA4 page size in pixels
Thumbnail gallery20–40165×234 to 331×468
On-screen preview96–110794×1123
OCR (Tesseract)3002480×3508
Print-quality raster300–600up to 4961×7016

For scanned pages, cap the rendering DPI at the scan's native resolution from the diagnostic — rendering a 200-DPI scan at 300 DPI only interpolates.

Step 2: Render Pages with PyMuPDF

# pip install pymupdf
from pathlib import Path
import pymupdf

def render_pages(pdf_path: Path, out_dir: Path, dpi: int = 150, fmt: str = "png",
                 gray: bool = False) -> list[Path]:
    """Render every page to an image file; returns the written paths."""
    out_dir.mkdir(parents=True, exist_ok=True)
    written = []
    try:
        doc = pymupdf.open(pdf_path)
    except (pymupdf.FileDataError, RuntimeError) as exc:
        raise RuntimeError(f"cannot open {pdf_path}: {exc}") from exc
    with doc:
        if doc.needs_pass:
            raise PermissionError(f"{pdf_path} is encrypted")
        for page in doc:
            pix = page.get_pixmap(
                dpi=dpi,
                colorspace=pymupdf.csGRAY if gray else pymupdf.csRGB,
                alpha=False,                        # white background, no transparency
                annots=True,                        # include stamps and form field appearances
            )
            dest = out_dir / f"{pdf_path.stem}-p{page.number + 1:03d}.{fmt}"
            if fmt in ("jpg", "jpeg"):
                pix.save(dest, jpg_quality=85)
            else:
                pix.save(dest)
            written.append(dest)
    return written

get_pixmap applies the page's /Rotate value, so rotated scans come out upright without extra work. alpha=False gives an opaque white background; with alpha=True, pages without a painted background render transparent, which looks black in many viewers. Grayscale output is a third of the size of RGB and is what OCR wants anyway.

Step 3: Write Multi-Page TIFF When a System Demands It

Document management and fax systems often want one multi-page TIFF rather than a folder of PNGs. Pillow writes it from PyMuPDF pixmaps without temporary files:

# pip install pymupdf pillow
from pathlib import Path
import pymupdf
from PIL import Image

def pdf_to_tiff(pdf_path: Path, dest: Path, dpi: int = 200) -> Path:
    frames = []
    with pymupdf.open(pdf_path) as doc:
        for page in doc:
            pix = page.get_pixmap(dpi=dpi, colorspace=pymupdf.csGRAY)
            frames.append(Image.frombytes("L", (pix.width, pix.height), pix.samples))
    if not frames:
        raise ValueError(f"{pdf_path} has no pages")
    bitonal = [f.point(lambda v: 255 if v > 160 else 0, mode="1") for f in frames]
    bitonal[0].save(dest, save_all=True, append_images=bitonal[1:],
                    compression="group4", dpi=(dpi, dpi))
    return dest

CCITT Group 4 compression on 1-bit frames is what fax-derived systems expect, and it keeps a 20-page document well under a megabyte. For colour or grayscale TIFFs use compression="tiff_lzw" and skip the bitonal step.

Render time and memory by DPI Rendering one A4 page with PyMuPDF took about 9 milliseconds at 72 DPI, 34 milliseconds at 150 DPI, 128 milliseconds at 300 DPI and 510 milliseconds at 600 DPI. Uncompressed RGB memory grows from 1.8 megabytes to 105 megabytes over the same range, so doubling DPI roughly quadruples both. One A4 page, RGB, PyMuPDF on one core 72 dpi (1.8 MB raw) 9 ms 150 dpi (7.8 MB raw) 34 ms 300 dpi (26 MB raw) 128 ms 600 dpi (105 MB raw) 510 ms Pick the lowest DPI the consumer needs; render time and memory scale with DPI squared

Core Implementation: Images to PDF

Step 4: Normalise Images with Pillow

Clean inputs before assembling: apply EXIF orientation physically, flatten transparency onto white, convert CMYK and palette images to RGB, and downsize photos that exceed what a page can show.

# pip install pillow
from pathlib import Path
from PIL import Image, ImageOps

MAX_EDGE = 2400                      # ~290 dpi across an A4 width; plenty for receipts

def normalise(src: Path, work_dir: Path) -> Path:
    work_dir.mkdir(parents=True, exist_ok=True)
    try:
        with Image.open(src) as im:
            im = ImageOps.exif_transpose(im)             # bake orientation into pixels
            if "A" in im.getbands() or im.mode == "P":
                background = Image.new("RGB", im.size, "white")
                rgba = im.convert("RGBA")
                background.paste(rgba, mask=rgba.getchannel("A"))
                im = background                          # transparency flattened onto white
            elif im.mode not in ("RGB", "L"):
                im = im.convert("RGB")                   # CMYK, LAB, etc.
            if max(im.size) > MAX_EDGE:
                im.thumbnail((MAX_EDGE, MAX_EDGE), Image.Resampling.LANCZOS)
            dest = work_dir / (src.stem + ".jpg")
            im.save(dest, "JPEG", quality=85, optimize=True)
            return dest
    except OSError as exc:
        raise RuntimeError(f"cannot read image {src}: {exc}") from exc

ImageOps.exif_transpose is the single most important line for phone photos. After it runs, the pixels are upright and the orientation tag is removed, so no downstream tool can apply the rotation a second time. Screenshots and line art should stay PNG — JPEG blurs text edges — so branch on source type if your inputs mix both.

Step 5: Assemble with img2pdf

# pip install img2pdf
from pathlib import Path
import img2pdf

A4 = (img2pdf.mm_to_pt(210), img2pdf.mm_to_pt(297))

def images_to_pdf(images: list[Path], dest: Path, fit_a4: bool = True) -> Path:
    if not images:
        raise ValueError("no images to convert")
    layout = img2pdf.get_layout_fun(A4, fit=img2pdf.FitMode.into) if fit_a4 else None
    dest.parent.mkdir(parents=True, exist_ok=True)
    try:
        with dest.open("wb") as fh:
            fh.write(img2pdf.convert([str(p) for p in images], layout_fun=layout))
    except img2pdf.AlphaChannelError as exc:
        raise RuntimeError("an image still has transparency; normalise it first") from exc
    return dest

img2pdf copies JPEG bytes straight into the PDF, so there is no second generation of compression loss and the PDF is barely larger than the images themselves. The A4 layout function scales each image to fit inside the page with its aspect ratio intact, which gives consistent page sizes regardless of the camera — accounts systems and printers both prefer that. The focused walkthrough, including sorting and per-page sizing, is convert images to a single PDF.

Receipt photos to one PDF Five steps. EXIF transpose bakes the camera orientation into the pixels. Transparency is flattened onto a white background. CMYK and palette modes are converted to RGB. Photos larger than 2400 pixels on the long edge are downsized. img2pdf embeds the resulting JPEG bytes on A4 pages without recompression. A branch under the first step shows the sideways receipt that results when orientation is ignored. exif_transpose bake rotation Flatten alpha onto white Colour mode CMYK to RGB Downsize 2400 px max img2pdf fit into A4 Skipped receipt lands sideways

Edge Cases and Variants

Existing pdf2image Code

Many OCR pipelines already use pdf2image. Keep it if Poppler is installed everywhere, but avoid its default of loading every page into memory at once:

# pip install pdf2image
from pathlib import Path
from pdf2image import convert_from_path, pdfinfo_from_path
from pdf2image.exceptions import PDFInfoNotInstalledError

def render_with_poppler(pdf_path: Path, out_dir: Path, dpi: int = 300, batch: int = 10) -> list[str]:
    out_dir.mkdir(parents=True, exist_ok=True)
    try:
        pages = pdfinfo_from_path(pdf_path)["Pages"]
    except PDFInfoNotInstalledError:
        raise SystemExit("Poppler is missing: install poppler-utils or pass poppler_path")
    paths: list[str] = []
    for first in range(1, pages + 1, batch):
        paths += convert_from_path(
            pdf_path, dpi=dpi, first_page=first, last_page=min(first + batch - 1, pages),
            output_folder=out_dir, fmt="png", paths_only=True, thread_count=2,
        )
    return paths

output_folder plus paths_only=True writes images to disk and returns paths instead of holding PIL images in memory, and batching bounds memory for thousand-page files.

Thumbnails need small, consistent sizes rather than a DPI. Scale each page so its longest edge hits a pixel target, as covered in render PDF thumbnails with PyMuPDF.

Rasterising to Flatten a Document

Sometimes rendering and re-assembling is the goal: a recipient system that cannot handle forms, layers or odd fonts. Render at 200 DPI, then rebuild with img2pdf. Text becomes unsearchable, so add an OCR layer if people need to search, and never use this as a redaction method without the content-level approach in Redacting Sensitive Data in PDFs.

Validation

For PDF to image, assert page count and pixel dimensions match the page geometry at the chosen DPI, and that no page rendered blank. For image to PDF, assert one page per image, correct orientation (portrait receipts should produce portrait pages), and a sensible size.

# pip install pymupdf pillow
from pathlib import Path
import pymupdf
from PIL import Image, ImageStat

def verify_renders(pdf_path: Path, images: list[Path], dpi: int) -> None:
    with pymupdf.open(pdf_path) as doc:
        assert len(images) == doc.page_count, f"{len(images)} images for {doc.page_count} pages"
        for page, img_path in zip(doc, images):
            expected = (round(page.rect.width * dpi / 72), round(page.rect.height * dpi / 72))
            with Image.open(img_path) as im:
                assert abs(im.size[0] - expected[0]) <= 2 and abs(im.size[1] - expected[1]) <= 2, \
                    f"{img_path.name}: {im.size} != {expected}"
                extrema = ImageStat.Stat(im.convert("L")).extrema[0]
                assert extrema[0] < 250, f"{img_path.name}: page rendered blank"

def verify_assembled(images: list[Path], pdf_path: Path) -> None:
    with pymupdf.open(pdf_path) as doc:
        assert doc.page_count == len(images), "page count differs from image count"
        for page, img_path in zip(doc, images):
            with Image.open(img_path) as im:
                portrait_img = im.height >= im.width
            portrait_page = page.rect.height >= page.rect.width
            if abs(im.height - im.width) > 0.1 * max(im.size):    # ignore near-square images
                assert portrait_img == portrait_page, f"page {page.number + 1}: orientation flipped"
    print(f"{pdf_path.name}: {len(images)} pages verified")

The blank-page check uses the darkest pixel value: a real page has some ink darker than 250, while a failed render — wrong colour space, transparent background on a dark theme — is uniformly light or uniformly dark. The dimension check allows two pixels of rounding difference.

Performance and Scale Notes

Rendering cost scales with pixels, so DPI is the main lever; page content (vector-heavy maps, huge embedded images) is the second. PyMuPDF renders several pages per second at 300 DPI on one core; use a process pool with one document per worker for batches, and write each image as soon as it is rendered rather than collecting a list of pixmaps. Memory for one raw 300-DPI A4 RGB page is about 26 MB, so ten workers rendering A3 pages at 600 DPI need several gigabytes — size the pool to available memory, not only cores. For images to PDF, img2pdf is nearly free once inputs are normalised; the Pillow normalisation step dominates, and caching normalised JPEGs by source hash avoids repeating it when a claim is resubmitted with one extra receipt.

Troubleshooting

Error or symptomRoot causeFix
PDFInfoNotInstalledError: Unable to get page count. Is poppler installed and in PATH?pdf2image cannot find PopplerInstall poppler-utils or use PyMuPDF; see the Poppler fix
img2pdf.AlphaChannelError: Refusing to work on images with alpha channelPNG with transparencyFlatten onto white with Pillow first
Receipts sideways in the PDFEXIF orientation ignoredImageOps.exif_transpose before assembly
Black page backgroundsRendered with alpha=TrueRender with alpha=False
Worker killed, MemoryErrorToo many high-DPI pages held in memoryLower DPI, write per page, fewer workers
Colours look wrong after conversionCMYK JPEG embedded or converted naivelyConvert to RGB with Pillow; check for ICC profiles

Complete Working Script

#!/usr/bin/env python3
# pip install pymupdf img2pdf pillow
"""pdf2img: render PDF pages to PNG.  img2pdf: combine images into one A4 PDF."""
import argparse
import sys
from pathlib import Path

import img2pdf
import pymupdf
from PIL import Image, ImageOps

IMAGE_EXT = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp"}


def cmd_pdf2img(args) -> int:
    args.out.mkdir(parents=True, exist_ok=True)
    with pymupdf.open(args.src) as doc:
        for page in doc:
            pix = page.get_pixmap(dpi=args.dpi, alpha=False,
                                  colorspace=pymupdf.csGRAY if args.gray else pymupdf.csRGB)
            pix.save(args.out / f"{args.src.stem}-p{page.number + 1:03d}.png")
        print(f"{doc.page_count} page(s) rendered at {args.dpi} dpi")
    return 0


def prepare(path: Path, work: Path) -> Path:
    with Image.open(path) as im:
        im = ImageOps.exif_transpose(im)
        if "A" in im.getbands() or im.mode == "P":
            rgba = im.convert("RGBA")
            flat = Image.new("RGB", im.size, "white")
            flat.paste(rgba, mask=rgba.getchannel("A"))
            im = flat
        elif im.mode not in ("RGB", "L"):
            im = im.convert("RGB")
        im.thumbnail((2400, 2400), Image.Resampling.LANCZOS)
        dest = work / f"{path.stem}.jpg"
        im.save(dest, "JPEG", quality=85, optimize=True)
    return dest


def cmd_img2pdf(args) -> int:
    images = sorted(p for p in args.src.iterdir() if p.suffix.lower() in IMAGE_EXT)
    if not images:
        print("no images found", file=sys.stderr)
        return 1
    work = args.out.parent / ".img2pdf-work"
    work.mkdir(parents=True, exist_ok=True)
    prepared = []
    for img in images:
        try:
            prepared.append(prepare(img, work))
        except OSError as exc:
            print(f"skipping {img.name}: {exc}", file=sys.stderr)
    layout = img2pdf.get_layout_fun((img2pdf.mm_to_pt(210), img2pdf.mm_to_pt(297)),
                                    fit=img2pdf.FitMode.into)
    args.out.write_bytes(img2pdf.convert([str(p) for p in prepared], layout_fun=layout))
    print(f"{len(prepared)} image(s) -> {args.out} ({args.out.stat().st_size / 1e6:.1f} MB)")
    return 0


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__)
    sub = ap.add_subparsers(dest="cmd", required=True)
    a = sub.add_parser("pdf2img")
    a.add_argument("src", type=Path)
    a.add_argument("out", type=Path)
    a.add_argument("--dpi", type=int, default=150)
    a.add_argument("--gray", action="store_true")
    b = sub.add_parser("img2pdf")
    b.add_argument("src", type=Path, help="folder of images")
    b.add_argument("out", type=Path, help="output PDF")
    args = ap.parse_args()
    try:
        return cmd_pdf2img(args) if args.cmd == "pdf2img" else cmd_img2pdf(args)
    except (RuntimeError, OSError, pymupdf.FileDataError) as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    sys.exit(main())

Frequently Asked Questions

PyMuPDF or pdf2image? PyMuPDF for new code: no external binaries and faster. Keep pdf2image when Poppler's rendering matches a downstream expectation, or when licensing rules out MuPDF's AGPL terms for your distribution model.

What DPI does Tesseract need? Around 300 DPI for normal body text. Higher rarely helps; below 200 DPI accuracy drops quickly for small print. See improve OCR accuracy with image preprocessing.

Can I convert HEIC photos from iPhones? Pillow needs the pillow-heif plugin: pip install pillow-heif, then call pillow_heif.register_heif_opener() before opening files. After that the normalisation step handles them like JPEGs.

How do I render only part of a page, such as a signature box? Pass clip=pymupdf.Rect(x0, y0, x1, y1) to get_pixmap, with coordinates in points from the top-left corner. Combine it with a high DPI to get a sharp crop without rendering the whole page at that resolution, which is far cheaper than rendering everything and cropping afterwards with Pillow.

Why do vector charts look jagged in my renders? Anti-aliasing is on by default but can be reduced by a global setting left over from other code. Call pymupdf.TOOLS.set_aa_level(8) before rendering to restore full anti-aliasing for both text and graphics.

Why is my image-to-PDF output huge with Pillow's save_all? Pillow re-encodes every image, often at high quality and full resolution. Downsize first and use img2pdf, which embeds the JPEG bytes as they are.

Part of Automating PDF Extraction & Generation.

Explore next