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(']*)?/?>', '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 °'); + }); }); });