diff --git a/src/constants/string_mappings.js b/src/constants/string_mappings.js
new file mode 100644
index 00000000000..de41701e641
--- /dev/null
+++ b/src/constants/string_mappings.js
@@ -0,0 +1,36 @@
+/**
+* Copyright 2012-2016, Plotly, Inc.
+* All rights reserved.
+*
+* This source code is licensed under the MIT license found in the
+* LICENSE file in the root directory of this source tree.
+*/
+
+
+'use strict';
+
+// N.B. HTML entities are listed without the leading '&' and trailing ';'
+
+module.exports = {
+
+ entityToUnicode: {
+ 'mu': 'μ',
+ 'amp': '&',
+ 'lt': '<',
+ 'gt': '>',
+ 'nbsp': ' ',
+ 'times': '×',
+ 'plusmn': '±',
+ 'deg': '°'
+ },
+
+ unicodeToEntity: {
+ '&': 'amp',
+ '<': 'lt',
+ '>': 'gt',
+ '"': 'quot',
+ '\'': '#x27',
+ '\/': '#x2F'
+ }
+
+};
diff --git a/src/lib/html2unicode.js b/src/lib/html2unicode.js
index c01ae754a19..fd76582077f 100644
--- a/src/lib/html2unicode.js
+++ b/src/lib/html2unicode.js
@@ -10,13 +10,7 @@
'use strict';
var toSuperScript = require('superscript-text');
-
-var ENTITIES = {
- 'mu': 'μ',
- 'amp': '&',
- 'lt': '<',
- 'gt': '>'
-};
+var stringMappings = require('../constants/string_mappings');
function fixSuperScript(x) {
var idx = 0;
@@ -40,6 +34,7 @@ function stripTags(x) {
}
function fixEntities(x) {
+ var entityToUnicode = stringMappings.entityToUnicode;
var idx = 0;
while((idx = x.indexOf('&', idx)) >= 0) {
@@ -49,7 +44,7 @@ function fixEntities(x) {
continue;
}
- var entity = ENTITIES[x.slice(idx + 1, nidx)];
+ var entity = entityToUnicode[x.slice(idx + 1, nidx)];
if(entity) {
x = x.slice(0, idx) + entity + x.slice(nidx + 1);
} else {
diff --git a/src/lib/svg_text_utils.js b/src/lib/svg_text_utils.js
index fa0382eb54b..8b8e37257d8 100644
--- a/src/lib/svg_text_utils.js
+++ b/src/lib/svg_text_utils.js
@@ -11,13 +11,11 @@
/* global MathJax:false */
-var Plotly = require('../plotly');
var d3 = require('d3');
var Lib = require('../lib');
var xmlnsNamespaces = require('../constants/xmlns_namespaces');
-
-var util = module.exports = {};
+var stringMappings = require('../constants/string_mappings');
// Append SVG
@@ -45,7 +43,7 @@ d3.selection.prototype.appendSVG = function(_svgString) {
// Text utilities
-util.html_entity_decode = function(s) {
+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
@@ -56,7 +54,7 @@ util.html_entity_decode = function(s) {
return replaced;
};
-util.xml_entity_encode = function(str) {
+exports.xml_entity_encode = function(str) {
return str.replace(/&(?!\w+;|\#[0-9]+;| \#x[0-9A-F]+;)/g, '&');
};
@@ -66,10 +64,11 @@ function getSize(_selection, _dimension) {
return _selection.node().getBoundingClientRect()[_dimension];
}
-util.convertToTspans = function(_context, _callback) {
+exports.convertToTspans = function(_context, _callback) {
var str = _context.text();
var converted = convertToSVG(str);
var that = _context;
+
// 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 = (!that.attr('data-notex')) && converted.match(/([^$]*)([$]+[^$]*[$]+)([^$]*)/);
@@ -112,7 +111,7 @@ util.convertToTspans = function(_context, _callback) {
}
if(tex) {
- var td = Plotly.Lib.getPlotDiv(that.node());
+ var td = Lib.getPlotDiv(that.node());
((td && td._promises) || []).push(new Promise(function(resolve) {
that.style({visibility: 'hidden'});
var config = {fontSize: parseInt(that.style('font-size'), 10)};
@@ -195,7 +194,7 @@ function cleanEscapesForTex(s) {
}
function texToSVG(_texString, _config, _callback) {
- var randomID = 'math-output-' + Plotly.Lib.randstr([], 64);
+ var randomID = 'math-output-' + Lib.randstr([], 64);
var tmpDiv = d3.select('body').append('div')
.attr({id: randomID})
.style({visibility: 'hidden', position: 'absolute'})
@@ -236,22 +235,48 @@ var PROTOCOLS = ['http:', 'https:', 'mailto:'];
var STRIP_TAGS = new RegExp('?(' + Object.keys(TAG_STYLES).join('|') + ')( [^>]*)?/?>', 'g');
-util.plainText = function(_str) {
+var ENTITY_TO_UNICODE = Object.keys(stringMappings.entityToUnicode).map(function(k) {
+ return {
+ regExp: new RegExp('&' + k + ';', 'g'),
+ sub: stringMappings.entityToUnicode[k]
+ };
+});
+
+var UNICODE_TO_ENTITY = Object.keys(stringMappings.unicodeToEntity).map(function(k) {
+ return {
+ regExp: new RegExp(k, 'g'),
+ sub: '&' + stringMappings.unicodeToEntity[k] + ';'
+ };
+});
+
+exports.plainText = function(_str) {
// strip out our pseudo-html so we have a readable
// version to put into text fields
return (_str || '').replace(STRIP_TAGS, ' ');
};
+function replaceFromMapObject(_str, list) {
+ var out = _str || '';
+
+ for(var i = 0; i < list.length; i++) {
+ var item = list[i];
+ out = out.replace(item.regExp, item.sub);
+ }
+
+ return out;
+}
+
+function convertEntities(_str) {
+ return replaceFromMapObject(_str, ENTITY_TO_UNICODE);
+}
+
function encodeForHTML(_str) {
- return (_str || '').replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''')
- .replace(/\//g, '/');
+ return replaceFromMapObject(_str, UNICODE_TO_ENTITY);
}
function convertToSVG(_str) {
+ _str = convertEntities(_str);
+
var result = _str
.split(/(<[^<>]*>)/).map(function(d) {
var match = d.match(/<(\/?)([^ >]*)\s*(.*)>/i),
@@ -270,6 +295,7 @@ function convertToSVG(_str) {
* resurrect it.
*/
extraStyle = extra.match(/^style\s*=\s*"([^"]+)"\s*/i);
+
// anchor and br are the only ones that don't turn into a tspan
if(tag === 'a') {
if(close) return '';
@@ -316,7 +342,7 @@ function convertToSVG(_str) {
}
}
else {
- return Plotly.util.xml_entity_encode(d).replace(/green eggs",
- "& ham",
+ "& ham",
"H2O",
"Gorgonzola"
],
diff --git a/test/jasmine/tests/svg_text_utils_test.js b/test/jasmine/tests/svg_text_utils_test.js
index 7daa24f4bfb..3468f65317c 100644
--- a/test/jasmine/tests/svg_text_utils_test.js
+++ b/test/jasmine/tests/svg_text_utils_test.js
@@ -121,34 +121,33 @@ describe('svg+text utils', function() {
});
it('wrap XSS attacks in href', function() {
- var node = mockTextSVGElement(
+ var textCases = [
+ 'Subtitle',
'Subtitle'
- );
-
- expect(node.text()).toEqual('Subtitle');
- assertAnchorAttrs(node);
- assertAnchorLink(node, 'XSS onmouseover=alert(1) style=font-size:300px');
- });
+ ];
- it('wrap XSS attacks with quoted entities in href', function() {
- var node = mockTextSVGElement(
- 'Subtitle'
- );
+ textCases.forEach(function(textCase) {
+ var node = mockTextSVGElement(textCase);
- console.log(node.select('a').attr('xlink:href'));
- expect(node.text()).toEqual('Subtitle');
- assertAnchorAttrs(node);
- assertAnchorLink(node, 'XSS" onmouseover="alert(1)" style="font-size:300px');
+ expect(node.text()).toEqual('Subtitle');
+ assertAnchorAttrs(node);
+ assertAnchorLink(node, 'XSS onmouseover=alert(1) style=font-size:300px');
+ });
});
it('should keep query parameters in href', function() {
- var node = mockTextSVGElement(
- 'abc.com?shared-key'
- );
+ var textCases = [
+ 'abc.com?shared-key',
+ 'abc.com?shared-key'
+ ];
- assertAnchorAttrs(node);
- expect(node.text()).toEqual('abc.com?shared-key');
- assertAnchorLink(node, 'https://abc.com/myFeature.jsp?name=abc&pwd=def');
+ textCases.forEach(function(textCase) {
+ var node = mockTextSVGElement(textCase);
+
+ assertAnchorAttrs(node);
+ expect(node.text()).toEqual('abc.com?shared-key');
+ assertAnchorLink(node, 'https://abc.com/myFeature.jsp?name=abc&pwd=def');
+ });
});
it('allow basic spans', function() {
@@ -195,5 +194,14 @@ describe('svg+text utils', function() {
expect(node.text()).toEqual('text');
assertTspanStyle(node, 'quoted: yeah&\';;');
});
+
+ it('decode some HTML entities in text', function() {
+ var node = mockTextSVGElement(
+ '100μ & < 10 > 0 ' +
+ '100 × 20 ± 0.5 °'
+ );
+
+ expect(node.text()).toEqual('100μ & < 10 > 0 100 × 20 ± 0.5 °');
+ });
});
});