From 5a916187e56b1443e112c12750feb3707a33186b Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Tue, 9 Jun 2020 19:32:22 -0400 Subject: [PATCH] Backport PR #17560: FIX: do not let no-op monkey patches to renderer leak out Merge pull request #17560 from tacaswell/fix_noop_tight_bbox FIX: do not let no-op monkey patches to renderer leak out Conflicts: lib/matplotlib/backend_bases.py - trivial conflicting imports - did not back port other changes to tight box lib/matplotlib/tight_layout.py - ended up not backporting any of the changes --- lib/matplotlib/backend_bases.py | 45 ++++++++++++++++--------- lib/matplotlib/figure.py | 13 ++++--- lib/matplotlib/tests/test_bbox_tight.py | 23 +++++++++++++ 3 files changed, 61 insertions(+), 20 deletions(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index aa62c309b319..ea1f1b13d6b3 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -32,7 +32,7 @@ The base class for the messaging area. """ -from contextlib import contextmanager +from contextlib import contextmanager, suppress from enum import IntEnum import functools import importlib @@ -52,6 +52,7 @@ from matplotlib._pylab_helpers import Gcf from matplotlib.transforms import Affine2D from matplotlib.path import Path +from matplotlib.cbook import _setattr_cm try: from PIL import __version__ as PILLOW_VERSION @@ -712,6 +713,23 @@ def stop_filter(self, filter_func): Currently only supported by the agg renderer. """ + def _draw_disabled(self): + """ + Context manager to temporary disable drawing. + + This is used for getting the drawn size of Artists. This lets us + run the draw process to update any Python state but does not pay the + cost of the draw_XYZ calls on the canvas. + """ + no_ops = { + meth_name: lambda *args, **kwargs: None + for meth_name in dir(RendererBase) + if (meth_name.startswith("draw_") + or meth_name in ["open_group", "close_group"]) + } + + return _setattr_cm(self, **no_ops) + class GraphicsContextBase: """An abstract base class that provides color, line styles, etc.""" @@ -1520,15 +1538,14 @@ def __init__(self, name, canvas, key, x=0, y=0, guiEvent=None): LocationEvent.__init__(self, name, canvas, x, y, guiEvent=guiEvent) -def _get_renderer(figure, print_method, *, draw_disabled=False): +def _get_renderer(figure, print_method): """ Get the renderer that would be used to save a `~.Figure`, and cache it on the figure. - If *draw_disabled* is True, additionally replace drawing methods on - *renderer* by no-ops. This is used by the tight-bbox-saving renderer, - which needs to walk through the artist tree to compute the tight-bbox, but - for which the output file may be closed early. + If you need a renderer without any active draw methods use + renderer._draw_disabled to temporary patch them out at your call site. + """ # This is implemented by triggering a draw, then immediately jumping out of # Figure.draw() by raising an exception. @@ -1544,12 +1561,6 @@ def _draw(renderer): raise Done(renderer) except Done as exc: renderer, = figure._cachedRenderer, = exc.args - if draw_disabled: - for meth_name in dir(RendererBase): - if (meth_name.startswith("draw_") - or meth_name in ["open_group", "close_group"]): - setattr(renderer, meth_name, lambda *args, **kwargs: None) - return renderer @@ -2079,9 +2090,13 @@ def print_figure(self, filename, dpi=None, facecolor=None, edgecolor=None, renderer = _get_renderer( self.figure, functools.partial( - print_method, dpi=dpi, orientation=orientation), - draw_disabled=True) - self.figure.draw(renderer) + print_method, dpi=dpi, orientation=orientation) + ) + ctx = (renderer._draw_disabled() + if hasattr(renderer, '_draw_disabled') + else suppress()) + with ctx: + self.figure.draw(renderer) bbox_artists = kwargs.pop("bbox_extra_artists", None) bbox_inches = self.figure.get_tightbbox(renderer, bbox_extra_artists=bbox_artists) diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index 901408121eaa..c857fffb6a26 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -2478,7 +2478,7 @@ def tight_layout(self, renderer=None, pad=1.08, h_pad=None, w_pad=None, from .tight_layout import ( get_renderer, get_subplotspec_list, get_tight_layout_figure) - + from contextlib import suppress subplotspec_list = get_subplotspec_list(self.axes) if None in subplotspec_list: cbook._warn_external("This figure includes Axes that are not " @@ -2487,10 +2487,13 @@ def tight_layout(self, renderer=None, pad=1.08, h_pad=None, w_pad=None, if renderer is None: renderer = get_renderer(self) - - kwargs = get_tight_layout_figure( - self, self.axes, subplotspec_list, renderer, - pad=pad, h_pad=h_pad, w_pad=w_pad, rect=rect) + ctx = (renderer._draw_disabled() + if hasattr(renderer, '_draw_disabled') + else suppress()) + with ctx: + kwargs = get_tight_layout_figure( + self, self.axes, subplotspec_list, renderer, + pad=pad, h_pad=h_pad, w_pad=w_pad, rect=rect) if kwargs: self.subplots_adjust(**kwargs) diff --git a/lib/matplotlib/tests/test_bbox_tight.py b/lib/matplotlib/tests/test_bbox_tight.py index 1cee27382a42..ef27697c174e 100644 --- a/lib/matplotlib/tests/test_bbox_tight.py +++ b/lib/matplotlib/tests/test_bbox_tight.py @@ -108,3 +108,26 @@ def test_tight_pcolorfast(): # Previously, the bbox would include the area of the image clipped out by # the axes, resulting in a very tall image given the y limits of (0, 0.1). assert width > height + + +def test_noop_tight_bbox(): + from PIL import Image + x_size, y_size = (10, 7) + dpi = 100 + # make the figure just the right size up front + fig = plt.figure(frameon=False, dpi=dpi, figsize=(x_size/dpi, y_size/dpi)) + ax = plt.Axes(fig, [0., 0., 1., 1.]) + fig.add_axes(ax) + ax.set_axis_off() + ax.get_xaxis().set_visible(False) + ax.get_yaxis().set_visible(False) + + data = np.arange(x_size * y_size).reshape(y_size, x_size) + ax.imshow(data) + out = BytesIO() + fig.savefig(out, bbox_inches='tight', pad_inches=0) + out.seek(0) + im = np.asarray(Image.open(out)) + assert (im[:, :, 3] == 255).all() + assert not (im[:, :, :3] == 255).all() + assert im.shape == (7, 10, 4)