Coverage for /usr/lib/python3/dist-packages/matplotlib/textpath.py: 21%

192 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2025-06-14 15:25 +0200

1from collections import OrderedDict 

2import logging 

3import urllib.parse 

4 

5import numpy as np 

6 

7from matplotlib import _api, _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 

82 s : str 

83 The text to be converted. 

84 

85 ismath : {False, True, "TeX"} 

86 If True, use mathtext parser. If "TeX", use tex for rendering. 

87 

88 Returns 

89 ------- 

90 verts : list 

91 A list of numpy arrays containing the x and y coordinates of the 

92 vertices. 

93 

94 codes : list 

95 A list of path codes. 

96 

97 Examples 

98 -------- 

99 Create a list of vertices and codes from a text, and create a `.Path` 

100 from those:: 

101 

102 from matplotlib.path import Path 

103 from matplotlib.textpath import TextToPath 

104 from matplotlib.font_manager import FontProperties 

105 

106 fp = FontProperties(family="Humor Sans", style="italic") 

107 verts, codes = TextToPath().get_text_path(fp, "ABC") 

108 path = Path(verts, codes, closed=False) 

109 

110 Also see `TextPath` for a more direct way to create a path from a text. 

111 """ 

112 if ismath == "TeX": 

113 glyph_info, glyph_map, rects = self.get_glyphs_tex(prop, s) 

114 elif not ismath: 

115 font = self._get_font(prop) 

116 glyph_info, glyph_map, rects = self.get_glyphs_with_font(font, s) 

117 else: 

118 glyph_info, glyph_map, rects = self.get_glyphs_mathtext(prop, s) 

119 

120 verts, codes = [], [] 

121 for glyph_id, xposition, yposition, scale in glyph_info: 

122 verts1, codes1 = glyph_map[glyph_id] 

123 verts.extend(verts1 * scale + [xposition, yposition]) 

124 codes.extend(codes1) 

125 for verts1, codes1 in rects: 

126 verts.extend(verts1) 

127 codes.extend(codes1) 

128 

129 # Make sure an empty string or one with nothing to print 

130 # (e.g. only spaces & newlines) will be valid/empty path 

131 if not verts: 

132 verts = np.empty((0, 2)) 

133 

134 return verts, codes 

135 

136 def get_glyphs_with_font(self, font, s, glyph_map=None, 

137 return_new_glyphs_only=False): 

138 """ 

139 Convert string *s* to vertices and codes using the provided ttf font. 

140 """ 

141 

142 if glyph_map is None: 

143 glyph_map = OrderedDict() 

144 

145 if return_new_glyphs_only: 

146 glyph_map_new = OrderedDict() 

147 else: 

148 glyph_map_new = glyph_map 

149 

150 xpositions = [] 

151 glyph_ids = [] 

152 for item in _text_helpers.layout(s, font): 

153 char_id = self._get_char_id(item.ft_object, ord(item.char)) 

154 glyph_ids.append(char_id) 

155 xpositions.append(item.x) 

156 if char_id not in glyph_map: 

157 glyph_map_new[char_id] = item.ft_object.get_path() 

158 

159 ypositions = [0] * len(xpositions) 

160 sizes = [1.] * len(xpositions) 

161 

162 rects = [] 

163 

164 return (list(zip(glyph_ids, xpositions, ypositions, sizes)), 

165 glyph_map_new, rects) 

166 

167 def get_glyphs_mathtext(self, prop, s, glyph_map=None, 

168 return_new_glyphs_only=False): 

169 """ 

170 Parse mathtext string *s* and convert it to a (vertices, codes) pair. 

171 """ 

172 

173 prop = prop.copy() 

174 prop.set_size(self.FONT_SCALE) 

175 

176 width, height, descent, glyphs, rects = self.mathtext_parser.parse( 

177 s, self.DPI, prop) 

178 

179 if not glyph_map: 

180 glyph_map = OrderedDict() 

181 

182 if return_new_glyphs_only: 

183 glyph_map_new = OrderedDict() 

184 else: 

185 glyph_map_new = glyph_map 

186 

187 xpositions = [] 

188 ypositions = [] 

189 glyph_ids = [] 

190 sizes = [] 

191 

192 for font, fontsize, ccode, ox, oy in glyphs: 

193 char_id = self._get_char_id(font, ccode) 

194 if char_id not in glyph_map: 

195 font.clear() 

196 font.set_size(self.FONT_SCALE, self.DPI) 

197 font.load_char(ccode, flags=LOAD_NO_HINTING) 

198 glyph_map_new[char_id] = font.get_path() 

199 

200 xpositions.append(ox) 

201 ypositions.append(oy) 

202 glyph_ids.append(char_id) 

203 size = fontsize / self.FONT_SCALE 

204 sizes.append(size) 

205 

206 myrects = [] 

207 for ox, oy, w, h in rects: 

208 vert1 = [(ox, oy), (ox, oy + h), (ox + w, oy + h), 

209 (ox + w, oy), (ox, oy), (0, 0)] 

210 code1 = [Path.MOVETO, 

211 Path.LINETO, Path.LINETO, Path.LINETO, Path.LINETO, 

212 Path.CLOSEPOLY] 

213 myrects.append((vert1, code1)) 

214 

215 return (list(zip(glyph_ids, xpositions, ypositions, sizes)), 

216 glyph_map_new, myrects) 

217 

218 @_api.deprecated("3.6", alternative="TexManager()") 

219 def get_texmanager(self): 

220 """Return the cached `~.texmanager.TexManager` instance.""" 

221 if self._texmanager is None: 

222 self._texmanager = TexManager() 

223 return self._texmanager 

224 

225 def get_glyphs_tex(self, prop, s, glyph_map=None, 

226 return_new_glyphs_only=False): 

227 """Convert the string *s* to vertices and codes using usetex mode.""" 

228 # Mostly borrowed from pdf backend. 

229 

230 dvifile = TexManager().make_dvi(s, self.FONT_SCALE) 

231 with dviread.Dvi(dvifile, self.DPI) as dvi: 

232 page, = dvi 

233 

234 if glyph_map is None: 

235 glyph_map = OrderedDict() 

236 

237 if return_new_glyphs_only: 

238 glyph_map_new = OrderedDict() 

239 else: 

240 glyph_map_new = glyph_map 

241 

242 glyph_ids, xpositions, ypositions, sizes = [], [], [], [] 

243 

244 # Gather font information and do some setup for combining 

245 # characters into strings. 

246 for text in page.text: 

247 font = get_font(text.font_path) 

248 char_id = self._get_char_id(font, text.glyph) 

249 if char_id not in glyph_map: 

250 font.clear() 

251 font.set_size(self.FONT_SCALE, self.DPI) 

252 glyph_name_or_index = text.glyph_name_or_index 

253 if isinstance(glyph_name_or_index, str): 

254 index = font.get_name_index(glyph_name_or_index) 

255 font.load_glyph(index, flags=LOAD_TARGET_LIGHT) 

256 elif isinstance(glyph_name_or_index, int): 

257 self._select_native_charmap(font) 

258 font.load_char( 

259 glyph_name_or_index, flags=LOAD_TARGET_LIGHT) 

260 else: # Should not occur. 

261 raise TypeError(f"Glyph spec of unexpected type: " 

262 f"{glyph_name_or_index!r}") 

263 glyph_map_new[char_id] = font.get_path() 

264 

265 glyph_ids.append(char_id) 

266 xpositions.append(text.x) 

267 ypositions.append(text.y) 

268 sizes.append(text.font_size / self.FONT_SCALE) 

269 

270 myrects = [] 

271 

272 for ox, oy, h, w in page.boxes: 

273 vert1 = [(ox, oy), (ox + w, oy), (ox + w, oy + h), 

274 (ox, oy + h), (ox, oy), (0, 0)] 

275 code1 = [Path.MOVETO, 

276 Path.LINETO, Path.LINETO, Path.LINETO, Path.LINETO, 

277 Path.CLOSEPOLY] 

278 myrects.append((vert1, code1)) 

279 

280 return (list(zip(glyph_ids, xpositions, ypositions, sizes)), 

281 glyph_map_new, myrects) 

282 

283 @staticmethod 

284 def _select_native_charmap(font): 

285 # Select the native charmap. (we can't directly identify it but it's 

286 # typically an Adobe charmap). 

287 for charmap_code in [ 

288 1094992451, # ADOBE_CUSTOM. 

289 1094995778, # ADOBE_STANDARD. 

290 ]: 

291 try: 

292 font.select_charmap(charmap_code) 

293 except (ValueError, RuntimeError): 

294 pass 

295 else: 

296 break 

297 else: 

298 _log.warning("No supported encoding in font (%s).", font.fname) 

299 

300 

301text_to_path = TextToPath() 

302 

303 

304class TextPath(Path): 

305 """ 

306 Create a path from the text. 

307 """ 

308 

309 def __init__(self, xy, s, size=None, prop=None, 

310 _interpolation_steps=1, usetex=False): 

311 r""" 

312 Create a path from the text. Note that it simply is a path, 

313 not an artist. You need to use the `.PathPatch` (or other artists) 

314 to draw this path onto the canvas. 

315 

316 Parameters 

317 ---------- 

318 xy : tuple or array of two float values 

319 Position of the text. For no offset, use ``xy=(0, 0)``. 

320 

321 s : str 

322 The text to convert to a path. 

323 

324 size : float, optional 

325 Font size in points. Defaults to the size specified via the font 

326 properties *prop*. 

327 

328 prop : `matplotlib.font_manager.FontProperties`, optional 

329 Font property. If not provided, will use a default 

330 ``FontProperties`` with parameters from the 

331 :ref:`rcParams<customizing-with-dynamic-rc-settings>`. 

332 

333 _interpolation_steps : int, optional 

334 (Currently ignored) 

335 

336 usetex : bool, default: False 

337 Whether to use tex rendering. 

338 

339 Examples 

340 -------- 

341 The following creates a path from the string "ABC" with Helvetica 

342 font face; and another path from the latex fraction 1/2:: 

343 

344 from matplotlib.textpath import TextPath 

345 from matplotlib.font_manager import FontProperties 

346 

347 fp = FontProperties(family="Helvetica", style="italic") 

348 path1 = TextPath((12, 12), "ABC", size=12, prop=fp) 

349 path2 = TextPath((0, 0), r"$\frac{1}{2}$", size=12, usetex=True) 

350 

351 Also see :doc:`/gallery/text_labels_and_annotations/demo_text_path`. 

352 """ 

353 # Circular import. 

354 from matplotlib.text import Text 

355 

356 prop = FontProperties._from_any(prop) 

357 if size is None: 

358 size = prop.get_size_in_points() 

359 

360 self._xy = xy 

361 self.set_size(size) 

362 

363 self._cached_vertices = None 

364 s, ismath = Text(usetex=usetex)._preprocess_math(s) 

365 super().__init__( 

366 *text_to_path.get_text_path(prop, s, ismath=ismath), 

367 _interpolation_steps=_interpolation_steps, 

368 readonly=True) 

369 self._should_simplify = False 

370 

371 def set_size(self, size): 

372 """Set the text size.""" 

373 self._size = size 

374 self._invalid = True 

375 

376 def get_size(self): 

377 """Get the text size.""" 

378 return self._size 

379 

380 @property 

381 def vertices(self): 

382 """ 

383 Return the cached path after updating it if necessary. 

384 """ 

385 self._revalidate_path() 

386 return self._cached_vertices 

387 

388 @property 

389 def codes(self): 

390 """ 

391 Return the codes 

392 """ 

393 return self._codes 

394 

395 def _revalidate_path(self): 

396 """ 

397 Update the path if necessary. 

398 

399 The path for the text is initially create with the font size of 

400 `.FONT_SCALE`, and this path is rescaled to other size when necessary. 

401 """ 

402 if self._invalid or self._cached_vertices is None: 

403 tr = (Affine2D() 

404 .scale(self._size / text_to_path.FONT_SCALE) 

405 .translate(*self._xy)) 

406 self._cached_vertices = tr.transform(self._vertices) 

407 self._cached_vertices.flags.writeable = False 

408 self._invalid = False