From 64b7846a599210b74418f7089d4da47a8538ffc4 Mon Sep 17 00:00:00 2001 From: apoorva-01 Date: Fri, 3 Jul 2026 06:17:15 +0530 Subject: [PATCH] Don't escape underscores that can't close emphasis Emphasis needs a closing delimiter, so escaping only the runs that can close keeps every underscore literal without the needless backslashes. --- docs/users/changelog.md | 5 +++ src/mdformat/renderer/__init__.py | 1 + src/mdformat/renderer/_context.py | 17 +++++++- src/mdformat/renderer/_util.py | 66 ++++++++++++++++++++----------- tests/data/default_style.md | 22 ++++++++++- 5 files changed, 84 insertions(+), 27 deletions(-) diff --git a/docs/users/changelog.md b/docs/users/changelog.md index 0faac615..378b3906 100644 --- a/docs/users/changelog.md +++ b/docs/users/changelog.md @@ -3,6 +3,11 @@ This log documents all Python API or CLI breaking backwards incompatible changes. Note that there is currently no guarantee for a stable Markdown formatting style across versions. +## **unreleased** + +- Changed + - Style: No longer escape underscores that can't close emphasis. + ## 1.0.0 - Removed diff --git a/src/mdformat/renderer/__init__.py b/src/mdformat/renderer/__init__.py index c335c378..eae32902 100644 --- a/src/mdformat/renderer/__init__.py +++ b/src/mdformat/renderer/__init__.py @@ -125,3 +125,4 @@ def label_sort_key(label: str) -> str: def _prepare_env(self, env: MutableMapping) -> None: env["indent_width"] = 0 env["used_refs"] = set() + env["within_underscore_emphasis"] = False diff --git a/src/mdformat/renderer/_context.py b/src/mdformat/renderer/_context.py index 238dd07d..7ea7dfb7 100644 --- a/src/mdformat/renderer/_context.py +++ b/src/mdformat/renderer/_context.py @@ -111,6 +111,17 @@ def softbreak(node: RenderTreeNode, context: RenderContext) -> str: return "\n" +def inline(node: RenderTreeNode, context: RenderContext) -> str: + # A "_" emphasis marker is a literal underscore in the output that a relaxed + # underscore elsewhere in the inline could pair with. Detect them once here + # so text escaping can stay conservative for the whole inline when present. + context.env["within_underscore_emphasis"] = any( + child.type in ("em", "strong") and set(child.markup) == {"_"} + for child in node.walk() + ) + return "".join(out for out in (c.render(context) for c in node.children) if out) + + def text(node: RenderTreeNode, context: RenderContext) -> str: """Process a text token. @@ -129,7 +140,9 @@ def text(node: RenderTreeNode, context: RenderContext) -> str: text = text.replace("\\", "\\\\") text = escape_asterisk_emphasis(text) # Escape emphasis/strong marker. - text = escape_underscore_emphasis(text) # Escape emphasis/strong marker. + text = escape_underscore_emphasis( # Escape emphasis/strong marker. + text, escape_openers=context.env["within_underscore_emphasis"] + ) # Escape link label and link ref enclosures text = escape_square_brackets(text, context.env["used_refs"]) text = escape_less_than_sign(text) # Escape URI enclosure and HTML. @@ -596,7 +609,7 @@ def ordered_list(node: RenderTreeNode, context: RenderContext) -> str: DEFAULT_RENDERERS: Mapping[str, Render] = MappingProxyType( { - "inline": make_render_children(""), + "inline": inline, "root": make_render_children("\n\n"), "hr": hr, "code_inline": code_inline, diff --git a/src/mdformat/renderer/_util.py b/src/mdformat/renderer/_util.py index b400c7bd..e5b12a49 100644 --- a/src/mdformat/renderer/_util.py +++ b/src/mdformat/renderer/_util.py @@ -134,41 +134,59 @@ def escape_asterisk_emphasis(text: str) -> str: return escaped_text -def escape_underscore_emphasis(text: str) -> str: - """Escape underscores to prevent unexpected emphasis/strong emphasis. - Currently we escape all underscores unless: - - - Neither of the surrounding characters are one of Unicode whitespace, - start or end of line, or Unicode punctuation - - Both surrounding characters are Unicode whitespace +def escape_underscore_emphasis(text: str, *, escape_openers: bool = False) -> str: + """Escape underscores that could be interpreted as emphasis/strong + emphasis. + + Emphasis needs both an opening and a closing delimiter, so escaping every + underscore run that can *close* emphasis is enough to keep all underscores + literal. Per CommonMark section 6.2 an underscore run can close emphasis + only when it is preceded by a non-whitespace character and not followed by + a word character (and can open emphasis in the mirror case). Runs that can + neither open nor close emphasis, e.g. intraword underscores, stay literal. + + The characters bordering this text (the start and end of the string) belong + to sibling nodes we cannot see here, so a run touching either end is escaped + regardless. When the text is wrapped by ``_`` emphasis markers those markers + can themselves pair with an inner run, so ``escape_openers`` additionally + escapes runs that can only open, keeping the wrapped text intact. """ # Fast exit to improve performance if "_" not in text: return text - bad_neighbor_chars = ( - codepoints.UNICODE_WHITESPACE - | codepoints.UNICODE_PUNCTUATION - | frozenset({None}) - ) + non_word_chars = codepoints.UNICODE_WHITESPACE | codepoints.UNICODE_PUNCTUATION escaped_text = "" text_length = len(text) - for i, current_char in enumerate(text): + i = 0 + while i < text_length: + current_char = text[i] if current_char != "_": escaped_text += current_char + i += 1 continue - prev_char = text[i - 1] if (i - 1) >= 0 else None - next_char = text[i + 1] if (i + 1) < text_length else None - if ( - prev_char in codepoints.UNICODE_WHITESPACE - and next_char in codepoints.UNICODE_WHITESPACE - ) or ( - prev_char not in bad_neighbor_chars and next_char not in bad_neighbor_chars - ): - escaped_text += current_char - continue - escaped_text += "\\" + current_char + run_start = i + while i < text_length and text[i] == "_": + i += 1 + prev_char = text[run_start - 1] if run_start else None + next_char = text[i] if i < text_length else None + left_nonspace = ( + prev_char is not None and prev_char not in codepoints.UNICODE_WHITESPACE + ) + right_nonspace = ( + next_char is not None and next_char not in codepoints.UNICODE_WHITESPACE + ) + left_word = prev_char is not None and prev_char not in non_word_chars + right_word = next_char is not None and next_char not in non_word_chars + can_close = left_nonspace and not right_word + can_open = right_nonspace and not left_word + at_border = prev_char is None or next_char is None + escape = at_border or can_close or (escape_openers and can_open) + if escape: + escaped_text += "\\_" * (i - run_start) + else: + escaped_text += text[run_start:i] return escaped_text diff --git a/tests/data/default_style.md b/tests/data/default_style.md index 7f1d4538..c63890eb 100644 --- a/tests/data/default_style.md +++ b/tests/data/default_style.md @@ -311,7 +311,7 @@ Don't esc_ape Don't _ escape _ underscore . -Do \_escape +Do _escape Don't esc_ape @@ -319,6 +319,26 @@ Don't _ escape _ underscore . +Don't escape underscores that can't close emphasis +. +When using *target=_blank* the underscore is literal. + +As a ______, I want X. + +Set target=_blank or read foo._bar here. + +Keep _a \_b c_ escaped inside emphasis. +. +When using *target=_blank* the underscore is literal. + +As a ______, I want X. + +Set target=_blank or read foo._bar here. + +Keep _a \_b c_ escaped inside emphasis. +. + + Keep shortcut reference links (dont convert to full reference) . ![Image]