From aaf4e75d56ec09d556a8c8c005e078a56e3b31eb Mon Sep 17 00:00:00 2001 From: wolfgang-aura <169568318+wolfgang-aura@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:38:01 +0800 Subject: [PATCH 1/2] fix(documents): collapse whitespace runs inside a text node (#1370) Hard-wrapped
text kept its newlines because _collapse_edges only collapsed a text node's edges. Runs of ASCII whitespace inside the node now collapse to one space, except under
, white-space: pre* or preserve_whitespace=True.
and non-breaking spaces are unchanged. --- changelog.d/1370.fixed.md | 1 + .../documents/strategies/document_builder.py | 42 +++++++++++++++---- .../documents/test_html_parser_regressions.py | 31 ++++++++++++++ 3 files changed, 66 insertions(+), 8 deletions(-) create mode 100644 changelog.d/1370.fixed.md 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/strategies/document_builder.py b/edgar/documents/strategies/document_builder.py index dff654a89..eacf8d6e1 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 aor 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-wraptext 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 @@ -627,8 +648,13 @@ def _get_element_text(self, element: HtmlElement) -> str: # so word boundaries survive (the preprocessor strips whitespace adjacent # to tags, and this re-separates the parts). if inline and len(text_parts) == 1: - return text_parts[0] - return ' '.join(text_parts) + text = text_parts[0] + else: + text = ' '.join(text_parts) + # 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 = _ASCII_WHITESPACE_RUN.sub(' ', text) + return text def _is_inline_run_container(self, element: HtmlElement) -> bool: """Check that every child is a genuine inline run and no block sits below. diff --git a/tests/documents/test_html_parser_regressions.py b/tests/documents/test_html_parser_regressions.py index 14a44ca60..288729233 100644 --- a/tests/documents/test_html_parser_regressions.py +++ b/tests/documents/test_html_parser_regressions.py @@ -156,6 +156,37 @@ 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,
' + text = parse_html(html).text() + assert 'Mountain View,\nCalifornia' in text + + def test_pre_keeps_its_newlines(self): + html = '
CaliforniaLine one\nLine two' + text = parse_html(html).text() + assert 'Line one\nLine two' in text + + class TestSpacerElementWordGluing: """A third copy of the same bug: whitespace-only spacer elements. From 2fda300232177ffa472ecc38bbbae601fc4eaa76 Mon Sep 17 00:00:00 2001 From: Dwight GunningDate: Wed, 30 Sep 2026 14:15:51 -0400 Subject: [PATCH 2/2] fix(documents): keep the lines that carry structure when collapsing whitespace Follow-up to the gh-1370 whitespace collapse, which failed 18 tests in CI. Six were real losses; the rest were whitespace-only moves. Losses, now fixed: - Plain-text filings. Filing.html() wraps a .txt primary document in , and Filing.parsed_items hands the raw .txt straight to the parser. Their line breaks are the structure ("Item 8.01 Other Events" on its own line), so collapsing them lost 8-K items (GMAC, BayCorp 2005; a 1999 8-K) and a 2001 20-F's Item 7. The parser now marks either input white-space: pre-wrap, which the collapse already honours. Input that is only comments still fails to parse. - Block children of an inline wrapper...
\n..
is separated only by the newline between thes, and _get_element_text collapsed it, joining Merck's 2005 CORRESP letter into one line. That break is no longer collapsed; runs inside each part still are. - 424B cover agents. With "per Note.\nBofA Securities, Inc. ("BofAS")" joined onto one line, _resolve_abbreviation's capture opened on "Note." A leading "Word. " sentence tail is trimmed; "J.P. Morgan" is unaffected. Whitespace-only moves, re-pinned: - dt1f1 era titles and wrapped headers: -1 and -2 chars. Word streams of all four fixtures are identical before and after. - Filing text baselines: 4 of 5 filings moved, re-captured with scripts/recapture_text_baseline.py, which verified every changed line differs by whitespace only. Of the paragraph breaks removed, BofA's 424B2 lost 367 of 393 mid-sentence; the rest sit inside one text node. Checked: fast lane 7,736 passed, 0 failed; all 60 section network regression files pass, one per call; new tests fail on the PR's previous head. Co-Authored-By: Claude Opus 5.5
--- edgar/documents/parser.py | 18 +++++++ .../documents/strategies/document_builder.py | 21 +++++--- edgar/offerings/prospectus/_424b_cover.py | 10 +++- .../documents/test_html_parser_regressions.py | 31 ++++++++++++ .../regression/test_dt1f1_era_item_titles.py | 6 +-- .../test_dt1f1_wrapped_item_headers.py | 4 +- .../regression/test_filing_text_baseline.py | 48 +++++++++++++++++-- tests/issues/regression/test_issue_2h4c.py | 6 +++ 8 files changed, 127 insertions(+), 17 deletions(-) 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 eacf8d6e1..a0df78e01 100644 --- a/edgar/documents/strategies/document_builder.py +++ b/edgar/documents/strategies/document_builder.py @@ -612,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: @@ -640,21 +641,27 @@ 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). if inline and len(text_parts) == 1: - text = text_parts[0] - else: - text = ' '.join(text_parts) - # 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 = _ASCII_WHITESPACE_RUN.sub(' ', text) - return text + return text_parts[0] + return ' '.join(text_parts) def _is_inline_run_container(self, element: HtmlElement) -> bool: """Check that every child is a genuine inline run and no block sits below. 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 288729233..8cfb77b00 100644 --- a/tests/documents/test_html_parser_regressions.py +++ b/tests/documents/test_html_parser_regressions.py @@ -186,6 +186,37 @@ def test_pre_keeps_its_newlines(self): 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 blocks: 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: