[IMP] *: support image/webp image format

Before this commit `.webp` images could not be used in odoo.

After this commit `.webp` images can be uploaded to odoo.
- can be used in image field
- can be used in HTML field image
- can be used in mails and website
- can be transformed (shape mask, filter effect, crop, rotate, resize, quality)
- can be included in PDF reports

Because the Pillow plugin for `webp` relies on the `libwebp` native library, it cannot be used.
Because of this, the server-size resize (for Image fields) and conversion to `jpeg` (for `wkhtmltopdf`) are done beforehand by precomputing them when a webp is uploaded.

After this PR, the general flow when uploading an image is:
1. On upload in `website`: suggest conversion to `webp`
2. On `webp` upload:
2-A. upload converted `jpeg`
2-B. upload resized `webp` (and therefore `jpeg` of each resize because of (2-A))

There is currently no "fallback to original" in (2-A).

task-2774352

closes odoo/odoo#85494

Related: odoo/enterprise#24998
Signed-off-by: Romain Derie (rde) <rde@odoo.com>
This commit is contained in:
Odoo's Mergebot
2023-07-15 05:10:47 +02:00
committed by GitHub
26 changed files with 423 additions and 83 deletions
@@ -62,6 +62,7 @@ export class Attachment {
"image/svg+xml",
"image/tiff",
"image/x-icon",
"image/webp",
];
return imageMimetypes.includes(this.mimetype);
}
+1 -1
View File
@@ -152,7 +152,7 @@
<div class="oe_kanban_global_area oe_kanban_global_click o_kanban_attachment">
<div class="o_kanban_image">
<div class="o_kanban_image_wrapper">
<t t-set="webimage" t-value="new RegExp('image.*(gif|jpeg|jpg|png)').test(record.mimetype.value)"/>
<t t-set="webimage" t-value="new RegExp('image.*(gif|jpeg|jpg|png|webp)').test(record.mimetype.value)"/>
<div t-if="record.type.raw_value == 'url'" class="o_url_image fa fa-link fa-3x text-muted" aria-label="Image is a link"/>
<img t-elif="webimage" t-attf-src="/web/image/#{record.id.raw_value}" width="100" height="100" alt="Document" class="o_attachment_image"/>
<div t-else="!webimage" class="o_image o_image_thumbnail" t-att-data-mimetype="record.mimetype.value"/>
+1 -1
View File
@@ -24,7 +24,7 @@
<t t-set="binaryPreviewable"
t-value="new RegExp('(image|video|application/pdf|text)').test(record.mimetype.value) &amp;&amp; record.type.raw_value === 'binary'"/>
<div t-attf-class="o_kanban_image_wrapper #{(webimage or binaryPreviewable) ? 'o_kanban_previewer' : ''}">
<t t-set="webimage" t-value="new RegExp('image.*(gif|jpeg|jpg|png)').test(record.mimetype.value)"/>
<t t-set="webimage" t-value="new RegExp('image.*(gif|jpeg|jpg|png|webp)').test(record.mimetype.value)"/>
<div t-if="record.type.raw_value == 'url'" class="o_url_image fa fa-link fa-3x text-muted" aria-label="Image is a link"/>
<img t-elif="webimage" t-attf-src="/web/image/#{record.ir_attachment_id.raw_value}" width="100" height="100" alt="Document" class="o_attachment_image"/>
<div t-else="" class="o_image o_image_thumbnail" t-att-data-mimetype="record.mimetype.value"/>
@@ -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
}, {
@@ -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') + "')");
}
});
@@ -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() {
+23 -1
View File
@@ -542,7 +542,7 @@ class Web_Editor(http.Controller):
return files_data_by_bundle
@http.route('/web_editor/modify_image/<model("ir.attachment"):attachment>', 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.
@@ -12,6 +12,7 @@ SUPPORTED_IMAGE_MIMETYPES = {
'image/jpg': '.jpg',
'image/png': '.png',
'image/svg+xml': '.svg',
'image/webp': '.webp',
}
@@ -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() {
@@ -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);
}
@@ -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
@@ -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) {
@@ -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(`<we-button data-select-width="${value}">${label}</we-button>`);
const $select = $(uiFragment).find('we-select[data-name=format_select_opt]');
(await this._computeAvailableFormats()).forEach(([value, [label, targetFormat]]) => {
$select.append(`<we-button data-select-format="${Math.round(value)} ${targetFormat}" class="o_we_badge_at_end">${label} <span class="badge rounded-pill text-bg-dark">${targetFormat.split('/')[1]}</span></we-button>`);
});
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';
},
});
@@ -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;
},
/**
@@ -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();
@@ -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
+3 -3
View File
@@ -118,10 +118,10 @@
data-max="2000"
data-step="100"/>
<t t-set="width_label">Width</t>
<we-select t-att-string="width_label"
<t t-set="format_label">Format</t>
<we-select t-att-string="format_label"
t-att-class="indent and 'o_we_sublevel_2'"
data-name="width_select_opt"/>
data-name="format_select_opt"/>
<t t-set="quality_label">Quality</t>
<we-range t-att-string="quality_label"
@@ -794,6 +794,7 @@ class IrActionsReport(models.Model):
if (tools.config['test_enable'] or tools.config['test_file']) and not self.env.context.get('force_report_rendering'):
return self._render_qweb_html(report_ref, res_ids, data=data)
self = self.with_context(webp_as_jpg=True)
collected_streams = self._render_qweb_pdf_prepare_streams(report_ref, data, res_ids=res_ids)
# access the report details with sudo() but keep evaluation context as current user
+26
View File
@@ -1,6 +1,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import binascii
import contextlib
import hashlib
import io
@@ -664,6 +665,31 @@ class IrAttachment(models.Model):
tokens.append(access_token)
return tokens
@api.model
def create_unique(self, values_list):
ids = []
for values in values_list:
# Create only if record does not already exist for checksum and size.
try:
bin_data = base64.b64decode(values.get('datas', '')) or False
except binascii.Error:
raise UserError(_("Attachment is not encoded in base64."))
checksum = self._compute_checksum(bin_data)
existing_domain = [
['id', '!=', False], # No implicit condition on res_field.
['checksum', '=', checksum],
['file_size', '=', len(bin_data)],
['mimetype', '=', values['mimetype']],
]
existing = self.sudo().search(existing_domain)
if existing:
for attachment in existing:
ids.append(attachment.id)
else:
attachment = self.create(values)
ids.append(attachment.id)
return ids
def _generate_access_token(self):
return str(uuid.uuid4())
+27 -3
View File
@@ -359,6 +359,7 @@ structure.
"""
import base64
import fnmatch
import io
import logging
@@ -384,7 +385,7 @@ from odoo.tools.constants import SUPPORTED_DEBUGGER, EXTERNAL_ASSET
from odoo.tools.safe_eval import assert_valid_codeobj, _BUILTINS, to_opcodes, _EXPR_OPCODES, _BLACKLIST
from odoo.tools.json import scriptsafe
from odoo.tools.misc import str2bool
from odoo.tools.image import image_data_uri
from odoo.tools.image import image_data_uri, FILETYPE_BASE64_MAGICWORD
from odoo.http import request
from odoo.tools.profiler import QwebTracker
from odoo.exceptions import UserError, AccessDenied, AccessError, MissingError, ValidationError
@@ -861,6 +862,30 @@ class IrQWeb(models.AbstractModel):
# values for running time
def _get_converted_image_data_uri(self, base64_source):
if self.env.context.get('webp_as_jpg'):
mimetype = FILETYPE_BASE64_MAGICWORD.get(base64_source[:1], 'png')
if 'webp' in mimetype:
# Use converted image so that is recognized by wkhtmltopdf.
bin_source = base64.b64decode(base64_source)
Attachment = self.env['ir.attachment']
checksum = Attachment._compute_checksum(bin_source)
origins = Attachment.sudo().search([
['id', '!=', False], # No implicit condition on res_field.
['checksum', '=', checksum],
])
if origins:
converted_domain = [
['id', '!=', False], # No implicit condition on res_field.
['res_model', '=', 'ir.attachment'],
['res_id', 'in', origins.ids],
['mimetype', '=', 'image/jpeg'],
]
converted = Attachment.sudo().search(converted_domain, limit=1)
if converted:
base64_source = converted.datas
return image_data_uri(base64_source)
def _prepare_environment(self, values):
""" Prepare the values and context that will sent to the
compiled and evaluated function.
@@ -878,7 +903,6 @@ class IrQWeb(models.AbstractModel):
values.setdefault('debug', debug)
values.setdefault('user_id', self.env.user.with_env(self.env))
values.setdefault('res_company', self.env.company.sudo())
values.update(
request=request, # might be unbound if we're not in an httprequest context
test_mode_enabled=bool(config['test_enable'] or config['test_file']),
@@ -887,7 +911,7 @@ class IrQWeb(models.AbstractModel):
time=safe_eval.time,
datetime=safe_eval.datetime,
relativedelta=relativedelta,
image_data_uri=image_data_uri,
image_data_uri=self._get_converted_image_data_uri,
# specific 'math' functions to ease rounding in templates and lessen controller marshmalling
floor=math.floor,
ceil=math.ceil,
+15
View File
@@ -272,6 +272,21 @@ class TestImage(TransactionCase):
image = img_open(tools.image_process(image_1080_1920_tiff, quality=95))
self.assertEqual(image.format, 'JPEG', "unsupported format to JPEG")
def test_17_get_webp_size(self):
# Using 32 bytes image headers as data.
# Lossy webp: 550x368
webp_lossy = b'RIFFhv\x00\x00WEBPVP8 \\v\x00\x00\xd2\xbe\x01\x9d\x01*&\x02p\x01>\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'))
+10
View File
@@ -25,6 +25,11 @@ SVG = b"""PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iaXNvLTg4NTktMSI/PjwhRE9DVFlQRS
NAMESPACED_SVG = b"""<svg:svg xmlns:svg="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<svg:rect x="10" y="10" width="80" height="80" fill="green" />
</svg:svg>"""
# 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')
+32 -7
View File
@@ -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.
+1
View File
@@ -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')
+56 -4
View File
@@ -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
+7
View File
@@ -110,6 +110,10 @@ def _check_svg(data):
if b'<svg' in data and b'/svg' in data:
return 'image/svg+xml'
def _check_webp(data):
"""This checks the presence of the WEBP and VP8 in the RIFF"""
if data[8:15] == b'WEBPVP8':
return 'image/webp'
# for "master" formats with many subformats, discriminants is a list of
# functions, tried in order and the first non-falsy value returned is the
@@ -128,6 +132,9 @@ _mime_mappings = (
_check_svg,
]),
_Entry('image/x-icon', [b'\x00\x00\x01\x00'], []),
_Entry('image/webp', [b'RIFF'], [
_check_webp,
]),
# 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