Render PDF Thumbnails with PyMuPDF

A document portal lists uploaded PDFs with a preview of the first page. The thumbnail job renders at a fixed DPI, and the grid looks broken: an A4 invoice gives a neat 165×234 preview, an A0 site plan produces a 2,300-pixel image that takes seconds to load, a landscape statement appears as a thin strip, and a slide deck exported at 13.33×7.5 inches renders wider than the card. The job also re-renders every file every night, even though almost none have changed.

Root Cause

DPI is a physical scale: pixels per inch of the page. Rendering all pages at the same DPI gives thumbnails proportional to their physical size, so paper size, not the display slot, decides the pixel dimensions. A0 is about sixteen times the area of A4, so its thumbnail is sixteen times the pixels. Thumbnails need the opposite: a fixed pixel box, with each page scaled to fit it regardless of its physical size. Separately, a preview job that has no notion of whether the source changed will redo all its work on every run, and render cost grows with the archive rather than with the number of new uploads.

Minimal Diagnostic

Compute the pixel size each page would produce at your current DPI, alongside its physical size and rotation. The spread shows immediately why the grid is inconsistent.

# pip install pymupdf
from pathlib import Path
import pymupdf

SOURCE_DIR = Path("in/uploads")
CURRENT_DPI = 20

def thumbnail_spread(folder: Path, dpi: int) -> None:
    for pdf in sorted(folder.glob("*.pdf")):
        try:
            with pymupdf.open(pdf) as doc:
                page = doc[0]
                rect = page.rect                                  # already accounts for /Rotate
                w_px, h_px = round(rect.width * dpi / 72), round(rect.height * dpi / 72)
                print(f"{pdf.name:<28} {rect.width / 72:5.1f}x{rect.height / 72:5.1f} in "
                      f"rotate={page.rotation:<3} -> {w_px}x{h_px} px")
        except (pymupdf.FileDataError, RuntimeError, IndexError) as exc:
            print(f"{pdf.name:<28} unreadable or empty: {exc}")

if __name__ == "__main__":
    thumbnail_spread(SOURCE_DIR, CURRENT_DPI)
invoice-2291.pdf               8.3x 11.7 in rotate=0   -> 165x234 px
site-plan-rev-c.pdf           33.1x 46.8 in rotate=0   -> 662x936 px
statement-q3.pdf              11.7x  8.3 in rotate=90  -> 234x165 px
board-deck.pdf                13.3x  7.5 in rotate=0   -> 267x150 px

Same DPI, four very different thumbnails. The fix is to choose the scale per page from a target box.

Thumbnail width at a fixed 20 DPI At a fixed 20 DPI the A4 invoice renders 165 pixels wide, the A0 site plan 662 pixels, the landscape statement 234 pixels and the widescreen slide deck 267 pixels. A fixed pixel box of 240 pixels on the longest edge would give every document the same footprint in the grid. First-page thumbnail width in pixels at 20 DPI A4 invoice 165 px A0 site plan 662 px Landscape statement 234 px Widescreen slide deck 267 px Physical page size sets the pixels at fixed DPI; a grid needs a fixed pixel box instead

Fix: Scale Each Page to a Pixel Box

Compute a zoom factor that fits the page's displayed rectangle into the target box, render with that matrix, and save in a compact web format. Changed lines are commented.

# pip install pymupdf pillow
from pathlib import Path
import pymupdf
from PIL import Image

SOURCE = Path("in/uploads/site-plan-rev-c.pdf")
OUT_DIR = Path("out/thumbs")
BOX = (240, 240)                                   # max width, max height in pixels

def fit_zoom(page: pymupdf.Page, box: tuple[int, int]) -> float:
    rect = page.rect                               # rotated, cropped page size in points
    return min(box[0] / rect.width, box[1] / rect.height)   # changed: scale to box, not DPI

def render_thumbnail(pdf_path: Path, out_dir: Path, page_index: int = 0,
                     box: tuple[int, int] = BOX) -> Path:
    out_dir.mkdir(parents=True, exist_ok=True)
    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.name} is encrypted")
        if doc.page_count == 0:
            raise ValueError(f"{pdf_path.name} has no pages")
        page = doc[min(page_index, doc.page_count - 1)]
        zoom = fit_zoom(page, box)
        matrix = pymupdf.Matrix(zoom * 2, zoom * 2)                 # changed: render at 2x ...
        pix = page.get_pixmap(matrix=matrix, alpha=False, annots=True)
        image = Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
        image.thumbnail(box, Image.Resampling.LANCZOS)              # changed: ... then downscale
        dest = out_dir / f"{pdf_path.stem}-p{page.number + 1}.webp"
        image.save(dest, "WEBP", quality=80, method=6)              # changed: small web format
    return dest

if __name__ == "__main__":
    print(render_thumbnail(SOURCE, OUT_DIR))

Rendering at twice the target and downscaling with Lanczos produces noticeably crisper text than rendering directly at thumbnail size, where MuPDF's anti-aliasing at tiny scales blurs thin strokes. The cost is small because the render is still tiny. page.rect already reflects the page's /Rotate value and crop box, so rotated statements come out upright and scanner margins that were cropped away stay hidden.

WebP at quality 80 is typically a third of the size of an equivalent PNG for page previews. Keep PNG if the portal must support very old browsers, or if thumbnails are mostly line art where PNG compresses well.

Fixed-box thumbnail rendering The page rectangle, which already includes rotation and crop box, gives the page size in points. The fit zoom is the smaller of box width over page width and box height over page height. The page is rendered at twice that zoom with alpha disabled, downscaled into the box with Lanczos resampling, and saved as WebP at quality 80. page.rect rotated and cropped Fit zoom min of box ratios Render 2x crisp strokes Lanczos down into 240x240 Save WebP quality 80

Variant Fix 1: Enormous or Pathological Pages

Engineering drawings and maps can contain millions of vector paths; even a thumbnail render walks all of them and can take many seconds. Protect the portal with a time budget per file and a placeholder on timeout, running each render in a separate process that can be abandoned:

# pip install pymupdf pillow
from concurrent.futures import ProcessPoolExecutor, TimeoutError as FutureTimeout
from pathlib import Path
import shutil

PLACEHOLDER = Path("assets/thumb-placeholder.webp")

def safe_thumbnail(pool: ProcessPoolExecutor, pdf_path: Path, out_dir: Path, seconds: int = 10) -> Path:
    future = pool.submit(render_thumbnail, pdf_path, out_dir)      # from the fix above
    try:
        return future.result(timeout=seconds)
    except FutureTimeout:
        future.cancel()
        dest = out_dir / f"{pdf_path.stem}-p1.webp"
        shutil.copyfile(PLACEHOLDER, dest)                          # generic preview, retried later
        return dest
    except (RuntimeError, PermissionError, ValueError):
        dest = out_dir / f"{pdf_path.stem}-p1.webp"
        shutil.copyfile(PLACEHOLDER, dest)
        return dest

A timed-out future keeps its worker busy until the render finishes, so size the pool with headroom, or restart it periodically in long-running services. Record timeouts: a document type that always exceeds the budget is a candidate for rendering from a pre-rasterised version the uploader provides, or for a lower render multiplier.

Variant Fix 2: Re-Rendering Unchanged Files Every Night

Key thumbnails on a hash of the file contents rather than the file name, and skip work when a thumbnail for that hash exists. Renames and re-uploads of identical files then cost nothing, and a changed file with the same name gets a fresh thumbnail automatically.

# pip install pymupdf pillow
import hashlib
from pathlib import Path

def content_hash(path: Path, chunk: int = 1 << 20) -> str:
    h = hashlib.sha256()
    with path.open("rb") as fh:
        while block := fh.read(chunk):
            h.update(block)
    return h.hexdigest()[:20]

def cached_thumbnail(pdf_path: Path, cache_dir: Path) -> Path:
    key = content_hash(pdf_path)
    dest = cache_dir / key[:2] / f"{key}.webp"               # fan out into subfolders
    if dest.exists():
        return dest
    rendered = render_thumbnail(pdf_path, dest.parent)
    rendered.replace(dest)
    return dest

Splitting the cache into subfolders by the first two hash characters keeps directory listings fast once the cache holds hundreds of thousands of files. Include the rendering settings — box size, format, quality — in the key if they might change, so a design change does not serve stale thumbnails.

Cache lookup for a thumbnail request The portal asks the thumbnail job for a preview. The job reads the PDF bytes from storage and computes a SHA-256 key. It checks the cache for that key. On a hit the cached WebP is returned immediately. On a miss the job renders with PyMuPDF, writes the WebP to the cache under the key and returns it. Portal Thumbnail job File storage Cache thumbnail for upload 8841 read PDF bytes exists sha256 key? miss write rendered WebP WebP 240 px

Choosing Which Page to Preview

The first page is not always the most useful preview. Fax cover sheets, blank separator pages from scanners, and "this page intentionally left blank" inserts all make a grid of identical-looking cards. Pick the first page that carries meaningful ink, using a cheap low-resolution render to decide before producing the real thumbnail:

# pip install pymupdf pillow
from pathlib import Path
import pymupdf
from PIL import Image, ImageStat

COVER_WORDS = ("fax cover", "cover sheet", "intentionally left blank")

def preview_page_index(pdf_path: Path, max_pages: int = 4, min_ink: float = 0.01) -> int:
    """Index of the first page with enough dark pixels and no cover-sheet wording."""
    with pymupdf.open(pdf_path) as doc:
        for page in doc.pages(0, min(max_pages, doc.page_count)):
            text = page.get_text().lower()
            if any(word in text for word in COVER_WORDS):
                continue
            pix = page.get_pixmap(dpi=12, colorspace=pymupdf.csGRAY, alpha=False)
            gray = Image.frombytes("L", (pix.width, pix.height), pix.samples)
            dark = sum(ImageStat.Stat(gray.point(lambda v: 255 if v < 200 else 0)).sum) / 255
            if dark / (pix.width * pix.height) >= min_ink:
                return page.number
    return 0                                                    # fall back to the first page

Rendering at 12 DPI costs a few milliseconds per page, and the text check catches typed cover sheets that happen to carry plenty of ink. Pass the returned index to render_thumbnail(pdf_path, out_dir, page_index=...) and store it alongside the thumbnail, so the portal can open the document at the same page the preview shows — users find that consistency reassuring, and it saves a click for multi-page scans where page one is always a cover.

Verification

Check that every thumbnail fits the box, that one dimension actually reaches the box edge (otherwise the fit calculation is wrong), that images are not blank, and that output size stays within a budget suitable for a grid of previews.

# pip install pillow
from pathlib import Path
from PIL import Image, ImageStat

def verify_thumbs(folder: Path, box: tuple[int, int] = (240, 240), max_kb: int = 30) -> None:
    problems = []
    thumbs = sorted(folder.rglob("*.webp"))
    for path in thumbs:
        with Image.open(path) as im:
            w, h = im.size
            if w > box[0] or h > box[1]:
                problems.append(f"{path.name}: {w}x{h} exceeds box")
            if max(w / box[0], h / box[1]) < 0.97:
                problems.append(f"{path.name}: {w}x{h} does not fill the box")
            darkest = ImageStat.Stat(im.convert("L")).extrema[0][0]
            if darkest > 245:
                problems.append(f"{path.name}: looks blank")
        if path.stat().st_size > max_kb * 1024:
            problems.append(f"{path.name}: {path.stat().st_size // 1024} KB")
    assert not problems, "\n".join(problems[:20])
    print(f"{len(thumbs)} thumbnails verified")

if __name__ == "__main__":
    verify_thumbs(Path("out/thumbs"))

A genuinely blank first page — a cover sheet deliberately left empty — will trip the blank check. If that is common in your uploads, render the first page with any ink instead: loop over the first few pages and use the first one whose darkest pixel is below the threshold.

FAQ

Should I use page.get_pixmap(dpi=...) at all for thumbnails? Only when all documents share one paper size. For mixed uploads, a matrix computed from the target box is the only way to get uniform output.

Can I thumbnail every page for a page picker? Yes; loop over pages with the same function. For long documents render lazily — first page on upload, the rest when a user opens the picker — and cache by file hash plus page number.

How do I thumbnail password-protected PDFs? You cannot render encrypted content without the password. Show a lock placeholder, or decrypt at upload time if your process has the key, as in remove a password from a PDF with Python.

Do thumbnails leak redacted information? They render whatever the page contains. Generate thumbnails from the redacted version only, after the checks in Redacting Sensitive Data in PDFs pass.

Part of Converting PDFs to Images and Back with Python.