` 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 `
- ` elements (ignoring `