From ac331af9dca9b1342f34bc4bdece64eb6fc0a1c5 Mon Sep 17 00:00:00 2001 From: Naaz nuria Date: Wed, 28 Oct 2026 22:36:04 +0530 Subject: [PATCH] Avoid cubic time for comments inside open inline tags `HTMLExtractor.inline_close_follows` decides whether a comment inside raw inline HTML stays in the paragraph by copying the rest of the paragraph and running one regex per entry of `inline_stack` over it. Every comment repeats this, so n open inline tags followed by n comments cost O(n^3): `'x ' + '' * 2000 + '' * 2000` (26 KB) takes about 75 seconds to convert. The end tags of a paragraph are now found once, recording the position of the last end tag of each name. The parser counts how many times each name is in `inline_stack` and how many open names still have an end tag ahead. A later comment in the same paragraph only drops the names whose last end tag it has passed, so each comment no longer depends on the size of the stack or the length of the paragraph. Output is unchanged. --- docs/changelog.md | 2 + markdown/htmlparser.py | 52 ++++++++++++++++++-- tests/test_syntax/blocks/test_html_blocks.py | 7 +++ 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index f3335f1e..da607f85 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -33,6 +33,8 @@ See the [Contributing Guide](contributing.md) for details. characters is followed by more text on the same line. * Fix quadratic time when a document contains many unclosed HTML comments (``). +* Fix cubic time when a paragraph contains many HTML comments inside many + unclosed raw inline tags. * Fix regression in `BacktickInlineProcessor` where text could be lost when opening and closing backticks did not match (#1668). * Escape the language name in the `codehilite` extension's non-Pygments output to diff --git a/markdown/htmlparser.py b/markdown/htmlparser.py index 78c9e04f..24aad3cb 100644 --- a/markdown/htmlparser.py +++ b/markdown/htmlparser.py @@ -95,6 +95,9 @@ # Match a blank line anywhere in a run of text. inner_blank_line_re = re.compile(r'\n[ \t]*\n') +# Match an end tag and capture its name. +inline_endtag_re = re.compile(r']+)\s*>') + # Elements which never have an end tag and so cannot contain a comment. void_tags = frozenset([ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr' @@ -155,11 +158,19 @@ def reset(self): self.intail = False self.stack: list[str] = [] # When `inraw==True`, stack contains a list of tags self.inline_stack: list[str] = [] # Unclosed raw inline tags in the current paragraph + self.inline_counts: dict[str, int] = {} # Number of times each tag name is in `inline_stack` self._cache: list[str] = [] self.cleandoc: list[str] = [] self.lineno_start_cache = [0] # The `rawdata` and position of the last failed search for a comment close. self._no_commentclose: tuple[str | None, int] = (None, 0) + # The `rawdata`, start and end of the paragraph text last searched for end tags, the position + # of the last end tag of each name in it, the names whose last end tag has not been passed yet + # (latest first), and how many of those names are in `inline_stack`. + self._endtag_span: tuple[str | None, int, int] = (None, 0, 0) + self._endtags: dict[str, int] = {} + self._endtags_ahead: list[str] = [] + self._open_with_endtag = 0 super().reset() @@ -273,20 +284,53 @@ def open_inline_tag(self, tag: str): """ Track an unclosed raw inline tag so a comment inside it is not treated as a block. """ if not self.md.is_block_level(tag) and tag not in void_tags: self.inline_stack.append(tag) + self._count_inline_tag(tag, 1) def close_inline_tag(self, tag: str): """ Stop tracking an inline tag and any unclosed inline tags opened after it. """ if tag in self.inline_stack: while self.inline_stack: - if self.inline_stack.pop() == tag: + popped = self.inline_stack.pop() + self._count_inline_tag(popped, -1) + if popped == tag: break + def clear_inline_tags(self): + """ Stop tracking all open inline tags. """ + self.inline_stack = [] + self.inline_counts = {} + self._open_with_endtag = 0 + + def _count_inline_tag(self, tag: str, delta: int): + """ Update the counts when `tag` is added to (`delta=1`) or removed from (`delta=-1`) `inline_stack`. """ + count = self.inline_counts.get(tag, 0) + delta + self.inline_counts[tag] = count + if count == (delta > 0) and self._endtags.get(tag, -1) >= self._endtag_span[1]: + # The first open tag of this name, or the last one closed, and its end tag is still ahead. + self._open_with_endtag += delta + def inline_close_follows(self, text: str) -> bool: """ Return `True` if an open inline tag is closed in the same paragraph after the comment `text`. """ if not self.inline_stack: return False - rest = inner_blank_line_re.split(self.rawdata[self.line_offset + self.offset + len(text):], 1)[0] - return any(re.search(r''.format(re.escape(tag)), rest, re.I) for tag in self.inline_stack) + pos = self.line_offset + self.offset + len(text) + # Searching the rest of the paragraph for each open tag after every comment is quadratic in the + # length of the paragraph, so the end tags are found once per paragraph. Later comments in it + # only drop names whose last end tag they have passed. + searched, start, end = self._endtag_span + if searched is not self.rawdata or not start <= pos <= end: + m = inner_blank_line_re.search(self.rawdata, pos) + end = m.start() if m else len(self.rawdata) + self._endtags = {} + for m in inline_endtag_re.finditer(self.rawdata, pos, end): + self._endtags[m.group(1).lower()] = m.start() + self._endtags_ahead = sorted(self._endtags, key=self._endtags.__getitem__, reverse=True) + self._open_with_endtag = sum(1 for tag in self._endtags if self.inline_counts.get(tag)) + while self._endtags_ahead and self._endtags[self._endtags_ahead[-1]] < pos: + if self.inline_counts.get(self._endtags_ahead.pop()): + self._open_with_endtag -= 1 + self._endtag_span = (self.rawdata, pos, end) + return self._open_with_endtag > 0 def handle_data(self, data: str): if self.intail and '\n' in data: @@ -296,7 +340,7 @@ def handle_data(self, data: str): else: if inner_blank_line_re.search(data): # A blank line ends the paragraph, so open inline tags no longer enclose what follows. - self.inline_stack = [] + self.clear_inline_tags() self.cleandoc.append(data) def handle_empty_tag(self, data: str, is_block: bool): diff --git a/tests/test_syntax/blocks/test_html_blocks.py b/tests/test_syntax/blocks/test_html_blocks.py index ced51e01..c69198dc 100644 --- a/tests/test_syntax/blocks/test_html_blocks.py +++ b/tests/test_syntax/blocks/test_html_blocks.py @@ -1867,6 +1867,13 @@ def test_many_unclosed_comments(self): '

' + '<!--' * 50000 + '

' ) + def test_many_comments_in_many_unclosed_inline_tags(self): + """Test that each comment in open inline tags does not rescan the rest of the paragraph for every tag.""" + self.assertMarkdownRenders( + 'x ' + '' * 5000 + '' * 5000 + '', + '

x ' + '' * 5000 + '' * 5000 + '

' + ) + def test_no_hang_issue_1586(self): """Test no hang condition for issue #1586."""