From 157004982cdcb3f8ac566cd56595a8937b625fc4 Mon Sep 17 00:00:00 2001 From: Alexander Kondratev Date: Fri, 30 Oct 2026 10:59:56 +0500 Subject: [PATCH 1/2] Add Gravity factories --- CHANGELOG.md | 2 + docs/examples/options/resize.py | 2 +- docs/pages/user-guide/options.rst | 2 +- imgproxykit/options/gravity.py | 115 +++++++++++++++++-- tests/test_unit/test_options/test_gravity.py | 66 +++++++++++ 5 files changed, 176 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c77c96e..1038e6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ but with a deprecation period. ### Features +- Added `Gravity` factories to configure crop positioning without + using protocol codes, # - 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 diff --git a/docs/examples/options/resize.py b/docs/examples/options/resize.py index 676fe66..85aecda 100644 --- a/docs/examples/options/resize.py +++ b/docs/examples/options/resize.py @@ -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, ) diff --git a/docs/pages/user-guide/options.rst b/docs/pages/user-guide/options.rst index b3fcd2c..d3284d7 100644 --- a/docs/pages/user-guide/options.rst +++ b/docs/pages/user-guide/options.rst @@ -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. diff --git a/imgproxykit/options/gravity.py b/imgproxykit/options/gravity.py index c2f1b6b..98d3ad0 100644 --- a/imgproxykit/options/gravity.py +++ b/imgproxykit/options/gravity.py @@ -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: @@ -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) @@ -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 @@ -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, ...]: diff --git a/tests/test_unit/test_options/test_gravity.py b/tests/test_unit/test_options/test_gravity.py index 1c22b4d..13858aa 100644 --- a/tests/test_unit/test_options/test_gravity.py +++ b/tests/test_unit/test_options/test_gravity.py @@ -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( @@ -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 + ) From d0b5a2f1c5fa28780b11aa7c5b2a704b9f13c1a5 Mon Sep 17 00:00:00 2001 From: Alexander Kondratev Date: Fri, 30 Oct 2026 11:00:50 +0500 Subject: [PATCH 2/2] Add Gravity factories --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1038e6e..70a800b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ but with a deprecation period. ### Features - Added `Gravity` factories to configure crop positioning without - using protocol codes, # + 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