Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 94ff452

Browse files
tfpfQuLogic
authored andcommitted
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.
1 parent c1d3c87 commit 94ff452

2 files changed

Lines changed: 172 additions & 50 deletions

File tree

lib/matplotlib/_mathtext.py

Lines changed: 166 additions & 45 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,16 +932,45 @@ 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+
supdrop = 0.354296875
962+
subdrop = 0.354296875
963+
sup1 = 0.79716796875
964+
sub1 = 0.354296875
965+
sub2 = 0.5314453125
966+
delta = 0.132861328125
932967
delta_slanted = 0.3
933968
delta_integral = 0.3
969+
num1 = 1.5
970+
num2 = 1.5
971+
num3 = 1.5
972+
denom1 = 1.6
973+
denom2 = 1.2
934974

935975

936976
class STIXFontConstants(FontConstantsBase):
@@ -940,17 +980,27 @@ class STIXFontConstants(FontConstantsBase):
940980
delta = 0.05
941981
delta_slanted = 0.3
942982
delta_integral = 0.3
983+
num1 = 1.6
984+
num2 = 1.6
985+
num3 = 1.6
986+
denom1 = 1.6
943987

944988

945989
class STIXSansFontConstants(FontConstantsBase):
946990
script_space = 0.05
947991
sup1 = 0.8
948992
delta_slanted = 0.6
949993
delta_integral = 0.3
994+
num1 = 1.5
995+
num3 = 1.5
996+
denom1 = 1.5
950997

951998

952999
class DejaVuSerifFontConstants(FontConstantsBase):
953-
pass
1000+
num1 = 1.5
1001+
num2 = 1.6
1002+
num3 = 1.4
1003+
denom1 = 1.4
9541004

9551005

9561006
class DejaVuSansFontConstants(FontConstantsBase):
@@ -1015,6 +1065,15 @@ def shrink(self) -> None:
10151065
def render(self, output: Output, x: float, y: float) -> None:
10161066
"""Render this node."""
10171067

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

10191078
class Box(Node):
10201079
"""A node with a physical location."""
@@ -1204,6 +1263,10 @@ def __init__(self, elements: T.Sequence[Node], w: float = 0.0,
12041263
self.kern()
12051264
self.hpack(w=w, m=m)
12061265

1266+
def is_char_node(self) -> bool:
1267+
# See description in Node.is_char_node.
1268+
return all(map(lambda node: node.is_char_node(), self.children))
1269+
12071270
def kern(self) -> None:
12081271
"""
12091272
Insert `Kern` nodes between `Char` nodes to set kerning.
@@ -1295,6 +1358,10 @@ def __init__(self, elements: T.Sequence[Node], h: float = 0.0,
12951358
super().__init__(elements)
12961359
self.vpack(h=h, m=m)
12971360

1361+
def is_char_node(self) -> bool:
1362+
# See description in Node.is_char_node.
1363+
return False
1364+
12981365
def vpack(self, h: float = 0.0,
12991366
m: T.Literal['additional', 'exactly'] = 'additional',
13001367
l: float = np.inf) -> None:
@@ -2111,6 +2178,7 @@ def csnames(group: str, names: Iterable[str]) -> Regex:
21112178
| p.text
21122179
| p.boldsymbol
21132180
| p.substack
2181+
| p.auto_delim
21142182
)
21152183

21162184
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:
24402508
state = self.get_state()
24412509
rule_thickness = state.fontset.get_underline_thickness(
24422510
state.font, state.fontsize, state.dpi)
2443-
x_height = state.fontset.get_xheight(
2444-
state.font, state.fontsize, state.dpi)
2511+
x_height = state.fontset.get_xheight(state.font, state.fontsize, state.dpi)
24452512

24462513
if napostrophes:
24472514
if super is None:
@@ -2530,9 +2597,19 @@ def subsuper(self, s: str, loc: int, toks: ParseResults) -> T.Any:
25302597
else:
25312598
subkern = 0
25322599

2600+
# Set the minimum shifts for the superscript and subscript (node756).
2601+
if nucleus.is_char_node():
2602+
shift_up = 0.0
2603+
shift_down = 0.0
2604+
else:
2605+
shrunk_x_height = state.fontset.get_xheight(
2606+
state.font, state.fontsize * SHRINK_FACTOR, state.dpi)
2607+
shift_up = nucleus.height - consts.supdrop * shrunk_x_height
2608+
shift_down = nucleus.depth + consts.subdrop * shrunk_x_height
2609+
25332610
x: List
25342611
if super is None:
2535-
# node757
2612+
# Align subscript without superscript (node757).
25362613
# Note: One of super or sub must be a Node if we're in this function, but
25372614
# mypy can't know this, since it can't interpret pyparsing expressions,
25382615
# hence the cast.
@@ -2541,29 +2618,37 @@ def subsuper(self, s: str, loc: int, toks: ParseResults) -> T.Any:
25412618
if self.is_dropsub(last_char):
25422619
shift_down = lc_baseline + consts.subdrop * x_height
25432620
else:
2544-
shift_down = consts.sub1 * x_height
2621+
shift_down = max(shift_down, consts.sub1 * x_height,
2622+
x.height - x_height * 4 / 5)
25452623
x.shift_amount = shift_down
25462624
else:
2625+
# Align superscript (node758).
25472626
x = Hlist([Kern(superkern), super])
25482627
x.shrink()
25492628
if self.is_dropsub(last_char):
25502629
shift_up = lc_height - consts.subdrop * x_height
25512630
else:
2552-
shift_up = consts.sup1 * x_height
2631+
shift_up = max(shift_up, consts.sup1 * x_height, x.depth + x_height / 4)
25532632
if sub is None:
25542633
x.shift_amount = -shift_up
2555-
else: # Both sub and superscript
2634+
else:
2635+
# Align subscript with superscript (node759).
25562636
y = Hlist([Kern(subkern), sub])
25572637
y.shrink()
25582638
if self.is_dropsub(last_char):
25592639
shift_down = lc_baseline + consts.subdrop * x_height
25602640
else:
2561-
shift_down = consts.sub2 * x_height
2562-
# If sub and superscript collide, move super up
2563-
clr = (2 * rule_thickness -
2641+
shift_down = max(shift_down, consts.sub2 * x_height)
2642+
# If the subscript and superscript are too close to each other,
2643+
# move the subscript down.
2644+
clr = (4 * rule_thickness -
25642645
((shift_up - x.depth) - (y.height - shift_down)))
25652646
if clr > 0.:
2566-
shift_up += clr
2647+
shift_down += clr
2648+
clr = x_height * 4 / 5 - shift_up + x.depth
2649+
if clr > 0:
2650+
shift_up += clr
2651+
shift_down -= clr
25672652
x = Vlist([
25682653
x,
25692654
Kern((shift_up - x.depth) - (y.height - shift_down)),
@@ -2586,32 +2671,68 @@ def _genfrac(self, ldelim: str, rdelim: str, rule: float | None, style: _MathSty
25862671
state = self.get_state()
25872672
thickness = state.get_current_underline_thickness()
25882673

2674+
axis_height = state.fontset.get_axis_height(
2675+
state.font, state.fontsize, state.dpi)
2676+
consts = _get_font_constant_set(state)
2677+
x_height = state.fontset.get_xheight(state.font, state.fontsize, state.dpi)
2678+
25892679
for _ in range(style.value):
2680+
x_height *= SHRINK_FACTOR
25902681
num.shrink()
25912682
den.shrink()
25922683
cnum = HCentered([num])
25932684
cden = HCentered([den])
25942685
width = max(num.width, den.width)
25952686
cnum.hpack(width, 'exactly')
25962687
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-
])
26042688

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)])]
2689+
# Align the fraction with a fraction line (node743, node744 and node746).
2690+
if rule:
2691+
if style is self._MathStyle.DISPLAYSTYLE:
2692+
num_shift_up = consts.num1 * x_height
2693+
den_shift_down = consts.denom1 * x_height
2694+
clr = 3 * rule # The minimum clearance.
2695+
else:
2696+
num_shift_up = consts.num2 * x_height
2697+
den_shift_down = consts.denom2 * x_height
2698+
clr = rule # The minimum clearance.
2699+
delta = rule / 2
2700+
num_clr = max((num_shift_up - cnum.depth) - (axis_height + delta), clr)
2701+
den_clr = max((axis_height - delta) - (cden.height - den_shift_down), clr)
2702+
# Possible bug in fraction rendering. See GitHub PR 22852 comments.
2703+
vlist = Vlist([cnum, # numerator
2704+
Vbox(0, num_clr - rule), # space
2705+
Hrule(state, rule), # rule
2706+
Vbox(0, den_clr + rule), # space
2707+
cden # denominator
2708+
])
2709+
vlist.shift_amount = cden.height + den_clr + delta - axis_height
2710+
2711+
# Align the fraction without a fraction line (node743, node744 and node745).
2712+
else:
2713+
if style is self._MathStyle.DISPLAYSTYLE:
2714+
num_shift_up = consts.num1 * x_height
2715+
den_shift_down = consts.denom1 * x_height
2716+
min_clr = 7 * thickness # The minimum clearance.
2717+
else:
2718+
num_shift_up = consts.num3 * x_height
2719+
den_shift_down = consts.denom2 * x_height
2720+
min_clr = 3 * thickness # The minimum clearance.
2721+
def_clr = (num_shift_up - cnum.depth) - (cden.height - den_shift_down)
2722+
clr = max(def_clr, min_clr)
2723+
vlist = Vlist([cnum, # numerator
2724+
Vbox(0, clr), # space
2725+
cden # denominator
2726+
])
2727+
vlist.shift_amount = den_shift_down
2728+
if def_clr < min_clr:
2729+
vlist.shift_amount += (min_clr - def_clr) / 2
2730+
2731+
result: list[Box | Char | str] = [Hlist([
2732+
Hbox(thickness),
2733+
vlist,
2734+
Hbox(thickness)
2735+
])]
26152736
if ldelim or rdelim:
26162737
return self._auto_sized_delimiter(ldelim or ".", result, rdelim or ".")
26172738
return result

lib/matplotlib/tests/test_mathtext.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@
7878
r'$x+{y}^{\frac{2}{k+1}}$',
7979
r'$\frac{a}{b/2}$',
8080
r'${a}_{0}+\frac{1}{{a}_{1}+\frac{1}{{a}_{2}+\frac{1}{{a}_{3}+\frac{1}{{a}_{4}}}}}$',
81-
r'${a}_{0}+\frac{1}{{a}_{1}+\frac{1}{{a}_{2}+\frac{1}{{a}_{3}+\frac{1}{{a}_{4}}}}}$',
81+
r'${a}_{0}+\dfrac{1}{{a}_{1}+\dfrac{1}{{a}_{2}+\dfrac{1}{{a}_{3}+\dfrac{1}{{a}_{4}}}}}$',
8282
r'$\binom{n}{k/2}$',
8383
r'$\binom{p}{2}{x}^{2}{y}^{p-2}-\frac{1}{1-x}\frac{1}{1-{x}^{2}}$',
8484
r'${x}^{2y}$',
@@ -577,11 +577,12 @@ def test_box_repr():
577577
_mathtext.DejaVuSansFonts(fm.FontProperties(), LoadFlags.NO_HINTING),
578578
fontsize=12, dpi=100))
579579
assert s == textwrap.dedent("""\
580-
Hlist<w=9.51 h=15.81 d=6.57 s=0.00>[
580+
Hlist<w=9.51 h=18.09 d=7.03 s=0.00>[
581581
Hlist<w=0.00 h=0.00 d=0.00 s=0.00>[],
582-
Hlist<w=9.51 h=15.81 d=6.57 s=0.00>[
583-
Hlist<w=9.51 h=15.81 d=6.57 s=0.00>[
584-
Vlist<w=7.43 h=22.38 d=0.00 s=6.57>[
582+
Hlist<w=9.51 h=18.09 d=7.03 s=0.00>[
583+
Hlist<w=9.51 h=18.09 d=7.03 s=0.00>[
584+
Hbox,
585+
Vlist<w=7.43 h=25.12 d=0.00 s=7.03>[
585586
HCentered<w=7.43 h=8.51 d=0.00 s=0.00>[
586587
Glue,
587588
Hlist<w=7.43 h=8.51 d=0.00 s=0.00>[

0 commit comments

Comments
 (0)