[IMP] web_editor: allow adding a shape-on-image via custom URL (themes)
The goal of this commit is to add a "Python version" of the
shape-on-image feature using a controller.
Since the whole logic to apply shapes on image is JS-based, we need
to use this URL (just like for background shapes) when we want to add
a shape by default on images in themes. When the configurator replaces a
snippet image (which the theme defines to have a shape), the shape
option should still be applied on the new image.
On the JS side, loadImageInfo() is overridden in order to mark
images (with theme default shapes) with corresponding attachment
data (original-id, original-src, mimetype).
Here is an example of the minimum xpath required to add a shape on a
snippet image in a theme:
```
<template id="s_image_text" inherit_id="website.s_image_text">
<xpath expr="//img" position="attributes">
<attribute name="src">/web_editor/image_shape/website.s_image_text_default_image/web_editor/solid/blob_1_solid_rd.svg?c2=o-color-1</attribute>
<attribute name="data-shape">web_editor/solid/blob_1_solid_rd</attribute>
<attribute name="data-original-mimetype">image/jpeg</attribute>
<attribute name="data-file-name">s_image_text.svg</attribute>
<attribute name="data-shape-colors">;o-color-1;;;</attribute>
</xpath>
</template>
```
task-2593454
closes odoo/odoo#73938
Signed-off-by: Quentin Smetz (qsm) <qsm@odoo.com>
This commit is contained in:
@@ -19,6 +19,9 @@ from odoo.exceptions import UserError
|
||||
from odoo.modules.module import get_module_path, get_resource_path
|
||||
from odoo.tools.misc import file_open
|
||||
from odoo.tools.mimetypes import guess_mimetype
|
||||
from odoo.tools.image import image_data_uri, base64_to_image
|
||||
from odoo.addons.web.controllers.main import Binary
|
||||
from odoo.addons.base.models.assetsbundle import AssetsBundle
|
||||
|
||||
from ..models.ir_attachment import SUPPORTED_IMAGE_EXTENSIONS, SUPPORTED_IMAGE_MIMETYPES
|
||||
|
||||
@@ -521,6 +524,57 @@ class Web_Editor(http.Controller):
|
||||
attachment.generate_access_token()
|
||||
return '%s?access_token=%s' % (attachment.image_src, attachment.access_token)
|
||||
|
||||
def _get_shape_svg(self, module, *segments):
|
||||
shape_path = get_resource_path(module, 'static', *segments)
|
||||
if not shape_path:
|
||||
raise werkzeug.exceptions.NotFound()
|
||||
with tools.file_open(shape_path, 'r', filter_ext=('.svg',)) as file:
|
||||
return file.read()
|
||||
|
||||
def _update_svg_colors(self, options, svg):
|
||||
user_colors = []
|
||||
svg_options = {}
|
||||
default_palette = {
|
||||
'1': '#3AADAA',
|
||||
'2': '#7C6576',
|
||||
'3': '#F6F6F6',
|
||||
'4': '#FFFFFF',
|
||||
'5': '#383E45',
|
||||
}
|
||||
bundle_css = None
|
||||
regex_hex = r'#[0-9A-F]{6,8}'
|
||||
regex_rgba = r'rgba?\(\d{1,3},\d{1,3},\d{1,3}(?:,[0-9.]{1,4})?\)'
|
||||
for key, value in options.items():
|
||||
colorMatch = re.match('^c([1-5])$', key)
|
||||
if colorMatch:
|
||||
css_color_value = value
|
||||
# Check that color is hex or rgb(a) to prevent arbitrary injection
|
||||
if not re.match(r'(?i)^%s$|^%s$' % (regex_hex, regex_rgba), css_color_value.replace(' ', '')):
|
||||
if re.match('^o-color-([1-5])$', css_color_value):
|
||||
if not bundle_css:
|
||||
bundle = 'web.assets_frontend'
|
||||
files, _ = request.env["ir.qweb"]._get_asset_content(bundle, options=request.context)
|
||||
asset = AssetsBundle(bundle, files)
|
||||
bundle_css = asset.css().index_content
|
||||
color_search = re.search(r'(?i)--%s:\s+(%s|%s)' % (css_color_value, regex_hex, regex_rgba), bundle_css)
|
||||
if not color_search:
|
||||
raise werkzeug.exceptions.BadRequest()
|
||||
css_color_value = color_search.group(1)
|
||||
else:
|
||||
raise werkzeug.exceptions.BadRequest()
|
||||
user_colors.append([tools.html_escape(css_color_value), colorMatch.group(1)])
|
||||
else:
|
||||
svg_options[key] = value
|
||||
|
||||
color_mapping = {default_palette[palette_number]: color for color, palette_number in user_colors}
|
||||
# create a case-insensitive regex to match all the colors to replace, eg: '(?i)(#3AADAA)|(#7C6576)'
|
||||
regex = '(?i)%s' % '|'.join('(%s)' % color for color in color_mapping.keys())
|
||||
|
||||
def subber(match):
|
||||
key = match.group().upper()
|
||||
return color_mapping[key] if key in color_mapping else key
|
||||
return re.sub(regex, subber, svg), svg_options
|
||||
|
||||
@http.route(['/web_editor/shape/<module>/<path:filename>'], type='http', auth="public", website=True)
|
||||
def shape(self, module, filename, **kwargs):
|
||||
"""
|
||||
@@ -536,43 +590,38 @@ class Web_Editor(http.Controller):
|
||||
raise werkzeug.exceptions.NotFound()
|
||||
svg = b64decode(attachment.datas).decode('utf-8')
|
||||
else:
|
||||
shape_path = get_resource_path(module, 'static', 'shapes', filename)
|
||||
if not shape_path:
|
||||
raise werkzeug.exceptions.NotFound()
|
||||
with tools.file_open(shape_path, 'r') as file:
|
||||
svg = file.read()
|
||||
svg = self._get_shape_svg(module, 'shapes', filename)
|
||||
|
||||
user_colors = []
|
||||
for key, value in kwargs.items():
|
||||
colorMatch = re.match('^c([1-5])$', key)
|
||||
if colorMatch:
|
||||
# Check that color is hex or rgb(a) to prevent arbitrary injection
|
||||
if not re.match(r'(?i)^#[0-9A-F]{6,8}$|^rgba?\(\d{1,3},\d{1,3},\d{1,3}(?:,[0-9.]{1,4})?\)$', value.replace(' ', '')):
|
||||
raise werkzeug.exceptions.BadRequest()
|
||||
user_colors.append([tools.html_escape(value), colorMatch.group(1)])
|
||||
elif key == 'flip':
|
||||
if value == 'x':
|
||||
svg = svg.replace('<svg ', '<svg style="transform: scaleX(-1);" ')
|
||||
elif value == 'y':
|
||||
svg = svg.replace('<svg ', '<svg style="transform: scaleY(-1)" ')
|
||||
elif value == 'xy':
|
||||
svg = svg.replace('<svg ', '<svg style="transform: scale(-1)" ')
|
||||
svg, options = self._update_svg_colors(kwargs, svg)
|
||||
flip_value = options.get('flip', False)
|
||||
if flip_value == 'x':
|
||||
svg = svg.replace('<svg ', '<svg style="transform: scaleX(-1);" ')
|
||||
elif flip_value == 'y':
|
||||
svg = svg.replace('<svg ', '<svg style="transform: scaleY(-1)" ')
|
||||
elif flip_value == 'xy':
|
||||
svg = svg.replace('<svg ', '<svg style="transform: scale(-1)" ')
|
||||
|
||||
default_palette = {
|
||||
'1': '#3AADAA',
|
||||
'2': '#7C6576',
|
||||
'3': '#F6F6F6',
|
||||
'4': '#FFFFFF',
|
||||
'5': '#383E45',
|
||||
}
|
||||
color_mapping = {default_palette[palette_number]: color for color, palette_number in user_colors}
|
||||
# create a case-insensitive regex to match all the colors to replace, eg: '(?i)(#3AADAA)|(#7C6576)'
|
||||
regex = '(?i)%s' % '|'.join('(%s)' % color for color in color_mapping.keys())
|
||||
return request.make_response(svg, [
|
||||
('Content-type', 'image/svg+xml'),
|
||||
('Cache-control', 'max-age=%s' % http.STATIC_CACHE_LONG),
|
||||
])
|
||||
|
||||
def subber(match):
|
||||
key = match.group().upper()
|
||||
return color_mapping[key] if key in color_mapping else key
|
||||
svg = re.sub(regex, subber, svg)
|
||||
@http.route(['/web_editor/image_shape/<string:img_key>/<module>/<path:filename>'], type='http', auth="public", website=True)
|
||||
def image_shape(self, module, filename, img_key, **kwargs):
|
||||
svg = self._get_shape_svg(module, 'image_shapes', filename)
|
||||
_, _, image_base64 = request.env['ir.http'].binary_content(
|
||||
xmlid=img_key, model='ir.attachment', field='datas', default_mimetype='image/png')
|
||||
if not image_base64:
|
||||
image_base64 = b64encode(Binary.placeholder())
|
||||
image = base64_to_image(image_base64)
|
||||
width, height = tuple(str(size) for size in image.size)
|
||||
root = etree.fromstring(svg)
|
||||
root.attrib.update({'width': width, 'height': height})
|
||||
# Update default color palette on shape SVG.
|
||||
svg, _ = self._update_svg_colors(kwargs, etree.tostring(root, pretty_print=True).decode('utf-8'))
|
||||
# Add image in base64 inside the shape.
|
||||
uri = image_data_uri(image_base64)
|
||||
svg = svg.replace('<image xlink:href="', '<image xlink:href="%s' % uri)
|
||||
|
||||
return request.make_response(svg, [
|
||||
('Content-type', 'image/svg+xml'),
|
||||
|
||||
@@ -303,9 +303,11 @@ async function activateCropper(image, aspectRatio, dataset) {
|
||||
* @param {HTMLImageElement} img the image whose attachment data should be found
|
||||
* @param {Function} rpc a function that can be used to make the RPC. Typically
|
||||
* this would be passed as 'this._rpc.bind(this)' from widgets.
|
||||
* @param {string} [attachmentSrc=''] specifies the URL of the corresponding
|
||||
* attachment if it can't be found in the 'src' attribute.
|
||||
*/
|
||||
async function loadImageInfo(img, rpc) {
|
||||
const src = img.getAttribute('src');
|
||||
async function loadImageInfo(img, rpc, attachmentSrc = '') {
|
||||
const src = attachmentSrc || img.getAttribute('src');
|
||||
// If there is a marked originalSrc, the data is already loaded.
|
||||
if (img.dataset.originalSrc || !src) {
|
||||
return;
|
||||
|
||||
@@ -4231,7 +4231,7 @@ const ImageHandlerOption = SnippetOptionWidget.extend({
|
||||
*/
|
||||
async willStart() {
|
||||
const _super = this._super.bind(this);
|
||||
await this._loadImageInfo();
|
||||
await this._initializeImage();
|
||||
return _super(...arguments);
|
||||
},
|
||||
/**
|
||||
@@ -4438,9 +4438,9 @@ const ImageHandlerOption = SnippetOptionWidget.extend({
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async _loadImageInfo() {
|
||||
async _loadImageInfo(attachmentSrc = '') {
|
||||
const img = this._getImg();
|
||||
await loadImageInfo(img, this._rpc.bind(this));
|
||||
await loadImageInfo(img, this._rpc.bind(this), attachmentSrc);
|
||||
if (!img.dataset.originalId) {
|
||||
this.originalId = null;
|
||||
this.originalSrc = null;
|
||||
@@ -4493,6 +4493,12 @@ const ImageHandlerOption = SnippetOptionWidget.extend({
|
||||
_getImageMimetype(img) {
|
||||
return img.dataset.mimetype;
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
async _initializeImage() {
|
||||
return this._loadImageInfo();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -4603,9 +4609,11 @@ registry.ImageOptimize = ImageHandlerOption.extend({
|
||||
async _applyOptions() {
|
||||
const img = await this._super(...arguments);
|
||||
if (img && img.dataset.shape) {
|
||||
// Reapplying the shape
|
||||
await this._loadShape(img.dataset.shape);
|
||||
await this._applyShapeAndColors(true, (img.dataset.shapeColors && img.dataset.shapeColors.split(';')));
|
||||
if (/^data:/.test(img.src)) {
|
||||
// Reapplying the shape
|
||||
await this._applyShapeAndColors(true, (img.dataset.shapeColors && img.dataset.shapeColors.split(';')));
|
||||
}
|
||||
}
|
||||
return img;
|
||||
},
|
||||
@@ -4649,7 +4657,7 @@ registry.ImageOptimize = ImageHandlerOption.extend({
|
||||
// shape's colors by the current palette's
|
||||
newColors = oldColors.map((color, i) => color !== null ? this._getCSSColorValue(`o-color-${(i + 1)}`) : null);
|
||||
}
|
||||
newColors.forEach((color, i) => shape = shape.replace(new RegExp(oldColors[i], 'g'), color));
|
||||
newColors.forEach((color, i) => shape = shape.replace(new RegExp(oldColors[i], 'g'), this._getCSSColorValue(color)));
|
||||
await this._writeShape(shape);
|
||||
if (save) {
|
||||
img.dataset.shapeColors = newColors.join(';');
|
||||
@@ -4792,11 +4800,29 @@ registry.ImageOptimize = ImageHandlerOption.extend({
|
||||
* @returns {string}
|
||||
*/
|
||||
_getCSSColorValue(color) {
|
||||
if (ColorpickerWidget.isCSSColor(color)) {
|
||||
if (!color || ColorpickerWidget.isCSSColor(color)) {
|
||||
return color;
|
||||
}
|
||||
return weUtils.getCSSVariableValue(color);
|
||||
},
|
||||
/**
|
||||
* Overridden to set attachment data on theme images (with default shapes).
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
async _initializeImage() {
|
||||
const img = this._getImg();
|
||||
const match = img.src.match(/\/web_editor\/image_shape\/(\w+\.\w+)/);
|
||||
if (img.dataset.shape && match) {
|
||||
await this._loadImageInfo(`/web/image/${match[1]}`);
|
||||
// Image data-mimetype should be changed to SVG since loadImageInfo()
|
||||
// will set the original attachment mimetype on it.
|
||||
img.dataset.mimetype = 'image/svg+xml';
|
||||
return;
|
||||
}
|
||||
return this._super(...arguments);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
|
||||
Reference in New Issue
Block a user