diff --git a/src/lib/svg_text_utils.js b/src/lib/svg_text_utils.js index e1708b5156a..9494b1fa0bc 100644 --- a/src/lib/svg_text_utils.js +++ b/src/lib/svg_text_utils.js @@ -17,76 +17,20 @@ var Lib = require('../lib'); var xmlnsNamespaces = require('../constants/xmlns_namespaces'); var stringMappings = require('../constants/string_mappings'); -var DOM_PARSER; - -exports.getDOMParser = function() { - if(DOM_PARSER) { - return DOM_PARSER; - } else if(window.DOMParser) { - DOM_PARSER = new window.DOMParser(); - return DOM_PARSER; - } else { - throw new Error('Cannot initialize DOMParser'); - } -}; - -// Append SVG - -d3.selection.prototype.appendSVG = function(_svgString) { - var skeleton = [ - '', - _svgString, - '' - ].join(''); - - var domParser = exports.getDOMParser(); - var dom = domParser.parseFromString(skeleton, 'application/xml'); - var childNode = dom.documentElement.firstChild; - - while(childNode) { - this.node().appendChild(this.node().ownerDocument.importNode(childNode, true)); - childNode = childNode.nextSibling; - } - if(dom.querySelector('parsererror')) { - Lib.log(dom.querySelector('parsererror div').textContent); - return null; - } - return d3.select(this.node().lastChild); -}; - -// Text utilities - -exports.html_entity_decode = function(s) { - var hiddenDiv = d3.select('body').append('div').style({display: 'none'}).html(''); - var replaced = s.replace(/(&[^;]*;)/gi, function(d) { - if(d === '<') { return '<'; } // special handling for brackets - if(d === '&rt;') { return '>'; } - if(d.indexOf('<') !== -1 || d.indexOf('>') !== -1) { return ''; } - return hiddenDiv.html(d).text(); // everything else, let the browser decode it to unicode - }); - hiddenDiv.remove(); - return replaced; -}; - -exports.xml_entity_encode = function(str) { - return str.replace(/&(?!\w+;|\#[0-9]+;| \#x[0-9A-F]+;)/g, '&'); -}; - // text converter function getSize(_selection, _dimension) { return _selection.node().getBoundingClientRect()[_dimension]; } +var FIND_TEX = /([^$]*)([$]+[^$]*[$]+)([^$]*)/; + exports.convertToTspans = function(_context, gd, _callback) { var str = _context.text(); - var converted = convertToSVG(str); // Until we get tex integrated more fully (so it can be used along with non-tex) // allow some elements to prohibit it by attaching 'data-notex' to the original - var tex = (!_context.attr('data-notex')) && converted.match(/([^$]*)([$]+[^$]*[$]+)([^$]*)/); - var result = str; + var tex = (!_context.attr('data-notex')) && str.match(FIND_TEX); var parent = d3.select(_context.node().parentNode); if(parent.empty()) return; var svgClass = (_context.attr('class')) ? _context.attr('class').split(' ')[0] : 'text'; @@ -106,11 +50,9 @@ exports.convertToTspans = function(_context, gd, _callback) { 'white-space': 'pre' }); - result = _context.appendSVG(converted); - - if(!result) _context.text(str); + var hasLink = buildSVGText(_context.node(), str); - if(_context.select('a').size()) { + if(hasLink) { // at least in Chrome, pointer-events does not seem // to be honored in children of elements // so if we have an anchor, we have to make the @@ -198,9 +140,12 @@ exports.convertToTspans = function(_context, gd, _callback) { // MathJax +var LT_MATCH = /(<|<|<)/g; +var GT_MATCH = /(>|>|>)/g; + function cleanEscapesForTex(s) { - return s.replace(/(<|<|<)/g, '\\lt ') - .replace(/(>|>|>)/g, '\\gt '); + return s.replace(LT_MATCH, '\\lt ') + .replace(GT_MATCH, '\\gt '); } function texToSVG(_texString, _config, _callback) { @@ -231,21 +176,27 @@ var TAG_STYLES = { // would like to use baseline-shift for sub/sup but FF doesn't support it // so we need to use dy along with the uber hacky shift-back-to // baseline below - sup: 'font-size:70%" dy="-0.6em', - sub: 'font-size:70%" dy="0.3em', + sup: 'font-size:70%', + sub: 'font-size:70%', b: 'font-weight:bold', i: 'font-style:italic', a: 'cursor:pointer', span: '', - br: '', em: 'font-style:italic;font-weight:bold' }; -// sub/sup: extra tspan with zero-width space to get back to the right baseline -var TAG_CLOSE = { - sup: '', - sub: '' +// baseline shifts for sub and sup +var SHIFT_DY = { + sub: '0.3em', + sup: '-0.6em' }; +// reset baseline by adding a tspan (empty except for a zero-width space) +// with dy of -70% * SHIFT_DY (because font-size=70%) +var RESET_DY = { + sub: '-0.21em', + sup: '0.42em' +}; +var ZERO_WIDTH_SPACE = '\u200b'; /* * Whitelist of protocols in user-supplied urls. Mostly we want to avoid javascript @@ -264,19 +215,14 @@ var ENTITY_TO_UNICODE = Object.keys(stringMappings.entityToUnicode).map(function }; }); -var UNICODE_TO_ENTITY = Object.keys(stringMappings.unicodeToEntity).map(function(k) { - return { - regExp: new RegExp(k, 'g'), - sub: '&' + stringMappings.unicodeToEntity[k] + ';' - }; -}); - var NEWLINES = /(\r\n?|\n)/g; var SPLIT_TAGS = /(<[^<>]*>)/; var ONE_TAG = /<(\/?)([^ >]*)(\s+(.*))?>/i; +var BR_TAG = //i; + /* * style and href: pull them out of either single or double quotes. Also * - target: (_blank|_self|_parent|_top|framename) @@ -307,7 +253,6 @@ function getQuotedMatch(_str, re) { return match && (match[3] || match[4]); } - var COLORMATCH = /(^|;)\s*color:/; exports.plainText = function(_str) { @@ -331,12 +276,18 @@ function convertEntities(_str) { return replaceFromMapObject(_str, ENTITY_TO_UNICODE); } -function encodeForHTML(_str) { - return replaceFromMapObject(_str, UNICODE_TO_ENTITY); -} - -function convertToSVG(_str) { - _str = convertEntities(_str) +/* + * buildSVGText: convert our pseudo-html into SVG tspan elements, and attach these + * to containerNode + * + * @param {svg text element} containerNode: the node to insert this text into + * @param {string} str: the pseudo-html string to convert to svg + * + * @returns {bool}: does the result contain any links? We need to handle the text element + * somewhat differently if it does, so just keep track of this when it happens. + */ +function buildSVGText(containerNode, str) { + str = convertEntities(str) /* * Normalize behavior between IE and others wrt newlines and whitespace:pre * this combination makes IE barf https://github.com/plotly/plotly.js/issues/746 @@ -346,145 +297,166 @@ function convertToSVG(_str) { */ .replace(NEWLINES, ' '); - var result = _str - .split(SPLIT_TAGS).map(function(d) { - var match = d.match(ONE_TAG); - var tag = match && match[2].toLowerCase(); - var tagStyle = TAG_STYLES[tag]; - - if(tagStyle !== undefined) { - var isClose = match[1]; - if(isClose) return (tag === 'a' ? '' : '') + (TAG_CLOSE[tag] || ''); - - // break: later we'll turn these into newline s - // but we need to know about all the other tags first - if(tag === 'br') return '
'; - - /** - * extra includes href and any random extra css (that's supported by svg) - * use this like to change font in the middle - * - * at one point we supported but as this isn't even - * valid HTML anymore and we dropped it accidentally for many months, we will not - * resurrect it. - */ - var extra = match[4]; + var hasLink = false; - var out; + // as we're building the text, keep track of what elements we're nested inside + // nodeStack will be an array of {node, type, style, href, target, popup} + // where only type: 'a' gets the last 3 and node is only added when it's created + var nodeStack = []; + var currentNode; + var currentLine = -1; - // anchor is the only tag that doesn't turn into a tspan - if(tag === 'a') { - var href = getQuotedMatch(extra, HREFMATCH); + function newLine() { + currentLine++; - out = ' 1) { + for(var i = 1; i < oldNodeStack.length; i++) { + enterNode(oldNodeStack[i]); + } + } + } + + function enterNode(nodeSpec) { + var type = nodeSpec.type; + var nodeAttrs = {}; + var nodeType; + + if(type === 'a') { + nodeType = 'a'; + var target = nodeSpec.target; + var href = nodeSpec.href; + var popup = nodeSpec.popup; + if(href) { + nodeAttrs = { + 'xlink:xlink:show': (target === '_blank' || target.charAt(0) !== '_') ? 'new' : 'replace', + target: target, + 'xlink:xlink:href': href + }; + if(popup) { + nodeAttrs.onclick = 'window.open("' + href + '","' + target + '","' + + popup + '");return false;'; } + } + } + else nodeType = 'tspan'; + + if(nodeSpec.style) nodeAttrs.style = nodeSpec.style; + + var newNode = document.createElementNS(xmlnsNamespaces.svg, nodeType); + + if(type === 'sup' || type === 'sub') { + addTextNode(currentNode, ZERO_WIDTH_SPACE); + currentNode.appendChild(newNode); + + var resetter = document.createElementNS(xmlnsNamespaces.svg, 'tspan'); + addTextNode(resetter, ZERO_WIDTH_SPACE); + d3.select(resetter).attr('dy', RESET_DY[type]); + nodeAttrs.dy = SHIFT_DY[type]; + + currentNode.appendChild(newNode); + currentNode.appendChild(resetter); + } + else { + currentNode.appendChild(newNode); + } + + d3.select(newNode).attr(nodeAttrs); + + currentNode = nodeSpec.node = newNode; + nodeStack.push(nodeSpec); + } + + function addTextNode(node, text) { + node.appendChild(document.createTextNode(text)); + } + + function exitNode(type) { + var innerNode = nodeStack.pop(); + if(type !== innerNode.type) { + Lib.log('Start tag <' + innerNode.type + '> doesnt match end tag <' + + type + '>. Pretending it did match.', str); + } + currentNode = nodeStack[nodeStack.length - 1].node; + } + + var hasLines = BR_TAG.test(str); + + if(hasLines) newLine(); + else { + currentNode = containerNode; + nodeStack = [{node: containerNode}]; + } + + var parts = str.split(SPLIT_TAGS); + for(var i = 0; i < parts.length; i++) { + var parti = parts[i]; + var match = parti.match(ONE_TAG); + var tagType = match && match[2].toLowerCase(); + var tagStyle = TAG_STYLES[tagType]; + + if(tagType === 'br') { + newLine(); + } + else if(tagStyle === undefined) { + addTextNode(currentNode, parti); + } + else { + // tag - open or close + if(match[1]) { + exitNode(tagType); + } + else { + var extra = match[4]; + + var nodeSpec = {type: tagType}; // now add style, from both the tag name and any extra css // Most of the svg css that users will care about is just like html, // but font color is different (uses fill). Let our users ignore this. var css = getQuotedMatch(extra, STYLEMATCH); if(css) { - css = encodeForHTML(css.replace(COLORMATCH, '$1 fill:')); + css = css.replace(COLORMATCH, '$1 fill:'); if(tagStyle) css += ';' + tagStyle; } else if(tagStyle) css = tagStyle; - if(css) return out + ' style="' + css + '">'; + if(css) nodeSpec.style = css; - return out + '>'; - } - else { - return exports.xml_entity_encode(d).replace(/ which isn't a tspan even now!) - // we should really do this in a type-aware way *before* converting to tspans. - var indices = []; - for(var index = result.indexOf('
'); index > 0; index = result.indexOf('
', index + 1)) { - indices.push(index); - } - var count = 0; - indices.forEach(function(d) { - var brIndex = d + count; - var search = result.slice(0, brIndex); - var previousOpenTag = ''; - for(var i2 = search.length - 1; i2 >= 0; i2--) { - var isTag = search[i2].match(/<(\/?).*>/i); - if(isTag && search[i2] !== '
') { - if(!isTag[1]) previousOpenTag = search[i2]; - break; + var href = getQuotedMatch(extra, HREFMATCH); + + if(href) { + // check safe protocols + var dummyAnchor = document.createElement('a'); + dummyAnchor.href = href; + if(PROTOCOLS.indexOf(dummyAnchor.protocol) !== -1) { + nodeSpec.href = href; + nodeSpec.target = getQuotedMatch(extra, TARGETMATCH) || '_blank'; + nodeSpec.popup = getQuotedMatch(extra, POPUPMATCH); + } + } + } + + enterNode(nodeSpec); } } - if(previousOpenTag) { - result.splice(brIndex + 1, 0, previousOpenTag); - result.splice(brIndex, 0, '
'); - count += 2; - } - }); - - var joined = result.join(''); - var splitted = joined.split(/
/gi); - if(splitted.length > 1) { - result = splitted.map(function(d, i) { - // TODO: figure out max font size of this line and alter dy - // this requires either: - // 1) bringing the base font size into convertToTspans, or - // 2) only allowing relative percentage font sizes. - // I think #2 is the way to go - return '' + d + ''; - }); } - return result.join(''); + return hasLink; } function alignHTMLWith(_base, container, options) { diff --git a/src/snapshot/tosvg.js b/src/snapshot/tosvg.js index e5db4f6cd0e..386d45566cb 100644 --- a/src/snapshot/tosvg.js +++ b/src/snapshot/tosvg.js @@ -11,7 +11,6 @@ var d3 = require('d3'); -var svgTextUtils = require('../lib/svg_text_utils'); var Drawing = require('../components/drawing'); var Color = require('../components/color'); @@ -20,6 +19,21 @@ var DOUBLEQUOTE_REGEX = /"/g; var DUMMY_SUB = 'TOBESTRIPPED'; var DUMMY_REGEX = new RegExp('("' + DUMMY_SUB + ')|(' + DUMMY_SUB + '")', 'g'); +function htmlEntityDecode(s) { + var hiddenDiv = d3.select('body').append('div').style({display: 'none'}).html(''); + var replaced = s.replace(/(&[^;]*;)/gi, function(d) { + if(d === '<') { return '<'; } // special handling for brackets + if(d === '&rt;') { return '>'; } + if(d.indexOf('<') !== -1 || d.indexOf('>') !== -1) { return ''; } + return hiddenDiv.html(d).text(); // everything else, let the browser decode it to unicode + }); + hiddenDiv.remove(); + return replaced; +} + +function xmlEntityEncode(str) { + return str.replace(/&(?!\w+;|\#[0-9]+;| \#x[0-9A-F]+;)/g, '&'); +} module.exports = function toSVG(gd, format) { var fullLayout = gd._fullLayout, @@ -121,8 +135,8 @@ module.exports = function toSVG(gd, format) { svg.node().setAttributeNS(xmlnsNamespaces.xmlns, 'xmlns:xlink', xmlnsNamespaces.xlink); var s = new window.XMLSerializer().serializeToString(svg.node()); - s = svgTextUtils.html_entity_decode(s); - s = svgTextUtils.xml_entity_encode(s); + s = htmlEntityDecode(s); + s = xmlEntityEncode(s); // Fix quotations around font strings and gradient URLs s = s.replace(DUMMY_REGEX, '\''); diff --git a/test/jasmine/tests/svg_text_utils_test.js b/test/jasmine/tests/svg_text_utils_test.js index d85552f0c32..eadbf80e842 100644 --- a/test/jasmine/tests/svg_text_utils_test.js +++ b/test/jasmine/tests/svg_text_utils_test.js @@ -11,7 +11,7 @@ describe('svg+text utils', function() { function mockTextSVGElement(txt) { return d3.select('body') .append('svg') - .attr('id', 'text') + .classed('text-tester', true) .append('text') .text(txt) .call(util.convertToTspans) @@ -74,7 +74,7 @@ describe('svg+text utils', function() { } afterEach(function() { - d3.select('#text').remove(); + d3.selectAll('.text-tester').remove(); }); it('checks for XSS attack in href', function() { @@ -190,7 +190,7 @@ describe('svg+text utils', function() { it('attaches onclick if popup is specified', function() { var node = mockTextSVGElement('link'); assertAnchorLink(node, 'x', 'fred', 'new'); - assertAnchorAttrs(node, {onclick: 'window.open(\'x\',\'fred\',\'width=500,height=400\');return false;'}); + assertAnchorAttrs(node, {onclick: 'window.open("x","fred","width=500,height=400");return false;'}); }); it('keeps query parameters in href', function() { @@ -265,24 +265,24 @@ describe('svg+text utils', function() { it('supports superscript by itself', function() { var node = mockTextSVGElement('123'); expect(node.html()).toBe( - '​123' + - ''); + '\u200b123' + + '\u200b'); }); it('supports subscript by itself', function() { var node = mockTextSVGElement('123'); expect(node.html()).toBe( - '​123' + - ''); + '\u200b123' + + '\u200b'); }); it('supports superscript and subscript together with normal text', function() { var node = mockTextSVGElement('SO42-'); expect(node.html()).toBe( - 'SO​4' + - '​' + + 'SO\u200b4' + + '\u200b\u200b' + '2-' + - ''); + '\u200b'); }); it('allows one to span
s', function() { @@ -300,11 +300,36 @@ describe('svg+text utils', function() { it('allows one to span
s', function() { var node = mockTextSVGElement('SO4
44
'); expect(node.html()).toBe( - 'SO​' + - '4' + - '​' + + 'SO\u200b' + + '4' + + '\u200b' + + '\u200b' + '44' + - ''); + '\u200b'); + }); + + it('allows nested tags to break at
, eventually closed or not', function() { + var textCases = [ + 'many
lines
modified', + 'many
lines
modified
', + 'many
lines

modified', + ]; + + textCases.forEach(function(textCase) { + var node = mockTextSVGElement(textCase); + function opener(dy) { + return '' + + '' + + '' + + '\u200b'; + } + var closer = '\u200b' + + ''; + expect(node.html()).toBe( + opener(0) + 'many' + closer + + opener(1.3) + 'lines' + closer + + opener(2.6) + 'modified' + closer, textCase); + }); }); }); });