From 5d1cc8146f2bdd41a26c7069f7858701d80b33b3 Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Mon, 5 Aug 2024 10:31:24 +1200 Subject: [PATCH] Add a `cover` option --- README.md | 11 ++++++++++- easy_images/core.py | 4 ++-- easy_images/engine.py | 17 ++++++++++++++--- easy_images/models.py | 2 ++ easy_images/options.py | 24 ++++++++++++++++++++---- easy_images/types_.py | 4 ++-- tests/test_engine.py | 12 +++++++++++- tests/test_options.py | 11 +++++++++++ 8 files changed, 72 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 5c3faa1..4fc3cfb 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,16 @@ Use a boolean, or tuple of two floats, or the comma separated string equivalent. You can also use the following keywords: `tl` (top left), `tr` (top right), `bl` (bottom left), `br` (bottom right), `l`, `r`, `t` or `b`. This will set the percentage to 0 or 100 for the appropriate axis. -If crop is `False`, the image will be resized so that it will cover the requested ratio but not cropped down. This is useful when you want to handle positioning in CSS using `object-fit`. +If crop is `False`, the image will be resized so that it will cover the requested ratio but not cropped down. +This is useful when you want to handle positioning in CSS using `object-fit`. + +#### `cover` + +Whether to resize the image to cover the requested ratio or to contain it (when not cropping). + +The default is `True`, meaning the image will be resized down to cover the requested ratio (which means the image dimensions may be larger than the requested dimensions). + +To rezise the image to always fit within the requested dimensions, set `cover=False`. #### `focal_window` diff --git a/easy_images/core.py b/easy_images/core.py index fdf500e..09f7fd4 100644 --- a/easy_images/core.py +++ b/easy_images/core.py @@ -17,13 +17,13 @@ from easy_images.models import EasyImage -format_map = {"avif": "image/avif", "webp": "image/webp"} - +format_map = {"avif": "image/avif", "webp": "image/webp", "jpeg": "image/jpeg"} option_defaults: ImgOptions = { "quality": 80, "ratio": "video", "crop": True, + "cover": False, "densities": [2], "format": "webp", } diff --git a/easy_images/engine.py b/easy_images/engine.py index a202c4d..a6f22c2 100644 --- a/easy_images/engine.py +++ b/easy_images/engine.py @@ -22,6 +22,7 @@ def scale_image( target: tuple[int, int], /, crop: tuple[float, float] | bool | None = None, + cover: bool = True, focal_window: tuple[float, float, float, float] | None = None, ): """ @@ -30,8 +31,15 @@ def scale_image( """ w, h = img.width, img.height - # Size image down to cover the dimensions - scale = max(target[0] / w, target[1] / h) + if crop: + cover = True + + if cover: + # Size image down to cover the dimensions + scale = max(target[0] / w, target[1] / h) + else: + # Size image to contain the dimensions + scale = min(target[0] / w, target[1] / h) # Focal window scaling if focal_window: @@ -44,7 +52,10 @@ def scale_image( if f_right - f_left > target[0] and f_bottom - f_top > target[1]: img = img.extract_area(f_left, f_top, f_right - f_left, f_bottom - f_top) w, h = img.width, h - scale = max(target[0] / w, target[1] / h) + if cover: + scale = max(target[0] / w, target[1] / h) + else: + scale = min(target[0] / w, target[1] / h) focal_window = None # Otherwise, if cropping then set the crop focal point to the center of the # focal window. diff --git a/easy_images/models.py b/easy_images/models.py index 4b4c2b6..8a8a8da 100644 --- a/easy_images/models.py +++ b/easy_images/models.py @@ -137,6 +137,8 @@ def build( scale_args["focal_window"] = options.window if options.crop: scale_args["crop"] = options.crop + if options.cover: + scale_args["cover"] = options.cover img = engine.scale_image(source_img, size, **scale_args) else: img = source_img diff --git a/easy_images/options.py b/easy_images/options.py index c75b862..17ee057 100644 --- a/easy_images/options.py +++ b/easy_images/options.py @@ -39,15 +39,18 @@ class ParsedOptions: - __slots__ = ("quality", "crop", "window", "width", "ratio", "mimetype") + __slots__ = ("quality", "crop", "cover", "window", "width", "ratio", "mimetype") quality: int crop: tuple[float, float] | None + cover: bool window: tuple[float, float, float, float] | None width: int | None ratio: float | None mimetype: str | None + _defaults = {"cover": True} + def __init__(self, bound=None, string="", /, **options): if string: for part in smart_split(string): @@ -64,9 +67,12 @@ def __init__(self, bound=None, string="", /, **options): value = value.resolve(context) if value or value == 0: parse_func = getattr(self, f"parse_{key}") - setattr(self, key, parse_func(value, **options)) + value = parse_func(value, **options) + elif key in self._defaults: + value = self._defaults[key] else: - setattr(self, key, 80 if key == "quality" else None) + value = 80 if key == "quality" else None + setattr(self, key, value) @classmethod def from_str(cls, s: str): @@ -103,6 +109,12 @@ def parse_crop(value, **options) -> tuple[float, float]: pass raise ValueError(f"Invalid crop value {value}") + @staticmethod + def parse_cover(value, **options) -> bool: + if not isinstance(value, bool): + raise ValueError(f"Invalid cover value {value}") + return value + @staticmethod def parse_window(value, **options) -> tuple[float, float, float, float]: if isinstance(value, str): @@ -166,7 +178,11 @@ def size(self): return self.width, int(self.width / self.ratio) def to_dict(self): - return {key: getattr(self, key) for key in self.__slots__} + return { + key: getattr(self, key) + for key in self.__slots__ + if key not in self._defaults or getattr(self, key) != self._defaults[key] + } def source_x(self, source_x: int): if self.window: diff --git a/easy_images/types_.py b/easy_images/types_.py index 9878500..afef0ae 100644 --- a/easy_images/types_.py +++ b/easy_images/types_.py @@ -32,17 +32,17 @@ "golden", "golden_vertical", ] +FitChoices: TypeAlias = Literal["contain", "cover"] alternative_re = re.compile(r"^(\d+w|\d(?:\.\d)?x)$") BuildChoices: TypeAlias = Literal["srcset", "src", None] -format_map = {"avif": "image/avif", "webp": "image/webp", "jpeg": "image/jpeg"} - class Options(TypedDict, total=False): quality: int crop: tuple[float, float] | CropChoices | bool + cover: bool window: tuple[float, float, float, float] | None width: int | WidthChoices | None ratio: float | tuple[float, float] | RatioChoices | None diff --git a/tests/test_engine.py b/tests/test_engine.py index 5277f0c..abf1492 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -5,7 +5,7 @@ SimpleUploadedFile, ) -from easy_images.engine import efficient_load +from easy_images.engine import efficient_load, scale_image from easy_images.options import ParsedOptions from pyvips import Image @@ -51,3 +51,13 @@ def test_efficient_load_from_memory(): file = SimpleUploadedFile("test.jpg", image.write_to_buffer(".jpg[Q=90]")) e_image = efficient_load(file, [ParsedOptions(width=100, ratio="video")]) assert (e_image.width, e_image.height) == (500, 500) + + +def test_scale(): + source = Image.black(1000, 1000) + scaled_cover = scale_image(source, (400, 500)) + assert (scaled_cover.width, scaled_cover.height) == (500, 500) + scaled = scale_image(source, (400, 500), cover=False) + assert (scaled.width, scaled.height) == (400, 400) + cropped = scale_image(source, (400, 500), crop=True) + assert (cropped.width, cropped.height) == (400, 500) diff --git a/tests/test_options.py b/tests/test_options.py index 59df9b5..3368c66 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -50,3 +50,14 @@ def test_hash(): ParsedOptions(quality=80).hash().hexdigest() == "cce6431a80fe3a84c7ea9f6c5293cbce4ed8848349bb0f2182eb6bb0d7a19f78" ) + + +def test_str(): + assert ( + str(ParsedOptions(width=100, ratio="video")) + == '{"crop": null, "mimetype": null, "quality": 80, "ratio": 1.7777777777777777, "width": 100, "window": null}' + ) + assert ( + str(ParsedOptions(width=100, cover=False)) + == '{"cover": false, "crop": null, "mimetype": null, "quality": 80, "ratio": null, "width": 100, "window": null}' + )