diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 00000000..2b6413e9 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,33 @@ +version: 2 + +jobs: + build: + docker: + # see https://circleci.com/docs/2.0/circleci-images/ + # use '-browsers' version to have access to xvfb wrappers + - image: circleci/node:6.10.3-browsers + + steps: + - checkout + + # Download and cache dependencies + - restore_cache: + keys: + - v1-dependencies-{{ checksum "package.json" }} + # fallback to using the latest cache if no exact match is found + - v1-dependencies- + + - run: npm install + + - save_cache: + paths: + - node_modules + key: v1-dependencies-{{ checksum "package.json" }} + + # install pdftops + - run: sudo apt-get install poppler-utils + + - run: npm test + + - store_artifacts: + path: build diff --git a/bin/args.js b/bin/args.js index 0e187a05..7b176bcf 100644 --- a/bin/args.js +++ b/bin/args.js @@ -1,7 +1,7 @@ const plotlyGraphCst = require('../src/component/plotly-graph/constants') const minimist = require('minimist') -const PLOTLYJS_STRING = ['plotly', 'mapbox-access-token', 'topojson', 'mathjax', 'batik'] +const PLOTLYJS_STRING = ['plotly', 'mapbox-access-token', 'topojson', 'mathjax'] const PLOTLYJS_ALIAS = { 'plotly': ['plotlyjs', 'plotly-js', 'plotly_js', 'plotlyJS', 'plotlyJs'], @@ -13,8 +13,7 @@ const PLOTLYJS_DEFAULT = { 'plotly': '', 'mapbox-access-token': process.env.MAPBOX_ACCESS_TOKEN || '', 'topojson': '', - 'mathjax': '', - 'batik': process.env.BATIK_RASTERIZER_PATH || '' + 'mathjax': '' } const DESCRIPTION = { @@ -32,9 +31,7 @@ const DESCRIPTION = { topojson: `Sets path to topojson files. By default topojson files on the plot.ly CDN are used.`, - mathjax: `Sets path to MathJax files. Required to export LaTeX characters.`, - - batik: 'Sets path to batik-rasterizer jar file. Required to export PDF and EPS formats.' + mathjax: `Sets path to MathJax files. Required to export LaTeX characters.` } const EXPORTER_MINIMIST_CONFIG = { @@ -151,9 +148,6 @@ exports.getExporterHelpMsg = function () { --mathjax ${formatAliases('mathjax')} ${DESCRIPTION.mathjax} - --batik - ${DESCRIPTION.batik} - --format ${formatAliases('format')} Sets the output format (${Object.keys(plotlyGraphCst.contentFormat).join(', ')}). Applies to all output images. @@ -219,9 +213,6 @@ exports.getServerHelpMsg = function () { --mathjax ${formatAliases('mathjax')} ${DESCRIPTION.mathjax} - --batik - ${DESCRIPTION.batik} - --debug ${DESCRIPTION.debug} ` diff --git a/bin/plotly-export-server_electron.js b/bin/plotly-export-server_electron.js index 3e2a7a1e..44e58b47 100644 --- a/bin/plotly-export-server_electron.js +++ b/bin/plotly-export-server_electron.js @@ -1,5 +1,4 @@ const plotlyExporter = require('../') -const Batik = require('../src/util/batik') const { getServerArgs, getServerHelpMsg } = require('./args') const pkg = require('../package.json') @@ -20,32 +19,12 @@ if (argv.help) { // - try https://github.com/indexzero/node-portfinder let app -let batik - -if (argv.batik) { - if (!Batik.isJavaInstalled()) { - console.warn('Missing binaries for PDF exports') - process.exit(1) - } - if (!Batik.isPdftopsInstalled()) { - console.warn('Missing binaries for EPS exports') - process.exit(1) - } - - batik = new Batik(argv.batik) - - if (!batik.doesBatikJarExist()) { - console.warn('Path to batik-rasterizer jar file does not exist') - process.exit(1) - } -} const plotlyJsOpts = { plotlyJS: argv.plotlyJS, mapboxAccessToken: argv['mapbox-access-token'], mathjax: argv.mathjax, - topojson: argv.topojson, - batik: batik + topojson: argv.topojson } const opts = { diff --git a/bin/plotly-graph-exporter_electron.js b/bin/plotly-graph-exporter_electron.js index ce3f593b..42fb2e18 100644 --- a/bin/plotly-graph-exporter_electron.js +++ b/bin/plotly-graph-exporter_electron.js @@ -58,7 +58,6 @@ 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'), format: argv.format, scale: argv.scale, width: argv.width, diff --git a/package.json b/package.json index 99634dbf..4bb59446 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "plotly-export-server": "./bin/plotly-export-server.js" }, "scripts": { + "pretest": "node test/pretest.js", "test:lint": "standard | snazzy", "test:unit": "tap test/unit/*_test.js", "test:integration": "tap test/integration/*_test.js", @@ -35,6 +36,7 @@ "request": "^2.81.0", "run-parallel": "^1.1.6", "run-parallel-limit": "^1.0.3", + "run-series": "^1.1.4", "semver": "^5.4.1", "string-to-stream": "^1.1.0", "uuid": "^3.1.0" diff --git a/src/component/plotly-dashboard/constants.js b/src/component/plotly-dashboard/constants.js new file mode 100644 index 00000000..7b6ebf55 --- /dev/null +++ b/src/component/plotly-dashboard/constants.js @@ -0,0 +1,7 @@ +module.exports = { + iframeLoadDelay: 5000, + + statusMsg: { + 525: 'print to PDF error' + } +} diff --git a/src/component/plotly-dashboard/render.js b/src/component/plotly-dashboard/render.js index a5fb5a17..6e01a910 100644 --- a/src/component/plotly-dashboard/render.js +++ b/src/component/plotly-dashboard/render.js @@ -1,4 +1,5 @@ -const IFRAME_LOAD_TIMEOUT = 5000 +const remote = require('../../util/remote') +const cst = require('./constants') /** * @param {object} info : info object @@ -12,56 +13,48 @@ 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 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? // - use `content.capturePage` to render PNGs and JPEGs - // - or use batik? contents.once('did-finish-load', () => { 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 diff --git a/src/component/plotly-graph/constants.js b/src/component/plotly-graph/constants.js index bd837092..eb19c2cf 100644 --- a/src/component/plotly-graph/constants.js +++ b/src/component/plotly-graph/constants.js @@ -28,5 +28,11 @@ module.exports = { svg: /^data:image\/svg\+xml,/ }, - mathJaxConfigQuery: '?config=TeX-AMS-MML_SVG' + mathJaxConfigQuery: '?config=TeX-AMS-MML_SVG', + + // config option passed in render step + plotGlPixelRatio: 3, + + // time [in ms] after which printToPDF errors when image isn't loaded + pdfPageLoadImgTimeout: 2000 } diff --git a/src/component/plotly-graph/convert.js b/src/component/plotly-graph/convert.js index c2fb98ba..b9e885d4 100644 --- a/src/component/plotly-graph/convert.js +++ b/src/component/plotly-graph/convert.js @@ -1,4 +1,4 @@ -const Batik = require('../../util/batik') +const Pdftops = require('../../util/pdftops') const cst = require('./constants') /** plotly-graph convert @@ -7,7 +7,7 @@ const cst = require('./constants') * - format {string} (from parse) * - imgData {string} (from render) * @param {object} opts : component options - * - batik {string or instance of Batik} + * - pdftops {string or instance of Pdftops) * @param {function} reply * - errorCode {number or null} * - result {object} @@ -19,59 +19,63 @@ function convert (info, opts, reply) { const imgData = info.imgData const format = info.format - const result = { - head: {'Content-Type': cst.contentFormat[format]} - } - + const result = {} let errorCode = null + let body + let bodyLength const done = () => { if (errorCode) { result.msg = cst.statusMsg[errorCode] + } else { + result.body = body + result.bodyLength = bodyLength + result.head = { + 'Content-Type': cst.contentFormat[format], + 'Content-Length': bodyLength + } } reply(errorCode, result) } + const pdf2eps = (pdf, cb) => { + const pdftops = opts.pdftops instanceof Pdftops + ? opts.pdftops + : new Pdftops(opts.pdftops) + + pdftops.pdf2eps(pdf, {id: info.id}, (err, eps) => { + if (err) { + errorCode = 530 + result.error = err + return done() + } + cb(eps) + }) + } + // 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 + case 'pdf': + body = Buffer.from(imgData, 'base64') + bodyLength = body.length return done() case 'svg': // see http://stackoverflow.com/a/12205668/800548 - result.body = imgData - result.bodyLength = encodeURI(imgData).split(/%..|./).length - 1 + body = imgData + bodyLength = encodeURI(imgData).split(/%..|./).length - 1 return done() - case 'pdf': case 'eps': - if (!opts.batik) { - errorCode = 530 - result.error = new Error('path to batik-rasterizer jar not given') + pdf2eps(imgData, (eps) => { + body = eps + bodyLength = body.length return done() - } - - 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() }) + break } } diff --git a/src/component/plotly-graph/render.js b/src/component/plotly-graph/render.js index 08d84995..22a797a4 100644 --- a/src/component/plotly-graph/render.js +++ b/src/component/plotly-graph/render.js @@ -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 @@ -21,10 +22,10 @@ function render (info, opts, sendToMain) { const figure = info.figure const format = info.format - const config = Object.assign({}, - {mapboxAccessToken: opts.mapboxAccessToken || ''}, - figure.config - ) + const config = Object.assign({ + mapboxAccessToken: opts.mapboxAccessToken || '', + plotGlPixelRatio: cst.plotGlPixelRatio + }, figure.config) const result = {} let errorCode = null @@ -36,30 +37,41 @@ function render (info, opts, sendToMain) { sendToMain(errorCode, result) } - // TODO - // - 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) ?? - // - does webp (via batik) support transparency now? + const PRINT_TO_PDF = (format === 'pdf' || format === 'eps') + + // stash `paper_bgcolor` here in order to set the pdf window bg color + let bgColor + const pdfBackground = (gd, _bgColor) => { + if (!bgColor) bgColor = _bgColor + gd._fullLayout.paper_bgcolor = 'rgba(0,0,0,0)' + } const imgOpts = { - format: (format === 'pdf' || format === 'eps') ? 'svg' : format, - width: info.scale * info.width, - height: info.scale * info.height, + format: PRINT_TO_PDF ? 'svg' : format, + width: info.width, + height: info.height, + // works as of https://github.com/plotly/plotly.js/compare/to-image-scale + scale: info.scale, // 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' : '' + setBackground: format === 'jpeg' ? 'opaque' + : PRINT_TO_PDF ? pdfBackground + : '' } 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, bgColor) + } else { + return imgData + } + }) } else if (semver.gte(Plotly.version, '1.11.0')) { const gd = document.createElement('div') @@ -69,13 +81,16 @@ 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': + return toPDF(imgData, imgOpts, bgColor) } }) } else { @@ -95,4 +110,88 @@ function render (info, opts, sendToMain) { }) } +function decodeSVG (imgData) { + return window.decodeURIComponent(imgData.replace(cst.imgPrefix.svg, '')) +} + +function toPDF (imgData, imgOpts, bgColor) { + const wPx = imgOpts.scale * imgOpts.width + const hPx = imgOpts.scale * imgOpts.height + + const pxByMicrometer = 0.00377957517575025 + const offset = 6 + + // See other available options: + // https://github.com/electron/electron/blob/master/docs/api/web-contents.md#contentsprinttopdfoptions-callback + const printOpts = { + // no margins + marginsType: 1, + // make bg (set to `paper_bgcolor` value) appear in export + printBackground: true, + // printToPDF expects page size setting in micrometer (1e-6 m) + // - Px by micrometer factor is taken from + // https://www.translatorscafe.com/unit-converter/en/length/13-110/micrometer-pixel/ + // - Even under the `marginsType: 1` setting (meaning no margins), printToPDF still + // outputs small margins. We need to take this into consideration so that the output PDF + // does not span multiple pages. The offset value was found empirically via trial-and-error. + pageSize: { + width: (wPx + offset) / pxByMicrometer, + height: (hPx + offset) / pxByMicrometer + } + } + + return new Promise((resolve, reject) => { + let win = remote.createBrowserWindow({ + width: wPx, + height: hPx + }) + + win.on('closed', () => { + win = null + }) + + const html = window.encodeURIComponent(` + + + + + + + `) + + // we can't set image src into html as chromium has a 2MB URL limit + // https://craignicol.wordpress.com/2016/07/19/excellent-export-and-the-chrome-url-limit/ + + win.loadURL(`data:text/html,${html}`) + + win.webContents.executeJavaScript(`new Promise((resolve, reject) => { + const img = document.body.firstChild + img.onload = resolve + img.onerror = reject + img.src = "${imgData}" + setTimeout(() => reject(new Error('too long to load image')), ${cst.pdfPageLoadImgTimeout}) + })`) + .then(() => { + win.webContents.printToPDF(printOpts, (err, pdfData) => { + if (err) { + reject(err) + } else { + resolve(pdfData) + } + win.close() + }) + }) + .catch((err) => { + reject(err) + win.close() + }) + }) +} + module.exports = render diff --git a/src/util/batik.js b/src/util/batik.js deleted file mode 100644 index 94b06466..00000000 --- a/src/util/batik.js +++ /dev/null @@ -1,177 +0,0 @@ -const fs = require('fs') -const path = require('path') -const childProcess = require('child_process') -const parallel = require('run-parallel') -const uuid = require('uuid/v4') - -const PATH_TO_BUILD = path.join(__dirname, '..', '..', 'build') - -/** Node wrapper for Batik - * - * See: - * - https://xmlgraphics.apache.org/batik/tools/rasterizer.html - * - http://archive.apache.org/dist/xmlgraphics/batik/ - */ -class Batik { - /** Create batik rasterizer instance - * - * @param {string} batikJar : path to batik rasterizer jar file - */ - constructor (batikJar) { - this.batikJar = path.resolve(batikJar) - - this.bg = '255.255.255.255' - this.dpi = '300' - this.javaBase = 'java -jar -XX:+UseParallelGC -server' - this.batikBase = `${this.javaBase} ${this.batikJar}` - this.pdftopsBase = 'pdftops' - } - - /** Convert svg input - * - * @param {string} svg : svg string to convert - * @param {object} opts : - * - id {string} - * - format {string} - * - width {numeric} - * - height {numeric} - * @param {function} cb - * - err {null || error} - * - result {buffer} - */ - convertSVG (svg, opts, cb) { - const id = opts.id || uuid() - const format = opts.format || 'pdf' - const width = opts.width - const height = opts.height - const isEPS = format === 'eps' - - // TODO old batik wrapper had: - // - // svg = svg.replace(/(font-family: )('Courier New')/g, '$1\'Liberation mono\'') - // .replace(/(font-family: )('Times New Roman')/g, '$1\'Liberation Serif\'') - // .replace(/(font-family: )(Arial)/g, '$1\'Liberation Sans\'') - // .replace(/(font-family: )(Balto)/g, '$1\'Balto Book\'') - // .replace(/(font-family: )(Tahoma)/g, '$1\'Liberation Sans\''); - // - // find out why, and check if it's still needed. - - const lookup = { - out: tmpFile(id + '-out', ''), - // TODO do we still need this `\ufeff` addition? - svg: tmpFile(id + '-svg', '\ufeff' + svg) - } - - if (isEPS) { - lookup.eps = tmpFile(id + '-eps', '') - } - - const fileNames = Object.keys(lookup) - const createTasks = fileNames.map(k => lookup[k].create) - const destroyTasks = fileNames.map(k => lookup[k].destroy) - - let args - - switch (format) { - case 'pdf': - case 'eps': - args = `-m application/pdf` - break - default: - args = `-bg ${this.bg} -dpi ${this.dpi} ` - if (width) args += `-w ${width} ` - if (height) args += `-h ${height} ` - break - } - - const done = (err, out) => parallel(destroyTasks, (err2) => { - if (err) { - return cb(err) - } - if (err2) { - return cb(new Error('problem while removing temporary files')) - } - cb(null, out) - }) - - const toBuffer = (outPath) => { - fs.readFile(outPath, (err, buf) => { - if (err) { - return done(new Error('problem while reading output file')) - } - done(null, buf) - }) - } - - // go! - parallel(createTasks, (err) => { - if (err) { - return done(new Error('problem while initializing temporary files')) - } - - const batikCmd = `${this.batikBase} ${args} -d ${lookup.out.path} ${lookup.svg.path}` - - childProcess.exec(batikCmd, (err) => { - if (err) { - return done(new Error('problem while executing batik command')) - } - - if (isEPS) { - const pdftopsCmd = `${this.pdftopsBase} -eps ${lookup.out.path} ${lookup.eps.path}` - - childProcess.exec(pdftopsCmd, (err) => { - if (err) { - return done(new Error('problem while executing pdftops command')) - } - toBuffer(lookup.eps.path) - }) - } else { - toBuffer(lookup.out.path) - } - }) - }) - } - - /** Does batik-rasterizer jar file exist? - * @return {boolean} - */ - doesBatikJarExist () { - return fs.existsSync(this.batikJar) - } - - /** Is java installed? - * @return {boolean} - */ - static isJavaInstalled () { - try { - childProcess.execSync('java -version', {stdio: 'ignore'}) - } catch (e) { - return false - } - return true - } - - /** Is pdftops installed? - * @return {boolean} - */ - static isPdftopsInstalled () { - try { - childProcess.execSync('pdftops -v', {stdio: 'ignore'}) - } catch (e) { - return false - } - return true - } -} - -function tmpFile (fileName, content) { - var pathToFile = path.join(PATH_TO_BUILD, fileName) - - return { - path: pathToFile, - create: (cb) => fs.writeFile(pathToFile, content, 'utf8', cb), - destroy: (cb) => fs.unlink(pathToFile, cb) - } -} - -module.exports = Batik diff --git a/src/util/pdftops.js b/src/util/pdftops.js new file mode 100644 index 00000000..8a2304b8 --- /dev/null +++ b/src/util/pdftops.js @@ -0,0 +1,67 @@ +const fs = require('fs') +const path = require('path') +const childProcess = require('child_process') +const parallel = require('run-parallel') +const series = require('run-series') +const uuid = require('uuid/v4') + +const PATH_TO_BUILD = path.join(__dirname, '..', '..', 'build') + +/** Node wrapper for pdftops + * + * $ apt-get poppler-utils + * + * See: + * - https://linux.die.net/man/1/pdftops + * - https://en.wikipedia.org/wiki/Poppler_(software)#poppler-utils + */ +class Pdftops { + constructor (pathToPdftops) { + this.cmdBase = pathToPdftops || 'pdftops' + } + + /** Convert PDF to EPS + * + * @param {buffer} pdf : pdf data buffer + * @param {object} opts + * - id {string} + * @param {function} cb + * - err {null || error} + * - result {buffer} + */ + pdf2eps (pdf, opts, cb) { + const id = opts.id || uuid() + const inPath = path.join(PATH_TO_BUILD, id + '-pdf') + const outPath = path.join(PATH_TO_BUILD, id + '-eps') + const cmd = `${this.cmdBase} -eps ${inPath} ${outPath}` + + const destroyTmpFiles = (cb) => parallel([ + (cb) => fs.unlink(inPath, cb), + (cb) => fs.unlink(outPath, cb) + ], cb) + + series([ + (cb) => fs.writeFile(inPath, pdf, 'utf-8', cb), + (cb) => childProcess.exec(cmd, cb), + (cb) => fs.readFile(outPath, cb) + ], (err, result) => { + destroyTmpFiles(() => { + cb(err, result[2]) + }) + }) + } + + /** Is pdftops installed? + * @return {boolean} + */ + static isPdftopsInstalled () { + try { + childProcess.execSync(`${this.cmdBase} -v`, {stdio: 'ignore'}) + } catch (e) { + return false + } + return true + } +} + +module.exports = Pdftops diff --git a/src/util/remote.js b/src/util/remote.js new file mode 100644 index 00000000..20fbb15e --- /dev/null +++ b/src/util/remote.js @@ -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() +} diff --git a/test/common.js b/test/common.js index 8557b59c..68d20060 100644 --- a/test/common.js +++ b/test/common.js @@ -1,11 +1,15 @@ +const fs = require('fs') const path = require('path') +const EventEmitter = require('events') +const sinon = require('sinon') const paths = {} const urls = {} +const mocks = {} paths.root = path.join(__dirname, '..') paths.build = path.join(paths.root, 'build') -paths.batik = process.env.BATIK_RASTERIZER_PATH || path.join(paths.build, 'batik-1.7', 'batik-rasterizer.jar') +paths.bin = path.join(paths.root, 'bin') paths.readme = path.join(paths.root, 'README.md') paths.pkg = path.join(paths.root, 'package.json') paths.glob = path.join(paths.root, 'src', 'util', '*') @@ -13,7 +17,41 @@ 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' +try { + mocks.svg = fs.readFileSync(path.join(paths.build, 'test-mock.svg'), 'utf-8') + mocks.pdf = fs.readFileSync(path.join(paths.build, 'test-mock.pdf')) +} catch (e) {} + +function createMockWindow (opts = {}) { + const win = new EventEmitter() + const webContents = new EventEmitter() + + webContents.executeJavaScript = sinon.stub() + webContents.printToPDF = sinon.stub() + + Object.assign(win, opts, { + webContents: webContents, + loadURL: sinon.stub().callsFake(() => { + webContents.emit('did-finish-load') + }), + close: sinon.stub().callsFake(() => { + win.emit('closed') + }) + }) + + 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, + mocks: mocks, + createMockWindow: createMockWindow, + stubProp: stubProp } diff --git a/test/integration/plotly-graph-exporter_test.js b/test/integration/plotly-graph-exporter_test.js index 4a582624..7cc5e47e 100644 --- a/test/integration/plotly-graph-exporter_test.js +++ b/test/integration/plotly-graph-exporter_test.js @@ -52,24 +52,3 @@ tap.test('should print help message', t => { t.end() }) - -tap.test('should print export info on success', t => { - const input = '{"data":[{"y":[1,2,1]}],"layout":{"title":"tester"}}' - const subprocess = _spawn(t, input) - - const matches = [ - /^exported fig/, - /done with code 0/ - ] - - let i = 0 - - subprocess.stdout.on('data', d => { - t.match(d.toString(), matches[i], `msg ${i}`) - i++ - }) - - subprocess.stderr.on('data', d => { - t.fail(d, 'unwanted msg to stderr') - }) -}) diff --git a/test/pretest.js b/test/pretest.js new file mode 100644 index 00000000..733fd905 --- /dev/null +++ b/test/pretest.js @@ -0,0 +1,17 @@ +const { execSync } = require('child_process') +const { paths } = require('./common') + +const mock = JSON.stringify({ + data: [{ + y: [1, 2, 1] + }], + layout: { + title: 'test mock' + } +}) + +execSync(`${paths.bin}/plotly-graph-exporter.js '${mock}' -f svg -d build -o test-mock`) +console.log('build/test-mock.svg created') + +execSync(`${paths.bin}/plotly-graph-exporter.js '${mock}' -f pdf -d build -o test-mock`) +console.log('build/test-mock.pdf created') diff --git a/test/unit/batik_test.js b/test/unit/batik_test.js deleted file mode 100644 index 65284c89..00000000 --- a/test/unit/batik_test.js +++ /dev/null @@ -1,214 +0,0 @@ -const tap = require('tap') -const sinon = require('sinon') -const path = require('path') -const fs = require('fs') -const childProcess = require('child_process') -const imageSize = require('image-size') -const Batik = require('../../src/util/batik') - -const { paths } = require('../common') -const SVG_MOCK = fs.readFileSync(path.join(paths.build, '20.svg')) - -tap.test('Batik constructor:', t => { - t.test('', t => { - const batik = new Batik(paths.batik) - t.type(batik.convertSVG, 'function') - t.end() - }) - - t.end() -}) - -tap.test('batik.convertSVG', t => { - t.test('should convert svg to pdf', t => { - const batik = new Batik(paths.batik) - const outPath = path.join(paths.build, 'batik-test.pdf') - - batik.convertSVG(SVG_MOCK, {format: 'pdf'}, (err, result) => { - if (err) t.fail(err) - t.type(result, Buffer) - - fs.writeFile(outPath, result, (err) => { - if (err) t.fail(err) - - const size = fs.statSync(outPath).size - t.ok(size > 5e4, 'min pdf file size') - t.ok(size < 7e4, 'max pdf file size') - t.end() - }) - }) - }) - - t.test('should convert svg to eps', t => { - const batik = new Batik(paths.batik) - const outPath = path.join(paths.build, 'batik-test.eps') - - batik.convertSVG(SVG_MOCK, {format: 'eps'}, (err, result) => { - if (err) t.fail(err) - t.type(result, Buffer) - - fs.writeFile(outPath, result, (err) => { - if (err) t.fail(err) - - const size = fs.statSync(outPath).size - t.ok(size > 1e6, 'min eps file size') - t.ok(size < 2e6, 'max eps file size') - t.end() - }) - }) - }) - - t.test('should convert svg to png', t => { - const batik = new Batik(paths.batik) - const outPath = path.join(paths.build, 'batik-test.png') - - batik.convertSVG(SVG_MOCK, {format: 'png'}, (err, result) => { - if (err) t.fail(err) - t.type(result, Buffer) - - fs.writeFile(outPath, result, (err) => { - if (err) t.fail(err) - - const {width, height} = imageSize(outPath) - t.equal(width, 750, 'png width') - t.equal(height, 600, 'png height') - t.end() - }) - }) - }) - - t.test('should rescale svg into png', t => { - const batik = new Batik(paths.batik) - const outPath = path.join(paths.build, 'batik-test.png') - - batik.convertSVG(SVG_MOCK, {format: 'png', width: 200, height: 200}, (err, result) => { - if (err) t.fail(err) - t.type(result, Buffer) - - fs.writeFile(outPath, result, (err) => { - if (err) t.fail(err) - - const {width, height} = imageSize(outPath) - t.equal(width, 200, 'png width') - t.equal(height, 200, 'png height') - t.end() - }) - }) - }) - - t.test('should error out when batik command fails', t => { - const batik = new Batik(paths.batik) - batik.batikBase = 'not gonna work' - - batik.convertSVG(SVG_MOCK, {}, (err) => { - t.throws(() => { throw err }, /problem while executing batik command/) - t.end() - }) - }) - - t.test('should error out when pdftops command fails', t => { - const batik = new Batik(paths.batik) - batik.pdftopsBase = 'not gonna work' - - batik.convertSVG(SVG_MOCK, {format: 'eps'}, (err) => { - t.throws(() => { throw err }, /problem while executing pdftops command/) - t.end() - }) - }) - - t.test('should error out when something goes wrong when initializing files', t => { - sinon.stub(fs, 'writeFile').yields(true) - - const batik = new Batik(paths.batik) - const outPath = path.join(paths.build, 'tmp-out') - const svgPath = path.join(paths.build, 'tmp-svg') - - batik.convertSVG(SVG_MOCK, {id: 'tmp'}, (err) => { - t.throws(() => { throw err }, /problem while initializing temporary files/) - t.notOk(fs.existsSync(outPath), 'clears tmp out file') - t.notOk(fs.existsSync(svgPath), 'clears tmp svg file') - - fs.writeFile.restore() - t.end() - }) - }) - - t.test('should error out when something goes wrong when reading output files', t => { - sinon.stub(fs, 'readFile').yields(true) - - const batik = new Batik(paths.batik) - const outPath = path.join(paths.build, 'tmp-out') - const svgPath = path.join(paths.build, 'tmp-svg') - - batik.convertSVG(SVG_MOCK, {id: 'tmp'}, (err) => { - t.throws(() => { throw err }, /problem while reading output file/) - t.notOk(fs.existsSync(outPath), 'clears tmp out file') - t.notOk(fs.existsSync(svgPath), 'clears tmp svg file') - - fs.readFile.restore() - t.end() - }) - }) - - t.test('should error out when something goes wrong when removing temporary files', t => { - sinon.stub(fs, 'unlink').yields(true) - - const batik = new Batik(paths.batik) - const outPath = path.join(paths.build, 'tmp-out') - const svgPath = path.join(paths.build, 'tmp-svg') - - batik.convertSVG(SVG_MOCK, {id: 'tmp'}, (err) => { - t.throws(() => { throw err }, /problem while removing temporary files/) - t.ok(fs.existsSync(outPath), 'cannot clear tmp out file') - t.ok(fs.existsSync(svgPath), 'cannot clears tmp svg file') - - fs.unlink.restore() - fs.unlinkSync(outPath) - fs.unlinkSync(svgPath) - t.notOk(fs.existsSync(outPath), 'clears tmp out file') - t.notOk(fs.existsSync(svgPath), 'clears tmp svg file') - - t.end() - }) - }) - - t.end() -}) - -tap.test('doesBatikJarExist:', t => { - t.test('should return true when it does exists', t => { - const batik = new Batik(paths.batik) - t.ok(batik.doesBatikJarExist()) - t.end() - }) - - t.test('should return false when it does not exists', t => { - const batik = new Batik('not gonna work') - t.notOk(batik.doesBatikJarExist()) - t.end() - }) - t.end() -}) - -tap.test('isJavaInstalled / isPdftopsInstalled:', t => { - t.afterEach((done) => { - childProcess.execSync.restore() - done() - }) - - t.test('should return true when binary execute correctly', t => { - sinon.stub(childProcess, 'execSync').returns(true) - t.ok(Batik.isJavaInstalled()) - t.ok(Batik.isPdftopsInstalled()) - t.end() - }) - - t.test('should return false when binary does not execute correctly', t => { - sinon.stub(childProcess, 'execSync').throws() - t.notOk(Batik.isJavaInstalled()) - t.notOk(Batik.isPdftopsInstalled()) - t.end() - }) - - t.end() -}) diff --git a/test/unit/pdftops_test.js b/test/unit/pdftops_test.js new file mode 100644 index 00000000..db11b27d --- /dev/null +++ b/test/unit/pdftops_test.js @@ -0,0 +1,81 @@ +const tap = require('tap') +const sinon = require('sinon') +const path = require('path') +const fs = require('fs') +const childProcess = require('child_process') +const Pdftops = require('../../src/util/pdftops') + +const { paths, mocks } = require('../common') + +tap.test('pdftops.pdf2eps', t => { + t.test('should convert pdf to eps', t => { + const pdftops = new Pdftops() + const outPath = path.join(paths.build, 'pdftops-test.eps') + + pdftops.pdf2eps(mocks.pdf, {}, (err, result) => { + if (err) t.fail(err) + t.type(result, Buffer) + + fs.writeFile(outPath, result, (err) => { + if (err) t.fail(err) + + const size = fs.statSync(outPath).size + t.ok(size > 3e5, 'min pdf file size') + t.ok(size < 4e5, 'max pdf file size') + t.end() + }) + }) + }) + + t.test('should remove tmp files after conversion', t => { + const pdftops = new Pdftops() + const tmpOutPath = path.join(paths.build, 'tmp-eps') + const tmpSvgPath = path.join(paths.build, 'tmp-pdf') + + pdftops.pdf2eps(mocks.pdf, {id: 'tmp'}, (err, result) => { + if (err) t.fail(err) + + t.type(result, Buffer) + t.notOk(fs.existsSync(tmpOutPath), 'clears tmp eps file') + t.notOk(fs.existsSync(tmpSvgPath), 'clears tmp pdf file') + t.end() + }) + }) + + t.test('should error out when pdftops command fails', t => { + const pdftops = new Pdftops('not gonna work') + + const tmpOutPath = path.join(paths.build, 'tmp-eps') + const tmpSvgPath = path.join(paths.build, 'tmp-pdf') + + pdftops.pdf2eps(mocks.pdf, {id: 'tmp'}, (err) => { + t.throws(() => { throw err }, /Command failed/) + t.notOk(fs.existsSync(tmpOutPath), 'clears tmp eps file') + t.notOk(fs.existsSync(tmpSvgPath), 'clears tmp pdf file') + t.end() + }) + }) + + t.end() +}) + +tap.test('isPdftopsInstalled', t => { + t.afterEach((done) => { + childProcess.execSync.restore() + done() + }) + + t.test('should return true when binary execute correctly', t => { + sinon.stub(childProcess, 'execSync').returns(true) + t.ok(Pdftops.isPdftopsInstalled()) + t.end() + }) + + t.test('should return false when binary does not execute correctly', t => { + sinon.stub(childProcess, 'execSync').throws() + t.notOk(Pdftops.isPdftopsInstalled()) + t.end() + }) + + t.end() +}) diff --git a/test/unit/plotly-dashboard_test.js b/test/unit/plotly-dashboard_test.js index 4868e8b1..41cfdab5 100644 --- a/test/unit/plotly-dashboard_test.js +++ b/test/unit/plotly-dashboard_test.js @@ -1,5 +1,10 @@ const tap = require('tap') +const sinon = require('sinon') + const _module = require('../../src/component/plotly-dashboard') +const cst = require('../../src/component/plotly-dashboard/constants') +const remote = require('../../src/util/remote') +const { createMockWindow, stubProp } = require('../common') tap.test('parse:', t => { const fn = _module.parse @@ -21,3 +26,70 @@ tap.test('parse:', t => { t.end() }) + +tap.test('render:', t => { + const fn = _module.render + const restoreIframeLoadDelay = stubProp(cst, 'iframeLoadDelay', 0) + + t.afterEach((done) => { + remote.createBrowserWindow.restore() + done() + }) + + t.tearDown(() => { + restoreIframeLoadDelay() + }) + + t.test('should call printToPDF', t => { + const win = createMockWindow() + sinon.stub(remote, 'createBrowserWindow').returns(win) + win.webContents.printToPDF.yields(null, '-> image data <-') + + fn({ + width: 500, + height: 500, + url: 'dummy' + }, {}, (errorCode, result) => { + t.ok(win.webContents.printToPDF.calledOnce) + t.ok(win.close.calledOnce) + t.equal(errorCode, null, 'code') + t.equal(result.imgData, '-> image data <-', 'result') + t.end() + }) + }) + + t.test('should handle printToPDF errors', t => { + const win = createMockWindow() + sinon.stub(remote, 'createBrowserWindow').returns(win) + win.webContents.printToPDF.yields(new Error('printToPDF error')) + + fn({ + width: 500, + height: 500, + url: 'dummy' + }, {}, (errorCode, result) => { + t.ok(win.webContents.printToPDF.calledOnce) + t.ok(win.close.calledOnce) + t.equal(errorCode, 525, 'code') + t.equal(result.msg, 'print to PDF error', 'error msg') + t.end() + }) + }) + + t.test('should cleanup window ref if window is manually closed', t => { + const win = createMockWindow() + sinon.stub(remote, 'createBrowserWindow').returns(win) + + fn({ + width: 500, + height: 500, + url: 'dummy' + }) + + t.equal(win.listenerCount('closed'), 1) + win.on('closed', t.end) + win.emit('closed') + }) + + t.end() +}) diff --git a/test/unit/plotly-graph_test.js b/test/unit/plotly-graph_test.js index 3212f291..75886762 100644 --- a/test/unit/plotly-graph_test.js +++ b/test/unit/plotly-graph_test.js @@ -1,8 +1,10 @@ const tap = require('tap') const sinon = require('sinon') +const Pdftops = require('../../src/util/pdftops') const _module = require('../../src/component/plotly-graph') -const { paths } = require('../common') +const remote = require('../../src/util/remote') +const { paths, mocks, createMockWindow } = require('../common') tap.test('inject:', t => { const fn = _module.inject @@ -318,13 +320,13 @@ tap.test('convert:', t => { const fn = _module.convert t.test('should convert image data to buffer', t => { - const formats = ['png', 'webp', 'jpeg'] + const formats = ['png', 'webp', 'jpeg', 'pdf'] formats.forEach(f => { t.test(`for format ${f}`, t => { fn({imgData: 'asdfDFDASFsafadsf', format: f}, {}, (errorCode, result) => { t.equal(errorCode, null) - t.equal(result.head['Content-Type'], `image/${f}`) + t.match(result.head['Content-Type'], f) t.type(result.body, Buffer) t.type(result.bodyLength, 'number') t.end() @@ -345,18 +347,41 @@ tap.test('convert:', t => { }) }) - t.test('should convert image data to pdf', t => { - const info = {imgData: '', format: 'pdf'} - const opts = {batik: paths.batik} + t.test('should convert pdf data to eps', t => { + const info = {imgData: mocks.pdf, format: 'eps'} + + fn(info, {}, (errorCode, result) => { + t.equal(errorCode, null) + t.equal(result.head['Content-Type'], 'application/postscript') + t.type(result.body, Buffer) + t.end() + }) + }) + + t.test('should convert pdf data to eps (while passing instance of Pdftops)', t => { + const info = {imgData: mocks.pdf, format: 'eps'} + const opts = {pdftops: new Pdftops()} fn(info, opts, (errorCode, result) => { t.equal(errorCode, null) - t.equal(result.head['Content-Type'], 'application/pdf') + t.equal(result.head['Content-Type'], 'application/postscript') t.type(result.body, Buffer) t.end() }) }) + t.test('should pass pdftops errors', t => { + const info = {imgData: mocks.pdf, format: 'eps'} + const opts = {pdftops: 'not gonna work'} + + fn(info, opts, (errorCode, result) => { + t.equal(errorCode, 530) + t.match(result.msg, 'image conversion error') + t.match(result.error.message, 'Command failed') + t.end() + }) + }) + t.end() }) @@ -371,51 +396,93 @@ tap.test('render:', t => { let Plotly let document + let window t.beforeEach((done) => { global.Plotly = Plotly = {} global.document = document = {} + global.window = window = {} done() }) t.afterEach((done) => { delete global.Plotly delete global.document + delete global.window done() }) const mock130 = () => { Plotly.version = '1.30.0' - Plotly.toImage = sinon.stub().returns(new Promise(resolve => { - resolve('image data') - })) + Plotly.toImage = sinon.stub().returns( + new Promise(resolve => resolve('image data')) + ) } const mock110 = () => { Plotly.version = '1.11.0' - Plotly.toImage = sinon.stub().returns(new Promise(resolve => { - resolve('image data') - })) - Plotly.newPlot = sinon.stub().returns(new Promise(resolve => { - resolve({}) - })) + Plotly.toImage = sinon.stub().resolves( + new Promise(resolve => resolve('image data')) + ) + Plotly.newPlot = sinon.stub().resolves( + new Promise(resolve => resolve({})) + ) Plotly.purge = sinon.stub() + } + + const mockBrowser = () => { document.createElement = sinon.stub() + window.decodeURIComponent = sinon.stub().returns('decoded image data') + window.encodeURIComponent = sinon.stub().returns('encoded image data') + } + + const mockWindow = () => { + const win = createMockWindow() + sinon.stub(remote, 'createBrowserWindow').returns(win) + return { + win: win, + restore: () => remote.createBrowserWindow.restore() + } } t.test('v1.30.0 and up', t => { - mock130() + t.test('(format png)', t => { + mock130() - fn({}, {}, (errorCode, result) => { - t.equal(result.imgData, 'image data') - t.ok(Plotly.toImage.calledOnce) - t.end() + fn({}, {}, (errorCode, result) => { + t.equal(result.imgData, 'image data') + t.ok(Plotly.toImage.calledOnce) + t.end() + }) }) + + t.test('(format pdf)', t => { + mock130() + mockBrowser() + const {win, restore} = mockWindow() + win.webContents.executeJavaScript.returns(new Promise(resolve => resolve())) + win.webContents.printToPDF.yields(null, 'pdf data') + + fn({format: 'pdf'}, {}, (errorCode, result) => { + t.equal(errorCode, null) + t.equal(result.imgData, 'pdf data') + t.equal(window.encodeURIComponent.callCount, 1, 'encodeURIComponent calls') + t.equal(win.webContents.executeJavaScript.callCount, 1, 'executeJavaScript calls') + t.equal(win.webContents.printToPDF.callCount, 1, 'printToPDF calls') + t.ok(win.close.calledOnce) + + restore() + t.end() + }) + }) + + t.end() }) t.test('v1.11.0 <= versions < v1.30.0', t => { t.test('(format png)', t => { mock110() + mockBrowser() fn({}, {}, (errorCode, result) => { t.equal(result.imgData, 'image data') @@ -429,17 +496,41 @@ tap.test('render:', t => { t.test('(format svg)', t => { mock110() - global.decodeURIComponent = sinon.stub().returns('decoded image data') + mockBrowser() fn({format: 'svg'}, {}, (errorCode, result) => { t.equal(result.imgData, 'decoded image data') t.ok(document.createElement.calledOnce) + t.ok(window.decodeURIComponent.calledOnce) t.ok(Plotly.newPlot.calledOnce) t.ok(Plotly.toImage.calledOnce) t.ok(Plotly.purge.calledOnce) - t.ok(global.decodeURIComponent) + t.end() + }) + }) - delete global.decodeURIComponent + t.test('(format pdf)', t => { + mock110() + mockBrowser() + const {win, restore} = mockWindow() + win.webContents.executeJavaScript.returns(new Promise(resolve => resolve())) + win.webContents.printToPDF.yields(null, 'pdf data') + + fn({format: 'pdf'}, {}, (errorCode, result) => { + t.equal(errorCode, null) + t.equal(result.imgData, 'pdf data') + t.equal(document.createElement.callCount, 1, 'createElement calls') + t.equal(window.encodeURIComponent.callCount, 1, 'encodeURIComponent calls') + t.equal(win.webContents.executeJavaScript.callCount, 1, 'executeJavaScript calls') + t.equal(win.webContents.printToPDF.callCount, 1, 'printToPDF calls') + t.doesNotThrow(() => { + const gd = { + _fullLayout: {paper_bgcolor: 'some color'} + } + Plotly.toImage.args[0][1].setBackground(gd, 'some other color') + }, 'custom setBackground function') + + restore() t.end() }) }) @@ -468,6 +559,41 @@ tap.test('render:', t => { }) }) + t.test('should return error code on executeJavaScript errors', t => { + mock130() + mockBrowser() + const {win, restore} = mockWindow() + win.webContents.executeJavaScript.returns(new Promise((resolve, reject) => { + reject(new Error('oh no!')) + })) + + fn({format: 'pdf'}, {}, (errorCode, result) => { + t.equal(errorCode, 525) + t.match(result.error, /oh no/, 'error') + t.ok(win.close.calledOnce) + + restore() + t.end() + }) + }) + + t.test('should return error code on printToPDF errors', t => { + mock130() + mockBrowser() + const {win, restore} = mockWindow() + win.webContents.executeJavaScript.returns(new Promise(resolve => resolve())) + win.webContents.printToPDF.yields(new Error('oops')) + + fn({format: 'pdf'}, {}, (errorCode, result) => { + t.equal(errorCode, 525) + t.match(result.error, /oops/, 'error') + t.ok(win.close.calledOnce) + + restore() + t.end() + }) + }) + t.test('should generate svg for format pdf and eps', t => { const formats = ['pdf', 'eps']