[MERGE] tools, base, *: improve image tools

Merge commit for PR: #31811

The main goal is to keep the image ratio on resize (instead of adding a white border).

The opportunity is taken to also clean up the image tools.

See individual commit messages for more information.

Functional:

- Remove the white border from all images (never resize to square, keep aspect ratio)
- Prevent some images from being 3px shorter than expected
- Resize the website favicons appropriately, always encode them as ICO, add a default favicon to new websites
- Secure images going through the tools by returning an error when non-image content is given
- Incrase the max image filesize from 42e6 to 45e6 to fit some of the biggest images we might get from Unsplash
- Always return the correct mimetype/Content-Type for images
- The avatar placeholder was white displayed on top of white, now it's grey so we can actually see it

Technical:

- make image tools more consistent by merging the tools and removing duplicate code
- add tests for the image tools
- remove uncessary parameters
- remove the "ghost border fix"

task-1958000

Signed-off-by: Olivier Dony (odo) <odo@openerp.com>
This commit is contained in:
Robot Odoo
2019-04-29 17:00:46 +02:00
committed by GitHub
33 changed files with 748 additions and 471 deletions
+1 -1
View File
@@ -106,7 +106,7 @@ class Employee(models.Model):
@api.model
def _default_image(self):
image_path = get_module_resource('hr', 'static/src/img', 'default_image.png')
return tools.image_resize_image_big(base64.b64encode(open(image_path, 'rb').read()))
return tools.image_process(base64.b64encode(open(image_path, 'rb').read()), size=tools.IMAGE_BIG_SIZE)
# resource and user
# required on the resource, make sure required="True" set in the view
@@ -21,7 +21,7 @@ class ImLivechatChannel(models.Model):
def _default_image(self):
image_path = modules.get_module_resource('im_livechat', 'static/src/img', 'default.png')
return tools.image_resize_image_big(base64.b64encode(open(image_path, 'rb').read()))
return tools.image_process(base64.b64encode(open(image_path, 'rb').read()), size=tools.IMAGE_BIG_SIZE)
def _default_user_ids(self):
return [(6, 0, [self._uid])]
+1 -1
View File
@@ -64,7 +64,7 @@ class Channel(models.Model):
def _get_default_image(self):
image_path = modules.get_module_resource('mail', 'static/src/img', 'groupdefault.png')
return tools.image_resize_image_big(base64.b64encode(open(image_path, 'rb').read()))
return tools.image_process(base64.b64encode(open(image_path, 'rb').read()), size=tools.IMAGE_BIG_SIZE)
@api.model
def default_get(self, fields):
+6 -7
View File
@@ -7,11 +7,10 @@ from datetime import datetime
from dateutil import relativedelta
import pprint
from odoo import api, exceptions, fields, models, _
from odoo.tools import consteq, float_round, image_resize_images, image_resize_image, ustr
from odoo import api, exceptions, fields, models, _, SUPERUSER_ID
from odoo.tools import consteq, float_round, image_resize_images, image_process, ustr
from odoo.addons.base.models import ir_module
from odoo.exceptions import ValidationError
from odoo import api, SUPERUSER_ID
from odoo.tools.misc import DEFAULT_SERVER_DATETIME_FORMAT
from odoo.tools.misc import formatLang
@@ -525,16 +524,16 @@ class PaymentIcon(models.Model):
for vals in vals_list:
if 'image' in vals:
image = ustr(vals['image'] or '').encode('utf-8')
vals['image_payment_form'] = image_resize_image(image, size=(45,30))
vals['image'] = image_resize_image(image, size=(64,64))
vals['image_payment_form'] = image_process(image, size=(45,30))
vals['image'] = image_process(image, size=(64,64))
return super(PaymentIcon, self).create(vals_list)
@api.multi
def write(self, vals):
if 'image' in vals:
image = ustr(vals['image'] or '').encode('utf-8')
vals['image_payment_form'] = image_resize_image(image, size=(45,30))
vals['image'] = image_resize_image(image, size=(64,64))
vals['image_payment_form'] = image_process(image, size=(45,30))
vals['image'] = image_process(image, size=(64,64))
return super(PaymentIcon, self).write(vals)
class PaymentTransaction(models.Model):
+1 -1
View File
@@ -168,7 +168,7 @@ class ProductProduct(models.Model):
for record in self:
images = tools.image_get_resized_images(record.image_raw_original, big_name=False)
record.image_raw_big = tools.image_get_resized_images(record.image_raw_original,
large_name=False, medium_name=False, small_name=False, preserve_aspect_ratio=True)['image']
large_name=False, medium_name=False, small_name=False)['image']
record.image_raw_large = images['image_large']
record.image_raw_medium = images['image_medium']
record.image_raw_small = images['image_small']
+2 -2
View File
@@ -7,7 +7,7 @@ from collections import OrderedDict
from odoo import http
from odoo.exceptions import AccessError, MissingError
from odoo.http import request
from odoo.tools import image_resize_image
from odoo.tools import image_process
from odoo.tools.translate import _
from odoo.addons.portal.controllers.portal import pager as portal_pager, CustomerPortal
from odoo.addons.web.controllers.main import Binary
@@ -28,7 +28,7 @@ class CustomerPortal(CustomerPortal):
def resize_to_48(b64source):
if not b64source:
b64source = base64.b64encode(Binary().placeholder())
return image_resize_image(b64source, size=(48, 48))
return image_process(b64source, size=(48, 48))
values = {
'order': order,
+12 -22
View File
@@ -36,7 +36,7 @@ import odoo
import odoo.modules.registry
from odoo.api import call_kw, Environment
from odoo.modules import get_module_path, get_resource_path
from odoo.tools import limited_image_resize, topological_sort, html_escape, pycompat
from odoo.tools import image_process, topological_sort, html_escape, pycompat
from odoo.tools.mimetypes import guess_mimetype
from odoo.tools.translate import _
from odoo.tools.misc import str2bool, xlwt, file_open
@@ -971,11 +971,6 @@ class Binary(http.Controller):
addons_path = http.addons_manifest['web']['addons_path']
return open(os.path.join(addons_path, 'web', 'static', 'src', 'img', image), 'rb').read()
def force_contenttype(self, headers, contenttype='image/png'):
dictheaders = dict(headers)
dictheaders['Content-Type'] = contenttype
return list(dictheaders.items())
@http.route(['/web/content',
'/web/content/<string:xmlid>',
'/web/content/<string:xmlid>/<string:filename>',
@@ -1023,30 +1018,25 @@ class Binary(http.Controller):
'/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',
filename_field='datas_fname', unique=None, filename=None, mimetype=None,
download=None, width=0, height=0, crop=False, access_token=None, avoid_if_small=False,
upper_limit=False, placeholder='placeholder.png', **kw):
status, headers, content = request.env['ir.http'].binary_content(
download=None, width=0, height=0, crop=False, access_token=None,
placeholder='placeholder.png', **kwargs):
status, headers, image_base64 = request.env['ir.http'].binary_content(
xmlid=xmlid, model=model, id=id, field=field, unique=unique, filename=filename,
filename_field=filename_field, download=download, mimetype=mimetype,
default_mimetype='image/png', access_token=access_token)
if status == 301 or (status != 200 and download):
return request.env['ir.http']._response_by_status(status, headers, content)
if not content:
content = base64.b64encode(self.placeholder(image=placeholder))
headers = self.force_contenttype(headers, contenttype='image/png')
return request.env['ir.http']._response_by_status(status, headers, image_base64)
if not image_base64:
image_base64 = base64.b64encode(self.placeholder(image=placeholder))
if not (width or height):
suffix = 'big' if field == 'image' else field.split('_')[-1]
if suffix in ('small', 'medium', 'large', 'big'):
content = getattr(odoo.tools, 'image_resize_image_%s' % suffix)(content)
width, height = odoo.tools.image_guess_size_from_field_name(field)
image_base64 = image_process(image_base64, (width, height), crop=crop)
content = limited_image_resize(
content, width=width, height=height, crop=crop, upper_limit=upper_limit, avoid_if_small=avoid_if_small)
image_base64 = base64.b64decode(content)
headers.append(('Content-Length', len(image_base64)))
response = request.make_response(image_base64, headers)
content = base64.b64decode(image_base64)
headers = http.set_safe_image_headers(headers, content)
response = request.make_response(content, headers)
response.status_code = status
return response
+1 -2
View File
@@ -45,7 +45,6 @@ class Image(models.AbstractModel):
sha = hashlib.sha1(str(getattr(record, '__last_update')).encode('utf-8')).hexdigest()[0:7]
max_size = '' if max_size is None else '/%s' % max_size
avoid_if_small = '&avoid_if_small=true' if options.get('avoid_if_small') else ''
if options.get('filename-field') and getattr(record, options['filename-field'], None):
filename = record[options['filename-field']]
@@ -54,7 +53,7 @@ class Image(models.AbstractModel):
else:
filename = record.display_name
src = '/web/image/%s/%s/%s%s/%s?unique=%s%s' % (record._name, record.id, options.get('preview_image', field_name), max_size, url_quote(filename), sha, avoid_if_small)
src = '/web/image/%s/%s/%s%s/%s?unique=%s' % (record._name, record.id, options.get('preview_image', field_name), max_size, url_quote(filename), sha)
if options.get('alt-field') and getattr(record, options['alt-field'], None):
alt = escape(record[options['alt-field']])
@@ -177,21 +177,19 @@ var KanbanRecord = Widget.extend({
* @param {string} model the name of the model
* @param {string} field the name of the field
* @param {integer} id the id of the resource
* @param {integer} cache the cache duration, in seconds
* @param {Object} options
* @param {string} placeholder
* @returns {string} the url of the image
*/
_getImageURL: function (model, field, id, cache, options) {
options = options || {};
_getImageURL: function (model, field, id, placeholder) {
id = (_.isArray(id) ? id[0] : id) || false;
field = options.preview_image || field;
placeholder = placeholder || "/web/static/src/img/placeholder.png";
var isCurrentRecord = this.modelName === model && this.recordData.id === id;
var url;
if (isCurrentRecord && this.record[field] && this.record[field].raw_value && !utils.is_bin_size(this.record[field].raw_value)) {
// Use magic-word technique for detecting image type
url = 'data:image/' + this.file_type_magic_word[this.record[field].raw_value[0]] + ';base64,' + this.record[field].raw_value;
} else if (!model || !field || !id || (isCurrentRecord && this.record[field] && !this.record[field].raw_value)) {
url = "/web/static/src/img/placeholder.png";
url = placeholder;
} else {
var session = this.getSession();
var params = {
@@ -202,10 +200,6 @@ var KanbanRecord = Widget.extend({
if (isCurrentRecord) {
params.unique = this.record.__last_update && this.record.__last_update.value.replace(/[^0-9]/g, '');
}
if (cache !== undefined) {
// Set the cache duration in seconds.
params.cache = parseInt(cache, 10);
}
url = session.url('/web/image', params);
}
return url;
+13 -1
View File
@@ -146,7 +146,15 @@
margin-right: $o-kanban-inside-hgutter*2;
flex: 1 0 $o-kanban-image-fill-width;
min-height: 95px;
background: {size: cover; position: center };
background: {
size: cover;
position: center;
repeat: no-repeat;
};
&.o_kanban_image_full {
background-size: contain;
}
}
// Adapt default 'o_kanban_image' element if present.
@@ -158,6 +166,10 @@
flex: 0 0 $o-kanban-image-width;
min-height: $o-kanban-image-width;
align-self: baseline;
background: {
repeat: no-repeat;
position: center;
}
// On medium screen size, align 'o_kanban_image' to 'o_kanban_image_fill_left' elements.
@include media-breakpoint-up(md) {
+2 -2
View File
@@ -13,10 +13,10 @@ class TestImage(HttpCase):
"""The goal of this test is to make sure the placeholder image is
resized appropriately depending on the given URL parameters."""
# CASE: resize placeholder to given size
# CASE: resize placeholder, given size but original ratio is always kept
response = self.url_open('/web/image/0/200x150')
image = Image.open(io.BytesIO(response.content))
self.assertEqual(image.size, (200, 150))
self.assertEqual(image.size, (150, 150))
# CASE: resize placeholder to small
response = self.url_open('/web/image/fake/0/image_small')
+7 -17
View File
@@ -158,27 +158,17 @@ class Web_Editor(http.Controller):
#------------------------------------------------------
@http.route('/web_editor/add_image_base64', type='json', auth='user', methods=['POST'], website=True)
def add_image_base64(self, res_model, res_id, image_base64, filename, disable_optimization=None, **kwargs):
data = base64.b64decode(image_base64)
attachment = self._image_to_attachment(res_model, res_id, data, filename, filename, disable_optimization=disable_optimization)
attachment = self._image_to_attachment(res_model, res_id, image_base64, filename, filename, disable_optimization=disable_optimization)
return attachment.read(['name', 'mimetype', 'checksum', 'url', 'res_id', 'res_model', 'access_token'])[0]
def _image_to_attachment(self, res_model, res_id, data, name, datas_fname, disable_optimization=None):
def _image_to_attachment(self, res_model, res_id, image_base64, name, datas_fname, disable_optimization=None):
Attachments = request.env['ir.attachment']
try:
image = Image.open(io.BytesIO(data))
w, h = image.size
if w*h > 42e6: # Nokia Lumia 1020 photo resolution
raise ValueError(
u"Image size excessive, uploaded images must be smaller "
u"than 42 million pixel")
if not disable_optimization and image.format in ('PNG', 'JPEG'):
data = tools.image_save_for_web(image)
except IOError:
pass
if not disable_optimization:
image_base64 = tools.image_process(image_base64, verify_resolution=True)
attachment = Attachments.create({
'name': name,
'datas_fname': datas_fname,
'datas': base64.b64encode(data),
'datas': image_base64,
'public': res_model == 'ir.ui.view',
'res_id': res_id,
'res_model': res_model,
@@ -224,12 +214,12 @@ class Web_Editor(http.Controller):
try:
attachments = request.env['ir.attachment']
for c_file in request.httprequest.files.getlist('upload'):
data = c_file.read()
image_base64 = base64.b64encode(c_file.read())
name = c_file.filename
datas_fname = name
if filters:
datas_fname = filters + '_' + datas_fname
attachments += self._image_to_attachment(res_model, res_id, data, name, datas_fname, disable_optimization=disable_optimization)
attachments += self._image_to_attachment(res_model, res_id, image_base64, name, datas_fname, disable_optimization=disable_optimization)
uploads += attachments.read(['name', 'mimetype', 'checksum', 'url', 'res_id', 'res_model', 'access_token'])
except Exception as e:
logger.exception("Failed to upload image to attachment")
+8 -12
View File
@@ -1,14 +1,14 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import io
import logging
import mimetypes
import requests
import werkzeug.utils
from PIL import Image
from odoo import http, tools, _
from odoo.http import request
from odoo.tools.mimetypes import guess_mimetype
from werkzeug.urls import url_encode
logger = logging.getLogger(__name__)
@@ -89,8 +89,7 @@ class Web_Unsplash(http.Controller):
continue
# get mime-type of image url because unsplash url dosn't contains mime-types in url
mimetype = req.headers.get('Content-Type')
datas = req.content
image_base64 = base64.b64encode(req.content)
except requests.exceptions.ConnectionError as e:
logger.exception("Connection Error: " + str(e))
continue
@@ -98,13 +97,10 @@ class Web_Unsplash(http.Controller):
logger.exception("Timeout: " + str(e))
continue
# optimized image before save
if mimetype in ('image/jpeg', 'image/png'):
image = Image.open(io.BytesIO(datas))
if image.format in ('PNG', 'JPEG'):
datas = tools.image_save_for_web(image)
# append image extension in name
query += '.' + str.lower(image.format)
image_base64 = tools.image_process(image_base64)
mimetype = guess_mimetype(base64.b64decode(image_base64))
# append image extension in name
query += mimetypes.guess_extension(mimetype) or ''
# /unsplash/5gR788gfd/lion
url_frags = ['unsplash', key, query]
@@ -114,7 +110,7 @@ class Web_Unsplash(http.Controller):
'url': '/' + '/'.join(url_frags),
'datas_fname': '_'.join(url_frags),
'mimetype': mimetype,
'datas': base64.b64encode(datas),
'datas': image_base64,
'public': res_model == 'ir.ui.view',
'res_id': res_id,
'res_model': res_model,
-1
View File
@@ -156,7 +156,6 @@
<field name="domain"></field>
<field name="company_id" ref="base.main_company"/>
<field name="user_id" ref="base.public_user"/>
<field name="favicon" type="base64" file="web/static/src/img/favicon.ico"/>
<field name="homepage_id" ref="homepage_page"/>
</record>
+18 -1
View File
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import inspect
import logging
import hashlib
@@ -14,6 +15,7 @@ from odoo.addons.http_routing.models.ir_http import slugify, _guess_mimetype
from odoo.addons.website.models.ir_http import sitemap_qs2dom
from odoo.addons.portal.controllers.portal import pager
from odoo.http import request
from odoo.modules.module import get_resource_path
from odoo.osv.expression import FALSE_DOMAIN
from odoo.tools.translate import _
@@ -100,7 +102,13 @@ class Website(models.Model):
partner_id = fields.Many2one(related='user_id.partner_id', relation='res.partner', string='Public Partner', readonly=False)
menu_id = fields.Many2one('website.menu', compute='_compute_menu', string='Main Menu')
homepage_id = fields.Many2one('website.page', string='Homepage')
favicon = fields.Binary(string="Website Favicon", help="This field holds the image used to display a favicon on the website.")
def _default_favicon(self):
img_path = get_resource_path('web', 'static/src/img/favicon.ico')
with tools.file_open(img_path, 'rb') as f:
return base64.b64encode(f.read())
favicon = fields.Binary(string="Website Favicon", help="This field holds the image used to display a favicon on the website.", default=_default_favicon)
theme_id = fields.Many2one('ir.module.module', help='Installed theme')
specific_user_account = fields.Boolean('Specific User Account', help='If True, new accounts will be associated to the current website')
@@ -122,6 +130,8 @@ class Website(models.Model):
@api.model
def create(self, vals):
self._handle_favicon(vals)
if 'user_id' not in vals:
company = self.env['res.company'].browse(vals.get('company_id'))
vals['user_id'] = company._get_public_user().id if company else self.env.ref('base.public_user').id
@@ -138,6 +148,8 @@ class Website(models.Model):
@api.multi
def write(self, values):
self._handle_favicon(values)
self._get_languages.clear_cache(self)
if 'company_id' in values and 'user_id' not in values:
company = self.env['res.company'].browse(values['company_id'])
@@ -149,6 +161,11 @@ class Website(models.Model):
self.env['ir.qweb'].clear_caches()
return result
@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')
# ----------------------------------------------------------
# Page Management
# ----------------------------------------------------------
+1
View File
@@ -9,4 +9,5 @@ from . import test_ui
from . import test_views
from . import test_menu
from . import test_page
from . import test_website_favicon
from . import test_website_reset_password
@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from PIL import Image
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
from odoo.tools import base64_to_image, image_to_base64
@tagged('post_install', '-at_install')
class TestWebsiteResetPassword(TransactionCase):
def test_01_website_favicon(self):
"""The goal of this test is to make sure the favicon is correctly
handled on the website."""
# Test setting an Ico file directly, done through create
Website = self.env['website']
website = Website.create({
'name': 'Test Website',
'favicon': Website._default_favicon(),
})
image = base64_to_image(website.favicon)
self.assertEqual(image.format, 'ICO')
# Test setting a JPEG file that is too big, done through write
bg_color = (135, 90, 123)
image = Image.new('RGB', (1920, 1080), color=bg_color)
website.favicon = image_to_base64(image, 'JPEG')
image = base64_to_image(website.favicon)
self.assertEqual(image.format, 'ICO')
self.assertEqual(image.size, (256, 256))
self.assertEqual(image.getpixel((0, 0)), bg_color)
@@ -17,7 +17,7 @@
<template id="partner_detail" name="Partner Details">
<h1 class="col-lg-12 text-center" id="partner_name" t-field="partner.display_name"/>
<div class="col-lg-4">
<div t-field="partner.image" t-options='{"widget": "image", "class": "d-block mx-auto mb16", "max_width": 512, "avoid_if_small": True}'/>
<div t-field="partner.image" t-options='{"widget": "image", "class": "d-block mx-auto mb16", "max_width": 512}'/>
<address>
<div t-field="partner.self" t-options='{
"widget": "contact",
+14 -21
View File
@@ -32,18 +32,10 @@ class WebsiteProfile(http.Controller):
return user.website_published and user.karma > 0
return False
def _get_default_avatar(self, field, headers, width, height):
def _get_default_avatar(self):
img_path = modules.get_module_resource('web', 'static/src/img', 'placeholder.png')
with open(img_path, 'rb') as f:
image = f.read()
content = base64.b64encode(image)
dictheaders = dict(headers) if headers else {}
dictheaders['Content-Type'] = 'image/png'
if not (width or height):
suffix = field.split('_')[-1] if '_' in field else 'large'
if suffix in ('small', 'medium', 'large', 'big'):
content = getattr(tools, 'image_resize_image_%s' % suffix)(content)
return content
return base64.b64encode(f.read())
def _check_user_profile_access(self, user_id):
user_sudo = request.env['res.users'].sudo().browse(user_id)
@@ -80,33 +72,34 @@ class WebsiteProfile(http.Controller):
@http.route([
'/profile/avatar/<int:user_id>',
], type='http', auth="public", website=True, sitemap=False)
def get_user_profile_avatar(self, user_id, field='image_large', width=0, height=0, crop=False, avoid_if_small=False, upper_limit=False, **post):
def get_user_profile_avatar(self, user_id, field='image_large', width=0, height=0, crop=False, **post):
if field not in ('image_small', 'image_medium', 'image_large'):
return werkzeug.exceptions.Forbidden()
can_sudo = self._check_avatar_access(user_id, **post)
if can_sudo:
status, headers, content = request.env['ir.http'].sudo().binary_content(
status, headers, image_base64 = request.env['ir.http'].sudo().binary_content(
model='res.users', id=user_id, field=field,
default_mimetype='image/png')
else:
status, headers, content = request.env['ir.http'].binary_content(
status, headers, image_base64 = request.env['ir.http'].binary_content(
model='res.users', id=user_id, field=field,
default_mimetype='image/png')
if status == 301:
return request.env['ir.http']._response_by_status(status, headers, content)
return request.env['ir.http']._response_by_status(status, headers, image_base64)
if status == 304:
return werkzeug.wrappers.Response(status=304)
if not content:
content = self._get_default_avatar(field, headers, width, height)
if not image_base64:
image_base64 = self._get_default_avatar()
if not (width or height):
width, height = tools.image_guess_size_from_field_name(field)
content = tools.limited_image_resize(
content, width=width, height=height, crop=crop, upper_limit=upper_limit, avoid_if_small=avoid_if_small)
image_base64 = tools.image_process(image_base64, (width, height), crop=crop)
image_base64 = base64.b64decode(content)
headers.append(('Content-Length', len(image_base64)))
response = request.make_response(image_base64, headers)
content = base64.b64decode(image_base64)
headers = http.set_safe_image_headers(headers, content)
response = request.make_response(content, headers)
response.status_code = status
return response
@@ -49,19 +49,19 @@ class TestWebsiteSaleImage(odoo.tests.HttpCase):
# first image (blue) for the template
f = io.BytesIO()
Image.new('RGB', (1920, 1080), color_blue).save(f, 'PNG')
Image.new('RGB', (1920, 1080), color_blue).save(f, 'JPEG')
f.seek(0)
blue_image = base64.b64encode(f.read())
# second image (red) for the variant 1, small image (no zoom)
f = io.BytesIO()
Image.new('RGB', (800, 500), color_red).save(f, 'PNG')
Image.new('RGB', (800, 500), color_red).save(f, 'JPEG')
f.seek(0)
red_image = base64.b64encode(f.read())
# second image (green) for the variant 2, big image (zoom)
f = io.BytesIO()
Image.new('RGB', (1920, 1080), color_green).save(f, 'PNG')
Image.new('RGB', (1920, 1080), color_green).save(f, 'JPEG')
f.seek(0)
green_image = base64.b64encode(f.read())
@@ -82,9 +82,9 @@ class TestWebsiteSaleImage(odoo.tests.HttpCase):
# Green Variant Extra Image 1
f = io.BytesIO()
Image.new('RGB', (2147, 3251)).save(f, 'JPEG')
Image.new('RGB', (2147, 3251)).save(f, 'PNG')
f.seek(0)
image_jpeg = base64.b64encode(f.read())
image_png = base64.b64encode(f.read())
# create the template, without creating the variants
template = self.env['product.template'].with_context(create_product_product=True).create({
@@ -124,7 +124,7 @@ class TestWebsiteSaleImage(odoo.tests.HttpCase):
'image': green_image,
'product_tmpl_id': template.id,
'attribute_value_ids': [(6, 0, attr_values.filtered(lambda l: l.name == name_green).ids)],
'product_variant_image_ids': [(0, 0, {'name': 'image 3', 'image': image_jpeg})],
'product_variant_image_ids': [(0, 0, {'name': 'image 3', 'image': image_png})],
})
# now set the red image on the first variant, that works because
@@ -140,65 +140,70 @@ class TestWebsiteSaleImage(odoo.tests.HttpCase):
self.assertTrue(product_green.can_image_be_zoomed)
self.assertTrue(product_green.product_variant_image_ids[0].can_image_be_zoomed)
# jpeg encoding is changing the color a bit
jpeg_blue = (65, 105, 227)
jpeg_red = (205, 93, 92)
jpeg_green = (34, 139, 34)
# Verify original size: keep original
image = Image.open(io.BytesIO(base64.b64decode(template.image_original)))
self.assertEqual(image.size, (1920, 1080))
self.assertEqual(image.getpixel((image.size[0] / 2, image.size[1] / 2)), (65, 105, 225), "blue")
self.assertEqual(image.getpixel((image.size[0] / 2, image.size[1] / 2)), jpeg_blue, "blue")
image = Image.open(io.BytesIO(base64.b64decode(product_red.image_original)))
self.assertEqual(image.size, (800, 500))
self.assertEqual(image.getpixel((image.size[0] / 2, image.size[1] / 2)), (205, 92, 92), "red")
self.assertEqual(image.getpixel((image.size[0] / 2, image.size[1] / 2)), jpeg_red, "red")
image = Image.open(io.BytesIO(base64.b64decode(product_green.image_original)))
self.assertEqual(image.size, (1920, 1080))
self.assertEqual(image.getpixel((image.size[0] / 2, image.size[1] / 2)), (34, 139, 34), "green")
self.assertEqual(image.getpixel((image.size[0] / 2, image.size[1] / 2)), jpeg_green, "green")
# Verify big size: keep aspect ratio
image = Image.open(io.BytesIO(base64.b64decode(template.image_big)))
self.assertEqual(image.size, (1024, 576))
self.assertEqual(image.getpixel((image.size[0] / 2, image.size[1] / 2)), (65, 105, 225), "blue")
self.assertEqual(image.getpixel((image.size[0] / 2, image.size[1] / 2)), jpeg_blue, "blue")
image = Image.open(io.BytesIO(base64.b64decode(product_red.image_big)))
self.assertEqual(image.size, (800, 500))
self.assertEqual(image.getpixel((image.size[0] / 2, image.size[1] / 2)), (205, 92, 92), "red")
self.assertEqual(image.getpixel((image.size[0] / 2, image.size[1] / 2)), jpeg_red, "red")
image = Image.open(io.BytesIO(base64.b64decode(product_green.image_big)))
self.assertEqual(image.size, (1024, 576))
self.assertEqual(image.getpixel((image.size[0] / 2, image.size[1] / 2)), (34, 139, 34), "green")
self.assertEqual(image.getpixel((image.size[0] / 2, image.size[1] / 2)), jpeg_green, "green")
# Verify image == image_big
self.assertEqual(template.image_big, template.image)
self.assertEqual(product_red.image_big, product_red.image)
self.assertEqual(product_green.image_big, product_green.image)
# Verify large size
# Verify large size: keep aspect ratio
image = Image.open(io.BytesIO(base64.b64decode(template.image_large)))
self.assertEqual(image.size, (256, 256))
self.assertEqual(image.getpixel((image.size[0] / 2, image.size[1] / 2)), (65, 105, 225), "blue")
self.assertEqual(image.size, (256, 144))
self.assertEqual(image.getpixel((image.size[0] / 2, image.size[1] / 2)), jpeg_blue, "blue")
image = Image.open(io.BytesIO(base64.b64decode(product_red.image_large)))
self.assertEqual(image.size, (256, 256))
self.assertEqual(image.getpixel((image.size[0] / 2, image.size[1] / 2)), (205, 92, 92), "red")
self.assertEqual(image.size, (256, 160))
self.assertEqual(image.getpixel((image.size[0] / 2, image.size[1] / 2)), jpeg_red, "red")
image = Image.open(io.BytesIO(base64.b64decode(product_green.image_large)))
self.assertEqual(image.size, (256, 256))
self.assertEqual(image.getpixel((image.size[0] / 2, image.size[1] / 2)), (34, 139, 34), "green")
self.assertEqual(image.size, (256, 144))
self.assertEqual(image.getpixel((image.size[0] / 2, image.size[1] / 2)), jpeg_green, "green")
# Verify medium size
# Verify medium size: keep aspect ratio
image = Image.open(io.BytesIO(base64.b64decode(template.image_medium)))
self.assertEqual(image.size, (128, 128))
self.assertEqual(image.getpixel((image.size[0] / 2, image.size[1] / 2)), (65, 105, 225), "blue")
self.assertEqual(image.size, (128, 72))
self.assertEqual(image.getpixel((image.size[0] / 2, image.size[1] / 2)), jpeg_blue, "blue")
image = Image.open(io.BytesIO(base64.b64decode(product_red.image_medium)))
self.assertEqual(image.size, (128, 128))
self.assertEqual(image.getpixel((image.size[0] / 2, image.size[1] / 2)), (205, 92, 92), "red")
self.assertEqual(image.size, (128, 80))
self.assertEqual(image.getpixel((image.size[0] / 2, image.size[1] / 2)), jpeg_red, "red")
image = Image.open(io.BytesIO(base64.b64decode(product_green.image_medium)))
self.assertEqual(image.size, (128, 128))
self.assertEqual(image.getpixel((image.size[0] / 2, image.size[1] / 2)), (34, 139, 34), "green")
self.assertEqual(image.size, (128, 72))
self.assertEqual(image.getpixel((image.size[0] / 2, image.size[1] / 2)), jpeg_green, "green")
# Verify small size
# Verify small size: keep aspect ratio
image = Image.open(io.BytesIO(base64.b64decode(template.image_small)))
self.assertEqual(image.size, (64, 64))
self.assertEqual(image.getpixel((image.size[0] / 2, image.size[1] / 2)), (65, 105, 225), "blue")
self.assertEqual(image.size, (64, 36))
self.assertEqual(image.getpixel((image.size[0] / 2, image.size[1] / 2)), jpeg_blue, "blue")
image = Image.open(io.BytesIO(base64.b64decode(product_red.image_small)))
self.assertEqual(image.size, (64, 64))
self.assertEqual(image.getpixel((image.size[0] / 2, image.size[1] / 2)), (205, 92, 92), "red")
self.assertEqual(image.size, (64, 40))
self.assertEqual(image.getpixel((image.size[0] / 2, image.size[1] / 2)), jpeg_red, "red")
image = Image.open(io.BytesIO(base64.b64decode(product_green.image_small)))
self.assertEqual(image.size, (64, 64))
self.assertEqual(image.getpixel((image.size[0] / 2, image.size[1] / 2)), (34, 139, 34), "green")
self.assertEqual(image.size, (64, 36))
self.assertEqual(image.getpixel((image.size[0] / 2, image.size[1] / 2)), jpeg_green, "green")
# self.env.cr.commit() # uncomment to save the product to test in browser
@@ -223,7 +228,7 @@ class TestWebsiteSaleImage(odoo.tests.HttpCase):
# write on the raw field, otherwise it will write on the fallback here
product_green.image_raw_original = False
images = product_green._get_images()
self.assertEqual(images[0].image_original, image_jpeg)
self.assertEqual(images[0].image_original, image_png)
self.assertEqual(images[1].image_original, red_image)
self.assertEqual(images[2].image_original, image_gif)
self.assertEqual(images[3].image_original, image_svg)
+2 -2
View File
@@ -933,7 +933,7 @@
<t t-if="website_sale_order and website_sale_order.website_order_line">
<t t-foreach="website_sale_order.website_order_line" t-as="line">
<div class="row mb8 cart_line">
<div class="col-3">
<div class="col-3 text-center">
<span t-field="line.product_id.image_small" t-options="{'widget': 'image', 'class': 'rounded'}" />
</div>
<div class="col-9">
@@ -1414,7 +1414,7 @@
<tbody>
<tr t-foreach="website_sale_order.website_order_line" t-as="line">
<td class='' colspan="2" t-if="not line.product_id.product_tmpl_id"></td>
<td class='td-img' t-if="line.product_id.product_tmpl_id">
<td class='td-img text-center' t-if="line.product_id.product_tmpl_id">
<span t-field="line.product_id.image_small" t-options="{'widget': 'image', 'class': 'rounded'}" />
</td>
<td class='td-product_name' t-if="line.product_id.product_tmpl_id">
+11 -10
View File
@@ -506,7 +506,7 @@ class WebsiteSlides(WebsiteProfile):
return response
@http.route('/slides/slide/<int:slide_id>/get_image', type='http', auth="public", website=True, sitemap=False)
def slide_get_image(self, slide_id, field='image_medium', width=0, height=0, crop=False, avoid_if_small=False, upper_limit=False):
def slide_get_image(self, slide_id, field='image_medium', width=0, height=0, crop=False):
# Protect infographics by limiting access to 256px (large) images
if field not in ('image_small', 'image_medium', 'image_large'):
return werkzeug.exceptions.Forbidden()
@@ -515,23 +515,24 @@ class WebsiteSlides(WebsiteProfile):
if not slide:
raise werkzeug.exceptions.NotFound()
status, headers, content = request.env['ir.http'].sudo().binary_content(
status, headers, image_base64 = request.env['ir.http'].sudo().binary_content(
model='slide.slide', id=slide.id, field=field,
default_mimetype='image/png')
if status == 301:
return request.env['ir.http']._response_by_status(status, headers, content)
return request.env['ir.http']._response_by_status(status, headers, image_base64)
if status == 304:
return werkzeug.wrappers.Response(status=304)
if not content:
content = self._get_default_avatar(field, headers, width, height)
if not image_base64:
image_base64 = self._get_default_avatar()
if not (width or height):
width, height = tools.image_guess_size_from_field_name(field)
content = tools.limited_image_resize(
content, width=width, height=height, crop=crop, upper_limit=upper_limit, avoid_if_small=avoid_if_small)
image_base64 = tools.image_process(image_base64, (width, height), crop=crop)
image_base64 = base64.b64decode(content)
headers.append(('Content-Length', len(image_base64)))
response = request.make_response(image_base64, headers)
content = base64.b64decode(image_base64)
headers = http.set_safe_image_headers(headers, content)
response = request.make_response(content, headers)
response.status_code = status
return response
+1 -1
View File
@@ -28,7 +28,7 @@ class ImageMixin(models.AbstractModel):
for record in self:
images = tools.image_get_resized_images(record.image_original, big_name=False)
record.image_big = tools.image_get_resized_images(record.image_original,
large_name=False, medium_name=False, small_name=False, preserve_aspect_ratio=True)['image']
large_name=False, medium_name=False, small_name=False)['image']
record.image_large = images['image_large']
record.image_medium = images['image_medium']
record.image_small = images['image_small']
+1 -1
View File
@@ -120,7 +120,7 @@ class Company(models.Model):
@api.depends('partner_id', 'partner_id.image')
def _compute_logo_web(self):
for company in self:
company.logo_web = tools.image_resize_image(company.partner_id.image, (180, None))
company.logo_web = tools.image_process(company.partner_id.image, (180, None))
@api.onchange('state_id')
def _onchange_state(self):
+12 -12
View File
@@ -328,29 +328,29 @@ class Partner(models.Model):
if getattr(threading.currentThread(), 'testing', False) or self._context.get('install_mode'):
return False
colorize, img_path, image = False, False, False
colorize, img_path, image_base64 = False, False, False
if partner_type in ['other'] and parent_id:
parent_image = self.browse(parent_id).image
image = parent_image and base64.b64decode(parent_image) or None
image_base64 = parent_image or None
if not image and partner_type == 'invoice':
if not image_base64 and partner_type == 'invoice':
img_path = get_module_resource('base', 'static/img', 'money.png')
elif not image and partner_type == 'delivery':
elif not image_base64 and partner_type == 'delivery':
img_path = get_module_resource('base', 'static/img', 'truck.png')
elif not image and is_company:
elif not image_base64 and is_company:
img_path = get_module_resource('base', 'static/img', 'company_image.png')
elif not image:
elif not image_base64:
img_path = get_module_resource('base', 'static/img', 'avatar.png')
colorize = True
if img_path:
with open(img_path, 'rb') as f:
image = f.read()
if image and colorize:
image = tools.image_colorize(image)
image_base64 = base64.b64encode(f.read())
if image_base64 and colorize:
image_base64 = tools.image_process(image_base64, colorize=True)
return tools.image_resize_image_big(base64.b64encode(image))
return tools.image_process(image_base64, size=tools.IMAGE_BIG_SIZE)
@api.model
def _fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False):
@@ -572,7 +572,7 @@ class Partner(models.Model):
("The selected company is not compatible with the companies of the related user(s)"))
# no padding on the big image, because it's used as website logo
tools.image_resize_images(vals, return_big=False)
tools.image_resize_images(vals, return_medium=False, return_small=False, preserve_aspect_ratio=True)
tools.image_resize_images(vals, return_medium=False, return_small=False)
result = True
# To write in SUPERUSER on field is_company and avoid access rights problems.
@@ -601,7 +601,7 @@ class Partner(models.Model):
vals['image'] = self._get_default_image(vals.get('type'), vals.get('is_company'), vals.get('parent_id'))
# no padding on the big image, because it's used as website logo
tools.image_resize_images(vals, return_big=False)
tools.image_resize_images(vals, return_medium=False, return_small=False, preserve_aspect_ratio=True)
tools.image_resize_images(vals, return_medium=False, return_small=False)
partners = super(Partner, self).create(vals_list)
if self.env.context.get('_partners_skip_fields_sync'):
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+1
View File
@@ -7,6 +7,7 @@ from . import test_db_cursor
from . import test_expression
from . import test_float
from . import test_func
from . import test_image
from . import test_ir_actions
from . import test_ir_attachment
from . import test_ir_http
+235
View File
@@ -0,0 +1,235 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import binascii
from PIL import Image, ImageDraw, PngImagePlugin
from odoo import tools
from odoo.tests.common import TransactionCase
class TestImage(TransactionCase):
"""Tests for the different image tools helpers.
The following helpers are not tested here because they are wrappers for the
other methods or they are tested elsewhere (eg. on TestWebsiteSaleImage):
- image_get_resized_images
- image_resize_images
- is_image_size_above
"""
def setUp(self):
super(TestImage, self).setUp()
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')
# 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
# the middle is going to be `self.fill_color`.
# horizontal image (border is left/right)
image = Image.new('RGB', (1920, 1080), color=self.bg_color)
offset = (image.size[0] - image.size[1]) / 2
draw = ImageDraw.Draw(image)
draw.rectangle(xy=[
(offset, 0),
(image.size[0] - offset, image.size[1])
], fill=self.fill_color)
self.base64_1920x1080_png = tools.image_to_base64(image, 'PNG')
# vertical image (border is top/bottom)
image = Image.new('RGB', (1080, 1920), color=self.bg_color)
offset = (image.size[1] - image.size[0]) / 2
draw = ImageDraw.Draw(image)
draw.rectangle(xy=[
(0, offset),
(image.size[0], image.size[1] - offset)
], fill=self.fill_color)
self.base64_1080x1920_png = tools.image_to_base64(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)
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(binascii.Error, msg="wrong base64: binascii.Error: Incorrect padding"):
image = tools.base64_to_image(b'oazdazpodazdpok')
with self.assertRaises(OSError, msg="wrong base64: OSError: cannot identify image file"):
image = tools.base64_to_image(b'oazdazpodazdpokd')
def test_01_image_to_base64(self):
"""Test that a PIL image is correctly saved as base64."""
image = Image.new('RGB', (1, 1))
image_base64 = tools.image_to_base64(image, 'PNG')
self.assertEqual(image_base64, self.base64_1x1_png)
def test_10_image_process_base64_source(self):
"""Test the base64_source parameter of image_process."""
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")
with self.assertRaises(binascii.Error, msg="wrong base64: binascii.Error: Incorrect padding"):
tools.image_process(b'oazdazpodazdpok')
with self.assertRaises(OSError, msg="wrong base64: OSError: cannot identify image file"):
tools.image_process(b'oazdazpodazdpokd')
image = tools.base64_to_image(tools.image_process(self.base64_1920x1080_jpeg))
self.assertEqual(image.size, (1920, 1080), "OK return the image")
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)
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"),
]
count = 0
for test in tests:
image = tools.base64_to_image(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)
self.assertNotEqual(res, False, "size ok")
base64_image_excessive = tools.image_to_base64(Image.new('RGB', (45001, 1000)), 'PNG')
with self.assertRaises(ValueError, msg="size excessive"):
tools.image_process(base64_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')
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')
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))
# CASE: GIF doesn't apply quality, just optimize
image = tools.image_to_base64(Image.new('RGB', (1080, 1920)), 'GIF')
res = tools.image_process(image)
self.assertLessEqual(len(res), len(image))
def test_14_image_process_crop(self):
"""Test the crop parameter of image_process."""
# Optimized PNG use palette, getpixel below will return palette value.
fill = 0
bg = 1
# 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.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"),
]
count = 0
for test in tests:
count = count + 1
# process the image
image = tools.base64_to_image(tools.image_process(test[0], size=test[1], crop=test[2]))
# verify size
self.assertEqual(image.size, test[3], "%s - correct size" % test[5])
half_width, half_height = image.size[0] / 2, image.size[1] / 2
top, bottom, left, right = 0, image.size[1] - 1, 0, image.size[0] - 1
# verify top
px = (half_width, top)
self.assertEqual(image.getpixel(px), test[4][0], "%s - color top (%s, %s)" % (test[5], px[0], px[1]))
# verify bottom
px = (half_width, bottom)
self.assertEqual(image.getpixel(px), test[4][1], "%s - color bottom (%s, %s)" % (test[5], px[0], px[1]))
# verify left
px = (left, half_height)
self.assertEqual(image.getpixel(px), test[4][2], "%s - color left (%s, %s)" % (test[5], px[0], px[1]))
# verify right
px = (right, half_height)
self.assertEqual(image.getpixel(px), test[4][3], "%s - color right (%s, %s)" % (test[5], px[0], px[1]))
self.assertEqual(count, 2 * 9, "ensure the loop is ran")
def test_15_image_process_colorize(self):
"""Test the colorize parameter of image_process."""
# verify initial condition
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')
# CASE: color random, color has changed
image = tools.base64_to_image(tools.image_process(base64_rgba, colorize=True))
self.assertEqual(image.mode, 'P')
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'))
self.assertEqual(image.format, 'PNG', "change format to PNG")
image = tools.base64_to_image(tools.image_process(self.base64_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'))
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'))
self.assertEqual(image.format, 'JPEG', "change format PNG with RGBA to JPEG")
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))
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'))
+1 -1
View File
@@ -64,7 +64,7 @@ class test_ir_http_mimetype(common.TransactionCase):
'type': 'binary',
})
resized = odoo.tools.image_resize_image_small(prop.value_binary)
resized = odoo.tools.image_process(prop.value_binary, odoo.tools.IMAGE_SMALL_SIZE)
# Simul computed field which resize and that is not attachement=True (E.G. on product)
prop.write({'value_binary': resized})
status, headers, content = self.env['ir.http'].binary_content(
+9 -21
View File
@@ -502,37 +502,25 @@
<field name="mobile"/>
<field name="state_id"/>
<field name="category_id"/>
<field name="image_small"/>
<field name="image_medium"/>
<field name="type"/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click o_kanban_record_has_image_fill o_res_partner_kanban">
<t t-if="record.image_small.raw_value &amp;&amp; !record.is_company.raw_value">
<div class="o_kanban_image_fill_left d-none d-md-block" t-attf-style="background-image:url('#{kanban_image('res.partner', 'image_medium', record.id.raw_value)}')">
<t t-if="!record.is_company.raw_value">
<t t-if="record.type.raw_value === 'delivery'" t-set="placeholder" t-value="'/base/static/img/truck.png'"/>
<t t-elif="record.type.raw_value === 'invoice'" t-set="placeholder" t-value="'/base/static/img/money.png'"/>
<t t-else="" t-set="placeholder" t-value="'/base/static/img/avatar_grey.png'"/>
<div class="o_kanban_image_fill_left d-none d-md-block" t-attf-style="background-image:url('#{kanban_image('res.partner', 'image_medium', record.id.raw_value, placeholder)}')">
<img class="o_kanban_image_inner_pic" t-if="record.parent_id.raw_value" t-att-alt="record.parent_id.value" t-att-src="kanban_image('res.partner', 'image_small', record.parent_id.raw_value)"/>
</div>
<div class="o_kanban_image rounded-circle d-md-none" t-attf-style="background-image:url('#{kanban_image('res.partner', 'image_small', record.id.raw_value)}')">
<div class="o_kanban_image rounded-circle d-md-none" t-attf-style="background-image:url('#{kanban_image('res.partner', 'image_medium', record.id.raw_value, placeholder)}')">
<img class="o_kanban_image_inner_pic" t-if="record.parent_id.raw_value" t-att-alt="record.parent_id.value" t-att-src="kanban_image('res.partner', 'image_small', record.parent_id.raw_value)"/>
</div>
</t>
<t t-elif="record.is_company.raw_value">
<img class="o_kanban_image" t-att-src="kanban_image('res.partner', 'image_small', record.id.raw_value)" t-att-alt="record.display_name"/>
</t>
<t t-else="">
<t t-if="record.type.raw_value === 'delivery'">
<img alt="Delivery" t-att-src='_s + "/base/static/img/truck.png"' class="o_kanban_image oe_kanban_avatar_smallbox"/>
</t>
<t t-if="record.type.raw_value === 'invoice'">
<img alt="Invoice" t-att-src='_s + "/base/static/img/money.png"' class="o_kanban_image oe_kanban_avatar_smallbox"/>
</t>
<t t-if="record.type.raw_value != 'invoice' &amp;&amp; record.type.raw_value != 'delivery'">
<t t-if="record.is_company.raw_value === true">
<img class="o_kanban_image" alt="Logo" t-att-src='_s + "/base/static/img/company_image.png"'/>
</t>
<t t-if="record.is_company.raw_value === false">
<img class="o_kanban_image" alt="Avatar" t-att-src='_s + "/base/static/img/avatar.png"'/>
</t>
</t>
<t t-set="placeholder" t-value="'/base/static/img/company_image.png'"/>
<div class="o_kanban_image_fill_left o_kanban_image_full" t-attf-style="background-image: url(#{kanban_image('res.partner', 'image_medium', record.id.raw_value, placeholder)})" role="img"/>
</t>
<div class="oe_kanban_details">
<strong class="o_kanban_record_title oe_partner_heading"><field name="display_name"/></strong>
+35
View File
@@ -50,6 +50,7 @@ from .service.server import memory_info
from .service import security, model as service_model
from .tools.func import lazy_property
from .tools import ustr, consteq, frozendict, pycompat, unique, date_utils
from .tools.mimetypes import guess_mimetype
from .modules.module import module_manifest
@@ -1617,5 +1618,39 @@ def content_disposition(filename):
return "attachment; filename*=UTF-8''%s" % escaped
def set_safe_image_headers(headers, content):
"""Return new headers based on `headers` but with `Content-Length` and
`Content-Type` set appropriately depending on the given `content` only if it
is safe to do."""
content_type = guess_mimetype(content)
safe_types = ['image/jpeg', 'image/png', 'image/gif', 'image/x-icon']
if content_type in safe_types:
headers = set_header_field(headers, 'Content-Type', content_type)
set_header_field(headers, 'Content-Length', len(content))
return headers
def set_header_field(headers, name, value):
""" Return new headers based on `headers` but with `value` set for the
header field `name`.
:param headers: the existing headers
:type headers: list of tuples (name, value)
:param name: the header field name
:type name: string
:param value: the value to set for the `name` header
:type value: string
:return: the updated headers
:rtype: list of tuples (name, value)
"""
dictheaders = dict(headers)
dictheaders[name] = value
return list(dictheaders.items())
# main wsgi handler
root = Root()
+271 -286
View File
@@ -1,13 +1,17 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import codecs
import io
from PIL import Image
from PIL import ImageEnhance
# We can preload Ico too because it is considered safe
from PIL import IcoImagePlugin
from random import randrange
from odoo.tools.translate import _
# Preload PIL with the minimal subset of image formats we need
Image.preinit()
Image._initialized = 2
@@ -26,285 +30,297 @@ IMAGE_LARGE_SIZE = (256, 256)
IMAGE_MEDIUM_SIZE = (128, 128)
IMAGE_SMALL_SIZE = (64, 64)
# Arbitraty limit to fit most resolutions, including Nokia Lumia 1020 photo,
# 8K with a ratio up to 16:10, and almost all variants of 4320p
IMAGE_MAX_RESOLUTION = 45e6
# ----------------------------------------
# Image resizing
# ----------------------------------------
def image_resize_image(base64_source, size=IMAGE_BIG_SIZE, encoding='base64', filetype=None, avoid_if_small=False, preserve_aspect_ratio=False, upper_limit=False):
""" Function to resize an image. The image will be resized to the given
size, while keeping the aspect ratios, and holes in the image will be
filled with transparent background. The image will not be stretched if
smaller than the expected size.
Steps of the resizing:
- Compute width and height if not specified.
- if avoid_if_small: if both image sizes are smaller than the requested
sizes, the original image is returned. This is used to avoid adding
transparent content around images that we do not want to alter but
just resize if too big. This is used for example when storing images
in the 'image' field: we keep the original image, resized to a maximal
size, without adding transparent content around it if smaller.
- create a thumbnail of the source image through using the thumbnail
function. Aspect ratios are preserved when using it. Note that if the
source image is smaller than the expected size, it will not be
extended, but filled to match the size.
- create a transparent background that will hold the final image.
- paste the thumbnail on the transparent background and center it.
class ImageProcess():
:param base64_source: base64-encoded version of the source
image; if False, returns False
:param size: 2-tuple(width, height). A None value for any of width or
height mean an automatically computed value based respectively
on height or width of the source image.
:param encoding: the output encoding
:param filetype: the output filetype, by default the source image's
:type filetype: str, any PIL image format (supported for creation)
:param avoid_if_small: do not resize if image height and width
are smaller than the expected size.
"""
if not base64_source:
return False
# Return unmodified content if no resize or we etect first 6 bits of '<'
# (0x3C) for SVG documents - This will bypass XML files as well, but it's
# harmless for these purposes
if size == (None, None) or base64_source[:1] == b'P':
return base64_source
image_stream = io.BytesIO(codecs.decode(base64_source, encoding))
image = Image.open(image_stream)
# store filetype here, as Image.new below will lose image.format
filetype = (filetype or image.format).upper()
def __init__(self, base64_source, verify_resolution=True):
"""Initialize the `base64_source` image for processing.
filetype = {
'BMP': 'PNG',
}.get(filetype, filetype)
:param base64_source: the original image base64 encoded
No processing will be done if the `base64_source` is falsy or if
the image is SVG.
:type base64_source: string or bytes
asked_width, asked_height = size
if upper_limit:
if asked_width:
asked_width = min(asked_width, image.size[0])
if asked_height:
asked_height = min(asked_height, image.size[1])
:param verify_resolution: if True, make sure the original image size is not
excessive before starting to process it. The max allowed resolution is
defined by `IMAGE_MAX_RESOLUTION`.
:type verify_resolution: bool
if image.size[0] >= image.size[1]:
asked_height = None
:return: self
:rtype: ImageProcess
:raise: ValueError if `verify_resolution` is True and the image is too large
:raise: binascii.Error: if the base64 is incorrect
:raise: OSError if the image can't be identified by PIL
"""
self.base64_source = base64_source or False
if not base64_source or base64_source[:1] == b'P':
# don't process empty source or SVG
self.image = False
else:
asked_width = None
if asked_width is None and asked_height is None:
return base64_source
self.image = base64_to_image(self.base64_source)
if asked_width is None:
asked_width = int(image.size[0] * (float(asked_height) / image.size[1]))
if asked_height is None:
asked_height = int(image.size[1] * (float(asked_width) / image.size[0]))
size = asked_width, asked_height
# check image size: do not create a thumbnail if avoiding smaller images
if avoid_if_small and image.size[0] <= size[0] and image.size[1] <= size[1]:
return base64_source
w, h = self.image.size
if verify_resolution and w * h > IMAGE_MAX_RESOLUTION:
raise ValueError(_("Image size excessive, uploaded images must be smaller than %s million pixels.") % str(IMAGE_MAX_RESOLUTION / 10e6))
if image.size != size:
image = image_resize_and_sharpen(image, size, preserve_aspect_ratio=preserve_aspect_ratio, upper_limit=upper_limit)
if image.mode not in ["1", "L", "P", "RGB", "RGBA"] or (filetype == 'JPEG' and image.mode == 'RGBA'):
image = image.convert("RGB")
self.original_format = self.image.format
background_stream = io.BytesIO()
image.save(background_stream, filetype)
return codecs.encode(background_stream.getvalue(), encoding)
def image_base64(self, quality=80, output_format=None):
"""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.
:param quality: quality setting to apply.
- Ignored if image is not JPEG.
- 1 is worse, 95 is best. Default to 80.
:type quality: int
:param 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.
:type output_format: string
:return: image base64 encoded or False
:rtype: bytes or False
"""
output_image = self.image
if not output_image:
return self.base64_source
output_format = (output_format or self.original_format).upper()
if output_format == 'BMP':
output_format = 'PNG'
elif output_format not in ['PNG', 'JPEG', 'GIF', 'ICO']:
output_format = 'JPEG'
opt = {'format': output_format}
if output_format == 'PNG':
opt['optimize'] = True
alpha = False
if output_image.mode in ('RGBA', 'LA') or (output_image.mode == 'P' and 'transparency' in output_image.info):
alpha = output_image.convert('RGBA').split()[-1]
if output_image.mode != 'P':
# Floyd Steinberg dithering by default
output_image = output_image.convert('RGBA').convert('P', palette=Image.WEB, colors=256)
if alpha:
output_image.putalpha(alpha)
if output_format == 'JPEG':
opt['optimize'] = True
opt['quality'] = quality
if output_format == 'GIF':
opt['optimize'] = True
if output_image.mode not in ["1", "L", "P", "RGB", "RGBA"] or (output_format == 'JPEG' and output_image.mode == 'RGBA'):
output_image = output_image.convert("RGB")
return image_to_base64(output_image, **opt)
def resize(self, max_width=0, max_height=0):
"""Resize the image.
The image is never resized above the current image size. This method is
only to create a smaller version of the image.
The current ratio is preserved. To change the ratio, see `crop_resize`.
If `max_width` or `max_height` is falsy, it will be computed from the
other to keep the current ratio. If both are falsy, no resize is done.
:param max_width: max width
:type max_width: int
:param max_height: max height
:type max_height: int
:return: self to allow chaining
:rtype: ImageProcess
"""
if self.image and (max_width or max_height):
w, h = self.image.size
asked_width = max_width or (w * max_height) // h
asked_height = max_height or (h * max_width) // w
self.image.thumbnail((asked_width, asked_height), Image.LANCZOS)
return self
def crop_resize(self, max_width, max_height, center_x=0.5, center_y=0.5):
"""Crop and resize the image.
The image is never resized above the current image size. This method is
only to create smaller versions of the image.
Instead of preserving the ratio of the original image like `resize`,
this method will force the output to take the ratio of the given
`max_width` and `max_height`, so both have to be defined.
The crop is done before the resize in order to preserve as much of the
original image as possible. The goal of this method is primarily to
resize to a given ratio, and it is not to crop unwanted parts of the
original image. If the latter is what you want to do, you should create
another method, or directly use the `crop` method from PIL.
:param max_width: max width
:type max_width: int
:param max_height: max height
:type max_height: int
:param center_x: the center of the crop between 0 (left) and 1 (right)
Default to 0.5 (center).
:type center_x: float
:param center_y: the center of the crop between 0 (top) and 1 (bottom)
Default to 0.5 (center).
:type center_y: float
:return: self to allow chaining
:rtype: ImageProcess
"""
if self.image and max_width and max_height:
w, h = self.image.size
# We want to keep as much of the image as possible -> at least one
# of the 2 crop dimensions always has to be the same value as the
# original image.
# The target size will be reached with the final resize.
if w / max_width > h / max_height:
new_w, new_h = w, (max_height * w) // max_width
else:
new_w, new_h = (max_width * h) // max_height, h
# No cropping above image size.
if new_w > w:
new_w, new_h = w, (new_h * w) // new_w
if new_h > h:
new_w, new_h = (new_w * h) // new_h, h
# Corretly place the center of the crop.
x_offset = (w - new_w) * center_x
h_offset = (h - new_h) * center_y
self.image = self.image.crop((x_offset, h_offset, x_offset + new_w, h_offset + new_h))
return self.resize(max_width, max_height)
def colorize(self):
"""Replace the trasparent background by a random color.
:return: self to allow chaining
:rtype: ImageProcess
"""
if self.image:
original = self.image
color = (randrange(32, 224, 24), randrange(32, 224, 24), randrange(32, 224, 24))
self.image = Image.new('RGB', original.size)
self.image.paste(color, box=(0, 0) + original.size)
self.image.paste(original, mask=original)
return self
def image_resize_and_sharpen(image, size, preserve_aspect_ratio=False, factor=2.0, upper_limit=False):
def image_process(base64_source, size=(0, 0), verify_resolution=False, quality=80, crop=None, colorize=False, output_format=None):
"""Process the `base64_source` image by executing the given operations and
return the result as a base64 encoded image.
"""
Create a thumbnail by resizing while keeping ratio.
A sharpen filter is applied for a better looking result.
:param image: PIL.Image.Image()
:param size: 2-tuple(width, height)
:param preserve_aspect_ratio: boolean (default: False)
:param factor: Sharpen factor (default: 2.0)
"""
origin_mode = image.mode
if image.mode != 'RGBA':
image = image.convert('RGBA')
image.thumbnail(size, Image.ANTIALIAS)
if preserve_aspect_ratio:
size = image.size
sharpener = ImageEnhance.Sharpness(image)
resized_image = sharpener.enhance(factor)
# create a transparent image for background and paste the image on it
if upper_limit:
image = Image.new('RGBA', (size[0], size[1]-3), (255, 255, 255, 0)) # FIXME temporary fix for trimming the ghost border.
else:
image = Image.new('RGBA', size, (255, 255, 255, 0))
image.paste(resized_image, ((size[0] - resized_image.size[0]) // 2, (size[1] - resized_image.size[1]) // 2))
if image.mode != origin_mode:
image = image.convert(origin_mode)
return image
def image_save_for_web(image, fp=None, format=None):
"""
Save image optimized for web usage.
:param image: PIL.Image.Image()
:param fp: File name or file object. If not specified, a bytestring is returned.
:param format: File format if could not be deduced from image.
"""
opt = dict(format=image.format or format)
if image.format == 'PNG':
opt.update(optimize=True)
alpha = False
if image.mode in ('RGBA', 'LA') or (image.mode == 'P' and 'transparency' in image.info):
alpha = image.convert('RGBA').split()[-1]
if image.mode != 'P':
# Floyd Steinberg dithering by default
image = image.convert('RGBA').convert('P', palette=Image.WEB, colors=256)
if alpha:
image.putalpha(alpha)
elif image.format == 'JPEG':
opt.update(optimize=True, quality=80)
if fp:
image.save(fp, **opt)
else:
img = io.BytesIO()
image.save(img, **opt)
return img.getvalue()
def image_resize_image_big(base64_source, encoding='base64', filetype=None, avoid_if_small=True, preserve_aspect_ratio=False, upper_limit=False):
""" Wrapper on image_resize_image, to resize images larger than the standard
'big' image size: 1024x1024px.
Refer to image_resize_image for the parameters.
"""
return image_resize_image(base64_source, IMAGE_BIG_SIZE, encoding, filetype, avoid_if_small, preserve_aspect_ratio, upper_limit)
def image_resize_image_large(base64_source, encoding='base64', filetype=None, avoid_if_small=True, preserve_aspect_ratio=False, upper_limit=False):
""" Wrapper on image_resize_image, to resize to the standard 'large'
image size: 256x256.
Refer to image_resize_image for the parameters.
"""
return image_resize_image(base64_source, IMAGE_LARGE_SIZE, encoding, filetype, avoid_if_small, preserve_aspect_ratio, upper_limit)
def image_resize_image_medium(base64_source, encoding='base64', filetype=None, avoid_if_small=False, preserve_aspect_ratio=False, upper_limit=False):
""" Wrapper on image_resize_image, to resize to the standard 'medium'
image size: 128x128.
Refer to image_resize_image for the parameters.
"""
return image_resize_image(base64_source, IMAGE_MEDIUM_SIZE, encoding, filetype, avoid_if_small, preserve_aspect_ratio, upper_limit)
def image_resize_image_small(base64_source, encoding='base64', filetype=None, avoid_if_small=False, preserve_aspect_ratio=False, upper_limit=False):
""" Wrapper on image_resize_image, to resize to the standard 'small' image
size: 64x64.
Refer to image_resize_image for the parameters.
"""
return image_resize_image(base64_source, IMAGE_SMALL_SIZE, encoding, filetype, avoid_if_small, preserve_aspect_ratio, upper_limit)
# ----------------------------------------
# Crop Image
# ----------------------------------------
def crop_image(data, type='top', ratio=None, size=None, image_format=None):
""" Used for cropping image and create thumbnail
:param data: base64 data of image.
:param type: Used for cropping position possible
Possible Values : 'top', 'center', 'bottom'
:param ratio: Cropping ratio
e.g for (4,3), (16,9), (16,10) etc
send ratio(1,1) to generate square image
:param size: Resize image to size
e.g (200, 200)
after crop resize to 200x200 thumbnail
:param image_format: return image format PNG,JPEG etc
"""
if not data:
return False
image_stream = Image.open(io.BytesIO(base64.b64decode(data)))
output_stream = io.BytesIO()
w, h = image_stream.size
new_h = h
new_w = w
if ratio:
w_ratio, h_ratio = ratio
new_h = (w * h_ratio) // w_ratio
new_w = w
if new_h > h:
new_h = h
new_w = (h * w_ratio) // h_ratio
image_format = image_format or image_stream.format or 'JPEG'
if type == "top":
cropped_image = image_stream.crop((0, 0, new_w, new_h))
cropped_image.save(output_stream, format=image_format)
elif type == "center":
cropped_image = image_stream.crop(((w - new_w) // 2, (h - new_h) // 2, (w + new_w) // 2, (h + new_h) // 2))
cropped_image.save(output_stream, format=image_format)
elif type == "bottom":
cropped_image = image_stream.crop((0, h - new_h, new_w, h))
cropped_image.save(output_stream, format=image_format)
else:
raise ValueError('ERROR: invalid value for crop_type')
image = ImageProcess(base64_source, verify_resolution)
if size:
thumbnail = Image.open(io.BytesIO(output_stream.getvalue()))
output_stream.truncate(0)
output_stream.seek(0)
thumbnail.thumbnail(size, Image.ANTIALIAS)
thumbnail.save(output_stream, image_format)
return base64.b64encode(output_stream.getvalue())
# ----------------------------------------
# Colors
# ---------------------------------------
def image_colorize(original, randomize=True, color=(255, 255, 255)):
""" Add a color to the transparent background of an image.
:param original: file object on the original image file
:param randomize: randomize the background color
:param color: background-color, if not randomize
"""
# create a new image, based on the original one
original = Image.open(io.BytesIO(original))
image = Image.new('RGB', original.size)
# generate the background color, past it as background
if randomize:
color = (randrange(32, 224, 24), randrange(32, 224, 24), randrange(32, 224, 24))
image.paste(color, box=(0, 0) + original.size)
image.paste(original, mask=original)
# return the new image
buffer = io.BytesIO()
image.save(buffer, 'PNG')
return buffer.getvalue()
if crop:
center_x = 0.5
center_y = 0.5
if crop == 'top':
center_y = 0
elif crop == 'bottom':
center_y = 1
image.crop_resize(max_width=size[0], max_height=size[1], center_x=center_x, center_y=center_y)
else:
image.resize(max_width=size[0], max_height=size[1])
if colorize:
image.colorize()
return image.image_base64(quality=quality, output_format=output_format)
# ----------------------------------------
# Misc image tools
# ---------------------------------------
def is_image_size_above(base64_source, encoding='base64', size=IMAGE_BIG_SIZE):
def base64_to_image(base64_source):
"""Return a PIL image from the given `base64_source`.
:param base64_source: the image base64 encoded
:type base64_source: string or bytes
:return: the PIL image
:rtype: PIL.Image
:raise: binascii.Error: if the base64 is incorrect
:raise: OSError if the image can't be identified by PIL
"""
return Image.open(io.BytesIO(base64.b64decode(base64_source)))
def image_to_base64(image, format, **params):
"""Return a base64_image from the given PIL `image` using `params`.
:param image: the PIL image
:type image: PIL.Image
:param params: params to expand when calling PIL.Image.save()
:type params: dict
:return: the image base64 encoded
:rtype: bytes
"""
stream = io.BytesIO()
image.save(stream, format=format, **params)
return base64.b64encode(stream.getvalue())
def is_image_size_above(base64_source, size=IMAGE_BIG_SIZE):
"""Return whether or not the size of the given image `base64_source` is
above the provided `size` (tuple: width, height).
"""
if not base64_source:
return False
if isinstance(base64_source, str):
base64_source = base64_source.encode('ascii')
if base64_source[:1] == b'P':
# False for SVG
return False
image_stream = io.BytesIO(codecs.decode(base64_source, encoding))
image = Image.open(image_stream)
image = base64_to_image(base64_source)
width, height = image.size
return width > size[0] or height > size[1]
def image_guess_size_from_field_name(field_name):
"""Attempt to guess the image size based on `field_name`.
If it can't be guessed, return (0, 0) instead.
:param field_name: the name of a field
:type field_name: string
:return: the guessed size
:rtype: tuple (width, height)
"""
suffix = 'big' if field_name == 'image' else field_name.split('_')[-1]
if suffix == 'big':
return IMAGE_BIG_SIZE
if suffix == 'large':
return IMAGE_LARGE_SIZE
if suffix == 'medium':
return IMAGE_MEDIUM_SIZE
if suffix == 'small':
return IMAGE_SMALL_SIZE
return (0, 0)
def image_get_resized_images(base64_source,
big_name='image', large_name='image_large', medium_name='image_medium', small_name='image_small',
avoid_resize_big=True, avoid_resize_large=True, avoid_resize_medium=True, avoid_resize_small=True,
preserve_aspect_ratio=False, upper_limit=False):
big_name='image', large_name='image_large', medium_name='image_medium', small_name='image_small'):
""" Standard tool function that returns a dictionary containing the
big, medium, large and small versions of the source image.
@@ -318,23 +334,20 @@ def image_get_resized_images(base64_source,
previous parameters.
"""
return_dict = dict()
if isinstance(base64_source, str):
base64_source = base64_source.encode('ascii')
if big_name:
return_dict[big_name] = image_resize_image_big(base64_source, avoid_if_small=avoid_resize_big, preserve_aspect_ratio=preserve_aspect_ratio, upper_limit=upper_limit)
return_dict[big_name] = image_process(base64_source, size=IMAGE_BIG_SIZE)
if large_name:
return_dict[large_name] = image_resize_image_large(base64_source, avoid_if_small=avoid_resize_large, preserve_aspect_ratio=preserve_aspect_ratio, upper_limit=upper_limit)
return_dict[large_name] = image_process(base64_source, size=IMAGE_LARGE_SIZE)
if medium_name:
return_dict[medium_name] = image_resize_image_medium(base64_source, avoid_if_small=avoid_resize_medium, preserve_aspect_ratio=preserve_aspect_ratio, upper_limit=upper_limit)
return_dict[medium_name] = image_process(base64_source, size=IMAGE_MEDIUM_SIZE)
if small_name:
return_dict[small_name] = image_resize_image_small(base64_source, avoid_if_small=avoid_resize_small, preserve_aspect_ratio=preserve_aspect_ratio, upper_limit=upper_limit)
return_dict[small_name] = image_process(base64_source, size=IMAGE_SMALL_SIZE)
return return_dict
def image_resize_images(vals,
return_big=True, return_large=False, return_medium=True, return_small=True,
big_name='image', large_name='image_large', medium_name='image_medium', small_name='image_small',
preserve_aspect_ratio=False, upper_limit=False):
big_name='image', large_name='image_large', medium_name='image_medium', small_name='image_small'):
""" Update ``vals`` with image fields resized as expected. """
big_image = vals.get(big_name)
large_image = vals.get(large_name)
@@ -345,11 +358,7 @@ def image_resize_images(vals,
if biggest_image:
vals.update(image_get_resized_images(biggest_image,
big_name=return_big and big_name, large_name=return_large and large_name, medium_name=return_medium and medium_name, small_name=return_small and small_name,
avoid_resize_big=True, avoid_resize_large=True,
avoid_resize_medium=(not big_image and not large_image),
avoid_resize_small=(not big_image and not large_image and not medium_image),
preserve_aspect_ratio=preserve_aspect_ratio, upper_limit=upper_limit))
big_name=return_big and big_name, large_name=return_large and large_name, medium_name=return_medium and medium_name, small_name=return_small and small_name))
elif any(f in vals for f in [big_name, large_name, medium_name, small_name]):
if return_big:
vals[big_name] = False
@@ -361,30 +370,6 @@ def image_resize_images(vals,
vals[small_name] = False
def limited_image_resize(content, width=None, height=None, crop=False, upper_limit=False, avoid_if_small=False):
"""
:param content: bytes (should be an image)
"""
if content and (width or height):
signatures = [b'\xFF\xD8\xFF', b'\x89PNG\r\n\x1A\n']
decoded_content = base64.b64decode(content)
is_image = any(decoded_content.startswith(signature) for signature in signatures)
if is_image:
height = int(height or 0)
width = int(width or 0)
if crop:
return crop_image(content, type='center', size=(width, height), ratio=(1, 1))
else:
if not upper_limit:
# resize maximum 500*500
width = min(width, 500)
height = min(height, 500)
return image_resize_image(
base64_source=content, size=(width or None, height or None), encoding='base64', upper_limit=upper_limit,
avoid_if_small=avoid_if_small)
return content
def image_data_uri(base64_source):
"""This returns data URL scheme according RFC 2397
(https://tools.ietf.org/html/rfc2397) for all kind of supported images
@@ -402,5 +387,5 @@ if __name__=="__main__":
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_resize_image(img, (128,100))
new = image_process(img, (128, 100))
open(sys.argv[2], 'wb').write(base64.b64decode(new))
+1
View File
@@ -125,6 +125,7 @@ _mime_mappings = (
_Entry('image/svg+xml', [b'<'], [
_check_svg,
]),
_Entry('image/x-icon', [b'\x00\x00\x01\x00'], []),
# OLECF files in general (Word, Excel, PPT, default to word because why not?)
_Entry('application/msword', [b'\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1', b'\x0D\x44\x4F\x43'], [
_check_olecf