Skip to content

Commit 2841c33

Browse files
Escape leading block markers in MarkdownRenderer
When MarkdownRenderer reformats text whose leading punctuation was backslash-escaped in the source, it dropped the escape and emitted the bare marker. Re-parsing the result then turned literal text into a new block, e.g. "\* literal" round-tripped into a list item and "\# text" into a heading. The same happened on continuation lines of a paragraph and inside list items. Re-escape a leading list/heading/block-quote marker on each rendered line, and keep backticks escaped in text so they are not re-parsed as a code span. Markers that are genuinely list/heading/quote syntax still come through their own tokens, and ordinary prose (snake_case, hyphens, "C#", "a.b.c") is left untouched.
1 parent f21a29b commit 2841c33

2 files changed

Lines changed: 73 additions & 3 deletions

File tree

src/mistune/renderers/markdown.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88

99
fenced_re = re.compile(r"^[`~]+", re.M)
1010

11+
#: leading markers that would be parsed as a new block (list, heading, block
12+
#: quote) if they appear unescaped at the start of a line.
13+
_block_prefix_re = re.compile(r"^(\s*)(>|[-+*]|#{1,6}|\d{1,9}[.)])(\s|$)")
14+
1115

1216
class MarkdownRenderer(BaseRenderer):
1317
"""A renderer to re-format Markdown text."""
@@ -35,7 +39,9 @@ def render_children(self, token: Dict[str, Any], state: BlockState) -> str:
3539
return self.render_tokens(children, state)
3640

3741
def text(self, token: Dict[str, Any], state: BlockState) -> str:
38-
return cast(str, token["raw"])
42+
# a backtick always opens a code span, so it must stay escaped to
43+
# survive a re-parse as literal text.
44+
return cast(str, token["raw"]).replace("`", "\\`")
3945

4046
def emphasis(self, token: Dict[str, Any], state: BlockState) -> str:
4147
return "*" + self.render_children(token, state) + "*"
@@ -87,7 +93,7 @@ def inline_html(self, token: Dict[str, Any], state: BlockState) -> str:
8793

8894
def paragraph(self, token: Dict[str, Any], state: BlockState) -> str:
8995
text = self.render_children(token, state)
90-
return text + "\n\n"
96+
return _escape_block_prefix(text) + "\n\n"
9197

9298
def heading(self, token: Dict[str, Any], state: BlockState) -> str:
9399
level = cast(int, token["attrs"]["level"])
@@ -99,7 +105,7 @@ def thematic_break(self, token: Dict[str, Any], state: BlockState) -> str:
99105
return "***\n\n"
100106

101107
def block_text(self, token: Dict[str, Any], state: BlockState) -> str:
102-
return self.render_children(token, state) + "\n"
108+
return _escape_block_prefix(self.render_children(token, state)) + "\n"
103109

104110
def block_code(self, token: Dict[str, Any], state: BlockState) -> str:
105111
attrs = token.get("attrs", {})
@@ -129,6 +135,20 @@ def list(self, token: Dict[str, Any], state: BlockState) -> str:
129135
return render_list(self, token, state)
130136

131137

138+
def _escape_block_prefix(text: str) -> str:
139+
"""Backslash-escape a leading block marker on each line so that literal
140+
text is not re-parsed as a list, heading or block quote."""
141+
return "\n".join(_escape_line_prefix(line) for line in text.split("\n"))
142+
143+
144+
def _escape_line_prefix(line: str) -> str:
145+
m = _block_prefix_re.match(line)
146+
if not m:
147+
return line
148+
indent_, marker = m.group(1), m.group(2)
149+
return indent_ + marker[:-1] + "\\" + marker[-1] + line[m.end(2) :]
150+
151+
132152
def _get_fenced_marker(code: str) -> str:
133153
found = fenced_re.findall(code)
134154
if not found:

tests/test_renderers.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from unittest import TestCase
2+
13
from mistune import create_markdown
24
from mistune.renderers.rst import RSTRenderer
35
from mistune.renderers.markdown import MarkdownRenderer
@@ -15,3 +17,51 @@ class TestRenderer(BaseTestCase):
1517

1618
load_renderer(RSTRenderer())
1719
load_renderer(MarkdownRenderer())
20+
21+
22+
class TestMarkdownRendererRoundTrip(TestCase):
23+
"""Reformatting valid Markdown must not change its meaning: rendering the
24+
reformatted source to HTML must match rendering the original source."""
25+
26+
to_html = create_markdown(escape=False)
27+
reformat = create_markdown(renderer=MarkdownRenderer())
28+
29+
def assert_round_trip(self, text):
30+
self.assertEqual(
31+
self.to_html(self.reformat(text)),
32+
self.to_html(text),
33+
)
34+
35+
def test_escaped_block_markers(self):
36+
# an escaped leading marker is literal text, not a new block
37+
for marker in (r"\*", r"\-", r"\+", r"\#", r"\##", r"\>", r"1\.", r"3\)"):
38+
self.assert_round_trip(marker + " literal\n")
39+
40+
def test_escaped_marker_on_continuation_line(self):
41+
self.assert_round_trip("paragraph\n\\* not a list\n")
42+
43+
def test_escaped_marker_inside_list_item(self):
44+
self.assert_round_trip("- item\n\n \\# not a heading\n")
45+
46+
def test_escaped_backtick(self):
47+
self.assert_round_trip(r"\`not code\`" + "\n")
48+
49+
def test_real_markers_preserved(self):
50+
for text in (
51+
"* bullet\n",
52+
"# heading\n",
53+
"1. ordered\n",
54+
"> quote\n",
55+
"`code`\n",
56+
"*emphasis*\n",
57+
):
58+
self.assert_round_trip(text)
59+
60+
def test_prose_not_over_escaped(self):
61+
for text in (
62+
"snake_case_var\n",
63+
"use - dashes - here\n",
64+
"C# and a.b.c and 1.5\n",
65+
"ratio a/b is fine\n",
66+
):
67+
self.assert_round_trip(text)

0 commit comments

Comments
 (0)