Skip to content

Commit dc98eed

Browse files
committed
Support {xe,lua}tex as alternative usetex engines.
1 parent 6ab4c57 commit dc98eed

File tree

6 files changed

+357
-2
lines changed

6 files changed

+357
-2
lines changed

README.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ Noteworthy points include:
3737
(see `examples/opentype_variations.py`_) (requires cairo≥1.16.0), and partial
3838
support for color fonts (e.g., emojis), using Raqm_. **Note** that Raqm
3939
depends by default on Fribidi, which is licensed under the LGPLv2.1+.
40+
- Support for compiling usetex strings with xelatex or lualatex.
4041
- Support for embedding URLs in PDF (but not SVG) output (requires
4142
cairo≥1.15.4).
4243
- Support for multi-page output both for PDF and PS (Matplotlib only supports
@@ -474,6 +475,18 @@ implemented in Matplotlib itself.
474475

475476
Color fonts (e.g. emojis) are handled.
476477

478+
Alternate TeX engines
479+
---------------------
480+
The XeTeX and LuaTeX engines are supported; they can be selected
481+
with ``mplcairo.set_options(tex_engine="xelatex")`` and
482+
``mplcairo.set_options(tex_engine="lualatex)``, respectively (the default
483+
engine remains ``"latex"``).
484+
485+
To select arbitrary system-wide-installed fonts, add e.g.
486+
``\usepackage{fontspec}\setmainfont{...}`` to
487+
``rcParams["text.latex.preamble"]``. Refer to the ``fontspec`` docs for
488+
details on its usage.
489+
477490
Multi-page output
478491
-----------------
479492

ext/_mplcairo.cpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2029,6 +2029,10 @@ miter_limit : float, default: 10
20292029
raqm : bool, default: if available
20302030
Whether to use Raqm for text rendering.
20312031
2032+
tex_engine : "latex" or "xelatex" or "lualatex"
2033+
The TeX engine used in usetex mode. This option is experimental and may be
2034+
removed once support is merged into Matplotlib itself.
2035+
20322036
_debug: bool, default: False
20332037
Whether to print debugging information. This option is only intended for
20342038
debugging and is not part of the stable API.

ext/_util.cpp

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ py::object RC_PARAMS{},
6868
int COLLECTION_THREADS{};
6969
cairo_format_t IMAGE_FORMAT{CAIRO_FORMAT_ARGB32};
7070
double MITER_LIMIT{10.};
71+
std::string TEX_ENGINE{"latex"};
7172
bool DEBUG{};
7273
MplcairoScriptSurface MPLCAIRO_SCRIPT_SURFACE{[] {
7374
if (auto script_surface = std::getenv("MPLCAIRO_SCRIPT_SURFACE")) {
@@ -117,6 +118,7 @@ py::dict get_options()
117118
"image_format"_a=detail::IMAGE_FORMAT,
118119
"miter_limit"_a=detail::MITER_LIMIT,
119120
"raqm"_a=has_raqm(),
121+
"tex_engine"_a=detail::TEX_ENGINE,
120122
"_debug"_a=detail::DEBUG);
121123
}
122124

@@ -171,6 +173,14 @@ py::object set_options(py::kwargs kwargs)
171173
unload_raqm();
172174
}
173175
}
176+
if (auto const& t_e = pop_option("tex_engine", std::string{})) {
177+
if (*t_e == "latex" || *t_e == "xelatex" || *t_e == "lualatex") {
178+
detail::TEX_ENGINE = *t_e;
179+
} else {
180+
throw std::runtime_error{
181+
"not a valid tex_engine: {}"_format(*t_e).cast<std::string>()};
182+
}
183+
}
174184
if (auto const& debug = pop_option("_debug", bool{})) {
175185
detail::DEBUG = *debug;
176186
}

ext/_util.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ extern py::object UNIT_CIRCLE;
123123
extern int COLLECTION_THREADS;
124124
extern cairo_format_t IMAGE_FORMAT;
125125
extern double MITER_LIMIT;
126+
extern std::string TEX_ENGINE;
126127
extern bool DEBUG;
127128
enum class MplcairoScriptSurface {
128129
None, Raster, Vector

src/mplcairo/_texmanager.py

Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
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

Comments
 (0)