From 8e49ba03e87d380b1f2562286c320c4106a00452 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Mon, 17 May 2021 23:57:59 +0200 Subject: [PATCH] Alternate implementation of tight_bbox. Instead of trying to fix the position of all artists during the tight_bbox save (which causes all kinds of grief), instead intercept all calls to renderer drawing methods and perform the shifting there. This is mostly at the proof of concept stage. The advantage is that this could perhaps ultimately without having to implement anything on the renderer side (a single implemenation of patched drawing methods suffices); the problem is that the patching may be rather tricky to get exact. For example, patching draw_text seems tricky, because some backends do not actually use the `x` and `y` parameters of `draw_text`, but rather directly fiddle with the Text object. Some other issues with dpi changes (for fixed-dpi vector formats) are also present. --- lib/matplotlib/backend_bases.py | 27 ++-- lib/matplotlib/backends/backend_mixed.py | 30 +++++ lib/matplotlib/figure.py | 160 ++++++++++++++++++++++- 3 files changed, 200 insertions(+), 17 deletions(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 872550018f3f..7d9b55ff7b1f 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -45,7 +45,7 @@ import matplotlib as mpl from matplotlib import ( _api, backend_tools as tools, cbook, colors, docstring, textpath, - tight_bbox, transforms, widgets, get_backend, is_interactive, rcParams) + transforms, widgets, get_backend, is_interactive, rcParams) from matplotlib._pylab_helpers import Gcf from matplotlib.backend_managers import ToolManager from matplotlib.cbook import _setattr_cm @@ -2276,8 +2276,9 @@ def print_figure( if bbox_inches is None: bbox_inches = rcParams['savefig.bbox'] - if (self.figure.get_constrained_layout() or - bbox_inches == "tight"): + orig_dpi = self.figure.dpi + + if self.figure.get_constrained_layout() or bbox_inches == "tight": # we need to trigger a draw before printing to make sure # CL works. "tight" also needs a draw to get the right # locations: @@ -2289,6 +2290,8 @@ def print_figure( with getattr(renderer, "_draw_disabled", nullcontext)(): self.figure.draw(renderer) + save_dpi = self.figure.dpi + if bbox_inches: if bbox_inches == "tight": bbox_inches = self.figure.get_tightbbox( @@ -2296,14 +2299,12 @@ def print_figure( if pad_inches is None: pad_inches = rcParams['savefig.pad_inches'] bbox_inches = bbox_inches.padded(pad_inches) - - # call adjust_bbox to save only the given area - restore_bbox = tight_bbox.adjust_bbox(self.figure, bbox_inches, - canvas.fixed_dpi) - - _bbox_inches_restore = (bbox_inches, restore_bbox) - else: - _bbox_inches_restore = None + stack.enter_context( + self.figure._install_bbox_renderer_hook( + bbox_inches + # Affine2D().scale(save_dpi / orig_dpi) + # .transform_bbox(bbox_inches) + )) # we have already done CL above, so turn it off: stack.enter_context(self.figure._cm_set(constrained_layout=False)) @@ -2316,12 +2317,8 @@ def print_figure( facecolor=facecolor, edgecolor=edgecolor, orientation=orientation, - bbox_inches_restore=_bbox_inches_restore, **kwargs) finally: - if bbox_inches and restore_bbox: - restore_bbox() - self.figure.set_canvas(self) return result diff --git a/lib/matplotlib/backends/backend_mixed.py b/lib/matplotlib/backends/backend_mixed.py index 54ca8f81ba02..9ac9fcb8cdaa 100644 --- a/lib/matplotlib/backends/backend_mixed.py +++ b/lib/matplotlib/backends/backend_mixed.py @@ -68,6 +68,36 @@ def __getattr__(self, attr): # to the underlying C implementation). return getattr(self._renderer, attr) + # These methods are explicitly defined as forwarders here so that they + # can be setattr_cm'd by _install_bbox_renderer_hook. + + def draw_path(self, *args, **kwargs): + return self._renderer.draw_path(*args, **kwargs) + + def draw_markers(self, *args, **kwargs): + return self._renderer.draw_markers(*args, **kwargs) + + def draw_path_collection(self, *args, **kwargs): + return self._renderer.draw_path_collection(*args, **kwargs) + + def draw_quad_mesh(self, *args, **kwargs): + return self._renderer.draw_quad_mesh(*args, **kwargs) + + def draw_gouraud_triangle(self, *args, **kwargs): + return self._renderer.draw_gouraud_triangle(*args, **kwargs) + + def draw_gouraud_triangles(self, *args, **kwargs): + return self._renderer.draw_gouraud_triangles(*args, **kwargs) + + def draw_image(self, *args, **kwargs): + return self._renderer.draw_image(*args, **kwargs) + + def draw_tex(self, *args, **kwargs): + return self._renderer.draw_tex(*args, **kwargs) + + def draw_text(self, *args, **kwargs): + return self._renderer.draw_text(*args, **kwargs) + def start_rasterizing(self): """ Enter "raster" mode. All subsequent drawing commands (until diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index 3e03f7a7f198..2d1bec270344 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -14,6 +14,7 @@ Control the default spacing between subplots. """ +import contextlib from contextlib import ExitStack import inspect import logging @@ -39,8 +40,9 @@ import matplotlib.legend as mlegend from matplotlib.patches import Rectangle from matplotlib.text import Text -from matplotlib.transforms import (Affine2D, Bbox, BboxTransformTo, - TransformedBbox) +from matplotlib.transforms import ( + Affine2D, Bbox, BboxTransformTo, IdentityTransform, ScaledTranslation, + TransformedBbox, TransformedPath) import matplotlib._layoutgrid as layoutgrid _log = logging.getLogger(__name__) @@ -2723,6 +2725,9 @@ def clear(self, keep_observers=False): @allow_rasterization def draw(self, renderer): # docstring inherited + if hasattr(self, "_bbox_renderer_hook"): + self._bbox_renderer_hook(renderer) + self._cachedRenderer = renderer # draw the figure bounding box, perhaps none for white figure @@ -2755,6 +2760,157 @@ def draw(self, renderer): self.canvas.draw_event(renderer) + @contextlib.contextmanager + def _install_bbox_renderer_hook(self, bbox_inches): + """ + Helper for rendering only part of the figure. + + Temporarily that the figure's inches bbox is *bbox_inches*. When + `draw` is called, patch the renderer so that all its draws are shifted + according to the origin of *bbox_inches*, and undo the bbox patching. + """ + orig_bbox_inches_bounds = self.bbox_inches.bounds + self.bbox_inches.x1 = bbox_inches.width + self.bbox_inches.y1 = bbox_inches.height + x0 = bbox_inches.x0 + y0 = bbox_inches.y0 + shift_trf = ScaledTranslation(-x0, -y0, self.dpi_scale_trans) + + def bbox_renderer_hook(renderer): + try: # o_: original, s_: shifted. + origs = { + "draw_path": renderer.draw_path, + "draw_markers": renderer.draw_markers, + "draw_path_collection": renderer.draw_path_collection, + "draw_quad_mesh": renderer.draw_quad_mesh, + "draw_gouraud_triangle": renderer.draw_gouraud_triangle, + "draw_gouraud_triangles": renderer.draw_gouraud_triangles, + "draw_image": renderer.draw_image, + "draw_tex": renderer.draw_tex, + "draw_text": renderer.draw_text, + } + + @contextlib.contextmanager + def _shifted_clips_and_unpatched(gc): + cr = gc.get_clip_rectangle() + if cr is not None: + gc.set_clip_rectangle(shift_trf.transform_bbox(cr)) + tp, tr = gc.get_clip_path() + if tp is not None: + gc.set_clip_path(TransformedPath(tp, tr + shift_trf)) + try: + with cbook._setattr_cm(renderer, **origs): + yield + finally: + if tp: + gc.set_clip_path(TransformedPath(tp, tr)) + if cr: + gc.set_clip_rectangle(cr) + + def s_draw_path(gc, path, transform, rgbFace=None): + with _shifted_clips_and_unpatched(gc): + return origs["draw_path"]( + gc, path, transform + shift_trf, rgbFace) + + def s_draw_markers( + gc, marker_path, marker_trans, path, trans, + rgbFace=None): + with _shifted_clips_and_unpatched(gc): + return origs["draw_markers"]( + gc, marker_path, marker_trans, path, + trans + shift_trf, rgbFace) + + def s_draw_path_collection( + gc, master_transform, paths, all_transforms, + offsets, offsetTrans, facecolors, edgecolors, + linewidths, linestyles, antialiaseds, urls, + offset_position): + with _shifted_clips_and_unpatched(gc): + return orig["draw_path_collection"]( + gc, master_transform + shift_trf, paths, + all_transforms, offsets, offsetTrans, facecolors, + edgecolors, linewidths, linestyles, antialiaseds, + urls, offset_position) + + def s_draw_quad_mesh( + gc, master_transform, meshWidth, meshHeight, + coordinates, offsets, offsetTrans, facecolors, + antialiased, edgecolors): + with _shifted_clips_and_unpatched(gc): + return orig["draw_quad_mesh"]( + gc, master_transform + shift_trf, meshWidth, + meshHeight, coordinates, offsets, offsetTrans, + facecolors, antialiased, edgecolors) + + def s_draw_gouraud_triangle(gc, points, colors, transform): + with _shifted_clips_and_unpatched(gc): + return orig["draw_gouraud_triangle"]( + gc, points, colors, transform + shift_trf) + + def s_draw_gouraud_triangles( + gc, triangles_array, colors_array, transform): + with _shifted_clips_and_unpatched(gc): + return orig["draw_gouraud_triangles"]( + gc, triangles_array, colors_array, transform) + + # draw_image may take a transform kwarg, or not. + def s_draw_image(gc, x, y, *args, **kwargs): + with _shifted_clips_and_unpatched(gc): + dx = -x0 * self.dpi + dy = -y0 * self.dpi + return origs["draw_image"]( + gc, x + dx, y + dy, *args, **kwargs) + + def s_draw_tex(gc, x, y, s, prop, angle, *, mtext=None): + with _shifted_clips_and_unpatched(gc): + dx = -x0 * self.dpi + dy = -y0 * self.dpi + if renderer.flipy(): + dy = -dy + return origs["draw_tex"]( + gc, x + dx, y + dy, s, prop, angle, mtext) + + def s_draw_text( + gc, x, y, s, prop, angle, ismath=False, mtext=None): + with _shifted_clips_and_unpatched(gc): + dx = -x0 * self.dpi + dy = -y0 * self.dpi + if renderer.flipy(): + dy = -dy + # Some backends ignore x and y and directly read + # mtext.get_position()/get_unitless_position(). + with cbook._setattr_cm( + mtext, + get_position=lambda: (x + dx, y + dy), + get_unitless_position=lambda: (x + dx, y + dy), + get_transform=lambda: IdentityTransform(), + ): + return origs["draw_text"]( + gc, x + dx, y + dy, s, prop, angle, ismath, + mtext) + + stack.enter_context(cbook._setattr_cm( + renderer, + draw_path=s_draw_path, + draw_markers=s_draw_markers, + draw_path_collection=s_draw_path_collection, + draw_quad_mesh=s_draw_quad_mesh, + draw_gouraud_triangle=s_draw_gouraud_triangle, + draw_gouraud_triangles=s_draw_gouraud_triangles, + draw_image=s_draw_image, + draw_tex=s_draw_tex, + draw_text=s_draw_text, + )) + + finally: + self.bbox_inches.bounds = orig_bbox_inches_bounds + del self._bbox_renderer_hook + + self._bbox_renderer_hook = bbox_renderer_hook + + with ExitStack() as stack: + yield + def draw_no_output(self): """ Draw the figure with no output. Useful to get the final size of