123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500 |
- """
- A Cairo backend for Matplotlib
- ==============================
- :Author: Steve Chaplin and others
- This backend depends on cairocffi or pycairo.
- """
- import functools
- import gzip
- import math
- import numpy as np
- try:
- import cairo
- if cairo.version_info < (1, 14, 0): # Introduced set_device_scale.
- raise ImportError(f"Cairo backend requires cairo>=1.14.0, "
- f"but only {cairo.version_info} is available")
- except ImportError:
- try:
- import cairocffi as cairo
- except ImportError as err:
- raise ImportError(
- "cairo backend requires that pycairo>=1.14.0 or cairocffi "
- "is installed") from err
- from .. import _api, cbook, font_manager
- from matplotlib.backend_bases import (
- _Backend, FigureCanvasBase, FigureManagerBase, GraphicsContextBase,
- RendererBase)
- from matplotlib.font_manager import ttfFontProperty
- from matplotlib.path import Path
- from matplotlib.transforms import Affine2D
- def _append_path(ctx, path, transform, clip=None):
- for points, code in path.iter_segments(
- transform, remove_nans=True, clip=clip):
- if code == Path.MOVETO:
- ctx.move_to(*points)
- elif code == Path.CLOSEPOLY:
- ctx.close_path()
- elif code == Path.LINETO:
- ctx.line_to(*points)
- elif code == Path.CURVE3:
- cur = np.asarray(ctx.get_current_point())
- a = points[:2]
- b = points[-2:]
- ctx.curve_to(*(cur / 3 + a * 2 / 3), *(a * 2 / 3 + b / 3), *b)
- elif code == Path.CURVE4:
- ctx.curve_to(*points)
- def _cairo_font_args_from_font_prop(prop):
- """
- Convert a `.FontProperties` or a `.FontEntry` to arguments that can be
- passed to `.Context.select_font_face`.
- """
- def attr(field):
- try:
- return getattr(prop, f"get_{field}")()
- except AttributeError:
- return getattr(prop, field)
- name = attr("name")
- slant = getattr(cairo, f"FONT_SLANT_{attr('style').upper()}")
- weight = attr("weight")
- weight = (cairo.FONT_WEIGHT_NORMAL
- if font_manager.weight_dict.get(weight, weight) < 550
- else cairo.FONT_WEIGHT_BOLD)
- return name, slant, weight
- class RendererCairo(RendererBase):
- def __init__(self, dpi):
- self.dpi = dpi
- self.gc = GraphicsContextCairo(renderer=self)
- self.width = None
- self.height = None
- self.text_ctx = cairo.Context(
- cairo.ImageSurface(cairo.FORMAT_ARGB32, 1, 1))
- super().__init__()
- def set_context(self, ctx):
- surface = ctx.get_target()
- if hasattr(surface, "get_width") and hasattr(surface, "get_height"):
- size = surface.get_width(), surface.get_height()
- elif hasattr(surface, "get_extents"): # GTK4 RecordingSurface.
- ext = surface.get_extents()
- size = ext.width, ext.height
- else: # vector surfaces.
- ctx.save()
- ctx.reset_clip()
- rect, *rest = ctx.copy_clip_rectangle_list()
- if rest:
- raise TypeError("Cannot infer surface size")
- _, _, *size = rect
- ctx.restore()
- self.gc.ctx = ctx
- self.width, self.height = size
- def _fill_and_stroke(self, ctx, fill_c, alpha, alpha_overrides):
- if fill_c is not None:
- ctx.save()
- if len(fill_c) == 3 or alpha_overrides:
- ctx.set_source_rgba(fill_c[0], fill_c[1], fill_c[2], alpha)
- else:
- ctx.set_source_rgba(fill_c[0], fill_c[1], fill_c[2], fill_c[3])
- ctx.fill_preserve()
- ctx.restore()
- ctx.stroke()
- def draw_path(self, gc, path, transform, rgbFace=None):
- # docstring inherited
- ctx = gc.ctx
- # Clip the path to the actual rendering extents if it isn't filled.
- clip = (ctx.clip_extents()
- if rgbFace is None and gc.get_hatch() is None
- else None)
- transform = (transform
- + Affine2D().scale(1, -1).translate(0, self.height))
- ctx.new_path()
- _append_path(ctx, path, transform, clip)
- self._fill_and_stroke(
- ctx, rgbFace, gc.get_alpha(), gc.get_forced_alpha())
- def draw_markers(self, gc, marker_path, marker_trans, path, transform,
- rgbFace=None):
- # docstring inherited
- ctx = gc.ctx
- ctx.new_path()
- # Create the path for the marker; it needs to be flipped here already!
- _append_path(ctx, marker_path, marker_trans + Affine2D().scale(1, -1))
- marker_path = ctx.copy_path_flat()
- # Figure out whether the path has a fill
- x1, y1, x2, y2 = ctx.fill_extents()
- if x1 == 0 and y1 == 0 and x2 == 0 and y2 == 0:
- filled = False
- # No fill, just unset this (so we don't try to fill it later on)
- rgbFace = None
- else:
- filled = True
- transform = (transform
- + Affine2D().scale(1, -1).translate(0, self.height))
- ctx.new_path()
- for i, (vertices, codes) in enumerate(
- path.iter_segments(transform, simplify=False)):
- if len(vertices):
- x, y = vertices[-2:]
- ctx.save()
- # Translate and apply path
- ctx.translate(x, y)
- ctx.append_path(marker_path)
- ctx.restore()
- # Slower code path if there is a fill; we need to draw
- # the fill and stroke for each marker at the same time.
- # Also flush out the drawing every once in a while to
- # prevent the paths from getting way too long.
- if filled or i % 1000 == 0:
- self._fill_and_stroke(
- ctx, rgbFace, gc.get_alpha(), gc.get_forced_alpha())
- # Fast path, if there is no fill, draw everything in one step
- if not filled:
- self._fill_and_stroke(
- ctx, rgbFace, gc.get_alpha(), gc.get_forced_alpha())
- def draw_image(self, gc, x, y, im):
- im = cbook._unmultiplied_rgba8888_to_premultiplied_argb32(im[::-1])
- surface = cairo.ImageSurface.create_for_data(
- im.ravel().data, cairo.FORMAT_ARGB32,
- im.shape[1], im.shape[0], im.shape[1] * 4)
- ctx = gc.ctx
- y = self.height - y - im.shape[0]
- ctx.save()
- ctx.set_source_surface(surface, float(x), float(y))
- ctx.paint()
- ctx.restore()
- def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
- # docstring inherited
- # Note: (x, y) are device/display coords, not user-coords, unlike other
- # draw_* methods
- if ismath:
- self._draw_mathtext(gc, x, y, s, prop, angle)
- else:
- ctx = gc.ctx
- ctx.new_path()
- ctx.move_to(x, y)
- ctx.save()
- ctx.select_font_face(*_cairo_font_args_from_font_prop(prop))
- ctx.set_font_size(self.points_to_pixels(prop.get_size_in_points()))
- opts = cairo.FontOptions()
- opts.set_antialias(gc.get_antialiased())
- ctx.set_font_options(opts)
- if angle:
- ctx.rotate(np.deg2rad(-angle))
- ctx.show_text(s)
- ctx.restore()
- def _draw_mathtext(self, gc, x, y, s, prop, angle):
- ctx = gc.ctx
- width, height, descent, glyphs, rects = \
- self._text2path.mathtext_parser.parse(s, self.dpi, prop)
- ctx.save()
- ctx.translate(x, y)
- if angle:
- ctx.rotate(np.deg2rad(-angle))
- for font, fontsize, idx, ox, oy in glyphs:
- ctx.new_path()
- ctx.move_to(ox, -oy)
- ctx.select_font_face(
- *_cairo_font_args_from_font_prop(ttfFontProperty(font)))
- ctx.set_font_size(self.points_to_pixels(fontsize))
- ctx.show_text(chr(idx))
- for ox, oy, w, h in rects:
- ctx.new_path()
- ctx.rectangle(ox, -oy, w, -h)
- ctx.set_source_rgb(0, 0, 0)
- ctx.fill_preserve()
- ctx.restore()
- def get_canvas_width_height(self):
- # docstring inherited
- return self.width, self.height
- def get_text_width_height_descent(self, s, prop, ismath):
- # docstring inherited
- if ismath == 'TeX':
- return super().get_text_width_height_descent(s, prop, ismath)
- if ismath:
- width, height, descent, *_ = \
- self._text2path.mathtext_parser.parse(s, self.dpi, prop)
- return width, height, descent
- ctx = self.text_ctx
- # problem - scale remembers last setting and font can become
- # enormous causing program to crash
- # save/restore prevents the problem
- ctx.save()
- ctx.select_font_face(*_cairo_font_args_from_font_prop(prop))
- ctx.set_font_size(self.points_to_pixels(prop.get_size_in_points()))
- y_bearing, w, h = ctx.text_extents(s)[1:4]
- ctx.restore()
- return w, h, h + y_bearing
- def new_gc(self):
- # docstring inherited
- self.gc.ctx.save()
- self.gc._alpha = 1
- self.gc._forced_alpha = False # if True, _alpha overrides A from RGBA
- return self.gc
- def points_to_pixels(self, points):
- # docstring inherited
- return points / 72 * self.dpi
- class GraphicsContextCairo(GraphicsContextBase):
- _joind = {
- 'bevel': cairo.LINE_JOIN_BEVEL,
- 'miter': cairo.LINE_JOIN_MITER,
- 'round': cairo.LINE_JOIN_ROUND,
- }
- _capd = {
- 'butt': cairo.LINE_CAP_BUTT,
- 'projecting': cairo.LINE_CAP_SQUARE,
- 'round': cairo.LINE_CAP_ROUND,
- }
- def __init__(self, renderer):
- super().__init__()
- self.renderer = renderer
- def restore(self):
- self.ctx.restore()
- def set_alpha(self, alpha):
- super().set_alpha(alpha)
- _alpha = self.get_alpha()
- rgb = self._rgb
- if self.get_forced_alpha():
- self.ctx.set_source_rgba(rgb[0], rgb[1], rgb[2], _alpha)
- else:
- self.ctx.set_source_rgba(rgb[0], rgb[1], rgb[2], rgb[3])
- def set_antialiased(self, b):
- self.ctx.set_antialias(
- cairo.ANTIALIAS_DEFAULT if b else cairo.ANTIALIAS_NONE)
- def get_antialiased(self):
- return self.ctx.get_antialias()
- def set_capstyle(self, cs):
- self.ctx.set_line_cap(_api.check_getitem(self._capd, capstyle=cs))
- self._capstyle = cs
- def set_clip_rectangle(self, rectangle):
- if not rectangle:
- return
- x, y, w, h = np.round(rectangle.bounds)
- ctx = self.ctx
- ctx.new_path()
- ctx.rectangle(x, self.renderer.height - h - y, w, h)
- ctx.clip()
- def set_clip_path(self, path):
- if not path:
- return
- tpath, affine = path.get_transformed_path_and_affine()
- ctx = self.ctx
- ctx.new_path()
- affine = (affine
- + Affine2D().scale(1, -1).translate(0, self.renderer.height))
- _append_path(ctx, tpath, affine)
- ctx.clip()
- def set_dashes(self, offset, dashes):
- self._dashes = offset, dashes
- if dashes is None:
- self.ctx.set_dash([], 0) # switch dashes off
- else:
- self.ctx.set_dash(
- list(self.renderer.points_to_pixels(np.asarray(dashes))),
- offset)
- def set_foreground(self, fg, isRGBA=None):
- super().set_foreground(fg, isRGBA)
- if len(self._rgb) == 3:
- self.ctx.set_source_rgb(*self._rgb)
- else:
- self.ctx.set_source_rgba(*self._rgb)
- def get_rgb(self):
- return self.ctx.get_source().get_rgba()[:3]
- def set_joinstyle(self, js):
- self.ctx.set_line_join(_api.check_getitem(self._joind, joinstyle=js))
- self._joinstyle = js
- def set_linewidth(self, w):
- self._linewidth = float(w)
- self.ctx.set_line_width(self.renderer.points_to_pixels(w))
- class _CairoRegion:
- def __init__(self, slices, data):
- self._slices = slices
- self._data = data
- class FigureCanvasCairo(FigureCanvasBase):
- @property
- def _renderer(self):
- # In theory, _renderer should be set in __init__, but GUI canvas
- # subclasses (FigureCanvasFooCairo) don't always interact well with
- # multiple inheritance (FigureCanvasFoo inits but doesn't super-init
- # FigureCanvasCairo), so initialize it in the getter instead.
- if not hasattr(self, "_cached_renderer"):
- self._cached_renderer = RendererCairo(self.figure.dpi)
- return self._cached_renderer
- def get_renderer(self):
- return self._renderer
- def copy_from_bbox(self, bbox):
- surface = self._renderer.gc.ctx.get_target()
- if not isinstance(surface, cairo.ImageSurface):
- raise RuntimeError(
- "copy_from_bbox only works when rendering to an ImageSurface")
- sw = surface.get_width()
- sh = surface.get_height()
- x0 = math.ceil(bbox.x0)
- x1 = math.floor(bbox.x1)
- y0 = math.ceil(sh - bbox.y1)
- y1 = math.floor(sh - bbox.y0)
- if not (0 <= x0 and x1 <= sw and bbox.x0 <= bbox.x1
- and 0 <= y0 and y1 <= sh and bbox.y0 <= bbox.y1):
- raise ValueError("Invalid bbox")
- sls = slice(y0, y0 + max(y1 - y0, 0)), slice(x0, x0 + max(x1 - x0, 0))
- data = (np.frombuffer(surface.get_data(), np.uint32)
- .reshape((sh, sw))[sls].copy())
- return _CairoRegion(sls, data)
- def restore_region(self, region):
- surface = self._renderer.gc.ctx.get_target()
- if not isinstance(surface, cairo.ImageSurface):
- raise RuntimeError(
- "restore_region only works when rendering to an ImageSurface")
- surface.flush()
- sw = surface.get_width()
- sh = surface.get_height()
- sly, slx = region._slices
- (np.frombuffer(surface.get_data(), np.uint32)
- .reshape((sh, sw))[sly, slx]) = region._data
- surface.mark_dirty_rectangle(
- slx.start, sly.start, slx.stop - slx.start, sly.stop - sly.start)
- def print_png(self, fobj):
- self._get_printed_image_surface().write_to_png(fobj)
- def print_rgba(self, fobj):
- width, height = self.get_width_height()
- buf = self._get_printed_image_surface().get_data()
- fobj.write(cbook._premultiplied_argb32_to_unmultiplied_rgba8888(
- np.asarray(buf).reshape((width, height, 4))))
- print_raw = print_rgba
- def _get_printed_image_surface(self):
- self._renderer.dpi = self.figure.dpi
- width, height = self.get_width_height()
- surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height)
- self._renderer.set_context(cairo.Context(surface))
- self.figure.draw(self._renderer)
- return surface
- def _save(self, fmt, fobj, *, orientation='portrait'):
- # save PDF/PS/SVG
- dpi = 72
- self.figure.dpi = dpi
- w_in, h_in = self.figure.get_size_inches()
- width_in_points, height_in_points = w_in * dpi, h_in * dpi
- if orientation == 'landscape':
- width_in_points, height_in_points = (
- height_in_points, width_in_points)
- if fmt == 'ps':
- if not hasattr(cairo, 'PSSurface'):
- raise RuntimeError('cairo has not been compiled with PS '
- 'support enabled')
- surface = cairo.PSSurface(fobj, width_in_points, height_in_points)
- elif fmt == 'pdf':
- if not hasattr(cairo, 'PDFSurface'):
- raise RuntimeError('cairo has not been compiled with PDF '
- 'support enabled')
- surface = cairo.PDFSurface(fobj, width_in_points, height_in_points)
- elif fmt in ('svg', 'svgz'):
- if not hasattr(cairo, 'SVGSurface'):
- raise RuntimeError('cairo has not been compiled with SVG '
- 'support enabled')
- if fmt == 'svgz':
- if isinstance(fobj, str):
- fobj = gzip.GzipFile(fobj, 'wb')
- else:
- fobj = gzip.GzipFile(None, 'wb', fileobj=fobj)
- surface = cairo.SVGSurface(fobj, width_in_points, height_in_points)
- else:
- raise ValueError(f"Unknown format: {fmt!r}")
- self._renderer.dpi = self.figure.dpi
- self._renderer.set_context(cairo.Context(surface))
- ctx = self._renderer.gc.ctx
- if orientation == 'landscape':
- ctx.rotate(np.pi / 2)
- ctx.translate(0, -height_in_points)
- # Perhaps add an '%%Orientation: Landscape' comment?
- self.figure.draw(self._renderer)
- ctx.show_page()
- surface.finish()
- if fmt == 'svgz':
- fobj.close()
- print_pdf = functools.partialmethod(_save, "pdf")
- print_ps = functools.partialmethod(_save, "ps")
- print_svg = functools.partialmethod(_save, "svg")
- print_svgz = functools.partialmethod(_save, "svgz")
- @_Backend.export
- class _BackendCairo(_Backend):
- backend_version = cairo.version
- FigureCanvas = FigureCanvasCairo
- FigureManager = FigureManagerBase
|