Repository: matplotlib/matplotlib
Pull request: Add substack cmd for mathtext
## PR summary
Fixes #14247 

## PR checklist
<!-- Please mark any checkboxes that do not apply to this PR as [N/A].-->

- [x] "closes #0000" is in the body of the PR description to [link the related issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue)
- [ ] new and changed code is [tested](https://matplotlib.org/devdocs/devel/testing.html)
- [ ] *Plotting related* features are demonstrated in an [example](https://matplotlib.org/devdocs/devel/documenting_mpl.html#writing-examples-and-tutorials)
- [ ] *New Features* and *API Changes* are noted with a [directive and release note](https://matplotlib.org/devdocs/devel/coding_guide.html#new-features-and-api-changes)
- [ ] Documentation complies with [general](https://matplotlib.org/devdocs/devel/documenting_mpl.html#writing-rest-pages) and [docstring](https://matplotlib.org/devdocs/devel/documenting_mpl.html#writing-docstrings) guidelines

<!--
Thank you so much for your PR!  To help us review your contribution, please
consider the following points:

- A development guide is available at https://matplotlib.org/devdocs/devel/index.html.

- Help with git and github is available at https://matplotlib.org/devdocs/devel/development_workflow.html

- Create a separate branch for your changes and open the PR from this branch. Please avoid working on `main`.

- The PR title should summarize the changes, for example "Raise ValueError on
  non-numeric input to set_xlim".  Avoid non-descriptive titles such as
  "Addresses issue #8576".

- The summary should provide at least 1-2 sentences describing the pull request
  in detail (Why is this change required?  What problem does it solve?) and
  link to any relevant issues.

- If you are contributing fixes to docstrings, please pay attention to
  https://matplotlib.org/stable/devel/documenting_mpl.html#formatting-conventions.  In particular,
  note the difference between using single backquotes, double backquotes, and
  asterisks in the markup.

We understand that PRs can sometimes be overwhelming, especially as the
reviews start coming in.  Please let us know if the reviews are unclear or
the recommended next step seems overly demanding, if you would like help in
addressing a reviewer's comments, or if you have been waiting too long to hear
back on your PR.
-->


Problem statement:
latex \substack doesn't work
<!--To help us understand and resolve your issue, please fill out the form to the best of your ability.-->
<!--You can feel free to delete the sections that do not apply.-->

### Bug report

**Bug summary**

<!--A short 1-2 sentences that succinctly describes the bug-->

**Code for reproduction**

<!--A minimum code snippet required to reproduce the bug.
Please make sure to minimize the number of dependencies required, and provide
any necessary plotted data.
Avoid using threads, as Matplotlib is (explicitly) not thread-safe.-->

```python
# Paste your code here
#
#
```

**Actual outcome**

<!--The output produced by the above code, which may be a screenshot, console output, etc.-->

```
# If applicable, paste the console output here
#
#
```

**Expected outcome**

<!--A description of the expected outcome from the code snippet-->
<!--If this used to work in an earlier version of Matplotlib, please note the version it used to work on-->

**Matplotlib version**
<!--Please specify your platform and versions of the relevant libraries you are using:-->
  * Operating system: win10
  * Matplotlib version: 3.0.1
  * Matplotlib backend (`print(matplotlib.get_backend())`):
  * Python version: python37
  * Jupyter version (if applicable):
  * Other libraries: 

<!--Please tell us how you installed matplotlib and python e.g., from source, pip, conda-->
<!--If you installed from conda, please specify which channel you used if not the default-->

Hints from issue discussion:
Could you post a minimal example of the code needed to produce the error, and the error message you get?
Let's just assume this is a request for the latex \substack command (https://tex.stackexchange.com/questions/153490/atop-vs-substack-for-multiple-lines-under-a-sum).
This issue has been marked "inactive" because it has been 365 days since the last comment. If this issue is still present in recent Matplotlib releases, or the feature request is still wanted, please leave a comment and this label will be removed. If there are no updates in another 30 days, this issue will be automatically closed, but you are free to re-open or create a new issue if needed. We value issue reports, and this procedure is meant to help us resurface and prioritize issues that have not been addressed yet, not make them disappear.  Thanks for your help!
attn @devRD, perhaps.

Commit message: Add substack command for mathtext

Co-Authored By: Kyle Sunden <git@ksunden.space>

Patch to review:
diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py
index d86385e94589..a80c9f614b4a 100644
--- a/lib/matplotlib/_mathtext.py
+++ b/lib/matplotlib/_mathtext.py
@@ -18,7 +18,7 @@
     Empty, Forward, Literal, NotAny, oneOf, OneOrMore, Optional,
     ParseBaseException, ParseException, ParseExpression, ParseFatalException,
     ParserElement, ParseResults, QuotedString, Regex, StringEnd, ZeroOrMore,
-    pyparsing_common)
+    pyparsing_common, Group)
 
 import matplotlib as mpl
 from . import cbook
@@ -27,6 +27,11 @@
 from .font_manager import FontProperties, findfont, get_font
 from .ft2font import FT2Image, KERNING_DEFAULT
 
+from pyparsing import __version__ as pyparsing_version
+if pyparsing_version < '3.0.0':
+    from pyparsing import nestedExpr as nested_expr
+else:
+    from pyparsing import nested_expr
 
 ParserElement.enablePackrat()
 _log = logging.getLogger("matplotlib.mathtext")
@@ -1865,7 +1870,7 @@ def csnames(group, names):
             + r"|\\(?:{})(?![A-Za-z])".format(
                 "|".join(map(re.escape, tex2uni)))
         )("sym").leaveWhitespace()
-        p.unknown_symbol = Regex(r"\\[A-Za-z]*")("name")
+        p.unknown_symbol = Regex(r"\\[A-Za-z]+")("name")
 
         p.font           = csnames("font", self._fontnames)
         p.start_group    = Optional(r"\math" + oneOf(self._fontnames)("font")) + "{"
@@ -1925,6 +1930,11 @@ def csnames(group, names):
 
         p.text = cmd(r"\text", QuotedString('{', '\\', endQuoteChar="}"))
 
+        p.substack = cmd(r"\substack",
+                           nested_expr(opener="{", closer="}",
+                                       content=Group(OneOrMore(p.token)) +
+                                       ZeroOrMore(Literal("\\\\").suppress()))("parts"))
+
         p.subsuper = (
             (Optional(p.placeable)("nucleus")
              + OneOrMore(oneOf(["_", "^"]) - p.placeable)("subsuper")
@@ -1963,6 +1973,7 @@ def csnames(group, names):
             | p.overline
             | p.text
             | p.boldsymbol
+            | p.substack
         )
 
         mdelim = r"\middle" - (p.delim("mdelim") | Error("Expected a delimiter"))
@@ -2648,3 +2659,25 @@ def boldsymbol(self, s, loc, toks):
         self.pop_state()
 
         return Hlist(hlist)
+
+    def substack(self, s, loc, toks):
+        parts = toks["parts"]
+        state = self.get_state()
+        thickness = state.get_current_underline_thickness()
+        vlist = []
+
+        hlist = [Hlist(k) for k in parts[0]]
+        max_width = max(map(lambda c: c.width, hlist))
+
+        for sub in hlist:
+            cp = HCentered([sub])
+            cp.hpack(max_width, 'exactly')
+            vlist.append(cp)
+
+        vlist = [val for pair in zip(vlist,
+                                     [Vbox(0, thickness * 2)] *
+                                     len(vlist)) for val in pair]
+        del vlist[-1]
+        vlt = Vlist(vlist)
+        result = [Hlist([vlt])]
+        return result
diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext1_dejavusans_08.png b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext1_dejavusans_08.png
new file mode 100644
index 000000000000..565464062e39
Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext1_dejavusans_08.png differ
diff --git a/lib/matplotlib/tests/test_mathtext.py b/lib/matplotlib/tests/test_mathtext.py
index dab981f29a23..f281b1e47412 100644
--- a/lib/matplotlib/tests/test_mathtext.py
+++ b/lib/matplotlib/tests/test_mathtext.py
@@ -137,6 +137,7 @@
     r'$\boldsymbol{abcde} \boldsymbol{+} \boldsymbol{\Gamma + \Omega} \boldsymbol{01234} \boldsymbol{\alpha * \beta}$',
     r'$\left\lbrace\frac{\left\lbrack A^b_c\right\rbrace}{\left\leftbrace D^e_f \right\rbrack}\right\rightbrace\ \left\leftparen\max_{x} \left\lgroup \frac{A}{B}\right\rgroup \right\rightparen$',
     r'$\left( a\middle. b \right)$ $\left( \frac{a}{b} \middle\vert x_i \in P^S \right)$ $\left[ 1 - \middle| a\middle| + \left( x  - \left\lfloor \dfrac{a}{b}\right\rfloor \right)  \right]$',
+    r'$\sum_{\substack{k = 1\\ k \neq \lfloor n/2\rfloor}}^{n}P(i,j) \sum_{\substack{i \neq 0\\ -1 \leq i \leq 3\\ 1 \leq j \leq 5}} F^i(x,y) \sum_{\substack{\left \lfloor \frac{n}{2} \right\rfloor}} F(n)$',
 ]
 
 digits = "0123456789"


Code review comments:
diff_hunk: @@ -27,6 +27,11 @@
 from .font_manager import FontProperties, findfont, get_font
 from .ft2font import FT2Image, KERNING_DEFAULT
 
+from pyparsing import __version__ as pyparsing_version
+if pyparsing_version < '3.0.0':
path: lib/matplotlib/_mathtext.py
text: @user1:
This falls into the category of lurking problems that _probably_ won't be a real problem but _could_ be...

string comparison of version numbers is likely to fail. 

Unfortunately the version we are gaurding against doesn't have a tuple version (with integers) to compare to. (Current pyparsing _does_)

we are saved a bit by this having minor/patch version of 0, but would still fail if pyparsing got to version `10.0.0` as we would want that to be greater than 3.0.0, but string comparison would fail. Now _probably_ by the time that happens (_if_ it happens) we will no longer support versions less than 3... but if pyparsing decided to switch to date versions of `2023.7.0` or something, we would be in the same boat a whole lot sooner (and possibly without warning).

```suggestion
if tuple(int(x) for x in pyparsing_version.split(".")) < (3, 0, 0):
```

may work in a bit more robust manner

Or we can just use `nestedExpr` for now and handle switching to the new name later (as at least oneOf is already using the deprecated version, so that change can be separated from this PR.