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.
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.
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.
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.
Related
- Converting PDFs to Images and Back with Python — DPI choices and full-size rendering
- Convert Images to a Single PDF — the reverse direction for uploaded photos
- Watching Folders for Incoming Documents — triggering thumbnail jobs when uploads arrive
- Split a PDF by Page Ranges with Python — previews for each section of a long document