From 6b8752604898bf2b583b7f5334e35f6a1583595e Mon Sep 17 00:00:00 2001 From: Fabien Pinckaers Date: Mon, 17 Jan 2022 13:09:33 +0000 Subject: [PATCH] [IMP] Speed Imp: remove unnecessary base64 encode & decode Avoid to base64 encode, then decode to process assets and images for a ~25% speed improvement. Change image processing tool to work on images, rather than base64 encoded strings. Performance is ~25% faster on assets & images: /web/assets/...frontend.min.css: 13ms to 7ms, base64 enc/dec: 2 -> 0 /web/image/XML_ID: 10ms to 8ms, base64 enc/dec: 3 -> 0 /web/image/res.users/2/avatar_128: 40ms to 20ms, base64 enc/dec: 6 -> 2 closes odoo/odoo#82851 Related: odoo/enterprise#23537 Signed-off-by: Fabien Pinckaers --- addons/hr/models/hr_employee.py | 3 +- addons/im_livechat/controllers/main.py | 7 +- addons/purchase/controllers/portal.py | 10 +- addons/web/controllers/main.py | 2 +- addons/web/models/ir_http.py | 28 ++-- addons/web_editor/controllers/main.py | 22 +-- addons/web_editor/tools.py | 2 +- addons/web_unsplash/controllers/main.py | 8 +- addons/website/models/website.py | 2 +- addons/website_event_track/models/website.py | 5 +- addons/website_profile/controllers/main.py | 19 +-- addons/website_slides/controllers/main.py | 11 +- odoo/addons/base/models/avatar_mixin.py | 14 +- odoo/addons/base/models/ir_attachment.py | 8 +- odoo/addons/base/models/ir_http.py | 39 ++--- odoo/addons/base/models/res_company.py | 3 +- odoo/addons/base/models/res_partner.py | 2 +- odoo/addons/base/models/res_users.py | 2 +- odoo/addons/base/tests/test_avatar_mixin.py | 4 +- odoo/addons/base/tests/test_image.py | 155 +++++++++---------- odoo/addons/base/tests/test_ir_http.py | 5 +- odoo/fields.py | 11 +- odoo/tools/image.py | 89 ++++------- 23 files changed, 205 insertions(+), 246 deletions(-) diff --git a/addons/hr/models/hr_employee.py b/addons/hr/models/hr_employee.py index 92547ec1c57..176382a7c6f 100644 --- a/addons/hr/models/hr_employee.py +++ b/addons/hr/models/hr_employee.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import base64 from pytz import UTC from datetime import datetime, time from random import choice @@ -153,7 +154,7 @@ class HrEmployeePrivate(models.Model): if employee.user_id: avatar = employee.user_id[avatar_field] else: - avatar = employee._avatar_get_placeholder() + avatar = base64.b64encode(employee._avatar_get_placeholder()) employee[avatar_field] = avatar def name_get(self): diff --git a/addons/im_livechat/controllers/main.py b/addons/im_livechat/controllers/main.py index 3078ebefae2..4d8e0f67c48 100644 --- a/addons/im_livechat/controllers/main.py +++ b/addons/im_livechat/controllers/main.py @@ -24,10 +24,9 @@ class LivechatController(http.Controller): if isinstance(mock_attachment, list): # suppose that CSS asset will not required to be split in pages mock_attachment = mock_attachment[0] # can't use /web/content directly because we don't have attachment ids (attachments must be created) - status, headers, content = request.env['ir.http'].binary_content(id=mock_attachment.id, unique=asset.checksum) - content_base64 = base64.b64decode(content) if content else '' - headers.append(('Content-Length', len(content_base64))) - return request.make_response(content_base64, headers) + _, headers, content = request.env['ir.http'].binary_content(id=mock_attachment.id, unique=asset.checksum) + headers.append(('Content-Length', len(content))) + return request.make_response(content, headers) @http.route('/im_livechat/load_templates', type='json', auth='none', cors="*") def load_templates(self, **kwargs): diff --git a/addons/purchase/controllers/portal.py b/addons/purchase/controllers/portal.py index bcefb00813e..666c2d47c4b 100644 --- a/addons/purchase/controllers/portal.py +++ b/addons/purchase/controllers/portal.py @@ -88,10 +88,12 @@ class CustomerPortal(portal.CustomerPortal): def _purchase_order_get_page_view_values(self, order, access_token, **kwargs): # - def resize_to_48(b64source): - if not b64source: - b64source = base64.b64encode(request.env['ir.http']._placeholder()) - return image_process(b64source, size=(48, 48)) + def resize_to_48(source): + if not source: + source = request.env['ir.http']._placeholder() + else: + source = base64.b64decode(source) + return base64.b64encode(image_process(source, size=(48, 48))) values = { 'order': order, diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index eb2de297d86..2f7a8a4e237 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -1436,7 +1436,7 @@ class Binary(http.Controller): '/web/image/-/', '/web/image/-/x', '/web/image/-/x/'], type='http', auth="public") - def content_image(self, xmlid=None, model='ir.attachment', id=None, field='datas', + def content_image(self, xmlid=None, model='ir.attachment', id=None, field='raw', filename_field='name', unique=None, filename=None, mimetype=None, download=None, width=0, height=0, crop=False, access_token=None, **kwargs): diff --git a/addons/web/models/ir_http.py b/addons/web/models/ir_http.py index 4e9e0f94c21..3bb58fce05b 100644 --- a/addons/web/models/ir_http.py +++ b/addons/web/models/ir_http.py @@ -140,54 +140,50 @@ class Http(models.AbstractModel): if status != 200: return self._response_by_status(status, headers, content) else: - content_base64 = base64.b64decode(content) - headers.append(('Content-Length', len(content_base64))) - response = request.make_response(content_base64, headers) + headers.append(('Content-Length', len(content))) + response = request.make_response(content, headers) return response @api.model - def _content_image(self, xmlid=None, model='ir.attachment', res_id=None, field='datas', + def _content_image(self, xmlid=None, model='ir.attachment', res_id=None, field='raw', filename_field='name', unique=None, filename=None, mimetype=None, download=None, width=0, height=0, crop=False, quality=0, access_token=None, **kwargs): - status, headers, image_base64 = self.binary_content( + status, headers, image = self.binary_content( xmlid=xmlid, model=model, id=res_id, field=field, unique=unique, filename=filename, filename_field=filename_field, download=download, mimetype=mimetype, default_mimetype='image/png', access_token=access_token ) return self._content_image_get_response( - status, headers, image_base64, model=model, field=field, download=download, + status, headers, image, model=model, field=field, download=download, width=width, height=height, crop=crop, quality=quality) @api.model - def _content_image_get_response(self, status, headers, image_base64, model='ir.attachment', - field='datas', download=None, width=0, height=0, crop=False, quality=0): + def _content_image_get_response(self, status, headers, image, model='ir.attachment', + field='raw', download=None, width=0, height=0, crop=False, quality=0): if status in [301, 304] or (status != 200 and download): - return self._response_by_status(status, headers, image_base64) - if not image_base64: + return self._response_by_status(status, headers, image) + if not image: placeholder_filename = False if model in self.env: placeholder_filename = self.env[model]._get_placeholder_filename(field) - placeholder_content = self._placeholder(image=placeholder_filename) + image = self._placeholder(image=placeholder_filename) # Since we set a placeholder for any missing image, the status must be 200. In case one # wants to configure a specific 404 page (e.g. though nginx), a 404 status will cause # troubles. status = 200 - image_base64 = base64.b64encode(placeholder_content) if not (width or height): width, height = odoo.tools.image_guess_size_from_field_name(field) try: - image_base64 = image_process(image_base64, size=(int(width), int(height)), crop=crop, quality=int(quality)) + content = image_process(image, size=(int(width), int(height)), crop=crop, quality=int(quality)) except Exception: return request.not_found() - content = base64.b64decode(image_base64) headers = http.set_safe_image_headers(headers, content) response = request.make_response(content, headers) response.status_code = status return response @api.model - def _placeholder_image_get_response(self, placeholder_base64): - content = base64.b64decode(placeholder_base64) + def _placeholder_image_get_response(self, content): headers = http.set_safe_image_headers([], content) response = request.make_response(content, headers) response.status_code = 200 diff --git a/addons/web_editor/controllers/main.py b/addons/web_editor/controllers/main.py index e5cc1c481fe..8fbf91c80bc 100644 --- a/addons/web_editor/controllers/main.py +++ b/addons/web_editor/controllers/main.py @@ -20,7 +20,7 @@ from odoo.exceptions import UserError from odoo.modules.module import get_resource_path from odoo.tools import file_open from odoo.tools.mimetypes import guess_mimetype -from odoo.tools.image import image_data_uri, base64_to_image +from odoo.tools.image import image_data_uri, binary_to_image from odoo.addons.base.models.assetsbundle import AssetsBundle from ..models.ir_attachment import SUPPORTED_IMAGE_EXTENSIONS, SUPPORTED_IMAGE_MIMETYPES @@ -193,8 +193,8 @@ class Web_Editor(http.Controller): if is_image: format_error_msg = _("Uploaded image's format is not supported. Try with: %s", ', '.join(SUPPORTED_IMAGE_EXTENSIONS)) try: - data = tools.image_process(data, size=(width, height), quality=quality, verify_resolution=True) - mimetype = guess_mimetype(b64decode(data)) + data = tools.image_process(b64decode(data), size=(width, height), quality=quality, verify_resolution=True) + mimetype = guess_mimetype(data) if mimetype not in SUPPORTED_IMAGE_MIMETYPES: return {'error': format_error_msg} except UserError: @@ -299,7 +299,7 @@ class Web_Editor(http.Controller): } if data: - attachment_data['datas'] = data + attachment_data['raw'] = data elif url: attachment_data.update({ 'type': 'url', @@ -613,7 +613,7 @@ class Web_Editor(http.Controller): or not attachment.public or not attachment.url.startswith(request.httprequest.path)): raise werkzeug.exceptions.NotFound() - svg = b64decode(attachment.datas).decode('utf-8') + svg = attachment.raw.decode('utf-8') else: svg = self._get_shape_svg(module, 'shapes', filename) @@ -634,18 +634,18 @@ class Web_Editor(http.Controller): @http.route(['/web_editor/image_shape///'], type='http', auth="public", website=True) def image_shape(self, module, filename, img_key, **kwargs): svg = self._get_shape_svg(module, 'image_shapes', filename) - _, _, image_base64 = request.env['ir.http'].binary_content( + _, _, image = request.env['ir.http'].binary_content( xmlid=img_key, model='ir.attachment', field='datas', default_mimetype='image/png') - if not image_base64: - image_base64 = b64encode(request.env['ir.http']._placeholder()) - image = base64_to_image(image_base64) - width, height = tuple(str(size) for size in image.size) + if not image: + image = request.env['ir.http']._placeholder() + img = binary_to_image(image) + width, height = tuple(str(size) for size in img.size) root = etree.fromstring(svg) root.attrib.update({'width': width, 'height': height}) # Update default color palette on shape SVG. svg, _ = self._update_svg_colors(kwargs, etree.tostring(root, pretty_print=True).decode('utf-8')) # Add image in base64 inside the shape. - uri = image_data_uri(image_base64) + uri = image_data_uri(b64encode(image)) svg = svg.replace(' h else h image.crop_resize(square_size, square_size) image.image = image.image.resize((512, 512)) image.operationsCount += 1 - website.app_icon = image.image_quality_base64(output_format='PNG') + website.app_icon = base64.b64encode(image.image_quality(output_format='PNG')) diff --git a/addons/website_profile/controllers/main.py b/addons/website_profile/controllers/main.py index aab723efdaa..dbcd8ad5c38 100644 --- a/addons/website_profile/controllers/main.py +++ b/addons/website_profile/controllers/main.py @@ -36,9 +36,8 @@ class WebsiteProfile(http.Controller): return False def _get_default_avatar(self): - img_path = modules.get_module_resource('web', 'static/img', 'placeholder.png') - with open(img_path, 'rb') as f: - return base64.b64encode(f.read()) + with tools.file_open("web/static/img/placeholder.png", 'rb') as f: + return f.read() def _check_user_profile_access(self, user_id): user_sudo = request.env['res.users'].sudo().browse(user_id) @@ -82,26 +81,24 @@ class WebsiteProfile(http.Controller): can_sudo = self._check_avatar_access(user_id, **post) if can_sudo: - status, headers, image_base64 = request.env['ir.http'].sudo().binary_content( + status, headers, image = request.env['ir.http'].sudo().binary_content( model='res.users', id=user_id, field=field, default_mimetype='image/png') else: - status, headers, image_base64 = request.env['ir.http'].binary_content( + status, headers, image = 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, image_base64) + return request.env['ir.http']._response_by_status(status, headers, image) if status == 304: return werkzeug.wrappers.Response(status=304) - if not image_base64: - image_base64 = self._get_default_avatar() + if not image: + image = self._get_default_avatar() if not (width or height): width, height = tools.image_guess_size_from_field_name(field) - image_base64 = tools.image_process(image_base64, size=(int(width), int(height)), crop=crop) - - content = base64.b64decode(image_base64) + content = tools.image_process(image, size=(int(width), int(height)), crop=crop) headers = http.set_safe_image_headers(headers, content) response = request.make_response(content, headers) response.status_code = status diff --git a/addons/website_slides/controllers/main.py b/addons/website_slides/controllers/main.py index b3bb297e078..69de0219d71 100644 --- a/addons/website_slides/controllers/main.py +++ b/addons/website_slides/controllers/main.py @@ -747,22 +747,21 @@ class WebsiteSlides(WebsiteProfile): if not slide: raise werkzeug.exceptions.NotFound() - status, headers, image_base64 = request.env['ir.http'].sudo().binary_content( + status, headers, image = 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, image_base64) + return request.env['ir.http']._response_by_status(status, headers, image) if status == 304: return werkzeug.wrappers.Response(status=304) - if not image_base64: - image_base64 = self._get_default_avatar() + if not image: + image = self._get_default_avatar() if not (width or height): width, height = tools.image_guess_size_from_field_name(field) - image_base64 = tools.image_process(image_base64, size=(int(width), int(height)), crop=crop) + content = tools.image_process(image, size=(int(width), int(height)), crop=crop) - content = base64.b64decode(image_base64) headers = http.set_safe_image_headers(headers, content) response = request.make_response(content, headers) response.status_code = status diff --git a/odoo/addons/base/models/avatar_mixin.py b/odoo/addons/base/models/avatar_mixin.py index 29075e83338..bc3ca6bb48c 100644 --- a/odoo/addons/base/models/avatar_mixin.py +++ b/odoo/addons/base/models/avatar_mixin.py @@ -25,11 +25,11 @@ class AvatarMixin(models.AbstractModel): _avatar_name_field = "name" # all image fields are base64 encoded and PIL-supported - avatar_1920 = fields.Image("Avatar", max_width=1920, max_height=1920, compute="_compute_avatar_1920") - avatar_1024 = fields.Image("Avatar 1024", max_width=1024, max_height=1024, compute="_compute_avatar_1024") - avatar_512 = fields.Image("Avatar 512", max_width=512, max_height=512, compute="_compute_avatar_512") - avatar_256 = fields.Image("Avatar 256", max_width=256, max_height=256, compute="_compute_avatar_256") - avatar_128 = fields.Image("Avatar 128", max_width=128, max_height=128, compute="_compute_avatar_128") + avatar_1920 = fields.Image("Avatar", compute="_compute_avatar_1920") + avatar_1024 = fields.Image("Avatar 1024", compute="_compute_avatar_1024") + avatar_512 = fields.Image("Avatar 512", compute="_compute_avatar_512") + avatar_256 = fields.Image("Avatar 256", compute="_compute_avatar_256") + avatar_128 = fields.Image("Avatar 128", compute="_compute_avatar_128") def _compute_avatar(self, avatar_field, image_field): for record in self: @@ -38,7 +38,7 @@ class AvatarMixin(models.AbstractModel): if record.id and record[record._avatar_name_field]: avatar = record._avatar_generate_svg() else: - avatar = record._avatar_get_placeholder() + avatar = b64encode(record._avatar_get_placeholder()) record[avatar_field] = avatar @api.depends(lambda self: [self._avatar_name_field, 'image_1920']) @@ -76,4 +76,4 @@ class AvatarMixin(models.AbstractModel): return "base/static/img/avatar_grey.png" def _avatar_get_placeholder(self): - return b64encode(file_open(self._avatar_get_placeholder_path(), 'rb').read()) + return file_open(self._avatar_get_placeholder_path(), 'rb').read() diff --git a/odoo/addons/base/models/ir_attachment.py b/odoo/addons/base/models/ir_attachment.py index c1229d50ba3..0a95ac61aea 100644 --- a/odoo/addons/base/models/ir_attachment.py +++ b/odoo/addons/base/models/ir_attachment.py @@ -316,13 +316,11 @@ class IrAttachment(models.Model): try: img = fn_quality = False if is_raw: - img = ImageProcess(False, verify_resolution=False) - img.image = Image.open(io.BytesIO(values['raw'])) - img.original_format = (img.image.format or '').upper() + img = ImageProcess(values['raw'], verify_resolution=False) fn_quality = img.image_quality else: # datas - img = ImageProcess(values['datas'], verify_resolution=False) - fn_quality = img.image_quality_base64 + img = ImageProcess(base64.b64decode(values['datas']), verify_resolution=False) + fn_quality = lambda **args: base64.b64encode(img.image_quality(**args)) w, h = img.image.size nw, nh = map(int, max_resolution.split('x')) diff --git a/odoo/addons/base/models/ir_http.py b/odoo/addons/base/models/ir_http.py index bbdb1cd6cf5..05724431263 100644 --- a/odoo/addons/base/models/ir_http.py +++ b/odoo/addons/base/models/ir_http.py @@ -359,11 +359,11 @@ class IrHttp(models.AbstractModel): module_resource_path = os.path.normpath(module_resource_path) if module_resource_path.startswith(module_path): with open(module_resource_path, 'rb') as f: - content = base64.b64encode(f.read()) + content = f.read() status = 200 filename = os.path.basename(module_resource_path) - mimetype = guess_mimetype(base64.b64decode(content), default=default_mimetype) - filehash = '"%s"' % hashlib.md5(pycompat.to_text(content).encode('utf-8')).hexdigest() + mimetype = record.mimetype + filehash = record.checksum if not content: status = 301 @@ -372,7 +372,7 @@ class IrHttp(models.AbstractModel): return status, content, filename, mimetype, filehash def _binary_record_content( - self, record, field='datas', filename=None, + self, record, field='raw', filename=None, filename_field='name', default_mimetype='application/octet-stream'): model = record._name @@ -383,18 +383,23 @@ class IrHttp(models.AbstractModel): field_def = record._fields[field] if field_def.type == 'binary' and field_def.attachment and not field_def.related: if model != 'ir.attachment': - field_attachment = self.env['ir.attachment'].sudo().search_read(domain=[('res_model', '=', model), ('res_id', '=', record.id), ('res_field', '=', field)], fields=['datas', 'mimetype', 'checksum'], limit=1) + field_attachment = self.env['ir.attachment'].sudo().search_read(domain=[('res_model', '=', model), ('res_id', '=', record.id), ('res_field', '=', field)], fields=['raw', 'mimetype', 'checksum'], limit=1) if field_attachment: mimetype = field_attachment[0]['mimetype'] - content = field_attachment[0]['datas'] + content = field_attachment[0]['raw'] filehash = field_attachment[0]['checksum'] else: mimetype = record['mimetype'] - content = record['datas'] + content = record['raw'] filehash = record['checksum'] if not content: - content = record[field] or '' + if model == 'ir.attachment': + content = record.raw + else: + data = record[field] or b'' + content = base64.b64decode(data) + filehash = '"%s"' % hashlib.md5(str(content).encode('utf-8')).hexdigest() # filename default_filename = False @@ -406,11 +411,7 @@ class IrHttp(models.AbstractModel): filename = "%s-%s-%s" % (record._name, record.id, field) if not mimetype: - try: - decoded_content = base64.b64decode(content) - except base64.binascii.Error: # if we could not decode it, no need to pass it down: it would crash elsewhere... - return (404, [], None) - mimetype = guess_mimetype(decoded_content, default=default_mimetype) + mimetype = guess_mimetype(content, default=default_mimetype) # extension _, existing_extension = os.path.splitext(filename) @@ -420,12 +421,12 @@ class IrHttp(models.AbstractModel): filename = "%s%s" % (filename, extension) if not filehash: - filehash = '"%s"' % hashlib.md5(pycompat.to_text(content).encode('utf-8')).hexdigest() + filehash = '"%s"' % hashlib.md5(str(base64.b64encode(content)).encode('utf-8')).hexdigest() status = 200 if content else 404 return status, content, filename, mimetype, filehash - def _binary_set_headers(self, status, content, filename, mimetype, unique, filehash=None, download=False): + def _binary_set_headers(self, status, filename, mimetype, unique, filehash=None, download=False): headers = [('Content-Type', mimetype), ('X-Content-Type-Options', 'nosniff'), ('Content-Security-Policy', "default-src 'none'")] # cache etag = bool(request) and request.httprequest.headers.get('If-None-Match') @@ -439,9 +440,9 @@ class IrHttp(models.AbstractModel): if download: headers.append(('Content-Disposition', content_disposition(filename))) - return (status, headers, content) + return (status, headers) - def binary_content(self, xmlid=None, model='ir.attachment', id=None, field='datas', + def binary_content(self, xmlid=None, model='ir.attachment', id=None, field='raw', unique=False, filename=None, filename_field='name', download=False, mimetype=None, default_mimetype='application/octet-stream', access_token=None): @@ -479,8 +480,8 @@ class IrHttp(models.AbstractModel): record, field=field, filename=filename, filename_field=filename_field, default_mimetype='application/octet-stream') - status, headers, content = self._binary_set_headers( - status, content, filename, mimetype, unique, filehash=filehash, download=download) + status, headers = self._binary_set_headers( + status, filename, mimetype, unique, filehash=filehash, download=download) return status, headers, content diff --git a/odoo/addons/base/models/res_company.py b/odoo/addons/base/models/res_company.py index 73774f81da4..a33ab9e1424 100644 --- a/odoo/addons/base/models/res_company.py +++ b/odoo/addons/base/models/res_company.py @@ -159,7 +159,8 @@ class Company(models.Model): @api.depends('partner_id.image_1920') def _compute_logo_web(self): for company in self: - company.logo_web = tools.image_process(company.partner_id.image_1920, size=(180, 0)) + img = company.partner_id.image_1920 + company.logo_web = img and base64.b64encode(tools.image_process(base64.b64decode(img), size=(180, 0))) @api.onchange('state_id') def _onchange_state(self): diff --git a/odoo/addons/base/models/res_partner.py b/odoo/addons/base/models/res_partner.py index 83c0dd6a798..2ecb5efff7c 100644 --- a/odoo/addons/base/models/res_partner.py +++ b/odoo/addons/base/models/res_partner.py @@ -264,7 +264,7 @@ class Partner(models.Model): partners_with_internal_user = self.filtered(lambda partner: partner.user_ids - partner.user_ids.filtered('share')) super(Partner, partners_with_internal_user)._compute_avatar(avatar_field, image_field) for partner in self - partners_with_internal_user: - partner[avatar_field] = partner[image_field] or partner._avatar_get_placeholder() + partner[avatar_field] = partner[image_field] or base64.b64encode(partner._avatar_get_placeholder()) def _avatar_get_placeholder_path(self): if self.is_company: diff --git a/odoo/addons/base/models/res_users.py b/odoo/addons/base/models/res_users.py index ad0ce68fe6a..775206e1044 100644 --- a/odoo/addons/base/models/res_users.py +++ b/odoo/addons/base/models/res_users.py @@ -28,7 +28,7 @@ from odoo.exceptions import AccessDenied, AccessError, UserError, ValidationErro from odoo.http import request from odoo.osv import expression from odoo.service.db import check_super -from odoo.tools import partition, collections, frozendict, lazy_property, image_process +from odoo.tools import partition, collections, frozendict, lazy_property _logger = logging.getLogger(__name__) diff --git a/odoo/addons/base/tests/test_avatar_mixin.py b/odoo/addons/base/tests/test_avatar_mixin.py index d6d4cf23cda..934c1fbb0d7 100644 --- a/odoo/addons/base/tests/test_avatar_mixin.py +++ b/odoo/addons/base/tests/test_avatar_mixin.py @@ -51,10 +51,10 @@ class TestAvatarMixin(TransactionCase): self.assertEqual(expectedAvatar, b64decode(self.user_without_image.partner_id.avatar_1920).decode('utf-8')) def test_partner_without_name_has_default_placeholder_image_as_avatar(self): - self.assertEqual(self.user_without_name.partner_id._avatar_get_placeholder(), self.user_without_name.partner_id.avatar_1920) + self.assertEqual(self.user_without_name.partner_id._avatar_get_placeholder(), b64decode(self.user_without_name.partner_id.avatar_1920)) def test_external_partner_has_default_placeholder_image_as_avatar(self): - self.assertEqual(self.external_partner._avatar_get_placeholder(), self.external_partner.avatar_1920) + self.assertEqual(self.external_partner._avatar_get_placeholder(), b64decode(self.external_partner.avatar_1920)) def test_partner_and_user_have_the_same_avatar(self): self.assertEqual(self.user_without_image.partner_id.avatar_1920, self.user_without_image.avatar_1920) diff --git a/odoo/addons/base/tests/test_image.py b/odoo/addons/base/tests/test_image.py index 1966ece06f1..9309a0d07b7 100644 --- a/odoo/addons/base/tests/test_image.py +++ b/odoo/addons/base/tests/test_image.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import base64 +import io import binascii from PIL import Image, ImageDraw, PngImagePlugin @@ -11,6 +12,10 @@ from odoo.exceptions import UserError from odoo.tests.common import TransactionCase +def img_open(data): + return Image.open(io.BytesIO(data)) + + class TestImage(TransactionCase): """Tests for the different image tools helpers.""" def setUp(self): @@ -18,12 +23,12 @@ class TestImage(TransactionCase): self.bg_color = (135, 90, 123) self.fill_color = (0, 160, 157) - self.base64_1x1_png = b'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYGAAAAAEAAH2FzhVAAAAAElFTkSuQmCC' - self.base64_svg = base64.b64encode(b'') - self.base64_1920x1080_jpeg = tools.image_to_base64(Image.new('RGB', (1920, 1080)), 'JPEG') + self.img_1x1_png = base64.b64decode(b'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYGAAAAAEAAH2FzhVAAAAAElFTkSuQmCC') + self.img_svg = b'' + self.img_1920x1080_jpeg = tools.image_apply_opt(Image.new('RGB', (1920, 1080)), 'JPEG') # The following image contains a tag `Lens Info` with a value of `3.99mm f/1.8` # This particular tag 0xa432 makes the `exif_transpose` method fail in 5.4.1 < Pillow < 7.2.0 - self.base64_exif_jpg = b"""/9j/4AAQSkZJRgABAQAAAQABAAD/4QDQRXhpZgAATU0AKgAAAAgABgESAAMAAAABAAYAAAEaAAUA + self.img_exif_jpg = base64.b64decode(b"""/9j/4AAQSkZJRgABAQAAAQABAAD/4QDQRXhpZgAATU0AKgAAAAgABgESAAMAAAABAAYAAAEaAAUA AAABAAAAVgEbAAUAAAABAAAAXgEoAAMAAAABAAEAAAITAAMAAAABAAEAAIdpAAQAAAABAAAAZgAA AAAAAAABAAAAAQAAAAEAAAABAAWQAAAHAAAABDAyMzGRAQAHAAAABAECAwCgAAAHAAAABDAxMDCg AQADAAAAAf//AACkMgAFAAAABAAAAKgAAAAAAAABjwAAAGQAAAGPAAAAZAAAAAkAAAAFAAAACQAA @@ -37,7 +42,7 @@ class TestImage(TransactionCase): AwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHB CSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0 dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX - 2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD3+iiigD//2Q==""" + 2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD3+iiigD//2Q==""") # Draw a red square in the middle of the image, this will be used to # verify crop is working. The border is going to be `self.bg_color` and @@ -51,7 +56,7 @@ class TestImage(TransactionCase): (offset, 0), (image.size[0] - offset, image.size[1]) ], fill=self.fill_color) - self.base64_1920x1080_png = tools.image_to_base64(image, 'PNG') + self.img_1920x1080_png = tools.image_apply_opt(image, 'PNG') # vertical image (border is top/bottom) image = Image.new('RGB', (1080, 1920), color=self.bg_color) @@ -61,18 +66,14 @@ class TestImage(TransactionCase): (0, offset), (image.size[0], image.size[1] - offset) ], fill=self.fill_color) - self.base64_1080x1920_png = tools.image_to_base64(image, 'PNG') + self.img_1080x1920_png = tools.image_apply_opt(image, 'PNG') def test_00_base64_to_image(self): """Test that base64 is correctly opened as a PIL image.""" - image = tools.base64_to_image(self.base64_1x1_png) + image = img_open(self.img_1x1_png) self.assertEqual(type(image), PngImagePlugin.PngImageFile, "base64 as bytes, correct format") self.assertEqual(image.size, (1, 1), "base64 as bytes, correct size") - image = tools.base64_to_image(self.base64_1x1_png.decode('ASCII')) - self.assertEqual(type(image), PngImagePlugin.PngImageFile, "base64 as string, correct format") - self.assertEqual(image.size, (1, 1), "base64 as string, correct size") - with self.assertRaises(UserError, msg="This file could not be decoded as an image file. Please try with a different file."): image = tools.base64_to_image(b'oazdazpodazdpok') @@ -83,7 +84,7 @@ class TestImage(TransactionCase): """Test that a PIL image is correctly saved as base64.""" image = Image.new('RGB', (1, 1)) image_base64 = tools.image_to_base64(image, 'PNG') - self.assertEqual(image_base64, self.base64_1x1_png) + self.assertEqual(image_base64, base64.b64encode(self.img_1x1_png)) def test_02_image_fix_orientation(self): """Test that the orientation of images is correct.""" @@ -110,84 +111,74 @@ class TestImage(TransactionCase): def test_03_image_fix_orientation_exif(self): """Test that a jpg image with exif orientation tag gets rotated""" - image = tools.base64_to_image(self.base64_exif_jpg) + image = img_open(self.img_exif_jpg) self.assertEqual(image.size, (6,3)) image = tools.image_fix_orientation(image) self.assertEqual(image.size, (3,6)) - def test_10_image_process_base64_source(self): - """Test the base64_source parameter of image_process.""" - wrong_base64 = b'oazdazpodazdpok' - - self.assertFalse(tools.image_process(False), "return False if base64_source is falsy") - self.assertEqual(tools.image_process(self.base64_svg), self.base64_svg, "return base64_source if format is SVG") + def test_10_image_process_source(self): + """Test the source parameter of image_process.""" + self.assertFalse(tools.image_process(False), "return False if source is falsy") + self.assertEqual(tools.image_process(self.img_svg), self.img_svg, "return source if format is SVG") # in the following tests, pass `quality` to force the processing - with self.assertRaises(UserError, msg="This file could not be decoded as an image file. Please try with a different file."): - tools.image_process(wrong_base64, quality=95) - with self.assertRaises(UserError, msg="This file could not be decoded as an image file. Please try with a different file."): tools.image_process(b'oazdazpodazdpokd', quality=95) - image = tools.base64_to_image(tools.image_process(self.base64_1920x1080_jpeg, quality=95)) + image = img_open(tools.image_process(self.img_1920x1080_jpeg, quality=95)) self.assertEqual(image.size, (1920, 1080), "OK return the image") - # test that nothing happens if no operation has been requested - # (otherwise those would raise because of wrong base64) - self.assertEqual(tools.image_process(wrong_base64), wrong_base64) - self.assertEqual(tools.image_process(wrong_base64, size=False), wrong_base64) - def test_11_image_process_size(self): """Test the size parameter of image_process.""" - # Format of `tests`: (original base64 image, size parameter, expected result, text) + # Format of `tests`: (original image, size parameter, expected result, text) tests = [ - (self.base64_1920x1080_jpeg, (192, 108), (192, 108), "resize to given size"), - (self.base64_1920x1080_jpeg, (1920, 1080), (1920, 1080), "same size, no change"), - (self.base64_1920x1080_jpeg, (192, None), (192, 108), "set height from ratio"), - (self.base64_1920x1080_jpeg, (0, 108), (192, 108), "set width from ratio"), - (self.base64_1920x1080_jpeg, (192, 200), (192, 108), "adapt to width"), - (self.base64_1920x1080_jpeg, (400, 108), (192, 108), "adapt to height"), - (self.base64_1920x1080_jpeg, (3000, 2000), (1920, 1080), "don't resize above original, both set"), - (self.base64_1920x1080_jpeg, (3000, False), (1920, 1080), "don't resize above original, width set"), - (self.base64_1920x1080_jpeg, (None, 2000), (1920, 1080), "don't resize above original, height set"), - (self.base64_1080x1920_png, (3000, 192), (108, 192), "vertical image, resize if below"), + (self.img_1920x1080_jpeg, (192, 108), (192, 108), "resize to given size"), + (self.img_1920x1080_jpeg, (1920, 1080), (1920, 1080), "same size, no change"), + (self.img_1920x1080_jpeg, (192, None), (192, 108), "set height from ratio"), + (self.img_1920x1080_jpeg, (0, 108), (192, 108), "set width from ratio"), + (self.img_1920x1080_jpeg, (192, 200), (192, 108), "adapt to width"), + (self.img_1920x1080_jpeg, (400, 108), (192, 108), "adapt to height"), + (self.img_1920x1080_jpeg, (3000, 2000), (1920, 1080), "don't resize above original, both set"), + (self.img_1920x1080_jpeg, (3000, False), (1920, 1080), "don't resize above original, width set"), + (self.img_1920x1080_jpeg, (None, 2000), (1920, 1080), "don't resize above original, height set"), + (self.img_1080x1920_png, (3000, 192), (108, 192), "vertical image, resize if below"), ] count = 0 for test in tests: - image = tools.base64_to_image(tools.image_process(test[0], size=test[1])) + image = img_open(tools.image_process(test[0], size=test[1])) self.assertEqual(image.size, test[2], test[3]) count = count + 1 self.assertEqual(count, 10, "ensure the loop is ran") def test_12_image_process_verify_resolution(self): """Test the verify_resolution parameter of image_process.""" - res = tools.image_process(self.base64_1920x1080_jpeg, verify_resolution=True) + res = tools.image_process(self.img_1920x1080_jpeg, verify_resolution=True) self.assertNotEqual(res, False, "size ok") - base64_image_excessive = tools.image_to_base64(Image.new('RGB', (45001, 1000)), 'PNG') + image_excessive = tools.image_apply_opt(Image.new('RGB', (45001, 1000)), 'PNG') with self.assertRaises(ValueError, msg="size excessive"): - tools.image_process(base64_image_excessive, verify_resolution=True) + tools.image_process(image_excessive, verify_resolution=True) def test_13_image_process_quality(self): """Test the quality parameter of image_process.""" # CASE: PNG RGBA doesn't apply quality, just optimize - image = tools.image_to_base64(Image.new('RGBA', (1080, 1920)), 'PNG') + image = tools.image_apply_opt(Image.new('RGBA', (1080, 1920)), 'PNG') res = tools.image_process(image) self.assertLessEqual(len(res), len(image)) # CASE: PNG RGB doesn't apply quality, just optimize - image = tools.image_to_base64(Image.new('P', (1080, 1920)), 'PNG') + image = tools.image_apply_opt(Image.new('P', (1080, 1920)), 'PNG') res = tools.image_process(image) self.assertLessEqual(len(res), len(image)) # CASE: JPEG optimize + reduced quality - res = tools.image_process(self.base64_1920x1080_jpeg) - self.assertLessEqual(len(res), len(self.base64_1920x1080_jpeg)) + res = tools.image_process(self.img_1920x1080_jpeg) + self.assertLessEqual(len(res), len(self.img_1920x1080_jpeg)) # CASE: GIF doesn't apply quality, just optimize - image = tools.image_to_base64(Image.new('RGB', (1080, 1920)), 'GIF') + image = tools.image_apply_opt(Image.new('RGB', (1080, 1920)), 'GIF') res = tools.image_process(image) self.assertLessEqual(len(res), len(image)) @@ -200,32 +191,32 @@ class TestImage(TransactionCase): # Format of `tests`: (original base64 image, size parameter, crop parameter, res size, res color (top, bottom, left, right), text) tests = [ - (self.base64_1920x1080_png, None, None, (1920, 1080), (fill, fill, bg, bg), "horizontal, verify initial"), - (self.base64_1920x1080_png, (2000, 2000), 'center', (1080, 1080), (fill, fill, fill, fill), "horizontal, crop biggest possible"), - (self.base64_1920x1080_png, (2000, 4000), 'center', (540, 1080), (fill, fill, fill, fill), "horizontal, size vertical, limit height"), - (self.base64_1920x1080_png, (4000, 2000), 'center', (1920, 960), (fill, fill, bg, bg), "horizontal, size horizontal, limit width"), - (self.base64_1920x1080_png, (512, 512), 'center', (512, 512), (fill, fill, fill, fill), "horizontal, type center"), - (self.base64_1920x1080_png, (512, 512), 'top', (512, 512), (fill, fill, fill, fill), "horizontal, type top"), - (self.base64_1920x1080_png, (512, 512), 'bottom', (512, 512), (fill, fill, fill, fill), "horizontal, type bottom"), - (self.base64_1920x1080_png, (512, 512), 'wrong', (512, 512), (fill, fill, fill, fill), "horizontal, wrong crop value, use center"), - (self.base64_1920x1080_png, (192, 0), None, (192, 108), (fill, fill, bg, bg), "horizontal, not cropped, just do resize"), + (self.img_1920x1080_png, None, None, (1920, 1080), (fill, fill, bg, bg), "horizontal, verify initial"), + (self.img_1920x1080_png, (2000, 2000), 'center', (1080, 1080), (fill, fill, fill, fill), "horizontal, crop biggest possible"), + (self.img_1920x1080_png, (2000, 4000), 'center', (540, 1080), (fill, fill, fill, fill), "horizontal, size vertical, limit height"), + (self.img_1920x1080_png, (4000, 2000), 'center', (1920, 960), (fill, fill, bg, bg), "horizontal, size horizontal, limit width"), + (self.img_1920x1080_png, (512, 512), 'center', (512, 512), (fill, fill, fill, fill), "horizontal, type center"), + (self.img_1920x1080_png, (512, 512), 'top', (512, 512), (fill, fill, fill, fill), "horizontal, type top"), + (self.img_1920x1080_png, (512, 512), 'bottom', (512, 512), (fill, fill, fill, fill), "horizontal, type bottom"), + (self.img_1920x1080_png, (512, 512), 'wrong', (512, 512), (fill, fill, fill, fill), "horizontal, wrong crop value, use center"), + (self.img_1920x1080_png, (192, 0), None, (192, 108), (fill, fill, bg, bg), "horizontal, not cropped, just do resize"), - (self.base64_1080x1920_png, None, None, (1080, 1920), (bg, bg, fill, fill), "vertical, verify initial"), - (self.base64_1080x1920_png, (2000, 2000), 'center', (1080, 1080), (fill, fill, fill, fill), "vertical, crop biggest possible"), - (self.base64_1080x1920_png, (2000, 4000), 'center', (960, 1920), (bg, bg, fill, fill), "vertical, size vertical, limit height"), - (self.base64_1080x1920_png, (4000, 2000), 'center', (1080, 540), (fill, fill, fill, fill), "vertical, size horizontal, limit width"), - (self.base64_1080x1920_png, (512, 512), 'center', (512, 512), (fill, fill, fill, fill), "vertical, type center"), - (self.base64_1080x1920_png, (512, 512), 'top', (512, 512), (bg, fill, fill, fill), "vertical, type top"), - (self.base64_1080x1920_png, (512, 512), 'bottom', (512, 512), (fill, bg, fill, fill), "vertical, type bottom"), - (self.base64_1080x1920_png, (512, 512), 'wrong', (512, 512), (fill, fill, fill, fill), "vertical, wrong crop value, use center"), - (self.base64_1080x1920_png, (108, 0), None, (108, 192), (bg, bg, fill, fill), "vertical, not cropped, just do resize"), + (self.img_1080x1920_png, None, None, (1080, 1920), (bg, bg, fill, fill), "vertical, verify initial"), + (self.img_1080x1920_png, (2000, 2000), 'center', (1080, 1080), (fill, fill, fill, fill), "vertical, crop biggest possible"), + (self.img_1080x1920_png, (2000, 4000), 'center', (960, 1920), (bg, bg, fill, fill), "vertical, size vertical, limit height"), + (self.img_1080x1920_png, (4000, 2000), 'center', (1080, 540), (fill, fill, fill, fill), "vertical, size horizontal, limit width"), + (self.img_1080x1920_png, (512, 512), 'center', (512, 512), (fill, fill, fill, fill), "vertical, type center"), + (self.img_1080x1920_png, (512, 512), 'top', (512, 512), (bg, fill, fill, fill), "vertical, type top"), + (self.img_1080x1920_png, (512, 512), 'bottom', (512, 512), (fill, bg, fill, fill), "vertical, type bottom"), + (self.img_1080x1920_png, (512, 512), 'wrong', (512, 512), (fill, fill, fill, fill), "vertical, wrong crop value, use center"), + (self.img_1080x1920_png, (108, 0), None, (108, 192), (bg, bg, fill, fill), "vertical, not cropped, just do resize"), ] count = 0 for test in tests: count = count + 1 # process the image, pass quality to make sure the result is palette - image = tools.base64_to_image(tools.image_process(test[0], size=test[1], crop=test[2], quality=95)) + image = img_open(tools.image_process(test[0], size=test[1], crop=test[2], quality=95)) # verify size self.assertEqual(image.size, test[3], "%s - correct size" % test[5]) @@ -253,44 +244,44 @@ class TestImage(TransactionCase): image_rgba = Image.new('RGBA', (1, 1)) self.assertEqual(image_rgba.mode, 'RGBA') self.assertEqual(image_rgba.getpixel((0, 0)), (0, 0, 0, 0)) - base64_rgba = tools.image_to_base64(image_rgba, 'PNG') + rgba = tools.image_apply_opt(image_rgba, 'PNG') # CASE: color random, color has changed - image = tools.base64_to_image(tools.image_process(base64_rgba, colorize=True)) + image = img_open(tools.image_process(rgba, colorize=True)) self.assertEqual(image.mode, 'RGB') self.assertNotEqual(image.getpixel((0, 0)), (0, 0, 0)) def test_16_image_process_format(self): """Test the format parameter of image_process.""" - image = tools.base64_to_image(tools.image_process(self.base64_1920x1080_jpeg, output_format='PNG')) + image = img_open(tools.image_process(self.img_1920x1080_jpeg, output_format='PNG')) self.assertEqual(image.format, 'PNG', "change format to PNG") - image = tools.base64_to_image(tools.image_process(self.base64_1x1_png, output_format='JpEg')) + image = img_open(tools.image_process(self.img_1x1_png, output_format='JpEg')) self.assertEqual(image.format, 'JPEG', "change format to JPEG (case insensitive)") - image = tools.base64_to_image(tools.image_process(self.base64_1920x1080_jpeg, output_format='BMP')) + image = img_open(tools.image_process(self.img_1920x1080_jpeg, output_format='BMP')) self.assertEqual(image.format, 'PNG', "change format to BMP converted to PNG") - self.base64_image_1080_1920_rgba = tools.image_to_base64(Image.new('RGBA', (108, 192)), 'PNG') - image = tools.base64_to_image(tools.image_process(self.base64_image_1080_1920_rgba, output_format='jpeg')) + image_1080_1920_rgba = tools.image_apply_opt(Image.new('RGBA', (108, 192)), 'PNG') + image = img_open(tools.image_process(image_1080_1920_rgba, output_format='jpeg')) self.assertEqual(image.format, 'JPEG', "change format PNG with RGBA to JPEG") # pass quality to force the image to be processed - self.base64_image_1080_1920_tiff = tools.image_to_base64(Image.new('RGB', (108, 192)), 'TIFF') - image = tools.base64_to_image(tools.image_process(self.base64_image_1080_1920_tiff, quality=95)) + image_1080_1920_tiff = tools.image_apply_opt(Image.new('RGB', (108, 192)), 'TIFF') + image = img_open(tools.image_process(image_1080_1920_tiff, quality=95)) self.assertEqual(image.format, 'JPEG', "unsupported format to JPEG") def test_20_image_data_uri(self): """Test that image_data_uri is working as expected.""" - self.assertEqual(tools.image_data_uri(self.base64_1x1_png), 'data:image/png;base64,' + self.base64_1x1_png.decode('ascii')) + self.assertEqual(tools.image_data_uri(base64.b64encode(self.img_1x1_png)), 'data:image/png;base64,' + base64.b64encode(self.img_1x1_png).decode('ascii')) def _assertAlmostEqualSequence(self, rgb1, rgb2, delta=10): self.assertEqual(len(rgb1), len(rgb2)) for index, t in enumerate(zip(rgb1, rgb2)): self.assertAlmostEqual(t[0], t[1], delta=delta, msg="%s vs %s at %d" % (rgb1, rgb2, index)) - def _get_exif_colored_square_b64(self, orientation, colors, size): + def _get_exif_colored_square(self, orientation, colors, size): image = Image.new('RGB', (size, size), color=self.bg_color) draw = ImageDraw.Draw(image) # Paint the colors on the 4 corners, to be able to test which colors @@ -302,13 +293,13 @@ class TestImage(TransactionCase): # Set the proper exif tag based on orientation params. exif = b'Exif\x00\x00II*\x00\x08\x00\x00\x00\x01\x00\x12\x01\x03\x00\x01\x00\x00\x00' + bytes([orientation]) + b'\x00\x00\x00\x00\x00\x00\x00' # The image image is saved with the exif tag. - return tools.image_to_base64(image, 'JPEG', exif=exif) + return tools.image_apply_opt(image, 'JPEG', exif=exif) def _orientation_test(self, orientation, colors, size, expected): # Generate the test image based on orientation and order of colors. - b64_image = self._get_exif_colored_square_b64(orientation, colors, size) + image = self._get_exif_colored_square(orientation, colors, size) # The image is read again now that it has orientation added. - fixed_image = tools.image_fix_orientation(tools.base64_to_image(b64_image)) + fixed_image = tools.image_fix_orientation(img_open(image)) # Ensure colors are in the right order (blue, yellow, green, pink). self._assertAlmostEqualSequence(fixed_image.getpixel((0, 0)), expected[0]) # top/left self._assertAlmostEqualSequence(fixed_image.getpixel((size - 1, 0)), expected[1]) # top/right diff --git a/odoo/addons/base/tests/test_ir_http.py b/odoo/addons/base/tests/test_ir_http.py index 542fe9c07a6..5575dcd6370 100644 --- a/odoo/addons/base/tests/test_ir_http.py +++ b/odoo/addons/base/tests/test_ir_http.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from odoo.tests import common +import base64 import odoo GIF = b"R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs=" @@ -62,9 +63,9 @@ class test_ir_http_mimetype(common.TransactionCase): 'type': 'binary', }) - resized = odoo.tools.image_process(prop.value_binary, size=(64, 64)) + resized = odoo.tools.image_process(base64.b64decode(prop.value_binary), size=(64, 64)) # Simul computed field which resize and that is not attachement=True (E.G. on product) - prop.write({'value_binary': resized}) + prop.write({'value_binary': base64.b64encode(resized)}) status, headers, content = self.env['ir.http'].binary_content( model='ir.property', id=prop.id, diff --git a/odoo/fields.py b/odoo/fields.py index 20f10d23808..ff2a896591b 100644 --- a/odoo/fields.py +++ b/odoo/fields.py @@ -2356,10 +2356,17 @@ class Image(Binary): records.env.cache.update(records, self, [cache_value] * len(records)) def _image_process(self, value): - return image_process(value, + if self.readonly and not self.max_width and not self.max_height: + # no need to process images for computed fields, or related fields + return value + try: + img = base64.b64decode(value or '') or False + except: + raise UserError(_("Image is not encoded in base64.")) + return base64.b64encode(image_process(img, size=(self.max_width, self.max_height), verify_resolution=self.verify_resolution, - ) + ) or b'') or False def _process_related(self, value): """Override to resize the related value before saving it on self.""" diff --git a/odoo/tools/image.py b/odoo/tools/image.py index 2494b6bd5ab..8ec30e3fc6b 100644 --- a/odoo/tools/image.py +++ b/odoo/tools/image.py @@ -49,14 +49,13 @@ IMAGE_MAX_RESOLUTION = 45e6 class ImageProcess(): - def __init__(self, base64_source, verify_resolution=True): - """Initialize the `base64_source` image for processing. + def __init__(self, source, verify_resolution=True): + """Initialize the `source` image for processing. - :param base64_source: the original image base64 encoded + :param source: the original image binary - No processing will be done if the `base64_source` is falsy or if + No processing will be done if the `source` is falsy or if the image is SVG. - :type base64_source: string or bytes :param verify_resolution: if True, make sure the original image size is not excessive before starting to process it. The max allowed resolution is @@ -65,16 +64,19 @@ class ImageProcess(): :rtype: ImageProcess :raise: ValueError if `verify_resolution` is True and the image is too large - :raise: UserError if the base64 is incorrect or the image can't be identified by PIL + :raise: UserError if the image can't be identified by PIL """ - self.base64_source = base64_source or False + self.source = source or False self.operationsCount = 0 - if not base64_source or base64_source[:1] in (b'P', 'P'): + if not source or source[:1] == b'<': # don't process empty source or SVG self.image = False else: - self.image = base64_to_image(self.base64_source) + try: + self.image = Image.open(io.BytesIO(source)) + except (OSError, binascii.Error): + raise UserError(_("This file could not be decoded as an image file.")) # Original format has to be saved before fixing the orientation or # doing any other operations because the information will be lost on @@ -112,7 +114,7 @@ class ImageProcess(): :rtype: bytes or False """ if not self.image: - return self.image + return self.source output_image = self.image @@ -123,7 +125,7 @@ class ImageProcess(): output_format = 'JPEG' if not self.operationsCount and output_format == self.original_format and not quality: - return self.image + return self.source opt = {'output_format': output_format} @@ -145,40 +147,6 @@ class ImageProcess(): return image_apply_opt(output_image, **opt) - def image_quality_base64(self, quality=0, output_format=''): - """Return the base64 encoded image resulting of all the image processing - operations that have been applied previously. - - Return False if the initialized `base64_source` was falsy, and return - the initialized `base64_source` without change if it was SVG. - - Also return the initialized `base64_source` if no operations have been - applied and the `output_format` is the same as the original format and - the quality is not specified. - - :param int quality: quality setting to apply. Default to 0. - - - for JPEG: 1 is worse, 95 is best. Values above 95 should be - avoided. Falsy values will fallback to 95, but only if the image - was changed, otherwise the original image is returned. - - for PNG: set falsy to prevent conversion to a WEB palette. - - for other formats: no effect. - :param str output_format: the output format. Can be PNG, JPEG, GIF, or ICO. - Default to the format of the original image. BMP is converted to - PNG, other formats than those mentioned above are converted to JPEG. - :return: image base64 encoded or False - :rtype: bytes or False - """ - - if not self.image: - return self.base64_source - - stream = self.image_quality(quality=quality, output_format=output_format) - - if stream != self.image: - return base64.b64encode(stream) - return self.base64_source - def resize(self, max_width=0, max_height=0): """Resize the image. @@ -280,16 +248,16 @@ class ImageProcess(): return self -def image_process(base64_source, size=(0, 0), verify_resolution=False, quality=0, crop=None, colorize=False, output_format=''): - """Process the `base64_source` image by executing the given operations and - return the result as a base64 encoded image. +def image_process(source, size=(0, 0), verify_resolution=False, quality=0, crop=None, colorize=False, output_format=''): + """Process the `source` image by executing the given operations and + return the result image. """ - if not base64_source or ((not size or (not size[0] and not size[1])) and not verify_resolution and not quality and not crop and not colorize and not output_format): + if not source or ((not size or (not size[0] and not size[1])) and not verify_resolution and not quality and not crop and not colorize and not output_format): # for performance: don't do anything if the image is falsy or if # no operations have been requested - return base64_source + return source - image = ImageProcess(base64_source, verify_resolution) + image = ImageProcess(source, verify_resolution) if size: if crop: center_x = 0.5 @@ -303,7 +271,7 @@ def image_process(base64_source, size=(0, 0), verify_resolution=False, quality=0 image.resize(max_width=size[0], max_height=size[1]) if colorize: image.colorize() - return image.image_quality_base64(quality=quality, output_format=output_format) + return image.image_quality(quality=quality, output_format=output_format) # ---------------------------------------- @@ -411,6 +379,12 @@ def image_fix_orientation(image): return image +def binary_to_image(source): + try: + return Image.open(io.BytesIO(source)) + except (OSError, binascii.Error): + raise UserError(_("This file could not be decoded as an image file.")) + def base64_to_image(base64_source): """Return a PIL image from the given `base64_source`. @@ -422,7 +396,7 @@ def base64_to_image(base64_source): try: return Image.open(io.BytesIO(base64.b64decode(base64_source))) except (OSError, binascii.Error): - raise UserError(_("This file could not be decoded as an image file. Please try with a different file.")) + raise UserError(_("This file could not be decoded as an image file.")) def image_apply_opt(image, output_format, **params): @@ -523,12 +497,3 @@ def rgb_to_hex(rgb): """Converts a RGB tuple or list to an hexadecimal string""" return '#' + ''.join([(hex(c).split('x')[-1].zfill(2)) for c in rgb]) - -if __name__=="__main__": - import sys - - assert len(sys.argv)==3, 'Usage to Test: image.py SRC.png DEST.png' - - img = base64.b64encode(open(sys.argv[1],'rb').read()) - new = image_process(img, size=(128, 100)) - open(sys.argv[2], 'wb').write(base64.b64decode(new))