Skip to content
Navigation Menu
{{ message }}
-
-
Notifications
You must be signed in to change notification settings - Fork 8.4k
Implement TeX's fraction and script alignment #31046
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
QuLogic
merged 3 commits into
matplotlib:text-overhaul
from
QuLogic:22852/mathtext-vertical-align
Feb 7, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
@@ -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: | ||
|
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. | ||
|
|
@@ -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,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 | ||
|
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 | ||
|
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 | ||
|
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
You can’t perform that action at this time.

There was a problem hiding this comment.
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).
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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
pcltis 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.
There was a problem hiding this comment.
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?