From d303518841a2d51d787630d1292918d682b489c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Theys?= Date: Fri, 26 Apr 2019 10:55:07 +0000 Subject: [PATCH 01/16] [IMP] tools,base: remove image encoding parameter The image tools were accepting an encoding parameter but it was never used. Indeed the given images are always encoded in base64. `image_colorize` was the only method not taking base64 directly, so it has been modified for consistency. This allows to change the code in partner to not base64 decode the image in some case just to be base64 encode it again after. `crop_image` and `limited_image_resize` have been slightly refactored to account for this change, but in this commit their behavior was kept. task-1958000 PR: #31811 --- odoo/addons/base/models/res_partner.py | 20 +-- odoo/tools/image.py | 174 +++++++++++++++---------- 2 files changed, 114 insertions(+), 80 deletions(-) diff --git a/odoo/addons/base/models/res_partner.py b/odoo/addons/base/models/res_partner.py index 49979b9e2c5..6ac46332653 100644 --- a/odoo/addons/base/models/res_partner.py +++ b/odoo/addons/base/models/res_partner.py @@ -328,29 +328,29 @@ class Partner(models.Model): if getattr(threading.currentThread(), 'testing', False) or self._context.get('install_mode'): return False - colorize, img_path, image = False, False, False + colorize, img_path, image_base64 = False, False, False if partner_type in ['other'] and parent_id: parent_image = self.browse(parent_id).image - image = parent_image and base64.b64decode(parent_image) or None + image_base64 = parent_image or None - if not image and partner_type == 'invoice': + if not image_base64 and partner_type == 'invoice': img_path = get_module_resource('base', 'static/img', 'money.png') - elif not image and partner_type == 'delivery': + elif not image_base64 and partner_type == 'delivery': img_path = get_module_resource('base', 'static/img', 'truck.png') - elif not image and is_company: + elif not image_base64 and is_company: img_path = get_module_resource('base', 'static/img', 'company_image.png') - elif not image: + elif not image_base64: img_path = get_module_resource('base', 'static/img', 'avatar.png') colorize = True if img_path: with open(img_path, 'rb') as f: - image = f.read() - if image and colorize: - image = tools.image_colorize(image) + image_base64 = base64.b64encode(f.read()) + if image_base64 and colorize: + image_base64 = tools.image_colorize(image_base64) - return tools.image_resize_image_big(base64.b64encode(image)) + return tools.image_resize_image_big(image_base64) @api.model def _fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): diff --git a/odoo/tools/image.py b/odoo/tools/image.py index 137f84d1ab4..8a865b908c2 100644 --- a/odoo/tools/image.py +++ b/odoo/tools/image.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import base64 -import codecs import io from PIL import Image @@ -31,7 +30,7 @@ IMAGE_SMALL_SIZE = (64, 64) # Image resizing # ---------------------------------------- -def image_resize_image(base64_source, size=IMAGE_BIG_SIZE, encoding='base64', filetype=None, avoid_if_small=False, preserve_aspect_ratio=False, upper_limit=False): +def image_resize_image(base64_source, size=IMAGE_BIG_SIZE, filetype=None, avoid_if_small=False, preserve_aspect_ratio=False, upper_limit=False): """ Function to resize an image. The image will be resized to the given size, while keeping the aspect ratios, and holes in the image will be filled with transparent background. The image will not be stretched if @@ -56,7 +55,6 @@ def image_resize_image(base64_source, size=IMAGE_BIG_SIZE, encoding='base64', fi :param size: 2-tuple(width, height). A None value for any of width or height mean an automatically computed value based respectively on height or width of the source image. - :param encoding: the output encoding :param filetype: the output filetype, by default the source image's :type filetype: str, any PIL image format (supported for creation) :param avoid_if_small: do not resize if image height and width @@ -69,8 +67,7 @@ def image_resize_image(base64_source, size=IMAGE_BIG_SIZE, encoding='base64', fi # harmless for these purposes if size == (None, None) or base64_source[:1] == b'P': return base64_source - image_stream = io.BytesIO(codecs.decode(base64_source, encoding)) - image = Image.open(image_stream) + image = base64_to_image(base64_source) # store filetype here, as Image.new below will lose image.format filetype = (filetype or image.format).upper() @@ -106,9 +103,7 @@ def image_resize_image(base64_source, size=IMAGE_BIG_SIZE, encoding='base64', fi if image.mode not in ["1", "L", "P", "RGB", "RGBA"] or (filetype == 'JPEG' and image.mode == 'RGBA'): image = image.convert("RGB") - background_stream = io.BytesIO() - image.save(background_stream, filetype) - return codecs.encode(background_stream.getvalue(), encoding) + return image_to_base64(image, filetype) def image_resize_and_sharpen(image, size, preserve_aspect_ratio=False, factor=2.0, upper_limit=False): @@ -170,45 +165,45 @@ def image_save_for_web(image, fp=None, format=None): return img.getvalue() -def image_resize_image_big(base64_source, encoding='base64', filetype=None, avoid_if_small=True, preserve_aspect_ratio=False, upper_limit=False): +def image_resize_image_big(base64_source, filetype=None, avoid_if_small=True, preserve_aspect_ratio=False, upper_limit=False): """ Wrapper on image_resize_image, to resize images larger than the standard 'big' image size: 1024x1024px. Refer to image_resize_image for the parameters. """ - return image_resize_image(base64_source, IMAGE_BIG_SIZE, encoding, filetype, avoid_if_small, preserve_aspect_ratio, upper_limit) + return image_resize_image(base64_source, IMAGE_BIG_SIZE, filetype, avoid_if_small, preserve_aspect_ratio, upper_limit) -def image_resize_image_large(base64_source, encoding='base64', filetype=None, avoid_if_small=True, preserve_aspect_ratio=False, upper_limit=False): +def image_resize_image_large(base64_source, filetype=None, avoid_if_small=True, preserve_aspect_ratio=False, upper_limit=False): """ Wrapper on image_resize_image, to resize to the standard 'large' image size: 256x256. Refer to image_resize_image for the parameters. """ - return image_resize_image(base64_source, IMAGE_LARGE_SIZE, encoding, filetype, avoid_if_small, preserve_aspect_ratio, upper_limit) + return image_resize_image(base64_source, IMAGE_LARGE_SIZE, filetype, avoid_if_small, preserve_aspect_ratio, upper_limit) -def image_resize_image_medium(base64_source, encoding='base64', filetype=None, avoid_if_small=False, preserve_aspect_ratio=False, upper_limit=False): +def image_resize_image_medium(base64_source, filetype=None, avoid_if_small=False, preserve_aspect_ratio=False, upper_limit=False): """ Wrapper on image_resize_image, to resize to the standard 'medium' image size: 128x128. Refer to image_resize_image for the parameters. """ - return image_resize_image(base64_source, IMAGE_MEDIUM_SIZE, encoding, filetype, avoid_if_small, preserve_aspect_ratio, upper_limit) + return image_resize_image(base64_source, IMAGE_MEDIUM_SIZE, filetype, avoid_if_small, preserve_aspect_ratio, upper_limit) -def image_resize_image_small(base64_source, encoding='base64', filetype=None, avoid_if_small=False, preserve_aspect_ratio=False, upper_limit=False): +def image_resize_image_small(base64_source, filetype=None, avoid_if_small=False, preserve_aspect_ratio=False, upper_limit=False): """ Wrapper on image_resize_image, to resize to the standard 'small' image size: 64x64. Refer to image_resize_image for the parameters. """ - return image_resize_image(base64_source, IMAGE_SMALL_SIZE, encoding, filetype, avoid_if_small, preserve_aspect_ratio, upper_limit) + return image_resize_image(base64_source, IMAGE_SMALL_SIZE, filetype, avoid_if_small, preserve_aspect_ratio, upper_limit) # ---------------------------------------- # Crop Image # ---------------------------------------- -def crop_image(data, type='top', ratio=None, size=None, image_format=None): +def crop_image(base64_source, type='top', ratio=None, size=None, image_format=None): """ Used for cropping image and create thumbnail - :param data: base64 data of image. + :param base64_source: the image base64 encoded :param type: Used for cropping position possible Possible Values : 'top', 'center', 'bottom' :param ratio: Cropping ratio @@ -219,11 +214,10 @@ def crop_image(data, type='top', ratio=None, size=None, image_format=None): after crop resize to 200x200 thumbnail :param image_format: return image format PNG,JPEG etc """ - if not data: + if not base64_source: return False - image_stream = Image.open(io.BytesIO(base64.b64decode(data))) - output_stream = io.BytesIO() - w, h = image_stream.size + image = base64_to_image(base64_source) + w, h = image.size new_h = h new_w = w @@ -235,68 +229,91 @@ def crop_image(data, type='top', ratio=None, size=None, image_format=None): new_h = h new_w = (h * w_ratio) // h_ratio - image_format = image_format or image_stream.format or 'JPEG' + image_format = image_format or image.format or 'JPEG' if type == "top": - cropped_image = image_stream.crop((0, 0, new_w, new_h)) - cropped_image.save(output_stream, format=image_format) + box = (0, 0, new_w, new_h) elif type == "center": - cropped_image = image_stream.crop(((w - new_w) // 2, (h - new_h) // 2, (w + new_w) // 2, (h + new_h) // 2)) - cropped_image.save(output_stream, format=image_format) + box = ((w - new_w) // 2, (h - new_h) // 2, (w + new_w) // 2, (h + new_h) // 2) elif type == "bottom": - cropped_image = image_stream.crop((0, h - new_h, new_w, h)) - cropped_image.save(output_stream, format=image_format) + box = (0, h - new_h, new_w, h) else: raise ValueError('ERROR: invalid value for crop_type') + + image = image.crop(box) + if size: - thumbnail = Image.open(io.BytesIO(output_stream.getvalue())) - output_stream.truncate(0) - output_stream.seek(0) - thumbnail.thumbnail(size, Image.ANTIALIAS) - thumbnail.save(output_stream, image_format) - return base64.b64encode(output_stream.getvalue()) + image.thumbnail(size, Image.ANTIALIAS) + + return image_to_base64(image, image_format) # ---------------------------------------- # Colors # --------------------------------------- -def image_colorize(original, randomize=True, color=(255, 255, 255)): +def image_colorize(base64_source, randomize=True, color=(255, 255, 255)): """ Add a color to the transparent background of an image. - :param original: file object on the original image file + :param base64_source: the image base64 encoded :param randomize: randomize the background color :param color: background-color, if not randomize """ # create a new image, based on the original one - original = Image.open(io.BytesIO(original)) + original = base64_to_image(base64_source) image = Image.new('RGB', original.size) # generate the background color, past it as background if randomize: color = (randrange(32, 224, 24), randrange(32, 224, 24), randrange(32, 224, 24)) image.paste(color, box=(0, 0) + original.size) image.paste(original, mask=original) - # return the new image - buffer = io.BytesIO() - image.save(buffer, 'PNG') - return buffer.getvalue() + return image_to_base64(image, original.format) # ---------------------------------------- # Misc image tools # --------------------------------------- -def is_image_size_above(base64_source, encoding='base64', size=IMAGE_BIG_SIZE): +def base64_to_image(base64_source): + """Return a PIL image from the given `base64_source`. + + :param base64_source: the image base64 encoded + :type base64_source: string or bytes + + :return: the PIL image + :rtype: PIL.Image + + :raise: binascii.Error: if the base64 is incorrect + :raise: OSError if the image can't be identified by PIL + """ + return Image.open(io.BytesIO(base64.b64decode(base64_source))) + + +def image_to_base64(image, format, **params): + """Return a base64_image from the given PIL `image` using `params`. + + :param image: the PIL image + :type image: PIL.Image + + :param params: params to expand when calling PIL.Image.save() + :type params: dict + + :return: the image base64 encoded + :rtype: bytes + """ + stream = io.BytesIO() + image.save(stream, format=format, **params) + return base64.b64encode(stream.getvalue()) + + +def is_image_size_above(base64_source, size=IMAGE_BIG_SIZE): """Return whether or not the size of the given image `base64_source` is above the provided `size` (tuple: width, height). """ if not base64_source: return False - if isinstance(base64_source, str): - base64_source = base64_source.encode('ascii') if base64_source[:1] == b'P': # False for SVG return False - image_stream = io.BytesIO(codecs.decode(base64_source, encoding)) - image = Image.open(image_stream) + image = base64_to_image(base64_source) width, height = image.size return width > size[0] or height > size[1] @@ -318,8 +335,6 @@ def image_get_resized_images(base64_source, previous parameters. """ return_dict = dict() - if isinstance(base64_source, str): - base64_source = base64_source.encode('ascii') if big_name: return_dict[big_name] = image_resize_image_big(base64_source, avoid_if_small=avoid_resize_big, preserve_aspect_ratio=preserve_aspect_ratio, upper_limit=upper_limit) if large_name: @@ -361,28 +376,47 @@ def image_resize_images(vals, vals[small_name] = False -def limited_image_resize(content, width=None, height=None, crop=False, upper_limit=False, avoid_if_small=False): +def limited_image_resize(base64_source, width=None, height=None, crop=False, upper_limit=False, avoid_if_small=False): + """Return the given `base64_source` image resized to the given `width` and + `height`. Return the original image if both `width` and `height` are fasly. + + :param base64_source: the image base64 encoded + :type base64_source: string or bytes + + :param width: target width, or 0 to keep aspect ratio if height given + :type width: int + + :param height: target height, or 0 to keep aspect ratio if width given + :type height: int + + :param crop: True if the result should be cropped to a square. + False to resize without cropping, then the result will be controlled + by `upper_limit` and `avoid_if_small` + :type crop: bool + + :param upper_limit: value sent to `image_resize_image` if `crop` is False + :type upper_limit: bool + + :param avoid_if_small: value sent to `image_resize_image` if `crop` is False + :type avoid_if_small: bool + + :return: resulting image base64 encoded + :rtype: string or bytes """ - :param content: bytes (should be an image) - """ - if content and (width or height): - signatures = [b'\xFF\xD8\xFF', b'\x89PNG\r\n\x1A\n'] - decoded_content = base64.b64decode(content) - is_image = any(decoded_content.startswith(signature) for signature in signatures) - if is_image: - height = int(height or 0) - width = int(width or 0) - if crop: - return crop_image(content, type='center', size=(width, height), ratio=(1, 1)) - else: - if not upper_limit: - # resize maximum 500*500 - width = min(width, 500) - height = min(height, 500) - return image_resize_image( - base64_source=content, size=(width or None, height or None), encoding='base64', upper_limit=upper_limit, - avoid_if_small=avoid_if_small) - return content + if base64_source and (width or height): + height = int(height or 0) + width = int(width or 0) + if crop: + return crop_image(base64_source, type='center', size=(width or None, height or None), ratio=(1, 1)) + else: + if not upper_limit: + # resize maximum 500*500 + width = min(width, 500) + height = min(height, 500) + return image_resize_image( + base64_source=base64_source, size=(width or None, height or None), upper_limit=upper_limit, + avoid_if_small=avoid_if_small) + return base64_source def image_data_uri(base64_source): From d61e9d940f13c699996e358a516e144505823544 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Theys?= Date: Fri, 26 Apr 2019 10:55:09 +0000 Subject: [PATCH 02/16] [IMP] tools: rename the image filter ANTIALIAS to LANCZOS This is the new name since Pillow 2.7.0. task-1958000 PR: #31811 --- odoo/tools/image.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/odoo/tools/image.py b/odoo/tools/image.py index 8a865b908c2..c08b35d0b1b 100644 --- a/odoo/tools/image.py +++ b/odoo/tools/image.py @@ -119,7 +119,7 @@ def image_resize_and_sharpen(image, size, preserve_aspect_ratio=False, factor=2. origin_mode = image.mode if image.mode != 'RGBA': image = image.convert('RGBA') - image.thumbnail(size, Image.ANTIALIAS) + image.thumbnail(size, Image.LANCZOS) if preserve_aspect_ratio: size = image.size sharpener = ImageEnhance.Sharpness(image) @@ -242,7 +242,7 @@ def crop_image(base64_source, type='top', ratio=None, size=None, image_format=No image = image.crop(box) if size: - image.thumbnail(size, Image.ANTIALIAS) + image.thumbnail(size, Image.LANCZOS) return image_to_base64(image, image_format) From 8269f855136050c9139352ed71bc75f81899e255 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Theys?= Date: Fri, 26 Apr 2019 10:55:11 +0000 Subject: [PATCH 03/16] [IMP] tools: remove the "ghost border fix" from image tools We can't reproduce the issue it was trying to solve. task-1958000 PR: #31811 --- odoo/tools/image.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/odoo/tools/image.py b/odoo/tools/image.py index c08b35d0b1b..16177831445 100644 --- a/odoo/tools/image.py +++ b/odoo/tools/image.py @@ -99,14 +99,14 @@ def image_resize_image(base64_source, size=IMAGE_BIG_SIZE, filetype=None, avoid_ return base64_source if image.size != size: - image = image_resize_and_sharpen(image, size, preserve_aspect_ratio=preserve_aspect_ratio, upper_limit=upper_limit) + image = image_resize_and_sharpen(image, size, preserve_aspect_ratio=preserve_aspect_ratio) if image.mode not in ["1", "L", "P", "RGB", "RGBA"] or (filetype == 'JPEG' and image.mode == 'RGBA'): image = image.convert("RGB") return image_to_base64(image, filetype) -def image_resize_and_sharpen(image, size, preserve_aspect_ratio=False, factor=2.0, upper_limit=False): +def image_resize_and_sharpen(image, size, preserve_aspect_ratio=False, factor=2.0): """ Create a thumbnail by resizing while keeping ratio. A sharpen filter is applied for a better looking result. @@ -125,10 +125,7 @@ def image_resize_and_sharpen(image, size, preserve_aspect_ratio=False, factor=2. sharpener = ImageEnhance.Sharpness(image) resized_image = sharpener.enhance(factor) # create a transparent image for background and paste the image on it - if upper_limit: - image = Image.new('RGBA', (size[0], size[1]-3), (255, 255, 255, 0)) # FIXME temporary fix for trimming the ghost border. - else: - image = Image.new('RGBA', size, (255, 255, 255, 0)) + image = Image.new('RGBA', size, (255, 255, 255, 0)) image.paste(resized_image, ((size[0] - resized_image.size[0]) // 2, (size[1] - resized_image.size[1]) // 2)) if image.mode != origin_mode: From 433d0070755b9917e46827c2ad1c258cfe31f804 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Theys?= Date: Fri, 26 Apr 2019 10:55:13 +0000 Subject: [PATCH 04/16] [IMP] tools,web_editor,web_unsplash: improve image optimize for web This commit introduces the possibility to resize and to select the quality when optimizing an image for web. This will be used by the media dialog in a future commit. The arbitrary resolution limit of 42e6 has been increased to 45e6 to fit some of the biggest images we might get from Unsplash, and this value has been moved into a variable so it can be customized. The mimetype computation has been moved after the image processing because in a next commit the operation could return the image in a different format than what was originally given. Eg. BMP -> PNG, or unsupported format -> JPEG. task-1958000 PR: #31811 --- addons/web_editor/controllers/main.py | 24 +++------ addons/web_unsplash/controllers/main.py | 20 +++----- odoo/tools/image.py | 67 +++++++++++++++++++------ 3 files changed, 66 insertions(+), 45 deletions(-) diff --git a/addons/web_editor/controllers/main.py b/addons/web_editor/controllers/main.py index 8360161fb3d..d4542f8505d 100644 --- a/addons/web_editor/controllers/main.py +++ b/addons/web_editor/controllers/main.py @@ -158,27 +158,17 @@ class Web_Editor(http.Controller): #------------------------------------------------------ @http.route('/web_editor/add_image_base64', type='json', auth='user', methods=['POST'], website=True) def add_image_base64(self, res_model, res_id, image_base64, filename, disable_optimization=None, **kwargs): - data = base64.b64decode(image_base64) - attachment = self._image_to_attachment(res_model, res_id, data, filename, filename, disable_optimization=disable_optimization) + attachment = self._image_to_attachment(res_model, res_id, image_base64, filename, filename, disable_optimization=disable_optimization) return attachment.read(['name', 'mimetype', 'checksum', 'url', 'res_id', 'res_model', 'access_token'])[0] - def _image_to_attachment(self, res_model, res_id, data, name, datas_fname, disable_optimization=None): + def _image_to_attachment(self, res_model, res_id, image_base64, name, datas_fname, disable_optimization=None): Attachments = request.env['ir.attachment'] - try: - image = Image.open(io.BytesIO(data)) - w, h = image.size - if w*h > 42e6: # Nokia Lumia 1020 photo resolution - raise ValueError( - u"Image size excessive, uploaded images must be smaller " - u"than 42 million pixel") - if not disable_optimization and image.format in ('PNG', 'JPEG'): - data = tools.image_save_for_web(image) - except IOError: - pass + if not disable_optimization: + image_base64 = tools.image_optimize_for_web(image_base64) attachment = Attachments.create({ 'name': name, 'datas_fname': datas_fname, - 'datas': base64.b64encode(data), + 'datas': image_base64, 'public': res_model == 'ir.ui.view', 'res_id': res_id, 'res_model': res_model, @@ -224,12 +214,12 @@ class Web_Editor(http.Controller): try: attachments = request.env['ir.attachment'] for c_file in request.httprequest.files.getlist('upload'): - data = c_file.read() + image_base64 = base64.b64encode(c_file.read()) name = c_file.filename datas_fname = name if filters: datas_fname = filters + '_' + datas_fname - attachments += self._image_to_attachment(res_model, res_id, data, name, datas_fname, disable_optimization=disable_optimization) + attachments += self._image_to_attachment(res_model, res_id, image_base64, name, datas_fname, disable_optimization=disable_optimization) uploads += attachments.read(['name', 'mimetype', 'checksum', 'url', 'res_id', 'res_model', 'access_token']) except Exception as e: logger.exception("Failed to upload image to attachment") diff --git a/addons/web_unsplash/controllers/main.py b/addons/web_unsplash/controllers/main.py index 219342d1c39..6cd0ef9e82b 100644 --- a/addons/web_unsplash/controllers/main.py +++ b/addons/web_unsplash/controllers/main.py @@ -1,14 +1,14 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import base64 -import io import logging +import mimetypes import requests import werkzeug.utils -from PIL import Image from odoo import http, tools, _ from odoo.http import request +from odoo.tools.mimetypes import guess_mimetype from werkzeug.urls import url_encode logger = logging.getLogger(__name__) @@ -89,8 +89,7 @@ class Web_Unsplash(http.Controller): continue # get mime-type of image url because unsplash url dosn't contains mime-types in url - mimetype = req.headers.get('Content-Type') - datas = req.content + image_base64 = base64.b64encode(req.content) except requests.exceptions.ConnectionError as e: logger.exception("Connection Error: " + str(e)) continue @@ -98,13 +97,10 @@ class Web_Unsplash(http.Controller): logger.exception("Timeout: " + str(e)) continue - # optimized image before save - if mimetype in ('image/jpeg', 'image/png'): - image = Image.open(io.BytesIO(datas)) - if image.format in ('PNG', 'JPEG'): - datas = tools.image_save_for_web(image) - # append image extension in name - query += '.' + str.lower(image.format) + image_base64 = tools.image_optimize_for_web(image_base64) + mimetype = guess_mimetype(base64.b64decode(image_base64)) + # append image extension in name + query += mimetypes.guess_extension(mimetype) or '' # /unsplash/5gR788gfd/lion url_frags = ['unsplash', key, query] @@ -114,7 +110,7 @@ class Web_Unsplash(http.Controller): 'url': '/' + '/'.join(url_frags), 'datas_fname': '_'.join(url_frags), 'mimetype': mimetype, - 'datas': base64.b64encode(datas), + 'datas': image_base64, 'public': res_model == 'ir.ui.view', 'res_id': res_id, 'res_model': res_model, diff --git a/odoo/tools/image.py b/odoo/tools/image.py index 16177831445..e1a3b0e0328 100644 --- a/odoo/tools/image.py +++ b/odoo/tools/image.py @@ -7,6 +7,9 @@ from PIL import Image from PIL import ImageEnhance from random import randrange +from odoo.tools.translate import _ + + # Preload PIL with the minimal subset of image formats we need Image.preinit() Image._initialized = 2 @@ -25,6 +28,10 @@ IMAGE_LARGE_SIZE = (256, 256) IMAGE_MEDIUM_SIZE = (128, 128) IMAGE_SMALL_SIZE = (64, 64) +# Arbitraty limit to fit most resolutions, including Nokia Lumia 1020 photo, +# 8K with a ratio up to 16:10, and almost all variants of 4320p +IMAGE_MAX_RESOLUTION = 45e6 + # ---------------------------------------- # Image resizing @@ -133,17 +140,44 @@ def image_resize_and_sharpen(image, size, preserve_aspect_ratio=False, factor=2. return image -def image_save_for_web(image, fp=None, format=None): - """ - Save image optimized for web usage. +def image_optimize_for_web(base64_source, max_width=0, quality=80): + """Return the given `base64_source` optimized for web usage. - :param image: PIL.Image.Image() - :param fp: File name or file object. If not specified, a bytestring is returned. - :param format: File format if could not be deduced from image. + :param base64_source: the image base64 encoded + :type base64_source: string or bytes + + :param max_width: max width for the image. No resize if 0 given or if + the image is already smaller than the given value. + :type max_width: int + + :param quality: quality setting to apply. Ignored if image is not JPEG. + 1 is worse, 100 is best. Default to 80. + Set to 95 to keep the original quality. + :type quality: int + + :return: optimized image base64 encoded + :rtype: bytes + + :raise: ValueError if image too large """ - opt = dict(format=image.format or format) - if image.format == 'PNG': - opt.update(optimize=True) + if base64_source[:1] == b'P': + # don't process SVG + return base64_source + + image = base64_to_image(base64_source) + + w, h = image.size + if w * h > IMAGE_MAX_RESOLUTION: + raise ValueError(_("Image size excessive, uploaded images must be smaller than %s million pixels.") % str(IMAGE_MAX_RESOLUTION / 10e6)) + + # get the format of the original image (must be done before resize) + opt = {'format': image.format} + + if max_width and w and max_width < w: + image.thumbnail((max_width, (h * max_width) / w)) + + if opt['format'] == 'PNG': + opt['optimize'] = True alpha = False if image.mode in ('RGBA', 'LA') or (image.mode == 'P' and 'transparency' in image.info): alpha = image.convert('RGBA').split()[-1] @@ -152,14 +186,15 @@ def image_save_for_web(image, fp=None, format=None): image = image.convert('RGBA').convert('P', palette=Image.WEB, colors=256) if alpha: image.putalpha(alpha) - elif image.format == 'JPEG': - opt.update(optimize=True, quality=80) - if fp: - image.save(fp, **opt) + elif opt['format'] == 'JPEG': + opt['optimize'] = True + opt['quality'] = quality + elif opt['format'] == 'GIF': + opt['optimize'] = True else: - img = io.BytesIO() - image.save(img, **opt) - return img.getvalue() + raise ValueError(_("Unsupported image format: %s. Only JPEG, PNG and GIF are supported.") % image.format) + + return image_to_base64(image, **opt) def image_resize_image_big(base64_source, filetype=None, avoid_if_small=True, preserve_aspect_ratio=False, upper_limit=False): From 2864f8a6cf502aeebc61ca0145f918fc14c25cc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Theys?= Date: Fri, 26 Apr 2019 10:55:17 +0000 Subject: [PATCH 05/16] [IMP] base: add tests for the image tools task-1958000 PR: #31811 --- odoo/addons/base/tests/__init__.py | 1 + odoo/addons/base/tests/test_image.py | 270 +++++++++++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 odoo/addons/base/tests/test_image.py diff --git a/odoo/addons/base/tests/__init__.py b/odoo/addons/base/tests/__init__.py index 281aec864c8..bba0d5b6742 100644 --- a/odoo/addons/base/tests/__init__.py +++ b/odoo/addons/base/tests/__init__.py @@ -7,6 +7,7 @@ from . import test_db_cursor from . import test_expression from . import test_float from . import test_func +from . import test_image from . import test_ir_actions from . import test_ir_attachment from . import test_ir_http diff --git a/odoo/addons/base/tests/test_image.py b/odoo/addons/base/tests/test_image.py new file mode 100644 index 00000000000..58ed6c245e1 --- /dev/null +++ b/odoo/addons/base/tests/test_image.py @@ -0,0 +1,270 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import base64 +import binascii +import PIL + +from odoo import tools +from odoo.tests.common import TransactionCase + + +class TestImage(TransactionCase): + """Tests for the different image tools helpers. + + The following helpers are not tested here because they are wrappers for the + other methods or they are tested elsewhere (eg. on TestWebsiteSaleImage): + - image_resize_image_big + - image_resize_image_large + - image_resize_image_medium + - image_resize_image_small + - image_get_resized_images + - image_resize_images + - image_resize_and_sharpen + - is_image_size_above + """ + def setUp(self): + super(TestImage, self).setUp() + # This is the base64 of a 1px * 1px image saved as PNG. + self.base64_image_1x1 = b'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYGAAAAAEAAH2FzhVAAAAAElFTkSuQmCC' + self.base64_svg = base64.b64encode(b'') + self.base64_image_1920_1080 = tools.image_to_base64(PIL.Image.new('RGB', (1920, 1080)), 'JPEG') + + def test_00_base64_to_image(self): + """Test that base64 is correctly opened as a PIL image.""" + + # CASE: base64 as bytes + image = tools.base64_to_image(self.base64_image_1x1) + self.assertEqual(type(image), PIL.PngImagePlugin.PngImageFile) + self.assertEqual(image.size, (1, 1)) + + # CASE: base64 as string + image = tools.base64_to_image(self.base64_image_1x1.decode('ASCII')) + self.assertEqual(type(image), PIL.PngImagePlugin.PngImageFile) + self.assertEqual(image.size, (1, 1)) + + # CASE: wrong base64: binascii.Error: Incorrect padding + with self.assertRaises(binascii.Error): + image = tools.base64_to_image(b'oazdazpodazdpok') + + # CASE: wrong base64: OSError: cannot identify image file + with self.assertRaises(OSError): + image = tools.base64_to_image(b'oazdazpodazdpokd') + + def test_01_image_to_base64(self): + """Test that a PIL image is correctly saved as base64.""" + image = PIL.Image.new('RGB', (1, 1)) + image_base64 = tools.image_to_base64(image, 'PNG') + self.assertEqual(image_base64, self.base64_image_1x1) + + def test_10_image_resize_image(self): + """Test that image_resize_image is working as expected.""" + + # CASE: return False if base64_source is falsy + res = tools.image_resize_image(False) + self.assertFalse(res) + + # CASE: return base64_source if size == (None, None) + res = tools.image_resize_image(self.base64_image_1x1, size=(None, None)) + self.assertEqual(res, self.base64_image_1x1) + + # CASE: return base64_source if format is SVG + res = tools.image_resize_image(self.base64_svg) + self.assertEqual(res, self.base64_svg) + + # CASE: ok with default parameters + image = tools.base64_to_image(tools.image_resize_image(self.base64_image_1920_1080)) + self.assertEqual(image.size, tools.IMAGE_BIG_SIZE) + + # CASE: correct resize + image = tools.base64_to_image(tools.image_resize_image(self.base64_image_1920_1080, size=(800, 600))) + self.assertEqual(image.size, (800, 600)) + + # CASE: change filetype to PNG + image = tools.base64_to_image(tools.image_resize_image(self.base64_image_1920_1080, filetype='PNG')) + self.assertEqual(image.format, 'PNG') + + # CASE: change filetype to JPEG (case insensitive) + image = tools.base64_to_image(tools.image_resize_image(self.base64_image_1x1, filetype='jpeg')) + self.assertEqual(image.format, 'JPEG') + + # CASE: change filetype to BMP converted to PNG + image = tools.base64_to_image(tools.image_resize_image(self.base64_image_1x1, filetype='BMP')) + self.assertEqual(image.format, 'PNG') + + # CASE: change filetype PNG with RGBA to JPEG + base64_image_1080_1920_rgba = tools.image_to_base64(PIL.Image.new('RGBA', (1080, 1920)), 'PNG') + image = tools.base64_to_image(tools.image_resize_image(base64_image_1080_1920_rgba, filetype='jpeg')) + self.assertEqual(image.format, 'JPEG') + + # CASE: keep ratio if no height given + image = tools.base64_to_image(tools.image_resize_image(self.base64_image_1920_1080, size=(192, None))) + self.assertEqual(image.size, (192, 108)) + + # CASE: keep ratio if no width given + image = tools.base64_to_image(tools.image_resize_image(self.base64_image_1920_1080, size=(None, 108))) + self.assertEqual(image.size, (192, 108)) + + # CASE: resize above original size + image = tools.base64_to_image(tools.image_resize_image(self.base64_image_1920_1080, size=(3000, 2000))) + self.assertEqual(image.size, (3000, 2000)) + + # CASE: avoid_if_small=True and size above, return original + res = tools.image_resize_image(self.base64_image_1920_1080, size=(3000, 2000), avoid_if_small=True) + self.assertEqual(res, self.base64_image_1920_1080) + + # CASE: same size, no sharpen + res = tools.image_resize_image(self.base64_image_1920_1080, size=(1920, 1080)) + self.assertEqual(res, self.base64_image_1920_1080) + + # CASE: upper_limit=True, no resize if above + image = tools.base64_to_image(tools.image_resize_image(self.base64_image_1920_1080, size=(3000, 2000), upper_limit=True)) + self.assertEqual(image.size, (1920, 1080)) + + # CASE: upper_limit=True, keep ratio, no resize if above + image = tools.base64_to_image(tools.image_resize_image(self.base64_image_1920_1080, size=(3000, None), upper_limit=True)) + self.assertEqual(image.size, (1920, 1080)) + + # CASE: upper_limit=True, keep ratio, no resize if above + image = tools.base64_to_image(tools.image_resize_image(self.base64_image_1920_1080, size=(None, 2000), upper_limit=True)) + self.assertEqual(image.size, (1920, 1080)) + + # CASE: upper_limit=True, vertical image, correct resize if below + base64_image_1080_1920 = tools.image_to_base64(PIL.Image.new('RGB', (1080, 1920)), 'PNG') + image = tools.base64_to_image(tools.image_resize_image(base64_image_1080_1920, size=(3000, 192), upper_limit=True)) + self.assertEqual(image.size, (108, 192)) + + # CASE: preserve_aspect_ratio=True, adapt to width + image = tools.base64_to_image(tools.image_resize_image(self.base64_image_1920_1080, size=(192, 200), preserve_aspect_ratio=True)) + self.assertEqual(image.size, (192, 108)) + + # CASE: preserve_aspect_ratio=True, adapt to height + image = tools.base64_to_image(tools.image_resize_image(self.base64_image_1920_1080, size=(400, 108), preserve_aspect_ratio=True)) + self.assertEqual(image.size, (192, 108)) + + def test_11_image_optimize_for_web(self): + """Test that image_optimize_for_web is working as expected.""" + # CASE: return base64_source if format is SVG + res = tools.image_optimize_for_web(self.base64_svg) + self.assertEqual(res, self.base64_svg) + + # CASE: size excessive + base64_image_excessive = tools.image_to_base64(PIL.Image.new('RGB', (45001, 1000)), 'PNG') + with self.assertRaises(ValueError): + res = tools.image_optimize_for_web(base64_image_excessive) + + # CASE: max_width + image = tools.base64_to_image(tools.image_optimize_for_web(self.base64_image_1920_1080, max_width=192)) + self.assertEqual(image.size, (192, 108)) + + # CASE: PNG RGBA doesn't apply quality, just optimize + base64_image_1080_1920_rgba = tools.image_to_base64(PIL.Image.new('RGBA', (1080, 1920)), 'PNG') + res = tools.image_optimize_for_web(base64_image_1080_1920_rgba) + self.assertLessEqual(len(res), len(base64_image_1080_1920_rgba)) + + # CASE: PNG RGB doesn't apply quality, just optimize + base64_image_1080_1920_rgb = tools.image_to_base64(PIL.Image.new('P', (1080, 1920)), 'PNG') + res = tools.image_optimize_for_web(base64_image_1080_1920_rgb) + self.assertLessEqual(len(res), len(base64_image_1080_1920_rgb)) + + # CASE: JPEG strictly reduced quality + res = tools.image_optimize_for_web(self.base64_image_1920_1080) + self.assertLessEqual(len(res), len(self.base64_image_1920_1080)) + + # CASE: GIF doesn't apply quality, just optimize + base64_image_1080_1920_gif = tools.image_to_base64(PIL.Image.new('RGB', (1080, 1920)), 'GIF') + res = tools.image_optimize_for_web(base64_image_1080_1920_gif) + self.assertLessEqual(len(res), len(base64_image_1080_1920_gif)) + + # CASE: unsupported format + base64_image_1080_1920_tiff = tools.image_to_base64(PIL.Image.new('RGB', (1080, 1920)), 'TIFF') + with self.assertRaises(ValueError): + res = tools.image_optimize_for_web(base64_image_1080_1920_tiff) + + def test_12_crop_image(self): + """Test that crop_image is working as expected.""" + + # CASE: return False if base64_source is falsy + res = tools.crop_image(False) + self.assertFalse(res) + + # CASE: no param, no crop + image = tools.base64_to_image(tools.crop_image(self.base64_image_1920_1080)) + self.assertEqual(image.size, (1920, 1080)) + + # CASE: ratio, crop biggest possible + image = tools.base64_to_image(tools.crop_image(self.base64_image_1920_1080, ratio=(1, 1))) + self.assertEqual(image.size, (1080, 1080)) + + # CASE: ratio vertical, limit height + image = tools.base64_to_image(tools.crop_image(self.base64_image_1920_1080, ratio=(1, 2))) + self.assertEqual(image.size, (540, 1080)) + + # CASE: ratio horizontal, limit width + image = tools.base64_to_image(tools.crop_image(self.base64_image_1920_1080, ratio=(2, 1))) + self.assertEqual(image.size, (1920, 960)) + + # CASE: type center + image = tools.base64_to_image(tools.crop_image(self.base64_image_1920_1080, ratio=(1, 1), type='center')) + self.assertEqual(image.size, (1080, 1080)) + + # CASE: type bottom + image = tools.base64_to_image(tools.crop_image(self.base64_image_1920_1080, ratio=(1, 1), type='bottom')) + self.assertEqual(image.size, (1080, 1080)) + + # CASE: wrong type + with self.assertRaises(ValueError): + tools.crop_image(self.base64_image_1920_1080, ratio=(1, 1), type='wrong') + + # CASE: size given, resize too (but keep ratio) + image = tools.base64_to_image(tools.crop_image(self.base64_image_1920_1080, ratio=(2, 1), size=(512, 512))) + self.assertEqual(image.size, (512, 256)) + + def test_13_image_colorize(self): + """Test that image_colorize is working as expected.""" + + # verify initial condition + image_rgba = PIL.Image.new('RGBA', (1, 1)) + self.assertEqual(image_rgba.getpixel((0, 0)), (0, 0, 0, 0)) + base64_rgba = tools.image_to_base64(image_rgba, 'PNG') + + # CASE: color random, color has changed + image = tools.base64_to_image(tools.image_colorize(base64_rgba)) + self.assertNotEqual(image.getpixel((0, 0)), (0, 0, 0)) + + # CASE: not random, fixed color + image = tools.base64_to_image(tools.image_colorize(base64_rgba, randomize=False)) + self.assertEqual(image.getpixel((0, 0)), (255, 255, 255)) + + def test_16_limited_image_resize(self): + """Test that limited_image_resize is working as expected.""" + + # CASE: return input if base64_source is falsy + self.assertEqual(tools.limited_image_resize(False), False) + + # CASE: return input if no width or height + self.assertEqual(tools.limited_image_resize(self.base64_image_1x1), self.base64_image_1x1) + + # CASE: given width, resize and keep ratio + image = tools.base64_to_image(tools.limited_image_resize(self.base64_image_1920_1080, width=192)) + self.assertEqual(image.size, (192, 108)) + + # CASE: given height, resize and keep ratio + image = tools.base64_to_image(tools.limited_image_resize(self.base64_image_1920_1080, height=108)) + self.assertEqual(image.size, (192, 108)) + + # CASE: if crop, ratio always 1:1 + image = tools.base64_to_image(tools.limited_image_resize(self.base64_image_1920_1080, width=192, height=108, crop=True)) + self.assertEqual(image.size, (108, 108)) + + # CASE: if not upper_limit, max 500px * 500px + image = tools.base64_to_image(tools.limited_image_resize(self.base64_image_1920_1080, width=1000, height=1000)) + self.assertEqual(image.size, (500, 500)) + + # CASE: if upper_limit=True, no limit + image = tools.base64_to_image(tools.limited_image_resize(self.base64_image_1920_1080, width=1000, height=1000, upper_limit=True)) + self.assertEqual(image.size, (1000, 562)) + + def test_17_image_data_uri(self): + """Test that image_data_uri is working as expected.""" + self.assertEqual(tools.image_data_uri(self.base64_image_1x1), 'data:image/png;base64,' + self.base64_image_1x1.decode('ascii')) From b230114fcf41403cbb5fe353f1f94298c958cccc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Theys?= Date: Fri, 26 Apr 2019 10:55:19 +0000 Subject: [PATCH 06/16] [IMP] web,website_profile,website_slides: rename content and base64 There is a mix in the variable names: before this commit, what is called `image_base64` is actually not base64 encoded, and what is called `content` is actually base64 encoded. The variables have been renamed to avoid further confusion. task-1958000 PR: #31811 --- addons/web/controllers/main.py | 21 ++++++++++----------- addons/website_profile/controllers/main.py | 20 ++++++++++---------- addons/website_slides/controllers/main.py | 18 +++++++++--------- 3 files changed, 29 insertions(+), 30 deletions(-) diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index cf33f0232a4..dc345e047ed 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -1025,28 +1025,27 @@ class Binary(http.Controller): filename_field='datas_fname', unique=None, filename=None, mimetype=None, download=None, width=0, height=0, crop=False, access_token=None, avoid_if_small=False, upper_limit=False, placeholder='placeholder.png', **kw): - status, headers, content = request.env['ir.http'].binary_content( + status, headers, image_base64 = request.env['ir.http'].binary_content( xmlid=xmlid, model=model, id=id, field=field, unique=unique, filename=filename, filename_field=filename_field, download=download, mimetype=mimetype, default_mimetype='image/png', access_token=access_token) if status == 301 or (status != 200 and download): - return request.env['ir.http']._response_by_status(status, headers, content) - if not content: - content = base64.b64encode(self.placeholder(image=placeholder)) + return request.env['ir.http']._response_by_status(status, headers, image_base64) + if not image_base64: + image_base64 = base64.b64encode(self.placeholder(image=placeholder)) headers = self.force_contenttype(headers, contenttype='image/png') if not (width or height): suffix = 'big' if field == 'image' else field.split('_')[-1] if suffix in ('small', 'medium', 'large', 'big'): - content = getattr(odoo.tools, 'image_resize_image_%s' % suffix)(content) + image_base64 = getattr(odoo.tools, 'image_resize_image_%s' % suffix)(image_base64) + image_base64 = limited_image_resize( + image_base64, width=width, height=height, crop=crop, upper_limit=upper_limit, avoid_if_small=avoid_if_small) - content = limited_image_resize( - content, width=width, height=height, crop=crop, upper_limit=upper_limit, avoid_if_small=avoid_if_small) - - image_base64 = base64.b64decode(content) - headers.append(('Content-Length', len(image_base64))) - response = request.make_response(image_base64, headers) + content = base64.b64decode(image_base64) + headers.append(('Content-Length', len(content))) + response = request.make_response(content, headers) response.status_code = status return response diff --git a/addons/website_profile/controllers/main.py b/addons/website_profile/controllers/main.py index 9c6d31c2efc..fc1e9b70715 100644 --- a/addons/website_profile/controllers/main.py +++ b/addons/website_profile/controllers/main.py @@ -86,27 +86,27 @@ class WebsiteProfile(http.Controller): can_sudo = self._check_avatar_access(user_id, **post) if can_sudo: - status, headers, content = request.env['ir.http'].sudo().binary_content( + status, headers, image_base64 = request.env['ir.http'].sudo().binary_content( model='res.users', id=user_id, field=field, default_mimetype='image/png') else: - status, headers, content = request.env['ir.http'].binary_content( + status, headers, image_base64 = request.env['ir.http'].binary_content( model='res.users', id=user_id, field=field, default_mimetype='image/png') if status == 301: - return request.env['ir.http']._response_by_status(status, headers, content) + return request.env['ir.http']._response_by_status(status, headers, image_base64) if status == 304: return werkzeug.wrappers.Response(status=304) - if not content: - content = self._get_default_avatar(field, headers, width, height) + if not image_base64: + image_base64 = self._get_default_avatar(field, headers, width, height) - content = tools.limited_image_resize( - content, width=width, height=height, crop=crop, upper_limit=upper_limit, avoid_if_small=avoid_if_small) + image_base64 = tools.limited_image_resize( + image_base64, width=width, height=height, crop=crop, upper_limit=upper_limit, avoid_if_small=avoid_if_small) - image_base64 = base64.b64decode(content) - headers.append(('Content-Length', len(image_base64))) - response = request.make_response(image_base64, headers) + content = base64.b64decode(image_base64) + headers.append(('Content-Length', len(content))) + response = request.make_response(content, headers) response.status_code = status return response diff --git a/addons/website_slides/controllers/main.py b/addons/website_slides/controllers/main.py index edb00cc0e7b..f2b0bb10c11 100644 --- a/addons/website_slides/controllers/main.py +++ b/addons/website_slides/controllers/main.py @@ -515,23 +515,23 @@ class WebsiteSlides(WebsiteProfile): if not slide: raise werkzeug.exceptions.NotFound() - status, headers, content = request.env['ir.http'].sudo().binary_content( + status, headers, image_base64 = request.env['ir.http'].sudo().binary_content( model='slide.slide', id=slide.id, field=field, default_mimetype='image/png') if status == 301: - return request.env['ir.http']._response_by_status(status, headers, content) + return request.env['ir.http']._response_by_status(status, headers, image_base64) if status == 304: return werkzeug.wrappers.Response(status=304) - if not content: - content = self._get_default_avatar(field, headers, width, height) + if not image_base64: + image_base64 = self._get_default_avatar(field, headers, width, height) - content = tools.limited_image_resize( - content, width=width, height=height, crop=crop, upper_limit=upper_limit, avoid_if_small=avoid_if_small) + image_base64 = tools.limited_image_resize( + image_base64, width=width, height=height, crop=crop, upper_limit=upper_limit, avoid_if_small=avoid_if_small) - image_base64 = base64.b64decode(content) - headers.append(('Content-Length', len(image_base64))) - response = request.make_response(image_base64, headers) + content = base64.b64decode(image_base64) + headers.append(('Content-Length', len(content))) + response = request.make_response(content, headers) response.status_code = status return response From d870877967b5debc11d9364ba1572e7aa6956c28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Theys?= Date: Fri, 26 Apr 2019 10:55:21 +0000 Subject: [PATCH 07/16] [IMP] tools,base,web,website_*: keep image ratio on resize * = website_partner, website_profile, website_sale, website_slides Before ====== Since commit: 2e3848b394748bf23eff0db0d6d92b9e8c9bf875 The images that are resized have additional borders if the target ratio is different than the image ratio. Those borders are transparent if the image format supports it, and are white otherwise. With that current solution, if the background where the image is displayed is another color than white, it is looking really bad. Moreover, most images are stored resized like this, so it is not even possible to decide if it should have borders or not depending on the context, the original image and ratio is forever lost. It is also inconsistent because if the image is already smaller than the target size then it doesn't include borders. In that case it keeps the original ratio instead of the target ratio. So it isn't even guaranteed that the target ratio is going to be respected. After ===== This commit will solve all of those problems by always keeping the ratio of the original image. This implies the views should be taking care of adding borders when necessary. task-1958000 PR: #31811 --- addons/product/models/product.py | 2 +- addons/web/controllers/main.py | 6 +- addons/web/models/ir_qweb.py | 3 +- addons/web/tests/test_image.py | 4 +- .../views/website_partner_templates.xml | 2 +- addons/website_profile/controllers/main.py | 4 +- .../tests/test_website_sale_image.py | 24 +-- addons/website_slides/controllers/main.py | 4 +- odoo/addons/base/models/image_mixin.py | 2 +- odoo/addons/base/models/res_partner.py | 4 +- odoo/addons/base/tests/test_image.py | 53 +++---- odoo/tools/image.py | 140 ++++-------------- 12 files changed, 75 insertions(+), 173 deletions(-) diff --git a/addons/product/models/product.py b/addons/product/models/product.py index 99a314662dd..4d566f2b100 100644 --- a/addons/product/models/product.py +++ b/addons/product/models/product.py @@ -168,7 +168,7 @@ class ProductProduct(models.Model): for record in self: images = tools.image_get_resized_images(record.image_raw_original, big_name=False) record.image_raw_big = tools.image_get_resized_images(record.image_raw_original, - large_name=False, medium_name=False, small_name=False, preserve_aspect_ratio=True)['image'] + large_name=False, medium_name=False, small_name=False)['image'] record.image_raw_large = images['image_large'] record.image_raw_medium = images['image_medium'] record.image_raw_small = images['image_small'] diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index dc345e047ed..226d5347e84 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -1023,8 +1023,8 @@ class Binary(http.Controller): '/web/image/-/x/'], type='http', auth="public") def content_image(self, xmlid=None, model='ir.attachment', id=None, field='datas', filename_field='datas_fname', unique=None, filename=None, mimetype=None, - download=None, width=0, height=0, crop=False, access_token=None, avoid_if_small=False, - upper_limit=False, placeholder='placeholder.png', **kw): + download=None, width=0, height=0, crop=False, access_token=None, + placeholder='placeholder.png', **kwargs): status, headers, image_base64 = request.env['ir.http'].binary_content( xmlid=xmlid, model=model, id=id, field=field, unique=unique, filename=filename, filename_field=filename_field, download=download, mimetype=mimetype, @@ -1041,7 +1041,7 @@ class Binary(http.Controller): image_base64 = getattr(odoo.tools, 'image_resize_image_%s' % suffix)(image_base64) image_base64 = limited_image_resize( - image_base64, width=width, height=height, crop=crop, upper_limit=upper_limit, avoid_if_small=avoid_if_small) + image_base64, width=width, height=height, crop=crop) content = base64.b64decode(image_base64) headers.append(('Content-Length', len(content))) diff --git a/addons/web/models/ir_qweb.py b/addons/web/models/ir_qweb.py index d1d792e8f54..e8a966647e3 100644 --- a/addons/web/models/ir_qweb.py +++ b/addons/web/models/ir_qweb.py @@ -45,7 +45,6 @@ class Image(models.AbstractModel): sha = hashlib.sha1(str(getattr(record, '__last_update')).encode('utf-8')).hexdigest()[0:7] max_size = '' if max_size is None else '/%s' % max_size - avoid_if_small = '&avoid_if_small=true' if options.get('avoid_if_small') else '' if options.get('filename-field') and getattr(record, options['filename-field'], None): filename = record[options['filename-field']] @@ -54,7 +53,7 @@ class Image(models.AbstractModel): else: filename = record.display_name - src = '/web/image/%s/%s/%s%s/%s?unique=%s%s' % (record._name, record.id, options.get('preview_image', field_name), max_size, url_quote(filename), sha, avoid_if_small) + src = '/web/image/%s/%s/%s%s/%s?unique=%s' % (record._name, record.id, options.get('preview_image', field_name), max_size, url_quote(filename), sha) if options.get('alt-field') and getattr(record, options['alt-field'], None): alt = escape(record[options['alt-field']]) diff --git a/addons/web/tests/test_image.py b/addons/web/tests/test_image.py index e17073d0fb7..1e4b0c3c5b5 100644 --- a/addons/web/tests/test_image.py +++ b/addons/web/tests/test_image.py @@ -13,10 +13,10 @@ class TestImage(HttpCase): """The goal of this test is to make sure the placeholder image is resized appropriately depending on the given URL parameters.""" - # CASE: resize placeholder to given size + # CASE: resize placeholder, given size but original ratio is always kept response = self.url_open('/web/image/0/200x150') image = Image.open(io.BytesIO(response.content)) - self.assertEqual(image.size, (200, 150)) + self.assertEqual(image.size, (150, 150)) # CASE: resize placeholder to small response = self.url_open('/web/image/fake/0/image_small') diff --git a/addons/website_partner/views/website_partner_templates.xml b/addons/website_partner/views/website_partner_templates.xml index c8682008619..2f5c58b1dee 100644 --- a/addons/website_partner/views/website_partner_templates.xml +++ b/addons/website_partner/views/website_partner_templates.xml @@ -17,7 +17,7 @@