"""Provides operations related to the HTML table stored in `.metadata.text_as_html`. Used during partitioning as well as chunking. """ from __future__ import annotations import html from functools import cached_property from typing import TYPE_CHECKING, Iterator, Sequence, cast from lxml import etree from lxml.html import fragment_fromstring if TYPE_CHECKING: from lxml.html import HtmlElement def htmlify_matrix_of_cell_texts(matrix: Sequence[Sequence[str]]) -> str: """Form an HTML table from "rows" and "columns" of `matrix`. Character overhead is minimized: - No whitespace padding is added for human readability - No newlines ("\n") are added - No ``, ``, or `` elements are used; we can't tell where those might be semantically appropriate anyway so at best they would consume unnecessary space and at worst would be misleading. """ def iter_trs(rows_of_cell_strs: Sequence[Sequence[str]]) -> Iterator[str]: for row_cell_strs in rows_of_cell_strs: # -- suppress emission of rows with no cells -- if not row_cell_strs: continue yield f"{''.join(iter_tds(row_cell_strs))}" def iter_tds(row_cell_strs: Sequence[str]) -> Iterator[str]: for s in row_cell_strs: # -- take care of things like '<' and '>' in the text -- s = html.escape(s) # -- substitute
elements for line-feeds in the text -- s = "
".join(s.split("\n")) # -- normalize whitespace in cell -- cell_text = " ".join(s.split()) # -- emit void `` when cell text is empty string -- yield f"{cell_text}" if cell_text else "" return f"{''.join(iter_trs(matrix))}
" if matrix else "" class HtmlTable: """A `` element.""" def __init__( self, table: HtmlElement, header_row_idxs: set[int] | None = None, source_row_htmls: Sequence[str] | None = None, ): self._table = table self._header_row_idxs = header_row_idxs or set() self._source_row_htmls = tuple(source_row_htmls or ()) @classmethod def from_html_text(cls, html_text: str) -> HtmlTable: # -- root is always a `
` element so far but let's be robust -- root = fragment_fromstring(html_text) tables = root.xpath("//table") if not tables: raise ValueError("`html_text` contains no `
` element") table = tables[0] # -- capture header semantics and source row HTML before compactification strips details -- rows = cast("list[HtmlElement]", table.xpath("./tr | ./thead/tr | ./tbody/tr | ./tfoot/tr")) source_row_htmls = tuple(etree.tostring(tr, encoding=str) for tr in rows) header_row_idxs = { idx for idx, tr in enumerate(rows) if tr.getparent().tag == "thead" or bool(tr.xpath("./th")) } # -- remove ``, ``, and `` noise elements when present -- noise_elements = table.xpath(".//thead | .//tbody | .//tfoot") for e in noise_elements: e.drop_tag() # -- normalize and compactify the HTML -- for e in table.iter(): # -- Strip cosmetic attributes like border="1", class="dataframe" added # -- by pandas.DataFrame.to_html(), style="text-align: right;", etc. # -- Preserve colspan/rowspan: they are structural, not cosmetic, and are # -- required to reconstruct merged-cell layout in chunk HTML. preserved = {k: e.attrib[k] for k in ("colspan", "rowspan") if k in e.attrib} e.attrib.clear() for k, v in preserved.items(): e.attrib[k] = v # -- change any ``, ``, or `` tags. It is made as compact as possible to maximize the semantic content in a given space. This is particularly important for chunking. """ return etree.tostring(self._table, encoding=str) def iter_rows(self) -> Iterator[HtmlRow]: rows = cast("list[HtmlElement]", self._table.xpath("./tr")) for idx, tr in enumerate(rows): source_html = self._source_row_htmls[idx] if idx < len(self._source_row_htmls) else None yield HtmlRow(tr, is_header=(idx in self._header_row_idxs), source_html=source_html) @cached_property def text(self) -> str: """The clean, concatenated, text for this table.""" table_text = " ".join(self._table.itertext()) # -- blank cells will introduce extra whitespace, so normalize after accumulating -- return " ".join(table_text.split()) class HtmlRow: """A `` element.""" def __init__(self, tr: HtmlElement, is_header: bool = False, source_html: str | None = None): self._tr = tr self._is_header = is_header self._source_html = source_html @cached_property def html(self) -> str: """Like "".""" return etree.tostring(self._tr, encoding=str) def iter_cells(self) -> Iterator[HtmlCell]: for td in self._tr: yield HtmlCell(td) @property def is_header(self) -> bool: """True when this row originated from `` or contains `` HTML captured before compactification, when available.""" return self._source_html def iter_cell_texts(self) -> Iterator[str]: """Generate contents of each cell of this row as a separate string. A cell that is empty or contains only whitespace does not generate a string. """ for td in self._tr: text = " ".join(td.text_content().split()) if not text: continue yield text @cached_property def text_len(self) -> int: """Length of the normalized text, as it would appear in `element.text`.""" return len(" ".join(self.iter_cell_texts())) class HtmlCell: """A `".""" return etree.tostring(self._td, encoding=str) if self.text else "
` elements to `` so all cells have the same tag -- if e.tag == "th": e.tag = "td" # -- normalize whitespace in element text; this removes indent whitespace before nested # -- elements and reduces whitespace between words to a single space. if e.text: e.text = " ".join(e.text.split()) # -- normalize tails. A tail is the text between an element's closing tag and the # -- start of the next sibling. Pure-whitespace tails are pretty-printing noise and # -- can be dropped, but tails can also carry real content (e.g. mixed inline # -- markup like `foo bar baz` or text between `
` tags), which # -- must be preserved. if e.tail: parts = e.tail.split() if not parts: e.tail = None else: prefix = " " if e.tail[0].isspace() else "" suffix = " " if e.tail[-1].isspace() else "" e.tail = prefix + " ".join(parts) + suffix return cls(table, header_row_idxs=header_row_idxs, source_row_htmls=source_row_htmls) @cached_property def html(self) -> str: """The HTML-fragment for this `` element, all on one line. Like: `
foo
bar
` The HTML contains no human-readability whitespace, attributes, or `
foobar
` cells.""" return self._is_header @property def source_html(self) -> str | None: """Original source `
` element.""" def __init__(self, td: HtmlElement): self._td = td @cached_property def html(self) -> str: """Like "foo bar baz" @cached_property def text(self) -> str: """Text inside `` element, empty string when no text.""" return " ".join(self._td.text_content().split())