Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 37 additions & 12 deletions src/mistune/renderers/markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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("`", "\\`")
Expand All @@ -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 "<url>" 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:
Expand Down Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions tests/test_renderers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <em>/<strong>
Expand All @@ -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 "<url>" 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 (
"<https://en.wikipedia.org/wiki/Foo_(bar)>\n",
"<https://example.com/a/__init__.py>\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
Expand Down