Source code for cowpatch.svg_utils

import io
import plotnine as p9
import cairosvg
import svgutils.transform as sg
from PIL import Image
import warnings
from .exceptions import CowpatchWarning

import re
from IPython.display import SVG, display
import IPython

from .utils import _transform_size_to_pt, _proposed_scaling_both, \
                    to_inches, from_inches

import matplotlib.pyplot as plt
import matplotlib.image as mpimg

import numpy as np
import copy

import pdb

def _raw_gg_to_svg(gg, width, height, dpi, limitsize=True):
    """
    Convert plotnine ggplot figure to svg and return it (pass width, height
    directly to p9.save, no correction to incorrect saving)

    Arguments
    ---------
    gg: plotnine.ggplot.ggplot
        object to save as a png image
    width : float
        width in inches to be passed to the plotnine's ggplot.save function
    height: float
        height in inches to be passed to the plotnine's ggplot.save function
    dpi: int
        dots per inch, to be passed to the plotnine's ggplot.save function
    limitsize: boolean
        logic if plotnine's ggplot.save function should check if the requested
        width and height in inches are greater than 50 (assumes the user
        accidentally entered in these values w.r.t. pixels)

    Returns
    -------
    svgutils.tranform representation of the ggplot object
        (aka an svg representation)

    Notes
    -----
    Code idea motified by the stackoverflow question here, https://stackoverflow.com/questions/8598673/how-to-save-a-pylab-figure-into-in-memory-file-which-can-be-read-into-pil-image/8598881
    and truly influenced by svgutils.transform.from_mpl function.
    """

    fid = io.StringIO()

    try:
        gg.save(fid, format="svg", height=height, width=width,
            dpi=dpi, units="in", limitsize=limitsize, verbose=False)
    except ValueError:
        raise(ValueError, "No ggplot SVG backend")
    fid.seek(0)
    img = sg.fromstring(fid.read())

    return img

def _real_size_out_svg(gg, height, width, dpi, limitsize=True):
    """
    Calculate the output size for a plotnine.ggplot object saving as an
    svg

    Notes
    -----
    This function is useful given default approach for the saving of
    images uses `bbox_inches="tight"`. This appears to be done since to obtain
    desirable containment of all parts in the image (not to overflow the
    provided space) and because matplotlib's `plt.tight_layout()` doesn't
    preform as expected for the `plotnine.ggplot` objects.

    This code leverages ideas that are presented in a blog post by Kavi Gupta
    at https://kavigupta.org/2019/05/18/Setting-the-size-of-figures-in-matplotlib/

    Arguments
    ---------
    gg : plotnine.ggplot.ggplot
        ggplot object to calculate optimal size
    height : float
        desired height of svg output (in inches)
    width : float
        desired width of svg output (in inches)
    dpi : float
        dots per inch of saved object
    limitsize : boolean
        logic if plotnine's ggplot.save function should check if the requested
        width and height in inches are greater than 50 (assumes the user
        accidentally entered in these values w.r.t. pixels)

    Returns
    -------
    tuple
        of the actual height and width (in inches) of the svg image that would
        be created if the above
    """
    img = _raw_gg_to_svg(gg,
                         height=height,
                         width=width,
                         dpi=dpi, limitsize=limitsize)
    # TODO: transform this to getting inches right away?
    new_width, new_height = _transform_size_to_pt(img.get_size())

    return to_inches(new_width, "pt", dpi), to_inches(new_height, "pt", dpi)

def _select_correcting_size_svg(gg, height, width, dpi, limitsize=True,
                    eps=1e-2, maxIter=20, min_size_px=10, throw_error=True):
    """
    Obtain the correct input saving size plotnine.ggplot object to actual
    obtain desired height and width (inches)

    Notes
    -----
    This function is useful given default approach for the saving of
    images uses `bbox_inches="tight"`. This appears to be done since to obtain
    desirable containment of all parts in the image (not to overflow the
    provided space) and because matplotlib's `plt.tight_layout()` doesn't
    preform as expected for the `plotnine.ggplot` objects.

    This code leverages ideas that are presented in a blog post by Kavi Gupta
    at https://kavigupta.org/2019/05/18/Setting-the-size-of-figures-in-matplotlib/.
    It is iterative procedure in nature (the reason for eps and maxIter), eps
    looks at the difference between the desired and obtained height and width.

    Arguments
    ---------
    gg : plotnine.ggplot.ggplot
        ggplot object to calculate optimal size
    height : float
        desired height of svg output (in inches)
    width : float
        desired width of svg output (in inches)
    dpi : float
        dots per inch of saved object
    limitsize : boolean
        logic if plotnine's ggplot.save function should check if the requested
        width and height in inches are greater than 50 (assumes the user
        accidentally entered in these values w.r.t. pixels)
    eps : float
        maximum allowed difference between height and width output versus the
        desired output
    maxIter : int
        maximum number of steps that can be used to the difference
        between desired and output height and width within minimum distance
    min_size_px : int
        early stopping rule if converging height or width has a pixel size
        smaller than or equal to this value (assumes process will not converge)
    throw_error : boolean
        logic if an error should be thrown if the convergence fails. If False,
        then this will return ratios of width_requested/width_obtained,
        height_requested/height_obtained, and a boolean = False.

    Returns
    -------
    tuple
        if the process converges successful, it will return a three value
        tuple of a width and height to provide desired measures and a
        boolean value (True). if process fails to converge, either this
        functions raises an error (if throw_error is True), or a three value
        tuple of a scaled width_requested/width_obtained,
        height_requested/height_obtained, and a boolean value (False).
    """
    # starting at desired values (a reasonsable starting values)
    desired_width, desired_height = width, height
    current_width, current_height = width, height

    deltas = [] # how close we've gotten
    while True:
        actual_width, actual_height = _real_size_out_svg(gg=gg,
                                                        height=current_height,
                                                        width=current_width,
                                                        dpi=dpi,
                                                        limitsize=limitsize)
        current_width *= desired_width / actual_width
        current_height *= desired_height / actual_height
        deltas.append(abs(actual_width - desired_width) +
                      abs(actual_height - desired_height))

        # decisions to terminate interation
        if deltas[-1] < eps:
            return current_width, current_height, True
        elif len(deltas) > maxIter:
            error_str = "unable to get correct size within "+\
                                "epsilon and number of interations"
            break
        elif current_width * dpi < min_size_px or \
            current_height * dpi < min_size_px:
            error_str = "height or width is too small for "+\
                             "acceptable image"
            break

    if throw_error:
        raise StopIteration(error_str)
    else:
        actual_width, actual_height = _real_size_out_svg(gg=gg,
                                            height=desired_height,
                                            width=desired_width,
                                            dpi=dpi,
                                            limitsize=limitsize)
        return desired_width/actual_width, \
            desired_height/actual_height, \
            False

[docs]def gg_to_svg(gg, width, height, dpi, limitsize=True, eps=1e-2, maxIter=20, min_size_px=10): """ Convert plotnine ggplot figure to svg and return it (with close to perfect sizing). Arguments --------- gg: plotnine.ggplot.ggplot object to save as a png image width : float width in inches to be passed to the plotnine's ggplot.save function height: float height in inches to be passed to the plotnine's ggplot.save function dpi: int dots per inch, to be passed to the plotnine's ggplot.save function limitsize : boolean logic if plotnine's ggplot.save function should check if the requested width and height in inches are greater than 50 (assumes the user accidentally entered in these values w.r.t. pixels) eps : float maximum allowed difference between height and width output versus the desired output maxIter : int maximum number of steps that can be used to the difference between desired and output height and width within minimum distance min_size_px : int early stopping rule if converging height or width has a pixel size smaller than or equal to this value (assumes process will not converge) Returns ------- svgutils.tranform representation of the ggplot object (aka an svg representation) Notes ----- Code idea motified by the stackoverflow question here, https://stackoverflow.com/questions/8598673/how-to-save-a-pylab-figure-into-in-memory-file-which-can-be-read-into-pil-image/8598881 and truly influenced by svgutils.transform.from_mpl function. """ correct_width_in, \ correct_height_in, _ = _select_correcting_size_svg(gg=gg, height=height, width=width, dpi=dpi, limitsize=limitsize, maxIter=maxIter, eps=eps, min_size_px=min_size_px) svg = _raw_gg_to_svg(gg, width=correct_width_in, height=correct_height_in, dpi=dpi) current_size_raw = svg.get_size() current_size = _transform_size_to_pt(current_size_raw) desired_size_raw = (str(from_inches(width, "pt",dpi))+"pt", str(from_inches(height, "pt",dpi))+"pt") desired_size = _transform_size_to_pt(desired_size_raw) scale = _proposed_scaling_both(current_size, desired_size) inner_root = svg.getroot() inner_root.moveto(x=0,y=0,scale_x=scale[0], scale_y=scale[1]) scaled_svg = sg.SVGFigure() scaled_svg.set_size((str(current_size[0]*scale[0])+"pt", str(current_size[1]*scale[1])+"pt")) scaled_svg.append(inner_root) scaled_size_raw = scaled_svg.get_size() return scaled_svg
def _raw_mpt_to_svg(fig, ax, width, height, dpi): """ ... taken from proof_of_concept_wrapper's fa_encapsulate """ # preserve fig.figsize attribute after processing base_figsize = fig.get_size_inches() self.fig.set_size_inches((width, height)) fid = io.StringIO() self.fig.savefig(fid, format="svg") fid.seek(0) image_string = fid.read() img = sg.fromstring(image_string) # preserve fig.figsize attribute after processing if base_figsize is not None: self.fig.set_size_inches(base_figsize) return img
[docs]def mpt_to_svg(gg, width, height, dpi): # need to make wrapping to deal with sizing like we did for gg_to_svg... # # we also need to think about the default figsize and how to incorporate it # if width and height aren't defined (does that belong here.) raise ValueError("Todo: impliment")
_file_options = ["pdf", "png", "ps", "eps", "jpg", "jpeg", "svg"] def _save_svg_wrapper(svg, filename, width, height, dpi=300, _format=None, verbose=True): """ save svg object to a range of different file names Arguments --------- svg: svgutils.transform.SVGFigure svg object to save filename : str local string to save the file to (this can also be at least io.BytesIO) width : float width of output image in inches (this should actually be associated with the svg...) height : float height of svg in inches (this should actually be associated with the svg...) dpi : int or float dots per square inch, default is 300 _format : str string of format (error tells options). If provided this is the format used, if None, then we'll try to use the filename extension. verbose : bool If `True`, print the saving information. Returns ------- None saves to a file """ # format checking if _format is None: dot_ending = re.findall("\\..+$", filename)[0] _format = re.sub("\\.", "", dot_ending) _format = _format.lower() if _format not in _file_options: raise ValueError("format / end of file name must be one of\n{}".format(_file_options)) # verbosity if verbose: warnings.warn("Saving {0:,.2g} x {1:,.2g} inch image.".format( width, height), CowpatchWarning) warnings.warn('Filename: {}'.format(filename), CowpatchWarning) if _format == "svg": svg.save(filename) elif _format == "pdf": base_image_string = svg.to_str() cairosvg.svg2pdf(bytestring=base_image_string, write_to=filename, output_width=width * 96, output_height=height * 96) elif _format == "ps": base_image_string = svg.to_str() cairosvg.svg2ps(bytestring=base_image_string, write_to=filename, output_width=width * 96, output_height=height * 96) elif _format == "eps": base_image_string = svg.to_str() cairosvg.svg2eps(bytestring=base_image_string, write_to=filename, output_width=width * 96, output_height=height * 96) else: # raster base_image_string = svg.to_str() if dpi != 96: scale = dpi / 96 else: scale = 1 if _format == "png": cairosvg.svg2png(bytestring=base_image_string, write_to=filename, scale=scale, output_width=width * 96 * scale, output_height=height * 96 * scale) elif _format == "jpeg" or _format == "jpg": fid = io.BytesIO() out_bytes = cairosvg.svg2png(bytestring=base_image_string, write_to=fid, scale=scale, output_width=width * 96 * scale, output_height=height * 96 * scale) img_png = Image.open(io.BytesIO(fid.getvalue())) img_png.save(filename) def _show_image(svg, width, height, dpi=300, verbose=True): """ display svg object for user (either run from command line or jupyter notebook) Arguments --------- svg: svgutils.transform.SVGFigure svg object to save width : float width of output image in inches (this should actually be associated with the svg...) height : float height of svg in inches (this should actually be associated with the svg...) Returns ------- None shows svg object (with provided width + height + dpi) Note ---- If run from the command line, the image will be presented using matplotlib's plotting tool with a png representation of the object. If run within a jupyter notebook, the object will leverage ipython's internal svg presenter to present the object as real svg object. Both approaches do not allow for resizing of the image and seeing the image correct itself to the new size, which is a bummer for command line usage. """ if verbose: warnings.warn("Showing {0:,.2g} x {1:,.2g} inch image.".format( width, height), CowpatchWarning) ipython_info = IPython.get_ipython() if ipython_info is None or ipython_info.config.get("IPKernelApp") is None: # base python or ipython in the terminal will just show png ---------- fid = io.BytesIO() _save_svg_wrapper(svg, filename = fid, width = width, height = height, dpi = dpi, _format = "png", verbose=False) img = mpimg.imread(io.BytesIO(fid.getvalue())) fig, ax = plt.subplots(figsize=(width, height)) ax.imshow(img) ax.axis("off") fig.tight_layout() plt.show() else: # jupyter notebook ------ base_image_string = svg.to_str() IPython.display.display(IPython.display.SVG(data = base_image_string)) def _uniquify_svg_safe(svg_obj, str_update): """ Update svg code to 'uniquify' svg but by making sure 'url(#___)' Arguments --------- svg_obj : svg object svg object, svgutils.transform.SVGFigure str_update : str string addition for ids (required to include '_' if desired it) Return ------ svg_obj : svg object updated svg object, svgutils.transform.SVGFigure Details ------- Internally this function calls "_uniquify_svg_str_safe" for the vast majority of the heavy lifting """ # string collection inner_svg = copy.deepcopy(svg_obj) svg_str = str(inner_svg.to_str()) # updating string updated_svg_str = _uniquify_svg_str_safe(svg_str, str_update=str_update) # need to identify start and end of svg to re-convert it to and svg_object start = re.search("<svg", updated_svg_str) end = re.search("</svg>", updated_svg_str) updated_end_svg = sg.fromstring(updated_svg_str[start.start():end.end()]) return updated_end_svg def _uniquify_svg_str_safe(svg_str, str_update): """ Update svg code to 'uniquify' svg but by making sure 'url(#___)' Arguments --------- svg_str : str svg string presentation str_update : str string addition for ids (required to include '_' if desired it) Return ------ svg_str : str updated svg code """ # identify which ids are linked to "url(#___)" and must be extended ids_to_extend = \ np.unique( [re.sub("(url\(\#)|(\))", "", x) for x in re.findall("url\(\#[A-Za-z0-9\_]+\)", svg_str)]) for old_id_name in ids_to_extend: new_id_name = old_id_name + str_update # id svg_str = re.sub("id=\"{0}\"".format(old_id_name), "id=\"{0}\"".format(new_id_name), svg_str) # url(#) svg_str = re.sub("url\(\#{0}\)".format(old_id_name), "url(#{0})".format(new_id_name), svg_str) return svg_str