Repository: bokeh/bokeh

Issue:
Headless static (svg, png) image generation 
Enable generation of PNG images directly on the server, similar to the Vega.js headless mode (https://github.com/trifacta/vega/wiki/Headless-Mode).


Discussion:

Patch:
diff --git a/bokeh/core/enums.py b/bokeh/core/enums.py
index 107cf475c..2087556b1 100644
--- a/bokeh/core/enums.py
+++ b/bokeh/core/enums.py
@@ -229,6 +229,9 @@ NumeralLanguage = enumeration("be-nl", "chs", "cs", "da-dk", "de-ch", "de", "en"
                               "fr", "hu", "it", "ja", "nl-nl", "pl", "pt-br",
                               "pt-pt", "ru", "ru-UA", "sk", "th", "tr", "uk-UA")
 
+#: Specify an output backend to render a plot area onto
+OutputBackend = enumeration("canvas", "svg")
+
 #: Specify a position in the render order for a Bokeh renderer
 RenderLevel = enumeration("image", "underlay", "glyph", "annotation", "overlay")
 
diff --git a/bokeh/models/plots.py b/bokeh/models/plots.py
index bd1ba3913..a7c948fc4 100644
--- a/bokeh/models/plots.py
+++ b/bokeh/models/plots.py
@@ -5,7 +5,7 @@ from __future__ import absolute_import
 
 from six import string_types
 
-from ..core.enums import Location
+from ..core.enums import Location, OutputBackend
 from ..core.properties import Bool, Dict, Enum, Include, Instance, Int, List, Override, String
 from ..core.property_mixins import LineProps, FillProps
 from ..core.query import find
@@ -697,3 +697,7 @@ class Plot(LayoutDOM):
     Whether WebGL is enabled for this plot. If True, the glyphs that
     support this will render via WebGL instead of the 2D canvas.
     """)
+
+    output_backend = Enum(OutputBackend, default="canvas", help="""
+    Specify the output backend for the plot area. Default is HTML5 Canvas.
+    """)
diff --git a/bokehjs/package.json b/bokehjs/package.json
index 730fa9b01..de29eb92f 100644
--- a/bokehjs/package.json
+++ b/bokehjs/package.json
@@ -125,6 +125,7 @@
     "jquery-mousewheel": "^3.1.12",
     "jquery-ui": "~1.10.5",
 
+    "canvas2svg": "https://github.com/bokeh/canvas2svg#v1.0.20",
     "numbro": "https://github.com/bokeh/numbro.git#e1b6c52",
     "kiwi": "https://github.com/bokeh/kiwi.git#6941045",
     "root-require": "^0.3.1"
diff --git a/bokehjs/src/coffee/core/enums.coffee b/bokehjs/src/coffee/core/enums.coffee
index c556fbe0e..b6a578105 100644
--- a/bokehjs/src/coffee/core/enums.coffee
+++ b/bokehjs/src/coffee/core/enums.coffee
@@ -24,6 +24,8 @@ export LegendLocation = [
 
 export Orientation = ["vertical", "horizontal"]
 
+export OutputBackend = ["canvas", "svg"]
+
 export RenderLevel = ["image", "underlay", "glyph", "annotation", "overlay"]
 
 export RenderMode = ["canvas", "css"]
diff --git a/bokehjs/src/coffee/core/properties.coffee b/bokehjs/src/coffee/core/properties.coffee
index 663b90967..86a5590ed 100644
--- a/bokehjs/src/coffee/core/properties.coffee
+++ b/bokehjs/src/coffee/core/properties.coffee
@@ -194,6 +194,8 @@ export class LegendLocation extends enum_prop("LegendLocation", enums.LegendLoca
 
 export class Location extends enum_prop("Location", enums.Location)
 
+export class OutputBackend extends enum_prop("OutputBackend", enums.OutputBackend)
+
 export class Orientation extends enum_prop("Orientation", enums.Orientation)
 
 export class TextAlign extends enum_prop("TextAlign", enums.TextAlign)
diff --git a/bokehjs/src/coffee/core/util/canvas.coffee b/bokehjs/src/coffee/core/util/canvas.coffee
index 327babe21..1f699691b 100644
--- a/bokehjs/src/coffee/core/util/canvas.coffee
+++ b/bokehjs/src/coffee/core/util/canvas.coffee
@@ -69,8 +69,10 @@ export fixup_ctx = (ctx) ->
   fixup_measure_text(ctx)
   fixup_ellipse(ctx)
 
-export get_scale_ratio = (ctx, hidpi) ->
-  if hidpi
+export get_scale_ratio = (ctx, hidpi, backend) ->
+  if backend == "svg"
+    return 1
+  else if hidpi
     devicePixelRatio = window.devicePixelRatio || 1
     backingStoreRatio = ctx.webkitBackingStorePixelRatio ||
                         ctx.mozBackingStorePixelRatio ||
diff --git a/bokehjs/src/coffee/models/canvas/canvas.coffee b/bokehjs/src/coffee/models/canvas/canvas.coffee
index 990985b8d..54b451553 100644
--- a/bokehjs/src/coffee/models/canvas/canvas.coffee
+++ b/bokehjs/src/coffee/models/canvas/canvas.coffee
@@ -8,6 +8,8 @@ import {div, canvas} from "core/dom"
 import {isEqual} from "core/util/eq"
 import {fixup_ctx, get_scale_ratio} from "core/util/canvas"
 
+import * as canvas2svg from "canvas2svg"
+
 # fixes up a problem with some versions of IE11
 # ref: http://stackoverflow.com/questions/22062313/imagedata-set-in-internetexplorer
 if window.CanvasPixelArray?
@@ -24,11 +26,16 @@ export class CanvasView extends DOMView
     @map_el      = if @model.map then @el.appendChild(div({class: "bk-canvas-map"})) else null
     @events_el   = @el.appendChild(div({class: "bk-canvas-events"}))
     @overlays_el = @el.appendChild(div({class: "bk-canvas-overlays"}))
-    @canvas_el   = @el.appendChild(canvas({class: "bk-canvas"}))
 
-    # create the canvas context that gets passed around for drawing
-    @ctx = @get_ctx()
+    switch @model.output_backend
+      when "canvas"
+        @canvas_el = @el.appendChild(canvas({class: "bk-canvas"}))
+        @_ctx = @canvas_el.getContext('2d')
+      when "svg"
+        @_ctx = new canvas2svg()
+        @canvas_el = @el.appendChild(@_ctx.getSvg())
 
+    @ctx = @get_ctx()
     # work around canvas incompatibilities
     fixup_ctx(@ctx)
 
@@ -37,10 +44,10 @@ export class CanvasView extends DOMView
 
     @connect(@solver.layout_reset, () => @_add_constraints())
 
-  get_canvas_element: () -> @canvas_el
+  # Method exists so that context can be stubbed in unit tests
+  get_ctx: () -> return @_ctx
 
-  get_ctx: () ->
-    return @canvas_el.getContext('2d')
+  get_canvas_element: () -> return @canvas_el
 
   prepare_canvas: () ->
     # Ensure canvas has the correct size, taking HIDPI into account
@@ -50,7 +57,7 @@ export class CanvasView extends DOMView
     @el.style.width = "#{width}px"
     @el.style.height = "#{height}px"
 
-    pixel_ratio = get_scale_ratio(@ctx, @model.use_hidpi)
+    pixel_ratio = get_scale_ratio(@ctx, @model.use_hidpi, @model.output_backend)
     @model.pixel_ratio = pixel_ratio
 
     @canvas_el.style.width = "#{width}px"
@@ -108,6 +115,7 @@ export class Canvas extends LayoutCanvas
     initial_height: [ p.Number         ]
     use_hidpi:      [ p.Boolean, true  ]
     pixel_ratio:    [ p.Number,  1     ]
+    output_backend: [ p.OutputBackend, "canvas"]
   }
 
   initialize: (attrs, options) ->
diff --git a/bokehjs/src/coffee/models/plots/plot.coffee b/bokehjs/src/coffee/models/plots/plot.coffee
index 6a7d4aca4..d5772f8bd 100644
--- a/bokehjs/src/coffee/models/plots/plot.coffee
+++ b/bokehjs/src/coffee/models/plots/plot.coffee
@@ -339,6 +339,7 @@ export class Plot extends LayoutDOM
 
       webgl:             [ p.Bool,     false                  ]
       hidpi:             [ p.Bool,     true                   ]
+      output_backend:    [ p.OutputBackend, "canvas"          ]
 
       min_border:        [ p.Number,   5                      ]
       min_border_top:    [ p.Number,   null                   ]
diff --git a/bokehjs/src/coffee/models/plots/plot_canvas.coffee b/bokehjs/src/coffee/models/plots/plot_canvas.coffee
index b642e3bab..8e417f510 100644
--- a/bokehjs/src/coffee/models/plots/plot_canvas.coffee
+++ b/bokehjs/src/coffee/models/plots/plot_canvas.coffee
@@ -613,11 +613,10 @@ export class PlotCanvasView extends DOMView
   _render_levels: (ctx, levels, clip_region) ->
     ctx.save()
 
-    if clip_region?
+    if clip_region? and @model.plot.output_backend == "canvas"
       ctx.beginPath()
       ctx.rect.apply(ctx, clip_region)
       ctx.clip()
-      ctx.beginPath()
 
     indices = {}
     for renderer, i in @model.plot.renderers
@@ -646,17 +645,28 @@ export class PlotCanvasView extends DOMView
       ctx.fillRect(frame_box...)
 
   save: (name) ->
-    canvas = @canvas_view.get_canvas_element()
-
-    if canvas.msToBlob?
-      blob = canvas.msToBlob()
-      window.navigator.msSaveBlob(blob, name)
-    else
-      link = document.createElement('a')
-      link.href = canvas.toDataURL('image/png')
-      link.download = name
-      link.target = "_blank"
-      link.dispatchEvent(new MouseEvent('click'))
+    if @model.plot.output_backend == "canvas"
+      canvas = @canvas_view.get_canvas_element()
+      if canvas.msToBlob?
+        blob = canvas.msToBlob()
+        window.navigator.msSaveBlob(blob, name)
+      else
+        link = document.createElement('a')
+        link.href = canvas.toDataURL('image/png')
+        link.download = name + ".png"
+        link.target = "_blank"
+        link.dispatchEvent(new MouseEvent('click'))
+    else if @model.plot.output_backend == "svg"
+      svg = @canvas_view.ctx.getSerializedSvg(true)
+      svgblob = new Blob([svg], {type:'text/plain'})
+      downloadLink = document.createElement("a")
+      downloadLink.download =  name + ".svg"
+      downloadLink.innerHTML = "Download svg"
+      downloadLink.href = window.URL.createObjectURL(svgblob)
+      downloadLink.onclick = (event) -> document.body.removeChild(event.target)
+      downloadLink.style.display = "none"
+      document.body.appendChild(downloadLink)
+      downloadLink.click()
 
 export class PlotCanvas extends LayoutDOM
   type: 'PlotCanvas'
@@ -669,7 +679,8 @@ export class PlotCanvas extends LayoutDOM
       map: @use_map ? false
       initial_width: @plot.plot_width,
       initial_height: @plot.plot_height,
-      use_hidpi: @plot.hidpi
+      use_hidpi: @plot.hidpi,
+      output_backend: @plot.output_backend
     })
 
     @frame = new CartesianFrame({
diff --git a/bokehjs/src/coffee/models/tools/actions/save_tool.coffee b/bokehjs/src/coffee/models/tools/actions/save_tool.coffee
index c6f225dd7..83b272b4b 100644
--- a/bokehjs/src/coffee/models/tools/actions/save_tool.coffee
+++ b/bokehjs/src/coffee/models/tools/actions/save_tool.coffee
@@ -2,7 +2,7 @@ import {ActionTool, ActionToolView} from "./action_tool"
 
 export class SaveToolView extends ActionToolView
 
-  doit: () -> @plot_view.save("bokeh_plot.png")
+  doit: () -> @plot_view.save("bokeh_plot")
 
 export class SaveTool extends ActionTool
   default_view: SaveToolView


Test patch:
diff --git a/bokeh/core/tests/test_enums.py b/bokeh/core/tests/test_enums.py
index 50a60d0ff..4f52314fd 100644
--- a/bokeh/core/tests/test_enums.py
+++ b/bokeh/core/tests/test_enums.py
@@ -62,6 +62,7 @@ def test_enums_contents():
         'NamedColor',
         'NumeralLanguage',
         'Orientation',
+        'OutputBackend',
         'PaddingUnits',
         'Palette',
         'RenderLevel',