chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:33:56 +08:00
commit 461bf6fd40
1313 changed files with 1079898 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
from .partition.utils.config import env_config
from .telemetry import init_telemetry
# init env_config
env_config
# Explicit startup boundary for telemetry (opt-in, best-effort)
init_telemetry()
+6
View File
@@ -0,0 +1,6 @@
"""Allow ``python -m unstructured``."""
from unstructured.cli import main
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())
+1
View File
@@ -0,0 +1 @@
__version__ = "0.24.2" # pragma: no cover
+22
View File
@@ -0,0 +1,22 @@
"""Chunking module initializer.
Publishes the public aspects of the chunking sub-package interface.
"""
from __future__ import annotations
from unstructured.chunking.base import CHUNK_MAX_CHARS_DEFAULT, CHUNK_MULTI_PAGE_DEFAULT
from unstructured.chunking.dispatch import (
Chunker,
add_chunking_strategy,
register_chunking_strategy,
)
__all__ = [
"CHUNK_MAX_CHARS_DEFAULT",
"CHUNK_MULTI_PAGE_DEFAULT",
"add_chunking_strategy",
# -- these must be published to allow pluggable chunkers in other code-bases --
"Chunker",
"register_chunking_strategy",
]
File diff suppressed because it is too large Load Diff
+124
View File
@@ -0,0 +1,124 @@
"""Implementation of baseline chunking.
This is the "plain-vanilla" chunking strategy. All the fundamental chunking behaviors are present in
this strategy and also in all other strategies. Those are:
- Maximally fill each chunk with sequential elements.
- Isolate oversized elements and divide (only) those chunks by text-splitting.
- Overlap when requested.
"Fancier" strategies add higher-level semantic-unit boundaries to be respected. For example, in the
by-title strategy, section boundaries are respected, meaning a chunk never contains text from two
different sections. When a new section is detected the current chunk is closed and a new one
started.
"""
from __future__ import annotations
from typing import Iterable, Optional
from unstructured.chunking.base import ChunkingOptions, PreChunker
from unstructured.documents.elements import Element
def chunk_elements(
elements: Iterable[Element],
*,
include_orig_elements: Optional[bool] = None,
max_characters: Optional[int] = None,
max_tokens: Optional[int] = None,
new_after_n_chars: Optional[int] = None,
new_after_n_tokens: Optional[int] = None,
overlap: Optional[int] = None,
overlap_all: Optional[bool] = None,
tokenizer: Optional[str] = None,
repeat_table_headers: Optional[bool] = None,
skip_table_chunking: Optional[bool] = None,
isolate_table: Optional[bool] = None,
) -> list[Element]:
"""Combine sequential `elements` into chunks, respecting specified text-length limits.
Produces a sequence of `CompositeElement`, `Table`, and `TableChunk` elements (chunks).
Parameters
----------
elements
A list of unstructured elements. Usually the output of a partition function.
include_orig_elements
When `True` (default), add elements from pre-chunk to the `.metadata.orig_elements` field
of the chunk(s) formed from that pre-chunk. Among other things, this allows access to
original-element metadata that cannot be consolidated and is dropped in the course of
chunking.
max_characters
Hard maximum chunk length. No chunk will exceed this length. A single element that exceeds
this length will be divided into two or more chunks using text-splitting. Mutually
exclusive with `max_tokens`.
max_tokens
Hard maximum chunk token count. No chunk will exceed this token count. Requires `tokenizer`
to be specified. Mutually exclusive with `max_characters`.
new_after_n_chars
A chunk that of this length or greater is not extended to include the next element, even if
that element would fit without exceeding `max_characters`. A "soft max" length that can be
used in conjunction with `max_characters` to limit most chunks to a preferred length while
still allowing larger elements to be included in a single chunk without resorting to
text-splitting. Defaults to `max_characters` when not specified, which effectively disables
any soft window. Specifying 0 for this argument causes each element to appear in a chunk by
itself (although an element with text longer than `max_characters` will be still be split
into two or more chunks).
new_after_n_tokens
Token-based equivalent of `new_after_n_chars`. A chunk with this token count or greater is
not extended. Requires `max_tokens` and `tokenizer` to be specified.
overlap
Specifies the length of a string ("tail") to be drawn from each chunk and prefixed to the
next chunk as a context-preserving mechanism. By default, this only applies to split-chunks
where an oversized element is divided into multiple chunks by text-splitting.
overlap_all
Default: `False`. When `True`, apply overlap between "normal" chunks formed from whole
elements and not subject to text-splitting. Use this with caution as it produces a certain
level of "pollution" of otherwise clean semantic chunk boundaries.
tokenizer
The tokenizer to use for token-based chunking. Can be either an encoding name (e.g.,
"cl100k_base") or a model name (e.g., "gpt-4"). Required when using `max_tokens`.
repeat_table_headers
Default: `True`. When `True`, repeated table-header behavior is enabled for chunked table
continuations. Specify `False` to opt out and preserve legacy table-chunk behavior.
skip_table_chunking
Default: `False`. When `True`, `Table` elements are passed through unchanged without
being split into `TableChunk` elements, regardless of their size.
isolate_table
Default: `True`. When `True`, `Table` and `TableChunk` elements are always staged in
their own pre-chunk and never combined with adjacent non-table elements. Specify
`False` to allow tables to share pre-chunks with adjacent elements (the pre-#4307
behavior).
"""
# -- raises ValueError on invalid parameters --
opts = _BasicChunkingOptions.new(
include_orig_elements=include_orig_elements,
max_characters=max_characters,
max_tokens=max_tokens,
new_after_n_chars=new_after_n_chars,
new_after_n_tokens=new_after_n_tokens,
overlap=overlap,
overlap_all=overlap_all,
tokenizer=tokenizer,
repeat_table_headers=repeat_table_headers,
skip_table_chunking=skip_table_chunking,
isolate_table=isolate_table,
)
return _chunk_elements(elements, opts)
def _chunk_elements(elements: Iterable[Element], opts: _BasicChunkingOptions) -> list[Element]:
"""Implementation of actual basic chunking."""
# -- Note(scanny): it might seem like over-abstraction for this to be a separate function but
# -- it eases overriding or adding individual chunking options when customizing a stock chunker.
return [
chunk
for pre_chunk in PreChunker.iter_pre_chunks(elements, opts)
for chunk in pre_chunk.iter_chunks()
]
class _BasicChunkingOptions(ChunkingOptions):
"""Options for `basic` chunking."""
+325
View File
@@ -0,0 +1,325 @@
"""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
+199
View File
@@ -0,0 +1,199 @@
"""Implementation of chunking by title.
Main entry point is the `@add_chunking_strategy()` decorator.
"""
from __future__ import annotations
from functools import cached_property
from typing import Iterable, Iterator, Optional
from unstructured.chunking.base import (
CHUNK_MULTI_PAGE_DEFAULT,
BoundaryPredicate,
ChunkingOptions,
PreChunkCombiner,
PreChunker,
is_on_next_page,
is_title,
)
from unstructured.documents.elements import Element
def chunk_by_title(
elements: Iterable[Element],
*,
combine_text_under_n_chars: Optional[int] = None,
include_orig_elements: Optional[bool] = None,
max_characters: Optional[int] = None,
max_tokens: Optional[int] = None,
multipage_sections: Optional[bool] = None,
new_after_n_chars: Optional[int] = None,
new_after_n_tokens: Optional[int] = None,
overlap: Optional[int] = None,
overlap_all: Optional[bool] = None,
tokenizer: Optional[str] = None,
repeat_table_headers: Optional[bool] = None,
skip_table_chunking: Optional[bool] = None,
isolate_table: Optional[bool] = None,
) -> list[Element]:
"""Uses title elements to identify sections within the document for chunking.
Splits off into a new CompositeElement when a title is detected or if metadata changes, which
happens when page numbers or sections change. Cuts off sections once they have exceeded a
character length of max_characters (or token count of max_tokens).
Parameters
----------
elements
A list of unstructured elements. Usually the output of a partition function.
combine_text_under_n_chars
Combines elements (for example a series of titles) until a section reaches a length of
n characters. Defaults to `max_characters` which combines chunks whenever space allows.
Specifying 0 for this argument suppresses combining of small chunks. Note this value is
"capped" at the `new_after_n_chars` value since a value higher than that would not change
this parameter's effect.
include_orig_elements
When `True` (default), add elements from pre-chunk to the `.metadata.orig_elements` field
of the chunk(s) formed from that pre-chunk. Among other things, this allows access to
original-element metadata that cannot be consolidated and is dropped in the course of
chunking.
max_characters
Chunks elements text and text_as_html (if present) into chunks of length
n characters (hard max). Mutually exclusive with `max_tokens`.
max_tokens
Chunks elements into chunks of n tokens (hard max). Requires `tokenizer` to be specified.
Mutually exclusive with `max_characters`.
multipage_sections
If True, sections can span multiple pages. Defaults to True.
new_after_n_chars
Cuts off new sections once they reach a length of n characters (soft max). Defaults to
`max_characters` when not specified, which effectively disables any soft window.
Specifying 0 for this argument causes each element to appear in a chunk by itself (although
an element with text longer than `max_characters` will be still be split into two or more
chunks).
new_after_n_tokens
Token-based equivalent of `new_after_n_chars`. Cuts off new sections once they reach
n tokens (soft max). Requires `max_tokens` and `tokenizer` to be specified.
overlap
Specifies the length of a string ("tail") to be drawn from each chunk and prefixed to the
next chunk as a context-preserving mechanism. By default, this only applies to split-chunks
where an oversized element is divided into multiple chunks by text-splitting.
overlap_all
Default: `False`. When `True`, apply overlap between "normal" chunks formed from whole
elements and not subject to text-splitting. Use this with caution as it entails a certain
level of "pollution" of otherwise clean semantic chunk boundaries.
tokenizer
The tokenizer to use for token-based chunking. Can be either an encoding name (e.g.,
"cl100k_base") or a model name (e.g., "gpt-4"). Required when using `max_tokens`.
repeat_table_headers
Default: `True`. When `True`, repeated table-header behavior is enabled for chunked table
continuations. Specify `False` to opt out and preserve legacy table-chunk behavior.
skip_table_chunking
Default: `False`. When `True`, `Table` elements are passed through unchanged without
being split into `TableChunk` elements, regardless of their size.
isolate_table
Default: `True`. When `True`, `Table` and `TableChunk` elements are always staged in
their own pre-chunk and never combined with adjacent non-table elements. Specify
`False` to allow tables to share pre-chunks with adjacent elements (the pre-#4307
behavior).
"""
opts = _ByTitleChunkingOptions.new(
combine_text_under_n_chars=combine_text_under_n_chars,
include_orig_elements=include_orig_elements,
max_characters=max_characters,
max_tokens=max_tokens,
multipage_sections=multipage_sections,
new_after_n_chars=new_after_n_chars,
new_after_n_tokens=new_after_n_tokens,
overlap=overlap,
overlap_all=overlap_all,
tokenizer=tokenizer,
repeat_table_headers=repeat_table_headers,
skip_table_chunking=skip_table_chunking,
isolate_table=isolate_table,
)
return _chunk_by_title(elements, opts)
def _chunk_by_title(elements: Iterable[Element], opts: _ByTitleChunkingOptions) -> list[Element]:
"""Implementation of actual "by-title" chunking."""
# -- Note(scanny): it might seem like over-abstraction for this to be a separate function but
# -- it eases overriding or adding individual chunking options when customizing a stock chunker.
pre_chunks = PreChunkCombiner(
PreChunker.iter_pre_chunks(elements, opts), opts=opts
).iter_combined_pre_chunks()
return [chunk for pre_chunk in pre_chunks for chunk in pre_chunk.iter_chunks()]
class _ByTitleChunkingOptions(ChunkingOptions):
"""Adds the by-title-specific chunking options to the base case.
`by_title`-specific options:
combine_text_under_n_chars
A remedy to over-chunking caused by elements mis-identified as Title elements.
Every Title element would start a new chunk and this setting mitigates that, at the
expense of sometimes violating legitimate semantic boundaries.
multipage_sections
Indicates that page-boundaries should not be respected while chunking, i.e. elements
appearing on two different pages can appear in the same chunk.
"""
@cached_property
def boundary_predicates(self) -> tuple[BoundaryPredicate, ...]:
"""The semantic-boundary detectors to be applied to break pre-chunks.
For the `by_title` strategy these are sections indicated by a title (section-heading), an
explicit section metadata item (only present for certain document types), and optionally
page boundaries.
"""
def iter_boundary_predicates() -> Iterator[BoundaryPredicate]:
yield is_title
if not self.multipage_sections:
yield is_on_next_page()
return tuple(iter_boundary_predicates())
@cached_property
def combine_text_under_n_chars(self) -> int:
"""Combine consecutive text pre-chunks if former is smaller than this and both will fit.
- Does not combine text chunks if together they would exceed the chunking window.
- Defaults to `max_characters` when not specified.
- Is reduced to `new_after_n_chars` when it exceeds that value.
"""
# -- `combine_text_under_n_chars` defaults to `max_characters` when not specified --
arg_value = self._kwargs.get("combine_text_under_n_chars")
return self.hard_max if arg_value is None else arg_value
@cached_property
def multipage_sections(self) -> bool:
"""When False, break pre-chunks on page-boundaries."""
arg_value = self._kwargs.get("multipage_sections")
return CHUNK_MULTI_PAGE_DEFAULT if arg_value is None else bool(arg_value)
def _validate(self) -> None:
"""Raise ValueError if request option-set is invalid."""
# -- start with base-class validations --
super()._validate()
# -- `combine_text_under_n_chars == 0` is valid (suppresses chunk combination)
# -- but a negative value is not
if self.combine_text_under_n_chars < 0:
raise ValueError(
f"'combine_text_under_n_chars' argument must be >= 0,"
f" got {self.combine_text_under_n_chars}"
)
# -- `combine_text_under_n_chars` > `max_characters` can produce behavior confusing to
# -- users. The chunking behavior would be no different than when
# -- `combine_text_under_n_chars == max_characters`, but if `max_characters` is left to
# -- default (500) then it can look like chunk-combining isn't working.
if self.combine_text_under_n_chars > self.hard_max:
raise ValueError(
f"'combine_text_under_n_chars' argument must not exceed `max_characters`"
f" value, got {self.combine_text_under_n_chars} > {self.hard_max}"
)
View File
+490
View File
@@ -0,0 +1,490 @@
from __future__ import annotations
import quopri
import re
import sys
import unicodedata
from typing import Optional, Tuple
import numpy as np
from unstructured.file_utils.encoding import (
format_encoding_str,
)
from unstructured.nlp.patterns import (
DOUBLE_PARAGRAPH_PATTERN_RE,
E_BULLET_PATTERN,
LINE_BREAK_RE,
PARAGRAPH_PATTERN,
PARAGRAPH_PATTERN_RE,
UNICODE_BULLETS_RE,
UNICODE_BULLETS_RE_0W,
)
def clean_non_ascii_chars(text) -> str:
"""Cleans non-ascii characters from unicode string.
Example
-------
\x88This text contains non-ascii characters!\x88
-> This text contains non-ascii characters!
"""
en = text.encode("ascii", "ignore")
return en.decode()
def clean_bullets(text: str) -> str:
"""Cleans unicode bullets from a section of text.
Example
-------
● This is an excellent point! -> This is an excellent point!
"""
search = UNICODE_BULLETS_RE.match(text)
if search is None:
return text
cleaned_text = UNICODE_BULLETS_RE.sub("", text, 1)
return cleaned_text.strip()
def clean_ordered_bullets(text) -> str:
"""Cleans the start of bulleted text sections up to three “sub-section”
bullets accounting numeric and alphanumeric types.
Example
-------
1.1 This is a very important point -> This is a very important point
a.b This is a very important point -> This is a very important point
"""
text_sp = text.split()
text_cl = " ".join(text_sp[1:])
if any(["." not in text_sp[0], ".." in text_sp[0]]):
return text
bullet = re.split(pattern=r"[\.]", string=text_sp[0])
if not bullet[-1]:
del bullet[-1]
if len(bullet[0]) > 2:
return text
return text_cl
def clean_ligatures(text) -> str:
"""Replaces ligatures with their most likely equivalent characters.
Example
-------
The benefits -> The benefits
High quality financial -> High quality financial
"""
ligatures_map = {
"æ": "ae",
"Æ": "AE",
"": "ff",
"": "fi",
"": "fl",
"": "ffi",
"": "ffl",
"": "ft",
"ʪ": "ls",
"œ": "oe",
"Œ": "OE",
"ȹ": "qp",
"": "st",
"ʦ": "ts",
}
cleaned_text: str = text
for k, v in ligatures_map.items():
cleaned_text = cleaned_text.replace(k, v)
return cleaned_text
def group_bullet_paragraph(paragraph: str) -> list:
"""Groups paragraphs with bullets that have line breaks for visual/formatting purposes.
For example:
'''○ The big red fox
is walking down the lane.
○ At the end of the lane
the fox met a friendly bear.'''
Gets converted to
'''○ The big red fox is walking down the lane.
○ At the end of the land the fox met a bear.'''
"""
paragraph_pattern_re = re.compile(PARAGRAPH_PATTERN)
# pytesseract converts some bullet points to standalone "e" characters.
# Substitute "e" with bullets since they are later used in partition_text
# to determine list element type.
paragraph = E_BULLET_PATTERN.sub("·", paragraph).strip()
bullet_paras = UNICODE_BULLETS_RE_0W.split(paragraph)
clean_paragraphs = []
for bullet in bullet_paras:
if bullet:
clean_paragraphs.append(paragraph_pattern_re.sub(" ", bullet))
return clean_paragraphs
def group_broken_paragraphs(
text: str,
line_split: re.Pattern[str] = PARAGRAPH_PATTERN_RE,
paragraph_split: re.Pattern[str] = DOUBLE_PARAGRAPH_PATTERN_RE,
) -> str:
"""Groups paragraphs that have line breaks for visual/formatting purposes.
For example:
'''The big red fox
is walking down the lane.
At the end of the lane
the fox met a bear.'''
Gets converted to
'''The big red fox is walking down the lane.
At the end of the land the fox met a bear.'''
"""
paragraph_pattern_re = (
PARAGRAPH_PATTERN
if isinstance(PARAGRAPH_PATTERN, re.Pattern)
else re.compile(PARAGRAPH_PATTERN)
)
paragraphs = paragraph_split.split(text)
clean_paragraphs = []
for paragraph in paragraphs:
stripped_par = paragraph.strip()
if not stripped_par:
continue
if UNICODE_BULLETS_RE.match(stripped_par) or E_BULLET_PATTERN.match(stripped_par):
clean_paragraphs.extend(group_bullet_paragraph(paragraph))
continue
# NOTE(robinson) - This block is to account for lines like the following that shouldn't be
# grouped together, but aren't separated by a double line break.
# Apache License
# Version 2.0, January 2004
# http://www.apache.org/licenses/
para_split = line_split.split(paragraph)
all_lines_short = all(len(line.strip().split(" ")) < 5 for line in para_split)
if all_lines_short:
clean_paragraphs.extend(line for line in para_split if line.strip())
else:
clean_paragraphs.append(paragraph_pattern_re.sub(" ", paragraph))
return "\n\n".join(clean_paragraphs)
def new_line_grouper(
text: str,
paragraph_split: re.Pattern[str] = LINE_BREAK_RE,
) -> str:
"""
Concatenates text document that has one-line paragraph break pattern
For example,
Iwan Roberts
Roberts celebrating after scoring a goal for Norwich City
in 2004
Will be returned as:
Iwan Roberts\n\nRoberts celebrating after scoring a goal for Norwich City\n\nin 2004
"""
paragraphs = paragraph_split.split(text)
clean_paragraphs = []
for paragraph in paragraphs:
if not paragraph.strip():
continue
clean_paragraphs.append(paragraph)
return "\n\n".join(clean_paragraphs)
def blank_line_grouper(
text: str,
paragraph_split: re.Pattern = DOUBLE_PARAGRAPH_PATTERN_RE,
) -> str:
"""
Concatenates text document that has blank-line paragraph break pattern
For example,
Vestibulum auctor dapibus neque.
Nunc dignissim risus id metus.
Will be returned as:
Vestibulum auctor dapibus neque.\n\nNunc dignissim risus id metus.\n\n
"""
return group_broken_paragraphs(text)
def auto_paragraph_grouper(
text: str,
line_split: re.Pattern[str] = LINE_BREAK_RE,
max_line_count: int = 2000,
threshold: float = 0.1,
) -> str:
"""
Checks the ratio of new line (\n) over the total max_line_count
If the ratio of new line is less than the threshold,
the document is considered a new-line grouping type
and return the original text
If the ratio of new line is greater than or equal to the threshold,
the document is considered a blank-line grouping type
and passed on to blank_line_grouper function
"""
lines = line_split.split(text)
max_line_count = min(len(lines), max_line_count)
line_count, empty_line_count = 0, 0
for line in lines[:max_line_count]:
line_count += 1
if not line.strip():
empty_line_count += 1
ratio = empty_line_count / line_count
# NOTE(klaijan) - for ratio < threshold, we pass to new-line grouper,
# otherwise to blank-line grouper
if ratio < threshold:
return new_line_grouper(text)
else:
return blank_line_grouper(text)
# TODO(robinson) - There's likely a cleaner was to accomplish this and get all of the
# unicode characters instead of just the quotes. Doing this for now since quotes are
# an issue that are popping up in the SEC filings tests
def replace_unicode_quotes(text: str) -> str:
"""Replaces unicode bullets in text with the expected character
Example
-------
\x93What a lovely quote!\x94 -> “What a lovely quote!”
"""
# NOTE(robinson) - We should probably make this something more sane like a regex
# instead of a whole big series of replaces
text = text.replace("\x91", "")
text = text.replace("\x92", "")
text = text.replace("\x93", "")
text = text.replace("\x94", "")
text = text.replace("&apos;", "'")
text = text.replace("â\x80\x99", "'")
text = text.replace("â\x80", "")
text = text.replace("â\x80", "")
text = text.replace("â\x80˜", "")
text = text.replace("â\x80¦", "")
text = text.replace("â\x80", "")
text = text.replace("â\x80œ", "")
text = text.replace("â\x80?", "")
text = text.replace("â\x80ť", "")
text = text.replace("â\x80ś", "")
text = text.replace("â\x80¨", "")
text = text.replace("â\x80ł", "")
text = text.replace("â\x80Ž", "")
text = text.replace("â\x80", "")
text = text.replace("â\x80", "")
text = text.replace("â\x80", "")
text = text.replace("â\x80", "")
text = text.replace("â\x80s'", "")
return text
tbl = dict.fromkeys(
i for i in range(sys.maxunicode) if unicodedata.category(chr(i)).startswith("P")
)
def remove_punctuation(s: str) -> str:
"""Removes punctuation from a given string."""
return s.translate(tbl)
def remove_sentence_punctuation(s: str, exclude_punctuation: Optional[list]) -> str:
tbl_new = tbl.copy()
if exclude_punctuation:
for punct in exclude_punctuation:
del tbl_new[ord(punct)]
s = s.translate(tbl_new)
return s
def clean_extra_whitespace(text: str) -> str:
"""Cleans extra whitespace characters that appear between words.
Example
-------
ITEM 1. BUSINESS -> ITEM 1. BUSINESS
"""
cleaned_text = re.sub(r"[\xa0\n]", " ", text)
cleaned_text = re.sub(r"([ ]{2,})", " ", cleaned_text)
return cleaned_text.strip()
def clean_dashes(text: str) -> str:
"""Cleans dash characters in text.
Example
-------
ITEM 1. -BUSINESS -> ITEM 1. BUSINESS
"""
# NOTE(Yuming): '\u2013' is the unicode string of 'EN DASH', a variation of "-"
return re.sub(r"[-\u2013]", " ", text).strip()
def clean_trailing_punctuation(text: str) -> str:
"""Clean all trailing punctuation in text
Example
-------
ITEM 1. BUSINESS. -> ITEM 1. BUSINESS
"""
return text.strip().rstrip(".,:;")
def replace_mime_encodings(text: str, encoding: str = "utf-8") -> str:
"""Replaces MIME encodings with their equivalent characters in the specified encoding.
Example
-------
5 w=E2=80-99s -> 5 ws
"""
formatted_encoding = format_encoding_str(encoding)
return quopri.decodestring(text.encode(formatted_encoding)).decode(formatted_encoding)
def clean_prefix(text: str, pattern: str, ignore_case: bool = False, strip: bool = True) -> str:
"""Removes prefixes from a string according to the specified pattern. Strips leading
whitespace if the strip parameter is set to True.
Input
-----
text: The text to clean
pattern: The pattern for the prefix. Can be a simple string or a regex pattern
ignore_case: If True, ignores case in the pattern
strip: If True, removes leading whitespace from the cleaned string.
"""
flags = re.IGNORECASE if ignore_case else 0
clean_text = re.sub(rf"^{pattern}", "", text, flags=flags)
clean_text = clean_text.lstrip() if strip else clean_text
return clean_text
def clean_postfix(text: str, pattern: str, ignore_case: bool = False, strip: bool = True) -> str:
"""Removes postfixes from a string according to the specified pattern. Strips trailing
whitespace if the strip parameters is set to True.
Input
-----
text: The text to clean
pattern: The pattern for the postfix. Can be a simple string or a regex pattern
ignore_case: If True, ignores case in the pattern
strip: If True, removes trailing whitespace from the cleaned string.
"""
flags = re.IGNORECASE if ignore_case else 0
clean_text = re.sub(rf"{pattern}$", "", text, flags=flags)
clean_text = clean_text.rstrip() if strip else clean_text
return clean_text
def clean(
text: str,
extra_whitespace: bool = False,
dashes: bool = False,
bullets: bool = False,
trailing_punctuation: bool = False,
lowercase: bool = False,
) -> str:
"""Cleans text.
Input
-----
extra_whitespace: Whether to clean extra whitespace characters in text.
dashes: Whether to clean dash characters in text.
bullets: Whether to clean unicode bullets from a section of text.
trailing_punctuation: Whether to clean all trailing punctuation in text.
lowercase: Whether to return lowercase text.
"""
cleaned_text = text.lower() if lowercase else text
cleaned_text = (
clean_trailing_punctuation(cleaned_text) if trailing_punctuation else cleaned_text
)
cleaned_text = clean_dashes(cleaned_text) if dashes else cleaned_text
cleaned_text = clean_extra_whitespace(cleaned_text) if extra_whitespace else cleaned_text
cleaned_text = clean_bullets(cleaned_text) if bullets else cleaned_text
return cleaned_text.strip()
def bytes_string_to_string(text: str, encoding: str = "utf-8"):
"""Converts a string representation of a byte string to a regular string using the
specified encoding."""
text_bytes = bytes([ord(char) for char in text])
formatted_encoding = format_encoding_str(encoding)
return text_bytes.decode(formatted_encoding)
def clean_extra_whitespace_with_index_run(text: str) -> Tuple[str, np.ndarray]:
"""Cleans extra whitespace characters that appear between words.
Calculate distance between characters of original text and cleaned text.
Returns cleaned text along with array of indices it has moved from original.
Example
-------
ITEM 1. BUSINESS -> ITEM 1. BUSINESS
array([0., 0., 0., 0., 0., 0., 0., 0., 4., 4., 4., 4., 4., 4., 4., 4., 4., 4., 4., 4.]))
"""
# Replace non-breaking space and newlines with a space (using translation table for speed)
translate_table = {ord("\xa0"): ord(" "), ord("\n"): ord(" ")}
cleaned_text = text.translate(translate_table)
# Collapse multiple spaces into one (keeps only single runs)
cleaned_text = re.sub(r"([ ]{2,})", " ", cleaned_text)
cleaned_text = cleaned_text.strip()
moved_indices = np.zeros(len(text))
cleaned_len = len(cleaned_text)
ws_chars = {"\xa0", "\n"} # For a quick lookup
distance = 0
original_index = 0
cleaned_index = 0
while cleaned_index < cleaned_len:
c_orig = text[original_index]
c_clean = cleaned_text[cleaned_index]
if c_orig == c_clean or (c_orig in ws_chars and c_clean == " "):
moved_indices[cleaned_index] = distance
original_index += 1
cleaned_index += 1
continue
distance += 1
moved_indices[cleaned_index] = distance
original_index += 1
moved_indices[cleaned_index:] = distance
return cleaned_text, moved_indices
def index_adjustment_after_clean_extra_whitespace(index, moved_indices) -> int:
return int(index - moved_indices[index])
+143
View File
@@ -0,0 +1,143 @@
import datetime
import re
from typing import List, Optional
from unstructured.nlp.patterns import (
EMAIL_ADDRESS_PATTERN,
EMAIL_DATETIMETZ_PATTERN,
IMAGE_URL_PATTERN,
IP_ADDRESS_NAME_PATTERN,
IP_ADDRESS_PATTERN_RE,
MAPI_ID_PATTERN,
US_PHONE_NUMBERS_RE,
)
def _get_indexed_match(text: str, pattern: str, index: int = 0) -> re.Match:
if not isinstance(index, int) or index < 0:
raise ValueError(f"The index is {index}. Index must be a non-negative integer.")
regex_match = None
for i, result in enumerate(re.finditer(pattern, text)):
if i == index:
regex_match = result
if regex_match is None:
raise ValueError(f"Result with index {index} was not found. The largest index was {i}.")
return regex_match
def extract_text_before(text: str, pattern: str, index: int = 0, strip: bool = True) -> str:
"""Extracts texts that occurs before the specified pattern. By default, it will use
the first occurrence of the pattern (index 0). Use the index kwarg to choose a different
index.
Input
-----
strip: If True, removes trailing whitespace from the extracted string
"""
regex_match = _get_indexed_match(text, pattern, index)
start, _ = regex_match.span()
before_text = text[:start]
return before_text.rstrip() if strip else before_text
def extract_text_after(text: str, pattern: str, index: int = 0, strip: bool = True) -> str:
"""Extracts texts that occurs before the specified pattern. By default, it will use
the first occurrence of the pattern (index 0). Use the index kwarg to choose a different
index.
Input
-----
strip: If True, removes leading whitespace from the extracted string
"""
regex_match = _get_indexed_match(text, pattern, index)
_, end = regex_match.span()
before_text = text[end:]
return before_text.lstrip() if strip else before_text
def extract_email_address(text: str) -> List[str]:
return re.findall(EMAIL_ADDRESS_PATTERN, text.lower())
def extract_ip_address(text: str) -> List[str]:
return re.findall(IP_ADDRESS_PATTERN_RE, text)
def extract_ip_address_name(text: str) -> List[str]:
return re.findall(IP_ADDRESS_NAME_PATTERN, text)
def extract_mapi_id(text: str) -> List[str]:
mapi_ids = re.findall(MAPI_ID_PATTERN, text)
mapi_ids = [mid.replace(";", "") for mid in mapi_ids]
return mapi_ids
def extract_datetimetz(text: str) -> Optional[datetime.datetime]:
date_extractions = re.findall(EMAIL_DATETIMETZ_PATTERN, text)
if len(date_extractions) > 0:
return datetime.datetime.strptime(date_extractions[0], "%a, %d %b %Y %H:%M:%S %z")
else:
return None
def extract_us_phone_number(text: str):
"""Extracts a US phone number from a section of text that includes a phone number. If there
is no phone number present, the result will be an empty string.
Example
-------
extract_phone_number("Phone Number: 215-867-5309") -> "215-867-5309"
"""
regex_match = US_PHONE_NUMBERS_RE.search(text)
if regex_match is None:
return ""
start, end = regex_match.span()
phone_number = text[start:end]
return phone_number.strip()
def extract_ordered_bullets(text) -> tuple:
"""Extracts the start of bulleted text sections bullets
accounting numeric and alphanumeric types.
Output
-----
tuple(section, sub_section, sub_sub_section): Each bullet partition
is a string or None if not present.
Example
-------
This is a very important point -> (None, None, None)
1.1 This is a very important point -> ("1", "1", None)
a.1 This is a very important point -> ("a", "1", None)
"""
a, b, c, temp = None, None, None, None
text_sp = text.split()
if any(["." not in text_sp[0], ".." in text_sp[0]]):
return a, b, c
bullet = re.split(pattern=r"[\.]", string=text_sp[0])
if not bullet[-1]:
del bullet[-1]
if len(bullet[0]) > 2:
return a, b, c
a, *temp = bullet
if temp:
try:
b, c, *_ = temp
except ValueError:
b = temp
b = "".join(b)
c = "".join(c) if c else None
return a, b, c
def extract_image_urls_from_html(text: str) -> List[str]:
return re.findall(IMAGE_URL_PATTERN, text)
+87
View File
@@ -0,0 +1,87 @@
import warnings
from typing import List, Optional
import langdetect
from transformers import MarianMTModel, MarianTokenizer
from unstructured.nlp.tokenize import sent_tokenize
from unstructured.staging.huggingface import chunk_by_attention_window
def _get_opus_mt_model_name(source_lang: str, target_lang: str):
"""Constructs the name of the MarianMT machine translation model based on the
source and target language."""
return f"Helsinki-NLP/opus-mt-{source_lang}-{target_lang}"
def _validate_language_code(language_code: str):
if not isinstance(language_code, str) or len(language_code) != 2:
raise ValueError(
f"Invalid language code: {language_code}. Language codes must be two letter strings.",
)
def translate_text(text: str, source_lang: Optional[str] = None, target_lang: str = "en") -> str:
"""Translates the foreign language text. If the source language is not specified, the
function will attempt to detect it using langdetect.
Parameters
----------
text: str
The text to translate
target_lang: str
The two letter language code for the target langague. Defaults to "en".
source_lang: Optional[str]
The two letter language code for the language of the input text. If source_lang is
not provided, the function will try to detect it.
"""
if text.strip() == "":
return text
_source_lang: str = source_lang if source_lang is not None else langdetect.detect(text)
# NOTE(robinson) - Chinese gets detected with codes zh-cn, zh-tw, zh-hk for various
# Chinese variants. We normalizes these because there is a single model for Chinese
# machine translation
if _source_lang.startswith("zh"):
_source_lang = "zh"
_validate_language_code(target_lang)
_validate_language_code(_source_lang)
if target_lang == _source_lang:
return text
model_name = _get_opus_mt_model_name(_source_lang, target_lang)
print(f"Using model: {model_name}")
try:
tokenizer = MarianTokenizer.from_pretrained(model_name)
model = MarianMTModel.from_pretrained(model_name)
except OSError:
raise ValueError(
f"Transformers could not find the translation model {model_name}. "
"The requested source/target language combo is not supported.",
)
chunks: List[str] = chunk_by_attention_window(text, tokenizer, split_function=sent_tokenize)
translated_chunks: List[str] = []
for chunk in chunks:
translated_chunks.append(_translate_text(text, model, tokenizer))
return " ".join(translated_chunks)
def _translate_text(text, model, tokenizer):
"""Translates text using the specified model and tokenizer."""
# NOTE(robinson) - Suppresses the HuggingFace UserWarning resulting from the "max_length"
# key in the MarianMT config. The warning states that "max_length" will be deprecated
# in transformers v5
with warnings.catch_warnings():
warnings.simplefilter("ignore")
translated = model.generate(
**tokenizer([text], return_tensors="pt", padding=True, truncation=True),
)
return [tokenizer.decode(t, max_new_tokens=512, skip_special_tokens=True) for t in translated][
0
]
+87
View File
@@ -0,0 +1,87 @@
"""Command-line interface for :mod:`unstructured`."""
from __future__ import annotations
import argparse
import sys
def _cmd_doctor(argv: list[str] | None = None) -> int:
from unstructured.doctor import build_report, evaluate_specifier, file_path_to_capability
parser = argparse.ArgumentParser(
prog="unstructured doctor",
description="Print dependency and capability diagnostics for partitioning.",
)
parser.add_argument(
"--for",
dest="for_cap",
metavar="TYPE",
help="Check readiness for a file type or family (e.g. pdf, docx, image, audio).",
)
parser.add_argument(
"--file",
dest="file_path",
metavar="PATH",
help="Infer file type from PATH and check readiness for that type.",
)
ns = parser.parse_args(argv)
if ns.for_cap and ns.file_path:
print("Use either --for or --file, not both.", file=sys.stderr)
return 2
if ns.for_cap:
try:
result = evaluate_specifier(ns.for_cap)
except ValueError as e:
print(str(e), file=sys.stderr)
return 2
if result.messages:
print("\n".join(result.messages))
if not result.ready:
return 1
return 0
if ns.file_path:
result = file_path_to_capability(ns.file_path)
if result.messages:
print("\n".join(result.messages))
if not result.ready:
return 1
return 0
print(build_report(), end="")
return 0
def main(argv: list[str] | None = None) -> int:
"""Entry point for the ``unstructured`` console script."""
argv = list(sys.argv[1:] if argv is None else argv)
if not argv or argv[0] in ("-h", "--help"):
parser = argparse.ArgumentParser(
prog="unstructured",
description="Unstructured document processing utilities.",
)
parser.add_argument(
"command",
nargs="?",
choices=["doctor"],
help="Subcommand to run.",
)
parser.print_help()
return 0
if argv[0] == "doctor":
return _cmd_doctor(argv[1:])
print(
f"unstructured: unknown command {argv[0]!r}. Try `unstructured --help`.",
file=sys.stderr,
)
return 2
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())
View File
+207
View File
@@ -0,0 +1,207 @@
"""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())
+296
View File
@@ -0,0 +1,296 @@
"""Environment and capability diagnostics for optional dependencies and system tools."""
from __future__ import annotations
import importlib.util
import platform
import shutil
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
from unstructured.__version__ import __version__
from unstructured.file_utils.model import FileType
from unstructured.utils import dependency_exists
Status = Literal["ok", "missing", "warn"]
# File types that rely on LibreOffice for conversion (see partition/common/common.py).
_FILE_TYPES_NEEDING_SOFFICE: frozenset[FileType] = frozenset({FileType.DOC, FileType.PPT})
@dataclass(frozen=True)
class CapabilityResult:
"""Result of checking whether partitioning is viable for a target file type."""
ready: bool
"""True when Python deps and required external tools for this type are satisfied."""
messages: tuple[str, ...]
"""Human-readable issues (blocking or informational)."""
def _pip_hint_for_file_type(file_type: FileType) -> str:
extra = file_type.extra_name
if extra:
return f'pip install "unstructured[{extra}]"'
return "pip install unstructured"
def _needs_pandoc_runtime(file_type: FileType) -> bool:
return "pypandoc" in file_type.importable_package_dependencies
def _audio_extra_import_ok() -> bool:
return importlib.util.find_spec("whisper") is not None
def _libmagic_status() -> tuple[Status, str]:
try:
import magic
_ = magic.from_buffer(b"%PDF-1.4\n", mime=True)
except ImportError:
return "warn", "python `magic` module not available (filetype fallback still works)"
except Exception as exc: # noqa: BLE001 — surface libmagic load/binary issues
return "warn", f"libmagic not usable: {exc!s}"[:200]
else:
return "ok", "libmagic (python-magic) OK"
def _tool_status(name: str, on_path: bool) -> tuple[Status, str]:
if on_path:
return "ok", f"{name} found on PATH"
return "missing", f"{name} not found on PATH"
def python_import_deps_ok(file_type: FileType) -> tuple[bool, list[str]]:
"""Return whether declared importable dependencies for `file_type` are importable."""
missing: list[str] = []
for mod in file_type.importable_package_dependencies:
if not dependency_exists(mod):
missing.append(mod)
return len(missing) == 0, missing
def evaluate_file_type_capability(file_type: FileType) -> CapabilityResult:
"""Check partitioning readiness for a single :class:`FileType`."""
if not file_type.is_partitionable:
return CapabilityResult(
ready=False,
messages=(f"{file_type.name} is not partitionable.",),
)
ok, missing = python_import_deps_ok(file_type)
messages: list[str] = []
if not ok:
messages.append(
f"Missing Python module(s): {', '.join(missing)}. "
f"Install with: {_pip_hint_for_file_type(file_type)}",
)
if _needs_pandoc_runtime(file_type) and ok and shutil.which("pandoc") is None:
ok = False
messages.append(
"Pandoc executable not found on PATH (required by pypandoc). "
"Install pandoc from https://pandoc.org/installing.html",
)
if file_type in _FILE_TYPES_NEEDING_SOFFICE and ok and _libreoffice_on_path() is None:
ok = False
messages.append(
"soffice (LibreOffice CLI) not found on PATH. "
"Required to convert legacy .doc / .ppt to Open XML formats.",
)
# Audio partitioner uses the `audio` extra (Whisper); FileType members only list empty deps.
if file_type.extra_name == "audio":
if not _audio_extra_import_ok():
ok = False
messages.append(
"Missing audio extra (e.g. Whisper). "
'Install with: pip install "unstructured[audio]"',
)
if shutil.which("ffmpeg") is None:
ok = False
messages.append(
"ffmpeg not on PATH - required for Whisper audio decoding "
"(https://ffmpeg.org/download.html).",
)
return CapabilityResult(ready=ok, messages=tuple(messages))
def _libreoffice_on_path() -> str | None:
"""Return ``soffice`` on PATH; matches :func:`convert_office_doc` in ``partition.common``."""
return shutil.which("soffice")
def resolve_specifier(spec: str) -> list[FileType]:
"""Map a user string (e.g. ``pdf``, ``png``, ``image``) to :class:`FileType` members."""
raw = spec.strip()
if not raw:
raise ValueError("Empty specifier")
lower = raw.lower()
if lower == "image":
return [ft for ft in FileType if ft.is_partitionable and ft.extra_name == "image"]
if lower == "audio":
return [ft for ft in FileType if ft.is_partitionable and ft.extra_name == "audio"]
matches: list[FileType] = []
for ft in FileType:
if not ft.is_partitionable:
continue
if ft.name.lower() == lower or ft.value == lower:
matches.append(ft)
continue
if ft.partitioner_shortname and ft.partitioner_shortname.lower() == lower:
matches.append(ft)
if not matches:
valid = sorted(
{ft.value for ft in FileType if ft.is_partitionable}
| {ft.name.lower() for ft in FileType if ft.is_partitionable}
| {"image", "audio"},
)
sample = ", ".join(valid[:20])
raise ValueError(f"Unknown file type or alias {spec!r}. Examples: {sample}...")
# Prefer exact value/name matches over partitioner_shortname duplicates.
exact = [ft for ft in matches if ft.value == lower or ft.name.lower() == lower]
return exact or matches
def evaluate_specifier(spec: str) -> CapabilityResult:
"""Combine evaluation for all file types matched by :func:`resolve_specifier`."""
targets = resolve_specifier(spec)
if (
len(targets) > 1
and targets[0].extra_name in ("image", "audio")
and all(t.extra_name == targets[0].extra_name for t in targets)
):
targets = [targets[0]]
combined_ready = True
all_messages: list[str] = []
for ft in targets:
result = evaluate_file_type_capability(ft)
if not result.ready:
combined_ready = False
for m in result.messages:
all_messages.append(f"[{ft.name}] {m}")
# De-duplicate messages while preserving order
seen: set[str] = set()
deduped: list[str] = []
for m in all_messages:
if m not in seen:
seen.add(m)
deduped.append(m)
return CapabilityResult(ready=combined_ready, messages=tuple(deduped))
def environment_rows() -> list[tuple[str, str, str]]:
"""Rows for the environment section: (name, status, detail)."""
return [
("Python", "ok", platform.python_version()),
("Platform", "ok", platform.platform()),
("unstructured", "ok", __version__),
]
def system_tool_rows() -> list[tuple[str, Status, str]]:
"""Rows for optional system tools."""
rows: list[tuple[str, Status, str]] = []
st, detail = _libmagic_status()
rows.append(("libmagic (MIME detection)", st, detail))
for label, cmd in (
("tesseract (OCR)", "tesseract"),
("pandoc", "pandoc"),
("ffmpeg (audio/codecs)", "ffmpeg"),
):
status, msg = _tool_status(label, shutil.which(cmd) is not None)
rows.append((label, status, msg))
lo = _libreoffice_on_path()
rows.append(
(
"soffice (LibreOffice CLI)",
"ok" if lo else "missing",
f"found: {lo}" if lo else "not found on PATH",
),
)
return rows
def partitionable_file_type_rows() -> list[tuple[str, str, str, str]]:
"""One row per partitionable file type: type, deps OK, extra, notes."""
out: list[tuple[str, str, str, str]] = []
for ft in sorted(
(m for m in FileType if m.is_partitionable),
key=lambda x: x.name,
):
py_ok, _missing = python_import_deps_ok(ft)
deps_cell = "yes" if py_ok else "no"
extra = ft.extra_name or "-"
cap = evaluate_file_type_capability(ft)
notes = " | ".join(cap.messages) if cap.messages else "-"
out.append((ft.name, deps_cell, extra, notes))
return out
def format_table(headers: tuple[str, ...], rows: list[tuple[str, ...]]) -> str:
"""Render a simple fixed-width table (no third-party deps)."""
if not rows:
return " | ".join(headers) + "\n(no rows)\n"
col_widths = [len(h) for h in headers]
str_rows: list[list[str]] = []
for row in rows:
cells = [str(c) for c in row]
str_rows.append(cells)
for i, cell in enumerate(cells):
col_widths[i] = max(col_widths[i], len(cell))
sep = "-+-".join("-" * w for w in col_widths)
lines = [
" | ".join(headers[i].ljust(col_widths[i]) for i in range(len(headers))),
sep,
]
for cells in str_rows:
lines.append(" | ".join(cells[i].ljust(col_widths[i]) for i in range(len(headers))))
return "\n".join(lines) + "\n"
def build_report() -> str:
"""Full diagnostic report for :command:`unstructured doctor` with no filter."""
parts: list[str] = []
parts.append("Environment\n")
parts.append(format_table(("Component", "Status", "Detail"), environment_rows()))
parts.append("System tools (optional but commonly needed)\n")
sys_rows = system_tool_rows()
parts.append(format_table(("Tool", "Status", "Detail"), sys_rows))
parts.append("Partitionable file types (Python extras)\n")
p_rows = partitionable_file_type_rows()
parts.append(
format_table(
("File type", "Python deps OK", "pip extra", "Notes"),
[tuple(r) for r in p_rows],
),
)
return "".join(parts)
def file_path_to_capability(path: str | Path) -> CapabilityResult:
"""Infer file type from ``path`` and evaluate capability."""
p = Path(path)
if not p.is_file():
return CapabilityResult(ready=False, messages=(f"Not a file or not found: {p}",))
from unstructured.file_utils.filetype import detect_filetype
ft = detect_filetype(file_path=str(p.resolve()))
if not ft.is_partitionable:
return CapabilityResult(
ready=False,
messages=(f"Detected type {ft.name} is not partitionable.",),
)
return evaluate_file_type_capability(ft)
View File
+113
View File
@@ -0,0 +1,113 @@
from __future__ import annotations
from enum import Enum
from typing import Any, Dict, Sequence, Tuple, Union
class Orientation(Enum):
SCREEN = (1, -1) # Origin in top left, y increases in the down direction
CARTESIAN = (1, 1) # Origin in bottom left, y increases in upward direction
def convert_coordinate(old_t, old_t_max, new_t_max, t_orientation):
"""Convert a coordinate into another system along an axis using a linear transformation"""
return (
(1 - old_t / old_t_max) * (1 - t_orientation) / 2
+ old_t / old_t_max * (1 + t_orientation) / 2
) * new_t_max
class CoordinateSystem:
"""A finite coordinate plane with given width and height."""
orientation: Orientation
def __init__(self, width: Union[int, float], height: Union[int, float]):
self.width = width
self.height = height
def __eq__(self, other: object):
if not isinstance(other, CoordinateSystem):
return False
return (
str(self.__class__.__name__) == str(other.__class__.__name__)
and self.width == other.width
and self.height == other.height
and self.orientation == other.orientation
)
def convert_from_relative(
self,
x: Union[float, int],
y: Union[float, int],
) -> Tuple[Union[float, int], Union[float, int]]:
"""Convert to this coordinate system from a relative coordinate system."""
x_orientation, y_orientation = self.orientation.value
new_x = convert_coordinate(x, 1, self.width, x_orientation)
new_y = convert_coordinate(y, 1, self.height, y_orientation)
return new_x, new_y
def convert_to_relative(
self,
x: Union[float, int],
y: Union[float, int],
) -> Tuple[Union[float, int], Union[float, int]]:
"""Convert from this coordinate system to a relative coordinate system."""
x_orientation, y_orientation = self.orientation.value
new_x = convert_coordinate(x, self.width, 1, x_orientation)
new_y = convert_coordinate(y, self.height, 1, y_orientation)
return new_x, new_y
def convert_coordinates_to_new_system(
self,
new_system: CoordinateSystem,
x: Union[float, int],
y: Union[float, int],
) -> Tuple[Union[float, int], Union[float, int]]:
"""Convert from this coordinate system to another given coordinate system."""
rel_x, rel_y = self.convert_to_relative(x, y)
return new_system.convert_from_relative(rel_x, rel_y)
def convert_multiple_coordinates_to_new_system(
self,
new_system: CoordinateSystem,
coordinates: Sequence[Tuple[Union[float, int], Union[float, int]]],
) -> Tuple[Tuple[Union[float, int], Union[float, int]], ...]:
"""Convert (x, y) coordinates from current system to another coordinate system."""
new_system_coordinates = []
for x, y in coordinates:
new_system_coordinates.append(
self.convert_coordinates_to_new_system(new_system=new_system, x=x, y=y),
)
return tuple(new_system_coordinates)
class RelativeCoordinateSystem(CoordinateSystem):
"""Relative coordinate system where x and y are on a scale from 0 to 1."""
orientation = Orientation.CARTESIAN
def __init__(self):
self.width = 1
self.height = 1
class PixelSpace(CoordinateSystem):
"""Coordinate system representing a pixel space, such as an image. The origin is at the top
left."""
orientation = Orientation.SCREEN
class PointSpace(CoordinateSystem):
"""Coordinate system representing a point space, such as a pdf. The origin is at the bottom
left."""
orientation = Orientation.CARTESIAN
TYPE_TO_COORDINATE_SYSTEM_MAP: Dict[str, Any] = {
"PixelSpace": PixelSpace,
"PointSpace": PointSpace,
"CoordinateSystem": CoordinateSystem,
}
File diff suppressed because it is too large Load Diff
+427
View File
@@ -0,0 +1,427 @@
"""Central HTML output-sanitization policy for the ontology (v2) HTML path.
`unstructured` renders untrusted document content into HTML in two places:
* ``OntologyElement.to_html`` (``documents/ontology.py``), which fills
``ElementMetadata.text_as_html``. Some callers return this value to clients
verbatim, so it must be safe on its own.
* ``elements_to_html`` (``partition/html/convert.py``), which assembles a full
HTML document from a list of elements.
Both used to interpolate attacker-controlled text, attribute names, attribute
values, and URL schemes with no output encoding, allowing stored XSS
(GHSA-v5mq-3xhg-98m9). This module is the single source of truth for the
sanitization policy shared by both paths:
* an allowlist of HTML tags we ever legitimately emit,
* an allowlist of attribute names (event-handler ``on*`` attributes are never
allowed, killing ``onerror``/``onload``/``onmouseover``),
* a URL-scheme allowlist for URL-bearing attributes (``href``/``src``/...),
which drops ``javascript:`` / ``vbscript:`` and permits ``data:`` only for
raster image MIME types on ``img[src]``.
The emitter (``ontology.py``) uses the lightweight filters here plus
``html.escape`` to make ``text_as_html`` safe on its own; ``elements_to_html``
additionally runs the assembled document through :func:`sanitize_html_fragment`
(``nh3``) as defense-in-depth that also covers attributes it injects itself
(e.g. ``href`` from ``metadata.url``).
"""
from __future__ import annotations
import re
import nh3
# -- Tags the ontology / convert paths legitimately emit. Anything outside this
# -- set (``<script>``, ``<iframe>``, ...) is dropped/neutralized. --
ALLOWED_TAGS: frozenset[str] = frozenset(
{
# layout / structural
"body",
"div",
"section",
"header",
"footer",
"aside",
"nav",
"figure",
"figcaption",
"hr",
"br",
# text
"span",
"p",
"blockquote",
"pre",
"address",
"time",
"mark",
"ins",
"del",
"cite",
"sub",
"sup",
"b",
"i",
"s",
"code",
# headings
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
# lists
"ul",
"ol",
"li",
"dl",
# tables
"table",
"thead",
"tbody",
"tr",
"td",
"th",
# links / media
"a",
"img",
"svg",
"audio",
"video",
# forms
"form",
"input",
"label",
"button",
# misc content
"math",
"meta",
}
)
# -- Attribute names carrying a URL; their values are scheme-filtered. Keep this
# -- list broader than the attributes we currently allow so newly-allowed URL
# -- attributes are scheme-checked by default. --
URL_ATTRIBUTES: frozenset[str] = frozenset(
{
"action",
"cite",
"data-src",
"formaction",
"href",
"poster",
"src",
"srcset",
"xlink:href",
}
)
# -- Data URLs are only needed for embedded image bytes. SVG is intentionally
# -- excluded because SVG documents can carry active content in some render
# -- contexts. --
ALLOWED_DATA_IMAGE_MIME_TYPES: frozenset[str] = frozenset(
{
"image/avif",
"image/bmp",
"image/gif",
"image/jpeg",
"image/jpg",
"image/png",
"image/webp",
"image/x-icon",
}
)
# -- Inline CSS is untrusted too. Keep only inert presentation properties that
# -- preserve existing table/background formatting and reject layout/overlay
# -- controls like position/inset/z-index.
ALLOWED_CSS_PROPERTIES: frozenset[str] = frozenset(
{
"background-color",
"border",
"border-bottom",
"border-collapse",
"border-color",
"border-left",
"border-right",
"border-style",
"border-top",
"border-width",
"color",
"font-style",
"font-weight",
"text-align",
"text-decoration",
"vertical-align",
"white-space",
}
)
# -- Attributes allowed on every tag. --
_GLOBAL_ATTRIBUTES: frozenset[str] = frozenset(
{"class", "id", "style", "title", "dir", "lang", "role", "name", "align"}
)
# -- Per-tag attributes in addition to the global set. --
_TAG_ATTRIBUTES: dict[str, frozenset[str]] = {
"a": frozenset({"href", "target", "rel"}),
"img": frozenset({"src", "alt", "width", "height"}),
"svg": frozenset({"src", "alt", "width", "height", "xlink:href"}),
"audio": frozenset({"src", "controls"}),
"video": frozenset({"src", "controls", "poster", "width", "height"}),
"input": frozenset({"type", "checked", "value", "placeholder"}),
"td": frozenset({"colspan", "rowspan", "headers", "scope"}),
"th": frozenset({"colspan", "rowspan", "headers", "scope"}),
"ol": frozenset({"start", "type"}),
"label": frozenset({"for"}),
"meta": frozenset({"charset", "content"}),
"time": frozenset({"datetime"}),
}
# -- Attribute-name prefixes allowed on any tag (data-page-number, aria-*, ...). --
_GENERIC_ATTRIBUTE_PREFIXES: frozenset[str] = frozenset({"data-", "aria-"})
# -- URL schemes permitted on URL-bearing attributes. ``data`` is permitted here
# -- but further restricted to raster image MIME types on ``img[src]`` by
# -- :func:`is_safe_url` / the nh3 attribute filter; relative URLs (no scheme)
# -- are always allowed. --
ALLOWED_URL_SCHEMES: frozenset[str] = frozenset({"http", "https", "mailto", "tel", "data"})
# -- Valid HTML/XML attribute name (prevents attribute-name breakout on emit). --
_ATTRIBUTE_NAME_RE = re.compile(r"^[a-zA-Z_:][-a-zA-Z0-9_:.]*$")
# -- Matches a leading ``scheme:`` ignoring surrounding whitespace and embedded
# -- control chars that browsers strip (e.g. ``java\tscript:``). --
_SCHEME_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+.\-]*):")
_REQUIRED_BLANK_TARGET_REL_VALUES: frozenset[str] = frozenset({"noopener", "noreferrer"})
# -- Match an anchor start tag and detect `target="_blank"` / an existing `rel`
# -- so we can add reverse-tabnabbing protection to new-tab links only. nh3's
# -- output is well-formed (attribute values are escaped, so `>` never appears
# -- inside one), which makes matching whole start tags with a regex safe. --
_ANCHOR_START_TAG_RE = re.compile(r"<a\b[^>]*>", flags=re.IGNORECASE)
_BLANK_TARGET_RE = re.compile(r"""\btarget\s*=\s*["']?_blank\b""", flags=re.IGNORECASE)
_REL_ATTRIBUTE_RE = re.compile(r"""\brel\s*=""", flags=re.IGNORECASE)
_CSS_COMMENT_RE = re.compile(r"/\*.*?\*/", flags=re.DOTALL)
_CSS_UNSAFE_VALUE_RE = re.compile(
r"(@import|expression\s*\(|url\s*\(|javascript:|vbscript:|data:|-moz-binding|behavior\s*:)",
flags=re.IGNORECASE,
)
def _normalize_url(value: str) -> str:
"""Lower-case and strip whitespace/control chars a browser would ignore."""
return re.sub(r"[\x00-\x20]+", "", value).lower()
def _normalized_tag_and_attribute(
tag_name: str | None = None,
attribute_name: str | None = None,
) -> tuple[str | None, str | None]:
tag = tag_name.strip().lower() if tag_name else None
attribute = attribute_name.strip().lower() if attribute_name else None
return tag, attribute
def _is_allowed_data_image_url(
normalized_value: str,
*,
tag_name: str | None = None,
attribute_name: str | None = None,
) -> bool:
tag, attribute = _normalized_tag_and_attribute(tag_name, attribute_name)
if tag != "img" or attribute != "src":
return False
if not normalized_value.startswith("data:") or "," not in normalized_value:
return False
media_type = normalized_value[5:].split(",", 1)[0].split(";", 1)[0]
return media_type in ALLOWED_DATA_IMAGE_MIME_TYPES
def is_safe_url(
value: str,
*,
tag_name: str | None = None,
attribute_name: str | None = None,
) -> bool:
"""True if ``value`` is safe to keep in a URL-bearing attribute.
Relative URLs (no scheme) are allowed. Absolute URLs are allowed only for
:data:`ALLOWED_URL_SCHEMES`, and ``data:`` is further narrowed to raster
image MIME types on ``img[src]`` so embedded base64 images survive while
SVG / HTML / JavaScript data documents are rejected.
"""
normalized = _normalize_url(value)
match = _SCHEME_RE.match(normalized)
if match is None:
# -- no scheme -> relative URL (or a fragment/anchor); safe --
return True
scheme = match.group(1)
if scheme not in ALLOWED_URL_SCHEMES:
return False
if scheme == "data":
return _is_allowed_data_image_url(
normalized,
tag_name=tag_name,
attribute_name=attribute_name,
)
return True
def is_event_handler_attribute(name: str) -> bool:
"""True for ``on*`` event-handler attribute names (onerror, onload, ...)."""
return name.strip().lower().startswith("on")
def _is_allowed_attribute_for_tag(name: str, tag_name: str | None) -> bool:
lowered = name.lower()
if any(lowered.startswith(prefix) for prefix in _GENERIC_ATTRIBUTE_PREFIXES):
return True
allowed_attributes = set(_GLOBAL_ATTRIBUTES)
if tag_name:
allowed_attributes.update(_TAG_ATTRIBUTES.get(tag_name.strip().lower(), frozenset()))
return lowered in allowed_attributes
def _link_rel_with_required_values(value: object | None) -> str:
existing_values = str(value or "").split()
existing_lowered = {rel.lower() for rel in existing_values}
missing_values = [
rel for rel in sorted(_REQUIRED_BLANK_TARGET_REL_VALUES) if rel not in existing_lowered
]
return " ".join([*existing_values, *missing_values]).strip()
def sanitize_style_attribute(value: object) -> str:
"""Return a style attribute containing only safe presentation declarations."""
style = _CSS_COMMENT_RE.sub("", str(value))
safe_declarations: list[str] = []
for declaration in style.split(";"):
if ":" not in declaration:
continue
raw_property, raw_value = declaration.split(":", 1)
property_name = raw_property.strip().lower()
property_value = " ".join(raw_value.strip().split())
if property_name not in ALLOWED_CSS_PROPERTIES:
continue
if not property_value or _CSS_UNSAFE_VALUE_RE.search(property_value):
continue
safe_declarations.append(f"{property_name}: {property_value}")
return "; ".join(safe_declarations)
def sanitize_attributes(
attributes: dict[str, object],
tag_name: str | None = None,
) -> dict[str, object]:
"""Filter an attribute mapping for safe emission (does NOT html-escape).
Drops event-handler (``on*``) attributes, attribute names that aren't valid
HTML attribute names, attributes not allowed for ``tag_name``, and URL-bearing
attributes whose value uses an unsafe scheme. Values are returned unchanged;
the emitter is responsible for ``html.escape``-ing them so escaping happens
exactly once.
"""
safe: dict[str, object] = {}
for key, value in attributes.items():
name = str(key).strip()
lowered = name.lower()
if is_event_handler_attribute(lowered):
continue
if not _ATTRIBUTE_NAME_RE.match(name):
continue
if not _is_allowed_attribute_for_tag(name, tag_name):
continue
if lowered == "style":
sanitized_style = sanitize_style_attribute(value)
if not sanitized_style:
continue
safe[name] = sanitized_style
continue
if lowered in URL_ATTRIBUTES and value is not None:
candidate = value[0] if isinstance(value, list) and value else value
if isinstance(candidate, str) and not is_safe_url(
candidate,
tag_name=tag_name,
attribute_name=lowered,
):
continue
safe[name] = value
if (tag_name or "").strip().lower() == "a" and str(safe.get("target", "")).lower() == "_blank":
safe["rel"] = _link_rel_with_required_values(safe.get("rel"))
return safe
def is_safe_tag(tag_name: str | None) -> bool:
"""True if ``tag_name`` is in the emit allowlist."""
return bool(tag_name) and tag_name.strip().lower() in ALLOWED_TAGS
def _nh3_attribute_filter(tag: str, attribute: str, value: str) -> str | None:
"""nh3 per-attribute hook: drop event handlers and unsafe URL values."""
if is_event_handler_attribute(attribute):
return None
if attribute.lower() == "style":
return sanitize_style_attribute(value) or None
if attribute.lower() in URL_ATTRIBUTES and not is_safe_url(
value,
tag_name=tag,
attribute_name=attribute,
):
return None
return value
def _add_rel_to_blank_target_links(html_fragment: str) -> str:
"""Add reverse-tabnabbing ``rel`` tokens to ``target="_blank"`` anchors only.
nh3's ``link_rel`` would stamp ``rel`` onto *every* link, stripping the
``Referer`` header from ordinary same-tab navigation. Reverse tabnabbing is
only a concern for new-tab links, so we scope the tokens to anchors that
actually open a new browsing context and leave same-tab links untouched.
"""
def add_rel(match: re.Match[str]) -> str:
tag = match.group(0)
if not _BLANK_TARGET_RE.search(tag) or _REL_ATTRIBUTE_RE.search(tag):
return tag
rel = " ".join(sorted(_REQUIRED_BLANK_TARGET_REL_VALUES))
return f'{tag[:-1].rstrip()} rel="{rel}">'
return _ANCHOR_START_TAG_RE.sub(add_rel, html_fragment)
def sanitize_html_fragment(html_fragment: str) -> str:
"""Sanitize an assembled HTML fragment with ``nh3`` (defense-in-depth).
Applies the shared tag/attribute/URL-scheme allowlists. Used on the final
output of ``elements_to_html`` so that attributes injected outside the
ontology emitter (e.g. an ``href`` built from ``metadata.url``) are also
neutralized.
"""
attributes: dict[str, set[str]] = {"*": set(_GLOBAL_ATTRIBUTES)}
for tag, attrs in _TAG_ATTRIBUTES.items():
attributes[tag] = set(attrs)
cleaned = nh3.clean(
html_fragment,
tags=set(ALLOWED_TAGS),
attributes=attributes,
url_schemes=set(ALLOWED_URL_SCHEMES),
generic_attribute_prefixes=set(_GENERIC_ATTRIBUTE_PREFIXES),
filter_style_properties=set(ALLOWED_CSS_PROPERTIES),
attribute_filter=_nh3_attribute_filter,
# -- Scope reverse-tabnabbing `rel` tokens to `_blank` anchors ourselves
# -- rather than letting nh3 add them to every link (which would strip
# -- the Referer header from same-tab navigation). --
link_rel=None,
strip_comments=True,
)
return _add_rel_to_blank_target_links(cleaned)
+195
View File
@@ -0,0 +1,195 @@
"""
This module contains mapping between:
HTML Tags <-> Elements Ontology <-> Unstructured Element classes
They are used to simplify transformations between different representations
of parsed documents
"""
from typing import Dict, Type
from unstructured.documents import elements, ontology
from unstructured.documents.elements import Element
def get_all_subclasses(cls: type) -> list[type]:
"""
Recursively find all subclasses of a given class.
Parameters:
cls (type): The class for which to find all subclasses.
Returns:
list[type]: A list of all subclasses of the given class.
"""
subclasses = cls.__subclasses__()
all_subclasses = subclasses.copy()
for subclass in subclasses:
all_subclasses.extend(get_all_subclasses(subclass))
return all_subclasses
def get_ontology_to_unstructured_type_mapping() -> dict[
Type[ontology.OntologyElement], Type[Element]
]:
"""
Get a mapping of ontology element to unstructured type.
The dictionary here was created base on ontology mapping json
Can be generated via the following code:
```
ontology_elements_list = json.loads(
Path("unstructured_element_ontology.json").read_text()
)
ontology_to_unstructured_class_mapping = {
ontology_element["name"]: ontology_element["ontologyV1Mapping"]
for ontology_element in ontology_elements_list
}
```
Returns:
dict: A dictionary where keys are ontology element classes
and values are unstructured types.
"""
ontology_to_unstructured_class_mapping: Dict[Type[ontology.OntologyElement], Type[Element]] = {
ontology.Document: elements.Text,
ontology.Section: elements.Text,
ontology.Page: elements.Text,
ontology.Column: elements.Text,
ontology.Paragraph: elements.NarrativeText,
ontology.Header: elements.Header,
ontology.Footer: elements.Footer,
ontology.Sidebar: elements.Text,
ontology.PageBreak: elements.PageBreak,
ontology.Title: elements.Title,
ontology.Subtitle: elements.Title,
ontology.Heading: elements.Title,
ontology.NarrativeText: elements.NarrativeText,
ontology.Quote: elements.NarrativeText,
ontology.Footnote: elements.Text,
ontology.Caption: elements.FigureCaption,
ontology.PageNumber: elements.PageNumber,
ontology.UncategorizedText: elements.Text,
ontology.OrderedList: elements.Text,
ontology.UnorderedList: elements.Text,
ontology.DefinitionList: elements.Text,
ontology.ListItem: elements.ListItem,
ontology.Table: elements.Table,
ontology.TableRow: elements.Table,
ontology.TableCell: elements.Table,
ontology.TableCellHeader: elements.Table,
ontology.TableBody: elements.Table,
ontology.TableHeader: elements.Table,
ontology.Image: elements.Image,
ontology.Figure: elements.Image,
ontology.Video: elements.Text,
ontology.Audio: elements.Text,
ontology.Barcode: elements.Image,
ontology.QRCode: elements.Image,
ontology.Logo: elements.Image,
ontology.CodeBlock: elements.CodeSnippet,
ontology.InlineCode: elements.CodeSnippet,
ontology.Formula: elements.Formula,
ontology.Equation: elements.Formula,
ontology.FootnoteReference: elements.Text,
ontology.Citation: elements.Text,
ontology.Bibliography: elements.Text,
ontology.Glossary: elements.Text,
ontology.Author: elements.Text,
ontology.MetaDate: elements.Text,
ontology.Keywords: elements.Text,
ontology.Abstract: elements.NarrativeText,
ontology.Hyperlink: elements.Text,
ontology.TableOfContents: elements.Table,
ontology.Index: elements.Text,
ontology.Form: elements.Text,
ontology.FormField: elements.Text,
ontology.FormFieldValue: elements.Text,
ontology.Checkbox: elements.Text,
ontology.RadioButton: elements.Text,
ontology.Button: elements.Text,
ontology.Comment: elements.Text,
ontology.Highlight: elements.Text,
ontology.RevisionInsertion: elements.Text,
ontology.RevisionDeletion: elements.Text,
ontology.Address: elements.Address,
ontology.EmailAddress: elements.EmailAddress,
ontology.PhoneNumber: elements.Text,
ontology.CalendarDate: elements.Text,
ontology.Time: elements.Text,
ontology.Currency: elements.Text,
ontology.Measurement: elements.Text,
ontology.Letterhead: elements.Header,
ontology.Signature: elements.Text,
ontology.Watermark: elements.Text,
ontology.Stamp: elements.Text,
}
return ontology_to_unstructured_class_mapping
ALL_ONTOLOGY_ELEMENT_TYPES = get_all_subclasses(ontology.OntologyElement)
HTML_TAG_AND_CSS_NAME_TO_ELEMENT_TYPE_MAP: Dict[tuple[str, str], Type[ontology.OntologyElement]] = {
(tag, element_type().css_class_name): element_type
for element_type in ALL_ONTOLOGY_ELEMENT_TYPES
for tag in element_type().allowed_tags
}
CSS_CLASS_TO_ELEMENT_TYPE_MAP: Dict[str, Type[ontology.OntologyElement]] = {
element_type().css_class_name: element_type for element_type in ALL_ONTOLOGY_ELEMENT_TYPES
}
HTML_TAG_TO_DEFAULT_ELEMENT_TYPE_MAP: Dict[str, Type[ontology.OntologyElement]] = {
"a": ontology.Hyperlink,
"address": ontology.Address,
"aside": ontology.Sidebar,
"audio": ontology.Audio,
"blockquote": ontology.Quote,
"body": ontology.Document,
"button": ontology.Button,
"cite": ontology.Citation,
"code": ontology.CodeBlock,
"del": ontology.RevisionDeletion,
"div": ontology.UncategorizedText,
"dl": ontology.DefinitionList,
"figcaption": ontology.Caption,
"figure": ontology.Figure,
"footer": ontology.Footer,
"form": ontology.Form,
"h1": ontology.Title,
"h2": ontology.Subtitle,
"h3": ontology.Heading,
"h4": ontology.Heading,
"h5": ontology.Heading,
"h6": ontology.Heading,
"header": ontology.Header,
"hr": ontology.PageBreak,
"img": ontology.Image,
"input": ontology.Checkbox,
"ins": ontology.RevisionInsertion,
"label": ontology.FormField,
"li": ontology.ListItem,
"mark": ontology.Highlight,
"math": ontology.Equation,
"meta": ontology.Keywords,
"nav": ontology.Index,
"ol": ontology.OrderedList,
"p": ontology.Paragraph,
"pre": ontology.CodeBlock,
"section": ontology.Section,
"span": ontology.UncategorizedText,
"sub": ontology.FootnoteReference,
"svg": ontology.Signature,
"table": ontology.Table,
"tbody": ontology.TableBody,
"td": ontology.TableCell,
"th": ontology.TableCellHeader,
"thead": ontology.TableHeader,
"time": ontology.Time,
"tr": ontology.TableRow,
"ul": ontology.UnorderedList,
"video": ontology.Video,
}
ONTOLOGY_CLASS_TO_UNSTRUCTURED_ELEMENT_TYPE = get_ontology_to_unstructured_type_mapping()
+647
View File
@@ -0,0 +1,647 @@
"""
This file contains all classes allowed in the ontology V2.
This Type is used as intermediate representation between HTML
and Unstructured Elements.
All the processing could be done without the intermediate representation,
but it simplifies the process.
It needs to be decide whether we keep it or not.
The classes are represented as pydantic models to mimic Unstructured Elements V1 solutions.
However it results in lots of code that could be strongly simplified.
TODO (Pluto): OntologyElement is the only needed class. It could contains data about
allowed html tags, css classes and descriptions as metadata.
"""
from __future__ import annotations
import html
import uuid
from copy import copy
from enum import Enum
from typing import Any, List, Optional
from bs4 import BeautifulSoup
from pydantic import BaseModel, Field
from unstructured.documents.html_sanitization import is_safe_tag, sanitize_attributes
class ElementTypeEnum(str, Enum):
layout = "Layout"
text = "Text"
list = "List"
table = "Table"
media = "Media"
code = "Code"
mathematical = "Mathematical"
reference = "Reference"
metadata = "Metadata"
navigation = "Navigation"
form = "Form"
annotation = "Annotation"
specialized_text = "Specialized Text"
document_specific = "Document-Specific"
class OntologyElement(BaseModel):
text: Optional[str] = Field("", description="Text content of the element")
css_class_name: Optional[str] = Field(
default_factory=lambda: "", description="CSS class associated with the element"
)
html_tag_name: Optional[str] = Field(
default_factory=lambda: "", description="HTML Tag name associated with the element"
)
elementType: ElementTypeEnum = Field(description="Type of the element")
children: list["OntologyElement"] = Field(
default_factory=list, description="List of child elements"
)
description: str = Field(description="Description of the element")
allowed_tags: list[str] = Field(description="HTML tags associated with the element")
additional_attributes: dict[str, Any] = Field(
default_factory=dict, description="Optional HTML attributes or CSS properties"
)
def __init__(self, **kwargs: dict[str, Any]):
super().__init__(**kwargs)
if self.css_class_name == "": # if None, then do not set
self.css_class_name = self.__class__.__name__
if self.html_tag_name == "":
self.html_tag_name = self.allowed_tags[0]
if "id" not in self.additional_attributes:
self.additional_attributes["id"] = self.generate_unique_id()
@staticmethod
def generate_unique_id() -> str:
return str(uuid.uuid4()).replace("-", "")
def to_html(self, add_children: bool = True) -> str:
additional_attrs = copy(self.additional_attributes)
additional_attrs.pop("class", None)
additional_attrs.pop("id", None)
tag_name = self.html_tag_name if is_safe_tag(self.html_tag_name) else "span"
attr_str = self._construct_attribute_string(additional_attrs, tag_name)
class_attr = (
f'class="{html.escape(self.css_class_name, quote=True)}"' if self.css_class_name else ""
)
combined_attr_str = f"{class_attr} {attr_str}".strip()
children_html = self._generate_children_html(add_children)
result_html = self._generate_final_html(combined_attr_str, children_html, tag_name)
return result_html
def to_text(self, add_children: bool = True, add_img_alt_text: bool = True) -> str:
"""
Returns the text representation of the element.
Args:
add_children: If True, the text of the children will be included.
Otherwise, element is represented as single self-closing tag.
add_img_alt_text: If True, the alt text of the image will be included.
"""
if self.children and add_children:
children_text = " ".join(
child.to_text(add_children, add_img_alt_text).strip() for child in self.children
)
return children_text
# -- Derive the plain text from the raw `text`. When it holds markup
# -- (e.g. the collapsed subtree stored when the recursion limit is hit)
# -- strip the tags with BeautifulSoup; otherwise use it as-is (which also
# -- avoids BeautifulSoup's MarkupResemblesLocatorWarning on plain strings).
# -- This is deliberately NOT taken from `to_html()`, whose output is now
# -- HTML-escaped for safety -- reparsing that escaped output would surface
# -- the markup as literal text instead of stripping it. --
raw_text = self.text or ""
if "<" in raw_text:
text = BeautifulSoup(raw_text, "html.parser").get_text().strip()
else:
text = raw_text.strip()
if add_img_alt_text and self.html_tag_name == "img" and "alt" in self.additional_attributes:
text += f" {self.additional_attributes.get('alt', '')}"
return text.strip()
def _construct_attribute_string(self, attributes: dict[str, str], tag_name: str) -> str:
# -- Drop event-handler (on*) and unsafe-scheme attributes, then HTML-escape
# -- each value (quote=True) so an attacker cannot break out of the quoted
# -- value with `">`. Escaping happens here, at emit time, so callers must
# -- pass raw (un-escaped) values -- see html_sanitization.sanitize_attributes. --
safe_attributes = sanitize_attributes(attributes, tag_name=tag_name)
return " ".join(
f'{key}="{html.escape(str(value), quote=True)}"' if value else f"{key}"
for key, value in safe_attributes.items()
)
def _generate_children_html(self, add_children: bool) -> str:
if not add_children or not self.children:
return ""
return "".join(child.to_html() for child in self.children)
def _generate_final_html(self, attr_str: str, children_html: str, tag_name: str) -> str:
# -- Escape element text; `children_html` is already-sanitized HTML from
# -- recursive `to_html` calls, so it must NOT be re-escaped. --
text = html.escape(self.text) if self.text else ""
if text or children_html:
inside_tag_text = f"{text} {children_html}".strip()
return f"<{tag_name} {attr_str}>{inside_tag_text}</{tag_name}>"
else:
return f"<{tag_name} {attr_str} />"
@property
def id(self) -> str | None:
return self.additional_attributes.get("id", None)
@property
def page_number(self) -> int | None:
if "data-page-number" in self.additional_attributes:
try:
page_attr = self.additional_attributes.get("data-page-number")
if page_attr is not None:
return int(page_attr)
except ValueError:
return None
return None
def remove_ids_and_class_from_table(
soup: BeautifulSoup, class_attr_to_keep: list[str] = ["img", "input"]
) -> BeautifulSoup:
"""
Remove id and class attributes from tags inside tables,
except preserve class attributes for selected tags.
Args:
soup: BeautifulSoup object containing the HTML
class_attr_to_keep: a list of tag names whose class attr will be kept
Returns:
BeautifulSoup: Modified soup with attributes removed
"""
for tag in soup.find_all(True):
if tag.name.lower() == "table": # type: ignore
continue # We keep table tag
tag.attrs.pop("id", None) # type: ignore
if tag.name.lower() not in class_attr_to_keep: # type: ignore
tag.attrs.pop("class", None) # type: ignore
return soup
# Define specific elements
class Document(OntologyElement):
description: str = Field("Root element of the document", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.layout, frozen=True)
allowed_tags: List[str] = Field(["body"], frozen=True)
class Section(OntologyElement):
description: str = Field("A distinct part or subdivision of a document", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.layout, frozen=True)
allowed_tags: List[str] = Field(["section"], frozen=True)
class Page(OntologyElement):
description: str = Field("A single side of a paper in a document", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.layout, frozen=True)
allowed_tags: List[str] = Field(["div"], frozen=True)
class Column(OntologyElement):
description: str = Field("A vertical section of a page", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.layout, frozen=True)
allowed_tags: List[str] = Field(["div"], frozen=True)
class Paragraph(OntologyElement):
description: str = Field("A self-contained unit of discourse in writing", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.text, frozen=True)
allowed_tags: List[str] = Field(["p"], frozen=True)
class Header(OntologyElement):
description: str = Field("The top section of a page", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.text, frozen=True)
allowed_tags: List[str] = Field(["header"], frozen=True)
class Footer(OntologyElement):
description: str = Field("The bottom section of a page", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.text, frozen=True)
allowed_tags: List[str] = Field(["footer"], frozen=True)
class Sidebar(OntologyElement):
description: str = Field("A side section of a page", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.layout, frozen=True)
allowed_tags: List[str] = Field(["aside"], frozen=True)
class PageBreak(OntologyElement):
description: str = Field("A break between pages", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.layout, frozen=True)
allowed_tags: List[str] = Field(["hr"], frozen=True)
class Title(OntologyElement):
description: str = Field("Main heading of a document or section", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.text, frozen=True)
allowed_tags: List[str] = Field(["h1"], frozen=True)
class Subtitle(OntologyElement):
description: str = Field("Secondary title of a document or section", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.text, frozen=True)
allowed_tags: List[str] = Field(["h2"], frozen=True)
class Heading(OntologyElement):
description: str = Field("Section headings (levels 1-6)", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.text, frozen=True)
allowed_tags: List[str] = Field(["h1", "h2", "h3", "h4", "h5", "h6"], frozen=True)
class NarrativeText(OntologyElement):
description: str = Field("Main content text", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.text, frozen=True)
allowed_tags: List[str] = Field(["p"], frozen=True)
class Quote(OntologyElement):
description: str = Field("A repetition of someone else's statement", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.text, frozen=True)
allowed_tags: List[str] = Field(["blockquote"], frozen=True)
class Footnote(OntologyElement):
description: str = Field("A note at the bottom of a page", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.text, frozen=True)
allowed_tags: List[str] = Field(["div"], frozen=True)
class Caption(OntologyElement):
description: str = Field("Text describing a figure or image", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.text, frozen=True)
allowed_tags: List[str] = Field(["figcaption"], frozen=True)
class PageNumber(OntologyElement):
description: str = Field("The number of a page", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.text, frozen=True)
allowed_tags: List[str] = Field(["span"], frozen=True)
class UncategorizedText(OntologyElement):
description: str = Field("Miscellaneous text", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.text, frozen=True)
allowed_tags: List[str] = Field(["span"], frozen=True)
class OrderedList(OntologyElement):
description: str = Field("A list with a specific sequence", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.list, frozen=True)
allowed_tags: List[str] = Field(["ol"], frozen=True)
class UnorderedList(OntologyElement):
description: str = Field("A list without a specific sequence", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.list, frozen=True)
allowed_tags: List[str] = Field(["ul"], frozen=True)
class DefinitionList(OntologyElement):
description: str = Field("A list of terms and their definitions", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.list, frozen=True)
allowed_tags: List[str] = Field(["dl"], frozen=True)
class ListItem(OntologyElement):
description: str = Field("An item in a list", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.list, frozen=True)
allowed_tags: List[str] = Field(["li"], frozen=True)
class Table(OntologyElement):
description: str = Field("A structured set of data", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.table, frozen=True)
allowed_tags: List[str] = Field(["table"], frozen=True)
def to_html(self, add_children: bool = True) -> str:
soup = BeautifulSoup(super().to_html(add_children), "html.parser")
soup = remove_ids_and_class_from_table(soup)
return str(soup)
class TableBody(OntologyElement):
description: str = Field("A body of the table", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.table, frozen=True)
allowed_tags: List[str] = Field(["tbody"], frozen=True)
class TableHeader(OntologyElement):
description: str = Field("A header of the table", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.table, frozen=True)
allowed_tags: List[str] = Field(["thead"], frozen=True)
class TableRow(OntologyElement):
description: str = Field("A row in a table", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.table, frozen=True)
allowed_tags: List[str] = Field(["tr"], frozen=True)
class TableCell(OntologyElement):
description: str = Field("A cell in a table", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.table, frozen=True)
allowed_tags: List[str] = Field(["td"], frozen=True)
# Note(Pluto): Renamed from TableCellHeader to TableHeaderCell to be consistent with TableCell
class TableCellHeader(OntologyElement):
description: str = Field("A header cell in a table", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.table, frozen=True)
allowed_tags: List[str] = Field(["th"], frozen=True)
class Image(OntologyElement):
description: str = Field("A visual representation", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.media, frozen=True)
allowed_tags: List[str] = Field(["img"], frozen=True)
class Figure(OntologyElement):
description: str = Field("An illustration or diagram in a document", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.media, frozen=True)
allowed_tags: List[str] = Field(["figure"], frozen=True)
class Video(OntologyElement):
description: str = Field("A moving visual media element", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.media, frozen=True)
allowed_tags: List[str] = Field(["video"], frozen=True)
class Audio(OntologyElement):
description: str = Field("A sound or music element", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.media, frozen=True)
allowed_tags: List[str] = Field(["audio"], frozen=True)
class Barcode(OntologyElement):
description: str = Field("A machine-readable representation of data", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.media, frozen=True)
allowed_tags: List[str] = Field(["img"], frozen=True)
class QRCode(OntologyElement):
description: str = Field("A two-dimensional barcode", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.media, frozen=True)
allowed_tags: List[str] = Field(["img"], frozen=True)
class Logo(OntologyElement):
description: str = Field("A graphical representation of a company or brand", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.media, frozen=True)
allowed_tags: List[str] = Field(["img"], frozen=True)
class CodeBlock(OntologyElement):
description: str = Field("A block of programming code", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.code, frozen=True)
allowed_tags: List[str] = Field(["pre", "code"], frozen=True)
class InlineCode(OntologyElement):
description: str = Field("Code within a line of text", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.code, frozen=True)
allowed_tags: List[str] = Field(["code"], frozen=True)
class Formula(OntologyElement):
description: str = Field("A mathematical formula", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.mathematical, frozen=True)
allowed_tags: List[str] = Field(["math"], frozen=True)
class Equation(OntologyElement):
description: str = Field("A mathematical equation", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.mathematical, frozen=True)
allowed_tags: List[str] = Field(["math"], frozen=True)
class FootnoteReference(OntologyElement):
description: str = Field(
"A subscripted reference to a note at the bottom of a page", frozen=True
)
elementType: ElementTypeEnum = Field(ElementTypeEnum.reference, frozen=True)
allowed_tags: List[str] = Field(["sub"], frozen=True)
class Citation(OntologyElement):
description: str = Field("A reference to a source", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.reference, frozen=True)
allowed_tags: List[str] = Field(["cite"], frozen=True)
class Bibliography(OntologyElement):
description: str = Field("A list of sources", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.reference, frozen=True)
allowed_tags: List[str] = Field(["ul"], frozen=True)
class Glossary(OntologyElement):
description: str = Field("A list of terms and their definitions", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.reference, frozen=True)
allowed_tags: List[str] = Field(["dl"], frozen=True)
class Author(OntologyElement):
description: str = Field("The creator of the document", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.metadata, frozen=True)
allowed_tags: List[str] = Field(["meta"], frozen=True)
class MetaDate(OntologyElement):
description: str = Field("The date associated with the document", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.metadata, frozen=True)
allowed_tags: List[str] = Field(["meta"], frozen=True)
class Keywords(OntologyElement):
description: str = Field("Key terms associated with the document", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.metadata, frozen=True)
allowed_tags: List[str] = Field(["meta"], frozen=True)
class Abstract(OntologyElement):
description: str = Field("A summary of the document", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.metadata, frozen=True)
allowed_tags: List[str] = Field(["section"], frozen=True)
class Hyperlink(OntologyElement):
description: str = Field("A reference to data that can be directly followed", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.navigation, frozen=True)
allowed_tags: List[str] = Field(["a"], frozen=True)
class TableOfContents(OntologyElement):
description: str = Field(
"A list of the document's contents. Total table columns will be "
"equal to the degree of hierarchy (n) plus 1 for the target value. "
"Header Row: L1,L2,...Ln,Value",
frozen=True,
)
elementType: ElementTypeEnum = Field(ElementTypeEnum.table, frozen=True)
allowed_tags: List[str] = Field(["table"], frozen=True)
def to_html(self, add_children: bool = True) -> str:
soup = BeautifulSoup(super().to_html(add_children), "html.parser")
soup = remove_ids_and_class_from_table(soup)
return str(soup)
class Index(OntologyElement):
description: str = Field("An alphabetical list of terms and their page numbers", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.navigation, frozen=True)
allowed_tags: List[str] = Field(["nav"], frozen=True)
class Form(OntologyElement):
description: str = Field("A document section with interactive controls", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.form, frozen=True)
allowed_tags: List[str] = Field(["form"], frozen=True)
class FormField(OntologyElement):
description: str = Field("A property value of a form", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.form, frozen=True)
allowed_tags: List[str] = Field(["label"], frozen=True)
class FormFieldValue(OntologyElement):
description: str = Field("A field for user input", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.form, frozen=True)
allowed_tags: List[str] = Field(["input"], frozen=True)
def to_text(self, add_children: bool = True, add_img_alt_text: bool = True) -> str:
text = super().to_text(add_children, add_img_alt_text)
value = self.additional_attributes.get("value", "")
if not value:
return text
return f"{text} {value}".strip()
class Checkbox(OntologyElement):
description: str = Field("A small box that can be checked or unchecked", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.form, frozen=True)
allowed_tags: List[str] = Field(["input"], frozen=True)
class RadioButton(OntologyElement):
description: str = Field("A circular button that can be selected", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.form, frozen=True)
allowed_tags: List[str] = Field(["input"], frozen=True)
class Button(OntologyElement):
description: str = Field("An interactive button element", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.form, frozen=True)
allowed_tags: List[str] = Field(["button"], frozen=True)
class Comment(OntologyElement):
description: str = Field("A note or remark", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.annotation, frozen=True)
allowed_tags: List[str] = Field(["span"], frozen=True)
class Highlight(OntologyElement):
description: str = Field("Emphasized text or section", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.annotation, frozen=True)
allowed_tags: List[str] = Field(["mark"], frozen=True)
class RevisionInsertion(OntologyElement):
description: str = Field("A changed or edited element", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.annotation, frozen=True)
allowed_tags: List[str] = Field(["ins"], frozen=True)
class RevisionDeletion(OntologyElement):
description: str = Field("A changed or edited element", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.annotation, frozen=True)
allowed_tags: List[str] = Field(["del"], frozen=True)
class Address(OntologyElement):
description: str = Field("A physical location", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.specialized_text, frozen=True)
allowed_tags: List[str] = Field(["address"], frozen=True)
class EmailAddress(OntologyElement):
description: str = Field("An email address", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.specialized_text, frozen=True)
allowed_tags: List[str] = Field(["a"], frozen=True)
class PhoneNumber(OntologyElement):
description: str = Field("A telephone number", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.specialized_text, frozen=True)
allowed_tags: List[str] = Field(["span"], frozen=True)
class CalendarDate(OntologyElement):
description: str = Field("A calendar date", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.specialized_text, frozen=True)
allowed_tags: List[str] = Field(["time"], frozen=True)
class Time(OntologyElement):
description: str = Field("A specific time", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.specialized_text, frozen=True)
allowed_tags: List[str] = Field(["time"], frozen=True)
class Currency(OntologyElement):
description: str = Field("A monetary value", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.specialized_text, frozen=True)
allowed_tags: List[str] = Field(["span"], frozen=True)
class Measurement(OntologyElement):
description: str = Field("A quantitative value with units", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.specialized_text, frozen=True)
allowed_tags: List[str] = Field(["span"], frozen=True)
class Letterhead(OntologyElement):
description: str = Field("The heading at the top of a letter", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.document_specific, frozen=True)
allowed_tags: List[str] = Field(["header"], frozen=True)
class Signature(OntologyElement):
description: str = Field("A person's name written in a distinctive way", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.document_specific, frozen=True)
allowed_tags: List[str] = Field(["img", "svg"], frozen=True)
class Watermark(OntologyElement):
description: str = Field("A faint design made in paper during manufacture", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.document_specific, frozen=True)
allowed_tags: List[str] = Field(["div"], frozen=True)
class Stamp(OntologyElement):
description: str = Field("An official mark or seal", frozen=True)
elementType: ElementTypeEnum = Field(ElementTypeEnum.document_specific, frozen=True)
allowed_tags: List[str] = Field(["img", "svg"], frozen=True)
+6
View File
@@ -0,0 +1,6 @@
# Embed
![Project unmaintained](https://img.shields.io/badge/project-unmaintained-red.svg)
Project has been moved to: [Unstructured Ingest](https://github.com/Unstructured-IO/unstructured-ingest)
This python module will be removed from this repo in the near future.
+27
View File
@@ -0,0 +1,27 @@
import warnings
from unstructured.embed.bedrock import BedrockEmbeddingEncoder
from unstructured.embed.huggingface import HuggingFaceEmbeddingEncoder
from unstructured.embed.mixedbreadai import MixedbreadAIEmbeddingEncoder
from unstructured.embed.octoai import OctoAIEmbeddingEncoder
from unstructured.embed.openai import OpenAIEmbeddingEncoder
from unstructured.embed.vertexai import VertexAIEmbeddingEncoder
from unstructured.embed.voyageai import VoyageAIEmbeddingEncoder
EMBEDDING_PROVIDER_TO_CLASS_MAP = {
"langchain-openai": OpenAIEmbeddingEncoder,
"langchain-huggingface": HuggingFaceEmbeddingEncoder,
"langchain-aws-bedrock": BedrockEmbeddingEncoder,
"langchain-vertexai": VertexAIEmbeddingEncoder,
"voyageai": VoyageAIEmbeddingEncoder,
"mixedbread-ai": MixedbreadAIEmbeddingEncoder,
"octoai": OctoAIEmbeddingEncoder,
}
warnings.warn(
"unstructured.ingest will be removed in a future version. "
"Functionality moved to the unstructured-ingest project.",
DeprecationWarning,
stacklevel=2,
)
+76
View File
@@ -0,0 +1,76 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING, List
import numpy as np
from pydantic import SecretStr
from unstructured.documents.elements import (
Element,
)
from unstructured.embed.interfaces import BaseEmbeddingEncoder, EmbeddingConfig
from unstructured.utils import requires_dependencies
if TYPE_CHECKING:
from langchain_community.embeddings import BedrockEmbeddings
class BedrockEmbeddingConfig(EmbeddingConfig):
aws_access_key_id: SecretStr
aws_secret_access_key: SecretStr
region_name: str = "us-west-2"
@requires_dependencies(
["boto3", "numpy", "langchain_community"],
extras="bedrock",
)
def get_client(self) -> "BedrockEmbeddings":
# delay import only when needed
import boto3
from langchain_community.embeddings import BedrockEmbeddings
bedrock_runtime = boto3.client(
service_name="bedrock-runtime",
aws_access_key_id=self.aws_access_key_id.get_secret_value(),
aws_secret_access_key=self.aws_secret_access_key.get_secret_value(),
region_name=self.region_name,
)
bedrock_client = BedrockEmbeddings(client=bedrock_runtime)
return bedrock_client
@dataclass
class BedrockEmbeddingEncoder(BaseEmbeddingEncoder):
config: BedrockEmbeddingConfig
def get_exemplary_embedding(self) -> List[float]:
return self.embed_query(query="Q")
def __post_init__(self):
self.initialize()
def num_of_dimensions(self):
exemplary_embedding = self.get_exemplary_embedding()
return np.shape(exemplary_embedding)
def is_unit_vector(self):
exemplary_embedding = self.get_exemplary_embedding()
return np.isclose(np.linalg.norm(exemplary_embedding), 1.0)
def embed_query(self, query):
bedrock_client = self.config.get_client()
return np.array(bedrock_client.embed_query(query))
def embed_documents(self, elements: List[Element]) -> List[Element]:
bedrock_client = self.config.get_client()
embeddings = bedrock_client.embed_documents([str(e) for e in elements])
elements_with_embeddings = self._add_embeddings_to_elements(elements, embeddings)
return elements_with_embeddings
def _add_embeddings_to_elements(self, elements, embeddings) -> List[Element]:
assert len(elements) == len(embeddings)
elements_w_embedding = []
for i, element in enumerate(elements):
element.embeddings = embeddings[i]
elements_w_embedding.append(element)
return elements
+67
View File
@@ -0,0 +1,67 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING, List, Optional
import numpy as np
from pydantic import Field
from unstructured.documents.elements import (
Element,
)
from unstructured.embed.interfaces import BaseEmbeddingEncoder, EmbeddingConfig
from unstructured.utils import requires_dependencies
if TYPE_CHECKING:
from langchain_huggingface.embeddings import HuggingFaceEmbeddings
class HuggingFaceEmbeddingConfig(EmbeddingConfig):
model_name: Optional[str] = Field(default="sentence-transformers/all-MiniLM-L6-v2")
model_kwargs: Optional[dict] = Field(default_factory=lambda: {"device": "cpu"})
encode_kwargs: Optional[dict] = Field(default_factory=lambda: {"normalize_embeddings": False})
cache_folder: Optional[dict] = Field(default=None)
@requires_dependencies(
["langchain_huggingface"],
extras="embed-huggingface",
)
def get_client(self) -> "HuggingFaceEmbeddings":
"""Creates a langchain Huggingface python client to embed elements."""
from langchain_huggingface.embeddings import HuggingFaceEmbeddings
client = HuggingFaceEmbeddings(**self.dict())
return client
@dataclass
class HuggingFaceEmbeddingEncoder(BaseEmbeddingEncoder):
config: HuggingFaceEmbeddingConfig
def get_exemplary_embedding(self) -> List[float]:
return self.embed_query(query="Q")
def num_of_dimensions(self):
exemplary_embedding = self.get_exemplary_embedding()
return np.shape(exemplary_embedding)
def is_unit_vector(self):
exemplary_embedding = self.get_exemplary_embedding()
return np.isclose(np.linalg.norm(exemplary_embedding), 1.0)
def embed_query(self, query):
client = self.config.get_client()
return client.embed_query(str(query))
def embed_documents(self, elements: List[Element]) -> List[Element]:
client = self.config.get_client()
embeddings = client.embed_documents([str(e) for e in elements])
elements_with_embeddings = self._add_embeddings_to_elements(elements, embeddings)
return elements_with_embeddings
def _add_embeddings_to_elements(self, elements, embeddings) -> List[Element]:
assert len(elements) == len(embeddings)
elements_w_embedding = []
for i, element in enumerate(elements):
element.embeddings = embeddings[i]
elements_w_embedding.append(element)
return elements
+39
View File
@@ -0,0 +1,39 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import List, Tuple
from pydantic import BaseModel
from unstructured.documents.elements import Element
class EmbeddingConfig(BaseModel):
pass
@dataclass
class BaseEmbeddingEncoder(ABC):
config: EmbeddingConfig
@abstractmethod
def initialize(self):
"""Initializes the embedding encoder class. Should also validate the instance
is properly configured: e.g., embed a single a element"""
@property
@abstractmethod
def num_of_dimensions(self) -> Tuple[int]:
"""Number of dimensions for the embedding vector."""
@property
@abstractmethod
def is_unit_vector(self) -> bool:
"""Denotes if the embedding vector is a unit vector."""
@abstractmethod
def embed_documents(self, elements: List[Element]) -> List[Element]:
pass
@abstractmethod
def embed_query(self, query: str) -> List[float]:
pass
+178
View File
@@ -0,0 +1,178 @@
import os
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, List, Optional
import numpy as np
from pydantic import Field, SecretStr
from unstructured.documents.elements import Element
from unstructured.embed.interfaces import BaseEmbeddingEncoder, EmbeddingConfig
from unstructured.utils import requires_dependencies
USER_AGENT = "@mixedbread-ai/unstructured"
BATCH_SIZE = 128
TIMEOUT = 60
MAX_RETRIES = 3
ENCODING_FORMAT = "float"
TRUNCATION_STRATEGY = "end"
if TYPE_CHECKING:
from mixedbread_ai.client import MixedbreadAI
from mixedbread_ai.core import RequestOptions
class MixedbreadAIEmbeddingConfig(EmbeddingConfig):
"""
Configuration class for Mixedbread AI Embedding Encoder.
Attributes:
api_key (str): API key for accessing Mixedbread AI..
model_name (str): Name of the model to use for embeddings.
"""
api_key: SecretStr = Field(
default_factory=lambda: SecretStr(os.environ.get("MXBAI_API_KEY")),
)
model_name: str = Field(
default="mixedbread-ai/mxbai-embed-large-v1",
)
@requires_dependencies(
["mixedbread_ai"],
extras="embed-mixedbreadai",
)
def get_client(self) -> "MixedbreadAI":
"""
Create the Mixedbread AI client.
Returns:
MixedbreadAI: Initialized client.
"""
from mixedbread_ai.client import MixedbreadAI
return MixedbreadAI(
api_key=self.api_key.get_secret_value(),
)
@dataclass
class MixedbreadAIEmbeddingEncoder(BaseEmbeddingEncoder):
"""
Embedding encoder for Mixedbread AI.
Attributes:
config (MixedbreadAIEmbeddingConfig): Configuration for the embedding encoder.
"""
config: MixedbreadAIEmbeddingConfig
_exemplary_embedding: Optional[List[float]] = field(init=False, default=None)
_request_options: Optional["RequestOptions"] = field(init=False, default=None)
def get_exemplary_embedding(self) -> List[float]:
"""Get an exemplary embedding to determine dimensions and unit vector status."""
return self._embed(["Q"])[0]
def initialize(self):
if self.config.api_key is None:
raise ValueError(
"The Mixedbread AI API key must be specified."
+ "You either pass it in the constructor using 'api_key'"
+ "or via the 'MXBAI_API_KEY' environment variable."
)
from mixedbread_ai.core import RequestOptions
self._request_options = RequestOptions(
max_retries=MAX_RETRIES,
timeout_in_seconds=TIMEOUT,
additional_headers={"User-Agent": USER_AGENT},
)
@property
def num_of_dimensions(self):
"""Get the number of dimensions for the embeddings."""
exemplary_embedding = self.get_exemplary_embedding()
return np.shape(exemplary_embedding)
@property
def is_unit_vector(self) -> bool:
"""Check if the embedding is a unit vector."""
exemplary_embedding = self.get_exemplary_embedding()
return np.isclose(np.linalg.norm(exemplary_embedding), 1.0)
def _embed(self, texts: List[str]) -> List[List[float]]:
"""
Embed a list of texts using the Mixedbread AI API.
Args:
texts (List[str]): List of texts to embed.
Returns:
List[List[float]]: List of embeddings.
"""
batch_size = BATCH_SIZE
batch_itr = range(0, len(texts), batch_size)
responses = []
client = self.config.get_client()
for i in batch_itr:
batch = texts[i : i + batch_size]
response = client.embeddings(
model=self.config.model_name,
normalized=True,
encoding_format=ENCODING_FORMAT,
truncation_strategy=TRUNCATION_STRATEGY,
request_options=self._request_options,
input=batch,
)
responses.append(response)
return [item.embedding for response in responses for item in response.data]
@staticmethod
def _add_embeddings_to_elements(
elements: List[Element], embeddings: List[List[float]]
) -> List[Element]:
"""
Add embeddings to elements.
Args:
elements (List[Element]): List of elements.
embeddings (List[List[float]]): List of embeddings.
Returns:
List[Element]: Elements with embeddings added.
"""
assert len(elements) == len(embeddings)
elements_w_embedding = []
for i, element in enumerate(elements):
element.embeddings = embeddings[i]
elements_w_embedding.append(element)
return elements
def embed_documents(self, elements: List[Element]) -> List[Element]:
"""
Embed a list of document elements.
Args:
elements (List[Element]): List of document elements.
Returns:
List[Element]: Elements with embeddings.
"""
embeddings = self._embed([str(e) for e in elements])
return self._add_embeddings_to_elements(elements, embeddings)
def embed_query(self, query: str) -> List[float]:
"""
Embed a query string.
Args:
query (str): Query string to embed.
Returns:
List[float]: Embedding of the query.
"""
return self._embed([query])[0]
+69
View File
@@ -0,0 +1,69 @@
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, List, Optional
import numpy as np
from pydantic import Field, SecretStr
from unstructured.documents.elements import (
Element,
)
from unstructured.embed.interfaces import BaseEmbeddingEncoder, EmbeddingConfig
from unstructured.utils import requires_dependencies
if TYPE_CHECKING:
from openai import OpenAI
class OctoAiEmbeddingConfig(EmbeddingConfig):
api_key: SecretStr
model_name: str = Field(default="thenlper/gte-large")
base_url: str = Field(default="https://text.octoai.run/v1")
@requires_dependencies(
["openai", "tiktoken"],
extras="embed-octoai",
)
def get_client(self) -> "OpenAI":
"""Creates an OpenAI python client to embed elements. Uses the OpenAI SDK."""
from openai import OpenAI
return OpenAI(api_key=self.api_key.get_secret_value(), base_url=self.base_url)
@dataclass
class OctoAIEmbeddingEncoder(BaseEmbeddingEncoder):
config: OctoAiEmbeddingConfig
# Uses the OpenAI SDK
_exemplary_embedding: Optional[List[float]] = field(init=False, default=None)
def get_exemplary_embedding(self) -> List[float]:
return self.embed_query("Q")
def initialize(self):
pass
def num_of_dimensions(self):
exemplary_embedding = self.get_exemplary_embedding()
return np.shape(exemplary_embedding)
def is_unit_vector(self):
exemplary_embedding = self.get_exemplary_embedding()
return np.isclose(np.linalg.norm(exemplary_embedding), 1.0)
def embed_query(self, query):
client = self.config.get_client()
response = client.embeddings.create(input=str(query), model=self.config.model_name)
return response.data[0].embedding
def embed_documents(self, elements: List[Element]) -> List[Element]:
embeddings = [self.embed_query(e) for e in elements]
elements_with_embeddings = self._add_embeddings_to_elements(elements, embeddings)
return elements_with_embeddings
def _add_embeddings_to_elements(self, elements, embeddings) -> List[Element]:
assert len(elements) == len(embeddings)
elements_w_embedding = []
for i, element in enumerate(elements):
element.embeddings = embeddings[i]
elements_w_embedding.append(element)
return elements
+67
View File
@@ -0,0 +1,67 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING, List
import numpy as np
from pydantic import Field, SecretStr
from unstructured.documents.elements import (
Element,
)
from unstructured.embed.interfaces import BaseEmbeddingEncoder, EmbeddingConfig
from unstructured.utils import requires_dependencies
if TYPE_CHECKING:
from langchain_openai.embeddings import OpenAIEmbeddings
class OpenAIEmbeddingConfig(EmbeddingConfig):
api_key: SecretStr
model_name: str = Field(default="text-embedding-ada-002")
@requires_dependencies(["langchain_openai"], extras="openai")
def get_client(self) -> "OpenAIEmbeddings":
"""Creates a langchain OpenAI python client to embed elements."""
from langchain_openai import OpenAIEmbeddings
openai_client = OpenAIEmbeddings(
openai_api_key=self.api_key.get_secret_value(),
model=self.model_name, # type: ignore
)
return openai_client
@dataclass
class OpenAIEmbeddingEncoder(BaseEmbeddingEncoder):
config: OpenAIEmbeddingConfig
def get_exemplary_embedding(self) -> List[float]:
return self.embed_query(query="Q")
def initialize(self):
pass
def num_of_dimensions(self):
exemplary_embedding = self.get_exemplary_embedding()
return np.shape(exemplary_embedding)
def is_unit_vector(self):
exemplary_embedding = self.get_exemplary_embedding()
return np.isclose(np.linalg.norm(exemplary_embedding), 1.0)
def embed_query(self, query):
client = self.config.get_client()
return client.embed_query(str(query))
def embed_documents(self, elements: List[Element]) -> List[Element]:
client = self.config.get_client()
embeddings = client.embed_documents([str(e) for e in elements])
elements_with_embeddings = self._add_embeddings_to_elements(elements, embeddings)
return elements_with_embeddings
def _add_embeddings_to_elements(self, elements, embeddings) -> List[Element]:
assert len(elements) == len(embeddings)
elements_w_embedding = []
for i, element in enumerate(elements):
element.embeddings = embeddings[i]
elements_w_embedding.append(element)
return elements
+76
View File
@@ -0,0 +1,76 @@
# type: ignore
import json
import os
from dataclasses import dataclass
from typing import TYPE_CHECKING, List, Optional
import numpy as np
from pydantic import Field, SecretStr
from unstructured.documents.elements import (
Element,
)
from unstructured.embed.interfaces import BaseEmbeddingEncoder, EmbeddingConfig
from unstructured.utils import FileHandler, requires_dependencies
if TYPE_CHECKING:
from langchain_google_vertexai import VertexAIEmbeddings
class VertexAIEmbeddingConfig(EmbeddingConfig):
api_key: SecretStr
model_name: Optional[str] = Field(default="textembedding-gecko@001")
def register_application_credentials(self):
application_credentials_path = os.path.join("/tmp", "google-vertex-app-credentials.json")
credentials_file = FileHandler(application_credentials_path)
credentials_file.write_file(json.dumps(json.loads(self.api_key.get_secret_value())))
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = application_credentials_path
@requires_dependencies(
["langchain", "langchain_google_vertexai"],
extras="embed-vertexai",
)
def get_client(self) -> "VertexAIEmbeddings":
"""Creates a Langchain VertexAI python client to embed elements."""
from langchain_google_vertexai import VertexAIEmbeddings
self.register_application_credentials()
vertexai_client = VertexAIEmbeddings(model_name=self.model_name)
return vertexai_client
@dataclass
class VertexAIEmbeddingEncoder(BaseEmbeddingEncoder):
config: VertexAIEmbeddingConfig
def get_exemplary_embedding(self) -> List[float]:
return self.embed_query(query="A sample query.")
def initialize(self):
pass
def num_of_dimensions(self):
exemplary_embedding = self.get_exemplary_embedding()
return np.shape(exemplary_embedding)
def is_unit_vector(self):
exemplary_embedding = self.get_exemplary_embedding()
return np.isclose(np.linalg.norm(exemplary_embedding), 1.0)
def embed_query(self, query):
client = self.config.get_client()
result = client.embed_query(str(query))
return result
def embed_documents(self, elements: List[Element]) -> List[Element]:
client = self.config.get_client()
embeddings = client.embed_documents([str(e) for e in elements])
elements_with_embeddings = self._add_embeddings_to_elements(elements, embeddings)
return elements_with_embeddings
def _add_embeddings_to_elements(self, elements, embeddings) -> List[Element]:
assert len(elements) == len(embeddings)
for element, embedding in zip(elements, embeddings):
element.embeddings = embedding
return elements
+237
View File
@@ -0,0 +1,237 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING, Iterable, List, Optional
import numpy as np
from pydantic import Field, SecretStr
from unstructured.documents.elements import Element
from unstructured.embed.interfaces import BaseEmbeddingEncoder, EmbeddingConfig
from unstructured.utils import requires_dependencies
if TYPE_CHECKING:
from voyageai import Client
# Token limits for different VoyageAI models
VOYAGE_TOTAL_TOKEN_LIMITS = {
"voyage-context-3": 32_000,
"voyage-3.5-lite": 1_000_000,
"voyage-3.5": 320_000,
"voyage-2": 320_000,
"voyage-02": 320_000,
"voyage-3-large": 120_000,
"voyage-code-3": 120_000,
"voyage-large-2-instruct": 120_000,
"voyage-finance-2": 120_000,
"voyage-multilingual-2": 120_000,
"voyage-law-2": 120_000,
"voyage-large-2": 120_000,
"voyage-3": 120_000,
"voyage-3-lite": 120_000,
"voyage-code-2": 120_000,
"voyage-3-m-exp": 120_000,
"voyage-multimodal-3": 120_000,
}
# Batch size for embedding requests (max documents per batch)
MAX_BATCH_SIZE = 1000
class VoyageAIEmbeddingConfig(EmbeddingConfig):
api_key: SecretStr
model_name: str
show_progress_bar: bool = False
batch_size: Optional[int] = Field(default=None)
truncation: Optional[bool] = Field(default=None)
output_dimension: Optional[int] = Field(default=None)
@requires_dependencies(
["voyageai"],
extras="embed-voyageai",
)
def get_client(self) -> "Client":
"""Creates a VoyageAI python client to embed elements."""
from voyageai import Client
return Client(
api_key=self.api_key.get_secret_value(),
)
def get_token_limit(self) -> int:
"""Get the token limit for the current model."""
return VOYAGE_TOTAL_TOKEN_LIMITS.get(self.model_name, 120_000)
@dataclass
class VoyageAIEmbeddingEncoder(BaseEmbeddingEncoder):
config: VoyageAIEmbeddingConfig
def get_exemplary_embedding(self) -> List[float]:
return self.embed_query(query="A sample query.")
def initialize(self):
pass
@property
def num_of_dimensions(self) -> tuple[int, ...]:
exemplary_embedding = self.get_exemplary_embedding()
return np.shape(exemplary_embedding)
@property
def is_unit_vector(self) -> bool:
exemplary_embedding = self.get_exemplary_embedding()
return np.isclose(np.linalg.norm(exemplary_embedding), 1.0)
def _is_context_model(self) -> bool:
"""Check if the model is a contextualized embedding model."""
return "context" in self.config.model_name
def _build_batches(self, texts: List[str], client: "Client") -> Iterable[List[str]]:
"""
Generate batches of texts based on token limits.
Args:
texts: List of texts to batch.
client: VoyageAI client instance to use for tokenization.
Yields:
Batches of texts as lists.
"""
if not texts:
return
max_tokens_per_batch = self.config.get_token_limit()
current_batch: List[str] = []
current_batch_tokens = 0
# Tokenize all texts in one API call
all_token_lists = client.tokenize(texts, model=self.config.model_name)
token_counts = [len(tokens) for tokens in all_token_lists]
for i, text in enumerate(texts):
n_tokens = token_counts[i]
# Check if adding this text would exceed limits
if current_batch and (
len(current_batch) >= MAX_BATCH_SIZE
or (current_batch_tokens + n_tokens > max_tokens_per_batch)
):
# Yield the current batch and start a new one
yield current_batch
current_batch = []
current_batch_tokens = 0
current_batch.append(text)
current_batch_tokens += n_tokens
# Yield the last batch (always has at least one text)
if current_batch:
yield current_batch
def _embed_batch(
self, batch: List[str], client: "Client", input_type: str = "document"
) -> List[List[float]]:
"""
Embed a batch of texts using the appropriate method for the model.
Args:
batch: List of texts to embed.
client: VoyageAI client instance to use for embedding.
input_type: Type of input ("document" or "query").
Returns:
List of embedding vectors.
"""
if self._is_context_model():
result = client.contextualized_embed(
inputs=[batch],
model=self.config.model_name,
input_type=input_type,
output_dimension=self.config.output_dimension,
)
return [list(emb) for emb in result.results[0].embeddings]
else:
result = client.embed(
texts=batch,
model=self.config.model_name,
input_type=input_type,
truncation=self.config.truncation,
output_dimension=self.config.output_dimension,
)
return [list(emb) for emb in result.embeddings]
def embed_documents(self, elements: List[Element]) -> List[Element]:
"""
Embed documents with automatic batching based on token limits.
Args:
elements: List of elements to embed.
Returns:
List of elements with embeddings added.
"""
if not elements:
return []
client = self.config.get_client()
texts = [str(e) for e in elements]
all_embeddings: List[List[float]] = []
# Process each batch
batches = list(self._build_batches(texts, client))
if self.config.show_progress_bar:
try:
from tqdm.auto import tqdm # type: ignore
batches = tqdm(batches, desc="Embedding batches")
except ImportError as e:
raise ImportError(
"Must have tqdm installed if `show_progress_bar` is set to True. "
"Please install with `pip install tqdm`."
) from e
for batch in batches:
batch_embeddings = self._embed_batch(batch, client, input_type="document")
all_embeddings.extend(batch_embeddings)
return self._add_embeddings_to_elements(elements, all_embeddings)
def embed_query(self, query: str) -> List[float]:
"""
Embed a single query string.
Args:
query: Query string to embed.
Returns:
Embedding vector.
"""
client = self.config.get_client()
batch_embeddings = self._embed_batch([query], client, input_type="query")
return batch_embeddings[0]
def count_tokens(self, texts: List[str]) -> List[int]:
"""
Count tokens for the given texts.
Args:
texts: List of texts to count tokens for.
Returns:
List of token counts for each text.
"""
if not texts:
return []
client = self.config.get_client()
token_lists = client.tokenize(texts, model=self.config.model_name)
return [len(token_list) for token_list in token_lists]
@staticmethod
def _add_embeddings_to_elements(elements, embeddings) -> List[Element]:
assert len(elements) == len(embeddings)
elements_w_embedding = []
for i, element in enumerate(elements):
element.embeddings = embeddings[i]
elements_w_embedding.append(element)
return elements
+27
View File
@@ -0,0 +1,27 @@
class PageCountExceededError(ValueError):
"""Error raised, when number of pages exceeds pdf_hi_res_max_pages limit."""
def __init__(self, document_pages: int, pdf_hi_res_max_pages: int):
self.document_pages = document_pages
self.pdf_hi_res_max_pages = pdf_hi_res_max_pages
self.message = (
f"Maximum number of PDF file pages exceeded - "
f"pages={document_pages}, maximum={pdf_hi_res_max_pages}."
)
super().__init__(self.message)
class UnprocessableEntityError(Exception):
"""Error raised when a file is not valid."""
class DecompressedSizeExceededError(ValueError):
"""Error raised when decompressed data exceeds the maximum size limit."""
def __init__(self, max_size: int):
self.max_size = max_size
self.message = (
f"Decompressed data exceeds maximum allowed size of {max_size} bytes "
f"({max_size / (1024 * 1024):.1f} MB)."
)
super().__init__(self.message)
View File
+150
View File
@@ -0,0 +1,150 @@
from typing import IO, Optional, Tuple, Union
from charset_normalizer import detect
from unstructured.errors import UnprocessableEntityError
from unstructured.partition.common.common import convert_to_bytes
ENCODE_REC_THRESHOLD = 0.8
# popular encodings from https://en.wikipedia.org/wiki/Popularity_of_text_encodings
COMMON_ENCODINGS = [
"utf_8",
"iso_8859_1",
"iso_8859_6",
"iso_8859_8",
"ascii",
"big5",
"utf_16",
"utf_16_be",
"utf_16_le",
"utf_32",
"utf_32_be",
"utf_32_le",
"euc_jis_2004",
"euc_jisx0213",
"euc_jp",
"euc_kr",
"gb18030",
"shift_jis",
"shift_jis_2004",
"shift_jisx0213",
]
def format_encoding_str(encoding: str) -> str:
"""Format input encoding string (e.g., `utf-8`, `iso-8859-1`, etc).
Parameters
----------
encoding
The encoding string to be formatted (e.g., `UTF-8`, `utf_8`, `ISO-8859-1`, `iso_8859_1`,
etc).
"""
formatted_encoding = encoding.lower().replace("_", "-")
# Special case for Arabic and Hebrew charsets with directional annotations
annotated_encodings = ["iso-8859-6-i", "iso-8859-6-e", "iso-8859-8-i", "iso-8859-8-e"]
if formatted_encoding in annotated_encodings:
formatted_encoding = formatted_encoding[:-2] # remove the annotation
return formatted_encoding
def validate_encoding(encoding: str) -> bool:
"""Checks if an encoding string is valid. Helps to avoid errors in cases where
invalid encodings are extracted from malformed documents."""
for common_encoding in COMMON_ENCODINGS:
if format_encoding_str(common_encoding) == format_encoding_str(encoding):
return True
return False
def detect_file_encoding(
filename: str = "",
file: Optional[Union[bytes, IO[bytes]]] = None,
) -> Tuple[str, str]:
if filename:
with open(filename, "rb") as f:
byte_data = f.read()
elif file:
byte_data = convert_to_bytes(file)
else:
raise FileNotFoundError("No filename nor file were specified")
result = detect(byte_data)
encoding = result["encoding"]
confidence = result["confidence"]
if encoding is None or confidence is None or confidence < ENCODE_REC_THRESHOLD:
# Encoding detection failed, fallback to predefined encodings
for enc in COMMON_ENCODINGS:
try:
if filename:
with open(filename, encoding=enc) as f:
file_text = f.read()
else:
file_text = byte_data.decode(enc)
encoding = enc
break
except (UnicodeDecodeError, UnicodeError):
continue
else:
# NOTE: Use UnprocessableEntityError instead of UnicodeDecodeError to avoid
# logging the entire file content. UnicodeDecodeError automatically stores
# the complete input data, which can be problematic for large files.
raise UnprocessableEntityError(
"Unable to determine file encoding after trying all common encodings. "
"File may be corrupted or in an unsupported format."
) from None
else:
# NOTE: Catch UnicodeDecodeError to avoid logging the entire file content.
# UnicodeDecodeError automatically stores the complete input data in its
# 'object' attribute, which can cause issues with large files in logging
# and error reporting systems.
try:
file_text = byte_data.decode(encoding)
except (UnicodeDecodeError, UnicodeError):
raise UnprocessableEntityError(
f"File encoding detection failed: detected '{encoding}' but decode failed. "
f"File may be corrupted or in an unsupported format."
) from None
formatted_encoding = format_encoding_str(encoding)
return formatted_encoding, file_text
def read_txt_file(
filename: str = "",
file: Optional[Union[bytes, IO[bytes]]] = None,
encoding: Optional[str] = None,
) -> Tuple[str, str]:
"""Extracts document metadata from a plain text document."""
if filename:
if encoding:
formatted_encoding = format_encoding_str(encoding)
with open(filename, encoding=formatted_encoding) as f:
try:
file_text = f.read()
except (UnicodeDecodeError, UnicodeError) as error:
raise error
else:
formatted_encoding, file_text = detect_file_encoding(filename)
elif file:
if encoding:
formatted_encoding = format_encoding_str(encoding)
try:
file_content = file if isinstance(file, bytes) else file.read()
if isinstance(file_content, bytes):
file_text = file_content.decode(formatted_encoding)
else:
file_text = file_content
except (UnicodeDecodeError, UnicodeError) as error:
raise error
else:
formatted_encoding, file_text = detect_file_encoding(file=file)
else:
raise FileNotFoundError("No filename was specified")
return formatted_encoding, file_text
@@ -0,0 +1,81 @@
from __future__ import annotations
import os
import re
import tempfile
from typing import IO
from unstructured.errors import UnprocessableEntityError
from unstructured.partition.common.common import exactly_one
from unstructured.utils import requires_dependencies
@requires_dependencies(["pypandoc"])
def convert_file_to_text(filename: str, source_format: str, target_format: str) -> str:
"""Uses pandoc to convert the source document to a raw text string."""
import pypandoc
try:
text: str = pypandoc.convert_file(
filename, target_format, format=source_format, sandbox=True
)
except FileNotFoundError as err:
msg = (
f"Error converting the file to text. Ensure you have the pandoc package installed on"
f" your system. Installation instructions are available at"
f" https://pandoc.org/installing.html. The original exception text was:\n{err}"
)
raise FileNotFoundError(msg)
except RuntimeError as err:
err_str = str(err)
if source_format == "epub" and (
"Couldn't extract ePub file" in err_str
or "No entry on path" in err_str
or re.search(r"exitcode ['\"]?64['\"]?", err_str)
):
raise UnprocessableEntityError(f"Invalid EPUB file: {err_str}")
supported_source_formats, _ = pypandoc.get_pandoc_formats()
if source_format == "rtf" and source_format not in supported_source_formats:
additional_info = (
"Support for RTF files is not available in the current pandoc installation. "
"It was introduced in pandoc 2.14.2.\n"
"Reference: https://pandoc.org/releases.html#pandoc-2.14.2-2021-08-21"
)
else:
additional_info = ""
msg = (
f"{err}\n\n{additional_info}\n\n"
f"Current version of pandoc: {pypandoc.get_pandoc_version()}\n"
"Make sure you have the right version installed in your system. Please follow the"
" pandoc installation instructions in README.md to install the right version."
)
raise RuntimeError(msg)
return text
def convert_file_to_html_text_using_pandoc(
source_format: str, filename: str | None = None, file: IO[bytes] | None = None
) -> str:
"""Converts a document to HTML raw text.
Enables the doucment to be processed using `partition_html()`.
"""
exactly_one(filename=filename, file=file)
if file is not None:
with tempfile.TemporaryDirectory() as temp_dir_path:
tmp_file_path = os.path.join(temp_dir_path, f"tmp_file.{source_format}")
with open(tmp_file_path, "wb") as tmp_file:
tmp_file.write(file.read())
return convert_file_to_text(
filename=tmp_file_path, source_format=source_format, target_format="html"
)
assert filename is not None
return convert_file_to_text(
filename=filename, source_format=source_format, target_format="html"
)
+965
View File
@@ -0,0 +1,965 @@
"""Automatically detect file-type based on inspection of the file's contents.
Auto-detection proceeds via a sequence of strategies. The first strategy to confidently determine a
file-type returns that value. A strategy that is not applicable, either because it lacks the input
required or fails to determine a file-type, returns `None` and execution continues with the next
strategy.
`_FileTypeDetector` is the main object and implements the three strategies.
The three strategies are:
- Use MIME-type asserted by caller in the `content_type` argument.
- Guess a MIME-type using libmagic, falling back to the `filetype` package when libmagic is
unavailable.
- Map filename-extension to a `FileType` member.
A file that fails all three strategies is assigned the value `FileType.UNK`, for "unknown".
`_FileTypeDetectionContext` encapsulates the various arguments received by `detect_filetype()` and
provides values derived from them. This object is immutable and can be passed to delegates of
`_FileTypeDetector` to provide whatever context they need on the current detection instance.
`_FileTypeDetector` delegates to _differentiator_ objects like `_ZipFileDifferentiator` for
specialized discrimination and/or confirmation of ambiguous or frequently mis-identified
MIME-types. Additional differentiators are planned, one for `application/x-ole-storage`
(DOC, PPT, XLS, and MSG file-types) and perhaps others.
"""
from __future__ import annotations
import contextlib
import functools
import importlib.util
import io
import json
import os
import re
import tempfile
import zipfile
from functools import cached_property
from typing import IO, Callable, Iterator, Optional, cast
import filetype as ft
from olefile import OleFileIO
from oxmsg.storage import Storage
from typing_extensions import ParamSpec
from unstructured.documents.elements import Element
from unstructured.file_utils.encoding import detect_file_encoding, format_encoding_str
from unstructured.file_utils.model import FileType
from unstructured.logger import logger
from unstructured.nlp.patterns import EMAIL_HEAD_RE, LIST_OF_DICTS_PATTERN
from unstructured.partition.common.common import add_element_metadata, exactly_one
from unstructured.partition.common.metadata import set_element_hierarchy
from unstructured.utils import get_call_args_applying_defaults
_JSON_DISAMBIGUATION_CHUNK_SIZE = 8192
_JSON_DISAMBIGUATION_MAX_CHARS = 1024 * 1024
try:
importlib.import_module("magic")
LIBMAGIC_AVAILABLE = True
except ImportError:
LIBMAGIC_AVAILABLE = False # pyright: ignore[reportConstantRedefinition]
def detect_filetype(
file_path: str | None = None,
file: IO[bytes] | tempfile.SpooledTemporaryFile | None = None,
encoding: str | None = None,
content_type: str | None = None,
metadata_file_path: Optional[str] = None,
) -> FileType:
"""Determine file-type of specified file using libmagic and/or fallback methods.
One of `file_path` or `file` must be specified. A `file_path` that does not
correspond to a file on the filesystem raises `ValueError`.
Args:
content_type: MIME-type of document-source, when already known. Providing
a value for this argument disables auto-detection unless it does not map
to a FileType member or is ambiguous, in which case it is ignored.
encoding: Only used for textual file-types. When omitted, `utf-8` is
assumed. Should generally be omitted except to resolve a problem with
textual file-types like HTML.
metadata_file_path: Only used when `file` is provided and then only as a
source for a filename-extension that may be needed as a secondary
content-type indicator. Ignored with the document is specified using
`file_path`.
Returns:
A member of the `FileType` enumeration, `FileType.UNK` when the file type
could not be determined or is not supported.
Raises:
ValueError: when:
- `file_path` is specified but does not correspond to a file on the
filesystem.
- Neither `file_path` nor `file` were specified.
"""
file_buffer = file
if isinstance(file, tempfile.SpooledTemporaryFile):
file_buffer = io.BytesIO(file.read())
file.seek(0)
ctx = _FileTypeDetectionContext.new(
file_path=file_path,
file=file_buffer,
encoding=encoding,
content_type=content_type,
metadata_file_path=metadata_file_path,
)
return _FileTypeDetector.file_type(ctx)
def is_json_processable(
filename: Optional[str] = None,
file: Optional[IO[bytes]] = None,
file_text: Optional[str] = None,
encoding: Optional[str] = "utf-8",
) -> bool:
"""True when file looks like a JSON array of objects.
Uses regex on a file prefix, so not entirely reliable but good enough if you already know the
file is JSON.
"""
exactly_one(filename=filename, file=file, file_text=file_text)
if file_text is None:
file_text = _FileTypeDetectionContext.new(
file_path=filename, file=file, encoding=encoding
).text_head
return re.match(LIST_OF_DICTS_PATTERN, file_text) is not None
def is_ndjson_processable(
filename: Optional[str] = None,
file: Optional[IO[bytes]] = None,
file_text: Optional[str] = None,
encoding: Optional[str] = "utf-8",
allow_truncated_single_line: bool = False,
) -> bool:
"""True when file looks like newline-delimited JSON objects.
NDJSON is a sequence of one JSON value per line, conventionally an object on each line. A
payload that parses as a single JSON value (e.g. a multi-line `{...}` object or a `[...]`
array) is *not* NDJSON and must not be matched here, otherwise `partition_ndjson` will fail
later when it splits the text by lines and tries to parse each fragment.
"""
exactly_one(filename=filename, file=file, file_text=file_text)
allow_truncated = allow_truncated_single_line
if file_text is None:
file_text, allow_truncated = _FileTypeDetectionContext.new(
file_path=filename, file=file, encoding=encoding
).json_disambiguation_text
text = file_text.lstrip()
if not text or not text.startswith("{"):
return False
newline_idx = text.find("\n")
if newline_idx == -1:
# Single-line input. A complete `{...}` parses as a dict and is treated as 1-record
# NDJSON (existing tests and `partition_ndjson` rely on this). When the caller knows this
# is a truncated first line from a JSON-like payload, a parse failure is still compatible
# with a long 1-record NDJSON payload.
try:
return isinstance(json.loads(text), dict)
except json.JSONDecodeError:
return allow_truncated
# Multi-line input. NDJSON requires each record to be on its own line, so the first line
# must independently parse as a JSON object. A pretty-printed single JSON object has its
# first line be just `{` (or similar fragment) which won't parse alone — that's how we
# distinguish it from real NDJSON.
first_line = text[:newline_idx].rstrip()
if not first_line:
return False
try:
return isinstance(json.loads(first_line), dict)
except json.JSONDecodeError:
return False
class _FileTypeDetector:
"""Determines file type from a variety of possible inputs."""
def __init__(self, ctx: _FileTypeDetectionContext):
self._ctx = ctx
@classmethod
def file_type(cls, ctx: _FileTypeDetectionContext) -> FileType:
"""Detect file-type of document-source described by `ctx`."""
return cls(ctx)._file_type
@property
def _file_type(self) -> FileType:
"""FileType member corresponding to this document source."""
# -- An explicit content-type most commonly asserted by the client/SDK and is therefore
# -- inherently unreliable. On the other hand, binary file-types can be detected with 100%
# -- accuracy. So start with binary types and only then consider an asserted content-type,
# -- generally as a last resort.
if (
( # strategy 1: most binary types can be detected with 100% accuracy
predicted_file_type := self._known_binary_file_type
)
or ( # strategy 2: use content-type asserted by caller
predicted_file_type := self._file_type_from_content_type
)
or ( # strategy 3: guess MIME-type using libmagic and use that
predicted_file_type := self._file_type_from_guessed_mime_type
)
or ( # strategy 4: use filename-extension, like ".docx" -> FileType.DOCX
predicted_file_type := self._file_type_from_file_extension
)
):
result_file_type = predicted_file_type
else:
# give up and report FileType.UNK
result_file_type = FileType.UNK
if result_file_type == FileType.JSON:
# edge case where JSON/NDJSON content without file extension
# (magic lib can't distinguish them)
result_file_type = self._disambiguate_json_file_type
return result_file_type
@property
def _known_binary_file_type(self) -> FileType | None:
"""Detect file-type for binary types we can positively detect."""
if file_type := _OleFileDetector.file_type(self._ctx):
return file_type
self._ctx.rule_out_cfb_content_types()
if file_type := _ZipFileDetector.file_type(self._ctx):
return file_type
self._ctx.rule_out_zip_content_types()
return None
@property
def _file_type_from_content_type(self) -> FileType | None:
"""Map passed content-type argument to a file-type, subject to certain rules."""
# -- when no content-type was asserted by caller, this strategy is not applicable --
if not self._ctx.content_type:
return None
# -- otherwise we trust the passed `content_type` as long as `FileType` recognizes it --
return FileType.from_mime_type(self._ctx.content_type)
@property
def _disambiguate_json_file_type(self) -> FileType:
"""Disambiguate JSON/NDJSON file-type based on file contents.
NDJSON is detected first because it has the strictest signature (multiple JSON values
separated by newlines, with the first line independently parsable). Anything else that
libmagic flagged as JSON is classified as `FileType.JSON`; the JSON partitioner has its
own `is_json_processable` schema check and will reject non-conforming payloads with a
clear error.
"""
file_text, allow_truncated_single_line = self._ctx.json_disambiguation_text
if is_ndjson_processable(
file_text=file_text,
allow_truncated_single_line=allow_truncated_single_line,
):
return FileType.NDJSON
return FileType.JSON
@property
def _file_type_from_guessed_mime_type(self) -> FileType | None:
"""FileType based on auto-detection of MIME-type by libmagic.
In some cases refinements are necessary on the magic-derived MIME-types. This process
includes applying those rules, most of which are accumulated through practical experience.
"""
mime_type = self._ctx.mime_type
extension = self._ctx.extension
# -- when libmagic is not installed, the `filetype` package is used instead.
# -- `filetype.guess()` returns `None` for file-types it does not support, which
# -- unfortunately includes all the textual file-types like CSV, EML, HTML, MD, RST, RTF,
# -- TSV, and TXT. When we have no guessed MIME-type, this strategy is not applicable.
if mime_type is None:
return None
if mime_type.endswith("xml"):
return FileType.HTML if extension in (".html", ".htm") else FileType.XML
if differentiator := _TextFileDifferentiator.applies(self._ctx):
return differentiator.file_type
# -- All source-code files (e.g. *.py, *.js) are classified as plain text for the moment --
if self._ctx.has_code_mime_type:
return FileType.TXT
if mime_type.endswith("empty"):
return FileType.EMPTY
if mime_type.endswith("json") and self._ctx.extension == ".ndjson":
return FileType.NDJSON
# -- if no more-specific rules apply, use the MIME-type -> FileType mapping when present --
file_type = FileType.from_mime_type(mime_type)
if file_type != FileType.UNK:
return file_type
# -- on some environments libmagic can return a generic/unhelpful MIME-type
# -- like octet-stream") for files that the `filetype` package identify.
# -- when that happens we retry using `filetype` `FileType.UNK` results.
if LIBMAGIC_AVAILABLE:
fallback_mime_type = (
ft.guess_mime(self._ctx.file_path)
if self._ctx.file_path
else ft.guess_mime(self._ctx.file_head)
)
fallback_file_type = FileType.from_mime_type(fallback_mime_type)
if fallback_file_type and fallback_file_type != FileType.UNK:
return fallback_file_type
return None
@cached_property
def _file_type_from_file_extension(self) -> FileType | None:
"""Determine file-type from filename extension.
Returns `None` when no filename is available or when the extension does not map to a
supported file-type.
"""
return FileType.from_extension(self._ctx.extension)
class _FileTypeDetectionContext:
"""Provides all arguments to auto-file detection and values derived from them.
NOTE that `._content_type` is mutable via `.rule_out_*_content_types()` methods, so it should
not be assumed to be a constant value across those calls.
This keeps computation of derived values out of the file-detection code but more importantly
allows the main filetype-detector to pass the full context to any delegates without coupling
itself to which values it might need.
"""
def __init__(
self,
file_path: str | None = None,
*,
file: IO[bytes] | None = None,
encoding: str | None = None,
content_type: str | None = None,
metadata_file_path: str | None = None,
):
self._file_path_arg = file_path
self._file_arg = file
self._encoding_arg = encoding
self._content_type = content_type
self._metadata_file_path = metadata_file_path
@classmethod
def new(
cls,
*,
file_path: str | None,
file: IO[bytes] | None,
encoding: str | None,
content_type: str | None = None,
metadata_file_path: str | None = None,
) -> _FileTypeDetectionContext:
self = cls(
file_path=file_path,
file=file,
encoding=encoding,
content_type=content_type,
metadata_file_path=metadata_file_path,
)
self._validate()
return self
@property
def content_type(self) -> str | None:
"""MIME-type asserted by caller; not based on inspection of file by this process.
Would commonly occur when the file was downloaded via HTTP and a `"Content-Type:` header was
present on the response. These are often ambiguous and sometimes just wrong so get some
further verification. All lower-case when not `None`.
"""
# -- Note `._content_type` is mutable via `.invalidate_content_type()` so this cannot be a
# -- `@cached_property`.
return self._content_type.lower() if self._content_type else None
@cached_property
def encoding(self) -> str:
"""Character-set used to encode text of this file.
Relevant for textual file-types only, like HTML, TXT, JSON, etc.
"""
return format_encoding_str(self._encoding_arg or "utf-8")
@cached_property
def extension(self) -> str:
"""Best filename-extension we can muster, "" when there is no available source."""
# -- get from file_path, or file when it has a name (path) --
with self.open() as file:
if hasattr(file, "name") and file.name:
return os.path.splitext(file.name)[1].lower()
# -- otherwise use metadata file-path when provided --
if file_path := self._metadata_file_path:
return os.path.splitext(file_path)[1].lower()
# -- otherwise empty str means no extension, same as a path like "a/b/name-no-ext" --
return ""
@cached_property
def file_head(self) -> bytes:
"""The initial bytes of the file to be recognized, for use with libmagic detection."""
with self.open() as file:
return file.read(8192)
@cached_property
def file_path(self) -> str | None:
"""Filesystem path to file to be inspected, when provided on call.
None when the caller specified the source as a file-like object instead. Useful for user
feedback on an error, but users of context should have little use for it otherwise.
"""
if (file_path := self._file_path_arg) is None:
return None
return os.path.realpath(file_path) if os.path.islink(file_path) else file_path
@cached_property
def has_code_mime_type(self) -> bool:
"""True when `mime_type` plausibly indicates a programming language source-code file."""
mime_type = self.mime_type
if mime_type is None:
return False
# -- check Go separately to avoid matching other MIME type containing "go" --
if mime_type == "text/x-go":
return True
return any(
lang in mime_type
for lang in [
"c#",
"c++",
"cpp",
"csharp",
"java",
"javascript",
"php",
"python",
"ruby",
"swift",
"typescript",
]
)
@cached_property
def is_zipfile(self) -> bool:
"""True when file is a Zip archive."""
with self.open() as file:
return zipfile.is_zipfile(file)
@cached_property
def mime_type(self) -> str | None:
"""The best MIME-type we can get from `magic` (or `filetype` package).
A `str` return value is always in lower-case.
"""
file_path = self.file_path
if LIBMAGIC_AVAILABLE:
import magic
magic_mime = (
magic.from_file(file_path, mime=True)
if file_path
else magic.from_buffer(self.file_head, mime=True)
)
magic_mime = magic_mime.lower() if magic_mime else None
# When libmagic returns None or "application/octet-stream", try the filetype package
# (magic-byte signatures) for formats libmagic often mis-detects (e.g. BMP, HEIC, WAV).
if magic_mime and magic_mime != "application/octet-stream":
return magic_mime
ft_mime = ft.guess_mime(file_path) if file_path else ft.guess_mime(self.file_head)
if ft_mime:
return ft_mime.lower()
# filetype could not identify; same outcome as if we had not tried it.
return magic_mime
mime_type = ft.guess_mime(file_path) if file_path else ft.guess_mime(self.file_head)
if mime_type is None:
logger.warning(
"libmagic is unavailable but assists in filetype detection. Please consider"
" installing libmagic for better results."
)
return None
return mime_type.lower()
@contextlib.contextmanager
def open(self) -> Iterator[IO[bytes]]:
"""Encapsulates complexity of dealing with file-path or file-like-object.
Provides an `IO[bytes]` object as the "common-denominator" document source.
Must be used as a context manager using a `with` statement:
with self._file as file:
do things with file
File is guaranteed to be at read position 0 when called.
"""
if self.file_path:
with open(self.file_path, "rb") as f:
yield f
else:
file = self._file_arg
assert file is not None # -- guaranteed by `._validate()` --
file.seek(0)
yield file
def rule_out_cfb_content_types(self) -> None:
"""Invalidate content-type when a legacy MS-Office file-type is asserted.
Used before returning `None`; at that point we know the file is not one of these formats
so if the asserted `content-type` is a legacy MS-Office type we know it's wrong and should
not be used as a fallback later in the detection process.
"""
if FileType.from_mime_type(self._content_type) in (
FileType.DOC,
FileType.MSG,
FileType.PPT,
FileType.XLS,
):
self._content_type = None
def rule_out_zip_content_types(self) -> None:
"""Invalidate content-type when an MS-Office 2007+ file-type is asserted.
Used before returning `None`; at that point we know the file is not one of these formats
so if the asserted `content-type` is an MS-Office 2007+ type we know it's wrong and should
not be used as a fallback later in the detection process.
"""
if FileType.from_mime_type(self._content_type) in (
FileType.DOCX,
FileType.EPUB,
FileType.ODT,
FileType.PPTX,
FileType.XLSX,
FileType.ZIP,
):
self._content_type = None
@cached_property
def text_head(self) -> str:
"""The initial characters of the text file for use with text-format differentiation.
Raises:
UnicodeDecodeError if file cannot be read as text.
"""
# TODO: only attempts fallback character-set detection for file-path case, not for
# file-like object case. Seems like we should do both.
if file := self._file_arg:
file.seek(0)
content = file.read(4096)
file.seek(0)
return (
content
if isinstance(content, str)
else content.decode(encoding=self.encoding, errors="ignore")
)
file_path = self.file_path
assert file_path is not None # -- guaranteed by `._validate` --
try:
with open(file_path, encoding=self.encoding) as f:
return f.read(4096)
except UnicodeDecodeError:
encoding, _ = detect_file_encoding(filename=file_path)
with open(file_path, encoding=encoding) as f:
return f.read(4096)
@cached_property
def json_disambiguation_text(self) -> tuple[str, bool]:
"""Text prefix for JSON/NDJSON disambiguation and whether the first line was truncated."""
if file := self._file_arg:
file.seek(0)
content, first_line_truncated = self._read_until_newline_or_limit(file)
file.seek(0)
if isinstance(content, str):
return content, first_line_truncated
return content.decode(encoding=self.encoding, errors="ignore"), first_line_truncated
file_path = self.file_path
assert file_path is not None # -- guaranteed by `._validate` --
try:
with open(file_path, encoding=self.encoding) as f:
content, first_line_truncated = self._read_until_newline_or_limit(f)
assert isinstance(content, str)
return content, first_line_truncated
except UnicodeDecodeError:
encoding, _ = detect_file_encoding(filename=file_path)
with open(file_path, encoding=encoding) as f:
content, first_line_truncated = self._read_until_newline_or_limit(f)
assert isinstance(content, str)
return content, first_line_truncated
def _validate(self) -> None:
"""Raise if the context is invalid."""
if self.file_path and not os.path.isfile(self.file_path):
raise FileNotFoundError(f"no such file {self._file_path_arg}")
if not self.file_path and not self._file_arg:
raise ValueError("either `file_path` or `file` argument must be provided")
@staticmethod
def _read_until_newline_or_limit(file: IO) -> tuple[str | bytes, bool]:
"""Read through the first newline, stopping at a bounded prefix if none is found."""
chunks: list[str | bytes] = []
chars_read = 0
while chars_read < _JSON_DISAMBIGUATION_MAX_CHARS:
chars_to_read = min(
_JSON_DISAMBIGUATION_CHUNK_SIZE,
_JSON_DISAMBIGUATION_MAX_CHARS - chars_read,
)
chunk = file.read(chars_to_read)
if not chunk:
return _FileTypeDetectionContext._join_text_chunks(chunks), False
newline = b"\n" if isinstance(chunk, bytes) else "\n"
newline_idx = chunk.find(newline)
if newline_idx != -1:
chunks.append(chunk[: newline_idx + 1])
return _FileTypeDetectionContext._join_text_chunks(chunks), False
chunks.append(chunk)
chars_read += len(chunk)
return _FileTypeDetectionContext._join_text_chunks(chunks), True
@staticmethod
def _join_text_chunks(chunks: list[str | bytes]) -> str | bytes:
"""Join chunks without mixing text and bytes types."""
if chunks and isinstance(chunks[0], bytes):
return b"".join(cast(list[bytes], chunks))
return "".join(cast(list[str], chunks))
class _OleFileDetector:
"""Detect and differentiate a CFB file, aka. "OLE" file.
Compound File Binary Format (CFB), aka. OLE file, is use by Microsoft for legacy MS Office
files (DOC, PPT, XLS) as well as for Outlook MSG files.
"""
def __init__(self, ctx: _FileTypeDetectionContext):
self._ctx = ctx
@classmethod
def file_type(cls, ctx: _FileTypeDetectionContext) -> FileType | None:
"""Specific file-type when file is a CFB file, `None` otherwise."""
return cls(ctx)._file_type
@property
def _file_type(self) -> FileType | None:
"""Differentiated file-type for Microsoft Compound File Binary Format (CFBF).
Returns one of:
- `FileType.DOC`
- `FileType.PPT`
- `FileType.XLS`
- `FileType.MSG`
- `None` when the file is not one of these.
"""
# -- all CFB files share common magic number, start with that --
if not self._is_ole_file:
return None
# -- check storage contents of the ole file for file-type specific stream names --
if (ole_file_type := self._ole_file_type) is not None:
return ole_file_type
return None
@cached_property
def _is_ole_file(self) -> bool:
"""True when file has CFB magic first 8 bytes."""
with self._ctx.open() as file:
return file.read(8) == b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
@cached_property
def _ole_file_type(self) -> FileType | None:
with self._ctx.open() as f:
ole = OleFileIO(f) # pyright: ignore[reportUnknownVariableType]
root_storage = Storage.from_ole(ole) # pyright: ignore[reportUnknownMemberType]
for stream in root_storage.streams:
if stream.name == "WordDocument":
return FileType.DOC
elif stream.name == "PowerPoint Document":
return FileType.PPT
elif stream.name == "Workbook":
return FileType.XLS
elif stream.name == "__properties_version1.0":
return FileType.MSG
return None
class _TextFileDifferentiator:
"""Refine a textual file-type that may not be as specific as it could be."""
def __init__(self, ctx: _FileTypeDetectionContext):
self._ctx = ctx
@classmethod
def applies(cls, ctx: _FileTypeDetectionContext) -> _TextFileDifferentiator | None:
"""Constructs an instance, but only if this differentiator applies in `ctx`."""
mime_type = ctx.mime_type
return (
cls(ctx)
if mime_type and (mime_type == "message/rfc822" or mime_type.startswith("text"))
else None
)
@cached_property
def file_type(self) -> FileType:
"""Differentiated file-type for textual content.
Always produces a file-type, worst case that's `FileType.TXT` when nothing more specific
applies.
"""
extension = self._ctx.extension
if extension in [
".csv",
".eml",
".html",
".json",
".markdown",
".md",
".org",
".p7s",
".rst",
".rtf",
".tab",
".tsv",
]:
return FileType.from_extension(extension) or FileType.TXT
# NOTE(crag): for older versions of the OS libmagic package, such as is currently
# installed on the Unstructured docker image, .json files resolve to "text/plain"
# rather than "application/json". this corrects for that case.
if self._is_json:
return FileType.JSON
if self._is_csv:
return FileType.CSV
if self._is_eml:
return FileType.EML
if extension in (".text", ".txt"):
return FileType.TXT
# Safety catch
if file_type := FileType.from_mime_type(self._ctx.mime_type):
return file_type
return FileType.TXT
@cached_property
def _is_csv(self) -> bool:
"""True when file is plausibly in Comma Separated Values (CSV) format."""
def count_commas(text: str):
"""Counts the number of commas in a line, excluding commas in quotes."""
pattern = r"(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$),"
matches = re.findall(pattern, text)
return len(matches)
lines = self._ctx.text_head.strip().splitlines()
if len(lines) < 2:
return False
# -- check at most the first 10 lines --
lines = lines[: len(lines)] if len(lines) < 10 else lines[:10]
# -- any lines without at least one comma disqualifies the file --
if any("," not in line for line in lines):
return False
header_count = count_commas(lines[0])
return all(count_commas(line) == header_count for line in lines[1:])
@cached_property
def _is_eml(self) -> bool:
"""Checks if a text/plain file is actually a .eml file.
Uses a regex pattern to see if the start of the file matches the typical pattern for a .eml
file.
"""
return EMAIL_HEAD_RE.match(self._ctx.text_head) is not None
@cached_property
def _is_json(self) -> bool:
"""True when file is JSON collection.
A JSON file that contains only a string, number, or boolean, while valid JSON, will fail
this test since it is not partitionable.
"""
text_head = self._ctx.text_head
# -- an empty file is not JSON --
if not text_head.lstrip():
return False
# -- has to be a list or object, no string, number, or bool --
if text_head.lstrip()[0] not in "[{":
return False
try:
with self._ctx.open() as file:
json.load(file)
return True
except json.JSONDecodeError:
return False
class _ZipFileDetector:
"""Detect and differentiate a Zip-archive file."""
def __init__(self, ctx: _FileTypeDetectionContext):
self._ctx = ctx
@classmethod
def file_type(cls, ctx: _FileTypeDetectionContext) -> FileType | None:
"""Most specific file-type available when file is a Zip file, `None` otherwise.
MS-Office 2007+ files are detected with 100% accuracy. Otherwise this returns `None`, even
when we can tell it's a Zip file, so later strategies can have a crack at it. In
particular, ODT and EPUB files are Zip archives but are not detected here.
"""
return cls(ctx)._file_type
@cached_property
def _file_type(self) -> FileType | None:
"""Differentiated file-type for a Zip archive.
Returns `FileType.DOCX`, `FileType.PPTX`, or `FileType.XLSX` when one of those applies,
`None` otherwise.
"""
if not self._ctx.is_zipfile:
return None
with self._ctx.open() as file:
zip = zipfile.ZipFile(file)
filenames = zip.namelist()
if any(re.match(r"word/document.*\.xml$", filename) for filename in filenames):
return FileType.DOCX
if any(re.match(r"xl/workbook.*\.xml$", filename) for filename in filenames):
return FileType.XLSX
if any(re.match(r"ppt/presentation.*\.xml$", filename) for filename in filenames):
return FileType.PPTX
# -- ODT and EPUB files place their MIME-type in `mimetype` in the archive root --
if "mimetype" in filenames:
with zip.open("mimetype") as f:
mime_type = f.read().decode("utf-8").strip()
return FileType.from_mime_type(mime_type)
return FileType.ZIP
_P = ParamSpec("_P")
def add_metadata(func: Callable[_P, list[Element]]) -> Callable[_P, list[Element]]:
@functools.wraps(func)
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> list[Element]:
elements = func(*args, **kwargs)
call_args = get_call_args_applying_defaults(func, *args, **kwargs)
if call_args.get("metadata_filename"):
call_args["filename"] = call_args.get("metadata_filename")
metadata_kwargs = {
kwarg: call_args.get(kwarg) for kwarg in ("filename", "url", "text_as_html")
}
# NOTE (yao): do not use cast here as cast(None) still is None
if not str(kwargs.get("model_name", "")).startswith("chipper"):
# NOTE(alan): Skip hierarchy if using chipper, as it should take care of that
elements = set_element_hierarchy(elements)
for element in elements:
# NOTE(robinson) - Attached files have already run through this logic
# in their own partitioning function
if element.metadata.attached_to_filename is None:
add_element_metadata(element, **metadata_kwargs)
return elements
return wrapper
def add_filetype(
filetype: FileType,
) -> Callable[[Callable[_P, list[Element]]], Callable[_P, list[Element]]]:
"""Post-process element-metadata for list[Element] from partitioning.
This decorator adds a post-processing step to a document partitioner.
- Adds `.metadata.filetype` (source-document MIME-type) metadata value
This "partial" decorator is present because `partition_image()` does not apply
`.metadata.filetype` this way since each image type has its own MIME-type (e.g. `image.jpeg`,
`image/png`, etc.).
"""
def decorator(func: Callable[_P, list[Element]]) -> Callable[_P, list[Element]]:
@functools.wraps(func)
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> list[Element]:
elements = func(*args, **kwargs)
for element in elements:
# NOTE(robinson) - Attached files have already run through this logic
# in their own partitioning function
if element.metadata.attached_to_filename is None:
add_element_metadata(element, filetype=filetype.mime_type)
return elements
return wrapper
return decorator
def add_metadata_with_filetype(
filetype: FileType,
) -> Callable[[Callable[_P, list[Element]]], Callable[_P, list[Element]]]:
"""..."""
def decorator(func: Callable[_P, list[Element]]) -> Callable[_P, list[Element]]:
return add_filetype(filetype=filetype)(add_metadata(func))
return decorator
@@ -0,0 +1,9 @@
GOOGLE_DRIVE_EXPORT_TYPES = {
"application/vnd.google-apps.document": "application/"
"vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.google-apps.spreadsheet": "application/"
"vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.google-apps.presentation": "application/"
"vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.google-apps.photo": "image/jpeg",
}
+591
View File
@@ -0,0 +1,591 @@
"""Domain-model for file-types."""
from __future__ import annotations
import enum
from typing import TYPE_CHECKING, Callable, Iterable, Type, cast
from typing_extensions import ParamSpec
if TYPE_CHECKING:
from unstructured.documents.elements import Element
else:
Element = None
def _create_file_type_enum(
cls: Type["FileType"],
value: str,
partitioner_shortname: str | None,
importable_package_dependencies: Iterable[str],
extra_name: str | None,
extensions: Iterable[str],
canonical_mime_type: str,
alias_mime_types: Iterable[str],
partitioner_full_module_path: str | None = None,
) -> "FileType":
"""
Moving here instead of directly in the FileType.__new__ allows us
to dynamically create new enum properties.
FileType.__new__ does not work with dynamic properties.
"""
val = object.__new__(cls)
val._value_ = value
val._partitioner_shortname = partitioner_shortname
val._importable_package_dependencies = tuple(importable_package_dependencies)
val._extra_name = extra_name
val._extensions = tuple(extensions)
val._canonical_mime_type = canonical_mime_type
val._alias_mime_types = tuple(alias_mime_types)
val._partitioner_full_module_path = partitioner_full_module_path
return val
class FileType(enum.Enum):
"""The collection of file-types recognized by `unstructured`.
Note not all of these can be partitioned, e.g. ZIP has no partitioner.
"""
_partitioner_shortname: str | None
"""Like "docx", from which partitioner module and function-name can be derived via template."""
_importable_package_dependencies: tuple[str, ...]
"""Packages that must be available for import for this file-type's partitioner to work."""
_extra_name: str | None
"""`pip install` extra that provides package dependencies for this file-type."""
_extensions: tuple[str, ...]
"""Filename-extensions recognized as this file-type. Use for secondary identification only."""
_canonical_mime_type: str
"""The MIME-type used as `.metadata.filetype` for this file-type."""
_alias_mime_types: tuple[str, ...]
"""MIME-types accepted as identifying this file-type."""
_partitioner_full_module_path: str | None
"""Fully-qualified name of module providing partitioner for this file-type."""
def __new__(
cls,
value: str,
partitioner_shortname: str | None,
importable_package_dependencies: Iterable[str],
extra_name: str | None,
extensions: Iterable[str],
canonical_mime_type: str,
alias_mime_types: Iterable[str],
partitioner_full_module_path: str | None = None,
):
return _create_file_type_enum(
cls,
value,
partitioner_shortname,
importable_package_dependencies,
extra_name,
extensions,
canonical_mime_type,
alias_mime_types,
partitioner_full_module_path,
)
def __lt__(self, other: FileType) -> bool:
"""Makes `FileType` members comparable with relational operators, at least with `<`.
This makes them sortable, in particular it supports sorting for pandas groupby functions.
"""
return self.name < other.name
@classmethod
def from_extension(cls, extension: str | None) -> FileType | None:
"""Select a FileType member based on an extension.
`extension` must include the leading period, like `".pdf"`. Extension is suitable as a
secondary file-type identification method but is unreliable for primary identification.
Returns `None` when `extension` is not registered for any supported file-type.
"""
if extension in (None, "", "."):
return None
# -- not super efficient but plenty fast enough for once-or-twice-per-file use and avoids
# -- limitations on defining a class variable on an Enum.
for m in cls.__members__.values():
if extension in m._extensions:
return m
return None
@classmethod
def from_mime_type(cls, mime_type: str | None) -> FileType | None:
"""Select a FileType member based on a MIME-type.
Returns `None` when `mime_type` is `None` or does not map to the canonical MIME-type of a
`FileType` member or one of its alias MIME-types.
"""
if mime_type is None:
return None
# -- not super efficient but plenty fast enough for once-or-twice-per-file use and avoids
# -- limitations on defining a class variable on an Enum.
for m in cls.__members__.values():
if mime_type == m._canonical_mime_type or mime_type in m._alias_mime_types:
return m
return None
@property
def extra_name(self) -> str | None:
"""The `pip` "extra" that must be installed to provide this file-type's dependencies.
Like "image" for PNG, as in `pip install "unstructured[image]"`.
`None` when partitioning this file-type requires only the base `unstructured` install.
"""
return self._extra_name
@property
def importable_package_dependencies(self) -> tuple[str, ...]:
"""Packages that must be importable for this file-type's partitioner to work.
In general, these are the packages provided by the `pip install` "extra" for this file-type,
like `pip install "unstructured[docx]"` loads the `python-docx` package.
Note that these names are the ones used in an `import` statement, which is not necessarily
the same as the _distribution_ package name used by `pip`. For example, the DOCX
distribution package name is `"python-docx"` whereas the _importable_ package name is
`"docx"`. This latter name as it appears like `import docx` is what is provided by this
property.
The return value is an empty tuple for file-types that do not require optional dependencies.
Note this property does not complain when accessed on a non-partitionable file-type, it
simply returns an empty tuple because file-types that are not partitionable require no
optional dependencies.
"""
return self._importable_package_dependencies
@property
def is_partitionable(self) -> bool:
"""True when there is a partitioner for this file-type.
Note this does not check whether the dependencies for this file-type are installed so
attempting to partition a file of this type may still fail. This is meant for
distinguishing file-types like ZIP, EMPTY, and UNK which are legitimate file-types
but have no associated partitioner.
"""
return bool(self._partitioner_shortname) or bool(self._partitioner_full_module_path)
@property
def mime_type(self) -> str:
"""The canonical MIME-type for this file-type, suitable for use in metadata.
This value is used in `.metadata.filetype` for elements partitioned from files of this
type. In general it is the "offical", "recommended", or "defacto-standard" MIME-type for
files of this type, in that order, as available.
"""
return self._canonical_mime_type
@property
def partitioner_function_name(self) -> str:
"""Name of partitioner function for this file-type. Like "partition_docx".
Raises when this property is accessed on a file-type that is not partitionable. Use
`.is_partitionable` to avoid exceptions when partitionability is unknown.
"""
# -- Raise when this property is accessed on a FileType member that has no partitioner
# -- shortname. This prevents a harder-to-find bug from appearing far away from this call
# -- when code would try to `getattr(module, None)` or whatever.
if full_module_path := self._partitioner_full_module_path:
return full_module_path.split(".")[-1]
if (shortname := self._partitioner_shortname) is None:
raise ValueError(
f"`.partitioner_function_name` is undefined because FileType.{self.name} is not"
f" partitionable. Use `.is_partitionable` to determine whether a `FileType`"
f" is partitionable."
)
return f"partition_{shortname}"
@property
def partitioner_module_qname(self) -> str:
"""Fully-qualified name of module providing partitioner for this file-type.
e.g. "unstructured.partition.docx" for FileType.DOCX.
"""
# -- Raise when this property is accessed on a FileType member that has no partitioner
# -- shortname. This prevents a harder-to-find bug from appearing far away from this call
# -- when code would try to `importlib.import_module(None)` or whatever.
if full_module_path := self._partitioner_full_module_path:
return ".".join(full_module_path.split(".")[:-1])
if (shortname := self._partitioner_shortname) is None:
raise ValueError(
f"`.partitioner_module_qname` is undefined because FileType.{self.name} is not"
f" partitionable. Use `.is_partitionable` to determine whether a `FileType`"
f" is partitionable."
)
return f"unstructured.partition.{shortname}"
@property
def partitioner_shortname(self) -> str | None:
"""Familiar name of partitioner, like "image" for file-types that use `partition_image()`.
One use is to determine whether a file-type is one of the five image types, all of which
are processed by `partition_image()`.
`None` for file-types that are not partitionable, although `.is_partitionable` is the
preferred way of discovering that.
"""
return self._partitioner_shortname
BMP = (
"bmp", # -- value for this Enum member, like BMP = "bmp" in a simple enum --
"image", # -- partitioner_shortname --
["unstructured_inference"], # -- importable_package_dependencies --
"image", # -- extra_name - like `pip install "unstructured[image]"` in this case --
[".bmp"], # -- extensions - filename extensions that map to this file-type --
"image/bmp", # -- canonical_mime_type - MIME-type written to `.metadata.filetype` --
[
"image/x-bmp",
"image/x-ms-bmp", # returned by older libmagic versions
],
)
CSV = (
"csv",
"csv",
["pandas"],
"csv",
[".csv"],
"text/csv",
[
"application/csv",
"application/x-csv",
"text/comma-separated-values",
"text/x-comma-separated-values",
"text/x-csv",
],
)
DOC = ("doc", "doc", ["docx"], "doc", [".doc"], "application/msword", cast(list[str], []))
DOCX = (
"docx",
"docx",
["docx"],
"docx",
[".docx"],
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
cast(list[str], []),
)
EML = (
"eml",
"email",
cast(list[str], []),
None,
[".eml", ".p7s"],
"message/rfc822",
cast(list[str], []),
)
EPUB = (
"epub",
"epub",
["pypandoc"],
"epub",
[".epub"],
"application/epub",
["application/epub+zip"],
)
FLAC = (
"flac",
"audio",
cast(list[str], []), # STT agent deps validated at runtime by the chosen agent
"audio",
[".flac"],
"audio/flac",
["audio/x-flac"],
)
HEIC = (
"heic",
"image",
["unstructured_inference"],
"image",
[".heic"],
"image/heic",
["image/x-heic"],
)
HTML = (
"html",
"html",
cast(list[str], []),
None,
[".html", ".htm"],
"text/html",
cast(list[str], []),
)
JPG = (
"jpg",
"image",
["unstructured_inference"],
"image",
[".jpeg", ".jpg"],
"image/jpeg",
cast(list[str], []),
)
JSON = (
"json",
"json",
cast(list[str], []),
None,
[".json"],
"application/json",
cast(list[str], []),
)
M4A = (
"m4a",
"audio",
cast(list[str], []), # STT agent deps validated at runtime by the chosen agent
"audio",
[".m4a"],
"audio/mp4",
["audio/x-m4a"],
)
MD = ("md", "md", ["markdown"], "md", [".md"], "text/markdown", ["text/x-markdown"])
MP3 = (
"mp3",
"audio",
cast(list[str], []), # STT agent deps validated at runtime by the chosen agent
"audio",
[".mp3"],
"audio/mpeg",
["audio/mp3", "audio/x-mp3", "audio/x-mpeg"],
)
MSG = (
"msg",
"msg",
["oxmsg"],
"msg",
[".msg"],
"application/vnd.ms-outlook",
cast(list[str], []),
)
NDJSON = (
"ndjson",
"ndjson",
cast(list[str], []),
None,
[".ndjson"],
"application/x-ndjson",
cast(list[str], []),
)
ODT = (
"odt",
"odt",
["docx", "pypandoc"],
"odt",
[".odt"],
"application/vnd.oasis.opendocument.text",
cast(list[str], []),
)
OGG = (
"ogg",
"audio",
cast(list[str], []), # STT agent deps validated at runtime by the chosen agent
"audio",
[".ogg", ".oga"],
"audio/ogg",
["audio/x-ogg"],
)
OPUS = (
"opus",
"audio",
cast(list[str], []), # STT agent deps validated at runtime by the chosen agent
"audio",
[".opus"],
"audio/opus",
cast(list[str], []),
)
ORG = ("org", "org", ["pypandoc"], "org", [".org"], "text/org", cast(list[str], []))
PDF = (
"pdf",
"pdf",
["pdf2image", "pdfminer", "PIL"],
"pdf",
[".pdf"],
"application/pdf",
cast(list[str], []),
)
PNG = (
"png",
"image",
["unstructured_inference"],
"image",
[".png"],
"image/png",
cast(list[str], []),
)
PPT = (
"ppt",
"ppt",
["pptx"],
"ppt",
[".ppt"],
"application/vnd.ms-powerpoint",
cast(list[str], []),
)
PPTX = (
"pptx",
"pptx",
["pptx"],
"pptx",
[".pptx"],
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
cast(list[str], []),
)
RST = ("rst", "rst", ["pypandoc"], "rst", [".rst"], "text/x-rst", cast(list[str], []))
RTF = ("rtf", "rtf", ["pypandoc"], "rtf", [".rtf"], "text/rtf", ["application/rtf"])
TIFF = (
"tiff",
"image",
["unstructured_inference"],
"image",
[".tiff"],
"image/tiff",
cast(list[str], []),
)
TSV = ("tsv", "tsv", ["pandas"], "tsv", [".tab", ".tsv"], "text/tsv", cast(list[str], []))
TXT = (
"txt",
"text",
cast(list[str], []),
None,
[
".txt",
".text",
# NOTE(robinson) - for now we are treating code files as plain text
".c",
".cc",
".cpp",
".cs",
".cxx",
".go",
".java",
".js",
".log",
".php",
".py",
".rb",
".swift",
".ts",
".yaml",
".yml",
],
"text/plain",
[
# NOTE(robinson) - In the future, we may have special processing for YAML files
# instead of treating them as plaintext.
"text/yaml",
"application/x-yaml",
"application/yaml",
"text/x-yaml",
],
)
WAV = (
"wav",
"audio",
cast(list[str], []), # STT agent deps validated at runtime by the chosen agent
"audio",
[".wav"],
"audio/wav",
[
"audio/vnd.wav",
"audio/vnd.wave",
"audio/wave",
"audio/x-pn-wav",
"audio/x-wav",
],
)
WEBM = (
"webm",
"audio",
cast(list[str], []), # STT agent deps validated at runtime by the chosen agent
"audio",
[".webm"],
"audio/webm",
[], # Do not alias video/webm: WebM is a container; this type is for audio-only.
)
XLS = (
"xls",
"xlsx",
["pandas", "openpyxl"],
"xlsx",
[".xls"],
"application/vnd.ms-excel",
cast(list[str], []),
)
XLSX = (
"xlsx",
"xlsx",
["pandas", "openpyxl"],
"xlsx",
[".xlsx"],
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
cast(list[str], []),
)
XML = ("xml", "xml", cast(list[str], []), None, [".xml"], "application/xml", ["text/xml"])
ZIP = ("zip", None, cast(list[str], []), None, [".zip"], "application/zip", cast(list[str], []))
UNK = (
"unk",
None,
cast(list[str], []),
None,
cast(list[str], []),
"application/octet-stream",
cast(list[str], []),
)
EMPTY = (
"empty",
None,
cast(list[str], []),
None,
cast(list[str], []),
"inode/x-empty",
cast(list[str], []),
)
def create_file_type(
name: str,
*,
canonical_mime_type: str,
importable_package_dependencies: Iterable[str] | None = None,
extra_name: str | None = None,
extensions: Iterable[str] | None = None,
alias_mime_types: Iterable[str] | None = None,
) -> FileType:
"""Register a new FileType member."""
type_ = _create_file_type_enum(
FileType,
name,
None,
importable_package_dependencies or cast(list[str], []),
extra_name,
extensions or cast(list[str], []),
canonical_mime_type,
alias_mime_types or cast(list[str], []),
None,
)
type_._name_ = name
FileType._member_map_[name] = type_
return type_
_P = ParamSpec("_P")
def register_partitioner(
file_type: FileType,
) -> Callable[[Callable[_P, list[Element]]], Callable[_P, list[Element]]]:
def decorator(func: Callable[_P, list[Element]]) -> Callable[_P, list[Element]]:
file_type._partitioner_full_module_path = func.__module__ + "." + func.__name__
return func
return decorator
+67
View File
@@ -0,0 +1,67 @@
"""
Adds support for working with newline-delimited JSON (ndjson) files. This format is useful for
streaming json content that would otherwise not be possible using raw JSON files.
"""
import json
from typing import IO, Any
def dumps(obj: list[dict[str, Any]], **kwargs) -> str:
"""
Converts the list of dictionaries into string representation
Args:
obj (list[dict[str, Any]]): List of dictionaries to convert
**kwargs: Additional keyword arguments to pass to json.dumps
Returns:
str: string representation of the list of dictionaries
"""
return "\n".join(json.dumps(each, **kwargs) for each in obj)
def dump(obj: list[dict[str, Any]], fp: IO, **kwargs) -> None:
"""
Writes the list of dictionaries to a newline-delimited file
Args:
obj (list[dict[str, Any]]): List of dictionaries to convert
fp (IO): File pointer to write the string representation to
**kwargs: Additional keyword arguments to pass to json.dumps
Returns:
None
"""
# Indent breaks ndjson formatting
kwargs["indent"] = None
text = dumps(obj, **kwargs)
fp.write(text)
def loads(s: str, **kwargs) -> list[dict[str, Any]]:
"""
Converts the raw string into a list of dictionaries
Args:
s (str): Raw string to convert
**kwargs: Additional keyword arguments to pass to json.loads
Returns:
list[dict[str, Any]]: List of dictionaries parsed from the input string
"""
return [json.loads(line, **kwargs) for line in s.splitlines()]
def load(fp: IO, **kwargs) -> list[dict[str, Any]]:
"""
Converts the contents of the file into a list of dictionaries
Args:
fp (IO): File pointer to read the string representation from
**kwargs: Additional keyword arguments to pass to json.loads
Returns:
list[dict[str, Any]]: List of dictionaries parsed from the file
"""
return loads(fp.read(), **kwargs)
+18
View File
@@ -0,0 +1,18 @@
import logging
logger = logging.getLogger("unstructured")
trace_logger = logging.getLogger("unstructured.trace")
# Create a custom logging level
DETAIL = 15
logging.addLevelName(DETAIL, "DETAIL")
# Create a custom log method for the "DETAIL" level
def detail(self, message, *args, **kws):
if self.isEnabledFor(DETAIL):
self._log(DETAIL, message, args, **kws)
# Add the custom log method to the logging.Logger class
logging.Logger.detail = detail # type: ignore
View File
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
import json
from typing_extensions import TypeAlias
FrequencyDict: TypeAlias = "dict[tuple[str, int | None], int]"
"""Like:
{
("ListItem", 0): 2,
("NarrativeText", None): 2,
("Title", 0): 5,
("UncategorizedText", None): 6,
}
"""
def get_element_type_frequency(
elements: str,
) -> FrequencyDict:
"""
Calculate the frequency of Element Types from a list of elements.
Args:
elements (str): String-formatted json of all elements (as a result of elements_to_json).
Returns:
Element type and its frequency in dictionary format.
"""
frequency: dict[tuple[str, int | None], int] = {}
if len(elements) == 0:
return frequency
for element in json.loads(elements):
type = element.get("type")
category_depth = element["metadata"].get("category_depth")
key = (type, category_depth)
if key not in frequency:
frequency[key] = 1
else:
frequency[key] += 1
return frequency
def calculate_element_type_percent_match(
output: FrequencyDict,
source: FrequencyDict,
category_depth_weight: float = 0.5,
) -> float:
"""Calculate the percent match between two frequency dictionary.
Intended to use with `get_element_type_frequency` function. The function counts the absolute
exact match (type and depth), and counts the weighted match (correct type but different depth),
then normalized with source's total elements.
"""
if len(output) == 0 or len(source) == 0:
return 0.0
output_copy = output.copy()
source_copy = source.copy()
total_source_element_count = 0
total_match_element_count = 0
unmatched_depth_output: dict[str, int] = {}
unmatched_depth_source: dict[str, int] = {}
# loop through the output list to find match with source
for k, _ in output_copy.items():
if k in source_copy:
match_count = min(output_copy[k], source_copy[k])
total_match_element_count += match_count
total_source_element_count += match_count
# update the dictionary by removing already matched values
output_copy[k] -= match_count
source_copy[k] -= match_count
# add unmatched leftovers from output_copy to a new dictionary
element_type = k[0]
if element_type not in unmatched_depth_output:
unmatched_depth_output[element_type] = output_copy[k]
else:
unmatched_depth_output[element_type] += output_copy[k]
# add unmatched leftovers from source_copy to a new dictionary
unmatched_depth_source = _convert_to_frequency_without_depth(source_copy)
# loop through the source list to match any existing partial match left
for k, _ in unmatched_depth_source.items():
total_source_element_count += unmatched_depth_source[k]
if k in unmatched_depth_output:
match_count = min(unmatched_depth_output[k], unmatched_depth_source[k])
total_match_element_count += match_count * category_depth_weight
return min(max(total_match_element_count / total_source_element_count, 0.0), 1.0)
def _convert_to_frequency_without_depth(d: FrequencyDict) -> dict[str, int]:
"""
Takes in element frequency with depth of format (type, depth): value
and converts to dictionary without depth of format type: value
"""
res: dict[str, int] = {}
for k, v in d.items():
element_type = k[0]
if element_type not in res:
res[element_type] = v
else:
res[element_type] += v
return res
+898
View File
@@ -0,0 +1,898 @@
#! /usr/bin/env python3
from __future__ import annotations
import concurrent.futures
import json
import logging
import os
import sys
from abc import ABC, abstractmethod
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional, Union
import numpy as np
import pandas as pd
from tqdm import tqdm
from unstructured.metrics.element_type import (
calculate_element_type_percent_match,
get_element_type_frequency,
)
from unstructured.metrics.object_detection import (
ObjectDetectionEvalProcessor,
)
from unstructured.metrics.table.table_eval import TableEvalProcessor
from unstructured.metrics.text_extraction import calculate_accuracy, calculate_percent_missing_text
from unstructured.metrics.utils import (
_count,
_display,
_format_grouping_output,
_mean,
_prepare_output_cct,
_pstdev,
_read_text_file,
_rename_aggregated_columns,
_stdev,
_write_to_file,
)
logger = logging.getLogger("unstructured.eval")
handler = logging.StreamHandler()
handler.name = "eval_log_handler"
formatter = logging.Formatter("%(asctime)s %(processName)-10s %(levelname)-8s %(message)s")
handler.setFormatter(formatter)
# Only want to add the handler once
if "eval_log_handler" not in [h.name for h in logger.handlers]:
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)
AGG_HEADERS = ["metric", "average", "sample_sd", "population_sd", "count"]
AGG_HEADERS_MAPPING = {
"index": "metric",
"_mean": "average",
"_stdev": "sample_sd",
"_pstdev": "population_sd",
"_count": "count",
}
OUTPUT_TYPE_OPTIONS = ["json", "txt"]
@dataclass
class BaseMetricsCalculator(ABC):
"""Foundation class for specialized metrics calculators.
It provides a common interface for calculating metrics based on outputs and ground truths.
Those can be provided as either directories or lists of files.
"""
documents_dir: str | Path
ground_truths_dir: str | Path
def __post_init__(self):
"""Discover all files in the provided directories."""
self.documents_dir = Path(self.documents_dir).resolve()
self.ground_truths_dir = Path(self.ground_truths_dir).resolve()
# -- auto-discover all files in the directories --
self._document_paths = [
path.relative_to(self.documents_dir)
for path in self.documents_dir.glob("*")
if path.is_file()
]
self._ground_truth_paths = [
path.relative_to(self.ground_truths_dir)
for path in self.ground_truths_dir.glob("*")
if path.is_file()
]
@property
@abstractmethod
def default_tsv_name(self):
"""Default name for the per-document metrics TSV file."""
@property
@abstractmethod
def default_agg_tsv_name(self):
"""Default name for the aggregated metrics TSV file."""
@abstractmethod
def _generate_dataframes(self, rows: list) -> tuple[pd.DataFrame, pd.DataFrame]:
"""Generates pandas DataFrames from the list of rows.
The first DF (index 0) is a dataframe containing metrics per file.
The second DF (index 1) is a dataframe containing the aggregated
metrics.
"""
def on_files(
self,
document_paths: Optional[list[str | Path]] = None,
ground_truth_paths: Optional[list[str | Path]] = None,
) -> BaseMetricsCalculator:
"""Overrides the default list of files to process."""
if document_paths:
self._document_paths = [Path(p) for p in document_paths]
if ground_truth_paths:
self._ground_truth_paths = [Path(p) for p in ground_truth_paths]
return self
def calculate(
self,
executor: Optional[concurrent.futures.Executor] = None,
export_dir: Optional[str | Path] = None,
visualize_progress: bool = True,
display_agg_df: bool = True,
) -> pd.DataFrame:
"""Calculates metrics for each document using the provided executor.
* Optionally, the results can be exported and displayed.
* It loops through the list of structured output from all of `documents_dir` or
selected files from `document_paths`, and compares them with gold-standard
of the same file name under `ground_truths_dir` or selected files from `ground_truth_paths`.
Args:
executor: concurrent.futures.Executor instance
export_dir: directory to export the results
visualize_progress: whether to display progress bar
display_agg_df: whether to display the aggregated results
Returns:
Metrics for each document as a pandas DataFrame
"""
if executor is None:
executor = self._default_executor()
rows = self._process_all_documents(executor, visualize_progress)
df, agg_df = self._generate_dataframes(rows)
if export_dir is not None:
_write_to_file(export_dir, self.default_tsv_name, df)
_write_to_file(export_dir, self.default_agg_tsv_name, agg_df)
if display_agg_df is True:
_display(agg_df)
return df
@classmethod
def _default_executor(cls):
max_processors = int(os.environ.get("MAX_PROCESSES", os.cpu_count()))
logger.info(f"Configuring a pool of {max_processors} processors for parallel processing.")
return cls._get_executor_class()(max_workers=max_processors)
@classmethod
def _get_executor_class(
cls,
) -> type[concurrent.futures.ThreadPoolExecutor] | type[concurrent.futures.ProcessPoolExecutor]:
return concurrent.futures.ProcessPoolExecutor
def _process_all_documents(
self, executor: concurrent.futures.Executor, visualize_progress: bool
) -> list:
"""Triggers processing of all documents using the provided executor.
Failures are omitted from the returned result.
"""
with executor:
return [
row
for row in tqdm(
executor.map(self._try_process_document, self._document_paths),
total=len(self._document_paths),
leave=False,
disable=not visualize_progress,
)
if row is not None
]
def _try_process_document(self, doc: Path) -> Optional[list]:
"""Safe wrapper around the document processing method."""
logger.info(f"Processing {doc}")
try:
return self._process_document(doc)
except Exception as e:
logger.error(f"Failed to process document {doc}: {e}")
return None
@abstractmethod
def _process_document(self, doc: Path) -> Optional[list]:
"""Should return all metadata and metrics for a single document."""
@dataclass
class TableStructureMetricsCalculator(BaseMetricsCalculator):
"""Calculates the following metrics for tables:
- tables found accuracy
- table-level accuracy
- element in column index accuracy
- element in row index accuracy
- element's column content accuracy
- element's row content accuracy
It also calculates the aggregated accuracy.
"""
cutoff: Optional[float] = None
weighted_average: bool = True
include_false_positives: bool = True
def __post_init__(self):
super().__post_init__()
@property
def supported_metric_names(self):
return [
"total_tables",
"table_level_acc",
"table_detection_recall",
"table_detection_precision",
"table_detection_f1",
"composite_structure_acc",
"element_col_level_index_acc",
"element_row_level_index_acc",
"element_col_level_content_acc",
"element_row_level_content_acc",
]
@property
def default_tsv_name(self):
return "all-docs-table-structure-accuracy.tsv"
@property
def default_agg_tsv_name(self):
return "aggregate-table-structure-accuracy.tsv"
def _process_document(self, doc: Path) -> Optional[list]:
doc_path = Path(doc)
out_filename = doc_path.stem
doctype = Path(out_filename).suffix
src_gt_filename = out_filename + ".json"
connector = doc_path.parts[-2] if len(doc_path.parts) > 1 else None
if src_gt_filename in self._ground_truth_paths: # type: ignore
return None
prediction_file = self.documents_dir / doc
if not prediction_file.exists():
logger.warning(f"Prediction file {prediction_file} does not exist, skipping")
return None
ground_truth_file = self.ground_truths_dir / src_gt_filename
if not ground_truth_file.exists():
logger.warning(f"Ground truth file {ground_truth_file} does not exist, skipping")
return None
processor_from_text_as_html = TableEvalProcessor.from_json_files(
prediction_file=prediction_file,
ground_truth_file=ground_truth_file,
cutoff=self.cutoff,
source_type="html",
)
report_from_html = processor_from_text_as_html.process_file()
return [
out_filename,
doctype,
connector,
report_from_html.total_predicted_tables,
] + [getattr(report_from_html, metric) for metric in self.supported_metric_names]
def _generate_dataframes(self, rows):
headers = [
"filename",
"doctype",
"connector",
"total_predicted_tables",
] + self.supported_metric_names
df = pd.DataFrame(rows, columns=headers)
df["_table_weights"] = df["total_tables"]
if self.include_false_positives:
# we give false positive tables a 1 table worth of weight in computing table level acc
_fp_mask = df.total_tables.eq(0) & df.total_predicted_tables.gt(0)
df.loc[_fp_mask, "_table_weights"] = 1
# filter down to only those with actual and/or predicted tables
has_tables_df = df[df["_table_weights"] > 0]
if not self.weighted_average:
# for all non zero elements assign them value 1
df["_table_weights"] = df["_table_weights"].apply(
lambda table_weight: 1 if table_weight != 0 else 0
)
if has_tables_df.empty:
agg_df = pd.DataFrame(
[[metric, None, None, None, 0] for metric in self.supported_metric_names]
).reset_index()
else:
element_metrics_results = {}
for metric in self.supported_metric_names:
metric_df = has_tables_df[has_tables_df[metric].notnull()]
agg_metric = metric_df[metric].agg([_stdev, _pstdev, _count]).transpose()
if metric.startswith("total_tables"):
agg_metric["_mean"] = metric_df[metric].mean()
elif metric.startswith("table_level_acc"):
agg_metric["_mean"] = np.round(
np.average(metric_df[metric], weights=metric_df["_table_weights"]),
3,
)
else:
# false positive tables do not contribute to table structure and content
# extraction metrics
agg_metric["_mean"] = np.round(
np.average(metric_df[metric], weights=metric_df["total_tables"]),
3,
)
if agg_metric.empty:
element_metrics_results[metric] = pd.Series(
data=[None, None, None, 0], index=["_mean", "_stdev", "_pstdev", "_count"]
)
else:
element_metrics_results[metric] = agg_metric
agg_df = pd.DataFrame(element_metrics_results).transpose().reset_index()
agg_df = agg_df.rename(columns=AGG_HEADERS_MAPPING)
return df, agg_df
@dataclass
class TextExtractionMetricsCalculator(BaseMetricsCalculator):
"""Calculates text accuracy and percent missing between document and ground truth texts.
It also calculates the aggregated accuracy and percent missing.
"""
group_by: Optional[str] = None
weights: tuple[int, int, int] = (1, 1, 1)
document_type: str = "json"
def __post_init__(self):
super().__post_init__()
self._validate_inputs()
@property
def default_tsv_name(self) -> str:
return "all-docs-cct.tsv"
@property
def default_agg_tsv_name(self) -> str:
return "aggregate-scores-cct.tsv"
def calculate(
self,
executor: Optional[concurrent.futures.Executor] = None,
export_dir: Optional[str | Path] = None,
visualize_progress: bool = True,
display_agg_df: bool = True,
) -> pd.DataFrame:
"""See the parent class for the method's docstring."""
df = super().calculate(
executor=executor,
export_dir=export_dir,
visualize_progress=visualize_progress,
display_agg_df=display_agg_df,
)
if export_dir is not None and self.group_by:
get_mean_grouping(self.group_by, df, export_dir, "text_extraction")
return df
def _validate_inputs(self):
if not self._document_paths:
logger.info("No output files to calculate to edit distances for, exiting")
sys.exit(0)
if self.document_type not in OUTPUT_TYPE_OPTIONS:
raise ValueError(
"Specified file type under `documents_dir` or `output_list` should be one of "
f"`json` or `txt`. The given file type is {self.document_type}, exiting."
)
for path in self._document_paths:
try:
path.suffixes[-1]
except IndexError:
logger.error(f"File {path} does not have a suffix, skipping")
continue
if path.suffixes[-1] != f".{self.document_type}":
logger.warning(
"The directory contains file type inconsistent with the given input. "
"Please note that some files will be skipped."
)
if not all(path.suffixes[-1] == f".{self.document_type}" for path in self._document_paths):
logger.warning(
"The directory contains file type inconsistent with the given input. "
"Please note that some files will be skipped."
)
def _process_document(self, doc: Path) -> Optional[list]:
filename = doc.stem
doctype = doc.suffixes[-2]
connector = doc.parts[0] if len(doc.parts) > 1 else None
output_cct, source_cct = self._get_ccts(doc)
# NOTE(amadeusz): Levenshtein distance calculation takes too long
# skip it if file sizes differ wildly
if 0.5 < len(output_cct.encode()) / len(source_cct.encode()) < 2.0:
accuracy = round(calculate_accuracy(output_cct, source_cct, self.weights), 3)
else:
# 0.01 to distinguish it was set manually
accuracy = 0.01
percent_missing = round(calculate_percent_missing_text(output_cct, source_cct), 3)
return [filename, doctype, connector, accuracy, percent_missing]
def _get_ccts(self, doc: Path) -> tuple[str, str]:
output_cct = _prepare_output_cct(
docpath=self.documents_dir / doc, output_type=self.document_type
)
source_cct = _read_text_file(self.ground_truths_dir / doc.with_suffix(".txt"))
return output_cct, source_cct
def _generate_dataframes(self, rows):
headers = ["filename", "doctype", "connector", "cct-accuracy", "cct-%missing"]
df = pd.DataFrame(rows, columns=headers)
acc = df[["cct-accuracy"]].agg([_mean, _stdev, _pstdev, _count]).transpose()
miss = df[["cct-%missing"]].agg([_mean, _stdev, _pstdev, _count]).transpose()
if acc.shape[1] == 0 and miss.shape[1] == 0:
agg_df = pd.DataFrame(columns=AGG_HEADERS)
else:
agg_df = pd.concat((acc, miss)).reset_index()
agg_df.columns = AGG_HEADERS
return df, agg_df
@dataclass
class ElementTypeMetricsCalculator(BaseMetricsCalculator):
"""
Calculates element type frequency accuracy, percent missing and
aggregated accuracy between document and ground truth.
"""
group_by: Optional[str] = None
def calculate(
self,
executor: Optional[concurrent.futures.Executor] = None,
export_dir: Optional[str | Path] = None,
visualize_progress: bool = True,
display_agg_df: bool = False,
) -> pd.DataFrame:
"""See the parent class for the method's docstring."""
df = super().calculate(
executor=executor,
export_dir=export_dir,
visualize_progress=visualize_progress,
display_agg_df=display_agg_df,
)
if export_dir is not None and self.group_by:
get_mean_grouping(self.group_by, df, export_dir, "element_type")
return df
@property
def default_tsv_name(self) -> str:
return "all-docs-element-type-frequency.tsv"
@property
def default_agg_tsv_name(self) -> str:
return "aggregate-scores-element-type.tsv"
def _process_document(self, doc: Path) -> Optional[list]:
filename = doc.stem
doctype = doc.suffixes[-2]
connector = doc.parts[0] if len(doc.parts) > 1 else None
output = get_element_type_frequency(_read_text_file(self.documents_dir / doc))
source = get_element_type_frequency(
_read_text_file(self.ground_truths_dir / doc.with_suffix(".json"))
)
accuracy = round(calculate_element_type_percent_match(output, source), 3)
return [filename, doctype, connector, accuracy]
def _generate_dataframes(self, rows):
headers = ["filename", "doctype", "connector", "element-type-accuracy"]
df = pd.DataFrame(rows, columns=headers)
if df.empty:
agg_df = pd.DataFrame(["element-type-accuracy", None, None, None, 0]).transpose()
else:
agg_df = df.agg({"element-type-accuracy": [_mean, _stdev, _pstdev, _count]}).transpose()
agg_df = agg_df.reset_index()
agg_df.columns = AGG_HEADERS
return df, agg_df
def get_mean_grouping(
group_by: str,
data_input: Union[pd.DataFrame, str],
export_dir: str,
eval_name: str,
agg_name: Optional[str] = None,
export_filename: Optional[str] = None,
) -> None:
"""Aggregates accuracy and missing metrics by column name 'doctype' or 'connector',
or 'all' for all rows. Export to TSV.
If `all`, passing export_name is recommended.
Args:
group_by (str): Grouping category ('doctype' or 'connector' or 'all').
data_input (Union[pd.DataFrame, str]): DataFrame or path to a CSV/TSV file.
export_dir (str): Directory for the exported TSV file.
eval_name (str): Evaluated metric ('text_extraction' or 'element_type').
agg_name (str, optional): String to use with export filename. Default is `cct` for
group_by `text_extraction` and `element-type` for `element_type`
export_name (str, optional): Export filename.
"""
if group_by not in ("doctype", "connector") and group_by != "all":
raise ValueError("Invalid grouping category. Returning a non-group evaluation.")
if eval_name == "text_extraction":
agg_fields = ["cct-accuracy", "cct-%missing"]
agg_name = "cct"
elif eval_name == "element_type":
agg_fields = ["element-type-accuracy"]
agg_name = "element-type"
elif eval_name == "object_detection":
agg_fields = ["f1_score", "m_ap"]
agg_name = "object-detection"
else:
raise ValueError(
f"Unknown metric for eval {eval_name}. "
f"Expected `text_extraction` or `element_type` or `table_extraction`."
)
if isinstance(data_input, str):
if not os.path.exists(data_input):
raise FileNotFoundError(f"File {data_input} not found.")
if data_input.endswith(".csv"):
df = pd.read_csv(data_input, header=None)
elif data_input.endswith(".tsv"):
df = pd.read_csv(data_input, sep="\t")
elif data_input.endswith(".txt"):
df = pd.read_csv(data_input, sep="\t", header=None)
else:
raise ValueError("Please provide a .csv or .tsv file.")
else:
df = data_input
if df.empty:
raise SystemExit("Data is empty. Exiting.")
elif group_by != "all" and (group_by not in df.columns or df[group_by].isnull().all()):
raise SystemExit(
f"Data cannot be aggregated by `{group_by}`."
f" Check if it's empty or the column is missing/empty."
)
grouped_df = []
if group_by and group_by != "all":
for field in agg_fields:
grouped_df.append(
_rename_aggregated_columns(
df.groupby(group_by).agg({field: [_mean, _stdev, _pstdev, _count]})
)
)
if group_by == "all":
df["grouping_key"] = 0
for field in agg_fields:
grouped_df.append(
_rename_aggregated_columns(
df.groupby("grouping_key").agg({field: [_mean, _stdev, _pstdev, _count]})
)
)
grouped_df = _format_grouping_output(*grouped_df)
if "grouping_key" in grouped_df.columns.get_level_values(0):
grouped_df = grouped_df.drop("grouping_key", axis=1, level=0)
if export_filename:
if not export_filename.endswith(".tsv"):
export_filename = export_filename + ".tsv"
_write_to_file(export_dir, export_filename, grouped_df)
else:
_write_to_file(export_dir, f"all-{group_by}-agg-{agg_name}.tsv", grouped_df)
def filter_metrics(
data_input: Union[str, pd.DataFrame],
filter_list: Union[str, List[str]],
filter_by: str = "filename",
export_filename: Optional[str] = None,
export_dir: str = "metrics",
return_type: str = "file",
) -> Optional[pd.DataFrame]:
"""Reads the data_input file and filter only selected row available in filter_list.
Args:
data_input (str, dataframe): the source data, path to file or dataframe
filter_list (str, list): the filter, path to file or list of string
filter_by (str): data_input's column to filter the filter_list to
export_filename (str, optional): export filename. required when return_type is "file"
export_dir (str, optional): export directory. default to <current directory>/metrics
return_type (str): "file" or "dataframe"
"""
if isinstance(data_input, str):
if not os.path.exists(data_input):
raise FileNotFoundError(f"File {data_input} not found.")
if data_input.endswith(".csv"):
df = pd.read_csv(data_input, header=None)
elif data_input.endswith(".tsv"):
df = pd.read_csv(data_input, sep="\t")
elif data_input.endswith(".txt"):
df = pd.read_csv(data_input, sep="\t", header=None)
else:
raise ValueError("Please provide a .csv or .tsv file.")
else:
df = data_input
if isinstance(filter_list, str):
if not os.path.exists(filter_list):
raise FileNotFoundError(f"File {filter_list} not found.")
if filter_list.endswith(".csv"):
filter_df = pd.read_csv(filter_list, header=None)
elif filter_list.endswith(".tsv"):
filter_df = pd.read_csv(filter_list, sep="\t")
elif filter_list.endswith(".txt"):
filter_df = pd.read_csv(filter_list, sep="\t", header=None)
else:
raise ValueError("Please provide a .csv or .tsv file.")
filter_list = filter_df.iloc[:, 0].astype(str).values.tolist()
elif not isinstance(filter_list, list):
raise ValueError("Please provide a List of strings or path to file.")
if filter_by not in df.columns:
raise ValueError("`filter_by` key does not exists in the data provided.")
res = df[df[filter_by].isin(filter_list)]
if res.empty:
raise SystemExit("No common file names between data_input and filter_list. Exiting.")
if return_type == "dataframe":
return res
elif return_type == "file" and export_filename:
_write_to_file(export_dir, export_filename, res)
elif return_type == "file" and not export_filename:
raise ValueError("Please provide `export_filename`.")
else:
raise ValueError("Return type must be either `dataframe` or `file`.")
@dataclass
class ObjectDetectionMetricsCalculatorBase(BaseMetricsCalculator, ABC):
"""
Calculates object detection metrics for each document:
- f1 score
- precision
- recall
- average precision (mAP)
It also calculates aggregated metrics.
"""
def __post_init__(self):
super().__post_init__()
self._document_paths = [
path.relative_to(self.documents_dir)
for path in self.documents_dir.rglob("analysis/*/layout_dump/object_detection.json")
if path.is_file()
]
@property
def supported_metric_names(self):
return ["f1_score", "precision", "recall", "m_ap"]
@property
def default_tsv_name(self):
return "all-docs-object-detection-metrics.tsv"
@property
def default_agg_tsv_name(self):
return "aggregate-object-detection-metrics.tsv"
def _find_file_in_ground_truth(self, file_stem: str) -> Optional[Path]:
"""Find the file corresponding to OD model dump file among the set of ground truth files
The files in ground truth paths keep the original extension and have .json suffix added,
e.g.:
some_document.pdf.json
poster.jpg.json
To compare to `file_stem` we need to take the prefix part of the file, thus double-stem
is applied.
"""
for path in self._ground_truth_paths:
if Path(path.stem).stem == file_stem:
return path
return None
def _get_paths(self, doc: Path) -> tuple(str, Path, Path):
"""Resolves ground doctype, prediction file path and ground truth path.
As OD dump directory structure differes from other simple outputs, it needs
a specific processing to match the output OD dump file with corresponding
OD GT file.
The outputs are placed in a dicrectory structure:
analysis
|- document_name
|- layout_dump
|- object_detection.json
|- bboxes # not used in this evaluation
and the GT file is pleced in od_gt directory for given dataset
dataset_name
|- od_gt
|- document_name.pdf.json
Args:
doc (Path): path to the OD dump file
Returns:
tuple: doctype, prediction file path, ground truth path
"""
od_dump_path = Path(doc)
file_stem = od_dump_path.parts[-3] # we take the `document_name` - so the filename stem
src_gt_filename = self._find_file_in_ground_truth(file_stem)
if src_gt_filename not in self._ground_truth_paths:
raise ValueError(f"Ground truth file {src_gt_filename} not found in list of GT files")
doctype = Path(src_gt_filename.stem).suffix[1:]
prediction_file = self.documents_dir / doc
if not prediction_file.exists():
logger.warning(f"Prediction file {prediction_file} does not exist, skipping")
raise ValueError(f"Prediction file {prediction_file} does not exist")
ground_truth_file = self.ground_truths_dir / src_gt_filename
if not ground_truth_file.exists():
logger.warning(f"Ground truth file {ground_truth_file} does not exist, skipping")
raise ValueError(f"Ground truth file {ground_truth_file} does not exist")
return doctype, prediction_file, ground_truth_file
def _generate_dataframes(self, rows) -> tuple[pd.DataFrame, pd.DataFrame]:
headers = ["filename", "doctype", "connector"] + self.supported_metric_names
df = pd.DataFrame(rows, columns=headers)
if df.empty:
agg_df = pd.DataFrame(columns=AGG_HEADERS)
else:
element_metrics_results = {}
for metric in self.supported_metric_names:
metric_df = df[df[metric].notnull()]
agg_metric = metric_df[metric].agg([_mean, _stdev, _pstdev, _count]).transpose()
if agg_metric.empty:
element_metrics_results[metric] = pd.Series(
data=[None, None, None, 0], index=["_mean", "_stdev", "_pstdev", "_count"]
)
else:
element_metrics_results[metric] = agg_metric
agg_df = pd.DataFrame(element_metrics_results).transpose().reset_index()
agg_df.columns = AGG_HEADERS
return df, agg_df
class ObjectDetectionPerClassMetricsCalculator(ObjectDetectionMetricsCalculatorBase):
def __post_init__(self):
super().__post_init__()
self.per_class_metric_names: list[str] | None = None
self._set_supported_metrics()
@property
def supported_metric_names(self):
if self.per_class_metric_names:
return self.per_class_metric_names
else:
raise ValueError("per_class_metrics not initialized - cannot get class names")
@property
def default_tsv_name(self):
return "all-docs-object-detection-metrics-per-class.tsv"
@property
def default_agg_tsv_name(self):
return "aggregate-object-detection-metrics-per-class.tsv"
def _process_document(self, doc: Path) -> Optional[list]:
"""Calculate both class-aggregated and per-class metrics for a single document.
Args:
doc (Path): path to the OD dump file
Returns:
tuple: a tuple of aggregated and per-class metrics for a single document
"""
try:
doctype, prediction_file, ground_truth_file = self._get_paths(doc)
except ValueError as e:
logger.error(f"Failed to process document {doc}: {e}")
return None
processor = ObjectDetectionEvalProcessor.from_json_files(
prediction_file_path=prediction_file,
ground_truth_file_path=ground_truth_file,
)
_, per_class_metrics = processor.get_metrics()
per_class_metrics_row = [
ground_truth_file.stem,
doctype,
None, # connector
]
for combined_metric_name in self.supported_metric_names:
metric = "_".join(combined_metric_name.split("_")[:-1])
class_name = combined_metric_name.split("_")[-1]
class_metrics = getattr(per_class_metrics, metric)
per_class_metrics_row.append(class_metrics[class_name])
return per_class_metrics_row
def _set_supported_metrics(self):
"""Sets the supported metrics based on the classes found in the ground truth files.
The difference between per class and aggregated calculator is that the list of classes
(so the metrics) bases on the contents of the GT / prediction files.
"""
metrics = ["f1_score", "precision", "recall", "m_ap"]
classes = set()
for gt_file in self._ground_truth_paths:
gt_file_path = self.ground_truths_dir / gt_file
with open(gt_file_path) as f:
gt = json.load(f)
gt_classes = gt["object_detection_classes"]
classes.update(gt_classes)
per_class_metric_names = []
for metric in metrics:
for class_name in classes:
per_class_metric_names.append(f"{metric}_{class_name}")
self.per_class_metric_names = sorted(per_class_metric_names)
class ObjectDetectionAggregatedMetricsCalculator(ObjectDetectionMetricsCalculatorBase):
"""Calculates object detection metrics for each document and aggregates by all classes"""
@property
def supported_metric_names(self):
return ["f1_score", "precision", "recall", "m_ap"]
@property
def default_tsv_name(self):
return "all-docs-object-detection-metrics.tsv"
@property
def default_agg_tsv_name(self):
return "aggregate-object-detection-metrics.tsv"
def _process_document(self, doc: Path) -> Optional[list]:
"""Calculate both class-aggregated and per-class metrics for a single document.
Args:
doc (Path): path to the OD dump file
Returns:
list: a list of aggregated metrics for a single document
"""
try:
doctype, prediction_file, ground_truth_file = self._get_paths(doc)
except ValueError as e:
logger.error(f"Failed to process document {doc}: {e}")
return None
processor = ObjectDetectionEvalProcessor.from_json_files(
prediction_file_path=prediction_file,
ground_truth_file_path=ground_truth_file,
)
metrics, _ = processor.get_metrics()
return [
ground_truth_file.stem,
doctype,
None, # connector
] + [getattr(metrics, metric) for metric in self.supported_metric_names]
+721
View File
@@ -0,0 +1,721 @@
"""
Implements object detection metrics: average precision, precision, recall, and f1 score.
"""
import json
from dataclasses import dataclass
from pathlib import Path
import numpy as np
import torch
IOU_THRESHOLDS = torch.tensor(
[0.5000, 0.5500, 0.6000, 0.6500, 0.7000, 0.7500, 0.8000, 0.8500, 0.9000, 0.9500]
)
SCORE_THRESHOLD = 0.1
RECALL_THRESHOLDS = torch.arange(0, 1.01, 0.01)
@dataclass
class ObjectDetectionAggregatedEvaluation:
"""Class representing a gathered class-aggregated object detection metrics"""
f1_score: float
precision: float
recall: float
m_ap: float
@dataclass
class ObjectDetectionPerClassEvaluation:
"""Class representing a gathered object detection metrics per-class"""
f1_score: dict[str, float]
precision: dict[str, float]
recall: dict[str, float]
m_ap: dict[str, float]
@classmethod
def from_tensors(cls, ap, precision, recall, f1, class_labels):
f1_score = {class_labels[i]: f1[i] for i in range(len(class_labels))}
precision = {class_labels[i]: precision[i] for i in range(len(class_labels))}
recall = {class_labels[i]: recall[i] for i in range(len(class_labels))}
m_ap = {class_labels[i]: ap[i] for i in range(len(class_labels))}
return cls(f1_score, precision, recall, m_ap)
class ObjectDetectionEvalProcessor:
iou_thresholds = IOU_THRESHOLDS
score_threshold = SCORE_THRESHOLD
recall_thresholds = RECALL_THRESHOLDS
def __init__(
self,
document_preds: list[torch.Tensor],
document_targets: list[torch.Tensor],
pages_height: list[int],
pages_width: list[int],
class_labels: list[str],
device: str = "cpu",
):
"""
Initializes the ObjectDetection prediction and ground truth.
Args:
document_preds (list): list (of length pages of document) of
Tensors of shape (num_predictions, 6)
format: (x1, y1, x2, y2, confidence,class_label)
where x1,y1,x2,y2 are according to image size
document_targets (list): list (of length pages of document) of
Tensors of shape (num_targets, 6)
format: (label, x1, y1, x2, y2)
where x,y,w,h are according to image size
pages_height (list): list of height of each page in the document
pages_width (list): list of width of each page in the document
class_labels (list): list of class labels
"""
self.device = device
self.document_preds = [pred.to(device) for pred in document_preds]
self.document_targets = [target.to(device) for target in document_targets]
self.pages_height = pages_height
self.pages_width = pages_width
self.class_labels = class_labels
@classmethod
def from_json_files(
cls,
prediction_file_path: Path,
ground_truth_file_path: Path,
) -> "ObjectDetectionEvalProcessor":
"""
Initializes the ObjectDetection prediction and ground truth,
and converts the data to the required format.
Args:
prediction_file_path (Path): path to json file with predictions dump from OD model
ground_truth_file_path (Path): path to json file with OD ground truth data
"""
# TODO: Test after https://unstructured-ai.atlassian.net/browse/ML-92
# is done.
with open(prediction_file_path) as f:
predictions_data = json.load(f)
with open(ground_truth_file_path) as f:
ground_truth_data = json.load(f)
assert sorted(predictions_data["object_detection_classes"]) == sorted(
ground_truth_data["object_detection_classes"]
), "Classes in predictions and ground truth do not match."
assert len(predictions_data["pages"]) == len(ground_truth_data["pages"]), (
"Pages number in predictions and ground truth do not match."
)
for pred_page, gt_page in zip(
sorted(predictions_data["pages"], key=lambda p: p["number"]),
sorted(ground_truth_data["pages"], key=lambda p: p["number"]),
):
assert pred_page["number"] == gt_page["number"], (
f"Page numbers in predictions {prediction_file_path.name} "
f"({pred_page['number']}) and ground truth {ground_truth_file_path.name} "
f"({gt_page['number']}) do not match."
)
page_num = pred_page["number"]
# TODO: translate the bboxes instead of raising error
assert pred_page["size"] == gt_page["size"], (
f"Page sizes in predictions {prediction_file_path.name} "
f"({pred_page['size'][0]} x {pred_page['size'][1]}) "
f"and ground truth {ground_truth_file_path.name} ({gt_page['size'][0]} x "
f"{gt_page['size'][1]}) do not match for page {page_num}."
)
class_labels = predictions_data["object_detection_classes"]
document_preds = cls._process_data(predictions_data, class_labels, prediction=True)
document_targets = cls._process_data(ground_truth_data, class_labels)
pages_height, pages_width = cls._parse_page_dimensions(predictions_data)
return cls(document_preds, document_targets, pages_height, pages_width, class_labels)
def get_metrics(
self,
) -> tuple[ObjectDetectionAggregatedEvaluation, ObjectDetectionPerClassEvaluation]:
"""Get per document OD metrics.
Returns:
tuple: Tuple of ObjectDetectionAggregatedEvaluation and
ObjectDetectionPerClassEvaluation
"""
document_matchings = []
for preds, targets, height, width in zip(
self.document_preds, self.document_targets, self.pages_height, self.pages_width
):
# iterate over each page
page_matching_tensors = self._compute_page_detection_matching(
preds=preds,
targets=targets,
height=height,
width=width,
)
document_matchings.append(page_matching_tensors)
# compute metrics for all detections and targets
mean_ap, mean_precision, mean_recall, mean_f1 = (
-1.0,
-1.0,
-1.0,
-1.0,
)
num_cls = len(self.class_labels)
mean_ap_per_class = np.full(num_cls, np.nan)
mean_precision_per_class = np.full(num_cls, np.nan)
mean_recall_per_class = np.full(num_cls, np.nan)
mean_f1_per_class = np.full(num_cls, np.nan)
if len(document_matchings):
matching_info_tensors = [torch.cat(x, 0) for x in list(zip(*document_matchings))]
# shape (n_class, nb_iou_thresh)
(
ap_per_present_classes,
precision_per_present_classes,
recall_per_present_classes,
f1_per_present_classes,
present_classes,
) = self._compute_detection_metrics(
*matching_info_tensors,
)
# Precision, recall and f1 are computed for IoU threshold range, averaged over classes
# results before version 3.0.4 (Dec 11 2022) were computed only for smallest value
# (i.e IoU 0.5 if metric is @0.5:0.95)
mean_precision, mean_recall, mean_f1 = (
precision_per_present_classes.mean(),
recall_per_present_classes.mean(),
f1_per_present_classes.mean(),
)
# MaP is averaged over IoU thresholds and over classes
mean_ap = ap_per_present_classes.mean()
# Fill array of per-class AP scores with values for classes that were present in the
# dataset
ap_per_class = ap_per_present_classes.mean(1)
precision_per_class = precision_per_present_classes.mean(1)
recall_per_class = recall_per_present_classes.mean(1)
f1_per_class = f1_per_present_classes.mean(1)
for i, class_index in enumerate(present_classes):
mean_ap_per_class[class_index] = float(ap_per_class[i])
mean_precision_per_class[class_index] = float(precision_per_class[i])
mean_recall_per_class[class_index] = float(recall_per_class[i])
mean_f1_per_class[class_index] = float(f1_per_class[i])
od_per_class_evaluation = ObjectDetectionPerClassEvaluation.from_tensors(
ap=mean_ap_per_class,
precision=mean_precision_per_class,
recall=mean_recall_per_class,
f1=mean_f1_per_class,
class_labels=self.class_labels,
)
od_evaluation = ObjectDetectionAggregatedEvaluation(
f1_score=float(mean_f1),
precision=float(mean_precision),
recall=float(mean_recall),
m_ap=float(mean_ap),
)
return od_evaluation, od_per_class_evaluation
@staticmethod
def _parse_page_dimensions(data: dict) -> tuple[list, list]:
"""
Process the page dimensions from the json file to the required format.
"""
pages_height = []
pages_width = []
for page in data["pages"]:
pages_height.append(page["size"]["height"])
pages_width.append(page["size"]["width"])
return pages_height, pages_width
@staticmethod
def _process_data(data: dict, class_labels, prediction: bool = False) -> list[dict]:
"""
Process the elements from the json file to the required format.
"""
pages_list = []
for page in data["pages"]:
page_elements = []
for element in page["elements"]:
# Extract coordinates, confidence, and class label from each prediction
class_label = element["type"]
class_idx = class_labels.index(class_label)
x1, y1, x2, y2 = element["bbox"]
if prediction:
confidence = element["prob"]
page_elements.append([x1, y1, x2, y2, confidence, class_idx])
else:
page_elements.append([class_idx, x1, y1, x2, y2])
page_tensor = torch.tensor(page_elements)
pages_list.append(page_tensor)
return pages_list
@staticmethod
def _get_top_k_idx_per_cls(
preds_scores: torch.Tensor, preds_cls: torch.Tensor, top_k: int
) -> torch.Tensor:
# From: https://github.com/Deci-AI/super-gradients/blob/master/src/super_gradients/training/utils/detection_utils.py # noqa E501
"""
Get the indexes of all the top k predictions for every class
Args:
preds_scores: The confidence scores, vector of shape (n_pred)
preds_cls: The predicted class, vector of shape (n_pred)
top_k: Number of predictions to keep per class, ordered by confidence score
Returns:
top_k_idx: Indexes of the top k predictions. length <= (k * n_unique_class)
"""
n_unique_cls = torch.max(preds_cls)
mask = preds_cls.view(-1, 1) == torch.arange(
n_unique_cls + 1, device=preds_scores.device
).view(1, -1)
preds_scores_per_cls = preds_scores.view(-1, 1) * mask
sorted_scores_per_cls, sorting_idx = preds_scores_per_cls.sort(0, descending=True)
idx_with_satisfying_scores = sorted_scores_per_cls[:top_k, :].nonzero(as_tuple=False)
top_k_idx = sorting_idx[idx_with_satisfying_scores.split(1, dim=1)]
return top_k_idx.view(-1)
@staticmethod
def _change_bbox_bounds_for_image_size(
boxes: np.ndarray, img_shape: tuple[int, int]
) -> np.ndarray:
# From: https://github.com/Deci-AI/super-gradients/blob/master/src/super_gradients/training/utils/detection_utils.py # noqa E501
"""
Clips bboxes to image boundaries.
Args:
bboxes: Input bounding boxes in XYXY format of [..., 4] shape
img_shape: Image shape (height, width).
Returns:
clipped_boxes: Clipped bboxes in XYXY format of [..., 4] shape
"""
boxes[..., [0, 2]] = boxes[..., [0, 2]].clip(min=0, max=img_shape[1])
boxes[..., [1, 3]] = boxes[..., [1, 3]].clip(min=0, max=img_shape[0])
return boxes
@staticmethod
def _box_iou(box1: torch.Tensor, box2: torch.Tensor) -> torch.Tensor:
# From: https://github.com/Deci-AI/super-gradients/blob/master/src/super_gradients/training/utils/detection_utils.py # noqa E501
"""
Return intersection-over-union (Jaccard index) of boxes.
Both sets of boxes are expected to be in (x1, y1, x2, y2) format.
Args:
box1: Tensor of shape [N, 4]
box2: Tensor of shape [M, 4]
Returns:
iou: Tensor of shape [N, M]: the NxM matrix containing the pairwise IoU values
for every element in boxes1 and boxes2
"""
def box_area(box):
# box = 4xn
return (box[2] - box[0]) * (box[3] - box[1])
area1 = box_area(box1.T)
area2 = box_area(box2.T)
# inter(N,M) = (rb(N,M,2) - lt(N,M,2)).clamp(0).prod(2)
inter = (
(torch.min(box1[:, None, 2:], box2[:, 2:]) - torch.max(box1[:, None, :2], box2[:, :2]))
.clamp(0)
.prod(2)
)
return inter / (area1[:, None] + area2 - inter) # iou = inter / (area1 + area2 - inter)
def _compute_targets(
self,
preds_box_xyxy: torch.Tensor,
preds_cls: torch.Tensor,
targets_box_xyxy: torch.Tensor,
targets_cls: torch.Tensor,
preds_matched: torch.Tensor,
targets_matched: torch.Tensor,
preds_idx_to_use: torch.Tensor,
iou_thresholds: torch.Tensor,
) -> torch.Tensor:
# From: https://github.com/Deci-AI/super-gradients/blob/master/src/super_gradients/training/utils/detection_utils.py # noqa E501
"""
Computes the matching targets based on IoU for regular scenarios.
Args:
preds_box_xyxy: (torch.Tensor) Predicted bounding boxes in XYXY format.
preds_cls: (torch.Tensor) Predicted classes.
targets_box_xyxy: (torch.Tensor) Target bounding boxes in XYXY format.
targets_cls: (torch.Tensor) Target classes.
preds_matched: (torch.Tensor) Tensor indicating which predictions are matched.
targets_matched: (torch.Tensor) Tensor indicating which targets are matched.
preds_idx_to_use: (torch.Tensor) Indices of predictions to use.
Returns:
targets: Computed matching targets.
"""
# shape = (n_preds x n_targets)
iou = self._box_iou(preds_box_xyxy[preds_idx_to_use], targets_box_xyxy)
# Fill IoU values at index (i, j) with 0 when the prediction (i) and target(j)
# are of different class
# Filling with 0 is equivalent to ignore these values
# since with want IoU > iou_threshold > 0
cls_mismatch = preds_cls[preds_idx_to_use].view(-1, 1) != targets_cls.view(1, -1)
iou[cls_mismatch] = 0
# The matching priority is first detection confidence and then IoU value.
# The detection is already sorted by confidence in NMS,
# so here for each prediction we order the targets by iou.
sorted_iou, target_sorted = iou.sort(descending=True, stable=True)
# Only iterate over IoU values higher than min threshold to speed up the process
for pred_selected_i, target_sorted_i in (sorted_iou > iou_thresholds[0]).nonzero(
as_tuple=False
):
# pred_selected_i and target_sorted_i are relative to filters/sorting,
# so we extract their absolute indexes
pred_i = preds_idx_to_use[pred_selected_i]
target_i = target_sorted[pred_selected_i, target_sorted_i]
# Vector[j], True when IoU(pred_i, target_i) is above the (j)th threshold
is_iou_above_threshold = sorted_iou[pred_selected_i, target_sorted_i] > iou_thresholds
# Vector[j], True when both pred_i and target_i are not matched yet
# for the (j)th threshold
are_candidates_free = torch.logical_and(
~preds_matched[pred_i, :], ~targets_matched[target_i, :]
)
# Vector[j], True when (pred_i, target_i) can be matched for the (j)th threshold
are_candidates_good = torch.logical_and(is_iou_above_threshold, are_candidates_free)
# For every threshold (j) where target_i and pred_i can be matched together
# ( are_candidates_good[j]==True )
# fill the matching placeholders with True
targets_matched[target_i, are_candidates_good] = True
preds_matched[pred_i, are_candidates_good] = True
# When all the targets are matched with a prediction for every IoU Threshold, stop.
if targets_matched.all():
break
return preds_matched
def _compute_page_detection_matching(
self,
preds: torch.Tensor,
targets: torch.Tensor,
height: int,
width: int,
top_k: int = 100,
return_on_cpu: bool = True,
) -> tuple:
# Adapted from: https://github.com/Deci-AI/super-gradients/blob/master/src/super_gradients/training/utils/detection_utils.py # noqa E501
"""
Match predictions (NMS output) and the targets (ground truth) with respect to metric
and confidence score for a given image.
Args:
preds: Tensor of shape (num_img_predictions, 6)
format: (x1, y1, x2, y2, confidence, class_label)
where x1,y1,x2,y2 are according to image size
targets: targets for this image of shape (num_img_targets, 5)
format: (label, x1, y1, x2, y2)
where x1,y1,x2,y2 are according to image size
height: dimensions of the image
width: dimensions of the image
top_k: Number of predictions to keep per class, ordered by confidence score
return_on_cpu: If True, the output will be returned on "CPU", otherwise it will be
returned on "device"
Returns:
preds_matched: Tensor of shape (num_img_predictions, n_thresholds)
True when prediction (i) is matched with a target with respect to
the (j)th threshold
preds_to_ignore: Tensor of shape (num_img_predictions, n_thresholds)
True when prediction (i) is matched with a crowd target with
respect to the (j)th threshold
preds_scores: Tensor of shape (num_img_predictions),
confidence score for every prediction
preds_cls: Tensor of shape (num_img_predictions),
predicted class for every prediction
targets_cls: Tensor of shape (num_img_targets),
ground truth class for every target
"""
thresholds = self.iou_thresholds.to(device=self.device)
num_thresholds = len(thresholds)
if preds is None or len(preds) == 0:
preds_matched = torch.zeros((0, num_thresholds), dtype=torch.bool, device=self.device)
preds_to_ignore = torch.zeros((0, num_thresholds), dtype=torch.bool, device=self.device)
preds_scores = torch.tensor([], dtype=torch.float32, device=self.device)
preds_cls = torch.tensor([], dtype=torch.float32, device=self.device)
targets_cls = targets[:, 0].to(device=self.device)
return preds_matched, preds_to_ignore, preds_scores, preds_cls, targets_cls
preds_matched = torch.zeros(
len(preds), num_thresholds, dtype=torch.bool, device=self.device
)
targets_matched = torch.zeros(
len(targets), num_thresholds, dtype=torch.bool, device=self.device
)
preds_to_ignore = torch.zeros(
len(preds), num_thresholds, dtype=torch.bool, device=self.device
)
preds_cls, preds_box, preds_scores = preds[:, -1], preds[:, 0:4], preds[:, 4]
targets_cls, targets_box = targets[:, 0], targets[:, 1:5]
# Ignore all but the predictions that were top_k for their class
preds_idx_to_use = self._get_top_k_idx_per_cls(preds_scores, preds_cls, top_k)
preds_to_ignore[:, :] = True
preds_to_ignore[preds_idx_to_use] = False
if len(targets) > 0: # or len(crowd_targets) > 0:
self._change_bbox_bounds_for_image_size(preds, (height, width))
preds_matched = self._compute_targets(
preds_box,
preds_cls,
targets_box,
targets_cls,
preds_matched,
targets_matched,
preds_idx_to_use,
thresholds,
)
return preds_matched, preds_to_ignore, preds_scores, preds_cls, targets_cls
def _compute_detection_metrics(
self,
preds_matched: torch.Tensor,
preds_to_ignore: torch.Tensor,
preds_scores: torch.Tensor,
preds_cls: torch.Tensor,
targets_cls: torch.Tensor,
) -> tuple:
# Adapted from: https://github.com/Deci-AI/super-gradients/blob/master/src/super_gradients/training/utils/detection_utils.py # noqa E501
"""
Compute the list of precision, recall, MaP and f1 for every class.
Args:
preds_matched: Tensor of shape (num_predictions, n_iou_thresholds)
True when prediction (i) is matched with a target with respect
to the (j)th IoU threshold
preds_to_ignore Tensor of shape (num_predictions, n_iou_thresholds)
True when prediction (i) is matched with a crowd target with
respect to the (j)th IoU threshold
preds_scores: Tensor of shape (num_predictions),
confidence score for every prediction
preds_cls: Tensor of shape (num_predictions),
predicted class for every prediction
targets_cls: Tensor of shape (num_targets),
ground truth class for every target box to be detected
Returns:
ap, precision, recall, f1: Tensors of shape (n_class, nb_iou_thrs)
unique_classes: Vector with all unique target classes
"""
preds_matched, preds_to_ignore = (
preds_matched.to(self.device),
preds_to_ignore.to(self.device),
)
preds_scores, preds_cls, targets_cls = (
preds_scores.to(self.device),
preds_cls.to(self.device),
targets_cls.to(self.device),
)
recall_thresholds = self.recall_thresholds.to(self.device)
score_threshold = self.score_threshold
unique_classes = torch.unique(targets_cls).long()
n_class, nb_iou_thrs = len(unique_classes), preds_matched.shape[-1]
ap = torch.zeros((n_class, nb_iou_thrs), device=self.device)
precision = torch.zeros((n_class, nb_iou_thrs), device=self.device)
recall = torch.zeros((n_class, nb_iou_thrs), device=self.device)
for cls_i, class_value in enumerate(unique_classes):
cls_preds_idx, cls_targets_idx = (
(preds_cls == class_value),
(targets_cls == class_value),
)
(
cls_ap,
cls_precision,
cls_recall,
) = self._compute_detection_metrics_per_cls(
preds_matched=preds_matched[cls_preds_idx],
preds_to_ignore=preds_to_ignore[cls_preds_idx],
preds_scores=preds_scores[cls_preds_idx],
n_targets=cls_targets_idx.sum(),
recall_thresholds=recall_thresholds,
score_threshold=score_threshold,
)
ap[cls_i, :] = cls_ap
precision[cls_i, :] = cls_precision
recall[cls_i, :] = cls_recall
f1 = 2 * precision * recall / (precision + recall + 1e-16)
return ap, precision, recall, f1, unique_classes
def _compute_detection_metrics_per_cls(
self,
preds_matched: torch.Tensor,
preds_to_ignore: torch.Tensor,
preds_scores: torch.Tensor,
n_targets: int,
recall_thresholds: torch.Tensor,
score_threshold: float,
):
# Adapted from: https://github.com/Deci-AI/super-gradients/blob/master/src/super_gradients/training/utils/detection_utils.py # noqa E501
"""
Compute the list of precision, recall and MaP of a given class for every recall threshold.
Args:
preds_matched: Tensor of shape (num_predictions, n_thresholds)
True when prediction (i) is matched with a target
with respect to the(j)th threshold
preds_to_ignore Tensor of shape (num_predictions, n_thresholds)
True when prediction (i) is matched with a crowd target
with respect to the (j)th threshold
preds_scores: Tensor of shape (num_predictions),
confidence score for every prediction
n_targets: Number of target boxes of this class
recall_thresholds: Tensor of shape (max_n_rec_thresh)
list of recall thresholds used to compute MaP
score_threshold: Minimum confidence score to consider a prediction
for the computation of precision and recall (not MaP)
Returns:
ap, precision, recall: Tensors of shape (nb_thrs)
"""
nb_iou_thrs = preds_matched.shape[-1]
tps = preds_matched
fps = torch.logical_and(
torch.logical_not(preds_matched), torch.logical_not(preds_to_ignore)
)
if len(tps) == 0:
return (
torch.zeros(nb_iou_thrs, device=self.device),
torch.zeros(nb_iou_thrs, device=self.device),
torch.zeros(nb_iou_thrs, device=self.device),
)
# Sort by decreasing score
dtype = (
torch.uint8
if preds_scores.is_cuda and preds_scores.dtype is torch.bool
else preds_scores.dtype
)
sort_ind = torch.argsort(preds_scores.to(dtype), descending=True)
tps = tps[sort_ind, :]
fps = fps[sort_ind, :]
preds_scores = preds_scores[sort_ind].contiguous()
# Rolling sum over the predictions
rolling_tps = torch.cumsum(tps, axis=0, dtype=torch.float)
rolling_fps = torch.cumsum(fps, axis=0, dtype=torch.float)
rolling_recalls = rolling_tps / n_targets
rolling_precisions = rolling_tps / (
rolling_tps + rolling_fps + torch.finfo(torch.float64).eps
)
# Reversed cummax to only have decreasing values
rolling_precisions = rolling_precisions.flip(0).cummax(0).values.flip(0)
# ==================
# RECALL & PRECISION
# We want the rolling precision/recall at index i so that:
# preds_scores[i-1] >= score_threshold > preds_scores[i]
# Note: torch.searchsorted works on increasing sequence and preds_scores is decreasing,
# so we work with "-"
# Note2: right=True due to negation
lowest_score_above_threshold = torch.searchsorted(
-preds_scores, -score_threshold, right=True
)
if (
lowest_score_above_threshold == 0
): # Here score_threshold > preds_scores[0], so no pred is above the threshold
recall = torch.zeros(nb_iou_thrs, device=self.device)
precision = torch.zeros(
nb_iou_thrs, device=self.device
) # the precision is not really defined when no pred but we need to give it a value
else:
recall = rolling_recalls[lowest_score_above_threshold - 1]
precision = rolling_precisions[lowest_score_above_threshold - 1]
# ==================
# AVERAGE PRECISION
# shape = (nb_iou_thrs, n_recall_thresholds)
recall_thresholds = recall_thresholds.view(1, -1).repeat(nb_iou_thrs, 1)
# We want the index i so that:
# rolling_recalls[i-1] < recall_thresholds[k] <= rolling_recalls[i]
# Note: when recall_thresholds[k] > max(rolling_recalls), i = len(rolling_recalls)
# Note2: we work with transpose (.T) to apply torch.searchsorted on first dim
# instead of the last one
recall_threshold_idx = torch.searchsorted(
rolling_recalls.T.contiguous(), recall_thresholds, right=False
).T
# When recall_thresholds[k] > max(rolling_recalls),
# rolling_precisions[i] is not defined, and we want precision = 0
rolling_precisions = torch.cat(
(rolling_precisions, torch.zeros(1, nb_iou_thrs, device=self.device)), dim=0
)
# shape = (n_recall_thresholds, nb_iou_thrs)
sampled_precision_points = torch.gather(
input=rolling_precisions, index=recall_threshold_idx, dim=0
)
# Average over the recall_thresholds
ap = sampled_precision_points.mean(0)
return ap, precision, recall
if __name__ == "__main__":
from dataclasses import asdict
# Example usage
prediction_file_paths = [Path("pths/to/predictions.json"), Path("pths/to/predictions2.json")]
ground_truth_file_paths = [
Path("pths/to/ground_truth.json"),
Path("pths/to/ground_truth2.json"),
]
for prediction_file_path, ground_truth_file_path in zip(
prediction_file_paths, ground_truth_file_paths
):
eval_processor = ObjectDetectionEvalProcessor.from_json_files(
prediction_file_path, ground_truth_file_path
)
metrics, per_class_metrics = eval_processor.get_metrics()
print(f"Metrics for {ground_truth_file_path.name}:\n{asdict(metrics)}")
print(f"Per class Metrics for {ground_truth_file_path.name}:\n{asdict(per_class_metrics)}")
@@ -0,0 +1,180 @@
import difflib
from typing import Any, Dict, List
import numpy as np
import pandas as pd
from unstructured_inference.models.eval import compare_contents_as_df
class TableAlignment:
def __init__(self, cutoff: float = 0.8):
self.cutoff = cutoff
@staticmethod
def get_content_in_tables(table_data: List[List[Dict[str, Any]]]) -> List[str]:
# Replace below docstring with google-style docstring
"""Extracts and concatenates the content of cells from each table in a list of tables.
Args:
table_data: A list of tables, each table being a list of cell data dictionaries.
Returns:
List of strings where each string represents the concatenated content of one table.
"""
return [" ".join([d["content"] for d in td if "content" in d]) for td in table_data]
@staticmethod
def get_table_level_alignment(
predicted_table_data: List[List[Dict[str, Any]]],
ground_truth_table_data: List[List[Dict[str, Any]]],
) -> List[int]:
"""Compares predicted table data with ground truth data to find the best
matching table index for each predicted table.
Args:
predicted_table_data: A list of predicted tables.
ground_truth_table_data: A list of ground truth tables.
Returns:
A list of indices indicating the best match in the ground truth for
each predicted table.
"""
ground_truth_texts = TableAlignment.get_content_in_tables(ground_truth_table_data)
matched_indices = []
for td in predicted_table_data:
reference = TableAlignment.get_content_in_tables([td])[0]
matches = difflib.get_close_matches(reference, ground_truth_texts, cutoff=0.1, n=1)
matched_indices.append(ground_truth_texts.index(matches[0]) if matches else -1)
return matched_indices
@staticmethod
def _zip_to_dataframe(table_data: List[Dict[str, Any]]) -> pd.DataFrame:
df = pd.DataFrame(table_data, columns=["row_index", "col_index", "content"])
df = df.set_index("row_index")
df["col_index"] = df["col_index"].astype(str)
return df
@staticmethod
def get_element_level_alignment(
predicted_table_data: List[List[Dict[str, Any]]],
ground_truth_table_data: List[List[Dict[str, Any]]],
matched_indices: List[int],
cutoff: float = 0.8,
) -> Dict[str, float]:
"""Aligns elements of the predicted tables with the ground truth tables at the cell level.
Args:
predicted_table_data: A list of predicted tables.
ground_truth_table_data: A list of ground truth tables.
matched_indices: Indices of the best matching ground truth table for each predicted table.
cutoff: The cutoff value for the close matches.
Returns:
A dictionary with column and row alignment accuracies.
"""
content_diff_cols = []
content_diff_rows = []
col_index_acc = []
row_index_acc = []
for idx, td in zip(matched_indices, predicted_table_data):
if idx == -1:
content_diff_cols.append(0)
content_diff_rows.append(0)
col_index_acc.append(0)
row_index_acc.append(0)
continue
ground_truth_td = ground_truth_table_data[idx]
# Get row and col content accuracy
predict_table_df = TableAlignment._zip_to_dataframe(td)
ground_truth_table_df = TableAlignment._zip_to_dataframe(ground_truth_td)
table_content_diff = compare_contents_as_df(
ground_truth_table_df.fillna(""),
predict_table_df.fillna(""),
)
content_diff_cols.append(table_content_diff["by_col_token_ratio"])
content_diff_rows.append(table_content_diff["by_row_token_ratio"])
aligned_element_col_count = 0
aligned_element_row_count = 0
total_element_count = 0
# Get row and col index accuracy
ground_truth_td_contents_list = [gtd["content"].lower() for gtd in ground_truth_td]
used_indices = set()
indices_tuple_pairs = []
for td_ele in td:
content = td_ele["content"].lower()
row_index = td_ele["row_index"]
col_idx = td_ele["col_index"]
matches = difflib.get_close_matches(
content,
ground_truth_td_contents_list,
cutoff=cutoff,
n=1,
)
# BUG FIX: the previous matched_idx will only output the first matched index if
# the match has duplicates in the
# ground_truth_td_contents_list, the current fix will output its correspondence idx
# once matching is exhausted, it will go back search again the same fashion
matching_indices = []
if matches != []:
b_indices = [
i
for i, b_string in enumerate(ground_truth_td_contents_list)
if b_string == matches[0] and i not in used_indices
]
if not b_indices:
# If all indices are used, reset used_indices and use the first index
used_indices.clear()
b_indices = [
i
for i, b_string in enumerate(ground_truth_td_contents_list)
if b_string == matches[0] and i not in used_indices
]
matching_index = b_indices[0]
matching_indices.append(matching_index)
used_indices.add(matching_index)
else:
matching_indices = [-1]
matched_idx = matching_indices[0]
if matched_idx >= 0:
gt_row_index = ground_truth_td[matched_idx]["row_index"]
gt_col_index = ground_truth_td[matched_idx]["col_index"]
indices_tuple_pairs.append(((row_index, col_idx), (gt_row_index, gt_col_index)))
for indices_tuple_pair in indices_tuple_pairs:
if indices_tuple_pair[0][0] == indices_tuple_pair[1][0]:
aligned_element_row_count += 1
if indices_tuple_pair[0][1] == indices_tuple_pair[1][1]:
aligned_element_col_count += 1
total_element_count += 1
table_col_index_acc = 0
table_row_index_acc = 0
if total_element_count > 0:
table_col_index_acc = round(aligned_element_col_count / total_element_count, 2)
table_row_index_acc = round(aligned_element_row_count / total_element_count, 2)
col_index_acc.append(table_col_index_acc)
row_index_acc.append(table_row_index_acc)
not_found_gt_table_indexes = [
id for id in range(len(ground_truth_table_data)) if id not in matched_indices
]
for _ in not_found_gt_table_indexes:
content_diff_cols.append(0)
content_diff_rows.append(0)
col_index_acc.append(0)
row_index_acc.append(0)
return {
"col_index_acc": round(np.mean(col_index_acc), 2),
"row_index_acc": round(np.mean(row_index_acc), 2),
"col_content_acc": round(np.mean(content_diff_cols) / 100.0, 2),
"row_content_acc": round(np.mean(content_diff_rows) / 100.0, 2),
}
+342
View File
@@ -0,0 +1,342 @@
"""
The purpose of this script is to create a comprehensive metric for table evaluation
1. Verify table identification.
a. Concatenate all text in the table and ground truth.
b. Calculate the difference to find the closest matches.
c. If contents are too different, mark as a failure.
2. For each identified table:
a. Align elements at the level of individual elements.
b. Match elements by text.
c. Determine indexes for both predicted and actual data.
d. Compare index tuples at column and row levels to assess content shifts.
e. Compare the token orders by flattened along column and row levels
f. Note: Imperfect HTML is acceptable unless it impedes parsing,
in which case the table is considered failed.
Example
python table_eval.py \
--prediction_file "model_output.pdf.json" \
--ground_truth_file "ground_truth.pdf.json"
"""
import difflib
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional
import click
import numpy as np
from unstructured.metrics.table.table_alignment import TableAlignment
from unstructured.metrics.table.table_extraction import (
extract_and_convert_tables_from_ground_truth,
extract_and_convert_tables_from_prediction,
)
@dataclass
class TableEvaluation:
"""Class representing a gathered table metrics."""
total_tables: int
total_predicted_tables: int
table_level_acc: float
table_detection_recall: float
table_detection_precision: float
table_detection_f1: float
element_col_level_index_acc: float
element_row_level_index_acc: float
element_col_level_content_acc: float
element_row_level_content_acc: float
@property
def composite_structure_acc(self) -> float:
return (
self.element_col_level_index_acc
+ self.element_row_level_index_acc
+ (self.element_col_level_content_acc + self.element_row_level_content_acc) / 2
) / 3
def table_level_acc(predicted_table_data, ground_truth_table_data, matched_indices):
"""computes for each predicted table its accurary compared to ground truth.
The accuracy is defined as the SequenceMatcher.ratio() between those two strings. If a
prediction does not have a matched ground truth its accuracy is 0
"""
score = np.zeros((len(matched_indices),))
ground_truth_text = TableAlignment.get_content_in_tables(ground_truth_table_data)
for idx, predicted in enumerate(predicted_table_data):
matched_idx = matched_indices[idx]
if matched_idx == -1:
# false positive; default score 0
continue
score[idx] = difflib.SequenceMatcher(
None,
TableAlignment.get_content_in_tables([predicted])[0],
ground_truth_text[matched_idx],
).ratio()
return score
def _count_predicted_tables(matched_indices: List[int]) -> int:
"""Counts the number of predicted tables that have a corresponding match in the ground truth.
Args:
matched_indices: List of indices indicating matches between predicted
and ground truth tables.
Returns:
The count of matched predicted tables.
"""
return sum(1 for idx in matched_indices if idx >= 0)
def calculate_table_detection_metrics(
matched_indices: list[int], ground_truth_tables_number: int
) -> tuple[float, float, float]:
"""
Calculate the table detection metrics: recall, precision, and f1 score.
Args:
matched_indices:
List of indices indicating matches between predicted and ground truth tables
For example: matched_indices[i] = j means that the
i-th predicted table is matched with the j-th ground truth table.
ground_truth_tables_number: the number of ground truth tables.
Returns:
Tuple of recall, precision, and f1 scores
"""
predicted_tables_number = len(matched_indices)
matched_set = set(matched_indices)
if -1 in matched_set:
matched_set.remove(-1)
true_positive = len(matched_set)
false_positive = predicted_tables_number - true_positive
positive = ground_truth_tables_number
recall = true_positive / positive if positive > 0 else 0
precision = (
true_positive / (true_positive + false_positive)
if true_positive + false_positive > 0
else 0
)
f1 = 2 * precision * recall / (precision + recall) if precision + recall > 0 else 0
return recall, precision, f1
class TableEvalProcessor:
def __init__(
self,
prediction: List[Dict[str, Any]],
ground_truth: List[Dict[str, Any]],
cutoff: float = 0.8,
source_type: str = "html",
):
"""
Initializes the TableEvalProcessor prediction and ground truth.
Args:
ground_truth: Ground truth table data. The tables text should be in the deckerd format.
prediction: Predicted table data.
cutoff: The cutoff value for the element level alignment. Default is 0.8.
Examples:
ground_truth: [
{
"type": "Table",
"text": [
{
"id": "f4c35dae-105b-46f5-a77a-7fbc199d6aca",
"x": 0,
"y": 0,
"w": 1,
"h": 1,
"content": "Cell text"
},
...
}
]
prediction: [
{
"element_id": <id_string>,
...
"metadata": {
...
"text_as_html": "<table><thead><tr><th rowspan=\"2\">June....
</tr></td></table>",
"table_as_cells":
[
{
"x": 0,
"y": 0,
"w": 1,
"h": 2,
"content": "June"
},
...
]
}
},
]
"""
self.prediction = prediction
self.ground_truth = ground_truth
self.cutoff = cutoff
self.source_type = source_type
@classmethod
def from_json_files(
cls,
prediction_file: Path,
ground_truth_file: Path,
cutoff: Optional[float] = None,
source_type: str = "html",
) -> "TableEvalProcessor":
"""Factory classmethod to initialize the object with path to json files instead of dicts
Args:
prediction_file: Path to the json file containing the predicted table data.
ground_truth_file: Path to the json file containing the ground truth table data.
source_type: 'cells' or 'html'. 'cells' refers to reading 'table_as_cells' field while
'html' is extracted from 'text_as_html'
cutoff: The cutoff value for the element level alignment.
If not set, class default value is used (=0.8).
Returns:
TableEvalProcessor: An instance of the class initialized with the provided data.
"""
with open(prediction_file) as f:
prediction = json.load(f)
with open(ground_truth_file) as f:
ground_truth = json.load(f)
if cutoff is not None:
return cls(
prediction=prediction,
ground_truth=ground_truth,
cutoff=cutoff,
source_type=source_type,
)
else:
return cls(prediction=prediction, ground_truth=ground_truth, source_type=source_type)
def process_file(self) -> TableEvaluation:
"""Processes the files and computes table-level and element-level accuracy.
Returns:
TableEvaluation: A dataclass object containing the computed metrics.
"""
ground_truth_table_data = extract_and_convert_tables_from_ground_truth(
self.ground_truth,
)
predicted_table_data = extract_and_convert_tables_from_prediction(
file_elements=self.prediction, source_type=self.source_type
)
is_table_in_gt = bool(ground_truth_table_data)
is_table_predicted = bool(predicted_table_data)
if not is_table_in_gt:
# There is no table data in ground truth, you either got perfect score or 0
score = 0 if is_table_predicted else np.nan
table_acc = 1 if not is_table_predicted else 0
return TableEvaluation(
total_tables=0,
total_predicted_tables=len(predicted_table_data),
table_level_acc=table_acc,
table_detection_recall=score,
table_detection_precision=score,
table_detection_f1=score,
element_col_level_index_acc=score,
element_row_level_index_acc=score,
element_col_level_content_acc=score,
element_row_level_content_acc=score,
)
if is_table_in_gt and not is_table_predicted:
return TableEvaluation(
total_tables=len(ground_truth_table_data),
total_predicted_tables=0,
table_level_acc=0,
table_detection_recall=0,
table_detection_precision=0,
table_detection_f1=0,
element_col_level_index_acc=0,
element_row_level_index_acc=0,
element_col_level_content_acc=0,
element_row_level_content_acc=0,
)
else:
# We have both ground truth tables and predicted tables
matched_indices = TableAlignment.get_table_level_alignment(
predicted_table_data,
ground_truth_table_data,
)
predicted_table_acc = np.mean(
table_level_acc(predicted_table_data, ground_truth_table_data, matched_indices)
)
metrics = TableAlignment.get_element_level_alignment(
predicted_table_data,
ground_truth_table_data,
matched_indices,
cutoff=self.cutoff,
)
(
table_detection_recall,
table_detection_precision,
table_detection_f1,
) = calculate_table_detection_metrics(
matched_indices=matched_indices,
ground_truth_tables_number=len(ground_truth_table_data),
)
evaluation = TableEvaluation(
total_tables=len(ground_truth_table_data),
total_predicted_tables=len(predicted_table_data),
table_level_acc=predicted_table_acc,
table_detection_recall=table_detection_recall,
table_detection_precision=table_detection_precision,
table_detection_f1=table_detection_f1,
element_col_level_index_acc=metrics.get("col_index_acc", 0),
element_row_level_index_acc=metrics.get("row_index_acc", 0),
element_col_level_content_acc=metrics.get("col_content_acc", 0),
element_row_level_content_acc=metrics.get("row_content_acc", 0),
)
return evaluation
@click.command()
@click.option(
"--prediction_file", help="Path to the model prediction JSON file", type=click.Path(exists=True)
)
@click.option(
"--ground_truth_file", help="Path to the ground truth JSON file", type=click.Path(exists=True)
)
@click.option(
"--cutoff",
type=float,
show_default=True,
default=0.8,
help="The cutoff value for the element level alignment. \
If not set, a default value is used",
)
def run(prediction_file: str, ground_truth_file: str, cutoff: Optional[float]):
"""Runs the table evaluation process and prints the computed metrics."""
processor = TableEvalProcessor.from_json_files(
Path(prediction_file),
Path(ground_truth_file),
cutoff=cutoff,
)
report = processor.process_file()
print(report)
if __name__ == "__main__":
run()
@@ -0,0 +1,288 @@
from __future__ import annotations
from typing import Any, Dict, List
from bs4 import BeautifulSoup
from unstructured_inference.models.tables import cells_to_html
EMPTY_CELL = {
"row_index": "",
"col_index": "",
"content": "",
}
def _move_cells_for_spanned_cells(cells: List[Dict[str, Any]]):
"""Move cells to the right if spanned cells have an influence on the rendering.
Args:
cells: List of cells in the table in Deckerd format.
Returns:
List of cells in the table in Deckerd format with cells moved to the right if spanned.
"""
sorted_cells = sorted(cells, key=lambda x: (x["y"], x["x"]))
cells_occupied_by_spanned = set()
for cell in sorted_cells:
if cell["w"] > 1 or cell["h"] > 1:
for i in range(cell["y"], cell["y"] + cell["h"]):
for j in range(cell["x"], cell["x"] + cell["w"]):
if (i, j) != (cell["y"], cell["x"]):
cells_occupied_by_spanned.add((i, j))
while (cell["y"], cell["x"]) in cells_occupied_by_spanned:
cell_y, cell_x = cell["y"], cell["x"]
cells_to_the_right = [c for c in sorted_cells if c["y"] == cell_y and c["x"] >= cell_x]
for cell_to_move in cells_to_the_right:
cell_to_move["x"] += 1
cells_occupied_by_spanned.remove((cell_y, cell_x))
return sorted_cells
def html_table_to_deckerd(content: str) -> List[Dict[str, Any]]:
"""Convert html format to Deckerd table structure.
Args:
content: The html content with a table to extract.
Returns:
A list of dictionaries where each dictionary represents a cell in the table.
"""
soup = BeautifulSoup(content, "html.parser")
table = soup.find("table")
rows = table.find_all(["tr"])
table_data = []
for i, row in enumerate(rows):
cells = row.find_all(["th", "td"])
for j, cell_data in enumerate(cells):
cell = {
"y": i,
"x": j,
"w": int(cell_data.attrs.get("colspan", 1)),
"h": int(cell_data.attrs.get("rowspan", 1)),
"content": cell_data.text,
}
table_data.append(cell)
return _move_cells_for_spanned_cells(table_data)
def deckerd_table_to_html(cells: List[Dict[str, Any]]) -> str:
"""Convert Deckerd table structure to html format.
Args:
cells: List of dictionaries where each dictionary represents a cell in the table.
Returns:
A string with the html content of the table.
"""
transformer_cells = []
# determine which cells are in header. Consider row 0 as header
# but spans may make it larger
first_row_cells = [cell for cell in cells if cell["y"] == 0]
header_length = max(cell["w"] for cell in first_row_cells)
header_rows = set(range(header_length))
for cell in cells:
cell_data = {
"row_nums": list(range(cell["y"], cell["y"] + cell["h"])),
"column_nums": list(range(cell["x"], cell["x"] + cell["w"])),
"w": cell["w"],
"h": cell["h"],
"cell text": cell["content"],
"column header": cell["y"] in header_rows,
}
transformer_cells.append(cell_data)
# reuse the existing function to convert to HTML
table = cells_to_html(transformer_cells)
return table
def _convert_table_from_html(content: str) -> List[Dict[str, Any]]:
"""Convert html format to table structure. As a middle step it converts
html to the Deckerd format as it's more convenient to work with.
Args:
content: The html content with a table to extract.
Returns:
A list of dictionaries where each dictionary represents a cell in the table.
"""
deckerd_cells = html_table_to_deckerd(content)
return _convert_table_from_deckerd(deckerd_cells)
def _convert_table_from_deckerd(content: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Convert deckerd format to table structure.
Args:
content: The deckerd formatted content with a table to extract.
Returns:
A list of dictionaries where each dictionary represents a cell in the table.
"""
table_data = []
for table in content:
try:
cell_data = {
"row_index": table["y"],
"col_index": table["x"],
"content": table["content"],
}
except KeyError:
cell_data = EMPTY_CELL
except TypeError:
cell_data = EMPTY_CELL
table_data.append(cell_data)
return table_data
def _sort_table_cells(table_data: List[List[Dict[str, Any]]]) -> List[List[Dict[str, Any]]]:
return sorted(table_data, key=lambda cell: (cell["row_index"], cell["col_index"]))
def extract_and_convert_tables_from_ground_truth(
file_elements: List[Dict[str, Any]],
) -> List[List[Dict[str, Any]]]:
"""Extracts and converts tables data to a structured format based on the specified table type.
Args:
file_elements: List of elements from the ground truth file.
Returns:
A list of tables with each table represented as a list of cell data dictionaries.
"""
ground_truth_table_data = []
for element in file_elements:
if "type" in element and element["type"] == "Table" and "text" in element:
try:
converted_data = _convert_table_from_deckerd(
element["text"],
)
ground_truth_table_data.append(_sort_table_cells(converted_data))
except Exception as e:
print(f"Error converting ground truth data: {e}")
ground_truth_table_data.append({})
return ground_truth_table_data
def extract_and_convert_tables_from_prediction(
file_elements: List[Dict[str, Any]], source_type: str = "html"
) -> List[List[Dict[str, Any]]]:
"""Extracts and converts table data to a structured format
Args:
file_elements: List of elements from the file.
source_type: 'cells' or 'html'. 'cells' refers to reading 'table_as_cells' field while
'html' is extracted from 'text_as_html'
Returns:
A list of tables with each table represented as a list of cell data dictionaries.
"""
source_type_to_extraction_strategies = {
"html": extract_cells_from_text_as_html,
"cells": extract_cells_from_table_as_cells,
}
if source_type not in source_type_to_extraction_strategies:
raise ValueError(
f'source_type {source_type} is not valid. Allowed source_types are "html" and "cells"'
)
extract_cells_fn = source_type_to_extraction_strategies[source_type]
fallback_extract_cells_fn = (
extract_cells_from_table_as_cells
if source_type == "cells"
else extract_cells_from_text_as_html
)
predicted_table_data = []
for element in file_elements:
if element.get("type") == "Table":
extracted_cells = extract_cells_fn(element)
if not extracted_cells:
extracted_cells = fallback_extract_cells_fn(element)
if extracted_cells:
sorted_cells = _sort_table_cells(extracted_cells)
predicted_table_data.append(sorted_cells)
return predicted_table_data
def extract_cells_from_text_as_html(element: Dict[str, Any]) -> List[Dict[str, Any]] | None:
"""Extracts and parse cells from "text_as_html" field in Element structure
Args:
element: Example element:
{
"type": "Table",
"metadata": {
"text_as_html": "<table>
<thead>
<tr>
<th>Month A.</th>
</tr>
</thead>
</tbody>
<tr>
<td>22</td><
</tr>
</tbody>
</table>"
}
}
Returns:
List of extracted cells in a format:
[
{
"row_index": 0,
"col_index": 0,
"content": "Month A.",
},
...,
]
"""
val = element["metadata"].get("text_as_html")
if not val or "<table>" not in val:
return None
predicted_cells = None
try:
predicted_cells = _convert_table_from_html(val)
except Exception as e:
print(f"Error converting Unstructured table data: {e}")
return predicted_cells
def extract_cells_from_table_as_cells(element: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Extracts and parse cells from "table_as_cells" field in Element structure
Args:
element: Example element:
{
"type": "Table",
"metadata": {
"table_as_cells": [{"x": 0, "y": 0, "w": 1, "h": 1, "content": "Month A."},
{"x": 0, "y": 1, "w": 1, "h": 1, "content": "22"}]
}
}
Returns:
List of extracted cells in a format:
[
{
"row_index": 0,
"col_index": 0,
"content": "Month A.",
},
...,
]
"""
predicted_cells = element["metadata"].get("table_as_cells")
converted_cells = None
if predicted_cells:
converted_cells = _convert_table_from_deckerd(predicted_cells)
return converted_cells
@@ -0,0 +1,49 @@
from dataclasses import dataclass
from typing import Union
@dataclass
class SimpleTableCell:
x: int
y: int
w: int
h: int
content: str = ""
def to_dict(self):
return {
"x": self.x,
"y": self.y,
"w": self.w,
"h": self.h,
"content": self.content,
}
@classmethod
def from_table_transformer_cell(cls, tatr_table_cell: dict[str, Union[list[int], str]]):
"""
Args:
tatr_table_cell (dict):
Cell in a format returned by Table Transformer model, for example:
{
"row_nums": [1,2,3],
"column_nums": [2],
"cell text": "Text inside cell"
}
"""
row_nums = tatr_table_cell.get("row_nums", [])
column_nums = tatr_table_cell.get("column_nums", [])
if not row_nums:
raise ValueError(f'Cell {tatr_table_cell} has missing values under "row_nums" key')
if not column_nums:
raise ValueError(f'Cell {tatr_table_cell} has missing values under "column_nums" key')
return cls(
x=min(column_nums),
y=min(row_nums),
w=len(column_nums),
h=len(row_nums),
content=tatr_table_cell.get("cell text", ""),
)
+48
View File
@@ -0,0 +1,48 @@
import numpy as np
import pandas as pd
from PIL import Image
from unstructured.partition.pdf_image.ocr import get_table_tokens
from unstructured.partition.pdf_image.pdf_image_utils import convert_pdf_to_images
from unstructured.partition.utils.ocr_models.ocr_interface import OCRAgent
from unstructured.utils import requires_dependencies
@requires_dependencies("unstructured_inference")
def image_or_pdf_to_dataframe(filename: str) -> pd.DataFrame:
"""helper to JUST run table transformer on the input image/pdf file. It assumes the input is
JUST a table. This is intended to facilitate metric tracking on table structure detection ALONE
without mixing metric of element detection model"""
from unstructured_inference.models.tables import load_agent, tables_agent
load_agent()
if filename.endswith(".pdf"):
image = list(convert_pdf_to_images(filename))[0].convert("RGB")
else:
image = Image.open(filename).convert("RGB")
ocr_agent = OCRAgent.get_agent(language="eng")
return tables_agent.run_prediction(
image, ocr_tokens=get_table_tokens(image, ocr_agent), result_format="dataframe"
)
@requires_dependencies("unstructured_inference")
def eval_table_transformer_for_file(
filename: str,
true_table_filename: str,
eval_func: str = "token_ratio",
) -> float:
"""evaluate the predicted table structure vs. actual table structure by column and row as a
number between 0 and 1"""
from unstructured_inference.models.eval import compare_contents_as_df
pred_table = image_or_pdf_to_dataframe(filename).fillna("").replace(np.nan, "")
actual_table = pd.read_csv(true_table_filename).astype(str).fillna("").replace(np.nan, "")
results = np.array(
list(compare_contents_as_df(actual_table, pred_table, eval_func=eval_func).values()),
)
return results.mean() / 100.0
+224
View File
@@ -0,0 +1,224 @@
from typing import Dict, Optional, Tuple
from rapidfuzz.distance import Levenshtein
from unstructured.cleaners.core import clean_bullets, remove_sentence_punctuation
_DOUBLE_QUOTE_CODEPOINTS = (
"\u0022", # U+0022 Standard typewriter/programmer's quote
"\u201c", # U+201C Left double quotation mark
"\u201d", # U+201D Right double quotation mark
"\u201e", # U+201E Double low-9 quotation mark
"\u201f", # U+201F Double high-reversed-9 quotation mark
"\u00ab", # U+00AB Left-pointing double angle quotation mark
"\u00bb", # U+00BB Right-pointing double angle quotation mark
"\u275d", # U+275D Heavy double turned comma quotation mark ornament
"\u275e", # U+275E Heavy double comma quotation mark ornament
"\u2e42", # U+2E42 Double low-reversed-9 quotation mark
"\U0001f676", # U+1F676 SANS-SERIF HEAVY DOUBLE TURNED COMMA QUOTATION MARK ORNAMENT
"\U0001f677", # U+1F677 SANS-SERIF HEAVY DOUBLE COMMA QUOTATION MARK ORNAMENT
"\U0001f678", # U+1F678 SANS-SERIF HEAVY LOW DOUBLE COMMA QUOTATION MARK ORNAMENT
"\u2826", # U+2826 Braille double closing quotation mark
"\u2834", # U+2834 Braille double opening quotation mark
"\u301d", # U+301D REVERSED DOUBLE PRIME QUOTATION MARK
"\u301e", # U+301E DOUBLE PRIME QUOTATION MARK
"\u301f", # U+301F LOW DOUBLE PRIME QUOTATION MARK
"\uff02", # U+FF02 FULLWIDTH QUOTATION MARK
)
_SINGLE_QUOTE_CODEPOINTS = (
"\u0027", # U+0027 Standard typewriter/programmer's quote
"\u2018", # U+2018 Left single quotation mark
"\u2019", # U+2019 Right single quotation mark
"\u201a", # U+201A Single low-9 quotation mark
"\u201b", # U+201B Single high-reversed-9 quotation mark
"\u2039", # U+2039 Single left-pointing angle quotation mark
"\u203a", # U+203A Single right-pointing angle quotation mark
"\u275b", # U+275B Heavy single turned comma quotation mark ornament
"\u275c", # U+275C Heavy single comma quotation mark ornament
"\u300c", # U+300C Left corner bracket
"\u300d", # U+300D Right corner bracket
"\u300e", # U+300E Left white corner bracket
"\u300f", # U+300F Right white corner bracket
"\ufe41", # U+FE41 PRESENTATION FORM FOR VERTICAL LEFT CORNER BRACKET
"\ufe42", # U+FE42 PRESENTATION FORM FOR VERTICAL RIGHT CORNER BRACKET
"\ufe43", # U+FE43 PRESENTATION FORM FOR VERTICAL LEFT WHITE CORNER BRACKET
"\ufe44", # U+FE44 PRESENTATION FORM FOR VERTICAL RIGHT WHITE CORNER BRACKET
"\uff07", # U+FF07 FULLWIDTH APOSTROPHE
"\uff62", # U+FF62 HALFWIDTH LEFT CORNER BRACKET
"\uff63", # U+FF63 HALFWIDTH RIGHT CORNER BRACKET
)
_TRANSLATION_TABLE = str.maketrans(
dict.fromkeys(_DOUBLE_QUOTE_CODEPOINTS, '"') | dict.fromkeys(_SINGLE_QUOTE_CODEPOINTS, "'")
)
def calculate_accuracy(
output: Optional[str],
source: Optional[str],
weights: Tuple[int, int, int] = (2, 1, 1),
) -> float:
"""
Calculates accuracy by calling calculate_edit_distance function using `return_as=score`.
The function will return complement of the edit distance instead.
"""
return calculate_edit_distance(output, source, weights, return_as="score")
def calculate_edit_distance(
output: Optional[str],
source: Optional[str],
weights: Tuple[int, int, int] = (2, 1, 1),
return_as: str = "distance",
standardize_whitespaces: bool = True,
) -> float:
"""
Calculates edit distance using Levenshtein distance between two strings.
Args:
output (str): The target string to be compared.
source (str): The reference string against which 'output' is compared.
weights (Tuple[int, int, int], optional): A tuple containing weights
for insertion, deletion, and substitution operations in the edit
distance calculation. Default is (2, 1, 1).
return_as (str, optional): The type of result to return, one of
["score", "distance"].
Default is "distance".
Returns:
float: The calculated edit distance or similarity score between
the 'output' and 'source' strings.
Raises:
ValueError: If 'return_as' is not one of the valid return types
["score", "distance"].
Note:
This function calculates the edit distance (or similarity score) between
two strings using the Levenshtein distance algorithm. The 'weights' parameter
allows customizing the cost of insertion, deletion, and substitution
operations. The 'return_as' parameter determines the type of result to return:
- "score": Returns the similarity score, where 1.0 indicates a perfect match.
- "distance": Returns the raw edit distance value.
"""
return_types = ["score", "distance"]
if return_as not in return_types:
raise ValueError("Invalid return value type. Expected one of: %s" % return_types)
output = standardize_quotes(prepare_str(output, standardize_whitespaces))
source = standardize_quotes(prepare_str(source, standardize_whitespaces))
distance = Levenshtein.distance(output, source, weights=weights) # type: ignore
# lower bounded the char length for source string at 1.0 because to avoid division by zero
# in the case where source string is empty, the distance should be at 100%
source_char_len = max(len(source), 1.0) # type: ignore
bounded_percentage_distance = min(max(distance / source_char_len, 0.0), 1.0)
if return_as == "score":
return 1 - bounded_percentage_distance
elif return_as == "distance":
return distance
return 0.0
def bag_of_words(text: str) -> Dict[str, int]:
"""
Outputs the bag of words (BOW) found in the input text and their frequencies.
Takes "clean, concatenated text" (CCT) from a document as input.
Removes sentence punctuation, but not punctuation within a word (ex. apostrophes).
"""
bow: Dict[str, int] = {}
incorrect_word: str = ""
words = clean_bullets(remove_sentence_punctuation(text.lower(), ["-", "'"])).split()
i = 0
while i < len(words):
if len(words[i]) > 1:
if words[i] in bow:
bow[words[i]] += 1
else:
bow[words[i]] = 1
i += 1
else:
j = i
incorrect_word = ""
while j < len(words) and len(words[j]) == 1:
incorrect_word += words[j]
j += 1
if len(incorrect_word) == 1 and words[i].isalnum():
if incorrect_word in bow:
bow[incorrect_word] += 1
else:
bow[incorrect_word] = 1
i = j
return bow
def calculate_percent_missing_text(
output: Optional[str],
source: Optional[str],
) -> float:
"""
Creates the bag of words (BOW) found in each input text and their frequencies, then compares the
output BOW against the source BOW to calculate the % of text from the source text missing from
the output text.
Takes "clean, concatenated text" (CCT) from a document output and the ground truth source text
as inputs.
If the output text contains all words from the source text and then some extra, result will be
0% missing text - this calculation does not penalize duplication.
A spaced-out word (ex. h e l l o) is considered missing; individual characters of a word
will not be counted as separate words.
Returns the percentage of missing text represented as a decimal between 0 and 1.
"""
output = prepare_str(output)
source = prepare_str(source)
output_bow = bag_of_words(output)
source_bow = bag_of_words(source)
# get total words in source bow while counting missing words
total_source_word_count = 0
total_missing_word_count = 0
for source_word, source_count in source_bow.items():
total_source_word_count += source_count
if source_word not in output_bow:
# entire count is missing
total_missing_word_count += source_count
else:
output_count = output_bow[source_word]
total_missing_word_count += max(source_count - output_count, 0)
# calculate percent missing text
if total_source_word_count == 0:
return 0 # nothing missing because nothing in source document
fraction_missing = round(total_missing_word_count / total_source_word_count, 3)
return min(fraction_missing, 1) # limit to 100%
def prepare_str(string: Optional[str], standardize_whitespaces: bool = False) -> str:
if not string:
return ""
if standardize_whitespaces:
return " ".join(string.split())
return str(string) # type: ignore
def standardize_quotes(text: str) -> str:
"""
Converts all unicode quotes to standard ASCII quotes with comprehensive coverage.
Args:
text (str): The input text to be standardized.
Returns:
str: The text with standardized quotes.
"""
return text.translate(_TRANSLATION_TABLE)
+248
View File
@@ -0,0 +1,248 @@
import logging
import os
import re
import statistics
from pathlib import Path
from typing import List, Optional, Union
import click
import pandas as pd
from unstructured.staging.base import elements_from_json, elements_to_text
logger = logging.getLogger("unstructured.eval")
def _prepare_output_cct(docpath: str, output_type: str) -> str:
"""
Convert given input document (path) into cct-ready. The function only support conversion
from `json` or `txt` file.
"""
try:
if output_type == "json":
output_cct = elements_to_text(elements_from_json(docpath))
elif output_type == "txt":
output_cct = _read_text_file(docpath)
else:
raise ValueError(
f"File type not supported. Expects one of `json` or `txt`, \
but received {output_type} instead."
)
except ValueError as e:
logger.error(f"Could not read the file {docpath}")
raise e
return output_cct
def _listdir_recursive(dir: str) -> List[str]:
"""
Recursively lists all files in the given directory and its subdirectories.
Returns a list of all files found, with each file's path relative to the
initial directory.
"""
listdir = []
for dirpath, _, filenames in os.walk(dir):
for filename in filenames:
# Remove the starting directory from the path to show the relative path
relative_path = os.path.relpath(dirpath, dir)
if relative_path == ".":
listdir.append(filename)
else:
listdir.append(os.path.join(relative_path, filename))
return listdir
def _rename_aggregated_columns(df):
"""
Renames aggregated columns in a DataFrame based on a predefined mapping.
Parameters:
df (pandas.DataFrame): The DataFrame with aggregated columns to rename.
Returns:
pandas.DataFrame: A new DataFrame with renamed aggregated columns.
"""
rename_map = {"_mean": "mean", "_stdev": "stdev", "_pstdev": "pstdev", "_count": "count"}
return df.rename(columns=rename_map)
def _format_grouping_output(*df):
"""
Concatenates multiple pandas DataFrame objects along the columns (side-by-side)
and resets the index.
"""
return pd.concat(df, axis=1).reset_index()
def _display(df):
"""
Displays the evaluation metrics in a formatted text table.
"""
if len(df) == 0:
return
headers = df.columns.tolist()
col_widths = [
max(len(header), max(len(str(item)) for item in df[header])) for header in headers
]
click.echo(" ".join(header.ljust(col_widths[i]) for i, header in enumerate(headers)))
click.echo("-" * sum(col_widths) + "-" * (len(headers) - 1))
for _, row in df.iterrows():
formatted_row = []
for item in row:
if isinstance(item, float):
formatted_row.append(f"{item:.3f}")
else:
formatted_row.append(str(item))
click.echo(
" ".join(formatted_row[i].ljust(col_widths[i]) for i in range(len(formatted_row))),
)
def _write_to_file(
directory: str, filename: str, df: pd.DataFrame, mode: str = "w", overwrite: bool = True
):
"""
Save the metrics report to tsv file. The function allows an option 1) to choose `mode`
as `w` (write) or `a` (append) and 2) to `overwrite` the file if filename existed or not.
"""
if mode not in ["w", "a"]:
raise ValueError("Mode not supported. Mode must be one of [w, a].")
if directory:
Path(directory).mkdir(exist_ok=True)
# Operate on a copy so callers can pass a view/slice without chained-assignment warnings.
df = df.copy()
if "count" in df.columns:
df["count"] = df["count"].astype(int)
if "filename" in df.columns and "connector" in df.columns:
df = df.sort_values(by=["connector", "filename"])
if not overwrite:
filename = _get_non_duplicated_filename(directory, filename)
df.to_csv(
os.path.join(directory, filename), sep="\t", mode=mode, index=False, header=(mode == "w")
)
def _sorting_key(filename):
"""
A function that defines the sorting method for duplicated file names. For example,
with filename.ext filename (1).ext filename (2).ext filename (10).ext - this function
extracts the integer in the bracket and sort those numbers ascendingly.
"""
# Regular expression to find the number in the filename
numbers = re.findall(r"(\d+)", filename)
if numbers:
# If there's a number, return it as an integer for sorting
return int(numbers[-1])
else:
# If no number, return 0 so these files come first
return 0
def _uniquity_file(file_list, target_filename) -> str:
"""
Checks the duplicity of the file name from the list and run the numerical check
of the minimum number needed as extension to not overwrite the exising file.
Returns a string of file name in the format of `filename (<min number>).ext`.
"""
original_filename, extension = target_filename.rsplit(".", 1)
pattern = rf"^{re.escape(original_filename)}(?: \((\d+)\))?\.{re.escape(extension)}$"
duplicated_files = sorted([f for f in file_list if re.match(pattern, f)], key=_sorting_key)
numbers = []
for file in duplicated_files:
match = re.search(r"\((\d+)\)", file)
if match:
numbers.append(int(match.group(1)))
numbers.sort()
counter = 1
for number in numbers:
if number == counter:
counter += 1
else:
break
return original_filename + " (" + str(counter) + ")." + extension
def _get_non_duplicated_filename(dir, filename) -> str:
"""
Helper function to calls the `_uniquity_file` function. Takes in directory and file name
to check on.
"""
filename = _uniquity_file(os.listdir(dir), filename)
return filename
def _mean(scores: Union[pd.Series, List[float]], rounding: Optional[int] = 3) -> Union[float, None]:
"""
Find mean from the list. Returns None if no element in the list.
Args:
rounding (int): optional argument that allows user to define decimal points. Default at 3.
"""
if len(scores) == 0:
return None
mean = statistics.mean(scores)
if not rounding:
return mean
return round(mean, rounding)
def _stdev(scores: List[Optional[float]], rounding: Optional[int] = 3) -> Union[float, None]:
"""
Find standard deviation from the list.
Returns None if only 0 or 1 element in the list.
Args:
rounding (int): optional argument that allows user to define decimal points. Default at 3.
"""
# Filter out None values
scores = [score for score in scores if score is not None]
# Proceed only if there are more than one value
if len(scores) <= 1:
return None
if not rounding:
return statistics.stdev(scores)
return round(statistics.stdev(scores), rounding)
def _pstdev(scores: List[Optional[float]], rounding: Optional[int] = 3) -> Union[float, None]:
"""
Find population standard deviation from the list.
Returns None if only 0 or 1 element in the list.
Args:
rounding (int): optional argument that allows user to define decimal points. Default at 3.
"""
scores = [score for score in scores if score is not None]
if len(scores) <= 1:
return None
if not rounding:
return statistics.pstdev(scores)
return round(statistics.pstdev(scores), rounding)
def _count(scores: List[Optional[float]]) -> float:
"""
Returns the row count of the list.
"""
return len(scores)
def _read_text_file(path):
"""
Reads the contents of a text file and returns it as a string.
"""
# Check if the file exists
if not os.path.exists(path):
raise FileNotFoundError(f"The file at {path} does not exist.")
try:
with open(path, errors="ignore") as f:
text = f.read()
return text
except OSError as e:
# Handle other I/O related errors
raise IOError(f"An error occurred when reading the file at {path}: {e}")
View File
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
import os
import pathlib
from typing import List, Set
DIRECTORY = pathlib.Path(__file__).parent.resolve()
# NOTE(robinson) - the list of English words is based on the nlkt.corpus.words corpus
# and the list of English words found here at the link below. Add more words to the text
# file if needed.
# ref: https://github.com/jeremy-rifkin/Wordlist
ENGLISH_WORDS_FILE = os.path.join(DIRECTORY, "english-words.txt")
with open(ENGLISH_WORDS_FILE) as f:
BASE_ENGLISH_WORDS = f.read().split("\n")
# NOTE(robinson) - add new words that we want to pass for the English check in here
ADDITIONAL_ENGLISH_WORDS: List[str] = []
ENGLISH_WORDS: Set[str] = set(BASE_ENGLISH_WORDS + ADDITIONAL_ENGLISH_WORDS)
+7
View File
@@ -0,0 +1,7 @@
# flake8: noqa
from unstructured.partition.pdf import partition_pdf # noqa
from unstructured.partition.text_type import ( # noqa
is_bulleted_text,
is_possible_narrative_text,
is_possible_title,
)
+143
View File
@@ -0,0 +1,143 @@
import re
from typing import Final, List
# NOTE(robinson) - Modified from answers found on this stackoverflow post
# ref: https://stackoverflow.com/questions/16699007/
# regular-expression-to-match-standard-10-digit-phone-number
US_PHONE_NUMBERS_PATTERN = (
r"(?:\+?(\d{1,3}))?[-. (]*(\d{3})?[-. )]*(\d{3})[-. ]*(\d{4})(?: *x(\d+))?\s*$"
)
US_PHONE_NUMBERS_RE = re.compile(US_PHONE_NUMBERS_PATTERN)
# NOTE(robinson) - Based on this regex from regex101. Regex was updated to run fast
# and avoid catastrophic backtracking
# ref: https://regex101.com/library/oR3jU1?page=673
US_CITY_STATE_ZIP_PATTERN = (
r"(?i)\b(?:[A-Z][a-z.-]{1,15}[ ]?){1,5},\s?"
r"(?:{Alabama|Alaska|Arizona|Arkansas|California|Colorado|Connecticut|Delaware|Florida"
r"|Georgia|Hawaii|Idaho|Illinois|Indiana|Iowa|Kansas|Kentucky|Louisiana|Maine|Maryland"
r"|Massachusetts|Michigan|Minnesota|Mississippi|Missouri|Montana|Nebraska|Nevada|"
r"New[ ]Hampshire|New[ ]Jersey|New[ ]Mexico|New[ ]York|North[ ]Carolina|North[ ]Dakota"
r"|Ohio|Oklahoma|Oregon|Pennsylvania|Rhode[ ]Island|South[ ]Carolina|South[ ]Dakota"
r"|Tennessee|Texas|Utah|Vermont|Virginia|Washington|West[ ]Virginia|Wisconsin|Wyoming}"
r"|{AL|AK|AS|AZ|AR|CA|CO|CT|DE|DC|FM|FL|GA|GU|HI|ID|IL|IN|IA|KS|KY|LA|ME|MH|MD|MA|MI|MN"
r"|MS|MO|MT|NE|NV|NH|NJ|NM|NY|NC|ND|MP|OH|OK|OR|PW|PA|PR|RI|SC|SD|TN|TX|UT|VT|VI|VA|"
r"WA|WV|WI|WY})(, |\s)?(?:\b\d{5}(?:-\d{4})?\b)"
)
US_CITY_STATE_ZIP_RE = re.compile(US_CITY_STATE_ZIP_PATTERN)
UNICODE_BULLETS: Final[List[str]] = [
"\u0095",
"\u2022",
"\u2023",
"\u2043",
"\u3164",
"\u204c",
"\u204d",
"\u2219",
"\u25cb",
"\u25cf",
"\u25d8",
"\u25e6",
"\u2619",
"\u2765",
"\u2767",
"\u29be",
"\u29bf",
"\u002d",
"\u2013",
"",
r"\*",
"\x95",
"·",
]
BULLETS_PATTERN = "|".join(UNICODE_BULLETS)
UNICODE_BULLETS_RE = re.compile(f"(?:{BULLETS_PATTERN})(?!{BULLETS_PATTERN})")
# zero-width positive lookahead so bullet characters will not be removed when using .split()
UNICODE_BULLETS_RE_0W = re.compile(f"(?={BULLETS_PATTERN})(?<!{BULLETS_PATTERN})")
E_BULLET_PATTERN = re.compile(r"^e(?=\s)", re.MULTILINE)
# NOTE(klaijan) - Captures reference of format [1] or [i] or [a] at any point in the line.
REFERENCE_PATTERN = r"\[(?:[\d]+|[a-z]|[ivxlcdm])\]"
REFERENCE_PATTERN_RE = re.compile(REFERENCE_PATTERN)
ENUMERATED_BULLETS_RE = re.compile(r"(?:(?:\d{1,3}|[a-z][A-Z])\.?){1,3}")
EMAIL_HEAD_PATTERN = (
r"(MIME-Version: 1.0(.*)?\n)?Date:.*\nMessage-ID:.*\nSubject:.*\nFrom:.*\nTo:.*"
)
EMAIL_HEAD_RE = re.compile(EMAIL_HEAD_PATTERN)
# Helps split text by paragraphs. There must be one newline, with potential whitespace
# (incluing \r and \n chars) on either side
PARAGRAPH_PATTERN = r"\s*\n\s*"
PARAGRAPH_PATTERN_RE = re.compile(
f"((?:{BULLETS_PATTERN})|{PARAGRAPH_PATTERN})(?!{BULLETS_PATTERN}|$)",
)
DOUBLE_PARAGRAPH_PATTERN_RE = re.compile("(" + PARAGRAPH_PATTERN + "){2}")
# Captures all new line \n and keeps the \n as its own element,
# considers \n\n as two separate elements
LINE_BREAK = r"(?<=\n)"
LINE_BREAK_RE = re.compile(LINE_BREAK)
# NOTE(klaijan) - captures a line that does not ends with period (.)
ONE_LINE_BREAK_PARAGRAPH_PATTERN = r"^(?:(?!\.\s*$).)*$"
ONE_LINE_BREAK_PARAGRAPH_PATTERN_RE = re.compile(ONE_LINE_BREAK_PARAGRAPH_PATTERN)
# IP Address examples: ba23::58b5:2236:45g2:88h2, 10.0.2.01 or 68.183.71.12
IP_ADDRESS_PATTERN = (
r"(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)){3}",
"[a-z0-9]{4}::[a-z0-9]{4}:[a-z0-9]{4}:[a-z0-9]{4}:[a-z0-9]{4}%?[0-9]*",
)
IP_ADDRESS_PATTERN_RE = re.compile(f"({'|'.join(IP_ADDRESS_PATTERN)})")
IP_ADDRESS_NAME_PATTERN = r"[a-zA-Z0-9-]*\.[a-zA-Z]*\.[a-zA-Z]*"
# Mapi ID example: 32.88.5467.123
MAPI_ID_PATTERN = r"[0-9]*\.[0-9]*\.[0-9]*\.[0-9]*;"
# Date, time, timezone example: Fri, 26 Mar 2021 11:04:09 +1200
EMAIL_DATETIMETZ_PATTERN = (
r"[A-Za-z]{3},\s\d{1,2}\s[A-Za-z]{3}\s\d{4}\s\d{2}:\d{2}:\d{2}\s[+-]\d{4}"
)
EMAIL_DATETIMETZ_PATTERN_RE = re.compile(EMAIL_DATETIMETZ_PATTERN)
EMAIL_ADDRESS_PATTERN = r"[a-z0-9\.\-+_]+@[a-z0-9\.\-+_]+\.[a-z]+"
EMAIL_ADDRESS_PATTERN_RE = re.compile(EMAIL_ADDRESS_PATTERN)
ENDS_IN_PUNCT_PATTERN = r"[^\w\s]\Z"
ENDS_IN_PUNCT_RE = re.compile(ENDS_IN_PUNCT_PATTERN)
# NOTE(robinson) - Used to detect if text is in the expected "list of dicts"
# format for document elements
LIST_OF_DICTS_PATTERN = r"\A\s*\[\s*{?"
# (?s) dot all (including newline characters)
# \{(?=.*:) opening brace and at least one colon
# .*? any characters (non-greedy)
# (?:\}|$) non-capturing group that matches either the closing brace } or the end of
# the string to handle cases where the JSON is cut off
# | or
# \[(?s:.*?)\] matches the opening bracket [ in a JSON array and any characters inside the array
# (?:$|,|\]) non-capturing group that matches either the end of the string, a comma,
# or the closing bracket to handle cases where the JSON array is cut off
JSON_PATTERN = r"(?s)\{(?=.*:).*?(?:\}|$)|\[(?s:.*?)\](?:$|,|\])"
# taken from https://stackoverflow.com/a/3845829/12406158
VALID_JSON_CHARACTERS = r"[,:{}\[\]0-9.\-+Eaeflnr-u \n\r\t]"
IMAGE_URL_PATTERN = (
r"(?i)https?://"
r"(?:[a-z0-9$_@.&+!*\\(\\),%-])+"
r"(?:/[a-z0-9$_@.&+!*\\(\\),%-]*)*"
r"\.(?:jpg|jpeg|png|gif|bmp|heic)"
)
# NOTE(klaijan) - only supports one level numbered list for now
# e.g. 1. 2. 3. or 1) 2) 3), not 1.1 1.2 1.3
NUMBERED_LIST_PATTERN = r"^\d+(\.|\))\s(.+)"
NUMBERED_LIST_RE = re.compile(NUMBERED_LIST_PATTERN)
+192
View File
@@ -0,0 +1,192 @@
from __future__ import annotations
import hashlib
import importlib
import logging
import os
import shutil
import sys
import sysconfig
import tempfile
import urllib.error
import urllib.request
from functools import lru_cache
from typing import Final, List, Tuple
import spacy
from filelock import FileLock
logger = logging.getLogger(__name__)
CACHE_MAX_SIZE: Final[int] = 128
_SPACY_MODEL_NAME: Final[str] = "en_core_web_sm"
_SPACY_MODEL_VERSION: Final[str] = "3.8.0"
_SPACY_MODEL_URL: Final[str] = (
f"https://github.com/explosion/spacy-models/releases/download/"
f"{_SPACY_MODEL_NAME}-{_SPACY_MODEL_VERSION}/"
f"{_SPACY_MODEL_NAME}-{_SPACY_MODEL_VERSION}-py3-none-any.whl"
)
_SPACY_MODEL_SHA256: Final[str] = "1932429db727d4bff3deed6b34cfc05df17794f4a52eeb26cf8928f7c1a0fb85"
_DOWNLOAD_TIMEOUT_SECONDS: Final[int] = 120
_INSTALL_LOCK_PATH: Final[str] = os.path.join(
tempfile.gettempdir(), f"{_SPACY_MODEL_NAME}.install.lock"
)
def _download_with_timeout(url: str, dest: str) -> None:
"""Download a URL to a local file with a socket-level timeout."""
try:
with urllib.request.urlopen(url, timeout=_DOWNLOAD_TIMEOUT_SECONDS) as resp:
with open(dest, "wb") as out:
shutil.copyfileobj(resp, out)
except urllib.error.URLError as exc:
raise RuntimeError(
f"Failed to download spaCy model from {url}: {exc}. "
"Check your network connection and try again."
) from exc
def _install_spacy_model() -> None:
"""Download and install the pinned spaCy model wheel using the `installer` library."""
from installer import install
from installer.destinations import SchemeDictionaryDestination
from installer.sources import WheelFile
from installer.utils import get_launcher_kind
with tempfile.TemporaryDirectory() as tmp:
whl_path = os.path.join(tmp, f"{_SPACY_MODEL_NAME}-{_SPACY_MODEL_VERSION}-py3-none-any.whl")
logger.info("Downloading spaCy model %s %s", _SPACY_MODEL_NAME, _SPACY_MODEL_VERSION)
_download_with_timeout(_SPACY_MODEL_URL, whl_path)
with open(whl_path, "rb") as f:
sha256 = hashlib.sha256(f.read()).hexdigest()
if sha256 != _SPACY_MODEL_SHA256:
raise RuntimeError(
f"Hash mismatch for {_SPACY_MODEL_NAME}: "
f"expected {_SPACY_MODEL_SHA256}, got {sha256}"
)
# Install into a staging directory to avoid races with other processes
staging = os.path.join(tmp, "staging")
paths = sysconfig.get_paths()
staged_paths = paths.copy()
staged_paths["purelib"] = staging
staged_paths["platlib"] = staging
destination = SchemeDictionaryDestination(
staged_paths,
interpreter=sys.executable,
script_kind=get_launcher_kind(),
)
with WheelFile.open(whl_path) as source:
install(source=source, destination=destination, additional_metadata={})
# Move installed packages from staging into real site-packages.
# The caller holds _INSTALL_LOCK_PATH so no other process races here.
# Any dst that already exists is a remnant of a previous failed install
# (spacy.load() just failed), so remove it before moving to avoid
# shutil.move placing src *inside* an existing directory.
site_packages = paths["purelib"]
for item in os.listdir(staging):
src = os.path.join(staging, item)
dst = os.path.join(site_packages, item)
try:
if os.path.isdir(dst):
shutil.rmtree(dst)
elif os.path.exists(dst):
os.remove(dst)
shutil.move(src, dst)
except OSError as exc:
raise RuntimeError(
f"Failed to install {_SPACY_MODEL_NAME} to {site_packages}: {exc}. "
"Ensure the site-packages directory is writable, or pre-install the model "
f"with: python -m spacy download {_SPACY_MODEL_NAME}"
) from exc
logger.info("Installed %s %s", _SPACY_MODEL_NAME, _SPACY_MODEL_VERSION)
# Only tok2vec, tagger, parser (sentence boundaries), and sentencizer are used
# (pos_tag and sent_tokenize). Excluding the remaining components saves ~7 MiB
# of model weights per process.
_SPACY_EXCLUDE = ["ner", "lemmatizer", "attribute_ruler"]
def _load_spacy_model() -> spacy.language.Language:
try:
return spacy.load(_SPACY_MODEL_NAME, exclude=_SPACY_EXCLUDE)
except OSError:
pass
# Serialize model installation across processes with an exclusive file lock.
# A well-known path in the system temp dir is visible to all processes
# regardless of their working directory.
with FileLock(_INSTALL_LOCK_PATH, timeout=-1):
# Double-check: another process may have installed while we waited.
importlib.invalidate_caches()
try:
return spacy.load(_SPACY_MODEL_NAME, exclude=_SPACY_EXCLUDE)
except OSError:
pass
_install_spacy_model()
importlib.invalidate_caches()
try:
return spacy.load(_SPACY_MODEL_NAME, exclude=_SPACY_EXCLUDE)
except OSError as exc:
raise RuntimeError(
f"Installed {_SPACY_MODEL_NAME} but spacy.load() still failed. "
"Check site-packages permissions and installation integrity."
) from exc
@lru_cache(maxsize=1)
def _get_nlp() -> spacy.language.Language:
"""Load the spaCy model on first use and cache it for the lifetime of the process."""
return _load_spacy_model()
def _process(text: str) -> spacy.tokens.Doc:
"""Run the spaCy pipeline once. All public functions extract what they need from the Doc."""
# -- str() handles numpy.str_ from OCR pipelines --
text = str(text)
nlp = _get_nlp()
if len(text) > nlp.max_length:
logger.warning(
"Input text of length %d exceeds spaCy max_length=%d; "
"truncating for partition heuristics.",
len(text),
nlp.max_length,
)
# Prefer to cut at the last whitespace within the budget so we don't split a token.
cut = text.rfind(" ", max(0, nlp.max_length - 256), nlp.max_length)
truncated = text[: cut if cut != -1 else nlp.max_length]
return nlp(truncated)
return nlp(text)
def sent_tokenize(text: str) -> List[str]:
"""A wrapper so that we can cache the result of sentence tokenization as an
immutable, while returning the expected return type (list)."""
return list(_tokenize_for_cache(text))
@lru_cache(maxsize=CACHE_MAX_SIZE)
def word_tokenize(text: str) -> List[str]:
"""A wrapper around the spaCy word tokenizer with LRU caching enabled."""
return [token.text for token in _process(text)]
@lru_cache(maxsize=CACHE_MAX_SIZE)
def pos_tag(text: str) -> List[Tuple[str, str]]:
"""A wrapper around the spaCy POS tagger with LRU caching enabled."""
doc = _process(text)
return [(token.text, token.tag_) for token in doc]
@lru_cache(maxsize=CACHE_MAX_SIZE)
def _tokenize_for_cache(text: str) -> Tuple[str, ...]:
"""A wrapper around the spaCy sentence tokenizer with LRU caching enabled."""
return tuple(sent.text for sent in _process(text).sents)
View File
+341
View File
@@ -0,0 +1,341 @@
from __future__ import annotations
import contextlib
from typing import IO, Any, Optional, Sequence
import requests
from unstructured_client import UnstructuredClient
from unstructured_client.models import operations, shared
from unstructured_client.utils import retries
from unstructured.documents.elements import Element
from unstructured.logger import logger
from unstructured.partition.common.common import exactly_one
from unstructured.staging.base import elements_from_dicts, elements_from_json
# Default retry configuration taken from the client code
DEFAULT_RETRIES_INITIAL_INTERVAL_SEC = 3000
DEFAULT_RETRIES_MAX_INTERVAL_SEC = 720000
DEFAULT_RETRIES_EXPONENT = 1.5
DEFAULT_RETRIES_MAX_ELAPSED_TIME_SEC = 1800000
DEFAULT_RETRIES_CONNECTION_ERRORS = True
def partition_via_api(
filename: Optional[str] = None,
content_type: Optional[str] = None,
file: Optional[IO[bytes]] = None,
file_filename: Optional[str] = None,
api_url: str = "https://api.unstructured.io/general/v0/general",
api_key: str = "",
metadata_filename: Optional[str] = None,
retries_initial_interval: [int] = None,
retries_max_interval: Optional[int] = None,
retries_exponent: Optional[float] = None,
retries_max_elapsed_time: Optional[int] = None,
retries_connection_errors: Optional[bool] = None,
**request_kwargs: Any,
) -> list[Element]:
"""Partitions a document using the Unstructured REST API. This is equivalent to
running the document through partition.
See https://api.unstructured.io/general/docs for the hosted API documentation or
https://github.com/Unstructured-IO/unstructured-api for instructions on how to run
the API locally as a container.
Parameters
----------
filename
A string defining the target filename path.
content_type
A string defining the file content in MIME type
file
A file-like object using "rb" mode --> open(filename, "rb").
metadata_filename
When file is not None, the filename (string) to store in element metadata. E.g. "foo.txt"
api_url
The URL for the Unstructured API. Defaults to the hosted Unstructured API.
api_key
The API key to pass to the Unstructured API.
retries_initial_interval
Defines the time interval (in seconds) to wait before the first retry in case of a request
failure. Defaults to 3000. If set should be > 0.
retries_max_interval
Defines the maximum time interval (in seconds) to wait between retries (the interval
between retries is increased as using exponential increase algorithm
- this setting limits it). Defaults to 720000. If set should be > 0.
retries_exponent
Defines the exponential factor to increase the interval between retries. Defaults to 1.5.
If set should be > 0.0.
retries_max_elapsed_time
Defines the maximum time (in seconds) to wait for retries. If exceeded, the original
exception is raised. Defaults to 1800000. If set should be > 0.
retries_connection_errors
Defines whether to retry on connection errors. Defaults to True.
request_kwargs
Additional parameters to pass to the data field of the request to the Unstructured API.
For example the `strategy` parameter.
"""
exactly_one(filename=filename, file=file)
if metadata_filename and file_filename:
raise ValueError(
"Only one of metadata_filename and file_filename is specified. "
"metadata_filename is preferred. file_filename is marked for deprecation.",
)
if file_filename is not None:
metadata_filename = file_filename
logger.warning(
"The file_filename kwarg will be deprecated in a future version of unstructured. "
"Please use metadata_filename instead.",
)
# Note(austin) - the sdk takes the base url, but we have the full api_url
# For consistency, just strip off the path when it's given
base_url = api_url[:-19] if "/general/v0/general" in api_url else api_url
sdk = UnstructuredClient(api_key_auth=api_key, server_url=base_url)
if filename is not None:
with open(filename, "rb") as f:
files = shared.Files(
content=f.read(),
file_name=filename,
)
elif file is not None:
if metadata_filename is None:
raise ValueError(
"If file is specified in partition_via_api, "
"metadata_filename must be specified as well.",
)
files = shared.Files(content=file, file_name=metadata_filename)
req = operations.PartitionRequest(
partition_parameters=shared.PartitionParameters(files=files, **request_kwargs)
)
retries_config = get_retries_config(
retries_connection_errors=retries_connection_errors,
retries_exponent=retries_exponent,
retries_initial_interval=retries_initial_interval,
retries_max_elapsed_time=retries_max_elapsed_time,
retries_max_interval=retries_max_interval,
sdk=sdk,
)
response = sdk.general.partition(
request=req,
retries=retries_config,
)
if response.status_code == 200:
return elements_from_json(text=response.raw_response.text)
else:
raise ValueError(
f"Receive unexpected status code {response.status_code} from the API.",
)
def get_retries_config(
retries_connection_errors: Optional[bool],
retries_exponent: Optional[float],
retries_initial_interval: Optional[int],
retries_max_elapsed_time: Optional[int],
retries_max_interval: Optional[int],
sdk: UnstructuredClient,
) -> Optional[retries.RetryConfig]:
"""Constructs a RetryConfig object from the provided parameters. If any of the parameters
are None, the default values are taken from the SDK configuration or the default constants.
If all parameters are None, returns None (and the SDK-managed defaults are used within the
client)
The solution is not perfect as the RetryConfig object does not include the defaults by
itself so we might need to construct it basing on our defaults.
Parameters
----------
retries_connection_errors
Defines whether to retry on connection errors. If not set the
DEFAULT_RETRIES_CONNECTION_ERRORS constant is used.
retries_exponent
Defines the exponential factor to increase the interval between retries.
If set, should be > 0.0 (otherwise the DEFAULT_RETRIES_EXPONENT constant is used)
retries_initial_interval
Defines the time interval to wait before the first retry in case of a request failure.
If set, should be > 0 (otherwise the DEFAULT_RETRIES_INITIAL_INTERVAL_SEC constant is used)
retries_max_elapsed_time
Defines the maximum time to wait for retries. If exceeded, the original exception is raised.
If set, should be > 0 (otherwise the DEFAULT_RETRIES_MAX_ELAPSED_TIME_SEC constant is used)
retries_max_interval
Defines the maximum time interval to wait between retries. If set, should be > 0
(otherwise the DEFAULT_RETRIES_MAX_INTERVAL_SEC constant is used)
sdk
The UnstructuredClient object to take the default values from.
"""
retries_config = None
sdk_default_retries_config = sdk.sdk_configuration.retry_config
if any(
setting is not None
for setting in (
retries_initial_interval,
retries_max_interval,
retries_exponent,
retries_max_elapsed_time,
retries_connection_errors,
)
):
def get_backoff_default(setting_name: str, default_value: Any) -> Any:
if sdk_default_retries_config: # noqa: SIM102
if setting_value := getattr(sdk_default_retries_config.backoff, setting_name):
return setting_value
return default_value
default_retries_connneciton_errors = (
sdk_default_retries_config.retry_connection_errors
if sdk_default_retries_config
and sdk_default_retries_config.retry_connection_errors is not None
else DEFAULT_RETRIES_CONNECTION_ERRORS
)
backoff_strategy = retries.BackoffStrategy(
initial_interval=(
retries_initial_interval
or get_backoff_default("initial_interval", DEFAULT_RETRIES_INITIAL_INTERVAL_SEC)
),
max_interval=(
retries_max_interval
or get_backoff_default("max_interval", DEFAULT_RETRIES_MAX_INTERVAL_SEC)
),
exponent=(
retries_exponent or get_backoff_default("exponent", DEFAULT_RETRIES_EXPONENT)
),
max_elapsed_time=(
retries_max_elapsed_time
or get_backoff_default("max_elapsed_time", DEFAULT_RETRIES_MAX_ELAPSED_TIME_SEC)
),
)
retries_config = retries.RetryConfig(
strategy="backoff",
backoff=backoff_strategy,
retry_connection_errors=(
retries_connection_errors
if retries_connection_errors is not None
else default_retries_connneciton_errors
),
)
return retries_config
def partition_multiple_via_api(
filenames: Optional[list[str]] = None,
content_types: Optional[list[str]] = None,
files: Optional[Sequence[IO[bytes]]] = None,
file_filenames: Optional[list[str]] = None,
api_url: str = "https://api.unstructured.io/general/v0/general",
api_key: str = "",
metadata_filenames: Optional[list[str]] = None,
**request_kwargs: Any,
) -> list[list[Element]]:
"""Partitions multiple documents using the Unstructured REST API by batching
the documents into a single HTTP request.
See https://api.unstructured.io/general/docs for the hosted API documentation or
https://github.com/Unstructured-IO/unstructured-api for instructions on how to run
the API locally as a container.
Parameters
----------
filenames
A list of strings defining the target filename paths.
content_types
A list of strings defining the file contents in MIME types.
files
A list of file-like object using "rb" mode --> open(filename, "rb").
metadata_filename
When file is not None, the filename (string) to store in element metadata. E.g. "foo.txt"
api_url
The URL for the Unstructured API. Defaults to the hosted Unstructured API.
api_key
The API key to pass to the Unstructured API.
request_kwargs
Additional parameters to pass to the data field of the request to the Unstructured API.
For example the `strategy` parameter.
"""
headers = {
"ACCEPT": "application/json",
"UNSTRUCTURED-API-KEY": api_key,
}
if metadata_filenames and file_filenames:
raise ValueError(
"Only one of metadata_filenames and file_filenames is specified. "
"metadata_filenames is preferred. file_filenames is marked for deprecation.",
)
if file_filenames is not None:
metadata_filenames = file_filenames
logger.warning(
"The file_filenames kwarg will be deprecated in a future version of unstructured. "
"Please use metadata_filenames instead.",
)
if filenames is not None:
if content_types and len(content_types) != len(filenames):
raise ValueError("content_types and filenames must have the same length.")
with contextlib.ExitStack() as stack:
files = [stack.enter_context(open(f, "rb")) for f in filenames] # type: ignore
_files = []
for i, file in enumerate(files):
filename = filenames[i]
content_type = content_types[i] if content_types is not None else None
_files.append(("files", (filename, file, content_type)))
response = requests.post(
api_url,
headers=headers,
data=request_kwargs,
files=_files, # type: ignore
)
elif files is not None:
if content_types and len(content_types) != len(files):
raise ValueError("content_types and files must have the same length.")
if not metadata_filenames:
raise ValueError("metadata_filenames must be specified if files are passed")
elif len(metadata_filenames) != len(files):
raise ValueError("metadata_filenames and files must have the same length.")
_files = []
for i, _file in enumerate(files): # type: ignore
content_type = content_types[i] if content_types is not None else None
filename = metadata_filenames[i]
_files.append(("files", (filename, _file, content_type)))
response = requests.post(
api_url,
headers=headers,
data=request_kwargs,
files=_files, # type: ignore
)
if response.status_code == 200:
documents = []
response_list = response.json()
# NOTE(robinson) - this check is because if only one filename is passed, the return
# type from the API is a list of objects instead of a list of lists
if not isinstance(response_list[0], list):
response_list = [response_list]
for document in response_list:
documents.append(elements_from_dicts(document))
return documents
else:
raise ValueError(
f"Receive unexpected status code {response.status_code} from the API.",
)
+145
View File
@@ -0,0 +1,145 @@
"""Partition audio files into elements using speech-to-text transcription."""
from __future__ import annotations
import shutil
import tempfile
from pathlib import Path
from typing import IO, Any
from unstructured.chunking import add_chunking_strategy
from unstructured.documents.elements import Element, ElementMetadata, NarrativeText
from unstructured.file_utils.filetype import detect_filetype
from unstructured.file_utils.model import FileType
from unstructured.partition.common.common import exactly_one
from unstructured.partition.common.metadata import apply_metadata, get_last_modified_date
from unstructured.partition.utils.speech_to_text.speech_to_text_interface import (
SpeechToTextAgent,
)
from unstructured.utils import is_temp_file_path
_AUDIO_MIME_TYPE_FALLBACK = FileType.WAV.mime_type
@apply_metadata()
@add_chunking_strategy
def partition_audio(
filename: str | None = None,
*,
file: IO[bytes] | None = None,
language: str | None = None,
stt_agent: str | None = None,
metadata_filename: str | None = None,
metadata_last_modified: str | None = None,
**kwargs: Any,
) -> list[Element]:
"""Partition an audio file into elements using speech-to-text transcription.
Supports any audio format handled by the configured STT agent. The default Whisper
agent accepts any format that ffmpeg supports (WAV, MP3, FLAC, M4A, OGG, OPUS, WEBM,
and more). Transcription produces one NarrativeText element per segment, preserving
segment-level timestamps in ``metadata.segment_start_seconds`` and
``metadata.segment_end_seconds``. Requires the optional ``audio`` extra:
``pip install "unstructured[audio]"``.
.. note::
**Chunking drops segment timestamps.** When elements are merged by a chunking
strategy, ``segment_start_seconds`` and ``segment_end_seconds`` are currently
discarded (consolidation strategy ``DROP``). If audio timeline alignment matters
for your use-case, consume the un-chunked elements directly.
Parameters
----------
filename
Path to the audio file.
file
File-like object opened in binary mode (e.g. ``open("audio.mp3", "rb")``).
language
Optional ISO 639-1 language code for the spoken language (e.g. ``"en"``).
When ``None``, the speech-to-text agent may auto-detect.
stt_agent
Optional fully-qualified class name of the SpeechToTextAgent implementation.
Defaults to the Whisper agent when the ``audio`` extra is installed.
metadata_filename
Filename to store in element metadata when partitioning from a file object.
metadata_last_modified
Last modified date to store in element metadata.
"""
exactly_one(filename=filename, file=file)
# Metadata from original input only (not the temp path); compute before any temp file lifecycle.
last_modified = get_last_modified_date(filename) if filename else None
mime_type = _audio_mime_type(filename, file, metadata_filename)
audio_path: str
if filename is not None:
audio_path = filename
else:
assert file is not None # guaranteed by exactly_one()
file.seek(0)
suffix = _audio_suffix(file, metadata_filename)
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
shutil.copyfileobj(file, tmp)
audio_path = tmp.name
try:
agent = SpeechToTextAgent.get_agent(stt_agent)
segments = agent.transcribe_segments(audio_path, language=language)
finally:
if filename is None and is_temp_file_path(audio_path):
Path(audio_path).unlink(missing_ok=True)
if not segments:
return []
elements: list[Element] = []
for seg in segments:
text = seg["text"].strip()
if not text:
continue
element = NarrativeText(text=text)
element.metadata = ElementMetadata(
filetype=mime_type,
last_modified=last_modified,
segment_start_seconds=seg["start"],
segment_end_seconds=seg["end"],
)
element.metadata.detection_origin = "speech_to_text"
elements.append(element)
return elements
def _audio_mime_type(
filename: str | None,
file: IO[bytes] | None,
metadata_filename: str | None,
) -> str:
"""Return the MIME type for the audio source, falling back to WAV when undetectable."""
detected: FileType | None = None
if filename is not None:
detected = detect_filetype(file_path=filename)
elif file is not None:
detected = detect_filetype(
file=file,
metadata_file_path=metadata_filename,
)
if detected is not None and detected not in (FileType.UNK, FileType.EMPTY):
return detected.mime_type
return _AUDIO_MIME_TYPE_FALLBACK
def _audio_suffix(file: IO[bytes], metadata_filename: str | None) -> str:
"""Return the file-extension suffix for a temp file that wraps `file`.
Preference order:
1. Extension of `metadata_filename` when provided (e.g. ".mp3" from "recording.mp3").
2. Extension of `file.name` when the file object exposes a name (e.g. a real opened file).
3. ".wav" as a safe fallback recognised by all STT backends.
"""
for name in (metadata_filename, getattr(file, "name", None)):
if name:
suffix = Path(name).suffix
if suffix:
return suffix
return ".wav"
+390
View File
@@ -0,0 +1,390 @@
"""Provides partitioning with automatic file-type detection."""
from __future__ import annotations
import copy
import importlib
import io
from typing import IO, Any, Callable, Optional
from typing_extensions import TypeAlias
from unstructured.documents.elements import DataSourceMetadata, Element
from unstructured.file_utils.filetype import (
detect_filetype,
is_json_processable,
is_ndjson_processable,
)
from unstructured.file_utils.model import FileType
from unstructured.logger import logger
from unstructured.partition.common import UnsupportedFileFormatError
from unstructured.partition.common.common import exactly_one
from unstructured.partition.common.lang import check_language_args
from unstructured.partition.utils.constants import PartitionStrategy
from unstructured.safe_http import safe_get
from unstructured.utils import dependency_exists
Partitioner: TypeAlias = Callable[..., list[Element]]
def partition(
filename: Optional[str] = None,
*,
file: Optional[IO[bytes]] = None,
encoding: Optional[str] = None,
content_type: Optional[str] = None,
url: Optional[str] = None,
headers: dict[str, str] = {},
ssl_verify: bool = True,
request_timeout: Optional[int] = None,
strategy: str = PartitionStrategy.AUTO,
skip_infer_table_types: list[str] = ["pdf", "jpg", "png", "heic"],
ocr_languages: Optional[str] = None, # changing to optional for deprecation
languages: Optional[list[str]] = None,
detect_language_per_element: bool = False,
language_fallback: Optional[Callable[[str], Optional[list[str]]]] = None,
pdf_infer_table_structure: bool = False,
extract_images_in_pdf: bool = False,
extract_image_block_types: Optional[list[str]] = None,
extract_image_block_output_dir: Optional[str] = None,
extract_image_block_to_payload: bool = False,
data_source_metadata: Optional[DataSourceMetadata] = None,
metadata_filename: Optional[str] = None,
hi_res_model_name: Optional[str] = None,
model_name: Optional[str] = None, # to be deprecated
starting_page_number: int = 1,
**kwargs: Any,
) -> list[Element]:
"""Partitions a document into its constituent elements.
Uses libmagic to determine the file's type and route it to the appropriate partitioning
function. Applies the default parameters for each partitioning function. Use the document-type
specific partitioning functions if you need access to additional kwarg options.
Parameters
----------
filename
A string defining the target filename path.
file
A file-like object using "rb" mode --> open(filename, "rb").
encoding
The character-encoding used to decode the input bytes when drawn from `filename` or `file`.
Defaults to "utf-8".
url
The url for a remote document. Pass in content_type if you want partition to treat
the document as a specific content_type.
headers
The headers to be used in conjunction with the HTTP request if URL is set.
ssl_verify
If the URL parameter is set, determines whether or not partition uses SSL verification
in the HTTP request.
request_timeout
The timeout for the HTTP request if URL is set. Defaults to None meaning no timeout and
requests will block indefinitely.
content_type
A string defining the file content in MIME type
metadata_filename
When file is not None, the filename (string) to store in element metadata. E.g. "foo.txt"
strategy
The strategy to use for partitioning PDF/image. Uses a layout detection model if set
to 'hi_res', otherwise partition simply extracts the text from the document
and processes it.
skip_infer_table_types
The document types that you want to skip table extraction with.
languages
The languages present in the document, for use in partitioning and/or OCR. For partitioning
image or pdf documents with Tesseract, you'll first need to install the appropriate
Tesseract language pack. For other partitions, language is detected using naive Bayesian
filter via `langdetect`. Multiple languages indicates text could be in either language.
detect_language_per_element
Detect language per element instead of at the document level.
language_fallback
Optional callable for short text (e.g. when detection defaults to English).
Called with the text; return a list of ISO 639-3 codes or None to leave
language unspecified.
pdf_infer_table_structure
Deprecated! Use `skip_infer_table_types` to opt out of table extraction for any document
type.
If True and strategy=hi_res, any Table Elements extracted from a PDF will include an
additional metadata field, "text_as_html," where the value (string) is a just a
transformation of the data into an HTML <table>.
The "text" field for a partitioned Table Element is always present, whether True or False.
extract_images_in_pdf
Only applicable if `strategy=hi_res`.
If True, any detected images will be saved in the path specified by
'extract_image_block_output_dir' or stored as base64 encoded data within metadata fields.
Deprecation Note: This parameter is marked for deprecation. Future versions will use
'extract_image_block_types' for broader extraction capabilities.
extract_image_block_types
Only applicable if `strategy=hi_res`.
Images of the element type(s) specified in this list (e.g., ["Image", "Table"]) will be
saved in the path specified by 'extract_image_block_output_dir' or stored as base64
encoded data within metadata fields.
extract_image_block_to_payload
Only applicable if `strategy=hi_res`.
If True, images of the element type(s) defined in 'extract_image_block_types' will be
encoded as base64 data and stored in two metadata fields: 'image_base64' and
'image_mime_type'.
This parameter facilitates the inclusion of element data directly within the payload,
especially for web-based applications or APIs.
extract_image_block_output_dir
Only applicable if `strategy=hi_res` and `extract_image_block_to_payload=False`.
The filesystem path for saving images of the element type(s)
specified in 'extract_image_block_types'.
hi_res_model_name
The layout detection model used when partitioning strategy is set to `hi_res`.
model_name
The layout detection model used when partitioning strategy is set to `hi_res`. To be
deprecated in favor of `hi_res_model_name`.
starting_page_number
Indicates what page number should be assigned to the first page in the document.
This information will be reflected in elements' metadata and can be be especially
useful when partitioning a document that is part of a larger document.
"""
exactly_one(file=file, filename=filename, url=url)
kwargs.setdefault("metadata_filename", metadata_filename)
if pdf_infer_table_structure:
logger.warning(
"The pdf_infer_table_structure kwarg is deprecated. Please use skip_infer_table_types "
"instead."
)
languages = check_language_args(languages or [], ocr_languages)
if url is not None:
file, file_type = file_and_type_from_url(
url=url,
content_type=content_type,
headers=headers,
ssl_verify=ssl_verify,
request_timeout=request_timeout,
)
else:
if headers != {}:
logger.warning(
"The headers kwarg is set but the url kwarg is not. "
"The headers kwarg will be ignored.",
)
file_type = detect_filetype(
file_path=filename,
file=file,
encoding=encoding,
content_type=content_type,
metadata_file_path=metadata_filename,
)
if file is not None:
file.seek(0)
# avoid double specification of infer_table_structure; this can happen when the kwarg passed
# into a partition function, e.g., partition_email is reused to partition sub-elements, e.g.,
# partition an image attachment buy calling partition with the kwargs. In that case here kwargs
# would have a infer_table_structure already
kwargs_infer_table_structure = kwargs.pop("infer_table_structure", None)
infer_table_structure = (
kwargs_infer_table_structure
if kwargs_infer_table_structure is not None
else decide_table_extraction(
file_type,
skip_infer_table_types,
pdf_infer_table_structure,
)
)
partitioner_loader = _PartitionerLoader()
# -- extracting this post-processing to allow multiple exit-points from function --
def augment_metadata(elements: list[Element]) -> list[Element]:
"""Add some metadata fields to each element."""
for element in elements:
element.metadata.url = url
element.metadata.data_source = data_source_metadata
if content_type is not None:
out_filetype = FileType.from_mime_type(content_type)
element.metadata.filetype = out_filetype.mime_type if out_filetype else None
else:
element.metadata.filetype = file_type.mime_type
return elements
# -- handle PDF/Image partitioning separately because they have a lot of special-case
# -- parameters. We'll come back to this after sorting out the other file types.
if file_type == FileType.PDF:
partition_pdf = partitioner_loader.get(file_type)
elements = partition_pdf(
filename=filename,
file=file,
url=None,
infer_table_structure=infer_table_structure,
strategy=strategy,
languages=languages,
detect_language_per_element=detect_language_per_element,
language_fallback=language_fallback,
hi_res_model_name=hi_res_model_name or model_name,
extract_images_in_pdf=extract_images_in_pdf,
extract_image_block_types=extract_image_block_types,
extract_image_block_output_dir=extract_image_block_output_dir,
extract_image_block_to_payload=extract_image_block_to_payload,
starting_page_number=starting_page_number,
**kwargs,
)
return augment_metadata(elements)
if file_type.partitioner_shortname and file_type.partitioner_shortname == "image":
partition_image = partitioner_loader.get(file_type)
elements = partition_image(
filename=filename,
file=file,
url=None,
infer_table_structure=infer_table_structure,
strategy=strategy,
languages=languages,
detect_language_per_element=detect_language_per_element,
language_fallback=language_fallback,
hi_res_model_name=hi_res_model_name or model_name,
extract_images_in_pdf=extract_images_in_pdf,
extract_image_block_types=extract_image_block_types,
extract_image_block_output_dir=extract_image_block_output_dir,
extract_image_block_to_payload=extract_image_block_to_payload,
starting_page_number=starting_page_number,
**kwargs,
)
return augment_metadata(elements)
# -- JSON is a special case because it's not a document format per se and is insensitive to
# -- most of the parameters that apply to other file types.
if file_type == FileType.JSON:
if not is_json_processable(filename=filename, file=file):
raise ValueError(
"Detected a JSON file that does not conform to the Unstructured schema. "
"partition_json currently only processes serialized Unstructured output.",
)
partition_json = partitioner_loader.get(file_type)
elements = partition_json(filename=filename, file=file, **kwargs)
return augment_metadata(elements)
if file_type == FileType.NDJSON:
if not is_ndjson_processable(filename=filename, file=file):
raise ValueError(
"Detected an NDJSON file that does not conform to the Unstructured schema. "
"partition_json currently only processes serialized Unstructured output.",
)
partition_ndjson = partitioner_loader.get(file_type)
elements = partition_ndjson(filename=filename, file=file, **kwargs)
return augment_metadata(elements)
# -- EMPTY is also a special case because while we can't determine the file type, we can be
# -- sure it doesn't contain any elements.
if file_type == FileType.EMPTY:
return []
# ============================================================================================
# ALL OTHER FILE TYPES
# ============================================================================================
partitioning_kwargs = copy.deepcopy(kwargs)
partitioning_kwargs["detect_language_per_element"] = detect_language_per_element
partitioning_kwargs["language_fallback"] = language_fallback
partitioning_kwargs["encoding"] = encoding
partitioning_kwargs["infer_table_structure"] = infer_table_structure
partitioning_kwargs["languages"] = languages
partitioning_kwargs["starting_page_number"] = starting_page_number
partitioning_kwargs["strategy"] = strategy
partitioning_kwargs["extract_image_block_types"] = extract_image_block_types
partitioning_kwargs["extract_image_block_to_payload"] = extract_image_block_to_payload
partition = partitioner_loader.get(file_type)
elements = partition(filename=filename, file=file, **partitioning_kwargs)
return augment_metadata(elements)
def file_and_type_from_url(
url: str,
content_type: Optional[str] = None,
headers: dict[str, str] = {},
ssl_verify: bool = True,
request_timeout: Optional[int] = None,
) -> tuple[io.BytesIO, FileType]:
response = safe_get(url, headers=headers, verify=ssl_verify, timeout=request_timeout)
file = io.BytesIO(response.content)
if content_type := content_type or response.headers.get("Content-Type", None):
content_type = content_type.split(";")[0].strip().lower()
# -- non-None when response is textual --
encoding = response.encoding
filetype = detect_filetype(file=file, encoding=encoding, content_type=content_type)
return file, filetype
def decide_table_extraction(
filetype: Optional[FileType],
skip_infer_table_types: list[str],
pdf_infer_table_structure: bool,
) -> bool:
doc_type = filetype.name.lower() if filetype else None
if doc_type == "pdf":
# For backwards compatibility. Ultimately we want to remove pdf_infer_table_structure
# completely and rely exclusively on `skip_infer_table_types` for all file types.
# Until then for pdf files we first check pdf_infer_table_structure and then update
# based on skip_infer_tables.
return pdf_infer_table_structure or doc_type not in skip_infer_table_types
return doc_type not in skip_infer_table_types
class _PartitionerLoader:
"""Provides uniform helpful error when a partitioner dependency is not installed.
Used by `partition()` to encapsulate coping with the possibility the Python environment it is
executing in may not have all dependencies installed for a particular partitioner.
Provides `.get()` to access partitioners by file-type, which raises when one or more
dependencies for that partitioner are not installed.
The error message indicates what extra needs to be installed to enable that partitioner. This
avoids an inconsistent variety of possibly puzzling exceptions arising from much deeper in the
partitioner when access to the missing dependency is first attempted.
"""
# -- module-lifetime cache for partitioners once loaded --
_partitioners: dict[FileType, Partitioner] = {}
def get(self, file_type: FileType) -> Partitioner:
"""Return partitioner for `file_type`.
Raises when one or more package dependencies for that file-type have not been
installed. Also raises when the file-type is not partitionable.
"""
if not file_type.is_partitionable:
raise UnsupportedFileFormatError(
f"Partitioning is not supported for the {file_type} file type."
)
# -- if the partitioner is not in the cache, load it; note this raises if one or more of
# -- the partitioner's dependencies is not installed.
if file_type not in self._partitioners:
self._partitioners[file_type] = self._load_partitioner(file_type)
return self._partitioners[file_type]
def _load_partitioner(self, file_type: FileType) -> Partitioner:
"""Load the partitioner for `file_type` after verifying dependencies."""
# -- verify all package dependencies are installed --
for pkg_name in file_type.importable_package_dependencies:
if not dependency_exists(pkg_name):
raise ImportError(
f"{file_type.partitioner_function_name}() is not available because one or"
f" more dependencies are not installed. Use:"
f' pip install "unstructured[{file_type.extra_name}]" (including quotes)'
f" to install the required dependencies",
)
# -- load the partitioner and return it --
assert file_type.is_partitionable # -- would be a programming error if this failed --
partitioner_module = importlib.import_module(file_type.partitioner_module_qname)
return getattr(partitioner_module, file_type.partitioner_function_name)
+19
View File
@@ -0,0 +1,19 @@
from typing import Final
class UnsupportedFileFormatError(Exception):
"""File-type is not supported for this operation.
For example, when receiving a file for auto-partitioning where its file-formatt cannot be
identified or there is no partitioner available for that file-format.
"""
# Exceptions that email/msg partitioners treat as "unsupported attachment" and skip with a
# warning (no data loss). Intentionally narrow: we do not catch RuntimeError (OOM, broken pipe,
# parser failures in e.g. pdfminer would otherwise be silently skipped).
EXPECTED_ATTACHMENT_ERRORS: Final[tuple[type[BaseException], ...]] = (
UnsupportedFileFormatError,
ImportError,
FileNotFoundError,
)
+460
View File
@@ -0,0 +1,460 @@
from __future__ import annotations
import numbers
import subprocess
from enum import Enum
from io import BufferedReader, BytesIO, TextIOWrapper
from tempfile import SpooledTemporaryFile
from time import sleep
from typing import IO, TYPE_CHECKING, Any, Optional, TypeVar, cast
import emoji
import psutil
from unstructured.documents.coordinates import CoordinateSystem, PixelSpace
from unstructured.documents.elements import (
TYPE_TO_TEXT_ELEMENT_MAP,
CheckBox,
CoordinatesMetadata,
Element,
ElementMetadata,
ElementType,
ListItem,
PageBreak,
Text,
)
from unstructured.logger import logger
from unstructured.nlp.patterns import ENUMERATED_BULLETS_RE, UNICODE_BULLETS_RE
if TYPE_CHECKING:
from unstructured_inference.inference.layout import PageLayout
from unstructured_inference.inference.layoutelement import LayoutElement
def normalize_layout_element(
layout_element: LayoutElement | Element | dict[str, Any],
coordinate_system: Optional[CoordinateSystem] = None,
infer_list_items: bool = True,
source_format: Optional[str] = "html",
) -> Element | list[Element]:
"""Converts an unstructured_inference LayoutElement object to an unstructured Element."""
if isinstance(layout_element, Element) and source_format == "html":
return layout_element
# NOTE(alan): Won't the lines above ensure this never runs (PageBreak is a subclass of Element)?
if isinstance(layout_element, PageBreak):
return PageBreak(text="")
if not isinstance(layout_element, dict):
layout_dict = layout_element.to_dict()
else:
layout_dict = layout_element
text = layout_dict.get("text", "")
# Both `coordinates` and `coordinate_system` must be present
# in order to add coordinates metadata to the element.
coordinates = layout_dict.get("coordinates") if coordinate_system else None
element_type = layout_dict.get("type")
prob = layout_dict.get("prob")
aux_origin = layout_dict.get("source", None)
origin = None
if isinstance(layout_dict.get("is_extracted"), Enum):
is_extracted = layout_dict["is_extracted"].value
else:
is_extracted = None
if aux_origin:
origin = aux_origin.value
if prob and isinstance(prob, (int, str, float, numbers.Number)):
class_prob_metadata = ElementMetadata(detection_class_prob=float(prob)) # type: ignore
else:
class_prob_metadata = ElementMetadata()
class_prob_metadata.is_extracted = is_extracted
common_kwargs = {
"coordinates": coordinates,
"coordinate_system": coordinate_system,
"metadata": class_prob_metadata,
"detection_origin": origin,
}
if element_type == ElementType.LIST:
if infer_list_items:
return layout_list_to_list_items(
text,
**common_kwargs,
)
else:
return ListItem(
text=text,
**common_kwargs,
)
elif element_type in TYPE_TO_TEXT_ELEMENT_MAP:
assert isinstance(element_type, str) # Added to resolve type-error
_element_class = TYPE_TO_TEXT_ELEMENT_MAP[element_type]
_element_class = _element_class(
text=text,
**common_kwargs,
)
if element_type == ElementType.HEADLINE:
_element_class.metadata.category_depth = 1
elif element_type == ElementType.SUB_HEADLINE:
_element_class.metadata.category_depth = 2
return _element_class
elif element_type in [
ElementType.CHECK_BOX_CHECKED,
ElementType.CHECK_BOX_UNCHECKED,
ElementType.RADIO_BUTTON_CHECKED,
ElementType.RADIO_BUTTON_UNCHECKED,
ElementType.CHECKED,
ElementType.UNCHECKED,
]:
checked = element_type in [
ElementType.CHECK_BOX_CHECKED,
ElementType.RADIO_BUTTON_CHECKED,
ElementType.CHECKED,
]
return CheckBox(
checked=checked,
**common_kwargs,
)
else:
return Text(
text=text,
**common_kwargs,
)
def layout_list_to_list_items(
text: Optional[str],
coordinates: Optional[tuple[tuple[float, float], ...]],
coordinate_system: Optional[CoordinateSystem],
metadata: Optional[ElementMetadata],
detection_origin: Optional[str],
) -> list[Element]:
"""Converts a list LayoutElement to a list of ListItem elements."""
split_items = ENUMERATED_BULLETS_RE.split(text) if text else []
# NOTE(robinson) - this means there wasn't a match for the enumerated bullets
if len(split_items) == 1:
split_items = UNICODE_BULLETS_RE.split(text) if text else []
list_items: list[Element] = []
for text_segment in split_items:
if len(text_segment.strip()) > 0:
# Both `coordinates` and `coordinate_system` must be present
# in order to add coordinates metadata to the element.
item = ListItem(
text=text_segment.strip(),
coordinates=coordinates,
coordinate_system=coordinate_system,
metadata=metadata,
detection_origin=detection_origin,
)
list_items.append(item)
return list_items
def add_element_metadata(
element: Element,
filename: Optional[str] = None,
filetype: Optional[str] = None,
page_number: Optional[int] = None,
url: Optional[str] = None,
text_as_html: Optional[str] = None,
coordinates: Optional[tuple[tuple[float, float], ...]] = None,
coordinate_system: Optional[CoordinateSystem] = None,
image_path: Optional[str] = None,
detection_origin: Optional[str] = None,
languages: Optional[list[str]] = None,
**kwargs: Any,
) -> Element:
"""Adds document metadata to the document element.
Document metadata includes information like the filename, source url, and page number.
"""
coordinates_metadata = (
CoordinatesMetadata(
points=coordinates,
system=coordinate_system,
)
if coordinates is not None and coordinate_system is not None
else None
)
links = element.links if hasattr(element, "links") and len(element.links) > 0 else None
link_urls = [link.get("url") for link in links] if links else None
link_texts = [link.get("text") for link in links] if links else None
link_start_indexes = [link.get("start_index") for link in links] if links else None
emphasized_texts = (
element.emphasized_texts
if hasattr(element, "emphasized_texts") and len(element.emphasized_texts) > 0
else None
)
emphasized_text_contents = (
[emphasized_text.get("text") for emphasized_text in emphasized_texts]
if emphasized_texts
else None
)
emphasized_text_tags = (
[emphasized_text.get("tag") for emphasized_text in emphasized_texts]
if emphasized_texts
else None
)
depth = element.metadata.category_depth if element.metadata.category_depth else None
metadata = ElementMetadata(
coordinates=coordinates_metadata,
filename=filename,
filetype=filetype,
page_number=page_number,
url=url,
text_as_html=text_as_html,
link_urls=link_urls,
link_texts=link_texts,
link_start_indexes=link_start_indexes,
emphasized_text_contents=emphasized_text_contents,
emphasized_text_tags=emphasized_text_tags,
category_depth=depth,
image_path=image_path,
languages=languages,
)
element.metadata.update(metadata)
if detection_origin is not None:
element.metadata.detection_origin = detection_origin
return element
def remove_element_metadata(layout_elements: list[Element]) -> list[Element]:
"""Removes document metadata from the document element.
Document metadata includes information like the filename, source url, and page number.
"""
elements: list[Element] = []
metadata = ElementMetadata()
for layout_element in layout_elements:
element = normalize_layout_element(layout_element)
if isinstance(element, list):
for _element in element:
_element.metadata = metadata
elements.extend(element)
else:
element.metadata = metadata
elements.append(element)
return elements
def _is_soffice_running():
for proc in psutil.process_iter():
try:
if "soffice" in proc.name().lower():
return True
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
pass
return False
def convert_office_doc(
input_filename: str,
output_directory: str,
target_format: str = "docx",
target_filter: Optional[str] = None,
wait_for_soffice_ready_time_out: int = 10,
):
"""Converts a .doc/.ppt file to a .docx/.pptx file using the libreoffice CLI.
Parameters
----------
input_filename: str
The name of the .doc file to convert to .docx
output_directory: str
The output directory for the convert .docx file
target_format: str
The desired output format
target_filter: str
The output filter name to use when converting. See references below
for details.
wait_for_soffice_ready_time_out: int
The max wait time in seconds for soffice to become available to run
References
----------
https://stackoverflow.com/questions/52277264/convert-doc-to-docx-using-soffice-not-working
https://git.libreoffice.org/core/+/refs/heads/master/filter/source/config/fragments/filters
"""
if target_filter is not None:
target_format = f"{target_format}:{target_filter}"
# NOTE(robinson) - In the future can also include win32com client as a fallback for windows
# users who do not have LibreOffice installed
# ref: https://stackoverflow.com/questions/38468442/
# multiple-doc-to-docx-file-conversion-using-python
command = [
"soffice",
"--headless",
"--convert-to",
target_format,
"--outdir",
output_directory,
input_filename,
]
try:
# only one soffice process can be ran
wait_time = 0
sleep_time = 0.1
output = subprocess.run(command, capture_output=True)
message = output.stdout.decode().strip()
# we can't rely on returncode unfortunately because on macOS it would return 0 even when the
# command failed to run; instead we have to rely on the stdout being empty as a sign of the
# process failed
while (wait_time < wait_for_soffice_ready_time_out) and (message == ""):
wait_time += sleep_time
if _is_soffice_running():
sleep(sleep_time)
else:
output = subprocess.run(command, capture_output=True)
message = output.stdout.decode().strip()
except FileNotFoundError:
raise FileNotFoundError(
"""soffice command was not found. Please install libreoffice
on your system and try again.
- Install instructions: https://www.libreoffice.org/get-help/install-howto/
- Mac: https://formulae.brew.sh/cask/libreoffice
- Debian: https://wiki.debian.org/LibreOffice""",
)
logger.info(message)
if output.returncode != 0 or message == "":
logger.error(
"soffice failed to convert to format %s with code %i", target_format, output.returncode
)
logger.error(output.stderr.decode().strip())
def exactly_one(**kwargs: Any) -> None:
"""
Verify arguments; exactly one of all keyword arguments must not be None.
Example:
>>> exactly_one(filename=filename, file=file, text=text, url=url)
"""
if sum([(arg is not None and arg != "") for arg in kwargs.values()]) != 1:
names = list(kwargs.keys())
if len(names) > 1:
message = f"Exactly one of {', '.join(names[:-1])} and {names[-1]} must be specified."
else:
message = f"{names[0]} must be specified."
raise ValueError(message)
_T = TypeVar("_T")
def spooled_to_bytes_io_if_needed(file: _T | SpooledTemporaryFile[bytes]) -> _T | BytesIO:
"""Convert `file` to `BytesIO` when it is a `SpooledTemporaryFile`.
Note that `file` does not need to be IO[bytes]. It can be `None` or `bytes` and this function
will not complain.
In Python <3.11, `SpooledTemporaryFile` does not implement `.readable()` or `.seekable()` which
triggers an exception when the file is loaded by certain packages. In particular, the stdlib
`zipfile.Zipfile` raises on opening a `SpooledTemporaryFile` as does `Pandas.read_csv()`.
"""
if isinstance(file, SpooledTemporaryFile):
file.seek(0)
return BytesIO(cast(bytes, file.read()))
# -- return `file` unchanged otherwise --
return file
def convert_to_bytes(file: bytes | IO[bytes]) -> bytes:
"""Extract the bytes from `file` without preventing it from being read again later.
As a convenience to simplify client code, also returns `file` unchanged if it is already bytes.
"""
if isinstance(file, bytes):
return file
if isinstance(file, SpooledTemporaryFile):
file.seek(0)
f_bytes = file.read()
file.seek(0)
return f_bytes
if isinstance(file, BytesIO):
return file.getvalue()
if isinstance(file, (TextIOWrapper, BufferedReader)):
with open(file.name, "rb") as f:
return f.read()
raise ValueError("Invalid file-like object type")
def contains_emoji(s: str) -> bool:
"""
Check if the input string contains any emoji characters.
Parameters:
- s (str): The input string to check.
Returns:
- bool: True if the string contains any emoji, False otherwise.
"""
return bool(emoji.emoji_count(s))
def get_page_image_metadata(page: PageLayout) -> dict[str, Any]:
"""Retrieve image metadata and coordinate system from a page."""
image = getattr(page, "image", None)
image_metadata = getattr(page, "image_metadata", None)
if image:
image_format = image.format
image_width = image.width
image_height = image.height
elif image_metadata:
image_format = image_metadata.get("format")
image_width = image_metadata.get("width")
image_height = image_metadata.get("height")
else:
image_format = None
image_width = None
image_height = None
return {
"format": image_format,
"width": image_width,
"height": image_height,
}
def ocr_data_to_elements(
ocr_data: list["LayoutElement"],
image_size: tuple[int | float, int | float],
common_metadata: Optional[ElementMetadata] = None,
infer_list_items: bool = True,
source_format: Optional[str] = None,
) -> list[Element]:
"""Convert OCR layout data into `unstructured` elements with associated metadata."""
image_width, image_height = image_size
coordinate_system = PixelSpace(width=image_width, height=image_height)
elements: list[Element] = []
for layout_element in ocr_data:
element = normalize_layout_element(
layout_element,
coordinate_system=coordinate_system,
infer_list_items=infer_list_items,
source_format=source_format if source_format else "html",
)
if common_metadata:
element.metadata.update(common_metadata)
elements.append(element)
return elements
+583
View File
@@ -0,0 +1,583 @@
from __future__ import annotations
import re
from functools import lru_cache
from typing import Callable, Iterable, Iterator, Optional
import iso639 # pyright: ignore[reportMissingTypeStubs]
from langdetect import ( # pyright: ignore[reportMissingTypeStubs]
DetectorFactory,
detect_langs, # pyright: ignore[reportUnknownVariableType]
lang_detect_exception,
)
from unstructured.documents.elements import Element
from unstructured.logger import logger
from unstructured.partition.utils.constants import (
TESSERACT_LANGUAGES_AND_CODES,
TESSERACT_LANGUAGES_SPLITTER,
)
_ASCII_RE = re.compile(r"^[\x00-\x7F]+$")
# pytesseract.get_languages(config="") only shows user installed language packs,
# so manually include the list of all currently supported Tesseract languages
PYTESSERACT_LANG_CODES = [
"afr",
"amh",
"ara",
"asm",
"aze",
"aze_cyrl",
"bel",
"ben",
"bod",
"bos",
"bre",
"bul",
"cat",
"ceb",
"ces",
"chi_sim",
"chi_sim_vert",
"chi_tra",
"chi_tra_vert",
"chr",
"cos",
"cym",
"dan",
"deu",
"div",
"dzo",
"ell",
"eng",
"enm",
"epo",
"equ",
"est",
"eus",
"fao",
"fas",
"fil",
"fin",
"fra",
"frk",
"frm",
"fry",
"gla",
"gle",
"glg",
"grc",
"guj",
"hat",
"heb",
"hin",
"hrv",
"hun",
"hye",
"iku",
"ind",
"isl",
"ita",
"ita_old",
"jav",
"jpn",
"jpn_vert",
"kan",
"kat",
"kat_old",
"kaz",
"khm",
"kir",
"kmr",
"kor",
"kor_vert",
"lao",
"lat",
"lav",
"lit",
"ltz",
"mal",
"mar",
"mkd",
"mlt",
"mon",
"mri",
"msa",
"mya",
"nep",
"nld",
"nor",
"oci",
"ori",
"osd",
"pan",
"pol",
"por",
"pus",
"que",
"ron",
"rus",
"san",
"sin",
"slk",
"slv",
"snd",
"snum",
"spa",
"spa_old",
"sqi",
"srp",
"srp_latn",
"sun",
"swa",
"swe",
"syr",
"tam",
"tat",
"tel",
"tgk",
"tha",
"tir",
"ton",
"tur",
"uig",
"ukr",
"urd",
"uzb",
"uzb_cyrl",
"vie",
"yid",
"yor",
]
PYTESSERACT_TO_PADDLE_LANG_CODE_MAP = {
"afr": "af", # Afrikaans
"ara": "ar", # Arabic
"aze": "az", # Azerbaijani
"bel": "be", # Belarusian
"bos": "bs", # Bosnian
"bul": "bg", # Bulgarian
"ces": "cs", # Czech
"chi_sim": "ch", # Simplified Chinese
"chi_tra": "chinese_cht", # Traditional Chinese
"cym": "cy", # Welsh
"dan": "da", # Danish
"deu": "german", # German
"eng": "en", # English
"est": "et", # Estonian
"fas": "fa", # Persian
"fra": "fr", # French
"gle": "ga", # Irish
"hin": "hi", # Hindi
"hrv": "hr", # Croatian
"hun": "hu", # Hungarian
"ind": "id", # Indonesian
"isl": "is", # Icelandic
"ita": "it", # Italian
"jpn": "japan", # Japanese
"kor": "korean", # Korean
"kmr": "ku", # Kurdish
"lat": "rs_latin", # Latin
"lav": "lv", # Latvian
"lit": "lt", # Lithuanian
"mar": "mr", # Marathi
"mlt": "mt", # Maltese
"msa": "ms", # Malay
"nep": "ne", # Nepali
"nld": "nl", # Dutch
"nor": "no", # Norwegian
"pol": "pl", # Polish
"por": "pt", # Portuguese
"ron": "ro", # Romanian
"rus": "ru", # Russian
"slk": "sk", # Slovak
"slv": "sl", # Slovenian
"spa": "es", # Spanish
"sqi": "sq", # Albanian
"srp": "rs_cyrillic", # Serbian
"swa": "sw", # Swahili
"swe": "sv", # Swedish
"tam": "ta", # Tamil
"tel": "te", # Telugu
"tur": "tr", # Turkish
"uig": "ug", # Uyghur
"ukr": "uk", # Ukrainian
"urd": "ur", # Urdu
"uzb": "uz", # Uzbek
"vie": "vi", # Vietnamese
}
def prepare_languages_for_tesseract(languages: Optional[list[str]] = ["eng"]) -> str:
"""
Entry point: convert languages (list of strings) into tesseract ocr langcode format (uses +)
"""
if languages is None:
raise ValueError("`languages` can not be `None`")
converted_languages = [
lang_code
for lang_code in (
_convert_language_code_to_pytesseract_lang_code(lang) for lang in languages
)
if lang_code
]
# Remove duplicates from the list but keep the original order
converted_languages = list(dict.fromkeys(converted_languages))
if len(converted_languages) == 0:
logger.warning(
"Failed to find any valid standard language code from "
f"languages: {languages}, proceed with `eng` instead.",
)
return "eng"
return TESSERACT_LANGUAGES_SPLITTER.join(converted_languages)
def tesseract_to_paddle_language(tesseract_language: str) -> str:
"""
Convert TesseractOCR language code to PaddleOCR language code.
:param tesseract_language: str, language code used in TesseractOCR
:return: str, corresponding language code for PaddleOCR or None if not found
"""
lang = PYTESSERACT_TO_PADDLE_LANG_CODE_MAP.get(tesseract_language.lower())
if not lang:
logger.warning(
f"{tesseract_language} is not a language code supported by PaddleOCR, "
f"proceeding with `en` instead."
)
return "en"
return lang
def check_language_args(
languages: list[str], ocr_languages: str | list[str] | None
) -> list[str] | None:
"""Handle users defining both `ocr_languages` and `languages`.
Give preference to `languages` and convert `ocr_languages` if needed, but default to `None`.
`ocr_languages` is only a parameter for `auto.partition`, `partition_image`, & `partition_pdf`.
`ocr_languages` should not be defined as 'auto' since 'auto' is intended for language detection
which is not supported by `partition_image` or `partition_pdf`.
"""
# --- Clean and update defaults
if ocr_languages:
ocr_languages = _clean_ocr_languages_arg(ocr_languages)
logger.warning(
"The ocr_languages kwarg will be deprecated in a future version of unstructured. "
"Please use languages instead.",
)
assert ocr_languages is None or isinstance(ocr_languages, str)
if ocr_languages and "auto" in ocr_languages:
raise ValueError(
"`ocr_languages` is deprecated but was used to extract text from pdfs and images."
" The 'auto' argument is only for language *detection* when it is assigned"
" to `languages` and partitioning documents other than pdfs or images."
" Language detection is not currently supported in pdfs or images."
)
if not isinstance(languages, list): # pyright: ignore[reportUnnecessaryIsInstance]
raise TypeError(
"The language parameter must be a list of language codes as strings, ex. ['eng']",
)
# --- If `languages` is a null/default value and `ocr_languages` is defined, use `ocr_languages`
if ocr_languages and (languages == ["auto"] or languages == [""] or not languages):
languages = ocr_languages.split(TESSERACT_LANGUAGES_SPLITTER)
logger.warning(
"Only one of languages and ocr_languages should be specified. "
"languages is preferred. ocr_languages is marked for deprecation.",
)
# --- Clean `languages`
# If "auto" is included in the list of inputs, language detection will be triggered downstream.
# The rest of the inputted languages are ignored.
if languages:
if "auto" not in languages:
for i, lang in enumerate(languages):
languages[i] = TESSERACT_LANGUAGES_AND_CODES.get(lang.lower(), lang)
str_languages = _clean_ocr_languages_arg(languages)
if not str_languages:
return None
languages = str_languages.split(TESSERACT_LANGUAGES_SPLITTER)
# else, remove the extraneous languages.
# NOTE (jennings): "auto" should only be used for partitioners OTHER THAN `_pdf` or `_image`
else:
# define as 'auto' for language detection when partitioning non-pdfs or -images
languages = ["auto"]
return languages
return None
def convert_old_ocr_languages_to_languages(ocr_languages: str) -> list[str]:
"""
Convert ocr_languages parameter to list of langcode strings.
Assumption: ocr_languages is in tesseract plus sign format
"""
return ocr_languages.split(TESSERACT_LANGUAGES_SPLITTER)
def _convert_language_code_to_pytesseract_lang_code(lang: str) -> str:
"""
Convert a single language code to its tesseract formatted and recognized
langcode(s), if supported.
"""
# if language is already tesseract langcode, return it immediately
# this will catch the tesseract special cases equ and osd
# NOTE(shreya): this may catch some cases of choosing between tesseract code variants for a lang
if lang in PYTESSERACT_LANG_CODES:
return lang
lang_iso639 = _get_iso639_language_object(lang)
# tesseract uses 3 digit codes (639-3, 639-2b, etc) as prefixes, with suffixes for orthography
# use first 3 letters of tesseract codes for matching to standard codes
pytesseract_langs_3 = {lang[:3] for lang in PYTESSERACT_LANG_CODES}
if lang_iso639:
# try to match ISO 639-3 code
if lang_iso639.part3 in pytesseract_langs_3:
matched_langcodes = _get_all_tesseract_langcodes_with_prefix(lang_iso639.part3)
return TESSERACT_LANGUAGES_SPLITTER.join(matched_langcodes)
# try to match ISO 639-2b
elif lang_iso639.part2b in pytesseract_langs_3:
matched_langcodes = _get_all_tesseract_langcodes_with_prefix(lang_iso639.part2b)
return TESSERACT_LANGUAGES_SPLITTER.join(matched_langcodes)
# try to match ISO 639-2t
elif lang_iso639.part2t in pytesseract_langs_3:
matched_langcodes = _get_all_tesseract_langcodes_with_prefix(lang_iso639.part2t)
return TESSERACT_LANGUAGES_SPLITTER.join(matched_langcodes)
else:
logger.warning(f"{lang} is not a language supported by Tesseract.")
return ""
logger.warning(f"{lang} is not a language supported by Tesseract.")
return ""
def _get_iso639_language_object(lang: str) -> Optional[iso639.Language]:
language = _cached_iso639_language_match(lang)
if language is not None:
return language
logger.warning(f"{lang} is not a valid standard language code.")
return None
def _get_all_tesseract_langcodes_with_prefix(prefix: str) -> list[str]:
"""
Get all matching tesseract langcodes with this prefix (may be one or multiple variants).
"""
return [langcode for langcode in PYTESSERACT_LANG_CODES if langcode.startswith(prefix)]
def _validate_fallback_languages(
value: Optional[list[str]],
) -> Optional[list[str]]:
"""Validate and normalize language_fallback return value to ISO 639-3 codes.
Returns None for None, non-list, or when no valid codes remain (invalid entries
are logged and skipped).
"""
if value is None:
return None
if not isinstance(value, list):
logger.warning(
f"language_fallback must return None or a list of strings, got {type(value).__name__}."
)
return None
validated: list[str] = []
for item in value:
if not isinstance(item, str) or not item.strip():
continue
lang = item.strip()
if lang == "zho":
validated.append("zho")
else:
language = _get_iso639_language_object(lang[:3])
if language is not None:
validated.append(language.part3)
return validated if validated else None
def detect_languages(
text: str,
languages: Optional[list[str]] = None,
language_fallback: Optional[Callable[[str], Optional[list[str]]]] = None,
) -> Optional[list[str]]:
"""
Detects the list of languages present in the text (in the default "auto" mode),
or formats and passes through the user inputted document languages if provided.
For short ASCII text (fewer than 5 words), language detection is unreliable. By
default such text is assigned English (["eng"]). Use ``language_fallback`` to
override: pass a callable that takes the text and returns a list of ISO 639-3
codes or None. Return None to leave language unspecified. The caller is
responsible for returning valid ISO 639-3 codes (e.g. "eng", "fra"); invalid
entries are filtered out and a warning is logged; if none remain, this function
returns None.
"""
if languages is None:
languages = ["auto"]
if not isinstance(languages, list):
raise TypeError(
'The language parameter must be a list of language codes as strings, ex. ["eng"]',
)
# Skip language detection for partitioners that use other partitioners.
# For example, partition_msg relies on partition_html and partition_text, but the metadata
# gets overwritten after elements have been returned by _html and _text,
# so `languages` would be detected twice.
# Also return None if there is no text.
if languages[0] == "" or text.strip() == "":
return None
# If text contains special characters (like ñ, å, or Korean/Mandarin/etc.) it will NOT default
# to English. It will default to English if text is only ascii characters and is short.
if _ASCII_RE.match(text) and len(text.split()) < 5:
if language_fallback is not None:
return _validate_fallback_languages(language_fallback(text))
logger.debug(f'short text: "{text}". Defaulting to English.')
return ["eng"]
# set seed for deterministic langdetect outputs
DetectorFactory.seed = 0
doc_languages: list[str] = []
# user inputted languages:
# if "auto" is included in the list of inputs, language detection will be triggered
# and the rest of the inputted languages will be ignored
if languages and "auto" not in languages:
for lang in languages:
str_lang = TESSERACT_LANGUAGES_AND_CODES.get(lang.lower(), lang)
language = _get_iso639_language_object(str_lang[:3])
if language:
doc_languages.append(language.part3)
# language detection:
else:
# warn if any values other than "auto" were provided
if len(languages) > 1:
logger.warning(
f'Since "auto" is present in the input languages provided ({languages}), '
"the language will be auto detected and the rest of the inputted "
"languages will be ignored.",
)
try:
langdetect_result = detect_langs(text)
except lang_detect_exception.LangDetectException as e:
logger.warning(e)
return None # None as default
langdetect_langs: list[str] = []
# NOTE(robinson) - Chinese gets detected with codes zh-cn, zh-tw, zh-hk for various
# Chinese variants. We normalizes these because there is a single model for Chinese
# machine translation
# TODO(shreya): decide how to maintain nonstandard chinese script information
for langobj in langdetect_result:
lang_val = str(langobj.lang)
if lang_val.startswith("zh"): # pyright: ignore
langdetect_langs.append("zho")
else:
language = _get_iso639_language_object(lang_val[:3]) # pyright: ignore
if language:
langdetect_langs.append(language.part3)
# remove duplicate chinese (if exists) without modifying order
seen = set(doc_languages)
for lang in langdetect_langs:
if lang not in seen:
doc_languages.append(lang)
seen.add(lang)
return doc_languages
def apply_lang_metadata(
elements: Iterable[Element],
languages: Optional[list[str]],
detect_language_per_element: bool = False,
language_fallback: Optional[Callable[[str], Optional[list[str]]]] = None,
) -> Iterator[Element]:
"""Detect language and apply it to metadata.languages for each element in `elements`.
If languages is None, default to auto detection.
If languages is an empty string, skip.
language_fallback is used for short text when detection is unreliable; see detect_languages."""
# -- Note this function has a stream interface, but reads the full `elements` stream into memory
# -- before emitting the first updated element as output.
# The auto `partition` function uses `None` as a default because the default for
# `partition_pdf` and `partition_img` conflict with the other partitioners that use ["auto"]
if languages is None:
languages = ["auto"]
# Skip language detection for partitioners that use other partitioners.
# For example, partition_msg relies on partition_html and partition_text, but the metadata
# gets overwritten after elements have been returned by _html and _text,
# so `languages` would be detected twice.
if languages == [""]:
yield from elements
return
# Convert elements to a list to get the text, detect the language, and add it to the elements
if not isinstance(elements, list):
elements = list(elements)
def detect(text: str) -> Optional[list[str]]:
return detect_languages(text=text, languages=languages, language_fallback=language_fallback)
full_text = " ".join(str(e.text) for e in elements if hasattr(e, "text") and e.text)
detected_languages = detect(full_text)
if (
detected_languages is not None
and len(detected_languages) == 1
and detect_language_per_element is False
):
# -- apply detected language to each element's metadata --
for e in elements:
e.metadata.languages = detected_languages
yield e
else:
for e in elements:
if hasattr(e, "text"):
text_value = str(e.text) if e.text is not None else ""
e.metadata.languages = detect(text_value)
yield e
else:
yield e
def _clean_ocr_languages_arg(ocr_languages: list[str] | str) -> str:
"""Fix common incorrect definitions for ocr_languages:
defining it as a list, adding extra quotation marks, adding brackets.
Returns a single string of ocr_languages"""
# extract from list
if isinstance(ocr_languages, list):
ocr_languages = "+".join(ocr_languages)
# remove extra quotations
ocr_languages = re.sub(r"[\"']", "", ocr_languages)
# remove brackets
ocr_languages = re.sub(r"[\[\]]", "", ocr_languages)
return ocr_languages
@lru_cache(maxsize=256)
def _cached_iso639_language_match(lang: str) -> Optional[iso639.Language]:
try:
return iso639.Language.match(lang.lower()) # pyright: ignore[reportUnknownMemberType]
except iso639.LanguageNotFoundError:
return None
+335
View File
@@ -0,0 +1,335 @@
"""Helpers used across multiple partitioners to compute metadata."""
from __future__ import annotations
import copy
import datetime as dt
import functools
import os
from typing import Any, Callable, Iterator, Sequence
from typing_extensions import ParamSpec
from unstructured.documents.elements import Element, ElementMetadata, ListItem, Title
from unstructured.file_utils.model import FileType
from unstructured.partition.common.lang import apply_lang_metadata
from unstructured.utils import get_call_args_applying_defaults
_P = ParamSpec("_P")
def get_last_modified_date(filename: str) -> str | None:
"""Modification time of file at path `filename`, if it exists.
Returns `None` when `filename` is not a path to a file on the local filesystem.
Otherwise returns date and time in ISO 8601 string format (YYYY-MM-DDTHH:MM:SS) like
"2024-03-05T17:02:53".
"""
if not os.path.isfile(filename):
return None
modify_date = dt.datetime.fromtimestamp(os.path.getmtime(filename))
return modify_date.strftime("%Y-%m-%dT%H:%M:%S%z")
HIERARCHY_RULE_SET = {
"Title": [
"Text",
"UncategorizedText",
"NarrativeText",
"ListItem",
"BulletedText",
"Table",
"FigureCaption",
"CheckBox",
"Table",
],
"Header": [
"Title",
"Text",
"UncategorizedText",
"NarrativeText",
"ListItem",
"BulletedText",
"Table",
"FigureCaption",
"CheckBox",
"Table",
],
}
# Canonical HTML heading levels -> zero-indexed category_depth. The HTML spec
# defines exactly six heading levels (there is no h7), so this closed mapping is
# the single source of truth for both the depth value and the heading-tag set
# (HEADING_TAGS is derived from it, not a second copy).
_HEADING_DEPTH = {"h1": 0, "h2": 1, "h3": 2, "h4": 3, "h5": 4, "h6": 5}
HEADING_TAGS = tuple(_HEADING_DEPTH)
def category_depth_from_html_tag(
ElementCls: type[Element], tag: str | None, list_ancestor_count: int = 0
) -> int | None:
"""Compute `category_depth` from an element's HTML heading level (not DOM-nesting depth).
This is the canonical mapping used by both the v1 HTML parser and the v2 (ontology) HTML
converter so the two paths agree on what `category_depth` means:
- `Title` (which includes ontology Title/Subtitle/Heading, i.e. ``<h1>``-``<h6>``): the heading
level, zero-indexed -- ``h1`` -> 0, ``h2`` -> 1, ... ``h6`` -> 5. A `Title` whose tag is not a
heading (e.g. a styled paragraph) is treated as a top-level heading (0).
- `ListItem`: the number of enclosing list containers (``ol``/``ul``/``dl``), passed in by the
caller (the v1 HTML parser, which computes it from list nesting). The v2 converter serializes
a whole ``ol``/``ul``/``dl`` as one element and never emits a standalone ``ListItem``, so it
does not use this.
- Everything else: ``None`` (no meaningful depth).
`tag` is the element's HTML tag name (e.g. ``"h2"``); it may be ``None`` for derived elements.
"""
if ElementCls is ListItem:
return list_ancestor_count
if ElementCls is Title:
return _HEADING_DEPTH.get(tag, 0)
return None
def set_element_hierarchy(
elements: Sequence[Element], ruleset: dict[str, list[str]] = HIERARCHY_RULE_SET
) -> list[Element]:
"""Sets `.metadata.parent_id` for each element it applies to.
`parent_id` assignment is based on the element's category and depth. The importance of an
element's category is determined by a rule set. The rule set trumps category_depth. That is,
category_depth is only relevant when elements are of the same category.
"""
stack: list[Element] = []
for element in elements:
if element.metadata.parent_id is not None:
continue
parent_id = None
element_category = getattr(element, "category", None)
element_category_depth = getattr(element.metadata, "category_depth", 0) or 0
# -- skip elements without a category --
if not element_category:
continue
while stack:
top_element: Element = stack[-1]
top_element_category = getattr(top_element, "category")
top_element_category_depth = (
getattr(
top_element.metadata,
"category_depth",
0,
)
or 0
)
if (
top_element_category == element_category
and top_element_category_depth < element_category_depth
) or (
top_element_category != element_category
and element_category in ruleset.get(top_element_category, [])
):
parent_id = top_element.id
break
stack.pop()
element.metadata.parent_id = parent_id
stack.append(element)
return list(elements)
# ================================================================================================
# METADATA POST-PARTITIONING PROCESSING DECORATOR
# ================================================================================================
def apply_metadata(
file_type: FileType | None = None,
) -> Callable[[Callable[_P, list[Element]]], Callable[_P, list[Element]]]:
"""Post-process element-metadata for this document.
This decorator adds a post-processing step to a partitioner, primarily to apply metadata that
is common to all partitioners. It assumes the following responsibilities:
- Hash element-ids. Computes and applies SHA1 hash element.id when `unique_element_ids`
argument is False.
- Element Hierarchy. Computes and applies `parent_id` metadata based on `category_depth`
etc. added by partitioner.
- Language metadata. Computes and applies `language` metadata based on a language detection
model.
- Apply `filetype` (MIME-type) metadata. There are three cases; first one in this order that
applies is used:
- `metadata_file_type` argument is present in call, use that.
- `file_type` decorator argument is populated, use that.
- `file_type` decorator argument is omitted or None, don't apply `.metadata.filetype`
(assume the partitioner will do that for itself, like `partition_image()`.
- Replace `filename` with `metadata_filename` when present.
- Replace `last_modified` with `metadata_last_modified` when present.
- Apply `url` metadata when present.
"""
def decorator(func: Callable[_P, list[Element]]) -> Callable[_P, list[Element]]:
"""The decorator function itself.
This function is returned by the `apply_metadata()` function and is the actual decorator.
Think of `apply_metadata()` as a factory function that configures this decorator, in
particular by setting its `file_type` value.
"""
@functools.wraps(func)
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> list[Element]:
elements = func(*args, **kwargs)
call_args = get_call_args_applying_defaults(func, *args, **kwargs)
# ------------------------------------------------------------------------------------
# unique-ify elements
# ------------------------------------------------------------------------------------
# Do this first to ensure all following operations behave as expected. It's easy for a
# partitioner to re-use an element or metadata instance when its values are common to
# multiple elements. This can lead to very hard-to diagnose bugs downstream when
# mutating one element unexpectedly also mutates others (because they are the same
# instance).
# ------------------------------------------------------------------------------------
elements = _uniqueify_elements_and_metadata(elements)
# ------------------------------------------------------------------------------------
# apply metadata - do this first because it affects the hash computation.
# ------------------------------------------------------------------------------------
# -- `language` - auto-detect language (e.g. eng, spa) --
languages = call_args.get("languages")
detect_language_per_element = call_args.get("detect_language_per_element", False)
language_fallback = call_args.get("language_fallback")
elements = list(
apply_lang_metadata(
elements=elements,
languages=languages,
detect_language_per_element=detect_language_per_element,
language_fallback=language_fallback,
)
)
# == apply filetype, filename, last_modified, and url metadata ===================
metadata_kwargs: dict[str, Any] = {}
# -- `filetype` (MIME-type) metadata --
metadata_file_type = call_args.get("metadata_file_type") or file_type
if metadata_file_type is not None:
metadata_kwargs["filetype"] = metadata_file_type.mime_type
# -- `filename` metadata - override with metadata_filename when it's present --
filename = call_args.get("metadata_filename") or call_args.get("filename")
if filename:
metadata_kwargs["filename"] = filename
# -- `last_modified` metadata - override with metadata_last_modified when present --
metadata_last_modified = call_args.get("metadata_last_modified")
if metadata_last_modified:
metadata_kwargs["last_modified"] = metadata_last_modified
# -- `url` metadata - record url when present --
url = call_args.get("url")
if url:
metadata_kwargs["url"] = url
# -- update element.metadata in single pass --
for element in elements:
# NOTE(robinson) - Attached files have already run through this logic in their own
# partitioning function
if element.metadata.attached_to_filename:
continue
element.metadata.update(ElementMetadata(**metadata_kwargs))
# ------------------------------------------------------------------------------------
# compute hash ids (when so requestsd)
# ------------------------------------------------------------------------------------
# -- Compute and apply hash-ids if the user does not want UUIDs. Note this mutates the
# -- elements themselves, not their metadata.
unique_element_ids: bool = call_args.get("unique_element_ids", False)
if unique_element_ids is False:
elements = _assign_hash_ids(elements)
# ------------------------------------------------------------------------------------
# assign parent-id - do this after hash computation so parent-id is stable.
# ------------------------------------------------------------------------------------
# -- `parent_id` - process category-level etc. to assign parent-id --
elements = set_element_hierarchy(elements)
return elements
return wrapper
return decorator
def _assign_hash_ids(elements: list[Element]) -> list[Element]:
"""Converts `.id` of each element from UUID to hash and remaps `parent_id` accordingly.
The hash is based on the `.text` of the element, but also on its page-number and sequence number
on that page. This provides for deterministic results even when the document is split into one
or more fragments for parallel processing.
After hashing, any `element.metadata.parent_id` that references a known original UUID is
updated to the corresponding new hash ID. Parent IDs that do not appear in the mapping (e.g.
because the parent element was filtered out before hashing, or the ID was set manually to an
external value) are left unchanged.
"""
# -- generate sequence number for each element on a page --
page_seq_counts = {}
id_mapping = {}
for element in elements:
page_number = element.metadata.page_number
seq_on_page_counter = page_seq_counts.get(page_number, 0)
original_id = element.id
element.id_to_hash(seq_on_page_counter)
id_mapping[original_id] = element.id
page_seq_counts[page_number] = seq_on_page_counter + 1
for element in elements:
if element.metadata.parent_id is not None and element.metadata.parent_id in id_mapping:
element.metadata.parent_id = id_mapping[element.metadata.parent_id]
return elements
def _uniqueify_elements_and_metadata(elements: list[Element]) -> list[Element]:
"""Ensure each of `elements` and their metadata are unique instances.
This prevents hard-to-diagnose bugs downstream when mutating one element unexpectedly also
mutates others because they are the same instance.
"""
def iter_unique_elements(elements: list[Element]) -> Iterator[Element]:
"""Substitute deep-copies of any non-unique elements or metadata in `elements`."""
seen_elements: set[int] = set()
seen_metadata: set[int] = set()
for element in elements:
if id(element) in seen_elements:
element = copy.deepcopy(element)
if id(element.metadata) in seen_metadata:
element.metadata = copy.deepcopy(element.metadata)
seen_elements.add(id(element))
seen_metadata.add(id(element.metadata))
yield element
return list(iter_unique_elements(elements))
+187
View File
@@ -0,0 +1,187 @@
from __future__ import annotations
import contextlib
import csv
from functools import cached_property
from typing import IO, Any, Iterator
import pandas as pd
from unstructured.chunking import add_chunking_strategy
from unstructured.common.html_table import HtmlTable
from unstructured.documents.elements import Element, ElementMetadata, Table
from unstructured.file_utils.model import FileType
from unstructured.partition.common.metadata import apply_metadata, get_last_modified_date
from unstructured.utils import is_temp_file_path
DETECTION_ORIGIN: str = "csv"
CSV_FIELD_LIMIT = 10 * 1048576 # 10MiB
@apply_metadata(FileType.CSV)
@add_chunking_strategy
def partition_csv(
filename: str | None = None,
*,
file: IO[bytes] | None = None,
encoding: str | None = None,
include_header: bool = False,
infer_table_structure: bool = True,
**kwargs: Any,
) -> list[Element]:
"""Partitions Microsoft Excel Documents in .csv format into its document elements.
Parameters
----------
filename
A string defining the target filename path.
file
A file-like object using "rb" mode --> open(filename, "rb").
encoding
The encoding method used to decode the text input. If None, utf-8 will be used.
include_header
Determines whether or not header info info is included in text and medatada.text_as_html.
infer_table_structure
If True, any Table elements that are extracted will also have a metadata field
named "text_as_html" where the table's text content is rendered into an html string.
I.e., rows and cells are preserved.
Whether True or False, the "text" field is always present in any Table element
and is the text content of the table (no structure).
"""
ctx = _CsvPartitioningContext.load(
file_path=filename,
file=file,
encoding=encoding,
include_header=include_header,
infer_table_structure=infer_table_structure,
)
csv.field_size_limit(CSV_FIELD_LIMIT)
with ctx.open() as file:
read_kw: dict = {"header": ctx.header, "sep": ctx.delimiter, "encoding": ctx.encoding}
# sep=None is not supported by the C engine; use Python engine to avoid ParserWarning.
if ctx.delimiter is None:
read_kw["engine"] = "python"
dataframe = pd.read_csv(file, **read_kw)
html_table = HtmlTable.from_html_text(
dataframe.to_html(index=False, header=include_header, na_rep="")
)
metadata = ElementMetadata(
filename=filename,
last_modified=ctx.last_modified,
text_as_html=html_table.html if infer_table_structure else None,
)
# -- a CSV file becomes a single `Table` element --
return [Table(text=html_table.text, metadata=metadata, detection_origin=DETECTION_ORIGIN)]
class _CsvPartitioningContext:
"""Encapsulates the partitioning-run details.
Provides access to argument values and especially encapsulates computation of values derived
from those values so they don't obscure the core partitioning logic.
"""
def __init__(
self,
file_path: str | None = None,
file: IO[bytes] | None = None,
encoding: str | None = None,
include_header: bool = False,
infer_table_structure: bool = True,
):
self._file_path = file_path
self._file = file
self._encoding = encoding
self._include_header = include_header
self._infer_table_structure = infer_table_structure
@classmethod
def load(
cls,
file_path: str | None,
file: IO[bytes] | None,
encoding: str | None,
include_header: bool,
infer_table_structure: bool,
) -> _CsvPartitioningContext:
return cls(
file_path=file_path,
file=file,
encoding=encoding,
include_header=include_header,
infer_table_structure=infer_table_structure,
)._validate()
@cached_property
def delimiter(self) -> str | None:
"""The CSV delimiter, nominally a comma ",".
`None` for a single-column CSV file which naturally has no delimiter.
"""
sniffer = csv.Sniffer()
num_bytes = 65536
with self.open() as file:
# -- read whole lines, sniffer can be confused by a trailing partial line --
data = "\n".join(
ln.decode(self._encoding or "utf-8") for ln in file.readlines(num_bytes)
)
try:
return sniffer.sniff(data, delimiters=",;|").delimiter
except csv.Error:
# -- sniffing will fail on single-column csv as no default can be assumed --
return None
@cached_property
def header(self) -> int | None:
"""Identifies the header row, if any, to Pandas, by idx."""
return 0 if self._include_header else None
@cached_property
def encoding(self) -> str | None:
"""The encoding to use for reading the file."""
return self._encoding
@cached_property
def last_modified(self) -> str | None:
"""The best last-modified date available, None if no sources are available."""
return (
None
if not self._file_path or is_temp_file_path(self._file_path)
else get_last_modified_date(self._file_path)
)
@contextlib.contextmanager
def open(self) -> Iterator[IO[bytes]]:
"""Encapsulates complexity of dealing with file-path or file-like-object.
Provides an `IO[bytes]` object as the "common-denominator" document source.
Must be used as a context manager using a `with` statement:
with self._file as file:
do things with file
File is guaranteed to be at read position 0 when called.
"""
if self._file_path:
with open(self._file_path, "rb") as f:
yield f
else:
file = self._file
assert file is not None # -- guaranteed by `._validate()` --
# -- Be polite on principle. Reset file-pointer both before and after use --
file.seek(0)
yield file
file.seek(0)
def _validate(self) -> _CsvPartitioningContext:
"""Raise on invalid argument values."""
if self._file_path is None and self._file is None:
raise ValueError("either file-path or file-like object must be provided")
return self
+103
View File
@@ -0,0 +1,103 @@
from __future__ import annotations
import os
import tempfile
from typing import IO, Any, Optional
from unstructured.documents.elements import Element
from unstructured.file_utils.model import FileType
from unstructured.partition.common.common import convert_office_doc, exactly_one
from unstructured.partition.common.metadata import get_last_modified_date
from unstructured.partition.docx import partition_docx
def partition_doc(
filename: Optional[str] = None,
file: Optional[IO[bytes]] = None,
metadata_filename: Optional[str] = None,
metadata_last_modified: Optional[str] = None,
libre_office_filter: Optional[str] = "MS Word 2007 XML",
**kwargs: Any,
) -> list[Element]:
"""Partitions Microsoft Word Documents in .doc format into its document elements.
All parameters available on `partition_docx()` are also available here.
Parameters
----------
filename
A string defining the target filename path.
file
A file-like object using "rb" mode --> open(filename, "rb").
metadata_last_modified
The last modified date for the document.
libre_office_filter
The filter to use when coverting to .doc. The default is the
filter that is required when using LibreOffice7. Pass in None
if you do not want to apply any filter.
languages
User defined value for `metadata.languages` if provided. Otherwise language is detected
using naive Bayesian filter via `langdetect`. Multiple languages indicates text could be
in either language.
Additional Parameters:
detect_language_per_element
Detect language per element instead of at the document level.
starting_page_number
Indicates what page number should be assigned to the first page in the document.
This information will be reflected in elements' metadata and can be be especially
useful when partitioning a document that is part of a larger document.
"""
exactly_one(filename=filename, file=file)
last_modified = get_last_modified_date(filename) if filename else None
# -- validate file-path when provided so we can provide a more meaningful error --
if filename is not None and not os.path.exists(filename):
raise ValueError(f"The file {filename} does not exist.")
# -- `convert_office_doc` uses a command-line program that ships with LibreOffice to convert
# -- from DOC -> DOCX. So both the source and the target need to be file-system files. Put
# -- transient files in a temporary directory that is automatically removed so they don't
# -- pile up.
with tempfile.TemporaryDirectory() as target_dir:
source_file_path = f"{target_dir}/document.doc" if file is not None else filename
assert source_file_path is not None
# -- when source is a file-like object, write it to the filesystem so the command-line
# -- process can access it (CLI executes in different memory-space).
if file is not None:
with open(source_file_path, "wb") as f:
f.write(file.read())
# -- convert the .doc file to .docx. The resulting file takes the same base-name as the
# -- source file and is written to `target_dir`.
convert_office_doc(
source_file_path,
target_dir,
target_format="docx",
target_filter=libre_office_filter,
)
# -- compute the path of the resulting .docx document --
_, filename_no_path = os.path.split(os.path.abspath(source_file_path))
base_filename, _ = os.path.splitext(filename_no_path)
target_file_path = os.path.join(target_dir, f"{base_filename}.docx")
# -- and partition it. Note that `kwargs` is not passed which is a sketchy way to partially
# -- disable post-partitioning processing (what the decorators do) so for example the
# -- resulting elements are not double-chunked.
elements = partition_docx(
filename=target_file_path,
metadata_filename=metadata_filename or filename,
metadata_file_type=FileType.DOC,
metadata_last_modified=metadata_last_modified or last_modified,
**kwargs,
)
# -- Remove temporary document.docx path from metadata when necessary. Note `metadata_filename`
# -- defaults to `None` but that's better than a meaningless temporary filename.
if file:
for element in elements:
element.metadata.filename = metadata_filename
return elements
+990
View File
@@ -0,0 +1,990 @@
# pyright: reportPrivateUsage=false
from __future__ import annotations
import io
import itertools
import logging
import os
import tempfile
import zipfile
from functools import cached_property
from typing import IO, Any, Iterator, Protocol, Type
import docx
from docx.document import Document
from docx.enum.section import WD_SECTION_START
from docx.oxml.table import CT_Tbl
from docx.oxml.text.paragraph import CT_P
from docx.section import Section, _Footer, _Header
from docx.table import Table as DocxTable
from docx.table import _Cell, _Row
from docx.text.hyperlink import Hyperlink
from docx.text.pagebreak import RenderedPageBreak
from docx.text.paragraph import Paragraph
from docx.text.run import Run
from typing_extensions import TypeAlias
from unstructured.chunking import add_chunking_strategy
from unstructured.cleaners.core import clean_bullets
from unstructured.common.html_table import htmlify_matrix_of_cell_texts
from unstructured.documents.elements import (
Address,
Element,
ElementMetadata,
EmailAddress,
Footer,
Header,
Image,
Link,
ListItem,
NarrativeText,
PageBreak,
Table,
Text,
Title,
)
from unstructured.file_utils.model import FileType
from unstructured.partition.common.metadata import apply_metadata, get_last_modified_date
from unstructured.partition.text_type import (
is_bulleted_text,
is_email_address,
is_possible_narrative_text,
is_us_city_state_zip,
)
from unstructured.partition.utils.constants import PartitionStrategy
from unstructured.utils import is_temp_file_path
STYLE_TO_ELEMENT_MAPPING = {
"Caption": Text, # TODO(robinson) - add caption element type
"Heading 1": Title,
"Heading 2": Title,
"Heading 3": Title,
"Heading 4": Title,
"Heading 5": Title,
"Heading 6": Title,
"Heading 7": Title,
"Heading 8": Title,
"Heading 9": Title,
"Intense Quote": Text, # TODO(robinson) - add quote element type
"List": ListItem,
"List 2": ListItem,
"List 3": ListItem,
"List Bullet": ListItem,
"List Bullet 2": ListItem,
"List Bullet 3": ListItem,
"List Continue": ListItem,
"List Continue 2": ListItem,
"List Continue 3": ListItem,
"List Number": ListItem,
"List Number 2": ListItem,
"List Number 3": ListItem,
"List Paragraph": ListItem,
"Macro Text": Text,
"No Spacing": Text,
"Quote": Text, # TODO(robinson) - add quote element type
"Subtitle": Title,
"TOCHeading": Title,
"Title": Title,
}
DETECTION_ORIGIN: str = "docx"
# -- CT_* stands for "complex-type", an XML element type in docx parlance --
BlockElement: TypeAlias = "CT_P | CT_Tbl"
BlockItem: TypeAlias = "Paragraph | DocxTable"
def register_picture_partitioner(picture_partitioner: PicturePartitionerT) -> None:
"""Specify a pluggable sub-partitioner to be used for partitioning DOCX images."""
DocxPartitionerOptions.register_picture_partitioner(picture_partitioner)
# ================================================================================================
# DOCX DOMAIN MODEL DEFINITIONS
# ================================================================================================
class PicturePartitionerT(Protocol):
"""Defines the interface for a pluggable sub-partitioner for DOCX Picture objects.
In Microsoft Word parlance, an image is a "picture". We use that term here for an image in a
DOCX file both for domain consistency and because it conveniently avoids confusion with an
`unstructured` `Image` element.
A picture can be either *inline* or *floating*. An inline picture is treated like a big
character in the text of a paragraph, moving with the text. A floating picture can be moved
freely and text flows around it.
Both inline and floating pictures are defined inside a paragraph in the DOCX file. A paragraph
can have zero or more pictures. A DOCX picture partitioner takes a `docx` `Paragraph` object
and generates an `Image` element for each picture found in that paragraph.
"""
@classmethod
def iter_elements(cls, paragraph: Paragraph, opts: DocxPartitionerOptions) -> Iterator[Image]:
"""Generate an `Image` element for each picture in `paragraph`."""
...
# ================================================================================================
# PARTITIONER
# ================================================================================================
@apply_metadata(FileType.DOCX)
@add_chunking_strategy
def partition_docx(
filename: str | None = None,
*,
file: IO[bytes] | None = None,
include_page_breaks: bool = True,
infer_table_structure: bool = True,
starting_page_number: int = 1,
strategy: str | None = None,
**kwargs: Any,
) -> list[Element]:
"""Partitions Microsoft Word Documents in .docx format into its document elements.
Parameters
----------
filename
A string defining the target filename path.
file
A file-like object using "rb" mode --> open(filename, "rb").
include_page_breaks
When True, add a `PageBreak` element to the element-stream when a page-break is detected in
the document. Note that not all DOCX files include page-break information.
infer_table_structure
If True, any Table elements that are extracted will also have a metadata field
named "text_as_html" where the table's text content is rendered into an html string.
I.e., rows and cells are preserved.
Whether True or False, the "text" field is always present in any Table element
and is the text content of the table (no structure).
metadata_filename
The filename to use for the metadata. Relevant because partition_doc converts the document
to .docx before partition. We want the original source filename in the metadata.
metadata_last_modified
The last modified date for the document.
starting_page_number
Assign this number to the first page of this document and increment the page number from
there.
"""
opts = DocxPartitionerOptions.load(
file=file,
file_path=filename,
include_page_breaks=include_page_breaks,
infer_table_structure=infer_table_structure,
starting_page_number=starting_page_number,
strategy=strategy,
)
elements = _DocxPartitioner.iter_document_elements(opts)
return list(elements)
class DocxPartitionerOptions:
"""Encapsulates partitioning option validation, computation, and application of defaults."""
_PicturePartitionerCls = None
"""Sub-partitioner used to extract pictures from a paragraph as `Image` elements.
This value has module lifetime and is updated by calling the `register_picture_partitioner()`
function defined in this module. The value sent to `register_picture_partitioner()` must be a
pluggable sub-partitioner implementing the `PicturePartitionerT` interface. After
registration, all paragraphs in subsequently partitioned DOCX documents will be sent to this
sub-partitioner to extract images when so configured.
"""
def __init__(
self,
*,
file: IO[bytes] | None,
file_path: str | None,
include_page_breaks: bool,
infer_table_structure: bool,
starting_page_number: int = 1,
strategy: str | None = None,
):
self._file = file
self._file_path = file_path
self._include_page_breaks = include_page_breaks
self._infer_table_structure = infer_table_structure
self._strategy = strategy
# -- options object maintains page-number state --
self._page_counter = starting_page_number
@classmethod
def load(cls, **kwargs: Any) -> DocxPartitionerOptions:
"""Construct and validate an instance."""
return cls(**kwargs)._validate()
@classmethod
def register_picture_partitioner(cls, picture_partitioner: PicturePartitionerT):
"""Specify a pluggable sub-partitioner to extract images from DOCX paragraphs."""
cls._PicturePartitionerCls = picture_partitioner
@cached_property
def document(self) -> Document:
"""The python-docx `Document` object loaded from file or filename."""
return docx.Document(self._docx_file)
@cached_property
def include_page_breaks(self) -> bool:
"""When True, include `PageBreak` elements in element-stream.
Note that regardless of this setting, page-breaks are detected, and page-number is tracked
and included in element metadata. Only the presence of distinct `PageBreak` elements (which
contain no text) in the element stream is affected.
"""
return self._include_page_breaks
def increment_page_number(self) -> Iterator[PageBreak]:
"""Increment page-number by 1 and generate a PageBreak element if enabled."""
self._page_counter += 1
# -- only emit page-breaks when enabled --
if self._include_page_breaks:
yield PageBreak("", detection_origin=DETECTION_ORIGIN)
@cached_property
def infer_table_structure(self) -> bool:
"""True when partitioner should compute and apply `text_as_html` metadata for tables."""
return self._infer_table_structure
@cached_property
def last_modified(self) -> str | None:
"""The best last-modified date available, None if no sources are available."""
if not self._file_path:
return None
return (
None if is_temp_file_path(self._file_path) else get_last_modified_date(self._file_path)
)
@cached_property
def metadata_file_path(self) -> str | None:
"""The best available file-path for this document or `None` if unavailable."""
return self._file_path
@property
def metadata_page_number(self) -> int | None:
"""The current page number to report in metadata, or None if we can't really tell.
Page numbers are not added to element metadata if we can't find any page-breaks in the
document (which may be a common case).
In the DOCX format, determining page numbers is strictly a best-efforts attempt since
actual page-breaks are determined at rendering time (e.g. printing) based on the
font-metrics of the target device. Explicit (hard) page-breaks are always recorded in the
docx file but the rendered page-breaks are only added optionally.
"""
return self._page_counter if self._document_contains_pagebreaks else None
@property
def page_number(self) -> int:
"""The current page number.
Note this value may not represent the actual rendered page number when rendered page-break
indicators are not present in the document (not uncommon). Use `.metadata_page_number` for
metadata purposes, which is `None` when rendered page-breaks are not present in this
document.
"""
return self._page_counter
@cached_property
def picture_partitioner(self) -> PicturePartitionerT:
"""The sub-partitioner to use for DOCX image extraction."""
# -- Note this value has partitioning-run scope. An instance of this options class is
# -- instantiated once per partitioning run (each document can have different options).
# -- Because this is a lazyproperty, it is computed only on the first reference. All
# -- subsequent references during the same partitioning run will get the same value. This
# -- ensures image extraction is processed consistently within a single document.
return self._PicturePartitionerCls or _NullPicturePartitioner
@cached_property
def strategy(self) -> str:
"""The partitioning strategy for this document.
One of "hi_res", "fast", and a few others. These are available as class attributes on
`unstructured.partition.utils.constants.PartitionStrategy` but resolve to str values.
"""
return PartitionStrategy.HI_RES if self._strategy is None else self._strategy
@cached_property
def _document_contains_pagebreaks(self) -> bool:
"""True when there is at least one page-break detected in the document.
Only `w:lastRenderedPageBreak` elements reliably indicate a page-break. These are reliably
inserted by Microsoft Word, but probably don't appear in documents converted into .docx
format from for example .odt format.
"""
xpath = (
# NOTE(scanny) - w:lastRenderedPageBreak (lrpb) is run (w:r) inner content. `w:r` can
# appear in a paragraph (w:p). w:r can also appear in a hyperlink (w:hyperlink), which
# is w:p inner-content and both of these can occur inside a table-cell as well as the
# document body
"./w:body/w:p/w:r/w:lastRenderedPageBreak"
" | ./w:body/w:p/w:hyperlink/w:r/w:lastRenderedPageBreak"
" | ./w:body/w:tbl/w:tr/w:tc/w:p/w:r/w:lastRenderedPageBreak"
" | ./w:body/w:tbl/w:tr/w:tc/w:p/w:hyperlink/w:r/w:lastRenderedPageBreak"
)
return bool(self.document.element.xpath(xpath))
@cached_property
def _docx_file(self) -> str | IO[bytes]:
"""The Word 2007+ document file to be partitioned.
This is either a `str` path or a file-like object. `python-docx` accepts either for opening
a document file.
"""
if self._file_path:
return self._file_path
# -- In Python <3.11 SpooledTemporaryFile does not implement ".seekable" which triggers an
# -- exception when Zipfile tries to open it. The docx format is a zip archive so we need
# -- to work around that bug here.
if isinstance(self._file, tempfile.SpooledTemporaryFile):
self._file.seek(0)
return io.BytesIO(self._file.read())
assert self._file is not None # -- assured by `._validate()` --
return self._file
def _validate(self) -> DocxPartitionerOptions:
"""Raise on first invalide option, return self otherwise."""
# -- provide distinguished error between "file-not-found" and "not-a-DOCX-file" --
if self._file_path:
if not os.path.isfile(self._file_path):
raise FileNotFoundError(f"no such file or directory: {repr(self._file_path)}")
if not zipfile.is_zipfile(self._file_path):
raise ValueError(f"not a ZIP archive (so not a DOCX file): {repr(self._file_path)}")
elif self._file:
if not zipfile.is_zipfile(self._file):
raise ValueError(f"not a ZIP archive (so not a DOCX file): {repr(self._file)}")
else:
raise ValueError(
"no DOCX document specified, either `filename` or `file` argument must be provided"
)
return self
class _DocxPartitioner:
"""Provides `.partition()` for MS-Word 2007+ (.docx) files."""
def __init__(self, opts: DocxPartitionerOptions) -> None:
self._opts = opts
@classmethod
def iter_document_elements(cls, opts: DocxPartitionerOptions) -> Iterator[Element]:
"""Partition MS Word documents (.docx format) into its document elements."""
self = cls(opts)
# NOTE(scanny): It's possible for a Word document to have no sections. In particular, a
# Microsoft Teams chat transcript exported to DOCX contains no sections. Such a
# "section-less" document has to be interated differently and has no headers or footers and
# therefore no page-size or margins.
return (
self._iter_document_elements()
if self._document_contains_sections
else self._iter_sectionless_document_elements()
)
def _iter_document_elements(self) -> Iterator[Element]:
"""Generate each document-element in (docx) `document` in document order."""
# -- This implementation composes a collection of iterators into a "combined" iterator
# -- return value using `yield from`. You can think of the return value as an Element
# -- stream and each `yield from` as "add elements found by this function to the stream".
# -- This is functionally analogous to declaring `elements: list[Element] = []` at the top
# -- and using `elements.extend()` for the results of each of the function calls, but is
# -- more perfomant, uses less memory (avoids producing and then garbage-collecting all
# -- those small lists), is more flexible for later iterator operations like filter,
# -- chain, map, etc. and is perhaps more elegant and simpler to read once you have the
# -- concept of what it's doing. You can see the same pattern repeating in the "sub"
# -- functions like `._iter_paragraph_elements()` where the "just return when done"
# -- characteristic of a generator avoids repeated code to form interim results into lists.
for section_idx, section in enumerate(self._document.sections):
yield from self._iter_section_page_breaks(section_idx, section)
yield from self._iter_section_headers(section)
for block_item in section.iter_inner_content():
# -- a block-item can be a Paragraph or a Table, maybe others later so elif here.
# -- Paragraph is more common so check that first.
if isinstance(block_item, Paragraph):
yield from self._iter_paragraph_elements(block_item)
elif isinstance( # pyright: ignore[reportUnnecessaryIsInstance]
block_item, DocxTable
):
yield from self._iter_table_element(block_item)
yield from self._iter_section_footers(section)
def _iter_sectionless_document_elements(self) -> Iterator[Element]:
"""Generate each document-element in a docx `document` that has no sections.
A "section-less" DOCX must be iterated differently. Also it will have no headers or footers
(because those live in a section).
"""
for block_item in self._document.iter_inner_content():
if isinstance(block_item, Paragraph):
yield from self._iter_paragraph_elements(block_item)
# -- can only be a Paragraph or Table so far but more types may come later --
elif isinstance(block_item, DocxTable): # pyright: ignore[reportUnnecessaryIsInstance]
yield from self._iter_table_element(block_item)
def _classify_paragraph_to_element(self, paragraph: Paragraph) -> Iterator[Element]:
"""Generate zero-or-one document element for `paragraph`.
In Word, an empty paragraph is commonly used for inter-paragraph spacing. An empty paragraph
does not contribute to the document-element stream and will not cause an element to be
emitted.
"""
text = "".join(
e.text
for e in paragraph._p.xpath(
"w:r | w:hyperlink | w:r/descendant::wp:inline[ancestor::w:drawing][1]//w:r"
)
)
# -- blank paragraphs are commonly used for spacing between paragraphs and do not
# -- contribute to the document-element stream
if not text.strip():
return
metadata = self._paragraph_metadata(paragraph)
# -- a list-item gets some special treatment, mutating the text to remove a
# -- bullet-character if present
if self._is_list_item(paragraph):
clean_text = clean_bullets(text).strip()
if clean_text:
yield ListItem(
text=clean_text,
metadata=metadata,
detection_origin=DETECTION_ORIGIN,
)
return
# -- determine element-type from an explicit Word paragraph-style if possible --
TextSubCls = self._style_based_element_type(paragraph)
if TextSubCls:
yield TextSubCls(text=text, metadata=metadata, detection_origin=DETECTION_ORIGIN)
return
# -- try to recognize the element type by parsing its text --
TextSubCls = self._parse_paragraph_text_for_element_type(paragraph)
if TextSubCls:
yield TextSubCls(text=text, metadata=metadata, detection_origin=DETECTION_ORIGIN)
return
# -- if all that fails we give it the default `Text` element-type --
yield Text(text, metadata=metadata, detection_origin=DETECTION_ORIGIN)
def _convert_table_to_html(self, table: DocxTable) -> str:
"""HTML string version of `table`.
Example:
<table>
<tbody>
<tr><th>item </th><th style="text-align: right;"> qty</th></tr>
<tr><td>spam </td><td style="text-align: right;"> 42</td></tr>
<tr><td>eggs </td><td style="text-align: right;"> 451</td></tr>
<tr><td>bacon </td><td style="text-align: right;"> 0</td></tr>
</tbody>
</table>
`is_nested` is used for recursive calls when a nested table is encountered. Certain
behaviors are different in that case, but the caller can safely ignore that parameter and
allow it to take its default value.
"""
def iter_cell_block_items(cell: _Cell) -> Iterator[str]:
"""Generate the text of each paragraph or table in `cell` as a separate string.
A table nested in `cell` is converted to the normalized text it contains.
"""
for block_item in cell.iter_inner_content():
if isinstance(paragraph := block_item, Paragraph):
# -- all docx content is ultimately in a paragraph; a nested table contributes
# -- structure only
yield paragraph.text
elif isinstance(table := block_item, DocxTable):
for row in table.rows:
yield from iter_row_cells_as_text(row)
def iter_row_cells_as_text(row: _Row) -> Iterator[str]:
"""Generate the normalized text of each cell in `row` as a separate string.
The text of each paragraph within a cell is not separated. A table nested in a cell is
converted to a normalized string of its contents and combined with the text of the
cell that contains the table.
"""
# -- Each omitted cell at the start of the row (pretty rare) gets the empty string.
# -- This preserves column alignment when one or more initial cells are omitted.
for _ in range(row.grid_cols_before):
yield ""
try:
# -- row.cells may introduce `ValueError: no tc element at grid_offset=X` if the
# -- table has merged or malformed cells. always wrap in try/except.
for cell in row.cells:
cell_text = " ".join(iter_cell_block_items(cell))
yield " ".join(cell_text.split())
except Exception as e:
logging.warning(f"Skipping cell in _iter_row_cells_as_text due to: {e}")
yield ""
# -- Each omitted cell at the end of the row (also rare) gets the empty string. --
for _ in range(row.grid_cols_after):
yield ""
return htmlify_matrix_of_cell_texts([list(iter_row_cells_as_text(r)) for r in table.rows])
@cached_property
def _document(self) -> Document:
"""The python-docx `Document` object loaded from file or filename."""
return self._opts.document
@cached_property
def _document_contains_sections(self) -> bool:
"""True when there is at least one section in the document.
This is always true for a document produced by Word, but may not always be the case when the
document results from conversion or export. In particular, a Microsoft Teams chat-transcript
export will have no sections.
"""
return bool(self._document.sections)
def _header_footer_text(self, hdrftr: _Header | _Footer) -> str:
"""The text enclosed in `hdrftr` as a single string.
Each paragraph is included along with the text of each table cell. Empty text is omitted.
Each paragraph text-item is separated by a newline ("\n") although note that a paragraph
that contains a line-break will also include a newline representing that line-break, so
newlines do not necessarily distinguish separate paragraphs.
The entire text of a table is included as a single string with a space separating the text
of each cell.
A header with no text or only whitespace returns the empty string ("").
"""
def iter_hdrftr_texts(hdrftr: _Header | _Footer) -> Iterator[str]:
"""Generate each text item in `hdrftr` stripped of leading and trailing whitespace.
This includes paragraphs as well as table cell contents.
"""
for block_item in hdrftr.iter_inner_content():
if isinstance(block_item, Paragraph):
yield block_item.text.strip()
# -- can only be a Paragraph or Table so far but more types may come later --
elif isinstance( # pyright: ignore[reportUnnecessaryIsInstance]
block_item, DocxTable
):
yield " ".join(self._iter_table_texts(block_item))
return "\n".join(text for text in iter_hdrftr_texts(hdrftr) if text)
def _is_list_item(self, paragraph: Paragraph) -> bool:
"""True when `paragraph` can be identified as a list-item."""
if is_bulleted_text(paragraph.text):
return True
return "<w:numPr>" in paragraph._p.xml
def _iter_paragraph_elements(self, paragraph: Paragraph) -> Iterator[Element]:
"""Generate zero-or-more document elements for `paragraph`.
The generated elements can be both textual elements and PageBreak elements. An empty
paragraph produces no elements.
"""
def iter_paragraph_items(paragraph: Paragraph) -> Iterator[Paragraph | RenderedPageBreak]:
"""Generate Paragraph and RenderedPageBreak items from `paragraph`.
Each generated paragraph is the portion of the paragraph on the same page. When the
paragraph contains no page-breaks, it is iterated unchanged and iteration stops. When
there is a page-break, in general there one paragraph "fragment" before the page break,
the page break, and then the fragment after the page break. However many combinations
are possible. The first item can be either a page-break or a paragraph, but the type
always alternates throughout the sequence.
"""
if not paragraph.contains_page_break:
yield paragraph
return
page_break = paragraph.rendered_page_breaks[0]
# -- preceding-fragment is None when first paragraph content is a page-break --
preceding_paragraph_fragment = page_break.preceding_paragraph_fragment
if preceding_paragraph_fragment:
yield preceding_paragraph_fragment
yield page_break
# -- following-fragment is None when page-break is last paragraph content. This is
# -- probably quite rare (Word moves these to the start of the next paragraph) but
# -- easier to check for it than prove it can't happen.
following_paragraph_fragment = page_break.following_paragraph_fragment
# -- the paragraph fragment following a page-break can itself contain another
# -- page-break; this would also be quite rare, but it can happen so we just recurse
# -- into the second fragment the same way we handled the original paragraph
if following_paragraph_fragment:
yield from iter_paragraph_items(following_paragraph_fragment)
for item in iter_paragraph_items(paragraph):
if isinstance(item, Paragraph):
yield from self._classify_paragraph_to_element(item)
yield from self._iter_paragraph_images(item)
else:
yield from self._opts.increment_page_number()
def _iter_paragraph_emphasis(self, paragraph: Paragraph) -> Iterator[dict[str, str]]:
"""Generate e.g. {"text": "MUST", "tag": "b"} for each emphasis in `paragraph`."""
for run in paragraph.runs:
text = run.text.strip() if run.text else ""
if not text:
continue
if run.bold:
yield {"text": text, "tag": "b"}
if run.italic:
yield {"text": text, "tag": "i"}
def _iter_paragraph_images(self, paragraph: Paragraph) -> Iterator[Image]:
"""Generate `Image` element for each picture shape in `paragraph` when so configured."""
# -- Delegate this job to the pluggable Picture partitioner. Note the default picture
# -- partitioner does not extract images.
PicturePartitionerCls = self._opts.picture_partitioner
yield from PicturePartitionerCls.iter_elements(paragraph, self._opts)
def _iter_section_footers(self, section: Section) -> Iterator[Footer]:
"""Generate any `Footer` elements defined for this section.
A Word document has up to three header and footer definition pairs for each document
section, a primary, first-page, and even-page header and footer. The first-page pair
applies only to the first page of the section (perhaps a title page or chapter start). The
even-page pair is used in book-bound documents where there are both recto and verso pages
(it is applied to verso (even-numbered) pages). A page where neither more specialized
footer applies uses the primary footer.
"""
def iter_footer(footer: _Footer, header_footer_type: str) -> Iterator[Footer]:
"""Generate zero-or-one Footer elements for `footer`."""
if footer.is_linked_to_previous:
return
text = self._header_footer_text(footer)
if not text:
return
yield Footer(
text=text,
detection_origin=DETECTION_ORIGIN,
metadata=ElementMetadata(
filename=self._opts.metadata_file_path,
header_footer_type=header_footer_type,
category_depth=0,
),
)
yield from iter_footer(section.footer, "primary")
if section.different_first_page_header_footer:
yield from iter_footer(section.first_page_footer, "first_page")
if self._document.settings.odd_and_even_pages_header_footer:
yield from iter_footer(section.even_page_footer, "even_page")
def _iter_section_headers(self, section: Section) -> Iterator[Header]:
"""Generate `Header` elements for this section if it has them.
See `._iter_section_footers()` docstring for more on docx headers and footers.
"""
def maybe_iter_header(header: _Header, header_footer_type: str) -> Iterator[Header]:
"""Generate zero-or-one Header elements for `header`."""
if header.is_linked_to_previous:
return
text = self._header_footer_text(header)
if not text:
return
yield Header(
text=text,
detection_origin=DETECTION_ORIGIN,
metadata=ElementMetadata(
filename=self._opts.metadata_file_path,
header_footer_type=header_footer_type,
category_depth=0, # -- headers are always at the root level}
),
)
yield from maybe_iter_header(section.header, "primary")
if section.different_first_page_header_footer:
yield from maybe_iter_header(section.first_page_header, "first_page")
if self._document.settings.odd_and_even_pages_header_footer:
yield from maybe_iter_header(section.even_page_header, "even_page")
def _iter_section_page_breaks(self, section_idx: int, section: Section) -> Iterator[PageBreak]:
"""Generate zero-or-one `PageBreak` document elements for `section`.
A docx section has a "start" type which can be "continuous" (no page-break), "nextPage",
"evenPage", or "oddPage". For the next, even, and odd varieties, a `w:renderedPageBreak`
element signals one page break. Here we only need to handle the case where we need to add
another, for example to go from one odd page to another odd page and we need a total of
two page-breaks.
"""
def page_is_odd() -> bool:
return self._opts.page_number % 2 == 1
start_type = section.start_type
# -- This method is called upon entering a new section, which happens before any paragraphs
# -- in that section are partitioned. A rendered page-break due to a section-start occurs
# -- in the first paragraph of the section and so occurs _later_ in the proces. Here we
# -- predict when two page breaks will be needed and emit one of them. The second will be
# -- emitted by the rendered page-break to follow.
if start_type == WD_SECTION_START.EVEN_PAGE: # noqa
# -- on an even page we need two total, add one to supplement the rendered page break
# -- to follow. There is no "first-document-page" special case because 1 is odd.
if not page_is_odd():
yield from self._opts.increment_page_number()
elif start_type == WD_SECTION_START.ODD_PAGE:
# -- the first page of the document is an implicit "new" odd-page, so no page-break --
if section_idx == 0:
return
if page_is_odd():
yield from self._opts.increment_page_number()
# -- otherwise, start-type is one of "continuous", "new-column", or "next-page", none of
# -- which need our help to get the page-breaks right.
return
def _iter_table_element(self, table: DocxTable) -> Iterator[Table]:
"""Generate zero-or-one Table element for a DOCX `w:tbl` XML element."""
# -- at present, we always generate exactly one Table element, but we might want
# -- to skip, for example, an empty table.
html_table = (
self._convert_table_to_html(table) if self._opts.infer_table_structure else None
)
text_table = " ".join(self._iter_table_texts(table))
emphasized_text_contents, emphasized_text_tags = self._table_emphasis(table)
yield Table(
text_table,
detection_origin=DETECTION_ORIGIN,
metadata=ElementMetadata(
text_as_html=html_table,
filename=self._opts.metadata_file_path,
page_number=self._opts.metadata_page_number,
last_modified=self._opts.last_modified,
emphasized_text_contents=emphasized_text_contents or None,
emphasized_text_tags=emphasized_text_tags or None,
),
)
def _iter_table_emphasis(self, table: DocxTable) -> Iterator[dict[str, str]]:
"""Generate e.g. {"text": "word", "tag": "b"} for each emphasis in `table`."""
for row in table.rows:
try:
# -- row.cells may introduce `ValueError: no tc element at grid_offset=X` if the
# -- table has merged or malformed cells. always wrap in try/except.
for cell in row.cells:
for paragraph in cell.paragraphs:
yield from self._iter_paragraph_emphasis(paragraph)
except Exception as e:
logging.warning(f"Skipping row in _iter_table_emphasis due to: {e}")
continue
def _iter_table_texts(self, table: DocxTable) -> Iterator[str]:
"""Generate text of each cell in `table` stripped of leading and trailing whitespace.
Nested tables are recursed into and their text contributes to the output in depth-first
pre-order. Empty strings due to empty or whitespace-only cells are dropped.
"""
def iter_cell_texts(cell: _Cell) -> Iterator[str]:
"""Generate each text item in `cell` stripped of leading and trailing whitespace.
This includes paragraphs as well as table cell contents.
"""
for block_item in cell.iter_inner_content():
if isinstance(block_item, Paragraph):
yield block_item.text.strip()
# -- can only be a Paragraph or Table so far but more types may come later --
elif isinstance( # pyright: ignore[reportUnnecessaryIsInstance]
block_item, DocxTable
):
yield from self._iter_table_texts(block_item)
for row in table.rows:
tr = row._tr
for tc in tr.tc_lst:
# -- vMerge="continue" indicates a spanned cell in a vertical merge --
if tc.vMerge == "continue":
continue
# -- do not generate empty strings --
yield from (text for text in iter_cell_texts(_Cell(tc, table)) if text)
def _paragraph_emphasis(self, paragraph: Paragraph) -> tuple[list[str], list[str]]:
"""[contents, tags] pair describing emphasized text in `paragraph`."""
iter_p_emph, iter_p_emph_2 = itertools.tee(self._iter_paragraph_emphasis(paragraph))
return ([e["text"] for e in iter_p_emph], [e["tag"] for e in iter_p_emph_2])
def _paragraph_link_meta(self, paragraph: Paragraph) -> tuple[list[str], list[str], list[Link]]:
"""Describes hyperlinks in `paragraph`, if any."""
if not paragraph.hyperlinks:
return [], [], []
def iter_paragraph_links() -> Iterator[Link]:
"""Generate `Link` typed-dict for each external link in `paragraph`.
Word uses hyperlinks for internal "jumps" within the document, as well as for web and
other external locations. Only generate the external ones.
"""
offset = 0
for item in paragraph.iter_inner_content():
if isinstance(item, Run):
offset += len(item.text)
elif isinstance(item, Hyperlink): # pyright: ignore[reportUnnecessaryIsInstance]
text = item.text
url = item.url
start_index = offset
offset += len(text)
# -- docx hyperlinks include "internal" links, like a table-of-contents
# -- (TOC) entry has a jump to the named heading in the document (e.g.
# -- '#_Toc147925734'. Such links have a fragment but not an address
# -- (URL). Treat those as regular text.
if not url:
continue
# -- all Word hyperlinks should contain text, otherwise they have no
# -- visual appearance on the document. Not expected, but technically possible
# -- so filter these out too.
if not text:
continue
yield Link(text=text, url=url, start_index=start_index)
links = list(iter_paragraph_links())
# -- link["text"] is allowed to be None by the declared type for `Link`, but never will be
# -- here because such a link is filtered out above. Use empty str to satisfy type-checker.
link_texts = [link["text"] or "" for link in links]
link_urls = [link["url"] for link in links]
return link_texts, link_urls, links
def _paragraph_metadata(self, paragraph: Paragraph) -> ElementMetadata:
"""ElementMetadata object describing `paragraph`."""
category_depth = self._parse_category_depth_by_style(paragraph)
emphasized_text_contents, emphasized_text_tags = self._paragraph_emphasis(paragraph)
link_texts, link_urls, links = self._paragraph_link_meta(paragraph)
element_metadata = ElementMetadata(
category_depth=category_depth,
emphasized_text_contents=emphasized_text_contents or None,
emphasized_text_tags=emphasized_text_tags or None,
filename=self._opts.metadata_file_path,
last_modified=self._opts.last_modified,
link_texts=link_texts or None,
link_urls=link_urls or None,
links=links or None,
page_number=self._opts.metadata_page_number,
)
element_metadata.detection_origin = "docx"
return element_metadata
def _parse_category_depth_by_style(self, paragraph: Paragraph) -> int:
"""Determine category depth from paragraph metadata"""
# Determine category depth from paragraph ilvl xpath
xpath = paragraph._element.xpath("./w:pPr/w:numPr/w:ilvl/@w:val")
if xpath:
return round(float(xpath[0]))
# Determine category depth from style name
style_name = (paragraph.style and paragraph.style.name) or "Normal"
depth = self._parse_category_depth_by_style_name(style_name)
if depth > 0:
return depth
else:
# Check if category depth can be determined from style ilvl
return self._parse_category_depth_by_style_ilvl()
def _parse_category_depth_by_style_ilvl(self) -> int:
# TODO(newelh) Parsing category depth by style ilvl is not yet implemented
return 0
def _parse_category_depth_by_style_name(self, style_name: str) -> int:
"""Parse category-depth from the style-name of `paragraph`.
Category depth is 0-indexed and relative to the other element types in the document.
"""
def _extract_number(suffix: str) -> int:
parts = suffix.split()
return int(parts[-1]) - 1 if (parts and parts[-1].isdigit()) else 0
# Heading styles
if style_name.startswith("Heading"):
return _extract_number(style_name)
if style_name == "Subtitle":
return 1
# List styles
list_prefixes = ("List", "List Bullet", "List Continue", "List Number")
if style_name.startswith(list_prefixes):
return _extract_number(style_name)
# Other styles
return 0
def _parse_paragraph_text_for_element_type(self, paragraph: Paragraph) -> Type[Text] | None:
"""Attempt to differentiate the element-type by inspecting the raw text."""
text = paragraph.text.strip()
if len(text) < 2:
return None
if is_us_city_state_zip(text):
return Address
if is_email_address(text):
return EmailAddress
if is_possible_narrative_text(text):
return NarrativeText
return None
def _style_based_element_type(self, paragraph: Paragraph) -> Type[Text] | None:
"""Element-type for `paragraph` based on its paragraph-style.
Returns `None` when the style doesn't tell us anything useful, including when it
is the default "Normal" style.
"""
# NOTE(robinson) - documentation on built-in styles at the link below:
# https://python-docx.readthedocs.io/en/latest/user/styles-understanding.html \
# #paragraph-styles-in-default-template
# -- paragraph.style can be None in rare cases, so can style.name. That's going
# -- to mean default style which is equivalent to "Normal" for our purposes.
style_name = (paragraph.style and paragraph.style.name) or "Normal"
# NOTE(robinson) - The "Normal" style name will return None since it's not
# in the mapping. Unknown style names will also return None.
return STYLE_TO_ELEMENT_MAPPING.get(style_name)
def _table_emphasis(self, table: DocxTable) -> tuple[list[str], list[str]]:
"""[contents, tags] pair describing emphasized text in `table`."""
iter_tbl_emph, iter_tbl_emph_2 = itertools.tee(self._iter_table_emphasis(table))
return ([e["text"] for e in iter_tbl_emph], [e["tag"] for e in iter_tbl_emph_2])
# ================================================================================================
# SUB-PARTITIONERS
# ================================================================================================
class _NullPicturePartitioner:
"""Does not parse the provided paragraph for pictures and generates zero `Image` elements."""
@classmethod
def iter_elements(cls, paragraph: Paragraph, opts: DocxPartitionerOptions) -> Iterator[Image]:
"""No-op picture partitioner."""
return
yield
+441
View File
@@ -0,0 +1,441 @@
"""Provides `partition_email()` function.
Suitable for use with `.eml` files, which can be exported from many email clients.
"""
from __future__ import annotations
import datetime as dt
import email
import email.policy
import email.utils
import io
import os
from email.message import EmailMessage, MIMEPart
from functools import cached_property
from typing import IO, Any, Final, Iterator, cast
from dateutil import parser
from unstructured.documents.elements import Element, ElementMetadata
from unstructured.file_utils.model import FileType
from unstructured.logger import logger
from unstructured.partition.common import EXPECTED_ATTACHMENT_ERRORS
from unstructured.partition.common.metadata import get_last_modified_date
from unstructured.partition.html import partition_html
from unstructured.partition.text import partition_text
VALID_CONTENT_SOURCES: Final[tuple[str, ...]] = ("text/html", "text/plain")
def partition_email(
filename: str | None = None,
*,
file: IO[bytes] | None = None,
content_source: str = "text/html",
metadata_filename: str | None = None,
metadata_last_modified: str | None = None,
process_attachments: bool = True,
**kwargs: Any,
) -> list[Element]:
"""Partitions an .eml file into document elements.
Args:
filename: str path of the target file.
file: A file-like object open for reading bytes (not str) e.g. --> open(filename, "rb").
content_source: The preferred message body. Many emails contain both a plain-text and an
HTML version of the message body. By default, the HTML version will be used when
available. Specifying "text/plain" will cause the plain-text version to be preferred.
When the preferred version is not available, the other version will be used.
metadata_filename: The file-path to use for metadata purposes. Useful when the target file
is specified as a file-like object or when `filename` is a temporary file and the
original file-path is known or a more meaningful file-path is desired.
metadata_last_modified: The last-modified timestamp to be applied in metadata. Useful when
a file-like object (which can have no last-modified date) target is used. The
last-modified metadata is otherwise drawn from the filesystem when a path is provided.
process_attachments: When True, also partition any attachments in the message after
partitioning the message body. All document elements appear in the single returned
element list. The filename of the attachment, when available, is used as the
`filename` metadata value for elements arising from the attachment.
Note that all global keyword arguments such as `unique_element_ids`, `language` and
`chunking_strategy` can be used and will be passed along to the decorators that implement
those functions. Further, any keyword arguments applicable to HTML will be passed along to the
HTML partitioner when processing an HTML message body.
"""
ctx = EmailPartitioningContext.load(
file_path=filename,
file=file,
content_source=content_source,
metadata_file_path=metadata_filename,
metadata_last_modified=metadata_last_modified,
process_attachments=process_attachments,
kwargs=kwargs,
)
return list(_EmailPartitioner.iter_elements(ctx=ctx))
class EmailPartitioningContext:
"""Encapsulates partitioning option validation, computation, and application of defaults."""
def __init__(
self,
file_path: str | None = None,
file: IO[bytes] | None = None,
content_source: str = "text/html",
metadata_file_path: str | None = None,
metadata_last_modified: str | None = None,
process_attachments: bool = False,
kwargs: dict[str, Any] = {},
):
self._file_path = file_path
self._file = file
self._content_source = content_source
self._metadata_file_path = metadata_file_path
self._metadata_last_modified = metadata_last_modified
self._process_attachments = process_attachments
self._kwargs = kwargs
@classmethod
def load(
cls,
file_path: str | None,
file: IO[bytes] | None,
content_source: str,
metadata_file_path: str | None,
metadata_last_modified: str | None,
process_attachments: bool,
kwargs: dict[str, Any],
) -> EmailPartitioningContext:
"""Construct and validate an instance."""
return cls(
file_path=file_path,
file=file,
content_source=content_source,
metadata_file_path=metadata_file_path,
metadata_last_modified=metadata_last_modified,
process_attachments=process_attachments,
kwargs=kwargs,
)._validate()
@cached_property
def bcc_addresses(self) -> list[str] | None:
"""The "blind carbon-copy" Bcc: addresses of the message."""
bccs = self.msg.get_all("Bcc")
if not bccs:
return None
addrs = email.utils.getaddresses(bccs)
return [email.utils.formataddr(addr) for addr in addrs]
@cached_property
def body_part(self) -> MIMEPart | None:
"""The message part containing the actual textual email message.
This is as opposed to attachments or "related" parts like an image that appears in the
message etc.
"""
return self.msg.get_body(preferencelist=self.content_type_preference)
@cached_property
def cc_addresses(self) -> list[str] | None:
"""The "carbon-copy" Cc: addresses of the message."""
ccs = self.msg.get_all("Cc")
if not ccs:
return None
addrs = email.utils.getaddresses(ccs)
return [email.utils.formataddr(addr) for addr in addrs]
@cached_property
def content_type_preference(self) -> tuple[str, ...]:
"""Whether to prefer HTML or plain-text body when message-body has both.
The default order of preference is `("html", "plain")`. The order can be switched by
specifying `"text/plain"` as the `content_source` arg value.
"""
return ("plain", "html") if self._content_source == "text/plain" else ("html", "plain")
@cached_property
def email_metadata(self) -> ElementMetadata:
"""The email-specific metadata fields for this message.
Suitable for use with `.metadata.update()` on the base metadata applied to message body
elements by delegate partitioners for text and HTML.
"""
return ElementMetadata(
bcc_recipient=self.bcc_addresses,
cc_recipient=self.cc_addresses,
email_message_id=self.message_id,
sent_from=[self.from_address] if self.from_address else None,
sent_to=self.to_addresses,
subject=self.subject,
)
@cached_property
def from_address(self) -> str | None:
"""The address of the message sender."""
froms = self.msg.get_all("From")
if not froms:
# -- this should never occur because the From: header is mandatory per RFC 5322 --
return None
addrs = email.utils.getaddresses(froms)
formatted_addrs = [email.utils.formataddr(addr) for addr in addrs]
return formatted_addrs[0]
@cached_property
def message_id(self) -> str | None:
"""The value of the Message-ID: header, when present."""
raw_id = self.msg.get("Message-ID")
if not raw_id:
return None
return raw_id.strip().strip("<>")
@cached_property
def metadata_file_path(self) -> str | None:
"""The best available file-path information for this email message.
It's value is computed according to these rules, applied in order:
- The `metadata_filename` arg value when one was provided to `partition_email()`.
- The `file_path` value when one was provided.
- None otherwise.
This value is used as the `filename` metadata value for elements produced by partitioning
the email message (but not those from its attachments).
"""
return self._metadata_file_path or self._file_path or None
@cached_property
def metadata_last_modified(self) -> str | None:
"""The best available last-modified date for this message, as an ISO8601 string.
It's value is computed according to these rules, applied in order:
- The `metadata_last_modified` arg value when one was provided to `partition_email()`.
- The date-time in the `Sent:` header of the message, when present.
- The last-modified date recorded on the filesystem for `file_path` when it was provided.
- None otherwise.
This value is used as the `last_modified` metadata value for all elements produced by
partitioning this email message, including any attachments.
"""
return self._metadata_last_modified or self._sent_date or self._filesystem_last_modified
@cached_property
def msg(self) -> EmailMessage:
"""The Python stdlib `email.message.EmailMessage` object parsed from the EML file."""
if self._file_path is not None:
with open(self._file_path, "rb") as f:
return cast(
EmailMessage, email.message_from_binary_file(f, policy=email.policy.default)
)
assert self._file is not None
file_bytes = self._file.read()
return cast(EmailMessage, email.message_from_bytes(file_bytes, policy=email.policy.default))
@cached_property
def partitioning_kwargs(self) -> dict[str, Any]:
"""The "extra" keyword arguments received by `partition_email()`.
These are passed along to delegate partitioners which extract keyword args like
`chunking_strategy` etc. in their decorators to control metadata behaviors, etc.
"""
return self._kwargs
@cached_property
def process_attachments(self) -> bool:
"""When True, partition attachments in addition to the email message body.
Any attachment having file-format that cannot be partitioned by unstructured is silently
skipped.
"""
return self._process_attachments
@cached_property
def subject(self) -> str | None:
"""The value of the Subject: header, when present."""
subject = self.msg.get("Subject")
if not subject:
return None
return subject
@cached_property
def to_addresses(self) -> list[str] | None:
"""The To: addresses of the message."""
tos = self.msg.get_all("To")
if not tos:
return None
addrs = email.utils.getaddresses(tos)
return [email.utils.formataddr(addr) for addr in addrs]
@cached_property
def _filesystem_last_modified(self) -> str | None:
"""Last-modified retrieved from filesystem when a file-path was provided, None otherwise."""
return get_last_modified_date(self._file_path) if self._file_path else None
@cached_property
def _sent_date(self) -> str | None:
"""ISO-8601 str representation of message sent-date, if available."""
date_str = self.msg.get("Date")
if not date_str:
return None
try:
sent_date = parser.parse(date_str)
except (parser.ParserError, TypeError, ValueError):
return None
return sent_date.astimezone(dt.timezone.utc).isoformat(timespec="seconds")
def _validate(self) -> EmailPartitioningContext:
"""Raise on first invalid option, return self otherwise."""
if not self._file_path and not self._file:
raise ValueError(
"no document specified; either a `filename` or `file` argument must be provided."
)
if self._file:
if not isinstance( # pyright: ignore[reportUnnecessaryIsInstance]
self._file.read(0), bytes
):
raise ValueError("file object must be opened in binary mode")
self._file.seek(0)
if self._content_source not in VALID_CONTENT_SOURCES:
raise ValueError(
f"{repr(self._content_source)} is not a valid value for content_source;"
f" must be one of: {VALID_CONTENT_SOURCES}",
)
return self
class _EmailPartitioner:
"""Encapsulates the partitioning logic for email documents."""
def __init__(self, ctx: EmailPartitioningContext):
self._ctx = ctx
@classmethod
def iter_elements(cls, ctx: EmailPartitioningContext) -> Iterator[Element]:
"""Generate the document elements for the email described by `ctx`."""
return cls(ctx=ctx)._iter_elements()
def _iter_elements(self) -> Iterator[Element]:
"""Generate the document elements for the email described in the partitioning context.
This optionally includes elements generated by partitioning any partitionable attachments
in the message as well.
"""
for e in self._iter_email_body_elements():
e.metadata.update(self._ctx.email_metadata)
yield e
if not self._ctx.process_attachments:
return
for attachment in self._ctx.msg.iter_attachments():
yield from _AttachmentPartitioner.iter_elements(attachment, self._ctx)
def _iter_email_body_elements(self) -> Iterator[Element]:
"""Generate document elements from the email body."""
body_part = self._ctx.body_part
# -- it's possible to have no body part; that translates to zero elements --
if body_part is None:
return
content_type = body_part.get_content_type()
content = body_part.get_content()
assert isinstance(content, str)
if content_type == "text/html":
yield from partition_html(
text=content,
metadata_filename=self._ctx.metadata_file_path,
metadata_file_type=FileType.EML,
metadata_last_modified=self._ctx.metadata_last_modified,
**self._ctx.partitioning_kwargs,
)
else:
yield from partition_text(
text=content,
metadata_filename=self._ctx.metadata_file_path,
metadata_file_type=FileType.EML,
metadata_last_modified=self._ctx.metadata_last_modified,
**self._ctx.partitioning_kwargs,
)
class _AttachmentPartitioner:
"""Partitions an attachment to a MSG file."""
def __init__(self, attachment: EmailMessage, ctx: EmailPartitioningContext):
self._attachment = attachment
self._ctx = ctx
@classmethod
def iter_elements(
cls, attachment: EmailMessage, ctx: EmailPartitioningContext
) -> Iterator[Element]:
"""Partition an attachment MIME-part from a MIME email message (.eml file)."""
return cls(attachment, ctx)._iter_elements()
def _iter_elements(self) -> Iterator[Element]:
"""Partition the byte-stream in the attachment MIME-part into elements.
Generates zero elements if the attachment is not partitionable.
"""
# -- `auto.partition()` imports this module, so we need to defer the import to here to
# -- avoid a circular import.
from unstructured.partition.auto import partition
file = io.BytesIO(self._file_bytes)
# -- partition the attachment --
try:
elements = partition(
file=file,
metadata_filename=self._attachment_file_name,
metadata_last_modified=self._ctx.metadata_last_modified,
**self._ctx.partitioning_kwargs,
)
except BaseException as e:
if not isinstance(e, EXPECTED_ATTACHMENT_ERRORS):
raise
logger.warning(
"Skipping attachment %s: %s",
self._attachment_file_name,
f"{type(e).__name__}: {e}",
)
return
for e in elements:
e.metadata.attached_to_filename = self._attached_to_filename
yield e
@cached_property
def _attached_to_filename(self) -> str | None:
"""The file-name (no path) of the message. `None` if not available."""
file_path = self._ctx.metadata_file_path
if file_path is None:
return None
return os.path.basename(file_path)
@cached_property
def _attachment_file_name(self) -> str | None:
"""The original name of the attached file, `None` if not present in the MIME part."""
return self._attachment.get_filename()
@cached_property
def _file_bytes(self) -> bytes:
"""The bytes of the attached file."""
content = self._attachment.get_content()
if isinstance(content, str):
return content.encode("utf-8")
assert isinstance(content, bytes)
return content
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
from typing import IO, Any, Optional
from unstructured.documents.elements import Element
from unstructured.file_utils.file_conversion import convert_file_to_html_text_using_pandoc
from unstructured.file_utils.model import FileType
from unstructured.partition.common.common import exactly_one
from unstructured.partition.common.metadata import get_last_modified_date
from unstructured.partition.html import partition_html
DETECTION_ORIGIN: str = "epub"
def partition_epub(
filename: Optional[str] = None,
*,
file: Optional[IO[bytes]] = None,
metadata_filename: Optional[str] = None,
metadata_last_modified: Optional[str] = None,
languages: Optional[list[str]] = None,
detect_language_per_element: bool = False,
**kwargs: Any,
) -> list[Element]:
"""Partitions an EPUB document. The document is first converted to HTML and then
partitioned using partition_html.
Parameters
----------
filename
A string defining the target filename path.
file
A file-like object using "rb" mode --> open(filename, "rb").
metadata_last_modified
The last modified date for the document.
languages
User defined value for `metadata.languages` if provided. Otherwise language is detected
using naive Bayesian filter via `langdetect`. Multiple languages indicates text could be
in either language.
Additional Parameters:
detect_language_per_element
Detect language per element instead of at the document level.
"""
exactly_one(filename=filename, file=file)
last_modified = get_last_modified_date(filename) if filename else None
html_text = convert_file_to_html_text_using_pandoc(
source_format="epub",
filename=filename,
file=file,
)
return partition_html(
text=html_text,
metadata_filename=metadata_filename or filename,
metadata_file_type=FileType.EPUB,
metadata_last_modified=metadata_last_modified or last_modified,
languages=languages,
detect_language_per_element=detect_language_per_element,
detection_origin=DETECTION_ORIGIN,
**kwargs,
)
+3
View File
@@ -0,0 +1,3 @@
from unstructured.partition.html.partition import partition_html
__all__ = ["partition_html"]
+346
View File
@@ -0,0 +1,346 @@
import logging
from abc import ABC
from collections import defaultdict
from typing import Any, Optional, Union
from bs4 import BeautifulSoup, Tag
from unstructured.documents.elements import Element, ElementType
from unstructured.documents.html_sanitization import sanitize_html_fragment
logger = logging.getLogger(__name__)
HTML_PARSER = "html.parser"
HTML_TEMPLATE = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title></title>
</head>
<body>
</body>
</html>
"""
TABLE_BORDER_STYLE = "border: 1px solid black;"
TABLE_BORDER_COLLAPSE_STYLE = "border-collapse: collapse;"
class ElementHtml(ABC):
element: Element
children: list["ElementHtml"]
_html_tag: str = "div"
def __init__(self, element: Element, children: Optional[list["ElementHtml"]] = None):
self.element = element
self.children = children or []
@property
def html_tag(self) -> str:
return self._html_tag
def _inject_html_element_attrs(self, element_html: Tag) -> None:
return None
def _inject_html_element_content(self, element_html: Tag, **kwargs: Any) -> None:
element_html.string = self.element.text
def get_text_as_html(self) -> Union[Tag, None]:
element_html = BeautifulSoup(self.element.metadata.text_as_html or "", HTML_PARSER).find()
if not isinstance(element_html, Tag):
return None
return element_html
def _get_children_html(self, soup: BeautifulSoup, element_html: Tag, **kwargs: Any) -> Tag:
wrapper = soup.new_tag(name="div")
wrapper.append(element_html)
for child in self.children:
child_html = child.get_html_element(_soup=soup, **kwargs)
wrapper.append(child_html)
return wrapper
def get_html_element(self, **kwargs: Any) -> Tag:
soup: Optional[BeautifulSoup] = kwargs.pop("_soup", None)
if soup is None:
soup = BeautifulSoup("", HTML_PARSER)
element_html = self.get_text_as_html()
if element_html is None:
element_html = soup.new_tag(name=self.html_tag)
self._inject_html_element_content(element_html, **kwargs)
element_html["class"] = self.element.category
element_html["id"] = self.element.id
self._inject_html_element_attrs(element_html)
if self.children: # if element has children wrap it with a 'div' tag
return self._get_children_html(soup, element_html, **kwargs)
return element_html
def set_children(self, children: list["ElementHtml"]) -> None:
self.children = children
class TitleElementHtml(ElementHtml):
_html_tag = "h%d"
@property
def html_tag(self) -> str:
return self._html_tag % (self.element.metadata.category_depth or 1)
class ImageElementHtml(ElementHtml):
_html_tag = "img"
def _inject_html_element_content(self, element_html: Tag, **kwargs: Any) -> None:
exclude_binary_image_data = kwargs.get("exclude_binary_image_data", False)
if self.element.metadata.image_base64 and not exclude_binary_image_data:
image_mime_type = self.element.metadata.image_mime_type or "image/png"
element_html["src"] = (
f"data:{image_mime_type};base64,{self.element.metadata.image_base64}"
)
element_html["alt"] = self.element.text
class TableElementHtml(ElementHtml):
_html_tag = "table"
def _inject_html_element_content(self, element_html: Tag, **kwargs: Any) -> None:
# -- A table with no `text_as_html` carries only raw text. Wrap it in a
# -- proper row/cell so the markup is valid; loose text placed directly in
# -- a `<table>` is otherwise foster-parented out of the table by HTML5
# -- parsers (e.g. the nh3 sanitization pass), leaving the table empty. --
if not self.element.text:
return
soup = BeautifulSoup("", HTML_PARSER)
tbody = soup.new_tag(name="tbody")
row = soup.new_tag(name="tr")
cell = soup.new_tag(name="td")
cell.string = self.element.text
row.append(cell)
tbody.append(row)
element_html.append(tbody)
def _inject_html_element_attrs(self, element_html: Tag) -> None:
element_html["style"] = f"{TABLE_BORDER_STYLE} {TABLE_BORDER_COLLAPSE_STYLE}"
for tag in element_html.find_all(["tr", "th", "td"]):
tag["style"] = TABLE_BORDER_STYLE
class LinkElementHtml(ElementHtml):
_html_tag = "a"
def _inject_html_element_attrs(self, element_html: Tag) -> None:
element_html["href"] = self.element.metadata.url or ""
class TextElementHtml(ElementHtml):
_html_tag = "p"
class UnorderedListElementHtml(ElementHtml):
_html_tag = "ul"
def _get_children_html(self, soup: BeautifulSoup, element_html: Tag, **kwargs: Any) -> Tag:
for child in self.children:
child_html = child.get_html_element(**kwargs)
element_html.append(child_html)
return element_html
class OrderedListElementHtml(UnorderedListElementHtml):
_html_tag = "ol"
class ListItemElementHtml(UnorderedListElementHtml):
_html_tag = "li"
class LabelElementHtml(ElementHtml):
_html_tag = "label"
class FormElementHtml(ElementHtml):
_html_tag = "form"
class InputElementHtml(ElementHtml):
_html_tag = "input"
class CheckboxElementHtml(InputElementHtml):
def _inject_html_element_attrs(self, element_html: Tag) -> None:
element_html["type"] = "checkbox"
class CheckboxCheckedElementHtml(InputElementHtml):
def _inject_html_element_attrs(self, element_html: Tag) -> None:
element_html["type"] = "checkbox"
element_html["checked"] = "true"
class RadioElementHtml(InputElementHtml):
def _inject_html_element_attrs(self, element_html: Tag) -> None:
element_html["type"] = "radio"
class RadioCheckedElementHtml(InputElementHtml):
def _inject_html_element_attrs(self, element_html: Tag) -> None:
element_html["type"] = "radio"
element_html["checked"] = "true"
LIST_ELEMENTS = [ElementType.LIST_ITEM, ElementType.LIST_ITEM_OTHER]
TYPE_TO_HTML_MAP = {
ElementType.UNCATEGORIZED_TEXT: TextElementHtml,
ElementType.TITLE: TitleElementHtml,
ElementType.IMAGE: ImageElementHtml,
ElementType.TABLE: TableElementHtml,
ElementType.LINK: LinkElementHtml,
ElementType.TEXT: TextElementHtml,
ElementType.PARAGRAPH: TextElementHtml,
ElementType.LIST: OrderedListElementHtml,
ElementType.LIST_ITEM: ListItemElementHtml,
ElementType.LIST_ITEM_OTHER: ListItemElementHtml,
ElementType.FIELD_NAME: LabelElementHtml,
ElementType.BULLETED_TEXT: ListItemElementHtml,
ElementType.FORM: FormElementHtml,
ElementType.CAPTION: TextElementHtml,
ElementType.CHECKED: CheckboxCheckedElementHtml,
ElementType.UNCHECKED: CheckboxElementHtml,
ElementType.CHECK_BOX_CHECKED: CheckboxCheckedElementHtml,
ElementType.CHECK_BOX_UNCHECKED: CheckboxElementHtml,
ElementType.RADIO_BUTTON_CHECKED: RadioCheckedElementHtml,
ElementType.RADIO_BUTTON_UNCHECKED: RadioElementHtml,
ElementType.NARRATIVE_TEXT: TextElementHtml,
ElementType.FIGURE_CAPTION: TextElementHtml,
ElementType.VALUE: InputElementHtml,
ElementType.ABSTRACT: ElementHtml,
ElementType.THREADING: ElementHtml,
ElementType.COMPOSITE_ELEMENT: ElementHtml,
ElementType.PICTURE: ElementHtml,
ElementType.FIGURE: ElementHtml,
ElementType.ADDRESS: ElementHtml,
ElementType.EMAIL_ADDRESS: ElementHtml,
ElementType.PAGE_BREAK: ElementHtml,
ElementType.FORMULA: ElementHtml,
ElementType.HEADER: ElementHtml,
ElementType.HEADLINE: ElementHtml,
ElementType.SUB_HEADLINE: ElementHtml,
ElementType.PAGE_HEADER: ElementHtml,
ElementType.SECTION_HEADER: ElementHtml,
ElementType.FOOTER: ElementHtml,
ElementType.FOOTNOTE: ElementHtml,
ElementType.PAGE_FOOTER: ElementHtml,
ElementType.PAGE_NUMBER: ElementHtml,
ElementType.CODE_SNIPPET: ElementHtml,
ElementType.FORM_KEYS_VALUES: ElementHtml,
ElementType.DOCUMENT_DATA: ElementHtml,
}
def _group_element_children(children: list[ElementHtml]) -> list[ElementHtml]:
grouped_children: list[ElementHtml] = []
temp_group: list["ElementHtml"] = []
prev_grouping = False
for child in children:
grouping = child.element.category in LIST_ELEMENTS
if grouping:
temp_group.append(child)
elif prev_grouping:
grouped_children.append(OrderedListElementHtml(Element(), temp_group))
grouped_children.append(child)
temp_group = []
else:
grouped_children.append(child)
prev_grouping = grouping
if temp_group:
grouped_children.append(OrderedListElementHtml(Element(), temp_group))
return grouped_children
def _elements_to_html_tags_by_parent(elements: list[ElementHtml]) -> list[ElementHtml]:
parent_to_children_map: dict[str, list[ElementHtml]] = defaultdict(list)
for element in elements:
if element.element.metadata.parent_id is not None:
parent_to_children_map[element.element.metadata.parent_id].append(element)
for parent_id, children in parent_to_children_map.items():
grouped_children = _group_element_children(children)
parent = next((el for el in elements if el.element.id == parent_id), None)
if parent is None:
logger.warning(f"Parent element with id {parent_id} not found. Skipping.")
continue
parent.set_children(grouped_children)
return [el for el in elements if el.element.metadata.parent_id is None]
def _elements_to_html_tags(
elements: list[Element], exclude_binary_image_data: bool = False
) -> list[Tag]:
elements_html = [
TYPE_TO_HTML_MAP.get(element.category, ElementHtml)(element) for element in elements
]
elements_html = _elements_to_html_tags_by_parent(elements_html)
return [
element_html.get_html_element(exclude_binary_image_data=exclude_binary_image_data)
for element_html in elements_html
]
def _elements_to_html_tags_by_page(
elements: list[Element], exclude_binary_image_data: bool = False
) -> list[Tag]:
soup = BeautifulSoup("", HTML_PARSER)
pages_tags: list[Tag] = []
grouped_elements = group_elements_by_page(elements)
for page, g_elements in enumerate(grouped_elements, start=1):
page_html = soup.new_tag(name="div", attrs={"data-page_number": page})
elements_html = _elements_to_html_tags(g_elements, exclude_binary_image_data)
for element_html in elements_html:
page_html.append(element_html)
pages_tags.append(page_html)
return pages_tags
def group_elements_by_page(
unstructured_elements: list[Element],
) -> list[list[Element]]:
pages_dict: defaultdict[int, list[Element]] = defaultdict(list)
for element in unstructured_elements:
page_number = element.metadata.page_number
if page_number is None:
logger.warning(f"Page number is not set for an element {element.id}. Skipping.")
continue
pages_dict[page_number].append(element)
pages_list = list(pages_dict.values())
return pages_list
def elements_to_html(
elements: list[Element],
exclude_binary_image_data: bool = False,
no_group_by_page: bool = False,
) -> str:
soup = BeautifulSoup(HTML_TEMPLATE, HTML_PARSER)
if soup.body is None:
raise ValueError("Body tag not found in the HTML template")
elements_html = (
_elements_to_html_tags(elements, exclude_binary_image_data)
if no_group_by_page
else _elements_to_html_tags_by_page(elements, exclude_binary_image_data)
)
# -- Defense-in-depth (GHSA-v5mq-3xhg-98m9): sanitize the assembled body content
# -- through nh3 before returning. This neutralizes any dangerous markup that
# -- survived into `text_as_html` as well as attributes injected here directly
# -- (e.g. an `href` built from `metadata.url`). We sanitize the fragment and
# -- reinsert it, since nh3 strips the surrounding <html>/<head>/<body> wrapper. --
body_fragment = "".join(str(element_html) for element_html in elements_html)
sanitized_fragment = sanitize_html_fragment(body_fragment)
# -- materialize the parsed nodes before appending: `append` moves a node out
# -- of the source soup's `.contents`, so iterating it live would skip nodes. --
for element_html in list(BeautifulSoup(sanitized_fragment, HTML_PARSER).contents):
soup.body.append(element_html)
return soup.prettify()
+25
View File
@@ -0,0 +1,25 @@
from bs4 import BeautifulSoup
def indent_html(html_string: str, html_parser="html.parser") -> str:
"""
Formats / indents HTML.
This function takes an HTML string and formats it using the specified HTML parser.
It parses the HTML content and returns a prettified version of it.
Args:
html_string (str): The HTML content to be formatted.
html_parser (str, optional): The parser to use for parsing the HTML. Defaults to 'html5lib':
- 'html.parser': The built-in HTML parser. Use when you need just parsing
- 'html5lib': The slowest. Use when you expect valid HTML parsed
the same way a browser does. It adds some extra
tags and attributes like <html>, <head>, <body>
More in docs https://www.crummy.com/software/BeautifulSoup/bs4/doc/#installing-a-parser
Returns:
str: The formatted and indented HTML content.
"""
soup = BeautifulSoup(html_string, html_parser)
pretty_html = soup.prettify()
return pretty_html
File diff suppressed because it is too large Load Diff
+292
View File
@@ -0,0 +1,292 @@
# pyright: reportPrivateUsage=false
"""Provides `partition_html()."""
from __future__ import annotations
from functools import cached_property
from typing import IO, Any, Callable, Iterator, List, Literal, Optional, cast
from lxml import etree
from unstructured.chunking import add_chunking_strategy
from unstructured.documents.elements import Element, ElementType
from unstructured.file_utils.encoding import read_txt_file
from unstructured.file_utils.model import FileType
from unstructured.partition.common.metadata import apply_metadata, get_last_modified_date
from unstructured.partition.html.parser import Flow, html_parser
from unstructured.partition.html.transformations import (
ontology_to_unstructured_elements,
parse_html_to_ontology,
)
from unstructured.safe_http import safe_get
from unstructured.utils import is_temp_file_path
@apply_metadata(FileType.HTML)
@add_chunking_strategy
def partition_html(
filename: Optional[str] = None,
*,
file: Optional[IO[bytes]] = None,
text: Optional[str] = None,
encoding: Optional[str] = None,
url: Optional[str] = None,
headers: dict[str, str] = {},
ssl_verify: bool = True,
skip_headers_and_footers: bool = False,
detection_origin: Optional[str] = None,
html_parser_version: Literal["v1", "v2"] = "v1",
image_alt_mode: Optional[Literal["to_text"]] = "to_text",
extract_image_block_to_payload: bool = False,
extract_image_block_types: Optional[list[str]] = None,
languages: Optional[list[str]] = None,
detect_language_per_element: bool = False,
language_fallback: Optional[Callable[[str], Optional[list[str]]]] = None,
**kwargs: Any,
) -> list[Element]:
"""Partitions an HTML document into its constituent elements.
HTML source parameters
----------------------
The HTML to be partitioned can be specified four different ways:
filename
A string defining the target filename path.
file
A file-like object using "r" mode --> open(filename, "r").
text
The string representation of the HTML document.
url
The URL of a webpage to parse. Only for URLs that return an HTML document.
headers
The HTTP headers to be used in the HTTP request when `url` is specified.
ssl_verify
If the URL parameter is set, determines whether or not SSL verification is performed
on the HTTP request.
encoding
The encoding method used to decode the text input. If None, utf-8 will be used.
skip_headers_and_footers
If True, ignores any content that is within <header> or <footer> tags
html_parser_version (Literal['v1', 'v2']):
The version of the HTML parser to use. The default is 'v1'. For 'v2' the parser will
use the ontology schema to parse the HTML document.
image_alt_mode (Literal['to_text']):
When set 'to_text', the v2 parser will include the alternative text of images in the output.
languages
The languages present in the document. Use ``["auto"]`` to detect; use ``[""]`` to disable.
detect_language_per_element
Detect language per element instead of at the document level.
language_fallback
Optional callable for short text; called with the text, return ISO 639-3 codes or None.
"""
# -- parser rejects an empty str, nip that edge-case in the bud here --
if text is not None and text.strip() == "" and not file and not filename and not url:
return []
opts = HtmlPartitionerOptions(
file_path=filename,
file=file,
text=text,
encoding=encoding,
url=url,
headers=headers,
ssl_verify=ssl_verify,
skip_headers_and_footers=skip_headers_and_footers,
detection_origin=detection_origin,
html_parser_version=html_parser_version,
image_alt_mode=image_alt_mode,
extract_image_block_types=extract_image_block_types,
extract_image_block_to_payload=extract_image_block_to_payload,
)
return list(_HtmlPartitioner.iter_elements(opts))
class HtmlPartitionerOptions:
"""Encapsulates partitioning option validation, computation, and application of defaults."""
def __init__(
self,
*,
file_path: str | None,
file: IO[bytes] | None,
text: str | None,
encoding: str | None,
url: str | None,
headers: dict[str, str],
ssl_verify: bool,
skip_headers_and_footers: bool,
detection_origin: str | None,
html_parser_version: Literal["v1", "v2"] = "v1",
image_alt_mode: Optional[Literal["to_text"]] = "to_text",
extract_image_block_types: Optional[list[str]] = None,
extract_image_block_to_payload: bool = False,
):
self._file_path = file_path
self._file = file
self._text = text
self._encoding = encoding
self._url = url
self._headers = headers
self._ssl_verify = ssl_verify
self._skip_headers_and_footers = skip_headers_and_footers
self._detection_origin = detection_origin
self._html_parser_version = html_parser_version
self._image_alt_mode = image_alt_mode
self._extract_image_block_types = extract_image_block_types
self._extract_image_block_to_payload = extract_image_block_to_payload
@cached_property
def detection_origin(self) -> str | None:
"""Trace of initial partitioner to be included in metadata for debugging purposes."""
return self._detection_origin
@cached_property
def html_text(self) -> str:
"""The HTML document as a string, loaded from wherever the caller specified."""
if self._file_path:
return read_txt_file(filename=self._file_path, encoding=self._encoding)[1]
if self._file:
return read_txt_file(file=self._file, encoding=self._encoding)[1]
if self._text:
return str(self._text)
if self._url:
response = safe_get(self._url, headers=self._headers, verify=self._ssl_verify)
if not response.ok:
raise ValueError(
f"Error status code on GET of provided URL: {response.status_code}"
)
content_type = response.headers.get("Content-Type", "")
if not content_type.startswith("text/html"):
raise ValueError(f"Expected content type text/html. Got {content_type}.")
return response.text
raise ValueError("Exactly one of filename, file, text, or url must be specified.")
@cached_property
def last_modified(self) -> str | None:
"""The best last-modified date available, None if no sources are available."""
return (
None
if not self._file_path or is_temp_file_path(self._file_path)
else get_last_modified_date(self._file_path)
)
@cached_property
def skip_headers_and_footers(self) -> bool:
"""When True, elements located within a header or footer are pruned."""
return self._skip_headers_and_footers
@cached_property
def html_parser_version(self) -> Literal["v1", "v2"]:
"""When html_parser_version=='v2', HTML elements follow ontology schema."""
return self._html_parser_version
@cached_property
def add_img_alt_text(self) -> bool:
"""When True, the alternative text of images is included in the output."""
return self._image_alt_mode == "to_text"
class _HtmlPartitioner:
"""Partition HTML document into document-elements."""
def __init__(self, opts: HtmlPartitionerOptions):
self._opts = opts
def _should_include_image_base64(self, element: Element) -> bool:
"""Determines if an image_base64 element should be included in the output."""
return (
element.category == ElementType.IMAGE
and self._opts._extract_image_block_to_payload
and self._opts._extract_image_block_types is not None
and "Image" in self._opts._extract_image_block_types
)
@classmethod
def iter_elements(cls, opts: HtmlPartitionerOptions) -> Iterator[Element]:
"""Partition HTML document provided by `opts` into document-elements."""
yield from cls(opts)._iter_elements()
def _iter_elements(self) -> Iterator[Element]:
"""Generated document-elements (e.g. Title, NarrativeText, etc.) parsed from document.
Elements appear in document order.
"""
# -- handle empty or whitespace-only HTML content --
html_text = self._opts.html_text
if not html_text or html_text.strip() == "":
return
elements_iter = (
self._main.iter_elements()
if self._opts.html_parser_version == "v1"
else self._from_ontology
)
for e in elements_iter:
e.metadata.last_modified = self._opts.last_modified
e.metadata.detection_origin = self._opts.detection_origin
# -- remove <image_base64> if not requested --
if not self._should_include_image_base64(e):
e.metadata.image_base64 = None
e.metadata.image_mime_type = None
yield e
@cached_property
def _main(self) -> Flow:
"""The root HTML element."""
# NOTE(scanny) - get `html_text` first so any encoding error raised is not confused with a
# recoverable parsing error.
html_text = self._opts.html_text
# NOTE(scanny) - `lxml` will not parse a `str` that includes an XML encoding declaration
# and will raise the following error:
# ValueError: Unicode strings with encoding declaration are not supported. ...
# This is not valid HTML (would be in XHTML), but Chrome accepts it so we work around it
# by UTF-8 encoding the str bytes and parsing those.
try:
root = etree.fromstring(html_text, html_parser)
except ValueError:
root = etree.fromstring(html_text.encode("utf-8"), html_parser)
# -- remove a variety of HTML element types like <script> and <style> that we prefer not
# -- to encounter while parsing.
etree.strip_elements(
root, ["del", "link", "meta", "noscript", "script", "style"], with_tail=False
)
# -- remove <header> and <footer> tags if the caller doesn't want their contents --
if self._opts.skip_headers_and_footers:
etree.strip_elements(root, ["header", "footer"], with_tail=False)
# -- jump to the core content if the document indicates where it is --
if (main := root.find(".//main")) is not None:
return cast(Flow, main)
if (body := root.find(".//body")) is not None:
return cast(Flow, body)
return cast(Flow, root)
@cached_property
def _from_ontology(self) -> List[Element]:
"""Convert an ontology elements represented in HTML to an ontology element."""
html_text = self._opts.html_text
# -- handle empty or whitespace-only HTML content --
if not html_text or html_text.strip() == "":
return []
ontology = parse_html_to_ontology(html_text)
unstructured_elements = ontology_to_unstructured_elements(
ontology, add_img_alt_text=self._opts.add_img_alt_text
)
return unstructured_elements
@@ -0,0 +1,590 @@
from __future__ import annotations
from itertools import chain
from typing import Sequence, Type
from bs4 import BeautifulSoup, Tag
from unstructured.documents import elements, ontology
from unstructured.documents.html_sanitization import sanitize_attributes
from unstructured.documents.mappings import (
CSS_CLASS_TO_ELEMENT_TYPE_MAP,
HTML_TAG_AND_CSS_NAME_TO_ELEMENT_TYPE_MAP,
HTML_TAG_TO_DEFAULT_ELEMENT_TYPE_MAP,
ONTOLOGY_CLASS_TO_UNSTRUCTURED_ELEMENT_TYPE,
)
from unstructured.partition.common.metadata import (
HEADING_TAGS,
category_depth_from_html_tag,
)
RECURSION_LIMIT = 50
def ontology_to_unstructured_elements(
ontology_element: ontology.OntologyElement,
parent_id: str | None = None,
page_number: int | None = None,
depth: int = 0,
filename: str | None = None,
add_img_alt_text: bool = True,
) -> list[elements.Element]:
"""
Converts an OntologyElement object to a list of unstructured Element objects.
To preserve the structure of the ontology, the function is recursive
and the tree structure is represented in flatten list by the parent_id
attribute in the metadata of each Element object.
To preserve all the attributes of the ontology element, the HTML code
is injected to unstructured Element in ElementMetadata.text_as_html attribute.
For Layout elements, the function creates an empty Text Element (with the
HTML code injected the same way).
TODO (Pluto): Better way would be to have special Element type in Unstructured
Args:
ontology_element (OntologyElement): The ontology element to be converted.
parent_id (str, optional): The ID of the parent element. Defaults to None.
page_number (int, optional): The page number of the element. Defaults to None.
depth (int, optional): The depth of the element in the hierarchy. Defaults to 0.
filename (str, optional): The name of the file the element comes from. Defaults to None.
add_img_alt_text (bool): Whether to include the alternative text of images
in the output. Defaults to True.
Returns:
list[Element]: A list of unstructured Element objects.
Note on `category_depth` and `parent_id` (ML-1328):
`category_depth` is derived from the element's HTML *heading level* (h1 -> 0, h2 -> 1, ...)
via the shared `category_depth_from_html_tag` helper, NOT from DOM/recursion nesting depth.
This keeps the v2 (ontology) parser consistent with the v1 parser and with the documented
metadata contract, and makes depth independent of layout (e.g. multi-column pages no longer
bump every element's depth).
`parent_id` is left to the metadata layer, like every other partitioner. Layout/container
elements (Page, Column, ...) keep their tree parent so the physical layout structure is
preserved; content elements are emitted with ``parent_id=None``. The `@apply_metadata`
decorator that wraps `partition_html` then runs `set_element_hierarchy`, which fills each
content element's heading-based parent (a subsection's parent becomes its enclosing heading)
from the heading-level `category_depth` and skips the containers that already have a parent.
Both production callers -- `partition_html` and the VLM partitioner -- go through that
decorator, so this converter does not run `set_element_hierarchy` itself.
"""
# -- The worker carries each element's DOM-nesting depth alongside it (used only to decide
# -- inline merging); strip those depths here so the public output is plain Elements. --
elements_with_depth = _ontology_to_unstructured_elements(
ontology_element,
parent_id=parent_id,
page_number=page_number,
depth=depth,
filename=filename,
add_img_alt_text=add_img_alt_text,
)
return [element for element, _nesting_depth in elements_with_depth]
def _ontology_to_unstructured_elements(
ontology_element: ontology.OntologyElement,
parent_id: str | None = None,
page_number: int | None = None,
depth: int = 0,
filename: str | None = None,
add_img_alt_text: bool = True,
) -> list[tuple[elements.Element, int]]:
"""Recursive worker for `ontology_to_unstructured_elements`.
Builds the flat element list with layout-container `parent_id` set to the tree parent and
content `parent_id` left as ``None`` -- the `@apply_metadata` decorator on `partition_html`
fills in content elements' heading-based `parent_id` via `set_element_hierarchy`.
Each element is returned paired with its DOM-nesting `depth`. That depth is recursion-local
bookkeeping consumed only by `combine_inline_elements` (to gate inline merging by tree level);
it is deliberately NOT stored on the element or its `ElementMetadata`, and the public wrapper
discards it.
"""
elements_to_return: list[tuple[elements.Element, int]] = []
if ontology_element.elementType == ontology.ElementTypeEnum.layout and depth <= RECURSION_LIMIT:
if page_number is None and isinstance(ontology_element, ontology.Page):
page_number = ontology_element.page_number
if not isinstance(ontology_element, ontology.Document):
# -- Layout/container element (Page, Column, ...). Keep its tree `parent_id` so the
# -- physical layout structure is preserved, and leave `category_depth` unset -- a
# -- container is not a heading. --
container_element = elements.Text(
text="",
element_id=ontology_element.id,
detection_origin="vlm_partitioner",
metadata=elements.ElementMetadata(
parent_id=parent_id,
text_as_html=ontology_element.to_html(add_children=False),
page_number=page_number,
category_depth=None,
filename=filename,
),
)
# -- pair the container with its DOM-nesting depth, used only to decide inline merging;
# -- `category_depth` now carries heading level, not nesting, so it can't be reused. --
elements_to_return += [(container_element, depth)]
children: list[tuple[elements.Element, int]] = []
for child in ontology_element.children:
child = _ontology_to_unstructured_elements(
child,
parent_id=ontology_element.id,
page_number=page_number,
depth=0 if isinstance(ontology_element, ontology.Document) else depth + 1,
filename=filename,
add_img_alt_text=add_img_alt_text,
)
children += child
combined_children = combine_inline_elements(children)
elements_to_return += combined_children
else:
element_class: type[elements.Element] = ONTOLOGY_CLASS_TO_UNSTRUCTURED_ELEMENT_TYPE[
ontology_element.__class__
]
html_code_of_ontology_element = ontology_element.to_html()
element_text = ontology_element.to_text(add_img_alt_text=add_img_alt_text)
# -- `category_depth` from heading level (not nesting depth); see function docstring. --
category_depth = category_depth_from_html_tag(
element_class,
ontology_element.html_tag_name,
)
unstructured_element = element_class(
text=element_text, # type: ignore
element_id=ontology_element.id,
detection_origin="vlm_partitioner",
metadata=elements.ElementMetadata(
# -- `parent_id` left unset; `@apply_metadata` runs `set_element_hierarchy` to
# -- assign a heading-based parent (see the docstring). --
parent_id=None,
text_as_html=html_code_of_ontology_element,
page_number=page_number,
category_depth=category_depth,
filename=filename,
),
)
elements_to_return = [(unstructured_element, depth)]
return elements_to_return
def combine_inline_elements(
elements_with_depth: list[tuple[elements.Element, int]],
) -> list[tuple[elements.Element, int]]:
"""
Combines consecutive inline elements into a single element. Inline elements
can be also combined with text elements.
Combined elements contains multiple HTML tags together eg.
{
'text': "Text from element 1 Text from element 2",
'metadata': {
'text_as_html': "<p>Text from element 1</p><a>Text from element 2</a>"
}
}
Each element is paired with its DOM-nesting depth; merging is only allowed between elements at
the same depth (see `can_unstructured_elements_be_merged`). The depth travels with the element
rather than being stored on it.
Args:
elements_with_depth (list[tuple[Element, int]]): (element, nesting-depth) pairs to combine.
Returns:
list[tuple[Element, int]]: The combined (element, nesting-depth) pairs.
"""
result_elements: list[tuple[elements.Element, int]] = []
current: tuple[elements.Element, int] | None = None
for nxt in elements_with_depth:
if current is None:
current = nxt
continue
current_element, current_depth = current
next_element, next_depth = nxt
if can_unstructured_elements_be_merged(
current_element, next_element, current_depth=current_depth, next_depth=next_depth
):
current_element.text += " " + next_element.text
current_element.metadata.text_as_html += next_element.metadata.text_as_html
else:
result_elements.append(current)
current = nxt
if current is not None:
result_elements.append(current)
return result_elements
def can_unstructured_elements_be_merged(
current_element: elements.Element,
next_element: elements.Element,
*,
current_depth: int,
next_depth: int,
) -> bool:
"""
Elements can be merged when:
- They are on the same level in the HTML tree
- Neither of them has children
- All elements are inline elements or text element
"""
# NOTE(ML-1328): "same level in the HTML tree" is the DOM-nesting depth, passed in alongside
# each element. It used to live on `category_depth`, but that field now carries heading level,
# so it can no longer be used as the nesting signal here.
if current_depth != next_depth:
return False
current_html_tags = BeautifulSoup(
current_element.metadata.text_as_html, "html.parser"
).find_all(recursive=False)
next_html_tags = BeautifulSoup(next_element.metadata.text_as_html, "html.parser").find_all(
recursive=False
)
ontology_elements = [
parse_html_to_ontology_element(html_tag)
for html_tag in chain(current_html_tags, next_html_tags)
]
for ontology_element in ontology_elements:
if ontology_element.children:
return False
if not (is_inline_element(ontology_element) or is_text_element(ontology_element)):
return False
return True
def is_text_element(ontology_element: ontology.OntologyElement) -> bool:
"""Categories or classes that we want to combine with inline text"""
text_classes = [
ontology.NarrativeText,
ontology.Quote,
ontology.Paragraph,
ontology.Footnote,
ontology.FootnoteReference,
ontology.Citation,
ontology.Bibliography,
ontology.Glossary,
]
text_categories = [ontology.ElementTypeEnum.metadata]
if any(isinstance(ontology_element, class_) for class_ in text_classes):
return True
return any(ontology_element.elementType == category for category in text_categories)
def is_inline_element(ontology_element: ontology.OntologyElement) -> bool:
"""Categories or classes that we want to combine with text elements"""
inline_classes = [ontology.Hyperlink]
inline_categories = [
ontology.ElementTypeEnum.specialized_text,
ontology.ElementTypeEnum.annotation,
]
if any(isinstance(ontology_element, class_) for class_ in inline_classes):
return True
return any(ontology_element.elementType == category for category in inline_categories)
def unstructured_elements_to_ontology(
unstructured_elements: Sequence[elements.Element],
) -> ontology.OntologyElement:
"""
Converts a sequence of unstructured Element objects to an OntologyElement object.
The function caches the elements in a dictionary and each element is assigned to its parent.
At the end the root element is popped from the dictionary and returned.
Such approach comes with limitations:
- The parent element has to be in the list before the child element
Args:
unstructured_elements (Sequence[Element]): The sequence of unstructured Element objects.
Returns:
OntologyElement: The converted OntologyElement object.
"""
if not unstructured_elements:
# -- empty input -> empty Document; avoid an IndexError dereferencing element[0] --
return ontology.Document(
additional_attributes={"id": ontology.OntologyElement.generate_unique_id()}
)
root_element_id = unstructured_elements[0].metadata.parent_id
if root_element_id is None:
root_element_id = ontology.OntologyElement.generate_unique_id()
unstructured_elements[0].metadata.parent_id = root_element_id
root_element = ontology.Document(additional_attributes={"id": root_element_id})
# NOTE(ML-1328): Tree reconstruction is driven by the *layout-container* elements (Page,
# Column, Section, ...), which retain their tree `parent_id`. Content-element `parent_id` is no
# longer the tree parent -- it is the heading-based parent assigned by `set_element_hierarchy`
# -- so it must NOT be used to rebuild the layout tree. Instead, each content element is nested
# in the innermost open layout container, tracked with a stack keyed on the containers' own
# (tree) `parent_id`. This is independent of document content `parent_id` and reproduces the
# original layout nesting exactly.
container_stack: list[tuple[str, ontology.OntologyElement]] = [(root_element_id, root_element)]
for element in unstructured_elements:
# -- an element with no HTML payload carries nothing to rebuild the tree from;
# -- skip it per-element rather than letting BeautifulSoup(None) abort the whole
# -- reconstruction (e.g. mixed/partially-stripped element streams). --
if not element.metadata.text_as_html:
continue
html_as_tags = BeautifulSoup(element.metadata.text_as_html, "html.parser").find_all(
recursive=False
)
for html_as_tag in html_as_tags:
ontology_element = parse_html_to_ontology_element(html_as_tag)
is_layout_container = ontology_element.elementType == ontology.ElementTypeEnum.layout
if is_layout_container:
# -- pop back to this container's tree parent, then attach + open it. Only pop if
# -- that parent is actually open on the stack; a `parent_id` matching no open
# -- container (e.g. malformed/reordered input that violates the documented
# -- parent-before-child precondition) must not pop past valid ancestors to root --
# -- which would mis-nest later content. In that case attach to the current
# -- innermost container instead, preserving document order and losing nothing. --
# -- a container with no `parent_id` is a top-level container -> attach at root --
parent_id = element.metadata.parent_id or root_element_id
if any(container_id == parent_id for container_id, _ in container_stack):
while len(container_stack) > 1 and container_stack[-1][0] != parent_id:
container_stack.pop()
container_stack[-1][1].children.append(ontology_element)
container_stack.append((element.id, ontology_element))
else:
# -- content nests in the innermost currently-open layout container --
container_stack[-1][1].children.append(ontology_element)
return root_element
def parse_html_to_ontology(html_code: str) -> ontology.OntologyElement:
"""
Parses the given HTML code and converts it into an Element object.
Args:
html_code (str): The HTML code to be parsed.
Parsing HTML will start from <div class="Page">.
Returns:
OntologyElement: The parsed Element object.
Raises:
ValueError: If no <body class="Document"> element is found in the HTML.
"""
html_code = remove_empty_divs_from_html_content(html_code)
html_code = remove_empty_tags_from_html_content(html_code)
soup = BeautifulSoup(html_code, "html.parser")
document = soup.find("body", class_="Document")
if not document:
document = soup.find("div", class_="Page")
if not document:
raise ValueError(
"No <body class='Document'> or <div class='Page'> element found in the HTML."
)
document_element = parse_html_to_ontology_element(document)
return document_element
def remove_empty_divs_from_html_content(html_content: str) -> str:
soup = BeautifulSoup(html_content, "html.parser")
divs = soup.find_all("div")
for div in reversed(divs):
if not div.attrs:
div.unwrap()
return str(soup)
def remove_empty_tags_from_html_content(html_content: str) -> str:
soup = BeautifulSoup(html_content, "html.parser")
def is_empty(tag):
# Remove only specific tags, omit self-closing ones
if tag.name not in (*HEADING_TAGS, "p", "span", "div"):
return False
if tag.find():
return False
if tag.attrs:
return False
return bool(not tag.get_text(strip=True))
def remove_empty_tags(soup):
for tag in soup.find_all():
if is_empty(tag):
tag.decompose()
remove_empty_tags(soup)
return str(soup)
def parse_html_to_ontology_element(soup: Tag, recursion_depth: int = 1) -> ontology.OntologyElement:
"""
Converts a BeautifulSoup Tag object into an OntologyElement object. This function is recursive.
First tries to recognize a class from Unstructured Ontology, then if class is matched tries
to go deeper inside HTML tree. The recursive parsing is ended if the class is not recognized or
there are no HTML Tags inside HTML - just text. Then it is parsed to
Paragraph or UncategorizedText object.
Args:
soup (Tag): The BeautifulSoup Tag object to be converted.
recursion_depth (int): Flag to control limit of recursion depth.
Returns:
OntologyElement: The converted OntologyElement object.
"""
ontology_html_tag, ontology_class = extract_tag_and_ontology_class_from_tag(soup)
escaped_attrs = get_sanitized_attributes(soup, tag_name=ontology_html_tag)
if soup.name == "br": # Note(Pluto) should it be <br class="UncategorizedText">?
return ontology.Paragraph(
text="",
css_class_name=None,
html_tag_name="br",
additional_attributes=escaped_attrs,
)
has_children = (
(ontology_class != ontology.UncategorizedText)
and any(isinstance(content, Tag) for content in soup.contents)
or ontology_class().elementType == ontology.ElementTypeEnum.layout
)
should_unwrap_html = has_children and recursion_depth <= RECURSION_LIMIT
if should_unwrap_html:
text = ""
children = [
(
parse_html_to_ontology_element(child, recursion_depth=recursion_depth + 1)
if isinstance(child, Tag)
else ontology.Paragraph(text=str(child).strip())
)
for child in soup.children
if str(child).strip()
]
else:
text = "\n".join([str(content).strip() for content in soup.contents]).strip()
children = []
output_element = ontology_class(
text=text,
children=children,
html_tag_name=ontology_html_tag,
additional_attributes=escaped_attrs,
)
# TODO (Pluto): <input class="FormFieldValue"/> requires being wrapped in <label> tags
return output_element
def extract_tag_and_ontology_class_from_tag(
soup: Tag,
) -> tuple[str, Type[ontology.OntologyElement]]:
"""
Extracts the HTML tag and corresponding ontology class
from a BeautifulSoup Tag object. The CSS class is prioritized over
the HTML tag. If not recognized soup.name and UnstructuredText is returned.
Args:
soup (Tag): The BeautifulSoup Tag object to extract information from.
Returns:
tuple: A tuple containing the HTML tag (str) and the ontology class (Type[OntologyElement]).
"""
html_tag, element_class = None, None
# Scenario 1: Valid Ontology Element
if soup.attrs.get("class"):
html_tag, element_class = (
soup.name,
HTML_TAG_AND_CSS_NAME_TO_ELEMENT_TYPE_MAP.get((soup.name, soup.attrs["class"][0])),
)
# Scenario 2: HTML tag incorrect, CSS class correct
# Fallback to css name selector and overwrite html tag
if (
not element_class
and soup.attrs.get("class")
and soup.attrs["class"][0] in CSS_CLASS_TO_ELEMENT_TYPE_MAP
):
element_class = CSS_CLASS_TO_ELEMENT_TYPE_MAP.get(soup.attrs["class"][0])
html_tag = element_class().allowed_tags[0]
# Scenario 3: <input> elements, handled explicitly based on their 'type' attribute
if not element_class and soup.name == "input":
input_type = (str(soup.get("type")) or "").lower()
if input_type == "checkbox":
element_class = ontology.Checkbox
elif input_type == "radio":
element_class = ontology.RadioButton
else:
# Any other input (including missing type or text/number/etc.) is considered
# a generic form field value.
element_class = ontology.FormFieldValue
html_tag = "input"
# Scenario 4: CSS class incorrect, but HTML tag correct and exclusive in ontology
if not element_class and soup.name in HTML_TAG_TO_DEFAULT_ELEMENT_TYPE_MAP:
html_tag, element_class = soup.name, HTML_TAG_TO_DEFAULT_ELEMENT_TYPE_MAP[soup.name]
# Scenario 5: CSS class incorrect, HTML tag incorrect
# Fallback to default UncategorizedText
if not element_class:
# TODO (Pluto): Sometimes we could infer that from parent type and soup.name
# e.g. parent=FormField soup.name=input -> element=FormFieldInput
html_tag = "span"
element_class = ontology.UncategorizedText
# Scenario 6: UncategorizedText has image and no text
# Typically, this happens with a span or div tag with an image inside
if element_class == ontology.UncategorizedText and soup.find("img") and not soup.text.strip():
element_class = ontology.Image
return html_tag, element_class
def get_sanitized_attributes(soup: Tag, tag_name: str | None = None) -> dict[str, str | list[str]]:
"""
Sanitizes the attributes of a BeautifulSoup Tag object for the ontology.
Drops event-handler (``on*``) attributes and URL attributes with unsafe
schemes (``javascript:`` / ``vbscript:`` / non-image ``data:``) so an
ontology element never carries a dangerous attribute in the first place.
Values are intentionally left un-escaped: HTML-escaping is done exactly once,
at emit time, in ``OntologyElement.to_html`` (see
``unstructured.documents.html_sanitization``). Escaping here as well would
double-encode entities (e.g. ``&`` -> ``&amp;amp;``).
Args:
soup (Tag): The BeautifulSoup Tag object whose attributes to sanitize.
tag_name: The ontology tag that will be emitted for this element.
Returns:
dict: A dictionary with the safe subset of attributes.
"""
return sanitize_attributes(dict(soup.attrs), tag_name=tag_name) # type: ignore[return-value]
# -- Backwards-compatible alias; the previous name implied it html-escaped, which
# -- it no longer does (escaping moved to emit time). --
get_escaped_attributes = get_sanitized_attributes
+123
View File
@@ -0,0 +1,123 @@
from __future__ import annotations
from typing import IO, Any, Optional
from unstructured.chunking import add_chunking_strategy
from unstructured.documents.elements import Element, process_metadata
from unstructured.file_utils.filetype import add_metadata
from unstructured.partition.common.common import exactly_one
from unstructured.partition.common.lang import check_language_args
from unstructured.partition.pdf import partition_pdf_or_image
from unstructured.partition.utils.constants import PartitionStrategy
@process_metadata() # TODO(shreya): update to use `apply_metadata` decorator
@add_metadata
@add_chunking_strategy
def partition_image(
filename: Optional[str] = None,
file: Optional[IO[bytes]] = None,
include_page_breaks: bool = False,
infer_table_structure: bool = False,
ocr_languages: Optional[str] = None,
languages: Optional[list[str]] = None,
detect_language_per_element: bool = False,
strategy: str = PartitionStrategy.HI_RES,
metadata_last_modified: Optional[str] = None,
chunking_strategy: Optional[str] = None,
hi_res_model_name: Optional[str] = None,
extract_images_in_pdf: bool = False,
extract_image_block_types: Optional[list[str]] = None,
extract_image_block_output_dir: Optional[str] = None,
extract_image_block_to_payload: bool = False,
starting_page_number: int = 1,
extract_forms: bool = False,
form_extraction_skip_tables: bool = True,
password: Optional[str] = None,
**kwargs: Any,
) -> list[Element]:
"""Parses an image into a list of interpreted elements.
Parameters
----------
filename
A string defining the target filename path.
file
A file-like object as bytes --> open(filename, "rb").
include_page_breaks
If True, includes page breaks at the end of each page in the document.
infer_table_structure
Only applicable if `strategy=hi_res`.
If True, any Table elements that are extracted will also have a metadata field
named "text_as_html" where the table's text content is rendered into an html string.
I.e., rows and cells are preserved.
Whether True or False, the "text" field is always present in any Table element
and is the text content of the table (no structure).
languages
The languages present in the document, for use in partitioning and/or OCR. To use a language
with Tesseract, you'll first need to install the appropriate Tesseract language pack.
strategy
The strategy to use for partitioning the image. Valid strategies are "hi_res" and
"ocr_only". When using the "hi_res" strategy, the function uses a layout detection
model if to identify document elements. When using the "ocr_only" strategy,
partition_image simply extracts the text from the document using OCR and processes it.
The default strategy is `hi_res`.
metadata_last_modified
The last modified date for the document.
hi_res_model_name
The layout detection model used when partitioning strategy is set to `hi_res`.
extract_images_in_pdf
Only applicable if `strategy=hi_res`.
If True, any detected images will be saved in the path specified by
'extract_image_block_output_dir' or stored as base64 encoded data within metadata fields.
Deprecation Note: This parameter is marked for deprecation. Future versions will use
'extract_image_block_types' for broader extraction capabilities.
extract_image_block_types
Only applicable if `strategy=hi_res`.
Images of the element type(s) specified in this list (e.g., ["Image", "Table"]) will be
saved in the path specified by 'extract_image_block_output_dir' or stored as base64 encoded
data within metadata fields.
extract_image_block_to_payload
Only applicable if `strategy=hi_res`.
If True, images of the element type(s) defined in 'extract_image_block_types' will be
encoded as base64 data and stored in two metadata fields: 'image_base64' and
'image_mime_type'.
This parameter facilitates the inclusion of element data directly within the payload,
especially for web-based applications or APIs.
extract_image_block_output_dir
Only applicable if `strategy=hi_res` and `extract_image_block_to_payload=False`.
The filesystem path for saving images of the element type(s)
specified in 'extract_image_block_types'.
extract_forms
Whether the form extraction logic should be run
(results in adding FormKeysValues elements to output).
form_extraction_skip_tables
Whether the form extraction logic should ignore regions designated as Tables.
password
The password to decrypt the PDF file.
"""
exactly_one(filename=filename, file=file)
languages = check_language_args(languages or [], ocr_languages)
return partition_pdf_or_image(
filename=filename,
file=file,
is_image=True,
include_page_breaks=include_page_breaks,
infer_table_structure=infer_table_structure,
languages=languages,
detect_language_per_element=detect_language_per_element,
strategy=strategy,
metadata_last_modified=metadata_last_modified,
hi_res_model_name=hi_res_model_name,
extract_images_in_pdf=extract_images_in_pdf,
extract_image_block_types=extract_image_block_types,
extract_image_block_output_dir=extract_image_block_output_dir,
extract_image_block_to_payload=extract_image_block_to_payload,
starting_page_number=starting_page_number,
extract_forms=extract_forms,
form_extraction_skip_tables=form_extraction_skip_tables,
password=password,
**kwargs,
)
+88
View File
@@ -0,0 +1,88 @@
"""Provides `partition_json()`.
Note this does not partition arbitrary JSON. Its only use-case is to "rehydrate" unstructured
document elements serialized to JSON, essentially the same function as `elements_from_json()`, but
this allows a document of already-partitioned elements to be combined transparently with other
documents in a partitioning run. It also allows multiple (low-cost) chunking runs to be performed on
a document while only incurring partitioning cost once.
"""
from __future__ import annotations
import json
from typing import IO, Any, Optional
from unstructured.chunking import add_chunking_strategy
from unstructured.documents.elements import Element, process_metadata
from unstructured.file_utils.filetype import (
FileType,
add_metadata_with_filetype,
is_json_processable,
)
from unstructured.partition.common.common import exactly_one
from unstructured.partition.common.metadata import get_last_modified_date
from unstructured.staging.base import elements_from_dicts
@process_metadata()
@add_metadata_with_filetype(FileType.JSON)
@add_chunking_strategy
def partition_json(
filename: Optional[str] = None,
file: Optional[IO[bytes]] = None,
text: Optional[str] = None,
metadata_last_modified: Optional[str] = None,
**kwargs: Any,
) -> list[Element]:
"""Partitions serialized Unstructured output into its constituent elements.
Parameters
----------
filename
A string defining the target filename path.
file
A file-like object as bytes --> open(filename, "rb").
text
The string representation of the .json document.
metadata_last_modified
The last modified date for the document.
"""
if text is not None and text.strip() == "" and not file and not filename:
return []
exactly_one(filename=filename, file=file, text=text)
last_modified = get_last_modified_date(filename) if filename else None
file_text = ""
if filename is not None:
with open(filename, encoding="utf8") as f:
file_text = f.read()
elif file is not None:
file_content = file.read()
file_text = file_content if isinstance(file_content, str) else file_content.decode()
file.seek(0)
elif text is not None:
file_text = str(text)
if not is_json_processable(file_text=file_text):
raise ValueError(
"JSON cannot be partitioned. Schema does not match the Unstructured schema.",
)
try:
element_dicts = json.loads(file_text)
elements = elements_from_dicts(element_dicts)
# if we found at least one json element, but no unstructured elements were found, throw 422
if len(element_dicts) > 0 and len(elements) == 0:
raise ValueError(
"JSON cannot be partitioned. Schema does not match the Unstructured schema.",
)
except json.JSONDecodeError:
raise ValueError("Not a valid json")
for element in elements:
element.metadata.last_modified = metadata_last_modified or last_modified
return elements
+126
View File
@@ -0,0 +1,126 @@
from __future__ import annotations
from typing import IO, Any, Optional
import markdown
from markdown.extensions import Extension
from unstructured.documents.elements import Element
from unstructured.file_utils.encoding import read_txt_file
from unstructured.file_utils.model import FileType
from unstructured.partition.common.common import exactly_one
from unstructured.partition.common.metadata import get_last_modified_date
from unstructured.partition.html import partition_html
from unstructured.safe_http import safe_get
def optional_decode(contents: str | bytes) -> str:
if isinstance(contents, bytes):
return contents.decode("utf-8")
return contents
DETECTION_ORIGIN: str = "md"
_DEFAULT_MARKDOWN_EXTENSIONS: list[str] = ["tables", "fenced_code"]
def _validate_markdown_extensions(extensions: Any) -> list[Any]:
"""Return ``extensions`` if it is a list of strings and/or ``Extension`` instances.
Python-Markdown accepts extension entry points as registered names (``str``) or configured
``Extension`` instances; both are supported here. Any other shape raises ``ValueError``.
"""
if not isinstance(extensions, list):
raise ValueError(
"'extensions' must be a list of extension names (str) and/or "
f"markdown.extensions.Extension instances, got {type(extensions).__name__!r}"
)
for item in extensions:
if not isinstance(item, (str, Extension)):
raise ValueError(
"Each entry in 'extensions' must be a str or markdown.extensions.Extension "
f"instance, got {type(item).__name__}: {item!r}"
)
return extensions
def partition_md(
filename: str | None = None,
file: IO[bytes] | None = None,
text: str | None = None,
url: str | None = None,
metadata_filename: str | None = None,
metadata_last_modified: str | None = None,
languages: Optional[list[str]] = None,
**kwargs: Any,
) -> list[Element]:
"""Partitions a markdown file into its constituent elements
Parameters
----------
filename
A string defining the target filename path.
file
A file-like object using "rb" mode --> open(filename, "rb").
text
The string representation of the markdown document.
url
The URL of a webpage to parse. Only for URLs that return a markdown document.
metadata_last_modified
The last modified date for the document.
languages
The languages present in the document. Use ``["auto"]`` to detect (default when None).
Use ``[""]`` to disable language detection.
Other keyword arguments are forwarded to ``partition_html``. In addition, ``extensions`` may be
passed to ``markdown.markdown()`` as a list of registered extension names (``str``) and/or
configured ``markdown.extensions.Extension`` instances. The default is
``["tables", "fenced_code"]``. Pass e.g. ``extensions=["tables"]`` if you need the legacy
behavior where ``#`` inside unfenced content is parsed as a heading (see #4006).
"""
if text is None:
text = ""
# -- verify that only one of the arguments was provided --
exactly_one(filename=filename, file=file, text=text, url=url)
last_modified = get_last_modified_date(filename) if filename else None
if filename is not None:
_, text = read_txt_file(filename=filename)
elif file is not None:
_, text = read_txt_file(file=file)
elif url is not None:
response = safe_get(url)
if not response.ok:
raise ValueError(f"URL return an error: {response.status_code}")
content_type = response.headers.get("Content-Type", "")
if not content_type.startswith("text/markdown"):
raise ValueError(
f"Expected content type text/markdown. Got {content_type}.",
)
text = response.text
# -- optional markdown extensions; default matches historical partition_md behavior --
extensions = _validate_markdown_extensions(
kwargs.pop("extensions", _DEFAULT_MARKDOWN_EXTENSIONS)
)
html = markdown.markdown(text, extensions=extensions)
html_kwargs: dict[str, Any] = {
"text": html,
"metadata_filename": metadata_filename or filename,
"metadata_file_type": FileType.MD,
"metadata_last_modified": metadata_last_modified or last_modified,
"detection_origin": DETECTION_ORIGIN,
**kwargs,
}
if languages is not None:
html_kwargs["languages"] = languages
return partition_html(**html_kwargs)
+16
View File
@@ -0,0 +1,16 @@
import os
from unstructured_inference.models.base import get_model
def initialize():
"""Download default model or model specified by UNSTRUCTURED_HI_RES_MODEL_NAME environment
variable (avoids subprocesses all doing the same)"""
# If more than one model will be supported and left up to user selection
supported_model = os.environ.get("UNSTRUCTURED_HI_RES_SUPPORTED_MODEL", "")
if supported_model:
for model_name in supported_model.split(","):
get_model(model_name=model_name)
get_model(os.environ.get("UNSTRUCTURED_HI_RES_MODEL_NAME"))
+323
View File
@@ -0,0 +1,323 @@
from __future__ import annotations
import os
import re
import tempfile
from functools import cached_property
from typing import IO, Any, Iterator, Optional
from oxmsg import Message
from oxmsg.attachment import Attachment
from unstructured.documents.elements import Element, ElementMetadata
from unstructured.file_utils.model import FileType
from unstructured.logger import logger
from unstructured.partition.common import EXPECTED_ATTACHMENT_ERRORS
from unstructured.partition.common.metadata import get_last_modified_date
from unstructured.partition.html import partition_html
from unstructured.partition.text import partition_text
from unstructured.utils import is_temp_file_path
def partition_msg(
filename: Optional[str] = None,
*,
file: Optional[IO[bytes]] = None,
metadata_filename: Optional[str] = None,
metadata_last_modified: Optional[str] = None,
process_attachments: bool = True,
**kwargs: Any,
) -> list[Element]:
"""Partitions a MSFT Outlook .msg file
Parameters
----------
filename
A string defining the target filename path.
file
A file-like object using "rb" mode --> open(filename, "rb").
metadata_filename
The filename to use for the metadata.
metadata_last_modified
The last modified date for the document.
process_attachments
If True, partition_email will process email attachments in addition to
processing the content of the email itself.
"""
opts = MsgPartitionerOptions(
file=file,
file_path=filename,
metadata_file_path=metadata_filename,
metadata_last_modified=metadata_last_modified,
partition_attachments=process_attachments,
kwargs=kwargs,
)
return list(_MsgPartitioner.iter_message_elements(opts))
class MsgPartitionerOptions:
"""Encapsulates partitioning option validation, computation, and application of defaults."""
def __init__(
self,
*,
file: IO[bytes] | None,
file_path: str | None,
metadata_file_path: str | None,
metadata_last_modified: str | None,
partition_attachments: bool,
kwargs: dict[str, Any],
):
self._file = file
self._file_path = file_path
self._metadata_file_path = metadata_file_path
self._metadata_last_modified = metadata_last_modified
self._partition_attachments = partition_attachments
self._kwargs = kwargs
@cached_property
def extra_msg_metadata(self) -> ElementMetadata:
"""ElementMetadata suitable for use on an element formed from message content.
These are only the metadata fields specific to email messages. The remaining metadata
fields produced by the delegate partitioner are used as produced.
None of these metadata fields change based on the element, so we just compute it once.
"""
msg = self.msg
sent_from = [s.strip() for s in sender.split(",")] if (sender := msg.sender) else None
sent_to = [r.email_address for r in msg.recipients] or None
bcc_recipient = (
[c.strip() for c in bcc.split(",")] if (bcc := msg.message_headers.get("Bcc")) else None
)
cc_recipient = (
[c.strip() for c in cc.split(",")] if (cc := msg.message_headers.get("Cc")) else None
)
if email_message_id := msg.message_headers.get("Message-Id"):
email_message_id = re.sub(r"^<|>$", "", email_message_id) # Strip angle brackets
element_metadata = ElementMetadata(
bcc_recipient=bcc_recipient,
cc_recipient=cc_recipient,
email_message_id=email_message_id,
sent_from=sent_from,
sent_to=sent_to,
subject=msg.subject or None,
)
element_metadata.detection_origin = "msg"
return element_metadata
@cached_property
def is_encrypted(self) -> bool:
"""True when message is encrypted."""
# NOTE(robinson) - Per RFC 2015, the content type for emails with PGP encrypted content
# is multipart/encrypted (ref: https://www.ietf.org/rfc/rfc2015.txt)
# NOTE(scanny) - pretty sure we're going to want to dig deeper to discover messages that are
# encrypted with something other than PGP.
# - might be able to distinguish based on PID_MESSAGE_CLASS = 'IPM.Note.Signed'
# - Content-Type header might include "application/pkcs7-mime" for Microsoft S/MIME
# encryption.
return "encrypted" in self.msg.message_headers.get("Content-Type", "")
@cached_property
def metadata_file_path(self) -> str | None:
"""Best available path for MSG file.
The value is the caller supplied `metadata_filename` if present, falling back to the
source file-path if that was provided, otherwise `None`.
"""
return self._metadata_file_path or self._file_path
@cached_property
def metadata_last_modified(self) -> str | None:
"""Caller override for `.metadata.last_modified` to be applied to all elements."""
email_date = sent_date.isoformat() if (sent_date := self.msg.sent_date) else None
return self._metadata_last_modified or email_date or self._last_modified
@cached_property
def msg(self) -> Message:
"""The `oxmsg.Message` object loaded from file or filename."""
return Message.load(self._msg_file)
@cached_property
def partition_attachments(self) -> bool:
"""True when message attachments should also be partitioned."""
return self._partition_attachments
@cached_property
def partitioning_kwargs(self) -> dict[str, Any]:
"""The "extra" keyword arguments received by `partition_msg()`.
These are passed along to delegate partitioners which extract keyword args like
`chunking_strategy` etc. in their decorators to control metadata behaviors, etc.
"""
return self._kwargs
@cached_property
def _last_modified(self) -> str | None:
"""The best last-modified date available from source-file, None if not available."""
if not self._file_path or is_temp_file_path(self._file_path):
return None
return get_last_modified_date(self._file_path)
@cached_property
def _msg_file(self) -> str | IO[bytes]:
"""The source for the bytes of the message, either a file-path or a file-like object."""
if file_path := self._file_path:
return file_path
if file := self._file:
return file
raise ValueError("one of `file` or `filename` arguments must be provided")
class _MsgPartitioner:
"""Partitions Outlook email message (MSG) files."""
def __init__(self, opts: MsgPartitionerOptions):
self._opts = opts
@classmethod
def iter_message_elements(cls, opts: MsgPartitionerOptions) -> Iterator[Element]:
"""Partition MS Outlook email messages (.msg files) into elements."""
if opts.is_encrypted:
logger.warning("Encrypted email detected. Partitioner will return an empty list.")
return
yield from cls(opts)._iter_message_elements()
def _iter_message_elements(self) -> Iterator[Element]:
"""Partition MS Outlook email messages (.msg files) into elements."""
yield from self._iter_message_body_elements()
if not self._opts.partition_attachments:
return
for attachment in self._attachments:
yield from _AttachmentPartitioner.iter_elements(attachment, self._opts)
@cached_property
def _attachments(self) -> tuple[Attachment, ...]:
"""The `oxmsg.attachment.Attachment` objects for this message."""
return tuple(self._opts.msg.attachments)
def _iter_message_body_elements(self) -> Iterator[Element]:
"""Partition the message body (but not the attachments)."""
msg = self._opts.msg
if html_body := msg.html_body:
elements = partition_html(
text=html_body,
metadata_filename=self._opts.metadata_file_path,
metadata_file_type=FileType.MSG,
metadata_last_modified=self._opts.metadata_last_modified,
**self._opts.partitioning_kwargs,
)
elif msg.body:
elements = partition_text(
text=msg.body,
metadata_filename=self._opts.metadata_file_path,
metadata_file_type=FileType.MSG,
metadata_last_modified=self._opts.metadata_last_modified,
**self._opts.partitioning_kwargs,
)
else:
elements: list[Element] = []
# -- augment the element metadata with email-specific values --
email_specific_metadata = self._opts.extra_msg_metadata
for e in elements:
e.metadata.update(email_specific_metadata)
yield e
class _AttachmentPartitioner:
"""Partitions an attachment to a MSG file."""
def __init__(self, attachment: Attachment, opts: MsgPartitionerOptions):
self._attachment = attachment
self._opts = opts
@classmethod
def iter_elements(
cls, attachment: Attachment, opts: MsgPartitionerOptions
) -> Iterator[Element]:
"""Partition an `oxmsg.attachment.Attachment` from an Outlook email message (.msg file)."""
return cls(attachment, opts)._iter_elements()
def _iter_elements(self) -> Iterator[Element]:
"""Partition the file in an `oxmsg.attachment.Attachment` into elements."""
from unstructured.partition.auto import partition
with tempfile.TemporaryDirectory() as tmp_dir_path:
# -- save attachment as file in this temporary directory --
detached_file_path = os.path.join(tmp_dir_path, self._attachment_file_name)
with open(detached_file_path, "wb") as f:
f.write(self._file_bytes)
# -- partition the attachment --
try:
elements = partition(
detached_file_path,
metadata_filename=self._attachment_file_name,
metadata_last_modified=self._attachment_last_modified,
**self._opts.partitioning_kwargs,
)
except BaseException as e:
if not isinstance(e, EXPECTED_ATTACHMENT_ERRORS):
raise
logger.warning(
"Skipping attachment %s: %s",
self._attachment_file_name,
f"{type(e).__name__}: {e}",
)
return
for e in elements:
e.metadata.attached_to_filename = self._opts.metadata_file_path
yield e
@cached_property
def _attachment_file_name(self) -> str:
"""The original name of the attached file, no path.
This value is 'unknown' if it is not present in the MSG file (not expected).
The filename is sanitized to prevent path traversal attacks.
"""
raw_filename = self._attachment.file_name or "unknown"
# Sanitize the filename to prevent path traversal attacks
# Remove any path components for both Unix and Windows paths
# Use both separators to handle cross-platform attacks
safe_filename = os.path.basename(raw_filename.replace("\\", "/"))
# Remove null bytes and other control characters
safe_filename = safe_filename.replace("\0", "")
# If the filename becomes empty after sanitization, use a default
if not safe_filename or safe_filename in (".", ".."):
safe_filename = "unknown"
return safe_filename
@cached_property
def _attachment_last_modified(self) -> str | None:
"""ISO8601 string timestamp of attachment last-modified date.
This value generally available on the attachment and will be the most reliable last-modifed
time. There are fallbacks for when it is not present, ultimately `None` if we have no way
of telling.
"""
if last_modified := self._attachment.last_modified:
return last_modified.isoformat()
return self._opts.metadata_last_modified
@cached_property
def _file_bytes(self) -> bytes:
"""The bytes of the attached file."""
return self._attachment.file_bytes or b""
+89
View File
@@ -0,0 +1,89 @@
"""Provides `partition_ndjson()`.
Note this does not partition arbitrary NDJSON. Its only use-case is to "rehydrate" unstructured
document elements serialized to JSON, essentially the same function as `elements_from_json()`, but
this allows a document of already-partitioned elements to be combined transparently with other
documents in a partitioning run. It also allows multiple (low-cost) chunking runs to be performed on
a document while only incurring partitioning cost once.
"""
from __future__ import annotations
import json
from typing import IO, Any, Optional
from unstructured.chunking import add_chunking_strategy
from unstructured.documents.elements import Element, process_metadata
from unstructured.file_utils.filetype import (
FileType,
add_metadata_with_filetype,
is_ndjson_processable,
)
from unstructured.file_utils.ndjson import loads as ndjson_loads
from unstructured.partition.common.common import exactly_one
from unstructured.partition.common.metadata import get_last_modified_date
from unstructured.staging.base import elements_from_dicts
@process_metadata()
@add_metadata_with_filetype(FileType.NDJSON)
@add_chunking_strategy
def partition_ndjson(
filename: Optional[str] = None,
file: Optional[IO[bytes]] = None,
text: Optional[str] = None,
metadata_last_modified: Optional[str] = None,
**kwargs: Any,
) -> list[Element]:
"""Partitions serialized Unstructured output into its constituent elements.
Parameters
----------
filename
A string defining the target filename path.
file
A file-like object as bytes --> open(filename, "rb").
text
The string representation of the .json document.
metadata_last_modified
The last modified date for the document.
"""
if text is not None and text.strip() == "" and not file and not filename:
return []
exactly_one(filename=filename, file=file, text=text)
last_modified = get_last_modified_date(filename) if filename else None
file_text = ""
if filename is not None:
with open(filename, encoding="utf8") as f:
file_text = f.read()
elif file is not None:
file_content = file.read()
file_text = file_content if isinstance(file_content, str) else file_content.decode()
file.seek(0)
elif text is not None:
file_text = str(text)
if not is_ndjson_processable(file_text=file_text):
raise ValueError(
"NDJSON cannot be partitioned. Schema does not match the Unstructured schema.",
)
try:
element_dicts = ndjson_loads(file_text)
elements = elements_from_dicts(element_dicts)
# if we found at least one json element, but no unstructured elements were found, throw 422
if len(element_dicts) > 0 and len(elements) == 0:
raise ValueError(
"JSON cannot be partitioned. Schema does not match the Unstructured schema.",
)
except json.JSONDecodeError:
raise ValueError("Not a valid ndjson")
for element in elements:
element.metadata.last_modified = metadata_last_modified or last_modified
return elements
+125
View File
@@ -0,0 +1,125 @@
from __future__ import annotations
import os
import tempfile
from typing import IO, Any, Optional, cast
from unstructured.documents.elements import Element
from unstructured.file_utils.model import FileType
from unstructured.partition.common.common import exactly_one
from unstructured.partition.common.metadata import get_last_modified_date
from unstructured.partition.docx import partition_docx
from unstructured.utils import requires_dependencies
def partition_odt(
filename: Optional[str] = None,
*,
file: Optional[IO[bytes]] = None,
metadata_filename: Optional[str] = None,
metadata_last_modified: Optional[str] = None,
**kwargs: Any,
) -> list[Element]:
"""Partitions Open Office Documents in .odt format into its document elements.
All parameters that are available on `partition_docx()` are also available here.
Parameters
----------
filename
A string defining the target filename path.
file
A file-like object using "rb" mode --> open(filename, "rb").
infer_table_structure
If True, any Table elements that are extracted will also have a metadata field
named "text_as_html" where the table's text content is rendered into an html string.
I.e., rows and cells are preserved.
Whether True or False, the "text" field is always present in any Table element
and is the text content of the table (no structure).
metadata_last_modified
The last modified date for the document.
languages
User defined value for `metadata.languages` if provided. Otherwise language is detected
using naive Bayesian filter via `langdetect`. Multiple languages indicates text could be
in either language.
Additional Parameters:
detect_language_per_element
Detect language per element instead of at the document level.
"""
last_modified = get_last_modified_date(filename) if filename else None
with tempfile.TemporaryDirectory() as target_dir:
docx_path = _convert_odt_to_docx(target_dir, filename, file)
elements = partition_docx(
filename=docx_path,
metadata_filename=metadata_filename or filename,
metadata_file_type=FileType.ODT,
metadata_last_modified=metadata_last_modified or last_modified,
**kwargs,
)
return elements
@requires_dependencies("pypandoc")
def _convert_odt_to_docx(
target_dir: str, filename: Optional[str], file: Optional[IO[bytes]]
) -> str:
"""Convert ODT document to DOCX returning the new .docx file's path.
Parameters
----------
target_dir
The str directory-path to use for conversion purposes. The new DOCX file is written to this
directory. When passed as a file-like object, a copy of the source file is written here as
well. It is the caller's responsibility to remove this directory and its contents when
they are no longer needed.
filename
A str file-path specifying the location of the source ODT file on the local filesystem.
file
A file-like object open for reading in binary mode ("rb" mode).
"""
exactly_one(filename=filename, file=file)
# -- validate file-path when provided so we can provide a more meaningful error than whatever
# -- would come from pandoc.
if filename is not None and not os.path.exists(filename):
raise ValueError(f"The file {filename} does not exist.")
# -- Pandoc is a command-line program running in its own memory-space. It can therefore only
# -- operate on files on the filesystem. If the source document was passed as `file`, write
# -- it to `target_dir/document.odt` and use that path as the source-path.
source_file_path = f"{target_dir}/document.odt" if file is not None else cast(str, filename)
if file is not None:
with open(source_file_path, "wb") as f:
f.write(file.read())
# -- Compute the path of the resulting .docx document. We want its file-name to be preserved
# -- if the source-document was provided as `filename`.
# -- a/b/foo.odt -> foo.odt --
file_name = os.path.basename(source_file_path)
# -- foo.odt -> foo --
base_name, _ = os.path.splitext(file_name)
# -- foo -> foo.docx --
target_docx_path = os.path.join(target_dir, f"{base_name}.docx")
import pypandoc
# Try with sandbox=True first for security. If it fails due to Pandoc's known limitation
# with DOCX output in sandbox mode (see jgm/pandoc#8128), fallback without sandbox only
# if ALLOW_PANDOC_NO_SANDBOX env var is set to "true".
try:
pypandoc.convert_file(
source_file_path, "docx", format="odt", outputfile=target_docx_path, sandbox=True
)
except RuntimeError:
allow_no_sandbox = os.environ.get("ALLOW_PANDOC_NO_SANDBOX", "").lower() == "true"
if allow_no_sandbox:
pypandoc.convert_file(
source_file_path, "docx", format="odt", outputfile=target_docx_path
)
else:
raise
return target_docx_path
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
from typing import IO, Any
from unstructured.documents.elements import Element
from unstructured.file_utils.file_conversion import convert_file_to_html_text_using_pandoc
from unstructured.file_utils.model import FileType
from unstructured.partition.common.common import exactly_one
from unstructured.partition.common.metadata import get_last_modified_date
from unstructured.partition.html import partition_html
DETECTION_ORIGIN: str = "org"
def partition_org(
filename: str | None = None,
*,
file: IO[bytes] | None = None,
metadata_filename: str | None = None,
metadata_last_modified: str | None = None,
**kwargs: Any,
) -> list[Element]:
"""Partitions an org document. The document is first converted to HTML and then
partitioned using partition_html.
Parameters
----------
filename
A string defining the target filename path.
file
A file-like object using "rb" mode --> open(filename, "rb").
metadata_last_modified
The last modified date for the document.
"""
exactly_one(filename=filename, file=file)
last_modified = get_last_modified_date(filename) if filename else None
html_text = convert_file_to_html_text_using_pandoc(
source_format="org", filename=filename, file=file
)
return partition_html(
text=html_text,
metadata_filename=metadata_filename or filename,
metadata_file_type=FileType.ORG,
metadata_last_modified=metadata_last_modified or last_modified,
detection_origin=DETECTION_ORIGIN,
**kwargs,
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,712 @@
import logging
import math
import tempfile
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum
from io import BytesIO
from pathlib import Path
from typing import Any, Generator, List, Optional, TypeVar, Union
import numpy as np
from matplotlib import colors, font_manager
from numba import njit
from PIL import Image, ImageDraw, ImageFont
from unstructured_inference.constants import ElementType
from unstructured.partition.pdf_image.analysis.processor import AnalysisProcessor
from unstructured.partition.pdf_image.pdf_image_utils import convert_pdf_to_image
PageImage = TypeVar("PageImage", Image.Image, np.ndarray)
def get_font():
preferred_fonts = ["Arial.ttf"]
available_fonts = font_manager.findSystemFonts()
if not available_fonts:
raise ValueError("No fonts available")
for font in preferred_fonts:
for available_font in available_fonts:
if font in available_font:
return available_font
return available_fonts[0]
COLOR_WHITE = ("white", (255, 255, 255))
COLOR_BLACK = ("black", (0, 0, 0))
class TextAlignment(Enum):
TOP_LEFT = "top_left"
TOP_RIGHT = "top_right"
BOTTOM_LEFT = "bottom_left"
BOTTOM_RIGHT = "bottom_right"
CENTER = "center"
@dataclass
class BboxLabels:
top_left: Optional[str] = None
top_right: Optional[str] = None
bottom_left: Optional[str] = None
bottom_right: Optional[str] = None
center: Optional[str] = None
@dataclass
class BBox:
points: tuple[int, int, int, int]
labels: Optional[BboxLabels] = None
def get_rgb_color(color: str) -> tuple[int, int, int]:
"""Convert a color name to RGB values.
Args:
color: A color name supported by matplotlib.
Returns:
A tuple of three integers representing the RGB values of the color.
"""
try:
rgb_colors = colors.to_rgb(color)
except ValueError:
print("Error")
raise
return int(rgb_colors[0] * 255), int(rgb_colors[1] * 255), int(rgb_colors[2] * 255)
@njit(cache=True, fastmath=True)
def _get_bbox_to_page_ratio(bbox: tuple[int, int, int, int], page_size: tuple[int, int]) -> float:
"""Compute the ratio of the bounding box to the page size.
Args:
bbox: Tuple containing coordinates of the bbox: (x1, y1, x2, y2).
page_size: Tuple containing page size: (width, height).
Returns:
The ratio of the bounding box to the page size.
"""
x1, y1, x2, y2 = bbox
page_width, page_height = page_size
page_diagonal = math.sqrt(page_height**2 + page_width**2)
bbox_width = x2 - x1
bbox_height = y2 - y1
bbox_diagonal = math.sqrt(bbox_height**2 + bbox_width**2)
return bbox_diagonal / page_diagonal
def _get_optimal_value_for_bbox(
bbox: tuple[int, int, int, int],
page_size: tuple[int, int],
min_value: int,
max_value: int,
ratio_for_min_value: float = 0.01,
ratio_for_max_value: float = 0.5,
) -> int:
"""Compute the optimal value for a given bounding box using a linear function
generated for given min and max values and ratios
Args:
bbox: Tuple containing coordinates of the bbox: (x1, y1, x2, y2).
page_size: Tuple containing page size: (width, height).
min_value: The minimum value returned by the function.
max_value: The maximum value returned by the function.
ratio_for_min_value: The ratio of the bbox to page size for the min value.
ratio_for_max_value: The ratio of the bbox to page size for the max value.
Returns:
The optimal value for the given bounding box and parameters given.
"""
bbox_to_page_ratio = _get_bbox_to_page_ratio(bbox, page_size)
slope, intercept = _linear_polyfit_2point(
ratio_for_min_value, ratio_for_max_value, min_value, max_value
)
value = int(bbox_to_page_ratio * slope + intercept)
return max(min_value, min(max_value, value))
def get_bbox_text_size(
bbox: tuple[int, int, int, int],
page_size: tuple[int, int],
min_font_size: int = 16,
max_font_size: int = 32,
) -> int:
"""Compute the optimal font size for a given bounding box.
Args:
bbox: Tuple containing coordinates of the bbox: (x1, y1, x2, y2).
page_size: Tuple containing page size: (width, height).
min_font_size: The minimum font size returned by the function.
max_font_size: The maximum font size returned by the function.
Returns:
The optimal font size for the given bounding box.
"""
return _get_optimal_value_for_bbox(
bbox=bbox,
page_size=page_size,
min_value=min_font_size,
max_value=max_font_size,
)
def get_bbox_thickness(
bbox: tuple[int, int, int, int],
page_size: tuple[int, int],
min_thickness: int = 1,
max_thickness: int = 4,
) -> float:
"""Compute the optimal thickness for a given bounding box.
Args:
bbox: Tuple containing coordinates of the bbox: (x1, y1, x2, y2).
page_size: Tuple containing page size: (width, height).
min_thickness: The minimum font size returned by the function.
max_thickness: The maximum font size returned by the function.
Returns:
"""
return _get_optimal_value_for_bbox(
bbox=bbox,
page_size=page_size,
min_value=min_thickness,
max_value=max_thickness,
)
def get_text_color(
background_color: Union[str, tuple[int, int, int]], brightness_threshold: float = 0.5
) -> tuple[str, tuple[int, int, int]]:
"""Returns the contrastive text color (black or white) for a given background color.
Args:
background_color: Tuple containing RGB values of the background color.
Returns:
Tuple containing RGB values of the text color.
"""
if isinstance(background_color, str):
background_color = get_rgb_color(background_color)
background_brightness = (
0.299 * background_color[0] + 0.587 * background_color[1] + 0.114 * background_color[0]
) / 255
if background_brightness > brightness_threshold:
return COLOR_BLACK
else:
return COLOR_WHITE
def get_label_rect_and_coords(
alignment: TextAlignment,
bbox_points: tuple[int, int, int, int],
text_width: int,
text_height: int,
):
indent = max(int(text_width * 0.2), 10)
vertical_correction = max(int(text_height * 0.3), 10)
# with this the text should be centered in the rectangle
rect_width = text_width + indent * 2
# we apply a correction to the height to make the text not overlap over the rectangle
# (the text height getter looks to return too small value)
rect_height = text_height + vertical_correction
x1, y1, x2, y2 = bbox_points
if alignment is TextAlignment.CENTER:
# center:
horizontal_half = int(rect_width / 2 * 1.05)
vertical_half = int(rect_height / 2 * 1.05)
center_point = x1 + (x2 - x1) // 2, y1 + (y2 - y1) // 2
# resize rectangle to make it look better in the center
label_rectangle = (
(
center_point[0] - horizontal_half,
center_point[1] - vertical_half,
),
(
center_point[0] + horizontal_half,
center_point[1] + vertical_half,
),
)
label_coords = (
center_point[0] - horizontal_half + int(indent * 1.05),
center_point[1] - vertical_half * 1.05,
)
elif alignment is TextAlignment.TOP_LEFT:
label_rectangle = (
(x1, y1 - rect_height),
(x1 + rect_width, y1),
)
label_coords = (x1 + indent, y1 - rect_height)
elif alignment is TextAlignment.TOP_RIGHT:
label_rectangle = (
(x2 - rect_width, y1),
(x2, y1 + rect_height),
)
label_coords = (x2 - text_width - indent, y1)
elif alignment is TextAlignment.BOTTOM_LEFT:
label_rectangle = (
(x1, y2 - rect_height),
(x1 + rect_width, y2),
)
label_coords = (x1 + indent, y2 - rect_height)
elif alignment is TextAlignment.BOTTOM_RIGHT:
label_rectangle = (
(x2 - rect_width, y2 - rect_height),
(x2, y2),
)
label_coords = (x2 - text_width - indent, y2 - rect_height)
else:
raise ValueError(f"Unknown alignment {alignment}")
return label_rectangle, label_coords
def draw_bbox_label(
image_draw: ImageDraw.ImageDraw,
text: str,
bbox_points: tuple[int, int, int, int],
alignment: TextAlignment,
font_size: int,
background_color: str,
):
"""Draw a label stick to a bounding box.
The alignment parameter specifies where the label should be placed.
Args:
image_draw: ImageDraw object to draw on the image.
text: Text to draw.
bbox_points: Bounding box points.
alignment: Text alignment.
font_size: Font size of the text.
background_color: RGB values of the background color.
"""
font = ImageFont.truetype(get_font(), font_size)
text_x1, text_y1, text_x2, text_y2 = image_draw.textbbox(
(0, 0), text, font=font, align="center"
)
text_width = text_x2 - text_x1
text_height = text_y2 - text_y1
label_rectangle, label_coords = get_label_rect_and_coords(
alignment, bbox_points, text_width, text_height
)
rgb_background_color = get_rgb_color(background_color)
try:
image_draw.rectangle(
label_rectangle,
fill=background_color,
outline=background_color,
)
except TypeError:
image_draw.rectangle(
label_rectangle,
fill=rgb_background_color,
outline=rgb_background_color,
)
text_color, text_color_rgb = get_text_color(background_color)
try:
image_draw.text(label_coords, text, fill=text_color, font=font, align="center")
except TypeError:
image_draw.text(label_coords, text, fill=text_color_rgb, font=font, align="center")
def draw_bbox_on_image(
image_draw: ImageDraw.ImageDraw,
bbox: BBox,
color: str,
):
"""Draw bbox with additional labels on the image..
Args:
image_draw: ImageDraw object to draw on the image.
bbox: Bounding box to draw.
color: RGB values of the color of the bounding box (edges + label backgrounds).
"""
x1, y1, x2, y2 = bbox.points
if x1 >= x2 or y1 >= y2:
print(f"Invalid bbox coordinates: {bbox.points}")
return
top_left = x1, y1 # the main
bottom_right = x2, y2
box_thickness = get_bbox_thickness(bbox=bbox.points, page_size=image_draw.im.size)
font_size = get_bbox_text_size(bbox=bbox.points, page_size=image_draw.im.size)
try:
image_draw.rectangle((top_left, bottom_right), outline=color, width=box_thickness)
except TypeError:
rgb_color = get_rgb_color(color)
image_draw.rectangle((top_left, bottom_right), outline=rgb_color, width=box_thickness)
if bbox.labels is not None:
if top_left_label := bbox.labels.top_left:
draw_bbox_label(
image_draw,
top_left_label,
bbox_points=bbox.points,
alignment=TextAlignment.TOP_LEFT,
font_size=font_size,
background_color=color,
)
if top_right_label := bbox.labels.top_right:
draw_bbox_label(
image_draw,
top_right_label,
bbox_points=bbox.points,
alignment=TextAlignment.TOP_RIGHT,
font_size=font_size,
background_color=color,
)
if bottom_left_label := bbox.labels.bottom_left:
draw_bbox_label(
image_draw,
bottom_left_label,
bbox_points=bbox.points,
alignment=TextAlignment.BOTTOM_LEFT,
font_size=font_size,
background_color=color,
)
if bottom_right_label := bbox.labels.bottom_right:
draw_bbox_label(
image_draw,
bottom_right_label,
bbox_points=bbox.points,
alignment=TextAlignment.BOTTOM_RIGHT,
font_size=font_size,
background_color=color,
)
if center_label := bbox.labels.center:
draw_bbox_label(
image_draw,
center_label,
bbox_points=bbox.points,
alignment=TextAlignment.CENTER,
font_size=font_size * 2,
background_color=color,
)
@njit(cache=True, fastmath=True)
def _linear_polyfit_2point(x0: float, x1: float, y0: float, y1: float):
"""Compute slope and intercept for a line passing through (x0, y0), (x1, y1)."""
if x1 == x0:
slope = 0.0
intercept = (y0 + y1) / 2.0
else:
slope = (y1 - y0) / (x1 - x0)
intercept = y0 - slope * x0
return slope, intercept
class LayoutDrawer(ABC):
layout_source: str = "unknown"
laytout_dump: dict
def __init__(self, layout_dump: dict):
self.layout_dump = layout_dump
def draw_layout_on_page(self, page_image: Image.Image, page_num: int) -> Image.Image:
"""Draw the layout bboxes with additional metadata on the image."""
layout_pages = self.layout_dump.get("pages")
if not layout_pages:
print(f"Warning: layout in drawer {self.__class__.__name__} is empty - skipping")
return page_image
if len(layout_pages) < page_num:
print(f"Error! Page {page_num} not found in layout (pages: {len(layout_pages)})")
return page_image
image_draw = ImageDraw.ImageDraw(page_image)
page_layout_dump = layout_pages[page_num - 1]
if page_num != page_layout_dump.get("number"):
dump_page_num = page_layout_dump.get("number")
print(f"Warning: Requested page num {page_num} differs from dump {dump_page_num}")
for idx, elements in enumerate(page_layout_dump["elements"], 1):
self.render_element_on_page(idx, image_draw, elements)
return page_image
@abstractmethod
def render_element_on_page(self, idx: int, image_draw: ImageDraw, elements: dict[str, Any]):
"""Draw a single element on the image."""
class SimpleLayoutDrawer(LayoutDrawer, ABC):
color: str
show_order: bool = False
show_text_length: bool = False
def render_element_on_page(self, idx: int, image_draw: ImageDraw, elements: dict[str, Any]):
text_len = len(elements["text"]) if elements.get("text") else 0
element_prob = elements.get("prob")
element_order = f"{idx}" if self.show_order else None
text_len = f"len: {text_len}" if self.show_text_length else None
bbox = BBox(
points=elements["bbox"],
labels=BboxLabels(
top_right=f"prob: {element_prob:.2f}" if element_prob else None,
bottom_left=text_len,
center=element_order,
),
)
draw_bbox_on_image(image_draw, bbox, color=self.color)
class PdfminerLayoutDrawer(SimpleLayoutDrawer):
layout_source = "pdfminer"
def __init__(self, layout_dump: dict, color: str = "red"):
self.layout_dump = layout_dump
self.color = color
self.show_order = True
super().__init__(layout_dump)
class OCRLayoutDrawer(SimpleLayoutDrawer):
layout_source = "ocr"
def __init__(self, layout_dump: dict, color: str = "red"):
self.color = color
self.show_order = False
self.show_text_length = False
super().__init__(layout_dump)
class ODModelLayoutDrawer(LayoutDrawer):
layout_source = "od_model"
color_map = {
ElementType.CAPTION: "salmon",
ElementType.FOOTNOTE: "orange",
ElementType.FORMULA: "mediumpurple",
ElementType.LIST_ITEM: "navy",
ElementType.PAGE_FOOTER: "deeppink",
ElementType.PAGE_HEADER: "green",
ElementType.PICTURE: "sienna",
ElementType.SECTION_HEADER: "darkorange",
ElementType.TABLE: "blue",
ElementType.TEXT: "turquoise",
ElementType.TITLE: "greenyellow",
}
def render_element_on_page(self, idx: int, image_draw: ImageDraw, elements: dict[str, Any]):
element_type = elements["type"]
element_prob = elements.get("prob")
bbox_points = elements["bbox"]
color = self.get_element_type_color(element_type)
bbox = BBox(
points=bbox_points,
labels=BboxLabels(
top_left=f"{element_type}",
top_right=f"prob: {element_prob:.2f}" if element_prob else None,
),
)
draw_bbox_on_image(image_draw, bbox, color=color)
def get_element_type_color(self, element_type: str) -> str:
return self.color_map.get(element_type, "cyan")
class FinalLayoutDrawer(LayoutDrawer):
layout_source = "final"
color_map = {
"CheckBox": "brown",
"ListItem": "red",
"Title": "greenyellow",
"NarrativeText": "turquoise",
"Header": "green",
"Footer": "orange",
"FigureCaption": "sienna",
"Image": "sienna",
"Table": "blue",
"Address": "gold",
"EmailAddress": "lightskyblue",
"Formula": "mediumpurple",
"CodeSnippet": "magenta",
"PageNumber": "crimson",
}
def __init__(self, layout_dump: dict):
self.layout_dump = layout_dump
def render_element_on_page(self, idx: int, image_draw: ImageDraw, elements: dict[str, Any]):
element_type = elements["type"]
element_prob = elements.get("prob")
text_len = len(elements["text"]) if elements.get("text") else 0
bbox_points = elements["bbox"]
color = self.get_element_type_color(element_type)
cluster = elements.get("cluster")
bbox = BBox(
points=bbox_points,
labels=BboxLabels(
top_left=f"{element_type}",
top_right=f"prob: {element_prob:.2f}" if element_prob else None,
bottom_right=f"len: {text_len}",
bottom_left=f"cl: {cluster}" if cluster else None,
center=f"{idx}",
),
)
draw_bbox_on_image(image_draw, bbox, color=color)
def get_element_type_color(self, element_type: str) -> str:
return self.color_map.get(element_type, "cyan")
class AnalysisDrawer(AnalysisProcessor):
def __init__(
self,
filename: Optional[Union[str, Path]],
is_image: bool,
save_dir: Union[str, Path],
file: Optional[BytesIO] = None,
draw_caption: bool = True,
draw_grid: bool = False,
resize: Optional[float] = None,
format: str = "png",
):
self.draw_caption = draw_caption
self.draw_grid = draw_grid
self.resize = resize
self.is_image = is_image
self.format = format
self.drawers = []
self.file = file
super().__init__(filename, save_dir)
def add_drawer(self, drawer: LayoutDrawer):
self.drawers.append(drawer)
def process(self):
filename_stem = Path(self.filename).stem
analysis_save_dir = Path(self.save_dir) / "analysis" / filename_stem / "bboxes"
analysis_save_dir.mkdir(parents=True, exist_ok=True)
for page_idx, orig_image_page in enumerate(self.load_source_image()):
images_for_grid = []
page_num = page_idx + 1
for drawer in self.drawers:
try:
image = drawer.draw_layout_on_page(orig_image_page.copy(), page_num=page_num)
except: # noqa: E722
logging.exception(
f"Error while drawing layout for page {page_num} "
f"for file {self.filename} with drawer "
f"{drawer.__class__.__name__}"
)
continue
if self.draw_caption:
image = self.add_caption(
image, caption=f"Layout source: {drawer.layout_source}"
)
if not self.draw_grid:
if self.resize is not None:
image = image.resize(
(int(image.width * self.resize), int(image.height * self.resize)),
)
image.save(
analysis_save_dir / f"page{page_num}"
f"_layout_{drawer.layout_source}.{self.format}",
optimize=True,
quality=85,
)
image.close()
else:
images_for_grid.append(image)
if images_for_grid:
grid_image = self.paste_images_on_grid(images_for_grid)
if self.resize is not None:
grid_image = grid_image.resize(
(int(grid_image.width * self.resize), int(grid_image.height * self.resize))
)
grid_image.save(
analysis_save_dir / f"page{page_num}_layout_all.{self.format}",
optimize=True,
quality=85,
)
grid_image.close()
def add_caption(self, image: Image.Image, caption: str):
font = ImageFont.truetype(get_font(), 52)
draw = ImageDraw.ImageDraw(image)
text_x1, text_y1, text_x2, text_y2 = draw.textbbox(
(0, 0), caption, font=font, align="center"
)
text_width = text_x2 - text_x1
text_height = int((text_y2 - text_y1) * 1.5)
text_xy = (image.width - text_width) // 2, 10
caption_image = Image.new("RGB", (image.width, text_height), color=(255, 255, 255))
caption_draw = ImageDraw.ImageDraw(caption_image)
caption_draw.text(text_xy, caption, (0, 0, 0), font=font)
expanded_image = Image.new("RGB", (image.width, image.height + text_height))
expanded_image.paste(caption_image, (0, 0))
expanded_image.paste(image, (0, text_height))
image.close()
return expanded_image
def paste_images_on_grid(self, images: List[Image.Image]) -> Image.Image:
"""Creates a single image that presents all the images on a grid 2 x n/2"""
pairs = []
for i in range(0, len(images), 2):
left_image = images[i]
right_image = images[i + 1] if i < len(images) - 1 else None
pairs.append((left_image, right_image))
max_pair_width = max([pair[0].width + (pair[1].width if pair[1] else 0) for pair in pairs])
sum_height = sum([max(pair[0].height, pair[1].height if pair[1] else 0) for pair in pairs])
new_im = Image.new("RGB", (max_pair_width, sum_height))
height_shift = 0
for image_left, image_right in pairs:
new_im.paste(image_left, (0, height_shift))
if image_right:
new_im.paste(image_right, (image_left.width, height_shift))
height_shift += max(image_left.height, image_right.height if image_right else 0)
for image in images:
image.close()
return new_im
def load_source_image(self) -> Generator[Image.Image, None, None]:
with tempfile.TemporaryDirectory() as temp_dir:
image_paths = []
if self.is_image:
if self.file:
try:
image = Image.open(self.file)
output_file = Path(temp_dir) / self.filename
image.save(output_file, format="PNG")
image_paths = [output_file]
except Exception as ex: # noqa: E722
print(
f"Error while converting image to PNG for file {self.filename}, "
f"exception: {ex}"
)
else:
image_paths = [self.filename]
else:
try:
str_filename = (
""
if (self.filename is None or self.file is not None)
else (
str(self.filename) if isinstance(self.filename, Path) else self.filename
)
)
image_paths = convert_pdf_to_image(
filename=str_filename,
file=self.file,
output_folder=temp_dir,
path_only=True,
)
except Exception as ex: # noqa: E722
print(
f"Error while converting pdf to image for file {self.filename}",
f"exception: {ex}",
)
for image_path in image_paths:
with Image.open(str(image_path)) as image:
yield image.convert("RGB")
@@ -0,0 +1,203 @@
import json
from abc import ABC, abstractmethod
from collections import defaultdict
from pathlib import Path
from typing import List, Optional
from unstructured_inference.inference.elements import ImageTextRegion, TextRegion
from unstructured_inference.inference.layout import DocumentLayout
from unstructured_inference.models.base import get_model
from unstructured_inference.models.detectron2onnx import (
DEFAULT_LABEL_MAP as DETECTRON_LABEL_MAP,
)
from unstructured_inference.models.detectron2onnx import (
UnstructuredDetectronONNXModel,
)
from unstructured_inference.models.yolox import YOLOX_LABEL_MAP, UnstructuredYoloXModel
from unstructured.documents.elements import Element, Text
from unstructured.partition.pdf_image.analysis.processor import AnalysisProcessor
from unstructured.partition.utils.sorting import coordinates_to_bbox
class LayoutDumper(ABC):
layout_source: str = "unknown"
@abstractmethod
def dump(self) -> dict:
"""Transforms the results to a dict convertible structured formats like JSON or YAML"""
def extract_document_layout_info(layout: DocumentLayout) -> dict:
pages = []
for page in layout.pages:
size = {
"width": page.image_metadata.get("width"),
"height": page.image_metadata.get("height"),
}
elements = []
for element in page.elements:
bbox = element.bbox
elements.append(
{
"bbox": [bbox.x1, bbox.y1, bbox.x2, bbox.y2],
"type": element.type,
"prob": element.prob,
}
)
pages.append({"number": page.number, "size": size, "elements": elements})
return {"pages": pages}
def object_detection_classes(model_name) -> List[str]:
model = get_model(model_name)
if isinstance(model, UnstructuredYoloXModel):
return list(YOLOX_LABEL_MAP.values())
if isinstance(model, UnstructuredDetectronONNXModel):
return list(DETECTRON_LABEL_MAP.values())
else:
raise ValueError(f"Cannot get OD model classes - unknown model type: {model_name}")
class ObjectDetectionLayoutDumper(LayoutDumper):
"""Forms the results in COCO format and saves them to a file"""
layout_source = "object_detection"
def __init__(self, layout: DocumentLayout, model_name: Optional[str] = None):
self.layout: dict = extract_document_layout_info(layout)
self.model_name = model_name
def dump(self) -> dict:
"""Transforms the results to COCO format and saves them to a file"""
try:
classes_dict = {"object_detection_classes": object_detection_classes(self.model_name)}
except ValueError:
classes_dict = {"object_detection_classes": []}
self.layout.update(classes_dict)
return self.layout
def _get_info_from_extracted_page(page: List[TextRegion]) -> List[dict]:
elements = []
for element in page:
is_image = isinstance(element, ImageTextRegion)
bbox = element.bbox
elements.append(
{
"bbox": [bbox.x1, bbox.y1, bbox.x2, bbox.y2],
"text": element.text,
"source": str(element.source.value),
"is_image": is_image,
}
)
return elements
def extract_text_regions_info(layout: List[List[TextRegion]]) -> dict:
pages = []
for page_num, page in enumerate(layout, 1):
elements = _get_info_from_extracted_page(page)
pages.append({"number": page_num, "elements": elements})
return {"pages": pages}
class ExtractedLayoutDumper(LayoutDumper):
layout_source = "pdfminer"
def __init__(self, layout: List[List[TextRegion]]):
self.layout = extract_text_regions_info(layout)
def dump(self) -> dict:
return self.layout
class OCRLayoutDumper(LayoutDumper):
layout_source = "ocr"
def __init__(self):
self.layout = []
self.page_number = 1
def add_ocred_page(self, page: List[TextRegion]):
elements = _get_info_from_extracted_page(page)
self.layout.append({"number": self.page_number, "elements": elements})
self.page_number += 1
def dump(self) -> dict:
return {"pages": self.layout}
def _extract_final_element_info(element: Element) -> dict:
element_type = (
element.category if isinstance(element, Text) else str(element.__class__.__name__)
)
element_prob = getattr(element.metadata, "detection_class_prob", None)
text = element.text
bbox_points = coordinates_to_bbox(element.metadata.coordinates)
cluster = getattr(element.metadata, "cluster", None)
return {
"type": element_type,
"prob": element_prob,
"text": text,
"bbox": bbox_points,
"cluster": cluster,
}
def _extract_final_element_page_size(element: Element) -> dict:
try:
return {
"width": element.metadata.coordinates.system.width,
"height": element.metadata.coordinates.system.height,
}
except AttributeError:
return {
"width": None,
"height": None,
}
class FinalLayoutDumper(LayoutDumper):
layout_source = "final"
def __init__(self, layout: List[Element]):
pages = defaultdict(list)
for element in layout:
element_page_number = element.metadata.page_number
pages[element_page_number].append(_extract_final_element_info(element))
extracted_pages = [
{
"number": page_number,
"size": (
_extract_final_element_page_size(page_elements[0]) if page_elements else None
),
"elements": page_elements,
}
for page_number, page_elements in pages.items()
]
self.layout = {"pages": sorted(extracted_pages, key=lambda x: x["number"])}
def dump(self) -> dict:
return self.layout
class JsonLayoutDumper(AnalysisProcessor):
"""Dumps the results of the analysis to a JSON file"""
def __init__(self, filename: str, save_dir: str):
self.dumpers = []
super().__init__(filename, save_dir)
def add_layout_dumper(self, dumper: LayoutDumper):
self.dumpers.append(dumper)
def process(self):
filename_stem = Path(self.filename).stem
analysis_save_dir = Path(self.save_dir) / "analysis" / filename_stem / "layout_dump"
analysis_save_dir.mkdir(parents=True, exist_ok=True)
for dumper in self.dumpers:
results = dumper.dump()
with open(analysis_save_dir / f"{dumper.layout_source}.json", "w") as f:
f.write(json.dumps(results, indent=2))
@@ -0,0 +1,18 @@
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Union
class AnalysisProcessor(ABC):
def __init__(
self,
filename: Union[str, Path],
save_dir: Union[str, Path],
):
self.filename = filename
self.save_dir = save_dir
@abstractmethod
def process(self):
"""Performs the analysis and saves the results"""
raise NotImplementedError()
@@ -0,0 +1,190 @@
import json
import uuid
from io import BytesIO
from pathlib import Path
from typing import Optional
from unstructured.partition.pdf_image.analysis.bbox_visualisation import (
AnalysisDrawer,
FinalLayoutDrawer,
LayoutDrawer,
OCRLayoutDrawer,
ODModelLayoutDrawer,
PdfminerLayoutDrawer,
)
from unstructured.partition.pdf_image.analysis.layout_dump import (
ExtractedLayoutDumper,
FinalLayoutDumper,
JsonLayoutDumper,
LayoutDumper,
ObjectDetectionLayoutDumper,
OCRLayoutDumper,
)
def _get_drawer_for_dumper(dumper: LayoutDumper) -> Optional[LayoutDrawer]:
"""For a given layout dumper, return the corresponding layout drawer instance initialized with
a dumped layout dict.
Args:
dumper: The layout dumper instance
Returns:
LayoutDrawer: The corresponding layout drawer instance
"""
if isinstance(dumper, ObjectDetectionLayoutDumper):
return ODModelLayoutDrawer(layout_dump=dumper.dump())
elif isinstance(dumper, ExtractedLayoutDumper):
return PdfminerLayoutDrawer(layout_dump=dumper.dump())
elif isinstance(dumper, OCRLayoutDumper):
return OCRLayoutDrawer(layout_dump=dumper.dump())
elif isinstance(dumper, FinalLayoutDumper):
return FinalLayoutDrawer(layout_dump=dumper.dump())
else:
raise ValueError(f"Unknown dumper type: {dumper}")
def _generate_filename(is_image: bool):
"""Generate a filename for the analysis artifacts based on the file type.
Adds a random uuid suffix
"""
suffix = uuid.uuid4().hex[:5]
if is_image:
return f"image_{suffix}.png"
return f"pdf_{suffix}.pdf"
def save_analysis_artifiacts(
*layout_dumpers: LayoutDumper,
is_image: bool,
analyzed_image_output_dir_path: str,
filename: Optional[str] = None,
file: Optional[BytesIO] = None,
skip_bboxes: bool = False,
skip_dump_od: bool = False,
draw_grid: bool = False,
draw_caption: bool = True,
resize: Optional[float] = None,
format: str = "png",
):
"""Save the analysis artifacts for a given file. Loads some settings from
the environment configuration.
Args:
layout_dumpers: The layout dumpers to save and use for bboxes rendering
is_image: Flag for the file type (pdf/image)
analyzed_image_output_dir_path: The directory to save the analysis artifacts
filename: The filename of the sources analyzed file (pdf/image).
Only one of filename or file should be provided.
file: The file object for the analyzed file.
Only one of filename or file should be provided.
draw_grid: Flag for drawing the analysis bboxes on a single image (as grid)
draw_caption: Flag for drawing the caption above the analyzed page (for e.g. layout source)
resize: Output image resize value. If not provided, the image will not be resized.
format: The format for analyzed pages with bboxes drawn on them. Default is 'png'.
"""
if not filename:
filename = _generate_filename(is_image)
if skip_bboxes or skip_dump_od:
return
output_path = Path(analyzed_image_output_dir_path)
output_path.mkdir(parents=True, exist_ok=True)
if not skip_dump_od:
json_layout_dumper = JsonLayoutDumper(
filename=filename,
save_dir=output_path,
)
for layout_dumper in layout_dumpers:
json_layout_dumper.add_layout_dumper(layout_dumper)
json_layout_dumper.process()
if not skip_bboxes:
analysis_drawer = AnalysisDrawer(
filename=filename,
file=file,
is_image=is_image,
save_dir=output_path,
draw_grid=draw_grid,
draw_caption=draw_caption,
resize=resize,
format=format,
)
for layout_dumper in layout_dumpers:
drawer = _get_drawer_for_dumper(layout_dumper)
analysis_drawer.add_drawer(drawer)
analysis_drawer.process()
def render_bboxes_for_file(
filename: str,
analyzed_image_output_dir_path: str,
renders_output_dir_path: Optional[str] = None,
draw_grid: bool = False,
draw_caption: bool = True,
resize: Optional[float] = None,
format: str = "png",
):
"""Render the bounding boxes for a given layout dimp file.
To be used for analysis after the partition is performed for
only dumping the layouts - the bboxes can be rendered later.
Expects that the analyzed_image_output_dir_path keeps the structure
that was created by the save_analysis_artifacts function.
Args:
filename: The filename of the sources analyzed file (pdf/image)
analyzed_image_output_dir_path: The directory where the analysis artifacts
(layout dumps) are saved. It should be the root directory of the structure
created by the save_analysis_artifacts function.
renders_output_dir_path: Optional directory to save the rendered bboxes -
if not provided, it will be saved in the analysis directory.
draw_grid: Flag for drawing the analysis bboxes on a single image (as grid)
draw_caption: Flag for drawing the caption above the analyzed page (for e.g. layout source)
resize: Output image resize value. If not provided, the image will not be resized.
format: The format for analyzed pages with bboxes drawn on them. Default is 'png'.
"""
filename_stem = Path(filename).stem
is_image = not Path(filename).suffix.endswith("pdf")
analysis_dumps_dir = (
Path(analyzed_image_output_dir_path) / "analysis" / filename_stem / "layout_dump"
)
if not analysis_dumps_dir.exists():
return
layout_drawers = []
for analysis_dump_filename in analysis_dumps_dir.iterdir():
if not analysis_dump_filename.is_file():
continue
with open(analysis_dump_filename) as f:
layout_dump = json.load(f)
if analysis_dump_filename.stem == "final":
layout_drawers.append(FinalLayoutDrawer(layout_dump=layout_dump))
if analysis_dump_filename.stem == "object_detection":
layout_drawers.append(ODModelLayoutDrawer(layout_dump=layout_dump))
if analysis_dump_filename.stem == "ocr":
layout_drawers.append(OCRLayoutDrawer(layout_dump=layout_dump))
if analysis_dump_filename.stem == "pdfminer":
layout_drawers.append(PdfminerLayoutDrawer(layout_dump=layout_dump))
if layout_drawers:
if not renders_output_dir_path:
output_path = (
Path(analyzed_image_output_dir_path) / "analysis" / filename_stem / "bboxes"
)
else:
output_path = Path(renders_output_dir_path)
output_path.mkdir(parents=True, exist_ok=True)
analysis_drawer = AnalysisDrawer(
filename=filename,
save_dir=output_path,
is_image=is_image,
draw_grid=draw_grid,
draw_caption=draw_caption,
resize=resize,
format=format,
)
for drawer in layout_drawers:
analysis_drawer.add_drawer(drawer)
analysis_drawer.process()
@@ -0,0 +1,15 @@
from __future__ import annotations
from typing import IO
from unstructured.documents.elements import Element, FormKeysValues
def run_form_extraction(
filename: str,
file: IO[bytes],
model_name: str,
elements: list[Element],
skip_table_regions: bool,
) -> list[FormKeysValues]:
raise NotImplementedError("Form extraction not yet available.")
@@ -0,0 +1,108 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
import numpy as np
from unstructured_inference.constants import Source
from unstructured_inference.inference.elements import TextRegion, TextRegions
from unstructured_inference.inference.layoutelement import (
LayoutElement,
LayoutElements,
partition_groups_from_regions,
)
from unstructured.documents.elements import ElementType
if TYPE_CHECKING:
from unstructured_inference.inference.elements import Rectangle
def build_text_region_from_coords(
x1: int | float,
y1: int | float,
x2: int | float,
y2: int | float,
text: Optional[str] = None,
source: Optional[Source] = None,
) -> TextRegion:
""""""
return TextRegion.from_coords(x1, y1, x2, y2, text=text, source=source)
def build_layout_element(
bbox: "Rectangle",
text: Optional[str] = None,
source: Optional[Source] = None,
element_type: Optional[str] = None,
) -> LayoutElement:
""""""
return LayoutElement(bbox=bbox, text=text, source=source, type=element_type)
def build_layout_elements_from_ocr_regions(
ocr_regions: TextRegions,
ocr_text: Optional[str] = None,
group_by_ocr_text: bool = False,
) -> LayoutElements:
"""
Get layout elements from OCR regions
"""
grouped_regions = []
if group_by_ocr_text:
text_sections = ocr_text.split("\n\n")
mask = np.ones(ocr_regions.texts.shape).astype(bool)
indices = np.arange(len(mask))
for text_section in text_sections:
regions = []
words = text_section.replace("\n", " ").split()
for i, text in enumerate(ocr_regions.texts[mask]):
if not words:
break
if text in words:
regions.append(indices[mask][i])
words.remove(text)
if not regions:
continue
mask[regions] = False
grouped_regions.append(ocr_regions.slice(regions))
else:
grouped_regions = partition_groups_from_regions(ocr_regions)
merged_regions = TextRegions.from_list([merge_text_regions(group) for group in grouped_regions])
return LayoutElements(
element_coords=merged_regions.element_coords,
texts=merged_regions.texts,
sources=merged_regions.sources,
element_class_ids=np.zeros(merged_regions.texts.shape),
element_class_id_map={0: ElementType.UNCATEGORIZED_TEXT},
)
def merge_text_regions(regions: TextRegions) -> TextRegion:
"""
Merge a list of TextRegion objects into a single TextRegion.
Parameters:
- group (TextRegions): A group of TextRegion objects to be merged.
Returns:
- TextRegion: A single merged TextRegion object.
"""
if not regions:
raise ValueError("The text regions to be merged must be provided.")
min_x1 = regions.x1.min().astype(float)
min_y1 = regions.y1.min().astype(float)
max_x2 = regions.x2.max().astype(float)
max_y2 = regions.y2.max().astype(float)
merged_text = " ".join([text for text in regions.texts if text])
# assumption is the regions has the same source
source = regions.sources[0]
return TextRegion.from_coords(min_x1, min_y1, max_x2, max_y2, merged_text, source)
+491
View File
@@ -0,0 +1,491 @@
from __future__ import annotations
import os
import tempfile
from typing import IO, TYPE_CHECKING, Any, List, Optional, cast
import numpy as np
# NOTE(yuming): Rename PIL.Image to avoid conflict with
# unstructured.documents.elements.Image
from PIL import Image as PILImage
from PIL import ImageSequence
from unstructured.documents.elements import ElementType
from unstructured.metrics.table.table_formats import SimpleTableCell
from unstructured.partition.common.lang import tesseract_to_paddle_language
from unstructured.partition.pdf_image.analysis.layout_dump import OCRLayoutDumper
from unstructured.partition.pdf_image.pdf_image_utils import convert_pdf_to_image, valid_text
from unstructured.partition.pdf_image.pdfminer_processing import (
aggregate_embedded_text_by_block,
bboxes1_is_almost_subregion_of_bboxes2,
)
from unstructured.partition.utils.config import env_config
from unstructured.partition.utils.constants import OCR_AGENT_PADDLE, OCR_AGENT_TESSERACT, OCRMode
from unstructured.partition.utils.ocr_models.ocr_interface import OCRAgent
from unstructured.utils import requires_dependencies
if TYPE_CHECKING:
from unstructured_inference.inference.elements import TextRegion, TextRegions
from unstructured_inference.inference.layout import DocumentLayout, PageLayout
from unstructured_inference.inference.layoutelement import LayoutElement, LayoutElements
from unstructured_inference.models.tables import UnstructuredTableTransformerModel
def process_data_with_ocr(
data: bytes | IO[bytes],
out_layout: "DocumentLayout",
extracted_layout: List[List["TextRegion"]],
is_image: bool = False,
infer_table_structure: bool = False,
ocr_agent: str = OCR_AGENT_TESSERACT,
ocr_languages: str = "eng",
ocr_mode: str = OCRMode.FULL_PAGE.value,
pdf_image_dpi: int = env_config.PDF_RENDER_DPI,
ocr_layout_dumper: Optional[OCRLayoutDumper] = None,
password: Optional[str] = None,
table_ocr_agent: str = OCR_AGENT_TESSERACT,
) -> "DocumentLayout":
"""
Process OCR data from a given data and supplement the output DocumentLayout
from unstructured_inference with ocr.
Parameters:
- data (Union[bytes, BinaryIO]): The input file data,
which can be either bytes or a BinaryIO object.
- out_layout (DocumentLayout): The output layout from unstructured-inference.
- is_image (bool, optional): Indicates if the input data is an image (True) or not (False).
Defaults to False.
- infer_table_structure (bool, optional): If true, extract the table content.
- ocr_languages (str, optional): The languages for OCR processing. Defaults to "eng" (English).
- ocr_mode (str, optional): The OCR processing mode, e.g., "entire_page" or "individual_blocks".
Defaults to "entire_page". If choose "entire_page" OCR, OCR processes the entire image
page and will be merged with the output layout. If choose "individual_blocks" OCR,
OCR is performed on individual elements by cropping the image.
- pdf_image_dpi (int, optional): DPI (dots per inch) for processing PDF images. Defaults to
env_config.PDF_RENDER_DPI's value.
- ocr_layout_dumper (OCRLayoutDumper, optional): The OCR layout dumper to save the OCR layout.
Returns:
DocumentLayout: The merged layout information obtained after OCR processing.
"""
data_bytes = data if isinstance(data, bytes) else data.read()
with tempfile.TemporaryDirectory() as tmp_dir_path:
tmp_file_path = os.path.join(tmp_dir_path, "tmp_file")
with open(tmp_file_path, "wb") as tmp_file:
tmp_file.write(data_bytes)
merged_layouts = process_file_with_ocr(
filename=tmp_file_path,
out_layout=out_layout,
extracted_layout=extracted_layout,
is_image=is_image,
infer_table_structure=infer_table_structure,
ocr_agent=ocr_agent,
ocr_languages=ocr_languages,
ocr_mode=ocr_mode,
pdf_image_dpi=pdf_image_dpi,
ocr_layout_dumper=ocr_layout_dumper,
password=password,
table_ocr_agent=table_ocr_agent,
)
return merged_layouts
@requires_dependencies("unstructured_inference")
def process_file_with_ocr(
filename: str,
out_layout: "DocumentLayout",
extracted_layout: List[TextRegions],
is_image: bool = False,
infer_table_structure: bool = False,
ocr_agent: str = OCR_AGENT_TESSERACT,
ocr_languages: str = "eng",
ocr_mode: str = OCRMode.FULL_PAGE.value,
pdf_image_dpi: int = env_config.PDF_RENDER_DPI,
ocr_layout_dumper: Optional[OCRLayoutDumper] = None,
password: Optional[str] = None,
table_ocr_agent: str = OCR_AGENT_TESSERACT,
) -> "DocumentLayout":
"""
Process OCR data from a given file and supplement the output DocumentLayout
from unstructured-inference with ocr.
Parameters:
- filename (str): The path to the input file, which can be an image or a PDF.
- out_layout (DocumentLayout): The output layout from unstructured-inference.
- extracted_layout (List[TextRegions]): a list of text regions extracted by pdfminer, one for
each page
- is_image (bool, optional): Indicates if the input data is an image (True) or not (False).
Defaults to False.
- infer_table_structure (bool, optional): If true, extract the table content.
- ocr_languages (str, optional): The languages for OCR processing. Defaults to "eng" (English).
- ocr_mode (str, optional): The OCR processing mode, e.g., "entire_page" or "individual_blocks".
Defaults to "entire_page". If choose "entire_page" OCR, OCR processes the entire image
page and will be merged with the output layout. If choose "individual_blocks" OCR,
OCR is performed on individual elements by cropping the image.
- pdf_image_dpi (int, optional): DPI (dots per inch) for processing PDF images. Defaults to
env_config.PDF_RENDER_DPI.
Returns:
DocumentLayout: The merged layout information obtained after OCR processing.
"""
from unstructured_inference.inference.layout import DocumentLayout
merged_page_layouts: list[PageLayout] = []
try:
if is_image:
with PILImage.open(filename) as images:
image_format = images.format
for i, image in enumerate(ImageSequence.Iterator(images)):
image = image.convert("RGB")
image.format = image_format
extracted_regions = extracted_layout[i] if i < len(extracted_layout) else None
merged_page_layout = supplement_page_layout_with_ocr(
page_layout=out_layout.pages[i],
image=image,
infer_table_structure=infer_table_structure,
ocr_agent=ocr_agent,
ocr_languages=ocr_languages,
ocr_mode=ocr_mode,
extracted_regions=extracted_regions,
ocr_layout_dumper=ocr_layout_dumper,
table_ocr_agent=table_ocr_agent,
)
merged_page_layouts.append(merged_page_layout)
return DocumentLayout.from_pages(merged_page_layouts)
else:
with tempfile.TemporaryDirectory() as temp_dir:
_image_paths = convert_pdf_to_image(
filename,
dpi=pdf_image_dpi,
output_folder=temp_dir,
path_only=True,
password=password,
)
image_paths = cast(List[str], _image_paths)
for i, image_path in enumerate(image_paths):
extracted_regions = extracted_layout[i] if i < len(extracted_layout) else None
with PILImage.open(image_path) as image:
merged_page_layout = supplement_page_layout_with_ocr(
page_layout=out_layout.pages[i],
image=image,
infer_table_structure=infer_table_structure,
ocr_agent=ocr_agent,
ocr_languages=ocr_languages,
ocr_mode=ocr_mode,
extracted_regions=extracted_regions,
ocr_layout_dumper=ocr_layout_dumper,
table_ocr_agent=table_ocr_agent,
)
merged_page_layouts.append(merged_page_layout)
return DocumentLayout.from_pages(merged_page_layouts)
except Exception as e:
if os.path.isdir(filename) or os.path.isfile(filename):
raise e
else:
raise FileNotFoundError(f'File "{filename}" not found!') from e
@requires_dependencies("unstructured_inference")
def supplement_page_layout_with_ocr(
page_layout: "PageLayout",
image: PILImage.Image,
infer_table_structure: bool = False,
ocr_agent: str = OCR_AGENT_TESSERACT,
ocr_languages: str = "eng",
ocr_mode: str = OCRMode.FULL_PAGE.value,
extracted_regions: Optional[TextRegions] = None,
ocr_layout_dumper: Optional[OCRLayoutDumper] = None,
table_ocr_agent: str = OCR_AGENT_TESSERACT,
) -> "PageLayout":
"""
Supplement an PageLayout with OCR results depending on OCR mode.
If mode is "entire_page", we get the OCR layout for the entire image and
merge it with PageLayout.
If mode is "individual_blocks", we find the elements from PageLayout
with no text and add text from OCR to each element.
"""
language = ocr_languages
if ocr_agent == OCR_AGENT_PADDLE:
language = tesseract_to_paddle_language(ocr_languages)
_ocr_agent = OCRAgent.get_instance(ocr_agent_module=ocr_agent, language=language)
if ocr_mode == OCRMode.FULL_PAGE.value:
ocr_layout = _ocr_agent.get_layout_from_image(image)
if ocr_layout_dumper:
ocr_layout_dumper.add_ocred_page(ocr_layout.as_list())
page_layout.elements_array = merge_out_layout_with_ocr_layout(
out_layout=page_layout.elements_array,
ocr_layout=ocr_layout,
)
elif ocr_mode == OCRMode.INDIVIDUAL_BLOCKS.value:
# individual block mode still keeps using the list data structure for elements instead of
# the vectorized page_layout.elements_array data structure
for i, text in enumerate(page_layout.elements_array.texts):
if text:
continue
padding = env_config.IMAGE_CROP_PAD
cropped_image = image.crop(
(
page_layout.elements_array.x1[i] - padding,
page_layout.elements_array.y1[i] - padding,
page_layout.elements_array.x2[i] + padding,
page_layout.elements_array.y2[i] + padding,
),
)
# Note(yuming): instead of getting OCR layout, we just need
# the text extraced from OCR for individual elements
text_from_ocr = _ocr_agent.get_text_from_image(cropped_image)
page_layout.elements_array.texts[i] = text_from_ocr
else:
raise ValueError(
"Invalid OCR mode. Parameter `ocr_mode` "
"must be set to `entire_page` or `individual_blocks`.",
)
# Note(yuming): use the OCR data from entire page OCR for table extraction
if infer_table_structure:
language = ocr_languages
if table_ocr_agent == OCR_AGENT_PADDLE:
language = tesseract_to_paddle_language(ocr_languages)
_table_ocr_agent = OCRAgent.get_instance(
ocr_agent_module=table_ocr_agent, language=language
)
from unstructured_inference.models import tables
tables.load_agent()
if tables.tables_agent is None:
raise RuntimeError("Unable to load table extraction agent.")
page_layout.elements_array = supplement_element_with_table_extraction(
elements=page_layout.elements_array,
image=image,
tables_agent=tables.tables_agent,
ocr_agent=_table_ocr_agent,
extracted_regions=extracted_regions,
)
return page_layout
@requires_dependencies("unstructured_inference")
def supplement_element_with_table_extraction(
elements: LayoutElements,
image: PILImage.Image,
tables_agent: "UnstructuredTableTransformerModel",
ocr_agent,
extracted_regions: Optional[TextRegions] = None,
) -> List["LayoutElement"]:
"""Supplement the existing layout with table extraction. Any Table elements
that are extracted will have a metadata fields "text_as_html" where
the table's text content is rendered into a html string and "table_as_cells"
with the raw table cells output from table agent if env_config.EXTRACT_TABLE_AS_CELLS is True
"""
from unstructured_inference.models.tables import cells_to_html
table_id = {v: k for k, v in elements.element_class_id_map.items()}.get(ElementType.TABLE)
if table_id is None:
# no table found in this page
return elements
table_ele_indices = np.where(elements.element_class_ids == table_id)[0]
table_elements = elements.slice(table_ele_indices)
padding = env_config.TABLE_IMAGE_CROP_PAD
for i, element_coords in enumerate(table_elements.element_coords):
cropped_image = image.crop(
(
element_coords[0] - padding,
element_coords[1] - padding,
element_coords[2] + padding,
element_coords[3] + padding,
),
)
table_tokens = get_table_tokens(
table_element_image=cropped_image,
ocr_agent=ocr_agent,
)
tatr_cells = tables_agent.predict(
cropped_image, ocr_tokens=table_tokens, result_format="cells"
)
# NOTE(christine): `tatr_cells == ""` means that the table was not recognized
text_as_html = "" if tatr_cells == "" else cells_to_html(tatr_cells)
elements.text_as_html[table_ele_indices[i]] = text_as_html
if env_config.EXTRACT_TABLE_AS_CELLS:
simple_table_cells = [
SimpleTableCell.from_table_transformer_cell(cell).to_dict() for cell in tatr_cells
]
elements.table_as_cells[table_ele_indices[i]] = simple_table_cells
return elements
def get_table_tokens(
table_element_image: PILImage.Image,
ocr_agent: OCRAgent,
) -> List[dict[str, Any]]:
"""Get OCR tokens from either paddleocr or tesseract"""
ocr_layout = ocr_agent.get_layout_from_image(image=table_element_image)
table_tokens = []
for i, text in enumerate(ocr_layout.texts):
table_tokens.append(
{
"bbox": [
ocr_layout.x1[i],
ocr_layout.y1[i],
ocr_layout.x2[i],
ocr_layout.y2[i],
],
"text": text,
# 'table_tokens' is a list of tokens
# Need to be in a relative reading order
"span_num": i,
"line_num": 0,
"block_num": 0,
}
)
return table_tokens
def merge_out_layout_with_ocr_layout(
out_layout: LayoutElements,
ocr_layout: TextRegions,
supplement_with_ocr_elements: bool = True,
subregion_threshold: float = env_config.OCR_LAYOUT_SUBREGION_THRESHOLD,
) -> LayoutElements:
"""
Merge the out layout with the OCR-detected text regions on page level.
This function iterates over each out layout element and aggregates the associated text from
the OCR layout using the specified threshold. The out layout's text attribute is then updated
with this aggregated text. If `supplement_with_ocr_elements` is `True`, the out layout will be
supplemented with the OCR layout.
"""
if len(out_layout) == 0 or len(ocr_layout) == 0:
# what if od model finds nothing but ocr finds something? should we use ocr output at all
# currently we require some kind of bounding box, from `out_layout` to aggreaget ocr
# results. Can we just use ocr bounding boxes (gonna be many but at least we save
# information)
return out_layout
invalid_text_indices = [i for i, text in enumerate(out_layout.texts) if not valid_text(text)]
out_layout.texts = out_layout.texts.astype(object)
for idx in invalid_text_indices:
out_layout.texts[idx], _ = aggregate_embedded_text_by_block(
target_region=out_layout.slice([idx]),
source_regions=ocr_layout,
subregion_threshold=subregion_threshold,
)
final_layout = (
supplement_layout_with_ocr_elements(out_layout, ocr_layout)
if supplement_with_ocr_elements
else out_layout
)
return final_layout
def aggregate_ocr_text_by_block(
ocr_layout: List["TextRegion"],
region: "TextRegion",
subregion_threshold: float = env_config.OCR_LAYOUT_SUBREGION_THRESHOLD,
) -> Optional[str]:
"""Extracts the text aggregated from the regions of the ocr layout that lie within the given
block."""
extracted_texts = []
for ocr_region in ocr_layout:
ocr_region_is_subregion_of_given_region = ocr_region.bbox.is_almost_subregion_of(
region.bbox,
subregion_threshold,
)
if ocr_region_is_subregion_of_given_region and ocr_region.text:
extracted_texts.append(ocr_region.text)
return " ".join(extracted_texts) if extracted_texts else ""
@requires_dependencies("unstructured_inference")
def supplement_layout_with_ocr_elements(
layout: LayoutElements,
ocr_layout: TextRegions,
subregion_threshold: float = env_config.OCR_LAYOUT_SUBREGION_THRESHOLD,
) -> LayoutElements:
"""
Supplement the existing layout with additional OCR-derived elements.
This function takes two lists: one list of pre-existing layout elements (`layout`)
and another list of OCR-detected text regions (`ocr_layout`). It identifies OCR regions
that are subregions of the elements in the existing layout and removes them from the
OCR-derived list. Then, it appends the remaining OCR-derived regions to the existing layout.
Parameters:
- layout (LayoutElements): A collection of existing layout elements in array structures
- ocr_layout (TextRegions): A collection of OCR-derived text regions in array structures
Returns:
- List[LayoutElement]: The final combined layout consisting of both the original layout
elements and the new OCR-derived elements.
Note:
- The function relies on `is_almost_subregion_of()` method to determine if an OCR region
is a subregion of an existing layout element.
- It also relies on `build_layout_elements_from_ocr_regions()` to convert OCR regions to
layout elements.
- The env_config `OCR_LAYOUT_SUBREGION_THRESHOLD` is used to specify the subregion matching
threshold.
"""
from unstructured_inference.inference.layoutelement import LayoutElements
from unstructured.partition.pdf_image.inference_utils import (
build_layout_elements_from_ocr_regions,
)
if len(layout) == 0:
if len(ocr_layout) == 0:
return layout
else:
ocr_regions_to_add = ocr_layout
else:
mask = ~bboxes1_is_almost_subregion_of_bboxes2(
ocr_layout.element_coords, layout.element_coords, subregion_threshold
).sum(axis=1).astype(bool)
# add ocr regions that are not covered by layout
ocr_regions_to_add = ocr_layout.slice(mask)
if len(ocr_regions_to_add):
ocr_elements_to_add = build_layout_elements_from_ocr_regions(ocr_regions_to_add)
final_layout = LayoutElements.concatenate([layout, ocr_elements_to_add])
else:
final_layout = layout
return final_layout
@@ -0,0 +1,439 @@
from __future__ import annotations
import base64
import os
import re
import tempfile
import unicodedata
from copy import deepcopy
from io import BytesIO
from pathlib import Path, PurePath
from typing import IO, TYPE_CHECKING, BinaryIO, Iterator, List, Optional, Tuple, Union, cast
import cv2
import numpy as np
import pdf2image
from PIL import Image
from unstructured_inference.inference.layout import convert_pdf_to_image as render_pdf_to_image
from unstructured_inference.inference.pdf_image import PdfRenderTooLargeError
from unstructured.documents.elements import ElementType
from unstructured.errors import UnprocessableEntityError
from unstructured.logger import logger
from unstructured.partition.common.common import convert_to_bytes, exactly_one
from unstructured.partition.utils.config import env_config
if TYPE_CHECKING:
from unstructured_inference.inference.elements import TextRegion
from unstructured_inference.inference.layout import DocumentLayout, PageLayout
from unstructured_inference.inference.layoutelement import LayoutElement
from unstructured.documents.elements import Element
def write_image(image: Union[Image.Image, np.ndarray], output_image_path: str):
"""
Write an image to a specified file path, supporting both PIL Image and numpy ndarray formats.
Parameters:
- image (Union[Image.Image, np.ndarray]): The image to be written, which can be in PIL Image
format or a numpy ndarray format.
- output_image_path (str): The path to which the image will be written.
Raises:
- ValueError: If the provided image type is neither PIL Image nor numpy ndarray.
Returns:
- None: The function writes the image to the specified path but does not return any value.
"""
if isinstance(image, Image.Image):
image.save(output_image_path)
elif isinstance(image, np.ndarray):
cv2.imwrite(output_image_path, image)
else:
raise ValueError("Unsupported Image Type")
def convert_pdf_to_image(
filename: str,
file: Optional[Union[bytes, BinaryIO]] = None,
dpi: Optional[int] = None,
output_folder: Optional[Union[str, PurePath]] = None,
path_only: bool = False,
password: Optional[str] = None,
) -> Union[List[Image.Image], List[str]]:
exactly_one(filename=filename, file=file)
if dpi is None:
dpi = env_config.PDF_RENDER_DPI
try:
return render_pdf_to_image(
filename=filename,
file=file,
dpi=dpi,
output_folder=output_folder,
path_only=path_only,
password=password,
pdf_render_max_pixels_per_page=env_config.PDF_RENDER_MAX_PIXELS_PER_PAGE,
)
except PdfRenderTooLargeError as exc:
raise UnprocessableEntityError(str(exc)) from exc
def pad_element_bboxes(
element: "LayoutElement",
padding: Union[int, float],
) -> "LayoutElement":
"""Increases (or decreases, if padding is negative) the size of the bounding
boxes of the element by extending the boundary outward (resp. inward)"""
out_element = deepcopy(element)
out_element.bbox.x1 -= padding
out_element.bbox.x2 += padding
out_element.bbox.y1 -= padding
out_element.bbox.y2 += padding
return out_element
def pad_bbox(
bbox: Tuple[float, float, float, float],
padding: Tuple[Union[int, float], Union[int, float]],
) -> Tuple[float, float, float, float]:
"""Pads a bounding box (bbox) by a specified horizontal and vertical padding."""
x1, y1, x2, y2 = bbox
h_padding, v_padding = padding
x1 -= h_padding
x2 += h_padding
y1 -= v_padding
y2 += v_padding
return x1, y1, x2, y2
def save_elements(
elements: List["Element"],
starting_page_number: int,
element_category_to_save: str,
pdf_image_dpi: int,
filename: str = "",
file: bytes | IO[bytes] | None = None,
is_image: bool = False,
extract_image_block_to_payload: bool = False,
output_dir_path: str | None = None,
password: Optional[str] = None,
):
"""
Saves specific elements from a PDF as images either to a directory or embeds them in the
element's payload.
This function processes a list of elements partitioned from a PDF file. For each element of
a specified category, it extracts and saves the image. The images can either be saved to
a specified directory or embedded into the element's payload as a base64-encoded string.
"""
# Determine the output directory path
if not extract_image_block_to_payload:
output_dir_path = output_dir_path or (
str(Path(env_config.GLOBAL_WORKING_PROCESS_DIR) / "figures")
if env_config.GLOBAL_WORKING_DIR_ENABLED
else str(Path.cwd() / "figures")
)
os.makedirs(output_dir_path, exist_ok=True)
with tempfile.TemporaryDirectory() as temp_dir:
if is_image:
if file is None:
image_paths = [filename]
else:
if isinstance(file, bytes):
file_data = file
else:
file.seek(0)
file_data = file.read()
tmp_file_path = os.path.join(temp_dir, "tmp_file")
with open(tmp_file_path, "wb") as tmp_file:
tmp_file.write(file_data)
image_paths = [tmp_file_path]
else:
_image_paths = convert_pdf_to_image(
filename,
file,
pdf_image_dpi,
output_folder=temp_dir,
path_only=True,
password=password,
)
image_paths = cast(List[str], _image_paths)
figure_number = 0
for el in elements:
if el.category != element_category_to_save:
continue
coordinates = el.metadata.coordinates
if not coordinates or not coordinates.points:
continue
points = coordinates.points
x1, y1 = points[0]
x2, y2 = points[2]
h_padding = env_config.EXTRACT_IMAGE_BLOCK_CROP_HORIZONTAL_PAD
v_padding = env_config.EXTRACT_IMAGE_BLOCK_CROP_VERTICAL_PAD
padded_bbox = cast(
Tuple[int, int, int, int], pad_bbox((x1, y1, x2, y2), (h_padding, v_padding))
)
# The page number in the metadata may have been offset
# by starting_page_number. Make sure we use the right
# value for indexing!
assert el.metadata.page_number
metadata_page_number = el.metadata.page_number
page_index = metadata_page_number - starting_page_number
figure_number += 1
try:
image_path = image_paths[page_index]
image = Image.open(image_path)
cropped_image = image.crop(padded_bbox)
# PNG images with transparency need to be converted before saving
if cropped_image.mode == "RGBA":
cropped_image = cropped_image.convert("RGB")
if extract_image_block_to_payload:
buffered = BytesIO()
cropped_image.save(buffered, format="JPEG")
img_base64 = base64.b64encode(buffered.getvalue())
img_base64_str = img_base64.decode()
el.metadata.image_base64 = img_base64_str
el.metadata.image_mime_type = "image/jpeg"
else:
basename = "table" if el.category == ElementType.TABLE else "figure"
assert output_dir_path
output_f_path = os.path.join(
output_dir_path,
f"{basename}-{metadata_page_number}-{figure_number}.jpg",
)
write_image(cropped_image, output_f_path)
# add image path to element metadata
el.metadata.image_path = output_f_path
except (ValueError, IOError):
logger.warning("Image Extraction Error: Skipping the failed image", exc_info=True)
def check_element_types_to_extract(
extract_image_block_types: Optional[List[str]],
) -> List[str]:
"""Check and normalize the provided list of element types to extract."""
if extract_image_block_types is None:
return []
if not isinstance(extract_image_block_types, list):
raise TypeError(
"The extract_image_block_types parameter must be a list of element types as strings, "
"ex. ['Table', 'Image']",
)
available_element_types = {e_type.lower(): e_type for e_type in ElementType.to_dict().values()}
normalized_extract_image_block_types = []
for el_type in extract_image_block_types:
normalized_el_type = available_element_types.get(
el_type.lower(), el_type.lower().capitalize()
)
if normalized_el_type not in available_element_types.values():
logger.warning(f"The requested type ({el_type}) doesn't match any available type")
normalized_extract_image_block_types.append(normalized_el_type)
return normalized_extract_image_block_types
def valid_text(text: str) -> bool:
"""a helper that determines if the text is valid ascii text"""
if not text:
return False
return "(cid:" not in text
def cid_ratio(text: str) -> float:
"""Gets ratio of unknown 'cid' characters extracted from text to all characters."""
if not is_cid_present(text):
return 0.0
cid_pattern = r"\(cid\:(\d+)\)"
unmatched, n_cid = re.subn(cid_pattern, "", text)
total = n_cid + len(unmatched)
return n_cid / total
def is_cid_present(text: str) -> bool:
"""Checks if a cid code is present in a text selection."""
if len(text) < len("(cid:x)"):
return False
return text.find("(cid:") != -1
def annotate_layout_elements_with_image(
inferred_page_layout: "PageLayout",
extracted_page_layout: Optional["PageLayout"],
output_dir_path: str,
output_f_basename: str,
page_number: int,
):
"""
Annotates a page image with both inferred and extracted layout elements.
This function takes the layout elements of a single page, either extracted from or inferred
for the document, and annotates them on the page image. It creates two separate annotated
images, one for each set of layout elements: 'inferred' and 'extracted'.
These annotated images are saved to a specified directory.
"""
layout_map = {"inferred": {"layout": inferred_page_layout, "color": "blue"}}
if extracted_page_layout:
layout_map["extracted"] = {"layout": extracted_page_layout, "color": "green"}
for label, layout_data in layout_map.items():
page_layout = layout_data.get("layout")
color = layout_data.get("color")
img = page_layout.annotate(colors=color)
output_f_path = os.path.join(
output_dir_path, f"{output_f_basename}_{page_number}_{label}.jpg"
)
write_image(img, output_f_path)
print(f"output_image_path: {output_f_path}")
def annotate_layout_elements(
inferred_document_layout: "DocumentLayout",
extracted_layout: List["TextRegion"],
filename: str,
output_dir_path: str,
pdf_image_dpi: int,
is_image: bool = False,
) -> None:
"""
Annotates layout elements on images extracted from a PDF or an image file.
This function processes a given document (PDF or image) and annotates layout elements based
on the inferred and extracted layout information.
It handles both PDF documents and standalone image files. For PDFs, it converts each page
into an image, whereas for image files, it processes the single image.
"""
from unstructured_inference.inference.layout import PageLayout
output_f_basename = os.path.splitext(os.path.basename(filename))[0]
images = []
try:
if is_image:
with Image.open(filename) as img:
img = img.convert("RGB")
images.append(img)
extracted_page_layout = None
if extracted_layout:
extracted_page_layout = PageLayout(
number=1,
image=img,
)
extracted_page_layout.elements = extracted_layout[0]
inferred_page_layout = inferred_document_layout.pages[0]
inferred_page_layout.image = img
annotate_layout_elements_with_image(
inferred_page_layout=inferred_document_layout.pages[0],
extracted_page_layout=extracted_page_layout,
output_dir_path=output_dir_path,
output_f_basename=output_f_basename,
page_number=1,
)
else:
with tempfile.TemporaryDirectory() as temp_dir:
_image_paths = convert_pdf_to_image(
filename,
dpi=pdf_image_dpi,
output_folder=temp_dir,
path_only=True,
)
image_paths = cast(List[str], _image_paths)
for i, image_path in enumerate(image_paths):
with Image.open(image_path) as img:
page_number = i + 1
extracted_page_layout = None
if extracted_layout:
extracted_page_layout = PageLayout(
number=page_number,
image=img,
)
extracted_page_layout.elements = extracted_layout[i]
inferred_page_layout = inferred_document_layout.pages[i]
inferred_page_layout.image = img
annotate_layout_elements_with_image(
inferred_page_layout=inferred_document_layout.pages[i],
extracted_page_layout=extracted_page_layout,
output_dir_path=output_dir_path,
output_f_basename=output_f_basename,
page_number=page_number,
)
except Exception as e:
if os.path.isdir(filename) or os.path.isfile(filename):
raise e
else:
raise FileNotFoundError(f'File "{filename}" not found!') from e
def convert_pdf_to_images(
filename: str = "",
file: Optional[bytes | IO[bytes]] = None,
chunk_size: int = 10,
password: Optional[str] = None,
) -> Iterator[Image.Image]:
# Convert a PDF in small chunks of pages at a time (e.g. 1-10, 11-20... and so on)
exactly_one(filename=filename, file=file)
if file is not None:
f_bytes = convert_to_bytes(file)
info = pdf2image.pdfinfo_from_bytes(f_bytes, userpw=password)
else:
f_bytes = None
info = pdf2image.pdfinfo_from_path(filename, userpw=password)
total_pages = info["Pages"]
for start_page in range(1, total_pages + 1, chunk_size):
end_page = min(start_page + chunk_size - 1, total_pages)
try:
chunk_images = render_pdf_to_image(
filename=filename if f_bytes is None else None,
file=f_bytes,
dpi=env_config.PDF_RENDER_DPI,
first_page=start_page,
last_page=end_page,
password=password,
pdf_render_max_pixels_per_page=env_config.PDF_RENDER_MAX_PIXELS_PER_PAGE,
)
except PdfRenderTooLargeError as exc:
raise UnprocessableEntityError(str(exc)) from exc
chunk_images = cast(List[Image.Image], chunk_images)
for image in chunk_images:
yield image
def remove_control_characters(text: str) -> str:
"""Removes control characters from text."""
# Replace newline character with a space
text = text.replace("\t", " ").replace("\n", " ")
# Remove other control characters
out_text = "".join(c for c in text if unicodedata.category(c)[0] != "C")
return out_text
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,536 @@
import os
import re
import tempfile
import zlib
from typing import BinaryIO, List, Mapping, Optional, Tuple, Union
from pdfminer import settings as pdfminer_settings
from pdfminer.cmapdb import CMap, CMapDB
from pdfminer.converter import PDFPageAggregator
from pdfminer.layout import LAParams, LTChar, LTContainer, LTImage, LTItem, LTTextLine
from pdfminer.pdffont import PDFCIDFont, PDFFontError
from pdfminer.pdfinterp import LITERAL_FONT, PDFPageInterpreter, PDFResourceManager
from pdfminer.pdfpage import PDFPage
from pdfminer.pdftypes import (
LITERALS_ASCII85_DECODE,
LITERALS_ASCIIHEX_DECODE,
LITERALS_FLATE_DECODE,
PDFStream,
resolve1,
)
from pdfminer.psexceptions import PSSyntaxError
from pdfminer.psparser import literal_name
from pydantic import BaseModel
from unstructured.logger import logger
from unstructured.partition.utils.config import env_config
from unstructured.utils import requires_dependencies
_RE_CIDRANGE_BLOCK = re.compile(rb"begincidrange\s+(.*?)\s+endcidrange", re.DOTALL)
_RE_CIDRANGE_ENTRY = re.compile(rb"<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s+(\d+)")
_RE_CIDCHAR_BLOCK = re.compile(rb"begincidchar\s+(.*?)\s+endcidchar", re.DOTALL)
_RE_CIDCHAR_ENTRY = re.compile(rb"<([0-9A-Fa-f]+)>\s+(\d+)")
_RE_WMODE = re.compile(rb"/WMode\s+(\d+)")
# Cap on decompressed CMap stream size to bound regex/parse cost before any mapping
# cap kicks in. Real-world embedded CMaps are typically a few hundred bytes; even
# large CJK CMaps are well under 100 KB.
_MAX_CMAP_STREAM_BYTES = 1_000_000
# Cap on total code-to-CID mappings to prevent malicious PDFs from causing excessive
# memory/CPU usage via huge begincidrange spans. 131072 covers all real-world fonts
# (single-byte: max 256, double-byte CJK: typically ~20-30K glyphs).
_MAX_CODE2CID_MAPPINGS = 131072
def _parse_embedded_cmap_stream(data: bytes) -> CMap:
"""Parse an embedded CMap stream into a CMap with a populated code2cid mapping.
pdfminer.six does not parse embedded Encoding CMap streams for CIDFonts — it only
looks up the CMap name in its predefined database. When a PDF (e.g. produced by
Prince XML) embeds a custom CMap as a stream, pdfminer silently falls back to an
empty CMap and all text using that font is lost.
This function parses the begincidrange/begincidchar sections from the raw CMap
stream and builds the code2cid dict that CMap.decode() uses. It also extracts
WMode (writing mode: 0=horizontal, 1=vertical) when present.
"""
if len(data) > _MAX_CMAP_STREAM_BYTES:
logger.warning(
"Embedded CMap stream too large (%d bytes, limit %d), skipping",
len(data),
_MAX_CMAP_STREAM_BYTES,
)
return CMap()
code2cid: dict[int, object] = {}
total_mappings = 0
for match in _RE_CIDRANGE_BLOCK.finditer(data):
entries = _RE_CIDRANGE_ENTRY.findall(match.group(1))
for start_hex, end_hex, cid_str in entries:
start_bytes = bytes.fromhex(start_hex.decode("ascii"))
end_bytes = bytes.fromhex(end_hex.decode("ascii"))
start_cid = int(cid_str)
code_len = len(start_bytes)
start_val = int.from_bytes(start_bytes, "big")
end_val = int.from_bytes(end_bytes, "big")
if end_val < start_val:
continue
range_size = end_val - start_val + 1
if total_mappings + range_size > _MAX_CODE2CID_MAPPINGS:
logger.warning(
"Embedded CMap would exceed %d mappings; discarding partial CMap",
_MAX_CODE2CID_MAPPINGS,
)
return CMap()
for i in range(range_size):
code_val = start_val + i
cid = start_cid + i
if code_len == 1:
code2cid[code_val] = cid
else:
code_bytes = code_val.to_bytes(code_len, "big")
d = code2cid
for b in code_bytes[:-1]:
if b not in d:
d[b] = {}
d = d[b] # type: ignore[assignment]
d[code_bytes[-1]] = cid
total_mappings += range_size
for match in _RE_CIDCHAR_BLOCK.finditer(data):
entries = _RE_CIDCHAR_ENTRY.findall(match.group(1))
for code_hex, cid_str in entries:
if total_mappings >= _MAX_CODE2CID_MAPPINGS:
logger.warning(
"Embedded CMap exceeded %d mappings; discarding partial CMap",
_MAX_CODE2CID_MAPPINGS,
)
return CMap()
code_bytes = bytes.fromhex(code_hex.decode("ascii"))
cid = int(cid_str)
if len(code_bytes) == 1:
code2cid[code_bytes[0]] = cid
else:
d = code2cid
for b in code_bytes[:-1]:
if b not in d:
d[b] = {}
d = d[b] # type: ignore[assignment]
d[code_bytes[-1]] = cid
total_mappings += 1
cmap = CMap()
cmap.code2cid = code2cid
wmode_match = _RE_WMODE.search(data)
if wmode_match and int(wmode_match.group(1)) != 0:
cmap.attrs["WMode"] = int(wmode_match.group(1))
return cmap
def _flate_decode_with_limit(data: bytes, limit: int) -> Optional[bytes]:
"""Decompress Flate data with a hard output size limit.
Returns None if the decompressed output would exceed `limit` bytes,
without fully materializing the result.
"""
decomp = zlib.decompressobj()
out = bytearray()
try:
out.extend(decomp.decompress(data, limit + 1))
if len(out) > limit or decomp.unconsumed_tail:
return None
out.extend(decomp.flush(limit + 1 - len(out)))
if len(out) > limit:
return None
except zlib.error:
return None
return bytes(out)
def _decode_pdfstream_with_limit(stream: PDFStream, max_decoded_bytes: int) -> Optional[bytes]:
"""Decode a PDFStream with a hard output size limit, without mutating the stream.
Unlike PDFStream.get_data(), this never assigns to stream.data or stream.rawdata.
Returns None if the decoded output exceeds the limit or uses an unsupported filter.
"""
raw = stream.get_rawdata()
if raw is None:
# Stream was already decoded elsewhere; check the cached result.
data = stream.data
if data is None or len(data) > max_decoded_bytes:
return None
return data
data = raw
if stream.decipher:
if stream.objid is None or stream.genno is None:
logger.debug("Encrypted CMap stream missing objid/genno; skipping")
return None
data = stream.decipher(stream.objid, stream.genno, data, stream.attrs)
for filt, params in stream.get_filters():
if filt in LITERALS_FLATE_DECODE:
result = _flate_decode_with_limit(data, max_decoded_bytes)
if result is None:
logger.warning(
"Embedded CMap stream exceeded %d bytes during decompression; skipping",
max_decoded_bytes,
)
return None
data = result
elif filt in LITERALS_ASCII85_DECODE:
from pdfminer.ascii85 import ascii85decode
data = ascii85decode(data)
elif filt in LITERALS_ASCIIHEX_DECODE:
from pdfminer.ascii85 import asciihexdecode
data = asciihexdecode(data)
else:
logger.debug("Unsupported embedded CMap filter %r; skipping", filt)
return None
if len(data) > max_decoded_bytes:
logger.warning(
"Embedded CMap stream exceeded %d bytes after filter; skipping",
max_decoded_bytes,
)
return None
return data
class CustomPDFCIDFont(PDFCIDFont):
"""A CIDFont subclass that handles embedded Encoding CMap streams at construction time.
pdfminer.six's PDFCIDFont.get_cmap_from_spec only looks up CMap names in its
predefined database. When a PDF embeds a custom CMap as a stream, pdfminer falls
back to an empty CMap and all text is lost.
This subclass overrides get_cmap_from_spec to parse the embedded stream when the
name lookup fails. Because this runs during __init__, all constructor-time state
(vertical mode, widths, displacements) is derived from the correct CMap.
"""
def get_cmap_from_spec(self, spec: Mapping, strict: bool):
cmap_name = self._get_cmap_name(spec, strict)
try:
return CMapDB.get_cmap(cmap_name)
except CMapDB.CMapNotFound as e:
encoding = resolve1(spec.get("Encoding"))
if isinstance(encoding, PDFStream):
try:
data = _decode_pdfstream_with_limit(encoding, _MAX_CMAP_STREAM_BYTES)
if data is not None:
cmap = _parse_embedded_cmap_stream(data)
if cmap.code2cid:
logger.debug(
"Parsed embedded CMap stream %r (%d code mappings)",
cmap_name,
len(cmap.code2cid),
)
return cmap
except (
ValueError,
KeyError,
TypeError,
UnicodeDecodeError,
zlib.error,
PSSyntaxError,
) as exc:
logger.warning(
"Failed to parse embedded CMap stream %r: %s",
cmap_name,
exc,
exc_info=True,
)
if strict:
raise PDFFontError(e) from e
return CMap()
class CustomPDFResourceManager(PDFResourceManager):
"""A resource manager that uses CustomPDFCIDFont for CID font construction.
This ensures embedded CMap streams are resolved during font construction,
not after, so all constructor-time state (WMode, widths) is correct.
"""
def get_font(self, objid, spec):
subtype = literal_name(spec["Subtype"]) if "Subtype" in spec else "Type1"
if subtype in ("CIDFontType0", "CIDFontType2"):
if objid and objid in self._cached_fonts:
return self._cached_fonts[objid]
if pdfminer_settings.STRICT and spec.get("Type") is not LITERAL_FONT:
raise PDFFontError("Type is not /Font")
font = CustomPDFCIDFont(self, spec)
if objid and self.caching:
self._cached_fonts[objid] = font
return font
return super().get_font(objid, spec)
class CustomPDFPageInterpreter(PDFPageInterpreter):
"""a custom pdfminer page interpreter that adds character render mode information to LTChar
object as `rendermode` attribute. This is intended to be used to detect invisible text."""
def _patch_current_chars_with_render_mode(self, start: int):
"""Add render_mode to LTChar objects added since index `start`."""
cur_item = getattr(self.device, "cur_item", None)
if not cur_item:
return
render_mode = self.textstate.render
for obj in getattr(cur_item, "_objs", ())[start:]:
if isinstance(obj, LTChar):
obj.rendermode = render_mode
def do_TJ(self, seq):
start = len(getattr(getattr(self.device, "cur_item", None), "_objs", ()))
super().do_TJ(seq)
self._patch_current_chars_with_render_mode(start)
class PDFMinerConfig(BaseModel):
line_overlap: Optional[float] = None
word_margin: Optional[float] = None
line_margin: Optional[float] = None
char_margin: Optional[float] = None
detect_vertical: Optional[bool] = None
def init_pdfminer(pdfminer_config: Optional[PDFMinerConfig] = None):
rsrcmgr = CustomPDFResourceManager()
laparams_kwargs = pdfminer_config.model_dump(exclude_none=True) if pdfminer_config else {}
laparams = LAParams(**laparams_kwargs)
device = PDFPageAggregator(rsrcmgr, laparams=laparams)
interpreter = CustomPDFPageInterpreter(rsrcmgr, device)
return device, interpreter
def extract_image_objects(parent_object: LTItem) -> List[LTImage]:
"""Recursively extracts image objects from a given parent object in a PDF document."""
objects = []
if isinstance(parent_object, LTImage):
objects.append(parent_object)
elif isinstance(parent_object, LTContainer):
for child in parent_object:
objects.extend(extract_image_objects(child))
return objects
def extract_text_objects(parent_object: LTItem) -> List[LTTextLine]:
"""Recursively extracts text objects from a given parent object in a PDF document."""
objects = []
if isinstance(parent_object, LTTextLine):
objects.append(parent_object)
elif isinstance(parent_object, LTContainer):
for child in parent_object:
objects.extend(extract_text_objects(child))
return objects
def rect_to_bbox(
rect: Tuple[float, float, float, float],
height: float,
) -> Tuple[float, float, float, float]:
"""
Converts a PDF rectangle coordinates (x1, y1, x2, y2) to a bounding box in the specified
coordinate system where the vertical axis is measured from the top of the page.
Args:
rect (Tuple[float, float, float, float]): A tuple representing a PDF rectangle
coordinates (x1, y1, x2, y2).
height (float): The height of the page in the specified coordinate system.
Returns:
Tuple[float, float, float, float]: A tuple representing the bounding box coordinates
(x1, y1, x2, y2) with the y-coordinates adjusted to be measured from the top of the page.
"""
x1, y2, x2, y1 = rect
y1 = height - y1
y2 = height - y2
return (x1, y1, x2, y2)
def _is_duplicate_char(char1: LTChar, char2: LTChar, threshold: float) -> bool:
"""Detect if two characters are duplicates caused by fake bold rendering.
Some PDF generators create bold text by rendering the same character twice at slightly
offset positions. This function detects such duplicates by checking if two characters
have the same text content and overlapping bounding boxes.
Key insight: Fake-bold duplicates OVERLAP significantly, while legitimate consecutive
identical letters (like "ll" in "skills") are ADJACENT with minimal/no overlap.
Args:
char1: First LTChar object.
char2: Second LTChar object.
threshold: Maximum pixel distance to consider as duplicate.
Returns:
True if char2 appears to be a duplicate of char1.
"""
# Must be the same character
if char1.get_text() != char2.get_text():
return False
# Calculate horizontal and vertical distances between character origins
x_diff = abs(char1.x0 - char2.x0)
y_diff = abs(char1.y0 - char2.y0)
# Characters must be very close in position
if x_diff >= threshold or y_diff >= threshold:
return False
# Additional check: Calculate bounding box overlap to distinguish
# fake-bold (high overlap) from legitimate doubles (low/no overlap)
# Get character widths and heights
char1_width = char1.x1 - char1.x0
char2_width = char2.x1 - char2.x0
# Calculate horizontal overlap
overlap_x_start = max(char1.x0, char2.x0)
overlap_x_end = min(char1.x1, char2.x1)
horizontal_overlap = max(0, overlap_x_end - overlap_x_start)
# Calculate overlap percentage relative to character width
avg_width = (char1_width + char2_width) / 2
overlap_ratio = horizontal_overlap / avg_width if avg_width > 0 else 0
# Fake-bold duplicates typically have >70% overlap
# Legitimate consecutive letters have <30% overlap (or none)
# Use configurable threshold (default 50%) to be conservative
overlap_ratio_threshold = env_config.PDF_CHAR_OVERLAP_RATIO_THRESHOLD
return overlap_ratio > overlap_ratio_threshold
def deduplicate_chars_in_text_line(text_line: LTTextLine, threshold: float) -> str:
"""Extract text from an LTTextLine with duplicate characters removed.
Some PDFs create bold text by rendering each character twice at slightly offset
positions. This function removes such duplicates by keeping only the first instance
when two identical characters appear at nearly the same position.
Args:
text_line: An LTTextLine object containing characters to extract.
threshold: Maximum pixel distance to consider characters as duplicates.
Set to 0 to disable deduplication.
Returns:
The extracted text with duplicate characters removed.
"""
if threshold <= 0:
return text_line.get_text()
# Build deduplicated text while preserving non-LTChar items (like LTAnno for spaces)
result_parts: List[str] = []
last_ltchar: Optional[LTChar] = None
for item in text_line:
if isinstance(item, LTChar):
# Check if this is a duplicate of the last LTChar
if last_ltchar is not None and _is_duplicate_char(last_ltchar, item, threshold):
# Skip this duplicate character
continue
last_ltchar = item
result_parts.append(item.get_text())
else:
# Non-LTChar items (e.g., LTAnno for spaces) - keep as-is
if hasattr(item, "get_text"):
result_parts.append(item.get_text())
return "".join(result_parts)
def get_text_with_deduplication(
text_obj: Union[LTTextLine, LTContainer, LTItem],
threshold: float,
) -> str:
"""Get text from a text object with optional character deduplication.
This is the main entry point for extracting text with fake-bold deduplication.
It handles LTTextLine objects and recursively processes containers.
Args:
text_obj: An LTTextLine, LTContainer, or other LTItem object.
threshold: Maximum pixel distance to consider characters as duplicates.
Set to 0 to disable deduplication.
Returns:
The extracted text with duplicate characters removed.
"""
if isinstance(text_obj, LTTextLine):
return deduplicate_chars_in_text_line(text_obj, threshold)
elif isinstance(text_obj, LTContainer):
parts: List[str] = []
for child in text_obj:
if isinstance(child, LTTextLine):
parts.append(deduplicate_chars_in_text_line(child, threshold))
elif hasattr(child, "get_text"):
parts.append(child.get_text())
return "".join(parts)
elif hasattr(text_obj, "get_text"):
return text_obj.get_text()
return ""
@requires_dependencies(["pikepdf", "pypdf"])
def open_pdfminer_pages_generator(
fp: BinaryIO, password: Optional[str] = None, pdfminer_config: Optional[PDFMinerConfig] = None
):
"""Open PDF pages using PDFMiner, handling and repairing invalid dictionary constructs."""
import pikepdf
from unstructured.partition.pdf_image.pypdf_utils import get_page_data
device, interpreter = init_pdfminer(pdfminer_config=pdfminer_config)
with tempfile.TemporaryDirectory() as tmp_dir_path:
tmp_file_path = os.path.join(tmp_dir_path, "tmp_file")
try:
pages = PDFPage.get_pages(fp, password=password or "")
# Detect invalid dictionary construct for entire PDF
for i, page in enumerate(pages):
try:
# Detect invalid dictionary construct for one page
interpreter.process_page(page)
page_layout = device.get_result()
except PSSyntaxError:
logger.info("Detected invalid dictionary construct for PDFminer")
logger.info(f"Repairing the PDF page {i + 1} ...")
# find the error page from binary data fp
error_page_data = get_page_data(fp, page_number=i)
# repair the error page with pikepdf
with pikepdf.Pdf.open(error_page_data) as pdf:
pdf.save(tmp_file_path)
page = next(PDFPage.get_pages(open(tmp_file_path, "rb"))) # noqa: SIM115
interpreter.process_page(page)
page_layout = device.get_result()
yield page, page_layout
except PSSyntaxError:
logger.info("Detected invalid dictionary construct for PDFminer")
logger.info("Repairing the PDF document ...")
# repair the entire doc with pikepdf
with pikepdf.Pdf.open(fp) as pdf:
pdf.save(tmp_file_path)
pages = PDFPage.get_pages(open(tmp_file_path, "rb")) # noqa: SIM115
for page in pages:
interpreter.process_page(page)
page_layout = device.get_result()
yield page, page_layout

Some files were not shown because too many files have changed in this diff Show More