#-----------------------------------------------------------------------------
# Copyright (c) Anaconda, Inc., and Bokeh Contributors.
# All rights reserved.
#
# The full license is in the file LICENSE.txt, distributed with this software.
#-----------------------------------------------------------------------------
'''
'''
#-----------------------------------------------------------------------------
# Boilerplate
#-----------------------------------------------------------------------------
from __future__ import annotations
import logging # isort:skip
log = logging.getLogger(__name__)
#-----------------------------------------------------------------------------
# Imports
#-----------------------------------------------------------------------------
# Standard library imports
import io
import os
from contextlib import contextmanager
from os.path import abspath, expanduser, splitext
from tempfile import NamedTemporaryFile
from typing import TYPE_CHECKING, Iterator, cast
# Bokeh imports
from ..embed import file_html
from ..resources import INLINE
from .state import curstate
from .util import default_filename
if TYPE_CHECKING:
from tempfile import _TemporaryFileWrapper
from PIL import Image
from selenium.webdriver.remote.webdriver import WebDriver
from ..core.types import PathLike
from ..document import Document
from ..model import Model
from ..models.plots import Plot
from ..models.ui import UIElement
from ..resources import Resources
from ..themes import Theme
from .state import State
#-----------------------------------------------------------------------------
# Globals and constants
#-----------------------------------------------------------------------------
__all__ = (
'export_png',
'export_svg',
'export_svgs',
'get_layout_html',
'get_screenshot_as_png',
'get_svgs',
)
#-----------------------------------------------------------------------------
# General API
#-----------------------------------------------------------------------------
[docs]
def export_png(obj: UIElement | Document, *, filename: PathLike | None = None, width: int | None = None,
height: int | None = None, scale_factor: float = 1, webdriver: WebDriver | None = None,
timeout: int = 5, state: State | None = None) -> str:
''' Export the ``UIElement`` object or document as a PNG.
If the filename is not given, it is derived from the script name (e.g.
``/foo/myplot.py`` will create ``/foo/myplot.png``)
Args:
obj (UIElement or Document) : a Layout (Row/Column), Plot or Widget
object or Document to export.
filename (PathLike, e.g. str, Path, optional) : filename to save document under (default: None)
If None, infer from the filename.
width (int) : the desired width of the exported layout obj only if
it's a Plot instance. Otherwise the width kwarg is ignored.
height (int) : the desired height of the exported layout obj only if
it's a Plot instance. Otherwise the height kwarg is ignored.
scale_factor (float, optional) : A factor to scale the output PNG by,
providing a higher resolution while maintaining element relative
scales.
webdriver (selenium.webdriver) : a selenium webdriver instance to use
to export the image.
timeout (int) : the maximum amount of time (in seconds) to wait for
Bokeh to initialize (default: 5) (Added in 1.1.1).
state (State, optional) :
A :class:`State` object. If None, then the current default
implicit state is used. (default: None).
Returns:
filename (str) : the filename where the static file is saved.
If you would like to access an Image object directly, rather than save a
file to disk, use the lower-level :func:`~bokeh.io.export.get_screenshot_as_png`
function.
.. warning::
Responsive sizing_modes may generate layouts with unexpected size and
aspect ratios. It is recommended to use the default ``fixed`` sizing mode.
'''
image = get_screenshot_as_png(obj, width=width, height=height, scale_factor=scale_factor, driver=webdriver,
timeout=timeout, state=state)
if filename is None:
filename = default_filename("png")
if image.width == 0 or image.height == 0:
raise ValueError("unable to save an empty image")
filename = os.fspath(filename) # XXX: Image.save() doesn't fully support PathLike
image.save(filename)
return abspath(expanduser(filename))
[docs]
def export_svg(obj: UIElement | Document, *, filename: PathLike | None = None, width: int | None = None,
height: int | None = None, webdriver: WebDriver | None = None, timeout: int = 5, state: State | None = None) -> list[str]:
''' Export a layout as SVG file or a document as a set of SVG files.
If the filename is not given, it is derived from the script name
(e.g. ``/foo/myplot.py`` will create ``/foo/myplot.svg``)
Args:
obj (UIElement object) : a Layout (Row/Column), Plot or Widget object to display
filename (PathLike, e.g. str, Path, optional) : filename to save document under (default: None)
If None, infer from the filename.
width (int) : the desired width of the exported layout obj only if
it's a Plot instance. Otherwise the width kwarg is ignored.
height (int) : the desired height of the exported layout obj only if
it's a Plot instance. Otherwise the height kwarg is ignored.
webdriver (selenium.webdriver) : a selenium webdriver instance to use
to export the image.
timeout (int) : the maximum amount of time (in seconds) to wait for
Bokeh to initialize (default: 5)
state (State, optional) :
A :class:`State` object. If None, then the current default
implicit state is used. (default: None).
Returns:
filenames (list(str)) : the list of filenames where the SVGs files are saved.
.. warning::
Responsive sizing_modes may generate layouts with unexpected size and
aspect ratios. It is recommended to use the default ``fixed`` sizing mode.
'''
svgs = get_svg(obj, width=width, height=height, driver=webdriver, timeout=timeout, state=state)
return _write_collection(svgs, filename, "svg")
[docs]
def export_svgs(obj: UIElement | Document, *, filename: str | None = None, width: int | None = None,
height: int | None = None, webdriver: WebDriver | None = None, timeout: int = 5, state: State | None = None) -> list[str]:
''' Export the SVG-enabled plots within a layout. Each plot will result
in a distinct SVG file.
If the filename is not given, it is derived from the script name
(e.g. ``/foo/myplot.py`` will create ``/foo/myplot.svg``)
Args:
obj (UIElement object) : a Layout (Row/Column), Plot or Widget object to display
filename (str, optional) : filename to save document under (default: None)
If None, infer from the filename.
width (int) : the desired width of the exported layout obj only if
it's a Plot instance. Otherwise the width kwarg is ignored.
height (int) : the desired height of the exported layout obj only if
it's a Plot instance. Otherwise the height kwarg is ignored.
webdriver (selenium.webdriver) : a selenium webdriver instance to use
to export the image.
timeout (int) : the maximum amount of time (in seconds) to wait for
Bokeh to initialize (default: 5) (Added in 1.1.1).
state (State, optional) :
A :class:`State` object. If None, then the current default
implicit state is used. (default: None).
Returns:
filenames (list(str)) : the list of filenames where the SVGs files are saved.
.. warning::
Responsive sizing_modes may generate layouts with unexpected size and
aspect ratios. It is recommended to use the default ``fixed`` sizing mode.
'''
svgs = get_svgs(obj, width=width, height=height, driver=webdriver, timeout=timeout, state=state)
if len(svgs) == 0:
log.warning("No SVG Plots were found.")
return []
return _write_collection(svgs, filename, "svg")
#-----------------------------------------------------------------------------
# Dev API
#-----------------------------------------------------------------------------
[docs]
def get_screenshot_as_png(obj: UIElement | Document, *, driver: WebDriver | None = None, timeout: int = 5,
resources: Resources = INLINE, width: int | None = None, height: int | None = None,
scale_factor: float = 1, state: State | None = None) -> Image.Image:
''' Get a screenshot of a ``UIElement`` object.
Args:
obj (UIElement or Document) : a Layout (Row/Column), Plot or Widget
object or Document to export.
driver (selenium.webdriver) : a selenium webdriver instance to use
to export the image.
timeout (int) : the maximum amount of time to wait for initialization.
It will be used as a timeout for loading Bokeh, then when waiting for
the layout to be rendered.
scale_factor (float, optional) : A factor to scale the output PNG by,
providing a higher resolution while maintaining element relative
scales.
state (State, optional) :
A :class:`State` object. If None, then the current default
implicit state is used. (default: None).
Returns:
image (PIL.Image.Image) : a pillow image loaded from PNG.
.. warning::
Responsive sizing_modes may generate layouts with unexpected size and
aspect ratios. It is recommended to use the default ``fixed`` sizing mode.
'''
from .webdriver import (
get_web_driver_device_pixel_ratio,
scale_factor_less_than_web_driver_device_pixel_ratio,
webdriver_control,
)
with _tmp_html() as tmp:
theme = (state or curstate()).document.theme
html = get_layout_html(obj, resources=resources, width=width, height=height, theme=theme)
with tmp as f:
f.write(html.encode("utf-8"))
if driver is not None:
web_driver = driver
if not scale_factor_less_than_web_driver_device_pixel_ratio(scale_factor, web_driver):
device_pixel_ratio = get_web_driver_device_pixel_ratio(web_driver)
raise ValueError(f'Expected the web driver to have a device pixel ratio greater than {scale_factor}. '
f'Was given a web driver with a device pixel ratio of {device_pixel_ratio}.')
else:
web_driver = webdriver_control.get(scale_factor=scale_factor)
web_driver.maximize_window()
web_driver.get(f"file://{tmp.name}")
wait_until_render_complete(web_driver, timeout)
[width, height, dpr] = _maximize_viewport(web_driver)
png = web_driver.get_screenshot_as_png()
from PIL import Image
return (Image.open(io.BytesIO(png))
.convert("RGBA")
.crop((0, 0, width*dpr, height*dpr))
.resize((int(width*scale_factor), int(height*scale_factor))))
def get_svg(obj: UIElement | Document, *, driver: WebDriver | None = None, timeout: int = 5,
resources: Resources = INLINE, width: int | None = None, height: int | None = None, state: State | None = None) -> list[str]:
from .webdriver import webdriver_control
with _tmp_html() as tmp:
theme = (state or curstate()).document.theme
html = get_layout_html(obj, resources=resources, width=width, height=height, theme=theme)
with tmp as f:
f.write(html.encode("utf-8"))
web_driver = driver if driver is not None else webdriver_control.get()
web_driver.get(f"file://{tmp.name}")
wait_until_render_complete(web_driver, timeout)
svgs = cast(list[str], web_driver.execute_script(_SVG_SCRIPT(obj)))
return svgs
def get_svgs(obj: UIElement | Document, *, driver: WebDriver | None = None, timeout: int = 5,
resources: Resources = INLINE, width: int | None = None, height: int | None = None, state: State | None = None) -> list[str]:
from .webdriver import webdriver_control
with _tmp_html() as tmp:
theme = (state or curstate()).document.theme
html = get_layout_html(obj, resources=resources, width=width, height=height, theme=theme)
with tmp as f:
f.write(html.encode("utf-8"))
web_driver = driver if driver is not None else webdriver_control.get()
web_driver.get(f"file://{tmp.name}")
wait_until_render_complete(web_driver, timeout)
svgs = cast(list[str], web_driver.execute_script(_SVGS_SCRIPT))
return svgs
[docs]
def get_layout_html(obj: UIElement | Document, *, resources: Resources = INLINE,
width: int | None = None, height: int | None = None, theme: Theme | None = None) -> str:
'''
'''
template = r"""\
{% block preamble %}
<style>
html, body {
box-sizing: border-box;
width: 100%;
height: 100%;
margin: 0;
border: 0;
padding: 0;
overflow: hidden;
}
</style>
{% endblock %}
"""
def html() -> str:
return file_html(
obj,
resources=resources,
title="",
template=template,
theme=theme,
suppress_callback_warning=True,
_always_new=True,
)
if width is not None or height is not None:
# Defer this import, it is expensive
from ..models.plots import Plot
if not isinstance(obj, Plot):
from ..util.warnings import warn
warn("Export method called with width or height argument on a non-Plot model. The size values will be ignored.")
else:
with _resized(obj, width, height):
return html()
return html()
def wait_until_render_complete(driver: WebDriver, timeout: int) -> None:
'''
'''
from selenium.common.exceptions import TimeoutException
from selenium.webdriver.support.wait import WebDriverWait
def is_bokeh_loaded(driver: WebDriver) -> bool:
return cast(bool, driver.execute_script('''
return typeof Bokeh !== "undefined" && Bokeh.documents != null && Bokeh.documents.length != 0
'''))
try:
WebDriverWait(driver, timeout, poll_frequency=0.1).until(is_bokeh_loaded)
except TimeoutException as e:
_log_console(driver)
raise RuntimeError('Bokeh was not loaded in time. Something may have gone wrong.') from e
driver.execute_script(_WAIT_SCRIPT)
def is_bokeh_render_complete(driver: WebDriver) -> bool:
return cast(bool, driver.execute_script('return window._bokeh_render_complete;'))
try:
WebDriverWait(driver, timeout, poll_frequency=0.1).until(is_bokeh_render_complete)
except TimeoutException:
log.warning("The webdriver raised a TimeoutException while waiting for "
"a 'bokeh:idle' event to signify that the layout has rendered. "
"Something may have gone wrong.")
finally:
_log_console(driver)
#-----------------------------------------------------------------------------
# Private API
#-----------------------------------------------------------------------------
@contextmanager
def _resized(obj: Plot, width: int | None, height: int | None) -> Iterator[None]:
old_width = obj.width
old_height = obj.height
if width is not None:
obj.width = width
if height is not None:
obj.height = height
yield
obj.width = old_width
obj.height = old_height
def _write_collection(items: list[str], filename: PathLike | None, ext: str) -> list[str]:
if filename is None:
filename = default_filename(ext)
filename = os.fspath(filename)
filenames: list[str] = []
def _indexed(name: str, i: int) -> str:
basename, ext = splitext(name)
return f"{basename}_{i}{ext}"
for i, item in enumerate(items):
fname = filename if i == 0 else _indexed(filename, i)
with open(fname, mode="w", encoding="utf-8") as f:
f.write(item)
filenames.append(fname)
return filenames
def _log_console(driver: WebDriver) -> None:
levels = {'WARNING', 'ERROR', 'SEVERE'}
try:
driver_logs = driver.get_log('browser')
except Exception:
return
messages = [driver_log.get("message") for driver_log in driver_logs if driver_log.get('level') in levels]
if len(messages) > 0:
log.warning("There were browser warnings and/or errors that may have affected your export")
for message in messages:
log.warning(message)
def _maximize_viewport(web_driver: WebDriver) -> tuple[int, int, int]:
calculate_viewport_size = """\
const root_view = Bokeh.index.roots[0]
const {width, height} = root_view.el.getBoundingClientRect()
return [Math.round(width), Math.round(height), window.devicePixelRatio]
"""
viewport_size: tuple[int, int, int] = web_driver.execute_script(calculate_viewport_size)
calculate_window_size = """\
const [width, height, dpr] = arguments
return [
// XXX: outer{Width,Height} can be 0 in headless mode under certain window managers
Math.round(Math.max(0, window.outerWidth - window.innerWidth) + width*dpr),
Math.round(Math.max(0, window.outerHeight - window.innerHeight) + height*dpr),
]
"""
[width, height] = web_driver.execute_script(calculate_window_size, *viewport_size)
eps = 100 # XXX: can't set window size exactly in certain window managers, crop it to size later
web_driver.set_window_size(width + eps, height + eps)
return viewport_size
# TODO: consider UIElement like Pane
_SVGS_SCRIPT = """
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)
}
}
}
return [...collect_svgs(Bokeh.index)]
"""
def _SVG_SCRIPT(obj: Model | Document) -> str:
from ..document import Document
if isinstance(obj, Document):
ids = [root.id for root in obj.roots]
else:
ids = [obj.id]
return f"""\
const ids = new Set({ids})
function* export_svgs(views) {{
for (const view of views) {{
// TODO: use to_blob() API in future
if (ids.has(view.model.id)) {{
const {{ctx}} = view.export("svg")
yield ctx.get_serialized_svg(true)
}}
}}
}}
return [...export_svgs(Bokeh.index)]
"""
_WAIT_SCRIPT = """
// add private window prop to check that render is complete
window._bokeh_render_complete = false;
function done() {
window._bokeh_render_complete = true;
}
const doc = Bokeh.documents[0];
if (doc.is_idle)
done();
else
doc.idle.connect(done);
"""
@contextmanager
def _tmp_html() -> Iterator[_TemporaryFileWrapper[bytes]]:
# according to https://docs.python.org/3/library/tempfile.html#tempfile.NamedTemporaryFile
# in order for named temp files to be safely re-openable on Windows, we need
# to set delete=False, so explicitly this context manager is for explicitly
# managing the unlink after we are done.
tmp = NamedTemporaryFile(mode="wb", prefix="bokeh", suffix=".html", delete=False)
try:
yield tmp
finally:
os.unlink(tmp.name)
#-----------------------------------------------------------------------------
# Code
#-----------------------------------------------------------------------------