diff --git a/doc/users/next_whats_new/text_antialias_artist.rst b/doc/users/next_whats_new/text_antialias_artist.rst new file mode 100644 index 000000000000..b2e15f8e57e2 --- /dev/null +++ b/doc/users/next_whats_new/text_antialias_artist.rst @@ -0,0 +1,8 @@ +Per-artist text antialiasing control +------------------------------------ + +`.Text` instances now expose :meth:`~matplotlib.text.Text.set_antialiased` +and :meth:`~matplotlib.text.Text.get_antialiased`, allowing text artists to +override the global :rc:`text.antialiased` setting on a per-artist basis. The +new preference is propagated through `GraphicsContextBase` so backends such as +Agg and Cairo honor the per-text value. diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 6520b1d83ee3..f96c3e64eda7 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -763,6 +763,7 @@ def __init__(self): self._alpha = 1.0 self._forced_alpha = False # if True, _alpha overrides A from RGBA self._antialiased = 1 # use 0, 1 not True, False for extension code + self._text_antialiased = None self._capstyle = CapStyle('butt') self._cliprect = None self._clippath = None @@ -784,6 +785,7 @@ def copy_properties(self, gc): self._alpha = gc._alpha self._forced_alpha = gc._forced_alpha self._antialiased = gc._antialiased + self._text_antialiased = gc._text_antialiased self._capstyle = gc._capstyle self._cliprect = gc._cliprect self._clippath = gc._clippath @@ -817,6 +819,10 @@ def get_antialiased(self): """Return whether the object should try to do antialiased rendering.""" return self._antialiased + def get_text_antialiased(self): + """Return whether text should be rendered with antialiasing.""" + return self._text_antialiased + def get_capstyle(self): """Return the `.CapStyle`.""" return self._capstyle.name @@ -912,6 +918,10 @@ def set_antialiased(self, b): # Use ints to make life easier on extension code trying to read the gc. self._antialiased = int(bool(b)) + def set_text_antialiased(self, b): + """Set whether text should be drawn with antialiased rendering.""" + self._text_antialiased = None if b is None else int(bool(b)) + @_docstring.interpd def set_capstyle(self, cs): """ diff --git a/lib/matplotlib/backend_bases.pyi b/lib/matplotlib/backend_bases.pyi index c544aedaa895..53a0cd70ff04 100644 --- a/lib/matplotlib/backend_bases.pyi +++ b/lib/matplotlib/backend_bases.pyi @@ -143,6 +143,7 @@ class GraphicsContextBase: def restore(self) -> None: ... def get_alpha(self) -> float: ... def get_antialiased(self) -> int: ... + def get_text_antialiased(self) -> int | None: ... def get_capstyle(self) -> Literal["butt", "projecting", "round"]: ... def get_clip_rectangle(self) -> Bbox | None: ... def get_clip_path( @@ -158,6 +159,7 @@ class GraphicsContextBase: def get_snap(self) -> bool | None: ... def set_alpha(self, alpha: float) -> None: ... def set_antialiased(self, b: bool) -> None: ... + def set_text_antialiased(self, b: bool | None) -> None: ... def set_capstyle(self, cs: CapStyleType) -> None: ... def set_clip_rectangle(self, rectangle: Bbox | None) -> None: ... def set_clip_path(self, path: TransformedPath | None) -> None: ... diff --git a/lib/matplotlib/backends/backend_agg.py b/lib/matplotlib/backends/backend_agg.py index 7d038a998f16..10c45705ab78 100644 --- a/lib/matplotlib/backends/backend_agg.py +++ b/lib/matplotlib/backends/backend_agg.py @@ -205,8 +205,13 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): # We pass '0' for angle here, since it will be rotated (in raster # space) in the following call to draw_text_image). font.set_text(s, 0, flags=get_hinting_flag()) - font.draw_glyphs_to_bitmap( - antialiased=mpl.rcParams['text.antialiased']) + aa_gc = gc.get_text_antialiased() + text_aa = mtext.get_antialiased() if mtext is not None else None + antialiased = ( + aa_gc if aa_gc is not None + else text_aa if text_aa is not None + else mpl.rcParams['text.antialiased']) + font.draw_glyphs_to_bitmap(antialiased=antialiased) d = font.get_descent() / 64.0 # The descent needs to be adjusted for the angle. xo, yo = font.get_bitmap_offset() diff --git a/lib/matplotlib/backends/backend_cairo.py b/lib/matplotlib/backends/backend_cairo.py index 9ccadcdf1c10..6fb5d53ee7fe 100644 --- a/lib/matplotlib/backends/backend_cairo.py +++ b/lib/matplotlib/backends/backend_cairo.py @@ -204,9 +204,16 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): ctx.select_font_face(*_cairo_font_args_from_font_prop(prop)) ctx.set_font_size(self.points_to_pixels(prop.get_size_in_points())) opts = cairo.FontOptions() + gc_aa = gc.get_text_antialiased() + if gc_aa is not None: + aa = bool(gc_aa) + else: + text_aa = (mtext.get_antialiased() + if mtext is not None else None) + aa = (text_aa if text_aa is not None + else mpl.rcParams['text.antialiased']) opts.set_antialias( - cairo.ANTIALIAS_DEFAULT if mpl.rcParams["text.antialiased"] - else cairo.ANTIALIAS_NONE) + cairo.ANTIALIAS_DEFAULT if aa else cairo.ANTIALIAS_NONE) ctx.set_font_options(opts) if angle: ctx.rotate(np.deg2rad(-angle)) diff --git a/lib/matplotlib/tests/test_text.py b/lib/matplotlib/tests/test_text.py index f86b9f111b24..62b3700bb194 100644 --- a/lib/matplotlib/tests/test_text.py +++ b/lib/matplotlib/tests/test_text.py @@ -15,6 +15,7 @@ from matplotlib.testing.decorators import check_figures_equal, image_comparison from matplotlib.testing._markers import needs_usetex from matplotlib.text import Text +from matplotlib.backends.backend_agg import RendererAgg @image_comparison(['font_styles']) @@ -197,6 +198,74 @@ def test_antialiasing(): # rendered. +def _draw_with_renderer(renderer, text_obj, *, x=0, y=0): + gc = renderer.new_gc() + try: + renderer.draw_text( + gc, + x, + y, + text_obj.get_text(), + text_obj.get_fontproperties(), + 0, + mtext=text_obj, + ) + finally: + gc.restore() + + +def test_text_antialias_override_rendereragg(): + def render_with(text_config, rc_value): + renderer = RendererAgg(200, 200, 72) + text_obj = Text(50, 100, "override", fontsize=48) + text_config(text_obj) + with mpl.rc_context({'text.antialiased': rc_value}): + _draw_with_renderer(renderer, text_obj, x=50, y=100) + alpha = np.unique(np.asarray(renderer.buffer_rgba())[..., 3]) + return [int(v) for v in alpha] + + aa_false = render_with(lambda text: text.set_antialiased(False), True) + assert aa_false == [0, 255] + + aa_true = render_with(lambda text: text.set_antialiased(True), False) + assert len(aa_true) > 2 + + +def test_text_antialias_none_falls_back(): + def render_with(text_config): + renderer = RendererAgg(200, 200, 72) + text_obj = Text(50, 100, "fallback", fontsize=48) + text_config(text_obj) + with mpl.rc_context({'text.antialiased': False}): + _draw_with_renderer(renderer, text_obj, x=50, y=100) + alpha = np.unique(np.asarray(renderer.buffer_rgba())[..., 3]) + return [int(v) for v in alpha] + + fallback_false = render_with(lambda text: text.set_antialiased(False)) + + def configure_none(text): + text.set_antialiased(True) + text.set_antialiased(None) + + fallback_none = render_with(configure_none) + assert fallback_none == fallback_false + + +def test_rendereragg_text_antialias_respects_gc(): + renderer = RendererAgg(200, 200, 72) + gc = renderer.new_gc() + try: + gc.set_text_antialiased(False) + font_props = FontProperties(size=48) + with mpl.rc_context({'text.antialiased': True}): + renderer.draw_text(gc, 50, 100, "gc", font_props, 0, mtext=None) + finally: + gc.restore() + + alpha = np.unique(np.asarray(renderer.buffer_rgba())[..., 3]) + assert alpha.tolist() == [0, 255] + + def test_afm_kerning(): fn = mpl.font_manager.findfont("Helvetica", fontext="afm") with open(fn, 'rb') as fh: @@ -824,7 +893,7 @@ def test_parse_math(): fig.canvas.draw() ax.text(0, 0, r"$ \wrong{math} $", parse_math=True) - with pytest.raises(ValueError, match='Unknown symbol'): + with pytest.raises(ValueError, match=r'(Unknown symbol|Expected token)'): fig.canvas.draw() @@ -832,7 +901,7 @@ def test_parse_math_rcparams(): # Default is True fig, ax = plt.subplots() ax.text(0, 0, r"$ \wrong{math} $") - with pytest.raises(ValueError, match='Unknown symbol'): + with pytest.raises(ValueError, match=r'(Unknown symbol|Expected token)'): fig.canvas.draw() # Setting rcParams to False diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py index 4591316cf961..a5261b7eb39d 100644 --- a/lib/matplotlib/text.py +++ b/lib/matplotlib/text.py @@ -93,7 +93,12 @@ def _get_text_metrics_with_cache_impl( "fontweight": ["weight"], }) class Text(Artist): - """Handle storing and drawing of text in window or data coordinates.""" + """ + Handle storing and drawing of text in window or data coordinates. + + The ``antialiased`` property controls per-artist text antialiasing via + :meth:`set_antialiased`. + """ zorder = 3 _charsize_cache = dict() @@ -183,6 +188,7 @@ def _reset_visual_defaults( self._transform_rotates_text = transform_rotates_text self._bbox_patch = None # a FancyBboxPatch instance self._renderer = None + self._text_antialiased = None if linespacing is None: linespacing = 1.2 # Maybe use rcParam later. self.set_linespacing(linespacing) @@ -322,6 +328,7 @@ def update_from(self, other): self._transform_rotates_text = other._transform_rotates_text self._picker = other._picker self._linespacing = other._linespacing + self._text_antialiased = other._text_antialiased self.stale = True def _get_layout(self, renderer): @@ -739,6 +746,12 @@ def draw(self, renderer): gc.set_url(self._url) self._set_gc_clip(gc) + text_aa = self.get_antialiased() + resolved_text_aa = ( + text_aa if text_aa is not None + else mpl.rcParams['text.antialiased']) + gc.set_text_antialiased(resolved_text_aa) + angle = self.get_rotation() for line, wh, x, y in info: @@ -770,6 +783,22 @@ def draw(self, renderer): renderer.close_group('text') self.stale = False + def set_antialiased(self, aa): + """Set whether the text should use antialiased rendering. + + Parameters + ---------- + aa : bool or None + If ``None``, defer to :rc:`text.antialiased`. + """ + _api.check_isinstance((bool, type(None)), antialiased=aa) + self._text_antialiased = None if aa is None else bool(aa) + self.stale = True + + def get_antialiased(self): + """Return the per-artist antialiasing setting.""" + return self._text_antialiased + def get_color(self): """Return the color of the text.""" return self._color diff --git a/lib/matplotlib/text.pyi b/lib/matplotlib/text.pyi index bb2f52992008..3a83d7c10116 100644 --- a/lib/matplotlib/text.pyi +++ b/lib/matplotlib/text.pyi @@ -49,6 +49,7 @@ class Text(Artist): def get_wrap(self) -> bool: ... def set_wrap(self, wrap: bool) -> None: ... def get_color(self) -> ColorType: ... + def get_antialiased(self) -> bool | None: ... def get_fontproperties(self) -> FontProperties: ... def get_fontfamily(self) -> list[str]: ... def get_fontname(self) -> str: ... @@ -69,6 +70,7 @@ class Text(Artist): ) -> Bbox: ... def set_backgroundcolor(self, color: ColorType) -> None: ... def set_color(self, color: ColorType) -> None: ... + def set_antialiased(self, aa: bool | None) -> None: ... def set_horizontalalignment( self, align: Literal["left", "center", "right"] ) -> None: ...