[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:
@@ -62,6 +62,7 @@ export class Attachment {
|
||||
"image/svg+xml",
|
||||
"image/tiff",
|
||||
"image/x-icon",
|
||||
"image/webp",
|
||||
];
|
||||
return imageMimetypes.includes(this.mimetype);
|
||||
}
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<t t-set="binaryPreviewable"
|
||||
t-value="new RegExp('(image|video|application/pdf|text)').test(record.mimetype.value) && 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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user