From 957813b40b48b25701496e81f14882fbc5413c86 Mon Sep 17 00:00:00 2001 From: Ryan Chou <88779759+ryanchou1994@users.noreply.github.com> Date: Wed, 9 Sep 2026 06:07:46 +0800 Subject: [PATCH] Fix hard wrapping of long headings --- AUTHORS.rst | 1 + ChangeLog.rst | 5 +++++ html2text/config.py | 4 ++-- html2text/utils.py | 4 ++++ test/long_headings.html | 7 +++++++ test/long_headings.md | 13 +++++++++++++ test/test_html2text.py | 34 ++++++++++++++++++++++++++++++++++ 7 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 test/long_headings.html create mode 100644 test/long_headings.md diff --git a/AUTHORS.rst b/AUTHORS.rst index 5b3ac10..cb802b5 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -40,6 +40,7 @@ The AUTHORS/Contributors are (and/or have been): * Edward Ross * Gregory Anders * Alex Vandiver +* Ryan Chou Maintainer: diff --git a/ChangeLog.rst b/ChangeLog.rst index 2d96e3b..1bc7072 100644 --- a/ChangeLog.rst +++ b/ChangeLog.rst @@ -1,3 +1,8 @@ +Unreleased +========== + +* Fix #424: Keep long headings on one line, including headings in blockquotes. + 2025.4.15 ========= ---- diff --git a/html2text/config.py b/html2text/config.py index ee316b4..b0d7c6d 100644 --- a/html2text/config.py +++ b/html2text/config.py @@ -62,6 +62,7 @@ RE_ORDERED_LIST_MATCHER = re.compile(r"\d+\.\s") RE_UNORDERED_LIST_MATCHER = re.compile(r"[-\*\+]\s") +RE_MD_HEADING_MATCHER = re.compile(r"(?:>[ \t]*)*#{1,6}[ \t]") RE_MD_CHARS_MATCHER = re.compile(r"([\\\[\]\(\)])") RE_MD_CHARS_MATCHER_ALL = re.compile(r"([`\*_{}\[\]\(\)#!])") @@ -104,8 +105,7 @@ r""" (\\) # match one slash (?=[%s]) # followed by a char that requires escaping - """ - % re.escape(RE_SLASH_CHARS), + """ % re.escape(RE_SLASH_CHARS), flags=re.VERBOSE, ) diff --git a/html2text/utils.py b/html2text/utils.py index 8f77668..33f02d4 100644 --- a/html2text/utils.py +++ b/html2text/utils.py @@ -202,6 +202,10 @@ def skipwrap( if para[0:4] == " " or para[0] == "\t": return True + # ATX headings, including quoted headings, must stay on a single line. + if config.RE_MD_HEADING_MATCHER.match(para.lstrip(" \t")): + return True + # If the text begins with only two "--", possibly preceded by # whitespace, that's an emdash; so wrap. stripped = para.lstrip() diff --git a/test/long_headings.html b/test/long_headings.html new file mode 100644 index 0000000..ba2119a --- /dev/null +++ b/test/long_headings.html @@ -0,0 +1,7 @@ +

A long heading with enough words to exceed the default line width while preserving all of its text

+

A long heading with enough words to exceed the default line width while preserving all of its text

+

A long heading with a link containing several words that must stay together and more text

+

A long heading with enough words to exceed the default line width while preserving all of its text

+
A long heading with enough words to exceed the default line width while preserving all of its text
+
A long heading with enough words to exceed the default line width while preserving all of its text
+

A long heading with enough words to exceed the default line width while preserving all of its text

diff --git a/test/long_headings.md b/test/long_headings.md new file mode 100644 index 0000000..343bb6c --- /dev/null +++ b/test/long_headings.md @@ -0,0 +1,13 @@ +# A long heading with enough words to exceed the default line width while preserving all of its text + +## _A long heading with enough words to exceed the default line width while preserving all of its text_ + +### A long heading with a [link containing several words that must stay together](https://example.com/) and more text + +#### A long heading with enough words to exceed the default line width while preserving all of its text + +##### A long heading with enough words to exceed the default line width while preserving all of its text + +###### A long heading with enough words to exceed the default line width while preserving all of its text + +> ### A long heading with enough words to exceed the default line width while preserving all of its text diff --git a/test/test_html2text.py b/test/test_html2text.py index 1f16b4f..585c019 100644 --- a/test/test_html2text.py +++ b/test/test_html2text.py @@ -254,3 +254,37 @@ def test_strong_emptied() -> None: h.strong_mark = "" string = "A B C." assert h.handle(string) == "A B _C_.\n\n" + + +@pytest.mark.parametrize("level", range(1, 7)) +@pytest.mark.parametrize("depth", [0, 1, 2]) +@pytest.mark.parametrize("body_width", [12, 0]) +def test_heading_is_not_wrapped(level, depth, body_width): + heading = "A long heading with several words that must stay together" + source = ( + "
" * depth + + f"{heading}" + + "
" * depth + ) + expected = "> " * depth + "#" * level + " " + heading + assert html2text.HTML2Text(bodywidth=body_width).handle(source).strip() == expected + + +@pytest.mark.parametrize("prefix", ["", "#hashtag ", "####### "]) +def test_non_heading_paragraph_still_wraps(prefix): + text = prefix + "Some ordinary paragraph words that should still wrap." + result = html2text.html2text(f"

{text}

", bodywidth=20).strip() + assert "\n" in result + assert " ".join(result.splitlines()) == text + assert all(len(line) <= 20 for line in result.splitlines()) + + +@pytest.mark.parametrize("prefix", ["# ", "> # ", " # "]) +def test_non_ascii_heading_whitespace_still_wraps(prefix): + h = html2text.HTML2Text(bodywidth=20) + h.unicode_snob = True + result = h.handle( + "

" + prefix + "Several ordinary words that should still wrap.

" + ).strip() + assert "\n" in result + assert all(len(line) <= 20 for line in result.splitlines())