diff --git a/docs/changelog.md b/docs/changelog.md index b7a50f0c..54b3c45c 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -16,6 +16,8 @@ See the [Contributing Guide](contributing.md) for details. ### Fixed +* Fix quadratic rendering time for many unclosed square brackets in links + and images. * Sanitize attribute names in the `fenced_code` and `legacy_attrs` extensions so an attribute name cannot break out of its tag and inject markup (#1663). * For backwards compatibility in some languages, revert the enforcement of CommonMark diff --git a/markdown/inlinepatterns.py b/markdown/inlinepatterns.py index 785585d0..9f4f2fa3 100644 --- a/markdown/inlinepatterns.py +++ b/markdown/inlinepatterns.py @@ -1157,19 +1157,38 @@ def getText(self, data: str, index: int) -> tuple[str, int, bool]: resolving nested square brackets. """ - bracket_count = 1 - text = [] - for pos in range(index, len(data)): - c = data[pos] - if c == ']': - bracket_count -= 1 - elif c == '[': - bracket_count += 1 - index += 1 - if bracket_count == 0: - break - text.append(c) - return ''.join(text), index, bracket_count == 0 + # Scanning forward from every `[` is quadratic when the brackets do not + # close, so find the closing bracket for every index of `data` at once. + if getattr(self, '_bracket_data', None) is not data: + self._bracket_data = data + self._bracket_ends = self._findBracketEnds(data) + end = self._bracket_ends[index] + if end is None: + return data[index:], len(data), False + return data[index:end - 1], end, True + + @staticmethod + def _findBracketEnds(data: str) -> list[int | None]: + """ + For each index of `data`, return the index just past the `]` which closes an + opening bracket before that index, or `None` if the bracket never closes. + + """ + ends: list[int | None] = [None] * (len(data) + 1) + depth = 0 + # `stack` holds the indexes still waiting for a closing bracket. The bracket + # depth before each one is strictly increasing from bottom to top. + stack: list[tuple[int, int]] = [] + for pos in range(len(data) + 1): + while stack and depth < stack[-1][1]: + ends[stack.pop()[0]] = pos + stack.append((pos, depth)) + if pos < len(data): + if data[pos] == '[': + depth += 1 + elif data[pos] == ']': + depth -= 1 + return ends class ImageInlineProcessor(LinkInlineProcessor): diff --git a/tests/test_syntax/inline/test_links.py b/tests/test_syntax/inline/test_links.py index e57bd995..48464da6 100644 --- a/tests/test_syntax/inline/test_links.py +++ b/tests/test_syntax/inline/test_links.py @@ -164,6 +164,19 @@ def test_angles_and_nonsense_url(self): '

test nonsense.

' ) + def test_unclosed_square_brackets(self): + self.assertMarkdownRenders( + '[[[Text](http://link.com) [more [text', + '

[[Text [more [text

' + ) + + def test_many_unclosed_square_brackets(self): + # Each unclosed bracket used to rescan the rest of the text. + self.assertMarkdownRenders( + '[[' * 10000 + '[Text](http://link.com)', + '

' + '[[' * 10000 + 'Text

' + ) + class TestReferenceLinks(TestCase):