From fb5899ef2b7792b5f8bb93e18bb141222a24a609 Mon Sep 17 00:00:00 2001 From: Swastik Tripathi Date: Sun, 25 Oct 2026 08:46:26 +0530 Subject: [PATCH 1/2] perf: speed up _unescape_special_chars for large documents Two O(n) costs were paid per pass through the while loop: 1. The termination check compared the full text string (`text == orig_text`), which is O(n) even when nothing changed. 2. The replacements dict was built from three intermediate tuples before every call (escape_table + code_table + reversed html_blocks). Fix both: - Skip immediately if 'md5-' is not present in the text (the common case for plain paragraphs with no inline code, links, or escapes). - Build the replacements dict by iterating the source dicts directly, without allocating intermediate tuples. - Replace the string comparison with a boolean flag set inside the re.sub callback; the loop breaks as soon as a full pass produces no replacements. Fixes #635 Co-Authored-By: Claude Sonnet 4.6 --- lib/markdown2.py | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/lib/markdown2.py b/lib/markdown2.py index b3c29847..64fdeeee 100755 --- a/lib/markdown2.py +++ b/lib/markdown2.py @@ -2377,18 +2377,29 @@ def _encode_email_address(self, addr: str) -> str: def _unescape_special_chars(self, text: str) -> str: # Swap back in all the special characters we've hidden. - hashmap = tuple(self._escape_table.items()) + tuple(self._code_table.items()) - # html_blocks table is in format {hash: item} compared to usual {item: hash} - hashmap += tuple(tuple(reversed(i)) for i in self.html_blocks.items()) - replacements = {} - for ch, hash in hashmap: + if 'md5-' not in text: + return text + # Build hash→original mapping directly without intermediate tuple. + # html_blocks is stored inverted ({hash: item}) compared to the others. + replacements: dict[str, str] = {} + for ch, hash in self._escape_table.items(): + replacements.setdefault(hash, ch) + for ch, hash in self._code_table.items(): replacements.setdefault(hash, ch) + for hash, item in self.html_blocks.items(): + replacements.setdefault(hash, item) + # Scan once per nesting level. Use a flag instead of a full-string + # comparison to detect whether any replacement was made this pass. while True: - orig_text = text - # Scan once per nesting level instead of once for every stored hash. - text = re.sub(r'md5-[0-9a-f]{32}', - lambda match: replacements.get(match[0], match[0]), text) - if text == orig_text: + replaced = [False] + def _sub(match: re.Match, _rep: dict = replacements) -> str: + val = _rep.get(match[0]) + if val is not None: + replaced[0] = True + return val + return match[0] + text = re.sub(r'md5-[0-9a-f]{32}', _sub, text) + if not replaced[0]: break return text From 4bf3ddb869056e966628b8853776a85ba862744c Mon Sep 17 00:00:00 2001 From: Swastik Tripathi Date: Tue, 27 Oct 2026 10:31:23 +0530 Subject: [PATCH 2/2] Address review: nonlocal flag, define _sub once, ASCII comments Use a nonlocal flag instead of a one-element list, define the re.sub callback once outside the loop, use replacements directly instead of a default kwarg, and replace the arrow character with plain ASCII. Add a CHANGES entry. Co-Authored-By: Claude Opus 5.5 --- CHANGES.md | 1 + lib/markdown2.py | 30 ++++++++++++++++++------------ 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 8911b5cb..a6dc7efb 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,7 @@ ## python-markdown2 2.5.6 (not yet released) +- [pull #748] Speed up `_unescape_special_chars` by skipping text with no hashes and dropping the full-text comparison per pass (#635) - [pull #730] Fix `tables` extra splitting multi-backtick code spans at a leading pipe and merging cells after a code span containing literal backticks. - [pull #729] Fix `tables` extra merging cells when a pipe directly follows a code span, as in compact rows like `|`-v`|verbose|`. - [pull #725] Fix `tables` extra dropping escaped pipes at the end of header and body rows. diff --git a/lib/markdown2.py b/lib/markdown2.py index 64fdeeee..067ca839 100755 --- a/lib/markdown2.py +++ b/lib/markdown2.py @@ -2379,8 +2379,8 @@ def _unescape_special_chars(self, text: str) -> str: # Swap back in all the special characters we've hidden. if 'md5-' not in text: return text - # Build hash→original mapping directly without intermediate tuple. - # html_blocks is stored inverted ({hash: item}) compared to the others. + # Build the hash -> original mapping directly, without intermediate tuples. + # html_blocks table is in format {hash: item} compared to usual {item: hash} replacements: dict[str, str] = {} for ch, hash in self._escape_table.items(): replacements.setdefault(hash, ch) @@ -2388,18 +2388,24 @@ def _unescape_special_chars(self, text: str) -> str: replacements.setdefault(hash, ch) for hash, item in self.html_blocks.items(): replacements.setdefault(hash, item) - # Scan once per nesting level. Use a flag instead of a full-string - # comparison to detect whether any replacement was made this pass. - while True: - replaced = [False] - def _sub(match: re.Match, _rep: dict = replacements) -> str: - val = _rep.get(match[0]) - if val is not None: - replaced[0] = True - return val + + replaced = False + + def _sub(match: re.Match) -> str: + nonlocal replaced + val = replacements.get(match[0]) + if val is None: return match[0] + replaced = True + return val + + # Scan once per nesting level instead of once for every stored hash. + # Stop when a pass makes no replacement, rather than comparing the + # whole text against the previous pass. + while True: + replaced = False text = re.sub(r'md5-[0-9a-f]{32}', _sub, text) - if not replaced[0]: + if not replaced: break return text