diff --git a/src/snapshot/tosvg.js b/src/snapshot/tosvg.js
index 75cd7086b98..4a172f907ea 100644
--- a/src/snapshot/tosvg.js
+++ b/src/snapshot/tosvg.js
@@ -9,17 +9,39 @@ var Color = require('../components/color');
var xmlnsNamespaces = require('../constants/xmlns_namespaces');
var DOUBLEQUOTE_REGEX = /"/g;
var DUMMY_SUB = 'TOBESTRIPPED';
-var DUMMY_REGEX = new RegExp('("' + DUMMY_SUB + ')|(' + DUMMY_SUB + '")', 'g');
-
+// Match TOBESTRIPPED adjacent to either a literal " or its entity form ".
+// XMLSerializer escapes inner double-quotes to " inside "-delimited
+// attributes, and htmlEntityDecode now preserves that entity for safety.
+const DUMMY_REGEX = new RegExp(`("${DUMMY_SUB})|(${DUMMY_SUB}")|("${DUMMY_SUB})|(${DUMMY_SUB}")`, 'g');
+
+// Entities for & " ' - decoding these in attribute context is an XSS vector,
+// so preserve them as-is. List includes named, decimal, and hex numeric forms.
+const PRESERVED_ENTITIES = ['&', '&', '&', '"', '"', '"', ''', ''', '''];
+// Entities for < and > - normalize to numeric so downstream passes treat them
+// uniformly regardless of which form the serializer emitted.
+const LESS_THAN_ENTITIES = ['<', '<', '<'];
+const GREATER_THAN_ENTITIES = ['>', '>', '>'];
+
+/**
+ * Decode non-structural entities to Unicode for non-browser SVG renderers,
+ * keeping & " ' < > entity-encoded to prevent attribute-context escape (XSS).
+ *
+ * @param s - serialized SVG string
+ * @returns entity-normalized SVG string
+ */
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 ''; }
+ const hiddenDiv = d3.select('body').append('div').style({ display: 'none' }).html('');
+ const replaced = s.replace(/(&[^;]*;)/gi, (d) => {
+ const lower = d.toLowerCase();
+ if (PRESERVED_ENTITIES.includes(lower)) return d;
+ if (LESS_THAN_ENTITIES.includes(lower)) return '<';
+ if (GREATER_THAN_ENTITIES.includes(lower)) return '>';
+ if (d.includes('<') || d.includes('>')) return '';
+
return hiddenDiv.html(d).text(); // everything else, let the browser decode it to unicode
});
hiddenDiv.remove();
+
return replaced;
}
@@ -48,29 +70,29 @@ module.exports = function toSVG(gd, format, scale) {
// which notably add the contents of the gl-container
// into the main svg node
var basePlotModules = fullLayout._basePlotModules || [];
- for(i = 0; i < basePlotModules.length; i++) {
+ for (i = 0; i < basePlotModules.length; i++) {
var _module = basePlotModules[i];
- if(_module.toSVG) _module.toSVG(gd);
+ if (_module.toSVG) _module.toSVG(gd);
}
// add top items above them assumes everything in toppaper is either
// a group or a defs, and if it's empty (like hoverlayer) we can ignore it.
- if(toppaper) {
+ if (toppaper) {
var nodes = toppaper.node().childNodes;
// make copy of nodes as childNodes prop gets mutated in loop below
var topGroups = Array.prototype.slice.call(nodes);
- for(i = 0; i < topGroups.length; i++) {
+ for (i = 0; i < topGroups.length; i++) {
var topGroup = topGroups[i];
- if(topGroup.childNodes.length) svg.node().appendChild(topGroup);
+ if (topGroup.childNodes.length) svg.node().appendChild(topGroup);
}
}
// remove draglayer for Adobe Illustrator compatibility
- if(fullLayout._draggers) {
+ if (fullLayout._draggers) {
fullLayout._draggers.remove();
}
@@ -80,81 +102,82 @@ module.exports = function toSVG(gd, format, scale) {
svg.node().style.background = '';
svg.selectAll('text')
- .attr({'data-unformatted': null, 'data-math': null})
- .each(function() {
+ .attr({ 'data-unformatted': null, 'data-math': null })
+ .each(function () {
var txt = d3.select(this);
// hidden text is pre-formatting mathjax, the browser ignores it
// but in a static plot it's useless and it can confuse batik
// we've tried to standardize on display:none but make sure we still
// catch visibility:hidden if it ever arises
- if(this.style.visibility === 'hidden' || this.style.display === 'none') {
+ if (this.style.visibility === 'hidden' || this.style.display === 'none') {
txt.remove();
return;
} else {
// clear other visibility/display values to default
// to not potentially confuse non-browser SVG implementations
- txt.style({visibility: null, display: null});
+ txt.style({ visibility: null, display: null });
}
// Font family styles break things because of quotation marks,
// so we must remove them *after* the SVG DOM has been serialized
// to a string (browsers convert singles back)
var ff = this.style.fontFamily;
- if(ff && ff.indexOf('"') !== -1) {
+ if (ff && ff.indexOf('"') !== -1) {
txt.style('font-family', ff.replace(DOUBLEQUOTE_REGEX, DUMMY_SUB));
}
// Drop normal font-weight, font-style and font-variant to reduce the size
var fw = this.style.fontWeight;
- if(fw && (fw === 'normal' || fw === '400')) { // font-weight 400 is similar to normal
+ if (fw && (fw === 'normal' || fw === '400')) {
+ // font-weight 400 is similar to normal
txt.style('font-weight', undefined);
}
var fs = this.style.fontStyle;
- if(fs && fs === 'normal') {
+ if (fs && fs === 'normal') {
txt.style('font-style', undefined);
}
var fv = this.style.fontVariant;
- if(fv && fv === 'normal') {
+ if (fv && fv === 'normal') {
txt.style('font-variant', undefined);
}
});
- svg.selectAll('.gradient_filled,.pattern_filled').each(function() {
+ svg.selectAll('.gradient_filled,.pattern_filled').each(function () {
var pt = d3.select(this);
// similar to font family styles above,
// we must remove " after the SVG DOM has been serialized
var fill = this.style.fill;
- if(fill && fill.indexOf('url(') !== -1) {
+ if (fill && fill.indexOf('url(') !== -1) {
pt.style('fill', fill.replace(DOUBLEQUOTE_REGEX, DUMMY_SUB));
}
var stroke = this.style.stroke;
- if(stroke && stroke.indexOf('url(') !== -1) {
+ if (stroke && stroke.indexOf('url(') !== -1) {
pt.style('stroke', stroke.replace(DOUBLEQUOTE_REGEX, DUMMY_SUB));
}
});
- if(format === 'pdf' || format === 'eps') {
+ if (format === 'pdf' || format === 'eps') {
// these formats make the extra line MathJax adds around symbols look super thick in some cases
// it looks better if this is removed entirely.
- svg.selectAll('#MathJax_SVG_glyphs path')
- .attr('stroke-width', 0);
+ svg.selectAll('#MathJax_SVG_glyphs path').attr('stroke-width', 0);
}
- if(format === 'svg' && scale) {
+ if (format === 'svg' && scale) {
svg.attr('width', scale * width);
svg.attr('height', scale * height);
svg.attr('viewBox', '0 0 ' + width + ' ' + height);
}
var s = new window.XMLSerializer().serializeToString(svg.node());
+ // Decode numeric refs to Unicode so non-browser renderers (Batik, Illustrator) render them correctly.
s = htmlEntityDecode(s);
s = xmlEntityEncode(s);
// Fix quotations around font strings and gradient URLs
- s = s.replace(DUMMY_REGEX, '\'');
+ s = s.replace(DUMMY_REGEX, "'");
return s;
};
diff --git a/test/jasmine/tests/toimage_test.js b/test/jasmine/tests/toimage_test.js
index 447aa4d3f8d..88dbe14b30f 100644
--- a/test/jasmine/tests/toimage_test.js
+++ b/test/jasmine/tests/toimage_test.js
@@ -15,30 +15,34 @@ var pieAutoMargin = require('../../image/mocks/pie_automargin');
var FORMATS = ['png', 'jpeg', 'webp', 'svg'];
-describe('Plotly.toImage', function() {
+describe('Plotly.toImage', function () {
'use strict';
var gd;
- beforeEach(function() {
+ beforeEach(function () {
gd = createGraphDiv();
});
afterEach(destroyGraphDiv);
function createImage(url) {
- return new Promise(function(resolve, reject) {
+ return new Promise(function (resolve, reject) {
var img = document.createElement('img');
img.src = url;
- img.onload = function() { return resolve(img); };
- img.onerror = function() { return reject('error during createImage'); };
+ img.onload = function () {
+ return resolve(img);
+ };
+ img.onerror = function () {
+ return reject('error during createImage');
+ };
});
}
function assertSize(url, width, height) {
- return new Promise(function(resolve, reject) {
+ return new Promise(function (resolve, reject) {
var img = new Image();
- img.onload = function() {
+ img.onload = function () {
expect(img.width).toBe(width, 'image width');
expect(img.height).toBe(height, 'image height');
resolve(url);
@@ -48,295 +52,385 @@ describe('Plotly.toImage', function() {
});
}
- it('should be attached to Plotly', function() {
+ it('should be attached to Plotly', function () {
expect(Plotly.toImage).toBeDefined();
});
- it('should return a promise', function(done) {
+ it('should return a promise', function (done) {
function isPromise(x) {
return !!x.then && typeof x.then === 'function';
}
- var returnValue = Plotly.newPlot(gd, subplotMock.data, subplotMock.layout)
- .then(Plotly.toImage);
+ var returnValue = Plotly.newPlot(gd, subplotMock.data, subplotMock.layout).then(Plotly.toImage);
expect(isPromise(returnValue)).toBe(true);
returnValue.then(done, done.fail);
});
- it('should throw error with unsupported file type', function(done) {
+ it('should throw error with unsupported file type', function (done) {
var fig = Lib.extendDeep({}, subplotMock);
Plotly.newPlot(gd, fig.data, fig.layout)
- .then(function(gd) {
- expect(function() { Plotly.toImage(gd, {format: 'x'}); })
- .toThrow(new Error('Export format is not png, jpeg, webp, svg or full-json.'));
- })
- .then(done, done.fail);
+ .then(function (gd) {
+ expect(function () {
+ Plotly.toImage(gd, { format: 'x' });
+ }).toThrow(new Error('Export format is not png, jpeg, webp, svg or full-json.'));
+ })
+ .then(done, done.fail);
});
- it('should throw error with height and/or width < 1', function(done) {
+ it('should throw error with height and/or width < 1', function (done) {
var fig = Lib.extendDeep({}, subplotMock);
Plotly.newPlot(gd, fig.data, fig.layout)
- .then(function() {
- expect(function() { Plotly.toImage(gd, {height: 0.5}); })
- .toThrow(new Error('Height and width should be pixel values.'));
- })
- .then(function() {
- expect(function() { Plotly.toImage(gd, {width: 0.5}); })
- .toThrow(new Error('Height and width should be pixel values.'));
- })
- .then(done, done.fail);
+ .then(function () {
+ expect(function () {
+ Plotly.toImage(gd, { height: 0.5 });
+ }).toThrow(new Error('Height and width should be pixel values.'));
+ })
+ .then(function () {
+ expect(function () {
+ Plotly.toImage(gd, { width: 0.5 });
+ }).toThrow(new Error('Height and width should be pixel values.'));
+ })
+ .then(done, done.fail);
});
- it('should create img with proper height and width', function(done) {
+ it('should create img with proper height and width', function (done) {
var fig = Lib.extendDeep({}, subplotMock);
// specify height and width
fig.layout.height = 600;
fig.layout.width = 700;
- Plotly.newPlot(gd, fig.data, fig.layout).then(function(gd) {
- expect(gd.layout.height).toBe(600);
- expect(gd.layout.width).toBe(700);
- return Plotly.toImage(gd);
- })
- .then(createImage)
- .then(function(img) {
- expect(img.height).toBe(600);
- expect(img.width).toBe(700);
-
- return Plotly.toImage(gd, {height: 400, width: 400});
- })
- .then(createImage)
- .then(function(img) {
- expect(img.height).toBe(400);
- expect(img.width).toBe(400);
- })
- .then(done, done.fail);
+ Plotly.newPlot(gd, fig.data, fig.layout)
+ .then(function (gd) {
+ expect(gd.layout.height).toBe(600);
+ expect(gd.layout.width).toBe(700);
+ return Plotly.toImage(gd);
+ })
+ .then(createImage)
+ .then(function (img) {
+ expect(img.height).toBe(600);
+ expect(img.width).toBe(700);
+
+ return Plotly.toImage(gd, { height: 400, width: 400 });
+ })
+ .then(createImage)
+ .then(function (img) {
+ expect(img.height).toBe(400);
+ expect(img.width).toBe(400);
+ })
+ .then(done, done.fail);
});
- it('should use width/height of graph div when width/height are set to *null*', function(done) {
+ it('should use width/height of graph div when width/height are set to *null*', function (done) {
var fig = Lib.extendDeep({}, subplotMock);
gd.style.width = '832px';
gd.style.height = '502px';
- Plotly.newPlot(gd, fig.data, fig.layout).then(function() {
- expect(gd.layout.width).toBe(undefined, 'user layout width');
- expect(gd.layout.height).toBe(undefined, 'user layout height');
- expect(gd._fullLayout.width).toBe(832, 'full layout width');
- expect(gd._fullLayout.height).toBe(502, 'full layout height');
- })
- .then(function() { return Plotly.toImage(gd, {width: null, height: null}); })
- .then(function(url) { return assertSize(url, 832, 502); })
- .then(done, done.fail);
+ Plotly.newPlot(gd, fig.data, fig.layout)
+ .then(function () {
+ expect(gd.layout.width).toBe(undefined, 'user layout width');
+ expect(gd.layout.height).toBe(undefined, 'user layout height');
+ expect(gd._fullLayout.width).toBe(832, 'full layout width');
+ expect(gd._fullLayout.height).toBe(502, 'full layout height');
+ })
+ .then(function () {
+ return Plotly.toImage(gd, { width: null, height: null });
+ })
+ .then(function (url) {
+ return assertSize(url, 832, 502);
+ })
+ .then(done, done.fail);
});
- it('should create proper file type', function(done) {
+ it('should create proper file type', function (done) {
var fig = Lib.extendDeep({}, subplotMock);
Plotly.newPlot(gd, fig.data, fig.layout)
- .then(function() { return Plotly.toImage(gd, {format: 'png'}); })
- .then(function(url) { return assertSize(url, 700, 450); })
- .then(function(url) {
- expect(url.split('png')[0]).toBe('data:image/');
- })
- .then(function() { return Plotly.toImage(gd, {format: 'jpeg'}); })
- .then(function(url) { return assertSize(url, 700, 450); })
- .then(function(url) {
- expect(url.split('jpeg')[0]).toBe('data:image/');
- })
- .then(function() { return Plotly.toImage(gd, {format: 'svg'}); })
- .then(function(url) { return assertSize(url, 700, 450); })
- .then(function(url) {
- expect(url.split('svg')[0]).toBe('data:image/');
- })
- .then(function() { return Plotly.toImage(gd, {format: 'webp'}); })
- .then(function(url) { return assertSize(url, 700, 450); })
- .then(function(url) {
- expect(url.split('webp')[0]).toBe('data:image/');
- })
- .then(done, done.fail);
+ .then(function () {
+ return Plotly.toImage(gd, { format: 'png' });
+ })
+ .then(function (url) {
+ return assertSize(url, 700, 450);
+ })
+ .then(function (url) {
+ expect(url.split('png')[0]).toBe('data:image/');
+ })
+ .then(function () {
+ return Plotly.toImage(gd, { format: 'jpeg' });
+ })
+ .then(function (url) {
+ return assertSize(url, 700, 450);
+ })
+ .then(function (url) {
+ expect(url.split('jpeg')[0]).toBe('data:image/');
+ })
+ .then(function () {
+ return Plotly.toImage(gd, { format: 'svg' });
+ })
+ .then(function (url) {
+ return assertSize(url, 700, 450);
+ })
+ .then(function (url) {
+ expect(url.split('svg')[0]).toBe('data:image/');
+ })
+ .then(function () {
+ return Plotly.toImage(gd, { format: 'webp' });
+ })
+ .then(function (url) {
+ return assertSize(url, 700, 450);
+ })
+ .then(function (url) {
+ expect(url.split('webp')[0]).toBe('data:image/');
+ })
+ .then(done, done.fail);
});
- it('should strip *data:image* prefix when *imageDataOnly* is turned on', function(done) {
+ it('should strip *data:image* prefix when *imageDataOnly* is turned on', function (done) {
var fig = Lib.extendDeep({}, subplotMock);
Plotly.newPlot(gd, fig.data, fig.layout)
- .then(function() { return Plotly.toImage(gd, {format: 'png', imageDataOnly: true}); })
- .then(function(d) {
- expect(d.indexOf('data:image/')).toBe(-1);
- expect(d.length).toBeWithin(52500, 7500, 'png image length');
- })
- .then(function() { return Plotly.toImage(gd, {format: 'jpeg', imageDataOnly: true}); })
- .then(function(d) {
- expect(d.indexOf('data:image/')).toBe(-1);
- expect(d.length).toBeWithin(43251, 5e3, 'jpeg image length');
- })
- .then(function() { return Plotly.toImage(gd, {format: 'svg', imageDataOnly: true}); })
- .then(function(d) {
- expect(d.indexOf('data:image/')).toBe(-1);
- expect(d.length).toBeWithin(32062, 1e3, 'svg image length');
- })
- .then(function() { return Plotly.toImage(gd, {format: 'webp', imageDataOnly: true}); })
- .then(function(d) {
- expect(d.indexOf('data:image/')).toBe(-1);
- expect(d.length).toBeWithin(15831, 1e3, 'webp image length');
- })
- .then(done, done.fail);
+ .then(function () {
+ return Plotly.toImage(gd, { format: 'png', imageDataOnly: true });
+ })
+ .then(function (d) {
+ expect(d.indexOf('data:image/')).toBe(-1);
+ expect(d.length).toBeWithin(52500, 7500, 'png image length');
+ })
+ .then(function () {
+ return Plotly.toImage(gd, { format: 'jpeg', imageDataOnly: true });
+ })
+ .then(function (d) {
+ expect(d.indexOf('data:image/')).toBe(-1);
+ expect(d.length).toBeWithin(43251, 5e3, 'jpeg image length');
+ })
+ .then(function () {
+ return Plotly.toImage(gd, { format: 'svg', imageDataOnly: true });
+ })
+ .then(function (d) {
+ expect(d.indexOf('data:image/')).toBe(-1);
+ expect(d.length).toBeWithin(32520, 1e3, 'svg image length');
+ })
+ .then(function () {
+ return Plotly.toImage(gd, { format: 'webp', imageDataOnly: true });
+ })
+ .then(function (d) {
+ expect(d.indexOf('data:image/')).toBe(-1);
+ expect(d.length).toBeWithin(15831, 1e3, 'webp image length');
+ })
+ .then(done, done.fail);
});
- FORMATS.forEach(function(f) {
- it('should respond to *scale* option ( format ' + f + ')', function(done) {
+ FORMATS.forEach(function (f) {
+ it('should respond to *scale* option ( format ' + f + ')', function (done) {
var fig = Lib.extendDeep({}, subplotMock);
Plotly.newPlot(gd, fig.data, fig.layout)
- .then(function() { return Plotly.toImage(gd, {format: f, scale: 2}); })
- .then(function(url) { return assertSize(url, 1400, 900); })
- .then(function() { return Plotly.toImage(gd, {format: f, scale: 0.5}); })
- .then(function(url) { return assertSize(url, 350, 225); })
- .then(done, done.fail);
+ .then(function () {
+ return Plotly.toImage(gd, { format: f, scale: 2 });
+ })
+ .then(function (url) {
+ return assertSize(url, 1400, 900);
+ })
+ .then(function () {
+ return Plotly.toImage(gd, { format: f, scale: 0.5 });
+ })
+ .then(function (url) {
+ return assertSize(url, 350, 225);
+ })
+ .then(done, done.fail);
});
});
- it('should accept data/layout/config figure object as input', function(done) {
+ it('should accept data/layout/config figure object as input', function (done) {
var fig = Lib.extendDeep({}, subplotMock);
Plotly.toImage(fig)
- .then(createImage)
- .then(function(img) {
- expect(img.width).toBe(700);
- expect(img.height).toBe(450);
- })
- .then(done, done.fail);
+ .then(createImage)
+ .then(function (img) {
+ expect(img.width).toBe(700);
+ expect(img.height).toBe(450);
+ })
+ .then(done, done.fail);
});
- it('should accept graph div id as input', function(done) {
+ it('should accept graph div id as input', function (done) {
var fig = Lib.extendDeep({}, subplotMock);
Plotly.newPlot(gd, fig)
- .then(function() { return Plotly.toImage('graph'); })
- .then(createImage)
- .then(function(img) {
- expect(img.width).toBe(700);
- expect(img.height).toBe(450);
- })
- .then(done, done.fail);
+ .then(function () {
+ return Plotly.toImage('graph');
+ })
+ .then(createImage)
+ .then(function (img) {
+ expect(img.width).toBe(700);
+ expect(img.height).toBe(450);
+ })
+ .then(done, done.fail);
});
- it('should work on pages with ', function(done) {
+ describe('SVG export attribute escaping (XSS regression)', () => {
+ // Regression: pseudo-html style attributes encoded with numeric quote
+ // entities used to break out of the serialized SVG attribute context
+ // because htmlEntityDecode() ran after XMLSerializer and un-escaped
+ // ". See src/snapshot/tosvg.js htmlEntityDecode.
+ const parser = new DOMParser();
+
+ const expectNoEventHandlerAttrs = (svg) => {
+ const doc = parser.parseFromString(svg, 'image/svg+xml');
+ const nodes = doc.getElementsByTagName('*');
+ for (const el of nodes) {
+ for (const attr of el.attributes) {
+ const name = attr.name.toLowerCase();
+ if (name.startsWith('on')) {
+ fail(`parsed SVG has event-handler attribute <${el.nodeName} ${name}="${attr.value}">`);
+ }
+ }
+ }
+ };
+
+ const runXssCase = (payload, done) => {
+ const fig = {
+ data: [{ x: [1], y: [1], type: 'scatter' }],
+ layout: { annotations: [{ x: 1, y: 1, showarrow: false, text: payload }] }
+ };
+
+ Plotly.newPlot(gd, fig)
+ .then(() => Plotly.toImage(gd, { format: 'svg', imageDataOnly: true }))
+ .then((svg) => expectNoEventHandlerAttrs(decodeURIComponent(svg)))
+ .then(done, done.fail);
+ };
+
+ it('should not let entity-encoded quotes escape attribute context', (done) => {
+ runXssCase('hi', done);
+ });
+
+ it('should not let entity-encoded quotes escape attribute context', (done) => {
+ runXssCase(
+ 'click',
+ done
+ );
+ });
+
+ it('should block " (named) and " (hex) quote entities', (done) => {
+ runXssCase('hi', done);
+ });
+ });
+
+ it('should work on pages with ', function (done) {
var parser = new DOMParser();
- var base = d3Select('body')
- .append('base')
- .attr('href', 'https://chart-studio.plotly.com');
+ var base = d3Select('body').append('base').attr('href', 'https://chart-studio.plotly.com');
Plotly.newPlot(gd, [{ y: [1, 2, 1] }])
- .then(function() {
- return Plotly.toImage(gd, {format: 'svg', imageDataOnly: true});
- })
- .then(function(svg) {
- var svgDOM = parser.parseFromString(svg, 'image/svg+xml');
- var gSubplot = svgDOM
- .getElementsByClassName('overplot')[0]
- .getElementsByClassName('xy')[0];
-
- var clipPath = gSubplot.getAttribute('clip-path');
- var len = clipPath.length;
-
- var head = clipPath.slice(0, 4);
- var tail = clipPath.slice(len - 7, len);
- expect(head).toBe('url(', 'subplot clipPath head');
- expect(tail).toBe('xyplot)', 'subplot clipPath tail');
-
- var middle = clipPath.slice(4, 14);
- expect(middle.length).toBe(10, 'subplot clipPath uid length');
- expect(middle.indexOf('http://')).toBe(-1, 'no URL in subplot clipPath!');
- expect(middle.indexOf('https://')).toBe(-1, 'no URL in subplot clipPath!');
- })
- .then(function() {
- base.remove();
- done();
- }, done.fail);
+ .then(function () {
+ return Plotly.toImage(gd, { format: 'svg', imageDataOnly: true });
+ })
+ .then(function (svg) {
+ var svgDOM = parser.parseFromString(svg, 'image/svg+xml');
+ var gSubplot = svgDOM.getElementsByClassName('overplot')[0].getElementsByClassName('xy')[0];
+
+ var clipPath = gSubplot.getAttribute('clip-path');
+ var len = clipPath.length;
+
+ var head = clipPath.slice(0, 4);
+ var tail = clipPath.slice(len - 7, len);
+ expect(head).toBe('url(', 'subplot clipPath head');
+ expect(tail).toBe('xyplot)', 'subplot clipPath tail');
+
+ var middle = clipPath.slice(4, 14);
+ expect(middle.length).toBe(10, 'subplot clipPath uid length');
+ expect(middle.indexOf('http://')).toBe(-1, 'no URL in subplot clipPath!');
+ expect(middle.indexOf('https://')).toBe(-1, 'no URL in subplot clipPath!');
+ })
+ .then(function () {
+ base.remove();
+ done();
+ }, done.fail);
});
- describe('with format `full-json`', function() {
- var imgOpts = {format: 'full-json', imageDataOnly: true};
+ describe('with format `full-json`', function () {
+ var imgOpts = { format: 'full-json', imageDataOnly: true };
var gd;
- beforeEach(function() {
+ beforeEach(function () {
gd = createGraphDiv();
});
afterEach(destroyGraphDiv);
- it('export a graph div', function(done) {
- Plotly.newPlot(gd, [{y: [1, 2, 3]}])
- .then(function(gd) { return Plotly.toImage(gd, imgOpts);})
- .then(function(fig) {
- fig = JSON.parse(fig);
- ['data', 'layout', 'config'].forEach(function(key) {
- expect(fig.hasOwnProperty(key)).toBeTruthy('is missing key: ' + key);
- });
- expect(fig.data[0].mode).toBe('lines+markers', 'contain default mode');
- expect(fig.version).toBe(Plotly.version, 'contains Plotly version');
- })
- .then(done, done.fail);
+ it('export a graph div', function (done) {
+ Plotly.newPlot(gd, [{ y: [1, 2, 3] }])
+ .then(function (gd) {
+ return Plotly.toImage(gd, imgOpts);
+ })
+ .then(function (fig) {
+ fig = JSON.parse(fig);
+ ['data', 'layout', 'config'].forEach(function (key) {
+ expect(fig.hasOwnProperty(key)).toBeTruthy('is missing key: ' + key);
+ });
+ expect(fig.data[0].mode).toBe('lines+markers', 'contain default mode');
+ expect(fig.version).toBe(Plotly.version, 'contains Plotly version');
+ })
+ .then(done, done.fail);
});
- it('export an object with data/layout/config', function(done) {
- Plotly.toImage({data: [{y: [1, 2, 3]}]}, imgOpts)
- .then(function(fig) {
- fig = JSON.parse(fig);
- ['data', 'layout', 'config'].forEach(function(key) {
- expect(fig.hasOwnProperty(key)).toBeTruthy('is missing key: ' + key);
- });
- expect(fig.data[0].mode).toBe('lines+markers', 'contain default mode');
- expect(fig.version).toBe(Plotly.version, 'contains Plotly version');
- })
- .then(done, done.fail);
+ it('export an object with data/layout/config', function (done) {
+ Plotly.toImage({ data: [{ y: [1, 2, 3] }] }, imgOpts)
+ .then(function (fig) {
+ fig = JSON.parse(fig);
+ ['data', 'layout', 'config'].forEach(function (key) {
+ expect(fig.hasOwnProperty(key)).toBeTruthy('is missing key: ' + key);
+ });
+ expect(fig.data[0].mode).toBe('lines+markers', 'contain default mode');
+ expect(fig.version).toBe(Plotly.version, 'contains Plotly version');
+ })
+ .then(done, done.fail);
});
- it('export typed arrays as regular arrays', function(done) {
+ it('export typed arrays as regular arrays', function (done) {
var x = new Float64Array([-1 / 3, 1 / 3]);
var y = new Float32Array([-1 / 3, 1 / 3]);
- var z = [
- new Int16Array([-32768, 32767]),
- new Uint16Array([65535, 0])
- ];
-
- Plotly.newPlot(gd, [{
- type: 'surface',
- x: x,
- y: y,
- z: z
- }])
- .then(function(gd) {
- var trace = gd._fullData[0];
+ var z = [new Int16Array([-32768, 32767]), new Uint16Array([65535, 0])];
+
+ Plotly.newPlot(gd, [
+ {
+ type: 'surface',
+ x: x,
+ y: y,
+ z: z
+ }
+ ])
+ .then(function (gd) {
+ var trace = gd._fullData[0];
- expect(trace.visible).toEqual(true);
+ expect(trace.visible).toEqual(true);
- expect(trace.x.slice()).toEqual(x);
- expect(trace.y.slice()).toEqual(y);
- expect(trace.z.slice()).toEqual(z);
+ expect(trace.x.slice()).toEqual(x);
+ expect(trace.y.slice()).toEqual(y);
+ expect(trace.z.slice()).toEqual(z);
- return Plotly.toImage(gd, imgOpts);
- })
- .then(function(fig) {
- var trace = JSON.parse(fig).data[0];
+ return Plotly.toImage(gd, imgOpts);
+ })
+ .then(function (fig) {
+ var trace = JSON.parse(fig).data[0];
- expect(trace.visible).toEqual(true);
+ expect(trace.visible).toEqual(true);
- expect(trace.x).toEqual([-0.3333333333333333, 0.3333333333333333]);
- expect(trace.y).toEqual([-0.3333333432674408, 0.3333333432674408]);
- expect(trace.z).toEqual([[-32768, 32767], [65535, 0]]);
- })
- .then(done, done.fail);
+ expect(trace.x).toEqual([-0.3333333333333333, 0.3333333333333333]);
+ expect(trace.y).toEqual([-0.3333333432674408, 0.3333333432674408]);
+ expect(trace.z).toEqual([
+ [-32768, 32767],
+ [65535, 0]
+ ]);
+ })
+ .then(done, done.fail);
});
- it('import & export 1d and 2d typed arrays', function(done) {
+ it('import & export 1d and 2d typed arrays', function (done) {
var allX = new Float64Array([-1 / 3, 0, 1 / 3]);
var allY = new Float32Array([1 / 3, -1 / 3]);
var allZ = new Uint16Array([0, 100, 200, 300, 400, 500]);
@@ -344,48 +438,47 @@ describe('Plotly.toImage', function() {
var y = b64encodeTypedArray(allY);
var z = b64encodeTypedArray(allZ);
- Plotly.newPlot(gd, [{
- type: 'surface',
- x: {bdata: x, dtype: 'f8'},
- y: {bdata: y, dtype: 'f4'},
- z: {bdata: z, dtype: 'u2', shape: '2,3'}
- }])
- .then(function(gd) {
- var trace = gd._fullData[0];
+ Plotly.newPlot(gd, [
+ {
+ type: 'surface',
+ x: { bdata: x, dtype: 'f8' },
+ y: { bdata: y, dtype: 'f4' },
+ z: { bdata: z, dtype: 'u2', shape: '2,3' }
+ }
+ ])
+ .then(function (gd) {
+ var trace = gd._fullData[0];
- expect(trace.visible).toEqual(true);
+ expect(trace.visible).toEqual(true);
- expect(trace.x.slice()).toEqual(allX);
- expect(trace.y.slice()).toEqual(allY);
- expect(trace.z.slice()).toEqual([
- new Uint16Array([0, 100, 200]),
- new Uint16Array([300, 400, 500])
- ]);
+ expect(trace.x.slice()).toEqual(allX);
+ expect(trace.y.slice()).toEqual(allY);
+ expect(trace.z.slice()).toEqual([new Uint16Array([0, 100, 200]), new Uint16Array([300, 400, 500])]);
- return Plotly.toImage(gd, imgOpts);
- })
- .then(function(fig) {
- var trace = JSON.parse(fig).data[0];
+ return Plotly.toImage(gd, imgOpts);
+ })
+ .then(function (fig) {
+ var trace = JSON.parse(fig).data[0];
- expect(trace.visible).toEqual(true);
+ expect(trace.visible).toEqual(true);
- expect(trace.x.bdata).toEqual('VVVVVVVV1b8AAAAAAAAAAFVVVVVVVdU/');
- expect(trace.y.bdata).toEqual('q6qqPquqqr4=');
- expect(trace.z.bdata).toEqual('AABkAMgALAGQAfQB');
+ expect(trace.x.bdata).toEqual('VVVVVVVV1b8AAAAAAAAAAFVVVVVVVdU/');
+ expect(trace.y.bdata).toEqual('q6qqPquqqr4=');
+ expect(trace.z.bdata).toEqual('AABkAMgALAGQAfQB');
- expect(trace.x.dtype).toEqual('f8');
- expect(trace.x.shape).toEqual('3');
+ expect(trace.x.dtype).toEqual('f8');
+ expect(trace.x.shape).toEqual('3');
- expect(trace.y.dtype).toEqual('f4');
- expect(trace.y.shape).toEqual('2');
+ expect(trace.y.dtype).toEqual('f4');
+ expect(trace.y.shape).toEqual('2');
- expect(trace.z.dtype).toEqual('u2');
- expect(trace.z.shape).toEqual('2,3');
- })
- .then(done, done.fail);
+ expect(trace.z.dtype).toEqual('u2');
+ expect(trace.z.shape).toEqual('2,3');
+ })
+ .then(done, done.fail);
});
- it('import buffer and export b64', function(done) {
+ it('import buffer and export b64', function (done) {
var allX = new Float64Array([-1 / 3, 0, 1 / 3]);
var allY = new Float32Array([1 / 3, -1 / 3]);
var allZ = new Uint16Array([0, 100, 200, 300, 400, 500]);
@@ -393,53 +486,48 @@ describe('Plotly.toImage', function() {
var y = allY.buffer;
var z = allZ.buffer;
- Plotly.newPlot(gd, [{
- type: 'surface',
- x: {bdata: x, dtype: 'f8', shape: '3'},
- y: {bdata: y, dtype: 'f4', shape: '2'},
- z: {bdata: z, dtype: 'u2', shape: '2,3'}
- }])
- .then(function(gd) {
- var trace = gd._fullData[0];
+ Plotly.newPlot(gd, [
+ {
+ type: 'surface',
+ x: { bdata: x, dtype: 'f8', shape: '3' },
+ y: { bdata: y, dtype: 'f4', shape: '2' },
+ z: { bdata: z, dtype: 'u2', shape: '2,3' }
+ }
+ ])
+ .then(function (gd) {
+ var trace = gd._fullData[0];
- expect(trace.visible).toEqual(true);
+ expect(trace.visible).toEqual(true);
- expect(trace.x.slice()).toEqual(allX);
- expect(trace.y.slice()).toEqual(allY);
- expect(trace.z.slice()).toEqual([
- new Uint16Array([0, 100, 200]),
- new Uint16Array([300, 400, 500])
- ]);
+ expect(trace.x.slice()).toEqual(allX);
+ expect(trace.y.slice()).toEqual(allY);
+ expect(trace.z.slice()).toEqual([new Uint16Array([0, 100, 200]), new Uint16Array([300, 400, 500])]);
- return Plotly.toImage(gd, imgOpts);
- })
- .then(function(fig) {
- var trace = JSON.parse(fig).data[0];
+ return Plotly.toImage(gd, imgOpts);
+ })
+ .then(function (fig) {
+ var trace = JSON.parse(fig).data[0];
- expect(trace.visible).toEqual(true);
+ expect(trace.visible).toEqual(true);
- expect(trace.x.bdata).toEqual('VVVVVVVV1b8AAAAAAAAAAFVVVVVVVdU/');
- expect(trace.y.bdata).toEqual('q6qqPquqqr4=');
- expect(trace.z.bdata).toEqual('AABkAMgALAGQAfQB');
+ expect(trace.x.bdata).toEqual('VVVVVVVV1b8AAAAAAAAAAFVVVVVVVdU/');
+ expect(trace.y.bdata).toEqual('q6qqPquqqr4=');
+ expect(trace.z.bdata).toEqual('AABkAMgALAGQAfQB');
- expect(trace.x.dtype).toEqual('f8');
- expect(trace.x.shape).toEqual('3');
+ expect(trace.x.dtype).toEqual('f8');
+ expect(trace.x.shape).toEqual('3');
- expect(trace.y.dtype).toEqual('f4');
- expect(trace.y.shape).toEqual('2');
+ expect(trace.y.dtype).toEqual('f4');
+ expect(trace.y.shape).toEqual('2');
- expect(trace.z.dtype).toEqual('u2');
- expect(trace.z.shape).toEqual('2,3');
- })
- .then(done, done.fail);
+ expect(trace.z.dtype).toEqual('u2');
+ expect(trace.z.shape).toEqual('2,3');
+ })
+ .then(done, done.fail);
});
- [
- 'scatter3d',
- 'scattergl',
- 'scatter'
- ].forEach(function(type) {
- it('import & export arrayOk marker.color and marker.size for ' + type, function(done) {
+ ['scatter3d', 'scattergl', 'scatter'].forEach(function (type) {
+ it('import & export arrayOk marker.color and marker.size for ' + type, function (done) {
var is3D = type === 'scatter3d';
var allX = new Int16Array([-100, 200, -300, 400]);
@@ -454,107 +542,109 @@ describe('Plotly.toImage', function() {
var s = b64encodeTypedArray(allS);
var c = b64encodeTypedArray(allC);
- Plotly.newPlot(gd, [{
- type: type,
- x: {bdata: x, dtype: 'i2'},
- y: {bdata: y, dtype: 'u2'},
- z: {bdata: z, dtype: 'i1'},
- marker: {
- color: {bdata: c, dtype: 'u1'},
- size: {bdata: s, dtype: 'u1c'}
+ Plotly.newPlot(gd, [
+ {
+ type: type,
+ x: { bdata: x, dtype: 'i2' },
+ y: { bdata: y, dtype: 'u2' },
+ z: { bdata: z, dtype: 'i1' },
+ marker: {
+ color: { bdata: c, dtype: 'u1' },
+ size: { bdata: s, dtype: 'u1c' }
+ }
}
- }])
- .then(function(gd) {
- var trace = gd._fullData[0];
-
- expect(trace.visible).toEqual(true);
-
- expect(trace.x.slice()).toEqual(allX);
- expect(trace.y.slice()).toEqual(allY);
- if(is3D) expect(trace.z.slice()).toEqual(allZ);
- expect(trace.marker.size.slice()).toEqual(allS);
- expect(trace.marker.color.slice()).toEqual(allC);
- expect(trace.line.color).toEqual('#1f77b4');
-
- return Plotly.toImage(gd, imgOpts);
- })
- .then(function(fig) {
- var trace = JSON.parse(fig).data[0];
-
- expect(trace.visible).toEqual(true);
-
- expect(trace.x.bdata).toEqual('nP/IANT+kAE=');
- expect(trace.x.dtype).toEqual('i2');
- expect(trace.x.shape).toEqual('4');
-
- expect(trace.y.bdata).toEqual('ZADIACwBkAE=');
- expect(trace.y.dtype).toEqual('u2');
- expect(trace.y.shape).toEqual('4');
-
- if(is3D) {
- expect(trace.z.bdata).toEqual('iMQAPA==');
- expect(trace.z.dtype).toEqual('i1');
- expect(trace.z.shape).toEqual('4');
- }
-
- expect(trace.marker.size.bdata).toEqual('ADx48A==');
- expect(trace.marker.size.dtype).toEqual('u1c');
- expect(trace.marker.size.shape).toEqual('4');
-
- expect(trace.marker.color.bdata).toEqual('ADx48A==');
- expect(trace.marker.color.dtype).toEqual('u1');
- expect(trace.marker.color.shape).toEqual('4');
-
- expect(trace.marker.colorscale).toBeDefined();
- })
- .then(done, done.fail);
+ ])
+ .then(function (gd) {
+ var trace = gd._fullData[0];
+
+ expect(trace.visible).toEqual(true);
+
+ expect(trace.x.slice()).toEqual(allX);
+ expect(trace.y.slice()).toEqual(allY);
+ if (is3D) expect(trace.z.slice()).toEqual(allZ);
+ expect(trace.marker.size.slice()).toEqual(allS);
+ expect(trace.marker.color.slice()).toEqual(allC);
+ expect(trace.line.color).toEqual('#1f77b4');
+
+ return Plotly.toImage(gd, imgOpts);
+ })
+ .then(function (fig) {
+ var trace = JSON.parse(fig).data[0];
+
+ expect(trace.visible).toEqual(true);
+
+ expect(trace.x.bdata).toEqual('nP/IANT+kAE=');
+ expect(trace.x.dtype).toEqual('i2');
+ expect(trace.x.shape).toEqual('4');
+
+ expect(trace.y.bdata).toEqual('ZADIACwBkAE=');
+ expect(trace.y.dtype).toEqual('u2');
+ expect(trace.y.shape).toEqual('4');
+
+ if (is3D) {
+ expect(trace.z.bdata).toEqual('iMQAPA==');
+ expect(trace.z.dtype).toEqual('i1');
+ expect(trace.z.shape).toEqual('4');
+ }
+
+ expect(trace.marker.size.bdata).toEqual('ADx48A==');
+ expect(trace.marker.size.dtype).toEqual('u1c');
+ expect(trace.marker.size.shape).toEqual('4');
+
+ expect(trace.marker.color.bdata).toEqual('ADx48A==');
+ expect(trace.marker.color.dtype).toEqual('u1');
+ expect(trace.marker.color.shape).toEqual('4');
+
+ expect(trace.marker.colorscale).toBeDefined();
+ })
+ .then(done, done.fail);
});
});
- it('export computed margins', function(done) {
+ it('export computed margins', function (done) {
Plotly.toImage(pieAutoMargin, imgOpts)
- .then(function(fig) {
- fig = JSON.parse(fig);
- var computed = fig.layout.computed;
- expect(computed).toBeDefined('no computed');
- expect(computed.margin).toBeDefined('no computed margin');
- expect(computed.margin.t).toBeDefined('no top');
- expect(computed.margin.l).toBeDefined('no left');
- expect(computed.margin.r).toBeDefined('no right');
- expect(computed.margin.b).toBeDefined('no bottom');
- })
- .then(done, done.fail);
+ .then(function (fig) {
+ fig = JSON.parse(fig);
+ var computed = fig.layout.computed;
+ expect(computed).toBeDefined('no computed');
+ expect(computed.margin).toBeDefined('no computed margin');
+ expect(computed.margin.t).toBeDefined('no top');
+ expect(computed.margin.l).toBeDefined('no left');
+ expect(computed.margin.r).toBeDefined('no right');
+ expect(computed.margin.b).toBeDefined('no bottom');
+ })
+ .then(done, done.fail);
});
- it('record and export computed margins with "Too many auto-margin redraws"', function(done) {
- Plotly.toImage({
- data: [{
- x: [
- 'a',
- 'b',
- 'looooooooooooooooooooooooooooooooooog',
- 'd'
- ]
- }],
- layout: {
- width: 400,
- height: 400,
- paper_bgcolor: 'lightblue',
- xaxis: {
- automargin: true
- },
- yaxis: {
- automargin: true
+ it('record and export computed margins with "Too many auto-margin redraws"', function (done) {
+ Plotly.toImage(
+ {
+ data: [
+ {
+ x: ['a', 'b', 'looooooooooooooooooooooooooooooooooog', 'd']
+ }
+ ],
+ layout: {
+ width: 400,
+ height: 400,
+ paper_bgcolor: 'lightblue',
+ xaxis: {
+ automargin: true
+ },
+ yaxis: {
+ automargin: true
+ }
}
- }
- }, imgOpts)
- .then(function(fig) {
- fig = JSON.parse(fig);
- var computed = fig.layout.computed;
- expect(computed.margin.b).toBeGreaterThan(80);
- expect(computed.margin.r).toBeGreaterThan(80);
- })
- .then(done, done.fail);
+ },
+ imgOpts
+ )
+ .then(function (fig) {
+ fig = JSON.parse(fig);
+ var computed = fig.layout.computed;
+ expect(computed.margin.b).toBeGreaterThan(80);
+ expect(computed.margin.r).toBeGreaterThan(80);
+ })
+ .then(done, done.fail);
});
});
});