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

@@ -7,7 +7,7 @@ Nakon što je gitea webhook i testirana skripta za automatsko povlačenje promje
```bash ```bash
cd /opt/erp/app cd /opt/erp/app
git fetch origin git fetch origin
git reset --hard origin/main git reset --hard origin/main ili git pull --ff-only origin main
# rebuild backend + worker + beat + frontend # rebuild backend + worker + beat + frontend
docker compose -f docker-compose.yml -f docker-compose.prod.yml build backend worker beat frontend docker compose -f docker-compose.yml -f docker-compose.prod.yml build backend worker beat frontend

View File

@@ -464,24 +464,29 @@ def _resolve_service_report_tasks(work_order, task_id):
return [selected_task], selected_task 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): def _compress_image_for_pdf(image_field, max_width=1280, quality=75):
""" """
Otvori image_field (Django FileField), kompresiraj na max_width JPEG u memoriji, Otvori image_field (Django FileField), kompresiraj na max_width JPEG u memoriji,
vrati ImageReader spreman za reportlab. Vraća None ako slika nije dostupna. vrati ImageReader spreman za reportlab. Vraća None ako slika nije dostupna.
Koristi Image.draft() + thumbnail() kako bi se izbjeglo učitavanje pune rezolucije Redoslijed thumbnail() → convert() je bitan: thumbnail() interno poziva draft()
velikih fotografija u RAM (sprječava OOM kill Gunicorn workera). 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: try:
image_field.open('rb') image_field.open('rb')
with Image.open(image_field) as src: with Image.open(image_field) as img:
# draft() smanjuje dekodiranu rezoluciju za JPEG na serveru (hint, ne garantija) w, h = img.size # čita samo header, bez dekodiranja piksela
src.draft('RGB', (max_width, max_width * 4)) if w * h > _COMPRESS_IMAGE_MAX_MEGAPIXELS * 1_000_000:
img = src.convert('RGB') return None
# thumbnail() radi in-place i ne alocira novu sliku pune veličine
img.thumbnail((max_width, max_width * 4), Image.LANCZOS) img.thumbnail((max_width, max_width * 4), Image.LANCZOS)
rgb = img.convert('RGB')
buf = BytesIO() buf = BytesIO()
img.save(buf, format='JPEG', quality=quality, optimize=True) rgb.save(buf, format='JPEG', quality=quality, optimize=True)
buf.seek(0) buf.seek(0)
return ImageReader(buf) return ImageReader(buf)
except Exception: 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. Pripremi sliku za python-docx kao JPEG stream razumne veličine.
Koristi Image.draft() + thumbnail() kako bi se izbjeglo učitavanje pune rezolucije Redoslijed thumbnail() → convert() je bitan: thumbnail() interno poziva draft()
velikih fotografija u RAM (sprječava OOM kill Gunicorn workera). 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: try:
image_field.open('rb') image_field.open('rb')
with Image.open(image_field) as src: with Image.open(image_field) as img:
src.draft('RGB', (max_width, max_width * 4)) w, h = img.size # čita samo header, bez dekodiranja piksela
img = src.convert('RGB') if w * h > _COMPRESS_IMAGE_MAX_MEGAPIXELS * 1_000_000:
return None
img.thumbnail((max_width, max_width * 4), Image.LANCZOS) img.thumbnail((max_width, max_width * 4), Image.LANCZOS)
rgb = img.convert('RGB')
buf = BytesIO() buf = BytesIO()
img.save(buf, format='JPEG', quality=quality, optimize=True) rgb.save(buf, format='JPEG', quality=quality, optimize=True)
buf.seek(0) buf.seek(0)
return buf return buf
except Exception: except Exception: