From 39095bab2667212d8261acc437e0e6de4809ceca Mon Sep 17 00:00:00 2001 From: benoit74 Date: Fri, 20 Dec 2024 19:31:07 +0000 Subject: [PATCH] Make image optimization methods stricter with options types --- src/zimscraperlib/image/optimization.py | 239 ++++++++++------- src/zimscraperlib/image/presets.py | 141 +++++----- tests/image/test_image.py | 333 ++++++++++++++++++++---- 3 files changed, 497 insertions(+), 216 deletions(-) diff --git a/src/zimscraperlib/image/optimization.py b/src/zimscraperlib/image/optimization.py index 55e2f7f..e17c84a 100644 --- a/src/zimscraperlib/image/optimization.py +++ b/src/zimscraperlib/image/optimization.py @@ -18,13 +18,11 @@ can still run on default settings which give a bit less size than the original images but maintain a high quality. """ -import functools import io import os import pathlib import subprocess -from collections.abc import Callable -from typing import Any +from dataclasses import dataclass import piexif # pyright: ignore[reportMissingTypeStubs] from optimize_images.img_aux_processing import ( # pyright: ignore[reportMissingTypeStubs] @@ -54,18 +52,9 @@ def ensure_matches( raise ValueError(f"{src} is not of format {fmt}") -def optimize_png( - src: pathlib.Path | io.BytesIO, - dst: pathlib.Path | io.BytesIO | None = None, - max_colors: int = 256, - background_color: tuple[int, int, int] = (255, 255, 255), - *, - reduce_colors: bool | None = False, - fast_mode: bool | None = True, - remove_transparency: bool | None = False, - **_: Any, -) -> pathlib.Path | io.BytesIO: - """method to optimize PNG files using a pure python external optimizer +@dataclass +class OptimizePngOptions: + """Dataclass holding PNG optimization options Arguments: reduce_colors: Whether to reduce colors using adaptive color pallette (boolean) @@ -79,20 +68,38 @@ def optimize_png( values: True | False background_color: Background color if remove_transparency is True (tuple containing RGB values) - values: (255, 255, 255) | (221, 121, 108) | (XX, YY, ZZ)""" + values: (255, 255, 255) | (221, 121, 108) | (XX, YY, ZZ) + """ + + max_colors: int = 256 + background_color: tuple[int, int, int] = (255, 255, 255) + reduce_colors: bool | None = False + fast_mode: bool | None = True + remove_transparency: bool | None = False + + +def optimize_png( + src: pathlib.Path | io.BytesIO, + dst: pathlib.Path | io.BytesIO | None = None, + options: OptimizePngOptions | None = None, +) -> pathlib.Path | io.BytesIO: + """method to optimize PNG files using a pure python external optimizer""" ensure_matches(src, "PNG") img = Image.open(src) - if remove_transparency: - img = remove_alpha(img, background_color) + if options is None: + options = OptimizePngOptions() + + if options.remove_transparency: + img = remove_alpha(img, options.background_color) - if reduce_colors: - img, __, __ = do_reduce_colors(img, max_colors) + if options.reduce_colors: + img, _, _ = do_reduce_colors(img, options.max_colors) - if not fast_mode and img.mode == "P": - img, __ = rebuild_palette(img) + if not options.fast_mode and img.mode == "P": + img, _ = rebuild_palette(img) if dst is None: dst = io.BytesIO() @@ -102,16 +109,9 @@ def optimize_png( return dst -def optimize_jpeg( - src: pathlib.Path | io.BytesIO, - dst: pathlib.Path | io.BytesIO | None = None, - quality: int | None = 85, - *, - fast_mode: bool | None = True, - keep_exif: bool | None = True, - **_: Any, -) -> pathlib.Path | io.BytesIO: - """method to optimize JPEG files using a pure python external optimizer +@dataclass +class OptimizeJpgOptions: + """Dataclass holding JPG optimization options Arguments: quality: JPEG quality (integer between 1 and 100) @@ -120,7 +120,23 @@ def optimize_jpeg( values: True | False fast_mode: Use the supplied quality value. If turned off, optimizer will get dynamic quality value to ensure better compression - values: True | False""" + values: True | False + """ + + quality: int | None = 85 + fast_mode: bool | None = True + keep_exif: bool | None = True + + +def optimize_jpeg( + src: pathlib.Path | io.BytesIO, + dst: pathlib.Path | io.BytesIO | None = None, + options: OptimizeJpgOptions | None = None, +) -> pathlib.Path | io.BytesIO: + """method to optimize JPEG files using a pure python external optimizer""" + + if options is None: + options = OptimizeJpgOptions() ensure_matches(src, "JPEG") @@ -146,10 +162,10 @@ def optimize_jpeg( # only use progressive if file size is bigger use_progressive_jpg = orig_size > 10240 # 10KiB # noqa: PLR2004 - if fast_mode: - quality_setting = quality + if options.fast_mode: + quality_setting = options.quality else: - quality_setting, __ = jpeg_dynamic_quality(img) + quality_setting, _ = jpeg_dynamic_quality(img) if dst is None: dst = io.BytesIO() @@ -165,7 +181,7 @@ def optimize_jpeg( if isinstance(dst, io.BytesIO): dst.seek(0) - if keep_exif and had_exif: + if options.keep_exif and had_exif: piexif.transplant( # pyright: ignore[reportUnknownMemberType] exif_src=( str(src.resolve()) if isinstance(src, pathlib.Path) else src.getvalue() @@ -179,16 +195,9 @@ def optimize_jpeg( return dst -def optimize_webp( - src: pathlib.Path | io.BytesIO, - dst: pathlib.Path | io.BytesIO | None = None, - quality: int | None = 60, - method: int | None = 6, - *, - lossless: bool | None = False, - **_: Any, -) -> pathlib.Path | io.BytesIO: - """method to optimize WebP using Pillow options +@dataclass +class OptimizeWebpOptions: + """Dataclass holding WebP optimization options Arguments: lossless: Whether to use lossless compression (boolean); @@ -201,13 +210,29 @@ def optimize_webp( values: 1 | 2 | 3 | 4 | 5 | 6 refer to the link for more details - https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#webp""" + https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#webp + """ + + quality: int | None = 60 + method: int | None = 6 + lossless: bool | None = False + + +def optimize_webp( + src: pathlib.Path | io.BytesIO, + dst: pathlib.Path | io.BytesIO | None = None, + options: OptimizeWebpOptions | None = None, +) -> pathlib.Path | io.BytesIO: + """method to optimize WebP using Pillow options""" + + if options is None: + options = OptimizeWebpOptions() ensure_matches(src, "WEBP") params: dict[str, bool | int | None] = { - "lossless": lossless, - "quality": quality, - "method": method, + "lossless": options.lossless, + "quality": options.quality, + "method": options.method, } webp_image = Image.open(src) @@ -230,18 +255,9 @@ def optimize_webp( return dst -def optimize_gif( - src: pathlib.Path, - dst: pathlib.Path, - optimize_level: int | None = 1, - lossiness: int | None = None, - max_colors: int | None = None, - *, - interlace: bool | None = True, - no_extensions: bool | None = True, - **_: Any, -) -> pathlib.Path: - """method to optimize GIFs using gifsicle >= 1.92 +@dataclass +class OptimizeGifOptions: + """Dataclass holding GIF optimization options Arguments: optimize_level: Optimization level; higher values give better compression @@ -258,21 +274,37 @@ def optimize_gif( (integer between 2 and 256) values: 2 | 86 | 128 | 256 | XX - refer to the link for more details - https://www.lcdf.org/gifsicle/man.html""" + refer to the link for more details - https://www.lcdf.org/gifsicle/man.html + """ + + optimize_level: int | None = 1 + lossiness: int | None = None + max_colors: int | None = None + interlace: bool | None = True + no_extensions: bool | None = True + + +def optimize_gif( + src: pathlib.Path, dst: pathlib.Path, options: OptimizeGifOptions | None = None +) -> pathlib.Path: + """method to optimize GIFs using gifsicle >= 1.92""" + + if options is None: + options = OptimizeGifOptions() ensure_matches(src, "GIF") # use gifsicle args = ["/usr/bin/env", "gifsicle"] - if optimize_level: - args += [f"-O{optimize_level}"] - if max_colors: - args += ["--colors", str(max_colors)] - if lossiness: - args += [f"--lossy={lossiness}"] - if no_extensions: + if options.optimize_level: + args += [f"-O{options.optimize_level}"] + if options.max_colors: + args += ["--colors", str(options.max_colors)] + if options.lossiness: + args += [f"--lossy={options.lossiness}"] + if options.no_extensions: args += ["--no-extensions"] - if interlace: + if options.interlace: args += ["--interlace"] args += [str(src)] with open(dst, "w") as out_file: @@ -287,13 +319,39 @@ def optimize_gif( return dst +@dataclass +class OptimizeOptions: + """Dataclass holding GIF optimization options for all supported formats""" + + gif: OptimizeGifOptions + webp: OptimizeWebpOptions + jpg: OptimizeJpgOptions + png: OptimizePngOptions + + @classmethod + def of( + cls, + gif: OptimizeGifOptions | None = None, + webp: OptimizeWebpOptions | None = None, + jpg: OptimizeJpgOptions | None = None, + png: OptimizePngOptions | None = None, + ): + """Helper to override only few options from default value""" + return OptimizeOptions( + gif=gif or OptimizeGifOptions(), + png=png or OptimizePngOptions(), + webp=webp or OptimizeWebpOptions(), + jpg=jpg or OptimizeJpgOptions(), + ) + + def optimize_image( src: pathlib.Path, dst: pathlib.Path, + options: OptimizeOptions | None = None, *, delete_src: bool | None = False, convert: bool | str | None = False, - **options: Any, ): """Optimize image, automatically selecting correct optimizer @@ -305,6 +363,9 @@ def optimize_image( True: convert to format implied by dst suffix "FMT": convert to format FMT (use Pillow names)""" + if options is None: + options = OptimizeOptions.of() + src_format, dst_format = format_for(src, from_suffix=False), format_for(dst) if src_format is None: # pragma: no cover @@ -321,26 +382,20 @@ def optimize_image( else: src_img = pathlib.Path(src) - get_optimization_method(src_format)(src_img, dst, **options) + src_format = src_format.lower() + if src_format in ("jpg", "jpeg"): + optimize_jpeg(src=src_img, dst=dst, options=options.jpg) + elif src_format == "gif": + optimize_gif(src=src_img, dst=dst, options=options.gif) + elif src_format == "png": + optimize_png(src=src_img, dst=dst, options=options.png) + elif src_format == "webp": + optimize_webp(src=src_img, dst=dst, options=options.webp) + else: + raise NotImplementedError( + f"Image format '{src_format}' cannot yet be optimized" + ) # delete src image if requested if delete_src and src.exists() and src.resolve() != dst.resolve(): src.unlink() - - -def get_optimization_method(fmt: str) -> Callable[..., Any]: - """Return the proper optimization method to call for a given image format""" - - def raise_error(*_, orig_format: str): - raise NotImplementedError( - f"Image format '{orig_format}' cannot yet be optimized" - ) - - fmt = fmt.lower().strip() - return { - "gif": optimize_gif, - "jpg": optimize_jpeg, - "jpeg": optimize_jpeg, - "webp": optimize_webp, - "png": optimize_png, - }.get(fmt, functools.partial(raise_error, orig_format=fmt)) diff --git a/src/zimscraperlib/image/presets.py b/src/zimscraperlib/image/presets.py index 7be415f..70f90c8 100644 --- a/src/zimscraperlib/image/presets.py +++ b/src/zimscraperlib/image/presets.py @@ -1,4 +1,9 @@ -from typing import ClassVar +from zimscraperlib.image.optimization import ( + OptimizeGifOptions, + OptimizeJpgOptions, + OptimizePngOptions, + OptimizeWebpOptions, +) """ presets for ImageOptimizer in zimscraperlib.image.optimization module """ @@ -17,11 +22,11 @@ class WebpLow: ext = "webp" mimetype = f"{preset_type}/webp" - options: ClassVar[dict[str, str | bool | int | None]] = { - "lossless": False, - "quality": 40, - "method": 6, - } + options: OptimizeWebpOptions = OptimizeWebpOptions( + lossless=False, + quality=40, + method=6, + ) class WebpMedium: @@ -36,11 +41,11 @@ class WebpMedium: ext = "webp" mimetype = f"{preset_type}/webp" - options: ClassVar[dict[str, str | bool | int | None]] = { - "lossless": False, - "quality": 50, - "method": 6, - } + options: OptimizeWebpOptions = OptimizeWebpOptions( + lossless=False, + quality=50, + method=6, + ) class WebpHigh: @@ -55,11 +60,11 @@ class WebpHigh: ext = "webp" mimetype = f"{preset_type}/webp" - options: ClassVar[dict[str, str | bool | int | None]] = { - "lossless": False, - "quality": 90, - "method": 6, - } + options: OptimizeWebpOptions = OptimizeWebpOptions( + lossless=False, + quality=90, + method=6, + ) class GifLow: @@ -76,13 +81,13 @@ class GifLow: ext = "gif" mimetype = f"{preset_type}/gif" - options: ClassVar[dict[str, str | bool | int | None]] = { - "optimize_level": 3, - "max_colors": 256, - "lossiness": 80, - "no_extensions": True, - "interlace": True, - } + options: OptimizeGifOptions = OptimizeGifOptions( + optimize_level=3, + max_colors=256, + lossiness=80, + no_extensions=True, + interlace=True, + ) class GifMedium: @@ -99,12 +104,12 @@ class GifMedium: ext = "gif" mimetype = f"{preset_type}/gif" - options: ClassVar[dict[str, str | bool | int | None]] = { - "optimize_level": 3, - "lossiness": 20, - "no_extensions": True, - "interlace": True, - } + options: OptimizeGifOptions = OptimizeGifOptions( + optimize_level=3, + lossiness=20, + no_extensions=True, + interlace=True, + ) class GifHigh: @@ -121,12 +126,12 @@ class GifHigh: ext = "gif" mimetype = f"{preset_type}/gif" - options: ClassVar[dict[str, str | bool | int | None]] = { - "optimize_level": 2, - "lossiness": None, - "no_extensions": True, - "interlace": True, - } + options: OptimizeGifOptions = OptimizeGifOptions( + optimize_level=2, + lossiness=None, + no_extensions=True, + interlace=True, + ) class PngLow: @@ -140,12 +145,12 @@ class PngLow: ext = "png" mimetype = f"{preset_type}/png" - options: ClassVar[dict[str, str | bool | int | None]] = { - "reduce_colors": True, - "remove_transparency": False, - "max_colors": 256, - "fast_mode": False, - } + options: OptimizePngOptions = OptimizePngOptions( + reduce_colors=True, + remove_transparency=False, + max_colors=256, + fast_mode=False, + ) class PngMedium: @@ -159,11 +164,11 @@ class PngMedium: ext = "png" mimetype = f"{preset_type}/png" - options: ClassVar[dict[str, str | bool | int | None]] = { - "reduce_colors": False, - "remove_transparency": False, - "fast_mode": False, - } + options: OptimizePngOptions = OptimizePngOptions( + reduce_colors=False, + remove_transparency=False, + fast_mode=False, + ) class PngHigh: @@ -177,11 +182,11 @@ class PngHigh: ext = "png" mimetype = f"{preset_type}/png" - options: ClassVar[dict[str, str | bool | int | None]] = { - "reduce_colors": False, - "remove_transparency": False, - "fast_mode": True, - } + options: OptimizePngOptions = OptimizePngOptions( + reduce_colors=False, + remove_transparency=False, + fast_mode=True, + ) class JpegLow: @@ -193,14 +198,14 @@ class JpegLow: VERSION = 1 - ext = "png" - mimetype = f"{preset_type}/png" + ext = "jpg" + mimetype = f"{preset_type}/jpeg" - options: ClassVar[dict[str, str | bool | int | None]] = { - "quality": 45, - "keep_exif": False, - "fast_mode": True, - } + options: OptimizeJpgOptions = OptimizeJpgOptions( + quality=45, + keep_exif=False, + fast_mode=True, + ) class JpegMedium: @@ -215,11 +220,11 @@ class JpegMedium: ext = "jpg" mimetype = f"{preset_type}/jpeg" - options: ClassVar[dict[str, str | bool | int | None]] = { - "quality": 65, - "keep_exif": False, - "fast_mode": True, - } + options: OptimizeJpgOptions = OptimizeJpgOptions( + quality=65, + keep_exif=False, + fast_mode=True, + ) class JpegHigh: @@ -234,8 +239,8 @@ class JpegHigh: ext = "jpg" mimetype = f"{preset_type}/jpeg" - options: ClassVar[dict[str, str | bool | int | None]] = { - "quality": 80, - "keep_exif": True, - "fast_mode": True, - } + options: OptimizeJpgOptions = OptimizeJpgOptions( + quality=80, + keep_exif=True, + fast_mode=True, + ) diff --git a/tests/image/test_image.py b/tests/image/test_image.py index c39b367..cea9204 100644 --- a/tests/image/test_image.py +++ b/tests/image/test_image.py @@ -4,6 +4,7 @@ import pathlib import re import shutil +from dataclasses import asdict, is_dataclass from typing import Any import piexif # pyright: ignore[reportMissingTypeStubs] @@ -20,12 +21,17 @@ create_favicon, ) from zimscraperlib.image.optimization import ( + OptimizeGifOptions, + OptimizeJpgOptions, + OptimizeOptions, + OptimizePngOptions, + OptimizeWebpOptions, ensure_matches, - get_optimization_method, optimize_gif, optimize_image, optimize_jpeg, optimize_png, + optimize_webp, ) from zimscraperlib.image.presets import ( GifHigh, @@ -50,7 +56,11 @@ from zimscraperlib.image.transformation import resize_image from zimscraperlib.image.utils import save_image -ALL_PRESETS = [(n, p) for n, p in inspect.getmembers(presets) if inspect.isclass(p)] +ALL_PRESETS = [ + (n, p) + for n, p in inspect.getmembers(presets) + if inspect.isclass(p) and not is_dataclass(p) +] def get_image_size(fpath: pathlib.Path | io.BytesIO) -> tuple[int, int]: @@ -463,7 +473,7 @@ def test_wrong_extension( "fmt", ["png", "jpg", "gif", "webp"], ) -def test_optimize_image_default( +def test_optimize_image_default_generic( png_image2: pathlib.Path, jpg_image: pathlib.Path, gif_image: pathlib.Path, @@ -483,6 +493,40 @@ def test_optimize_image_default( assert os.path.getsize(dst) < os.path.getsize(src) +@pytest.mark.parametrize( + "fmt", + ["png", "jpg", "gif", "webp"], +) +def test_optimize_image_default_direct( + png_image2: pathlib.Path, + jpg_image: pathlib.Path, + gif_image: pathlib.Path, + webp_image: pathlib.Path, + tmp_path: pathlib.Path, + fmt: str, +): + src, dst = get_src_dst( + tmp_path, + fmt, + png_image=png_image2, + jpg_image=jpg_image, + gif_image=gif_image, + webp_image=webp_image, + ) + + if fmt in ("jpg", "jpeg"): + optimize_jpeg(src=src, dst=dst) + elif fmt == "gif": + optimize_gif(src=src, dst=dst) + elif fmt == "png": + optimize_png(src=src, dst=dst) + elif fmt == "webp": + optimize_webp(src=src, dst=dst) + else: + raise NotImplementedError(f"Image format '{fmt}' cannot yet be optimized") + assert os.path.getsize(dst) < os.path.getsize(src) + + def test_optimize_image_del_src(png_image: pathlib.Path, tmp_path: pathlib.Path): shutil.copy(png_image, tmp_path) src = tmp_path / png_image.name @@ -511,11 +555,54 @@ def test_optimize_image_bad_dst(png_image: pathlib.Path, tmp_path: pathlib.Path) @pytest.mark.parametrize( - "preset,expected_version,options,fmt", + "preset,expected_version,options", + [ + (WebpLow(), 1, {"lossless": False, "quality": 40, "method": 6}), + (WebpMedium(), 1, {"lossless": False, "quality": 50, "method": 6}), + (WebpHigh(), 1, {"lossless": False, "quality": 90, "method": 6}), + ], +) +def test_image_preset_webp( + preset: WebpLow | WebpMedium | WebpHigh, + expected_version: int, + options: dict[str, str | bool | int | None], + webp_image: pathlib.Path, + tmp_path: pathlib.Path, +): + assert preset.VERSION == expected_version + assert preset.ext == "webp" + assert preset.mimetype == "image/webp" + + default_options = OptimizeWebpOptions() + preset_options = asdict(preset.options) + + for key, value in preset_options.items(): + assert value == ( + options[key] if key in options else getattr(default_options, key) + ) + + src = webp_image + dst = tmp_path / f"out.{preset.ext}" + optimize_image( + src, + tmp_path / f"out.{preset.ext}", + delete_src=False, + options=OptimizeOptions.of(webp=preset.options), + ) + assert os.path.getsize(dst) < os.path.getsize(src) + + image_bytes = "" + with open(src, "rb") as fl: + image_bytes = fl.read() + byte_stream = io.BytesIO(image_bytes) + dst_bytes = optimize_webp(src=byte_stream, options=preset.options) + assert isinstance(dst_bytes, io.BytesIO) + assert dst_bytes.getbuffer().nbytes < byte_stream.getbuffer().nbytes + + +@pytest.mark.parametrize( + "preset,expected_version,options", [ - (WebpLow(), 1, {"lossless": False, "quality": 40, "method": 6}, "webp"), - (WebpMedium(), 1, {"lossless": False, "quality": 50, "method": 6}, "webp"), - (WebpHigh(), 1, {"lossless": False, "quality": 90, "method": 6}, "webp"), ( GifLow(), 1, @@ -526,7 +613,6 @@ def test_optimize_image_bad_dst(png_image: pathlib.Path, tmp_path: pathlib.Path) "no_extensions": True, "interlace": True, }, - "gif", ), ( GifMedium(), @@ -537,7 +623,6 @@ def test_optimize_image_bad_dst(png_image: pathlib.Path, tmp_path: pathlib.Path) "no_extensions": True, "interlace": True, }, - "gif", ), ( GifHigh(), @@ -548,8 +633,42 @@ def test_optimize_image_bad_dst(png_image: pathlib.Path, tmp_path: pathlib.Path) "no_extensions": True, "interlace": True, }, - "gif", ), + ], +) +def test_image_preset_gif( + preset: GifLow | GifMedium | GifHigh, + expected_version: int, + options: dict[str, str | bool | int | None], + gif_image: pathlib.Path, + tmp_path: pathlib.Path, +): + assert preset.VERSION == expected_version + assert preset.ext == "gif" + assert preset.mimetype == "image/gif" + + default_options = OptimizeGifOptions() + preset_options = asdict(preset.options) + + for key, value in preset_options.items(): + assert value == ( + options[key] if key in options else getattr(default_options, key) + ) + + src = gif_image + dst = tmp_path / f"out.{preset.ext}" + optimize_image( + src, + tmp_path / f"out.{preset.ext}", + delete_src=False, + options=OptimizeOptions.of(gif=preset.options), + ) + assert os.path.getsize(dst) < os.path.getsize(src) + + +@pytest.mark.parametrize( + "preset,expected_version,options", + [ ( PngLow(), 1, @@ -559,76 +678,105 @@ def test_optimize_image_bad_dst(png_image: pathlib.Path, tmp_path: pathlib.Path) "max_colors": 256, "fast_mode": False, }, - "png", ), ( PngMedium(), 1, {"reduce_colors": False, "remove_transparency": False, "fast_mode": False}, - "png", ), ( PngHigh(), 1, {"reduce_colors": False, "remove_transparency": False, "fast_mode": True}, - "png", ), - (JpegLow(), 1, {"quality": 45, "keep_exif": False, "fast_mode": True}, "jpg"), + ], +) +def test_image_preset_png( + preset: PngLow | PngMedium | PngHigh, + expected_version: int, + options: dict[str, str | bool | int | None], + png_image: pathlib.Path, + tmp_path: pathlib.Path, +): + assert preset.VERSION == expected_version + assert preset.ext == "png" + assert preset.mimetype == "image/png" + + default_options = OptimizePngOptions() + preset_options = asdict(preset.options) + + for key, value in preset_options.items(): + assert value == ( + options[key] if key in options else getattr(default_options, key) + ) + + src = png_image + dst = tmp_path / f"out.{preset.ext}" + optimize_image( + src, + tmp_path / f"out.{preset.ext}", + delete_src=False, + options=OptimizeOptions.of(png=preset.options), + ) + assert os.path.getsize(dst) < os.path.getsize(src) + + image_bytes = "" + with open(src, "rb") as fl: + image_bytes = fl.read() + byte_stream = io.BytesIO(image_bytes) + dst_bytes = optimize_png(src=byte_stream, options=preset.options) + assert isinstance(dst_bytes, io.BytesIO) + assert dst_bytes.getbuffer().nbytes < byte_stream.getbuffer().nbytes + + +@pytest.mark.parametrize( + "preset,expected_version,options", + [ + (JpegLow(), 1, {"quality": 45, "keep_exif": False, "fast_mode": True}), ( JpegMedium(), 1, {"quality": 65, "keep_exif": False, "fast_mode": True}, - "jpg", ), - (JpegHigh(), 1, {"quality": 80, "keep_exif": True, "fast_mode": True}, "jpg"), + (JpegHigh(), 1, {"quality": 80, "keep_exif": True, "fast_mode": True}), ], ) -def test_preset( - preset: ( - WebpLow - | WebpMedium - | WebpHigh - | JpegLow - | JpegMedium - | JpegHigh - | PngLow - | PngMedium - | PngHigh - ), +def test_image_preset_jpg( + preset: JpegLow | JpegMedium | JpegHigh, expected_version: int, options: dict[str, str | bool | int | None], - fmt: str, - png_image: pathlib.Path, jpg_image: pathlib.Path, - gif_image: pathlib.Path, - webp_image: pathlib.Path, tmp_path: pathlib.Path, ): assert preset.VERSION == expected_version - assert preset.options == options - src, dst = get_src_dst( - tmp_path, - fmt, - png_image=png_image, - jpg_image=jpg_image, - gif_image=gif_image, - webp_image=webp_image, - ) + assert preset.ext == "jpg" + assert preset.mimetype == "image/jpeg" + + default_options = OptimizeJpgOptions() + preset_options = asdict(preset.options) + + for key, value in preset_options.items(): + assert value == ( + options[key] if key in options else getattr(default_options, key) + ) + + src = jpg_image + dst = tmp_path / f"out.{preset.ext}" optimize_image( src, - dst, + tmp_path / f"out.{preset.ext}", delete_src=False, - **preset.options, # pyright: ignore[reportArgumentType] + options=OptimizeOptions.of(jpg=preset.options), ) assert os.path.getsize(dst) < os.path.getsize(src) - if fmt in ["jpg", "webp", "png"]: - image_bytes = "" - with open(src, "rb") as fl: - image_bytes = fl.read() - byte_stream = io.BytesIO(image_bytes) - dst_bytes = get_optimization_method(fmt)(src=byte_stream, **preset.options) - assert dst_bytes.getbuffer().nbytes < byte_stream.getbuffer().nbytes + image_bytes = "" + with open(src, "rb") as fl: + image_bytes = fl.read() + byte_stream = io.BytesIO(image_bytes) + dst_bytes = optimize_jpeg(src=byte_stream, options=preset.options) + assert isinstance(dst_bytes, io.BytesIO) + assert dst_bytes.getbuffer().nbytes < byte_stream.getbuffer().nbytes def test_optimize_image_unsupported_format(): @@ -640,7 +788,7 @@ def test_optimize_image_unsupported_format(): optimize_image(src, dst, delete_src=False) -def test_preset_has_mime_and_ext(): +def test_image_preset_has_mime_and_ext(): for _, preset in ALL_PRESETS: assert preset().ext assert preset().mimetype.startswith("image/") @@ -648,7 +796,9 @@ def test_preset_has_mime_and_ext(): def test_remove_png_transparency(png_image: pathlib.Path, tmp_path: pathlib.Path): dst = tmp_path / "out.png" - optimize_png(src=png_image, dst=dst, remove_transparency=True) + optimize_png( + src=png_image, dst=dst, options=OptimizePngOptions(remove_transparency=True) + ) assert os.path.getsize(dst) == 2352 @@ -683,7 +833,7 @@ def test_jpeg_exif_preserve(jpg_exif_image: pathlib.Path, tmp_path: pathlib.Path def test_dynamic_jpeg_quality(jpg_image: pathlib.Path, tmp_path: pathlib.Path): # check optimization without fast mode dst = tmp_path / "out.jpg" - optimize_jpeg(src=jpg_image, dst=dst, fast_mode=False) + optimize_jpeg(src=jpg_image, dst=dst, options=OptimizeJpgOptions(fast_mode=False)) assert os.path.getsize(dst) < os.path.getsize(jpg_image) @@ -822,12 +972,83 @@ def test_is_valid_image( def test_optimize_gif_no_optimize_level( gif_image: pathlib.Path, tmp_path: pathlib.Path ): - optimize_gif(gif_image, tmp_path / "out.gif", delete_src=False, optimize_level=None) + optimize_gif( + gif_image, tmp_path / "out.gif", options=OptimizeGifOptions(optimize_level=None) + ) def test_optimize_gif_no_no_extensions(gif_image: pathlib.Path, tmp_path: pathlib.Path): - optimize_gif(gif_image, tmp_path / "out.gif", delete_src=False, no_extensions=None) + optimize_gif( + gif_image, tmp_path / "out.gif", options=OptimizeGifOptions(no_extensions=None) + ) def test_optimize_gif_no_interlace(gif_image: pathlib.Path, tmp_path: pathlib.Path): - optimize_gif(gif_image, tmp_path / "out.gif", delete_src=False, interlace=None) + optimize_gif( + gif_image, tmp_path / "out.gif", options=OptimizeGifOptions(interlace=None) + ) + + +@pytest.mark.parametrize( + "fmt, preset", + [ + ("png", "low"), + ("jpg", "low"), + ("gif", "low"), + ("webp", "low"), + ("png", "medium"), + ("jpg", "medium"), + ("gif", "medium"), + ("webp", "medium"), + ("png", "high"), + ("jpg", "high"), + ("gif", "high"), + ("webp", "high"), + ], +) +def test_optimize_any_image( + png_image: pathlib.Path, + jpg_image: pathlib.Path, + gif_image: pathlib.Path, + webp_image: pathlib.Path, + tmp_path: pathlib.Path, + fmt: str, + preset: str, +): + src, dst = get_src_dst( + tmp_path, + fmt, + png_image=png_image, + jpg_image=jpg_image, + gif_image=gif_image, + webp_image=webp_image, + ) + # test call to optimize_image where src format is not set and all options are + # different than default values, just checking that at least we can set these opts + optimize_image( + src, + dst, + options=OptimizeOptions( + gif=( + GifMedium.options + if preset == "low" + else GifHigh.options if preset == "high" else GifMedium.options + ), + webp=( + WebpLow.options + if preset == "low" + else WebpHigh.options if preset == "high" else WebpMedium.options + ), + jpg=( + JpegLow.options + if preset == "low" + else JpegHigh.options if preset == "high" else JpegMedium.options + ), + png=( + PngLow.options + if preset == "low" + else PngHigh.options if preset == "high" else PngMedium.options + ), + ), + ) + assert os.path.getsize(dst) < os.path.getsize(src)