Coverage for /usr/lib/python3/dist-packages/matplotlib/texmanager.py: 35%

147 statements  

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

1r""" 

2Support for embedded TeX expressions in Matplotlib. 

3 

4Requirements: 

5 

6* LaTeX. 

7* \*Agg backends: dvipng>=1.6. 

8* PS backend: PSfrag, dvips, and Ghostscript>=9.0. 

9* PDF and SVG backends: if LuaTeX is present, it will be used to speed up some 

10 post-processing steps, but note that it is not used to parse the TeX string 

11 itself (only LaTeX is supported). 

12 

13To enable TeX rendering of all text in your Matplotlib figure, set 

14:rc:`text.usetex` to True. 

15 

16TeX and dvipng/dvips processing results are cached 

17in ~/.matplotlib/tex.cache for reuse between sessions. 

18 

19`TexManager.get_rgba` can also be used to directly obtain raster output as RGBA 

20NumPy arrays. 

21""" 

22 

23import functools 

24import hashlib 

25import logging 

26import os 

27from pathlib import Path 

28import subprocess 

29from tempfile import TemporaryDirectory 

30 

31import numpy as np 

32 

33import matplotlib as mpl 

34from matplotlib import _api, cbook, dviread 

35 

36_log = logging.getLogger(__name__) 

37 

38 

39def _usepackage_if_not_loaded(package, *, option=None): 

40 """ 

41 Output LaTeX code that loads a package (possibly with an option) if it 

42 hasn't been loaded yet. 

43 

44 LaTeX cannot load twice a package with different options, so this helper 

45 can be used to protect against users loading arbitrary packages/options in 

46 their custom preamble. 

47 """ 

48 option = f"[{option}]" if option is not None else "" 

49 return ( 

50 r"\makeatletter" 

51 r"\@ifpackageloaded{%(package)s}{}{\usepackage%(option)s{%(package)s}}" 

52 r"\makeatother" 

53 ) % {"package": package, "option": option} 

54 

55 

56class TexManager: 

57 """ 

58 Convert strings to dvi files using TeX, caching the results to a directory. 

59 

60 Repeated calls to this constructor always return the same instance. 

61 """ 

62 

63 texcache = os.path.join(mpl.get_cachedir(), 'tex.cache') 

64 _grey_arrayd = {} 

65 

66 _font_families = ('serif', 'sans-serif', 'cursive', 'monospace') 

67 _font_preambles = { 

68 'new century schoolbook': r'\renewcommand{\rmdefault}{pnc}', 

69 'bookman': r'\renewcommand{\rmdefault}{pbk}', 

70 'times': r'\usepackage{mathptmx}', 

71 'palatino': r'\usepackage{mathpazo}', 

72 'zapf chancery': r'\usepackage{chancery}', 

73 'cursive': r'\usepackage{chancery}', 

74 'charter': r'\usepackage{charter}', 

75 'serif': '', 

76 'sans-serif': '', 

77 'helvetica': r'\usepackage{helvet}', 

78 'avant garde': r'\usepackage{avant}', 

79 'courier': r'\usepackage{courier}', 

80 # Loading the type1ec package ensures that cm-super is installed, which 

81 # is necessary for Unicode computer modern. (It also allows the use of 

82 # computer modern at arbitrary sizes, but that's just a side effect.) 

83 'monospace': r'\usepackage{type1ec}', 

84 'computer modern roman': r'\usepackage{type1ec}', 

85 'computer modern sans serif': r'\usepackage{type1ec}', 

86 'computer modern typewriter': r'\usepackage{type1ec}', 

87 } 

88 _font_types = { 

89 'new century schoolbook': 'serif', 

90 'bookman': 'serif', 

91 'times': 'serif', 

92 'palatino': 'serif', 

93 'zapf chancery': 'cursive', 

94 'charter': 'serif', 

95 'helvetica': 'sans-serif', 

96 'avant garde': 'sans-serif', 

97 'courier': 'monospace', 

98 'computer modern roman': 'serif', 

99 'computer modern sans serif': 'sans-serif', 

100 'computer modern typewriter': 'monospace', 

101 } 

102 

103 grey_arrayd = _api.deprecate_privatize_attribute("3.5") 

104 font_family = _api.deprecate_privatize_attribute("3.5") 

105 font_families = _api.deprecate_privatize_attribute("3.5") 

106 font_info = _api.deprecate_privatize_attribute("3.5") 

107 

108 @functools.lru_cache() # Always return the same instance. 

109 def __new__(cls): 

110 Path(cls.texcache).mkdir(parents=True, exist_ok=True) 

111 return object.__new__(cls) 

112 

113 @_api.deprecated("3.6") 

114 def get_font_config(self): 

115 preamble, font_cmd = self._get_font_preamble_and_command() 

116 # Add a hash of the latex preamble to fontconfig so that the 

117 # correct png is selected for strings rendered with same font and dpi 

118 # even if the latex preamble changes within the session 

119 preambles = preamble + font_cmd + self.get_custom_preamble() 

120 return hashlib.md5(preambles.encode('utf-8')).hexdigest() 

121 

122 @classmethod 

123 def _get_font_family_and_reduced(cls): 

124 """Return the font family name and whether the font is reduced.""" 

125 ff = mpl.rcParams['font.family'] 

126 ff_val = ff[0].lower() if len(ff) == 1 else None 

127 if len(ff) == 1 and ff_val in cls._font_families: 

128 return ff_val, False 

129 elif len(ff) == 1 and ff_val in cls._font_preambles: 

130 return cls._font_types[ff_val], True 

131 else: 

132 _log.info('font.family must be one of (%s) when text.usetex is ' 

133 'True. serif will be used by default.', 

134 ', '.join(cls._font_families)) 

135 return 'serif', False 

136 

137 @classmethod 

138 def _get_font_preamble_and_command(cls): 

139 requested_family, is_reduced_font = cls._get_font_family_and_reduced() 

140 

141 preambles = {} 

142 for font_family in cls._font_families: 

143 if is_reduced_font and font_family == requested_family: 

144 preambles[font_family] = cls._font_preambles[ 

145 mpl.rcParams['font.family'][0].lower()] 

146 else: 

147 for font in mpl.rcParams['font.' + font_family]: 

148 if font.lower() in cls._font_preambles: 

149 preambles[font_family] = \ 

150 cls._font_preambles[font.lower()] 

151 _log.debug( 

152 'family: %s, font: %s, info: %s', 

153 font_family, font, 

154 cls._font_preambles[font.lower()]) 

155 break 

156 else: 

157 _log.debug('%s font is not compatible with usetex.', 

158 font) 

159 else: 

160 _log.info('No LaTeX-compatible font found for the %s font' 

161 'family in rcParams. Using default.', 

162 font_family) 

163 preambles[font_family] = cls._font_preambles[font_family] 

164 

165 # The following packages and commands need to be included in the latex 

166 # file's preamble: 

167 cmd = {preambles[family] 

168 for family in ['serif', 'sans-serif', 'monospace']} 

169 if requested_family == 'cursive': 

170 cmd.add(preambles['cursive']) 

171 cmd.add(r'\usepackage{type1cm}') 

172 preamble = '\n'.join(sorted(cmd)) 

173 fontcmd = (r'\sffamily' if requested_family == 'sans-serif' else 

174 r'\ttfamily' if requested_family == 'monospace' else 

175 r'\rmfamily') 

176 return preamble, fontcmd 

177 

178 @classmethod 

179 def get_basefile(cls, tex, fontsize, dpi=None): 

180 """ 

181 Return a filename based on a hash of the string, fontsize, and dpi. 

182 """ 

183 src = cls._get_tex_source(tex, fontsize) + str(dpi) 

184 return os.path.join( 

185 cls.texcache, hashlib.md5(src.encode('utf-8')).hexdigest()) 

186 

187 @classmethod 

188 def get_font_preamble(cls): 

189 """ 

190 Return a string containing font configuration for the tex preamble. 

191 """ 

192 font_preamble, command = cls._get_font_preamble_and_command() 

193 return font_preamble 

194 

195 @classmethod 

196 def get_custom_preamble(cls): 

197 """Return a string containing user additions to the tex preamble.""" 

198 return mpl.rcParams['text.latex.preamble'] 

199 

200 @classmethod 

201 def _get_tex_source(cls, tex, fontsize): 

202 """Return the complete TeX source for processing a TeX string.""" 

203 font_preamble, fontcmd = cls._get_font_preamble_and_command() 

204 baselineskip = 1.25 * fontsize 

205 return "\n".join([ 

206 r"\documentclass{article}", 

207 r"% Pass-through \mathdefault, which is used in non-usetex mode", 

208 r"% to use the default text font but was historically suppressed", 

209 r"% in usetex mode.", 

210 r"\newcommand{\mathdefault}[1]{#1}", 

211 font_preamble, 

212 r"\usepackage[utf8]{inputenc}", 

213 r"\DeclareUnicodeCharacter{2212}{\ensuremath{-}}", 

214 r"% geometry is loaded before the custom preamble as ", 

215 r"% convert_psfrags relies on a custom preamble to change the ", 

216 r"% geometry.", 

217 r"\usepackage[papersize=72in, margin=1in]{geometry}", 

218 cls.get_custom_preamble(), 

219 r"% Use `underscore` package to take care of underscores in text.", 

220 r"% The [strings] option allows to use underscores in file names.", 

221 _usepackage_if_not_loaded("underscore", option="strings"), 

222 r"% Custom packages (e.g. newtxtext) may already have loaded ", 

223 r"% textcomp with different options.", 

224 _usepackage_if_not_loaded("textcomp"), 

225 r"\pagestyle{empty}", 

226 r"\begin{document}", 

227 r"% The empty hbox ensures that a page is printed even for empty", 

228 r"% inputs, except when using psfrag which gets confused by it.", 

229 r"% matplotlibbaselinemarker is used by dviread to detect the", 

230 r"% last line's baseline.", 

231 rf"\fontsize{{{fontsize}}}{{{baselineskip}}}%", 

232 r"\ifdefined\psfrag\else\hbox{}\fi%", 

233 rf"{{{fontcmd} {tex}}}%", 

234 r"\end{document}", 

235 ]) 

236 

237 @classmethod 

238 def make_tex(cls, tex, fontsize): 

239 """ 

240 Generate a tex file to render the tex string at a specific font size. 

241 

242 Return the file name. 

243 """ 

244 texfile = cls.get_basefile(tex, fontsize) + ".tex" 

245 Path(texfile).write_text(cls._get_tex_source(tex, fontsize), 

246 encoding='utf-8') 

247 return texfile 

248 

249 @classmethod 

250 def _run_checked_subprocess(cls, command, tex, *, cwd=None): 

251 _log.debug(cbook._pformat_subprocess(command)) 

252 try: 

253 report = subprocess.check_output( 

254 command, cwd=cwd if cwd is not None else cls.texcache, 

255 stderr=subprocess.STDOUT) 

256 except FileNotFoundError as exc: 

257 raise RuntimeError( 

258 'Failed to process string with tex because {} could not be ' 

259 'found'.format(command[0])) from exc 

260 except subprocess.CalledProcessError as exc: 

261 raise RuntimeError( 

262 '{prog} was not able to process the following string:\n' 

263 '{tex!r}\n\n' 

264 'Here is the full command invocation and its output:\n\n' 

265 '{format_command}\n\n' 

266 '{exc}\n\n'.format( 

267 prog=command[0], 

268 format_command=cbook._pformat_subprocess(command), 

269 tex=tex.encode('unicode_escape'), 

270 exc=exc.output.decode('utf-8', 'backslashreplace')) 

271 ) from None 

272 _log.debug(report) 

273 return report 

274 

275 @classmethod 

276 def make_dvi(cls, tex, fontsize): 

277 """ 

278 Generate a dvi file containing latex's layout of tex string. 

279 

280 Return the file name. 

281 """ 

282 basefile = cls.get_basefile(tex, fontsize) 

283 dvifile = '%s.dvi' % basefile 

284 if not os.path.exists(dvifile): 

285 texfile = Path(cls.make_tex(tex, fontsize)) 

286 # Generate the dvi in a temporary directory to avoid race 

287 # conditions e.g. if multiple processes try to process the same tex 

288 # string at the same time. Having tmpdir be a subdirectory of the 

289 # final output dir ensures that they are on the same filesystem, 

290 # and thus replace() works atomically. It also allows referring to 

291 # the texfile with a relative path (for pathological MPLCONFIGDIRs, 

292 # the absolute path may contain characters (e.g. ~) that TeX does 

293 # not support.) 

294 with TemporaryDirectory(dir=Path(dvifile).parent) as tmpdir: 

295 cls._run_checked_subprocess( 

296 ["latex", "-interaction=nonstopmode", "--halt-on-error", 

297 f"../{texfile.name}"], tex, cwd=tmpdir) 

298 (Path(tmpdir) / Path(dvifile).name).replace(dvifile) 

299 return dvifile 

300 

301 @classmethod 

302 def make_png(cls, tex, fontsize, dpi): 

303 """ 

304 Generate a png file containing latex's rendering of tex string. 

305 

306 Return the file name. 

307 """ 

308 basefile = cls.get_basefile(tex, fontsize, dpi) 

309 pngfile = '%s.png' % basefile 

310 # see get_rgba for a discussion of the background 

311 if not os.path.exists(pngfile): 

312 dvifile = cls.make_dvi(tex, fontsize) 

313 cmd = ["dvipng", "-bg", "Transparent", "-D", str(dpi), 

314 "-T", "tight", "-o", pngfile, dvifile] 

315 # When testing, disable FreeType rendering for reproducibility; but 

316 # dvipng 1.16 has a bug (fixed in f3ff241) that breaks --freetype0 

317 # mode, so for it we keep FreeType enabled; the image will be 

318 # slightly off. 

319 if (getattr(mpl, "_called_from_pytest", False) and 

320 mpl._get_executable_info("dvipng").raw_version != "1.16"): 

321 cmd.insert(1, "--freetype0") 

322 cls._run_checked_subprocess(cmd, tex) 

323 return pngfile 

324 

325 @classmethod 

326 def get_grey(cls, tex, fontsize=None, dpi=None): 

327 """Return the alpha channel.""" 

328 if not fontsize: 

329 fontsize = mpl.rcParams['font.size'] 

330 if not dpi: 

331 dpi = mpl.rcParams['savefig.dpi'] 

332 key = cls._get_tex_source(tex, fontsize), dpi 

333 alpha = cls._grey_arrayd.get(key) 

334 if alpha is None: 

335 pngfile = cls.make_png(tex, fontsize, dpi) 

336 rgba = mpl.image.imread(os.path.join(cls.texcache, pngfile)) 

337 cls._grey_arrayd[key] = alpha = rgba[:, :, -1] 

338 return alpha 

339 

340 @classmethod 

341 def get_rgba(cls, tex, fontsize=None, dpi=None, rgb=(0, 0, 0)): 

342 r""" 

343 Return latex's rendering of the tex string as an rgba array. 

344 

345 Examples 

346 -------- 

347 >>> texmanager = TexManager() 

348 >>> s = r"\TeX\ is $\displaystyle\sum_n\frac{-e^{i\pi}}{2^n}$!" 

349 >>> Z = texmanager.get_rgba(s, fontsize=12, dpi=80, rgb=(1, 0, 0)) 

350 """ 

351 alpha = cls.get_grey(tex, fontsize, dpi) 

352 rgba = np.empty((*alpha.shape, 4)) 

353 rgba[..., :3] = mpl.colors.to_rgb(rgb) 

354 rgba[..., -1] = alpha 

355 return rgba 

356 

357 @classmethod 

358 def get_text_width_height_descent(cls, tex, fontsize, renderer=None): 

359 """Return width, height and descent of the text.""" 

360 if tex.strip() == '': 

361 return 0, 0, 0 

362 dvifile = cls.make_dvi(tex, fontsize) 

363 dpi_fraction = renderer.points_to_pixels(1.) if renderer else 1 

364 with dviread.Dvi(dvifile, 72 * dpi_fraction) as dvi: 

365 page, = dvi 

366 # A total height (including the descent) needs to be returned. 

367 return page.width, page.height + page.descent, page.descent