Skip to content

Commit a2992c8

Browse files
committed
Support luatex as alternative usetex engine.
Currently, this PR is mostly a proof of concept; it only implements the dvi generation and parsing parts, but does not implement rendering in any of the builtin backends, except svg (under rcParams["svg.fonttype"] = "none", the default). However, there is a companion branch on the mplcairo repository, also named "luadvi", which implements support. 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"; plt.rcParams["text.latex.preamble"] = ( # luatex can use any font installed on the system, spec'd using its # "normal" name. r"\usepackage{fontspec}\setmainfont{TeX Gyre Pagella}") plt.figtext(.5, .5, r"\textrm{gff\textwon}", usetex=True) plt.show() ``` 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 :-) NOTES: - xetex could possibly be supported as well, but uses its own (more divergent) dvi variant which would require a bit more work...
1 parent a9dc9ac commit a2992c8

File tree

6 files changed

+195
-114
lines changed

6 files changed

+195
-114
lines changed

lib/matplotlib/backends/backend_pdf.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -991,7 +991,7 @@ def _embedTeXFont(self, fontinfo):
991991

992992
# Widths
993993
widthsObject = self.reserveObject('font widths')
994-
self.writeObject(widthsObject, fontinfo.dvifont.widths)
994+
self.writeObject(widthsObject, fontinfo.dvifont._metrics.widths)
995995

996996
# Font dictionary
997997
fontdictObject = self.reserveObject('font dictionary')

lib/matplotlib/dviread.py

+117-63
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@
3030

3131
import numpy as np
3232

33-
from matplotlib import _api, cbook
33+
from matplotlib import _api, cbook, textpath
34+
from matplotlib.ft2font import FT2Font, LoadFlags
3435

3536
_log = logging.getLogger(__name__)
3637

@@ -106,18 +107,29 @@ def font_effects(self):
106107
@property
107108
def glyph_name_or_index(self):
108109
"""
109-
Either the glyph name or the native charmap glyph index.
110-
111-
If :file:`pdftex.map` specifies an encoding for this glyph's font, that
112-
is a mapping of glyph indices to Adobe glyph names; use it to convert
113-
dvi indices to glyph names. Callers can then convert glyph names to
114-
glyph indices (with FT_Get_Name_Index/get_name_index), and load the
115-
glyph using FT_Load_Glyph/load_glyph.
116-
117-
If :file:`pdftex.map` specifies no encoding, the indices directly map
118-
to the font's "native" charmap; glyphs should directly load using
119-
FT_Load_Char/load_char after selecting the native charmap.
110+
The glyph name, the native charmap glyph index, or the raw glyph index.
111+
112+
If the font is a TrueType file (which can currently only happen for
113+
DVI files generated by luatex), then this number is the raw index of
114+
the glyph, which can be passed to FT_Load_Glyph/load_glyph. Note that
115+
xetex is currently unsupported and the behavior on xdv files (xetex's
116+
version of dvi) is undefined.
117+
118+
Otherwise, the font is a PostScript font. For such fonts, if
119+
:file:`pdftex.map` specifies an encoding for this glyph's font,
120+
that is a mapping of glyph indices to Adobe glyph names; which
121+
is used by this property to convert dvi numbers to glyph names.
122+
Callers can then convert glyph names to glyph indices (with
123+
FT_Get_Name_Index/get_name_index), and load the glyph using
124+
FT_Load_Glyph/load_glyph.
125+
126+
If :file:`pdftex.map` specifies no encoding for a PostScript font,
127+
this number is an index to the font's "native" charmap; glyphs should
128+
directly load using FT_Load_Char/load_char after selecting the native
129+
charmap.
120130
"""
131+
# TODO: The last section is only true since luaotfload 3.15; add a
132+
# version check in the tex file generated by texmanager.
121133
entry = self._get_pdftexmap_entry()
122134
return (_parse_enc(entry.encoding)[self.glyph]
123135
if entry.encoding is not None else self.glyph)
@@ -399,7 +411,7 @@ def _put_char_real(self, char):
399411
scale = font._scale
400412
for x, y, f, g, w in font._vf[char].text:
401413
newf = DviFont(scale=_mul2012(scale, f._scale),
402-
tfm=f._tfm, texname=f.texname, vf=f._vf)
414+
metrics=f._metrics, texname=f.texname, vf=f._vf)
403415
self.text.append(Text(self.h + _mul2012(x, scale),
404416
self.v + _mul2012(y, scale),
405417
newf, g, newf._width_of(g)))
@@ -496,6 +508,12 @@ def _fnt_def(self, k, c, s, d, a, l):
496508
def _fnt_def_real(self, k, c, s, d, a, l):
497509
n = self.file.read(a + l)
498510
fontname = n[-l:].decode('ascii')
511+
# TODO: Implement full spec, https://tug.org/pipermail/dvipdfmx/2021-January/000168.html
512+
# Note that checksum seems wrong?
513+
if fontname.startswith('[') and fontname.endswith(']'):
514+
metrics = TtfMetrics(fontname[1:-1])
515+
self.fonts[k] = DviFont(scale=s, metrics=metrics, texname=n, vf=None)
516+
return
499517
try:
500518
tfm = _tfmfile(fontname)
501519
except FileNotFoundError as exc:
@@ -512,7 +530,7 @@ def _fnt_def_real(self, k, c, s, d, a, l):
512530
vf = _vffile(fontname)
513531
except FileNotFoundError:
514532
vf = None
515-
self.fonts[k] = DviFont(scale=s, tfm=tfm, texname=n, vf=vf)
533+
self.fonts[k] = DviFont(scale=s, metrics=tfm, texname=n, vf=vf)
516534

517535
@_dispatch(247, state=_dvistate.pre, args=('u1', 'u4', 'u4', 'u4', 'u1'))
518536
def _pre(self, i, num, den, mag, k):
@@ -562,7 +580,7 @@ class DviFont:
562580
----------
563581
scale : float
564582
Factor by which the font is scaled from its natural size.
565-
tfm : Tfm
583+
tfm : Tfm | TtfMetrics
566584
TeX font metrics for this font
567585
texname : bytes
568586
Name of the font as used internally by TeX and friends, as an ASCII
@@ -582,21 +600,17 @@ class DviFont:
582600
the point size.
583601
584602
"""
585-
__slots__ = ('texname', 'size', 'widths', '_scale', '_vf', '_tfm')
603+
__slots__ = ('texname', 'size', '_scale', '_vf', '_metrics')
586604

587-
def __init__(self, scale, tfm, texname, vf):
605+
def __init__(self, scale, metrics, texname, vf):
588606
_api.check_isinstance(bytes, texname=texname)
589607
self._scale = scale
590-
self._tfm = tfm
608+
self._metrics = metrics
591609
self.texname = texname
592610
self._vf = vf
593611
self.size = scale * (72.0 / (72.27 * 2**16))
594-
try:
595-
nchars = max(tfm.width) + 1
596-
except ValueError:
597-
nchars = 0
598-
self.widths = [(1000*tfm.width.get(char, 0)) >> 20
599-
for char in range(nchars)]
612+
613+
widths = _api.deprecated("3.11")(property(lambda self: ...))
600614

601615
def __eq__(self, other):
602616
return (type(self) is type(other)
@@ -610,32 +624,30 @@ def __repr__(self):
610624

611625
def _width_of(self, char):
612626
"""Width of char in dvi units."""
613-
width = self._tfm.width.get(char, None)
614-
if width is not None:
615-
return _mul2012(width, self._scale)
616-
_log.debug('No width for char %d in font %s.', char, self.texname)
617-
return 0
627+
metrics = self._metrics.get_metrics(char)
628+
if metrics is None:
629+
_log.debug('No width for char %d in font %s.', char, self.texname)
630+
return 0
631+
return _mul2012(metrics.width, self._scale)
618632

619633
def _height_depth_of(self, char):
620634
"""Height and depth of char in dvi units."""
621-
result = []
622-
for metric, name in ((self._tfm.height, "height"),
623-
(self._tfm.depth, "depth")):
624-
value = metric.get(char, None)
625-
if value is None:
626-
_log.debug('No %s for char %d in font %s',
627-
name, char, self.texname)
628-
result.append(0)
629-
else:
630-
result.append(_mul2012(value, self._scale))
635+
metrics = self._metrics.get_metrics(char)
636+
if metrics is None:
637+
_log.debug('No metrics for char %d in font %s', char, self.texname)
638+
return [0, 0]
639+
metrics = [
640+
_mul2012(metrics.height, self._scale),
641+
_mul2012(metrics.depth, self._scale),
642+
]
631643
# cmsyXX (symbols font) glyph 0 ("minus") has a nonzero descent
632644
# so that TeX aligns equations properly
633645
# (https://tex.stackexchange.com/q/526103/)
634646
# but we actually care about the rasterization depth to align
635647
# the dvipng-generated images.
636648
if re.match(br'^cmsy\d+$', self.texname) and char == 0:
637-
result[-1] = 0
638-
return result
649+
metrics[-1] = 0
650+
return metrics
639651

640652

641653
class Vf(Dvi):
@@ -767,6 +779,9 @@ def _mul2012(num1, num2):
767779
return (num1*num2) >> 20
768780

769781

782+
WHD = namedtuple('WHD', 'width height depth')
783+
784+
770785
class Tfm:
771786
"""
772787
A TeX Font Metric file.
@@ -782,13 +797,13 @@ class Tfm:
782797
checksum : int
783798
Used for verifying against the dvi file.
784799
design_size : int
785-
Design size of the font (unknown units)
800+
Design size of the font (unknown units).
786801
width, height, depth : dict
787802
Dimensions of each character, need to be scaled by the factor
788803
specified in the dvi file. These are dicts because indexing may
789804
not start from 0.
790805
"""
791-
__slots__ = ('checksum', 'design_size', 'width', 'height', 'depth')
806+
__slots__ = ('checksum', 'design_size', '_whds', 'widths')
792807

793808
def __init__(self, filename):
794809
_log.debug('opening tfm file %s', filename)
@@ -804,15 +819,36 @@ def __init__(self, filename):
804819
widths = struct.unpack(f'!{nw}i', file.read(4*nw))
805820
heights = struct.unpack(f'!{nh}i', file.read(4*nh))
806821
depths = struct.unpack(f'!{nd}i', file.read(4*nd))
807-
self.width = {}
808-
self.height = {}
809-
self.depth = {}
822+
self._whds = {}
810823
for idx, char in enumerate(range(bc, ec+1)):
811824
byte0 = char_info[4*idx]
812825
byte1 = char_info[4*idx+1]
813-
self.width[char] = widths[byte0]
814-
self.height[char] = heights[byte1 >> 4]
815-
self.depth[char] = depths[byte1 & 0xf]
826+
self._whds[char] = WHD(
827+
widths[byte0], heights[byte1 >> 4], depths[byte1 & 0xf])
828+
self.widths = [(1000 * self._whds[c].width if c in self._whds else 0) >> 20
829+
for c in range(max(self._whds))] if self._whds else []
830+
831+
def get_metrics(self, char):
832+
return self._whds[char]
833+
834+
width = _api.deprecated("3.11")(
835+
property(lambda self: {c: m.width for c, m in self._whds}))
836+
height = _api.deprecated("3.11")(
837+
property(lambda self: {c: m.height for c, m in self._whds}))
838+
depth = _api.deprecated("3.11")(
839+
property(lambda self: {c: m.depth for c, m in self._whds}))
840+
841+
842+
class TtfMetrics:
843+
def __init__(self, filename):
844+
self._face = FT2Font(filename, hinting_factor=1) # Manage closing?
845+
846+
def get_metrics(self, char):
847+
mul = self._face.units_per_EM
848+
g = self._face.load_glyph(char, LoadFlags.NO_SCALE)
849+
return WHD(g.horiAdvance * mul,
850+
g.height * mul,
851+
(g.height - g.horiBearingY) * mul)
816852

817853

818854
PsFont = namedtuple('PsFont', 'texname psname effects encoding filename')
@@ -1007,8 +1043,7 @@ def _parse_enc(path):
10071043
Returns
10081044
-------
10091045
list
1010-
The nth entry of the list is the PostScript glyph name of the nth
1011-
glyph.
1046+
The nth list item is the PostScript glyph name of the nth glyph.
10121047
"""
10131048
no_comments = re.sub("%.*", "", Path(path).read_text(encoding="ascii"))
10141049
array = re.search(r"(?s)\[(.*)\]", no_comments).group(1)
@@ -1113,26 +1148,45 @@ def _fontfile(cls, suffix, texname):
11131148
from argparse import ArgumentParser
11141149
import itertools
11151150

1151+
import fontTools.agl
1152+
11161153
parser = ArgumentParser()
11171154
parser.add_argument("filename")
11181155
parser.add_argument("dpi", nargs="?", type=float, default=None)
11191156
args = parser.parse_args()
11201157
with Dvi(args.filename, args.dpi) as dvi:
11211158
fontmap = PsfontsMap(find_tex_file('pdftex.map'))
11221159
for page in dvi:
1123-
print(f"=== new page === "
1160+
print(f"=== NEW PAGE === "
11241161
f"(w: {page.width}, h: {page.height}, d: {page.descent})")
1125-
for font, group in itertools.groupby(
1126-
page.text, lambda text: text.font):
1127-
print(f"font: {font.texname.decode('latin-1')!r}\t"
1128-
f"scale: {font._scale / 2 ** 20}")
1129-
print("x", "y", "glyph", "chr", "w", "(glyphs)", sep="\t")
1162+
print("--- GLYPHS ---")
1163+
for font, group in itertools.groupby(page.text, lambda text: text.font):
1164+
font_name = font.texname.decode("latin-1")
1165+
filename = (font_name[1:-1] if font_name.startswith("[")
1166+
else fontmap[font.texname].filename)
1167+
if font_name.startswith("["):
1168+
print(f"font: {font_name}")
1169+
else:
1170+
print(f"font: {font_name} at {filename}")
1171+
print(f"scale: {font._scale / 2 ** 20}")
1172+
print(" ".join(map("{:>11}".format, ["x", "y", "glyph", "chr", "w"])))
1173+
face = FT2Font(filename)
11301174
for text in group:
1131-
print(text.x, text.y, text.glyph,
1132-
chr(text.glyph) if chr(text.glyph).isprintable()
1133-
else ".",
1134-
text.width, sep="\t")
1175+
if font_name.startswith("["):
1176+
glyph_name = face.get_glyph_name(text.glyph)
1177+
else:
1178+
if isinstance(text.glyph_name_or_index, str):
1179+
glyph_name = text.glyph_name_or_index
1180+
else:
1181+
textpath.TextToPath._select_native_charmap(face)
1182+
glyph_name = face.get_glyph_name(
1183+
face.get_char_index(text.glyph))
1184+
glyph_str = fontTools.agl.toUnicode(glyph_name)
1185+
print(" ".join(map("{:>11}".format, [
1186+
text.x, text.y, text.glyph, glyph_str, text.width])))
11351187
if page.boxes:
1136-
print("x", "y", "h", "w", "", "(boxes)", sep="\t")
1188+
print("--- BOXES ---")
1189+
print(" ".join(map("{:>11}".format, ["x", "y", "h", "w"])))
11371190
for box in page.boxes:
1138-
print(box.x, box.y, box.height, box.width, sep="\t")
1191+
print(" ".join(map("{:>11}".format, [
1192+
box.x, box.y, box.height, box.width])))

lib/matplotlib/mpl-data/matplotlibrc

+1
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,7 @@
322322
# zapf chancery, charter, serif, sans-serif, helvetica,
323323
# avant garde, courier, monospace, computer modern roman,
324324
# computer modern sans serif, computer modern typewriter
325+
#text.latex.engine: latex
325326
#text.latex.preamble: # IMPROPER USE OF THIS FEATURE WILL LEAD TO LATEX FAILURES
326327
# AND IS THEREFORE UNSUPPORTED. PLEASE DO NOT ASK FOR HELP
327328
# IF THIS FEATURE DOES NOT DO WHAT YOU EXPECT IT TO.

lib/matplotlib/rcsetup.py

+1
Original file line numberDiff line numberDiff line change
@@ -1031,6 +1031,7 @@ def _convert_validator_spec(key, conv):
10311031
# text props
10321032
"text.color": validate_color,
10331033
"text.usetex": validate_bool,
1034+
"text.latex.engine": ["latex", "lualatex"],
10341035
"text.latex.preamble": validate_string,
10351036
"text.hinting": ["default", "no_autohint", "force_autohint",
10361037
"no_hinting", "auto", "native", "either", "none"],

0 commit comments

Comments
 (0)