[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 <fp@odoo.com>
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1436,7 +1436,7 @@ class Binary(http.Controller):
|
||||
'/web/image/<int:id>-<string:unique>/<string:filename>',
|
||||
'/web/image/<int:id>-<string:unique>/<int:width>x<int:height>',
|
||||
'/web/image/<int:id>-<string:unique>/<int:width>x<int:height>/<string:filename>'], 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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/<string:img_key>/<module>/<path:filename>'], 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('<image xlink:href="', '<image xlink:href="%s' % uri)
|
||||
|
||||
return request.make_response(svg, [
|
||||
|
||||
@@ -144,5 +144,5 @@ def get_video_thumbnail(video_url):
|
||||
response = requests.get(f'https://www.instagram.com/p/{video_id}/media/?size=t', timeout=10)
|
||||
|
||||
if response and response.ok:
|
||||
return image_process(base64.b64encode(response.content))
|
||||
return image_process(response.content)
|
||||
return None
|
||||
|
||||
@@ -90,7 +90,7 @@ class Web_Unsplash(http.Controller):
|
||||
continue
|
||||
|
||||
# get mime-type of image url because unsplash url dosn't contains mime-types in url
|
||||
image_base64 = base64.b64encode(req.content)
|
||||
image = req.content
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
logger.exception("Connection Error: " + str(e))
|
||||
continue
|
||||
@@ -98,8 +98,8 @@ class Web_Unsplash(http.Controller):
|
||||
logger.exception("Timeout: " + str(e))
|
||||
continue
|
||||
|
||||
image_base64 = tools.image_process(image_base64, verify_resolution=True)
|
||||
mimetype = guess_mimetype(base64.b64decode(image_base64))
|
||||
image = tools.image_process(image, verify_resolution=True)
|
||||
mimetype = guess_mimetype(image)
|
||||
# append image extension in name
|
||||
query += mimetypes.guess_extension(mimetype) or ''
|
||||
|
||||
@@ -110,7 +110,7 @@ class Web_Unsplash(http.Controller):
|
||||
'name': '_'.join(url_frags),
|
||||
'url': '/' + '/'.join(url_frags),
|
||||
'mimetype': mimetype,
|
||||
'datas': image_base64,
|
||||
'raw': image,
|
||||
'public': res_model == 'ir.ui.view',
|
||||
'res_id': res_id,
|
||||
'res_model': res_model,
|
||||
|
||||
@@ -248,7 +248,7 @@ class Website(models.Model):
|
||||
@api.model
|
||||
def _handle_favicon(self, vals):
|
||||
if 'favicon' in vals:
|
||||
vals['favicon'] = tools.image_process(vals['favicon'], size=(256, 256), crop='center', output_format='ICO')
|
||||
vals['favicon'] = base64.b64encode(tools.image_process(base64.b64decode(vals['favicon']), size=(256, 256), crop='center', output_format='ICO'))
|
||||
|
||||
@api.model
|
||||
def _handle_domain(self, vals):
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
|
||||
from PIL import Image
|
||||
import base64
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
@@ -37,10 +38,10 @@ class Website(models.Model):
|
||||
if not website.favicon:
|
||||
website.app_icon = False
|
||||
continue
|
||||
image = ImageProcess(website.favicon)
|
||||
image = ImageProcess(base64.b64decode(website.favicon))
|
||||
w, h = image.image.size
|
||||
square_size = w if w > 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'))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'<svg></svg>')
|
||||
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'<svg></svg>'
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+9
-2
@@ -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."""
|
||||
|
||||
+27
-62
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user