pdf_thumbnail.py 2.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
  1. """PDF Thumbnail Generation Service.
  2. Renders the first page of a PDF with PDFium (pypdfium2) so a library PDF gets
  3. its grid thumbnail at upload/scan time. Before this, PDF thumbnails only
  4. existed once somebody had opened the in-browser preview, which posted its
  5. first render back (#2976); that route stays as the fallback for a PDF this
  6. renderer cannot read.
  7. """
  8. import logging
  9. import threading
  10. import uuid
  11. from pathlib import Path
  12. logger = logging.getLogger(__name__)
  13. # PDFium is not thread-safe, and callers run this through asyncio.to_thread,
  14. # so two uploads landing together would otherwise enter it concurrently.
  15. _PDFIUM_LOCK = threading.Lock()
  16. # Rendered at twice the output size and downsampled, so small print on the
  17. # page stays legible instead of breaking up into aliased specks.
  18. _SUPERSAMPLE = 2
  19. def generate_pdf_thumbnail(pdf_path: Path, thumbnails_dir: Path, size: int = 256) -> str | None:
  20. """Render page one of a PDF into a square, white-backed PNG thumbnail.
  21. Same shape as the thumbnail the browser preview posts back (a ``size``
  22. square with the page centred on white), so a grid mixing both kinds
  23. looks uniform. Returns the thumbnail path, or None if the file cannot be
  24. rendered - an encrypted, empty or damaged PDF simply gets no thumbnail.
  25. """
  26. try:
  27. import pypdfium2 as pdfium
  28. from PIL import Image
  29. except ImportError:
  30. logger.warning("pypdfium2 not installed, skipping PDF thumbnail for %s", pdf_path.name)
  31. return None
  32. try:
  33. with _PDFIUM_LOCK:
  34. pdf = pdfium.PdfDocument(str(pdf_path))
  35. try:
  36. if len(pdf) == 0:
  37. return None
  38. page = pdf[0]
  39. try:
  40. width, height = page.get_size()
  41. if width <= 0 or height <= 0:
  42. return None
  43. # Scale from the page's own size, so the bitmap is bounded
  44. # by the output size whatever MediaBox the file declares.
  45. scale = (size * _SUPERSAMPLE) / max(width, height)
  46. rendered = page.render(scale=scale).to_pil()
  47. finally:
  48. page.close()
  49. finally:
  50. pdf.close()
  51. rendered = rendered.convert("RGB")
  52. rendered.thumbnail((size, size), Image.Resampling.LANCZOS)
  53. thumbnail = Image.new("RGB", (size, size), (255, 255, 255))
  54. thumbnail.paste(rendered, ((size - rendered.width) // 2, (size - rendered.height) // 2))
  55. thumb_filename = f"{uuid.uuid4().hex}.png"
  56. thumb_path = thumbnails_dir / thumb_filename # SEC-PATH-OK: thumb_filename = uuid.uuid4().hex + ".png"
  57. thumbnail.save(thumb_path, "PNG", optimize=True)
  58. return str(thumb_path)
  59. except Exception as e:
  60. logger.warning("Failed to create PDF thumbnail for %s: %s", pdf_path.name, e)
  61. return None