@@ -1,3 +1,4 @@
|
||||
* Preserve image ratio
|
||||
|
||||
Version 7.0.0 - 2023-10-30
|
||||
--------------------------
|
||||
|
||||
@@ -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``
|
||||
==============
|
||||
|
||||
|
||||
@@ -12,4 +12,5 @@ Product Image
|
||||
The *Product Image* concept store images for a `Product
|
||||
<product:concept-product>`.
|
||||
The images are stored as `JPEGs <https://en.wikipedia.org/wiki/JPEG>`_ format
|
||||
and with a maximal horizontal and vertical size of 2048 pixels.
|
||||
and with a maximal horizontal and vertical size of `image_size_max
|
||||
<config-product-image_size_max>`.
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user