Skip to content

Commit

Permalink
Add a cover option
Browse files Browse the repository at this point in the history
  • Loading branch information
SmileyChris committed Aug 4, 2024
1 parent c72e768 commit 5d1cc81
Show file tree
Hide file tree
Showing 8 changed files with 72 additions and 13 deletions.
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
4 changes: 2 additions & 2 deletions easy_images/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Expand Down
17 changes: 14 additions & 3 deletions easy_images/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
):
"""
Expand All @@ -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:
Expand All @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions easy_images/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 20 additions & 4 deletions easy_images/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions easy_images/types_.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion tests/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
11 changes: 11 additions & 0 deletions tests/test_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}'
)

0 comments on commit 5d1cc81

Please sign in to comment.