8000 plotly-graph PDF with electron's printToPDF by etpinard · Pull Request #6 · plotly/orca · GitHub
[go: up one dir, main page]

Skip to content

plotly-graph PDF with electron's printToPDF #6

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 19 commits into from
Sep 1, 2017
Merged
Show file tree
Hide file tree
Changes from 2 commits
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
2 changes: 1 addition & 1 deletion bin/plotly-graph-exporter_electron.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ getStdin().then((txt) => {
mapboxAccessToken: argv['mapbox-access-token'],
mathjax: argv.mathjax,
topojson: argv.topojson,
batik: argv.batik || path.join(__dirname, '..', 'build', 'batik-1.7', 'batik-rasterizer.jar'),
batik: argv.batik,
format: argv.format,
scale: argv.scale,
width: argv.width,
Expand Down
7 changes: 7 additions & 0 deletions src/component/plotly-dashboard/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module.exports = {
iframeLoadDelay: 5000,

statusMsg: {
525: 'print to PDF error'
}
}
36 changes: 15 additions & 21 deletions src/component/plotly-dashboard/render.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const IFRAME_LOAD_TIMEOUT = 5000
const remote = require('../../util/remote')
const cst = require('./constants')

/**
* @param {object} info : info object
Expand All @@ -12,35 +13,28 @@ const IFRAME_LOAD_TIMEOUT = 5000
* - imgData
*/
function render (info, opts, sendToMain) {
// Cannot require 'remote' in the module scope
// as this file gets required in main process first
// during the coerce-component step
//
// TODO
// - maybe require this in <html> from create-index,
// so that we don't have to worry about requiring it
// inside the function body AND to make mockable for testing
const {BrowserWindow} = require('electron').remote

let win = new BrowserWindow({
let win = remote.createBrowserWindow({
width: info.width,
height: info.height
})

const contents = win.webContents
const result = {}
let contents = win.webContents
let errorCode = null

const done = () => {
win.close()

if (errorCode) {
result.msg = cst.statusMsg[errorCode]
}
sendToMain(errorCode, result)
}

win.on('closed', () => {
win = null
})

// ... or plain index.html + `win.executeJavascript`
win.loadURL(info.url)

// TODO
// - find better solution than IFRAME_LOAD_TIMEOUT
// - but really, we shouldn't be using iframes in embed view?
Expand All @@ -51,17 +45,17 @@ function render (info, opts, sendToMain) {
setTimeout(() => {
contents.printToPDF({}, (err, imgData) => {
if (err) {
result.msg = 'print to PDF error'
sendToMain(525, result)
errorCode = 525
return done()
}

result.imgData = imgData
sendToMain(null, result)
done()
return done()
})
}, IFRAME_LOAD_TIMEOUT)
}, cst.iframeLoadDelay)
})

win.loadURL(info.url)
}

module.exports = render
62 changes: 37 additions & 25 deletions src/component/plotly-graph/convert.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,46 +32,58 @@ function convert (info, opts, reply) {
reply(errorCode, result)
}

const toBuffer = () => {
const body = result.body = Buffer.from(imgData, 'base64')
result.bodyLength = result.head['Content-Length'] = body.length
return done()
}

const convertSVG = () => {
const batik = opts.batik instanceof Batik
? opts.batik
: new Batik(opts.batik)

batik.convertSVG(imgData, {format: format}, (err, buf) => {
if (err) {
errorCode = 530
result.error = err
return done()
}

result.bodyLength = result.head['Content-Length'] = buf.length
result.body = buf
return done()
})
}

// TODO
const pdf2eps = () => {}

// TODO
// - should pdf and eps format be part of a streambed-only component?
// - should we use batik for that or something?
// - is the 'encoded' option still relevant?

switch (format) {
case 'png':
case 'jpeg':
case 'webp':
const body = result.body = Buffer.from(imgData, 'base64')
result.bodyLength = result.head['Content-Length'] = body.length
return done()
return toBuffer()
case 'svg':
// see http://stackoverflow.com/a/12205668/800548
result.body = imgData
result.bodyLength = encodeURI(imgData).split(/%..|./).length - 1
return done()
case 'pdf':
if (opts.batik) {
return convertSVG()
} else {
return toBuffer()
}
case 'eps':
if (!opts.batik) {
errorCode = 530
result.error = new Error('path to batik-rasterizer jar not given')
return done()
if (opts.batik) {
return convertSVG()
} else {
return pdf2eps()
}

const batik = opts.batik instanceof Batik
? opts.batik
: new Batik(opts.batik)

batik.convertSVG(info.imgData, {format: format}, (err, buf) => {
if (err) {
errorCode = 530
result.error = err
return done()
}

result.bodyLength = result.head['Content-Length'] = buf.length
result.body = buf
done()
})
}
}

Expand Down
92 changes: 82 additions & 10 deletions src/component/plotly-graph/render.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/* global Plotly:false */

const cst = require('./constants')
const semver = require('semver')
const remote = require('../../util/remote')
const cst = require('./constants')

/**
* @param {object} info : info object
Expand All @@ -12,6 +13,7 @@ const semver = require('semver')
* - scale
* @param {object} opts : component options
* - mapboxAccessToken
* - batik
* @param {function} sendToMain
* - errorCode
* - result
Expand Down Expand Up @@ -40,26 +42,34 @@ function render (info, opts, sendToMain) {
// - figure out if we still need this:
// https://github.com/plotly/streambed/blob/7311d4386d80d45999797e87992f43fb6ecf48a1/image_server/server_app/main.js#L224-L229
// - increase pixel ratio images (scale up here, scale down in convert) ??
// + scale down using https://github.com/oliver-moran/jimp ??
// - does webp (via batik) support transparency now?

const PDF_OR_EPS = (format === 'pdf' || format === 'eps')
const PRINT_TO_PDF = PDF_OR_EPS && !opts.batik

const imgOpts = {
format: (format === 'pdf' || format === 'eps') ? 'svg' : format,
format: PDF_OR_EPS ? 'svg' : format,
width: info.scale * info.width,
height: info.scale * info.height,
// return image data w/o the leading 'data:image' spec
imageDataOnly: true,
imageDataOnly: !PRINT_TO_PDF,
// blend jpeg background color as jpeg does not support transparency
setBackground: format === 'jpeg' ? 'opaque' : ''
}

let promise

if (semver.gte(Plotly.version, '1.30.0')) {
promise = Plotly.toImage({
data: figure.data,
layout: figure.layout,
config: config
}, imgOpts)
promise = Plotly
.toImage({data: figure.data, layout: figure.layout, config: config}, imgOpts)
.then((imgData) => {
if (PRINT_TO_PDF) {
return toPDF(imgData, imgOpts)
} else {
return imgData
}
})
} else if (semver.gte(Plotly.version, '1.11.0')) {
const gd = document.createElement('div')

Expand All @@ -69,13 +79,20 @@ function render (info, opts, sendToMain) {
.then((imgData) => {
Plotly.purge(gd)

switch (imgOpts.format) {
switch (format) {
case 'png':
case 'jpeg':
case 'webp':
return imgData.replace(cst.imgPrefix.base64, '')
case 'svg':
return decodeURIComponent(imgData.replace(cst.imgPrefix.svg, ''))
return decodeSVG(imgData)
case 'pdf':
case 'eps':
if (PRINT_TO_PDF) {
return toPDF(imgData, imgOpts, info)
} else {
return decodeSVG(imgData)
}
}
})
} else {
Expand All @@ -95,4 +112,59 @@ function render (info, opts, sendToMain) {
})
}

function decodeSVG (imgData) {
return window.decodeURIComponent(imgData.replace(cst.imgPrefix.svg, ''))
}

/**
* See https://github.com/electron/electron/blob/master/docs/api/web-contents.md#contentsprinttopdfoptions-callback
* for other available options
*/
function toPDF (imgData, imgOpts, info) {
const win = remote.getCurrentWindow()

// TODO
// - how to (robustly) get pixel to microns (for pageSize) conversion factor
// - this work great, except runner app can't get all pdf to generate
// when parallelLimit > 1 ???
// + figure out why???
// + maybe restrict that in coerce-opts?
const printOpts = {
marginsType: 1,
printSelectionOnly: true,
pageSize: {
width: (imgOpts.width) / 0.0035,
height: (imgOpts.height) / 0.0035
Copy link
Contributor Author
@etpinard etpinard Aug 22, 2017

Choose a reason for hiding this comment

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

pageSize here expects numbers in micrometer, plotly.js set width and height in pixels. I took this number from this source. I gives ok results, but not perfect.

Comparing the batik and electron outputs of a graph with non-white bg illustrates the issue:

image

batik on the left / printToPDF on the right.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Commit 5992a8b improves this.

But printToPDF still output a (small white) margin:

image

Would this be a deal-breaker for printToPDF?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

with 135cb1b

image

those white margins are gone (actually, they're still there, but now they appear with background-color: fullLayout.paper_bgcolor)

Copy link
Collaborator

Choose a reason for hiding this comment

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

Thanks for the deep dive into this one @etpinard - given all the other improvements we get with printToPDF I think this extra bit of margin, now that it keeps the right color, is acceptable.

Might be worthwhile at some point taking another look at this but for the first release this is great.

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 worthwhile at some point taking another look at this

For sure. I'll open an issue as soon as this PR is merged.

}
}

return new Promise((resolve, reject) => {
const div = document.createElement('div')
const img = document.createElement('img')

document.body.appendChild(div)
div.appendChild(img)

img.addEventListener('load', () => {
window.getSelection().selectAllChildren(div)

win.webContents.printToPDF(printOpts, (err, pdfData) => {
document.body.removeChild(div)

if (err) {
return reject(new Error('electron print to PDF error'))
}
return resolve(pdfData)
})
})

img.addEventListener('error', () => {
document.body.removeChild(div)
return reject(new Error('image failed to load'))
})

img.src = imgData
})
}

module.exports = render
18 changes: 18 additions & 0 deletions src/util/remote.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/* Small wrapper around the renderer process 'remote' module, to
* easily mock it using sinon in test/common.js
*
* More info on the remote module:
* - https://electron.atom.io/docs/api/remote/
*/

function load () {
return require('electron').remote
}

module.exports = {
createBrowserWindow: (opts) => {
const _module = load()
return new _module.BrowserWindow(opts)
},
getCurrentWindow: () => load().getCurrentWindow()
}
27 changes: 26 additions & 1 deletion test/common.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
const path = require('path')
const EventEmitter = require('events')
const sinon = require('sinon')

const paths = {}
const urls = {}
Expand All @@ -13,7 +15,30 @@ paths.glob = path.join(paths.root, 'src', 'util', '*')
urls.dummy = 'http://dummy.url'
urls.plotlyGraphMock = 'https://raw.githubusercontent.com/plotly/plotly.js/master/test/image/mocks/20.json'

function createMockWindow (opts = {}) {
const win = new EventEmitter()
const webContents = new EventEmitter()

webContents.printToPDF = sinon.stub()

Object.assign(win, opts, {
webContents: webContents,
loadURL: () => { webContents.emit('did-finish-load') },
close: sinon.stub()
})

return win
}

function stubProp (obj, key, newVal) {
const oldVal = obj[key]
obj[key] = newVal
return () => { obj[key] = oldVal }
}

module.exports = {
paths: paths,
urls: urls
urls: urls,
createMockWindow: createMockWindow,
stubProp: stubProp
}
Loading
0