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 @@ -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 (`<!--`
with no matching `-->`).
* 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
Expand Down
52 changes: 48 additions & 4 deletions markdown/htmlparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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*([^\s/>]+)\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'
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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'</\s*{}\s*>'.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:
Expand All @@ -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):
Expand Down
7 changes: 7 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,13 @@ def test_many_unclosed_comments(self):
'<p>' + '&lt;!--' * 50000 + '</p>'
)

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 ' + '<span>' * 5000 + '<!---->' * 5000 + '</b>',
'<p>x ' + '<span>' * 5000 + '<!---->' * 5000 + '</b></p>'
)

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

Expand Down
Loading