diff --git a/modules/product_image/CHANGELOG b/modules/product_image/CHANGELOG index ab690e1a11..131d44a359 100644 --- a/modules/product_image/CHANGELOG +++ b/modules/product_image/CHANGELOG @@ -1,3 +1,4 @@ +* Preserve image ratio Version 7.0.0 - 2023-10-30 -------------------------- diff --git a/modules/product_image/doc/configuration.rst b/modules/product_image/doc/configuration.rst index 51dca7fa55..cca9febd9d 100644 --- a/modules/product_image/doc/configuration.rst +++ b/modules/product_image/doc/configuration.rst @@ -24,6 +24,16 @@ This value is only used when the `image_filestore The default value is: ``None`` +.. _config-product-image_size_max: + +``image_size_max`` +================== + +The is the maximal horizontal and vertical size in pixels for the stored +images. + +The default value is: ``2048`` + ``image_base`` ============== diff --git a/modules/product_image/doc/design.rst b/modules/product_image/doc/design.rst index 8fbe716bb8..64e9d8423e 100644 --- a/modules/product_image/doc/design.rst +++ b/modules/product_image/doc/design.rst @@ -12,4 +12,5 @@ Product Image The *Product Image* concept store images for a `Product `. The images are stored as `JPEGs `_ format -and with a maximal horizontal and vertical size of 2048 pixels. +and with a maximal horizontal and vertical size of `image_size_max +`. diff --git a/modules/product_image/product.py b/modules/product_image/product.py index 31062f5cd4..b355e509bc 100644 --- a/modules/product_image/product.py +++ b/modules/product_image/product.py @@ -22,6 +22,7 @@ if config.getboolean('product', 'image_filestore', default=False): else: file_id = None store_prefix = None +SIZE_MAX = config.getint('product', 'image_size_max', default=2048) URL_BASE = config.get('product', 'image_base', default='') URL_EXTERNAL_BASE = config.get('product', 'image_base', default=http_host()) @@ -157,15 +158,21 @@ class Image(ImageMixin, sequence_ordered(), ModelSQL, ModelView, MatchMixin): super().write(*args) cls.clear_cache(sum(args[0:None:2], [])) - def get(self, size=400): - size = min(( + @classmethod + def _round_size(cls, size): + return min(( 2 ** math.ceil(math.log2(size)), 10 * math.ceil(size / 10) if size <= 100 else 50 * math.ceil(size / 50))) - if not (0 < size <= 2048): - raise ValueError("Invalid size") + + def get(self, size=400): + if isinstance(size, int): + size = (size, size) + size = tuple(map(self._round_size, size)) + if not all(0 < s <= SIZE_MAX for s in size): + raise ValueError(f"Invalid size {size}") for cache in self.cache: - if cache.size == size: + if (cache.width, cache.height) == size: return cache.image with Transaction().new_transaction(): cache = self._store_cache(size, self._resize(size)) @@ -178,15 +185,7 @@ class Image(ImageMixin, sequence_ordered(), ModelSQL, ModelView, MatchMixin): def convert(cls, image, **_params): data = io.BytesIO() img = PIL.Image.open(io.BytesIO(image)) - width, height = img.size - size = min(width, height) - img = img.crop(( - (width - size) // 2, - (height - size) // 2, - (width + size) // 2, - (height + size) // 2)) - if size > 2048: - img = img.resize((2048, 2048)) + img.thumbnail((SIZE_MAX, SIZE_MAX)) if img.mode in {'RGBA', 'P'}: img = img.convert('RGB') img.save(data, format='jpeg', optimize=True, **_params) @@ -195,17 +194,24 @@ class Image(ImageMixin, sequence_ordered(), ModelSQL, ModelView, MatchMixin): def _resize(self, size=64, **_params): data = io.BytesIO() img = PIL.Image.open(io.BytesIO(self.image)) - img = img.resize((size, size)) + if isinstance(size, int): + size = (size, size) + img.thumbnail(size) img.save(data, format='jpeg', optimize=True, **_params) return data.getvalue() def _store_cache(self, size, image): pool = Pool() Cache = pool.get('product.image.cache') + if isinstance(size, int): + width = height = size + else: + width, height = size return Cache( product_image=self, image=image, - size=size) + width=width, + height=height) @classmethod def clear_cache(cls, images): @@ -220,19 +226,40 @@ class ImageCache(ImageMixin, ModelSQL): __name__ = 'product.image.cache' product_image = fields.Many2One( 'product.image', "Product Image", required=True, ondelete='CASCADE') - size = fields.Integer( - "Size", required=True, + width = fields.Integer( + "Width", required=True, domain=[ - ('size', '>', 0), - ('size', '<=', 2048), + ('width', '>', 0), + ('width', '<=', SIZE_MAX), + ]) + height = fields.Integer( + "Height", required=True, + domain=[ + ('height', '>', 0), + ('height', '<=', SIZE_MAX), ]) @classmethod def __setup__(cls): super().__setup__() t = cls.__table__() - cls._sql_constraints = [ - ('size_unique', Unique(t, t.product_image, t.size), + cls._sql_constraints += [ + ('dimension_unique', Unique(t, t.product_image, t.width, t.height), 'ir.msg_product_image_size_unique'), ] - cls._order.append(('size', 'ASC')) + + @classmethod + def __register__(cls, module): + cursor = Transaction().connection.cursor() + table = cls.__table__() + table_h = cls.__table_handler__(module) + + super().__register__(module) + + # Migration from 7.0: split size into width and height + table_h.drop_constraint('size_unique') + if table_h.column_exist('size'): + cursor.execute(*table.update( + [table.width, table.height], + [table.size, table.size])) + table_h.drop_column('size') diff --git a/modules/product_image/routes.py b/modules/product_image/routes.py index 3d9d10fbb8..67f90898c0 100644 --- a/modules/product_image/routes.py +++ b/modules/product_image/routes.py @@ -62,7 +62,16 @@ def _image(request, pool, record): size = int(request.args.get('s', 400)) except ValueError: abort(HTTPStatus.BAD_REQUEST) - response = Response(image.get(size), mimetype='image/jpeg') + try: + width = int(request.args.get('w', size)) + except ValueError: + abort(HTTPStatus.BAD_REQUEST) + try: + height = int(request.args.get('h', size)) + except ValueError: + abort(HTTPStatus.BAD_REQUEST) + + response = Response(image.get((width, height)), mimetype='image/jpeg') response.headers['Cache-Control'] = ( 'max-age=%s, public' % TIMEOUT) response.add_etag() diff --git a/modules/product_image/tests/test_module.py b/modules/product_image/tests/test_module.py index 9a670ba642..6e41f14600 100644 --- a/modules/product_image/tests/test_module.py +++ b/modules/product_image/tests/test_module.py @@ -14,8 +14,8 @@ class ProductImageTestCase(ModuleTestCase): module = 'product_image' @with_transaction() - def test_image(self): - "Test image" + def test_image_square(self): + "Test image square" pool = Pool() Image = pool.get('product.image') Template = pool.get('product.template') @@ -40,6 +40,55 @@ class ProductImageTestCase(ModuleTestCase): img = PIL.Image.open(io.BytesIO(image.get(size=100))) self.assertEqual(img.size, (100, 100)) + img = PIL.Image.open(io.BytesIO(image.get(size=(150, 200)))) + self.assertEqual(img.size, (150, 150)) + + @with_transaction() + def test_image_non_square(self): + "Test image non square" + pool = Pool() + Image = pool.get('product.image') + Template = pool.get('product.template') + Uom = pool.get('product.uom') + + template = Template(name="Template") + template.default_uom, = Uom.search([], limit=1) + template.save() + + image = Image(template=template) + image.image = urllib.request.urlopen( + 'https://picsum.photos/200/400').read() + image.save() + + self.assertEqual(template.image_url, None) + template.code = "CODE" + template.save() + self.assertRegex( + template.image_url, + r'/product/image/CODE/.*/Template\?s=64') + + img = PIL.Image.open(io.BytesIO(image.get(size=100))) + self.assertEqual(img.size, (50, 100)) + + img = PIL.Image.open(io.BytesIO(image.get(size=(100, 200)))) + self.assertEqual(img.size, (100, 200)) + + @with_transaction() + def test_round_size(self): + "Test round size" + pool = Pool() + Image = pool.get('product.image') + for size, result in [ + (1, 1), + (2, 2), + (19, 20), + (45, 50), + (101, 128), + (129, 150), + ]: + with self.subTest(size=size): + self.assertEqual(Image._round_size(size), result) + @with_transaction() def test_get_image_url(self): "Test get_image_url"