From 927c0e07a53eb45b2f5f770fca89787d6743efe9 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Mon, 7 Sep 2026 13:32:47 -0600 Subject: [PATCH 01/34] Rewrite emphasis handling - This is a complete rewrite of how emphasis handling is done. - Drop use of multiple regex patterns in run in multiple passes and instead evaluate delimiters, nested or otherwise, and build up HTML elements. - Try to consume tokens as much as possible until a full element is constructed (with children if any). - If an outer set of tokens cannot be resolved, but one or more sub tokens can, render the first sub token span and cache the remaining ones for subsequent reentry and render those until the cache is exhausted. - Two tests results were updated to match new behavior. --- markdown/extensions/legacy_em.py | 28 +- markdown/inlinepatterns.py | 531 +++++++++++++++------- tests/misc/underscores.html | 6 - tests/misc/underscores.txt | 11 - tests/test_syntax/inline/test_emphasis.py | 33 +- 5 files changed, 400 insertions(+), 209 deletions(-) delete mode 100644 tests/misc/underscores.html delete mode 100644 tests/misc/underscores.txt diff --git a/markdown/extensions/legacy_em.py b/markdown/extensions/legacy_em.py index 39efe9a73..a78ebb402 100644 --- a/markdown/extensions/legacy_em.py +++ b/markdown/extensions/legacy_em.py @@ -14,29 +14,7 @@ from __future__ import annotations from . import Extension -from ..inlinepatterns import UnderscoreProcessor, EmStrongItem, EM_STRONG2_RE, STRONG_EM2_RE -import re - -# _emphasis_ -EMPHASIS_RE = r'(_)([^_]+)\1' - -# __strong__ -STRONG_RE = r'(_{2})(.+?)\1' - -# __strong_em___ -STRONG_EM_RE = r'(_)\1(?!\1)([^_]+?)\1(?!\1)(.+?)\1{3}' - - -class LegacyUnderscoreProcessor(UnderscoreProcessor): - """Emphasis processor for handling strong and em matches inside underscores.""" - - PATTERNS = [ - EmStrongItem(re.compile(EM_STRONG2_RE, re.DOTALL | re.UNICODE), 'double', 'strong,em'), - EmStrongItem(re.compile(STRONG_EM2_RE, re.DOTALL | re.UNICODE), 'double', 'em,strong'), - EmStrongItem(re.compile(STRONG_EM_RE, re.DOTALL | re.UNICODE), 'double2', 'strong,em'), - EmStrongItem(re.compile(STRONG_RE, re.DOTALL | re.UNICODE), 'single', 'strong'), - EmStrongItem(re.compile(EMPHASIS_RE, re.DOTALL | re.UNICODE), 'single', 'em') - ] +from ..inlinepatterns import DelimiterProcessor class LegacyEmExtension(Extension): @@ -50,8 +28,8 @@ def extendMarkdown(self, md): | [`LegacyUnderscoreProcessor`][markdown.extensions.legacy_em.LegacyUnderscoreProcessor] | [`inlinepatterns`][markdown.inlinepatterns.build_inlinepatterns] | `em_strong2` | `50` | """ - # flake8: noqa: E501 48-50 - md.inlinePatterns.register(LegacyUnderscoreProcessor(r'_'), 'em_strong2', 50) + # flake8: noqa: E501 27-29 + md.inlinePatterns.register(DelimiterProcessor(r'_', 'strong,em'), 'em_strong2', 50) def makeExtension(**kwargs): # pragma: no cover diff --git a/markdown/inlinepatterns.py b/markdown/inlinepatterns.py index d3c3bef26..7ce4efe7c 100644 --- a/markdown/inlinepatterns.py +++ b/markdown/inlinepatterns.py @@ -41,7 +41,8 @@ from __future__ import annotations from . import util -from typing import TYPE_CHECKING, Any, Collection, NamedTuple +from typing import TYPE_CHECKING, Any, Collection, NamedTuple, cast +from collections import deque import re import xml.etree.ElementTree as etree from html import entities @@ -89,9 +90,8 @@ def build_inlinepatterns(md: Markdown, **kwargs: Any) -> util.Registry[InlinePro inlinePatterns.register(SubstituteTagInlineProcessor(LINE_BREAK_RE, 'br'), 'linebreak', 100) inlinePatterns.register(HtmlInlineProcessor(HTML_RE, md), 'html', 90) inlinePatterns.register(HtmlInlineProcessor(ENTITY_RE, md), 'entity', 80) - inlinePatterns.register(SimpleTextInlineProcessor(NOT_STRONG_RE), 'not_strong', 70) - inlinePatterns.register(AsteriskProcessor(r'\*'), 'em_strong', 60) - inlinePatterns.register(UnderscoreProcessor(r'_'), 'em_strong2', 50) + inlinePatterns.register(DelimiterProcessor('*', 'strong,em'), 'em_strong', 60) + inlinePatterns.register(DelimiterProcessor('_', 'strong,em', smart=True), 'em_strong2', 50) return inlinePatterns @@ -107,36 +107,6 @@ def build_inlinepatterns(md: Markdown, **kwargs: Any) -> util.Registry[InlinePro ESCAPE_RE = r'\\(.)' """ Match a backslash escaped character (`\\<` or `\\*`). """ -EMPHASIS_RE = r'(\*)([^\*]+)\1' -""" Match emphasis with an asterisk (`*emphasis*`). """ - -STRONG_RE = r'(\*{2})(.+?)\1' -""" Match strong with an asterisk (`**strong**`). """ - -SMART_STRONG_RE = r'(?)` or `[text](url "title")`). """ @@ -149,9 +119,6 @@ def build_inlinepatterns(md: Markdown, **kwargs: Any) -> util.Registry[InlinePro IMAGE_REFERENCE_RE = IMAGE_LINK_RE """ Match start of image reference (`![alt text][2]`). """ -NOT_STRONG_RE = r'((^|(?<=\s))(\*{1,3}|_{1,3})(?=\s|$))' -""" Match a stand-alone `*` or `_`. """ - AUTOLINK_RE = r'<((?:[Ff]|[Hh][Tt])[Tt][Pp][Ss]?://[^<>]*)>' """ Match an automatic link (``). """ @@ -596,151 +563,383 @@ def _unescape(m: re.Match[str]) -> str: return RE.sub(_unescape, text) -class AsteriskProcessor(InlineProcessor): - """Emphasis processor for handling strong and em matches inside asterisks.""" - - PATTERNS = [ - EmStrongItem(re.compile(EM_STRONG_RE, re.DOTALL | re.UNICODE), 'double', 'strong,em'), - EmStrongItem(re.compile(STRONG_EM_RE, re.DOTALL | re.UNICODE), 'double', 'em,strong'), - EmStrongItem(re.compile(STRONG_EM3_RE, re.DOTALL | re.UNICODE), 'double2', 'strong,em'), - EmStrongItem(re.compile(STRONG_RE, re.DOTALL | re.UNICODE), 'single', 'strong'), - EmStrongItem(re.compile(EMPHASIS_RE, re.DOTALL | re.UNICODE), 'single', 'em') - ] - """ The various strong and emphasis patterns handled by this processor. """ +class DelimiterProcessor(InlineProcessor): + """Processor for handling complex nested patterns such as strong and em matches.""" - def build_single(self, m: re.Match[str], tag: str, idx: int) -> etree.Element: - """Return single tag.""" - el1 = etree.Element(tag) - text = m.group(2) - self.parse_sub_patterns(text, el1, None, idx) - return el1 - - def build_double(self, m: re.Match[str], tags: str, idx: int) -> etree.Element: - """Return double tag.""" - - tag1, tag2 = tags.split(",") - el1 = etree.Element(tag1) - el2 = etree.Element(tag2) - text = m.group(2) - self.parse_sub_patterns(text, el2, None, idx) - el1.append(el2) - if len(m.groups()) == 3: - text = m.group(3) - self.parse_sub_patterns(text, el1, el2, idx) - return el1 - - def build_double2(self, m: re.Match[str], tags: str, idx: int) -> etree.Element: - """Return double tags (variant 2): `text text`.""" - - tag1, tag2 = tags.split(",") - el1 = etree.Element(tag1) - el2 = etree.Element(tag2) - text = m.group(2) - self.parse_sub_patterns(text, el1, None, idx) - text = m.group(3) - el1.append(el2) - self.parse_sub_patterns(text, el2, None, idx) - return el1 - - def parse_sub_patterns( - self, data: str, parent: etree.Element, last: etree.Element | None, idx: int + def __init__( + self, + token: str, + tags: str, + md: Markdown | None = None, + smart: bool = False, + double: bool = False ) -> None: """ - Parses sub patterns. - - `data`: text to evaluate. + Initialize. - `parent`: Parent to attach text and sub elements to. - - `last`: Last appended child to parent. Can also be None if parent has no children. + Arguments: + token: A single character token. + tags: A tag or two tags seprated by comma. When two are specified, the first will be the + one that takes double tokens. + md: the Markdown object + smart: Enable intelligent word logic. + double: If only one tag is specified, indicate whether it requires double tokens. - `idx`: Current pattern index that was used to evaluate the parent. """ - offset = 0 - pos = 0 - - length = len(data) - while pos < length: - # Find the start of potential emphasis or strong tokens - if self.compiled_re.match(data, pos): - matched = False - # See if the we can match an emphasis/strong pattern - for index, item in enumerate(self.PATTERNS): - # Only evaluate patterns that are after what was used on the parent - if index <= idx: - continue - m = item.pattern.match(data, pos) - if m: - # Append child nodes to parent - # Text nodes should be appended to the last - # child if present, and if not, it should - # be added as the parent's text node. - text = data[offset:m.start(0)] - if text: - if last is not None: - last.tail = text - else: - parent.text = text - el = self.build_element(m, item.builder, item.tags, index) - parent.append(el) - last = el - # Move our position past the matched hunk - offset = pos = m.end(0) - matched = True - if not matched: - # We matched nothing, move on to the next character - pos += 1 + # Cache info + self.regions: list[tuple[int, int, int, int, int]] = [] + self.stack: deque[tuple[int, int, int]] = deque() + self.cache_index = 0 + self.cache_pos = 0 + + self.smart = smart + self.tags = tags.split(',') + self.double = len(tags) != 2 and double + super().__init__(self._build_patterns(token), md) + + def _build_patterns(self, token: str) -> str: + """Build regular expression patterns.""" + + # Build up patterns + self.token = token + etoken = re.escape(token) + avoid_start = fr'(?:(?<=_)|(?(?(?{avoid_start}{etoken}{{1,3}}(?![\s{etoken}])(?!$)) + ''', + flags=re.UNICODE + ) + elif self.double: + self.boundary = re.compile( + fr'''(?x) + (?P(?(?{avoid_start}{etoken}{{2}}(?![\s{etoken}])(?!$)) + ''', + flags=re.UNICODE + ) else: - # Increment position as no potential emphasis start was found. - pos += 1 - - # Append any leftover text as a text node. - text = data[offset:] - if text: - if last is not None: - last.tail = text + # This case is not currently used + self.boundary = re.compile( + fr'''(?x) + (?P(?(?{avoid_start}{etoken}{{1}}(?![\s{etoken}])(?!$)) + ''', + flags=re.UNICODE + ) + # Patterns for "dumb" cases. + else: + if len(self.tags) == 2: + self.boundary = re.compile( + fr'''(?x)(?: + (?P(?(?{etoken}{{1,3}}(?![\s{etoken}])(?!$)) + )''', + flags=re.UNICODE + ) + elif self.double: + self.boundary = re.compile( + fr'''(?x) + (?P(?(?{etoken}{{2}}(?![\s{etoken}])(?!$)) + ''', + flags=re.UNICODE + ) else: - parent.text = text - - def build_element(self, m: re.Match[str], builder: str, tags: str, index: int) -> etree.Element: + self.boundary = re.compile( + fr'''(?x) + (?P(?(?{etoken}{{1}}(?![\s{etoken}])(?!$)) + ''', + flags=re.UNICODE + ) + + return fr'{etoken}' + + def _build_element( + self, + data: str, + start: int = 0, + offset: int = 0 + ) -> tuple[etree.Element, int]: """Element builder.""" - if builder == 'double2': - return self.build_double2(m, tags, index) - elif builder == 'double': - return self.build_double(m, tags, index) + regions = self.regions + el: etree.Element | None = None + last: Any = None + previous: Any = None + greater: Any = None + lesser: Any = None + + triple = set() + outer: list[etree.Element] = [] + outer_r: list[tuple[int, int, int, int, int]] = [] + + if len(self.tags) == 2: + greater, lesser = self.tags + elif self.double: + greater = self.tags[0] + lesser = None else: - return self.build_single(m, tags, index) - - def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element | None, int | None, int | None]: - """Parse patterns.""" - - el = None - start = None - end = None - - for index, item in enumerate(self.PATTERNS): - m1 = item.pattern.match(data, m.start(0)) - if m1: - start = m1.start(0) - end = m1.end(0) - el = self.build_element(m1, item.builder, item.tags, index) + lesser = self.tags[0] + greater = None + + # Iterate regions creating the elements they represent + end = len(regions) + idx = 0 + for idx, i in enumerate(range(start, end), 1): + r = regions[i] + # Not contained within region + if idx and r[0] > regions[start][3]: + idx -= 1 break - return el, start, end + # Get the appropriate element(s) + if r[4] == 3: + el1 = etree.Element(greater) + el2 = etree.Element(lesser) + elif r[4] == 2: + el1 = etree.Element(greater) + el2 = None + else: + el1 = etree.Element(lesser) + el2 = None + + # Populate the elements with their text + if idx > 1: + if last.text is None: + if previous[2] < r[0]: + last.text = data[previous[1]+offset:previous[2]+offset] + else: + last.text = data[previous[1]+offset:r[0]+offset] + if last is not outer[-1] and last.tail is None: + if r[0] < outer_r[-1][3]: + last.tail = data[previous[3]+offset:r[0]+offset] + else: + last.tail = data[previous[3]+offset:outer_r[-1][2]+offset] + outer[-1].tail = data[outer_r[-1][3]+offset:r[0]+offset] + + # First element + if el is None: + el = el1 + last = el + outer.append(el) + outer_r.append(r) + + # Subsequent elements + else: + # Is the current outer element no longer wrapping this one? + while len(outer_r) > 1 and r[3] > outer_r[-1][3]: + outer.pop() + outer_r.pop() + # Double nested element (triple token) + if outer[-1] in triple: + outer[-1][-1].append(el1) + + # Non-nested + else: + outer[-1].append(el1) + + # Is this element wrapping the next? + if i + 1 < end: + if r[3] > regions[i + 1][3]: + outer.append(el1) + outer_r.append(r) + + # Track the last element we parsed. + last = el1 + + # Nest secondary element if there is one. + # Track triple tokens (double elements) + # so we can identify quickly and properly nest. + if el2 is not None: + el1.append(el2) + last = el2 + triple.add(el1) + + # Track the previous region. + previous = r + + # Populate remaining elements with their text + while outer: + if last.text is None: + last.text = data[previous[1]+offset:previous[2]+offset] + if last.tail is None and last is not outer[-1]: + last.tail = data[previous[3]+offset:outer_r[-1][2]+offset] + last = outer.pop() + previous = outer_r.pop() + + return cast('etree.Element', el), idx + + def get_cached_result(self, pos: int, data: str) -> tuple[etree.Element, int, int]: + """Get a cached result.""" + + stack = self.stack + regions = self.regions + + # Process the next region(s) in the cache + offset = pos - self.cache_pos + start, end = regions[self.cache_index][0], regions[self.cache_index][3] + el, count = self._build_element(data, self.cache_index, offset) + + # Determine next offset + self.cache_index += count + if self.cache_index < len(regions): + self.cache_pos = regions[self.cache_index][0] + while stack: + entry = stack.popleft() + if entry[0] > end: + if entry[0] < self.cache_pos: + self.cache_pos = entry[0] + break + + # Nothing left to process + else: + regions.clear() + stack.clear() + self.cache_index = 0 + self.cache_pos = 0 + + # Whether element is valid or not, we'll advance past the end + return el, start + offset, end + offset + + def handleMatch( # type: ignore[override] + self, + m: re.Match[str], + data: str + ) -> tuple[etree.Element | None, int | None, int | None]: + """Parse delimiter pattern.""" + + # Do we have entries we haven't returned yet? + if self.regions: + return self.get_cached_result(m.start(0), data) + + # If token is not an opening, quit + m2 = self.boundary.match(data, m.start(0)) + if m2 is None or m2.lastgroup[0] == 'e': # type: ignore[index] + if m2 is not None: + m = m2 + # Advance past the full length of the delimiter found + return None, m.start(0), m.end(0) + + # Get the stack and regions + stack = self.stack + regions = self.regions + + # Data offset + offset = m2.end(0) + # Stack of opening delimiters + stack.append((m2.start(0), offset, len(m2.group(0)))) + + # Pair tokens until the stack is empty or we can no longer find tokens. + while stack: + m2 = self.boundary.search(data, offset) + if m2 is None: + break + offset = m2.end(0) + + # Get current and last delimiter size + current = len(m2.group(0)) + last = stack[-1][-1] + + # Some delimiters may be ambiguous and look like both a start or an end + is_start = m2.lastgroup[0] != 'e' # type: ignore[index] + is_end = not is_start or m2.lastgroup[0] != 's' # type: ignore[index] + ambiguous = is_start and is_end + + # Find closing tokens + # Looking for: + # - `*em*` + # - `**strong**` + # - `***strong,em***` + # - `*em**` + # - `*em***` + # - `**strong***` + # + # Avoid ambiguous tokens that could be a start or an end. + # Consume starts until the end token is fully consumed. + # If we don't consume the entire end, see if next rule consumes it. + if is_end and ((not ambiguous and current > last) or (current == last)): + is_start = False + + # Consume previous tokens until the delimiter is consumed + s = m2.start(0) + while current and last <= current: + delimiter = stack.pop() + + # Build up region for pair and adjust accounting. + regions.append((delimiter[0], delimiter[1], s, s + delimiter[-1], delimiter[-1])) + s += delimiter[-1] + current -= delimiter[-1] + if not stack: + is_end = False + break + last = stack[-1][-1] + + # Do we still have more to consume? + is_end = current and stack and last > current + + # Looking for: + # - `***em*` + # - `***strong**` + # - `**em*` + if is_end and (last == 3 or not ambiguous) and last > current: + is_start = False + delimiter = stack.pop() + new = last - current + regions.append((delimiter[0] + new, delimiter[1], m2.start(0), offset, current)) + stack.append((delimiter[0], delimiter[0] + new, new)) + + # Find opening tokens + if is_start: + # Looking for: + # - `*em ...*` + # - `**strong ...*` + # - `***em ...*` + stack.append((m2.start(0), m2.end(0), current)) + + # Build the HTML elements + if regions: + # Regions may be out of order. + regions.sort(key=lambda x: x[0]) + start, end = regions[0][0], regions[0][3] + el, count = self._build_element(data) + + # Cache unprocessed regions to avoid repeated searches + if count < len(regions): + self.cache_index = count + self.cache_pos = self.regions[count][0] + while stack: + entry = stack.popleft() + if entry[0] > end: + if entry[0] < self.cache_pos: + self.cache_pos = entry[0] + break + else: + # Cleanup + stack.clear() + regions.clear() -class UnderscoreProcessor(AsteriskProcessor): - """Emphasis processor for handling strong and em matches inside underscores.""" + return el, start, end - PATTERNS = [ - EmStrongItem(re.compile(EM_STRONG2_RE, re.DOTALL | re.UNICODE), 'double', 'strong,em'), - EmStrongItem(re.compile(STRONG_EM2_RE, re.DOTALL | re.UNICODE), 'double', 'em,strong'), - EmStrongItem(re.compile(SMART_STRONG_EM_RE, re.DOTALL | re.UNICODE), 'double2', 'strong,em'), - EmStrongItem(re.compile(SMART_STRONG_RE, re.DOTALL | re.UNICODE), 'single', 'strong'), - EmStrongItem(re.compile(SMART_EMPHASIS_RE, re.DOTALL | re.UNICODE), 'single', 'em') - ] - """ The various strong and emphasis patterns handled by this processor. """ + # We failed to pair any valid start/end delimiters, avoid the parsed range next pass. + start = m.start(0) + end = stack[-1][1] if stack else m.end(0) + stack.clear() + return None, start, end class LinkInlineProcessor(InlineProcessor): diff --git a/tests/misc/underscores.html b/tests/misc/underscores.html deleted file mode 100644 index 72d51b8b5..000000000 --- a/tests/misc/underscores.html +++ /dev/null @@ -1,6 +0,0 @@ -

THIS_SHOULD_STAY_AS_IS

-

Here is some emphasis, ok?

-

Ok, at least this should work.

-

THIS__SHOULD__STAY

-

Here is some strong stuff.

-

THISSHOULDSTAY?

\ No newline at end of file diff --git a/tests/misc/underscores.txt b/tests/misc/underscores.txt deleted file mode 100644 index 3c7f4bdd9..000000000 --- a/tests/misc/underscores.txt +++ /dev/null @@ -1,11 +0,0 @@ -THIS_SHOULD_STAY_AS_IS - -Here is some _emphasis_, ok? - -Ok, at least _this_ should work. - -THIS__SHOULD__STAY - -Here is some __strong__ stuff. - -THIS___SHOULD___STAY? diff --git a/tests/test_syntax/inline/test_emphasis.py b/tests/test_syntax/inline/test_emphasis.py index 6e96ea32c..a7b3c56fa 100644 --- a/tests/test_syntax/inline/test_emphasis.py +++ b/tests/test_syntax/inline/test_emphasis.py @@ -20,6 +20,7 @@ """ from markdown.test_tools import TestCase +import textwrap class TestNotEmphasis(TestCase): @@ -147,7 +148,7 @@ def test_complex_emphasis_smart_underscore(self): def test_complex_emphasis_smart_underscore_mid_word(self): self.assertMarkdownRenders( 'This is text __bold_italic bold___ with more text', - '

This is text __bold_italic bold___ with more text

' + '

This is text bold_italic bold_ with more text

' ) def test_nested_emphasis(self): @@ -191,3 +192,33 @@ def test_link_emphasis_inner_outer(self): '**[**text**](url)**', '

text

' ) + + def test_underscore_legacy(self): + + self.assertMarkdownRenders( + textwrap.dedent( + """ + THIS_SHOULD_STAY_AS_IS + + Here is some _emphasis_, ok? + + Ok, at least _this_ should work. + + THIS__SHOULD__STAY + + Here is some __strong__ stuff. + + THIS___SHOULD___STAY? + """ + ), + textwrap.dedent( + """ +

THIS_SHOULD_STAY_AS_IS

+

Here is some emphasis, ok?

+

Ok, at least this should work.

+

THIS__SHOULD__STAY

+

Here is some strong stuff.

+

THIS___SHOULD___STAY?

+ """ + ).strip() + ) From c06c76bffb005003333725e43b8a1b3f303c64c0 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Tue, 8 Sep 2026 06:44:37 -0600 Subject: [PATCH 02/34] Remove outdated comment --- markdown/inlinepatterns.py | 1 - 1 file changed, 1 deletion(-) diff --git a/markdown/inlinepatterns.py b/markdown/inlinepatterns.py index 7ce4efe7c..12211dc9c 100644 --- a/markdown/inlinepatterns.py +++ b/markdown/inlinepatterns.py @@ -813,7 +813,6 @@ def get_cached_result(self, pos: int, data: str) -> tuple[etree.Element, int, in self.cache_index = 0 self.cache_pos = 0 - # Whether element is valid or not, we'll advance past the end return el, start + offset, end + offset def handleMatch( # type: ignore[override] From 190dda30c1b4b6a96e8ec17f9afde46d87eedc4c Mon Sep 17 00:00:00 2001 From: facelessuser Date: Tue, 8 Sep 2026 06:59:59 -0600 Subject: [PATCH 03/34] If Markdown object is ever left in a bad state reset the stack This is a prevented measure to ensure the processor is always in a good state. This situation has never been observed, but if it did occur, this would allow the processor to reset its state and continue properly. Only extensions have a way to reset, processors don't. Nor do they have a way to detect when a reset would be needed. - Add a current time for each Markdown run. - Have DelimiterProcessor compare the each run time, and if it has changed, perform a reset of the stack. --- markdown/core.py | 3 +++ markdown/extensions/legacy_em.py | 2 +- markdown/inlinepatterns.py | 12 ++++++++++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/markdown/core.py b/markdown/core.py index 370cb7ec5..fd946d54f 100644 --- a/markdown/core.py +++ b/markdown/core.py @@ -20,6 +20,7 @@ from __future__ import annotations import codecs +import time import sys import logging import importlib @@ -106,6 +107,7 @@ def __init__(self, **kwargs): """ + self.last_run: float = 0.0 self.tab_length: int = kwargs.get('tab_length', 4) self.ESCAPED_CHARS: list[str] = [ @@ -267,6 +269,7 @@ def reset(self) -> Markdown: Called once upon creation of a class instance. Should be called manually between calls to [`Markdown.convert`][markdown.Markdown.convert]. """ + self.last_run = time.time() self.htmlStash.reset() self.references.clear() diff --git a/markdown/extensions/legacy_em.py b/markdown/extensions/legacy_em.py index a78ebb402..3a9b02e1d 100644 --- a/markdown/extensions/legacy_em.py +++ b/markdown/extensions/legacy_em.py @@ -29,7 +29,7 @@ def extendMarkdown(self, md): """ # flake8: noqa: E501 27-29 - md.inlinePatterns.register(DelimiterProcessor(r'_', 'strong,em'), 'em_strong2', 50) + md.inlinePatterns.register(DelimiterProcessor(r'_', 'strong,em', md), 'em_strong2', 50) def makeExtension(**kwargs): # pragma: no cover diff --git a/markdown/inlinepatterns.py b/markdown/inlinepatterns.py index 12211dc9c..9c985c244 100644 --- a/markdown/inlinepatterns.py +++ b/markdown/inlinepatterns.py @@ -90,8 +90,8 @@ def build_inlinepatterns(md: Markdown, **kwargs: Any) -> util.Registry[InlinePro inlinePatterns.register(SubstituteTagInlineProcessor(LINE_BREAK_RE, 'br'), 'linebreak', 100) inlinePatterns.register(HtmlInlineProcessor(HTML_RE, md), 'html', 90) inlinePatterns.register(HtmlInlineProcessor(ENTITY_RE, md), 'entity', 80) - inlinePatterns.register(DelimiterProcessor('*', 'strong,em'), 'em_strong', 60) - inlinePatterns.register(DelimiterProcessor('_', 'strong,em', smart=True), 'em_strong2', 50) + inlinePatterns.register(DelimiterProcessor('*', 'strong,em', md), 'em_strong', 60) + inlinePatterns.register(DelimiterProcessor('_', 'strong,em', md, smart=True), 'em_strong2', 50) return inlinePatterns @@ -593,6 +593,7 @@ def __init__( self.cache_index = 0 self.cache_pos = 0 + self.last_run = 0.0 self.smart = smart self.tags = tags.split(',') self.double = len(tags) != 2 and double @@ -822,6 +823,13 @@ def handleMatch( # type: ignore[override] ) -> tuple[etree.Element | None, int | None, int | None]: """Parse delimiter pattern.""" + # We are in a new run. Reset just in case we were somehow left in a bad state. + if self.md.last_run != self.last_run: + self.regions.clear() + self.stack.clear() + self.cache_index = 0 + self.cache_pos = 0 + # Do we have entries we haven't returned yet? if self.regions: return self.get_cached_result(m.start(0), data) From 6fca854a113a48ddedaac70042b5855fd9684f83 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Tue, 8 Sep 2026 07:25:18 -0600 Subject: [PATCH 04/34] Consolidate reset behavior and ensure we update our tracked run --- markdown/inlinepatterns.py | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/markdown/inlinepatterns.py b/markdown/inlinepatterns.py index 9c985c244..9224d588a 100644 --- a/markdown/inlinepatterns.py +++ b/markdown/inlinepatterns.py @@ -587,18 +587,22 @@ def __init__( """ - # Cache info - self.regions: list[tuple[int, int, int, int, int]] = [] - self.stack: deque[tuple[int, int, int]] = deque() - self.cache_index = 0 - self.cache_pos = 0 - self.last_run = 0.0 self.smart = smart self.tags = tags.split(',') self.double = len(tags) != 2 and double + self.reset() super().__init__(self._build_patterns(token), md) + def reset(self): + """Rest.""" + + # Cache info + self.regions: list[tuple[int, int, int, int, int]] = [] + self.stack: deque[tuple[int, int, int]] = deque() + self.cache_index = 0 + self.cache_pos = 0 + def _build_patterns(self, token: str) -> str: """Build regular expression patterns.""" @@ -809,10 +813,7 @@ def get_cached_result(self, pos: int, data: str) -> tuple[etree.Element, int, in # Nothing left to process else: - regions.clear() - stack.clear() - self.cache_index = 0 - self.cache_pos = 0 + self.reset() return el, start + offset, end + offset @@ -825,10 +826,8 @@ def handleMatch( # type: ignore[override] # We are in a new run. Reset just in case we were somehow left in a bad state. if self.md.last_run != self.last_run: - self.regions.clear() - self.stack.clear() - self.cache_index = 0 - self.cache_pos = 0 + self.last_run = self.md.last_run + self.reset() # Do we have entries we haven't returned yet? if self.regions: From fb46bb35be3d71a24653bf8c030a406d59f5ca60 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Tue, 8 Sep 2026 07:27:37 -0600 Subject: [PATCH 05/34] Have other reset locations call the reset function as well --- markdown/inlinepatterns.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/markdown/inlinepatterns.py b/markdown/inlinepatterns.py index 9224d588a..d4c47f2d4 100644 --- a/markdown/inlinepatterns.py +++ b/markdown/inlinepatterns.py @@ -936,15 +936,14 @@ def handleMatch( # type: ignore[override] break else: # Cleanup - stack.clear() - regions.clear() + self.reset() return el, start, end # We failed to pair any valid start/end delimiters, avoid the parsed range next pass. start = m.start(0) end = stack[-1][1] if stack else m.end(0) - stack.clear() + self.reset() return None, start, end From 3475564c816a311f136811086459bad4ad4af7df Mon Sep 17 00:00:00 2001 From: facelessuser Date: Tue, 8 Sep 2026 08:04:49 -0600 Subject: [PATCH 06/34] Add return type --- markdown/inlinepatterns.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/markdown/inlinepatterns.py b/markdown/inlinepatterns.py index d4c47f2d4..52294e713 100644 --- a/markdown/inlinepatterns.py +++ b/markdown/inlinepatterns.py @@ -594,7 +594,7 @@ def __init__( self.reset() super().__init__(self._build_patterns(token), md) - def reset(self): + def reset(self) -> None: """Rest.""" # Cache info From 96b9e4bca40ba07554fed4798bcbf539a5aed657 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Tue, 8 Sep 2026 08:27:01 -0600 Subject: [PATCH 07/34] Fix link errors --- docs/extensions/api.md | 2 +- markdown/extensions/legacy_em.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/extensions/api.md b/docs/extensions/api.md index 6952bcd19..4822bbc2b 100644 --- a/docs/extensions/api.md +++ b/docs/extensions/api.md @@ -349,7 +349,7 @@ Here are some convenience functions and other examples: | Class | Kind | Description | | -------------------------------------------------------------------------------------|-----------|---------------------------------------------------------------| -| [`AsteriskProcessor`][markdown.inlinepatterns.AsteriskProcessor] | built-in | Emphasis processor for handling strong and em matches inside asterisks | +| [`DelimiterProcessor`][markdown.inlinepatterns.DelimiterProcessor] | built-in | Emphasis processor for handling strong and em matches | | [`WikiLinksInlineProcessor`][markdown.extensions.wikilinks.WikiLinksInlineProcessor] | extension | Link `[[article names]]` to wiki given in metadata | | [`FootnoteInlineProcessor`][markdown.extensions.footnotes.FootnoteInlineProcessor] | extension | Replaces footnote in text with link to footnote div at bottom | diff --git a/markdown/extensions/legacy_em.py b/markdown/extensions/legacy_em.py index 3a9b02e1d..e0edf05b4 100644 --- a/markdown/extensions/legacy_em.py +++ b/markdown/extensions/legacy_em.py @@ -23,9 +23,9 @@ class LegacyEmExtension(Extension): def extendMarkdown(self, md): """ Register the processor. - | Class Instance | Registry | Name | Priority | - | ------------------------------------------------------------- | ---------------------------------------------------------------- | ------ | :------: | - | [`LegacyUnderscoreProcessor`][markdown.extensions.legacy_em.LegacyUnderscoreProcessor] | [`inlinepatterns`][markdown.inlinepatterns.build_inlinepatterns] | `em_strong2` | `50` | + | Class Instance | Registry | Name | Priority | + | ------------------------------------------------------------------ | ---------------------------------------------------------------- | ------------ | :------: | + | [`DelimiterProcessor`][markdown.inlinepatterns.DelimiterProcessor] | [`inlinepatterns`][markdown.inlinepatterns.build_inlinepatterns] | `em_strong2` | `50` | """ # flake8: noqa: E501 27-29 From f7bb325979ab4c2bbfcabeddebd738cb497722a6 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Tue, 8 Sep 2026 08:30:52 -0600 Subject: [PATCH 08/34] Add a changelog item --- docs/changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/changelog.md b/docs/changelog.md index a14c77293..618bce09a 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -20,6 +20,7 @@ See the [Contributing Guide](contributing.md) for details. same paragraph, instead of splitting the paragraph around it (#1643). * Fix issue where inline HTML attributes were rejected if they had `<` or `>` in the attribute (#1647). * Fix issue where an unterminated end tag (` Date: Wed, 9 Sep 2026 10:03:55 -0600 Subject: [PATCH 09/34] Consolidate duplicate code --- markdown/inlinepatterns.py | 51 ++++++++++++++++---------------------- 1 file changed, 21 insertions(+), 30 deletions(-) diff --git a/markdown/inlinepatterns.py b/markdown/inlinepatterns.py index 52294e713..0f5b3ab15 100644 --- a/markdown/inlinepatterns.py +++ b/markdown/inlinepatterns.py @@ -789,32 +789,37 @@ def _build_element( return cast('etree.Element', el), idx - def get_cached_result(self, pos: int, data: str) -> tuple[etree.Element, int, int]: - """Get a cached result.""" - - stack = self.stack - regions = self.regions + def increment_next_position(self, end: int, count: int) -> None: + """ + Increment cache position to the next location that we can initiate an insertion. - # Process the next region(s) in the cache - offset = pos - self.cache_pos - start, end = regions[self.cache_index][0], regions[self.cache_index][3] - el, count = self._build_element(data, self.cache_index, offset) + Cache position should be the first match after our current replacement. + This gives us an anchor to calculate the new offset after insertion. + """ # Determine next offset self.cache_index += count - if self.cache_index < len(regions): - self.cache_pos = regions[self.cache_index][0] - while stack: - entry = stack.popleft() + if self.cache_index < len(self.regions): + self.cache_pos = self.regions[self.cache_index][0] + while self.stack: + entry = self.stack.popleft() if entry[0] > end: - if entry[0] < self.cache_pos: - self.cache_pos = entry[0] + self.cache_pos = entry[0] break # Nothing left to process else: self.reset() + def get_cached_result(self, pos: int, data: str) -> tuple[etree.Element, int, int]: + """Get a cached result.""" + + # Process the next region(s) in the cache + regions = self.regions + offset = pos - self.cache_pos + start, end = regions[self.cache_index][0], regions[self.cache_index][3] + el, count = self._build_element(data, self.cache_index, offset) + self.increment_next_position(end, count) return el, start + offset, end + offset def handleMatch( # type: ignore[override] @@ -923,21 +928,7 @@ def handleMatch( # type: ignore[override] regions.sort(key=lambda x: x[0]) start, end = regions[0][0], regions[0][3] el, count = self._build_element(data) - - # Cache unprocessed regions to avoid repeated searches - if count < len(regions): - self.cache_index = count - self.cache_pos = self.regions[count][0] - while stack: - entry = stack.popleft() - if entry[0] > end: - if entry[0] < self.cache_pos: - self.cache_pos = entry[0] - break - else: - # Cleanup - self.reset() - + self.increment_next_position(end, count) return el, start, end # We failed to pair any valid start/end delimiters, avoid the parsed range next pass. From 70785a665db76a39070097f3ac4b265c77e51a0a Mon Sep 17 00:00:00 2001 From: facelessuser Date: Thu, 10 Sep 2026 09:28:19 -0600 Subject: [PATCH 10/34] Fix some edge cases - Fix an issue in element building where we should have exited - When an ambiguous `***` is partially consumed by a smaller start, the remainder should be a non-ambiguous start. - Don't consume an ambiguous opening with an end that is smaller than the opening. - `***` are always consumed, even when ambiguous. --- markdown/inlinepatterns.py | 56 ++++++++++++-------- tests/test_syntax/inline/test_emphasis.py | 62 +++++++++++++++++++++++ 2 files changed, 98 insertions(+), 20 deletions(-) diff --git a/markdown/inlinepatterns.py b/markdown/inlinepatterns.py index 0f5b3ab15..b1e1e563d 100644 --- a/markdown/inlinepatterns.py +++ b/markdown/inlinepatterns.py @@ -599,7 +599,7 @@ def reset(self) -> None: # Cache info self.regions: list[tuple[int, int, int, int, int]] = [] - self.stack: deque[tuple[int, int, int]] = deque() + self.stack: deque[tuple[int, int, bool, int]] = deque() self.cache_index = 0 self.cache_pos = 0 @@ -623,7 +623,7 @@ def _build_patterns(self, token: str) -> str: ''', flags=re.UNICODE ) - elif self.double: + elif self.double: # pragma: no cover self.boundary = re.compile( fr'''(?x) (?P(? str: ''', flags=re.UNICODE ) - else: + else: # pragma: no cover # This case is not currently used self.boundary = re.compile( fr'''(?x) @@ -653,7 +653,7 @@ def _build_patterns(self, token: str) -> str: )''', flags=re.UNICODE ) - elif self.double: + elif self.double: # pragma: no cover self.boundary = re.compile( fr'''(?x) (?P(? str: ''', flags=re.UNICODE ) - else: + else: # pragma: no cover self.boundary = re.compile( fr'''(?x) (?P(? regions[start][3]: + if idx and r[0] >= regions[start][3]: idx -= 1 break # Get the appropriate element(s) @@ -789,7 +789,7 @@ def _build_element( return cast('etree.Element', el), idx - def increment_next_position(self, end: int, count: int) -> None: + def increment_next_position(self, start: int, count: int) -> None: """ Increment cache position to the next location that we can initiate an insertion. @@ -803,7 +803,7 @@ def increment_next_position(self, end: int, count: int) -> None: self.cache_pos = self.regions[self.cache_index][0] while self.stack: entry = self.stack.popleft() - if entry[0] > end: + if entry[0] > start: self.cache_pos = entry[0] break @@ -819,7 +819,7 @@ def get_cached_result(self, pos: int, data: str) -> tuple[etree.Element, int, in offset = pos - self.cache_pos start, end = regions[self.cache_index][0], regions[self.cache_index][3] el, count = self._build_element(data, self.cache_index, offset) - self.increment_next_position(end, count) + self.increment_next_position(start, count) return el, start + offset, end + offset def handleMatch( # type: ignore[override] @@ -853,7 +853,8 @@ def handleMatch( # type: ignore[override] # Data offset offset = m2.end(0) # Stack of opening delimiters - stack.append((m2.start(0), offset, len(m2.group(0)))) + is_ambiguous = m2.lastgroup[0] != 's' # type: ignore[index] + stack.append((m2.start(0), offset, is_ambiguous, len(m2.group(0)))) # Pair tokens until the stack is empty or we can no longer find tokens. while stack: @@ -869,7 +870,7 @@ def handleMatch( # type: ignore[override] # Some delimiters may be ambiguous and look like both a start or an end is_start = m2.lastgroup[0] != 'e' # type: ignore[index] is_end = not is_start or m2.lastgroup[0] != 's' # type: ignore[index] - ambiguous = is_start and is_end + is_ambiguous = is_start and is_end # Find closing tokens # Looking for: @@ -883,11 +884,12 @@ def handleMatch( # type: ignore[override] # Avoid ambiguous tokens that could be a start or an end. # Consume starts until the end token is fully consumed. # If we don't consume the entire end, see if next rule consumes it. - if is_end and ((not ambiguous and current > last) or (current == last)): + if is_end and ((not is_ambiguous and current > last) or current in (last, 3)): is_start = False # Consume previous tokens until the delimiter is consumed s = m2.start(0) + original = current while current and last <= current: delimiter = stack.pop() @@ -900,19 +902,33 @@ def handleMatch( # type: ignore[override] break last = stack[-1][-1] + # Should remainder be treated as a new start? + if original == 3 and current and is_ambiguous: + self.stack.append((m2.start(0) + regions[-1][-1], m2.end(0), False, current)) + is_end = False + # Do we still have more to consume? - is_end = current and stack and last > current + else: + is_end = current and stack and last > current # Looking for: # - `***em*` # - `***strong**` # - `**em*` - if is_end and (last == 3 or not ambiguous) and last > current: - is_start = False + if is_end and (last == 3 or not is_ambiguous) and last > current: delimiter = stack.pop() + + # Don't pair with an ambiguous opening + while stack and delimiter[-1] != 3 and delimiter[2]: + delimiter = stack.pop() + last = delimiter[-1] + if delimiter[2]: + break + + is_start = False new = last - current regions.append((delimiter[0] + new, delimiter[1], m2.start(0), offset, current)) - stack.append((delimiter[0], delimiter[0] + new, new)) + stack.append((delimiter[0], delimiter[0] + new, False, new)) # Find opening tokens if is_start: @@ -920,7 +936,7 @@ def handleMatch( # type: ignore[override] # - `*em ...*` # - `**strong ...*` # - `***em ...*` - stack.append((m2.start(0), m2.end(0), current)) + stack.append((m2.start(0), m2.end(0), is_ambiguous, current)) # Build the HTML elements if regions: @@ -928,7 +944,7 @@ def handleMatch( # type: ignore[override] regions.sort(key=lambda x: x[0]) start, end = regions[0][0], regions[0][3] el, count = self._build_element(data) - self.increment_next_position(end, count) + self.increment_next_position(start, count) return el, start, end # We failed to pair any valid start/end delimiters, avoid the parsed range next pass. diff --git a/tests/test_syntax/inline/test_emphasis.py b/tests/test_syntax/inline/test_emphasis.py index a7b3c56fa..9909b7790 100644 --- a/tests/test_syntax/inline/test_emphasis.py +++ b/tests/test_syntax/inline/test_emphasis.py @@ -222,3 +222,65 @@ def test_underscore_legacy(self): """ ).strip() ) + + def test_advanced_nesting(self): + + self.maxDiff = None + + self.assertMarkdownRenders( + textwrap.dedent( + """ + **a*bc** + + *a**b**c**d**e**f* + + ***a**b*cd**e*f*** + + ***a**b*cd*e**f*** + + ***a**b*cd*e**f*g*h*** + + ***a***bc**d*e*** + + *a**b**c**d**e**f* + + *a**b***c**d***e**f* + + *a**b***c**d***e**f** + + __a _b c__ + + _a __b __c __d __e __f_ + + ___a __b _c d__ e_ f___ + + ___a __b _c d_ e__ f___ + + ___a __b _c d_ e__ f _g_ h___ + + ___a ___b c__ d_ e___ + + _a __b__ _c __d__ _e __f__ + """ + ), + textwrap.dedent( + """ +

*abc

+

abcde**f

+

abcdef

+

abcdef

+

abcdefgh

+

abcde

+

abcde**f

+

abcde**f

+

abcd*ef

+

_a b c

+

_a __b __c __d __e _f

+

a b c d e f

+

a b c d e f

+

a b c d e f g h

+

a b c d e

+

_a b _c d _e f

+ """ + ).strip() + ) From e8dec3af6c39b5345e66a6840ade8f431018318e Mon Sep 17 00:00:00 2001 From: facelessuser Date: Thu, 10 Sep 2026 10:33:29 -0600 Subject: [PATCH 11/34] Separate cache info defines from reset --- markdown/inlinepatterns.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/markdown/inlinepatterns.py b/markdown/inlinepatterns.py index b1e1e563d..f14c48aeb 100644 --- a/markdown/inlinepatterns.py +++ b/markdown/inlinepatterns.py @@ -587,6 +587,13 @@ def __init__( """ + # Cache info + self.regions: list[tuple[int, int, int, int, int]] = [] + self.stack: deque[tuple[int, int, bool, int]] = deque() + self.cache_index = 0 + self.cache_pos = 0 + self.cache_legacy_pos = -1 + self.last_run = 0.0 self.smart = smart self.tags = tags.split(',') @@ -597,9 +604,8 @@ def __init__( def reset(self) -> None: """Rest.""" - # Cache info - self.regions: list[tuple[int, int, int, int, int]] = [] - self.stack: deque[tuple[int, int, bool, int]] = deque() + self.regions.clear() + self.stack.clear() self.cache_index = 0 self.cache_pos = 0 From b9ffc0d262b1ba789d474b4ff1c58f7c54f6886b Mon Sep 17 00:00:00 2001 From: facelessuser Date: Thu, 10 Sep 2026 10:34:52 -0600 Subject: [PATCH 12/34] Fix lint --- markdown/inlinepatterns.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/markdown/inlinepatterns.py b/markdown/inlinepatterns.py index f14c48aeb..14d2f5540 100644 --- a/markdown/inlinepatterns.py +++ b/markdown/inlinepatterns.py @@ -859,7 +859,7 @@ def handleMatch( # type: ignore[override] # Data offset offset = m2.end(0) # Stack of opening delimiters - is_ambiguous = m2.lastgroup[0] != 's' # type: ignore[index] + is_ambiguous = m2.lastgroup[0] != 's' # type: ignore[index] stack.append((m2.start(0), offset, is_ambiguous, len(m2.group(0)))) # Pair tokens until the stack is empty or we can no longer find tokens. @@ -926,7 +926,7 @@ def handleMatch( # type: ignore[override] # Don't pair with an ambiguous opening while stack and delimiter[-1] != 3 and delimiter[2]: - delimiter = stack.pop() + delimiter = stack.pop() last = delimiter[-1] if delimiter[2]: break From 935733f132fb429b6a10410c10b390ef09ced1fd Mon Sep 17 00:00:00 2001 From: facelessuser Date: Thu, 10 Sep 2026 11:00:46 -0600 Subject: [PATCH 13/34] Fix a possible scenario where the next region is contiguous --- markdown/inlinepatterns.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/markdown/inlinepatterns.py b/markdown/inlinepatterns.py index 14d2f5540..37e78d3bc 100644 --- a/markdown/inlinepatterns.py +++ b/markdown/inlinepatterns.py @@ -822,7 +822,7 @@ def get_cached_result(self, pos: int, data: str) -> tuple[etree.Element, int, in # Process the next region(s) in the cache regions = self.regions - offset = pos - self.cache_pos + offset = pos - self.cache_pos if pos != self.cache_pos else pos - regions[self.cache_index][0] start, end = regions[self.cache_index][0], regions[self.cache_index][3] el, count = self._build_element(data, self.cache_index, offset) self.increment_next_position(start, count) From f5145af9acc586d6c569994dc191c9fac57c086b Mon Sep 17 00:00:00 2001 From: facelessuser Date: Thu, 10 Sep 2026 14:03:35 -0600 Subject: [PATCH 14/34] Rework how resetting is performed --- markdown/core.py | 10 +++++++-- markdown/inlinepatterns.py | 10 ++------- tests/test_syntax/inline/test_emphasis.py | 27 +++++++++++++++++++++++ 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/markdown/core.py b/markdown/core.py index fd946d54f..eab4e57b0 100644 --- a/markdown/core.py +++ b/markdown/core.py @@ -29,7 +29,7 @@ from .preprocessors import build_preprocessors from .blockprocessors import build_block_parser from .treeprocessors import build_treeprocessors -from .inlinepatterns import build_inlinepatterns +from .inlinepatterns import build_inlinepatterns, DelimiterProcessor from .postprocessors import build_postprocessors from .extensions import Extension from .serializers import to_html_string, to_xhtml_string @@ -120,6 +120,7 @@ def __init__(self, **kwargs): self.registeredExtensions: list[Extension] = [] self.docType = "" # TODO: Maybe delete this. It does not appear to be used anymore. self.stripTopLevelTags: bool = True + self.delimiters: dict[str, DelimiterProcessor] = {} self.build_parser() @@ -269,10 +270,15 @@ def reset(self) -> Markdown: Called once upon creation of a class instance. Should be called manually between calls to [`Markdown.convert`][markdown.Markdown.convert]. """ - self.last_run = time.time() self.htmlStash.reset() self.references.clear() + for key in list(self.delimiters): + ext = self.delimiters[key] + ext.reset() + if ext not in self.inlinePatterns: + del self.delimiters[key] + for extension in self.registeredExtensions: if hasattr(extension, 'reset'): extension.reset() diff --git a/markdown/inlinepatterns.py b/markdown/inlinepatterns.py index 37e78d3bc..f123b4463 100644 --- a/markdown/inlinepatterns.py +++ b/markdown/inlinepatterns.py @@ -570,7 +570,7 @@ def __init__( self, token: str, tags: str, - md: Markdown | None = None, + md: Markdown, smart: bool = False, double: bool = False ) -> None: @@ -588,17 +588,16 @@ def __init__( """ # Cache info + md.delimiters[token] = self self.regions: list[tuple[int, int, int, int, int]] = [] self.stack: deque[tuple[int, int, bool, int]] = deque() self.cache_index = 0 self.cache_pos = 0 - self.cache_legacy_pos = -1 self.last_run = 0.0 self.smart = smart self.tags = tags.split(',') self.double = len(tags) != 2 and double - self.reset() super().__init__(self._build_patterns(token), md) def reset(self) -> None: @@ -835,11 +834,6 @@ def handleMatch( # type: ignore[override] ) -> tuple[etree.Element | None, int | None, int | None]: """Parse delimiter pattern.""" - # We are in a new run. Reset just in case we were somehow left in a bad state. - if self.md.last_run != self.last_run: - self.last_run = self.md.last_run - self.reset() - # Do we have entries we haven't returned yet? if self.regions: return self.get_cached_result(m.start(0), data) diff --git a/tests/test_syntax/inline/test_emphasis.py b/tests/test_syntax/inline/test_emphasis.py index 9909b7790..8b3f4d086 100644 --- a/tests/test_syntax/inline/test_emphasis.py +++ b/tests/test_syntax/inline/test_emphasis.py @@ -284,3 +284,30 @@ def test_advanced_nesting(self): """ ).strip() ) + + +class TestProcessorRemoval(TestCase): + + def test_remove_processor(self): + + import markdown + from markdown.inlinepatterns import DelimiterProcessor + + # Remove all delimeter processors + md = markdown.Markdown() + + extensions = md.delimiters.values() + self.assertEqual(len(extensions), 2) + + for ext in md.delimiters.values(): + self.assertTrue(isinstance(ext, DelimiterProcessor)) + + md.inlinePatterns.deregister('em_strong') + md.inlinePatterns.deregister('em_strong2') + + # Call reset which will cause them to remove themselves from being registered + md.reset() + + extensions = md.delimiters.values() + self.assertEqual(len(extensions), 0) + From f8d87d26cd75d710769adb139669441c4714fca8 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Thu, 10 Sep 2026 14:05:19 -0600 Subject: [PATCH 15/34] Remove unused module --- markdown/core.py | 1 - tests/test_syntax/inline/test_emphasis.py | 1 - 2 files changed, 2 deletions(-) diff --git a/markdown/core.py b/markdown/core.py index eab4e57b0..a04510c03 100644 --- a/markdown/core.py +++ b/markdown/core.py @@ -20,7 +20,6 @@ from __future__ import annotations import codecs -import time import sys import logging import importlib diff --git a/tests/test_syntax/inline/test_emphasis.py b/tests/test_syntax/inline/test_emphasis.py index 8b3f4d086..1d8897a2b 100644 --- a/tests/test_syntax/inline/test_emphasis.py +++ b/tests/test_syntax/inline/test_emphasis.py @@ -310,4 +310,3 @@ def test_remove_processor(self): extensions = md.delimiters.values() self.assertEqual(len(extensions), 0) - From 53aca45e66395e3621e268aa84929a6b4e7563bd Mon Sep 17 00:00:00 2001 From: facelessuser Date: Thu, 10 Sep 2026 14:07:51 -0600 Subject: [PATCH 16/34] Spelling --- tests/test_syntax/inline/test_emphasis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_syntax/inline/test_emphasis.py b/tests/test_syntax/inline/test_emphasis.py index 1d8897a2b..750c93d77 100644 --- a/tests/test_syntax/inline/test_emphasis.py +++ b/tests/test_syntax/inline/test_emphasis.py @@ -293,7 +293,7 @@ def test_remove_processor(self): import markdown from markdown.inlinepatterns import DelimiterProcessor - # Remove all delimeter processors + # Remove all delimiter processors md = markdown.Markdown() extensions = md.delimiters.values() From 701a666c80fd7f3c9800b74b41c883bd3fc490c9 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Thu, 10 Sep 2026 15:57:09 -0600 Subject: [PATCH 17/34] Use test harness dedent --- tests/test_syntax/inline/test_emphasis.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/tests/test_syntax/inline/test_emphasis.py b/tests/test_syntax/inline/test_emphasis.py index 750c93d77..eae15889e 100644 --- a/tests/test_syntax/inline/test_emphasis.py +++ b/tests/test_syntax/inline/test_emphasis.py @@ -20,7 +20,6 @@ """ from markdown.test_tools import TestCase -import textwrap class TestNotEmphasis(TestCase): @@ -196,7 +195,7 @@ def test_link_emphasis_inner_outer(self): def test_underscore_legacy(self): self.assertMarkdownRenders( - textwrap.dedent( + self.dedent( """ THIS_SHOULD_STAY_AS_IS @@ -211,7 +210,7 @@ def test_underscore_legacy(self): THIS___SHOULD___STAY? """ ), - textwrap.dedent( + self.dedent( """

THIS_SHOULD_STAY_AS_IS

Here is some emphasis, ok?

@@ -220,15 +219,13 @@ def test_underscore_legacy(self):

Here is some strong stuff.

THIS___SHOULD___STAY?

""" - ).strip() + ) ) def test_advanced_nesting(self): - self.maxDiff = None - self.assertMarkdownRenders( - textwrap.dedent( + self.dedent( """ **a*bc** @@ -263,7 +260,7 @@ def test_advanced_nesting(self): _a __b__ _c __d__ _e __f__ """ ), - textwrap.dedent( + self.dedent( """

*abc

abcde**f

@@ -282,7 +279,7 @@ def test_advanced_nesting(self):

a b c d e

_a b _c d _e f

""" - ).strip() + ) ) From 366508708d2c74cfca1a44619f281805b00c0819 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Fri, 11 Sep 2026 18:36:39 -0600 Subject: [PATCH 18/34] Handle emphasis token chains longer than 3 - When consuming a chain of tokens greater than 2, consume in increments of 2 until all are consumed or 1 is left, and then consume 1. This means that the previous assumption that triple tokens yield `` is now ``. While the syntax is different the logic is the same. This is a necessary change in order to properly consume tokens. - Small clean up of pattern building - Add common mark tests --- markdown/inlinepatterns.py | 131 ++-- tests/basic/strong-and-em-together.html | 4 - tests/basic/strong-and-em-together.txt | 7 - tests/misc/em_strong_complex.html | 14 - tests/misc/em_strong_complex.txt | 27 - tests/misc/nested-patterns.html | 10 - tests/misc/nested-patterns.txt | 13 - tests/test_syntax/blocks/test_hr.py | 2 +- .../test_syntax/extensions/test_legacy_em.py | 4 +- tests/test_syntax/inline/test_emphasis.py | 573 +++++++++++++++++- 10 files changed, 625 insertions(+), 160 deletions(-) delete mode 100644 tests/basic/strong-and-em-together.html delete mode 100644 tests/basic/strong-and-em-together.txt delete mode 100644 tests/misc/em_strong_complex.html delete mode 100644 tests/misc/em_strong_complex.txt delete mode 100644 tests/misc/nested-patterns.html delete mode 100644 tests/misc/nested-patterns.txt diff --git a/markdown/inlinepatterns.py b/markdown/inlinepatterns.py index f123b4463..bd35fa7c4 100644 --- a/markdown/inlinepatterns.py +++ b/markdown/inlinepatterns.py @@ -616,66 +616,33 @@ def _build_patterns(self, token: str) -> str: etoken = re.escape(token) avoid_start = fr'(?:(?<=_)|(?(?(?{avoid_start}{etoken}{{1,3}}(?![\s{etoken}])(?!$)) - ''', - flags=re.UNICODE - ) - elif self.double: # pragma: no cover - self.boundary = re.compile( - fr'''(?x) - (?P(?(?{avoid_start}{etoken}{{2}}(?![\s{etoken}])(?!$)) - ''', - flags=re.UNICODE - ) - else: # pragma: no cover - # This case is not currently used - self.boundary = re.compile( - fr'''(?x) - (?P(?(?{avoid_start}{etoken}{{1}}(?![\s{etoken}])(?!$)) - ''', - flags=re.UNICODE - ) + self.boundary = re.compile( + fr'''(?x) + (?P(?(?{avoid_start}{etoken}{{{n}}}(?![\s{etoken}])(?!$)) + ''', + flags=re.UNICODE + ) # Patterns for "dumb" cases. else: - if len(self.tags) == 2: - self.boundary = re.compile( - fr'''(?x)(?: - (?P(?(?{etoken}{{1,3}}(?![\s{etoken}])(?!$)) - )''', - flags=re.UNICODE - ) - elif self.double: # pragma: no cover - self.boundary = re.compile( - fr'''(?x) - (?P(?(?{etoken}{{2}}(?![\s{etoken}])(?!$)) - ''', - flags=re.UNICODE - ) - else: # pragma: no cover - self.boundary = re.compile( - fr'''(?x) - (?P(?(?{etoken}{{1}}(?![\s{etoken}])(?!$)) - ''', - flags=re.UNICODE - ) + self.boundary = re.compile( + fr'''(?x)(?: + (?P(?(?{etoken}{{{n}}}(?![\s{etoken}])(?!$)) + )''', + flags=re.UNICODE + ) return fr'{etoken}' @@ -718,8 +685,8 @@ def _build_element( break # Get the appropriate element(s) if r[4] == 3: - el1 = etree.Element(greater) - el2 = etree.Element(lesser) + el1 = etree.Element(lesser) + el2 = etree.Element(greater) elif r[4] == 2: el1 = etree.Element(greater) el2 = None @@ -850,18 +817,19 @@ def handleMatch( # type: ignore[override] stack = self.stack regions = self.regions - # Data offset - offset = m2.end(0) - # Stack of opening delimiters + start = m2.start(0) + end = m2.end(0) + length = end - start is_ambiguous = m2.lastgroup[0] != 's' # type: ignore[index] - stack.append((m2.start(0), offset, is_ambiguous, len(m2.group(0)))) + self.stack.append((start, start + length, is_ambiguous, length)) # Pair tokens until the stack is empty or we can no longer find tokens. while stack: - m2 = self.boundary.search(data, offset) + m2 = self.boundary.search(data, end) if m2 is None: break - offset = m2.end(0) + start = m2.start(0) + end = m2.end(0) # Get current and last delimiter size current = len(m2.group(0)) @@ -884,27 +852,33 @@ def handleMatch( # type: ignore[override] # Avoid ambiguous tokens that could be a start or an end. # Consume starts until the end token is fully consumed. # If we don't consume the entire end, see if next rule consumes it. - if is_end and ((not is_ambiguous and current > last) or current in (last, 3)): + if ( + is_end and + ((not is_ambiguous and current > last) or (current <= 3 and current == last) or current >= 3) + ): is_start = False # Consume previous tokens until the delimiter is consumed - s = m2.start(0) original = current while current and last <= current: delimiter = stack.pop() # Build up region for pair and adjust accounting. - regions.append((delimiter[0], delimiter[1], s, s + delimiter[-1], delimiter[-1])) - s += delimiter[-1] - current -= delimiter[-1] + size = min(delimiter[-1], 2) + regions.append((delimiter[1] - size, delimiter[1], start, start + size, size)) + start += size + current -= size + if size < delimiter[-1]: + new = delimiter[-1] - size + stack.append((delimiter[0], delimiter[1] - size, delimiter[2], new)) if not stack: is_end = False break last = stack[-1][-1] # Should remainder be treated as a new start? - if original == 3 and current and is_ambiguous: - self.stack.append((m2.start(0) + regions[-1][-1], m2.end(0), False, current)) + if original >= 3 and current and is_ambiguous: + self.stack.append((regions[-1][3], end, False, current)) is_end = False # Do we still have more to consume? @@ -915,7 +889,7 @@ def handleMatch( # type: ignore[override] # - `***em*` # - `***strong**` # - `**em*` - if is_end and (last == 3 or not is_ambiguous) and last > current: + if is_end and (last >= 3 or not is_ambiguous) and last > current: delimiter = stack.pop() # Don't pair with an ambiguous opening @@ -926,9 +900,16 @@ def handleMatch( # type: ignore[override] break is_start = False - new = last - current - regions.append((delimiter[0] + new, delimiter[1], m2.start(0), offset, current)) - stack.append((delimiter[0], delimiter[0] + new, False, new)) + ds, de = delimiter[:2] + while current: + size = min(current, 2) + new = last - size + regions.append((ds + new, de, start, start + size, size)) + start += size + current -= size + last -= size + de -= size + stack.append((ds, de, False, last)) # Find opening tokens if is_start: @@ -936,7 +917,7 @@ def handleMatch( # type: ignore[override] # - `*em ...*` # - `**strong ...*` # - `***em ...*` - stack.append((m2.start(0), m2.end(0), is_ambiguous, current)) + stack.append((start, end, is_ambiguous, current)) # Build the HTML elements if regions: diff --git a/tests/basic/strong-and-em-together.html b/tests/basic/strong-and-em-together.html deleted file mode 100644 index 7bf5163e7..000000000 --- a/tests/basic/strong-and-em-together.html +++ /dev/null @@ -1,4 +0,0 @@ -

This is strong and em.

-

So is this word.

-

This is strong and em.

-

So is this word.

\ No newline at end of file diff --git a/tests/basic/strong-and-em-together.txt b/tests/basic/strong-and-em-together.txt deleted file mode 100644 index 95ee690db..000000000 --- a/tests/basic/strong-and-em-together.txt +++ /dev/null @@ -1,7 +0,0 @@ -***This is strong and em.*** - -So is ***this*** word. - -___This is strong and em.___ - -So is ___this___ word. diff --git a/tests/misc/em_strong_complex.html b/tests/misc/em_strong_complex.html deleted file mode 100644 index 65faddfad..000000000 --- a/tests/misc/em_strong_complex.html +++ /dev/null @@ -1,14 +0,0 @@ -

test test test test

-

test test test test

-

test

-

test

-

test test_

-

test test

-

test_test test_test

-

test test test test

-

test test test test

-

*test

-

test

-

test*

-

test test

-

testtest testtest

\ No newline at end of file diff --git a/tests/misc/em_strong_complex.txt b/tests/misc/em_strong_complex.txt deleted file mode 100644 index 042597184..000000000 --- a/tests/misc/em_strong_complex.txt +++ /dev/null @@ -1,27 +0,0 @@ -___test test__ test test_ - -___test test_ test test__ - -___test___ - -__test__ - -___test_ test___ - -___test_ test__ - -_test_test test_test_ - -***test test** test test* - -***test test* test test** - -**test* - -***test*** - -**test*** - -***test* test** - -*test*test test*test* \ No newline at end of file diff --git a/tests/misc/nested-patterns.html b/tests/misc/nested-patterns.html deleted file mode 100644 index 1c7bb43c6..000000000 --- a/tests/misc/nested-patterns.html +++ /dev/null @@ -1,10 +0,0 @@ -

link -link -link -link -link -link -link

-

I am italic and bold I am just bold

-

Example bold italic on the same line bold italic.

-

Example bold italic on the same line bold italic.

\ No newline at end of file diff --git a/tests/misc/nested-patterns.txt b/tests/misc/nested-patterns.txt deleted file mode 100644 index 9032cf131..000000000 --- a/tests/misc/nested-patterns.txt +++ /dev/null @@ -1,13 +0,0 @@ -___[link](http://example.com)___ -***[link](http://example.com)*** -**[*link*](http://example.com)** -__[_link_](http://example.com)__ -__[*link*](http://example.com)__ -**[_link_](http://example.com)** -[***link***](http://example.com) - -***I am ___italic_ and__ bold* I am `just` bold** - -Example __*bold italic*__ on the same line __*bold italic*__. - -Example **_bold italic_** on the same line **_bold italic_**. diff --git a/tests/test_syntax/blocks/test_hr.py b/tests/test_syntax/blocks/test_hr.py index a8753977b..5aa48465b 100644 --- a/tests/test_syntax/blocks/test_hr.py +++ b/tests/test_syntax/blocks/test_hr.py @@ -318,7 +318,7 @@ def test_hr_after_emstrong(self): ), self.dedent( """ -

text

+

text


""" ) diff --git a/tests/test_syntax/extensions/test_legacy_em.py b/tests/test_syntax/extensions/test_legacy_em.py index ad2862e5e..a1a9f2e5a 100644 --- a/tests/test_syntax/extensions/test_legacy_em.py +++ b/tests/test_syntax/extensions/test_legacy_em.py @@ -55,12 +55,12 @@ def test_complex_multple_underscore_type(self): self.assertMarkdownRenders( 'traced ___along___ bla __blocked__ if other ___or___', - '

traced along bla blocked if other or

' # noqa: E501 + '

traced along bla blocked if other or

' # noqa: E501 ) def test_complex_multple_underscore_type_variant2(self): self.assertMarkdownRenders( 'on the __1-4 row__ of the AP Combat Table ___and___ receive', - '

on the 1-4 row of the AP Combat Table and receive

' + '

on the 1-4 row of the AP Combat Table and receive

' ) diff --git a/tests/test_syntax/inline/test_emphasis.py b/tests/test_syntax/inline/test_emphasis.py index eae15889e..127d2c78f 100644 --- a/tests/test_syntax/inline/test_emphasis.py +++ b/tests/test_syntax/inline/test_emphasis.py @@ -161,14 +161,14 @@ def test_complex_multple_emphasis_type(self): self.assertMarkdownRenders( 'traced ***along*** bla **blocked** if other ***or***', - '

traced along bla blocked if other or

' # noqa: E501 + '

traced along bla blocked if other or

' # noqa: E501 ) def test_complex_multple_emphasis_type_variant2(self): self.assertMarkdownRenders( 'on the **1-4 row** of the AP Combat Table ***and*** receive', - '

on the 1-4 row of the AP Combat Table and receive

' + '

on the 1-4 row of the AP Combat Table and receive

' ) def test_link_emphasis_outer(self): @@ -222,6 +222,120 @@ def test_underscore_legacy(self): ) ) + def test_nested_patterns(self): + + self.assertMarkdownRenders( + self.dedent( + """ + ___[link](http://example.com)___ + ***[link](http://example.com)*** + **[*link*](http://example.com)** + __[_link_](http://example.com)__ + __[*link*](http://example.com)__ + **[_link_](http://example.com)** + [***link***](http://example.com) + + ***I am ___italic_ and__ bold* I am `just` bold** + + Example __*bold italic*__ on the same line __*bold italic*__. + + Example **_bold italic_** on the same line **_bold italic_**. + """ + ), + self.dedent( + """ +

link + link + link + link + link + link + link

+

I am italic and bold I am just bold

+

Example bold italic on the same line bold italic.

+

Example bold italic on the same line bold italic.

+ """ # noqa: E501 + ) + ) + + def test_em_strong_complex(self): + + self.assertMarkdownRenders( + self.dedent( + """ + ___test test__ test test_ + + ___test test_ test test__ + + ___test___ + + __test__ + + ___test_ test___ + + ___test_ test__ + + _test_test test_test_ + + ***test test** test test* + + ***test test* test test** + + **test* + + ***test*** + + **test*** + + ***test* test** + + *test*test test*test* + """ + ), + self.dedent( + """ +

test test test test

+

test test test test

+

test

+

test

+

test test_

+

test test

+

test_test test_test

+

test test test test

+

test test test test

+

*test

+

test

+

test*

+

test test

+

testtest testtest

+ """ + ) + ) + + def test_strong_and_em_together(self): + + self.assertMarkdownRenders( + self.dedent( + """ + ***This is strong and em.*** + + So is ***this*** word. + + ___This is strong and em.___ + + So is ___this___ word. + """ + ), + self.dedent( + """ +

This is strong and em.

+

So is this word.

+

This is strong and em.

+

So is this word.

+ """ + ) + ) + def test_advanced_nesting(self): self.assertMarkdownRenders( @@ -267,22 +381,467 @@ def test_advanced_nesting(self):

abcdef

abcdef

abcdefgh

-

abcde

+

abcde

abcde**f

abcde**f

abcd*ef

_a b c

_a __b __c __d __e _f

-

a b c d e f

-

a b c d e f

-

a b c d e f g h

-

a b c d e

+

a b c d e f

+

a b c d e f

+

a b c d e f g h

+

a b c d e

_a b _c d _e f

""" ) ) +class TestCommonMark(TestCase): + """Test CommonMark.""" + + def test_commonmark(self): + """Test CommonMark.""" + + self.maxDiff = None + + self.assertMarkdownRenders( + self.dedent( + R""" + *foo bar* + + a * foo bar* + + + + + + + test * a * + + foo*bar* + + 5*6*78 + + _foo bar_ + + _ foo bar_ + + a_"foo"_ + + foo_bar_ + + 5_6_78 + + пристаням_стремятся_ + + aa_"bb"_cc + + foo-_(bar)_ + + _foo* + + *foo bar * + + *foo bar + * + + + + *(*foo*)* + + *foo*bar + + _foo bar _ + + _(_foo) + + _(_foo_)_ + + _foo_bar + + _пристаням_стремятся + + _foo_bar_baz_ + + _(bar)_. + + **foo bar** + + ** foo bar** + + + + foo**bar** + + __foo bar__ + + __ foo bar__ + + __ + foo bar__ + + a__"foo"__ + + foo__bar__ + + 5__6__78 + + пристаням__стремятся__ + + __foo, __bar__, baz__ + + foo-__(bar)__ + + **foo bar ** + + + + *(**foo**)* + + **Gomphocarpus (*Gomphocarpus physocarpus*, syn. + *Asclepias physocarpa*)** + + **foo "*bar*" foo** + + **foo**bar + + __foo bar __ + + __(__foo) + + _(__foo__)_ + + __foo__bar + + __пристаням__стремятся + + __foo__bar__baz__ + + __(bar)__. + + *foo [bar](/url)* + + *foo + bar* + + _foo __bar__ baz_ + + _foo _bar_ baz_ + + __foo_ bar_ + + *foo *bar** + + *foo **bar** baz* + + *foo**bar**baz* + + *foo**bar* + + ***foo** bar* + + *foo **bar*** + + *foo**bar*** + + foo***bar***baz + + foo******bar*********baz + + *foo **bar *baz* bim** bop* + + *foo [*bar*](/url)* + + ** is not an empty emphasis + + **** is not an empty strong emphasis + + **foo [bar](/url)** + + **foo + bar** + + __foo _bar_ baz__ + + __foo __bar__ baz__ + + ____foo__ bar__ + + **foo **bar**** + + **foo *bar* baz** + + **foo*bar*baz** + + ***foo* bar** + + **foo *bar*** + + **foo *bar **baz** + bim* bop** + + **foo [*bar*](/url)** + + __ is not an empty emphasis + + ____ is not an empty strong emphasis + + foo *** + + foo *\** + + foo *_* + + foo ***** + + foo **\*** + + foo **_** + + **foo* + + *foo** + + ***foo** + + ****foo* + + **foo*** + + *foo**** + + foo ___ + + foo _\__ + + foo _*_ + + foo _____ + + foo __\___ + + foo __*__ + + __foo_ + + _foo__ + + ___foo__ + + ____foo_ + + __foo___ + + _foo____ + + **foo** + + *_foo_* + + __foo__ + + _*foo*_ + + ****foo**** + + ____foo____ + + ******foo****** + + ***foo*** + + _____foo_____ + + *foo _bar* baz_ + + + + **foo **bar baz** + + *foo *bar baz* + + *[bar*](/url) + + _foo [bar_](/url) + + * + + ** + + __ + + *a `*`* + + _a `_`_ + + **a + + __a + """ + ), + self.dedent( + """ +

foo bar

+

a * foo bar*

+ + + + + +

test * a *

+

foobar

+

5678

+

foo bar

+

_ foo bar_

+

a_"foo"_

+

foo_bar_

+

5_6_78

+

пристаням_стремятся_

+

aa_"bb"_cc

+

foo-(bar)

+

_foo*

+

*foo bar *

+

*foo bar + *

+ + +

(foo)

+

foobar

+

_foo bar _

+

_(_foo)

+

(foo)

+

_foo_bar

+

_пристаням_стремятся

+

foo_bar_baz

+

(bar).

+

foo bar

+

** foo bar**

+ + +

foobar

+

foo bar

+

__ foo bar__

+

__ + foo bar__

+

a__"foo"__

+

foo__bar__

+

5__6__78

+

пристаням__стремятся__

+

foo, bar, baz

+

foo-(bar)

+

**foo bar **

+ + +

(foo)

+

Gomphocarpus (Gomphocarpus physocarpus, syn. + Asclepias physocarpa)

+

foo "bar" foo

+

foobar

+

__foo bar __

+

__(__foo)

+

(foo)

+

__foo__bar

+

__пристаням__стремятся

+

foo__bar__baz

+

(bar).

+

foo bar

+

foo + bar

+

foo bar baz

+

foo bar baz

+

foo bar

+

foo bar

+

foo bar baz

+

foobarbaz

+

foo**bar

+

foo bar

+

foo bar

+

foobar

+

foobarbaz

+

foobar***baz

+

foo bar baz bim bop

+

foo bar

+

** is not an empty emphasis

+

**** is not an empty strong emphasis

+

foo bar

+

foo + bar

+

foo bar baz

+

foo bar baz

+

foo bar

+

foo bar

+

foo bar baz

+

foobarbaz

+

foo bar

+

foo bar

+

foo bar baz + bim bop

+

foo bar

+

__ is not an empty emphasis

+

____ is not an empty strong emphasis

+

foo ***

+

foo *

+

foo _

+

foo *****

+

foo *

+

foo _

+

*foo

+

foo*

+

*foo

+

***foo

+

foo*

+

foo***

+

foo ___

+

foo _

+

foo *

+

foo _____

+

foo _

+

foo *

+

_foo

+

foo_

+

_foo

+

___foo

+

foo_

+

foo___

+

foo

+

foo

+

foo

+

foo

+

foo

+

foo

+

foo

+

foo

+

foo

+

foo _bar baz_

+ + +

**foo bar baz

+

*foo bar baz

+

*bar*

+

_foo bar_

+

*

+

**

+

__

+

a *

+

a _

+

**ahttps://foo.bar/?q=**

+

__ahttps://foo.bar/?q=__

+ """ + ) + ) + + class TestProcessorRemoval(TestCase): def test_remove_processor(self): From 2ed5eca5b0d7f4324e1d151ec657dc1ea8b87182 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Fri, 11 Sep 2026 19:45:00 -0600 Subject: [PATCH 19/34] Ignore redundant tests --- tests/test_legacy.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_legacy.py b/tests/test_legacy.py index df4da4d71..808def4c6 100644 --- a/tests/test_legacy.py +++ b/tests/test_legacy.py @@ -91,7 +91,7 @@ class TestPl2004(LegacyTestCase): location = os.path.join(parent_test_dir, 'pl/Tests_2004') normalize = True input_ext = '.text' - exclude = ['Yuri_Footnotes', 'Yuri_Attributes'] + exclude = ['Yuri_Footnotes', 'Yuri_Attributes', 'Strong_and_em_together'] class TestPl2007(LegacyTestCase): @@ -116,7 +116,8 @@ class TestPl2007(LegacyTestCase): 'Code_Blocks', 'Links,_reference_style', 'Backslash_escapes', - 'Code_Spans' + 'Code_Spans', + 'Strong_and_em_together' ] From 345402e8f3fe39e7d6b68ba4a888b5d3deb3d6c3 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Sat, 12 Sep 2026 05:49:04 -0600 Subject: [PATCH 20/34] Remove dead code When building elements, we no longer need to account for regions of 3 tokens as they are always broken up into regions less than 3. --- markdown/inlinepatterns.py | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/markdown/inlinepatterns.py b/markdown/inlinepatterns.py index bd35fa7c4..f30d6deb4 100644 --- a/markdown/inlinepatterns.py +++ b/markdown/inlinepatterns.py @@ -661,7 +661,6 @@ def _build_element( greater: Any = None lesser: Any = None - triple = set() outer: list[etree.Element] = [] outer_r: list[tuple[int, int, int, int, int]] = [] @@ -684,15 +683,10 @@ def _build_element( idx -= 1 break # Get the appropriate element(s) - if r[4] == 3: - el1 = etree.Element(lesser) - el2 = etree.Element(greater) - elif r[4] == 2: + if r[4] == 2: el1 = etree.Element(greater) - el2 = None else: el1 = etree.Element(lesser) - el2 = None # Populate the elements with their text if idx > 1: @@ -722,10 +716,6 @@ def _build_element( outer.pop() outer_r.pop() - # Double nested element (triple token) - if outer[-1] in triple: - outer[-1][-1].append(el1) - # Non-nested else: outer[-1].append(el1) @@ -739,14 +729,6 @@ def _build_element( # Track the last element we parsed. last = el1 - # Nest secondary element if there is one. - # Track triple tokens (double elements) - # so we can identify quickly and properly nest. - if el2 is not None: - el1.append(el2) - last = el2 - triple.add(el1) - # Track the previous region. previous = r From b0eedf5d8968724a00a1b48b03733b6554f46590 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Sat, 12 Sep 2026 09:55:06 -0600 Subject: [PATCH 21/34] Fixes for if just a double or single token is every used We should grab all tokens, regardless of whether of what size we are looking for. Chunk large spans accordingly a delimiter that only requires single delimiters should chunk by one, all others by two. Limit adding new starting spans if a delimiter only requires double tokens. Also fix our double flag logic. --- markdown/inlinepatterns.py | 38 ++++++++++++++++++-------------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/markdown/inlinepatterns.py b/markdown/inlinepatterns.py index f30d6deb4..87b24e362 100644 --- a/markdown/inlinepatterns.py +++ b/markdown/inlinepatterns.py @@ -597,7 +597,7 @@ def __init__( self.last_run = 0.0 self.smart = smart self.tags = tags.split(',') - self.double = len(tags) != 2 and double + self.double = len(self.tags) != 2 and double super().__init__(self._build_patterns(token), md) def reset(self) -> None: @@ -616,20 +616,17 @@ def _build_patterns(self, token: str) -> str: etoken = re.escape(token) avoid_start = fr'(?:(?<=_)|(?(?(?{avoid_start}{etoken}{{{n}}}(?![\s{etoken}])(?!$)) + (?P(?(?{avoid_start}{etoken}{{1,}}(?![\s{etoken}])(?!$)) ''', flags=re.UNICODE ) @@ -637,9 +634,9 @@ def _build_patterns(self, token: str) -> str: else: self.boundary = re.compile( fr'''(?x)(?: - (?P(?(?{etoken}{{{n}}}(?![\s{etoken}])(?!$)) + (?P(?(?{etoken}{{1,}}(?![\s{etoken}])(?!$)) )''', flags=re.UNICODE ) @@ -836,7 +833,7 @@ def handleMatch( # type: ignore[override] # If we don't consume the entire end, see if next rule consumes it. if ( is_end and - ((not is_ambiguous and current > last) or (current <= 3 and current == last) or current >= 3) + ((not is_ambiguous and current > last) or current == last or current >= 3) ): is_start = False @@ -846,11 +843,11 @@ def handleMatch( # type: ignore[override] delimiter = stack.pop() # Build up region for pair and adjust accounting. - size = min(delimiter[-1], 2) + size = min(delimiter[-1], self.max_size) regions.append((delimiter[1] - size, delimiter[1], start, start + size, size)) start += size current -= size - if size < delimiter[-1]: + if size < delimiter[-1] and (not self.double or (delimiter[-1] - size) != 1): new = delimiter[-1] - size stack.append((delimiter[0], delimiter[1] - size, delimiter[2], new)) if not stack: @@ -883,18 +880,19 @@ def handleMatch( # type: ignore[override] is_start = False ds, de = delimiter[:2] - while current: - size = min(current, 2) + while current and (not self.double or current != 1): + size = min(current, self.max_size) new = last - size regions.append((ds + new, de, start, start + size, size)) start += size current -= size last -= size de -= size - stack.append((ds, de, False, last)) + if not self.double or last > 1: + stack.append((ds, de, False, last)) # Find opening tokens - if is_start: + if is_start and (not self.double or current != 1): # Looking for: # - `*em ...*` # - `**strong ...*` From beec4268043532dcba4395c5a594d97e593fc4ef Mon Sep 17 00:00:00 2001 From: facelessuser Date: Sat, 12 Sep 2026 13:38:32 -0600 Subject: [PATCH 22/34] A double width only delimiter processor could fail on single width token A delimiter processor that requires only double length tokens could fail on single width start --- markdown/inlinepatterns.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/markdown/inlinepatterns.py b/markdown/inlinepatterns.py index 87b24e362..c387ca952 100644 --- a/markdown/inlinepatterns.py +++ b/markdown/inlinepatterns.py @@ -799,6 +799,11 @@ def handleMatch( # type: ignore[override] start = m2.start(0) end = m2.end(0) length = end - start + + # Double needs at least a size of 2 + if self.double and length < 2: + return None, m.start(0), m.end(0) + is_ambiguous = m2.lastgroup[0] != 's' # type: ignore[index] self.stack.append((start, start + length, is_ambiguous, length)) From f961e435ab382fab44340872e0519ffef9f53863 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Sat, 12 Sep 2026 14:50:46 -0600 Subject: [PATCH 23/34] Avoid flipping strong/em tag nesting for triple emphasis tokens - This ensures Python Markdown behavior of nested strong/em tags, remain as they were prior to this change, ensuring the the absolute minimal difference between tested cases. --- markdown/inlinepatterns.py | 6 ++- tests/test_legacy.py | 5 +-- tests/test_syntax/blocks/test_hr.py | 2 +- .../test_syntax/extensions/test_legacy_em.py | 4 +- tests/test_syntax/inline/test_emphasis.py | 42 ++++++++++--------- 5 files changed, 32 insertions(+), 27 deletions(-) diff --git a/markdown/inlinepatterns.py b/markdown/inlinepatterns.py index c387ca952..4eb0ec1a7 100644 --- a/markdown/inlinepatterns.py +++ b/markdown/inlinepatterns.py @@ -848,7 +848,9 @@ def handleMatch( # type: ignore[override] delimiter = stack.pop() # Build up region for pair and adjust accounting. - size = min(delimiter[-1], self.max_size) + # Special handle span of 3 to preserve old Python Markdown behavior. + # For true CommonMark logic `2` should always be used. + size = min(delimiter[-1], 1 if len(self.tags) == 2 and delimiter[-1] == 3 else self.max_size) regions.append((delimiter[1] - size, delimiter[1], start, start + size, size)) start += size current -= size @@ -886,7 +888,7 @@ def handleMatch( # type: ignore[override] is_start = False ds, de = delimiter[:2] while current and (not self.double or current != 1): - size = min(current, self.max_size) + size = min(current, 1 if len(self.tags) == 2 and current == 3 else self.max_size) new = last - size regions.append((ds + new, de, start, start + size, size)) start += size diff --git a/tests/test_legacy.py b/tests/test_legacy.py index 808def4c6..df4da4d71 100644 --- a/tests/test_legacy.py +++ b/tests/test_legacy.py @@ -91,7 +91,7 @@ class TestPl2004(LegacyTestCase): location = os.path.join(parent_test_dir, 'pl/Tests_2004') normalize = True input_ext = '.text' - exclude = ['Yuri_Footnotes', 'Yuri_Attributes', 'Strong_and_em_together'] + exclude = ['Yuri_Footnotes', 'Yuri_Attributes'] class TestPl2007(LegacyTestCase): @@ -116,8 +116,7 @@ class TestPl2007(LegacyTestCase): 'Code_Blocks', 'Links,_reference_style', 'Backslash_escapes', - 'Code_Spans', - 'Strong_and_em_together' + 'Code_Spans' ] diff --git a/tests/test_syntax/blocks/test_hr.py b/tests/test_syntax/blocks/test_hr.py index 5aa48465b..a8753977b 100644 --- a/tests/test_syntax/blocks/test_hr.py +++ b/tests/test_syntax/blocks/test_hr.py @@ -318,7 +318,7 @@ def test_hr_after_emstrong(self): ), self.dedent( """ -

text

+

text


""" ) diff --git a/tests/test_syntax/extensions/test_legacy_em.py b/tests/test_syntax/extensions/test_legacy_em.py index a1a9f2e5a..ad2862e5e 100644 --- a/tests/test_syntax/extensions/test_legacy_em.py +++ b/tests/test_syntax/extensions/test_legacy_em.py @@ -55,12 +55,12 @@ def test_complex_multple_underscore_type(self): self.assertMarkdownRenders( 'traced ___along___ bla __blocked__ if other ___or___', - '

traced along bla blocked if other or

' # noqa: E501 + '

traced along bla blocked if other or

' # noqa: E501 ) def test_complex_multple_underscore_type_variant2(self): self.assertMarkdownRenders( 'on the __1-4 row__ of the AP Combat Table ___and___ receive', - '

on the 1-4 row of the AP Combat Table and receive

' + '

on the 1-4 row of the AP Combat Table and receive

' ) diff --git a/tests/test_syntax/inline/test_emphasis.py b/tests/test_syntax/inline/test_emphasis.py index 127d2c78f..f50d35fe7 100644 --- a/tests/test_syntax/inline/test_emphasis.py +++ b/tests/test_syntax/inline/test_emphasis.py @@ -161,14 +161,14 @@ def test_complex_multple_emphasis_type(self): self.assertMarkdownRenders( 'traced ***along*** bla **blocked** if other ***or***', - '

traced along bla blocked if other or

' # noqa: E501 + '

traced along bla blocked if other or

' # noqa: E501 ) def test_complex_multple_emphasis_type_variant2(self): self.assertMarkdownRenders( 'on the **1-4 row** of the AP Combat Table ***and*** receive', - '

on the 1-4 row of the AP Combat Table and receive

' + '

on the 1-4 row of the AP Combat Table and receive

' ) def test_link_emphasis_outer(self): @@ -244,13 +244,13 @@ def test_nested_patterns(self): ), self.dedent( """ -

link - link +

link + link link link link link - link

+ link

I am italic and bold I am just bold

Example bold italic on the same line bold italic.

Example bold italic on the same line bold italic.

@@ -296,7 +296,7 @@ def test_em_strong_complex(self): """

test test test test

test test test test

-

test

+

test

test

test test_

test test

@@ -304,7 +304,7 @@ def test_em_strong_complex(self):

test test test test

test test test test

*test

-

test

+

test

test*

test test

testtest testtest

@@ -328,10 +328,10 @@ def test_strong_and_em_together(self): ), self.dedent( """ -

This is strong and em.

-

So is this word.

-

This is strong and em.

-

So is this word.

+

This is strong and em.

+

So is this word.

+

This is strong and em.

+

So is this word.

""" ) ) @@ -381,16 +381,16 @@ def test_advanced_nesting(self):

abcdef

abcdef

abcdefgh

-

abcde

+

abcde

abcde**f

abcde**f

abcd*ef

_a b c

_a __b __c __d __e _f

-

a b c d e f

-

a b c d e f

-

a b c d e f g h

-

a b c d e

+

a b c d e f

+

a b c d e f

+

a b c d e f g h

+

a b c d e

_a b _c d _e f

""" ) @@ -549,6 +549,7 @@ def test_commonmark(self): *foo**bar*** + foo***bar***baz foo******bar*********baz @@ -653,6 +654,7 @@ def test_commonmark(self): ******foo****** + ***foo*** _____foo_____ @@ -767,7 +769,8 @@ def test_commonmark(self):

foo bar

foo bar

foobar

-

foobarbaz

+ +

foobarbaz

foobar***baz

foo bar baz bim bop

foo bar

@@ -820,8 +823,9 @@ def test_commonmark(self):

foo

foo

foo

-

foo

-

foo

+ +

foo

+

foo

foo _bar baz_

From 8a12d536facb59fcfd93ea7740ee2501208fd980 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Sun, 13 Sep 2026 06:58:40 -0600 Subject: [PATCH 24/34] Forgot to commit last fix, better cache point Ensure the cache point is after the last region start, but before or equal to the next region start so we can properly adjust the offset when returning the next region from the cache. --- markdown/inlinepatterns.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/markdown/inlinepatterns.py b/markdown/inlinepatterns.py index 4eb0ec1a7..15b43aefc 100644 --- a/markdown/inlinepatterns.py +++ b/markdown/inlinepatterns.py @@ -754,7 +754,7 @@ def increment_next_position(self, start: int, count: int) -> None: self.cache_pos = self.regions[self.cache_index][0] while self.stack: entry = self.stack.popleft() - if entry[0] > start: + if start < entry[0] <= self.cache_pos: self.cache_pos = entry[0] break From 606fc24f480a0a76863dcc6e1ba9e278514b255d Mon Sep 17 00:00:00 2001 From: facelessuser Date: Sun, 13 Sep 2026 21:09:52 -0600 Subject: [PATCH 25/34] Another fix that missed getting committed If the final delimiter we are comparing against is greater than 2, consume it anyway. --- markdown/inlinepatterns.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/markdown/inlinepatterns.py b/markdown/inlinepatterns.py index 15b43aefc..3905d6166 100644 --- a/markdown/inlinepatterns.py +++ b/markdown/inlinepatterns.py @@ -882,7 +882,7 @@ def handleMatch( # type: ignore[override] while stack and delimiter[-1] != 3 and delimiter[2]: delimiter = stack.pop() last = delimiter[-1] - if delimiter[2]: + if delimiter[2] and delimiter[-1] != 3: break is_start = False From 0901c7889314530563c76b26cdc4841e690c8f74 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Mon, 14 Sep 2026 15:08:48 -0600 Subject: [PATCH 26/34] Combine emphasis handling into one processor DelimiterProcessor can now handle multiple delimiters at the same time allowing for more natural nesting. If the `em_strong` inline processor already exists, tokens are added to the existing processor; otherwise, a new delimiter processor is added. --- markdown/core.py | 11 +- markdown/extensions/legacy_em.py | 7 +- markdown/inlinepatterns.py | 302 ++++++++++++++-------- tests/test_syntax/inline/test_emphasis.py | 26 +- 4 files changed, 217 insertions(+), 129 deletions(-) diff --git a/markdown/core.py b/markdown/core.py index a04510c03..a62b31160 100644 --- a/markdown/core.py +++ b/markdown/core.py @@ -119,7 +119,7 @@ def __init__(self, **kwargs): self.registeredExtensions: list[Extension] = [] self.docType = "" # TODO: Maybe delete this. It does not appear to be used anymore. self.stripTopLevelTags: bool = True - self.delimiters: dict[str, DelimiterProcessor] = {} + self.delimiters: DelimiterProcessor | None = None self.build_parser() @@ -272,11 +272,10 @@ def reset(self) -> Markdown: self.htmlStash.reset() self.references.clear() - for key in list(self.delimiters): - ext = self.delimiters[key] - ext.reset() - if ext not in self.inlinePatterns: - del self.delimiters[key] + if self.delimiters is not None: + self.delimiters.reset() + if self.delimiters not in self.inlinePatterns: + self.delimiters = None for extension in self.registeredExtensions: if hasattr(extension, 'reset'): diff --git a/markdown/extensions/legacy_em.py b/markdown/extensions/legacy_em.py index e0edf05b4..4ce47176e 100644 --- a/markdown/extensions/legacy_em.py +++ b/markdown/extensions/legacy_em.py @@ -25,12 +25,15 @@ def extendMarkdown(self, md): | Class Instance | Registry | Name | Priority | | ------------------------------------------------------------------ | ---------------------------------------------------------------- | ------------ | :------: | - | [`DelimiterProcessor`][markdown.inlinepatterns.DelimiterProcessor] | [`inlinepatterns`][markdown.inlinepatterns.build_inlinepatterns] | `em_strong2` | `50` | + | [`DelimiterProcessor`][markdown.inlinepatterns.DelimiterProcessor] | [`inlinepatterns`][markdown.inlinepatterns.build_inlinepatterns] | `em_strong` | `60` | """ # flake8: noqa: E501 27-29 - md.inlinePatterns.register(DelimiterProcessor(r'_', 'strong,em', md), 'em_strong2', 50) + if md.delimiters is not None: + md.delimiters.add('_', 'strong,em') + else: + md.inlinePatterns.register(DelimiterProcessor('_', 'strong,em', md), 'em_strong', 60) def makeExtension(**kwargs): # pragma: no cover """ Return an instance of the `LegacyEmExtension` """ diff --git a/markdown/inlinepatterns.py b/markdown/inlinepatterns.py index 3905d6166..788bba1f7 100644 --- a/markdown/inlinepatterns.py +++ b/markdown/inlinepatterns.py @@ -91,7 +91,7 @@ def build_inlinepatterns(md: Markdown, **kwargs: Any) -> util.Registry[InlinePro inlinePatterns.register(HtmlInlineProcessor(HTML_RE, md), 'html', 90) inlinePatterns.register(HtmlInlineProcessor(ENTITY_RE, md), 'entity', 80) inlinePatterns.register(DelimiterProcessor('*', 'strong,em', md), 'em_strong', 60) - inlinePatterns.register(DelimiterProcessor('_', 'strong,em', md, smart=True), 'em_strong2', 50) + md.delimiters.add('_', 'strong,em', smart=True) return inlinePatterns @@ -563,50 +563,20 @@ def _unescape(m: re.Match[str]) -> str: return RE.sub(_unescape, text) -class DelimiterProcessor(InlineProcessor): - """Processor for handling complex nested patterns such as strong and em matches.""" - - def __init__( - self, - token: str, - tags: str, - md: Markdown, - smart: bool = False, - double: bool = False - ) -> None: - """ - Initialize. +class Delimiter: + """Delimiter.""" - Arguments: - token: A single character token. - tags: A tag or two tags seprated by comma. When two are specified, the first will be the - one that takes double tokens. - md: the Markdown object - smart: Enable intelligent word logic. - double: If only one tag is specified, indicate whether it requires double tokens. - - """ + def __init__(self, token: str, tags: str, smart: bool, double: bool): + """Initialize.""" - # Cache info - md.delimiters[token] = self - self.regions: list[tuple[int, int, int, int, int]] = [] self.stack: deque[tuple[int, int, bool, int]] = deque() - self.cache_index = 0 - self.cache_pos = 0 - - self.last_run = 0.0 + temp = tags.split(',') + self.tag_count = len(temp) + self.tags: tuple[str, str] = (temp[0], temp[1]) if self.tag_count == 2 else (temp[0], temp[0]) + self.double = len(temp) != 2 and double + self.single = len(temp) != 2 and not double self.smart = smart - self.tags = tags.split(',') - self.double = len(self.tags) != 2 and double - super().__init__(self._build_patterns(token), md) - - def reset(self) -> None: - """Rest.""" - - self.regions.clear() - self.stack.clear() - self.cache_index = 0 - self.cache_pos = 0 + self._build_patterns(token) def _build_patterns(self, token: str) -> str: """Build regular expression patterns.""" @@ -614,35 +584,108 @@ def _build_patterns(self, token: str) -> str: # Build up patterns self.token = token etoken = re.escape(token) - avoid_start = fr'(?:(?<=_)|(?(?(?{avoid_start}{etoken}{{1,}}(?![\s{etoken}])(?!$)) + (?P(?(?{xstart}{etoken}{{1,}}(?![\s{etoken}])(?!$)) ''', flags=re.UNICODE ) # Patterns for "dumb" cases. else: self.boundary = re.compile( - fr'''(?x)(?: + fr'''(?x) (?P(?(?{etoken}{{1,}}(?![\s{etoken}])(?!$)) - )''', + ''', flags=re.UNICODE ) return fr'{etoken}' + def reset(self) -> None: + """Reset.""" + + # Cache info + self.stack.clear() + + +class DelimiterProcessor(InlineProcessor): + """Processor for handling complex nested patterns such as strong and em matches.""" + + SPACE = re.compile(r'\s') + + def __init__( + self, + token: str, + tags: str, + md: Markdown, + smart: bool = False, + double: bool = False + ) -> None: + """Initialize.""" + + md.delimiters = self + self.regions: list[tuple[int, int, int, int, tuple[str, str], int]] = [] + self.stack: list[tuple[int, int, bool, int]] = [] + self.tokens: list[str] = [] + self.delimiters: dict[str, Delimiter] = {} + self.cache_index = 0 + self.cache_pos = 0 + self.md = md + # API for Markdown to pass `safe_mode` into instance + self.safe_mode = False + self.add(token, tags, smart, double) + + def add( + self, + token: str, + tags: str, + smart: bool = False, + double: bool = False + ) -> None: + """Add a delimiter.""" + + if token not in self.tokens: + self.tokens.append(token) + self.delimiters[token] = Delimiter(token, tags, smart, double) + self.pattern = '|'.join([re.escape(t) for t in self.tokens]) + self.compiled_re = re.compile(self.pattern, re.DOTALL | re.UNICODE) + + def remove(self, token: str) -> None: + """Remove a token.""" + + try: + i = self.tokens.index(token) + del self.tokens[i] + del self.delimiters[token] + except Exception: + pass + self.pattern = '|'.join([re.escape(t) for t in self.tokens]) + self.compiled_re = re.compile(self.pattern, re.DOTALL | re.UNICODE) if self.pattern else re.compile(r'(?!)') + + def reset(self) -> None: + """Reset.""" + + # Cache info + for v in self.delimiters.values(): + v.reset() + self.regions.clear() + self.stack.clear() + self.cache_index = 0 + self.cache_pos = 0 + def _build_element( self, data: str, @@ -655,20 +698,9 @@ def _build_element( el: etree.Element | None = None last: Any = None previous: Any = None - greater: Any = None - lesser: Any = None outer: list[etree.Element] = [] - outer_r: list[tuple[int, int, int, int, int]] = [] - - if len(self.tags) == 2: - greater, lesser = self.tags - elif self.double: # pragma: no cover - greater = self.tags[0] - lesser = None - else: # pragma: no cover - lesser = self.tags[0] - greater = None + outer_r: list[tuple[int, int, int, int, tuple[str, str], int]] = [] # Iterate regions creating the elements they represent end = len(regions) @@ -679,11 +711,12 @@ def _build_element( if idx and r[0] >= regions[start][3]: idx -= 1 break + # Get the appropriate element(s) - if r[4] == 2: - el1 = etree.Element(greater) + if r[-1] == 2: + el1 = etree.Element(r[4][0]) else: - el1 = etree.Element(lesser) + el1 = etree.Element(r[4][1]) # Populate the elements with their text if idx > 1: @@ -753,7 +786,7 @@ def increment_next_position(self, start: int, count: int) -> None: if self.cache_index < len(self.regions): self.cache_pos = self.regions[self.cache_index][0] while self.stack: - entry = self.stack.popleft() + entry = self.stack.pop(0) if start < entry[0] <= self.cache_pos: self.cache_pos = entry[0] break @@ -773,6 +806,40 @@ def get_cached_result(self, pos: int, data: str) -> tuple[etree.Element, int, in self.increment_next_position(start, count) return el, start + offset, end + offset + def get_match(self, data: str, start: int) -> re.Match[str] | None: + """Get match.""" + + for d in self.delimiters.values(): + m = d.boundary.match(data, start) + if m is not None: + return m + return None + + def search(self, data: str, start: int) -> re.Match[str] | None: + """Search.""" + + for i in range(start, len(data)): + if data[i] in self.delimiters: + d = self.delimiters[data[i]] + m = d.boundary.match(data, i) + if m is not None: + if not d.stack and m.lastgroup[0] == 'e': # type: ignore[index] + continue + return m + return None + + def add_region(self, delim: Delimiter, a: int, b: int, c: int, d: int, size: int) -> None: + """Add region.""" + + self.regions.append((a, b, c, d, delim.tags, size)) + for delim in self.delimiters.values(): + while delim.stack: + p = delim.stack[-1][0] + if max(p, a) <= min(p, d - 1): + delim.stack.pop() + continue + break + def handleMatch( # type: ignore[override] self, m: re.Match[str], @@ -785,7 +852,7 @@ def handleMatch( # type: ignore[override] return self.get_cached_result(m.start(0), data) # If token is not an opening, quit - m2 = self.boundary.match(data, m.start(0)) + m2 = self.get_match(data, m.start(0)) if m2 is None or m2.lastgroup[0] == 'e': # type: ignore[index] if m2 is not None: m = m2 @@ -793,37 +860,44 @@ def handleMatch( # type: ignore[override] return None, m.start(0), m.end(0) # Get the stack and regions - stack = self.stack - regions = self.regions + token = data[m.start(0)] + delim = self.delimiters[token] + stack = delim.stack start = m2.start(0) end = m2.end(0) length = end - start # Double needs at least a size of 2 - if self.double and length < 2: + if delim.double and length < 2: return None, m.start(0), m.end(0) is_ambiguous = m2.lastgroup[0] != 's' # type: ignore[index] - self.stack.append((start, start + length, is_ambiguous, length)) + stack.append((start, start + length, is_ambiguous, length)) # Pair tokens until the stack is empty or we can no longer find tokens. - while stack: - m2 = self.boundary.search(data, end) + while any(d.stack for d in self.delimiters.values()): + m2 = self.search(data, end) if m2 is None: break + + token = data[m2.start(0)] + delim = self.delimiters[token] + stack = delim.stack + start = m2.start(0) end = m2.end(0) # Get current and last delimiter size current = len(m2.group(0)) - last = stack[-1][-1] # Some delimiters may be ambiguous and look like both a start or an end is_start = m2.lastgroup[0] != 'e' # type: ignore[index] is_end = not is_start or m2.lastgroup[0] != 's' # type: ignore[index] is_ambiguous = is_start and is_end + last = stack[-1][-1] if stack else 0 + # Find closing tokens # Looking for: # - `*em*` @@ -836,35 +910,36 @@ def handleMatch( # type: ignore[override] # Avoid ambiguous tokens that could be a start or an end. # Consume starts until the end token is fully consumed. # If we don't consume the entire end, see if next rule consumes it. - if ( - is_end and - ((not is_ambiguous and current > last) or current == last or current >= 3) - ): + if stack and is_end and ((not is_ambiguous and current > last) or current == last or current >= 3): is_start = False - # Consume previous tokens until the delimiter is consumed + # Consume previous points until the delimiter is consumed original = current - while current and last <= current: + while stack and current and last <= current: delimiter = stack.pop() # Build up region for pair and adjust accounting. - # Special handle span of 3 to preserve old Python Markdown behavior. - # For true CommonMark logic `2` should always be used. - size = min(delimiter[-1], 1 if len(self.tags) == 2 and delimiter[-1] == 3 else self.max_size) - regions.append((delimiter[1] - size, delimiter[1], start, start + size, size)) + size = min(delimiter[-1], 1 if delim.tag_count == 2 and delimiter[-1] == 3 else delim.max_size) + self.add_region(delim, delimiter[1] - size, delimiter[1], start, start + size, size) start += size current -= size - if size < delimiter[-1] and (not self.double or (delimiter[-1] - size) != 1): + new = 0 + if size < delimiter[-1] and (not delim.double or (delimiter[-1] - size) != 1): new = delimiter[-1] - size stack.append((delimiter[0], delimiter[1] - size, delimiter[2], new)) + if not stack: + if any(d.stack for d in self.delimiters.values() if d is not delim): + delim.reset() + continue is_end = False break + last = stack[-1][-1] # Should remainder be treated as a new start? if original >= 3 and current and is_ambiguous: - self.stack.append((regions[-1][3], end, False, current)) + delim.stack.append((m2.start(0) + (original - current), end, False, current)) is_end = False # Do we still have more to consume? @@ -875,49 +950,62 @@ def handleMatch( # type: ignore[override] # - `***em*` # - `***strong**` # - `**em*` - if is_end and (last >= 3 or not is_ambiguous) and last > current: + if stack and is_end and (last >= 3 or not is_ambiguous) and last > current: delimiter = stack.pop() # Don't pair with an ambiguous opening while stack and delimiter[-1] != 3 and delimiter[2]: delimiter = stack.pop() last = delimiter[-1] + if delimiter[2] and delimiter[-1] != 3: + if any(d.stack for d in self.delimiters.values() if d is not delim): + delim.reset() + continue break - is_start = False - ds, de = delimiter[:2] - while current and (not self.double or current != 1): - size = min(current, 1 if len(self.tags) == 2 and current == 3 else self.max_size) - new = last - size - regions.append((ds + new, de, start, start + size, size)) - start += size - current -= size - last -= size - de -= size - if not self.double or last > 1: - stack.append((ds, de, False, last)) + ignore = False + # Create new region if end is valid. + # If not valid, ignore the end but continue parsing. + if not ignore: + is_start = False + ds, de = delimiter[:2] + while current and (not delim.double or current != 1): + size = min(current, 1 if delim.tag_count == 2 and current == 3 else delim.max_size) + new = last - size + self.add_region(delim, ds + new, de, start, start + size, size) + start += size + current -= size + last -= size + de -= size + if not delim.double or last > 1: + stack.append((ds, de, False, last)) # Find opening tokens - if is_start and (not self.double or current != 1): - # Looking for: - # - `*em ...*` - # - `**strong ...*` - # - `***em ...*` + # Looking for: + # - `*em ...*` + # - `**strong ...*` + # - `***em ...*` + if is_start and (not delim.double or current != 1): stack.append((start, end, is_ambiguous, current)) + # Combine the stacks and order them + for delim in self.delimiters.values(): + self.stack.extend(delim.stack) + self.stack.sort(key=lambda x: x[0]) + # Build the HTML elements - if regions: + if self.regions: # Regions may be out of order. - regions.sort(key=lambda x: x[0]) - start, end = regions[0][0], regions[0][3] + self.regions.sort(key=lambda x: x[0]) + start, end = self.regions[0][0], self.regions[0][3] el, count = self._build_element(data) self.increment_next_position(start, count) return el, start, end # We failed to pair any valid start/end delimiters, avoid the parsed range next pass. start = m.start(0) - end = stack[-1][1] if stack else m.end(0) + end = self.stack[-1][1] if self.stack else m.end(0) self.reset() return None, start, end diff --git a/tests/test_syntax/inline/test_emphasis.py b/tests/test_syntax/inline/test_emphasis.py index f50d35fe7..a5c948ef6 100644 --- a/tests/test_syntax/inline/test_emphasis.py +++ b/tests/test_syntax/inline/test_emphasis.py @@ -372,6 +372,10 @@ def test_advanced_nesting(self): ___a ___b c__ d_ e___ _a __b__ _c __d__ _e __f__ + + ___bold and italic***bold and italic**bold and italic*__ italic_ + + ***a __b** __c *d__ e* """ ), self.dedent( @@ -392,7 +396,9 @@ def test_advanced_nesting(self):

a b c d e f g h

a b c d e

_a b _c d _e f

- """ +

bold and italicbold and italicbold and italic italic

+

a __b c *d e

+ """ # noqa: E501 ) ) @@ -661,8 +667,7 @@ def test_commonmark(self): *foo _bar* baz_ - + *foo __bar *baz bim__ bam* **foo **bar baz** @@ -827,9 +832,7 @@ def test_commonmark(self):

foo

foo

foo _bar baz_

- - +

foo bar *baz bim bam

**foo bar baz

*foo bar baz

*bar*

@@ -856,17 +859,12 @@ def test_remove_processor(self): # Remove all delimiter processors md = markdown.Markdown() - extensions = md.delimiters.values() - self.assertEqual(len(extensions), 2) - - for ext in md.delimiters.values(): - self.assertTrue(isinstance(ext, DelimiterProcessor)) + self.assertTrue(md.delimiters is not None) + self.assertTrue(isinstance(md.delimiters, DelimiterProcessor)) md.inlinePatterns.deregister('em_strong') - md.inlinePatterns.deregister('em_strong2') # Call reset which will cause them to remove themselves from being registered md.reset() - extensions = md.delimiters.values() - self.assertEqual(len(extensions), 0) + self.assertTrue(md.delimiters is None) From 572da3542b796fb32421318698eff2a72cab453b Mon Sep 17 00:00:00 2001 From: facelessuser Date: Tue, 15 Sep 2026 13:12:09 -0600 Subject: [PATCH 27/34] Add punctuation logic --- markdown/inlinepatterns.py | 111 ++++++++++++++++++++-- tests/test_syntax/inline/test_emphasis.py | 39 ++++---- 2 files changed, 121 insertions(+), 29 deletions(-) diff --git a/markdown/inlinepatterns.py b/markdown/inlinepatterns.py index 788bba1f7..1a5f4342e 100644 --- a/markdown/inlinepatterns.py +++ b/markdown/inlinepatterns.py @@ -563,6 +563,83 @@ def _unescape(m: re.Match[str]) -> str: return RE.sub(_unescape, text) +PUNCT = ( + b'!-/:-@\\[-`{-\\~\xc2\xa1-\xc2\xa9\xc2\xab-\xc2\xac\xc2\xae-\xc2\xb1\xc2\xb4\xc2\xb6-\xc2\xb8\xc2\xbb\xc2\xbf\xc3' + b'\x97\xc3\xb7\xcb\x82-\xcb\x85\xcb\x92-\xcb\x9f\xcb\xa5-\xcb\xab\xcb\xad\xcb\xaf-\xcb\xbf\xcd\xb5\xcd\xbe\xce\x84' + b'-\xce\x85\xce\x87\xcf\xb6\xd2\x82\xd5\x9a-\xd5\x9f\xd6\x89-\xd6\x8a\xd6\x8d-\xd6\x8f\xd6\xbe\xd7\x80\xd7\x83\xd7' + b'\x86\xd7\xb3-\xd7\xb4\xd8\x86-\xd8\x8f\xd8\x9b\xd8\x9d-\xd8\x9f\xd9\xaa-\xd9\xad\xdb\x94\xdb\x9e\xdb\xa9\xdb\xbd' + b'-\xdb\xbe\xdc\x80-\xdc\x8d\xdf\xb6-\xdf\xb9\xdf\xbe-\xdf\xbf\xe0\xa0\xb0-\xe0\xa0\xbe\xe0\xa1\x9e\xe0\xa2\x88' + b'\xe0\xa5\xa4-\xe0\xa5\xa5\xe0\xa5\xb0\xe0\xa7\xb2-\xe0\xa7\xb3\xe0\xa7\xba-\xe0\xa7\xbb\xe0\xa7\xbd\xe0\xa9\xb6' + b'\xe0\xab\xb0-\xe0\xab\xb1\xe0\xad\xb0\xe0\xaf\xb3-\xe0\xaf\xba\xe0\xb1\xb7\xe0\xb1\xbf\xe0\xb2\x84\xe0\xb5\x8f' + b'\xe0\xb5\xb9\xe0\xb7\xb4\xe0\xb8\xbf\xe0\xb9\x8f\xe0\xb9\x9a-\xe0\xb9\x9b\xe0\xbc\x81-\xe0\xbc\x97\xe0\xbc\x9a-' + b'\xe0\xbc\x9f\xe0\xbc\xb4\xe0\xbc\xb6\xe0\xbc\xb8\xe0\xbc\xba-\xe0\xbc\xbd\xe0\xbe\x85\xe0\xbe\xbe-\xe0\xbf\x85' + b'\xe0\xbf\x87-\xe0\xbf\x8c\xe0\xbf\x8e-\xe0\xbf\x9a\xe1\x81\x8a-\xe1\x81\x8f\xe1\x82\x9e-\xe1\x82\x9f\xe1\x83\xbb' + b'\xe1\x8d\xa0-\xe1\x8d\xa8\xe1\x8e\x90-\xe1\x8e\x99\xe1\x90\x80\xe1\x99\xad-\xe1\x99\xae\xe1\x9a\x9b-\xe1\x9a\x9c' + b'\xe1\x9b\xab-\xe1\x9b\xad\xe1\x9c\xb5-\xe1\x9c\xb6\xe1\x9f\x94-\xe1\x9f\x96\xe1\x9f\x98-\xe1\x9f\x9b\xe1\xa0\x80' + b'-\xe1\xa0\x8a\xe1\xa5\x80\xe1\xa5\x84-\xe1\xa5\x85\xe1\xa7\x9e-\xe1\xa7\xbf\xe1\xa8\x9e-\xe1\xa8\x9f\xe1\xaa\xa0' + b'-\xe1\xaa\xa6\xe1\xaa\xa8-\xe1\xaa\xad\xe1\xad\x8e-\xe1\xad\x8f\xe1\xad\x9a-\xe1\xad\xaa\xe1\xad\xb4-\xe1\xad' + b'\xbf\xe1\xaf\xbc-\xe1\xaf\xbf\xe1\xb0\xbb-\xe1\xb0\xbf\xe1\xb1\xbe-\xe1\xb1\xbf\xe1\xb3\x80-\xe1\xb3\x87\xe1\xb3' + b'\x93\xe1\xbe\xbd\xe1\xbe\xbf-\xe1\xbf\x81\xe1\xbf\x8d-\xe1\xbf\x8f\xe1\xbf\x9d-\xe1\xbf\x9f\xe1\xbf\xad-\xe1\xbf' + b'\xaf\xe1\xbf\xbd-\xe1\xbf\xbe\xe2\x80\x90-\xe2\x80\xa7\xe2\x80\xb0-\xe2\x81\x9e\xe2\x81\xba-\xe2\x81\xbe\xe2\x82' + b'\x8a-\xe2\x82\x8e\xe2\x82\xa0-\xe2\x83\x80\xe2\x84\x80-\xe2\x84\x81\xe2\x84\x83-\xe2\x84\x86\xe2\x84\x88-\xe2' + b'\x84\x89\xe2\x84\x94\xe2\x84\x96-\xe2\x84\x98\xe2\x84\x9e-\xe2\x84\xa3\xe2\x84\xa5\xe2\x84\xa7\xe2\x84\xa9\xe2' + b'\x84\xae\xe2\x84\xba-\xe2\x84\xbb\xe2\x85\x80-\xe2\x85\x84\xe2\x85\x8a-\xe2\x85\x8d\xe2\x85\x8f\xe2\x86\x8a-\xe2' + b'\x86\x8b\xe2\x86\x90-\xe2\x90\xa9\xe2\x91\x80-\xe2\x91\x8a\xe2\x92\x9c-\xe2\x93\xa9\xe2\x94\x80-\xe2\x9d\xb5\xe2' + b'\x9e\x94-\xe2\xad\xb3\xe2\xad\xb6-\xe2\xae\x95\xe2\xae\x97-\xe2\xaf\xbf\xe2\xb3\xa5-\xe2\xb3\xaa\xe2\xb3\xb9-' + b'\xe2\xb3\xbc\xe2\xb3\xbe-\xe2\xb3\xbf\xe2\xb5\xb0\xe2\xb8\x80-\xe2\xb8\xae\xe2\xb8\xb0-\xe2\xb9\x9d\xe2\xba\x80-' + b'\xe2\xba\x99\xe2\xba\x9b-\xe2\xbb\xb3\xe2\xbc\x80-\xe2\xbf\x95\xe2\xbf\xb0-\xe2\xbf\xbf\xe3\x80\x81-\xe3\x80\x84' + b'\xe3\x80\x88-\xe3\x80\xa0\xe3\x80\xb0\xe3\x80\xb6-\xe3\x80\xb7\xe3\x80\xbd-\xe3\x80\xbf\xe3\x82\x9b-\xe3\x82\x9c' + b'\xe3\x82\xa0\xe3\x83\xbb\xe3\x86\x90-\xe3\x86\x91\xe3\x86\x96-\xe3\x86\x9f\xe3\x87\x80-\xe3\x87\xa5\xe3\x87\xaf' + b'\xe3\x88\x80-\xe3\x88\x9e\xe3\x88\xaa-\xe3\x89\x87\xe3\x89\x90\xe3\x89\xa0-\xe3\x89\xbf\xe3\x8a\x8a-\xe3\x8a\xb0' + b'\xe3\x8b\x80-\xe3\x8f\xbf\xe4\xb7\x80-\xe4\xb7\xbf\xea\x92\x90-\xea\x93\x86\xea\x93\xbe-\xea\x93\xbf\xea\x98\x8d' + b'-\xea\x98\x8f\xea\x99\xb3\xea\x99\xbe\xea\x9b\xb2-\xea\x9b\xb7\xea\x9c\x80-\xea\x9c\x96\xea\x9c\xa0-\xea\x9c\xa1' + b'\xea\x9e\x89-\xea\x9e\x8a\xea\xa0\xa8-\xea\xa0\xab\xea\xa0\xb6-\xea\xa0\xb9\xea\xa1\xb4-\xea\xa1\xb7\xea\xa3\x8e' + b'-\xea\xa3\x8f\xea\xa3\xb8-\xea\xa3\xba\xea\xa3\xbc\xea\xa4\xae-\xea\xa4\xaf\xea\xa5\x9f\xea\xa7\x81-\xea\xa7\x8d' + b'\xea\xa7\x9e-\xea\xa7\x9f\xea\xa9\x9c-\xea\xa9\x9f\xea\xa9\xb7-\xea\xa9\xb9\xea\xab\x9e-\xea\xab\x9f\xea\xab\xb0' + b'-\xea\xab\xb1\xea\xad\x9b\xea\xad\xaa-\xea\xad\xab\xea\xaf\xab\xef\xac\xa9\xef\xae\xb2-\xef\xaf\x82\xef\xb4\xbe-' + b'\xef\xb5\x8f\xef\xb7\x8f\xef\xb7\xbc-\xef\xb7\xbf\xef\xb8\x90-\xef\xb8\x99\xef\xb8\xb0-\xef\xb9\x92\xef\xb9\x94-' + b'\xef\xb9\xa6\xef\xb9\xa8-\xef\xb9\xab\xef\xbc\x81-\xef\xbc\x8f\xef\xbc\x9a-\xef\xbc\xa0\xef\xbc\xbb-\xef\xbd\x80' + b'\xef\xbd\x9b-\xef\xbd\xa5\xef\xbf\xa0-\xef\xbf\xa6\xef\xbf\xa8-\xef\xbf\xae\xef\xbf\xbc-\xef\xbf\xbd\xf0\x90\x84' + b'\x80-\xf0\x90\x84\x82\xf0\x90\x84\xb7-\xf0\x90\x84\xbf\xf0\x90\x85\xb9-\xf0\x90\x86\x89\xf0\x90\x86\x8c-\xf0\x90' + b'\x86\x8e\xf0\x90\x86\x90-\xf0\x90\x86\x9c\xf0\x90\x86\xa0\xf0\x90\x87\x90-\xf0\x90\x87\xbc\xf0\x90\x8e\x9f\xf0' + b'\x90\x8f\x90\xf0\x90\x95\xaf\xf0\x90\xa1\x97\xf0\x90\xa1\xb7-\xf0\x90\xa1\xb8\xf0\x90\xa4\x9f\xf0\x90\xa4\xbf' + b'\xf0\x90\xa9\x90-\xf0\x90\xa9\x98\xf0\x90\xa9\xbf\xf0\x90\xab\x88\xf0\x90\xab\xb0-\xf0\x90\xab\xb6\xf0\x90\xac' + b'\xb9-\xf0\x90\xac\xbf\xf0\x90\xae\x99-\xf0\x90\xae\x9c\xf0\x90\xb5\xae\xf0\x90\xb6\x8e-\xf0\x90\xb6\x8f\xf0\x90' + b'\xba\xad\xf0\x90\xbd\x95-\xf0\x90\xbd\x99\xf0\x90\xbe\x86-\xf0\x90\xbe\x89\xf0\x91\x81\x87-\xf0\x91\x81\x8d\xf0' + b'\x91\x82\xbb-\xf0\x91\x82\xbc\xf0\x91\x82\xbe-\xf0\x91\x83\x81\xf0\x91\x85\x80-\xf0\x91\x85\x83\xf0\x91\x85\xb4-' + b'\xf0\x91\x85\xb5\xf0\x91\x87\x85-\xf0\x91\x87\x88\xf0\x91\x87\x8d\xf0\x91\x87\x9b\xf0\x91\x87\x9d-\xf0\x91\x87' + b'\x9f\xf0\x91\x88\xb8-\xf0\x91\x88\xbd\xf0\x91\x8a\xa9\xf0\x91\x8f\x94-\xf0\x91\x8f\x95\xf0\x91\x8f\x97-\xf0\x91' + b'\x8f\x98\xf0\x91\x91\x8b-\xf0\x91\x91\x8f\xf0\x91\x91\x9a-\xf0\x91\x91\x9b\xf0\x91\x91\x9d\xf0\x91\x93\x86\xf0' + b'\x91\x97\x81-\xf0\x91\x97\x97\xf0\x91\x99\x81-\xf0\x91\x99\x83\xf0\x91\x99\xa0-\xf0\x91\x99\xac\xf0\x91\x9a\xb9' + b'\xf0\x91\x9c\xbc-\xf0\x91\x9c\xbf\xf0\x91\xa0\xbb\xf0\x91\xa5\x84-\xf0\x91\xa5\x86\xf0\x91\xa7\xa2\xf0\x91\xa8' + b'\xbf-\xf0\x91\xa9\x86\xf0\x91\xaa\x9a-\xf0\x91\xaa\x9c\xf0\x91\xaa\x9e-\xf0\x91\xaa\xa2\xf0\x91\xac\x80-\xf0\x91' + b'\xac\x89\xf0\x91\xaf\xa1\xf0\x91\xb1\x81-\xf0\x91\xb1\x85\xf0\x91\xb1\xb0-\xf0\x91\xb1\xb1\xf0\x91\xbb\xb7-\xf0' + b'\x91\xbb\xb8\xf0\x91\xbd\x83-\xf0\x91\xbd\x8f\xf0\x91\xbf\x95-\xf0\x91\xbf\xb1\xf0\x91\xbf\xbf\xf0\x92\x91\xb0-' + b'\xf0\x92\x91\xb4\xf0\x92\xbf\xb1-\xf0\x92\xbf\xb2\xf0\x96\xa9\xae-\xf0\x96\xa9\xaf\xf0\x96\xab\xb5\xf0\x96\xac' + b'\xb7-\xf0\x96\xac\xbf\xf0\x96\xad\x84-\xf0\x96\xad\x85\xf0\x96\xb5\xad-\xf0\x96\xb5\xaf\xf0\x96\xba\x97-\xf0\x96' + b'\xba\x9a\xf0\x96\xbf\xa2\xf0\x9b\xb2\x9c\xf0\x9b\xb2\x9f\xf0\x9c\xb0\x80-\xf0\x9c\xb3\xaf\xf0\x9c\xb4\x80-\xf0' + b'\x9c\xba\xb3\xf0\x9c\xbd\x90-\xf0\x9c\xbf\x83\xf0\x9d\x80\x80-\xf0\x9d\x83\xb5\xf0\x9d\x84\x80-\xf0\x9d\x84\xa6' + b'\xf0\x9d\x84\xa9-\xf0\x9d\x85\xa4\xf0\x9d\x85\xaa-\xf0\x9d\x85\xac\xf0\x9d\x86\x83-\xf0\x9d\x86\x84\xf0\x9d\x86' + b'\x8c-\xf0\x9d\x86\xa9\xf0\x9d\x86\xae-\xf0\x9d\x87\xaa\xf0\x9d\x88\x80-\xf0\x9d\x89\x81\xf0\x9d\x89\x85\xf0\x9d' + b'\x8c\x80-\xf0\x9d\x8d\x96\xf0\x9d\x9b\x81\xf0\x9d\x9b\x9b\xf0\x9d\x9b\xbb\xf0\x9d\x9c\x95\xf0\x9d\x9c\xb5\xf0' + b'\x9d\x9d\x8f\xf0\x9d\x9d\xaf\xf0\x9d\x9e\x89\xf0\x9d\x9e\xa9\xf0\x9d\x9f\x83\xf0\x9d\xa0\x80-\xf0\x9d\xa7\xbf' + b'\xf0\x9d\xa8\xb7-\xf0\x9d\xa8\xba\xf0\x9d\xa9\xad-\xf0\x9d\xa9\xb4\xf0\x9d\xa9\xb6-\xf0\x9d\xaa\x83\xf0\x9d\xaa' + b'\x85-\xf0\x9d\xaa\x8b\xf0\x9e\x85\x8f\xf0\x9e\x8b\xbf\xf0\x9e\x97\xbf\xf0\x9e\xa5\x9e-\xf0\x9e\xa5\x9f\xf0\x9e' + b'\xb2\xac\xf0\x9e\xb2\xb0\xf0\x9e\xb4\xae\xf0\x9e\xbb\xb0-\xf0\x9e\xbb\xb1\xf0\x9f\x80\x80-\xf0\x9f\x80\xab\xf0' + b'\x9f\x80\xb0-\xf0\x9f\x82\x93\xf0\x9f\x82\xa0-\xf0\x9f\x82\xae\xf0\x9f\x82\xb1-\xf0\x9f\x82\xbf\xf0\x9f\x83\x81-' + b'\xf0\x9f\x83\x8f\xf0\x9f\x83\x91-\xf0\x9f\x83\xb5\xf0\x9f\x84\x8d-\xf0\x9f\x86\xad\xf0\x9f\x87\xa6-\xf0\x9f\x88' + b'\x82\xf0\x9f\x88\x90-\xf0\x9f\x88\xbb\xf0\x9f\x89\x80-\xf0\x9f\x89\x88\xf0\x9f\x89\x90-\xf0\x9f\x89\x91\xf0\x9f' + b'\x89\xa0-\xf0\x9f\x89\xa5\xf0\x9f\x8c\x80-\xf0\x9f\x9b\x97\xf0\x9f\x9b\x9c-\xf0\x9f\x9b\xac\xf0\x9f\x9b\xb0-\xf0' + b'\x9f\x9b\xbc\xf0\x9f\x9c\x80-\xf0\x9f\x9d\xb6\xf0\x9f\x9d\xbb-\xf0\x9f\x9f\x99\xf0\x9f\x9f\xa0-\xf0\x9f\x9f\xab' + b'\xf0\x9f\x9f\xb0\xf0\x9f\xa0\x80-\xf0\x9f\xa0\x8b\xf0\x9f\xa0\x90-\xf0\x9f\xa1\x87\xf0\x9f\xa1\x90-\xf0\x9f\xa1' + b'\x99\xf0\x9f\xa1\xa0-\xf0\x9f\xa2\x87\xf0\x9f\xa2\x90-\xf0\x9f\xa2\xad\xf0\x9f\xa2\xb0-\xf0\x9f\xa2\xbb\xf0\x9f' + b'\xa3\x80-\xf0\x9f\xa3\x81\xf0\x9f\xa4\x80-\xf0\x9f\xa9\x93\xf0\x9f\xa9\xa0-\xf0\x9f\xa9\xad\xf0\x9f\xa9\xb0-\xf0' + b'\x9f\xa9\xbc\xf0\x9f\xaa\x80-\xf0\x9f\xaa\x89\xf0\x9f\xaa\x8f-\xf0\x9f\xab\x86\xf0\x9f\xab\x8e-\xf0\x9f\xab\x9c' + b'\xf0\x9f\xab\x9f-\xf0\x9f\xab\xa9\xf0\x9f\xab\xb0-\xf0\x9f\xab\xb8\xf0\x9f\xac\x80-\xf0\x9f\xae\x92\xf0\x9f\xae' + b'\x94-\xf0\x9f\xaf\xaf' +).decode('utf-8') + + class Delimiter: """Delimiter.""" @@ -591,13 +668,26 @@ def _build_patterns(self, token: str) -> str: if self.tag_count != 2 and not self.double: self.max_size = 1 + # Python Markdown uses `STX` and `ETX` for placeholders. + # Include handling for these characters in addition to CommonMark rules. + stx, etx = '\x02', '\x03' + # Patterns for "smart" cases. if self.smart: self.boundary = re.compile( fr'''(?x) - (?P(?(?{xstart}{etoken}{{1,}}(?![\s{etoken}])(?!$)) + (?P + (? + (? + {xstart}{etoken}{{1,}}(?![\s{etoken}{PUNCT}])(?!$)| + (?:(?<=[\s{etx}{PUNCT}])|^)(? str: else: self.boundary = re.compile( fr'''(?x) - (?P(?(?{etoken}{{1,}}(?![\s{etoken}])(?!$)) + (?P + (? + (? + {etoken}{{1,}}(?![\s{etoken}{PUNCT}])(?!$)| + (?:(?<=[\s{etx}{PUNCT}])|^)(? + a*"foo"* - + *€*charlie. - + test * a * foo*bar* @@ -456,7 +456,7 @@ def test_commonmark(self): *foo bar * - + *(*foo) *(*foo*)* @@ -480,7 +480,7 @@ def test_commonmark(self): ** foo bar** - + a**"foo"** foo**bar** @@ -505,7 +505,7 @@ def test_commonmark(self): **foo bar ** - + **(**foo) *(**foo**)* @@ -696,15 +696,11 @@ def test_commonmark(self): """

foo bar

a * foo bar*

- - - - - +

a*"foo"*

+

*$*alpha.

+

*£*bravo.

+

*€*charlie.

+

test * a *

foobar

5678

@@ -720,9 +716,8 @@ def test_commonmark(self):

*foo bar *

*foo bar *

- - -

(foo)

+

*(*foo)

+

(foo)

foobar

_foo bar _

_(_foo)

@@ -733,8 +728,7 @@ def test_commonmark(self):

(bar).

foo bar

** foo bar**

- - +

a**"foo"**

foobar

foo bar

__ foo bar__

@@ -747,8 +741,7 @@ def test_commonmark(self):

foo, bar, baz

foo-(bar)

**foo bar **

- - +

**(**foo)

(foo)

Gomphocarpus (Gomphocarpus physocarpus, syn. Asclepias physocarpa)

From dbda9ff25de2161fa0115184ff6ec66542d709a4 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Tue, 15 Sep 2026 13:16:02 -0600 Subject: [PATCH 28/34] Fix typo --- tests/test_syntax/inline/test_emphasis.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_syntax/inline/test_emphasis.py b/tests/test_syntax/inline/test_emphasis.py index 1e015f48a..c4dbd2948 100644 --- a/tests/test_syntax/inline/test_emphasis.py +++ b/tests/test_syntax/inline/test_emphasis.py @@ -426,7 +426,7 @@ def test_commonmark(self): *€*charlie. - + test * a * foo*bar* @@ -700,7 +700,7 @@ def test_commonmark(self):

*$*alpha.

*£*bravo.

*€*charlie.

- +

test * a *

foobar

5678

From f37bef37fb0cd9235ddeeca47838096b7137850e Mon Sep 17 00:00:00 2001 From: facelessuser Date: Tue, 15 Sep 2026 18:24:59 -0600 Subject: [PATCH 29/34] Don't add a zero length delimiter to the stack --- markdown/inlinepatterns.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/markdown/inlinepatterns.py b/markdown/inlinepatterns.py index 1a5f4342e..85d662cf5 100644 --- a/markdown/inlinepatterns.py +++ b/markdown/inlinepatterns.py @@ -1077,7 +1077,7 @@ def handleMatch( # type: ignore[override] current -= size last -= size de -= size - if not delim.double or last > 1: + if last and (not delim.double or last > 1): stack.append((ds, de, False, last)) # Find opening tokens From c66cda25d5bc9106d27f05753db3d55784790f55 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Tue, 15 Sep 2026 18:51:39 -0600 Subject: [PATCH 30/34] Remove dead code As can be seen, the conditional does nothing and has been removed --- markdown/inlinepatterns.py | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/markdown/inlinepatterns.py b/markdown/inlinepatterns.py index 85d662cf5..961ae76cd 100644 --- a/markdown/inlinepatterns.py +++ b/markdown/inlinepatterns.py @@ -1063,22 +1063,19 @@ def handleMatch( # type: ignore[override] continue break - ignore = False - # Create new region if end is valid. - # If not valid, ignore the end but continue parsing. - if not ignore: - is_start = False - ds, de = delimiter[:2] - while current and (not delim.double or current != 1): - size = min(current, 1 if delim.tag_count == 2 and current == 3 else delim.max_size) - new = last - size - self.add_region(delim, ds + new, de, start, start + size, size) - start += size - current -= size - last -= size - de -= size - if last and (not delim.double or last > 1): - stack.append((ds, de, False, last)) + # Create new regions + is_start = False + ds, de = delimiter[:2] + while current and (not delim.double or current != 1): + size = min(current, 1 if delim.tag_count == 2 and current == 3 else delim.max_size) + new = last - size + self.add_region(delim, ds + new, de, start, start + size, size) + start += size + current -= size + last -= size + de -= size + if last and (not delim.double or last > 1): + stack.append((ds, de, False, last)) # Find opening tokens # Looking for: From c9678ebbf8d3ef1da59f6825d867e39b23f07180 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Tue, 22 Sep 2026 18:04:57 -0600 Subject: [PATCH 31/34] Minor performance improvement If a delimiter is neither left, right, or ambiguous, greedily capture adjacent delimiters and skip them. --- markdown/inlinepatterns.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/markdown/inlinepatterns.py b/markdown/inlinepatterns.py index 961ae76cd..e42b297ac 100644 --- a/markdown/inlinepatterns.py +++ b/markdown/inlinepatterns.py @@ -711,6 +711,8 @@ def _build_patterns(self, token: str) -> str: flags=re.UNICODE ) + self.bad = re.compile(fr'{etoken}+') + return fr'{etoken}' def reset(self) -> None: @@ -953,10 +955,11 @@ def handleMatch( # type: ignore[override] # If token is not an opening, quit m2 = self.get_match(data, m.start(0)) if m2 is None or m2.lastgroup[0] == 'e': # type: ignore[index] - if m2 is not None: - m = m2 + if m2 is None: + delim = self.delimiters[m.group(0)] + m2 = delim.bad.match(data, m.start(0)) # Advance past the full length of the delimiter found - return None, m.start(0), m.end(0) + return None, m2.start(0), m2.end(0) # Get the stack and regions token = data[m.start(0)] From 89ea17817ce0ecd59ad3794a7e210e5a6c380406 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Tue, 13 Oct 2026 09:07:50 -0600 Subject: [PATCH 32/34] Move delimiter specific options to the delimiter object --- markdown/extensions/legacy_em.py | 6 +++--- markdown/inlinepatterns.py | 35 ++++++++++++++++---------------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/markdown/extensions/legacy_em.py b/markdown/extensions/legacy_em.py index 4ce47176e..0d9cb3857 100644 --- a/markdown/extensions/legacy_em.py +++ b/markdown/extensions/legacy_em.py @@ -14,7 +14,7 @@ from __future__ import annotations from . import Extension -from ..inlinepatterns import DelimiterProcessor +from ..inlinepatterns import DelimiterProcessor, Delimiter class LegacyEmExtension(Extension): @@ -31,9 +31,9 @@ def extendMarkdown(self, md): # flake8: noqa: E501 27-29 if md.delimiters is not None: - md.delimiters.add('_', 'strong,em') + md.delimiters.add(Delimiter('_', 'strong,em')) else: - md.inlinePatterns.register(DelimiterProcessor('_', 'strong,em', md), 'em_strong', 60) + md.inlinePatterns.register(DelimiterProcessor(Delimiter('_', 'strong,em'), md), 'em_strong', 60) def makeExtension(**kwargs): # pragma: no cover """ Return an instance of the `LegacyEmExtension` """ diff --git a/markdown/inlinepatterns.py b/markdown/inlinepatterns.py index e42b297ac..6e9cb2439 100644 --- a/markdown/inlinepatterns.py +++ b/markdown/inlinepatterns.py @@ -90,8 +90,10 @@ def build_inlinepatterns(md: Markdown, **kwargs: Any) -> util.Registry[InlinePro inlinePatterns.register(SubstituteTagInlineProcessor(LINE_BREAK_RE, 'br'), 'linebreak', 100) inlinePatterns.register(HtmlInlineProcessor(HTML_RE, md), 'html', 90) inlinePatterns.register(HtmlInlineProcessor(ENTITY_RE, md), 'entity', 80) - inlinePatterns.register(DelimiterProcessor('*', 'strong,em', md), 'em_strong', 60) - md.delimiters.add('_', 'strong,em', smart=True) + delim1 = Delimiter('*', 'strong,em') + delim2 = Delimiter('_', 'strong,em', smart=True) + inlinePatterns.register(DelimiterProcessor(delim1, md), 'em_strong', 60) + md.delimiters.add(delim2) return inlinePatterns @@ -643,9 +645,16 @@ def _unescape(m: re.Match[str]) -> str: class Delimiter: """Delimiter.""" - def __init__(self, token: str, tags: str, smart: bool, double: bool): + def __init__( + self, + token: str, + tags: str, + smart: bool = False, + double: bool = False + ): """Initialize.""" + self.token = token self.stack: deque[tuple[int, int, bool, int]] = deque() temp = tags.split(',') self.tag_count = len(temp) @@ -729,11 +738,8 @@ class DelimiterProcessor(InlineProcessor): def __init__( self, - token: str, - tags: str, - md: Markdown, - smart: bool = False, - double: bool = False + delimiter: Delimiter, + md: Markdown ) -> None: """Initialize.""" @@ -747,20 +753,15 @@ def __init__( self.md = md # API for Markdown to pass `safe_mode` into instance self.safe_mode = False - self.add(token, tags, smart, double) + self.add(delimiter) - def add( - self, - token: str, - tags: str, - smart: bool = False, - double: bool = False - ) -> None: + def add(self, delimiter: Delimiter) -> None: """Add a delimiter.""" + token = delimiter.token if token not in self.tokens: self.tokens.append(token) - self.delimiters[token] = Delimiter(token, tags, smart, double) + self.delimiters[token] = delimiter self.pattern = '|'.join([re.escape(t) for t in self.tokens]) self.compiled_re = re.compile(self.pattern, re.DOTALL | re.UNICODE) From 89573fdcdb840e31d17722ef84c6f8815e03f148 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Tue, 13 Oct 2026 10:14:52 -0600 Subject: [PATCH 33/34] Revert "Move delimiter specific options to the delimiter object" MkDocs Autorefs breaks when we do this. It shouldn't, but it isn't worth fighting right now. This reverts commit 89ea17817ce0ecd59ad3794a7e210e5a6c380406. --- markdown/extensions/legacy_em.py | 6 +++--- markdown/inlinepatterns.py | 35 ++++++++++++++++---------------- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/markdown/extensions/legacy_em.py b/markdown/extensions/legacy_em.py index 0d9cb3857..4ce47176e 100644 --- a/markdown/extensions/legacy_em.py +++ b/markdown/extensions/legacy_em.py @@ -14,7 +14,7 @@ from __future__ import annotations from . import Extension -from ..inlinepatterns import DelimiterProcessor, Delimiter +from ..inlinepatterns import DelimiterProcessor class LegacyEmExtension(Extension): @@ -31,9 +31,9 @@ def extendMarkdown(self, md): # flake8: noqa: E501 27-29 if md.delimiters is not None: - md.delimiters.add(Delimiter('_', 'strong,em')) + md.delimiters.add('_', 'strong,em') else: - md.inlinePatterns.register(DelimiterProcessor(Delimiter('_', 'strong,em'), md), 'em_strong', 60) + md.inlinePatterns.register(DelimiterProcessor('_', 'strong,em', md), 'em_strong', 60) def makeExtension(**kwargs): # pragma: no cover """ Return an instance of the `LegacyEmExtension` """ diff --git a/markdown/inlinepatterns.py b/markdown/inlinepatterns.py index 6e9cb2439..e42b297ac 100644 --- a/markdown/inlinepatterns.py +++ b/markdown/inlinepatterns.py @@ -90,10 +90,8 @@ def build_inlinepatterns(md: Markdown, **kwargs: Any) -> util.Registry[InlinePro inlinePatterns.register(SubstituteTagInlineProcessor(LINE_BREAK_RE, 'br'), 'linebreak', 100) inlinePatterns.register(HtmlInlineProcessor(HTML_RE, md), 'html', 90) inlinePatterns.register(HtmlInlineProcessor(ENTITY_RE, md), 'entity', 80) - delim1 = Delimiter('*', 'strong,em') - delim2 = Delimiter('_', 'strong,em', smart=True) - inlinePatterns.register(DelimiterProcessor(delim1, md), 'em_strong', 60) - md.delimiters.add(delim2) + inlinePatterns.register(DelimiterProcessor('*', 'strong,em', md), 'em_strong', 60) + md.delimiters.add('_', 'strong,em', smart=True) return inlinePatterns @@ -645,16 +643,9 @@ def _unescape(m: re.Match[str]) -> str: class Delimiter: """Delimiter.""" - def __init__( - self, - token: str, - tags: str, - smart: bool = False, - double: bool = False - ): + def __init__(self, token: str, tags: str, smart: bool, double: bool): """Initialize.""" - self.token = token self.stack: deque[tuple[int, int, bool, int]] = deque() temp = tags.split(',') self.tag_count = len(temp) @@ -738,8 +729,11 @@ class DelimiterProcessor(InlineProcessor): def __init__( self, - delimiter: Delimiter, - md: Markdown + token: str, + tags: str, + md: Markdown, + smart: bool = False, + double: bool = False ) -> None: """Initialize.""" @@ -753,15 +747,20 @@ def __init__( self.md = md # API for Markdown to pass `safe_mode` into instance self.safe_mode = False - self.add(delimiter) + self.add(token, tags, smart, double) - def add(self, delimiter: Delimiter) -> None: + def add( + self, + token: str, + tags: str, + smart: bool = False, + double: bool = False + ) -> None: """Add a delimiter.""" - token = delimiter.token if token not in self.tokens: self.tokens.append(token) - self.delimiters[token] = delimiter + self.delimiters[token] = Delimiter(token, tags, smart, double) self.pattern = '|'.join([re.escape(t) for t in self.tokens]) self.compiled_re = re.compile(self.pattern, re.DOTALL | re.UNICODE) From 0d989f04f7f80c3e89e5ce9f8ba3c81d116bbb91 Mon Sep 17 00:00:00 2001 From: Waylan Limberg Date: Tue, 13 Oct 2026 14:09:54 -0400 Subject: [PATCH 34/34] Fix typo --- docs/changelog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index 618bce09a..4798ff7e4 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -20,7 +20,7 @@ See the [Contributing Guide](contributing.md) for details. same paragraph, instead of splitting the paragraph around it (#1643). * Fix issue where inline HTML attributes were rejected if they had `<` or `>` in the attribute (#1647). * Fix issue where an unterminated end tag (`