handful
Fabrice P. Lauss𝕪’s ℤillionth Web

handful

handful is a python script that takes a few pages of a PDF and fans them out like a hand of playing cards, on a transparent background, for a slide, a poster or a web page that wants to say "here is the paper" without showing a single page flat. It sits with pdf2img, newfig and showme in the small tackle of working with papers.

Written with Claude Opus 5.5 on 26 September (2026). Version 1.0.0.

Seven pages of the review Conventional and Unconventional Photon Statistics (Laser Photonics Rev. 2020), in a hand: handful zubizarretacasalengua20a.pdf -p 16,10,17,12,20,19,1.

The idea

A paper shown as one page is a wall of text; a paper shown as a hand of pages is a paper. The cards turn about a common point well below them, as a hand holds them, each one laid on top of the one before, so that the last is seen whole and the others only by their left edge. That is where the choice of pages matters: the front card is the title page, and the ones behind are pages whose left column or full width carries a figure, alternating colours, so that every visible strip shows something. A page of text behind the others shows nothing but text.

Each card has rounded corners and a thin grey edge, so that a white page stays a page on a white slide, and casts a soft shadow onto the cards below it. The background is transparent, shadows included, so the picture can go on any colour. The whole fan is drawn at 1.4 times its final size and then scaled down, which smooths the edges of the turned pages.

Usage

laussy@azag:~$ handful bib/sci/zubizarretacasalengua20a.pdf --contact -o zc.png
zc.png
laussy@azag:~$ handful bib/sci/zubizarretacasalengua20a.pdf -p 16,10,17,12,20,19,1 -o zfan.png
zfan.png 2658x1451
handful paper.pdf --contact                    # every page, small and numbered, to choose from
handful paper.pdf -p 16,10,17,12,20,19,1       # back card first, front card last
handful paper.pdf -p 16,10,17,12,20,19,1 -s 60 # a wider fan
handful paper.pdf                              # the first seven pages, page 1 in front
Option What it does
-p, --pages the pages, from the back of the hand to the front; the last one is seen whole (default: the first seven, page 1 in front)
-s, --spread the angle between the first and the last card, in degrees (default 52)
-o, --out the file to write
-w, --card-width the width of a card in pixels before the final scaling (default 1100, which gives a fan some 2700 pixels wide)
-z, --shrink the final scaling down, for smooth edges (default 1.4)
--contact write a sheet of all the pages, eight to a row and numbered, and stop
-V, --version the version

Without -o, the fan is written next to the PDF as paper-fan-tag.png, and the contact sheet as paper-contact-tag.png, with an Anno Fabri tag, so that a second run never overwrites the first.

How it is done

pdftoppm, from Poppler, renders each page at 220 dpi, and Pillow does the rest. Each page is cut to a rounded rectangle and pasted on a large transparent canvas with its centre straight above the pivot, at 1.55 card heights; the whole layer is then turned about the pivot, so the cards fan out from one point as they would in a hand. Before each card is laid down, its own outline, blurred and at 42 % opacity, is laid down a little lower and to the right: that is the shadow, and since it is laid on what is already there, it darkens the cards beneath and not the one casting it. The canvas is finally cropped to what is drawn, plus a margin for the shadows.

Limitations

The pages are not chosen for you: that takes looking at them, which is what --contact is for. Pages of different sizes make cards of different heights, turning about the same point. And the fan is a picture of the pages at a moderate resolution: the text of the front card is legible, but a page shown for its text should be shown flat.

Version history

The file

#!/usr/bin/env python3
"""handful — fan of PDF pages held like a hand of cards, on a transparent background.

  handful paper.pdf -p 20,14,22,18,16,23,1 [-s 52] [-o out.png] [-w 1100] [-z 1.4]
  handful paper.pdf --contact            # labelled contact sheet, to choose pages

Pages are listed back to front: the LAST one is the front card (fully visible);
the others show only their LEFT strip, so pick pages whose left column has figures.
Default output: <pdf>-fan-<AF tag>.png next to the PDF.
"""
__version__ = '1.0.0'

import argparse, math, os, subprocess, sys, tempfile
from PIL import Image, ImageDraw, ImageFilter

ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('pdf')
ap.add_argument('-p', '--pages', help='comma list, back card first, front card last (default: up to 7 pages, page 1 in front)')
ap.add_argument('-s', '--spread', type=float, default=52, help='total fan angle in degrees (default 52)')
ap.add_argument('-o', '--out')
ap.add_argument('-w', '--card-width', type=int, default=1100, help='card width in px at working scale (default 1100)')
ap.add_argument('-z', '--shrink', type=float, default=1.4, help='final downscale for antialiasing (default 1.4)')
ap.add_argument('--contact', action='store_true', help='write a labelled contact sheet of all pages and exit')
ap.add_argument('-V', '--version', action='version', version=__version__)
a = ap.parse_args()

pdf = os.path.abspath(a.pdf)
npages = int(next(l.split()[1] for l in subprocess.run(['pdfinfo', pdf], capture_output=True, text=True).stdout.splitlines() if l.startswith('Pages:')))
base = os.path.splitext(pdf)[0]
tag = subprocess.run(['af'], capture_output=True, text=True).stdout.strip()
tmp = tempfile.mkdtemp(prefix='handful-')

def render(p, dpi):
    stem = os.path.join(tmp, f'r{dpi}-{p}')
    subprocess.run(['pdftoppm', '-r', str(dpi), '-f', str(p), '-l', str(p), '-singlefile', '-png', pdf, stem], check=True)
    return Image.open(stem + '.png').convert('RGB')

if a.contact:
    thumbs = []
    for p in range(1, npages + 1):
        im = render(p, 40)
        im = im.resize((200, round(200 * im.height / im.width)), Image.LANCZOS)
        t = Image.new('RGB', (206, im.height + 30), 'yellow')
        t.paste(im, (3, 27)); ImageDraw.Draw(t).text((8, 6), f'page {p}', fill='black')
        thumbs.append(t)
    cols = 8; rows = math.ceil(len(thumbs) / cols); th = max(t.height for t in thumbs)
    sheet = Image.new('RGB', (cols * 206, rows * th), 'white')
    for i, t in enumerate(thumbs):
        sheet.paste(t, ((i % cols) * 206, (i // cols) * th))
    out = a.out or f'{base}-contact-{tag}.png'
    sheet.save(out); print(out); sys.exit()

pages = [int(x) for x in a.pages.split(',')] if a.pages else list(range(min(npages, 7), 0, -1))
bad = [p for p in pages if not 1 <= p <= npages]
if bad: sys.exit(f'handful: no page {bad} (PDF has {npages})')

W = a.card_width
cards = []
for p in pages:
    im = render(p, 220)
    H = round(W * im.height / im.width)
    im = im.resize((W, H), Image.LANCZOS)
    r = 18
    mask = Image.new('L', (W, H), 0)
    ImageDraw.Draw(mask).rounded_rectangle([0, 0, W-1, H-1], r, fill=255)
    card = im.convert('RGBA'); card.putalpha(mask)
    ImageDraw.Draw(card).rounded_rectangle([0, 0, W-1, H-1], r, outline=(170, 170, 170, 255), width=3)
    cards.append(card)

H = max(c.height for c in cards)
R = 1.55 * H                                  # pivot distance below the card centre
n = len(cards); spread = a.spread
angles = [(-spread/2 + spread*i/(n-1)) if n > 1 else 0 for i in range(n)]
CW, CH = int(2*R*math.sin(math.radians(spread/2)) + 2.2*W), int(1.5*H + 0.25*R + 0.6*R*(1-math.cos(math.radians(spread/2))))
canvas = Image.new('RGBA', (CW, CH), (0, 0, 0, 0))
pivot = (CW/2, 0.62*H + R + 120)
for card, ang in zip(cards, angles):
    layer = Image.new('RGBA', (CW, CH), (0, 0, 0, 0))
    cx, cy = pivot[0], pivot[1] - R
    layer.paste(card, (int(cx - card.width/2), int(cy - card.height/2)), card)
    layer = layer.rotate(-ang, resample=Image.BICUBIC, center=pivot)
    sh = Image.new('RGBA', (CW, CH), (20, 15, 10, 0))
    sh.putalpha(layer.getchannel('A').point(lambda v: int(v * 0.42)).filter(ImageFilter.GaussianBlur(26)))
    canvas.alpha_composite(sh, (14, 22))       # soft shadow falls on the cards below
    canvas.alpha_composite(layer)

x0, y0, x1, y1 = canvas.getchannel('A').point(lambda v: 255 if v > 2 else 0).getbbox()
pad = 30
canvas = canvas.crop((x0-pad, y0-pad, x1+pad, y1+pad))
canvas = canvas.resize((int(canvas.width/a.shrink), int(canvas.height/a.shrink)), Image.LANCZOS)
out = a.out or f'{base}-fan-{tag}.png'
canvas.save(out, optimize=True)
print(out, f'{canvas.width}x{canvas.height}')