From 567e5b58d544e4e56c3a68d148e862a516d77c4f Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Tue, 18 Oct 2022 11:02:59 +0000 Subject: [PATCH] [IMP] base, tools, web_editor, *: use original image if size increases *: web_tour, website When no transformation is applied on an image, changing the quality sometimes increases its storage size. This commit makes sure that the original image remains used if only the image quality is modified and if this makes its storage size bigger. Fixes #61619 task-2835144 closes odoo/odoo#103398 Signed-off-by: Romain Derie (rde) --- .../static/src/js/editor/image_processing.js | 19 ++++++- .../static/src/tour_service/tour_utils.js | 7 +++ .../tests/tours/snippet_image_quality.js | 55 +++++++++++++++++++ addons/website/tests/test_attachment.py | 6 ++ odoo/addons/base/tests/test_image.py | 13 +++++ odoo/tools/image.py | 7 ++- 6 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 addons/website/static/tests/tours/snippet_image_quality.js 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.