diff --git a/addons/mrp/views/ir_attachment_view.xml b/addons/mrp/views/ir_attachment_view.xml
index 9b23507d437..c27f163a7ab 100644
--- a/addons/mrp/views/ir_attachment_view.xml
+++ b/addons/mrp/views/ir_attachment_view.xml
@@ -24,7 +24,7 @@
-
+
diff --git a/addons/test_website/static/tests/tours/image_upload_progress.js b/addons/test_website/static/tests/tours/image_upload_progress.js
index 657ce28a45f..0e15a30796b 100644
--- a/addons/test_website/static/tests/tours/image_upload_progress.js
+++ b/addons/test_website/static/tests/tours/image_upload_progress.js
@@ -57,7 +57,7 @@ const setupSteps = [{
run: "drag_and_drop iframe #wrap",
}];
-const formatErrorMsg = "format is not supported. Try with: .gif, .jpe, .jpeg, .jpg, .png, .svg";
+const formatErrorMsg = "format is not supported. Try with: .gif, .jpe, .jpeg, .jpg, .png, .svg, .webp";
wTourUtils.registerWebsitePreviewTour('test_image_upload_progress', {
url: '/test_image_progress',
@@ -87,7 +87,7 @@ wTourUtils.registerWebsitePreviewTour('test_image_upload_progress', {
run: function () {}, // it's a check
}, {
content: "check upload progress bar is correctly shown (2)",
- trigger: `.o_we_progressbar:contains('image.webp'):contains('${formatErrorMsg}')`,
+ trigger: ".o_we_progressbar:contains('image.webp'):contains('File has been uploaded')",
in_modal: false,
run: function () {}, // it's a check
}, {
diff --git a/addons/web/static/src/legacy/js/public/public_root.js b/addons/web/static/src/legacy/js/public/public_root.js
index 6f84df144bf..b72fca991e5 100644
--- a/addons/web/static/src/legacy/js/public/public_root.js
+++ b/addons/web/static/src/legacy/js/public/public_root.js
@@ -92,7 +92,7 @@ export const PublicRoot = publicWidget.RootWidget.extend({
// Display image thumbnail
this.$(".o_image[data-mimetype^='image']").each(function () {
var $img = $(this);
- if (/gif|jpe|jpg|png/.test($img.data('mimetype')) && $img.data('src')) {
+ if (/gif|jpe|jpg|png|webp/.test($img.data('mimetype')) && $img.data('src')) {
$img.css('background-image', "url('" + $img.data('src') + "')");
}
});
diff --git a/addons/web/static/src/views/fields/image/image_field.js b/addons/web/static/src/views/fields/image/image_field.js
index 55d361754f7..16a42a37e55 100644
--- a/addons/web/static/src/views/fields/image/image_field.js
+++ b/addons/web/static/src/views/fields/image/image_field.js
@@ -17,6 +17,7 @@ export const fileTypeMagicWordMap = {
R: "gif",
i: "png",
P: "svg+xml",
+ U: "webp",
};
const placeholder = "/web/static/img/placeholder.png";
@@ -55,6 +56,7 @@ export class ImageField extends Component {
setup() {
this.notification = useService("notification");
+ this.orm = useService('orm');
this.isMobile = isMobileOS();
this.state = useState({
isValid: true,
@@ -123,10 +125,54 @@ export class ImageField extends Component {
this.state.isValid = true;
this.props.record.update({ [this.props.name]: false });
}
- onFileUploaded(info) {
+ async onFileUploaded(info) {
this.state.isValid = true;
// Invalidate the `rawCacheKey`.
this.rawCacheKey = null;
+ if (info.type === 'image/webp') {
+ // Generate alternate sizes and format for reports.
+ const image = document.createElement('img');
+ image.src = `data:image/webp;base64,${info.data}`;
+ await new Promise(resolve => image.addEventListener('load', resolve));
+ const originalSize = Math.max(image.width, image.height);
+ const smallerSizes = [1024, 512, 256, 128].filter(size => size < originalSize);
+ let referenceId = undefined;
+ for (const size of [originalSize, ...smallerSizes]) {
+ const ratio = size / originalSize;
+ const canvas = document.createElement('canvas');
+ canvas.width = image.width * ratio;
+ canvas.height = image.height * ratio;
+ const ctx = canvas.getContext('2d');
+ ctx.fillStyle = 'rgb(255, 255, 255)';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(image, 0, 0, image.width, image.height, 0, 0, canvas.width, canvas.height);
+ const [resizedId] = await this.orm.call(
+ 'ir.attachment',
+ 'create_unique',
+ [[{
+ 'name': info.name,
+ 'description': size === originalSize ? '' : `resize: ${size}`,
+ 'datas': size === originalSize ? info.data : canvas.toDataURL('image/webp', 0.75).split(',')[1],
+ 'res_id': referenceId,
+ 'res_model': 'ir.attachment',
+ 'mimetype': 'image/webp',
+ }]],
+ );
+ referenceId = referenceId || resizedId; // Keep track of original.
+ await this.orm.call(
+ 'ir.attachment',
+ 'create_unique',
+ [[{
+ 'name': info.name.replace(/\.webp$/, '.jpg'),
+ 'description': 'format: jpeg',
+ 'datas': canvas.toDataURL('image/jpeg', 0.75).split(',')[1],
+ 'res_id': resizedId,
+ 'res_model': 'ir.attachment',
+ 'mimetype': 'image/jpeg',
+ }]],
+ );
+ }
+ }
this.props.record.update({ [this.props.name]: info.data });
}
onLoadFailed() {
diff --git a/addons/web_editor/controllers/main.py b/addons/web_editor/controllers/main.py
index 47ece23cec1..1c78c74a982 100644
--- a/addons/web_editor/controllers/main.py
+++ b/addons/web_editor/controllers/main.py
@@ -542,7 +542,7 @@ class Web_Editor(http.Controller):
return files_data_by_bundle
@http.route('/web_editor/modify_image/
', type="json", auth="user", website=True)
- def modify_image(self, attachment, res_model=None, res_id=None, name=None, data=None, original_id=None, mimetype=None):
+ def modify_image(self, attachment, res_model=None, res_id=None, name=None, data=None, original_id=None, mimetype=None, alt_data=None):
"""
Creates a modified copy of an attachment and returns its image_src to be
inserted into the DOM.
@@ -561,6 +561,28 @@ class Web_Editor(http.Controller):
if name:
fields['name'] = name
attachment = attachment.copy(fields)
+ if alt_data:
+ for size, per_type in alt_data.items():
+ reference_id = attachment.id
+ if 'image/webp' in per_type:
+ resized = attachment.create_unique([{
+ 'name': attachment.name,
+ 'description': 'resize: %s' % size,
+ 'datas': per_type['image/webp'],
+ 'res_id': reference_id,
+ 'res_model': 'ir.attachment',
+ 'mimetype': 'image/webp',
+ }])
+ reference_id = resized[0]
+ if 'image/jpeg' in per_type:
+ attachment.create_unique([{
+ 'name': re.sub(r'\.webp$', '.jpg', attachment.name),
+ 'description': 'format: jpeg',
+ 'datas': per_type['image/jpeg'],
+ 'res_id': reference_id,
+ 'res_model': 'ir.attachment',
+ 'mimetype': 'image/jpeg',
+ }])
if attachment.url:
# Don't keep url if modifying static attachment because static images
# are only served from disk and don't fallback to attachments.
diff --git a/addons/web_editor/models/ir_attachment.py b/addons/web_editor/models/ir_attachment.py
index 14462baf8dc..427a7fbab66 100644
--- a/addons/web_editor/models/ir_attachment.py
+++ b/addons/web_editor/models/ir_attachment.py
@@ -12,6 +12,7 @@ SUPPORTED_IMAGE_MIMETYPES = {
'image/jpg': '.jpg',
'image/png': '.png',
'image/svg+xml': '.svg',
+ 'image/webp': '.webp',
}
diff --git a/addons/web_editor/static/src/components/media_dialog/file_selector.js b/addons/web_editor/static/src/components/media_dialog/file_selector.js
index d0306e83edc..cec28eb796a 100644
--- a/addons/web_editor/static/src/components/media_dialog/file_selector.js
+++ b/addons/web_editor/static/src/components/media_dialog/file_selector.js
@@ -9,8 +9,8 @@ import { SearchMedia } from './search_media';
import { Component, xml, useState, useRef, onWillStart } from "@odoo/owl";
-export const IMAGE_MIMETYPES = ['image/jpg', 'image/jpeg', 'image/jpe', 'image/png', 'image/svg+xml', 'image/gif'];
-export const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.jpe', '.png', '.svg', '.gif'];
+export const IMAGE_MIMETYPES = ['image/jpg', 'image/jpeg', 'image/jpe', 'image/png', 'image/svg+xml', 'image/gif', 'image/webp'];
+export const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.jpe', '.png', '.svg', '.gif', '.webp'];
class RemoveButton extends Component {
setup() {
diff --git a/addons/web_editor/static/src/components/upload_progress_toast/upload_service.js b/addons/web_editor/static/src/components/upload_progress_toast/upload_service.js
index de4a42b2e84..6ccddf26105 100644
--- a/addons/web_editor/static/src/components/upload_progress_toast/upload_service.js
+++ b/addons/web_editor/static/src/components/upload_progress_toast/upload_service.js
@@ -122,6 +122,29 @@ export const uploadService = {
file.hasError = true;
file.errorMessage = attachment.error;
} else {
+ if (attachment.mimetype === 'image/webp') {
+ // Generate alternate format for reports.
+ const image = document.createElement('img');
+ image.src = `data:image/webp;base64,${dataURL.split(',')[1]}`;
+ await new Promise(resolve => image.addEventListener('load', resolve));
+ const canvas = document.createElement('canvas');
+ canvas.width = image.width;
+ canvas.height = image.height;
+ const ctx = canvas.getContext('2d');
+ ctx.fillStyle = 'rgb(255, 255, 255)';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(image, 0, 0);
+ const altDataURL = canvas.toDataURL('image/jpeg', 0.75);
+ await rpc('/web_editor/attachment/add_data', {
+ 'name': file.name.replace(/\.webp$/, '.jpg'),
+ 'data': altDataURL.split(',')[1],
+ 'res_id': attachment.id,
+ 'res_model': 'ir.attachment',
+ 'is_image': true,
+ 'width': 0,
+ 'quality': 0,
+ }, {xhr});
+ }
file.uploaded = true;
await onUploaded(attachment);
}
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 6ec823593ae..b974ae9e321 100644
--- a/addons/web_editor/static/src/js/editor/image_processing.js
+++ b/addons/web_editor/static/src/js/editor/image_processing.js
@@ -456,7 +456,7 @@ function isImageSupportedForProcessing(mimetype, strict = false) {
if (isGif(mimetype)) {
return !strict;
}
- return ['image/jpeg', 'image/png'].includes(mimetype);
+ return ['image/jpeg', 'image/png', 'image/webp'].includes(mimetype);
}
/**
* @param {HTMLImageElement} img
diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/src/OdooEditor.js b/addons/web_editor/static/src/js/editor/odoo-editor/src/OdooEditor.js
index 90739d5f81c..d62d45f3e89 100644
--- a/addons/web_editor/static/src/js/editor/odoo-editor/src/OdooEditor.js
+++ b/addons/web_editor/static/src/js/editor/odoo-editor/src/OdooEditor.js
@@ -4597,7 +4597,7 @@ export class OdooEditor extends EventTarget {
const url = /^https?:\/\//i.test(text) ? text : 'https://' + text;
const youtubeUrl = this.options.allowCommandVideo &&YOUTUBE_URL_GET_VIDEO_ID.exec(url);
const urlFileExtention = url.split('.').pop();
- const isImageUrl = ['jpg', 'jpeg', 'png', 'gif', 'svg'].includes(urlFileExtention.toLowerCase());
+ const isImageUrl = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp'].includes(urlFileExtention.toLowerCase());
// A url cannot be transformed inside an existing link.
// An image can be embedded inside an existing link, a video cannot.
if (selectionIsInsideALink) {
diff --git a/addons/web_editor/static/src/js/editor/snippets.options.js b/addons/web_editor/static/src/js/editor/snippets.options.js
index 7b0fc852fc9..fd425d502c1 100644
--- a/addons/web_editor/static/src/js/editor/snippets.options.js
+++ b/addons/web_editor/static/src/js/editor/snippets.options.js
@@ -5677,8 +5677,11 @@ const ImageHandlerOption = SnippetOptionWidget.extend({
/**
* @see this.selectClass for parameters
*/
- selectWidth(previewMode, widgetValue, params) {
- this._getImg().dataset.resizeWidth = widgetValue;
+ selectFormat(previewMode, widgetValue, params) {
+ const values = widgetValue.split(' ');
+ const image = this._getImg();
+ image.dataset.resizeWidth = values[0];
+ image.dataset.mimetype = values[1];
return this._applyOptions();
},
/**
@@ -5747,8 +5750,8 @@ const ImageHandlerOption = SnippetOptionWidget.extend({
});
switch (methodName) {
- case 'selectWidth':
- return img.naturalWidth;
+ case 'selectFormat':
+ return img.naturalWidth + ' ' + this._getImageMimetype(img);
case 'setFilter':
return img.dataset.filter;
case 'glFilter':
@@ -5776,12 +5779,12 @@ const ImageHandlerOption = SnippetOptionWidget.extend({
if (!this.originalSrc || !this._isImageSupportedForProcessing(img)) {
return;
}
- const $select = $(uiFragment).find('we-select[data-name=width_select_opt]');
- (await this._computeAvailableWidths()).forEach(([value, label]) => {
- $select.append(`${label}`);
+ const $select = $(uiFragment).find('we-select[data-name=format_select_opt]');
+ (await this._computeAvailableFormats()).forEach(([value, [label, targetFormat]]) => {
+ $select.append(`${label} ${targetFormat.split('/')[1]}`);
});
- if (this._getImageMimetype(img) !== 'image/jpeg') {
+ if (!['image/jpeg', 'image/webp'].includes(this._getImageMimetype(img))) {
const optQuality = uiFragment.querySelector('we-range[data-set-quality]');
if (optQuality) {
optQuality.remove();
@@ -5789,26 +5792,31 @@ const ImageHandlerOption = SnippetOptionWidget.extend({
}
},
/**
- * Returns a list of valid widths for a given image.
+ * Returns a list of valid formats for a given image.
*
* @private
*/
- async _computeAvailableWidths() {
+ async _computeAvailableFormats() {
const img = this._getImg();
const original = await loadImage(this.originalSrc);
const maxWidth = img.dataset.width ? img.naturalWidth : original.naturalWidth;
const optimizedWidth = Math.min(maxWidth, this._computeMaxDisplayWidth());
this.optimizedWidth = optimizedWidth;
const widths = {
- 128: '128px',
- 256: '256px',
- 512: '512px',
- 1024: '1024px',
- 1920: '1920px',
+ 128: ['128px', 'image/webp'],
+ 256: ['256px', 'image/webp'],
+ 512: ['512px', 'image/webp'],
+ 1024: ['1024px', 'image/webp'],
+ 1920: ['1920px', 'image/webp'],
};
- widths[img.naturalWidth] = sprintf(_t("%spx"), img.naturalWidth);
- widths[optimizedWidth] = sprintf(_t("%spx (Suggested)"), optimizedWidth);
- widths[maxWidth] = sprintf(_t("%spx (Original)"), maxWidth);
+ widths[img.naturalWidth] = [sprintf(_t("%spx"), img.naturalWidth), 'image/webp'];
+ widths[optimizedWidth] = [sprintf(_t("%spx (Suggested)"), optimizedWidth), 'image/webp'];
+ widths[maxWidth] = [sprintf(_t("%spx (Original)"), maxWidth), img.dataset.originalMimetype];
+ if (img.dataset.originalMimetype !== 'image/webp') {
+ // Avoid a key collision by subtracting 0.1 - putting the webp
+ // above the original format one of the same size.
+ widths[maxWidth - 0.1] = [sprintf(_t("%spx"), maxWidth), 'image/webp'];
+ }
return Object.entries(widths)
.filter(([width]) => width <= maxWidth)
.sort(([v1], [v2]) => v1 - v2);
@@ -5863,6 +5871,9 @@ const ImageHandlerOption = SnippetOptionWidget.extend({
}
this.originalId = img.dataset.originalId;
this.originalSrc = img.dataset.originalSrc;
+ if (!img.dataset.originalMimetype) {
+ img.dataset.originalMimetype = img.dataset.mimetype;
+ }
},
/**
* Sets the image's width to its suggested size.
@@ -5872,7 +5883,12 @@ const ImageHandlerOption = SnippetOptionWidget.extend({
async _autoOptimizeImage() {
await this._loadImageInfo();
await this._rerenderXML();
- this._getImg().dataset.resizeWidth = this.optimizedWidth;
+ const img = this._getImg();
+ if (!['image/gif', 'image/svg+xml'].includes(img.dataset.mimetype)) {
+ // Convert to recommended format and width.
+ img.dataset.mimetype = 'image/webp';
+ img.dataset.resizeWidth = this.optimizedWidth;
+ }
await this._applyOptions();
await this.updateUI();
},
@@ -5944,7 +5960,7 @@ const ImageHandlerOption = SnippetOptionWidget.extend({
return params.optionsPossibleValues.glFilter
|| 'customFilter' in params.optionsPossibleValues
|| params.optionsPossibleValues.setQuality
- || widgetName === 'width_select_opt';
+ || widgetName === 'format_select_opt';
},
});
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/image_crop_widget.js b/addons/web_editor/static/src/js/wysiwyg/widgets/image_crop_widget.js
index dec5b6e930c..23cd161da90 100644
--- a/addons/web_editor/static/src/js/wysiwyg/widgets/image_crop_widget.js
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/image_crop_widget.js
@@ -35,7 +35,10 @@ const ImageCropWidget = Widget.extend({
const data = Object.assign({}, media.dataset);
this.initialSrc = src;
this.aspectRatio = data.aspectRatio || "0/0";
- const mimetype = data.mimetype || src.endsWith('.png') ? 'image/png' : 'image/jpeg';
+ const mimetype = data.mimetype ||
+ src.endsWith('.png') ? 'image/png' :
+ src.endsWith('.webp') ? 'image/webp' :
+ 'image/jpeg';
this.mimetype = options.mimetype || mimetype;
},
/**
diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js
index c5f0c8861db..5376dc51341 100644
--- a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js
+++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js
@@ -957,43 +957,14 @@ const Wysiwyg = Widget.extend({
savePendingImages($editable = this.$editable) {
const defs = Array.from($editable).map(async (editableEl) => {
const {oeModel: resModel, oeId: resId} = editableEl.dataset;
+ // When saving a webp, o_b64_image_to_save is turned into
+ // o_modified_image_to_save by _saveB64Image to request the saving
+ // of the pre-converted webp resizes and all the equivalent jpgs.
const b64Proms = [...editableEl.querySelectorAll('.o_b64_image_to_save')].map(async el => {
- const attachment = await this._rpc({
- route: '/web_editor/attachment/add_data',
- params: {
- name: el.dataset.fileName || '',
- data: el.getAttribute('src').split(',')[1],
- is_image: true,
- },
- });
- el.setAttribute('src', attachment.image_src);
- el.classList.remove('o_b64_image_to_save');
+ await this._saveB64Image(el, resModel, resId);
});
const modifiedProms = [...editableEl.querySelectorAll('.o_modified_image_to_save')].map(async el => {
- const isBackground = !el.matches('img');
- el.classList.remove('o_modified_image_to_save');
- // Modifying an image always creates a copy of the original, even if
- // it was modified previously, as the other modified image may be used
- // elsewhere if the snippet was duplicated or was saved as a custom one.
- const newAttachmentSrc = await this._rpc({
- route: `/web_editor/modify_image/${encodeURIComponent(el.dataset.originalId)}`,
- params: {
- res_model: resModel,
- res_id: parseInt(resId),
- data: (isBackground ? el.dataset.bgSrc : el.getAttribute('src')).split(',')[1],
- mimetype: el.dataset.mimetype,
- name: (el.dataset.fileName ? el.dataset.fileName : null),
- },
- });
- if (isBackground) {
- const parts = weUtils.backgroundImageCssToParts($(el).css('background-image'));
- parts.url = `url('${newAttachmentSrc}')`;
- const combined = weUtils.backgroundImagePartsToCss(parts);
- $(el).css('background-image', combined);
- delete el.dataset.bgSrc;
- } else {
- el.setAttribute('src', newAttachmentSrc);
- }
+ await this._saveModifiedImage(el, resModel, resId);
});
return Promise.all([...b64Proms, ...modifiedProms]);
});
@@ -2754,6 +2725,96 @@ const Wysiwyg = Widget.extend({
_bindOnBlur() {
this.$editable.on('blur', this._onBlur);
},
+ /**
+ * Saves a base64 encoded image as an attachment.
+ * Relies on _saveModifiedImage being called after it for webp.
+ *
+ * @private
+ * @param {Element} el
+ * @param {string} resModel
+ * @param {number} resId
+ */
+ async _saveB64Image(el, resModel, resId) {
+ const attachment = await this._rpc({
+ route: '/web_editor/attachment/add_data',
+ params: {
+ name: el.dataset.fileName || '',
+ data: el.getAttribute('src').split(',')[1],
+ is_image: true,
+ },
+ });
+ if (attachment.mimetype === 'image/webp') {
+ el.classList.add('o_modified_image_to_save');
+ el.dataset.originalId = attachment.id;
+ el.dataset.mimetype = attachment.mimetype;
+ el.dataset.fileName = attachment.name;
+ this._saveModifiedImage(el, resModel, resId);
+ } else {
+ el.setAttribute('src', attachment.image_src);
+ }
+ el.classList.remove('o_b64_image_to_save');
+ },
+ /**
+ * Saves a modified image as an attachment.
+ *
+ * @private
+ * @param {Element} el
+ * @param {string} resModel
+ * @param {number} resId
+ */
+ async _saveModifiedImage(el, resModel, resId) {
+ const isBackground = !el.matches('img');
+ el.classList.remove('o_modified_image_to_save');
+ // Modifying an image always creates a copy of the original, even if
+ // it was modified previously, as the other modified image may be used
+ // elsewhere if the snippet was duplicated or was saved as a custom one.
+ let altData = undefined;
+ if (el.dataset.mimetype === 'image/webp') {
+ // Generate alternate sizes and format for reports.
+ altData = {};
+ const image = document.createElement('img');
+ image.src = isBackground ? el.dataset.bgSrc : el.getAttribute('src');
+ await new Promise(resolve => image.addEventListener('load', resolve));
+ const originalSize = Math.max(image.width, image.height);
+ const smallerSizes = [1024, 512, 256, 128].filter(size => size < originalSize);
+ for (const size of [originalSize, ...smallerSizes]) {
+ const ratio = size / originalSize;
+ const canvas = document.createElement('canvas');
+ canvas.width = image.width * ratio;
+ canvas.height = image.height * ratio;
+ const ctx = canvas.getContext('2d');
+ ctx.fillStyle = 'rgb(255, 255, 255)';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(image, 0, 0, image.width, image.height, 0, 0, canvas.width, canvas.height);
+ altData[size] = {
+ 'image/jpeg': canvas.toDataURL('image/jpeg', 0.75).split(',')[1],
+ };
+ if (size !== originalSize) {
+ altData[size]['image/webp'] = canvas.toDataURL('image/webp', 0.75).split(',')[1];
+ }
+ }
+ }
+ const newAttachmentSrc = await this._rpc({
+ route: `/web_editor/modify_image/${encodeURIComponent(el.dataset.originalId)}`,
+ params: {
+ res_model: resModel,
+ res_id: parseInt(resId),
+ data: (isBackground ? el.dataset.bgSrc : el.getAttribute('src')).split(',')[1],
+ alt_data: altData,
+ mimetype: el.getAttribute('src').split(":")[1].split(";")[0],
+ name: (el.dataset.fileName ? el.dataset.fileName : null),
+ },
+ });
+ if (isBackground) {
+ const parts = weUtils.backgroundImageCssToParts($(el).css('background-image'));
+ parts.url = `url('${newAttachmentSrc}')`;
+ const combined = weUtils.backgroundImagePartsToCss(parts);
+ $(el).css('background-image', combined);
+ delete el.dataset.bgSrc;
+ } else {
+ el.setAttribute('src', newAttachmentSrc);
+ }
+ },
});
Wysiwyg.activeCollaborationChannelNames = new Set();
diff --git a/addons/web_editor/static/src/scss/wysiwyg_snippets.scss b/addons/web_editor/static/src/scss/wysiwyg_snippets.scss
index 43766b754bb..ede0b3ad368 100644
--- a/addons/web_editor/static/src/scss/wysiwyg_snippets.scss
+++ b/addons/web_editor/static/src/scss/wysiwyg_snippets.scss
@@ -1132,6 +1132,12 @@
}
}
+ &.o_we_badge_at_end > div {
+ display: flex;
+ width: 100%;
+ justify-content: space-between;
+ }
+
&:not(.d-none) ~ we-button {
// Use a border-top instead of a margin-top as when the
// mouse goes from one select button to another, the
diff --git a/addons/web_editor/views/snippets.xml b/addons/web_editor/views/snippets.xml
index bad5cede7f0..86ebed5b4c1 100644
--- a/addons/web_editor/views/snippets.xml
+++ b/addons/web_editor/views/snippets.xml
@@ -118,10 +118,10 @@
data-max="2000"
data-step="100"/>
- Width
- Format
+
+ data-name="format_select_opt"/>
Quality
\xd5'
+ size = tools.get_webp_size(webp_lossy)
+ self.assertEqual((550, 368), size, "Wrong resolution for lossy webp")
+ # Lossless webp: 421x163
+ webp_lossless = b'RIFF\xba\x84\x00\x00WEBPVP8L\xad\x84\x00\x00/\xa4\x81(\x10MHr\x1bI\x92\xa4'
+ size = tools.get_webp_size(webp_lossless)
+ self.assertEqual((421, 163), size, "Wrong resolution for lossless webp")
+ # Extended webp: 800x600
+ webp_extended = b'RIFF\x80\xce\x00\x00WEBPVP8X\n\x00\x00\x00\x10\x00\x00\x00\x1f\x03\x00W\x02\x00AL'
+ size = tools.get_webp_size(webp_extended)
+ self.assertEqual((800, 600), size, "Wrong resolution for extended webp")
+
def test_20_image_data_uri(self):
"""Test that image_data_uri is working as expected."""
self.assertEqual(tools.image_data_uri(base64.b64encode(self.img_1x1_png)), 'data:image/png;base64,' + base64.b64encode(self.img_1x1_png).decode('ascii'))
diff --git a/odoo/addons/base/tests/test_mimetypes.py b/odoo/addons/base/tests/test_mimetypes.py
index fc9257b5ef4..948e8df9434 100644
--- a/odoo/addons/base/tests/test_mimetypes.py
+++ b/odoo/addons/base/tests/test_mimetypes.py
@@ -25,6 +25,11 @@ SVG = b"""PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iaXNvLTg4NTktMSI/PjwhRE9DVFlQRS
NAMESPACED_SVG = b"""
"""
+
+# single pixel webp image
+WEBP = b"""UklGRjoAAABXRUJQVlA4IC4AAAAwAQCdASoBAAEAAUAmJaAAA3AA/u/uY//8s//2W/7LeM///5Bj
+/dl/pJxGAAAA"""
+
# minimal zip file with an empty `t.txt` file
ZIP = b"""UEsDBBQACAAIAGFva1AAAAAAAAAAAAAAAAAFACAAdC50eHRVVA0AB5bgaF6W4GheluBoXnV4CwABBOgDAAAE6AMAAA
MAUEsHCAAAAAACAAAAAAAAAFBLAQIUAxQACAAIAGFva1AAAAAAAgAAAAAAAAAFACAAAAAAAAAAAACkgQAAAAB0LnR4dFVUDQAHlu
@@ -105,6 +110,11 @@ class test_guess_mimetype(BaseCase):
self.assertNotIn("svg", mimetype)
+ def test_mimetype_webp(self):
+ content = base64.b64decode(WEBP)
+ mimetype = guess_mimetype(content, default='test')
+ self.assertEqual(mimetype, 'image/webp')
+
def test_mimetype_zip(self):
content = base64.b64decode(ZIP)
mimetype = guess_mimetype(content, default='test')
diff --git a/odoo/fields.py b/odoo/fields.py
index 72ae89920cb..216904d383c 100644
--- a/odoo/fields.py
+++ b/odoo/fields.py
@@ -703,9 +703,9 @@ class Field(MetaField('DummyField', (object,), {})):
)
# assign final values to records
for record, value in zip(records, values):
- record[self.name] = self._process_related(value[self.related_field.name])
+ record[self.name] = self._process_related(value[self.related_field.name], record.env)
- def _process_related(self, value):
+ def _process_related(self, value, env):
"""No transformation by default, but allows override."""
return value
@@ -2478,7 +2478,7 @@ class Image(Binary):
for record, value in record_values:
# strange behavior when setting related image field, when `self`
# does not resize the same way as its related field
- new_value = self._image_process(value)
+ new_value = self._image_process(value, record.env)
new_record_values.append((record, new_value))
cache_value = self.convert_to_cache(value if self.related else new_value, record)
record.env.cache.update(record, self, itertools.repeat(cache_value))
@@ -2486,7 +2486,7 @@ class Image(Binary):
def write(self, records, value):
try:
- new_value = self._image_process(value)
+ new_value = self._image_process(value, records.env)
except UserError:
if not any(records._ids):
# Some crap is assigned to a new record. This can happen in an
@@ -2502,7 +2502,7 @@ class Image(Binary):
dirty = self.column_type and self.store and any(records._ids)
records.env.cache.update(records, self, itertools.repeat(cache_value), dirty=dirty)
- def _image_process(self, value):
+ def _image_process(self, value, env):
if self.readonly and not self.max_width and not self.max_height:
# no need to process images for computed fields, or related fields
return value
@@ -2510,15 +2510,40 @@ class Image(Binary):
img = base64.b64decode(value or '') or False
except:
raise UserError(_("Image is not encoded in base64."))
+
+ if img and guess_mimetype(img, '') == 'image/webp':
+ if not self.max_width and not self.max_height:
+ return value
+ # Fetch resized version.
+ Attachment = env['ir.attachment']
+ checksum = Attachment._compute_checksum(img)
+ origins = Attachment.search([
+ ['id', '!=', False], # No implicit condition on res_field.
+ ['checksum', '=', checksum],
+ ])
+ if origins:
+ origin_ids = [attachment.id for attachment in origins]
+ resized_domain = [
+ ['id', '!=', False], # No implicit condition on res_field.
+ ['res_model', '=', 'ir.attachment'],
+ ['res_id', 'in', origin_ids],
+ ['description', '=', 'resize: %s' % max(self.max_width, self.max_height)],
+ ]
+ resized = Attachment.sudo().search(resized_domain, limit=1)
+ if resized:
+ # Fallback on non-resized image (value).
+ return resized.datas or value
+ return value
+
return base64.b64encode(image_process(img,
size=(self.max_width, self.max_height),
verify_resolution=self.verify_resolution,
) or b'') or False
- def _process_related(self, value):
+ def _process_related(self, value, env):
"""Override to resize the related value before saving it on self."""
try:
- return self._image_process(super()._process_related(value))
+ return self._image_process(super()._process_related(value, env), env)
except UserError:
# Avoid the following `write` to fail if the related image was saved
# invalid, which can happen for pre-existing databases.
diff --git a/odoo/http.py b/odoo/http.py
index 5e54e00ae05..c1240270621 100644
--- a/odoo/http.py
+++ b/odoo/http.py
@@ -197,6 +197,7 @@ _logger = logging.getLogger(__name__)
mimetypes.add_type('application/font-woff', '.woff')
mimetypes.add_type('application/vnd.ms-fontobject', '.eot')
mimetypes.add_type('application/x-font-ttf', '.ttf')
+mimetypes.add_type('image/webp', '.webp')
# Add potentially wrong (detected on windows) svg mime types
mimetypes.add_type('image/svg+xml', '.svg')
diff --git a/odoo/tools/image.py b/odoo/tools/image.py
index 1bf10e8d1f0..9b8b4efda1b 100644
--- a/odoo/tools/image.py
+++ b/odoo/tools/image.py
@@ -15,6 +15,7 @@ except ImportError:
from random import randrange
from odoo.exceptions import UserError
+from odoo.tools.misc import DotDict
from odoo.tools.translate import _
@@ -29,6 +30,7 @@ FILETYPE_BASE64_MAGICWORD = {
b'R': 'gif',
b'i': 'png',
b'P': 'svg+xml',
+ b'U': 'webp',
}
EXIF_TAG_ORIENTATION = 0x112
@@ -73,8 +75,8 @@ class ImageProcess():
self.source = source or False
self.operationsCount = 0
- if not source or source[:1] == b'<':
- # don't process empty source or SVG
+ if not source or source[:1] == b'<' or (source[0:4] == b'RIFF' and source[8:15] == b'WEBPVP8'):
+ # don't process empty source or SVG or WEBP
self.image = False
else:
try:
@@ -432,6 +434,43 @@ def image_to_base64(image, output_format, **params):
return base64.b64encode(stream)
+def get_webp_size(source):
+ """
+ Returns the size of the provided webp binary source for VP8, VP8X and
+ VP8L, otherwise returns None.
+ See https://developers.google.com/speed/webp/docs/riff_container.
+
+ :param source: binary source
+ :return: (width, height) tuple, or None if not supported
+ """
+ if not (source[0:4] == b'RIFF' and source[8:15] == b'WEBPVP8'):
+ raise UserError(_("This file is not a webp file."))
+
+ vp8_type = source[15]
+ if vp8_type == 0x20: # 0x20 = ' '
+ # Sizes on big-endian 16 bits at offset 26.
+ width_low, width_high, height_low, height_high = source[26:30]
+ width = (width_high << 8) + width_low
+ height = (height_high << 8) + height_low
+ return (width, height)
+ elif vp8_type == 0x58: # 0x48 = 'X'
+ # Sizes (minus one) on big-endian 24 bits at offset 24.
+ width_low, width_medium, width_high, height_low, height_medium, height_high = source[24:30]
+ width = 1 + (width_high << 16) + (width_medium << 8) + width_low
+ height = 1 + (height_high << 16) + (height_medium << 8) + height_low
+ return (width, height)
+ elif vp8_type == 0x4C and source[20] == 0x2F: # 0x4C = 'L'
+ # Sizes (minus one) on big-endian-ish 14 bits at offset 21.
+ # E.g. [@20] 2F ab cd ef gh
+ # - width = 1 + (c&0x3)d ab: ignore the two high bits of the second byte
+ # - height= 1 + hef(c&0xC>>2): used them as the first two bits of the height
+ ab, cd, ef, gh = source[21:25]
+ width = 1 + ((cd & 0x3F) << 8) + ab
+ height = 1 + ((gh & 0xF) << 10) + (ef << 2) + (cd >> 6)
+ return (width, height)
+ return None
+
+
def is_image_size_above(base64_source_1, base64_source_2):
"""Return whether or not the size of the given image `base64_source_1` is
above the size of the given image `base64_source_2`.
@@ -441,8 +480,21 @@ def is_image_size_above(base64_source_1, base64_source_2):
if base64_source_1[:1] in (b'P', 'P') or base64_source_2[:1] in (b'P', 'P'):
# False for SVG
return False
- image_source = image_fix_orientation(base64_to_image(base64_source_1))
- image_target = image_fix_orientation(base64_to_image(base64_source_2))
+
+ def get_image_size(base64_source):
+ source = base64.b64decode(base64_source)
+ if (source[0:4] == b'RIFF' and source[8:15] == b'WEBPVP8'):
+ size = get_webp_size(source)
+ if size:
+ return DotDict({'width': size[0], 'height': size[0]})
+ else:
+ # False for unknown WEBP format
+ return False
+ else:
+ return image_fix_orientation(binary_to_image(source))
+
+ image_source = get_image_size(base64_source_1)
+ image_target = get_image_size(base64_source_2)
return image_source.width > image_target.width or image_source.height > image_target.height
diff --git a/odoo/tools/mimetypes.py b/odoo/tools/mimetypes.py
index 862c8c06ce7..6efcd76a2ef 100644
--- a/odoo/tools/mimetypes.py
+++ b/odoo/tools/mimetypes.py
@@ -110,6 +110,10 @@ def _check_svg(data):
if b'