Skip to content

Commit 1c164bb

Browse files
authored
Merge pull request #62 from plotly/last-fixed-branch-3
Dash App Preview generation
2 parents 934999d + e7f5e4d commit 1c164bb

File tree

9 files changed

+332
-2
lines changed

9 files changed

+332
-2
lines changed

bin/plotly-export-server_electron.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ const opts = {
5353
name: 'plotly-dashboard-preview',
5454
route: '/dashboard-preview',
5555
options: plotlyJsOpts
56+
},
57+
{
58+
name: 'plotly-dash-preview',
59+
route: '/dash-preview'
5660
}]
5761
}
5862

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
"get-stdin": "^5.0.1",
5050
"glob": "^7.1.2",
5151
"is-plain-obj": "^1.1.0",
52-
"is-url": "^1.2.2",
52+
"is-url": "^1.2.4",
5353
"minimist": "^1.2.0",
5454
"read-chunk": "^2.1.0",
5555
"request": "^2.81.0",
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
const pixelsInInch = 96
2+
const micronsInInch = 25400
3+
4+
module.exports = {
5+
minInterval: 500,
6+
maxRenderingTries: 100,
7+
pixelsInMicron: pixelsInInch / micronsInInch,
8+
sizeMapping: {
9+
'A3': {'width': 11.7 * pixelsInInch, 'height': 16.5 * pixelsInInch},
10+
'A4': {'width': 8.3 * pixelsInInch, 'height': 11.7 * pixelsInInch},
11+
'A5': {'width': 5.8 * pixelsInInch, 'height': 8.3 * pixelsInInch},
12+
'Letter': {'width': 8.5 * pixelsInInch, 'height': 11 * pixelsInInch},
13+
'Legal': {'width': 8.5 * pixelsInInch, 'height': 14 * pixelsInInch},
14+
'Tabloid': {'width': 11 * pixelsInInch, 'height': 17 * pixelsInInch}
15+
},
16+
statusMsg: {
17+
525: 'dash preview generation failed',
18+
526: 'dash preview generation timed out'
19+
}
20+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
const cst = require('../plotly-graph/constants')
2+
3+
/**
4+
* @param {object} info : info object
5+
* - imgData
6+
* @param {object} opts : component options
7+
* @param {function} reply
8+
* - errorCode
9+
* - result
10+
*/
11+
function convert (info, opts, reply) {
12+
const result = {}
13+
14+
result.head = {}
15+
result.head['Content-Type'] = cst.contentFormat.pdf
16+
result.bodyLength = result.head['Content-Length'] = info.imgData.length
17+
result.body = Buffer.from(info.imgData, 'base64')
18+
19+
reply(null, result)
20+
}
21+
22+
module.exports = convert
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
module.exports = {
2+
name: 'plotly-dash-preview',
3+
ping: require('../../util/generic-ping'),
4+
// inject is not required here, but omitting it causes test-failures
5+
inject: require('./../plotly-graph/inject'),
6+
parse: require('./parse'),
7+
render: require('./render'),
8+
convert: require('./convert')
9+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
const isUrl = require('is-url')
2+
const cst = require('./constants')
3+
const isPositiveNumeric = require('../../util/is-positive-numeric')
4+
const isNonEmptyString = require('../../util/is-non-empty-string')
5+
6+
/**
7+
* @param {object} body : JSON-parsed request body
8+
* - url
9+
* - pdfOptions
10+
* @param {object} opts : component options
11+
* @param {function} sendToRenderer
12+
* - errorCode
13+
* - result
14+
*/
15+
function parse (body, opts, sendToRenderer) {
16+
const result = {}
17+
18+
const errorOut = (code, msg) => {
19+
result.msg = msg
20+
sendToRenderer(code, result)
21+
}
22+
23+
if (isUrl(body.url)) {
24+
result.url = body.url
25+
} else {
26+
return errorOut(400, 'invalid url')
27+
}
28+
29+
result.pdfOptions = body.pdf_options || {}
30+
if (!isNonEmptyString(body.selector) && !isPositiveNumeric(body.timeout)) {
31+
return errorOut(400, 'either selector or timeout must be specified')
32+
}
33+
34+
result.selector = body.selector
35+
result.timeOut = body.timeout
36+
result.tries = Number(result.timeOut * 1000 / cst.minInterval)
37+
38+
if (cst.sizeMapping[result.pdfOptions.pageSize]) {
39+
result.browserSize = cst.sizeMapping[result.pdfOptions.pageSize]
40+
} else if (body.pageSize && isPositiveNumeric(body.pageSize.width) &&
41+
isPositiveNumeric(body.pageSize.height)) {
42+
result.browserSize = {
43+
width: body.pageSize.width * cst.pixelsInMicron,
44+
height: body.pageSize.height * cst.pixelsInMicron
45+
}
46+
} else {
47+
return errorOut(
48+
400,
49+
'pageSize must either be A3, A4, A5, Legal, Letter, ' +
50+
'Tabloid or an Object containing height and width ' +
51+
'in microns.'
52+
)
53+
}
54+
55+
sendToRenderer(null, result)
56+
}
57+
58+
module.exports = parse
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
const remote = require('../../util/remote')
2+
const cst = require('./constants')
3+
4+
/**
5+
* @param {object} info : info object
6+
* - url
7+
* - pdfOptions
8+
* @param {object} opts : component options
9+
* @param {function} sendToMain
10+
* - errorCode
11+
* - result
12+
* - imgData
13+
*/
14+
function render (info, opts, sendToMain) {
15+
const result = {}
16+
17+
let win = remote.createBrowserWindow(info.browserSize)
18+
win.loadURL(info.url)
19+
20+
const contents = win.webContents
21+
22+
const done = errorCode => {
23+
win.close()
24+
25+
if (errorCode) {
26+
result.msg = cst.statusMsg[errorCode]
27+
}
28+
sendToMain(errorCode, result)
29+
}
30+
31+
/*
32+
* We check for a 'waitfor' div in the dash-app
33+
* which indicates that the app has finished rendering.
34+
*/
35+
const loaded = () => {
36+
return win.webContents.executeJavaScript(`
37+
new Promise((resolve, reject) => {
38+
let tries = ${info.tries} || ${cst.maxRenderingTries}
39+
40+
let interval = setInterval(() => {
41+
let el = document.querySelector('${info.selector}')
42+
43+
if (el) {
44+
clearInterval(interval)
45+
resolve(true)
46+
}
47+
48+
if (--tries === 0) {
49+
clearInterval(interval)
50+
51+
if (${info.timeOut}) {
52+
resolve(true)
53+
} else {
54+
reject('fail to load')
55+
}
56+
}
57+
}, ${cst.minInterval})
58+
59+
})`)
60+
}
61+
62+
win.on('closed', () => {
63+
win = null
64+
})
65+
66+
loaded().then(() => {
67+
contents.printToPDF(info.pdfOptions, (err, pdfData) => {
68+
if (err) {
69+
done(525)
70+
} else {
71+
result.imgData = pdfData
72+
done()
73+
}
74+
})
75+
}).catch(() => {
76+
done(526)
77+
})
78+
}
79+
80+
module.exports = render

test/integration/plotly-export-server_test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ tap.tearDown(() => {
3333
tap.test('should launch', t => {
3434
app.start().then(() => {
3535
app.client.getWindowCount().then(cnt => {
36-
t.equal(cnt, 5)
36+
t.equal(cnt, 6)
3737
t.end()
3838
})
3939
})

test/unit/plotly-dash-preview_test.js

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
const tap = require('tap')
2+
const sinon = require('sinon')
3+
4+
const _module = require('../../src/component/plotly-dash-preview')
5+
const remote = require('../../src/util/remote')
6+
const { createMockWindow } = require('../common')
7+
8+
tap.test('parse:', t => {
9+
const fn = _module.parse
10+
11+
t.test('should error when invalid *url* field is given', t => {
12+
const shouldFail = ['', null, true, 1, {}, 'dummy']
13+
14+
shouldFail.forEach(d => {
15+
t.test(`(case ${JSON.stringify(d)})`, t => {
16+
fn({url: d}, {}, (errorCode, result) => {
17+
t.equal(errorCode, 400)
18+
t.same(result, {'msg': 'invalid url'})
19+
t.end()
20+
})
21+
})
22+
})
23+
24+
t.end()
25+
})
26+
t.test('should error when neither loading_selector or timeout is given', t => {
27+
fn({url: 'https://dash-app.com'}, {}, (errorCode, result) => {
28+
t.equal(errorCode, 400)
29+
t.equal(result.msg, 'either selector or timeout must be specified')
30+
t.end()
31+
})
32+
})
33+
t.test('should error when pageSize is not given', t => {
34+
fn({
35+
url: 'https://dash-app.com',
36+
selector: 'dummy'
37+
}, {}, (errorCode, result) => {
38+
t.equal(errorCode, 400)
39+
t.same(result.msg, 'pageSize must either be A3, A4, A5, Legal, Letter, ' +
40+
'Tabloid or an Object containing height and width in microns.')
41+
t.end()
42+
})
43+
})
44+
t.test('should parse properly when pageSize is given', t => {
45+
fn({
46+
url: 'https://dash-app.com',
47+
selector: 'dummy',
48+
pageSize: {height: 1000, width: 1000}
49+
}, {}, (errorCode, result) => {
50+
t.equal(errorCode, null)
51+
52+
// height/width are converted from microns to pixels:
53+
t.same(result.browserSize, {
54+
height: 3.779527559055118,
55+
width: 3.779527559055118
56+
})
57+
t.end()
58+
})
59+
})
60+
t.test('should parse properly when pdf_options are given', t => {
61+
fn({
62+
url: 'https://dash-app.com',
63+
selector: 'dummy',
64+
pdf_options: {pageSize: 'Letter', marginsType: 1}
65+
}, {}, (errorCode, result) => {
66+
t.equal(errorCode, null)
67+
// height/width are converted to pixels from page-type:
68+
t.same(result.browserSize, {height: 1056, width: 816})
69+
t.same(result.pdfOptions, {pageSize: 'Letter', marginsType: 1})
70+
t.end()
71+
})
72+
})
73+
74+
t.end()
75+
})
76+
77+
tap.test('render:', t => {
78+
const fn = _module.render
79+
80+
t.afterEach((done) => {
81+
remote.createBrowserWindow.restore()
82+
done()
83+
})
84+
85+
t.test('should call printToPDF', t => {
86+
const win = createMockWindow()
87+
sinon.stub(remote, 'createBrowserWindow').returns(win)
88+
win.webContents.executeJavaScript.resolves(true)
89+
win.webContents.printToPDF.yields(null, '-> image data <-')
90+
91+
fn({
92+
url: 'https://dummy.com'
93+
}, {}, (errorCode, result) => {
94+
t.ok(win.webContents.printToPDF.calledOnce)
95+
t.ok(win.close.calledOnce)
96+
t.equal(errorCode, undefined, 'code')
97+
t.equal(result.imgData, '-> image data <-', 'result')
98+
t.end()
99+
})
100+
})
101+
102+
t.test('should handle executeJavascript errors', t => {
103+
const win = createMockWindow()
104+
sinon.stub(remote, 'createBrowserWindow').returns(win)
105+
win.webContents.executeJavaScript.rejects('fail to load')
106+
win.webContents.printToPDF.yields(null, '-> image data <-')
107+
108+
fn({
109+
url: 'https://dummy.com'
110+
}, {}, (errorCode, result) => {
111+
t.ok(win.webContents.printToPDF.notCalled)
112+
t.ok(win.close.calledOnce)
113+
t.equal(errorCode, 526, 'code')
114+
t.same(result, {'msg': 'dash preview generation timed out'}, 'result')
115+
t.end()
116+
})
117+
})
118+
119+
t.test('should handle printToPDF errors', t => {
120+
const win = createMockWindow()
121+
sinon.stub(remote, 'createBrowserWindow').returns(win)
122+
win.webContents.executeJavaScript.resolves(true)
123+
win.webContents.printToPDF.yields(new Error('printToPDF error'))
124+
125+
fn({
126+
url: 'https://dummy.com'
127+
}, {}, (errorCode, result) => {
128+
t.ok(win.webContents.printToPDF.calledOnce)
129+
t.ok(win.close.calledOnce)
130+
t.equal(errorCode, 525, 'code')
131+
t.equal(result.msg, 'dash preview generation failed', 'error msg')
132+
t.end()
133+
})
134+
})
135+
136+
t.end()
137+
})

0 commit comments

Comments
 (0)