backend_cairo.py 18 KB

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