diff --git a/demo/demo.js b/demo/demo.js index c5833c3..536c4ac 100644 --- a/demo/demo.js +++ b/demo/demo.js @@ -5,6 +5,9 @@ let canvas; let renderer; let boxfill; let variables; +let pdfBlobUrl; +let pngBlobUrl; + // const { vcs } = window; @@ -105,6 +108,63 @@ function vcsBoxfillResize() { console.log('div resize'); } +function captureCanvasScreenshot() { + canvas.screenshot('pdf', true, false, null).then((result) => { + console.log('Got screenshot result:'); + console.log(result); + const { blob, type } = result; + pdfBlobUrl = URL.createObjectURL(blob); + var link = document.createElement("a"); + link.href = pdfBlobUrl; + const fname = `image.${type}`; + link.download = fname; + link.innerHTML = `Click here to download ${fname}`; + document.body.appendChild(link); + }); + + canvas.screenshot('png', true, false, null, 1024, 768).then((result) => { + console.log('Got screenshot result:'); + console.log(result); + const { blob, type } = result; + pngBlobUrl = URL.createObjectURL(blob); + + var img = document.createElement("img"); + img.classList.add("obj"); + img.file = blob; + img.width = 200; + img.height = 176; + + var link = document.createElement("a"); + link.href = pngBlobUrl; + const fname = `image.${type}`; + link.download = fname; + link.appendChild(img); + document.body.appendChild(link); + + var reader = new FileReader(); + reader.onload = function(e) { + img.src = e.target.result; + }; + reader.readAsDataURL(blob); + }); +} + +function cleanup() { + /* + According the to the documentation here: + + https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL + + the objectURLs will be revoked when the document is unloaded, so this + isn't technically needed in the case of this demo. However it is + included as a reminder of the proper approach which should be taken + if necessary/possible on a case by case basis. + */ + console.log('Cleaning up object URLs'); + URL.revokeObjectURL(pdfBlobUrl); + URL.revokeObjectURL(pngBlobUrl); +} + /** * Prints the result from get_variables to the console. * @param {string? filename An absolute path to a netcdf file diff --git a/demo/index.html b/demo/index.html index 3a84d1f..094b885 100644 --- a/demo/index.html +++ b/demo/index.html @@ -6,7 +6,7 @@ - +
@@ -49,6 +49,7 @@
  • Print boxfill 'myboxfill' properties
  • Remove boxfill 'myboxfill' properties
  • Test variable counts method
  • +
  • Test capturing screenshot
  • diff --git a/package.json b/package.json index fb960fe..32ee7b2 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ }, "dependencies": { "axios": "0.17.1", + "b64-to-blob": "1.2.19", "css-element-queries": "1.0.1", "hammerjs": "2.0.8", "monologue.js": "0.3.5", diff --git a/src/index.js b/src/index.js index 05fef31..79d1878 100644 --- a/src/index.js +++ b/src/index.js @@ -91,6 +91,21 @@ function init(el, renderingType) { return this.backend.resize(this, newWidth, newHeight); }, + // When this resolves, the result should contain some expected keys: + // + // { + // success: boolean (did screenshot succeed on server) + // msg: possible error message in case of failure + // type: same as "saveType" argument + // blob: A Blob containing the binary image data + // } + // + screenshot(saveType = 'png', saveLocal = true, saveRemote = false, + remotePath = null, width = null, height = null) { + return this.backend.screenshot(this, saveType, saveLocal, saveRemote, + remotePath, width, height); + }, + close() { Object.keys(this.connection).map((k) => { return this.connection[k].then((c) => { diff --git a/src/vtkweb/index.js b/src/vtkweb/index.js index 02a1fea..c91ba0c 100644 --- a/src/vtkweb/index.js +++ b/src/vtkweb/index.js @@ -1,9 +1,17 @@ +import b64toBlob from 'b64-to-blob'; import RemoteRenderer from 'ParaViewWeb/NativeUI/Canvas/RemoteRenderer'; import SizeHelper from 'ParaViewWeb/Common/Misc/SizeHelper'; import SmartConnect from 'wslink/src/SmartConnect'; import { createClient } from 'ParaViewWeb/IO/WebSocket/ParaViewWebClient'; +const mimeTypeMap = { + ps: 'application/postscript', + pdf: 'application/pdf', + png: 'image/png', + svg: 'image/svg+xml', +}; + const backend = { // RemoteRenderer associated with a windowId _renderer: {}, @@ -124,6 +132,33 @@ const backend = { return rendererPromise; }); }, + screenshot(canvas, saveType, saveLocal, saveRemote, remotePath, width, height) { + if (!canvas.windowId) { + return Promise.resolve(false); + } + return canvas.connection.vtkweb.then((client) => { + const args = [ + canvas.windowId, + saveType, + saveLocal, + saveRemote, + remotePath, + width, + height, + ]; + return client.pvw.session.call('vcs.canvas.screenshot', args).then((result) => { + if (result && result.encodedImage) { + const blob = b64toBlob(result.encodedImage, mimeTypeMap[saveType]); + const newResult = Object.assign({}, result); + delete newResult.encodedImage; + newResult.blob = blob; + newResult.type = saveType; + return newResult; + } + return result; + }); + }); + }, clear(canvas) { if (!canvas.windowId) { return Promise.resolve(false); diff --git a/test/cases/vtkweb.js b/test/cases/vtkweb.js index 0046914..3143631 100644 --- a/test/cases/vtkweb.js +++ b/test/cases/vtkweb.js @@ -20,7 +20,7 @@ function getOrCreateColorMap(vcs, name) { describe('endToEnd', function endToEnd() { - this.timeout(5000); + this.timeout(10000); it('rendersABoxfillImage', function () { const testName = this.test.title; @@ -151,4 +151,38 @@ describe('endToEnd', function endToEnd() { }); }); }); + + it('capturesABase64Png', function () { + const testName = this.test.title; + const { vcs, container } = getTestRequirements(testName); + + const canvas = vcs.init(container); + const vars = { + u: { + uri: 'clt.nc', + variable: 'u', + }, + v: { + uri: 'clt.nc', + variable: 'v', + }, + }; + + return canvas.plot([vars.u, vars.v], ['vector', 'default']).then(() => { + return canvas.screenshot('png', true, false, null).then((result) => { + return new Promise((resolve, reject) => { + if (result.success && result.blob && result.blob.type === 'image/png') { + resolve(true); + } + const msga = `Expected result.success === true (actually ${result.success})`; + const msgb = `, and result.blob to be defined (actually ${result.blob})`; + let msgc = ''; + if (result.blob) { + msgc = `, and result.blob.type === \'image/png\' (actually \'${result.blob.type}\'')` + } + reject(new Error(`${msga}${msgb}${msgc}`)); + }); + }); + }); + }); }); diff --git a/vcs_server/Visualizer.py b/vcs_server/Visualizer.py index 4a250d8..cbae0b8 100644 --- a/vcs_server/Visualizer.py +++ b/vcs_server/Visualizer.py @@ -9,6 +9,9 @@ import cdms2 import genutil import cdutil +import sys +import base64, os, shutil, tempfile +import traceback import numpy import compute_graph import cdat_compute_graph @@ -17,6 +20,7 @@ class Visualizer(protocols.vtkWebProtocol): + _allowedScreenshotTypes = [ 'png', 'ps', 'pdf', 'svg' ] _canvas = {} @exportRpc('vcs.canvas.plot') @@ -101,6 +105,94 @@ def close(self, windowId): return True return False + @exportRpc('vcs.canvas.screenshot') + def screenshot(self, windowId, saveType, local, remote, + remotePath, width, height): + """ Save a screenshot of the current canvas + + This method can be used to save a screenshot of the current canvas, as + long as the canvas has been plotted once before. + + Arguments: + windowId -- Returned by 'plot' method, used to find associated canvas + saveType -- One of 'png', 'svg', 'pdf', or 'ps' + local -- Should screenshot be saved locally on the client + remote -- Should screenshot be save remotely on the server + remotePath -- Full path and filename to use when saving remotely + width -- Integer giving width of saved screenshot (if different than last plot) + height -- Integer giving height of saved screenshot (if different than last plot) + + Returns: + A dictionary is returned, including indication of success or failure, as + well as any error message, e.g. + + { + 'success': True, + 'msg': 'Screenshot successfully saved on server' , + 'encodedImage': + } + """ + if not windowId in self._canvas: + return { + 'success': False, + 'msg': 'windowId is required to find associated canvas' + } + + if not saveType in self._allowedScreenshotTypes: + return { + 'success': False, + 'msg': '%s not in allowed screenshot types' % saveType + } + + if remote and remotePath is None: + return { + 'success': False, + 'msg': 'Must have remotePath to save screenshot on server, none provided' + } + + # If not saving remotely, we will need a temporary dir/file for + # saving the screenshot + if not remote: + temporaryDirectoryName = tempfile.mkdtemp() + remotePath = os.path.join(temporaryDirectoryName, 'tempscreenshot.%s' % saveType) + print('Using temporary location for saving image: %s' % remotePath) + + canvas = self._canvas[windowId]; + + try: + if saveType == 'png': + canvas.png(remotePath, width=width, height=height) + elif saveType == 'svg': + canvas.svg(remotePath, width=width, height=height) + elif saveType == 'pdf': + canvas.pdf(remotePath, width=width, height=height) + elif saveType == 'ps': + canvas.postscript(remotePath, width=width, height=height) + except Exception as inst: + print('Caught exception saving screenshot on server:') + print(inst) + return { + 'success': False, + 'msg': 'Failed to save screenshot on server: %s' % remotePath + } + + returnValue = { + 'success': True, + 'msg': 'Screenshot successfully saved on server' + } + + if local: + with open(remotePath, 'rb') as f: + data = f.read() + encodedData = base64.b64encode(data) + returnValue['encodedImage'] = encodedData + + # If not saving remotely, clean up the temporary directory + if not remote: + shutil.rmtree(temporaryDirectoryName) + + return returnValue + # ====================================================================== # Common elements routines @exportRpc('vcs.listelements')