Skip to content

Commit

Permalink
[IMP] web_editor: allow adding a shape-on-image via custom URL (themes)
Browse files Browse the repository at this point in the history
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 #73938

Signed-off-by: Quentin Smetz (qsm) <qsm@odoo.com>
  • Loading branch information
xO-Tx authored and qsm-odoo committed Jul 30, 2021
1 parent 460d5ec commit eb12629
Show file tree
Hide file tree
Showing 3 changed files with 120 additions and 43 deletions.
117 changes: 83 additions & 34 deletions addons/web_editor/controllers/main.py
Expand Up @@ -19,6 +19,9 @@
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

Expand Down Expand Up @@ -521,6 +524,57 @@ def modify_image(self, attachment, res_model=None, res_id=None, name=None, data=
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):
"""
Expand All @@ -536,43 +590,38 @@ def shape(self, module, filename, **kwargs):
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'),
Expand Down
6 changes: 4 additions & 2 deletions addons/web_editor/static/src/js/editor/image_processing.js
Expand Up @@ -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;
Expand Down
40 changes: 33 additions & 7 deletions addons/web_editor/static/src/js/editor/snippets.options.js
Expand Up @@ -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);
},
/**
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -4493,6 +4493,12 @@ const ImageHandlerOption = SnippetOptionWidget.extend({
_getImageMimetype(img) {
return img.dataset.mimetype;
},
/**
* @private
*/
async _initializeImage() {
return this._loadImageInfo();
}
});

/**
Expand Down Expand Up @@ -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;
},
Expand Down Expand Up @@ -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(';');
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit eb12629

Please sign in to comment.