Skip to content

Improve Plotly.toImage #1939

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Aug 14, 2017
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
240 changes: 165 additions & 75 deletions src/plot_api/to_image.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <div>
* or an id to an existing graph <div>
* @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.');
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice job using coerce for this! But isBadlySet is confusing me... it's true if you either haven't set the attribute or if it has a valid input? Wouldn't that be isValidOrImplied or something? Anyway I don't see how you can use the same logic for format (which has a default) as for width and height (which don't).

Dimensions you could just look at fullOpts after coerce to see if you have something that's not undefined (the implicit dflt)...

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I see, it is OK to omit dimensions, then they default to what's in the layout... might be clearer if this was just used as the default width = coerce('width', layout.width)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might be clearer if this was just used as the default width = coerce('width', layout.width)?

right, but layout.width might not exist at this stage.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, but if it's not in opts and it's not in layout then what width do we get?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We get the dflt from plots/layout_attributes.js

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, I don't want to beat a dead horse and it's not a big deal, but I believe it would still work just fine if you coerce with a dflt which might be undefined at this point, then you coerce with the real dflt later on, overriding that undefined.

Anyway my initial comment stands, about isBadlySet being misnamed and confusing logic.

Copy link
Contributor Author

@etpinard etpinard Aug 14, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isBadlySet -> isImpliedOrValid in 3c42efb


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,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is plotGlPixelRatio something people might want to override? Would it work if they could?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, potentially. Good 👀

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done in 7565d0b

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);
Copy link
Contributor Author

@etpinard etpinard Aug 8, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fast pass for svg with imageDataOnly where Snapshot.toSVG gives the correct result.

Note that PDF and EPS images should first be generated as svg and then converted to pdf or eps using another library. In-house PDF and EPS image generation is outside the scope of plotly.js.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it worth handling imageDataOnly=false here too - and just adding the prefix? Or is it more complicated than that?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it shouldn't be too hard. I'll add that in.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done in 4ec3e0f

}

// 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;
Loading