|
| 1 | +# Alternative texmanager implementation, modified from Matplotlib's, with added |
| 2 | +# support for {xe,lua}latex. |
| 3 | + |
| 4 | +import functools |
| 5 | +import hashlib |
| 6 | +import logging |
| 7 | +import os |
| 8 | +from pathlib import Path |
| 9 | +import subprocess |
| 10 | +from tempfile import TemporaryDirectory |
| 11 | + |
| 12 | +import numpy as np |
| 13 | + |
| 14 | +import matplotlib as mpl |
| 15 | +from matplotlib import cbook, dviread |
| 16 | + |
| 17 | +_log = logging.getLogger(__name__) |
| 18 | + |
| 19 | + |
| 20 | +def _get_tex_engine(): |
| 21 | + from mplcairo import get_options |
| 22 | + return get_options()["tex_engine"] |
| 23 | + |
| 24 | + |
| 25 | +def _usepackage_if_not_loaded(package, *, option=None): |
| 26 | + """ |
| 27 | + Output LaTeX code that loads a package (possibly with an option) if it |
| 28 | + hasn't been loaded yet. |
| 29 | +
|
| 30 | + LaTeX cannot load twice a package with different options, so this helper |
| 31 | + can be used to protect against users loading arbitrary packages/options in |
| 32 | + their custom preamble. |
| 33 | + """ |
| 34 | + option = f"[{option}]" if option is not None else "" |
| 35 | + return ( |
| 36 | + r"\makeatletter" |
| 37 | + r"\@ifpackageloaded{%(package)s}{}{\usepackage%(option)s{%(package)s}}" |
| 38 | + r"\makeatother" |
| 39 | + ) % {"package": package, "option": option} |
| 40 | + |
| 41 | + |
| 42 | +class TexManager: |
| 43 | + """ |
| 44 | + Convert strings to dvi files using TeX, caching the results to a directory. |
| 45 | +
|
| 46 | + The cache directory is called ``tex.cache`` and is located in the directory |
| 47 | + returned by `.get_cachedir`. |
| 48 | +
|
| 49 | + Repeated calls to this constructor always return the same instance. |
| 50 | + """ |
| 51 | + |
| 52 | + _texcache = os.path.join(mpl.get_cachedir(), 'tex.cache') |
| 53 | + _grey_arrayd = {} |
| 54 | + |
| 55 | + _font_families = ('serif', 'sans-serif', 'cursive', 'monospace') |
| 56 | + _font_preambles = { |
| 57 | + 'new century schoolbook': r'\renewcommand{\rmdefault}{pnc}', |
| 58 | + 'bookman': r'\renewcommand{\rmdefault}{pbk}', |
| 59 | + 'times': r'\usepackage{mathptmx}', |
| 60 | + 'palatino': r'\usepackage{mathpazo}', |
| 61 | + 'zapf chancery': r'\usepackage{chancery}', |
| 62 | + 'cursive': r'\usepackage{chancery}', |
| 63 | + 'charter': r'\usepackage{charter}', |
| 64 | + 'serif': '', |
| 65 | + 'sans-serif': '', |
| 66 | + 'helvetica': r'\usepackage{helvet}', |
| 67 | + 'avant garde': r'\usepackage{avant}', |
| 68 | + 'courier': r'\usepackage{courier}', |
| 69 | + # Loading the type1ec package ensures that cm-super is installed, which |
| 70 | + # is necessary for Unicode computer modern. (It also allows the use of |
| 71 | + # computer modern at arbitrary sizes, but that's just a side effect.) |
| 72 | + 'monospace': r'\usepackage{type1ec}', |
| 73 | + 'computer modern roman': r'\usepackage{type1ec}', |
| 74 | + 'computer modern sans serif': r'\usepackage{type1ec}', |
| 75 | + 'computer modern typewriter': r'\usepackage{type1ec}', |
| 76 | + } |
| 77 | + _font_types = { |
| 78 | + 'new century schoolbook': 'serif', |
| 79 | + 'bookman': 'serif', |
| 80 | + 'times': 'serif', |
| 81 | + 'palatino': 'serif', |
| 82 | + 'zapf chancery': 'cursive', |
| 83 | + 'charter': 'serif', |
| 84 | + 'helvetica': 'sans-serif', |
| 85 | + 'avant garde': 'sans-serif', |
| 86 | + 'courier': 'monospace', |
| 87 | + 'computer modern roman': 'serif', |
| 88 | + 'computer modern sans serif': 'sans-serif', |
| 89 | + 'computer modern typewriter': 'monospace', |
| 90 | + } |
| 91 | + |
| 92 | + @functools.lru_cache # Always return the same instance. |
| 93 | + def __new__(cls): |
| 94 | + Path(cls._texcache).mkdir(parents=True, exist_ok=True) |
| 95 | + return object.__new__(cls) |
| 96 | + |
| 97 | + @classmethod |
| 98 | + def _get_font_family_and_reduced(cls): |
| 99 | + """Return the font family name and whether the font is reduced.""" |
| 100 | + ff = mpl.rcParams['font.family'] |
| 101 | + ff_val = ff[0].lower() if len(ff) == 1 else None |
| 102 | + if len(ff) == 1 and ff_val in cls._font_families: |
| 103 | + return ff_val, False |
| 104 | + elif len(ff) == 1 and ff_val in cls._font_preambles: |
| 105 | + return cls._font_types[ff_val], True |
| 106 | + else: |
| 107 | + _log.info('font.family must be one of (%s) when text.usetex is ' |
| 108 | + 'True. serif will be used by default.', |
| 109 | + ', '.join(cls._font_families)) |
| 110 | + return 'serif', False |
| 111 | + |
| 112 | + @classmethod |
| 113 | + def _get_font_preamble_and_command(cls): |
| 114 | + requested_family, is_reduced_font = cls._get_font_family_and_reduced() |
| 115 | + |
| 116 | + preambles = {} |
| 117 | + for font_family in cls._font_families: |
| 118 | + if is_reduced_font and font_family == requested_family: |
| 119 | + preambles[font_family] = cls._font_preambles[ |
| 120 | + mpl.rcParams['font.family'][0].lower()] |
| 121 | + else: |
| 122 | + rcfonts = mpl.rcParams[f"font.{font_family}"] |
| 123 | + for i, font in enumerate(map(str.lower, rcfonts)): |
| 124 | + if font in cls._font_preambles: |
| 125 | + preambles[font_family] = cls._font_preambles[font] |
| 126 | + _log.debug( |
| 127 | + 'family: %s, package: %s, font: %s, skipped: %s', |
| 128 | + font_family, cls._font_preambles[font], rcfonts[i], |
| 129 | + ', '.join(rcfonts[:i]), |
| 130 | + ) |
| 131 | + break |
| 132 | + else: |
| 133 | + _log.info('No LaTeX-compatible font found for the %s font' |
| 134 | + 'family in rcParams. Using default.', |
| 135 | + font_family) |
| 136 | + preambles[font_family] = cls._font_preambles[font_family] |
| 137 | + |
| 138 | + # The following packages and commands need to be included in the latex |
| 139 | + # file's preamble: |
| 140 | + cmd = {preambles[family] |
| 141 | + for family in ['serif', 'sans-serif', 'monospace']} |
| 142 | + if requested_family == 'cursive': |
| 143 | + cmd.add(preambles['cursive']) |
| 144 | + cmd.add(r'\usepackage{type1cm}') |
| 145 | + preamble = '\n'.join(sorted(cmd)) |
| 146 | + fontcmd = (r'\sffamily' if requested_family == 'sans-serif' else |
| 147 | + r'\ttfamily' if requested_family == 'monospace' else |
| 148 | + r'\rmfamily') |
| 149 | + return preamble, fontcmd |
| 150 | + |
| 151 | + @classmethod |
| 152 | + def get_basefile(cls, tex, fontsize, dpi=None): |
| 153 | + """ |
| 154 | + Return a filename based on a hash of the string, fontsize, and dpi. |
| 155 | + """ |
| 156 | + src = cls._get_tex_source(tex, fontsize) + str(dpi) |
| 157 | + filehash = hashlib.sha256( |
| 158 | + src.encode('utf-8'), |
| 159 | + usedforsecurity=False |
| 160 | + ).hexdigest() |
| 161 | + filepath = Path(cls._texcache) |
| 162 | + |
| 163 | + num_letters, num_levels = 2, 2 |
| 164 | + for i in range(0, num_letters*num_levels, num_letters): |
| 165 | + filepath = filepath / Path(filehash[i:i+2]) |
| 166 | + |
| 167 | + filepath.mkdir(parents=True, exist_ok=True) |
| 168 | + return os.path.join(filepath, filehash) |
| 169 | + |
| 170 | + @classmethod |
| 171 | + def get_font_preamble(cls): |
| 172 | + """ |
| 173 | + Return a string containing font configuration for the tex preamble. |
| 174 | + """ |
| 175 | + font_preamble, command = cls._get_font_preamble_and_command() |
| 176 | + return font_preamble |
| 177 | + |
| 178 | + @classmethod |
| 179 | + def get_custom_preamble(cls): |
| 180 | + """Return a string containing user additions to the tex preamble.""" |
| 181 | + return mpl.rcParams['text.latex.preamble'] |
| 182 | + |
| 183 | + @classmethod |
| 184 | + def _get_tex_source(cls, tex, fontsize): |
| 185 | + """Return the complete TeX source for processing a TeX string.""" |
| 186 | + font_preamble, fontcmd = cls._get_font_preamble_and_command() |
| 187 | + baselineskip = 1.25 * fontsize |
| 188 | + return "\n".join([ |
| 189 | + rf"% !TeX program = {_get_tex_engine()}", |
| 190 | + r"\documentclass{article}", |
| 191 | + r"% Pass-through \mathdefault, which is used in non-usetex mode", |
| 192 | + r"% to use the default text font but was historically suppressed", |
| 193 | + r"% in usetex mode.", |
| 194 | + r"\newcommand{\mathdefault}[1]{#1}", |
| 195 | + r"\usepackage{iftex}", |
| 196 | + r"\ifpdftex", |
| 197 | + r"\usepackage[utf8]{inputenc}", |
| 198 | + r"\DeclareUnicodeCharacter{2212}{\ensuremath{-}}", |
| 199 | + font_preamble, |
| 200 | + r"\fi", |
| 201 | + r"\ifluatex", |
| 202 | + r"\begingroup\catcode`\%=12\relax\gdef\percent{%}\endgroup", |
| 203 | + r"\directlua{", |
| 204 | + r" v = luaotfload.version", |
| 205 | + r" major, minor = string.match(v, '(\percent d+).(\percent d+)')", |
| 206 | + r" major = tonumber(major)", |
| 207 | + r" minor = tonumber(minor) - (string.sub(v, -4) == '-dev' and .5 or 0)", |
| 208 | + r" if major < 3 or major == 3 and minor < 15 then", |
| 209 | + r" tex.error(string.format(", |
| 210 | + r" 'luaotfload>=3.15 is required; you have \percent s', v))", |
| 211 | + r" end", |
| 212 | + r"}", |
| 213 | + r"\fi", |
| 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 | + f'Failed to process string with tex because {command[0]} ' |
| 259 | + 'could not be found') 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 | + ext = {"latex": "dvi", "xelatex": "xdv", "lualatex": "dvi"}[ |
| 284 | + _get_tex_engine()] |
| 285 | + dvifile = f"{basefile}.{ext}" |
| 286 | + if not os.path.exists(dvifile): |
| 287 | + texfile = Path(cls.make_tex(tex, fontsize)) |
| 288 | + # Generate the dvi in a temporary directory to avoid race |
| 289 | + # conditions e.g. if multiple processes try to process the same tex |
| 290 | + # string at the same time. Having tmpdir be a subdirectory of the |
| 291 | + # final output dir ensures that they are on the same filesystem, |
| 292 | + # and thus replace() works atomically. It also allows referring to |
| 293 | + # the texfile with a relative path (for pathological MPLCONFIGDIRs, |
| 294 | + # the absolute path may contain characters (e.g. ~) that TeX does |
| 295 | + # not support; n.b. relative paths cannot traverse parents, or it |
| 296 | + # will be blocked when `openin_any = p` in texmf.cnf). |
| 297 | + cwd = Path(dvifile).parent |
| 298 | + with TemporaryDirectory(dir=cwd) as tmpdir: |
| 299 | + tmppath = Path(tmpdir) |
| 300 | + cmd = { |
| 301 | + "latex": ["latex"], |
| 302 | + "xelatex": ["xelatex", "-no-pdf"], |
| 303 | + "lualatex": ["lualatex", "--output-format=dvi"], |
| 304 | + }[_get_tex_engine()] |
| 305 | + cls._run_checked_subprocess( |
| 306 | + [*cmd, "-interaction=nonstopmode", "--halt-on-error", |
| 307 | + f"--output-directory={tmppath.name}", |
| 308 | + f"{texfile.name}"], tex, cwd=cwd) |
| 309 | + (tmppath / Path(dvifile).name).replace(dvifile) |
| 310 | + return dvifile |
| 311 | + |
| 312 | + @classmethod |
| 313 | + def get_text_width_height_descent(cls, tex, fontsize, renderer=None): |
| 314 | + """Return width, height and descent of the text.""" |
| 315 | + if tex.strip() == '': |
| 316 | + return 0, 0, 0 |
| 317 | + dvifile = cls.make_dvi(tex, fontsize) |
| 318 | + dpi_fraction = renderer.points_to_pixels(1.) if renderer else 1 |
| 319 | + with dviread.Dvi(dvifile, 72 * dpi_fraction) as dvi: |
| 320 | + page, = dvi |
| 321 | + # A total height (including the descent) needs to be returned. |
| 322 | + return page.width, page.height + page.descent, page.descent |
0 commit comments