From 8349f6fe8e6bf99d2e7a59fc1d6d6c4c800e715b Mon Sep 17 00:00:00 2001 From: deadstrobe5 Date: Tue, 10 Nov 2026 05:51:36 +0000 Subject: [PATCH 1/3] Fix quadratic processing time for consecutive abbreviation definitions --- docs/changelog.md | 2 ++ markdown/extensions/abbr.py | 33 ++++++++++++++++++----- tests/test_syntax/extensions/test_abbr.py | 27 +++++++++++++++++++ 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 44cafbc2b..ac9b03ce5 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -24,6 +24,8 @@ See the [Contributing Guide](contributing.md) for details. placeholder instead of the text that preceded it. * Fix quadratic processing time in the `fenced_code` extension for documents with many fenced code blocks (#1660). +* Fix quadratic processing time for many consecutive abbreviation definitions + in the `abbr` extension, such as a glossary appended to every page. ## [3.11.3] - 2026-11-05 diff --git a/markdown/extensions/abbr.py b/markdown/extensions/abbr.py index 659d310f6..e101cfde0 100644 --- a/markdown/extensions/abbr.py +++ b/markdown/extensions/abbr.py @@ -159,19 +159,38 @@ def run(self, parent: etree.Element, blocks: list[str]) -> bool: block = blocks.pop(0) m = self.RE.search(block) if m: - abbr = m.group('abbr').strip() - title = m.group('title').strip() - if title and abbr: + start = end = m.start() + # When the block starts with a definition, record the definitions on + # the following lines in this pass rather than one per pass through + # the block processors. A definition after the first is only taken if + # it fits on one line and is followed by another definition or the end + # of the block, as otherwise another processor may claim it together + # with the lines after it. + matches = [m] + while start == 0 and block.startswith('\n', m.end()): + m = self.RE.match(block, m.end() + 1) + if m is None or '\n' in m.group(): + break + matches.append(m) + if len(matches) > 1 and block[matches[-1].end():].strip(): + matches.pop() + for m in matches: + abbr = m.group('abbr').strip() + title = m.group('title').strip() + if not (title and abbr): + break if title == "''" or title == '""': self.abbrs.pop(abbr, None) else: self.abbrs[abbr] = title - if block[m.end():].strip(): + end = m.end() + if end > start: + if block[end:].strip(): # Add any content after match back to blocks as separate block - blocks.insert(0, block[m.end():].lstrip('\n')) - if block[:m.start()].strip(): + blocks.insert(0, block[end:].lstrip('\n')) + if block[:start].strip(): # Add any content before match back to blocks as separate block - blocks.insert(0, block[:m.start()].rstrip('\n')) + blocks.insert(0, block[:start].rstrip('\n')) return True # No match. Restore block. blocks.insert(0, block) diff --git a/tests/test_syntax/extensions/test_abbr.py b/tests/test_syntax/extensions/test_abbr.py index f748c9f8e..24fefacd3 100644 --- a/tests/test_syntax/extensions/test_abbr.py +++ b/tests/test_syntax/extensions/test_abbr.py @@ -589,6 +589,33 @@ def test_abbr_clear(self): ) ) + def test_many_abbr_definitions(self): + """ + Consecutive definitions should be consumed together rather than returning + the remainder of the block to be parsed once per definition. + """ + count = 10000 + ext = AbbrExtension() + text = ''.join(f'*[ABBR{i}]: Abbreviation {i}\n' for i in range(count)) + self.assertEqual(Markdown(extensions=[ext]).convert(text), '') + self.assertEqual(ext.abbrs, {f'ABBR{i}': f'Abbreviation {i}' for i in range(count)}) + + def test_abbr_definitions_before_setext_underline(self): + self.assertMarkdownRenders( + self.dedent( + """ + *[ABBR]: Abbreviation + *[HTML]: Hyper Text Markup Language + === + """ + ), + self.dedent( + """ +

*[HTML]: Hyper Text Markup Language

+ """ + ) + ) + def test_abbr_reset(self): ext = AbbrExtension() md = Markdown(extensions=[ext]) From c66f873b6042ee3c6b56985cb17b5c46440e429f Mon Sep 17 00:00:00 2001 From: deadstrobe5 Date: Tue, 10 Nov 2026 15:06:19 +0000 Subject: [PATCH 2/3] Shorten test line --- tests/test_syntax/extensions/test_abbr.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_syntax/extensions/test_abbr.py b/tests/test_syntax/extensions/test_abbr.py index 752470457..857858e13 100644 --- a/tests/test_syntax/extensions/test_abbr.py +++ b/tests/test_syntax/extensions/test_abbr.py @@ -620,8 +620,8 @@ def test_three_abbr_definitions_before_setext_underline(self): self.assertMarkdownRenders( self.dedent( """ - *[ABBR]: Abbreviation - *[HTML]: Markup + *[ABBR]: Abbr + *[HTML]: Mark *[CSS]: Styles === ABBR, HTML and CSS @@ -630,7 +630,7 @@ def test_three_abbr_definitions_before_setext_underline(self): self.dedent( """

=== - ABBR, HTML and CSS

+ ABBR, HTML and CSS

""" ) ) From b39a7c0b53772c0ecd69a5729a6a5a65674cb924 Mon Sep 17 00:00:00 2001 From: deadstrobe5 Date: Wed, 11 Nov 2026 18:33:49 +0000 Subject: [PATCH 3/3] Collect consecutive abbreviation definitions in one finditer loop --- markdown/extensions/abbr.py | 55 +++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/markdown/extensions/abbr.py b/markdown/extensions/abbr.py index a42b42998..b718e80f8 100644 --- a/markdown/extensions/abbr.py +++ b/markdown/extensions/abbr.py @@ -157,36 +157,31 @@ def run(self, parent: etree.Element, blocks: list[str]) -> bool: """ block = blocks.pop(0) - m = self.RE.search(block) - if m: - start = end = m.start() - # When the block starts with a definition, record the definitions on - # the following lines in this pass rather than one per pass through - # the block processors. - matches = [m] - while start == 0 and block.startswith('\n', m.end()): - m = self.RE.match(block, m.end() + 1) - if m is None or '\n' in m.group(): - break - matches.append(m) - for m in matches: - abbr = m.group('abbr').strip() - title = m.group('title').strip() - if not (title and abbr): - break - if title == "''" or title == '""': - self.abbrs.pop(abbr, None) - else: - self.abbrs[abbr] = title - end = m.end() - if end > start: - if block[end:].strip(): - # Add any content after match back to blocks as separate block - blocks.insert(0, block[end:].lstrip('\n')) - if block[:start].strip(): - # Add any content before match back to blocks as separate block - blocks.insert(0, block[:start].rstrip('\n')) - return True + start = end = -1 + for m in self.RE.finditer(block): + # After the first definition, take only the definitions on the lines that + # directly follow it, so the rest of the block is parsed as before. + if end >= 0 and (m.start() != end + 1 or '\n' in m.group()): + break + abbr = m.group('abbr').strip() + title = m.group('title').strip() + if not (title and abbr): + break + if title == "''" or title == '""': + self.abbrs.pop(abbr, None) + else: + self.abbrs[abbr] = title + if start < 0: + start = m.start() + end = m.end() + if end >= 0: + if block[end:].strip(): + # Add any content after match back to blocks as separate block + blocks.insert(0, block[end:].lstrip('\n')) + if block[:start].strip(): + # Add any content before match back to blocks as separate block + blocks.insert(0, block[:start].rstrip('\n')) + return True # No match. Restore block. blocks.insert(0, block) return False