Repository: pulldown-cmark/pulldown-cmark
Pull request #734 (2023-10-19 00:38:58)

Yet another Math implementation
This feature is loosely based on what https://github.com/raphlinus/pulldown-cmark/commit/63a29a1d4a7981769d3f2b9655b63a099426d121 #733 described, but copies [commonmark-hs] more closely (the balanced braces feature is added).

[commonmark-hs]: https://github.com/nschloe/github-math-bugs

It largely ignores GitHub, because its math parsing [is very buggy].

[is very buggy]: https://github.com/nschloe/github-math-bugs

ollpu: > I've rebased this PR onto the current mainline, and started fuzz testing it against commonmark-hs (and found a couple of problems).

Great! I'll clean up the spec PR and add brace handling to it hopefully next week, so we can move forward with this.
Martin1887: Hi!, and sorry for the long time without a response about this.

I still have to review the code, but I have reviewed the possible specs and #733 and this are the way to go in my opinion.

The only thing I would change is the target of the pull request, so for major changes it is better to use the branch https://github.com/pulldown-cmark/pulldown-cmark/tree/branch_0.11 I created last week.

I will review the code of both pull requests and this will be merged soon, finally! The spec is mandatory in the crate docs though, I guess that we can convert #733 complete description into RustDoc comments and tests spec.

Thanks to both for this great work!
Martin1887: The only thing in the spec I would change is using `math math-inline` and `math math-display` classes instead `math inline` and `math display`, to avoid possible collisions with user-defined or CSS framework classes.

Patch:
diff --git a/pulldown-cmark/examples/parser-map-event-print.rs b/pulldown-cmark/examples/parser-map-event-print.rs
index 45be827..84b1651 100644
--- a/pulldown-cmark/examples/parser-map-event-print.rs
+++ b/pulldown-cmark/examples/parser-map-event-print.rs
@@ -19,6 +19,8 @@ fn main() {
             Event::InlineHtml(s) => println!("InlineHtml: {:?}", s),
             Event::Text(s) => println!("Text: {:?}", s),
             Event::Code(s) => println!("Code: {:?}", s),
+            Event::DisplayMath(s) => println!("DisplayMath: {:?}", s),
+            Event::InlineMath(s) => println!("Math: {:?}", s),
             Event::FootnoteReference(s) => println!("FootnoteReference: {:?}", s),
             Event::TaskListMarker(b) => println!("TaskListMarker: {:?}", b),
             Event::SoftBreak => println!("SoftBreak"),
diff --git a/pulldown-cmark/specs/math.txt b/pulldown-cmark/specs/math.txt
new file mode 100644
index 0000000..080e13c
--- /dev/null
+++ b/pulldown-cmark/specs/math.txt
@@ -0,0 +1,856 @@
+# `$`-delimited LaTeX Math in pulldown-cmark
+
+Mathematical expressions extension. Syntax based on
+<https://github.com/jgm/commonmark-hs/blob/master/commonmark-extensions/test/math.md>.
+
+Inline mode mathematical expressions:
+
+```````````````````````````````` example
+This sentence uses `$` delimiters to show math inline: $\sqrt{3x-1}+(1+x)^2$
+$\sum_{k=1}^n a_k b_k$: Mathematical expression at head of line
+
+`\` may follow just after the first `$`: $\{1, 2, 3\}$
+.
+<p>This sentence uses <code>$</code> delimiters to show math inline: <span class="math math-inline">\sqrt{3x-1}+(1+x)^2</span>
+<span class="math math-inline">\sum_{k=1}^n a_k b_k</span>: Mathematical expression at head of line</p>
+<p><code>\</code> may follow just after the first <code>$</code>: <span class="math math-inline">\{1, 2, 3\}</span></p>
+````````````````````````````````
+
+Display mode mathematical expressions:
+
+```````````````````````````````` example
+**The Cauchy-Schwarz Inequality**
+
+$$\left( \sum_{k=1}^n a_k b_k \right)^2 \leq \left( \sum_{k=1}^n a_k^2 \right) \left( \sum_{k=1}^n b_k^2 \right)$$
+.
+<p><strong>The Cauchy-Schwarz Inequality</strong></p>
+<p><span class="math math-display">\left( \sum_{k=1}^n a_k b_k \right)^2 \leq \left( \sum_{k=1}^n a_k^2 \right) \left( \sum_{k=1}^n b_k^2 \right)</span></p>
+````````````````````````````````
+
+Inline math expressions cannot be empty, but display mode expressions can.
+
+```````````````````````````````` example
+Oops empty $$ expression.
+
+$$$$
+.
+<p>Oops empty $$ expression.</p>
+<p><span class="math math-display"></span></p>
+````````````````````````````````
+
+This is a greedy, left-to-right parser.
+
+```````````````````````````````` example
+$x$$$$$$$y$$
+
+$x$$$$$$y$$
+
+$$x$$$$$$y$$
+.
+<p><span class="math math-inline">x</span><span class="math math-display"></span><span class="math math-display">y</span></p>
+<p><span class="math math-inline">x</span><span class="math math-display"></span><span class="math math-inline">y</span>$</p>
+<p><span class="math math-display">x</span><span class="math math-display"></span>y$$</p>
+````````````````````````````````
+
+Math expressions pass their content through as-is, ignoring any other inline
+Markdown constructs:
+
+```````````````````````````````` example
+$a<b>c</b>$
+
+$${a*b*c} _c_ d$$
+
+$not `code`$
+
+$![not an](/image)$
+
+$<https://not.a.link/>$
+
+$&alpha;$
+.
+<p><span class="math math-inline">a&lt;b&gt;c&lt;/b&gt;</span></p>
+<p><span class="math math-display">{a*b*c} _c_ d</span></p>
+<p><span class="math math-inline">not `code`</span></p>
+<p><span class="math math-inline">![not an](/image)</span></p>
+<p><span class="math math-inline">&lt;https://not.a.link/&gt;</span></p>
+<p><span class="math math-inline">&amp;alpha;</span></p>
+````````````````````````````````
+
+Sole `$` characters without a matching pair in the same block element
+are handled as normal text.
+
+```````````````````````````````` example
+Hello $world.
+
+Dollar at end of line$
+.
+<p>Hello $world.</p>
+<p>Dollar at end of line$</p>
+````````````````````````````````
+
+Mathematical expressions can continue across multiple lines:
+
+```````````````````````````````` example
+$5x + 2 =
+17$
+
+$$\left( \sum_{k=1}^n a_k b_k \right)^2 \leq \left( \sum_{k=1}^n a_k^2 \right)
+\left( \sum_{k=1}^n b_k^2 \right)$$
+.
+<p><span class="math math-inline">5x + 2 =
+17</span></p>
+<p><span class="math math-display">\left( \sum_{k=1}^n a_k b_k \right)^2 \leq \left( \sum_{k=1}^n a_k^2 \right)
+\left( \sum_{k=1}^n b_k^2 \right)</span></p>
+````````````````````````````````
+
+Markdown hard breaks are also not recognized inside math expressions:
+
+```````````````````````````````` example
+$not a\
+hard break  
+either$
+.
+<p><span class="math math-inline">not a\
+hard break  
+either</span></p>
+````````````````````````````````
+
+
+`$` character can be escaped with backslash in mathematical expressions:
+
+```````````````````````````````` example
+$\$$
+
+$$y = \$ x$$
+.
+<p><span class="math math-inline">\$</span></p>
+<p><span class="math math-display">y = \$ x</span></p>
+````````````````````````````````
+
+Inline mode math expressions cannot contain unescaped `$` characters.
+Neither can display math.
+
+```````````````````````````````` example
+$x $ x$
+
+$$ $ $$
+.
+<p>$x $ x$</p>
+<p>$$ $ $$</p>
+````````````````````````````````
+
+```````````````````````````````` example
+alpha$$beta$gamma$$delta
+.
+<p>alpha$<span class="math math-inline">beta</span>gamma$$delta</p>
+````````````````````````````````
+
+Inline math expressions cannot start or end with whitespace, including newlines:
+
+```````````````````````````````` example
+these are not math texts: $ y=x$, $y=x $, $
+y=x$ and $y=x
+$
+
+>The start of a line counts as whitespace $2 +
+>$
+
+While displays can start with whitespace, {${
+they should not allow inlines to do that $$2 +
+$*$
+.
+<p>these are not math texts: $ y=x$, $y=x $, $
+y=x$ and $y=x
+$</p>
+<blockquote>
+<p>The start of a line counts as whitespace $2 +
+$</p>
+</blockquote>
+<p>While displays can start with whitespace, {${
+they should not allow inlines to do that $$2 +
+<span class="math math-inline">*</span></p>
+````````````````````````````````
+
+Inline math expressions do not need to be surrounded with whitespace:
+
+```````````````````````````````` example
+these are math texts: foo$y=x$bar and $y=x$bar and foo$y=x$ bar
+.
+<p>these are math texts: foo<span class="math math-inline">y=x</span>bar and <span class="math math-inline">y=x</span>bar and foo<span class="math math-inline">y=x</span> bar</p>
+````````````````````````````````
+
+Inline math expressions can be surrounded by punctuation:
+
+```````````````````````````````` example
+math texts: $x=y$! and $x=y$? and $x=y$: and $x=y$. and $x=y$"
+
+also math texts: !$x=y$! and ?$x=y$? and :$x=y$: and .$x=y$. and "$x=y$"
+
+braces: ($x=y$) [$x=y$] {$x=y$}
+.
+<p>math texts: <span class="math math-inline">x=y</span>! and <span class="math math-inline">x=y</span>? and <span class="math math-inline">x=y</span>: and <span class="math math-inline">x=y</span>. and <span class="math math-inline">x=y</span>"</p>
+<p>also math texts: !<span class="math math-inline">x=y</span>! and ?<span class="math math-inline">x=y</span>? and :<span class="math math-inline">x=y</span>: and .<span class="math math-inline">x=y</span>. and "<span class="math math-inline">x=y</span>"</p>
+<p>braces: (<span class="math math-inline">x=y</span>) [<span class="math math-inline">x=y</span>] {<span class="math math-inline">x=y</span>}</p>
+````````````````````````````````
+
+Math expression as only item on a line:
+
+```````````````````````````````` example
+$x=y$
+.
+<p><span class="math math-inline">x=y</span></p>
+````````````````````````````````
+
+Math expressions can be immediately followed by other math expressions:
+
+```````````````````````````````` example
+$a$$b$
+
+$a$$$b$$
+
+$$a$$$b$
+
+$$a$$$$b$$
+.
+<p><span class="math math-inline">a</span><span class="math math-inline">b</span></p>
+<p><span class="math math-inline">a</span><span class="math math-display">b</span></p>
+<p><span class="math math-display">a</span><span class="math math-inline">b</span></p>
+<p><span class="math math-display">a</span><span class="math math-display">b</span></p>
+````````````````````````````````
+
+Both inline and display mode math expressions are inline elements with the same
+precedence as code spans. The leftmost valid element takes priority:
+
+```````````````````````````````` example
+$Inline `first$ then` code
+
+`Code $first` then$ inline
+
+$$ Display `first $$ then` code
+
+`Code $$ first` then $$ display
+.
+<p><span class="math math-inline">Inline `first</span> then` code</p>
+<p><code>Code $first</code> then$ inline</p>
+<p><span class="math math-display"> Display `first </span> then` code</p>
+<p><code>Code $$ first</code> then $$ display</p>
+````````````````````````````````
+
+Indicators of block structure take precedence over math expressions:
+
+```````````````````````````````` example
+$x + y - z$
+
+$x + y
+- z$
+
+$$ x + y
+> z $$
+.
+<p><span class="math math-inline">x + y - z</span></p>
+<p>$x + y</p>
+<ul>
+<li>z$</li>
+</ul>
+<p>$$ x + y</p>
+<blockquote>
+<p>z $$</p>
+</blockquote>
+````````````````````````````````
+
+This also means that math expressions cannot contain empty lines, since they
+start a new paragraph:
+
+```````````````````````````````` example
+$not
+
+math$
+
+$$
+not
+
+math
+$$
+.
+<p>$not</p>
+<p>math$</p>
+<p>$$
+not</p>
+<p>math
+$$</p>
+````````````````````````````````
+
+It also implies that math notation has lower
+parsing power than block elements.
+
+```````````````````````````````` example
+- $not
+    - *
+  math$
+.
+<ul>
+<li>$not
+<ul>
+<li>
+<ul>
+<li>
+</li>
+</ul>
+</li>
+</ul>
+math$</li>
+</ul>
+````````````````````````````````
+
+Note that math can contain embedded math.  In scanning
+for a closing delimiter, we skip material in balanced
+curly braces:
+
+```````````````````````````````` example
+This is display math:
+$$
+\text{Hello $x^2$}
+$$
+And this is inline math:
+$\text{Hello $x$ there!}$
+.
+<p>This is display math:
+<span class="math math-display">
+\text{Hello $x^2$}
+</span>
+And this is inline math:
+<span class="math math-inline">\text{Hello $x$ there!}</span></p>
+````````````````````````````````
+
+Math expressions must be nested within balanced curly braces.
+Backslash-escaped braces do not count.
+
+```````````````````````````````` example
+This is not valid math: $}{$
+
+Neither is this: { $}{$ }
+
+This is: $\}\{$
+
+This is: $\}$
+
+Math environment contains 2+2: $}$2+2$
+.
+<p>This is not valid math: $}{$</p>
+<p>Neither is this: { $}{$ }</p>
+<p>This is: <span class="math math-inline">\}\{</span></p>
+<p>This is: <span class="math math-inline">\}</span></p>
+<p>Math environment contains 2+2: $}<span class="math math-inline">2+2</span></p>
+````````````````````````````````
+
+Math expressions must contain properly nested braces.
+
+```````````````````````````````` example
+This is not display math. It is inline math:
+
+$$\text{first $$ second}$
+
+$$$\text{first $$ second}$
+
+This is display math:
+
+$$\text{first $$ second}$$
+
+$$$\text{first $$ second}$$
+
+This is also display math, but (counterintuitively) it's allowed to be empty
+and expected to be as short as possible:
+
+$$$$\text{first $$ second}$$
+.
+<p>This is not display math. It is inline math:</p>
+<p>$<span class="math math-inline">\text{first $$ second}</span></p>
+<p>$$<span class="math math-inline">\text{first $$ second}</span></p>
+<p>This is display math:</p>
+<p><span class="math math-display">\text{first $$ second}</span></p>
+<p>$<span class="math math-display">\text{first $$ second}</span></p>
+<p>This is also display math, but (counterintuitively) it's allowed to be empty
+and expected to be as short as possible:</p>
+<p><span class="math math-display"></span>\text{first $$ second}$$</p>
+````````````````````````````````
+
+Dollar signs must also be backslash-escaped if they occur within math:
+
+```````````````````````````````` example
+$\text{\$}$
+
+$$x$x$$
+
+${$^$$
+
+$}$$$$
+
+$}$] $$
+.
+<p><span class="math math-inline">\text{\$}</span></p>
+<p>$<span class="math math-inline">x</span>x$$</p>
+<p>${<span class="math math-inline">^</span>$</p>
+<p>$}<span class="math math-display"></span></p>
+<p>$}$] $$</p>
+````````````````````````````````
+
+## Edge case tests comparison with GitHub
+
+Test cases
+https://raw.githubusercontent.com/nschloe/github-math-bugs/db938ff690ab7c534d8195fe4a1a5163c20b1134/README.md
+
+Inline math wrapped in quotes
+
+```````````````````````````````` example
+$x$ $`y`$
+.
+<p><span class="math math-inline">x</span> <span class="math math-inline">`y`</span></p>
+````````````````````````````````
+
+Inline and display math in the same list
+
+```````````````````````````````` example
+- $a$
+
+  ```math
+  a
+  ```
+
+  $$
+  a
+  $$
+
+- ```math
+  b
+  ```
+
+  $$
+  b
+  $$
+.
+<ul>
+<li>
+<p><span class="math math-inline">a</span></p>
+<pre><code class="language-math">a
+</code></pre>
+<p><span class="math math-display">
+a
+</span></p>
+</li>
+<li>
+<pre><code class="language-math">b
+</code></pre>
+<p><span class="math math-display">
+b
+</span></p>
+</li>
+</ul>
+````````````````````````````````
+
+Images and math in the same list
+
+```````````````````````````````` example
+- ![node logo](https://nodejs.org/static/images/logo.svg)
+- $x$
+.
+<ul>
+<li><img src="https://nodejs.org/static/images/logo.svg" alt="node logo" /></li>
+<li><span class="math math-inline">x</span></li>
+</ul>
+````````````````````````````````
+
+Inline and display math in `<details>`
+
+```````````````````````````````` example
+<details>
+
+$A = 5$
+
+$$
+A = 5
+$$
+
+</details>
+.
+<details>
+<p><span class="math math-inline">A = 5</span></p>
+<p><span class="math math-display">
+A = 5
+</span></p>
+</details>
+````````````````````````````````
+
+`<` without surrounding whitespace
+
+```````````````````````````````` example
+$a<b$
+
+$$a<b$$
+.
+<p><span class="math math-inline">a&lt;b</span></p>
+<p><span class="math math-display">a&lt;b</span></p>
+````````````````````````````````
+
+Math in footnotes
+
+```````````````````````````````` example
+[^a]
+
+[^a]: Lorem $a$
+.
+<p><sup class="footnote-reference"><a href="#a">1</a></sup></p>
+<div class="footnote-definition" id="a"><sup class="footnote-definition-label">1</sup>
+<p>Lorem <span class="math math-inline">a</span></p>
+</div>
+````````````````````````````````
+
+Math in links
+
+```````````````````````````````` example
+[$a$](x)
+.
+<p>
+<a href="x"><span class="math math-inline">a</span></a>
+</p>
+````````````````````````````````
+
+Math preceded by an alphabetical character
+
+```````````````````````````````` example
+a$x$
+
+-$x$
+
+1$x$
+.
+<p>a<span class="math math-inline">x</span></p>
+<p>-<span class="math math-inline">x</span></p>
+<p>1<span class="math math-inline">x</span></p>
+````````````````````````````````
+
+Inline math at the end of italic text
+
+```````````````````````````````` example
+_$a$ equals $b$_
+
+_$a$ equals $b$_
+
+**$a$ equals $b$**
+.
+<p><em><span class="math math-inline">a</span> equals <span class="math math-inline">b</span></em></p>
+<p><em><span class="math math-inline">a</span> equals <span class="math math-inline">b</span></em></p>
+<p><strong><span class="math math-inline">a</span> equals <span class="math math-inline">b</span></strong></p>
+````````````````````````````````
+
+Dollar in `\text`
+
+```````````````````````````````` example
+$$
+a
+$$
+
+- $$
+  \text{$b$}
+  $$
+.
+<p><span class="math math-display">
+a
+</span>
+</p><ul>
+<li><span class="math math-display">
+\text{$b$}
+</span></li>
+</ul>
+````````````````````````````````
+
+Backslashes in `$`-math
+
+```````````````````````````````` example
+$\{a\,b\}$
+.
+<p><span class="math math-inline">\{a\,b\}</span></p>
+````````````````````````````````
+
+Math vs. HTML mix-up
+
+```````````````````````````````` example
+$a <b > c$
+
+$[(a+b)c](d+e)$
+
+${a}_b c_{d}$
+.
+<p><span class="math math-inline">a &lt;b &gt; c</span></p>
+<p><span class="math math-inline">[(a+b)c](d+e)</span></p>
+<p><span class="math math-inline">{a}_b c_{d}</span></p>
+````````````````````````````````
+
+Dollar-math with spaces
+
+```````````````````````````````` example
+When $a \ne 0$, there are two solutions to $(ax^2 + bx + c = 0)$ and they are
+$$ x = {-b \pm \sqrt{b^2-4ac} \over 2a} $$
+.
+<p>When <span class="math math-inline">a \ne 0</span>, there are two solutions to <span class="math math-inline">(ax^2 + bx + c = 0)</span> and they are
+<span class="math math-display"> x = {-b \pm \sqrt{b^2-4ac} \over 2a} </span></p>
+````````````````````````````````
+
+Spacing around dollar sign in math mode
+
+```````````````````````````````` example
+$x = \$$
+.
+<p><span class="math math-inline">x = \$</span></p>
+````````````````````````````````
+
+Math in italic text
+
+```````````````````````````````` example
+_Equation $\Omega(69)$ in italic text_
+.
+<p><em>Equation <span class="math math-inline">\Omega(69)</span> in italic text</em></p>
+````````````````````````````````
+
+Inline math can't be preceded by brackets, quotation marks etc.
+
+```````````````````````````````` example
+$\pi$
+'$\pi$
+"$\pi$
+($\pi$
+[$\pi$
+{$\pi$
+/$\pi$
+.
+<p>
+<span class="math math-inline">\pi</span>
+'<span class="math math-inline">\pi</span>
+"<span class="math math-inline">\pi</span>
+(<span class="math math-inline">\pi</span>
+[<span class="math math-inline">\pi</span>
+{<span class="math math-inline">\pi</span>
+/<span class="math math-inline">\pi</span>
+</p>
+````````````````````````````````
+
+## Relationship with tables
+
+As a block element, tables parsing is stronger than math.
+
+```````````````````````````````` example
+| first $|$ second |
+|--------|---------|
+| a ${   | }$ b    |
+.
+<table><thead>
+<tr><th>first $</th><th>$ second</th></tr>
+</thead><tbody>
+<tr><td>a ${</td><td>}$ b</td></tr>
+</tbody></table>
+````````````````````````````````
+
+As a special case, pipes in math environments in tables are escaped
+with backslashes. Though backslash-escaped characters in math
+environments are normally passed through verbatim to the LaTeX
+engine, escaped pipes in tables are an exception like they
+are in code spans.
+
+The behavior of the table parser should be as-if it found the bounds
+of the table cell in a separate pass that only looked for the
+strings `|` and `\|`, treating pipes as boundaries and removing the
+escaping backslash before passing the string to the inline parser.
+
+```````````````````````````````` example
+| first $\|$ second |
+|-------------------|
+| a ${   \| }$ b    |
+.
+<table><thead>
+<tr><th>first <span class="math math-inline">|</span> second</th></tr>
+</thead><tbody>
+<tr><td>a <span class="math math-inline">{   | }</span> b</td></tr>
+</tbody></table>
+````````````````````````````````
+
+```````````````````````````````` example
+| Description | Test case |
+|-------------|-----------|
+| Single      | $\$       |
+| Double      | $\\$      |
+| Basic test  | $\|$      |
+| Basic test 2| $\|\|\$   |
+| Basic test 3| $x\|y\|z\$|
+| Not pipe    | $\.$      |
+| Combo       | $\.\|$    |
+| Combo 2     | $\.\|\$   |
+| Extra       | $\\\.$    |
+| Wait, what? | $\\|$     |
+| Wait, what? | $\\\|$    |
+| Wait, what? | $\\\\|$   |
+| Wait, what? | $\\\\\|$  |
+.
+<table><thead><tr><th>Description</th><th>Test case</th></tr></thead><tbody>
+<tr><td>Single</td><td>$$</td></tr>
+<tr><td>Double</td><td><span class="math math-inline">\\</span></td></tr>
+<tr><td>Basic test</td><td><span class="math math-inline">|</span></td></tr>
+<tr><td>Basic test 2</td><td>$||$</td></tr>
+<tr><td>Basic test 3</td><td>$x|y|z$</td></tr>
+<tr><td>Not pipe</td><td><span class="math math-inline">\.</span></td></tr>
+<tr><td>Combo</td><td><span class="math math-inline">\.|</span></td></tr>
+<tr><td>Combo 2</td><td>$.|$</td></tr>
+<tr><td>Extra</td><td><span class="math math-inline">\\\.</span></td></tr>
+<tr><td>Wait, what?</td><td><span class="math math-inline">\|</span></td></tr>
+<tr><td>Wait, what?</td><td><span class="math math-inline">\\|</span></td></tr>
+<tr><td>Wait, what?</td><td><span class="math math-inline">\\\|</span></td></tr>
+<tr><td>Wait, what?</td><td><span class="math math-inline">\\\\|</span></td></tr>
+</tbody></table>
+````````````````````````````````
+
+## Implementation limits
+
+Implementations may impose limits on brace nesting to avoid performance issues,
+but at least three levels of nesting should be supported.
+
+Pulldown-cmark imposes the following limits:
+
+1. At 25 levels of nesting, it switches from tracking nested pairs to simply
+   counting the number of braces. This means the below example will spurriously
+   recognize a math environment with the correct number of braces, but not
+   nested correctly.
+
+```````````````````````````````` example
+This is not an inline math environment: $}{$
+But, because it's nested too deeply, this is parsed as an inline math environment:
+{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{
+improperly $}{$ nested
+}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}
+But this still isn't, because the braces are still counted: $}{$
+.
+<p>This is not an inline math environment: $}{$
+But, because it's nested too deeply, this is parsed as an inline math environment:
+{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{
+improperly <span class="math math-inline">}{</span> nested
+}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}
+But this still isn't, because the braces are still counted: $}{$</p>
+````````````````````````````````
+
+```````````````````````````````` example
+This is also deeply nested, but, unlike the first example,
+they don't have an equal number of close braces and open braces,
+so aren't detected as math.
+{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{
+improperly $}$ nested ${$ example
+}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}
+This, however, is detected ${}$
+
+${{{{{{{{{{{{{{{{{{{{{{{{{{{{{{
+another improperly nested example
+}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}$
+.
+<p>This is also deeply nested, but, unlike the first example,
+they don't have an equal number of close braces and open braces,
+so aren't detected as math.
+{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{
+improperly $}$ nested ${$ example
+}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}
+This, however, is detected <span class="math math-inline">{}</span></p>
+<p><span class="math math-inline">{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{
+another improperly nested example
+}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}</span></p>
+````````````````````````````````
+
+2. At 255 distinct brace-delimited groups, the counter rolls over. This means
+   the below example will spurriously recognize an incorrectly-nested
+   inline math environment.
+
+```````````````````````````````` example
+${}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}  20 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}  40 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}  60 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}  80 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{} 100 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{} 120 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{} 140 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{} 160 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{} 180 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{} 200 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{} 220 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{} 240 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{$ 255 brace pairs and one unclosed brace
+.
+<p><span class="math math-inline">{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}  20 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}  40 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}  60 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}  80 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{} 100 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{} 120 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{} 140 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{} 160 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{} 180 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{} 200 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{} 220 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{} 240 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{</span> 255 brace pairs and one unclosed brace</p>
+````````````````````````````````
+
+3. Thanks to rule 1, though, deeply-nested structures won't chew through all of
+   the ID space. This means that the below example, even though it nests 255
+   levels deep, parses correctly anyway.
+
+```````````````````````````````` example
+${{{{{{{{{{{{{{{{{{{{ 20 open braces
+{{{{{{{{{{{{{{{{{{{{  40 open braces
+{{{{{{{{{{{{{{{{{{{{  60 open braces
+{{{{{{{{{{{{{{{{{{{{  80 open braces
+{{{{{{{{{{{{{{{{{{{{ 100 open braces
+{{{{{{{{{{{{{{{{{{{{ 110 open braces
+{{{{{{{{{{{{{{{{{{{{ 120 open braces
+{{{{{{{{{{{{{{{{{{{{ 140 open braces
+{{{{{{{{{{{{{{{{{{{{ 160 open braces
+{{{{{{{{{{{{{{{{{{{{ 180 open braces
+{{{{{{{{{{{{{{{{{{{{ 200 open braces
+{{{{{{{{{{{{{{{{{{{{ 220 open braces
+{{{{{{{{{{{{{{{{{{{{ 240 open braces
+{{{{{{{{{{{{{{{ 255 open braces
+}}}}}}}}}}}}}}}}}}}}  20 close braces
+}}}}}}}}}}}}}}}}}}}}  40 close braces
+}}}}}}}}}}}}}}}}}}}}  60 close braces
+}}}}}}}}}}}}}}}}}}}}  80 close braces
+}}}}}}}}}}}}}}}}}}}} 100 close braces
+}}}}}}}}}}}}}}}}}}}} 120 close braces
+}}}}}}}}}}}}}}}}}}}} 140 close braces
+}}}}}}}}}}}}}}}}}}}} 160 close braces
+}}}}}}}}}}}}}}}}}}}} 180 close braces
+}}}}}}}}}}}}}}}}}}}} 200 close braces
+}}}}}}}}}}}}}}}}}}}} 220 close braces
+}}}}}}}}}}}}}}}}}}}} 240 close braces
+}}}}}}}}}}}}}}}{$ 255 close braces and one open brace
+.
+<p>${{{{{{{{{{{{{{{{{{{{ 20 open braces
+{{{{{{{{{{{{{{{{{{{{  40 open braces
+{{{{{{{{{{{{{{{{{{{{  60 open braces
+{{{{{{{{{{{{{{{{{{{{  80 open braces
+{{{{{{{{{{{{{{{{{{{{ 100 open braces
+{{{{{{{{{{{{{{{{{{{{ 110 open braces
+{{{{{{{{{{{{{{{{{{{{ 120 open braces
+{{{{{{{{{{{{{{{{{{{{ 140 open braces
+{{{{{{{{{{{{{{{{{{{{ 160 open braces
+{{{{{{{{{{{{{{{{{{{{ 180 open braces
+{{{{{{{{{{{{{{{{{{{{ 200 open braces
+{{{{{{{{{{{{{{{{{{{{ 220 open braces
+{{{{{{{{{{{{{{{{{{{{ 240 open braces
+{{{{{{{{{{{{{{{ 255 open braces
+}}}}}}}}}}}}}}}}}}}}  20 close braces
+}}}}}}}}}}}}}}}}}}}}  40 close braces
+}}}}}}}}}}}}}}}}}}}}  60 close braces
+}}}}}}}}}}}}}}}}}}}}  80 close braces
+}}}}}}}}}}}}}}}}}}}} 100 close braces
+}}}}}}}}}}}}}}}}}}}} 120 close braces
+}}}}}}}}}}}}}}}}}}}} 140 close braces
+}}}}}}}}}}}}}}}}}}}} 160 close braces
+}}}}}}}}}}}}}}}}}}}} 180 close braces
+}}}}}}}}}}}}}}}}}}}} 200 close braces
+}}}}}}}}}}}}}}}}}}}} 220 close braces
+}}}}}}}}}}}}}}}}}}}} 240 close braces
+}}}}}}}}}}}}}}}{$ 255 close braces and one open brace</p>
+````````````````````````````````
diff --git a/pulldown-cmark/src/firstpass.rs b/pulldown-cmark/src/firstpass.rs
index e2eb6c3..ea4ca79 100644
--- a/pulldown-cmark/src/firstpass.rs
+++ b/pulldown-cmark/src/firstpass.rs
@@ -34,10 +34,23 @@ pub(crate) fn run_first_pass(text: &str, options: Options) -> (Tree<Item>, Alloc
         options,
         lookup_table,
         next_paragraph_task: None,
+        brace_context_next: 0,
+        brace_context_stack: Vec::new(),
     };
     first_pass.run()
 }
 
+// Each level of brace nesting adds another entry to a hash table.
+// To limit the amount of memory consumed, do not create a new brace
+// context beyond some amount deep.
+//
+// There are actually two limits at play here: this one,
+// and the one where the maximum amount of distinct contexts passes
+// the 255 item limit imposed by using `u8`. When over 255 distinct
+// contexts are created, it wraps around, while this one instead makes it
+// saturate, which is a better behavior.
+const MATH_BRACE_CONTEXT_MAX_NESTING: usize = 25;
+
 /// State for the first parsing pass.
 struct FirstPass<'a, 'b> {
     text: &'a str,
@@ -50,6 +63,9 @@ struct FirstPass<'a, 'b> {
     /// This is `Some(item)` when the next paragraph
     /// starts with a task list marker.
     next_paragraph_task: Option<Item>,
+    /// Math environment brace nesting.
+    brace_context_stack: Vec<u8>,
+    brace_context_next: usize,
 }
 
 impl<'a, 'b> FirstPass<'a, 'b> {
@@ -69,6 +85,10 @@ impl<'a, 'b> FirstPass<'a, 'b> {
         let bytes = self.text.as_bytes();
         let mut line_start = LineStart::new(&bytes[start_ix..]);
 
+        // math spans and their braces are tracked only within a single block
+        self.brace_context_stack.clear();
+        self.brace_context_next = 0;
+
         let i = scan_containers(
             &self.tree,
             &mut line_start,
@@ -772,6 +792,87 @@ impl<'a, 'b> FirstPass<'a, 'b> {
                     }
                     LoopInstruction::ContinueAndSkip(count - 1)
                 }
+                b'$' => {
+                    let string_suffix = &self.text[ix..];
+                    let can_open = !string_suffix[1..].as_bytes().first().copied().map_or(true, is_ascii_whitespace);
+                    let can_close = ix > start && !self.text[..ix].as_bytes().last().copied().map_or(true, is_ascii_whitespace);
+
+                    // 0xFFFF_FFFF... represents the root brace context. Using None would require
+                    // storing Option<u8>, which is bigger than u8.
+                    //
+                    // These shouldn't conflict unless you have 255 levels of nesting, which is
+                    // past the intended limit anyway.
+                    //
+                    // Unbalanced braces will cause the root to be changed, which is why it gets
+                    // stored here.
+                    let brace_context = if self.brace_context_stack.len() > MATH_BRACE_CONTEXT_MAX_NESTING {
+                        self.brace_context_next as u8
+                    } else {
+                        self.brace_context_stack.last().copied().unwrap_or_else(|| {
+                            self.brace_context_stack.push(!0);
+                            !0
+                        })
+                    };
+
+                    self.tree.append_text(begin_text, ix, backslash_escaped);
+                    self.tree.append(Item {
+                        start: ix,
+                        end: ix + 1,
+                        body: ItemBody::MaybeMath(
+                            can_open,
+                            can_close,
+                            brace_context,
+                        ),
+                    });
+                    begin_text = ix + 1;
+                    LoopInstruction::ContinueAndSkip(0)
+                }
+                b'{' => {
+                    if self.brace_context_stack.len() == MATH_BRACE_CONTEXT_MAX_NESTING {
+                        self.brace_context_stack.push(self.brace_context_next as u8);
+                        self.brace_context_next = MATH_BRACE_CONTEXT_MAX_NESTING;
+                    } else if self.brace_context_stack.len() > MATH_BRACE_CONTEXT_MAX_NESTING {
+                        // When we reach the limit of nesting, switch from actually matching
+                        // braces to just counting them.
+                        self.brace_context_next += 1;
+                    } else if !self.brace_context_stack.is_empty() {
+                        // Store nothing if no math environment has been reached yet.
+                        self.brace_context_stack.push(self.brace_context_next as u8);
+                        self.brace_context_next += 1;
+                    }
+                    LoopInstruction::ContinueAndSkip(0)
+                }
+                b'}' => {
+                    if let &mut [ref mut top_level_context] = &mut self.brace_context_stack[..] {
+                        // Unbalanced Braces
+                        //
+                        // The initial, root top-level brace context is -1, but this is changed whenever an unbalanced
+                        // close brace is encountered:
+                        //
+                        //     This is not a math environment: $}$
+                        //     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^|^
+                        //     -1                               |-2
+                        //
+                        // To ensure this can't get parsed as math, each side of the unbalanced
+                        // brace is an irreversibly separate brace context. As long as the math
+                        // environment itself contains balanced braces, they should share a top level context.
+                        //
+                        //     Math environment contains 2+2: $}$2+2$
+                        //                                       ^^^ this is a math environment
+                        *top_level_context = top_level_context.wrapping_sub(1);
+                    } else if self.brace_context_stack.len() > MATH_BRACE_CONTEXT_MAX_NESTING {
+                        // When we exceed 25 levels of nesting, switch from accurately balancing braces
+                        // to just counting them. When we dip back below the limit, switch back.
+                        if self.brace_context_next <= MATH_BRACE_CONTEXT_MAX_NESTING {
+                            self.brace_context_stack.pop();
+                        } else {
+                            self.brace_context_next -= 1;
+                        }
+                    } else {
+                        self.brace_context_stack.pop();
+                    }
+                    LoopInstruction::ContinueAndSkip(0)
+                }
                 b'`' => {
                     self.tree.append_text(begin_text, ix, backslash_escaped);
                     backslash_escaped = false;
@@ -2116,6 +2217,11 @@ fn special_bytes(options: &Options) -> [bool; 256] {
     if options.contains(Options::ENABLE_STRIKETHROUGH) {
         bytes[b'~' as usize] = true;
     }
+    if options.contains(Options::ENABLE_MATH) {
+        bytes[b'$' as usize] = true;
+        bytes[b'{' as usize] = true;
+        bytes[b'}' as usize] = true;
+    }
     if options.contains(Options::ENABLE_SMART_PUNCTUATION) {
         for &byte in &[b'.', b'-', b'"', b'\''] {
             bytes[byte as usize] = true;
@@ -2351,6 +2457,11 @@ mod simd {
         if options.contains(Options::ENABLE_STRIKETHROUGH) {
             add_lookup_byte(&mut lookup, b'~');
         }
+        if options.contains(Options::ENABLE_MATH) {
+            add_lookup_byte(&mut lookup, b'$');
+            add_lookup_byte(&mut lookup, b'{');
+            add_lookup_byte(&mut lookup, b'}');
+        }
         if options.contains(Options::ENABLE_SMART_PUNCTUATION) {
             for &byte in &[b'.', b'-', b'"', b'\''] {
                 add_lookup_byte(&mut lookup, byte);
@@ -2497,6 +2608,7 @@ mod simd {
 
         fn check_expected_indices(bytes: &[u8], expected: &[usize], skip: usize) {
             let mut opts = Options::empty();
+            opts.insert(Options::ENABLE_MATH);
             opts.insert(Options::ENABLE_TABLES);
             opts.insert(Options::ENABLE_FOOTNOTES);
             opts.insert(Options::ENABLE_STRIKETHROUGH);
@@ -2541,7 +2653,7 @@ mod simd {
         #[test]
         fn exhaustive_search() {
             let chars = [
-                b'\n', b'\r', b'*', b'_', b'~', b'|', b'&', b'\\', b'[', b']', b'<', b'!', b'`',
+                b'\n', b'\r', b'*', b'_', b'~', b'|', b'&', b'\\', b'[', b']', b'<', b'!', b'`', b'$', b'{', b'}'
             ];
 
             for &c in &chars {
diff --git a/pulldown-cmark/src/html.rs b/pulldown-cmark/src/html.rs
index f4abf69..a082307 100644
--- a/pulldown-cmark/src/html.rs
+++ b/pulldown-cmark/src/html.rs
@@ -109,6 +109,16 @@ where
                     escape_html_body_text(&mut self.writer, &text)?;
                     self.write("</code>")?;
                 }
+                InlineMath(text) => {
+                    self.write(r#"<span class="math math-inline">"#)?;
+                    escape_html(&mut self.writer, &text)?;
+                    self.write("</span>")?;
+                }
+                DisplayMath(text) => {
+                    self.write(r#"<span class="math math-display">"#)?;
+                    escape_html(&mut self.writer, &text)?;
+                    self.write("</span>")?;
+                }
                 Html(html) | InlineHtml(html) => {
                     self.write(&html)?;
                 }
@@ -437,6 +447,16 @@ where
                     escape_html(&mut self.writer, &text)?;
                     self.end_newline = text.ends_with('\n');
                 }
+                InlineMath(text) => {
+                    self.write("$")?;
+                    escape_html(&mut self.writer, &text)?;
+                    self.write("$")?;
+                }
+                DisplayMath(text) => {
+                    self.write("$$")?;
+                    escape_html(&mut self.writer, &text)?;
+                    self.write("$$")?;
+                }
                 SoftBreak | HardBreak | Rule => {
                     self.write(" ")?;
                 }
diff --git a/pulldown-cmark/src/lib.rs b/pulldown-cmark/src/lib.rs
index 5bcfb18..6dbec68 100644
--- a/pulldown-cmark/src/lib.rs
+++ b/pulldown-cmark/src/lib.rs
@@ -365,6 +365,12 @@ pub enum Event<'a> {
     /// An inline code node.
     #[cfg_attr(feature = "serde", serde(borrow))]
     Code(CowStr<'a>),
+    /// An inline math environment node.
+    #[cfg_attr(feature = "serde", serde(borrow))]
+    InlineMath(CowStr<'a>),
+    /// A display math environment node.
+    #[cfg_attr(feature = "serde", serde(borrow))]
+    DisplayMath(CowStr<'a>),
     /// An HTML node.
     #[cfg_attr(feature = "serde", serde(borrow))]
     Html(CowStr<'a>),
@@ -461,6 +467,9 @@ bitflags::bitflags! {
         /// literal text [^4]. In old syntax, it creates a dangling link.
         /// ```
         const ENABLE_OLD_FOOTNOTES = (1 << 9) | (1 << 2);
+        /// With this feature enabled, `Tag::Math` events are emitted that
+        /// conventionally contain TeX formulas.
+        const ENABLE_MATH = 1 << 10;
     }
 }
 
diff --git a/pulldown-cmark/src/main.rs b/pulldown-cmark/src/main.rs
index 7a1e316..8ff59d1 100644
--- a/pulldown-cmark/src/main.rs
+++ b/pulldown-cmark/src/main.rs
@@ -72,6 +72,7 @@ pub fn main() -> std::io::Result<()> {
     opts.optflag("d", "dry-run", "dry run, produce no output");
     opts.optflag("e", "events", "print event sequence instead of rendering");
     opts.optflag("T", "enable-tables", "enable GitHub-style tables");
+    opts.optflag("m", "enable-math", "enable LaTeX-style math");
     opts.optflag("F", "enable-footnotes", "enable GitHub-style footnotes");
     opts.optflag("", "enable-old-footnotes", "enable Hoedown-style footnotes");
     opts.optflag(
@@ -108,6 +109,9 @@ pub fn main() -> std::io::Result<()> {
     if matches.opt_present("enable-tables") {
         opts.insert(Options::ENABLE_TABLES);
     }
+    if matches.opt_present("enable-math") {
+        opts.insert(Options::ENABLE_MATH);
+    }
     if matches.opt_present("enable-footnotes") {
         opts.insert(Options::ENABLE_FOOTNOTES);
     }
diff --git a/pulldown-cmark/src/parse.rs b/pulldown-cmark/src/parse.rs
index 054900e..0d838f4 100644
--- a/pulldown-cmark/src/parse.rs
+++ b/pulldown-cmark/src/parse.rs
@@ -49,11 +49,12 @@ pub(crate) struct Item {
     pub body: ItemBody,
 }
 
-#[derive(Debug, PartialEq, Clone, Copy)]
-#[derive(Default)]
+#[derive(Debug, PartialEq, Clone, Copy, Default)]
 pub(crate) enum ItemBody {
     Paragraph,
-    Text { backslash_escaped: bool },
+    Text {
+        backslash_escaped: bool,
+    },
     SoftBreak,
     // true = is backlash
     HardBreak(bool),
@@ -62,6 +63,8 @@ pub(crate) enum ItemBody {
 
     // repeats, can_open, can_close
     MaybeEmphasis(usize, bool, bool),
+    // can_open, can_close, brace context
+    MaybeMath(bool, bool, u8),
     // quote byte, can_open, can_close
     MaybeSmartQuote(u8, bool, bool),
     MaybeCode(usize, bool), // number of backticks, preceded by backslash
@@ -75,6 +78,7 @@ pub(crate) enum ItemBody {
     Emphasis,
     Strong,
     Strikethrough,
+    Math(CowIndex, bool), // true for display math
     Code(CowIndex),
     Link(LinkIndex),
     Image(LinkIndex),
@@ -113,6 +117,7 @@ impl ItemBody {
         matches!(
             *self,
             ItemBody::MaybeEmphasis(..)
+                | ItemBody::MaybeMath(..)
                 | ItemBody::MaybeSmartQuote(..)
                 | ItemBody::MaybeHtml
                 | ItemBody::MaybeCode(..)
@@ -138,8 +143,6 @@ impl ItemBody {
     }
 }
 
-
-
 #[derive(Debug)]
 pub struct BrokenLink<'a> {
     pub span: std::ops::Range<usize>,
@@ -277,9 +280,9 @@ impl<'input, F: BrokenLinkCallback<'input>> Parser<'input, F> {
         &mut self,
         link_label: CowStr<'input>,
         span: Range<usize>,
-        link_type: LinkType
+        link_type: LinkType,
     ) -> Option<(LinkType, CowStr<'input>, CowStr<'input>)> {
-        if self.link_ref_expansion_limit <= 0 {
+        if self.link_ref_expansion_limit == 0 {
             return None;
         }
 
@@ -297,30 +300,29 @@ impl<'input, F: BrokenLinkCallback<'input>> Parser<'input, F> {
                 let url = matching_def.dest.clone();
                 (link_type, url, title)
             })
-        .or_else(|| {
-            match self.broken_link_callback.as_mut() {
-                Some(callback) => {
-                    // Construct a BrokenLink struct, which will be passed to the callback
-                    let broken_link = BrokenLink {
-                        span,
-                        link_type,
-                        reference: link_label,
-                    };
+            .or_else(|| {
+                match self.broken_link_callback.as_mut() {
+                    Some(callback) => {
+                        // Construct a BrokenLink struct, which will be passed to the callback
+                        let broken_link = BrokenLink {
+                            span,
+                            link_type,
+                            reference: link_label,
+                        };
 
-                    callback.handle_broken_link(broken_link).map(
-                        |(url, title)| {
-                            (link_type.to_unknown(), url, title)
-                        },
-                    )
+                        callback
+                            .handle_broken_link(broken_link)
+                            .map(|(url, title)| (link_type.to_unknown(), url, title))
+                    }
+                    None => None,
                 }
-                None => None,
-            }
-        })?;
+            })?;
 
         // Limit expansion from link references.
         // This isn't a problem for footnotes, because multiple references to the same one
         // reuse the same node, but links/images get their HREF/SRC copied.
-        self.link_ref_expansion_limit = self.link_ref_expansion_limit
+        self.link_ref_expansion_limit = self
+            .link_ref_expansion_limit
             .saturating_sub(url.len() + title.len());
 
         Some((link_type, url, title))
@@ -344,6 +346,7 @@ impl<'input, F: BrokenLinkCallback<'input>> Parser<'input, F> {
     /// precedence, because the URL of links must not be processed.
     fn handle_inline_pass1(&mut self) {
         let mut code_delims = CodeDelims::new();
+        let mut math_delims = MathDelims::new();
         let mut cur = self.tree.cur();
         let mut prev = None;
 
@@ -365,7 +368,9 @@ impl<'input, F: BrokenLinkCallback<'input>> Parser<'input, F> {
                         let text_node = self.tree.create_node(Item {
                             start: self.tree[cur_ix].item.start + 1,
                             end: ix - 1,
-                            body: ItemBody::Text { backslash_escaped: false },
+                            body: ItemBody::Text {
+                                backslash_escaped: false,
+                            },
                         });
                         let link_ix =
                             self.allocs
@@ -409,13 +414,100 @@ impl<'input, F: BrokenLinkCallback<'input>> Parser<'input, F> {
                             continue;
                         }
                     }
-                    self.tree[cur_ix].item.body = ItemBody::Text { backslash_escaped: false };
+                    self.tree[cur_ix].item.body = ItemBody::Text {
+                        backslash_escaped: false,
+                    };
+                }
+                ItemBody::MaybeMath(can_open, _can_close, brace_context) => {
+                    if !can_open {
+                        self.tree[cur_ix].item.body = ItemBody::Text {
+                            backslash_escaped: false,
+                        };
+                        prev = cur;
+                        cur = self.tree[cur_ix].next;
+                        continue;
+                    }
+                    let is_display = self.tree[cur_ix].next.map_or(false, |next_ix| {
+                        matches!(
+                            self.tree[next_ix].item.body,
+                            ItemBody::MaybeMath(_can_open, _can_close, _brace_context)
+                        )
+                    });
+                    let result = if math_delims.is_populated() {
+                        // we have previously scanned all math environment delimiters,
+                        // so we can reuse that work
+                        math_delims.find(&self.tree, cur_ix, is_display, brace_context)
+                    } else {
+                        // we haven't previously scanned all codeblock delimiters,
+                        // so walk the AST
+                        let mut scan = self.tree[cur_ix].next;
+                        if is_display {
+                            // a display delimiter, `$$`, is actually two delimiters
+                            // skip the second one
+                            scan = self.tree[scan.unwrap()].next;
+                        }
+                        while let Some(scan_ix) = scan {
+                            if let ItemBody::MaybeMath(_can_open, can_close, delim_brace_context) =
+                                self.tree[scan_ix].item.body
+                            {
+                                let delim_is_display =
+                                    self.tree[scan_ix].next.map_or(false, |next_ix| {
+                                        matches!(
+                                            self.tree[next_ix].item.body,
+                                            ItemBody::MaybeMath(
+                                                _can_open,
+                                                _can_close,
+                                                _brace_context
+                                            )
+                                        )
+                                    });
+                                if delim_brace_context == brace_context {
+                                    if can_close || (is_display && delim_is_display) {
+                                        // This will skip ahead past everything we
+                                        // just inserted. This clear isn't needed for
+                                        // correctness, but does save memory.
+                                        math_delims.clear();
+                                    } else {
+                                        // can_close only applies to inline math
+                                        // block math can always close
+                                        scan = None;
+                                    }
+                                    break;
+                                } else {
+                                    math_delims.insert(
+                                        delim_is_display,
+                                        delim_brace_context,
+                                        scan_ix,
+                                        can_close,
+                                    );
+                                }
+                            }
+                            if self.tree[scan_ix].item.body.is_block() {
+                                // If this is a tight list, blocks and inlines might be
+                                // siblings. Inlines can't cross the boundary like that.
+                                scan = None;
+                                break;
+                            }
+                            scan = self.tree[scan_ix].next;
+                        }
+                        scan
+                    };
+
+                    if let Some(scan_ix) = result {
+                        self.make_math_span(cur_ix, scan_ix);
+                    } else {
+                        self.tree[cur_ix].item.body = ItemBody::Text {
+                            backslash_escaped: false,
+                        };
+                    }
                 }
                 ItemBody::MaybeCode(mut search_count, preceded_by_backslash) => {
                     if preceded_by_backslash {
                         search_count -= 1;
                         if search_count == 0 {
-                            self.tree[cur_ix].item.body = ItemBody::Text { backslash_escaped: false };
+                            self.tree[cur_ix].item.body = ItemBody::Text {
+                                backslash_escaped: false,
+                            };
                             prev = cur;
                             cur = self.tree[cur_ix].next;
                             continue;
@@ -428,7 +520,9 @@ impl<'input, F: BrokenLinkCallback<'input>> Parser<'input, F> {
                         if let Some(scan_ix) = code_delims.find(cur_ix, search_count) {
                             self.make_code_span(cur_ix, scan_ix, preceded_by_backslash);
                         } else {
-                            self.tree[cur_ix].item.body = ItemBody::Text { backslash_escaped: false };
+                            self.tree[cur_ix].item.body = ItemBody::Text {
+                                backslash_escaped: false,
+                            };
                         }
                     } else {
                         // we haven't previously scanned all codeblock delimiters,
@@ -459,26 +553,34 @@ impl<'input, F: BrokenLinkCallback<'input>> Parser<'input, F> {
                             scan = self.tree[scan_ix].next;
                         }
                         if scan.is_none() {
-                            self.tree[cur_ix].item.body = ItemBody::Text { backslash_escaped: false };
+                            self.tree[cur_ix].item.body = ItemBody::Text {
+                                backslash_escaped: false,
+                            };
                         }
                     }
                 }
                 ItemBody::MaybeLinkOpen => {
-                    self.tree[cur_ix].item.body = ItemBody::Text { backslash_escaped: false };
+                    self.tree[cur_ix].item.body = ItemBody::Text {
+                        backslash_escaped: false,
+                    };
                     self.link_stack.push(LinkStackEl {
                         node: cur_ix,
                         ty: LinkStackTy::Link,
                     });
                 }
                 ItemBody::MaybeImage => {
-                    self.tree[cur_ix].item.body = ItemBody::Text { backslash_escaped: false };
+                    self.tree[cur_ix].item.body = ItemBody::Text {
+                        backslash_escaped: false,
+                    };
                     self.link_stack.push(LinkStackEl {
                         node: cur_ix,
                         ty: LinkStackTy::Image,
                     });
                 }
                 ItemBody::MaybeLinkClose(could_be_ref) => {
-                    self.tree[cur_ix].item.body = ItemBody::Text { backslash_escaped: false };
+                    self.tree[cur_ix].item.body = ItemBody::Text {
+                        backslash_escaped: false,
+                    };
                     if let Some(tos) = self.link_stack.pop() {
                         if tos.ty == LinkStackTy::Disabled {
                             continue;
@@ -588,7 +690,9 @@ impl<'input, F: BrokenLinkCallback<'input>> Parser<'input, F> {
                             };
 
                             let id = match &label {
-                                Some((ReferenceLabel::Link(l), _) | (ReferenceLabel::Footnote(l), _)) => l.clone(),
+                                Some(
+                                    (ReferenceLabel::Link(l), _) | (ReferenceLabel::Footnote(l), _),
+                                ) => l.clone(),
                                 None => "".into(),
                             };
 
@@ -630,11 +734,13 @@ impl<'input, F: BrokenLinkCallback<'input>> Parser<'input, F> {
                                     continue;
                                 }
                             } else if let Some((ReferenceLabel::Link(link_label), end)) = label {
-                                if let Some((def_link_type, url, title)) = self.fetch_link_type_url_title(
-                                    link_label,
-                                    (self.tree[tos.node].item.start)..end,
-                                    link_type,
-                                ) {
+                                if let Some((def_link_type, url, title)) = self
+                                    .fetch_link_type_url_title(
+                                        link_label,
+                                        (self.tree[tos.node].item.start)..end,
+                                        link_type,
+                                    )
+                                {
                                     let link_ix =
                                         self.allocs.allocate_link(def_link_type, url, title, id);
                                     self.tree[tos.node].item.body = if tos.ty == LinkStackTy::Image
@@ -705,7 +811,8 @@ impl<'input, F: BrokenLinkCallback<'input>> Parser<'input, F> {
                     let both = can_open && can_close;
                     if can_close {
                         while let Some(el) =
-                            self.inline_stack.find_match(&mut self.tree, c, run_length, both)
+                            self.inline_stack
+                                .find_match(&mut self.tree, c, run_length, both)
                         {
                             // have a match!
                             if let Some(prev_ix) = prev {
@@ -774,7 +881,9 @@ impl<'input, F: BrokenLinkCallback<'input>> Parser<'input, F> {
                             });
                         } else {
                             for i in 0..count {
-                                self.tree[cur_ix + i].item.body = ItemBody::Text { backslash_escaped: false };
+                                self.tree[cur_ix + i].item.body = ItemBody::Text {
+                                    backslash_escaped: false,
+                                };
                             }
                         }
                         prev_ix = cur_ix + count - 1;
@@ -936,7 +1045,12 @@ impl<'input, F: BrokenLinkCallback<'input>> Parser<'input, F> {
                     continue;
                 }
             }
-            if self.tree.is_in_table() && c == b'\\' && i + 2 < bytes.len() && bytes[i + 1] == b'\\' && bytes[i + 2] == b'|' {
+            if self.tree.is_in_table()
+                && c == b'\\'
+                && i + 2 < bytes.len()
+                && bytes[i + 1] == b'\\'
+                && bytes[i + 2] == b'|'
+            {
                 // this runs if there are an even number of pipes in a table
                 // if it's odd, then it gets parsed as normal
                 title.push_str(&text[mark..i]);
@@ -955,6 +1069,135 @@ impl<'input, F: BrokenLinkCallback<'input>> Parser<'input, F> {
         None
     }
 
+    fn make_math_span(&mut self, mut open: TreeIndex, mut close: TreeIndex) {
+        let start_is_display = self.tree[open].next.filter(|&next_ix| {
+            next_ix != close
+                && matches!(
+                    self.tree[next_ix].item.body,
+                    ItemBody::MaybeMath(_can_open, _can_close, _brace_context)
+                )
+        });
+        let end_is_display = self.tree[close].next.filter(|&next_ix| {
+            matches!(
+                self.tree[next_ix].item.body,
+                ItemBody::MaybeMath(_can_open, _can_close, _brace_context)
+            )
+        });
+        let is_display = start_is_display.is_some() && end_is_display.is_some();
+        if is_display {
+            // These unwrap()s can't panic, because if the next variables were None, the _is_display values would be false
+            let (mut open_next, close_next) = (
+                self.tree[open].next.unwrap(),
+                self.tree[close].next.unwrap(),
+            );
+            while matches!(
+                self.tree[open_next]
+                    .next
+                    .map(|next_next| &self.tree[next_next].item.body),
+                Some(ItemBody::MaybeMath(_can_open, _can_close, _brace_context))
+            ) {
+                // march delimiters along to ensure that the dollar signs are outside the span
+                //
+                //     $$$x$$
+                //      ----- math span
+                //
+                // This means we look at open->next->next and move the delimiters
+                if let Some(next_ix) = self.tree[open_next].next {
+                    if next_ix == close {
+                        break;
+                    }
+                    self.tree[open].item.body = ItemBody::Text {
+                        backslash_escaped: false,
+                    };
+                    open = open_next;
+                    open_next = next_ix;
+                } else {
+                    break;
+                }
+            }
+            close = close_next;
+            self.tree[open].next = Some(close);
+            self.tree[open].item.end += 1;
+            self.tree[close].item.start -= 1;
+        } else {
+            if self.tree[open].item.end == self.tree[close].item.start {
+                // inline math spans cannot be empty
+                self.tree[open].item.body = ItemBody::Text {
+                    backslash_escaped: false,
+                };
+                return;
+            }
+            if let Some(start_trail_ix) = start_is_display {
+                self.tree[open].item.body = ItemBody::Text {
+                    backslash_escaped: false,
+                };
+                let start_can_open = matches!(
+                    self.tree[start_trail_ix].item.body,
+                    ItemBody::MaybeMath(true, _can_close, _brace_context)
+                );
+                // Generate spans like this:
+                //
+                //     $$test$
+                //     x^----^
+                //
+                // The spare should go on the outside. This is complicated because the
+                // scanner wants to treat a potentially-DisplayMode math delimiter as
+                // one thing, but needs to scan so the one marked `x` is what gets passed
+                // to this function.
+                if self.tree[start_trail_ix].item.end == self.tree[close].item.start
+                    || !start_can_open
+                {
+                    // inline math spans cannot be empty
+                    return;
+                }
+                open = start_trail_ix;
+            }
+            self.tree[open].next = Some(close);
+        }
+        let span_start = self.tree[open].item.end;
+        let span_end = self.tree[close].item.start;
+
+        let bytes = self.text.as_bytes();
+        let mut buf: Option<String> = None;
+
+        let mut start_ix = span_start;
+        let mut ix = span_start;
+        while ix < span_end {
+            let c = bytes[ix];
+            if c == b'\r' || c == b'\n' {
+                ix += 1;
+                let buf = buf.get_or_insert_with(|| String::with_capacity(ix - span_start));
+                buf.push_str(&self.text[start_ix..ix]);
+                let mut line_start = LineStart::new(&bytes[ix..]);
+                let _ = scan_containers(
+                    &self.tree,
+                    &mut line_start,
+                    self.options.has_gfm_footnotes(),
+                );
+                ix += line_start.bytes_scanned();
+                start_ix = ix;
+            } else if c == b'\\' && bytes.get(ix + 1) == Some(&b'|') && self.tree.is_in_table() {
+                let buf = buf.get_or_insert_with(|| String::with_capacity(ix + 1 - span_start));
+                buf.push_str(&self.text[start_ix..ix]);
+                buf.push('|');
+                ix += 2;
+                start_ix = ix;
+            } else {
+                ix += 1;
+            }
+        }
+
+        let cow = if let Some(mut buf) = buf {
+            buf.push_str(&self.text[start_ix..span_end]);
+            buf.into()
+        } else {
+            self.text[span_start..span_end].into()
+        };
+
+        self.tree[open].item.body = ItemBody::Math(self.allocs.allocate_cow(cow), is_display);
+        self.tree[open].item.end = self.tree[close].item.end;
+        self.tree[open].next = self.tree[close].next;
+    }
 
     /// Make a code span.
     ///
@@ -992,7 +1235,7 @@ impl<'input, F: BrokenLinkCallback<'input>> Parser<'input, F> {
                 ix += 1;
             }
         }
-        
+
         let (opening, closing, all_spaces) = {
             let s = if let Some(buf) = &mut buf {
                 buf.push_str(&self.text[start_ix..span_end]);
@@ -1003,7 +1246,7 @@ impl<'input, F: BrokenLinkCallback<'input>> Parser<'input, F> {
             (
                 s.as_bytes().first() == Some(&b' '),
                 s.as_bytes().last() == Some(&b' '),
-                s.bytes().all(|b| b == b' ')
+                s.bytes().all(|b| b == b' '),
             )
         };
 
@@ -1024,7 +1267,9 @@ impl<'input, F: BrokenLinkCallback<'input>> Parser<'input, F> {
         };
 
         if preceding_backslash {
-            self.tree[open].item.body = ItemBody::Text { backslash_escaped: true };
+            self.tree[open].item.body = ItemBody::Text {
+                backslash_escaped: true,
+            };
             self.tree[open].item.end = self.tree[open].item.start + 1;
             self.tree[open].next = Some(close);
             self.tree[close].item.body = ItemBody::Code(self.allocs.allocate_cow(cow));
@@ -1117,7 +1362,8 @@ impl Tree<Item> {
     pub(crate) fn append_text(&mut self, start: usize, end: usize, backslash_escaped: bool) {
         if end > start {
             if let Some(ix) = self.cur() {
-                if matches!(self[ix].item.body, ItemBody::Text { .. }) && self[ix].item.end == start {
+                if matches!(self[ix].item.body, ItemBody::Text { .. }) && self[ix].item.end == start
+                {
                     self[ix].item.end = end;
                     return;
                 }
@@ -1190,7 +1436,9 @@ impl InlineStack {
     fn pop_all(&mut self, tree: &mut Tree<Item>) {
         for el in self.stack.drain(..) {
             for i in 0..el.count {
-                tree[el.start + i].item.body = ItemBody::Text { backslash_escaped: false};
+                tree[el.start + i].item.body = ItemBody::Text {
+                    backslash_escaped: false,
+                };
             }
         }
         self.lower_bounds = [0; 9];
@@ -1261,14 +1509,19 @@ impl InlineStack {
             .cloned()
             .enumerate()
             .rfind(|(_, el)| {
-                el.c == c && (!both && !el.both || (run_length + el.run_length) % 3 != 0 || run_length % 3 == 0)
+                el.c == c
+                    && (!both && !el.both
+                        || (run_length + el.run_length) % 3 != 0
+                        || run_length % 3 == 0)
             });
 
         if let Some((matching_ix, matching_el)) = res {
             let matching_ix = matching_ix + lowerbound;
             for el in &self.stack[(matching_ix + 1)..] {
                 for i in 0..el.count {
-                    tree[el.start + i].item.body = ItemBody::Text { backslash_escaped: false };
+                    tree[el.start + i].item.body = ItemBody::Text {
+                        backslash_escaped: false,
+                    };
                 }
             }
             self.truncate(matching_ix);
@@ -1337,15 +1590,18 @@ fn scan_link_label<'text>(
     };
     if allow_footnote_refs && b'^' == bytes[1] && bytes.get(2) != Some(&b']') {
         let linebreak_handler: &dyn Fn(&[u8]) -> Option<usize> = if gfm_footnotes {
-            &|_|  None
+            &|_| None
         } else {
             &linebreak_handler
         };
-        if let Some((byte_index, cow)) = scan_link_label_rest(&text[2..], linebreak_handler, tree.is_in_table()) {
+        if let Some((byte_index, cow)) =
+            scan_link_label_rest(&text[2..], linebreak_handler, tree.is_in_table())
+        {
             return Some((byte_index + 2, ReferenceLabel::Footnote(cow)));
         }
     }
-    let (byte_index, cow) = scan_link_label_rest(&text[1..], &linebreak_handler, tree.is_in_table())?;
+    let (byte_index, cow) =
+        scan_link_label_rest(&text[1..], &linebreak_handler, tree.is_in_table())?;
     Some((byte_index + 1, ReferenceLabel::Link(cow)))
 }
 
@@ -1454,10 +1710,7 @@ impl CodeDelims {
 
     fn insert(&mut self, count: usize, ix: TreeIndex) {
         if self.seen_first {
-            self.inner
-                .entry(count)
-                .or_default()
-                .push_back(ix);
+            self.inner.entry(count).or_default().push_back(ix);
         } else {
             // Skip the first insert, since that delimiter will always
             // be an opener and not a closer.
@@ -1484,6 +1737,78 @@ impl CodeDelims {
     }
 }
 
+/// Tracks brace contexts and delimiter length for math delimiters.
+/// Provides amortized constant-time lookups.
+struct MathDelims {
+    inner: HashMap<u8, VecDeque<(TreeIndex, bool, bool)>>,
+    seen_first: bool,
+}
+
+impl MathDelims {
+    fn new() -> Self {
+        Self {
+            inner: Default::default(),
+            seen_first: false,
+        }
+    }
+
+    fn insert(
+        &mut self,
+        delim_is_display: bool,
+        brace_context: u8,
+        ix: TreeIndex,
+        can_close: bool,
+    ) {
+        if self.seen_first {
+            self.inner.entry(brace_context).or_default().push_back((
+                ix,
+                can_close,
+                delim_is_display,
+            ));
+        } else {
+            // Skip the first insert, since that delimiter will always
+            // be an opener and not a closer.
+            self.seen_first = true;
+        }
+    }
+
+    fn is_populated(&self) -> bool {
+        !self.inner.is_empty()
+    }
+
+    fn find(
+        &mut self,
+        tree: &Tree<Item>,
+        open_ix: TreeIndex,
+        is_display: bool,
+        brace_context: u8,
+    ) -> Option<TreeIndex> {
+        while let Some((ix, can_close, delim_is_display)) =
+            self.inner.get_mut(&brace_context)?.pop_front()
+        {
+            if ix <= open_ix || (is_display && tree[open_ix].next == Some(ix)) {
+                continue;
+            }
+            let can_close = can_close && tree[open_ix].item.end != tree[ix].item.start;
+            if can_close || (is_display && delim_is_display) {
+                return Some(ix);
+            }
+            // if we can't use it, leave it in the queue as a tombstone for the next
+            // thing that tries to match it
+            self.inner
+                .get_mut(&brace_context)?
+                .push_front((ix, can_close, delim_is_display));
+            break;
+        }
+        None
+    }
+
+    fn clear(&mut self) {
+        self.inner.clear();
+        self.seen_first = false;
+    }
+}
+
 #[derive(Copy, Clone, PartialEq, Eq, Debug)]
 pub(crate) struct LinkIndex(usize);
 
@@ -1851,6 +2176,13 @@ fn item_to_event<'a>(item: Item, text: &'a str, allocs: &mut Allocations<'a>) ->
         ItemBody::Table(alignment_ix) => Tag::Table(allocs.take_alignment(alignment_ix)),
         ItemBody::FootnoteDefinition(cow_ix) => Tag::FootnoteDefinition(allocs.take_cow(cow_ix)),
         ItemBody::MetadataBlock(kind) => Tag::MetadataBlock(kind),
+        ItemBody::Math(cow_ix, is_display) => {
+            return if is_display {
+                Event::DisplayMath(allocs.take_cow(cow_ix))
+            } else {
+                Event::InlineMath(allocs.take_cow(cow_ix))
+            }
+        }
         _ => panic!("unexpected item body {:?}", item.body),
     };
 


Test patch:
diff --git a/pulldown-cmark/tests/lib.rs b/pulldown-cmark/tests/lib.rs
index efba374..f01c31b 100644
--- a/pulldown-cmark/tests/lib.rs
+++ b/pulldown-cmark/tests/lib.rs
@@ -15,6 +15,7 @@ pub fn test_markdown_html(
     let mut s = String::new();
 
     let mut opts = Options::empty();
+    opts.insert(Options::ENABLE_MATH);
     opts.insert(Options::ENABLE_TABLES);
     opts.insert(Options::ENABLE_STRIKETHROUGH);
     opts.insert(Options::ENABLE_TASKLISTS);
diff --git a/pulldown-cmark/tests/suite/math.rs b/pulldown-cmark/tests/suite/math.rs
new file mode 100644
index 0000000..912c18c
--- /dev/null
+++ b/pulldown-cmark/tests/suite/math.rs
@@ -0,0 +1,916 @@
+// This file is auto-generated by the build script
+// Please, do not modify it manually
+
+use super::test_markdown_html;
+
+#[test]
+fn math_test_1() {
+    let original = r##"This sentence uses `$` delimiters to show math inline: $\sqrt{3x-1}+(1+x)^2$
+$\sum_{k=1}^n a_k b_k$: Mathematical expression at head of line
+
+`\` may follow just after the first `$`: $\{1, 2, 3\}$
+"##;
+    let expected = r##"<p>This sentence uses <code>$</code> delimiters to show math inline: <span class="math math-inline">\sqrt{3x-1}+(1+x)^2</span>
+<span class="math math-inline">\sum_{k=1}^n a_k b_k</span>: Mathematical expression at head of line</p>
+<p><code>\</code> may follow just after the first <code>$</code>: <span class="math math-inline">\{1, 2, 3\}</span></p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_2() {
+    let original = r##"**The Cauchy-Schwarz Inequality**
+
+$$\left( \sum_{k=1}^n a_k b_k \right)^2 \leq \left( \sum_{k=1}^n a_k^2 \right) \left( \sum_{k=1}^n b_k^2 \right)$$
+"##;
+    let expected = r##"<p><strong>The Cauchy-Schwarz Inequality</strong></p>
+<p><span class="math math-display">\left( \sum_{k=1}^n a_k b_k \right)^2 \leq \left( \sum_{k=1}^n a_k^2 \right) \left( \sum_{k=1}^n b_k^2 \right)</span></p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_3() {
+    let original = r##"Oops empty $$ expression.
+
+$$$$
+"##;
+    let expected = r##"<p>Oops empty $$ expression.</p>
+<p><span class="math math-display"></span></p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_4() {
+    let original = r##"$x$$$$$$$y$$
+
+$x$$$$$$y$$
+
+$$x$$$$$$y$$
+"##;
+    let expected = r##"<p><span class="math math-inline">x</span><span class="math math-display"></span><span class="math math-display">y</span></p>
+<p><span class="math math-inline">x</span><span class="math math-display"></span><span class="math math-inline">y</span>$</p>
+<p><span class="math math-display">x</span><span class="math math-display"></span>y$$</p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_5() {
+    let original = r##"$a<b>c</b>$
+
+$${a*b*c} _c_ d$$
+
+$not `code`$
+
+$![not an](/image)$
+
+$<https://not.a.link/>$
+
+$&alpha;$
+"##;
+    let expected = r##"<p><span class="math math-inline">a&lt;b&gt;c&lt;/b&gt;</span></p>
+<p><span class="math math-display">{a*b*c} _c_ d</span></p>
+<p><span class="math math-inline">not `code`</span></p>
+<p><span class="math math-inline">![not an](/image)</span></p>
+<p><span class="math math-inline">&lt;https://not.a.link/&gt;</span></p>
+<p><span class="math math-inline">&amp;alpha;</span></p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_6() {
+    let original = r##"Hello $world.
+
+Dollar at end of line$
+"##;
+    let expected = r##"<p>Hello $world.</p>
+<p>Dollar at end of line$</p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_7() {
+    let original = r##"$5x + 2 =
+17$
+
+$$\left( \sum_{k=1}^n a_k b_k \right)^2 \leq \left( \sum_{k=1}^n a_k^2 \right)
+\left( \sum_{k=1}^n b_k^2 \right)$$
+"##;
+    let expected = r##"<p><span class="math math-inline">5x + 2 =
+17</span></p>
+<p><span class="math math-display">\left( \sum_{k=1}^n a_k b_k \right)^2 \leq \left( \sum_{k=1}^n a_k^2 \right)
+\left( \sum_{k=1}^n b_k^2 \right)</span></p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_8() {
+    let original = r##"$not a\
+hard break  
+either$
+"##;
+    let expected = r##"<p><span class="math math-inline">not a\
+hard break  
+either</span></p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_9() {
+    let original = r##"$\$$
+
+$$y = \$ x$$
+"##;
+    let expected = r##"<p><span class="math math-inline">\$</span></p>
+<p><span class="math math-display">y = \$ x</span></p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_10() {
+    let original = r##"$x $ x$
+
+$$ $ $$
+"##;
+    let expected = r##"<p>$x $ x$</p>
+<p>$$ $ $$</p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_11() {
+    let original = r##"alpha$$beta$gamma$$delta
+"##;
+    let expected = r##"<p>alpha$<span class="math math-inline">beta</span>gamma$$delta</p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_12() {
+    let original = r##"these are not math texts: $ y=x$, $y=x $, $
+y=x$ and $y=x
+$
+
+>The start of a line counts as whitespace $2 +
+>$
+
+While displays can start with whitespace, {${
+they should not allow inlines to do that $$2 +
+$*$
+"##;
+    let expected = r##"<p>these are not math texts: $ y=x$, $y=x $, $
+y=x$ and $y=x
+$</p>
+<blockquote>
+<p>The start of a line counts as whitespace $2 +
+$</p>
+</blockquote>
+<p>While displays can start with whitespace, {${
+they should not allow inlines to do that $$2 +
+<span class="math math-inline">*</span></p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_13() {
+    let original = r##"these are math texts: foo$y=x$bar and $y=x$bar and foo$y=x$ bar
+"##;
+    let expected = r##"<p>these are math texts: foo<span class="math math-inline">y=x</span>bar and <span class="math math-inline">y=x</span>bar and foo<span class="math math-inline">y=x</span> bar</p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_14() {
+    let original = r##"math texts: $x=y$! and $x=y$? and $x=y$: and $x=y$. and $x=y$"
+
+also math texts: !$x=y$! and ?$x=y$? and :$x=y$: and .$x=y$. and "$x=y$"
+
+braces: ($x=y$) [$x=y$] {$x=y$}
+"##;
+    let expected = r##"<p>math texts: <span class="math math-inline">x=y</span>! and <span class="math math-inline">x=y</span>? and <span class="math math-inline">x=y</span>: and <span class="math math-inline">x=y</span>. and <span class="math math-inline">x=y</span>"</p>
+<p>also math texts: !<span class="math math-inline">x=y</span>! and ?<span class="math math-inline">x=y</span>? and :<span class="math math-inline">x=y</span>: and .<span class="math math-inline">x=y</span>. and "<span class="math math-inline">x=y</span>"</p>
+<p>braces: (<span class="math math-inline">x=y</span>) [<span class="math math-inline">x=y</span>] {<span class="math math-inline">x=y</span>}</p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_15() {
+    let original = r##"$x=y$
+"##;
+    let expected = r##"<p><span class="math math-inline">x=y</span></p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_16() {
+    let original = r##"$a$$b$
+
+$a$$$b$$
+
+$$a$$$b$
+
+$$a$$$$b$$
+"##;
+    let expected = r##"<p><span class="math math-inline">a</span><span class="math math-inline">b</span></p>
+<p><span class="math math-inline">a</span><span class="math math-display">b</span></p>
+<p><span class="math math-display">a</span><span class="math math-inline">b</span></p>
+<p><span class="math math-display">a</span><span class="math math-display">b</span></p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_17() {
+    let original = r##"$Inline `first$ then` code
+
+`Code $first` then$ inline
+
+$$ Display `first $$ then` code
+
+`Code $$ first` then $$ display
+"##;
+    let expected = r##"<p><span class="math math-inline">Inline `first</span> then` code</p>
+<p><code>Code $first</code> then$ inline</p>
+<p><span class="math math-display"> Display `first </span> then` code</p>
+<p><code>Code $$ first</code> then $$ display</p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_18() {
+    let original = r##"$x + y - z$
+
+$x + y
+- z$
+
+$$ x + y
+> z $$
+"##;
+    let expected = r##"<p><span class="math math-inline">x + y - z</span></p>
+<p>$x + y</p>
+<ul>
+<li>z$</li>
+</ul>
+<p>$$ x + y</p>
+<blockquote>
+<p>z $$</p>
+</blockquote>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_19() {
+    let original = r##"$not
+
+math$
+
+$$
+not
+
+math
+$$
+"##;
+    let expected = r##"<p>$not</p>
+<p>math$</p>
+<p>$$
+not</p>
+<p>math
+$$</p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_20() {
+    let original = r##"- $not
+    - *
+  math$
+"##;
+    let expected = r##"<ul>
+<li>$not
+<ul>
+<li>
+<ul>
+<li>
+</li>
+</ul>
+</li>
+</ul>
+math$</li>
+</ul>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_21() {
+    let original = r##"This is display math:
+$$
+\text{Hello $x^2$}
+$$
+And this is inline math:
+$\text{Hello $x$ there!}$
+"##;
+    let expected = r##"<p>This is display math:
+<span class="math math-display">
+\text{Hello $x^2$}
+</span>
+And this is inline math:
+<span class="math math-inline">\text{Hello $x$ there!}</span></p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_22() {
+    let original = r##"This is not valid math: $}{$
+
+Neither is this: { $}{$ }
+
+This is: $\}\{$
+
+This is: $\}$
+
+Math environment contains 2+2: $}$2+2$
+"##;
+    let expected = r##"<p>This is not valid math: $}{$</p>
+<p>Neither is this: { $}{$ }</p>
+<p>This is: <span class="math math-inline">\}\{</span></p>
+<p>This is: <span class="math math-inline">\}</span></p>
+<p>Math environment contains 2+2: $}<span class="math math-inline">2+2</span></p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_23() {
+    let original = r##"This is not display math. It is inline math:
+
+$$\text{first $$ second}$
+
+$$$\text{first $$ second}$
+
+This is display math:
+
+$$\text{first $$ second}$$
+
+$$$\text{first $$ second}$$
+
+This is also display math, but (counterintuitively) it's allowed to be empty
+and expected to be as short as possible:
+
+$$$$\text{first $$ second}$$
+"##;
+    let expected = r##"<p>This is not display math. It is inline math:</p>
+<p>$<span class="math math-inline">\text{first $$ second}</span></p>
+<p>$$<span class="math math-inline">\text{first $$ second}</span></p>
+<p>This is display math:</p>
+<p><span class="math math-display">\text{first $$ second}</span></p>
+<p>$<span class="math math-display">\text{first $$ second}</span></p>
+<p>This is also display math, but (counterintuitively) it's allowed to be empty
+and expected to be as short as possible:</p>
+<p><span class="math math-display"></span>\text{first $$ second}$$</p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_24() {
+    let original = r##"$\text{\$}$
+
+$$x$x$$
+
+${$^$$
+
+$}$$$$
+
+$}$] $$
+"##;
+    let expected = r##"<p><span class="math math-inline">\text{\$}</span></p>
+<p>$<span class="math math-inline">x</span>x$$</p>
+<p>${<span class="math math-inline">^</span>$</p>
+<p>$}<span class="math math-display"></span></p>
+<p>$}$] $$</p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_25() {
+    let original = r##"$x$ $`y`$
+"##;
+    let expected = r##"<p><span class="math math-inline">x</span> <span class="math math-inline">`y`</span></p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_26() {
+    let original = r##"- $a$
+
+  ```math
+  a
+  ```
+
+  $$
+  a
+  $$
+
+- ```math
+  b
+  ```
+
+  $$
+  b
+  $$
+"##;
+    let expected = r##"<ul>
+<li>
+<p><span class="math math-inline">a</span></p>
+<pre><code class="language-math">a
+</code></pre>
+<p><span class="math math-display">
+a
+</span></p>
+</li>
+<li>
+<pre><code class="language-math">b
+</code></pre>
+<p><span class="math math-display">
+b
+</span></p>
+</li>
+</ul>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_27() {
+    let original = r##"- ![node logo](https://nodejs.org/static/images/logo.svg)
+- $x$
+"##;
+    let expected = r##"<ul>
+<li><img src="https://nodejs.org/static/images/logo.svg" alt="node logo" /></li>
+<li><span class="math math-inline">x</span></li>
+</ul>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_28() {
+    let original = r##"<details>
+
+$A = 5$
+
+$$
+A = 5
+$$
+
+</details>
+"##;
+    let expected = r##"<details>
+<p><span class="math math-inline">A = 5</span></p>
+<p><span class="math math-display">
+A = 5
+</span></p>
+</details>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_29() {
+    let original = r##"$a<b$
+
+$$a<b$$
+"##;
+    let expected = r##"<p><span class="math math-inline">a&lt;b</span></p>
+<p><span class="math math-display">a&lt;b</span></p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_30() {
+    let original = r##"[^a]
+
+[^a]: Lorem $a$
+"##;
+    let expected = r##"<p><sup class="footnote-reference"><a href="#a">1</a></sup></p>
+<div class="footnote-definition" id="a"><sup class="footnote-definition-label">1</sup>
+<p>Lorem <span class="math math-inline">a</span></p>
+</div>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_31() {
+    let original = r##"[$a$](x)
+"##;
+    let expected = r##"<p>
+<a href="x"><span class="math math-inline">a</span></a>
+</p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_32() {
+    let original = r##"a$x$
+
+-$x$
+
+1$x$
+"##;
+    let expected = r##"<p>a<span class="math math-inline">x</span></p>
+<p>-<span class="math math-inline">x</span></p>
+<p>1<span class="math math-inline">x</span></p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_33() {
+    let original = r##"_$a$ equals $b$_
+
+_$a$ equals $b$_
+
+**$a$ equals $b$**
+"##;
+    let expected = r##"<p><em><span class="math math-inline">a</span> equals <span class="math math-inline">b</span></em></p>
+<p><em><span class="math math-inline">a</span> equals <span class="math math-inline">b</span></em></p>
+<p><strong><span class="math math-inline">a</span> equals <span class="math math-inline">b</span></strong></p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_34() {
+    let original = r##"$$
+a
+$$
+
+- $$
+  \text{$b$}
+  $$
+"##;
+    let expected = r##"<p><span class="math math-display">
+a
+</span>
+</p><ul>
+<li><span class="math math-display">
+\text{$b$}
+</span></li>
+</ul>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_35() {
+    let original = r##"$\{a\,b\}$
+"##;
+    let expected = r##"<p><span class="math math-inline">\{a\,b\}</span></p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_36() {
+    let original = r##"$a <b > c$
+
+$[(a+b)c](d+e)$
+
+${a}_b c_{d}$
+"##;
+    let expected = r##"<p><span class="math math-inline">a &lt;b &gt; c</span></p>
+<p><span class="math math-inline">[(a+b)c](d+e)</span></p>
+<p><span class="math math-inline">{a}_b c_{d}</span></p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_37() {
+    let original = r##"When $a \ne 0$, there are two solutions to $(ax^2 + bx + c = 0)$ and they are
+$$ x = {-b \pm \sqrt{b^2-4ac} \over 2a} $$
+"##;
+    let expected = r##"<p>When <span class="math math-inline">a \ne 0</span>, there are two solutions to <span class="math math-inline">(ax^2 + bx + c = 0)</span> and they are
+<span class="math math-display"> x = {-b \pm \sqrt{b^2-4ac} \over 2a} </span></p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_38() {
+    let original = r##"$x = \$$
+"##;
+    let expected = r##"<p><span class="math math-inline">x = \$</span></p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_39() {
+    let original = r##"_Equation $\Omega(69)$ in italic text_
+"##;
+    let expected = r##"<p><em>Equation <span class="math math-inline">\Omega(69)</span> in italic text</em></p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_40() {
+    let original = r##"$\pi$
+'$\pi$
+"$\pi$
+($\pi$
+[$\pi$
+{$\pi$
+/$\pi$
+"##;
+    let expected = r##"<p>
+<span class="math math-inline">\pi</span>
+'<span class="math math-inline">\pi</span>
+"<span class="math math-inline">\pi</span>
+(<span class="math math-inline">\pi</span>
+[<span class="math math-inline">\pi</span>
+{<span class="math math-inline">\pi</span>
+/<span class="math math-inline">\pi</span>
+</p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_41() {
+    let original = r##"| first $|$ second |
+|--------|---------|
+| a ${   | }$ b    |
+"##;
+    let expected = r##"<table><thead>
+<tr><th>first $</th><th>$ second</th></tr>
+</thead><tbody>
+<tr><td>a ${</td><td>}$ b</td></tr>
+</tbody></table>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_42() {
+    let original = r##"| first $\|$ second |
+|-------------------|
+| a ${   \| }$ b    |
+"##;
+    let expected = r##"<table><thead>
+<tr><th>first <span class="math math-inline">|</span> second</th></tr>
+</thead><tbody>
+<tr><td>a <span class="math math-inline">{   | }</span> b</td></tr>
+</tbody></table>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_43() {
+    let original = r##"| Description | Test case |
+|-------------|-----------|
+| Single      | $\$       |
+| Double      | $\\$      |
+| Basic test  | $\|$      |
+| Basic test 2| $\|\|\$   |
+| Basic test 3| $x\|y\|z\$|
+| Not pipe    | $\.$      |
+| Combo       | $\.\|$    |
+| Combo 2     | $\.\|\$   |
+| Extra       | $\\\.$    |
+| Wait, what? | $\\|$     |
+| Wait, what? | $\\\|$    |
+| Wait, what? | $\\\\|$   |
+| Wait, what? | $\\\\\|$  |
+"##;
+    let expected = r##"<table><thead><tr><th>Description</th><th>Test case</th></tr></thead><tbody>
+<tr><td>Single</td><td>$$</td></tr>
+<tr><td>Double</td><td><span class="math math-inline">\\</span></td></tr>
+<tr><td>Basic test</td><td><span class="math math-inline">|</span></td></tr>
+<tr><td>Basic test 2</td><td>$||$</td></tr>
+<tr><td>Basic test 3</td><td>$x|y|z$</td></tr>
+<tr><td>Not pipe</td><td><span class="math math-inline">\.</span></td></tr>
+<tr><td>Combo</td><td><span class="math math-inline">\.|</span></td></tr>
+<tr><td>Combo 2</td><td>$.|$</td></tr>
+<tr><td>Extra</td><td><span class="math math-inline">\\\.</span></td></tr>
+<tr><td>Wait, what?</td><td><span class="math math-inline">\|</span></td></tr>
+<tr><td>Wait, what?</td><td><span class="math math-inline">\\|</span></td></tr>
+<tr><td>Wait, what?</td><td><span class="math math-inline">\\\|</span></td></tr>
+<tr><td>Wait, what?</td><td><span class="math math-inline">\\\\|</span></td></tr>
+</tbody></table>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_44() {
+    let original = r##"This is not an inline math environment: $}{$
+But, because it's nested too deeply, this is parsed as an inline math environment:
+{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{
+improperly $}{$ nested
+}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}
+But this still isn't, because the braces are still counted: $}{$
+"##;
+    let expected = r##"<p>This is not an inline math environment: $}{$
+But, because it's nested too deeply, this is parsed as an inline math environment:
+{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{
+improperly <span class="math math-inline">}{</span> nested
+}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}
+But this still isn't, because the braces are still counted: $}{$</p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_45() {
+    let original = r##"This is also deeply nested, but, unlike the first example,
+they don't have an equal number of close braces and open braces,
+so aren't detected as math.
+{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{
+improperly $}$ nested ${$ example
+}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}
+This, however, is detected ${}$
+
+${{{{{{{{{{{{{{{{{{{{{{{{{{{{{{
+another improperly nested example
+}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}$
+"##;
+    let expected = r##"<p>This is also deeply nested, but, unlike the first example,
+they don't have an equal number of close braces and open braces,
+so aren't detected as math.
+{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{
+improperly $}$ nested ${$ example
+}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}
+This, however, is detected <span class="math math-inline">{}</span></p>
+<p><span class="math math-inline">{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{
+another improperly nested example
+}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}</span></p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_46() {
+    let original = r##"${}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}  20 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}  40 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}  60 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}  80 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{} 100 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{} 120 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{} 140 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{} 160 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{} 180 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{} 200 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{} 220 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{} 240 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{$ 255 brace pairs and one unclosed brace
+"##;
+    let expected = r##"<p><span class="math math-inline">{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}  20 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}  40 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}  60 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}  80 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{} 100 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{} 120 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{} 140 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{} 160 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{} 180 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{} 200 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{} 220 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{} 240 brace pairs
+{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{</span> 255 brace pairs and one unclosed brace</p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
+
+#[test]
+fn math_test_47() {
+    let original = r##"${{{{{{{{{{{{{{{{{{{{ 20 open braces
+{{{{{{{{{{{{{{{{{{{{  40 open braces
+{{{{{{{{{{{{{{{{{{{{  60 open braces
+{{{{{{{{{{{{{{{{{{{{  80 open braces
+{{{{{{{{{{{{{{{{{{{{ 100 open braces
+{{{{{{{{{{{{{{{{{{{{ 110 open braces
+{{{{{{{{{{{{{{{{{{{{ 120 open braces
+{{{{{{{{{{{{{{{{{{{{ 140 open braces
+{{{{{{{{{{{{{{{{{{{{ 160 open braces
+{{{{{{{{{{{{{{{{{{{{ 180 open braces
+{{{{{{{{{{{{{{{{{{{{ 200 open braces
+{{{{{{{{{{{{{{{{{{{{ 220 open braces
+{{{{{{{{{{{{{{{{{{{{ 240 open braces
+{{{{{{{{{{{{{{{ 255 open braces
+}}}}}}}}}}}}}}}}}}}}  20 close braces
+}}}}}}}}}}}}}}}}}}}}  40 close braces
+}}}}}}}}}}}}}}}}}}}}  60 close braces
+}}}}}}}}}}}}}}}}}}}}  80 close braces
+}}}}}}}}}}}}}}}}}}}} 100 close braces
+}}}}}}}}}}}}}}}}}}}} 120 close braces
+}}}}}}}}}}}}}}}}}}}} 140 close braces
+}}}}}}}}}}}}}}}}}}}} 160 close braces
+}}}}}}}}}}}}}}}}}}}} 180 close braces
+}}}}}}}}}}}}}}}}}}}} 200 close braces
+}}}}}}}}}}}}}}}}}}}} 220 close braces
+}}}}}}}}}}}}}}}}}}}} 240 close braces
+}}}}}}}}}}}}}}}{$ 255 close braces and one open brace
+"##;
+    let expected = r##"<p>${{{{{{{{{{{{{{{{{{{{ 20 open braces
+{{{{{{{{{{{{{{{{{{{{  40 open braces
+{{{{{{{{{{{{{{{{{{{{  60 open braces
+{{{{{{{{{{{{{{{{{{{{  80 open braces
+{{{{{{{{{{{{{{{{{{{{ 100 open braces
+{{{{{{{{{{{{{{{{{{{{ 110 open braces
+{{{{{{{{{{{{{{{{{{{{ 120 open braces
+{{{{{{{{{{{{{{{{{{{{ 140 open braces
+{{{{{{{{{{{{{{{{{{{{ 160 open braces
+{{{{{{{{{{{{{{{{{{{{ 180 open braces
+{{{{{{{{{{{{{{{{{{{{ 200 open braces
+{{{{{{{{{{{{{{{{{{{{ 220 open braces
+{{{{{{{{{{{{{{{{{{{{ 240 open braces
+{{{{{{{{{{{{{{{ 255 open braces
+}}}}}}}}}}}}}}}}}}}}  20 close braces
+}}}}}}}}}}}}}}}}}}}}  40 close braces
+}}}}}}}}}}}}}}}}}}}}  60 close braces
+}}}}}}}}}}}}}}}}}}}}  80 close braces
+}}}}}}}}}}}}}}}}}}}} 100 close braces
+}}}}}}}}}}}}}}}}}}}} 120 close braces
+}}}}}}}}}}}}}}}}}}}} 140 close braces
+}}}}}}}}}}}}}}}}}}}} 160 close braces
+}}}}}}}}}}}}}}}}}}}} 180 close braces
+}}}}}}}}}}}}}}}}}}}} 200 close braces
+}}}}}}}}}}}}}}}}}}}} 220 close braces
+}}}}}}}}}}}}}}}}}}}} 240 close braces
+}}}}}}}}}}}}}}}{$ 255 close braces and one open brace</p>
+"##;
+
+    test_markdown_html(original, expected, false, false, false);
+}
diff --git a/pulldown-cmark/tests/suite/mod.rs b/pulldown-cmark/tests/suite/mod.rs
index 932782d..045a98e 100644
--- a/pulldown-cmark/tests/suite/mod.rs
+++ b/pulldown-cmark/tests/suite/mod.rs
@@ -8,6 +8,7 @@ mod gfm_strikethrough;
 mod gfm_table;
 mod gfm_tasklist;
 mod heading_attrs;
+mod math;
 mod metadata_blocks;
 mod old_footnotes;
 mod regression;