pdf2img
Fabrice P. Lauss𝕪s inelike Web

pdf2img

pdf2img is a python script that takes the pictures out of one page of a PDF, each as a PNG of 2000 pixels on its longest side—or as many as one asks—and, when the page has no picture at all, the whole page instead. It sits with newfig, showme and ocrize in the small tackle of working with papers.

Written with Claude Opus 5 on 16 September (2026). Version 1.0.1.

The idea

There are two ways to get a picture out of a PDF, and the obvious one is usually the wrong one. pdfimages dumps the bitmaps the file embeds, exactly as they were put in: at whatever resolution the publisher chose (often a few hundred pixels), without the clipping that trims them on the page, and without everything the PDF draws over them—axis labels, arrows, panel letters—which in a scientific figure is half of it. What one wants is the figure as it is seen.

So pdf2img does not extract, it redraws. It finds where each picture sits on the page, then renders just that rectangle of the page again, at the resolution that makes it 2000 pixels on its longest side. What comes out is the picture as the page shows it, labels and all, and at the size asked for, whatever the file happened to embed. A figure whose bitmap is smaller than that is simply drawn larger; one whose bitmap is larger is not wasted either, -s 4000 asks for more.

A page with several pictures gives several files, numbered top to bottom and then left to right. Pieces that touch or overlap are one picture, because journals like to cut a figure into strips, and a figure should come out whole. Crumbs under half an inch on a side—a logo, a rule, the little icons of a header—are not pictures and are left out. And a page that is left with nothing, a figure drawn entirely in vectors or a page of plain text, is rendered whole, at the same 2000 pixels.

Usage

laussy@azag:~$ pdf2img bib/sci/choi17a.pdf -p 3
page 3: 2 images
  choi17a-p3-0AA7G-1.png       2000 × 1195 px
  choi17a-p3-0AA7G-2.png       2000 × 663 px
laussy@azag:~$ pdf2img bib/sci/choi17a.pdf -p 1 -s 1200
page 1: no image, the whole page
  choi17a-p1-0AA7G.png         927 × 1200 px
laussy@azag:~$ pdf2img bib/sci/dominici15a.pdf -p 3 -n
page 3: 9 images
  dominici15a-p3-0ArnY-1.png    111.7 × 82.9   pt at (110, 97)  →  2000 × 1484 px
  dominici15a-p3-0ArnY-2.png    111.6 × 83.2   pt at (241, 97)  →  2000 × 1490 px
  dominici15a-p3-0ArnY-3.png    111.6 × 126.5  pt at (368, 54)  →  1765 × 2000 px

  dominici15a-p3-0ArnY-9.png     99.5 × 92.0   pt at (375, 288)  →  2000 × 1850 px
pdf2img paper.pdf -p 3            # every picture of page 3, 2000 px on its longest side
pdf2img paper.pdf -p 3 -s 4000    # ... 4000 px
pdf2img paper.pdf -p 3 -o fig2    # fig2.png, or fig2-1.png, fig2-2.png, ... if several
pdf2img paper.pdf -p 3 -m 72      # ignore pictures under one inch
pdf2img paper.pdf -p 3 -n         # say what would be written, write nothing
Option What it does
-p, --page the page, counted from 1 (default 1)
-s, --size pixels on the longest side of each picture (default 2000)
-o, --output the name to write, with or without .png; a directory in it is created
-m, --min ignore pictures smaller than this many points on either side (default 36, half an inch)
-n, --dry-run list the pictures, their place on the page and the size they would come out at
-V, --version the version

Without -o, the files are named after the PDF, the page and the moment of the run, choi17a-p3-tag.png with an Anno Fabri tag, and written in the current directory: a second run never overwrites the first, and each extract has a name that can be pointed at.

How it finds the pictures

Two programs that come with any Linux distribution do the work. mutool trace, from MuPDF, replays the page as the list of its drawing calls; every picture is drawn there as a unit square put in place by a transform, which gives its rectangle on the page, already in page coordinates, with the page's crop box and rotation accounted for. The clips in force at that moment trim it, so a bitmap larger than what the page shows is cut to what it shows. Pictures inside a tiling pattern are the cell of a fill, not figures, and are skipped. pdftoppm, from Poppler, then renders each rectangle alone: it is given the resolution, the offset and the size in pixels, and draws only that slice, so a small figure at 3600 dpi does not cost a page rendered at 3600 dpi.

The two agree on where things are. Checked on a page as it is, on a copy of it with a crop box cutting into the page (the same pixels come out), and on a copy turned by 90° (the same figures come out, turned with the page).

Limitations

The crop is the picture's own rectangle, so a label that the PDF draws across its edge is cut where the picture ends: in a figure whose panel is a bitmap with the words Excitation and Polarizer sticking out on both sides, the words lose their first and last letters. The whole page, from a page with no picture or by cropping by hand, has them. And a figure drawn entirely in vectors is not recognised as a figure at all: there is nothing in the PDF to say where it begins, so the page comes out whole.

Version history

  • v°1.0.0 (16 September (2026)) — first version.
  • v°1.0.1 (16 September (2026)) — a page past the end is refused; mutool quietly drew some other page instead. A file that is not a PDF says so.

The file

#!/usr/bin/env python3
# pdf2img — v1.0.1 — the pictures of one PDF page, as high-resolution PNGs.
#
#   pdf2img paper.pdf -p 3          every image on page 3, 2000 px on its longest side
#   pdf2img paper.pdf -p 3 -s 4000  ... 4000 px
#
# Each image is RENDERED, not dumped: the part of the page it occupies is drawn
# again at whatever resolution makes it SIZE pixels on its longest side. So what
# comes out is the picture as the page shows it — cropped by its clip, with the
# axis labels or arrows the PDF draws on top of it — and at the size asked for,
# whatever resolution the file happened to embed it at.
#
# Several images on the page, several files, numbered top to bottom and left
# to right. Pieces that touch or overlap are one picture: a figure a journal
# cut into tiles comes out whole. Crumbs smaller than MIN points on a side (a
# logo, a rule, an icon) are ignored; a page left with no image at all — a
# vector figure, plain text — comes out as the whole page instead.
#
#   pdf2img paper.pdf -p 3 -o fig2     fig2.png (fig2-1.png, fig2-2.png, ... if several)
#   pdf2img paper.pdf -p 3 -m 72       ignore images under one inch
#   pdf2img paper.pdf -p 3 -n          say what would be written, write nothing
#
# Without -o the files are named after the PDF, the page and the moment:
# paper-p3-0AgZk.png, paper-p3-0AgZk-2.png, in the current directory, so that
# a second run never overwrites the first and each extract has a name that
# can be pointed at (AF tag, see ~/bin/AF).
#
# Needs mutool (MuPDF: where the images are) and pdftoppm (poppler: drawing
# them), both from the distribution's packages.
#
# F.P. Laussy & Claude (Opus 5), Wed Sep 16 2026
# v1.0.1 — a page past the end is refused (mutool drew some other page instead),
#          and a file that is not a PDF says so plainly.

import argparse
import os
import re
import shutil
import subprocess
import sys

VERSION = '1.0.1'

NUM = r'[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?'


def die(msg):
    sys.exit('pdf2img: ' + msg)


def attr(line, name):
    m = re.search(r'\b' + name + r'="([^"]*)"', line)
    return m.group(1) if m else None


def numbers(s):
    return [float(x) for x in re.findall(NUM, s or '')]


def apply(m, x, y):
    a, b, c, d, e, f = m
    return a * x + c * y + e, b * x + d * y + f


def bbox(points):
    xs = [p[0] for p in points]
    ys = [p[1] for p in points]
    return [min(xs), min(ys), max(xs), max(ys)]


def unit_square(m):
    return bbox([apply(m, u, v) for u in (0, 1) for v in (0, 1)])


def meet(r, s):
    if r is None:
        return s
    if s is None:
        return r
    out = [max(r[0], s[0]), max(r[1], s[1]), min(r[2], s[2]), min(r[3], s[3])]
    return out if out[0] < out[2] and out[1] < out[3] else None


# ---- where the images are ---------------------------------------------------
# mutool trace replays the page as a list of drawing calls, in page space
# (points, origin top left, the page's own rotation and crop box applied).
# An image is drawn as the unit square through its transform; what shows of
# it is what the clips in force at that moment leave. A clip path is given in
# user space with a transform of its own; a text clip has no useful outline
# and is taken to hide nothing. Images inside a tiling pattern are the cell of
# a fill, not pictures, and are skipped.
def placements(pdf, page):
    try:
        out = subprocess.run(['mutool', 'trace', pdf, str(page)],
                             capture_output=True, text=True, errors='replace')
    except FileNotFoundError:
        die('mutool not found (MuPDF tools)')
    if out.returncode != 0 or '<page' not in out.stdout:
        die('mutool could not read page %d of %s: %s' %
            (page, pdf, out.stderr.strip().splitlines()[-1] if out.stderr.strip() else '?'))

    page_box, clips, found = None, [], []
    path, path_m, tiles = None, None, 0

    for line in out.stdout.splitlines():
        s = line.strip()
        if s.startswith('<page '):
            box = numbers(attr(s, 'mediabox'))
            if len(box) == 4:
                page_box = [min(box[0], box[2]), min(box[1], box[3]),
                            max(box[0], box[2]), max(box[1], box[3])]
        elif s.startswith('<begin_tile'):
            tiles += 1
        elif s.startswith('<end_tile'):
            tiles = max(0, tiles - 1)
        elif s.startswith('<clip_path') or s.startswith('<clip_stroke_path'):
            path, path_m = [], numbers(attr(s, 'transform')) or [1, 0, 0, 1, 0, 0]
            if s.endswith('/>'):
                clips.append(clips[-1] if clips else page_box)
                path = None
        elif path is not None and (s.startswith('<moveto') or s.startswith('<lineto')
                                   or s.startswith('<curveto')):
            v = numbers(' '.join(re.findall(r'\b(?:x\d?|y\d?)="[^"]*"', s)))
            for i in range(0, len(v) - 1, 2):
                path.append(apply(path_m, v[i], v[i + 1]))
        elif path is not None and (s.startswith('</clip_path') or s.startswith('</clip_stroke_path')):
            top = clips[-1] if clips else page_box
            clips.append(meet(top, bbox(path)) if path else top)
            path = None
        elif s.startswith('<clip_text') or s.startswith('<clip_stroke_text'):
            clips.append(clips[-1] if clips else page_box)
        elif s.startswith('<clip_image_mask'):
            m = numbers(attr(s, 'transform'))
            top = clips[-1] if clips else page_box
            clips.append(meet(top, unit_square(m)) if len(m) == 6 else top)
        elif s.startswith('<pop_clip'):
            if clips:
                clips.pop()
        elif (s.startswith('<fill_image ') or s.startswith('<fill_image_mask ')) and not tiles:
            m = numbers(attr(s, 'transform'))
            if len(m) != 6:
                continue
            r = unit_square(m)
            if clips:
                r = meet(r, clips[-1])
            r = meet(r, page_box)
            if r:
                found.append(r)
    if page_box is None:
        die('no page box for page %d' % page)
    return page_box, found


# Pieces that touch or overlap, within a point, become one picture.
def merged(rects, slack=1.0):
    rects = [list(r) for r in rects]
    changed = True
    while changed:
        changed = False
        out = []
        while rects:
            r = rects.pop()
            i = 0
            while i < len(rects):
                q = rects[i]
                if (r[0] <= q[2] + slack and q[0] <= r[2] + slack and
                        r[1] <= q[3] + slack and q[1] <= r[3] + slack):
                    r = [min(r[0], q[0]), min(r[1], q[1]), max(r[2], q[2]), max(r[3], q[3])]
                    rects.pop(i)
                    changed = True
                else:
                    i += 1
            out.append(r)
        rects = out
    return rects


# Top to bottom, then left to right among those sharing a row.
def reading_order(rects):
    rects = sorted(rects, key=lambda r: (r[1], r[0]))
    rows, out = [], []
    for r in rects:
        if rows and r[1] < rows[-1][-1][3] - 0.5 * (rows[-1][-1][3] - rows[-1][-1][1]):
            rows[-1].append(r)
        else:
            rows.append([r])
    for row in rows:
        out.extend(sorted(row, key=lambda r: r[0]))
    return out


# ---- drawing ---------------------------------------------------------------
def render(pdf, page, page_box, r, size, target):
    w, h = r[2] - r[0], r[3] - r[1]
    dpi = size * 72.0 / max(w, h)
    k = dpi / 72.0
    x = int(round((r[0] - page_box[0]) * k))
    y = int(round((r[1] - page_box[1]) * k))
    W = size if w >= h else int(round(w * k))
    H = size if h > w else int(round(h * k))
    base = target[:-4] if target.lower().endswith('.png') else target
    cmd = ['pdftoppm', '-f', str(page), '-l', str(page), '-cropbox',
           '-r', '%.4f' % dpi, '-x', str(x), '-y', str(y), '-W', str(W), '-H', str(H),
           '-png', '-singlefile', pdf, base]
    res = subprocess.run(cmd, capture_output=True, text=True)
    if res.returncode != 0 or not os.path.exists(base + '.png'):
        die('pdftoppm failed on %s: %s' % (base + '.png', res.stderr.strip()))
    return W, H


def af_tag():
    for exe in ('af', 'AF', os.path.expanduser('~/bin/AF')):
        path = shutil.which(exe) or (exe if os.access(exe, os.X_OK) else None)
        if path:
            try:
                t = subprocess.run([path], capture_output=True, text=True, timeout=5).stdout.strip()
                if re.fullmatch(r'[0-9A-Za-z]{5,6}', t):
                    return t
            except (OSError, subprocess.SubprocessError):
                pass
    return None


def main():
    ap = argparse.ArgumentParser(
        prog='pdf2img',
        description='Extract the image(s) of one PDF page as high-resolution PNGs '
                    '(the whole page if it has none).')
    ap.add_argument('pdf')
    ap.add_argument('-p', '--page', type=int, default=1, help='page number, from 1 (default 1)')
    ap.add_argument('-s', '--size', type=int, default=2000,
                    help='pixels on the longest side of each image (default 2000)')
    ap.add_argument('-o', '--output', help='output name (default: <pdf>-p<page>-<AF tag>)')
    ap.add_argument('-m', '--min', type=float, default=36,
                    help='ignore images smaller than this many points on either side (default 36, half an inch)')
    ap.add_argument('-n', '--dry-run', action='store_true', help='list what would be written')
    ap.add_argument('-V', '--version', action='version', version='pdf2img ' + VERSION)
    a = ap.parse_args()

    if not os.path.isfile(a.pdf):
        die('no such file: ' + a.pdf)
    if a.page < 1:
        die('pages count from 1')
    if a.size < 16:
        die('size too small: %d' % a.size)
    if not shutil.which('pdftoppm'):
        die('pdftoppm not found (poppler-utils)')
    # mutool trace does not refuse a page past the end, it draws another one
    info = subprocess.run(['pdfinfo', a.pdf], capture_output=True, text=True, errors='replace')
    pages = re.search(r'^Pages:\s+(\d+)', info.stdout, re.M)
    if info.returncode != 0 or not pages:
        die('not a PDF, or not readable: ' + a.pdf)
    if a.page > int(pages.group(1)):
        die('%s has %s page%s' % (a.pdf, pages.group(1), '' if pages.group(1) == '1' else 's'))

    page_box, found = placements(a.pdf, a.page)
    rects = [r for r in merged(found)
             if r[2] - r[0] >= a.min and r[3] - r[1] >= a.min]
    whole = not rects
    rects = [page_box] if whole else reading_order(rects)

    if a.output:
        stem = a.output[:-4] if a.output.lower().endswith('.png') else a.output
    else:
        base = os.path.splitext(os.path.basename(a.pdf))[0]
        tag = af_tag()
        stem = '%s-p%d' % (base, a.page) + ('-' + tag if tag else '')

    names = [stem + '.png'] if len(rects) == 1 else \
        ['%s-%d.png' % (stem, i) for i in range(1, len(rects) + 1)]

    if whole:
        print('page %d: %s, the whole page' %
              (a.page, 'no image' if not found else 'no image of %g pt or more' % a.min))
    else:
        print('page %d: %d image%s' % (a.page, len(rects), '' if len(rects) == 1 else 's'))

    for r, name in zip(rects, names):
        w, h = r[2] - r[0], r[3] - r[1]
        if a.dry_run:
            k = a.size / max(w, h)
            print('  %-28s %6.1f × %-6.1f pt at (%.0f, %.0f)  →  %d × %d px' %
                  (name, w, h, r[0], r[1], round(w * k), round(h * k)))
            continue
        d = os.path.dirname(name)
        if d:
            os.makedirs(d, exist_ok=True)
        W, H = render(a.pdf, a.page, page_box, r, a.size, name)
        print('  %-28s %d × %d px' % (name, W, H))


if __name__ == '__main__':
    main()