Coverage for /usr/lib/python3/dist-packages/matplotlib/texmanager.py: 35%
147 statements
« prev ^ index » next coverage.py v7.4.4, created at 2025-06-14 15:25 +0200
« prev ^ index » next coverage.py v7.4.4, created at 2025-06-14 15:25 +0200
1r"""
2Support for embedded TeX expressions in Matplotlib.
4Requirements:
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).
13To enable TeX rendering of all text in your Matplotlib figure, set
14:rc:`text.usetex` to True.
16TeX and dvipng/dvips processing results are cached
17in ~/.matplotlib/tex.cache for reuse between sessions.
19`TexManager.get_rgba` can also be used to directly obtain raster output as RGBA
20NumPy arrays.
21"""
23import functools
24import hashlib
25import logging
26import os
27from pathlib import Path
28import subprocess
29from tempfile import TemporaryDirectory
31import numpy as np
33import matplotlib as mpl
34from matplotlib import _api, cbook, dviread
36_log = logging.getLogger(__name__)
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.
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}
56class TexManager:
57 """
58 Convert strings to dvi files using TeX, caching the results to a directory.
60 Repeated calls to this constructor always return the same instance.
61 """
63 texcache = os.path.join(mpl.get_cachedir(), 'tex.cache')
64 _grey_arrayd = {}
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 }
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")
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)
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()
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
137 @classmethod
138 def _get_font_preamble_and_command(cls):
139 requested_family, is_reduced_font = cls._get_font_family_and_reduced()
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]
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
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())
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
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']
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 ])
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.
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
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
275 @classmethod
276 def make_dvi(cls, tex, fontsize):
277 """
278 Generate a dvi file containing latex's layout of tex string.
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
301 @classmethod
302 def make_png(cls, tex, fontsize, dpi):
303 """
304 Generate a png file containing latex's rendering of tex string.
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
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
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.
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
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