From af2351b4e9fe6805c8c6880fd195603f840fc75f Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Thu, 25 Dec 2025 13:39:56 +0000 Subject: [PATCH] fix(text): decouple annotation inputs --- .../examples/repro_annotate_xy_mutability.py | 53 +++++++++++++ lib/matplotlib/tests/test_text.py | 79 ++++++++++++++++++- lib/matplotlib/text.py | 4 +- 3 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 doc/devel/examples/repro_annotate_xy_mutability.py diff --git a/doc/devel/examples/repro_annotate_xy_mutability.py b/doc/devel/examples/repro_annotate_xy_mutability.py new file mode 100644 index 000000000000..19d52bef2be1 --- /dev/null +++ b/doc/devel/examples/repro_annotate_xy_mutability.py @@ -0,0 +1,53 @@ +"""Reproduction script for annotation xy mutability regression (Issue #72). + +Run this module to observe how mutating the original ``xy`` or ``xytext`` +arrays changes the rendered annotation position. On affected builds the +rendered position drifts to the mutated coordinates after the redraw. +""" + +import matplotlib + + +matplotlib.use("Agg") + +import matplotlib.pyplot as plt # noqa: E402 +import numpy as np # noqa: E402 + + +def main() -> None: + fig, ax = plt.subplots(layout="constrained") + + xy = np.array([0.25, 0.75], dtype=float) + xytext = np.array([24.0, -12.0], dtype=float) + + annotation = ax.annotate( + "Mutable source demo", + xy=xy, + xytext=xytext, + textcoords="offset points", + arrowprops=dict(arrowstyle="->"), + ) + + def snapshot(tag: str) -> None: + fig.canvas.draw() + renderer = fig.canvas.get_renderer() + xy_pixels = annotation._get_position_xy(renderer) + print(f"{tag} annotation xy (data): {annotation.xy}") + print(f"{tag} annotation xy in pixels: {xy_pixels}") + print(f"{tag} annotation xytext (offset points): {annotation.get_position()}") + + snapshot("Initial") + + xy[:] = [0.6, 0.2] + xytext[:] = [-18.0, 30.0] + + snapshot("After mutation") + + ax.set_xlim(0, 1) + ax.set_ylim(0, 1) + ax.set_title("Annotation mutability reproduction") + fig.savefig("repro_annotate_xy_mutability.png", dpi=150) + + +if __name__ == "__main__": + main() diff --git a/lib/matplotlib/tests/test_text.py b/lib/matplotlib/tests/test_text.py index 47121b7a2262..a0711117a314 100644 --- a/lib/matplotlib/tests/test_text.py +++ b/lib/matplotlib/tests/test_text.py @@ -16,7 +16,7 @@ import matplotlib.transforms as mtransforms from matplotlib.testing.decorators import check_figures_equal, image_comparison from matplotlib.testing._markers import needs_usetex -from matplotlib.text import Text, Annotation +from matplotlib.text import Text, Annotation, OffsetFrom pyparsing_version = parse_version(pyparsing.__version__) @@ -676,6 +676,83 @@ def test_annotation_units(fig_test, fig_ref): ax.annotate("x", (datetime.now(), 0.5), xycoords=("data", "axes fraction")) +def test_annotation_xy_numpy_source_mutation_no_effect(): + fig, ax = plt.subplots() + xy = np.array([0.25, 0.75], dtype=float) + ann = ax.annotate("point", xy=xy) + + def current_xy_pixels(): + fig.canvas.draw() + renderer = fig.canvas.get_renderer() + return ann._get_position_xy(renderer) + + original_pixels = current_xy_pixels() + xy[:] = [0.5, 0.5] + mutated_pixels = current_xy_pixels() + + np.testing.assert_allclose(mutated_pixels, original_pixels) + assert ann.xy == (0.25, 0.75) + + +def test_annotation_xytext_numpy_source_mutation_no_effect(): + fig, ax = plt.subplots() + xytext = np.array([12.0, -6.0], dtype=float) + ann = ax.annotate( + "label", + xy=(0.4, 0.6), + xytext=xytext, + textcoords="offset points", + ) + + def current_text_position(): + fig.canvas.draw() + return ann.get_position() + + original_xytext = current_text_position() + xytext[:] = [18.0, -12.0] + mutated_xytext = current_text_position() + + assert mutated_xytext == original_xytext + assert original_xytext == ann.get_position() + + +def test_annotation_xy_list_source_mutation_no_effect(): + fig, ax = plt.subplots() + xy = [0.1, 0.3] + ann = ax.annotate("list", xy=xy) + + def current_xy_pixels(): + fig.canvas.draw() + renderer = fig.canvas.get_renderer() + return ann._get_position_xy(renderer) + + original_pixels = current_xy_pixels() + xy[0] = 0.9 + xy[1] = 0.9 + mutated_pixels = current_xy_pixels() + + np.testing.assert_allclose(mutated_pixels, original_pixels) + assert ann.xy == (0.1, 0.3) + + +def test_offsetfrom_ref_coord_numpy_mutation_no_effect(): + fig, ax = plt.subplots() + ref_coord = np.array([0.25, 0.5], dtype=float) + offset = OffsetFrom(ax, ref_coord, unit="points") + + fig.canvas.draw() + renderer = fig.canvas.get_renderer() + original_offset = offset(renderer).transform((0, 0)) + + ref_coord[:] = [0.75, 0.2] + fig.canvas.draw() + renderer = fig.canvas.get_renderer() + updated_offset = offset(renderer).transform((0, 0)) + + np.testing.assert_allclose(updated_offset, original_offset) + assert offset._ref_coord == (0.25, 0.5) + + @image_comparison(['large_subscript_title.png'], style='mpl20') def test_large_subscript_title(): # Remove this line when this test image is regenerated. diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py index d4922fc51774..b3693bc677c8 100644 --- a/lib/matplotlib/text.py +++ b/lib/matplotlib/text.py @@ -1389,7 +1389,7 @@ def __init__(self, artist, ref_coord, unit="points"): The screen units to use (pixels or points) for the offset input. """ self._artist = artist - self._ref_coord = ref_coord + self._ref_coord = tuple(ref_coord) self.set_unit(unit) def set_unit(self, unit): @@ -1456,7 +1456,7 @@ def __init__(self, xycoords='data', annotation_clip=None): - self.xy = xy + self.xy = tuple(xy) self.xycoords = xycoords self.set_annotation_clip(annotation_clip)