|  | 
|  | 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