1from collections import OrderedDict
2import logging
3import urllib.parse
4
5import numpy as np
6
7from matplotlib import _text_helpers, dviread
8from matplotlib.font_manager import (
9 FontProperties, get_font, fontManager as _fontManager
10)
11from matplotlib.ft2font import LOAD_NO_HINTING, LOAD_TARGET_LIGHT
12from matplotlib.mathtext import MathTextParser
13from matplotlib.path import Path
14from matplotlib.texmanager import TexManager
15from matplotlib.transforms import Affine2D
16
17_log = logging.getLogger(__name__)
18
19
20class TextToPath:
21 """A class that converts strings to paths."""
22
23 FONT_SCALE = 100.
24 DPI = 72
25
26 def __init__(self):
27 self.mathtext_parser = MathTextParser('path')
28 self._texmanager = None
29
30 def _get_font(self, prop):
31 """
32 Find the `FT2Font` matching font properties *prop*, with its size set.
33 """
34 filenames = _fontManager._find_fonts_by_props(prop)
35 font = get_font(filenames)
36 font.set_size(self.FONT_SCALE, self.DPI)
37 return font
38
39 def _get_hinting_flag(self):
40 return LOAD_NO_HINTING
41
42 def _get_char_id(self, font, ccode):
43 """
44 Return a unique id for the given font and character-code set.
45 """
46 return urllib.parse.quote(f"{font.postscript_name}-{ccode:x}")
47
48 def get_text_width_height_descent(self, s, prop, ismath):
49 fontsize = prop.get_size_in_points()
50
51 if ismath == "TeX":
52 return TexManager().get_text_width_height_descent(s, fontsize)
53
54 scale = fontsize / self.FONT_SCALE
55
56 if ismath:
57 prop = prop.copy()
58 prop.set_size(self.FONT_SCALE)
59 width, height, descent, *_ = \
60 self.mathtext_parser.parse(s, 72, prop)
61 return width * scale, height * scale, descent * scale
62
63 font = self._get_font(prop)
64 font.set_text(s, 0.0, flags=LOAD_NO_HINTING)
65 w, h = font.get_width_height()
66 w /= 64.0 # convert from subpixels
67 h /= 64.0
68 d = font.get_descent()
69 d /= 64.0
70 return w * scale, h * scale, d * scale
71
72 def get_text_path(self, prop, s, ismath=False):
73 """
74 Convert text *s* to path (a tuple of vertices and codes for
75 matplotlib.path.Path).
76
77 Parameters
78 ----------
79 prop : `~matplotlib.font_manager.FontProperties`
80 The font properties for the text.
81 s : str
82 The text to be converted.
83 ismath : {False, True, "TeX"}
84 If True, use mathtext parser. If "TeX", use tex for rendering.
85
86 Returns
87 -------
88 verts : list
89 A list of arrays containing the (x, y) coordinates of the vertices.
90 codes : list
91 A list of path codes.
92
93 Examples
94 --------
95 Create a list of vertices and codes from a text, and create a `.Path`
96 from those::
97
98 from matplotlib.path import Path
99 from matplotlib.text import TextToPath
100 from matplotlib.font_manager import FontProperties
101
102 fp = FontProperties(family="Comic Neue", style="italic")
103 verts, codes = TextToPath().get_text_path(fp, "ABC")
104 path = Path(verts, codes, closed=False)
105
106 Also see `TextPath` for a more direct way to create a path from a text.
107 """
108 if ismath == "TeX":
109 glyph_info, glyph_map, rects = self.get_glyphs_tex(prop, s)
110 elif not ismath:
111 font = self._get_font(prop)
112 glyph_info, glyph_map, rects = self.get_glyphs_with_font(font, s)
113 else:
114 glyph_info, glyph_map, rects = self.get_glyphs_mathtext(prop, s)
115
116 verts, codes = [], []
117 for glyph_id, xposition, yposition, scale in glyph_info:
118 verts1, codes1 = glyph_map[glyph_id]
119 verts.extend(verts1 * scale + [xposition, yposition])
120 codes.extend(codes1)
121 for verts1, codes1 in rects:
122 verts.extend(verts1)
123 codes.extend(codes1)
124
125 # Make sure an empty string or one with nothing to print
126 # (e.g. only spaces & newlines) will be valid/empty path
127 if not verts:
128 verts = np.empty((0, 2))
129
130 return verts, codes
131
132 def get_glyphs_with_font(self, font, s, glyph_map=None,
133 return_new_glyphs_only=False):
134 """
135 Convert string *s* to vertices and codes using the provided ttf font.
136 """
137
138 if glyph_map is None:
139 glyph_map = OrderedDict()
140
141 if return_new_glyphs_only:
142 glyph_map_new = OrderedDict()
143 else:
144 glyph_map_new = glyph_map
145
146 xpositions = []
147 glyph_ids = []
148 for item in _text_helpers.layout(s, font):
149 char_id = self._get_char_id(item.ft_object, ord(item.char))
150 glyph_ids.append(char_id)
151 xpositions.append(item.x)
152 if char_id not in glyph_map:
153 glyph_map_new[char_id] = item.ft_object.get_path()
154
155 ypositions = [0] * len(xpositions)
156 sizes = [1.] * len(xpositions)
157
158 rects = []
159
160 return (list(zip(glyph_ids, xpositions, ypositions, sizes)),
161 glyph_map_new, rects)
162
163 def get_glyphs_mathtext(self, prop, s, glyph_map=None,
164 return_new_glyphs_only=False):
165 """
166 Parse mathtext string *s* and convert it to a (vertices, codes) pair.
167 """
168
169 prop = prop.copy()
170 prop.set_size(self.FONT_SCALE)
171
172 width, height, descent, glyphs, rects = self.mathtext_parser.parse(
173 s, self.DPI, prop)
174
175 if not glyph_map:
176 glyph_map = OrderedDict()
177
178 if return_new_glyphs_only:
179 glyph_map_new = OrderedDict()
180 else:
181 glyph_map_new = glyph_map
182
183 xpositions = []
184 ypositions = []
185 glyph_ids = []
186 sizes = []
187
188 for font, fontsize, ccode, ox, oy in glyphs:
189 char_id = self._get_char_id(font, ccode)
190 if char_id not in glyph_map:
191 font.clear()
192 font.set_size(self.FONT_SCALE, self.DPI)
193 font.load_char(ccode, flags=LOAD_NO_HINTING)
194 glyph_map_new[char_id] = font.get_path()
195
196 xpositions.append(ox)
197 ypositions.append(oy)
198 glyph_ids.append(char_id)
199 size = fontsize / self.FONT_SCALE
200 sizes.append(size)
201
202 myrects = []
203 for ox, oy, w, h in rects:
204 vert1 = [(ox, oy), (ox, oy + h), (ox + w, oy + h),
205 (ox + w, oy), (ox, oy), (0, 0)]
206 code1 = [Path.MOVETO,
207 Path.LINETO, Path.LINETO, Path.LINETO, Path.LINETO,
208 Path.CLOSEPOLY]
209 myrects.append((vert1, code1))
210
211 return (list(zip(glyph_ids, xpositions, ypositions, sizes)),
212 glyph_map_new, myrects)
213
214 def get_glyphs_tex(self, prop, s, glyph_map=None,
215 return_new_glyphs_only=False):
216 """Convert the string *s* to vertices and codes using usetex mode."""
217 # Mostly borrowed from pdf backend.
218
219 dvifile = TexManager().make_dvi(s, self.FONT_SCALE)
220 with dviread.Dvi(dvifile, self.DPI) as dvi:
221 page, = dvi
222
223 if glyph_map is None:
224 glyph_map = OrderedDict()
225
226 if return_new_glyphs_only:
227 glyph_map_new = OrderedDict()
228 else:
229 glyph_map_new = glyph_map
230
231 glyph_ids, xpositions, ypositions, sizes = [], [], [], []
232
233 # Gather font information and do some setup for combining
234 # characters into strings.
235 for text in page.text:
236 font = get_font(text.font_path)
237 char_id = self._get_char_id(font, text.glyph)
238 if char_id not in glyph_map:
239 font.clear()
240 font.set_size(self.FONT_SCALE, self.DPI)
241 glyph_name_or_index = text.glyph_name_or_index
242 if isinstance(glyph_name_or_index, str):
243 index = font.get_name_index(glyph_name_or_index)
244 font.load_glyph(index, flags=LOAD_TARGET_LIGHT)
245 elif isinstance(glyph_name_or_index, int):
246 self._select_native_charmap(font)
247 font.load_char(
248 glyph_name_or_index, flags=LOAD_TARGET_LIGHT)
249 else: # Should not occur.
250 raise TypeError(f"Glyph spec of unexpected type: "
251 f"{glyph_name_or_index!r}")
252 glyph_map_new[char_id] = font.get_path()
253
254 glyph_ids.append(char_id)
255 xpositions.append(text.x)
256 ypositions.append(text.y)
257 sizes.append(text.font_size / self.FONT_SCALE)
258
259 myrects = []
260
261 for ox, oy, h, w in page.boxes:
262 vert1 = [(ox, oy), (ox + w, oy), (ox + w, oy + h),
263 (ox, oy + h), (ox, oy), (0, 0)]
264 code1 = [Path.MOVETO,
265 Path.LINETO, Path.LINETO, Path.LINETO, Path.LINETO,
266 Path.CLOSEPOLY]
267 myrects.append((vert1, code1))
268
269 return (list(zip(glyph_ids, xpositions, ypositions, sizes)),
270 glyph_map_new, myrects)
271
272 @staticmethod
273 def _select_native_charmap(font):
274 # Select the native charmap. (we can't directly identify it but it's
275 # typically an Adobe charmap).
276 for charmap_code in [
277 1094992451, # ADOBE_CUSTOM.
278 1094995778, # ADOBE_STANDARD.
279 ]:
280 try:
281 font.select_charmap(charmap_code)
282 except (ValueError, RuntimeError):
283 pass
284 else:
285 break
286 else:
287 _log.warning("No supported encoding in font (%s).", font.fname)
288
289
290text_to_path = TextToPath()
291
292
293class TextPath(Path):
294 """
295 Create a path from the text.
296 """
297
298 def __init__(self, xy, s, size=None, prop=None,
299 _interpolation_steps=1, usetex=False):
300 r"""
301 Create a path from the text. Note that it simply is a path,
302 not an artist. You need to use the `.PathPatch` (or other artists)
303 to draw this path onto the canvas.
304
305 Parameters
306 ----------
307 xy : tuple or array of two float values
308 Position of the text. For no offset, use ``xy=(0, 0)``.
309
310 s : str
311 The text to convert to a path.
312
313 size : float, optional
314 Font size in points. Defaults to the size specified via the font
315 properties *prop*.
316
317 prop : `~matplotlib.font_manager.FontProperties`, optional
318 Font property. If not provided, will use a default
319 `.FontProperties` with parameters from the
320 :ref:`rcParams<customizing-with-dynamic-rc-settings>`.
321
322 _interpolation_steps : int, optional
323 (Currently ignored)
324
325 usetex : bool, default: False
326 Whether to use tex rendering.
327
328 Examples
329 --------
330 The following creates a path from the string "ABC" with Helvetica
331 font face; and another path from the latex fraction 1/2::
332
333 from matplotlib.text import TextPath
334 from matplotlib.font_manager import FontProperties
335
336 fp = FontProperties(family="Helvetica", style="italic")
337 path1 = TextPath((12, 12), "ABC", size=12, prop=fp)
338 path2 = TextPath((0, 0), r"$\frac{1}{2}$", size=12, usetex=True)
339
340 Also see :doc:`/gallery/text_labels_and_annotations/demo_text_path`.
341 """
342 # Circular import.
343 from matplotlib.text import Text
344
345 prop = FontProperties._from_any(prop)
346 if size is None:
347 size = prop.get_size_in_points()
348
349 self._xy = xy
350 self.set_size(size)
351
352 self._cached_vertices = None
353 s, ismath = Text(usetex=usetex)._preprocess_math(s)
354 super().__init__(
355 *text_to_path.get_text_path(prop, s, ismath=ismath),
356 _interpolation_steps=_interpolation_steps,
357 readonly=True)
358 self._should_simplify = False
359
360 def set_size(self, size):
361 """Set the text size."""
362 self._size = size
363 self._invalid = True
364
365 def get_size(self):
366 """Get the text size."""
367 return self._size
368
369 @property
370 def vertices(self):
371 """
372 Return the cached path after updating it if necessary.
373 """
374 self._revalidate_path()
375 return self._cached_vertices
376
377 @property
378 def codes(self):
379 """
380 Return the codes
381 """
382 return self._codes
383
384 def _revalidate_path(self):
385 """
386 Update the path if necessary.
387
388 The path for the text is initially create with the font size of
389 `.FONT_SCALE`, and this path is rescaled to other size when necessary.
390 """
391 if self._invalid or self._cached_vertices is None:
392 tr = (Affine2D()
393 .scale(self._size / text_to_path.FONT_SCALE)
394 .translate(*self._xy))
395 self._cached_vertices = tr.transform(self._vertices)
396 self._cached_vertices.flags.writeable = False
397 self._invalid = False