Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<b` with no `>` after it.

## [3.11.3] - 2026-11-05

Expand Down
48 changes: 48 additions & 0 deletions markdown/htmlparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}')
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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`
Expand Down
11 changes: 11 additions & 0 deletions tests/test_syntax/blocks/test_html_blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -1867,6 +1867,17 @@ def test_many_unclosed_comments(self):
'<p>' + '&lt;!--' * 50000 + '</p>'
)

def test_many_unclosed_start_tags(self):
"""Test that each unclosed start tag does not rescan the rest of the document."""
self.assertMarkdownRenders(
'a<b ' * 25000,
'<p>' + 'a&lt;b ' * 25000 + '</p>'
)
self.assertMarkdownRenders(
'<a@a' * 25000,
'<p>' + '&lt;a@a' * 25000 + '</p>'
)

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

Expand Down
Loading