├── .dockerignore ├── orca_logo.png ├── .github └── FUNDING.yml ├── bin ├── orca.sh ├── orca.js ├── args.js ├── orca_electron.js ├── serve.js └── graph.js ├── recipe ├── bin │ ├── orca.cmd │ └── orca ├── bld.bat ├── meta.yaml ├── build.sh └── README.md ├── src ├── index.js ├── component │ ├── plotly-dashboard │ │ ├── constants.js │ │ ├── index.js │ │ ├── convert.js │ │ ├── parse.js │ │ └── render.js │ ├── plotly-graph │ │ ├── index.js │ │ ├── constants.js │ │ ├── inject.js │ │ ├── convert.js │ │ └── parse.js │ ├── plotly-thumbnail │ │ ├── index.js │ │ └── parse.js │ ├── plotly-dashboard-thumbnail │ │ ├── index.js │ │ ├── convert.js │ │ ├── parse.js │ │ └── render.js │ ├── plotly-dash-preview │ │ ├── index.js │ │ ├── convert.js │ │ ├── constants.js │ │ ├── parse.js │ │ └── render.js │ └── plotly-dashboard-preview │ │ ├── index.js │ │ ├── parse.js │ │ └── render.js ├── util │ ├── is-non-empty-string.js │ ├── is-positive-numeric.js │ ├── generic-ping.js │ ├── create-timer.js │ ├── remote.js │ ├── init-pings.js │ ├── init-renderers.js │ ├── init-app.js │ ├── pdftops.js │ ├── coerce-component.js │ └── create-index.js └── app │ ├── runner │ ├── constants.js │ ├── get-body.js │ ├── index.js │ ├── coerce-opts.js │ └── run.js │ └── server │ ├── constants.js │ ├── ping.js │ ├── coerce-opts.js │ ├── index.js │ └── create-server.js ├── test ├── image │ ├── baselines │ │ ├── 29.emf │ │ ├── 29.pdf │ │ ├── 29.png │ │ ├── fonts.emf │ │ ├── fonts.pdf │ │ ├── fonts.png │ │ ├── gl2d_14.emf │ │ ├── gl2d_14.pdf │ │ ├── gl2d_14.png │ │ ├── mathjax.emf │ │ ├── mathjax.pdf │ │ ├── mathjax.png │ │ ├── gl3d_bunny.emf │ │ ├── gl3d_bunny.pdf │ │ ├── gl3d_bunny.png │ │ ├── basic_heatmap.emf │ │ ├── basic_heatmap.pdf │ │ ├── basic_heatmap.png │ │ ├── geo_choropleth-usa.emf │ │ ├── geo_choropleth-usa.pdf │ │ ├── geo_choropleth-usa.png │ │ ├── gl2d_14.json │ │ └── basic_heatmap.json │ ├── mocks │ │ ├── basic_heatmap.json │ │ └── mathjax.json │ ├── render_mocks_cli │ └── compare_images ├── dash-preview │ ├── mocks │ │ ├── 3.json │ │ ├── 4.json │ │ └── 2.json │ └── render_mocks_cli ├── pretest.js ├── integration │ ├── orca_test.js │ ├── orca_serve_graph-only_test.js │ ├── orca_serve_offline_test.js │ ├── orca_graph_test.js │ └── orca_serve_test.js ├── common.js └── unit │ ├── plotly-dashboard-thumbnail_test.js │ ├── create-index_test.js │ ├── plotly-dashboard_test.js │ ├── pdftops_test.js │ ├── inkscape_test.js │ ├── plotly-thumbnail_test.js │ └── coerce-component_test.js ├── deployment ├── tools │ └── watch_deployment ├── roles │ ├── rollback │ │ └── tasks │ │ │ └── main.yml │ ├── common │ │ └── tasks │ │ │ └── main.yml │ ├── update │ │ └── tasks │ │ │ └── main.yml │ └── update_plotlyjs │ │ └── tasks │ │ └── main.yml ├── playbook_prod.yml ├── playbook_stage.yml ├── playbook_prod_rollback.yml ├── playbook_stage_rollback.yml ├── playbook_prod_update_plotlyjs.yml ├── playbook_stage_update_plotlyjs.yml ├── kube │ ├── prod │ │ ├── pdb.yaml │ │ ├── loadbalancer.yaml │ │ ├── hpa.yaml │ │ └── frontend.yaml │ └── stage │ │ ├── pdb.yaml │ │ ├── loadbalancer.yaml │ │ ├── hpa.yaml │ │ └── frontend.yaml ├── orca ├── entrypoint.sh ├── monitrc ├── preferences.xml ├── run_server ├── ImageMagickPolicy.xml ├── README.md └── Dockerfile ├── .npmignore ├── .gitignore ├── .circleci ├── test-image.sh ├── test-dash-preview.sh └── test.sh ├── setup.py ├── appveyor.yml ├── LICENSE ├── .travis.yml ├── package.json ├── CHANGELOG.md └── CODE_OF_CONDUCT.md /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log* 3 | 4 | build 5 | test 6 | -------------------------------------------------------------------------------- /orca_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotly/orca/HEAD/orca_logo.png -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: https://plot.ly/products/consulting-and-oem/ 2 | -------------------------------------------------------------------------------- /bin/orca.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | exec "/Applications/orca.app/Contents/MacOS/orca" "$@" 3 | -------------------------------------------------------------------------------- /recipe/bin/orca.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | "/opt/anaconda1anaconda2anaconda3\orca_app\orca.exe" %* 3 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | exports.run = require('./app/runner/') 2 | exports.serve = require('./app/server/') 3 | -------------------------------------------------------------------------------- /test/image/baselines/29.emf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotly/orca/HEAD/test/image/baselines/29.emf -------------------------------------------------------------------------------- /test/image/baselines/29.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotly/orca/HEAD/test/image/baselines/29.pdf -------------------------------------------------------------------------------- /test/image/baselines/29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotly/orca/HEAD/test/image/baselines/29.png -------------------------------------------------------------------------------- /deployment/tools/watch_deployment: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | kubectl rollout status deployments/imageserver 4 | -------------------------------------------------------------------------------- /test/image/baselines/fonts.emf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotly/orca/HEAD/test/image/baselines/fonts.emf -------------------------------------------------------------------------------- /test/image/baselines/fonts.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotly/orca/HEAD/test/image/baselines/fonts.pdf -------------------------------------------------------------------------------- /test/image/baselines/fonts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotly/orca/HEAD/test/image/baselines/fonts.png -------------------------------------------------------------------------------- /recipe/bld.bat: -------------------------------------------------------------------------------- 1 | move release\win-unpacked "%PREFIX%\orca_app" 2 | copy "%RECIPE_DIR%\bin\orca.cmd" "%PREFIX%\" 3 | -------------------------------------------------------------------------------- /test/image/baselines/gl2d_14.emf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotly/orca/HEAD/test/image/baselines/gl2d_14.emf -------------------------------------------------------------------------------- /test/image/baselines/gl2d_14.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotly/orca/HEAD/test/image/baselines/gl2d_14.pdf -------------------------------------------------------------------------------- /test/image/baselines/gl2d_14.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotly/orca/HEAD/test/image/baselines/gl2d_14.png -------------------------------------------------------------------------------- /test/image/baselines/mathjax.emf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotly/orca/HEAD/test/image/baselines/mathjax.emf -------------------------------------------------------------------------------- /test/image/baselines/mathjax.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotly/orca/HEAD/test/image/baselines/mathjax.pdf -------------------------------------------------------------------------------- /test/image/baselines/mathjax.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotly/orca/HEAD/test/image/baselines/mathjax.png -------------------------------------------------------------------------------- /test/image/baselines/gl3d_bunny.emf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotly/orca/HEAD/test/image/baselines/gl3d_bunny.emf -------------------------------------------------------------------------------- /test/image/baselines/gl3d_bunny.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotly/orca/HEAD/test/image/baselines/gl3d_bunny.pdf -------------------------------------------------------------------------------- /test/image/baselines/gl3d_bunny.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotly/orca/HEAD/test/image/baselines/gl3d_bunny.png -------------------------------------------------------------------------------- /test/image/baselines/basic_heatmap.emf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotly/orca/HEAD/test/image/baselines/basic_heatmap.emf -------------------------------------------------------------------------------- /test/image/baselines/basic_heatmap.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotly/orca/HEAD/test/image/baselines/basic_heatmap.pdf -------------------------------------------------------------------------------- /test/image/baselines/basic_heatmap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotly/orca/HEAD/test/image/baselines/basic_heatmap.png -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .circleci 2 | .dockerignore 3 | 4 | deployment 5 | test 6 | coverage 7 | build 8 | release 9 | recipe 10 | -------------------------------------------------------------------------------- /test/image/baselines/geo_choropleth-usa.emf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotly/orca/HEAD/test/image/baselines/geo_choropleth-usa.emf -------------------------------------------------------------------------------- /test/image/baselines/geo_choropleth-usa.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotly/orca/HEAD/test/image/baselines/geo_choropleth-usa.pdf -------------------------------------------------------------------------------- /test/image/baselines/geo_choropleth-usa.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotly/orca/HEAD/test/image/baselines/geo_choropleth-usa.png -------------------------------------------------------------------------------- /deployment/roles/rollback/tasks/main.yml: -------------------------------------------------------------------------------- 1 | - name: Undo rollout 2 | local_action: command kubectl rollout undo deployments/imageserver 3 | -------------------------------------------------------------------------------- /src/component/plotly-dashboard/constants.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | iframeLoadDelay: 5000, 3 | 4 | statusMsg: { 5 | 525: 'print to PDF error' 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/util/is-non-empty-string.js: -------------------------------------------------------------------------------- 1 | function isNonEmptyString (v) { 2 | return typeof v === 'string' && v.length > 0 3 | } 4 | 5 | module.exports = isNonEmptyString 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log* 3 | 4 | coverage 5 | .nyc_output 6 | 7 | /build 8 | /release 9 | 10 | deployment/*.retry 11 | 12 | /yarn-error.log 13 | -------------------------------------------------------------------------------- /deployment/roles/common/tasks/main.yml: -------------------------------------------------------------------------------- 1 | - name: Get credentials for cluster 2 | local_action: command gcloud container clusters get-credentials {{ cluster }} --zone {{ zone }} 3 | -------------------------------------------------------------------------------- /deployment/playbook_prod.yml: -------------------------------------------------------------------------------- 1 | - hosts: localhost 2 | gather_facts: False 3 | vars: 4 | cluster: prod 5 | zone: us-central1-a 6 | roles: 7 | - common 8 | - update 9 | -------------------------------------------------------------------------------- /deployment/playbook_stage.yml: -------------------------------------------------------------------------------- 1 | - hosts: localhost 2 | gather_facts: False 3 | vars: 4 | cluster: stage 5 | zone: us-central1-a 6 | roles: 7 | - common 8 | - update 9 | -------------------------------------------------------------------------------- /deployment/playbook_prod_rollback.yml: -------------------------------------------------------------------------------- 1 | - hosts: localhost 2 | gather_facts: False 3 | vars: 4 | cluster: prod 5 | zone: us-central1-a 6 | roles: 7 | - common 8 | - rollback 9 | -------------------------------------------------------------------------------- /deployment/playbook_stage_rollback.yml: -------------------------------------------------------------------------------- 1 | - hosts: localhost 2 | gather_facts: False 3 | vars: 4 | cluster: stage 5 | zone: us-central1-a 6 | roles: 7 | - common 8 | - rollback 9 | -------------------------------------------------------------------------------- /src/util/is-positive-numeric.js: -------------------------------------------------------------------------------- 1 | const isNumeric = require('fast-isnumeric') 2 | 3 | function isPositiveNumeric (v) { 4 | return isNumeric(v) && v > 0 5 | } 6 | 7 | module.exports = isPositiveNumeric 8 | -------------------------------------------------------------------------------- /deployment/playbook_prod_update_plotlyjs.yml: -------------------------------------------------------------------------------- 1 | - hosts: localhost 2 | gather_facts: False 3 | vars: 4 | cluster: prod 5 | zone: us-central1-a 6 | roles: 7 | - common 8 | - update_plotlyjs 9 | -------------------------------------------------------------------------------- /deployment/playbook_stage_update_plotlyjs.yml: -------------------------------------------------------------------------------- 1 | - hosts: localhost 2 | gather_facts: False 3 | vars: 4 | cluster: stage 5 | zone: us-central1-a 6 | roles: 7 | - common 8 | - update_plotlyjs 9 | -------------------------------------------------------------------------------- /deployment/kube/prod/pdb.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: policy/v1beta1 2 | kind: PodDisruptionBudget 3 | metadata: 4 | name: imageserver-pdb 5 | spec: 6 | minAvailable: 2 7 | selector: 8 | matchLabels: 9 | app: imageserver 10 | -------------------------------------------------------------------------------- /deployment/kube/stage/pdb.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: policy/v1beta1 2 | kind: PodDisruptionBudget 3 | metadata: 4 | name: imageserver-pdb 5 | spec: 6 | minAvailable: 2 7 | selector: 8 | matchLabels: 9 | app: imageserver 10 | -------------------------------------------------------------------------------- /src/component/plotly-dashboard/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'plotly-dashboard', 3 | ping: require('../../util/generic-ping'), 4 | parse: require('./parse'), 5 | render: require('./render'), 6 | convert: require('./convert') 7 | } 8 | -------------------------------------------------------------------------------- /test/dash-preview/mocks/3.json: -------------------------------------------------------------------------------- 1 | { 2 | "url": "https://dash-demo.plotly.host/portfolio-app/snapshot-1575661050-9602f3bb?print=true", 3 | "timeout": "15", 4 | "pdf_options": { 5 | "pageSize": "Letter", 6 | "marginsType": 1 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test/dash-preview/mocks/4.json: -------------------------------------------------------------------------------- 1 | { 2 | "url": "https://dash-playground.plotly.host/dash-slide-ab/all?print=true", 3 | "timeout": "15", 4 | "pdf_options": { 5 | "pageSize": {"width": 279400, "height": 184150}, 6 | "marginsType": 1 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test/dash-preview/mocks/2.json: -------------------------------------------------------------------------------- 1 | { 2 | "url": "https://dash-demo.plotly.host/ddk-clinical-trial-dashboard/snapshot-1575666495-72043aed?print=true", 3 | "timeout": "15", 4 | "pdf_options": { 5 | "pageSize": "Letter", 6 | "marginsType": 1 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/component/plotly-graph/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'plotly-graph', 3 | ping: require('../../util/generic-ping'), 4 | inject: require('./inject'), 5 | parse: require('./parse'), 6 | render: require('./render'), 7 | convert: require('./convert') 8 | } 9 | -------------------------------------------------------------------------------- /src/app/runner/constants.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | statusMsg: { 3 | 0: 'all task(s) completed', 4 | 1: 'failed or incomplete task(s)', 5 | 422: 'json parse error', 6 | 501: 'failed export' 7 | }, 8 | dflt: { 9 | parallelLimit: 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/util/generic-ping.js: -------------------------------------------------------------------------------- 1 | /** A generic ping function for modules that don't need anything special 2 | * 3 | * @param {function} sendToMain - sends the response to the main process 4 | */ 5 | function ping (sendToMain) { 6 | sendToMain() 7 | } 8 | 9 | module.exports = ping 10 | -------------------------------------------------------------------------------- /recipe/bin/orca: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | if [[ "$OSTYPE" == "darwin"* ]]; then 3 | # Mac OSX 4 | exec /opt/anaconda1anaconda2anaconda3/lib/orca.app/Contents/MacOS/orca "$@" 5 | else 6 | # Assume linux 7 | exec /opt/anaconda1anaconda2anaconda3/lib/orca_app/orca --no-sandbox "$@" 8 | fi 9 | -------------------------------------------------------------------------------- /recipe/meta.yaml: -------------------------------------------------------------------------------- 1 | {% set data = load_setup_py_data() %} 2 | 3 | package: 4 | name: plotly-orca 5 | version: {{ data.get('version') }} 6 | 7 | source: 8 | path: ../ 9 | 10 | build: 11 | number: 1 12 | binary_relocation: False 13 | 14 | requirements: 15 | build: 16 | - nodejs =8 17 | -------------------------------------------------------------------------------- /src/component/plotly-thumbnail/index.js: -------------------------------------------------------------------------------- 1 | const plotlyGraph = require('../plotly-graph') 2 | 3 | module.exports = { 4 | name: 'plotly-thumbnail', 5 | ping: require('../../util/generic-ping'), 6 | inject: plotlyGraph.inject, 7 | parse: require('./parse').parse, 8 | render: plotlyGraph.render, 9 | convert: plotlyGraph.convert 10 | } 11 | -------------------------------------------------------------------------------- /.circleci/test-image.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # override CircleCi's default run settings, 4 | # so that we run all tests and do not exit early 5 | # on test failures. 6 | set +e 7 | set +o pipefail 8 | 9 | ./test/image/render_mocks_cli build/test_images 10 | ./test/image/compare_images test/image/baselines build/test_images build/test_images_diff 11 | -------------------------------------------------------------------------------- /src/component/plotly-dashboard-thumbnail/index.js: -------------------------------------------------------------------------------- 1 | const plotlyGraph = require('../plotly-graph') 2 | 3 | module.exports = { 4 | name: 'plotly-dashboard-thumbnail', 5 | ping: require('../../util/generic-ping'), 6 | inject: plotlyGraph.inject, 7 | parse: require('./parse'), 8 | render: require('./render'), 9 | convert: require('./convert') 10 | } 11 | -------------------------------------------------------------------------------- /.circleci/test-dash-preview.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # override CircleCi's default run settings, 4 | # so that we run all tests and do not exit early 5 | # on test failures. 6 | set +e 7 | set +o pipefail 8 | 9 | ./test/dash-preview/render_mocks_cli build/test_dash_previews 10 | #./test/image/compare_images test/image/baselines build/test_images build/test_images_diff 11 | -------------------------------------------------------------------------------- /deployment/orca: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | DOCKER_IMAGE=quay.io/plotly/orca 4 | 5 | if [[ $1 == "--help" || $1 == "--version" || $1 == "graph" || $1 == "serve" ]]; then 6 | docker run --net=host -it \ 7 | -v "$(pwd)":"$(pwd)" -w "$(pwd)" \ 8 | "$DOCKER_IMAGE" "$@" 9 | else 10 | echo "Unrecognized orca command. Run \`$0 --help\` for more info" 11 | fi 12 | -------------------------------------------------------------------------------- /src/component/plotly-dash-preview/index.js: -------------------------------------------------------------------------------- 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 | } 10 | -------------------------------------------------------------------------------- /src/component/plotly-dashboard-preview/index.js: -------------------------------------------------------------------------------- 1 | const plotlyGraph = require('../plotly-graph') 2 | 3 | module.exports = { 4 | name: 'plotly-dashboard-preview', 5 | ping: require('../../util/generic-ping'), 6 | inject: plotlyGraph.inject, 7 | parse: require('./parse'), 8 | render: require('./render'), 9 | convert: require('../plotly-dashboard-thumbnail/convert') 10 | } 11 | -------------------------------------------------------------------------------- /deployment/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [[ $1 == "--help" || $1 == "--version" || $1 == "graph" ]]; then 4 | xvfb-run --server-args "-screen 0 640x480x24" -a /var/www/image-exporter/bin/orca.js "$@" 5 | elif [[ $1 == "serve" ]]; then 6 | shift 1 # Remove argument "serve" since it is assumed in the following 7 | /run_server "$@" 8 | else # By default, run the server 9 | /run_server "$@" 10 | fi 11 | -------------------------------------------------------------------------------- /test/image/mocks/basic_heatmap.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "uid": "data0", 5 | "z": [ 6 | [ 7 | 1, 8 | 20, 9 | 30 10 | ], 11 | [ 12 | 20, 13 | 1, 14 | 60 15 | ], 16 | [ 17 | 30, 18 | 60, 19 | 1 20 | ] 21 | ], 22 | "type": "heatmap" 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /recipe/build.sh: -------------------------------------------------------------------------------- 1 | # !/bin/sh 2 | 3 | # assumes that `npm run pack` has ran successfully 4 | 5 | mkdir -p $PREFIX/lib 6 | 7 | if [[ "$OSTYPE" == "darwin"* ]]; then 8 | # Mac OSX 9 | mv release/mac/orca.app $PREFIX/lib 10 | else 11 | # Assume Linux 12 | mv release/linux-unpacked/ $PREFIX/lib/orca_app 13 | fi 14 | 15 | mkdir -p $PREFIX/bin 16 | ORCA_ENTRY=$PREFIX/bin/orca 17 | cp $RECIPE_DIR/bin/orca $ORCA_ENTRY 18 | chmod +x $ORCA_ENTRY 19 | -------------------------------------------------------------------------------- /deployment/monitrc: -------------------------------------------------------------------------------- 1 | set daemon 10 2 | set init 3 | 4 | check process xvfb with pidfile "/var/run/xvfb.pid" 5 | start program = "/usr/local/bin/xvfb_wrapper" with timeout 120 seconds 6 | restart program = "/usr/local/bin/xvfb_wrapper" with timeout 120 seconds 7 | 8 | if failed 9 | host 127.0.0.1 10 | port 9091 11 | protocol http 12 | request "/ping" 13 | for 1 cycle 14 | then restart 15 | 16 | set httpd port 2812 17 | allow localhost 18 | -------------------------------------------------------------------------------- /src/app/server/constants.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | bufferOverflowLimit: 1e9, 3 | statusMsg: { 4 | 200: 'pong', 5 | 401: 'error during request', 6 | 402: 'too many windows are opened', 7 | 404: 'invalid route', 8 | 422: 'json parse error', 9 | 499: 'client closed request before generation complete', 10 | 504: 'window for given route does not exist', 11 | 522: 'client socket timeout' 12 | }, 13 | dflt: { 14 | maxNumberOfWindows: 50, 15 | requestTimeout: 50 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /deployment/kube/prod/loadbalancer.yaml: -------------------------------------------------------------------------------- 1 | # WARNING: There is considerable duplication between this file and the 2 | # stage version. When updating this file, please check if your changes 3 | # need to be made to the other version. 4 | 5 | apiVersion: v1 6 | kind: Service 7 | metadata: 8 | name: imageserver 9 | annotations: 10 | cloud.google.com/load-balancer-type: "internal" 11 | labels: 12 | app: imageserver 13 | spec: 14 | type: LoadBalancer 15 | ports: 16 | - port: 9091 17 | protocol: TCP 18 | selector: 19 | app: imageserver 20 | -------------------------------------------------------------------------------- /deployment/kube/stage/loadbalancer.yaml: -------------------------------------------------------------------------------- 1 | # WARNING: There is considerable duplication between this file and the 2 | # prod version. When updating this file, please check if your changes 3 | # need to be made to the other version. 4 | 5 | apiVersion: v1 6 | kind: Service 7 | metadata: 8 | name: imageserver 9 | annotations: 10 | cloud.google.com/load-balancer-type: "internal" 11 | labels: 12 | app: imageserver 13 | spec: 14 | type: LoadBalancer 15 | ports: 16 | - port: 9091 17 | protocol: TCP 18 | selector: 19 | app: imageserver 20 | -------------------------------------------------------------------------------- /src/component/plotly-dashboard/convert.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {object} info : info object 3 | * - imgData 4 | * @param {object} opts : component options 5 | * @param {function} reply 6 | * - errorCode 7 | * - result 8 | */ 9 | function convert (info, opts, reply) { 10 | const result = {} 11 | 12 | result.head = {} 13 | result.head['Content-Type'] = 'application/pdf' 14 | result.bodyLength = result.head['Content-Length'] = info.imgData.length 15 | result.body = Buffer.from(info.imgData, 'base64') 16 | 17 | reply(null, result) 18 | } 19 | 20 | module.exports = convert 21 | -------------------------------------------------------------------------------- /src/component/plotly-dashboard-thumbnail/convert.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {object} info : info object 3 | * - imgData 4 | * @param {object} opts : component options 5 | * @param {function} reply 6 | * - errorCode 7 | * - result 8 | */ 9 | function convert (info, opts, reply) { 10 | const result = {} 11 | 12 | result.head = {} 13 | result.head['Content-Type'] = 'image/png' 14 | result.bodyLength = result.head['Content-Length'] = info.imgData.length 15 | result.body = Buffer.from(info.imgData, 'base64') 16 | 17 | reply(null, result) 18 | } 19 | 20 | module.exports = convert 21 | -------------------------------------------------------------------------------- /src/util/create-timer.js: -------------------------------------------------------------------------------- 1 | const NS_PER_SEC = 1e9 2 | const MS_PER_NS = 1e6 3 | 4 | function Timer () { 5 | this.time0 = process.hrtime() 6 | } 7 | 8 | /** Return timer end time in ms 9 | * 10 | * @return {number} 11 | */ 12 | Timer.prototype.end = function () { 13 | const diff = process.hrtime(this.time0) 14 | 15 | return (diff[0] * NS_PER_SEC + diff[1]) / MS_PER_NS 16 | } 17 | 18 | /** Creates a timer object 19 | * 20 | * @return {object} 21 | * - end {function} 22 | */ 23 | function createTimer () { 24 | return new Timer() 25 | } 26 | 27 | module.exports = createTimer 28 | -------------------------------------------------------------------------------- /bin/orca.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const path = require('path') 4 | const { spawn } = require('child_process') 5 | const electronPath = process.env['ELECTRON_PATH'] ? process.env['ELECTRON_PATH'] : require('electron') 6 | 7 | const args = process.argv.slice(2) 8 | const pathToMain = path.join(__dirname, 'orca_electron.js') 9 | args.unshift(path.resolve(pathToMain)) 10 | 11 | // Sandbox is enabled by default since electron v5 12 | // Fix sandbox issue (https://github.com/electron/electron/issues/17972) 13 | args.push('--no-sandbox') 14 | 15 | spawn(electronPath, args, { stdio: 'inherit' }) 16 | -------------------------------------------------------------------------------- /deployment/kube/prod/hpa.yaml: -------------------------------------------------------------------------------- 1 | # WARNING: There is considerable duplication between this file and the 2 | # stage version. When updating this file, please check if your changes 3 | # need to be made to the other version. 4 | 5 | apiVersion: autoscaling/v1 6 | kind: HorizontalPodAutoscaler 7 | metadata: 8 | name: imageserver 9 | spec: 10 | scaleTargetRef: 11 | apiVersion: apps/v1 12 | kind: Deployment 13 | name: imageserver 14 | # Set this to 3x "min-nodes": 15 | minReplicas: 3 16 | # Set this to 3x "max-nodes": 17 | maxReplicas: 18 18 | targetCPUUtilizationPercentage: 30 19 | -------------------------------------------------------------------------------- /deployment/kube/stage/hpa.yaml: -------------------------------------------------------------------------------- 1 | # WARNING: There is considerable duplication between this file and the 2 | # prod version. When updating this file, please check if your changes 3 | # need to be made to the other version. 4 | 5 | apiVersion: autoscaling/v1 6 | kind: HorizontalPodAutoscaler 7 | metadata: 8 | name: imageserver 9 | spec: 10 | scaleTargetRef: 11 | apiVersion: apps/v1 12 | kind: Deployment 13 | name: imageserver 14 | # Set this to 3x "min-nodes": 15 | minReplicas: 3 16 | # Set this to 3x "max-nodes": 17 | maxReplicas: 6 18 | targetCPUUtilizationPercentage: 30 19 | -------------------------------------------------------------------------------- /deployment/roles/update/tasks/main.yml: -------------------------------------------------------------------------------- 1 | - name: Determine current sha1 to deploy 2 | local_action: command git rev-parse HEAD 3 | register: sha1 4 | - set_fact: sha1_to_deploy="{{ sha1.stdout }}" 5 | - debug: msg="Deploying rev {{ sha1_to_deploy }}" 6 | 7 | - name: Ensure image exists 8 | local_action: shell curl -o /dev/null --silent --head --fail https://quay.io/api/v1/repository/plotly/image-exporter/tag/{{ sha1_to_deploy }}/images 9 | 10 | - name: Rollout new image 11 | local_action: command kubectl set image deployments/imageserver imageserver-app=quay.io/plotly/image-exporter:{{ sha1_to_deploy }} 12 | -------------------------------------------------------------------------------- /src/util/remote.js: -------------------------------------------------------------------------------- 1 | /* Small wrapper around the renderer process 'remote' module, to 2 | * easily mock it using sinon in test/common.js 3 | * 4 | * More info on the remote module: 5 | * - https://electron.atom.io/docs/api/remote/ 6 | */ 7 | 8 | function load () { 9 | return require('electron').remote 10 | } 11 | 12 | module.exports = { 13 | createBrowserWindow: (opts) => { 14 | const _module = load() 15 | opts['skipTaskbar'] = true 16 | opts['webPreferences'] = { nodeIntegration: true } 17 | return new _module.BrowserWindow(opts) 18 | }, 19 | getCurrentWindow: () => load().getCurrentWindow() 20 | } 21 | -------------------------------------------------------------------------------- /src/util/init-pings.js: -------------------------------------------------------------------------------- 1 | const { ipcRenderer } = require('electron') 2 | 3 | /** Small wrapper that registers ipc listeners for all components 4 | * on channels based on the component name. 5 | * 6 | * @param {array of objects} components 7 | * - name {string} 8 | * - ping {function} 9 | */ 10 | function initPings (components) { 11 | components.forEach((_module) => { 12 | ipcRenderer.on(_module.name + '-ping', (event, id) => { 13 | const sendToMain = () => { 14 | ipcRenderer.send(id) 15 | } 16 | 17 | _module.ping(sendToMain) 18 | }) 19 | }) 20 | } 21 | 22 | module.exports = initPings 23 | -------------------------------------------------------------------------------- /src/component/plotly-dash-preview/convert.js: -------------------------------------------------------------------------------- 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 23 | -------------------------------------------------------------------------------- /deployment/roles/update_plotlyjs/tasks/main.yml: -------------------------------------------------------------------------------- 1 | # From https://github.com/kubernetes/kubernetes/issues/27081#issuecomment-238078103 2 | # Change an unused variable in the deployment config, which will cause a 3 | # rolling update that creates new pods. (New pods will automatically run the 4 | # latest plotly.js since that's loaded on boot.) 5 | 6 | - name: Show timestamp 7 | local_action: command date +%s 8 | register: date 9 | 10 | - name: Update plotly.js by patching the deployment 11 | local_action: command kubectl patch deployment imageserver --patch "{\"spec\":{\"template\":{\"metadata\":{\"labels\":{\"RESTART_DATE\":\"{{ date.stdout }}\"}}}}}" 12 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | import json 3 | 4 | # Rational 5 | # -------- 6 | # This file only exists to help give conda-build access to the version in our 7 | # package.json file. conda-build has built-in support for loading meta-data 8 | # from setup.py through the load_setup_py_data() function. See 9 | # https://conda.io/docs/user-guide/tasks/build-packages/define-metadata.html# 10 | 11 | with open('package.json') as f: 12 | package_json=json.load(f) 13 | 14 | # Convert NPM-compatible semantic version (e.g. "1.0.1-rc.1") 15 | # to setup tools compatible version string (e.g. "1.0.1rc1") 16 | npm_version = package_json['version'] 17 | ver_split = npm_version.split('-') 18 | py_version = ver_split[0] + ''.join(ver_split[1:]).replace('.', '') 19 | 20 | setup(version=py_version) 21 | -------------------------------------------------------------------------------- /test/pretest.js: -------------------------------------------------------------------------------- 1 | const { execSync } = require('child_process') 2 | const { paths } = require('./common') 3 | const fs = require('fs') 4 | 5 | const mock = JSON.stringify({ 6 | data: [{ 7 | y: [1, 2, 1] 8 | }], 9 | layout: { 10 | title: 'test mock' 11 | } 12 | }) 13 | 14 | fs.writeFileSync(`${paths.build}/test-mock.json`, mock) 15 | // https://stackoverflow.com/a/31104898/392162 – "Use child_process.execSync but keep output in console" 16 | const execSyncArgs = { stdio: [0, 1, 2] } 17 | 18 | execSync(`${paths.bin} graph '${mock}' -f svg -d ${paths.build} -o test-mock`, execSyncArgs) 19 | console.log(`${paths.build}/test-mock.svg created`) 20 | 21 | execSync(`${paths.bin} graph '${mock}' -f pdf -d ${paths.build} -o test-mock`, execSyncArgs) 22 | console.log(`${paths.build}/test-mock.pdf created`) 23 | -------------------------------------------------------------------------------- /src/app/server/ping.js: -------------------------------------------------------------------------------- 1 | const uuid = require('uuid/v4') 2 | 3 | /** Ping all components then resolve. If any component hangs, so will 4 | * this promise. 5 | * 6 | * @param {ipcMain} Electron ipcMain 7 | * @param {Array} components - list of components to ping 8 | */ 9 | const Ping = (ipcMain, components) => new Promise((resolve, reject) => { 10 | function runPing () { 11 | let pendingPings = components.length 12 | 13 | components.forEach((comp) => { 14 | const id = uuid() 15 | const channel = comp.name + '-ping' 16 | 17 | ipcMain.once(id, (event) => { 18 | if (--pendingPings <= 0) { 19 | resolve() 20 | } 21 | }) 22 | 23 | comp._win.webContents.send(channel, id) 24 | }) 25 | } 26 | 27 | runPing() 28 | }) 29 | 30 | module.exports = Ping 31 | -------------------------------------------------------------------------------- /src/util/init-renderers.js: -------------------------------------------------------------------------------- 1 | const { ipcRenderer } = require('electron') 2 | 3 | /** Small wrapper that registers ipc listeners for all components 4 | * on channels given by their component name. 5 | * 6 | * @param {array of objects} components 7 | * - name {string} 8 | * - render {function} 9 | */ 10 | function initRenderers (components) { 11 | components.forEach((_module) => { 12 | ipcRenderer.on(_module.name, (event, id, info, opts) => { 13 | const sendToMain = (errorCode, info) => { 14 | ipcRenderer.send(id, errorCode, info) 15 | } 16 | 17 | _module.render(info, opts, sendToMain) 18 | }) 19 | }) 20 | } 21 | 22 | // send errors to main process 23 | window.onerror = (err) => { 24 | ipcRenderer.send('renderer-error', err) 25 | } 26 | 27 | module.exports = initRenderers 28 | -------------------------------------------------------------------------------- /.circleci/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # override CircleCi's default run settings, 4 | # so that we run all tests and do not exit early 5 | # on test failures. 6 | set +e 7 | set +o pipefail 8 | 9 | EXIT_STATE=0 10 | MAX_AUTO_RETRY=2 11 | 12 | # inspired by https://unix.stackexchange.com/a/82602 13 | retry () { 14 | local n=0 15 | 16 | until [ $n -ge $MAX_AUTO_RETRY ]; do 17 | "$@" && break 18 | n=$[$n+1] 19 | echo '' 20 | echo run $n of $MAX_AUTO_RETRY failed, trying again ... 21 | echo '' 22 | sleep 15 23 | done 24 | 25 | if [ $n -eq $MAX_AUTO_RETRY ]; then 26 | EXIT_STATE=1 27 | fi 28 | } 29 | 30 | npm run pretest 31 | npm run test:lint || EXIT_STATE=$? 32 | npm run test:unit || EXIT_STATE=$? 33 | retry npm run test:integration 34 | exit $EXIT_STATE 35 | -------------------------------------------------------------------------------- /deployment/preferences.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 18 | 19 | -------------------------------------------------------------------------------- /src/util/init-app.js: -------------------------------------------------------------------------------- 1 | /** Small (common) wrapper for electron app that: 2 | * - adds command line flag to make WebGL work in headless envs 3 | * - registers renderer-error ipc listener 4 | * - handle gpu process crashed events 5 | * 6 | * @param {electron app} app 7 | * @param {ipcMain} ipcMain 8 | */ 9 | function initApp (app, ipcMain) { 10 | app.commandLine.appendSwitch('ignore-gpu-blacklist') 11 | 12 | ipcMain.on('renderer-error', (event, info) => { 13 | app.emit('renderer-error', { 14 | code: 501, 15 | msg: 'renderer error', 16 | error: info 17 | }) 18 | }) 19 | 20 | app.on('gpu-process-crashed', (event, info) => { 21 | app.emit('renderer-error', { 22 | code: 501, 23 | msg: 'gpu process crashed', 24 | error: info 25 | }) 26 | }) 27 | } 28 | 29 | module.exports = initApp 30 | -------------------------------------------------------------------------------- /src/component/plotly-dash-preview/constants.js: -------------------------------------------------------------------------------- 1 | const pixelsInInch = 96 2 | const micronsInInch = 25400 3 | 4 | module.exports = { 5 | minInterval: 500, 6 | maxRenderingTries: 100, 7 | maxPrintPDFTime: 50, 8 | pixelsInMicron: pixelsInInch / micronsInInch, 9 | sizeMapping: { 10 | 'A3': { 'width': 11.7 * pixelsInInch, 'height': 16.5 * pixelsInInch }, 11 | 'A4': { 'width': 8.3 * pixelsInInch, 'height': 11.7 * pixelsInInch }, 12 | 'A5': { 'width': 5.8 * pixelsInInch, 'height': 8.3 * pixelsInInch }, 13 | 'Letter': { 'width': 8.5 * pixelsInInch, 'height': 11 * pixelsInInch }, 14 | 'Legal': { 'width': 8.5 * pixelsInInch, 'height': 14 * pixelsInInch }, 15 | 'Tabloid': { 'width': 11 * pixelsInInch, 'height': 17 * pixelsInInch } 16 | }, 17 | statusMsg: { 18 | 525: 'dash preview generation failed', 19 | 526: 'dash preview generation timed out', 20 | 527: 'dash preview pdf generation timed out' 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/component/plotly-dashboard/parse.js: -------------------------------------------------------------------------------- 1 | const isNonEmptyString = require('../../util/is-non-empty-string') 2 | 3 | /** 4 | * @param {object} body : JSON-parsed request body 5 | * - url 6 | * - width 7 | * - height 8 | * - fid 9 | * @param {object} req: HTTP request 10 | * @param {object} opts : component options 11 | * @param {function} sendToRenderer 12 | * - errorCode 13 | * - result 14 | */ 15 | function parse (body, req, opts, sendToRenderer) { 16 | const result = {} 17 | 18 | const errorOut = (code) => { 19 | result.msg = 'missing dashboard url' 20 | sendToRenderer(code, result) 21 | } 22 | 23 | result.fid = isNonEmptyString(body.fid) ? body.fid : null 24 | 25 | if (isNonEmptyString(body.url)) { 26 | result.url = body.url 27 | } else { 28 | return errorOut(400) 29 | } 30 | 31 | result.width = body.width || 800 32 | result.height = body.height || 600 33 | 34 | sendToRenderer(null, result) 35 | } 36 | 37 | module.exports = parse 38 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | version: 0.1.{build} 2 | 3 | environment: 4 | matrix: 5 | - nodejs_version: 8 6 | MINICONDA: C:\Miniconda36-x64 7 | 8 | platform: 9 | - x64 10 | 11 | #services: 12 | 13 | cache: 14 | - node_modules -> package-lock.json 15 | - '%LOCALAPPDATA%\electron\Cache' 16 | - '%LOCALAPPDATA%\electron-builder\cache' 17 | 18 | init: 19 | - git config --global core.autocrlf input 20 | - "set PATH=%MINICONDA%;%MINICONDA%\\Scripts;%MINICONDA%\\Library\\bin;%PATH%" 21 | - conda install -y conda-build 22 | 23 | install: 24 | - ps: Install-Product node $env:nodejs_version $env:platform 25 | - npm i 26 | 27 | build_script: 28 | - npm run pack 29 | - conda build recipe\ 30 | - dir C:\Miniconda36-x64\conda-bld\win-64 31 | 32 | after_build: 33 | - 7z a release.zip release/latest.yml release/*.exe 34 | - 7z a conda-win-64.zip C:\Miniconda36-x64\conda-bld\win-64\ 35 | 36 | artifacts: 37 | - path: release.zip 38 | name: release 39 | - path: conda-win-64.zip 40 | name: conda_package 41 | 42 | test: off 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2024 Plotly Technologies Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/component/plotly-graph/constants.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | contentFormat: { 3 | png: 'image/png', 4 | jpeg: 'image/jpeg', 5 | webp: 'image/webp', 6 | svg: 'image/svg+xml', 7 | pdf: 'application/pdf', 8 | eps: 'application/postscript', 9 | emf: 'image/emf', 10 | json: 'application/json' 11 | }, 12 | 13 | statusMsg: { 14 | 400: 'invalid or malformed request syntax', 15 | 406: 'requested format is not acceptable', 16 | 525: 'plotly.js error', 17 | 526: 'plotly.js version 1.11.0 or up required', 18 | 527: 'plotly.js version 1.53.0 or up required for exporting to `json`', 19 | 530: 'image conversion error' 20 | }, 21 | 22 | dflt: { 23 | format: 'png', 24 | scale: 1, 25 | width: 700, 26 | height: 500 27 | }, 28 | 29 | // only used in render for plotly.js < v1.30.0 30 | imgPrefix: { 31 | base64: /^data:image\/\w+;base64,/, 32 | svg: /^data:image\/svg\+xml,/ 33 | }, 34 | 35 | mathJaxConfigQuery: '?config=TeX-AMS-MML_SVG', 36 | 37 | // config option passed in render step 38 | plotGlPixelRatio: 2.5, 39 | 40 | // time [in ms] after which printToPDF errors when image isn't loaded 41 | pdfPageLoadImgTimeout: 2000 42 | } 43 | -------------------------------------------------------------------------------- /test/integration/orca_test.js: -------------------------------------------------------------------------------- 1 | const tap = require('tap') 2 | const { spawn } = require('child_process') 3 | const { paths } = require('../common') 4 | const pkg = require('../../package.json') 5 | 6 | const _spawn = (t, args) => { 7 | const subprocess = spawn(paths.bin, args, { 8 | stdio: ['inherit', 'pipe', 'pipe'] 9 | }) 10 | 11 | subprocess.on('error', t.fail) 12 | 13 | return subprocess 14 | } 15 | 16 | tap.test('should print version', t => { 17 | const shouldPass = ['--version', '-v'] 18 | 19 | shouldPass.forEach(d => { 20 | t.test(`on ${d}`, t => { 21 | const subprocess = _spawn(t, [d]) 22 | 23 | subprocess.stdout.on('data', d => { 24 | t.equal(d.toString(), pkg.version + '\n') 25 | t.end() 26 | }) 27 | }) 28 | }) 29 | 30 | t.end() 31 | }) 32 | 33 | tap.test('should print help message', t => { 34 | const shouldPass = [[], ['--help'], ['-h']] 35 | 36 | shouldPass.forEach(d => { 37 | t.test(`on ${d}`, t => { 38 | const subprocess = _spawn(t, d) 39 | 40 | subprocess.stdout.on('data', d => { 41 | t.match(d.toString(), /Plotly's image-exporting utilities/) 42 | t.match(d.toString(), /Usage/) 43 | t.end() 44 | }) 45 | }) 46 | }) 47 | 48 | t.end() 49 | }) 50 | -------------------------------------------------------------------------------- /src/app/runner/get-body.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const isUrl = require('is-url') 3 | const isPlainObj = require('is-plain-obj') 4 | const request = require('request') 5 | 6 | /** 7 | * @param {string} item : 8 | * @param {function} cb : 9 | * - err 10 | * - body 11 | */ 12 | function getBody (item, cb) { 13 | let p 14 | let done 15 | 16 | // if item is object and has 'figure' key, 17 | // only parse its 'figure' value and accumulate it with item 18 | // to form body object 19 | if (isPlainObj(item) && item.figure) { 20 | p = item.figure 21 | done = (err, _figure) => { 22 | let figure 23 | 24 | try { 25 | figure = JSON.parse(_figure) 26 | } catch (e) { 27 | return cb(e) 28 | } 29 | 30 | const body = Object.assign({}, item, { figure: figure }) 31 | cb(err, body) 32 | } 33 | } else { 34 | p = item 35 | done = cb 36 | } 37 | 38 | if (fs.existsSync(p)) { 39 | fs.readFile(p, 'utf-8', done) 40 | } else if (fs.existsSync(p + '.json')) { 41 | fs.readFile(p + '.json', 'utf-8', done) 42 | } else if (isUrl(p)) { 43 | request.get(p, (err, res, body) => { 44 | if (err) { 45 | return done(err) 46 | } 47 | done(null, body) 48 | }) 49 | } else { 50 | done(null, item) 51 | } 52 | } 53 | 54 | module.exports = getBody 55 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | matrix: 2 | include: 3 | - os: osx 4 | osx_image: xcode9.0 5 | language: node_js 6 | node_js: "8" 7 | env: 8 | - ELECTRON_CACHE=$HOME/.cache/electron 9 | - ELECTRON_BUILDER_CACHE=$HOME/.cache/electron-builder 10 | 11 | cache: 12 | directories: 13 | - node_modules 14 | - $HOME/.cache/electron 15 | - $HOME/.cache/electron-builder 16 | 17 | before_install: 18 | # Conda config based on 19 | # https://github.com/astrofrog/example-travis-conda/blob/master/.travis.yml 20 | # 21 | # Here we just install Miniconda, which you shouldn't have to change. 22 | - wget http://repo.continuum.io/miniconda/Miniconda3-latest-MacOSX-x86_64.sh -O miniconda.sh 23 | - chmod +x miniconda.sh 24 | - ./miniconda.sh -b -p $HOME/miniconda 25 | - $HOME/miniconda/bin/conda install --yes conda-build 26 | 27 | install: 28 | - npm install 29 | 30 | script: 31 | - npm run pack 32 | - ls release/ 33 | - zip mac-release.zip release/orca* --junk-paths 34 | - $HOME/miniconda/bin/conda build recipe/ 35 | - ls $HOME/miniconda/conda-bld/osx-64 36 | - cp -r $HOME/miniconda/conda-bld/osx-64 ./osx-64 37 | - zip -r conda-osx-64.zip ./osx-64 38 | 39 | before_cache: 40 | - rm -rf $HOME/.cache/electron-builder/wine 41 | 42 | addons: 43 | artifacts: 44 | s3_region: us-east-1 45 | paths: 46 | - mac-release.zip 47 | - conda-osx-64.zip 48 | 49 | debug: true 50 | -------------------------------------------------------------------------------- /src/app/runner/index.js: -------------------------------------------------------------------------------- 1 | const { app, BrowserWindow } = require('electron') 2 | const { ipcMain } = require('electron') 3 | 4 | const initApp = require('../../util/init-app') 5 | const createIndex = require('../../util/create-index') 6 | const coerceOpts = require('./coerce-opts') 7 | const run = require('./run') 8 | 9 | /** Create runner app 10 | * 11 | * @param {object} _opts 12 | * - input {string or array of strings} 13 | * - debug {boolean} turn on debugging tooling 14 | * - component {string, object, array of a strings or array of objects} 15 | * - name {string} 16 | * - path {string} 17 | * - ... other options to be passed to methods 18 | * 19 | * @return {object} app 20 | */ 21 | function createApp (_opts) { 22 | initApp(app, ipcMain) 23 | 24 | const opts = coerceOpts(_opts) 25 | let win = null 26 | let index = null 27 | 28 | app.on('ready', () => { 29 | win = new BrowserWindow(opts._browserWindowOpts) 30 | 31 | if (opts.debug) { 32 | win.openDevTools() 33 | } 34 | 35 | win.on('closed', () => { 36 | win = null 37 | index.destroy() 38 | }) 39 | 40 | createIndex(opts.component, opts, (_index) => { 41 | index = _index 42 | win.loadURL(`file://${index.path}`) 43 | }) 44 | 45 | win.webContents.once('did-finish-load', () => { 46 | run(app, win, ipcMain, opts) 47 | }) 48 | }) 49 | 50 | return app 51 | } 52 | 53 | module.exports = createApp 54 | -------------------------------------------------------------------------------- /src/component/plotly-dashboard/render.js: -------------------------------------------------------------------------------- 1 | const remote = require('../../util/remote') 2 | const cst = require('./constants') 3 | 4 | /** 5 | * @param {object} info : info object 6 | * - url 7 | * - width 8 | * - height 9 | * @param {object} opts : component options 10 | * @param {function} sendToMain 11 | * - errorCode 12 | * - result 13 | * - imgData 14 | */ 15 | function render (info, opts = {}, sendToMain) { 16 | let win = remote.createBrowserWindow({ 17 | width: info.width, 18 | height: info.height, 19 | show: !!opts.debug 20 | }) 21 | 22 | const contents = win.webContents 23 | const result = {} 24 | let errorCode = null 25 | 26 | const done = () => { 27 | win.close() 28 | 29 | if (errorCode) { 30 | result.msg = cst.statusMsg[errorCode] 31 | } 32 | sendToMain(errorCode, result) 33 | } 34 | 35 | win.on('closed', () => { 36 | win = null 37 | }) 38 | 39 | // TODO 40 | // - find better solution than IFRAME_LOAD_TIMEOUT 41 | // - but really, we shouldn't be using iframes in embed view? 42 | // - use `content.capturePage` to render PNGs and JPEGs 43 | 44 | contents.once('did-finish-load', () => { 45 | setTimeout(() => { 46 | contents.printToPDF({}, (err, imgData) => { 47 | if (err) { 48 | errorCode = 525 49 | return done() 50 | } 51 | 52 | result.imgData = imgData 53 | return done() 54 | }) 55 | }, cst.iframeLoadDelay) 56 | }) 57 | 58 | win.loadURL(info.url) 59 | } 60 | 61 | module.exports = render 62 | -------------------------------------------------------------------------------- /test/image/render_mocks_cli: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | programname=$0 4 | 5 | PLOTLYJS_URL="https://cdn.plot.ly/plotly-1.54.0.min.js" 6 | 7 | function usage { 8 | echo "usage: $programname output_folder [docker:image]" 9 | echo " it renders all the JSON files found in ./test/image/mocks into the output_folder." 10 | echo " if docker:image is provided, rendering is done in the given Docker image." 11 | exit 1 12 | } 13 | 14 | # if less than two arguments supplied, display usage 15 | if [ $# -le 0 ] 16 | then 17 | usage 18 | fi 19 | 20 | for format in png pdf svg eps emf json 21 | do 22 | # To obtain reproducible results, we need to use software rendering 23 | export LIBGL_ALWAYS_SOFTWARE=true 24 | export GALLIUM_DRIVER=softpipe 25 | 26 | # Supply defaults assets to Orca 27 | ORCA_OPTS=(--topojson /plotly-geo-assets.js --mathjax /mathjax/MathJax.js) 28 | 29 | # pin Plotly.js' version 30 | ORCA_OPTS+=(--plotlyJS "$PLOTLYJS_URL") 31 | 32 | # Set Orca options 33 | ORCA_OPTS+=(--verbose --format "$format" --output-dir "$1") 34 | 35 | if [ -z "$2" ]; # 36 | then 37 | echo "Executing locally" 38 | echo "Generating figures in \"$format\" format" 39 | orca graph "${ORCA_OPTS[@]}" test/image/mocks/*.json 40 | else 41 | echo "Executing into Docker image \"$2\"" 42 | echo "Generating figures in \"$format\" format" 43 | docker run -v "$(pwd)":"$(pwd)" -w "$(pwd)" -it \ 44 | -e LIBGL_ALWAYS_SOFTWARE -e GALLIUM_DRIVER \ 45 | "$2" graph "${ORCA_OPTS[@]}" test/image/mocks/*.json 46 | fi 47 | done 48 | -------------------------------------------------------------------------------- /deployment/run_server: -------------------------------------------------------------------------------- 1 | #!/bin/bash -xe 2 | 3 | fc-cache -v /usr/share/fonts/user 4 | 5 | if [[ $PLOTLY_CUSTOM_FONTS_ENABLED = true ]]; then 6 | fc-cache -v /usr/share/fonts/customer 7 | fi 8 | 9 | if [[ $PLOTLY_IMAGESERVER_IGNORE_CERT_ERRORS = true ]]; then 10 | ORCA_IGNORECERTERRORS_ARG="--ignore-certificate-errors" 11 | fi 12 | 13 | BUILD_DIR=/var/www/image-exporter/build 14 | if [[ -n "${PLOTLY_JS_SRC}" ]]; then 15 | # Fetch plotly js bundle and save it locally: 16 | mkdir -p $BUILD_DIR 17 | while true; do 18 | wget --tries=1 --no-check-certificate -O $BUILD_DIR/plotly-bundle.js "$PLOTLY_JS_SRC" && break 19 | sleep 1 20 | done 21 | PLOTLYJS_ARG="--plotlyJS $BUILD_DIR/plotly-bundle.js" 22 | fi 23 | 24 | # Dynamically create the wrapper used to launch xvfb and the server so that 25 | # we can fill in $@ from the Docker "command" argument. 26 | cat > /usr/local/bin/xvfb_wrapper </proc/1/fd/1 2>/proc/1/fd/2 30 | 31 | PIDFILE=/var/run/xvfb.pid 32 | 33 | if [ -e \$PIDFILE ] ; then 34 | kill \`cat \$PIDFILE\` 35 | rm -f \$PIDFILE 36 | sleep 1 37 | fi 38 | 39 | pkill -9 Xvfb 40 | pkill -9 node 41 | pkill -9 electron 42 | 43 | xvfb-run --auto-servernum --server-args '-screen 0 1024x768x24' /var/www/image-exporter/bin/orca.js serve $REQUEST_LIMIT --safe-mode --verbose $PLOTLYJS_ARG $ORCA_IGNORECERTERRORS_ARG $@ & 44 | echo \$! > \$PIDFILE 45 | 46 | EOF 47 | 48 | chmod 755 /usr/local/bin/xvfb_wrapper 49 | 50 | /usr/local/bin/xvfb_wrapper 51 | 52 | if [[ "$PLOTLY_IMAGESERVER_ENABLE_MONIT" = "true" ]]; then 53 | /opt/monit/bin/monit -c /etc/monitrc 54 | else 55 | sleep infinity 56 | fi 57 | -------------------------------------------------------------------------------- /test/dash-preview/render_mocks_cli: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | programname=$0 4 | 5 | ENDPOINT="http://localhost:9091" 6 | 7 | function usage { 8 | echo "usage: $programname output_folder" 9 | echo " it renders all the JSON files found in ./test/dash-preview/mocks into the output_folder." 10 | exit 1 11 | } 12 | 13 | # if less than two arguments supplied, display usage 14 | if [ $# -le 0 ] 15 | then 16 | usage 17 | fi 18 | 19 | # Check that server is running 20 | function fail { 21 | echo $1 >&2 22 | exit 1 23 | } 24 | 25 | function retry { 26 | local n=1 27 | local max=5 28 | local delay=5 29 | while true; do 30 | "$@" && break || { 31 | if [[ $n -lt $max ]]; then 32 | ((n++)) 33 | echo "Command failed. Attempt $n/$max:" 34 | sleep $delay; 35 | else 36 | fail "The command has failed after $n attempts." 37 | fi 38 | } 39 | done 40 | } 41 | retry curl --silent "$ENDPOINT/ping" 42 | echo 43 | echo "Orca is up and running at $ENDPOINT" 44 | 45 | # TODO: use the following block instead when upgrading curl 46 | # output=$(curl --retry 5 --retry-delay 5 --retry-connrefuse --silent http://localhost:9091/ping) 47 | # if [[ $output == pong ]]; then 48 | # echo "Orca is up and running" 49 | # else 50 | # echo "Orca is not available at http://localhost:9091." 51 | # echo "You have to manually start it to run this test." 52 | # exit 1 53 | # fi 54 | 55 | mkdir -p "$1" 56 | 57 | for path in ./test/dash-preview/mocks/*.json; do 58 | filename=${path##*/} 59 | name=${filename%.json} 60 | echo "Exporting mock $name.json" 61 | curl -XPOST -d "@$path" "$ENDPOINT/dash-preview" -o "$1/$name.pdf" 62 | done 63 | -------------------------------------------------------------------------------- /test/common.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const EventEmitter = require('events') 4 | const sinon = require('sinon') 5 | 6 | const paths = {} 7 | const urls = {} 8 | const mocks = {} 9 | 10 | paths.root = path.join(__dirname, '..') 11 | paths.build = path.join(path.join(paths.root, 'build')) 12 | paths.bin = path.join(paths.root, 'bin', 'orca.js') 13 | paths.readme = path.join(paths.root, 'README.md') 14 | paths.pkg = path.join(paths.root, 'package.json') 15 | paths.glob = path.join(paths.root, 'src', 'util', '*') 16 | 17 | urls.dummy = 'http://dummy.url' 18 | urls.plotlyGraphMock = 'https://raw.githubusercontent.com/plotly/plotly.js/master/test/image/mocks/20.json' 19 | 20 | try { 21 | mocks.figure = JSON.parse(fs.readFileSync(path.join(paths.build, 'test-mock.json'), 'utf-8')) 22 | mocks.svg = fs.readFileSync(path.join(paths.build, 'test-mock.svg'), 'utf-8') 23 | mocks.pdf = fs.readFileSync(path.join(paths.build, 'test-mock.pdf')) 24 | } catch (e) {} 25 | 26 | function createMockWindow (opts = {}) { 27 | const win = new EventEmitter() 28 | const webContents = new EventEmitter() 29 | 30 | webContents.executeJavaScript = sinon.stub() 31 | webContents.printToPDF = sinon.stub() 32 | webContents.sendInputEvent = sinon.stub() 33 | webContents.session = sinon.stub() 34 | webContents.session.clearStorageData = sinon.stub() 35 | 36 | Object.assign(win, opts, { 37 | webContents: webContents, 38 | loadURL: sinon.stub().callsFake(() => { 39 | webContents.emit('did-finish-load') 40 | }), 41 | close: sinon.stub().callsFake(() => { 42 | win.emit('closed') 43 | }) 44 | }) 45 | 46 | return win 47 | } 48 | 49 | function stubProp (obj, key, newVal) { 50 | const oldVal = obj[key] 51 | obj[key] = newVal 52 | return () => { obj[key] = oldVal } 53 | } 54 | 55 | module.exports = { 56 | paths: paths, 57 | urls: urls, 58 | mocks: mocks, 59 | createMockWindow: createMockWindow, 60 | stubProp: stubProp 61 | } 62 | -------------------------------------------------------------------------------- /src/component/plotly-dashboard-thumbnail/parse.js: -------------------------------------------------------------------------------- 1 | const isPlainObj = require('is-plain-obj') 2 | const isNonEmptyString = require('../../util/is-non-empty-string') 3 | const overrideFigure = require('../plotly-thumbnail/parse').overrideFigure 4 | 5 | /** 6 | * @param {object} body : JSON-parsed request body 7 | * - layout: 8 | * - type 9 | * - direction 10 | * - first, second: 11 | * - boxType 12 | * - figure 13 | * - settings: 14 | * - backgroundColor 15 | * @param {object} req: HTTP request 16 | * @param {object} opts : component options 17 | * @param {function} sendToRenderer 18 | * - errorCode 19 | * - result 20 | */ 21 | function parse (body, req, opts, sendToRenderer) { 22 | const result = {} 23 | 24 | const errorOut = (code) => { 25 | result.msg = 'invalid body' 26 | sendToRenderer(code, result) 27 | } 28 | 29 | result.fid = isNonEmptyString(body.fid) ? body.fid : null 30 | 31 | const layout = body.figure.layout 32 | result.panels = [] 33 | 34 | const parseFromType = (cont) => { 35 | switch (cont.type) { 36 | case 'split': 37 | parseFromType(cont.first) 38 | parseFromType(cont.second) 39 | break 40 | case 'box': 41 | parseFromBoxType(cont) 42 | break 43 | } 44 | } 45 | 46 | const parseFromBoxType = (cont) => { 47 | if (cont.boxType === 'plot') { 48 | const figure = { 49 | data: cont.figure.data || [], 50 | layout: cont.figure.layout || {} 51 | } 52 | overrideFigure(figure) 53 | result.panels.push(figure) 54 | } 55 | } 56 | 57 | if (isPlainObj(layout)) { 58 | parseFromType(layout) 59 | } else { 60 | return errorOut(400) 61 | } 62 | 63 | const settings = body.settings 64 | 65 | if (isPlainObj(settings) && isNonEmptyString(settings.backgroundColor)) { 66 | result.backgroundColor = settings.backgroundColor 67 | } else { 68 | result.backgroundColor = '#fff' 69 | } 70 | 71 | sendToRenderer(null, result) 72 | } 73 | 74 | module.exports = parse 75 | -------------------------------------------------------------------------------- /src/component/plotly-graph/inject.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const isUrl = require('is-url') 4 | const semver = require('semver') 5 | const isNonEmptyString = require('../../util/is-non-empty-string') 6 | const cst = require('./constants') 7 | 8 | /** plotly-graph inject 9 | * 10 | * @param {object} opts : component options 11 | * - plotlyJS 12 | * - mathjax 13 | * - topojson 14 | * @return {array} 15 | */ 16 | function inject (opts = {}) { 17 | const plotlyJS = opts.plotlyJS 18 | const mathjax = opts.mathjax 19 | const topojson = opts.topojson 20 | const parts = [] 21 | 22 | if (isNonEmptyString(mathjax)) { 23 | let src = resolve(mathjax) 24 | if (src) { 25 | parts.push(script(src + cst.mathJaxConfigQuery)) 26 | } else { 27 | throw new Error('Provided path to MathJax files does not exists') 28 | } 29 | } 30 | 31 | if (isNonEmptyString(topojson)) { 32 | let src = resolve(topojson) 33 | if (src) { 34 | parts.push(script(src)) 35 | } else { 36 | throw new Error('Provided path to topojson files does not exists') 37 | } 38 | } 39 | 40 | if (isNonEmptyString(plotlyJS)) { 41 | let src = resolve(plotlyJS) 42 | if (src) { 43 | parts.push(script(src)) 44 | } else if (plotlyJS === 'latest' || semver.valid(plotlyJS)) { 45 | parts.push(script(cdnSrc(plotlyJS))) 46 | } else { 47 | throw new Error('Provided path to plotly.js bundle does not exist and does not correspond to a release version') 48 | } 49 | } else { 50 | parts.push(script(cdnSrc('latest'))) 51 | } 52 | 53 | return parts 54 | } 55 | 56 | function resolve (v) { 57 | if (isUrl(v)) { 58 | return v 59 | } else { 60 | const p = path.resolve(v) 61 | return fs.existsSync(p) ? p : false 62 | } 63 | } 64 | 65 | function script (src) { 66 | return `` 67 | } 68 | 69 | function cdnSrc (v) { 70 | v = v.charAt(0) === 'v' ? v.slice(1) : v 71 | return `https://cdn.plot.ly/plotly-${v}.min.js` 72 | } 73 | 74 | module.exports = inject 75 | -------------------------------------------------------------------------------- /src/util/pdftops.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const childProcess = require('child_process') 4 | const parallel = require('run-parallel') 5 | const series = require('run-series') 6 | const uuid = require('uuid/v4') 7 | const os = require('os') 8 | 9 | const PATH_TO_BUILD = path.join(os.tmpdir(), 'orca-build') 10 | try { 11 | fs.mkdirSync(PATH_TO_BUILD) 12 | } catch (e) {} 13 | 14 | /** Node wrapper for pdftops 15 | * 16 | * $ apt-get poppler-utils 17 | * ... or on OS X: 18 | * $ brew install poppler 19 | * 20 | * See: 21 | * - https://linux.die.net/man/1/pdftops 22 | * - https://en.wikipedia.org/wiki/Poppler_(software)#poppler-utils 23 | */ 24 | class Pdftops { 25 | constructor (pathToPdftops) { 26 | this.cmdBase = pathToPdftops || 'pdftops' 27 | } 28 | 29 | /** Convert PDF to EPS 30 | * 31 | * @param {buffer} pdf : pdf data buffer 32 | * @param {object} opts 33 | * - id {string} 34 | * @param {function} cb 35 | * - err {null || error} 36 | * - result {buffer} 37 | */ 38 | pdf2eps (pdf, opts, cb) { 39 | const id = opts.id || uuid() 40 | const inPath = path.join(PATH_TO_BUILD, id + '-pdf') 41 | const outPath = path.join(PATH_TO_BUILD, id + '-eps') 42 | const cmd = `${this.cmdBase} -eps ${inPath} ${outPath}` 43 | 44 | const destroyTmpFiles = (cb) => parallel([ 45 | (cb) => fs.unlink(inPath, cb), 46 | (cb) => fs.unlink(outPath, cb) 47 | ], cb) 48 | 49 | series([ 50 | (cb) => fs.writeFile(inPath, pdf, 'utf-8', cb), 51 | (cb) => childProcess.exec(cmd, cb), 52 | (cb) => fs.readFile(outPath, cb) 53 | ], (err, result) => { 54 | destroyTmpFiles(() => { 55 | cb(err, result[2]) 56 | }) 57 | }) 58 | } 59 | 60 | /** Is pdftops installed? 61 | * @return {boolean} 62 | */ 63 | static isPdftopsInstalled () { 64 | try { 65 | childProcess.execSync(`${this.cmdBase} -v`, { stdio: 'ignore' }) 66 | } catch (e) { 67 | return false 68 | } 69 | return true 70 | } 71 | } 72 | 73 | module.exports = Pdftops 74 | -------------------------------------------------------------------------------- /src/app/server/coerce-opts.js: -------------------------------------------------------------------------------- 1 | const coerceComponent = require('../../util/coerce-component') 2 | const isPositiveNumeric = require('../../util/is-positive-numeric') 3 | const cst = require('./constants') 4 | 5 | /** Coerce server options 6 | * 7 | * @param {object} _opts : (user) server options container 8 | * @return {object} coerce options including: 9 | * - _browserWindowOpts {object} 10 | * - _componentLookup {object} 11 | */ 12 | function coerceOpts (_opts = {}) { 13 | const opts = {} 14 | 15 | if (isPositiveNumeric(_opts.port)) { 16 | opts.port = Number(_opts.port) 17 | } else { 18 | throw new Error('invalid port number') 19 | } 20 | 21 | opts.maxNumberOfWindows = isPositiveNumeric(_opts.maxNumberOfWindows) 22 | ? Number(_opts.maxNumberOfWindows) 23 | : cst.dflt.maxNumberOfWindows 24 | 25 | opts.debug = !!_opts.debug 26 | opts.cors = !!_opts.cors 27 | opts._browserWindowOpts = { 28 | show: !!opts.debug, 29 | // Starting with Electron 5 we need the following 30 | // see: https://stackoverflow.com/questions/44391448/electron-require-is-not-defined 31 | webPreferences: { 32 | nodeIntegration: true 33 | } 34 | } 35 | 36 | const _components = Array.isArray(_opts.component) ? _opts.component : [_opts.component] 37 | const componentLookup = {} 38 | opts.component = [] 39 | 40 | _components.forEach((_comp) => { 41 | const comp = coerceComponent(_comp, opts.debug) 42 | 43 | if (comp) { 44 | if (componentLookup[comp.route]) { 45 | throw new Error('trying to register multiple components on same route') 46 | } 47 | 48 | componentLookup[comp.route] = comp 49 | opts.component.push(comp) 50 | } 51 | }) 52 | 53 | if (opts.component.length === 0) { 54 | throw new Error('no valid component registered') 55 | } 56 | 57 | opts._componentLookup = componentLookup 58 | 59 | opts.requestTimeout = isPositiveNumeric(_opts.requestTimeout) 60 | ? Number(_opts.requestTimeout) * 1000 61 | : cst.dflt.requestTimeout * 1000 62 | 63 | return opts 64 | } 65 | 66 | module.exports = coerceOpts 67 | -------------------------------------------------------------------------------- /src/app/runner/coerce-opts.js: -------------------------------------------------------------------------------- 1 | const glob = require('glob') 2 | const isPlainObj = require('is-plain-obj') 3 | const coerceComponent = require('../../util/coerce-component') 4 | const isPositiveNumeric = require('../../util/is-positive-numeric') 5 | const isNonEmptyString = require('../../util/is-non-empty-string') 6 | const cst = require('./constants') 7 | 8 | /** Coerce runner options 9 | * 10 | * @param {object} _opts : (user) runner options container 11 | * @return {object} coerce options including: 12 | * - _browserWindowOpts {object} 13 | */ 14 | function coerceOpts (_opts = {}) { 15 | const opts = {} 16 | 17 | opts.debug = !!_opts.debug 18 | opts._browserWindowOpts = { 19 | show: !!opts.debug, 20 | // Starting with Electron 5 we need the following 21 | // see: https://stackoverflow.com/questions/44391448/electron-require-is-not-defined 22 | webPreferences: { 23 | nodeIntegration: true 24 | } 25 | } 26 | 27 | opts.parallelLimit = isPositiveNumeric(_opts.parallelLimit) 28 | ? Number(_opts.parallelLimit) 29 | : cst.dflt.parallelLimit 30 | 31 | const _comp = Array.isArray(_opts.component) ? _opts.component[0] : _opts.component 32 | const comp = coerceComponent(_comp, opts.debug) 33 | 34 | if (comp) { 35 | opts.component = comp 36 | } else { 37 | throw new Error('no valid component registered') 38 | } 39 | 40 | const _input = Array.isArray(_opts.input) ? _opts.input : [_opts.input] 41 | let input = [] 42 | 43 | _input.forEach((item) => { 44 | if (isNonEmptyString(item)) { 45 | const matches = glob.sync(item) 46 | 47 | if (matches.length === 0) { 48 | input.push(item) 49 | } else { 50 | input = input.concat(matches) 51 | } 52 | } else if (isPlainObj(item)) { 53 | input.push(item) 54 | } 55 | }) 56 | 57 | if (input.length === 0) { 58 | throw new Error('no valid input given') 59 | } 60 | 61 | opts.write = typeof _opts.write === 'function' 62 | ? _opts.write 63 | : false 64 | 65 | opts.input = input 66 | 67 | return opts 68 | } 69 | 70 | module.exports = coerceOpts 71 | -------------------------------------------------------------------------------- /test/integration/orca_serve_graph-only_test.js: -------------------------------------------------------------------------------- 1 | const tap = require('tap') 2 | const Application = require('spectron').Application 3 | const request = require('request') 4 | 5 | const { paths } = require('../common') 6 | const PORT = 9110 7 | const SERVER_URL = `http://localhost:${PORT}` 8 | 9 | const app = new Application({ 10 | path: paths.bin, 11 | args: ['serve', '--port', PORT, '--graph-only'] 12 | }) 13 | 14 | tap.tearDown(() => { 15 | if (app && app.isRunning()) { 16 | app.stop() 17 | } 18 | }) 19 | 20 | tap.test('should launch', t => { 21 | app.start().then(() => { 22 | app.client.getWindowCount().then(cnt => { 23 | // Only one window since only graph component should be running 24 | t.equal(cnt, 1) 25 | t.end() 26 | }) 27 | }) 28 | }) 29 | 30 | tap.test('should reply pong to ping POST', t => { 31 | request.post(SERVER_URL + '/ping', (err, res, body) => { 32 | if (err) t.fail(err) 33 | 34 | t.equal(res.statusCode, 200, 'code') 35 | t.equal(body, 'pong', 'body') 36 | t.end() 37 | }) 38 | }) 39 | 40 | tap.test('should work for *plotly-graph* component', t => { 41 | request({ 42 | method: 'POST', 43 | url: SERVER_URL + '/', 44 | body: JSON.stringify({ 45 | figure: { 46 | layout: { 47 | data: [{ y: [1, 2, 1] }] 48 | } 49 | } 50 | }) 51 | }, (err, res, body) => { 52 | if (err) t.fail(err) 53 | 54 | t.equal(res.statusCode, 200, 'code') 55 | t.type(body, 'string') 56 | t.end() 57 | }) 58 | }) 59 | 60 | tap.test('should not work for *plotly-thumbnail* component', t => { 61 | request({ 62 | method: 'POST', 63 | url: SERVER_URL + '/thumbnail', 64 | body: JSON.stringify({ 65 | figure: { 66 | layout: { 67 | data: [{ y: [1, 2, 1] }] 68 | } 69 | } 70 | }) 71 | }, (err, res) => { 72 | if (err) t.fail(err) 73 | 74 | t.equal(res.statusCode, 404, 'should return a HTTP 404 response') 75 | t.end() 76 | }) 77 | }) 78 | 79 | tap.test('should teardown', t => { 80 | app.stop() 81 | .catch(t.fail) 82 | .then(t.end) 83 | }) 84 | -------------------------------------------------------------------------------- /src/app/server/index.js: -------------------------------------------------------------------------------- 1 | const { app, BrowserWindow } = require('electron') 2 | const { ipcMain } = require('electron') 3 | // require('electron-debug')({showDevTools: true}) 4 | 5 | const initApp = require('../../util/init-app') 6 | const createIndex = require('../../util/create-index') 7 | const createTimer = require('../../util/create-timer') 8 | const coerceOpts = require('./coerce-opts') 9 | const createServer = require('./create-server') 10 | 11 | /** Create server app 12 | * 13 | * @param {object} _opts 14 | * - port {number} port number 15 | * - debug {boolean} turn on debugging tooling 16 | * - component {string, object} 17 | * - name {string} 18 | * - path {string} 19 | * - ... other options to be passed to methods 20 | * 21 | * @return {object} app 22 | */ 23 | function createApp (_opts) { 24 | initApp(app, ipcMain) 25 | 26 | const opts = coerceOpts(_opts) 27 | const components = opts.component 28 | const server = createServer(app, BrowserWindow, ipcMain, opts) 29 | 30 | let timer = createTimer() 31 | let numberOfWindowstYetToBeLoaded = components.length 32 | 33 | app.on('ready', () => { 34 | components.forEach((comp) => { 35 | let win = new BrowserWindow(opts._browserWindowOpts) 36 | comp._win = win 37 | 38 | if (opts.debug) { 39 | win.openDevTools() 40 | } 41 | 42 | win.on('closed', () => { 43 | win = null 44 | }) 45 | 46 | createIndex(comp, opts, (index) => { 47 | comp._index = index 48 | win.loadURL(`file://${index.path}`) 49 | }) 50 | 51 | win.webContents.once('did-finish-load', () => { 52 | if (--numberOfWindowstYetToBeLoaded === 0) { 53 | server.listen(opts.port, () => { 54 | app.emit('after-connect', { 55 | port: opts.port, 56 | startupTime: timer.end(), 57 | openRoutes: components.map((comp) => comp.route) 58 | }) 59 | }) 60 | } 61 | }) 62 | }) 63 | }) 64 | 65 | process.on('exit', () => { 66 | server.close() 67 | components.forEach((comp) => comp._index.destroy()) 68 | }) 69 | 70 | return app 71 | } 72 | 73 | module.exports = createApp 74 | -------------------------------------------------------------------------------- /test/unit/plotly-dashboard-thumbnail_test.js: -------------------------------------------------------------------------------- 1 | const tap = require('tap') 2 | const _module = require('../../src/component/plotly-dashboard-thumbnail') 3 | 4 | tap.test('parse:', t => { 5 | const fn = _module.parse 6 | 7 | t.test('should fill in defaults', t => { 8 | const body = { 9 | settings: { backgroundColor: '#d3d3d3' }, 10 | figure: { 11 | layout: { 12 | type: 'split', 13 | first: { 14 | type: 'split', 15 | first: { 16 | type: 'box', 17 | boxType: 'plot', 18 | figure: { 19 | data: [{ 20 | y: [1, 2, 1] 21 | }] 22 | } 23 | }, 24 | second: { 25 | type: 'box', 26 | boxType: 'plot', 27 | figure: { 28 | data: [{ 29 | type: 'bar', 30 | y: [1, 2, 4] 31 | }] 32 | } 33 | } 34 | }, 35 | second: { 36 | type: 'split', 37 | first: { 38 | type: 'box', 39 | boxType: 'plot', 40 | figure: { 41 | data: [{ 42 | type: 'heatmap', 43 | z: [[1, 2, 4], [1, 2, 3]] 44 | }] 45 | } 46 | }, 47 | second: { 48 | type: 'box', 49 | boxType: 'plot', 50 | figure: { 51 | data: [{ 52 | type: 'scatter3d', 53 | x: [1, 2, 3], 54 | y: [1, 2, 3], 55 | z: [1, 2, 1] 56 | }] 57 | } 58 | } 59 | } 60 | } 61 | } 62 | } 63 | 64 | fn(body, {}, {}, (errorCode, result) => { 65 | t.equal(errorCode, null, 'code') 66 | 67 | t.equal(result.backgroundColor, '#d3d3d3', 'backgroundColor') 68 | t.equal(result.panels.length, 4, '# of panels') 69 | 70 | result.panels.forEach(p => { 71 | t.type(p.data, Array, 'has data array') 72 | t.type(p.layout, Object, 'has layout object') 73 | }) 74 | 75 | t.end() 76 | }) 77 | }) 78 | 79 | t.end() 80 | }) 81 | -------------------------------------------------------------------------------- /src/util/coerce-component.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const isPlainObj = require('is-plain-obj') 3 | const isNonEmptyString = require('./is-non-empty-string') 4 | 5 | const REQUIRED_METHODS = ['ping', 'parse', 'render', 'convert'] 6 | const PATH_TO_COMPONENT = path.join(__dirname, '..', 'component') 7 | const NOOP = function () {} 8 | 9 | /** Coerce component options 10 | * 11 | * @param {object} _comp : user component option object 12 | * @param {object} opts : app options 13 | * - debug 14 | * @return {object or null} : 15 | * full component option object or null (if component is invalid) 16 | */ 17 | function coerceComponent (_comp, opts = {}) { 18 | const debug = opts.debug 19 | const comp = {} 20 | 21 | if (isNonEmptyString(_comp)) { 22 | comp.path = path.join(PATH_TO_COMPONENT, _comp) 23 | } else if (isPlainObj(_comp)) { 24 | if (isNonEmptyString(_comp.path)) { 25 | comp.path = _comp.path 26 | } else if (isNonEmptyString(_comp.name)) { 27 | comp.path = path.join(PATH_TO_COMPONENT, _comp.name) 28 | } else { 29 | if (debug) console.warn(`path to component not found`) 30 | return null 31 | } 32 | } else { 33 | if (debug) console.warn(`non-string, non-object component passed`) 34 | return null 35 | } 36 | 37 | try { 38 | comp._module = require(comp.path) 39 | comp.name = comp._module.name 40 | } catch (e) { 41 | if (debug) console.warn(e) 42 | return null 43 | } 44 | 45 | if (!isModuleValid(comp._module)) { 46 | if (debug) console.warn(`invalid component module ${comp.path}`) 47 | return null 48 | } 49 | 50 | if (isPlainObj(_comp)) { 51 | const r = isNonEmptyString(_comp.route) ? _comp.route : comp.name 52 | comp.route = r.charAt(0) === '/' ? r : '/' + r 53 | 54 | comp.options = isPlainObj(_comp.options) 55 | ? Object.assign({}, _comp.options) 56 | : {} 57 | } else { 58 | comp.route = '/' + comp.name 59 | comp.options = {} 60 | } 61 | 62 | if (typeof comp._module.inject !== 'function') { 63 | comp._module.inject = NOOP 64 | } 65 | 66 | return comp 67 | } 68 | 69 | function isModuleValid (_module) { 70 | return ( 71 | isNonEmptyString(_module.name) && 72 | REQUIRED_METHODS.every((m) => typeof _module[m] === 'function') 73 | ) 74 | } 75 | 76 | module.exports = coerceComponent 77 | -------------------------------------------------------------------------------- /test/integration/orca_serve_offline_test.js: -------------------------------------------------------------------------------- 1 | const tap = require('tap') 2 | const Application = require('spectron').Application 3 | 4 | const { paths } = require('../common') 5 | const path = require('path') 6 | 7 | const PORT = 9111 8 | 9 | const numberOfComponents = 6 10 | const axios = require('axios') 11 | const fs = require('fs') 12 | const pathToPlotlyJS = path.join(paths.build, 'plotly-latest.min.js') 13 | 14 | const app = new Application({ 15 | path: paths.bin, 16 | args: ['serve', '--port', PORT, '--plotlyjs', pathToPlotlyJS] 17 | }) 18 | 19 | tap.tearDown(() => { 20 | if (app && app.isRunning()) { 21 | app.stop() 22 | } 23 | }) 24 | 25 | tap.test('should launch', t => { 26 | axios.request({ 27 | url: 'https://cdn.plot.ly/plotly-latest.min.js', 28 | method: 'get' 29 | }) 30 | .then((result) => { 31 | fs.writeFileSync(pathToPlotlyJS, result.data) 32 | }) 33 | .then(() => { 34 | return app.start() 35 | }) 36 | .then(() => { 37 | app.client.getWindowCount().then(cnt => { 38 | t.equal(cnt, numberOfComponents) 39 | t.end() 40 | }) 41 | }) 42 | .catch(err => { 43 | t.fail(err) 44 | t.end() 45 | }) 46 | }) 47 | 48 | function getScriptTagsSrc (index) { 49 | // executeJavaScript in Spectron is broken https://github.com/electron/spectron/issues/163 50 | return app.client.windowByIndex(index).then(() => { 51 | return app.client.execute(() => { 52 | var htmlCollection = document.getElementsByTagName('script') 53 | var arr = [].slice.call(htmlCollection) 54 | return arr.map(script => { 55 | return script.src 56 | }) 57 | }) 58 | }) 59 | } 60 | 61 | tap.test('should not link to resources on the network', t => { 62 | var promises = [] 63 | for (var i = 0; i < numberOfComponents; i++) { 64 | promises.push(getScriptTagsSrc(0)) 65 | } 66 | Promise.all(promises).then(values => { 67 | values.forEach(result => { 68 | var urls = result.value 69 | urls.forEach(url => { 70 | t.notOk(url.match('http'), `A script tag refers to an HTTP(S) resource ${url}`) 71 | }) 72 | }) 73 | t.end() 74 | }) 75 | .catch(err => { 76 | t.fail(err) 77 | t.end() 78 | }) 79 | }) 80 | 81 | tap.test('should teardown', t => { 82 | app.stop() 83 | .catch(t.fail) 84 | .then(t.end) 85 | }) 86 | -------------------------------------------------------------------------------- /src/util/create-index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const uuid = require('uuid/v4') 4 | const isNonEmptyString = require('./is-non-empty-string') 5 | const os = require('os') 6 | 7 | const COMPONENT_GLOBAL = 'PlotlyExporterComponent' 8 | const PATH_TO_BUILD = path.join(os.tmpdir(), 'orca-build') 9 | try { 10 | fs.mkdirSync(PATH_TO_BUILD, 0o777) 11 | } catch (e) {} 12 | const PATH_TO_INIT_RENDERERS = path.join(__dirname, 'init-renderers.js') 13 | const PATH_TO_INIT_PINGS = path.join(__dirname, 'init-pings.js') 14 | 15 | /** Create HTML index file 16 | * 17 | * @param {object} comp : (full) component object 18 | * - name 19 | * - path 20 | * - options 21 | * - _method.inject 22 | * @param {object} opts : app options 23 | * - debug 24 | * @param {function} cb callback 25 | * - err 26 | * - index {object} 27 | * - path {string} 28 | * - destroy {function} 29 | */ 30 | function createIndex (comp, opts, cb) { 31 | const debug = (opts || {}).debug 32 | const uid = uuid() 33 | const pathToIndex = path.join(PATH_TO_BUILD, `index-${uid}.html`) 34 | 35 | const inject = () => { 36 | const parts = comp._module.inject(comp.options) 37 | 38 | if (isNonEmptyString(parts)) { 39 | return parts 40 | } else if (Array.isArray(parts)) { 41 | return parts.join('\n ') 42 | } else { 43 | return '' 44 | } 45 | } 46 | 47 | const req = (p) => { 48 | return `require(${JSON.stringify(p)})` 49 | } 50 | 51 | const html = ` 52 | 53 | 54 | 55 | plotly image exporter - component ${comp.name} (${uid}) 56 | ${inject()} 57 | 58 | 59 | 64 | 65 | ` 66 | 67 | fs.writeFile(pathToIndex, html, (err) => { 68 | if (err) throw err 69 | 70 | if (debug) { 71 | console.log(`created ${path.basename(pathToIndex)} for ${comp.name} component`) 72 | } 73 | 74 | const index = { 75 | path: pathToIndex, 76 | destroy: () => fs.unlinkSync(pathToIndex) 77 | } 78 | 79 | cb(index) 80 | }) 81 | } 82 | 83 | module.exports = createIndex 84 | -------------------------------------------------------------------------------- /test/image/compare_images: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | programname=$0 4 | 5 | function usage { 6 | echo "usage: $programname baselines_folder test_images_folder destination_folder" 7 | exit 1 8 | } 9 | 10 | # if less than two arguments supplied, display usage 11 | if [ $# -le 2 ] 12 | then 13 | usage 14 | fi 15 | 16 | mkdir -p "$3" 17 | 18 | for fullfile in "$1"/* 19 | do 20 | filename="${fullfile##*/}" 21 | format="${filename##*.}" 22 | 23 | case "$format" in 24 | json) 25 | diff <(jq -S . "$1/$filename") <(jq -S . "$2/$filename") > "$3/$filename.diff" 26 | 27 | # if output are the same 28 | if [ $? -eq 0 ] 29 | then 30 | rm "$3/$filename.diff" 31 | # else copy 32 | else 33 | cp "$1/$filename" "$3/$filename.baseline.json" 34 | cp "$2/$filename" "$3/$filename" 35 | echo "all: 1" > "$3/$filename.txt" 36 | fi 37 | 38 | continue # skip the rest 39 | ;; 40 | png) 41 | # Copy as is 42 | cp "$1/$filename" "$3/$filename.baseline.png" 43 | cp "$2/$filename" "$3/$filename.png" 44 | ;; 45 | eps) 46 | # Use ImageMagick to convert EPS to PNG because Inkscape fails to render embedded fonts 47 | # TODO: use Inkscape's --pdf-poppler when v1 is released (https://gitlab.com/inkscape/inkscape/issues/263) 48 | convert -density 300 "$fullfile" "$3/$filename.baseline.png" 49 | convert -density 300 "$2/$filename" "$3/$filename.png" 50 | ;; 51 | pdf) 52 | # Use ImageMagick to convert PDF to PNG because Inkscape reverse gradients 53 | convert -density 300 "$fullfile" "$3/$filename.baseline.png" 54 | convert -density 300 "$2/$filename" "$3/$filename.png" 55 | ;; 56 | *) 57 | # Use Inkscape to convert to PNG 58 | inkscape "$fullfile" --export-dpi 300 --export-png "$3/$filename.baseline.png" 2> inkscape.stderr 59 | inkscape "$2/$filename" --export-dpi 300 --export-png "$3/$filename.png" 2> inkscape.stderr 60 | ;; 61 | esac 62 | 63 | # Do exact pixel comparison using ImageMagick 64 | compare -verbose -metric AE "$3/$filename.baseline.png" "$3/$filename.png" "$3/$filename.diff.png" 2> "$3/$filename.txt" 65 | 66 | # Check result and log 67 | if grep -q "all: [^0]" "$3/$filename.txt"; then 68 | # If it doesn't start by 0, they are different 69 | cat "$3/$filename.txt" 70 | else 71 | # If it starts by 0, they are the same 72 | rm "$3/$filename.txt" 73 | rm "$3/$filename.diff.png" 74 | rm "$3/$filename.baseline.png" 75 | rm "$3/$filename.png" 76 | fi 77 | done 78 | 79 | CODE=$(ls "$3"/*.txt | wc -l) 80 | 81 | echo "$CODE different images" 82 | exit "$CODE" 83 | -------------------------------------------------------------------------------- /src/component/plotly-dash-preview/parse.js: -------------------------------------------------------------------------------- 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} req: HTTP request 11 | * @param {object} opts : component options 12 | * @param {function} sendToRenderer 13 | * - errorCode 14 | * - result 15 | */ 16 | function parse (body, req, opts, sendToRenderer) { 17 | const result = {} 18 | 19 | const errorOut = (code, msg) => { 20 | result.msg = msg 21 | sendToRenderer(code, result) 22 | } 23 | 24 | if (isUrl(body.url)) { 25 | result.url = body.url 26 | } else { 27 | return errorOut(400, 'invalid url') 28 | } 29 | 30 | result.pdfOptions = body.pdf_options || {} 31 | if (!isNonEmptyString(body.selector) && !isPositiveNumeric(body.timeout)) { 32 | return errorOut(400, 'either selector or timeout must be specified') 33 | } 34 | 35 | result.selector = body.selector 36 | result.timeOut = body.timeout 37 | result.tries = Number(result.timeOut * 1000 / cst.minInterval) 38 | 39 | var pageSize 40 | if (result.pdfOptions.pageSize) { 41 | pageSize = result.pdfOptions.pageSize 42 | } else if (body.pageSize) { 43 | pageSize = body.pageSize 44 | } 45 | 46 | if (cst.sizeMapping[pageSize]) { 47 | result.browserSize = cst.sizeMapping[pageSize] 48 | result.pdfOptions.pageSize = pageSize 49 | } else if (pageSize && isPositiveNumeric(pageSize.width) && 50 | isPositiveNumeric(pageSize.height)) { 51 | result.browserSize = { 52 | width: pageSize.width * cst.pixelsInMicron, 53 | height: pageSize.height * cst.pixelsInMicron 54 | } 55 | result.pdfOptions.pageSize = { 56 | width: Math.ceil(pageSize.width), 57 | height: Math.ceil(pageSize.height) 58 | } 59 | } else { 60 | return errorOut( 61 | 400, 62 | 'pageSize must either be A3, A4, A5, Legal, Letter, ' + 63 | 'Tabloid or an Object containing height and width ' + 64 | 'in microns.' 65 | ) 66 | } 67 | 68 | // Change browser size orientation if landscape 69 | if (result.pdfOptions.landscape) { 70 | result.browserSize = { width: result.browserSize.height, height: result.browserSize.width } 71 | } 72 | 73 | // BrowserWindow only accepts integer values: 74 | result.browserSize['width'] = Math.ceil(result.browserSize['width']) 75 | result.browserSize['height'] = Math.ceil(result.browserSize['height']) 76 | 77 | sendToRenderer(null, result) 78 | } 79 | 80 | module.exports = parse 81 | -------------------------------------------------------------------------------- /test/unit/create-index_test.js: -------------------------------------------------------------------------------- 1 | const tap = require('tap') 2 | const sinon = require('sinon') 3 | const fs = require('fs') 4 | 5 | const createIndex = require('../../src/util/create-index') 6 | const coerceComponent = require('../../src/util/coerce-component') 7 | 8 | tap.test('should create index object', t => { 9 | createIndex(coerceComponent('plotly-graph'), {}, (index) => { 10 | t.type(index.path, 'string') 11 | t.type(index.destroy, 'function') 12 | 13 | t.ok(fs.existsSync(index.path), 'index file should exist') 14 | index.destroy() 15 | t.notOk(fs.existsSync(index.path), 'index file should not exist') 16 | t.end() 17 | }) 18 | }) 19 | 20 | tap.test('should inject correct head content', t => { 21 | const fn = (t, inject, line) => { 22 | const comp = coerceComponent('plotly-graph') 23 | comp._module.inject = inject 24 | 25 | createIndex(comp, {}, (index) => { 26 | const lines = fs.readFileSync(index.path).toString().split('\n') 27 | t.equal(lines[5], line) 28 | index.destroy() 29 | t.end() 30 | }) 31 | } 32 | 33 | t.test('(blank case)', t => { 34 | fn(t, () => {}, ' ') 35 | }) 36 | 37 | t.test('(string case)', t => { 38 | fn(t, () => 'a', ' a') 39 | }) 40 | 41 | t.test('(array case)', t => { 42 | fn(t, () => ['a', 'b'], ' a') 43 | }) 44 | 45 | t.end() 46 | }) 47 | 48 | tap.test('should log path to created index in debug mode', t => { 49 | const fn = (opts, cb) => { 50 | createIndex(coerceComponent('plotly-graph'), opts, cb) 51 | } 52 | 53 | t.beforeEach((done) => { 54 | sinon.stub(console, 'log') 55 | done() 56 | }) 57 | 58 | t.afterEach((done) => { 59 | console.log.restore() 60 | done() 61 | }) 62 | 63 | t.test('(debug mode)', t => { 64 | fn({ debug: true }, (index) => { 65 | t.ok(console.log.calledOnce) 66 | t.match(console.log.args[0], /^created index/) 67 | t.end() 68 | }) 69 | }) 70 | 71 | t.test('(converse)', t => { 72 | fn({}, (index) => { 73 | t.ok(console.log.notCalled) 74 | t.end() 75 | }) 76 | }) 77 | 78 | t.end() 79 | }) 80 | 81 | tap.test('should throw if writeFile fails', t => { 82 | const fn = () => { 83 | createIndex(coerceComponent('plotly-graph')) 84 | } 85 | 86 | t.beforeEach((done) => { 87 | sinon.stub(fs, 'writeFile').yields(true) 88 | done() 89 | }) 90 | 91 | t.afterEach((done) => { 92 | fs.writeFile.restore() 93 | done() 94 | }) 95 | 96 | t.test('does throw', t => { 97 | t.throws(fn, null) 98 | t.end() 99 | }) 100 | 101 | t.end() 102 | }) 103 | -------------------------------------------------------------------------------- /test/unit/plotly-dashboard_test.js: -------------------------------------------------------------------------------- 1 | const tap = require('tap') 2 | const sinon = require('sinon') 3 | 4 | const _module = require('../../src/component/plotly-dashboard') 5 | const cst = require('../../src/component/plotly-dashboard/constants') 6 | const remote = require('../../src/util/remote') 7 | const { createMockWindow, stubProp } = require('../common') 8 | 9 | tap.test('parse:', t => { 10 | const fn = _module.parse 11 | 12 | t.test('should error when no *url* field is given', t => { 13 | const shouldFail = ['', null, true, 1, {}] 14 | 15 | shouldFail.forEach(d => { 16 | t.test(`(case ${JSON.stringify(d)})`, t => { 17 | fn({ url: d }, {}, {}, (errorCode, result) => { 18 | t.equal(errorCode, 400, 'code') 19 | t.end() 20 | }) 21 | }) 22 | }) 23 | 24 | t.end() 25 | }) 26 | 27 | t.end() 28 | }) 29 | 30 | tap.test('render:', t => { 31 | const fn = _module.render 32 | const restoreIframeLoadDelay = stubProp(cst, 'iframeLoadDelay', 0) 33 | 34 | t.afterEach((done) => { 35 | remote.createBrowserWindow.restore() 36 | done() 37 | }) 38 | 39 | t.tearDown(() => { 40 | restoreIframeLoadDelay() 41 | }) 42 | 43 | t.test('should call printToPDF', t => { 44 | const win = createMockWindow() 45 | sinon.stub(remote, 'createBrowserWindow').returns(win) 46 | win.webContents.printToPDF.yields(null, '-> image data <-') 47 | 48 | fn({ 49 | width: 500, 50 | height: 500, 51 | url: 'dummy' 52 | }, {}, (errorCode, result) => { 53 | t.ok(win.webContents.printToPDF.calledOnce) 54 | t.ok(win.close.calledOnce) 55 | t.equal(errorCode, null, 'code') 56 | t.equal(result.imgData, '-> image data <-', 'result') 57 | t.end() 58 | }) 59 | }) 60 | 61 | t.test('should handle printToPDF errors', t => { 62 | const win = createMockWindow() 63 | sinon.stub(remote, 'createBrowserWindow').returns(win) 64 | win.webContents.printToPDF.yields(new Error('printToPDF error')) 65 | 66 | fn({ 67 | width: 500, 68 | height: 500, 69 | url: 'dummy' 70 | }, {}, (errorCode, result) => { 71 | t.ok(win.webContents.printToPDF.calledOnce) 72 | t.ok(win.close.calledOnce) 73 | t.equal(errorCode, 525, 'code') 74 | t.equal(result.msg, 'print to PDF error', 'error msg') 75 | t.end() 76 | }) 77 | }) 78 | 79 | t.test('should cleanup window ref if window is manually closed', t => { 80 | const win = createMockWindow() 81 | sinon.stub(remote, 'createBrowserWindow').returns(win) 82 | 83 | fn({ 84 | width: 500, 85 | height: 500, 86 | url: 'dummy' 87 | }) 88 | 89 | t.equal(win.listenerCount('closed'), 1) 90 | win.on('closed', t.end) 91 | win.emit('closed') 92 | }) 93 | 94 | t.end() 95 | }) 96 | -------------------------------------------------------------------------------- /test/unit/pdftops_test.js: -------------------------------------------------------------------------------- 1 | const tap = require('tap') 2 | const sinon = require('sinon') 3 | const path = require('path') 4 | const fs = require('fs') 5 | const readChunk = require('read-chunk') 6 | const fileType = require('file-type') 7 | const childProcess = require('child_process') 8 | const Pdftops = require('../../src/util/pdftops') 9 | 10 | const { paths, mocks } = require('../common') 11 | 12 | tap.test('pdftops.pdf2eps', t => { 13 | t.test('should convert pdf to eps', t => { 14 | const pdftops = new Pdftops() 15 | const outPath = path.join(paths.build, 'pdftops-test.eps') 16 | 17 | pdftops.pdf2eps(mocks.pdf, {}, (err, result) => { 18 | if (err) t.fail(err) 19 | t.type(result, Buffer) 20 | 21 | fs.writeFile(outPath, result, (err) => { 22 | if (err) t.fail(err) 23 | 24 | const size = fs.statSync(outPath).size 25 | t.ok(size > 4e4, 'min pdf file size') 26 | t.ok(size < 4e5, 'max pdf file size') 27 | t.ok(fileType(readChunk.sync(outPath, 0, 4100)).mime === 'application/postscript', 'postscript content') 28 | t.end() 29 | }) 30 | }) 31 | }) 32 | 33 | t.test('should remove tmp files after conversion', t => { 34 | const pdftops = new Pdftops() 35 | const tmpOutPath = path.join(paths.build, 'tmp-eps') 36 | const tmpSvgPath = path.join(paths.build, 'tmp-pdf') 37 | 38 | pdftops.pdf2eps(mocks.pdf, { id: 'tmp' }, (err, result) => { 39 | if (err) t.fail(err) 40 | 41 | t.type(result, Buffer) 42 | t.notOk(fs.existsSync(tmpOutPath), 'clears tmp eps file') 43 | t.notOk(fs.existsSync(tmpSvgPath), 'clears tmp pdf file') 44 | t.end() 45 | }) 46 | }) 47 | 48 | t.test('should error out when pdftops command fails', t => { 49 | const pdftops = new Pdftops('not gonna work') 50 | 51 | const tmpOutPath = path.join(paths.build, 'tmp-eps') 52 | const tmpSvgPath = path.join(paths.build, 'tmp-pdf') 53 | 54 | pdftops.pdf2eps(mocks.pdf, { id: 'tmp' }, (err) => { 55 | t.throws(() => { throw err }, /Command failed/) 56 | t.notOk(fs.existsSync(tmpOutPath), 'clears tmp eps file') 57 | t.notOk(fs.existsSync(tmpSvgPath), 'clears tmp pdf file') 58 | t.end() 59 | }) 60 | }) 61 | 62 | t.end() 63 | }) 64 | 65 | tap.test('isPdftopsInstalled', t => { 66 | t.afterEach((done) => { 67 | childProcess.execSync.restore() 68 | done() 69 | }) 70 | 71 | t.test('should return true when binary execute correctly', t => { 72 | sinon.stub(childProcess, 'execSync').returns(true) 73 | t.ok(Pdftops.isPdftopsInstalled()) 74 | t.end() 75 | }) 76 | 77 | t.test('should return false when binary does not execute correctly', t => { 78 | sinon.stub(childProcess, 'execSync').throws() 79 | t.notOk(Pdftops.isPdftopsInstalled()) 80 | t.end() 81 | }) 82 | 83 | t.end() 84 | }) 85 | -------------------------------------------------------------------------------- /src/component/plotly-dashboard-preview/parse.js: -------------------------------------------------------------------------------- 1 | const isPlainObj = require('is-plain-obj') 2 | const isNonEmptyString = require('../../util/is-non-empty-string') 3 | 4 | /** 5 | * @param {object} body : JSON-parsed request body 6 | * - layout: 7 | * - type 8 | * - direction 9 | * - first, second: 10 | * - boxType 11 | * - figure 12 | * - settings: 13 | * - backgroundColor 14 | * @param {object} req: HTTP request 15 | * @param {object} opts : component options 16 | * @param {function} sendToRenderer 17 | * - errorCode 18 | * - result 19 | */ 20 | function parse (body, req, opts, sendToRenderer) { 21 | const result = {} 22 | 23 | const errorOut = code => { 24 | result.msg = 'invalid body' 25 | sendToRenderer(code, result) 26 | } 27 | 28 | result.fid = isNonEmptyString(body.fid) ? body.fid : null 29 | 30 | const dashboardLayout = body.figure.layout 31 | 32 | const parseFromType = cont => { 33 | switch (cont.type) { 34 | case 'split': 35 | return { 36 | type: 'split', 37 | direction: cont.direction || 'horizontal', 38 | size: cont.size || 50, 39 | sizeUnit: cont.sizeUnit || '%', 40 | panels: [cont.first, cont.second].filter(d => d).map(parseFromType).filter(d => d) 41 | } 42 | case 'box': 43 | return parseFromBoxType(cont) 44 | } 45 | } 46 | 47 | const parseFromBoxType = cont => { 48 | switch (cont.boxType) { 49 | case 'plot': 50 | return { 51 | type: 'box', 52 | contents: { 53 | data: (cont.figure && cont.figure.data) || [], 54 | layout: (cont.figure && cont.figure.layout) || {} 55 | } 56 | } 57 | 58 | case 'text': 59 | return { 60 | type: 'box', 61 | contents: { 62 | data: [], 63 | layout: {}, 64 | annotations: [{ text: cont.text ? cont.text.substr(50) : '' }] 65 | } 66 | } 67 | 68 | default: 69 | return { 70 | type: 'box', 71 | contents: { 72 | data: [], 73 | layout: {} 74 | } 75 | } 76 | } 77 | } 78 | 79 | if (isPlainObj(dashboardLayout)) { 80 | result.panels = parseFromType(dashboardLayout) 81 | } else { 82 | return errorOut(400) 83 | } 84 | 85 | const settings = body.settings 86 | 87 | if (isPlainObj(settings) && isNonEmptyString(settings.backgroundColor)) { 88 | result.backgroundColor = settings.backgroundColor 89 | } else { 90 | result.backgroundColor = '#fff' 91 | } 92 | 93 | result.width = body.width || 1280 94 | result.height = body.height || 800 95 | 96 | sendToRenderer(null, result) 97 | } 98 | 99 | module.exports = parse 100 | -------------------------------------------------------------------------------- /bin/args.js: -------------------------------------------------------------------------------- 1 | const minimist = require('minimist') 2 | 3 | exports.PLOTLYJS_OPTS_META = [{ 4 | name: 'plotly', 5 | type: 'string', 6 | alias: ['plotlyjs', 'plotly-js', 'plotly_js', 'plotlyJS', 'plotlyJs'], 7 | dflt: '', 8 | description: `Sets the path to the plotly.js bundle to use. 9 | This option can be also set to 'latest' or any valid plotly.js server release (e.g. 'v1.2.3'), 10 | where the corresponding plot.ly CDN bundle is used. 11 | By default, the 'latest' CDN bundle is used.` 12 | }, { 13 | name: 'mapbox-access-token', 14 | type: 'string', 15 | alias: ['mapboxAccessToken'], 16 | dflt: process.env.MAPBOX_ACCESS_TOKEN || '', 17 | description: `Sets mapbox access token. Required to export mapbox graphs. 18 | Alternatively, one can set a \`MAPBOX_ACCESS_TOKEN\` environment variable.` 19 | }, { 20 | name: 'topojson', 21 | type: 'string', 22 | dflt: '', 23 | description: `Sets path to topojson files. 24 | By default, topojson files on the plot.ly CDN are used.` 25 | }, { 26 | name: 'mathjax', 27 | type: 'string', 28 | alias: ['MathJax'], 29 | dflt: '', 30 | description: `Sets path to MathJax files. Required to export LaTeX characters.` 31 | }, { 32 | name: 'inkscape', 33 | type: 'string', 34 | alias: ['Inkscape'], 35 | dflt: '', 36 | description: `Sets path to Inkscape executable. Required to export WMF and EMF formats.` 37 | }, { 38 | name: 'safe-mode', 39 | type: 'boolean', 40 | alias: ['safeMode', 'safe'], 41 | description: 'Turns on safe mode: where figures likely to make browser window hang during image generating are skipped.' 42 | }] 43 | 44 | exports.sliceArgs = function (args) { 45 | // https://electronjs.org/docs/api/process#processdefaultapp 46 | // https://github.com/electron/electron/issues/4690#issuecomment-217435222 47 | // https://github.com/tj/commander.js/issues/512 48 | const sliceBegin = process.defaultApp ? 2 : 1 49 | return args.slice(sliceBegin) 50 | } 51 | 52 | exports.extractOpts = function (args, meta) { 53 | const minimistOpts = { 54 | 'boolean': meta.filter(o => o.type === 'boolean').map(o => o.name), 55 | 'string': meta.filter(o => o.type === 'string').map(o => o.name) 56 | } 57 | 58 | minimistOpts.alias = {} 59 | meta.filter(o => o.alias).forEach(o => { minimistOpts.alias[o.name] = o.alias }) 60 | 61 | minimistOpts['default'] = {} 62 | meta.filter(o => o.dflt).forEach(o => { minimistOpts['default'][o.name] = o.dflt }) 63 | 64 | return minimist(args, minimistOpts) 65 | } 66 | 67 | exports.formatOptsMeta = function (meta) { 68 | const formatAlias = (o) => { 69 | if (!o.alias) return '' 70 | 71 | const list = o.alias 72 | .map(a => a.length === 1 ? `-${a}` : `--${a}`) 73 | .join(', ') 74 | 75 | return `[or ${list}]` 76 | } 77 | 78 | return meta 79 | .map(o => ` --${o.name} ${formatAlias(o)}\n ${o.description}`) 80 | .join('\n') 81 | } 82 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "orca", 3 | "version": "1.3.1", 4 | "description": "Plotly's image-exporting utilities", 5 | "license": "MIT", 6 | "main": "./bin/orca_electron.js", 7 | "bin": { 8 | "orca": "./bin/orca.js" 9 | }, 10 | "scripts": { 11 | "pretest": "node test/pretest.js", 12 | "test:lint": "standard | snazzy", 13 | "test:unit": "tap test/unit/*_test.js", 14 | "test:integration": "tap test/integration/*_test.js", 15 | "test": "npm run test:lint && npm run test:unit && npm run test:integration", 16 | "coverage": "npm run test:unit -- --cov", 17 | "lint": "standard --fix", 18 | "pack": "cross-env NODE_ENV=production electron-builder --publish=never", 19 | "postshrinkwrap": "chttps ." 20 | }, 21 | "build": { 22 | "appId": "com.plotly.orca", 23 | "productName": "orca", 24 | "files": [ 25 | "bin", 26 | "src" 27 | ], 28 | "asar": false, 29 | "linux": { 30 | "category": "Utility", 31 | "executableName": "orca", 32 | "maintainer": "chris@plot.ly", 33 | "target": [ 34 | "appimage" 35 | ] 36 | }, 37 | "win": { 38 | "target": [ 39 | "nsis" 40 | ] 41 | }, 42 | "mac": { 43 | "category": "public.app-category.tools", 44 | "extendInfo": { 45 | "LSUIElement": 1 46 | }, 47 | "target": [ 48 | "dmg" 49 | ] 50 | }, 51 | "directories": { 52 | "output": "release" 53 | } 54 | }, 55 | "author": "Plotly, Inc.", 56 | "keywords": [ 57 | "graphing", 58 | "plotting", 59 | "visualization", 60 | "plotly" 61 | ], 62 | "dependencies": { 63 | "body": "^5.1.0", 64 | "fast-isnumeric": "^1.1.3", 65 | "file-type": "^10.11.0", 66 | "get-stdin": "^5.0.1", 67 | "glob": "^7.1.6", 68 | "is-plain-obj": "^1.1.0", 69 | "is-url": "^1.2.4", 70 | "jsdom": "11.12.0", 71 | "minimist": "^1.2.0", 72 | "pngjs": "^3.4.0", 73 | "read-chunk": "^3.2.0", 74 | "request": "^2.88.0", 75 | "run-parallel": "^1.1.9", 76 | "run-parallel-limit": "^1.0.5", 77 | "run-series": "^1.1.8", 78 | "semver": "^5.7.1", 79 | "string-to-stream": "^1.1.1", 80 | "tinycolor2": "^1.4.1", 81 | "uuid": "^3.3.3" 82 | }, 83 | "devDependencies": { 84 | "axios": "^0.21.1", 85 | "chttps": "^1.0.6", 86 | "cross-env": "^5.2.1", 87 | "delay": "^4.3.0", 88 | "devtron": "^1.4.0", 89 | "electron": "^6.1.7", 90 | "electron-builder": "^21.2.0", 91 | "electron-debug": "^3.0.0", 92 | "image-size": "^0.6.3", 93 | "sinon": "^7.5.0", 94 | "snazzy": "^8.0.0", 95 | "spectron": "^8.0.0", 96 | "standard": "^12.0.1", 97 | "tap": "^12.7.0" 98 | }, 99 | "engines": { 100 | "node": ">=6.0.0" 101 | }, 102 | "nyc": { 103 | "exclude": [ 104 | "build", 105 | "test" 106 | ] 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/component/plotly-dash-preview/render.js: -------------------------------------------------------------------------------- 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 createBrowserWindowOpts = info.browserSize ? info.browserSize : {} 18 | createBrowserWindowOpts['enableLargerThanScreen'] = true 19 | createBrowserWindowOpts['useContentSize'] = true 20 | createBrowserWindowOpts['show'] = opts.debug 21 | 22 | let win = remote.createBrowserWindow(createBrowserWindowOpts) 23 | const contents = win.webContents 24 | const session = contents.session 25 | 26 | // Clear cookies before loading URL 27 | session.clearStorageData({}, () => { 28 | win.loadURL(info.url) 29 | }) 30 | 31 | const done = errorCode => { 32 | win.close() 33 | 34 | if (errorCode) { 35 | result.msg = cst.statusMsg[errorCode] 36 | } 37 | sendToMain(errorCode, result) 38 | } 39 | 40 | /* 41 | * We check for a 'waitfor' div in the dash-app 42 | * which indicates that the app has finished rendering. 43 | */ 44 | const loaded = () => { 45 | return win.webContents.executeJavaScript(` 46 | new Promise((resolve, reject) => { 47 | let tries = ${info.tries} || ${cst.maxRenderingTries} 48 | 49 | let interval = setInterval(() => { 50 | let el = document.querySelector('${info.selector}') 51 | 52 | if (el) { 53 | clearInterval(interval) 54 | resolve(true) 55 | } 56 | 57 | if (--tries === 0) { 58 | clearInterval(interval) 59 | 60 | if (${info.timeOut}) { 61 | resolve(true) 62 | } else { 63 | reject('fail to load') 64 | } 65 | } 66 | }, ${cst.minInterval}) 67 | 68 | })`) 69 | } 70 | 71 | win.on('closed', () => { 72 | win = null 73 | }) 74 | 75 | loaded().then(() => { 76 | // Move mouse outside the page to prevent hovering on figures 77 | contents.sendInputEvent({ type: 'mouseMove', x: -1, y: -1 }) 78 | 79 | // Close window if timeout is exceeded 80 | // This is necessary because `printToPDF` sometimes never end 81 | // https://github.com/electron/electron/issues/20634 82 | var timer = setTimeout(() => done(527), (info.timeOut || cst.maxPrintPDFTime) * 1000) 83 | 84 | contents.printToPDF(info.pdfOptions, (err, pdfData) => { 85 | if (err) { 86 | clearTimeout(timer) 87 | done(525) 88 | } else { 89 | clearTimeout(timer) 90 | result.imgData = pdfData 91 | done() 92 | } 93 | }) 94 | }).catch(() => { 95 | done(526) 96 | }) 97 | } 98 | 99 | module.exports = render 100 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Orca changelog 2 | 3 | For more context information, please read through the 4 | [release notes](https://github.com/plotly/orca/releases). 5 | 6 | To see all merged commits on the master branch that will be part of the next Orca release, go to: 7 | 8 | https://github.com/plotly/orca/compare/vX.Y.Z...master 9 | 10 | where X.Y.Z is the semver of most recent Orca release. 11 | 12 | ## [1.3.1] 2020-03-26 13 | 14 | ## Fixed 15 | 16 | - Fix sandbox problem in the conda package for Linux [#301] 17 | 18 | ## [1.3.0] 2020-03-12 19 | 20 | ## Added 21 | 22 | - Implement HTTP content-negotiation for plotly-graph [#228] 23 | 24 | ### Changed 25 | 26 | - Bump electron from `1.8.4` to `6.1.4` 27 | 28 | ## Fixed 29 | 30 | - Fix colorbar in EMF exports [#220] 31 | - Fix `pageSize` attribute for Dash app preview [#245, #250, #254] 32 | - Fix EPS export to preserve graphics in vector format [#266] 33 | - Segmentation fault when NODE_OPTIONS environment variable set [#266] 34 | 35 | 36 | ## [1.2.1] 2019-02-04 37 | 38 | ### Fixed 39 | 40 | - Fix `scattermapbox` image generation when mapbox access token is set 41 | in the `layout.mapbox` container in plotly-graph component [#195] 42 | 43 | 44 | ## [1.2.0] 2019-01-24 45 | 46 | ### Added 47 | 48 | - Add `--cors` CLI option for `orca serve` to enable Cross-Origin Resource Sharing (CORS) [#135] 49 | 50 | - Add support for EMF exports for `plotly-graph` [#152] 51 | 52 | ### Changed 53 | 54 | - Update dependencies `fast-isnumeric`, `file-type`, `glob`, `read-chunk` and `semver`[#177] 55 | 56 | ### Fixed 57 | 58 | - Fix --output `orca graph` CLI option for path/to/filename [#176] 59 | 60 | - Pass command line options to `plotly-dash-preview` [#191] 61 | 62 | 63 | ## [1.1.1] 2018-08-30 64 | 65 | This release is associated with improved standalone installation instructions 66 | in the repo README from #122. 67 | 68 | ### Fixed 69 | - Mac OS installer fixups [#122] 70 | + Don't make any changes if `orca` is already on the `PATH` 71 | + Only copy `orca.sh` to `/usr/local/bin` if orca is installed as an application 72 | + Perform `orca.sh` copy with administrator privileges to avoid permission denied errors 73 | 74 | ## [1.1.0] 2018-08-14 75 | 76 | Orca is now a `conda` package [#113]: 77 | 78 | ``` 79 | conda install -c plotly plotly-orca 80 | ``` 81 | 82 | ### Added 83 | - Add `--graph-only` CLI option for `orca serve` to only boot the `plotly-graph` 84 | component, saving memory [#114] 85 | - Add `table` plotly graph traces to `--safeMode` handler [#98] 86 | 87 | ### Changed 88 | - Use `request@2.88.0` 89 | 90 | ### Fixed 91 | - Hide electron icon from OS X dock [#103] 92 | 93 | 94 | ## [1.0.0] 2018-05-17 95 | 96 | First Orca release :tada: 97 | 98 | See installation instructions 99 | [here](https://github.com/plotly/orca#installation). This release ships with 100 | Orca CLI command `graph` (for plotly.js graph exports) and `serve` (which boots 101 | up a server similar to Plotly's Image Server). Run `orca graph --help` and `orce 102 | serve --help` for more info. 103 | -------------------------------------------------------------------------------- /src/component/plotly-thumbnail/parse.js: -------------------------------------------------------------------------------- 1 | const plotlyGraphParse = require('../plotly-graph/parse') 2 | const isPlainObj = require('is-plain-obj') 3 | 4 | const counter = '([2-9]|[1-9][0-9]+)?$' 5 | const axNameRegex = new RegExp('^[xy]axis' + counter) 6 | const axIdRegex = new RegExp('^[xy]' + counter) 7 | const sceneRegex = new RegExp('^scene' + counter) 8 | 9 | /** 10 | * @param {object} body : JSON-parsed request body 11 | * @param {object} req: HTTP request 12 | * @param {object} opts : component options 13 | * @param {function} sendToRenderer 14 | * - errorCode 15 | * - result 16 | */ 17 | function parse (body, req, opts, sendToRenderer) { 18 | plotlyGraphParse(body, req, opts, (errorCode, result) => { 19 | result.format = 'png' 20 | overrideFigure(result.figure) 21 | sendToRenderer(errorCode, result) 22 | }) 23 | } 24 | 25 | function overrideFigure (figure) { 26 | const data = figure.data 27 | const layout = figure.layout 28 | 29 | // remove title, margins and legend 30 | layout.title = '' 31 | layout.margin = { t: 0, b: 0, l: 0, r: 0 } 32 | layout.showlegend = false 33 | 34 | // remove all annotations 35 | delete layout.annotations 36 | 37 | // remove color bars and pie labels 38 | data.forEach(trace => { 39 | trace.showscale = false 40 | if (isPlainObj(trace.marker)) trace.marker.showscale = false 41 | if (trace.type === 'pie') trace.textposition = 'none' 42 | }) 43 | 44 | // remove title in base 2d axes 45 | overrideAxis(layout, 'xaxis') 46 | overrideAxis(layout, 'yaxis') 47 | 48 | // remove title in base 3d axes 49 | overrideScene(layout, 'scene') 50 | 51 | // look for other axes in layout 52 | for (var k in layout) { 53 | if (axNameRegex.test(k)) { 54 | overrideAxis(layout, k) 55 | } 56 | if (sceneRegex.test(k)) { 57 | overrideScene(layout, k) 58 | } 59 | } 60 | 61 | // look for traces linked to other 2d/3d axes 62 | data.forEach(trace => { 63 | if (axIdRegex.test(trace.xaxis)) { 64 | overrideAxis(layout, id2name(trace.xaxis)) 65 | } 66 | if (axIdRegex.test(trace.yaxis)) { 67 | overrideAxis(layout, id2name(trace.yaxis)) 68 | } 69 | if (sceneRegex.test(trace.scene)) { 70 | overrideScene(layout, trace.scene) 71 | } 72 | }) 73 | } 74 | 75 | function overrideAxis (container, axKey) { 76 | if (!isPlainObj(container[axKey])) { 77 | container[axKey] = {} 78 | } 79 | 80 | container[axKey].title = '' 81 | } 82 | 83 | function overrideScene (container, sceneKey) { 84 | if (!isPlainObj(container[sceneKey])) { 85 | container[sceneKey] = {} 86 | } 87 | 88 | var scene = container[sceneKey] 89 | var axKeys = ['xaxis', 'yaxis', 'zaxis'] 90 | 91 | axKeys.forEach(k => { 92 | if (!isPlainObj(scene[k])) { 93 | scene[k] = {} 94 | } 95 | 96 | scene[k].title = '' 97 | scene[k].showaxeslabels = false 98 | scene[k].showticklabels = false 99 | }) 100 | } 101 | 102 | function id2name (id) { 103 | return id.charAt(0) + 'axis' + id.substr(1) 104 | } 105 | 106 | module.exports = { 107 | parse: parse, 108 | overrideFigure: overrideFigure 109 | } 110 | -------------------------------------------------------------------------------- /test/image/mocks/mathjax.json: -------------------------------------------------------------------------------- 1 | { 2 | "data":[{ 3 | "uid": "data0", 4 | "x": [0, 1], 5 | "y": [0, 1.414], 6 | "text": ["Hx+yH", "H\\sqrt{2}H"], 7 | "mode": "text+markers", 8 | "name": "$E^2=m^2c^4+p^2c^2$" 9 | }, { 10 | "uid": "data1", 11 | "x": [0, 1], 12 | "y": [1.4, 0.1], 13 | "text": ["H1.400 \\pm 0.023H", "H0.100 \\pm 0.002H"], 14 | "textposition": "auto", 15 | "type": "bar", 16 | "name": "$x=\\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}$" 17 | }, { 18 | "uid": "data2", 19 | "type": "pie", 20 | "values": [1, 9], 21 | "labels": ["$\\frac{1}{10}=10\\%$", "$\\frac{9}{10}=90\\%$"], 22 | "textinfo": "label", 23 | "domain": {"x": [0.3, 0.75], "y": [0.55, 1]} 24 | }, { 25 | "uid": "data3", 26 | "type": "heatmap", 27 | "z": [[1,2],[3,4]], 28 | "xaxis": "x2", 29 | "yaxis": "y2", 30 | "colorbar": {"title": "He^{i\\pi}=-1H", "y": 0.225, "len": 0.45} 31 | }], 32 | "layout": { 33 | "yaxis":{"domain": [0, 0.45], "title": "$y=\\sin{2 \\theta}$"}, 34 | "xaxis":{ 35 | "domain": [0, 0.45], 36 | "title": "$x=\\int_0^a a^2+1$", 37 | "tickvals": [0, 1], 38 | "ticktext": ["$\\frac{0}{100}$", "$\\frac{100}{100}$"] 39 | }, 40 | "xaxis2": {"domain": [0.85, 1], "anchor": "y2"}, 41 | "yaxis2": { 42 | "domain": [0, 0.45], 43 | "anchor": "x2", 44 | "tickvals": [0, 1], 45 | "ticktext": ["Ha+b+c+dH", "He+f+g+hH"], 46 | "title": "$(||01\\rangle+|10\\rangle)/\\sqrt2$" 47 | }, 48 | "height":500, 49 | "width":800, 50 | "margin": {"r": 250}, 51 | "title": "$i\\hbar\\frac{d\\Psi}{dt}=-[V-\\frac{-\\hbar^2}{2m}\\nabla^2]\\Psi$", 52 | "annotations":[ 53 | { 54 | "text": "H is substituted for $
where we would like
math but do not yet
fully support it", 55 | "xref": "paper", "yref": "paper", 56 | "x": 1.2, "xanchor": "left", "y": 0, "yanchor": "bottom", 57 | "showarrow": false 58 | }, 59 | { 60 | "text":"$(top,left)$","showarrow":false,"xref":"paper","yref":"paper", 61 | "xanchor":"left","yanchor":"top","x":0,"y":1,"textangle":10, 62 | "bordercolor":"#0c0","borderpad":3,"bgcolor":"#dfd" 63 | }, 64 | { 65 | "text":"$(right,bottom)$","xref":"paper","yref":"paper", 66 | "xanchor":"right","yanchor":"bottom","x":0.2,"y":0.7,"ax":-20,"ay":-20, 67 | "textangle":-30,"bordercolor":"#0c0","borderpad":3,"bgcolor":"#dfd", 68 | "opacity":0.5 69 | }, 70 | {"text":"$not-visible$", "visible": false}, 71 | { 72 | "text":"$^{29}Si$","x":0.7,"y":0.7,"showarrow":false, 73 | "xanchor":"right","yanchor":"top" 74 | }, 75 | { 76 | "text":"$^{17}O$","x":0.7,"y":0.7,"ax":15,"ay":-15, 77 | "xanchor":"left","yanchor":"bottom" 78 | } 79 | ] 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/component/plotly-dashboard-thumbnail/render.js: -------------------------------------------------------------------------------- 1 | /* global Plotly:false */ 2 | 3 | const remote = require('../../util/remote') 4 | 5 | const pad = 5 6 | const imgWidth = 200 7 | const imgHeight = 200 8 | const winWidth = 2 * (imgWidth + 2 * pad) 9 | const winHeight = 2 * (imgHeight + 2 * pad) 10 | 11 | /** 12 | * @param {object} info : info object 13 | * - layoutType TODO 14 | * - direction TODO 15 | * - backgroundColor 16 | * - panels 17 | * @param {object} opts : component options 18 | * @param {function} sendToMain 19 | * - errorCode 20 | * - result 21 | * - imgData 22 | */ 23 | function render (info, opts, sendToMain) { 24 | let win = remote.createBrowserWindow({ 25 | width: winWidth, 26 | height: winHeight, 27 | show: !!opts.debug 28 | }) 29 | 30 | const config = { 31 | mapboxAccessToken: opts.mapboxAccessToken || '', 32 | plotGlPixelRatio: opts.plotGlPixelRatio 33 | } 34 | 35 | const html = window.encodeURIComponent(` 36 | 37 | 38 | 39 | 55 | 56 | 57 | `) 58 | 59 | win.loadURL(`data:text/html,${html}`) 60 | 61 | const result = {} 62 | let errorCode = null 63 | 64 | const done = () => { 65 | win.close() 66 | 67 | if (errorCode) { 68 | result.msg = 'dashboard thumbnail generation failed' 69 | } 70 | sendToMain(errorCode, result) 71 | } 72 | 73 | win.on('closed', () => { 74 | win = null 75 | }) 76 | 77 | const contents = win.webContents 78 | 79 | contents.once('did-finish-load', () => { 80 | const promises = info.panels.map(p => { 81 | return Plotly.toImage({ 82 | data: p.data, 83 | layout: p.layout, 84 | config: config 85 | }, { 86 | format: 'png', 87 | width: imgWidth, 88 | height: imgHeight, 89 | imageDataOnly: false 90 | }).then(imgData => { 91 | contents.executeJavaScript(` 92 | new Promise((resolve, reject) => { 93 | const img = document.createElement('img') 94 | document.body.appendChild(img) 95 | img.onload = resolve 96 | img.onerror = reject 97 | img.src = "${imgData}" 98 | setTimeout(() => reject(new Error('too long to load image')), 5000) 99 | })`) 100 | }) 101 | }) 102 | 103 | Promise.all(promises) 104 | .then(() => { 105 | setTimeout(() => { 106 | contents.capturePage(img => { 107 | result.imgData = img.toPNG() 108 | done() 109 | }) 110 | }, 100) 111 | }) 112 | .catch(() => { 113 | errorCode = 525 114 | done() 115 | }) 116 | }) 117 | } 118 | 119 | module.exports = render 120 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at accounts@plot.ly. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.4, available at [http://contributor-covenant.org/version/1/4](http://contributor-covenant.org/version/1/4/), and may also be found online at . 44 | -------------------------------------------------------------------------------- /deployment/ImageMagickPolicy.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | ]> 11 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 72 | 73 | 74 | 80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /deployment/README.md: -------------------------------------------------------------------------------- 1 | # Deployment to GKE using Docker and Kubernetes 2 | 3 | ## Provision: 4 | 5 | This is done once, manually. Replace `ENVIRONMENT` with the environment you're 6 | working with (currently `stage` and `prod` are supported). 7 | 8 | The scaling limit variables `--min-nodes`, `--max-nodes`, and `--num-nodes` 9 | must be set to the appropriate count *per zone*. 10 | 11 | `--cluster-version` may need to be updated to reflect newer versions of GKE. 12 | If you need to do this, visit the GCP web console, Kubernetes Engine section, 13 | start to create a new cluster, and check the default version under "Cluster 14 | Version". 15 | 16 | ``` 17 | gcloud beta container clusters create imageserver-ENVIRONMENT --enable-autoscaling --min-nodes=1 --max-nodes=3 --num-nodes=1 --zone=us-central1-a --additional-zones=us-central1-b,us-central1-c --enable-autoupgrade --cluster-version=1.7.8-gke.0 18 | 19 | kubectl apply -f deployment/kube/ENVIRONMENT 20 | kubectl get service imageserver # Will show the load balancer IP when it's ready 21 | ``` 22 | 23 | ## Build & push: 24 | 25 | Builds are performed automatically by CircleCI, and if all tests pass the image 26 | will be pushed to GCR. Images are tagged using the branch name and the 27 | sha1 of the git commit. 28 | 29 | ## Deploy, plotly.js upgrade, rollback 30 | 31 | This is done using plotbot. The following commands provide help: 32 | 33 | ``` 34 | @plotbot deploy how 35 | @plotbot run how 36 | ``` 37 | 38 | # Font Support 39 | 40 | The image server ships with many built-in fonts (see the Dockerfile for a list) 41 | and also supports external fonts. External fonts are intended for restrictively 42 | licensed fonts that we can not ship as part of the open source release, and 43 | may also be used by 3rd party users to install their own fonts (restrictively 44 | licensed or open source). 45 | 46 | On boot, the image server looks for fonts in `/usr/share/fonts/user` and will 47 | use any valid font found there. You may map a directory into this location in 48 | the container. 49 | 50 | In GKE, the pod requires a GCE Persistent Disk called 51 | `plotly-cloud-licensed-fonts` that contains the restrictively licensed fonts 52 | used by Plotly Cloud. To update fonts: 53 | 54 | 1. In the `PlotlyCloud` project, create a disk from the 55 | `plotly-cloud-licensed-fonts` image and attach it to a GCE VM. 56 | 57 | 2. Reboot the GCE VM, then mount /dev/sdb to a temporary directory. 58 | 59 | 3. Add/remove/update fonts in this temporary directory. 60 | 61 | 4. Unmount the temporary directory and detach the disk from the VM. 62 | 63 | 5. Delete the `plotly-cloud-licensed-fonts` image and re-create it from the disk. 64 | 65 | 6. Create new `plotly-cloud-licensed-fonts` persistent disks from the image: 66 | 67 | ``` 68 | for zone in us-central1-a us-central1-b us-central1-c ; do 69 | gcloud compute disks create plotly-cloud-licensed-fonts --image-project=sunlit-shelter-132119 --image=plotly-cloud-licensed-fonts --zone $zone 70 | done 71 | ``` 72 | 73 | # Mapbox Access Token 74 | 75 | In order to use the Mapbox functionality built in to plotly.js, a Mapbox 76 | access token must be provided. This can be part of the plot JSON, but for cases 77 | where it is not included in the plot JSON it is useful to have a default. 78 | 79 | To specify one, add it as a Kubernetes secret: 80 | 81 | ``` 82 | echo -n "pk.whatever.blabla" > /tmp/token 83 | kubectl create secret generic mapbox --from-file=default_access_token=/tmp/token 84 | ``` 85 | 86 | After adding the secret for the first time or it changes, you'll need to recreate 87 | all pods. The easiest way to do this is by running the update_plotlyjs command. 88 | -------------------------------------------------------------------------------- /test/integration/orca_graph_test.js: -------------------------------------------------------------------------------- 1 | const tap = require('tap') 2 | const fs = require('fs') 3 | const path = require('path') 4 | const { spawn } = require('child_process') 5 | const { paths } = require('../common') 6 | 7 | const BASE_ARGS = ['graph', '--verbose'] 8 | const DUMMY_DATA = '{ "data": [{"y": [1,2,1]}] }' 9 | 10 | const _spawn = (t, args) => { 11 | const allArgs = args ? BASE_ARGS.concat(args) : BASE_ARGS 12 | 13 | const subprocess = spawn(paths.bin, allArgs, { 14 | stdio: ['inherit', 'pipe', 'pipe'] 15 | }) 16 | 17 | subprocess.on('error', t.fail) 18 | 19 | return subprocess 20 | } 21 | 22 | tap.test('should print help message', t => { 23 | const shouldPass = ['--help', '-h'] 24 | 25 | shouldPass.forEach(d => { 26 | t.test(`on ${d}`, t => { 27 | const subprocess = _spawn(t, d) 28 | 29 | subprocess.stdout.on('data', d => { 30 | t.match(d.toString(), /orca graph/) 31 | t.match(d.toString(), /Usage/) 32 | t.end() 33 | }) 34 | }) 35 | }) 36 | 37 | t.end() 38 | }) 39 | 40 | tap.test('should log message when no input is given', t => { 41 | const subprocess = _spawn(t, '') 42 | 43 | subprocess.stdout.on('data', d => { 44 | t.match(d.toString(), /No input given/) 45 | t.end() 46 | }) 47 | }) 48 | 49 | tap.test('should output to fig.png when --output is not set', t => { 50 | const subprocess = _spawn(t, DUMMY_DATA) 51 | const p = path.join(process.cwd(), 'fig.png') 52 | 53 | subprocess.on('close', code => { 54 | t.same(code, 0) 55 | t.ok(fs.existsSync(p)) 56 | fs.unlinkSync(p) 57 | t.end() 58 | }) 59 | }) 60 | 61 | tap.test('should respect --output when set (just filename case)', t => { 62 | const subprocess = _spawn(t, [DUMMY_DATA, '--output=graph.png']) 63 | const p = path.join(process.cwd(), 'graph.png') 64 | 65 | subprocess.on('close', code => { 66 | t.same(code, 0) 67 | t.ok(fs.existsSync(p)) 68 | fs.unlinkSync(p) 69 | t.end() 70 | }) 71 | }) 72 | 73 | tap.test('should respect --output when set (path/to/filename case)', t => { 74 | const subprocess = _spawn(t, [DUMMY_DATA, '--output=build/tmp/graph.png']) 75 | const p = path.join(process.cwd(), 'build', 'tmp', 'graph.png') 76 | 77 | subprocess.on('close', code => { 78 | t.same(code, 0) 79 | t.ok(fs.existsSync(p)) 80 | fs.unlinkSync(p) 81 | t.end() 82 | }) 83 | }) 84 | 85 | tap.test('should respect --output-dir when set', t => { 86 | const subprocess = _spawn(t, [DUMMY_DATA, '--output-dir=build']) 87 | const p = path.join(process.cwd(), 'build', 'fig.png') 88 | 89 | subprocess.on('close', code => { 90 | t.same(code, 0) 91 | t.ok(fs.existsSync(p)) 92 | fs.unlinkSync(p) 93 | t.end() 94 | }) 95 | }) 96 | 97 | tap.test('should respect --output (filename) and --output-dir when set', t => { 98 | const subprocess = _spawn(t, [DUMMY_DATA, '--output-dir=build', '--output=graph.png']) 99 | const p = path.join(process.cwd(), 'build', 'graph.png') 100 | 101 | subprocess.on('close', code => { 102 | t.same(code, 0) 103 | t.ok(fs.existsSync(p)) 104 | fs.unlinkSync(p) 105 | t.end() 106 | }) 107 | }) 108 | 109 | tap.test('should respect --output (path/to/filename) and --output-dir when set', t => { 110 | const subprocess = _spawn(t, [DUMMY_DATA, '--output-dir=build', '--output=tmp/graph.png']) 111 | const p = path.join(process.cwd(), 'build', 'tmp', 'graph.png') 112 | 113 | subprocess.on('close', code => { 114 | t.same(code, 0) 115 | t.ok(fs.existsSync(p)) 116 | fs.unlinkSync(p) 117 | t.end() 118 | }) 119 | }) 120 | -------------------------------------------------------------------------------- /src/component/plotly-graph/convert.js: -------------------------------------------------------------------------------- 1 | const Pdftops = require('../../util/pdftops') 2 | const Inkscape = require('../../util/inkscape') 3 | const cst = require('./constants') 4 | 5 | /** plotly-graph convert 6 | * 7 | * @param {object} info : info object 8 | * - format {string} (from parse) 9 | * - encoded {string} (from parse) 10 | * - imgData {string} (from render) 11 | * @param {object} opts : component options 12 | * - pdftops {string or instance of Pdftops) 13 | * - inkscape {string or instance of Inkscape) 14 | * @param {function} reply 15 | * - errorCode {number or null} 16 | * - result {object} 17 | * - head 18 | * - body 19 | * - bodyLength 20 | */ 21 | function convert (info, opts, reply) { 22 | const imgData = info.imgData 23 | const format = info.format 24 | const encoded = info.encoded 25 | 26 | const result = {} 27 | let errorCode = null 28 | let body 29 | let bodyLength 30 | 31 | const done = () => { 32 | if (errorCode) { 33 | result.msg = cst.statusMsg[errorCode] 34 | } else { 35 | result.body = body 36 | result.bodyLength = bodyLength 37 | result.head = { 38 | 'Content-Type': cst.contentFormat[format], 39 | 'Content-Length': bodyLength 40 | } 41 | } 42 | reply(errorCode, result) 43 | } 44 | 45 | const pdf2eps = (pdf, cb) => { 46 | const pdftops = opts.pdftops instanceof Pdftops 47 | ? opts.pdftops 48 | : new Pdftops(opts.pdftops) 49 | 50 | pdftops.pdf2eps(pdf, { id: info.id }, (err, eps) => { 51 | if (err) { 52 | errorCode = 530 53 | result.error = err 54 | return done() 55 | } 56 | cb(eps) 57 | }) 58 | } 59 | 60 | const svg2emf = (svg, cb) => { 61 | const inkscape = opts.inkscape instanceof Inkscape 62 | ? opts.inkscape 63 | : new Inkscape(opts.inkscape) 64 | 65 | try { 66 | inkscape.CheckInstallation() 67 | } catch (e) { 68 | errorCode = 530 69 | result.error = e 70 | return done() 71 | } 72 | 73 | inkscape.svg2emf(svg, { id: info.id, figure: info.figure }, (err, emf) => { 74 | if (err) { 75 | errorCode = 530 76 | result.error = err 77 | return done() 78 | } 79 | cb(emf) 80 | }) 81 | } 82 | 83 | switch (format) { 84 | case 'json': 85 | body = imgData 86 | bodyLength = body.length 87 | return done() 88 | case 'png': 89 | case 'jpeg': 90 | case 'webp': 91 | body = encoded 92 | ? imgData 93 | : Buffer.from(imgData, 'base64') 94 | bodyLength = body.length 95 | return done() 96 | case 'pdf': 97 | body = encoded 98 | ? `data:${cst.contentFormat.pdf};base64,${imgData.toString('base64')}` 99 | : Buffer.from(imgData, 'base64') 100 | bodyLength = body.length 101 | return done() 102 | case 'svg': 103 | // see http://stackoverflow.com/a/12205668/800548 104 | body = imgData 105 | bodyLength = encodeURI(imgData).split(/%..|./).length - 1 106 | return done() 107 | case 'eps': 108 | pdf2eps(imgData, (eps) => { 109 | body = encoded 110 | ? `data:${cst.contentFormat.eps};base64,${eps.toString('base64')}` 111 | : Buffer.from(eps, 'base64') 112 | bodyLength = body.length 113 | return done() 114 | }) 115 | break 116 | case 'emf': 117 | svg2emf(imgData, (emf) => { 118 | body = encoded 119 | ? `data:${cst.contentFormat.emf};base64,${emf.toString('base64')}` 120 | : Buffer.from(emf, 'base64') 121 | bodyLength = body.length 122 | return done() 123 | }) 124 | break 125 | } 126 | } 127 | 128 | module.exports = convert 129 | -------------------------------------------------------------------------------- /deployment/kube/stage/frontend.yaml: -------------------------------------------------------------------------------- 1 | # WARNING: There is considerable duplication between this file and the 2 | # prod version. When updating this file, please check if your changes 3 | # need to be made to the other version. 4 | 5 | apiVersion: apps/v1 6 | kind: Deployment 7 | metadata: 8 | annotations: 9 | cluster-autoscaler.kubernetes.io/safe-to-evict: "true" 10 | name: imageserver 11 | labels: 12 | app: imageserver 13 | spec: 14 | replicas: 3 15 | strategy: 16 | rollingUpdate: 17 | maxSurge: 100% 18 | maxUnavailable: 25% 19 | type: RollingUpdate 20 | template: 21 | metadata: 22 | labels: 23 | app: imageserver 24 | tier: frontend 25 | spec: 26 | affinity: 27 | nodeAffinity: 28 | requiredDuringSchedulingIgnoredDuringExecution: 29 | nodeSelectorTerms: 30 | - matchExpressions: 31 | - key: failure-domain.beta.kubernetes.io/zone 32 | operator: In 33 | values: 34 | - us-central1-a 35 | - us-central1-b 36 | - us-central1-c 37 | podAntiAffinity: 38 | preferredDuringSchedulingIgnoredDuringExecution: 39 | - podAffinityTerm: 40 | labelSelector: 41 | matchExpressions: 42 | - key: app 43 | operator: In 44 | values: 45 | - imageserver 46 | topologyKey: failure-domain.beta.kubernetes.io/zone 47 | weight: 1 48 | requiredDuringSchedulingIgnoredDuringExecution: 49 | - labelSelector: 50 | matchExpressions: 51 | - key: app 52 | operator: In 53 | values: 54 | - imageserver 55 | topologyKey: kubernetes.io/hostname 56 | containers: 57 | - name: imageserver-app 58 | image: quay.io/plotly/image-exporter:master 59 | args: ["--plotlyJS", "https://stage.plot.ly/static/plotlyjs/build/plotlyjs-bundle.js"] 60 | env: 61 | - name: MAPBOX_ACCESS_TOKEN 62 | valueFrom: 63 | secretKeyRef: 64 | name: mapbox 65 | key: default_access_token 66 | - name: PLOTLY_IMAGESERVER_ENABLE_MONIT 67 | value: false 68 | # This setting makes nodes pull the docker image every time before 69 | # starting the pod. This is useful when debugging, but should be turned 70 | # off in production. 71 | imagePullPolicy: Always 72 | ports: 73 | - name: http-server 74 | containerPort: 9091 75 | resources: 76 | limits: 77 | memory: 2396Mi 78 | requests: 79 | cpu: 100m 80 | memory: 1Gi 81 | volumeMounts: 82 | - mountPath: "/usr/share/fonts/user" 83 | name: plotly-cloud-licensed-fonts 84 | - mountPath: "/dev/shm" 85 | name: dshm 86 | livenessProbe: 87 | httpGet: 88 | path: /ping 89 | port: 9091 90 | initialDelaySeconds: 10 91 | periodSeconds: 10 92 | timeoutSeconds: 5 93 | failureThreshold: 3 94 | readinessProbe: 95 | exec: 96 | command: 97 | - cat 98 | - /var/run/xvfb.pid 99 | failureThreshold: 1 100 | initialDelaySeconds: 20 101 | periodSeconds: 1 102 | successThreshold: 1 103 | timeoutSeconds: 1 104 | volumes: 105 | - name: plotly-cloud-licensed-fonts 106 | gcePersistentDisk: 107 | pdName: plotly-cloud-licensed-fonts 108 | readOnly: true 109 | fsType: ext4 110 | - name: dshm 111 | emptyDir: 112 | medium: Memory 113 | -------------------------------------------------------------------------------- /test/unit/inkscape_test.js: -------------------------------------------------------------------------------- 1 | const tap = require('tap') 2 | const sinon = require('sinon') 3 | const path = require('path') 4 | const fs = require('fs') 5 | const childProcess = require('child_process') 6 | const Inkscape = require('../../src/util/inkscape') 7 | 8 | const { paths, mocks } = require('../common') 9 | 10 | tap.test('inkscape.svg2emf', t => { 11 | t.test('should convert svg to emf', t => { 12 | const inkscape = new Inkscape() 13 | const outPath = path.join(paths.build, 'inkscape-test.emf') 14 | 15 | inkscape.svg2emf(mocks.svg, { figure: mocks.figure }, (err, result) => { 16 | if (err) t.fail(err) 17 | t.type(result, Buffer) 18 | 19 | fs.writeFile(outPath, result, (err) => { 20 | if (err) t.fail(err) 21 | 22 | const size = fs.statSync(outPath).size 23 | t.ok(size > 2e4, 'min emf file size') 24 | t.end() 25 | }) 26 | }) 27 | }) 28 | 29 | t.test('should remove tmp files after conversion', t => { 30 | const inkscape = new Inkscape() 31 | const tmpOutPath = path.join(paths.build, 'tmp-emf') 32 | const tmpSvgPath = path.join(paths.build, 'tmp-svg') 33 | 34 | inkscape.svg2emf(mocks.svg, { id: 'tmp', figure: mocks.figure }, (err, result) => { 35 | if (err) t.fail(err) 36 | 37 | t.type(result, Buffer) 38 | t.notOk(fs.existsSync(tmpOutPath), 'clears tmp emf file') 39 | t.notOk(fs.existsSync(tmpSvgPath), 'clears tmp svg file') 40 | t.end() 41 | }) 42 | }) 43 | 44 | t.test('should error out when inkscape command fails', t => { 45 | const inkscape = new Inkscape('not gonna work') 46 | 47 | const tmpOutPath = path.join(paths.build, 'tmp-emf') 48 | const tmpSvgPath = path.join(paths.build, 'tmp-svg') 49 | 50 | inkscape.svg2emf(mocks.svg, { id: 'tmp', figure: mocks.figure }, (err) => { 51 | t.throws(() => { throw err }, /Command failed/) 52 | t.notOk(fs.existsSync(tmpOutPath), 'clears tmp emf file') 53 | t.notOk(fs.existsSync(tmpSvgPath), 'clears tmp svg file') 54 | t.end() 55 | }) 56 | }) 57 | 58 | t.end() 59 | }) 60 | 61 | tap.test('Inkscape installation', t => { 62 | t.afterEach((done) => { 63 | childProcess.execSync.restore() 64 | done() 65 | }) 66 | 67 | t.test('should return true when binary execute correctly', t => { 68 | sinon.stub(childProcess, 'execSync').returns(true) 69 | var inkscape = new Inkscape() 70 | t.ok(inkscape.isInstalled()) 71 | t.end() 72 | }) 73 | 74 | t.test('should return false when binary does not execute correctly', t => { 75 | sinon.stub(childProcess, 'execSync').throws() 76 | var inkscape = new Inkscape() 77 | t.notOk(inkscape.isInstalled()) 78 | t.end() 79 | }) 80 | 81 | t.test('should return Inkscape version', t => { 82 | var inkscape = new Inkscape() 83 | sinon.stub(childProcess, 'execSync').returns('Inkscape 0.92.3 (2405546, 2018-03-11)') 84 | t.ok(inkscape.Version() === '0.92.3') 85 | 86 | childProcess.execSync.restore() 87 | sinon.stub(childProcess, 'execSync').returns('Inkscape 0.48.5 r10040 (Oct 7 2014)') 88 | t.ok(inkscape.Version() === '0.48.5') 89 | 90 | t.end() 91 | }) 92 | 93 | t.test('CheckInstallation() should throw error with older version of Inkscape', t => { 94 | var inkscape = new Inkscape() 95 | sinon.stub(childProcess, 'execSync').returns('Inkscape 0.48.5 r10040 (Oct 7 2014)') 96 | t.throws(function () { inkscape.CheckInstallation() }) 97 | t.end() 98 | }) 99 | 100 | t.test('CheckInstallation() should NOT throw error with newer version of Inkscape', t => { 101 | var inkscape = new Inkscape() 102 | sinon.stub(childProcess, 'execSync').returns('Inkscape 0.92.3 (2405546, 2018-03-11)') 103 | t.doesNotThrow(function () { inkscape.CheckInstallation() }) 104 | t.end() 105 | }) 106 | 107 | t.end() 108 | }) 109 | -------------------------------------------------------------------------------- /recipe/README.md: -------------------------------------------------------------------------------- 1 | Building the conda package 2 | ========================== 3 | This directory contains the configuration files that are needed to build 4 | orca into a standalone conda package. 5 | 6 | To build the conda package, first install 7 | [Anaconda](https://www.anaconda.com/download/#macos) or 8 | [Miniconda](https://conda.io/miniconda.html). 9 | 10 | Next, use `conda` to install the `conda-build` package: 11 | 12 | ```bash 13 | $ conda install conda-build 14 | ``` 15 | 16 | Finally, build the package from the root project directory: 17 | 18 | ```bash 19 | $ conda build recipe/ 20 | ``` 21 | 22 | The resulting package will be named `plotly-orca-*.tar.bz2`, and the build 23 | command will display the full path of the output location. 24 | 25 | How it works 26 | ------------ 27 | Here's a quick tour of the build configuration. For more information see the 28 | official conda docs for 29 | [building packags](https://conda.io/docs/user-guide/tasks/build-packages/define-metadata.html). 30 | 31 | ### `meta.yaml` ### 32 | The `meta.yaml` file in this directory is the top-level configuration file for 33 | the conda package. Here we specify the package name (`package.name`) and 34 | version (`package.version`, more on the version logic below), along with the 35 | relative path to the orca source directory (`source.path`). 36 | 37 | The build number (`build.number`) parameter should always start at 1 for 38 | each new version of orca. It should be incremented if it becomes necessary to 39 | publish multiple conda builds that correspond to the same orca version. 40 | 41 | By default, conda-build does a lot of work to 42 | [make binary packages relocatable](https://conda.io/docs/user-guide/tasks/build-packages/make-relocatable.html). 43 | On Linux, this logic results in a gconf settings warning being raised each 44 | time orca is used, and it doens't seem to be necessary, so it has been 45 | disabled by setting the `build.binary_relocation` property to `False`. 46 | 47 | Finally, the `requirements.build` section is used to specify that we need 48 | `nodejs` available during the package build process (but not at runtime). 49 | 50 | ## `build.sh` and `bld.bat` 51 | The `build.sh` and `bld.bat` files are scripts with special names that are 52 | recognized by conda-build. On Linux and OS X the `build.sh` script will be 53 | executed in a fresh conda environment that contains only the packages 54 | specified in the `requirements.build` section of `meta.yaml` 55 | (just `nodejs` in our case). Likewise, on Windows the `bld.bat` script is 56 | executed under the same conditions. 57 | 58 | These build scripts all start off by running `npm install` and `npm run pack` 59 | to create the electron build of orca in the `release/` directory. 60 | 61 | Then, for each OS, the directory of extracted build files is moved to a 62 | directory under the root of the environment 63 | (conda populates the `PREFIX` environment variable with this location). 64 | 65 | Finally, an entry-point script named `orca` (`orca.cmd` on Windows) is placed 66 | somewhere in the environment that will end up on the user's `PATH` 67 | (`$PREFIX/bin` for Linux and OS X, and just `$PREFIX` for Windows). This 68 | script is responsible for passing command line arguments through to the `orca` 69 | executable that lives somewhere inside the directory of build files that was 70 | moved in the previous step. 71 | 72 | ## Package version and `load_setup_py_data()` 73 | The canonical version string for `orca` resides in `package.json`. In order to 74 | avoid having a separate copy of this string in `meta.yaml` we use the following 75 | approach: 76 | 77 | conda build provides a built-in function called `load_setup_py_data` that can 78 | be used inside a `jinja2` template expression in `meta.yaml` to load all of 79 | the metadata associated with the project's `setup.py` file. Orca is not a 80 | Python library, but it order to take advantage of this function, 81 | a `setup.py` script has been added to the root project directory. The 82 | `setup.py` script is responsible for dynamically loading the version string 83 | from the `package.json` file. -------------------------------------------------------------------------------- /src/app/runner/run.js: -------------------------------------------------------------------------------- 1 | const uuid = require('uuid/v4') 2 | const isNumeric = require('fast-isnumeric') 3 | const parallelLimit = require('run-parallel-limit') 4 | 5 | const createTimer = require('../../util/create-timer') 6 | const getBody = require('./get-body') 7 | const cst = require('./constants') 8 | 9 | const STATUS_MSG = cst.statusMsg 10 | 11 | /** Run input -> image! 12 | * 13 | * @param {electron app} app 14 | * @param {electron window} win 15 | * @param {ipcMain} ipcMain 16 | * @param {object} opts : app options 17 | * - input 18 | * - component 19 | * - parallelLimit 20 | * - debug 21 | */ 22 | function run (app, win, ipcMain, opts) { 23 | const input = opts.input 24 | const comp = opts.component 25 | const compOpts = comp.options 26 | const totalTimer = createTimer() 27 | 28 | let pending = input.length 29 | let failed = 0 30 | 31 | const tasks = input.map((item, i) => (cb) => { 32 | const timer = createTimer() 33 | const id = uuid() 34 | 35 | // initialize 'full' info object 36 | // which accumulates parse, render, convert results 37 | // and is emitted on 'export-error' and 'after-export' 38 | const fullInfo = { 39 | itemIndex: i, 40 | id: id 41 | } 42 | 43 | // task callback wrapper: 44 | // - emits 'export-error' if given error code or error obj/msg 45 | // - emits 'after-export' if no argument is given 46 | const done = (err) => { 47 | fullInfo.pending = --pending 48 | fullInfo.processingTime = timer.end() 49 | 50 | if (err) { 51 | failed++ 52 | 53 | if (isNumeric(err)) { 54 | fullInfo.code = err 55 | } else { 56 | fullInfo.code = 501 57 | fullInfo.error = err 58 | } 59 | 60 | fullInfo.msg = fullInfo.msg || STATUS_MSG[fullInfo.code] || '' 61 | app.emit('export-error', fullInfo) 62 | } else { 63 | app.emit('after-export', fullInfo) 64 | } 65 | 66 | cb() 67 | } 68 | 69 | // setup parse callback 70 | const sendToRenderer = (errorCode, parseInfo) => { 71 | Object.assign(fullInfo, parseInfo) 72 | 73 | if (errorCode) { 74 | return done(errorCode) 75 | } 76 | 77 | win.webContents.send(comp.name, id, fullInfo, compOpts) 78 | } 79 | 80 | // setup convert callback 81 | const reply = (errorCode, convertInfo) => { 82 | Object.assign(fullInfo, convertInfo) 83 | 84 | if (errorCode) { 85 | return done(errorCode) 86 | } 87 | 88 | if (opts.write) { 89 | opts.write(fullInfo, compOpts, done) 90 | } else { 91 | done() 92 | } 93 | } 94 | 95 | // setup convert on render message -> emit 'after-export' 96 | ipcMain.once(id, (event, errorCode, renderInfo) => { 97 | Object.assign(fullInfo, renderInfo) 98 | 99 | if (errorCode) { 100 | return done(errorCode) 101 | } 102 | 103 | comp._module.convert(fullInfo, compOpts, reply) 104 | }) 105 | 106 | // parse -> send to renderer GO! 107 | getBody(item, (err, _body) => { 108 | let body 109 | 110 | if (err) { 111 | return done(422) 112 | } 113 | 114 | if (typeof _body === 'string') { 115 | try { 116 | body = JSON.parse(_body) 117 | } catch (e) { 118 | return done(422) 119 | } 120 | } else { 121 | body = _body 122 | } 123 | 124 | comp._module.parse(body, {}, compOpts, sendToRenderer) 125 | }) 126 | }) 127 | 128 | parallelLimit(tasks, opts.parallelLimit, (err) => { 129 | const exitCode = (err || pending > 0 || failed > 0) ? 1 : 0 130 | 131 | app.emit('after-export-all', { 132 | code: exitCode, 133 | msg: STATUS_MSG[exitCode], 134 | totalProcessingTime: totalTimer.end() 135 | }) 136 | 137 | // do not close window to look for unlogged console errors 138 | if (!opts.debug) { 139 | app.exit(exitCode) 140 | } 141 | }) 142 | } 143 | 144 | module.exports = run 145 | -------------------------------------------------------------------------------- /bin/orca_electron.js: -------------------------------------------------------------------------------- 1 | const minimist = require('minimist') 2 | const pkg = require('../package.json') 3 | const { sliceArgs } = require('./args') 4 | 5 | const args = sliceArgs(process.argv) 6 | 7 | const opts = minimist(args, { 8 | 'boolean': ['help', 'version'], 9 | 'alias': { 10 | 'help': ['h'], 11 | 'version': ['v'] 12 | } 13 | }) 14 | 15 | const BAD_CMD = 'Unrecognized orca command. Run `orca --help` for more info' 16 | 17 | const HELP = `Plotly's image-exporting utilities 18 | 19 | Usage: orca [--version] [--help] [] 20 | 21 | Available commands: 22 | - graph [or plotly-graph, plotly_graph] 23 | Generates an image of plotly graph from inputted plotly.js JSON attributes. 24 | For more info, run \`orca graph --help\`. 25 | - serve [or server] 26 | Boots up a server with one route per available export component 27 | For more info, run \`orca serve --help\`. 28 | ` 29 | 30 | if (process.platform === 'darwin' && process.argv.length === 1) { 31 | // On MacOS, create a script on /usr/local/bin, 32 | // so that orca can be invoked on the command line. 33 | const execSync = require('child_process').execSync 34 | const fs = require('fs') 35 | const path = require('path') 36 | const { app, dialog } = require('electron') 37 | 38 | const options = { 39 | type: 'info', 40 | buttons: ['Dismiss'], 41 | title: 'Orca Installer', 42 | message: 'Installation Succeeded!', 43 | detail: 44 | 'To create your first image, open a terminal and run:\n\norca graph \'{ "data": [{"y": [1,2,1]}] }\' -o fig.png' 45 | } 46 | 47 | let showMessage = false 48 | 49 | try { 50 | execSync('which orca') 51 | // Orca is already on the path, nothing to do 52 | } catch (err) { 53 | // Check if this is a standalone installation (not conda or npm) 54 | const standalonePath = '/Applications/orca.app/Contents/MacOS/orca' 55 | if (fs.existsSync(standalonePath)) { 56 | // Now we know that orca is not on the path, but it is installed 57 | // in the /Applications directory. So we'll ask the user if they 58 | // want to add it to the path 59 | const source = path.join(__dirname, 'orca.sh') 60 | const target = '/usr/local/bin/orca' 61 | 62 | if (!fs.existsSync(target)) { 63 | // Build copy command 64 | const copyCmd = `"cp ${source} ${target}"` 65 | 66 | // Use apple script to perform copy so that we can launch a GUI 67 | // prompt for administrator credentials 68 | const prompt = '"Add orca to system PATH (/usr/local/bin)?"' 69 | const cmd = `osascript -e 'do shell script ${copyCmd} with prompt ${prompt} with administrator privileges'` 70 | 71 | try { 72 | execSync(cmd) 73 | showMessage = true 74 | } catch (cmdErr) { 75 | // User cancelled. Nothing more to do 76 | } 77 | } 78 | } else { 79 | options.message = 'Executable in non-standard location' 80 | options.detail = 'No orca executable located at /Applications/orca.app/\nNo changes made' 81 | showMessage = true 82 | } 83 | } 84 | 85 | app.on('ready', function () { 86 | if (showMessage) { 87 | dialog.showMessageBox(options) 88 | } 89 | console.log(HELP) 90 | process.exit(options.type === 'error' ? 1 : 0) 91 | }) 92 | } else if (opts._.length) { 93 | const cmd = opts._[0] 94 | const cmdArgs = args.slice(1) 95 | 96 | switch (cmd) { 97 | case 'graph': 98 | case 'plotly_graph': 99 | case 'plotly-graph': 100 | require('./graph')(cmdArgs) 101 | break 102 | 103 | case 'serve': 104 | case 'server': 105 | require('./serve')(cmdArgs) 106 | break 107 | 108 | default: 109 | console.log(BAD_CMD) 110 | process.exit(1) 111 | } 112 | } else { 113 | if (opts.help) { 114 | console.log(HELP) 115 | process.exit(0) 116 | } 117 | if (opts.version) { 118 | console.log(pkg.version) 119 | process.exit(0) 120 | } 121 | 122 | console.log(HELP) 123 | process.exit(0) 124 | } 125 | -------------------------------------------------------------------------------- /test/unit/plotly-thumbnail_test.js: -------------------------------------------------------------------------------- 1 | const tap = require('tap') 2 | const _module = require('../../src/component/plotly-thumbnail') 3 | 4 | tap.test('parse:', t => { 5 | const fn = _module.parse 6 | 7 | t.test('should fill in defaults', t => { 8 | const body = { 9 | figure: { 10 | data: [{ 11 | y: [1, 2, 1], 12 | xaxis: 'x2', 13 | yaxis: 'y3' 14 | }, { 15 | scene: 'scene20' 16 | }, { 17 | type: 'pie', 18 | marker: {} 19 | }], 20 | layout: { 21 | margin: { l: 100, r: 100, t: 100, b: 100 }, 22 | yaxis: { 23 | title: 'some title', 24 | otherAttr: 'dummy' 25 | }, 26 | yaxis2: { 27 | title: 'another title' 28 | }, 29 | xaxis: { 30 | color: 'blue', 31 | title: 'yo' 32 | }, 33 | xaxis14: { 34 | title: 'yooo' 35 | }, 36 | scene: { 37 | xaxis: { 38 | title: '3D !!!', 39 | YO: 'yo' 40 | } 41 | }, 42 | scene3: { 43 | zaxis: { 44 | dummy: 'DUMMY', 45 | showaxeslabels: true, 46 | showticklabels: true 47 | } 48 | } 49 | } 50 | } 51 | } 52 | 53 | fn(body, {}, {}, (errorCode, result) => { 54 | t.equal(errorCode, null, 'code') 55 | t.same(result, { 56 | figure: { 57 | data: [{ 58 | y: [1, 2, 1], 59 | showscale: false, 60 | xaxis: 'x2', 61 | yaxis: 'y3' 62 | }, { 63 | scene: 'scene20', 64 | showscale: false 65 | }, { 66 | type: 'pie', 67 | showscale: false, 68 | marker: { showscale: false }, 69 | textposition: 'none' 70 | }], 71 | layout: { 72 | title: '', 73 | showlegend: false, 74 | margin: { b: 0, l: 0, r: 0, t: 0 }, 75 | yaxis: { 76 | title: '', 77 | otherAttr: 'dummy' 78 | }, 79 | yaxis2: { title: '' }, 80 | yaxis3: { title: '' }, 81 | xaxis: { 82 | color: 'blue', 83 | title: '' 84 | }, 85 | xaxis2: { title: '' }, 86 | xaxis14: { title: '' }, 87 | scene: { 88 | xaxis: { 89 | title: '', 90 | showaxeslabels: false, 91 | showticklabels: false, 92 | YO: 'yo' 93 | }, 94 | yaxis: { 95 | title: '', 96 | showaxeslabels: false, 97 | showticklabels: false 98 | }, 99 | zaxis: { 100 | title: '', 101 | showaxeslabels: false, 102 | showticklabels: false 103 | } 104 | }, 105 | scene3: { 106 | xaxis: { 107 | title: '', 108 | showaxeslabels: false, 109 | showticklabels: false 110 | }, 111 | yaxis: { 112 | title: '', 113 | showaxeslabels: false, 114 | showticklabels: false 115 | }, 116 | zaxis: { 117 | dummy: 'DUMMY', 118 | title: '', 119 | showaxeslabels: false, 120 | showticklabels: false 121 | } 122 | }, 123 | scene20: { 124 | xaxis: { 125 | title: '', 126 | showaxeslabels: false, 127 | showticklabels: false 128 | }, 129 | yaxis: { 130 | title: '', 131 | showaxeslabels: false, 132 | showticklabels: false 133 | }, 134 | zaxis: { 135 | title: '', 136 | showaxeslabels: false, 137 | showticklabels: false 138 | } 139 | } 140 | } 141 | }, 142 | format: 'png', 143 | scale: 1, 144 | width: 700, 145 | height: 500, 146 | encoded: false, 147 | fid: null 148 | }, 'result') 149 | 150 | t.end() 151 | }) 152 | }) 153 | 154 | t.end() 155 | }) 156 | -------------------------------------------------------------------------------- /deployment/kube/prod/frontend.yaml: -------------------------------------------------------------------------------- 1 | # WARNING: There is considerable duplication between this file and the 2 | # stage version. When updating this file, please check if your changes 3 | # need to be made to the other version. 4 | 5 | apiVersion: apps/v1 6 | kind: Deployment 7 | metadata: 8 | name: imageserver 9 | labels: 10 | app: imageserver 11 | spec: 12 | replicas: 3 13 | strategy: 14 | rollingUpdate: 15 | maxSurge: 100% 16 | maxUnavailable: 25% 17 | type: RollingUpdate 18 | template: 19 | metadata: 20 | labels: 21 | app: imageserver 22 | tier: frontend 23 | spec: 24 | affinity: 25 | nodeAffinity: 26 | requiredDuringSchedulingIgnoredDuringExecution: 27 | nodeSelectorTerms: 28 | - matchExpressions: 29 | - key: failure-domain.beta.kubernetes.io/zone 30 | operator: In 31 | values: 32 | - us-central1-a 33 | - us-central1-b 34 | - us-central1-c 35 | podAntiAffinity: 36 | preferredDuringSchedulingIgnoredDuringExecution: 37 | - podAffinityTerm: 38 | labelSelector: 39 | matchExpressions: 40 | - key: app 41 | operator: In 42 | values: 43 | - imageserver 44 | topologyKey: failure-domain.beta.kubernetes.io/zone 45 | weight: 1 46 | requiredDuringSchedulingIgnoredDuringExecution: 47 | - labelSelector: 48 | matchExpressions: 49 | - key: app 50 | operator: In 51 | values: 52 | - imageserver 53 | topologyKey: kubernetes.io/hostname 54 | containers: 55 | - name: imageserver-app 56 | image: quay.io/plotly/image-exporter:master 57 | args: ["--plotlyJS", "https://plot.ly/static/plotlyjs/build/plotlyjs-bundle.js"] 58 | env: 59 | - name: MAPBOX_ACCESS_TOKEN 60 | valueFrom: 61 | secretKeyRef: 62 | name: mapbox 63 | key: default_access_token 64 | - name: PLOTLY_IMAGESERVER_ENABLE_MONIT 65 | value: false 66 | # This setting makes nodes pull the docker image every time before 67 | # starting the pod. This is useful when debugging, but should be turned 68 | # off in production. 69 | imagePullPolicy: Always 70 | resources: 71 | limits: 72 | memory: 2396Mi 73 | requests: 74 | cpu: 100m 75 | memory: 1Gi 76 | ports: 77 | - name: http-server 78 | containerPort: 9091 79 | volumeMounts: 80 | - mountPath: "/usr/share/fonts/user" 81 | name: plotly-cloud-licensed-fonts 82 | - mountPath: "/dev/shm" 83 | name: dshm 84 | livenessProbe: 85 | httpGet: 86 | path: /ping 87 | port: 9091 88 | initialDelaySeconds: 10 89 | periodSeconds: 10 90 | timeoutSeconds: 5 91 | failureThreshold: 3 92 | readinessProbe: 93 | exec: 94 | command: 95 | - cat 96 | - /var/run/xvfb.pid 97 | failureThreshold: 1 98 | initialDelaySeconds: 20 99 | periodSeconds: 1 100 | successThreshold: 1 101 | timeoutSeconds: 1 102 | volumes: 103 | - name: plotly-cloud-licensed-fonts 104 | gcePersistentDisk: 105 | pdName: plotly-cloud-licensed-fonts 106 | readOnly: true 107 | fsType: ext4 108 | - name: dshm 109 | emptyDir: 110 | medium: Memory 111 | -------------------------------------------------------------------------------- /src/app/server/create-server.js: -------------------------------------------------------------------------------- 1 | const http = require('http') 2 | const textBody = require('body') 3 | const uuid = require('uuid/v4') 4 | 5 | const createTimer = require('../../util/create-timer') 6 | const cst = require('./constants') 7 | const Ping = require('./ping') 8 | 9 | const BUFFER_OVERFLOW_LIMIT = cst.bufferOverflowLimit 10 | const STATUS_MSG = cst.statusMsg 11 | 12 | /** Create server! 13 | * 14 | * @param {electron app} app 15 | * @param {ipcMain} ipcMain 16 | * @param {object} opts : app options 17 | * - port 18 | * - _componentLookup 19 | * - _win 20 | * - requestTimeout 21 | */ 22 | function createServer (app, BrowserWindow, ipcMain, opts) { 23 | let pending = 0 24 | 25 | const server = http.createServer((req, res) => { 26 | const timer = createTimer() 27 | const id = uuid() 28 | const route = req.url 29 | 30 | // initialize 'full' info object 31 | // which accumulates parse, render, convert results 32 | // and is emitted on 'export-error' and 'after-export' 33 | const fullInfo = { 34 | port: opts.port, 35 | method: req.method, 36 | id: id 37 | } 38 | 39 | const simpleReply = (code, msg) => { 40 | res.writeHead(code, { 'Content-Type': 'text/plain' }) 41 | return res.end(msg || STATUS_MSG[code]) 42 | } 43 | 44 | const errorReply = (code) => { 45 | fullInfo.msg = fullInfo.msg || STATUS_MSG[code] || '' 46 | 47 | app.emit('export-error', Object.assign( 48 | { code: code }, 49 | fullInfo 50 | )) 51 | 52 | return simpleReply(code, fullInfo.msg) 53 | } 54 | 55 | // Set CORS headers 56 | if (opts.cors) { 57 | res.setHeader('Access-Control-Allow-Origin', '*') 58 | res.setHeader('Access-Control-Allow-Methods', 'OPTIONS, GET, POST') 59 | res.setHeader('Access-Control-Allow-Headers', '*') 60 | if (req.method === 'OPTIONS') { 61 | return simpleReply(200) 62 | } 63 | } 64 | 65 | req.once('error', () => simpleReply(401)) 66 | req.once('close', () => simpleReply(499)) 67 | 68 | req.socket.removeAllListeners('timeout') 69 | req.socket.on('timeout', () => simpleReply(522)) 70 | req.socket.setTimeout(opts.requestTimeout) 71 | 72 | if (route === '/ping') { 73 | Ping(ipcMain, opts.component) 74 | .then(() => simpleReply(200)) 75 | .catch((err) => { 76 | fullInfo.msg = JSON.stringify(err, ['message', 'arguments', 'type', 'name']) 77 | errorReply(500) 78 | }) 79 | 80 | return 81 | } 82 | 83 | const comp = opts._componentLookup[route] 84 | 85 | if (!comp) { 86 | return errorReply(404) 87 | } 88 | 89 | if (!comp._win) { 90 | return errorReply(504) 91 | } 92 | 93 | if (BrowserWindow.getAllWindows().length > opts.maxNumberOfWindows) { 94 | return errorReply(402) 95 | } 96 | 97 | const compOpts = comp.options 98 | 99 | // setup parse callback 100 | const sendToRenderer = (errorCode, parseInfo) => { 101 | Object.assign(fullInfo, parseInfo) 102 | 103 | if (errorCode) { 104 | return errorReply(errorCode) 105 | } 106 | 107 | comp._win.webContents.send(comp.name, id, fullInfo, compOpts) 108 | } 109 | 110 | // setup convert callback 111 | const reply = (errorCode, convertInfo) => { 112 | Object.assign(fullInfo, convertInfo) 113 | 114 | fullInfo.pending = --pending 115 | fullInfo.processingTime = timer.end() 116 | 117 | if (errorCode) { 118 | return errorReply(errorCode) 119 | } 120 | 121 | const cb = () => { 122 | app.emit('after-export', fullInfo) 123 | } 124 | 125 | res.writeHead(200, fullInfo.head) 126 | 127 | if (res.write(fullInfo.body)) { 128 | res.end(cb) 129 | } else { 130 | res.once('drain', () => res.end(cb)) 131 | } 132 | } 133 | 134 | // setup convert on render message -> end response 135 | ipcMain.once(id, (event, errorCode, renderInfo) => { 136 | Object.assign(fullInfo, renderInfo) 137 | 138 | if (errorCode) { 139 | return errorReply(errorCode) 140 | } 141 | 142 | comp._module.convert(fullInfo, compOpts, reply) 143 | }) 144 | 145 | // parse -> send to renderer GO! 146 | textBody(req, { limit: BUFFER_OVERFLOW_LIMIT }, (err, _body) => { 147 | let body 148 | 149 | if (err) { 150 | return errorReply(422) 151 | } 152 | 153 | try { 154 | body = JSON.parse(_body) 155 | } catch (e) { 156 | return errorReply(422) 157 | } 158 | 159 | pending++ 160 | comp._module.parse(body, req, compOpts, sendToRenderer) 161 | }) 162 | }) 163 | 164 | return server 165 | } 166 | 167 | module.exports = createServer 168 | -------------------------------------------------------------------------------- /test/image/baselines/gl2d_14.json: -------------------------------------------------------------------------------- 1 | {"data":[{"connectgaps":false,"error_x":{"visible":false},"error_y":{"visible":false},"fill":"none","hoverinfo":"x+y+z+text","hoverlabel":{"align":"auto","font":{"family":"Arial, sans-serif","size":13},"namelength":15},"hovertemplate":"","hovertext":"","index":0,"legendgroup":"","line":{"color":"rgb(0, 0, 238)","dash":"solid","shape":"linear","width":2},"marker":{"color":"rgb(0, 0, 238)","line":{"color":"rgb(0, 0, 238)","width":0},"opacity":0.6,"size":12,"symbol":"circle"},"mode":"lines+markers","name":"PV learning curve.txt","opacity":1,"selected":{"marker":{"opacity":0.6}},"showlegend":true,"text":"","type":"scattergl","uid":"data0","unselected":{"marker":{"opacity":0.12}},"visible":true,"x":[0.002,0.004,0.006,0.009,0.013,0.02,0.028,0.037,0.054,0.076,0.099,0.125,0.154,0.188,0.228,0.275,0.33,0.388,0.448,0.517,0.594,0.683,0.809,0.964,1.165,1.425,1.753,2.22,2.798,3.911,5.34,6.915,9.443,15.772,23.21,40.019,69.684],"xaxis":"x","xcalendar":"gregorian","y":[16.25,12.5,10,7.5,6.875,6.875,6.25,5.625,3.75,3.5,2.75,2.125,1.625,1.375,1.5,1.5,1.25,1,0.875,0.825,0.775,0.713,0.713,0.55,0.525,0.538,0.5,0.45,0.4,0.401,0.403,0.411,0.379,0.387,0.248,0.216,0.154],"yaxis":"y","ycalendar":"gregorian"}],"layout":{"activeshape":{"fillcolor":"rgb(255,0,255)","opacity":0.5},"annotations":[],"autosize":false,"calendar":"gregorian","clickmode":"event","colorscale":{"diverging":[[0,"rgb(5,10,172)"],[0.35,"rgb(106,137,247)"],[0.5,"rgb(190,190,190)"],[0.6,"rgb(220,170,132)"],[0.7,"rgb(230,145,90)"],[1,"rgb(178,10,28)"]],"sequential":[[0,"rgb(220,220,220)"],[0.2,"rgb(245,195,157)"],[0.4,"rgb(245,160,105)"],[1,"rgb(178,10,28)"]],"sequentialminus":[[0,"rgb(5,10,172)"],[0.35,"rgb(40,60,190)"],[0.5,"rgb(70,100,245)"],[0.6,"rgb(90,120,245)"],[0.7,"rgb(106,137,247)"],[1,"rgb(220,220,220)"]]},"colorway":["#1f77b4","#ff7f0e","#2ca02c","#d62728","#9467bd","#8c564b","#e377c2","#7f7f7f","#bcbd22","#17becf"],"dragmode":"zoom","font":{"color":"#000","family":"Arial, sans-serif","size":12},"height":440,"hidesources":false,"hoverdistance":20,"hoverlabel":{"align":"auto","font":{"family":"Arial, sans-serif","size":13},"namelength":15},"hovermode":"x","images":[],"margin":{"autoexpand":true,"b":60,"l":50,"pad":2,"r":40,"t":80},"modebar":{"activecolor":"rgba(68, 68, 68, 0.7)","bgcolor":"rgba(255, 255, 255, 0.5)","color":"rgba(68, 68, 68, 0.3)","orientation":"h"},"newshape":{"drawdirection":"diagonal","fillcolor":"rgba(0,0,0,0)","fillrule":"evenodd","layer":"above","line":{"color":"#444","dash":"solid","width":4},"opacity":1},"paper_bgcolor":"#fff","plot_bgcolor":"#fff","separators":".,","shapes":[],"showlegend":false,"sliders":[],"spikedistance":20,"title":{"font":{"color":"#000","family":"Arial, sans-serif","size":17},"pad":{"b":0,"l":0,"r":0,"t":0},"text":"Silicon Photovoltaics Learning Curve","x":0.5,"xanchor":"auto","xref":"container","y":"auto","yanchor":"auto","yref":"container"},"uniformtext":{"mode":false},"updatemenus":[],"width":800,"xaxis":{"anchor":"y","automargin":false,"autorange":true,"color":"#444","constrain":"range","constraintoward":"center","domain":[0,1],"dtick":"D1","exponentformat":"power","fixedrange":false,"gridcolor":"#ddd","gridwidth":1,"hoverformat":"","layer":"above traces","nticks":40,"range":[-3.011967491973726,2.1561305597186564],"separatethousands":false,"showexponent":"all","showgrid":true,"showline":false,"showspikes":false,"showticklabels":true,"side":"bottom","tick0":0,"tickangle":0,"tickfont":{"color":"#000","family":"Arial, sans-serif","size":12},"tickformat":"","tickmode":"auto","tickprefix":"","ticks":"","ticksuffix":"","title":{"font":{"color":"#000","family":"Arial, sans-serif","size":14},"text":"Cumulative Production (GW)"},"type":"log","visible":true,"zeroline":true,"zerolinecolor":"#000","zerolinewidth":1},"yaxis":{"anchor":"x","automargin":false,"autorange":true,"color":"#444","constrain":"range","constraintoward":"middle","domain":[0,1],"dtick":"D2","exponentformat":"power","fixedrange":false,"gridcolor":"#ddd","gridwidth":1,"hoverformat":"","layer":"above traces","nticks":6,"range":[-0.9910086301469277,1.389382716298284],"separatethousands":false,"showexponent":"all","showgrid":true,"showline":false,"showspikes":false,"showticklabels":true,"side":"left","tick0":0,"tickangle":0,"tickfont":{"color":"#000","family":"Arial, sans-serif","size":12},"tickformat":"","tickmode":"auto","tickprefix":"","ticks":"","ticksuffix":"","title":{"font":{"color":"#000","family":"Arial, sans-serif","size":14},"text":"Cost ($/WP)"},"type":"log","visible":true,"zeroline":true,"zerolinecolor":"#000","zerolinewidth":1}},"frames":[],"config":{"autosizable":false,"displayModeBar":false,"displaylogo":true,"doubleClick":false,"doubleClickDelay":300,"editable":false,"edits":{},"fillFrame":false,"frameMargins":0,"globalTransforms":[],"linkText":"Edit chart","locale":"en-US","locales":{},"logging":1,"mapboxAccessToken":null,"modeBarButtons":false,"modeBarButtonsToAdd":[],"modeBarButtonsToRemove":[],"notifyOnLogging":0,"plotGlPixelRatio":2.5,"plotlyServerURL":"","queueLength":0,"responsive":false,"scrollZoom":false,"sendData":true,"setBackground":"_function","showAxisDragHandles":true,"showAxisRangeEntryBoxes":true,"showEditInChartStudio":false,"showLink":false,"showSendToCloud":false,"showSources":false,"showTips":false,"staticPlot":true,"toImageButtonOptions":{},"topojsonURL":"https://cdn.plot.ly/","watermark":false},"version":"1.54.0"} -------------------------------------------------------------------------------- /src/component/plotly-dashboard-preview/render.js: -------------------------------------------------------------------------------- 1 | /* global Plotly:false */ 2 | 3 | const remote = require('../../util/remote') 4 | const separator = 12 5 | 6 | /** 7 | * @param {object} info : info object 8 | * - layoutType TODO 9 | * - direction TODO 10 | * - backgroundColor 11 | * - panels 12 | * @param {object} opts : component options 13 | * @param {function} sendToMain 14 | * - errorCode 15 | * - result 16 | * - imgData 17 | */ 18 | 19 | function render (info, opts, sendToMain) { 20 | const winWidth = info.width 21 | const winHeight = info.height 22 | 23 | let win = remote.createBrowserWindow({ 24 | width: winWidth, 25 | height: winHeight, 26 | show: !!opts.debug 27 | }) 28 | 29 | const config = { 30 | mapboxAccessToken: opts.mapboxAccessToken || '', 31 | plotGlPixelRatio: opts.plotGlPixelRatio 32 | } 33 | 34 | const html = window.encodeURIComponent(` 35 | 36 | 37 | 38 | 66 | 67 | 68 | `) 69 | 70 | win.loadURL(`data:text/html,${html}`) 71 | 72 | const result = {} 73 | let errorCode = null 74 | 75 | const done = () => { 76 | win.close() 77 | 78 | if (errorCode) { 79 | result.msg = 'dashboard preview generation failed' 80 | } 81 | sendToMain(errorCode, result) 82 | } 83 | 84 | win.on('closed', () => { 85 | win = null 86 | }) 87 | 88 | const contents = win.webContents 89 | 90 | const renderOnePlot = (p, idArray, imgWidth, imgHeight) => { 91 | return Plotly.toImage({ 92 | data: p.data, 93 | layout: p.layout, 94 | config: config 95 | }, { 96 | format: 'png', 97 | width: imgWidth, 98 | height: imgHeight, 99 | imageDataOnly: false 100 | }) 101 | .then(imgData => { 102 | contents.executeJavaScript(`new Promise((resolve, reject) => { 103 | const img = document.createElement('img') 104 | const root = document.getElementById('gd_${idArray.join('_')}') 105 | root.appendChild(img) 106 | img.onload = resolve 107 | img.onerror = reject 108 | img.src = '${imgData}' 109 | setTimeout(() => reject(new Error('too long to load image')), 5000) 110 | })`) 111 | }) 112 | } 113 | 114 | const renderOneDiv = (idArray, verticalContainer) => { 115 | contents.executeJavaScript(`new Promise((resolve, reject) => { 116 | const root = ${idArray.length} ? document.getElementById('gd_${idArray.slice(0, -1).join('_')}') : document.body 117 | const div = document.createElement('div') 118 | div.setAttribute('id', 'gd_${idArray.join('_')}') 119 | if(${verticalContainer}) { 120 | div.style['flex-direction'] = 'column' 121 | } 122 | root.appendChild(div) 123 | })`) 124 | } 125 | 126 | contents.once('did-finish-load', () => { 127 | const promises = [] 128 | 129 | const traversePanels = (p, path, width, height) => { 130 | const dir = p.direction 131 | const size = p.size 132 | const sizeUnit = p.sizeUnit 133 | renderOneDiv(path, p.type === 'split' && dir === 'vertical') 134 | switch (p.type) { 135 | case 'box': { 136 | promises.push(renderOnePlot(p.contents, path, width, height)) 137 | break 138 | } 139 | case 'split': { 140 | let multiplier = 1 / p.panels.length 141 | if (p.panels.length) { 142 | if (sizeUnit === '%') { 143 | multiplier = size / 100 144 | } else if (sizeUnit === 'px') { 145 | multiplier = size / (dir === 'vertical' ? height : width) 146 | } 147 | } 148 | const newWidths = dir === 'vertical' 149 | ? [width, width] 150 | : [width * multiplier, width * (1 - multiplier) - 2 * separator] 151 | const newHeights = dir === 'horizontal' 152 | ? [height, height] 153 | : [height * multiplier, height * (1 - multiplier) - 2 * separator] 154 | p.panels.forEach((panel, i) => { 155 | traversePanels(panel, path.concat([i]), newWidths[i], newHeights[i]) 156 | }) 157 | break 158 | } 159 | } 160 | } 161 | 162 | traversePanels(info.panels, [], winWidth - 2 * separator, winHeight - 2 * separator) 163 | 164 | Promise.all(promises) 165 | .then(() => { 166 | setTimeout(() => { 167 | contents.capturePage(img => { 168 | result.imgData = img.toPNG() 169 | done() 170 | }) 171 | }, 100) 172 | }) 173 | .catch(() => { 174 | errorCode = 525 175 | done() 176 | }) 177 | }) 178 | } 179 | 180 | module.exports = render 181 | -------------------------------------------------------------------------------- /test/image/baselines/basic_heatmap.json: -------------------------------------------------------------------------------- 1 | {"data":[{"autocolorscale":false,"colorbar":{"bgcolor":"rgba(0,0,0,0)","bordercolor":"#444","borderwidth":0,"exponentformat":"B","len":1,"lenmode":"fraction","nticks":0,"outlinecolor":"#444","outlinewidth":1,"separatethousands":false,"showexponent":"all","showticklabels":true,"thickness":30,"thicknessmode":"pixels","tickangle":"auto","tickfont":{"color":"#444","family":"\"Open Sans\", verdana, arial, sans-serif","size":12},"tickformat":"","tickmode":"auto","tickprefix":"","ticks":"","ticksuffix":"","title":{"font":{"color":"#444","family":"\"Open Sans\", verdana, arial, sans-serif","size":12},"side":"top","text":"Click to enter Colorscale title"},"x":1.02,"xanchor":"left","xpad":10,"y":0.5,"yanchor":"middle","ypad":10},"colorscale":[[0,"rgb(5,10,172)"],[0.35,"rgb(106,137,247)"],[0.5,"rgb(190,190,190)"],[0.6,"rgb(220,170,132)"],[0.7,"rgb(230,145,90)"],[1,"rgb(178,10,28)"]],"connectgaps":false,"dx":1,"dy":1,"hoverinfo":"x+y+z+text","hoverlabel":{"align":"auto","font":{"family":"Arial, sans-serif","size":13},"namelength":15},"hoverongaps":true,"hovertemplate":"","index":0,"legendgroup":"","name":"trace 0","opacity":1,"reversescale":false,"showlegend":false,"showscale":true,"transpose":false,"type":"heatmap","uid":"data0","visible":true,"x0":0,"xaxis":"x","xcalendar":"gregorian","xgap":0,"y0":0,"yaxis":"y","ycalendar":"gregorian","ygap":0,"z":[[1,20,30],[20,1,60],[30,60,1]],"zauto":true,"zhoverformat":"","zmax":60,"zmin":1,"zsmooth":false}],"layout":{"activeshape":{"fillcolor":"rgb(255,0,255)","opacity":0.5},"annotations":[],"autosize":false,"calendar":"gregorian","clickmode":"event","colorscale":{"diverging":[[0,"rgb(5,10,172)"],[0.35,"rgb(106,137,247)"],[0.5,"rgb(190,190,190)"],[0.6,"rgb(220,170,132)"],[0.7,"rgb(230,145,90)"],[1,"rgb(178,10,28)"]],"sequential":[[0,"rgb(220,220,220)"],[0.2,"rgb(245,195,157)"],[0.4,"rgb(245,160,105)"],[1,"rgb(178,10,28)"]],"sequentialminus":[[0,"rgb(5,10,172)"],[0.35,"rgb(40,60,190)"],[0.5,"rgb(70,100,245)"],[0.6,"rgb(90,120,245)"],[0.7,"rgb(106,137,247)"],[1,"rgb(220,220,220)"]]},"colorway":["#1f77b4","#ff7f0e","#2ca02c","#d62728","#9467bd","#8c564b","#e377c2","#7f7f7f","#bcbd22","#17becf"],"dragmode":"zoom","font":{"color":"#444","family":"\"Open Sans\", verdana, arial, sans-serif","size":12},"height":500,"hidesources":false,"hoverdistance":20,"hoverlabel":{"align":"auto","font":{"family":"Arial, sans-serif","size":13},"namelength":15},"hovermode":"x","images":[],"margin":{"autoexpand":true,"b":80,"l":80,"pad":0,"r":80,"t":100},"modebar":{"activecolor":"rgba(68, 68, 68, 0.7)","bgcolor":"rgba(255, 255, 255, 0.5)","color":"rgba(68, 68, 68, 0.3)","orientation":"h"},"newshape":{"drawdirection":"diagonal","fillcolor":"rgba(0,0,0,0)","fillrule":"evenodd","layer":"above","line":{"color":"#444","dash":"solid","width":4},"opacity":1},"paper_bgcolor":"#fff","plot_bgcolor":"#fff","separators":".,","shapes":[],"showlegend":false,"sliders":[],"spikedistance":20,"title":{"font":{"color":"#444","family":"\"Open Sans\", verdana, arial, sans-serif","size":17},"pad":{"b":0,"l":0,"r":0,"t":0},"text":"Click to enter Plot title","x":0.5,"xanchor":"auto","xref":"container","y":"auto","yanchor":"auto","yref":"container"},"uniformtext":{"mode":false},"updatemenus":[],"width":700,"xaxis":{"anchor":"y","automargin":false,"autorange":true,"color":"#444","constrain":"range","constraintoward":"center","domain":[0,1],"dtick":0.5,"exponentformat":"B","fixedrange":false,"gridcolor":"rgb(238, 238, 238)","gridwidth":1,"hoverformat":"","layer":"above traces","mirror":false,"nticks":0,"range":[-0.5,2.5],"rangemode":"normal","separatethousands":false,"showexponent":"all","showgrid":true,"showline":false,"showspikes":false,"showticklabels":true,"side":"bottom","tick0":0,"tickangle":"auto","tickcolor":"#444","tickfont":{"color":"#444","family":"\"Open Sans\", verdana, arial, sans-serif","size":12},"tickformat":"","ticklen":5,"tickmode":"auto","tickprefix":"","ticks":"outside","ticksuffix":"","tickwidth":1,"title":{"font":{"color":"#444","family":"\"Open Sans\", verdana, arial, sans-serif","size":14},"text":"Click to enter X axis title"},"type":"linear","visible":true,"zeroline":true,"zerolinecolor":"#444","zerolinewidth":1},"yaxis":{"anchor":"x","automargin":false,"autorange":true,"color":"#444","constrain":"range","constraintoward":"middle","domain":[0,1],"dtick":0.5,"exponentformat":"B","fixedrange":false,"gridcolor":"rgb(238, 238, 238)","gridwidth":1,"hoverformat":"","layer":"above traces","mirror":false,"nticks":0,"range":[-0.5,2.5],"rangemode":"normal","separatethousands":false,"showexponent":"all","showgrid":true,"showline":false,"showspikes":false,"showticklabels":true,"side":"left","tick0":0,"tickangle":"auto","tickcolor":"#444","tickfont":{"color":"#444","family":"\"Open Sans\", verdana, arial, sans-serif","size":12},"tickformat":"","ticklen":5,"tickmode":"auto","tickprefix":"","ticks":"outside","ticksuffix":"","tickwidth":1,"title":{"font":{"color":"#444","family":"\"Open Sans\", verdana, arial, sans-serif","size":14},"text":"Click to enter Y axis title"},"type":"linear","visible":true,"zeroline":true,"zerolinecolor":"#444","zerolinewidth":1}},"frames":[],"config":{"autosizable":false,"displayModeBar":false,"displaylogo":true,"doubleClick":false,"doubleClickDelay":300,"editable":false,"edits":{},"fillFrame":false,"frameMargins":0,"globalTransforms":[],"linkText":"Edit chart","locale":"en-US","locales":{},"logging":1,"mapboxAccessToken":null,"modeBarButtons":false,"modeBarButtonsToAdd":[],"modeBarButtonsToRemove":[],"notifyOnLogging":0,"plotGlPixelRatio":2.5,"plotlyServerURL":"","queueLength":0,"responsive":false,"scrollZoom":false,"sendData":true,"setBackground":"_function","showAxisDragHandles":true,"showAxisRangeEntryBoxes":true,"showEditInChartStudio":false,"showLink":false,"showSendToCloud":false,"showSources":false,"showTips":false,"staticPlot":true,"toImageButtonOptions":{},"topojsonURL":"https://cdn.plot.ly/","watermark":false},"version":"1.54.0"} -------------------------------------------------------------------------------- /bin/serve.js: -------------------------------------------------------------------------------- 1 | const orca = require('../src') 2 | const { PLOTLYJS_OPTS_META, extractOpts, formatOptsMeta } = require('./args') 3 | const cst = require('../src/app/server/constants') 4 | 5 | const OPTS_META = [].concat([{ 6 | name: 'help', 7 | type: 'boolean', 8 | alias: ['h'], 9 | description: 'Displays this message.' 10 | }, { 11 | name: 'port', 12 | type: 'string', 13 | alias: ['p'], 14 | dflt: process.env.ORCA_PORT || 9091, 15 | description: 'Sets the server\'s port number.' 16 | }], PLOTLYJS_OPTS_META, [{ 17 | name: 'request-limit', 18 | type: 'string', 19 | alias: ['requestLimit'], 20 | description: 'Sets a request limit that makes orca exit when reached.' 21 | }, { 22 | name: 'keep-alive', 23 | type: 'boolean', 24 | alias: ['keepAlive'], 25 | description: 'Turn on keep alive mode where orca will (try to) relaunch server if process unexpectedly exits.' 26 | }, { 27 | name: 'window-max-number', 28 | type: 'string', 29 | alias: ['windowMaxNumber', 'maxNumberOfWindows'], 30 | description: 'Sets maximum number of browser windows the server can keep open at a given time.' 31 | }, { 32 | name: 'graph-only', 33 | type: 'boolean', 34 | alias: ['graphOnly'], 35 | description: 'Launches only the graph component (not thumbnails, dash, etc.) to save memory and reduce the number of processes.' 36 | }, { 37 | name: 'quiet', 38 | type: 'boolean', 39 | description: 'Suppress all logging info.' 40 | }, { 41 | name: 'debug', 42 | type: 'boolean', 43 | description: 'Starts app in debug mode.' 44 | }, { 45 | name: 'cors', 46 | type: 'boolean', 47 | description: 'Enables Cross-Origin Resource Sharing (CORS)' 48 | }, { 49 | name: 'request-timeout', 50 | type: 'string', 51 | alias: ['requestTimeout'], 52 | dflt: process.env.ORCA_TIMEOUT || cst.dflt.requestTimeout, 53 | description: 'Sets the server\'s request timeout (in seconds).' 54 | }]) 55 | 56 | const HELP_MSG = `orca serve 57 | 58 | Usage: 59 | $ orca serve -p 9999 60 | 61 | Options: 62 | ${formatOptsMeta(OPTS_META)}` 63 | 64 | function main (args) { 65 | let app 66 | let requestCount = 0 67 | 68 | const opts = extractOpts(args, OPTS_META) 69 | const SHOW_LOGS = !opts.quiet 70 | const DEBUG = opts.debug 71 | const requestLimit = opts.requestLimit ? Number(opts.requestLimit) : Infinity 72 | 73 | if (opts.help) { 74 | console.log(HELP_MSG) 75 | process.exit(0) 76 | } 77 | 78 | const plotlyJsOpts = { 79 | plotlyJS: opts.plotlyJS, 80 | mapboxAccessToken: opts['mapbox-access-token'], 81 | mathjax: opts.mathjax, 82 | topojson: opts.topojson, 83 | safeMode: opts.safeMode, 84 | inkscape: opts.inkscape 85 | } 86 | 87 | function launch () { 88 | if (DEBUG || SHOW_LOGS) { 89 | console.log(`Spinning up server with pid: ${process.pid}`) 90 | } 91 | 92 | let component = [{ 93 | name: 'plotly-graph', 94 | route: '/', 95 | options: plotlyJsOpts 96 | }] 97 | 98 | if (!opts.graphOnly) { 99 | component.push({ 100 | name: 'plotly-dashboard', 101 | route: '/dashboard' 102 | }, { 103 | name: 'plotly-thumbnail', 104 | route: '/thumbnail', 105 | options: plotlyJsOpts 106 | }, { 107 | name: 'plotly-dashboard-thumbnail', 108 | route: '/dashboard-thumbnail', 109 | options: plotlyJsOpts 110 | }, { 111 | name: 'plotly-dashboard-preview', 112 | route: '/dashboard-preview', 113 | options: plotlyJsOpts 114 | }, { 115 | name: 'plotly-dash-preview', 116 | route: '/dash-preview', 117 | options: plotlyJsOpts 118 | }) 119 | } 120 | 121 | app = orca.serve({ 122 | port: opts.port, 123 | maxNumberOfWindows: opts.maxNumberOfWindows, 124 | debug: opts.debug, 125 | component: component, 126 | cors: opts.cors, 127 | requestTimeout: opts.requestTimeout 128 | }) 129 | 130 | app.on('after-connect', (info) => { 131 | if (DEBUG || SHOW_LOGS) { 132 | console.log(`Listening on port ${info.port} after a ${info.startupTime} ms bootup`) 133 | console.log(`Open routes: ${info.openRoutes.join(' ')}`) 134 | } 135 | }) 136 | 137 | app.on('after-export', (info) => { 138 | if (SHOW_LOGS) { 139 | console.log(JSON.stringify({ 140 | severity: 'INFO', 141 | httpRequest: { 142 | requestMethod: info.method 143 | }, 144 | labels: { 145 | fid: info.fid, 146 | head: info.head, 147 | processingTime: info.processingTime 148 | } 149 | })) 150 | } 151 | 152 | if (requestCount++ >= requestLimit) { 153 | app.quit() 154 | } 155 | }) 156 | 157 | app.on('export-error', (info) => { 158 | if (SHOW_LOGS) { 159 | console.log(JSON.stringify({ 160 | severity: 'ERROR', 161 | textPayload: `${info.code} - ${info.msg}`, 162 | labels: { 163 | fid: info.fid, 164 | head: info.head 165 | } 166 | })) 167 | } 168 | }) 169 | 170 | app.on('renderer-error', (info) => { 171 | if (SHOW_LOGS) { 172 | console.log(JSON.stringify({ 173 | severity: 'ERROR', 174 | textPayload: `${info.msg} - ${info.error}` 175 | })) 176 | } 177 | }) 178 | } 179 | 180 | process.on('uncaughtException', (err) => { 181 | if (SHOW_LOGS) { 182 | console.log(JSON.stringify({ 183 | severity: 'ERROR', 184 | textPayload: err.toString() 185 | })) 186 | } 187 | 188 | if (opts.keepAlive) { 189 | if (DEBUG) { 190 | console.log('... relaunching') 191 | } 192 | launch() 193 | } 194 | }) 195 | 196 | launch() 197 | } 198 | 199 | module.exports = main 200 | -------------------------------------------------------------------------------- /test/unit/coerce-component_test.js: -------------------------------------------------------------------------------- 1 | const tap = require('tap') 2 | const sinon = require('sinon') 3 | const path = require('path') 4 | const fs = require('fs') 5 | const uuid = require('uuid/v4') 6 | const { paths } = require('../common') 7 | 8 | const coerceComponent = require('../../src/util/coerce-component') 9 | 10 | const testMockComponentModule = (t, compModuleContent, cb) => { 11 | const compPath = path.join(paths.build, uuid() + '.js') 12 | const comp = { path: compPath } 13 | 14 | fs.writeFile(compPath, compModuleContent, (err) => { 15 | if (err) fs.unlink(compPath, t.fail) 16 | 17 | cb(comp) 18 | fs.unlink(compPath, t.end) 19 | }) 20 | } 21 | 22 | tap.test('should fill defaults and reference to module', t => { 23 | const areEquivalent = [ 24 | () => coerceComponent('plotly-graph'), 25 | () => coerceComponent({ name: 'plotly-graph' }), 26 | () => coerceComponent({ name: 'plotly-graph', options: { plotlyJS: 'v1.20.0' } }), 27 | () => coerceComponent({ path: path.join(__dirname, '..', '..', 'src', 'component', 'plotly-graph') }) 28 | ] 29 | 30 | areEquivalent.forEach((fn, i) => { 31 | t.test(`(with input style ${i})`, t => { 32 | const comp = fn() 33 | 34 | t.same(Object.keys(comp).length, 5, '# of keys') 35 | t.equal(comp.name, 'plotly-graph', 'name') 36 | t.equal(path.basename(comp.path), 'plotly-graph', 'path') 37 | t.equal(comp.route, '/plotly-graph', 'route') 38 | t.type(comp._module, 'object', '_module ref') 39 | t.type(comp.options, 'object', 'options ref') 40 | t.end() 41 | }) 42 | }) 43 | 44 | t.end() 45 | }) 46 | 47 | tap.test('should return null on non-string and non-object input', t => { 48 | const shouldFail = ['', false, null, []] 49 | 50 | shouldFail.forEach(d => { 51 | t.test(`(input ${JSON.stringify(d)})`, t => { 52 | t.equal(coerceComponent(d), null) 53 | t.end() 54 | }) 55 | }) 56 | 57 | t.end() 58 | }) 59 | 60 | tap.test('should return null when path to component is not found', t => { 61 | t.test('when component has no *path* or *name* key', t => { 62 | const shouldFail = [{}, { nopath: 'p', noname: 'n' }] 63 | 64 | shouldFail.forEach(d => { 65 | t.test(`(input ${JSON.stringify(d)})`, t => { 66 | t.equal(coerceComponent(d), null) 67 | t.end() 68 | }) 69 | }) 70 | 71 | t.end() 72 | }) 73 | 74 | t.test('when *path* does not resolve', t => { 75 | t.equal(coerceComponent({ path: 'not/gonna/work' }), null) 76 | t.end() 77 | }) 78 | 79 | t.end() 80 | }) 81 | 82 | tap.test('should return null if module is invalid', t => { 83 | const content = {} 84 | 85 | content['missing name'] = `module.exports = { 86 | parse: () => {}, 87 | render: () => {}, 88 | convert: () => {} 89 | }` 90 | content['missing parse'] = `module.exports = { 91 | name: 'n', 92 | render: () => {}, 93 | convert: () => {} 94 | }` 95 | content['missing render'] = `module.exports = { 96 | name: 'n', 97 | parse: () => {}, 98 | convert: () => {} 99 | }` 100 | content['missing convert'] = `module.exports = { 101 | name: 'n', 102 | parse: () => {}, 103 | render: () => {} 104 | }` 105 | 106 | Object.keys(content).forEach(k => { 107 | t.test(`(${k})`, t => { 108 | testMockComponentModule(t, content[k], (comp) => { 109 | t.equal(coerceComponent(comp), null) 110 | }) 111 | }) 112 | }) 113 | 114 | t.end() 115 | }) 116 | 117 | tap.test('should normalize *route*', t => { 118 | const areEquivalent = [ 119 | () => coerceComponent({ name: 'plotly-graph' }), 120 | () => coerceComponent({ name: 'plotly-graph', route: '/plotly-graph' }), 121 | () => coerceComponent({ name: 'plotly-graph', route: 'plotly-graph' }) 122 | ] 123 | 124 | areEquivalent.forEach((fn, i) => { 125 | t.test(`(with input style ${i})`, t => { 126 | const comp = fn() 127 | t.equal(comp.route, '/plotly-graph') 128 | t.end() 129 | }) 130 | }) 131 | 132 | t.end() 133 | }) 134 | 135 | tap.test('should set *inject* in noop if not set', t => { 136 | const content = `module.exports = { 137 | name: 'n', 138 | ping: () => {}, 139 | parse: () => {}, 140 | render: () => {}, 141 | convert: () => {} 142 | }` 143 | 144 | testMockComponentModule(t, content, (_comp) => { 145 | const comp = coerceComponent(_comp) 146 | 147 | t.type(comp._module.inject, 'function') 148 | }) 149 | }) 150 | 151 | tap.test('should log info on debug', t => { 152 | t.beforeEach((done) => { 153 | sinon.stub(console, 'warn') 154 | done() 155 | }) 156 | 157 | t.afterEach((done) => { 158 | console.warn.restore() 159 | done() 160 | }) 161 | 162 | t.test('should log when non-string/non-object component are passed', t => { 163 | coerceComponent(null, { debug: true }) 164 | t.ok(console.warn.calledOnce) 165 | t.match(console.warn.args[0], /^non-string, non-object component passed/) 166 | t.end() 167 | }) 168 | 169 | t.test('should log when path to component is not set', t => { 170 | coerceComponent({ nopath: 'p' }, { debug: true }) 171 | t.ok(console.warn.calledOnce) 172 | t.match(console.warn.args[0], /^path to component not found/) 173 | t.end() 174 | }) 175 | 176 | t.test('should log MODULE_NOT_FOUND error when *require(comp.path)* fails', t => { 177 | coerceComponent({ path: 'not/gonna/work' }, { debug: true }) 178 | t.ok(console.warn.calledOnce) 179 | t.match(console.warn.args[0][0].code, /MODULE_NOT_FOUND/) 180 | t.end() 181 | }) 182 | 183 | t.test('should log *invalid component module* when a required method is missing', t => { 184 | const content = `module.exports = { 185 | name: 'n', 186 | render: () => {}, 187 | convert: () => {} 188 | }` 189 | 190 | testMockComponentModule(t, content, (comp) => { 191 | coerceComponent(comp, { debug: true }) 192 | t.ok(console.warn.calledOnce) 193 | t.match(console.warn.args[0], /^invalid component module/) 194 | }) 195 | }) 196 | 197 | t.end() 198 | }) 199 | -------------------------------------------------------------------------------- /deployment/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:xenial 2 | 3 | #################### 4 | # Install node and dependencies 5 | # From: https://github.com/nodejs/docker-node/blob/master/6.11/Dockerfile 6 | 7 | RUN apt-get update && apt-get install -y --no-install-recommends \ 8 | gnupg curl ca-certificates xz-utils wget libgtk2.0-0 libgconf-2-4 \ 9 | libxss1 \ 10 | && rm -rf /var/lib/apt/lists/* && apt-get clean 11 | 12 | # From https://github.com/nodejs/docker-node/blob/main/14/buster/Dockerfile 13 | RUN groupadd --gid 1000 node \ 14 | && useradd --uid 1000 --gid node --shell /bin/bash --create-home node 15 | 16 | ENV NODE_VERSION 8.17.0 17 | 18 | RUN ARCH= && dpkgArch="$(dpkg --print-architecture)" \ 19 | && case "${dpkgArch##*-}" in \ 20 | amd64) ARCH='x64';; \ 21 | ppc64el) ARCH='ppc64le';; \ 22 | s390x) ARCH='s390x';; \ 23 | arm64) ARCH='arm64';; \ 24 | armhf) ARCH='armv7l';; \ 25 | i386) ARCH='x86';; \ 26 | *) echo "unsupported architecture"; exit 1 ;; \ 27 | esac \ 28 | # gpg keys listed at https://github.com/nodejs/node#release-keys 29 | && set -ex \ 30 | && for key in \ 31 | 4ED778F539E3634C779C87C6D7062848A1AB005C \ 32 | 94AE36675C464D64BAFA68DD7434390BDBE9B9C5 \ 33 | 74F12602B6F1C4E913FAA37AD3A89613643B6201 \ 34 | 71DCFD284A79C3B38668286BC97EC7A07EDE3FC1 \ 35 | 8FCCA13FEF1D0C2E91008E09770F7A9A5AE15600 \ 36 | C4F0DFFF4E8C1A8236409D08E73BC641CC11F4C8 \ 37 | C82FA3AE1CBEDC6BE46B9360C43CEC45C17AB93C \ 38 | DD8F2338BAE7501E3DD5AC78C273792F7D83545D \ 39 | A48C2BEE680E841632CD4E44F07496B3EB3C1762 \ 40 | 108F52B48DB57BB0CC439B2997B01419BD92F80A \ 41 | B9E2F5981AA6E0CD28160D9FF13993A75599653C \ 42 | ; do \ 43 | # From https://github.com/nodejs/docker-node/issues/1500#issuecomment-865693819 44 | gpg --batch --keyserver hkp://keyserver.ubuntu.com --recv-keys "$key" || \ 45 | gpg --batch --keyserver hkp://keys.openpgp.org --recv-keys "$key" ; \ 46 | done \ 47 | && curl -fsSLO --compressed "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-$ARCH.tar.xz" \ 48 | && curl -fsSLO --compressed "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt.asc" \ 49 | && gpg --batch --decrypt --output SHASUMS256.txt SHASUMS256.txt.asc \ 50 | && grep " node-v$NODE_VERSION-linux-$ARCH.tar.xz\$" SHASUMS256.txt | sha256sum -c - \ 51 | && tar -xJf "node-v$NODE_VERSION-linux-$ARCH.tar.xz" -C /usr/local --strip-components=1 --no-same-owner \ 52 | && rm "node-v$NODE_VERSION-linux-$ARCH.tar.xz" SHASUMS256.txt.asc SHASUMS256.txt \ 53 | && ln -s /usr/local/bin/node /usr/local/bin/nodejs \ 54 | # smoke tests 55 | && node --version \ 56 | && npm --version 57 | 58 | #################### 59 | # Download fonts 60 | 61 | RUN apt-get update -y && \ 62 | apt-get install -y \ 63 | fontconfig \ 64 | fonts-ipafont-gothic \ 65 | fonts-ipafont-mincho \ 66 | subversion \ 67 | && \ 68 | rm -rf /var/lib/apt/lists/* && apt-get clean && \ 69 | cd /usr/share/fonts/truetype && \ 70 | for font in \ 71 | https://github.com/google/fonts/trunk/apache/droidsansmono \ 72 | https://github.com/google/fonts/trunk/apache/droidsans \ 73 | https://github.com/google/fonts/trunk/apache/droidserif \ 74 | https://github.com/google/fonts/trunk/apache/roboto \ 75 | https://github.com/google/fonts/trunk/apache/opensans \ 76 | https://github.com/google/fonts/trunk/ofl/gravitasone \ 77 | https://github.com/google/fonts/trunk/ofl/oldstandardtt \ 78 | https://github.com/google/fonts/trunk/ofl/ptsansnarrow \ 79 | https://github.com/google/fonts/trunk/ofl/raleway \ 80 | https://github.com/google/fonts/trunk/ofl/overpass \ 81 | ; do \ 82 | svn checkout $font ; \ 83 | done && \ 84 | mkdir /usr/share/fonts/user && \ 85 | fc-cache -fv && apt-get --auto-remove -y remove subversion 86 | 87 | #################### 88 | # Download mathjax (same version as plotly.js extras/) 89 | 90 | RUN mkdir /mathjax && cd /mathjax && \ 91 | curl -L https://github.com/mathjax/MathJax/archive/2.3.0.tar.gz \ 92 | | tar -xvzf - --strip-components=2 MathJax-2.3.0/unpacked 93 | 94 | #################### 95 | # Install and configure monit 96 | COPY deployment/monitrc /etc 97 | RUN cd /opt && \ 98 | wget -q -O - https://mmonit.com/monit/dist/binary/5.25.1/monit-5.25.1-linux-x64.tar.gz | \ 99 | tar xvzf - && \ 100 | ln -s monit-* monit && \ 101 | chmod 600 /etc/monitrc 102 | 103 | #################### 104 | # Install latest stable Inkscape 105 | RUN apt-get update && apt-get install -y software-properties-common python-software-properties \ 106 | && add-apt-repository -y ppa:inkscape.dev/stable \ 107 | && apt-get update && apt-get install -y inkscape=0.92.5+68~ubuntu16.04.1 \ 108 | && rm -rf /var/lib/apt/lists/* && apt-get clean 109 | 110 | # Copy Inkscape defaults 111 | COPY deployment/preferences.xml /root/.config/inkscape/ 112 | 113 | #################### 114 | # Download geo-assets (same version as plotly.js extras/) 115 | 116 | RUN wget https://raw.githubusercontent.com/plotly/plotly.js/master/dist/plotly-geo-assets.js -O /plotly-geo-assets.js 117 | 118 | #################### 119 | # Configure ImageMagick policy 120 | 121 | COPY deployment/ImageMagickPolicy.xml /etc/ImageMagick-6/policy.xml 122 | 123 | #################### 124 | # Copy and set up Orca 125 | 126 | RUN apt-get update && apt-get install -y chromium-browser fonts-liberation xvfb poppler-utils git libxss1 \ 127 | && rm -rf /var/lib/apt/lists/* && apt-get clean 128 | 129 | COPY package.json /var/www/image-exporter/ 130 | COPY package-lock.json /var/www/image-exporter/ 131 | WORKDIR /var/www/image-exporter 132 | RUN npm install && mkdir build 133 | COPY bin /var/www/image-exporter/bin 134 | COPY src /var/www/image-exporter/src 135 | 136 | #################### 137 | # Add entrypoint script 138 | COPY deployment/entrypoint.sh / 139 | # Add server script 140 | COPY deployment/run_server / 141 | # Symlink to entrypoint 142 | RUN ln -s /entrypoint.sh /usr/bin/orca 143 | 144 | EXPOSE 9091 145 | ENTRYPOINT ["/entrypoint.sh"] 146 | CMD ["--mathjax", "/mathjax/MathJax.js", "--topojson", "/plotly-geo-assets.js"] 147 | -------------------------------------------------------------------------------- /test/integration/orca_serve_test.js: -------------------------------------------------------------------------------- 1 | const tap = require('tap') 2 | const Application = require('spectron').Application 3 | 4 | const path = require('path') 5 | const fs = require('fs') 6 | const request = require('request') 7 | const readChunk = require('read-chunk') 8 | const fileType = require('file-type') 9 | const delay = require('delay') 10 | 11 | const { paths } = require('../common') 12 | const PORT = 9109 13 | const SERVER_URL = `http://localhost:${PORT}` 14 | 15 | const app = new Application({ 16 | path: paths.bin, 17 | args: ['serve', '--port', PORT] 18 | }) 19 | 20 | tap.tearDown(() => { 21 | if (app && app.isRunning()) { 22 | app.stop() 23 | } 24 | }) 25 | 26 | tap.test('should launch', t => { 27 | app.start() 28 | // Wait for HTTP API to boot up 29 | .then(() => { return delay(2000) }) 30 | .then(() => { 31 | app.client.getWindowCount().then(cnt => { 32 | t.equal(cnt, 6) 33 | t.end() 34 | }) 35 | }) 36 | }) 37 | 38 | tap.test('should reply pong to ping POST', t => { 39 | request.post(SERVER_URL + '/ping', (err, res, body) => { 40 | if (err) t.fail(err) 41 | 42 | t.equal(res.statusCode, 200, 'code') 43 | t.equal(body, 'pong', 'body') 44 | t.end() 45 | }) 46 | }) 47 | 48 | tap.test('should work for *plotly-graph* component', t => { 49 | request({ 50 | method: 'POST', 51 | url: SERVER_URL + '/', 52 | body: JSON.stringify({ 53 | figure: { 54 | layout: { 55 | data: [{ y: [1, 2, 1] }] 56 | } 57 | } 58 | }) 59 | }, (err, res, body) => { 60 | if (err) t.fail(err) 61 | 62 | t.equal(res.statusCode, 200, 'code') 63 | t.type(body, 'string') 64 | t.end() 65 | }) 66 | 67 | // more tests using: https://github.com/image-size/image-size 68 | }) 69 | 70 | tap.test('should work for *plotly-thumbnail* component', t => { 71 | request({ 72 | method: 'POST', 73 | url: SERVER_URL + '/thumbnail', 74 | body: JSON.stringify({ 75 | figure: { 76 | layout: { 77 | data: [{ y: [1, 2, 1] }] 78 | } 79 | } 80 | }) 81 | }, (err, res, body) => { 82 | if (err) t.fail(err) 83 | 84 | t.equal(res.statusCode, 200, 'code') 85 | t.type(body, 'string', 'body type') 86 | t.end() 87 | }) 88 | }) 89 | 90 | tap.test('should work for *plotly-dashboard* component', { timeout: 1e5 }, t => { 91 | t.test('responding with correct status code and body type', t => { 92 | request({ 93 | method: 'POST', 94 | url: SERVER_URL + '/dashboard', 95 | body: JSON.stringify({ 96 | fid: 'some-fid', 97 | url: 'https://chart-studio.plotly.com/dashboard/jackp:17872/embed', 98 | format: 'pdf' 99 | }) 100 | }, (err, res, body) => { 101 | if (err) t.fail(err) 102 | 103 | t.equal(res.statusCode, 200, 'code') 104 | t.type(body, 'string', 'body type') 105 | t.end() 106 | }) 107 | }) 108 | 109 | t.test('piping info write stream', t => { 110 | const outPath = path.join(paths.build, 'dashboard.pdf') 111 | const ws = fs.createWriteStream(outPath) 112 | 113 | request({ 114 | method: 'POST', 115 | url: SERVER_URL + '/dashboard', 116 | body: JSON.stringify({ 117 | fid: 'some-fid', 118 | url: 'https://chart-studio.plotly.com/dashboard/jackp:17872/embed', 119 | format: 'pdf' 120 | }) 121 | }).on('error', t.fail).pipe(ws) 122 | 123 | ws.on('error', t.fail) 124 | ws.on('finish', () => { 125 | const size = fs.statSync(outPath).size 126 | t.ok(size > 5e3, 'min pdf file size') 127 | t.ok(size < 2e4, 'max pdf file size') 128 | t.ok(fileType(readChunk.sync(outPath, 0, 4100)).mime === 'application/pdf', 'pdf content') 129 | t.end() 130 | }) 131 | }) 132 | 133 | t.end() 134 | }) 135 | 136 | tap.test('should work for *plotly-dashboard-thumbnail* component', t => { 137 | const outPath = path.join(paths.build, 'dashboard-thumbnail.png') 138 | const ws = fs.createWriteStream(outPath) 139 | 140 | request({ 141 | method: 'post', 142 | url: SERVER_URL + '/dashboard-thumbnail', 143 | body: JSON.stringify({ 144 | settings: { backgroundColor: '#d3d3d3' }, 145 | figure: { 146 | layout: { 147 | type: 'split', 148 | first: { 149 | type: 'split', 150 | first: { 151 | type: 'box', 152 | boxType: 'plot', 153 | figure: { 154 | data: [{ 155 | y: [1, 2, 1] 156 | }] 157 | } 158 | }, 159 | second: { 160 | type: 'box', 161 | boxType: 'plot', 162 | figure: { 163 | data: [{ 164 | type: 'bar', 165 | y: [1, 2, 4] 166 | }] 167 | } 168 | } 169 | }, 170 | second: { 171 | type: 'split', 172 | first: { 173 | type: 'box', 174 | boxType: 'plot', 175 | figure: { 176 | data: [{ 177 | type: 'heatmap', 178 | z: [[1, 2, 4], [1, 2, 3]] 179 | }] 180 | } 181 | }, 182 | second: { 183 | type: 'box', 184 | boxType: 'plot', 185 | figure: { 186 | data: [{ 187 | type: 'scatter3d', 188 | x: [1, 2, 3], 189 | y: [1, 2, 3], 190 | z: [1, 2, 1] 191 | }] 192 | } 193 | } 194 | } 195 | } 196 | } 197 | }) 198 | }).on('error', t.fail).pipe(ws) 199 | 200 | ws.on('error', t.fail) 201 | ws.on('finish', () => { 202 | const size = fs.statSync(outPath).size 203 | t.ok(size > 800, 'min png file size') 204 | t.ok(size < 6e4, 'max png file size') 205 | t.ok(fileType(readChunk.sync(outPath, 0, 4100)).mime === 'image/png', 'png content') 206 | t.end() 207 | }) 208 | }) 209 | 210 | tap.test('should teardown', t => { 211 | app.stop() 212 | .catch(t.fail) 213 | .then(t.end) 214 | }) 215 | -------------------------------------------------------------------------------- /bin/graph.js: -------------------------------------------------------------------------------- 1 | const orca = require('../src') 2 | const plotlyGraphCst = require('../src/component/plotly-graph/constants') 3 | 4 | const fs = require('fs') 5 | const path = require('path') 6 | const getStdin = require('get-stdin') 7 | const str = require('string-to-stream') 8 | 9 | const { PLOTLYJS_OPTS_META, extractOpts, formatOptsMeta } = require('./args') 10 | const DEBUG_INFO = '\n leaving window open for debugging' 11 | const CHROME_VERSION = process.versions.chrome 12 | const ELECTRON_VERSION = process.versions.electron 13 | 14 | const OPTS_META = [].concat([{ 15 | name: 'help', 16 | type: 'boolean', 17 | alias: ['h'], 18 | description: 'Displays this message.' 19 | }, { 20 | name: 'output-dir', 21 | type: 'string', 22 | alias: ['d', 'outputDir'], 23 | dflt: process.cwd(), 24 | description: `Sets output directory for the generated images. Defaults to the current working directory.` 25 | }, { 26 | name: 'output', 27 | type: 'string', 28 | alias: ['o'], 29 | description: `Sets output filename. If multiple inputs are provided, then their item index will be appended to the filename.` 30 | }], PLOTLYJS_OPTS_META, [{ 31 | name: 'format', 32 | type: 'string', 33 | alias: ['f'], 34 | dflt: 'png', 35 | description: `Sets the output format (${Object.keys(plotlyGraphCst.contentFormat).join(', ')}) Applies to all output images.` 36 | }, { 37 | name: 'scale', 38 | type: 'string', 39 | dflt: '1', 40 | description: `Sets the image scale. Applies to all output images.` 41 | }, { 42 | name: 'width', 43 | type: 'string', 44 | description: `Sets the image width. If not set, defaults to \`layout.width\` value. Applies to all output images.` 45 | }, { 46 | name: 'height', 47 | type: 'string', 48 | description: `Sets the image height. If not set, defaults to \`layout.height\` value. Applies to all output images.` 49 | }, { 50 | name: 'parallel-limit', 51 | type: 'string', 52 | alias: ['parallelLimit'], 53 | dflt: '1', 54 | description: 'Sets the limit of parallel tasks run.' 55 | }, { 56 | name: 'verbose', 57 | type: 'boolean', 58 | description: 'Turn on verbose logging on stdout.' 59 | }, { 60 | name: 'debug', 61 | type: 'boolean', 62 | description: 'Starts app in debug mode and turn on verbose logs on stdout.' 63 | }]) 64 | 65 | const HELP_MSG = `orca graph 66 | 67 | Usage: 68 | $ orca graph [path/to/json/file(s), URL(s), glob(s), '{"data":[],"layout":{}}'] {options} 69 | $ cat plot.json | orca graph {options} > plot.png 70 | 71 | Options: 72 | ${formatOptsMeta(OPTS_META)}` 73 | 74 | function makeGetItemName (input, opts) { 75 | const output = opts.output 76 | const hasMultipleInput = input.length > 1 77 | 78 | if (output) { 79 | const outputName = path.parse(output).name 80 | return hasMultipleInput 81 | ? (info) => `${outputName}_${info.itemIndex}` 82 | : () => outputName 83 | } else { 84 | return (info) => { 85 | const item = opts._[info.itemIndex] 86 | return fs.existsSync(item) 87 | ? path.parse(item).name 88 | : hasMultipleInput 89 | ? `fig_${info.itemIndex}` 90 | : 'fig' 91 | } 92 | } 93 | } 94 | 95 | function main (args) { 96 | const opts = extractOpts(args, OPTS_META) 97 | const DEBUG = opts.debug 98 | 99 | if (opts.help) { 100 | console.log(HELP_MSG) 101 | process.exit(0) 102 | } 103 | 104 | const fullOutputDir = opts.output 105 | ? path.join(opts.outputDir, path.dirname(opts.output)) 106 | : opts.outputDir 107 | 108 | if (!fs.existsSync(fullOutputDir)) { 109 | fs.mkdirSync(fullOutputDir) 110 | } 111 | 112 | getStdin().then((txt) => { 113 | const hasStdin = !!txt 114 | const pipeToStdOut = hasStdin && !opts.output 115 | const showLogs = !pipeToStdOut && (DEBUG || opts.verbose) 116 | const input = hasStdin ? opts._.concat([txt]) : opts._ 117 | const getItemName = makeGetItemName(input, opts) 118 | 119 | const write = (info, _, done) => { 120 | const itemName = getItemName(info) 121 | const outPath = path.resolve(fullOutputDir, `${itemName}.${info.format}`) 122 | 123 | if (pipeToStdOut) { 124 | str(info.body) 125 | .pipe(process.stdout.on('drain', done)) 126 | } else { 127 | fs.writeFile(outPath, info.body, done) 128 | } 129 | } 130 | 131 | if (input.length === 0) { 132 | console.log('No input given. Run `orca graph --help for more info.') 133 | process.exit(0) 134 | } 135 | 136 | const app = orca.run({ 137 | input: input, 138 | write: write, 139 | debug: DEBUG, 140 | parallelLimit: opts.parallelLimit, 141 | component: { 142 | name: 'plotly-graph', 143 | options: { 144 | plotlyJS: opts.plotly, 145 | mapboxAccessToken: opts['mapbox-access-token'], 146 | mathjax: opts.mathjax, 147 | topojson: opts.topojson, 148 | format: opts.format, 149 | scale: opts.scale, 150 | width: opts.width, 151 | height: opts.height, 152 | safeMode: opts.safeMode, 153 | inkscape: opts.inkscape 154 | } 155 | } 156 | }) 157 | 158 | app.on('after-export', (info) => { 159 | const itemName = getItemName(info) 160 | 161 | if (showLogs) { 162 | console.log(`exported ${itemName}, in ${info.processingTime} ms`) 163 | } 164 | }) 165 | 166 | app.on('export-error', (info) => { 167 | const itemName = getItemName(info) 168 | 169 | if (showLogs) { 170 | console.warn(`export error ${info.code} for ${itemName} - ${info.msg}`) 171 | console.warn(` ${info.error}`) 172 | } 173 | }) 174 | 175 | app.on('after-export-all', (info) => { 176 | const time = info.totalProcessingTime 177 | const timeStr = time > 6e4 ? `${(time / 6e4).toFixed(2)} min` 178 | : time > 1e3 ? `${(time / 1e3).toFixed(2)} sec` 179 | : `${time.toFixed(2)} ms` 180 | 181 | if (DEBUG) { 182 | console.log(DEBUG_INFO) 183 | } 184 | 185 | const msg = `\ndone with code ${info.code} in ${timeStr} - ${info.msg}` 186 | 187 | if (info.code === 0) { 188 | if (showLogs) { 189 | console.log(msg) 190 | } 191 | } else { 192 | console.warn(msg) 193 | if (!DEBUG) { 194 | process.exit(1) 195 | } 196 | } 197 | }) 198 | 199 | app.on('renderer-error', (info) => { 200 | if (showLogs) { 201 | console.warn(`${info.msg} - ${info.error} 202 | Chrome version ${CHROME_VERSION} 203 | Electron version ${ELECTRON_VERSION}`) 204 | } 205 | }) 206 | 207 | if (DEBUG) { 208 | process.on('uncaughtException', (err) => { 209 | console.warn(err) 210 | console.warn(DEBUG_INFO) 211 | }) 212 | } 213 | }) 214 | } 215 | 216 | module.exports = main 217 | -------------------------------------------------------------------------------- /src/component/plotly-graph/parse.js: -------------------------------------------------------------------------------- 1 | const cst = require('./constants') 2 | const isPlainObj = require('is-plain-obj') 3 | const isPositiveNumeric = require('../../util/is-positive-numeric') 4 | const isNonEmptyString = require('../../util/is-non-empty-string') 5 | 6 | const contentFormat = cst.contentFormat 7 | const ACCEPT_HEADER = Object.keys(contentFormat).reduce(function (obj, key) { 8 | obj[ contentFormat[key] ] = key 9 | return obj 10 | }, {}) 11 | 12 | /** plotly-graph parse 13 | * 14 | * @param {object} body : JSON-parsed request body 15 | * - figure 16 | * - format 17 | * - scale (only for plotly.js v.1.31.0 and up) 18 | * - width 19 | * - height 20 | * - encoded 21 | * - fid (figure id) 22 | * 0r: 23 | * - data 24 | * - layout 25 | * @param {object} req: HTTP request 26 | * @param {object} _opts : component options 27 | * - format 28 | * - scale (only for plotly.js v.1.31.0 and up) 29 | * - width 30 | * - height 31 | * - safeMode 32 | * @param {function} sendToRenderer 33 | * - errorCode 34 | * - result 35 | */ 36 | function parse (body, req, _opts, sendToRenderer) { 37 | const result = {} 38 | 39 | const errorOut = (code, extra) => { 40 | result.msg = `${cst.statusMsg[code]}` 41 | if (extra) result.msg = `${result.msg} (${extra})` 42 | sendToRenderer(code, result) 43 | } 44 | 45 | let figure 46 | let opts 47 | 48 | // to support both 'serve' requests (figure/format/../) 49 | // and 'run' body (data/layout) structures 50 | if (body.figure) { 51 | figure = body.figure 52 | opts = body 53 | } else { 54 | figure = body 55 | opts = _opts 56 | } 57 | 58 | result.scale = isPositiveNumeric(opts.scale) ? Number(opts.scale) : cst.dflt.scale 59 | result.fid = isNonEmptyString(opts.fid) ? opts.fid : null 60 | result.encoded = !!opts.encoded 61 | 62 | if (isNonEmptyString(opts.format)) { 63 | if (cst.contentFormat[opts.format]) { 64 | result.format = opts.format 65 | } else { 66 | return errorOut(400, 'wrong format') 67 | } 68 | } else { 69 | // HTTP content-negotiation 70 | if (req && req.headers && req.headers.accept && ACCEPT_HEADER.hasOwnProperty(req.headers.accept)) { 71 | result.format = ACCEPT_HEADER[req.headers.accept] 72 | } else { 73 | result.format = cst.dflt.format 74 | } 75 | } 76 | 77 | if (!isPlainObj(figure)) { 78 | return errorOut(400, 'non-object figure') 79 | } 80 | 81 | if (!figure.data && !figure.layout) { 82 | return errorOut(400, 'no \'data\' and no \'layout\' in figure') 83 | } 84 | 85 | result.figure = {} 86 | 87 | if ('data' in figure) { 88 | if (Array.isArray(figure.data)) { 89 | result.figure.data = figure.data 90 | } else { 91 | return errorOut(400, 'non-array figure data') 92 | } 93 | } else { 94 | result.figure.data = [] 95 | } 96 | 97 | if ('layout' in figure) { 98 | if (isPlainObj(figure.layout)) { 99 | result.figure.layout = figure.layout 100 | } else { 101 | return errorOut(400, 'non-object figure layout') 102 | } 103 | } else { 104 | result.figure.layout = {} 105 | } 106 | 107 | result.width = parseDim(result, opts, 'width') 108 | result.height = parseDim(result, opts, 'height') 109 | 110 | if (_opts.safeMode && willFigureHang(result)) { 111 | return errorOut(400, 'figure data is likely to make exporter hang, rejecting request') 112 | } 113 | 114 | sendToRenderer(null, result) 115 | } 116 | 117 | function parseDim (result, opts, dim) { 118 | const layout = result.figure.layout 119 | 120 | if (isPositiveNumeric(opts[dim])) { 121 | return Number(opts[dim]) 122 | } else if (isPositiveNumeric(layout[dim]) && !layout.autosize) { 123 | return Number(layout[dim]) 124 | } else { 125 | return cst.dflt[dim] 126 | } 127 | } 128 | 129 | function willFigureHang (result) { 130 | const data = result.figure.data 131 | 132 | // cap the number of traces 133 | if (data.length > 200) return true 134 | 135 | let maxPtBudget = 0 136 | 137 | for (let i = 0; i < data.length; i++) { 138 | const trace = data[i] || {} 139 | 140 | // cap the number of points using a budget 141 | maxPtBudget += estimateDataLength(trace) / maxPtsPerTrace(trace) 142 | if (maxPtBudget > 1) return true 143 | } 144 | } 145 | 146 | // Consider the array of maximum length as a proxy to determine 147 | // the number of points to be drawn. In general, this estimate 148 | // can be (much) smaller than the true number of points plotted 149 | // when it does not match the length of the other coordinate arrays. 150 | function findMaxArrayLength (cont) { 151 | const arrays = Object.keys(cont) 152 | .filter(k => Array.isArray(cont[k])) 153 | .map(k => cont[k]) 154 | 155 | const lengths = arrays.map(arr => { 156 | if (Array.isArray(arr[0])) { 157 | // 2D array case 158 | return arr.reduce((a, r) => a + r.length, 0) 159 | } else { 160 | return arr.length 161 | } 162 | }) 163 | 164 | return Math.max(0, ...lengths) 165 | } 166 | 167 | function estimateDataLength (trace) { 168 | const topLevel = findMaxArrayLength(trace) 169 | let dimLevel = 0 170 | let cellLevel = 0 171 | 172 | // special case for e.g. parcoords and splom traces 173 | if (Array.isArray(trace.dimensions)) { 174 | dimLevel = trace.dimensions 175 | .map(findMaxArrayLength) 176 | .reduce((a, v) => a + v) 177 | } 178 | 179 | // special case for e.g. table traces 180 | if (isPlainObj(trace.cells)) { 181 | cellLevel = findMaxArrayLength(trace.cells) 182 | } 183 | 184 | return Math.max(topLevel, dimLevel, cellLevel) 185 | } 186 | 187 | function maxPtsPerTrace (trace) { 188 | const type = trace.type || 'scatter' 189 | 190 | switch (type) { 191 | case 'scattergl': 192 | case 'splom': 193 | case 'pointcloud': 194 | case 'table': 195 | return 1e7 196 | 197 | case 'scatterpolargl': 198 | case 'heatmap': 199 | case 'heatmapgl': 200 | return 1e6 201 | 202 | case 'scatter3d': 203 | case 'surface': 204 | return 5e5 205 | 206 | case 'mesh3d': 207 | if ('alphahull' in trace && Number(trace.alphahull) >= 0) { 208 | return 1000 209 | } else { 210 | return 5e5 211 | } 212 | 213 | case 'parcoords': 214 | return 5e5 215 | case 'scattermapbox': 216 | return 5e5 217 | 218 | case 'histogram': 219 | case 'histogram2d': 220 | case 'histogram2dcontour': 221 | return 1e6 222 | 223 | case 'box': 224 | if (trace.boxpoints === 'all') { 225 | return 5e4 226 | } else { 227 | return 1e6 228 | } 229 | case 'violin': 230 | if (trace.points === 'all') { 231 | return 5e4 232 | } else { 233 | return 1e6 234 | } 235 | 236 | default: 237 | return 5e4 238 | } 239 | } 240 | 241 | module.exports = parse 242 | --------------------------------------------------------------------------------