@@ -64,17 +64,89 @@ def _get_textbox(text, renderer):
64
64
65
65
def _get_text_metrics_with_cache (renderer , text , fontprop , ismath , dpi ):
66
66
"""Call ``renderer.get_text_width_height_descent``, caching the results."""
67
- # Cached based on a copy of fontprop so that later in-place mutations of
68
- # the passed-in argument do not mess up the cache.
69
- return _get_text_metrics_with_cache_impl (
70
- weakref .ref (renderer ), text , fontprop .copy (), ismath , dpi )
71
67
68
+ # hit the outer cache layer and get the function to compute the metrics
69
+ # for this renderer instance
70
+ get_text_metrics = _get_text_metrics_function (renderer )
71
+ # call the function to compute the metrics and return
72
+ #
73
+ # We pass a copy of the fontprop because FontProperties is both mutable and
74
+ # has a `__hash__` that depends on that mutable state. This is not ideal
75
+ # as it means the hash of an object is not stable over time which leads to
76
+ # very confusing behavior when used as keys in dictionaries or hashes.
77
+ return get_text_metrics (text , fontprop .copy (), ismath , dpi )
72
78
73
- @functools .lru_cache (4096 )
74
- def _get_text_metrics_with_cache_impl (
75
- renderer_ref , text , fontprop , ismath , dpi ):
76
- # dpi is unused, but participates in cache invalidation (via the renderer).
77
- return renderer_ref ().get_text_width_height_descent (text , fontprop , ismath )
79
+
80
+ def _get_text_metrics_function (input_renderer , _cache = weakref .WeakKeyDictionary ()):
81
+ """
82
+ Helper function to provide a two-layered cache for font metrics
83
+
84
+
85
+ To get the rendered size of a size of string we need to know:
86
+ - what renderer we are using
87
+ - the current dpi of the renderer
88
+ - the string
89
+ - the font properties
90
+ - is it math text or not
91
+
92
+ We do this as a two-layer cache with the outer layer being tied to a
93
+ renderer instance and the inner layer handling everything else.
94
+
95
+ The outer layer is implemented as `.WeakKeyDictionary` keyed on the
96
+ renderer. As long as someone else is holding a hard ref to the renderer
97
+ we will keep the cache alive, but it will be automatically dropped when
98
+ the renderer is garbage collected.
99
+
100
+ The inner layer is provided by an lru_cache with a large maximum size (such
101
+ that we do not expect it to be hit in very few actual use cases). As the
102
+ dpi is mutable on the renderer, we need to explicitly include it as part of
103
+ the cache key on the inner layer even though we do not directly use it (it is
104
+ used in the method call on the renderer).
105
+
106
+ This function takes a renderer and returns a function that can be used to
107
+ get the font metrics.
108
+
109
+ Parameters
110
+ ----------
111
+ input_renderer : maplotlib.backend_bases.RendererBase
112
+ The renderer to set the cache up for.
113
+
114
+ _cache : dict, optional
115
+ We are using the mutable default value to attach the cache to the function.
116
+
117
+ In principle you could pass a different dict-like to this function to inject
118
+ a different cache, but please don't. This is an internal function not meant to
119
+ be reused outside of the narrow context we need it for.
120
+
121
+ There is a possible race condition here between threads, we may need to drop the
122
+ mutable default and switch to a threadlocal variable in the future.
123
+
124
+ """
125
+ if (_text_metrics := _cache .get (input_renderer , None )) is None :
126
+ # We are going to include this in the closure we put as values in the
127
+ # cache. Closing over a hard-ref would create an unbreakable reference
128
+ # cycle.
129
+ renderer_ref = weakref .ref (input_renderer )
130
+
131
+ # define the function locally to get a new lru_cache per renderer
132
+ @functools .lru_cache (4096 )
133
+ # dpi is unused, but participates in cache invalidation (via the renderer).
134
+ def _text_metrics (text , fontprop , ismath , dpi ):
135
+ # this should never happen under normal use, but this is a better error to
136
+ # raise than an AttributeError on `None`
137
+ if (local_renderer := renderer_ref ()) is None :
138
+ raise RuntimeError (
139
+ "Trying to get text metrics for a renderer that no longer exists. "
140
+ "This should never happen and is evidence of a bug elsewhere."
141
+ )
142
+ # do the actual method call we need and return the result
143
+ return local_renderer .get_text_width_height_descent (text , fontprop , ismath )
144
+
145
+ # stash the function for later use.
146
+ _cache [input_renderer ] = _text_metrics
147
+
148
+ # return the inner function
149
+ return _text_metrics
78
150
79
151
80
152
@_docstring .interpd
0 commit comments