From 94ff452edd18042395cf6578c538e41398378eca Mon Sep 17 00:00:00 2001 From: tfpf <19171016+tfpf@users.noreply.github.com> Date: Sun, 17 Jul 2022 13:34:00 +0530 Subject: [PATCH 1/3] Implement TeX's fraction and script alignment As described in *TeX: the Program* by Don Knuth. New font constants are set to the nearest integral multiples of 0.1 for which numerators and denominators containing normal text do not have to be shifted beyond their default shift amounts at font size 30 in display and text styles. To better process superscripts and subscripts, the x-height is now always calculated instead of being retrieved from the font table (which was the case for Computer Modern); the affected font constants have been changed. A duplicate test was also fixed in the process. --- lib/matplotlib/_mathtext.py | 211 ++++++++++++++++++++------ lib/matplotlib/tests/test_mathtext.py | 11 +- 2 files changed, 172 insertions(+), 50 deletions(-) diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index 8f0d85bd5ab3..67184c5a27d2 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,16 +932,45 @@ 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 + supdrop = 0.354296875 + subdrop = 0.354296875 + sup1 = 0.79716796875 + sub1 = 0.354296875 + sub2 = 0.5314453125 + delta = 0.132861328125 delta_slanted = 0.3 delta_integral = 0.3 + num1 = 1.5 + num2 = 1.5 + num3 = 1.5 + denom1 = 1.6 + denom2 = 1.2 class STIXFontConstants(FontConstantsBase): @@ -940,6 +980,10 @@ class STIXFontConstants(FontConstantsBase): delta = 0.05 delta_slanted = 0.3 delta_integral = 0.3 + num1 = 1.6 + num2 = 1.6 + num3 = 1.6 + denom1 = 1.6 class STIXSansFontConstants(FontConstantsBase): @@ -947,10 +991,16 @@ class STIXSansFontConstants(FontConstantsBase): sup1 = 0.8 delta_slanted = 0.6 delta_integral = 0.3 + num1 = 1.5 + num3 = 1.5 + denom1 = 1.5 class DejaVuSerifFontConstants(FontConstantsBase): - pass + num1 = 1.5 + num2 = 1.6 + num3 = 1.4 + denom1 = 1.4 class DejaVuSansFontConstants(FontConstantsBase): @@ -1015,6 +1065,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 +1263,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 +1358,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: @@ -2111,6 +2178,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 +2508,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 +2597,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 +2618,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 +2671,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 +2685,54 @@ 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) + # Possible bug in fraction rendering. See GitHub PR 22852 comments. + vlist = Vlist([cnum, # numerator + Vbox(0, num_clr - rule), # space + Hrule(state, rule), # rule + Vbox(0, den_clr + rule), # 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..3d62d2640ce6 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[ From 4bfa0f975042ab82d65d5233cf9d926bcaef8ce2 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Sun, 1 Feb 2026 17:38:00 -0500 Subject: [PATCH 2/3] Fix positioning of mathtext Rules At the call site https://github.com/matplotlib/matplotlib/blob/51fbfc4eb0e882ef7e95ceab9777c7047f4db819/lib/matplotlib/_mathtext.py#L1706-L1709 the box should clearly go from `cur_v + off_v` to `cur_v + off_v - rule_height` (this is why `cur_v` is shifted by `+ rule_height` just before; also at that point some print debugging indicates that y's go *downwards*), so `Rule.render` should indeed call `render_rect_filled` from `y - h` to `y`. --- lib/matplotlib/_mathtext.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index 67184c5a27d2..1882e76d3921 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -1453,7 +1453,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): @@ -2699,12 +2699,11 @@ def _genfrac(self, ldelim: str, rdelim: str, rule: float | None, style: _MathSty 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) - # Possible bug in fraction rendering. See GitHub PR 22852 comments. - vlist = Vlist([cnum, # numerator - Vbox(0, num_clr - rule), # space - Hrule(state, rule), # rule - Vbox(0, den_clr + rule), # space - cden # denominator + 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 From 1cd851066810b5acb870c3172eb3fcefe6edbb49 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Fri, 6 Feb 2026 03:00:39 -0500 Subject: [PATCH 3/3] Fix up mathtext constants for fraction/script alignment Computer Modern values are taken from `cmsy10.tfm`, and divided by the x-height in that output to match the scale used in Matplotlib. DejaVu Sans/Serif and STIX constants are taken from the embedded TeX table extracted with FontForge. --- lib/matplotlib/_mathtext.py | 86 ++++++++++++++++++--------- lib/matplotlib/tests/test_mathtext.py | 8 +-- 2 files changed, 62 insertions(+), 32 deletions(-) diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index 1882e76d3921..c41d2d4caa55 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -958,53 +958,83 @@ class ComputerModernFontConstants(FontConstantsBase): # 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 - supdrop = 0.354296875 - subdrop = 0.354296875 - sup1 = 0.79716796875 - sub1 = 0.354296875 - sub2 = 0.5314453125 delta = 0.132861328125 delta_slanted = 0.3 delta_integral = 0.3 - num1 = 1.5 - num2 = 1.5 - num3 = 1.5 - denom1 = 1.6 - denom2 = 1.2 + _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 - num1 = 1.6 - num2 = 1.6 - num3 = 1.6 - denom1 = 1.6 - - -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 - num1 = 1.5 - num3 = 1.5 - denom1 = 1.5 class DejaVuSerifFontConstants(FontConstantsBase): - num1 = 1.5 - num2 = 1.6 - num3 = 1.4 - denom1 = 1.4 + # 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 diff --git a/lib/matplotlib/tests/test_mathtext.py b/lib/matplotlib/tests/test_mathtext.py index 3d62d2640ce6..ff3e4a4c0e60 100644 --- a/lib/matplotlib/tests/test_mathtext.py +++ b/lib/matplotlib/tests/test_mathtext.py @@ -577,12 +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[ + Hlist[ + Hlist[ Hbox, - Vlist[ + Vlist[ HCentered[ Glue, Hlist[