Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ but with a deprecation period.

### Features

- Added `Resize`, `Padding`, and `Background` factories to select resizing
strategies, configure uniform or symmetric padding, and set or explicitly
disable background colors, #107
- Added `Client(source_encoding='plain')` to generate URLs with
percent-encoded sources and imgproxy's `/plain/` marker. Base64 remains
the default, #106
Expand Down
36 changes: 29 additions & 7 deletions imgproxykit/options/background.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from dataclasses import dataclass
from typing import ClassVar, override
from typing import ClassVar, Self, override

from imgproxykit.options.base import Option
from imgproxykit.types import Argument
Expand All @@ -8,24 +8,25 @@
@dataclass(frozen=True, slots=True, kw_only=True)
class Background(Option):
"""
Fill the background with a hex color or RGB tuple; omit to disable.
Fill the background with a hex or RGB color, or explicitly disable filling.

Useful when converting transparent images to JPEG. Omit the color
to disable background manipulation.
Useful when converting transparent images to JPEG. Use :meth:`hex` or
:meth:`rgb` to select a color, and :meth:`disabled` to override background
filling set earlier in the request, including by a preset.

Attributes:
color: Hex color without a leading hash, or an RGB tuple with
channel values from 0 to 255. Omission disables filling.

Usage:

>>> option = Background(color='ffffff')
>>> option = Background.hex('ffffff')
>>> option.arguments
('ffffff',)
>>> option = Background(color=(255, 255, 255))
>>> option = Background.rgb(red=255, green=255, blue=255)
>>> option.arguments
(255, 255, 255)
>>> option = Background()
>>> option = Background.disabled()
>>> option.arguments
(None,)

Expand All @@ -39,6 +40,27 @@ class Background(Option):
name: ClassVar[str] = 'background'
short_name: ClassVar[str | None] = 'bg'

@classmethod
def rgb(cls, *, red: int, green: int, blue: int) -> Self:
"""Fill the background using three required RGB channels."""
return cls(color=(red, green, blue))

@classmethod
def hex(cls, color: str) -> Self:
"""Fill the background using a hex color without a leading hash."""
return cls(color=color)

@classmethod
def disabled(cls) -> Self:
"""
Explicitly disable background filling, including a preset's color.

Emits the background option without arguments. Unlike omitting the
option, this overrides background filling configured earlier in the
request. Equivalent to ``Background()``.
"""
return cls()

@property
@override
def arguments(self) -> tuple[Argument, ...]:
Expand Down
20 changes: 18 additions & 2 deletions imgproxykit/options/padding.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from dataclasses import dataclass
from typing import ClassVar, override
from typing import ClassVar, Self, override

from imgproxykit.options.base import Option
from imgproxykit.types import Argument
Expand All @@ -15,6 +15,9 @@ class Padding(Option):
Supply at least one side; omitted arguments remain
``None``.

Use :meth:`all` for uniform padding, :meth:`symmetric` for separate
horizontal and vertical padding, or the constructor for individual sides.

Attributes:
top: Top padding in pixels; fallback for omitted sides.
right: Right padding; also used for an omitted left side.
Expand All @@ -23,9 +26,12 @@ class Padding(Option):

Usage:

>>> option = Padding(top=10)
>>> option = Padding.all(10)
>>> option.arguments
(10, None, None, None)
>>> option = Padding.symmetric(horizontal=20, vertical=10)
>>> option.arguments
(10, 20, None, None)
>>> option = Padding(top=10, right=20, bottom=30, left=40)
>>> option.arguments
(10, 20, 30, 40)
Expand All @@ -43,6 +49,16 @@ class Padding(Option):
name: ClassVar[str] = 'padding'
short_name: ClassVar[str | None] = 'pd'

@classmethod
def all(cls, value: int) -> Self:
"""Apply the same padding to all sides using protocol fallback."""
return cls(top=value)

@classmethod
def symmetric(cls, *, horizontal: int, vertical: int) -> Self:
"""Set padding for both axes, requiring both values explicitly."""
return cls(top=vertical, right=horizontal)

@property
@override
def arguments(self) -> tuple[Argument, ...]:
Expand Down
103 changes: 100 additions & 3 deletions imgproxykit/options/resize.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
from dataclasses import dataclass
from typing import ClassVar, override
from typing import ClassVar, Self, override

from imgproxykit.options.base import Option
from imgproxykit.types import Argument, ResizeType


@dataclass(frozen=True, slots=True, kw_only=True)
class Resize(Option):
class Resize(Option): # noqa: WPS214
"""
Configure image dimensions and resizing behavior.

Values left as ``None`` are not supplied
explicitly; imgproxy resolves their defaults.

Select a strategy with :meth:`fit`, :meth:`fill`, :meth:`fill_down`,
:meth:`force`, or :meth:`auto`. Use the constructor when the mode is
supplied dynamically or should be left to the server.

Attributes:
width: Target width in pixels.
height: Target height in pixels.
Expand All @@ -22,9 +26,12 @@ class Resize(Option):

Usage:

>>> option = Resize(width=96, height=96, mode='fill')
>>> option = Resize.fill(width=96, height=96)
>>> option.arguments
('fill', 96, 96, None, None)
>>> option = Resize.fit(width=300, enlarge=False)
>>> option.arguments
('fit', 300, None, False, None)

See `imgproxy resize
<https://docs.imgproxy.net/usage/processing#resize>`_.
Expand All @@ -42,6 +49,96 @@ class Resize(Option):
name: ClassVar[str] = 'resize'
short_name: ClassVar[str | None] = 'rs'

@classmethod
def fit(
cls,
*,
width: int | None = None,
height: int | None = None,
enlarge: bool | None = None,
extend: bool | None = None,
) -> Self:
"""Resize using the fit strategy with optional dimensions."""
return cls(
mode='fit',
width=width,
height=height,
enlarge=enlarge,
extend=extend,
)

@classmethod
def fill(
cls,
*,
width: int | None = None,
height: int | None = None,
enlarge: bool | None = None,
extend: bool | None = None,
) -> Self:
"""Resize using the fill strategy with optional dimensions."""
return cls(
mode='fill',
width=width,
height=height,
enlarge=enlarge,
extend=extend,
)

@classmethod
def fill_down(
cls,
*,
width: int | None = None,
height: int | None = None,
enlarge: bool | None = None,
extend: bool | None = None,
) -> Self:
"""Resize using the fill-down strategy with optional dimensions."""
return cls(
mode='fill-down',
width=width,
height=height,
enlarge=enlarge,
extend=extend,
)

@classmethod
def force(
cls,
*,
width: int | None = None,
height: int | None = None,
enlarge: bool | None = None,
extend: bool | None = None,
) -> Self:
"""Resize using the force strategy with optional dimensions."""
return cls(
mode='force',
width=width,
height=height,
enlarge=enlarge,
extend=extend,
)

@classmethod
def auto(
cls,
*,
width: int | None = None,
height: int | None = None,
enlarge: bool | None = None,
extend: bool | None = None,
) -> Self:
"""Resize using the auto strategy with optional dimensions."""
return cls(
mode='auto',
width=width,
height=height,
enlarge=enlarge,
extend=extend,
)

@property
@override
def arguments(self) -> tuple[Argument, ...]:
Expand Down
3 changes: 3 additions & 0 deletions tests/test_unit/test_options/test_background.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
(Background(), 'background'),
(Background(color='ff0080'), 'background:ff0080'),
(Background(color=(0, 128, 255)), 'background:0:128:255'),
(Background.rgb(red=0, green=128, blue=255), 'background:0:128:255'),
(Background.hex('ff0080'), 'background:ff0080'),
(Background.disabled(), 'background'),
],
)
def test_background(
Expand Down
12 changes: 3 additions & 9 deletions tests/test_unit/test_options/test_padding.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,10 @@
('option', 'expected'),
[
(Padding(), 'padding'),
(
Padding(
top=10,
right=20,
bottom=30,
left=40,
),
'padding:10:20:30:40',
),
(Padding(top=10, right=20, bottom=30, left=40), 'padding:10:20:30:40'),
(Padding(left=0), 'padding::::0'),
(Padding.all(0), 'padding:0'),
(Padding.symmetric(horizontal=0, vertical=10), 'padding:10:0'),
],
)
def test_padding(
Expand Down
41 changes: 41 additions & 0 deletions tests/test_unit/test_options/test_resize.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
from collections.abc import Callable

import pytest

from imgproxykit.options import Resize
from imgproxykit.serialization import PathSerializer
from imgproxykit.types import ResizeType


@pytest.mark.parametrize(
Expand Down Expand Up @@ -33,3 +36,41 @@ def test_resize(
path_serializer._serialize_segment(option.name, option.arguments)
== expected
)


@pytest.mark.parametrize(
('factory', 'mode'),
[
(Resize.fit, 'fit'),
(Resize.fill, 'fill'),
(Resize.fill_down, 'fill-down'),
(Resize.force, 'force'),
(Resize.auto, 'auto'),
],
)
@pytest.mark.parametrize(
('settings', 'suffix'),
[
({}, ''),
(
{'width': 0, 'height': 200, 'enlarge': False, 'extend': True},
':0:200:0:1',
),
({'enlarge': True, 'extend': False}, ':::1:0'),
],
)
def test_resize_factories(
path_serializer: PathSerializer,
*,
factory: Callable[..., Resize],
mode: ResizeType,
settings: dict[str, int | bool],
suffix: str,
) -> None:
"""Serialize each resize mode with omitted dimensions and explicit flags."""
option = factory(**settings)

assert (
path_serializer._serialize_segment(option.name, option.arguments)
== f'resize:{mode}{suffix}'
)
Loading