diff --git a/addons/web_editor/static/src/js/editor/image_processing.js b/addons/web_editor/static/src/js/editor/image_processing.js index 1f87360f9c4..8b66f16e6f6 100644 --- a/addons/web_editor/static/src/js/editor/image_processing.js +++ b/addons/web_editor/static/src/js/editor/image_processing.js @@ -334,7 +334,13 @@ async function applyModifications(img, dataOptions = {}) { ctx.fillRect(0, 0, result.width, result.height); // Quality - return result.toDataURL(mimetype, quality / 100); + const dataURL = result.toDataURL(mimetype, quality / 100); + const newSize = getDataURLBinarySize(dataURL); + const originalSize = _getImageSizeFromCache(originalSrc); + const isChanged = !!perspective || !!glFilter || + original.width !== result.width || original.height !== result.height || + original.width !== croppedImg.width || original.height !== croppedImg.height; + return (isChanged || originalSize >= newSize) ? dataURL : await _loadImageDataURL(originalSrc); } /** @@ -391,9 +397,18 @@ async function _updateImageData(src, key = 'objectURL') { } else { value = URL.createObjectURL(blob); } - imageCache.set(src, Object.assign(currentImageData || {}, {[key]: value})); + imageCache.set(src, Object.assign(currentImageData || {}, {[key]: value, size: blob.size})); return value; } +/** + * Returns the size of a cached image. + * + * @param {String} src used as a key on the image cache map. + * @returns {Number} size of the image in bytes. + */ +function _getImageSizeFromCache(src) { + return imageCache.get(src).size; +} /** * Activates the cropper on a given image. * diff --git a/addons/web_tour/static/src/tour_service/tour_utils.js b/addons/web_tour/static/src/tour_service/tour_utils.js index 26a36e779b3..5d9db91ce1c 100644 --- a/addons/web_tour/static/src/tour_service/tour_utils.js +++ b/addons/web_tour/static/src/tour_service/tour_utils.js @@ -211,6 +211,9 @@ export class RunningTourActionHelper { text_blur(text, element) { this._text_blur(this._get_action_values(element), text); } + range(text, element) { + this._range(this._get_action_values(element), text); + } drag_and_drop(to, element) { this._drag_and_drop_jquery(this._get_action_values(element), to); } @@ -313,6 +316,10 @@ export class RunningTourActionHelper { values.$element.trigger("focusout"); values.$element.trigger("blur"); } + _range(values, text) { + values.$element[0].value = text; + values.$element[0].dispatchEvent(new Event('change', { bubbles: true, cancelable: false })); + } _calculateCenter($el, selector) { const center = $el.offset(); if (selector && selector.indexOf("iframe") !== -1) { diff --git a/addons/website/static/tests/tours/snippet_image_quality.js b/addons/website/static/tests/tours/snippet_image_quality.js new file mode 100644 index 00000000000..4f36c7e66e4 --- /dev/null +++ b/addons/website/static/tests/tours/snippet_image_quality.js @@ -0,0 +1,55 @@ +/** @odoo-module */ + +import wTourUtils from 'website.tour_utils'; + +wTourUtils.registerWebsitePreviewTour('website_image_quality', { + test: true, + url: '/', + edition: true, +}, [ + wTourUtils.dragNDrop({ + id: 's_text_image', + name: 'Text - Image', + }), + { + content: "Select image", + trigger: 'iframe .s_text_image img', + }, + { + content: "Set low quality", + trigger: 'we-customizeblock-options:has(we-title:contains("Image")) we-range[data-set-quality] input', + run: 'range 5', + }, + { + content: "Wait for image update: NOT original image", + trigger: 'iframe .s_text_image img:not([src$="s_text_image_default_image"])', + run: () => {}, // It is a check. + }, + { + content: "Check image size", + // Reached size cannot be hardcoded because it changes with + // different versions of Chrome. + trigger: 'we-customizeblock-options:has(we-title:contains("Image")) .o_we_image_weight:contains(" kb"):not(:contains("16.1 kb"))', + run() { + // Make sure the reached size is smaller than the original one. + if (parseFloat(this.$anchor[0].innerText) >= 47.5) { + console.error("Image size should be smaller than original"); + } + }, + }, + { + content: "Set high quality", + trigger: 'we-customizeblock-options:has(we-title:contains("Image")) we-range[data-set-quality] input', + run: 'range 99', + }, + { + content: "Wait for image update: back to original image", + trigger: 'iframe .s_text_image img[src$="QUntVvdqEdljipEUu7ez5lb6IqyGKzQmRxVnZX/2Q=="]', + run: () => {}, // It is a check. + }, + { + content: "Check image size", + trigger: 'we-customizeblock-options:has(we-title:contains("Image")) .o_we_image_weight:contains("47.5 kb")', + run: () => {}, // It is a check. + }, +]); diff --git a/addons/website/tests/test_attachment.py b/addons/website/tests/test_attachment.py index 90d6844fa73..6070a7960be 100644 --- a/addons/website/tests/test_attachment.py +++ b/addons/website/tests/test_attachment.py @@ -1,3 +1,6 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + from urllib.parse import urlparse import odoo.tests @@ -51,3 +54,6 @@ class TestWebsiteAttachment(odoo.tests.HttpCase): req = self.opener.get(base + '/web/image/test.an_image_redirect_301', allow_redirects=True) self.assertEqual(req.status_code, 200) + + def test_02_image_quality(self): + self.start_tour(self.env['website'].get_client_action_url('/'), 'website_image_quality', login="admin") diff --git a/odoo/addons/base/tests/test_image.py b/odoo/addons/base/tests/test_image.py index 7ef40bc2926..7df9974a825 100644 --- a/odoo/addons/base/tests/test_image.py +++ b/odoo/addons/base/tests/test_image.py @@ -177,6 +177,19 @@ class TestImage(TransactionCase): res = tools.image_process(self.img_1920x1080_jpeg) self.assertLessEqual(len(res), len(self.img_1920x1080_jpeg)) + # CASE: JPEG optimize + bigger size => original + pil_image = Image.new('RGB', (1920, 1080), color=self.bg_color) + # Drawing non trivial content so that optimization matters. + ImageDraw.Draw(pil_image).ellipse(xy=[ + (400, 0), + (1500, 1080) + ], fill=self.fill_color, outline=(240, 25, 40), width=10) + image = tools.image_apply_opt(pil_image, 'JPEG') + res = tools.image_process(image, quality=50) + self.assertLess(len(res), len(image), "Low quality image should be smaller than original") + res = tools.image_process(image, quality=99) + self.assertEqual(len(res), len(image), "Original should be returned if size increased") + # CASE: GIF doesn't apply quality, just optimize image = tools.image_apply_opt(Image.new('RGB', (1080, 1920)), 'GIF') res = tools.image_process(image) diff --git a/odoo/tools/image.py b/odoo/tools/image.py index 9b8b4efda1b..ede32cd592c 100644 --- a/odoo/tools/image.py +++ b/odoo/tools/image.py @@ -151,7 +151,12 @@ class ImageProcess(): 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_apply_opt(output_image, **opt) + output_bytes = image_apply_opt(output_image, **opt) + if len(output_bytes) >= len(self.source) and self.original_format == output_format and not self.operationsCount: + # Format has not changed and image content is unchanged but the + # reached binary is bigger: rather use the original. + return self.source + return output_bytes def resize(self, max_width=0, max_height=0): """Resize the image.