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