diff --git a/changelog.d/1370.fixed.md b/changelog.d/1370.fixed.md
new file mode 100644
index 000000000..be9776a90
--- /dev/null
+++ b/changelog.d/1370.fixed.md
@@ -0,0 +1 @@
+- **Hard-wrapped paragraph text no longer keeps its line breaks** — `HTMLParser` now collapses whitespace runs inside a text node to one space, as a browser does, while `
` and `
` still break lines. Item 7 of Google's FY2004 10-K had about 500 mid-sentence newlines. (#1370)
diff --git a/edgar/documents/parser.py b/edgar/documents/parser.py
index d0ff8e06d..6e92dcdf2 100644
--- a/edgar/documents/parser.py
+++ b/edgar/documents/parser.py
@@ -3,6 +3,7 @@
"""
import logging
+import re
import time
from typing import List, Optional, Union
@@ -10,6 +11,7 @@
from lxml import etree
from lxml.html import HtmlElement
+from edgar.core import is_probably_html
from edgar.documents.config import ParserConfig
from edgar.documents.document import Document, DocumentMetadata
from edgar.documents.exceptions import DocumentTooLargeError, HTMLParsingError, InvalidConfigurationError
@@ -27,6 +29,14 @@
logger = logging.getLogger(__name__)
+# Filing.html() wraps a plain-text primary document in exactly this. Its line
+# breaks are the document's structure ("Item 8.01 Other Events" on its own
+# line), so it is parsed as preformatted text rather than collapsed as a browser
+# collapses HTML whitespace (gh-1370).
+_PLAIN_TEXT_WRAPPER = ''
+_PREFORMATTED_WRAPPER = ''
+_COMMENT = re.compile(r'', re.DOTALL)
+
class HTMLParser:
"""
@@ -133,6 +143,14 @@ def parse(self, html: Union[str, bytes]) -> Document:
# Store original HTML BEFORE preprocessing (needed for TOC analysis)
original_html = html
+ if html.startswith(_PLAIN_TEXT_WRAPPER):
+ html = _PREFORMATTED_WRAPPER + html[len(_PLAIN_TEXT_WRAPPER):]
+ elif not is_probably_html(html) and _COMMENT.sub('', html).strip():
+ # A raw .txt primary document handed straight to the parser
+ # (Filing.parsed_items on a 1999 8-K) is plain text too. Input with
+ # nothing but comments is not a document; it still fails to parse.
+ html = f"{_PREFORMATTED_WRAPPER}{html}"
+
# Preprocessing (will remove ix:hidden for rendering)
html = self.preprocessor.process(html)
diff --git a/edgar/documents/strategies/document_builder.py b/edgar/documents/strategies/document_builder.py
index dff654a89..a0df78e01 100644
--- a/edgar/documents/strategies/document_builder.py
+++ b/edgar/documents/strategies/document_builder.py
@@ -2,6 +2,7 @@
Document builder that converts parsed HTML tree into document nodes.
"""
+import re
from typing import Any, Dict, Optional
from lxml.html import HtmlElement
@@ -24,6 +25,10 @@
from edgar.documents.table_nodes import Cell, Row, TableNode
from edgar.documents.types import ParseContext, SemanticType, Style
+# HTML's "ASCII whitespace". Deliberately excludes \xa0: a non-breaking space is content.
+_ASCII_WHITESPACE_RUN = re.compile(r'[ \t\n\r\f]+')
+_PRE_WHITE_SPACE = re.compile(r'white-space\s*:\s*(pre|break-spaces)', re.IGNORECASE)
+
class DocumentBuilder:
"""
@@ -104,7 +109,17 @@ def build(self, tree: HtmlElement) -> DocumentNode:
return root
@staticmethod
- def _collapse_edges(text: str) -> str:
+ def _is_preformatted(element: Optional[HtmlElement]) -> bool:
+ """True when element sits in a or a white-space: pre* box, where newlines are content."""
+ while element is not None:
+ tag = element.tag
+ if isinstance(tag, str):
+ if tag.lower() == 'pre' or _PRE_WHITE_SPACE.search(element.get('style') or ''):
+ return True
+ element = element.getparent()
+ return False
+
+ def _collapse_edges(self, text: str, context: Optional[HtmlElement] = None) -> str:
"""Collapse a text node's edge whitespace to a single space instead of deleting it.
lxml puts the whitespace that separates a word from an adjacent inline element on
@@ -112,10 +127,16 @@ def _collapse_edges(text: str) -> str:
('per $1,000' -> 'per$1,000') and nothing downstream can tell that
apart from a genuine mid-word split. Same rule the preprocessor follows: collapse,
never delete. Whitespace-only nodes still return '' — those carry no word.
+
+ Runs of whitespace inside the node collapse to one space too, as a browser renders
+ them: older filings hard-wrap text at ~150 columns (gh-1370). `context` is the
+ element whose content the text is; inside
the inner whitespace is kept.
"""
stripped = text.strip()
if not stripped:
return ''
+ if not self._is_preformatted(context):
+ stripped = _ASCII_WHITESPACE_RUN.sub(' ', stripped)
return (' ' if text[:1].isspace() else '') + stripped + (' ' if text[-1:].isspace() else '')
def _process_element(self, element: HtmlElement, parent: Node) -> Optional[Node]:
@@ -139,7 +160,7 @@ def _process_element(self, element: HtmlElement, parent: Node) -> Optional[Node]
parent.add_child(text_node)
else:
if element.tail.strip():
- text_node = TextNode(content=self._collapse_edges(element.tail))
+ text_node = TextNode(content=self._collapse_edges(element.tail, element.getparent()))
parent.add_child(text_node)
return None
@@ -187,7 +208,7 @@ def _process_element(self, element: HtmlElement, parent: Node) -> Optional[Node]
node.add_child(text_node)
else:
if element.text.strip():
- text_node = TextNode(content=self._collapse_edges(element.text))
+ text_node = TextNode(content=self._collapse_edges(element.text, element))
node.add_child(text_node)
# Process child elements
@@ -201,7 +222,7 @@ def _process_element(self, element: HtmlElement, parent: Node) -> Optional[Node]
parent.add_child(text_node)
else:
if element.tail.strip():
- text_node = TextNode(content=self._collapse_edges(element.tail))
+ text_node = TextNode(content=self._collapse_edges(element.tail, element.getparent()))
parent.add_child(text_node)
elif element.tail.isspace():
# Even if tail is just whitespace, preserve the spacing info
@@ -216,7 +237,7 @@ def _process_element(self, element: HtmlElement, parent: Node) -> Optional[Node]
parent.add_child(text_node)
else:
if element.tail.strip():
- text_node = TextNode(content=self._collapse_edges(element.tail))
+ text_node = TextNode(content=self._collapse_edges(element.tail, element.getparent()))
parent.add_child(text_node)
elif element.tail.isspace():
# Even if tail is just whitespace, preserve the spacing info
@@ -234,7 +255,7 @@ def _process_element(self, element: HtmlElement, parent: Node) -> Optional[Node]
parent.add_child(text_node)
else:
if element.tail.strip():
- text_node = TextNode(content=self._collapse_edges(element.tail))
+ text_node = TextNode(content=self._collapse_edges(element.tail, element.getparent()))
parent.add_child(text_node)
# Exit XBRL context
@@ -591,6 +612,7 @@ def _get_element_text(self, element: HtmlElement) -> str:
"""Get text content from element."""
inline = element.tag.lower() in self.INLINE_ELEMENTS
text_parts = []
+ block_breaks: set = set() # indexes of parts that separate block children
# Get element's direct text
if element.text:
@@ -619,10 +641,21 @@ def _get_element_text(self, element: HtmlElement) -> str:
# their trailing text is still part of the document. (gh-898)
if child.tail:
if inline:
+ if child.tail.isspace() and child.tag.lower() not in self.INLINE_ELEMENTS:
+ # The line break between two block children of an inline
+ # element (..
\n..
) is the only
+ # thing separating their paragraphs; it is not a run to
+ # collapse (Merck's 2005 CORRESP letter lost all 67 lines).
+ block_breaks.add(len(text_parts))
text_parts.append(child.tail)
elif child.tail.strip():
text_parts.append(child.tail.strip())
+ # Collapse inner whitespace runs as a browser does; edges keep one space (gh-1370)
+ if not self.config.preserve_whitespace and not self._is_preformatted(element):
+ text_parts = [part if i in block_breaks else _ASCII_WHITESPACE_RUN.sub(' ', part)
+ for i, part in enumerate(text_parts)]
+
# A single preserved inline part is returned as-is; otherwise space-join
# so word boundaries survive (the preprocessor strips whitespace adjacent
# to tags, and this re-separates the parts).
diff --git a/edgar/offerings/prospectus/_424b_cover.py b/edgar/offerings/prospectus/_424b_cover.py
index e36b2e705..f229c8c93 100644
--- a/edgar/offerings/prospectus/_424b_cover.py
+++ b/edgar/offerings/prospectus/_424b_cover.py
@@ -244,6 +244,7 @@ def _clean_agent_name(name: str) -> str:
# Defined-abbreviation pattern: ' ("ABBR")'.
_ABBREV_DEF_RE = re.compile(
r"([A-Z][A-Za-z0-9 ,.&'-]{3,45}?)\s*\(\s*[\"“]([A-Za-z&.']{2,10})[\"”]\s*\)")
+_SENTENCE_TAIL_RE = re.compile(r"[A-Z][a-z]+\.\s+(?=[A-Z])")
def _resolve_abbreviation(name: str, text: str) -> str:
@@ -255,7 +256,14 @@ def _resolve_abbreviation(name: str, text: str) -> str:
"""
for m in _ABBREV_DEF_RE.finditer(text):
if m.group(2).strip() == name:
- return m.group(1).strip()
+ full = m.group(1).strip()
+ # The capture starts at the leftmost capital, so it can open on the
+ # end of the previous sentence: "... per Note. BofA Securities, Inc."
+ # A single capitalised word and a full stop is that sentence's end;
+ # "J.P. Morgan" and "Goldman Sachs & Co. LLC" do not open that way.
+ while (lead := _SENTENCE_TAIL_RE.match(full)) is not None:
+ full = full[lead.end():]
+ return full
return name
diff --git a/tests/documents/test_html_parser_regressions.py b/tests/documents/test_html_parser_regressions.py
index 14a44ca60..8cfb77b00 100644
--- a/tests/documents/test_html_parser_regressions.py
+++ b/tests/documents/test_html_parser_regressions.py
@@ -156,6 +156,68 @@ def test_unspaced_edges_still_stay_glued(self):
assert 'RegulationS-T(the rule)' in text
+class TestHardWrappedTextNode:
+ """gh-1370: whitespace runs INSIDE a text node collapse to one space.
+
+ Older EDGAR HTML is hard-wrapped at ~150 columns inside . Google's FY2004
+ 10-K (0001193125-05-065298) yielded ~500 mid-sentence newlines in Item 7.
+ """
+
+ def test_newline_inside_a_paragraph_collapses(self):
+ html = ('
Our stock has been quoted since August 19,\n2004. '
+ 'Prior to that time there was no market.
')
+ text = parse_html(html, ParserConfig(detect_sections=False)).text()
+ assert text.strip() == ('Our stock has been quoted since August 19, 2004. '
+ 'Prior to that time there was no market.')
+
+ def test_newline_inside_an_inline_font_collapses(self):
+ html = ('net revenues\n increased by 118% '
+ 'in 2004.
')
+ text = parse_html(html).text()
+ assert 'net revenues increased by 118% in 2004.' in text
+
+ def test_br_is_still_a_line_break(self):
+ html = 'Mountain View,
California
'
+ text = parse_html(html).text()
+ assert 'Mountain View,\nCalifornia' in text
+
+ def test_pre_keeps_its_newlines(self):
+ html = 'Line one\nLine two
'
+ text = parse_html(html).text()
+ assert 'Line one\nLine two' in text
+
+ def test_wrapped_plain_text_document_keeps_its_lines(self):
+ """Filing.html() wraps a .txt primary document in .
+
+ Its lines are its structure: GMAC's 2005 8-K (0000040729-05-000026) puts
+ "Item 8.01 Other Events" on its own line, and collapsing it into the
+ paragraph above lost the item.
+ """
+ html = ('registered public accounting firm, Deloitte & Touche LLP.\n\n'
+ 'Item 8.01 Other Events\n\nIn order to analyze')
+ text = parse_html(html).text()
+ assert '\nItem 8.01 Other Events\n' in text # the preprocessor squeezes spaces, as on main
+
+ def test_raw_plain_text_keeps_its_lines(self):
+ """A raw .txt document handed straight to the parser (Filing.parsed_items)."""
+ text = parse_html('ITEM 7. Financial Statements and Exhibits\n\n'
+ 'ITEM 8. Not applicable.').text()
+ assert 'Exhibits\n' in text and '\nITEM 8. Not applicable.' in text
+
+ def test_paragraphs_inside_an_inline_wrapper_stay_separate(self):
+ """ around block s: the break between them is not a run to collapse.
+
+ Merck's 2005 CORRESP letter (0000065873-05-000060) wraps every paragraph
+ in one , and collapsing joined all 67 lines into one.
+ """
+ html = ('ALLTEL CORPORATION
\n'
+ 'One Allied Drive, Little Rock,\nArkansas 72202
\n'
+ 'June 22, 2005
')
+ lines = [line.strip() for line in parse_html(html).text().splitlines() if line.strip()]
+ assert lines == ['ALLTEL CORPORATION', 'One Allied Drive, Little Rock, Arkansas 72202',
+ 'June 22, 2005']
+
+
class TestSpacerElementWordGluing:
"""A third copy of the same bug: whitespace-only spacer elements.
diff --git a/tests/issues/regression/test_dt1f1_era_item_titles.py b/tests/issues/regression/test_dt1f1_era_item_titles.py
index f3ba1d373..34a20eec2 100644
--- a/tests/issues/regression/test_dt1f1_era_item_titles.py
+++ b/tests/issues/regression/test_dt1f1_era_item_titles.py
@@ -143,7 +143,7 @@ def test_both_era_sections_are_detected(doc_1999):
# header cells sat in a column with one substantial entry per several rows,
# which the scorer read as spacing. ZERO tokens are lost and the number
# sequence is untouched -- the 547 added characters are those headers.
- assert len(sections["part_iii_item_14"].text()) == 16610
+ assert len(sections["part_iii_item_14"].text()) == 16609 # -1: a whitespace run inside a text node collapses (gh-1370)
assert sections["part_iii_item_14"].text().startswith("Item 14: Exhibits")
# The neighbours are unchanged — this adds sections, it does not re-cut the
@@ -190,8 +190,8 @@ def test_the_lookups_resolve_without_the_legacy_parser():
assert len(report["Item 4"]) == 177
assert len(report["4"]) == 177 # the short spelling resolves too
# Re-pinned for edgartools-y0ri; see the note in the test above.
- assert len(report["Item 14"]) == 16610
- assert len(report["14"]) == 16610
+ assert len(report["Item 14"]) == 16609 # -1: a whitespace run inside a text node collapses (gh-1370)
+ assert len(report["14"]) == 16609 # -1: a whitespace run inside a text node collapses (gh-1370)
items = report.items
assert "Item 4" in items
diff --git a/tests/issues/regression/test_dt1f1_wrapped_item_headers.py b/tests/issues/regression/test_dt1f1_wrapped_item_headers.py
index 799ca4e28..4393e8361 100644
--- a/tests/issues/regression/test_dt1f1_wrapped_item_headers.py
+++ b/tests/issues/regression/test_dt1f1_wrapped_item_headers.py
@@ -132,7 +132,7 @@ def test_20f_wrapped_headers_resolve_without_the_legacy_parser():
report = _without_legacy(TwentyF)(filing)
# 'Item 6. Directors, Senior Management and\nEmployees'
- assert len(report["Item 6"]) == 42395 # table cells no longer fused (edgartools-wzgu)
+ assert len(report["Item 6"]) == 42393 # wzgu; -2 for whitespace runs collapsed inside a text node (gh-1370)
assert report["Item 6"].split("\n", 1)[0].startswith("Item 6.")
# 'Item 11. Quantitative and Qualitative Disclosures\nAbout Market Risk.'
@@ -140,7 +140,7 @@ def test_20f_wrapped_headers_resolve_without_the_legacy_parser():
assert report["Item 11"].split("\n", 1)[0].startswith("Item 11.")
# And the same answers on the real class, legacy fallback and all.
- assert len(TwentyF(filing)["Item 6"]) == 42395 # table cells no longer fused (edgartools-wzgu)
+ assert len(TwentyF(filing)["Item 6"]) == 42393 # wzgu; -2 for gh-1370
assert len(TwentyF(filing)["Item 11"]) == 268
diff --git a/tests/issues/regression/test_filing_text_baseline.py b/tests/issues/regression/test_filing_text_baseline.py
index 757c2f197..f60f7c728 100644
--- a/tests/issues/regression/test_filing_text_baseline.py
+++ b/tests/issues/regression/test_filing_text_baseline.py
@@ -168,6 +168,46 @@ def sha256(text):
# already ("nothing was repaired in the text itself"). The invariant is a
# property of an individual re-capture, not of this file.
+# Re-captured against b022ad29ac3965248c473b5790cf8e124887eac6: gh-1370: whitespace runs inside a text node collapse to one space as a browser renders them, so hard-wrapped paragraph text rejoins. Block-level breaks inside inline wrappers and plain-text documents keep their lines.
+# 4 of 5 filings moved; 1 byte-identical.
+# 0001193125-20-052640: 5782 -> 3795 lines, ~2142 changed
+# -(Exact
+# -Name of Registrant as Specified in Its Charter)
+# +(Exact Name of Registrant as Specified in Its Charter)
+# -Indicate by check mark if the registrant is a well-known seasoned issuer, as defined in Rule 405 of the Securities
+# -Act. Yes ☐ No ☒
+# -
+# -Indicate by check mark if the registrant is not required to
+# -file reports pursuant to Section 13 or Section 15(d) of the Act. Yes ☐ No ☒
+# 0000065873-05-000060: 68 -> 42 lines, ~21 changed
+# -One Allied Drive, Little Rock,
+# -Arkansas 72202
+# +One Allied Drive, Little Rock, Arkansas 72202
+# -Re: ALLTEL
+# -Corporation
+# +Re: ALLTEL Corporation
+# -The undersigned respectively requests
+# -that the effective date of the above-referenced Registration Statement be
+# 0000950137-05-004969: 71 -> 62 lines, ~8 changed
+# -Check the appropriate box below if the Form 8-K filing is intended to simultaneously satisfy the
+# -filing obligation of the registrant under any of the following provisions:
+# +Check the appropriate box below if the Form 8-K filing is intended to simultaneously satisfy the filing obligation of the registrant under any of the
+# -Item 5.02. Departure of Directors or Principal Officers; Election of Directors; Appointment of
+# -Principal Officers.
+# +Item 5.02. Departure of Directors or Principal Officers; Election of Directors; Appointment of Principal Officers.
+# -On April 26, 2005, the board of directors of Exelon Corporation elected Thomas J. Ridge to serve as
+# -a Class I director, effective May 2, 2005. Mr. Ridge, age 59, served as Secretary of the United
+# 0001481057-23-010389: 1482 -> 696 lines, ~533 changed
+# -The initial estimated value of the Notes as of the
+# -
+# -pricing date is expected to be between $907.80 and $957.80 per $1,000 in principal amount of Notes, which is less than the public offering
+# -
+# -price listed below. The actual value of your Notes at any time will reflect many factors and cannot be predicted with accuracy. See
+# -
+# -“Risk Factors” beginning on page PS-8 of this pricing supplement and “Structuring the Notes” on page PS-16 of
+# -
+# Verified mechanically: every changed line differs by whitespace only.
+
BASELINE = {
# Modern iXBRL 10-K
"0000320193-23-000106": (
@@ -211,13 +251,13 @@ def sha256(text):
# Re-captured for edgartools-wzgu (short columns kept): a numbered list laid
# out as a table regains its "1." .. "9." markers and seven rule lines
# resize; no token is lost -- 3,803 numbers become 3,812, the old in order.
- "4174954dcdec09bd337c289a74d72cbeeff222d7897f2a80d4a90e8fa27142df",
+ "857b48fd9e2cd67cd65a66aa01debab8a23600a10174782b98432b0890a86c6f",
),
# CORRESP — the shape where the two paths already agreed before the fix
"0000065873-05-000060": (
dict(form="CORRESP", filing_date="2005-11-03", company="MERCK & CO INC",
cik=65873, accession_no="0000065873-05-000060"),
- "733944a9b6e911a63321cd3d37c3a1408ad3ac215e9a93e4ed04f549e5c42b6e",
+ "c195843d15e1aef88638c220a0f5714553e0cd2361252615ff137932a4efecbf",
),
# 2005-era HTML 8-K
"0000950137-05-004969": (
@@ -231,13 +271,13 @@ def sha256(text):
# unchanged at 2,629 characters and
# "".join(before.split()) == "".join(after.split()) holds, so nothing but
# whitespace moved. This is the only filing of the five that changed.
- "37211411d05d2120eece22347d8c7cbfde46680d516751fbcce17f060dcb7b5e",
+ "d97e03eeb34ae623257b4d6f63ccffb96ef7168edb2895b127ee5d80412d979b",
),
# 424B2 structured note
"0001481057-23-010389": (
dict(form="424B2", filing_date="2023-12-13", company="BANK OF AMERICA CORP /DE/",
cik=70858, accession_no="0001481057-23-010389"),
- "32a49882a47542bf4e0d03af0dd389571a83c2d42452825b7cb138dedcb4b1c2",
+ "4ebe4fd0ceab8a79412156bd691542d1b13959897d8f028d3e2b4de481426ace",
),
}
diff --git a/tests/issues/regression/test_issue_2h4c.py b/tests/issues/regression/test_issue_2h4c.py
index 8e2ac5cfb..6f2b94bc8 100644
--- a/tests/issues/regression/test_issue_2h4c.py
+++ b/tests/issues/regression/test_issue_2h4c.py
@@ -113,6 +113,12 @@ def test_resolve_abbreviation_expands_defined_tag():
assert _resolve_abbreviation("BofAS", text) == "BofA Securities, Inc."
# Already a full name (not a defined tag) — unchanged.
assert _resolve_abbreviation("Barclays Capital Inc.", text) == "Barclays Capital Inc."
+ # The capture must not open on the end of the previous sentence (gh-1370 joined
+ # "per Note.\nBofA ..." onto one line), but a name's own periods stay.
+ joined = 'The underwriting discount is $0.10 per Note. BofA Securities, Inc. (“BofAS”), an affiliate.'
+ assert _resolve_abbreviation("BofAS", joined) == "BofA Securities, Inc."
+ jpm = 'Agent: J.P. Morgan Securities LLC (“JPMS”), an affiliate.'
+ assert _resolve_abbreviation("JPMS", jpm) == "J.P. Morgan Securities LLC"
class TestCoverAgentExtraction: