diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index 8f0d85bd5ab3..c41d2d4caa55 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -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*. @@ -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) - 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 @@ -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 @@ -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, + # 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 @@ -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.""" @@ -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. @@ -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: @@ -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): @@ -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")) @@ -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: @@ -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: + 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. @@ -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)), @@ -2586,7 +2701,13 @@ 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 num.shrink() den.shrink() cnum = HCentered([num]) @@ -2594,24 +2715,53 @@ def _genfrac(self, ldelim: str, rdelim: str, rule: float | None, style: _MathSty 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 + 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 diff --git a/lib/matplotlib/tests/test_mathtext.py b/lib/matplotlib/tests/test_mathtext.py index 31b5d37ea041..ff3e4a4c0e60 100644 --- a/lib/matplotlib/tests/test_mathtext.py +++ b/lib/matplotlib/tests/test_mathtext.py @@ -78,7 +78,7 @@ r'$x+{y}^{\frac{2}{k+1}}$', r'$\frac{a}{b/2}$', r'${a}_{0}+\frac{1}{{a}_{1}+\frac{1}{{a}_{2}+\frac{1}{{a}_{3}+\frac{1}{{a}_{4}}}}}$', - r'${a}_{0}+\frac{1}{{a}_{1}+\frac{1}{{a}_{2}+\frac{1}{{a}_{3}+\frac{1}{{a}_{4}}}}}$', + r'${a}_{0}+\dfrac{1}{{a}_{1}+\dfrac{1}{{a}_{2}+\dfrac{1}{{a}_{3}+\dfrac{1}{{a}_{4}}}}}$', r'$\binom{n}{k/2}$', r'$\binom{p}{2}{x}^{2}{y}^{p-2}-\frac{1}{1-x}\frac{1}{1-{x}^{2}}$', r'${x}^{2y}$', @@ -577,11 +577,12 @@ def test_box_repr(): _mathtext.DejaVuSansFonts(fm.FontProperties(), LoadFlags.NO_HINTING), fontsize=12, dpi=100)) assert s == textwrap.dedent("""\ - Hlist[ + Hlist[ Hlist[], - Hlist[ - Hlist[ - Vlist[ + Hlist[ + Hlist[ + Hbox, + Vlist[ HCentered[ Glue, Hlist[