backend_cairo.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  1. """
  2. A Cairo backend for Matplotlib
  3. ==============================
  4. :Author: Steve Chaplin and others
  5. This backend depends on cairocffi or pycairo.
  6. """
  7. import functools
  8. import gzip
  9. import math
  10. import numpy as np
  11. try:
  12. import cairo
  13. if cairo.version_info < (1, 14, 0): # Introduced set_device_scale.
  14. raise ImportError(f"Cairo backend requires cairo>=1.14.0, "
  15. f"but only {cairo.version_info} is available")
  16. except ImportError:
  17. try:
  18. import cairocffi as cairo
  19. except ImportError as err:
  20. raise ImportError(
  21. "cairo backend requires that pycairo>=1.14.0 or cairocffi "
  22. "is installed") from err
  23. from .. import _api, cbook, font_manager
  24. from matplotlib.backend_bases import (
  25. _Backend, FigureCanvasBase, FigureManagerBase, GraphicsContextBase,
  26. RendererBase)
  27. from matplotlib.font_manager import ttfFontProperty
  28. from matplotlib.path import Path
  29. from matplotlib.transforms import Affine2D
  30. def _append_path(ctx, path, transform, clip=None):
  31. for points, code in path.iter_segments(
  32. transform, remove_nans=True, clip=clip):
  33. if code == Path.MOVETO:
  34. ctx.move_to(*points)
  35. elif code == Path.CLOSEPOLY:
  36. ctx.close_path()
  37. elif code == Path.LINETO:
  38. ctx.line_to(*points)
  39. elif code == Path.CURVE3:
  40. cur = np.asarray(ctx.get_current_point())
  41. a = points[:2]
  42. b = points[-2:]
  43. ctx.curve_to(*(cur / 3 + a * 2 / 3), *(a * 2 / 3 + b / 3), *b)
  44. elif code == Path.CURVE4:
  45. ctx.curve_to(*points)
  46. def _cairo_font_args_from_font_prop(prop):
  47. """
  48. Convert a `.FontProperties` or a `.FontEntry` to arguments that can be
  49. passed to `.Context.select_font_face`.
  50. """
  51. def attr(field):
  52. try:
  53. return getattr(prop, f"get_{field}")()
  54. except AttributeError:
  55. return getattr(prop, field)
  56. name = attr("name")
  57. slant = getattr(cairo, f"FONT_SLANT_{attr('style').upper()}")
  58. weight = attr("weight")
  59. weight = (cairo.FONT_WEIGHT_NORMAL
  60. if font_manager.weight_dict.get(weight, weight) < 550
  61. else cairo.FONT_WEIGHT_BOLD)
  62. return name, slant, weight
  63. class RendererCairo(RendererBase):
  64. def __init__(self, dpi):
  65. self.dpi = dpi
  66. self.gc = GraphicsContextCairo(renderer=self)
  67. self.width = None
  68. self.height = None
  69. self.text_ctx = cairo.Context(
  70. cairo.ImageSurface(cairo.FORMAT_ARGB32, 1, 1))
  71. super().__init__()
  72. def set_context(self, ctx):
  73. surface = ctx.get_target()
  74. if hasattr(surface, "get_width") and hasattr(surface, "get_height"):
  75. size = surface.get_width(), surface.get_height()
  76. elif hasattr(surface, "get_extents"): # GTK4 RecordingSurface.
  77. ext = surface.get_extents()
  78. size = ext.width, ext.height
  79. else: # vector surfaces.
  80. ctx.save()
  81. ctx.reset_clip()
  82. rect, *rest = ctx.copy_clip_rectangle_list()
  83. if rest:
  84. raise TypeError("Cannot infer surface size")
  85. _, _, *size = rect
  86. ctx.restore()
  87. self.gc.ctx = ctx
  88. self.width, self.height = size
  89. def _fill_and_stroke(self, ctx, fill_c, alpha, alpha_overrides):
  90. if fill_c is not None:
  91. ctx.save()
  92. if len(fill_c) == 3 or alpha_overrides:
  93. ctx.set_source_rgba(fill_c[0], fill_c[1], fill_c[2], alpha)
  94. else:
  95. ctx.set_source_rgba(fill_c[0], fill_c[1], fill_c[2], fill_c[3])
  96. ctx.fill_preserve()
  97. ctx.restore()
  98. ctx.stroke()
  99. def draw_path(self, gc, path, transform, rgbFace=None):
  100. # docstring inherited
  101. ctx = gc.ctx
  102. # Clip the path to the actual rendering extents if it isn't filled.
  103. clip = (ctx.clip_extents()
  104. if rgbFace is None and gc.get_hatch() is None
  105. else None)
  106. transform = (transform
  107. + Affine2D().scale(1, -1).translate(0, self.height))
  108. ctx.new_path()
  109. _append_path(ctx, path, transform, clip)
  110. self._fill_and_stroke(
  111. ctx, rgbFace, gc.get_alpha(), gc.get_forced_alpha())
  112. def draw_markers(self, gc, marker_path, marker_trans, path, transform,
  113. rgbFace=None):
  114. # docstring inherited
  115. ctx = gc.ctx
  116. ctx.new_path()
  117. # Create the path for the marker; it needs to be flipped here already!
  118. _append_path(ctx, marker_path, marker_trans + Affine2D().scale(1, -1))
  119. marker_path = ctx.copy_path_flat()
  120. # Figure out whether the path has a fill
  121. x1, y1, x2, y2 = ctx.fill_extents()
  122. if x1 == 0 and y1 == 0 and x2 == 0 and y2 == 0:
  123. filled = False
  124. # No fill, just unset this (so we don't try to fill it later on)
  125. rgbFace = None
  126. else:
  127. filled = True
  128. transform = (transform
  129. + Affine2D().scale(1, -1).translate(0, self.height))
  130. ctx.new_path()
  131. for i, (vertices, codes) in enumerate(
  132. path.iter_segments(transform, simplify=False)):
  133. if len(vertices):
  134. x, y = vertices[-2:]
  135. ctx.save()
  136. # Translate and apply path
  137. ctx.translate(x, y)
  138. ctx.append_path(marker_path)
  139. ctx.restore()
  140. # Slower code path if there is a fill; we need to draw
  141. # the fill and stroke for each marker at the same time.
  142. # Also flush out the drawing every once in a while to
  143. # prevent the paths from getting way too long.
  144. if filled or i % 1000 == 0:
  145. self._fill_and_stroke(
  146. ctx, rgbFace, gc.get_alpha(), gc.get_forced_alpha())
  147. # Fast path, if there is no fill, draw everything in one step
  148. if not filled:
  149. self._fill_and_stroke(
  150. ctx, rgbFace, gc.get_alpha(), gc.get_forced_alpha())
  151. def draw_image(self, gc, x, y, im):
  152. im = cbook._unmultiplied_rgba8888_to_premultiplied_argb32(im[::-1])
  153. surface = cairo.ImageSurface.create_for_data(
  154. im.ravel().data, cairo.FORMAT_ARGB32,
  155. im.shape[1], im.shape[0], im.shape[1] * 4)
  156. ctx = gc.ctx
  157. y = self.height - y - im.shape[0]
  158. ctx.save()
  159. ctx.set_source_surface(surface, float(x), float(y))
  160. ctx.paint()
  161. ctx.restore()
  162. def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
  163. # docstring inherited
  164. # Note: (x, y) are device/display coords, not user-coords, unlike other
  165. # draw_* methods
  166. if ismath:
  167. self._draw_mathtext(gc, x, y, s, prop, angle)
  168. else:
  169. ctx = gc.ctx
  170. ctx.new_path()
  171. ctx.move_to(x, y)
  172. ctx.save()
  173. ctx.select_font_face(*_cairo_font_args_from_font_prop(prop))
  174. ctx.set_font_size(self.points_to_pixels(prop.get_size_in_points()))
  175. opts = cairo.FontOptions()
  176. opts.set_antialias(gc.get_antialiased())
  177. ctx.set_font_options(opts)
  178. if angle:
  179. ctx.rotate(np.deg2rad(-angle))
  180. ctx.show_text(s)
  181. ctx.restore()
  182. def _draw_mathtext(self, gc, x, y, s, prop, angle):
  183. ctx = gc.ctx
  184. width, height, descent, glyphs, rects = \
  185. self._text2path.mathtext_parser.parse(s, self.dpi, prop)
  186. ctx.save()
  187. ctx.translate(x, y)
  188. if angle:
  189. ctx.rotate(np.deg2rad(-angle))
  190. for font, fontsize, idx, ox, oy in glyphs:
  191. ctx.new_path()
  192. ctx.move_to(ox, -oy)
  193. ctx.select_font_face(
  194. *_cairo_font_args_from_font_prop(ttfFontProperty(font)))
  195. ctx.set_font_size(self.points_to_pixels(fontsize))
  196. ctx.show_text(chr(idx))
  197. for ox, oy, w, h in rects:
  198. ctx.new_path()
  199. ctx.rectangle(ox, -oy, w, -h)
  200. ctx.set_source_rgb(0, 0, 0)
  201. ctx.fill_preserve()
  202. ctx.restore()
  203. def get_canvas_width_height(self):
  204. # docstring inherited
  205. return self.width, self.height
  206. def get_text_width_height_descent(self, s, prop, ismath):
  207. # docstring inherited
  208. if ismath == 'TeX':
  209. return super().get_text_width_height_descent(s, prop, ismath)
  210. if ismath:
  211. width, height, descent, *_ = \
  212. self._text2path.mathtext_parser.parse(s, self.dpi, prop)
  213. return width, height, descent
  214. ctx = self.text_ctx
  215. # problem - scale remembers last setting and font can become
  216. # enormous causing program to crash
  217. # save/restore prevents the problem
  218. ctx.save()
  219. ctx.select_font_face(*_cairo_font_args_from_font_prop(prop))
  220. ctx.set_font_size(self.points_to_pixels(prop.get_size_in_points()))
  221. y_bearing, w, h = ctx.text_extents(s)[1:4]
  222. ctx.restore()
  223. return w, h, h + y_bearing
  224. def new_gc(self):
  225. # docstring inherited
  226. self.gc.ctx.save()
  227. self.gc._alpha = 1
  228. self.gc._forced_alpha = False # if True, _alpha overrides A from RGBA
  229. return self.gc
  230. def points_to_pixels(self, points):
  231. # docstring inherited
  232. return points / 72 * self.dpi
  233. class GraphicsContextCairo(GraphicsContextBase):
  234. _joind = {
  235. 'bevel': cairo.LINE_JOIN_BEVEL,
  236. 'miter': cairo.LINE_JOIN_MITER,
  237. 'round': cairo.LINE_JOIN_ROUND,
  238. }
  239. _capd = {
  240. 'butt': cairo.LINE_CAP_BUTT,
  241. 'projecting': cairo.LINE_CAP_SQUARE,
  242. 'round': cairo.LINE_CAP_ROUND,
  243. }
  244. def __init__(self, renderer):
  245. super().__init__()
  246. self.renderer = renderer
  247. def restore(self):
  248. self.ctx.restore()
  249. def set_alpha(self, alpha):
  250. super().set_alpha(alpha)
  251. _alpha = self.get_alpha()
  252. rgb = self._rgb
  253. if self.get_forced_alpha():
  254. self.ctx.set_source_rgba(rgb[0], rgb[1], rgb[2], _alpha)
  255. else:
  256. self.ctx.set_source_rgba(rgb[0], rgb[1], rgb[2], rgb[3])
  257. def set_antialiased(self, b):
  258. self.ctx.set_antialias(
  259. cairo.ANTIALIAS_DEFAULT if b else cairo.ANTIALIAS_NONE)
  260. def get_antialiased(self):
  261. return self.ctx.get_antialias()
  262. def set_capstyle(self, cs):
  263. self.ctx.set_line_cap(_api.check_getitem(self._capd, capstyle=cs))
  264. self._capstyle = cs
  265. def set_clip_rectangle(self, rectangle):
  266. if not rectangle:
  267. return
  268. x, y, w, h = np.round(rectangle.bounds)
  269. ctx = self.ctx
  270. ctx.new_path()
  271. ctx.rectangle(x, self.renderer.height - h - y, w, h)
  272. ctx.clip()
  273. def set_clip_path(self, path):
  274. if not path:
  275. return
  276. tpath, affine = path.get_transformed_path_and_affine()
  277. ctx = self.ctx
  278. ctx.new_path()
  279. affine = (affine
  280. + Affine2D().scale(1, -1).translate(0, self.renderer.height))
  281. _append_path(ctx, tpath, affine)
  282. ctx.clip()
  283. def set_dashes(self, offset, dashes):
  284. self._dashes = offset, dashes
  285. if dashes is None:
  286. self.ctx.set_dash([], 0) # switch dashes off
  287. else:
  288. self.ctx.set_dash(
  289. list(self.renderer.points_to_pixels(np.asarray(dashes))),
  290. offset)
  291. def set_foreground(self, fg, isRGBA=None):
  292. super().set_foreground(fg, isRGBA)
  293. if len(self._rgb) == 3:
  294. self.ctx.set_source_rgb(*self._rgb)
  295. else:
  296. self.ctx.set_source_rgba(*self._rgb)
  297. def get_rgb(self):
  298. return self.ctx.get_source().get_rgba()[:3]
  299. def set_joinstyle(self, js):
  300. self.ctx.set_line_join(_api.check_getitem(self._joind, joinstyle=js))
  301. self._joinstyle = js
  302. def set_linewidth(self, w):
  303. self._linewidth = float(w)
  304. self.ctx.set_line_width(self.renderer.points_to_pixels(w))
  305. class _CairoRegion:
  306. def __init__(self, slices, data):
  307. self._slices = slices
  308. self._data = data
  309. class FigureCanvasCairo(FigureCanvasBase):
  310. @property
  311. def _renderer(self):
  312. # In theory, _renderer should be set in __init__, but GUI canvas
  313. # subclasses (FigureCanvasFooCairo) don't always interact well with
  314. # multiple inheritance (FigureCanvasFoo inits but doesn't super-init
  315. # FigureCanvasCairo), so initialize it in the getter instead.
  316. if not hasattr(self, "_cached_renderer"):
  317. self._cached_renderer = RendererCairo(self.figure.dpi)
  318. return self._cached_renderer
  319. def get_renderer(self):
  320. return self._renderer
  321. def copy_from_bbox(self, bbox):
  322. surface = self._renderer.gc.ctx.get_target()
  323. if not isinstance(surface, cairo.ImageSurface):
  324. raise RuntimeError(
  325. "copy_from_bbox only works when rendering to an ImageSurface")
  326. sw = surface.get_width()
  327. sh = surface.get_height()
  328. x0 = math.ceil(bbox.x0)
  329. x1 = math.floor(bbox.x1)
  330. y0 = math.ceil(sh - bbox.y1)
  331. y1 = math.floor(sh - bbox.y0)
  332. if not (0 <= x0 and x1 <= sw and bbox.x0 <= bbox.x1
  333. and 0 <= y0 and y1 <= sh and bbox.y0 <= bbox.y1):
  334. raise ValueError("Invalid bbox")
  335. sls = slice(y0, y0 + max(y1 - y0, 0)), slice(x0, x0 + max(x1 - x0, 0))
  336. data = (np.frombuffer(surface.get_data(), np.uint32)
  337. .reshape((sh, sw))[sls].copy())
  338. return _CairoRegion(sls, data)
  339. def restore_region(self, region):
  340. surface = self._renderer.gc.ctx.get_target()
  341. if not isinstance(surface, cairo.ImageSurface):
  342. raise RuntimeError(
  343. "restore_region only works when rendering to an ImageSurface")
  344. surface.flush()
  345. sw = surface.get_width()
  346. sh = surface.get_height()
  347. sly, slx = region._slices
  348. (np.frombuffer(surface.get_data(), np.uint32)
  349. .reshape((sh, sw))[sly, slx]) = region._data
  350. surface.mark_dirty_rectangle(
  351. slx.start, sly.start, slx.stop - slx.start, sly.stop - sly.start)
  352. def print_png(self, fobj):
  353. self._get_printed_image_surface().write_to_png(fobj)
  354. def print_rgba(self, fobj):
  355. width, height = self.get_width_height()
  356. buf = self._get_printed_image_surface().get_data()
  357. fobj.write(cbook._premultiplied_argb32_to_unmultiplied_rgba8888(
  358. np.asarray(buf).reshape((width, height, 4))))
  359. print_raw = print_rgba
  360. def _get_printed_image_surface(self):
  361. self._renderer.dpi = self.figure.dpi
  362. width, height = self.get_width_height()
  363. surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height)
  364. self._renderer.set_context(cairo.Context(surface))
  365. self.figure.draw(self._renderer)
  366. return surface
  367. def _save(self, fmt, fobj, *, orientation='portrait'):
  368. # save PDF/PS/SVG
  369. dpi = 72
  370. self.figure.dpi = dpi
  371. w_in, h_in = self.figure.get_size_inches()
  372. width_in_points, height_in_points = w_in * dpi, h_in * dpi
  373. if orientation == 'landscape':
  374. width_in_points, height_in_points = (
  375. height_in_points, width_in_points)
  376. if fmt == 'ps':
  377. if not hasattr(cairo, 'PSSurface'):
  378. raise RuntimeError('cairo has not been compiled with PS '
  379. 'support enabled')
  380. surface = cairo.PSSurface(fobj, width_in_points, height_in_points)
  381. elif fmt == 'pdf':
  382. if not hasattr(cairo, 'PDFSurface'):
  383. raise RuntimeError('cairo has not been compiled with PDF '
  384. 'support enabled')
  385. surface = cairo.PDFSurface(fobj, width_in_points, height_in_points)
  386. elif fmt in ('svg', 'svgz'):
  387. if not hasattr(cairo, 'SVGSurface'):
  388. raise RuntimeError('cairo has not been compiled with SVG '
  389. 'support enabled')
  390. if fmt == 'svgz':
  391. if isinstance(fobj, str):
  392. fobj = gzip.GzipFile(fobj, 'wb')
  393. else:
  394. fobj = gzip.GzipFile(None, 'wb', fileobj=fobj)
  395. surface = cairo.SVGSurface(fobj, width_in_points, height_in_points)
  396. else:
  397. raise ValueError(f"Unknown format: {fmt!r}")
  398. self._renderer.dpi = self.figure.dpi
  399. self._renderer.set_context(cairo.Context(surface))
  400. ctx = self._renderer.gc.ctx
  401. if orientation == 'landscape':
  402. ctx.rotate(np.pi / 2)
  403. ctx.translate(0, -height_in_points)
  404. # Perhaps add an '%%Orientation: Landscape' comment?
  405. self.figure.draw(self._renderer)
  406. ctx.show_page()
  407. surface.finish()
  408. if fmt == 'svgz':
  409. fobj.close()
  410. print_pdf = functools.partialmethod(_save, "pdf")
  411. print_ps = functools.partialmethod(_save, "ps")
  412. print_svg = functools.partialmethod(_save, "svg")
  413. print_svgz = functools.partialmethod(_save, "svgz")
  414. @_Backend.export
  415. class _BackendCairo(_Backend):
  416. backend_version = cairo.version
  417. FigureCanvas = FigureCanvasCairo
  418. FigureManager = FigureManagerBase