diff --git a/doc/api/next_api_changes/2018-04-29-JMK.rst b/doc/api/next_api_changes/2018-04-29-JMK.rst new file mode 100644 index 000000000000..d25c74062c16 --- /dev/null +++ b/doc/api/next_api_changes/2018-04-29-JMK.rst @@ -0,0 +1,20 @@ +`.matplotlib.Axes.get_tightbbox` now includes all artists +--------------------------------------------------------- + +Layout tools like `.Figure.tight_layout`, ``constrained_layout``, +and ``fig.savefig('fname.png', bbox_inches="tight")`` use +`.matplotlib.Axes.get_tightbbox` to determine the bounds of each axes on +a figure and adjust spacing between axes. + +In Matplotlib 2.2 ``get_tightbbox`` started to include legends made on the +axes, but still excluded some other artists, like text that may overspill an +axes. For Matplotlib 3.0, *all* artists are now included in the bounding box. + +This new default may be overridden in either of two ways: + +1. Make the artist to be excluded a child of the figure, not the axes. E.g., + call ``fig.legend()`` instead of ``ax.legend()`` (perhaps using + `~.matplotlib.Axes.get_legend_handles_labels` to gather handles and labels + from the parent axes). +2. If the artist is a child of the axes, set the artist property + ``artist.set_in_layout(False)``. diff --git a/lib/matplotlib/artist.py b/lib/matplotlib/artist.py index b2d7f971c973..6745ce69e406 100644 --- a/lib/matplotlib/artist.py +++ b/lib/matplotlib/artist.py @@ -114,6 +114,7 @@ def __init__(self): self._sketch = rcParams['path.sketch'] self._path_effects = rcParams['path.effects'] self._sticky_edges = _XYPair([], []) + self._in_layout = True def __getstate__(self): d = self.__dict__.copy() @@ -251,6 +252,33 @@ def get_window_extent(self, renderer): """ return Bbox([[0, 0], [0, 0]]) + def get_tightbbox(self, renderer): + """ + Like `Artist.get_window_extent`, but includes any clipping. + + Parameters + ---------- + renderer : `.RendererBase` instance + renderer that will be used to draw the figures (i.e. + ``fig.canvas.get_renderer()``) + + Returns + ------- + bbox : `.BboxBase` + containing the bounding box (in figure pixel co-ordinates). + """ + + bbox = self.get_window_extent(renderer) + if self.get_clip_on(): + clip_box = self.get_clip_box() + if clip_box is not None: + bbox = Bbox.intersection(bbox, clip_box) + clip_path = self.get_clip_path() + if clip_path is not None and bbox is not None: + clip_path = clip_path.get_fully_transformed_path() + bbox = Bbox.intersection(bbox, clip_path.get_extents()) + return bbox + def add_callback(self, func): """ Adds a callback function that will be called whenever one of @@ -701,6 +729,17 @@ def get_animated(self): "Return the artist's animated state" return self._animated + def get_in_layout(self): + """ + Return boolean flag, ``True`` if artist is included in layout + calculations. + + E.g. :doc:`/tutorials/intermediate/constrainedlayout_guide`, + `.Figure.tight_layout()`, and + ``fig.savefig(fname, bbox_inches='tight')``. + """ + return self._in_layout + def get_clip_on(self): 'Return whether artist uses clipping' return self._clipon @@ -830,6 +869,19 @@ def set_animated(self, b): self._animated = b self.pchanged() + def set_in_layout(self, in_layout): + """ + Set if artist is to be included in layout calculations, + E.g. :doc:`/tutorials/intermediate/constrainedlayout_guide`, + `.Figure.tight_layout()`, and + ``fig.savefig(fname, bbox_inches='tight')``. + + Parameters + ---------- + in_layout : bool + """ + self._in_layout = in_layout + def update(self, props): """ Update this artist's properties from the dictionary *prop*. diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index 7b43c6c4306a..e03ead41791c 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -4110,19 +4110,47 @@ def pick(self, *args): martist.Artist.pick(self, args[0]) def get_default_bbox_extra_artists(self): + """ + Return a default list of artists that are used for the bounding box + calculation. + + Artists are excluded either by not being visible or + ``artist.set_in_layout(False)``. + """ return [artist for artist in self.get_children() - if artist.get_visible()] + if (artist.get_visible() and artist.get_in_layout())] - def get_tightbbox(self, renderer, call_axes_locator=True): + def get_tightbbox(self, renderer, call_axes_locator=True, + bbox_extra_artists=None): """ - Return the tight bounding box of the axes. - The dimension of the Bbox in canvas coordinate. + Return the tight bounding box of the axes, including axis and their + decorators (xlabel, title, etc). + + Artists that have ``artist.set_in_layout(False)`` are not included + in the bbox. + + Parameters + ---------- + renderer : `.RendererBase` instance + renderer that will be used to draw the figures (i.e. + ``fig.canvas.get_renderer()``) + + bbox_extra_artists : list of `.Artist` or ``None`` + List of artists to include in the tight bounding box. If + ``None`` (default), then all artist children of the axes are + included in the tight bounding box. + + call_axes_locator : boolean (default ``True``) + If *call_axes_locator* is ``False``, it does not call the + ``_axes_locator`` attribute, which is necessary to get the correct + bounding box. ``call_axes_locator=False`` can be used if the + caller is only interested in the relative size of the tightbbox + compared to the axes bbox. - If *call_axes_locator* is *False*, it does not call the - _axes_locator attribute, which is necessary to get the correct - bounding box. ``call_axes_locator==False`` can be used if the - caller is only intereted in the relative size of the tightbbox - compared to the axes bbox. + Returns + ------- + bbox : `.BboxBase` + bounding box in figure pixel coordinates. """ bb = [] @@ -4155,11 +4183,14 @@ def get_tightbbox(self, renderer, call_axes_locator=True): if bb_yaxis: bb.append(bb_yaxis) - for child in self.get_children(): - if isinstance(child, OffsetBox) and child.get_visible(): - bb.append(child.get_window_extent(renderer)) - elif isinstance(child, Legend) and child.get_visible(): - bb.append(child._legend_box.get_window_extent(renderer)) + bbox_artists = bbox_extra_artists + if bbox_artists is None: + bbox_artists = self.get_default_bbox_extra_artists() + + for a in bbox_artists: + bbox = a.get_tightbbox(renderer) + if bbox is not None and (bbox.width != 0 or bbox.height != 0): + bb.append(bbox) _bbox = mtransforms.Bbox.union( [b for b in bb if b.width != 0 or b.height != 0]) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 43f22e3f4f90..ce15d9295fac 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -2048,36 +2048,9 @@ def print_figure(self, filename, dpi=None, facecolor=None, edgecolor=None, dryrun=True, **kwargs) renderer = self.figure._cachedRenderer - bbox_inches = self.figure.get_tightbbox(renderer) - bbox_artists = kwargs.pop("bbox_extra_artists", None) - if bbox_artists is None: - bbox_artists = \ - self.figure.get_default_bbox_extra_artists() - - bbox_filtered = [] - for a in bbox_artists: - bbox = a.get_window_extent(renderer) - if a.get_clip_on(): - clip_box = a.get_clip_box() - if clip_box is not None: - bbox = Bbox.intersection(bbox, clip_box) - clip_path = a.get_clip_path() - if clip_path is not None and bbox is not None: - clip_path = \ - clip_path.get_fully_transformed_path() - bbox = Bbox.intersection( - bbox, clip_path.get_extents()) - if bbox is not None and ( - bbox.width != 0 or bbox.height != 0): - bbox_filtered.append(bbox) - - if bbox_filtered: - _bbox = Bbox.union(bbox_filtered) - trans = Affine2D().scale(1.0 / self.figure.dpi) - bbox_extra = TransformedBbox(_bbox, trans) - bbox_inches = Bbox.union([bbox_inches, bbox_extra]) - + bbox_inches = self.figure.get_tightbbox(renderer, + bbox_extra_artists=bbox_artists) pad = kwargs.pop("pad_inches", None) if pad is None: pad = rcParams['savefig.pad_inches'] diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index 6fdf1265ff3b..080ad8a8cfa1 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -1593,10 +1593,7 @@ def draw(self, renderer): try: renderer.open_group('figure') if self.get_constrained_layout() and self.axes: - if True: - self.execute_constrained_layout(renderer) - else: - pass + self.execute_constrained_layout(renderer) if self.get_tight_layout() and self.axes: try: self.tight_layout(renderer, @@ -2181,7 +2178,7 @@ def waitforbuttonpress(self, timeout=-1): def get_default_bbox_extra_artists(self): bbox_artists = [artist for artist in self.get_children() - if artist.get_visible()] + if (artist.get_visible() and artist.get_in_layout())] for ax in self.axes: if ax.get_visible(): bbox_artists.extend(ax.get_default_bbox_extra_artists()) @@ -2189,18 +2186,44 @@ def get_default_bbox_extra_artists(self): bbox_artists.remove(self.patch) return bbox_artists - def get_tightbbox(self, renderer): + def get_tightbbox(self, renderer, bbox_extra_artists=None): """ Return a (tight) bounding box of the figure in inches. - Currently, this takes only axes title, axis labels, and axis - ticklabels into account. Needs improvement. + Artists that have ``artist.set_in_layout(False)`` are not included + in the bbox. + + Parameters + ---------- + renderer : `.RendererBase` instance + renderer that will be used to draw the figures (i.e. + ``fig.canvas.get_renderer()``) + + bbox_extra_artists : list of `.Artist` or ``None`` + List of artists to include in the tight bounding box. If + ``None`` (default), then all artist children of each axes are + included in the tight bounding box. + + Returns + ------- + bbox : `.BboxBase` + containing the bounding box (in figure inches). """ bb = [] + if bbox_extra_artists is None: + artists = self.get_default_bbox_extra_artists() + else: + artists = bbox_extra_artists + + for a in artists: + bbox = a.get_tightbbox(renderer) + if bbox is not None and (bbox.width != 0 or bbox.height != 0): + bb.append(bbox) + for ax in self.axes: if ax.get_visible(): - bb.append(ax.get_tightbbox(renderer)) + bb.append(ax.get_tightbbox(renderer, bbox_extra_artists)) if len(bb) == 0: return self.bbox_inches @@ -2252,6 +2275,10 @@ def tight_layout(self, renderer=None, pad=1.08, h_pad=None, w_pad=None, """ Automatically adjust subplot parameters to give specified padding. + To exclude an artist on the axes from the bounding box calculation + that determines the subplot parameters (i.e. legend, or annotation), + then set `a.set_in_layout(False)` for that artist. + Parameters ---------- pad : float diff --git a/lib/matplotlib/legend.py b/lib/matplotlib/legend.py index 2364a2c63df6..00e63951cd18 100644 --- a/lib/matplotlib/legend.py +++ b/lib/matplotlib/legend.py @@ -981,6 +981,22 @@ def get_window_extent(self, *args, **kwargs): 'Return extent of the legend.' return self._legend_box.get_window_extent(*args, **kwargs) + def get_tightbbox(self, renderer): + """ + Like `.Legend.get_window_extent`, but uses the box for the legend. + + Parameters + ---------- + renderer : `.RendererBase` instance + renderer that will be used to draw the figures (i.e. + ``fig.canvas.get_renderer()``) + + Returns + ------- + `.BboxBase` : containing the bounding box in figure pixel co-ordinates. + """ + return self._legend_box.get_window_extent(renderer) + def get_frame_on(self): """Get whether the legend box patch is drawn.""" return self._drawFrame diff --git a/lib/matplotlib/tests/test_figure.py b/lib/matplotlib/tests/test_figure.py index de5a6c8aa0dd..3e07838da984 100644 --- a/lib/matplotlib/tests/test_figure.py +++ b/lib/matplotlib/tests/test_figure.py @@ -391,3 +391,28 @@ def test_fspath(fmt, tmpdir): # All the supported formats include the format name (case-insensitive) # in the first 100 bytes. assert fmt.encode("ascii") in file.read(100).lower() + + +def test_tightbbox(): + fig, ax = plt.subplots() + ax.set_xlim(0, 1) + t = ax.text(1., 0.5, 'This dangles over end') + renderer = fig.canvas.get_renderer() + x1Nom0 = 9.035 # inches + assert np.abs(t.get_tightbbox(renderer).x1 - x1Nom0 * fig.dpi) < 2 + assert np.abs(ax.get_tightbbox(renderer).x1 - x1Nom0 * fig.dpi) < 2 + assert np.abs(fig.get_tightbbox(renderer).x1 - x1Nom0) < 0.05 + assert np.abs(fig.get_tightbbox(renderer).x0 - 0.679) < 0.05 + # now exclude t from the tight bbox so now the bbox is quite a bit + # smaller + t.set_in_layout(False) + x1Nom = 7.333 + assert np.abs(ax.get_tightbbox(renderer).x1 - x1Nom * fig.dpi) < 2 + assert np.abs(fig.get_tightbbox(renderer).x1 - x1Nom) < 0.05 + + t.set_in_layout(True) + x1Nom = 7.333 + assert np.abs(ax.get_tightbbox(renderer).x1 - x1Nom0 * fig.dpi) < 2 + # test bbox_extra_artists method... + assert np.abs(ax.get_tightbbox(renderer, + bbox_extra_artists=[]).x1 - x1Nom * fig.dpi) < 2 diff --git a/tutorials/intermediate/constrainedlayout_guide.py b/tutorials/intermediate/constrainedlayout_guide.py index 179b520761c3..144f4685c605 100644 --- a/tutorials/intermediate/constrainedlayout_guide.py +++ b/tutorials/intermediate/constrainedlayout_guide.py @@ -189,7 +189,7 @@ def example_plot(ax, fontsize=12, nodec=False): fig, ax = plt.subplots(constrained_layout=True) ax.plot(np.arange(10), label='This is a plot') -ax.legend(loc='center left', bbox_to_anchor=(0.9, 0.5)) +ax.legend(loc='center left', bbox_to_anchor=(0.8, 0.5)) ############################################# # However, this will steal space from a subplot layout: @@ -198,7 +198,39 @@ def example_plot(ax, fontsize=12, nodec=False): for ax in axs.flatten()[:-1]: ax.plot(np.arange(10)) axs[1, 1].plot(np.arange(10), label='This is a plot') -axs[1, 1].legend(loc='center left', bbox_to_anchor=(0.9, 0.5)) +axs[1, 1].legend(loc='center left', bbox_to_anchor=(0.8, 0.5)) + +############################################# +# In order for a legend or other artist to *not* steal space +# from the subplot layout, we can ``leg.set_in_layout(False)``. +# Of course this can mean the legend ends up +# cropped, but can be useful if the plot is subsequently called +# with ``fig.savefig('outname.png', bbox_inches='tight')``. Note, +# however, that the legend's ``get_in_layout`` status will have to be +# toggled again to make the saved file work: + +fig, axs = plt.subplots(2, 2, constrained_layout=True) +for ax in axs.flatten()[:-1]: + ax.plot(np.arange(10)) +axs[1, 1].plot(np.arange(10), label='This is a plot') +leg = axs[1, 1].legend(loc='center left', bbox_to_anchor=(0.8, 0.5)) +leg.set_in_layout(False) +wanttoprint = False +if wanttoprint: + leg.set_in_layout(True) + fig.do_constrained_layout(False) + fig.savefig('outname.png', bbox_inches='tight') + +############################################# +# A better way to get around this awkwardness is to simply +# use a legend for the figure: +fig, axs = plt.subplots(2, 2, constrained_layout=True) +for ax in axs.flatten()[:-1]: + ax.plot(np.arange(10)) +lines = axs[1, 1].plot(np.arange(10), label='This is a plot') +labels = [l.get_label() for l in lines] +leg = fig.legend(lines, labels, loc='center left', + bbox_to_anchor=(0.8, 0.5), bbox_transform=axs[1, 1].transAxes) ############################################################################### # Padding and Spacing diff --git a/tutorials/intermediate/tight_layout_guide.py b/tutorials/intermediate/tight_layout_guide.py index 333333bda7c1..f83f24dde8e9 100644 --- a/tutorials/intermediate/tight_layout_guide.py +++ b/tutorials/intermediate/tight_layout_guide.py @@ -283,6 +283,35 @@ def example_plot(ax, fontsize=12): None, 1 - (gs2.top-top)], h_pad=0.5) +############################################################################### +# Legends and Annotations +# ======================= +# +# Pre Matplotlih 2.2, legends and annotations were excluded from the bounding +# box calculations that decide the layout. Subsequently these artists were +# added to the calculation, but sometimes it is undesirable to include them. +# For instance in this case it might be good to have the axes shring a bit +# to make room for the legend: + +fig, ax = plt.subplots(figsize=(4, 3)) +lines = ax.plot(range(10), label='A simple plot') +ax.legend(bbox_to_anchor=(0.7, 0.5), loc='center left',) +fig.tight_layout() +plt.show() + +############################################################################### +# However, sometimes this is not desired (quite often when using +# ``fig.savefig('outname.png', bbox_inches='tight')``). In order to +# remove the legend from the bounding box calculation, we simply set its +# bounding ``leg.set_in_layout(False)`` and the legend will be ignored. + +fig, ax = plt.subplots(figsize=(4, 3)) +lines = ax.plot(range(10), label='B simple plot') +leg = ax.legend(bbox_to_anchor=(0.7, 0.5), loc='center left',) +leg.set_in_layout(False) +fig.tight_layout() +plt.show() + ############################################################################### # Use with AxesGrid1 # ==================