"""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("
") canonical_header_row_count, canonical_header_rows = _first_carried_header_rows(chunks) if canonical_header_rows: thead = fragment_fromstring("") 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