├── .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 |
--------------------------------------------------------------------------------