diff --git a/addons/web/static/src/core/image_processing_service.js b/addons/web/static/src/core/image_processing_service.js new file mode 100644 index 0000000000000..de3867258a08d --- /dev/null +++ b/addons/web/static/src/core/image_processing_service.js @@ -0,0 +1,220 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { canExportCanvasAsWebp, convertCanvasToDataURL } from "@web/core/utils/image_processing"; + +export const IMAGE_TYPE = { + WEBP: { + mimetype: "image/webp", + extension: "webp", + }, + JPEG: { + mimetype: "image/jpeg", + extension: "jpeg", + }, + PNG: { + mimetype: "image/png", + extension: "png", + }, +}; + +export const STANDARD_ALTERNATIVE_TYPES = [IMAGE_TYPE.WEBP, IMAGE_TYPE.JPEG]; +export const STANDARD_ALTERNATIVE_SIZES = [1024, 512, 256, 128]; + +/** + * Looks up the extension for the given mimetype from {@link IMAGE_TYPE} + * + * @returns {string} + */ +export function getImageExtensionForMimetype(mimetype) { + return Object.values(IMAGE_TYPE).find((type) => type.mimetype === mimetype).extension; +} + +export class ImageProcessingService { + static dependencies = ["orm"]; + + constructor() { + this.setup(...arguments); + } + + setup(env, { orm }) { + this.orm = orm; + } + + /** + * Generate alternative sizes for a given image, image sizes and types {@link IMAGE_TYPE}. + * Only available types are generated. + * Optionally save the generated images as an attachment tree (Original image > First image of size > Other types) + * + * @param {string} src + * @param {number} quality See {@link HTMLCanvasElement.toDataURL} + * @param {boolean} [doSave] Whether to save + * @param {string} [fileBasename] File basename for saved attachments + * @param {number[]} [requestedSizes] {@link IMAGE_TYPE} + * @param {{ mimetype: string, extension: string }[]} [imageTypes] {@link IMAGE_TYPE} + * @return {Promise<{ + * originalImage: {} | undefined, + * alternativeImagesBySize: Record + * }>} originalImage is only provided when `doSave === true`. + */ + async generateImageAlternatives( + src, + quality, + doSave = false, + fileBasename = undefined, + requestedSizes = STANDARD_ALTERNATIVE_SIZES, + imageTypes = STANDARD_ALTERNATIVE_TYPES + ) { + const imageElement = await this._makeImageElementForProcessing(src); + const { originalSize, alternativeSizes } = this._getImageElementAlternativeSizes( + imageElement, + requestedSizes + ); + + const alternativeImagesBySize = {}; + for (const size of [originalSize, ...alternativeSizes]) { + const availableImageTypes = imageTypes.filter( + (imageType) => imageType.mimetype !== "image/webp" || canExportCanvasAsWebp() + ); + const canvas = this._makeCanvasForImage(imageElement, size, originalSize); + alternativeImagesBySize[size] = this._getAlternativeImagesFromCanvas( + canvas, + quality, + availableImageTypes + ); + } + + let originalImage = undefined; + + if (doSave) { + originalImage = await this._saveAlternativeImageSizesAttachments( + alternativeImagesBySize, + originalSize, + fileBasename + ); + } + + return { + originalImage: originalImage, + alternativeImagesBySize: alternativeImagesBySize, + }; + } + + /** + * Saves images as attachment tree. + * + * @param alternativeImagesBySize {Record} Lists of images by size + * @param originalSize {number} + * @param fileBasename {string} + * @returns {Promise<{ id: number, dataURL: string, mimetype: string }>} The original image's data + */ + async _saveAlternativeImageSizesAttachments( + alternativeImagesBySize, + originalSize, + fileBasename + ) { + let originalId = undefined; + let originalImage = undefined; + for (const size in alternativeImagesBySize) { + const images = alternativeImagesBySize[size]; + const isOriginalSize = size === originalSize.toString(); + + let sizeRootId = undefined; + + for (const image of images) { + const extension = getImageExtensionForMimetype(image.mimetype); + + let description; + if (sizeRootId === undefined) { + description = isOriginalSize ? "" : `resize: ${size}`; + } else { + description = `format: ${extension}`; + } + + const attachmentData = { + res_model: "ir.attachment", + name: `${fileBasename}.${extension}`, + description: description, + datas: image.dataURL.split(",")[1], + res_id: sizeRootId || originalId, + mimetype: image.mimetype, + }; + const [attachmentId] = await this.orm.call("ir.attachment", "create_unique", [ + [attachmentData], + ]); + + sizeRootId = sizeRootId || attachmentId; + originalId = originalId || attachmentId; + originalImage = originalImage || image; + } + } + + return { + id: originalId, + ...originalImage, + }; + } + + async _makeImageElementForProcessing(src) { + const imageElement = document.createElement("img"); + imageElement.src = src; + await new Promise((resolve) => imageElement.addEventListener("load", resolve)); + return imageElement; + } + + _getImageElementAlternativeSizes(imageElement, requestedSizes) { + const originalSize = Math.max(imageElement.width, imageElement.height); + const smallerSizes = requestedSizes.filter((size) => size < originalSize); + return { + originalSize: originalSize, + alternativeSizes: smallerSizes, + }; + } + + _makeCanvasForImage(imageElement, size, originalSize) { + const ratio = size / originalSize; + const canvas = document.createElement("canvas"); + canvas.width = imageElement.width * ratio; + canvas.height = imageElement.height * ratio; + const ctx = canvas.getContext("2d"); + ctx.fillStyle = "rgb(255, 255, 255)"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.drawImage( + imageElement, + 0, + 0, + imageElement.width, + imageElement.height, + 0, + 0, + canvas.width, + canvas.height + ); + return canvas; + } + + _getAlternativeImagesFromCanvas(canvas, quality, imageTypes) { + const imageDatas = []; + for (const imageType of imageTypes) { + const { dataURL, mimetype } = convertCanvasToDataURL( + canvas, + imageType.mimetype, + quality + ); + imageDatas.push({ + dataURL: dataURL, + mimetype: mimetype, + }); + } + return imageDatas; + } +} + +export const imageProcessingService = { + dependencies: ImageProcessingService.dependencies, + start() { + return new ImageProcessingService(...arguments); + }, +}; + +registry.category("services").add("image_processing", imageProcessingService); diff --git a/addons/web/static/src/core/utils/image_processing.js b/addons/web/static/src/core/utils/image_processing.js new file mode 100644 index 0000000000000..5092186f848bb --- /dev/null +++ b/addons/web/static/src/core/utils/image_processing.js @@ -0,0 +1,36 @@ +/** @odoo-module **/ + +import { memoize } from "@web/core/utils/functions"; + +/** + * Use this function to handle implicit conversion on canvas.toDataURL due to browser incompatibility + * See {@link https://caniuse.com/mdn-api_htmlcanvaselement_todataurl_type_parameter_webp} + * + * @param {HTMLCanvasElement} canvas + * @param {string} mimetype See {@link HTMLCanvasElement.toDataURL} + * @param {number} quality See {@link HTMLCanvasElement.toDataURL} + * @returns {{dataURL: string, mimetype: string}} The resulting data url from {@link HTMLCanvasElement.toDataURL} and + * the actual output mimetype + */ +export function convertCanvasToDataURL(canvas, mimetype, quality) { + const dataURL = canvas.toDataURL(mimetype, quality); + const actualMimetype = dataURL.split(":")[1].split(";")[0]; + return { + dataURL, + mimetype: actualMimetype, + }; +} + +/** + * Checks whether the browser can export a webp image from a canvas using {@link HTMLCanvasElement.toDataURL} + * + * @type {() => boolean} + */ +export const canExportCanvasAsWebp = memoize(() => { + const dummyCanvas = document.createElement("canvas"); + dummyCanvas.width = 1; + dummyCanvas.height = 1; + const data = dummyCanvas.toDataURL("image/webp"); + dummyCanvas.remove(); + return data.split(":")[1].split(";")[0] === "image/webp"; +}); 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 a027048f310b1..477379daf3278 100644 --- a/addons/web/static/src/views/fields/image/image_field.js +++ b/addons/web/static/src/views/fields/image/image_field.js @@ -57,6 +57,7 @@ export class ImageField extends Component { }; setup() { + this.imageProcessing = useService("image_processing"); this.notification = useService("notification"); this.orm = useService("orm"); this.isMobile = isMobileOS(); @@ -130,60 +131,12 @@ export class ImageField extends Component { this.state.isValid = true; 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", - }, - ], - ]); - } + await this.imageProcessing.generateImageAlternatives( + `data:image/webp;base64,${info.data}`, + 0.75, + true, + info.name.split(".").splice(-1).join() + ); } this.props.record.update({ [this.props.name]: info.data }); } diff --git a/addons/web/static/tests/views/fields/image_field_tests.js b/addons/web/static/tests/views/fields/image_field_tests.js index b5aeb83326c2f..60313e203307b 100644 --- a/addons/web/static/tests/views/fields/image_field_tests.js +++ b/addons/web/static/tests/views/fields/image_field_tests.js @@ -15,6 +15,7 @@ const MY_IMAGE = "iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=="; const PRODUCT_IMAGE = "R0lGODlhDAAMAKIFAF5LAP/zxAAAANyuAP/gaP///wAAAAAAACH5BAEAAAUALAAAAAAMAAwAAAMlWLPcGjDKFYi9lxKBOaGcF35DhWHamZUW0K4mAbiwWtuf0uxFAgA7"; +const DUMMY_WEBP = "UklGRiwAAABXRUJQVlA4TB8AAAAv/8F/EAcQEREQCCT7e89QRP8z/vOf//znP//5z/8BAA=="; let serverData; let target; @@ -73,6 +74,17 @@ QUnit.module("Fields", (hooks) => { { id: 14, display_name: "silver", color: 5 }, ], }, + "ir.attachment": { + fields: { + mimetype: { string: "File mimetype" }, + datas: { string: "File data" }, + }, + methods: { + create_unique() { + return [0]; + }, + }, + }, }, }; @@ -860,4 +872,90 @@ QUnit.module("Fields", (hooks) => { ); } ); + + QUnit.test( + "ImageField sends the correct mimetype on webp to png implicit conversion", + async (assert) => { + let isMocked = false; + const imageData = Uint8Array.from([...atob(DUMMY_WEBP)].map((c) => c.charCodeAt(0))); + await makeView({ + type: "form", + resModel: "partner", + resId: 1, + serverData, + arch: `
+ + `, + mockRPC(route, { model, method, args }) { + if (model === "ir.attachment" && method === "create_unique") { + const attachment = args[0][0]; + if ( + attachment.mimetype !== "image/jpeg" && + attachment.description.startsWith("resize:") + ) { + assert.step("upload"); + const expectedMimetype = isMocked ? "image/png" : "image/webp"; + assert.strictEqual( + attachment.mimetype, + expectedMimetype, + "the uploaded attachment should have the right mimetype" + ); + return Promise.resolve([0]); + } + } + }, + }); + + assert.strictEqual( + target.querySelector('div[name="document"] img').dataset.src, + "data:image/png;base64,coucou==", + "the image should have the initial src" + ); + + async function uploadWebp() { + // Whitebox: replace the event target before the event is handled by the field so that we can modify + // the files that it will take into account. This relies on the fact that it reads the files from + // event.target and not from a direct reference to the input element. + const fileInput = target.querySelector("input[type=file]"); + const fakeInput = { + files: [new File([imageData], "fake_file.webp", { type: "image/webp" })], + }; + fileInput.addEventListener( + "change", + (ev) => { + Object.defineProperty(ev, "target", { + value: fakeInput, + configurable: true, + }); + }, + { capture: true } + ); + + fileInput.dispatchEvent(new Event("change")); + // It can take some time to encode the data as a base64 url + await new Promise((resolve) => setTimeout(resolve, 50)); + // Wait for a render + await nextTick(); + assert.verifySteps( + ["upload", "upload"], + "two modified images should have been uploaded" + ); + } + + await uploadWebp(); + + // Mock + const _original = HTMLCanvasElement.prototype.toDataURL; + HTMLCanvasElement.prototype.toDataURL = function (type, quality) { + return _original.call(this, type === "image/webp" ? "image/png" : type, quality); + }; + isMocked = true; + + await uploadWebp(); + + // Unmock + HTMLCanvasElement.prototype.toDataURL = _original; + isMocked = false; + } + ); }); diff --git a/addons/web/static/tests/views/helpers.js b/addons/web/static/tests/views/helpers.js index c182a0763b6cf..02ef84b5bdf77 100644 --- a/addons/web/static/tests/views/helpers.js +++ b/addons/web/static/tests/views/helpers.js @@ -4,6 +4,7 @@ import { makeTestEnv } from "@web/../tests/helpers/mock_env"; import { getFixture, mount, nextTick } from "@web/../tests/helpers/utils"; import { createDebugContext } from "@web/core/debug/debug_context"; import { Dialog } from "@web/core/dialog/dialog"; +import { imageProcessingService } from "@web/core/image_processing_service"; import { MainComponentsContainer } from "@web/core/main_components_container"; import { registry } from "@web/core/registry"; import { View, getDefaultConfig } from "@web/views/view"; @@ -124,4 +125,5 @@ export function setupViewRegistries() { serviceRegistry.add("router", makeFakeRouterService(), { force: true }); serviceRegistry.add("localization", makeFakeLocalizationService()); serviceRegistry.add("company", fakeCompanyService); + serviceRegistry.add("image_processing", imageProcessingService); } 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 a81d3db3eacc5..050c1ffb7e17e 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 @@ -1,5 +1,6 @@ /** @odoo-module **/ +import { IMAGE_TYPE } from "@web/core/image_processing_service"; import { registry } from '@web/core/registry'; import { UploadProgressToast } from './upload_progress_toast'; import { _t } from "@web/core/l10n/translation"; @@ -12,8 +13,8 @@ import { reactive } from "@odoo/owl"; export const AUTOCLOSE_DELAY = 3000; export const uploadService = { - dependencies: ['rpc', 'notification'], - start(env, { rpc, notification }) { + dependencies: ["image_processing", "rpc", "notification"], + start(env, { image_processing, rpc, notification }) { let fileId = 0; const progressToast = reactive({ files: {}, @@ -135,20 +136,20 @@ export const uploadService = { } 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); + const { alternativeImagesBySize } = await image_processing.generateImageAlternatives( + `data:image/webp;base64,${dataURL.split(",")[1]}`, + 0.75, + false, + undefined, + [], + [IMAGE_TYPE.JPEG] + ); + + const alternativeImage = Object.values(alternativeImagesBySize)[0][0]; + await rpc('/web_editor/attachment/add_data', { 'name': file.name.replace(/\.webp$/, '.jpg'), - 'data': altDataURL.split(',')[1], + 'data': alternativeImage.dataURL.split(",")[1], 'res_id': attachment.id, 'res_model': 'ir.attachment', 'is_image': true, 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 ebff1ecad2df0..256b06b7cb6bb 100644 --- a/addons/web_editor/static/src/js/editor/image_processing.js +++ b/addons/web_editor/static/src/js/editor/image_processing.js @@ -1,5 +1,6 @@ /** @odoo-module **/ +import { convertCanvasToDataURL } from "@web/core/utils/image_processing"; import { pick } from "@web/core/utils/objects"; import {getAffineApproximation, getProjective} from "@web_editor/js/editor/perspective_utils"; @@ -200,9 +201,12 @@ const glFilters = { * containing the result. This function does not modify the original image. * * @param {HTMLImageElement} img the image to which modifications are applied - * @returns {string} dataURL of the image with the applied modifications + * @param {boolean} returnResultWithMimetype TODO remove in master - false by default to not break compatibility, *must* + * be set to true. + * @returns {Promise<{dataURL: string, mimetype: string}>} dataURL of the image with the applied modifications and the + * actual output mimetype. */ -export async function applyModifications(img, dataOptions = {}) { +export async function applyModifications(img, dataOptions = {}, returnResultWithMimetype = false) { const data = Object.assign({ glFilter: '', filter: '#0000', @@ -338,13 +342,29 @@ export async function applyModifications(img, dataOptions = {}) { ctx.fillRect(0, 0, result.width, result.height); // Quality - const dataURL = result.toDataURL(mimetype, quality / 100); + const { dataURL, mimetype: outputMimetype } = convertCanvasToDataURL(result, mimetype, quality / 100); const newSize = getDataURLBinarySize(dataURL); const originalSize = _getImageSizeFromCache(originalSrc); const isChanged = !!perspective || !!glFilter || original.width !== result.width || original.height !== result.height || original.width !== croppedImg.width || original.height !== croppedImg.height; - return (isChanged || originalSize >= newSize) ? dataURL : await _loadImageDataURL(originalSrc); + + // TODO: remove in master, see jsdoc for returnResultWithMimetype parameter + if (!returnResultWithMimetype) { + return isChanged || originalSize >= newSize ? dataURL : await _loadImageDataURL(originalSrc); + } + + if (isChanged || originalSize >= newSize) { + return { + dataURL: dataURL, + mimetype: outputMimetype, + }; + } else { + return { + dataURL: await _loadImageDataURL(originalSrc), + mimetype: mimetype, + }; + } } /** 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 7ea3ce13f00c2..1bbc83049b750 100644 --- a/addons/web_editor/static/src/js/editor/snippets.options.js +++ b/addons/web_editor/static/src/js/editor/snippets.options.js @@ -32,6 +32,7 @@ import { getDataURLBinarySize, } from "@web_editor/js/editor/image_processing"; import * as OdooEditorLib from "@web_editor/js/editor/odoo-editor/src/OdooEditor"; +import { canExportCanvasAsWebp } from "@web/core/utils/image_processing"; import { pick } from "@web/core/utils/objects"; import { _t } from "@web/core/l10n/translation"; import { @@ -6340,23 +6341,23 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ const maxWidth = img.dataset.width ? img.naturalWidth : original.naturalWidth; const optimizedWidth = Math.min(maxWidth, this._computeMaxDisplayWidth()); this.optimizedWidth = optimizedWidth; - const widths = { - 128: ['128px', 'image/webp'], - 256: ['256px', 'image/webp'], - 512: ['512px', 'image/webp'], - 1024: ['1024px', 'image/webp'], - 1920: ['1920px', 'image/webp'], + const optimizedMimetype = canExportCanvasAsWebp() ? "image/webp" : "image/jpeg"; + const formatsByWidth = { + 128: ["128px", optimizedMimetype], + 256: ["256px", optimizedMimetype], + 512: ["512px", optimizedMimetype], + 1024: ["1024px", optimizedMimetype], + 1920: ["1920px", optimizedMimetype], }; - widths[img.naturalWidth] = [_t("%spx", img.naturalWidth), 'image/webp']; - widths[optimizedWidth] = [_t("%spx (Suggested)", optimizedWidth), 'image/webp']; + formatsByWidth[img.naturalWidth] = [_t("%spx", img.naturalWidth), optimizedMimetype]; + formatsByWidth[optimizedWidth] = [_t("%spx (Suggested)", optimizedWidth), optimizedMimetype]; const mimetypeBeforeConversion = img.dataset.mimetypeBeforeConversion; - widths[maxWidth] = [_t("%spx (Original)", maxWidth), mimetypeBeforeConversion]; - if (mimetypeBeforeConversion !== "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] = [_t("%spx", maxWidth), 'image/webp']; + formatsByWidth[maxWidth] = [_t("%spx (Original)", maxWidth), mimetypeBeforeConversion]; + if (mimetypeBeforeConversion !== optimizedMimetype) { + // Avoid key collision and ensure the optimized format is above the original one of the same size. + formatsByWidth[maxWidth - 0.1] = [_t("%spx", maxWidth), optimizedMimetype]; } - return Object.entries(widths) + return Object.entries(formatsByWidth) .filter(([width]) => width <= maxWidth) .sort(([v1], [v2]) => v1 - v2); }, @@ -6384,10 +6385,15 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ delete img.dataset.mimetype; return; } - const dataURL = await applyModifications(img, {mimetype: this._getImageMimetype(img)}); + const { dataURL, mimetype } = await applyModifications( + img, + { mimetype: this._getImageMimetype(img) }, + true, // TODO: remove in master + ); this._filesize = getDataURLBinarySize(dataURL) / 1024; if (update) { + img.dataset.mimetype = mimetype; img.classList.add('o_modified_image_to_save'); const loadedImg = await loadImage(dataURL, img); this._applyImage(loadedImg); @@ -6722,14 +6728,19 @@ registry.ImageTools = ImageHandlerOption.extend({ } } else { // Re-applying the modifications and deleting the shapes - img.src = await applyModifications(img, {mimetype: this._getImageMimetype(img)}); + const { dataURL, mimetype } = await applyModifications( + img, + { mimetype: this._getImageMimetype(img) }, + true, // TODO: remove in master + ); + img.src = dataURL; delete img.dataset.shape; delete img.dataset.shapeColors; delete img.dataset.fileName; delete img.dataset.shapeFlip; delete img.dataset.shapeRotate; if (saveData) { - img.dataset.mimetype = img.dataset.originalMimetype; + img.dataset.mimetype = mimetype; delete img.dataset.originalMimetype; } // Also apply to carousel thumbnail if applicable. @@ -7092,7 +7103,7 @@ registry.ImageTools = ImageHandlerOption.extend({ imgAspectRatio: svg.dataset.imgAspectRatio || null, svgAspectRatio: svgAspectRatio, }; - const imgDataURL = await applyModifications(img, options); + const { dataURL: imgDataURL } = await applyModifications(img, options, true, /* TODO: remove in master */); svg.removeChild(svg.querySelector('#preview')); svg.querySelectorAll("image").forEach(image => { image.setAttribute("xlink:href", imgDataURL); diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/image_crop.js b/addons/web_editor/static/src/js/wysiwyg/widgets/image_crop.js index b15fa8951e463..265a9cba16a67 100644 --- a/addons/web_editor/static/src/js/wysiwyg/widgets/image_crop.js +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/image_crop.js @@ -199,7 +199,13 @@ export class ImageCrop extends Component { } }); delete this.media.dataset.resizeWidth; - this.initialSrc = await applyModifications(this.media, {forceModification: true, mimetype: this.mimetype}); + const { dataURL, mimetype } = await applyModifications( + this.media, + { forceModification: true, mimetype: this.mimetype }, + true, // TODO: remove in master + ); + this.initialSrc = dataURL; + this.media.mimetype = this.mimetype = mimetype; this.media.classList.toggle('o_we_image_cropped', cropped); this.$media.trigger('image_cropped'); this._closeCropper(); diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js index f97535c65871c..c4fb77875c34f 100644 --- a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js +++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js @@ -148,6 +148,7 @@ export class Wysiwyg extends Component { this.orm = useService('orm'); this.rpc = useService('rpc'); this.getColorPickerTemplateService = useService('get_color_picker_template'); + this.imageProcessing = useService("image_processing"); this.notification = useService("notification"); this.popover = useService("popover"); this.busService = this.env.services.bus_service; @@ -3496,6 +3497,7 @@ export class Wysiwyg extends Component { */ async _saveModifiedImage(el, resModel, resId) { const isBackground = !el.matches('img'); + const imageSource = isBackground ? el.dataset.bgSrc : el.getAttribute("src"); // 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. @@ -3504,25 +3506,12 @@ export class Wysiwyg extends Component { if (el.dataset.mimetype === 'image/webp' && isImageField) { // 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 { alternativeImagesBySize } = this.imageProcessing.generateImageAlternatives(imageSource, 0.75); + + for (const size in alternativeImagesBySize) { + const images = alternativeImagesBySize[size]; + for (const image of images) { + altData[size][image.mimetype] = image.dataURL.split(",")[1]; } } } @@ -3531,7 +3520,7 @@ export class Wysiwyg extends Component { { res_model: resModel, res_id: parseInt(resId), - data: (isBackground ? el.dataset.bgSrc : el.getAttribute('src')).split(',')[1], + data: imageSource.split(",")[1], alt_data: altData, mimetype: (isBackground ? el.dataset.mimetype : el.getAttribute('src').split(":")[1].split(";")[0]), name: (el.dataset.fileName ? el.dataset.fileName : null), diff --git a/addons/website/static/src/js/editor/snippets.options.js b/addons/website/static/src/js/editor/snippets.options.js index e107c9a6cccea..c982a6675fadc 100644 --- a/addons/website/static/src/js/editor/snippets.options.js +++ b/addons/website/static/src/js/editor/snippets.options.js @@ -4,6 +4,7 @@ import { loadCSS } from "@web/core/assets"; import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; import { Dialog } from "@web/core/dialog/dialog"; import { useChildRef } from "@web/core/utils/hooks"; +import { canExportCanvasAsWebp, convertCanvasToDataURL } from "@web/core/utils/image_processing"; import weUtils from "@web_editor/js/common/utils"; import options from "@web_editor/js/editor/snippets.options"; import { NavbarLinkPopoverWidget } from "@website/js/widgets/link_popover_widget"; @@ -2986,17 +2987,42 @@ options.registry.CoverProperties = options.Class.extend({ const imgEl = document.createElement("img"); imgEl.src = widgetValue; await loadImageInfo(imgEl, this.rpc); - if (imgEl.dataset.mimetype && ![ + const originalMimetype = imgEl.dataset.mimetype; + if (originalMimetype && ![ "image/gif", "image/svg+xml", "image/webp", - ].includes(imgEl.dataset.mimetype)) { + ].includes(originalMimetype) && canExportCanvasAsWebp()) { // Convert to webp but keep original width. - imgEl.dataset.mimetype = "image/webp"; - const base64src = await applyModifications(imgEl, { - mimetype: "image/webp", - }); - widgetValue = base64src; + const canvas = document.createElement("canvas"); + canvas.width = imgEl.width; + canvas.height = imgEl.height; + const ctx = canvas.getContext("2d"); + ctx.fillStyle = "rgb(255, 255, 255)"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.drawImage( + imgEl, + 0, + 0, + imgEl.width, + imgEl.height, + 0, + 0, + canvas.width, + canvas.height + ); + const { dataURL: convertedDataURL, mimetype: convertedMimetype } = convertCanvasToDataURL(canvas, "image/webp", 0.75); + imgEl.src = convertedDataURL; + imgEl.dataset.mimetype = convertedMimetype; + + // Needed to keep the smallest (file size) image + const { dataURL, mimetype } = await applyModifications( + imgEl, + { mimetype: imgEl.dataset.mimetype }, + true, // TODO: remove in master + ); + imgEl.dataset.mimetype = mimetype; + widgetValue = dataURL; this.$image[0].classList.add("o_b64_image_to_save"); } } diff --git a/addons/website/static/src/js/tours/tour_utils.js b/addons/website/static/src/js/tours/tour_utils.js index 7391b4eaf9a2c..65d34ba35adde 100644 --- a/addons/website/static/src/js/tours/tour_utils.js +++ b/addons/website/static/src/js/tours/tour_utils.js @@ -143,7 +143,7 @@ function changePaddingSize(direction) { /** * Checks if an element is visible on the screen, i.e., not masked by another * element. - * + * * @param {String} elementSelector The selector of the element to be checked. * @returns {Object} The steps required to check if the element is visible. */ @@ -493,6 +493,45 @@ function toggleMobilePreview(toggleOn) { }]; } +/** + * Waits for an HTMLImageElement to load. Skips waiting if already loaded. + * + * @param {string} selector The {@link HTMLImageElement} to wait for + * @return {Array} + */ +function waitForImageToLoad(selector) { + if (selector) { + return [ + { + content: "Wait for image to load", + trigger: selector, + async run() { + const img = this.$anchor[0]; + await new Promise((resolve) => { + if (img.complete) resolve(); + else img.addEventListener("load", resolve); + }); + }, + }, + ]; + } else { + return waitForNextRenderFrame(); + } +} + +function waitForNextRenderFrame() { + return [ + { + content: "Wait for next render frame", + trigger: "body", + async run() { + await new Promise((resolve) => window.requestAnimationFrame(resolve)); + await new Promise((resolve) => setTimeout(resolve)); + }, + }, + ]; +} + export default { addMedia, assertCssVariable, @@ -525,4 +564,6 @@ export default { selectSnippetColumn, switchWebsite, toggleMobilePreview, + waitForImageToLoad, + waitForNextRenderFrame, }; diff --git a/addons/website/static/src/snippets/s_image_gallery/options.js b/addons/website/static/src/snippets/s_image_gallery/options.js index 7136fe6212790..5af00d7be0327 100644 --- a/addons/website/static/src/snippets/s_image_gallery/options.js +++ b/addons/website/static/src/snippets/s_image_gallery/options.js @@ -1,5 +1,6 @@ /** @odoo-module **/ +import { canExportCanvasAsWebp, convertCanvasToDataURL } from "@web/core/utils/image_processing"; import { MediaDialog } from "@web_editor/components/media_dialog/media_dialog"; import options from "@web_editor/js/editor/snippets.options"; import wUtils from '@website/js/utils'; @@ -423,6 +424,9 @@ options.registry.gallery = options.registry.GalleryLayout.extend({ }, }); +/** + * TODO this should use web_editor/ImageTools somehow (_getImageMimetype) or centralized image processing service + */ options.registry.GalleryImageList = options.registry.GalleryLayout.extend({ /** * @override @@ -521,17 +525,42 @@ options.registry.GalleryImageList = options.registry.GalleryLayout.extend({ const imgEl = $img[0]; imagePromises.push(new Promise(resolve => { loadImageInfo(imgEl, this.rpc).then(() => { - if (imgEl.dataset.mimetype && ![ + const originalMimetype = imgEl.dataset.mimetype; + if (originalMimetype && ![ "image/gif", "image/svg+xml", "image/webp", - ].includes(imgEl.dataset.mimetype)) { + ].includes(originalMimetype) && canExportCanvasAsWebp()) { // Convert to webp but keep original width. - imgEl.dataset.mimetype = "image/webp"; - applyModifications(imgEl, { - mimetype: "image/webp", - }).then(src => { - imgEl.src = src; + const canvas = document.createElement("canvas"); + canvas.width = imgEl.width; + canvas.height = imgEl.height; + const ctx = canvas.getContext("2d"); + ctx.fillStyle = "rgb(255, 255, 255)"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.drawImage( + imgEl, + 0, + 0, + imgEl.width, + imgEl.height, + 0, + 0, + canvas.width, + canvas.height + ); + const { dataURL: convertedDataURL, mimetype: convertedMimetype } = convertCanvasToDataURL(canvas, "image/webp", 0.75); + imgEl.src = convertedDataURL; + imgEl.dataset.mimetype = convertedMimetype; + + // Needed to keep the smallest (file size) image + applyModifications( + imgEl, + { mimetype: this._getImageMimetype(imgEl) }, + true, // TODO: remove in master + ).then(({ dataURL, mimetype }) => { + imgEl.dataset.mimetype = mimetype; + imgEl.src = dataURL; imgEl.classList.add("o_modified_image_to_save"); resolve(); }); @@ -616,6 +645,9 @@ options.registry.GalleryImageList = options.registry.GalleryLayout.extend({ } }); }, + _getImageMimetype(img) { + return img.dataset.shape && img.dataset.originalMimetype ? img.dataset.originalMimetype : img.dataset.mimetype; + }, }); options.registry.gallery_img = options.Class.extend({ diff --git a/addons/website/static/tests/tours/snippet_image_mimetype.js b/addons/website/static/tests/tours/snippet_image_mimetype.js new file mode 100644 index 0000000000000..8b8620f5c7a00 --- /dev/null +++ b/addons/website/static/tests/tours/snippet_image_mimetype.js @@ -0,0 +1,295 @@ +/** @odoo-module */ + +import wTourUtils from '@website/js/tours/tour_utils'; + +export const mockCanvasToDataURLStep = { + content: "Setup mock HTMLCanvasElement.toDataURL", + trigger: "body", + run() { + const _super = HTMLCanvasElement.prototype.toDataURL; + HTMLCanvasElement.prototype.toDataURL = function (type, quality) { + return _super.call(this, type === "image/webp" ? "image/png" : type, quality); + }; + }, +}; + +export function uploadImageFromDialog( + mimetype, + filename, + data, + confirmChoice = true, + targetSelector = ".o_we_existing_attachments .o_we_attachment_selected img", +) { + return [ + { + content: "Upload an image", + trigger: '.o_upload_media_button', + async run() { + const imageData = Uint8Array.from([...atob(data)].map((c) => c.charCodeAt(0))); + const fileInput = document.querySelector('.o_select_media_dialog input[type="file"]'); + let file = new File([imageData], filename, { type: mimetype }); + let transfer = new DataTransfer(); + transfer.items.add(file); + fileInput.files = transfer.files; + fileInput.dispatchEvent(new Event("change")); + }, + }, + ...wTourUtils.waitForImageToLoad(targetSelector), + ...(confirmChoice ? [{ + content: "Confirm choice", + trigger: '.o_select_media_dialog footer button:contains("Add")', + extraTrigger: ".o_we_existing_attachments .o_we_attachment_selected", + }] : []), + ]; +} + +export const PNG_THAT_CONVERTS_TO_BIGGER_WEBP = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQIW2NgAAIAAAUAAR4f7BQAAAAASUVORK5CYII="; + +export function generateTestPng(size) { + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + return canvas.toDataURL("image/png"); +} + +const selectImage = { + content: "Select image", + trigger: "iframe .s_text_image img", +}; + +function setOriginalImageFormat(originalFormat = "image/jpeg") { + return setImageFormat(undefined, true, originalFormat); +} + +function setImageFormat(targetFormat, isOriginalFormat = false, originalFormat = "image/jpeg") { + let formatSelector, postCheck; + if (isOriginalFormat) { + formatSelector = "we-button:last-child"; + postCheck = { + content: "Wait for image update: back to original image", + trigger: `iframe .s_text_image img[src^="data:${originalFormat};base64,"]`, + isCheck: true, + }; + } else { + formatSelector = `we-button[data-select-format="${targetFormat}"]`; + postCheck = { + content: "Wait for image update: NOT original image", + trigger: 'iframe .s_text_image img:not([src$="s_text_image_default_image"])', + isCheck: true, + }; + } + + return [ + selectImage, + { + content: "Open format select", + trigger: 'we-customizeblock-options:has(we-title:contains("Image")) we-select[data-name="format_select_opt"]', + }, + { + content: "Select 128 image/webp", + trigger: `we-customizeblock-options:has(we-title:contains("Image")) we-select[data-name="format_select_opt"] ${formatSelector}`, + }, + postCheck, + ]; +} + +function setImageShape() { + return [ + selectImage, + { + content: "Open shape select", + trigger: 'we-customizeblock-options:has(we-title:contains("Image")) we-select[data-name="shape_img_opt"]', + }, + { + content: "Open shape select", + trigger: 'we-customizeblock-options:has(we-title:contains("Image")) we-select[data-name="shape_img_opt"] we-button[data-select-label="Diamond"]', + }, + { + content: "Wait for image update: svg wrap", + trigger: 'iframe .s_text_image img[src^="data:image/svg+xml;base64,"]', + isCheck: true, + } + ]; +} + +function removeImageShape(targetMimetype) { + return [ + selectImage, + { + content: "Remove image shape", + trigger: 'we-customizeblock-options:has(we-title:contains("Image")) we-button[data-set-img-shape=""]' + }, + { + content: `Wait for image update: mimetype ${targetMimetype}`, + trigger: `iframe .s_text_image img[src^="data:${targetMimetype};base64,"]`, + isCheck: true, + } + ]; +} + +function cropImage(targetMimetype) { + return [ + selectImage, + { + content: "Open crop widget", + trigger: 'we-customizeblock-options:has(we-title:contains("Image")) we-button[data-crop="true"]', + }, + { + content: "Choose 1/1 crop ratio", + trigger: '[data-action="ratio"][data-value="1/1"]', + }, + { + content: "Apply", + trigger: '[data-action="apply"]', + }, + { + content: `Wait for image update: mimetype ${targetMimetype}`, + trigger: `iframe .s_text_image img[src^="data:${targetMimetype};base64,"]`, + isCheck: true, + } + ]; +} + +function testImageMimetypeIs(targetMimetype, originalMimetype) { + return [ + { + content: `Check image mimetype before save is ${targetMimetype}`, + trigger: "iframe .o_modified_image_to_save", + run() { + const actualMimetype = this.$anchor[0].dataset.mimetype; + const expectedMimetype = targetMimetype; + if (actualMimetype !== expectedMimetype) { + console.error(`Wrong image mimetype: ${actualMimetype} - Expected: ${expectedMimetype}`); + } + }, + }, + ...wTourUtils.clickOnSave(), + { + content: `Check image mimetype after save is ${targetMimetype}`, + trigger: `iframe img[data-mimetype-before-conversion="${originalMimetype}"]`, + run() { + const actualMimetype = this.$anchor[0].dataset.mimetype; + const expectedMimetype = targetMimetype; + if (actualMimetype !== expectedMimetype) { + console.error(`Wrong image mimetype: ${actualMimetype} - Expected: ${expectedMimetype}`); + } + }, + }, + ...wTourUtils.clickOnEditAndWaitEditMode(), + ]; +} + +function testImageSnippet(imageFormat, originalMimetype, targetMimetype, firstTargetMimetype = targetMimetype) { + return [ + ...setImageFormat(imageFormat), + ...testImageMimetypeIs(firstTargetMimetype, originalMimetype), + + ...setOriginalImageFormat(), + ...testImageMimetypeIs(originalMimetype, originalMimetype), + + ...setImageFormat(imageFormat), + + ...setImageShape(), + ...testImageMimetypeIs("image/svg+xml", originalMimetype), + + ...removeImageShape(targetMimetype), + ...testImageMimetypeIs(targetMimetype, originalMimetype), + + ...cropImage(targetMimetype), + ...testImageMimetypeIs(targetMimetype, originalMimetype), + ]; +} + +wTourUtils.registerWebsitePreviewTour("website_image_mimetype", { + test: true, + url: "/", + edition: true, +}, () => [ + wTourUtils.dragNDrop({ + id: "s_text_image", + name: "Text - Image", + }), + ...testImageSnippet("128 image/webp", "image/jpeg", "image/webp"), +]); + +wTourUtils.registerWebsitePreviewTour("website_image_mimetype_no_webp", { + test: true, + url: "/", + edition: true, +}, () => [ + mockCanvasToDataURLStep, + wTourUtils.dragNDrop({ + id: "s_text_image", + name: "Text - Image", + }), + ...testImageSnippet("128 image/jpeg", "image/jpeg", "image/jpeg"), +]); + +wTourUtils.registerWebsitePreviewTour("website_image_mimetype_bigger_output", { + test: true, + url: "/", + edition: true, +}, () => [ + wTourUtils.dragNDrop({ + id: "s_text_image", + name: "Text - Image", + }), + { + ...selectImage, + run: "dblclick", + }, + ...uploadImageFromDialog("image/png", "o.png", PNG_THAT_CONVERTS_TO_BIGGER_WEBP, false, selectImage.trigger), + ...testImageSnippet("1 image/webp", "image/png", "image/png", "image/webp"), +]); + +function testImageGallerySnippet(imageData, targetMimetype) { + return [ + wTourUtils.dragNDrop({ + id: "s_image_gallery", + name: "Image Gallery", + }), + { + content: "Open snippet options", + trigger: "iframe .s_image_gallery img", + }, + { + content: "Click on Images - Add button", + trigger: 'we-button[data-add-images="true"]', + }, + ...uploadImageFromDialog( + "image/png", + "o.png", + imageData, + ), + { + content: "Navigate to last carousel image", + trigger: 'iframe [data-bs-slide-to="3"]', + }, + ...testImageMimetypeIs(targetMimetype, "image/png"), + ]; +} + +wTourUtils.registerWebsitePreviewTour("website_image_mimetype_image_gallery", { + test: true, + url: "/", + edition: true, +}, () => [ + ...testImageGallerySnippet(generateTestPng(1024).split(",")[1], "image/webp"), +]); + +wTourUtils.registerWebsitePreviewTour("website_image_mimetype_image_gallery_no_webp", { + test: true, + url: "/", + edition: true, +}, () => [ + mockCanvasToDataURLStep, + ...testImageGallerySnippet(generateTestPng(1024).split(",")[1], "image/png"), +]); + +wTourUtils.registerWebsitePreviewTour("website_image_mimetype_image_gallery_bigger_output", { + test: true, + url: "/", + edition: true, +}, () => [ + ...testImageGallerySnippet(PNG_THAT_CONVERTS_TO_BIGGER_WEBP, "image/png"), +]); diff --git a/addons/website/tests/test_snippets.py b/addons/website/tests/test_snippets.py index 0ccdcb28a04d9..dff027fb8ab37 100644 --- a/addons/website/tests/test_snippets.py +++ b/addons/website/tests/test_snippets.py @@ -129,3 +129,22 @@ def test_snippet_image_gallery_thumbnail_update(self): def test_dropdowns_and_header_hide_on_scroll(self): self.start_tour(self.env['website'].get_client_action_url('/'), 'dropdowns_and_header_hide_on_scroll', login='admin') + + def test_image_mimetype(self): + self.start_tour('/', "website_image_mimetype", login='admin') + + def test_image_mimetype_no_webp(self): + self.start_tour('/', "website_image_mimetype_no_webp", login='admin') + + def test_image_mimetype_bigger_output(self): + self.start_tour('/', "website_image_mimetype_bigger_output", login='admin') + + def test_image_mimetype_image_gallery(self): + self.start_tour('/', "website_image_mimetype_image_gallery", login='admin') + + def test_image_mimetype_image_gallery_no_webp(self): + self.start_tour('/', "website_image_mimetype_image_gallery_no_webp", login='admin') + + def test_image_mimetype_image_gallery_bigger_output(self): + self.start_tour('/', "website_image_mimetype_image_gallery_bigger_output", login='admin') + diff --git a/addons/website_event/static/tests/tours/website_event_cover_image_mimetype.js b/addons/website_event/static/tests/tours/website_event_cover_image_mimetype.js new file mode 100644 index 0000000000000..4d937c5c7b33e --- /dev/null +++ b/addons/website_event/static/tests/tours/website_event_cover_image_mimetype.js @@ -0,0 +1,80 @@ +/** @odoo-module **/ + +import wTourUtils from "@website/js/tours/tour_utils"; +import { + generateTestPng, + mockCanvasToDataURLStep, + uploadImageFromDialog, + PNG_THAT_CONVERTS_TO_BIGGER_WEBP, +} from "@website/../tests/tours/snippet_image_mimetype"; + +// TODO run tests +function testPngUploadImplicitConversion(testImageData, expectedMimetype) { + return [ + { + content: "Click on the first event's cover", + trigger: "iframe .o_record_cover_component", + }, + { + content: "Open add image dialog", + trigger: '.snippet-option-CoverProperties we-button[data-background].active', + }, + ...uploadImageFromDialog( + "image/png", + "fake_file.png", + testImageData, + false, + undefined, + ), + ...wTourUtils.clickOnSave(), + { + content: `Verify image mimetype is ${expectedMimetype}`, + trigger: "iframe .o_record_cover_component", + async run() { + const cover = this.$anchor[0]; + + async function convertToBase64(file) { + return await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsDataURL(file); + }); + } + + const src = cover.style.backgroundImage.split('"')[1]; + const imgBlob = await (await fetch(src)).blob(); + const dataURL = await convertToBase64(imgBlob); + const mimetype = dataURL.split(':')[1].split(';')[0]; + if (mimetype !== expectedMimetype) { + console.error(`Wrong mimetype ${mimetype} - Expected ${expectedMimetype}`); + } + } + }, + ]; +} + +wTourUtils.registerWebsitePreviewTour("website_event_cover_image_mimetype", { + test: true, + edition: true, + url: "/event", +}, () => [ + ...testPngUploadImplicitConversion(generateTestPng(1024).split(",")[1], "image/webp"), +]); + +wTourUtils.registerWebsitePreviewTour("website_event_cover_image_mimetype_no_webp", { + test: true, + edition: true, + url: "/event", +}, () => [ + mockCanvasToDataURLStep, + ...testPngUploadImplicitConversion(generateTestPng(1024).split(",")[1], "image/png"), +]); + +wTourUtils.registerWebsitePreviewTour("website_event_cover_image_mimetype_bigger_output", { + test: true, + edition: true, + url: "/event", +}, () => [ + ...testPngUploadImplicitConversion(PNG_THAT_CONVERTS_TO_BIGGER_WEBP, "image/png"), +]); diff --git a/addons/website_event/tests/test_website_event.py b/addons/website_event/tests/test_website_event.py index d646531fbd34b..e819f40976752 100644 --- a/addons/website_event/tests/test_website_event.py +++ b/addons/website_event/tests/test_website_event.py @@ -288,3 +288,15 @@ def test_check_search_in_address(self): with self.assertRaises(AccessError): self.env['res.partner'].browse(self.partner.id).read() + +@tagged('-at_install', 'post_install') +class TestImageProcessing(HttpCase): + + def test_cover_image_mimetype(self): + self.start_tour('/event', "website_event_cover_image_mimetype", login='admin') + + def test_cover_image_mimetype_no_webp(self): + self.start_tour('/event', "website_event_cover_image_mimetype_no_webp", login='admin') + + def test_cover_image_mimetype_bigger_output(self): + self.start_tour('/event', "website_event_cover_image_mimetype_bigger_output", login='admin') diff --git a/addons/website_sale/static/src/js/website_sale.editor.js b/addons/website_sale/static/src/js/website_sale.editor.js index df15c536eb8b9..da01629b16bcd 100644 --- a/addons/website_sale/static/src/js/website_sale.editor.js +++ b/addons/website_sale/static/src/js/website_sale.editor.js @@ -467,6 +467,7 @@ options.registry.WebsiteSaleProductPage = options.Class.extend({ this.rpc = this.bindService("rpc"); this.orm = this.bindService("orm"); this.notification = this.bindService("notification"); + this.imageProcessing = this.bindService("image_processing"); }, /** @@ -590,7 +591,7 @@ options.registry.WebsiteSaleProductPage = options.Class.extend({ if (["image/gif", "image/svg+xml"].includes(attachment.mimetype)) { continue; } - await this._convertAttachmentToWebp(attachment, extraImageEls[index]); + await this._saveAlternativeImageSizesAttachments(attachment, extraImageEls[index]); } } this.rpc(`/shop/product/extra-images`, { @@ -605,51 +606,26 @@ options.registry.WebsiteSaleProductPage = options.Class.extend({ }); }, - async _convertAttachmentToWebp(attachment, imageEl) { - // This method is widely adapted from onFileUploaded in ImageField. - // Upon change, make sure to verify whether the same change needs - // to be applied on both sides. + async _saveAlternativeImageSizesAttachments(attachment, sourceImageElement) { // Generate alternate sizes and format for reports. - const imgEl = document.createElement("img"); - imgEl.src = imageEl.src; - await new Promise(resolve => imgEl.addEventListener("load", resolve)); - const originalSize = Math.max(imgEl.width, imgEl.height); - const smallerSizes = [1024, 512, 256, 128].filter(size => size < originalSize); - const webpName = attachment.name.replace(/\.(jpe?g|png)$/i, ".webp"); - let referenceId = undefined; - for (const size of [originalSize, ...smallerSizes]) { - const ratio = size / originalSize; - const canvas = document.createElement("canvas"); - canvas.width = imgEl.width * ratio; - canvas.height = imgEl.height * ratio; - const ctx = canvas.getContext("2d"); - ctx.fillStyle = "rgb(255, 255, 255)"; - ctx.fillRect(0, 0, canvas.width, canvas.height); - ctx.drawImage(imgEl, 0, 0, imgEl.width, imgEl.height, 0, 0, canvas.width, canvas.height); - const [resizedId] = await this.orm.call("ir.attachment", "create_unique", [[{ - name: webpName, - description: size === originalSize ? "" : `resize: ${size}`, - datas: canvas.toDataURL("image/webp", 0.75).split(",")[1], - res_id: referenceId, - res_model: "ir.attachment", - mimetype: "image/webp", - }]]); - if (size === originalSize) { - attachment.original_id = attachment.id; - attachment.id = resizedId; - attachment.image_src = `/web/image/${resizedId}-autowebp/${attachment.name}`; - attachment.mimetype = "image/webp"; - } - referenceId = referenceId || resizedId; // Keep track of original. - await this.orm.call("ir.attachment", "create_unique", [[{ - name: webpName.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", - }]]); - } + const { originalImage } = await this.imageProcessing.generateImageAlternatives( + sourceImageElement.src, + 0.75, + true, + attachment.name.split(".").splice(-1).join() + ); + + attachment.original_id = attachment.id; + attachment.id = originalImage.id; + attachment.image_src = `/web/image/${originalImage.id}-autowebp/${attachment.name}`; + attachment.mimetype = originalImage.mimetype; + }, + + /** + * TODO: remove in master + */ + async _convertAttachmentToWebp(attachment, sourceImageElement) { + return await this._saveAlternativeImageSizesAttachments(attachment, sourceImageElement); }, /** diff --git a/addons/website_sale/static/tests/tours/website_sale_add_image_mimetype.js b/addons/website_sale/static/tests/tours/website_sale_add_image_mimetype.js new file mode 100644 index 0000000000000..a658948b667a9 --- /dev/null +++ b/addons/website_sale/static/tests/tours/website_sale_add_image_mimetype.js @@ -0,0 +1,85 @@ +/** @odoo-module **/ + +import wTourUtils from "@website/js/tours/tour_utils"; +import { + mockCanvasToDataURLStep, + uploadImageFromDialog +} from "@website/../tests/tours/snippet_image_mimetype"; + +const DUMMY_WEBP = "UklGRiwAAABXRUJQVlA4TB8AAAAv/8F/EAcQEREQCCT7e89QRP8z/vOf//znP//5z/8BAA=="; + +function testWebpUploadImplicitConversion(expectedMimetype) { + return [ + { + content: "Go to product page", + trigger: "iframe .oe_product_cart a", + run() { + this.$anchor[0].click(); // for some reason the default action doesn't work + } + }, + ...wTourUtils.clickOnEditAndWaitEditMode(), + { + content: "Click on the product image", + trigger: "iframe #o-carousel-product .product_detail_img", + }, + { + content: "Open add image dialog", + trigger: 'we-button[data-add-images="true"]', + }, + ...uploadImageFromDialog( + "image/webp", + "fake_file.webp", + DUMMY_WEBP, + ), + { + content: "Go to last carousel image", + trigger: 'iframe [data-bs-target="#o-carousel-product"][data-bs-slide-to="1"]', + }, + ...wTourUtils.waitForImageToLoad("iframe #o-carousel-product .carousel-item:nth-child(2) img"), + ...wTourUtils.clickOnSave(), + { + content: "Go to last carousel image", + trigger: 'iframe [data-bs-target="#o-carousel-product"][data-bs-slide-to="1"]', + }, + { + content: `Verify image mimetype is ${expectedMimetype}`, + trigger: "iframe #o-carousel-product .carousel-item:nth-child(2) img", + async run() { + const img = this.$anchor[0]; + + async function convertToBase64(file) { + return await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsDataURL(file); + }); + } + + const imgBlob = await (await fetch(img.src)).blob(); + const dataURL = await convertToBase64(imgBlob); + const mimetype = dataURL.split(':')[1].split(';')[0]; + if (mimetype !== expectedMimetype) { + console.error(`Wrong mimetype ${mimetype} - Expected ${expectedMimetype}`); + } + } + }, + ]; +} + +wTourUtils.registerWebsitePreviewTour("website_sale_add_image_mimetype", { + test: true, + edition: false, + url: "/shop?search=customizable desk", +}, () => [ + ...testWebpUploadImplicitConversion("image/webp"), +]); + +wTourUtils.registerWebsitePreviewTour("website_sale_add_image_mimetype_no_webp", { + test: true, + edition: false, + url: "/shop?search=customizable desk", +}, () => [ + mockCanvasToDataURLStep, + ...testWebpUploadImplicitConversion("image/png"), +]); diff --git a/addons/website_sale/tests/test_website_editor.py b/addons/website_sale/tests/test_website_editor.py index 28be2f8b0d426..6265c929c260b 100644 --- a/addons/website_sale/tests/test_website_editor.py +++ b/addons/website_sale/tests/test_website_editor.py @@ -252,3 +252,39 @@ def test_website_sale_restricted_editor_ui(self): _logger.warning("This test relies on demo data. To be rewritten independently of demo data for accurate and reliable results.") return self.start_tour(self.env['website'].get_client_action_url('/shop'), 'website_sale_restricted_editor_ui', login='restricted') + + def test_website_sale_add_image_mimetype(self): + if not loaded_demo_data(self.env): + _logger.warning("This test relies on demo data. To be rewritten independently of demo " + "data for accurate and reliable results.") + return + self.start_tour(self.env['website'].get_client_action_url('/shop'), + "website_sale_add_image_mimetype", login='admin') + + attachments = self.env['ir.attachment'].search([ + ['res_model', '=', 'ir.attachment'], + ['name', 'like', '%webp'], + ['description', 'not like', 'format: %'], + ]) + self.assertEqual(len(attachments), 3, "Should have uploaded 3 attachments") + for attachment in attachments: + self.assertEqual(attachment.mimetype, "image/webp", + "Attachment mimetype should be image/webp") + + def test_website_sale_add_image_mimetype_no_webp(self): + if not loaded_demo_data(self.env): + _logger.warning("This test relies on demo data. To be rewritten independently of demo " + "data for accurate and reliable results.") + return + self.start_tour(self.env['website'].get_client_action_url('/shop'), + "website_sale_add_image_mimetype_no_webp", login='admin') + + attachments = self.env['ir.attachment'].search([ + ['res_model', '=', 'ir.attachment'], + ['name', 'like', '%webp'], + ['description', 'not like', 'format: %'], + ]) + self.assertEqual(len(attachments), 3, "Should have uploaded 3 attachments") + for attachment in attachments: + self.assertEqual(attachment.mimetype, "image/png", "Attachment mimetype should be " + "image/png")