diff --git a/src/mistune/renderers/markdown.py b/src/mistune/renderers/markdown.py index 8c00917..085755b 100644 --- a/src/mistune/renderers/markdown.py +++ b/src/mistune/renderers/markdown.py @@ -13,6 +13,10 @@ #: quote) if they appear unescaped at the start of a line. _block_prefix_re = re.compile(r"^(\s*)(>|[-+*]|#{1,6}|\d{1,9}[.)])(\s|$)") +#: a run of "*"/"_" that would be re-parsed as an emphasis delimiter unless +#: it is escaped. +_emphasis_run_re = re.compile(r"\*+|_+") + class MarkdownRenderer(BaseRenderer): """A renderer to re-format Markdown text.""" @@ -41,14 +45,14 @@ def render_children(self, token: Dict[str, Any], state: BlockState) -> str: def text(self, token: Dict[str, Any], state: BlockState) -> str: raw = cast(str, token["raw"]) - # a text token that is made up entirely of "*"/"_" is a literal - # emphasis delimiter -- either an escaped marker from the source - # (``\*``) or an unmatched leftover -- so every character must stay - # escaped, or it would re-parse as emphasis on the round-trip. Prose - # punctuation such as ``2 * 3`` or ``snake_case`` arrives mixed with - # other characters and is left untouched. - if raw and all(c in "*_" for c in raw): - return "".join("\\" + c for c in raw) + # a backslash in a text token is literal and must stay escaped, or the + # re-parse consumes it (``\\(`` comes back as ``(``). Each escape is its + # own token, so there is no neighbour here to make this conditional on. + raw = raw.replace("\\", "\\\\") + # "*"/"_" in a text token are literal -- an escaped marker from the + # source (``\*``) or an unmatched leftover -- and must stay escaped, or + # they would re-parse as emphasis on the round-trip. + raw = _emphasis_run_re.sub(_escape_emphasis_run, raw) # a backtick always opens a code span, so it must stay escaped to # survive a re-parse as literal text. return raw.replace("`", "\\`") @@ -69,10 +73,12 @@ def link(self, token: Dict[str, Any], state: BlockState) -> str: attrs = token["attrs"] url: str = attrs["url"] title = attrs.get("title") - if text == url and not title: - return "<" + text + ">" - elif "mailto:" + text == url and not title: - return "<" + text + ">" + # an autolink renders its own URL as its text, and the "" form takes + # no escapes, so match on the raw text rather than the escaped render + children = token["children"] + raw = "".join(cast(str, c["raw"]) for c in children) if all(c["type"] == "text" for c in children) else "" + if raw and not title and url in (raw, "mailto:" + raw): + return "<" + raw + ">" out += "(" if "(" in url or ")" in url: @@ -209,6 +215,25 @@ def table_cell(self, token: Dict[str, Any], state: BlockState) -> str: return _render_table_cell(self, token, state) +def _escape_emphasis_run(match: "re.Match[str]") -> str: + """Escape a run of literal "*"/"_" unless it could not delimit emphasis. + + A run needs a non-space character on one side to open or close emphasis, + and a "_" run also cannot do so from inside a word. ``match.string`` is the + token's own raw text, so a run at a token edge has no neighbour to inspect + and is escaped: the adjacent inline element is not known to be a space. + """ + run = match.group(0) + text = match.string + before = text[match.start() - 1] if match.start() else "" + after = text[match.end()] if match.end() < len(text) else "" + if before.isspace() and after.isspace(): + return run + if run[0] == "_" and before.isalnum() and after.isalnum(): + return run + return "".join("\\" + c for c in run) + + def _escape_title(title: str) -> str: """Escape a link/image title for emission inside double quotes. The closing quote would otherwise end the title early on a re-parse; a backslash is diff --git a/tests/test_renderers.py b/tests/test_renderers.py index 2076eb0..8b97264 100644 --- a/tests/test_renderers.py +++ b/tests/test_renderers.py @@ -46,6 +46,18 @@ def test_escaped_marker_inside_list_item(self): def test_escaped_backtick(self): self.assert_round_trip(r"\`not code\`" + "\n") + def test_escaped_backslash(self): + # a literal backslash was re-emitted bare and the re-parse consumed it + # as an escape for the character behind it: KaTeX's "\\(...\\)" came back + # as plain "(...)", and one ending a line became a hard line break + for text in ( + r"(2) \\(2^n\\)" + "\n", + r"a *b* c \\(d\\) e" + "\n", + r"escaped \\\\ pair" + "\n", + "para line one " + r"\\" + "\nline two\n", + ): + self.assert_round_trip(text) + def test_escaped_emphasis_markers(self): # an escaped "*"/"_" is a literal delimiter, not emphasis; re-emitting # it unescaped would turn plain text back into / @@ -61,6 +73,38 @@ def test_escaped_emphasis_markers(self): ): self.assert_round_trip(text) + def test_escaped_marker_alongside_real_emphasis(self): + # an escaped marker keeps its own text token only while the paragraph + # has no real emphasis; once it does, the inline parser hands the + # renderer one merged token ("a *b* c \*d\* e" -> emphasis + " c *d* e"), + # and the literal markers were re-emitted bare and re-parsed as emphasis + for text in ( + r"a *b* c \*d\* e" + "\n", + r"\*baz\* *bar*" + "\n", + r"*bar* \_baz\_" + "\n", + ): + self.assert_round_trip(text) + + def test_literal_markers_escaped_only_where_they_could_delimit(self): + # assert_round_trip compares HTML, which cannot see a stray backslash, + # so pin the emitted Markdown: a run with a space either side, and an + # intraword "_", cannot open or close emphasis and must stay bare + self.assertEqual( + self.reformat("2 * 3 = 6 and snake_case_var\n"), + "2 * 3 = 6 and snake_case_var\n", + ) + self.assertEqual(self.reformat(r"*bar* \*baz\*" + "\n"), r"*bar* \*baz\*" + "\n") + + def test_autolink_url_containing_a_marker(self): + # the "" form takes no escapes, so escaping the text must not + # demote an autolink to a verbose inline link with backslashes in it + for text in ( + "\n", + "\n", + ): + self.assertEqual(self.reformat(text), text) + self.assert_round_trip(text) + def test_real_emphasis_not_over_escaped(self): # genuine emphasis must survive untouched, and prose punctuation with # spaces around a "*"/"_" must not gain stray backslashes