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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ but with a deprecation period.

### Features

- Added `Gravity` factories to configure crop positioning without
using protocol codes, #104
- Added `Transform.extend()` to create a new transformation with additional
typed options and scalar keyword arguments, preserving the original, #103
- Added `Option.short_name` with documented processing option aliases and
Expand Down
2 changes: 1 addition & 1 deletion docs/examples/options/resize.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
# Combine structured options with a scalar keyword.
thumbnail = Transform(
Resize(width=300, height=300, mode='fill'),
Gravity(position='ce'),
Gravity.center(),
dpr=2,
)

Expand Down
2 changes: 1 addition & 1 deletion docs/pages/user-guide/options.rst
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ while :class:`~imgproxykit.options.Gravity` selects the crop position:
:language: python
:lines: 8-25

``Resize`` requests a 300×300 crop, ``Gravity(position='ce')`` keeps the
``Resize`` requests a 300×300 crop, ``Gravity.center()`` keeps the
center, and ``dpr=2`` requests twice the pixel density. Pass structured
options as positional arguments to ``Transform``; scalar keywords can
follow them.
Expand Down
115 changes: 106 additions & 9 deletions imgproxykit/options/gravity.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
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, Position


@dataclass(frozen=True, slots=True, kw_only=True)
class Gravity(Option):
class Gravity(Option): # noqa: WPS214
"""
Choose the image area retained when cropping.

Use readable anchor factories, :meth:`smart`, or :meth:`focus`.
The constructor also accepts imgproxy position codes directly.
Omitted offsets remain ``None``.

Attributes:
Expand All @@ -20,10 +22,10 @@ class Gravity(Option):

Usage:

>>> option = Gravity(position='ce')
>>> option = Gravity.center()
>>> option.arguments
('ce', None, None)
>>> option = Gravity(position='fp', offset_x=0.25, offset_y=0.5)
>>> option = Gravity.focus(x=0.25, y=0.5)
>>> option.arguments
('fp', 0.25, 0.5)

Expand All @@ -32,11 +34,6 @@ class Gravity(Option):

"""

# TODO: Add readable typed factories such as Gravity.smart(),
# Gravity.center(), and Gravity.focus(x=..., y=...) so common usage
# does not require protocol codes. Distinguish focus coordinates from
# anchor offsets; consider readable anchors for Crop and Watermark too.

# Public API:
position: Position
offset_x: float | None = None
Expand All @@ -46,6 +43,106 @@ class Gravity(Option):
name: ClassVar[str] = 'gravity'
short_name: ClassVar[str | None] = 'g'

@classmethod
def center(
cls,
*,
offset_x: float | None = None,
offset_y: float | None = None,
) -> Self:
"""Anchor cropping to the center with optional offsets."""
return cls(position='ce', offset_x=offset_x, offset_y=offset_y)

@classmethod
def top(
cls,
*,
offset_x: float | None = None,
offset_y: float | None = None,
) -> Self:
"""Anchor cropping to the top edge with optional offsets."""
return cls(position='no', offset_x=offset_x, offset_y=offset_y)

@classmethod
def bottom(
cls,
*,
offset_x: float | None = None,
offset_y: float | None = None,
) -> Self:
"""Anchor cropping to the bottom edge with optional offsets."""
return cls(position='so', offset_x=offset_x, offset_y=offset_y)

@classmethod
def left(
cls,
*,
offset_x: float | None = None,
offset_y: float | None = None,
) -> Self:
"""Anchor cropping to the left edge with optional offsets."""
return cls(position='we', offset_x=offset_x, offset_y=offset_y)

@classmethod
def right(
cls,
*,
offset_x: float | None = None,
offset_y: float | None = None,
) -> Self:
"""Anchor cropping to the right edge with optional offsets."""
return cls(position='ea', offset_x=offset_x, offset_y=offset_y)

@classmethod
def top_left(
cls,
*,
offset_x: float | None = None,
offset_y: float | None = None,
) -> Self:
"""Anchor cropping to the top-left corner with optional offsets."""
return cls(position='nowe', offset_x=offset_x, offset_y=offset_y)

@classmethod
def top_right(
cls,
*,
offset_x: float | None = None,
offset_y: float | None = None,
) -> Self:
"""Anchor cropping to the top-right corner with optional offsets."""
return cls(position='noea', offset_x=offset_x, offset_y=offset_y)

@classmethod
def bottom_left(
cls,
*,
offset_x: float | None = None,
offset_y: float | None = None,
) -> Self:
"""Anchor cropping to the bottom-left corner with optional offsets."""
return cls(position='sowe', offset_x=offset_x, offset_y=offset_y)

@classmethod
def bottom_right(
cls,
*,
offset_x: float | None = None,
offset_y: float | None = None,
) -> Self:
"""Anchor cropping to the bottom-right corner with optional offsets."""
return cls(position='soea', offset_x=offset_x, offset_y=offset_y)

@classmethod
def smart(cls) -> Self:
"""Let imgproxy select the most interesting image area."""
return cls(position='sm')

@classmethod
def focus(cls, *, x: float, y: float) -> Self: # noqa: WPS111
"""Center cropping on focus coordinates in the range [0, 1]."""
return cls(position='fp', offset_x=x, offset_y=y)

@property
@override
def arguments(self) -> tuple[Argument, ...]:
Expand Down
66 changes: 66 additions & 0 deletions tests/test_unit/test_options/test_gravity.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 Gravity
from imgproxykit.serialization import PathSerializer
from imgproxykit.types import Position


@pytest.mark.parametrize(
Expand Down Expand Up @@ -30,3 +33,66 @@ def test_gravity(
path_serializer._serialize_segment(option.name, option.arguments)
== expected
)


@pytest.mark.parametrize(
('factory', 'position'),
[
(Gravity.center, 'ce'),
(Gravity.top, 'no'),
(Gravity.bottom, 'so'),
(Gravity.left, 'we'),
(Gravity.right, 'ea'),
(Gravity.top_left, 'nowe'),
(Gravity.top_right, 'noea'),
(Gravity.bottom_left, 'sowe'),
(Gravity.bottom_right, 'soea'),
],
)
@pytest.mark.parametrize(
('offsets', 'suffix'),
[
({}, ''),
({'offset_x': 0}, ':0'),
({'offset_y': 0}, '::0'),
({'offset_x': 0.25, 'offset_y': 20}, ':0.25:20'),
],
)
def test_anchor_factories(
path_serializer: PathSerializer,
*,
factory: Callable[..., Gravity],
position: Position,
offsets: dict[str, float],
suffix: str,
) -> None:
"""Serialize each readable anchor with omitted and explicit offsets."""
option = factory(**offsets)

assert option == Gravity(position=position, **offsets)
assert (
path_serializer._serialize_segment(option.name, option.arguments)
== f'gravity:{position}{suffix}'
)


@pytest.mark.parametrize(
('option', 'expected'),
[
(Gravity.smart(), 'gravity:sm'),
(Gravity.focus(x=0.25, y=0.75), 'gravity:fp:0.25:0.75'),
(Gravity.focus(x=0, y=1), 'gravity:fp:0:1'),
(Gravity.focus(x=1, y=0), 'gravity:fp:1:0'),
],
)
def test_special_factories(
path_serializer: PathSerializer,
*,
option: Gravity,
expected: str,
) -> None:
"""Serialize smart gravity and focus coordinates, including endpoints."""
assert (
path_serializer._serialize_segment(option.name, option.arguments)
== expected
)
Loading