From 6a177bdd650acfa8bfd16ecd9873b0515981048b Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Thu, 17 Apr 2025 13:09:43 +0200 Subject: [PATCH 1/2] Parse {lua,xe}tex-generated dvi in dviread. --- lib/matplotlib/backends/backend_pdf.py | 16 +- lib/matplotlib/cbook.py | 10 +- lib/matplotlib/dviread.py | 303 +++++++++++++----- lib/matplotlib/dviread.pyi | 21 +- .../baseline_images/dviread/lualatex.json | 1 + .../baseline_images/dviread/pdflatex.json | 1 + .../tests/baseline_images/dviread/test.dvi | Bin 856 -> 0 bytes .../tests/baseline_images/dviread/test.json | 94 ------ .../tests/baseline_images/dviread/test.tex | 16 +- .../baseline_images/dviread/xelatex.json | 1 + lib/matplotlib/tests/test_dviread.py | 56 +++- lib/matplotlib/textpath.py | 4 +- 12 files changed, 306 insertions(+), 217 deletions(-) create mode 100644 lib/matplotlib/tests/baseline_images/dviread/lualatex.json create mode 100644 lib/matplotlib/tests/baseline_images/dviread/pdflatex.json delete mode 100644 lib/matplotlib/tests/baseline_images/dviread/test.dvi delete mode 100644 lib/matplotlib/tests/baseline_images/dviread/test.json create mode 100644 lib/matplotlib/tests/baseline_images/dviread/xelatex.json diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index 0ab5a65f0b75..2aa7e42933f3 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -940,18 +940,12 @@ def dviFontName(self, dvifont): tex_font_map = dviread.PsfontsMap(dviread.find_tex_file('pdftex.map')) psfont = tex_font_map[dvifont.texname] - if psfont.filename is None: - raise ValueError( - "No usable font file found for {} (TeX: {}); " - "the font may lack a Type-1 version" - .format(psfont.psname, dvifont.texname)) - pdfname = next(self._internal_font_seq) _log.debug('Assigning font %s = %s (dvi)', pdfname, dvifont.texname) self._dviFontInfo[dvifont.texname] = types.SimpleNamespace( dvifont=dvifont, pdfname=pdfname, - fontfile=psfont.filename, + fontfile=str(dvifont.path), # raises ValueError if psfont.filename is None. basefont=psfont.psname, encodingfile=psfont.encoding, effects=psfont.effects) @@ -996,11 +990,11 @@ def _embedTeXFont(self, fontinfo): # Widths widthsObject = self.reserveObject('font widths') - tfm = fontinfo.dvifont._tfm + font_metrics = fontinfo.dvifont._metrics # convert from TeX's 12.20 representation to 1/1000 text space units. - widths = [(1000 * metrics.tex_width) >> 20 - if (metrics := tfm.get_metrics(char)) else 0 - for char in range(max(tfm._glyph_metrics, default=-1) + 1)] + widths = [(1000 * glyph_metrics.tex_width) >> 20 + if (glyph_metrics := font_metrics.get_metrics(char)) else 0 + for char in range(max(font_metrics._glyph_metrics, default=-1) + 1)] self.writeObject(widthsObject, widths) # Font dictionary diff --git a/lib/matplotlib/cbook.py b/lib/matplotlib/cbook.py index 10048f1be782..59723af1c50f 100644 --- a/lib/matplotlib/cbook.py +++ b/lib/matplotlib/cbook.py @@ -43,16 +43,20 @@ class _ExceptionInfo: users and result in incorrect tracebacks. """ - def __init__(self, cls, *args): + def __init__(self, cls, *args, notes=None): self._cls = cls self._args = args + self._notes = notes if notes is not None else [] @classmethod def from_exception(cls, exc): - return cls(type(exc), *exc.args) + return cls(type(exc), *exc.args, notes=getattr(exc, "__notes__", [])) def to_exception(self): - return self._cls(*self._args) + exc = self._cls(*self._args) + for note in self._notes: + exc.add_note(note) + return exc def _get_running_interactive_framework(): diff --git a/lib/matplotlib/dviread.py b/lib/matplotlib/dviread.py index a588979f5fad..dd702c4d93a5 100644 --- a/lib/matplotlib/dviread.py +++ b/lib/matplotlib/dviread.py @@ -20,7 +20,7 @@ from collections import namedtuple import dataclasses import enum -from functools import cache, lru_cache, partial, wraps +from functools import cache, cached_property, lru_cache, partial, wraps import logging import os from pathlib import Path @@ -29,9 +29,11 @@ import subprocess import sys +import fontTools.agl import numpy as np from matplotlib import _api, cbook, font_manager +from matplotlib.ft2font import LoadFlags _log = logging.getLogger(__name__) @@ -67,43 +69,11 @@ class Text(namedtuple('Text', 'x y font glyph width')): """ A glyph in the dvi file. - The *x* and *y* attributes directly position the glyph. The *font*, - *glyph*, and *width* attributes are kept public for back-compatibility, - but users wanting to draw the glyph themselves are encouraged to instead - load the font specified by `font_path` at `font_size`, warp it with the - effects specified by `font_effects`, and load the glyph at the FreeType - glyph `index`. + In order to render the glyph, load the glyph at index ``text.font.index`` + from the font at ``text.font.path`` with size ``text.font.size``, warped + with ``text.font.effects``, then draw it at position ``(text.x, text.y)``. """ - def _get_pdftexmap_entry(self): - return PsfontsMap(find_tex_file("pdftex.map"))[self.font.texname] - - @property - def font_path(self): - """The `~pathlib.Path` to the font for this glyph.""" - psfont = self._get_pdftexmap_entry() - if psfont.filename is None: - raise ValueError("No usable font file found for {} ({}); " - "the font may lack a Type-1 version" - .format(psfont.psname.decode("ascii"), - psfont.texname.decode("ascii"))) - return Path(psfont.filename) - - @property - def font_size(self): - """The font size.""" - return self.font.size - - @property - def font_effects(self): - """ - The "font effects" dict for this glyph. - - This dict contains the values for this glyph of SlantFont and - ExtendFont (if any), read off :file:`pdftex.map`. - """ - return self._get_pdftexmap_entry().effects - @property def index(self): """ @@ -112,25 +82,51 @@ def index(self): # See DviFont._index_dvi_to_freetype for details on the index mapping. return self.font._index_dvi_to_freetype(self.glyph) - @property # To be deprecated together with font_size, font_effects. + font_path = property(lambda self: self.font.path) + font_size = property(lambda self: self.font.size) + font_effects = property(lambda self: self.font.effects) + + @property # To be deprecated together with font_path, font_size, font_effects. def glyph_name_or_index(self): """ - Either the glyph name or the native charmap glyph index. - - If :file:`pdftex.map` specifies an encoding for this glyph's font, that - is a mapping of glyph indices to Adobe glyph names; use it to convert - dvi indices to glyph names. Callers can then convert glyph names to - glyph indices (with FT_Get_Name_Index/get_name_index), and load the - glyph using FT_Load_Glyph/load_glyph. - - If :file:`pdftex.map` specifies no encoding, the indices directly map - to the font's "native" charmap; glyphs should directly load using - FT_Load_Char/load_char after selecting the native charmap. + The glyph name, the native charmap glyph index, or the raw glyph index. + + If the font is a TrueType file (which can currently only happen for + DVI files generated by xetex or luatex), then this number is the raw + index of the glyph, which can be passed to FT_Load_Glyph/load_glyph. + + Otherwise, the font is a PostScript font. For such fonts, if + :file:`pdftex.map` specifies an encoding for this glyph's font, + that is a mapping of glyph indices to Adobe glyph names; which + is used by this property to convert dvi numbers to glyph names. + Callers can then convert glyph names to glyph indices (with + FT_Get_Name_Index/get_name_index), and load the glyph using + FT_Load_Glyph/load_glyph. + + If :file:`pdftex.map` specifies no encoding for a PostScript font, + this number is an index to the font's "native" charmap; glyphs should + directly load using FT_Load_Char/load_char after selecting the native + 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). entry = self._get_pdftexmap_entry() return (_parse_enc(entry.encoding)[self.glyph] if entry.encoding is not None else self.glyph) + def _as_unicode_or_name(self): + if self.font.subfont: + raise NotImplementedError("Indexing TTC fonts is not supported yet") + face = font_manager.get_font(self.font.path) + glyph_name = face.get_glyph_name(self.index) + glyph_str = fontTools.agl.toUnicode(glyph_name) + return glyph_str or glyph_name + # Opcode argument parsing # @@ -408,7 +404,7 @@ def _put_char_real(self, char): scale = font._scale for x, y, f, g, w in font._vf[char].text: newf = DviFont(scale=_mul1220(scale, f._scale), - tfm=f._tfm, texname=f.texname, vf=f._vf) + metrics=f._metrics, texname=f.texname, vf=f._vf) self.text.append(Text(self.h + _mul1220(x, scale), self.v + _mul1220(y, scale), newf, g, newf._width_of(g))) @@ -504,10 +500,21 @@ def _fnt_def(self, k, c, s, d, a, l): def _fnt_def_real(self, k, c, s, d, a, l): n = self.file.read(a + l) - fontname = n[-l:].decode('ascii') + fontname = n[-l:] + if fontname.startswith(b"[") and c == 0x4c756146: # c == "LuaF" + # See https://chat.stackexchange.com/rooms/106428 (and also + # https://tug.org/pipermail/dvipdfmx/2021-January/000168.html). + # AFAICT luatex's dvi drops info re: OpenType variation-axis values. + self.fonts[k] = DviFont.from_luatex(s, n) + return + fontname = fontname.decode("ascii") try: tfm = _tfmfile(fontname) except FileNotFoundError as exc: + if fontname.startswith("[") and fontname.endswith(";") and c == 0: + exc.add_note( + "This dvi file was likely generated with a too-old " + "version of luaotfload; luaotfload 3.23 is required.") # Explicitly allow defining missing fonts for Vf support; we only # register an error when trying to load a glyph from a missing font # and throw that error in Dvi._read. For Vf, _finalize_packet @@ -521,12 +528,12 @@ def _fnt_def_real(self, k, c, s, d, a, l): vf = _vffile(fontname) except FileNotFoundError: vf = None - self.fonts[k] = DviFont(scale=s, tfm=tfm, texname=n, vf=vf) + self.fonts[k] = DviFont(scale=s, metrics=tfm, texname=n, vf=vf) @_dispatch(247, state=_dvistate.pre, args=('u1', 'u4', 'u4', 'u4', 'u1')) def _pre(self, i, num, den, mag, k): self.file.read(k) # comment in the dvi file - if i != 2: + if i not in [2, 7]: # 2: pdftex, luatex; 7: xetex raise ValueError(f"Unknown dvi format {i}") if num != 25400000 or den != 7227 * 2**16: raise ValueError("Nonstandard units in dvi file") @@ -547,13 +554,66 @@ def _post(self, _): # TODO: actually read the postamble and finale? # currently post_post just triggers closing the file - @_dispatch(249) - def _post_post(self, _): + @_dispatch(249, args=()) + def _post_post(self): raise NotImplementedError - @_dispatch(min=250, max=255) - def _malformed(self, offset): - raise ValueError(f"unknown command: byte {250 + offset}") + @_dispatch(250, args=()) + def _begin_reflect(self): + raise NotImplementedError + + @_dispatch(251, args=()) + def _end_reflect(self): + raise NotImplementedError + + @_dispatch(252, args=()) + def _define_native_font(self): + k = self._read_arg(4, signed=False) + s = self._read_arg(4, signed=False) + flags = self._read_arg(2, signed=False) + l = self._read_arg(1, signed=False) + n = self.file.read(l) + i = self._read_arg(4, signed=False) + effects = {} + if flags & 0x0200: + effects["rgba"] = [self._read_arg(1, signed=False) for _ in range(4)] + if flags & 0x1000: + effects["extend"] = self._read_arg(4, signed=True) / 65536 + if flags & 0x2000: + effects["slant"] = self._read_arg(4, signed=True) / 65536 + if flags & 0x4000: + effects["embolden"] = self._read_arg(4, signed=True) / 65536 + self.fonts[k] = DviFont.from_xetex(s, n, i, effects) + + @_dispatch(253, args=()) + def _set_glyphs(self): + w = self._read_arg(4, signed=False) + k = self._read_arg(2, signed=False) + xy = [self._read_arg(4, signed=True) for _ in range(2 * k)] + g = [self._read_arg(2, signed=False) for _ in range(k)] + font = self.fonts[self.f] + for i in range(k): + self.text.append(Text(self.h + xy[2 * i], self.v + xy[2 * i + 1], + font, g[i], font._width_of(g[i]))) + self.h += w + + @_dispatch(254, args=()) + def _set_text_and_glyphs(self): + l = self._read_arg(2, signed=False) + t = self.file.read(2 * l) # utf16 + w = self._read_arg(4, signed=False) + k = self._read_arg(2, signed=False) + xy = [self._read_arg(4, signed=True) for _ in range(2 * k)] + g = [self._read_arg(2, signed=False) for _ in range(k)] + font = self.fonts[self.f] + for i in range(k): + self.text.append(Text(self.h + xy[2 * i], self.v + xy[2 * i + 1], + font, g[i], font._width_of(g[i]))) + self.h += w + + @_dispatch(255) + def _malformed(self, raw): + raise ValueError("unknown command: byte 255") class DviFont: @@ -571,10 +631,10 @@ class DviFont: ---------- scale : float Factor by which the font is scaled from its natural size. - tfm : Tfm + metrics : Tfm | TtfMetrics TeX font metrics for this font texname : bytes - Name of the font as used internally by TeX and friends, as an ASCII + Name of the font as used internally in the DVI file, as an ASCII bytestring. This is usually very different from any external font names; `PsfontsMap` can be used to find the external name of the font. vf : Vf @@ -587,17 +647,53 @@ class DviFont: Size of the font in Adobe points, converted from the slightly smaller TeX points. """ - __slots__ = ('texname', 'size', '_scale', '_vf', '_tfm', '_encoding') - def __init__(self, scale, tfm, texname, vf): + def __init__(self, scale, metrics, texname, vf): _api.check_isinstance(bytes, texname=texname) self._scale = scale - self._tfm = tfm + self._metrics = metrics self.texname = texname self._vf = vf - self.size = scale * (72.0 / (72.27 * 2**16)) self._encoding = None + @classmethod + def from_luatex(cls, scale, texname): + path_b, sep, rest = texname[1:].rpartition(b"]") + if not (texname.startswith(b"[") and sep and rest[:1] in [b"", b":"]): + raise ValueError(f"Invalid modern font name: {texname}") + # utf8 on Windows, not utf16! + path = path_b.decode("utf8") if os.name == "nt" else os.fsdecode(path_b) + subfont = 0 + effects = {} + if rest[1:]: + for kv in rest[1:].decode("ascii").split(";"): + key, val = kv.split("=", 1) + if key == "index": + subfont = val + elif key in ["embolden", "slant", "extend"]: + effects[key] = int(val) / 65536 + else: + _log.warning("Ignoring invalid key-value pair: %r", kv) + metrics = TtfMetrics(path) + font = cls(scale, metrics, texname, vf=None) + font.path = Path(path) + font.subfont = subfont + font.effects = effects + return font + + @classmethod + def from_xetex(cls, scale, texname, subfont, effects): + # utf8 on Windows, not utf16! + path = texname.decode("utf8") if os.name == "nt" else os.fsdecode(texname) + metrics = TtfMetrics(path) + font = cls(scale, metrics, b"[" + texname + b"]", vf=None) + font.path = Path(path) + font.subfont = subfont + font.effects = effects + return font + + size = property(lambda self: self._scale * (72.0 / (72.27 * 2**16))) + widths = _api.deprecated("3.11")(property(lambda self: [ (1000 * self._tfm.width.get(char, 0)) >> 20 for char in range(max(self._tfm.width, default=-1) + 1)])) @@ -614,7 +710,7 @@ def __repr__(self): def _width_of(self, char): """Width of char in dvi units.""" - metrics = self._tfm.get_metrics(char) + metrics = self._metrics.get_metrics(char) if metrics is None: _log.debug('No width for char %d in font %s.', char, self.texname) return 0 @@ -622,7 +718,7 @@ def _width_of(self, char): def _height_depth_of(self, char): """Height and depth of char in dvi units.""" - metrics = self._tfm.get_metrics(char) + metrics = self._metrics.get_metrics(char) if metrics is None: _log.debug('No metrics for char %d in font %s', char, self.texname) return [0, 0] @@ -639,26 +735,41 @@ def _height_depth_of(self, char): hd[-1] = 0 return hd + @cached_property + def path(self): + psfont = PsfontsMap(find_tex_file("pdftex.map"))[self.texname] + if psfont.filename is None: + raise ValueError("No usable font file found for {} ({}); " + "the font may lack a Type-1 version" + .format(psfont.psname.decode("ascii"), + psfont.texname.decode("ascii"))) + return Path(psfont.filename) + + @cached_property + def subfont(self): + return 0 + + @cached_property + def effects(self): + return PsfontsMap(find_tex_file("pdftex.map"))[self.texname].effects + def _index_dvi_to_freetype(self, idx): """Convert dvi glyph indices to FreeType ones.""" # Glyphs indices stored in the dvi file map to FreeType glyph indices # (i.e., which can be passed to FT_Load_Glyph) in various ways: + # - for xetex & luatex "native fonts", dvi indices are directly equal + # to FreeType indices. # - if pdftex.map specifies an ".enc" file for the font, that file maps # dvi indices to Adobe glyph names, which can then be converted to # FreeType glyph indices with FT_Get_Name_Index. # - if no ".enc" file is specified, then the font must be a Type 1 # font, and dvi indices directly index into the font's CharStrings # vector. - # - (xetex & luatex, currently unsupported, can also declare "native - # fonts", for which dvi indices are equal to FreeType indices.) + if self.texname.startswith(b"["): + return idx if self._encoding is None: + face = font_manager.get_font(self.path) psfont = PsfontsMap(find_tex_file("pdftex.map"))[self.texname] - if psfont.filename is None: - raise ValueError("No usable font file found for {} ({}); " - "the font may lack a Type-1 version" - .format(psfont.psname.decode("ascii"), - psfont.texname.decode("ascii"))) - face = font_manager.get_font(psfont.filename) if psfont.encoding: self._encoding = [face.get_name_index(name) for name in _parse_enc(psfont.encoding)] @@ -867,6 +978,27 @@ def get_metrics(self, idx): property(lambda self: {c: m.tex_depth for c, m in self._glyph_metrics})) +class TtfMetrics: + def __init__(self, filename): + self._face = font_manager.get_font(filename, hinting_factor=1) + + def get_metrics(self, idx): + # _mul1220 uses a truncating bitshift for compatibility with dvitype. + # When upem is 2048 the conversion is exact to 12.20 is exact, but when + # upem is 1000 (e.g. lmroman10-regular.otf) the metrics themselves are + # not exactly representable as 12.20 fp. Manual testing via + # \sbox0{x}\count0=\wd0\typeout{\the\count0} suggests that metrics are + # rounded (not truncated) after conversion to 12.20 and before + # multiplication by the scale. + upem = self._face.units_per_EM # Usually 2048 or 1000. + g = self._face.load_glyph(idx, LoadFlags.NO_SCALE) + return TexMetrics( + tex_width=round(g.horiAdvance / upem * 2**20), + tex_height=round(g.horiBearingY / upem * 2**20), + tex_depth=round((g.height - g.horiBearingY) / upem * 2**20), + ) + + PsFont = namedtuple('PsFont', 'texname psname effects encoding filename') @@ -1164,10 +1296,6 @@ def _fontfile(cls, suffix, texname): from argparse import ArgumentParser import itertools - import fontTools.agl - - from matplotlib.ft2font import FT2Font - parser = ArgumentParser() parser.add_argument("filename") parser.add_argument("dpi", nargs="?", type=float, default=None) @@ -1182,17 +1310,20 @@ def _print_fields(*args): print(f"=== NEW PAGE === " f"(w: {page.width}, h: {page.height}, d: {page.descent})") print("--- GLYPHS ---") - for font, group in itertools.groupby( - page.text, lambda text: text.font): - psfont = fontmap[font.texname] - fontpath = psfont.filename - print(f"font: {font.texname.decode('latin-1')} " - f"(scale: {font._scale / 2 ** 20}) at {fontpath}") - face = FT2Font(fontpath) + for font, group in itertools.groupby(page.text, lambda text: text.font): + font_name = (font.texname.decode("utf8") if os.name == "nt" + else os.fsdecode(font.texname)) + if isinstance(font._metrics, Tfm): + print(f"font: {font_name} at {font.path}") + else: + print(f"font: {font_name}") + print(f"scale: {font._scale / 2 ** 20}") _print_fields("x", "y", "glyph", "chr", "w") + if font.subfont: + raise NotImplementedError("Indexing TTC fonts is not supported yet") for text in group: - glyph_str = fontTools.agl.toUnicode(face.get_glyph_name(text.index)) - _print_fields(text.x, text.y, text.glyph, glyph_str, text.width) + _print_fields(text.x, text.y, text.glyph, + text._as_unicode_or_name(), text.width) if page.boxes: print("--- BOXES ---") _print_fields("x", "y", "h", "w") diff --git a/lib/matplotlib/dviread.pyi b/lib/matplotlib/dviread.pyi index 41799c083218..d1fead01f64a 100644 --- a/lib/matplotlib/dviread.pyi +++ b/lib/matplotlib/dviread.pyi @@ -58,14 +58,27 @@ class Dvi: class DviFont: texname: bytes - size: float def __init__( - self, scale: float, tfm: Tfm, texname: bytes, vf: Vf | None + self, scale: float, metrics: Tfm | TtfMetrics, texname: bytes, vf: Vf | None ) -> None: ... + @classmethod + def from_luatex(cls, scale: float, texname: bytes): ... + @classmethod + def from_xetex( + cls, scale: float, texname: bytes, subfont: int, effects: dict[str, float] + ): ... def __eq__(self, other: object) -> bool: ... def __ne__(self, other: object) -> bool: ... @property + def size(self) -> float: ... + @property def widths(self) -> list[int]: ... + @property + def path(self) -> Path: ... + @property + def subfont(self) -> int: ... + @property + def effects(self) -> dict[str, float]: ... class Vf(Dvi): def __init__(self, filename: str | os.PathLike) -> None: ... @@ -91,6 +104,10 @@ class Tfm: @property def depth(self) -> dict[int, int]: ... +class TtfMetrics: + def __init__(self, filename: str | os.PathLike) -> None: ... + def get_metrics(self, idx: int) -> TexMetrics: ... + class PsFont(NamedTuple): texname: bytes psname: bytes diff --git a/lib/matplotlib/tests/baseline_images/dviread/lualatex.json b/lib/matplotlib/tests/baseline_images/dviread/lualatex.json new file mode 100644 index 000000000000..8f2d95017ec7 --- /dev/null +++ b/lib/matplotlib/tests/baseline_images/dviread/lualatex.json @@ -0,0 +1 @@ +[{"text": [[5046272, 4128768, "A", "lmroman10-regular.otf", 9.96, {}], [5756027, 4128768, "L", "lmroman10-regular.otf", 9.96, {}], [5929697, 4012179, "A", "lmroman7-regular.otf", 6.97, {}], [6218125, 4128768, "T", "lmroman10-regular.otf", 9.96, {}], [6582045, 4269998, "E", "lmroman10-regular.otf", 9.96, {}], [6946425, 4128768, "X", "lmroman10-regular.otf", 9.96, {}], [7656180, 4128768, "d", "DejaVuSans.ttf", 9.96, {"extend": 1.25, "slant": 0.25, "embolden": 0.25}], [8072180, 4128768, "o", "DejaVuSans.ttf", 9.96, {"extend": 1.25, "slant": 0.25, "embolden": 0.25}], [8473140, 4128768, "c", "DejaVuSans.ttf", 9.96, {"extend": 1.25, "slant": 0.25, "embolden": 0.25}], [8833460, 4128768, ".", "DejaVuSans.ttf", 9.96, {"extend": 1.25, "slant": 0.25, "embolden": 0.25}]], "boxes": []}, {"text": [[13686374, 5056284, "\u03c0", "cmmi5.pfb", 4.98, {}], [13716923, 5390308, "2", "cmr5.pfb", 4.98, {}], [13355110, 5463127, "integraldisplay", "cmex10.pfb", 9.96, {}], [13406537, 7324364, "0", "cmr7.pfb", 6.97, {}], [14010471, 5627696, "parenleftBig", "cmex10.pfb", 9.96, {}], [14937513, 5911796, "x", "cmmi10.pfb", 9.96, {}], [14480510, 6804696, "s", "lmroman10-regular.otf", 9.96, {}], [14738721, 6804696, "i", "lmroman10-regular.otf", 9.96, {}], [14920911, 6804696, "n", "lmroman10-regular.otf", 9.96, {}], [15394516, 6804696, "x", "cmmi10.pfb", 9.96, {}], [15847715, 5627696, "parenrightBig", "cmex10.pfb", 9.96, {}], [16239111, 5763501, "2", "cmr7.pfb", 6.97, {}], [16642338, 6355152, "d", "lmroman10-regular.otf", 9.96, {}], [17006718, 6355152, "x", "cmmi10.pfb", 9.96, {}]], "boxes": [[13686374, 5130818, 26213, 284106], [14480510, 6204418, 26213, 1288562]]}] diff --git a/lib/matplotlib/tests/baseline_images/dviread/pdflatex.json b/lib/matplotlib/tests/baseline_images/dviread/pdflatex.json new file mode 100644 index 000000000000..4754b722aa58 --- /dev/null +++ b/lib/matplotlib/tests/baseline_images/dviread/pdflatex.json @@ -0,0 +1 @@ +[{"text": [[5046272, 4128768, "A", "cmr10.pfb", 9.96, {}], [5756246, 4128768, "L", "cmr10.pfb", 9.96, {}], [5929917, 3994421, "A", "cmr7.pfb", 6.97, {}], [6218464, 4128768, "T", "cmr10.pfb", 9.96, {}], [6582530, 4269852, "E", "cmr10.pfb", 9.96, {}], [6946620, 4128768, "X", "cmr10.pfb", 9.96, {}], [7656594, 4128768, "d", "cmr10.pfb", 9.96, {}], [8020684, 4128768, "o", "cmr10.pfb", 9.96, {}], [8366570, 4128768, "c", "cmr10.pfb", 9.96, {}], [8657841, 4128768, ".", "cmr10.pfb", 9.96, {}]], "boxes": []}, {"text": [[13686591, 5056284, "\u03c0", "cmmi5.pfb", 4.98, {}], [13717140, 5390308, "2", "cmr5.pfb", 4.98, {}], [13355327, 5463127, "integraldisplay", "cmex10.pfb", 9.96, {}], [13406754, 7324364, "0", "cmr7.pfb", 6.97, {}], [14010688, 5627696, "parenleftBig", "cmex10.pfb", 9.96, {}], [14937658, 5911796, "x", "cmmi10.pfb", 9.96, {}], [14480727, 6804696, "s", "cmr10.pfb", 9.96, {}], [14739230, 6804696, "i", "cmr10.pfb", 9.96, {}], [14921275, 6804696, "n", "cmr10.pfb", 9.96, {}], [15394589, 6804696, "x", "cmmi10.pfb", 9.96, {}], [15847788, 5627696, "parenrightBig", "cmex10.pfb", 9.96, {}], [16239184, 5763501, "2", "cmr7.pfb", 6.97, {}], [16642411, 6355152, "d", "cmr10.pfb", 9.96, {}], [17006501, 6355152, "x", "cmmi10.pfb", 9.96, {}]], "boxes": [[13686591, 5130818, 26213, 284106], [14480727, 6204418, 26213, 1288418]]}] diff --git a/lib/matplotlib/tests/baseline_images/dviread/test.dvi b/lib/matplotlib/tests/baseline_images/dviread/test.dvi deleted file mode 100644 index 93751ffdcba0df980ecca01aec8ba561a4e4f485..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 856 zcmey)#MnIPfQ&T*5HP=xRtQOrP{=PWDJU&bFfuSS)iX5EGcd6-G%&U32C85LDI)~_ z1Hl5ON(P4B1%DSaFf3rQ2Q!}l$%(!U44>J(KPFT%Z~=`0Vb z8P~pv%#SM~CYPiZmrPE{pWrT=T$-DjH(|%)lD;K8Ae-$N7}D~KKvsa%Wagz$&P^;S z$jL9s$xKo&o}5yaS(KWX(zmb|th5E>RmMmLhQ7Y}ia##&_RfEI&jCpO4dCgW#Bkxd z{bvq;-diyYtRNpie36@Jx>>RhEIFT1S*;UjUn&Dj=&AY4Ul;XGC=dP2+QvLP3a$g> z6VnYweLxEY6%X_RO+9_me*UNA_RkEzHkJy(!-p+7H?_jhV09EwA>YYAKy_y(DQz_9 z>+1#Sxq8xJ{+DM7s|A4O00rj%v{3}xYpadsW>yQZzAu`f^^rJ!(~FM0CMdye3O)a1;>9L2uA9#B$Y0%Br-7M`TCDKRgE diff --git a/lib/matplotlib/tests/baseline_images/dviread/test.json b/lib/matplotlib/tests/baseline_images/dviread/test.json deleted file mode 100644 index 0809cb9531f1..000000000000 --- a/lib/matplotlib/tests/baseline_images/dviread/test.json +++ /dev/null @@ -1,94 +0,0 @@ -[ - { - "text": [ - [5046272, 4128768, "T", "cmr10", 9.96], - [5519588, 4128768, "h", "cmr10", 9.96], - [5883678, 4128768, "i", "cmr10", 9.96], - [6065723, 4128768, "s", "cmr10", 9.96], - [6542679, 4128768, "i", "cmr10", 9.96], - [6724724, 4128768, "s", "cmr10", 9.96], - [7201680, 4128768, "a", "cmr10", 9.96], - [7747814, 4128768, "L", "cmr10", 9.96], - [7921485, 3994421, "A", "cmr7", 6.97], - [8210032, 4128768, "T", "cmr10", 9.96], - [8574098, 4269852, "E", "cmr10", 9.96], - [8938188, 4128768, "X", "cmr10", 9.96], - [9648162, 4128768, "t", "cmr10", 9.96], - [9903025, 4128768, "e", "cmr10", 9.96], - [10194296, 4128768, "s", "cmr10", 9.96], - [10452799, 4128768, "t", "cmr10", 9.96], - [10926115, 4128768, "d", "cmr10", 9.96], - [11290205, 4128768, "o", "cmr10", 9.96], - [11636091, 4128768, "c", "cmr10", 9.96], - [11927362, 4128768, "u", "cmr10", 9.96], - [12291452, 4128768, "m", "cmr10", 9.96], - [12837587, 4128768, "e", "cmr10", 9.96], - [13128858, 4128768, "n", "cmr10", 9.96], - [13474743, 4128768, "t", "cmr10", 9.96], - [4063232, 4915200, "f", "cmr10", 9.96], - [4263482, 4915200, "o", "cmr10", 9.96], - [4591163, 4915200, "r", "cmr10", 9.96], - [5066299, 4915200, "t", "cmr10", 9.96], - [5321162, 4915200, "e", "cmr10", 9.96], - [5612433, 4915200, "s", "cmr10", 9.96], - [5870936, 4915200, "t", "cmr10", 9.96], - [6125799, 4915200, "i", "cmr10", 9.96], - [6307844, 4915200, "n", "cmr10", 9.96], - [6671934, 4915200, "g", "cmr10", 9.96], - [7218068, 4915200, "m", "cmr10", 9.96], - [7764203, 4915200, "a", "cmr10", 9.96], - [8091884, 4915200, "t", "cmr10", 9.96], - [8346747, 4915200, "p", "cmr10", 9.96], - [8710837, 4915200, "l", "cmr10", 9.96], - [8892882, 4915200, "o", "cmr10", 9.96], - [9220563, 4915200, "t", "cmr10", 9.96], - [9475426, 4915200, "l", "cmr10", 9.96], - [9657471, 4915200, "i", "cmr10", 9.96], - [9839516, 4915200, "b", "cmr10", 9.96], - [10203606, 4915200, "'", "cmr10", 9.96], - [10385651, 4915200, "s", "cmr10", 9.96], - [10862607, 4915200, "d", "cmr10", 9.96], - [11226697, 4915200, "v", "cmr10", 9.96], - [11572583, 4915200, "i", "cmr10", 9.96], - [11754628, 4915200, "r", "cmr10", 9.96], - [12011311, 4915200, "e", "cmr10", 9.96], - [12302582, 4915200, "a", "cmr10", 9.96], - [12630263, 4915200, "d", "cmr10", 9.96], - [13686591, 6629148, "\u0019", "cmmi5", 4.98], - [13717140, 6963172, "2", "cmr5", 4.98], - [13355327, 7035991, "Z", "cmex10", 9.96], - [13406754, 8897228, "0", "cmr7", 6.97], - [14010688, 7200560, "\u0010", "cmex10", 9.96], - [14937658, 7484660, "x", "cmmi10", 9.96], - [14480727, 8377560, "s", "cmr10", 9.96], - [14739230, 8377560, "i", "cmr10", 9.96], - [14921275, 8377560, "n", "cmr10", 9.96], - [15394589, 8377560, "x", "cmmi10", 9.96], - [15847788, 7200560, "\u0011", "cmex10", 9.96], - [16239184, 7336365, "2", "cmr7", 6.97], - [16642411, 7928016, "d", "cmr10", 9.96], - [17006501, 7928016, "x", "cmmi10", 9.96] - ], - "boxes": [ - [4063232, 5701632, 65536, 22609920], - [13686591, 6703682, 26213, 284106], - [14480727, 7777282, 26213, 1288418] - ] - }, - { - "text": [ - [5046272, 4128768, "a", "cmr10", 9.96], - [5373953, 4128768, "n", "cmr10", 9.96], - [5738043, 4128768, "o", "cmr10", 9.96], - [6065724, 4128768, "t", "cmr10", 9.96], - [6320587, 4128768, "h", "cmr10", 9.96], - [6684677, 4128768, "e", "cmr10", 9.96], - [6975948, 4128768, "r", "cmr10", 9.96], - [7451084, 4128768, "p", "cmr10", 9.96], - [7815174, 4128768, "a", "cmr10", 9.96], - [8142855, 4128768, "g", "cmr10", 9.96], - [8470536, 4128768, "e", "cmr10", 9.96] - ], - "boxes": [] - } -] diff --git a/lib/matplotlib/tests/baseline_images/dviread/test.tex b/lib/matplotlib/tests/baseline_images/dviread/test.tex index 33220fedae3e..4a2d4720c065 100644 --- a/lib/matplotlib/tests/baseline_images/dviread/test.tex +++ b/lib/matplotlib/tests/baseline_images/dviread/test.tex @@ -1,17 +1,19 @@ -% source file for test.dvi \documentclass{article} +\usepackage{iftex} +\iftutex\usepackage{fontspec}\fi % xetex or luatex \pagestyle{empty} + \begin{document} -This is a \LaTeX\ test document\\ -for testing matplotlib's dviread +A \LaTeX { + \iftutex\fontspec{DejaVuSans.ttf}[ + FakeSlant=0.25, FakeStretch=1.25, FakeBold=2.5, Color=0000FF]\fi + doc. +} -\noindent\rule{\textwidth}{1pt} +\newpage \[ \int\limits_0^{\frac{\pi}{2}} \Bigl(\frac{x}{\sin x}\Bigr)^2\,\mathrm{d}x \] \special{Special!} -\newpage -another page - \end{document} diff --git a/lib/matplotlib/tests/baseline_images/dviread/xelatex.json b/lib/matplotlib/tests/baseline_images/dviread/xelatex.json new file mode 100644 index 000000000000..8fb81ddf0c7e --- /dev/null +++ b/lib/matplotlib/tests/baseline_images/dviread/xelatex.json @@ -0,0 +1 @@ +[{"text": [[5046272, 4128768, "A", "lmroman10-regular.otf", 9.96, {}], [5756027, 4128768, "L", "lmroman10-regular.otf", 9.96, {}], [5929697, 4012179, "A", "lmroman7-regular.otf", 6.97, {}], [6218125, 4128768, "T", "lmroman10-regular.otf", 9.96, {}], [6582045, 4269998, "E", "lmroman10-regular.otf", 9.96, {}], [6946425, 4128768, "X", "lmroman10-regular.otf", 9.96, {}], [7656180, 4128768, "d", "DejaVuSans.ttf", 9.96, {"rgba": [0, 0, 255, 255], "extend": 1.25, "slant": 0.25, "embolden": 0.25}], [8176180, 4128768, "o", "DejaVuSans.ttf", 9.96, {"rgba": [0, 0, 255, 255], "extend": 1.25, "slant": 0.25, "embolden": 0.25}], [8677380, 4128768, "c", "DejaVuSans.ttf", 9.96, {"rgba": [0, 0, 255, 255], "extend": 1.25, "slant": 0.25, "embolden": 0.25}], [9127780, 4128768, ".", "DejaVuSans.ttf", 9.96, {"rgba": [0, 0, 255, 255], "extend": 1.25, "slant": 0.25, "embolden": 0.25}]], "boxes": []}, {"text": [[13686374, 5056284, "\u03c0", "cmmi5.pfb", 4.98, {}], [13716923, 5390308, "2", "cmr5.pfb", 4.98, {}], [13355110, 5463127, "integraldisplay", "cmex10.pfb", 9.96, {}], [13406537, 7324364, "0", "cmr7.pfb", 6.97, {}], [14010471, 5627696, "parenleftBig", "cmex10.pfb", 9.96, {}], [14937513, 5911796, "x", "cmmi10.pfb", 9.96, {}], [14480510, 6804696, "s", "lmroman10-regular.otf", 9.96, {}], [14738722, 6804696, "i", "lmroman10-regular.otf", 9.96, {}], [14920912, 6804696, "n", "lmroman10-regular.otf", 9.96, {}], [15394516, 6804696, "x", "cmmi10.pfb", 9.96, {}], [15847715, 5627696, "parenrightBig", "cmex10.pfb", 9.96, {}], [16239111, 5763501, "2", "cmr7.pfb", 6.97, {}], [16642338, 6355152, "d", "lmroman10-regular.otf", 9.96, {}], [17006718, 6355152, "x", "cmmi10.pfb", 9.96, {}]], "boxes": [[13686374, 5130818, 26213, 284106], [14480510, 6204418, 26213, 1288562]]}] diff --git a/lib/matplotlib/tests/test_dviread.py b/lib/matplotlib/tests/test_dviread.py index 7b7ff151be18..f61872ce123e 100644 --- a/lib/matplotlib/tests/test_dviread.py +++ b/lib/matplotlib/tests/test_dviread.py @@ -1,8 +1,9 @@ import json from pathlib import Path import shutil +import subprocess -import matplotlib.dviread as dr +from matplotlib import cbook, dviread as dr import pytest @@ -62,16 +63,45 @@ def test_PsfontsMap(monkeypatch): @pytest.mark.skipif(shutil.which("kpsewhich") is None, reason="kpsewhich is not available") -def test_dviread(): - dirpath = Path(__file__).parent / 'baseline_images/dviread' - with (dirpath / 'test.json').open() as f: - correct = json.load(f) - with dr.Dvi(str(dirpath / 'test.dvi'), None) as dvi: - data = [{'text': [[t.x, t.y, - chr(t.glyph), - t.font.texname.decode('ascii'), - round(t.font.size, 2)] - for t in page.text], - 'boxes': [[b.x, b.y, b.height, b.width] for b in page.boxes]} - for page in dvi] +@pytest.mark.parametrize("engine", ["pdflatex", "xelatex", "lualatex"]) +def test_dviread(tmp_path, engine, monkeypatch): + dirpath = Path(__file__).parent / "baseline_images/dviread" + shutil.copy(dirpath / "test.tex", tmp_path) + shutil.copy(cbook._get_data_path("fonts/ttf/DejaVuSans.ttf"), tmp_path) + cmd, fmt = { + "pdflatex": (["latex"], "dvi"), + "xelatex": (["xelatex", "-no-pdf"], "xdv"), + "lualatex": (["lualatex", "-output-format=dvi"], "dvi"), + }[engine] + if shutil.which(cmd[0]) is None: + pytest.skip(f"{cmd[0]} is not available") + subprocess.run( + [*cmd, "test.tex"], cwd=tmp_path, check=True, capture_output=True) + # dviread must be run from the tmppath directory because {xe,lua}tex output + # record the path to DejaVuSans.ttf as it is written in the tex source, + # i.e. as a relative path. + monkeypatch.chdir(tmp_path) + with dr.Dvi(tmp_path / f"test.{fmt}", None) as dvi: + try: + pages = [*dvi] + except FileNotFoundError as exc: + for note in getattr(exc, "__notes__", []): + if "too-old version of luaotfload" in note: + pytest.skip(note) + raise + data = [ + { + "text": [ + [ + t.x, t.y, + t._as_unicode_or_name(), + t.font.path.name, + round(t.font.size, 2), + t.font.effects, + ] for t in page.text + ], + "boxes": [[b.x, b.y, b.height, b.width] for b in page.boxes] + } for page in pages + ] + correct = json.loads((dirpath / f"{engine}.json").read_text()) assert data == correct diff --git a/lib/matplotlib/textpath.py b/lib/matplotlib/textpath.py index b57597ded363..97c4ed188381 100644 --- a/lib/matplotlib/textpath.py +++ b/lib/matplotlib/textpath.py @@ -234,7 +234,9 @@ def get_glyphs_tex(self, prop, s, glyph_map=None, # characters into strings. t1_encodings = {} for text in page.text: - font = get_font(text.font_path) + font = get_font(text.font.path) + if text.font.subfont: + raise NotImplementedError("Indexing TTC fonts is not supported yet") char_id = self._get_char_id(font, text.glyph) if char_id not in glyph_map: font.clear() From 7fc991707c20950fa12319099f102bb14d9da8f6 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Thu, 17 Apr 2025 13:11:12 +0200 Subject: [PATCH 2/2] 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 | 1 + lib/matplotlib/rcsetup.py | 1 + lib/matplotlib/texmanager.py | 30 +++++++++++++++++++++++++--- 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/lib/matplotlib/dviread.py b/lib/matplotlib/dviread.py index dd702c4d93a5..1b45a12ca9ef 100644 --- a/lib/matplotlib/dviread.py +++ b/lib/matplotlib/dviread.py @@ -109,12 +109,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 72117abf7317..2e3885c8f25a 100644 --- a/lib/matplotlib/mpl-data/matplotlibrc +++ b/lib/matplotlib/mpl-data/matplotlibrc @@ -327,6 +327,7 @@ # zapf chancery, charter, serif, sans-serif, helvetica, # avant garde, courier, monospace, computer modern roman, # computer modern sans serif, computer modern typewriter +#text.latex.engine: latex #text.latex.preamble: # IMPROPER USE OF THIS FEATURE WILL LEAD TO LATEX FAILURES # AND IS THEREFORE UNSUPPORTED. PLEASE DO NOT ASK FOR HELP # IF THIS FEATURE DOES NOT DO WHAT YOU EXPECT IT TO. diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index ce29c5076100..5d6cd4ed3df1 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -1051,6 +1051,7 @@ def _convert_validator_spec(key, conv): # text props "text.color": validate_color, "text.usetex": validate_bool, + "text.latex.engine": ["latex", "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 94fc94e9e840..4276a86cb3b4 100644 --- a/lib/matplotlib/texmanager.py +++ b/lib/matplotlib/texmanager.py @@ -200,14 +200,31 @@ 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"\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.", @@ -277,7 +294,9 @@ def make_dvi(cls, tex, fontsize): Return the file name. """ basefile = cls.get_basefile(tex, fontsize) - dvifile = '%s.dvi' % basefile + ext = {"latex": "dvi", "xelatex": "xdv", "lualatex": "dvi"}[ + mpl.rcParams["text.latex.engine"]] + dvifile = f"{basefile}.{ext}" if not os.path.exists(dvifile): texfile = Path(cls.make_tex(tex, fontsize)) # Generate the dvi in a temporary directory to avoid race @@ -292,8 +311,13 @@ def make_dvi(cls, tex, fontsize): cwd = Path(dvifile).parent with TemporaryDirectory(dir=cwd) as tmpdir: tmppath = Path(tmpdir) + 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", f"--output-directory={tmppath.name}", f"{texfile.name}"], tex, cwd=cwd) (tmppath / Path(dvifile).name).replace(dvifile)