From 7456a216224ff6656f6d97bc7f55a0cdfb321657 Mon Sep 17 00:00:00 2001
From: Ryan Chou <88779759+ryanchou1994@users.noreply.github.com>
Date: Tue, 22 Sep 2026 05:19:44 +0800
Subject: [PATCH] fix: preserve automatic links with entity text
---
ChangeLog.rst | 1 +
html2text/__init__.py | 158 +++++++++++++++++++++++++++++++++--------
test/test_html2text.py | 28 ++++++++
3 files changed, 159 insertions(+), 28 deletions(-)
diff --git a/ChangeLog.rst b/ChangeLog.rst
index 2d96e3b..6d23ac9 100644
--- a/ChangeLog.rst
+++ b/ChangeLog.rst
@@ -2,6 +2,7 @@
=========
----
+* Fix #441: Preserve automatic links when HTML entities split link text.
* Fix #418: Publishing wheels to PyPI.
* Feature #386: Support tri-backquote style code block and fix ordered list indent.
* Fix #310: Fix invalid character reference parsing.
diff --git a/html2text/__init__.py b/html2text/__init__.py
index 621fa74..211f2cc 100644
--- a/html2text/__init__.py
+++ b/html2text/__init__.py
@@ -106,6 +106,8 @@ def __init__(
self.a: List[AnchorElement] = []
self.astack: List[Optional[Dict[str, Optional[str]]]] = []
self.maybe_automatic_link: Optional[str] = None
+ self.maybe_automatic_link_text: List[Tuple[str, bool]] = []
+ self.automatic_link_emitted = False
self.empty_link = False
self.absolute_url_matcher = re.compile(r"^[a-zA-Z+]+://")
self.acount = 0
@@ -162,6 +164,11 @@ def outtextf(self, s: str) -> None:
def finish(self) -> str:
self.close()
+ # HTMLParser does not synthesize an end tag for an unclosed anchor.
+ # Close a pending auto-link so buffered text is not lost.
+ if self.should_buffer_maybe_automatic_link():
+ self.handle_tag("a", {}, start=False)
+
self.pbr()
self.o("", force="end")
@@ -200,6 +207,51 @@ def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> N
def handle_endtag(self, tag: str) -> None:
self.handle_tag(tag, {}, start=False)
+ def should_buffer_maybe_automatic_link(self) -> bool:
+ return bool(
+ self.maybe_automatic_link
+ and self.use_automatic_links
+ and self.absolute_url_matcher.match(self.maybe_automatic_link)
+ and "&" in self.maybe_automatic_link
+ )
+
+ def flush_maybe_automatic_link(self) -> None:
+ """Start a regular link and replay text buffered for auto-link detection."""
+ if self.maybe_automatic_link is None:
+ return
+
+ self.o("[")
+ for data, entity_char in self.maybe_automatic_link_text:
+ if not self.code and not self.pre and not entity_char:
+ data = escape_md_section(data, snob=self.escape_snob)
+ self.o(data, puredata=True)
+ self.maybe_automatic_link = None
+ self.maybe_automatic_link_text = []
+ self.automatic_link_emitted = False
+ self.empty_link = False
+
+ def finish_maybe_automatic_link(self) -> bool:
+ """Emit an automatic link if its complete label matches its href."""
+ if self.maybe_automatic_link is None:
+ return False
+
+ href = self.maybe_automatic_link
+ label = "".join(data for data, _ in self.maybe_automatic_link_text)
+ if (
+ label == href
+ and self.absolute_url_matcher.match(href)
+ and self.use_automatic_links
+ ):
+ self.o("<" + href + ">")
+ self.maybe_automatic_link = None
+ self.maybe_automatic_link_text = []
+ self.automatic_link_emitted = True
+ self.empty_link = False
+ return True
+
+ self.flush_maybe_automatic_link()
+ return False
+
def previousIndex(self, attrs: Dict[str, Optional[str]]) -> Optional[int]:
"""
:type attrs: dict
@@ -318,9 +370,13 @@ def handle_tag(
and tag not in ["p", "div", "style", "dl", "dt"]
and (tag != "img" or self.ignore_images)
):
- self.o("[")
- self.maybe_automatic_link = None
- self.empty_link = False
+ if self.should_buffer_maybe_automatic_link():
+ self.flush_maybe_automatic_link()
+ else:
+ self.o("[")
+ self.maybe_automatic_link = None
+ self.automatic_link_emitted = False
+ self.empty_link = False
if self.google_doc:
# the attrs parameter is empty for a closing tag. in addition, we
@@ -514,6 +570,8 @@ def link_url(self: HTML2Text, link: str, title: str = "") -> None:
):
self.astack.append(attrs)
self.maybe_automatic_link = attrs["href"]
+ self.maybe_automatic_link_text = []
+ self.automatic_link_emitted = False
self.empty_link = True
if self.protect_links:
attrs["href"] = "<" + attrs["href"] + ">"
@@ -522,7 +580,18 @@ def link_url(self: HTML2Text, link: str, title: str = "") -> None:
else:
if self.astack:
a = self.astack.pop()
- if self.maybe_automatic_link and not self.empty_link:
+ automatic_link = self.automatic_link_emitted
+ if (
+ self.should_buffer_maybe_automatic_link()
+ and not self.empty_link
+ ):
+ if automatic_link:
+ self.maybe_automatic_link = None
+ else:
+ automatic_link = self.finish_maybe_automatic_link()
+ if automatic_link:
+ pass
+ elif self.maybe_automatic_link and not self.empty_link:
self.maybe_automatic_link = None
elif a:
assert a["href"] is not None
@@ -544,6 +613,7 @@ def link_url(self: HTML2Text, link: str, title: str = "") -> None:
a_props = AnchorElement(a, self.acount, self.outcount)
self.a.append(a_props)
self.o("][" + str(a_props.count) + "]")
+ self.automatic_link_emitted = False
if tag == "img" and start and not self.ignore_images:
if "src" in attrs and attrs["src"] is not None:
@@ -567,20 +637,33 @@ def link_url(self: HTML2Text, link: str, title: str = "") -> None:
return
# If we have a link to create, output the start
+ if (
+ self.maybe_automatic_link is not None
+ and self.automatic_link_emitted
+ ):
+ self.o("[")
+ self.maybe_automatic_link = None
+ self.automatic_link_emitted = False
+ self.empty_link = False
if self.maybe_automatic_link is not None:
- href = self.maybe_automatic_link
- if (
- self.images_to_alt
- and escape_md(alt) == href
- and self.absolute_url_matcher.match(href)
- ):
- self.o("<" + escape_md(alt) + ">")
- self.empty_link = False
- return
+ if self.maybe_automatic_link_text:
+ self.flush_maybe_automatic_link()
else:
- self.o("[")
- self.maybe_automatic_link = None
- self.empty_link = False
+ href = self.maybe_automatic_link
+ if (
+ self.images_to_alt
+ and escape_md(alt) == href
+ and self.absolute_url_matcher.match(href)
+ ):
+ self.o("<" + escape_md(alt) + ">")
+ self.empty_link = False
+ self.automatic_link_emitted = True
+ return
+ else:
+ self.o("[")
+ self.maybe_automatic_link = None
+ self.automatic_link_emitted = False
+ self.empty_link = False
# If we have images_to_alt, we discard the image itself,
# considering only the alt text.
@@ -892,20 +975,39 @@ def handle_data(self, data: str, entity_char: bool = False) -> None:
if self.style:
self.style_def.update(dumb_css_parser(data))
+ if self.maybe_automatic_link is not None and self.automatic_link_emitted:
+ self.o("[")
+ self.maybe_automatic_link = None
+ self.automatic_link_emitted = False
+ self.empty_link = False
+
if self.maybe_automatic_link is not None:
- href = self.maybe_automatic_link
- if (
- href == data
- and self.absolute_url_matcher.match(href)
- and self.use_automatic_links
- ):
- self.o("<" + data + ">")
- self.empty_link = False
- return
+ if self.should_buffer_maybe_automatic_link():
+ if data.strip():
+ self.maybe_automatic_link_text.append((data, entity_char))
+ self.empty_link = False
+ self.preceding_data = (
+ escape_md_section(data, snob=self.escape_snob)
+ if not self.code and not self.pre and not entity_char
+ else data
+ )
+ return
+ self.flush_maybe_automatic_link()
else:
- self.o("[")
- self.maybe_automatic_link = None
- self.empty_link = False
+ href = self.maybe_automatic_link
+ if (
+ href == data
+ and self.absolute_url_matcher.match(href)
+ and self.use_automatic_links
+ ):
+ self.o("<" + data + ">")
+ self.empty_link = False
+ return
+ else:
+ self.o("[")
+ self.maybe_automatic_link = None
+ self.automatic_link_emitted = False
+ self.empty_link = False
if not self.code and not self.pre and not entity_char:
data = escape_md_section(data, snob=self.escape_snob)
diff --git a/test/test_html2text.py b/test/test_html2text.py
index 1f16b4f..0132315 100644
--- a/test/test_html2text.py
+++ b/test/test_html2text.py
@@ -254,3 +254,31 @@ def test_strong_emptied() -> None:
h.strong_mark = ""
string = "A B C."
assert h.handle(string) == "A B _C_.\n\n"
+
+
+def test_automatic_link_with_ampersand_entity() -> None:
+ """Entity callbacks must not make an equal link label look different."""
+ html = 'https://foo/?x=1&y=2'
+ assert html2text.html2text(html) == "\n\n"
+
+
+def test_automatic_link_keeps_regular_links_when_label_differs() -> None:
+ html = 'https://foo/?x=1&y=3'
+ assert html2text.html2text(html) == (
+ "[https://foo/?x=1&y=3](https://foo/?x=1&y=2)\n\n"
+ )
+
+
+def test_automatic_link_compares_before_markdown_escaping() -> None:
+ h = html2text.HTML2Text()
+ h.escape_snob = True
+ html = 'https://foo/a_b'
+ assert h.handle(html) == "\n\n"
+
+
+def test_automatic_link_handles_streamed_entity_callbacks() -> None:
+ h = html2text.HTML2Text()
+ h.feed('https://foo/?x=1&')
+ h.feed("y=2")
+ assert h.finish() == "\n"