Files
wehub-resource-sync 461bf6fd40
CI / lint (3.11) (push) Has been cancelled
CI / lint (3.12) (push) Has been cancelled
CI / lint (3.13) (push) Has been cancelled
CI / shellcheck (push) Has been cancelled
CI / shfmt (push) Has been cancelled
CI / setup (3.11) (push) Has been cancelled
CI / setup (3.12) (push) Has been cancelled
CI / setup (3.13) (push) Has been cancelled
CI / check-licenses (3.12) (push) Has been cancelled
CI / test_unit (3.11) (push) Has been cancelled
CI / test_unit (3.12) (push) Has been cancelled
CI / test_unit (3.13) (push) Has been cancelled
CI / test_unit_no_extras (3.11) (push) Has been cancelled
CI / test_unit_no_extras (3.12) (push) Has been cancelled
CI / test_json_to_html (3.12) (push) Has been cancelled
CI / test_unit_no_extras (3.13) (push) Has been cancelled
CI / test_unit_dependency_extras (csv, 3.12, --extra csv) (push) Has been cancelled
CI / test_unit_dependency_extras (xlsx, 3.11, --extra xlsx) (push) Has been cancelled
CI / test_unit_dependency_extras (xlsx, 3.12, --extra xlsx) (push) Has been cancelled
CI / test_unit_dependency_extras (csv, 3.11, --extra csv) (push) Has been cancelled
CI / test_unit_dependency_extras (csv, 3.13, --extra csv) (push) Has been cancelled
CI / test_unit_dependency_extras (docx, 3.11, --extra docx) (push) Has been cancelled
CI / test_unit_dependency_extras (docx, 3.12, --extra docx) (push) Has been cancelled
CI / test_unit_dependency_extras (docx, 3.13, --extra docx) (push) Has been cancelled
CI / test_unit_dependency_extras (markdown, 3.11, --extra md) (push) Has been cancelled
CI / test_unit_dependency_extras (markdown, 3.12, --extra md) (push) Has been cancelled
CI / test_unit_dependency_extras (markdown, 3.13, --extra md) (push) Has been cancelled
CI / test_unit_dependency_extras (odt, 3.11, --extra odt) (push) Has been cancelled
CI / test_unit_dependency_extras (odt, 3.12, --extra odt) (push) Has been cancelled
CI / test_unit_dependency_extras (odt, 3.13, --extra odt) (push) Has been cancelled
CI / test_unit_dependency_extras (pdf-image, 3.11, --extra pdf --extra image --extra paddleocr) (push) Has been cancelled
CI / test_unit_dependency_extras (pdf-image, 3.12, --extra pdf --extra image --extra paddleocr) (push) Has been cancelled
CI / test_unit_dependency_extras (pdf-image, 3.13, --extra pdf --extra image --extra paddleocr) (push) Has been cancelled
CI / test_unit_dependency_extras (pptx, 3.11, --extra pptx) (push) Has been cancelled
CI / test_unit_dependency_extras (pptx, 3.12, --extra pptx) (push) Has been cancelled
CI / test_unit_dependency_extras (pptx, 3.13, --extra pptx) (push) Has been cancelled
CI / test_unit_dependency_extras (pypandoc, 3.11, --extra epub --extra org --extra rtf --extra rst) (push) Has been cancelled
CI / test_unit_dependency_extras (pypandoc, 3.12, --extra epub --extra org --extra rtf --extra rst) (push) Has been cancelled
CI / test_unit_dependency_extras (pypandoc, 3.13, --extra epub --extra org --extra rtf --extra rst) (push) Has been cancelled
Build And Push Docker Image / set-short-sha (push) Has been cancelled
Partition Benchmark / setup (push) Has been cancelled
Partition Benchmark / Measure and compare partition() runtime (push) Has been cancelled
CI / test_unit_dependency_extras (xlsx, 3.13, --extra xlsx) (push) Has been cancelled
CI / test_ingest_src (3.12) (push) Has been cancelled
CI / test_json_to_markdown (3.12) (push) Has been cancelled
CI / changelog (push) Has been cancelled
CI / test_dockerfile (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Build And Push Docker Image / build-images (linux/amd64, opensource-linux-8core) (push) Has been cancelled
Build And Push Docker Image / build-images (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build And Push Docker Image / publish-images (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:33:56 +08:00

591 lines
24 KiB
Python

from __future__ import annotations
from itertools import chain
from typing import Sequence, Type
from bs4 import BeautifulSoup, Tag
from unstructured.documents import elements, ontology
from unstructured.documents.html_sanitization import sanitize_attributes
from unstructured.documents.mappings import (
CSS_CLASS_TO_ELEMENT_TYPE_MAP,
HTML_TAG_AND_CSS_NAME_TO_ELEMENT_TYPE_MAP,
HTML_TAG_TO_DEFAULT_ELEMENT_TYPE_MAP,
ONTOLOGY_CLASS_TO_UNSTRUCTURED_ELEMENT_TYPE,
)
from unstructured.partition.common.metadata import (
HEADING_TAGS,
category_depth_from_html_tag,
)
RECURSION_LIMIT = 50
def ontology_to_unstructured_elements(
ontology_element: ontology.OntologyElement,
parent_id: str | None = None,
page_number: int | None = None,
depth: int = 0,
filename: str | None = None,
add_img_alt_text: bool = True,
) -> list[elements.Element]:
"""
Converts an OntologyElement object to a list of unstructured Element objects.
To preserve the structure of the ontology, the function is recursive
and the tree structure is represented in flatten list by the parent_id
attribute in the metadata of each Element object.
To preserve all the attributes of the ontology element, the HTML code
is injected to unstructured Element in ElementMetadata.text_as_html attribute.
For Layout elements, the function creates an empty Text Element (with the
HTML code injected the same way).
TODO (Pluto): Better way would be to have special Element type in Unstructured
Args:
ontology_element (OntologyElement): The ontology element to be converted.
parent_id (str, optional): The ID of the parent element. Defaults to None.
page_number (int, optional): The page number of the element. Defaults to None.
depth (int, optional): The depth of the element in the hierarchy. Defaults to 0.
filename (str, optional): The name of the file the element comes from. Defaults to None.
add_img_alt_text (bool): Whether to include the alternative text of images
in the output. Defaults to True.
Returns:
list[Element]: A list of unstructured Element objects.
Note on `category_depth` and `parent_id` (ML-1328):
`category_depth` is derived from the element's HTML *heading level* (h1 -> 0, h2 -> 1, ...)
via the shared `category_depth_from_html_tag` helper, NOT from DOM/recursion nesting depth.
This keeps the v2 (ontology) parser consistent with the v1 parser and with the documented
metadata contract, and makes depth independent of layout (e.g. multi-column pages no longer
bump every element's depth).
`parent_id` is left to the metadata layer, like every other partitioner. Layout/container
elements (Page, Column, ...) keep their tree parent so the physical layout structure is
preserved; content elements are emitted with ``parent_id=None``. The `@apply_metadata`
decorator that wraps `partition_html` then runs `set_element_hierarchy`, which fills each
content element's heading-based parent (a subsection's parent becomes its enclosing heading)
from the heading-level `category_depth` and skips the containers that already have a parent.
Both production callers -- `partition_html` and the VLM partitioner -- go through that
decorator, so this converter does not run `set_element_hierarchy` itself.
"""
# -- The worker carries each element's DOM-nesting depth alongside it (used only to decide
# -- inline merging); strip those depths here so the public output is plain Elements. --
elements_with_depth = _ontology_to_unstructured_elements(
ontology_element,
parent_id=parent_id,
page_number=page_number,
depth=depth,
filename=filename,
add_img_alt_text=add_img_alt_text,
)
return [element for element, _nesting_depth in elements_with_depth]
def _ontology_to_unstructured_elements(
ontology_element: ontology.OntologyElement,
parent_id: str | None = None,
page_number: int | None = None,
depth: int = 0,
filename: str | None = None,
add_img_alt_text: bool = True,
) -> list[tuple[elements.Element, int]]:
"""Recursive worker for `ontology_to_unstructured_elements`.
Builds the flat element list with layout-container `parent_id` set to the tree parent and
content `parent_id` left as ``None`` -- the `@apply_metadata` decorator on `partition_html`
fills in content elements' heading-based `parent_id` via `set_element_hierarchy`.
Each element is returned paired with its DOM-nesting `depth`. That depth is recursion-local
bookkeeping consumed only by `combine_inline_elements` (to gate inline merging by tree level);
it is deliberately NOT stored on the element or its `ElementMetadata`, and the public wrapper
discards it.
"""
elements_to_return: list[tuple[elements.Element, int]] = []
if ontology_element.elementType == ontology.ElementTypeEnum.layout and depth <= RECURSION_LIMIT:
if page_number is None and isinstance(ontology_element, ontology.Page):
page_number = ontology_element.page_number
if not isinstance(ontology_element, ontology.Document):
# -- Layout/container element (Page, Column, ...). Keep its tree `parent_id` so the
# -- physical layout structure is preserved, and leave `category_depth` unset -- a
# -- container is not a heading. --
container_element = elements.Text(
text="",
element_id=ontology_element.id,
detection_origin="vlm_partitioner",
metadata=elements.ElementMetadata(
parent_id=parent_id,
text_as_html=ontology_element.to_html(add_children=False),
page_number=page_number,
category_depth=None,
filename=filename,
),
)
# -- pair the container with its DOM-nesting depth, used only to decide inline merging;
# -- `category_depth` now carries heading level, not nesting, so it can't be reused. --
elements_to_return += [(container_element, depth)]
children: list[tuple[elements.Element, int]] = []
for child in ontology_element.children:
child = _ontology_to_unstructured_elements(
child,
parent_id=ontology_element.id,
page_number=page_number,
depth=0 if isinstance(ontology_element, ontology.Document) else depth + 1,
filename=filename,
add_img_alt_text=add_img_alt_text,
)
children += child
combined_children = combine_inline_elements(children)
elements_to_return += combined_children
else:
element_class: type[elements.Element] = ONTOLOGY_CLASS_TO_UNSTRUCTURED_ELEMENT_TYPE[
ontology_element.__class__
]
html_code_of_ontology_element = ontology_element.to_html()
element_text = ontology_element.to_text(add_img_alt_text=add_img_alt_text)
# -- `category_depth` from heading level (not nesting depth); see function docstring. --
category_depth = category_depth_from_html_tag(
element_class,
ontology_element.html_tag_name,
)
unstructured_element = element_class(
text=element_text, # type: ignore
element_id=ontology_element.id,
detection_origin="vlm_partitioner",
metadata=elements.ElementMetadata(
# -- `parent_id` left unset; `@apply_metadata` runs `set_element_hierarchy` to
# -- assign a heading-based parent (see the docstring). --
parent_id=None,
text_as_html=html_code_of_ontology_element,
page_number=page_number,
category_depth=category_depth,
filename=filename,
),
)
elements_to_return = [(unstructured_element, depth)]
return elements_to_return
def combine_inline_elements(
elements_with_depth: list[tuple[elements.Element, int]],
) -> list[tuple[elements.Element, int]]:
"""
Combines consecutive inline elements into a single element. Inline elements
can be also combined with text elements.
Combined elements contains multiple HTML tags together eg.
{
'text': "Text from element 1 Text from element 2",
'metadata': {
'text_as_html': "<p>Text from element 1</p><a>Text from element 2</a>"
}
}
Each element is paired with its DOM-nesting depth; merging is only allowed between elements at
the same depth (see `can_unstructured_elements_be_merged`). The depth travels with the element
rather than being stored on it.
Args:
elements_with_depth (list[tuple[Element, int]]): (element, nesting-depth) pairs to combine.
Returns:
list[tuple[Element, int]]: The combined (element, nesting-depth) pairs.
"""
result_elements: list[tuple[elements.Element, int]] = []
current: tuple[elements.Element, int] | None = None
for nxt in elements_with_depth:
if current is None:
current = nxt
continue
current_element, current_depth = current
next_element, next_depth = nxt
if can_unstructured_elements_be_merged(
current_element, next_element, current_depth=current_depth, next_depth=next_depth
):
current_element.text += " " + next_element.text
current_element.metadata.text_as_html += next_element.metadata.text_as_html
else:
result_elements.append(current)
current = nxt
if current is not None:
result_elements.append(current)
return result_elements
def can_unstructured_elements_be_merged(
current_element: elements.Element,
next_element: elements.Element,
*,
current_depth: int,
next_depth: int,
) -> bool:
"""
Elements can be merged when:
- They are on the same level in the HTML tree
- Neither of them has children
- All elements are inline elements or text element
"""
# NOTE(ML-1328): "same level in the HTML tree" is the DOM-nesting depth, passed in alongside
# each element. It used to live on `category_depth`, but that field now carries heading level,
# so it can no longer be used as the nesting signal here.
if current_depth != next_depth:
return False
current_html_tags = BeautifulSoup(
current_element.metadata.text_as_html, "html.parser"
).find_all(recursive=False)
next_html_tags = BeautifulSoup(next_element.metadata.text_as_html, "html.parser").find_all(
recursive=False
)
ontology_elements = [
parse_html_to_ontology_element(html_tag)
for html_tag in chain(current_html_tags, next_html_tags)
]
for ontology_element in ontology_elements:
if ontology_element.children:
return False
if not (is_inline_element(ontology_element) or is_text_element(ontology_element)):
return False
return True
def is_text_element(ontology_element: ontology.OntologyElement) -> bool:
"""Categories or classes that we want to combine with inline text"""
text_classes = [
ontology.NarrativeText,
ontology.Quote,
ontology.Paragraph,
ontology.Footnote,
ontology.FootnoteReference,
ontology.Citation,
ontology.Bibliography,
ontology.Glossary,
]
text_categories = [ontology.ElementTypeEnum.metadata]
if any(isinstance(ontology_element, class_) for class_ in text_classes):
return True
return any(ontology_element.elementType == category for category in text_categories)
def is_inline_element(ontology_element: ontology.OntologyElement) -> bool:
"""Categories or classes that we want to combine with text elements"""
inline_classes = [ontology.Hyperlink]
inline_categories = [
ontology.ElementTypeEnum.specialized_text,
ontology.ElementTypeEnum.annotation,
]
if any(isinstance(ontology_element, class_) for class_ in inline_classes):
return True
return any(ontology_element.elementType == category for category in inline_categories)
def unstructured_elements_to_ontology(
unstructured_elements: Sequence[elements.Element],
) -> ontology.OntologyElement:
"""
Converts a sequence of unstructured Element objects to an OntologyElement object.
The function caches the elements in a dictionary and each element is assigned to its parent.
At the end the root element is popped from the dictionary and returned.
Such approach comes with limitations:
- The parent element has to be in the list before the child element
Args:
unstructured_elements (Sequence[Element]): The sequence of unstructured Element objects.
Returns:
OntologyElement: The converted OntologyElement object.
"""
if not unstructured_elements:
# -- empty input -> empty Document; avoid an IndexError dereferencing element[0] --
return ontology.Document(
additional_attributes={"id": ontology.OntologyElement.generate_unique_id()}
)
root_element_id = unstructured_elements[0].metadata.parent_id
if root_element_id is None:
root_element_id = ontology.OntologyElement.generate_unique_id()
unstructured_elements[0].metadata.parent_id = root_element_id
root_element = ontology.Document(additional_attributes={"id": root_element_id})
# NOTE(ML-1328): Tree reconstruction is driven by the *layout-container* elements (Page,
# Column, Section, ...), which retain their tree `parent_id`. Content-element `parent_id` is no
# longer the tree parent -- it is the heading-based parent assigned by `set_element_hierarchy`
# -- so it must NOT be used to rebuild the layout tree. Instead, each content element is nested
# in the innermost open layout container, tracked with a stack keyed on the containers' own
# (tree) `parent_id`. This is independent of document content `parent_id` and reproduces the
# original layout nesting exactly.
container_stack: list[tuple[str, ontology.OntologyElement]] = [(root_element_id, root_element)]
for element in unstructured_elements:
# -- an element with no HTML payload carries nothing to rebuild the tree from;
# -- skip it per-element rather than letting BeautifulSoup(None) abort the whole
# -- reconstruction (e.g. mixed/partially-stripped element streams). --
if not element.metadata.text_as_html:
continue
html_as_tags = BeautifulSoup(element.metadata.text_as_html, "html.parser").find_all(
recursive=False
)
for html_as_tag in html_as_tags:
ontology_element = parse_html_to_ontology_element(html_as_tag)
is_layout_container = ontology_element.elementType == ontology.ElementTypeEnum.layout
if is_layout_container:
# -- pop back to this container's tree parent, then attach + open it. Only pop if
# -- that parent is actually open on the stack; a `parent_id` matching no open
# -- container (e.g. malformed/reordered input that violates the documented
# -- parent-before-child precondition) must not pop past valid ancestors to root --
# -- which would mis-nest later content. In that case attach to the current
# -- innermost container instead, preserving document order and losing nothing. --
# -- a container with no `parent_id` is a top-level container -> attach at root --
parent_id = element.metadata.parent_id or root_element_id
if any(container_id == parent_id for container_id, _ in container_stack):
while len(container_stack) > 1 and container_stack[-1][0] != parent_id:
container_stack.pop()
container_stack[-1][1].children.append(ontology_element)
container_stack.append((element.id, ontology_element))
else:
# -- content nests in the innermost currently-open layout container --
container_stack[-1][1].children.append(ontology_element)
return root_element
def parse_html_to_ontology(html_code: str) -> ontology.OntologyElement:
"""
Parses the given HTML code and converts it into an Element object.
Args:
html_code (str): The HTML code to be parsed.
Parsing HTML will start from <div class="Page">.
Returns:
OntologyElement: The parsed Element object.
Raises:
ValueError: If no <body class="Document"> element is found in the HTML.
"""
html_code = remove_empty_divs_from_html_content(html_code)
html_code = remove_empty_tags_from_html_content(html_code)
soup = BeautifulSoup(html_code, "html.parser")
document = soup.find("body", class_="Document")
if not document:
document = soup.find("div", class_="Page")
if not document:
raise ValueError(
"No <body class='Document'> or <div class='Page'> element found in the HTML."
)
document_element = parse_html_to_ontology_element(document)
return document_element
def remove_empty_divs_from_html_content(html_content: str) -> str:
soup = BeautifulSoup(html_content, "html.parser")
divs = soup.find_all("div")
for div in reversed(divs):
if not div.attrs:
div.unwrap()
return str(soup)
def remove_empty_tags_from_html_content(html_content: str) -> str:
soup = BeautifulSoup(html_content, "html.parser")
def is_empty(tag):
# Remove only specific tags, omit self-closing ones
if tag.name not in (*HEADING_TAGS, "p", "span", "div"):
return False
if tag.find():
return False
if tag.attrs:
return False
return bool(not tag.get_text(strip=True))
def remove_empty_tags(soup):
for tag in soup.find_all():
if is_empty(tag):
tag.decompose()
remove_empty_tags(soup)
return str(soup)
def parse_html_to_ontology_element(soup: Tag, recursion_depth: int = 1) -> ontology.OntologyElement:
"""
Converts a BeautifulSoup Tag object into an OntologyElement object. This function is recursive.
First tries to recognize a class from Unstructured Ontology, then if class is matched tries
to go deeper inside HTML tree. The recursive parsing is ended if the class is not recognized or
there are no HTML Tags inside HTML - just text. Then it is parsed to
Paragraph or UncategorizedText object.
Args:
soup (Tag): The BeautifulSoup Tag object to be converted.
recursion_depth (int): Flag to control limit of recursion depth.
Returns:
OntologyElement: The converted OntologyElement object.
"""
ontology_html_tag, ontology_class = extract_tag_and_ontology_class_from_tag(soup)
escaped_attrs = get_sanitized_attributes(soup, tag_name=ontology_html_tag)
if soup.name == "br": # Note(Pluto) should it be <br class="UncategorizedText">?
return ontology.Paragraph(
text="",
css_class_name=None,
html_tag_name="br",
additional_attributes=escaped_attrs,
)
has_children = (
(ontology_class != ontology.UncategorizedText)
and any(isinstance(content, Tag) for content in soup.contents)
or ontology_class().elementType == ontology.ElementTypeEnum.layout
)
should_unwrap_html = has_children and recursion_depth <= RECURSION_LIMIT
if should_unwrap_html:
text = ""
children = [
(
parse_html_to_ontology_element(child, recursion_depth=recursion_depth + 1)
if isinstance(child, Tag)
else ontology.Paragraph(text=str(child).strip())
)
for child in soup.children
if str(child).strip()
]
else:
text = "\n".join([str(content).strip() for content in soup.contents]).strip()
children = []
output_element = ontology_class(
text=text,
children=children,
html_tag_name=ontology_html_tag,
additional_attributes=escaped_attrs,
)
# TODO (Pluto): <input class="FormFieldValue"/> requires being wrapped in <label> tags
return output_element
def extract_tag_and_ontology_class_from_tag(
soup: Tag,
) -> tuple[str, Type[ontology.OntologyElement]]:
"""
Extracts the HTML tag and corresponding ontology class
from a BeautifulSoup Tag object. The CSS class is prioritized over
the HTML tag. If not recognized soup.name and UnstructuredText is returned.
Args:
soup (Tag): The BeautifulSoup Tag object to extract information from.
Returns:
tuple: A tuple containing the HTML tag (str) and the ontology class (Type[OntologyElement]).
"""
html_tag, element_class = None, None
# Scenario 1: Valid Ontology Element
if soup.attrs.get("class"):
html_tag, element_class = (
soup.name,
HTML_TAG_AND_CSS_NAME_TO_ELEMENT_TYPE_MAP.get((soup.name, soup.attrs["class"][0])),
)
# Scenario 2: HTML tag incorrect, CSS class correct
# Fallback to css name selector and overwrite html tag
if (
not element_class
and soup.attrs.get("class")
and soup.attrs["class"][0] in CSS_CLASS_TO_ELEMENT_TYPE_MAP
):
element_class = CSS_CLASS_TO_ELEMENT_TYPE_MAP.get(soup.attrs["class"][0])
html_tag = element_class().allowed_tags[0]
# Scenario 3: <input> elements, handled explicitly based on their 'type' attribute
if not element_class and soup.name == "input":
input_type = (str(soup.get("type")) or "").lower()
if input_type == "checkbox":
element_class = ontology.Checkbox
elif input_type == "radio":
element_class = ontology.RadioButton
else:
# Any other input (including missing type or text/number/etc.) is considered
# a generic form field value.
element_class = ontology.FormFieldValue
html_tag = "input"
# Scenario 4: CSS class incorrect, but HTML tag correct and exclusive in ontology
if not element_class and soup.name in HTML_TAG_TO_DEFAULT_ELEMENT_TYPE_MAP:
html_tag, element_class = soup.name, HTML_TAG_TO_DEFAULT_ELEMENT_TYPE_MAP[soup.name]
# Scenario 5: CSS class incorrect, HTML tag incorrect
# Fallback to default UncategorizedText
if not element_class:
# TODO (Pluto): Sometimes we could infer that from parent type and soup.name
# e.g. parent=FormField soup.name=input -> element=FormFieldInput
html_tag = "span"
element_class = ontology.UncategorizedText
# Scenario 6: UncategorizedText has image and no text
# Typically, this happens with a span or div tag with an image inside
if element_class == ontology.UncategorizedText and soup.find("img") and not soup.text.strip():
element_class = ontology.Image
return html_tag, element_class
def get_sanitized_attributes(soup: Tag, tag_name: str | None = None) -> dict[str, str | list[str]]:
"""
Sanitizes the attributes of a BeautifulSoup Tag object for the ontology.
Drops event-handler (``on*``) attributes and URL attributes with unsafe
schemes (``javascript:`` / ``vbscript:`` / non-image ``data:``) so an
ontology element never carries a dangerous attribute in the first place.
Values are intentionally left un-escaped: HTML-escaping is done exactly once,
at emit time, in ``OntologyElement.to_html`` (see
``unstructured.documents.html_sanitization``). Escaping here as well would
double-encode entities (e.g. ``&`` -> ``&amp;amp;``).
Args:
soup (Tag): The BeautifulSoup Tag object whose attributes to sanitize.
tag_name: The ontology tag that will be emitted for this element.
Returns:
dict: A dictionary with the safe subset of attributes.
"""
return sanitize_attributes(dict(soup.attrs), tag_name=tag_name) # type: ignore[return-value]
# -- Backwards-compatible alias; the previous name implied it html-escaped, which
# -- it no longer does (escaping moved to emit time). --
get_escaped_attributes = get_sanitized_attributes