Skip to content

Commit c0ddb80

Browse files
committed
fix: escape a literal backslash in a text token
MarkdownRenderer.text() re-emitted a literal backslash bare, so a re-parse consumed it as an escape for whatever followed. KaTeX's "\\(2^n\\)" came back as "(2^n)", and a backslash ending a line turned into a hard line break. Escape it first, ahead of the "*"/"_" and backtick escapes, the way _escape_title() already does for the same reason. The escape is unconditional: parse_escape gives each escaped character its own text token, so by then there is no following character to condition on, and a lookahead rule misses the reported case entirely. The cost is a backslash that would not have been consumed being doubled too, so a Windows path is re-emitted with doubled separators. One of the 652 bundled CommonMark examples changes this way. Over that corpus, meaning- changing round-trips drop from 85 to 79 with none newly broken. Reported by kbulygin on #480.
1 parent 6e9b133 commit c0ddb80

2 files changed

Lines changed: 16 additions & 0 deletions

File tree

src/mistune/renderers/markdown.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ def render_children(self, token: Dict[str, Any], state: BlockState) -> str:
4545

4646
def text(self, token: Dict[str, Any], state: BlockState) -> str:
4747
raw = cast(str, token["raw"])
48+
# a backslash in a text token is literal and must stay escaped, or the
49+
# re-parse consumes it (``\\(`` comes back as ``(``). Each escape is its
50+
# own token, so there is no neighbour here to make this conditional on.
51+
raw = raw.replace("\\", "\\\\")
4852
# "*"/"_" in a text token are literal -- an escaped marker from the
4953
# source (``\*``) or an unmatched leftover -- and must stay escaped, or
5054
# they would re-parse as emphasis on the round-trip.

tests/test_renderers.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,18 @@ def test_escaped_marker_inside_list_item(self):
4646
def test_escaped_backtick(self):
4747
self.assert_round_trip(r"\`not code\`" + "\n")
4848

49+
def test_escaped_backslash(self):
50+
# a literal backslash was re-emitted bare and the re-parse consumed it
51+
# as an escape for the character behind it: KaTeX's "\\(...\\)" came back
52+
# as plain "(...)", and one ending a line became a hard line break
53+
for text in (
54+
r"(2) \\(2^n\\)" + "\n",
55+
r"a *b* c \\(d\\) e" + "\n",
56+
r"escaped \\\\ pair" + "\n",
57+
"para line one " + r"\\" + "\nline two\n",
58+
):
59+
self.assert_round_trip(text)
60+
4961
def test_escaped_emphasis_markers(self):
5062
# an escaped "*"/"_" is a literal delimiter, not emphasis; re-emitting
5163
# it unescaped would turn plain text back into <em>/<strong>

0 commit comments

Comments
 (0)