# pyright: reportPrivateUsage=false """Provides the HTML parser used by `partition_html()`. The names "flow" and "phrasing" derive from the language of the HTML Standard. PRINCIPLES - _Elements are paragraphs._ Each paragraph in the HTML document should become a distinct element. In particular, a paragraph should not be split into two elements and an element should not contain more than one paragraph. - _An empty paragraph is not an Element._ A paragraph which contains no text or contains only whitespace does not give rise to an Element (is skipped). - _The browser rendering is the document._ The HTML "source-code" is not the document. The document is the way that HTML is rendered by a browser (Chrome for a first authority). This foundational principle gives rise to a few that are more specific. - _Whitespace is normalized._ Whitespace used for formatting the HTML source is _normalized_ to a single space between text segments. More specifically: - Any leading or trailing space on a paragraph is removed. - All other runs of whitespace in the paragraph are reduced to a single space (" "). - Whitespace is never added where none existed in the HTML source. - Whitespace within a `
` element is the exception and is not normalized. Its
    whitespace is preserved excepting a leading and/or trailing newline ("\n").

- _Block-items are paragraphs._ Visible content in HTML can be divided into _block-items_ and
  _phrasing content_ (aka. _inline content_).
  - As an example, a `

` element is a block item and a `` element is phrasing. - A block item starts a new paragraph and so represents an Element boundary. - A phrasing item affects the appearance of a run of text within a paragraph, like making it bold or making it into a link. - Some elements can take either role, depending upon their ancestors and descendants. - The final authority for whether a particular element is displayed as a block or as inline "formatting" is the CSS. We do not attempt to interpret the CSS and assume the default role for each element. Other background - The parser's design is _recursive_, consistent with the recursive (tree) structure of HTML. The nodes of the tree are _HTML elements_. Unfortunately this naming sometimes conflicts with Unstructured _document-elements_. In the parser code the term "document-element" is used when there may be ambiguity. - The parser is primarily composed of `lxml` Custom Element Classes. The gist is you write a class like `Anchor` and then tell the `lxml` parser that all `` elements should be instantiated using the `Anchor` class. We also provide a default class for any elements that we haven't called out explicitly. - _Anatomy of an HTML element._ Some basic terms are important to know to understand the domain language of the parser code. Consider this example: ```html

Text bold child tail of child

tail of p
``` - An element can have _text_. - All visible content within an HTML document is the text (or tail) of some element. - The text of the `

` element (`p.text`) is "Text ". - Note the formatting whitespace is included. - An element can have _child elements_. - The `

` element (`p`) is a child of `div`. - `b` is a child of `p`. - An element can have a _tail_. - Whatever text follows an element, before the next element starts, is the tail of that element. - `b.tail` is `" tail of child"`. Note the included whitespace. - `p.tail` is `"\n tail of p\n"`. - Tail text is _accessed_ via the element that precedes it but that element does not _influence_ its tail text. For example, "tail of child" does not appear in a bold typeface even though it is the tail of `b`. """ from __future__ import annotations import re from collections import defaultdict, deque from functools import cached_property from types import MappingProxyType from typing import Any, Iterable, Iterator, Mapping, NamedTuple, Sequence, cast from lxml import etree from typing_extensions import TypeAlias from unstructured.cleaners.core import clean_bullets from unstructured.common.html_table import htmlify_matrix_of_cell_texts from unstructured.documents.elements import ( Address, CodeSnippet, Element, ElementMetadata, EmailAddress, Image, ListItem, NarrativeText, Table, Text, Title, ) from unstructured.partition.common.metadata import category_depth_from_html_tag from unstructured.partition.text_type import ( is_bulleted_text, is_email_address, is_possible_narrative_text, is_us_city_state_zip, ) # ------------------------------------------------------------------------------------------------ # DOMAIN MODEL # ------------------------------------------------------------------------------------------------ Annotation: TypeAlias = Mapping[str, Any] """A mapping with zero or more keywords, each represening a noted characteristic. An annotation can be associated with a text segment or element. In general the keys and value-types differ between the individual (text-segment) and consolidated (Element) forms. """ def _consolidate_annotations(annotations: Iterable[Annotation]) -> Annotation: """Combine individual text-segment annotations into an element-level annotation. Sequence is significant. """ combined_annotations = cast(defaultdict[str, list[str]], defaultdict(list)) for a in annotations: for k, v in a.items(): if isinstance(v, list): combined_annotations[k].extend(cast(list[Any], v)) else: combined_annotations[k].append(v) return MappingProxyType(dict(combined_annotations)) def _normalize_text(text: str) -> str: """`text` with normalized whitespace. - leading and trailing whitespace are removed - all whitespace segments within text (spacing between words) are reduced to a single space each. Produces the empty string when `text` contains only whitespace. """ return " ".join(text.strip().split()) class TextSegment(NamedTuple): """An annotated string from a Phrasing element. Annotations are for emphasis and for links. The text includes any leading, trailing, and inter-word whitespace, just as it occurred in the HTML. The text-segments for a paragraph are consolidated once the paragraph is fully parsed and whitespace it normalized at that time. It cannot be normalized prior to that without distoring or losing inter-word spacing. However, text within annotations, like the text of a link, is normalized since its full extents are known. """ text: str annotation: Annotation Phrase: TypeAlias = Sequence[TextSegment] """Contiguous text-segments formed from text and contiguous phrasing. These occur within a block element as the element text and contiguous phrasing or the tail and contiguous phrasing. For example, there are two phrases in this div, one before and one after the

child element:

Seagulls gonna come and

Poke me in the coconut

And they did, they did
The first is `div.text` and the phrasing (text and tail of phrasing elements) that follow it. A phrase terminates at a block element (`

` in this case) or at the end of the enclosing block (the `` in this example). """ # ------------------------------------------------------------------------------------------------ # PHRASING ACCUMULATORS # ------------------------------------------------------------------------------------------------ class _PhraseAccumulator: """Accumulates sequential `TextSegment`s making them available as iterable on flush(). - The accumulator starts empty. - `.flush()` is a Phrase iterator and generates zero or one Phrase. - `.flush()` generates zero items when no text-segments have been accumulated - `flush()` resets the accumulator to its initial empty state. So far, phrases are used only by the Anchor class. """ def __init__(self): self._text_segments: list[TextSegment] = [] def add(self, text_segment: TextSegment) -> None: """Add `text_segment` to this collection.""" self._text_segments.append(text_segment) def flush(self) -> Iterator[Phrase]: """Generate each of the stored `TextSegment` objects and clears the accumulator.""" # -- harvest accumulated text-segments and empty the accumulator -- text_segments = self._text_segments[:] self._text_segments.clear() if not text_segments: return yield tuple(text_segments) class _ElementAccumulator: """Accumulates sequential `TextSegment`s and forms them into an element on flush(). The text segments come from element text or tails and any contiguous phrasing elements that follow that text or tail. - The accumulator starts empty. - `.flush()` is an element iterator and generates zero or one Element. - `.flush()` generates zero elements when no text-segments have been accumulated or the ones that have been accumulated contain only whitespace. - `flush()` resets the accumulator to its initial empty state. """ def __init__(self, element: Flow): self._element = element self._text_segments: list[TextSegment] = [] def add(self, text_segment: TextSegment) -> None: """Add `text_segment` to this Element-under-construction.""" self._text_segments.append(text_segment) def flush(self, ElementCls: type[Element] | None) -> Iterator[Element]: """Generate zero-or-one document-`Element` object and clear the accumulator.""" # -- normalized-text must be computed before resetting the accumulator -- normalized_text = self._normalized_text # -- harvest accumulated text-segments and empty the accumulator -- text_segments = self._text_segments[:] self._text_segments.clear() if not text_segments or not normalized_text: return # -- if we don't have a more specific element-class, choose one based on the text -- if ElementCls is None: ElementCls = derive_element_type_from_text(normalized_text) # -- normalized text that contains only a single character is skipped unless it # -- identifies as a list-item if ElementCls is None: return # -- derived ListItem means text starts with a bullet character that needs removing -- if ElementCls is ListItem: normalized_text = clean_bullets(normalized_text) if not normalized_text: return category_depth = self._category_depth(ElementCls) yield ElementCls( normalized_text, metadata=ElementMetadata( **_consolidate_annotations(ts.annotation for ts in text_segments), category_depth=category_depth, page_number=self._element._page_number, ), ) def _category_depth(self, ElementCls: type[Element]) -> int | None: """`category_depth` from heading level (Title) or list-nesting (ListItem). Delegates to the shared `category_depth_from_html_tag` helper so the v1 and v2 (ontology) HTML parsers compute `category_depth` identically. """ list_ancestor_count = ( len([e for e in self._element.iterancestors() if e.tag in ("dl", "ol", "ul")]) if self._element.tag in ("li", "dd") else 0 ) return category_depth_from_html_tag( ElementCls, self._element.tag, list_ancestor_count=list_ancestor_count ) @property def _normalized_text(self) -> str: """Consolidate text-segment text values into a single whitespace-normalized string. This normalization is suitable for text inside a block element including any segments from phrasing elements immediately following that text. The spec is: - All text segments are concatenated (without adding or removing whitespace) - Leading and trailing whitespace are removed. - Each run of whitespace in the string is reduced to a single space. For example: " \n foo bar\nbaz bada \t bing\n " becomes: "foo bar baz bada bing" """ return " ".join("".join(ts.text for ts in self._text_segments).split()) class _PreElementAccumulator(_ElementAccumulator): """Accumulator specific to `

` element, preserves (most) whitespace in normalized text."""

    @property
    def _normalized_text(self) -> str:
        """Consolidate `texts` into a single whitespace-normalized string.

        This normalization is specific to the `
` element. Only a leading and or trailing
        newline is removed. All other whitespace is preserved.
        """
        text = "".join(ts.text for ts in self._text_segments)

        start = 1 if text.startswith("\n") else 0
        end = -1 if text.endswith("\n") else len(text)

        return text[start:end]


# ------------------------------------------------------------------------------------------------
# CUSTOM ELEMENT-CLASSES
# ------------------------------------------------------------------------------------------------


# -- FLOW (BLOCK-ITEM) ELEMENTS ------------------------------------------------------------------


class Flow(etree.ElementBase):
    """Base and default class for elements that act like a div.

    These can contain other flow elements or phrasing elements.
    """

    # -- by default, choose the element class based on the form of the text --
    _ElementCls = None

    @property
    def is_phrasing(self) -> bool:
        return False

    @cached_property
    def _page_number(self) -> int | None:
        """Page number from nearest ancestor (or self) with a valid `data-page-number` attribute."""
        page_attr = self.get("data-page-number")
        if page_attr is not None:
            try:
                return int(page_attr)
            except (ValueError, TypeError):
                pass
        parent = self.getparent()
        if parent is not None and isinstance(parent, Flow):
            return parent._page_number
        return None

    def iter_elements(self) -> Iterator[Element]:
        """Generate paragraph string for each block item within."""
        # -- place child elements in a queue --
        q: deque[Flow | Phrasing] = deque(self)

        yield from self._element_from_text_or_tail(self.text or "", q, self._ElementCls)

        while q:
            assert not q[0].is_phrasing
            block_item = cast(Flow, q.popleft())
            yield from block_item.iter_elements()
            yield from self._element_from_text_or_tail(block_item.tail or "", q)

    @cached_property
    def _element_accum(self) -> _ElementAccumulator:
        """Text-segment accumulator suitable for this block-element."""
        return _ElementAccumulator(self)

    def _element_from_text_or_tail(
        self, text: str, q: deque[Flow | Phrasing], ElementCls: type[Element] | None = None
    ) -> Iterator[Element]:
        """Generate zero-or-one paragraph formed from text and leading phrasing elements.

        Note this mutates `q` by popping phrasing elements off as they are processed.
        """
        element_accum = self._element_accum

        for node in self._iter_text_segments(text, q):
            if isinstance(node, TextSegment):
                element_accum.add(node)
            else:
                # -- otherwise x is an Element, which terminates any accumulating Element --
                yield from element_accum.flush(ElementCls)
                yield node

        yield from element_accum.flush(ElementCls)

    def _iter_text_segments(
        self, text: str, q: deque[Flow | Phrasing]
    ) -> Iterator[TextSegment | Element]:
        """Generate zero-or-more `TextSegment`s or `Element`s from text and leading phrasing.

        Note that while this method is named "._iter_text_segments()", it can also generate
        `Element` objects when a block item is nested within a phrasing element. This is not
        technically valid HTML, but folks write some wacky HTML and the browser is pretty forgiving
        so we try to do the right thing (what the browser does) when that happens, generally
        interpret each nested block as its own paragraph and generate a separate `Element` object
        for each.

        This method is used to process the text or tail of a block element, including any phrasing
        elements immediately following the text or tail.

        For example, this 
:
For a moment, nothing happened.

Then, after a second or so, nothing continued to happen.

The dolphins had always believed that they were far more intelligent.
Should generate three distinct elements: - One for the div's text "For a " and the phrasing element after it, - one for the

element, and - one for the tail of the

and the phrasing element that follows it. This method is invoked to process the first line beginning "For a" and the third line beginning "The dolphins", in two separate calls. Note this method mutates `q` by popping phrasing elements off as they are processed. """ yield TextSegment(text, {}) while q and q[0].is_phrasing: e = cast(Phrasing, q.popleft()) yield from e.iter_text_segments() class BlockItem(Flow): """Custom element-class for `

` element, `

`, and others like it. These can appear in a flow container like a div but can only contain phrasing content. """ # -- Turns out there are no implementation differences so far between Flow and BlockItem, but # -- maintaining the distinction for now. We may use it to add hierarchy information or # -- customize how we deal with invalid HTML that places flow items inside one of these. class Heading(Flow): """An `

..

` element. These are distinguished because they generate a `Title` element. """ _ElementCls = Title class ListBlock(Flow): """Either a `
    ` or `
      ` element, maybe a `
      ` element at some point. The primary reason for distinguishing these is because they increment the hierarchy depth for lists that are nested inside them. Can only contain `
    1. ` elements (ignoring `