From 2c6b8002688925f9edf55ff60ac4df711ea0f2c7 Mon Sep 17 00:00:00 2001 From: Naaz nuria Date: Mon, 19 Oct 2026 23:05:33 +0530 Subject: [PATCH 1/2] Fix quadratic time for unclosed brackets in link text `LinkInlineProcessor.getText` scanned forward from each `[` to find its matching `]`. The inline treeprocessor calls it for every `[` in the text, so when the brackets never close each call scanned to the end of the text and conversion became quadratic in the number of brackets. Find the closing bracket for every index in a single pass and cache the result for the current text. Matches and returned text are unchanged. --- docs/changelog.md | 2 ++ markdown/inlinepatterns.py | 45 ++++++++++++++++++-------- tests/test_syntax/inline/test_links.py | 13 ++++++++ 3 files changed, 47 insertions(+), 13 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index b7a50f0c7..54b3c45c5 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 785585d03..9f4f2fa3a 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 e57bd995d..48464da64 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): From 57f3021d981fb7ded126c42b9fa9f95f5e874148 Mon Sep 17 00:00:00 2001 From: Naaz nuria Date: Sat, 24 Oct 2026 11:53:56 +0530 Subject: [PATCH 2/2] Add rescan to spell dictionary --- .spell-dict | 1 + 1 file changed, 1 insertion(+) diff --git a/.spell-dict b/.spell-dict index c0f0c880f..f70d2bc8d 100644 --- a/.spell-dict +++ b/.spell-dict @@ -108,6 +108,7 @@ refactor refactored refactors registerExtension +rescan resettable richeland RSS