Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
256 changes: 203 additions & 53 deletions lib/matplotlib/_mathtext.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,12 @@ def render_rect_filled(self, output: Output,
"""
output.rects.append((x, y, w, h))

def get_axis_height(self, font: str, fontsize: float, dpi: float) -> float:
"""
Get the axis height for the given *font* and *fontsize*.
"""
raise NotImplementedError()

def get_xheight(self, font: str, fontsize: float, dpi: float) -> float:
"""
Get the xheight for the given *font* and *fontsize*.
Expand Down Expand Up @@ -407,17 +413,19 @@ def _get_info(self, fontname: str, font_class: str, sym: str, fontsize: float,
offset=offset
)

def get_axis_height(self, fontname: str, fontsize: float, dpi: float) -> float:
# The fraction line (if present) must be aligned with the minus sign. Therefore,
# the height of the latter from the baseline is the axis height.
metrics = self.get_metrics(
fontname, mpl.rcParams['mathtext.default'], '\u2212', fontsize, dpi)
return (metrics.ymax + metrics.ymin) / 2

def get_xheight(self, fontname: str, fontsize: float, dpi: float) -> float:
font = self._get_font(fontname)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the rationale for not reading the x-height from pclt anymore? (+ see comment re: tfm params for actually directly using hardcoding tfm params instead).

@tfpf tfpf Feb 2, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The algorithm breaks for the bundled Computer Modern font, because the x-height obtained from pclt is much greater than the rendered x-height. The other fonts don't have a font table, if I recall correctly.

If, however, the correct parameters could be added to the files, the x-height (and many other parameters, including the axis height, as you pointed out below) could definitely be read therefrom.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, so let's not bother with this too much for now and fix this later once when revisiting xheight/axis_height reading?

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
x_height = (pclt['xHeight'] / 64) * (fontsize / 12) * (dpi / 100)
return x_height
# Some fonts report the wrong x-height, while some don't store it, so
# we do a poor man's x-height.
metrics = self.get_metrics(
fontname, mpl.rcParams['mathtext.default'], 'x', fontsize, dpi)
return metrics.iceberg

def get_underline_thickness(self, font: str, fontsize: float, dpi: float) -> float:
# This function used to grab underline thickness from the font
Expand Down Expand Up @@ -895,7 +903,10 @@ class FontConstantsBase:
# 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
# Percentage of x-height that superscripts drop below the top of large box
supdrop: T.ClassVar[float] = 0.4

# Percentage of x-height that subscripts drop below the bottom of large box
subdrop: T.ClassVar[float] = 0.4

# Percentage of x-height that superscripts are raised from the baseline
Expand All @@ -921,40 +932,109 @@ class FontConstantsBase:
# integrals
delta_integral: T.ClassVar[float] = 0.1

# Percentage of x-height the numerator is shifted up in display style.
num1: T.ClassVar[float] = 1.4

# Percentage of x-height the numerator is shifted up in text, script and
# scriptscript styles if there is a fraction line.
num2: T.ClassVar[float] = 1.5

# Percentage of x-height the numerator is shifted up in text, script and
# scriptscript styles if there is no fraction line.
num3: T.ClassVar[float] = 1.3

# Percentage of x-height the denominator is shifted down in display style.
denom1: T.ClassVar[float] = 1.3

# Percentage of x-height the denominator is shifted down in text, script
# and scriptscript styles.
denom2: T.ClassVar[float] = 1.1


class ComputerModernFontConstants(FontConstantsBase):
script_space = 0.075
subdrop = 0.2
sup1 = 0.45
sub1 = 0.2
sub2 = 0.3
delta = 0.075
# Previously, the x-height of Computer Modern was obtained from the font
# table. However, that x-height was greater than the the actual (rendered)
# x-height by a factor of 1.771484375 (at font size 12, DPI 100 and hinting
# type 32). Now that we're using the rendered x-height, some font constants
# have been increased by the same factor to compensate.
script_space = 0.132861328125
delta = 0.132861328125
delta_slanted = 0.3
delta_integral = 0.3
_x_height = 451470
# These all come from the cmsy10.tfm metrics, divided by the design xheight from
# there, since we multiply these values by the scaled xheight later.
supdrop = 404864 / _x_height
subdrop = 52429 / _x_height
sup1 = 432949 / _x_height
sub1 = 157286 / _x_height
sub2 = 259226 / _x_height
num1 = 709370 / _x_height
num2 = 412858 / _x_height
num3 = 465286 / _x_height
denom1 = 719272 / _x_height
denom2 = 361592 / _x_height


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):
# These values are extracted from the TeX table of STIXGeneral.ttf using FreeType,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should have said FontForge, not FreeType; I'll fix it in a followup.

# and then divided by design xheight, since we multiply these values by the scaled
# xheight later.
_x_height = 450
supdrop = 386 / _x_height
subdrop = 50.0002 / _x_height
sup1 = 413 / _x_height
sub1 = 150 / _x_height
sub2 = 309 / _x_height
num1 = 747 / _x_height
num2 = 424 / _x_height
num3 = 474 / _x_height
denom1 = 756 / _x_height
denom2 = 375 / _x_height


class STIXSansFontConstants(STIXFontConstants):
script_space = 0.05
sup1 = 0.8
delta_slanted = 0.6
delta_integral = 0.3


class DejaVuSerifFontConstants(FontConstantsBase):
pass
# These values are extracted from the TeX table of DejaVuSerif.ttf using FreeType,
# and then divided by design xheight, since we multiply these values by the scaled
# xheight later.
_x_height = 1063
supdrop = 790.527 / _x_height
subdrop = 102.4 / _x_height
sup1 = 845.824 / _x_height
sub1 = 307.199 / _x_height
sub2 = 632.832 / _x_height
num1 = 1529.86 / _x_height
num2 = 868.352 / _x_height
num3 = 970.752 / _x_height
denom1 = 1548.29 / _x_height
denom2 = 768 / _x_height


class DejaVuSansFontConstants(FontConstantsBase):
pass
# These values are extracted from the TeX table of DejaVuSans.ttf using FreeType,
# and then divided by design xheight, since we multiply these values by the scaled
# xheight later.
_x_height = 1120
supdrop = 790.527 / _x_height
subdrop = 102.4 / _x_height
sup1 = 845.824 / _x_height
sub1 = 307.199 / _x_height
sub2 = 632.832 / _x_height
num1 = 1529.86 / _x_height
num2 = 868.352 / _x_height
num3 = 970.752 / _x_height
denom1 = 1548.29 / _x_height
denom2 = 768 / _x_height


# Maps font family names to the FontConstantBase subclass to use
Expand Down Expand Up @@ -1015,6 +1095,15 @@ def shrink(self) -> None:
def render(self, output: Output, x: float, y: float) -> None:
"""Render this node."""

def is_char_node(self) -> bool:
# TeX defines a `char_node` as one which represents a single character,
# but also states that a `char_node` will never appear in a `Vlist`
# (node134). Further, nuclei made of one `Char` and nuclei made of
# multiple `Char`s have their superscripts and subscripts shifted by
# the same amount. In order to make Mathtext behave similarly, just
# check whether this node is a `Vlist` or has any `Vlist` descendants.
return True


class Box(Node):
"""A node with a physical location."""
Expand Down Expand Up @@ -1204,6 +1293,10 @@ def __init__(self, elements: T.Sequence[Node], w: float = 0.0,
self.kern()
self.hpack(w=w, m=m)

def is_char_node(self) -> bool:
# See description in Node.is_char_node.
return all(map(lambda node: node.is_char_node(), self.children))

def kern(self) -> None:
"""
Insert `Kern` nodes between `Char` nodes to set kerning.
Expand Down Expand Up @@ -1295,6 +1388,10 @@ def __init__(self, elements: T.Sequence[Node], h: float = 0.0,
super().__init__(elements)
self.vpack(h=h, m=m)

def is_char_node(self) -> bool:
# See description in Node.is_char_node.
return False

def vpack(self, h: float = 0.0,
m: T.Literal['additional', 'exactly'] = 'additional',
l: float = np.inf) -> None:
Expand Down Expand Up @@ -1386,7 +1483,7 @@ def __init__(self, width: float, height: float, depth: float, state: ParserState

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, w, h)
self.fontset.render_rect_filled(output, x, y - h, w, h)


class Hrule(Rule):
Expand Down Expand Up @@ -2111,6 +2208,7 @@ def csnames(group: str, names: Iterable[str]) -> Regex:
| p.text
| p.boldsymbol
| p.substack
| p.auto_delim
)

mdelim = r"\middle" - (p.delim("mdelim") | Error("Expected a delimiter"))
Expand Down Expand Up @@ -2440,8 +2538,7 @@ def subsuper(self, s: str, loc: int, toks: ParseResults) -> T.Any:
state = self.get_state()
rule_thickness = state.fontset.get_underline_thickness(
state.font, state.fontsize, state.dpi)
x_height = state.fontset.get_xheight(
state.font, state.fontsize, state.dpi)
x_height = state.fontset.get_xheight(state.font, state.fontsize, state.dpi)

if napostrophes:
if super is None:
Expand Down Expand Up @@ -2530,9 +2627,19 @@ def subsuper(self, s: str, loc: int, toks: ParseResults) -> T.Any:
else:
subkern = 0

# Set the minimum shifts for the superscript and subscript (node756).
if nucleus.is_char_node():
shift_up = 0.0
shift_down = 0.0
else:
Comment thread
QuLogic marked this conversation as resolved.
shrunk_x_height = state.fontset.get_xheight(
state.font, state.fontsize * SHRINK_FACTOR, state.dpi)
shift_up = nucleus.height - consts.supdrop * shrunk_x_height
shift_down = nucleus.depth + consts.subdrop * shrunk_x_height

x: List
if super is None:
# node757
# Align subscript without superscript (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.
Expand All @@ -2541,29 +2648,37 @@ def subsuper(self, s: str, loc: int, toks: ParseResults) -> T.Any:
if self.is_dropsub(last_char):
shift_down = lc_baseline + consts.subdrop * x_height
else:
shift_down = consts.sub1 * x_height
shift_down = max(shift_down, consts.sub1 * x_height,
x.height - x_height * 4 / 5)
x.shift_amount = shift_down
else:
# Align superscript (node758).
x = Hlist([Kern(superkern), super])
x.shrink()
if self.is_dropsub(last_char):
shift_up = lc_height - consts.subdrop * x_height
else:
shift_up = consts.sup1 * x_height
shift_up = max(shift_up, consts.sup1 * x_height, x.depth + x_height / 4)
if sub is None:
x.shift_amount = -shift_up
else: # Both sub and superscript
else:
# Align subscript with superscript (node759).
y = Hlist([Kern(subkern), sub])
y.shrink()
if self.is_dropsub(last_char):
shift_down = lc_baseline + consts.subdrop * x_height
else:
shift_down = consts.sub2 * x_height
# If sub and superscript collide, move super up
clr = (2 * rule_thickness -
shift_down = max(shift_down, consts.sub2 * x_height)
# If the subscript and superscript are too close to each other,
# move the subscript down.
clr = (4 * rule_thickness -
((shift_up - x.depth) - (y.height - shift_down)))
if clr > 0.:
shift_up += clr
shift_down += clr
clr = x_height * 4 / 5 - shift_up + x.depth
if clr > 0:
shift_up += clr
shift_down -= clr
x = Vlist([
x,
Kern((shift_up - x.depth) - (y.height - shift_down)),
Expand All @@ -2586,32 +2701,67 @@ def _genfrac(self, ldelim: str, rdelim: str, rule: float | None, style: _MathSty
state = self.get_state()
thickness = state.get_current_underline_thickness()

axis_height = state.fontset.get_axis_height(
state.font, state.fontsize, state.dpi)
consts = _get_font_constant_set(state)
x_height = state.fontset.get_xheight(state.font, state.fontsize, state.dpi)

for _ in range(style.value):
x_height *= SHRINK_FACTOR
Comment thread
anntzer marked this conversation as resolved.
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, 2 * thickness), # space
Hrule(state, rule), # rule
Vbox(0, 2 * thickness), # 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 - 3 * thickness))
vlist.shift_amount = shift

result: list[Box | Char | str] = [Hlist([vlist, Hbox(2 * thickness)])]
# Align the fraction with a fraction line (node743, node744 and node746).
if rule:
if style is self._MathStyle.DISPLAYSTYLE:
num_shift_up = consts.num1 * x_height
den_shift_down = consts.denom1 * x_height
clr = 3 * rule # The minimum clearance.
else:
num_shift_up = consts.num2 * x_height
den_shift_down = consts.denom2 * x_height
clr = rule # The minimum clearance.
delta = rule / 2
Comment thread
anntzer marked this conversation as resolved.
num_clr = max((num_shift_up - cnum.depth) - (axis_height + delta), clr)
den_clr = max((axis_height - delta) - (cden.height - den_shift_down), clr)
vlist = Vlist([cnum, # numerator
Vbox(0, num_clr), # space
Hrule(state, rule), # rule
Vbox(0, den_clr), # space
cden # denominator
])
vlist.shift_amount = cden.height + den_clr + delta - axis_height

# Align the fraction without a fraction line (node743, node744 and node745).
else:
if style is self._MathStyle.DISPLAYSTYLE:
num_shift_up = consts.num1 * x_height
den_shift_down = consts.denom1 * x_height
min_clr = 7 * thickness # The minimum clearance.
else:
num_shift_up = consts.num3 * x_height
den_shift_down = consts.denom2 * x_height
min_clr = 3 * thickness # The minimum clearance.
def_clr = (num_shift_up - cnum.depth) - (cden.height - den_shift_down)
clr = max(def_clr, min_clr)
vlist = Vlist([cnum, # numerator
Vbox(0, clr), # space
cden # denominator
])
vlist.shift_amount = den_shift_down
if def_clr < min_clr:
vlist.shift_amount += (min_clr - def_clr) / 2

result: list[Box | Char | str] = [Hlist([
Hbox(thickness),
vlist,
Hbox(thickness)
])]
if ldelim or rdelim:
return self._auto_sized_delimiter(ldelim or ".", result, rdelim or ".")
return result
Expand Down
Loading
Loading