fix: thumbnail-first + megapixel guard to prevent image OOM in PDF/DOCX
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled

Previous draft()+convert()+thumbnail() ordering still caused OOM for
non-JPEG formats (PNG/HEIC) because draft() is a no-op for those formats,
and convert('RGB') forces a full pixel decode regardless.

Replace with thumbnail()-first ordering:
- thumbnail() internally calls draft() for JPEG before decoding
- thumbnail() performs in-place resize without allocating a second
  full-resolution buffer
- convert('RGB') is then called on the already-small image (safe for
  any format)

Add _COMPRESS_IMAGE_MAX_MEGAPIXELS guard (20 MP): read image dimensions
from headers only (no pixel decode) and return None for images that
exceed the limit. This prevents OOM even for pathologically large files
where draft() provides no benefit (e.g. PNG, TIFF, HEIC).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
mariomitte
2026-08-31 10:10:01 +02:00
parent a7220d27f5
commit 8e9d23fcfa
2 changed files with 24 additions and 15 deletions

View File

@@ -464,24 +464,29 @@ def _resolve_service_report_tasks(work_order, task_id):
return [selected_task], selected_task
_COMPRESS_IMAGE_MAX_MEGAPIXELS = 20 # preskači slike iznad 20 MP kako bi se izbjegao OOM
def _compress_image_for_pdf(image_field, max_width=1280, quality=75):
"""
Otvori image_field (Django FileField), kompresiraj na max_width JPEG u memoriji,
vrati ImageReader spreman za reportlab. Vraća None ako slika nije dostupna.
Koristi Image.draft() + thumbnail() kako bi se izbjeglo učitavanje pune rezolucije
velikih fotografija u RAM (sprječava OOM kill Gunicorn workera).
Redoslijed thumbnail() → convert() je bitan: thumbnail() interno poziva draft()
za JPEG (smanjuje rezoluciju JPEG dekodera na serveru) te radi in-place resize
bez alokacije full-resolution kopije. convert() se poziva tek na već maloj slici.
Pixel-count guard preskači slike koje bi i uz draft() prekoračile RAM.
"""
try:
image_field.open('rb')
with Image.open(image_field) as src:
# draft() smanjuje dekodiranu rezoluciju za JPEG na serveru (hint, ne garantija)
src.draft('RGB', (max_width, max_width * 4))
img = src.convert('RGB')
# thumbnail() radi in-place i ne alocira novu sliku pune veličine
with Image.open(image_field) as img:
w, h = img.size # čita samo header, bez dekodiranja piksela
if w * h > _COMPRESS_IMAGE_MAX_MEGAPIXELS * 1_000_000:
return None
img.thumbnail((max_width, max_width * 4), Image.LANCZOS)
rgb = img.convert('RGB')
buf = BytesIO()
img.save(buf, format='JPEG', quality=quality, optimize=True)
rgb.save(buf, format='JPEG', quality=quality, optimize=True)
buf.seek(0)
return ImageReader(buf)
except Exception:
@@ -497,17 +502,21 @@ def _compress_image_for_docx(image_field, max_width=1600, quality=80):
"""
Pripremi sliku za python-docx kao JPEG stream razumne veličine.
Koristi Image.draft() + thumbnail() kako bi se izbjeglo učitavanje pune rezolucije
velikih fotografija u RAM (sprječava OOM kill Gunicorn workera).
Redoslijed thumbnail() → convert() je bitan: thumbnail() interno poziva draft()
za JPEG (smanjuje rezoluciju JPEG dekodera na serveru) te radi in-place resize
bez alokacije full-resolution kopije. convert() se poziva tek na već maloj slici.
Pixel-count guard preskači slike koje bi i uz draft() prekoračile RAM.
"""
try:
image_field.open('rb')
with Image.open(image_field) as src:
src.draft('RGB', (max_width, max_width * 4))
img = src.convert('RGB')
with Image.open(image_field) as img:
w, h = img.size # čita samo header, bez dekodiranja piksela
if w * h > _COMPRESS_IMAGE_MAX_MEGAPIXELS * 1_000_000:
return None
img.thumbnail((max_width, max_width * 4), Image.LANCZOS)
rgb = img.convert('RGB')
buf = BytesIO()
img.save(buf, format='JPEG', quality=quality, optimize=True)
rgb.save(buf, format='JPEG', quality=quality, optimize=True)
buf.seek(0)
return buf
except Exception: