Files
unstructured-io--unstructured/unstructured/chunking/dispatch.py
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:33:56 +08:00

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