Feature: Generate PDF thumbnails via poppler utils (#14420)

* Chore: Generate PDF thumbnails with pdftoppm and Pillow instead of ImageMagick

Replace the ImageMagick convert based PDF thumbnail path with pdftoppm and
Pillow. Keeps thumbnails at 500px wide for small and odd sized pages, covers
the qpdf repair and double failure paths, removes the unused run_convert
helper, hardens thumbnail encoding, deprecates PAPERLESS_CONVERT_MEMORY_LIMIT
and PAPERLESS_CONVERT_TMPDIR, and corrects the related docs and CI.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Chore: Supersample the PDF thumbnail render 2x

Render the PDF page at twice the target size and downsample, with edge case
handling for small and odd sized pages, simplified helpers and tests, and
trimmed comments.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Fix: use a fractional thumbnail DPI so oversized pages render a bounded raster

* Deprecate the unused PAPERLESS_GS_BINARY setting too

* Warn at startup when pdftoppm is missing

* Resample PDF thumbnails once instead of twice with LANCZOS, still never enlarging

* Read PDF page size from pdfinfo so it matches pdftoppm

* Mark the thumbnail constants Final

* Comment each pdftoppm option
This commit is contained in:
Trenton HandClaude Sonnet 5.5 authored and GitHub committed 2026-10-20 11:22:42 -07:00
1 parent f730513e79
commit a05afd654c
12 files changed
+916 -143

No files matched your search

+66
View File
@@ -10,7 +10,9 @@ from __future__ import annotations
import codecs
import logging
import os
import re
import subprocess
import tempfile
from pathlib import Path
from typing import TYPE_CHECKING
@@ -25,6 +27,15 @@ logger = logging.getLogger("paperless.parsers.utils")
# Used by both the consumer (archive decision) and the tesseract parser (skip-OCR decision).
PDF_TEXT_MIN_LENGTH: Final[int] = 50
_PDFINFO_PAGE_SIZE: Final[re.Pattern[str]] = re.compile(
r"^Page\s+1 size:\s+([\d.eE+-]+) x ([\d.eE+-]+) pts",
re.MULTILINE,
)
_PDFINFO_PAGE_ROTATION: Final[re.Pattern[str]] = re.compile(
r"^Page\s+1 rot:\s+(\d+)",
re.MULTILINE,
)
def is_tagged_pdf(
path: Path,
@@ -265,6 +276,61 @@ def get_page_count_for_pdf(
return None
def get_pdf_first_page_size_points(
path: Path,
log: logging.Logger | None = None,
) -> tuple[float, float] | None:
"""Return the first page's (width, height) in PDF points, post-rotation.
Asks ``pdfinfo`` rather than reading the page boxes directly, so the size
matches what ``pdftoppm -cropbox`` renders, including poppler's clipping of
the CropBox to the MediaBox. pdfinfo reports the box size before rotation,
so width and height are swapped for 90/270 rotation.
Parameters
----------
path:
Absolute path to the PDF file.
log:
Logger for warnings. Falls back to the module-level logger when omitted.
Returns
-------
tuple[float, float] | None
``(width_points, height_points)``, or ``None`` if pdfinfo fails, the
file has no pages, or the page size is degenerate.
"""
_log = log or logger
try:
proc = subprocess.run(
["pdfinfo", "-f", "1", "-l", "1", str(path)],
capture_output=True,
check=True,
text=True,
errors="ignore",
# The labels parsed below are localized
env={**os.environ, "LC_ALL": "C"},
)
except (OSError, subprocess.CalledProcessError) as e:
_log.warning("Could not determine PDF page size for %s: %s", path, e)
return None
size_match = _PDFINFO_PAGE_SIZE.search(proc.stdout)
if size_match is None:
_log.warning("Could not determine PDF page size for %s", path)
return None
width = float(size_match.group(1))
height = float(size_match.group(2))
if width <= 0 or height <= 0:
return None
rotation_match = _PDFINFO_PAGE_ROTATION.search(proc.stdout)
if rotation_match is not None and int(rotation_match.group(1)) in (90, 270):
width, height = height, width
return width, height
def extract_pdf_metadata(
document_path: Path,
log: logging.Logger | None = None,