Repository: bokeh/bokeh

Issue:
Add height/width kwargs to bokeh.io.export to support resizing the exported PNG
Add height/width kwargs to bokeh.io.export to support resizing the exported PNG


Discussion:

Resolving patch:
diff --git a/bokeh/command/subcommands/png.py b/bokeh/command/subcommands/png.py
--- a/bokeh/command/subcommands/png.py
+++ b/bokeh/command/subcommands/png.py
@@ -49,8 +49,10 @@
 import io
 import os
 import sys
+import warnings
 
 from bokeh.io import _get_screenshot_as_png
+from bokeh.models import Plot
 from .file_output import FileOutputSubcommand
 
 class PNG(FileOutputSubcommand):
@@ -68,6 +70,20 @@ class PNG(FileOutputSubcommand):
 
         FileOutputSubcommand.files_arg("PNG"),
 
+        ('--height', dict(
+            metavar='HEIGHT',
+            type=int,
+            help="The desired height of the exported layout obj only if it's a Plot instance",
+            default=None,
+        )),
+
+        ('--width', dict(
+            metavar='WIDTH',
+            type=int,
+            help="The desired width of the exported layout obj only if it's a Plot instance",
+            default=None,
+        )),
+
     ) + FileOutputSubcommand.other_args()
 
     def invoke(self, args):
@@ -86,7 +102,16 @@ def write_file(self, args, filename, doc):
         self.after_write_file(args, filename, doc)
 
     def file_contents(self, args, doc):
-        image = _get_screenshot_as_png(doc, self.driver)
+        if args.width is not None or args.height is not None:
+            layout = doc.roots
+            if len(layout) != 1 or not isinstance(layout[0], Plot):
+                warnings.warn("Export called with height or width kwargs on a non-single Plot layout. The size values will be ignored.")
+            else:
+                plot = layout[0]
+                plot.plot_height = args.height or plot.plot_height
+                plot.plot_width  = args.width or plot.plot_width
+
+        image = _get_screenshot_as_png(doc, driver=self.driver)
         buf = io.BytesIO()
         image.save(buf, "png")
         buf.seek(0)
diff --git a/bokeh/command/subcommands/svg.py b/bokeh/command/subcommands/svg.py
--- a/bokeh/command/subcommands/svg.py
+++ b/bokeh/command/subcommands/svg.py
@@ -50,8 +50,10 @@
 
 import io
 import os
+import warnings
 
 from bokeh.io import _get_svgs
+from bokeh.models import Plot
 from bokeh.util.string import decode_utf8
 from .file_output import FileOutputSubcommand
 
@@ -70,6 +72,20 @@ class SVG(FileOutputSubcommand):
 
         FileOutputSubcommand.files_arg("SVG"),
 
+        ('--height', dict(
+            metavar='HEIGHT',
+            type=int,
+            help="The desired height of the exported layout obj only if it's a Plot instance",
+            default=None,
+        )),
+
+        ('--width', dict(
+            metavar='WIDTH',
+            type=int,
+            help="The desired width of the exported layout obj only if it's a Plot instance",
+            default=None,
+        )),
+
     ) + FileOutputSubcommand.other_args()
 
     def invoke(self, args):
@@ -94,4 +110,12 @@ def write_file(self, args, filename, doc):
             self.after_write_file(args, filename, doc)
 
     def file_contents(self, args, doc):
-        return _get_svgs(doc, self.driver)
+        if args.width is not None or args.height is not None:
+            layout = doc.roots
+            if len(layout) != 1 or not isinstance(layout[0], Plot):
+                warnings.warn("Export called with height or width kwargs on a non-single Plot layout. The size values will be ignored.")
+            else:
+                plot = layout[0]
+                plot.plot_height = args.height or plot.plot_height
+                plot.plot_width  = args.width or plot.plot_width
+        return _get_svgs(doc, driver=self.driver)
diff --git a/bokeh/io.py b/bokeh/io.py
--- a/bokeh/io.py
+++ b/bokeh/io.py
@@ -30,6 +30,7 @@
 from .document import Document
 from .embed import autoload_server, notebook_div, file_html
 from .layouts import gridplot, GridSpec ; gridplot, GridSpec
+from .models import Plot
 from .resources import INLINE
 import bokeh.util.browser as browserlib  # full import needed for test mocking to work
 from .util.dependencies import import_required, detect_phantomjs
@@ -600,13 +601,34 @@ def is_bokeh_render_complete(driver):
         if len(severe_errors) > 0:
             logger.warn("There were severe browser errors that may have affected your export: {}".format(severe_errors))
 
+def _save_layout_html(obj, resources=INLINE, **kwargs):
+    resize = False
+    if kwargs.get('height') is not None or kwargs.get('width') is not None:
+        if not isinstance(obj, Plot):
+            warnings.warn("Export method called with height or width kwargs on a non-Plot layout. The size values will be ignored.")
+        else:
+            resize = True
+            old_height = obj.plot_height
+            old_width = obj.plot_width
+            obj.plot_height = kwargs.get('height', old_height)
+            obj.plot_width = kwargs.get('width', old_width)
+
+    html_path = tempfile.NamedTemporaryFile(suffix=".html").name
+    save(obj, filename=html_path, resources=resources, title="")
+
+    if resize:
+        obj.plot_height = old_height
+        obj.plot_width = old_width
+
+    return html_path
+
 def _crop_image(image, left=0, top=0, right=0, bottom=0, **kwargs):
     '''Crop the border from the layout'''
     cropped_image = image.crop((left, top, right, bottom))
 
     return cropped_image
 
-def _get_screenshot_as_png(obj, driver):
+def _get_screenshot_as_png(obj, **kwargs):
     webdriver = import_required('selenium.webdriver',
                                 'To use bokeh.io.export_png you need selenium ' +
                                 '("conda install -c bokeh selenium" or "pip install selenium")')
@@ -617,13 +639,9 @@ def _get_screenshot_as_png(obj, driver):
     # assert that phantomjs is in path for webdriver
     detect_phantomjs()
 
-    html_path = tempfile.NamedTemporaryFile(suffix=".html").name
-    save(obj, filename=html_path, resources=INLINE, title="")
+    html_path = _save_layout_html(obj, **kwargs)
 
-    if driver is None:
-        web_driver = webdriver.PhantomJS(service_log_path=os.path.devnull)
-    else:
-        web_driver = driver
+    web_driver = kwargs.get('driver', webdriver.PhantomJS(service_log_path=os.path.devnull))
 
     web_driver.get("file:///" + html_path)
     web_driver.maximize_window()
@@ -638,7 +656,7 @@ def _get_screenshot_as_png(obj, driver):
     bounding_rect_script = "return document.getElementsByClassName('bk-root')[0].children[0].getBoundingClientRect()"
     b_rect = web_driver.execute_script(bounding_rect_script)
 
-    if driver is None: # only quit webdriver if not passed in as arg
+    if kwargs.get('driver') is None: # only quit webdriver if not passed in as arg
         web_driver.quit()
 
     image = Image.open(io.BytesIO(png))
@@ -646,7 +664,7 @@ def _get_screenshot_as_png(obj, driver):
 
     return cropped_image
 
-def export_png(obj, filename=None):
+def export_png(obj, filename=None, height=None, width=None):
     ''' Export the LayoutDOM object or document as a PNG.
 
     If the filename is not given, it is derived from the script name
@@ -659,6 +677,12 @@ def export_png(obj, filename=None):
         filename (str, optional) : filename to save document under (default: None)
             If None, infer from the filename.
 
+        height (int) : the desired height of the exported layout obj only if
+            it's a Plot instance. Otherwise the height kwarg is ignored.
+
+        width (int) : the desired width of the exported layout obj only if
+            it's a Plot instance. Otherwise the width kwarg is ignored.
+
     Returns:
         filename (str) : the filename where the static file is saved.
 
@@ -670,7 +694,8 @@ def export_png(obj, filename=None):
         Glyphs that are rendered via webgl won't be included in the generated PNG.
 
     '''
-    image = _get_screenshot_as_png(obj, None)
+
+    image = _get_screenshot_as_png(obj, height=height, width=width)
 
     if filename is None:
         filename = _detect_filename("png")
@@ -679,21 +704,16 @@ def export_png(obj, filename=None):
 
     return os.path.abspath(filename)
 
-def _get_svgs(obj, driver):
+def _get_svgs(obj, **kwargs):
     webdriver = import_required('selenium.webdriver',
                                 'To use bokeh.io.export_svgs you need selenium ' +
                                 '("conda install -c bokeh selenium" or "pip install selenium")')
     # assert that phantomjs is in path for webdriver
     detect_phantomjs()
 
-    html_path = tempfile.NamedTemporaryFile(suffix=".html").name
-    save(obj, filename=html_path, resources=INLINE, title="")
-
-    if driver is None:
-        web_driver = webdriver.PhantomJS(service_log_path=os.path.devnull)
-    else:
-        web_driver = driver
+    html_path = _save_layout_html(obj, **kwargs)
 
+    web_driver = kwargs.get('driver', webdriver.PhantomJS(service_log_path=os.path.devnull))
     web_driver.get("file:///" + html_path)
 
     _wait_until_render_complete(web_driver)
@@ -710,12 +730,12 @@ def _get_svgs(obj, driver):
 
     svgs = web_driver.execute_script(svg_script)
 
-    if driver is None: # only quit webdriver if not passed in as arg
+    if kwargs.get('driver') is None: # only quit webdriver if not passed in as arg
         web_driver.quit()
 
     return svgs
 
-def export_svgs(obj, filename=None):
+def export_svgs(obj, filename=None, height=None, width=None):
     ''' Export the SVG-enabled plots within a layout. Each plot will result
     in a distinct SVG file.
 
@@ -728,6 +748,12 @@ def export_svgs(obj, filename=None):
         filename (str, optional) : filename to save document under (default: None)
             If None, infer from the filename.
 
+        height (int) : the desired height of the exported layout obj only if
+            it's a Plot instance. Otherwise the height kwarg is ignored.
+
+        width (int) : the desired width of the exported layout obj only if
+            it's a Plot instance. Otherwise the width kwarg is ignored.
+
     Returns:
         filenames (list(str)) : the list of filenames where the SVGs files
             are saved.
@@ -737,7 +763,7 @@ def export_svgs(obj, filename=None):
         aspect ratios. It is recommended to use the default ``fixed`` sizing mode.
 
     '''
-    svgs = _get_svgs(obj, None)
+    svgs = _get_svgs(obj, height=height, width=width)
 
     if len(svgs) == 0:
         logger.warn("No SVG Plots were found.")


Test patch:
diff --git a/bokeh/command/subcommands/tests/__init__.py b/bokeh/command/subcommands/tests/__init__.py
--- a/bokeh/command/subcommands/tests/__init__.py
+++ b/bokeh/command/subcommands/tests/__init__.py
@@ -8,7 +8,7 @@
 p1 = figure()
 p1.scatter(x,y, color="#FF00FF")
 doc = curdoc()
-doc.add_root(Row(p1, p2))
+doc.add_root(p1)
 """
 
 basic_svg_scatter_script = """
diff --git a/bokeh/command/subcommands/tests/test_png.py b/bokeh/command/subcommands/tests/test_png.py
--- a/bokeh/command/subcommands/tests/test_png.py
+++ b/bokeh/command/subcommands/tests/test_png.py
@@ -36,6 +36,21 @@ def test_args():
             default=None,
         )),
 
+
+        ('--height', dict(
+            metavar='HEIGHT',
+            type=int,
+            help="The desired height of the exported layout obj only if it's a Plot instance",
+            default=None,
+        )),
+
+        ('--width', dict(
+            metavar='WIDTH',
+            type=int,
+            help="The desired width of the exported layout obj only if it's a Plot instance",
+            default=None,
+        )),
+
         (('-o', '--output'), dict(
             metavar='FILENAME',
             action='append',
@@ -61,7 +76,8 @@ def test_no_script(capsys):
             too_few = "too few arguments"
         else:
             too_few = "the following arguments are required: DIRECTORY-OR-SCRIPT"
-        assert err == """usage: bokeh png [-h] [-o FILENAME] [--args ...]
+        assert err == """usage: bokeh png [-h] [--height HEIGHT] [--width WIDTH] [-o FILENAME]
+                 [--args ...]
                  DIRECTORY-OR-SCRIPT [DIRECTORY-OR-SCRIPT ...]
 bokeh png: error: %s
 """ % (too_few)
diff --git a/bokeh/command/subcommands/tests/test_svg.py b/bokeh/command/subcommands/tests/test_svg.py
--- a/bokeh/command/subcommands/tests/test_svg.py
+++ b/bokeh/command/subcommands/tests/test_svg.py
@@ -36,6 +36,20 @@ def test_args():
             default=None,
         )),
 
+        ('--height', dict(
+            metavar='HEIGHT',
+            type=int,
+            help="The desired height of the exported layout obj only if it's a Plot instance",
+            default=None,
+        )),
+
+        ('--width', dict(
+            metavar='WIDTH',
+            type=int,
+            help="The desired width of the exported layout obj only if it's a Plot instance",
+            default=None,
+        )),
+
         (('-o', '--output'), dict(
             metavar='FILENAME',
             action='append',
@@ -61,7 +75,8 @@ def test_no_script(capsys):
             too_few = "too few arguments"
         else:
             too_few = "the following arguments are required: DIRECTORY-OR-SCRIPT"
-        assert err == """usage: bokeh svg [-h] [-o FILENAME] [--args ...]
+        assert err == """usage: bokeh svg [-h] [--height HEIGHT] [--width WIDTH] [-o FILENAME]
+                 [--args ...]
                  DIRECTORY-OR-SCRIPT [DIRECTORY-OR-SCRIPT ...]
 bokeh svg: error: %s
 """ % (too_few)
diff --git a/bokeh/tests/test_io.py b/bokeh/tests/test_io.py
--- a/bokeh/tests/test_io.py
+++ b/bokeh/tests/test_io.py
@@ -305,6 +305,16 @@ def test__crop_image():
     cropped = io._crop_image(image, **rect)
     assert cropped.size == (6,4)
 
+def test__save_layout_html_resets_plot_dims():
+    initial_height, initial_width = 200, 250
+
+    layout = Plot(x_range=Range1d(), y_range=Range1d(),
+                  plot_height=initial_height, plot_width=initial_width)
+
+    io._save_layout_html(layout, height=100, width=100)
+    assert layout.plot_height == initial_height
+    assert layout.plot_width == initial_width
+
 @pytest.mark.unit
 @pytest.mark.selenium
 def test__get_screenshot_as_png():
@@ -313,7 +323,7 @@ def test__get_screenshot_as_png():
                   outline_line_color=None, background_fill_color=None,
                   border_fill_color=None)
 
-    png = io._get_screenshot_as_png(layout, None)
+    png = io._get_screenshot_as_png(layout)
     assert png.size == (20, 20)
     # a 20x20px image of transparent pixels
     assert png.tobytes() == ("\x00"*1600).encode()
@@ -328,7 +338,7 @@ def test__get_screenshot_as_png_with_driver():
 
     driver = webdriver.PhantomJS(service_log_path=os.path.devnull)
 
-    png = io._get_screenshot_as_png(layout, driver)
+    png = io._get_screenshot_as_png(layout, driver=driver)
 
     # Have to manually clean up the driver session
     driver.quit()
@@ -348,7 +358,7 @@ def test__get_screenshot_as_png_large_plot():
     driver = webdriver.PhantomJS(service_log_path=os.path.devnull)
     assert driver.get_window_size() == {'width': 400, 'height': 300}
 
-    io._get_screenshot_as_png(layout, driver)
+    io._get_screenshot_as_png(layout, driver=driver)
 
     # LC: Although the window size doesn't match the plot dimensions (unclear
     # why), the window resize allows for the whole plot to be captured
@@ -363,7 +373,7 @@ def test__get_svgs_no_svg_present():
     layout = Plot(x_range=Range1d(), y_range=Range1d(),
               plot_height=20, plot_width=20, toolbar_location=None)
 
-    svgs = io._get_svgs(layout, None)
+    svgs = io._get_svgs(layout)
     assert svgs == []
 
 @pytest.mark.unit
@@ -374,7 +384,7 @@ def test__get_svgs_with_svg_present():
                   outline_line_color=None, border_fill_color=None,
                   background_fill_color=None, output_backend="svg")
 
-    svgs = io._get_svgs(layout, None)
+    svgs = io._get_svgs(layout)
     assert svgs[0] == ('<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" '
                        'width="20" height="20" style="width: 20px; height: 20px;"><defs/><g><g/><g transform="scale(1,1) '
                        'translate(0.5,0.5)"><rect fill="#FFFFFF" stroke="none" x="0" y="0" width="20" height="20"/><g/><g/><g/><g/></g></g></svg>')
@@ -389,7 +399,7 @@ def test__get_svgs_with_svg_present_with_driver():
 
     driver = webdriver.PhantomJS(service_log_path=os.path.devnull)
 
-    svgs = io._get_svgs(layout, driver)
+    svgs = io._get_svgs(layout)
 
     # Have to manually clean up the driver session
     driver.quit()