diff --git a/CHANGES.md b/CHANGES.md index cd1c7952..e72e8cbf 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -3,6 +3,7 @@ ## python-markdown2 2.5.6 (not yet released) - [pull #762] Speed up documents with many links by skipping the backslash-escape scan for text without a backslash +- [pull #748] Speed up `_unescape_special_chars` by skipping text with no hashes and dropping the full-text comparison per pass (#635) - [pull #758] Fix `footnotes` extra adding an empty footnotes block when no footnote is referenced. - [pull #746] Preserve query strings and fragments in `markdown-file-links` conversions, including reference-link targets. - [pull #739] Fix `header-ids` extra putting sanitized HTML into header ids in safe mode, which allowed attribute injection (#649) diff --git a/lib/markdown2.py b/lib/markdown2.py index 07667744..aefa2acd 100755 --- a/lib/markdown2.py +++ b/lib/markdown2.py @@ -2425,19 +2425,33 @@ 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()) + if 'md5-' not in text: + return text + # Build the hash -> original mapping directly, without intermediate tuples. # 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: + replacements: dict[str, str] = {} + for ch, hash in self._escape_table.items(): replacements.setdefault(hash, ch) - 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: - break + for ch, hash in self._code_table.items(): + replacements.setdefault(hash, ch) + for hash, item in self.html_blocks.items(): + replacements.setdefault(hash, item) + + 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. + replaced = True + while replaced: + replaced = False + text = re.sub(r'md5-[0-9a-f]{32}', _sub, text) return text def _outdent(self, text: str) -> str: