461bf6fd40
CI / lint (3.11) (push) Blocked by required conditions
CI / lint (3.12) (push) Blocked by required conditions
CI / lint (3.13) (push) Blocked by required conditions
CI / shellcheck (push) Waiting to run
CI / shfmt (push) Waiting to run
CI / setup (3.11) (push) Waiting to run
CI / setup (3.12) (push) Waiting to run
CI / setup (3.13) (push) Waiting to run
CI / check-licenses (3.12) (push) Blocked by required conditions
CI / test_unit (3.11) (push) Blocked by required conditions
CI / test_unit (3.12) (push) Blocked by required conditions
CI / test_unit (3.13) (push) Blocked by required conditions
CI / test_unit_no_extras (3.11) (push) Blocked by required conditions
CI / test_unit_no_extras (3.12) (push) Blocked by required conditions
CI / test_json_to_html (3.12) (push) Blocked by required conditions
CI / test_unit_no_extras (3.13) (push) Blocked by required conditions
CI / test_unit_dependency_extras (csv, 3.11, --extra csv) (push) Blocked by required conditions
CI / test_unit_dependency_extras (csv, 3.12, --extra csv) (push) Blocked by required conditions
CI / test_unit_dependency_extras (csv, 3.13, --extra csv) (push) Blocked by required conditions
CI / test_unit_dependency_extras (docx, 3.11, --extra docx) (push) Blocked by required conditions
CI / test_unit_dependency_extras (docx, 3.12, --extra docx) (push) Blocked by required conditions
CI / test_unit_dependency_extras (docx, 3.13, --extra docx) (push) Blocked by required conditions
CI / test_unit_dependency_extras (markdown, 3.11, --extra md) (push) Blocked by required conditions
CI / test_unit_dependency_extras (markdown, 3.12, --extra md) (push) Blocked by required conditions
CI / test_unit_dependency_extras (markdown, 3.13, --extra md) (push) Blocked by required conditions
CI / test_unit_dependency_extras (odt, 3.11, --extra odt) (push) Blocked by required conditions
CI / test_unit_dependency_extras (odt, 3.12, --extra odt) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pdf-image, 3.12, --extra pdf --extra image --extra paddleocr) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pdf-image, 3.13, --extra pdf --extra image --extra paddleocr) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pptx, 3.13, --extra pptx) (push) Blocked by required conditions
CI / test_unit_dependency_extras (odt, 3.13, --extra odt) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pdf-image, 3.11, --extra pdf --extra image --extra paddleocr) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pptx, 3.11, --extra pptx) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pptx, 3.12, --extra pptx) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pypandoc, 3.11, --extra epub --extra org --extra rtf --extra rst) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pypandoc, 3.12, --extra epub --extra org --extra rtf --extra rst) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pypandoc, 3.13, --extra epub --extra org --extra rtf --extra rst) (push) Blocked by required conditions
CI / test_unit_dependency_extras (xlsx, 3.11, --extra xlsx) (push) Blocked by required conditions
CI / test_unit_dependency_extras (xlsx, 3.12, --extra xlsx) (push) Blocked by required conditions
CI / test_unit_dependency_extras (xlsx, 3.13, --extra xlsx) (push) Blocked by required conditions
CI / test_ingest_src (3.12) (push) Blocked by required conditions
CI / test_json_to_markdown (3.12) (push) Blocked by required conditions
CI / changelog (push) Waiting to run
CI / test_dockerfile (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Build And Push Docker Image / set-short-sha (push) Waiting to run
Build And Push Docker Image / build-images (linux/amd64, opensource-linux-8core) (push) Blocked by required conditions
Build And Push Docker Image / build-images (linux/arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
Build And Push Docker Image / publish-images (push) Blocked by required conditions
Partition Benchmark / setup (push) Waiting to run
Partition Benchmark / Measure and compare partition() runtime (push) Blocked by required conditions
208 lines
8.0 KiB
Python
208 lines
8.0 KiB
Python
"""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 `<thead>`, `<tbody>`, or `<tfoot>` 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"<tr>{''.join(iter_tds(row_cell_strs))}</tr>"
|
|
|
|
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 <br/> elements for line-feeds in the text --
|
|
s = "<br/>".join(s.split("\n"))
|
|
# -- normalize whitespace in cell --
|
|
cell_text = " ".join(s.split())
|
|
# -- emit void `<td/>` when cell text is empty string --
|
|
yield f"<td>{cell_text}</td>" if cell_text else "<td/>"
|
|
|
|
return f"<table>{''.join(iter_trs(matrix))}</table>" if matrix else ""
|
|
|
|
|
|
class HtmlTable:
|
|
"""A `<table>` 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 `<table>` 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 `<table>` 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 `<thead>`, `<tbody>`, and `<tfoot>` 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 `<th>` elements to `<td>` 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 `<b>foo</b> bar <b>baz</b>` or text between `<br/>` 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 `<table>` element, all on one line.
|
|
|
|
Like: `<table><tr><td>foo</td></tr><tr><td>bar</td></tr></table>`
|
|
|
|
The HTML contains no human-readability whitespace, attributes, or `<thead>`, `<tbody>`, or
|
|
`<tfoot>` 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 `<tr>` 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 "<tr><td>foo</td><td>bar</td></tr>"."""
|
|
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 `<thead>` or contains `<th>` cells."""
|
|
return self._is_header
|
|
|
|
@property
|
|
def source_html(self) -> str | None:
|
|
"""Original source `<tr>` 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 `<td>` element."""
|
|
|
|
def __init__(self, td: HtmlElement):
|
|
self._td = td
|
|
|
|
@cached_property
|
|
def html(self) -> str:
|
|
"""Like "<td>foo bar baz</td>"."""
|
|
return etree.tostring(self._td, encoding=str) if self.text else "<td/>"
|
|
|
|
@cached_property
|
|
def text(self) -> str:
|
|
"""Text inside `<td>` element, empty string when no text."""
|
|
return " ".join(self._td.text_content().split())
|