From f758c35c84d1cbb682bca543f24308fb306f1b7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Etienne=20T=C3=A9treault-Pinard?= Date: Fri, 4 Aug 2017 15:08:43 -0400 Subject: [PATCH 01/15] add isPlainObject test case for HTML
- which should fail ;) --- test/jasmine/tests/is_plain_object_test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/jasmine/tests/is_plain_object_test.js b/test/jasmine/tests/is_plain_object_test.js index cf2ba311f25..803d595770d 100644 --- a/test/jasmine/tests/is_plain_object_test.js +++ b/test/jasmine/tests/is_plain_object_test.js @@ -31,7 +31,8 @@ describe('isPlainObject', function() { new Array(10), new Date(), new RegExp('foo'), - new String('string') + new String('string'), + document.createElement('div') ]; shouldPass.forEach(function(obj) { From 3d19676d09d78b5deb3d6233d2ad676007b21098 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Etienne=20T=C3=A9treault-Pinard?= Date: Fri, 4 Aug 2017 15:09:43 -0400 Subject: [PATCH 02/15] make gl3d, gl2d and mapbox .destroy() idempotent - i.e. not crash when called multiple times --- src/plots/gl2d/scene2d.js | 2 ++ src/plots/gl3d/scene.js | 2 ++ src/plots/mapbox/mapbox.js | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/plots/gl2d/scene2d.js b/src/plots/gl2d/scene2d.js index 96e5045ab32..57b9e6f9bbf 100644 --- a/src/plots/gl2d/scene2d.js +++ b/src/plots/gl2d/scene2d.js @@ -356,6 +356,8 @@ proto.handleAnnotations = function() { }; proto.destroy = function() { + if(!this.glplot) return; + var traces = this.traces; if(traces) { diff --git a/src/plots/gl3d/scene.js b/src/plots/gl3d/scene.js index 1dee8b4917d..071310a78ec 100644 --- a/src/plots/gl3d/scene.js +++ b/src/plots/gl3d/scene.js @@ -593,6 +593,8 @@ proto.plot = function(sceneData, fullLayout, layout) { }; proto.destroy = function() { + if(!this.glplot) return; + this.camera.mouseListener.enabled = false; this.container.removeEventListener('wheel', this.camera.wheelListener); this.camera = this.glplot.camera = null; diff --git a/src/plots/mapbox/mapbox.js b/src/plots/mapbox/mapbox.js index 272bd9afe80..dbe3bd228bc 100644 --- a/src/plots/mapbox/mapbox.js +++ b/src/plots/mapbox/mapbox.js @@ -454,8 +454,8 @@ proto.destroy = function() { if(this.map) { this.map.remove(); this.map = null; + this.container.removeChild(this.div); } - this.container.removeChild(this.div); }; proto.toImage = function() { From dd003ceec40dd89473aea3e0df0f4fa23738f457 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Etienne=20T=C3=A9treault-Pinard?= Date: Tue, 8 Aug 2017 14:21:44 -0400 Subject: [PATCH 03/15] set setBackground config option in setPlotContext - instead of in `plot_config.js` declaration --- src/plot_api/plot_api.js | 15 ++++++++++++++- src/plot_api/plot_config.js | 20 +++----------------- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index f7fb3e83880..31942e423e8 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -25,6 +25,7 @@ var Polar = require('../plots/polar'); var initInteractions = require('../plots/cartesian/graph_interact'); var Drawing = require('../components/drawing'); +var Color = require('../components/color'); var ErrorBars = require('../components/errorbars'); var xmlnsNamespaces = require('../constants/xmlns_namespaces'); var svgTextUtils = require('../lib/svg_text_utils'); @@ -390,10 +391,17 @@ Plotly.plot = function(gd, data, layout, config) { }); }; +function setBackground(gd, bgColor) { + try { + gd._fullLayout._paper.style('background', bgColor); + } catch(e) { + Lib.error(e); + } +} function opaqueSetBackground(gd, bgColor) { gd._fullLayout._paperdiv.style('background', 'white'); - Plotly.defaultConfig.setBackground(gd, bgColor); + setBackground(gd, bgColor); } function setPlotContext(gd, config) { @@ -460,6 +468,11 @@ function setPlotContext(gd, config) { if(context.displayModeBar === 'hover' && !hasHover) { context.displayModeBar = true; } + + // default and fallback for setBackground + if(context.setBackground === 'transparent' || typeof context.setBackground !== 'function') { + context.setBackground = setBackground; + } } function plotPolar(gd, data, layout) { diff --git a/src/plot_api/plot_config.js b/src/plot_api/plot_config.js index c19d0d3ebc8..dd07a7b51cf 100644 --- a/src/plot_api/plot_config.js +++ b/src/plot_api/plot_config.js @@ -8,8 +8,6 @@ 'use strict'; -/* eslint-disable no-console */ - /** * This will be transferred over to gd and overridden by * config args to Plotly.plot. @@ -109,8 +107,9 @@ module.exports = { plotGlPixelRatio: 2, // function to add the background color to a different container - // or 'opaque' to ensure there's white behind it - setBackground: defaultSetBackground, + // or 'opaque' to ensure there's white behind it, + // or any other custom function of gd + setBackground: 'transparent', // URL to topojson files used in geo charts topojsonURL: 'https://cdn.plot.ly/', @@ -128,16 +127,3 @@ module.exports = { // specification needed globalTransforms: [] }; - -// where and how the background gets set can be overridden by context -// so we define the default (plotly.js) behavior here -function defaultSetBackground(gd, bgColor) { - try { - gd._fullLayout._paper.style('background', bgColor); - } - catch(e) { - if(module.exports.logging > 0) { - console.error(e); - } - } -} From 9128e1386f995ef238417aa0889c5728544a5202 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Etienne=20T=C3=A9treault-Pinard?= Date: Tue, 8 Aug 2017 14:22:38 -0400 Subject: [PATCH 04/15] add 'blend' setBackground value - which is currently used in the image server --- src/plot_api/plot_api.js | 16 +++++++++++++--- src/plot_api/plot_config.js | 1 + 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 31942e423e8..7c2e6ef92aa 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -404,6 +404,11 @@ function opaqueSetBackground(gd, bgColor) { setBackground(gd, bgColor); } +function blendSetBackground(gd, bgColor) { + var blend = Color.combine(bgColor, 'white'); + setBackground(gd, blend); +} + function setPlotContext(gd, config) { if(!gd._context) gd._context = Lib.extendDeep({}, Plotly.defaultConfig); var context = gd._context; @@ -416,10 +421,15 @@ function setPlotContext(gd, config) { key = keys[i]; if(key === 'editable' || key === 'edits') continue; if(key in context) { - if(key === 'setBackground' && config[key] === 'opaque') { - context[key] = opaqueSetBackground; + if(key === 'setBackground') { + if(config[key] === 'opaque') { + context[key] = opaqueSetBackground; + } else if(config[key] === 'blend') { + context[key] = blendSetBackground; + } + } else { + context[key] = config[key]; } - else context[key] = config[key]; } } diff --git a/src/plot_api/plot_config.js b/src/plot_api/plot_config.js index dd07a7b51cf..3af13ef46f8 100644 --- a/src/plot_api/plot_config.js +++ b/src/plot_api/plot_config.js @@ -108,6 +108,7 @@ module.exports = { // function to add the background color to a different container // or 'opaque' to ensure there's white behind it, + // or 'blend' to blend bg color with white, // or any other custom function of gd setBackground: 'transparent', From 5d2b635130de7ee769a3bbab9fd1221d320b7d10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Etienne=20T=C3=A9treault-Pinard?= Date: Tue, 8 Aug 2017 14:24:49 -0400 Subject: [PATCH 05/15] robustify toImage tests - + add 'webp' test case --- test/jasmine/tests/toimage_test.js | 86 +++++++++++++++++------------- 1 file changed, 48 insertions(+), 38 deletions(-) diff --git a/test/jasmine/tests/toimage_test.js b/test/jasmine/tests/toimage_test.js index 6cde6567067..ebbaeb0db1c 100644 --- a/test/jasmine/tests/toimage_test.js +++ b/test/jasmine/tests/toimage_test.js @@ -1,10 +1,9 @@ -// move toimage to plot_api_test.js -// once established and confirmed? - -var Plotly = require('@lib/index'); +var Plotly = require('@lib'); +var Lib = require('@src/lib'); var d3 = require('d3'); var createGraphDiv = require('../assets/create_graph_div'); +var fail = require('../assets/fail_test'); var subplotMock = require('@mocks/multiple_subplots.json'); @@ -25,6 +24,15 @@ describe('Plotly.toImage', function() { d3.selectAll('#graph').remove(); }); + function createImage(url) { + 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'); }; + }); + } + it('should be attached to Plotly', function() { expect(Plotly.toImage).toBeDefined(); }); @@ -71,52 +79,54 @@ describe('Plotly.toImage', function() { }); it('should create img with proper height and width', function(done) { - var img = document.createElement('img'); + var fig = Lib.extendDeep({}, subplotMock); // specify height and width - subplotMock.layout.height = 600; - subplotMock.layout.width = 700; + fig.layout.height = 600; + fig.layout.width = 700; - Plotly.plot(gd, subplotMock.data, subplotMock.layout).then(function(gd) { + Plotly.plot(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(function(url) { - return new Promise(function(resolve) { - img.src = url; - img.onload = function() { - expect(img.height).toBe(600); - expect(img.width).toBe(700); - }; - // now provide height and width in opts - resolve(Plotly.toImage(gd, {height: 400, width: 400})); - }); - }).then(function(url) { - img.src = url; - img.onload = function() { - expect(img.height).toBe(400); - expect(img.width).toBe(400); - done(); - }; - }); + }) + .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); + }) + .catch(fail) + .then(done); }); it('should create proper file type', function(done) { - var plot = Plotly.plot(gd, subplotMock.data, subplotMock.layout); + var fig = Lib.extendDeep({}, subplotMock); - plot.then(function(gd) { - return Plotly.toImage(gd, {format: 'png'}); - }).then(function(url) { + Plotly.plot(gd, fig.data, fig.layout) + .then(function() { return Plotly.toImage(gd, {format: 'png'}); }) + .then(function(url) { expect(url.split('png')[0]).toBe('data:image/'); - // now do jpeg - return Plotly.toImage(gd, {format: 'jpeg'}); - }).then(function(url) { + }) + .then(function() { return Plotly.toImage(gd, {format: 'jpeg'}); }) + .then(function(url) { expect(url.split('jpeg')[0]).toBe('data:image/'); - // now do svg - return Plotly.toImage(gd, {format: 'svg'}); - }).then(function(url) { + }) + .then(function() { return Plotly.toImage(gd, {format: 'svg'}); }) + .then(function(url) { expect(url.split('svg')[0]).toBe('data:image/'); - done(); - }); + }) + .then(function() { return Plotly.toImage(gd, {format: 'webp'}); }) + .then(function(url) { + expect(url.split('webp')[0]).toBe('data:image/'); + }) + .catch(fail) + .then(done); }); }); From 8866525c6f5129526165d618e7b77d44b30e0da3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Etienne=20T=C3=A9treault-Pinard?= Date: Tue, 8 Aug 2017 14:31:16 -0400 Subject: [PATCH 06/15] improve Plotly.toImage - accept data/layout/config figure (plain) object as input, along side existing graph div or (string) id of existing graph div - use Lib.validate & Lib.coerce to sanatize options - bypass Snapshot.cloneplot (Lib.extendDeep is really all we need) - handle 'setBackground` (same as config option) - add 'imageDataOnly' option to strip 'data:image/' prefix --- src/plot_api/to_image.js | 240 ++++++++++++++++++++--------- test/jasmine/tests/toimage_test.js | 124 ++++++++++++--- 2 files changed, 266 insertions(+), 98 deletions(-) diff --git a/src/plot_api/to_image.js b/src/plot_api/to_image.js index 6ebcf75b367..a81227d1537 100644 --- a/src/plot_api/to_image.js +++ b/src/plot_api/to_image.js @@ -8,101 +8,191 @@ 'use strict'; -var isNumeric = require('fast-isnumeric'); - var Plotly = require('../plotly'); var Lib = require('../lib'); var helpers = require('../snapshot/helpers'); -var clonePlot = require('../snapshot/cloneplot'); var toSVG = require('../snapshot/tosvg'); var svgToImg = require('../snapshot/svgtoimg'); -/** - * @param {object} gd figure Object - * @param {object} opts option object - * @param opts.format 'jpeg' | 'png' | 'webp' | 'svg' - * @param opts.width width of snapshot in px - * @param opts.height height of snapshot in px +var getGraphDiv = require('./helpers').getGraphDiv; + +var attrs = { + format: { + valType: 'enumerated', + values: ['png', 'jpeg', 'webp', 'svg'], + dflt: 'png', + description: 'Sets the format of exported image.' + }, + width: { + valType: 'number', + min: 1, + description: [ + 'Sets the exported image width.', + 'Defaults to the value found in `layout.width`' + ].join(' ') + }, + height: { + valType: 'number', + min: 1, + description: [ + 'Sets the exported image height.', + 'Defaults to the value found in `layout.height`' + ].join(' ') + }, + setBackground: { + valType: 'any', + dflt: false, + description: [ + 'Sets the image background mode.', + 'By default, the image background is determined by `layout.paper_bgcolor`,', + 'the *transparent* mode.', + 'One might consider setting `setBackground` to *opaque* or *blend*', + 'when exporting a *jpeg* image as JPEGs do not support opacity.' + ].join(' ') + }, + imageDataOnly: { + valType: 'boolean', + dflt: false, + description: [ + 'Determines whether or not the return value is prefixed by', + 'the image format\'s corresponding \'data:image;\' spec.' + ].join(' ') + } +}; + +var IMAGE_URL_PREFIX = /^data:image\/\w+;base64,/; + +/** Plotly.toImage + * + * @param {object | string | HTML div} gd + * can either be a data/layout/config object + * or an existing graph
+ * or an id to an existing graph
+ * @param {object} opts (see above) + * @return {promise} */ function toImage(gd, opts) { + opts = opts || {}; + + var data; + var layout; + var config; + + if(Lib.isPlainObject(gd)) { + data = gd.data || []; + layout = gd.layout || {}; + config = gd.config || {}; + } else { + gd = getGraphDiv(gd); + data = Lib.extendDeep([], gd.data); + layout = Lib.extendDeep({}, gd.layout); + config = gd._context; + } + + function isBadlySet(attr) { + return !(attr in opts) || Lib.validate(opts[attr], attrs[attr]); + } + + if(!isBadlySet('width') || !isBadlySet('height')) { + throw new Error('Height and width should be pixel values.'); + } + + if(!isBadlySet('format')) { + throw new Error('Image format is not jpeg, png, svg or webp.'); + } + + var fullOpts = {}; + + function coerce(attr, dflt) { + return Lib.coerce(opts, fullOpts, attrs, attr, dflt); + } + + var format = coerce('format'); + var width = coerce('width'); + var height = coerce('height'); + var setBackground = coerce('setBackground'); + var imageDataOnly = coerce('imageDataOnly'); + + // put the cloned div somewhere off screen before attaching to DOM + var clonedGd = document.createElement('div'); + clonedGd.style.position = 'absolute'; + clonedGd.style.left = '-5000px'; + document.body.appendChild(clonedGd); + + // extend layout with image options + var layoutImage = Lib.extendFlat({}, layout); + if(width) layoutImage.width = width; + if(height) layoutImage.height = height; + + // extend config for static plot + var configImage = Lib.extendFlat({}, config, { + staticPlot: true, + plotGlPixelRatio: 2, + displaylogo: false, + showLink: false, + showTips: false, + setBackground: setBackground + }); - var promise = new Promise(function(resolve, reject) { - // check for undefined opts - opts = opts || {}; - // default to png - opts.format = opts.format || 'png'; - - var isSizeGood = function(size) { - // undefined and null are valid options - if(size === undefined || size === null) { - return true; - } + var redrawFunc = helpers.getRedrawFunc(clonedGd); - if(isNumeric(size) && size > 1) { - return true; - } + function wait() { + return new Promise(function(resolve) { + setTimeout(resolve, helpers.getDelay(clonedGd._fullLayout)); + }); + } - return false; - }; + function convert() { + return new Promise(function(resolve, reject) { + var svg = toSVG(clonedGd); - if(!isSizeGood(opts.width) || !isSizeGood(opts.height)) { - reject(new Error('Height and width should be pixel values.')); - } + if(format === 'svg' && imageDataOnly) { + return resolve(svg); + } - // first clone the GD so we can operate in a clean environment - var clone = clonePlot(gd, {format: 'png', height: opts.height, width: opts.width}); - var clonedGd = clone.gd; - - // put the cloned div somewhere off screen before attaching to DOM - clonedGd.style.position = 'absolute'; - clonedGd.style.left = '-5000px'; - document.body.appendChild(clonedGd); - - function wait() { - var delay = helpers.getDelay(clonedGd._fullLayout); - - return new Promise(function(resolve, reject) { - setTimeout(function() { - var svg = toSVG(clonedGd); - - var canvas = document.createElement('canvas'); - canvas.id = Lib.randstr(); - - svgToImg({ - format: opts.format, - width: clonedGd._fullLayout.width, - height: clonedGd._fullLayout.height, - canvas: canvas, - svg: svg, - // ask svgToImg to return a Promise - // rather than EventEmitter - // leave EventEmitter for backward - // compatibility - promise: true - }).then(function(url) { - if(clonedGd) document.body.removeChild(clonedGd); - resolve(url); - }).catch(function(err) { - reject(err); - }); - - }, delay); + var canvas = document.createElement('canvas'); + canvas.id = Lib.randstr(); + + svgToImg({ + format: format, + width: clonedGd._fullLayout.width, + height: clonedGd._fullLayout.height, + canvas: canvas, + svg: svg, + // ask svgToImg to return a Promise + // rather than EventEmitter + // leave EventEmitter for backward + // compatibility + promise: true + }) + .then(function(url) { + Plotly.purge(clonedGd); + document.body.removeChild(clonedGd); + resolve(url); + }) + .catch(function(err) { + reject(err); }); + }); + } + + function urlToImageData(url) { + if(imageDataOnly) { + return url.replace(IMAGE_URL_PREFIX, ''); + } else { + return url; } + } - var redrawFunc = helpers.getRedrawFunc(clonedGd); - - Plotly.plot(clonedGd, clone.data, clone.layout, clone.config) + return new Promise(function(resolve, reject) { + Plotly.plot(clonedGd, data, layoutImage, configImage) .then(redrawFunc) .then(wait) - .then(function(url) { resolve(url); }) - .catch(function(err) { - reject(err); - }); + .then(convert) + .then(function(url) { resolve(urlToImageData(url)); }) + .catch(function(err) { reject(err); }); }); - - return promise; } module.exports = toImage; diff --git a/test/jasmine/tests/toimage_test.js b/test/jasmine/tests/toimage_test.js index ebbaeb0db1c..a90fa33e1bf 100644 --- a/test/jasmine/tests/toimage_test.js +++ b/test/jasmine/tests/toimage_test.js @@ -4,20 +4,23 @@ var Lib = require('@src/lib'); var d3 = require('d3'); var createGraphDiv = require('../assets/create_graph_div'); var fail = require('../assets/fail_test'); +var customMatchers = require('../assets/custom_matchers'); var subplotMock = require('@mocks/multiple_subplots.json'); - describe('Plotly.toImage', function() { 'use strict'; var gd; + beforeAll(function() { + jasmine.addMatchers(customMatchers); + }); + beforeEach(function() { gd = createGraphDiv(); }); afterEach(function() { - // make sure ALL graph divs are deleted, // even the ones generated by Plotly.toImage d3.selectAll('.js-plotly-plot').remove(); @@ -51,31 +54,51 @@ describe('Plotly.toImage', function() { }); it('should throw error with unsupported file type', function(done) { - // error should actually come in the svgToImg step - - Plotly.plot(gd, subplotMock.data, subplotMock.layout) - .then(function(gd) { - Plotly.toImage(gd, {format: 'x'}).catch(function(err) { - expect(err.message).toEqual('Image format is not jpeg, png or svg'); - done(); - }); - }); + var fig = Lib.extendDeep({}, subplotMock); + var errors = []; + Plotly.plot(gd, fig.data, fig.layout) + .then(function(gd) { + try { + Plotly.toImage(gd, {format: 'x'}); + } catch(e) { + errors.push(e.message); + } + }) + .then(function() { + expect(errors.length).toBe(1); + expect(errors[0]).toBe('Image format is not jpeg, png, svg or webp.'); + }) + .catch(fail) + .then(done); }); it('should throw error with height and/or width < 1', function(done) { - // let user know that Plotly expects pixel values - Plotly.plot(gd, subplotMock.data, subplotMock.layout) - .then(function(gd) { - return Plotly.toImage(gd, {height: 0.5}).catch(function(err) { - expect(err.message).toEqual('Height and width should be pixel values.'); - }); - }).then(function() { - Plotly.toImage(gd, {width: 0.5}).catch(function(err) { - expect(err.message).toEqual('Height and width should be pixel values.'); - done(); - }); - }); + var fig = Lib.extendDeep({}, subplotMock); + var errors = []; + + Plotly.plot(gd, fig.data, fig.layout) + .then(function() { + try { + Plotly.toImage(gd, {height: 0.5}); + } catch(e) { + errors.push(e.message); + } + }) + .then(function() { + try { + Plotly.toImage(gd, {width: 0.5}); + } catch(e) { + errors.push(e.message); + } + }) + .then(function() { + expect(errors.length).toBe(2); + expect(errors[0]).toBe('Height and width should be pixel values.'); + expect(errors[1]).toBe('Height and width should be pixel values.'); + }) + .catch(fail) + .then(done); }); it('should create img with proper height and width', function(done) { @@ -129,4 +152,59 @@ describe('Plotly.toImage', function() { .catch(fail) .then(done); }); + + it('should strip *data:image* prefix when *imageDataOnly* is turned on', function(done) { + var fig = Lib.extendDeep({}, subplotMock); + + Plotly.plot(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(53660, 1e3); + }) + .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, 1e3); + }) + .then(function() { return Plotly.toImage(gd, {format: 'svg', imageDataOnly: true}); }) + .then(function(d) { + expect(d.indexOf('data:image/')).toBe(-1); + expect(d.length).toBeWithin(39485, 1e3); + }) + .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); + }) + .catch(fail) + .then(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); + }) + .catch(fail) + .then(done); + }); + + it('should accept graph div id as input', function(done) { + var fig = Lib.extendDeep({}, subplotMock); + + Plotly.plot(gd, fig) + .then(function() { return Plotly.toImage('graph'); }) + .then(createImage) + .then(function(img) { + expect(img.width).toBe(700); + expect(img.height).toBe(450); + }) + .catch(fail) + .then(done); + }); }); From b3fac37e7dd7089097ff6b83dfba24b82f026aa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Etienne=20T=C3=A9treault-Pinard?= Date: Tue, 8 Aug 2017 14:32:01 -0400 Subject: [PATCH 07/15] add 'webp' to list of available formats in svgToImg error msg --- src/snapshot/svgtoimg.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/snapshot/svgtoimg.js b/src/snapshot/svgtoimg.js index f90e4bb386b..a14d096738c 100644 --- a/src/snapshot/svgtoimg.js +++ b/src/snapshot/svgtoimg.js @@ -89,11 +89,12 @@ function svgToImg(opts) { imgData = url; break; default: - reject(new Error('Image format is not jpeg, png or svg')); + var errorMsg = 'Image format is not jpeg, png, svg or webp.'; + reject(new Error(errorMsg)); // eventually remove the ev // in favor of promises if(!opts.promise) { - return ev.emit('error', 'Image format is not jpeg, png or svg'); + return ev.emit('error', errorMsg); } } resolve(imgData); From 32f46c50965dcc1aae7f6bd84adbdcdade7879fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Etienne=20T=C3=A9treault-Pinard?= Date: Tue, 8 Aug 2017 14:49:38 -0400 Subject: [PATCH 08/15] add tolerance for jpeg image length test --- test/jasmine/tests/toimage_test.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/jasmine/tests/toimage_test.js b/test/jasmine/tests/toimage_test.js index a90fa33e1bf..98d965dc9bd 100644 --- a/test/jasmine/tests/toimage_test.js +++ b/test/jasmine/tests/toimage_test.js @@ -160,22 +160,22 @@ describe('Plotly.toImage', function() { .then(function() { return Plotly.toImage(gd, {format: 'png', imageDataOnly: true}); }) .then(function(d) { expect(d.indexOf('data:image/')).toBe(-1); - expect(d.length).toBeWithin(53660, 1e3); + expect(d.length).toBeWithin(53660, 1e3, '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, 1e3); + 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(39485, 1e3); + expect(d.length).toBeWithin(39485, 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); + expect(d.length).toBeWithin(15831, 1e3, 'webp image length'); }) .catch(fail) .then(done); From 22a598bb814498a2057c2ea665195e77c0a2f598 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Etienne=20T=C3=A9treault-Pinard?= Date: Mon, 14 Aug 2017 16:19:19 -0400 Subject: [PATCH 09/15] rm 'blend' special setBackground value - and make 'opaque' use Color.combine instead of hard-setting _paperdiv. --- src/plot_api/plot_api.js | 13 ++----------- src/plot_api/plot_config.js | 6 +++--- src/plot_api/to_image.js | 2 +- 3 files changed, 6 insertions(+), 15 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 7c2e6ef92aa..ba3caf2bf63 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -400,11 +400,6 @@ function setBackground(gd, bgColor) { } function opaqueSetBackground(gd, bgColor) { - gd._fullLayout._paperdiv.style('background', 'white'); - setBackground(gd, bgColor); -} - -function blendSetBackground(gd, bgColor) { var blend = Color.combine(bgColor, 'white'); setBackground(gd, blend); } @@ -421,12 +416,8 @@ function setPlotContext(gd, config) { key = keys[i]; if(key === 'editable' || key === 'edits') continue; if(key in context) { - if(key === 'setBackground') { - if(config[key] === 'opaque') { - context[key] = opaqueSetBackground; - } else if(config[key] === 'blend') { - context[key] = blendSetBackground; - } + if(key === 'setBackground' && config[key] === 'opaque') { + context[key] = opaqueSetBackground; } else { context[key] = config[key]; } diff --git a/src/plot_api/plot_config.js b/src/plot_api/plot_config.js index 3af13ef46f8..27f4735a24e 100644 --- a/src/plot_api/plot_config.js +++ b/src/plot_api/plot_config.js @@ -106,9 +106,9 @@ module.exports = { // increase the pixel ratio for Gl plot images plotGlPixelRatio: 2, - // function to add the background color to a different container - // or 'opaque' to ensure there's white behind it, - // or 'blend' to blend bg color with white, + // background setting function + // 'transparent' sets the background `layout.paper_color` + // 'opaque' blends bg color with white ensuring an opaque background // or any other custom function of gd setBackground: 'transparent', diff --git a/src/plot_api/to_image.js b/src/plot_api/to_image.js index a81227d1537..a388839d01c 100644 --- a/src/plot_api/to_image.js +++ b/src/plot_api/to_image.js @@ -47,7 +47,7 @@ var attrs = { 'Sets the image background mode.', 'By default, the image background is determined by `layout.paper_bgcolor`,', 'the *transparent* mode.', - 'One might consider setting `setBackground` to *opaque* or *blend*', + 'One might consider setting `setBackground` to *opaque*', 'when exporting a *jpeg* image as JPEGs do not support opacity.' ].join(' ') }, From 43a615022281b582b0b4b283ef778870f535c54f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Etienne=20T=C3=A9treault-Pinard?= Date: Mon, 14 Aug 2017 16:19:46 -0400 Subject: [PATCH 10/15] lint (use toThrow, instead of error msg comparison) --- test/jasmine/tests/toimage_test.js | 32 ++++++------------------------ 1 file changed, 6 insertions(+), 26 deletions(-) diff --git a/test/jasmine/tests/toimage_test.js b/test/jasmine/tests/toimage_test.js index 98d965dc9bd..fad0b27fbc3 100644 --- a/test/jasmine/tests/toimage_test.js +++ b/test/jasmine/tests/toimage_test.js @@ -55,19 +55,11 @@ describe('Plotly.toImage', function() { it('should throw error with unsupported file type', function(done) { var fig = Lib.extendDeep({}, subplotMock); - var errors = []; Plotly.plot(gd, fig.data, fig.layout) .then(function(gd) { - try { - Plotly.toImage(gd, {format: 'x'}); - } catch(e) { - errors.push(e.message); - } - }) - .then(function() { - expect(errors.length).toBe(1); - expect(errors[0]).toBe('Image format is not jpeg, png, svg or webp.'); + expect(function() { Plotly.toImage(gd, {format: 'x'}); }) + .toThrow(new Error('Image format is not jpeg, png, svg or webp.')); }) .catch(fail) .then(done); @@ -75,27 +67,15 @@ describe('Plotly.toImage', function() { it('should throw error with height and/or width < 1', function(done) { var fig = Lib.extendDeep({}, subplotMock); - var errors = []; Plotly.plot(gd, fig.data, fig.layout) .then(function() { - try { - Plotly.toImage(gd, {height: 0.5}); - } catch(e) { - errors.push(e.message); - } - }) - .then(function() { - try { - Plotly.toImage(gd, {width: 0.5}); - } catch(e) { - errors.push(e.message); - } + expect(function() { Plotly.toImage(gd, {height: 0.5}); }) + .toThrow(new Error('Height and width should be pixel values.')); }) .then(function() { - expect(errors.length).toBe(2); - expect(errors[0]).toBe('Height and width should be pixel values.'); - expect(errors[1]).toBe('Height and width should be pixel values.'); + expect(function() { Plotly.toImage(gd, {width: 0.5}); }) + .toThrow(new Error('Height and width should be pixel values.')); }) .catch(fail) .then(done); From 7565d0b8089c01562b5a953d6e2bfff6cea96048 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Etienne=20T=C3=A9treault-Pinard?= Date: Mon, 14 Aug 2017 16:20:48 -0400 Subject: [PATCH 11/15] allow plotGlPixelRatio to be overrode by user config - :hocho: config assignments to we already get for free via staticPlot: true --- src/plot_api/to_image.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/plot_api/to_image.js b/src/plot_api/to_image.js index a388839d01c..5ce72e9a45d 100644 --- a/src/plot_api/to_image.js +++ b/src/plot_api/to_image.js @@ -128,10 +128,7 @@ function toImage(gd, opts) { // extend config for static plot var configImage = Lib.extendFlat({}, config, { staticPlot: true, - plotGlPixelRatio: 2, - displaylogo: false, - showLink: false, - showTips: false, + plotGlPixelRatio: config.plotGlPixelRatio || 2, setBackground: setBackground }); From 3c42efbe778adc1afd7113e49dda7395dceaa5d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Etienne=20T=C3=A9treault-Pinard?= Date: Mon, 14 Aug 2017 16:21:09 -0400 Subject: [PATCH 12/15] rename isBadlySet -> isImpliedOrValid --- src/plot_api/to_image.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/plot_api/to_image.js b/src/plot_api/to_image.js index 5ce72e9a45d..c0b6f30a173 100644 --- a/src/plot_api/to_image.js +++ b/src/plot_api/to_image.js @@ -90,15 +90,15 @@ function toImage(gd, opts) { config = gd._context; } - function isBadlySet(attr) { + function isImpliedOrValid(attr) { return !(attr in opts) || Lib.validate(opts[attr], attrs[attr]); } - if(!isBadlySet('width') || !isBadlySet('height')) { + if(!isImpliedOrValid('width') || !isImpliedOrValid('height')) { throw new Error('Height and width should be pixel values.'); } - if(!isBadlySet('format')) { + if(!isImpliedOrValid('format')) { throw new Error('Image format is not jpeg, png, svg or webp.'); } From 4ec3e0f90ce2689bb617cb1b0dce86a3e593d035 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Etienne=20T=C3=A9treault-Pinard?= Date: Mon, 14 Aug 2017 16:22:29 -0400 Subject: [PATCH 13/15] move IE-specific SVG string tweaks to toSVG - make Plotly.toImage bypass svgToImg even when imageDataOnly is false. --- src/plot_api/to_image.js | 8 ++++++-- src/snapshot/svgtoimg.js | 42 ++++++++++------------------------------ src/snapshot/tosvg.js | 20 +++++++++++++++++++ 3 files changed, 36 insertions(+), 34 deletions(-) diff --git a/src/plot_api/to_image.js b/src/plot_api/to_image.js index c0b6f30a173..0b0d1a5ef64 100644 --- a/src/plot_api/to_image.js +++ b/src/plot_api/to_image.js @@ -144,8 +144,12 @@ function toImage(gd, opts) { return new Promise(function(resolve, reject) { var svg = toSVG(clonedGd); - if(format === 'svg' && imageDataOnly) { - return resolve(svg); + if(format === 'svg') { + if(imageDataOnly) { + return resolve(svg); + } else { + return resolve('data:image/svg+xml,' + encodeURIComponent(svg)); + } } var canvas = document.createElement('canvas'); diff --git a/src/snapshot/svgtoimg.js b/src/snapshot/svgtoimg.js index a14d096738c..86310cf5413 100644 --- a/src/snapshot/svgtoimg.js +++ b/src/snapshot/svgtoimg.js @@ -12,49 +12,27 @@ var Lib = require('../lib'); var EventEmitter = require('events').EventEmitter; function svgToImg(opts) { - var ev = opts.emitter || new EventEmitter(); var promise = new Promise(function(resolve, reject) { - var Image = window.Image; - var svg = opts.svg; var format = opts.format || 'png'; - // IE is very strict, so we will need to clean - // svg with the following regex - // yes this is messy, but do not know a better way - // Even with this IE will not work due to tainted canvas - // see https://github.com/kangax/fabric.js/issues/1957 - // http://stackoverflow.com/questions/18112047/canvas-todataurl-working-in-all-browsers-except-ie10 - // Leave here just in case the CORS/tainted IE issue gets resolved - if(Lib.isIE()) { - // replace double quote with single quote - svg = svg.replace(/"/gi, '\''); - // url in svg are single quoted - // since we changed double to single - // we'll need to change these to double-quoted - svg = svg.replace(/(\('#)([^']*)('\))/gi, '(\"$2\")'); - // font names with spaces will be escaped single-quoted - // we'll need to change these to double-quoted - svg = svg.replace(/(\\')/gi, '\"'); - // IE only support svg - if(format !== 'svg') { - var ieSvgError = new Error('Sorry IE does not support downloading from canvas. Try {format:\'svg\'} instead.'); - reject(ieSvgError); - // eventually remove the ev - // in favor of promises - if(!opts.promise) { - return ev.emit('error', ieSvgError); - } else { - return promise; - } + // IE only support svg + if(Lib.isIE() && format !== 'svg') { + var ieSvgError = new Error('Sorry IE does not support downloading from canvas. Try {format:\'svg\'} instead.'); + reject(ieSvgError); + // eventually remove the ev + // in favor of promises + if(!opts.promise) { + return ev.emit('error', ieSvgError); + } else { + return promise; } } var canvas = opts.canvas; - var ctx = canvas.getContext('2d'); var img = new Image(); diff --git a/src/snapshot/tosvg.js b/src/snapshot/tosvg.js index 70308d112e1..ea7189bedc3 100644 --- a/src/snapshot/tosvg.js +++ b/src/snapshot/tosvg.js @@ -11,6 +11,7 @@ var d3 = require('d3'); +var Lib = require('../lib'); var Drawing = require('../components/drawing'); var Color = require('../components/color'); @@ -143,5 +144,24 @@ module.exports = function toSVG(gd, format) { // Fix quotations around font strings and gradient URLs s = s.replace(DUMMY_REGEX, '\''); + // IE is very strict, so we will need to clean + // svg with the following regex + // yes this is messy, but do not know a better way + // Even with this IE will not work due to tainted canvas + // see https://github.com/kangax/fabric.js/issues/1957 + // http://stackoverflow.com/questions/18112047/canvas-todataurl-working-in-all-browsers-except-ie10 + // Leave here just in case the CORS/tainted IE issue gets resolved + if(Lib.isIE()) { + // replace double quote with single quote + s = s.replace(/"/gi, '\''); + // url in svg are single quoted + // since we changed double to single + // we'll need to change these to double-quoted + s = s.replace(/(\('#)([^']*)('\))/gi, '(\"$2\")'); + // font names with spaces will be escaped single-quoted + // we'll need to change these to double-quoted + s = s.replace(/(\\')/gi, '\"'); + } + return s; }; From 816df165605a401764140396aeec2e6b0a516bc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Etienne=20T=C3=A9treault-Pinard?= Date: Mon, 14 Aug 2017 17:20:12 -0400 Subject: [PATCH 14/15] purge cloneGd before resolving/rejecting convert promise - so that even 'svg' formats (which bypass svgToImg) clear the clone graph div - no need to remove *all* graph div in toImage test anymore :tada: --- src/plot_api/to_image.js | 19 +++++++++---------- test/jasmine/tests/toimage_test.js | 8 ++------ 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/plot_api/to_image.js b/src/plot_api/to_image.js index 0b0d1a5ef64..5e854c6458e 100644 --- a/src/plot_api/to_image.js +++ b/src/plot_api/to_image.js @@ -143,6 +143,11 @@ function toImage(gd, opts) { function convert() { return new Promise(function(resolve, reject) { var svg = toSVG(clonedGd); + var width = clonedGd._fullLayout.width; + var height = clonedGd._fullLayout.height; + + Plotly.purge(clonedGd) + document.body.removeChild(clonedGd) if(format === 'svg') { if(imageDataOnly) { @@ -157,8 +162,8 @@ function toImage(gd, opts) { svgToImg({ format: format, - width: clonedGd._fullLayout.width, - height: clonedGd._fullLayout.height, + width: width, + height: height, canvas: canvas, svg: svg, // ask svgToImg to return a Promise @@ -167,14 +172,8 @@ function toImage(gd, opts) { // compatibility promise: true }) - .then(function(url) { - Plotly.purge(clonedGd); - document.body.removeChild(clonedGd); - resolve(url); - }) - .catch(function(err) { - reject(err); - }); + .then(resolve) + .catch(reject) }); } diff --git a/test/jasmine/tests/toimage_test.js b/test/jasmine/tests/toimage_test.js index fad0b27fbc3..4312a1772e0 100644 --- a/test/jasmine/tests/toimage_test.js +++ b/test/jasmine/tests/toimage_test.js @@ -3,6 +3,7 @@ var Lib = require('@src/lib'); var d3 = require('d3'); var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); var fail = require('../assets/fail_test'); var customMatchers = require('../assets/custom_matchers'); var subplotMock = require('@mocks/multiple_subplots.json'); @@ -20,12 +21,7 @@ describe('Plotly.toImage', function() { gd = createGraphDiv(); }); - afterEach(function() { - // make sure ALL graph divs are deleted, - // even the ones generated by Plotly.toImage - d3.selectAll('.js-plotly-plot').remove(); - d3.selectAll('#graph').remove(); - }); + afterEach(destroyGraphDiv) function createImage(url) { return new Promise(function(resolve, reject) { From 640503ac53a05f615b1b4c4591a865b325a8558c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Etienne=20T=C3=A9treault-Pinard?= Date: Mon, 14 Aug 2017 17:43:24 -0400 Subject: [PATCH 15/15] lint --- src/plot_api/to_image.js | 6 +++--- test/jasmine/tests/toimage_test.js | 3 +-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/plot_api/to_image.js b/src/plot_api/to_image.js index 5e854c6458e..4ce0821aaaa 100644 --- a/src/plot_api/to_image.js +++ b/src/plot_api/to_image.js @@ -146,8 +146,8 @@ function toImage(gd, opts) { var width = clonedGd._fullLayout.width; var height = clonedGd._fullLayout.height; - Plotly.purge(clonedGd) - document.body.removeChild(clonedGd) + Plotly.purge(clonedGd); + document.body.removeChild(clonedGd); if(format === 'svg') { if(imageDataOnly) { @@ -173,7 +173,7 @@ function toImage(gd, opts) { promise: true }) .then(resolve) - .catch(reject) + .catch(reject); }); } diff --git a/test/jasmine/tests/toimage_test.js b/test/jasmine/tests/toimage_test.js index 4312a1772e0..df348abf85e 100644 --- a/test/jasmine/tests/toimage_test.js +++ b/test/jasmine/tests/toimage_test.js @@ -1,7 +1,6 @@ var Plotly = require('@lib'); var Lib = require('@src/lib'); -var d3 = require('d3'); var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var fail = require('../assets/fail_test'); @@ -21,7 +20,7 @@ describe('Plotly.toImage', function() { gd = createGraphDiv(); }); - afterEach(destroyGraphDiv) + afterEach(destroyGraphDiv); function createImage(url) { return new Promise(function(resolve, reject) {