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