Repository: bokeh/bokeh

Issue:
[BUG] export_svgs() function creats two files (one is empty)

#### software version info (bokeh, python)
Python: 3.6.10
Bokeh: 2.1.1

#### Description of expected behavior and the observed behavior
While generating a svg export in bokeh 2.1.1 an unnecessary empty file is created, too. The second file gets the same name as the first one with a suffix "_1" right before the file extension.
It was expected that only on file is created.

#### Minimal example
```
import numpy as np
import bokeh.plotting as bk_plt
import bokeh.io as bk_io

bk_plt.output_notebook()

N = 500
x = np.linspace(0, 10, N)
y = np.linspace(0, 10, N)
xx, yy = np.meshgrid(x, y)
d = np.sin(xx)*np.cos(yy)

p2 = bk_plt.figure(x_range=(0, 10), y_range=(0, 10))
p2.image(image=[d], x=0, y=0, dw=10, dh=10, palette="Spectral11", alpha = 0.5)
p2.output_backend = 'svg'
bk_io.export_svgs(p2, filename = 'asdf.svg')
```

#### Screenshots
![code_svg_exports](https://user-images.githubusercontent.com/68053396/87018192-346b6300-c1d1-11ea-97bd-2b22786b7fd3.png)
![svg_exports](https://user-images.githubusercontent.com/68053396/87017021-bfe3f480-c1cf-11ea-9f6e-e6ee4fd746f5.png)


Discussion:
Broken in PR #9948. Since we have a canvas per layer, then multiple SVG files are produced, because of this code:

https://github.com/bokeh/bokeh/blob/253bc4e012b3959c5ab3b5d694f3437f340d6e8e/bokeh/io/export.py#L350

Technically a regression, but even adding a SVG icon would break this.
Together with PR #9964, we will be able to generate a single SVG in most cases.

Resolving patch:
diff --git a/bokeh/io/export.py b/bokeh/io/export.py
--- a/bokeh/io/export.py
+++ b/bokeh/io/export.py
@@ -346,13 +346,23 @@ def _maximize_viewport(web_driver: "WebDriver") -> Tuple[int, int, int]:
     return viewport_size
 
 _SVG_SCRIPT = """
-var serialized_svgs = [];
-var svgs = document.getElementsByClassName('bk-root')[0].getElementsByTagName("svg");
-for (var i = 0; i < svgs.length; i++) {
-    var source = (new XMLSerializer()).serializeToString(svgs[i]);
-    serialized_svgs.push(source);
-};
-return serialized_svgs
+const {LayoutDOMView} = Bokeh.require("models/layouts/layout_dom")
+const {PlotView} = Bokeh.require("models/plots/plot")
+
+function* collect_svgs(views) {
+  for (const view of views) {
+    if (view instanceof LayoutDOMView) {
+      yield* collect_svgs(view.child_views.values())
+    }
+    if (view instanceof PlotView && view.model.output_backend == "svg") {
+      const {ctx} = view.canvas_view.compose()
+      yield ctx.get_serialized_svg(true)
+    }
+  }
+}
+
+const root_views = Object.values(Bokeh.index)
+return [...collect_svgs(root_views)]
 """
 
 _WAIT_SCRIPT = """


Test patch:
diff --git a/bokehjs/test/unit/models/canvas/canvas.ts b/bokehjs/test/unit/models/canvas/canvas.ts
--- a/bokehjs/test/unit/models/canvas/canvas.ts
+++ b/bokehjs/test/unit/models/canvas/canvas.ts
@@ -34,5 +34,57 @@ describe("Canvas", () => {
       expect(composite_layer.ctx.canvas.width).to.be.equal(1800)
       expect(composite_layer.ctx.canvas.height).to.be.equal(1800)
     })
+
+    it("with SVG backend", async () => {
+      const canvas = new Canvas({output_backend: "svg", hidpi: true})
+      const canvas_view = await build_view(canvas)
+
+      canvas_view.resize(600, 600)
+      canvas_view.primary.prepare()
+      canvas_view.overlays.prepare()
+
+      canvas_view.primary.ctx.fillStyle = "blue"
+      canvas_view.primary.ctx.fillRect(100, 200, 300, 400)
+
+      canvas_view.overlays.ctx.fillStyle = "green"
+      canvas_view.overlays.ctx.fillRect(300, 400, 200, 100)
+
+      // TODO: expose SVG canvas/context type information
+      const primary_svg = (canvas_view.primary.ctx as any).get_serialized_svg(true)
+      expect(primary_svg).to.be.equal(`\
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="600" height="600">\
+<defs/>\
+<g transform="scale(1,1) translate(0.5,0.5)">\
+<rect fill="blue" stroke="none" x="100" y="200" width="300" height="400"/>\
+</g>\
+</svg>\
+`)
+
+      const overlays_svg = (canvas_view.overlays.ctx as any).get_serialized_svg(true)
+      expect(overlays_svg).to.be.equal(`\
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="600" height="600">\
+<defs/>\
+<g transform="scale(1,1) translate(0.5,0.5)">\
+<rect fill="green" stroke="none" x="300" y="400" width="200" height="100"/>\
+</g>\
+</svg>\
+`)
+
+      const composite_layer = canvas_view.compose()
+      const composite_svg = (composite_layer.ctx as any).get_serialized_svg(true)
+      expect(composite_svg).to.be.equal(`\
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="600" height="600">\
+<defs/>\
+<g>\
+<g transform="scale(1,1) translate(0.5,0.5) translate(0, 0)">\
+<rect fill="blue" stroke="none" x="100" y="200" width="300" height="400"/>\
+</g>\
+<g transform="scale(1,1) translate(0.5,0.5) translate(0, 0)">\
+<rect fill="green" stroke="none" x="300" y="400" width="200" height="100"/>\
+</g>\
+</g>\
+</svg>\
+`)
+    })
   })
 })
diff --git a/tests/unit/bokeh/io/test_export.py b/tests/unit/bokeh/io/test_export.py
--- a/tests/unit/bokeh/io/test_export.py
+++ b/tests/unit/bokeh/io/test_export.py
@@ -164,9 +164,12 @@ def fix_ids(svg):
     svg2 = (
         '<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="20" height="20">'
         '<defs/>'
-        '<g transform="scale(1,1) translate(0.5,0.5)">'
-            '<rect fill="rgb(255,0,0)" stroke="none" x="5" y="5" width="10" height="10" fill-opacity="1"/>'
-            '<g/>'
+        '<g>'
+            '<g transform="scale(1,1) translate(0.5,0.5) translate(0, 0)">'
+                '<rect fill="rgb(255,0,0)" stroke="none" x="5" y="5" width="10" height="10" fill-opacity="1"/>'
+                '<g/>'
+            '</g>'
+            '<g transform="scale(1,1) translate(0.5,0.5) translate(0, 0)"/>'
         '</g>'
         '</svg>'
     )