From 044776da45ca2d7aa3d69f694435f65c11a66afd Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Thu, 17 Apr 2025 13:11:12 +0200 Subject: [PATCH] Support {lua,xe}tex as alternative usetex engine. Currently, this PR is mostly a proof of concept; only the svg backend is supported (under rcParams["svg.fonttype"] = "none", the default). However, there is a companion branch on the mplcairo repository, also named "luadvi", which implements support for all output formats. Example (requiring both this PR, and mplcairo installed from its luadvi branch): ``` import matplotlib as mpl; mpl.use("module://mplcairo.qt") from matplotlib import pyplot as plt plt.rcParams["text.latex.engine"] = "lualatex" # or "xelatex" plt.rcParams["text.latex.preamble"] = ( # {lua,xe}tex can use any font installed on the system, spec'd using its # "normal" name. Try e.g. DejaVu Sans instead. r"\usepackage{fontspec}\setmainfont{TeX Gyre Pagella}") plt.figtext(.5, .5, r"\textrm{gff\textwon}", usetex=True) plt.show() ``` Font effects are supported by mplcairo, e.g. `\fontspec{DejaVu Sans}[FakeSlant=0.2] abc`. TODO: - Fix many likely remaining bugs. - Rework font selection in texmanager, which is currently very ad-hoc due to the limited number of fonts supported by latex. - Implement rendering support in the (other) builtin backends. In particular, the Agg (and, if we care, cairo) backend will require significant reworking because dvipng, currently used to rasterize dvi to png, doesn't support luatex-generated dvi; instead we will need to proceed as with the other backends, reading the glyphs one at a time from the dvi file and rasterizing them one at a time to the output buffer. Working on the other backends is not very high on my priority list (as I already have mplcairo as playground...) so it would be nice if others showed some interest for it :-) --- lib/matplotlib/dviread.py | 12 +++++------ lib/matplotlib/mpl-data/matplotlibrc | 6 +++--- lib/matplotlib/rcsetup.py | 2 +- lib/matplotlib/texmanager.py | 32 ++++++++++++++++++++++++---- 4 files changed, 38 insertions(+), 14 deletions(-) diff --git a/lib/matplotlib/dviread.py b/lib/matplotlib/dviread.py index e41618d67579..cb02e22495a8 100644 --- a/lib/matplotlib/dviread.py +++ b/lib/matplotlib/dviread.py @@ -115,12 +115,12 @@ def glyph_name_or_index(self): charmap. """ # The last section is only true on luatex since luaotfload 3.23; this - # must be checked by the code generated by texmanager. (luaotfload's - # docs states "No one should rely on the mapping between DVI character - # codes and font glyphs [prior to v3.15] unless they tightly - # control all involved versions and are deeply familiar with the - # implementation", but a further mapping bug was fixed in luaotfload - # commit 8f2dca4, first included in v3.23). + # is checked by the code generated by texmanager. (luaotfload's docs + # states "No one should rely on the mapping between DVI character codes + # and font glyphs [prior to v3.15] unless they tightly control all + # involved versions and are deeply familiar with the implementation", + # but a further mapping bug was fixed in luaotfload commit 8f2dca4, + # first included in v3.23). entry = self._get_pdftexmap_entry() return (_parse_enc(entry.encoding)[self.glyph] if entry.encoding is not None else self.glyph) diff --git a/lib/matplotlib/mpl-data/matplotlibrc b/lib/matplotlib/mpl-data/matplotlibrc index 3b8d222bb3a0..bc1bdbf834a7 100644 --- a/lib/matplotlib/mpl-data/matplotlibrc +++ b/lib/matplotlib/mpl-data/matplotlibrc @@ -331,9 +331,9 @@ # computer modern sans serif, computer modern typewriter ## The TeX engine/format to use. The following values are supported: -## - "latex": The classic TeX engine (the current default). All backends render -## TeX's output by parsing the DVI output into glyphs and boxes and emitting -## those one by one. +## - "latex", "xelatex", "lualatex": The corresponding TeX engines. All +## backends render TeX's output by parsing the DVI output into glyphs and +## boxes and emitting those one by one. ## - "latex+dvipng": The same as "latex", with the exception that Agg-based ## backends rely on dvipng to rasterize TeX's output. This value was the ## default up to Matplotlib 3.10. diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index f88f07c0e82d..a81fa04d4570 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -1111,7 +1111,7 @@ def _convert_validator_spec(key, conv): # text props "text.color": validate_color, "text.usetex": validate_bool, - "text.latex.engine": ["latex", "latex+dvipng"], + "text.latex.engine": ["latex", "latex+dvipng", "xelatex", "lualatex"], "text.latex.preamble": validate_string, "text.hinting": ["default", "no_autohint", "force_autohint", "no_hinting", "auto", "native", "either", "none"], diff --git a/lib/matplotlib/texmanager.py b/lib/matplotlib/texmanager.py index 2a7201c87d77..bf0a6f57ae31 100644 --- a/lib/matplotlib/texmanager.py +++ b/lib/matplotlib/texmanager.py @@ -210,15 +210,32 @@ def _get_tex_source(cls, tex, fontsize): font_preamble, fontcmd = cls._get_font_preamble_and_command() baselineskip = 1.25 * fontsize return "\n".join([ + rf"% !TeX program = {mpl.rcParams['text.latex.engine']}", r"\RequirePackage{fix-cm}", r"\documentclass{article}", r"% Pass-through \mathdefault, which is used in non-usetex mode", r"% to use the default text font but was historically suppressed", r"% in usetex mode.", r"\newcommand{\mathdefault}[1]{#1}", - font_preamble, + r"\usepackage{iftex}", + r"\ifpdftex", r"\usepackage[utf8]{inputenc}", r"\DeclareUnicodeCharacter{2212}{\ensuremath{-}}", + font_preamble, + r"\fi", + r"\ifluatex", + r"\begingroup\catcode`\%=12\relax\gdef\percent{%}\endgroup", + r"\directlua{", + r" v = luaotfload.version", + r" major, minor = string.match(v, '(\percent d+).(\percent d+)')", + r" major = tonumber(major)", + r" minor = tonumber(minor) - (string.sub(v, -4) == '-dev' and .5 or 0)", + r" if major < 3 or major == 3 and minor < 23 then", + r" tex.error(string.format(", + r" 'luaotfload>=3.23 is required; you have \percent s', v))", + r" end", + r"}", + r"\fi", r"% geometry is loaded before the custom preamble as ", r"% convert_psfrags relies on a custom preamble to change the ", r"% geometry.", @@ -284,7 +301,9 @@ def make_dvi(cls, tex, fontsize): Return the file name. """ - dvipath = cls._get_base_path(tex, fontsize).with_suffix(".dvi") + ext = {"latex": "dvi", "xelatex": "xdv", "lualatex": "dvi"}[ + mpl.rcParams["text.latex.engine"]] + dvipath = cls._get_base_path(tex, fontsize).with_suffix(f".{ext}") if not dvipath.exists(): # Generate the tex and dvi in a temporary directory to avoid race # conditions e.g. if multiple processes try to process the same tex @@ -298,10 +317,15 @@ def make_dvi(cls, tex, fontsize): with TemporaryDirectory(dir=dvipath.parent) as tmpdir: Path(tmpdir, "file.tex").write_text( cls._get_tex_source(tex, fontsize), encoding='utf-8') + cmd = { + "latex": ["latex"], + "xelatex": ["xelatex", "-no-pdf"], + "lualatex": ["lualatex", "--output-format=dvi"], + }[mpl.rcParams["text.latex.engine"]] cls._run_checked_subprocess( - ["latex", "-interaction=nonstopmode", "-halt-on-error", + [*cmd, "-interaction=nonstopmode", "--halt-on-error", "-no-shell-escape", "file.tex"], tex, cwd=tmpdir) - Path(tmpdir, "file.dvi").replace(dvipath) + Path(tmpdir, f"file.{ext}").replace(dvipath) # Also move the tex source to the main cache directory, but # only for backcompat. Path(tmpdir, "file.tex").replace(dvipath.with_suffix(".tex"))