From 058da08aa01d0e01d062b3a8e8770249610c5b4f Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Wed, 10 Jun 2026 16:04:42 -0600 Subject: [PATCH 1/3] Linting/formatting --- src/snapshot/tosvg.js | 60 ++- test/jasmine/tests/toimage_test.js | 780 +++++++++++++++-------------- 2 files changed, 444 insertions(+), 396 deletions(-) diff --git a/src/snapshot/tosvg.js b/src/snapshot/tosvg.js index 75cd7086b98..57ffe4c3ce1 100644 --- a/src/snapshot/tosvg.js +++ b/src/snapshot/tosvg.js @@ -12,11 +12,17 @@ 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 ''; } + 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(); @@ -48,29 +54,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,70 +86,70 @@ 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); @@ -154,7 +160,7 @@ module.exports = function toSVG(gd, format, scale) { 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..01c39134e5e 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,337 @@ 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(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); }); - 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) { + 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 +390,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 +438,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 +494,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); }); }); }); From 6dfed3119e21580b7dc4562558cc2faf6b278dee Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Thu, 11 Jun 2026 14:11:15 -0600 Subject: [PATCH 2/3] fix: Preserve XML structural entities during decode --- src/snapshot/tosvg.js | 43 ++++++++++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/src/snapshot/tosvg.js b/src/snapshot/tosvg.js index 57ffe4c3ce1..4a172f907ea 100644 --- a/src/snapshot/tosvg.js +++ b/src/snapshot/tosvg.js @@ -9,23 +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; } @@ -156,6 +172,7 @@ module.exports = function toSVG(gd, format, scale) { } 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); From 47763c25a16a3c3f59e4674fbec53beecbf4c5d8 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Thu, 11 Jun 2026 14:13:12 -0600 Subject: [PATCH 3/3] Add tests --- test/jasmine/tests/toimage_test.js | 50 +++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/test/jasmine/tests/toimage_test.js b/test/jasmine/tests/toimage_test.js index 01c39134e5e..88dbe14b30f 100644 --- a/test/jasmine/tests/toimage_test.js +++ b/test/jasmine/tests/toimage_test.js @@ -213,7 +213,7 @@ describe('Plotly.toImage', function () { }) .then(function (d) { expect(d.indexOf('data:image/')).toBe(-1); - expect(d.length).toBeWithin(32062, 1e3, 'svg image length'); + expect(d.length).toBeWithin(32520, 1e3, 'svg image length'); }) .then(function () { return Plotly.toImage(gd, { format: 'webp', imageDataOnly: true }); @@ -273,6 +273,54 @@ describe('Plotly.toImage', function () { .then(done, done.fail); }); + 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();