diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index 0ab5a65f0b75..d8c0d3cdff9e 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -940,18 +940,13 @@ 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, + # raises ValueError if psfont.filename is None. + fontfile=str(dvifont.resolve_path()), basefont=psfont.psname, encodingfile=psfont.encoding, effects=psfont.effects) @@ -996,11 +991,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..d8d571250444 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,12 @@ 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.resolve_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 +83,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.resolve_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.resolve_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 +405,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 +501,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 +529,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 +555,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(250, args=()) + def _begin_reflect(self): + raise NotImplementedError + + @_dispatch(251, args=()) + def _end_reflect(self): raise NotImplementedError - @_dispatch(min=250, max=255) - def _malformed(self, offset): - raise ValueError(f"unknown command: byte {250 + offset}") + @_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 +632,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 +648,54 @@ 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._path = None 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 +712,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 +720,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 +737,42 @@ def _height_depth_of(self, char): hd[-1] = 0 return hd + def resolve_path(self): + if self._path is None: + 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"))) + self._path = Path(psfont.filename) + return self._path + + @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.resolve_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 +981,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 +1299,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 +1313,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.resolve_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..2b86f46ffd4b 100644 --- a/lib/matplotlib/dviread.pyi +++ b/lib/matplotlib/dviread.pyi @@ -58,14 +58,26 @@ 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]: ... + def resolve_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 +103,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 93751ffdcba0..000000000000 Binary files a/lib/matplotlib/tests/baseline_images/dviread/test.dvi and /dev/null differ 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..1998ceb202ad 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.resolve_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..8deae19c42e7 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.resolve_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()