diff --git a/doc/release/next_whats_new/tex_phantoms.rst b/doc/release/next_whats_new/tex_phantoms.rst new file mode 100644 index 000000000000..82d39d502fb5 --- /dev/null +++ b/doc/release/next_whats_new/tex_phantoms.rst @@ -0,0 +1,11 @@ +mathtext support for ``\phantom``, ``\llap``, ``\rlap`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +mathtext gained support for the TeX macros ``\phantom``, ``\llap``, and +``\rlap``. ``\phantom`` allows to occupy some space on the canvas as if +some text was being rendered, without actually rendering that text, whereas +``\llap`` and ``\rlap`` allows to render some text on the canvas while +pretending that it occupies no space. Altogether these macros allow some finer +control of text alignments. + +See https://www.tug.org/TUGboat/tb22-4/tb72perlS.pdf for a detailed description +of these macros. diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index dc09dedcca2c..3a60a31eb58d 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -1326,6 +1326,7 @@ def __init__(self, elements: T.Sequence[Node], w: float = 0.0, if do_kern: self.kern() self.hpack(w=w, m=m) + self.is_phantom = False def is_char_node(self) -> bool: # See description in Node.is_char_node. @@ -1716,6 +1717,11 @@ def ship(box: Box, xy: tuple[float, float] = (0, 0)) -> Output: off_v = oy + box.height output = Output(box) + phantom: list[bool] = [] + def render(node, *args): + if not any(phantom): + node.render(*args) + def clamp(value: float) -> float: return -1e9 if value < -1e9 else +1e9 if value > +1e9 else value @@ -1729,9 +1735,11 @@ def hlist_out(box: Hlist) -> None: base_line = cur_v left_edge = cur_h + phantom.append(box.is_phantom) + for p in box.children: if isinstance(p, Char): - p.render(output, cur_h + off_h, cur_v + off_v) + render(p, output, cur_h + off_h, cur_v + off_v) cur_h += p.width elif isinstance(p, Kern): cur_h += p.width @@ -1762,9 +1770,9 @@ def hlist_out(box: Hlist) -> None: rule_depth = box.depth if rule_height > 0 and rule_width > 0: cur_v = base_line + rule_depth - p.render(output, - cur_h + off_h, cur_v + off_v, - rule_width, rule_height) + render(p, output, + cur_h + off_h, cur_v + off_v, + rule_width, rule_height) cur_v = base_line cur_h += rule_width elif isinstance(p, Glue): @@ -1782,6 +1790,8 @@ def hlist_out(box: Hlist) -> None: rule_width += cur_g cur_h += rule_width + phantom.pop() + def vlist_out(box: Vlist) -> None: nonlocal cur_v, cur_h @@ -1821,9 +1831,9 @@ def vlist_out(box: Vlist) -> None: rule_height += rule_depth if rule_height > 0 and rule_depth > 0: cur_v += rule_height - p.render(output, - cur_h + off_h, cur_v + off_v, - rule_width, rule_height) + render(p, output, + cur_h + off_h, cur_v + off_v, + rule_width, rule_height) elif isinstance(p, Glue): glue_spec = p.glue_spec rule_height = glue_spec.width - cur_g @@ -2159,6 +2169,10 @@ def csnames(group: str, names: Iterable[str]) -> Regex: p.customspace = cmd(r"\hspace", "{" + p.float_literal("space") + "}") + p.phantom = cmd(r"\phantom", p.optional_group("value")) + p.llap = cmd(r"\llap", p.optional_group("value")) + p.rlap = cmd(r"\rlap", p.optional_group("value")) + p.accent = ( csnames("accent", [*self._accent_map, *self._wide_accents]) - p.named_placeable("sym")) @@ -2225,7 +2239,8 @@ def csnames(group: str, names: Iterable[str]) -> Regex: r"\boldsymbol", "{" + ZeroOrMore(p.simple)("value") + "}") p.placeable <<= ( - p.accent # Must be before symbol as all accents are symbols + p.phantom | p.llap | p.rlap + | p.accent # Must be before symbol as all accents are symbols | p.symbol # Must be second to catch all named symbols and single # chars not in a group | p.function @@ -2419,6 +2434,16 @@ def symbol(self, s: str, loc: int, def unknown_symbol(self, s: str, loc: int, toks: ParseResults) -> T.Any: raise ParseFatalException(s, loc, f"Unknown symbol: {toks['name']}") + def phantom(self, toks: ParseResults) -> T.Any: + toks["value"].is_phantom = True + return toks["value"] + + def llap(self, toks: ParseResults) -> T.Any: + return [Hlist([Kern(-toks["value"].width), toks["value"]])] + + def rlap(self, toks: ParseResults) -> T.Any: + return [Hlist([toks["value"], Kern(-toks["value"].width)])] + _accent_map = { r'hat': r'\circumflexaccent', r'breve': r'\combiningbreve', diff --git a/lib/matplotlib/tests/test_mathtext.py b/lib/matplotlib/tests/test_mathtext.py index 264d0b44c320..c85cbc5e21a8 100644 --- a/lib/matplotlib/tests/test_mathtext.py +++ b/lib/matplotlib/tests/test_mathtext.py @@ -578,6 +578,21 @@ def test_mathnormal(fig_test, fig_ref): fig_ref.text(0.1, 0.2, r"$\mathrm{0123456789}$") +# Test vector output because in raster output some minor differences remain, +# likely due to double-striking. +@check_figures_equal(extensions=["pdf"]) +def test_phantoms(fig_test, fig_ref): + fig_test.text(0.5, 0.9, r"$\rlap{rlap}extra$", ha="left") + fig_ref.text(0.5, 0.9, r"$rlap$", ha="left") + fig_ref.text(0.5, 0.9, r"$extra$", ha="left") + + fig_test.text(0.5, 0.8, r"$extra\llap{llap}$", ha="right") + fig_ref.text(0.5, 0.8, r"$llap$", ha="right") + fig_ref.text(0.5, 0.8, r"$extra$", ha="right") + + fig_test.text(0.5, 0.7, r"$\phantom{phantom}$") + + def test_box_repr(): s = repr(_mathtext.Parser().parse( r"$\frac{1}{2}$",