1r""" 2Support for embedded TeX expressions in Matplotlib via dvipng and dvips for the 3raster and PostScript backends. The tex and dvipng/dvips information is cached 4in ~/.matplotlib/tex.cache for reuse between sessions. 5 6Requirements: 7 8* LaTeX 9* \*Agg backends: dvipng>=1.6 10* PS backend: psfrag, dvips, and Ghostscript>=9.0 11 12For raster output, you can get RGBA numpy arrays from TeX expressions 13as follows:: 14 15 texmanager = TexManager() 16 s = "\TeX\ is Number $\displaystyle\sum_{n=1}^\infty\frac{-e^{i\pi}}{2^n}$!" 17 Z = texmanager.get_rgba(s, fontsize=12, dpi=80, rgb=(1, 0, 0)) 18 19To enable TeX rendering of all text in your Matplotlib figure, set 20:rc:`text.usetex` to True. 21""" 22 23import functools 24import glob 25import hashlib 26import logging 27import os 28from pathlib import Path 29import re 30import subprocess 31from tempfile import TemporaryDirectory 32 33import numpy as np 34 35import matplotlib as mpl 36from matplotlib import _api, cbook, dviread, rcParams 37 38_log = logging.getLogger(__name__) 39 40 41class TexManager: 42 """ 43 Convert strings to dvi files using TeX, caching the results to a directory. 44 45 Repeated calls to this constructor always return the same instance. 46 """ 47 48 # Caches. 49 texcache = os.path.join(mpl.get_cachedir(), 'tex.cache') 50 grey_arrayd = {} 51 52 font_family = 'serif' 53 font_families = ('serif', 'sans-serif', 'cursive', 'monospace') 54 55 font_info = { 56 'new century schoolbook': ('pnc', r'\renewcommand{\rmdefault}{pnc}'), 57 'bookman': ('pbk', r'\renewcommand{\rmdefault}{pbk}'), 58 'times': ('ptm', r'\usepackage{mathptmx}'), 59 'palatino': ('ppl', r'\usepackage{mathpazo}'), 60 'zapf chancery': ('pzc', r'\usepackage{chancery}'), 61 'cursive': ('pzc', r'\usepackage{chancery}'), 62 'charter': ('pch', r'\usepackage{charter}'), 63 'serif': ('cmr', ''), 64 'sans-serif': ('cmss', ''), 65 'helvetica': ('phv', r'\usepackage{helvet}'), 66 'avant garde': ('pag', r'\usepackage{avant}'), 67 'courier': ('pcr', r'\usepackage{courier}'), 68 # Loading the type1ec package ensures that cm-super is installed, which 69 # is necessary for unicode computer modern. (It also allows the use of 70 # computer modern at arbitrary sizes, but that's just a side effect.) 71 'monospace': ('cmtt', r'\usepackage{type1ec}'), 72 'computer modern roman': ('cmr', r'\usepackage{type1ec}'), 73 'computer modern sans serif': ('cmss', r'\usepackage{type1ec}'), 74 'computer modern typewriter': ('cmtt', r'\usepackage{type1ec}')} 75 76 cachedir = _api.deprecated( 77 "3.3", alternative="matplotlib.get_cachedir()")( 78 property(lambda self: mpl.get_cachedir())) 79 rgba_arrayd = _api.deprecated("3.3")(property(lambda self: {})) 80 _fonts = {} # Only for deprecation period. 81 serif = _api.deprecated("3.3")(property( 82 lambda self: self._fonts.get("serif", ('cmr', '')))) 83 sans_serif = _api.deprecated("3.3")(property( 84 lambda self: self._fonts.get("sans-serif", ('cmss', '')))) 85 cursive = _api.deprecated("3.3")(property( 86 lambda self: 87 self._fonts.get("cursive", ('pzc', r'\usepackage{chancery}')))) 88 monospace = _api.deprecated("3.3")(property( 89 lambda self: self._fonts.get("monospace", ('cmtt', '')))) 90 91 @functools.lru_cache() # Always return the same instance. 92 def __new__(cls): 93 Path(cls.texcache).mkdir(parents=True, exist_ok=True) 94 return object.__new__(cls) 95 96 def get_font_config(self): 97 ff = rcParams['font.family'] 98 if len(ff) == 1 and ff[0].lower() in self.font_families: 99 self.font_family = ff[0].lower() 100 else: 101 _log.info('font.family must be one of (%s) when text.usetex is ' 102 'True. serif will be used by default.', 103 ', '.join(self.font_families)) 104 self.font_family = 'serif' 105 106 fontconfig = [self.font_family] 107 for font_family in self.font_families: 108 for font in rcParams['font.' + font_family]: 109 if font.lower() in self.font_info: 110 self._fonts[font_family] = self.font_info[font.lower()] 111 _log.debug('family: %s, font: %s, info: %s', 112 font_family, font, self.font_info[font.lower()]) 113 break 114 else: 115 _log.debug('%s font is not compatible with usetex.', font) 116 else: 117 _log.info('No LaTeX-compatible font found for the %s font ' 118 'family in rcParams. Using default.', font_family) 119 self._fonts[font_family] = self.font_info[font_family] 120 fontconfig.append(self._fonts[font_family][0]) 121 # Add a hash of the latex preamble to fontconfig so that the 122 # correct png is selected for strings rendered with same font and dpi 123 # even if the latex preamble changes within the session 124 preamble_bytes = self.get_custom_preamble().encode('utf-8') 125 fontconfig.append(hashlib.md5(preamble_bytes).hexdigest()) 126 127 # The following packages and commands need to be included in the latex 128 # file's preamble: 129 cmd = [self._fonts['serif'][1], 130 self._fonts['sans-serif'][1], 131 self._fonts['monospace'][1]] 132 if self.font_family == 'cursive': 133 cmd.append(self._fonts['cursive'][1]) 134 self._font_preamble = '\n'.join([r'\usepackage{type1cm}', *cmd]) 135 136 return ''.join(fontconfig) 137 138 def get_basefile(self, tex, fontsize, dpi=None): 139 """ 140 Return a filename based on a hash of the string, fontsize, and dpi. 141 """ 142 s = ''.join([tex, self.get_font_config(), '%f' % fontsize, 143 self.get_custom_preamble(), str(dpi or '')]) 144 return os.path.join( 145 self.texcache, hashlib.md5(s.encode('utf-8')).hexdigest()) 146 147 def get_font_preamble(self): 148 """ 149 Return a string containing font configuration for the tex preamble. 150 """ 151 return self._font_preamble 152 153 def get_custom_preamble(self): 154 """Return a string containing user additions to the tex preamble.""" 155 return rcParams['text.latex.preamble'] 156 157 def _get_preamble(self): 158 return "\n".join([ 159 r"\documentclass{article}", 160 # Pass-through \mathdefault, which is used in non-usetex mode to 161 # use the default text font but was historically suppressed in 162 # usetex mode. 163 r"\newcommand{\mathdefault}[1]{#1}", 164 self._font_preamble, 165 r"\usepackage[utf8]{inputenc}", 166 r"\DeclareUnicodeCharacter{2212}{\ensuremath{-}}", 167 # geometry is loaded before the custom preamble as convert_psfrags 168 # relies on a custom preamble to change the geometry. 169 r"\usepackage[papersize=72in, margin=1in]{geometry}", 170 self.get_custom_preamble(), 171 # textcomp is loaded last (if not already loaded by the custom 172 # preamble) in order not to clash with custom packages (e.g. 173 # newtxtext) which load it with different options. 174 r"\makeatletter" 175 r"\@ifpackageloaded{textcomp}{}{\usepackage{textcomp}}" 176 r"\makeatother", 177 ]) 178 179 def make_tex(self, tex, fontsize): 180 """ 181 Generate a tex file to render the tex string at a specific font size. 182 183 Return the file name. 184 """ 185 basefile = self.get_basefile(tex, fontsize) 186 texfile = '%s.tex' % basefile 187 fontcmd = {'sans-serif': r'{\sffamily %s}', 188 'monospace': r'{\ttfamily %s}'}.get(self.font_family, 189 r'{\rmfamily %s}') 190 191 Path(texfile).write_text( 192 r""" 193%s 194\pagestyle{empty} 195\begin{document} 196%% The empty hbox ensures that a page is printed even for empty inputs, except 197%% when using psfrag which gets confused by it. 198\fontsize{%f}{%f}%% 199\ifdefined\psfrag\else\hbox{}\fi%% 200%s 201\end{document} 202""" % (self._get_preamble(), fontsize, fontsize * 1.25, fontcmd % tex), 203 encoding='utf-8') 204 205 return texfile 206 207 _re_vbox = re.compile( 208 r"MatplotlibBox:\(([\d.]+)pt\+([\d.]+)pt\)x([\d.]+)pt") 209 210 @_api.deprecated("3.3") 211 def make_tex_preview(self, tex, fontsize): 212 """ 213 Generate a tex file to render the tex string at a specific font size. 214 215 It uses the preview.sty to determine the dimension (width, height, 216 descent) of the output. 217 218 Return the file name. 219 """ 220 basefile = self.get_basefile(tex, fontsize) 221 texfile = '%s.tex' % basefile 222 fontcmd = {'sans-serif': r'{\sffamily %s}', 223 'monospace': r'{\ttfamily %s}'}.get(self.font_family, 224 r'{\rmfamily %s}') 225 226 # newbox, setbox, immediate, etc. are used to find the box 227 # extent of the rendered text. 228 229 Path(texfile).write_text( 230 r""" 231%s 232\usepackage[active,showbox,tightpage]{preview} 233 234%% we override the default showbox as it is treated as an error and makes 235%% the exit status not zero 236\def\showbox#1%% 237{\immediate\write16{MatplotlibBox:(\the\ht#1+\the\dp#1)x\the\wd#1}} 238 239\begin{document} 240\begin{preview} 241{\fontsize{%f}{%f}%s} 242\end{preview} 243\end{document} 244""" % (self._get_preamble(), fontsize, fontsize * 1.25, fontcmd % tex), 245 encoding='utf-8') 246 247 return texfile 248 249 def _run_checked_subprocess(self, command, tex, *, cwd=None): 250 _log.debug(cbook._pformat_subprocess(command)) 251 try: 252 report = subprocess.check_output( 253 command, cwd=cwd if cwd is not None else self.texcache, 254 stderr=subprocess.STDOUT) 255 except FileNotFoundError as exc: 256 raise RuntimeError( 257 'Failed to process string with tex because {} could not be ' 258 'found'.format(command[0])) from exc 259 except subprocess.CalledProcessError as exc: 260 raise RuntimeError( 261 '{prog} was not able to process the following string:\n' 262 '{tex!r}\n\n' 263 'Here is the full report generated by {prog}:\n' 264 '{exc}\n\n'.format( 265 prog=command[0], 266 tex=tex.encode('unicode_escape'), 267 exc=exc.output.decode('utf-8'))) from exc 268 _log.debug(report) 269 return report 270 271 def make_dvi(self, tex, fontsize): 272 """ 273 Generate a dvi file containing latex's layout of tex string. 274 275 Return the file name. 276 """ 277 278 if dict.__getitem__(rcParams, 'text.latex.preview'): 279 return self.make_dvi_preview(tex, fontsize) 280 281 basefile = self.get_basefile(tex, fontsize) 282 dvifile = '%s.dvi' % basefile 283 if not os.path.exists(dvifile): 284 texfile = self.make_tex(tex, fontsize) 285 # Generate the dvi in a temporary directory to avoid race 286 # conditions e.g. if multiple processes try to process the same tex 287 # string at the same time. Having tmpdir be a subdirectory of the 288 # final output dir ensures that they are on the same filesystem, 289 # and thus replace() works atomically. 290 with TemporaryDirectory(dir=Path(dvifile).parent) as tmpdir: 291 self._run_checked_subprocess( 292 ["latex", "-interaction=nonstopmode", "--halt-on-error", 293 texfile], tex, cwd=tmpdir) 294 (Path(tmpdir) / Path(dvifile).name).replace(dvifile) 295 return dvifile 296 297 @_api.deprecated("3.3") 298 def make_dvi_preview(self, tex, fontsize): 299 """ 300 Generate a dvi file containing latex's layout of tex string. 301 302 It calls make_tex_preview() method and store the size information 303 (width, height, descent) in a separate file. 304 305 Return the file name. 306 """ 307 basefile = self.get_basefile(tex, fontsize) 308 dvifile = '%s.dvi' % basefile 309 baselinefile = '%s.baseline' % basefile 310 311 if not os.path.exists(dvifile) or not os.path.exists(baselinefile): 312 texfile = self.make_tex_preview(tex, fontsize) 313 report = self._run_checked_subprocess( 314 ["latex", "-interaction=nonstopmode", "--halt-on-error", 315 texfile], tex) 316 317 # find the box extent information in the latex output 318 # file and store them in ".baseline" file 319 m = TexManager._re_vbox.search(report.decode("utf-8")) 320 with open(basefile + '.baseline', "w") as fh: 321 fh.write(" ".join(m.groups())) 322 323 for fname in glob.glob(basefile + '*'): 324 if not fname.endswith(('dvi', 'tex', 'baseline')): 325 try: 326 os.remove(fname) 327 except OSError: 328 pass 329 330 return dvifile 331 332 def make_png(self, tex, fontsize, dpi): 333 """ 334 Generate a png file containing latex's rendering of tex string. 335 336 Return the file name. 337 """ 338 basefile = self.get_basefile(tex, fontsize, dpi) 339 pngfile = '%s.png' % basefile 340 # see get_rgba for a discussion of the background 341 if not os.path.exists(pngfile): 342 dvifile = self.make_dvi(tex, fontsize) 343 cmd = ["dvipng", "-bg", "Transparent", "-D", str(dpi), 344 "-T", "tight", "-o", pngfile, dvifile] 345 # When testing, disable FreeType rendering for reproducibility; but 346 # dvipng 1.16 has a bug (fixed in f3ff241) that breaks --freetype0 347 # mode, so for it we keep FreeType enabled; the image will be 348 # slightly off. 349 if (getattr(mpl, "_called_from_pytest", False) 350 and mpl._get_executable_info("dvipng").version != "1.16"): 351 cmd.insert(1, "--freetype0") 352 self._run_checked_subprocess(cmd, tex) 353 return pngfile 354 355 def get_grey(self, tex, fontsize=None, dpi=None): 356 """Return the alpha channel.""" 357 if not fontsize: 358 fontsize = rcParams['font.size'] 359 if not dpi: 360 dpi = rcParams['savefig.dpi'] 361 key = tex, self.get_font_config(), fontsize, dpi 362 alpha = self.grey_arrayd.get(key) 363 if alpha is None: 364 pngfile = self.make_png(tex, fontsize, dpi) 365 rgba = mpl.image.imread(os.path.join(self.texcache, pngfile)) 366 self.grey_arrayd[key] = alpha = rgba[:, :, -1] 367 return alpha 368 369 def get_rgba(self, tex, fontsize=None, dpi=None, rgb=(0, 0, 0)): 370 """Return latex's rendering of the tex string as an rgba array.""" 371 alpha = self.get_grey(tex, fontsize, dpi) 372 rgba = np.empty((*alpha.shape, 4)) 373 rgba[..., :3] = mpl.colors.to_rgb(rgb) 374 rgba[..., -1] = alpha 375 return rgba 376 377 def get_text_width_height_descent(self, tex, fontsize, renderer=None): 378 """Return width, height and descent of the text.""" 379 if tex.strip() == '': 380 return 0, 0, 0 381 382 dpi_fraction = renderer.points_to_pixels(1.) if renderer else 1 383 384 if dict.__getitem__(rcParams, 'text.latex.preview'): 385 # use preview.sty 386 basefile = self.get_basefile(tex, fontsize) 387 baselinefile = '%s.baseline' % basefile 388 389 if not os.path.exists(baselinefile): 390 dvifile = self.make_dvi_preview(tex, fontsize) 391 392 with open(baselinefile) as fh: 393 l = fh.read().split() 394 height, depth, width = [float(l1) * dpi_fraction for l1 in l] 395 return width, height + depth, depth 396 397 else: 398 # use dviread. 399 dvifile = self.make_dvi(tex, fontsize) 400 with dviread.Dvi(dvifile, 72 * dpi_fraction) as dvi: 401 page, = dvi 402 # A total height (including the descent) needs to be returned. 403 return page.width, page.height + page.descent, page.descent 404