From a8560a99572798712d59d09569e480aec910f764 Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Wed, 22 Apr 2026 18:01:02 +0530 Subject: [PATCH 1/2] Fix text wrap width on figure edges (#31537) Text with wrap=True at an axis-aligned rotation on the matching figure edge collapsed to zero wrap width. In _get_dist_to_box the trig formula divides by cos(radians(90)) (~6e-17): normally that term's large value loses the min() to the sensible other side, but when the position sits exactly on the edge its numerator is also zero and the returned distance is zero. Short-circuit cardinal rotations. Also normalize rotation to [0, 360) with a small absolute tolerance so near-cardinal angles coming out of transform_angles (e.g. -90.00000000000003 via transform_rotates_text) still take the fast path instead of blowing up in the trig formula. Regression tests cover each cardinal rotation on its broken edge plus a transform_rotates_text case. --- lib/matplotlib/tests/test_text.py | 41 +++++++++++++++++++++++++++++++ lib/matplotlib/text.py | 15 +++++++++++ 2 files changed, 56 insertions(+) diff --git a/lib/matplotlib/tests/test_text.py b/lib/matplotlib/tests/test_text.py index 83b65be400db..6ee1a7e3e30c 100644 --- a/lib/matplotlib/tests/test_text.py +++ b/lib/matplotlib/tests/test_text.py @@ -753,6 +753,47 @@ def test_wrap_no_wrap(): assert text._get_wrapped_text() == 'non wrapped text' +@pytest.mark.parametrize( + "x, y, rotation", + [(0.0, 1.0, 0), + (1.0, 0.5, 90), + (0.5, 1.0, 180), + (0.0, 0.5, 270)]) +def test_wrap_on_figure_edge(x, y, rotation): + # Regression test for #31537 - wrap collapsed to zero on figure edges. + s = 'This is a very long text that should be wrapped multiple times.' + fig = plt.figure(figsize=(6, 4)) + t = fig.text(x, y, s, wrap=True, rotation=rotation) + fig.canvas.draw() + + # Compare to a nudged-off-the-edge reference that should wrap the same. + nudge = 1e-4 + x_ref = x - nudge if x == 1.0 else x + nudge if x == 0.0 else x + y_ref = y - nudge if y == 1.0 else y + nudge if y == 0.0 else y + fig_ref = plt.figure(figsize=(6, 4)) + t_ref = fig_ref.text(x_ref, y_ref, s, wrap=True, rotation=rotation) + fig_ref.canvas.draw() + + assert t._get_wrapped_text() == t_ref._get_wrapped_text() + + +def test_wrap_on_figure_edge_transform_rotates_text(): + # With transform_rotates_text=True, get_rotation() goes through + # transform_angles() and can return near-cardinal angles with float + # noise (e.g. -90.00000000000003 for a 270 degree rotation), which + # used to skip the cardinal-angle short-circuit in _get_dist_to_box. + from matplotlib.transforms import Affine2D + fig = plt.figure(figsize=(6, 4)) + s = 'This is a very long text that should be wrapped multiple times.' + # rotate_deg(90).rotate_deg(-90) is identity geometrically, but carries + # enough numerical slop to perturb transform_angles. + transform = Affine2D().rotate_deg(90).rotate_deg(-90) + fig.transFigure + t = fig.text(0.0, 0.5, s, transform=transform, wrap=True, rotation=270, + transform_rotates_text=True) + fig.canvas.draw() + assert any(' ' in line for line in t._get_wrapped_text().split('\n')) + + @check_figures_equal() def test_buffer_size(fig_test, fig_ref): # On old versions of the Agg renderer, large non-ascii single-character diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py index 9c6478f9c7df..5d43b0f840b1 100644 --- a/lib/matplotlib/text.py +++ b/lib/matplotlib/text.py @@ -757,6 +757,21 @@ def _get_dist_to_box(self, rotation, x0, y0, figure_box): Return the distance from the given points to the boundaries of a rotated box, in pixels. """ + # Normalize rotation; transform_angles can return values outside + # [0, 360) with tiny float noise. + rotation = rotation % 360 + # Short-circuit cardinal angles, otherwise cos(radians(90)) makes + # the trig formula below blow up when the text is on the edge. + tol = 1e-10 + if rotation < tol or rotation > 360 - tol: + return figure_box.x1 - x0 + if abs(rotation - 90) < tol: + return figure_box.y1 - y0 + if abs(rotation - 180) < tol: + return x0 - figure_box.x0 + if abs(rotation - 270) < tol: + return y0 - figure_box.y0 + if rotation > 270: quad = rotation - 270 h1 = (y0 - figure_box.y0) / math.cos(math.radians(quad)) From a2411fad5f0504e628e2b597db7bfbbe92cbf108 Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Thu, 23 Apr 2026 14:42:20 +0530 Subject: [PATCH 2/2] FIX: clamp get_rotation to [0, 360) when transform_rotates_text transform_angles can return a tiny-negative value, and in Python (tiny_negative) % 360 float-rounds to exactly 360.0, breaking the [0, 360) range that the docstring promises. Clamp 360 back to 0 so callers can rely on the documented range. With that in place, the normalize + tolerance wrapper added to _get_dist_to_box in the previous commit is no longer needed. Reduce it to plain cardinal-angle short-circuits, per rcomer's suggestion in #31537. Rework the transform_rotates_text regression test to compare wrapped output against a plain rotation=0 reference at the same position, instead of the weaker "contains a space" assertion. --- lib/matplotlib/tests/test_text.py | 26 ++++++++++++++------------ lib/matplotlib/text.py | 20 +++++++++----------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/lib/matplotlib/tests/test_text.py b/lib/matplotlib/tests/test_text.py index 6ee1a7e3e30c..d2a7865df8ff 100644 --- a/lib/matplotlib/tests/test_text.py +++ b/lib/matplotlib/tests/test_text.py @@ -778,20 +778,22 @@ def test_wrap_on_figure_edge(x, y, rotation): def test_wrap_on_figure_edge_transform_rotates_text(): - # With transform_rotates_text=True, get_rotation() goes through - # transform_angles() and can return near-cardinal angles with float - # noise (e.g. -90.00000000000003 for a 270 degree rotation), which - # used to skip the cardinal-angle short-circuit in _get_dist_to_box. - from matplotlib.transforms import Affine2D - fig = plt.figure(figsize=(6, 4)) + # Regression test for #31537 - transform_rotates_text with an axis-aligned + # transform can make get_rotation() float-round to 360.0. s = 'This is a very long text that should be wrapped multiple times.' - # rotate_deg(90).rotate_deg(-90) is identity geometrically, but carries - # enough numerical slop to perturb transform_angles. - transform = Affine2D().rotate_deg(90).rotate_deg(-90) + fig.transFigure - t = fig.text(0.0, 0.5, s, transform=transform, wrap=True, rotation=270, - transform_rotates_text=True) + + fig = plt.figure(figsize=(6, 4)) + transform = mtransforms.Affine2D().rotate_deg(270) + fig.transFigure + t = fig.text(0.0, 0.0, s, transform=transform, wrap=True, + rotation=90, transform_rotates_text=True) fig.canvas.draw() - assert any(' ' in line for line in t._get_wrapped_text().split('\n')) + + fig_ref = plt.figure(figsize=(6, 4)) + t_ref = fig_ref.text(0.0, 0.0, s, wrap=True, rotation=0) + fig_ref.canvas.draw() + + assert 0 <= t.get_rotation() < 360 + assert t._get_wrapped_text() == t_ref._get_wrapped_text() @check_figures_equal() diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py index 5d43b0f840b1..a17a01d195d6 100644 --- a/lib/matplotlib/text.py +++ b/lib/matplotlib/text.py @@ -339,8 +339,10 @@ def _char_index_at(self, x): def get_rotation(self): """Return the text angle in degrees in the range [0, 360).""" if self.get_transform_rotates_text(): - return self.get_transform().transform_angles( + angle = self.get_transform().transform_angles( [self._rotation], [self.get_unitless_position()]).item(0) % 360 + # `(tiny_negative) % 360` can float-round to exactly 360.0. + return 0.0 if angle == 360 else angle else: return self._rotation @@ -757,19 +759,15 @@ def _get_dist_to_box(self, rotation, x0, y0, figure_box): Return the distance from the given points to the boundaries of a rotated box, in pixels. """ - # Normalize rotation; transform_angles can return values outside - # [0, 360) with tiny float noise. - rotation = rotation % 360 - # Short-circuit cardinal angles, otherwise cos(radians(90)) makes - # the trig formula below blow up when the text is on the edge. - tol = 1e-10 - if rotation < tol or rotation > 360 - tol: + # Short-circuit cardinals; otherwise cos(radians(90)) makes the trig + # formula below blow up when the text is on the matching edge. + if rotation == 0: return figure_box.x1 - x0 - if abs(rotation - 90) < tol: + if rotation == 90: return figure_box.y1 - y0 - if abs(rotation - 180) < tol: + if rotation == 180: return x0 - figure_box.x0 - if abs(rotation - 270) < tol: + if rotation == 270: return y0 - figure_box.y0 if rotation > 270: