From 066e1c7c84a689354bc5a844e72dfd9fddfe1ad5 Mon Sep 17 00:00:00 2001 From: Antoine Guenet Date: Fri, 24 Sep 2021 19:27:55 +0000 Subject: [PATCH] [IMP] mass_mailing: multiple transpiler improvements This brings a series of improvements to the mechanism in place to convert the output html of mass mailing into html that is more compliant with the main mail clients' requirements. The biggest change is the automatic conversion of Bootstrap grids into table structures. Currently all templates in `mass_mailing` are designed with tables so they work in mailings as is. That has limitations though as it makes it less easy to edit with snippets such as those used in the website builder. This new automatic conversion from Bootstrap grid will allow us to adapt the mailing and snippet templates and be more free within `mass_mailing`. Note: Because of the limited support of media queries in emails, this doesn't support the mixing and matching of column options (e.g., `"col-4 col-sm-6"` and `"col col-4"` aren't supported). Other changes include: - The conversion of Bootstrap cards to table structures - The conversion of Bootstrap list-groups to table structures - The conversion of snippets (.o_mail_snippet_general) and mailings (.o_layout) into table structures - The conversion of all rgb colors to hexadecimal - The conversion of all "rem" sizes to "px" - Various small corrections to the output styles task-2554899 X-original-commit: e2e00939e09220051f6f4ecfb7bb3276105387bf Part-of: odoo/odoo#77724 --- .../static/src/js/mass_mailing_widget.js | 26 +- addons/mass_mailing/tests/test_mailing_ui.py | 2 +- addons/web_editor/controllers/main.py | 15 +- .../static/src/js/backend/convert_inline.js | 518 ++++++++++++++++-- 4 files changed, 505 insertions(+), 56 deletions(-) diff --git a/addons/mass_mailing/static/src/js/mass_mailing_widget.js b/addons/mass_mailing/static/src/js/mass_mailing_widget.js index c17abbf7d6c..b5e37b3b3fd 100644 --- a/addons/mass_mailing/static/src/js/mass_mailing_widget.js +++ b/addons/mass_mailing/static/src/js/mass_mailing_widget.js @@ -67,18 +67,26 @@ var MassMailingFieldHtml = FieldHtml.extend({ self._isDirty = self.wysiwyg.isDirty(); self._doAction(); + // fix outlook image rendering bug (this change will be kept in both + // fields) + _.each(['width', 'height'], function (attribute) { + $editable.find('img').attr(attribute, function () { + return $(this)[attribute](); + }).css(attribute, function () { + return $(this).get(0).style[attribute] || attribute === 'width' ? $(this)[attribute]() + 'px' : 'auto'; + }); + }); + convertInline.attachmentThumbnailToLinkImg($editable); convertInline.fontToImg($editable); convertInline.classToStyle($editable); - - // fix outlook image rendering bug - _.each(['width', 'height'], function (attribute) { - $editable.find('img[style*="width"], img[style*="height"]').attr(attribute, function () { - return $(this)[attribute](); - }).css(attribute, function () { - return $(this).get(0).style[attribute] || 'auto'; - }); - }); + convertInline.bootstrapToTable($editable); + convertInline.cardToTable($editable); + convertInline.listGroupToTable($editable); + convertInline.addTables($editable); + convertInline.formatTables($editable); + convertInline.normalizeColors($editable); + convertInline.normalizeRem($editable); self.trigger_up('field_changed', { dataPointID: self.dataPointID, diff --git a/addons/mass_mailing/tests/test_mailing_ui.py b/addons/mass_mailing/tests/test_mailing_ui.py index 808bb608d1c..22b6ab5e224 100644 --- a/addons/mass_mailing/tests/test_mailing_ui.py +++ b/addons/mass_mailing/tests/test_mailing_ui.py @@ -20,4 +20,4 @@ class TestUi(HttpCaseWithUserDemo): # for email client compatibility should be saved in body_html. This # ensures both fields have different values. self.assertEqual(mail.body_arch, '


') - self.assertEqual(mail.body_html, '


') + self.assertEqual(mail.body_html, '


') diff --git a/addons/web_editor/controllers/main.py b/addons/web_editor/controllers/main.py index a02e3e5d2c7..7349542fa5b 100644 --- a/addons/web_editor/controllers/main.py +++ b/addons/web_editor/controllers/main.py @@ -35,13 +35,17 @@ class Web_Editor(http.Controller): '/web_editor/font_to_img//', '/web_editor/font_to_img///', '/web_editor/font_to_img////', + '/web_editor/font_to_img///', + '/web_editor/font_to_img////', + '/web_editor/font_to_img/////', ], type='http', auth="none") - def export_icon_to_png(self, icon, color='#000', size=100, alpha=255, font='/web/static/lib/fontawesome/fonts/fontawesome-webfont.ttf'): + def export_icon_to_png(self, icon, color='#000', bg=None, size=100, alpha=255, font='/web/static/lib/fontawesome/fonts/fontawesome-webfont.ttf'): """ This method converts an unicode character to an image (using Font Awesome font by default) and is used only for mass mailing because custom fonts are not supported in mail. :param icon : decimal encoding of unicode character :param color : RGB code of the color + :param bg : RGB code of the background color :param size : Pixels in integer :param alpha : transparency of the image from 0 to 255 :param font : font path @@ -57,6 +61,11 @@ class Web_Editor(http.Controller): # if received character is not a number, keep old behaviour (icon is character) icon = chr(int(icon)) if icon.isdigit() else icon + # Background standardization + if bg is not None and bg.startswith('rgba'): + bg = bg.replace('rgba', 'rgb') + bg = ','.join(bg.split(',')[:-1])+')' + # Determine the dimensions of the icon image = Image.new("RGBA", (size, size), color=(0, 0, 0, 0)) draw = ImageDraw.Draw(image) @@ -78,8 +87,8 @@ class Web_Editor(http.Controller): iconimage.putalpha(imagemask) # Create output image - outimage = Image.new("RGBA", (boxw, size), (0, 0, 0, 0)) - outimage.paste(iconimage, (left, top)) + outimage = Image.new("RGBA", (boxw, size), bg or (0, 0, 0, 0)) + outimage.paste(iconimage, (left, top), iconimage) # output image output = io.BytesIO() diff --git a/addons/web_editor/static/src/js/backend/convert_inline.js b/addons/web_editor/static/src/js/backend/convert_inline.js index ff285255286..7ad783af1b5 100644 --- a/addons/web_editor/static/src/js/backend/convert_inline.js +++ b/addons/web_editor/static/src/js/backend/convert_inline.js @@ -4,6 +4,21 @@ odoo.define('web_editor.convertInline', function (require) { var FieldHtml = require('web_editor.field.html'); const SELECTORS_IGNORE = /(^\*$|:hover|:before|:after|:active|:link|::|'|\([^(),]+[,(])/; + +// Note: duplicated from odoo-editor utils. +// DOES NOT SUPPORT RGBA (Outlook doesn't support transparency) +function rgbToHex(rgb = '') { + return ( + '#' + + (rgb.match(/\d{1,3}/g) || []) + .map(x => { + x = parseInt(x).toString(16); + return x.length === 1 ? '0' + x : x; + }) + .join('') + ); +} + /** * Returns the css rules which applies on an element, tweaked so that they are * browser/mail client ok. @@ -12,7 +27,7 @@ const SELECTORS_IGNORE = /(^\*$|:hover|:before|:after|:active|:link|::|'|\([^(), * @returns {Object} css property name -> css property value */ function getMatchedCSSRules(a) { - var i, r, k; + var i, r, k, l; var doc = a.ownerDocument; var rulesCache = a.ownerDocument._rulesCache || (a.ownerDocument._rulesCache = []); @@ -29,11 +44,37 @@ function getMatchedCSSRules(a) { } if (rules) { for (r = rules.length-1; r >= 0; r--) { + const conditionText = rules[r].conditionText; + const minWidthMatch = conditionText && conditionText.match(/\(min-width *: *(\d+)/); + const minWidth = minWidthMatch && +(minWidthMatch[1] || '0'); + if (minWidth && minWidth >= 1200) { + // Large min-width media queries should be included. + // eg., .container has a default max-width for all + // screens. + let mediaRules; + try { + mediaRules = rules[r].rules || rules[r].cssRules; + } catch (e) { + console.log(`Can't read the css rules of: ${sheets[i].href} (${conditionText})`, e); + continue; + } + if (mediaRules) { + for (k = mediaRules.length-1; k >= 0; k--) { + var selectorText = mediaRules[k].selectorText; + if (selectorText && !SELECTORS_IGNORE.test(selectorText)) { + var st = selectorText.split(/\s*,\s*/); + for (l = 0 ; l < st.length ; l++) { + rulesCache.push({ 'selector': st[l], 'style': mediaRules[k].style }); + } + } + } + } + } var selectorText = rules[r].selectorText; if (selectorText && !SELECTORS_IGNORE.test(selectorText)) { var st = selectorText.split(/\s*,\s*/); - for (k = 0 ; k < st.length ; k++) { - rulesCache.push({ 'selector': st[k], 'style': rules[r].style }); + for (l = 0 ; l < st.length ; l++) { + rulesCache.push({ 'selector': st[l], 'style': rules[r].style }); } } } @@ -93,9 +134,12 @@ function getMatchedCSSRules(a) { } }); - if (style.display === 'block') { + if (style.display === 'block' && !(a.classList && a.classList.contains('btn-block'))) { delete style.display; } + if (!style['box-sizing']) { + style['box-sizing'] = 'border-box'; // This is by default with Bootstrap. + } // The css generates all the attributes separately and not in simplified form. // In order to have a better compatibility (outlook for example) we simplify the css tags. @@ -157,18 +201,34 @@ function getMatchedCSSRules(a) { delete style['text-decoration-thickness']; } - // text-align inheritance does not seem to get past elements on some - // mail clients - if (style['text-align'] === 'inherit') { - var $el = $(a).parent(); - do { - var align = $el.css('text-align'); - if (_.indexOf(['left', 'right', 'center', 'justify'], align) >= 0) { - style['text-align'] = align; - break; + // color and text-align inheritance do not seem to get past elements on + // some mail clients. TODO: This is hacky as it applies a color/text-align + // style to all descendants of nodes with a color style. We can probably do + // this more elegantly. + if (style.color || style['text-align']) { + function _styleDescendants(node, styleName) { + const camelCased = styleName.replace(/-(\w)/g, match => match[1].toUpperCase()); + node.style[camelCased] = style[styleName]; + for (const child of $(node).children()) { + const childStyle = $(child).css(styleName); + if (childStyle && childStyle !== 'inherit') { + _styleDescendants(child, styleName); + } } - $el = $el.parent(); - } while ($el.length && !$el.is('html')); + } + if (style.color) { + _styleDescendants(a, 'color'); + } + if (style['text-align']) { + _styleDescendants(a, 'text-align'); + } + } + + // flexboxes are not supported in Windows Outlook + for (const styleName in style) { + if (styleName.includes('flex') || `${style[styleName]}`.includes('flex')) { + delete style[styleName]; + } } return style; @@ -197,13 +257,45 @@ function fontToImg($editable) { }); if (content) { var color = $font.css('color').replace(/\s/g, ''); - $font.replaceWith($('', { - src: _.str.sprintf('/web_editor/font_to_img/%s/%s/%s', content.charCodeAt(0), window.encodeURI(color), Math.max(1, Math.round($font.height()))), + let $backgroundColoredElement = $font; + let bg, isTransparent; + do { + bg = $backgroundColoredElement.css('background-color').replace(/\s/g, ''); + isTransparent = bg === 'transparent' || bg === 'rgba(0,0,0,0)'; + $backgroundColoredElement = $backgroundColoredElement.parent(); + } while (isTransparent && $backgroundColoredElement[0]); + if (bg === 'rgba(0,0,0,0)' && isTransparent) { + // default on white rather than black background since opacity + // is not supported. + bg = 'rgb(255,255,255)'; + } + const style = $font.attr('style'); + const width = $font.width(); + const height = $font.height(); + const lineHeight = $font.css('line-height'); + // Compute the padding. + // First get the dimensions of the icon itself (::before) + $font.css({height: 'fit-content', width: 'fit-content', 'line-height': 'normal'}); + const hPadding = width && (width - $font.width()) / 2; + const vPadding = height && (height - $font.height()) / 2; + let padding = ''; + if (hPadding || vPadding) { + padding = vPadding ? vPadding + 'px ' : '0 '; + padding += hPadding ? hPadding + 'px' : '0'; + } + const $img = $('').attr({ + width, height, + src: `/web_editor/font_to_img/${content.charCodeAt(0)}/${window.encodeURI(color)}/${window.encodeURI(bg)}/${Math.max(1, $font.height())}`, 'data-class': $font.attr('class'), - 'data-style': $font.attr('style'), + 'data-style': style, class: $font.attr('class').replace(new RegExp('(^|\\s+)' + icon + '(-[^\\s]+)?', 'gi'), ''), // remove inline font-awsome style - style: $font.attr('style'), - }).css({height: 'auto', width: 'auto'})); + style, + }).css({ + 'box-sizing': 'border-box', // keep the fontawesome's dimensions + 'line-height': lineHeight, + padding, width: width + 'px', height: height + 'px', + }); + $font.replaceWith($img); } else { $font.remove(); } @@ -237,6 +329,312 @@ function applyOverDescendants(node, func) { } } +const reColMatch = /(^| )col(-[\w\d]+)*( |$)/; +const reOffsetMatch = /(^| )offset(-[\w\d]+)*( |$)/; +function _getColumnSize(column) { + const colMatch = column.className.match(reColMatch); + const colOptions = colMatch[2] && colMatch[2].substr(1).split('-'); + const colSize = colOptions && (colOptions.length === 2 ? +colOptions[1] : +colOptions[0]) || 0; + const offsetMatch = column.className.match(reOffsetMatch); + const offsetOptions = offsetMatch && offsetMatch[2] && offsetMatch[2].substr(1).split('-'); + const offsetSize = offsetOptions && (offsetOptions.length === 2 ? +offsetOptions[1] : +offsetOptions[0]) || 0; + return colSize + offsetSize; +} + + +// Attributes all tables should have in a mailing. +const tableAttributes = { + cellspacing: 0, + cellpadding: 0, + border: 0, + width: '100%', + align: 'center', + role: 'presentation', +}; +// Cancel tables default styles. +const tableStyles = { + 'border-collapse': 'collapse', + 'text-align': 'inherit', +}; +function _createTable(attributes = []) { + const $table = $(''); + $table.attr(tableAttributes).css(tableStyles); + $table[0].style.setProperty('width', '100%', 'important'); + for (const attr of attributes) { + if (attr.name !== 'width' && attr.value !== '100%') { + $table.attr(attr.name, attr.value); + } + } + return $table; +} +function _createColumnGrid() { + return new Array(12).fill().map(() => $(''); + for (const attr of bootstrapRow.attributes) { + $row.attr(attr.name, attr.value); + } + $row.removeClass('row'); + for (const child of [...bootstrapRow.childNodes]) { + $row.append(child); + } + $bootstrapRow.before($row); + $bootstrapRow.remove(); + + + // COLUMNS + const $bootstrapColumns = $row.children().filter((i, column) => column.className && column.className.match(reColMatch)); + + // 1. Replace generic "col" classes with specific "col-n", computed + // by sharing the available space between them. + const $flexColumns = $bootstrapColumns.filter((i, column) => !/\d/.test(column.className.match(reColMatch)[0] || '0')); + const colTotalSize = $bootstrapColumns.toArray().map(child => _getColumnSize(child)).reduce((a, b) => a + b); + const colSize = Math.round((12 - colTotalSize) / $flexColumns.length); + for (const flexColumn of $flexColumns) { + flexColumn.classList.remove(flexColumn.className.match(reColMatch)[0].trim()); + flexColumn.classList.add(`col-${colSize}`); + } + + // 2. Create and fill up the row(s) with grid(s). + let grid = _createColumnGrid(); + let gridIndex = 0; + let $currentRow = $($row[0].cloneNode()); + $row.after($currentRow); + let $currentCol; + let columnIndex = 0; + for (const bootstrapColumn of $bootstrapColumns) { + const columnSize = _getColumnSize(bootstrapColumn); + if (gridIndex + columnSize < 12) { + $currentCol = grid[gridIndex]; + _applyColspanToGridElement($currentCol, columnSize); + gridIndex += columnSize; + if (columnIndex === $bootstrapColumns.length - 1) { + // We handled all the columns but there is still space + // in the row. Insert the columns and fill the row. + grid[gridIndex].attr('colspan', 12 - gridIndex); + $currentRow.append(...grid.filter(td => td.attr('colspan'))); + } + } else if (gridIndex + columnSize === 12) { + // Finish the row. + $currentCol = grid[gridIndex]; + _applyColspanToGridElement($currentCol, columnSize); + $currentRow.append(...grid.filter(td => td.attr('colspan'))); + if (columnIndex !== $bootstrapColumns.length - 1) { + // The row was filled before we handled all of its + // columns. Create a new one and start again from there. + const $previousRow = $currentRow; + $currentRow = $($currentRow[0].cloneNode()); + $previousRow.after($currentRow); + grid = _createColumnGrid(); + gridIndex = 0; + } + } else { + // Fill the row with what was in the grid before it + // overflowed. + _applyColspanToGridElement(grid[gridIndex], 12 - gridIndex); + $currentRow.append(...grid.filter(td => td.attr('colspan'))); + // Start a new row that starts with the current col. + const $previousRow = $currentRow; + $currentRow = $($currentRow[0].cloneNode()); + $previousRow.after($currentRow); + grid = _createColumnGrid(); + $currentCol = grid[0]; + _applyColspanToGridElement($currentCol, columnSize); + gridIndex = columnSize; + } + if ($currentCol) { + for (const attr of bootstrapColumn.attributes) { + if (attr.name !== 'colspan') { + $currentCol.attr(attr.name, attr.value); + } + } + const colMatch = bootstrapColumn.className.match(reColMatch); + $currentCol.removeClass(colMatch[0]); + for (const child of [...bootstrapColumn.childNodes]) { + $currentCol.append(child); + } + // Adapt width to colspan. + _applyColspanToGridElement($currentCol, +$currentCol.attr('colspan')); + } + columnIndex++; + } + $row.remove(); // $row was cloned and inserted already + } + } +} + +function cardToTable($editable) { + for (const card of $editable.find('.card')) { + const $card = $(card); + // Table + const $table = _createTable(card.attributes); + for (const child of [...card.childNodes]) { + if (child.nodeType === Node.TEXT_NODE) { + $table.append(child); + } else { + const $row = $(''); + const $col = $(''); + const $col = $(''); + const $col = $(''); + const $colB = $('
')); +} +function _applyColspanToGridElement($gridElement, colspan) { + $gridElement.attr('colspan', colspan); + const width = Math.round(+$gridElement.attr('colspan') * 100 / 12) + '%'; + $gridElement.attr('width', width); + $gridElement.css('width', width); +} + +/** + * Converts bootstrap rows and columns to actual tables. + * + * Note: Because of the limited support of media queries in emails, this doesn't + * support the mixing and matching of column options (e.g., "col-4 col-sm-6" and + * "col col-4" aren't supported). + * + * @param {jQuery} $editable + */ +function bootstrapToTable($editable) { + // First give all rows in columns a separate container parent. + $editable.find('.row').filter((i, row) => reColMatch.test(row.parentElement.className)).wrap('
'); + + // Now convert all containers with rows to tables. + for (const container of $editable.find('.container:has(.row), .container-fluid:has(.row), .o_fake_table:has(.row)')) { + const $container = $(container); + + + // TABLE + const $table = _createTable(container.attributes); + for (const child of [...container.childNodes]) { + $table.append(child); + } + $table.removeClass('container container-fluid o_fake_table'); + $container.before($table); + $container.remove(); + + + // ROWS + const $bootstrapRows = $table.children().filter('.row'); + for (const bootstrapRow of $bootstrapRows) { + const $bootstrapRow = $(bootstrapRow); + const $row = $('
'); + if (child.nodeName === 'IMG') { + $col.append(child); + } else { + for (const attr of child.attributes) { + $col.attr(attr.name, attr.value); + } + for (const descendant of [...child.childNodes]) { + $col.append(descendant); + } + $(child).remove(); + } + $row.append($col); + $table.append($row); + } + } + $card.before($table); + $card.remove(); + } +} + +function listGroupToTable($editable) { + for (const listGroup of $editable.find('.list-group')) { + const $listGroup = $(listGroup); + // Table + let $table; + if ($listGroup.find('.list-group-item').length) { + $table = _createTable(listGroup.attributes); + } else { + $table = $(listGroup.cloneNode()); + for (const attr of $listGroup.attributes) { + $table.attr(attr.name, attr.value); + } + } + for (const child of [...listGroup.childNodes]) { + const $child = $(child); + if ($child.hasClass('list-group-item')) { + // List groups are
    s that render like tables. Their + // li.list-group-item children should translate to tr > td. + const $row = $('
'); + for (const attr of child.attributes) { + $col.attr(attr.name, attr.value); + } + for (const descendant of [...child.childNodes]) { + $col.append(descendant); + } + $col.removeClass('list-group-item'); + $row.append($col); + $table.append($row); + $(child).remove(); + } else { + $table.append(child); + } + } + $table.removeClass('list-group'); + if ($listGroup.is('td')) { + $listGroup.append($table); + $listGroup.removeClass('list-group'); + } else { + $listGroup.before($table); + $listGroup.remove(); + } + } +} + +function addTables($editable) { + for (const snippet of $editable.find('.o_mail_snippet_general, .o_layout')) { + // Convert all snippets and the mailing itself into table > tr > td + const $table = _createTable(snippet.attributes); + const $row = $('
'); + $row.append($col); + $table.append($row); + for (const child of [...snippet.childNodes]) { + $col.append(child); + } + $(snippet).before($table); + $(snippet).remove(); + + // If snippet doesn't have a table as child, wrap its contents in one. + if (!$col.children().filter('table')) { + const $tableB = _createTable(); + $tableB[0].style.width + const $rowB = $('
'); + $rowB.append($colB); + $tableB.append($rowB); + for (const child of [...$table[0].childNodes]) { + $colB.append(child); + } + $col.append($tableB); + } + } +} + +const rePadding = /(\d+)/; +function formatTables($editable) { + for (const table of $editable.find('table.o_mail_snippet_general, .o_mail_snippet_general table')) { + const $table = $(table); + const tablePaddingTop = +$table.css('padding-top').match(rePadding)[1]; + const tablePaddingRight = +$table.css('padding-right').match(rePadding)[1]; + const tablePaddingBottom = +$table.css('padding-bottom').match(rePadding)[1]; + const tablePaddingLeft = +$table.css('padding-left').match(rePadding)[1]; + for (const column of $table.find('td').filter((i, td) => $(td).closest('table').is($table))) { + const $column = $(column); + if ($column.css('padding')) { + const columnPaddingTop = +$column.css('padding-top').match(rePadding)[1]; + const columnPaddingRight = +$column.css('padding-right').match(rePadding)[1]; + const columnPaddingBottom = +$column.css('padding-bottom').match(rePadding)[1]; + const columnPaddingLeft = +$column.css('padding-left').match(rePadding)[1]; + $column.css({ + 'padding-top': columnPaddingTop + tablePaddingTop, + 'padding-right': columnPaddingRight + tablePaddingRight, + 'padding-bottom': columnPaddingBottom + tablePaddingBottom, + 'padding-left': columnPaddingLeft + tablePaddingLeft, + }); + } + } + $table.css('padding', ''); + } +} + /** * Converts css style to inline style (leave the classes on elements but forces * the style they give as inline style). @@ -247,9 +645,17 @@ function classToStyle($editable) { applyOverDescendants($editable[0], function (node) { var $target = $(node); var css = getMatchedCSSRules(node); + // Flexbox + for (const styleName in node.style) { + if (styleName.includes('flex') || `${node.style[styleName]}`.includes('flex')) { + node.style[styleName] = ''; + } + } + + // Do not apply css that would override inline styles (which are prioritary). var style = $target.attr('style') || ''; _.each(css, function (v,k) { - if (!(new RegExp('(^|;)\s*' + k).test(style))) { + if (!(new RegExp('(^|;)\\s*' + k).test(style))) { style = k+':'+v+';'+style; } }); @@ -258,6 +664,9 @@ function classToStyle($editable) { } else { $target.attr('style', style); } + if ($target.get(0).style.width) { + $target.attr('width', $target.css('width')); // Widths need to be applied as attributes as well. + } // Apple Mail if (node.nodeName === 'TD' && !node.childNodes.length) { $(node).html(' '); @@ -265,26 +674,8 @@ function classToStyle($editable) { // Outlook if (node.nodeName === 'A' && $target.hasClass('btn') && !$target.hasClass('btn-link') && !$target.children().length) { - var $hack = $('
'); - $hack.find('td') - .attr('height', $target.outerHeight()) - .css({ - 'text-align': $target.parent().css('text-align'), - 'margin': $target.css('padding'), - 'border-radius': $target.css('border-radius'), - 'background-color': $target.css('background-color'), - }); - $target.after($hack); - $target.appendTo($hack.find('td')); - // the space add a line when it's a table but it's invisible when it's a link - node = $hack[0].previousSibling; - if (node && node.nodeType === Node.TEXT_NODE && !node.textContent.match(/\S/)) { - $(node).remove(); - } - node = $hack[0].nextSibling; - if (node && node.nodeType === Node.TEXT_NODE && !node.textContent.match(/\S/)) { - $(node).remove(); - } + $target.prepend(``); + $target.append(``); } else if (node.nodeName === 'IMG' && $target.is('.mx-auto.d-block')) { $target.wrap('

'); @@ -292,6 +683,33 @@ function classToStyle($editable) { }); } +// Note: ignores rgba colors, which are not supported in Outlook. +function normalizeColors($editable) { + for (const node of $editable.find('[style*="rgb"]')) { + const rgbMatch = node.getAttribute('style').match(/rgb?\(([\d\.]*,?\s?){3,4}\)/g); + for (const rgb of rgbMatch || []) { + node.setAttribute('style', node.getAttribute('style').replace(rgb, rgbToHex(rgb))); + } + } +} + +/** + * Converts all css values that use the rem unit to px. + * + * @param {JQuery} $editable + */ +function normalizeRem($editable) { + const rootFontSizeProperty = $editable.closest('html').css('font-size'); + const rootFontSize = parseFloat(rootFontSizeProperty.replace(/[^\d\.]/g, '')); + for (const node of $editable.find('[style*="rem"]')) { + const remMatch = node.getAttribute('style').match(/[\d\.]+\s*rem/g); + for (const rem of remMatch || []) { + const remValue = parseFloat(rem.replace(/[^\d\.]/g, '')); + node.setAttribute('style', node.getAttribute('style').replace(rem, remValue * rootFontSize + 'px')); + } + } +} + /** * Converts css display for attachment link to real image. * Without this post process, the display depends on the css and the picture @@ -353,13 +771,20 @@ FieldHtml.include({ attachmentThumbnailToLinkImg($editable); fontToImg($editable); classToStyle($editable); + bootstrapToTable($editable); + cardToTable($editable); + listGroupToTable($editable); + addTables($editable); + formatTables($editable); + normalizeColors($editable); + normalizeRem($editable); // fix outlook image rendering bug _.each(['width', 'height'], function(attribute) { - $editable.find('img[style*="width"], img[style*="height"]').attr(attribute, function(){ + $editable.find('img').attr(attribute, function(){ return $(this)[attribute](); }).css(attribute, function(){ - return $(this).get(0).style[attribute] || 'auto'; + return $(this).get(0).style[attribute] || $(this)[attribute]() || 'auto'; }); }); $odooEditor.addClass('odoo-editor'); @@ -372,7 +797,14 @@ FieldHtml.include({ return { fontToImg: fontToImg, + bootstrapToTable: bootstrapToTable, + cardToTable: cardToTable, + listGroupToTable: listGroupToTable, + addTables: addTables, + formatTables: formatTables, classToStyle: classToStyle, + normalizeColors: normalizeColors, + normalizeRem: normalizeRem, attachmentThumbnailToLinkImg: attachmentThumbnailToLinkImg, }; });