holoviz/panel · #8970
fix: Protect math from markdown emphasis parsing in Markdown pane
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<b & 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)