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 The cache directory is called ``tex.cache`` and is located in the directory
61 returned by `.get_cachedir`.
62
63 Repeated calls to this constructor always return the same instance.
64 """
65
66 texcache = _api.deprecate_privatize_attribute("3.8")
67 _texcache = os.path.join(mpl.get_cachedir(), 'tex.cache')
68 _grey_arrayd = {}
69
70 _font_families = ('serif', 'sans-serif', 'cursive', 'monospace')
71 _font_preambles = {
72 'new century schoolbook': r'\renewcommand{\rmdefault}{pnc}',
73 'bookman': r'\renewcommand{\rmdefault}{pbk}',
74 'times': r'\usepackage{mathptmx}',
75 'palatino': r'\usepackage{mathpazo}',
76 'zapf chancery': r'\usepackage{chancery}',
77 'cursive': r'\usepackage{chancery}',
78 'charter': r'\usepackage{charter}',
79 'serif': '',
80 'sans-serif': '',
81 'helvetica': r'\usepackage{helvet}',
82 'avant garde': r'\usepackage{avant}',
83 'courier': r'\usepackage{courier}',
84 # Loading the type1ec package ensures that cm-super is installed, which
85 # is necessary for Unicode computer modern. (It also allows the use of
86 # computer modern at arbitrary sizes, but that's just a side effect.)
87 'monospace': r'\usepackage{type1ec}',
88 'computer modern roman': r'\usepackage{type1ec}',
89 'computer modern sans serif': r'\usepackage{type1ec}',
90 'computer modern typewriter': r'\usepackage{type1ec}',
91 }
92 _font_types = {
93 'new century schoolbook': 'serif',
94 'bookman': 'serif',
95 'times': 'serif',
96 'palatino': 'serif',
97 'zapf chancery': 'cursive',
98 'charter': 'serif',
99 'helvetica': 'sans-serif',
100 'avant garde': 'sans-serif',
101 'courier': 'monospace',
102 'computer modern roman': 'serif',
103 'computer modern sans serif': 'sans-serif',
104 'computer modern typewriter': 'monospace',
105 }
106
107 @functools.lru_cache # Always return the same instance.
108 def __new__(cls):
109 Path(cls._texcache).mkdir(parents=True, exist_ok=True)
110 return object.__new__(cls)
111
112 @classmethod
113 def _get_font_family_and_reduced(cls):
114 """Return the font family name and whether the font is reduced."""
115 ff = mpl.rcParams['font.family']
116 ff_val = ff[0].lower() if len(ff) == 1 else None
117 if len(ff) == 1 and ff_val in cls._font_families:
118 return ff_val, False
119 elif len(ff) == 1 and ff_val in cls._font_preambles:
120 return cls._font_types[ff_val], True
121 else:
122 _log.info('font.family must be one of (%s) when text.usetex is '
123 'True. serif will be used by default.',
124 ', '.join(cls._font_families))
125 return 'serif', False
126
127 @classmethod
128 def _get_font_preamble_and_command(cls):
129 requested_family, is_reduced_font = cls._get_font_family_and_reduced()
130
131 preambles = {}
132 for font_family in cls._font_families:
133 if is_reduced_font and font_family == requested_family:
134 preambles[font_family] = cls._font_preambles[
135 mpl.rcParams['font.family'][0].lower()]
136 else:
137 for font in mpl.rcParams['font.' + font_family]:
138 if font.lower() in cls._font_preambles:
139 preambles[font_family] = \
140 cls._font_preambles[font.lower()]
141 _log.debug(
142 'family: %s, font: %s, info: %s',
143 font_family, font,
144 cls._font_preambles[font.lower()])
145 break
146 else:
147 _log.debug('%s font is not compatible with usetex.',
148 font)
149 else:
150 _log.info('No LaTeX-compatible font found for the %s font'
151 'family in rcParams. Using default.',
152 font_family)
153 preambles[font_family] = cls._font_preambles[font_family]
154
155 # The following packages and commands need to be included in the latex
156 # file's preamble:
157 cmd = {preambles[family]
158 for family in ['serif', 'sans-serif', 'monospace']}
159 if requested_family == 'cursive':
160 cmd.add(preambles['cursive'])
161 cmd.add(r'\usepackage{type1cm}')
162 preamble = '\n'.join(sorted(cmd))
163 fontcmd = (r'\sffamily' if requested_family == 'sans-serif' else
164 r'\ttfamily' if requested_family == 'monospace' else
165 r'\rmfamily')
166 return preamble, fontcmd
167
168 @classmethod
169 def get_basefile(cls, tex, fontsize, dpi=None):
170 """
171 Return a filename based on a hash of the string, fontsize, and dpi.
172 """
173 src = cls._get_tex_source(tex, fontsize) + str(dpi)
174 filehash = hashlib.md5(src.encode('utf-8')).hexdigest()
175 filepath = Path(cls._texcache)
176
177 num_letters, num_levels = 2, 2
178 for i in range(0, num_letters*num_levels, num_letters):
179 filepath = filepath / Path(filehash[i:i+2])
180
181 filepath.mkdir(parents=True, exist_ok=True)
182 return os.path.join(filepath, filehash)
183
184 @classmethod
185 def get_font_preamble(cls):
186 """
187 Return a string containing font configuration for the tex preamble.
188 """
189 font_preamble, command = cls._get_font_preamble_and_command()
190 return font_preamble
191
192 @classmethod
193 def get_custom_preamble(cls):
194 """Return a string containing user additions to the tex preamble."""
195 return mpl.rcParams['text.latex.preamble']
196
197 @classmethod
198 def _get_tex_source(cls, tex, fontsize):
199 """Return the complete TeX source for processing a TeX string."""
200 font_preamble, fontcmd = cls._get_font_preamble_and_command()
201 baselineskip = 1.25 * fontsize
202 return "\n".join([
203 r"\documentclass{article}",
204 r"% Pass-through \mathdefault, which is used in non-usetex mode",
205 r"% to use the default text font but was historically suppressed",
206 r"% in usetex mode.",
207 r"\newcommand{\mathdefault}[1]{#1}",
208 font_preamble,
209 r"\usepackage[utf8]{inputenc}",
210 r"\DeclareUnicodeCharacter{2212}{\ensuremath{-}}",
211 r"% geometry is loaded before the custom preamble as ",
212 r"% convert_psfrags relies on a custom preamble to change the ",
213 r"% geometry.",
214 r"\usepackage[papersize=72in, margin=1in]{geometry}",
215 cls.get_custom_preamble(),
216 r"% Use `underscore` package to take care of underscores in text.",
217 r"% The [strings] option allows to use underscores in file names.",
218 _usepackage_if_not_loaded("underscore", option="strings"),
219 r"% Custom packages (e.g. newtxtext) may already have loaded ",
220 r"% textcomp with different options.",
221 _usepackage_if_not_loaded("textcomp"),
222 r"\pagestyle{empty}",
223 r"\begin{document}",
224 r"% The empty hbox ensures that a page is printed even for empty",
225 r"% inputs, except when using psfrag which gets confused by it.",
226 r"% matplotlibbaselinemarker is used by dviread to detect the",
227 r"% last line's baseline.",
228 rf"\fontsize{{{fontsize}}}{{{baselineskip}}}%",
229 r"\ifdefined\psfrag\else\hbox{}\fi%",
230 rf"{{{fontcmd} {tex}}}%",
231 r"\end{document}",
232 ])
233
234 @classmethod
235 def make_tex(cls, tex, fontsize):
236 """
237 Generate a tex file to render the tex string at a specific font size.
238
239 Return the file name.
240 """
241 texfile = cls.get_basefile(tex, fontsize) + ".tex"
242 Path(texfile).write_text(cls._get_tex_source(tex, fontsize),
243 encoding='utf-8')
244 return texfile
245
246 @classmethod
247 def _run_checked_subprocess(cls, command, tex, *, cwd=None):
248 _log.debug(cbook._pformat_subprocess(command))
249 try:
250 report = subprocess.check_output(
251 command, cwd=cwd if cwd is not None else cls._texcache,
252 stderr=subprocess.STDOUT)
253 except FileNotFoundError as exc:
254 raise RuntimeError(
255 f'Failed to process string with tex because {command[0]} '
256 'could not be found') from exc
257 except subprocess.CalledProcessError as exc:
258 raise RuntimeError(
259 '{prog} was not able to process the following string:\n'
260 '{tex!r}\n\n'
261 'Here is the full command invocation and its output:\n\n'
262 '{format_command}\n\n'
263 '{exc}\n\n'.format(
264 prog=command[0],
265 format_command=cbook._pformat_subprocess(command),
266 tex=tex.encode('unicode_escape'),
267 exc=exc.output.decode('utf-8', 'backslashreplace'))
268 ) from None
269 _log.debug(report)
270 return report
271
272 @classmethod
273 def make_dvi(cls, tex, fontsize):
274 """
275 Generate a dvi file containing latex's layout of tex string.
276
277 Return the file name.
278 """
279 basefile = cls.get_basefile(tex, fontsize)
280 dvifile = '%s.dvi' % basefile
281 if not os.path.exists(dvifile):
282 texfile = Path(cls.make_tex(tex, fontsize))
283 # Generate the dvi in a temporary directory to avoid race
284 # conditions e.g. if multiple processes try to process the same tex
285 # string at the same time. Having tmpdir be a subdirectory of the
286 # final output dir ensures that they are on the same filesystem,
287 # and thus replace() works atomically. It also allows referring to
288 # the texfile with a relative path (for pathological MPLCONFIGDIRs,
289 # the absolute path may contain characters (e.g. ~) that TeX does
290 # not support; n.b. relative paths cannot traverse parents, or it
291 # will be blocked when `openin_any = p` in texmf.cnf).
292 cwd = Path(dvifile).parent
293 with TemporaryDirectory(dir=cwd) as tmpdir:
294 tmppath = Path(tmpdir)
295 cls._run_checked_subprocess(
296 ["latex", "-interaction=nonstopmode", "--halt-on-error",
297 f"--output-directory={tmppath.name}",
298 f"{texfile.name}"], tex, cwd=cwd)
299 (tmppath / Path(dvifile).name).replace(dvifile)
300 return dvifile
301
302 @classmethod
303 def make_png(cls, tex, fontsize, dpi):
304 """
305 Generate a png file containing latex's rendering of tex string.
306
307 Return the file name.
308 """
309 basefile = cls.get_basefile(tex, fontsize, dpi)
310 pngfile = '%s.png' % basefile
311 # see get_rgba for a discussion of the background
312 if not os.path.exists(pngfile):
313 dvifile = cls.make_dvi(tex, fontsize)
314 cmd = ["dvipng", "-bg", "Transparent", "-D", str(dpi),
315 "-T", "tight", "-o", pngfile, dvifile]
316 # When testing, disable FreeType rendering for reproducibility; but
317 # dvipng 1.16 has a bug (fixed in f3ff241) that breaks --freetype0
318 # mode, so for it we keep FreeType enabled; the image will be
319 # slightly off.
320 if (getattr(mpl, "_called_from_pytest", False) and
321 mpl._get_executable_info("dvipng").raw_version != "1.16"):
322 cmd.insert(1, "--freetype0")
323 cls._run_checked_subprocess(cmd, tex)
324 return pngfile
325
326 @classmethod
327 def get_grey(cls, tex, fontsize=None, dpi=None):
328 """Return the alpha channel."""
329 if not fontsize:
330 fontsize = mpl.rcParams['font.size']
331 if not dpi:
332 dpi = mpl.rcParams['savefig.dpi']
333 key = cls._get_tex_source(tex, fontsize), dpi
334 alpha = cls._grey_arrayd.get(key)
335 if alpha is None:
336 pngfile = cls.make_png(tex, fontsize, dpi)
337 rgba = mpl.image.imread(os.path.join(cls._texcache, pngfile))
338 cls._grey_arrayd[key] = alpha = rgba[:, :, -1]
339 return alpha
340
341 @classmethod
342 def get_rgba(cls, tex, fontsize=None, dpi=None, rgb=(0, 0, 0)):
343 r"""
344 Return latex's rendering of the tex string as an RGBA array.
345
346 Examples
347 --------
348 >>> texmanager = TexManager()
349 >>> s = r"\TeX\ is $\displaystyle\sum_n\frac{-e^{i\pi}}{2^n}$!"
350 >>> Z = texmanager.get_rgba(s, fontsize=12, dpi=80, rgb=(1, 0, 0))
351 """
352 alpha = cls.get_grey(tex, fontsize, dpi)
353 rgba = np.empty((*alpha.shape, 4))
354 rgba[..., :3] = mpl.colors.to_rgb(rgb)
355 rgba[..., -1] = alpha
356 return rgba
357
358 @classmethod
359 def get_text_width_height_descent(cls, tex, fontsize, renderer=None):
360 """Return width, height and descent of the text."""
361 if tex.strip() == '':
362 return 0, 0, 0
363 dvifile = cls.make_dvi(tex, fontsize)
364 dpi_fraction = renderer.points_to_pixels(1.) if renderer else 1
365 with dviread.Dvi(dvifile, 72 * dpi_fraction) as dvi:
366 page, = dvi
367 # A total height (including the descent) needs to be returned.
368 return page.width, page.height + page.descent, page.descent