Independent guide. Not affiliated with the pdfplumber project or its maintainers.Official repository: jsvine/pdfplumber ↗

Beyond text

Images and page renders

pdfplumber deals with images in two ways. page.images tells you where each embedded image is and gives you its data, and page.to_image() renders what the page looks like. Each has a default that quietly costs quality, and both are measured below on a sample made for the purpose.

Tested with pdfplumber 0.11.10 · Python 3.13Updated 2026-11-04

Short answer

page.images lists every image drawn on the page, with its position, the pixel size stored in the file and the stream that holds its data:

list_images.py
import pdfplumber

with pdfplumber.open("samples/images.pdf") as pdf:
    for page in pdf.pages:
        for img in page.images:
            w_px, h_px = img["srcsize"]                # pixels stored in the file
            dpi = w_px / (img["width"] / 72)           # pixels per inch as drawn on the page
            fmt = "+".join(f.name for f, _ in img["stream"].get_filters())
            print(f"p{page.page_number}  {w_px}x{h_px} px  drawn {img['width']:.0f}x{img['height']:.0f} pt"
                  f"  at x0={img['x0']:.0f} top={img['top']:.0f}  {dpi:.0f} dpi  {fmt}")
Outputcaptured · Python 3.13.18 · pdfplumber 0.11.10
p1  240x240 px  drawn 36x36 pt  at x0=54 top=42  480 dpi  FlateDecode
p1  900x600 px  drawn 324x216 pt  at x0=54 top=144  200 dpi  DCTDecode
p2  240x240 px  drawn 36x36 pt  at x0=54 top=42  480 dpi  FlateDecode
p2  64x40 px  drawn 288x180 pt  at x0=54 top=140  16 dpi  FlateDecode

The sample is images.pdf: a JPEG photo, a transparent PNG logo that appears on both pages, and a chart pasted at thumbnail size and stretched. All three were generated for this page.

What each image entry tells you

Images have the same position keys as every other object (x0, top, x1, bottom, in points; see objects). Three more keys are specific to images:

  • srcsize: the pixels stored in the PDF, (width, height).
  • width, height: the size it is drawn at, in points. Dividing pixels by inches drawn gives the effective resolution: the logo is 480 dpi, the photo 200 dpi and the chart 16 dpi, which is why the chart prints blocky.
  • stream: the pdfminer.six stream with the image data, plus bits, colorspace and imagemask taken from it.

The logo shows up on both pages with the same name. It is one object in the file, drawn twice. Deduplicate on img["stream"].objid when you extract, or you save a copy of every letterhead logo per page.

Save images in their original format

How you get a usable file depends on how the image is compressed, which stream.get_filters() tells you:

  • JPEG (DCTDecode): stream.get_data() is a complete .jpg file. Write the bytes as they are; re-encoding only loses quality.
  • Flate (FlateDecode, the usual compression for PNG-like images): get_data() returns raw pixels, not a file. Build a Pillow image from them, and attach the soft mask (SMask) as the alpha channel if there is one.
  • CCITT and JBIG2 (black-and-white scans): pdfminer.six’s ImageWriter decodes CCITT to a .bmp, and writes JBIG2 out as a .jb2 file without decoding it.

This function covers all three, and falls back to ImageWriter for everything it does not handle itself:

save_images.py
import io
from pathlib import Path

import pdfplumber
from pdfminer.image import ImageWriter
from pdfminer.layout import LTImage
from pdfminer.pdftypes import resolve1
from PIL import Image

def to_pil(stream):
    """The image as stored in the PDF, with its transparency, or None if unsupported."""
    filters = [f.name for f, _ in stream.get_filters()]
    if filters and filters[-1] == "DCTDecode":                 # JPEG: the bytes are a .jpg
        return Image.open(io.BytesIO(stream.get_data()))
    if filters and filters[-1] not in ("FlateDecode", "LZWDecode"):
        return None                                              # JPX, JBIG2, CCITT, ...
    size = (stream["Width"], stream["Height"])
    space = getattr(resolve1(stream.get("ColorSpace")), "name", None)
    mode = {("DeviceRGB", 8): "RGB", ("DeviceGray", 8): "L", ("DeviceGray", 1): "1"}.get(
        (space, stream.get("BitsPerComponent")))
    if mode is None:
        return None                                              # Indexed, ICCBased, CMYK, ...
    img = Image.frombytes(mode, size, stream.get_data())
    smask = resolve1(stream.get("SMask"))
    if smask is not None:                                        # soft mask = alpha channel
        img.putalpha(Image.frombytes("L", size, smask.get_data()))
    return img

def save_images(pdf_path, out):
    out = Path(out)
    out.mkdir(exist_ok=True)
    writer, seen, saved = ImageWriter(str(out)), set(), []
    with pdfplumber.open(pdf_path) as pdf:
        for page in pdf.pages:
            for i, img in enumerate(page.images):
                stream = img["stream"]
                if stream.objid in seen:          # same object drawn again (a logo on every page)
                    continue
                seen.add(stream.objid)
                name = f"page{page.page_number}-{i}"
                pil = to_pil(stream)
                if pil is None:                   # pdfminer decodes CCITT and JBIG2 itself
                    bbox = (img["x0"], img["top"], img["x1"], img["bottom"])
                    saved.append(writer.export_image(LTImage(name, stream, bbox)))
                elif pil.format == "JPEG":
                    (out / f"{name}.jpg").write_bytes(stream.get_data())  # original bytes
                    saved.append(f"{name}.jpg")
                else:
                    pil.save(out / f"{name}.png")
                    saved.append(f"{name}.png ({pil.mode})")
    return saved

print(save_images("samples/images.pdf", "extracted"))
scans = save_images("samples/real/federal-register-2020-17221.pdf", "extracted-fr")
print(len(scans), "images, e.g.", scans[0])
Outputcaptured · Python 3.13.18 · pdfplumber 0.11.10
['page1-0.png (RGBA)', 'page1-1.jpg', 'page2-1.png (RGB)']
11 images, e.g. page7-0.bmp

The logo keeps its transparent corners, and the JPEG is the exact embedded file. The second document is a real Federal Register notice: its 11 images are black-and-white scans at about 300 dpi compressed with CCITT, which ImageWriter decodes in pure Python. That decoding is most of the several seconds this example takes.

Those scans also contain text, which extract_text() cannot see because it is pixels. If a page’s words are inside images, you need OCR.

stream.rawdata is the still-compressed data. For anything except JPEG it is not an image file, which is the cause of “cannot identify image file” errors when people pass it to Pillow (#1207).

Render an image region instead

When you want the image as it appears (clipped, masked and scaled), or its format is one the function skips (Indexed, ICCBased or CMYK colour), crop the page to the image’s box and render that:

render_region.py
import pdfplumber

with pdfplumber.open("samples/images.pdf") as pdf:
    for page, n in [(pdf.pages[0], 1), (pdf.pages[1], 1)]:
        img = page.images[n]
        region = page.crop((img["x0"], img["top"], img["x1"], img["bottom"]))
        shot = region.to_image(resolution=200)
        shot.save(f"render-p{page.page_number}.png", quantize=False)
        print(f"page {page.page_number}: stored {img['srcsize']} px, rendered {shot.original.size} px")
Outputcaptured · Python 3.13.18 · pdfplumber 0.11.10
page 1: stored (900, 600) px, rendered (900, 600) px
page 2: stored (64, 40) px, rendered (800, 500) px

Rendering at 200 dpi gives the photo’s stored 900 × 600 pixels exactly. The chart becomes 800 × 500 pixels, but it still holds only the detail of its 64 × 40 original: rendering can enlarge an image, not add to it. Check srcsize before you promise anyone a sharp picture.

crop() does not make rendering cheaper. pdfplumber renders the whole page at the requested resolution and then cuts out the box, so rendering ten regions of one page means ten full-page renders. Render once and crop the Pillow image yourself when you need many regions.

Save a page as PNG

to_image() takes resolution (pixels per inch, 72 by default) or a target width or height in pixels, one of the three:

page_to_png.py
import os

import pdfplumber
from PIL import Image

with pdfplumber.open("samples/images.pdf") as pdf:
    page = pdf.pages[0]
    print(page.to_image(width=1000).original.size)      # or give a width in pixels
    im = page.to_image(resolution=150)                  # pixels per inch; the default is 72
    print(im.original.size)
    im.save("page-default.png")                         # quantize=True: 256-colour palette
    im.save("page-full.png", quantize=False)            # every colour kept

for name in ["page-default.png", "page-full.png"]:
    colours = len(Image.open(name).convert("RGB").getcolors(1 << 24))
    print(f"{name:17} {colours:>6} colours  {os.path.getsize(name) // 1024} KB")
Outputcaptured · Python 3.13.18 · pdfplumber 0.11.10
(1000, 1295)
(1275, 1651)
page-default.png     112 colours  24 KB
page-full.png       3993 colours  105 KB

The default save() reduced the page to 112 colours. pdfplumber quantizes to a palette of up to 256 colours unless you pass quantize=False, which keeps files small for debugging pictures of text and lines. On photos and gradients it bands:

Two zoomed renders of the sample photo's sky: the default save shows visible horizontal bands, quantize=False shows a smooth gradient
The same 150 dpi render saved both ways, enlarged 2×. Use quantize=False for anything you will look at, OCR or feed to a model.

Rendering has a second default to know about: text and lines are not antialiased. That keeps edges crisp for checking positions, but looks rough at low resolution:

The heading Ridge 2 tent rendered at 150 dpi, jagged by default and smooth with antialias=True
150 dpi, enlarged 2×. Pass antialias=True for images people will read.

A US Letter page at 300 dpi is 2550 × 3300 pixels, about 25 MB of RGB in memory. For OCR, 300 dpi is the usual choice; for previews and debugging, 100–150 dpi is plenty.

Use the render in your own code

PageImage.original is the Pillow image, without any debugging drawings (those go on annotated). From there it is one line to NumPy or to bytes in memory, with no temporary file:

pil_numpy_bytes.py
import io

import numpy as np
import pdfplumber

with pdfplumber.open("samples/images.pdf") as pdf:
    im = pdf.pages[0].to_image(resolution=100)

pil = im.original                 # a PIL.Image: the page as rendered, without drawings
arr = np.asarray(pil)             # for OpenCV, scikit-image, a model's preprocessing
buf = io.BytesIO()
im.save(buf, quantize=False)      # PNG bytes in memory, e.g. for an HTTP response
print(type(pil).__name__, pil.mode, arr.shape, len(buf.getvalue()), "bytes")
Outputcaptured · Python 3.13.18 · pdfplumber 0.11.10
Image RGB (1100, 850, 3) 66978 bytes

In Jupyter, a PageImage as the last expression of a cell displays itself. In a script, im.show() opens it in the system viewer. Visual debugging covers drawing objects and table finder results on top of it.

Errors about Wand, ImageMagick or Ghostscript

Before version 0.10.0, to_image() rendered pages with ImageMagick through the Wand package, which in turn needed Ghostscript. That produced a family of installation errors:

  • ImportError: MagickWand shared library not found (#81)
  • wand.exceptions.DelegateError (#104, #95)
  • MagickReadImage returns false, but did not raise ImageMagick exception (#144)

Since 0.10.0, rendering uses pypdfium2, a self-contained wheel that pip installs with pdfplumber. If you see any of these messages, the fix is to upgrade (python -m pip install -U pdfplumber), not to install ImageMagick. Old tutorials that tell you to apt install ghostscript imagemagick for pdfplumber are out of date.