1"""
2Implementation details for :mod:`.mathtext`.
3"""
4
5from __future__ import annotations
6
7import abc
8import copy
9import enum
10import functools
11import logging
12import os
13import re
14import types
15import unicodedata
16import string
17import typing as T
18from typing import NamedTuple
19
20import numpy as np
21from pyparsing import (
22 Empty, Forward, Literal, NotAny, oneOf, OneOrMore, Optional,
23 ParseBaseException, ParseException, ParseExpression, ParseFatalException,
24 ParserElement, ParseResults, QuotedString, Regex, StringEnd, ZeroOrMore,
25 pyparsing_common, Group)
26
27import matplotlib as mpl
28from . import cbook
29from ._mathtext_data import (
30 latex_to_bakoma, stix_glyph_fixes, stix_virtual_fonts, tex2uni)
31from .font_manager import FontProperties, findfont, get_font
32from .ft2font import FT2Font, FT2Image, KERNING_DEFAULT
33
34from packaging.version import parse as parse_version
35from pyparsing import __version__ as pyparsing_version
36if parse_version(pyparsing_version).major < 3:
37 from pyparsing import nestedExpr as nested_expr
38else:
39 from pyparsing import nested_expr
40
41if T.TYPE_CHECKING:
42 from collections.abc import Iterable
43 from .ft2font import Glyph
44
45ParserElement.enablePackrat()
46_log = logging.getLogger("matplotlib.mathtext")
47
48
49##############################################################################
50# FONTS
51
52
53def get_unicode_index(symbol: str) -> int: # Publicly exported.
54 r"""
55 Return the integer index (from the Unicode table) of *symbol*.
56
57 Parameters
58 ----------
59 symbol : str
60 A single (Unicode) character, a TeX command (e.g. r'\pi') or a Type1
61 symbol name (e.g. 'phi').
62 """
63 try: # This will succeed if symbol is a single Unicode char
64 return ord(symbol)
65 except TypeError:
66 pass
67 try: # Is symbol a TeX symbol (i.e. \alpha)
68 return tex2uni[symbol.strip("\\")]
69 except KeyError as err:
70 raise ValueError(
71 f"{symbol!r} is not a valid Unicode character or TeX/Type1 symbol"
72 ) from err
73
74
75class VectorParse(NamedTuple):
76 """
77 The namedtuple type returned by ``MathTextParser("path").parse(...)``.
78
79 Attributes
80 ----------
81 width, height, depth : float
82 The global metrics.
83 glyphs : list
84 The glyphs including their positions.
85 rect : list
86 The list of rectangles.
87 """
88 width: float
89 height: float
90 depth: float
91 glyphs: list[tuple[FT2Font, float, int, float, float]]
92 rects: list[tuple[float, float, float, float]]
93
94VectorParse.__module__ = "matplotlib.mathtext"
95
96
97class RasterParse(NamedTuple):
98 """
99 The namedtuple type returned by ``MathTextParser("agg").parse(...)``.
100
101 Attributes
102 ----------
103 ox, oy : float
104 The offsets are always zero.
105 width, height, depth : float
106 The global metrics.
107 image : FT2Image
108 A raster image.
109 """
110 ox: float
111 oy: float
112 width: float
113 height: float
114 depth: float
115 image: FT2Image
116
117RasterParse.__module__ = "matplotlib.mathtext"
118
119
120class Output:
121 r"""
122 Result of `ship`\ping a box: lists of positioned glyphs and rectangles.
123
124 This class is not exposed to end users, but converted to a `VectorParse` or
125 a `RasterParse` by `.MathTextParser.parse`.
126 """
127
128 def __init__(self, box: Box):
129 self.box = box
130 self.glyphs: list[tuple[float, float, FontInfo]] = [] # (ox, oy, info)
131 self.rects: list[tuple[float, float, float, float]] = [] # (x1, y1, x2, y2)
132
133 def to_vector(self) -> VectorParse:
134 w, h, d = map(
135 np.ceil, [self.box.width, self.box.height, self.box.depth])
136 gs = [(info.font, info.fontsize, info.num, ox, h - oy + info.offset)
137 for ox, oy, info in self.glyphs]
138 rs = [(x1, h - y2, x2 - x1, y2 - y1)
139 for x1, y1, x2, y2 in self.rects]
140 return VectorParse(w, h + d, d, gs, rs)
141
142 def to_raster(self, *, antialiased: bool) -> RasterParse:
143 # Metrics y's and mathtext y's are oriented in opposite directions,
144 # hence the switch between ymin and ymax.
145 xmin = min([*[ox + info.metrics.xmin for ox, oy, info in self.glyphs],
146 *[x1 for x1, y1, x2, y2 in self.rects], 0]) - 1
147 ymin = min([*[oy - info.metrics.ymax for ox, oy, info in self.glyphs],
148 *[y1 for x1, y1, x2, y2 in self.rects], 0]) - 1
149 xmax = max([*[ox + info.metrics.xmax for ox, oy, info in self.glyphs],
150 *[x2 for x1, y1, x2, y2 in self.rects], 0]) + 1
151 ymax = max([*[oy - info.metrics.ymin for ox, oy, info in self.glyphs],
152 *[y2 for x1, y1, x2, y2 in self.rects], 0]) + 1
153 w = xmax - xmin
154 h = ymax - ymin - self.box.depth
155 d = ymax - ymin - self.box.height
156 image = FT2Image(np.ceil(w), np.ceil(h + max(d, 0)))
157
158 # Ideally, we could just use self.glyphs and self.rects here, shifting
159 # their coordinates by (-xmin, -ymin), but this yields slightly
160 # different results due to floating point slop; shipping twice is the
161 # old approach and keeps baseline images backcompat.
162 shifted = ship(self.box, (-xmin, -ymin))
163
164 for ox, oy, info in shifted.glyphs:
165 info.font.draw_glyph_to_bitmap(
166 image, ox, oy - info.metrics.iceberg, info.glyph,
167 antialiased=antialiased)
168 for x1, y1, x2, y2 in shifted.rects:
169 height = max(int(y2 - y1) - 1, 0)
170 if height == 0:
171 center = (y2 + y1) / 2
172 y = int(center - (height + 1) / 2)
173 else:
174 y = int(y1)
175 image.draw_rect_filled(int(x1), y, np.ceil(x2), y + height)
176 return RasterParse(0, 0, w, h + d, d, image)
177
178
179class FontMetrics(NamedTuple):
180 """
181 Metrics of a font.
182
183 Attributes
184 ----------
185 advance : float
186 The advance distance (in points) of the glyph.
187 height : float
188 The height of the glyph in points.
189 width : float
190 The width of the glyph in points.
191 xmin, xmax, ymin, ymax : float
192 The ink rectangle of the glyph.
193 iceberg : float
194 The distance from the baseline to the top of the glyph. (This corresponds to
195 TeX's definition of "height".)
196 slanted : bool
197 Whether the glyph should be considered as "slanted" (currently used for kerning
198 sub/superscripts).
199 """
200 advance: float
201 height: float
202 width: float
203 xmin: float
204 xmax: float
205 ymin: float
206 ymax: float
207 iceberg: float
208 slanted: bool
209
210
211class FontInfo(NamedTuple):
212 font: FT2Font
213 fontsize: float
214 postscript_name: str
215 metrics: FontMetrics
216 num: int
217 glyph: Glyph
218 offset: float
219
220
221class Fonts(abc.ABC):
222 """
223 An abstract base class for a system of fonts to use for mathtext.
224
225 The class must be able to take symbol keys and font file names and
226 return the character metrics. It also delegates to a backend class
227 to do the actual drawing.
228 """
229
230 def __init__(self, default_font_prop: FontProperties, load_glyph_flags: int):
231 """
232 Parameters
233 ----------
234 default_font_prop : `~.font_manager.FontProperties`
235 The default non-math font, or the base font for Unicode (generic)
236 font rendering.
237 load_glyph_flags : int
238 Flags passed to the glyph loader (e.g. ``FT_Load_Glyph`` and
239 ``FT_Load_Char`` for FreeType-based fonts).
240 """
241 self.default_font_prop = default_font_prop
242 self.load_glyph_flags = load_glyph_flags
243
244 def get_kern(self, font1: str, fontclass1: str, sym1: str, fontsize1: float,
245 font2: str, fontclass2: str, sym2: str, fontsize2: float,
246 dpi: float) -> float:
247 """
248 Get the kerning distance for font between *sym1* and *sym2*.
249
250 See `~.Fonts.get_metrics` for a detailed description of the parameters.
251 """
252 return 0.
253
254 def _get_font(self, font: str) -> FT2Font:
255 raise NotImplementedError
256
257 def _get_info(self, font: str, font_class: str, sym: str, fontsize: float,
258 dpi: float) -> FontInfo:
259 raise NotImplementedError
260
261 def get_metrics(self, font: str, font_class: str, sym: str, fontsize: float,
262 dpi: float) -> FontMetrics:
263 r"""
264 Parameters
265 ----------
266 font : str
267 One of the TeX font names: "tt", "it", "rm", "cal", "sf", "bf",
268 "default", "regular", "bb", "frak", "scr". "default" and "regular"
269 are synonyms and use the non-math font.
270 font_class : str
271 One of the TeX font names (as for *font*), but **not** "bb",
272 "frak", or "scr". This is used to combine two font classes. The
273 only supported combination currently is ``get_metrics("frak", "bf",
274 ...)``.
275 sym : str
276 A symbol in raw TeX form, e.g., "1", "x", or "\sigma".
277 fontsize : float
278 Font size in points.
279 dpi : float
280 Rendering dots-per-inch.
281
282 Returns
283 -------
284 FontMetrics
285 """
286 info = self._get_info(font, font_class, sym, fontsize, dpi)
287 return info.metrics
288
289 def render_glyph(self, output: Output, ox: float, oy: float, font: str,
290 font_class: str, sym: str, fontsize: float, dpi: float) -> None:
291 """
292 At position (*ox*, *oy*), draw the glyph specified by the remaining
293 parameters (see `get_metrics` for their detailed description).
294 """
295 info = self._get_info(font, font_class, sym, fontsize, dpi)
296 output.glyphs.append((ox, oy, info))
297
298 def render_rect_filled(self, output: Output,
299 x1: float, y1: float, x2: float, y2: float) -> None:
300 """
301 Draw a filled rectangle from (*x1*, *y1*) to (*x2*, *y2*).
302 """
303 output.rects.append((x1, y1, x2, y2))
304
305 def get_xheight(self, font: str, fontsize: float, dpi: float) -> float:
306 """
307 Get the xheight for the given *font* and *fontsize*.
308 """
309 raise NotImplementedError()
310
311 def get_underline_thickness(self, font: str, fontsize: float, dpi: float) -> float:
312 """
313 Get the line thickness that matches the given font. Used as a
314 base unit for drawing lines such as in a fraction or radical.
315 """
316 raise NotImplementedError()
317
318 def get_sized_alternatives_for_symbol(self, fontname: str,
319 sym: str) -> list[tuple[str, str]]:
320 """
321 Override if your font provides multiple sizes of the same
322 symbol. Should return a list of symbols matching *sym* in
323 various sizes. The expression renderer will select the most
324 appropriate size for a given situation from this list.
325 """
326 return [(fontname, sym)]
327
328
329class TruetypeFonts(Fonts, metaclass=abc.ABCMeta):
330 """
331 A generic base class for all font setups that use Truetype fonts
332 (through FT2Font).
333 """
334
335 def __init__(self, default_font_prop: FontProperties, load_glyph_flags: int):
336 super().__init__(default_font_prop, load_glyph_flags)
337 # Per-instance cache.
338 self._get_info = functools.cache(self._get_info) # type: ignore[method-assign]
339 self._fonts = {}
340 self.fontmap: dict[str | int, str] = {}
341
342 filename = findfont(self.default_font_prop)
343 default_font = get_font(filename)
344 self._fonts['default'] = default_font
345 self._fonts['regular'] = default_font
346
347 def _get_font(self, font: str | int) -> FT2Font:
348 if font in self.fontmap:
349 basename = self.fontmap[font]
350 else:
351 # NOTE: An int is only passed by subclasses which have placed int keys into
352 # `self.fontmap`, so we must cast this to confirm it to typing.
353 basename = T.cast(str, font)
354 cached_font = self._fonts.get(basename)
355 if cached_font is None and os.path.exists(basename):
356 cached_font = get_font(basename)
357 self._fonts[basename] = cached_font
358 self._fonts[cached_font.postscript_name] = cached_font
359 self._fonts[cached_font.postscript_name.lower()] = cached_font
360 return T.cast(FT2Font, cached_font) # FIXME: Not sure this is guaranteed.
361
362 def _get_offset(self, font: FT2Font, glyph: Glyph, fontsize: float,
363 dpi: float) -> float:
364 if font.postscript_name == 'Cmex10':
365 return (glyph.height / 64 / 2) + (fontsize/3 * dpi/72)
366 return 0.
367
368 def _get_glyph(self, fontname: str, font_class: str,
369 sym: str) -> tuple[FT2Font, int, bool]:
370 raise NotImplementedError
371
372 # The return value of _get_info is cached per-instance.
373 def _get_info(self, fontname: str, font_class: str, sym: str, fontsize: float,
374 dpi: float) -> FontInfo:
375 font, num, slanted = self._get_glyph(fontname, font_class, sym)
376 font.set_size(fontsize, dpi)
377 glyph = font.load_char(num, flags=self.load_glyph_flags)
378
379 xmin, ymin, xmax, ymax = [val/64.0 for val in glyph.bbox]
380 offset = self._get_offset(font, glyph, fontsize, dpi)
381 metrics = FontMetrics(
382 advance = glyph.linearHoriAdvance/65536.0,
383 height = glyph.height/64.0,
384 width = glyph.width/64.0,
385 xmin = xmin,
386 xmax = xmax,
387 ymin = ymin+offset,
388 ymax = ymax+offset,
389 # iceberg is the equivalent of TeX's "height"
390 iceberg = glyph.horiBearingY/64.0 + offset,
391 slanted = slanted
392 )
393
394 return FontInfo(
395 font = font,
396 fontsize = fontsize,
397 postscript_name = font.postscript_name,
398 metrics = metrics,
399 num = num,
400 glyph = glyph,
401 offset = offset
402 )
403
404 def get_xheight(self, fontname: str, fontsize: float, dpi: float) -> float:
405 font = self._get_font(fontname)
406 font.set_size(fontsize, dpi)
407 pclt = font.get_sfnt_table('pclt')
408 if pclt is None:
409 # Some fonts don't store the xHeight, so we do a poor man's xHeight
410 metrics = self.get_metrics(
411 fontname, mpl.rcParams['mathtext.default'], 'x', fontsize, dpi)
412 return metrics.iceberg
413 xHeight = (pclt['xHeight'] / 64.0) * (fontsize / 12.0) * (dpi / 100.0)
414 return xHeight
415
416 def get_underline_thickness(self, font: str, fontsize: float, dpi: float) -> float:
417 # This function used to grab underline thickness from the font
418 # metrics, but that information is just too un-reliable, so it
419 # is now hardcoded.
420 return ((0.75 / 12.0) * fontsize * dpi) / 72.0
421
422 def get_kern(self, font1: str, fontclass1: str, sym1: str, fontsize1: float,
423 font2: str, fontclass2: str, sym2: str, fontsize2: float,
424 dpi: float) -> float:
425 if font1 == font2 and fontsize1 == fontsize2:
426 info1 = self._get_info(font1, fontclass1, sym1, fontsize1, dpi)
427 info2 = self._get_info(font2, fontclass2, sym2, fontsize2, dpi)
428 font = info1.font
429 return font.get_kerning(info1.num, info2.num, KERNING_DEFAULT) / 64
430 return super().get_kern(font1, fontclass1, sym1, fontsize1,
431 font2, fontclass2, sym2, fontsize2, dpi)
432
433
434class BakomaFonts(TruetypeFonts):
435 """
436 Use the Bakoma TrueType fonts for rendering.
437
438 Symbols are strewn about a number of font files, each of which has
439 its own proprietary 8-bit encoding.
440 """
441 _fontmap = {
442 'cal': 'cmsy10',
443 'rm': 'cmr10',
444 'tt': 'cmtt10',
445 'it': 'cmmi10',
446 'bf': 'cmb10',
447 'sf': 'cmss10',
448 'ex': 'cmex10',
449 }
450
451 def __init__(self, default_font_prop: FontProperties, load_glyph_flags: int):
452 self._stix_fallback = StixFonts(default_font_prop, load_glyph_flags)
453
454 super().__init__(default_font_prop, load_glyph_flags)
455 for key, val in self._fontmap.items():
456 fullpath = findfont(val)
457 self.fontmap[key] = fullpath
458 self.fontmap[val] = fullpath
459
460 _slanted_symbols = set(r"\int \oint".split())
461
462 def _get_glyph(self, fontname: str, font_class: str,
463 sym: str) -> tuple[FT2Font, int, bool]:
464 font = None
465 if fontname in self.fontmap and sym in latex_to_bakoma:
466 basename, num = latex_to_bakoma[sym]
467 slanted = (basename == "cmmi10") or sym in self._slanted_symbols
468 font = self._get_font(basename)
469 elif len(sym) == 1:
470 slanted = (fontname == "it")
471 font = self._get_font(fontname)
472 if font is not None:
473 num = ord(sym)
474 if font is not None and font.get_char_index(num) != 0:
475 return font, num, slanted
476 else:
477 return self._stix_fallback._get_glyph(fontname, font_class, sym)
478
479 # The Bakoma fonts contain many pre-sized alternatives for the
480 # delimiters. The AutoSizedChar class will use these alternatives
481 # and select the best (closest sized) glyph.
482 _size_alternatives = {
483 '(': [('rm', '('), ('ex', '\xa1'), ('ex', '\xb3'),
484 ('ex', '\xb5'), ('ex', '\xc3')],
485 ')': [('rm', ')'), ('ex', '\xa2'), ('ex', '\xb4'),
486 ('ex', '\xb6'), ('ex', '\x21')],
487 '{': [('cal', '{'), ('ex', '\xa9'), ('ex', '\x6e'),
488 ('ex', '\xbd'), ('ex', '\x28')],
489 '}': [('cal', '}'), ('ex', '\xaa'), ('ex', '\x6f'),
490 ('ex', '\xbe'), ('ex', '\x29')],
491 # The fourth size of '[' is mysteriously missing from the BaKoMa
492 # font, so I've omitted it for both '[' and ']'
493 '[': [('rm', '['), ('ex', '\xa3'), ('ex', '\x68'),
494 ('ex', '\x22')],
495 ']': [('rm', ']'), ('ex', '\xa4'), ('ex', '\x69'),
496 ('ex', '\x23')],
497 r'\lfloor': [('ex', '\xa5'), ('ex', '\x6a'),
498 ('ex', '\xb9'), ('ex', '\x24')],
499 r'\rfloor': [('ex', '\xa6'), ('ex', '\x6b'),
500 ('ex', '\xba'), ('ex', '\x25')],
501 r'\lceil': [('ex', '\xa7'), ('ex', '\x6c'),
502 ('ex', '\xbb'), ('ex', '\x26')],
503 r'\rceil': [('ex', '\xa8'), ('ex', '\x6d'),
504 ('ex', '\xbc'), ('ex', '\x27')],
505 r'\langle': [('ex', '\xad'), ('ex', '\x44'),
506 ('ex', '\xbf'), ('ex', '\x2a')],
507 r'\rangle': [('ex', '\xae'), ('ex', '\x45'),
508 ('ex', '\xc0'), ('ex', '\x2b')],
509 r'\__sqrt__': [('ex', '\x70'), ('ex', '\x71'),
510 ('ex', '\x72'), ('ex', '\x73')],
511 r'\backslash': [('ex', '\xb2'), ('ex', '\x2f'),
512 ('ex', '\xc2'), ('ex', '\x2d')],
513 r'/': [('rm', '/'), ('ex', '\xb1'), ('ex', '\x2e'),
514 ('ex', '\xcb'), ('ex', '\x2c')],
515 r'\widehat': [('rm', '\x5e'), ('ex', '\x62'), ('ex', '\x63'),
516 ('ex', '\x64')],
517 r'\widetilde': [('rm', '\x7e'), ('ex', '\x65'), ('ex', '\x66'),
518 ('ex', '\x67')],
519 r'<': [('cal', 'h'), ('ex', 'D')],
520 r'>': [('cal', 'i'), ('ex', 'E')]
521 }
522
523 for alias, target in [(r'\leftparen', '('),
524 (r'\rightparent', ')'),
525 (r'\leftbrace', '{'),
526 (r'\rightbrace', '}'),
527 (r'\leftbracket', '['),
528 (r'\rightbracket', ']'),
529 (r'\{', '{'),
530 (r'\}', '}'),
531 (r'\[', '['),
532 (r'\]', ']')]:
533 _size_alternatives[alias] = _size_alternatives[target]
534
535 def get_sized_alternatives_for_symbol(self, fontname: str,
536 sym: str) -> list[tuple[str, str]]:
537 return self._size_alternatives.get(sym, [(fontname, sym)])
538
539
540class UnicodeFonts(TruetypeFonts):
541 """
542 An abstract base class for handling Unicode fonts.
543
544 While some reasonably complete Unicode fonts (such as DejaVu) may
545 work in some situations, the only Unicode font I'm aware of with a
546 complete set of math symbols is STIX.
547
548 This class will "fallback" on the Bakoma fonts when a required
549 symbol cannot be found in the font.
550 """
551
552 # Some glyphs are not present in the `cmr10` font, and must be brought in
553 # from `cmsy10`. Map the Unicode indices of those glyphs to the indices at
554 # which they are found in `cmsy10`.
555 _cmr10_substitutions = {
556 0x00D7: 0x00A3, # Multiplication sign.
557 0x2212: 0x00A1, # Minus sign.
558 }
559
560 def __init__(self, default_font_prop: FontProperties, load_glyph_flags: int):
561 # This must come first so the backend's owner is set correctly
562 fallback_rc = mpl.rcParams['mathtext.fallback']
563 font_cls: type[TruetypeFonts] | None = {
564 'stix': StixFonts,
565 'stixsans': StixSansFonts,
566 'cm': BakomaFonts
567 }.get(fallback_rc)
568 self._fallback_font = (font_cls(default_font_prop, load_glyph_flags)
569 if font_cls else None)
570
571 super().__init__(default_font_prop, load_glyph_flags)
572 for texfont in "cal rm tt it bf sf bfit".split():
573 prop = mpl.rcParams['mathtext.' + texfont]
574 font = findfont(prop)
575 self.fontmap[texfont] = font
576 prop = FontProperties('cmex10')
577 font = findfont(prop)
578 self.fontmap['ex'] = font
579
580 # include STIX sized alternatives for glyphs if fallback is STIX
581 if isinstance(self._fallback_font, StixFonts):
582 stixsizedaltfonts = {
583 0: 'STIXGeneral',
584 1: 'STIXSizeOneSym',
585 2: 'STIXSizeTwoSym',
586 3: 'STIXSizeThreeSym',
587 4: 'STIXSizeFourSym',
588 5: 'STIXSizeFiveSym'}
589
590 for size, name in stixsizedaltfonts.items():
591 fullpath = findfont(name)
592 self.fontmap[size] = fullpath
593 self.fontmap[name] = fullpath
594
595 _slanted_symbols = set(r"\int \oint".split())
596
597 def _map_virtual_font(self, fontname: str, font_class: str,
598 uniindex: int) -> tuple[str, int]:
599 return fontname, uniindex
600
601 def _get_glyph(self, fontname: str, font_class: str,
602 sym: str) -> tuple[FT2Font, int, bool]:
603 try:
604 uniindex = get_unicode_index(sym)
605 found_symbol = True
606 except ValueError:
607 uniindex = ord('?')
608 found_symbol = False
609 _log.warning("No TeX to Unicode mapping for %a.", sym)
610
611 fontname, uniindex = self._map_virtual_font(
612 fontname, font_class, uniindex)
613
614 new_fontname = fontname
615
616 # Only characters in the "Letter" class should be italicized in 'it'
617 # mode. Greek capital letters should be Roman.
618 if found_symbol:
619 if fontname == 'it' and uniindex < 0x10000:
620 char = chr(uniindex)
621 if (unicodedata.category(char)[0] != "L"
622 or unicodedata.name(char).startswith("GREEK CAPITAL")):
623 new_fontname = 'rm'
624
625 slanted = (new_fontname == 'it') or sym in self._slanted_symbols
626 found_symbol = False
627 font = self._get_font(new_fontname)
628 if font is not None:
629 if (uniindex in self._cmr10_substitutions
630 and font.family_name == "cmr10"):
631 font = get_font(
632 cbook._get_data_path("fonts/ttf/cmsy10.ttf"))
633 uniindex = self._cmr10_substitutions[uniindex]
634 glyphindex = font.get_char_index(uniindex)
635 if glyphindex != 0:
636 found_symbol = True
637
638 if not found_symbol:
639 if self._fallback_font:
640 if (fontname in ('it', 'regular')
641 and isinstance(self._fallback_font, StixFonts)):
642 fontname = 'rm'
643
644 g = self._fallback_font._get_glyph(fontname, font_class, sym)
645 family = g[0].family_name
646 if family in list(BakomaFonts._fontmap.values()):
647 family = "Computer Modern"
648 _log.info("Substituting symbol %s from %s", sym, family)
649 return g
650
651 else:
652 if (fontname in ('it', 'regular')
653 and isinstance(self, StixFonts)):
654 return self._get_glyph('rm', font_class, sym)
655 _log.warning("Font %r does not have a glyph for %a [U+%x], "
656 "substituting with a dummy symbol.",
657 new_fontname, sym, uniindex)
658 font = self._get_font('rm')
659 uniindex = 0xA4 # currency char, for lack of anything better
660 slanted = False
661
662 return font, uniindex, slanted
663
664 def get_sized_alternatives_for_symbol(self, fontname: str,
665 sym: str) -> list[tuple[str, str]]:
666 if self._fallback_font:
667 return self._fallback_font.get_sized_alternatives_for_symbol(
668 fontname, sym)
669 return [(fontname, sym)]
670
671
672class DejaVuFonts(UnicodeFonts, metaclass=abc.ABCMeta):
673 _fontmap: dict[str | int, str] = {}
674
675 def __init__(self, default_font_prop: FontProperties, load_glyph_flags: int):
676 # This must come first so the backend's owner is set correctly
677 if isinstance(self, DejaVuSerifFonts):
678 self._fallback_font = StixFonts(default_font_prop, load_glyph_flags)
679 else:
680 self._fallback_font = StixSansFonts(default_font_prop, load_glyph_flags)
681 self.bakoma = BakomaFonts(default_font_prop, load_glyph_flags)
682 TruetypeFonts.__init__(self, default_font_prop, load_glyph_flags)
683 # Include Stix sized alternatives for glyphs
684 self._fontmap.update({
685 1: 'STIXSizeOneSym',
686 2: 'STIXSizeTwoSym',
687 3: 'STIXSizeThreeSym',
688 4: 'STIXSizeFourSym',
689 5: 'STIXSizeFiveSym',
690 })
691 for key, name in self._fontmap.items():
692 fullpath = findfont(name)
693 self.fontmap[key] = fullpath
694 self.fontmap[name] = fullpath
695
696 def _get_glyph(self, fontname: str, font_class: str,
697 sym: str) -> tuple[FT2Font, int, bool]:
698 # Override prime symbol to use Bakoma.
699 if sym == r'\prime':
700 return self.bakoma._get_glyph(fontname, font_class, sym)
701 else:
702 # check whether the glyph is available in the display font
703 uniindex = get_unicode_index(sym)
704 font = self._get_font('ex')
705 if font is not None:
706 glyphindex = font.get_char_index(uniindex)
707 if glyphindex != 0:
708 return super()._get_glyph('ex', font_class, sym)
709 # otherwise return regular glyph
710 return super()._get_glyph(fontname, font_class, sym)
711
712
713class DejaVuSerifFonts(DejaVuFonts):
714 """
715 A font handling class for the DejaVu Serif fonts
716
717 If a glyph is not found it will fallback to Stix Serif
718 """
719 _fontmap = {
720 'rm': 'DejaVu Serif',
721 'it': 'DejaVu Serif:italic',
722 'bf': 'DejaVu Serif:weight=bold',
723 'bfit': 'DejaVu Serif:italic:bold',
724 'sf': 'DejaVu Sans',
725 'tt': 'DejaVu Sans Mono',
726 'ex': 'DejaVu Serif Display',
727 0: 'DejaVu Serif',
728 }
729
730
731class DejaVuSansFonts(DejaVuFonts):
732 """
733 A font handling class for the DejaVu Sans fonts
734
735 If a glyph is not found it will fallback to Stix Sans
736 """
737 _fontmap = {
738 'rm': 'DejaVu Sans',
739 'it': 'DejaVu Sans:italic',
740 'bf': 'DejaVu Sans:weight=bold',
741 'bfit': 'DejaVu Sans:italic:bold',
742 'sf': 'DejaVu Sans',
743 'tt': 'DejaVu Sans Mono',
744 'ex': 'DejaVu Sans Display',
745 0: 'DejaVu Sans',
746 }
747
748
749class StixFonts(UnicodeFonts):
750 """
751 A font handling class for the STIX fonts.
752
753 In addition to what UnicodeFonts provides, this class:
754
755 - supports "virtual fonts" which are complete alpha numeric
756 character sets with different font styles at special Unicode
757 code points, such as "Blackboard".
758
759 - handles sized alternative characters for the STIXSizeX fonts.
760 """
761 _fontmap: dict[str | int, str] = {
762 'rm': 'STIXGeneral',
763 'it': 'STIXGeneral:italic',
764 'bf': 'STIXGeneral:weight=bold',
765 'bfit': 'STIXGeneral:italic:bold',
766 'nonunirm': 'STIXNonUnicode',
767 'nonuniit': 'STIXNonUnicode:italic',
768 'nonunibf': 'STIXNonUnicode:weight=bold',
769 0: 'STIXGeneral',
770 1: 'STIXSizeOneSym',
771 2: 'STIXSizeTwoSym',
772 3: 'STIXSizeThreeSym',
773 4: 'STIXSizeFourSym',
774 5: 'STIXSizeFiveSym',
775 }
776 _fallback_font = None
777 _sans = False
778
779 def __init__(self, default_font_prop: FontProperties, load_glyph_flags: int):
780 TruetypeFonts.__init__(self, default_font_prop, load_glyph_flags)
781 for key, name in self._fontmap.items():
782 fullpath = findfont(name)
783 self.fontmap[key] = fullpath
784 self.fontmap[name] = fullpath
785
786 def _map_virtual_font(self, fontname: str, font_class: str,
787 uniindex: int) -> tuple[str, int]:
788 # Handle these "fonts" that are actually embedded in
789 # other fonts.
790 font_mapping = stix_virtual_fonts.get(fontname)
791 if (self._sans and font_mapping is None
792 and fontname not in ('regular', 'default')):
793 font_mapping = stix_virtual_fonts['sf']
794 doing_sans_conversion = True
795 else:
796 doing_sans_conversion = False
797
798 if isinstance(font_mapping, dict):
799 try:
800 mapping = font_mapping[font_class]
801 except KeyError:
802 mapping = font_mapping['rm']
803 elif isinstance(font_mapping, list):
804 mapping = font_mapping
805 else:
806 mapping = None
807
808 if mapping is not None:
809 # Binary search for the source glyph
810 lo = 0
811 hi = len(mapping)
812 while lo < hi:
813 mid = (lo+hi)//2
814 range = mapping[mid]
815 if uniindex < range[0]:
816 hi = mid
817 elif uniindex <= range[1]:
818 break
819 else:
820 lo = mid + 1
821
822 if range[0] <= uniindex <= range[1]:
823 uniindex = uniindex - range[0] + range[3]
824 fontname = range[2]
825 elif not doing_sans_conversion:
826 # This will generate a dummy character
827 uniindex = 0x1
828 fontname = mpl.rcParams['mathtext.default']
829
830 # Fix some incorrect glyphs.
831 if fontname in ('rm', 'it'):
832 uniindex = stix_glyph_fixes.get(uniindex, uniindex)
833
834 # Handle private use area glyphs
835 if fontname in ('it', 'rm', 'bf', 'bfit') and 0xe000 <= uniindex <= 0xf8ff:
836 fontname = 'nonuni' + fontname
837
838 return fontname, uniindex
839
840 @functools.cache
841 def get_sized_alternatives_for_symbol( # type: ignore[override]
842 self,
843 fontname: str,
844 sym: str) -> list[tuple[str, str]] | list[tuple[int, str]]:
845 fixes = {
846 '\\{': '{', '\\}': '}', '\\[': '[', '\\]': ']',
847 '<': '\N{MATHEMATICAL LEFT ANGLE BRACKET}',
848 '>': '\N{MATHEMATICAL RIGHT ANGLE BRACKET}',
849 }
850 sym = fixes.get(sym, sym)
851 try:
852 uniindex = get_unicode_index(sym)
853 except ValueError:
854 return [(fontname, sym)]
855 alternatives = [(i, chr(uniindex)) for i in range(6)
856 if self._get_font(i).get_char_index(uniindex) != 0]
857 # The largest size of the radical symbol in STIX has incorrect
858 # metrics that cause it to be disconnected from the stem.
859 if sym == r'\__sqrt__':
860 alternatives = alternatives[:-1]
861 return alternatives
862
863
864class StixSansFonts(StixFonts):
865 """
866 A font handling class for the STIX fonts (that uses sans-serif
867 characters by default).
868 """
869 _sans = True
870
871
872##############################################################################
873# TeX-LIKE BOX MODEL
874
875# The following is based directly on the document 'woven' from the
876# TeX82 source code. This information is also available in printed
877# form:
878#
879# Knuth, Donald E.. 1986. Computers and Typesetting, Volume B:
880# TeX: The Program. Addison-Wesley Professional.
881#
882# The most relevant "chapters" are:
883# Data structures for boxes and their friends
884# Shipping pages out (ship())
885# Packaging (hpack() and vpack())
886# Data structures for math mode
887# Subroutines for math mode
888# Typesetting math formulas
889#
890# Many of the docstrings below refer to a numbered "node" in that
891# book, e.g., node123
892#
893# Note that (as TeX) y increases downward, unlike many other parts of
894# matplotlib.
895
896# How much text shrinks when going to the next-smallest level.
897SHRINK_FACTOR = 0.7
898# The number of different sizes of chars to use, beyond which they will not
899# get any smaller
900NUM_SIZE_LEVELS = 6
901
902
903class FontConstantsBase:
904 """
905 A set of constants that controls how certain things, such as sub-
906 and superscripts are laid out. These are all metrics that can't
907 be reliably retrieved from the font metrics in the font itself.
908 """
909 # Percentage of x-height of additional horiz. space after sub/superscripts
910 script_space: T.ClassVar[float] = 0.05
911
912 # Percentage of x-height that sub/superscripts drop below the baseline
913 subdrop: T.ClassVar[float] = 0.4
914
915 # Percentage of x-height that superscripts are raised from the baseline
916 sup1: T.ClassVar[float] = 0.7
917
918 # Percentage of x-height that subscripts drop below the baseline
919 sub1: T.ClassVar[float] = 0.3
920
921 # Percentage of x-height that subscripts drop below the baseline when a
922 # superscript is present
923 sub2: T.ClassVar[float] = 0.5
924
925 # Percentage of x-height that sub/superscripts are offset relative to the
926 # nucleus edge for non-slanted nuclei
927 delta: T.ClassVar[float] = 0.025
928
929 # Additional percentage of last character height above 2/3 of the
930 # x-height that superscripts are offset relative to the subscript
931 # for slanted nuclei
932 delta_slanted: T.ClassVar[float] = 0.2
933
934 # Percentage of x-height that superscripts and subscripts are offset for
935 # integrals
936 delta_integral: T.ClassVar[float] = 0.1
937
938
939class ComputerModernFontConstants(FontConstantsBase):
940 script_space = 0.075
941 subdrop = 0.2
942 sup1 = 0.45
943 sub1 = 0.2
944 sub2 = 0.3
945 delta = 0.075
946 delta_slanted = 0.3
947 delta_integral = 0.3
948
949
950class STIXFontConstants(FontConstantsBase):
951 script_space = 0.1
952 sup1 = 0.8
953 sub2 = 0.6
954 delta = 0.05
955 delta_slanted = 0.3
956 delta_integral = 0.3
957
958
959class STIXSansFontConstants(FontConstantsBase):
960 script_space = 0.05
961 sup1 = 0.8
962 delta_slanted = 0.6
963 delta_integral = 0.3
964
965
966class DejaVuSerifFontConstants(FontConstantsBase):
967 pass
968
969
970class DejaVuSansFontConstants(FontConstantsBase):
971 pass
972
973
974# Maps font family names to the FontConstantBase subclass to use
975_font_constant_mapping = {
976 'DejaVu Sans': DejaVuSansFontConstants,
977 'DejaVu Sans Mono': DejaVuSansFontConstants,
978 'DejaVu Serif': DejaVuSerifFontConstants,
979 'cmb10': ComputerModernFontConstants,
980 'cmex10': ComputerModernFontConstants,
981 'cmmi10': ComputerModernFontConstants,
982 'cmr10': ComputerModernFontConstants,
983 'cmss10': ComputerModernFontConstants,
984 'cmsy10': ComputerModernFontConstants,
985 'cmtt10': ComputerModernFontConstants,
986 'STIXGeneral': STIXFontConstants,
987 'STIXNonUnicode': STIXFontConstants,
988 'STIXSizeFiveSym': STIXFontConstants,
989 'STIXSizeFourSym': STIXFontConstants,
990 'STIXSizeThreeSym': STIXFontConstants,
991 'STIXSizeTwoSym': STIXFontConstants,
992 'STIXSizeOneSym': STIXFontConstants,
993 # Map the fonts we used to ship, just for good measure
994 'Bitstream Vera Sans': DejaVuSansFontConstants,
995 'Bitstream Vera': DejaVuSansFontConstants,
996 }
997
998
999def _get_font_constant_set(state: ParserState) -> type[FontConstantsBase]:
1000 constants = _font_constant_mapping.get(
1001 state.fontset._get_font(state.font).family_name, FontConstantsBase)
1002 # STIX sans isn't really its own fonts, just different code points
1003 # in the STIX fonts, so we have to detect this one separately.
1004 if (constants is STIXFontConstants and
1005 isinstance(state.fontset, StixSansFonts)):
1006 return STIXSansFontConstants
1007 return constants
1008
1009
1010class Node:
1011 """A node in the TeX box model."""
1012
1013 def __init__(self) -> None:
1014 self.size = 0
1015
1016 def __repr__(self) -> str:
1017 return type(self).__name__
1018
1019 def get_kerning(self, next: Node | None) -> float:
1020 return 0.0
1021
1022 def shrink(self) -> None:
1023 """
1024 Shrinks one level smaller. There are only three levels of
1025 sizes, after which things will no longer get smaller.
1026 """
1027 self.size += 1
1028
1029 def render(self, output: Output, x: float, y: float) -> None:
1030 """Render this node."""
1031
1032
1033class Box(Node):
1034 """A node with a physical location."""
1035
1036 def __init__(self, width: float, height: float, depth: float) -> None:
1037 super().__init__()
1038 self.width = width
1039 self.height = height
1040 self.depth = depth
1041
1042 def shrink(self) -> None:
1043 super().shrink()
1044 if self.size < NUM_SIZE_LEVELS:
1045 self.width *= SHRINK_FACTOR
1046 self.height *= SHRINK_FACTOR
1047 self.depth *= SHRINK_FACTOR
1048
1049 def render(self, output: Output, # type: ignore[override]
1050 x1: float, y1: float, x2: float, y2: float) -> None:
1051 pass
1052
1053
1054class Vbox(Box):
1055 """A box with only height (zero width)."""
1056
1057 def __init__(self, height: float, depth: float):
1058 super().__init__(0., height, depth)
1059
1060
1061class Hbox(Box):
1062 """A box with only width (zero height and depth)."""
1063
1064 def __init__(self, width: float):
1065 super().__init__(width, 0., 0.)
1066
1067
1068class Char(Node):
1069 """
1070 A single character.
1071
1072 Unlike TeX, the font information and metrics are stored with each `Char`
1073 to make it easier to lookup the font metrics when needed. Note that TeX
1074 boxes have a width, height, and depth, unlike Type1 and TrueType which use
1075 a full bounding box and an advance in the x-direction. The metrics must
1076 be converted to the TeX model, and the advance (if different from width)
1077 must be converted into a `Kern` node when the `Char` is added to its parent
1078 `Hlist`.
1079 """
1080
1081 def __init__(self, c: str, state: ParserState):
1082 super().__init__()
1083 self.c = c
1084 self.fontset = state.fontset
1085 self.font = state.font
1086 self.font_class = state.font_class
1087 self.fontsize = state.fontsize
1088 self.dpi = state.dpi
1089 # The real width, height and depth will be set during the
1090 # pack phase, after we know the real fontsize
1091 self._update_metrics()
1092
1093 def __repr__(self) -> str:
1094 return '`%s`' % self.c
1095
1096 def _update_metrics(self) -> None:
1097 metrics = self._metrics = self.fontset.get_metrics(
1098 self.font, self.font_class, self.c, self.fontsize, self.dpi)
1099 if self.c == ' ':
1100 self.width = metrics.advance
1101 else:
1102 self.width = metrics.width
1103 self.height = metrics.iceberg
1104 self.depth = -(metrics.iceberg - metrics.height)
1105
1106 def is_slanted(self) -> bool:
1107 return self._metrics.slanted
1108
1109 def get_kerning(self, next: Node | None) -> float:
1110 """
1111 Return the amount of kerning between this and the given character.
1112
1113 This method is called when characters are strung together into `Hlist`
1114 to create `Kern` nodes.
1115 """
1116 advance = self._metrics.advance - self.width
1117 kern = 0.
1118 if isinstance(next, Char):
1119 kern = self.fontset.get_kern(
1120 self.font, self.font_class, self.c, self.fontsize,
1121 next.font, next.font_class, next.c, next.fontsize,
1122 self.dpi)
1123 return advance + kern
1124
1125 def render(self, output: Output, x: float, y: float) -> None:
1126 self.fontset.render_glyph(
1127 output, x, y,
1128 self.font, self.font_class, self.c, self.fontsize, self.dpi)
1129
1130 def shrink(self) -> None:
1131 super().shrink()
1132 if self.size < NUM_SIZE_LEVELS:
1133 self.fontsize *= SHRINK_FACTOR
1134 self.width *= SHRINK_FACTOR
1135 self.height *= SHRINK_FACTOR
1136 self.depth *= SHRINK_FACTOR
1137
1138
1139class Accent(Char):
1140 """
1141 The font metrics need to be dealt with differently for accents,
1142 since they are already offset correctly from the baseline in
1143 TrueType fonts.
1144 """
1145 def _update_metrics(self) -> None:
1146 metrics = self._metrics = self.fontset.get_metrics(
1147 self.font, self.font_class, self.c, self.fontsize, self.dpi)
1148 self.width = metrics.xmax - metrics.xmin
1149 self.height = metrics.ymax - metrics.ymin
1150 self.depth = 0
1151
1152 def shrink(self) -> None:
1153 super().shrink()
1154 self._update_metrics()
1155
1156 def render(self, output: Output, x: float, y: float) -> None:
1157 self.fontset.render_glyph(
1158 output, x - self._metrics.xmin, y + self._metrics.ymin,
1159 self.font, self.font_class, self.c, self.fontsize, self.dpi)
1160
1161
1162class List(Box):
1163 """A list of nodes (either horizontal or vertical)."""
1164
1165 def __init__(self, elements: T.Sequence[Node]):
1166 super().__init__(0., 0., 0.)
1167 self.shift_amount = 0. # An arbitrary offset
1168 self.children = [*elements] # The child nodes of this list
1169 # The following parameters are set in the vpack and hpack functions
1170 self.glue_set = 0. # The glue setting of this list
1171 self.glue_sign = 0 # 0: normal, -1: shrinking, 1: stretching
1172 self.glue_order = 0 # The order of infinity (0 - 3) for the glue
1173
1174 def __repr__(self) -> str:
1175 return '{}<w={:.02f} h={:.02f} d={:.02f} s={:.02f}>[{}]'.format(
1176 super().__repr__(),
1177 self.width, self.height,
1178 self.depth, self.shift_amount,
1179 ', '.join([repr(x) for x in self.children]))
1180
1181 def _set_glue(self, x: float, sign: int, totals: list[float],
1182 error_type: str) -> None:
1183 self.glue_order = o = next(
1184 # Highest order of glue used by the members of this list.
1185 (i for i in range(len(totals))[::-1] if totals[i] != 0), 0)
1186 self.glue_sign = sign
1187 if totals[o] != 0.:
1188 self.glue_set = x / totals[o]
1189 else:
1190 self.glue_sign = 0
1191 self.glue_ratio = 0.
1192 if o == 0:
1193 if len(self.children):
1194 _log.warning("%s %s: %r",
1195 error_type, type(self).__name__, self)
1196
1197 def shrink(self) -> None:
1198 for child in self.children:
1199 child.shrink()
1200 super().shrink()
1201 if self.size < NUM_SIZE_LEVELS:
1202 self.shift_amount *= SHRINK_FACTOR
1203 self.glue_set *= SHRINK_FACTOR
1204
1205
1206class Hlist(List):
1207 """A horizontal list of boxes."""
1208
1209 def __init__(self, elements: T.Sequence[Node], w: float = 0.0,
1210 m: T.Literal['additional', 'exactly'] = 'additional',
1211 do_kern: bool = True):
1212 super().__init__(elements)
1213 if do_kern:
1214 self.kern()
1215 self.hpack(w=w, m=m)
1216
1217 def kern(self) -> None:
1218 """
1219 Insert `Kern` nodes between `Char` nodes to set kerning.
1220
1221 The `Char` nodes themselves determine the amount of kerning they need
1222 (in `~Char.get_kerning`), and this function just creates the correct
1223 linked list.
1224 """
1225 new_children = []
1226 num_children = len(self.children)
1227 if num_children:
1228 for i in range(num_children):
1229 elem = self.children[i]
1230 if i < num_children - 1:
1231 next = self.children[i + 1]
1232 else:
1233 next = None
1234
1235 new_children.append(elem)
1236 kerning_distance = elem.get_kerning(next)
1237 if kerning_distance != 0.:
1238 kern = Kern(kerning_distance)
1239 new_children.append(kern)
1240 self.children = new_children
1241
1242 def hpack(self, w: float = 0.0,
1243 m: T.Literal['additional', 'exactly'] = 'additional') -> None:
1244 r"""
1245 Compute the dimensions of the resulting boxes, and adjust the glue if
1246 one of those dimensions is pre-specified. The computed sizes normally
1247 enclose all of the material inside the new box; but some items may
1248 stick out if negative glue is used, if the box is overfull, or if a
1249 ``\vbox`` includes other boxes that have been shifted left.
1250
1251 Parameters
1252 ----------
1253 w : float, default: 0
1254 A width.
1255 m : {'exactly', 'additional'}, default: 'additional'
1256 Whether to produce a box whose width is 'exactly' *w*; or a box
1257 with the natural width of the contents, plus *w* ('additional').
1258
1259 Notes
1260 -----
1261 The defaults produce a box with the natural width of the contents.
1262 """
1263 # I don't know why these get reset in TeX. Shift_amount is pretty
1264 # much useless if we do.
1265 # self.shift_amount = 0.
1266 h = 0.
1267 d = 0.
1268 x = 0.
1269 total_stretch = [0.] * 4
1270 total_shrink = [0.] * 4
1271 for p in self.children:
1272 if isinstance(p, Char):
1273 x += p.width
1274 h = max(h, p.height)
1275 d = max(d, p.depth)
1276 elif isinstance(p, Box):
1277 x += p.width
1278 if not np.isinf(p.height) and not np.isinf(p.depth):
1279 s = getattr(p, 'shift_amount', 0.)
1280 h = max(h, p.height - s)
1281 d = max(d, p.depth + s)
1282 elif isinstance(p, Glue):
1283 glue_spec = p.glue_spec
1284 x += glue_spec.width
1285 total_stretch[glue_spec.stretch_order] += glue_spec.stretch
1286 total_shrink[glue_spec.shrink_order] += glue_spec.shrink
1287 elif isinstance(p, Kern):
1288 x += p.width
1289 self.height = h
1290 self.depth = d
1291
1292 if m == 'additional':
1293 w += x
1294 self.width = w
1295 x = w - x
1296
1297 if x == 0.:
1298 self.glue_sign = 0
1299 self.glue_order = 0
1300 self.glue_ratio = 0.
1301 return
1302 if x > 0.:
1303 self._set_glue(x, 1, total_stretch, "Overful")
1304 else:
1305 self._set_glue(x, -1, total_shrink, "Underful")
1306
1307
1308class Vlist(List):
1309 """A vertical list of boxes."""
1310
1311 def __init__(self, elements: T.Sequence[Node], h: float = 0.0,
1312 m: T.Literal['additional', 'exactly'] = 'additional'):
1313 super().__init__(elements)
1314 self.vpack(h=h, m=m)
1315
1316 def vpack(self, h: float = 0.0,
1317 m: T.Literal['additional', 'exactly'] = 'additional',
1318 l: float = np.inf) -> None:
1319 """
1320 Compute the dimensions of the resulting boxes, and to adjust the glue
1321 if one of those dimensions is pre-specified.
1322
1323 Parameters
1324 ----------
1325 h : float, default: 0
1326 A height.
1327 m : {'exactly', 'additional'}, default: 'additional'
1328 Whether to produce a box whose height is 'exactly' *h*; or a box
1329 with the natural height of the contents, plus *h* ('additional').
1330 l : float, default: np.inf
1331 The maximum height.
1332
1333 Notes
1334 -----
1335 The defaults produce a box with the natural height of the contents.
1336 """
1337 # I don't know why these get reset in TeX. Shift_amount is pretty
1338 # much useless if we do.
1339 # self.shift_amount = 0.
1340 w = 0.
1341 d = 0.
1342 x = 0.
1343 total_stretch = [0.] * 4
1344 total_shrink = [0.] * 4
1345 for p in self.children:
1346 if isinstance(p, Box):
1347 x += d + p.height
1348 d = p.depth
1349 if not np.isinf(p.width):
1350 s = getattr(p, 'shift_amount', 0.)
1351 w = max(w, p.width + s)
1352 elif isinstance(p, Glue):
1353 x += d
1354 d = 0.
1355 glue_spec = p.glue_spec
1356 x += glue_spec.width
1357 total_stretch[glue_spec.stretch_order] += glue_spec.stretch
1358 total_shrink[glue_spec.shrink_order] += glue_spec.shrink
1359 elif isinstance(p, Kern):
1360 x += d + p.width
1361 d = 0.
1362 elif isinstance(p, Char):
1363 raise RuntimeError(
1364 "Internal mathtext error: Char node found in Vlist")
1365
1366 self.width = w
1367 if d > l:
1368 x += d - l
1369 self.depth = l
1370 else:
1371 self.depth = d
1372
1373 if m == 'additional':
1374 h += x
1375 self.height = h
1376 x = h - x
1377
1378 if x == 0:
1379 self.glue_sign = 0
1380 self.glue_order = 0
1381 self.glue_ratio = 0.
1382 return
1383
1384 if x > 0.:
1385 self._set_glue(x, 1, total_stretch, "Overful")
1386 else:
1387 self._set_glue(x, -1, total_shrink, "Underful")
1388
1389
1390class Rule(Box):
1391 """
1392 A solid black rectangle.
1393
1394 It has *width*, *depth*, and *height* fields just as in an `Hlist`.
1395 However, if any of these dimensions is inf, the actual value will be
1396 determined by running the rule up to the boundary of the innermost
1397 enclosing box. This is called a "running dimension". The width is never
1398 running in an `Hlist`; the height and depth are never running in a `Vlist`.
1399 """
1400
1401 def __init__(self, width: float, height: float, depth: float, state: ParserState):
1402 super().__init__(width, height, depth)
1403 self.fontset = state.fontset
1404
1405 def render(self, output: Output, # type: ignore[override]
1406 x: float, y: float, w: float, h: float) -> None:
1407 self.fontset.render_rect_filled(output, x, y, x + w, y + h)
1408
1409
1410class Hrule(Rule):
1411 """Convenience class to create a horizontal rule."""
1412
1413 def __init__(self, state: ParserState, thickness: float | None = None):
1414 if thickness is None:
1415 thickness = state.get_current_underline_thickness()
1416 height = depth = thickness * 0.5
1417 super().__init__(np.inf, height, depth, state)
1418
1419
1420class Vrule(Rule):
1421 """Convenience class to create a vertical rule."""
1422
1423 def __init__(self, state: ParserState):
1424 thickness = state.get_current_underline_thickness()
1425 super().__init__(thickness, np.inf, np.inf, state)
1426
1427
1428class _GlueSpec(NamedTuple):
1429 width: float
1430 stretch: float
1431 stretch_order: int
1432 shrink: float
1433 shrink_order: int
1434
1435
1436_GlueSpec._named = { # type: ignore[attr-defined]
1437 'fil': _GlueSpec(0., 1., 1, 0., 0),
1438 'fill': _GlueSpec(0., 1., 2, 0., 0),
1439 'filll': _GlueSpec(0., 1., 3, 0., 0),
1440 'neg_fil': _GlueSpec(0., 0., 0, 1., 1),
1441 'neg_fill': _GlueSpec(0., 0., 0, 1., 2),
1442 'neg_filll': _GlueSpec(0., 0., 0, 1., 3),
1443 'empty': _GlueSpec(0., 0., 0, 0., 0),
1444 'ss': _GlueSpec(0., 1., 1, -1., 1),
1445}
1446
1447
1448class Glue(Node):
1449 """
1450 Most of the information in this object is stored in the underlying
1451 ``_GlueSpec`` class, which is shared between multiple glue objects.
1452 (This is a memory optimization which probably doesn't matter anymore, but
1453 it's easier to stick to what TeX does.)
1454 """
1455
1456 def __init__(self,
1457 glue_type: _GlueSpec | T.Literal["fil", "fill", "filll",
1458 "neg_fil", "neg_fill", "neg_filll",
1459 "empty", "ss"]):
1460 super().__init__()
1461 if isinstance(glue_type, str):
1462 glue_spec = _GlueSpec._named[glue_type] # type: ignore[attr-defined]
1463 elif isinstance(glue_type, _GlueSpec):
1464 glue_spec = glue_type
1465 else:
1466 raise ValueError("glue_type must be a glue spec name or instance")
1467 self.glue_spec = glue_spec
1468
1469 def shrink(self) -> None:
1470 super().shrink()
1471 if self.size < NUM_SIZE_LEVELS:
1472 g = self.glue_spec
1473 self.glue_spec = g._replace(width=g.width * SHRINK_FACTOR)
1474
1475
1476class HCentered(Hlist):
1477 """
1478 A convenience class to create an `Hlist` whose contents are
1479 centered within its enclosing box.
1480 """
1481
1482 def __init__(self, elements: list[Node]):
1483 super().__init__([Glue('ss'), *elements, Glue('ss')], do_kern=False)
1484
1485
1486class VCentered(Vlist):
1487 """
1488 A convenience class to create a `Vlist` whose contents are
1489 centered within its enclosing box.
1490 """
1491
1492 def __init__(self, elements: list[Node]):
1493 super().__init__([Glue('ss'), *elements, Glue('ss')])
1494
1495
1496class Kern(Node):
1497 """
1498 A `Kern` node has a width field to specify a (normally
1499 negative) amount of spacing. This spacing correction appears in
1500 horizontal lists between letters like A and V when the font
1501 designer said that it looks better to move them closer together or
1502 further apart. A kern node can also appear in a vertical list,
1503 when its *width* denotes additional spacing in the vertical
1504 direction.
1505 """
1506
1507 height = 0
1508 depth = 0
1509
1510 def __init__(self, width: float):
1511 super().__init__()
1512 self.width = width
1513
1514 def __repr__(self) -> str:
1515 return "k%.02f" % self.width
1516
1517 def shrink(self) -> None:
1518 super().shrink()
1519 if self.size < NUM_SIZE_LEVELS:
1520 self.width *= SHRINK_FACTOR
1521
1522
1523class AutoHeightChar(Hlist):
1524 """
1525 A character as close to the given height and depth as possible.
1526
1527 When using a font with multiple height versions of some characters (such as
1528 the BaKoMa fonts), the correct glyph will be selected, otherwise this will
1529 always just return a scaled version of the glyph.
1530 """
1531
1532 def __init__(self, c: str, height: float, depth: float, state: ParserState,
1533 always: bool = False, factor: float | None = None):
1534 alternatives = state.fontset.get_sized_alternatives_for_symbol(
1535 state.font, c)
1536
1537 xHeight = state.fontset.get_xheight(
1538 state.font, state.fontsize, state.dpi)
1539
1540 state = state.copy()
1541 target_total = height + depth
1542 for fontname, sym in alternatives:
1543 state.font = fontname
1544 char = Char(sym, state)
1545 # Ensure that size 0 is chosen when the text is regular sized but
1546 # with descender glyphs by subtracting 0.2 * xHeight
1547 if char.height + char.depth >= target_total - 0.2 * xHeight:
1548 break
1549
1550 shift = 0.0
1551 if state.font != 0 or len(alternatives) == 1:
1552 if factor is None:
1553 factor = target_total / (char.height + char.depth)
1554 state.fontsize *= factor
1555 char = Char(sym, state)
1556
1557 shift = (depth - char.depth)
1558
1559 super().__init__([char])
1560 self.shift_amount = shift
1561
1562
1563class AutoWidthChar(Hlist):
1564 """
1565 A character as close to the given width as possible.
1566
1567 When using a font with multiple width versions of some characters (such as
1568 the BaKoMa fonts), the correct glyph will be selected, otherwise this will
1569 always just return a scaled version of the glyph.
1570 """
1571
1572 def __init__(self, c: str, width: float, state: ParserState, always: bool = False,
1573 char_class: type[Char] = Char):
1574 alternatives = state.fontset.get_sized_alternatives_for_symbol(
1575 state.font, c)
1576
1577 state = state.copy()
1578 for fontname, sym in alternatives:
1579 state.font = fontname
1580 char = char_class(sym, state)
1581 if char.width >= width:
1582 break
1583
1584 factor = width / char.width
1585 state.fontsize *= factor
1586 char = char_class(sym, state)
1587
1588 super().__init__([char])
1589 self.width = char.width
1590
1591
1592def ship(box: Box, xy: tuple[float, float] = (0, 0)) -> Output:
1593 """
1594 Ship out *box* at offset *xy*, converting it to an `Output`.
1595
1596 Since boxes can be inside of boxes inside of boxes, the main work of `ship`
1597 is done by two mutually recursive routines, `hlist_out` and `vlist_out`,
1598 which traverse the `Hlist` nodes and `Vlist` nodes inside of horizontal
1599 and vertical boxes. The global variables used in TeX to store state as it
1600 processes have become local variables here.
1601 """
1602 ox, oy = xy
1603 cur_v = 0.
1604 cur_h = 0.
1605 off_h = ox
1606 off_v = oy + box.height
1607 output = Output(box)
1608
1609 def clamp(value: float) -> float:
1610 return -1e9 if value < -1e9 else +1e9 if value > +1e9 else value
1611
1612 def hlist_out(box: Hlist) -> None:
1613 nonlocal cur_v, cur_h, off_h, off_v
1614
1615 cur_g = 0
1616 cur_glue = 0.
1617 glue_order = box.glue_order
1618 glue_sign = box.glue_sign
1619 base_line = cur_v
1620 left_edge = cur_h
1621
1622 for p in box.children:
1623 if isinstance(p, Char):
1624 p.render(output, cur_h + off_h, cur_v + off_v)
1625 cur_h += p.width
1626 elif isinstance(p, Kern):
1627 cur_h += p.width
1628 elif isinstance(p, List):
1629 # node623
1630 if len(p.children) == 0:
1631 cur_h += p.width
1632 else:
1633 edge = cur_h
1634 cur_v = base_line + p.shift_amount
1635 if isinstance(p, Hlist):
1636 hlist_out(p)
1637 elif isinstance(p, Vlist):
1638 # p.vpack(box.height + box.depth, 'exactly')
1639 vlist_out(p)
1640 else:
1641 assert False, "unreachable code"
1642 cur_h = edge + p.width
1643 cur_v = base_line
1644 elif isinstance(p, Box):
1645 # node624
1646 rule_height = p.height
1647 rule_depth = p.depth
1648 rule_width = p.width
1649 if np.isinf(rule_height):
1650 rule_height = box.height
1651 if np.isinf(rule_depth):
1652 rule_depth = box.depth
1653 if rule_height > 0 and rule_width > 0:
1654 cur_v = base_line + rule_depth
1655 p.render(output,
1656 cur_h + off_h, cur_v + off_v,
1657 rule_width, rule_height)
1658 cur_v = base_line
1659 cur_h += rule_width
1660 elif isinstance(p, Glue):
1661 # node625
1662 glue_spec = p.glue_spec
1663 rule_width = glue_spec.width - cur_g
1664 if glue_sign != 0: # normal
1665 if glue_sign == 1: # stretching
1666 if glue_spec.stretch_order == glue_order:
1667 cur_glue += glue_spec.stretch
1668 cur_g = round(clamp(box.glue_set * cur_glue))
1669 elif glue_spec.shrink_order == glue_order:
1670 cur_glue += glue_spec.shrink
1671 cur_g = round(clamp(box.glue_set * cur_glue))
1672 rule_width += cur_g
1673 cur_h += rule_width
1674
1675 def vlist_out(box: Vlist) -> None:
1676 nonlocal cur_v, cur_h, off_h, off_v
1677
1678 cur_g = 0
1679 cur_glue = 0.
1680 glue_order = box.glue_order
1681 glue_sign = box.glue_sign
1682 left_edge = cur_h
1683 cur_v -= box.height
1684 top_edge = cur_v
1685
1686 for p in box.children:
1687 if isinstance(p, Kern):
1688 cur_v += p.width
1689 elif isinstance(p, List):
1690 if len(p.children) == 0:
1691 cur_v += p.height + p.depth
1692 else:
1693 cur_v += p.height
1694 cur_h = left_edge + p.shift_amount
1695 save_v = cur_v
1696 p.width = box.width
1697 if isinstance(p, Hlist):
1698 hlist_out(p)
1699 elif isinstance(p, Vlist):
1700 vlist_out(p)
1701 else:
1702 assert False, "unreachable code"
1703 cur_v = save_v + p.depth
1704 cur_h = left_edge
1705 elif isinstance(p, Box):
1706 rule_height = p.height
1707 rule_depth = p.depth
1708 rule_width = p.width
1709 if np.isinf(rule_width):
1710 rule_width = box.width
1711 rule_height += rule_depth
1712 if rule_height > 0 and rule_depth > 0:
1713 cur_v += rule_height
1714 p.render(output,
1715 cur_h + off_h, cur_v + off_v,
1716 rule_width, rule_height)
1717 elif isinstance(p, Glue):
1718 glue_spec = p.glue_spec
1719 rule_height = glue_spec.width - cur_g
1720 if glue_sign != 0: # normal
1721 if glue_sign == 1: # stretching
1722 if glue_spec.stretch_order == glue_order:
1723 cur_glue += glue_spec.stretch
1724 cur_g = round(clamp(box.glue_set * cur_glue))
1725 elif glue_spec.shrink_order == glue_order: # shrinking
1726 cur_glue += glue_spec.shrink
1727 cur_g = round(clamp(box.glue_set * cur_glue))
1728 rule_height += cur_g
1729 cur_v += rule_height
1730 elif isinstance(p, Char):
1731 raise RuntimeError(
1732 "Internal mathtext error: Char node found in vlist")
1733
1734 assert isinstance(box, Hlist)
1735 hlist_out(box)
1736 return output
1737
1738
1739##############################################################################
1740# PARSER
1741
1742
1743def Error(msg: str) -> ParserElement:
1744 """Helper class to raise parser errors."""
1745 def raise_error(s: str, loc: int, toks: ParseResults) -> T.Any:
1746 raise ParseFatalException(s, loc, msg)
1747
1748 return Empty().setParseAction(raise_error)
1749
1750
1751class ParserState:
1752 """
1753 Parser state.
1754
1755 States are pushed and popped from a stack as necessary, and the "current"
1756 state is always at the top of the stack.
1757
1758 Upon entering and leaving a group { } or math/non-math, the stack is pushed
1759 and popped accordingly.
1760 """
1761
1762 def __init__(self, fontset: Fonts, font: str, font_class: str, fontsize: float,
1763 dpi: float):
1764 self.fontset = fontset
1765 self._font = font
1766 self.font_class = font_class
1767 self.fontsize = fontsize
1768 self.dpi = dpi
1769
1770 def copy(self) -> ParserState:
1771 return copy.copy(self)
1772
1773 @property
1774 def font(self) -> str:
1775 return self._font
1776
1777 @font.setter
1778 def font(self, name: str) -> None:
1779 if name in ('rm', 'it', 'bf', 'bfit'):
1780 self.font_class = name
1781 self._font = name
1782
1783 def get_current_underline_thickness(self) -> float:
1784 """Return the underline thickness for this state."""
1785 return self.fontset.get_underline_thickness(
1786 self.font, self.fontsize, self.dpi)
1787
1788
1789def cmd(expr: str, args: ParserElement) -> ParserElement:
1790 r"""
1791 Helper to define TeX commands.
1792
1793 ``cmd("\cmd", args)`` is equivalent to
1794 ``"\cmd" - (args | Error("Expected \cmd{arg}{...}"))`` where the names in
1795 the error message are taken from element names in *args*. If *expr*
1796 already includes arguments (e.g. "\cmd{arg}{...}"), then they are stripped
1797 when constructing the parse element, but kept (and *expr* is used as is) in
1798 the error message.
1799 """
1800
1801 def names(elt: ParserElement) -> T.Generator[str, None, None]:
1802 if isinstance(elt, ParseExpression):
1803 for expr in elt.exprs:
1804 yield from names(expr)
1805 elif elt.resultsName:
1806 yield elt.resultsName
1807
1808 csname = expr.split("{", 1)[0]
1809 err = (csname + "".join("{%s}" % name for name in names(args))
1810 if expr == csname else expr)
1811 return csname - (args | Error(f"Expected {err}"))
1812
1813
1814class Parser:
1815 """
1816 A pyparsing-based parser for strings containing math expressions.
1817
1818 Raw text may also appear outside of pairs of ``$``.
1819
1820 The grammar is based directly on that in TeX, though it cuts a few corners.
1821 """
1822
1823 class _MathStyle(enum.Enum):
1824 DISPLAYSTYLE = 0
1825 TEXTSTYLE = 1
1826 SCRIPTSTYLE = 2
1827 SCRIPTSCRIPTSTYLE = 3
1828
1829 _binary_operators = set(
1830 '+ * - \N{MINUS SIGN}'
1831 r'''
1832 \pm \sqcap \rhd
1833 \mp \sqcup \unlhd
1834 \times \vee \unrhd
1835 \div \wedge \oplus
1836 \ast \setminus \ominus
1837 \star \wr \otimes
1838 \circ \diamond \oslash
1839 \bullet \bigtriangleup \odot
1840 \cdot \bigtriangledown \bigcirc
1841 \cap \triangleleft \dagger
1842 \cup \triangleright \ddagger
1843 \uplus \lhd \amalg
1844 \dotplus \dotminus \Cap
1845 \Cup \barwedge \boxdot
1846 \boxminus \boxplus \boxtimes
1847 \curlyvee \curlywedge \divideontimes
1848 \doublebarwedge \leftthreetimes \rightthreetimes
1849 \slash \veebar \barvee
1850 \cupdot \intercal \amalg
1851 \circledcirc \circleddash \circledast
1852 \boxbar \obar \merge
1853 \minuscolon \dotsminusdots
1854 '''.split())
1855
1856 _relation_symbols = set(r'''
1857 = < > :
1858 \leq \geq \equiv \models
1859 \prec \succ \sim \perp
1860 \preceq \succeq \simeq \mid
1861 \ll \gg \asymp \parallel
1862 \subset \supset \approx \bowtie
1863 \subseteq \supseteq \cong \Join
1864 \sqsubset \sqsupset \neq \smile
1865 \sqsubseteq \sqsupseteq \doteq \frown
1866 \in \ni \propto \vdash
1867 \dashv \dots \doteqdot \leqq
1868 \geqq \lneqq \gneqq \lessgtr
1869 \leqslant \geqslant \eqgtr \eqless
1870 \eqslantless \eqslantgtr \lesseqgtr \backsim
1871 \backsimeq \lesssim \gtrsim \precsim
1872 \precnsim \gnsim \lnsim \succsim
1873 \succnsim \nsim \lesseqqgtr \gtreqqless
1874 \gtreqless \subseteqq \supseteqq \subsetneqq
1875 \supsetneqq \lessapprox \approxeq \gtrapprox
1876 \precapprox \succapprox \precnapprox \succnapprox
1877 \npreccurlyeq \nsucccurlyeq \nsqsubseteq \nsqsupseteq
1878 \sqsubsetneq \sqsupsetneq \nlesssim \ngtrsim
1879 \nlessgtr \ngtrless \lnapprox \gnapprox
1880 \napprox \approxeq \approxident \lll
1881 \ggg \nparallel \Vdash \Vvdash
1882 \nVdash \nvdash \vDash \nvDash
1883 \nVDash \oequal \simneqq \triangle
1884 \triangleq \triangleeq \triangleleft
1885 \triangleright \ntriangleleft \ntriangleright
1886 \trianglelefteq \ntrianglelefteq \trianglerighteq
1887 \ntrianglerighteq \blacktriangleleft \blacktriangleright
1888 \equalparallel \measuredrightangle \varlrtriangle
1889 \Doteq \Bumpeq \Subset \Supset
1890 \backepsilon \because \therefore \bot
1891 \top \bumpeq \circeq \coloneq
1892 \curlyeqprec \curlyeqsucc \eqcirc \eqcolon
1893 \eqsim \fallingdotseq \gtrdot \gtrless
1894 \ltimes \rtimes \lessdot \ne
1895 \ncong \nequiv \ngeq \ngtr
1896 \nleq \nless \nmid \notin
1897 \nprec \nsubset \nsubseteq \nsucc
1898 \nsupset \nsupseteq \pitchfork \preccurlyeq
1899 \risingdotseq \subsetneq \succcurlyeq \supsetneq
1900 \varpropto \vartriangleleft \scurel
1901 \vartriangleright \rightangle \equal \backcong
1902 \eqdef \wedgeq \questeq \between
1903 \veeeq \disin \varisins \isins
1904 \isindot \varisinobar \isinobar \isinvb
1905 \isinE \nisd \varnis \nis
1906 \varniobar \niobar \bagmember \ratio
1907 \Equiv \stareq \measeq \arceq
1908 \rightassert \rightModels \smallin \smallowns
1909 \notsmallowns \nsimeq'''.split())
1910
1911 _arrow_symbols = set(r"""
1912 \leftarrow \longleftarrow \uparrow \Leftarrow \Longleftarrow
1913 \Uparrow \rightarrow \longrightarrow \downarrow \Rightarrow
1914 \Longrightarrow \Downarrow \leftrightarrow \updownarrow
1915 \longleftrightarrow \updownarrow \Leftrightarrow
1916 \Longleftrightarrow \Updownarrow \mapsto \longmapsto \nearrow
1917 \hookleftarrow \hookrightarrow \searrow \leftharpoonup
1918 \rightharpoonup \swarrow \leftharpoondown \rightharpoondown
1919 \nwarrow \rightleftharpoons \leadsto \dashrightarrow
1920 \dashleftarrow \leftleftarrows \leftrightarrows \Lleftarrow
1921 \Rrightarrow \twoheadleftarrow \leftarrowtail \looparrowleft
1922 \leftrightharpoons \curvearrowleft \circlearrowleft \Lsh
1923 \upuparrows \upharpoonleft \downharpoonleft \multimap
1924 \leftrightsquigarrow \rightrightarrows \rightleftarrows
1925 \rightrightarrows \rightleftarrows \twoheadrightarrow
1926 \rightarrowtail \looparrowright \rightleftharpoons
1927 \curvearrowright \circlearrowright \Rsh \downdownarrows
1928 \upharpoonright \downharpoonright \rightsquigarrow \nleftarrow
1929 \nrightarrow \nLeftarrow \nRightarrow \nleftrightarrow
1930 \nLeftrightarrow \to \Swarrow \Searrow \Nwarrow \Nearrow
1931 \leftsquigarrow \overleftarrow \overleftrightarrow \cwopencirclearrow
1932 \downzigzagarrow \cupleftarrow \rightzigzagarrow \twoheaddownarrow
1933 \updownarrowbar \twoheaduparrow \rightarrowbar \updownarrows
1934 \barleftarrow \mapsfrom \mapsdown \mapsup \Ldsh \Rdsh
1935 """.split())
1936
1937 _spaced_symbols = _binary_operators | _relation_symbols | _arrow_symbols
1938
1939 _punctuation_symbols = set(r', ; . ! \ldotp \cdotp'.split())
1940
1941 _overunder_symbols = set(r'''
1942 \sum \prod \coprod \bigcap \bigcup \bigsqcup \bigvee
1943 \bigwedge \bigodot \bigotimes \bigoplus \biguplus
1944 '''.split())
1945
1946 _overunder_functions = set("lim liminf limsup sup max min".split())
1947
1948 _dropsub_symbols = set(r'\int \oint \iint \oiint \iiint \oiiint \iiiint'.split())
1949
1950 _fontnames = set("rm cal it tt sf bf bfit "
1951 "default bb frak scr regular".split())
1952
1953 _function_names = set("""
1954 arccos csc ker min arcsin deg lg Pr arctan det lim sec arg dim
1955 liminf sin cos exp limsup sinh cosh gcd ln sup cot hom log tan
1956 coth inf max tanh""".split())
1957
1958 _ambi_delims = set(r"""
1959 | \| / \backslash \uparrow \downarrow \updownarrow \Uparrow
1960 \Downarrow \Updownarrow . \vert \Vert""".split())
1961 _left_delims = set(r"""
1962 ( [ \{ < \lfloor \langle \lceil \lbrace \leftbrace \lbrack \leftparen \lgroup
1963 """.split())
1964 _right_delims = set(r"""
1965 ) ] \} > \rfloor \rangle \rceil \rbrace \rightbrace \rbrack \rightparen \rgroup
1966 """.split())
1967 _delims = _left_delims | _right_delims | _ambi_delims
1968
1969 _small_greek = set([unicodedata.name(chr(i)).split()[-1].lower() for i in
1970 range(ord('\N{GREEK SMALL LETTER ALPHA}'),
1971 ord('\N{GREEK SMALL LETTER OMEGA}') + 1)])
1972 _latin_alphabets = set(string.ascii_letters)
1973
1974 def __init__(self) -> None:
1975 p = types.SimpleNamespace()
1976
1977 def set_names_and_parse_actions() -> None:
1978 for key, val in vars(p).items():
1979 if not key.startswith('_'):
1980 # Set names on (almost) everything -- very useful for debugging
1981 # token, placeable, and auto_delim are forward references which
1982 # are left without names to ensure useful error messages
1983 if key not in ("token", "placeable", "auto_delim"):
1984 val.setName(key)
1985 # Set actions
1986 if hasattr(self, key):
1987 val.setParseAction(getattr(self, key))
1988
1989 # Root definitions.
1990
1991 # In TeX parlance, a csname is a control sequence name (a "\foo").
1992 def csnames(group: str, names: Iterable[str]) -> Regex:
1993 ends_with_alpha = []
1994 ends_with_nonalpha = []
1995 for name in names:
1996 if name[-1].isalpha():
1997 ends_with_alpha.append(name)
1998 else:
1999 ends_with_nonalpha.append(name)
2000 return Regex(
2001 r"\\(?P<{group}>(?:{alpha})(?![A-Za-z]){additional}{nonalpha})".format(
2002 group=group,
2003 alpha="|".join(map(re.escape, ends_with_alpha)),
2004 additional="|" if ends_with_nonalpha else "",
2005 nonalpha="|".join(map(re.escape, ends_with_nonalpha)),
2006 )
2007 )
2008
2009 p.float_literal = Regex(r"[-+]?([0-9]+\.?[0-9]*|\.[0-9]+)")
2010 p.space = oneOf(self._space_widths)("space")
2011
2012 p.style_literal = oneOf(
2013 [str(e.value) for e in self._MathStyle])("style_literal")
2014
2015 p.symbol = Regex(
2016 r"[a-zA-Z0-9 +\-*/<>=:,.;!\?&'@()\[\]|\U00000080-\U0001ffff]"
2017 r"|\\[%${}\[\]_|]"
2018 + r"|\\(?:{})(?![A-Za-z])".format(
2019 "|".join(map(re.escape, tex2uni)))
2020 )("sym").leaveWhitespace()
2021 p.unknown_symbol = Regex(r"\\[A-Za-z]+")("name")
2022
2023 p.font = csnames("font", self._fontnames)
2024 p.start_group = Optional(r"\math" + oneOf(self._fontnames)("font")) + "{"
2025 p.end_group = Literal("}")
2026
2027 p.delim = oneOf(self._delims)
2028
2029 # Mutually recursive definitions. (Minimizing the number of Forward
2030 # elements is important for speed.)
2031 p.auto_delim = Forward()
2032 p.placeable = Forward()
2033 p.named_placeable = Forward()
2034 p.required_group = Forward()
2035 p.optional_group = Forward()
2036 p.token = Forward()
2037
2038 # Workaround for placable being part of a cycle of definitions
2039 # calling `p.placeable("name")` results in a copy, so not guaranteed
2040 # to get the definition added after it is used.
2041 # ref https://github.com/matplotlib/matplotlib/issues/25204
2042 # xref https://github.com/pyparsing/pyparsing/issues/95
2043 p.named_placeable <<= p.placeable
2044
2045 set_names_and_parse_actions() # for mutually recursive definitions.
2046
2047 p.optional_group <<= "{" + ZeroOrMore(p.token)("group") + "}"
2048 p.required_group <<= "{" + OneOrMore(p.token)("group") + "}"
2049
2050 p.customspace = cmd(r"\hspace", "{" + p.float_literal("space") + "}")
2051
2052 p.accent = (
2053 csnames("accent", [*self._accent_map, *self._wide_accents])
2054 - p.named_placeable("sym"))
2055
2056 p.function = csnames("name", self._function_names)
2057
2058 p.group = p.start_group + ZeroOrMore(p.token)("group") + p.end_group
2059 p.unclosed_group = (p.start_group + ZeroOrMore(p.token)("group") + StringEnd())
2060
2061 p.frac = cmd(r"\frac", p.required_group("num") + p.required_group("den"))
2062 p.dfrac = cmd(r"\dfrac", p.required_group("num") + p.required_group("den"))
2063 p.binom = cmd(r"\binom", p.required_group("num") + p.required_group("den"))
2064
2065 p.genfrac = cmd(
2066 r"\genfrac",
2067 "{" + Optional(p.delim)("ldelim") + "}"
2068 + "{" + Optional(p.delim)("rdelim") + "}"
2069 + "{" + p.float_literal("rulesize") + "}"
2070 + "{" + Optional(p.style_literal)("style") + "}"
2071 + p.required_group("num")
2072 + p.required_group("den"))
2073
2074 p.sqrt = cmd(
2075 r"\sqrt{value}",
2076 Optional("[" + OneOrMore(NotAny("]") + p.token)("root") + "]")
2077 + p.required_group("value"))
2078
2079 p.overline = cmd(r"\overline", p.required_group("body"))
2080
2081 p.overset = cmd(
2082 r"\overset",
2083 p.optional_group("annotation") + p.optional_group("body"))
2084 p.underset = cmd(
2085 r"\underset",
2086 p.optional_group("annotation") + p.optional_group("body"))
2087
2088 p.text = cmd(r"\text", QuotedString('{', '\\', endQuoteChar="}"))
2089
2090 p.substack = cmd(r"\substack",
2091 nested_expr(opener="{", closer="}",
2092 content=Group(OneOrMore(p.token)) +
2093 ZeroOrMore(Literal("\\\\").suppress()))("parts"))
2094
2095 p.subsuper = (
2096 (Optional(p.placeable)("nucleus")
2097 + OneOrMore(oneOf(["_", "^"]) - p.placeable)("subsuper")
2098 + Regex("'*")("apostrophes"))
2099 | Regex("'+")("apostrophes")
2100 | (p.named_placeable("nucleus") + Regex("'*")("apostrophes"))
2101 )
2102
2103 p.simple = p.space | p.customspace | p.font | p.subsuper
2104
2105 p.token <<= (
2106 p.simple
2107 | p.auto_delim
2108 | p.unclosed_group
2109 | p.unknown_symbol # Must be last
2110 )
2111
2112 p.operatorname = cmd(r"\operatorname", "{" + ZeroOrMore(p.simple)("name") + "}")
2113
2114 p.boldsymbol = cmd(
2115 r"\boldsymbol", "{" + ZeroOrMore(p.simple)("value") + "}")
2116
2117 p.placeable <<= (
2118 p.accent # Must be before symbol as all accents are symbols
2119 | p.symbol # Must be second to catch all named symbols and single
2120 # chars not in a group
2121 | p.function
2122 | p.operatorname
2123 | p.group
2124 | p.frac
2125 | p.dfrac
2126 | p.binom
2127 | p.genfrac
2128 | p.overset
2129 | p.underset
2130 | p.sqrt
2131 | p.overline
2132 | p.text
2133 | p.boldsymbol
2134 | p.substack
2135 )
2136
2137 mdelim = r"\middle" - (p.delim("mdelim") | Error("Expected a delimiter"))
2138 p.auto_delim <<= (
2139 r"\left" - (p.delim("left") | Error("Expected a delimiter"))
2140 + ZeroOrMore(p.simple | p.auto_delim | mdelim)("mid")
2141 + r"\right" - (p.delim("right") | Error("Expected a delimiter"))
2142 )
2143
2144 # Leaf definitions.
2145 p.math = OneOrMore(p.token)
2146 p.math_string = QuotedString('$', '\\', unquoteResults=False)
2147 p.non_math = Regex(r"(?:(?:\\[$])|[^$])*").leaveWhitespace()
2148 p.main = (
2149 p.non_math + ZeroOrMore(p.math_string + p.non_math) + StringEnd()
2150 )
2151 set_names_and_parse_actions() # for leaf definitions.
2152
2153 self._expression = p.main
2154 self._math_expression = p.math
2155
2156 # To add space to nucleus operators after sub/superscripts
2157 self._in_subscript_or_superscript = False
2158
2159 def parse(self, s: str, fonts_object: Fonts, fontsize: float, dpi: float) -> Hlist:
2160 """
2161 Parse expression *s* using the given *fonts_object* for
2162 output, at the given *fontsize* and *dpi*.
2163
2164 Returns the parse tree of `Node` instances.
2165 """
2166 self._state_stack = [
2167 ParserState(fonts_object, 'default', 'rm', fontsize, dpi)]
2168 self._em_width_cache: dict[tuple[str, float, float], float] = {}
2169 try:
2170 result = self._expression.parseString(s)
2171 except ParseBaseException as err:
2172 # explain becomes a plain method on pyparsing 3 (err.explain(0)).
2173 raise ValueError("\n" + ParseException.explain(err, 0)) from None
2174 self._state_stack = []
2175 self._in_subscript_or_superscript = False
2176 # prevent operator spacing from leaking into a new expression
2177 self._em_width_cache = {}
2178 ParserElement.resetCache()
2179 return T.cast(Hlist, result[0]) # Known return type from main.
2180
2181 def get_state(self) -> ParserState:
2182 """Get the current `State` of the parser."""
2183 return self._state_stack[-1]
2184
2185 def pop_state(self) -> None:
2186 """Pop a `State` off of the stack."""
2187 self._state_stack.pop()
2188
2189 def push_state(self) -> None:
2190 """Push a new `State` onto the stack, copying the current state."""
2191 self._state_stack.append(self.get_state().copy())
2192
2193 def main(self, toks: ParseResults) -> list[Hlist]:
2194 return [Hlist(toks.asList())]
2195
2196 def math_string(self, toks: ParseResults) -> ParseResults:
2197 return self._math_expression.parseString(toks[0][1:-1], parseAll=True)
2198
2199 def math(self, toks: ParseResults) -> T.Any:
2200 hlist = Hlist(toks.asList())
2201 self.pop_state()
2202 return [hlist]
2203
2204 def non_math(self, toks: ParseResults) -> T.Any:
2205 s = toks[0].replace(r'\$', '$')
2206 symbols = [Char(c, self.get_state()) for c in s]
2207 hlist = Hlist(symbols)
2208 # We're going into math now, so set font to 'it'
2209 self.push_state()
2210 self.get_state().font = mpl.rcParams['mathtext.default']
2211 return [hlist]
2212
2213 float_literal = staticmethod(pyparsing_common.convertToFloat)
2214
2215 def text(self, toks: ParseResults) -> T.Any:
2216 self.push_state()
2217 state = self.get_state()
2218 state.font = 'rm'
2219 hlist = Hlist([Char(c, state) for c in toks[1]])
2220 self.pop_state()
2221 return [hlist]
2222
2223 def _make_space(self, percentage: float) -> Kern:
2224 # In TeX, an em (the unit usually used to measure horizontal lengths)
2225 # is not the width of the character 'm'; it is the same in different
2226 # font styles (e.g. roman or italic). Mathtext, however, uses 'm' in
2227 # the italic style so that horizontal spaces don't depend on the
2228 # current font style.
2229 state = self.get_state()
2230 key = (state.font, state.fontsize, state.dpi)
2231 width = self._em_width_cache.get(key)
2232 if width is None:
2233 metrics = state.fontset.get_metrics(
2234 'it', mpl.rcParams['mathtext.default'], 'm',
2235 state.fontsize, state.dpi)
2236 width = metrics.advance
2237 self._em_width_cache[key] = width
2238 return Kern(width * percentage)
2239
2240 _space_widths = {
2241 r'\,': 0.16667, # 3/18 em = 3 mu
2242 r'\thinspace': 0.16667, # 3/18 em = 3 mu
2243 r'\/': 0.16667, # 3/18 em = 3 mu
2244 r'\>': 0.22222, # 4/18 em = 4 mu
2245 r'\:': 0.22222, # 4/18 em = 4 mu
2246 r'\;': 0.27778, # 5/18 em = 5 mu
2247 r'\ ': 0.33333, # 6/18 em = 6 mu
2248 r'~': 0.33333, # 6/18 em = 6 mu, nonbreakable
2249 r'\enspace': 0.5, # 9/18 em = 9 mu
2250 r'\quad': 1, # 1 em = 18 mu
2251 r'\qquad': 2, # 2 em = 36 mu
2252 r'\!': -0.16667, # -3/18 em = -3 mu
2253 }
2254
2255 def space(self, toks: ParseResults) -> T.Any:
2256 num = self._space_widths[toks["space"]]
2257 box = self._make_space(num)
2258 return [box]
2259
2260 def customspace(self, toks: ParseResults) -> T.Any:
2261 return [self._make_space(toks["space"])]
2262
2263 def symbol(self, s: str, loc: int,
2264 toks: ParseResults | dict[str, str]) -> T.Any:
2265 c = toks["sym"]
2266 if c == "-":
2267 # "U+2212 minus sign is the preferred representation of the unary
2268 # and binary minus sign rather than the ASCII-derived U+002D
2269 # hyphen-minus, because minus sign is unambiguous and because it
2270 # is rendered with a more desirable length, usually longer than a
2271 # hyphen." (https://www.unicode.org/reports/tr25/)
2272 c = "\N{MINUS SIGN}"
2273 try:
2274 char = Char(c, self.get_state())
2275 except ValueError as err:
2276 raise ParseFatalException(s, loc,
2277 "Unknown symbol: %s" % c) from err
2278
2279 if c in self._spaced_symbols:
2280 # iterate until we find previous character, needed for cases
2281 # such as ${ -2}$, $ -2$, or $ -2$.
2282 prev_char = next((c for c in s[:loc][::-1] if c != ' '), '')
2283 # Binary operators at start of string should not be spaced
2284 # Also, operators in sub- or superscripts should not be spaced
2285 if (self._in_subscript_or_superscript or (
2286 c in self._binary_operators and (
2287 len(s[:loc].split()) == 0 or prev_char == '{' or
2288 prev_char in self._left_delims))):
2289 return [char]
2290 else:
2291 return [Hlist([self._make_space(0.2),
2292 char,
2293 self._make_space(0.2)],
2294 do_kern=True)]
2295 elif c in self._punctuation_symbols:
2296 prev_char = next((c for c in s[:loc][::-1] if c != ' '), '')
2297 next_char = next((c for c in s[loc + 1:] if c != ' '), '')
2298
2299 # Do not space commas between brackets
2300 if c == ',':
2301 if prev_char == '{' and next_char == '}':
2302 return [char]
2303
2304 # Do not space dots as decimal separators
2305 if c == '.' and prev_char.isdigit() and next_char.isdigit():
2306 return [char]
2307 else:
2308 return [Hlist([char, self._make_space(0.2)], do_kern=True)]
2309 return [char]
2310
2311 def unknown_symbol(self, s: str, loc: int, toks: ParseResults) -> T.Any:
2312 raise ParseFatalException(s, loc, f"Unknown symbol: {toks['name']}")
2313
2314 _accent_map = {
2315 r'hat': r'\circumflexaccent',
2316 r'breve': r'\combiningbreve',
2317 r'bar': r'\combiningoverline',
2318 r'grave': r'\combininggraveaccent',
2319 r'acute': r'\combiningacuteaccent',
2320 r'tilde': r'\combiningtilde',
2321 r'dot': r'\combiningdotabove',
2322 r'ddot': r'\combiningdiaeresis',
2323 r'dddot': r'\combiningthreedotsabove',
2324 r'ddddot': r'\combiningfourdotsabove',
2325 r'vec': r'\combiningrightarrowabove',
2326 r'"': r'\combiningdiaeresis',
2327 r"`": r'\combininggraveaccent',
2328 r"'": r'\combiningacuteaccent',
2329 r'~': r'\combiningtilde',
2330 r'.': r'\combiningdotabove',
2331 r'^': r'\circumflexaccent',
2332 r'overrightarrow': r'\rightarrow',
2333 r'overleftarrow': r'\leftarrow',
2334 r'mathring': r'\circ',
2335 }
2336
2337 _wide_accents = set(r"widehat widetilde widebar".split())
2338
2339 def accent(self, toks: ParseResults) -> T.Any:
2340 state = self.get_state()
2341 thickness = state.get_current_underline_thickness()
2342 accent = toks["accent"]
2343 sym = toks["sym"]
2344 accent_box: Node
2345 if accent in self._wide_accents:
2346 accent_box = AutoWidthChar(
2347 '\\' + accent, sym.width, state, char_class=Accent)
2348 else:
2349 accent_box = Accent(self._accent_map[accent], state)
2350 if accent == 'mathring':
2351 accent_box.shrink()
2352 accent_box.shrink()
2353 centered = HCentered([Hbox(sym.width / 4.0), accent_box])
2354 centered.hpack(sym.width, 'exactly')
2355 return Vlist([
2356 centered,
2357 Vbox(0., thickness * 2.0),
2358 Hlist([sym])
2359 ])
2360
2361 def function(self, s: str, loc: int, toks: ParseResults) -> T.Any:
2362 hlist = self.operatorname(s, loc, toks)
2363 hlist.function_name = toks["name"]
2364 return hlist
2365
2366 def operatorname(self, s: str, loc: int, toks: ParseResults) -> T.Any:
2367 self.push_state()
2368 state = self.get_state()
2369 state.font = 'rm'
2370 hlist_list: list[Node] = []
2371 # Change the font of Chars, but leave Kerns alone
2372 name = toks["name"]
2373 for c in name:
2374 if isinstance(c, Char):
2375 c.font = 'rm'
2376 c._update_metrics()
2377 hlist_list.append(c)
2378 elif isinstance(c, str):
2379 hlist_list.append(Char(c, state))
2380 else:
2381 hlist_list.append(c)
2382 next_char_loc = loc + len(name) + 1
2383 if isinstance(name, ParseResults):
2384 next_char_loc += len('operatorname{}')
2385 next_char = next((c for c in s[next_char_loc:] if c != ' '), '')
2386 delimiters = self._delims | {'^', '_'}
2387 if (next_char not in delimiters and
2388 name not in self._overunder_functions):
2389 # Add thin space except when followed by parenthesis, bracket, etc.
2390 hlist_list += [self._make_space(self._space_widths[r'\,'])]
2391 self.pop_state()
2392 # if followed by a super/subscript, set flag to true
2393 # This flag tells subsuper to add space after this operator
2394 if next_char in {'^', '_'}:
2395 self._in_subscript_or_superscript = True
2396 else:
2397 self._in_subscript_or_superscript = False
2398
2399 return Hlist(hlist_list)
2400
2401 def start_group(self, toks: ParseResults) -> T.Any:
2402 self.push_state()
2403 # Deal with LaTeX-style font tokens
2404 if toks.get("font"):
2405 self.get_state().font = toks.get("font")
2406 return []
2407
2408 def group(self, toks: ParseResults) -> T.Any:
2409 grp = Hlist(toks.get("group", []))
2410 return [grp]
2411
2412 def required_group(self, toks: ParseResults) -> T.Any:
2413 return Hlist(toks.get("group", []))
2414
2415 optional_group = required_group
2416
2417 def end_group(self) -> T.Any:
2418 self.pop_state()
2419 return []
2420
2421 def unclosed_group(self, s: str, loc: int, toks: ParseResults) -> T.Any:
2422 raise ParseFatalException(s, len(s), "Expected '}'")
2423
2424 def font(self, toks: ParseResults) -> T.Any:
2425 self.get_state().font = toks["font"]
2426 return []
2427
2428 def is_overunder(self, nucleus: Node) -> bool:
2429 if isinstance(nucleus, Char):
2430 return nucleus.c in self._overunder_symbols
2431 elif isinstance(nucleus, Hlist) and hasattr(nucleus, 'function_name'):
2432 return nucleus.function_name in self._overunder_functions
2433 return False
2434
2435 def is_dropsub(self, nucleus: Node) -> bool:
2436 if isinstance(nucleus, Char):
2437 return nucleus.c in self._dropsub_symbols
2438 return False
2439
2440 def is_slanted(self, nucleus: Node) -> bool:
2441 if isinstance(nucleus, Char):
2442 return nucleus.is_slanted()
2443 return False
2444
2445 def subsuper(self, s: str, loc: int, toks: ParseResults) -> T.Any:
2446 nucleus = toks.get("nucleus", Hbox(0))
2447 subsuper = toks.get("subsuper", [])
2448 napostrophes = len(toks.get("apostrophes", []))
2449
2450 if not subsuper and not napostrophes:
2451 return nucleus
2452
2453 sub = super = None
2454 while subsuper:
2455 op, arg, *subsuper = subsuper
2456 if op == '_':
2457 if sub is not None:
2458 raise ParseFatalException("Double subscript")
2459 sub = arg
2460 else:
2461 if super is not None:
2462 raise ParseFatalException("Double superscript")
2463 super = arg
2464
2465 state = self.get_state()
2466 rule_thickness = state.fontset.get_underline_thickness(
2467 state.font, state.fontsize, state.dpi)
2468 xHeight = state.fontset.get_xheight(
2469 state.font, state.fontsize, state.dpi)
2470
2471 if napostrophes:
2472 if super is None:
2473 super = Hlist([])
2474 for i in range(napostrophes):
2475 super.children.extend(self.symbol(s, loc, {"sym": "\\prime"}))
2476 # kern() and hpack() needed to get the metrics right after
2477 # extending
2478 super.kern()
2479 super.hpack()
2480
2481 # Handle over/under symbols, such as sum or prod
2482 if self.is_overunder(nucleus):
2483 vlist = []
2484 shift = 0.
2485 width = nucleus.width
2486 if super is not None:
2487 super.shrink()
2488 width = max(width, super.width)
2489 if sub is not None:
2490 sub.shrink()
2491 width = max(width, sub.width)
2492
2493 vgap = rule_thickness * 3.0
2494 if super is not None:
2495 hlist = HCentered([super])
2496 hlist.hpack(width, 'exactly')
2497 vlist.extend([hlist, Vbox(0, vgap)])
2498 hlist = HCentered([nucleus])
2499 hlist.hpack(width, 'exactly')
2500 vlist.append(hlist)
2501 if sub is not None:
2502 hlist = HCentered([sub])
2503 hlist.hpack(width, 'exactly')
2504 vlist.extend([Vbox(0, vgap), hlist])
2505 shift = hlist.height + vgap + nucleus.depth
2506 vlt = Vlist(vlist)
2507 vlt.shift_amount = shift
2508 result = Hlist([vlt])
2509 return [result]
2510
2511 # We remove kerning on the last character for consistency (otherwise
2512 # it will compute kerning based on non-shrunk characters and may put
2513 # them too close together when superscripted)
2514 # We change the width of the last character to match the advance to
2515 # consider some fonts with weird metrics: e.g. stix's f has a width of
2516 # 7.75 and a kerning of -4.0 for an advance of 3.72, and we want to put
2517 # the superscript at the advance
2518 last_char = nucleus
2519 if isinstance(nucleus, Hlist):
2520 new_children = nucleus.children
2521 if len(new_children):
2522 # remove last kern
2523 if (isinstance(new_children[-1], Kern) and
2524 hasattr(new_children[-2], '_metrics')):
2525 new_children = new_children[:-1]
2526 last_char = new_children[-1]
2527 if hasattr(last_char, '_metrics'):
2528 last_char.width = last_char._metrics.advance
2529 # create new Hlist without kerning
2530 nucleus = Hlist(new_children, do_kern=False)
2531 else:
2532 if isinstance(nucleus, Char):
2533 last_char.width = last_char._metrics.advance
2534 nucleus = Hlist([nucleus])
2535
2536 # Handle regular sub/superscripts
2537 constants = _get_font_constant_set(state)
2538 lc_height = last_char.height
2539 lc_baseline = 0
2540 if self.is_dropsub(last_char):
2541 lc_baseline = last_char.depth
2542
2543 # Compute kerning for sub and super
2544 superkern = constants.delta * xHeight
2545 subkern = constants.delta * xHeight
2546 if self.is_slanted(last_char):
2547 superkern += constants.delta * xHeight
2548 superkern += (constants.delta_slanted *
2549 (lc_height - xHeight * 2. / 3.))
2550 if self.is_dropsub(last_char):
2551 subkern = (3 * constants.delta -
2552 constants.delta_integral) * lc_height
2553 superkern = (3 * constants.delta +
2554 constants.delta_integral) * lc_height
2555 else:
2556 subkern = 0
2557
2558 x: List
2559 if super is None:
2560 # node757
2561 # Note: One of super or sub must be a Node if we're in this function, but
2562 # mypy can't know this, since it can't interpret pyparsing expressions,
2563 # hence the cast.
2564 x = Hlist([Kern(subkern), T.cast(Node, sub)])
2565 x.shrink()
2566 if self.is_dropsub(last_char):
2567 shift_down = lc_baseline + constants.subdrop * xHeight
2568 else:
2569 shift_down = constants.sub1 * xHeight
2570 x.shift_amount = shift_down
2571 else:
2572 x = Hlist([Kern(superkern), super])
2573 x.shrink()
2574 if self.is_dropsub(last_char):
2575 shift_up = lc_height - constants.subdrop * xHeight
2576 else:
2577 shift_up = constants.sup1 * xHeight
2578 if sub is None:
2579 x.shift_amount = -shift_up
2580 else: # Both sub and superscript
2581 y = Hlist([Kern(subkern), sub])
2582 y.shrink()
2583 if self.is_dropsub(last_char):
2584 shift_down = lc_baseline + constants.subdrop * xHeight
2585 else:
2586 shift_down = constants.sub2 * xHeight
2587 # If sub and superscript collide, move super up
2588 clr = (2.0 * rule_thickness -
2589 ((shift_up - x.depth) - (y.height - shift_down)))
2590 if clr > 0.:
2591 shift_up += clr
2592 x = Vlist([
2593 x,
2594 Kern((shift_up - x.depth) - (y.height - shift_down)),
2595 y])
2596 x.shift_amount = shift_down
2597
2598 if not self.is_dropsub(last_char):
2599 x.width += constants.script_space * xHeight
2600
2601 # Do we need to add a space after the nucleus?
2602 # To find out, check the flag set by operatorname
2603 spaced_nucleus = [nucleus, x]
2604 if self._in_subscript_or_superscript:
2605 spaced_nucleus += [self._make_space(self._space_widths[r'\,'])]
2606 self._in_subscript_or_superscript = False
2607
2608 result = Hlist(spaced_nucleus)
2609 return [result]
2610
2611 def _genfrac(self, ldelim: str, rdelim: str, rule: float | None, style: _MathStyle,
2612 num: Hlist, den: Hlist) -> T.Any:
2613 state = self.get_state()
2614 thickness = state.get_current_underline_thickness()
2615
2616 for _ in range(style.value):
2617 num.shrink()
2618 den.shrink()
2619 cnum = HCentered([num])
2620 cden = HCentered([den])
2621 width = max(num.width, den.width)
2622 cnum.hpack(width, 'exactly')
2623 cden.hpack(width, 'exactly')
2624 vlist = Vlist([cnum, # numerator
2625 Vbox(0, thickness * 2.0), # space
2626 Hrule(state, rule), # rule
2627 Vbox(0, thickness * 2.0), # space
2628 cden # denominator
2629 ])
2630
2631 # Shift so the fraction line sits in the middle of the
2632 # equals sign
2633 metrics = state.fontset.get_metrics(
2634 state.font, mpl.rcParams['mathtext.default'],
2635 '=', state.fontsize, state.dpi)
2636 shift = (cden.height -
2637 ((metrics.ymax + metrics.ymin) / 2 -
2638 thickness * 3.0))
2639 vlist.shift_amount = shift
2640
2641 result = [Hlist([vlist, Hbox(thickness * 2.)])]
2642 if ldelim or rdelim:
2643 if ldelim == '':
2644 ldelim = '.'
2645 if rdelim == '':
2646 rdelim = '.'
2647 return self._auto_sized_delimiter(ldelim,
2648 T.cast(list[T.Union[Box, Char, str]],
2649 result),
2650 rdelim)
2651 return result
2652
2653 def style_literal(self, toks: ParseResults) -> T.Any:
2654 return self._MathStyle(int(toks["style_literal"]))
2655
2656 def genfrac(self, toks: ParseResults) -> T.Any:
2657 return self._genfrac(
2658 toks.get("ldelim", ""), toks.get("rdelim", ""),
2659 toks["rulesize"], toks.get("style", self._MathStyle.TEXTSTYLE),
2660 toks["num"], toks["den"])
2661
2662 def frac(self, toks: ParseResults) -> T.Any:
2663 return self._genfrac(
2664 "", "", self.get_state().get_current_underline_thickness(),
2665 self._MathStyle.TEXTSTYLE, toks["num"], toks["den"])
2666
2667 def dfrac(self, toks: ParseResults) -> T.Any:
2668 return self._genfrac(
2669 "", "", self.get_state().get_current_underline_thickness(),
2670 self._MathStyle.DISPLAYSTYLE, toks["num"], toks["den"])
2671
2672 def binom(self, toks: ParseResults) -> T.Any:
2673 return self._genfrac(
2674 "(", ")", 0,
2675 self._MathStyle.TEXTSTYLE, toks["num"], toks["den"])
2676
2677 def _genset(self, s: str, loc: int, toks: ParseResults) -> T.Any:
2678 annotation = toks["annotation"]
2679 body = toks["body"]
2680 thickness = self.get_state().get_current_underline_thickness()
2681
2682 annotation.shrink()
2683 centered_annotation = HCentered([annotation])
2684 centered_body = HCentered([body])
2685 width = max(centered_annotation.width, centered_body.width)
2686 centered_annotation.hpack(width, 'exactly')
2687 centered_body.hpack(width, 'exactly')
2688
2689 vgap = thickness * 3
2690 if s[loc + 1] == "u": # \underset
2691 vlist = Vlist([
2692 centered_body, # body
2693 Vbox(0, vgap), # space
2694 centered_annotation # annotation
2695 ])
2696 # Shift so the body sits in the same vertical position
2697 vlist.shift_amount = centered_body.depth + centered_annotation.height + vgap
2698 else: # \overset
2699 vlist = Vlist([
2700 centered_annotation, # annotation
2701 Vbox(0, vgap), # space
2702 centered_body # body
2703 ])
2704
2705 # To add horizontal gap between symbols: wrap the Vlist into
2706 # an Hlist and extend it with an Hbox(0, horizontal_gap)
2707 return vlist
2708
2709 overset = underset = _genset
2710
2711 def sqrt(self, toks: ParseResults) -> T.Any:
2712 root = toks.get("root")
2713 body = toks["value"]
2714 state = self.get_state()
2715 thickness = state.get_current_underline_thickness()
2716
2717 # Determine the height of the body, and add a little extra to
2718 # the height so it doesn't seem cramped
2719 height = body.height - body.shift_amount + thickness * 5.0
2720 depth = body.depth + body.shift_amount
2721 check = AutoHeightChar(r'\__sqrt__', height, depth, state, always=True)
2722 height = check.height - check.shift_amount
2723 depth = check.depth + check.shift_amount
2724
2725 # Put a little extra space to the left and right of the body
2726 padded_body = Hlist([Hbox(2 * thickness), body, Hbox(2 * thickness)])
2727 rightside = Vlist([Hrule(state), Glue('fill'), padded_body])
2728 # Stretch the glue between the hrule and the body
2729 rightside.vpack(height + (state.fontsize * state.dpi) / (100.0 * 12.0),
2730 'exactly', depth)
2731
2732 # Add the root and shift it upward so it is above the tick.
2733 # The value of 0.6 is a hard-coded hack ;)
2734 if not root:
2735 root = Box(check.width * 0.5, 0., 0.)
2736 else:
2737 root = Hlist(root)
2738 root.shrink()
2739 root.shrink()
2740
2741 root_vlist = Vlist([Hlist([root])])
2742 root_vlist.shift_amount = -height * 0.6
2743
2744 hlist = Hlist([root_vlist, # Root
2745 # Negative kerning to put root over tick
2746 Kern(-check.width * 0.5),
2747 check, # Check
2748 rightside]) # Body
2749 return [hlist]
2750
2751 def overline(self, toks: ParseResults) -> T.Any:
2752 body = toks["body"]
2753
2754 state = self.get_state()
2755 thickness = state.get_current_underline_thickness()
2756
2757 height = body.height - body.shift_amount + thickness * 3.0
2758 depth = body.depth + body.shift_amount
2759
2760 # Place overline above body
2761 rightside = Vlist([Hrule(state), Glue('fill'), Hlist([body])])
2762
2763 # Stretch the glue between the hrule and the body
2764 rightside.vpack(height + (state.fontsize * state.dpi) / (100.0 * 12.0),
2765 'exactly', depth)
2766
2767 hlist = Hlist([rightside])
2768 return [hlist]
2769
2770 def _auto_sized_delimiter(self, front: str,
2771 middle: list[Box | Char | str],
2772 back: str) -> T.Any:
2773 state = self.get_state()
2774 if len(middle):
2775 height = max([x.height for x in middle if not isinstance(x, str)])
2776 depth = max([x.depth for x in middle if not isinstance(x, str)])
2777 factor = None
2778 for idx, el in enumerate(middle):
2779 if el == r'\middle':
2780 c = T.cast(str, middle[idx + 1]) # Should be one of p.delims.
2781 if c != '.':
2782 middle[idx + 1] = AutoHeightChar(
2783 c, height, depth, state, factor=factor)
2784 else:
2785 middle.remove(c)
2786 del middle[idx]
2787 # There should only be \middle and its delimiter as str, which have
2788 # just been removed.
2789 middle_part = T.cast(list[T.Union[Box, Char]], middle)
2790 else:
2791 height = 0
2792 depth = 0
2793 factor = 1.0
2794 middle_part = []
2795
2796 parts: list[Node] = []
2797 # \left. and \right. aren't supposed to produce any symbols
2798 if front != '.':
2799 parts.append(
2800 AutoHeightChar(front, height, depth, state, factor=factor))
2801 parts.extend(middle_part)
2802 if back != '.':
2803 parts.append(
2804 AutoHeightChar(back, height, depth, state, factor=factor))
2805 hlist = Hlist(parts)
2806 return hlist
2807
2808 def auto_delim(self, toks: ParseResults) -> T.Any:
2809 return self._auto_sized_delimiter(
2810 toks["left"],
2811 # if "mid" in toks ... can be removed when requiring pyparsing 3.
2812 toks["mid"].asList() if "mid" in toks else [],
2813 toks["right"])
2814
2815 def boldsymbol(self, toks: ParseResults) -> T.Any:
2816 self.push_state()
2817 state = self.get_state()
2818 hlist: list[Node] = []
2819 name = toks["value"]
2820 for c in name:
2821 if isinstance(c, Hlist):
2822 k = c.children[1]
2823 if isinstance(k, Char):
2824 k.font = "bf"
2825 k._update_metrics()
2826 hlist.append(c)
2827 elif isinstance(c, Char):
2828 c.font = "bf"
2829 if (c.c in self._latin_alphabets or
2830 c.c[1:] in self._small_greek):
2831 c.font = "bfit"
2832 c._update_metrics()
2833 c._update_metrics()
2834 hlist.append(c)
2835 else:
2836 hlist.append(c)
2837 self.pop_state()
2838
2839 return Hlist(hlist)
2840
2841 def substack(self, toks: ParseResults) -> T.Any:
2842 parts = toks["parts"]
2843 state = self.get_state()
2844 thickness = state.get_current_underline_thickness()
2845
2846 hlist = [Hlist(k) for k in parts[0]]
2847 max_width = max(map(lambda c: c.width, hlist))
2848
2849 vlist = []
2850 for sub in hlist:
2851 cp = HCentered([sub])
2852 cp.hpack(max_width, 'exactly')
2853 vlist.append(cp)
2854
2855 stack = [val
2856 for pair in zip(vlist, [Vbox(0, thickness * 2)] * len(vlist))
2857 for val in pair]
2858 del stack[-1]
2859 vlt = Vlist(stack)
2860 result = [Hlist([vlt])]
2861 return result