2862 lines
105 KiB
Python
2862 lines
105 KiB
Python
"""
|
|
Implementation details for :mod:`.mathtext`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import abc
|
|
import copy
|
|
import enum
|
|
import functools
|
|
import logging
|
|
import os
|
|
import re
|
|
import types
|
|
import unicodedata
|
|
import string
|
|
import typing as T
|
|
from typing import NamedTuple
|
|
|
|
import numpy as np
|
|
from pyparsing import (
|
|
Empty, Forward, Literal, NotAny, oneOf, OneOrMore, Optional,
|
|
ParseBaseException, ParseException, ParseExpression, ParseFatalException,
|
|
ParserElement, ParseResults, QuotedString, Regex, StringEnd, ZeroOrMore,
|
|
pyparsing_common, Group)
|
|
|
|
import matplotlib as mpl
|
|
from . import cbook
|
|
from ._mathtext_data import (
|
|
latex_to_bakoma, stix_glyph_fixes, stix_virtual_fonts, tex2uni)
|
|
from .font_manager import FontProperties, findfont, get_font
|
|
from .ft2font import FT2Font, FT2Image, KERNING_DEFAULT
|
|
|
|
from packaging.version import parse as parse_version
|
|
from pyparsing import __version__ as pyparsing_version
|
|
if parse_version(pyparsing_version).major < 3:
|
|
from pyparsing import nestedExpr as nested_expr
|
|
else:
|
|
from pyparsing import nested_expr
|
|
|
|
if T.TYPE_CHECKING:
|
|
from collections.abc import Iterable
|
|
from .ft2font import Glyph
|
|
|
|
ParserElement.enablePackrat()
|
|
_log = logging.getLogger("matplotlib.mathtext")
|
|
|
|
|
|
##############################################################################
|
|
# FONTS
|
|
|
|
|
|
def get_unicode_index(symbol: str) -> int: # Publicly exported.
|
|
r"""
|
|
Return the integer index (from the Unicode table) of *symbol*.
|
|
|
|
Parameters
|
|
----------
|
|
symbol : str
|
|
A single (Unicode) character, a TeX command (e.g. r'\pi') or a Type1
|
|
symbol name (e.g. 'phi').
|
|
"""
|
|
try: # This will succeed if symbol is a single Unicode char
|
|
return ord(symbol)
|
|
except TypeError:
|
|
pass
|
|
try: # Is symbol a TeX symbol (i.e. \alpha)
|
|
return tex2uni[symbol.strip("\\")]
|
|
except KeyError as err:
|
|
raise ValueError(
|
|
f"{symbol!r} is not a valid Unicode character or TeX/Type1 symbol"
|
|
) from err
|
|
|
|
|
|
class VectorParse(NamedTuple):
|
|
"""
|
|
The namedtuple type returned by ``MathTextParser("path").parse(...)``.
|
|
|
|
Attributes
|
|
----------
|
|
width, height, depth : float
|
|
The global metrics.
|
|
glyphs : list
|
|
The glyphs including their positions.
|
|
rect : list
|
|
The list of rectangles.
|
|
"""
|
|
width: float
|
|
height: float
|
|
depth: float
|
|
glyphs: list[tuple[FT2Font, float, int, float, float]]
|
|
rects: list[tuple[float, float, float, float]]
|
|
|
|
VectorParse.__module__ = "matplotlib.mathtext"
|
|
|
|
|
|
class RasterParse(NamedTuple):
|
|
"""
|
|
The namedtuple type returned by ``MathTextParser("agg").parse(...)``.
|
|
|
|
Attributes
|
|
----------
|
|
ox, oy : float
|
|
The offsets are always zero.
|
|
width, height, depth : float
|
|
The global metrics.
|
|
image : FT2Image
|
|
A raster image.
|
|
"""
|
|
ox: float
|
|
oy: float
|
|
width: float
|
|
height: float
|
|
depth: float
|
|
image: FT2Image
|
|
|
|
RasterParse.__module__ = "matplotlib.mathtext"
|
|
|
|
|
|
class Output:
|
|
r"""
|
|
Result of `ship`\ping a box: lists of positioned glyphs and rectangles.
|
|
|
|
This class is not exposed to end users, but converted to a `VectorParse` or
|
|
a `RasterParse` by `.MathTextParser.parse`.
|
|
"""
|
|
|
|
def __init__(self, box: Box):
|
|
self.box = box
|
|
self.glyphs: list[tuple[float, float, FontInfo]] = [] # (ox, oy, info)
|
|
self.rects: list[tuple[float, float, float, float]] = [] # (x1, y1, x2, y2)
|
|
|
|
def to_vector(self) -> VectorParse:
|
|
w, h, d = map(
|
|
np.ceil, [self.box.width, self.box.height, self.box.depth])
|
|
gs = [(info.font, info.fontsize, info.num, ox, h - oy + info.offset)
|
|
for ox, oy, info in self.glyphs]
|
|
rs = [(x1, h - y2, x2 - x1, y2 - y1)
|
|
for x1, y1, x2, y2 in self.rects]
|
|
return VectorParse(w, h + d, d, gs, rs)
|
|
|
|
def to_raster(self, *, antialiased: bool) -> RasterParse:
|
|
# Metrics y's and mathtext y's are oriented in opposite directions,
|
|
# hence the switch between ymin and ymax.
|
|
xmin = min([*[ox + info.metrics.xmin for ox, oy, info in self.glyphs],
|
|
*[x1 for x1, y1, x2, y2 in self.rects], 0]) - 1
|
|
ymin = min([*[oy - info.metrics.ymax for ox, oy, info in self.glyphs],
|
|
*[y1 for x1, y1, x2, y2 in self.rects], 0]) - 1
|
|
xmax = max([*[ox + info.metrics.xmax for ox, oy, info in self.glyphs],
|
|
*[x2 for x1, y1, x2, y2 in self.rects], 0]) + 1
|
|
ymax = max([*[oy - info.metrics.ymin for ox, oy, info in self.glyphs],
|
|
*[y2 for x1, y1, x2, y2 in self.rects], 0]) + 1
|
|
w = xmax - xmin
|
|
h = ymax - ymin - self.box.depth
|
|
d = ymax - ymin - self.box.height
|
|
image = FT2Image(np.ceil(w), np.ceil(h + max(d, 0)))
|
|
|
|
# Ideally, we could just use self.glyphs and self.rects here, shifting
|
|
# their coordinates by (-xmin, -ymin), but this yields slightly
|
|
# different results due to floating point slop; shipping twice is the
|
|
# old approach and keeps baseline images backcompat.
|
|
shifted = ship(self.box, (-xmin, -ymin))
|
|
|
|
for ox, oy, info in shifted.glyphs:
|
|
info.font.draw_glyph_to_bitmap(
|
|
image, ox, oy - info.metrics.iceberg, info.glyph,
|
|
antialiased=antialiased)
|
|
for x1, y1, x2, y2 in shifted.rects:
|
|
height = max(int(y2 - y1) - 1, 0)
|
|
if height == 0:
|
|
center = (y2 + y1) / 2
|
|
y = int(center - (height + 1) / 2)
|
|
else:
|
|
y = int(y1)
|
|
image.draw_rect_filled(int(x1), y, np.ceil(x2), y + height)
|
|
return RasterParse(0, 0, w, h + d, d, image)
|
|
|
|
|
|
class FontMetrics(NamedTuple):
|
|
"""
|
|
Metrics of a font.
|
|
|
|
Attributes
|
|
----------
|
|
advance : float
|
|
The advance distance (in points) of the glyph.
|
|
height : float
|
|
The height of the glyph in points.
|
|
width : float
|
|
The width of the glyph in points.
|
|
xmin, xmax, ymin, ymax : float
|
|
The ink rectangle of the glyph.
|
|
iceberg : float
|
|
The distance from the baseline to the top of the glyph. (This corresponds to
|
|
TeX's definition of "height".)
|
|
slanted : bool
|
|
Whether the glyph should be considered as "slanted" (currently used for kerning
|
|
sub/superscripts).
|
|
"""
|
|
advance: float
|
|
height: float
|
|
width: float
|
|
xmin: float
|
|
xmax: float
|
|
ymin: float
|
|
ymax: float
|
|
iceberg: float
|
|
slanted: bool
|
|
|
|
|
|
class FontInfo(NamedTuple):
|
|
font: FT2Font
|
|
fontsize: float
|
|
postscript_name: str
|
|
metrics: FontMetrics
|
|
num: int
|
|
glyph: Glyph
|
|
offset: float
|
|
|
|
|
|
class Fonts(abc.ABC):
|
|
"""
|
|
An abstract base class for a system of fonts to use for mathtext.
|
|
|
|
The class must be able to take symbol keys and font file names and
|
|
return the character metrics. It also delegates to a backend class
|
|
to do the actual drawing.
|
|
"""
|
|
|
|
def __init__(self, default_font_prop: FontProperties, load_glyph_flags: int):
|
|
"""
|
|
Parameters
|
|
----------
|
|
default_font_prop : `~.font_manager.FontProperties`
|
|
The default non-math font, or the base font for Unicode (generic)
|
|
font rendering.
|
|
load_glyph_flags : int
|
|
Flags passed to the glyph loader (e.g. ``FT_Load_Glyph`` and
|
|
``FT_Load_Char`` for FreeType-based fonts).
|
|
"""
|
|
self.default_font_prop = default_font_prop
|
|
self.load_glyph_flags = load_glyph_flags
|
|
|
|
def get_kern(self, font1: str, fontclass1: str, sym1: str, fontsize1: float,
|
|
font2: str, fontclass2: str, sym2: str, fontsize2: float,
|
|
dpi: float) -> float:
|
|
"""
|
|
Get the kerning distance for font between *sym1* and *sym2*.
|
|
|
|
See `~.Fonts.get_metrics` for a detailed description of the parameters.
|
|
"""
|
|
return 0.
|
|
|
|
def _get_font(self, font: str) -> FT2Font:
|
|
raise NotImplementedError
|
|
|
|
def _get_info(self, font: str, font_class: str, sym: str, fontsize: float,
|
|
dpi: float) -> FontInfo:
|
|
raise NotImplementedError
|
|
|
|
def get_metrics(self, font: str, font_class: str, sym: str, fontsize: float,
|
|
dpi: float) -> FontMetrics:
|
|
r"""
|
|
Parameters
|
|
----------
|
|
font : str
|
|
One of the TeX font names: "tt", "it", "rm", "cal", "sf", "bf",
|
|
"default", "regular", "bb", "frak", "scr". "default" and "regular"
|
|
are synonyms and use the non-math font.
|
|
font_class : str
|
|
One of the TeX font names (as for *font*), but **not** "bb",
|
|
"frak", or "scr". This is used to combine two font classes. The
|
|
only supported combination currently is ``get_metrics("frak", "bf",
|
|
...)``.
|
|
sym : str
|
|
A symbol in raw TeX form, e.g., "1", "x", or "\sigma".
|
|
fontsize : float
|
|
Font size in points.
|
|
dpi : float
|
|
Rendering dots-per-inch.
|
|
|
|
Returns
|
|
-------
|
|
FontMetrics
|
|
"""
|
|
info = self._get_info(font, font_class, sym, fontsize, dpi)
|
|
return info.metrics
|
|
|
|
def render_glyph(self, output: Output, ox: float, oy: float, font: str,
|
|
font_class: str, sym: str, fontsize: float, dpi: float) -> None:
|
|
"""
|
|
At position (*ox*, *oy*), draw the glyph specified by the remaining
|
|
parameters (see `get_metrics` for their detailed description).
|
|
"""
|
|
info = self._get_info(font, font_class, sym, fontsize, dpi)
|
|
output.glyphs.append((ox, oy, info))
|
|
|
|
def render_rect_filled(self, output: Output,
|
|
x1: float, y1: float, x2: float, y2: float) -> None:
|
|
"""
|
|
Draw a filled rectangle from (*x1*, *y1*) to (*x2*, *y2*).
|
|
"""
|
|
output.rects.append((x1, y1, x2, y2))
|
|
|
|
def get_xheight(self, font: str, fontsize: float, dpi: float) -> float:
|
|
"""
|
|
Get the xheight for the given *font* and *fontsize*.
|
|
"""
|
|
raise NotImplementedError()
|
|
|
|
def get_underline_thickness(self, font: str, fontsize: float, dpi: float) -> float:
|
|
"""
|
|
Get the line thickness that matches the given font. Used as a
|
|
base unit for drawing lines such as in a fraction or radical.
|
|
"""
|
|
raise NotImplementedError()
|
|
|
|
def get_sized_alternatives_for_symbol(self, fontname: str,
|
|
sym: str) -> list[tuple[str, str]]:
|
|
"""
|
|
Override if your font provides multiple sizes of the same
|
|
symbol. Should return a list of symbols matching *sym* in
|
|
various sizes. The expression renderer will select the most
|
|
appropriate size for a given situation from this list.
|
|
"""
|
|
return [(fontname, sym)]
|
|
|
|
|
|
class TruetypeFonts(Fonts, metaclass=abc.ABCMeta):
|
|
"""
|
|
A generic base class for all font setups that use Truetype fonts
|
|
(through FT2Font).
|
|
"""
|
|
|
|
def __init__(self, default_font_prop: FontProperties, load_glyph_flags: int):
|
|
super().__init__(default_font_prop, load_glyph_flags)
|
|
# Per-instance cache.
|
|
self._get_info = functools.cache(self._get_info) # type: ignore[method-assign]
|
|
self._fonts = {}
|
|
self.fontmap: dict[str | int, str] = {}
|
|
|
|
filename = findfont(self.default_font_prop)
|
|
default_font = get_font(filename)
|
|
self._fonts['default'] = default_font
|
|
self._fonts['regular'] = default_font
|
|
|
|
def _get_font(self, font: str | int) -> FT2Font:
|
|
if font in self.fontmap:
|
|
basename = self.fontmap[font]
|
|
else:
|
|
# NOTE: An int is only passed by subclasses which have placed int keys into
|
|
# `self.fontmap`, so we must cast this to confirm it to typing.
|
|
basename = T.cast(str, font)
|
|
cached_font = self._fonts.get(basename)
|
|
if cached_font is None and os.path.exists(basename):
|
|
cached_font = get_font(basename)
|
|
self._fonts[basename] = cached_font
|
|
self._fonts[cached_font.postscript_name] = cached_font
|
|
self._fonts[cached_font.postscript_name.lower()] = cached_font
|
|
return T.cast(FT2Font, cached_font) # FIXME: Not sure this is guaranteed.
|
|
|
|
def _get_offset(self, font: FT2Font, glyph: Glyph, fontsize: float,
|
|
dpi: float) -> float:
|
|
if font.postscript_name == 'Cmex10':
|
|
return (glyph.height / 64 / 2) + (fontsize/3 * dpi/72)
|
|
return 0.
|
|
|
|
def _get_glyph(self, fontname: str, font_class: str,
|
|
sym: str) -> tuple[FT2Font, int, bool]:
|
|
raise NotImplementedError
|
|
|
|
# The return value of _get_info is cached per-instance.
|
|
def _get_info(self, fontname: str, font_class: str, sym: str, fontsize: float,
|
|
dpi: float) -> FontInfo:
|
|
font, num, slanted = self._get_glyph(fontname, font_class, sym)
|
|
font.set_size(fontsize, dpi)
|
|
glyph = font.load_char(num, flags=self.load_glyph_flags)
|
|
|
|
xmin, ymin, xmax, ymax = [val/64.0 for val in glyph.bbox]
|
|
offset = self._get_offset(font, glyph, fontsize, dpi)
|
|
metrics = FontMetrics(
|
|
advance = glyph.linearHoriAdvance/65536.0,
|
|
height = glyph.height/64.0,
|
|
width = glyph.width/64.0,
|
|
xmin = xmin,
|
|
xmax = xmax,
|
|
ymin = ymin+offset,
|
|
ymax = ymax+offset,
|
|
# iceberg is the equivalent of TeX's "height"
|
|
iceberg = glyph.horiBearingY/64.0 + offset,
|
|
slanted = slanted
|
|
)
|
|
|
|
return FontInfo(
|
|
font = font,
|
|
fontsize = fontsize,
|
|
postscript_name = font.postscript_name,
|
|
metrics = metrics,
|
|
num = num,
|
|
glyph = glyph,
|
|
offset = offset
|
|
)
|
|
|
|
def get_xheight(self, fontname: str, fontsize: float, dpi: float) -> float:
|
|
font = self._get_font(fontname)
|
|
font.set_size(fontsize, dpi)
|
|
pclt = font.get_sfnt_table('pclt')
|
|
if pclt is None:
|
|
# Some fonts don't store the xHeight, so we do a poor man's xHeight
|
|
metrics = self.get_metrics(
|
|
fontname, mpl.rcParams['mathtext.default'], 'x', fontsize, dpi)
|
|
return metrics.iceberg
|
|
xHeight = (pclt['xHeight'] / 64.0) * (fontsize / 12.0) * (dpi / 100.0)
|
|
return xHeight
|
|
|
|
def get_underline_thickness(self, font: str, fontsize: float, dpi: float) -> float:
|
|
# This function used to grab underline thickness from the font
|
|
# metrics, but that information is just too un-reliable, so it
|
|
# is now hardcoded.
|
|
return ((0.75 / 12.0) * fontsize * dpi) / 72.0
|
|
|
|
def get_kern(self, font1: str, fontclass1: str, sym1: str, fontsize1: float,
|
|
font2: str, fontclass2: str, sym2: str, fontsize2: float,
|
|
dpi: float) -> float:
|
|
if font1 == font2 and fontsize1 == fontsize2:
|
|
info1 = self._get_info(font1, fontclass1, sym1, fontsize1, dpi)
|
|
info2 = self._get_info(font2, fontclass2, sym2, fontsize2, dpi)
|
|
font = info1.font
|
|
return font.get_kerning(info1.num, info2.num, KERNING_DEFAULT) / 64
|
|
return super().get_kern(font1, fontclass1, sym1, fontsize1,
|
|
font2, fontclass2, sym2, fontsize2, dpi)
|
|
|
|
|
|
class BakomaFonts(TruetypeFonts):
|
|
"""
|
|
Use the Bakoma TrueType fonts for rendering.
|
|
|
|
Symbols are strewn about a number of font files, each of which has
|
|
its own proprietary 8-bit encoding.
|
|
"""
|
|
_fontmap = {
|
|
'cal': 'cmsy10',
|
|
'rm': 'cmr10',
|
|
'tt': 'cmtt10',
|
|
'it': 'cmmi10',
|
|
'bf': 'cmb10',
|
|
'sf': 'cmss10',
|
|
'ex': 'cmex10',
|
|
}
|
|
|
|
def __init__(self, default_font_prop: FontProperties, load_glyph_flags: int):
|
|
self._stix_fallback = StixFonts(default_font_prop, load_glyph_flags)
|
|
|
|
super().__init__(default_font_prop, load_glyph_flags)
|
|
for key, val in self._fontmap.items():
|
|
fullpath = findfont(val)
|
|
self.fontmap[key] = fullpath
|
|
self.fontmap[val] = fullpath
|
|
|
|
_slanted_symbols = set(r"\int \oint".split())
|
|
|
|
def _get_glyph(self, fontname: str, font_class: str,
|
|
sym: str) -> tuple[FT2Font, int, bool]:
|
|
font = None
|
|
if fontname in self.fontmap and sym in latex_to_bakoma:
|
|
basename, num = latex_to_bakoma[sym]
|
|
slanted = (basename == "cmmi10") or sym in self._slanted_symbols
|
|
font = self._get_font(basename)
|
|
elif len(sym) == 1:
|
|
slanted = (fontname == "it")
|
|
font = self._get_font(fontname)
|
|
if font is not None:
|
|
num = ord(sym)
|
|
if font is not None and font.get_char_index(num) != 0:
|
|
return font, num, slanted
|
|
else:
|
|
return self._stix_fallback._get_glyph(fontname, font_class, sym)
|
|
|
|
# The Bakoma fonts contain many pre-sized alternatives for the
|
|
# delimiters. The AutoSizedChar class will use these alternatives
|
|
# and select the best (closest sized) glyph.
|
|
_size_alternatives = {
|
|
'(': [('rm', '('), ('ex', '\xa1'), ('ex', '\xb3'),
|
|
('ex', '\xb5'), ('ex', '\xc3')],
|
|
')': [('rm', ')'), ('ex', '\xa2'), ('ex', '\xb4'),
|
|
('ex', '\xb6'), ('ex', '\x21')],
|
|
'{': [('cal', '{'), ('ex', '\xa9'), ('ex', '\x6e'),
|
|
('ex', '\xbd'), ('ex', '\x28')],
|
|
'}': [('cal', '}'), ('ex', '\xaa'), ('ex', '\x6f'),
|
|
('ex', '\xbe'), ('ex', '\x29')],
|
|
# The fourth size of '[' is mysteriously missing from the BaKoMa
|
|
# font, so I've omitted it for both '[' and ']'
|
|
'[': [('rm', '['), ('ex', '\xa3'), ('ex', '\x68'),
|
|
('ex', '\x22')],
|
|
']': [('rm', ']'), ('ex', '\xa4'), ('ex', '\x69'),
|
|
('ex', '\x23')],
|
|
r'\lfloor': [('ex', '\xa5'), ('ex', '\x6a'),
|
|
('ex', '\xb9'), ('ex', '\x24')],
|
|
r'\rfloor': [('ex', '\xa6'), ('ex', '\x6b'),
|
|
('ex', '\xba'), ('ex', '\x25')],
|
|
r'\lceil': [('ex', '\xa7'), ('ex', '\x6c'),
|
|
('ex', '\xbb'), ('ex', '\x26')],
|
|
r'\rceil': [('ex', '\xa8'), ('ex', '\x6d'),
|
|
('ex', '\xbc'), ('ex', '\x27')],
|
|
r'\langle': [('ex', '\xad'), ('ex', '\x44'),
|
|
('ex', '\xbf'), ('ex', '\x2a')],
|
|
r'\rangle': [('ex', '\xae'), ('ex', '\x45'),
|
|
('ex', '\xc0'), ('ex', '\x2b')],
|
|
r'\__sqrt__': [('ex', '\x70'), ('ex', '\x71'),
|
|
('ex', '\x72'), ('ex', '\x73')],
|
|
r'\backslash': [('ex', '\xb2'), ('ex', '\x2f'),
|
|
('ex', '\xc2'), ('ex', '\x2d')],
|
|
r'/': [('rm', '/'), ('ex', '\xb1'), ('ex', '\x2e'),
|
|
('ex', '\xcb'), ('ex', '\x2c')],
|
|
r'\widehat': [('rm', '\x5e'), ('ex', '\x62'), ('ex', '\x63'),
|
|
('ex', '\x64')],
|
|
r'\widetilde': [('rm', '\x7e'), ('ex', '\x65'), ('ex', '\x66'),
|
|
('ex', '\x67')],
|
|
r'<': [('cal', 'h'), ('ex', 'D')],
|
|
r'>': [('cal', 'i'), ('ex', 'E')]
|
|
}
|
|
|
|
for alias, target in [(r'\leftparen', '('),
|
|
(r'\rightparent', ')'),
|
|
(r'\leftbrace', '{'),
|
|
(r'\rightbrace', '}'),
|
|
(r'\leftbracket', '['),
|
|
(r'\rightbracket', ']'),
|
|
(r'\{', '{'),
|
|
(r'\}', '}'),
|
|
(r'\[', '['),
|
|
(r'\]', ']')]:
|
|
_size_alternatives[alias] = _size_alternatives[target]
|
|
|
|
def get_sized_alternatives_for_symbol(self, fontname: str,
|
|
sym: str) -> list[tuple[str, str]]:
|
|
return self._size_alternatives.get(sym, [(fontname, sym)])
|
|
|
|
|
|
class UnicodeFonts(TruetypeFonts):
|
|
"""
|
|
An abstract base class for handling Unicode fonts.
|
|
|
|
While some reasonably complete Unicode fonts (such as DejaVu) may
|
|
work in some situations, the only Unicode font I'm aware of with a
|
|
complete set of math symbols is STIX.
|
|
|
|
This class will "fallback" on the Bakoma fonts when a required
|
|
symbol cannot be found in the font.
|
|
"""
|
|
|
|
# Some glyphs are not present in the `cmr10` font, and must be brought in
|
|
# from `cmsy10`. Map the Unicode indices of those glyphs to the indices at
|
|
# which they are found in `cmsy10`.
|
|
_cmr10_substitutions = {
|
|
0x00D7: 0x00A3, # Multiplication sign.
|
|
0x2212: 0x00A1, # Minus sign.
|
|
}
|
|
|
|
def __init__(self, default_font_prop: FontProperties, load_glyph_flags: int):
|
|
# This must come first so the backend's owner is set correctly
|
|
fallback_rc = mpl.rcParams['mathtext.fallback']
|
|
font_cls: type[TruetypeFonts] | None = {
|
|
'stix': StixFonts,
|
|
'stixsans': StixSansFonts,
|
|
'cm': BakomaFonts
|
|
}.get(fallback_rc)
|
|
self._fallback_font = (font_cls(default_font_prop, load_glyph_flags)
|
|
if font_cls else None)
|
|
|
|
super().__init__(default_font_prop, load_glyph_flags)
|
|
for texfont in "cal rm tt it bf sf bfit".split():
|
|
prop = mpl.rcParams['mathtext.' + texfont]
|
|
font = findfont(prop)
|
|
self.fontmap[texfont] = font
|
|
prop = FontProperties('cmex10')
|
|
font = findfont(prop)
|
|
self.fontmap['ex'] = font
|
|
|
|
# include STIX sized alternatives for glyphs if fallback is STIX
|
|
if isinstance(self._fallback_font, StixFonts):
|
|
stixsizedaltfonts = {
|
|
0: 'STIXGeneral',
|
|
1: 'STIXSizeOneSym',
|
|
2: 'STIXSizeTwoSym',
|
|
3: 'STIXSizeThreeSym',
|
|
4: 'STIXSizeFourSym',
|
|
5: 'STIXSizeFiveSym'}
|
|
|
|
for size, name in stixsizedaltfonts.items():
|
|
fullpath = findfont(name)
|
|
self.fontmap[size] = fullpath
|
|
self.fontmap[name] = fullpath
|
|
|
|
_slanted_symbols = set(r"\int \oint".split())
|
|
|
|
def _map_virtual_font(self, fontname: str, font_class: str,
|
|
uniindex: int) -> tuple[str, int]:
|
|
return fontname, uniindex
|
|
|
|
def _get_glyph(self, fontname: str, font_class: str,
|
|
sym: str) -> tuple[FT2Font, int, bool]:
|
|
try:
|
|
uniindex = get_unicode_index(sym)
|
|
found_symbol = True
|
|
except ValueError:
|
|
uniindex = ord('?')
|
|
found_symbol = False
|
|
_log.warning("No TeX to Unicode mapping for %a.", sym)
|
|
|
|
fontname, uniindex = self._map_virtual_font(
|
|
fontname, font_class, uniindex)
|
|
|
|
new_fontname = fontname
|
|
|
|
# Only characters in the "Letter" class should be italicized in 'it'
|
|
# mode. Greek capital letters should be Roman.
|
|
if found_symbol:
|
|
if fontname == 'it' and uniindex < 0x10000:
|
|
char = chr(uniindex)
|
|
if (unicodedata.category(char)[0] != "L"
|
|
or unicodedata.name(char).startswith("GREEK CAPITAL")):
|
|
new_fontname = 'rm'
|
|
|
|
slanted = (new_fontname == 'it') or sym in self._slanted_symbols
|
|
found_symbol = False
|
|
font = self._get_font(new_fontname)
|
|
if font is not None:
|
|
if (uniindex in self._cmr10_substitutions
|
|
and font.family_name == "cmr10"):
|
|
font = get_font(
|
|
cbook._get_data_path("fonts/ttf/cmsy10.ttf"))
|
|
uniindex = self._cmr10_substitutions[uniindex]
|
|
glyphindex = font.get_char_index(uniindex)
|
|
if glyphindex != 0:
|
|
found_symbol = True
|
|
|
|
if not found_symbol:
|
|
if self._fallback_font:
|
|
if (fontname in ('it', 'regular')
|
|
and isinstance(self._fallback_font, StixFonts)):
|
|
fontname = 'rm'
|
|
|
|
g = self._fallback_font._get_glyph(fontname, font_class, sym)
|
|
family = g[0].family_name
|
|
if family in list(BakomaFonts._fontmap.values()):
|
|
family = "Computer Modern"
|
|
_log.info("Substituting symbol %s from %s", sym, family)
|
|
return g
|
|
|
|
else:
|
|
if (fontname in ('it', 'regular')
|
|
and isinstance(self, StixFonts)):
|
|
return self._get_glyph('rm', font_class, sym)
|
|
_log.warning("Font %r does not have a glyph for %a [U+%x], "
|
|
"substituting with a dummy symbol.",
|
|
new_fontname, sym, uniindex)
|
|
font = self._get_font('rm')
|
|
uniindex = 0xA4 # currency char, for lack of anything better
|
|
slanted = False
|
|
|
|
return font, uniindex, slanted
|
|
|
|
def get_sized_alternatives_for_symbol(self, fontname: str,
|
|
sym: str) -> list[tuple[str, str]]:
|
|
if self._fallback_font:
|
|
return self._fallback_font.get_sized_alternatives_for_symbol(
|
|
fontname, sym)
|
|
return [(fontname, sym)]
|
|
|
|
|
|
class DejaVuFonts(UnicodeFonts, metaclass=abc.ABCMeta):
|
|
_fontmap: dict[str | int, str] = {}
|
|
|
|
def __init__(self, default_font_prop: FontProperties, load_glyph_flags: int):
|
|
# This must come first so the backend's owner is set correctly
|
|
if isinstance(self, DejaVuSerifFonts):
|
|
self._fallback_font = StixFonts(default_font_prop, load_glyph_flags)
|
|
else:
|
|
self._fallback_font = StixSansFonts(default_font_prop, load_glyph_flags)
|
|
self.bakoma = BakomaFonts(default_font_prop, load_glyph_flags)
|
|
TruetypeFonts.__init__(self, default_font_prop, load_glyph_flags)
|
|
# Include Stix sized alternatives for glyphs
|
|
self._fontmap.update({
|
|
1: 'STIXSizeOneSym',
|
|
2: 'STIXSizeTwoSym',
|
|
3: 'STIXSizeThreeSym',
|
|
4: 'STIXSizeFourSym',
|
|
5: 'STIXSizeFiveSym',
|
|
})
|
|
for key, name in self._fontmap.items():
|
|
fullpath = findfont(name)
|
|
self.fontmap[key] = fullpath
|
|
self.fontmap[name] = fullpath
|
|
|
|
def _get_glyph(self, fontname: str, font_class: str,
|
|
sym: str) -> tuple[FT2Font, int, bool]:
|
|
# Override prime symbol to use Bakoma.
|
|
if sym == r'\prime':
|
|
return self.bakoma._get_glyph(fontname, font_class, sym)
|
|
else:
|
|
# check whether the glyph is available in the display font
|
|
uniindex = get_unicode_index(sym)
|
|
font = self._get_font('ex')
|
|
if font is not None:
|
|
glyphindex = font.get_char_index(uniindex)
|
|
if glyphindex != 0:
|
|
return super()._get_glyph('ex', font_class, sym)
|
|
# otherwise return regular glyph
|
|
return super()._get_glyph(fontname, font_class, sym)
|
|
|
|
|
|
class DejaVuSerifFonts(DejaVuFonts):
|
|
"""
|
|
A font handling class for the DejaVu Serif fonts
|
|
|
|
If a glyph is not found it will fallback to Stix Serif
|
|
"""
|
|
_fontmap = {
|
|
'rm': 'DejaVu Serif',
|
|
'it': 'DejaVu Serif:italic',
|
|
'bf': 'DejaVu Serif:weight=bold',
|
|
'bfit': 'DejaVu Serif:italic:bold',
|
|
'sf': 'DejaVu Sans',
|
|
'tt': 'DejaVu Sans Mono',
|
|
'ex': 'DejaVu Serif Display',
|
|
0: 'DejaVu Serif',
|
|
}
|
|
|
|
|
|
class DejaVuSansFonts(DejaVuFonts):
|
|
"""
|
|
A font handling class for the DejaVu Sans fonts
|
|
|
|
If a glyph is not found it will fallback to Stix Sans
|
|
"""
|
|
_fontmap = {
|
|
'rm': 'DejaVu Sans',
|
|
'it': 'DejaVu Sans:italic',
|
|
'bf': 'DejaVu Sans:weight=bold',
|
|
'bfit': 'DejaVu Sans:italic:bold',
|
|
'sf': 'DejaVu Sans',
|
|
'tt': 'DejaVu Sans Mono',
|
|
'ex': 'DejaVu Sans Display',
|
|
0: 'DejaVu Sans',
|
|
}
|
|
|
|
|
|
class StixFonts(UnicodeFonts):
|
|
"""
|
|
A font handling class for the STIX fonts.
|
|
|
|
In addition to what UnicodeFonts provides, this class:
|
|
|
|
- supports "virtual fonts" which are complete alpha numeric
|
|
character sets with different font styles at special Unicode
|
|
code points, such as "Blackboard".
|
|
|
|
- handles sized alternative characters for the STIXSizeX fonts.
|
|
"""
|
|
_fontmap: dict[str | int, str] = {
|
|
'rm': 'STIXGeneral',
|
|
'it': 'STIXGeneral:italic',
|
|
'bf': 'STIXGeneral:weight=bold',
|
|
'bfit': 'STIXGeneral:italic:bold',
|
|
'nonunirm': 'STIXNonUnicode',
|
|
'nonuniit': 'STIXNonUnicode:italic',
|
|
'nonunibf': 'STIXNonUnicode:weight=bold',
|
|
0: 'STIXGeneral',
|
|
1: 'STIXSizeOneSym',
|
|
2: 'STIXSizeTwoSym',
|
|
3: 'STIXSizeThreeSym',
|
|
4: 'STIXSizeFourSym',
|
|
5: 'STIXSizeFiveSym',
|
|
}
|
|
_fallback_font = None
|
|
_sans = False
|
|
|
|
def __init__(self, default_font_prop: FontProperties, load_glyph_flags: int):
|
|
TruetypeFonts.__init__(self, default_font_prop, load_glyph_flags)
|
|
for key, name in self._fontmap.items():
|
|
fullpath = findfont(name)
|
|
self.fontmap[key] = fullpath
|
|
self.fontmap[name] = fullpath
|
|
|
|
def _map_virtual_font(self, fontname: str, font_class: str,
|
|
uniindex: int) -> tuple[str, int]:
|
|
# Handle these "fonts" that are actually embedded in
|
|
# other fonts.
|
|
font_mapping = stix_virtual_fonts.get(fontname)
|
|
if (self._sans and font_mapping is None
|
|
and fontname not in ('regular', 'default')):
|
|
font_mapping = stix_virtual_fonts['sf']
|
|
doing_sans_conversion = True
|
|
else:
|
|
doing_sans_conversion = False
|
|
|
|
if isinstance(font_mapping, dict):
|
|
try:
|
|
mapping = font_mapping[font_class]
|
|
except KeyError:
|
|
mapping = font_mapping['rm']
|
|
elif isinstance(font_mapping, list):
|
|
mapping = font_mapping
|
|
else:
|
|
mapping = None
|
|
|
|
if mapping is not None:
|
|
# Binary search for the source glyph
|
|
lo = 0
|
|
hi = len(mapping)
|
|
while lo < hi:
|
|
mid = (lo+hi)//2
|
|
range = mapping[mid]
|
|
if uniindex < range[0]:
|
|
hi = mid
|
|
elif uniindex <= range[1]:
|
|
break
|
|
else:
|
|
lo = mid + 1
|
|
|
|
if range[0] <= uniindex <= range[1]:
|
|
uniindex = uniindex - range[0] + range[3]
|
|
fontname = range[2]
|
|
elif not doing_sans_conversion:
|
|
# This will generate a dummy character
|
|
uniindex = 0x1
|
|
fontname = mpl.rcParams['mathtext.default']
|
|
|
|
# Fix some incorrect glyphs.
|
|
if fontname in ('rm', 'it'):
|
|
uniindex = stix_glyph_fixes.get(uniindex, uniindex)
|
|
|
|
# Handle private use area glyphs
|
|
if fontname in ('it', 'rm', 'bf', 'bfit') and 0xe000 <= uniindex <= 0xf8ff:
|
|
fontname = 'nonuni' + fontname
|
|
|
|
return fontname, uniindex
|
|
|
|
@functools.cache
|
|
def get_sized_alternatives_for_symbol( # type: ignore[override]
|
|
self,
|
|
fontname: str,
|
|
sym: str) -> list[tuple[str, str]] | list[tuple[int, str]]:
|
|
fixes = {
|
|
'\\{': '{', '\\}': '}', '\\[': '[', '\\]': ']',
|
|
'<': '\N{MATHEMATICAL LEFT ANGLE BRACKET}',
|
|
'>': '\N{MATHEMATICAL RIGHT ANGLE BRACKET}',
|
|
}
|
|
sym = fixes.get(sym, sym)
|
|
try:
|
|
uniindex = get_unicode_index(sym)
|
|
except ValueError:
|
|
return [(fontname, sym)]
|
|
alternatives = [(i, chr(uniindex)) for i in range(6)
|
|
if self._get_font(i).get_char_index(uniindex) != 0]
|
|
# The largest size of the radical symbol in STIX has incorrect
|
|
# metrics that cause it to be disconnected from the stem.
|
|
if sym == r'\__sqrt__':
|
|
alternatives = alternatives[:-1]
|
|
return alternatives
|
|
|
|
|
|
class StixSansFonts(StixFonts):
|
|
"""
|
|
A font handling class for the STIX fonts (that uses sans-serif
|
|
characters by default).
|
|
"""
|
|
_sans = True
|
|
|
|
|
|
##############################################################################
|
|
# TeX-LIKE BOX MODEL
|
|
|
|
# The following is based directly on the document 'woven' from the
|
|
# TeX82 source code. This information is also available in printed
|
|
# form:
|
|
#
|
|
# Knuth, Donald E.. 1986. Computers and Typesetting, Volume B:
|
|
# TeX: The Program. Addison-Wesley Professional.
|
|
#
|
|
# The most relevant "chapters" are:
|
|
# Data structures for boxes and their friends
|
|
# Shipping pages out (ship())
|
|
# Packaging (hpack() and vpack())
|
|
# Data structures for math mode
|
|
# Subroutines for math mode
|
|
# Typesetting math formulas
|
|
#
|
|
# Many of the docstrings below refer to a numbered "node" in that
|
|
# book, e.g., node123
|
|
#
|
|
# Note that (as TeX) y increases downward, unlike many other parts of
|
|
# matplotlib.
|
|
|
|
# How much text shrinks when going to the next-smallest level.
|
|
SHRINK_FACTOR = 0.7
|
|
# The number of different sizes of chars to use, beyond which they will not
|
|
# get any smaller
|
|
NUM_SIZE_LEVELS = 6
|
|
|
|
|
|
class FontConstantsBase:
|
|
"""
|
|
A set of constants that controls how certain things, such as sub-
|
|
and superscripts are laid out. These are all metrics that can't
|
|
be reliably retrieved from the font metrics in the font itself.
|
|
"""
|
|
# Percentage of x-height of additional horiz. space after sub/superscripts
|
|
script_space: T.ClassVar[float] = 0.05
|
|
|
|
# Percentage of x-height that sub/superscripts drop below the baseline
|
|
subdrop: T.ClassVar[float] = 0.4
|
|
|
|
# Percentage of x-height that superscripts are raised from the baseline
|
|
sup1: T.ClassVar[float] = 0.7
|
|
|
|
# Percentage of x-height that subscripts drop below the baseline
|
|
sub1: T.ClassVar[float] = 0.3
|
|
|
|
# Percentage of x-height that subscripts drop below the baseline when a
|
|
# superscript is present
|
|
sub2: T.ClassVar[float] = 0.5
|
|
|
|
# Percentage of x-height that sub/superscripts are offset relative to the
|
|
# nucleus edge for non-slanted nuclei
|
|
delta: T.ClassVar[float] = 0.025
|
|
|
|
# Additional percentage of last character height above 2/3 of the
|
|
# x-height that superscripts are offset relative to the subscript
|
|
# for slanted nuclei
|
|
delta_slanted: T.ClassVar[float] = 0.2
|
|
|
|
# Percentage of x-height that superscripts and subscripts are offset for
|
|
# integrals
|
|
delta_integral: T.ClassVar[float] = 0.1
|
|
|
|
|
|
class ComputerModernFontConstants(FontConstantsBase):
|
|
script_space = 0.075
|
|
subdrop = 0.2
|
|
sup1 = 0.45
|
|
sub1 = 0.2
|
|
sub2 = 0.3
|
|
delta = 0.075
|
|
delta_slanted = 0.3
|
|
delta_integral = 0.3
|
|
|
|
|
|
class STIXFontConstants(FontConstantsBase):
|
|
script_space = 0.1
|
|
sup1 = 0.8
|
|
sub2 = 0.6
|
|
delta = 0.05
|
|
delta_slanted = 0.3
|
|
delta_integral = 0.3
|
|
|
|
|
|
class STIXSansFontConstants(FontConstantsBase):
|
|
script_space = 0.05
|
|
sup1 = 0.8
|
|
delta_slanted = 0.6
|
|
delta_integral = 0.3
|
|
|
|
|
|
class DejaVuSerifFontConstants(FontConstantsBase):
|
|
pass
|
|
|
|
|
|
class DejaVuSansFontConstants(FontConstantsBase):
|
|
pass
|
|
|
|
|
|
# Maps font family names to the FontConstantBase subclass to use
|
|
_font_constant_mapping = {
|
|
'DejaVu Sans': DejaVuSansFontConstants,
|
|
'DejaVu Sans Mono': DejaVuSansFontConstants,
|
|
'DejaVu Serif': DejaVuSerifFontConstants,
|
|
'cmb10': ComputerModernFontConstants,
|
|
'cmex10': ComputerModernFontConstants,
|
|
'cmmi10': ComputerModernFontConstants,
|
|
'cmr10': ComputerModernFontConstants,
|
|
'cmss10': ComputerModernFontConstants,
|
|
'cmsy10': ComputerModernFontConstants,
|
|
'cmtt10': ComputerModernFontConstants,
|
|
'STIXGeneral': STIXFontConstants,
|
|
'STIXNonUnicode': STIXFontConstants,
|
|
'STIXSizeFiveSym': STIXFontConstants,
|
|
'STIXSizeFourSym': STIXFontConstants,
|
|
'STIXSizeThreeSym': STIXFontConstants,
|
|
'STIXSizeTwoSym': STIXFontConstants,
|
|
'STIXSizeOneSym': STIXFontConstants,
|
|
# Map the fonts we used to ship, just for good measure
|
|
'Bitstream Vera Sans': DejaVuSansFontConstants,
|
|
'Bitstream Vera': DejaVuSansFontConstants,
|
|
}
|
|
|
|
|
|
def _get_font_constant_set(state: ParserState) -> type[FontConstantsBase]:
|
|
constants = _font_constant_mapping.get(
|
|
state.fontset._get_font(state.font).family_name, FontConstantsBase)
|
|
# STIX sans isn't really its own fonts, just different code points
|
|
# in the STIX fonts, so we have to detect this one separately.
|
|
if (constants is STIXFontConstants and
|
|
isinstance(state.fontset, StixSansFonts)):
|
|
return STIXSansFontConstants
|
|
return constants
|
|
|
|
|
|
class Node:
|
|
"""A node in the TeX box model."""
|
|
|
|
def __init__(self) -> None:
|
|
self.size = 0
|
|
|
|
def __repr__(self) -> str:
|
|
return type(self).__name__
|
|
|
|
def get_kerning(self, next: Node | None) -> float:
|
|
return 0.0
|
|
|
|
def shrink(self) -> None:
|
|
"""
|
|
Shrinks one level smaller. There are only three levels of
|
|
sizes, after which things will no longer get smaller.
|
|
"""
|
|
self.size += 1
|
|
|
|
def render(self, output: Output, x: float, y: float) -> None:
|
|
"""Render this node."""
|
|
|
|
|
|
class Box(Node):
|
|
"""A node with a physical location."""
|
|
|
|
def __init__(self, width: float, height: float, depth: float) -> None:
|
|
super().__init__()
|
|
self.width = width
|
|
self.height = height
|
|
self.depth = depth
|
|
|
|
def shrink(self) -> None:
|
|
super().shrink()
|
|
if self.size < NUM_SIZE_LEVELS:
|
|
self.width *= SHRINK_FACTOR
|
|
self.height *= SHRINK_FACTOR
|
|
self.depth *= SHRINK_FACTOR
|
|
|
|
def render(self, output: Output, # type: ignore[override]
|
|
x1: float, y1: float, x2: float, y2: float) -> None:
|
|
pass
|
|
|
|
|
|
class Vbox(Box):
|
|
"""A box with only height (zero width)."""
|
|
|
|
def __init__(self, height: float, depth: float):
|
|
super().__init__(0., height, depth)
|
|
|
|
|
|
class Hbox(Box):
|
|
"""A box with only width (zero height and depth)."""
|
|
|
|
def __init__(self, width: float):
|
|
super().__init__(width, 0., 0.)
|
|
|
|
|
|
class Char(Node):
|
|
"""
|
|
A single character.
|
|
|
|
Unlike TeX, the font information and metrics are stored with each `Char`
|
|
to make it easier to lookup the font metrics when needed. Note that TeX
|
|
boxes have a width, height, and depth, unlike Type1 and TrueType which use
|
|
a full bounding box and an advance in the x-direction. The metrics must
|
|
be converted to the TeX model, and the advance (if different from width)
|
|
must be converted into a `Kern` node when the `Char` is added to its parent
|
|
`Hlist`.
|
|
"""
|
|
|
|
def __init__(self, c: str, state: ParserState):
|
|
super().__init__()
|
|
self.c = c
|
|
self.fontset = state.fontset
|
|
self.font = state.font
|
|
self.font_class = state.font_class
|
|
self.fontsize = state.fontsize
|
|
self.dpi = state.dpi
|
|
# The real width, height and depth will be set during the
|
|
# pack phase, after we know the real fontsize
|
|
self._update_metrics()
|
|
|
|
def __repr__(self) -> str:
|
|
return '`%s`' % self.c
|
|
|
|
def _update_metrics(self) -> None:
|
|
metrics = self._metrics = self.fontset.get_metrics(
|
|
self.font, self.font_class, self.c, self.fontsize, self.dpi)
|
|
if self.c == ' ':
|
|
self.width = metrics.advance
|
|
else:
|
|
self.width = metrics.width
|
|
self.height = metrics.iceberg
|
|
self.depth = -(metrics.iceberg - metrics.height)
|
|
|
|
def is_slanted(self) -> bool:
|
|
return self._metrics.slanted
|
|
|
|
def get_kerning(self, next: Node | None) -> float:
|
|
"""
|
|
Return the amount of kerning between this and the given character.
|
|
|
|
This method is called when characters are strung together into `Hlist`
|
|
to create `Kern` nodes.
|
|
"""
|
|
advance = self._metrics.advance - self.width
|
|
kern = 0.
|
|
if isinstance(next, Char):
|
|
kern = self.fontset.get_kern(
|
|
self.font, self.font_class, self.c, self.fontsize,
|
|
next.font, next.font_class, next.c, next.fontsize,
|
|
self.dpi)
|
|
return advance + kern
|
|
|
|
def render(self, output: Output, x: float, y: float) -> None:
|
|
self.fontset.render_glyph(
|
|
output, x, y,
|
|
self.font, self.font_class, self.c, self.fontsize, self.dpi)
|
|
|
|
def shrink(self) -> None:
|
|
super().shrink()
|
|
if self.size < NUM_SIZE_LEVELS:
|
|
self.fontsize *= SHRINK_FACTOR
|
|
self.width *= SHRINK_FACTOR
|
|
self.height *= SHRINK_FACTOR
|
|
self.depth *= SHRINK_FACTOR
|
|
|
|
|
|
class Accent(Char):
|
|
"""
|
|
The font metrics need to be dealt with differently for accents,
|
|
since they are already offset correctly from the baseline in
|
|
TrueType fonts.
|
|
"""
|
|
def _update_metrics(self) -> None:
|
|
metrics = self._metrics = self.fontset.get_metrics(
|
|
self.font, self.font_class, self.c, self.fontsize, self.dpi)
|
|
self.width = metrics.xmax - metrics.xmin
|
|
self.height = metrics.ymax - metrics.ymin
|
|
self.depth = 0
|
|
|
|
def shrink(self) -> None:
|
|
super().shrink()
|
|
self._update_metrics()
|
|
|
|
def render(self, output: Output, x: float, y: float) -> None:
|
|
self.fontset.render_glyph(
|
|
output, x - self._metrics.xmin, y + self._metrics.ymin,
|
|
self.font, self.font_class, self.c, self.fontsize, self.dpi)
|
|
|
|
|
|
class List(Box):
|
|
"""A list of nodes (either horizontal or vertical)."""
|
|
|
|
def __init__(self, elements: T.Sequence[Node]):
|
|
super().__init__(0., 0., 0.)
|
|
self.shift_amount = 0. # An arbitrary offset
|
|
self.children = [*elements] # The child nodes of this list
|
|
# The following parameters are set in the vpack and hpack functions
|
|
self.glue_set = 0. # The glue setting of this list
|
|
self.glue_sign = 0 # 0: normal, -1: shrinking, 1: stretching
|
|
self.glue_order = 0 # The order of infinity (0 - 3) for the glue
|
|
|
|
def __repr__(self) -> str:
|
|
return '{}<w={:.02f} h={:.02f} d={:.02f} s={:.02f}>[{}]'.format(
|
|
super().__repr__(),
|
|
self.width, self.height,
|
|
self.depth, self.shift_amount,
|
|
', '.join([repr(x) for x in self.children]))
|
|
|
|
def _set_glue(self, x: float, sign: int, totals: list[float],
|
|
error_type: str) -> None:
|
|
self.glue_order = o = next(
|
|
# Highest order of glue used by the members of this list.
|
|
(i for i in range(len(totals))[::-1] if totals[i] != 0), 0)
|
|
self.glue_sign = sign
|
|
if totals[o] != 0.:
|
|
self.glue_set = x / totals[o]
|
|
else:
|
|
self.glue_sign = 0
|
|
self.glue_ratio = 0.
|
|
if o == 0:
|
|
if len(self.children):
|
|
_log.warning("%s %s: %r",
|
|
error_type, type(self).__name__, self)
|
|
|
|
def shrink(self) -> None:
|
|
for child in self.children:
|
|
child.shrink()
|
|
super().shrink()
|
|
if self.size < NUM_SIZE_LEVELS:
|
|
self.shift_amount *= SHRINK_FACTOR
|
|
self.glue_set *= SHRINK_FACTOR
|
|
|
|
|
|
class Hlist(List):
|
|
"""A horizontal list of boxes."""
|
|
|
|
def __init__(self, elements: T.Sequence[Node], w: float = 0.0,
|
|
m: T.Literal['additional', 'exactly'] = 'additional',
|
|
do_kern: bool = True):
|
|
super().__init__(elements)
|
|
if do_kern:
|
|
self.kern()
|
|
self.hpack(w=w, m=m)
|
|
|
|
def kern(self) -> None:
|
|
"""
|
|
Insert `Kern` nodes between `Char` nodes to set kerning.
|
|
|
|
The `Char` nodes themselves determine the amount of kerning they need
|
|
(in `~Char.get_kerning`), and this function just creates the correct
|
|
linked list.
|
|
"""
|
|
new_children = []
|
|
num_children = len(self.children)
|
|
if num_children:
|
|
for i in range(num_children):
|
|
elem = self.children[i]
|
|
if i < num_children - 1:
|
|
next = self.children[i + 1]
|
|
else:
|
|
next = None
|
|
|
|
new_children.append(elem)
|
|
kerning_distance = elem.get_kerning(next)
|
|
if kerning_distance != 0.:
|
|
kern = Kern(kerning_distance)
|
|
new_children.append(kern)
|
|
self.children = new_children
|
|
|
|
def hpack(self, w: float = 0.0,
|
|
m: T.Literal['additional', 'exactly'] = 'additional') -> None:
|
|
r"""
|
|
Compute the dimensions of the resulting boxes, and adjust the glue if
|
|
one of those dimensions is pre-specified. The computed sizes normally
|
|
enclose all of the material inside the new box; but some items may
|
|
stick out if negative glue is used, if the box is overfull, or if a
|
|
``\vbox`` includes other boxes that have been shifted left.
|
|
|
|
Parameters
|
|
----------
|
|
w : float, default: 0
|
|
A width.
|
|
m : {'exactly', 'additional'}, default: 'additional'
|
|
Whether to produce a box whose width is 'exactly' *w*; or a box
|
|
with the natural width of the contents, plus *w* ('additional').
|
|
|
|
Notes
|
|
-----
|
|
The defaults produce a box with the natural width of the contents.
|
|
"""
|
|
# I don't know why these get reset in TeX. Shift_amount is pretty
|
|
# much useless if we do.
|
|
# self.shift_amount = 0.
|
|
h = 0.
|
|
d = 0.
|
|
x = 0.
|
|
total_stretch = [0.] * 4
|
|
total_shrink = [0.] * 4
|
|
for p in self.children:
|
|
if isinstance(p, Char):
|
|
x += p.width
|
|
h = max(h, p.height)
|
|
d = max(d, p.depth)
|
|
elif isinstance(p, Box):
|
|
x += p.width
|
|
if not np.isinf(p.height) and not np.isinf(p.depth):
|
|
s = getattr(p, 'shift_amount', 0.)
|
|
h = max(h, p.height - s)
|
|
d = max(d, p.depth + s)
|
|
elif isinstance(p, Glue):
|
|
glue_spec = p.glue_spec
|
|
x += glue_spec.width
|
|
total_stretch[glue_spec.stretch_order] += glue_spec.stretch
|
|
total_shrink[glue_spec.shrink_order] += glue_spec.shrink
|
|
elif isinstance(p, Kern):
|
|
x += p.width
|
|
self.height = h
|
|
self.depth = d
|
|
|
|
if m == 'additional':
|
|
w += x
|
|
self.width = w
|
|
x = w - x
|
|
|
|
if x == 0.:
|
|
self.glue_sign = 0
|
|
self.glue_order = 0
|
|
self.glue_ratio = 0.
|
|
return
|
|
if x > 0.:
|
|
self._set_glue(x, 1, total_stretch, "Overful")
|
|
else:
|
|
self._set_glue(x, -1, total_shrink, "Underful")
|
|
|
|
|
|
class Vlist(List):
|
|
"""A vertical list of boxes."""
|
|
|
|
def __init__(self, elements: T.Sequence[Node], h: float = 0.0,
|
|
m: T.Literal['additional', 'exactly'] = 'additional'):
|
|
super().__init__(elements)
|
|
self.vpack(h=h, m=m)
|
|
|
|
def vpack(self, h: float = 0.0,
|
|
m: T.Literal['additional', 'exactly'] = 'additional',
|
|
l: float = np.inf) -> None:
|
|
"""
|
|
Compute the dimensions of the resulting boxes, and to adjust the glue
|
|
if one of those dimensions is pre-specified.
|
|
|
|
Parameters
|
|
----------
|
|
h : float, default: 0
|
|
A height.
|
|
m : {'exactly', 'additional'}, default: 'additional'
|
|
Whether to produce a box whose height is 'exactly' *h*; or a box
|
|
with the natural height of the contents, plus *h* ('additional').
|
|
l : float, default: np.inf
|
|
The maximum height.
|
|
|
|
Notes
|
|
-----
|
|
The defaults produce a box with the natural height of the contents.
|
|
"""
|
|
# I don't know why these get reset in TeX. Shift_amount is pretty
|
|
# much useless if we do.
|
|
# self.shift_amount = 0.
|
|
w = 0.
|
|
d = 0.
|
|
x = 0.
|
|
total_stretch = [0.] * 4
|
|
total_shrink = [0.] * 4
|
|
for p in self.children:
|
|
if isinstance(p, Box):
|
|
x += d + p.height
|
|
d = p.depth
|
|
if not np.isinf(p.width):
|
|
s = getattr(p, 'shift_amount', 0.)
|
|
w = max(w, p.width + s)
|
|
elif isinstance(p, Glue):
|
|
x += d
|
|
d = 0.
|
|
glue_spec = p.glue_spec
|
|
x += glue_spec.width
|
|
total_stretch[glue_spec.stretch_order] += glue_spec.stretch
|
|
total_shrink[glue_spec.shrink_order] += glue_spec.shrink
|
|
elif isinstance(p, Kern):
|
|
x += d + p.width
|
|
d = 0.
|
|
elif isinstance(p, Char):
|
|
raise RuntimeError(
|
|
"Internal mathtext error: Char node found in Vlist")
|
|
|
|
self.width = w
|
|
if d > l:
|
|
x += d - l
|
|
self.depth = l
|
|
else:
|
|
self.depth = d
|
|
|
|
if m == 'additional':
|
|
h += x
|
|
self.height = h
|
|
x = h - x
|
|
|
|
if x == 0:
|
|
self.glue_sign = 0
|
|
self.glue_order = 0
|
|
self.glue_ratio = 0.
|
|
return
|
|
|
|
if x > 0.:
|
|
self._set_glue(x, 1, total_stretch, "Overful")
|
|
else:
|
|
self._set_glue(x, -1, total_shrink, "Underful")
|
|
|
|
|
|
class Rule(Box):
|
|
"""
|
|
A solid black rectangle.
|
|
|
|
It has *width*, *depth*, and *height* fields just as in an `Hlist`.
|
|
However, if any of these dimensions is inf, the actual value will be
|
|
determined by running the rule up to the boundary of the innermost
|
|
enclosing box. This is called a "running dimension". The width is never
|
|
running in an `Hlist`; the height and depth are never running in a `Vlist`.
|
|
"""
|
|
|
|
def __init__(self, width: float, height: float, depth: float, state: ParserState):
|
|
super().__init__(width, height, depth)
|
|
self.fontset = state.fontset
|
|
|
|
def render(self, output: Output, # type: ignore[override]
|
|
x: float, y: float, w: float, h: float) -> None:
|
|
self.fontset.render_rect_filled(output, x, y, x + w, y + h)
|
|
|
|
|
|
class Hrule(Rule):
|
|
"""Convenience class to create a horizontal rule."""
|
|
|
|
def __init__(self, state: ParserState, thickness: float | None = None):
|
|
if thickness is None:
|
|
thickness = state.get_current_underline_thickness()
|
|
height = depth = thickness * 0.5
|
|
super().__init__(np.inf, height, depth, state)
|
|
|
|
|
|
class Vrule(Rule):
|
|
"""Convenience class to create a vertical rule."""
|
|
|
|
def __init__(self, state: ParserState):
|
|
thickness = state.get_current_underline_thickness()
|
|
super().__init__(thickness, np.inf, np.inf, state)
|
|
|
|
|
|
class _GlueSpec(NamedTuple):
|
|
width: float
|
|
stretch: float
|
|
stretch_order: int
|
|
shrink: float
|
|
shrink_order: int
|
|
|
|
|
|
_GlueSpec._named = { # type: ignore[attr-defined]
|
|
'fil': _GlueSpec(0., 1., 1, 0., 0),
|
|
'fill': _GlueSpec(0., 1., 2, 0., 0),
|
|
'filll': _GlueSpec(0., 1., 3, 0., 0),
|
|
'neg_fil': _GlueSpec(0., 0., 0, 1., 1),
|
|
'neg_fill': _GlueSpec(0., 0., 0, 1., 2),
|
|
'neg_filll': _GlueSpec(0., 0., 0, 1., 3),
|
|
'empty': _GlueSpec(0., 0., 0, 0., 0),
|
|
'ss': _GlueSpec(0., 1., 1, -1., 1),
|
|
}
|
|
|
|
|
|
class Glue(Node):
|
|
"""
|
|
Most of the information in this object is stored in the underlying
|
|
``_GlueSpec`` class, which is shared between multiple glue objects.
|
|
(This is a memory optimization which probably doesn't matter anymore, but
|
|
it's easier to stick to what TeX does.)
|
|
"""
|
|
|
|
def __init__(self,
|
|
glue_type: _GlueSpec | T.Literal["fil", "fill", "filll",
|
|
"neg_fil", "neg_fill", "neg_filll",
|
|
"empty", "ss"]):
|
|
super().__init__()
|
|
if isinstance(glue_type, str):
|
|
glue_spec = _GlueSpec._named[glue_type] # type: ignore[attr-defined]
|
|
elif isinstance(glue_type, _GlueSpec):
|
|
glue_spec = glue_type
|
|
else:
|
|
raise ValueError("glue_type must be a glue spec name or instance")
|
|
self.glue_spec = glue_spec
|
|
|
|
def shrink(self) -> None:
|
|
super().shrink()
|
|
if self.size < NUM_SIZE_LEVELS:
|
|
g = self.glue_spec
|
|
self.glue_spec = g._replace(width=g.width * SHRINK_FACTOR)
|
|
|
|
|
|
class HCentered(Hlist):
|
|
"""
|
|
A convenience class to create an `Hlist` whose contents are
|
|
centered within its enclosing box.
|
|
"""
|
|
|
|
def __init__(self, elements: list[Node]):
|
|
super().__init__([Glue('ss'), *elements, Glue('ss')], do_kern=False)
|
|
|
|
|
|
class VCentered(Vlist):
|
|
"""
|
|
A convenience class to create a `Vlist` whose contents are
|
|
centered within its enclosing box.
|
|
"""
|
|
|
|
def __init__(self, elements: list[Node]):
|
|
super().__init__([Glue('ss'), *elements, Glue('ss')])
|
|
|
|
|
|
class Kern(Node):
|
|
"""
|
|
A `Kern` node has a width field to specify a (normally
|
|
negative) amount of spacing. This spacing correction appears in
|
|
horizontal lists between letters like A and V when the font
|
|
designer said that it looks better to move them closer together or
|
|
further apart. A kern node can also appear in a vertical list,
|
|
when its *width* denotes additional spacing in the vertical
|
|
direction.
|
|
"""
|
|
|
|
height = 0
|
|
depth = 0
|
|
|
|
def __init__(self, width: float):
|
|
super().__init__()
|
|
self.width = width
|
|
|
|
def __repr__(self) -> str:
|
|
return "k%.02f" % self.width
|
|
|
|
def shrink(self) -> None:
|
|
super().shrink()
|
|
if self.size < NUM_SIZE_LEVELS:
|
|
self.width *= SHRINK_FACTOR
|
|
|
|
|
|
class AutoHeightChar(Hlist):
|
|
"""
|
|
A character as close to the given height and depth as possible.
|
|
|
|
When using a font with multiple height versions of some characters (such as
|
|
the BaKoMa fonts), the correct glyph will be selected, otherwise this will
|
|
always just return a scaled version of the glyph.
|
|
"""
|
|
|
|
def __init__(self, c: str, height: float, depth: float, state: ParserState,
|
|
always: bool = False, factor: float | None = None):
|
|
alternatives = state.fontset.get_sized_alternatives_for_symbol(
|
|
state.font, c)
|
|
|
|
xHeight = state.fontset.get_xheight(
|
|
state.font, state.fontsize, state.dpi)
|
|
|
|
state = state.copy()
|
|
target_total = height + depth
|
|
for fontname, sym in alternatives:
|
|
state.font = fontname
|
|
char = Char(sym, state)
|
|
# Ensure that size 0 is chosen when the text is regular sized but
|
|
# with descender glyphs by subtracting 0.2 * xHeight
|
|
if char.height + char.depth >= target_total - 0.2 * xHeight:
|
|
break
|
|
|
|
shift = 0.0
|
|
if state.font != 0 or len(alternatives) == 1:
|
|
if factor is None:
|
|
factor = target_total / (char.height + char.depth)
|
|
state.fontsize *= factor
|
|
char = Char(sym, state)
|
|
|
|
shift = (depth - char.depth)
|
|
|
|
super().__init__([char])
|
|
self.shift_amount = shift
|
|
|
|
|
|
class AutoWidthChar(Hlist):
|
|
"""
|
|
A character as close to the given width as possible.
|
|
|
|
When using a font with multiple width versions of some characters (such as
|
|
the BaKoMa fonts), the correct glyph will be selected, otherwise this will
|
|
always just return a scaled version of the glyph.
|
|
"""
|
|
|
|
def __init__(self, c: str, width: float, state: ParserState, always: bool = False,
|
|
char_class: type[Char] = Char):
|
|
alternatives = state.fontset.get_sized_alternatives_for_symbol(
|
|
state.font, c)
|
|
|
|
state = state.copy()
|
|
for fontname, sym in alternatives:
|
|
state.font = fontname
|
|
char = char_class(sym, state)
|
|
if char.width >= width:
|
|
break
|
|
|
|
factor = width / char.width
|
|
state.fontsize *= factor
|
|
char = char_class(sym, state)
|
|
|
|
super().__init__([char])
|
|
self.width = char.width
|
|
|
|
|
|
def ship(box: Box, xy: tuple[float, float] = (0, 0)) -> Output:
|
|
"""
|
|
Ship out *box* at offset *xy*, converting it to an `Output`.
|
|
|
|
Since boxes can be inside of boxes inside of boxes, the main work of `ship`
|
|
is done by two mutually recursive routines, `hlist_out` and `vlist_out`,
|
|
which traverse the `Hlist` nodes and `Vlist` nodes inside of horizontal
|
|
and vertical boxes. The global variables used in TeX to store state as it
|
|
processes have become local variables here.
|
|
"""
|
|
ox, oy = xy
|
|
cur_v = 0.
|
|
cur_h = 0.
|
|
off_h = ox
|
|
off_v = oy + box.height
|
|
output = Output(box)
|
|
|
|
def clamp(value: float) -> float:
|
|
return -1e9 if value < -1e9 else +1e9 if value > +1e9 else value
|
|
|
|
def hlist_out(box: Hlist) -> None:
|
|
nonlocal cur_v, cur_h, off_h, off_v
|
|
|
|
cur_g = 0
|
|
cur_glue = 0.
|
|
glue_order = box.glue_order
|
|
glue_sign = box.glue_sign
|
|
base_line = cur_v
|
|
left_edge = cur_h
|
|
|
|
for p in box.children:
|
|
if isinstance(p, Char):
|
|
p.render(output, cur_h + off_h, cur_v + off_v)
|
|
cur_h += p.width
|
|
elif isinstance(p, Kern):
|
|
cur_h += p.width
|
|
elif isinstance(p, List):
|
|
# node623
|
|
if len(p.children) == 0:
|
|
cur_h += p.width
|
|
else:
|
|
edge = cur_h
|
|
cur_v = base_line + p.shift_amount
|
|
if isinstance(p, Hlist):
|
|
hlist_out(p)
|
|
elif isinstance(p, Vlist):
|
|
# p.vpack(box.height + box.depth, 'exactly')
|
|
vlist_out(p)
|
|
else:
|
|
assert False, "unreachable code"
|
|
cur_h = edge + p.width
|
|
cur_v = base_line
|
|
elif isinstance(p, Box):
|
|
# node624
|
|
rule_height = p.height
|
|
rule_depth = p.depth
|
|
rule_width = p.width
|
|
if np.isinf(rule_height):
|
|
rule_height = box.height
|
|
if np.isinf(rule_depth):
|
|
rule_depth = box.depth
|
|
if rule_height > 0 and rule_width > 0:
|
|
cur_v = base_line + rule_depth
|
|
p.render(output,
|
|
cur_h + off_h, cur_v + off_v,
|
|
rule_width, rule_height)
|
|
cur_v = base_line
|
|
cur_h += rule_width
|
|
elif isinstance(p, Glue):
|
|
# node625
|
|
glue_spec = p.glue_spec
|
|
rule_width = glue_spec.width - cur_g
|
|
if glue_sign != 0: # normal
|
|
if glue_sign == 1: # stretching
|
|
if glue_spec.stretch_order == glue_order:
|
|
cur_glue += glue_spec.stretch
|
|
cur_g = round(clamp(box.glue_set * cur_glue))
|
|
elif glue_spec.shrink_order == glue_order:
|
|
cur_glue += glue_spec.shrink
|
|
cur_g = round(clamp(box.glue_set * cur_glue))
|
|
rule_width += cur_g
|
|
cur_h += rule_width
|
|
|
|
def vlist_out(box: Vlist) -> None:
|
|
nonlocal cur_v, cur_h, off_h, off_v
|
|
|
|
cur_g = 0
|
|
cur_glue = 0.
|
|
glue_order = box.glue_order
|
|
glue_sign = box.glue_sign
|
|
left_edge = cur_h
|
|
cur_v -= box.height
|
|
top_edge = cur_v
|
|
|
|
for p in box.children:
|
|
if isinstance(p, Kern):
|
|
cur_v += p.width
|
|
elif isinstance(p, List):
|
|
if len(p.children) == 0:
|
|
cur_v += p.height + p.depth
|
|
else:
|
|
cur_v += p.height
|
|
cur_h = left_edge + p.shift_amount
|
|
save_v = cur_v
|
|
p.width = box.width
|
|
if isinstance(p, Hlist):
|
|
hlist_out(p)
|
|
elif isinstance(p, Vlist):
|
|
vlist_out(p)
|
|
else:
|
|
assert False, "unreachable code"
|
|
cur_v = save_v + p.depth
|
|
cur_h = left_edge
|
|
elif isinstance(p, Box):
|
|
rule_height = p.height
|
|
rule_depth = p.depth
|
|
rule_width = p.width
|
|
if np.isinf(rule_width):
|
|
rule_width = box.width
|
|
rule_height += rule_depth
|
|
if rule_height > 0 and rule_depth > 0:
|
|
cur_v += rule_height
|
|
p.render(output,
|
|
cur_h + off_h, cur_v + off_v,
|
|
rule_width, rule_height)
|
|
elif isinstance(p, Glue):
|
|
glue_spec = p.glue_spec
|
|
rule_height = glue_spec.width - cur_g
|
|
if glue_sign != 0: # normal
|
|
if glue_sign == 1: # stretching
|
|
if glue_spec.stretch_order == glue_order:
|
|
cur_glue += glue_spec.stretch
|
|
cur_g = round(clamp(box.glue_set * cur_glue))
|
|
elif glue_spec.shrink_order == glue_order: # shrinking
|
|
cur_glue += glue_spec.shrink
|
|
cur_g = round(clamp(box.glue_set * cur_glue))
|
|
rule_height += cur_g
|
|
cur_v += rule_height
|
|
elif isinstance(p, Char):
|
|
raise RuntimeError(
|
|
"Internal mathtext error: Char node found in vlist")
|
|
|
|
assert isinstance(box, Hlist)
|
|
hlist_out(box)
|
|
return output
|
|
|
|
|
|
##############################################################################
|
|
# PARSER
|
|
|
|
|
|
def Error(msg: str) -> ParserElement:
|
|
"""Helper class to raise parser errors."""
|
|
def raise_error(s: str, loc: int, toks: ParseResults) -> T.Any:
|
|
raise ParseFatalException(s, loc, msg)
|
|
|
|
return Empty().setParseAction(raise_error)
|
|
|
|
|
|
class ParserState:
|
|
"""
|
|
Parser state.
|
|
|
|
States are pushed and popped from a stack as necessary, and the "current"
|
|
state is always at the top of the stack.
|
|
|
|
Upon entering and leaving a group { } or math/non-math, the stack is pushed
|
|
and popped accordingly.
|
|
"""
|
|
|
|
def __init__(self, fontset: Fonts, font: str, font_class: str, fontsize: float,
|
|
dpi: float):
|
|
self.fontset = fontset
|
|
self._font = font
|
|
self.font_class = font_class
|
|
self.fontsize = fontsize
|
|
self.dpi = dpi
|
|
|
|
def copy(self) -> ParserState:
|
|
return copy.copy(self)
|
|
|
|
@property
|
|
def font(self) -> str:
|
|
return self._font
|
|
|
|
@font.setter
|
|
def font(self, name: str) -> None:
|
|
if name in ('rm', 'it', 'bf', 'bfit'):
|
|
self.font_class = name
|
|
self._font = name
|
|
|
|
def get_current_underline_thickness(self) -> float:
|
|
"""Return the underline thickness for this state."""
|
|
return self.fontset.get_underline_thickness(
|
|
self.font, self.fontsize, self.dpi)
|
|
|
|
|
|
def cmd(expr: str, args: ParserElement) -> ParserElement:
|
|
r"""
|
|
Helper to define TeX commands.
|
|
|
|
``cmd("\cmd", args)`` is equivalent to
|
|
``"\cmd" - (args | Error("Expected \cmd{arg}{...}"))`` where the names in
|
|
the error message are taken from element names in *args*. If *expr*
|
|
already includes arguments (e.g. "\cmd{arg}{...}"), then they are stripped
|
|
when constructing the parse element, but kept (and *expr* is used as is) in
|
|
the error message.
|
|
"""
|
|
|
|
def names(elt: ParserElement) -> T.Generator[str, None, None]:
|
|
if isinstance(elt, ParseExpression):
|
|
for expr in elt.exprs:
|
|
yield from names(expr)
|
|
elif elt.resultsName:
|
|
yield elt.resultsName
|
|
|
|
csname = expr.split("{", 1)[0]
|
|
err = (csname + "".join("{%s}" % name for name in names(args))
|
|
if expr == csname else expr)
|
|
return csname - (args | Error(f"Expected {err}"))
|
|
|
|
|
|
class Parser:
|
|
"""
|
|
A pyparsing-based parser for strings containing math expressions.
|
|
|
|
Raw text may also appear outside of pairs of ``$``.
|
|
|
|
The grammar is based directly on that in TeX, though it cuts a few corners.
|
|
"""
|
|
|
|
class _MathStyle(enum.Enum):
|
|
DISPLAYSTYLE = 0
|
|
TEXTSTYLE = 1
|
|
SCRIPTSTYLE = 2
|
|
SCRIPTSCRIPTSTYLE = 3
|
|
|
|
_binary_operators = set(
|
|
'+ * - \N{MINUS SIGN}'
|
|
r'''
|
|
\pm \sqcap \rhd
|
|
\mp \sqcup \unlhd
|
|
\times \vee \unrhd
|
|
\div \wedge \oplus
|
|
\ast \setminus \ominus
|
|
\star \wr \otimes
|
|
\circ \diamond \oslash
|
|
\bullet \bigtriangleup \odot
|
|
\cdot \bigtriangledown \bigcirc
|
|
\cap \triangleleft \dagger
|
|
\cup \triangleright \ddagger
|
|
\uplus \lhd \amalg
|
|
\dotplus \dotminus \Cap
|
|
\Cup \barwedge \boxdot
|
|
\boxminus \boxplus \boxtimes
|
|
\curlyvee \curlywedge \divideontimes
|
|
\doublebarwedge \leftthreetimes \rightthreetimes
|
|
\slash \veebar \barvee
|
|
\cupdot \intercal \amalg
|
|
\circledcirc \circleddash \circledast
|
|
\boxbar \obar \merge
|
|
\minuscolon \dotsminusdots
|
|
'''.split())
|
|
|
|
_relation_symbols = set(r'''
|
|
= < > :
|
|
\leq \geq \equiv \models
|
|
\prec \succ \sim \perp
|
|
\preceq \succeq \simeq \mid
|
|
\ll \gg \asymp \parallel
|
|
\subset \supset \approx \bowtie
|
|
\subseteq \supseteq \cong \Join
|
|
\sqsubset \sqsupset \neq \smile
|
|
\sqsubseteq \sqsupseteq \doteq \frown
|
|
\in \ni \propto \vdash
|
|
\dashv \dots \doteqdot \leqq
|
|
\geqq \lneqq \gneqq \lessgtr
|
|
\leqslant \geqslant \eqgtr \eqless
|
|
\eqslantless \eqslantgtr \lesseqgtr \backsim
|
|
\backsimeq \lesssim \gtrsim \precsim
|
|
\precnsim \gnsim \lnsim \succsim
|
|
\succnsim \nsim \lesseqqgtr \gtreqqless
|
|
\gtreqless \subseteqq \supseteqq \subsetneqq
|
|
\supsetneqq \lessapprox \approxeq \gtrapprox
|
|
\precapprox \succapprox \precnapprox \succnapprox
|
|
\npreccurlyeq \nsucccurlyeq \nsqsubseteq \nsqsupseteq
|
|
\sqsubsetneq \sqsupsetneq \nlesssim \ngtrsim
|
|
\nlessgtr \ngtrless \lnapprox \gnapprox
|
|
\napprox \approxeq \approxident \lll
|
|
\ggg \nparallel \Vdash \Vvdash
|
|
\nVdash \nvdash \vDash \nvDash
|
|
\nVDash \oequal \simneqq \triangle
|
|
\triangleq \triangleeq \triangleleft
|
|
\triangleright \ntriangleleft \ntriangleright
|
|
\trianglelefteq \ntrianglelefteq \trianglerighteq
|
|
\ntrianglerighteq \blacktriangleleft \blacktriangleright
|
|
\equalparallel \measuredrightangle \varlrtriangle
|
|
\Doteq \Bumpeq \Subset \Supset
|
|
\backepsilon \because \therefore \bot
|
|
\top \bumpeq \circeq \coloneq
|
|
\curlyeqprec \curlyeqsucc \eqcirc \eqcolon
|
|
\eqsim \fallingdotseq \gtrdot \gtrless
|
|
\ltimes \rtimes \lessdot \ne
|
|
\ncong \nequiv \ngeq \ngtr
|
|
\nleq \nless \nmid \notin
|
|
\nprec \nsubset \nsubseteq \nsucc
|
|
\nsupset \nsupseteq \pitchfork \preccurlyeq
|
|
\risingdotseq \subsetneq \succcurlyeq \supsetneq
|
|
\varpropto \vartriangleleft \scurel
|
|
\vartriangleright \rightangle \equal \backcong
|
|
\eqdef \wedgeq \questeq \between
|
|
\veeeq \disin \varisins \isins
|
|
\isindot \varisinobar \isinobar \isinvb
|
|
\isinE \nisd \varnis \nis
|
|
\varniobar \niobar \bagmember \ratio
|
|
\Equiv \stareq \measeq \arceq
|
|
\rightassert \rightModels \smallin \smallowns
|
|
\notsmallowns \nsimeq'''.split())
|
|
|
|
_arrow_symbols = set(r"""
|
|
\leftarrow \longleftarrow \uparrow \Leftarrow \Longleftarrow
|
|
\Uparrow \rightarrow \longrightarrow \downarrow \Rightarrow
|
|
\Longrightarrow \Downarrow \leftrightarrow \updownarrow
|
|
\longleftrightarrow \updownarrow \Leftrightarrow
|
|
\Longleftrightarrow \Updownarrow \mapsto \longmapsto \nearrow
|
|
\hookleftarrow \hookrightarrow \searrow \leftharpoonup
|
|
\rightharpoonup \swarrow \leftharpoondown \rightharpoondown
|
|
\nwarrow \rightleftharpoons \leadsto \dashrightarrow
|
|
\dashleftarrow \leftleftarrows \leftrightarrows \Lleftarrow
|
|
\Rrightarrow \twoheadleftarrow \leftarrowtail \looparrowleft
|
|
\leftrightharpoons \curvearrowleft \circlearrowleft \Lsh
|
|
\upuparrows \upharpoonleft \downharpoonleft \multimap
|
|
\leftrightsquigarrow \rightrightarrows \rightleftarrows
|
|
\rightrightarrows \rightleftarrows \twoheadrightarrow
|
|
\rightarrowtail \looparrowright \rightleftharpoons
|
|
\curvearrowright \circlearrowright \Rsh \downdownarrows
|
|
\upharpoonright \downharpoonright \rightsquigarrow \nleftarrow
|
|
\nrightarrow \nLeftarrow \nRightarrow \nleftrightarrow
|
|
\nLeftrightarrow \to \Swarrow \Searrow \Nwarrow \Nearrow
|
|
\leftsquigarrow \overleftarrow \overleftrightarrow \cwopencirclearrow
|
|
\downzigzagarrow \cupleftarrow \rightzigzagarrow \twoheaddownarrow
|
|
\updownarrowbar \twoheaduparrow \rightarrowbar \updownarrows
|
|
\barleftarrow \mapsfrom \mapsdown \mapsup \Ldsh \Rdsh
|
|
""".split())
|
|
|
|
_spaced_symbols = _binary_operators | _relation_symbols | _arrow_symbols
|
|
|
|
_punctuation_symbols = set(r', ; . ! \ldotp \cdotp'.split())
|
|
|
|
_overunder_symbols = set(r'''
|
|
\sum \prod \coprod \bigcap \bigcup \bigsqcup \bigvee
|
|
\bigwedge \bigodot \bigotimes \bigoplus \biguplus
|
|
'''.split())
|
|
|
|
_overunder_functions = set("lim liminf limsup sup max min".split())
|
|
|
|
_dropsub_symbols = set(r'\int \oint \iint \oiint \iiint \oiiint \iiiint'.split())
|
|
|
|
_fontnames = set("rm cal it tt sf bf bfit "
|
|
"default bb frak scr regular".split())
|
|
|
|
_function_names = set("""
|
|
arccos csc ker min arcsin deg lg Pr arctan det lim sec arg dim
|
|
liminf sin cos exp limsup sinh cosh gcd ln sup cot hom log tan
|
|
coth inf max tanh""".split())
|
|
|
|
_ambi_delims = set(r"""
|
|
| \| / \backslash \uparrow \downarrow \updownarrow \Uparrow
|
|
\Downarrow \Updownarrow . \vert \Vert""".split())
|
|
_left_delims = set(r"""
|
|
( [ \{ < \lfloor \langle \lceil \lbrace \leftbrace \lbrack \leftparen \lgroup
|
|
""".split())
|
|
_right_delims = set(r"""
|
|
) ] \} > \rfloor \rangle \rceil \rbrace \rightbrace \rbrack \rightparen \rgroup
|
|
""".split())
|
|
_delims = _left_delims | _right_delims | _ambi_delims
|
|
|
|
_small_greek = set([unicodedata.name(chr(i)).split()[-1].lower() for i in
|
|
range(ord('\N{GREEK SMALL LETTER ALPHA}'),
|
|
ord('\N{GREEK SMALL LETTER OMEGA}') + 1)])
|
|
_latin_alphabets = set(string.ascii_letters)
|
|
|
|
def __init__(self) -> None:
|
|
p = types.SimpleNamespace()
|
|
|
|
def set_names_and_parse_actions() -> None:
|
|
for key, val in vars(p).items():
|
|
if not key.startswith('_'):
|
|
# Set names on (almost) everything -- very useful for debugging
|
|
# token, placeable, and auto_delim are forward references which
|
|
# are left without names to ensure useful error messages
|
|
if key not in ("token", "placeable", "auto_delim"):
|
|
val.setName(key)
|
|
# Set actions
|
|
if hasattr(self, key):
|
|
val.setParseAction(getattr(self, key))
|
|
|
|
# Root definitions.
|
|
|
|
# In TeX parlance, a csname is a control sequence name (a "\foo").
|
|
def csnames(group: str, names: Iterable[str]) -> Regex:
|
|
ends_with_alpha = []
|
|
ends_with_nonalpha = []
|
|
for name in names:
|
|
if name[-1].isalpha():
|
|
ends_with_alpha.append(name)
|
|
else:
|
|
ends_with_nonalpha.append(name)
|
|
return Regex(
|
|
r"\\(?P<{group}>(?:{alpha})(?![A-Za-z]){additional}{nonalpha})".format(
|
|
group=group,
|
|
alpha="|".join(map(re.escape, ends_with_alpha)),
|
|
additional="|" if ends_with_nonalpha else "",
|
|
nonalpha="|".join(map(re.escape, ends_with_nonalpha)),
|
|
)
|
|
)
|
|
|
|
p.float_literal = Regex(r"[-+]?([0-9]+\.?[0-9]*|\.[0-9]+)")
|
|
p.space = oneOf(self._space_widths)("space")
|
|
|
|
p.style_literal = oneOf(
|
|
[str(e.value) for e in self._MathStyle])("style_literal")
|
|
|
|
p.symbol = Regex(
|
|
r"[a-zA-Z0-9 +\-*/<>=:,.;!\?&'@()\[\]|\U00000080-\U0001ffff]"
|
|
r"|\\[%${}\[\]_|]"
|
|
+ r"|\\(?:{})(?![A-Za-z])".format(
|
|
"|".join(map(re.escape, tex2uni)))
|
|
)("sym").leaveWhitespace()
|
|
p.unknown_symbol = Regex(r"\\[A-Za-z]+")("name")
|
|
|
|
p.font = csnames("font", self._fontnames)
|
|
p.start_group = Optional(r"\math" + oneOf(self._fontnames)("font")) + "{"
|
|
p.end_group = Literal("}")
|
|
|
|
p.delim = oneOf(self._delims)
|
|
|
|
# Mutually recursive definitions. (Minimizing the number of Forward
|
|
# elements is important for speed.)
|
|
p.auto_delim = Forward()
|
|
p.placeable = Forward()
|
|
p.named_placeable = Forward()
|
|
p.required_group = Forward()
|
|
p.optional_group = Forward()
|
|
p.token = Forward()
|
|
|
|
# Workaround for placable being part of a cycle of definitions
|
|
# calling `p.placeable("name")` results in a copy, so not guaranteed
|
|
# to get the definition added after it is used.
|
|
# ref https://github.com/matplotlib/matplotlib/issues/25204
|
|
# xref https://github.com/pyparsing/pyparsing/issues/95
|
|
p.named_placeable <<= p.placeable
|
|
|
|
set_names_and_parse_actions() # for mutually recursive definitions.
|
|
|
|
p.optional_group <<= "{" + ZeroOrMore(p.token)("group") + "}"
|
|
p.required_group <<= "{" + OneOrMore(p.token)("group") + "}"
|
|
|
|
p.customspace = cmd(r"\hspace", "{" + p.float_literal("space") + "}")
|
|
|
|
p.accent = (
|
|
csnames("accent", [*self._accent_map, *self._wide_accents])
|
|
- p.named_placeable("sym"))
|
|
|
|
p.function = csnames("name", self._function_names)
|
|
|
|
p.group = p.start_group + ZeroOrMore(p.token)("group") + p.end_group
|
|
p.unclosed_group = (p.start_group + ZeroOrMore(p.token)("group") + StringEnd())
|
|
|
|
p.frac = cmd(r"\frac", p.required_group("num") + p.required_group("den"))
|
|
p.dfrac = cmd(r"\dfrac", p.required_group("num") + p.required_group("den"))
|
|
p.binom = cmd(r"\binom", p.required_group("num") + p.required_group("den"))
|
|
|
|
p.genfrac = cmd(
|
|
r"\genfrac",
|
|
"{" + Optional(p.delim)("ldelim") + "}"
|
|
+ "{" + Optional(p.delim)("rdelim") + "}"
|
|
+ "{" + p.float_literal("rulesize") + "}"
|
|
+ "{" + Optional(p.style_literal)("style") + "}"
|
|
+ p.required_group("num")
|
|
+ p.required_group("den"))
|
|
|
|
p.sqrt = cmd(
|
|
r"\sqrt{value}",
|
|
Optional("[" + OneOrMore(NotAny("]") + p.token)("root") + "]")
|
|
+ p.required_group("value"))
|
|
|
|
p.overline = cmd(r"\overline", p.required_group("body"))
|
|
|
|
p.overset = cmd(
|
|
r"\overset",
|
|
p.optional_group("annotation") + p.optional_group("body"))
|
|
p.underset = cmd(
|
|
r"\underset",
|
|
p.optional_group("annotation") + p.optional_group("body"))
|
|
|
|
p.text = cmd(r"\text", QuotedString('{', '\\', endQuoteChar="}"))
|
|
|
|
p.substack = cmd(r"\substack",
|
|
nested_expr(opener="{", closer="}",
|
|
content=Group(OneOrMore(p.token)) +
|
|
ZeroOrMore(Literal("\\\\").suppress()))("parts"))
|
|
|
|
p.subsuper = (
|
|
(Optional(p.placeable)("nucleus")
|
|
+ OneOrMore(oneOf(["_", "^"]) - p.placeable)("subsuper")
|
|
+ Regex("'*")("apostrophes"))
|
|
| Regex("'+")("apostrophes")
|
|
| (p.named_placeable("nucleus") + Regex("'*")("apostrophes"))
|
|
)
|
|
|
|
p.simple = p.space | p.customspace | p.font | p.subsuper
|
|
|
|
p.token <<= (
|
|
p.simple
|
|
| p.auto_delim
|
|
| p.unclosed_group
|
|
| p.unknown_symbol # Must be last
|
|
)
|
|
|
|
p.operatorname = cmd(r"\operatorname", "{" + ZeroOrMore(p.simple)("name") + "}")
|
|
|
|
p.boldsymbol = cmd(
|
|
r"\boldsymbol", "{" + ZeroOrMore(p.simple)("value") + "}")
|
|
|
|
p.placeable <<= (
|
|
p.accent # Must be before symbol as all accents are symbols
|
|
| p.symbol # Must be second to catch all named symbols and single
|
|
# chars not in a group
|
|
| p.function
|
|
| p.operatorname
|
|
| p.group
|
|
| p.frac
|
|
| p.dfrac
|
|
| p.binom
|
|
| p.genfrac
|
|
| p.overset
|
|
| p.underset
|
|
| p.sqrt
|
|
| p.overline
|
|
| p.text
|
|
| p.boldsymbol
|
|
| p.substack
|
|
)
|
|
|
|
mdelim = r"\middle" - (p.delim("mdelim") | Error("Expected a delimiter"))
|
|
p.auto_delim <<= (
|
|
r"\left" - (p.delim("left") | Error("Expected a delimiter"))
|
|
+ ZeroOrMore(p.simple | p.auto_delim | mdelim)("mid")
|
|
+ r"\right" - (p.delim("right") | Error("Expected a delimiter"))
|
|
)
|
|
|
|
# Leaf definitions.
|
|
p.math = OneOrMore(p.token)
|
|
p.math_string = QuotedString('$', '\\', unquoteResults=False)
|
|
p.non_math = Regex(r"(?:(?:\\[$])|[^$])*").leaveWhitespace()
|
|
p.main = (
|
|
p.non_math + ZeroOrMore(p.math_string + p.non_math) + StringEnd()
|
|
)
|
|
set_names_and_parse_actions() # for leaf definitions.
|
|
|
|
self._expression = p.main
|
|
self._math_expression = p.math
|
|
|
|
# To add space to nucleus operators after sub/superscripts
|
|
self._in_subscript_or_superscript = False
|
|
|
|
def parse(self, s: str, fonts_object: Fonts, fontsize: float, dpi: float) -> Hlist:
|
|
"""
|
|
Parse expression *s* using the given *fonts_object* for
|
|
output, at the given *fontsize* and *dpi*.
|
|
|
|
Returns the parse tree of `Node` instances.
|
|
"""
|
|
self._state_stack = [
|
|
ParserState(fonts_object, 'default', 'rm', fontsize, dpi)]
|
|
self._em_width_cache: dict[tuple[str, float, float], float] = {}
|
|
try:
|
|
result = self._expression.parseString(s)
|
|
except ParseBaseException as err:
|
|
# explain becomes a plain method on pyparsing 3 (err.explain(0)).
|
|
raise ValueError("\n" + ParseException.explain(err, 0)) from None
|
|
self._state_stack = []
|
|
self._in_subscript_or_superscript = False
|
|
# prevent operator spacing from leaking into a new expression
|
|
self._em_width_cache = {}
|
|
ParserElement.resetCache()
|
|
return T.cast(Hlist, result[0]) # Known return type from main.
|
|
|
|
def get_state(self) -> ParserState:
|
|
"""Get the current `State` of the parser."""
|
|
return self._state_stack[-1]
|
|
|
|
def pop_state(self) -> None:
|
|
"""Pop a `State` off of the stack."""
|
|
self._state_stack.pop()
|
|
|
|
def push_state(self) -> None:
|
|
"""Push a new `State` onto the stack, copying the current state."""
|
|
self._state_stack.append(self.get_state().copy())
|
|
|
|
def main(self, toks: ParseResults) -> list[Hlist]:
|
|
return [Hlist(toks.asList())]
|
|
|
|
def math_string(self, toks: ParseResults) -> ParseResults:
|
|
return self._math_expression.parseString(toks[0][1:-1], parseAll=True)
|
|
|
|
def math(self, toks: ParseResults) -> T.Any:
|
|
hlist = Hlist(toks.asList())
|
|
self.pop_state()
|
|
return [hlist]
|
|
|
|
def non_math(self, toks: ParseResults) -> T.Any:
|
|
s = toks[0].replace(r'\$', '$')
|
|
symbols = [Char(c, self.get_state()) for c in s]
|
|
hlist = Hlist(symbols)
|
|
# We're going into math now, so set font to 'it'
|
|
self.push_state()
|
|
self.get_state().font = mpl.rcParams['mathtext.default']
|
|
return [hlist]
|
|
|
|
float_literal = staticmethod(pyparsing_common.convertToFloat)
|
|
|
|
def text(self, toks: ParseResults) -> T.Any:
|
|
self.push_state()
|
|
state = self.get_state()
|
|
state.font = 'rm'
|
|
hlist = Hlist([Char(c, state) for c in toks[1]])
|
|
self.pop_state()
|
|
return [hlist]
|
|
|
|
def _make_space(self, percentage: float) -> Kern:
|
|
# In TeX, an em (the unit usually used to measure horizontal lengths)
|
|
# is not the width of the character 'm'; it is the same in different
|
|
# font styles (e.g. roman or italic). Mathtext, however, uses 'm' in
|
|
# the italic style so that horizontal spaces don't depend on the
|
|
# current font style.
|
|
state = self.get_state()
|
|
key = (state.font, state.fontsize, state.dpi)
|
|
width = self._em_width_cache.get(key)
|
|
if width is None:
|
|
metrics = state.fontset.get_metrics(
|
|
'it', mpl.rcParams['mathtext.default'], 'm',
|
|
state.fontsize, state.dpi)
|
|
width = metrics.advance
|
|
self._em_width_cache[key] = width
|
|
return Kern(width * percentage)
|
|
|
|
_space_widths = {
|
|
r'\,': 0.16667, # 3/18 em = 3 mu
|
|
r'\thinspace': 0.16667, # 3/18 em = 3 mu
|
|
r'\/': 0.16667, # 3/18 em = 3 mu
|
|
r'\>': 0.22222, # 4/18 em = 4 mu
|
|
r'\:': 0.22222, # 4/18 em = 4 mu
|
|
r'\;': 0.27778, # 5/18 em = 5 mu
|
|
r'\ ': 0.33333, # 6/18 em = 6 mu
|
|
r'~': 0.33333, # 6/18 em = 6 mu, nonbreakable
|
|
r'\enspace': 0.5, # 9/18 em = 9 mu
|
|
r'\quad': 1, # 1 em = 18 mu
|
|
r'\qquad': 2, # 2 em = 36 mu
|
|
r'\!': -0.16667, # -3/18 em = -3 mu
|
|
}
|
|
|
|
def space(self, toks: ParseResults) -> T.Any:
|
|
num = self._space_widths[toks["space"]]
|
|
box = self._make_space(num)
|
|
return [box]
|
|
|
|
def customspace(self, toks: ParseResults) -> T.Any:
|
|
return [self._make_space(toks["space"])]
|
|
|
|
def symbol(self, s: str, loc: int,
|
|
toks: ParseResults | dict[str, str]) -> T.Any:
|
|
c = toks["sym"]
|
|
if c == "-":
|
|
# "U+2212 minus sign is the preferred representation of the unary
|
|
# and binary minus sign rather than the ASCII-derived U+002D
|
|
# hyphen-minus, because minus sign is unambiguous and because it
|
|
# is rendered with a more desirable length, usually longer than a
|
|
# hyphen." (https://www.unicode.org/reports/tr25/)
|
|
c = "\N{MINUS SIGN}"
|
|
try:
|
|
char = Char(c, self.get_state())
|
|
except ValueError as err:
|
|
raise ParseFatalException(s, loc,
|
|
"Unknown symbol: %s" % c) from err
|
|
|
|
if c in self._spaced_symbols:
|
|
# iterate until we find previous character, needed for cases
|
|
# such as ${ -2}$, $ -2$, or $ -2$.
|
|
prev_char = next((c for c in s[:loc][::-1] if c != ' '), '')
|
|
# Binary operators at start of string should not be spaced
|
|
# Also, operators in sub- or superscripts should not be spaced
|
|
if (self._in_subscript_or_superscript or (
|
|
c in self._binary_operators and (
|
|
len(s[:loc].split()) == 0 or prev_char == '{' or
|
|
prev_char in self._left_delims))):
|
|
return [char]
|
|
else:
|
|
return [Hlist([self._make_space(0.2),
|
|
char,
|
|
self._make_space(0.2)],
|
|
do_kern=True)]
|
|
elif c in self._punctuation_symbols:
|
|
prev_char = next((c for c in s[:loc][::-1] if c != ' '), '')
|
|
next_char = next((c for c in s[loc + 1:] if c != ' '), '')
|
|
|
|
# Do not space commas between brackets
|
|
if c == ',':
|
|
if prev_char == '{' and next_char == '}':
|
|
return [char]
|
|
|
|
# Do not space dots as decimal separators
|
|
if c == '.' and prev_char.isdigit() and next_char.isdigit():
|
|
return [char]
|
|
else:
|
|
return [Hlist([char, self._make_space(0.2)], do_kern=True)]
|
|
return [char]
|
|
|
|
def unknown_symbol(self, s: str, loc: int, toks: ParseResults) -> T.Any:
|
|
raise ParseFatalException(s, loc, f"Unknown symbol: {toks['name']}")
|
|
|
|
_accent_map = {
|
|
r'hat': r'\circumflexaccent',
|
|
r'breve': r'\combiningbreve',
|
|
r'bar': r'\combiningoverline',
|
|
r'grave': r'\combininggraveaccent',
|
|
r'acute': r'\combiningacuteaccent',
|
|
r'tilde': r'\combiningtilde',
|
|
r'dot': r'\combiningdotabove',
|
|
r'ddot': r'\combiningdiaeresis',
|
|
r'dddot': r'\combiningthreedotsabove',
|
|
r'ddddot': r'\combiningfourdotsabove',
|
|
r'vec': r'\combiningrightarrowabove',
|
|
r'"': r'\combiningdiaeresis',
|
|
r"`": r'\combininggraveaccent',
|
|
r"'": r'\combiningacuteaccent',
|
|
r'~': r'\combiningtilde',
|
|
r'.': r'\combiningdotabove',
|
|
r'^': r'\circumflexaccent',
|
|
r'overrightarrow': r'\rightarrow',
|
|
r'overleftarrow': r'\leftarrow',
|
|
r'mathring': r'\circ',
|
|
}
|
|
|
|
_wide_accents = set(r"widehat widetilde widebar".split())
|
|
|
|
def accent(self, toks: ParseResults) -> T.Any:
|
|
state = self.get_state()
|
|
thickness = state.get_current_underline_thickness()
|
|
accent = toks["accent"]
|
|
sym = toks["sym"]
|
|
accent_box: Node
|
|
if accent in self._wide_accents:
|
|
accent_box = AutoWidthChar(
|
|
'\\' + accent, sym.width, state, char_class=Accent)
|
|
else:
|
|
accent_box = Accent(self._accent_map[accent], state)
|
|
if accent == 'mathring':
|
|
accent_box.shrink()
|
|
accent_box.shrink()
|
|
centered = HCentered([Hbox(sym.width / 4.0), accent_box])
|
|
centered.hpack(sym.width, 'exactly')
|
|
return Vlist([
|
|
centered,
|
|
Vbox(0., thickness * 2.0),
|
|
Hlist([sym])
|
|
])
|
|
|
|
def function(self, s: str, loc: int, toks: ParseResults) -> T.Any:
|
|
hlist = self.operatorname(s, loc, toks)
|
|
hlist.function_name = toks["name"]
|
|
return hlist
|
|
|
|
def operatorname(self, s: str, loc: int, toks: ParseResults) -> T.Any:
|
|
self.push_state()
|
|
state = self.get_state()
|
|
state.font = 'rm'
|
|
hlist_list: list[Node] = []
|
|
# Change the font of Chars, but leave Kerns alone
|
|
name = toks["name"]
|
|
for c in name:
|
|
if isinstance(c, Char):
|
|
c.font = 'rm'
|
|
c._update_metrics()
|
|
hlist_list.append(c)
|
|
elif isinstance(c, str):
|
|
hlist_list.append(Char(c, state))
|
|
else:
|
|
hlist_list.append(c)
|
|
next_char_loc = loc + len(name) + 1
|
|
if isinstance(name, ParseResults):
|
|
next_char_loc += len('operatorname{}')
|
|
next_char = next((c for c in s[next_char_loc:] if c != ' '), '')
|
|
delimiters = self._delims | {'^', '_'}
|
|
if (next_char not in delimiters and
|
|
name not in self._overunder_functions):
|
|
# Add thin space except when followed by parenthesis, bracket, etc.
|
|
hlist_list += [self._make_space(self._space_widths[r'\,'])]
|
|
self.pop_state()
|
|
# if followed by a super/subscript, set flag to true
|
|
# This flag tells subsuper to add space after this operator
|
|
if next_char in {'^', '_'}:
|
|
self._in_subscript_or_superscript = True
|
|
else:
|
|
self._in_subscript_or_superscript = False
|
|
|
|
return Hlist(hlist_list)
|
|
|
|
def start_group(self, toks: ParseResults) -> T.Any:
|
|
self.push_state()
|
|
# Deal with LaTeX-style font tokens
|
|
if toks.get("font"):
|
|
self.get_state().font = toks.get("font")
|
|
return []
|
|
|
|
def group(self, toks: ParseResults) -> T.Any:
|
|
grp = Hlist(toks.get("group", []))
|
|
return [grp]
|
|
|
|
def required_group(self, toks: ParseResults) -> T.Any:
|
|
return Hlist(toks.get("group", []))
|
|
|
|
optional_group = required_group
|
|
|
|
def end_group(self) -> T.Any:
|
|
self.pop_state()
|
|
return []
|
|
|
|
def unclosed_group(self, s: str, loc: int, toks: ParseResults) -> T.Any:
|
|
raise ParseFatalException(s, len(s), "Expected '}'")
|
|
|
|
def font(self, toks: ParseResults) -> T.Any:
|
|
self.get_state().font = toks["font"]
|
|
return []
|
|
|
|
def is_overunder(self, nucleus: Node) -> bool:
|
|
if isinstance(nucleus, Char):
|
|
return nucleus.c in self._overunder_symbols
|
|
elif isinstance(nucleus, Hlist) and hasattr(nucleus, 'function_name'):
|
|
return nucleus.function_name in self._overunder_functions
|
|
return False
|
|
|
|
def is_dropsub(self, nucleus: Node) -> bool:
|
|
if isinstance(nucleus, Char):
|
|
return nucleus.c in self._dropsub_symbols
|
|
return False
|
|
|
|
def is_slanted(self, nucleus: Node) -> bool:
|
|
if isinstance(nucleus, Char):
|
|
return nucleus.is_slanted()
|
|
return False
|
|
|
|
def subsuper(self, s: str, loc: int, toks: ParseResults) -> T.Any:
|
|
nucleus = toks.get("nucleus", Hbox(0))
|
|
subsuper = toks.get("subsuper", [])
|
|
napostrophes = len(toks.get("apostrophes", []))
|
|
|
|
if not subsuper and not napostrophes:
|
|
return nucleus
|
|
|
|
sub = super = None
|
|
while subsuper:
|
|
op, arg, *subsuper = subsuper
|
|
if op == '_':
|
|
if sub is not None:
|
|
raise ParseFatalException("Double subscript")
|
|
sub = arg
|
|
else:
|
|
if super is not None:
|
|
raise ParseFatalException("Double superscript")
|
|
super = arg
|
|
|
|
state = self.get_state()
|
|
rule_thickness = state.fontset.get_underline_thickness(
|
|
state.font, state.fontsize, state.dpi)
|
|
xHeight = state.fontset.get_xheight(
|
|
state.font, state.fontsize, state.dpi)
|
|
|
|
if napostrophes:
|
|
if super is None:
|
|
super = Hlist([])
|
|
for i in range(napostrophes):
|
|
super.children.extend(self.symbol(s, loc, {"sym": "\\prime"}))
|
|
# kern() and hpack() needed to get the metrics right after
|
|
# extending
|
|
super.kern()
|
|
super.hpack()
|
|
|
|
# Handle over/under symbols, such as sum or prod
|
|
if self.is_overunder(nucleus):
|
|
vlist = []
|
|
shift = 0.
|
|
width = nucleus.width
|
|
if super is not None:
|
|
super.shrink()
|
|
width = max(width, super.width)
|
|
if sub is not None:
|
|
sub.shrink()
|
|
width = max(width, sub.width)
|
|
|
|
vgap = rule_thickness * 3.0
|
|
if super is not None:
|
|
hlist = HCentered([super])
|
|
hlist.hpack(width, 'exactly')
|
|
vlist.extend([hlist, Vbox(0, vgap)])
|
|
hlist = HCentered([nucleus])
|
|
hlist.hpack(width, 'exactly')
|
|
vlist.append(hlist)
|
|
if sub is not None:
|
|
hlist = HCentered([sub])
|
|
hlist.hpack(width, 'exactly')
|
|
vlist.extend([Vbox(0, vgap), hlist])
|
|
shift = hlist.height + vgap + nucleus.depth
|
|
vlt = Vlist(vlist)
|
|
vlt.shift_amount = shift
|
|
result = Hlist([vlt])
|
|
return [result]
|
|
|
|
# We remove kerning on the last character for consistency (otherwise
|
|
# it will compute kerning based on non-shrunk characters and may put
|
|
# them too close together when superscripted)
|
|
# We change the width of the last character to match the advance to
|
|
# consider some fonts with weird metrics: e.g. stix's f has a width of
|
|
# 7.75 and a kerning of -4.0 for an advance of 3.72, and we want to put
|
|
# the superscript at the advance
|
|
last_char = nucleus
|
|
if isinstance(nucleus, Hlist):
|
|
new_children = nucleus.children
|
|
if len(new_children):
|
|
# remove last kern
|
|
if (isinstance(new_children[-1], Kern) and
|
|
hasattr(new_children[-2], '_metrics')):
|
|
new_children = new_children[:-1]
|
|
last_char = new_children[-1]
|
|
if hasattr(last_char, '_metrics'):
|
|
last_char.width = last_char._metrics.advance
|
|
# create new Hlist without kerning
|
|
nucleus = Hlist(new_children, do_kern=False)
|
|
else:
|
|
if isinstance(nucleus, Char):
|
|
last_char.width = last_char._metrics.advance
|
|
nucleus = Hlist([nucleus])
|
|
|
|
# Handle regular sub/superscripts
|
|
constants = _get_font_constant_set(state)
|
|
lc_height = last_char.height
|
|
lc_baseline = 0
|
|
if self.is_dropsub(last_char):
|
|
lc_baseline = last_char.depth
|
|
|
|
# Compute kerning for sub and super
|
|
superkern = constants.delta * xHeight
|
|
subkern = constants.delta * xHeight
|
|
if self.is_slanted(last_char):
|
|
superkern += constants.delta * xHeight
|
|
superkern += (constants.delta_slanted *
|
|
(lc_height - xHeight * 2. / 3.))
|
|
if self.is_dropsub(last_char):
|
|
subkern = (3 * constants.delta -
|
|
constants.delta_integral) * lc_height
|
|
superkern = (3 * constants.delta +
|
|
constants.delta_integral) * lc_height
|
|
else:
|
|
subkern = 0
|
|
|
|
x: List
|
|
if super is None:
|
|
# node757
|
|
# Note: One of super or sub must be a Node if we're in this function, but
|
|
# mypy can't know this, since it can't interpret pyparsing expressions,
|
|
# hence the cast.
|
|
x = Hlist([Kern(subkern), T.cast(Node, sub)])
|
|
x.shrink()
|
|
if self.is_dropsub(last_char):
|
|
shift_down = lc_baseline + constants.subdrop * xHeight
|
|
else:
|
|
shift_down = constants.sub1 * xHeight
|
|
x.shift_amount = shift_down
|
|
else:
|
|
x = Hlist([Kern(superkern), super])
|
|
x.shrink()
|
|
if self.is_dropsub(last_char):
|
|
shift_up = lc_height - constants.subdrop * xHeight
|
|
else:
|
|
shift_up = constants.sup1 * xHeight
|
|
if sub is None:
|
|
x.shift_amount = -shift_up
|
|
else: # Both sub and superscript
|
|
y = Hlist([Kern(subkern), sub])
|
|
y.shrink()
|
|
if self.is_dropsub(last_char):
|
|
shift_down = lc_baseline + constants.subdrop * xHeight
|
|
else:
|
|
shift_down = constants.sub2 * xHeight
|
|
# If sub and superscript collide, move super up
|
|
clr = (2.0 * rule_thickness -
|
|
((shift_up - x.depth) - (y.height - shift_down)))
|
|
if clr > 0.:
|
|
shift_up += clr
|
|
x = Vlist([
|
|
x,
|
|
Kern((shift_up - x.depth) - (y.height - shift_down)),
|
|
y])
|
|
x.shift_amount = shift_down
|
|
|
|
if not self.is_dropsub(last_char):
|
|
x.width += constants.script_space * xHeight
|
|
|
|
# Do we need to add a space after the nucleus?
|
|
# To find out, check the flag set by operatorname
|
|
spaced_nucleus = [nucleus, x]
|
|
if self._in_subscript_or_superscript:
|
|
spaced_nucleus += [self._make_space(self._space_widths[r'\,'])]
|
|
self._in_subscript_or_superscript = False
|
|
|
|
result = Hlist(spaced_nucleus)
|
|
return [result]
|
|
|
|
def _genfrac(self, ldelim: str, rdelim: str, rule: float | None, style: _MathStyle,
|
|
num: Hlist, den: Hlist) -> T.Any:
|
|
state = self.get_state()
|
|
thickness = state.get_current_underline_thickness()
|
|
|
|
for _ in range(style.value):
|
|
num.shrink()
|
|
den.shrink()
|
|
cnum = HCentered([num])
|
|
cden = HCentered([den])
|
|
width = max(num.width, den.width)
|
|
cnum.hpack(width, 'exactly')
|
|
cden.hpack(width, 'exactly')
|
|
vlist = Vlist([cnum, # numerator
|
|
Vbox(0, thickness * 2.0), # space
|
|
Hrule(state, rule), # rule
|
|
Vbox(0, thickness * 2.0), # space
|
|
cden # denominator
|
|
])
|
|
|
|
# Shift so the fraction line sits in the middle of the
|
|
# equals sign
|
|
metrics = state.fontset.get_metrics(
|
|
state.font, mpl.rcParams['mathtext.default'],
|
|
'=', state.fontsize, state.dpi)
|
|
shift = (cden.height -
|
|
((metrics.ymax + metrics.ymin) / 2 -
|
|
thickness * 3.0))
|
|
vlist.shift_amount = shift
|
|
|
|
result = [Hlist([vlist, Hbox(thickness * 2.)])]
|
|
if ldelim or rdelim:
|
|
if ldelim == '':
|
|
ldelim = '.'
|
|
if rdelim == '':
|
|
rdelim = '.'
|
|
return self._auto_sized_delimiter(ldelim,
|
|
T.cast(list[T.Union[Box, Char, str]],
|
|
result),
|
|
rdelim)
|
|
return result
|
|
|
|
def style_literal(self, toks: ParseResults) -> T.Any:
|
|
return self._MathStyle(int(toks["style_literal"]))
|
|
|
|
def genfrac(self, toks: ParseResults) -> T.Any:
|
|
return self._genfrac(
|
|
toks.get("ldelim", ""), toks.get("rdelim", ""),
|
|
toks["rulesize"], toks.get("style", self._MathStyle.TEXTSTYLE),
|
|
toks["num"], toks["den"])
|
|
|
|
def frac(self, toks: ParseResults) -> T.Any:
|
|
return self._genfrac(
|
|
"", "", self.get_state().get_current_underline_thickness(),
|
|
self._MathStyle.TEXTSTYLE, toks["num"], toks["den"])
|
|
|
|
def dfrac(self, toks: ParseResults) -> T.Any:
|
|
return self._genfrac(
|
|
"", "", self.get_state().get_current_underline_thickness(),
|
|
self._MathStyle.DISPLAYSTYLE, toks["num"], toks["den"])
|
|
|
|
def binom(self, toks: ParseResults) -> T.Any:
|
|
return self._genfrac(
|
|
"(", ")", 0,
|
|
self._MathStyle.TEXTSTYLE, toks["num"], toks["den"])
|
|
|
|
def _genset(self, s: str, loc: int, toks: ParseResults) -> T.Any:
|
|
annotation = toks["annotation"]
|
|
body = toks["body"]
|
|
thickness = self.get_state().get_current_underline_thickness()
|
|
|
|
annotation.shrink()
|
|
centered_annotation = HCentered([annotation])
|
|
centered_body = HCentered([body])
|
|
width = max(centered_annotation.width, centered_body.width)
|
|
centered_annotation.hpack(width, 'exactly')
|
|
centered_body.hpack(width, 'exactly')
|
|
|
|
vgap = thickness * 3
|
|
if s[loc + 1] == "u": # \underset
|
|
vlist = Vlist([
|
|
centered_body, # body
|
|
Vbox(0, vgap), # space
|
|
centered_annotation # annotation
|
|
])
|
|
# Shift so the body sits in the same vertical position
|
|
vlist.shift_amount = centered_body.depth + centered_annotation.height + vgap
|
|
else: # \overset
|
|
vlist = Vlist([
|
|
centered_annotation, # annotation
|
|
Vbox(0, vgap), # space
|
|
centered_body # body
|
|
])
|
|
|
|
# To add horizontal gap between symbols: wrap the Vlist into
|
|
# an Hlist and extend it with an Hbox(0, horizontal_gap)
|
|
return vlist
|
|
|
|
overset = underset = _genset
|
|
|
|
def sqrt(self, toks: ParseResults) -> T.Any:
|
|
root = toks.get("root")
|
|
body = toks["value"]
|
|
state = self.get_state()
|
|
thickness = state.get_current_underline_thickness()
|
|
|
|
# Determine the height of the body, and add a little extra to
|
|
# the height so it doesn't seem cramped
|
|
height = body.height - body.shift_amount + thickness * 5.0
|
|
depth = body.depth + body.shift_amount
|
|
check = AutoHeightChar(r'\__sqrt__', height, depth, state, always=True)
|
|
height = check.height - check.shift_amount
|
|
depth = check.depth + check.shift_amount
|
|
|
|
# Put a little extra space to the left and right of the body
|
|
padded_body = Hlist([Hbox(2 * thickness), body, Hbox(2 * thickness)])
|
|
rightside = Vlist([Hrule(state), Glue('fill'), padded_body])
|
|
# Stretch the glue between the hrule and the body
|
|
rightside.vpack(height + (state.fontsize * state.dpi) / (100.0 * 12.0),
|
|
'exactly', depth)
|
|
|
|
# Add the root and shift it upward so it is above the tick.
|
|
# The value of 0.6 is a hard-coded hack ;)
|
|
if not root:
|
|
root = Box(check.width * 0.5, 0., 0.)
|
|
else:
|
|
root = Hlist(root)
|
|
root.shrink()
|
|
root.shrink()
|
|
|
|
root_vlist = Vlist([Hlist([root])])
|
|
root_vlist.shift_amount = -height * 0.6
|
|
|
|
hlist = Hlist([root_vlist, # Root
|
|
# Negative kerning to put root over tick
|
|
Kern(-check.width * 0.5),
|
|
check, # Check
|
|
rightside]) # Body
|
|
return [hlist]
|
|
|
|
def overline(self, toks: ParseResults) -> T.Any:
|
|
body = toks["body"]
|
|
|
|
state = self.get_state()
|
|
thickness = state.get_current_underline_thickness()
|
|
|
|
height = body.height - body.shift_amount + thickness * 3.0
|
|
depth = body.depth + body.shift_amount
|
|
|
|
# Place overline above body
|
|
rightside = Vlist([Hrule(state), Glue('fill'), Hlist([body])])
|
|
|
|
# Stretch the glue between the hrule and the body
|
|
rightside.vpack(height + (state.fontsize * state.dpi) / (100.0 * 12.0),
|
|
'exactly', depth)
|
|
|
|
hlist = Hlist([rightside])
|
|
return [hlist]
|
|
|
|
def _auto_sized_delimiter(self, front: str,
|
|
middle: list[Box | Char | str],
|
|
back: str) -> T.Any:
|
|
state = self.get_state()
|
|
if len(middle):
|
|
height = max([x.height for x in middle if not isinstance(x, str)])
|
|
depth = max([x.depth for x in middle if not isinstance(x, str)])
|
|
factor = None
|
|
for idx, el in enumerate(middle):
|
|
if el == r'\middle':
|
|
c = T.cast(str, middle[idx + 1]) # Should be one of p.delims.
|
|
if c != '.':
|
|
middle[idx + 1] = AutoHeightChar(
|
|
c, height, depth, state, factor=factor)
|
|
else:
|
|
middle.remove(c)
|
|
del middle[idx]
|
|
# There should only be \middle and its delimiter as str, which have
|
|
# just been removed.
|
|
middle_part = T.cast(list[T.Union[Box, Char]], middle)
|
|
else:
|
|
height = 0
|
|
depth = 0
|
|
factor = 1.0
|
|
middle_part = []
|
|
|
|
parts: list[Node] = []
|
|
# \left. and \right. aren't supposed to produce any symbols
|
|
if front != '.':
|
|
parts.append(
|
|
AutoHeightChar(front, height, depth, state, factor=factor))
|
|
parts.extend(middle_part)
|
|
if back != '.':
|
|
parts.append(
|
|
AutoHeightChar(back, height, depth, state, factor=factor))
|
|
hlist = Hlist(parts)
|
|
return hlist
|
|
|
|
def auto_delim(self, toks: ParseResults) -> T.Any:
|
|
return self._auto_sized_delimiter(
|
|
toks["left"],
|
|
# if "mid" in toks ... can be removed when requiring pyparsing 3.
|
|
toks["mid"].asList() if "mid" in toks else [],
|
|
toks["right"])
|
|
|
|
def boldsymbol(self, toks: ParseResults) -> T.Any:
|
|
self.push_state()
|
|
state = self.get_state()
|
|
hlist: list[Node] = []
|
|
name = toks["value"]
|
|
for c in name:
|
|
if isinstance(c, Hlist):
|
|
k = c.children[1]
|
|
if isinstance(k, Char):
|
|
k.font = "bf"
|
|
k._update_metrics()
|
|
hlist.append(c)
|
|
elif isinstance(c, Char):
|
|
c.font = "bf"
|
|
if (c.c in self._latin_alphabets or
|
|
c.c[1:] in self._small_greek):
|
|
c.font = "bfit"
|
|
c._update_metrics()
|
|
c._update_metrics()
|
|
hlist.append(c)
|
|
else:
|
|
hlist.append(c)
|
|
self.pop_state()
|
|
|
|
return Hlist(hlist)
|
|
|
|
def substack(self, toks: ParseResults) -> T.Any:
|
|
parts = toks["parts"]
|
|
state = self.get_state()
|
|
thickness = state.get_current_underline_thickness()
|
|
|
|
hlist = [Hlist(k) for k in parts[0]]
|
|
max_width = max(map(lambda c: c.width, hlist))
|
|
|
|
vlist = []
|
|
for sub in hlist:
|
|
cp = HCentered([sub])
|
|
cp.hpack(max_width, 'exactly')
|
|
vlist.append(cp)
|
|
|
|
stack = [val
|
|
for pair in zip(vlist, [Vbox(0, thickness * 2)] * len(vlist))
|
|
for val in pair]
|
|
del stack[-1]
|
|
vlt = Vlist(stack)
|
|
result = [Hlist([vlt])]
|
|
return result
|