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.
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.
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.
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).
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.
mutool quietly drew some other page instead. A file that is not a PDF says so.#!/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()