import logging from abc import ABC from collections import defaultdict from typing import Any, Optional, Union from bs4 import BeautifulSoup, Tag from unstructured.documents.elements import Element, ElementType from unstructured.documents.html_sanitization import sanitize_html_fragment logger = logging.getLogger(__name__) HTML_PARSER = "html.parser" HTML_TEMPLATE = """ """ TABLE_BORDER_STYLE = "border: 1px solid black;" TABLE_BORDER_COLLAPSE_STYLE = "border-collapse: collapse;" class ElementHtml(ABC): element: Element children: list["ElementHtml"] _html_tag: str = "div" def __init__(self, element: Element, children: Optional[list["ElementHtml"]] = None): self.element = element self.children = children or [] @property def html_tag(self) -> str: return self._html_tag def _inject_html_element_attrs(self, element_html: Tag) -> None: return None def _inject_html_element_content(self, element_html: Tag, **kwargs: Any) -> None: element_html.string = self.element.text def get_text_as_html(self) -> Union[Tag, None]: element_html = BeautifulSoup(self.element.metadata.text_as_html or "", HTML_PARSER).find() if not isinstance(element_html, Tag): return None return element_html def _get_children_html(self, soup: BeautifulSoup, element_html: Tag, **kwargs: Any) -> Tag: wrapper = soup.new_tag(name="div") wrapper.append(element_html) for child in self.children: child_html = child.get_html_element(_soup=soup, **kwargs) wrapper.append(child_html) return wrapper def get_html_element(self, **kwargs: Any) -> Tag: soup: Optional[BeautifulSoup] = kwargs.pop("_soup", None) if soup is None: soup = BeautifulSoup("", HTML_PARSER) element_html = self.get_text_as_html() if element_html is None: element_html = soup.new_tag(name=self.html_tag) self._inject_html_element_content(element_html, **kwargs) element_html["class"] = self.element.category element_html["id"] = self.element.id self._inject_html_element_attrs(element_html) if self.children: # if element has children wrap it with a 'div' tag return self._get_children_html(soup, element_html, **kwargs) return element_html def set_children(self, children: list["ElementHtml"]) -> None: self.children = children class TitleElementHtml(ElementHtml): _html_tag = "h%d" @property def html_tag(self) -> str: return self._html_tag % (self.element.metadata.category_depth or 1) class ImageElementHtml(ElementHtml): _html_tag = "img" def _inject_html_element_content(self, element_html: Tag, **kwargs: Any) -> None: exclude_binary_image_data = kwargs.get("exclude_binary_image_data", False) if self.element.metadata.image_base64 and not exclude_binary_image_data: image_mime_type = self.element.metadata.image_mime_type or "image/png" element_html["src"] = ( f"data:{image_mime_type};base64,{self.element.metadata.image_base64}" ) element_html["alt"] = self.element.text class TableElementHtml(ElementHtml): _html_tag = "table" def _inject_html_element_content(self, element_html: Tag, **kwargs: Any) -> None: # -- A table with no `text_as_html` carries only raw text. Wrap it in a # -- proper row/cell so the markup is valid; loose text placed directly in # -- a `` is otherwise foster-parented out of the table by HTML5 # -- parsers (e.g. the nh3 sanitization pass), leaving the table empty. -- if not self.element.text: return soup = BeautifulSoup("", HTML_PARSER) tbody = soup.new_tag(name="tbody") row = soup.new_tag(name="tr") cell = soup.new_tag(name="td") cell.string = self.element.text row.append(cell) tbody.append(row) element_html.append(tbody) def _inject_html_element_attrs(self, element_html: Tag) -> None: element_html["style"] = f"{TABLE_BORDER_STYLE} {TABLE_BORDER_COLLAPSE_STYLE}" for tag in element_html.find_all(["tr", "th", "td"]): tag["style"] = TABLE_BORDER_STYLE class LinkElementHtml(ElementHtml): _html_tag = "a" def _inject_html_element_attrs(self, element_html: Tag) -> None: element_html["href"] = self.element.metadata.url or "" class TextElementHtml(ElementHtml): _html_tag = "p" class UnorderedListElementHtml(ElementHtml): _html_tag = "ul" def _get_children_html(self, soup: BeautifulSoup, element_html: Tag, **kwargs: Any) -> Tag: for child in self.children: child_html = child.get_html_element(**kwargs) element_html.append(child_html) return element_html class OrderedListElementHtml(UnorderedListElementHtml): _html_tag = "ol" class ListItemElementHtml(UnorderedListElementHtml): _html_tag = "li" class LabelElementHtml(ElementHtml): _html_tag = "label" class FormElementHtml(ElementHtml): _html_tag = "form" class InputElementHtml(ElementHtml): _html_tag = "input" class CheckboxElementHtml(InputElementHtml): def _inject_html_element_attrs(self, element_html: Tag) -> None: element_html["type"] = "checkbox" class CheckboxCheckedElementHtml(InputElementHtml): def _inject_html_element_attrs(self, element_html: Tag) -> None: element_html["type"] = "checkbox" element_html["checked"] = "true" class RadioElementHtml(InputElementHtml): def _inject_html_element_attrs(self, element_html: Tag) -> None: element_html["type"] = "radio" class RadioCheckedElementHtml(InputElementHtml): def _inject_html_element_attrs(self, element_html: Tag) -> None: element_html["type"] = "radio" element_html["checked"] = "true" LIST_ELEMENTS = [ElementType.LIST_ITEM, ElementType.LIST_ITEM_OTHER] TYPE_TO_HTML_MAP = { ElementType.UNCATEGORIZED_TEXT: TextElementHtml, ElementType.TITLE: TitleElementHtml, ElementType.IMAGE: ImageElementHtml, ElementType.TABLE: TableElementHtml, ElementType.LINK: LinkElementHtml, ElementType.TEXT: TextElementHtml, ElementType.PARAGRAPH: TextElementHtml, ElementType.LIST: OrderedListElementHtml, ElementType.LIST_ITEM: ListItemElementHtml, ElementType.LIST_ITEM_OTHER: ListItemElementHtml, ElementType.FIELD_NAME: LabelElementHtml, ElementType.BULLETED_TEXT: ListItemElementHtml, ElementType.FORM: FormElementHtml, ElementType.CAPTION: TextElementHtml, ElementType.CHECKED: CheckboxCheckedElementHtml, ElementType.UNCHECKED: CheckboxElementHtml, ElementType.CHECK_BOX_CHECKED: CheckboxCheckedElementHtml, ElementType.CHECK_BOX_UNCHECKED: CheckboxElementHtml, ElementType.RADIO_BUTTON_CHECKED: RadioCheckedElementHtml, ElementType.RADIO_BUTTON_UNCHECKED: RadioElementHtml, ElementType.NARRATIVE_TEXT: TextElementHtml, ElementType.FIGURE_CAPTION: TextElementHtml, ElementType.VALUE: InputElementHtml, ElementType.ABSTRACT: ElementHtml, ElementType.THREADING: ElementHtml, ElementType.COMPOSITE_ELEMENT: ElementHtml, ElementType.PICTURE: ElementHtml, ElementType.FIGURE: ElementHtml, ElementType.ADDRESS: ElementHtml, ElementType.EMAIL_ADDRESS: ElementHtml, ElementType.PAGE_BREAK: ElementHtml, ElementType.FORMULA: ElementHtml, ElementType.HEADER: ElementHtml, ElementType.HEADLINE: ElementHtml, ElementType.SUB_HEADLINE: ElementHtml, ElementType.PAGE_HEADER: ElementHtml, ElementType.SECTION_HEADER: ElementHtml, ElementType.FOOTER: ElementHtml, ElementType.FOOTNOTE: ElementHtml, ElementType.PAGE_FOOTER: ElementHtml, ElementType.PAGE_NUMBER: ElementHtml, ElementType.CODE_SNIPPET: ElementHtml, ElementType.FORM_KEYS_VALUES: ElementHtml, ElementType.DOCUMENT_DATA: ElementHtml, } def _group_element_children(children: list[ElementHtml]) -> list[ElementHtml]: grouped_children: list[ElementHtml] = [] temp_group: list["ElementHtml"] = [] prev_grouping = False for child in children: grouping = child.element.category in LIST_ELEMENTS if grouping: temp_group.append(child) elif prev_grouping: grouped_children.append(OrderedListElementHtml(Element(), temp_group)) grouped_children.append(child) temp_group = [] else: grouped_children.append(child) prev_grouping = grouping if temp_group: grouped_children.append(OrderedListElementHtml(Element(), temp_group)) return grouped_children def _elements_to_html_tags_by_parent(elements: list[ElementHtml]) -> list[ElementHtml]: parent_to_children_map: dict[str, list[ElementHtml]] = defaultdict(list) for element in elements: if element.element.metadata.parent_id is not None: parent_to_children_map[element.element.metadata.parent_id].append(element) for parent_id, children in parent_to_children_map.items(): grouped_children = _group_element_children(children) parent = next((el for el in elements if el.element.id == parent_id), None) if parent is None: logger.warning(f"Parent element with id {parent_id} not found. Skipping.") continue parent.set_children(grouped_children) return [el for el in elements if el.element.metadata.parent_id is None] def _elements_to_html_tags( elements: list[Element], exclude_binary_image_data: bool = False ) -> list[Tag]: elements_html = [ TYPE_TO_HTML_MAP.get(element.category, ElementHtml)(element) for element in elements ] elements_html = _elements_to_html_tags_by_parent(elements_html) return [ element_html.get_html_element(exclude_binary_image_data=exclude_binary_image_data) for element_html in elements_html ] def _elements_to_html_tags_by_page( elements: list[Element], exclude_binary_image_data: bool = False ) -> list[Tag]: soup = BeautifulSoup("", HTML_PARSER) pages_tags: list[Tag] = [] grouped_elements = group_elements_by_page(elements) for page, g_elements in enumerate(grouped_elements, start=1): page_html = soup.new_tag(name="div", attrs={"data-page_number": page}) elements_html = _elements_to_html_tags(g_elements, exclude_binary_image_data) for element_html in elements_html: page_html.append(element_html) pages_tags.append(page_html) return pages_tags def group_elements_by_page( unstructured_elements: list[Element], ) -> list[list[Element]]: pages_dict: defaultdict[int, list[Element]] = defaultdict(list) for element in unstructured_elements: page_number = element.metadata.page_number if page_number is None: logger.warning(f"Page number is not set for an element {element.id}. Skipping.") continue pages_dict[page_number].append(element) pages_list = list(pages_dict.values()) return pages_list def elements_to_html( elements: list[Element], exclude_binary_image_data: bool = False, no_group_by_page: bool = False, ) -> str: soup = BeautifulSoup(HTML_TEMPLATE, HTML_PARSER) if soup.body is None: raise ValueError("Body tag not found in the HTML template") elements_html = ( _elements_to_html_tags(elements, exclude_binary_image_data) if no_group_by_page else _elements_to_html_tags_by_page(elements, exclude_binary_image_data) ) # -- Defense-in-depth (GHSA-v5mq-3xhg-98m9): sanitize the assembled body content # -- through nh3 before returning. This neutralizes any dangerous markup that # -- survived into `text_as_html` as well as attributes injected here directly # -- (e.g. an `href` built from `metadata.url`). We sanitize the fragment and # -- reinsert it, since nh3 strips the surrounding // wrapper. -- body_fragment = "".join(str(element_html) for element_html in elements_html) sanitized_fragment = sanitize_html_fragment(body_fragment) # -- materialize the parsed nodes before appending: `append` moves a node out # -- of the source soup's `.contents`, so iterating it live would skip nodes. -- for element_html in list(BeautifulSoup(sanitized_fragment, HTML_PARSER).contents): soup.body.append(element_html) return soup.prettify()