From 7b376d5a1cf418cc18668e94475d70cfacfabb17 Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Tue, 7 Apr 2026 15:54:39 +0530 Subject: [PATCH 1/2] Fix y-label overlap with tick labels for non-default rotations (#19029) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `YAxis._update_label_position` previously assumed a 90° label rotation when computing the label's position, so y-labels with other rotations (e.g. ``rotation=270`` for right-side colorbar labels) could overlap the tick labels. The label position is now computed from the rendered label's actual extent, so it sits clear of the tick labels for any rotation. This implements @jklymak's suggestion in the issue thread of checking the rendered label extent against the tick labels in `_update_label_position`. The fix is a no-op for the default ``rotation=90`` case, so existing image baselines are unaffected. Tests added in test_axes.py cover the generic axis-layer fix (parametrized over 7 rotations × 2 label_positions) plus an ``align_ylabels`` regression that guards against the per-axis overhang correction breaking sibling alignment when rotations differ. A colorbar smoke test in test_colorbar.py exercises the original user-facing symptom from the issue via ``cbar.set_label(..., rotation=270)``. --- .../next_api_changes/behavior/19029-FU.rst | 11 +++++ lib/matplotlib/axis.py | 21 ++++++-- lib/matplotlib/tests/test_axes.py | 48 +++++++++++++++++++ lib/matplotlib/tests/test_colorbar.py | 18 +++++++ 4 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 doc/api/next_api_changes/behavior/19029-FU.rst diff --git a/doc/api/next_api_changes/behavior/19029-FU.rst b/doc/api/next_api_changes/behavior/19029-FU.rst new file mode 100644 index 000000000000..2abfba176ee3 --- /dev/null +++ b/doc/api/next_api_changes/behavior/19029-FU.rst @@ -0,0 +1,11 @@ +y-axis labels with non-default rotation no longer overlap tick labels +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Setting a y-axis label with a rotation other than the default (e.g. +``ax.set_ylabel("...", rotation=270)``, or ``cbar.set_label("...", +rotation=270)`` on a colorbar) previously could cause the label to +overlap the tick labels because the automatic label placement assumed a +90° rotation. The label position is now computed from the rendered +label's actual extent, so it sits clear of the tick labels for any +rotation. This may shift the position of y-labels that use non-default +rotations. diff --git a/lib/matplotlib/axis.py b/lib/matplotlib/axis.py index 0ddfee2d537c..5c6b471e307b 100644 --- a/lib/matplotlib/axis.py +++ b/lib/matplotlib/axis.py @@ -2735,14 +2735,27 @@ def _update_label_position(self, renderer): # Union with extents of the left spine if present, of the axes otherwise. bbox = mtransforms.Bbox.union([ *bboxes, self.axes.spines.get("left", self.axes).get_window_extent()]) - self.label.set_position( - (bbox.x0 - self.labelpad * self.get_figure(root=True).dpi / 72, y)) + target_x = bbox.x0 - self.labelpad * self.get_figure(root=True).dpi / 72 + self.label.set_position((target_x, y)) + # The vertical alignment set in `set_label_position` assumes a + # 90° rotation; for any other rotation the rotated label may + # extend back toward the tick labels and overlap them. Shift + # the label outward by the overhang. See issue #19029. + label_bbox = self.label.get_window_extent(renderer) + overhang = label_bbox.x1 - target_x + if overhang > 0: + self.label.set_position((target_x - overhang, y)) else: # Union with extents of the right spine if present, of the axes otherwise. bbox = mtransforms.Bbox.union([ *bboxes2, self.axes.spines.get("right", self.axes).get_window_extent()]) - self.label.set_position( - (bbox.x1 + self.labelpad * self.get_figure(root=True).dpi / 72, y)) + target_x = bbox.x1 + self.labelpad * self.get_figure(root=True).dpi / 72 + self.label.set_position((target_x, y)) + # See comment in the 'left' branch above (issue #19029). + label_bbox = self.label.get_window_extent(renderer) + overhang = target_x - label_bbox.x0 + if overhang > 0: + self.label.set_position((target_x + overhang, y)) def _update_offset_text_position(self, bboxes, bboxes2): """ diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 57a295d418a6..04943c8784d2 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -9070,6 +9070,54 @@ def test_ylabel_ha_with_position(ha): assert ax.yaxis.label.get_ha() == ha +@pytest.mark.parametrize('label_position', ['left', 'right']) +@pytest.mark.parametrize('rotation', [0, 45, 90, 135, 180, 270, -90]) +def test_ylabel_no_overlap_with_ticklabels(label_position, rotation): + """Regression test for #19029: a y-label with rotation other than 90° + must not overlap the tick labels regardless of label_position.""" + fig, ax = plt.subplots() + ax.plot([1, 3, 2]) + ax.yaxis.set_label_position(label_position) + if label_position == 'right': + ax.yaxis.tick_right() + ax.set_ylabel("test label", rotation=rotation) + fig.canvas.draw() + renderer = fig.canvas.get_renderer() + label_bbox = ax.yaxis.label.get_window_extent(renderer) + tick_bboxes = [t.get_window_extent(renderer) + for t in ax.yaxis.get_ticklabels() if t.get_visible()] + tick_union = mtransforms.Bbox.union(tick_bboxes) + if label_position == 'right': + assert label_bbox.x0 >= tick_union.x1 + else: + assert label_bbox.x1 <= tick_union.x0 + + +@pytest.mark.parametrize('label_position', ['left', 'right']) +def test_align_ylabels_mixed_rotation(label_position): + """Issue #19029: ``fig.align_ylabels`` must keep two y-labels visually + aligned even when they use different rotations. The per-axis overhang + correction in `_update_label_position` shifts each axis independently; + this test guards against that breaking sibling alignment.""" + fig, axs = plt.subplots(2, figsize=(4, 6)) + for ax in axs: + ax.plot([1, 30, 2]) + ax.yaxis.set_label_position(label_position) + if label_position == 'right': + ax.yaxis.tick_right() + axs[0].set_ylabel('first', rotation=90) + axs[1].set_ylabel('second', rotation=270) + fig.align_ylabels(axs) + fig.canvas.draw() + renderer = fig.canvas.get_renderer() + bbox0 = axs[0].yaxis.label.get_window_extent(renderer) + bbox1 = axs[1].yaxis.label.get_window_extent(renderer) + # Both visible label bboxes should occupy the same horizontal range, + # even though the underlying anchor positions differ between rotations. + assert bbox0.x0 == pytest.approx(bbox1.x0, abs=0.5) + assert bbox0.x1 == pytest.approx(bbox1.x1, abs=0.5) + + def test_bar_label_location_vertical(): ax = plt.gca() xs, heights = [1, 2], [3, -4] diff --git a/lib/matplotlib/tests/test_colorbar.py b/lib/matplotlib/tests/test_colorbar.py index 691df9cde3bd..f387e1995b61 100644 --- a/lib/matplotlib/tests/test_colorbar.py +++ b/lib/matplotlib/tests/test_colorbar.py @@ -733,6 +733,24 @@ def test_colorbar_label(): assert cbar3.ax.get_xlabel() == 'horizontal cbar' +def test_colorbar_label_rotation_no_overlap(): + """Smoke test for #19029: a rotation=270 colorbar label must not + overlap negative tick labels (the original user-facing symptom).""" + fig, ax = plt.subplots() + im = ax.imshow([[-100, 0], [50, 100]]) + cbar = fig.colorbar(im, ax=ax) + cbar.set_label('RT(1ax) - RT(2ax) [ms]', rotation=270) + fig.canvas.draw() + renderer = fig.canvas.get_renderer() + label_bbox = cbar.ax.yaxis.label.get_window_extent(renderer) + # Colorbar labels live on the right, so the label must sit clear of + # the rightmost visible tick-label edge. + tick_x1_max = max(t.get_window_extent(renderer).x1 + for t in cbar.ax.yaxis.get_ticklabels() + if t.get_visible()) + assert label_bbox.x0 >= tick_x1_max + + # TODO: tighten tolerance after baseline image is regenerated for text overhaul @image_comparison(['colorbar_keeping_xlabel.png'], style='mpl20', tol=0.03) def test_keeping_xlabel(): From e964783c8d74f971c317d09a18dfbff9eeaf1d7d Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Tue, 7 Apr 2026 17:11:24 +0530 Subject: [PATCH 2/2] Fix D209 --- lib/matplotlib/tests/test_axes.py | 6 ++++-- lib/matplotlib/tests/test_colorbar.py | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 04943c8784d2..76833a2dfb7f 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -9074,7 +9074,8 @@ def test_ylabel_ha_with_position(ha): @pytest.mark.parametrize('rotation', [0, 45, 90, 135, 180, 270, -90]) def test_ylabel_no_overlap_with_ticklabels(label_position, rotation): """Regression test for #19029: a y-label with rotation other than 90° - must not overlap the tick labels regardless of label_position.""" + must not overlap the tick labels regardless of label_position. + """ fig, ax = plt.subplots() ax.plot([1, 3, 2]) ax.yaxis.set_label_position(label_position) @@ -9098,7 +9099,8 @@ def test_align_ylabels_mixed_rotation(label_position): """Issue #19029: ``fig.align_ylabels`` must keep two y-labels visually aligned even when they use different rotations. The per-axis overhang correction in `_update_label_position` shifts each axis independently; - this test guards against that breaking sibling alignment.""" + this test guards against that breaking sibling alignment. + """ fig, axs = plt.subplots(2, figsize=(4, 6)) for ax in axs: ax.plot([1, 30, 2]) diff --git a/lib/matplotlib/tests/test_colorbar.py b/lib/matplotlib/tests/test_colorbar.py index f387e1995b61..8d1490438d9e 100644 --- a/lib/matplotlib/tests/test_colorbar.py +++ b/lib/matplotlib/tests/test_colorbar.py @@ -735,7 +735,8 @@ def test_colorbar_label(): def test_colorbar_label_rotation_no_overlap(): """Smoke test for #19029: a rotation=270 colorbar label must not - overlap negative tick labels (the original user-facing symptom).""" + overlap negative tick labels (the original user-facing symptom). + """ fig, ax = plt.subplots() im = ax.imshow([[-100, 0], [50, 100]]) cbar = fig.colorbar(im, ax=ax)