textpath.py 15 KB

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