From b55599cecad73d1ae3c0a9ea8073fc8ee913afd6 Mon Sep 17 00:00:00 2001
From: Naaz nuria
Date: Fri, 13 Nov 2026 22:24:58 +0530
Subject: [PATCH] Avoid rescanning the rest of the text after unclosed start
tags
`HTMLExtractor.parse_starttag` passes `<` through as data and returns
`i + 1` when `check_for_whole_start_tag` finds no closing `>`. The
`locatetagend` match behind that check accepts `<` in tag and attribute
names and runs across whitespace and blank lines, so in text such as
`a` after it, every `<` scans to the end of the
raw data again and n of them cost O(n^2). `'a` the first tag is unclosed but `` is
a tag. So `check_for_whole_start_tag` now matches the same text as
`locatetagend` one attribute at a time and records, for the current
`rawdata`, the positions from which the attributes ran out without a
`>` and the extent of the last unclosed tag name. The match is greedy
and never backtracks past the start of an attribute, so a later `<`
whose tag name ends in the same place, or whose attributes reach one of
those positions, has the same result and is not scanned again. Output
is unchanged.
---
docs/changelog.md | 2 +
markdown/htmlparser.py | 48 ++++++++++++++++++++
tests/test_syntax/blocks/test_html_blocks.py | 11 +++++
3 files changed, 61 insertions(+)
diff --git a/docs/changelog.md b/docs/changelog.md
index 7926ab4aa..40fb325b5 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -39,6 +39,8 @@ See the [Contributing Guide](contributing.md) for details.
* Fix quadratic processing time in the `tables` extension for long blocks which
are split into many smaller blocks, such as many headers with no blank lines
between them (#1704).
+* Fix quadratic processing time for documents with many unclosed HTML start
+ tags, such as `a` after it.
## [3.11.3] - 2026-11-05
diff --git a/markdown/htmlparser.py b/markdown/htmlparser.py
index 78c9e04fb..20c3002ec 100644
--- a/markdown/htmlparser.py
+++ b/markdown/htmlparser.py
@@ -88,6 +88,20 @@
>?
""", re.VERBOSE)
+# The parts of `locatetagend` as matched one at a time by `HTMLExtractor.check_for_whole_start_tag`:
+# the tag name and the whitespace after it, and then each attribute and the whitespace after it.
+tagname_re = re.compile(r'([a-zA-Z][^`\t\n\r\f />]*)[\t\n\r\f /]*')
+attr_re = re.compile(r"""
+ (?<=['"\t\n\r\f /])[^`\t\n\r\f />][^\t\n\r\f /=>]* # attribute name
+ (?:= # value indicator
+ (?:'[^']*' # LITA-enclosed value
+ |"[^"]*" # LIT-enclosed value
+ |(?!['"])[^>\t\n\r\f ]* # bare value
+ )
+ )?
+ [\t\n\r\f /]* # possibly followed by a space
+""", re.VERBOSE)
+
# Match a blank line at the start of a block of text (two newlines).
# The newlines may be preceded by additional whitespace.
blank_line_re = re.compile(r'^([ ]*\n){2}')
@@ -160,6 +174,9 @@ def reset(self):
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`, the start and end of the tag name of the last unclosed start tag, and the
+ # positions from which the attributes of a start tag are known to run out without a closing `>`.
+ self._unclosed_tag: tuple[str | None, int, int, set[int]] = (None, 0, 0, set())
super().reset()
@@ -402,6 +419,37 @@ def parse_bogus_comment(self, i: int, report: int = 0) -> int:
self.handle_empty_tag(self.rawdata[i:pos], is_block=False)
return pos
+ def check_for_whole_start_tag(self, i: int) -> int:
+ """
+ Return the end of the start tag at `i`, or `-1` if it has no closing `>`.
+
+ This matches the same text as `locatetagend`, one attribute at a time. An unclosed start tag is
+ passed through as text, so each `<` within it is checked next. To avoid scanning the rest of the
+ text again for each of those, remember where the last unclosed tag name ends and where attributes
+ are known to run out without a closing `>`.
+ """
+ rawdata = self.rawdata
+ if self._unclosed_tag[0] is not rawdata:
+ self._unclosed_tag = (rawdata, 0, 0, set())
+ _, name_start, name_end, unclosed = self._unclosed_tag
+ if name_start <= i + 1 < name_end:
+ # This tag name ends where the unclosed one does, so the rest is the same.
+ return -1
+ name = tagname_re.match(rawdata, i + 1)
+ j = name.end()
+ seen = []
+ while j not in unclosed:
+ seen.append(j)
+ m = attr_re.match(rawdata, j)
+ if not m:
+ if rawdata.startswith('>', j):
+ return j + 1
+ break
+ j = m.end()
+ unclosed.update(seen)
+ self._unclosed_tag = (rawdata, i + 1, name.end(1), unclosed)
+ return -1
+
# The rest has been copied from base class in standard lib to address #1036.
# As `__startag_text` is private, all references to it must be in this subclass.
# The last few lines of `parse_starttag` are reversed so that `handle_starttag`
diff --git a/tests/test_syntax/blocks/test_html_blocks.py b/tests/test_syntax/blocks/test_html_blocks.py
index ced51e018..6293a3377 100644
--- a/tests/test_syntax/blocks/test_html_blocks.py
+++ b/tests/test_syntax/blocks/test_html_blocks.py
@@ -1867,6 +1867,17 @@ def test_many_unclosed_comments(self):
'' + '<!--' * 50000 + '
'
)
+ def test_many_unclosed_start_tags(self):
+ """Test that each unclosed start tag does not rescan the rest of the document."""
+ self.assertMarkdownRenders(
+ 'a' + 'a<b ' * 25000 + '
'
+ )
+ self.assertMarkdownRenders(
+ '' + '<a@a' * 25000 + ''
+ )
+
def test_no_hang_issue_1586(self):
"""Test no hang condition for issue #1586."""