textpath.py 13 KB

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