[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:
@@ -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])]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
# ----------------------------------------------------------
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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'))
|
||||
@@ -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(
|
||||
|
||||
@@ -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 && !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' && 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>
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user