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
326 lines
12 KiB
Python
326 lines
12 KiB
Python
"""Handles dispatch of elements to a chunking-strategy by name.
|
|
|
|
Also provides the `@add_chunking_strategy` decorator which is the chief current user of "by-name"
|
|
chunking dispatch.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import dataclasses as dc
|
|
import functools
|
|
import inspect
|
|
from functools import cached_property
|
|
from typing import Any, Callable, Iterable, Optional, Protocol
|
|
|
|
from lxml.etree import ParserError, tostring
|
|
from lxml.html import fragment_fromstring
|
|
from typing_extensions import ParamSpec
|
|
|
|
from unstructured.chunking.basic import chunk_elements
|
|
from unstructured.chunking.title import chunk_by_title
|
|
from unstructured.documents.elements import Element, Table, TableChunk
|
|
from unstructured.utils import get_call_args_applying_defaults
|
|
|
|
_P = ParamSpec("_P")
|
|
|
|
|
|
class Chunker(Protocol):
|
|
"""Abstract interface for chunking functions."""
|
|
|
|
def __call__(
|
|
self, elements: Iterable[Element], *, max_characters: Optional[int]
|
|
) -> list[Element]:
|
|
"""A chunking function must have this signature.
|
|
|
|
In particular it must minimally have an `elements` parameter and all chunkers will have a
|
|
`max_characters` parameter (doesn't need to follow `elements` directly). All others can
|
|
vary by chunker.
|
|
"""
|
|
...
|
|
|
|
|
|
def add_chunking_strategy(func: Callable[_P, list[Element]]) -> Callable[_P, list[Element]]:
|
|
"""Decorator for chunking text.
|
|
|
|
Chunks the element sequence produced by the partitioner it decorates when a `chunking_strategy`
|
|
argument is present in the partitioner call and it names an available chunking strategy.
|
|
"""
|
|
# -- Patch the docstring of the decorated function to add chunking strategy and
|
|
# -- chunking-related argument documentation. This only applies when `chunking_strategy`
|
|
# -- is an explicit argument of the decorated function and "chunking_strategy" is not
|
|
# -- already mentioned in the docstring.
|
|
if func.__doc__ and (
|
|
"chunking_strategy" in func.__code__.co_varnames and "chunking_strategy" not in func.__doc__
|
|
):
|
|
func.__doc__ += (
|
|
"\nchunking_strategy"
|
|
+ "\n\tStrategy used for chunking text into larger or smaller elements."
|
|
+ "\n\tDefaults to `None` with optional arg of 'basic' or 'by_title'."
|
|
+ "\n\tAdditional Parameters:"
|
|
+ "\n\t\tmultipage_sections"
|
|
+ "\n\t\t\tIf True, sections can span multiple pages. Defaults to True."
|
|
+ "\n\t\tcombine_text_under_n_chars"
|
|
+ "\n\t\t\tCombines elements (for example a series of titles) until a section"
|
|
+ "\n\t\t\treaches a length of n characters. Only applies to 'by_title' strategy."
|
|
+ "\n\t\tnew_after_n_chars"
|
|
+ "\n\t\t\tCuts off chunks once they reach a length of n characters; a soft max."
|
|
+ "\n\t\tmax_characters"
|
|
+ "\n\t\t\tChunks elements text and text_as_html (if present) into chunks"
|
|
+ "\n\t\t\tof length n characters, a hard max."
|
|
+ "\n\t\trepeat_table_headers"
|
|
+ "\n\t\t\tDefault: True. Repeat detected table headers on continuation"
|
|
+ "\n\t\t\ttable chunks. Set to False to opt out."
|
|
+ "\n\t\tskip_table_chunking"
|
|
+ "\n\t\t\tDefault: False. When True, Table elements are passed through"
|
|
+ "\n\t\t\tunchanged without being split into TableChunk elements."
|
|
+ "\n\t\tisolate_table"
|
|
+ "\n\t\t\tDefault: True. When True, Table/TableChunk elements are always"
|
|
+ "\n\t\t\tstaged in their own pre-chunk. Set to False to allow tables to"
|
|
+ "\n\t\t\tshare pre-chunks with adjacent non-table elements."
|
|
)
|
|
|
|
@functools.wraps(func)
|
|
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> list[Element]:
|
|
"""The decorated function is replaced with this one."""
|
|
|
|
# -- call the partitioning function to get the elements --
|
|
elements = func(*args, **kwargs)
|
|
|
|
# -- look for a chunking-strategy argument --
|
|
call_args = get_call_args_applying_defaults(func, *args, **kwargs)
|
|
chunking_strategy = call_args.pop("chunking_strategy", None)
|
|
|
|
# -- no chunking-strategy means no chunking --
|
|
if chunking_strategy is None:
|
|
return elements
|
|
|
|
# -- otherwise, chunk away :) --
|
|
return chunk(elements, chunking_strategy, **call_args)
|
|
|
|
return wrapper
|
|
|
|
|
|
def chunk(elements: Iterable[Element], chunking_strategy: str, **kwargs: Any) -> list[Element]:
|
|
"""Dispatch chunking of `elements` to the chunking function for `chunking_strategy`."""
|
|
chunker_spec = _chunker_registry.get(chunking_strategy)
|
|
|
|
if chunker_spec is None:
|
|
raise ValueError(f"unrecognized chunking strategy {repr(chunking_strategy)}")
|
|
|
|
# -- `kwargs` will in general be an omnibus dict of all keyword arguments to the partitioner;
|
|
# -- pick out and use only those supported by this chunker.
|
|
chunking_kwargs = {k: v for k, v in kwargs.items() if k in chunker_spec.kw_arg_names}
|
|
|
|
return chunker_spec.chunker(elements, **chunking_kwargs)
|
|
|
|
|
|
def register_chunking_strategy(name: str, chunker: Chunker) -> None:
|
|
"""Make chunker available by using `name` as `chunking_strategy` arg in partitioner call."""
|
|
_chunker_registry[name] = _ChunkerSpec(chunker)
|
|
|
|
|
|
@dc.dataclass(frozen=True)
|
|
class _ChunkerSpec:
|
|
"""A registry entry for a chunker."""
|
|
|
|
chunker: Chunker
|
|
"""The "chunk_by_{x}() function that implements this chunking strategy."""
|
|
|
|
@cached_property
|
|
def kw_arg_names(self) -> tuple[str, ...]:
|
|
"""Keyword arguments supported by this chunker.
|
|
|
|
These are all arguments other than the required `elements: list[Element]` first parameter.
|
|
"""
|
|
sig = inspect.signature(self.chunker)
|
|
return tuple(key for key in sig.parameters if key != "elements")
|
|
|
|
|
|
_chunker_registry: dict[str, _ChunkerSpec] = {
|
|
"basic": _ChunkerSpec(chunk_elements),
|
|
"by_title": _ChunkerSpec(chunk_by_title),
|
|
}
|
|
|
|
|
|
def reconstruct_table_from_chunks(elements: Iterable[Element]) -> list[Table]:
|
|
"""Reconstruct original tables from a mixed list of chunked elements.
|
|
|
|
Filters `TableChunk` elements, groups them by `table_id`, orders by `chunk_index`, and
|
|
merges each group into a single `Table` with combined text and HTML. Non-`TableChunk`
|
|
elements are ignored. Returns reconstructed tables in reading order (order of first chunk
|
|
appearance).
|
|
"""
|
|
# -- filter to only TableChunk instances, preserving input order --
|
|
table_chunks = [e for e in elements if isinstance(e, TableChunk)]
|
|
if not table_chunks:
|
|
return []
|
|
|
|
# -- group by table_id, preserving first-seen order --
|
|
groups: dict[str, list[TableChunk]] = {}
|
|
for chunk in table_chunks:
|
|
tid = chunk.metadata.table_id
|
|
if tid is None:
|
|
continue
|
|
if tid not in groups:
|
|
groups[tid] = []
|
|
groups[tid].append(chunk)
|
|
|
|
# -- sort each group by chunk_index and merge --
|
|
tables: list[Table] = []
|
|
|
|
def _chunk_sort_key(chunk: TableChunk) -> tuple[bool, int]:
|
|
chunk_index = chunk.metadata.chunk_index
|
|
return (chunk_index is None, 0 if chunk_index is None else chunk_index)
|
|
|
|
for group in groups.values():
|
|
group.sort(key=_chunk_sort_key)
|
|
tables.append(_merge_table_chunks(group))
|
|
|
|
return tables
|
|
|
|
|
|
def _merge_table_chunks(chunks: list[TableChunk]) -> Table:
|
|
"""Merge an ordered list of TableChunks from the same table into a single Table."""
|
|
# -- combine text --
|
|
text = " ".join(
|
|
chunk_text for chunk in chunks if (chunk_text := _strip_carried_over_header_text(chunk))
|
|
)
|
|
|
|
# -- build metadata from first chunk --
|
|
metadata = copy.deepcopy(chunks[0].metadata)
|
|
metadata.is_continuation = None
|
|
metadata.table_id = None
|
|
metadata.chunk_index = None
|
|
metadata.num_carried_over_header_rows = None
|
|
|
|
# -- combine HTML if all chunks have it --
|
|
if all(c.metadata.text_as_html for c in chunks):
|
|
combined = fragment_fromstring("<table></table>")
|
|
canonical_header_row_count, canonical_header_rows = _first_carried_header_rows(chunks)
|
|
if canonical_header_rows:
|
|
thead = fragment_fromstring("<thead></thead>")
|
|
for row in canonical_header_rows:
|
|
thead.append(row)
|
|
combined.append(thead)
|
|
|
|
for c in chunks:
|
|
parsed = fragment_fromstring(c.metadata.text_as_html)
|
|
carried_over_header_rows = _num_carried_over_header_rows(c)
|
|
rows = parsed.xpath("./tr | ./thead/tr | ./tbody/tr | ./tfoot/tr")
|
|
skip_count = carried_over_header_rows
|
|
if c is chunks[0] and canonical_header_row_count:
|
|
skip_count = canonical_header_row_count
|
|
for row in rows[skip_count:]:
|
|
combined.append(row)
|
|
metadata.text_as_html = tostring(combined, encoding=str)
|
|
else:
|
|
metadata.text_as_html = None
|
|
|
|
return Table(text=text, metadata=metadata)
|
|
|
|
|
|
def _num_carried_over_header_rows(chunk: TableChunk) -> int:
|
|
"""Header rows prepended synthetically to this chunk.
|
|
|
|
Reconstruction can be called on user-provided/deserialized chunks, so treat missing values as
|
|
"no carried header rows."
|
|
"""
|
|
value = chunk.metadata.num_carried_over_header_rows
|
|
return value or 0
|
|
|
|
|
|
def _first_carried_header_rows(chunks: list[TableChunk]) -> tuple[int, list[Any]]:
|
|
"""Header rows from first continuation chunk carrying repeated headers, if any."""
|
|
first_chunk_rows = _top_level_table_rows(chunks[0].metadata.text_as_html)
|
|
if first_chunk_rows is None:
|
|
return 0, []
|
|
|
|
for chunk in chunks:
|
|
carried_row_count = _num_carried_over_header_rows(chunk)
|
|
if carried_row_count <= 0:
|
|
continue
|
|
|
|
rows = _top_level_table_rows(chunk.metadata.text_as_html)
|
|
if rows is None:
|
|
continue
|
|
|
|
if carried_row_count > len(rows):
|
|
continue
|
|
|
|
carried_rows = rows[:carried_row_count]
|
|
if not _leading_row_texts_match(first_chunk_rows, carried_rows):
|
|
continue
|
|
|
|
return carried_row_count, [copy.deepcopy(row) for row in rows[:carried_row_count]]
|
|
|
|
return 0, []
|
|
|
|
|
|
def _top_level_table_rows(text_as_html: str | None) -> list[Any] | None:
|
|
"""Top-level rows from a table fragment, preserving section ordering."""
|
|
if not text_as_html:
|
|
return None
|
|
|
|
try:
|
|
parsed = fragment_fromstring(text_as_html)
|
|
except (ParserError, ValueError):
|
|
return None
|
|
|
|
return parsed.xpath("./tr | ./thead/tr | ./tbody/tr | ./tfoot/tr")
|
|
|
|
|
|
def _leading_row_texts_match(first_chunk_rows: list[Any], carried_rows: list[Any]) -> bool:
|
|
"""True when carried rows match first chunk's leading rows by normalized cell text."""
|
|
if len(first_chunk_rows) < len(carried_rows):
|
|
return False
|
|
|
|
for first_row, carried_row in zip(first_chunk_rows, carried_rows):
|
|
if _row_text_signature(first_row) != _row_text_signature(carried_row):
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def _row_text_signature(row: Any) -> tuple[str, ...]:
|
|
"""Normalized cell text tuple for a row."""
|
|
return tuple(" ".join(cell.text_content().split()) for cell in row.iter("td", "th"))
|
|
|
|
|
|
def _strip_carried_over_header_text(chunk: TableChunk) -> str:
|
|
"""Strip synthetic carried-over header text from continuation chunk text."""
|
|
carried_row_count = _num_carried_over_header_rows(chunk)
|
|
if carried_row_count == 0:
|
|
return chunk.text
|
|
|
|
text_as_html = chunk.metadata.text_as_html
|
|
if not text_as_html:
|
|
return chunk.text
|
|
|
|
try:
|
|
parsed = fragment_fromstring(text_as_html)
|
|
except (ParserError, ValueError):
|
|
return chunk.text
|
|
|
|
rows = parsed.xpath("./tr | ./thead/tr | ./tbody/tr | ./tfoot/tr")
|
|
if carried_row_count > len(rows):
|
|
return chunk.text
|
|
|
|
carried_header_text = " ".join(
|
|
text
|
|
for row in rows[:carried_row_count]
|
|
for text in (" ".join(cell.text_content().split()) for cell in row.iter("td", "th"))
|
|
if text
|
|
)
|
|
if not carried_header_text:
|
|
return chunk.text
|
|
|
|
chunk_text = chunk.text.lstrip()
|
|
if chunk_text == carried_header_text:
|
|
return ""
|
|
if chunk_text.startswith(f"{carried_header_text} "):
|
|
return chunk_text[len(carried_header_text) + 1 :]
|
|
if chunk_text.startswith(carried_header_text):
|
|
return chunk_text[len(carried_header_text) :].lstrip()
|
|
return chunk.text
|