"""Zachowawcza, bezstratna dla PNG / wysokiej jakosci dla JPEG, optymalizacja. Ten modul NIGDY nie nadpisuje pliku zrodlowego: tworzy jedynie propozycje w pliku tymczasowym. main.py odpowiada za backup, podmiane i transakcje SQL. """ from __future__ import annotations import os import tempfile from pathlib import Path from PIL import Image, ImageOps, PngImagePlugin from image_safety import DEFAULT_MAX_IMAGE_PIXELS, ImagePixelLimitExceeded, safe_image_open class ImageOptimizationError(Exception): pass def _image_save_kwargs(source, image_format): kwargs = {} if source.info.get('icc_profile'): kwargs['icc_profile'] = source.info['icc_profile'] if source.info.get('dpi'): kwargs['dpi'] = source.info['dpi'] if image_format == 'PNG': pnginfo = PngImagePlugin.PngInfo() text_chunks = getattr(source, 'text', {}) or {} for key, value in text_chunks.items(): if isinstance(key, str) and isinstance(value, str): pnginfo.add_text(key, value) if text_chunks: kwargs['pnginfo'] = pnginfo return kwargs def create_optimized_candidate( path, *, max_width=2400, max_height=3200, max_bytes=2 * 1024 * 1024, jpeg_quality=92, min_jpeg_quality=85, min_gain_percent=5.0, temp_dir=None, max_pixels=DEFAULT_MAX_IMAGE_PIXELS, ): """Zwraca {status,reason,before,after,width,height,quality,temp_path}. Status: ready, skip, error. Wlasnosc pliku temp_path przekazywana wywolujacemu: wywolujacy MUSI usunac plik, takze przy bledzie. """ orig_size = os.path.getsize(path) result = { 'status': 'skip', 'reason': '', 'before': orig_size, 'after': orig_size, 'width': 0, 'height': 0, 'quality': None, 'temp_path': None, } temp_path = None try: with safe_image_open(path, max_pixels=max_pixels) as source: fmt = source.format ext = Path(path).suffix.lower() if fmt not in ('JPEG', 'PNG'): result['reason'] = f'format {fmt or "nieznany"} nie jest bezpiecznie obslugiwany' return result if fmt == 'JPEG' and ext not in ('.jpg', '.jpeg'): result['reason'] = 'format i rozszerzenie pliku nie sa zgodne' return result if fmt == 'PNG' and ext != '.png': result['reason'] = 'format i rozszerzenie pliku nie sa zgodne' return result if getattr(source, 'is_animated', False) and getattr(source, 'n_frames', 1) > 1: result['reason'] = 'animacja (nie modyfikujemy)' return result original_width, original_height = source.size result['width'], result['height'] = original_width, original_height if original_width <= max_width and original_height <= max_height and orig_size <= max_bytes: result['reason'] = 'obraz juz spelnia limity' return result # Duze JPEG-i dekodujemy w mozliwie mniejszej rozdzielczosci. # JPEG draft korzysta z dekodera DCT i zapobiega alokacji # kilkuset MB / kilku GB przy wejsciu rzedu 200 MP. # Nie stosujemy draft do JPEG bez planowanego skalowania. if fmt == 'JPEG' and (original_width > max_width or original_height > max_height): source.draft(None, (max_width, max_height)) # EXIF Orientation ma znaczenie zwlaszcza dla pionowych skanow. working = ImageOps.exif_transpose(source) if (fmt == 'PNG' and working.mode == 'P' and (working.width > max_width or working.height > max_height)): # Skalowanie obrazu paletowego przed konwersja daje nearest-neighbor. # Konwertuj najpierw, aby LANCZOS pracowal na pelnych kolorach. working = working.convert('RGBA' if 'transparency' in working.info else 'RGB') working.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) result['width'], result['height'] = working.size kwargs = _image_save_kwargs(source, fmt) fd, temp_path = tempfile.mkstemp(suffix=ext, prefix='.d6-image-', dir=temp_dir) os.close(fd) if fmt == 'JPEG': if working.mode not in ('RGB', 'L', 'CMYK'): if working.mode == 'RGBA' or 'transparency' in working.info: result['reason'] = 'JPEG z kanalem alfa (nie modyfikujemy)' return result working = working.convert('RGB') # Pobieraj EXIF juz po prawidlowej korekcji orientacji. try: exif = working.getexif() if exif: kwargs['exif'] = exif.tobytes() except (ValueError, TypeError, OSError): # Uszkodzone EXIF nie powinno blokowac zapisu obrazu. pass qualities = list(range(jpeg_quality, min_jpeg_quality - 1, -3)) if min_jpeg_quality not in qualities: qualities.append(min_jpeg_quality) best_quality = None best_size = orig_size for quality in qualities: working.save( temp_path, format='JPEG', optimize=True, progressive=True, subsampling=0, quality=quality, **kwargs, ) size = os.path.getsize(temp_path) if size < best_size: best_quality, best_size = quality, size if size <= max_bytes and 100.0 * (orig_size - size) / max(1, orig_size) >= min_gain_percent: break # Jezeli nie udalo sie zejsc pod limit, zastosuj najmniejsza # akceptowalna jakosc. Plik wynikowy bedzie ostatnim kandydatem. if best_quality != quality: working.save( temp_path, format='JPEG', optimize=True, progressive=True, subsampling=0, quality=best_quality or min_jpeg_quality, **kwargs, ) result['quality'] = best_quality else: working.save(temp_path, format='PNG', optimize=True, compress_level=9, **kwargs) result['after'] = os.path.getsize(temp_path) gain_percent = 100.0 * (orig_size - result['after']) / max(1, orig_size) if result['after'] >= orig_size or gain_percent < min_gain_percent: result['reason'] = f'za mala oszczednosc ({gain_percent:.1f}% < {min_gain_percent:.1f}%)' return result result['status'] = 'ready' result['reason'] = 'osiagnieto limit' if result['after'] <= max_bytes else 'zmniejszono; nadal powyzej limitu' result['temp_path'] = temp_path temp_path = None # przekazujemy plik wywolujacemu return result except ImagePixelLimitExceeded as exc: result['status'] = 'skip' result['reason'] = str(exc) return result except Exception as exc: result['status'] = 'error' result['reason'] = f'{type(exc).__name__}: {exc}' return result finally: if temp_path and os.path.exists(temp_path): os.unlink(temp_path)