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.
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.
| Consumer | DPI | A4 page size in pixels |
|---|---|---|
| Thumbnail gallery | 20–40 | 165×234 to 331×468 |
| On-screen preview | 96–110 | 794×1123 |
| OCR (Tesseract) | 300 | 2480×3508 |
| Print-quality raster | 300–600 | up 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.
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.
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 for a Web Gallery
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 symptom | Root cause | Fix |
|---|---|---|
PDFInfoNotInstalledError: Unable to get page count. Is poppler installed and in PATH? | pdf2image cannot find Poppler | Install poppler-utils or use PyMuPDF; see the Poppler fix |
img2pdf.AlphaChannelError: Refusing to work on images with alpha channel | PNG with transparency | Flatten onto white with Pillow first |
| Receipts sideways in the PDF | EXIF orientation ignored | ImageOps.exif_transpose before assembly |
| Black page backgrounds | Rendered with alpha=True | Render with alpha=False |
Worker killed, MemoryError | Too many high-DPI pages held in memory | Lower DPI, write per page, fewer workers |
| Colours look wrong after conversion | CMYK JPEG embedded or converted naively | Convert 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.
Related
- Fix pdf2image Poppler Not Installed — the most common first-run failure
- Convert Images to a Single PDF — ordering, orientation and page sizing in depth
- Render PDF Thumbnails with PyMuPDF — fixed-size previews for galleries and portals
- Scanning and OCR Processing with Python — what usually consumes rendered pages
- Compressing and Optimizing PDFs with Python — keeping image-built PDFs small