from collections import OrderedDict import functools import logging import urllib.parse import numpy as np from matplotlib import _text_layout, dviread, font_manager, rcParams from matplotlib.font_manager import FontProperties, get_font from matplotlib.ft2font import LOAD_NO_HINTING, LOAD_TARGET_LIGHT from matplotlib.mathtext import MathTextParser from matplotlib.path import Path from matplotlib.transforms import Affine2D _log = logging.getLogger(__name__) class TextToPath: """A class that converts strings to paths.""" FONT_SCALE = 100. DPI = 72 def __init__(self): self.mathtext_parser = MathTextParser('path') self._texmanager = None def _get_font(self, prop): """ Find the `FT2Font` matching font properties *prop*, with its size set. """ fname = font_manager.findfont(prop) font = get_font(fname) font.set_size(self.FONT_SCALE, self.DPI) return font def _get_hinting_flag(self): return LOAD_NO_HINTING def _get_char_id(self, font, ccode): """ Return a unique id for the given font and character-code set. """ return urllib.parse.quote(f"{font.postscript_name}-{ccode:x}") def get_text_width_height_descent(self, s, prop, ismath): if ismath == "TeX": texmanager = self.get_texmanager() fontsize = prop.get_size_in_points() w, h, d = texmanager.get_text_width_height_descent(s, fontsize, renderer=None) return w, h, d fontsize = prop.get_size_in_points() scale = fontsize / self.FONT_SCALE if ismath: prop = prop.copy() prop.set_size(self.FONT_SCALE) width, height, descent, *_ = \ self.mathtext_parser.parse(s, 72, prop) return width * scale, height * scale, descent * scale font = self._get_font(prop) font.set_text(s, 0.0, flags=LOAD_NO_HINTING) w, h = font.get_width_height() w /= 64.0 # convert from subpixels h /= 64.0 d = font.get_descent() d /= 64.0 return w * scale, h * scale, d * scale def get_text_path(self, prop, s, ismath=False): """ Convert text *s* to path (a tuple of vertices and codes for matplotlib.path.Path). Parameters ---------- prop : `~matplotlib.font_manager.FontProperties` The font properties for the text. s : str The text to be converted. ismath : {False, True, "TeX"} If True, use mathtext parser. If "TeX", use tex for rendering. Returns ------- verts : list A list of numpy arrays containing the x and y coordinates of the vertices. codes : list A list of path codes. Examples -------- Create a list of vertices and codes from a text, and create a `.Path` from those:: from matplotlib.path import Path from matplotlib.textpath import TextToPath from matplotlib.font_manager import FontProperties fp = FontProperties(family="Humor Sans", style="italic") verts, codes = TextToPath().get_text_path(fp, "ABC") path = Path(verts, codes, closed=False) Also see `TextPath` for a more direct way to create a path from a text. """ if ismath == "TeX": glyph_info, glyph_map, rects = self.get_glyphs_tex(prop, s) elif not ismath: font = self._get_font(prop) glyph_info, glyph_map, rects = self.get_glyphs_with_font(font, s) else: glyph_info, glyph_map, rects = self.get_glyphs_mathtext(prop, s) verts, codes = [], [] for glyph_id, xposition, yposition, scale in glyph_info: verts1, codes1 = glyph_map[glyph_id] if len(verts1): verts1 = np.array(verts1) * scale + [xposition, yposition] verts.extend(verts1) codes.extend(codes1) for verts1, codes1 in rects: verts.extend(verts1) codes.extend(codes1) return verts, codes def get_glyphs_with_font(self, font, s, glyph_map=None, return_new_glyphs_only=False): """ Convert string *s* to vertices and codes using the provided ttf font. """ if glyph_map is None: glyph_map = OrderedDict() if return_new_glyphs_only: glyph_map_new = OrderedDict() else: glyph_map_new = glyph_map xpositions = [] glyph_ids = [] for item in _text_layout.layout(s, font): char_id = self._get_char_id(font, ord(item.char)) glyph_ids.append(char_id) xpositions.append(item.x) if char_id not in glyph_map: glyph_map_new[char_id] = font.get_path() ypositions = [0] * len(xpositions) sizes = [1.] * len(xpositions) rects = [] return (list(zip(glyph_ids, xpositions, ypositions, sizes)), glyph_map_new, rects) def get_glyphs_mathtext(self, prop, s, glyph_map=None, return_new_glyphs_only=False): """ Parse mathtext string *s* and convert it to a (vertices, codes) pair. """ prop = prop.copy() prop.set_size(self.FONT_SCALE) width, height, descent, glyphs, rects = self.mathtext_parser.parse( s, self.DPI, prop) if not glyph_map: glyph_map = OrderedDict() if return_new_glyphs_only: glyph_map_new = OrderedDict() else: glyph_map_new = glyph_map xpositions = [] ypositions = [] glyph_ids = [] sizes = [] for font, fontsize, ccode, ox, oy in glyphs: char_id = self._get_char_id(font, ccode) if char_id not in glyph_map: font.clear() font.set_size(self.FONT_SCALE, self.DPI) font.load_char(ccode, flags=LOAD_NO_HINTING) glyph_map_new[char_id] = font.get_path() xpositions.append(ox) ypositions.append(oy) glyph_ids.append(char_id) size = fontsize / self.FONT_SCALE sizes.append(size) myrects = [] for ox, oy, w, h in rects: vert1 = [(ox, oy), (ox, oy + h), (ox + w, oy + h), (ox + w, oy), (ox, oy), (0, 0)] code1 = [Path.MOVETO, Path.LINETO, Path.LINETO, Path.LINETO, Path.LINETO, Path.CLOSEPOLY] myrects.append((vert1, code1)) return (list(zip(glyph_ids, xpositions, ypositions, sizes)), glyph_map_new, myrects) def get_texmanager(self): """Return the cached `~.texmanager.TexManager` instance.""" if self._texmanager is None: from matplotlib.texmanager import TexManager self._texmanager = TexManager() return self._texmanager def get_glyphs_tex(self, prop, s, glyph_map=None, return_new_glyphs_only=False): """Convert the string *s* to vertices and codes using usetex mode.""" # Mostly borrowed from pdf backend. dvifile = self.get_texmanager().make_dvi(s, self.FONT_SCALE) with dviread.Dvi(dvifile, self.DPI) as dvi: page, = dvi if glyph_map is None: glyph_map = OrderedDict() if return_new_glyphs_only: glyph_map_new = OrderedDict() else: glyph_map_new = glyph_map glyph_ids, xpositions, ypositions, sizes = [], [], [], [] # Gather font information and do some setup for combining # characters into strings. for x1, y1, dvifont, glyph, width in page.text: font, enc = self._get_ps_font_and_encoding(dvifont.texname) char_id = self._get_char_id(font, glyph) if char_id not in glyph_map: font.clear() font.set_size(self.FONT_SCALE, self.DPI) # See comments in _get_ps_font_and_encoding. if enc is not None: index = font.get_name_index(enc[glyph]) font.load_glyph(index, flags=LOAD_TARGET_LIGHT) else: font.load_char(glyph, flags=LOAD_TARGET_LIGHT) glyph_map_new[char_id] = font.get_path() glyph_ids.append(char_id) xpositions.append(x1) ypositions.append(y1) sizes.append(dvifont.size / self.FONT_SCALE) myrects = [] for ox, oy, h, w in page.boxes: vert1 = [(ox, oy), (ox + w, oy), (ox + w, oy + h), (ox, oy + h), (ox, oy), (0, 0)] code1 = [Path.MOVETO, Path.LINETO, Path.LINETO, Path.LINETO, Path.LINETO, Path.CLOSEPOLY] myrects.append((vert1, code1)) return (list(zip(glyph_ids, xpositions, ypositions, sizes)), glyph_map_new, myrects) @staticmethod @functools.lru_cache(50) def _get_ps_font_and_encoding(texname): tex_font_map = dviread.PsfontsMap(dviread.find_tex_file('pdftex.map')) psfont = tex_font_map[texname] if psfont.filename is None: raise ValueError( f"No usable font file found for {psfont.psname} ({texname}). " f"The font may lack a Type-1 version.") font = get_font(psfont.filename) if psfont.encoding: # If psfonts.map specifies an encoding, use it: it gives us a # mapping of glyph indices to Adobe glyph names; use it to convert # dvi indices to glyph names and use the FreeType-synthesized # unicode charmap to convert glyph names to glyph indices (with # FT_Get_Name_Index/get_name_index), and load the glyph using # FT_Load_Glyph/load_glyph. (That charmap has a coverage at least # as good as, and possibly better than, the native charmaps.) enc = dviread._parse_enc(psfont.encoding) else: # If psfonts.map specifies no encoding, the indices directly # map to the font's "native" charmap; so don't use the # FreeType-synthesized charmap but the native ones (we can't # directly identify it but it's typically an Adobe charmap), and # directly load the dvi glyph indices using FT_Load_Char/load_char. for charmap_code in [ 1094992451, # ADOBE_CUSTOM. 1094995778, # ADOBE_STANDARD. ]: try: font.select_charmap(charmap_code) except (ValueError, RuntimeError): pass else: break else: _log.warning("No supported encoding in font (%s).", psfont.filename) enc = None return font, enc text_to_path = TextToPath() class TextPath(Path): """ Create a path from the text. """ def __init__(self, xy, s, size=None, prop=None, _interpolation_steps=1, usetex=False): r""" Create a path from the text. Note that it simply is a path, not an artist. You need to use the `~.PathPatch` (or other artists) to draw this path onto the canvas. Parameters ---------- xy : tuple or array of two float values Position of the text. For no offset, use ``xy=(0, 0)``. s : str The text to convert to a path. size : float, optional Font size in points. Defaults to the size specified via the font properties *prop*. prop : `matplotlib.font_manager.FontProperties`, optional Font property. If not provided, will use a default ``FontProperties`` with parameters from the :ref:`rcParams `. _interpolation_steps : int, optional (Currently ignored) usetex : bool, default: False Whether to use tex rendering. Examples -------- The following creates a path from the string "ABC" with Helvetica font face; and another path from the latex fraction 1/2:: from matplotlib.textpath import TextPath from matplotlib.font_manager import FontProperties fp = FontProperties(family="Helvetica", style="italic") path1 = TextPath((12, 12), "ABC", size=12, prop=fp) path2 = TextPath((0, 0), r"$\frac{1}{2}$", size=12, usetex=True) Also see :doc:`/gallery/text_labels_and_annotations/demo_text_path`. """ # Circular import. from matplotlib.text import Text prop = FontProperties._from_any(prop) if size is None: size = prop.get_size_in_points() self._xy = xy self.set_size(size) self._cached_vertices = None s, ismath = Text(usetex=usetex)._preprocess_math(s) self._vertices, self._codes = text_to_path.get_text_path( prop, s, ismath=ismath) self._should_simplify = False self._simplify_threshold = rcParams['path.simplify_threshold'] self._interpolation_steps = _interpolation_steps def set_size(self, size): """Set the text size.""" self._size = size self._invalid = True def get_size(self): """Get the text size.""" return self._size @property def vertices(self): """ Return the cached path after updating it if necessary. """ self._revalidate_path() return self._cached_vertices @property def codes(self): """ Return the codes """ return self._codes def _revalidate_path(self): """ Update the path if necessary. The path for the text is initially create with the font size of `~.FONT_SCALE`, and this path is rescaled to other size when necessary. """ if self._invalid or self._cached_vertices is None: tr = (Affine2D() .scale(self._size / text_to_path.FONT_SCALE) .translate(*self._xy)) self._cached_vertices = tr.transform(self._vertices) self._invalid = False