Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ The AUTHORS/Contributors are (and/or have been):
* Edward Ross <gh: EdwardJRoss>
* Gregory Anders <gh: gpanders>
* Alex Vandiver <gh: alexmv>
* Ryan Chou <gh: ryanchou1994>

Maintainer:

Expand Down
5 changes: 5 additions & 0 deletions ChangeLog.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
Unreleased
==========

* Fix #424: Keep long headings on one line, including headings in blockquotes.

2025.4.15
=========
----
Expand Down
4 changes: 2 additions & 2 deletions html2text/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"([`\*_{}\[\]\(\)#!])")

Expand Down Expand Up @@ -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,
)

Expand Down
4 changes: 4 additions & 0 deletions html2text/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
7 changes: 7 additions & 0 deletions test/long_headings.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<h1>A long heading with enough words to exceed the default line width while preserving all of its text</h1>
<h2><em>A long heading with enough words to exceed the default line width while preserving all of its text</em></h2>
<h3>A long heading with a <a href="https://example.com/">link containing several words that must stay together</a> and more text</h3>
<h4>A long heading with enough words to exceed the default line width while preserving all of its text</h4>
<h5>A long heading with enough words to exceed the default line width while preserving all of its text</h5>
<h6>A long heading with enough words to exceed the default line width while preserving all of its text</h6>
<blockquote><h3>A long heading with enough words to exceed the default line width while preserving all of its text</h3></blockquote>
13 changes: 13 additions & 0 deletions test/long_headings.md
Original file line number Diff line number Diff line change
@@ -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
34 changes: 34 additions & 0 deletions test/test_html2text.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,3 +254,37 @@ def test_strong_emptied() -> None:
h.strong_mark = ""
string = "A <b>B</b> <i>C</i>."
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 = (
"<blockquote>" * depth
+ f"<h{level}>{heading}</h{level}>"
+ "</blockquote>" * 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"<p>{text}</p>", 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", ["#&nbsp;", "&gt;&nbsp;# ", "&nbsp;# "])
def test_non_ascii_heading_whitespace_still_wraps(prefix):
h = html2text.HTML2Text(bodywidth=20)
h.unicode_snob = True
result = h.handle(
"<p>" + prefix + "Several ordinary words that should still wrap.</p>"
).strip()
assert "\n" in result
assert all(len(line) <= 20 for line in result.splitlines())