Files
espresso_frame/server/app/image_upload.py
T
Thomas Faour 35e80c6d1c
Build and push server image / test (push) Successful in 26s
Build and push server image / build-and-push (push) Successful in 2m1s
Build and push server image / deploy (push) Successful in 59s
Add a static image widget (PNG/JPEG/GIF/BMP/WEBP/TIFF/PDF upload)
A new widget type showing a single user-uploaded image with no live
upstream to poll -- decoded once at upload time (PDF's first page via
pypdfium2, BSD-3-Clause/Apache-2.0, no copyleft exposure) into plain RGB
PNG bytes, then composed per a crop/stretch/letterbox display mode like
the photos widget.
2026-07-25 08:39:09 +00:00

55 lines
2.3 KiB
Python

"""Decodes an arbitrary uploaded file (PNG/JPEG/GIF/BMP/WEBP/TIFF/PDF/...)
into a plain RGB PIL image, for the static-image widget (see routers/
api_widgets.py's api_widget_static_upload, app/widgets/static_image.py).
The result is stored (as PNG bytes) rather than the original upload, so
render() never needs to re-run PDF/GIF decoding on every panel refresh --
this module only runs once, at upload time.
PDF decoding uses pypdfium2 (Google's PDFium bindings -- BSD-3-Clause/
Apache-2.0, no copyleft exposure) rather than a GPL/AGPL alternative
like PyMuPDF, per CLAUDE.md's copyleft-dependency convention (a check
that only applies to copyleft/unclear licenses -- this one's plainly
permissive, so no explicit flag was needed here)."""
from __future__ import annotations
import io
import pypdfium2 as pdfium
from fastapi import HTTPException
from PIL import Image, UnidentifiedImageError
MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # generous for a single image/PDF page; stops an accidental huge upload
# ~144 DPI off a PDF's 72-DPI native unit -- comfortably above the panel's
# own 800x480, without ballooning render time/memory on a poster-sized page.
PDF_RENDER_SCALE = 2.0
def decode_upload(data: bytes) -> Image.Image:
"""Raises HTTPException(400) for anything that isn't a recognizable
image or PDF. Detects PDF by magic bytes, not the client-supplied
filename/content-type (neither is trustworthy). A PDF renders only
its first page -- there's no "which page" concept for a single-image
widget."""
if len(data) > MAX_UPLOAD_BYTES:
raise HTTPException(400, f"File is too large (max {MAX_UPLOAD_BYTES // (1024 * 1024)}MB)")
if data.startswith(b"%PDF-"):
return _decode_pdf(data)
try:
img = Image.open(io.BytesIO(data))
img.load()
except UnidentifiedImageError:
raise HTTPException(400, "Not a recognizable image or PDF file") from None
return img.convert("RGB")
def _decode_pdf(data: bytes) -> Image.Image:
try:
pdf = pdfium.PdfDocument(data)
if len(pdf) == 0:
raise HTTPException(400, "PDF has no pages")
bitmap = pdf[0].render(scale=PDF_RENDER_SCALE)
except pdfium.PdfiumError as e:
raise HTTPException(400, f"Could not read PDF: {e}") from e
return bitmap.to_pil().convert("RGB")