Repository: matplotlib/matplotlib

Issue:
[ENH]: Allow ignoring x-extent (but not y-extent) of xticklabels when computing axes extents (e.g. for geometry manager)
### Problem

Currently, if an xtick is at the very edge of an x-axis, and thus the xticklabel "overhangs" further, then it will be taken into account for constrained_layout (CL) purposes.  Unfortunately, this means that the axes size, after CL runs, will depend on whether there is such an "edge" xtick.  In some cases, it may be preferable to ignore the x-extent of that ticklabel, even if it ends up "going over" the edge of the figure, to maintain the same axes size.  (An example use case is when exporting multiple figures, all with similar axes only differing by whether there's an overhanging xticklabel, to SVG, as with this format one can just readjust the viewport of the SVG to unclip the ticklabel.)

Concretely, consider the following example:
```python
from pylab import *

ax = plt.figure(layout="constrained", figsize=(3, 3)).add_subplot(xlim=(-.1, 1.1))
ax = plt.figure(layout="constrained", figsize=(3, 3)).add_subplot(xlim=(0, 1))

show()
```
Right now the axes have slightly different physical sizes, because the second one is reduced to give room to the edge tick at x=1.

I would like to be able to do something like (\*)
```python
from pylab import *

ax = plt.figure(layout="constrained", figsize=(3, 3)).add_subplot(xlim=(-.1, 1.1))
ax = plt.figure(layout="constrained", figsize=(3, 3)).add_subplot(xlim=(0, 1))
ax.figure.draw_without_rendering()
ax.xaxis.majorTicks[-1].label1.set_in_layout(False)

show()
```
to force the last xtick to be ignored for CL purposes.

Making this work is actually relatively easy; I believe the patch is simply
```patch
diff --git i/lib/matplotlib/axis.py w/lib/matplotlib/axis.py
index fafdf92017..a9b31ebb4e 100644
--- i/lib/matplotlib/axis.py
+++ w/lib/matplotlib/axis.py
@@ -1335,9 +1335,11 @@ class Axis(martist.Artist):
         if renderer is None:
             renderer = self.get_figure(root=True)._get_renderer()
         return ([tick.label1.get_window_extent(renderer)
-                 for tick in ticks if tick.label1.get_visible()],
+                 for tick in ticks
+                 if tick.label1.get_visible() and tick.label1.get_in_layout()],
                 [tick.label2.get_window_extent(renderer)
-                 for tick in ticks if tick.label2.get_visible()])
+                 for tick in ticks
+                 if tick.label2.get_visible() and tick.label2.get_in_layout()])
 
     def get_tightbbox(self, renderer=None, *, for_layout_only=False):
         """
```
but a few questions remain
- The end-user experience is not great if one really needs to do (\*); can we design a better API?
- Ideally, I only want to ignore the x-extent of the xticklabels, not their y-extent (although in practice I'm saved by the fact that *other* xticks contribute the necessary info for the y-extent).  Should we introduce set_in_layout_x() and set_in_layout_y()?

Of course, the same applies for the y-extent of yticklabels.

### Proposed solution

See above.


Discussion:
Would `artist.set_in_layout(xonly=True)` or something like that be the cleanest API?  
API-wise fiddling with individual ticks is sub-optimal because they are volatile. But that’s all we have right now, and it works at least for a static plot.

It would be nice to have a semantic container for the ticks, on which you could set all the properties, instead of a plain list of tick instances. See https://github.com/matplotlib/matplotlib/issues/5665#issuecomment-875155264
If we're considering splitting `set_in_layout` by x/y, could we further split it by top/bottom/left/right?  I assume you only care about the tick at one or other end of the axis, so we could continue to calculate `Axis.get_window_extent` as now and the user does something like
```python
ax.xaxis.set_in_layout(right=False)
```
So no need to fiddle with individual ticks.
An issue here is that set_in_layout takes a positional argument.  

I'm not clear on why you'd want the right side of the xaxis to be excluded but not the left?  You can have long xtick labels at the beginning of the axis as well.  

I'm not 100% sure how all this gets implemented.   Setting some internal flags is straightforward, but using them consistently might be a bit of work 


API compatibility could be kept by changing the single bool parameter to `bool | Literal[“horizontal”, “vertical”]` or a more refined version of this, e.g.  a Flag enum.
> An issue here is that set_in_layout takes a positional argument.

The "easy" way out is probably to have set_in_layout_left(), set_in_layout_right(), etc. and set_in_layout() would globally set all four.  The flag version suggested by @timhoffm would work, too.

> I'm not clear on why you'd want the right side of the xaxis to be excluded but not the left? You can have long xtick labels at the beginning of the axis as well.

Good catch, but I guess @rcomer's point is still useful in that I could just ignore the x-extents of *all* xticklabels.
Do we need a general solution here that works for all artists, or would it be enough to implement something specific to `Axis`?  If we only address `Axis`, that would of course be far more simple to implement and we could call it something really specific like `set_prodtruding_ticklabels_in_layout`.

I do not know of issues for other artists, but it seems to come up repeatedly for `Axis`: #26014, #28623
I would prefer a general solution, but don't feel very strongly about it.



Patch:
diff --git a/lib/matplotlib/axis.py b/lib/matplotlib/axis.py
index d80e7b4dafb9..ba17c634ac5c 100644
--- a/lib/matplotlib/axis.py
+++ b/lib/matplotlib/axis.py
@@ -1333,9 +1333,11 @@ def _update_ticks(self):
     def _get_ticklabel_bboxes(self, ticks, renderer):
         """Return lists of bboxes for ticks' label1's and label2's."""
         return ([tick.label1.get_window_extent(renderer)
-                 for tick in ticks if tick.label1.get_visible()],
+                 for tick in ticks
+                 if tick.label1.get_visible() and tick.label1.get_in_layout()],
                 [tick.label2.get_window_extent(renderer)
-                 for tick in ticks if tick.label2.get_visible()])
+                 for tick in ticks
+                 if tick.label2.get_visible() and tick.label2.get_in_layout()])
 
     def get_tightbbox(self, renderer=None, *, for_layout_only=False):
         """


Test patch:
diff --git a/lib/matplotlib/tests/test_axis.py b/lib/matplotlib/tests/test_axis.py
index 97884a33208f..67d9ed5bde62 100644
--- a/lib/matplotlib/tests/test_axis.py
+++ b/lib/matplotlib/tests/test_axis.py
@@ -2,6 +2,7 @@
 
 import matplotlib.pyplot as plt
 from matplotlib.axis import XTick
+from matplotlib.testing.decorators import check_figures_equal
 
 
 def test_tick_labelcolor_array():
@@ -31,6 +32,21 @@ def test_axis_not_in_layout():
     assert ax1_right.get_position().bounds == ax2_right.get_position().bounds
 
 
+@check_figures_equal()
+def test_tick_not_in_layout(fig_test, fig_ref):
+    # Check that the "very long" ticklabel is ignored from layouting after
+    # set_in_layout(False); i.e. the layout is as if the ticklabel was empty.
+    # Ticklabels are set to white so that the actual string doesn't matter.
+    fig_test.set_layout_engine("constrained")
+    ax = fig_test.add_subplot(xticks=[0, 1], xticklabels=["short", "very long"])
+    ax.tick_params(labelcolor="w")
+    fig_test.draw_without_rendering()  # Ensure ticks are correct.
+    ax.xaxis.majorTicks[-1].label1.set_in_layout(False)
+    fig_ref.set_layout_engine("constrained")
+    ax = fig_ref.add_subplot(xticks=[0, 1], xticklabels=["short", ""])
+    ax.tick_params(labelcolor="w")
+
+
 def test_translate_tick_params_reverse():
     fig, ax = plt.subplots()
     kw = {'label1On': 'a', 'label2On': 'b', 'tick1On': 'c', 'tick2On': 'd'}