diff --git a/README.md b/README.md index 03871fc..417e55c 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,25 @@ python main.py \ Dostaniesz rzeczywisty przewidywany rozmiar po rekompresji (test wykonany na pliku tymczasowym). **Nie zmieni to oryginałów ani SQL.** `--top` dotyczy wyświetlania; `--optimize-top` ogranicza liczbę obrazów poddawanych testowi/zmniejszaniu. +### Duze skany: DecompressionBombWarning / DecompressionBombError + +Pillow standardowo ostrzega powyzej ok. **89,5 mln pikseli (89,5 MP)**, a powyzej ok. **179 mln** przerywa otwieranie z bledem `DecompressionBombError`. To limit liczby pikseli, **nie rozmiaru pliku w MB**. Uczciwe skany dokumentow moga miec 100-200 MP. + +W tej wersji prog jest konfigurowalny: domyslnie `--max-image-mpixels 220`, dzieki czemu skany o **199 756 800 pikselach** z podanego logu nie wywoluja juz ostrzezen ani automatycznego bledu Pillow. Narzedzie **nie wylacza globalnie ochrony**: otwiera naglowek, sprawdza liczbe pikseli **przed dekodowaniem**, a przekraczajace limit obrazy wypisuje jako `[SKIP-LIMIT]` z przyczyna. Pozostale rodzaje ostrzezen nie sa filtrowane. Zbyt duzy obraz nie zostaje zmieniony na dysku ani w bazie. + +Uruchom ponownie swoj test: + +```bash +python main.py \ + --min-size-mb 5 --optimize --dry-run \ + --max-width 4000 --max-height 6000 --max-file-mb 3 \ + --max-image-mpixels 220 --report-csv plan.csv +``` + +Jesli serwer ma malo RAM, mozna obnizyc limit, np. do `--max-image-mpixels 120`: obrazy powyzej 120 MP zostana **pominiete**, a nie bezwarunkowo dekodowane. Skany JPEG wymagajace skalowania korzystaja z `draft()` do zmniejszenia pamieci przy dekodowaniu. Bardzo duze PNG moga nadal potrzebowac **ponad 1 GB RAM** na dekodowanie i skalowanie; domyslny limit MP nie jest gwarancja bezpiecznego zuzycia pamieci. Uruchamiaj najpierw `--dry-run`, a `--apply` testuj na pojedynczych plikach z backupem. + +Nie ustawiaj `Image.MAX_IMAGE_PIXELS = None` ani nie ignoruj wszystkich ostrzezen Pillow: takie ustawienia usuwaja mechanizm ostrzegania przed niebezpiecznymi obrazami. + ## 3. Zmniejsz np. pierwsze 10 obrazów i aktualizuj bazę **Najpierw zrób pełen backup bazy danych i katalogu plików** oraz przetestuj na kopii. Następnie: diff --git a/image_optimizer.py b/image_optimizer.py index b616528..a3daf5a 100644 --- a/image_optimizer.py +++ b/image_optimizer.py @@ -10,6 +10,7 @@ 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): @@ -43,6 +44,7 @@ def create_optimized_candidate( 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}. @@ -57,7 +59,7 @@ def create_optimized_candidate( } temp_path = None try: - with Image.open(path) as source: + 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'): @@ -79,6 +81,13 @@ def create_optimized_candidate( 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 @@ -144,6 +153,10 @@ def create_optimized_candidate( 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}' diff --git a/image_safety.py b/image_safety.py new file mode 100644 index 0000000..9e14e32 --- /dev/null +++ b/image_safety.py @@ -0,0 +1,58 @@ +"""Ograniczona obsluga duzych obrazow Pillow. + +Pillow domyslnie ostrzega powyzej ~89 MP i zglasza blad powyzej ~179 MP. +Dla skanow dopuszczamy konfigurowalny limit, ale PRZED dekodowaniem +odrzucamy wszystko, co ten limit przekracza. Nie wylaczamy ochrony globalnie. + +Narzędzie CLI wykonuje operacje obrazow sekwencyjnie; prog PIL jest globalny, +ale zmieniamy go tylko na czas kontrolowanego otwarcia i przywracamy po nim. +""" +from __future__ import annotations + +import warnings +from contextlib import contextmanager + +from PIL import Image + + +DEFAULT_MAX_IMAGE_PIXELS = 220_000_000 # 220 MP; obejmuje skany ~200 MP + + +class ImagePixelLimitExceeded(ValueError): + """Obraz nie zostal zdekodowany, bo przekracza ustalony limit.""" + + +@contextmanager +def safe_image_open(path, *, max_pixels=DEFAULT_MAX_IMAGE_PIXELS, check_pixels=True): + """Otworz naglowek obrazu z limitem, zanim Pillow zdekoduje piksele. + + Ostrzezenie Pillow dot. progu zostaje zastapione JAWNYM sprawdzeniem + wielkosci (i statusem SKIP w callerze). Pozostale ostrzezenia pozostaja + widoczne. Dla samego odczytu wymiarow check_pixels=False nie dekoduje. + """ + max_pixels = int(max_pixels) + if max_pixels <= 0: + raise ValueError('max_pixels musi byc wieksze od zera') + old_limit = Image.MAX_IMAGE_PIXELS + with warnings.catch_warnings(): + warnings.filterwarnings('ignore', category=Image.DecompressionBombWarning) + Image.MAX_IMAGE_PIXELS = max_pixels + try: + try: + with Image.open(path) as image: + pixels = image.width * image.height + if check_pixels and pixels > max_pixels: + raise ImagePixelLimitExceeded( + f'{image.width}x{image.height} ({pixels / 1_000_000:.1f} MP) ' + f'przekracza --max-image-mpixels={max_pixels / 1_000_000:g}; ' + 'pominieto dla ochrony pamieci RAM' + ) + yield image + except Image.DecompressionBombError as exc: + # Bardzo duze obrazy Pillow moze odrzucic juz przy Image.open. + raise ImagePixelLimitExceeded( + f'Pillow odrzucil obraz przekraczajacy limit ' + f'--max-image-mpixels={max_pixels / 1_000_000:g}: {exc}' + ) from exc + finally: + Image.MAX_IMAGE_PIXELS = old_limit diff --git a/main.py b/main.py index 6e7c337..f18f46c 100644 --- a/main.py +++ b/main.py @@ -26,6 +26,7 @@ from PIL import Image from drupal_lookup import DrupalReferenceLookup, node_url, search_url from image_optimizer import create_optimized_candidate +from image_safety import DEFAULT_MAX_IMAGE_PIXELS, safe_image_open def build_parser(): @@ -75,6 +76,8 @@ def build_parser(): change.add_argument('--jpeg-quality', type=int, default=92, help='Poczatkowa jakosc JPEG') change.add_argument('--min-jpeg-quality', type=int, default=85, help='Minimalna dopuszczalna jakosc JPEG') change.add_argument('--min-gain-percent', type=float, default=5.0, help='Minimalne zmniejszenie objetosci w procentach') + change.add_argument('--max-image-mpixels', type=float, default=DEFAULT_MAX_IMAGE_PIXELS / 1_000_000, + help='Limit megapikseli oryginalnego obrazu; powyzej tego progu plik bedzie pominiety (ochrona RAM)') report = parser.add_argument_group('Raporty') report.add_argument('--report-csv', help='CSV z plikami i linkami (UTF-8 BOM, otwiera sie w Excelu)') @@ -93,6 +96,8 @@ def validate_args(args, parser): parser.error('--min-gain-percent musi byc w przedziale 0..100') if args.max_changes < 0 or args.optimize_top < 0: parser.error('--max-changes i --optimize-top nie moga byc ujemne') + if not (0 < args.max_image_mpixels <= 500): + parser.error('--max-image-mpixels musi byc w zakresie (0, 500]') if args.apply and not (args.optimize or args.update_db): parser.error('--apply wymaga --optimize lub --update-db') if args.apply and args.optimize and not args.backup_dir: @@ -203,9 +208,12 @@ def sha256_file(path): return digest.hexdigest() -def file_resolution(path): +def file_resolution(path, *, max_pixels=DEFAULT_MAX_IMAGE_PIXELS): try: - with Image.open(path) as im: + # W metadanych mozemy odczytac wymiary nawet wtedy, gdy uzytkownik + # ustawi niski prog optymalizacji. Nie dekodujemy pikseli. + with safe_image_open(path, max_pixels=max(max_pixels, DEFAULT_MAX_IMAGE_PIXELS), + check_pixels=False) as im: return im.size except Exception: return (0, 0) @@ -257,7 +265,7 @@ def collect_images(connection, config, args): if stat_result.st_size < args.min_size_mb * 1024 * 1024: continue if full_path not in groups: - width, height = file_resolution(full_path) + width, height = file_resolution(full_path, max_pixels=int(args.max_image_mpixels * 1_000_000)) groups[full_path] = { 'path': full_path, 'rel_path': rel_path, @@ -286,6 +294,7 @@ def collect_images(connection, config, args): def is_candidate(image, args): return ( + not image['width'] or not image['height'] or image['width'] > args.max_width or image['height'] > args.max_height or image['before_size'] > args.max_file_mb * 1024 * 1024 @@ -465,6 +474,7 @@ def process_changes(connection, config, args, images, table_prefix): min_jpeg_quality=args.min_jpeg_quality, min_gain_percent=args.min_gain_percent, temp_dir=os.path.dirname(image['path']) if args.apply else None, + max_pixels=int(args.max_image_mpixels * 1_000_000), ) image['optimization_reason'] = result['reason'] image['optimize_status'] = result['status'] @@ -475,7 +485,9 @@ def process_changes(connection, config, args, images, table_prefix): print(f'[ERROR] {image["rel_path"]}: {result["reason"]}') continue if result['status'] == 'skip': - if args.debug: + if 'przekracza --max-image-mpixels' in result['reason'] or 'Pillow odrzucil' in result['reason']: + print(f'[SKIP-LIMIT] {image["rel_path"]}: {result["reason"]}') + elif args.debug: print(f'[SKIP] {image["rel_path"]}: {result["reason"]}') # Opcjonalny sync DB dla plikow, ktorych optymalizacja sie nie udala. if args.update_db and mismatched_rows: diff --git a/tests/test_drupal6.py b/tests/test_drupal6.py index c3067f8..ec5e41a 100644 --- a/tests/test_drupal6.py +++ b/tests/test_drupal6.py @@ -1,10 +1,13 @@ import io import os import re +import struct import sys import tempfile import types import unittest +import warnings +import zlib from contextlib import redirect_stdout from pathlib import Path from unittest.mock import patch @@ -15,6 +18,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import main from drupal_lookup import DrupalReferenceLookup, ReferenceMatcher, node_url, search_url from image_optimizer import create_optimized_candidate +from image_safety import safe_image_open class FakeCursor: @@ -346,6 +350,68 @@ class TestFiles(unittest.TestCase): Image.new('RGB', (300, 500), 'red').save(gif) self.assertEqual(create_optimized_candidate(str(gif), max_width=100)['status'], 'skip') + def test_image_limit_replaces_pillow_warning_without_disabling_safety(self): + # Symulujemy niski prog Pillow: nasz konfigurowalny limit nie + # powinien powodowac ostrzezen ani wychodzic poza scope. + with patch.object(Image, 'MAX_IMAGE_PIXELS', 100_000): + with warnings.catch_warnings(record=True) as emitted: + warnings.simplefilter('always', Image.DecompressionBombWarning) + self.assertEqual(main.file_resolution(str(self.image), max_pixels=2_000_000), + (950, 1250)) + result = create_optimized_candidate( + str(self.image), max_width=550, max_height=700, + max_bytes=100_000, max_pixels=2_000_000, + ) + self.assertEqual(result['status'], 'ready', result['reason']) + if result['temp_path']: + os.unlink(result['temp_path']) + self.assertEqual(Image.MAX_IMAGE_PIXELS, 100_000) + self.assertFalse(any(issubclass(item.category, Image.DecompressionBombWarning) + for item in emitted)) + + def test_giant_image_header_is_readable_but_skipped_above_limit(self): + # Header PNG deklaruje tyle pikseli co trzy problematyczne pliki + # z logu: 14400 * 13872 = 199 756 800. Nie generujemy tych pikseli. + fake = self.folder / 'scans' / '200mp.png' + with io.BytesIO() as buffer: + Image.new('RGB', (2, 2), 'red').save(buffer, format='PNG') + content = bytearray(buffer.getvalue()) + content[16:24] = struct.pack('>II', 14400, 13872) + content[29:33] = struct.pack('>I', zlib.crc32(content[12:29])) + fake.write_bytes(content) + with patch.object(Image, 'MAX_IMAGE_PIXELS', 100_000): + with warnings.catch_warnings(record=True) as emitted: + warnings.simplefilter('always', Image.DecompressionBombWarning) + self.assertEqual(main.file_resolution(str(fake), max_pixels=220_000_000), + (14400, 13872)) + with safe_image_open(str(fake), max_pixels=220_000_000) as source: + self.assertEqual(source.width * source.height, 199_756_800) + # Obrazu nie nalezy dekodowac bez limitu RAM. + rejected = create_optimized_candidate(str(fake), max_pixels=150_000_000) + self.assertEqual(rejected['status'], 'skip') + self.assertIn('--max-image-mpixels=150', rejected['reason']) + # Przypadek > 2 * limitu Pillow rowniez jest SKIP, nie ERROR. + rejected_strict = create_optimized_candidate(str(fake), max_pixels=60_000_000) + self.assertEqual(rejected_strict['status'], 'skip') + self.assertIn('--max-image-mpixels=60', rejected_strict['reason']) + self.assertEqual(Image.MAX_IMAGE_PIXELS, 100_000) + self.assertFalse(any(issubclass(item.category, Image.DecompressionBombWarning) + for item in emitted)) + + def test_oversize_images_are_reported_as_skipped_not_errors(self): + args = self.args('--optimize', '--dry-run', '--max-image-mpixels', '0.5', + '--max-width', '500') + original = self.image.read_bytes() + images, _, prefix = main.collect_images(self.conn, self.cfg, args) + with redirect_stdout(io.StringIO()) as output: + stats = main.process_changes(self.conn, self.cfg, args, images, prefix) + self.assertEqual(stats['errors'], 0) + self.assertEqual(stats['planned'], 0) + self.assertEqual(images[0]['optimize_status'], 'skip') + self.assertIn('[SKIP-LIMIT]', output.getvalue()) + self.assertEqual(self.image.read_bytes(), original) + self.assertEqual(self.conn.files[0]['filesize'], 12345) + def test_cli_end_to_end_read_only_generates_reports(self): csv_path = self.root / 'end.csv' json_path = self.root / 'end.json'