"""
Classes for including text in a figure.
"""
from collections.abc import Sequence
import functools
import itertools
import logging
import math
from numbers import Real
import weakref
import numpy as np
import matplotlib as mpl
from . import _api, artist, cbook, _docstring, colors as mcolors
from .artist import Artist
from .font_manager import FontProperties, fontManager, get_font
from .patches import FancyArrowPatch, FancyBboxPatch, Rectangle
from .textpath import TextPath, TextToPath # noqa # Logically located here
from .transforms import (
Affine2D, Bbox, BboxBase, BboxTransformTo, IdentityTransform, Transform)
_log = logging.getLogger(__name__)
@functools.lru_cache(maxsize=128)
def _rotate(theta):
"""
Return an Affine2D object that rotates by the given angle in radians.
"""
return Affine2D().rotate(theta)
def _rotate_point(angle, x, y):
"""
Rotate point (x, y) by rotation angle in degrees
"""
if angle == 0:
return (x, y)
angle_rad = math.radians(angle)
cos, sin = math.cos(angle_rad), math.sin(angle_rad)
return (cos * x - sin * y, sin * x + cos * y)
def _get_text_metrics_with_cache(renderer, text, fontprop, ismath, dpi):
"""Call ``renderer.get_text_width_height_descent``, caching the results."""
# hit the outer cache layer and get the function to compute the metrics
# for this renderer instance
get_text_metrics = _get_text_metrics_function(renderer)
# call the function to compute the metrics and return
#
# We pass a copy of the fontprop because FontProperties is both mutable and
# has a `__hash__` that depends on that mutable state. This is not ideal
# as it means the hash of an object is not stable over time which leads to
# very confusing behavior when used as keys in dictionaries or hashes.
return get_text_metrics(text, fontprop.copy(), ismath, dpi)
def _get_text_metrics_function(input_renderer, _cache=weakref.WeakKeyDictionary()):
"""
Helper function to provide a two-layered cache for font metrics
To get the rendered size of a size of string we need to know:
- what renderer we are using
- the current dpi of the renderer
- the string
- the font properties
- is it math text or not
We do this as a two-layer cache with the outer layer being tied to a
renderer instance and the inner layer handling everything else.
The outer layer is implemented as `.WeakKeyDictionary` keyed on the
renderer. As long as someone else is holding a hard ref to the renderer
we will keep the cache alive, but it will be automatically dropped when
the renderer is garbage collected.
The inner layer is provided by an lru_cache with a large maximum size (such
that we expect very few cache misses in actual use cases). As the
dpi is mutable on the renderer, we need to explicitly include it as part of
the cache key on the inner layer even though we do not directly use it (it is
used in the method call on the renderer).
This function takes a renderer and returns a function that can be used to
get the font metrics.
Parameters
----------
input_renderer : maplotlib.backend_bases.RendererBase
The renderer to set the cache up for.
_cache : dict, optional
We are using the mutable default value to attach the cache to the function.
In principle you could pass a different dict-like to this function to inject
a different cache, but please don't. This is an internal function not meant to
be reused outside of the narrow context we need it for.
There is a possible race condition here between threads, we may need to drop the
mutable default and switch to a threadlocal variable in the future.
"""
if (_text_metrics := _cache.get(input_renderer, None)) is None:
# We are going to include this in the closure we put as values in the
# cache. Closing over a hard-ref would create an unbreakable reference
# cycle.
renderer_ref = weakref.ref(input_renderer)
# define the function locally to get a new lru_cache per renderer
@functools.lru_cache(4096)
# dpi is unused, but participates in cache invalidation (via the renderer).
def _text_metrics(text, fontprop, ismath, dpi):
# this should never happen under normal use, but this is a better error to
# raise than an AttributeError on `None`
if (local_renderer := renderer_ref()) is None:
raise RuntimeError(
"Trying to get text metrics for a renderer that no longer exists. "
"This should never happen and is evidence of a bug elsewhere."
)
# do the actual method call we need and return the result
return local_renderer.get_text_width_height_descent(text, fontprop, ismath)
# stash the function for later use.
_cache[input_renderer] = _text_metrics
# return the inner function
return _text_metrics
@_docstring.interpd
@_api.define_aliases({
"color": ["c"],
"fontproperties": ["font", "font_properties"],
"fontfamily": ["family"],
"fontname": ["name"],
"fontsize": ["size"],
"fontstretch": ["stretch"],
"fontstyle": ["style"],
"fontvariant": ["variant"],
"fontweight": ["weight"],
"horizontalalignment": ["ha"],
"verticalalignment": ["va"],
"multialignment": ["ma"],
})
class Text(Artist):
"""Handle storing and drawing of text in window or data coordinates."""
zorder = 3
_charsize_cache = dict()
def __repr__(self):
return f"Text({self._x}, {self._y}, {self._text!r})"
def __init__(self,
x=0, y=0, text='', *,
color=None, # defaults to rc params
verticalalignment='baseline',
horizontalalignment='left',
multialignment=None,
fontproperties=None, # defaults to FontProperties()
rotation=None,
linespacing=None,
rotation_mode=None,
usetex=None, # defaults to rcParams['text.usetex']
wrap=False,
transform_rotates_text=False,
parse_math=None, # defaults to rcParams['text.parse_math']
antialiased=None, # defaults to rcParams['text.antialiased']
**kwargs
):
"""
Create a `.Text` instance at *x*, *y* with string *text*.
The text is aligned relative to the anchor point (*x*, *y*) according
to ``horizontalalignment`` (default: 'left') and ``verticalalignment``
(default: 'baseline'). See also
:doc:`/gallery/text_labels_and_annotations/text_alignment`.
While Text accepts the 'label' keyword argument, by default it is not
added to the handles of a legend.
Valid keyword arguments are:
%(Text:kwdoc)s
"""
super().__init__()
self._x, self._y = x, y
self._text = ''
self._features = None
self.set_language(None)
self._reset_visual_defaults(
text=text,
color=color,
fontproperties=fontproperties,
usetex=usetex,
parse_math=parse_math,
wrap=wrap,
verticalalignment=verticalalignment,
horizontalalignment=horizontalalignment,
multialignment=multialignment,
rotation=rotation,
transform_rotates_text=transform_rotates_text,
linespacing=linespacing,
rotation_mode=rotation_mode,
antialiased=antialiased
)
self.update(kwargs)
def _reset_visual_defaults(
self,
text='',
color=None,
fontproperties=None,
usetex=None,
parse_math=None,
wrap=False,
verticalalignment='baseline',
horizontalalignment='left',
multialignment=None,
rotation=None,
transform_rotates_text=False,
linespacing=None,
rotation_mode=None,
antialiased=None
):
self.set_text(text)
self.set_color(mpl._val_or_rc(color, "text.color"))
self.set_fontproperties(fontproperties)
self.set_usetex(usetex)
self.set_parse_math(mpl._val_or_rc(parse_math, 'text.parse_math'))
self.set_wrap(wrap)
self.set_verticalalignment(verticalalignment)
self.set_horizontalalignment(horizontalalignment)
self._multialignment = multialignment
self.set_rotation(rotation)
self._transform_rotates_text = transform_rotates_text
self._bbox_patch = None # a FancyBboxPatch instance
self._renderer = None
if linespacing is None:
linespacing = 'normal' # Maybe use rcParam later.
self.set_linespacing(linespacing)
self.set_rotation_mode(rotation_mode)
self.set_antialiased(mpl._val_or_rc(antialiased, 'text.antialiased'))
def update(self, kwargs):
# docstring inherited
ret = []
kwargs = cbook.normalize_kwargs(kwargs, Text)
sentinel = object() # bbox can be None, so use another sentinel.
# Update fontproperties first, as it has lowest priority.
fontproperties = kwargs.pop("fontproperties", sentinel)
if fontproperties is not sentinel:
ret.append(self.set_fontproperties(fontproperties))
# Update bbox last, as it depends on font properties.
bbox = kwargs.pop("bbox", sentinel)
ret.extend(super().update(kwargs))
if bbox is not sentinel:
ret.append(self.set_bbox(bbox))
return ret
def __getstate__(self):
d = super().__getstate__()
# remove the cached _renderer (if it exists)
d['_renderer'] = None
return d
def contains(self, mouseevent):
"""
Return whether the mouse event occurred inside the axis-aligned
bounding-box of the text.
"""
if (self._different_canvas(mouseevent) or not self.get_visible()
or self._renderer is None):
return False, {}
# Explicitly use Text.get_window_extent(self) and not
# self.get_window_extent() so that Annotation.contains does not
# accidentally cover the entire annotation bounding box.
bbox = Text.get_window_extent(self)
inside = (bbox.x0