X Tutup
Skip to content

Commit afdd53a

Browse files
authored
Merge pull request #31046 from QuLogic/22852/mathtext-vertical-align
Implement TeX's fraction and script alignment
2 parents c1d3c87 + 1cd8510 commit afdd53a

File tree

2 files changed

+209
-58
lines changed

2 files changed

+209
-58
lines changed

lib/matplotlib/_mathtext.py

Lines changed: 203 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,12 @@ def render_rect_filled(self, output: Output,
306306
"""
307307
output.rects.append((x, y, w, h))
308308

309+
def get_axis_height(self, font: str, fontsize: float, dpi: float) -> float:
310+
"""
311+
Get the axis height for the given *font* and *fontsize*.
312+
"""
313+
raise NotImplementedError()
314+
309315
def get_xheight(self, font: str, fontsize: float, dpi: float) -> float:
310316
"""
311317
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,
407413
offset=offset
408414
)
409415

416+
def get_axis_height(self, fontname: str, fontsize: float, dpi: float) -> float:
417+
# The fraction line (if present) must be aligned with the minus sign. Therefore,
418+
# the height of the latter from the baseline is the axis height.
419+
metrics = self.get_metrics(
420+
fontname, mpl.rcParams['mathtext.default'], '\u2212', fontsize, dpi)
421+
return (metrics.ymax + metrics.ymin) / 2
422+
410423
def get_xheight(self, fontname: str, fontsize: float, dpi: float) -> float:
411-
font = self._get_font(fontname)
412-
font.set_size(fontsize, dpi)
413-
pclt = font.get_sfnt_table('pclt')
414-
if pclt is None:
415-
# Some fonts don't store the xHeight, so we do a poor man's xHeight
416-
metrics = self.get_metrics(
417-
fontname, mpl.rcParams['mathtext.default'], 'x', fontsize, dpi)
418-
return metrics.iceberg
419-
x_height = (pclt['xHeight'] / 64) * (fontsize / 12) * (dpi / 100)
420-
return x_height
424+
# Some fonts report the wrong x-height, while some don't store it, so
425+
# we do a poor man's x-height.
426+
metrics = self.get_metrics(
427+
fontname, mpl.rcParams['mathtext.default'], 'x', fontsize, dpi)
428+
return metrics.iceberg
421429

422430
def get_underline_thickness(self, font: str, fontsize: float, dpi: float) -> float:
423431
# This function used to grab underline thickness from the font
@@ -895,7 +903,10 @@ class FontConstantsBase:
895903
# Percentage of x-height of additional horiz. space after sub/superscripts
896904
script_space: T.ClassVar[float] = 0.05
897905

898-
# Percentage of x-height that sub/superscripts drop below the baseline
906+
# Percentage of x-height that superscripts drop below the top of large box
907+
supdrop: T.ClassVar[float] = 0.4
908+
909+
# Percentage of x-height that subscripts drop below the bottom of large box
899910
subdrop: T.ClassVar[float] = 0.4
900911

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

935+
# Percentage of x-height the numerator is shifted up in display style.
936+
num1: T.ClassVar[float] = 1.4
937+
938+
# Percentage of x-height the numerator is shifted up in text, script and
939+
# scriptscript styles if there is a fraction line.
940+
num2: T.ClassVar[float] = 1.5
941+
942+
# Percentage of x-height the numerator is shifted up in text, script and
943+
# scriptscript styles if there is no fraction line.
944+
num3: T.ClassVar[float] = 1.3
945+
946+
# Percentage of x-height the denominator is shifted down in display style.
947+
denom1: T.ClassVar[float] = 1.3
948+
949+
# Percentage of x-height the denominator is shifted down in text, script
950+
# and scriptscript styles.
951+
denom2: T.ClassVar[float] = 1.1
952+
924953

925954
class ComputerModernFontConstants(FontConstantsBase):
926-
script_space = 0.075
927-
subdrop = 0.2
928-
sup1 = 0.45
929-
sub1 = 0.2
930-
sub2 = 0.3
931-
delta = 0.075
955+
# Previously, the x-height of Computer Modern was obtained from the font
956+
# table. However, that x-height was greater than the the actual (rendered)
957+
# x-height by a factor of 1.771484375 (at font size 12, DPI 100 and hinting
958+
# type 32). Now that we're using the rendered x-height, some font constants
959+
# have been increased by the same factor to compensate.
960+
script_space = 0.132861328125
961+
delta = 0.132861328125
932962
delta_slanted = 0.3
933963
delta_integral = 0.3
964+
_x_height = 451470
965+
# These all come from the cmsy10.tfm metrics, divided by the design xheight from
966+
# there, since we multiply these values by the scaled xheight later.
967+
supdrop = 404864 / _x_height
968+
subdrop = 52429 / _x_height
969+
sup1 = 432949 / _x_height
970+
sub1 = 157286 / _x_height
971+
sub2 = 259226 / _x_height
972+
num1 = 709370 / _x_height
973+
num2 = 412858 / _x_height
974+
num3 = 465286 / _x_height
975+
denom1 = 719272 / _x_height
976+
denom2 = 361592 / _x_height
934977

935978

936979
class STIXFontConstants(FontConstantsBase):
937980
script_space = 0.1
938-
sup1 = 0.8
939-
sub2 = 0.6
940981
delta = 0.05
941982
delta_slanted = 0.3
942983
delta_integral = 0.3
943-
944-
945-
class STIXSansFontConstants(FontConstantsBase):
984+
# These values are extracted from the TeX table of STIXGeneral.ttf using FreeType,
985+
# and then divided by design xheight, since we multiply these values by the scaled
986+
# xheight later.
987+
_x_height = 450
988+
supdrop = 386 / _x_height
989+
subdrop = 50.0002 / _x_height
990+
sup1 = 413 / _x_height
991+
sub1 = 150 / _x_height
992+
sub2 = 309 / _x_height
993+
num1 = 747 / _x_height
994+
num2 = 424 / _x_height
995+
num3 = 474 / _x_height
996+
denom1 = 756 / _x_height
997+
denom2 = 375 / _x_height
998+
999+
1000+
class STIXSansFontConstants(STIXFontConstants):
9461001
script_space = 0.05
947-
sup1 = 0.8
9481002
delta_slanted = 0.6
9491003
delta_integral = 0.3
9501004

9511005

9521006
class DejaVuSerifFontConstants(FontConstantsBase):
953-
pass
1007+
# These values are extracted from the TeX table of DejaVuSerif.ttf using FreeType,
1008+
# and then divided by design xheight, since we multiply these values by the scaled
1009+
# xheight later.
1010+
_x_height = 1063
1011+
supdrop = 790.527 / _x_height
1012+
subdrop = 102.4 / _x_height
1013+
sup1 = 845.824 / _x_height
1014+
sub1 = 307.199 / _x_height
1015+
sub2 = 632.832 / _x_height
1016+
num1 = 1529.86 / _x_height
1017+
num2 = 868.352 / _x_height
1018+
num3 = 970.752 / _x_height
1019+
denom1 = 1548.29 / _x_height
1020+
denom2 = 768 / _x_height
9541021

9551022

9561023
class DejaVuSansFontConstants(FontConstantsBase):
957-
pass
1024+
# These values are extracted from the TeX table of DejaVuSans.ttf using FreeType,
1025+
# and then divided by design xheight, since we multiply these values by the scaled
1026+
# xheight later.
1027+
_x_height = 1120
1028+
supdrop = 790.527 / _x_height
1029+
subdrop = 102.4 / _x_height
1030+
sup1 = 845.824 / _x_height
1031+
sub1 = 307.199 / _x_height
1032+
sub2 = 632.832 / _x_height
1033+
num1 = 1529.86 / _x_height
1034+
num2 = 868.352 / _x_height
1035+
num3 = 970.752 / _x_height
1036+
denom1 = 1548.29 / _x_height
1037+
denom2 = 768 / _x_height
9581038

9591039

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

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

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

1296+
def is_char_node(self) -> bool:
1297+
# See description in Node.is_char_node.
1298+
return all(map(lambda node: node.is_char_node(), self.children))
1299+
12071300
def kern(self) -> None:
12081301
"""
12091302
Insert `Kern` nodes between `Char` nodes to set kerning.
@@ -1295,6 +1388,10 @@ def __init__(self, elements: T.Sequence[Node], h: float = 0.0,
12951388
super().__init__(elements)
12961389
self.vpack(h=h, m=m)
12971390

1391+
def is_char_node(self) -> bool:
1392+
# See description in Node.is_char_node.
1393+
return False
1394+
12981395
def vpack(self, h: float = 0.0,
12991396
m: T.Literal['additional', 'exactly'] = 'additional',
13001397
l: float = np.inf) -> None:
@@ -1386,7 +1483,7 @@ def __init__(self, width: float, height: float, depth: float, state: ParserState
13861483

13871484
def render(self, output: Output, # type: ignore[override]
13881485
x: float, y: float, w: float, h: float) -> None:
1389-
self.fontset.render_rect_filled(output, x, y, w, h)
1486+
self.fontset.render_rect_filled(output, x, y - h, w, h)
13901487

13911488

13921489
class Hrule(Rule):
@@ -2111,6 +2208,7 @@ def csnames(group: str, names: Iterable[str]) -> Regex:
21112208
| p.text
21122209
| p.boldsymbol
21132210
| p.substack
2211+
| p.auto_delim
21142212
)
21152213

21162214
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:
24402538
state = self.get_state()
24412539
rule_thickness = state.fontset.get_underline_thickness(
24422540
state.font, state.fontsize, state.dpi)
2443-
x_height = state.fontset.get_xheight(
2444-
state.font, state.fontsize, state.dpi)
2541+
x_height = state.fontset.get_xheight(state.font, state.fontsize, state.dpi)
24452542

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

2630+
# Set the minimum shifts for the superscript and subscript (node756).
2631+
if nucleus.is_char_node():
2632+
shift_up = 0.0
2633+
shift_down = 0.0
2634+
else:
2635+
shrunk_x_height = state.fontset.get_xheight(
2636+
state.font, state.fontsize * SHRINK_FACTOR, state.dpi)
2637+
shift_up = nucleus.height - consts.supdrop * shrunk_x_height
2638+
shift_down = nucleus.depth + consts.subdrop * shrunk_x_height
2639+
25332640
x: List
25342641
if super is None:
2535-
# node757
2642+
# Align subscript without superscript (node757).
25362643
# Note: One of super or sub must be a Node if we're in this function, but
25372644
# mypy can't know this, since it can't interpret pyparsing expressions,
25382645
# hence the cast.
@@ -2541,29 +2648,37 @@ def subsuper(self, s: str, loc: int, toks: ParseResults) -> T.Any:
25412648
if self.is_dropsub(last_char):
25422649
shift_down = lc_baseline + consts.subdrop * x_height
25432650
else:
2544-
shift_down = consts.sub1 * x_height
2651+
shift_down = max(shift_down, consts.sub1 * x_height,
2652+
x.height - x_height * 4 / 5)
25452653
x.shift_amount = shift_down
25462654
else:
2655+
# Align superscript (node758).
25472656
x = Hlist([Kern(superkern), super])
25482657
x.shrink()
25492658
if self.is_dropsub(last_char):
25502659
shift_up = lc_height - consts.subdrop * x_height
25512660
else:
2552-
shift_up = consts.sup1 * x_height
2661+
shift_up = max(shift_up, consts.sup1 * x_height, x.depth + x_height / 4)
25532662
if sub is None:
25542663
x.shift_amount = -shift_up
2555-
else: # Both sub and superscript
2664+
else:
2665+
# Align subscript with superscript (node759).
25562666
y = Hlist([Kern(subkern), sub])
25572667
y.shrink()
25582668
if self.is_dropsub(last_char):
25592669
shift_down = lc_baseline + consts.subdrop * x_height
25602670
else:
2561-
shift_down = consts.sub2 * x_height
2562-
# If sub and superscript collide, move super up
2563-
clr = (2 * rule_thickness -
2671+
shift_down = max(shift_down, consts.sub2 * x_height)
2672+
# If the subscript and superscript are too close to each other,
2673+
# move the subscript down.
2674+
clr = (4 * rule_thickness -
25642675
((shift_up - x.depth) - (y.height - shift_down)))
25652676
if clr > 0.:
2566-
shift_up += clr
2677+
shift_down += clr
2678+
clr = x_height * 4 / 5 - shift_up + x.depth
2679+
if clr > 0:
2680+
shift_up += clr
2681+
shift_down -= clr
25672682
x = Vlist([
25682683
x,
25692684
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
25862701
state = self.get_state()
25872702
thickness = state.get_current_underline_thickness()
25882703

2704+
axis_height = state.fontset.get_axis_height(
2705+
state.font, state.fontsize, state.dpi)
2706+
consts = _get_font_constant_set(state)
2707+
x_height = state.fontset.get_xheight(state.font, state.fontsize, state.dpi)
2708+
25892709
for _ in range(style.value):
2710+
x_height *= SHRINK_FACTOR
25902711
num.shrink()
25912712
den.shrink()
25922713
cnum = HCentered([num])
25932714
cden = HCentered([den])
25942715
width = max(num.width, den.width)
25952716
cnum.hpack(width, 'exactly')
25962717
cden.hpack(width, 'exactly')
2597-
vlist = Vlist([
2598-
cnum, # numerator
2599-
Vbox(0, 2 * thickness), # space
2600-
Hrule(state, rule), # rule
2601-
Vbox(0, 2 * thickness), # space
2602-
cden, # denominator
2603-
])
26042718

2605-
# Shift so the fraction line sits in the middle of the
2606-
# equals sign
2607-
metrics = state.fontset.get_metrics(
2608-
state.font, mpl.rcParams['mathtext.default'],
2609-
'=', state.fontsize, state.dpi)
2610-
shift = (cden.height -
2611-
((metrics.ymax + metrics.ymin) / 2 - 3 * thickness))
2612-
vlist.shift_amount = shift
2613-
2614-
result: list[Box | Char | str] = [Hlist([vlist, Hbox(2 * thickness)])]
2719+
# Align the fraction with a fraction line (node743, node744 and node746).
2720+
if rule:
2721+
if style is self._MathStyle.DISPLAYSTYLE:
2722+
num_shift_up = consts.num1 * x_height
2723+
den_shift_down = consts.denom1 * x_height
2724+
clr = 3 * rule # The minimum clearance.
2725+
else:
2726+
num_shift_up = consts.num2 * x_height
2727+
den_shift_down = consts.denom2 * x_height
2728+
clr = rule # The minimum clearance.
2729+
delta = rule / 2
2730+
num_clr = max((num_shift_up - cnum.depth) - (axis_height + delta), clr)
2731+
den_clr = max((axis_height - delta) - (cden.height - den_shift_down), clr)
2732+
vlist = Vlist([cnum, # numerator
2733+
Vbox(0, num_clr), # space
2734+
Hrule(state, rule), # rule
2735+
Vbox(0, den_clr), # space
2736+
cden # denominator
2737+
])
2738+
vlist.shift_amount = cden.height + den_clr + delta - axis_height
2739+
2740+
# Align the fraction without a fraction line (node743, node744 and node745).
2741+
else:
2742+
if style is self._MathStyle.DISPLAYSTYLE:
2743+
num_shift_up = consts.num1 * x_height
2744+
den_shift_down = consts.denom1 * x_height
2745+
min_clr = 7 * thickness # The minimum clearance.
2746+
else:
2747+
num_shift_up = consts.num3 * x_height
2748+
den_shift_down = consts.denom2 * x_height
2749+
min_clr = 3 * thickness # The minimum clearance.
2750+
def_clr = (num_shift_up - cnum.depth) - (cden.height - den_shift_down)
2751+
clr = max(def_clr, min_clr)
2752+
vlist = Vlist([cnum, # numerator
2753+
Vbox(0, clr), # space
2754+
cden # denominator
2755+
])
2756+
vlist.shift_amount = den_shift_down
2757+
if def_clr < min_clr:
2758+
vlist.shift_amount += (min_clr - def_clr) / 2
2759+
2760+
result: list[Box | Char | str] = [Hlist([
2761+
Hbox(thickness),
2762+
vlist,
2763+
Hbox(thickness)
2764+
])]
26152765
if ldelim or rdelim:
26162766
return self._auto_sized_delimiter(ldelim or ".", result, rdelim or ".")
26172767
return result

0 commit comments

Comments
 (0)
X Tutup