Repository: matplotlib/matplotlib
Pull request: Add parse_math in Text and default it False for TextBox
## PR Summary
Following up the comment: https://github.com/matplotlib/matplotlib/issues/20266#issuecomment-854794023, this PR adds a `override_math` to Text objects, and make TextBox's text object default it to True.

This resolves https://github.com/matplotlib/matplotlib/issues/20266.
It does not allow GUI to go in invalid state when there's invalid mathtext in it, because it isn't parsed at all.
(also allows folks to override mathtext _at all_ in their text objects)

Interactive trial:
```python
import matplotlib.pyplot as plt
from matplotlib.widgets import TextBox

fig, ax = plt.subplots()
fig.subplots_adjust(left=0.25, top=0.55, bottom=0.45)
text_box = TextBox(ax, "Something in '\$ \$'")                       # try typing wrong mathtext here

plt.show()
```

Static trial:
```python
import matplotlib.pyplot as plt
fig, ax = plt.subplots()
ax.text(1, 2, "$$")
plt.show()
```

Needs a changenote (API change) and tests, but let's wait for a review before that.
## PR Checklist

<!-- Please mark any checkboxes that do not apply to this PR as [N/A]. -->

- [ ] Has pytest style unit tests (and `pytest` passes).
- [x] Is [Flake 8](https://flake8.pycqa.org/en/latest/) compliant (run `flake8` on changed files to check).
- [ ] New features are documented, with examples if plot related.
- [ ] Documentation is sphinx and numpydoc compliant (the docs should [build](https://matplotlib.org/devel/documenting_mpl.html#building-the-docs) without error).
- [ ] Conforms to Matplotlib style conventions (install `flake8-docstrings` and run `flake8 --docstring-convention=all`).
- [ ] New features have an entry in `doc/users/next_whats_new/` (follow instructions in README.rst there).
- [ ] API changes documented in `doc/api/next_api_changes/` (follow instructions in README.rst there).

<!--
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/devel/gitwash/development_workflow.html.

- Do not create the PR out of master, but out of a separate branch.

- 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
  http://matplotlib.org/devel/documenting_mpl.html#formatting.  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:
"$$" cannot be displayed by ax.text()
<!--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**
I guess the minimal code-example tells it all...
<!--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
import matplotlib.pyplot as plt
f, ax = plt.subplots()
ax.text(1, 2, "$$")
```

**Actual outcome**

<!--The output produced by the above code, which may be a screenshot, console output, etc.-->

```
Out[1]: Text(1, 2, '$$')Traceback (most recent call last):
  File " ...\lib\site-packages\matplotlib\_mathtext.py", line 2277, in parse
    result = self._expression.parseString(s)
  File " ...\lib\site-packages\pyparsing.py", line 1955, in parseString
    raise exc
  File " ...\lib\site-packages\pyparsing.py", line 3814, in parseImpl
    raise ParseException(instring, loc, self.errmsg, self)
pyparsing.ParseException: Expected end of text, found '$'  (at char 0), (line:1, col:1)

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File " ...\lib\site-packages\matplotlib\backends\backend_qt5.py", line 475, in _draw_idle
    self.draw()
  File " ...\lib\site-packages\matplotlib\backends\backend_agg.py", line 406, in draw
    self.figure.draw(self.renderer)
  File " ...\lib\site-packages\matplotlib\artist.py", line 74, in draw_wrapper
    result = draw(artist, renderer, *args, **kwargs)
  File " ...\lib\site-packages\matplotlib\artist.py", line 51, in draw_wrapper
    return draw(artist, renderer, *args, **kwargs)
  File " ...\lib\site-packages\matplotlib\figure.py", line 2780, in draw
    mimage._draw_list_compositing_images(
  File " ...\lib\site-packages\matplotlib\image.py", line 132, in _draw_list_compositing_images
    a.draw(renderer)
  File " ...\lib\site-packages\matplotlib\artist.py", line 51, in draw_wrapper
    return draw(artist, renderer, *args, **kwargs)
  File " ...\lib\site-packages\matplotlib\_api\deprecation.py", line 431, in wrapper
    return func(*inner_args, **inner_kwargs)
  File " ...\lib\site-packages\matplotlib\axes\_base.py", line 2921, in draw
    mimage._draw_list_compositing_images(renderer, self, artists)
  File " ...\lib\site-packages\matplotlib\image.py", line 132, in _draw_list_compositing_images
    a.draw(renderer)
  File " ...\lib\site-packages\matplotlib\artist.py", line 51, in draw_wrapper
    return draw(artist, renderer, *args, **kwargs)
  File " ...\lib\site-packages\matplotlib\text.py", line 679, in draw
    bbox, info, descent = textobj._get_layout(renderer)
  File " ...\lib\site-packages\matplotlib\text.py", line 314, in _get_layout
    w, h, d = renderer.get_text_width_height_descent(
  File " ...\lib\site-packages\matplotlib\backends\backend_agg.py", line 235, in get_text_width_height_descent
    self.mathtext_parser.parse(s, self.dpi, prop)
  File " ...\lib\site-packages\matplotlib\mathtext.py", line 452, in parse
    return self._parse_cached(s, dpi, prop, _force_standard_ps_fonts)
  File " ...\lib\site-packages\matplotlib\mathtext.py", line 473, in _parse_cached
    box = self._parser.parse(s, font_output, fontsize, dpi)
  File " ...\lib\site-packages\matplotlib\_mathtext.py", line 2279, in parse
    raise ValueError("\n".join(["",
ValueError: 
$$
^
Expected end of text, found '$'  (at char 0), (line:1, col:1)
```

**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: Windows 10
  * Matplotlib version: 3.4.2
  * Matplotlib backend: QT5Agg
  * Python version: 3.8.8
  * 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-->
` conda install -c conda-forge matplotlib`

Hints from issue discussion:
`'$$'` sends the string to the latex parser.  Which cannot find anything between the dollar signs and fails.  I guess you could argue that it should just no-op.  OTOH if you actually want dollar signs, you need to escape them: `'\$\$'` 
hey, thanks for the quick response!

my problem actually originated from a gui that is used to dynamically update a text-instance...
(escaping the dollar-signs is not a very elegant option since you would need to communicate this to the user...)
- everything works just fine until a user accidentally types `$$`, then the plot crashes immediately
  - note that this also affects [TextBox widgets](https://matplotlib.org/stable/gallery/widgets/textbox.html)

A simple check for empty strings in the parser would solve this while keeping the 
(awesome!) latex-parsing functionality intact ...




I think thats a fair request.  I suspect its not super hard to fix.  @aitikgupta are you familiar with this corner of the code?
awesome, thanks!

I've just noticed that this could be fixed more properly by ignoring latex-errors in general! 

- would also fix inputs like `$\unknown_command$` which raise a `ParseFatalException`


Not sure I would go that far - I think we want parsing errors to fail.  I'm not even sure `"$$"` shouldn't fail...
Ah, I was intending to open this issue for a while. I brought this up on Gitter a while ago while talking about interactivity.

> Talking about interactivity, currently there's no way to get back to the correct state in GUI when an error occurs right?
Eg: When you use a TextBox with `$ some_mathtext $` and if that's invalid (can happen a lot) we see a nice error log in terminal, but the GUI is in invalid state now, and there's no way to get it working again unless I start over.

@anntzer:
> @aitikgupta  I'm not sure what cases you refer too, but I guess these should be considered as bugs

It's either of these 2 things:
1. As @jklymak says escape the empty mathtext (`"$$"`) in-general.
2. A better way would be to let the errors persist even when `"$$"` is parsed, however, for interactive things like a `TextBox widget`, or any further downstream GUI, when we parse mathtext, just use a `try & catch`, or raise a warning instead of error, anything which _does not_ make the GUI go haywire - in an invalid state.
    <ins>End user really can't do anything when GUI goes in invalid state</ins>, unless they start over.

2nd will solve OP's problem, as well as keep mathtext in intended behaviour - (we want parsing errors to fail, but just not for GUI).
Right, so whether `$\badcommand$ fails or not, the GUI should deal with it gracefully.  I don't know how the GUI works exactly, but it should probably have a catch if its possible at that level.  
> A better way would be to let the errors persist even when `"$$"` is parsed, however, for interactive things like a `TextBox widget`, or any further downstream GUI, when we parse mathtext, just use a `try & catch`, or raise a warning instead of error, anything which _does not_ make the GUI go haywire - in an invalid state.

I don't think `TextBox` should parse _any_ mathtext. That's a consequence of using `Text` to display things, but I don't think it's an intentional behaviour.
Dev call agrees with the above!
Hmm, even if we magically somehow force only `TextBox` to not parse any mathtext, the original problem would still stand, i.e,
```python
import matplotlib.pyplot as plt
f, ax = plt.subplots()
ax.text(1, 2, "$$")
```

I propose we add a parameter to `Text` class, something like `override_math`, which will force that text object to not parse any mathtext, just like we have a check for usetex in `_preprocess_math`: https://github.com/matplotlib/matplotlib/blob/11258c27be3a83832bff8659bd1a03ea2ad35aa6/lib/matplotlib/text.py#L1229-L1232

This would solve both problems, as well as what @anntzer mentioned in dev call: people wanting option to remove mathttext parsing altogether in their text objects.

Commit message: Add WhatsNew API entry for parse_math

Patch to review:
diff --git a/doc/api/next_api_changes/behavior/20367-AG.rst b/doc/api/next_api_changes/behavior/20367-AG.rst
new file mode 100644
index 000000000000..decf8b8374b7
--- /dev/null
+++ b/doc/api/next_api_changes/behavior/20367-AG.rst
@@ -0,0 +1,12 @@
+``Text``
+~~~~~~~~
+
+``Text`` objects now allow a **parse_math** keyword-only argument
+which defaults to True. When it is passed as False, that text object
+will consider the string as a literal and will not try to parse it
+as a mathtext object.
+
+``TextBox``
+-----------
+``TextBox`` now defaults its text object's **parse_math** argument
+as False. 
diff --git a/lib/matplotlib/tests/test_text.py b/lib/matplotlib/tests/test_text.py
index dccb74ba0038..71d465bef60f 100644
--- a/lib/matplotlib/tests/test_text.py
+++ b/lib/matplotlib/tests/test_text.py
@@ -731,3 +731,13 @@ def test_unsupported_script(recwarn):
         [warn.message.args for warn in recwarn] ==
         [(r"Glyph 2534 (\N{BENGALI DIGIT ZERO}) missing from current font.",),
          (r"Matplotlib currently does not support Bengali natively.",)])
+
+
+def test_parse_math():
+    fig, ax = plt.subplots()
+    ax.text(0, 0, r"$ \wrong{math} $", parse_math=False)
+    fig.canvas.draw()
+
+    with pytest.raises(ValueError, match='Unknown symbol'):
+        ax.text(0, 0, r"$ \wrong{math} $", parse_math=True)
+        fig.canvas.draw()
diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py
index 9673f6ee6fc2..c8f84c831746 100644
--- a/lib/matplotlib/text.py
+++ b/lib/matplotlib/text.py
@@ -125,6 +125,8 @@ def __init__(self,
                  usetex=None,          # defaults to rcParams['text.usetex']
                  wrap=False,
                  transform_rotates_text=False,
+                 *,
+                 parse_math=True,
                  **kwargs
                  ):
         """
@@ -142,6 +144,7 @@ def __init__(self,
             color if color is not None else mpl.rcParams["text.color"])
         self.set_fontproperties(fontproperties)
         self.set_usetex(usetex)
+        self.set_parse_math(parse_math)
         self.set_wrap(wrap)
         self.set_verticalalignment(verticalalignment)
         self.set_horizontalalignment(horizontalalignment)
@@ -1237,7 +1240,7 @@ def _preprocess_math(self, s):
             if s == " ":
                 s = r"\ "
             return s, "TeX"
-        elif cbook.is_math_text(s):
+        elif self.get_parse_math() and cbook.is_math_text(s):
             return s, True
         else:
             return s.replace(r"\$", "$"), False
@@ -1274,6 +1277,25 @@ def get_usetex(self):
         """Return whether this `Text` object uses TeX for rendering."""
         return self._usetex
 
+    def set_parse_math(self, parse_math):
+        """
+        Override switch to enable/disable any mathtext
+        parsing for the given `Text` object.
+
+        Parameters
+        ----------
+        parse_math : bool
+            Whether to consider mathtext parsing for the string
+        """
+        self._parse_math = bool(parse_math)
+
+    def get_parse_math(self):
+        """
+        Return whether mathtext parsing is considered
+        for this `Text` object.
+        """
+        return self._parse_math
+
     def set_fontname(self, fontname):
         """
         Alias for `set_family`.
diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py
index 7d2243f6c553..58a88263a90e 100644
--- a/lib/matplotlib/widgets.py
+++ b/lib/matplotlib/widgets.py
@@ -1018,9 +1018,12 @@ def __init__(self, ax, label, initial='',
         self.label = ax.text(
             -label_pad, 0.5, label, transform=ax.transAxes,
             verticalalignment='center', horizontalalignment='right')
+
+        # TextBox's text object should not parse mathtext at all.
         self.text_disp = self.ax.text(
-            self.DIST_FROM_LEFT, 0.5, initial, transform=self.ax.transAxes,
-            verticalalignment='center', horizontalalignment='left')
+            self.DIST_FROM_LEFT, 0.5, initial,
+            transform=self.ax.transAxes, verticalalignment='center',
+            horizontalalignment='left', parse_math=False)
 
         self._observers = cbook.CallbackRegistry()
 


Code review comments:
diff_hunk: @@ -0,0 +1,12 @@
+``Text``
+~~~~~~~~
+
+``Text`` objects now allow a **parse_math** keyword-only argument
path: doc/api/next_api_changes/behavior/20367-AG.rst
text: @user1:
`Text` should link, `parse_path` should be single `*`.