holoviz/panel · #8970

fix: Protect math from markdown emphasis parsing in Markdown pane

philippjfr · merged Oct 31, 20263 files · 109 + / 3 −
panel/pane/markup.py58 + / 3 −
@@ -38,6 +38,54 @@ # links, definition lists) or which breaks line offsets (\r). _NON_LOCAL_SYNTAX = re.compile(r'\[\^|\]:|^ {0,3}[:~][ \t]|\r', re.MULTILINE) +def _math_plugin(md: MarkdownIt) -> None:+    """+    Keeps math delimited by $$ verbatim, since the client-side+    MathJax pass runs on the rendered HTML and would otherwise see+    markup generated from characters such as '*' inside the math.+    """+    from mdit_py_plugins.dollarmath import dollarmath_plugin+    from mdit_py_plugins.dollarmath.index import math_inline_dollar++    dollarmath_plugin(md, allow_labels=False, double_inline=True)++    # The client only typesets $$, so single $ must stay ordinary text+    # (e.g. currency) and keep its surrounding emphasis.+    inline_rule = math_inline_dollar(True, True, True)++    def double_only(state, silent):+        if state.src[state.pos:state.pos + 2] != '$$':+            return False+        return inline_rule(state, silent)++    md.inline.ruler.at('math_inline', double_only)++    def raw(delimiter, block=False):+        def render(self, tokens, idx, options, env):+            math = escape(tokens[idx].content, quote=False)+            return f'{delimiter}{math}{delimiter}\n' if block else f'{delimiter}{math}{delimiter}'+        return render++    md.add_render_rule('math_inline_double', raw('$$'))+    md.add_render_rule('math_block', raw('$$', block=True))+++def _MathExtension():+    from markdown.extensions import Extension+    from markdown.inlinepatterns import InlineProcessor++    class MathProcessor(InlineProcessor):+        def handleMatch(self, m, data):+            return m.group(0), m.start(0), m.end(0)++    class MathExtension(Extension):+        def extendMarkdown(self, md):+            # Below backticks so code spans are left alone, above escapes so the math is not touched+            md.inlinePatterns.register(MathProcessor(r'\$\$.+?\$\$', md), 'math', 185)++    return MathExtension()++ _HEADING_ID = re.compile(r'<h[1-6][^>]*?\sid="([^"]*)"')  @@ -654,7 +702,7 @@ def applies(cls, object: t.Any) -> float | bool | None:      @classmethod     @functools.cache-    def _get_parser(cls, renderer, plugins, hard_line_break=False, disable_anchors=True, **renderer_options):+    def _get_parser(cls, renderer, plugins, hard_line_break=False, disable_anchors=True, disable_math=True, **renderer_options):         if renderer == 'markdown':             return None         from markdown_it import MarkdownIt@@ -696,6 +744,8 @@ def hilite(token, langname, attrs):         )         if not disable_anchors:             parser = parser.use(anchors_plugin, permalink=True)+        if not disable_math:+            parser = parser.use(_math_plugin)         for plugin in plugins:             parser = parser.use(plugin)         try:@@ -725,15 +775,20 @@ def _render_markdown(self, obj: t.Any) -> str:         if self.renderer == 'markdown':             import markdown             self._block_cache = None-            extensions = self.extensions + ['nl2br'] if self.hard_line_break else self.extensions+            extensions = list(self.extensions)+            if self.hard_line_break:+                extensions.append('nl2br')+            if not self.disable_math:+                extensions.append(_MathExtension())             return markdown.markdown(                 obj,                 extensions=extensions,                 output_format='xhtml',                 **self.renderer_options             )         parser = self._get_parser(-            self.renderer, tuple(self.plugins), self.hard_line_break, self.disable_anchors, **self.renderer_options+            self.renderer, tuple(self.plugins), self.hard_line_break,+            self.disable_anchors, self.disable_math, **self.renderer_options         )         if self.renderer == 'markdown-it' and not self.plugins and not _NON_LOCAL_SYNTAX.search(obj):             try:
panel/tests/pane/test_markup.py38 + / 0 −
@@ -501,6 +501,44 @@ def test_json_theme():         assert JSON({"x": 1}, theme="light").theme == "light"  +@pytest.mark.parametrize('renderer', ['markdown-it', 'markdown'])+def test_markdown_math_not_parsed_as_emphasis(renderer):+    src = "$${(100+10)*2=220}$$ **NOT** $${100+10*2=120}$$ done"+    html = Markdown(src, renderer=renderer)._render_markdown(src)+    assert html.strip() == "<p>$${(100+10)*2=220}$$ <strong>NOT</strong> $${100+10*2=120}$$ done</p>"++def test_markdown_math_block_not_parsed_as_emphasis():+    src = "$$\na*b*c\n$$\n\n*hi*"+    assert Markdown(src)._render_markdown(src) == "$$\na*b*c\n$$\n<p><em>hi</em></p>\n"++def test_markdown_math_escapes_html():+    src = "$$a<b & c$$"+    assert Markdown(src)._render_markdown(src) == "$$a&lt;b &amp; c$$\n"++@pytest.mark.parametrize('renderer', ['markdown-it', 'markdown'])+def test_markdown_disable_math_keeps_emphasis(renderer):+    src = "$$a*b$$ and $$c*d$$"+    html = Markdown(src, renderer=renderer, disable_math=True)._render_markdown(src)+    assert "<em>" in html++@pytest.mark.parametrize('renderer', ['markdown-it', 'markdown'])+def test_markdown_dollar_amounts_are_not_math(renderer):+    src = "costs $5 and $10 for *x* and *y*"+    html = Markdown(src, renderer=renderer)._render_markdown(src)+    assert html.startswith("<p>costs $5 and $10 for <em>x</em> and <em>y</em></p>")++@pytest.mark.parametrize('renderer', ['markdown-it', 'markdown'])+def test_markdown_emphasis_between_dollar_amounts(renderer):+    src = "costs $5 for *basic* and $10 for *pro*"+    html = Markdown(src, renderer=renderer)._render_markdown(src)+    assert html.strip() == "<p>costs $5 for <em>basic</em> and $10 for <em>pro</em></p>"++@pytest.mark.parametrize('renderer', ['markdown-it', 'markdown'])+def test_markdown_double_dollar_in_code_span_is_code(renderer):+    src = "use `$$` and `$$` then *a* *b*"+    html = Markdown(src, renderer=renderer)._render_markdown(src)+    assert html.strip() == "<p>use <code>$$</code> and <code>$$</code> then <em>a</em> <em>b</em></p>"+ STREAM_DOCS = [     "# Title\n\nSome *text* with `code`.\n\n- a\n- b\n\n  - nested\n\n```python\nx = 1\n```\n\n| a | b |\n|---|---|\n| 1 | 2 |\n",     "para\n- list interrupts\n\n> quote\nlazy\n\n    indented\n\n    code\n\nsetext\n===\n\n<div>\n\n*html*\n\n</div>\n\n```\nunclosed",
panel/tests/ui/pane/test_markup.py13 + / 0 −
@@ -274,3 +274,16 @@ def app():     }""")      expect(page.locator(".markdown").locator("div")).to_be_visible()+++@pytest.mark.parametrize('renderer', ['markdown-it', 'markdown'])+def test_markdown_pane_math_with_asterisks(page, renderer):+    md = Markdown(+        "$${(100+10)*2=220}$$ **NOT** $${100+10*2=120}$$ done", renderer=renderer+    )++    serve_component(page, md)++    expect(page.locator(".markdown mjx-container")).to_have_count(2)+    # 0x3C is the "<" of a stray <em> tag typeset as math+    expect(page.locator(".markdown mjx-container [data-c='3C']")).to_have_count(0)