diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index f7fb3e83880..ba3caf2bf63 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); + var blend = Color.combine(bgColor, 'white'); + setBackground(gd, blend); } function setPlotContext(gd, config) { @@ -410,8 +418,9 @@ function setPlotContext(gd, config) { if(key in context) { if(key === 'setBackground' && config[key] === 'opaque') { context[key] = opaqueSetBackground; + } else { + context[key] = config[key]; } - else context[key] = config[key]; } } @@ -460,6 +469,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..27f4735a24e 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. @@ -108,9 +106,11 @@ 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 - setBackground: defaultSetBackground, + // 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', // URL to topojson files used in geo charts topojsonURL: 'https://cdn.plot.ly/', @@ -128,16 +128,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); - } - } -} diff --git a/src/plot_api/to_image.js b/src/plot_api/to_image.js index 6ebcf75b367..4ce0821aaaa 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*', + '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 isImpliedOrValid(attr) { + return !(attr in opts) || Lib.validate(opts[attr], attrs[attr]); + } + + if(!isImpliedOrValid('width') || !isImpliedOrValid('height')) { + throw new Error('Height and width should be pixel values.'); + } + + if(!isImpliedOrValid('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: config.plotGlPixelRatio || 2, + 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; - } - - if(isNumeric(size) && size > 1) { - return true; + var redrawFunc = helpers.getRedrawFunc(clonedGd); + + function wait() { + return new Promise(function(resolve) { + setTimeout(resolve, helpers.getDelay(clonedGd._fullLayout)); + }); + } + + 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) { + return resolve(svg); + } else { + return resolve('data:image/svg+xml,' + encodeURIComponent(svg)); + } } - return false; - }; - - if(!isSizeGood(opts.width) || !isSizeGood(opts.height)) { - reject(new Error('Height and width should be pixel values.')); - } - - // 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: width, + height: height, + canvas: canvas, + svg: svg, + // ask svgToImg to return a Promise + // rather than EventEmitter + // leave EventEmitter for backward + // compatibility + promise: true + }) + .then(resolve) + .catch(reject); + }); + } + + 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/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() { diff --git a/src/snapshot/svgtoimg.js b/src/snapshot/svgtoimg.js index f90e4bb386b..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(); @@ -89,11 +67,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); 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; }; 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) { diff --git a/test/jasmine/tests/toimage_test.js b/test/jasmine/tests/toimage_test.js index 6cde6567067..df348abf85e 100644 --- a/test/jasmine/tests/toimage_test.js +++ b/test/jasmine/tests/toimage_test.js @@ -1,29 +1,35 @@ -// move toimage to plot_api_test.js -// once established and confirmed? +var Plotly = require('@lib'); +var Lib = require('@src/lib'); -var Plotly = require('@lib/index'); - -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'); - describe('Plotly.toImage', function() { 'use strict'; var gd; + beforeAll(function() { + jasmine.addMatchers(customMatchers); + }); + beforeEach(function() { gd = createGraphDiv(); }); - afterEach(function() { + afterEach(destroyGraphDiv); - // make sure ALL graph divs are deleted, - // even the ones generated by Plotly.toImage - d3.selectAll('.js-plotly-plot').remove(); - 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(); @@ -43,80 +49,137 @@ 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); + + Plotly.plot(gd, fig.data, fig.layout) + .then(function(gd) { + expect(function() { Plotly.toImage(gd, {format: 'x'}); }) + .toThrow(new Error('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); + + Plotly.plot(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.')); + }) + .catch(fail) + .then(done); }); 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); + }); + + 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, '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(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, 'webp image length'); + }) + .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); }); });