From f03e1b58bf7aa6734e46fa64a2e8827fc64e69be Mon Sep 17 00:00:00 2001 From: "Aryan Singh K." <70511529+aryansk@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:09:41 +0530 Subject: [PATCH 1/2] Fix quadratic rendering time for many inline links Scanning from index 0 after every inline-pattern match rescanned the unprocessed text repeatedly, making conversion quadratic in the number of inline elements. Return the index just past the inserted placeholder so the next scan starts at the unprocessed tail. Fixes #1619. --- docs/changelog.md | 1 + markdown/treeprocessors.py | 9 +++++++-- tests/test_syntax/inline/test_links.py | 20 ++++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index cd6b79bb6..95e4ebfe3 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -15,6 +15,7 @@ See the [Contributing Guide](contributing.md) for details. ### Fixed * Fix an issue with excessive backtracking when matching inline code blocks (#1617). +* Fix quadratic rendering time when a paragraph contains many inline links (#1619). ## [3.10.3] - 2026-07-30 diff --git a/markdown/treeprocessors.py b/markdown/treeprocessors.py index 9a27446d4..4d2f318d1 100644 --- a/markdown/treeprocessors.py +++ b/markdown/treeprocessors.py @@ -323,12 +323,17 @@ def __applyPattern( placeholder = self.__stashNode(node, pattern.type()) if new_style: + # Return the index just past the inserted placeholder so the + # next call scans only the unprocessed tail. Scanning from 0 + # after every match makes repeated inline patterns quadratic. return "{}{}{}".format(data[:start], - placeholder, data[end:]), True, 0 + placeholder, data[end:]), True, start + len(placeholder) else: # pragma: no cover return "{}{}{}{}".format(leftData, match.group(1), - placeholder, match.groups()[-1]), True, 0 + placeholder, match.groups()[-1]), True, ( + len(leftData) + len(match.group(1)) + len(placeholder) + ) def __build_ancestors(self, parent: etree.Element | None, parents: list[str]) -> None: """Build the ancestor list.""" diff --git a/tests/test_syntax/inline/test_links.py b/tests/test_syntax/inline/test_links.py index e57bd995d..475f89b6f 100644 --- a/tests/test_syntax/inline/test_links.py +++ b/tests/test_syntax/inline/test_links.py @@ -21,6 +21,8 @@ from markdown.test_tools import TestCase +import time + class TestInlineLinks(TestCase): @@ -434,3 +436,21 @@ def test_ref_round_brackets(self): """ ) ) + + def test_many_repeated_links(self): + # Regression test for #1619: rendering many inline links in one + # paragraph used to rescan the unprocessed text from the start after + # every match, making conversion quadratic in the number of links. + from markdown import Markdown + + text = "[link](x)" * 8192 + start = time.monotonic() + html = Markdown().convert(text) + elapsed = time.monotonic() - start + + # The old implementation takes several seconds (or more on slow CI) + # for this input; the linear implementation finishes well under a + # second on any machine. Allow a generous ceiling to avoid flakes. + self.assertLess(elapsed, 5) + self.assertEqual(html.count(""), 8192) From b745bba2f65f717607a0aaf79e9da66cf7d8c56c Mon Sep 17 00:00:00 2001 From: "Aryan Singh K." <70511529+aryansk@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:01:53 +0530 Subject: [PATCH 2/2] Address review feedback on inline processor fix --- docs/changelog.md | 6 ++++- tests/test_apis.py | 35 ++++++++++++++++++++++++++ tests/test_syntax/inline/test_links.py | 20 --------------- 3 files changed, 40 insertions(+), 21 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 95e4ebfe3..ea02e6d3d 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -12,10 +12,14 @@ See the [Contributing Guide](contributing.md) for details. ## [Unreleased] +### Changed + +* Inline processors now resume searching after the previous match, improving + performance for repeated inline patterns (#1619). + ### Fixed * Fix an issue with excessive backtracking when matching inline code blocks (#1617). -* Fix quadratic rendering time when a paragraph contains many inline links (#1619). ## [3.10.3] - 2026-07-30 diff --git a/tests/test_apis.py b/tests/test_apis.py index 55e2cdb66..b4afc4f7a 100644 --- a/tests/test_apis.py +++ b/tests/test_apis.py @@ -725,6 +725,15 @@ def testInlineProcessorDoesntCrashWithWrongAtomicString(self): '

a <b>atomic</b> c

' ) + def testInlineProcessorAdvancesSearchIndex(self): + """Test that repeated matches resume after the previous match.""" + pattern = _InlineProcessorThatRecordsSearchIndex(r'x', self.md) + self.md.inlinePatterns.register(pattern, 'record-search-index', 1000) + + self.assertEqual(self.md.convert('xxxx'), '

xxxx

') + self.assertEqual(pattern.start_indices[0], 0) + self.assertGreater(pattern.start_indices[1], pattern.start_indices[0]) + class _InlineProcessorThatReturnsAtomicString(inlinepatterns.InlineProcessor): """ Return a simple text of `group(1)` of a Pattern. """ @@ -732,6 +741,32 @@ def handleMatch(self, m, data): return markdown.util.AtomicString('atomic'), m.start(0), m.end(0) +class _RecordingPattern: + """Proxy a compiled pattern while recording the search offsets.""" + + def __init__(self, pattern, start_indices): + self.pattern = pattern + self.start_indices = start_indices + + def finditer(self, data, start_index=0): + self.start_indices.append(start_index) + return self.pattern.finditer(data, start_index) + + +class _InlineProcessorThatRecordsSearchIndex(inlinepatterns.InlineProcessor): + """Record each offset passed to the processor's compiled expression.""" + + def __init__(self, pattern, md): + super().__init__(pattern, md) + self.start_indices = [] + + def getCompiledRegExp(self): + return _RecordingPattern(self.compiled_re, self.start_indices) + + def handleMatch(self, m, data): + return m.group(0), m.start(0), m.end(0) + + class TestConfigParsing(unittest.TestCase): def assertParses(self, value, result): self.assertIs(markdown.util.parseBoolValue(value, False), result) diff --git a/tests/test_syntax/inline/test_links.py b/tests/test_syntax/inline/test_links.py index 475f89b6f..e57bd995d 100644 --- a/tests/test_syntax/inline/test_links.py +++ b/tests/test_syntax/inline/test_links.py @@ -21,8 +21,6 @@ from markdown.test_tools import TestCase -import time - class TestInlineLinks(TestCase): @@ -436,21 +434,3 @@ def test_ref_round_brackets(self): """ ) ) - - def test_many_repeated_links(self): - # Regression test for #1619: rendering many inline links in one - # paragraph used to rescan the unprocessed text from the start after - # every match, making conversion quadratic in the number of links. - from markdown import Markdown - - text = "[link](x)" * 8192 - start = time.monotonic() - html = Markdown().convert(text) - elapsed = time.monotonic() - start - - # The old implementation takes several seconds (or more on slow CI) - # for this input; the linear implementation finishes well under a - # second on any machine. Allow a generous ceiling to avoid flakes. - self.assertLess(elapsed, 5) - self.assertEqual(html.count("
"), 8192)