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
5 changes: 5 additions & 0 deletions docs/users/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/mdformat/renderer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
17 changes: 15 additions & 2 deletions src/mdformat/renderer/_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
66 changes: 42 additions & 24 deletions src/mdformat/renderer/_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 21 additions & 1 deletion tests/data/default_style.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,14 +311,34 @@ Don't esc_ape

Don't _ escape _ underscore
.
Do \_escape
Do _escape

Don't esc_ape

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]
Expand Down