Source code for cand.canvas


import paranoid as pns
import numpy as np
import matplotlib
import matplotlib.figure
from PIL import Image, PngImagePlugin
import fitz as mupdf # PyMuPDF
import tempfile
import atexit
import os
from .metrics import Metric, Vector, Point, BinopPoint, BinopVector, Height, Width
from .fontant import find_font, find_font_family, MultipleFontsFoundError, NoFontFoundError
from ._version import __version__

_idstr = f"CanD {__version__} (github.com/mwshinn/cand)"

# If IPython is installed, try to import the display code for it.
try:
    from IPython.display import Image as IPython_Image, display as IPython_display
except ImportError:
    pass

[docs] @pns.paranoidclass class Canvas: """Canvas is a convenient way of arranging and organizing axes and other elements on a figure. Create a Canvas by specifying its x and y size in inches (`size_x` and `size_y`). Optionally, specify the font size in points (`fontsize`) and the font name as a valid postscript font (`font`). A Canvas makes it easy to add create attractive composite layouts in matplotlib. When a figure consists of just a single axis, it is not difficult to create an attractive layout. However, when many axes are combined (e.g. into subpanels), it becomes more difficult to standardize the components such as font size and style. Additionally, for non-grid layouts, it is difficult to correctly compute the position of the axes, align text and arrows, and other layout-related tasks. Canvas provides a set of features to make layouts and fonts trivial. """ @pns.accepts(pns.Self, pns.Number, pns.Number, pns.String) @pns.paranoidconfig(unit_test=False) def __init__(self, size_x, size_y, unit="inches"): self.axes = dict() self.default_unit = "figure" self.fontsize = 8 self.fontsize_ticks = 8 self.fontsize_title = 8 self.images = [] self.tmpfiles = [] self.font = dict(name="DejaVu Sans", stretch="normal") atexit.register(self._cleanup) self.localRc = {} self.backend = "default" # Create default units. Dictionary of tuples indexed by unit # name. First two elements are x and y scale (with respect to # inches) and the last is the origin of the coordinate system. if unit in ["inches", "in", "inch"]: size_x_inches = size_x size_y_inches = size_y elif unit in ["cm", "centimeter", "centimeters", "centimetre", "centimetres"]: size_x_inches = size_x/2.54 size_y_inches = size_y/2.54 elif unit in ["mm", "millimeter", "millimeters", "millimetre", "millimetres"]: size_x_inches = size_x/25.4 size_y_inches = size_y/25.4 else: raise ValueError("Invalid unit") # Create matplotlib figure object figsize = (size_x_inches, size_y_inches) self.figure = matplotlib.figure.Figure(figsize=figsize) self.size = figsize # Size of the figure in inches self.trans_absolute = self.figure.dpi_scale_trans self.units = dict() self.add_unit("in", Vector(1/size_x_inches, 1/size_y_inches, "figure")) self.units["inch"] = self.units["in"] self.units["inches"] = self.units["in"] self.add_unit("cm", Vector(1/2.54, 1/2.54, "inches")) self.units["centimeters"] = self.units["cm"] self.units["centimeter"] = self.units["cm"] self.units["centimetres"] = self.units["cm"] self.units["centimetre"] = self.units["cm"] self.add_unit("mm", Vector(.1, .1, "cm")) self.units["millimeter"] = self.units["mm"] self.units["millimeters"] = self.units["mm"] self.units["millimetre"] = self.units["mm"] self.units["millimetres"] = self.units["mm"] self.add_unit("pt", Vector(1/72, 1/72, "inches")) self.units["point"] = self.units["pt"] self.units["points"] = self.units["pt"] self.add_unit("px", Vector(1/self.figure.dpi, 1/self.figure.dpi, "in")) self.units["pixel"] = self.units["px"] self.units["pixels"] = self.units["px"] def _cleanup(self): for f in self.tmpfiles: os.remove(f) def set_font(self, name, *, size=None, weight=None, style=None, stretch=None, foundry=None, special=None, opticalsize=None, monospace=None, ticksize=None, titlesize=None): if size: # Set up font sizes self.localRc['font.size'] = size self.localRc['axes.titlesize'] = titlesize or size self.localRc['axes.labelsize'] = size self.localRc['xtick.labelsize'] = ticksize or size self.localRc['ytick.labelsize'] = ticksize or size self.localRc['legend.fontsize'] = size self.localRc['figure.titlesize'] = titlesize or size self.fontsize = size self.fontsize_ticks = ticksize or size self.fontsize_title = titlesize or size newfont = {} newfont['name'] = name if weight is not None: newfont['weight'] = weight if style is not None: newfont['style'] = style if stretch is not None: newfont['stretch'] = stretch if foundry is not None: newfont['foundry'] = foundry if special is not None: newfont['special'] = special if monospace is not None: newfont['monospace'] = monospace if opticalsize is not None: newfont['opticalsize'] = opticalsize print(newfont) try: self._get_font(**newfont) # Run once to confirm the font is valid except MultipleFontsFoundError as e: e.args = ("Font specified in call to Canvas.set_font() was not specific enough.\n"+e.args[0],) + e.args[1:] raise self.font = newfont def _get_font(self, name=None, *, size=None, weight=None, style=None, stretch=None, foundry=None, special=None, opticalsize=None, monospace=None): if name is None: defaultfont = self.font.copy() else: defaultfont = {} # Otherwise, go through and make any necessary changes to the default # Canvas font. if name is not None: defaultfont['name'] = name if weight is not None: defaultfont['weight'] = weight if style is not None: defaultfont['style'] = style if stretch is not None: defaultfont['stretch'] = stretch if foundry is not None: defaultfont['foundry'] = foundry if special is not None: defaultfont['special'] = special if monospace is not None: defaultfont['monospace'] = monospace if opticalsize is not None: defaultfont['opticalsize'] = opticalsize if size is None: size = self.fontsize # If we just want the bold or italic versions of a font, we can use the # font family method. if ('weight' not in defaultfont.keys() or defaultfont['weight'] == "bold") and \ ('style' not in defaultfont.keys() or defaultfont['style'] in ["italic", "oblique"]): family = find_font_family(name=defaultfont['name'], stretch=defaultfont.get('stretch', None), foundry=defaultfont.get('foundry', None), special=defaultfont.get('special', None), opticalsize=defaultfont.get('opticalsize', None), monospace=defaultfont.get('monospace', None)) if 'weight' not in defaultfont.keys() and 'style' not in defaultfont.keys(): key = "regular" elif 'weight' in defaultfont.keys() and 'style' not in defaultfont.keys(): key = "bold" elif 'weight' not in defaultfont.keys() and 'style' in defaultfont.keys(): key = "italic" else: key = "bolditalic" if key in family.keys(): fprops = matplotlib.font_manager.FontProperties(fname=family[key]['fname'], size=size) return fprops # If not, we use the more heavy duty font selection tool fontfile = find_font(**defaultfont)['fname'] fprops = matplotlib.font_manager.FontProperties(fname=fontfile, size=size) return fprops
[docs] @pns.accepts(pns.Self, pns.Maybe(pns.String), pns.String) def use_latex(self, preamble=None, engine="pdflatex"): """Use latex to render all text. `engine` is the latex engine ("texsystem" in matplotlib lingo) used to render the text. It can be "pdflatex", "lualatex", or "xelatex". Note that this will break math font compatibility. Because latex is being used to render the fonts, you must import a package that allows latex to render in the desired font. For example, if `engine` is "pdflatex", to get Helvetica, set preamble=r"\\usepackage[scaled]{helvet}\\usepackage[helvet]{sfmath}". """ self.backend = "latex" self.latex_engine = engine self.latex_preamble = preamble
[docs] @pns.accepts(pns.Self, pns.String, Vector, Point) @pns.ensures('not self.is_valid_identifier(name)') def add_unit(self, name, scale, origin=Point(0, 0, "absolute")): """Create a new unit of measure. A unit is defined by an affine transformation. It has an origin and a scale in the x and y dimensions. The name of the unit is given by `name`, the scale in the x and y directions is given by `scale`, and the origin of the coordinate system is optionally specified by `origin`. """ assert self.is_valid_identifier(name), f"Invalid unit name {name!r}" scale = self.convert_to_absolute_length(scale) origin = self.convert_to_absolute_coord(origin) self.units[name] = (scale.width().x, scale.height().y, origin)
[docs] @pns.accepts(pns.Self, pns.String) def set_default_unit(self, name): """Changes the default unit for the Canvas. When a unit is not specified for a Point or a Vector, it uses the default unit of the Canvas. The default is initially set to figure coordinates. """ assert self.is_unit(name), f"Invalid unit name {name!r} set as default" self.default_unit = name
[docs] @pns.accepts(pns.Self, pns.String, Point, Point) @pns.ensures("not self.is_valid_identifier(name)") def add_axis(self, name, pos_ll, pos_ur): """Create a new axis on the Canvas. Create a new matplotlib axis named `name`, with the lower left corner at point `pos_ll` and the upper right corner at `pos_ur`. Note that the axis labels, title, and other elements may fall outside of this bounding box. The axis of a Canvas object "c" with name "axname" can be accessed by: ax = c.ax("axname") Then, ax can be used like a normal matplotlib axis. This also creates two new coordinate systems: one is given by the axis' name, which uses the coordinates of the data in the axis, and "axis_" prepended to the axis' name, which is (0,0) at the lower left corner and (1,1) at the upper right corner """ assert name not in self.axes.keys(), "Axis name alredy exists" assert self.is_valid_identifier(name), f"Invalid axis name {name!r}" assert name != "figure", "Invalid axis name 'figure'" # Need to use figure coordinates here because of a bug in # matplotlib which won't accept the 'transform' argument to # add_axes. pt_ll = self.convert_to_figure_coord(pos_ll) pt_ur = self.convert_to_figure_coord(pos_ur) ax = self.figure.add_axes([pt_ll.x, pt_ll.y, pt_ur.x-pt_ll.x, pt_ur.y-pt_ll.y], label=name) self.axes[name] = ax return ax
[docs] @pns.accepts(pns.Self, pns.String) @pns.ensures("self.is_unit(name)") def ax(self, name): """Return the axis of name `name`.""" return self.axes[name]
#@pns.accepts(pns.Self, pns.String, Point, Point, pns.Or(pns.Tuple(pns.Number, pns.Number), matplotlib.colors.Normalize))
[docs] def add_colorbar(self, name, pos_ll, pos_ur, bounds, **kwargs): """Add a colorbar. Name the colorbar `name`, which will be an axis just like any other Canvas axis. The lower left corner is at `pos_ll` and the upper right corner is at `pos_ur`. `bounds` should be a tuple of numbers, representing the lower and upper bounds of the colorbar, respectively. All other arguments are passed directly to matplotlib.colorbar.ColorbarBase. Of particular note is the argument `cmap`, defining the colormap. Return the ColorbarBase object. """ ax = self.add_axis(name, pos_ll, pos_ur) if isinstance(bounds, tuple): norm = matplotlib.colors.Normalize(vmin=bounds[0], vmax=bounds[1]) else: norm = bounds size = self.convert_to_absolute_coord(pos_ur - pos_ll) orientation = "horizontal" if size.x > size.y else "vertical" if 'cmap' in kwargs: kwargs['cmap'] = matplotlib.cm.get_cmap(kwargs['cmap']) colorbar = matplotlib.colorbar.ColorbarBase(ax, norm=norm, orientation=orientation, **kwargs) return colorbar
[docs] @pns.accepts(pns.Self, pns.String) @pns.returns(pns.Boolean) def is_unit(self, name): """Whether the value is a in use as a unit of measure. `name` is a name to test. Returns True if it is in use or False if it is not. """ if name in ["default", "figure", "absolute", "-absolute", "various"]: return True if name in self.axes.keys(): return True if name.startswith("axis_") and self.is_unit(name[5:]): return True if name in self.units.keys(): return True return False
[docs] @pns.accepts(pns.Self, pns.String) @pns.returns(pns.Boolean) def is_valid_identifier(self, name): """Whether the value can be used as an axis or unit name. `name` is a name to test. Returns True if it can be used or False if it cannot (e.g. it is a reserved keyword). """ if name.startswith("axis_"): return False return not self.is_unit(name)
[docs] @pns.accepts(pns.Self, Metric) @pns.returns(Metric) @pns.ensures("return.coordinate == 'absolute'") def convert_to_absolute_coord(self, point): """Convert the coordinate system of the Point or Vector `point` to be "figure". We can convert any coordinate system to the "figure" coordinate system for a given Point or Vector. This is useful for comparing Points and Vectors, and also used internally as a universal coordinate system. It also collapses Binop objects into Points or Vectors. """ if isinstance(point, Vector): return self.convert_to_absolute_length(point) if point.coordinate == "default": return self.convert_to_absolute_coord(Point(point.x, point.y, self.default_unit)) if point.coordinate == "absolute": return point if point.coordinate == "figure": return Point(point.x*self.size[0], point.y*self.size[1], "absolute") if point.coordinate == "-absolute": return Point(self.size[0]-point.x, self.size[1]-point.y, "absolute") if point.coordinate in ["Msize", "fontsize"]: # Msize for backward compatibility return self.convert_to_absolute_coord(Point(point.x*self.fontsize, point.y*self.fontsize, "point")) if point.coordinate in self.axes.keys(): # The call to autoscale_view fix the problem that automatic data # limits are updated lazily, and thus, gives an outdated transData # matrix until a display function is called. self.axes[point.coordinate].autoscale_view() tf_data = self.axes[point.coordinate].transData tf_fig = self.trans_absolute.inverted() x,y = tf_fig.transform(tf_data.transform((point.x, point.y))) return Point(x, y, "absolute") if point.coordinate.startswith("axis_") and point.coordinate[5:] in self.axes.keys(): tf_ax = self.axes[point.coordinate[5:]].transAxes tf_fig = self.trans_absolute.inverted() x,y = tf_fig.transform(tf_ax.transform((point.x, point.y))) return Point(x, y, "absolute") if point.coordinate in self.units: u = point.coordinate return Vector(point.x*self.units[u][0], point.y*self.units[u][1], "absolute") + self.units[u][2] if isinstance(point, BinopPoint): # Call recursively, but handle the scalar case separately if isinstance(point.lhs, Point) or isinstance(point.lhs, Vector): lhs = self.convert_to_absolute_coord(point.lhs) else: lhs = point.lhs if isinstance(point.rhs, Point) or isinstance(point.rhs, Vector): rhs = self.convert_to_absolute_coord(point.rhs) else: rhs = point.rhs return BinopPoint.op_table[point.op](lhs, rhs) raise ValueError("Invalid point coordinate system %s" % point.coordinate)
[docs] @pns.accepts(pns.Self, Vector) @pns.returns(Vector) @pns.ensures("return.coordinate == 'absolute'") def convert_to_absolute_length(self, vector): """Convert the coordinate system of the Vector `vector` to be "figure". We can convert any coordinate system to the "figure" coordinate system for a given Vector. This is useful for comparing vectors, and also used internally as a universal coordinate system. It also collapses BinopVectors into Vectors. """ if vector.coordinate == "absolute": return vector elif isinstance(vector, BinopVector): # Call recursively, but handle the scalar case separately if isinstance(vector.lhs, Point) or isinstance(vector.lhs, Vector): lhs = self.convert_to_absolute_coord(vector.lhs) else: lhs = vector.lhs if isinstance(vector.rhs, Point) or isinstance(vector.rhs, Vector): rhs = self.convert_to_absolute_coord(vector.rhs) else: rhs = vector.rhs return BinopVector.op_table[vector.op](lhs, rhs) else: origin = Point(0, 0, vector.coordinate) return self.convert_to_absolute_coord(origin+vector) - self.convert_to_absolute_coord(origin)
[docs] @pns.accepts(pns.Self, Metric) @pns.returns(Metric) @pns.ensures("return.coordinate == 'figure'") def convert_to_figure_coord(self, point): """Converts units in a Point or Vector to "figure".""" if isinstance(point, Vector): return self.convert_to_figure_length(point) p = self.convert_to_absolute_coord(point) return Point(p.x / self.size[0], p.y / self.size[1], "figure")
[docs] @pns.accepts(pns.Self, Vector) @pns.returns(Vector) @pns.ensures("return.coordinate == 'figure'") def convert_to_figure_length(self, vec): """Converts units in a Vector to "figure".""" v = self.convert_to_absolute_length(vec) return Vector(v.x / self.size[0], v.y / self.size[1], "figure")
[docs] @pns.accepts(pns.Self, pns.List(Point)) def add_poly(self, points, **kwargs): """Draw a polygon with given vertices. Vertices are passed as a list of Point objects via the `points` argument. All other keyword arguments are passed directly to matplotlib.patches.Polygon. """ np_points = np.zeros((len(points), 2)) for i,p in enumerate(points): pt = self.convert_to_absolute_coord(p) np_points[i] = [pt.x, pt.y] if "fill" not in kwargs.keys(): kwargs['fill'] = False poly = matplotlib.patches.Polygon(np_points, transform=self.trans_absolute, **kwargs) self.figure.add_artist(poly)
[docs] @pns.accepts(pns.Self, Point, Point, pns.Maybe(pns.String)) def add_rect(self, pos_ll, pos_ur, unitname=None, **kwargs): """Draw a rectangle. The lower left corner is the Point `pos_ll` and the upper right corner is the Point `pos_ur`. All other keyword arguments are passed directly to matplotlib.patches.Polygon. """ pt_ll = self.convert_to_absolute_coord(pos_ll) pt_ur = self.convert_to_absolute_coord(pos_ur) connect = pt_ur - pt_ll # When drawing a box you have to duplicate the last point for # some reason... probably a bug in matplotlib self.add_poly([pt_ll, pt_ll+connect.height(), pt_ur, pt_ll+connect.width(), pt_ll, pt_ll], **kwargs) if unitname is not None: assert self.is_valid_identifier(unitname), f"Invalid axis name {unitname!r}" self.add_unit(unitname, (pos_ur-pos_ll), pos_ll)
[docs] @pns.accepts(pns.Self, Point, Point, pns.Maybe(pns.String)) def add_box(self, pos_ll, pos_ur, unitname=None, **kwargs): """Draw a bounding box. The lower left corner is the Point `pos_ll` and the upper right corner is the Point `pos_ur`. All other keyword arguments are passed directly to matplotlib.patches.FancyBboxPatch. Note that this differs from add_rect because it uses the FancyBboxPatch rather than matplotlib.patches.Polygon. Thus, this function can be used for, e.g., boxes with round corners """ pt_ll = self.convert_to_absolute_coord(pos_ll) pt_ur = self.convert_to_absolute_coord(pos_ur) diff = pt_ur - pt_ll if "fill" not in kwargs.keys(): kwargs['fill'] = False if 'mutation_scale' not in kwargs.keys(): kwargs['mutation_scale'] = 1/2 # 1/2 inch mutation scale box = matplotlib.patches.FancyBboxPatch((pt_ll.x, pt_ll.y), diff.x, diff.y, transform=self.trans_absolute, **kwargs) self.figure.add_artist(box) if unitname is not None: assert self.is_valid_identifier(unitname), f"Invalid axis name {unitname!r}" self.add_unit(unitname, (pos_ur-pos_ll), pos_ll)
[docs] @pns.accepts(pns.Self, Point, Point) def add_ellipse(self, pos_ll, pos_ur, **kwargs): """Draw an ellipse. The lower left corner is the Point `pos_ll` and the upper right corner is the Point `pos_ur`. Note that rotation is not currently possible because it is not clear in which coordinate system the rotation would be applied. All other keyword arguments are passed directly to matplotlib.patches.Ellipse. """ pt_ll = self.convert_to_absolute_coord(pos_ll) pt_ur = self.convert_to_absolute_coord(pos_ur) diff = pt_ur - pt_ll center = pt_ll | pt_ur if "angle" in kwargs.keys(): print("Warning: the 'angle' keyword passed to add_ellipse may give unexpected results.") # When drawing a box you have to duplicate the last point for # some reason... probably a bug in matplotlib e = matplotlib.patches.Ellipse(xy=tuple(center), width=diff.width().x, height=diff.height().y, transform=self.trans_absolute, **kwargs) self.figure.add_artist(e)
[docs] def add_arrow(self, frm, to, arrowstyle="->,head_width=3,head_length=4", lw=2, linestyle='solid', **kwargs): """Draw an arrow. Draw an arrow from Point `frm` to Point `to`. All other keyword arguments are passed directly to matplotlib.patches.FancyArrowPatch. Reasonable default arguments are given, but these can be overridden. """ pt_frm = self.convert_to_absolute_coord(frm) pt_to = self.convert_to_absolute_coord(to) pt_delta = pt_frm - pt_to if "linewidth" in kwargs: lw = kwargs['linewidth'] # if "shrinkA" not in kwargs: # kwargs['shrinkA'] = 0 # if "shrinkB" not in kwargs: # kwargs['shrinkB'] = 0 arrow = matplotlib.patches.FancyArrowPatch(tuple(pt_frm), tuple(pt_to), transform=self.trans_absolute, arrowstyle=arrowstyle, lw=lw, linestyle=linestyle, **kwargs) self.figure.patches.append(arrow)
[docs] def add_text(self, text, pos, font=None, size=None, weight=None, style=None, stretch=None, foundry=None, special=None, opticalsize=None, monospace=None, **kwargs): """Add text at a given point. Draw the text `text` at Point `pos`. The argument `weight` specifies the font weight, which varies depending on the weights of the font, but potential options may be "light", "roma", "bold", "heavy", "black". The size is given by `size` in points, which defaults to the Canvas' default. The width is given by "stretch", which can be "consensed", "normal", or "wide", depending on the font. All other keyword arguments are identical to those of matplotlib.pyplot.text. Notably, the `horizontalalignment` and `verticalalignment` arguments are often useful. (`ha` and `va` are also accepted as aliases for matplotlib compatibility). The `rotation` keyword may also be useful for rotated text. """ if size is None: size = self.fontsize kwargs = kwargs.copy() if 'horizontalalignment' not in kwargs: kwargs['horizontalalignment'] = kwargs['ha'] if 'ha' in kwargs else 'center' if 'verticalalignment' not in kwargs: kwargs['verticalalignment'] = kwargs['va'] if 'va' in kwargs else 'center' if 'fontname' in kwargs.keys() and font is None: font = kwargs['fontname'] pt = self.convert_to_absolute_coord(pos) # Check valid font names with matplotlib.font_manager.FontManager().findfont(name) #self.figure.text(pt.x, pt.y, text, transform=self.figure.transFigure, fontdict={'fontname': "Helvetica Neue LT Std", "fontsize": 20}) fprops = self._get_font(name=font, weight=weight, size=size, stretch=stretch, style=style, foundry=foundry, special=special, opticalsize=opticalsize, monospace=monospace) self.figure.text(pt.x, pt.y, text, transform=self.trans_absolute, fontproperties=fprops, fontsize=size, **kwargs)
[docs] def add_line(self, frm, to, **kwargs): """Draw a line. Draw a line from Point `frm` to Point `to`. All other keyword arguments are passed directly to matplotlib.lines.Line2D. """ frm = self.convert_to_absolute_coord(frm) to = self.convert_to_absolute_coord(to) l2d = matplotlib.lines.Line2D([frm.x, to.x], [frm.y, to.y], transform=self.trans_absolute, **kwargs) self.figure.add_artist(l2d)
[docs] def add_marker(self, pos, **kwargs): """Draw a matplotlib marker. Plot a marker at Point `pos`. All other keyword arguments are passed directly to matplotlib.lines.Line2D. """ pos = self.convert_to_absolute_coord(pos) l2d = matplotlib.lines.Line2D([pos.x], [pos.y], transform=self.trans_absolute, **kwargs) self.figure.add_artist(l2d)
[docs] @pns.accepts(pns.Self, Point, pns.List(pns.Tuple(pns.String, pns.Dict(k=pns.String, v=pns.Unchecked))), pns.Maybe(pns.Natural1), Metric, Metric, Metric) def add_legend(self, pos_tl, els, fontsize=None, line_spacing=Height(2.2, "Msize"), sym_width=Width(2.3, "Msize"), padding_sep=Width(1.2, "Msize")): """Add a legend without using the matplotlib API. The top-left corner of the legend should be located at the Point `pos_tl`. The `els` argument should be a list of tuples representing the elements to include in the legend. The first element of each tuple should be the name of the legend item, the second element should be a dictionary of line properties to be passed to the add_line and add_marker functions, with an optional dictionary element in the dictionary, text_params passed to add_text. To withhold drawing a line and only draw markers, set 'linestyle' to the string 'None'. Additional parameters control formatting. `line_spacing` determines spacing between each line of descriptive text in the legend. `sym_width` is the width of the symbols (lines and markers). `padding_sep` is the separation between the symbols and the descriptive text. """ if fontsize is None: fontsize = self.fontsize pos_tl = self.convert_to_absolute_coord(pos_tl) assert len(els) >= 1 # Get the text height fprops = self._get_font() # All params are in units of M width or height padding_top = Height(0, "Msize") # Space on top of figure padding_left = Width(0, "Msize") # Space on left of lines # Convert these to an easier coordinate system padding_top = self.convert_to_absolute_length(padding_top) padding_left = self.convert_to_absolute_length(padding_left) padding_sep = self.convert_to_absolute_length(padding_sep) line_spacing = self.convert_to_absolute_length(line_spacing) sym_width = self.convert_to_absolute_length(sym_width) top_left = pos_tl - padding_top + padding_left for i in range(0, len(els)): # Figure out the vertical position of this element of the legend y_offset = -1*line_spacing*i # Draw the text params_dict = els[i][1].copy() params_text = params_dict.pop('text_params', {}) self.add_text(els[i][0], top_left + sym_width + padding_sep + y_offset, horizontalalignment="left", size=fontsize, **params_text) pt1 = top_left + y_offset pt2 = top_left + sym_width + y_offset # Draw the line params_nomarker = params_dict.copy() params_nomarker['markersize'] = 0 self.add_line(pt1, pt2, **params_nomarker) # Draw the marker. We need this in an if statement due to # a bug in matplotlib. params_noline = params_dict.copy() params_noline['linestyle'] = 'None' self.add_marker(pt1+(pt2-pt1)/2, **params_noline)
[docs] @pns.accepts(pns.Self, pns.List(pns.Or(pns.Tuple(pns.String, pns.String), pns.Tuple(pns.String, pns.String, Metric))), pns.Natural0) def add_figure_labels(self, labs, size=12): """Add letter labels to axes. `labs` should be a list of tuples of length 2 or 3. The first element of each tuple is a string of the label text, most commonly a single lowercase letter. The second element of the tuple is the name of an axis on the canvas. The optional final element may be either a point (to specify manual positioning) or a vector (to specify an offset from the default position). """ for l in labs: offset = Vector(-.15, .12, "absolute") unit_name = "axis_"+l[1] if l[1] in self.axes.keys() else l[1] assert self.is_unit(unit_name), f"Invalid unit or axis {l[1]} in figure label" loc = Point(0, 1, unit_name) + offset if len(l) == 3: if isinstance(l[2], Vector): loc += l[2] elif isinstance(l[2], Point): loc = l[2] self.add_text(l[0], loc, weight="bold", size=size)
[docs] def fix_fonts(self): """Convert all text to the desired font. This function will fix all font objects within the figure. This must be called before displaying or saving the Canvas. Usually this is called automatically, but can be called manually as well. """ fprops = self._get_font() try: fprops_bold = self._get_font(weight="bold") except (MultipleFontsFoundError, NoFontFoundError): fprops_bold = fprops try: fprops_it = self._get_font(style="italic") except (MultipleFontsFoundError, NoFontFoundError): fprops_it = fprops fprops_ticks = self._get_font(size=self.fontsize_ticks) fprops_title = self._get_font(size=self.fontsize_title) for ax in self.figure.axes: for label in ax.get_xticklabels(): label.set_fontproperties(fprops_ticks) for label in ax.get_yticklabels(): label.set_fontproperties(fprops_ticks) ax.set_xlabel(ax.get_xlabel(), fontproperties=fprops) ax.set_ylabel(ax.get_ylabel(), fontproperties=fprops) ax.set_title(ax.get_title(), fontproperties=fprops_title) if ax.legend_: for t in ax.legend_.texts: t.set_fontproperties(fprops) # Get Helvetica for math as well self.localRc['mathtext.fontset'] = 'custom' self.localRc['mathtext.rm'] = fprops.get_fontconfig_pattern() self.localRc['mathtext.default'] = "rm" self.localRc['mathtext.bf'] = fprops_bold.get_fontconfig_pattern() self.localRc['mathtext.it'] = fprops_it.get_fontconfig_pattern() self.localRc['mathtext.cal'] = fprops.get_fontconfig_pattern() self.localRc['mathtext.tt'] = fprops.get_fontconfig_pattern() self.localRc['mathtext.sf'] = fprops.get_fontconfig_pattern() # Workaround for a bug in matplotlib where there are no # negative signs in front of axis labels in latex mode if self.backend == "latex": self.localRc['axes.unicode_minus'] = False
def _grid_space(self, frm, to, spacing, count): dist = to-frm figsize = (dist-(count-1)*spacing)/count pos = [] for i in range(0, count): base = frm+i*(figsize+spacing) pos.append((base, base+figsize)) return pos
[docs] @pns.accepts(pns.Self, pns.List(pns.Maybe(pns.String)), pns.Natural1, Point, Point, pns.Maybe(Vector), pns.Maybe(Vector), pns.Maybe(Vector), pns.Maybe(Vector), pns.Maybe(Vector), pns.Maybe(Vector), pns.Maybe(pns.String)) @pns.requires("int(spacing_x is not None) + int(size_x is not None) + int(spacing is not None) + int(size is not None) == 1") # Exactly one of spacing_x, size_x, spacing, or size must be specified @pns.requires("int(spacing_y is not None) + int(size_y is not None) + int(spacing is not None) + int(size is not None) == 1") # Exactly one of spacing_y, size_y, spacing, or size must be specified def add_grid(self, names, nrows, pos_ll, pos_ur, spacing_x=None, spacing_y=None, spacing=None, size_x=None, size_y=None, size=None, unitname=None): """Create a grid of axes. Axes are specified by the `names` argument, a (one-dimensional) list of strings specifying the names of the axes to be created on the grid. Optionally an element may be None, indicating that no axis should be created at this location on the grid. Names start on the top line and go left to right and then line by line, just like words on a page. The argument `nrows` specifies how many rows should be created; the number of columns is implied by the number of rows and the length of `names`. The lower left corner of the grid is located as Point `pos_ll` and the upper right corner is at Point `pos_ur`. There are two ways to specify spacing of axes. One method is to specify the size of the axes by the Vector `size`, or component-wise by the Width `size_x` and Height `size_y`. Axes will be automatically spaced to maintain the given size The other method is to specify the spacing of the axes with the Vector `spacing` or component-wise with the Width `spacing_x` and Height `spacing_y`. The axes will be automatically sized to give the desired spacing. Finally, it is possible to mix these styles by specifying `spacing_x` and `size_y` or vice versa. Optionally, `unitname` may specify a name for relative units based on the grid as a whole, i.e. where the origin is located at the bottom left corner of the bottom left figure in the grid, and (1,1) is located at the upper right corner of the upper right grid. """ if spacing is not None: spacing_x = spacing.width() spacing_y = spacing.height() if size is not None: size_x = size.width() size_y = size.height() ncols = len(names)//nrows + int(len(names) % nrows != 0) pt_ll = self.convert_to_absolute_coord(pos_ll) pt_ur = self.convert_to_absolute_coord(pos_ur) if size_x is not None: size_x = self.convert_to_absolute_length(size_x) if ncols > 1: spacing_x = ((pt_ur - pt_ll).width() - size_x * ncols)/(ncols-1) elif ncols == 1: spacing_x = size_x*0 w = (pt_ur-pt_ll).width() pt_ll = pt_ll + (w - size_x)/2 pt_ur = pt_ur - (w - size_x)/2 if size_y is not None: size_y = self.convert_to_absolute_length(size_y) if nrows > 1: spacing_y = ((pt_ur - pt_ll).height() - size_y * nrows)/(nrows-1) elif nrows == 1: spacing_y = size_y*0 h = (pt_ur-pt_ll).height() pt_ll = pt_ll + (h - size_y)/2 pt_ur = pt_ur - (h - size_y)/2 spacing_x = self.convert_to_absolute_length(spacing_x) spacing_y = self.convert_to_absolute_length(spacing_y) posx = self._grid_space(pt_ll.x, pt_ur.x, spacing_x.x, ncols) posy = list(reversed(self._grid_space(pt_ll.y, pt_ur.y, spacing_y.y, nrows))) for i in range(0, len(names)): x = i % ncols y = i // ncols if names[i] is not None: self.add_axis(names[i], Point(posx[x][0], posy[y][0], "absolute"), Point(posx[x][1], posy[y][1], "absolute")) if unitname is not None: assert self.is_valid_identifier(unitname), f"Invalid axis name {unitname!r}" self.add_unit(unitname, (pt_ur-pt_ll), pt_ll)
[docs] @pns.accepts(pns.Self, pns.String, pns.Maybe(pns.Natural1)) def save(self, filename, dpi=600, *args, **kwargs): """Save the Canvas to a png or pdf file. The filename is specified by the string `filename`. Optionally, the `dpi` argument may specify the dots per inch (dpi) of the output .png file, where larger numbers indicate a higher resolution and larger file size. This is most relevant to .png output, but is also used when individual axes are rasterized through ax.set_rasterized(). Any additional arguments or keyword arguments are passed to the "savefig" function in matplotlib. """ filetypes = ['png', 'pdf'] filetype = next(ft for ft in filetypes if filename.endswith("."+ft)) self.fix_fonts() # Force a white background in jupyter, which makes it transparent if self._in_jupyter(): self.figure.patch.set_alpha(1) matplotlib.rc('pdf', fonttype=42) # This embeds (rather than subsets) all fonts in PDFs. # Lazy importing of matplotlib backend. See https://matplotlib.org/3.3.1/tutorials/introductory/usage.html#the-builtin-backends if self.backend == "latex": from matplotlib.backends.backend_pgf import FigureCanvasPgf mplcanvas = FigureCanvasPgf(self.figure) matplotlib.rc('pgf', texsystem=self.latex_engine) if self.latex_preamble is not None: self.localRc['pgf.preamble'] = matplotlib.rcParams['pgf.preamble'] + self.latex_preamble elif self.backend == "default": from matplotlib.backends.backend_agg import FigureCanvasAgg mplcanvas = FigureCanvasAgg(self.figure) with matplotlib.rc_context(rc=self.localRc): self.figure.savefig(filename, dpi=dpi, *args, **kwargs) if filetype == "png": with Image.open(filename) as img: imgtext = img.text img = img.convert('RGBA') for image in self.images: if image[0].endswith(".pdf"): # Convert pdf to png first pdf = mupdf.open(image[0]) page = pdf[0] imgpath = tempfile.mkstemp('.png')[1] zoom = int(np.ceil(dpi/72)) if dpi else 1 try: page.getPixmap(alpha=True, matrix=mupdf.Matrix(zoom, zoom)).writeImage(imgpath) except AttributeError: page.get_pixmap(alpha=True, matrix=mupdf.Matrix(zoom, zoom)).save(imgpath) else: imgpath = image[0] with Image.open(imgpath) as subimg: subimg = subimg.convert('RGBA') imwidth = img.size[0] imheight = img.size[1] pos_ll = image[1] pos_ur = image[2] # subimg.thumbnail(size) bounds = (int(imwidth*pos_ll.x), int(imheight*(1-pos_ur.y)), int(imwidth*pos_ur.x), int(imheight*(1-pos_ll.y))) subimg_size = (bounds[2]-bounds[0], bounds[3]-bounds[1]) subimg = subimg.resize(subimg_size, Image.LANCZOS) img.alpha_composite(subimg, bounds[0:2]) existing_meta = ("; "+imgtext['Software']) if 'Software' in imgtext.keys() else "" imgtext["Software"] = f"{_idstr}{existing_meta}" newmeta = PngImagePlugin.PngInfo() for k,v in imgtext.items(): newmeta.add_text(k, v) img.save(filename, pnginfo=newmeta) elif filetype == "pdf": pdf = mupdf.open(filename) page = pdf[0] pwidth = page.bound().width pheight = page.bound().height for image in self.images: pos_ll = image[1] pos_ur = image[2] rect = mupdf.Rect(pwidth*pos_ll.x, pheight*(1-pos_ur.y), pwidth*pos_ur.x, pheight*(1-pos_ll.y)) #rect = mupdf.Rect(0.0, 0.0, 200.0, 200.0) #imagedoc = mupdf.open(image[0]) #imagepdf_bytes = imagedoc.convertToPDF() #imagepdf = mupdf.open("pdf", imagepdf_bytes) #page.showPDFpage(rect, imagepdf) if image[0].endswith(".pdf"): toinsert = mupdf.open(image[0]) try: page.showPDFpage(rect, src=toinsert, keep_proportion=False) except AttributeError: page.show_pdf_page(rect, toinsert, keep_proportion=False) else: try: page.insertImage(rect, filename=image[0], keep_proportion=False) except AttributeError: page.insert_image(rect, filename=image[0], keep_proportion=False) pdf.metadata['creator'] = f"{_idstr}; {pdf.metadata['creator']}" pdf.metadata['producer'] = f"{_idstr}; {pdf.metadata['producer']}" try: pdf.setMetadata(pdf.metadata) except AttributeError: pdf.set_metadata(pdf.metadata) pdf.save(pdf.name, deflate=True, incremental=True, encryption=mupdf.PDF_ENCRYPT_KEEP) pdf.close()
[docs] @pns.accepts(pns.Self, Vector, Point) def debug_grid(self, spacing, origin=Point(0, 0, "absolute"), **kwargs): """Create a grid to help you design a layout. This function is intended to help in the process of designing a layout, rather than as a feature of a finished figure. It draws a grid over the top of the canvas at spacing defined by the Vector `spacing`. Optionally, it begins this grid at the Point `origin`. """ bottom_left = Point(0, 0, "figure") top_right = Point(1, 1, "figure") args = {"zorder": 100, "alpha": .2, "c": "k"} args.update(kwargs) i = 0 while True: xpos = i*spacing.width() + origin if self.convert_to_figure_coord(xpos).x >= 0: self.add_line(xpos >> bottom_left, xpos >> top_right, **args) else: break i -= 1 i = 0 while True: xpos = i*spacing.width() + origin if self.convert_to_figure_coord(xpos).x <= 1: self.add_line(xpos >> bottom_left, xpos >> top_right, **args) else: break i += 1 i = 0 while True: ypos = i*spacing.height() + origin if self.convert_to_figure_coord(ypos).y >= 0: self.add_line(bottom_left >> ypos, top_right >> ypos, **args) else: break i -= 1 i = 0 while True: ypos = i*spacing.height() + origin if self.convert_to_figure_coord(ypos).y <= 1: self.add_line(bottom_left >> ypos, top_right >> ypos, **args) else: break i += 1
[docs] def add_image(self, filename, pos, unitname=None, height=None, width=None, horizontalalignment=None, verticalalignment=None, ha=None, va=None): """Add a png or pdf image to the Canvas. Insert a .png or .pdf file overlaid on the Canvas. The string `filename` is the filename of the image, the Point `pos` is the location of the image, aligned according to `horizontalalignment` (may be "left", "center", or "right") and `verticalalignment` (may be "top", "center", or "bottom"). For compatibility, `ha` and `va` may be used instead. Either a Height `height` or a Width `width` must be specified. The image will be scaled in the unspecified direction to maintain the aspect ratio. If both `height` and `width` are specified, then the image's aspect ratio will be ignored. Note: As the conversion processes for png and pdf export use different libraries, there may be slight differences in output depending on output format. Optionally, `unitname` may specify a unit for the image, i.e. the origin is located at the bototm left corner of the image, and (1,1) is located at the upper right corner of the image. """ if horizontalalignment is None: horizontalalignment = ha if ha is not None else "center" if verticalalignment is None: verticalalignment = va if va is not None else "center" pos_ll = self.convert_to_figure_coord(pos) assert height is not None or width is not None, "Either height or width must be given" if filename.endswith(".pdf"): pdf = mupdf.open(filename) page = pdf[0] imwidth = page.bound().width imheight = page.bound().height else: with Image.open(filename) as img: imwidth = img.size[0] imheight = img.size[1] if width is None: height = self.convert_to_figure_length(height.height()) width = Width(height.y * (self.size[1]/self.size[0]) * (imwidth/imheight), "figure") elif height is None: width = self.convert_to_figure_length(width.width()) height = Height(width.x * (self.size[0]/self.size[1]) * (imheight/imwidth), "figure") else: height = self.convert_to_figure_length(height.height()) width = self.convert_to_figure_length(width.width()) pos_ur = pos_ll + height + width if horizontalalignment == "center": shift = (pos_ur-pos_ll).width()/2 pos_ur -= shift pos_ll -= shift elif horizontalalignment == "right": shift = (pos_ur-pos_ll).width() pos_ur -= shift pos_ll -= shift if verticalalignment == "center": shift = (pos_ur-pos_ll).height()/2 pos_ur -= shift pos_ll -= shift elif verticalalignment == "top": shift = (pos_ur-pos_ll).height() pos_ur -= shift pos_ll -= shift assert 0 <= pos_ll.x and 0 <= pos_ll.y and pos_ur.x <= 1 and pos_ur.y <= 1, "Coordinates must not go off the screen" filetypes = ["png", "jpg", "jpeg", "gif", "pdf"] assert any(ft for ft in filetypes if filename.endswith("."+ft)) self.images.append((filename, pos_ll, pos_ur)) if unitname is not None: assert self.is_valid_identifier(unitname), f"Invalid axis name {unitname!r}" self.add_unit(unitname, (pos_ur-pos_ll), pos_ll)
def _in_jupyter(self): """Test if we are in a Jupyter notebook or in the IPython interpreter.""" try: get_ipython except NameError: return False return True
[docs] def show(self, **kwargs): """Display the Canvas in a new window (non-blocking) or in Jupyter. Keyword arguments are the same as they are for Canvas.save, with the exception of "filename", which is of course not available here. """ # Set a low resolution by default so plots aren't too big for the # screen if "dpi" not in kwargs.keys(): kwargs['dpi'] = 100 # Save a temporary image file with the plot tmp = tempfile.mkstemp('.png')[1] self.tmpfiles.append(tmp) self.save(tmp, **kwargs) # Display, either in a new window, or in Jupyter if self._in_jupyter(): IPython_display(IPython_Image(filename=tmp)) else: Image.open(tmp).show()
# TODO: # TODO add "thin" to font-manager.py in matplotlib line 81 # TODO set metadata for png/pdf export # TODO test all fonts for all backends, and figure out pgf backend