Source code for bokeh.io.export

#-----------------------------------------------------------------------------
# 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 mkstemp
from typing import (
    TYPE_CHECKING,
    Any,
    Iterator,
    cast,
)

if TYPE_CHECKING:
    from selenium.webdriver.remote.webdriver import WebDriver

# Bokeh imports
from ..core.types import PathLike
from ..document import Document
from ..embed import file_html
from ..resources import INLINE, Resources
from ..themes import Theme
from ..util.warnings import warn
from .state import State, curstate
from .util import default_filename

if TYPE_CHECKING:
    from PIL import Image

    from ..models.plots import Plot
    from ..models.ui import UIElement

#-----------------------------------------------------------------------------
# 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 open(tmp.path, mode="w", encoding="utf-8") as file: file.write(html) 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.path}") 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 open(tmp.path, mode="w", encoding="utf-8") as file: file.write(html) web_driver = driver if driver is not None else webdriver_control.get() web_driver.get(f"file://{tmp.path}") wait_until_render_complete(web_driver, timeout) svgs = cast(list[str], web_driver.execute_script(_SVG_SCRIPT)) 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 open(tmp.path, mode="w", encoding="utf-8") as file: file.write(html) web_driver = driver if driver is not None else webdriver_control.get() web_driver.get(f"file://{tmp.path}") 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): 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: logs = driver.get_log('browser') except Exception: return messages = [ log.get("message") for log in logs if 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)] """ _SVG_SCRIPT = """\ function* export_svgs(views) { for (const view of views) { // TODO: use to_blob() API in future 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); """ class _TempFile: _closed: bool = False fd: int path: str def __init__(self, *, prefix: str = "tmp", suffix: str = "") -> None: # XXX: selenium has issues with /tmp directory (or equivalent), so try using the # current directory first, if writable, and otherwise fall back to the system # default tmp directory. try: self.fd, self.path = mkstemp(prefix=prefix, suffix=suffix, dir=os.getcwd()) except OSError: self.fd, self.path = mkstemp(prefix=prefix, suffix=suffix) def __enter__(self) -> _TempFile: return self def __exit__(self, exc: Any, value: Any, tb: Any) -> None: self.close() def __del__(self) -> None: self.close() def close(self) -> None: if self._closed: return try: os.close(self.fd) except OSError: pass try: os.unlink(self.path) except OSError: pass self._closed = True def _tmp_html() -> _TempFile: return _TempFile(prefix="bokeh", suffix=".html") #----------------------------------------------------------------------------- # Code #-----------------------------------------------------------------------------