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 = [
- ''
- ].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(
- 'SO4' +
- '' +
+ '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);
+ });
});
});
});