From 7cd7ac249fe562d3d58b1c9b49d4cea9750cf190 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Mon, 28 Sep 2026 11:20:08 -0600 Subject: [PATCH 1/6] Serialize HTML non-recursively --- markdown/serializers.py | 121 +++++++++++++++++++++++----------------- 1 file changed, 69 insertions(+), 52 deletions(-) diff --git a/markdown/serializers.py b/markdown/serializers.py index 573b26483..f6ee5c72a 100644 --- a/markdown/serializers.py +++ b/markdown/serializers.py @@ -48,6 +48,7 @@ from xml.etree.ElementTree import ProcessingInstruction from xml.etree.ElementTree import Comment, ElementTree, Element, QName, HTML_EMPTY import re +from collections import deque from typing import Callable, Literal, NoReturn __all__ = ['to_html_string', 'to_xhtml_string'] @@ -116,60 +117,76 @@ def _escape_attrib_html(text: str) -> str: def _serialize_html(write: Callable[[str], None], elem: Element, format: Literal["html", "xhtml"]) -> None: - tag = elem.tag - text = elem.text - if tag is Comment: - write("" % _escape_cdata(text)) - elif tag is ProcessingInstruction: - write("" % _escape_cdata(text)) - elif tag is None: - if text: - write(_escape_cdata(text)) - for e in elem: - _serialize_html(write, e, format) - else: - namespace_uri = None - if isinstance(tag, QName): - # `QNAME` objects store their data as a string: `{uri}tag` - if tag.text[:1] == "{": - namespace_uri, tag = tag.text[1:].split("}", 1) - else: - raise ValueError('QName objects must define a tag.') - write("<" + tag) - items = elem.items() - if items: - items = sorted(items) # lexical order - for k, v in items: - if isinstance(k, QName): - # Assume a text only `QName` - k = k.text - if isinstance(v, QName): - # Assume a text only `QName` - v = v.text - else: - v = _escape_attrib_html(v) - if k == v and format == 'html': - # handle boolean attributes - write(" %s" % v) - else: - write(' {}="{}"'.format(k, v)) - if namespace_uri: - write(' xmlns="%s"' % (_escape_attrib(namespace_uri))) - if format == "xhtml" and tag.lower() in HTML_EMPTY: - write(" />") - else: - write(">") + stack: deque[Element | str] = deque([elem]) + + count = 0 + while stack: + count += 1 + el = stack.popleft() + + if isinstance(el, str): + write(el) + continue + + tag = el.tag + text = el.text + + if tag is Comment: + write("" % _escape_cdata(text)) + elif tag is ProcessingInstruction: + write("" % _escape_cdata(text)) + elif tag is None: if text: - if tag.lower() in ["script", "style"]: - write(text) + write(_escape_cdata(text)) + # Add the children in reverse order so we process them in the right order. + stack.extendleft(reversed(el)) + else: + namespace_uri = None + if isinstance(tag, QName): + # `QNAME` objects store their data as a string: `{uri}tag` + if tag.text[:1] == "{": + namespace_uri, tag = tag.text[1:].split("}", 1) else: - write(_escape_cdata(text)) - for e in elem: - _serialize_html(write, e, format) - if tag.lower() not in HTML_EMPTY: - write("") - if elem.tail: - write(_escape_cdata(elem.tail)) + raise ValueError('QName objects must define a tag.') + write("<" + tag) + items = el.items() + if items: + items = sorted(items) # lexical order + for k, v in items: + if isinstance(k, QName): + # Assume a text only `QName` + k = k.text + if isinstance(v, QName): + # Assume a text only `QName` + v = v.text + else: + v = _escape_attrib_html(v) + if k == v and format == 'html': + # handle boolean attributes + write(" %s" % v) + else: + write(' {}="{}"'.format(k, v)) + if namespace_uri: + write(' xmlns="%s"' % (_escape_attrib(namespace_uri))) + if format == "xhtml" and tag.lower() in HTML_EMPTY: + write(" />") + else: + write(">") + if text: + if tag.lower() in ["script", "style"]: + write(text) + else: + write(_escape_cdata(text)) + # Add the tail, end tag, and then the children in reverse order. + # Since we pop from the left, this will ensure results are ordered correctly. + if el.tail: + stack.appendleft(_escape_cdata(el.tail)) + if tag.lower() not in HTML_EMPTY: + stack.appendleft("") + stack.extendleft(reversed(el)) + continue + if el.tail: + write(_escape_cdata(el.tail)) def _write_html(root: Element, format: Literal["html", "xhtml"] = "html") -> str: From ad808e7c01244dcddc8cb700dd83e586a70c4793 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Mon, 28 Sep 2026 17:55:13 -0600 Subject: [PATCH 2/6] Update changelog --- docs/changelog.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index 661161c65..025689fa3 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -12,6 +12,10 @@ and this project adheres to the [Python Version Specification](https://packaging.python.org/en/latest/specifications/version-specifiers/). See the [Contributing Guide](contributing.md) for details. +## [Unreleased] + +* Upate serializer to be non-recursive (#1644). + ## [3.11.0] - 2026-09-25 ### Changed From 56482281aa1b03c4c6920c409316f6ee71f55607 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Mon, 28 Sep 2026 19:03:14 -0600 Subject: [PATCH 3/6] Store tail for None tag, just in case --- markdown/serializers.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/markdown/serializers.py b/markdown/serializers.py index f6ee5c72a..3498ab6d5 100644 --- a/markdown/serializers.py +++ b/markdown/serializers.py @@ -139,7 +139,10 @@ def _serialize_html(write: Callable[[str], None], elem: Element, format: Literal if text: write(_escape_cdata(text)) # Add the children in reverse order so we process them in the right order. + if el.tail: + stack.appendleft(_escape_cdata(el.tail)) stack.extendleft(reversed(el)) + continue else: namespace_uri = None if isinstance(tag, QName): From cc38aff0762fec81fb192253d5c9b533e75339f3 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Mon, 28 Sep 2026 19:05:07 -0600 Subject: [PATCH 4/6] Don't call serializer if value is a string Serializer only takes elements, so handle strings before calling instead of purposely feeding it in and capturing failure --- markdown/inlinepatterns.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/markdown/inlinepatterns.py b/markdown/inlinepatterns.py index 0f3533b2e..1908625a5 100644 --- a/markdown/inlinepatterns.py +++ b/markdown/inlinepatterns.py @@ -571,10 +571,10 @@ def get_stash(m: re.Match[str]) -> str: id = m.group(1) value = stash.get(id) if value is not None: - try: + if isinstance(value, etree.Element): # Ensure we don't have a placeholder inside a placeholder return self.unescape(self.md.serializer(value)) - except Exception: + else: return r'\%s' % value return util.INLINE_PLACEHOLDER_RE.sub(get_stash, text) From 82319e6a4dc25d7426af3baa74d1943e5454702d Mon Sep 17 00:00:00 2001 From: facelessuser Date: Tue, 29 Sep 2026 15:52:28 -0600 Subject: [PATCH 5/6] Use f-strings --- markdown/serializers.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/markdown/serializers.py b/markdown/serializers.py index 3498ab6d5..255092b40 100644 --- a/markdown/serializers.py +++ b/markdown/serializers.py @@ -132,9 +132,9 @@ def _serialize_html(write: Callable[[str], None], elem: Element, format: Literal text = el.text if tag is Comment: - write("" % _escape_cdata(text)) + write(f"") elif tag is ProcessingInstruction: - write("" % _escape_cdata(text)) + write(f"") elif tag is None: if text: write(_escape_cdata(text)) @@ -151,7 +151,7 @@ def _serialize_html(write: Callable[[str], None], elem: Element, format: Literal namespace_uri, tag = tag.text[1:].split("}", 1) else: raise ValueError('QName objects must define a tag.') - write("<" + tag) + write(f"<{tag}") items = el.items() if items: items = sorted(items) # lexical order @@ -166,11 +166,11 @@ def _serialize_html(write: Callable[[str], None], elem: Element, format: Literal v = _escape_attrib_html(v) if k == v and format == 'html': # handle boolean attributes - write(" %s" % v) + write(f" {v}") else: - write(' {}="{}"'.format(k, v)) + write(f' {k}="{v}"') if namespace_uri: - write(' xmlns="%s"' % (_escape_attrib(namespace_uri))) + write(f' xmlns="{_escape_attrib(namespace_uri)}"') if format == "xhtml" and tag.lower() in HTML_EMPTY: write(" />") else: @@ -185,7 +185,7 @@ def _serialize_html(write: Callable[[str], None], elem: Element, format: Literal if el.tail: stack.appendleft(_escape_cdata(el.tail)) if tag.lower() not in HTML_EMPTY: - stack.appendleft("") + stack.appendleft(f"") stack.extendleft(reversed(el)) continue if el.tail: From f9966ef3c4602f7fa49706a721d5c966e4b0a82d Mon Sep 17 00:00:00 2001 From: facelessuser Date: Wed, 30 Sep 2026 06:02:53 -0600 Subject: [PATCH 6/6] Fix spelling --- docs/changelog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index 025689fa3..cc31289d9 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -14,7 +14,7 @@ See the [Contributing Guide](contributing.md) for details. ## [Unreleased] -* Upate serializer to be non-recursive (#1644). +* Update serializer to be non-recursive (#1644). ## [3.11.0] - 2026-09-25