diff --git a/docs/changelog.md b/docs/changelog.md index 6bf91f4eb..7612bf740 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -36,6 +36,8 @@ See the [Contributing Guide](contributing.md) for details. * Stop definition list nesting short of the recursion limit, so deeply nested definitions are kept as text instead of raising an uncaught `RecursionError` (#1700). +* Fix quadratic processing time for many consecutive abbreviation definitions + in the `abbr` extension, such as a glossary appended to every page (#1702). ## [3.11.3] - 2026-11-05 diff --git a/markdown/extensions/abbr.py b/markdown/extensions/abbr.py index 659d310f6..b718e80f8 100644 --- a/markdown/extensions/abbr.py +++ b/markdown/extensions/abbr.py @@ -157,22 +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 = -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 title and abbr: - if title == "''" or title == '""': - self.abbrs.pop(abbr, None) - else: - self.abbrs[abbr] = title - if block[m.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(): - # Add any content before match back to blocks as separate block - blocks.insert(0, block[:m.start()].rstrip('\n')) - return True + 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 diff --git a/tests/test_syntax/extensions/test_abbr.py b/tests/test_syntax/extensions/test_abbr.py index f748c9f8e..857858e13 100644 --- a/tests/test_syntax/extensions/test_abbr.py +++ b/tests/test_syntax/extensions/test_abbr.py @@ -589,6 +589,52 @@ 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( + """ +

===

+ """ + ) + ) + + def test_three_abbr_definitions_before_setext_underline(self): + self.assertMarkdownRenders( + self.dedent( + """ + *[ABBR]: Abbr + *[HTML]: Mark + *[CSS]: Styles + === + ABBR, HTML and CSS + """ + ), + self.dedent( + """ +

=== + ABBR, HTML and CSS

+ """ + ) + ) + def test_abbr_reset(self): ext = AbbrExtension() md = Markdown(extensions=[ext])