From 20dbf7954b9b80bca938422bea8aac22620ed25a Mon Sep 17 00:00:00 2001 From: "E. McConville" Date: Mon, 31 Mar 2025 21:03:08 -0500 Subject: [PATCH] Issue #669 - Allow x/y coords with gravity parameters --- docs/changes.rst | 10 ++ tests/image_methods_test.py | 40 ++++---- wand/image.py | 183 +++++++++++++++++++----------------- 3 files changed, 126 insertions(+), 107 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index ed21a474..fa6fa359 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -24,6 +24,16 @@ Unreleased. - Fixed :meth:`Image.liquid_rescale() ` behavior by switching default value of ``delta_x`` from ``0`` to ``1``. [:issue:`653`] - Fixed :meth:`Image.sparse_color() `'s ``colors`` argument structure to allow multiple (x, y) points with the same color value. + - Fixed offset coordinates when used with ``gravity`` parameters. [:issue:`669`] + + - :meth:`Image.chop() ` + - :meth:`Image.composite() ` + - :meth:`Image.composite_channel() ` + - :meth:`Image.crop() ` + - :meth:`Image.extent() ` + - :meth:`Image.region() ` + - :meth:`Image.splice() ` + - [TEST] Added Python 3.12 regression test. [:issue:`648` by Thijs Triemstra] diff --git a/tests/image_methods_test.py b/tests/image_methods_test.py index 7dc606f1..c390c252 100644 --- a/tests/image_methods_test.py +++ b/tests/image_methods_test.py @@ -250,9 +250,9 @@ def test_chop_gravity(): with Image(filename='rose:') as img: img.chop(width=10, height=10, gravity='south_east') assert (60, 36, 0, 0) == img.page - with raises(ValueError): - with Image(filename='rose:') as img: - img.chop(x=10, gravity='north') + with Image(filename='rose:') as img: + img.chop(width=10, height=10, x=10, y=10, gravity='north') + assert (60, 36, 0, 0) == img.page @mark.skipif(MAGICK_VERSION_NUMBER < 0x709, @@ -660,14 +660,6 @@ def test_crop_gravity(fx_asset): assert southeast[mid_width, mid_height] == Color('transparent') -def test_crop_gravity_error(): - with Image(filename='rose:') as img: - with raises(TypeError): - img.crop(gravity='center') - with raises(ValueError): - img.crop(width=1, height=1, gravity='nowhere') - - def test_crop_issue367(): with Image(filename='rose:') as img: expected = img.size @@ -679,6 +671,12 @@ def test_crop_issue367(): assert actual.size == expected +def test_crop_issue669(): + with Image(filename='rose:') as img: + img.crop(width=50, height=25, left=10, gravity='south') + assert 50, 25 == img.size + + def test_cycle_color_map(fx_asset): with Image(filename=str(fx_asset.joinpath('trim-color-test.png'))) as img: img.type = 'palette' @@ -874,9 +872,9 @@ def test_extent_gravity(): assert (10, 10, 0, 0) == img.page img.extent(width=100, height=100, gravity='center') assert (100, 100, 0, 0) == img.page - with raises(ValueError): - with Image(filename='rose:') as img: - img.extent(x=10, gravity='north') + with Image(filename='rose:') as img: + img.extent(x=10, gravity='north') + assert 70, 46 == img.size def test_features(): @@ -1730,10 +1728,10 @@ def test_region(): with src.region(width=10, height=10, gravity='south_east') as dst: assert (70, 46, 60, 36) == dst.page assert (10, 10) == dst.size - with raises(ValueError): - with Image(filename='rose:') as img: - with img.region(x=10, gravity='center') as _: - pass + with Image(filename='rose:') as img: + with img.region(x=10, gravity='center') as dst: + assert (70, 46, 10, 0) == dst.page + assert (60, 46) == dst.size def test_remap(): @@ -2155,9 +2153,9 @@ def test_splice(): was = img.signature img.splice(width=10, height=10, gravity='center') assert img.signature != was - with raises(ValueError): - with Image(filename='rose:') as img: - img.splice(width=10, height=10, x=10, gravity='center') + with Image(filename='rose:') as img: + img.splice(width=10, height=10, x=10, gravity='center') + assert (80, 56) == img.size def test_spread(): diff --git a/wand/image.py b/wand/image.py index cd57dc50..8b4bce55 100644 --- a/wand/image.py +++ b/wand/image.py @@ -3429,22 +3429,25 @@ def chop(self, width=None, height=None, x=None, y=None, gravity=None): .. versionchanged:: 0.6.12 Allow zero values for ``width`` & ``height`` arguments. + + .. versionchanged:: 0.7.0 + Allow ``x`` & ``y`` offset to apply relative to ``gravity``. """ + ow, oh = self.size if width is None: - width = self.width + width = ow if height is None: - height = self.height + height = oh assertions.assert_unsigned_integer(width=width, height=height) - if gravity is None: - if x is None: - x = 0 - if y is None: - y = 0 - else: - if x is not None or y is not None: - raise ValueError('x & y can not be used with gravity.') - y, x = self._gravity_to_offset(gravity, width, height) + if x is None: + x = 0 + if y is None: + y = 0 assertions.assert_integer(x=x, y=y) + if gravity is not None: + gy, gx = self._gravity_to_offset(gravity, width, height) + x += gx + y += gy return library.MagickChopImage(self.wand, width, height, x, y) @manipulative @@ -4026,26 +4029,27 @@ def composite(self, image, left=None, top=None, operator='over', .. versionchanged:: 0.5.3 Optional ``gravity`` argument was added. + + .. versionchanged:: 0.7.0 + Allow ``x`` & ``y`` offset to apply relative to ``gravity``. """ - if top is None and left is None: - if gravity is None: - gravity = self.gravity - top, left = self._gravity_to_offset(gravity, - image.width, - image.height) - elif gravity is not None: - raise TypeError('Can not use gravity if top & left are given') - elif top is None: + if top is None: top = 0 - elif left is None: + if left is None: left = 0 assertions.assert_integer(left=left, top=top) - try: - op = COMPOSITE_OPERATORS.index(operator) - except IndexError: - raise ValueError(repr(operator) + ' is an invalid composite ' - 'operator type; see wand.image.COMPOSITE_' - 'OPERATORS dictionary') + if gravity is None and self.gravity != 'forget': + gravity = self.gravity + if gravity is not None: + gtop, gleft = self._gravity_to_offset(gravity, + image.width, + image.height) + top += gtop + left += gleft + assertions.string_in_list(COMPOSITE_OPERATORS, + 'wand.image.COMPOSITE_OPERATORS', + operator=operator) + op = COMPOSITE_OPERATORS.index(operator) if arguments: assertions.assert_string(arguments=arguments) r = library.MagickSetImageArtifact(image.wand, @@ -4104,27 +4108,27 @@ def composite_channel(self, channel, image, operator, left=None, top=None, .. versionchanged:: 0.5.3 Optional ``gravity`` argument was added. + + .. versionchanged:: 0.7.0 + Allow ``x`` & ``y`` offset to apply relative to ``gravity``. """ assertions.assert_string(operator=operator) ch_const = self._channel_to_mask(channel) - if gravity: - if left is None and top is None: - top, left = self._gravity_to_offset(gravity, - image.width, - image.height) - else: - raise TypeError('Can not use gravity if top & left are given') if top is None: top = 0 if left is None: left = 0 assertions.assert_integer(left=left, top=top) - try: - op = COMPOSITE_OPERATORS.index(operator) - except IndexError: - raise IndexError(repr(operator) + ' is an invalid composite ' - 'operator type; see wand.image.COMPOSITE_' - 'OPERATORS dictionary') + if gravity is not None: + gtop, gleft = self._gravity_to_offset(gravity, + image.width, + image.height) + top += gtop + left += gleft + assertions.string_in_list(COMPOSITE_OPERATORS, + 'wand.image.COMPOSITE_OPERATORS', + operator=operator) + op = COMPOSITE_OPERATORS.index(operator) if arguments: assertions.assert_string(arguments=arguments) library.MagickSetImageArtifact(image.wand, @@ -4135,12 +4139,12 @@ def composite_channel(self, channel, image, operator, left=None, top=None, binary(arguments)) if library.MagickCompositeImageChannel: r = library.MagickCompositeImageChannel(self.wand, ch_const, - image.wand, op, int(left), - int(top)) + image.wand, op, left, + top) else: # pragma: no cover ch_mask = library.MagickSetImageChannelMask(self.wand, ch_const) r = library.MagickCompositeImage(self.wand, image.wand, op, True, - int(left), int(top)) + left, top) library.MagickSetImageChannelMask(self.wand, ch_mask) return r @@ -4583,18 +4587,20 @@ def crop(self, left=0, top=0, right=None, bottom=None, If you want to crop the image but not in-place, use slicing operator. - .. versionchanged:: 0.4.1 - Added ``gravity`` option. Using ``gravity`` along with - ``width`` & ``height`` to auto-adjust ``left`` & ``top`` - attributes. + .. versionadded:: 0.1.7 .. versionchanged:: 0.1.8 Made to raise :exc:`~exceptions.ValueError` instead of :exc:`~exceptions.IndexError` for invalid ``width``/``height`` arguments. - .. versionadded:: 0.1.7 + .. versionchanged:: 0.4.1 + Added ``gravity`` option. Using ``gravity`` along with + ``width`` & ``height`` to auto-adjust ``left`` & ``top`` + attributes. + .. versionchanged:: 0.7.0 + Allow ``x`` & ``y`` offset to apply relative to ``gravity``. """ if not (right is None or width is None): raise TypeError('parameters right and width are exclusive each ' @@ -4613,27 +4619,24 @@ def abs_(n, m, null=None): return m + n if n < 0 else n # Define left & top if gravity is given. - if gravity: - if width is None or height is None: - raise TypeError( - 'both width and height must be defined with gravity' - ) - top, left = self._gravity_to_offset(gravity, width, height) - else: - left = abs_(left, self.width, 0) - top = abs_(top, self.height, 0) - + ow, oh = self.size + left = abs_(left, ow, 0) + top = abs_(top, oh, 0) if width is None: - right = abs_(right, self.width) + right = abs_(right, ow) width = right - left if height is None: - bottom = abs_(bottom, self.height) + bottom = abs_(bottom, oh) height = bottom - top + if gravity is not None: + gtop, gleft = self._gravity_to_offset(gravity, width, height) + top += gtop + left += gleft assertions.assert_counting_number(width=width, height=height) if ( left == top == 0 and - width == self.width and - height == self.height + width == ow and + height == oh ): return True if self.animation: @@ -5114,22 +5117,25 @@ def extent(self, width=None, height=None, x=None, y=None, gravity=None): .. versionchanged:: 0.6.8 Added ``gravity`` argument. + + .. versionchanged:: 0.7.0 + Allow ``x`` & ``y`` offset to apply relative to ``gravity``. """ + ow, oh = self.size if width is None or width == 0: - width = self.width + width = ow if height is None or height == 0: - height = self.height + height = oh assertions.assert_unsigned_integer(width=width, height=height) - if gravity is None: - if x is None: - x = 0 - if y is None: - y = 0 - else: - if x is not None or y is not None: - raise ValueError('x & y can not be used with gravity.') - y, x = self._gravity_to_offset(gravity, width, height) + if x is None: + x = 0 + if y is None: + y = 0 assertions.assert_integer(x=x, y=y) + if gravity is not None: + gy, gx = self._gravity_to_offset(gravity, width, height) + x += gx + y += gy return library.MagickExtentImage(self.wand, width, height, x, y) def features(self, distance): @@ -7617,6 +7623,9 @@ def region(self, width=None, height=None, x=None, y=None, gravity=None): :rtype: :class:`Image` .. versionadded:: 0.6.8 + + .. versionchanged:: 0.7.0 + Allow ``x`` & ``y`` offset to apply relative to ``gravity``. """ ow, oh, ox, oy = self.page if width is None: @@ -7624,15 +7633,15 @@ def region(self, width=None, height=None, x=None, y=None, gravity=None): if height is None: height = oh assertions.assert_unsigned_integer(width=width, height=height) - if gravity is not None: - if x is not None or y is not None: - raise ValueError('x & y can not be used with gravity.') - y, x = self._gravity_to_offset(gravity, width, height) if x is None: x = ox if y is None: y = oy assertions.assert_integer(x=x, y=y) + if gravity is not None: + gy, gx = self._gravity_to_offset(gravity, width, height) + y += gy + x += gx new_wand = library.MagickGetImageRegion(self.wand, width, height, x, y) if not new_wand: self.raise_exception() @@ -8544,6 +8553,9 @@ def splice(self, width=None, height=None, x=None, y=None, gravity=None): :type y: :class:`numbers.Integral` .. versionadded:: 0.5.3 + + .. versionchanged:: 0.7.0 + Allow ``x`` & ``y`` offset to apply relative to ``gravity``. """ ow, oh = self.size if width is None: @@ -8551,16 +8563,15 @@ def splice(self, width=None, height=None, x=None, y=None, gravity=None): if height is None: height = oh assertions.assert_unsigned_integer(width=width, height=height) - if gravity is None: - if x is None: - x = 0 - if y is None: - y = 0 - else: - if x is not None or y is not None: - raise ValueError('x & y can not be used with gravity.') - y, x = self._gravity_to_offset(gravity, width, height) + if x is None: + x = 0 + if y is None: + y = 0 assertions.assert_integer(x=x, y=y) + if gravity is not None: + gy, gx = self._gravity_to_offset(gravity, width, height) + x += gx + y += gy return library.MagickSpliceImage(self.wand, width, height, x, y) @manipulative