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
View File
@@ -0,0 +1,26 @@
from unstructured.metrics.text_extraction import standardize_quotes
SAMPLE_TEXTS = [
"She said \u201cHello\u201d and then whispered \u2018Goodbye\u2019 before leaving.",
"\u201eTo be, or not to be, that is the question\u201d - Shakespeare\u2019s famous quote.",
"\u00abWhen he said \u201clife is beautiful,\u201d I believed him\u00bb wrote Maria.",
"\u275dDo you remember when we first met?\u275e she asked with a smile.",
"\u301dThe meeting starts at 10:00, don\u2019t be late!\u301f announced the manager.",
'\u300cHe told me "This is important" yesterday\u300d, she explained.',
"\u300eThe sun was setting. The birds were singing. It was peaceful.\u300f",
"\ufe42Meeting #123 @ 15:00 - Don\u2019t forget!\ufe41",
"\u300cHello\u300d, \u275dWorld\u275e, \"Test\", 'Example', \u201eQuote\u201d, \u00abFinal\u00bb", # noqa: E501
"It\u2019s John\u2019s book, isn\u2019t it?",
'\u2039Testing the system\u2019s capability for "quoted" text\u203a',
"\u275bFirst sentence. Second sentence. Third sentence.\u275c",
"\u300cChapter 1\u300d: \u275dThe Beginning\u275e - \u201eA new story\u201d begins \u00abtoday\u00bb.", # noqa: E501
]
def run_standardize_quotes():
for text in SAMPLE_TEXTS:
standardize_quotes(text)
def test_benchmark_standardize_quotes(benchmark):
benchmark(run_standardize_quotes)
File diff suppressed because it is too large Load Diff
+308
View File
@@ -0,0 +1,308 @@
"""Test suite for the `unstructured.chunking.basic` module.
That module implements the baseline chunking strategy. The baseline strategy has all behaviors
shared by all chunking strategies and no extra rules like perserve section or page boundaries.
"""
from __future__ import annotations
from typing import Any
import pytest
from test_unstructured.unit_utils import FixtureRequest, Mock, function_mock
from unstructured.chunking.basic import chunk_elements
from unstructured.documents.elements import (
CompositeElement,
ElementMetadata,
Table,
TableChunk,
Text,
Title,
)
from unstructured.partition.docx import partition_docx
def test_it_chunks_a_document_when_basic_chunking_strategy_is_specified_on_partition_function():
"""Basic chunking can be combined with partitioning, exercising the decorator."""
filename = "example-docs/handbook-1p.docx"
chunks = partition_docx(filename, chunking_strategy="basic")
assert chunks == [
CompositeElement(
"US Trustee Handbook\n\nCHAPTER 1\n\nINTRODUCTION\n\nCHAPTER 1 INTRODUCTION"
"\n\nA. PURPOSE"
),
CompositeElement(
"The United States Trustee appoints and supervises standing trustees and monitors and"
" supervises cases under chapter 13 of title 11 of the United States Code. 28 U.S.C."
" § 586(b). The Handbook, issued as part of our duties under 28 U.S.C. § 586,"
" establishes or clarifies the position of the United States Trustee Program (Program)"
" on the duties owed by a standing trustee to the debtors, creditors, other parties in"
" interest, and the United States Trustee. The Handbook does not present a full and"
),
CompositeElement(
"complete statement of the law; it should not be used as a substitute for legal"
" research and analysis. The standing trustee must be familiar with relevant"
" provisions of the Bankruptcy Code, Federal Rules of Bankruptcy Procedure (Rules),"
" any local bankruptcy rules, and case law. 11 U.S.C. § 321, 28 U.S.C. § 586,"
" 28 C.F.R. § 58.6(a)(3). Standing trustees are encouraged to follow Practice Tips"
" identified in this Handbook but these are not considered mandatory."
),
CompositeElement(
"Nothing in this Handbook should be construed to excuse the standing trustee from"
" complying with all duties imposed by the Bankruptcy Code and Rules, local rules, and"
" orders of the court. The standing trustee should notify the United States Trustee"
" whenever the provision of the Handbook conflicts with the local rules or orders of"
" the court. The standing trustee is accountable for all duties set forth in this"
" Handbook, but need not personally perform any duty unless otherwise indicated. All"
),
CompositeElement(
"statutory references in this Handbook refer to the Bankruptcy Code, 11 U.S.C. § 101"
" et seq., unless otherwise indicated."
),
CompositeElement(
"This Handbook does not create additional rights against the standing trustee or"
" United States Trustee in favor of other parties.\n\nB. ROLE OF THE UNITED STATES"
" TRUSTEE"
),
CompositeElement(
"The Bankruptcy Reform Act of 1978 removed the bankruptcy judge from the"
" responsibilities for daytoday administration of cases. Debtors, creditors, and"
" third parties with adverse interests to the trustee were concerned that the court,"
" which previously appointed and supervised the trustee, would not impartially"
" adjudicate their rights as adversaries of that trustee. To address these concerns,"
" judicial and administrative functions within the bankruptcy system were bifurcated."
),
CompositeElement(
"Many administrative functions formerly performed by the court were placed within the"
" Department of Justice through the creation of the Program. Among the administrative"
" functions assigned to the United States Trustee were the appointment and supervision"
" of chapter 13 trustees./ This Handbook is issued under the authority of the"
" Programs enabling statutes.\n\nC. STATUTORY DUTIES OF A STANDING TRUSTEE"
),
CompositeElement(
"The standing trustee has a fiduciary responsibility to the bankruptcy estate. The"
" standing trustee is more than a mere disbursing agent. The standing trustee must"
" be personally involved in the trustee operation. If the standing trustee is or"
" becomes unable to perform the duties and responsibilities of a standing trustee,"
" the standing trustee must immediately advise the United States Trustee."
" 28 U.S.C. § 586(b), 28 C.F.R. § 58.4(b) referencing 28 C.F.R. § 58.3(b)."
),
CompositeElement(
"Although this Handbook is not intended to be a complete statutory reference, the"
" standing trustees primary statutory duties are set forth in 11 U.S.C. § 1302, which"
" incorporates by reference some of the duties of chapter 7 trustees found in"
" 11 U.S.C. § 704. These duties include, but are not limited to, the"
" following:\n\nCopyright"
),
]
def test_it_chunks_elements_when_the_user_already_has_them():
elements = [
Title("Introduction"),
Text(
# --------------------------------------------------------- 64 -v
"Lorem ipsum dolor sit amet consectetur adipiscing elit. In rhoncus ipsum sed lectus"
" porta volutpat.",
),
]
chunks = chunk_elements(elements, max_characters=64)
assert chunks == [
CompositeElement("Introduction"),
# -- splits on even word boundary, not mid-"rhoncus" --
CompositeElement("Lorem ipsum dolor sit amet consectetur adipiscing elit. In"),
CompositeElement("rhoncus ipsum sed lectus porta volutpat."),
]
def test_it_includes_original_elements_as_metadata_when_requested():
element = Title("Introduction")
element_2 = Text("Lorem ipsum dolor sit amet consectetur adipiscing elit.")
element_3 = Text("In rhoncus ipsum sed lectus porta volutpat.")
chunks = chunk_elements(
[element, element_2, element_3], max_characters=70, include_orig_elements=True
)
assert len(chunks) == 2
chunk = chunks[0]
assert chunk == CompositeElement(
"Introduction\n\nLorem ipsum dolor sit amet consectetur adipiscing elit."
)
assert chunk.metadata.orig_elements == [element, element_2]
# --
chunk = chunks[1]
assert chunk == CompositeElement("In rhoncus ipsum sed lectus porta volutpat.")
assert chunk.metadata.orig_elements == [element_3]
def test_it_repeats_table_headers_by_default_but_can_opt_out():
table_html = (
"<table>"
"<thead>"
"<tr><th>Header A</th><th>Header B</th></tr>"
"<tr><th>Subhead A</th><th>Subhead B</th></tr>"
"</thead>"
"<tbody>"
"<tr><td>Body 1</td><td>Alpha</td></tr>"
"<tr><td>Body 2</td><td>Bravo</td></tr>"
"<tr><td>Body 3</td><td>Charlie</td></tr>"
"<tr><td>Body 4</td><td>Delta</td></tr>"
"</tbody>"
"</table>"
)
table_text = (
"Header A Header B\n"
"Subhead A Subhead B\n"
"Body 1 Alpha\n"
"Body 2 Bravo\n"
"Body 3 Charlie\n"
"Body 4 Delta"
)
table = Table(table_text, metadata=ElementMetadata(text_as_html=table_html))
repeated_header_chunks = chunk_elements([table], max_characters=55)
opt_out_chunks = chunk_elements([table], max_characters=55, repeat_table_headers=False)
assert len(repeated_header_chunks) == 4
assert all(isinstance(chunk, TableChunk) for chunk in repeated_header_chunks)
assert [chunk.text for chunk in repeated_header_chunks] == [
"Header A Header B Subhead A Subhead B Body 1 Alpha",
"Header A Header B Subhead A Subhead B Body 2 Bravo",
"Header A Header B Subhead A Subhead B Body 3 Charlie",
"Header A Header B Subhead A Subhead B Body 4 Delta",
]
assert [chunk.text for chunk in opt_out_chunks] == [
"Header A Header B Subhead A Subhead B Body 1 Alpha",
"Body 2 Bravo Body 3 Charlie Body 4 Delta",
]
def test_skip_table_chunking_passes_oversized_table_through_unchanged():
table_text = "cell " * 200 # 1000 chars, well above max_characters=100
table = Table(table_text.strip())
text_before = Text("Hello world")
text_after = Text("Goodbye world")
chunks = chunk_elements(
[text_before, table, text_after],
max_characters=100,
skip_table_chunking=True,
)
assert len(chunks) == 3
assert isinstance(chunks[0], CompositeElement)
assert isinstance(chunks[1], Table)
assert isinstance(chunks[2], CompositeElement)
# -- table text is unchanged --
assert chunks[1].text == table_text.strip()
def test_skip_table_chunking_does_not_affect_text_element_chunking():
long_text = Text("word " * 200)
table = Table("small table")
chunks = chunk_elements(
[long_text, table],
max_characters=100,
skip_table_chunking=True,
)
# -- long text element is still split, table is still isolated --
text_chunks = [c for c in chunks if isinstance(c, CompositeElement)]
table_chunks = [c for c in chunks if isinstance(c, Table)]
assert len(text_chunks) > 1
assert len(table_chunks) == 1
assert table_chunks[0].text == "small table"
# ------------------------------------------------------------------------------------------------
# UNIT TESTS
# ------------------------------------------------------------------------------------------------
class Describe_chunk_elements:
"""Unit-test suite for `unstructured.chunking.basic.chunk_elements()` function."""
@pytest.mark.parametrize(
("kwargs", "expected_value"),
[
({"include_orig_elements": True}, True),
({"include_orig_elements": False}, False),
({"include_orig_elements": None}, True),
({}, True),
],
)
def it_supports_the_include_orig_elements_option(
self, kwargs: dict[str, Any], expected_value: bool, _chunk_elements_: Mock
):
# -- this line would raise if "include_orig_elements" was not an available parameter on
# -- `chunk_elements()`.
chunk_elements([], **kwargs)
_, opts = _chunk_elements_.call_args.args
assert opts.include_orig_elements is expected_value
@pytest.mark.parametrize(
("kwargs", "expected_value"),
[
({"repeat_table_headers": True}, True),
({"repeat_table_headers": False}, False),
({"repeat_table_headers": None}, True),
({}, True),
],
)
def it_supports_the_repeat_table_headers_option(
self, kwargs: dict[str, Any], expected_value: bool, _chunk_elements_: Mock
):
# -- this line would raise if "repeat_table_headers" was not an available parameter on
# -- `chunk_elements()`.
chunk_elements([], **kwargs)
_, opts = _chunk_elements_.call_args.args
assert opts.repeat_table_headers is expected_value
@pytest.mark.parametrize(
("kwargs", "expected_value"),
[
({"skip_table_chunking": True}, True),
({"skip_table_chunking": False}, False),
({"skip_table_chunking": None}, False),
({}, False),
],
)
def it_supports_the_skip_table_chunking_option(
self, kwargs: dict[str, Any], expected_value: bool, _chunk_elements_: Mock
):
chunk_elements([], **kwargs)
_, opts = _chunk_elements_.call_args.args
assert opts.skip_table_chunking is expected_value
@pytest.mark.parametrize(
("kwargs", "expected_value"),
[
({"isolate_table": True}, True),
({"isolate_table": False}, False),
({"isolate_table": None}, True),
({}, True),
],
)
def it_supports_the_isolate_table_option(
self, kwargs: dict[str, Any], expected_value: bool, _chunk_elements_: Mock
):
chunk_elements([], **kwargs)
_, opts = _chunk_elements_.call_args.args
assert opts.isolate_table is expected_value
# -- fixtures --------------------------------------------------------------------------------
@pytest.fixture()
def _chunk_elements_(self, request: FixtureRequest):
return function_mock(request, "unstructured.chunking.basic._chunk_elements")
@@ -0,0 +1,92 @@
# pyright: reportPrivateUsage=false
"""Unit-test suite for the `unstructured.chunking.dispatch` module."""
from __future__ import annotations
from typing import Any, Iterable, Optional
import pytest
from unstructured.chunking import add_chunking_strategy, register_chunking_strategy
from unstructured.chunking.dispatch import _ChunkerSpec, chunk
from unstructured.documents.elements import CompositeElement, Element, Text
class Describe_add_chunking_strategy:
"""Unit-test suite for `unstructured.chunking.add_chunking_strategy()` decorator."""
def it_dispatches_the_partitioned_elements_to_the_indicated_chunker(self):
decorated_partitioner = add_chunking_strategy(partition_this)
chunks = decorated_partitioner(chunking_strategy="basic")
assert chunks == [CompositeElement("Lorem ipsum.\n\nSit amet.")]
def but_it_skips_dispatch_when_no_chunking_strategy_is_specified(self):
decorated_partitioner = add_chunking_strategy(partition_this)
elements = decorated_partitioner()
assert elements == [Text("Lorem ipsum."), Text("Sit amet.")]
class Describe_chunk:
"""Unit-test suite for `unstructured.chunking.dispatch.chunk()` function."""
def it_dispatches_to_the_chunker_registered_for_the_chunking_strategy(self):
register_chunking_strategy("by_something_else", chunk_by_something_else)
kwargs = {
"max_characters": 750,
# -- unused kwargs shouldn't cause a problem; in general `kwargs` will contain all
# -- keyword arguments used in the partitioning call.
"foo": "bar",
}
chunks = chunk([Text("Lorem"), Text("Ipsum")], "by_something_else", **kwargs)
assert chunks == [
CompositeElement("chunked 2 elements with `(max_characters=750, whizbang=None)`")
]
def it_raises_when_the_requested_chunking_strategy_is_not_registered(self):
with pytest.raises(
ValueError,
match="unrecognized chunking strategy 'foobar'",
):
chunk(elements=[], chunking_strategy="foobar")
class Describe_ChunkerSpec:
"""Unit-test suite for `unstructured.chunking.dispatch._ChunkerSpec` objects."""
def it_provides_access_to_the_chunking_function(self):
spec = _ChunkerSpec(chunk_by_something_else)
assert spec.chunker is chunk_by_something_else
def it_knows_which_keyword_args_the_chunking_function_can_accept(self):
spec = _ChunkerSpec(chunk_by_something_else)
assert spec.kw_arg_names == ("max_characters", "whizbang")
# -- MODULE-LEVEL FIXTURES -----------------------------------------------------------------------
def chunk_by_something_else(
elements: Iterable[Element],
max_characters: Optional[int] = None,
whizbang: Optional[float] = None,
) -> list[Element]:
"""A "fake" minimal chunker suitable for use in tests."""
els = list(elements)
return [
CompositeElement(
f"chunked {len(els)} elements with"
f" `(max_characters={max_characters}, whizbang={whizbang})`"
)
]
def partition_this(**kwargs: Any) -> list[Element]:
"""A fake partitioner."""
return [Text("Lorem ipsum."), Text("Sit amet.")]
@@ -0,0 +1,90 @@
from functools import partial
import pytest
from unstructured.chunking.basic import chunk_elements
from unstructured.chunking.title import chunk_by_title
from unstructured.documents.elements import ElementMetadata, NarrativeText, Text, Title
@pytest.fixture(params=[chunk_elements, partial(chunk_by_title, combine_text_under_n_chars=0)])
def chunking_fn(request):
return request.param
def test_combining_html_metadata_when_multiple_elements_in_composite_element(chunking_fn):
metadata_1 = '<h1 class="Title" id="1">Header </h1>'
metadata_2 = '<time class="CalendarDate" id="2">Date: October 30, 2023 </time>'
metadata_3 = (
'<form class="Form" id="3"> '
'<label class="FormField" for="company-name" id="4">Form field name </label>'
'<input class="FormFieldValue" id="5" value="Example value" />'
"</form>"
)
combined_metadata = " ".join([metadata_1, metadata_2, metadata_3])
elements = [
Title(text="Header", metadata=ElementMetadata(text_as_html=metadata_1)),
Text(text="Date: October 30, 2023", metadata=ElementMetadata(text_as_html=metadata_2)),
Text(
text="Form field name Example value", metadata=ElementMetadata(text_as_html=metadata_3)
),
]
chunks = chunking_fn(elements)
assert len(chunks) == 1
assert chunks[0].metadata.text_as_html == combined_metadata
def test_combining_html_metadata_with_nested_relationship_between_elements(chunking_fn):
"""
Ground truth
<Document>
<Page>
<Section>
<p>First</p>
<p>Second</p>
</Section>
</Page>
</Document>
Elements: Document, Page, Section, Paragraph, Paragraph
Chunk 1: Document, Page, Section, Paragraph
Chunk 2:
Paragraph
"""
metadata_1 = '<div class="Section" id="1" />'
metadata_2 = '<p class="Paragraph" id="2">First </p>'
metadata_3 = '<p class="Paragraph" id="3">Second </p>'
elements = [
Text(text="", metadata=ElementMetadata(text_as_html=metadata_1)),
NarrativeText(
text="First", metadata=ElementMetadata(text_as_html=metadata_2, parent_id="1")
),
NarrativeText(
text="Second", metadata=ElementMetadata(text_as_html=metadata_3, parent_id="1")
),
]
chunks = chunking_fn(elements, max_characters=6)
assert len(chunks) == 2
assert chunks[0].text == "First"
assert chunks[1].text == "Second"
assert chunks[0].metadata.text_as_html == metadata_1 + " " + metadata_2
assert chunks[1].metadata.text_as_html == metadata_3
def test_html_metadata_exist_in_both_element_when_text_is_split(chunking_fn):
"""Mimic behaviour of elements with non-html metadata"""
metadata_1 = '<h1 class="Title" id="1">Header </h1>'
elements = [
Title(text="Header", metadata=ElementMetadata(text_as_html=metadata_1)),
]
chunks = chunking_fn(elements, max_characters=3)
assert len(chunks) == 2
assert chunks[0].text == "Hea"
assert chunks[1].text == "der"
assert chunks[0].metadata.text_as_html == '<h1 class="Title" id="1">Header </h1>'
assert chunks[1].metadata.text_as_html == '<h1 class="Title" id="1">Header </h1>'
@@ -0,0 +1,357 @@
# pyright: reportPrivateUsage=false
"""Regression and characterization tests for table isolation during chunking (issue #3921).
`Table` (and `TableChunk`, which subclasses `Table`) must never share a pre-chunk with unrelated
text elements, so downstream logic can emit standalone table chunks instead of `CompositeElement`
wrapping mixed content.
"""
from __future__ import annotations
from typing import Callable
import pytest
from unstructured.chunking.base import (
ChunkingOptions,
PreChunk,
PreChunkBuilder,
PreChunkCombiner,
PreChunker,
)
from unstructured.chunking.basic import chunk_elements
from unstructured.chunking.title import chunk_by_title
from unstructured.documents.elements import (
CompositeElement,
ElementMetadata,
Table,
TableChunk,
Text,
Title,
)
class DescribeTableIsolationPreChunkBuilder:
"""`PreChunkBuilder` must keep every table-family element in its own pre-chunk."""
@pytest.mark.parametrize(
("preamble", "make_table"),
[
(Text("Short preamble."), lambda: Table("H\nC")),
(Text("Short preamble."), lambda: TableChunk(text="x", metadata=ElementMetadata())),
],
)
def it_refuses_to_append_a_table_after_any_other_element(
self, preamble: Text, make_table: Callable[[], Table]
):
builder = PreChunkBuilder(opts=ChunkingOptions(max_characters=500))
builder.add_element(preamble)
assert not builder.will_fit(make_table())
@pytest.mark.parametrize(
"trailing",
[
Text("Follow-up paragraph."),
Title("Next section"),
],
)
def it_refuses_to_append_a_non_table_after_a_table(self, trailing: Text | Title):
builder = PreChunkBuilder(opts=ChunkingOptions(max_characters=500))
builder.add_element(Table("Heading\nCell"))
assert not builder.will_fit(trailing)
def it_allows_only_a_table_when_the_builder_is_empty(self):
builder = PreChunkBuilder(opts=ChunkingOptions())
assert builder.will_fit(Table("Heading\nCell text"))
def it_allows_text_after_flush_even_if_previous_pre_chunk_was_a_table(self):
opts = ChunkingOptions(max_characters=200)
builder = PreChunkBuilder(opts=opts)
builder.add_element(Table("Heading\nCell text"))
list(builder.flush()) # clears builder state
assert builder.will_fit(Text("Fresh start after table pre-chunk."))
class DescribeTableIsolationPreChunkStream:
"""End-to-end pre-chunk segmentation from `PreChunker.iter_pre_chunks`."""
def it_emits_a_table_only_pre_chunk_between_text_blocks(self):
elements = [
Title("Section A"),
Text("Narrative before the table."),
Table("Col1\nCell A"),
Text("Narrative after the table."),
]
# -- `new_after_n_chars=0` forces one element per pre-chunk (deterministic layout) --
opts = ChunkingOptions(max_characters=500, new_after_n_chars=0)
pre_chunks = list(PreChunker.iter_pre_chunks(elements, opts=opts))
assert len(pre_chunks) == 4
assert pre_chunks[0]._elements == [Title("Section A")]
assert pre_chunks[1]._elements == [Text("Narrative before the table.")]
assert pre_chunks[2]._elements == [Table("Col1\nCell A")]
assert pre_chunks[3]._elements == [Text("Narrative after the table.")]
def it_emits_one_pre_chunk_per_table_when_multiple_tables_are_adjacent(self):
elements = [
Table("T1\nA"),
Table("T2\nB"),
Text("Closing text."),
]
opts = ChunkingOptions(max_characters=500)
pre_chunks = list(PreChunker.iter_pre_chunks(elements, opts=opts))
assert len(pre_chunks) == 3
assert pre_chunks[0]._elements == [Table("T1\nA")]
assert pre_chunks[1]._elements == [Table("T2\nB")]
assert pre_chunks[2]._elements == [Text("Closing text.")]
class DescribeTableIsolationPreChunkCombiner:
"""`PreChunkCombiner` must not stitch table pre-chunks onto text neighbors."""
def it_keeps_a_table_pre_chunk_separate_when_combining_is_enabled(self):
opts = ChunkingOptions(max_characters=500, combine_text_under_n_chars=500)
stream = [
PreChunk([Text("Hello world.")], overlap_prefix="", opts=opts),
PreChunk([Table("H\nC")], overlap_prefix="", opts=opts),
PreChunk([Text("Goodbye world.")], overlap_prefix="", opts=opts),
]
combined = list(PreChunkCombiner(stream, opts=opts).iter_combined_pre_chunks())
assert len(combined) == 3
assert combined[0]._elements == [Text("Hello world.")]
assert combined[1]._elements == [Table("H\nC")]
assert combined[2]._elements == [Text("Goodbye world.")]
class DescribeTableIsolationOrderingGuarantees:
"""Invariants that should hold for any future refactor of table isolation."""
def it_preserves_global_element_order_in_pre_chunks(self):
elements = [
Text("alpha"),
Table("T\n1"),
Text("beta"),
Table("T\n2"),
Title("gamma"),
]
opts = ChunkingOptions(max_characters=500, new_after_n_chars=0)
flat = [e for pc in PreChunker.iter_pre_chunks(elements, opts=opts) for e in pc._elements]
assert flat == elements
def it_preserves_global_element_order_in_chunk_elements_output(self):
elements = [
Title("Intro"),
Text("Body before."),
Table("K\nV"),
Text("Body after."),
]
chunks = chunk_elements(elements, max_characters=500, new_after_n_chars=0)
# -- flatten chunk element categories in stream order (table chunks are atomic) --
categories = [c.category for c in chunks]
assert categories == [
"CompositeElement",
"CompositeElement",
"Table",
"CompositeElement",
]
class DescribeTableIsolationChunkElements:
"""`chunk_elements` (basic strategy) should emit `Table`/`TableChunk`, not mixed composites."""
def it_does_not_wrap_a_table_and_surrounding_text_in_one_composite_element(self):
elements = [
Title("Report"),
Text("Short intro."),
Table("Key\nValue"),
Text("Short outro."),
]
chunks = chunk_elements(
elements,
max_characters=500,
new_after_n_chars=0,
)
assert len(chunks) == 4
assert isinstance(chunks[0], CompositeElement)
assert isinstance(chunks[1], CompositeElement)
assert isinstance(chunks[2], Table)
assert isinstance(chunks[3], CompositeElement)
assert "Key" in chunks[2].text or "Value" in chunks[2].text
def it_yields_distinct_chunks_for_two_tables_in_a_row(self):
elements = [
Table("T1\nA"),
Table("T2\nB"),
]
chunks = chunk_elements(elements, max_characters=500)
assert len(chunks) == 2
assert all(isinstance(c, Table) for c in chunks)
def it_still_isolates_a_table_even_when_the_window_is_very_large(self):
"""Regression: isolation is a semantic rule, not a size heuristic."""
elements = [
Text("x"),
Table("tiny"),
Text("y"),
]
chunks = chunk_elements(elements, max_characters=50_000, new_after_n_chars=10_000)
table_chunks = [c for c in chunks if isinstance(c, Table)]
composite_chunks = [c for c in chunks if isinstance(c, CompositeElement)]
assert len(table_chunks) == 1
assert len(composite_chunks) == 2
assert "tiny" in table_chunks[0].text
def it_never_produces_a_composite_element_that_lists_a_table_in_orig_elements(
self,
):
"""`CompositeElement` chunks come from `_Chunker`, not `_TableChunker`."""
elements = [
Text("preamble"),
Table("H\nC"),
Text("post"),
]
chunks = chunk_elements(
elements,
max_characters=400,
new_after_n_chars=0,
include_orig_elements=True,
)
composites = [c for c in chunks if isinstance(c, CompositeElement)]
for comp in composites:
orig = comp.metadata.orig_elements or []
assert not any(isinstance(e, Table) for e in orig)
class DescribeTableIsolationDisabled:
"""When `isolate_table=False`, the pre-#4307 behavior is restored."""
def it_lets_a_table_share_a_pre_chunk_with_adjacent_text_when_disabled(self):
opts = ChunkingOptions(max_characters=500, isolate_table=False)
builder = PreChunkBuilder(opts=opts)
builder.add_element(Text("Short preamble."))
assert builder.will_fit(Table("Heading\nCell text"))
def it_lets_text_follow_a_table_in_the_same_pre_chunk_when_disabled(self):
opts = ChunkingOptions(max_characters=500, isolate_table=False)
builder = PreChunkBuilder(opts=opts)
builder.add_element(Table("Heading\nCell text"))
assert builder.will_fit(Text("Follow-up paragraph."))
def it_combines_a_table_pre_chunk_with_text_neighbors_when_disabled(self):
opts = ChunkingOptions(
max_characters=500, combine_text_under_n_chars=500, isolate_table=False
)
stream = [
PreChunk([Text("Hello world.")], overlap_prefix="", opts=opts),
PreChunk([Table("H\nC")], overlap_prefix="", opts=opts),
PreChunk([Text("Goodbye world.")], overlap_prefix="", opts=opts),
]
combined = list(PreChunkCombiner(stream, opts=opts).iter_combined_pre_chunks())
assert len(combined) == 1
assert combined[0]._elements == [
Text("Hello world."),
Table("H\nC"),
Text("Goodbye world."),
]
def it_wraps_a_table_into_a_composite_element_with_neighbors_when_disabled(self):
"""End-to-end: opting out collapses tiny table + text into one CompositeElement."""
elements = [
Text("preamble"),
Table("H\nC"),
Text("post"),
]
chunks = chunk_elements(
elements,
max_characters=500,
isolate_table=False,
)
assert len(chunks) == 1
assert isinstance(chunks[0], CompositeElement)
text = chunks[0].text
assert "preamble" in text
assert "post" in text
class DescribeTableIsolationOverlapAll:
"""With overlap_all=True, overlap must not cross table / narrative boundaries."""
def it_does_not_prefix_table_chunk_with_prior_text_overlap(self):
"""Regression: pre-chunk overlap_tail must not become table's overlap_prefix."""
elements = [Text("Alpha beta gamma delta."), Table("H\nC")]
chunks = chunk_elements(
elements,
max_characters=500,
new_after_n_chars=0,
overlap=5,
overlap_all=True,
)
table_chunks = [c for c in chunks if isinstance(c, Table)]
assert len(table_chunks) == 1
t = table_chunks[0].text or ""
assert "Alpha" not in t
assert "elta" not in t # tail of "delta" leaked in buggy overlap
def it_does_not_prefix_text_after_table_with_table_overlap(self):
elements = [Table("H\nC"), Text("Omega sigma tau upsilon.")]
chunks = chunk_elements(
elements,
max_characters=500,
new_after_n_chars=0,
overlap=5,
overlap_all=True,
)
composites = [c for c in chunks if isinstance(c, CompositeElement)]
assert len(composites) == 1
assert composites[0].text.startswith("Omega")
assert "H" not in composites[0].text[:20]
def it_chunk_by_title_respects_same_overlap_boundaries(self):
elements = [
Title("Section"),
Text("Alpha beta gamma delta."),
Table("H\nC"),
Text("Omega sigma tau upsilon."),
]
chunks = chunk_by_title(
elements,
max_characters=500,
new_after_n_chars=0,
overlap=5,
overlap_all=True,
combine_text_under_n_chars=0,
)
table_chunks = [c for c in chunks if isinstance(c, Table)]
assert len(table_chunks) == 1
assert "Alpha" not in (table_chunks[0].text or "")
assert "elta" not in (table_chunks[0].text or "")
omega_composites = [
c for c in chunks if isinstance(c, CompositeElement) and "Omega" in (c.text or "")
]
assert len(omega_composites) == 1
assert omega_composites[0].text.startswith("Omega")
+890
View File
@@ -0,0 +1,890 @@
# pyright: reportPrivateUsage=false
"""Test suite for the `unstructured.chunking.title` module."""
from __future__ import annotations
from typing import Any, Optional
import pytest
from test_unstructured.unit_utils import FixtureRequest, Mock, function_mock, input_path
from unstructured.chunking.base import CHUNK_MULTI_PAGE_DEFAULT
from unstructured.chunking.title import _ByTitleChunkingOptions, chunk_by_title
from unstructured.documents.coordinates import CoordinateSystem
from unstructured.documents.elements import (
CheckBox,
CompositeElement,
CoordinatesMetadata,
Element,
ElementMetadata,
ListItem,
Table,
TableChunk,
Text,
Title,
)
from unstructured.partition.html import partition_html
from unstructured.staging.base import elements_from_json
# ================================================================================================
# INTEGRATION-TESTS
# ================================================================================================
# These test `chunk_by_title()` as an integrated whole, calling `chunk_by_title()` and inspecting
# the outputs.
# ================================================================================================
def test_it_chunks_text_followed_by_table_together_when_both_fit():
elements = elements_from_json(input_path("chunking/title_table_200.json"))
chunks = chunk_by_title(elements, combine_text_under_n_chars=0)
assert len(chunks) == 2
assert isinstance(chunks[0], CompositeElement)
assert isinstance(chunks[1], Table)
def test_it_chunks_table_followed_by_text_together_when_both_fit():
elements = elements_from_json(input_path("chunking/table_text_200.json"))
# -- disable chunk combining so we test pre-chunking behavior, not chunk-combining --
chunks = chunk_by_title(elements, combine_text_under_n_chars=0)
assert len(chunks) == 2
assert isinstance(chunks[0], Table)
assert isinstance(chunks[1], CompositeElement)
def test_it_splits_oversized_table():
elements = elements_from_json(input_path("chunking/table_2000.json"))
chunks = chunk_by_title(elements)
assert len(chunks) == 5
assert all(isinstance(chunk, TableChunk) for chunk in chunks)
def test_skip_table_chunking_passes_oversized_table_through_unchanged():
elements = elements_from_json(input_path("chunking/table_2000.json"))
chunks = chunk_by_title(elements, skip_table_chunking=True)
assert len(chunks) == 1
assert isinstance(chunks[0], Table)
def test_skip_table_chunking_does_not_combine_table_with_adjacent_text():
table_text = "cell " * 200
table = Table(table_text.strip())
text_before = Text("Hello world")
text_after = Text("Goodbye world")
chunks = chunk_by_title(
[text_before, table, text_after],
max_characters=5000,
combine_text_under_n_chars=5000,
skip_table_chunking=True,
)
assert isinstance(chunks[0], CompositeElement)
assert isinstance(chunks[1], Table)
assert isinstance(chunks[2], CompositeElement)
assert chunks[1].text == table_text.strip()
def test_it_repeats_table_headers_by_default_but_can_opt_out():
table_html = (
"<table>"
"<thead>"
"<tr><th>Header A</th><th>Header B</th></tr>"
"<tr><th>Subhead A</th><th>Subhead B</th></tr>"
"</thead>"
"<tbody>"
"<tr><td>Body 1</td><td>Alpha</td></tr>"
"<tr><td>Body 2</td><td>Bravo</td></tr>"
"<tr><td>Body 3</td><td>Charlie</td></tr>"
"<tr><td>Body 4</td><td>Delta</td></tr>"
"</tbody>"
"</table>"
)
table_text = (
"Header A Header B\n"
"Subhead A Subhead B\n"
"Body 1 Alpha\n"
"Body 2 Bravo\n"
"Body 3 Charlie\n"
"Body 4 Delta"
)
table = Table(table_text, metadata=ElementMetadata(text_as_html=table_html))
repeated_header_chunks = chunk_by_title(
[table], combine_text_under_n_chars=0, max_characters=55
)
opt_out_chunks = chunk_by_title(
[table],
combine_text_under_n_chars=0,
max_characters=55,
repeat_table_headers=False,
)
assert len(repeated_header_chunks) == 4
assert all(isinstance(chunk, TableChunk) for chunk in repeated_header_chunks)
assert [chunk.text for chunk in repeated_header_chunks] == [
"Header A Header B Subhead A Subhead B Body 1 Alpha",
"Header A Header B Subhead A Subhead B Body 2 Bravo",
"Header A Header B Subhead A Subhead B Body 3 Charlie",
"Header A Header B Subhead A Subhead B Body 4 Delta",
]
assert [chunk.text for chunk in opt_out_chunks] == [
"Header A Header B Subhead A Subhead B Body 1 Alpha",
"Body 2 Bravo Body 3 Charlie Body 4 Delta",
]
def test_it_starts_new_chunk_for_table_after_full_text_chunk():
elements = elements_from_json(input_path("chunking/long_text_table_200.json"))
chunks = chunk_by_title(elements, max_characters=250)
assert len(chunks) == 2
assert [type(chunk) for chunk in chunks] == [CompositeElement, Table]
def test_it_starts_new_chunk_for_text_after_full_table_chunk():
elements = elements_from_json(input_path("chunking/full_table_long_text_250.json"))
chunks = chunk_by_title(elements, max_characters=250)
assert len(chunks) == 2
assert [type(chunk) for chunk in chunks] == [Table, CompositeElement]
def test_it_splits_a_large_text_element_into_multiple_chunks():
elements: list[Element] = [
Title("Introduction"),
Text(
"Lorem ipsum dolor sit amet consectetur adipiscing elit. In rhoncus ipsum sed lectus"
" porta volutpat.",
),
]
chunks = chunk_by_title(elements, max_characters=50)
assert chunks == [
CompositeElement("Introduction"),
CompositeElement("Lorem ipsum dolor sit amet consectetur adipiscing"),
CompositeElement("elit. In rhoncus ipsum sed lectus porta volutpat."),
]
def test_it_splits_elements_by_title_and_table():
elements: list[Element] = [
Title("A Great Day"),
Text("Today is a great day."),
Text("It is sunny outside."),
Table("Heading\nCell text"),
Title("An Okay Day"),
Text("Today is an okay day."),
Text("It is rainy outside."),
Title("A Bad Day"),
Text("Today is a bad day."),
Text("It is storming outside."),
CheckBox(),
]
chunks = chunk_by_title(elements, combine_text_under_n_chars=0, include_orig_elements=True)
assert len(chunks) == 4
# --
chunk = chunks[0]
assert isinstance(chunk, CompositeElement)
assert chunk.metadata.orig_elements == [
Title("A Great Day"),
Text("Today is a great day."),
Text("It is sunny outside."),
]
# --
chunk = chunks[1]
assert isinstance(chunk, Table)
assert len(chunk.metadata.orig_elements) == 1
assert isinstance(chunk.metadata.orig_elements[0], Table)
assert chunk.metadata.orig_elements[0].text == "Heading\nCell text"
# --
chunk = chunks[2]
assert isinstance(chunk, CompositeElement)
assert chunk.metadata.orig_elements == [
Title("An Okay Day"),
Text("Today is an okay day."),
Text("It is rainy outside."),
]
# --
chunk = chunks[3]
assert isinstance(chunk, CompositeElement)
assert chunk.metadata.orig_elements == [
Title("A Bad Day"),
Text("Today is a bad day."),
Text("It is storming outside."),
CheckBox(),
]
def test_chunk_by_title():
elements: list[Element] = [
Title("A Great Day", metadata=ElementMetadata(emphasized_text_contents=["Day"])),
Text("Today is a great day.", metadata=ElementMetadata(emphasized_text_contents=["day"])),
Text("It is sunny outside."),
Table("Heading\nCell text"),
Title("An Okay Day"),
Text("Today is an okay day."),
Text("It is rainy outside."),
Title("A Bad Day"),
Text("Today is a bad day."),
Text("It is storming outside."),
CheckBox(),
]
chunks = chunk_by_title(elements, combine_text_under_n_chars=0, include_orig_elements=False)
assert len(chunks) == 4
assert chunks[0] == CompositeElement(
"A Great Day\n\nToday is a great day.\n\nIt is sunny outside."
)
assert isinstance(chunks[1], Table)
assert chunks[1].text == "Heading\nCell text"
assert chunks[2] == CompositeElement(
"An Okay Day\n\nToday is an okay day.\n\nIt is rainy outside."
)
assert chunks[3] == CompositeElement(
"A Bad Day\n\nToday is a bad day.\n\nIt is storming outside."
)
assert chunks[0].metadata == ElementMetadata(emphasized_text_contents=["Day", "day"])
def test_chunk_by_title_separates_by_page_number():
elements: list[Element] = [
Title("A Great Day", metadata=ElementMetadata(page_number=1)),
Text("Today is a great day.", metadata=ElementMetadata(page_number=2)),
Text("It is sunny outside.", metadata=ElementMetadata(page_number=2)),
Table("Heading\nCell text"),
Title("An Okay Day"),
Text("Today is an okay day."),
Text("It is rainy outside."),
Title("A Bad Day"),
Text("Today is a bad day."),
Text("It is storming outside."),
CheckBox(),
]
chunks = chunk_by_title(elements, multipage_sections=False, combine_text_under_n_chars=0)
assert len(chunks) == 5
assert chunks[0] == CompositeElement("A Great Day")
assert chunks[1] == CompositeElement("Today is a great day.\n\nIt is sunny outside.")
assert isinstance(chunks[2], Table)
assert chunks[2].text == "Heading\nCell text"
assert chunks[3] == CompositeElement(
"An Okay Day\n\nToday is an okay day.\n\nIt is rainy outside."
)
assert chunks[4] == CompositeElement(
"A Bad Day\n\nToday is a bad day.\n\nIt is storming outside."
)
def test_chuck_by_title_respects_multipage():
elements: list[Element] = [
Title("A Great Day", metadata=ElementMetadata(page_number=1)),
Text("Today is a great day.", metadata=ElementMetadata(page_number=2)),
Text("It is sunny outside.", metadata=ElementMetadata(page_number=2)),
Table("Heading\nCell text"),
Title("An Okay Day"),
Text("Today is an okay day."),
Text("It is rainy outside."),
Title("A Bad Day"),
Text("Today is a bad day."),
Text("It is storming outside."),
CheckBox(),
]
chunks = chunk_by_title(elements, multipage_sections=True, combine_text_under_n_chars=0)
assert len(chunks) == 4
assert chunks[0] == CompositeElement(
"A Great Day\n\nToday is a great day.\n\nIt is sunny outside."
)
assert isinstance(chunks[1], Table)
assert chunks[1].text == "Heading\nCell text"
assert chunks[2] == CompositeElement(
"An Okay Day\n\nToday is an okay day.\n\nIt is rainy outside."
)
assert chunks[3] == CompositeElement(
"A Bad Day\n\nToday is a bad day.\n\nIt is storming outside."
)
def test_chunk_by_title_groups_across_pages():
elements: list[Element] = [
Title("A Great Day", metadata=ElementMetadata(page_number=1)),
Text("Today is a great day.", metadata=ElementMetadata(page_number=2)),
Text("It is sunny outside.", metadata=ElementMetadata(page_number=2)),
Table("Heading\nCell text"),
Title("An Okay Day"),
Text("Today is an okay day."),
Text("It is rainy outside."),
Title("A Bad Day"),
Text("Today is a bad day."),
Text("It is storming outside."),
CheckBox(),
]
chunks = chunk_by_title(elements, multipage_sections=True, combine_text_under_n_chars=0)
assert len(chunks) == 4
assert chunks[0] == CompositeElement(
"A Great Day\n\nToday is a great day.\n\nIt is sunny outside."
)
assert isinstance(chunks[1], Table)
assert chunks[1].text == "Heading\nCell text"
assert chunks[2] == CompositeElement(
"An Okay Day\n\nToday is an okay day.\n\nIt is rainy outside."
)
assert chunks[3] == CompositeElement(
"A Bad Day\n\nToday is a bad day.\n\nIt is storming outside."
)
def test_add_chunking_strategy_on_partition_html():
filename = "example-docs/example-10k-1p.html"
chunk_elements = partition_html(filename, chunking_strategy="by_title")
elements = partition_html(filename)
chunks = chunk_by_title(elements)
assert chunk_elements != elements
assert chunk_elements == chunks
def test_add_chunking_strategy_respects_max_characters():
filename = "example-docs/example-10k-1p.html"
chunk_elements = partition_html(
filename,
chunking_strategy="by_title",
combine_text_under_n_chars=0,
new_after_n_chars=50,
max_characters=100,
)
elements = partition_html(filename)
chunks = chunk_by_title(
elements,
combine_text_under_n_chars=0,
new_after_n_chars=50,
max_characters=100,
)
for chunk in chunks:
assert isinstance(chunk, Text)
assert len(chunk.text) <= 100
for chunk_element in chunk_elements:
assert isinstance(chunk_element, Text)
assert len(chunk_element.text) <= 100
assert chunk_elements != elements
assert chunk_elements == chunks
def test_add_chunking_strategy_forwards_repeat_table_headers():
filename = "example-docs/example-10k-1p.html"
chunk_elements = partition_html(
filename,
chunking_strategy="by_title",
repeat_table_headers=False,
)
elements = partition_html(filename)
chunks = chunk_by_title(elements, repeat_table_headers=False)
assert chunk_elements != elements
assert chunk_elements == chunks
def test_chunk_by_title_drops_detection_class_prob():
elements: list[Element] = [
Title(
"A Great Day",
metadata=ElementMetadata(
detection_class_prob=0.5,
),
),
Text(
"Today is a great day.",
metadata=ElementMetadata(
detection_class_prob=0.62,
),
),
Text(
"It is sunny outside.",
metadata=ElementMetadata(
detection_class_prob=0.73,
),
),
Title(
"An Okay Day",
metadata=ElementMetadata(
detection_class_prob=0.84,
),
),
Text(
"Today is an okay day.",
metadata=ElementMetadata(
detection_class_prob=0.95,
),
),
]
chunks = chunk_by_title(elements, combine_text_under_n_chars=0)
assert str(chunks[0]) == str(
CompositeElement("A Great Day\n\nToday is a great day.\n\nIt is sunny outside."),
)
assert str(chunks[1]) == str(CompositeElement("An Okay Day\n\nToday is an okay day."))
def test_chunk_by_title_drops_extra_metadata():
elements: list[Element] = [
Title(
"A Great Day",
metadata=ElementMetadata(
coordinates=CoordinatesMetadata(
points=(
(0.1, 0.1),
(0.2, 0.1),
(0.1, 0.2),
(0.2, 0.2),
),
system=CoordinateSystem(width=0.1, height=0.1),
),
),
),
Text(
"Today is a great day.",
metadata=ElementMetadata(
coordinates=CoordinatesMetadata(
points=(
(0.2, 0.2),
(0.3, 0.2),
(0.2, 0.3),
(0.3, 0.3),
),
system=CoordinateSystem(width=0.2, height=0.2),
),
),
),
Text(
"It is sunny outside.",
metadata=ElementMetadata(
coordinates=CoordinatesMetadata(
points=(
(0.3, 0.3),
(0.4, 0.3),
(0.3, 0.4),
(0.4, 0.4),
),
system=CoordinateSystem(width=0.3, height=0.3),
),
),
),
Title(
"An Okay Day",
metadata=ElementMetadata(
coordinates=CoordinatesMetadata(
points=(
(0.3, 0.3),
(0.4, 0.3),
(0.3, 0.4),
(0.4, 0.4),
),
system=CoordinateSystem(width=0.3, height=0.3),
),
),
),
Text(
"Today is an okay day.",
metadata=ElementMetadata(
coordinates=CoordinatesMetadata(
points=(
(0.4, 0.4),
(0.5, 0.4),
(0.4, 0.5),
(0.5, 0.5),
),
system=CoordinateSystem(width=0.4, height=0.4),
),
),
),
]
chunks = chunk_by_title(elements, combine_text_under_n_chars=0)
assert str(chunks[0]) == str(
CompositeElement("A Great Day\n\nToday is a great day.\n\nIt is sunny outside."),
)
assert str(chunks[1]) == str(CompositeElement("An Okay Day\n\nToday is an okay day."))
def test_it_considers_separator_length_when_pre_chunking():
"""PreChunker includes length of separators when computing remaining space."""
elements: list[Element] = [
Title("Chunking Priorities"), # 19 chars
ListItem("Divide text into manageable chunks"), # 34 chars
ListItem("Preserve semantic boundaries"), # 28 chars
ListItem("Minimize mid-text chunk-splitting"), # 33 chars
] # 114 chars total but 120 chars with separators
chunks = chunk_by_title(elements, max_characters=115)
assert chunks == [
CompositeElement(
"Chunking Priorities"
"\n\nDivide text into manageable chunks"
"\n\nPreserve semantic boundaries",
),
CompositeElement("Minimize mid-text chunk-splitting"),
]
# ================================================================================================
# UNIT-TESTS
# ================================================================================================
# These test individual components in isolation so can exercise all edge cases while still
# performing well.
# ================================================================================================
class Describe_chunk_by_title:
"""Unit-test suite for `unstructured.chunking.title.chunk_by_title()` function."""
@pytest.mark.parametrize(
("kwargs", "expected_value"),
[
({"include_orig_elements": True}, True),
({"include_orig_elements": False}, False),
({"include_orig_elements": None}, True),
({}, True),
],
)
def it_supports_the_include_orig_elements_option(
self, kwargs: dict[str, Any], expected_value: bool, _chunk_by_title_: Mock
):
# -- this line would raise if "include_orig_elements" was not an available parameter on
# -- `chunk_by_title()`.
chunk_by_title([], **kwargs)
_, opts = _chunk_by_title_.call_args.args
assert opts.include_orig_elements is expected_value
@pytest.mark.parametrize(
("kwargs", "expected_value"),
[
({"repeat_table_headers": True}, True),
({"repeat_table_headers": False}, False),
({"repeat_table_headers": None}, True),
({}, True),
],
)
def it_supports_the_repeat_table_headers_option(
self, kwargs: dict[str, Any], expected_value: bool, _chunk_by_title_: Mock
):
# -- this line would raise if "repeat_table_headers" was not an available parameter on
# -- `chunk_by_title()`.
chunk_by_title([], **kwargs)
_, opts = _chunk_by_title_.call_args.args
assert opts.repeat_table_headers is expected_value
@pytest.mark.parametrize(
("kwargs", "expected_value"),
[
({"skip_table_chunking": True}, True),
({"skip_table_chunking": False}, False),
({"skip_table_chunking": None}, False),
({}, False),
],
)
def it_supports_the_skip_table_chunking_option(
self, kwargs: dict[str, Any], expected_value: bool, _chunk_by_title_: Mock
):
chunk_by_title([], **kwargs)
_, opts = _chunk_by_title_.call_args.args
assert opts.skip_table_chunking is expected_value
@pytest.mark.parametrize(
("kwargs", "expected_value"),
[
({"isolate_table": True}, True),
({"isolate_table": False}, False),
({"isolate_table": None}, True),
({}, True),
],
)
def it_supports_the_isolate_table_option(
self, kwargs: dict[str, Any], expected_value: bool, _chunk_by_title_: Mock
):
chunk_by_title([], **kwargs)
_, opts = _chunk_by_title_.call_args.args
assert opts.isolate_table is expected_value
# -- fixtures --------------------------------------------------------------------------------
@pytest.fixture()
def _chunk_by_title_(self, request: FixtureRequest):
return function_mock(request, "unstructured.chunking.title._chunk_by_title")
class Describe_ByTitleChunkingOptions:
"""Unit-test suite for `unstructured.chunking.title._ByTitleChunkingOptions` objects."""
@pytest.mark.parametrize("n_chars", [-1, -42])
def it_rejects_combine_text_under_n_chars_for_n_less_than_zero(self, n_chars: int):
with pytest.raises(
ValueError,
match=f"'combine_text_under_n_chars' argument must be >= 0, got {n_chars}",
):
_ByTitleChunkingOptions.new(combine_text_under_n_chars=n_chars)
def it_accepts_0_for_combine_text_under_n_chars_to_disable_chunk_combining(self):
"""Specifying `combine_text_under_n_chars=0` is how a caller disables chunk-combining."""
opts = _ByTitleChunkingOptions(combine_text_under_n_chars=0)
assert opts.combine_text_under_n_chars == 0
def it_does_not_complain_when_specifying_combine_text_under_n_chars_by_itself(self):
"""Caller can specify `combine_text_under_n_chars` arg without specifying other options."""
try:
opts = _ByTitleChunkingOptions(combine_text_under_n_chars=50)
except ValueError:
pytest.fail("did not accept `combine_text_under_n_chars` as option by itself")
assert opts.combine_text_under_n_chars == 50
@pytest.mark.parametrize(
("combine_text_under_n_chars", "max_characters", "expected_hard_max"),
[(600, None, 500), (600, 450, 450)],
)
def it_rejects_combine_text_under_n_chars_greater_than_maxchars(
self, combine_text_under_n_chars: int, max_characters: Optional[int], expected_hard_max: int
):
"""`combine_text_under_n_chars` > `max_characters` can produce behavior confusing to users.
The behavior is no different from `combine_text_under_n_chars == max_characters`, but if
`max_characters` is left to default (500) and `combine_text_under_n_chars` is set to a
larger number like 1500 then it can look like chunk-combining isn't working.
"""
with pytest.raises(
ValueError,
match=(
"'combine_text_under_n_chars' argument must not exceed `max_characters` value,"
f" got {combine_text_under_n_chars} > {expected_hard_max}"
),
):
_ByTitleChunkingOptions.new(
max_characters=max_characters, combine_text_under_n_chars=combine_text_under_n_chars
)
def it_does_not_complain_when_specifying_new_after_n_chars_by_itself(self):
"""Caller can specify `new_after_n_chars` arg without specifying any other options."""
try:
opts = _ByTitleChunkingOptions.new(new_after_n_chars=200)
except ValueError:
pytest.fail("did not accept `new_after_n_chars` as option by itself")
assert opts.soft_max == 200
@pytest.mark.parametrize(
("multipage_sections", "expected_value"),
[(True, True), (False, False), (None, CHUNK_MULTI_PAGE_DEFAULT)],
)
def it_knows_whether_to_break_chunks_on_page_boundaries(
self, multipage_sections: bool, expected_value: bool
):
opts = _ByTitleChunkingOptions(multipage_sections=multipage_sections)
assert opts.multipage_sections is expected_value
# ================================================================================================
# TOKEN-BASED CHUNKING INTEGRATION TESTS
# ================================================================================================
class DescribeTokenBasedChunking:
"""Integration tests for token-based chunking with chunk_by_title()."""
@pytest.fixture
def _tiktoken_installed(self):
"""Skip test if tiktoken is not installed."""
pytest.importorskip("tiktoken")
def it_chunks_elements_by_token_count(self, _tiktoken_installed: None):
"""Test that chunk_by_title works with max_tokens parameter and respects token limits."""
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
elements: list[Element] = [
Title("Introduction"),
Text("This is a test document with some text content."),
Text("Here is more content that should be chunked appropriately."),
]
chunks = chunk_by_title(
elements,
max_tokens=20,
tokenizer="cl100k_base",
combine_text_under_n_chars=0,
)
# -- verify chunks were created --
assert len(chunks) >= 1
assert all(isinstance(chunk, (CompositeElement, Table, TableChunk)) for chunk in chunks)
# -- verify each chunk respects the token limit --
for chunk in chunks:
token_count = len(enc.encode(chunk.text))
assert token_count <= 20, f"Chunk exceeded token limit: {token_count} tokens"
# -- verify the first chunk contains expected content --
assert "Introduction" in chunks[0].text
def it_respects_new_after_n_tokens_soft_limit(self, _tiktoken_installed: None):
"""Test that new_after_n_tokens creates smaller chunks with correct content."""
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
elements: list[Element] = [
Title("Section 1"),
Text("Some text for section one."),
Text("More text for section one."),
Title("Section 2"),
Text("Text for section two."),
]
# -- with a low soft limit, each section should become its own chunk --
chunks = chunk_by_title(
elements,
max_tokens=100,
new_after_n_tokens=10,
tokenizer="cl100k_base",
combine_text_under_n_chars=0,
)
# -- verify we get multiple chunks --
assert len(chunks) >= 2
# -- verify each chunk respects the hard limit --
for chunk in chunks:
token_count = len(enc.encode(chunk.text))
assert token_count <= 100, f"Chunk exceeded token limit: {token_count} tokens"
# -- verify content is distributed across chunks --
all_text = " ".join(chunk.text for chunk in chunks)
assert "Section 1" in all_text
assert "Section 2" in all_text
def it_rejects_max_tokens_and_max_characters_together(self, _tiktoken_installed: None):
"""Test that specifying both max_tokens and max_characters raises ValueError."""
elements: list[Element] = [Text("Some text")]
with pytest.raises(ValueError, match="mutually exclusive"):
chunk_by_title(
elements,
max_tokens=100,
max_characters=500,
tokenizer="cl100k_base",
)
def it_rejects_max_tokens_without_tokenizer(self, _tiktoken_installed: None):
"""Test that max_tokens requires tokenizer to be specified."""
elements: list[Element] = [Text("Some text")]
with pytest.raises(ValueError, match="'tokenizer' is required"):
chunk_by_title(elements, max_tokens=100)
def it_accepts_model_name_as_tokenizer(self, _tiktoken_installed: None):
"""Test that model names like 'gpt-4' work as tokenizer."""
import tiktoken
# -- gpt-4 uses cl100k_base encoding --
enc = tiktoken.encoding_for_model("gpt-4")
elements: list[Element] = [
Title("Test"),
Text("Some test content."),
]
chunks = chunk_by_title(
elements,
max_tokens=50,
tokenizer="gpt-4",
combine_text_under_n_chars=0,
)
assert len(chunks) >= 1
# -- verify token counts are within limit --
for chunk in chunks:
token_count = len(enc.encode(chunk.text))
assert token_count <= 50, f"Chunk exceeded token limit: {token_count} tokens"
# -- verify content --
assert chunks[0].text == "Test\n\nSome test content."
def it_splits_oversized_element_respecting_token_limit(self, _tiktoken_installed: None):
"""Test that oversized elements are split correctly by token count."""
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
# -- create a text that will definitely need to be split (>10 tokens) --
long_text = "The quick brown fox jumps over the lazy dog. " * 10
elements: list[Element] = [Text(long_text)]
chunks = chunk_by_title(
elements,
max_tokens=15,
tokenizer="cl100k_base",
combine_text_under_n_chars=0,
)
# -- should produce multiple chunks --
assert len(chunks) > 1
# -- each chunk should respect the token limit --
for chunk in chunks:
token_count = len(enc.encode(chunk.text))
assert token_count <= 15, f"Chunk exceeded token limit: {token_count} tokens"
def it_applies_token_based_overlap_in_split_chunks(self, _tiktoken_installed: None):
"""Test that overlap in token mode is measured in tokens, not characters."""
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
# -- create text that will be split into multiple chunks --
long_text = "Apple banana cherry date elderberry fig grape honeydew. " * 5
elements: list[Element] = [Text(long_text)]
chunks = chunk_by_title(
elements,
max_tokens=12,
tokenizer="cl100k_base",
overlap=3, # 3 tokens of overlap
combine_text_under_n_chars=0,
)
# -- should produce multiple chunks --
assert len(chunks) >= 2
# -- verify overlap: second chunk should start with tokens from end of first --
if len(chunks) >= 2:
# -- the overlap tail from chunk 0 should appear at start of chunk 1 --
first_chunk_text = chunks[0].text
second_chunk_text = chunks[1].text
# -- the overlap should be approximately 3 tokens worth of text --
# -- verify second chunk starts with content from end of first chunk --
# -- (the overlap mechanism prepends tail to remainder) --
assert len(second_chunk_text) > 0
# -- verify some content from first chunk appears at start of second --
first_chunk_words = first_chunk_text.split()
assert any(word in second_chunk_text for word in first_chunk_words[-3:])
# -- verify each chunk respects limits --
for chunk in chunks:
token_count = len(enc.encode(chunk.text))
assert token_count <= 12, f"Chunk exceeded token limit: {token_count} tokens"
+305
View File
@@ -0,0 +1,305 @@
import re
import pytest
from unstructured.cleaners import core
@pytest.mark.parametrize(
("text", "expected"),
[
(
"\x88This text contains non-ascii characters!\x88",
"This text contains non-ascii characters!",
),
("\x93A lovely quote!\x94", "A lovely quote!"),
("● An excellent point! ●●●", " An excellent point! "),
("Item\xa01A", "Item1A"),
("Our dog&apos;s bowl.", "Our dog&apos;s bowl."),
("5 w=E2=80=99s", "5 w=E2=80=99s"),
],
)
def test_clean_non_ascii_chars(text, expected):
assert core.clean_non_ascii_chars(text) == expected
@pytest.mark.parametrize(
("text", "expected"),
[
("● An excellent point!", "An excellent point!"),
("● An excellent point! ●●●", "An excellent point! ●●●"),
("An excellent point!", "An excellent point!"),
("Morse code! ●●●", "Morse code! ●●●"),
(" An EN DASH bullet point!", "An EN DASH bullet point!"),
("\u2013 Another EN DASH bullet!", "Another EN DASH bullet!"),
("Text with inside", "Text with inside"),
],
)
def test_clean_bullets(text, expected):
assert core.clean_bullets(text=text) == expected
assert core.clean(text=text, bullets=True) == expected
@pytest.mark.parametrize(
("text", "expected"),
[
("1. Introduction:", "Introduction:"),
("a. Introduction:", "Introduction:"),
("20.3 Morse code ●●●", "Morse code ●●●"),
("5.3.1 Convolutional Networks ", "Convolutional Networks"),
("D.b.C Recurrent Neural Networks", "Recurrent Neural Networks"),
("2.b.1 Recurrent Neural Networks", "Recurrent Neural Networks"),
("eins. Neural Networks", "eins. Neural Networks"),
("bb.c Feed Forward Neural Networks", "Feed Forward Neural Networks"),
("aaa.ccc Metrics", "aaa.ccc Metrics"),
(" version = 3.8", " version = 3.8"),
("1 2. 3 4", "1 2. 3 4"),
("1) 2. 3 4", "1) 2. 3 4"),
("2,3. Morse code 3. ●●●", "2,3. Morse code 3. ●●●"),
("1..2.3 four", "1..2.3 four"),
("Fig. 2: The relationship", "Fig. 2: The relationship"),
("23 is everywhere", "23 is everywhere"),
],
)
def test_clean_ordered_bullets(text, expected):
assert core.clean_ordered_bullets(text=text) == expected
@pytest.mark.parametrize(
("text", "expected"),
[
("The æther is a classic element.", "The aether is a classic element."),
("In old texts, Æsop's fables are", "In old texts, AEsop's fables are"),
("The buffer zone is there.", "The buffer zone is there."),
("The file was found in the system.", "The file was found in the system."),
("She had a flower in her hair.", "She had a flower in her hair."),
("The coffin was placed in the grave.", "The coffin was placed in the grave."),
("The buffle zone was clearly marked.", "The buffle zone was clearly marked."),
("The craſtsman worked with dedication.", "The craftsman worked with dedication."),
("The symbol ʪ is very rare.", "The symbol ls is very rare."),
("The word 'cœur' means 'heart' in French.", "The word 'coeur' means 'heart' in French."),
("The word 'Œuvre' refers to the works", "The word 'OEuvre' refers to the works"),
("The ȹ symbol is used in some contexts.", "The qp symbol is used in some contexts."),
("The postman delivers mail daily.", "The postman delivers mail daily."),
(
"The symbol ʦ can be found in certain alphabets.",
"The symbol ts can be found in certain alphabets.",
),
],
)
def test_clean_ligatures(text, expected):
assert core.clean_ligatures(text=text) == expected
@pytest.mark.parametrize(
("text", "expected"),
[
("\x93A lovely quote!\x94", "“A lovely quote!”"),
("\x91A lovely quote!\x92", "A lovely quote!"),
("Our dog&apos;s bowl.", "Our dog's bowl."),
],
)
def test_replace_unicode_quotes(text, expected):
assert core.replace_unicode_quotes(text=text) == expected
@pytest.mark.parametrize(
("text", "expected"),
[("5 w=E2=80=99s", "5 ws")],
)
def test_replace_mime_encodings(text, expected):
assert core.replace_mime_encodings(text=text) == expected
def test_replace_mime_encodings_works_with_different_encodings():
text = "5 w=E2=80-99s=E2=80-92"
assert core.replace_mime_encodings(text=text, encoding="latin-1") == "5 wâ\x80-99sâ\x80-92"
def test_replace_mime_encodings_works_with_right_to_left_encodings():
text = "=EE=E0=E9=E4"
assert core.replace_mime_encodings(text=text, encoding="iso-8859-8") == "מאיה"
@pytest.mark.parametrize(
("text", "expected"),
[
("“A lovely quote!”", "A lovely quote"),
("A lovely quote!", "A lovely quote"),
("'()[]{};:'\",.?/\\-_", ""),
],
)
def test_remove_punctuation(text, expected):
assert core.remove_punctuation(text) == expected
@pytest.mark.parametrize(
("text", "expected"),
[
("RISK\n\nFACTORS", "RISK FACTORS"),
("Item\xa01A", "Item 1A"),
(" Risk factors ", "Risk factors"),
("Risk factors ", "Risk factors"),
],
)
def test_clean_extra_whitespace(text, expected):
assert core.clean_extra_whitespace(text) == expected
assert core.clean(text=text, extra_whitespace=True) == expected
@pytest.mark.parametrize(
("text", "expected"),
[
("Risk-factors", "Risk factors"),
("Risk factors", "Risk factors"),
("Risk\u2013factors", "Risk factors"),
("Risk factors-\u2013", "Risk factors"),
],
)
def test_clean_dashes(text, expected):
assert core.clean_dashes(text) == expected
assert core.clean(text=text, dashes=True) == expected
@pytest.mark.parametrize(
("text", "expected"),
[
("Item 1A:", "Item 1A"),
("Item 1A;", "Item 1A"),
("Item 1A.", "Item 1A"),
("Item 1A,", "Item 1A"),
("Item, 1A: ", "Item, 1A"),
],
)
def test_clean_trailing_punctuation(text, expected):
assert core.clean_trailing_punctuation(text) == expected
assert core.clean(text=text, trailing_punctuation=True) == expected
@pytest.mark.parametrize(
("text", "pattern", "ignore_case", "strip", "expected"),
[
("SUMMARY: A great SUMMARY", r"(SUMMARY|DESC):", False, True, "A great SUMMARY"),
("DESC: A great SUMMARY", r"(SUMMARY|DESC):", False, True, "A great SUMMARY"),
("SUMMARY: A great SUMMARY", r"(SUMMARY|DESC):", False, False, " A great SUMMARY"),
("summary: A great SUMMARY", r"(SUMMARY|DESC):", True, True, "A great SUMMARY"),
],
)
def test_clean_prefix(text, pattern, ignore_case, strip, expected):
assert core.clean_prefix(text, pattern, ignore_case, strip) == expected
@pytest.mark.parametrize(
("text", "pattern", "ignore_case", "strip", "expected"),
[
("The END! END", r"(END|STOP)", False, True, "The END!"),
("The END! STOP", r"(END|STOP)", False, True, "The END!"),
("The END! END", r"(END|STOP)", False, False, "The END! "),
("The END! end", r"(END|STOP)", True, True, "The END!"),
],
)
def test_clean_postfix(text, pattern, ignore_case, strip, expected):
assert core.clean_postfix(text, pattern, ignore_case, strip) == expected
def test_group_broken_paragraphs():
text = """The big red fox
is walking down the lane.
At the end of the lane
the fox met a friendly bear."""
assert (
core.group_broken_paragraphs(text)
== """The big red fox is walking down the lane.
At the end of the lane the fox met a friendly bear."""
)
def test_group_broken_paragraphs_non_default_settings():
text = """The big red fox
is walking down the lane.
At the end of the lane
the fox met a friendly bear."""
para_split_re = re.compile(r"(\s*\n\s*){3}")
clean_text = core.group_broken_paragraphs(text, paragraph_split=para_split_re)
assert (
clean_text
== """The big red fox is walking down the lane.
At the end of the lane the fox met a friendly bear."""
)
def test_group_broken_paragraphs_with_bullets():
text = """○The big red fox
is walking down the lane.
○At the end of the lane
the fox met a friendly bear."""
assert core.group_bullet_paragraph(text) == [
"○The big red fox is walking down the lane. ",
"○At the end of the lane the fox met a friendly bear.",
]
def test_group_bullet_paragraph_with_e_bullets():
text = """e The big red fox
is walking down the lane.
e At the end of the lane
the fox met a friendly bear."""
assert core.group_bullet_paragraph(text) == [
"· The big red fox is walking down the lane. ",
"· At the end of the lane the fox met a friendly bear.",
]
@pytest.mark.parametrize(
# NOTE(yuming): Tests combined cleaners
(
"text",
"extra_whitespace",
"dashes",
"bullets",
"lowercase",
"trailing_punctuation",
"expected",
),
[
(" Risk-factors ", True, True, False, False, False, "Risk factors"),
("● Point! ●●● ", True, False, True, False, False, "Point! ●●●"),
("Risk- factors ", True, False, False, True, False, "risk- factors"),
("Risk factors: ", True, False, False, False, True, "Risk factors"),
("● Risk-factors●●● ", False, True, True, False, False, "Risk factors●●●"),
("Risk-factors ", False, True, False, True, False, "risk factors"),
("Risk-factors: ", False, True, False, False, True, "Risk factors"),
("● Point! ●●● ", False, False, True, True, False, "point! ●●●"),
("● Point! ●●●: ", False, False, True, False, True, "Point! ●●●"),
("Risk factors: ", False, False, False, True, True, "risk factors"),
],
)
def test_clean(text, extra_whitespace, dashes, bullets, lowercase, trailing_punctuation, expected):
assert (
core.clean(
text=text,
extra_whitespace=extra_whitespace,
dashes=dashes,
bullets=bullets,
trailing_punctuation=trailing_punctuation,
lowercase=lowercase,
)
== expected
)
def test_bytes_string_to_string():
text = "\xe6\xaf\x8f\xe6\x97\xa5\xe6\x96\xb0\xe9\x97\xbb"
assert core.bytes_string_to_string(text, "utf-8") == "每日新闻"
+156
View File
@@ -0,0 +1,156 @@
import datetime
import pytest
from unstructured.cleaners import extract
EMAIL_META_DATA_INPUT = """from ABC.DEF.local ([ba23::58b5:2236:45g2:88h2]) by
\n ABC.DEF.local ([68.183.71.12]) with mapi id\
n 32.88.5467.123; Fri, 26 Mar 2021 11:04:09 +1200"""
def test_get_indexed_match_raises_with_bad_index():
with pytest.raises(ValueError):
extract._get_indexed_match("BLAH BLAH BLAH", "BLAH", -1)
def test_get_indexed_match_raises_with_index_too_high():
with pytest.raises(ValueError):
extract._get_indexed_match("BLAH BLAH BLAH", "BLAH", 4)
def test_extract_text_before():
text = "Teacher: BLAH BLAH BLAH; Student: BLAH BLAH BLAH!"
assert extract.extract_text_before(text, "BLAH", 1) == "Teacher: BLAH"
def test_extract_text_after():
text = "Teacher: BLAH BLAH BLAH; Student: BLAH BLAH BLAH!"
assert extract.extract_text_after(text, "BLAH;", 0) == "Student: BLAH BLAH BLAH!"
def test_extract_email_address():
text = "Im Rabn <Im.Rabn@npf.gov.nr>"
assert extract.extract_email_address(text) == ["im.rabn@npf.gov.nr"]
def test_extract_ip_address():
assert extract.extract_ip_address(EMAIL_META_DATA_INPUT) == [
"ba23::58b5:2236:45g2:88h2",
"68.183.71.12",
]
def test_extract_ip_address_name():
assert extract.extract_ip_address_name(EMAIL_META_DATA_INPUT) == [
"ABC.DEF.local",
"ABC.DEF.local",
]
def test_extract_mapi_id():
assert extract.extract_mapi_id(EMAIL_META_DATA_INPUT) == ["32.88.5467.123"]
def test_extract_datetimetz():
assert extract.extract_datetimetz(EMAIL_META_DATA_INPUT) == datetime.datetime(
2021,
3,
26,
11,
4,
9,
tzinfo=datetime.timezone(datetime.timedelta(seconds=43200)),
)
def test_extract_datetimetz_works_with_no_date():
assert extract.extract_datetimetz("NO DATE HERE") is None
@pytest.mark.parametrize(
("text", "expected"),
[
("215-867-5309", "215-867-5309"),
("Phone Number: +1 215.867.5309", "+1 215.867.5309"),
("Phone Number: Just Kidding", ""),
],
)
def test_extract_us_phone_number(text, expected):
phone_number = extract.extract_us_phone_number(text)
assert phone_number == expected
@pytest.mark.parametrize(
("text", "expected"),
[
("1. Introduction:", ("1", None, None)),
("a. Introduction:", ("a", None, None)),
("20.3 Morse code ●●●", ("20", "3", None)),
("5.3.1 Convolutional Networks ", ("5", "3", "1")),
("D.b.C Recurrent Neural Networks", ("D", "b", "C")),
("2.b.1 Recurrent Neural Networks", ("2", "b", "1")),
("eins. Neural Networks", (None, None, None)),
("bb.c Feed Forward Neural Networks", ("bb", "c", None)),
("aaa.ccc Metrics", (None, None, None)),
(" version = 3.8", (None, None, None)),
("1 2. 3 4", (None, None, None)),
("1) 2. 3 4", (None, None, None)),
("2,3. Morse code 3. ●●●", (None, None, None)),
("1..2.3 four", (None, None, None)),
("Fig. 2: The relationship", (None, None, None)),
("23 is everywhere", (None, None, None)),
],
)
def test_extract_ordered_bullets(text, expected):
assert extract.extract_ordered_bullets(text=text) == expected
@pytest.mark.parametrize(
("text", "expected"),
[
(
"https://my-image.jpg",
(["https://my-image.jpg"]),
),
(
"https://my-image.png with some text",
(["https://my-image.png"]),
),
(
"https://my-image/with/some/path.png",
(["https://my-image/with/some/path.png"]),
),
(
"some text https://my-image.jpg with another http://my-image.bmp",
(["https://my-image.jpg", "http://my-image.bmp"]),
),
(
"http://not-an-image.com",
([]),
),
(
"some text",
([]),
),
(
"some text https://my-image.JPG with another http://my-image.BMP",
(["https://my-image.JPG", "http://my-image.BMP"]),
),
(
"http://my-path-with-CAPS/my-image.JPG",
(["http://my-path-with-CAPS/my-image.JPG"]),
),
(
"http://my-path/my%20image.JPG",
(["http://my-path/my%20image.JPG"]),
),
# url with reference #
(
"https://my-image.jpg#ref",
(["https://my-image.jpg"]),
),
],
)
def test_extract_image_urls_from_html(text, expected):
assert extract.extract_image_urls_from_html(text=text) == expected
@@ -0,0 +1,64 @@
import os
import pytest
from unstructured.cleaners import translate
IS_CI = os.getenv("CI") == "true"
def test_get_opus_mt_model_name():
model_name = translate._get_opus_mt_model_name("ru", "en")
assert model_name == "Helsinki-NLP/opus-mt-ru-en"
@pytest.mark.parametrize("code", ["way-too-long", "a", "", None])
def test_validate_language_code(code):
with pytest.raises(ValueError):
translate._validate_language_code(code)
def test_translate_returns_same_text_if_dest_is_same():
text = "This is already in English!"
assert translate.translate_text(text, "en", "en") == text
def test_translate_returns_same_text_text_is_empty():
text = " "
assert translate.translate_text(text) == text
@pytest.mark.skipif(IS_CI, reason="Skipping this test in CI pipeline")
def test_translate_with_language_specified():
text = "Ich bin ein Berliner!"
assert translate.translate_text(text, "de") == "I'm a Berliner!"
@pytest.mark.skipif(IS_CI, reason="Skipping this test in CI pipeline")
def test_translate_with_no_language_specified():
text = "Ich bin ein Berliner!"
assert translate.translate_text(text) == "I'm a Berliner!"
@pytest.mark.skipif(IS_CI, reason="Skipping this test in CI pipeline")
def test_translate_raises_with_bad_language():
text = "Ich bin ein Berliner!"
with pytest.raises(ValueError):
translate.translate_text(text, "zz")
@pytest.mark.skipif(IS_CI, reason="Skipping this test in CI pipeline")
def test_tranlate_works_with_russian():
text = "Я тоже можно переводать русский язык!"
assert translate.translate_text(text) == "I can also translate Russian!"
@pytest.mark.skipif(IS_CI, reason="Skipping this test in CI pipeline")
def test_translate_works_with_chinese():
text = "網站有中、英文版本"
translate.translate_text(text) == "Website available in Chinese and English"
def translate_works_with_arabic():
text = "مرحباً بكم في متجرنا"
translate.translate_text(text) == "Welcome to our store."
+301
View File
@@ -0,0 +1,301 @@
# pyright: reportPrivateUsage=false
"""Unit-test suite for the `unstructured.common.html_table` module."""
from __future__ import annotations
import pytest
from lxml.html import fragment_fromstring
from unstructured.common.html_table import (
HtmlCell,
HtmlRow,
HtmlTable,
htmlify_matrix_of_cell_texts,
)
class Describe_htmlify_matrix_of_cell_texts:
"""Unit-test suite for `unstructured.common.html_table.htmlify_matrix_of_cell_texts()`."""
def test_htmlify_matrix_handles_empty_cells(self):
assert htmlify_matrix_of_cell_texts([["cell1", "", "cell3"], ["", "cell5", ""]]) == (
"<table>"
"<tr><td>cell1</td><td/><td>cell3</td></tr>"
"<tr><td/><td>cell5</td><td/></tr>"
"</table>"
)
def test_htmlify_matrix_handles_special_characters(self):
assert htmlify_matrix_of_cell_texts([['<>&"', "newline\n"]]) == (
"<table><tr><td>&lt;&gt;&amp;&quot;</td><td>newline<br/></td></tr></table>"
)
def test_htmlify_matrix_handles_multiple_rows_and_cells(self):
assert htmlify_matrix_of_cell_texts([["cell1", "cell2"], ["cell3", "cell4"]]) == (
"<table>"
"<tr><td>cell1</td><td>cell2</td></tr>"
"<tr><td>cell3</td><td>cell4</td></tr>"
"</table>"
)
def test_htmlify_matrix_handles_empty_matrix(self):
assert htmlify_matrix_of_cell_texts([]) == ""
class DescribeHtmlTable:
"""Unit-test suite for `unstructured.common.html_table.HtmlTable`."""
def it_can_construct_from_html_text(self):
html_table = HtmlTable.from_html_text("<table><tr><td>foobar</td></tr></table>")
assert isinstance(html_table, HtmlTable)
assert html_table._table.tag == "table"
@pytest.mark.parametrize(
"html_text",
[
"<table><tr><td>foobar</td></tr></table>",
"<body><table><tr><td>foobar</td></tr></table></body>",
"<html><body><table><tr><td>foobar</td></tr></table></body></html>",
],
)
def it_can_find_a_table_wrapped_in_an_html_or_body_element(self, html_text: str):
html_table = HtmlTable.from_html_text(html_text)
assert isinstance(html_table, HtmlTable)
assert html_table._table.tag == "table"
def but_it_raises_when_no_table_element_is_present_in_the_html(self):
with pytest.raises(ValueError, match="`html_text` contains no `<table>` element"):
HtmlTable.from_html_text("<html><body><tr><td>foobar</td></tr></body></html>")
def it_removes_any_attributes_present_on_the_table_element(self):
html_table = HtmlTable.from_html_text(
'<table border="1", class="foobar"><tr><td>foobar</td></tr></table>',
)
assert html_table.html == "<table><tr><td>foobar</td></tr></table>"
def but_it_preserves_colspan_and_rowspan_as_structural_cell_attributes(self):
html_table = HtmlTable.from_html_text(
"<table>"
"<tr><th colspan='2' class='hdr' style='x'>A</th>"
"<th rowspan='2' id='foo'>B</th></tr>"
"<tr><td colspan='2' rowspan='3' data-k='v'>C</td><td>D</td></tr>"
"</table>"
)
table = fragment_fromstring(html_table.html)
# -- colspan/rowspan survive compactification --
assert table.xpath("./tr[1]/td[1]/@colspan") == ["2"]
assert table.xpath("./tr[1]/td[2]/@rowspan") == ["2"]
assert table.xpath("./tr[2]/td[1]/@colspan") == ["2"]
assert table.xpath("./tr[2]/td[1]/@rowspan") == ["3"]
# -- cosmetic / arbitrary attributes are still stripped --
assert table.xpath("./tr[1]/td[1]/@class") == []
assert table.xpath("./tr[1]/td[1]/@style") == []
assert table.xpath("./tr[1]/td[2]/@id") == []
assert table.xpath("./tr[2]/td[1]/@data-k") == []
@pytest.mark.parametrize(
"html_text",
[
"<table><thead><tr><td>foobar</td></tr></thead></table>",
"<table><thead><tr><td>foobar</td></tr></thead><tbody></tbody></table>",
"<table><tbody><tr><td>foobar</td></tr></tbody><tfoot></tfoot></table>",
],
)
def it_removes_any_thead_tbody_or_tfoot_elements_present_within_the_table_element(
self, html_text: str
):
html_table = HtmlTable.from_html_text(html_text)
assert html_table.html == "<table><tr><td>foobar</td></tr></table>"
def it_changes_any_th_elements_to_td_elements_for_cell_element_uniformity(self):
html_table = HtmlTable.from_html_text(
"<table> <tr><th>a</th><th/><th>b</th></tr> <tr><td/><td>c</td><td/></tr></table>"
)
assert html_table.html == (
"<table><tr><td>a</td><td/><td>b</td></tr><tr><td/><td>c</td><td/></tr></table>"
)
def it_removes_any_extra_whitespace_between_elements_and_normalizes_whitespace_in_text(self):
html_table = HtmlTable.from_html_text(
"\n <table>\n <tr>\n <td>\tabc def\nghi </td>\n </tr>\n</table>\n ",
)
assert html_table.html == "<table><tr><td>abc def ghi</td></tr></table>"
def it_preserves_tail_text_in_mixed_content_cells(self):
"""Tail text (between inline children) carries real content and must not be dropped."""
html_table = HtmlTable.from_html_text(
"<table><tr>"
"<td><b>foo</b> bar <b>baz</b></td>"
"<td><b>x</b><br/>y<br/>z</td>"
"</tr></table>"
)
assert html_table.html == (
"<table><tr>"
"<td><b>foo</b> bar <b>baz</b></td>"
"<td><b>x</b><br/>y<br/>z</td>"
"</tr></table>"
)
def and_it_normalizes_whitespace_within_tail_text(self):
"""Tail whitespace runs are collapsed, but leading/trailing spaces are kept."""
html_table = HtmlTable.from_html_text(
"<table><tr><td><b>a</b> b\n c <b>d</b></td></tr></table>"
)
assert html_table.html == "<table><tr><td><b>a</b> b c <b>d</b></td></tr></table>"
def it_can_serialize_the_table_element_to_str_html_text(self):
table = fragment_fromstring("<table><tr><td>foobar</td></tr></table>")
html_table = HtmlTable(table)
assert html_table.html == "<table><tr><td>foobar</td></tr></table>"
def it_can_iterate_the_rows_in_the_table(self):
html_table = HtmlTable.from_html_text(
"<table>"
" <tr><td>abc</td><td>def</td><td>ghi</td></tr>"
" <tr><td>jkl</td><td>mno</td><td>pqr</td></tr>"
" <tr><td>stu</td><td>vwx</td><td>yz</td></tr>"
"</table>"
)
row_iter = html_table.iter_rows()
row = next(row_iter)
assert isinstance(row, HtmlRow)
assert row.html == "<tr><td>abc</td><td>def</td><td>ghi</td></tr>"
# --
row = next(row_iter)
assert isinstance(row, HtmlRow)
assert row.html == "<tr><td>jkl</td><td>mno</td><td>pqr</td></tr>"
# --
row = next(row_iter)
assert isinstance(row, HtmlRow)
assert row.html == "<tr><td>stu</td><td>vwx</td><td>yz</td></tr>"
# --
with pytest.raises(StopIteration):
next(row_iter)
def it_preserves_row_header_semantics_when_iterating_rows(self):
html_table = HtmlTable.from_html_text(
"<table>"
" <thead><tr><td>head-from-thead</td></tr></thead>"
" <tbody>"
" <tr><th>head-from-th</th></tr>"
" <tr><td>body</td></tr>"
" </tbody>"
"</table>"
)
assert [row.is_header for row in html_table.iter_rows()] == [True, True, False]
def and_it_preserves_source_row_html_before_compactification(self):
html_table = HtmlTable.from_html_text(
"<table>"
" <thead><tr data-row='header'><th scope='col'>Header</th></tr></thead>"
" <tbody><tr><td class='body-cell'>Body</td></tr></tbody>"
"</table>"
)
rows = list(html_table.iter_rows())
header_row = fragment_fromstring(rows[0].source_html or "<tr/>")
body_row = fragment_fromstring(rows[1].source_html or "<tr/>")
assert header_row.xpath("./@data-row") == ["header"]
assert header_row.xpath("./th/@scope") == ["col"]
assert body_row.xpath("./td/@class") == ["body-cell"]
# -- compactified row HTML contract remains unchanged --
assert rows[0].html == "<tr><td>Header</td></tr>"
assert rows[1].html == "<tr><td>Body</td></tr>"
def it_provides_access_to_the_clear_concatenated_text_of_the_table(self):
html_table = HtmlTable.from_html_text(
"<table>"
" <tr><th> a\n b c </th><th/><th>def</th></tr>"
" <tr><td>gh \ti</td><td/><td>\n jk l </td></tr>"
" <tr><td/><td> m n op\n</td><td/></tr>"
"</table>"
)
assert html_table.text == "a b c def gh i jk l m n op"
class DescribeHtmlRow:
"""Unit-test suite for `unstructured.common.html_table.HtmlRow`."""
def it_can_serialize_the_row_to_html(self):
assert HtmlRow(fragment_fromstring("<tr><td>a</td><td>b</td><td/></tr>")).html == (
"<tr><td>a</td><td>b</td><td/></tr>"
)
def it_can_iterate_the_cells_in_the_row(self):
row = HtmlRow(fragment_fromstring("<tr><td>a</td><td>b</td><td/></tr>"))
cell_iter = row.iter_cells()
cell = next(cell_iter)
assert isinstance(cell, HtmlCell)
assert cell.html == "<td>a</td>"
# --
cell = next(cell_iter)
assert isinstance(cell, HtmlCell)
assert cell.html == "<td>b</td>"
# --
cell = next(cell_iter)
assert isinstance(cell, HtmlCell)
assert cell.html == "<td/>"
# --
with pytest.raises(StopIteration):
next(cell_iter)
def it_can_iterate_the_texts_of_the_cells_in_the_row(self):
row = HtmlRow(fragment_fromstring("<tr><td>a</td><td>b</td><td/></tr>"))
text_iter = row.iter_cell_texts()
assert next(text_iter) == "a"
assert next(text_iter) == "b"
with pytest.raises(StopIteration):
next(text_iter)
def and_it_includes_descendant_inline_text_in_cell_texts(self):
row = HtmlRow(
fragment_fromstring(
"<tr>"
"<td>ID</td>"
"<td><a href='#'>Category Link</a></td>"
"<td><span> Extra spacing </span></td>"
"</tr>"
)
)
assert list(row.iter_cell_texts()) == ["ID", "Category Link", "Extra spacing"]
def it_knows_when_it_represents_a_header_row(self):
assert HtmlRow(fragment_fromstring("<tr><td>a</td></tr>")).is_header is False
assert HtmlRow(fragment_fromstring("<tr><td>a</td></tr>"), is_header=True).is_header is True
class DescribeHtmlCell:
"""Unit-test suite for `unstructured.common.html_table.HtmlCell`."""
def it_can_serialize_the_cell_to_html(self):
assert HtmlCell(fragment_fromstring("<td>a b c</td>")).html == "<td>a b c</td>"
def and_it_preserves_nested_markup_when_serializing_nonempty_cells(self):
assert HtmlCell(fragment_fromstring("<td><a href='#'>Category Link</a></td>")).html == (
'<td><a href="#">Category Link</a></td>'
)
@pytest.mark.parametrize(
("cell_html", "expected_value"),
[
("<td> Lorem ipsum </td>", "Lorem ipsum"),
("<td><a href='#'>Category Link</a></td>", "Category Link"),
("<td/>", ""),
],
)
def it_knows_the_text_in_the_cell(self, cell_html: str, expected_value: str):
assert HtmlCell(fragment_fromstring(cell_html)).text == expected_value
@@ -0,0 +1,55 @@
<body class="Document">
<div class="Page" data-page-number="1">
<header class="Header">
<h1 class="Title">
Header
</h1>
<time class="CalendarDate">
Date: October 30, 2023
</time>
</header>
<form class="Form">
<label class="FormField" for="company-name">
From field name
</label>
<input class="FormFieldValue" value="Example value"/>
</form>
<section class="Section">
<table class="Table">
<thead class="TableHeader">
<tr class="TableRow">
<th class="TableCellHeader">
Description
</th>
<th class="TableCellHeader">
Row header
</th>
</tr>
</thead>
<tbody class="TableBody">
<tr class="TableRow">
<td class="TableCell">
Value description
</td>
<td class="TableCell">
<span class="Currency">
50 $
</span>
<span class="Measurement">
(1.32 %)
</span>
</td>
</tr>
</tbody>
</table>
</section>
<section class="Section">
<h2 class="Subtitle">
2. Subtitle
</h2>
<p class="NarrativeText">
Paragraph text
</p>
</section>
</div>
</body>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
<body class="Document">
<div class="Page" data-page-number="1">
<header class="Header">
<img alt="New York logo" class="Logo"/>
<img alt="A line graph showing the comparison of 5 year cumulative total return for stocks" class="Image"/>
</header>
</div>
</body>
@@ -0,0 +1,18 @@
<body class="Document">
<div class="Page" data-page-number="1">
<header class="Header">
<p class="NarrativeText">
Table of Contents
</p>
<address class="Address">
68 Prince Street Palmdale, CA 93550
</address>
<a class="Hyperlink">
www.google.com
</a>
<span class="UncategorizedText">
More text
</span>
</header>
</div>
</body>
@@ -0,0 +1,149 @@
<body class="Document">
<table class="Table">
<tr>
<th>
Header 1
</th>
<th>
Header 2
</th>
</tr>
<tr>
<td>
Row 1, Cell 1
</td>
<td>
Row 1, Cell 2
</td>
</tr>
<tr>
<td>
Row 2, Cell 1
</td>
<td>
Row 2, Cell 2
</td>
</tr>
</table>
<table>
<tr>
<th colspan="3">
Big Table Header
</th>
</tr>
<tr>
<td rowspan="2">
Merged Cell 1
</td>
<td>
Cell 2
</td>
<td>
Cell 3
</td>
</tr>
<tr>
<td colspan="2">
Merged Cell 4 and 5
</td>
</tr>
<tr>
<td>
Cell 6
</td>
<td>
Cell 7
</td>
<td>
Cell 8
</td>
</tr>
<tr>
<td>
Cell 9
</td>
<td colspan="2">
A cell with a lot of text. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
</td>
</tr>
<tr>
<td>
Cell 10
</td>
<td>
Cell 11
</td>
<td>
Cell 12
</td>
</tr>
</table>
<table class="TableOfContents">
<tr>
<th>
Chapter
</th>
<th>
Title
</th>
<th>
Page
</th>
</tr>
<tr>
<td>
1
</td>
<td>
Introduction
</td>
<td>
1
</td>
</tr>
<tr>
<td>
2
</td>
<td>
Getting Started
</td>
<td>
5
</td>
</tr>
<tr>
<td>
3
</td>
<td>
Basic Concepts
</td>
<td>
12
</td>
</tr>
<tr>
<td>
4
</td>
<td>
Advanced Topics
</td>
<td>
25
</td>
</tr>
<tr>
<td>
5
</td>
<td>
Conclusion
</td>
<td>
40
</td>
</tr>
</table>
</body>
@@ -0,0 +1,73 @@
import pytest
from unstructured.documents.coordinates import (
CoordinateSystem,
Orientation,
RelativeCoordinateSystem,
convert_coordinate,
)
@pytest.mark.parametrize(
("old_t", "old_t_max", "new_t_max", "t_orientation", "expected"),
[(0, 7, 5, 1, 0), (7, 7, 5, 1, 5), (0, 7, 5, -1, 5), (7, 7, 5, -1, 0)],
)
def test_convert_coordinate(old_t, old_t_max, new_t_max, t_orientation, expected):
assert convert_coordinate(old_t, old_t_max, new_t_max, t_orientation) == expected
@pytest.mark.parametrize(
("width", "height", "orientation", "x", "y", "expected_x", "expected_y"),
[
(100, 300, Orientation.CARTESIAN, 0.8, 0.4, 80, 120),
(100, 300, Orientation.SCREEN, 0.8, 0.6, 80, 120),
],
)
def test_convert_from_relative(width, height, orientation, x, y, expected_x, expected_y):
coord1 = CoordinateSystem(width, height)
coord1.orientation = orientation
assert coord1.convert_from_relative(x, y) == (expected_x, expected_y)
@pytest.mark.parametrize(
("width", "height", "orientation", "x", "y", "expected_x", "expected_y"),
[
(100, 300, Orientation.CARTESIAN, 80, 120, 0.8, 0.4),
(100, 300, Orientation.SCREEN, 80, 120, 0.8, 0.6),
],
)
def test_convert_to_relative(width, height, orientation, x, y, expected_x, expected_y):
coord1 = CoordinateSystem(width, height)
coord1.orientation = orientation
assert coord1.convert_to_relative(x, y) == (expected_x, expected_y)
@pytest.mark.parametrize(
("orientation1", "orientation2", "x", "y", "expected_x", "expected_y"),
[
(Orientation.CARTESIAN, Orientation.CARTESIAN, 80, 120, 800, 1200),
(Orientation.CARTESIAN, Orientation.SCREEN, 80, 120, 800, 800),
(Orientation.SCREEN, Orientation.CARTESIAN, 80, 120, 800, 800),
(Orientation.SCREEN, Orientation.SCREEN, 80, 120, 800, 1200),
],
)
def test_convert_to_new_system(orientation1, orientation2, x, y, expected_x, expected_y):
coord1 = CoordinateSystem(width=100, height=200)
coord1.orientation = orientation1
coord2 = CoordinateSystem(width=1000, height=2000)
coord2.orientation = orientation2
assert coord1.convert_coordinates_to_new_system(coord2, x, y) == (expected_x, expected_y)
@pytest.mark.parametrize(
("width", "height", "orientation", "x", "y", "expected_x", "expected_y"),
[
(100, 300, Orientation.CARTESIAN, 80, 120, 0.8, 0.4),
(100, 300, Orientation.SCREEN, 80, 120, 0.8, 0.6),
],
)
def test_relative_system(width, height, orientation, x, y, expected_x, expected_y):
coord1 = CoordinateSystem(width, height)
coord1.orientation = orientation
coord2 = RelativeCoordinateSystem()
assert coord1.convert_coordinates_to_new_system(coord2, x, y) == (expected_x, expected_y)
@@ -0,0 +1,776 @@
# pyright: reportPrivateUsage=false
"""Test-suite for `unstructured.documents.elements` module."""
from __future__ import annotations
import copy
import io
import json
import pathlib
from functools import partial
import pytest
from test_unstructured.unit_utils import assign_hash_ids, example_doc_path
from unstructured.cleaners.core import clean_bullets, clean_prefix
from unstructured.documents.coordinates import (
CoordinateSystem,
Orientation,
RelativeCoordinateSystem,
)
from unstructured.documents.elements import (
CheckBox,
ConsolidationStrategy,
CoordinatesMetadata,
DataSourceMetadata,
Element,
ElementMetadata,
Points,
Text,
Title,
assign_and_map_hash_ids,
)
from unstructured.partition.json import partition_json
from unstructured.staging.base import elements_from_base64_gzipped_json
@pytest.mark.parametrize("element", [Element(), Text(text=""), CheckBox()])
def test_Element_autoassigns_a_UUID_then_becomes_an_idempotent_and_deterministic_hash(
element: Element,
):
# -- element self-assigns itself a UUID --
assert isinstance(element.id, str)
assert len(element.id) == 36
assert element.id.count("-") == 4
expected_hash = "5336294a19f32ff03ef80066fbc3e0f7"
# -- calling `.id_to_hash()` changes the element's id-type to hash --
assert element.id_to_hash(0) == expected_hash
assert element.id == expected_hash
# -- `.id_to_hash()` is idempotent --
assert element.id_to_hash(0) == expected_hash
assert element.id == expected_hash
def test_Text_is_JSON_serializable():
# -- This shold run without an error --
json.dumps(Text(text="hello there!", element_id=None).to_dict())
@pytest.mark.parametrize(
"element",
[
Element(),
Text(text=""), # -- element_id should be implicitly None --
Text(text="", element_id=None), # -- setting explicitly to None --
CheckBox(),
],
)
def test_Element_self_assigns_itself_a_UUID_id(element: Element):
assert isinstance(element.id, str)
assert len(element.id) == 36
assert element.id.count("-") == 4
def test_text_element_apply_cleaners():
text_element = Text(text="[1] A Textbook on Crocodile Habitats")
text_element.apply(partial(clean_prefix, pattern=r"\[\d{1,2}\]"))
assert str(text_element) == "A Textbook on Crocodile Habitats"
def test_text_element_apply_multiple_cleaners():
cleaners = [partial(clean_prefix, pattern=r"\[\d{1,2}\]"), partial(clean_bullets)]
text_element = Text(text="[1] \u2022 A Textbook on Crocodile Habitats")
text_element.apply(*cleaners)
assert str(text_element) == "A Textbook on Crocodile Habitats"
def test_non_text_elements_are_serializable_to_text():
element = CheckBox()
assert hasattr(element, "text")
assert element.text is not None
assert element.text == ""
assert str(element) == ""
def test_apply_raises_if_func_does_not_produce_string():
def bad_cleaner(s: str):
return 1
text_element = Text(text="[1] A Textbook on Crocodile Habitats")
with pytest.raises(ValueError, match="Cleaner produced a non-string output."):
text_element.apply(bad_cleaner) # pyright: ignore[reportArgumentType]
@pytest.mark.parametrize(
("coordinates", "orientation1", "orientation2", "expected_coords"),
[
(
((1, 2), (1, 4), (3, 4), (3, 2)),
Orientation.CARTESIAN,
Orientation.CARTESIAN,
((10, 20), (10, 40), (30, 40), (30, 20)),
),
(
((1, 2), (1, 4), (3, 4), (3, 2)),
Orientation.CARTESIAN,
Orientation.SCREEN,
((10, 1980), (10, 1960), (30, 1960), (30, 1980)),
),
(
((1, 2), (1, 4), (3, 4), (3, 2)),
Orientation.SCREEN,
Orientation.CARTESIAN,
((10, 1980), (10, 1960), (30, 1960), (30, 1980)),
),
(
((1, 2), (1, 4), (3, 4), (3, 2)),
Orientation.SCREEN,
Orientation.SCREEN,
((10, 20), (10, 40), (30, 40), (30, 20)),
),
],
)
def test_convert_coordinates_to_new_system(
coordinates: Points,
orientation1: Orientation,
orientation2: Orientation,
expected_coords: Points,
):
coord1 = CoordinateSystem(100, 200)
coord1.orientation = orientation1
coord2 = CoordinateSystem(1000, 2000)
coord2.orientation = orientation2
element = Element(coordinates=coordinates, coordinate_system=coord1)
new_coords = element.convert_coordinates_to_new_system(coord2)
assert new_coords is not None
for new_coord, expected in zip(new_coords, expected_coords):
assert new_coord == pytest.approx(expected) # pyright: ignore[reportUnknownMemberType]
element.convert_coordinates_to_new_system(coord2, in_place=True)
assert element.metadata.coordinates is not None
assert element.metadata.coordinates.points is not None
for new_coord, expected in zip(element.metadata.coordinates.points, expected_coords):
assert new_coord == pytest.approx(expected) # pyright: ignore[reportUnknownMemberType]
assert element.metadata.coordinates.system == coord2
def test_convert_coordinate_to_new_system_none():
element = Element(coordinates=None, coordinate_system=None)
coord = CoordinateSystem(100, 200)
coord.orientation = Orientation.SCREEN
assert element.convert_coordinates_to_new_system(coord) is None
def test_element_constructor_coordinates_all_present():
coordinates = ((1, 2), (1, 4), (3, 4), (3, 2))
coordinate_system = RelativeCoordinateSystem()
element = Element(coordinates=coordinates, coordinate_system=coordinate_system)
expected_coordinates_metadata = CoordinatesMetadata(
points=coordinates,
system=coordinate_system,
)
assert element.metadata.coordinates == expected_coordinates_metadata
def test_element_constructor_coordinates_points_absent():
with pytest.raises(ValueError) as exc_info:
Element(coordinate_system=RelativeCoordinateSystem())
assert (
str(exc_info.value)
== "Coordinates points should not exist without coordinates system and vice versa."
)
def test_element_constructor_coordinates_system_absent():
with pytest.raises(ValueError) as exc_info:
Element(coordinates=((1, 2), (1, 4), (3, 4), (3, 2)))
assert (
str(exc_info.value)
== "Coordinates points should not exist without coordinates system and vice versa."
)
def test_coordinate_metadata_serdes():
coordinates = ((1, 2), (1, 4), (3, 4), (3, 2))
coordinate_system = RelativeCoordinateSystem()
coordinates_metadata = CoordinatesMetadata(points=coordinates, system=coordinate_system)
expected_schema = {
"layout_height": 1,
"layout_width": 1,
"points": ((1, 2), (1, 4), (3, 4), (3, 2)),
"system": "RelativeCoordinateSystem",
}
coordinates_metadata_dict = coordinates_metadata.to_dict()
assert coordinates_metadata_dict == expected_schema
assert CoordinatesMetadata.from_dict(coordinates_metadata_dict) == coordinates_metadata
def test_element_to_dict():
coordinates = ((1, 2), (1, 4), (3, 4), (3, 2))
coordinate_system = RelativeCoordinateSystem()
element = Element(
element_id="awt32t1",
coordinates=coordinates,
coordinate_system=coordinate_system,
)
assert element.to_dict() == {
"metadata": {
"coordinates": {
"layout_height": 1,
"layout_width": 1,
"points": ((1, 2), (1, 4), (3, 4), (3, 2)),
"system": "RelativeCoordinateSystem",
},
},
"type": None,
"text": "",
"element_id": "awt32t1",
}
class DescribeElementMetadata:
"""Unit-test suite for `unstructured.documents.elements.ElementMetadata`."""
# -- It can be constructed with known keyword arguments. In particular, including a non-known
# -- keyword argument produces a type-error at development time and raises an exception at
# -- runtime. This catches typos before they reach production.
def it_detects_unknown_constructor_args_at_both_development_time_and_runtime(self):
with pytest.raises(TypeError, match="got an unexpected keyword argument 'file_name'"):
ElementMetadata(file_name="memo.docx") # pyright: ignore[reportCallIssue]
@pytest.mark.parametrize(
"file_path",
[
pathlib.Path("documents/docx") / "memos" / "memo-2023-11-10.docx",
"documents/docx/memos/memo-2023-11-10.docx",
],
)
def it_accommodates_either_a_pathlib_Path_or_str_for_its_filename_arg(
self, file_path: pathlib.Path | str
):
meta = ElementMetadata(filename=file_path)
assert meta.file_directory == "documents/docx/memos"
assert meta.filename == "memo-2023-11-10.docx"
def it_leaves_both_filename_and_file_directory_None_when_neither_is_specified(self):
meta = ElementMetadata()
assert meta.file_directory is None
assert meta.filename is None
@pytest.mark.parametrize("file_path", [pathlib.Path("memo.docx"), "memo.docx"])
def and_it_leaves_file_directory_None_when_not_specified_and_filename_is_not_a_path(
self, file_path: pathlib.Path | str
):
meta = ElementMetadata(filename=file_path)
assert meta.file_directory is None
assert meta.filename == "memo.docx"
def and_it_splits_off_directory_path_from_its_filename_arg_when_it_is_a_file_path(self):
meta = ElementMetadata(filename="documents/docx/memo-2023-11-11.docx")
assert meta.file_directory == "documents/docx"
assert meta.filename == "memo-2023-11-11.docx"
def but_it_prefers_a_specified_file_directory_when_filename_also_contains_a_path(self):
meta = ElementMetadata(filename="tmp/staging/memo.docx", file_directory="documents/docx")
assert meta.file_directory == "documents/docx"
assert meta.filename == "memo.docx"
# -- It knows the types of its known members so type-checking support is available. --
def it_knows_the_types_of_its_known_members_so_type_checking_support_is_available(self):
ElementMetadata(
category_depth="2", # pyright: ignore[reportArgumentType]
file_directory=True, # pyright: ignore[reportArgumentType]
text_as_html=42, # pyright: ignore[reportArgumentType]
)
# -- it does not check types at runtime however (choosing to avoid validation overhead) --
# -- It only stores a field's value when it is not None. --
def it_returns_the_value_of_an_attribute_it_has(self):
meta = ElementMetadata(url="https://google.com")
assert "url" in meta.__dict__
assert meta.url == "https://google.com"
def and_it_returns_None_for_a_known_attribute_it_does_not_have(self):
meta = ElementMetadata()
assert "url" not in meta.__dict__
assert meta.url is None
def but_it_raises_AttributeError_for_an_unknown_attribute_it_does_not_have(self):
meta = ElementMetadata()
assert "coefficient" not in meta.__dict__
with pytest.raises(AttributeError, match="object has no attribute 'coefficient'"):
meta.coefficient
def it_stores_a_non_None_field_value_when_assigned(self):
meta = ElementMetadata()
assert "file_directory" not in meta.__dict__
meta.file_directory = "tmp/"
assert "file_directory" in meta.__dict__
assert meta.file_directory == "tmp/"
def it_removes_a_field_when_None_is_assigned_to_it(self):
meta = ElementMetadata(file_directory="tmp/")
assert "file_directory" in meta.__dict__
assert meta.file_directory == "tmp/"
meta.file_directory = None
assert "file_directory" not in meta.__dict__
assert meta.file_directory is None
# -- It can serialize itself to a dict -------------------------------------------------------
def it_can_serialize_itself_to_a_dict(self):
meta = ElementMetadata(
category_depth=1,
file_directory="tmp/",
page_number=2,
text_as_html="<table></table>",
url="https://google.com",
)
assert meta.to_dict() == {
"category_depth": 1,
"file_directory": "tmp/",
"page_number": 2,
"text_as_html": "<table></table>",
"url": "https://google.com",
}
def and_it_serializes_a_coordinates_sub_object_to_a_dict_when_it_is_present(self):
meta = ElementMetadata(
category_depth=1,
coordinates=CoordinatesMetadata(
points=((2, 2), (1, 4), (3, 4), (3, 2)),
system=RelativeCoordinateSystem(),
),
page_number=2,
)
assert meta.to_dict() == {
"category_depth": 1,
"coordinates": {
"layout_height": 1,
"layout_width": 1,
"points": ((2, 2), (1, 4), (3, 4), (3, 2)),
"system": "RelativeCoordinateSystem",
},
"page_number": 2,
}
def and_it_serializes_a_data_source_sub_object_to_a_dict_when_it_is_present(self):
meta = ElementMetadata(
category_depth=1,
data_source=DataSourceMetadata(
url="https://www.nih.gov/about-nih/who-we-are/nih-director",
date_created="2023-11-09",
),
page_number=2,
)
assert meta.to_dict() == {
"category_depth": 1,
"data_source": {
"url": "https://www.nih.gov/about-nih/who-we-are/nih-director",
"date_created": "2023-11-09",
},
"page_number": 2,
}
def and_it_round_trips_an_enrichment_origins_dict_of_lists_through_a_dict(self):
enrichment_origins = {
"text": [
{"type": "enrichment_foo", "provider": "provider_a", "model": "model_x"},
{"type": "enrichment_bar", "provider": "provider_a", "model": "model_x"},
],
"embeddings": [
{"type": "enrichment_baz", "provider": "provider_b", "model": "model_y"},
],
}
meta = ElementMetadata(enrichment_origins=enrichment_origins)
# -- it serializes verbatim (no special-casing needed) --
assert meta.to_dict()["enrichment_origins"] == enrichment_origins
# -- and rehydrates to an equal value --
assert ElementMetadata.from_dict(meta.to_dict()).enrichment_origins == enrichment_origins
def and_it_serializes_an_orig_elements_sub_object_to_base64_when_it_is_present(self):
elements = assign_hash_ids([Title("Lorem"), Text("Lorem Ipsum")])
meta = ElementMetadata(
category_depth=1,
orig_elements=elements,
page_number=2,
)
meta_dict = meta.to_dict()
assert meta_dict["category_depth"] == 1
assert meta_dict["page_number"] == 2
# Verify the orig_elements value is a base64 string that round-trips correctly.
# We don't compare the exact compressed bytes because zlib output varies across
# implementations (e.g. standard zlib vs zlib-ng).
assert isinstance(meta_dict["orig_elements"], str)
restored = elements_from_base64_gzipped_json(meta_dict["orig_elements"])
assert len(restored) == 2
assert restored[0].text == "Lorem"
assert restored[1].text == "Lorem Ipsum"
def but_unlike_in_ElementMetadata_unknown_fields_in_sub_objects_are_ignored(self):
"""Metadata sub-objects ignore fields they do not explicitly define.
This is _not_ the case for ElementMetadata itself where an non-known field is welcomed as a
user-defined ad-hoc metadata field.
"""
element_metadata = {
"new_field": "hello",
"data_source": {
"new_field": "world",
},
"coordinates": {
"new_field": "foo",
},
}
metadata = ElementMetadata.from_dict(element_metadata)
metadata_dict = metadata.to_dict()
assert "new_field" in metadata_dict
assert "new_field" not in metadata_dict["coordinates"]
assert "new_field" not in metadata_dict["data_source"]
# -- It can deserialize itself from a dict ---------------------------------------------------
def it_can_deserialize_itself_from_a_dict(self):
meta_dict = {
"category_depth": 1,
"coefficient": 0.58,
"coordinates": {
"layout_height": 4,
"layout_width": 2,
"points": ((1, 2), (1, 4), (3, 4), (3, 2)),
"system": "RelativeCoordinateSystem",
},
"data_source": {
"url": "https://www.nih.gov/about-nih/who-we-are/nih-director",
"date_created": "2023-11-09",
},
"languages": ["eng"],
}
meta = ElementMetadata.from_dict(meta_dict)
# -- known fields present in dict are present in meta --
assert meta.category_depth == 1
# -- known sub-object fields present in dict are present in meta --
assert meta.coordinates == CoordinatesMetadata(
points=((1, 2), (1, 4), (3, 4), (3, 2)),
system=RelativeCoordinateSystem(),
)
assert meta.data_source == DataSourceMetadata(
url="https://www.nih.gov/about-nih/who-we-are/nih-director",
date_created="2023-11-09",
)
# -- known fields absent from dict report None but are not present in meta --
assert meta.file_directory is None
assert "file_directory" not in meta.__dict__
# -- non-known fields present in dict are present in meta (we have no way to tell whether
# -- they are "ad-hoc" or not because we lack indication of user-intent)
assert meta.coefficient == 0.58
# -- ad-hoc fields absent from dict raise on attempted access --
with pytest.raises(AttributeError, match="ntMetadata' object has no attribute 'quotient'"):
meta.quotient
# -- but that can be worked around by end-user --
assert (meta.quotient if hasattr(meta, "quotient") else None) is None
# -- mutating a mutable (collection) field does not affect the original value --
assert isinstance(meta.languages, list)
assert meta.languages == ["eng"]
meta.languages.append("spa")
assert meta.languages == ["eng", "spa"]
assert meta_dict["languages"] == ["eng"]
# -- It allows downstream users to add an arbitrary new member by assignment. ----------------
def it_allows_an_end_user_to_add_an_arbitrary_field(self):
meta = ElementMetadata()
meta.foobar = 7
assert "foobar" in meta.__dict__
assert meta.foobar == 7
def and_fields_so_added_appear_in_the_metadata_JSON(self):
meta = ElementMetadata()
meta.foobar = 7
assert meta.to_dict() == {"foobar": 7}
def and_it_removes_an_end_user_field_when_it_is_assigned_None(self):
meta = ElementMetadata()
meta.foobar = 7
assert "foobar" in meta.__dict__
meta.foobar = None
assert "foobar" not in meta.__dict__
with pytest.raises(
AttributeError, match="'ElementMetadata' object has no attribute 'foobar'"
):
meta.foobar
# -- It can update itself from another instance ----------------------------------------------
def it_can_update_itself_from_another_instance(self):
meta = ElementMetadata(category_depth=1, page_number=1)
meta.coefficient = 0.58
meta.stem_length = 18
other = ElementMetadata(file_directory="tmp/", page_number=2)
other.quotient = 1.4
other.stem_length = 20
meta.update(other)
# -- known-fields present on self but not other are unchanged --
assert meta.category_depth == 1
# -- known-fields present on other but not self are added --
assert meta.file_directory == "tmp/"
# -- known-fields present on both self and other are updated --
assert meta.page_number == 2
# -- ad-hoc-fields present on self but not other are unchanged --
assert meta.coefficient == 0.58
# -- ad-hoc-fields present on other but not self are added --
assert meta.quotient == 1.4
# -- ad-hoc-fields present on both self and other are updated --
assert meta.stem_length == 20
# -- other is left unchanged --
assert other.category_depth is None
assert other.file_directory == "tmp/"
assert other.page_number == 2
assert other.text_as_html is None
assert other.url is None
assert other.quotient == 1.4
assert other.stem_length == 20
with pytest.raises(AttributeError, match="etadata' object has no attribute 'coefficient'"):
other.coefficient
def but_it_raises_on_attempt_to_update_from_a_non_ElementMetadata_object(self):
meta = ElementMetadata()
with pytest.raises(ValueError, match=r"ate\(\)' must be an instance of 'ElementMetadata'"):
meta.update({"coefficient": "0.56"}) # pyright: ignore[reportArgumentType]
# -- It knows when it is equal to another instance -------------------------------------------
def it_is_equal_to_another_instance_with_the_same_known_field_values(self):
meta = ElementMetadata(
category_depth=1,
coordinates=CoordinatesMetadata(
points=((1, 2), (1, 4), (3, 4), (3, 2)),
system=RelativeCoordinateSystem(),
),
data_source=DataSourceMetadata(
url="https://www.nih.gov/about-nih/who-we-are/nih-director",
date_created="2023-11-08",
),
file_directory="tmp/",
languages=["eng"],
page_number=2,
text_as_html="<table></table>",
url="https://google.com",
)
assert meta == ElementMetadata(
category_depth=1,
coordinates=CoordinatesMetadata(
points=((1, 2), (1, 4), (3, 4), (3, 2)),
system=RelativeCoordinateSystem(),
),
data_source=DataSourceMetadata(
url="https://www.nih.gov/about-nih/who-we-are/nih-director",
date_created="2023-11-08",
),
file_directory="tmp/",
languages=["eng"],
page_number=2,
text_as_html="<table></table>",
url="https://google.com",
)
def but_it_is_never_equal_to_a_non_ElementMetadata_object(self):
class NotElementMetadata:
pass
meta = ElementMetadata()
other = NotElementMetadata()
# -- all the "fields" are the same --
assert meta.__dict__ == other.__dict__
# -- but it is rejected solely because its type is different --
assert meta != other
def it_is_equal_to_another_instance_with_the_same_ad_hoc_field_values(self):
meta = ElementMetadata(category_depth=1)
meta.coefficient = 0.58
other = ElementMetadata(category_depth=1)
other.coefficient = 0.58
assert meta == other
def but_it_is_not_equal_to_an_instance_with_ad_hoc_fields_that_differ(self):
meta = ElementMetadata(category_depth=1)
meta.coefficient = 0.58
other = ElementMetadata(category_depth=1)
other.coefficient = 0.72
assert meta != other
def it_is_not_equal_when_a_list_field_contains_different_items(self):
meta = ElementMetadata(languages=["eng"])
assert meta != ElementMetadata(languages=["eng", "spa"])
def and_it_is_not_equal_when_the_coordinates_sub_object_field_differs(self):
meta = ElementMetadata(
coordinates=CoordinatesMetadata(
points=((1, 2), (1, 4), (3, 4), (3, 2)),
system=RelativeCoordinateSystem(),
)
)
assert meta != ElementMetadata(
coordinates=CoordinatesMetadata(
points=((2, 2), (2, 4), (3, 4), (4, 2)),
system=RelativeCoordinateSystem(),
)
)
def and_it_is_not_equal_when_the_data_source_sub_object_field_differs(self):
meta = ElementMetadata(
data_source=DataSourceMetadata(
url="https://www.nih.gov/about-nih/who-we-are/nih-director",
date_created="2023-11-08",
)
)
assert meta != ElementMetadata(
data_source=DataSourceMetadata(
url="https://www.nih.gov/about-nih/who-we-are/nih-director",
date_created="2023-11-09",
)
)
# -- There is a consolidation-strategy for all known fields ----------------------------------
def it_can_find_the_consolidation_strategy_for_each_of_its_known_fields(self):
metadata = ElementMetadata()
metadata_field_names = sorted(metadata._known_field_names)
consolidation_strategies = ConsolidationStrategy.field_consolidation_strategies()
for field_name in metadata_field_names:
assert field_name in consolidation_strategies, (
f"ElementMetadata field `.{field_name}` does not have a consolidation strategy."
f" Add one in `ConsolidationStrategy.field_consolidation_strategies()."
)
def test_hash_ids_are_unique_for_duplicate_elements():
# GIVEN
parent = Text(text="Parent", metadata=ElementMetadata(page_number=1))
elements: list[Element] = [
parent,
Text(text="Element", metadata=ElementMetadata(page_number=1, parent_id=parent.id)),
Text(text="Element", metadata=ElementMetadata(page_number=1, parent_id=parent.id)),
]
# WHEN
updated_elements = assign_and_map_hash_ids(copy.deepcopy(elements))
ids = [element.id for element in updated_elements]
# THEN
assert len(ids) == len(set(ids)), "Recalculated IDs must be unique."
assert elements[1].metadata.parent_id == elements[2].metadata.parent_id
for idx, updated_element in enumerate(updated_elements):
assert updated_element.id != elements[idx].id, "IDs haven't changed after recalculation"
if updated_element.metadata.parent_id is not None:
assert updated_element.metadata.parent_id in ids, "Parent ID not in the list of IDs"
assert updated_element.metadata.parent_id != elements[idx].metadata.parent_id, (
"Parent ID hasn't changed after recalculation"
)
def test_hash_ids_can_handle_duplicated_element_instances():
# GIVEN
parent = Text(text="Parent", metadata=ElementMetadata(page_number=1))
element = Text(text="Element", metadata=ElementMetadata(page_number=1, parent_id=parent.id))
elements: list[Element] = [parent, element, element]
# WHEN
updated_elements = assign_and_map_hash_ids(copy.deepcopy(elements))
ids = [element.id for element in updated_elements]
# THEN
assert len(ids) == len(set(ids)) + 1, "One element is duplicated so uniques should be one less."
assert elements[1].metadata.parent_id == elements[2].metadata.parent_id
def test_hash_ids_are_deterministic():
parent = Text(text="Parent", metadata=ElementMetadata(page_number=1))
elements: list[Element] = [
parent,
Text(text="Element", metadata=ElementMetadata(page_number=1, parent_id=parent.id)),
Text(text="Element", metadata=ElementMetadata(page_number=1, parent_id=parent.id)),
]
updated_elements = assign_and_map_hash_ids(elements)
ids = [element.id for element in updated_elements]
parent_ids = [element.metadata.parent_id for element in updated_elements]
assert ids == [
"ea9eb7e80383c190f8cafce1ad666624",
"4112a8d24886276e18e759d06956021b",
"eba84bbe7f03e8b91a1527323040ee3d",
]
assert parent_ids == [
None,
"ea9eb7e80383c190f8cafce1ad666624",
"ea9eb7e80383c190f8cafce1ad666624",
]
@pytest.mark.parametrize(
("text", "sequence_number", "filename", "page_number", "expected_hash"),
[
# -- pdf files support page numbers --
("foo", 1, "foo.pdf", 1, "4bb264eb23ceb44cd8fcc5af44f8dc71"),
("foo", 2, "foo.pdf", 1, "75fc1de48cf724ec00aa8d1c5a0d3758"),
# -- txt files don't have a page number --
("some text", 0, "some.txt", None, "1a2627b5760c06b1440102f11a1edb0f"),
("some text", 1, "some.txt", None, "e3fd10d867c4a1c0264dde40e3d7e45a"),
],
)
def test_id_to_hash_calculates(
text: str, sequence_number: int, filename: str, page_number: int | None, expected_hash: str
):
element = Text(
text=text,
metadata=ElementMetadata(filename=filename, page_number=page_number),
)
assert element.id_to_hash(sequence_number) == expected_hash, "Returned ID does not match"
assert element.id == expected_hash, "ID should be set"
def test_formskeysvalues_reads_saves():
filename = example_doc_path("test_evaluate_files/unstructured_output/form.json")
as_read = partition_json(filename=filename)
tmp_file = io.StringIO()
json.dump([element.to_dict() for element in as_read], tmp_file)
tmp_file.seek(0)
as_read_2 = partition_json(file=tmp_file) # type: ignore[arg-type]
assert as_read == as_read_2
@@ -0,0 +1,278 @@
"""Unit tests for the shared HTML output-sanitization policy (GHSA-v5mq-3xhg-98m9)."""
import pytest
from unstructured.documents.html_sanitization import (
ALLOWED_URL_SCHEMES,
is_event_handler_attribute,
is_safe_tag,
is_safe_url,
sanitize_attributes,
sanitize_html_fragment,
sanitize_style_attribute,
)
class DescribeIsSafeUrl:
@pytest.mark.parametrize(
"url",
[
"http://example.com",
"https://example.com/path?q=1",
"mailto:user@example.com",
"tel:+15551234",
"/relative/path",
"relative/path",
"#anchor",
"?query=only",
],
)
def it_allows_safe_urls(self, url: str):
assert is_safe_url(url) is True
@pytest.mark.parametrize(
"url",
[
"javascript:alert(1)",
"JaVaScRiPt:alert(1)",
" javascript:alert(1)",
"java\tscript:alert(1)",
"java\nscript:alert(1)",
"vbscript:msgbox(1)",
"data:text/html,<script>alert(1)</script>",
"data:application/javascript,alert(1)",
"file:///etc/passwd",
],
)
def it_rejects_dangerous_urls(self, url: str):
assert is_safe_url(url) is False
def it_only_permits_raster_image_data_uris_on_img_src(self):
# -- data: is in the scheme allowlist but narrowed to raster img[src] so
# -- base64 images survive while script-executable data URIs do not --
assert "data" in ALLOWED_URL_SCHEMES
assert (
is_safe_url(
"data:image/gif;base64,R0lGOD",
tag_name="img",
attribute_name="src",
)
is True
)
assert (
is_safe_url(
"data:image/svg+xml;base64,PHN2Zz4=",
tag_name="img",
attribute_name="src",
)
is False
)
assert (
is_safe_url(
"data:image/png;base64,iVBORw0KGgo=",
tag_name="a",
attribute_name="href",
)
is False
)
assert (
is_safe_url(
"data:text/html;base64,PHNjcmlwdD4=",
tag_name="img",
attribute_name="src",
)
is False
)
class DescribeIsEventHandlerAttribute:
@pytest.mark.parametrize("name", ["onerror", "onload", "onmouseover", "ONCLICK", " onfocus"])
def it_detects_event_handlers(self, name: str):
assert is_event_handler_attribute(name) is True
@pytest.mark.parametrize("name", ["class", "href", "id", "data-src", "title"])
def it_ignores_non_event_handlers(self, name: str):
assert is_event_handler_attribute(name) is False
class DescribeIsSafeTag:
@pytest.mark.parametrize("tag", ["div", "p", "a", "img", "table", "TD", "svg"])
def it_allows_known_tags(self, tag: str):
assert is_safe_tag(tag) is True
@pytest.mark.parametrize("tag", ["script", "iframe", "object", "embed", "", None])
def it_rejects_unknown_tags(self, tag):
assert is_safe_tag(tag) is False
class DescribeSanitizeAttributes:
def it_drops_event_handler_attributes(self):
result = sanitize_attributes(
{"onerror": "alert(1)", "onmouseover": "x", "class": "Foo"},
tag_name="p",
)
assert result == {"class": "Foo"}
def it_drops_url_attributes_with_unsafe_schemes(self):
result = sanitize_attributes({"href": "javascript:alert(1)", "id": "x"}, tag_name="a")
assert result == {"id": "x"}
def it_keeps_url_attributes_with_safe_schemes(self):
link_result = sanitize_attributes({"href": "https://example.com"}, tag_name="a")
image_result = sanitize_attributes({"src": "/img.png"}, tag_name="img")
assert link_result == {"href": "https://example.com"}
assert image_result == {"src": "/img.png"}
def it_keeps_raster_data_image_uris_on_img_src(self):
result = sanitize_attributes({"src": "data:image/png;base64,AAAA"}, tag_name="img")
assert result == {"src": "data:image/png;base64,AAAA"}
def it_drops_data_image_uris_outside_img_src(self):
result = sanitize_attributes({"href": "data:image/png;base64,AAAA"}, tag_name="a")
assert result == {}
def it_drops_svg_data_image_uris(self):
result = sanitize_attributes({"src": "data:image/svg+xml;base64,PHN2Zz4="}, tag_name="img")
assert result == {}
def it_drops_malformed_attribute_names(self):
# -- a name that isn't a valid HTML attribute name can't be emitted safely --
result = sanitize_attributes({'x"><svg onload=alert(1)>': "y", "id": "ok"}, tag_name="p")
assert result == {"id": "ok"}
def it_drops_attributes_not_allowed_on_the_tag(self):
result = sanitize_attributes(
{
"action": "https://example.com/submit",
"class": "Form",
"formaction": "https://example.com/button",
"http-equiv": "refresh",
"srcset": "https://example.com/a.png 1x",
},
tag_name="form",
)
assert result == {"class": "Form"}
def it_allows_tag_specific_attributes(self):
result = sanitize_attributes(
{"colspan": "2", "rowspan": "3", "headers": "h1", "scope": "col"},
tag_name="td",
)
assert result == {"colspan": "2", "rowspan": "3", "headers": "h1", "scope": "col"}
def it_filters_inline_style_values(self):
result = sanitize_attributes(
{
"style": (
"background-color: lightblue; position: fixed; inset: 0; "
"z-index: 9999; border: 1px solid black"
)
},
tag_name="p",
)
assert result == {"style": "background-color: lightblue; border: 1px solid black"}
def it_adds_noopener_noreferrer_for_blank_targets(self):
result = sanitize_attributes(
{"href": "https://example.com", "target": "_blank"},
tag_name="a",
)
assert result == {
"href": "https://example.com",
"target": "_blank",
"rel": "noopener noreferrer",
}
def it_does_not_html_escape_values(self):
# -- escaping happens once, at emit time; values pass through untouched here --
result = sanitize_attributes({"title": 'a & b < c "d"'}, tag_name="p")
assert result == {"title": 'a & b < c "d"'}
def it_scheme_filters_list_valued_url_attributes(self):
result = sanitize_attributes({"href": ["javascript:alert(1)"]}, tag_name="a")
assert result == {}
class DescribeSanitizeStyleAttribute:
def it_keeps_safe_presentation_declarations(self):
style = "background-color: lightblue; text-align: right; border-collapse: collapse"
assert sanitize_style_attribute(style) == style
def it_drops_layout_overlay_declarations(self):
style = "position: fixed; inset: 0; z-index: 9999; color: red"
assert sanitize_style_attribute(style) == "color: red"
def it_drops_url_and_expression_values(self):
style = "background-color: url(javascript:alert(1)); color: expression(alert(1))"
assert sanitize_style_attribute(style) == ""
class DescribeSanitizeHtmlFragment:
def it_strips_event_handlers(self):
assert "onerror" not in sanitize_html_fragment('<img src="x" onerror="alert(1)">')
assert "onmouseover" not in sanitize_html_fragment('<p onmouseover="alert(1)">hi</p>')
def it_strips_javascript_hrefs(self):
assert "javascript:" not in sanitize_html_fragment('<a href="javascript:alert(1)">x</a>')
def it_removes_disallowed_tags(self):
cleaned = sanitize_html_fragment("<script>alert(1)</script><p>ok</p>")
assert "<script" not in cleaned
assert "<p>" in cleaned or "ok" in cleaned
def it_preserves_base64_image_sources(self):
cleaned = sanitize_html_fragment('<img src="data:image/png;base64,iVBORw0KGgo=" alt="ok">')
assert "data:image/png" in cleaned
def it_preserves_avif_base64_image_sources(self):
cleaned = sanitize_html_fragment('<img src="data:image/avif;base64,AAAAAA==" alt="ok">')
assert "data:image/avif" in cleaned
def it_drops_svg_data_image_sources(self):
cleaned = sanitize_html_fragment(
'<img src="data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=" alt="bad">'
)
assert "data:image/svg" not in cleaned
def it_drops_data_image_links(self):
cleaned = sanitize_html_fragment('<a href="data:image/png;base64,AAAA">x</a>')
assert "data:image/png" not in cleaned
def it_drops_non_image_data_uris(self):
cleaned = sanitize_html_fragment('<img src="data:text/html,<script>alert(1)</script>">')
assert "data:text/html" not in cleaned
assert "<script" not in cleaned
def it_adds_noopener_noreferrer_to_links(self):
cleaned = sanitize_html_fragment('<a href="https://example.com" target="_blank">x</a>')
assert "noopener" in cleaned
assert "noreferrer" in cleaned
def it_does_not_add_rel_to_same_tab_links(self):
# -- same-tab links must keep their Referer header (no unconditional rel) --
cleaned = sanitize_html_fragment('<a href="https://example.com">x</a>')
assert "rel=" not in cleaned
assert "noreferrer" not in cleaned
def it_does_not_duplicate_rel_on_blank_target_links(self):
cleaned = sanitize_html_fragment(
'<a href="https://example.com" target="_blank" rel="noopener noreferrer">x</a>'
)
assert cleaned.count("rel=") == 1
def it_filters_inline_styles(self):
cleaned = sanitize_html_fragment(
'<p style="position:fixed;inset:0;z-index:9999;color:red">x</p>'
)
assert "position" not in cleaned
assert "inset" not in cleaned
assert "z-index" not in cleaned
assert "color:red" in cleaned
def it_preserves_legitimate_table_formatting(self):
cleaned = sanitize_html_fragment(
'<table><tr><td colspan="2" style="border: 1px solid black;">A</td></tr></table>'
)
assert "<table" in cleaned
assert 'colspan="2"' in cleaned
assert "border" in cleaned
@@ -0,0 +1,46 @@
from collections import defaultdict
from typing import Type
from unstructured.documents import elements, ontology
from unstructured.documents.mappings import (
ALL_ONTOLOGY_ELEMENT_TYPES,
HTML_TAG_TO_DEFAULT_ELEMENT_TYPE_MAP,
ONTOLOGY_CLASS_TO_UNSTRUCTURED_ELEMENT_TYPE,
get_all_subclasses,
)
from unstructured.documents.ontology import OntologyElement
def test_if_all_html_tags_have_default_ontology_type():
html_tag_to_possible_ontology_classes: dict[str, list[Type[ontology.OntologyElement]]] = (
defaultdict(list)
)
for ontology_class in ALL_ONTOLOGY_ELEMENT_TYPES:
for tag in ontology_class().allowed_tags:
html_tag_to_possible_ontology_classes[tag].append(ontology_class)
for html_tag, possible_ontology_classes in html_tag_to_possible_ontology_classes.items():
assert html_tag in HTML_TAG_TO_DEFAULT_ELEMENT_TYPE_MAP
assert HTML_TAG_TO_DEFAULT_ELEMENT_TYPE_MAP[html_tag] in possible_ontology_classes + [
ontology.UncategorizedText
] # In some cases it is better to use unknown type than assign incorrect type
def test_all_expected_ontology_types_are_subclasses_of_OntologyElement():
for element_type in HTML_TAG_TO_DEFAULT_ELEMENT_TYPE_MAP.values():
assert issubclass(element_type, OntologyElement)
def test_ontology_to_unstructured_mapping_has_valid_types():
for (
ontology_element,
unstructured_element,
) in ONTOLOGY_CLASS_TO_UNSTRUCTURED_ELEMENT_TYPE.items():
assert issubclass(unstructured_element, elements.Element)
assert issubclass(ontology_element, ontology.OntologyElement)
def test_all_ontology_elements_are_defined_in_mapping_to_unstructured():
for ontology_element in get_all_subclasses(ontology.OntologyElement):
assert ontology_element in ONTOLOGY_CLASS_TO_UNSTRUCTURED_ELEMENT_TYPE
@@ -0,0 +1,513 @@
from pathlib import Path
import pytest
from bs4 import BeautifulSoup
from unstructured.chunking.basic import chunk_elements
from unstructured.chunking.title import chunk_by_title
from unstructured.documents.elements import ElementMetadata, Text
from unstructured.documents.ontology import (
Column,
Document,
Heading,
Hyperlink,
Image,
Page,
Paragraph,
Section,
Subtitle,
Table,
Title,
remove_ids_and_class_from_table,
)
from unstructured.embed.openai import OpenAIEmbeddingConfig, OpenAIEmbeddingEncoder
from unstructured.partition.html import partition_html
from unstructured.partition.html.transformations import (
can_unstructured_elements_be_merged,
ontology_to_unstructured_elements,
parse_html_to_ontology,
)
from unstructured.partition.json import partition_json
from unstructured.staging.base import elements_from_json
def test_remove_ids_and_class_from_table():
html_text = """
<table>
<tr class="TableRow">
<td><img class="Signature" alt="cell 1"/></td>
<td>cell 2</td>
</tr>
<tr>
<td><IMG class="Signature" alt="cell 3"/></td>
<td>cell 4</td>
</tr>
<tr>
<td><input class="Checkbox" type="checkbox"/></td>
<td>Option 1</td>
</tr>
</table>
"""
soup = BeautifulSoup(html_text, "html.parser")
assert (
str(remove_ids_and_class_from_table(soup))
== """
<table>
<tr>
<td><img alt="cell 1" class="Signature"/></td>
<td>cell 2</td>
</tr>
<tr>
<td><img alt="cell 3" class="Signature"/></td>
<td>cell 4</td>
</tr>
<tr>
<td><input class="Checkbox" type="checkbox"/></td>
<td>Option 1</td>
</tr>
</table>
"""
)
def test_page_number_is_passed_correctly():
ontology = Document(
children=[
Page(
children=[Paragraph(text="Paragraph1")],
additional_attributes={"data-page-number": "1"},
),
Page(
children=[Paragraph(text="Paragraph2")],
additional_attributes={"data-page-number": "2"},
),
]
)
unstructured_elements = ontology_to_unstructured_elements(ontology)
page1, p1, page2, p2 = unstructured_elements
assert p1.metadata.page_number == 1
assert p2.metadata.page_number == 2
def test_invalid_page_number_is_not_passed():
ontology = Document(
children=[
Page(
children=[Paragraph(text="Paragraph1")],
additional_attributes={"data-page-number": "invalid"},
)
]
)
unstructured_elements = ontology_to_unstructured_elements(ontology)
page1, p1 = unstructured_elements
assert not p1.metadata.page_number
def test_category_depth_is_not_derived_from_layout_nesting():
"""ML-1328: `category_depth` reflects heading level, not DOM/layout nesting.
Layout containers (Page/Column) and non-heading content carry no `category_depth`, so a
paragraph reads the same whether it sits in a single-column page or inside a column of a
two-column page (i.e. depth does not change solely due to multi-column layout).
"""
ontology = Document(
children=[
Page(children=[Paragraph(text="Paragraph1")]),
Page(
children=[
Column(children=[Paragraph(text="Paragraph2")]),
Column(children=[Paragraph(text="Paragraph3")]),
]
),
]
)
unstructured_elements = ontology_to_unstructured_elements(ontology)
page1, p1, page2, c1, p2, c2, p3 = unstructured_elements
# -- layout containers are not headings -> no depth --
assert page1.metadata.category_depth is None
assert page2.metadata.category_depth is None
assert c1.metadata.category_depth is None
assert c2.metadata.category_depth is None
# -- plain paragraphs are not headings -> no depth, regardless of single- vs multi-column --
assert p1.metadata.category_depth is None
assert p2.metadata.category_depth is None
assert p3.metadata.category_depth is None
def test_category_depth_is_derived_from_heading_level():
"""ML-1328: heading elements get `category_depth` from their HTML heading level."""
ontology = Document(
children=[
Page(
children=[
Title(text="Section"), # <h1> -> 0
Subtitle(text="Subsection"), # <h2> -> 1
Heading(text="Sub-subsection", html_tag_name="h3"), # <h3> -> 2
Paragraph(text="Body text"), # not a heading -> None
]
),
]
)
unstructured_elements = ontology_to_unstructured_elements(ontology)
_page, title, subtitle, heading, body = unstructured_elements
assert title.metadata.category_depth == 0
assert subtitle.metadata.category_depth == 1
assert heading.metadata.category_depth == 2
assert body.metadata.category_depth is None
def test_partition_html_v2_assigns_heading_based_parent_id():
"""ML-1328 (AC #3): partition_html(v2) yields section->subsection parent_id.
Hierarchy is assigned by the `@apply_metadata` decorator from the heading-level
`category_depth`, exactly as for every other partitioner -- the v2 converter only
sets `category_depth`, it does not run `set_element_hierarchy` itself. Both production
callers (unstructured and the VLM partitioner) go through `partition_html`, so the
decorator always runs; this exercises that real path end to end.
"""
html = (
'<div class="Page">'
'<h1 class="Title">Section A</h1>'
'<p class="NarrativeText">intro body under A</p>'
'<h2 class="Subtitle">Sub A1</h2>'
'<p class="NarrativeText">body under A1</p>'
'<h3 class="Heading">Sub A1a</h3>'
'<p class="NarrativeText">body under A1a</p>'
'<h2 class="Subtitle">Sub A2</h2>'
'<p class="NarrativeText">body under A2</p>'
"</div>"
)
elements = partition_html(text=html, html_parser_version="v2")
by_id = {e.id: e for e in elements}
by_text = {e.text: e for e in elements if e.text}
def parent_of(text):
pid = by_text[text].metadata.parent_id
return by_id.get(pid) if pid else None
# top-level heading has no heading ancestor
assert parent_of("Section A") is None
# content + subsections are parented to their enclosing heading
assert parent_of("intro body under A") is by_text["Section A"]
assert parent_of("Sub A1") is by_text["Section A"]
assert parent_of("body under A1") is by_text["Sub A1"]
assert parent_of("Sub A1a") is by_text["Sub A1"]
assert parent_of("body under A1a") is by_text["Sub A1a"]
# a sibling subsection resets back to its section, not the deeper preceding heading
assert parent_of("Sub A2") is by_text["Section A"]
assert parent_of("body under A2") is by_text["Sub A2"]
def test_converter_leaves_content_parent_id_for_the_metadata_layer():
"""ML-1328 contract (abstraction boundary): called directly, the converter sets
`category_depth` but leaves content `parent_id=None` -- hierarchy is the
`@apply_metadata` layer's job, as for every other partitioner. This documents the
intended boundary (a direct caller does NOT get heading-based parent_id) and guards
against silently reintroducing a self-sufficient hierarchy pass in the converter.
"""
ontology = Document(
children=[
Page(
children=[
Title(text="Section A"), # h1 -> category_depth 0
Paragraph(text="intro body"),
Subtitle(text="Sub A1"), # h2 -> category_depth 1
]
),
]
)
page, section_a, intro, sub_a1 = ontology_to_unstructured_elements(ontology)
# layout container keeps its tree parent (physical structure preserved)
assert page.metadata.parent_id is not None
# content carries heading-level category_depth ...
assert section_a.metadata.category_depth == 0
assert sub_a1.metadata.category_depth == 1
# ... but no parent_id yet -- the decorator assigns the heading-based parent downstream
assert section_a.metadata.parent_id is None
assert intro.metadata.parent_id is None
assert sub_a1.metadata.parent_id is None
def test_chunking_is_applied_on_elements():
ontology = Document(
children=[
Page(children=[Paragraph(text="Paragraph1")]),
Page(
children=[
Column(children=[Paragraph(text="Paragraph2")]),
Column(children=[Paragraph(text="Paragraph3")]),
]
),
]
)
unstructured_elements = ontology_to_unstructured_elements(ontology)
chunked_basic = chunk_elements(unstructured_elements)
assert str(chunked_basic[0]) == "Paragraph1\n\nParagraph2\n\nParagraph3"
chunked_by_title = chunk_by_title(unstructured_elements)
assert str(chunked_by_title[0]) == "Paragraph1\n\nParagraph2\n\nParagraph3"
def test_embeddings_are_applied_on_elements(mocker):
ontology = Document(
children=[
Page(children=[Paragraph(text="Paragraph1")]),
Page(
children=[
Column(children=[Paragraph(text="Paragraph2")]),
Column(children=[Paragraph(text="Paragraph3")]),
]
),
]
)
unstructured_elements = ontology_to_unstructured_elements(ontology)
# Mocked client with the desired behavior for embed_documents
mock_client = mocker.MagicMock()
mock_client.embed_documents.return_value = [1, 2, 3, 4, 5, 6, 7]
# Mock get_client to return our mock_client
mocker.patch.object(OpenAIEmbeddingConfig, "get_client", return_value=mock_client)
encoder = OpenAIEmbeddingEncoder(config=OpenAIEmbeddingConfig(api_key="api_key"))
elements = encoder.embed_documents(
elements=unstructured_elements,
)
assert len(elements) == 7
page1, p1, page2, c1, p2, c2, p3 = elements
assert p1.embeddings == 2
assert p2.embeddings == 5
assert p3.embeddings == 7
@pytest.mark.parametrize(
("html_file_path", "json_file_path"),
[
("html_files/example.html", "unstructured_json_output/example.json"),
],
)
def test_ingest(html_file_path, json_file_path):
html_file_path = Path(__file__).parent / html_file_path
json_file_path = Path(__file__).parent / json_file_path
html_code = html_file_path.read_text()
expected_json_elements = elements_from_json(str(json_file_path))
ontology = parse_html_to_ontology(html_code)
unstructured_elements = ontology_to_unstructured_elements(ontology)
assert unstructured_elements == expected_json_elements
@pytest.mark.parametrize("json_file_path", ["unstructured_json_output/example.json"])
def test_parsed_ontology_can_be_serialized_from_json(json_file_path):
json_file_path = Path(__file__).parent / json_file_path
expected_json_elements = elements_from_json(str(json_file_path))
json_elements_text = json_file_path.read_text()
elements = partition_json(text=json_elements_text)
assert len(elements) == len(expected_json_elements)
for i in range(len(elements)):
assert elements[i] == expected_json_elements[i]
# The partitioning output comes from PDF file, so only stem is compared
# as the suffix is different .pdf != .json
assert Path(elements[i].metadata.filename).stem == json_file_path.stem
@pytest.mark.parametrize(
("html_file_path", "json_file_path"),
[
("html_files/example.html", "unstructured_json_output/example.json"),
("html_files/example_full_doc.html", "unstructured_json_output/example_full_doc.json"),
(
"html_files/example_with_alternative_text.html",
"unstructured_json_output/example_with_alternative_text.json",
),
("html_files/three_tables.html", "unstructured_json_output/three_tables.json"),
(
"html_files/example_with_inline_fields.html",
"unstructured_json_output/example_with_inline_fields.json",
),
],
)
def test_parsed_ontology_can_be_serialized_from_html(html_file_path, json_file_path):
html_file_path = Path(__file__).parent / html_file_path
json_file_path = Path(__file__).parent / json_file_path
expected_json_elements = elements_from_json(str(json_file_path))
html_code = html_file_path.read_text()
predicted_elements = partition_html(
text=html_code, html_parser_version="v2", unique_element_ids=True
)
assert len(expected_json_elements) == len(predicted_elements)
for i in range(len(expected_json_elements)):
assert expected_json_elements[i] == predicted_elements[i]
assert (
expected_json_elements[i].metadata.text_as_html
== predicted_elements[i].metadata.text_as_html
)
def test_inline_elements_are_squeezed():
ontology = Document(
children=[
Page(
children=[
Hyperlink(text="Hyperlink1"),
Hyperlink(text="Hyperlink2"),
Hyperlink(text="Hyperlink3"),
],
)
]
)
unstructured_elements = ontology_to_unstructured_elements(ontology)
assert len(unstructured_elements) == 2
page, text1 = unstructured_elements
assert text1.text == "Hyperlink1 Hyperlink2 Hyperlink3"
def test_text_elements_are_squeezed():
ontology = Document(
children=[
Page(
children=[
Paragraph(text="Paragraph1"),
Paragraph(text="Paragraph2"),
],
)
]
)
unstructured_elements = ontology_to_unstructured_elements(ontology)
assert len(unstructured_elements) == 2
page, text1 = unstructured_elements
assert text1.text == "Paragraph1 Paragraph2"
def test_inline_elements_are_squeezed_when_image():
ontology = Document(
children=[
Page(
children=[
Paragraph(text="Paragraph1"),
Hyperlink(text="Hyperlink1"),
Image(text="Image1"),
Hyperlink(text="Hyperlink2"),
Hyperlink(text="Hyperlink3"),
Paragraph(text="Paragraph2"),
Paragraph(text="Paragraph3"),
],
)
]
)
unstructured_elements = ontology_to_unstructured_elements(ontology)
assert len(unstructured_elements) == 4
page, text1, image, text2 = unstructured_elements
assert text1.text == "Paragraph1 Hyperlink1"
assert text2.text == "Hyperlink2 Hyperlink3 Paragraph2 Paragraph3"
assert '<a class="Hyperlink"' in text1.metadata.text_as_html
assert '<p class="Paragraph"' in text1.metadata.text_as_html
assert '<a class="Hyperlink"' in text2.metadata.text_as_html
assert '<p class="Paragraph"' in text2.metadata.text_as_html
def test_inline_elements_are_squeezed_when_table():
ontology = Document(
children=[
Page(
children=[
Hyperlink(text="Hyperlink1"),
Paragraph(text="Paragraph1"),
Paragraph(text="Paragraph2"),
Table(text="Table1"),
Paragraph(text="Paragraph2"),
Hyperlink(text="Hyperlink2"),
Hyperlink(text="Hyperlink3"),
],
)
]
)
unstructured_elements = ontology_to_unstructured_elements(ontology)
assert len(unstructured_elements) == 4
page, text1, table1, text3 = unstructured_elements
assert text1.text == "Hyperlink1 Paragraph1 Paragraph2"
assert table1.text == "Table1"
assert text3.text == "Paragraph2 Hyperlink2 Hyperlink3"
def test_inline_elements_are_on_many_depths():
ontology = Document(
children=[
Page(
children=[
Hyperlink(text="Hyperlink1"),
Paragraph(text="Paragraph1"),
Section(
children=[
Section(
children=[
Hyperlink(text="Hyperlink2"),
Hyperlink(text="Hyperlink3"),
]
),
Paragraph(text="Paragraph2"),
Hyperlink(text="Hyperlink4"),
]
),
],
)
]
)
unstructured_elements = ontology_to_unstructured_elements(ontology)
assert len(unstructured_elements) == 6
page, text1, section1, section2, text2, text3 = unstructured_elements
assert text1.text == "Hyperlink1 Paragraph1"
assert text2.text == "Hyperlink2 Hyperlink3"
assert text3.text == "Paragraph2 Hyperlink4"
def _inline_element(text: str) -> Text:
"""A childless inline (Hyperlink) element -- mergeable on the content rules alone."""
html = f'<a class="Hyperlink">{text}</a>'
return Text(text=text, metadata=ElementMetadata(text_as_html=html))
def test_inline_elements_at_the_same_nesting_depth_can_be_merged():
# ML-1328: "same level in the HTML tree" is the DOM-nesting depth. Two childless inline
# elements sitting at the same depth are eligible to be merged into one element.
first, second = _inline_element("a"), _inline_element("b")
assert can_unstructured_elements_be_merged(first, second, current_depth=2, next_depth=2) is True
def test_inline_elements_at_different_nesting_depths_are_not_merged():
# ML-1328: elements on different levels of the HTML tree must never merge, even when their
# content (childless inline) would otherwise allow it.
first, second = _inline_element("a"), _inline_element("b")
assert (
can_unstructured_elements_be_merged(first, second, current_depth=1, next_depth=2) is False
)
@@ -0,0 +1,146 @@
[
{
"element_id": "eda37931eb954fcc8dec8804c7e8fa4c",
"metadata": {
"category_depth": 0,
"file_directory": "test_unstructured/documents/html_files",
"filename": "example.html",
"filetype": "text/html",
"languages": [
"eng"
],
"last_modified": "2025-06-12T11:12:20",
"page_number": 1,
"parent_id": "037b418b76eb4ac1bd40326ff67e67b0",
"text_as_html": "<div class=\"Page\" data-page-number=\"1\" />"
},
"text": "",
"type": "UncategorizedText"
},
{
"element_id": "97eb491421584ad892074d039779fbfa",
"metadata": {
"category_depth": 1,
"file_directory": "test_unstructured/documents/html_files",
"filename": "example.html",
"filetype": "text/html",
"languages": [
"eng"
],
"last_modified": "2025-06-12T11:12:20",
"page_number": 1,
"parent_id": "eda37931eb954fcc8dec8804c7e8fa4c",
"text_as_html": "<header class=\"Header\"><h1 class=\"Title\">Header</h1><time class=\"CalendarDate\">Date: October 30, 2023</time></header>"
},
"text": "Header Date: October 30, 2023",
"type": "Header"
},
{
"element_id": "4afb6e4a90e14835b958dadb77cd8331",
"metadata": {
"category_depth": 1,
"file_directory": "test_unstructured/documents/html_files",
"filename": "example.html",
"filetype": "text/html",
"languages": [
"eng"
],
"last_modified": "2025-06-12T11:12:20",
"page_number": 1,
"parent_id": "eda37931eb954fcc8dec8804c7e8fa4c",
"text_as_html": "<form class=\"Form\"><label class=\"FormField\" for=\"company-name\">From field name</label><input class=\"FormFieldValue\" value=\"Example value\" /></form>"
},
"text": "From field name Example value",
"type": "UncategorizedText"
},
{
"element_id": "d8f996f2bc9a49f4979aac58a2a9ee93",
"metadata": {
"category_depth": 1,
"file_directory": "test_unstructured/documents/html_files",
"filename": "example.html",
"filetype": "text/html",
"languages": [
"eng"
],
"last_modified": "2025-06-12T11:12:20",
"page_number": 1,
"parent_id": "eda37931eb954fcc8dec8804c7e8fa4c",
"text_as_html": "<section class=\"Section\" />"
},
"text": "",
"type": "UncategorizedText"
},
{
"element_id": "d2c12f995ab248808900f66aec479e9d",
"metadata": {
"category_depth": 2,
"file_directory": "test_unstructured/documents/html_files",
"filename": "example.html",
"filetype": "text/html",
"languages": [
"eng"
],
"last_modified": "2025-06-12T11:12:20",
"page_number": 1,
"parent_id": "d8f996f2bc9a49f4979aac58a2a9ee93",
"text_as_html": "<table class=\"Table\"><thead><tr><th>Description</th><th>Row header</th></tr></thead><tbody><tr><td>Value description</td><td><span>50 $</span><span>(1.32 %)</span></td></tr></tbody></table>"
},
"text": "Description Row header Value description 50 $ (1.32 %)",
"type": "Table"
},
{
"element_id": "8e3f0d85329343008593f43afcad3327",
"metadata": {
"category_depth": 1,
"file_directory": "test_unstructured/documents/html_files",
"filename": "example.html",
"filetype": "text/html",
"languages": [
"eng"
],
"last_modified": "2025-06-12T11:12:20",
"page_number": 1,
"parent_id": "eda37931eb954fcc8dec8804c7e8fa4c",
"text_as_html": "<section class=\"Section\" />"
},
"text": "",
"type": "UncategorizedText"
},
{
"element_id": "5deaad75854741ccb69767881ef399db",
"metadata": {
"category_depth": 2,
"file_directory": "test_unstructured/documents/html_files",
"filename": "example.html",
"filetype": "text/html",
"languages": [
"eng"
],
"last_modified": "2025-06-12T11:12:20",
"page_number": 1,
"parent_id": "8e3f0d85329343008593f43afcad3327",
"text_as_html": "<h2 class=\"Subtitle\">2. Subtitle</h2>"
},
"text": "2. Subtitle",
"type": "Title"
},
{
"element_id": "9e61f29755bc4b6dbb41ea575d41edb6",
"metadata": {
"category_depth": 2,
"file_directory": "test_unstructured/documents/html_files",
"filename": "example.html",
"filetype": "text/html",
"languages": [
"eng"
],
"last_modified": "2025-06-12T11:12:20",
"page_number": 1,
"parent_id": "8e3f0d85329343008593f43afcad3327",
"text_as_html": "<p class=\"NarrativeText\">Paragraph text</p>"
},
"text": "Paragraph text",
"type": "NarrativeText"
}
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,38 @@
[
{
"element_id": "8157292897eb45e68229b5816da6e46c",
"metadata": {
"category_depth": 0,
"file_directory": "test_unstructured/documents/html_files",
"filename": "example_with_alternative_text.html",
"filetype": "text/html",
"languages": [
"eng"
],
"last_modified": "2025-06-12T11:12:20",
"page_number": 1,
"parent_id": "6f5f66a6a23642a7958aeff927e343cd",
"text_as_html": "<div class=\"Page\" data-page-number=\"1\" />"
},
"text": "",
"type": "UncategorizedText"
},
{
"element_id": "70c89a734fe1497293a2e01b2f35b887",
"metadata": {
"category_depth": 1,
"file_directory": "test_unstructured/documents/html_files",
"filename": "example_with_alternative_text.html",
"filetype": "text/html",
"languages": [
"eng"
],
"last_modified": "2025-06-12T11:12:20",
"page_number": 1,
"parent_id": "8157292897eb45e68229b5816da6e46c",
"text_as_html": "<header class=\"Header\"><img class=\"Logo\" alt=\"New York logo\" /><img class=\"Image\" alt=\"A line graph showing the comparison of 5 year cumulative total return for stocks\" /></header>"
},
"text": "New York logo A line graph showing the comparison of 5 year cumulative total return for stocks",
"type": "Header"
}
]
@@ -0,0 +1,38 @@
[
{
"element_id": "7aeac07bad7f44359c3b2403d2ca3d2a",
"metadata": {
"category_depth": 0,
"file_directory": "test_unstructured/documents/html_files",
"filename": "example_with_inline_fields.html",
"filetype": "text/html",
"languages": [
"eng"
],
"last_modified": "2025-06-12T11:12:20",
"page_number": 1,
"parent_id": "8bf6d9316275493499a373771a6e46d0",
"text_as_html": "<div class=\"Page\" data-page-number=\"1\" />"
},
"text": "",
"type": "UncategorizedText"
},
{
"element_id": "f8f71f7ce10748f08bee0f9922a04406",
"metadata": {
"category_depth": 1,
"file_directory": "test_unstructured/documents/html_files",
"filename": "example_with_inline_fields.html",
"filetype": "text/html",
"languages": [
"eng"
],
"last_modified": "2025-06-12T11:12:20",
"page_number": 1,
"parent_id": "7aeac07bad7f44359c3b2403d2ca3d2a",
"text_as_html": "<header class=\"Header\"><p class=\"NarrativeText\">Table of Contents</p><address class=\"Address\">68 Prince Street Palmdale, CA 93550</address><a class=\"Hyperlink\">www.google.com</a><span class=\"UncategorizedText\">More text</span></header>"
},
"text": "Table of Contents 68 Prince Street Palmdale, CA 93550 www.google.com More text",
"type": "Header"
}
]
@@ -0,0 +1,44 @@
[
{
"element_id": "2428404551304d4db5925f6afee11ed5",
"metadata": {
"category_depth": 0,
"filetype": "text/html",
"languages": [
"eng"
],
"parent_id": "517f8559ba594270bdd67e1b02bf19a2",
"text_as_html": "<table class=\"Table\"><tr><th>Header 1</th><th>Header 2</th></tr><tr><td>Row 1, Cell 1</td><td>Row 1, Cell 2</td></tr><tr><td>Row 2, Cell 1</td><td>Row 2, Cell 2</td></tr></table>"
},
"text": "Header 1 Header 2 Row 1, Cell 1 Row 1, Cell 2 Row 2, Cell 1 Row 2, Cell 2",
"type": "Table"
},
{
"element_id": "9f91cae321c74b31bb1c83ac86cd7afb",
"metadata": {
"category_depth": 0,
"filetype": "text/html",
"languages": [
"eng"
],
"parent_id": "517f8559ba594270bdd67e1b02bf19a2",
"text_as_html": "<table class=\"Table\"><tr><th colspan=\"3\">Big Table Header</th></tr><tr><td rowspan=\"2\">Merged Cell 1</td><td>Cell 2</td><td>Cell 3</td></tr><tr><td colspan=\"2\">Merged Cell 4 and 5</td></tr><tr><td>Cell 6</td><td>Cell 7</td><td>Cell 8</td></tr><tr><td>Cell 9</td><td colspan=\"2\">A cell with a lot of text. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</td></tr><tr><td>Cell 10</td><td>Cell 11</td><td>Cell 12</td></tr></table>"
},
"text": "Big Table Header Merged Cell 1 Cell 2 Cell 3 Merged Cell 4 and 5 Cell 6 Cell 7 Cell 8 Cell 9 A cell with a lot of text. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Cell 10 Cell 11 Cell 12",
"type": "Table"
},
{
"element_id": "da6c34391e544b3480e45d68f40870fa",
"metadata": {
"category_depth": 0,
"filetype": "text/html",
"languages": [
"eng"
],
"parent_id": "517f8559ba594270bdd67e1b02bf19a2",
"text_as_html": "<table class=\"TableOfContents\"><tr><th>Chapter</th><th>Title</th><th>Page</th></tr><tr><td>1</td><td>Introduction</td><td>1</td></tr><tr><td>2</td><td>Getting Started</td><td>5</td></tr><tr><td>3</td><td>Basic Concepts</td><td>12</td></tr><tr><td>4</td><td>Advanced Topics</td><td>25</td></tr><tr><td>5</td><td>Conclusion</td><td>40</td></tr></table>"
},
"text": "Chapter Title Page 1 Introduction 1 2 Getting Started 5 3 Basic Concepts 12 4 Advanced Topics 25 5 Conclusion 40",
"type": "Table"
}
]
View File
@@ -0,0 +1,41 @@
from unstructured.documents.elements import Text
from unstructured.embed.mixedbreadai import (
MixedbreadAIEmbeddingConfig,
MixedbreadAIEmbeddingEncoder,
)
def test_embed_documents_does_not_break_element_to_dict(mocker):
mock_client = mocker.MagicMock()
def mock_embeddings(
model,
normalized,
encoding_format,
truncation_strategy,
request_options,
input,
):
mock_response = mocker.MagicMock()
mock_response.data = [mocker.MagicMock(embedding=[i, i + 1]) for i in range(len(input))]
return mock_response
mock_client.embeddings.side_effect = mock_embeddings
# Mock get_client to return our mock_client
mocker.patch.object(MixedbreadAIEmbeddingConfig, "get_client", return_value=mock_client)
encoder = MixedbreadAIEmbeddingEncoder(
config=MixedbreadAIEmbeddingConfig(
api_key="api_key", model_name="mixedbread-ai/mxbai-embed-large-v1"
)
)
elements = encoder.embed_documents(
elements=[Text("This is sentence 1"), Text("This is sentence 2")],
)
assert len(elements) == 2
assert elements[0].to_dict()["text"] == "This is sentence 1"
assert elements[1].to_dict()["text"] == "This is sentence 2"
assert elements[0].embeddings is not None
assert elements[1].embeddings is not None
+19
View File
@@ -0,0 +1,19 @@
from unstructured.documents.elements import Text
from unstructured.embed.octoai import OctoAiEmbeddingConfig, OctoAIEmbeddingEncoder
def test_embed_documents_does_not_break_element_to_dict(mocker):
# Mocked client with the desired behavior for embed_documents
mock_client = mocker.MagicMock()
mock_client.embed_documents.return_value = [1, 2]
# Mock get_client to return our mock_client
mocker.patch.object(OctoAiEmbeddingConfig, "get_client", return_value=mock_client)
encoder = OctoAIEmbeddingEncoder(config=OctoAiEmbeddingConfig(api_key="api_key"))
elements = encoder.embed_documents(
elements=[Text("This is sentence 1"), Text("This is sentence 2")],
)
assert len(elements) == 2
assert elements[0].to_dict()["text"] == "This is sentence 1"
assert elements[1].to_dict()["text"] == "This is sentence 2"
+19
View File
@@ -0,0 +1,19 @@
from unstructured.documents.elements import Text
from unstructured.embed.openai import OpenAIEmbeddingConfig, OpenAIEmbeddingEncoder
def test_embed_documents_does_not_break_element_to_dict(mocker):
# Mocked client with the desired behavior for embed_documents
mock_client = mocker.MagicMock()
mock_client.embed_documents.return_value = [1, 2]
# Mock get_client to return our mock_client
mocker.patch.object(OpenAIEmbeddingConfig, "get_client", return_value=mock_client)
encoder = OpenAIEmbeddingEncoder(config=OpenAIEmbeddingConfig(api_key="api_key"))
elements = encoder.embed_documents(
elements=[Text("This is sentence 1"), Text("This is sentence 2")],
)
assert len(elements) == 2
assert elements[0].to_dict()["text"] == "This is sentence 1"
assert elements[1].to_dict()["text"] == "This is sentence 2"
+19
View File
@@ -0,0 +1,19 @@
from unstructured.documents.elements import Text
from unstructured.embed.vertexai import VertexAIEmbeddingConfig, VertexAIEmbeddingEncoder
def test_embed_documents_does_not_break_element_to_dict(mocker):
# Mocked client with the desired behavior for embed_documents
mock_client = mocker.MagicMock()
mock_client.embed_documents.return_value = [1, 2]
# Mock create_client to return our mock_client
mocker.patch.object(VertexAIEmbeddingConfig, "get_client", return_value=mock_client)
encoder = VertexAIEmbeddingEncoder(config=VertexAIEmbeddingConfig(api_key="api_key"))
elements = encoder.embed_documents(
elements=[Text("This is sentence 1"), Text("This is sentence 2")],
)
assert len(elements) == 2
assert elements[0].to_dict()["text"] == "This is sentence 1"
assert elements[1].to_dict()["text"] == "This is sentence 2"
+242
View File
@@ -0,0 +1,242 @@
from unittest.mock import Mock
from unstructured.documents.elements import Text
from unstructured.embed.voyageai import VoyageAIEmbeddingConfig, VoyageAIEmbeddingEncoder
def test_embed_documents_does_not_break_element_to_dict(mocker):
# Mocked client with the desired behavior for embed_documents
embed_response = Mock()
embed_response.embeddings = [[1], [2]]
mock_client = mocker.MagicMock()
mock_client.embed.return_value = embed_response
mock_client.tokenize.return_value = [[1], [1]] # Mock token counts
# Mock get_client to return our mock_client
mocker.patch.object(VoyageAIEmbeddingConfig, "get_client", return_value=mock_client)
encoder = VoyageAIEmbeddingEncoder(
config=VoyageAIEmbeddingConfig(api_key="api_key", model_name="voyage-3-large")
)
elements = encoder.embed_documents(
elements=[Text("This is sentence 1"), Text("This is sentence 2")],
)
assert len(elements) == 2
assert elements[0].to_dict()["text"] == "This is sentence 1"
assert elements[1].to_dict()["text"] == "This is sentence 2"
def test_embed_documents_voyage_3_5(mocker):
"""Test embedding with voyage-3.5 model."""
embed_response = Mock()
embed_response.embeddings = [[1.0] * 1024, [2.0] * 1024]
mock_client = mocker.MagicMock()
mock_client.embed.return_value = embed_response
mock_client.tokenize.return_value = [[1, 2, 3], [1, 2]] # Mock token counts
mocker.patch.object(VoyageAIEmbeddingConfig, "get_client", return_value=mock_client)
encoder = VoyageAIEmbeddingEncoder(
config=VoyageAIEmbeddingConfig(api_key="api_key", model_name="voyage-3.5")
)
elements = encoder.embed_documents(
elements=[Text("Test document 1"), Text("Test document 2")],
)
assert len(elements) == 2
assert len(elements[0].embeddings) == 1024
assert len(elements[1].embeddings) == 1024
def test_embed_documents_voyage_3_5_lite(mocker):
"""Test embedding with voyage-3.5-lite model."""
embed_response = Mock()
embed_response.embeddings = [[1.0] * 512, [2.0] * 512, [3.0] * 512]
mock_client = mocker.MagicMock()
mock_client.embed.return_value = embed_response
mock_client.tokenize.return_value = [[1], [1], [1]] # Mock token counts
mocker.patch.object(VoyageAIEmbeddingConfig, "get_client", return_value=mock_client)
encoder = VoyageAIEmbeddingEncoder(
config=VoyageAIEmbeddingConfig(api_key="api_key", model_name="voyage-3.5-lite")
)
elements = encoder.embed_documents(
elements=[Text("Test 1"), Text("Test 2"), Text("Test 3")],
)
assert len(elements) == 3
assert all(len(e.embeddings) == 512 for e in elements)
def test_embed_documents_contextual_model(mocker):
"""Test embedding with voyage-context-3 model."""
# Mock contextualized_embed response
contextualized_response = Mock()
result_item = Mock()
result_item.embeddings = [[1.0] * 1024, [2.0] * 1024]
contextualized_response.results = [result_item]
mock_client = mocker.MagicMock()
mock_client.contextualized_embed.return_value = contextualized_response
mock_client.tokenize.return_value = [[1, 2], [1, 2, 3]] # Mock token counts
mocker.patch.object(VoyageAIEmbeddingConfig, "get_client", return_value=mock_client)
encoder = VoyageAIEmbeddingEncoder(
config=VoyageAIEmbeddingConfig(api_key="api_key", model_name="voyage-context-3")
)
elements = encoder.embed_documents(
elements=[Text("Context document 1"), Text("Context document 2")],
)
assert len(elements) == 2
assert len(elements[0].embeddings) == 1024
assert len(elements[1].embeddings) == 1024
# Verify contextualized_embed was called
mock_client.contextualized_embed.assert_called_once()
def test_count_tokens(mocker):
"""Test token counting functionality."""
mock_client = mocker.MagicMock()
mock_client.tokenize.return_value = [[1, 2], [1, 2, 3, 4, 5]] # Different token counts
mocker.patch.object(VoyageAIEmbeddingConfig, "get_client", return_value=mock_client)
encoder = VoyageAIEmbeddingEncoder(
config=VoyageAIEmbeddingConfig(api_key="api_key", model_name="voyage-3.5")
)
texts = ["short text", "this is a longer text with more tokens"]
token_counts = encoder.count_tokens(texts)
assert len(token_counts) == 2
assert token_counts[0] == 2
assert token_counts[1] == 5
def test_count_tokens_empty_list(mocker):
"""Test token counting with empty list."""
mocker.patch.object(VoyageAIEmbeddingConfig, "get_client", return_value=mocker.MagicMock())
encoder = VoyageAIEmbeddingEncoder(
config=VoyageAIEmbeddingConfig(api_key="api_key", model_name="voyage-3.5")
)
token_counts = encoder.count_tokens([])
assert token_counts == []
def test_get_token_limit(mocker):
"""Test getting token limit for different models."""
mocker.patch.object(VoyageAIEmbeddingConfig, "get_client", return_value=mocker.MagicMock())
# Test voyage-3.5 model
config = VoyageAIEmbeddingConfig(api_key="api_key", model_name="voyage-3.5")
assert config.get_token_limit() == 320_000
# Test voyage-3.5-lite model
config_lite = VoyageAIEmbeddingConfig(api_key="api_key", model_name="voyage-3.5-lite")
assert config_lite.get_token_limit() == 1_000_000
# Test context model
config_context = VoyageAIEmbeddingConfig(api_key="api_key", model_name="voyage-context-3")
assert config_context.get_token_limit() == 32_000
# Test voyage-2 model
config_v2 = VoyageAIEmbeddingConfig(api_key="api_key", model_name="voyage-2")
assert config_v2.get_token_limit() == 320_000
# Test unknown model (should use default)
config_unknown = VoyageAIEmbeddingConfig(api_key="api_key", model_name="unknown-model")
assert config_unknown.get_token_limit() == 120_000
def test_is_context_model(mocker):
"""Test the _is_context_model helper method."""
mocker.patch.object(VoyageAIEmbeddingConfig, "get_client", return_value=mocker.MagicMock())
# Test with context model
encoder_context = VoyageAIEmbeddingEncoder(
config=VoyageAIEmbeddingConfig(api_key="api_key", model_name="voyage-context-3")
)
assert encoder_context._is_context_model() is True
# Test with regular model
encoder_regular = VoyageAIEmbeddingEncoder(
config=VoyageAIEmbeddingConfig(api_key="api_key", model_name="voyage-3.5")
)
assert encoder_regular._is_context_model() is False
def test_build_batches_with_token_limits(mocker):
"""Test that batching respects token limits."""
mock_client = mocker.MagicMock()
# Simulate different token counts for each text
mock_client.tokenize.return_value = [[1] * 10, [1] * 20, [1] * 15, [1] * 25]
mocker.patch.object(VoyageAIEmbeddingConfig, "get_client", return_value=mock_client)
encoder = VoyageAIEmbeddingEncoder(
config=VoyageAIEmbeddingConfig(api_key="api_key", model_name="voyage-2")
)
texts = ["text1", "text2", "text3", "text4"]
batches = list(encoder._build_batches(texts, mock_client))
# Should create at least one batch
assert len(batches) >= 1
# Total texts should be preserved
total_texts = sum(len(batch) for batch in batches)
assert total_texts == len(texts)
def test_embed_query(mocker):
"""Test embedding a single query."""
embed_response = Mock()
embed_response.embeddings = [[1.0] * 1024]
mock_client = mocker.MagicMock()
mock_client.embed.return_value = embed_response
mocker.patch.object(VoyageAIEmbeddingConfig, "get_client", return_value=mock_client)
encoder = VoyageAIEmbeddingEncoder(
config=VoyageAIEmbeddingConfig(api_key="api_key", model_name="voyage-3.5")
)
embedding = encoder.embed_query("test query")
assert len(embedding) == 1024
# Verify embed was called with input_type="query"
mock_client.embed.assert_called_once()
call_kwargs = mock_client.embed.call_args[1]
assert call_kwargs["input_type"] == "query"
def test_embed_documents_with_output_dimension(mocker):
"""Test embedding with custom output dimension."""
embed_response = Mock()
embed_response.embeddings = [[1.0] * 512, [2.0] * 512]
mock_client = mocker.MagicMock()
mock_client.embed.return_value = embed_response
mock_client.tokenize.return_value = [[1], [1]]
mocker.patch.object(VoyageAIEmbeddingConfig, "get_client", return_value=mock_client)
encoder = VoyageAIEmbeddingEncoder(
config=VoyageAIEmbeddingConfig(
api_key="api_key", model_name="voyage-3.5", output_dimension=512
)
)
elements = encoder.embed_documents(
elements=[Text("Test 1"), Text("Test 2")],
)
assert len(elements) == 2
# Verify output_dimension was passed
call_kwargs = mock_client.embed.call_args[1]
assert call_kwargs["output_dimension"] == 512
def test_embed_documents_empty_list(mocker):
"""Test embedding empty list of documents."""
mocker.patch.object(VoyageAIEmbeddingConfig, "get_client", return_value=mocker.MagicMock())
encoder = VoyageAIEmbeddingEncoder(
config=VoyageAIEmbeddingConfig(api_key="api_key", model_name="voyage-3.5")
)
elements = encoder.embed_documents(elements=[])
assert elements == []
File diff suppressed because one or more lines are too long
@@ -0,0 +1,71 @@
"""Test encoding detection error handling (PR #4071)."""
import os
import pickle
import sys
import tempfile
from unittest.mock import patch
import pytest
from unstructured.errors import UnprocessableEntityError
from unstructured.file_utils.encoding import detect_file_encoding
def test_charset_detection_failure():
"""Test encoding detection failure with memory safety checks."""
large_data = b"\x80\x81\x82\x83" * 250_000 # 1MB of invalid UTF-8
with tempfile.NamedTemporaryFile(mode="wb", delete=False) as f:
f.write(large_data)
temp_file_path = f.name
try:
detect_result = {"encoding": None, "confidence": None}
with patch("unstructured.file_utils.encoding.detect", return_value=detect_result):
with patch("unstructured.file_utils.encoding.COMMON_ENCODINGS", ["utf_8"]): # Will fail
with pytest.raises(UnprocessableEntityError) as exc_info:
detect_file_encoding(filename=temp_file_path)
exception = exc_info.value
assert "Unable to determine file encoding" in str(exception)
# Ensure no .object attribute that would store file content (prevents memory bloat)
# See: https://docs.python.org/3/library/exceptions.html#UnicodeError.object
assert not hasattr(exception, "object")
# Exception should be lightweight regardless of file size
exception_memory = sys.getsizeof(exception)
serialized_size = len(pickle.dumps(exception))
assert exception_memory < 10_000 # Small in-memory footprint
assert serialized_size < 10_000 # Small serialization footprint
finally:
os.unlink(temp_file_path)
def test_decode_failure():
"""Test decode failure with memory safety checks."""
# Invalid UTF-16: BOM followed by odd number of bytes
invalid_utf16 = b"\xff\xfe" + b"A\x00B\x00" + b"\x00"
detect_result = {"encoding": "utf-16", "confidence": 0.95}
with patch("unstructured.file_utils.encoding.detect", return_value=detect_result):
with pytest.raises(UnprocessableEntityError) as exc_info:
detect_file_encoding(file=invalid_utf16)
exception = exc_info.value
assert "detected 'utf-16' but decode failed" in str(exception)
# Ensure no .object attribute that would store file content (prevents memory bloat)
# See: https://docs.python.org/3/library/exceptions.html#UnicodeError.object
assert not hasattr(exception, "object")
# Exception should be lightweight
exception_memory = sys.getsizeof(exception)
serialized_size = len(pickle.dumps(exception))
assert exception_memory < 10_000 # Small in-memory footprint
assert serialized_size < 10_000 # Small serialization footprint
@@ -0,0 +1,54 @@
import os
import pathlib
import tempfile
from unittest.mock import patch
import pypandoc
import pytest
from test_unstructured.unit_utils import FixtureRequest, example_doc_path, stdlib_fn_mock
from unstructured.file_utils.file_conversion import (
convert_file_to_html_text_using_pandoc,
convert_file_to_text,
)
DIRECTORY = pathlib.Path(__file__).parent.resolve()
def test_convert_file_to_text():
filename = os.path.join(DIRECTORY, "..", "..", "example-docs", "winter-sports.epub")
html_text = convert_file_to_text(filename, source_format="epub", target_format="html")
assert html_text.startswith("<p>")
def test_convert_to_file_raises_if_pandoc_not_available():
filename = os.path.join(DIRECTORY, "..", "..", "example-docs", "winter-sports.epub")
with patch.object(pypandoc, "convert_file", side_effect=FileNotFoundError):
with pytest.raises(FileNotFoundError):
convert_file_to_text(filename, source_format="epub", target_format="html")
@pytest.mark.parametrize(
("source_format", "filename"),
[
("epub", "winter-sports.epub"),
("org", "README.org"),
("rst", "README.rst"),
("rtf", "fake-doc.rtf"),
],
)
def test_convert_file_to_html_text_using_pandoc(
request: FixtureRequest, tmp_path: pathlib.Path, source_format: str, filename: str
):
# -- Get a real tempdir: `tmp_path`
# -- Mock tempfile.TemporaryDirectory() using `stdlib_fn_mock`
# -- Set the return value of mock.__enter__ to the real tempdir
tempdir_ = stdlib_fn_mock(request, tempfile, "TemporaryDirectory")
tempdir_.return_value.__enter__.return_value = tmp_path
with open(example_doc_path(filename), "rb") as f:
html_text = convert_file_to_html_text_using_pandoc(file=f, source_format=source_format)
assert isinstance(html_text, str)
assert len(list(tmp_path.iterdir())) == 1
tempdir_.return_value.__exit__.assert_called_once()
File diff suppressed because it is too large Load Diff
+244
View File
@@ -0,0 +1,244 @@
"""Test suite for `unstructured.file_utils.filetype`."""
from __future__ import annotations
import pytest
from unstructured.file_utils.model import FileType, create_file_type, register_partitioner
class DescribeFileType:
"""Unit-test suite for `unstructured.file_utils.model.Filetype`."""
# -- .__lt__() ----------------------------------------------
def it_is_a_collection_ordered_by_name_and_can_be_sorted(self):
"""FileType is a total order on name, e.g. FileType.A < FileType.B."""
assert FileType.EML < FileType.HTML < FileType.XML
# -- .from_extension() --------------------------------------
@pytest.mark.parametrize(
("ext", "file_type"),
[
(".bmp", FileType.BMP),
(".html", FileType.HTML),
(".eml", FileType.EML),
(".p7s", FileType.EML),
(".java", FileType.TXT),
],
)
def it_can_recognize_a_file_type_from_an_extension(self, ext: str, file_type: FileType | None):
assert FileType.from_extension(ext) is file_type
@pytest.mark.parametrize("ext", [".foobar", ".xyz", ".mdx", "", ".", None])
def but_not_when_that_extension_is_empty_or_None_or_not_registered(self, ext: str | None):
assert FileType.from_extension(ext) is None
# -- .from_mime_type() --------------------------------------
@pytest.mark.parametrize(
("mime_type", "file_type"),
[
("image/bmp", FileType.BMP),
("text/x-csv", FileType.CSV),
("application/msword", FileType.DOC),
("message/rfc822", FileType.EML),
("text/plain", FileType.TXT),
("text/yaml", FileType.TXT),
("application/xml", FileType.XML),
("text/xml", FileType.XML),
("inode/x-empty", FileType.EMPTY),
],
)
def it_can_recognize_a_file_type_from_a_mime_type(
self, mime_type: str, file_type: FileType | None
):
assert FileType.from_mime_type(mime_type) is file_type
@pytest.mark.parametrize("mime_type", ["text/css", "image/gif", "foo/bar", None])
def but_not_when_that_mime_type_is_not_registered_by_a_file_type_or_None(
self, mime_type: str | None
):
assert FileType.from_mime_type(mime_type) is None
# -- .extra_name --------------------------------------------
@pytest.mark.parametrize(
("file_type", "expected_value"),
[
(FileType.BMP, "image"),
(FileType.DOC, "doc"),
(FileType.DOCX, "docx"),
(FileType.EML, None),
(FileType.EMPTY, None),
(FileType.MSG, "msg"),
(FileType.PDF, "pdf"),
(FileType.XLS, "xlsx"),
(FileType.UNK, None),
(FileType.WAV, "audio"),
(FileType.ZIP, None),
],
)
def and_it_knows_which_pip_extra_needs_to_be_installed_to_get_those_dependencies(
self, file_type: FileType, expected_value: str | None
):
assert file_type.extra_name == expected_value
# -- .importable_package_dependencies -----------------------
@pytest.mark.parametrize(
("file_type", "expected_value"),
[
(FileType.BMP, ("unstructured_inference",)),
(FileType.CSV, ("pandas",)),
(FileType.DOC, ("docx",)),
(FileType.EMPTY, ()),
(FileType.HTML, ()),
(FileType.ODT, ("docx", "pypandoc")),
(FileType.PDF, ("pdf2image", "pdfminer", "PIL")),
(FileType.UNK, ()),
(FileType.WAV, ()), # STT agent deps validated at runtime
(FileType.ZIP, ()),
],
)
def it_knows_which_importable_packages_its_partitioner_depends_on(
self, file_type: FileType, expected_value: tuple[str, ...]
):
assert file_type.importable_package_dependencies == expected_value
# -- .is_partitionable --------------------------------------
@pytest.mark.parametrize(
("file_type", "expected_value"),
[
(FileType.BMP, True),
(FileType.CSV, True),
(FileType.DOC, True),
(FileType.EML, True),
(FileType.JPG, True),
(FileType.PDF, True),
(FileType.PPTX, True),
(FileType.WAV, True),
(FileType.ZIP, False),
(FileType.EMPTY, False),
(FileType.UNK, False),
],
)
def it_knows_whether_files_of_its_type_are_directly_partitionable(
self, file_type: FileType, expected_value: str
):
assert file_type.is_partitionable is expected_value
# -- .mime_type ---------------------------------------------
@pytest.mark.parametrize(
("file_type", "mime_type"),
[
(FileType.BMP, "image/bmp"),
(FileType.CSV, "text/csv"),
(FileType.DOC, "application/msword"),
(FileType.EML, "message/rfc822"),
(FileType.HTML, "text/html"),
(FileType.JPG, "image/jpeg"),
(FileType.PDF, "application/pdf"),
(FileType.TXT, "text/plain"),
(FileType.XML, "application/xml"),
(FileType.EMPTY, "inode/x-empty"),
(FileType.UNK, "application/octet-stream"),
],
)
def it_knows_its_canonical_MIME_type(self, file_type: FileType, mime_type: str):
assert file_type.mime_type == mime_type
# -- .partitioner_function_name -----------------------------
@pytest.mark.parametrize(
("file_type", "expected_value"),
[
(FileType.BMP, "partition_image"),
(FileType.CSV, "partition_csv"),
(FileType.DOC, "partition_doc"),
(FileType.DOCX, "partition_docx"),
(FileType.JPG, "partition_image"),
(FileType.PNG, "partition_image"),
(FileType.TIFF, "partition_image"),
(FileType.WAV, "partition_audio"),
],
)
def it_knows_its_partitioner_function_name(self, file_type: FileType, expected_value: str):
assert file_type.partitioner_function_name == expected_value
@pytest.mark.parametrize("file_type", [FileType.ZIP, FileType.EMPTY, FileType.UNK])
def but_it_raises_on_partitioner_function_name_access_when_the_file_type_is_not_partitionable(
self, file_type: FileType
):
with pytest.raises(ValueError, match="`.partitioner_function_name` is undefined because "):
file_type.partitioner_function_name
# -- .partitioner_module_qname ------------------------------
@pytest.mark.parametrize(
("file_type", "expected_value"),
[
(FileType.BMP, "unstructured.partition.image"),
(FileType.CSV, "unstructured.partition.csv"),
(FileType.DOC, "unstructured.partition.doc"),
(FileType.DOCX, "unstructured.partition.docx"),
(FileType.JPG, "unstructured.partition.image"),
(FileType.PNG, "unstructured.partition.image"),
(FileType.TIFF, "unstructured.partition.image"),
(FileType.WAV, "unstructured.partition.audio"),
],
)
def it_knows_the_fully_qualified_name_of_its_partitioner_module(
self, file_type: FileType, expected_value: str
):
assert file_type.partitioner_module_qname == expected_value
@pytest.mark.parametrize("file_type", [FileType.ZIP, FileType.EMPTY, FileType.UNK])
def but_it_raises_on_partitioner_module_qname_access_when_the_file_type_is_not_partitionable(
self, file_type: FileType
):
with pytest.raises(ValueError, match="`.partitioner_module_qname` is undefined because "):
file_type.partitioner_module_qname
# -- .partitioner_shortname ---------------------------------
@pytest.mark.parametrize(
("file_type", "expected_value"),
[
(FileType.BMP, "image"),
(FileType.CSV, "csv"),
(FileType.DOC, "doc"),
(FileType.DOCX, "docx"),
(FileType.JPG, "image"),
(FileType.PNG, "image"),
(FileType.TIFF, "image"),
(FileType.WAV, "audio"),
(FileType.XLS, "xlsx"),
(FileType.XLSX, "xlsx"),
],
)
def it_provides_access_to_the_partitioner_shortname(
self, file_type: FileType, expected_value: str
):
assert file_type.partitioner_shortname == expected_value
def test_create_file_type():
file_type = create_file_type("FOO", canonical_mime_type="application/foo", extensions=[".foo"])
assert FileType.from_extension(".foo") is file_type
assert FileType.from_mime_type("application/foo") is file_type
def test_register_partitioner():
file_type = create_file_type("FOO", canonical_mime_type="application/foo", extensions=[".foo"])
@register_partitioner(file_type)
def partition_foo():
pass
assert file_type.partitioner_function_name == "partition_foo"
assert file_type.partitioner_module_qname == "test_unstructured.file_utils.test_model"
@@ -0,0 +1,113 @@
from __future__ import annotations
import pytest
from test_unstructured.unit_utils import example_doc_path
from unstructured.metrics.element_type import (
FrequencyDict,
calculate_element_type_percent_match,
get_element_type_frequency,
)
from unstructured.partition.auto import partition
from unstructured.staging.base import elements_to_json
@pytest.mark.parametrize(
("filename", "frequency"),
[
(
"fake-email.txt",
{
("NarrativeText", None): 1,
("UncategorizedText", None): 1,
("ListItem", 1): 2,
},
),
(
"sample-presentation.pptx",
{
("Title", 0): 4,
("Title", 1): 2,
("NarrativeText", 0): 2,
("PageBreak", None): 3,
("ListItem", 0): 6,
("ListItem", 1): 6,
("ListItem", 2): 3,
("Table", None): 1,
},
),
],
)
def test_get_element_type_frequency(filename: str, frequency: dict[tuple[str, int | None], int]):
elements = partition(example_doc_path(filename))
elements_freq = get_element_type_frequency(elements_to_json(elements))
assert elements_freq == frequency
@pytest.mark.parametrize(
("filename", "expected_frequency", "percent_matched"),
[
(
"fake-email.txt",
{
("UncategorizedText", None): 1,
("ListItem", 1): 2,
("NarrativeText", None): 2,
},
(0.8, 0.8, 0.80),
),
(
"sample-presentation.pptx",
{
("Title", 0): 3,
("Title", 1): 1,
("NarrativeText", None): 1,
("NarrativeText", 0): 3,
("ListItem", 0): 6,
("ListItem", 1): 6,
("ListItem", 2): 3,
("Table", None): 1,
},
(0.92, 0.92, 0.92),
),
(
"handbook-1p.docx",
{
("Header", None): 1,
("UncategorizedText", 0): 6,
("ListItem", 3): 3,
("NarrativeText", 0): 7,
("Footer", None): 1,
},
(0.78, 0.72, 0.81),
),
(
"handbook-1p.docx",
{
("Header", None): 1,
("UncategorizedText", 0): 6,
("NarrativeText", 0): 7,
("PageBreak", None): 1,
("Footer", None): 1,
},
(0.94, 0.88, 0.98),
),
],
)
def test_calculate_element_type_percent_match(
filename: str, expected_frequency: FrequencyDict, percent_matched: tuple[float, float, float]
):
elements = partition(example_doc_path(filename))
elements_frequency = get_element_type_frequency(elements_to_json(elements))
assert (
round(calculate_element_type_percent_match(elements_frequency, expected_frequency), 2)
== percent_matched[0]
)
assert (
round(calculate_element_type_percent_match(elements_frequency, expected_frequency, 0.0), 2)
== percent_matched[1]
)
assert (
round(calculate_element_type_percent_match(elements_frequency, expected_frequency, 0.8), 2)
== percent_matched[2]
)
+597
View File
@@ -0,0 +1,597 @@
import os
import pathlib
import shutil
from pathlib import Path
from unittest.mock import MagicMock, patch
import numpy as np
import pandas as pd
import pytest
from unstructured.metrics.evaluate import (
ElementTypeMetricsCalculator,
TableStructureMetricsCalculator,
TextExtractionMetricsCalculator,
filter_metrics,
get_mean_grouping,
)
is_in_docker = os.path.exists("/.dockerenv")
EXAMPLE_DOCS_DIRECTORY = os.path.join(
pathlib.Path(__file__).parent.resolve(), "..", "..", "example-docs"
)
TESTING_FILE_DIR = os.path.join(EXAMPLE_DOCS_DIRECTORY, "test_evaluate_files")
UNSTRUCTURED_OUTPUT_DIRNAME = "unstructured_output"
GOLD_CCT_DIRNAME = "gold_standard_cct"
GOLD_ELEMENT_TYPE_DIRNAME = "gold_standard_element_type"
GOLD_TABLE_STRUCTURE_DIRNAME = "gold_standard_table_structure"
UNSTRUCTURED_CCT_DIRNAME = "unstructured_output_cct"
UNSTRUCTURED_TABLE_STRUCTURE_DIRNAME = "unstructured_output_table_structure"
DUMMY_DF_CCT = pd.DataFrame(
{
"filename": [
"Bank Good Credit Loan.pptx",
"Performance-Audit-Discussion.pdf",
"currency.csv",
],
"doctype": ["pptx", "pdf", "csv"],
"connector": ["connector1", "connector1", "connector2"],
"cct-accuracy": [0.812, 0.994, 0.887],
"cct-%missing": [0.001, 0.002, 0.041],
}
)
DUMMY_DF_ELEMENT_TYPE = pd.DataFrame(
{
"filename": [
"Bank Good Credit Loan.pptx",
"Performance-Audit-Discussion.pdf",
"currency.csv",
],
"doctype": ["pptx", "pdf", "csv"],
"connector": ["connector1", "connector1", "connector2"],
"element-type-accuracy": [0.812, 0.994, 0.887],
}
)
@pytest.fixture
def mock_dependencies():
with (
patch("unstructured.metrics.evaluate.calculate_accuracy") as mock_calculate_accuracy,
patch(
"unstructured.metrics.evaluate.calculate_percent_missing_text"
) as mock_calculate_percent_missing_text,
patch.object(TextExtractionMetricsCalculator, "_get_ccts") as mock_get_ccts,
patch(
"unstructured.metrics.evaluate.get_element_type_frequency"
) as mock_get_element_type_frequency,
patch(
"unstructured.metrics.evaluate.calculate_element_type_percent_match"
) as mock_calculate_element_type_percent_match,
patch("unstructured.metrics.evaluate._read_text_file") as mock_read_text_file,
patch.object(Path, "exists") as mock_path_exists,
patch(
"unstructured.metrics.evaluate.TableEvalProcessor.from_json_files"
) as mock_table_eval_processor_from_json_files,
patch.object(
TableStructureMetricsCalculator, "supported_metric_names"
) as mock_supported_metric_names,
):
mocks = {
"mock_calculate_accuracy": mock_calculate_accuracy,
"mock_calculate_percent_missing_text": mock_calculate_percent_missing_text,
"mock_get_ccts": mock_get_ccts,
"mock_get_element_type_frequency": mock_get_element_type_frequency,
"mock_read_text_file": mock_read_text_file,
"mock_calculate_element_type_percent_match": mock_calculate_element_type_percent_match,
"mock_table_eval_processor_from_json_files": mock_table_eval_processor_from_json_files,
"mock_supported_metric_names": mock_supported_metric_names,
"mock_path_exists": mock_path_exists,
}
# setup mocks
mocks["mock_calculate_accuracy"].return_value = 0.5
mocks["mock_calculate_percent_missing_text"].return_value = 0.5
mocks["mock_get_ccts"].return_value = ["output_cct", "source_cct"]
mocks["mock_get_element_type_frequency"].side_effect = [{"ele1": 1}, {"ele2": 3}]
mocks["mock_calculate_element_type_percent_match"].return_value = 0.5
mocks["mock_supported_metric_names"].return_value = ["table_level_acc"]
mocks["mock_path_exists"].return_value = True
mocks["mock_read_text_file"].side_effect = ["output_text", "source_text"]
yield mocks
@pytest.fixture()
def _cleanup_after_test():
"""Fixture for removing side-effects of running tests in this file."""
def remove_generated_directories():
"""Remove directories created from running tests."""
# Directories to be removed:
target_dir_names = [
"test_evaluate_results_cct",
"test_evaluate_results_cct_txt",
"test_evaluate_results_element_type",
"test_evaluate_result_table_structure",
]
subdirs = (d for d in os.scandir(TESTING_FILE_DIR) if d.is_dir())
for d in subdirs:
if d.name in target_dir_names:
shutil.rmtree(d.path)
# Run test as normal
yield
remove_generated_directories()
@pytest.mark.skipif(is_in_docker, reason="Skipping this test in Docker container")
@pytest.mark.usefixtures("_cleanup_after_test")
def test_text_extraction_evaluation():
output_dir = os.path.join(TESTING_FILE_DIR, UNSTRUCTURED_OUTPUT_DIRNAME)
source_dir = os.path.join(TESTING_FILE_DIR, GOLD_CCT_DIRNAME)
export_dir = os.path.join(TESTING_FILE_DIR, "test_evaluate_results_cct")
TextExtractionMetricsCalculator(
documents_dir=output_dir, ground_truths_dir=source_dir
).calculate(export_dir=export_dir, visualize_progress=False, display_agg_df=False)
assert os.path.isfile(os.path.join(export_dir, "all-docs-cct.tsv"))
df = pd.read_csv(os.path.join(export_dir, "all-docs-cct.tsv"), sep="\t")
assert len(df) == 3
assert len(df.columns) == 5
assert df.iloc[0].filename == "Bank Good Credit Loan.pptx"
@pytest.mark.parametrize(
("calculator_class", "output_dirname", "source_dirname", "path", "expected_length", "kwargs"),
[
(
TextExtractionMetricsCalculator,
UNSTRUCTURED_CCT_DIRNAME,
GOLD_CCT_DIRNAME,
Path("Bank Good Credit Loan.pptx.txt"),
5,
{"document_type": "txt"},
),
(
TableStructureMetricsCalculator,
UNSTRUCTURED_TABLE_STRUCTURE_DIRNAME,
GOLD_TABLE_STRUCTURE_DIRNAME,
Path("IRS-2023-Form-1095-A.pdf.json"),
14,
{},
),
(
ElementTypeMetricsCalculator,
UNSTRUCTURED_OUTPUT_DIRNAME,
GOLD_ELEMENT_TYPE_DIRNAME,
Path("IRS-form-1987.pdf.json"),
4,
{},
),
],
)
def test_process_document_returns_the_correct_amount_of_values(
calculator_class, output_dirname, source_dirname, path, expected_length, kwargs
):
output_dir = Path(TESTING_FILE_DIR) / output_dirname
source_dir = Path(TESTING_FILE_DIR) / source_dirname
calculator = calculator_class(documents_dir=output_dir, ground_truths_dir=source_dir, **kwargs)
output_list = calculator._process_document(path)
assert len(output_list) == expected_length
@pytest.mark.skipif(is_in_docker, reason="Skipping this test in Docker container")
@pytest.mark.usefixtures("_cleanup_after_test", "mock_dependencies")
@pytest.mark.parametrize(
("calculator_class", "output_dirname", "source_dirname", "path", "kwargs"),
[
(
TextExtractionMetricsCalculator,
UNSTRUCTURED_CCT_DIRNAME,
GOLD_CCT_DIRNAME,
Path("2310.03502text_to_image_synthesis1-7.pdf.txt"),
{"document_type": "txt"},
),
],
)
def test_TextExtractionMetricsCalculator_process_document_returns_the_correct_doctype(
mock_dependencies, calculator_class, output_dirname, source_dirname, path, kwargs
):
output_dir = Path(TESTING_FILE_DIR) / output_dirname
source_dir = Path(TESTING_FILE_DIR) / source_dirname
mock_calculate_accuracy = mock_dependencies["mock_calculate_accuracy"]
mock_calculate_percent_missing_text = mock_dependencies["mock_calculate_percent_missing_text"]
mock_get_ccts = mock_dependencies["mock_get_ccts"]
calculator = calculator_class(documents_dir=output_dir, ground_truths_dir=source_dir, **kwargs)
output_list = calculator._process_document(path)
assert output_list[1] == ".pdf"
assert mock_calculate_accuracy.call_count == 1
assert mock_calculate_percent_missing_text.call_count == 1
assert mock_get_ccts.call_count == 1
@pytest.mark.skipif(is_in_docker, reason="Skipping this test in Docker container")
@pytest.mark.usefixtures("_cleanup_after_test", "mock_dependencies")
@pytest.mark.parametrize(
("calculator_class", "output_dirname", "source_dirname", "path", "kwargs"),
[
(
TableStructureMetricsCalculator,
UNSTRUCTURED_TABLE_STRUCTURE_DIRNAME,
GOLD_TABLE_STRUCTURE_DIRNAME,
Path("tablib-627mTABLES-2310.07875-p7.pdf.json"),
{},
),
# (
# ElementTypeMetricsCalculator,
# UNSTRUCTURED_OUTPUT_DIRNAME,
# GOLD_ELEMENT_TYPE_DIRNAME,
# Path("IRS-form.1987.pdf.json"),
# {},
# ),
],
)
def test_TableStructureMetricsCalculator_process_document_returns_the_correct_doctype(
mock_dependencies, calculator_class, output_dirname, source_dirname, path, kwargs
):
output_dir = Path(TESTING_FILE_DIR) / output_dirname
source_dir = Path(TESTING_FILE_DIR) / source_dirname
calculator = calculator_class(documents_dir=output_dir, ground_truths_dir=source_dir, **kwargs)
calculator._ground_truths_dir = source_dir
calculator._documents_dir = output_dir
calculator._ground_truth_paths = [source_dir / path]
mock_report = MagicMock()
mock_report.total_predicted_tables = 3
mock_report.table_evel_acc = 0.83
mock_table_eval_processor_from_json_files = mock_dependencies[
"mock_table_eval_processor_from_json_files"
]
mock_table_eval_processor_from_json_files.return_value.process_file.return_value = mock_report
output_list = calculator._process_document(path)
assert output_list[1] == ".pdf"
assert mock_table_eval_processor_from_json_files.call_count == 1
@pytest.mark.skipif(is_in_docker, reason="Skipping this test in Docker container")
@pytest.mark.usefixtures("_cleanup_after_test", "mock_dependencies")
@pytest.mark.parametrize(
("calculator_class", "output_dirname", "source_dirname", "path", "kwargs"),
[
(
ElementTypeMetricsCalculator,
UNSTRUCTURED_OUTPUT_DIRNAME,
GOLD_ELEMENT_TYPE_DIRNAME,
Path("IRS-form.1987.pdf.json"),
{},
),
],
)
def test_ElementTypeMetricsCalculator_process_document_returns_the_correct_doctype(
mock_dependencies, calculator_class, output_dirname, source_dirname, path, kwargs
):
output_dir = Path(TESTING_FILE_DIR) / output_dirname
source_dir = Path(TESTING_FILE_DIR) / source_dirname
calculator = calculator_class(documents_dir=output_dir, ground_truths_dir=source_dir, **kwargs)
mock_element_type_frequency = mock_dependencies["mock_get_element_type_frequency"]
mock_read_text_file = mock_dependencies["mock_read_text_file"]
mock_calculate_element_type_percent_match = mock_dependencies[
"mock_calculate_element_type_percent_match"
]
output_list = calculator._process_document(path)
assert output_list[1] == ".pdf"
assert mock_read_text_file.call_count == 2
assert mock_element_type_frequency.call_count == 2
assert mock_calculate_element_type_percent_match.call_count == 1
@pytest.mark.skipif(is_in_docker, reason="Skipping this test in Docker container")
@pytest.mark.usefixtures("_cleanup_after_test")
def test_text_extraction_evaluation_type_txt():
output_dir = os.path.join(TESTING_FILE_DIR, UNSTRUCTURED_CCT_DIRNAME)
source_dir = os.path.join(TESTING_FILE_DIR, GOLD_CCT_DIRNAME)
export_dir = os.path.join(TESTING_FILE_DIR, "test_evaluate_results_cct")
TextExtractionMetricsCalculator(
documents_dir=output_dir, ground_truths_dir=source_dir, document_type="txt"
).calculate(export_dir=export_dir)
df = pd.read_csv(os.path.join(export_dir, "all-docs-cct.tsv"), sep="\t")
assert len(df) == 3
assert len(df.columns) == 5
assert df.iloc[0].filename == "Bank Good Credit Loan.pptx"
@pytest.mark.skipif(is_in_docker, reason="Skipping this test in Docker container")
@pytest.mark.usefixtures("_cleanup_after_test")
def test_element_type_evaluation():
output_dir = os.path.join(TESTING_FILE_DIR, UNSTRUCTURED_OUTPUT_DIRNAME)
source_dir = os.path.join(TESTING_FILE_DIR, GOLD_ELEMENT_TYPE_DIRNAME)
export_dir = os.path.join(TESTING_FILE_DIR, "test_evaluate_results_cct")
ElementTypeMetricsCalculator(
documents_dir=output_dir,
ground_truths_dir=source_dir,
).calculate(export_dir=export_dir, visualize_progress=False)
assert os.path.isfile(os.path.join(export_dir, "all-docs-element-type-frequency.tsv"))
df = pd.read_csv(os.path.join(export_dir, "all-docs-element-type-frequency.tsv"), sep="\t")
assert len(df) == 1
assert len(df.columns) == 4
assert df.iloc[0].filename == "IRS-form-1987.pdf"
@pytest.mark.skipif(is_in_docker, reason="Skipping this test in Docker container")
@pytest.mark.usefixtures("_cleanup_after_test")
def test_table_structure_evaluation():
output_dir = os.path.join(TESTING_FILE_DIR, UNSTRUCTURED_TABLE_STRUCTURE_DIRNAME)
source_dir = os.path.join(TESTING_FILE_DIR, GOLD_TABLE_STRUCTURE_DIRNAME)
export_dir = os.path.join(TESTING_FILE_DIR, "test_evaluate_result_table_structure")
TableStructureMetricsCalculator(
documents_dir=output_dir,
ground_truths_dir=source_dir,
).calculate(export_dir=export_dir, visualize_progress=False)
assert os.path.isfile(os.path.join(export_dir, "all-docs-table-structure-accuracy.tsv"))
assert os.path.isfile(os.path.join(export_dir, "aggregate-table-structure-accuracy.tsv"))
df = pd.read_csv(os.path.join(export_dir, "all-docs-table-structure-accuracy.tsv"), sep="\t")
agg_df = pd.read_csv(
os.path.join(export_dir, "aggregate-table-structure-accuracy.tsv"), sep="\t"
).set_index("metric")
assert len(df) == 2
assert len(df.columns) == 15
assert df.iloc[1].filename == "IRS-2023-Form-1095-A.pdf"
assert (
np.round(np.average(df["table_level_acc"], weights=df["total_tables"]), 3)
== agg_df.loc["table_level_acc", "average"]
)
@pytest.mark.skipif(is_in_docker, reason="Skipping this test in Docker container")
@pytest.mark.usefixtures("_cleanup_after_test")
def test_text_extraction_takes_list():
output_dir = os.path.join(TESTING_FILE_DIR, UNSTRUCTURED_OUTPUT_DIRNAME)
output_list = ["currency.csv.json"]
source_dir = os.path.join(TESTING_FILE_DIR, GOLD_CCT_DIRNAME)
export_dir = os.path.join(TESTING_FILE_DIR, "test_evaluate_results_cct")
TextExtractionMetricsCalculator(
documents_dir=output_dir,
ground_truths_dir=source_dir,
).on_files(document_paths=output_list).calculate(export_dir=export_dir)
# check that only the listed files are included
assert os.path.isfile(os.path.join(export_dir, "all-docs-cct.tsv"))
df = pd.read_csv(os.path.join(export_dir, "all-docs-cct.tsv"), sep="\t")
assert len(df) == len(output_list)
@pytest.mark.skipif(is_in_docker, reason="Skipping this test in Docker container")
@pytest.mark.usefixtures("_cleanup_after_test")
def test_text_extraction_with_grouping():
output_dir = os.path.join(TESTING_FILE_DIR, UNSTRUCTURED_OUTPUT_DIRNAME)
source_dir = os.path.join(TESTING_FILE_DIR, GOLD_CCT_DIRNAME)
export_dir = os.path.join(TESTING_FILE_DIR, "test_evaluate_results_cct")
TextExtractionMetricsCalculator(
documents_dir=output_dir,
ground_truths_dir=source_dir,
group_by="doctype",
).calculate(export_dir=export_dir)
df = pd.read_csv(os.path.join(export_dir, "all-doctype-agg-cct.tsv"), sep="\t")
assert len(df) == 4 # metrics row and doctype rows
@pytest.mark.skipif(is_in_docker, reason="Skipping this test in Docker container")
@pytest.mark.usefixtures("_cleanup_after_test")
def test_text_extraction_wrong_type():
output_dir = os.path.join(TESTING_FILE_DIR, UNSTRUCTURED_OUTPUT_DIRNAME)
source_dir = os.path.join(TESTING_FILE_DIR, GOLD_CCT_DIRNAME)
export_dir = os.path.join(TESTING_FILE_DIR, "test_evaluate_results_cct")
with pytest.raises(ValueError):
TextExtractionMetricsCalculator(
documents_dir=output_dir, ground_truths_dir=source_dir, document_type="invalid type"
).calculate(export_dir=export_dir)
@pytest.mark.skipif(is_in_docker, reason="Skipping this test in Docker container")
@pytest.mark.usefixtures("_cleanup_after_test")
@pytest.mark.parametrize(("grouping", "count_row"), [("doctype", 3), ("connector", 2)])
def test_get_mean_grouping_df_input(grouping: str, count_row: int):
export_dir = os.path.join(TESTING_FILE_DIR, "test_evaluate_results_cct")
get_mean_grouping(
group_by=grouping,
data_input=DUMMY_DF_CCT,
export_dir=export_dir,
eval_name="text_extraction",
)
grouped_df = pd.read_csv(os.path.join(export_dir, f"all-{grouping}-agg-cct.tsv"), sep="\t")
assert grouped_df[grouping].dropna().nunique() == count_row
@pytest.mark.skipif(is_in_docker, reason="Skipping this test in Docker container")
@pytest.mark.usefixtures("_cleanup_after_test")
def test_get_mean_grouping_tsv_input():
output_dir = os.path.join(TESTING_FILE_DIR, UNSTRUCTURED_OUTPUT_DIRNAME)
source_dir = os.path.join(TESTING_FILE_DIR, GOLD_CCT_DIRNAME)
export_dir = os.path.join(TESTING_FILE_DIR, "test_evaluate_results_cct")
TextExtractionMetricsCalculator(
documents_dir=output_dir,
ground_truths_dir=source_dir,
).calculate(export_dir=export_dir)
filename = os.path.join(export_dir, "all-docs-cct.tsv")
get_mean_grouping(
group_by="doctype",
data_input=filename,
export_dir=export_dir,
eval_name="text_extraction",
)
grouped_df = pd.read_csv(os.path.join(export_dir, "all-doctype-agg-cct.tsv"), sep="\t")
assert grouped_df["doctype"].dropna().nunique() == 3
@pytest.mark.skipif(is_in_docker, reason="Skipping this test in Docker container")
@pytest.mark.usefixtures("_cleanup_after_test")
def test_get_mean_grouping_invalid_group():
output_dir = os.path.join(TESTING_FILE_DIR, UNSTRUCTURED_OUTPUT_DIRNAME)
source_dir = os.path.join(TESTING_FILE_DIR, GOLD_CCT_DIRNAME)
export_dir = os.path.join(TESTING_FILE_DIR, "test_evaluate_results_cct")
TextExtractionMetricsCalculator(
documents_dir=output_dir,
ground_truths_dir=source_dir,
).calculate(export_dir=export_dir)
df = pd.read_csv(os.path.join(export_dir, "all-docs-cct.tsv"), sep="\t")
with pytest.raises(ValueError):
get_mean_grouping(
group_by="invalid",
data_input=df,
export_dir=export_dir,
eval_name="text_extraction",
)
@pytest.mark.skipif(is_in_docker, reason="Skipping this test in Docker container")
@pytest.mark.usefixtures("_cleanup_after_test")
def test_text_extraction_grouping_empty_df():
empty_df = pd.DataFrame()
with pytest.raises(SystemExit):
get_mean_grouping("doctype", empty_df, "some_dir", eval_name="text_extraction")
@pytest.mark.skipif(is_in_docker, reason="Skipping this test in Docker container")
@pytest.mark.usefixtures("_cleanup_after_test")
def test_get_mean_grouping_missing_grouping_column():
df_with_no_grouping = pd.DataFrame({"some_column": [1, 2, 3]})
with pytest.raises(SystemExit):
get_mean_grouping("doctype", df_with_no_grouping, "some_dir", "text_extraction")
@pytest.mark.skipif(is_in_docker, reason="Skipping this test in Docker container")
@pytest.mark.usefixtures("_cleanup_after_test")
def test_get_mean_grouping_all_null_grouping_column():
df_with_null_grouping = pd.DataFrame({"doctype": [None, None, None]})
with pytest.raises(SystemExit):
get_mean_grouping("doctype", df_with_null_grouping, "some_dir", eval_name="text_extraction")
@pytest.mark.skipif(is_in_docker, reason="Skipping this test in Docker container")
@pytest.mark.usefixtures("_cleanup_after_test")
def test_get_mean_grouping_invalid_eval_name():
with pytest.raises(ValueError):
get_mean_grouping("doctype", DUMMY_DF_ELEMENT_TYPE, "some_dir", eval_name="invalid")
@pytest.mark.skipif(is_in_docker, reason="Skipping this test in Docker container")
@pytest.mark.usefixtures("_cleanup_after_test")
@pytest.mark.parametrize(("group_by", "count_row"), [("doctype", 3), ("connector", 2)])
def test_get_mean_grouping_element_type(group_by: str, count_row: int):
export_dir = os.path.join(TESTING_FILE_DIR, "test_evaluate_results_element_type")
get_mean_grouping(
group_by=group_by,
data_input=DUMMY_DF_ELEMENT_TYPE,
export_dir=export_dir,
eval_name="element_type",
)
grouped_df = pd.read_csv(
os.path.join(export_dir, f"all-{group_by}-agg-element-type.tsv"), sep="\t"
)
assert grouped_df[group_by].dropna().nunique() == count_row
@pytest.mark.skipif(is_in_docker, reason="Skipping this test in Docker container")
@pytest.mark.usefixtures("_cleanup_after_test")
def test_filter_metrics():
with open(os.path.join(TESTING_FILE_DIR, "filter_list.txt"), "w") as file:
file.write("Bank Good Credit Loan.pptx\n")
file.write("Performance-Audit-Discussion.pdf\n")
export_dir = os.path.join(TESTING_FILE_DIR, "test_evaluate_results_cct")
filter_metrics(
data_input=DUMMY_DF_CCT,
filter_list=os.path.join(TESTING_FILE_DIR, "filter_list.txt"),
filter_by="filename",
export_filename="filtered_metrics.tsv",
export_dir=export_dir,
return_type="file",
)
filtered_df = pd.read_csv(os.path.join(export_dir, "filtered_metrics.tsv"), sep="\t")
assert len(filtered_df) == 2
assert filtered_df["filename"].iloc[0] == "Bank Good Credit Loan.pptx"
@pytest.mark.skipif(is_in_docker, reason="Skipping this test in Docker container")
@pytest.mark.usefixtures("_cleanup_after_test")
def test_get_mean_grouping_all_file():
with open(os.path.join(TESTING_FILE_DIR, "filter_list.txt"), "w") as file:
file.write("Bank Good Credit Loan.pptx\n")
file.write("Performance-Audit-Discussion.pdf\n")
export_dir = os.path.join(TESTING_FILE_DIR, "test_evaluate_results_cct")
filter_metrics(
data_input=DUMMY_DF_CCT,
filter_list=["Bank Good Credit Loan.pptx", "Performance-Audit-Discussion.pdf"],
filter_by="filename",
export_filename="filtered_metrics.tsv",
export_dir=export_dir,
return_type="file",
)
filtered_df = pd.read_csv(os.path.join(export_dir, "filtered_metrics.tsv"), sep="\t")
get_mean_grouping(
group_by="all",
data_input=filtered_df,
export_dir=export_dir,
eval_name="text_extraction",
export_filename="two-filename-agg-cct.tsv",
)
grouped_df = pd.read_csv(os.path.join(export_dir, "two-filename-agg-cct.tsv"), sep="\t")
assert np.isclose(float(grouped_df.iloc[1, 0]), 0.903)
assert np.isclose(float(grouped_df.iloc[1, 1]), 0.129)
assert np.isclose(float(grouped_df.iloc[1, 2]), 0.091)
@pytest.mark.skipif(is_in_docker, reason="Skipping this test in Docker container")
@pytest.mark.usefixtures("_cleanup_after_test")
def test_get_mean_grouping_all_file_txt():
with open(os.path.join(TESTING_FILE_DIR, "filter_list.txt"), "w") as file:
file.write("Bank Good Credit Loan.pptx\n")
file.write("Performance-Audit-Discussion.pdf\n")
export_dir = os.path.join(TESTING_FILE_DIR, "test_evaluate_results_cct")
filter_metrics(
data_input=DUMMY_DF_CCT,
filter_list=os.path.join(TESTING_FILE_DIR, "filter_list.txt"),
filter_by="filename",
export_filename="filtered_metrics.tsv",
export_dir=export_dir,
return_type="file",
)
filtered_df = pd.read_csv(os.path.join(export_dir, "filtered_metrics.tsv"), sep="\t")
get_mean_grouping(
group_by="all",
data_input=filtered_df,
export_dir=export_dir,
eval_name="text_extraction",
export_filename="two-filename-agg-cct.tsv",
)
grouped_df = pd.read_csv(os.path.join(export_dir, "two-filename-agg-cct.tsv"), sep="\t")
assert np.isclose(float(grouped_df.iloc[1, 0]), 0.903)
assert np.isclose(float(grouped_df.iloc[1, 1]), 0.129)
assert np.isclose(float(grouped_df.iloc[1, 2]), 0.091)
@@ -0,0 +1,14 @@
from unstructured.metrics.table.table_alignment import TableAlignment
def test_get_element_level_alignment_when_no_match():
example_table = [{"row_index": 0, "col_index": 0, "content": "a"}]
metrics = TableAlignment.get_element_level_alignment(
predicted_table_data=[example_table],
ground_truth_table_data=[example_table],
matched_indices=[-1],
)
assert metrics["col_index_acc"] == 0
assert metrics["row_index_acc"] == 0
assert metrics["row_content_acc"] == 0
assert metrics["col_content_acc"] == 0
@@ -0,0 +1,33 @@
import pytest
from unstructured.metrics.table.table_eval import calculate_table_detection_metrics
@pytest.mark.parametrize(
("matched_indices", "ground_truth_tables_number", "expected_metrics"),
[
([0, 1, 2], 3, (1, 1, 1)), # everything was predicted correctly
([2, 1, 0], 3, (1, 1, 1)), # everything was predicted correctly
(
[-1, 2, -1, 1, 0, -1],
3,
(1, 0.5, 0.66),
), # some false positives, all tables matched, too many predictions
([2, 2, 1, 1], 8, (0.25, 0.5, 0.33)),
# Some false negatives, all predictions matched with gt, not enough predictions
# The precision here is not 1 as only one from tables matched with '1' index can be correct
([1, -1], 2, (0.5, 0.5, 0.5)), # typical case with false positive and false negative
([-1, -1, -1], 2, (0, 0, 0)), # nothing was matched
([-1, -1, -1], 0, (0, 0, 0)), # there was no table in ground truth
([], 0, (0, 0, 0)), # just zeros to account for errors
],
)
def test_calculate_table_metrics(matched_indices, ground_truth_tables_number, expected_metrics):
expected_recall, expected_precision, expected_f1 = expected_metrics
pred_recall, pred_precision, pred_f1 = calculate_table_detection_metrics(
matched_indices=matched_indices, ground_truth_tables_number=ground_truth_tables_number
)
assert pred_recall == expected_recall
assert pred_precision == expected_precision
assert pred_f1 == pytest.approx(expected_f1, abs=0.01)
@@ -0,0 +1,33 @@
import pytest
from unstructured.metrics.table.table_formats import SimpleTableCell
@pytest.mark.parametrize(
("row_nums", "column_nums", "x", "y", "w", "h"),
[
([3, 2, 1], [6, 7], 6, 1, 2, 3),
([2], [6, 7], 6, 2, 2, 1),
([1, 2, 3], [20], 20, 1, 1, 3),
([5], [5], 5, 5, 1, 1),
],
)
def test_simple_table_cell_parsing_from_table_transformer_when_expected_input(
row_nums, column_nums, x, y, w, h
):
table_transformer_cell = {"row_nums": row_nums, "column_nums": column_nums, "cell text": "text"}
transformed_cell = SimpleTableCell.from_table_transformer_cell(table_transformer_cell)
expected_cell = SimpleTableCell(x=x, y=y, w=w, h=h, content="text")
assert expected_cell == transformed_cell
def test_simple_table_cell_parsing_from_table_transformer_when_missing_row_nums():
cell = {"row_nums": [], "column_nums": [1], "cell text": "text"}
with pytest.raises(ValueError, match='has missing values under "row_nums" key'):
SimpleTableCell.from_table_transformer_cell(cell)
def test_simple_table_cell_parsing_from_table_transformer_when_missing_column_nums():
cell = {"row_nums": [1], "column_nums": [], "cell text": "text"}
with pytest.raises(ValueError, match='has missing values under "column_nums" key'):
SimpleTableCell.from_table_transformer_cell(cell)
@@ -0,0 +1,700 @@
from unittest import mock
import numpy as np
import pytest
from test_unstructured.unit_utils import example_doc_path
from unstructured.metrics.table.table_alignment import TableAlignment
from unstructured.metrics.table.table_eval import TableEvalProcessor
from unstructured.metrics.table_structure import (
eval_table_transformer_for_file,
image_or_pdf_to_dataframe,
)
@pytest.mark.parametrize(
"filename",
[
example_doc_path("img/table-multi-row-column-cells.png"),
example_doc_path("pdf/table-multi-row-column-cells.pdf"),
],
)
def test_image_or_pdf_to_dataframe(filename):
df = image_or_pdf_to_dataframe(filename)
assert ["Blind", "5", "1", "4", "34.5%, n=1", "1199 sec, n=1"] in df.values
def test_eval_table_transformer_for_file():
score = eval_table_transformer_for_file(
example_doc_path("img/table-multi-row-column-cells.png"),
example_doc_path("table-multi-row-column-cells-actual.csv"),
)
# avoid severe degradation of performance
assert 0.8 < score < 1
def test_table_eval_processor_simple():
prediction = [
{
"type": "Table",
"metadata": {
"text_as_html": """<table><thead><tr><th>r1c1</th><th>r1c2</th></tr></thead>
<tbody><tr><td>r2c1</td><td>r2c2</td></tr></tbody></table>"""
},
}
]
ground_truth = [
{
"type": "Table",
"text": [
{
"id": "ee862c7a-d27e-4484-92de-4faa42a63f3b",
"x": 0,
"y": 0,
"w": 1,
"h": 1,
"content": "r1c1",
},
{
"id": "6237ac7b-bfc8-40d2-92f2-d138277205e2",
"x": 0,
"y": 1,
"w": 1,
"h": 1,
"content": "r2c1",
},
{
"id": "9d0933a9-5984-4cad-80d9-6752bf9bc4df",
"x": 1,
"y": 0,
"w": 1,
"h": 1,
"content": "r1c2",
},
{
"id": "1152d043-5ead-4ab8-8b88-888d48831ac2",
"x": 1,
"y": 1,
"w": 1,
"h": 1,
"content": "r2c2",
},
],
}
]
te_processor = TableEvalProcessor(prediction, ground_truth)
result = te_processor.process_file()
assert result.total_tables == 1
assert result.table_level_acc == 1.0
assert result.element_row_level_index_acc == 1.0
assert result.element_col_level_index_acc == 1.0
assert result.element_row_level_content_acc == 1.0
assert result.element_col_level_content_acc == 1.0
def test_table_eval_processor_simple_when_input_as_cells():
prediction = [
{
"type": "Table",
"metadata": {
"table_as_cells": [
{
"x": 1,
"y": 1,
"w": 1,
"h": 1,
"content": "r2c2",
},
{
"x": 0,
"y": 0,
"w": 1,
"h": 1,
"content": "r1c1",
},
{
"x": 0,
"y": 1,
"w": 1,
"h": 1,
"content": "r2c1",
},
{
"x": 1,
"y": 0,
"w": 1,
"h": 1,
"content": "r1c2",
},
]
},
}
]
ground_truth = [
{
"type": "Table",
"text": [
{
"id": "ee862c7a-d27e-4484-92de-4faa42a63f3b",
"x": 0,
"y": 0,
"w": 1,
"h": 1,
"content": "r1c1",
},
{
"id": "6237ac7b-bfc8-40d2-92f2-d138277205e2",
"x": 0,
"y": 1,
"w": 1,
"h": 1,
"content": "r2c1",
},
{
"id": "9d0933a9-5984-4cad-80d9-6752bf9bc4df",
"x": 1,
"y": 0,
"w": 1,
"h": 1,
"content": "r1c2",
},
{
"id": "1152d043-5ead-4ab8-8b88-888d48831ac2",
"x": 1,
"y": 1,
"w": 1,
"h": 1,
"content": "r2c2",
},
],
}
]
te_processor = TableEvalProcessor(prediction, ground_truth, source_type="cells")
result = te_processor.process_file()
assert result.total_tables == 1
assert result.table_level_acc == 1.0
assert result.element_row_level_index_acc == 1.0
assert result.element_col_level_index_acc == 1.0
assert result.element_row_level_content_acc == 1.0
assert result.element_col_level_content_acc == 1.0
def test_table_eval_processor_when_wrong_source_type():
prediction = [
{
"type": "Table",
"metadata": {"table_as_cells": []},
}
]
ground_truth = [
{
"type": "Table",
"text": [],
}
]
te_processor = TableEvalProcessor(prediction, ground_truth, source_type="wrong_type")
with pytest.raises(ValueError):
te_processor.process_file()
@pytest.mark.parametrize(
"text_as_html",
[
"""
<table>
<thead>
<tr>
<th>r1c1</th>
<th>r1c2</th>
</tr>
</thead>
<tbody>
<tr>
<td>r2c1</td>
<td>r2c2</td>
</tr>
<tr>
<td>r3c1</td>
<td>r3c2</td>
</tr>
</tbody>
</table>
""",
"""
<table>
<tr>
<th>r1c1</th>
<th>r1c2</th>
</tr>
<tbody>
<tr>
<td>r2c1</td>
<td>r2c2</td>
</tr>
<tr>
<td>r3c1</td>
<td>r3c2</td>
</tr>
</tbody>
</table>
""",
"""
<table>
</tbody>
<tr>
<td>r1c1</td>
<td>r1c2</td>
</tr>
<tr>
<td>r2c1</td>
<td>r2c2</td>
</tr>
<tr>
<td>r3c1</td>
<td>r3c2</td>
</tr>
</tbody>
</table>
""",
],
)
def test_table_eval_processor_various_table_html_structures(text_as_html):
prediction = [{"type": "Table", "metadata": {"text_as_html": text_as_html}}]
ground_truth = [
{
"type": "Table",
"text": [
{
"id": "ee862c7a-d27e-4484-92de-4faa42a63f3b",
"x": 0,
"y": 0,
"w": 1,
"h": 1,
"content": "r1c1",
},
{
"id": "6237ac7b-bfc8-40d2-92f2-d138277205e2",
"x": 0,
"y": 1,
"w": 1,
"h": 1,
"content": "r2c1",
},
{
"id": "9d0933a9-5984-4cad-80d9-6752bf9bc4df",
"x": 1,
"y": 0,
"w": 1,
"h": 1,
"content": "r1c2",
},
{
"id": "1152d043-5ead-4ab8-8b88-888d48831ac2",
"x": 1,
"y": 1,
"w": 1,
"h": 1,
"content": "r2c2",
},
{
"id": "364f4a17-2979-4506-ae77-e8adf8e3f554",
"x": 0,
"y": 2,
"w": 1,
"h": 1,
"content": "r3c1",
},
{
"id": "30f87503-ac1f-4db1-b924-b316af585702",
"x": 1,
"y": 2,
"w": 1,
"h": 1,
"content": "r3c2",
},
],
}
]
te_processor = TableEvalProcessor(prediction, ground_truth)
result = te_processor.process_file()
assert result.total_tables == 1
assert result.table_level_acc == 1.0
assert result.element_row_level_index_acc == 1.0
assert result.element_col_level_index_acc == 1.0
assert result.element_row_level_content_acc == 1.0
assert result.element_col_level_content_acc == 1.0
def test_table_eval_processor_non_str_values_in_table():
prediction = [
{
"type": "Table",
"metadata": {
"text_as_html": """
<table>
<thead>
<tr>
<th>11</th>
<th>12</th>
</tr>
</thead>
<tbody>
<tr>
<td>21</td>
<td>22</td>
</tr>
</tbody>
</table>"""
},
}
]
ground_truth = [
{
"type": "Table",
"text": [
{
"id": "ee862c7a-d27e-4484-92de-4faa42a63f3b",
"x": 0,
"y": 0,
"w": 1,
"h": 1,
"content": "11",
},
{
"id": "6237ac7b-bfc8-40d2-92f2-d138277205e2",
"x": 0,
"y": 1,
"w": 1,
"h": 1,
"content": "21",
},
{
"id": "9d0933a9-5984-4cad-80d9-6752bf9bc4df",
"x": 1,
"y": 0,
"w": 1,
"h": 1,
"content": "12",
},
{
"id": "1152d043-5ead-4ab8-8b88-888d48831ac2",
"x": 1,
"y": 1,
"w": 1,
"h": 1,
"content": "22",
},
],
}
]
te_processor = TableEvalProcessor(prediction, ground_truth)
result = te_processor.process_file()
assert result.total_tables == 1
assert result.table_level_acc == 1.0
assert result.element_row_level_index_acc == 1.0
assert result.element_col_level_index_acc == 1.0
assert result.element_row_level_content_acc == 1.0
assert result.element_col_level_content_acc == 1.0
def test_table_eval_processor_merged_cells():
prediction = [
{
"type": "Table",
"metadata": {
"text_as_html": """
<table>
<thead>
<tr>
<th rowspan="2">r1c1</th>
<th>r1c2</th>
<th colspan="2">r1c3</th>
</tr>
<tr>
<th>r2c2</th>
<th>r2c3</th>
<th>r2c4</th>
</tr>
</thead>
<tbody>
<tr>
<td>r3c1</td>
<td>r3c2</td>
<td colspan="2" rowspan="2">r3c3</td>
</tr>
<tr>
<td>r4c1</td>
<td>r4c2</td>
</tr>
</tbody>
</table>
"""
},
}
]
ground_truth = [
{
"type": "Table",
"text": [
{
"id": "f399ef57-5b88-4509-8971-9cb63246866e",
"x": 0,
"y": 0,
"w": 1,
"h": 2,
"content": "r1c1",
},
{
"id": "2dfdec2f-e8f3-4be7-a6ac-8ff21c4e8556",
"x": 0,
"y": 2,
"w": 1,
"h": 1,
"content": "r3c1",
},
{
"id": "9c771c58-88c7-49d8-9c12-85d0e44b920e",
"x": 0,
"y": 3,
"w": 1,
"h": 1,
"content": "r4c1",
},
{
"id": "5bd6f3f0-34c5-495b-8a28-c4ac96989ef8",
"x": 1,
"y": 0,
"w": 1,
"h": 1,
"content": "r1c2",
},
{
"id": "7b8e6bc2-a310-4dd6-997c-313f951e7f96",
"x": 1,
"y": 1,
"w": 1,
"h": 1,
"content": "r2c2",
},
{
"id": "1c152ad4-12fa-4a7b-90de-a992aa6410a4",
"x": 1,
"y": 2,
"w": 1,
"h": 1,
"content": "r3c2",
},
{
"id": "55063f64-0003-4217-b6ca-aff5914793ff",
"x": 1,
"y": 3,
"w": 1,
"h": 1,
"content": "r4c2",
},
{
"id": "22852e86-0e22-4d32-b63a-9ba7dd4118a2",
"x": 2,
"y": 0,
"w": 2,
"h": 1,
"content": "r1c3",
},
{
"id": "eae013c5-5597-4a8b-9771-82e28c5c5cba",
"x": 2,
"y": 1,
"w": 1,
"h": 1,
"content": "r2c3",
},
{
"id": "0dea3a42-8523-4d6e-9e70-d65cc2314678",
"x": 2,
"y": 2,
"w": 2,
"h": 2,
"content": "r3c3",
},
{
"id": "60093e2c-d3e2-4146-92b5-97a2fc16c061",
"x": 3,
"y": 1,
"w": 1,
"h": 1,
"content": "r2c4",
},
],
}
]
te_processor = TableEvalProcessor(prediction, ground_truth)
result = te_processor.process_file()
assert result.total_tables == 1
assert result.table_level_acc == 1.0
assert result.element_row_level_index_acc == 1.0
assert result.element_col_level_index_acc == 1.0
assert result.element_row_level_content_acc == 1.0
assert result.element_col_level_content_acc == 1.0
def test_table_eval_processor_when_no_match_with_pred():
prediction = [
{
"type": "Table",
"metadata": {"text_as_html": """<table><tr><td>Some cell</td></tr></table>"""},
}
]
ground_truth = [
{
"type": "Table",
"text": [
{
"id": "ee862c7a-d27e-4484-92de-4faa42a63f3b",
"x": 0,
"y": 0,
"w": 1,
"h": 1,
"content": "11",
},
{
"id": "6237ac7b-bfc8-40d2-92f2-d138277205e2",
"x": 0,
"y": 1,
"w": 1,
"h": 1,
"content": "21",
},
{
"id": "9d0933a9-5984-4cad-80d9-6752bf9bc4df",
"x": 1,
"y": 0,
"w": 1,
"h": 1,
"content": "12",
},
{
"id": "1152d043-5ead-4ab8-8b88-888d48831ac2",
"x": 1,
"y": 1,
"w": 1,
"h": 1,
"content": "22",
},
],
}
]
with mock.patch.object(TableAlignment, "get_table_level_alignment") as align_fn:
align_fn.return_value = [-1]
te_processor = TableEvalProcessor(prediction, ground_truth)
result = te_processor.process_file()
assert result.total_tables == 1
assert result.table_level_acc == 0
assert result.element_row_level_index_acc == 0
assert result.element_col_level_index_acc == 0
assert result.element_row_level_content_acc == 0
assert result.element_col_level_content_acc == 0
def test_table_eval_processor_when_no_tables():
prediction = [{}]
ground_truth = [{}]
te_processor = TableEvalProcessor(prediction, ground_truth)
result = te_processor.process_file()
assert result.total_tables == 0
assert result.table_level_acc == 1
assert np.isnan(result.element_row_level_index_acc)
assert np.isnan(result.element_col_level_index_acc)
assert np.isnan(result.element_row_level_content_acc)
assert np.isnan(result.element_col_level_content_acc)
def test_table_eval_processor_when_only_gt():
prediction = []
ground_truth = [
{
"type": "Table",
"text": [
{
"id": "ee862c7a-d27e-4484-92de-4faa42a63f3b",
"x": 0,
"y": 0,
"w": 1,
"h": 1,
"content": "11",
},
{
"id": "6237ac7b-bfc8-40d2-92f2-d138277205e2",
"x": 0,
"y": 1,
"w": 1,
"h": 1,
"content": "21",
},
{
"id": "9d0933a9-5984-4cad-80d9-6752bf9bc4df",
"x": 1,
"y": 0,
"w": 1,
"h": 1,
"content": "12",
},
{
"id": "1152d043-5ead-4ab8-8b88-888d48831ac2",
"x": 1,
"y": 1,
"w": 1,
"h": 1,
"content": "22",
},
],
}
]
te_processor = TableEvalProcessor(prediction, ground_truth)
result = te_processor.process_file()
assert result.total_tables == 1
assert result.table_level_acc == 0
assert result.element_row_level_index_acc == 0
assert result.element_col_level_index_acc == 0
assert result.element_row_level_content_acc == 0
assert result.element_col_level_content_acc == 0
def test_table_eval_processor_when_only_pred():
prediction = [
{
"type": "Table",
"metadata": {"text_as_html": """<table><tr><td>Some cell</td></tr></table>"""},
}
]
ground_truth = [{}]
te_processor = TableEvalProcessor(prediction, ground_truth)
result = te_processor.process_file()
assert result.total_tables == 0
assert result.table_level_acc == 0
assert result.element_row_level_index_acc == 0
assert result.element_col_level_index_acc == 0
assert result.element_row_level_content_acc == 0
assert result.element_col_level_content_acc == 0
@@ -0,0 +1,891 @@
import re
import pytest
from unstructured.metrics import text_extraction
from unstructured.metrics.table.table_extraction import (
deckerd_table_to_html,
extract_cells_from_table_as_cells,
extract_cells_from_text_as_html,
html_table_to_deckerd,
)
from unstructured.partition.auto import partition
def test_calculate_edit_distance():
source_cct = "I like pizza. I like bagels."
source_cct_word_space = "I like p i z z a . I like bagles."
source_cct_spaces = re.sub(r"\s+", " ", " ".join(source_cct))
source_cct_no_space = source_cct.replace(" ", "")
source_cct_one_sentence = "I like pizza."
source_cct_missing_word = "I like pizza. I like ."
source_cct_addn_char = "I like pizza. I like beagles."
source_cct_dup_word = "I like pizza pizza. I like bagels."
assert (
round(text_extraction.calculate_edit_distance(source_cct, source_cct, return_as="score"), 2)
== 1.0
)
assert (
round(
text_extraction.calculate_edit_distance(
source_cct_word_space,
source_cct,
return_as="score",
),
2,
)
== 0.75
)
assert (
round(
text_extraction.calculate_edit_distance(
source_cct_spaces,
source_cct,
return_as="score",
),
2,
)
== 0.39
)
assert (
round(
text_extraction.calculate_edit_distance(
source_cct_no_space,
source_cct,
return_as="score",
),
2,
)
== 0.64
)
assert (
round(
text_extraction.calculate_edit_distance(
source_cct_one_sentence,
source_cct,
return_as="score",
),
2,
)
== 0.0
)
assert (
round(
text_extraction.calculate_edit_distance(
source_cct_missing_word,
source_cct,
return_as="score",
),
2,
)
== 0.57
)
assert (
round(
text_extraction.calculate_edit_distance(
source_cct_addn_char,
source_cct,
return_as="score",
),
2,
)
== 0.89
)
assert (
round(
text_extraction.calculate_edit_distance(
source_cct_dup_word,
source_cct,
return_as="score",
),
2,
)
== 0.79
)
@pytest.mark.parametrize(
("filename", "standardize_whitespaces", "expected_score", "expected_distance"),
[
("fake-text.txt", False, 0.78, 38),
("fake-text.txt", True, 0.92, 12),
],
)
def test_calculate_edit_distance_with_filename(
filename, standardize_whitespaces, expected_score, expected_distance
):
with open("example-docs/fake-text.txt") as f:
source_cct = f.read()
elements = partition(filename=f"example-docs/{filename}")
output_cct = "\n".join([str(el) for el in elements])
score = text_extraction.calculate_edit_distance(
output_cct, source_cct, return_as="score", standardize_whitespaces=standardize_whitespaces
)
distance = text_extraction.calculate_edit_distance(
output_cct,
source_cct,
return_as="distance",
standardize_whitespaces=standardize_whitespaces,
)
assert score >= 0
assert score <= 1.0
assert distance >= 0
assert round(score, 2) == expected_score
assert distance == expected_distance
@pytest.mark.parametrize(
("text1", "text2"),
[
(
"The dog\rloved the cat, but\t\n the cat\tloved the\n cow",
"The dog loved the cat, but the cat loved the cow",
),
(
"Hello my\tname\tis H a r p e r, \nwhat's your\vname?",
"Hello my name is H a r p e r, what's your name?",
),
(
"I have a\t\n\tdog and a\tcat,\fI love my\n\n\n\ndog.",
"I have a dog and a cat, I love my dog.",
),
(
"""
Name Age City Occupation
Alice 30 New York Engineer
Bob 25 Los Angeles Designer
Charlie 35 Chicago Teacher
David 40 San Francisco Developer
""",
"""
Name\tAge\tCity\tOccupation
Alice\t30\tNew York\tEngineer
Bob\t25\tLos Angeles\tDesigner
Charlie\t35\tChicago\tTeacher
David\t40\tSan Francisco\tDeveloper
""",
),
(
"""
Name\tAge\tCity\tOccupation
Alice\t30\tNew York\tEngineer
Bob\t25\tLos Angeles\tDesigner
Charlie\t35\tChicago\tTeacher
David\t40\tSan Francisco\tDeveloper
""",
"Name\tAge\tCity\tOccupation\n\n \nAlice\t30\tNew York\tEngineer\nBob\t25\tLos Angeles\tDesigner\nCharlie\t35\tChicago\tTeacher\nDavid\t40\tSan Francisco\tDeveloper", # noqa: E501
),
],
)
def test_calculate_edit_distance_with_various_whitespace_1(text1, text2):
assert (
text_extraction.calculate_edit_distance(
text1, text2, return_as="score", standardize_whitespaces=True
)
== 1.0
)
assert (
text_extraction.calculate_edit_distance(
text1, text2, return_as="distance", standardize_whitespaces=True
)
== 0
)
assert (
text_extraction.calculate_edit_distance(
text1, text2, return_as="score", standardize_whitespaces=False
)
< 1.0
)
assert (
text_extraction.calculate_edit_distance(
text1, text2, return_as="distance", standardize_whitespaces=False
)
> 0
)
def test_calculate_edit_distance_with_various_whitespace_2():
source_cct_tabs = """
Name\tAge\tCity\tOccupation
Alice\t30\tNew York\tEngineer
Bob\t25\tLos Angeles\tDesigner
Charlie\t35\tChicago\tTeacher
David\t40\tSan Francisco\tDeveloper
"""
source_cct_with_borders = """
| Name | Age | City | Occupation |
|---------|-----|--------------|----------------|
| Alice | 30 | New York | Engineer |
| Bob | 25 | Los Angeles | Designer |
| Charlie | 35 | Chicago | Teacher |
| David | 40 | San Francisco| Developer |
"""
assert text_extraction.calculate_edit_distance(
source_cct_tabs, source_cct_with_borders, return_as="score", standardize_whitespaces=True
) > text_extraction.calculate_edit_distance(
source_cct_tabs, source_cct_with_borders, return_as="score", standardize_whitespaces=False
)
assert text_extraction.calculate_edit_distance(
source_cct_tabs, source_cct_with_borders, return_as="distance", standardize_whitespaces=True
) < text_extraction.calculate_edit_distance(
source_cct_tabs,
source_cct_with_borders,
return_as="distance",
standardize_whitespaces=False,
)
@pytest.mark.parametrize(
("text", "expected"),
[
(
"The dog loved the cat, but the cat loved the cow",
{"the": 4, "cat": 2, "loved": 2, "dog": 1, "but": 1, "cow": 1},
),
(
"Hello my name is H a r p e r, what's your name?",
{"hello": 1, "my": 1, "name": 2, "is": 1, "what's": 1, "your": 1},
),
(
"I have a dog and a cat, I love my dog.",
{"i": 2, "have": 1, "a": 2, "dog": 2, "and": 1, "cat": 1, "love": 1, "my": 1},
),
(
"My dog's hair is red, but the dogs' houses are blue.",
{
"my": 1,
"dog's": 1,
"hair": 1,
"is": 1,
"red": 1,
"but": 1,
"the": 1,
"dogs'": 1,
"houses": 1,
"are": 1,
"blue": 1,
},
),
(
"""Sometimes sentences have a dash - like this one!
A hyphen connects 2 words with no gap: easy-peasy.""",
{
"sometimes": 1,
"sentences": 1,
"have": 1,
"a": 2,
"dash": 1,
"like": 1,
"this": 1,
"one": 1,
"hyphen": 1,
"connects": 1,
"2": 1,
"words": 1,
"with": 1,
"no": 1,
"gap": 1,
"easy-peasy": 1,
},
),
],
)
def test_bag_of_words(text, expected):
assert text_extraction.bag_of_words(text) == expected
@pytest.mark.parametrize(
("text", "expected"),
[
(
"The dog\rloved the cat, but\t\n the cat\tloved the\n cow\n\n",
"The dog loved the cat, but the cat loved the cow",
),
(
"\n\nHello my\tname\tis H a r p e r, \nwhat's your\vname?",
"Hello my name is H a r p e r, what's your name?",
),
(
"I have a\t\n\tdog and a\tcat,\fI love my\n\n\n\ndog.",
"I have a dog and a cat, I love my dog.",
),
(
"""L is for the way you look at me
O is for the only one I see
V is very, very extraordinary
E is even more than anyone that you adore can""",
"L is for the way you look at me O is for the only one I see V is very, very extraordinary E is even more than anyone that you adore can", # noqa: E501
),
(
"""
| Name | Age | City | Occupation |
|---------|-----|--------------|----------------|
| Alice | 30 | New York | Engineer |
| Bob | 25 | Los Angeles | Designer |
| Charlie | 35 | Chicago | Teacher |
| David | 40 | San Francisco| Developer |
""",
"| Name | Age | City | Occupation | |---------|-----|--------------|----------------| | Alice | 30 | New York | Engineer | | Bob | 25 | Los Angeles | Designer | | Charlie | 35 | Chicago | Teacher | | David | 40 | San Francisco| Developer |", # noqa: E501
),
],
)
def test_prepare_string(text, expected):
assert text_extraction.prepare_str(text, standardize_whitespaces=True) == expected
assert text_extraction.prepare_str(text) == text
@pytest.mark.parametrize(
("input_text", "expected_output"),
[
# Mixed quotes in longer sentences
(
"She said \"Hello\" and then whispered 'Goodbye' before leaving.",
"She said \"Hello\" and then whispered 'Goodbye' before leaving.",
),
# Double low-9 quotes with complex content
(
"„To be, or not to be, that is the question\" - Shakespeare's famous quote.",
'"To be, or not to be, that is the question" - Shakespeare\'s famous quote.',
),
# Angle quotes with nested quotes
(
'«When he said "life is beautiful," I believed him» wrote Maria.',
'"When he said "life is beautiful," I believed him" wrote Maria.',
),
# Heavy ornament quotes in dialogue
(
"❝Do you remember when we first met?❞ she asked with a smile.",
'"Do you remember when we first met?" she asked with a smile.',
),
# Double prime quotes with punctuation
(
"〝The meeting starts at 10:00, don't be late!〟 announced the manager.",
'"The meeting starts at 10:00, don\'t be late!" announced the manager.',
),
# Corner brackets with nested quotes
(
'「He told me "This is important" yesterday」, she explained.',
"'He told me \"This is important\" yesterday', she explained.",
),
# White corner brackets with multiple sentences
(
"『The sun was setting. The birds were singing. It was peaceful.』",
"'The sun was setting. The birds were singing. It was peaceful.'",
),
# Vertical corner brackets with numbers and special characters
("﹂Meeting #123 @ 15:00 - Don't forget!﹁", "'Meeting #123 @ 15:00 - Don't forget!'"),
# Complex mixed quote types
(
'「Hello」, ❝World❞, "Test", \'Example\', „Quote", «Final»',
'\'Hello\', "World", "Test", \'Example\', "Quote", "Final"',
),
# Quotes with multiple apostrophes
("It's John's book, isn't it?", "It's John's book, isn't it?"),
# Single angle quotes with nested content
(
'Testing the system\'s capability for "quoted" text',
"'Testing the system's capability for \"quoted\" text'",
),
# Heavy single ornament quotes with multiple sentences
(
"❛First sentence. Second sentence. Third sentence.❜",
"'First sentence. Second sentence. Third sentence.'",
),
# Mix of various quote types in complex text
(
'「Chapter 1」: ❝The Beginning❞ - „A new story" begins «today».',
'\'Chapter 1\': "The Beginning" - "A new story" begins "today".',
),
# --- Regression: U+201C / U+2018 were silently dropped by duplicate dict keys ---
# U+201C left double quotation mark (isolated)
("\u201c", '"'),
# U+2018 left single quotation mark (isolated)
("\u2018", "'"),
# Left + right double smart quotes wrapping a word
("\u201cHello\u201d", '"Hello"'),
# Left + right single smart quotes wrapping a word
("\u2018world\u2019", "'world'"),
# Mixed left/right smart quotes in a sentence
(
"She said \u201cHello\u201d and then whispered \u2018Goodbye\u2019",
"She said \"Hello\" and then whispered 'Goodbye'",
),
# Possessive with left single smart quote
("\u2018tis the season", "'tis the season"),
],
)
def test_standardize_quotes(input_text, expected_output):
assert text_extraction.standardize_quotes(input_text) == expected_output
@pytest.mark.parametrize(
("codepoint", "expected"),
[
pytest.param(cp, '"', id=f"U+{ord(cp):04X}->double")
for cp in text_extraction._DOUBLE_QUOTE_CODEPOINTS
]
+ [
pytest.param(cp, "'", id=f"U+{ord(cp):04X}->single")
for cp in text_extraction._SINGLE_QUOTE_CODEPOINTS
],
)
def test_standardize_quotes_every_codepoint(codepoint, expected):
"""Every codepoint in the translation table must map to its ASCII equivalent."""
assert text_extraction.standardize_quotes(codepoint) == expected
@pytest.mark.parametrize(
("output_text", "source_text", "expected_percentage"),
[
(
"extra",
"",
0,
),
(
"",
"Source text has a sentence.",
1,
),
(
"The original s e n t e n c e is normal.",
"The original sentence is normal...",
0.2,
),
(
"We saw 23% improvement in this quarter.",
"We saw 23% improvement in sales this quarter.",
0.125,
),
(
"no",
"Is it possible to have more than everything missing?",
1,
),
],
)
def test_calculate_percent_missing_text(output_text, source_text, expected_percentage):
assert (
text_extraction.calculate_percent_missing_text(output_text, source_text)
== expected_percentage
)
@pytest.mark.parametrize(
("table_as_cells", "expected_extraction"),
[
pytest.param(
[
{"x": 0, "y": 0, "w": 1, "h": 1, "content": "Month A."},
{"x": 0, "y": 1, "w": 1, "h": 1, "content": "22"},
],
[
{"row_index": 0, "col_index": 0, "content": "Month A."},
{"row_index": 1, "col_index": 0, "content": "22"},
],
id="Simple table, 1 head cell, 1 body cell, no spans",
),
pytest.param(
[
{"x": 0, "y": 0, "w": 1, "h": 1, "content": "Month A."},
{"x": 1, "y": 0, "w": 1, "h": 1, "content": "Month B."},
{"x": 2, "y": 0, "w": 1, "h": 1, "content": "Month C."},
{"x": 0, "y": 1, "w": 1, "h": 1, "content": "11"},
{"x": 1, "y": 1, "w": 1, "h": 1, "content": "12"},
{"x": 2, "y": 1, "w": 1, "h": 1, "content": "13"},
{"x": 0, "y": 2, "w": 1, "h": 1, "content": "21"},
{"x": 1, "y": 2, "w": 1, "h": 1, "content": "22"},
{"x": 2, "y": 2, "w": 1, "h": 1, "content": "23"},
],
[
{"row_index": 0, "col_index": 0, "content": "Month A."},
{"row_index": 0, "col_index": 1, "content": "Month B."},
{"row_index": 0, "col_index": 2, "content": "Month C."},
{"row_index": 1, "col_index": 0, "content": "11"},
{"row_index": 1, "col_index": 1, "content": "12"},
{"row_index": 1, "col_index": 2, "content": "13"},
{"row_index": 2, "col_index": 0, "content": "21"},
{"row_index": 2, "col_index": 1, "content": "22"},
{"row_index": 2, "col_index": 2, "content": "23"},
],
id="Simple table, 3 head cell, 5 body cell, no spans",
),
# +----------+---------------------+----------+
# | | h1col23 | h1col4 |
# | h12col1 |----------+----------+----------|
# | | h2col2 | h2col34 |
# |----------|----------+----------+----------+
# | r3col1 | r3col2 | |
# |----------+----------| r34col34 |
# | r4col12 | |
# +----------+----------+----------+----------+
pytest.param(
[
{
"y": 0,
"x": 0,
"w": 2,
"h": 1,
"content": "h12col1",
},
{
"y": 0,
"x": 1,
"w": 1,
"h": 2,
"content": "h1col23",
},
{
"y": 0,
"x": 3,
"w": 1,
"h": 1,
"content": "h1col4",
},
{
"y": 1,
"x": 1,
"w": 1,
"h": 1,
"content": "h2col2",
},
{
"y": 1,
"x": 2,
"w": 1,
"h": 2,
"content": "h2col34",
},
{
"y": 2,
"x": 0,
"w": 1,
"h": 1,
"content": "r3col1",
},
{
"y": 2,
"x": 1,
"w": 1,
"h": 1,
"content": "r3col2",
},
{
"y": 2,
"x": 2,
"w": 2,
"h": 2,
"content": "r34col34",
},
{
"y": 3,
"x": 0,
"w": 1,
"h": 2,
"content": "r4col12",
},
],
[
{
"row_index": 0,
"col_index": 0,
"content": "h12col1",
},
{
"row_index": 0,
"col_index": 1,
"content": "h1col23",
},
{
"row_index": 0,
"col_index": 3,
"content": "h1col4",
},
{
"row_index": 1,
"col_index": 1,
"content": "h2col2",
},
{
"row_index": 1,
"col_index": 2,
"content": "h2col34",
},
{
"row_index": 2,
"col_index": 0,
"content": "r3col1",
},
{
"row_index": 2,
"col_index": 1,
"content": "r3col2",
},
{
"row_index": 2,
"col_index": 2,
"content": "r34col34",
},
{
"row_index": 3,
"col_index": 0,
"content": "r4col12",
},
],
id="various spans, with 2 row header",
),
],
)
def test_cells_table_extraction_from_prediction(table_as_cells, expected_extraction):
example_element = {
"type": "Table",
"metadata": {"table_as_cells": table_as_cells},
}
assert extract_cells_from_table_as_cells(example_element) == expected_extraction
@pytest.mark.parametrize(
("text_as_html", "expected_extraction"),
[
pytest.param(
"""
<table>
<thead>
<tr>
<th>Month A.</th>
</tr>
</thead>
<tbody>
<tr>
<td>22</td>
</tr>
</tbody>
</table>"
""",
[
{"row_index": 0, "col_index": 0, "content": "Month A."},
{"row_index": 1, "col_index": 0, "content": "22"},
],
id="Simple table, 1 head cell, 1 body cell, no spans",
),
pytest.param(
"""
<table>
<thead>
<tr>
<th>Month A.</th>
<th>Month B.</th>
<th>Month C.</th>
</tr>
</thead>
<tbody>
<tr>
<td>11</td>
<td>12</td>
<td>13</td>
</tr>
<tr>
<td>21</td>
<td>22</td>
<td>23</td>
</tr>
</tbody>
</table>"
""",
[
{"row_index": 0, "col_index": 0, "content": "Month A."},
{"row_index": 0, "col_index": 1, "content": "Month B."},
{"row_index": 0, "col_index": 2, "content": "Month C."},
{"row_index": 1, "col_index": 0, "content": "11"},
{"row_index": 1, "col_index": 1, "content": "12"},
{"row_index": 1, "col_index": 2, "content": "13"},
{"row_index": 2, "col_index": 0, "content": "21"},
{"row_index": 2, "col_index": 1, "content": "22"},
{"row_index": 2, "col_index": 2, "content": "23"},
],
id="Simple table, 3 head cell, 5 body cell, no spans",
),
# +----------+---------------------+----------+
# | | h1col23 | h1col4 |
# | h12col1 |----------+----------+----------|
# | | h2col2 | h2col34 |
# |----------|----------+----------+----------+
# | r3col1 | r3col2 | |
# |----------+----------| r34col34 |
# | r4col12 | |
# +----------+----------+----------+----------+
pytest.param(
"""
<table>
<thead>
<tr>
<th rowspan="2">h12col1</th>
<th colspan="2">h1col23</th>
<th>h1col4</th>
</tr>
<tr>
<th>h2col2</th>
<th colspan="2">h2col34</th>
</tr>
</thead>
<tbody>
<tr>
<td>r3col1</td>
<td>r3col2</td>
<td colspan="2" rowspan="2">r34col34</td>
</tr>
<tr>
<td colspan="2">r4col12</td>
</tr>
</tbody>
</table>
""",
[
{
"row_index": 0,
"col_index": 0,
"content": "h12col1",
},
{
"row_index": 0,
"col_index": 1,
"content": "h1col23",
},
{
"row_index": 0,
"col_index": 3,
"content": "h1col4",
},
{
"row_index": 1,
"col_index": 1,
"content": "h2col2",
},
{
"row_index": 1,
"col_index": 2,
"content": "h2col34",
},
{
"row_index": 2,
"col_index": 0,
"content": "r3col1",
},
{
"row_index": 2,
"col_index": 1,
"content": "r3col2",
},
{
"row_index": 2,
"col_index": 2,
"content": "r34col34",
},
{
"row_index": 3,
"col_index": 0,
"content": "r4col12",
},
],
id="various spans, with 2 row header",
),
],
)
def test_html_table_extraction_from_prediction(text_as_html, expected_extraction):
example_element = {
"type": "Table",
"metadata": {
"text_as_html": text_as_html,
},
}
assert extract_cells_from_text_as_html(example_element) == expected_extraction
def test_cells_extraction_from_prediction_when_missing_prediction():
example_element = {"type": "Table", "metadata": {"text_as_html": "", "table_as_cells": []}}
assert extract_cells_from_text_as_html(example_element) is None
assert extract_cells_from_table_as_cells(example_element) is None
def _trim_html(html: str) -> str:
html_lines = [line.strip() for line in html.split("\n") if line]
return "".join(html_lines)
@pytest.mark.parametrize(
"html_to_test",
[
"""
<table>
<thead>
<tr>
<th>Month A.</th>
</tr>
</thead>
<tbody>
<tr>
<td>22</td>
</tr>
</tbody>
</table>
""",
"""
<table>
<thead>
<tr>
<th>Month A.</th>
<th>Month B.</th>
<th>Month C.</th>
</tr>
</thead>
<tbody>
<tr>
<td>11</td>
<td>12</td>
<td>13</td>
</tr>
<tr>
<td>21</td>
<td>22</td>
<td>23</td>
</tr>
</tbody>
</table>
""",
"""
<table>
<thead>
<tr>
<th rowspan="2">h12col1</th>
<th colspan="2">h1col23</th>
<th>h1col4</th>
</tr>
<tr>
<th>h2col2</th>
<th colspan="2">h2col34</th>
</tr>
</thead>
<tbody>
<tr>
<td>r3col1</td>
<td>r3col2</td>
<td colspan="2" rowspan="2">r34col34</td>
</tr>
<tr>
<td colspan="2">r4col12</td>
</tr>
</tbody>
</table>
""",
],
)
def test_deckerd_html_converter(html_to_test):
deckerd_table = html_table_to_deckerd(html_to_test)
html_table = deckerd_table_to_html(deckerd_table)
assert _trim_html(html_to_test) == html_table
+35
View File
@@ -0,0 +1,35 @@
import pytest
from unstructured.metrics.utils import (
_mean,
_pstdev,
_stdev,
_uniquity_file,
)
@pytest.mark.parametrize(
("numbers", "expected_mean", "expected_stdev", "expected_pstdev"),
[
([2, 5, 6, 7], 5, 2.16, 1.871),
([1, 100], 50.5, 70.004, 49.5),
([1], 1, None, None),
([], None, None, None),
],
)
def test_stats(numbers, expected_mean, expected_stdev, expected_pstdev):
mean = _mean(numbers)
stdev = _stdev(numbers)
pstdev = _pstdev(numbers)
assert mean == expected_mean
assert stdev == expected_stdev
assert pstdev == expected_pstdev
@pytest.mark.parametrize(
("filenames"),
[("filename.ext", "filename (1).ext", "randomfile.ext", "filename.txt", "filename (5).txt")],
)
def test_uniquity_file(filenames):
final_filename = _uniquity_file(filenames, "filename.ext")
assert final_filename == "filename (2).ext"
View File
+21
View File
@@ -0,0 +1,21 @@
from typing import List, Tuple
def mock_sent_tokenize(text: str) -> List[str]:
sentences = text.split(".")
return sentences[:-1] if text.endswith(".") else sentences
def mock_word_tokenize(text: str) -> List[str]:
return text.split(" ")
def mock_pos_tag(text: str) -> List[Tuple[str, str]]:
tokens = mock_word_tokenize(text)
pos_tags: List[Tuple[str, str]] = []
for token in tokens:
if token.lower() == "ask":
pos_tags.append((token, "VB"))
else:
pos_tags.append((token, ""))
return pos_tags
+1
View File
@@ -0,0 +1 @@
# flake8: noqa
+66
View File
@@ -0,0 +1,66 @@
from unstructured.nlp import tokenize
def test_pos_tag():
parts_of_speech = tokenize.pos_tag("ITEM 2A. PROPERTIES")
tags = dict(parts_of_speech)
assert "ITEM" in tags
assert "PROPERTIES" in tags
assert all(isinstance(t, tuple) and len(t) == 2 for t in parts_of_speech)
def test_word_tokenize_caches():
tokenize.word_tokenize.cache_clear()
assert tokenize.word_tokenize.cache_info().currsize == 0
tokenize.word_tokenize("Greetings! I am from outer space.")
assert tokenize.word_tokenize.cache_info().currsize == 1
tokenize.word_tokenize("Greetings! I am from outer space.")
assert tokenize.word_tokenize.cache_info().hits == 1
def test_sent_tokenize_caches():
tokenize._tokenize_for_cache.cache_clear()
assert tokenize._tokenize_for_cache.cache_info().currsize == 0
tokenize._tokenize_for_cache("Greetings! I am from outer space.")
assert tokenize._tokenize_for_cache.cache_info().currsize == 1
tokenize._tokenize_for_cache("Greetings! I am from outer space.")
assert tokenize._tokenize_for_cache.cache_info().hits == 1
def test_pos_tag_caches():
tokenize.pos_tag.cache_clear()
assert tokenize.pos_tag.cache_info().currsize == 0
tokenize.pos_tag("Greetings! I am from outer space.")
assert tokenize.pos_tag.cache_info().currsize == 1
tokenize.pos_tag("Greetings! I am from outer space.")
assert tokenize.pos_tag.cache_info().hits == 1
def test_tokenizers_functions_run():
sentence = "I am a big brown bear. What are you?"
tokenize.sent_tokenize(sentence)
tokenize.word_tokenize(sentence)
tokenize.pos_tag(sentence)
def test_process_truncates_text_exceeding_spacy_max_length(caplog):
# Build text well above spaCy's default 1,000,000-char limit, like the prod trace.
nlp = tokenize._get_nlp()
long_text = "This is a sentence. " * ((nlp.max_length // 20) + 10_000)
assert len(long_text) > nlp.max_length
with caplog.at_level("WARNING", logger=tokenize.logger.name):
# Must not raise spacy ValueError E088.
sents = tokenize.sent_tokenize(long_text)
assert len(sents) > 0
assert any("exceeds spaCy max_length" in rec.message for rec in caplog.records)
def test_process_does_not_truncate_text_within_limit():
nlp = tokenize._get_nlp()
text = "Greetings! I am from outer space."
assert len(text) <= nlp.max_length
doc = tokenize._process(text)
# When no truncation occurs the full text round-trips through spaCy.
assert doc.text == text
@@ -0,0 +1,468 @@
import pathlib
from multiprocessing import Pool
import numpy as np
import pytest
from PIL import Image
from unstructured_inference.constants import IsExtracted
from unstructured_inference.inference import layout
from unstructured_inference.inference.elements import TextRegion
from unstructured_inference.inference.layoutelement import LayoutElement
from test_unstructured.unit_utils import example_doc_path
from unstructured.documents.coordinates import PixelSpace
from unstructured.documents.elements import (
TYPE_TO_TEXT_ELEMENT_MAP,
CheckBox,
CoordinatesMetadata,
ElementType,
FigureCaption,
Header,
ListItem,
NarrativeText,
Text,
Title,
)
from unstructured.documents.elements import (
Image as ImageElement,
)
from unstructured.partition.common import common
class MockPageLayout(layout.PageLayout):
def __init__(self, number: int, image: Image.Image):
self.number = number
self.image = image
@property
def elements(self):
return [
LayoutElement(
type="Headline",
text="Charlie Brown and the Great Pumpkin",
bbox=None,
),
LayoutElement(
type="Subheadline",
text="The Beginning",
bbox=None,
),
LayoutElement(
type="Text",
text="This time Charlie Brown had it really tricky...",
bbox=None,
),
LayoutElement(
type="Title",
text="Another book title in the same page",
bbox=None,
),
]
class MockDocumentLayout(layout.DocumentLayout):
@property
def pages(self):
return [
MockPageLayout(number=1, image=Image.new("1", (1, 1))),
]
def test_normalize_layout_element_dict():
layout_element = {
"type": "Title",
"coordinates": [[1, 2], [3, 4], [5, 6], [7, 8]],
"coordinate_system": None,
"text": "Some lovely text",
}
coordinate_system = PixelSpace(width=10, height=20)
element = common.normalize_layout_element(
layout_element,
coordinate_system=coordinate_system,
)
assert element == Title(
text="Some lovely text",
coordinates=[[1, 2], [3, 4], [5, 6], [7, 8]],
coordinate_system=coordinate_system,
)
def test_normalize_layout_element_dict_caption():
layout_element = {
"type": "Figure",
"coordinates": ((1, 2), (3, 4), (5, 6), (7, 8)),
"text": "Some lovely text",
}
coordinate_system = PixelSpace(width=10, height=20)
element = common.normalize_layout_element(
layout_element,
coordinate_system=coordinate_system,
)
assert element == ImageElement(
text="Some lovely text",
coordinates=((1, 2), (3, 4), (5, 6), (7, 8)),
coordinate_system=coordinate_system,
)
@pytest.mark.parametrize(
("element_type", "expected_type", "expected_depth"),
[
("Title", Title, None),
("Headline", Title, 1),
("Subheadline", Title, 2),
("Header", Header, None),
],
)
def test_normalize_layout_element_headline(element_type, expected_type, expected_depth):
layout_element = {
"type": element_type,
"coordinates": [[1, 2], [3, 4], [5, 6], [7, 8]],
"text": "Some lovely text",
}
coordinate_system = PixelSpace(width=10, height=20)
element = common.normalize_layout_element(layout_element, coordinate_system=coordinate_system)
assert element.metadata.category_depth == expected_depth
assert isinstance(element, expected_type)
def test_normalize_layout_element_dict_figure_caption():
layout_element = {
"type": "FigureCaption",
"coordinates": [[1, 2], [3, 4], [5, 6], [7, 8]],
"text": "Some lovely text",
}
coordinate_system = PixelSpace(width=10, height=20)
element = common.normalize_layout_element(
layout_element,
coordinate_system=coordinate_system,
)
assert element == FigureCaption(
text="Some lovely text",
coordinates=[[1, 2], [3, 4], [5, 6], [7, 8]],
coordinate_system=coordinate_system,
)
def test_normalize_layout_element_dict_misc():
layout_element = {
"type": "Misc",
"coordinates": [[1, 2], [3, 4], [5, 6], [7, 8]],
"text": "Some lovely text",
}
coordinate_system = PixelSpace(width=10, height=20)
element = common.normalize_layout_element(
layout_element,
coordinate_system=coordinate_system,
)
assert element == Text(
text="Some lovely text",
coordinates=[[1, 2], [3, 4], [5, 6], [7, 8]],
coordinate_system=coordinate_system,
)
def test_normalize_layout_element_layout_element():
layout_element = LayoutElement.from_coords(
type="Text",
x1=1,
y1=2,
x2=3,
y2=4,
text="Some lovely text",
)
coordinate_system = PixelSpace(width=10, height=20)
element = common.normalize_layout_element(
layout_element,
coordinate_system=coordinate_system,
)
assert element == NarrativeText(
text="Some lovely text",
coordinates=((1, 2), (1, 4), (3, 4), (3, 2)),
coordinate_system=coordinate_system,
)
def test_normalize_layout_element_layout_element_narrative_text():
layout_element = LayoutElement.from_coords(
type="NarrativeText",
x1=1,
y1=2,
x2=3,
y2=4,
text="Some lovely text",
)
coordinate_system = PixelSpace(width=10, height=20)
element = common.normalize_layout_element(
layout_element,
coordinate_system=coordinate_system,
)
assert element == NarrativeText(
text="Some lovely text",
coordinates=((1, 2), (1, 4), (3, 4), (3, 2)),
coordinate_system=coordinate_system,
)
@pytest.mark.parametrize(
("element_type", "expected_element_class"),
TYPE_TO_TEXT_ELEMENT_MAP.items(),
)
def test_normalize_layout_element_layout_element_maps_to_appropriate_text_element(
element_type: str,
expected_element_class: type[Text],
):
layout_element = LayoutElement.from_coords(
type=element_type,
x1=1,
y1=2,
x2=3,
y2=4,
text="Some lovely text",
)
coordinate_system = PixelSpace(width=10, height=20)
element = common.normalize_layout_element(
layout_element,
coordinate_system=coordinate_system,
)
assert element == expected_element_class(
text="Some lovely text",
coordinates=((1, 2), (1, 4), (3, 4), (3, 2)),
coordinate_system=coordinate_system,
)
@pytest.mark.parametrize(
("element_type", "expected_checked"),
[
(ElementType.CHECK_BOX_UNCHECKED, False),
(ElementType.CHECK_BOX_CHECKED, True),
(ElementType.RADIO_BUTTON_UNCHECKED, False),
(ElementType.RADIO_BUTTON_CHECKED, True),
(ElementType.CHECKED, True),
(ElementType.UNCHECKED, False),
],
)
def test_normalize_layout_element_checkable(element_type: str, expected_checked: bool):
layout_element = LayoutElement.from_coords(
type=element_type,
x1=1,
y1=2,
x2=3,
y2=4,
text="",
)
coordinate_system = PixelSpace(width=10, height=20)
element = common.normalize_layout_element(
layout_element,
coordinate_system=coordinate_system,
)
assert isinstance(element, CheckBox)
assert element == CheckBox(
checked=expected_checked,
coordinates=((1, 2), (1, 4), (3, 4), (3, 2)),
coordinate_system=coordinate_system,
)
def test_normalize_layout_element_enumerated_list():
layout_element = LayoutElement.from_coords(
type="List",
x1=1,
y1=2,
x2=3,
y2=4,
text="1. I'm so cool! 2. You're cool too. 3. We're all cool!",
)
coordinate_system = PixelSpace(width=10, height=20)
elements = common.normalize_layout_element(
layout_element,
coordinate_system=coordinate_system,
)
assert elements == [
ListItem(
text="I'm so cool!",
coordinates=((1, 2), (1, 4), (3, 4), (3, 2)),
coordinate_system=coordinate_system,
),
ListItem(
text="You're cool too.",
coordinates=((1, 2), (1, 4), (3, 4), (3, 2)),
coordinate_system=coordinate_system,
),
ListItem(
text="We're all cool!",
coordinates=((1, 2), (1, 4), (3, 4), (3, 2)),
coordinate_system=coordinate_system,
),
]
def test_normalize_layout_element_bulleted_list():
layout_element = LayoutElement.from_coords(
type="List",
x1=1,
y1=2,
x2=3,
y2=4,
text="* I'm so cool! * You're cool too. * We're all cool!",
)
coordinate_system = PixelSpace(width=10, height=20)
elements = common.normalize_layout_element(
layout_element,
coordinate_system=coordinate_system,
)
assert elements == [
ListItem(
text="I'm so cool!",
coordinates=((1, 2), (1, 4), (3, 4), (3, 2)),
coordinate_system=coordinate_system,
),
ListItem(
text="You're cool too.",
coordinates=((1, 2), (1, 4), (3, 4), (3, 2)),
coordinate_system=coordinate_system,
),
ListItem(
text="We're all cool!",
coordinates=((1, 2), (1, 4), (3, 4), (3, 2)),
coordinate_system=coordinate_system,
),
]
class MockRunOutput:
def __init__(self, returncode, stdout, stderr):
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
def test_convert_office_doc_captures_errors(monkeypatch, caplog):
from unstructured.partition.common.common import subprocess
def mock_run(*args, **kwargs):
return MockRunOutput(1, "an error occurred".encode(), "error details".encode())
monkeypatch.setattr(subprocess, "run", mock_run)
common.convert_office_doc("no-real.docx", "fake-directory", target_format="docx")
assert "soffice failed to convert to format docx with code 1" in caplog.text
def test_convert_office_docs_avoids_concurrent_call_to_soffice():
paths_to_save = [pathlib.Path(path) for path in ("/tmp/proc1", "/tmp/proc2", "/tmp/proc3")]
for path in paths_to_save:
path.mkdir(exist_ok=True)
(path / "simple.docx").unlink(missing_ok=True)
file_to_convert = example_doc_path("simple.doc")
with Pool(3) as pool:
pool.starmap(common.convert_office_doc, [(file_to_convert, path) for path in paths_to_save])
assert np.sum([(path / "simple.docx").is_file() for path in paths_to_save]) == 3
def test_convert_office_docs_respects_wait_timeout():
paths_to_save = [
pathlib.Path(path) for path in ("/tmp/wait/proc1", "/tmp/wait/proc2", "/tmp/wait/proc3")
]
for path in paths_to_save:
path.mkdir(parents=True, exist_ok=True)
(path / "simple.docx").unlink(missing_ok=True)
file_to_convert = example_doc_path("simple.doc")
with Pool(3) as pool:
pool.starmap(
common.convert_office_doc,
# set timeout to wait for soffice to be available to 0 so only one process can convert
# the doc file on the first try; then the catch all
[(file_to_convert, path, "docx", None, 0) for path in paths_to_save],
)
# because this test file is very small we could have occasions where two files are converted
# when one of the processes spawned just a little
assert np.sum([(path / "simple.docx").is_file() for path in paths_to_save]) < 3
@pytest.mark.parametrize(
("text", "expected"),
[
("<table><tbody><tr><td>👨\\U+1F3FB🔧</td></tr></tbody></table>", True),
("<table><tbody><tr><td>Hello!</td></tr></tbody></table>", False),
],
)
def test_contains_emoji(text, expected):
assert common.contains_emoji(text) is expected
def test_get_page_image_metadata_and_coordinate_system():
doc = MockDocumentLayout()
metadata = common.get_page_image_metadata(doc.pages[0])
assert isinstance(metadata, dict)
def test_ocr_data_to_elements():
filename = example_doc_path("img/layout-parser-paper-fast.jpg")
text_regions = [
TextRegion.from_coords(
163.0,
115.0,
452.0,
129.0,
text="LayoutParser: A Unified Toolkit for Deep",
),
TextRegion.from_coords(
156.0,
132.0,
457.0,
147.0,
text="Learning Based Document Image Analysis",
),
]
ocr_data = [
LayoutElement(
bbox=r.bbox,
text=r.text,
source=r.source,
type=ElementType.UNCATEGORIZED_TEXT,
)
for r in text_regions
]
image = Image.open(filename)
elements = common.ocr_data_to_elements(
ocr_data=ocr_data,
image_size=image.size,
)
assert len(ocr_data) == len(elements)
assert {el.category for el in elements} == {ElementType.UNCATEGORIZED_TEXT}
# check coordinates metadata
image_width, image_height = image.size
coordinate_system = PixelSpace(width=image_width, height=image_height)
for el, layout_el in zip(elements, ocr_data):
assert el.metadata.coordinates == CoordinatesMetadata(
points=layout_el.bbox.coordinates,
system=coordinate_system,
)
def test_normalize_layout_element_layout_element_text_source_metadata():
layout_element = LayoutElement.from_coords(
type="NarrativeText",
x1=1,
y1=2,
x2=3,
y2=4,
text="Some lovely text",
is_extracted=IsExtracted.TRUE,
)
coordinate_system = PixelSpace(width=10, height=20)
element = common.normalize_layout_element(
layout_element,
coordinate_system=coordinate_system,
)
assert hasattr(element, "metadata")
assert hasattr(element.metadata, "is_extracted")
assert element.metadata.is_extracted == "true"
@@ -0,0 +1,321 @@
# pyright: reportPrivateUsage=false
"""Unit-test suite for the `unstructured.partition.lang` module."""
from __future__ import annotations
import os
import pathlib
import pytest
from test_unstructured.unit_utils import LogCaptureFixture
from unstructured.documents.elements import (
NarrativeText,
PageBreak,
)
from unstructured.partition.common.lang import (
_clean_ocr_languages_arg,
_convert_language_code_to_pytesseract_lang_code,
apply_lang_metadata,
check_language_args,
detect_languages,
prepare_languages_for_tesseract,
tesseract_to_paddle_language,
)
DIRECTORY = pathlib.Path(__file__).parent.resolve()
EXAMPLE_DOCS_DIRECTORY = os.path.join(DIRECTORY, "..", "..", "example-docs")
def test_prepare_languages_for_tesseract_with_one_language():
languages = ["en"]
assert prepare_languages_for_tesseract(languages) == "eng"
def test_prepare_languages_for_tesseract_with_duplicated_languages():
languages = ["en", "eng"]
assert prepare_languages_for_tesseract(languages) == "eng"
def test_prepare_languages_for_tesseract_special_case():
languages = ["osd"]
assert prepare_languages_for_tesseract(languages) == "osd"
languages = ["equ"]
assert prepare_languages_for_tesseract(languages) == "equ"
def test_prepare_languages_for_tesseract_removes_empty_inputs():
languages = ["kbd", "es"]
assert prepare_languages_for_tesseract(languages) == "spa+spa_old"
def test_prepare_languages_for_tesseract_includes_variants():
languages = ["chi"]
assert prepare_languages_for_tesseract(languages) == "chi_sim+chi_sim_vert+chi_tra+chi_tra_vert"
def test_prepare_languages_for_tesseract_with_multiple_languages():
languages = ["ja", "afr", "en", "equ"]
assert prepare_languages_for_tesseract(languages) == "jpn+jpn_vert+afr+eng+equ"
def test_prepare_languages_for_tesseract_warns_nonstandard_language(caplog: LogCaptureFixture):
languages = ["zzz", "chi"]
assert prepare_languages_for_tesseract(languages) == "chi_sim+chi_sim_vert+chi_tra+chi_tra_vert"
assert "not a valid standard language code" in caplog.text
def test_prepare_languages_for_tesseract_warns_non_tesseract_language(caplog: LogCaptureFixture):
languages = ["kbd", "eng"]
assert prepare_languages_for_tesseract(languages) == "eng"
assert "not a language supported by Tesseract" in caplog.text
def test_prepare_languages_for_tesseract_None_languages():
with pytest.raises(ValueError, match="`languages` can not be `None`"):
languages = None
prepare_languages_for_tesseract(languages)
def test_prepare_languages_for_tesseract_no_valid_languages(caplog: LogCaptureFixture):
languages = [""]
assert prepare_languages_for_tesseract(languages) == "eng"
assert "Failed to find any valid standard language code from languages" in caplog.text
@pytest.mark.parametrize(
("tesseract_lang", "expected_lang"),
[
("eng", "en"),
("chi_sim", "ch"),
("chi_tra", "chinese_cht"),
("deu", "german"),
("jpn", "japan"),
("kor", "korean"),
],
)
def test_tesseract_to_paddle_language_valid_codes(tesseract_lang: str, expected_lang: str):
assert expected_lang == tesseract_to_paddle_language(tesseract_lang)
def test_tesseract_to_paddle_language_invalid_codes(caplog: LogCaptureFixture):
tesseract_lang = "unsupported_lang"
assert tesseract_to_paddle_language(tesseract_lang) == "en"
assert "unsupported_lang is not a language code supported by PaddleOCR," in caplog.text
@pytest.mark.parametrize(
("tesseract_lang", "expected_lang"),
[
("ENG", "en"),
("Fra", "fr"),
("DEU", "german"),
],
)
def test_tesseract_to_paddle_language_case_sensitivity(tesseract_lang: str, expected_lang: str):
assert expected_lang == tesseract_to_paddle_language(tesseract_lang)
def test_detect_languages_english_auto():
text = "This is a short sentence."
assert detect_languages(text) == ["eng"]
def test_detect_languages_english_provided():
text = "This is another short sentence."
languages = ["en"]
assert detect_languages(text, languages) == ["eng"]
def test_detect_languages_korean_auto():
text = "안녕하세요"
assert detect_languages(text) == ["kor"]
def test_detect_languages_gets_multiple_languages():
text = "My lubimy mleko i chleb."
assert detect_languages(text) == ["ces", "pol", "slk"]
def test_detect_languages_warns_for_auto_and_other_input(caplog: LogCaptureFixture):
text = "This is another short sentence."
languages = ["en", "auto", "rus"]
assert detect_languages(text, languages) == ["eng"]
assert "rest of the inputted languages will be ignored" in caplog.text
def test_detect_languages_raises_TypeError_for_invalid_languages():
with pytest.raises(TypeError):
text = "This is a short sentence."
detect_languages(text, languages="eng") == ["eng"] # type: ignore
def test_apply_lang_metadata_has_no_warning_for_PageBreak(caplog: LogCaptureFixture):
elements = [NarrativeText("Sample text."), PageBreak("")]
elements = list(
apply_lang_metadata(
elements=elements,
languages=["auto"],
detect_language_per_element=True,
),
)
assert "No features in text." not in [rec.message for rec in caplog.records]
@pytest.mark.parametrize(
("lang_in", "expected_lang"),
[
("en", "eng"),
("fr", "fra"),
],
)
def test_convert_language_code_to_pytesseract_lang_code(lang_in: str, expected_lang: str):
assert expected_lang == _convert_language_code_to_pytesseract_lang_code(lang_in)
@pytest.mark.parametrize(
("input_ocr_langs", "expected"),
[
(["eng"], "eng"), # list
('"deu"', "deu"), # extra quotation marks
("[deu]", "deu"), # brackets
("['deu']", "deu"), # brackets and quotation marks
(["[deu]"], "deu"), # list, brackets and quotation marks
(['"deu"'], "deu"), # list and quotation marks
("deu+spa", "deu+spa"), # correct input
],
)
def test_clean_ocr_languages_arg(input_ocr_langs: str, expected: str):
assert _clean_ocr_languages_arg(input_ocr_langs) == expected
def test_detect_languages_handles_spelled_out_languages():
languages = detect_languages(text="Sample text longer than 5 words.", languages=["Spanish"])
assert languages == ["spa"]
def test_detect_languages_short_text_fallback_returns_none():
"""Short ASCII text with language_fallback returning None leaves language unspecified."""
result = detect_languages(
text="Hi there.",
language_fallback=lambda t: None,
)
assert result is None
def test_detect_languages_short_text_fallback_returns_custom():
"""Short ASCII text triggers fallback; we assert the fallback's return is used as-is."""
# Any short (<5 word) ASCII text would hit the fallback; content is irrelevant.
result = detect_languages(
text="Hi there.",
language_fallback=lambda t: ["fra"],
)
assert result == ["fra"]
def test_detect_languages_short_text_default_eng_without_fallback():
"""Short ASCII text without fallback still defaults to English (backward compat)."""
result = detect_languages(text="Hi there.")
assert result == ["eng"]
def test_apply_lang_metadata_with_language_fallback():
"""apply_lang_metadata passes language_fallback so short text can return None."""
elements = [NarrativeText("Hi.")]
result = list(
apply_lang_metadata(
elements=elements,
languages=["auto"],
language_fallback=lambda t: None,
)
)
assert len(result) == 1
assert result[0].metadata.languages is None
@pytest.mark.parametrize(
("languages", "ocr_languages", "expected_langs"),
[
(["spa"], "deu", ["spa"]),
(["spanish"], "english", ["spa"]),
(["spa"], "[deu]", ["spa"]),
(["spa"], '"deu"', ["spa"]),
(["spa"], ["deu"], ["spa"]),
(["spa"], ["[deu]"], ["spa"]),
(["spa+deu"], "eng+deu", ["spa", "deu"]),
],
)
def test_check_language_args_uses_languages_when_ocr_languages_and_languages_are_both_defined(
languages: list[str],
ocr_languages: list[str] | str,
expected_langs: list[str],
caplog: LogCaptureFixture,
):
returned_langs = check_language_args(
languages=languages,
ocr_languages=ocr_languages,
)
for lang in returned_langs: # type: ignore
assert lang in expected_langs
assert "ocr_languages" in caplog.text
@pytest.mark.parametrize(
("languages", "ocr_languages", "expected_langs"),
[
# raise warning and use `ocr_languages` when `languages` is empty or None
([], "deu", ["deu"]),
([""], '"deu"', ["deu"]),
([""], "deu", ["deu"]),
([""], "[deu]", ["deu"]),
],
)
def test_check_language_args_uses_ocr_languages_when_languages_is_empty_or_None(
languages: list[str],
ocr_languages: str,
expected_langs: list[str],
caplog: LogCaptureFixture,
):
returned_langs = check_language_args(languages=languages, ocr_languages=ocr_languages)
for lang in returned_langs: # type: ignore
assert lang in expected_langs
assert "ocr_languages" in caplog.text
@pytest.mark.parametrize(
("languages", "ocr_languages"),
[
([], None), # how check_language_args is called from auto.partition()
([""], None),
],
)
def test_check_language_args_returns_None(
languages: list[str],
ocr_languages: None,
):
returned_langs = check_language_args(languages=languages, ocr_languages=ocr_languages)
assert returned_langs is None
def test_check_language_args_returns_auto():
assert check_language_args(languages=["eng", "spa", "auto"], ocr_languages=None) == ["auto"]
@pytest.mark.parametrize(
("languages", "ocr_languages"),
[
([], ["auto"]),
([""], "eng+auto"),
],
)
def test_check_language_args_raises_error_when_ocr_languages_contains_auto(
languages: list[str],
ocr_languages: str | list[str],
):
with pytest.raises(ValueError):
check_language_args(
languages=languages,
ocr_languages=ocr_languages,
)
@@ -0,0 +1,540 @@
"""Test-suite for `unstructured.partition.common.metadata` module."""
# pyright: reportPrivateUsage=false
from __future__ import annotations
import copy
import datetime as dt
import os
import pathlib
from typing import Any, Callable
import pytest
from unstructured.documents.elements import (
CheckBox,
Element,
ElementMetadata,
FigureCaption,
Header,
ListItem,
NarrativeText,
Text,
Title,
)
from unstructured.file_utils.model import FileType
from unstructured.partition.common.metadata import (
_assign_hash_ids,
apply_metadata,
get_last_modified_date,
set_element_hierarchy,
)
# ================================================================================================
# LAST-MODIFIED
# ================================================================================================
class Describe_get_last_modified_date:
def it_gets_the_modified_time_of_a_file_identified_by_a_path(self, tmp_path: pathlib.Path):
modified_timestamp = dt.datetime(
year=2024, month=3, day=5, hour=17, minute=43, second=40
).timestamp()
file_path = tmp_path / "some_file.txt"
file_path.write_text("abcdefg")
os.utime(file_path, (modified_timestamp, modified_timestamp))
last_modified_date = get_last_modified_date(str(file_path))
assert last_modified_date == "2024-03-05T17:43:40"
def but_it_returns_None_when_there_is_no_file_at_that_path(self, tmp_path: pathlib.Path):
file_path = tmp_path / "some_file_that_does_not_exist.txt"
last_modified_date = get_last_modified_date(str(file_path))
assert last_modified_date is None
# ================================================================================================
# ELEMENT HIERARCHY
# ================================================================================================
class Describe_set_element_hierarchy:
def it_applies_default_ruleset(self):
elements = [
Title(element_id="0", text="Title0"),
Text(element_id="1", text="Text0"),
Header(element_id="2", text="Header0"),
Text(element_id="3", text="Text1"),
Title(element_id="4", text="Title1"),
Text(element_id="5", text="Text2"),
]
result = set_element_hierarchy(elements)
assert result[0].metadata.parent_id is None
assert result[1].metadata.parent_id == "0" # Text0 is under Title0
assert result[2].metadata.parent_id is None # Header0 is higher than Title0
assert result[3].metadata.parent_id == "2" # Text1 is under Header0
assert result[4].metadata.parent_id == "2" # Title1 is under Header0
assert result[5].metadata.parent_id == "4" # Text2 is under Title1, which is under Header0
def it_applies_category_depth_when_element_category_is_the_same(self):
elements = [
Title(element_id="0", text="Title0", metadata=ElementMetadata(category_depth=1)),
ListItem(element_id="1", text="ListItem0", metadata=ElementMetadata(category_depth=0)),
ListItem(element_id="2", text="ListItem1", metadata=ElementMetadata(category_depth=1)),
ListItem(element_id="3", text="ListItem2", metadata=ElementMetadata(category_depth=0)),
]
result = set_element_hierarchy(elements)
assert result[0].metadata.parent_id is None
assert result[1].metadata.parent_id == "0" # category_depth=0
assert result[2].metadata.parent_id == "1" # category_depth=1, so it is under ListItem0
assert result[3].metadata.parent_id == "0" # category_depth=0
def but_it_ignores_category_depth_when_elements_are_of_different_categories(self):
elements = [
Title(element_id="0", text="Title", metadata=ElementMetadata(category_depth=2)),
Text(element_id="1", text="Text", metadata=ElementMetadata(category_depth=0)),
Header(element_id="2", text="Header", metadata=ElementMetadata(category_depth=2)),
Text(element_id="3", text="Text", metadata=ElementMetadata(category_depth=0)),
ListItem(element_id="4", text="ListItem", metadata=ElementMetadata(category_depth=1)),
NarrativeText(element_id="5", text="", metadata=ElementMetadata(category_depth=0)),
]
result = set_element_hierarchy(elements)
assert result[0].metadata.parent_id is None
assert result[1].metadata.parent_id == "0" # Text is under Title despite category_depth=0
assert result[2].metadata.parent_id is None
assert result[3].metadata.parent_id == "2" # These are under Header despite category_depth
assert result[4].metadata.parent_id == "2"
assert result[5].metadata.parent_id == "2"
def it_skips_elements_with_pre_existing_parent_id(self):
elements = [
Title(element_id="0", text="Title", metadata=ElementMetadata(parent_id="10")),
Title(element_id="1", text="Title"),
Text(element_id="2", text="Text"),
]
result = set_element_hierarchy(elements)
# Parent ID should not change and element is skipped in figuring out other elements' parents
assert result[0].metadata.parent_id == "10"
assert result[1].metadata.parent_id is None
assert result[2].metadata.parent_id == "1"
def it_sets_parent_id_for_each_element_in_elements(self):
elements_to_set = [
Title(text="Title"), # 0
NarrativeText(text="NarrativeText"), # 1
FigureCaption(text="FigureCaption"), # 2
ListItem(text="ListItem"), # 3
ListItem(text="ListItem", metadata=ElementMetadata(category_depth=1)), # 4
ListItem(text="ListItem", metadata=ElementMetadata(category_depth=1)), # 5
ListItem(text="ListItem"), # 6
CheckBox(element_id="some-id-1", checked=True), # 7
Title(text="Title 2"), # 8
ListItem(text="ListItem"), # 9
ListItem(text="ListItem"), # 10
Text(text="Text"), # 11
]
elements = set_element_hierarchy(elements_to_set)
assert elements[1].metadata.parent_id == elements[0].id, (
"NarrativeText should be child of Title"
)
assert elements[2].metadata.parent_id == elements[0].id, (
"FigureCaption should be child of Title"
)
assert elements[3].metadata.parent_id == elements[0].id, "ListItem should be child of Title"
assert elements[4].metadata.parent_id == elements[3].id, "ListItem should be child of Title"
assert elements[5].metadata.parent_id == elements[3].id, "ListItem should be child of Title"
assert elements[6].metadata.parent_id == elements[0].id, "ListItem should be child of Title"
# NOTE(Hubert): moving the category field to Element, caused this to fail.
# Checkboxes will soon be deprecated, then we can remove the test.
# assert (
# elements[7].metadata.parent_id is None
# ), "CheckBox should be None, as it's not a Text based element"
assert elements[8].metadata.parent_id is None, "Title 2 should be child of None"
assert elements[9].metadata.parent_id == elements[8].id, (
"ListItem should be child of Title 2"
)
assert elements[10].metadata.parent_id == elements[8].id, (
"ListItem should be child of Title 2"
)
assert elements[11].metadata.parent_id == elements[8].id, "Text should be child of Title 2"
def it_applies_custom_rule_set(self):
elements_to_set = [
Header(text="Header"), # 0
Title(text="Title"), # 1
NarrativeText(text="NarrativeText"), # 2
Text(text="Text"), # 3
Title(text="Title 2"), # 4
FigureCaption(text="FigureCaption"), # 5
]
custom_rule_set = {
"Header": ["Title", "Text"],
"Title": ["NarrativeText", "UncategorizedText", "FigureCaption"],
}
elements = set_element_hierarchy(
elements=elements_to_set,
ruleset=custom_rule_set,
)
assert elements[1].metadata.parent_id == elements[0].id, "Title should be child of Header"
assert elements[2].metadata.parent_id == elements[1].id, (
"NarrativeText should be child of Title"
)
assert elements[3].metadata.parent_id == elements[1].id, "Text should be child of Title"
assert elements[4].metadata.parent_id == elements[0].id, "Title 2 should be child of Header"
assert elements[5].metadata.parent_id == elements[4].id, (
"FigureCaption should be child of Title 2"
)
# ================================================================================================
# APPLY METADATA DECORATOR
# ================================================================================================
class Describe_apply_metadata:
"""Unit-test suite for `unstructured.partition.common.metadata.apply_metadata()` decorator."""
# -- unique-ify elements and metadata ---------------------------------
def it_produces_unique_elements_and_metadata_when_input_reuses_element_instances(self):
element = Text(text="Element", metadata=ElementMetadata(filename="foo.bar", page_number=1))
def fake_partitioner(**kwargs: Any) -> list[Element]:
return [element, element, element]
partition = apply_metadata()(fake_partitioner)
elements = partition()
# -- all elements are unique instances --
assert len({id(e) for e in elements}) == len(elements)
# -- all metadatas are unique instances --
assert len({id(e.metadata) for e in elements}) == len(elements)
def and_it_produces_unique_elements_and_metadata_when_input_reuses_metadata_instances(self):
metadata = ElementMetadata(filename="foo.bar", page_number=1)
def fake_partitioner(**kwargs: Any) -> list[Element]:
return [
Text(text="foo", metadata=metadata),
Text(text="bar", metadata=metadata),
Text(text="baz", metadata=metadata),
]
partition = apply_metadata()(fake_partitioner)
elements = partition()
# -- all elements are unique instances --
assert len({id(e) for e in elements}) == len(elements)
# -- all metadatas are unique instances --
assert len({id(e.metadata) for e in elements}) == len(elements)
# -- unique-ids -------------------------------------------------------
def it_assigns_hash_element_ids_when_unique_ids_arg_is_not_specified(
self, fake_partitioner: Callable[..., list[Element]]
):
partition = apply_metadata()(fake_partitioner)
elements = partition()
elements_2 = partition()
# -- SHA1 hash is 32 characters long, no hyphens --
assert all(len(e.id) == 32 for e in elements)
assert all("-" not in e.id for e in elements)
# -- SHA1 hashes are deterministic --
assert all(e.id == e2.id for e, e2 in zip(elements, elements_2))
def it_assigns_hash_element_ids_when_unique_ids_arg_is_False(
self, fake_partitioner: Callable[..., list[Element]]
):
partition = apply_metadata()(fake_partitioner)
elements = partition(unique_element_ids=False)
elements_2 = partition(unique_element_ids=False)
# -- SHA1 hash is 32 characters long, no hyphens --
assert all(len(e.id) == 32 for e in elements)
assert all("-" not in e.id for e in elements)
# -- SHA1 hashes are deterministic --
assert all(e.id == e2.id for e, e2 in zip(elements, elements_2))
def it_leaves_UUID_element_ids_when_unique_ids_arg_is_True(
self, fake_partitioner: Callable[..., list[Element]]
):
partition = apply_metadata()(fake_partitioner)
elements = partition(unique_element_ids=True)
elements_2 = partition(unique_element_ids=True)
# -- UUID is 36 characters long with four hyphens --
assert all(len(e.id) == 36 for e in elements)
assert all(e.id.count("-") == 4 for e in elements)
# -- UUIDs are non-deterministic, different every time --
assert all(e.id != e2.id for e, e2 in zip(elements, elements_2))
# -- parent-id --------------------------------------------------------
def it_computes_and_assigns_parent_id(self, fake_partitioner: Callable[..., list[Element]]):
partition = apply_metadata()(fake_partitioner)
elements = partition()
title = elements[0]
assert title.metadata.category_depth == 1
narr_text = elements[1]
assert narr_text.metadata.parent_id == title.id
# -- languages --------------------------------------------------------
def it_applies_language_metadata(self, fake_partitioner: Callable[..., list[Element]]):
partition = apply_metadata()(fake_partitioner)
elements = partition(languages=["auto"], detect_language_per_element=True)
assert all(e.metadata.languages == ["eng"] for e in elements)
# -- filetype (MIME-type) ---------------------------------------------
def it_assigns_the_value_of_a_metadata_file_type_arg_when_there_is_one(
self, fake_partitioner: Callable[..., list[Element]]
):
"""A `metadata_file_type` arg overrides the file-type specified in the decorator.
This is used for example by a delegating partitioner to preserve the original file-type in
the metadata, like EPUB instead of the HTML that partitioner converts the .epub file to.
"""
partition = apply_metadata(file_type=FileType.DOCX)(fake_partitioner)
elements = partition(metadata_file_type=FileType.ODT)
assert all(
e.metadata.filetype == "application/vnd.oasis.opendocument.text" for e in elements
)
def and_it_assigns_the_decorator_file_type_when_the_metadata_file_type_arg_is_omitted(
self, fake_partitioner: Callable[..., list[Element]]
):
"""The `file_type=...` decorator arg is the "normal" way to specify the file-type.
This is used for principal (non-delegating) partitioners.
"""
partition = apply_metadata(file_type=FileType.DOCX)(fake_partitioner)
elements = partition()
DOCX_MIME_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
assert all(e.metadata.filetype == DOCX_MIME_TYPE for e in elements)
def and_it_does_not_assign_file_type_metadata_when_both_are_omitted(
self, fake_partitioner: Callable[..., list[Element]]
):
"""A partitioner can elect to assign `.metadata.filetype` for itself.
This is done in `partition_image()` for example where the same partitioner is used for
multiple file-types.
"""
partition = apply_metadata()(fake_partitioner)
elements = partition()
assert all(e.metadata.filetype == "image/jpeg" for e in elements)
# -- filename ---------------------------------------------------------
def it_uses_metadata_filename_arg_value_when_present(
self, fake_partitioner: Callable[..., list[Element]]
):
"""A `metadata_filename` arg overrides all other sources."""
partition = apply_metadata()(fake_partitioner)
elements = partition(metadata_filename="a/b/c.xyz")
assert all(e.metadata.filename == "c.xyz" for e in elements)
assert all(e.metadata.file_directory == "a/b" for e in elements)
def and_it_uses_filename_arg_value_when_metadata_filename_arg_not_present(
self, fake_partitioner: Callable[..., list[Element]]
):
partition = apply_metadata()(fake_partitioner)
elements = partition(filename="a/b/c.xyz")
assert all(e.metadata.filename == "c.xyz" for e in elements)
assert all(e.metadata.file_directory == "a/b" for e in elements)
def and_it_does_not_assign_filename_metadata_when_neither_are_present(
self, fake_partitioner: Callable[..., list[Element]]
):
partition = apply_metadata()(fake_partitioner)
elements = partition()
assert all(e.metadata.filename == "image.jpeg" for e in elements)
assert all(e.metadata.file_directory == "x/y/images" for e in elements)
# -- last_modified ----------------------------------------------------
def it_uses_metadata_last_modified_arg_value_when_present(
self, fake_partitioner: Callable[..., list[Element]]
):
"""A `metadata_last_modified` arg overrides all other sources."""
partition = apply_metadata()(fake_partitioner)
metadata_last_modified = "2024-09-26T15:17:53"
elements = partition(metadata_last_modified=metadata_last_modified)
assert all(e.metadata.last_modified == metadata_last_modified for e in elements)
@pytest.mark.parametrize("kwargs", [{}, {"metadata_last_modified": None}])
def but_it_does_not_update_last_modified_when_metadata_last_modified_arg_absent_or_None(
self, kwargs: dict[str, Any], fake_partitioner: Callable[..., list[Element]]
):
partition = apply_metadata()(fake_partitioner)
elements = partition(**kwargs)
assert all(e.metadata.last_modified == "2020-01-06T05:07:03" for e in elements)
# -- url --------------------------------------------------------------
def it_assigns_url_metadata_field_when_url_arg_is_present(
self, fake_partitioner: Callable[..., list[Element]]
):
partition = apply_metadata()(fake_partitioner)
elements = partition(url="https://adobe.com/stock/54321")
assert all(e.metadata.url == "https://adobe.com/stock/54321" for e in elements)
def and_it_does_not_assign_url_metadata_when_url_arg_is_not_present(
self, fake_partitioner: Callable[..., list[Element]]
):
partition = apply_metadata()(fake_partitioner)
elements = partition()
assert all(e.metadata.url == "http://images.com" for e in elements)
# -- fixtures --------------------------------------------------------------------------------
@pytest.fixture
def fake_partitioner(self) -> Callable[..., list[Element]]:
def fake_partitioner(**kwargs: Any) -> list[Element]:
title = Title("Introduction")
title.metadata.category_depth = 1
title.metadata.file_directory = "x/y/images"
title.metadata.filename = "image.jpeg"
title.metadata.filetype = "image/jpeg"
title.metadata.last_modified = "2020-01-06T05:07:03"
title.metadata.url = "http://images.com"
narr_text = NarrativeText("To understand bar you must first understand foo.")
narr_text.metadata.file_directory = "x/y/images"
narr_text.metadata.filename = "image.jpeg"
narr_text.metadata.filetype = "image/jpeg"
narr_text.metadata.last_modified = "2020-01-06T05:07:03"
narr_text.metadata.url = "http://images.com"
return [title, narr_text]
return fake_partitioner
# ================================================================================================
# HASH IDS
# ================================================================================================
def test_assign_hash_ids_produces_unique_and_deterministic_SHA1_ids_even_for_duplicate_elements():
elements: list[Element] = [
Text(text="Element", metadata=ElementMetadata(filename="foo.bar", page_number=1)),
Text(text="Element", metadata=ElementMetadata(filename="foo.bar", page_number=1)),
Text(text="Element", metadata=ElementMetadata(filename="foo.bar", page_number=1)),
]
# -- default ids are UUIDs --
assert all(len(e.id) == 36 for e in elements)
elements = _assign_hash_ids(copy.deepcopy(elements))
elements_2 = _assign_hash_ids(copy.deepcopy(elements))
ids = [e.id for e in elements]
# -- ids are now SHA1 --
assert all(len(e.id) == 32 for e in elements)
# -- each id is unique --
assert len(ids) == len(set(ids))
# -- ids are deterministic, same value is computed each time --
assert all(e.id == e2.id for e, e2 in zip(elements, elements_2))
def test_assign_hash_ids_remaps_parent_id_to_new_hash_id():
"""parent_id values (originally UUIDs) are updated to the corresponding hash IDs."""
title = Title(text="Title", metadata=ElementMetadata(filename="foo.bar", page_number=1))
child = Text(
text="Child",
metadata=ElementMetadata(filename="foo.bar", page_number=1, parent_id=title.id),
)
# -- sanity-check: ids are UUIDs before hashing --
assert len(title.id) == 36
assert child.metadata.parent_id == title.id
_assign_hash_ids([title, child])
# -- ids are now SHA1 hashes --
assert len(title.id) == 32
# -- parent_id has been updated to the new hash id, not the old UUID --
assert child.metadata.parent_id == title.id
def test_assign_hash_ids_leaves_unknown_parent_id_unchanged():
"""A parent_id that has no matching element (e.g. filtered out) is left as-is, not KeyError."""
external_parent_id = "some-external-or-filtered-id"
orphan = Text(
text="Orphan",
metadata=ElementMetadata(filename="foo.bar", page_number=1, parent_id=external_parent_id),
)
# -- should not raise KeyError even though external_parent_id is not in id_mapping --
_assign_hash_ids([orphan])
# -- parent_id is left unchanged because it wasn't in the mapping --
assert orphan.metadata.parent_id == external_parent_id
def test_partition_html_parent_child_relationships_preserved_with_hash_ids():
"""Integration: partition_html with unique_element_ids=False preserves parent-child links."""
from unstructured.partition.html import partition_html
html = "<html><body><h1>My Title</h1><p>My paragraph</p></body></html>"
elements = partition_html(text=html, unique_element_ids=False)
# -- all element ids should be SHA1 hashes (32 hex chars), not UUIDs (36 chars) --
assert all(len(e.id) == 32 for e in elements), "Expected SHA1 hash IDs"
# -- find the title and its child paragraph --
title = next((e for e in elements if isinstance(e, Title)), None)
child = next(
(e for e in elements if e.metadata.parent_id is not None),
None,
)
assert title is not None, "Expected a Title element"
assert child is not None, "Expected at least one element with a parent_id"
# -- parent_id must point to the hashed title id, not an old UUID --
assert child.metadata.parent_id == title.id
+18
View File
@@ -0,0 +1,18 @@
import pytest
from unstructured.partition.utils.constants import OCR_AGENT_PADDLE, OCR_AGENT_TESSERACT
@pytest.fixture
def mock_ocr_get_instance(mocker):
"""Fixture that mocks OCRAgent.get_instance to prevent real OCR agent instantiation."""
def mock_get_instance(ocr_agent_module, language):
if ocr_agent_module in (OCR_AGENT_TESSERACT, OCR_AGENT_PADDLE):
return mocker.MagicMock()
else:
raise ValueError(f"Unknown OCR agent: {ocr_agent_module}")
from unstructured.partition.pdf_image.ocr import OCRAgent
return mocker.patch.object(OCRAgent, "get_instance", side_effect=mock_get_instance)
@@ -0,0 +1,511 @@
# pyright: reportPrivateUsage=false, reportUnknownMemberType=false, reportOptionalMemberAccess=false
# pyright: reportAttributeAccessIssue=false, reportUnknownLambdaType=false
from collections import defaultdict
from typing import Any, Optional
import pytest
from bs4 import BeautifulSoup, Tag
from pytest_mock import MockerFixture
from unstructured.documents.elements import Element, ElementMetadata, ElementType
from unstructured.partition.html.convert import (
HTML_PARSER,
ElementHtml,
ImageElementHtml,
LinkElementHtml,
ListItemElementHtml,
TableElementHtml,
TextElementHtml,
TitleElementHtml,
UnorderedListElementHtml,
_elements_to_html_tags,
_elements_to_html_tags_by_page,
_elements_to_html_tags_by_parent,
_group_element_children,
elements_to_html,
group_elements_by_page,
)
class MockElement(Element):
def __init__(
self,
text: str = "",
metadata: Optional[ElementMetadata] = None,
category: str = "",
id: str = "",
) -> None:
self.text = text
self.metadata = metadata or ElementMetadata()
self.category = category
self._element_id = id
class MockElementMetadata(ElementMetadata):
def __init__(
self,
text_as_html: Optional[str] = None,
category_depth: Optional[int] = None,
image_base64: Optional[str] = None,
image_mime_type: Optional[str] = None,
url: Optional[str] = None,
parent_id: Optional[str] = None,
page_number: Optional[int] = None,
) -> None:
self.text_as_html = text_as_html
self.category_depth = category_depth
self.image_base64 = image_base64
self.image_mime_type = image_mime_type
self.url = url
self.parent_id = parent_id
self.page_number = page_number
@pytest.fixture
def mock_element() -> MockElement:
metadata = MockElementMetadata(text_as_html="<p>Test Text</p>")
return MockElement(text="Test Text", metadata=metadata, category="test-category", id="test-id")
@pytest.fixture
def element_html(mock_element: MockElement) -> ElementHtml:
return ElementHtml(mock_element)
@pytest.fixture
def title_element_html(mock_element: MockElement) -> TitleElementHtml:
metadata = MockElementMetadata(text_as_html="<p>Test HTML</p>")
MockElement(text="Test Text", metadata=metadata, category="test-category", id="test-id")
return TitleElementHtml(mock_element)
@pytest.fixture
def image_element_html(mock_element: MockElement) -> ImageElementHtml:
return ImageElementHtml(mock_element)
@pytest.fixture
def table_element_html(mock_element: MockElement) -> TableElementHtml:
return TableElementHtml(mock_element)
@pytest.fixture
def link_element_html(mock_element: MockElement) -> LinkElementHtml:
return LinkElementHtml(mock_element)
@pytest.fixture
def unordered_list_element_html(mock_element: MockElement) -> UnorderedListElementHtml:
return UnorderedListElementHtml(mock_element)
@pytest.fixture
def elements_html() -> list[ElementHtml]:
return [
ListItemElementHtml(
MockElement(
text="Test List Item",
metadata=MockElementMetadata(page_number=1),
category=ElementType.LIST_ITEM,
id="test-element-1",
)
),
TextElementHtml(
MockElement(
text="Test Text",
metadata=MockElementMetadata(page_number=None),
category=ElementType.TEXT,
id="test-element-2",
)
),
TextElementHtml(
MockElement(
text="Test Text",
metadata=MockElementMetadata(page_number=2),
category=ElementType.TEXT,
id="test-element-3",
)
),
ListItemElementHtml(
MockElement(
text="Test List Item",
metadata=MockElementMetadata(parent_id="test-element-3", page_number=2),
category=ElementType.LIST_ITEM,
id="test-element-4",
)
),
ListItemElementHtml(
MockElement(
text="Test List Item",
metadata=MockElementMetadata(parent_id="test-element-3", page_number=2),
category=ElementType.LIST_ITEM,
id="test-element-5",
)
),
TextElementHtml(
MockElement(
text="Test Text",
metadata=MockElementMetadata(page_number=3),
category=ElementType.TEXT,
id="test-element-6",
)
),
ListItemElementHtml(
MockElement(
text="Test List Item Other",
metadata=MockElementMetadata(parent_id="test-element-6", page_number=3),
category=ElementType.LIST_ITEM_OTHER,
id="test-element-7",
)
),
ListItemElementHtml(
MockElement(
text="Test List Item",
metadata=MockElementMetadata(parent_id="test-element-7", page_number=3),
category=ElementType.LIST_ITEM,
id="test-element-8",
)
),
ListItemElementHtml(
MockElement(
text="Test List Item Other",
metadata=MockElementMetadata(parent_id="test-element-6", page_number=3),
category=ElementType.LIST_ITEM_OTHER,
id="test-element-9",
)
),
TextElementHtml(
MockElement(
text="Test Text",
metadata=MockElementMetadata(parent_id="test-element-6", page_number=3),
category=ElementType.TEXT,
id="test-element-10",
)
),
]
@pytest.fixture
def elements(elements_html: list[ElementHtml]) -> list[Element]:
return [el.element for el in elements_html]
@pytest.fixture
def elements_small() -> list[Element]:
return [
MockElement(
text="Test Text 1",
category=ElementType.TEXT,
id="test-element-1",
metadata=MockElementMetadata(page_number=1),
),
MockElement(
text="Test Text 2",
category=ElementType.TEXT,
id="test-element-2",
metadata=MockElementMetadata(page_number=2),
),
]
def test_inject_html_element_content(element_html: ElementHtml) -> None:
soup = BeautifulSoup("", HTML_PARSER)
tag = soup.new_tag("div")
element_html._inject_html_element_content(tag)
assert tag.string == "Test Text"
def test_get_text_as_html(element_html: ElementHtml) -> None:
tag = element_html.get_text_as_html()
assert isinstance(tag, Tag)
assert tag.name == "p"
assert tag.string == "Test Text"
def test_get_children_html(element_html: ElementHtml) -> None:
soup = BeautifulSoup("", HTML_PARSER)
parent_tag = soup.new_tag("div")
child_element = MockElement(text="Child Text")
child_element_html = ElementHtml(child_element)
element_html.set_children([child_element_html])
result_tag = element_html._get_children_html(soup, parent_tag)
assert result_tag.name == "div"
assert len(result_tag.contents) == 2
assert result_tag.contents[1].string == "Child Text"
def test_get_html_element_text_as_html(element_html: ElementHtml) -> None:
tag = element_html.get_html_element()
assert isinstance(tag, Tag)
assert tag.name == "p"
assert tag.string == "Test Text"
assert tag["class"] == "test-category"
assert tag["id"] == "test-id"
def test_get_html_element_no_text_as_html(element_html: ElementHtml) -> None:
element_html.element.metadata.text_as_html = None
tag = element_html.get_html_element()
assert isinstance(tag, Tag)
assert tag.name == "div"
assert tag.string == "Test Text"
assert tag["class"] == "test-category"
assert tag["id"] == "test-id"
def test_set_children(element_html: ElementHtml) -> None:
child_element = MockElement(text="Child Text")
child_element_html = ElementHtml(child_element)
element_html.set_children([child_element_html])
assert len(element_html.children) == 1
assert element_html.children[0].element.text == "Child Text"
def test_title_element_html_tag(title_element_html: TitleElementHtml) -> None:
assert title_element_html.html_tag == "h1"
def test_image_element_html_content(image_element_html: ImageElementHtml) -> None:
soup = BeautifulSoup("", HTML_PARSER)
tag = soup.new_tag("img")
image_element_html._inject_html_element_content(tag)
assert tag["alt"] == "Test Text"
def test_image_element_html_content_with_base64(image_element_html: ImageElementHtml) -> None:
image_element_html.element.metadata.image_base64 = "base64data"
image_element_html.element.metadata.image_mime_type = "image/png"
soup = BeautifulSoup("", HTML_PARSER)
tag = soup.new_tag("img")
image_element_html._inject_html_element_content(tag)
assert tag["src"] == "data:image/png;base64,base64data"
assert tag["alt"] == "Test Text"
def test_table_element_html_attrs(table_element_html: TableElementHtml) -> None:
soup = BeautifulSoup("", HTML_PARSER)
tag = soup.new_tag("table")
table_element_html._inject_html_element_attrs(tag)
assert tag["style"] == "border: 1px solid black; border-collapse: collapse;"
def test_table_element_html_wraps_raw_text_in_a_cell(
table_element_html: TableElementHtml,
) -> None:
# -- raw table text must sit inside a `<td>` so HTML5 parsers (e.g. the nh3
# -- sanitization pass) don't foster-parent it out of the empty table --
table_element_html.element.metadata.text_as_html = None
table_element_html.element.text = "cell text"
tag = table_element_html.get_html_element()
cell = tag.find("td")
assert cell is not None
assert cell.get_text() == "cell text"
def test_table_element_html_leaves_empty_table_without_a_cell(
table_element_html: TableElementHtml,
) -> None:
table_element_html.element.metadata.text_as_html = None
table_element_html.element.text = ""
tag = table_element_html.get_html_element()
assert tag.find("td") is None
def test_link_element_html_attrs(link_element_html: LinkElementHtml) -> None:
link_element_html.element.metadata.url = "http://example.com"
soup = BeautifulSoup("", HTML_PARSER)
tag = soup.new_tag("a")
link_element_html._inject_html_element_attrs(tag)
assert tag["href"] == "http://example.com"
def test_unordered_list_element_html(unordered_list_element_html: UnorderedListElementHtml) -> None:
soup = BeautifulSoup("", HTML_PARSER)
tag = soup.new_tag("ul")
child_element = MockElement(text="Child Text")
child_element_html = ListItemElementHtml(child_element)
unordered_list_element_html.set_children([child_element_html])
result_tag = unordered_list_element_html._get_children_html(soup, tag)
assert result_tag.name == "ul"
assert len(result_tag.contents) == 1
assert result_tag.contents[0].name == "li"
assert result_tag.contents[0].string == "Child Text"
def test_group_element_children(elements_html: list[ElementHtml]) -> None:
grouped_children = _group_element_children(elements_html)
assert len(grouped_children) == 7
assert len(grouped_children[0].children) == 1
assert grouped_children[0].children[0].element.category == ElementType.LIST_ITEM
assert len(grouped_children[3].children) == 2
assert grouped_children[3].children[0].element.category == ElementType.LIST_ITEM
assert grouped_children[3].children[1].element.category == ElementType.LIST_ITEM
assert len(grouped_children[5].children) == 3
assert grouped_children[5].children[0].element.category == ElementType.LIST_ITEM_OTHER
assert grouped_children[5].children[1].element.category == ElementType.LIST_ITEM
assert grouped_children[5].children[2].element.category == ElementType.LIST_ITEM_OTHER
def test_elements_to_html_tags_by_parent(
mocker: MockerFixture, elements_html: list[ElementHtml]
) -> None:
mocker.patch(
"unstructured.partition.html.convert._group_element_children",
side_effect=lambda children: children,
)
result = _elements_to_html_tags_by_parent(elements_html)
assert len(result) == 4
assert result[0].element.id == "test-element-1"
assert len(result[0].children) == 0
assert result[1].element.id == "test-element-2"
assert len(result[1].children) == 0
assert result[2].element.id == "test-element-3"
assert len(result[2].children) == 2
assert result[2].children[0].element.id == "test-element-4"
assert result[2].children[1].element.id == "test-element-5"
assert result[3].element.id == "test-element-6"
assert len(result[3].children) == 3
assert result[3].children[0].element.id == "test-element-7"
assert len(result[3].children[0].children) == 1
assert result[3].children[0].children[0].element.id == "test-element-8"
assert result[3].children[1].element.id == "test-element-9"
assert result[3].children[2].element.id == "test-element-10"
def test_elements_to_html_tags(mocker: MockerFixture, elements: list[Element]) -> None:
def _mock_get_html_element(self: ElementHtml, **kwargs: Any):
return BeautifulSoup(f"<div>{self.element.id}</div>", HTML_PARSER).find()
mocker.patch(
"unstructured.partition.html.convert._elements_to_html_tags_by_parent",
side_effect=lambda elements: elements,
)
mocker.patch(
"unstructured.partition.html.convert.ElementHtml.get_html_element",
side_effect=_mock_get_html_element,
autospec=True,
)
result = _elements_to_html_tags(elements)
assert len(result) == 10
assert all(isinstance(tag, Tag) for tag in result)
for i, el in enumerate(result, start=1):
assert el.string == f"test-element-{i}"
def test_elements_to_html_tags_by_page(mocker: MockerFixture, elements: list[Element]) -> None:
def _mock_elements_to_html_tags(elements: list[Element], _: bool):
return [
BeautifulSoup(f"<div>{element.id}</div>", HTML_PARSER).find() for element in elements
]
def _mock_group_elements_by_page(elements: list[Element]) -> list[list[Element]]:
pages_dict: defaultdict[int, list[Element]] = defaultdict(list)
for element in elements:
if element.metadata.page_number is not None:
pages_dict[element.metadata.page_number].append(element)
return list(pages_dict.values())
mocker.patch(
"unstructured.partition.html.convert._elements_to_html_tags",
side_effect=_mock_elements_to_html_tags,
)
mocker.patch(
"unstructured.partition.html.convert.group_elements_by_page",
side_effect=_mock_group_elements_by_page,
)
result = _elements_to_html_tags_by_page(elements)
assert len(result) == 3
assert all(isinstance(tag, Tag) for tag in result)
assert result[0].name == "div"
assert result[0]["data-page_number"] == 1
assert len(result[0].contents) == 1
assert result[0].contents[0].string == "test-element-1"
assert result[1].name == "div"
assert result[1]["data-page_number"] == 2
assert len(result[1].contents) == 3
for i, el in enumerate(result[1].contents, start=3):
assert el.string == f"test-element-{i}"
assert result[2]["data-page_number"] == 3
assert len(result[2].contents) == 5
for i, el in enumerate(result[2].contents, start=6):
assert el.string == f"test-element-{i}"
def test_group_elements_by_page(caplog: pytest.LogCaptureFixture, elements: list[Element]) -> None:
result = group_elements_by_page(elements)
assert len(result) == 3
assert len(result[0]) == 1
assert len(result[1]) == 3
assert len(result[2]) == 5
assert result[0][0].id == "test-element-1"
for i, el in enumerate(result[1], start=3):
assert el.id == f"test-element-{i}"
for i, el in enumerate(result[2], start=6):
assert el.id == f"test-element-{i}"
assert "Page number is not set for an element test-element-2. Skipping." in caplog.text
def test_elements_to_html_no_group_by_page(
mocker: MockerFixture, elements_small: list[Element]
) -> None:
def _mock_elements_to_html_tags(elements: list[Element], _: bool):
return [
BeautifulSoup(f"<div>{element.id}</div>", HTML_PARSER).find() for element in elements
]
mock_elements_to_html_tags = mocker.patch(
"unstructured.partition.html.convert._elements_to_html_tags",
side_effect=_mock_elements_to_html_tags,
)
mock_elements_to_html_tags_by_page = mocker.patch(
"unstructured.partition.html.convert._elements_to_html_tags_by_page",
)
result = elements_to_html(elements_small, exclude_binary_image_data=True, no_group_by_page=True)
assert "<div>\n test-element-1\n </div>" in result
assert "<div>\n test-element-2\n </div>" in result
assert "data-page_number" not in result
mock_elements_to_html_tags.assert_called_once_with(elements_small, True)
mock_elements_to_html_tags_by_page.assert_not_called()
def test_elements_to_html_group_by_page(
mocker: MockerFixture, elements_small: list[Element]
) -> None:
def _mock_elements_to_html_tags_by_page(elements: list[Element], _: bool):
return [
BeautifulSoup(
f"<div data-page_number='{element.metadata.page_number}'>{element.id}</div>",
HTML_PARSER,
).find()
for element in elements
]
mock_elements_to_html_tags_by_page = mocker.patch(
"unstructured.partition.html.convert._elements_to_html_tags_by_page",
side_effect=_mock_elements_to_html_tags_by_page,
)
mock_elements_to_html_tags = mocker.patch(
"unstructured.partition.html.convert._elements_to_html_tags"
)
result = elements_to_html(
elements_small, exclude_binary_image_data=True, no_group_by_page=False
)
soup = BeautifulSoup(result, HTML_PARSER)
assert soup.find("div", {"data-page_number": "1"}).string.strip() == "test-element-1"
assert soup.find("div", {"data-page_number": "2"}).string.strip() == "test-element-2"
mock_elements_to_html_tags_by_page.assert_called_once_with(elements_small, True)
mock_elements_to_html_tags.assert_not_called()
def test_elements_to_html_invalid_html_template(
mocker: MockerFixture, elements: list[Element]
) -> None:
mocker.patch(
"unstructured.partition.html.convert.HTML_TEMPLATE",
"<html><head><title>Test</title></head></html>",
)
with pytest.raises(ValueError, match="Body tag not found in the HTML template"):
elements_to_html(elements)
@@ -0,0 +1,697 @@
from typing import Optional, Type
import pytest
from bs4 import BeautifulSoup
from unstructured.documents.ontology import (
Checkbox,
Form,
FormFieldValue,
Image,
OntologyElement,
Page,
RadioButton,
)
from unstructured.partition.html.html_utils import indent_html
from unstructured.partition.html.transformations import RECURSION_LIMIT, parse_html_to_ontology
def _wrap_with_body(html: str) -> str:
return f'<body class="Document">{html}</body>'
def remove_all_ids(html_str):
soup = BeautifulSoup(html_str, "html.parser")
for tag in soup.find_all(True):
if tag.has_attr("id"):
del tag["id"]
return str(soup)
def test_parsing_header_and_footer_into_correct_ontologyelement():
input_html = """
<div class="Page">
<header class="Header">
this is a header
</header>
<footer class="Footer">
this is a footer
</footer>
</div>
"""
page = parse_html_to_ontology(input_html)
assert len(page.children) == 2
header, footer = page.children
assert header.text == "this is a header"
assert header.html_tag_name == "header"
assert footer.text == "this is a footer"
assert footer.html_tag_name == "footer"
def test_wrong_html_parser_causes_paragraph_to_be_nested_in_div():
# This test would fail if html5lib parser would be applied on the input HTML.
# It would result in Page: <p></p> <address></address>
# instead of Page: <p><address></address></p>
# language=HTML
input_html = """
<div class="Page">
<p class="NarrativeText">
<address class="Address">
Mountain View, California
</address>
</p>
</div>
"""
page = parse_html_to_ontology(input_html)
assert len(page.children) == 1
narrative_text = page.children[0]
assert len(narrative_text.children) == 1
address = narrative_text.children[0]
assert address.text == "Mountain View, California"
def test_when_class_is_missing_it_can_be_inferred_from_type():
# language=HTML
base_html = _wrap_with_body("""
<div class="Page">
<aside>Some text</aside>
</div>
""")
base_html = indent_html(base_html)
# language=HTML
expected_html = _wrap_with_body("""
<div class="Page">
<aside class='Sidebar'><p class='Paragraph'>Some text</p></aside>
</div>
""")
expected_html = indent_html(expected_html)
ontology: OntologyElement = parse_html_to_ontology(base_html)
parsed_ontology = indent_html(remove_all_ids(ontology.to_html()))
assert parsed_ontology == expected_html
def test_when_class_is_wrong_tag_name_is_overwritten():
# language=HTML
base_html = _wrap_with_body("""
<div class="Page">
<p class='Sidebar'>Some text</p>
</div>
""")
base_html = indent_html(base_html)
# language=HTML
expected_html = _wrap_with_body("""
<div class="Page">
<aside class='Sidebar'><p class='Paragraph'>Some text</p></aside>
</div>
""")
expected_html = indent_html(expected_html)
ontology: OntologyElement = parse_html_to_ontology(base_html)
parsed_ontology = indent_html(remove_all_ids(ontology.to_html()))
assert parsed_ontology == expected_html
def test_when_tag_not_supported_by_ontology_and_wrong_then_consider_them_text():
# language=HTML
base_html = _wrap_with_body("""
<div class="Page">
<newtag class="wrongclass">Some text
</newtag>
</div>
""")
base_html = indent_html(base_html)
# TODO (Pluto): Maybe it should be considered as plain text?
# language=HTML
expected_html = _wrap_with_body("""
<div class="Page">
<span class="UncategorizedText">Some text
</span>
</div>
""")
expected_html = indent_html(expected_html)
ontology: OntologyElement = parse_html_to_ontology(base_html)
parsed_ontology = indent_html(remove_all_ids(ontology.to_html()))
assert parsed_ontology == expected_html
def test_div_are_ignored_when_no_attrs():
# language=HTML
base_html = _wrap_with_body("""
<div class="Page">
<div>
<input class="RadioButton" name="health-comparison" type="radio"/>
</div>
</div>
""")
base_html = indent_html(base_html)
# language=HTML
expected_html = _wrap_with_body("""
<div class="Page">
<input class="RadioButton" name="health-comparison" type="radio"/>
</div>
""")
expected_html = indent_html(expected_html)
ontology: OntologyElement = parse_html_to_ontology(base_html)
parsed_ontology = indent_html(remove_all_ids(ontology.to_html()))
assert parsed_ontology == expected_html
def test_ids_are_preserved():
# language=HTML
base_html = _wrap_with_body("""
<div class="Page">
<div style="background-color: lightblue" id="important_div">
<input class="RadioButton" name="health-comparison" type="radio"/>
</div>
</div>
""")
base_html = indent_html(base_html)
# language=HTML
expected_html = _wrap_with_body("""
<div class="Page">
<div class="UncategorizedText" style="background-color: lightblue" id="important_div">
<input class="RadioButton" name="health-comparison" type="radio"/>
</div>
</div>
""")
expected_html = indent_html(expected_html)
ontology: OntologyElement = parse_html_to_ontology(base_html)
page = ontology.children[0]
div_obj = page.children[0]
assert div_obj.additional_attributes["id"] == "important_div"
def test_br_is_not_considered_uncategorized_text():
# language=HTML
base_html = _wrap_with_body("""
<div class="Page">
<br/>
</div>
""")
base_html = indent_html(base_html)
# language=HTML
expected_html = _wrap_with_body("""
<div class="Page">
<br/>
</div>
""")
expected_html = indent_html(expected_html)
ontology: OntologyElement = parse_html_to_ontology(base_html)
parsed_ontology = indent_html(remove_all_ids(ontology.to_html()))
assert parsed_ontology == expected_html
def test_text_without_tag_is_marked_as_uncategorized_text_when_there_are_other_elements():
# language=HTML
base_html = _wrap_with_body("""
<div class="Page">
About the same
<input class="RadioButton" name="health-comparison" type="radio"/>
Some text
</div>
""")
# language=HTML
expected_html = _wrap_with_body("""
<div class="Page">
<p class="Paragraph">
About the same
</p>
<input class="RadioButton" name="health-comparison" type="radio"/>
<p class="Paragraph">
Some text
</p>
</div>
""")
expected_html = indent_html(expected_html)
ontology: OntologyElement = parse_html_to_ontology(base_html)
parsed_ontology = indent_html(remove_all_ids(ontology.to_html()))
assert parsed_ontology == expected_html
def test_keyword_only_attributes_are_preserved_during_mapping():
# language=HTML
base_html = _wrap_with_body("""
<input class="FormFieldValue" type="radio" name="options" value="2" checked>
""") # noqa: E501
base_html = indent_html(base_html)
# language=HTML
expected_html = _wrap_with_body("""
<input class="FormFieldValue" type="radio" name="options" value="2" checked>
""") # noqa: E501
expected_html = indent_html(expected_html)
ontology: OntologyElement = parse_html_to_ontology(base_html)
parsed_ontology = indent_html(remove_all_ids(ontology.to_html()))
assert parsed_ontology == expected_html
def test_when_unknown_element_keyword_only_attributes_are_preserved_during_mapping():
# <input> can be assigned to multiple classes so it is not clear what it is
# thus we assign it to UncategorizedText
# language=HTML
base_html = _wrap_with_body("""
<div class="Page">
<form class="Form">
<label class="FormField" for="option1">
<input type="radio" name="option1" value="2" checked>
<span class="UncategorizedText">
Option 1 (Checked)
</span>
</label>
</form>
</div>
""")
base_html = indent_html(base_html)
# TODO(Pluto): Maybe tag also should be overwritten? Or just leave it as it is?
# We classify <input> as UncategorizedText but all the text is preserved
# for UnstructuredElement so it make sense now as well
# language=HTML
expected_html = _wrap_with_body("""
<div class="Page">
<form class="Form">
<label class="FormField" for="option1">
<input class="RadioButton" type="radio" name="option1" value="2" checked />
<span class="UncategorizedText">
Option 1 (Checked)
</span>
</label>
</form>
</div>
""") # noqa: E501
expected_html = indent_html(expected_html)
ontology: OntologyElement = parse_html_to_ontology(base_html)
parsed_ontology = indent_html(remove_all_ids(ontology.to_html()))
assert parsed_ontology == expected_html
def test_broken_cell_is_not_raising_error():
# language=HTML
base_html = _wrap_with_body("""
<div class="Page">
<table class="Table">
<tbody class="TableBody">
<tr class="TableRow">
<td class="TableCell&gt;11,442,231&lt;/td&gt;&lt;td class=" tablecell"="">
83.64 GiB
</td>
<th class="TableCellHeader" rowspan="2">
Fair Value
</th>
</tr>
</tbody>
</table>
</div>
""")
base_html = indent_html(base_html)
# language=HTML
expected_html = _wrap_with_body("""
<div class="Page">
<table class="Table">
<tbody>
<tr>
<td>
83.64 GiB
</td>
<th rowspan="2">
Fair Value
</th>
</tr>
</tbody>
</table>
</div>
""")
expected_html = indent_html(expected_html)
ontology: OntologyElement = parse_html_to_ontology(base_html)
parsed_ontology = indent_html(remove_all_ids(ontology.to_html()))
assert parsed_ontology == expected_html
def test_table():
# language=HTML
base_html = _wrap_with_body("""
<div class="Page">
<table class="Table">
<tbody class="TableBody">
<tr class="TableRow">
<td class="TableCell">
Fair Value1
</td>
<th class="TableCellHeader" rowspan="2">
Fair Value2
</th>
</tr>
</tbody>
</table>
</div>
""")
base_html = indent_html(base_html)
# language=HTML
expected_html = _wrap_with_body("""
<div class="Page">
<table class="Table">
<tbody>
<tr>
<td>
Fair Value1
</td>
<th rowspan="2">
Fair Value2
</th>
</tr>
</tbody>
</table>
</div>
""")
expected_html = indent_html(expected_html)
ontology: OntologyElement = parse_html_to_ontology(base_html)
parsed_ontology = indent_html(remove_all_ids(ontology.to_html()))
assert parsed_ontology == expected_html
def test_table_and_time():
# language=HTML
base_html = _wrap_with_body("""
<div class="Page">
<table class="Table">
<thead class='TableHeader'>
<tr class="TableRow">
<th class="TableCellHeader" colspan="6">
Carrying Value
</th>
</tr>
</thead>
<tbody class='TableBody'>
<tr class="TableRow">
<td class="TableCell" colspan="5">
<time class="CalendarDate">
June 30, 2023
</time>
</td>
<td class="TableCell">
<span class="Currency">
$—
</span>
</td>
</tr>
</tbody>
</table>
</div>
""")
base_html = indent_html(base_html)
# language=HTML
expected_html = _wrap_with_body("""
<div class="Page">
<table class="Table">
<thead>
<tr>
<th colspan="6">
Carrying Value
</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="5">
<time>
June 30, 2023
</time>
</td>
<td>
<span>
$—
</span>
</td>
</tr>
</tbody>
</table>
</div>
""")
expected_html = indent_html(expected_html)
ontology: OntologyElement = parse_html_to_ontology(base_html)
parsed_ontology = indent_html(remove_all_ids(ontology.to_html()))
assert parsed_ontology == expected_html
def test_malformed_html():
# language=HTML
input_html = """
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
<html>
<head>
<title>Super Malformed HTML</title>
</head>
<body class="Document">
<!-- Unclosed comment
<div class=>
<p>Paragraph with missing closing angle bracket
<div>
<span>
<p>Improperly nested paragraph within a span</span>
</p>
</div>
<script>
var x = "Unclosed script tag example;
</script>
<p>Paragraph with invalid characters: </p>
</div>
</html>
"""
# Such malformed HTML won't be returned by html_partitioning as it uses html5lib parser
# to imitate the same behaviour it will be first parsed the same way
input_html = indent_html(input_html, html_parser="html5lib")
# Ontology has 1 element and everything inside is just Text.
# SECURITY (GHSA-v5mq-3xhg-98m9): the collapsed markup stored as this element's
# text is HTML-escaped on output, so the embedded <script>/<div>/<p> survive as
# inert escaped text (&lt;script&gt; ...) instead of being re-materialized as live
# tags. Previously this test asserted a live <script> tag in the output.
# language=HTML
expected_html = """
<body class="Document">
<p class="Paragraph">
Unclosed comment
&lt;div class=&gt;
&lt;p&gt;Paragraph with missing closing angle bracket
&lt;div&gt;
&lt;span&gt;
&lt;p&gt;Improperly nested paragraph within a span&lt;/span&gt;
&lt;/p&gt;
&lt;/div&gt;
&lt;script&gt;
var x = "Unclosed script tag example;
&lt;/script&gt;
&lt;p&gt;Paragraph with invalid characters: &lt;/p&gt;
&lt;/div&gt;
&lt;/html&gt;
</p>
</body>
"""
expected_html = indent_html(expected_html)
ontology: OntologyElement = parse_html_to_ontology(input_html)
parsed_ontology = indent_html(remove_all_ids(ontology.to_html()))
assert parsed_ontology == expected_html
def test_text_is_wrapped_inside_layout_element():
# language=HTML
base_html = _wrap_with_body("""
<div class="Page">
Text
</div>
""")
base_html = indent_html(base_html)
# language=HTML
expected_html = _wrap_with_body("""
<div class="Page">
<p class='Paragraph'>Text</p>
</div>
""")
expected_html = indent_html(expected_html)
ontology: OntologyElement = parse_html_to_ontology(base_html)
parsed_ontology = indent_html(remove_all_ids(ontology.to_html()))
assert parsed_ontology == expected_html
def test_text_in_form_field_value():
# language=HTML
input_html = """
<div class="Page">
<input class="FormFieldValue" value="Random Input Value"/>
</div>
"""
page = parse_html_to_ontology(input_html)
assert len(page.children) == 1
form_field_value = page.children[0]
assert form_field_value.text == ""
assert form_field_value.to_text() == "Random Input Value"
def test_text_in_form_field_value_with_null_value():
# language=HTML
input_html = """
<div class="Page">
<input class="FormFieldValue" value=""/>
</div>
"""
page = parse_html_to_ontology(input_html)
assert len(page.children) == 1
form_field_value = page.children[0]
assert form_field_value.text == ""
assert form_field_value.to_text() == ""
def test_to_text_when_form_field():
ontology = Page(
children=[
Form(
tag="input",
additional_attributes={"value": "Random Input Value"},
children=[
FormFieldValue(
tag="input",
additional_attributes={"value": "Random Input Value"},
)
],
)
]
)
assert ontology.to_text(add_children=True) == "Random Input Value"
def test_recursion_limit_is_limiting_parsing():
# language=HTML
broken_html = "some text"
for i in range(100):
broken_html = f"<p class='Paragraph'>{broken_html}</p>"
broken_html = _wrap_with_body(broken_html)
ontology = parse_html_to_ontology(broken_html)
iterator = 1
last_child = ontology.children[0]
while last_child.children:
last_child = last_child.children[0]
iterator += 1
assert last_child.text.startswith('<p class="Paragraph">')
assert iterator == RECURSION_LIMIT
def test_get_text_when_recursion_limit_activated():
broken_html = "some text"
for i in range(100):
broken_html = f"<p class='Paragraph'>{broken_html}</p>"
broken_html = _wrap_with_body(broken_html)
ontology = parse_html_to_ontology(broken_html)
last_child = ontology.children[0]
while last_child.children:
last_child = last_child.children[0]
assert last_child.to_text() == "some text"
def test_uncategorizedtest_has_image_and_no_text():
# language=HTML
base_html = _wrap_with_body("""
<div class="Page">
<div class="UncategorizedText">
<img src="https://www.example.com/image.jpg"/>
</div>
</div>
""")
base_html = indent_html(base_html)
ontology: OntologyElement = parse_html_to_ontology(base_html)
element = ontology.children[0].children[0]
assert type(element) is Image
assert element.css_class_name == "Image"
@pytest.mark.parametrize(
("input_type", "expected_class"),
[
("checkbox", Checkbox),
("radio", RadioButton),
("text", FormFieldValue), # explicit non-specialised type
(None, FormFieldValue), # missing type attribute
],
)
def test_input_tag_type_is_mapped_to_correct_ontology_class(
input_type: Optional[str], expected_class: Type[OntologyElement]
) -> None:
"""Ensure bare <input> tags are classified based on their *type* attribute."""
type_attr = f' type="{input_type}"' if input_type is not None else ""
html_snippet = f'<div class="Page"><input{type_attr} name="field" /></div>'
page = parse_html_to_ontology(html_snippet)
assert len(page.children) == 1
element = page.children[0]
# Validate chosen ontology class and preserved HTML semantics
assert isinstance(element, expected_class)
assert element.html_tag_name == "input"
assert element.css_class_name == expected_class.__name__
@@ -0,0 +1,480 @@
# End 2 End tests for 15 types
from unstructured.documents.elements import (
Element,
ElementMetadata,
Header,
NarrativeText,
Table,
Text,
)
from unstructured.documents.ontology import Address, Paragraph
from unstructured.partition.html.html_utils import indent_html
from unstructured.partition.html.partition import partition_html
from unstructured.partition.html.transformations import (
ontology_to_unstructured_elements,
parse_html_to_ontology,
unstructured_elements_to_ontology,
)
def _wrap_in_body_and_page(html_code):
return f'<body class="Document"><div class="Page" data-page-number="1">{html_code}</div></body>'
_page_elements = [
Text(
text="",
metadata=ElementMetadata(text_as_html='<div class="Page" data-page-number="1" />'),
),
]
def _assert_elements_equal(actual_elements: list[Element], expected_elements: list[Element]):
assert len(actual_elements) == len(expected_elements)
for actual, expected in zip(actual_elements, expected_elements):
assert actual == expected, f"Actual: {actual}, Expected: {expected}"
# Not all elements are considered be __eq__ Elements method
actual_html = indent_html(actual.metadata.text_as_html, html_parser="html.parser")
expected_html = indent_html(expected.metadata.text_as_html, html_parser="html.parser")
assert actual_html == expected_html, f"Actual: {actual_html}, Expected: {expected_html}"
def _parse_to_unstructured_elements_and_back_to_html(html_as_str: str):
unstructured_elements = partition_html(
text=html_as_str, add_img_alt_text=False, html_parser_version="v2", unique_element_ids=True
)
parsed_ontology = unstructured_elements_to_ontology(unstructured_elements)
return unstructured_elements, parsed_ontology
def test_simple_narrative_text_with_id():
# language=HTML
html_as_str = _wrap_in_body_and_page("""
<p class="NarrativeText">
DEALER ONLY
</p>
""")
unstructured_elements, parsed_ontology = _parse_to_unstructured_elements_and_back_to_html(
html_as_str
)
expected_html = indent_html(html_as_str, html_parser="html.parser")
parsed_html = indent_html(parsed_ontology.to_html(), html_parser="html.parser")
assert expected_html == parsed_html
expected_elements = _page_elements + [
NarrativeText(
text="DEALER ONLY",
metadata=ElementMetadata(
text_as_html='<p class="NarrativeText">DEALER ONLY</p>',
),
)
]
_assert_elements_equal(unstructured_elements, expected_elements)
def test_input_with_radio_button_checked():
# language=HTML
html_as_str = _wrap_in_body_and_page("""
<input class="RadioButton" name="health-comparison" type="radio" checked/>
""")
unstructured_elements, parsed_ontology = _parse_to_unstructured_elements_and_back_to_html(
html_as_str
)
expected_html = indent_html(html_as_str, html_parser="html.parser")
parsed_html = indent_html(parsed_ontology.to_html(), html_parser="html.parser")
assert expected_html == parsed_html
expected_elements = _page_elements + [
Text(
text="",
metadata=ElementMetadata(
text_as_html=(
'<input class="RadioButton" name="health-comparison" type="radio" checked />'
),
),
)
]
_assert_elements_equal(unstructured_elements, expected_elements)
def test_multiple_elements():
# language=HTML
html_as_str = _wrap_in_body_and_page("""
<p class="Paragraph">
About the same
</p>
<input class="RadioButton" name="health-comparison" type="radio"/>
<p class="Paragraph">
Some text
</p>
""")
unstructured_elements, parsed_ontology = _parse_to_unstructured_elements_and_back_to_html(
html_as_str
)
expected_html = indent_html(html_as_str, html_parser="html.parser")
parsed_html = indent_html(parsed_ontology.to_html(), html_parser="html.parser")
assert expected_html == parsed_html
expected_elements = _page_elements + [
NarrativeText(
text="About the same",
metadata=ElementMetadata(
text_as_html='<p class="Paragraph">About the same</p>',
),
),
Text(
text="",
metadata=ElementMetadata(
text_as_html='<input class="RadioButton" name="health-comparison" type="radio" />',
),
),
NarrativeText(
text="Some text",
metadata=ElementMetadata(
text_as_html='<p class="Paragraph">Some text</p>',
),
),
]
_assert_elements_equal(unstructured_elements, expected_elements)
def test_multiple_pages():
# language=HTML
html_as_str = """
<body class="Document">
<div class="Page" data-page-number="1">
<p class="Paragraph">
Some text
</p>
</div>
<div class="Page" data-page-number="2">
<p class="Paragraph">
Another text
</p>
</div>
</body>
"""
unstructured_elements, parsed_ontology = _parse_to_unstructured_elements_and_back_to_html(
html_as_str
)
expected_html = indent_html(html_as_str, html_parser="html.parser")
parsed_html = indent_html(parsed_ontology.to_html(), html_parser="html.parser")
assert expected_html == parsed_html
expected_elements = [
Text(
text="",
metadata=ElementMetadata(text_as_html='<div class="Page" data-page-number="1" />'),
),
NarrativeText(
text="Some text",
metadata=ElementMetadata(text_as_html='<p class="Paragraph">Some text</p>'),
),
Text(
text="",
metadata=ElementMetadata(text_as_html='<div class="Page" data-page-number="2" />'),
),
NarrativeText(
text="Another text",
metadata=ElementMetadata(text_as_html='<p class="Paragraph">Another text</p>'),
),
]
_assert_elements_equal(unstructured_elements, expected_elements)
def test_forms():
# language=HTML
html_as_str = _wrap_in_body_and_page("""
<form class="Form">
<label class="FormField" for="option1">
<input class="FormFieldValue" type="radio"
name="options" value="2" checked>
<p class="Paragraph">
Option 1 (Checked)
</p>
</label>
</form>
""")
unstructured_elements, parsed_ontology = _parse_to_unstructured_elements_and_back_to_html(
html_as_str
)
expected_html = indent_html(html_as_str, html_parser="html.parser")
parsed_html = indent_html(parsed_ontology.to_html(), html_parser="html.parser")
assert expected_html == parsed_html
expected_elements = _page_elements + [
Text(
text="2 Option 1 (Checked)",
metadata=ElementMetadata(
text_as_html=""
'<form class="Form">'
'<label class="FormField" '
'for="option1">'
'<input class="FormFieldValue" type="radio" '
'name="options" value="2" checked />'
'<p class="Paragraph">'
"Option 1 (Checked)"
"</p></label></form>",
),
)
]
_assert_elements_equal(unstructured_elements, expected_elements)
def test_table():
# language=HTML
html_as_str = _wrap_in_body_and_page("""
<table class="Table">
<tbody class="TableBody">
<tr class="TableRow">
<td class="TableCell">
Fair Value1
</td>
<th class="TableCellHeader" rowspan="2">
Fair Value2
</th>
</tr>
</tbody>
</table>
""")
unstructured_elements, parsed_ontology = _parse_to_unstructured_elements_and_back_to_html(
html_as_str
)
expected_elements = _page_elements + [
Table(
text="Fair Value1 Fair Value2",
metadata=ElementMetadata(
text_as_html='<table class="Table">'
"<tbody>"
"<tr>"
"<td>"
"Fair Value1"
"</td>"
'<th rowspan="2">'
"Fair Value2"
"</th></tr></tbody></table>",
),
)
]
_assert_elements_equal(unstructured_elements, expected_elements)
def test_very_nested_structure_is_preserved():
# language=HTML
html_as_str = _wrap_in_body_and_page("""
<section class='Section'>
<div class='Column'>
<header class='Header'>
<h1 class='Title'>
Title
</h1>
</header>
</div>
</section>
<div class='Column'>
<header class='Header'>
Page 1
</header>
<blockquote class="Quote">
<p class="Paragraph">
Clever Quote
</p>
</blockquote>
<div class='Footnote'>
<span class='UncategorizedText'>
Uncategorized footnote text
</span>
</div>
</div>
""")
unstructured_elements, parsed_ontology = _parse_to_unstructured_elements_and_back_to_html(
html_as_str
)
expected_html = indent_html(html_as_str, html_parser="html.parser")
parsed_html = indent_html(parsed_ontology.to_html(), html_parser="html.parser")
assert expected_html == parsed_html
expected_elements = _page_elements + [
Text(
text="",
metadata=ElementMetadata(text_as_html='<section class="Section" />'),
),
Text(
text="",
metadata=ElementMetadata(text_as_html='<div class="Column" />'),
),
Header(
text="Title",
metadata=ElementMetadata(
text_as_html='<header class="Header"><h1 class="Title">Title</h1></header>'
),
),
Text(
text="",
metadata=ElementMetadata(text_as_html='<div class="Column" />'),
),
Header(
text="Page 1",
metadata=ElementMetadata(text_as_html='<header class="Header">Page 1</header>'),
),
NarrativeText(
text="Clever Quote",
metadata=ElementMetadata(
text_as_html='<blockquote class="Quote">'
'<p class="Paragraph">'
"Clever Quote"
"</p>"
"</blockquote>",
),
),
Text(
text="Uncategorized footnote text",
metadata=ElementMetadata(
text_as_html='<div class="Footnote">'
'<span class="UncategorizedText">'
"Uncategorized footnote text"
"</span>"
"</div>",
),
),
]
_assert_elements_equal(unstructured_elements, expected_elements)
def test_ordered_list():
# language=HTML
html_as_str = _wrap_in_body_and_page("""
<ul class="UnorderedList">
<li class="ListItem">
Item 1
</li>
<li class="ListItem">
Item 2
</li>
<li class="ListItem">
Item 3
</li>
</ul>
""")
unstructured_elements, parsed_ontology = _parse_to_unstructured_elements_and_back_to_html(
html_as_str
)
expected_html = indent_html(html_as_str, html_parser="html.parser")
parsed_html = indent_html(parsed_ontology.to_html(), html_parser="html.parser")
assert expected_html == parsed_html
expected_elements = _page_elements + [
Text(
text="Item 1 Item 2 Item 3",
metadata=ElementMetadata(
text_as_html='<ul class="UnorderedList">'
'<li class="ListItem">'
"Item 1"
"</li>"
'<li class="ListItem">'
"Item 2</li>"
'<li class="ListItem">'
"Item 3"
"</li></ul>",
),
)
]
_assert_elements_equal(unstructured_elements, expected_elements)
def test_squeezed_elements_are_parsed_back():
# language=HTML
html_as_str = _wrap_in_body_and_page("""
<p class="NarrativeText">
Table of Contents
</p>
<address class="Address">
68 Prince Street Palmdale, CA 93550
</address>
<a class="Hyperlink">
www.google.com
</a>
""")
unstructured_elements, parsed_ontology = _parse_to_unstructured_elements_and_back_to_html(
html_as_str
)
expected_html = indent_html(html_as_str, html_parser="html.parser")
parsed_html = indent_html(parsed_ontology.to_html(), html_parser="html.parser")
assert expected_html == parsed_html
expected_elements = _page_elements + [
NarrativeText(
text="Table of Contents 68 Prince Street Palmdale, CA 93550 www.google.com",
metadata=ElementMetadata(
text_as_html='<p class="NarrativeText">Table of Contents</p>'
'<address class="Address">'
"68 Prince Street Palmdale, CA 93550"
"</address>"
'<a class="Hyperlink">www.google.com</a>',
),
)
]
_assert_elements_equal(unstructured_elements, expected_elements)
def test_inline_elements_are_squeezed_when_text_wrapped_into_paragraphs():
# language=HTML
base_html = """
<div class="Page">
About the same
<address class="Address">
1356 Hornor Avenue Oklahoma
</address>
Some text
</div>
"""
# Such HTML is transformed into Page: [Pargraph, Address, Paragraph]
# We would like it to be parsed to UnstructuredElements as [Page, NarrativeText]
ontology = parse_html_to_ontology(base_html)
p1, address, p2 = ontology.children
assert isinstance(p1, Paragraph)
assert isinstance(address, Address)
assert isinstance(p2, Paragraph)
unstructured_elements = ontology_to_unstructured_elements(ontology)
assert len(unstructured_elements) == 2
assert isinstance(unstructured_elements[0], Text)
assert isinstance(unstructured_elements[1], NarrativeText)
def test_alternate_text_from_image_is_passed():
# language=HTML
input_html = """
<div class="Page">
<table>
<tr>
<td rowspan="2">Example image nested in the table:</td>
<td rowspan="2"><img src="my-logo.png" alt="ALT TEXT Logo"></td>
</tr>
</table>
</div>add_img_alt_text
"""
page = parse_html_to_ontology(input_html)
unstructured_elements = ontology_to_unstructured_elements(page)
assert len(unstructured_elements) == 2
assert "ALT TEXT Logo" in unstructured_elements[1].text
@@ -0,0 +1,26 @@
import pytest
from unstructured.partition.html.transformations import remove_empty_tags_from_html_content
@pytest.mark.parametrize(
("html_content, expected_output"), # noqa PT006
[
("<div></div>", ""),
("<div><p></p></div>", "<div></div>"),
("<div><input/></div>", "<div><input/></div>"),
("<div><br/></div>", "<div><br/></div>"),
('<div><p id="1"></p></div>', '<div><p id="1"></p></div>'),
("<div><p>Content</p></div>", "<div><p>Content</p></div>"),
("<div><p> </p></div>", "<div></div>"),
("<div><p></p><span></span></div>", "<div></div>"),
("<div><p>Content</p><span></span></div>", "<div><p>Content</p></div>"),
("<div><p>Content</p><span> </span></div>", "<div><p>Content</p></div>"),
(
"<div><p>Content</p><span>Text</span></div>",
"<div><p>Content</p><span>Text</span></div>",
),
],
)
def test_removes_empty_tags(html_content, expected_output):
assert remove_empty_tags_from_html_content(html_content) == expected_output
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,164 @@
from unstructured.partition.html import partition_html
def _by_text(elements):
return {(e.text or ""): e for e in elements}
def test_category_depth_follows_heading_level_on_a_multi_level_page():
"""ML-1328 AC1/AC3: a heading's `category_depth` is its heading level (h1->0, h2->1, h3->2),
and `parent_id` chains each subsection under its enclosing section heading."""
# language=HTML
html = """
<body class="Document">
<div class="Page" data-page-number="1">
<h1 class="Title">Cost Share Summary</h1>
<p class="NarrativeText">Intro paragraph.</p>
<h2 class="Heading">Accumulation Period</h2>
<p class="NarrativeText">Accumulation body.</p>
<h2 class="Heading">Cost Share Summary Tables by Benefit</h2>
<h3 class="Heading">How to read the Cost Share summary tables</h3>
<p class="NarrativeText">How-to body.</p>
</div>
</body>
"""
elements = partition_html(text=html, html_parser_version="v2", unique_element_ids=False)
by_text = _by_text(elements)
section = by_text["Cost Share Summary"]
subsection_a = by_text["Accumulation Period"]
subsection_b = by_text["Cost Share Summary Tables by Benefit"]
sub_subsection = by_text["How to read the Cost Share summary tables"]
# -- depth tracks heading level --
assert section.metadata.category_depth == 0
assert subsection_a.metadata.category_depth == 1
assert subsection_b.metadata.category_depth == 1
assert sub_subsection.metadata.category_depth == 2
# -- parent_id chains section -> subsection -> sub-subsection --
assert subsection_a.metadata.parent_id == section.id
assert subsection_b.metadata.parent_id == section.id
assert sub_subsection.metadata.parent_id == subsection_b.id
# -- body text parents to its enclosing heading, not the page/column container --
assert by_text["How-to body."].metadata.parent_id == sub_subsection.id
# -- v2's defining behavior is preserved: every element keeps its text_as_html --
assert all(e.metadata.text_as_html for e in elements)
def test_category_depth_does_not_change_with_multi_column_layout():
"""ML-1328 AC2: the same heading reads the same `category_depth` whether the page is single- or
multi-column. Layout nesting (Page -> Column) must not bump the depth."""
# language=HTML
single_column = """
<body class="Document">
<div class="Page" data-page-number="1">
<h1 class="Title">Introduction</h1>
<h2 class="Heading">About</h2>
</div>
</body>
"""
two_column = """
<body class="Document">
<div class="Page" data-page-number="1">
<div class="Column">
<h1 class="Title">Introduction</h1>
<h2 class="Heading">About</h2>
</div>
<div class="Column">
<h2 class="Heading">Contact</h2>
</div>
</div>
</body>
"""
single = _by_text(
partition_html(text=single_column, html_parser_version="v2", unique_element_ids=False)
)
multi = _by_text(
partition_html(text=two_column, html_parser_version="v2", unique_element_ids=False)
)
# -- identical depth across layouts: h1 -> 0, h2 -> 1 -- column wrapping is irrelevant --
assert single["Introduction"].metadata.category_depth == 0
assert multi["Introduction"].metadata.category_depth == 0
assert single["About"].metadata.category_depth == 1
assert multi["About"].metadata.category_depth == 1
assert multi["Contact"].metadata.category_depth == 1
def test_alternative_image_text_can_be_included():
# language=HTML
html = """
<div class="Page">
<img src="my-logo.png" alt="ALT TEXT Logo"/>
</div>
"""
_, image_to_text_alt_mode = partition_html(
text=html,
image_alt_mode="to_text",
html_parser_version="v2",
)
assert "ALT TEXT Logo" in image_to_text_alt_mode.text
_, image_none_alt_mode = partition_html(
text=html,
image_alt_mode=None,
html_parser_version="v2",
)
assert "ALT TEXT Logo" not in image_none_alt_mode.text
def test_alternative_image_text_can_be_included_when_nested_in_paragraph():
# language=HTML
html = """
<div class="Page">
<p class="Paragraph">
<img src="my-logo.png" alt="ALT TEXT Logo"/>
</p>
</div>
"""
_, paragraph_to_text_alt_mode = partition_html(
text=html,
image_alt_mode="to_text",
html_parser_version="v2",
)
assert "ALT TEXT Logo" in paragraph_to_text_alt_mode.text
_, paragraph_none_alt_mode = partition_html(
text=html,
image_alt_mode=None,
html_parser_version="v2",
)
assert "ALT TEXT Logo" not in paragraph_none_alt_mode.text
def test_attr_and_html_inside_table_cell_is_kept():
# language=HTML
html = """
<div class="Page">
<table class="Table">
<tbody>
<tr>
<td colspan="2">
Some text
</td>
<td>
<input checked="" class="Checkbox" type="checkbox"/>
</td>
</tr>
</tbody>
</table>
</div>
"""
page, table = partition_html(
text=html,
image_alt_mode="to_text",
html_parser_version="v2",
)
assert (
'<input checked="" class="Checkbox" type="checkbox"/>' in table.metadata.text_as_html
) # class is removed
assert 'colspan="2"' in table.metadata.text_as_html
@@ -0,0 +1,294 @@
from unstructured.documents.elements import ElementMetadata, NarrativeText, Text
from unstructured.documents.ontology import Column, Document, Page, Paragraph
from unstructured.partition.html.transformations import unstructured_elements_to_ontology
def test_when_first_elements_does_not_have_id():
unstructured_elements = [
Text(
element_id="1",
text="",
metadata=ElementMetadata(text_as_html='<div class="Page"/>'),
),
NarrativeText(
element_id="2",
text="Example text",
metadata=ElementMetadata(
text_as_html='<p class="Paragraph"> Example text </p>', parent_id="1"
),
),
]
ontology = unstructured_elements_to_ontology(unstructured_elements)
assert isinstance(ontology, Document)
assert len(ontology.children) == 1
page = ontology.children[0]
assert isinstance(page, Page)
assert len(page.children) == 1
paragraph = page.children[0]
assert isinstance(paragraph, Paragraph)
assert paragraph.text == "Example text"
def test_elements_without_text_as_html_are_skipped_not_fatal():
# An element with no HTML payload (text_as_html=None) carries nothing to rebuild the
# ontology tree from. It must be skipped per-element, not crash the whole conversion.
unstructured_elements = [
Text(
element_id="1",
text="",
metadata=ElementMetadata(text_as_html='<div class="Page"/>'),
),
NarrativeText(
element_id="2",
text="no html payload",
metadata=ElementMetadata(text_as_html=None, parent_id="1"),
),
NarrativeText(
element_id="3",
text="Example text",
metadata=ElementMetadata(
text_as_html='<p class="Paragraph"> Example text </p>', parent_id="1"
),
),
]
ontology = unstructured_elements_to_ontology(unstructured_elements) # must not raise
assert isinstance(ontology, Document)
page = ontology.children[0]
assert isinstance(page, Page)
# the None-payload element is skipped; the valid one still reconstructs
assert len(page.children) == 1
assert isinstance(page.children[0], Paragraph)
assert page.children[0].text == "Example text"
def test_when_two_combined_elements_have_the_same_parent():
unstructured_elements = [
Text(
element_id="1",
text="",
metadata=ElementMetadata(text_as_html='<div class="Page"/>'),
),
NarrativeText(
element_id="2",
text="Example text",
metadata=ElementMetadata(
text_as_html=(
'<p class="Paragraph"> Example text </p>'
'<p class="Paragraph"> Example text 2 </p>'
),
parent_id="1",
),
),
NarrativeText(
element_id="3",
text="Example text 2",
metadata=ElementMetadata(
text_as_html=(
'<p class="Paragraph"> Example text 3 </p>'
'<p class="Paragraph"> Example text 4 </p>'
),
parent_id="1",
),
),
]
ontology = unstructured_elements_to_ontology(unstructured_elements)
assert isinstance(ontology, Document)
assert len(ontology.children) == 1
page = ontology.children[0]
assert isinstance(page, Page)
assert len(page.children) == 4
def test_content_element_without_parent_nests_in_current_container():
"""ML-1328: tree reconstruction is layout-container driven, not content-`parent_id` driven.
Content elements nest in the innermost open layout container regardless of their (now
heading-based, possibly absent) `parent_id`. A paragraph with no `parent_id` therefore lands in
the current Page rather than being lifted to a Document-level sibling -- and is not lost.
"""
unstructured_elements = [
Text(
element_id="1",
text="",
metadata=ElementMetadata(text_as_html='<div class="Page"/>'),
),
NarrativeText(
element_id="2",
text="Example text",
metadata=ElementMetadata(
text_as_html='<p class="Paragraph"> Example text </p>', parent_id="1"
),
),
NarrativeText(
element_id="3",
text="Example text without parent",
metadata=ElementMetadata(
text_as_html='<p class="Paragraph"> Example text without parent </p>'
),
),
]
ontology = unstructured_elements_to_ontology(unstructured_elements)
assert isinstance(ontology, Document)
assert len(ontology.children) == 1
page = ontology.children[0]
assert isinstance(page, Page)
assert len(page.children) == 2
first, second = page.children
assert first.text == "Example text"
assert second.text == "Example text without parent"
def test_multiple_pages_can_be_combined():
unstructured_elements = [
Text(
element_id="1",
text="",
metadata=ElementMetadata(text_as_html='<div class="Page"/>'),
),
NarrativeText(
element_id="2",
text="Example text on page 1",
metadata=ElementMetadata(
text_as_html='<p class="Paragraph"> Example text on page 1 </p>', parent_id="1"
),
),
Text(
element_id="3",
text="",
metadata=ElementMetadata(text_as_html='<div class="Page"/>'),
),
NarrativeText(
element_id="4",
text="Example text on page 2",
metadata=ElementMetadata(
text_as_html='<p class="Paragraph"> Example text on page 2 </p>', parent_id="3"
),
),
]
ontology = unstructured_elements_to_ontology(unstructured_elements)
assert isinstance(ontology, Document)
assert len(ontology.children) == 2
page1 = ontology.children[0]
page2 = ontology.children[1]
assert isinstance(page1, Page)
assert isinstance(page2, Page)
assert len(page1.children) == 1
assert len(page2.children) == 1
paragraph1 = page1.children[0]
paragraph2 = page2.children[0]
assert isinstance(paragraph1, Paragraph)
assert isinstance(paragraph2, Paragraph)
assert paragraph1.text == "Example text on page 1"
assert paragraph2.text == "Example text on page 2"
def test_empty_input_returns_empty_document():
"""ML-1328: empty input must return an empty Document, not raise IndexError."""
ontology = unstructured_elements_to_ontology([])
assert isinstance(ontology, Document)
assert ontology.children == []
def test_nested_layout_containers_rebuild_column_nesting():
"""ML-1328: a Column nests inside its Page (container `parent_id` drives the layout tree).
Two columns under one page; content nests in the innermost open container. This is the
multi-column round-trip that exercises container-under-container nesting.
"""
unstructured_elements = [
Text(
element_id="page",
text="",
metadata=ElementMetadata(text_as_html='<div class="Page"/>'),
),
Text(
element_id="col1",
text="",
metadata=ElementMetadata(text_as_html='<div class="Column"/>', parent_id="page"),
),
NarrativeText(
element_id="c1",
text="Left column text",
metadata=ElementMetadata(
text_as_html='<p class="Paragraph"> Left column text </p>', parent_id="col1"
),
),
Text(
element_id="col2",
text="",
metadata=ElementMetadata(text_as_html='<div class="Column"/>', parent_id="page"),
),
NarrativeText(
element_id="c2",
text="Right column text",
metadata=ElementMetadata(
text_as_html='<p class="Paragraph"> Right column text </p>', parent_id="col2"
),
),
]
ontology = unstructured_elements_to_ontology(unstructured_elements)
assert isinstance(ontology, Document)
assert len(ontology.children) == 1
page = ontology.children[0]
assert isinstance(page, Page)
# -- both columns nest under the page; the second column did not pop past the page to root --
assert len(page.children) == 2
col1, col2 = page.children
assert isinstance(col1, Column)
assert isinstance(col2, Column)
assert [c.text for c in col1.children] == ["Left column text"]
assert [c.text for c in col2.children] == ["Right column text"]
def test_layout_container_with_unknown_parent_id_does_not_pop_to_root():
"""ML-1328: a container whose `parent_id` matches no open container nests in the current one.
Malformed/reordered input (violating the documented parent-before-child precondition) must not
pop past valid ancestors to the Document root and mis-nest subsequent content. The Column here
references a non-existent parent; it should stay inside the open Page, and nothing is lost.
"""
unstructured_elements = [
Text(
element_id="page",
text="",
metadata=ElementMetadata(text_as_html='<div class="Page"/>'),
),
Text(
element_id="col",
text="",
metadata=ElementMetadata(
text_as_html='<div class="Column"/>', parent_id="DOES_NOT_EXIST"
),
),
NarrativeText(
element_id="c1",
text="Body text",
metadata=ElementMetadata(
text_as_html='<p class="Paragraph"> Body text </p>', parent_id="col"
),
),
]
ontology = unstructured_elements_to_ontology(unstructured_elements)
assert isinstance(ontology, Document)
assert len(ontology.children) == 1
page = ontology.children[0]
assert isinstance(page, Page)
# -- the Column stayed nested in the Page rather than being lifted to a Document-level sibling
assert len(page.children) == 1
column = page.children[0]
assert isinstance(column, Column)
assert [c.text for c in column.children] == ["Body text"]
@@ -0,0 +1,254 @@
"""End-to-end regression tests for the stored-XSS fix (GHSA-v5mq-3xhg-98m9).
These exercise the two production sinks:
* ``elements_to_html`` — the assembled HTML document,
* ``ElementMetadata.text_as_html`` — which some callers return to clients verbatim,
feeding them the exact proof-of-concept from the advisory and asserting every
vector is neutralized, while legitimate formatting is preserved.
"""
from __future__ import annotations
import re
from urllib.parse import urlparse
from bs4 import BeautifulSoup
from unstructured.documents.elements import ElementMetadata, Text
from unstructured.partition.html import partition_html
from unstructured.partition.html.convert import elements_to_html
# -- The advisory's proof-of-concept document. --
MALICIOUS_HTML = (
'<body class="Document">'
'<p class="Paragraph" onmouseover="alert(1)">hello'
'<img src=x onerror="alert(document.domain)"></p>'
'<a class="Hyperlink" href="javascript:alert(document.cookie)">click me</a>'
"</body>"
)
def _partition_v2(html_text: str):
return list(partition_html(text=html_text, html_parser_version="v2"))
def _all_text_as_html(elements) -> str:
return " ".join(e.metadata.text_as_html or "" for e in elements)
def _href_values(html: str) -> list[str]:
return [
str(anchor["href"])
for anchor in BeautifulSoup(html, "html.parser").find_all("a", href=True)
]
class DescribeElementsToHtmlIsInert:
"""The four advisory vectors must not survive into elements_to_html output."""
def it_strips_event_handler_attributes(self):
out = elements_to_html(_partition_v2(MALICIOUS_HTML), no_group_by_page=True)
assert "onmouseover" not in out
assert "onerror" not in out
assert "onload" not in out
def it_neutralizes_javascript_hrefs(self):
out = elements_to_html(_partition_v2(MALICIOUS_HTML), no_group_by_page=True)
assert "javascript:" not in out.lower()
def it_has_no_live_svg_from_attribute_breakout(self):
# -- title='"><svg onload=...>' must not break out of the quoted value --
breakout = (
'<body class="Document">'
'<p class="Paragraph" title=\'"><svg onload=alert(1)>\'>x</p>'
"</body>"
)
out = elements_to_html(_partition_v2(breakout), no_group_by_page=True)
# -- no live <svg> tag; the payload is escaped to inert text --
assert not re.search(r"<svg", out)
assert "&lt;svg" in out
def it_adds_safe_rel_for_blank_targets(self):
blank_target = (
'<body class="Document">'
'<a class="Hyperlink" href="https://example.com" target="_blank">x</a>'
"</body>"
)
out = elements_to_html(_partition_v2(blank_target), no_group_by_page=True)
assert 'target="_blank"' in out
assert "noopener" in out
assert "noreferrer" in out
def it_filters_overlay_styles(self):
styled = (
'<body class="Document">'
'<p class="Paragraph" style="position:fixed;inset:0;z-index:9999;color:red">x</p>'
"</body>"
)
out = elements_to_html(_partition_v2(styled), no_group_by_page=True)
assert "position" not in out
assert "inset" not in out
assert "z-index" not in out
assert "color:red" in out
class DescribeTextAsHtmlIsInert:
"""`text_as_html` is a value some callers return to clients directly."""
def it_strips_event_handlers_from_text_as_html(self):
tah = _all_text_as_html(_partition_v2(MALICIOUS_HTML))
assert "onmouseover" not in tah
assert "onerror" not in tah
def it_neutralizes_javascript_hrefs_in_text_as_html(self):
tah = _all_text_as_html(_partition_v2(MALICIOUS_HTML))
assert "javascript:" not in tah.lower()
def it_escapes_the_attribute_breakout_quote(self):
breakout = (
'<body class="Document">'
'<p class="Paragraph" title=\'"><svg onload=alert(1)>\'>x</p>'
"</body>"
)
tah = _all_text_as_html(_partition_v2(breakout))
# -- the double-quote that would break out is encoded --
assert "&quot;" in tah
assert "&lt;svg" in tah
assert not re.search(r"<svg", tah)
def it_drops_meta_refresh_attributes_from_text_as_html(self):
meta_refresh = (
'<body class="Document">'
'<meta class="Keywords" http-equiv="refresh" content="0;url=javascript:alert(1)">'
"</body>"
)
tah = _all_text_as_html(_partition_v2(meta_refresh))
assert "http-equiv" not in tah
assert "refresh" not in tah
def it_drops_form_navigation_attributes_from_text_as_html(self):
form_payload = (
'<body class="Document">'
'<form class="Form" action="javascript:alert(1)">'
'<input class="FormFieldValue" value="x" formaction="javascript:alert(2)">'
"</form>"
"</body>"
)
tah = _all_text_as_html(_partition_v2(form_payload))
assert "action=" not in tah
assert "formaction" not in tah
assert "javascript:" not in tah.lower()
def it_drops_srcset_from_text_as_html(self):
srcset_payload = (
'<body class="Document">'
'<img class="Image" src="https://example.com/ok.png" '
'srcset="javascript:alert(1) 1x">'
"</body>"
)
tah = _all_text_as_html(_partition_v2(srcset_payload))
assert "srcset" not in tah
assert "javascript:" not in tah.lower()
def it_rejects_svg_data_images_in_text_as_html(self):
svg_payload = (
'<body class="Document">'
'<img class="Image" src="data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=">'
"</body>"
)
tah = _all_text_as_html(_partition_v2(svg_payload))
assert "data:image/svg" not in tah
def it_adds_safe_rel_for_blank_targets_in_text_as_html(self):
blank_target = (
'<body class="Document">'
'<a class="Hyperlink" href="https://example.com" target="_blank">x</a>'
"</body>"
)
tah = _all_text_as_html(_partition_v2(blank_target))
assert 'target="_blank"' in tah
assert "noopener" in tah
assert "noreferrer" in tah
def it_filters_overlay_styles_in_text_as_html(self):
styled = (
'<body class="Document">'
'<p class="Paragraph" style="position:fixed;inset:0;z-index:9999;color:red">x</p>'
"</body>"
)
tah = _all_text_as_html(_partition_v2(styled))
assert "position" not in tah
assert "inset" not in tah
assert "z-index" not in tah
assert "color: red" in tah
class DescribeLinkUrlInjectionViaMetadata:
"""`elements_to_html` builds `href` from `metadata.url` outside the ontology
emitter, so the nh3 sweep must cover it too."""
def it_drops_a_javascript_url_from_link_metadata(self):
el = Text(
text="click me",
metadata=ElementMetadata(url="javascript:alert(1)"),
)
el.category = "Link" # type: ignore[assignment]
out = elements_to_html([el], no_group_by_page=True)
assert "javascript:" not in out.lower()
def it_keeps_an_https_url_from_link_metadata(self):
el = Text(
text="click me",
metadata=ElementMetadata(url="https://example.com"),
)
el.category = "Link" # type: ignore[assignment]
out = elements_to_html([el], no_group_by_page=True)
hrefs = _href_values(out)
assert any(
(p := urlparse(href)).scheme == "https" and p.hostname == "example.com"
for href in hrefs
)
class DescribeLegitimateFormattingPreserved:
"""The fix must not strip benign structure/formatting."""
def it_preserves_tables_with_colspan_and_borders(self):
tbl = (
'<body class="Document"><table class="Table"><tbody>'
'<tr><td colspan="2">A&amp;B</td></tr>'
"<tr><td>1</td><td>2</td></tr></tbody></table></body>"
)
out = elements_to_html(_partition_v2(tbl), no_group_by_page=True)
assert "<table" in out
assert 'colspan="2"' in out
# -- the ampersand text is HTML-escaped, not dropped --
assert "A&amp;B" in out
def it_preserves_safe_links_and_headings(self):
doc = (
'<body class="Document">'
'<h1 class="Title">Heading</h1>'
'<a class="Hyperlink" href="https://example.com/page">link</a>'
"</body>"
)
out = elements_to_html(_partition_v2(doc), no_group_by_page=True)
assert "https://example.com/page" in _href_values(out)
assert "Heading" in out
def it_preserves_mailto_scheme_in_text_as_html(self):
# -- `mailto:` is a safe scheme and must survive into `text_as_html`
# -- (a value some callers return to clients directly) --
doc = '<body class="Document"><a class="Hyperlink" href="mailto:a@b.com">email</a></body>'
tah = _all_text_as_html(_partition_v2(doc))
assert "mailto:a@b.com" in tah
def it_preserves_base64_images(self):
img_src = "data:image/png;base64,iVBORw0KGgo="
el = Text(text="", metadata=ElementMetadata(image_base64="iVBORw0KGgo="))
el.category = "Image" # type: ignore[assignment]
el.metadata.image_mime_type = "image/png"
out = elements_to_html([el], no_group_by_page=True)
assert img_src in out
@@ -0,0 +1,78 @@
import pytest
from unstructured_inference.inference.elements import EmbeddedTextRegion
@pytest.fixture()
def mock_embedded_text_regions():
return [
EmbeddedTextRegion.from_coords(
x1=453.00277777777774,
y1=317.319341111111,
x2=711.5338541666665,
y2=358.28571222222206,
text="LayoutParser:",
),
EmbeddedTextRegion.from_coords(
x1=726.4778125,
y1=317.319341111111,
x2=760.3308594444444,
y2=357.1698966666667,
text="A",
),
EmbeddedTextRegion.from_coords(
x1=775.2748177777777,
y1=317.319341111111,
x2=917.3579885555555,
y2=357.1698966666667,
text="Unified",
),
EmbeddedTextRegion.from_coords(
x1=932.3019468888888,
y1=317.319341111111,
x2=1071.8426522222221,
y2=357.1698966666667,
text="Toolkit",
),
EmbeddedTextRegion.from_coords(
x1=1086.7866105555556,
y1=317.319341111111,
x2=1141.2105142777777,
y2=357.1698966666667,
text="for",
),
EmbeddedTextRegion.from_coords(
x1=1156.154472611111,
y1=317.319341111111,
x2=1256.334784222222,
y2=357.1698966666667,
text="Deep",
),
EmbeddedTextRegion.from_coords(
x1=437.83888888888885,
y1=367.13322999999986,
x2=610.0171992222222,
y2=406.9837855555556,
text="Learning",
),
EmbeddedTextRegion.from_coords(
x1=624.9611575555555,
y1=367.13322999999986,
x2=741.6754646666665,
y2=406.9837855555556,
text="Based",
),
EmbeddedTextRegion.from_coords(
x1=756.619423,
y1=367.13322999999986,
x2=958.3867708333332,
y2=406.9837855555556,
text="Document",
),
EmbeddedTextRegion.from_coords(
x1=973.3307291666665,
y1=367.13322999999986,
x2=1092.0535042777776,
y2=406.9837855555556,
text="Image",
),
]
@@ -0,0 +1,154 @@
import numpy as np
import pytest
from PIL import Image
from unstructured_inference.inference.elements import Rectangle
from unstructured_inference.inference.layout import DocumentLayout, PageLayout
from unstructured_inference.inference.layoutelement import LayoutElement
from unstructured.partition.pdf_image.analysis.bbox_visualisation import (
TextAlignment,
get_bbox_text_size,
get_bbox_thickness,
get_label_rect_and_coords,
get_rgb_color,
get_text_color,
)
from unstructured.partition.pdf_image.analysis.layout_dump import ObjectDetectionLayoutDumper
@pytest.mark.parametrize("color", ["red", "green", "blue", "yellow", "black", "white"])
def test_get_rgb_color(color: str):
color_tuple = get_rgb_color(color)
assert isinstance(color_tuple, tuple)
assert len(color_tuple) == 3
assert all(isinstance(c, int) for c in color_tuple)
assert all(0 <= c <= 255 for c in color_tuple)
@pytest.mark.parametrize(
("bbox", "expected_text_size"),
[
((0, 0, 90, 90), 17),
((0, 0, 500, 200), 21),
((0, 0, 10000, 10000), 32),
],
)
def test_get_bbox_text_size(bbox: tuple[int, int, int, int], expected_text_size):
page_size = (1700, 2200) # standard size of a page
text_size = get_bbox_text_size(bbox, page_size)
assert text_size == expected_text_size
@pytest.mark.parametrize(
("bbox", "expected_box_thickness"),
[
((0, 0, 90, 90), 1),
((0, 0, 450, 250), 2),
((0, 0, 600, 1000), 3),
],
)
def test_get_bbox_thickness(bbox: tuple[int, int, int, int], expected_box_thickness):
page_size = (1700, 2200) # standard size of a page
box_thickness = get_bbox_thickness(bbox, page_size)
assert box_thickness == expected_box_thickness
@pytest.mark.parametrize(
("color", "expected_text_color"),
[
("navy", "white"),
("crimson", "white"),
("maroon", "white"),
("dimgray", "white"),
("darkgreen", "white"),
("darkcyan", "white"),
("fuchsia", "white"),
("violet", "black"),
("gold", "black"),
("aqua", "black"),
("greenyellow", "black"),
],
)
def test_best_text_color(color, expected_text_color):
color_tuple = get_rgb_color(color)
expected_text_color_tuple = get_rgb_color(expected_text_color)
_, text_color_tuple = get_text_color(color_tuple)
assert text_color_tuple == expected_text_color_tuple
@pytest.mark.parametrize(
("alignment", "expected_text_bbox"),
[
(TextAlignment.CENTER, ((145, 145), (155, 155))),
(TextAlignment.TOP_LEFT, ((100, 90), (120, 100))),
(TextAlignment.TOP_RIGHT, ((180, 100), (200, 110))),
(TextAlignment.BOTTOM_LEFT, ((100, 190), (120, 200))),
(TextAlignment.BOTTOM_RIGHT, ((180, 190), (200, 200))),
],
)
def test_get_text_bbox(alignment, expected_text_bbox):
text_bbox, text_xy = get_label_rect_and_coords(
alignment=alignment, bbox_points=(100, 100, 200, 200), text_width=10, text_height=10
)
# adding high atol to account for the text-based extending of the bbox
assert np.allclose(text_bbox, expected_text_bbox, atol=10)
def test_od_document_layout_dump():
page1 = PageLayout(
number=1,
image=Image.new("1", (1, 1)),
image_metadata={"width": 100, "height": 100},
)
page1.elements = [
LayoutElement(type="Title", bbox=Rectangle(x1=0, y1=0, x2=10, y2=10), prob=0.7),
LayoutElement(type="Paragraph", bbox=Rectangle(x1=0, y1=100, x2=10, y2=110), prob=0.8),
]
page2 = PageLayout(
number=2,
image=Image.new("1", (1, 1)),
image_metadata={"width": 100, "height": 100},
)
page2.elements = [
LayoutElement(type="Table", bbox=Rectangle(x1=0, y1=0, x2=10, y2=10), prob=0.9),
LayoutElement(type="Image", bbox=Rectangle(x1=0, y1=100, x2=10, y2=110), prob=1.0),
]
od_document_layout = DocumentLayout(pages=[page1, page2])
expected_dump = {
"pages": [
{
"number": 1,
"size": {
"width": 100,
"height": 100,
},
"elements": [
{"bbox": [0, 0, 10, 10], "type": "Title", "prob": 0.7},
{"bbox": [0, 100, 10, 110], "type": "Paragraph", "prob": 0.8},
],
},
{
"number": 2,
"size": {
"width": 100,
"height": 100,
},
"elements": [
{"bbox": [0, 0, 10, 10], "type": "Table", "prob": 0.9},
{"bbox": [0, 100, 10, 110], "type": "Image", "prob": 1.0},
],
},
]
}
od_layout_dump = ObjectDetectionLayoutDumper(od_document_layout).dump()
assert expected_dump == {"pages": od_layout_dump.get("pages")}
# check OD model classes are attached but do not depend on a specific model instance
assert "object_detection_classes" in od_layout_dump
assert len(od_layout_dump["object_detection_classes"]) > 0
@@ -0,0 +1,674 @@
from __future__ import annotations
import os
import pathlib
import tempfile
from unittest import mock
import pytest
from PIL import Image
from pytest_mock import MockFixture
from unstructured_inference.inference import layout
from unstructured_pytesseract import TesseractError
from test_unstructured.partition.pdf_image.test_pdf import assert_element_extraction
from test_unstructured.unit_utils import assert_round_trips_through_JSON, example_doc_path
from unstructured.chunking.title import chunk_by_title
from unstructured.documents.elements import ElementType
from unstructured.partition import image, pdf
from unstructured.partition.pdf_image import ocr
from unstructured.partition.utils.constants import (
UNSTRUCTURED_INCLUDE_DEBUG_METADATA,
PartitionStrategy,
)
from unstructured.utils import only
DIRECTORY = pathlib.Path(__file__).parent.resolve()
class MockResponse:
def __init__(self, status_code, response):
self.status_code = status_code
self.response = response
def json(self):
return self.response
def mock_healthy_get(url, **kwargs):
return MockResponse(status_code=200, response={})
def mock_unhealthy_get(url, **kwargs):
return MockResponse(status_code=500, response={})
def mock_unsuccessful_post(url, **kwargs):
return MockResponse(status_code=500, response={})
def mock_successful_post(url, **kwargs):
response = {
"pages": [
{
"number": 0,
"elements": [
{"type": "Title", "text": "Charlie Brown and the Great Pumpkin"},
],
},
{
"number": 1,
"elements": [{"type": "Title", "text": "A Charlie Brown Christmas"}],
},
],
}
return MockResponse(status_code=200, response=response)
class MockPageLayout(layout.PageLayout):
def __init__(self, number: int, image: Image):
self.number = number
self.image = image
self.image_metadata = {"pdf_rotation": 0}
self.elements = [
layout.LayoutElement.from_coords(
type="Title",
x1=0,
y1=0,
x2=2,
y2=2,
text="Charlie Brown and the Great Pumpkin",
),
]
self.elements_array = layout.LayoutElements.from_list(self.elements)
class MockDocumentLayout(layout.DocumentLayout):
@property
def pages(self):
return [
MockPageLayout(number=0, image=Image.new("1", (1, 1))),
]
@pytest.mark.parametrize(
("filename", "file"),
[
(example_doc_path("img/example.jpg"), None),
(None, b"0000"),
],
)
def test_partition_image_local(monkeypatch, filename, file):
monkeypatch.setattr(
layout,
"process_data_with_model",
lambda *args, **kwargs: MockDocumentLayout(),
)
monkeypatch.setattr(
layout,
"process_file_with_model",
lambda *args, **kwargs: MockDocumentLayout(),
)
monkeypatch.setattr(
ocr,
"process_data_with_ocr",
lambda *args, **kwargs: MockDocumentLayout(),
)
monkeypatch.setattr(
ocr,
"process_data_with_ocr",
lambda *args, **kwargs: MockDocumentLayout(),
)
partition_image_response = pdf._partition_pdf_or_image_local(
filename,
file,
is_image=True,
)
assert partition_image_response[0].text == "Charlie Brown and the Great Pumpkin"
@pytest.mark.skip("Needs to be fixed upstream in unstructured-inference")
def test_partition_image_local_raises_with_no_filename():
with pytest.raises(FileNotFoundError):
pdf._partition_pdf_or_image_local(filename="", file=None, is_image=True)
def test_partition_image_with_auto_strategy():
filename = example_doc_path("img/layout-parser-paper-fast.jpg")
elements = image.partition_image(filename=filename, strategy=PartitionStrategy.AUTO)
titles = [
el for el in elements if el.category == ElementType.TITLE and len(el.text.split(" ")) > 10
]
title = "LayoutParser: A Unified Toolkit for Deep Learning Based Document Image Analysis"
idx = 3
assert titles[0].text == title
assert elements[idx].metadata.detection_class_prob is not None
assert isinstance(elements[idx].metadata.detection_class_prob, float)
def test_partition_image_with_table_extraction():
filename = example_doc_path("img/layout-parser-paper-with-table.jpg")
elements = image.partition_image(
filename=filename,
strategy=PartitionStrategy.HI_RES,
infer_table_structure=True,
)
table = [el.metadata.text_as_html for el in elements if el.metadata.text_as_html]
assert len(table) == 1
assert "<table><thead><tr>" in table[0]
assert "</thead><tbody><tr>" in table[0]
def test_partition_image_with_multipage_tiff():
filename = example_doc_path("img/layout-parser-paper-combined.tiff")
elements = image.partition_image(filename=filename, strategy=PartitionStrategy.AUTO)
assert elements[-1].metadata.page_number == 2
def test_partition_image_with_bmp(tmpdir):
filename = example_doc_path("img/layout-parser-paper-with-table.jpg")
bmp_filename = os.path.join(tmpdir.dirname, "example.bmp")
img = Image.open(filename)
img.save(bmp_filename)
elements = image.partition_image(
filename=bmp_filename,
strategy=PartitionStrategy.HI_RES,
infer_table_structure=True,
)
table = [el.metadata.text_as_html for el in elements if el.metadata.text_as_html]
assert len(table) == 1
assert "<table><thead><tr>" in table[0]
assert "</thead><tbody><tr>" in table[0]
def test_partition_image_with_language_passed():
filename = example_doc_path("img/example.jpg")
with mock.patch.object(
ocr,
"process_file_with_ocr",
mock.MagicMock(),
) as mock_partition:
image.partition_image(
filename=filename,
strategy=PartitionStrategy.HI_RES,
ocr_languages="eng+swe",
)
assert mock_partition.call_args.kwargs.get("ocr_languages") == "eng+swe"
def test_partition_image_from_file_with_language_passed():
filename = example_doc_path("img/example.jpg")
with (
mock.patch.object(
ocr,
"process_data_with_ocr",
mock.MagicMock(),
) as mock_partition,
open(filename, "rb") as f,
):
image.partition_image(file=f, strategy=PartitionStrategy.HI_RES, ocr_languages="eng+swe")
assert mock_partition.call_args.kwargs.get("ocr_languages") == "eng+swe"
# NOTE(crag): see https://github.com/Unstructured-IO/unstructured/issues/1086
@pytest.mark.skip(reason="Current catching too many tesseract errors")
def test_partition_image_raises_with_invalid_language():
filename = example_doc_path("img/example.jpg")
with pytest.raises(TesseractError):
image.partition_image(
filename=filename,
strategy=PartitionStrategy.HI_RES,
ocr_languages="fakeroo",
)
@pytest.mark.parametrize(
"strategy",
[
PartitionStrategy.HI_RES,
PartitionStrategy.OCR_ONLY,
],
)
def test_partition_image_strategies_keep_languages_metadata(strategy):
filename = example_doc_path("img/english-and-korean.png")
elements = image.partition_image(
filename=filename,
languages=["eng", "kor"],
strategy=strategy,
)
assert elements[0].metadata.languages == ["eng", "kor"]
def test_partition_image_with_ocr_detects_korean():
filename = example_doc_path("img/english-and-korean.png")
elements = image.partition_image(
filename=filename,
ocr_languages="eng+kor",
strategy=PartitionStrategy.OCR_ONLY,
)
assert elements[0].text == "RULES AND INSTRUCTIONS"
# FIXME (yao): revisit this lstrip after refactoring merging logics; right now on docker and
# local testing yield different results and on docker there is a "," at the start of the Korean
# text line
assert elements[3].text.replace(" ", "").lstrip(",").startswith("안녕하세요")
def test_partition_image_with_ocr_detects_korean_from_file():
filename = example_doc_path("img/english-and-korean.png")
with open(filename, "rb") as f:
elements = image.partition_image(
file=f,
ocr_languages="eng+kor",
strategy=PartitionStrategy.OCR_ONLY,
)
assert elements[0].text == "RULES AND INSTRUCTIONS"
assert elements[3].text.replace(" ", "").lstrip(",").startswith("안녕하세요")
def test_partition_image_raises_with_bad_strategy():
filename = example_doc_path("img/english-and-korean.png")
with pytest.raises(ValueError):
image.partition_image(filename=filename, strategy="fakeroo")
def test_partition_image_default_strategy_hi_res():
filename = example_doc_path("img/layout-parser-paper-fast.jpg")
with open(filename, "rb") as f:
elements = image.partition_image(file=f)
title = "LayoutParser: A Unified Toolkit for Deep Learning Based Document Image Analysis"
idx = 2
assert elements[idx].text == title
assert elements[idx].metadata.coordinates is not None
assert elements[idx].metadata.detection_class_prob is not None
assert isinstance(elements[idx].metadata.detection_class_prob, float)
if UNSTRUCTURED_INCLUDE_DEBUG_METADATA:
# A bug in partition_groups_from_regions in unstructured-inference losses some sources
assert {element.metadata.detection_origin for element in elements} == {
"yolox",
"ocr_tesseract",
}
# -- .metadata.last_modified ---------------------------------------------------------------------
def test_partition_image_from_file_path_gets_last_modified_from_filesystem(mocker: MockFixture):
filesystem_last_modified = "2029-07-05T09:24:28"
mocker.patch(
"unstructured.partition.pdf.get_last_modified_date",
return_value=filesystem_last_modified,
)
elements = image.partition_image(example_doc_path("img/english-and-korean.png"))
assert all(e.metadata.last_modified == filesystem_last_modified for e in elements)
def test_partition_image_from_file_path_with_hi_res_strategy_gets_last_modified_from_filesystem(
mocker: MockFixture,
):
filesystem_last_modified = "2029-07-05T09:24:28"
mocker.patch(
"unstructured.partition.pdf.get_last_modified_date",
return_value=filesystem_last_modified,
)
elements = image.partition_image(
example_doc_path("img/english-and-korean.png"), strategy=PartitionStrategy.HI_RES
)
assert all(e.metadata.last_modified == filesystem_last_modified for e in elements)
def test_partition_image_from_file_path_prefers_metadata_last_modified(mocker: MockFixture):
filesystem_last_modified = "2029-07-05T09:24:28"
metadata_last_modified = "2009-07-05T09:24:28"
mocker.patch(
"unstructured.partition.pdf.get_last_modified_date",
return_value=filesystem_last_modified,
)
elements = image.partition_image(
example_doc_path("img/english-and-korean.png"),
metadata_last_modified=metadata_last_modified,
)
assert all(e.metadata.last_modified == metadata_last_modified for e in elements)
def test_partition_image_from_file_path_with_hi_res_strategy_prefers_metadata_last_modified(
mocker: MockFixture,
):
filesystem_last_modified = "2029-07-05T09:24:28"
metadata_last_modified = "2009-07-05T09:24:28"
mocker.patch(
"unstructured.partition.pdf.get_last_modified_date",
return_value=filesystem_last_modified,
)
elements = image.partition_image(
example_doc_path("img/english-and-korean.png"),
strategy=PartitionStrategy.HI_RES,
metadata_last_modified=metadata_last_modified,
)
assert all(e.metadata.last_modified == metadata_last_modified for e in elements)
def test_partition_image_from_file_gets_last_modified_None():
with open(example_doc_path("img/english-and-korean.png"), "rb") as f:
elements = image.partition_image(file=f)
assert all(e.metadata.last_modified is None for e in elements)
def test_partition_image_from_file_with_hi_res_strategy_gets_last_modified_None(
mocker: MockFixture,
):
with open(example_doc_path("img/english-and-korean.png"), "rb") as f:
elements = image.partition_image(file=f, strategy=PartitionStrategy.HI_RES)
assert all(e.metadata.last_modified is None for e in elements)
def test_partition_image_from_file_prefers_metadata_last_modified():
metadata_last_modified = "2009-07-05T09:24:28"
with open(example_doc_path("img/english-and-korean.png"), "rb") as f:
elements = image.partition_image(file=f, metadata_last_modified=metadata_last_modified)
assert all(e.metadata.last_modified == metadata_last_modified for e in elements)
def test_partition_image_from_file_with_hi_res_strategy_prefers_metadata_last_modified():
metadata_last_modified = "2009-07-05T09:24:28"
with open(example_doc_path("img/english-and-korean.png"), "rb") as f:
elements = image.partition_image(
file=f,
metadata_last_modified=metadata_last_modified,
strategy=PartitionStrategy.HI_RES,
)
assert all(e.metadata.last_modified == metadata_last_modified for e in elements)
# ------------------------------------------------------------------------------------------------
def test_partition_msg_with_json():
elements = image.partition_image(
example_doc_path("img/layout-parser-paper-fast.jpg"),
strategy=PartitionStrategy.AUTO,
)
assert_round_trips_through_JSON(elements)
def test_partition_image_with_ocr_has_coordinates_from_filename():
filename = example_doc_path("img/english-and-korean.png")
elements = image.partition_image(filename=filename, strategy=PartitionStrategy.OCR_ONLY)
int_coordinates = [(int(x), int(y)) for x, y in elements[0].metadata.coordinates.points]
assert int_coordinates == [(14, 16), (14, 37), (381, 37), (381, 16)]
@pytest.mark.parametrize(
"filename",
[
"img/layout-parser-paper-with-table.jpg",
"img/english-and-korean.png",
"img/layout-parser-paper-fast.jpg",
],
)
def test_partition_image_with_ocr_coordinates_are_not_nan_from_filename(
filename,
):
import math
elements = image.partition_image(
filename=example_doc_path(filename), strategy=PartitionStrategy.OCR_ONLY
)
for element in elements:
# TODO (jennings) One or multiple elements is an empty string
# without coordinates. This should be fixed in a new issue
if element.text:
box = element.metadata.coordinates.points
for point in box:
assert point[0] is not math.nan
assert point[1] is not math.nan
def test_partition_image_formats_languages_for_tesseract():
filename = example_doc_path("img/jpn-vert.jpeg")
with mock.patch(
"unstructured.partition.pdf_image.ocr.process_file_with_ocr",
) as mock_process_file_with_ocr:
image.partition_image(
filename=filename, strategy=PartitionStrategy.HI_RES, languages=["jpn_vert"]
)
_, kwargs = mock_process_file_with_ocr.call_args_list[0]
assert "ocr_languages" in kwargs
assert kwargs["ocr_languages"] == "jpn_vert"
def test_partition_image_warns_with_ocr_languages(caplog):
filename = example_doc_path("img/layout-parser-paper-fast.jpg")
image.partition_image(filename=filename, strategy=PartitionStrategy.HI_RES, ocr_languages="eng")
assert "The ocr_languages kwarg will be deprecated" in caplog.text
def test_add_chunking_strategy_on_partition_image():
filename = example_doc_path("img/layout-parser-paper-fast.jpg")
elements = image.partition_image(filename=filename)
chunk_elements = image.partition_image(filename, chunking_strategy="by_title")
chunks = chunk_by_title(elements)
assert chunk_elements != elements
assert chunk_elements == chunks
def test_add_chunking_strategy_on_partition_image_hi_res():
filename = example_doc_path("img/layout-parser-paper-with-table.jpg")
elements = image.partition_image(
filename=filename,
strategy=PartitionStrategy.HI_RES,
infer_table_structure=True,
)
chunk_elements = image.partition_image(
filename,
strategy=PartitionStrategy.HI_RES,
infer_table_structure=True,
chunking_strategy="by_title",
)
chunks = chunk_by_title(elements)
assert chunk_elements != elements
assert chunk_elements == chunks
def test_partition_image_uses_model_name():
with mock.patch.object(
pdf,
"_partition_pdf_or_image_local",
) as mockpartition:
image.partition_image(
example_doc_path("img/layout-parser-paper-fast.jpg"), model_name="test"
)
print(mockpartition.call_args)
assert "model_name" in mockpartition.call_args.kwargs
assert mockpartition.call_args.kwargs["model_name"]
def test_partition_image_uses_hi_res_model_name():
with mock.patch.object(
pdf,
"_partition_pdf_or_image_local",
) as mockpartition:
image.partition_image(
example_doc_path("img/layout-parser-paper-fast.jpg"), hi_res_model_name="test"
)
print(mockpartition.call_args)
assert "model_name" not in mockpartition.call_args.kwargs
assert "hi_res_model_name" in mockpartition.call_args.kwargs
assert mockpartition.call_args.kwargs["hi_res_model_name"] == "test"
@pytest.mark.parametrize(
("ocr_mode", "idx_title_element"),
[
("entire_page", 2),
("individual_blocks", 1),
],
)
def test_partition_image_hi_res_ocr_mode(ocr_mode, idx_title_element):
filename = example_doc_path("img/layout-parser-paper-fast.jpg")
elements = image.partition_image(
filename=filename, ocr_mode=ocr_mode, strategy=PartitionStrategy.HI_RES
)
# Note(yuming): idx_title_element is different based on xy-cut and ocr mode
assert elements[idx_title_element].category == ElementType.TITLE
def test_partition_image_hi_res_invalid_ocr_mode():
filename = example_doc_path("img/layout-parser-paper-fast.jpg")
with pytest.raises(ValueError):
_ = image.partition_image(
filename=filename, ocr_mode="invalid_ocr_mode", strategy=PartitionStrategy.HI_RES
)
@pytest.mark.parametrize(
"ocr_mode",
[
"entire_page",
"individual_blocks",
],
)
def test_partition_image_hi_res_ocr_mode_with_table_extraction(ocr_mode):
filename = example_doc_path("img/layout-parser-paper-with-table.jpg")
elements = image.partition_image(
filename=filename,
ocr_mode=ocr_mode,
strategy=PartitionStrategy.HI_RES,
infer_table_structure=True,
)
table = [el.metadata.text_as_html for el in elements if el.metadata.text_as_html]
assert len(table) == 1
assert "<table><thead><tr>" in table[0]
assert "</thead><tbody><tr>" in table[0]
assert "Layouts of history Japanese documents" in table[0]
assert "Layouts of scanned modern magazines and scientific reports" in table[0]
def test_partition_image_raises_type_error_for_invalid_languages():
filename = example_doc_path("img/layout-parser-paper-fast.jpg")
with pytest.raises(TypeError):
image.partition_image(filename=filename, strategy=PartitionStrategy.HI_RES, languages="eng")
@pytest.fixture()
def inference_results():
page = layout.PageLayout(
number=1,
image=mock.MagicMock(format="JPEG"),
)
page.elements = [layout.LayoutElement.from_coords(0, 0, 600, 800, text="hello")]
page.elements_array = layout.LayoutElements.from_list(page.elements)
doc = layout.DocumentLayout(pages=[page])
return doc
def test_partition_image_has_filename(inference_results):
filename = "layout-parser-paper-fast.jpg"
# Mock inference call with known return results
with mock.patch(
"unstructured_inference.inference.layout.process_file_with_model",
return_value=inference_results,
) as mock_inference_func:
elements = image.partition_image(
filename=example_doc_path(f"img/{filename}"),
strategy=PartitionStrategy.HI_RES,
)
# Make sure we actually went down the path we expect.
mock_inference_func.assert_called_once()
# Unpack element but also make sure there is only one
element = only(elements)
# This makes sure we are still getting the filetype metadata (should be translated from the
# fixtures)
assert element.metadata.filetype == "JPEG"
# This should be kept from the filename we originally gave
assert element.metadata.filename == filename
@pytest.mark.parametrize("file_mode", ["filename", "rb"])
@pytest.mark.parametrize("extract_image_block_to_payload", [False, True])
def test_partition_image_element_extraction(
file_mode,
extract_image_block_to_payload,
):
filename = example_doc_path("img/embedded-images-tables.jpg")
extract_image_block_types = ["Image", "Table"]
with tempfile.TemporaryDirectory() as tmpdir:
if file_mode == "filename":
elements = image.partition_image(
filename=filename,
extract_image_block_types=extract_image_block_types,
extract_image_block_to_payload=extract_image_block_to_payload,
extract_image_block_output_dir=tmpdir,
)
else:
with open(filename, "rb") as f:
elements = image.partition_image(
file=f,
extract_image_block_types=extract_image_block_types,
extract_image_block_to_payload=extract_image_block_to_payload,
extract_image_block_output_dir=tmpdir,
)
assert_element_extraction(
elements, extract_image_block_types, extract_image_block_to_payload, tmpdir
)
def test_partition_image_works_on_heic_file():
filename = example_doc_path("img/DA-1p.heic")
elements = image.partition_image(filename=filename, strategy=PartitionStrategy.AUTO)
titles = [el.text for el in elements if el.category == ElementType.TITLE]
assert "CREATURES" in titles
@pytest.mark.parametrize(
"strategy",
[PartitionStrategy.HI_RES, PartitionStrategy.OCR_ONLY],
)
def test_deterministic_element_ids(strategy: str):
elements_1 = image.partition_image(
example_doc_path("img/layout-parser-paper-with-table.jpg"),
strategy=strategy,
starting_page_number=2,
)
elements_2 = image.partition_image(
example_doc_path("img/layout-parser-paper-with-table.jpg"),
strategy=strategy,
starting_page_number=2,
)
ids_1 = [element.id for element in elements_1]
ids_2 = [element.id for element in elements_2]
assert ids_1 == ids_2
def test_multi_page_tiff_starts_on_starting_page_number():
elements = image.partition_image(
example_doc_path("img/layout-parser-paper-combined.tiff"),
starting_page_number=2,
)
pages = {element.metadata.page_number for element in elements}
assert pages == {2, 3}
@@ -0,0 +1,169 @@
from unstructured_inference.inference.elements import TextRegion, TextRegions
from unstructured_inference.inference.layoutelement import LayoutElement, LayoutElements
from unstructured.documents.elements import ElementType
from unstructured.partition.pdf_image.inference_utils import (
build_layout_elements_from_ocr_regions,
merge_text_regions,
)
def test_merge_text_regions(mock_embedded_text_regions):
expected = TextRegion.from_coords(
x1=437.83888888888885,
y1=317.319341111111,
x2=1256.334784222222,
y2=406.9837855555556,
text="LayoutParser: A Unified Toolkit for Deep Learning Based Document Image",
)
merged_text_region = merge_text_regions(TextRegions.from_list(mock_embedded_text_regions))
assert merged_text_region == expected
def test_build_layout_elements_from_ocr_regions(mock_embedded_text_regions):
expected = LayoutElements.from_list(
[
LayoutElement.from_coords(
x1=437.83888888888885,
y1=317.319341111111,
x2=1256.334784222222,
y2=406.9837855555556,
text="LayoutParser: A Unified Toolkit for Deep Learning Based Document Image",
type=ElementType.UNCATEGORIZED_TEXT,
),
]
)
elements = build_layout_elements_from_ocr_regions(
TextRegions.from_list(mock_embedded_text_regions)
)
assert elements == expected
def test_build_layout_elements_from_ocr_regions_with_text(mock_embedded_text_regions):
text = "LayoutParser: A Unified Toolkit for Deep Learning Based Document Image"
expected = LayoutElements.from_list(
[
LayoutElement.from_coords(
x1=437.83888888888885,
y1=317.319341111111,
x2=1256.334784222222,
y2=406.9837855555556,
text=text,
type=ElementType.UNCATEGORIZED_TEXT,
),
]
)
elements = build_layout_elements_from_ocr_regions(
TextRegions.from_list(mock_embedded_text_regions),
text,
group_by_ocr_text=True,
)
assert elements == expected
def test_build_layout_elements_from_ocr_regions_with_multi_line_text(mock_embedded_text_regions):
text = "LayoutParser: \n\nA Unified Toolkit for Deep Learning Based Document Image"
elements = build_layout_elements_from_ocr_regions(
TextRegions.from_list(mock_embedded_text_regions),
text,
group_by_ocr_text=True,
)
assert elements == LayoutElements.from_list(
[
LayoutElement.from_coords(
x1=453.00277777777774,
y1=317.319341111111,
x2=711.5338541666665,
y2=358.28571222222206,
text="LayoutParser:",
type=ElementType.UNCATEGORIZED_TEXT,
),
LayoutElement.from_coords(
x1=437.83888888888885,
y1=317.319341111111,
x2=1256.334784222222,
y2=406.9837855555556,
text="A Unified Toolkit for Deep Learning Based Document Image",
type=ElementType.UNCATEGORIZED_TEXT,
),
]
)
def test_build_layout_elements_from_ocr_regions_with_repeated_texts(mock_embedded_text_regions):
mock_embedded_text_regions.extend(
[
LayoutElement.from_coords(
x1=453.00277777777774,
y1=417.319341111111,
x2=711.5338541666665,
y2=458.28571222222206,
text="LayoutParser",
type=ElementType.UNCATEGORIZED_TEXT,
),
LayoutElement.from_coords(
x1=453.00277777777774,
y1=468.319341111111,
x2=711.5338541666665,
y2=478.28571222222206,
text="for",
type=ElementType.UNCATEGORIZED_TEXT,
),
LayoutElement.from_coords(
x1=453.00277777777774,
y1=488.319341111111,
x2=711.5338541666665,
y2=500.28571222222206,
text="Deep",
type=ElementType.UNCATEGORIZED_TEXT,
),
LayoutElement.from_coords(
x1=453.00277777777774,
y1=510.319341111111,
x2=711.5338541666665,
y2=550.28571222222206,
text="Learning",
type=ElementType.UNCATEGORIZED_TEXT,
),
]
)
text = (
"LayoutParser: \n\nA Unified Toolkit for Deep Learning Based Document Image\n\n"
"LayoutParser for Deep Learning"
)
elements = build_layout_elements_from_ocr_regions(
TextRegions.from_list(mock_embedded_text_regions),
text,
group_by_ocr_text=True,
)
assert elements == LayoutElements.from_list(
[
LayoutElement.from_coords(
x1=453.00277777777774,
y1=317.319341111111,
x2=711.5338541666665,
y2=358.28571222222206,
text="LayoutParser:",
type=ElementType.UNCATEGORIZED_TEXT,
),
LayoutElement.from_coords(
x1=437.83888888888885,
y1=317.319341111111,
x2=1256.334784222222,
y2=406.9837855555556,
text="A Unified Toolkit for Deep Learning Based Document Image",
type=ElementType.UNCATEGORIZED_TEXT,
),
LayoutElement.from_coords(
x1=453.00277777777774,
y1=417.319341111111,
x2=711.5338541666665,
y2=550.28571222222206,
text="LayoutParser for Deep Learning",
type=ElementType.UNCATEGORIZED_TEXT,
),
]
)
@@ -0,0 +1,84 @@
import numpy as np
from PIL import Image
from unstructured_inference.constants import IsExtracted
from unstructured_inference.inference.elements import Rectangle
from unstructured_inference.inference.layout import DocumentLayout, PageLayout
from unstructured_inference.inference.layoutelement import LayoutElement, LayoutElements
from unstructured.partition.pdf_image.pdfminer_processing import (
array_merge_inferred_layout_with_extracted_layout,
merge_inferred_with_extracted_layout,
)
def test_text_source_preserved_during_merge():
"""Test that text_source property is preserved when elements are merged."""
# Create two simple LayoutElements with different text_source values
inferred_element = LayoutElement(
bbox=Rectangle(0, 0, 100, 50), text=None, is_extracted=IsExtracted.FALSE
)
extracted_element = LayoutElement(
bbox=Rectangle(0, 0, 100, 50), text="Extracted text", is_extracted=IsExtracted.TRUE
)
# Create LayoutElements arrays
inferred_layout_elements = LayoutElements.from_list([inferred_element])
extracted_layout_elements = LayoutElements.from_list([extracted_element])
# Create a PageLayout for the inferred layout
image = Image.new("RGB", (200, 200))
inferred_page = PageLayout(number=1, image=image)
inferred_page.elements_array = inferred_layout_elements
# Create DocumentLayout from the PageLayout
inferred_document_layout = DocumentLayout(pages=[inferred_page])
# Merge them
merged_layout = merge_inferred_with_extracted_layout(
inferred_document_layout=inferred_document_layout,
extracted_layout=[extracted_layout_elements],
hi_res_model_name="test_model",
)
# Verify text_source is preserved
# Check the merged page's elements_array
merged_page = merged_layout.pages[0]
assert "Extracted text" in merged_page.elements_array.texts
assert hasattr(merged_page.elements_array, "is_extracted_array")
assert IsExtracted.TRUE in merged_page.elements_array.is_extracted_array
def test_single_extracted_region_at_index_zero_removes_inferred_subregion():
"""A lone extracted text region (at index 0) must still absorb an inferred subregion.
Regression test: the subregion-removal rule was gated on ``any(extracted_to_keep)`` where
``extracted_to_keep`` holds element *indices*. When the only kept extracted region was at
index 0, ``any([0])`` evaluated False and the rule was skipped, leaving the inferred box as
a duplicate of the extracted region. This happens with single-region pages such as a PDF
whose only text is a filled form field.
"""
# Inferred (non-table) box fully contained by the single extracted text region.
inferred = LayoutElements(
element_coords=np.array([[10.0, 10.0, 40.0, 40.0]]),
texts=np.array([None], dtype=object),
element_class_ids=np.array([0]),
element_class_id_map={0: "Section-header"},
)
extracted = LayoutElements(
element_coords=np.array([[0.0, 0.0, 100.0, 50.0]]),
texts=np.array(["Extracted text"], dtype=object),
element_class_ids=np.array([0]),
element_class_id_map={0: "UncategorizedText"},
)
merged = array_merge_inferred_layout_with_extracted_layout(
inferred_layout=inferred,
extracted_layout=extracted,
page_image_size=(200, 200),
)
# The inferred subregion is removed; only the extracted region remains.
assert len(merged) == 1
assert list(merged.texts) == ["Extracted text"]
@@ -0,0 +1,675 @@
from collections import namedtuple
from typing import Optional
from unittest.mock import MagicMock, patch
import numpy as np
import pandas as pd
import pytest
import unstructured_pytesseract
from lxml import etree
from PIL import Image, UnidentifiedImageError
from pypdfium2 import PdfiumError
from unstructured_inference.inference.elements import EmbeddedTextRegion, TextRegion, TextRegions
from unstructured_inference.inference.layout import DocumentLayout, PageLayout
from unstructured_inference.inference.layoutelement import (
LayoutElement,
LayoutElements,
)
from test_unstructured.unit_utils import example_doc_path
from unstructured.documents.elements import ElementType
from unstructured.partition.pdf_image import ocr
from unstructured.partition.pdf_image.pdf_image_utils import (
convert_pdf_to_images,
pad_element_bboxes,
)
from unstructured.partition.utils.config import env_config
from unstructured.partition.utils.constants import (
OCR_AGENT_PADDLE,
OCR_AGENT_TESSERACT,
Source,
)
from unstructured.partition.utils.ocr_models.google_vision_ocr import OCRAgentGoogleVision
from unstructured.partition.utils.ocr_models.ocr_interface import OCRAgent
from unstructured.partition.utils.ocr_models.paddle_ocr import OCRAgentPaddle
from unstructured.partition.utils.ocr_models.tesseract_ocr import (
OCRAgentTesseract,
zoom_image,
)
@pytest.mark.parametrize(
("is_image", "expected_error"),
[
(True, UnidentifiedImageError),
(False, PdfiumError),
],
)
def test_process_data_with_ocr_invalid_file(is_image, expected_error):
invalid_data = b"i am not a valid file"
with pytest.raises(expected_error):
_ = ocr.process_data_with_ocr(
data=invalid_data,
is_image=is_image,
out_layout=DocumentLayout(),
extracted_layout=[],
)
@pytest.mark.parametrize("is_image", [True, False])
def test_process_file_with_ocr_invalid_filename(is_image):
invalid_filename = "i am not a valid file name"
with pytest.raises(FileNotFoundError):
_ = ocr.process_file_with_ocr(
filename=invalid_filename,
is_image=is_image,
out_layout=DocumentLayout(),
extracted_layout=[],
)
def test_supplement_page_layout_with_ocr_invalid_ocr():
with pytest.raises(ValueError):
_ = ocr.supplement_page_layout_with_ocr(
page_layout=None, image=None, ocr_agent="invliad_ocr"
)
def test_get_ocr_layout_from_image_tesseract(monkeypatch):
monkeypatch.setattr(
OCRAgentTesseract,
"image_to_data_with_character_confidence_filter",
lambda *args, **kwargs: pd.DataFrame(
{
"left": [10, 20, 30, 0],
"top": [5, 15, 25, 0],
"width": [15, 25, 35, 0],
"height": [10, 20, 30, 0],
"text": ["Hello", "World", "!", ""],
},
),
)
image = Image.new("RGB", (100, 100))
ocr_agent = OCRAgentTesseract()
ocr_layout = ocr_agent.get_layout_from_image(image)
expected_layout = TextRegions(
element_coords=np.array([[10.0, 5, 25, 15], [20, 15, 45, 35], [30, 25, 65, 55]]),
texts=np.array(["Hello", "World", "!"]),
sources=np.array([Source.OCR_TESSERACT] * 3),
)
assert ocr_layout.texts.tolist() == expected_layout.texts.tolist()
np.testing.assert_array_equal(ocr_layout.element_coords, expected_layout.element_coords)
np.testing.assert_array_equal(ocr_layout.sources, expected_layout.sources)
def mock_ocr(*args, **kwargs):
return [
[
(
[(10, 5), (25, 5), (25, 15), (10, 15)],
["Hello"],
),
],
[
(
[(20, 15), (45, 15), (45, 35), (20, 35)],
["World"],
),
],
[
(
[(30, 25), (65, 25), (65, 55), (30, 55)],
["!"],
),
],
[
(
[(0, 0), (0, 0), (0, 0), (0, 0)],
[""],
),
],
]
def monkeypatch_load_agent(*args):
class MockAgent:
def __init__(self):
self.ocr = mock_ocr
return MockAgent()
def test_get_ocr_layout_from_image_paddle(monkeypatch):
monkeypatch.setattr(
OCRAgentPaddle,
"load_agent",
monkeypatch_load_agent,
)
image = Image.new("RGB", (100, 100))
ocr_layout = OCRAgentPaddle().get_layout_from_image(image)
expected_layout = TextRegions(
element_coords=np.array([[10.0, 5, 25, 15], [20, 15, 45, 35], [30, 25, 65, 55]]),
texts=np.array(["Hello", "World", "!"]),
sources=np.array([Source.OCR_PADDLE] * 3),
)
assert ocr_layout.texts.tolist() == expected_layout.texts.tolist()
np.testing.assert_array_equal(ocr_layout.element_coords, expected_layout.element_coords)
np.testing.assert_array_equal(ocr_layout.sources, expected_layout.sources)
def test_get_ocr_text_from_image_tesseract(monkeypatch):
monkeypatch.setattr(
unstructured_pytesseract,
"image_to_string",
lambda *args, **kwargs: "Hello World",
)
image = Image.new("RGB", (100, 100))
ocr_agent = OCRAgentTesseract()
ocr_text = ocr_agent.get_text_from_image(image)
assert ocr_text == "Hello World"
def test_get_ocr_text_from_image_paddle(monkeypatch):
monkeypatch.setattr(
OCRAgentPaddle,
"load_agent",
monkeypatch_load_agent,
)
image = Image.new("RGB", (100, 100))
ocr_agent = OCRAgentPaddle()
ocr_text = ocr_agent.get_text_from_image(image)
assert ocr_text == "Hello\n\nWorld\n\n!"
@pytest.fixture()
def google_vision_text_annotation():
from google.cloud.vision import (
Block,
BoundingPoly,
Page,
Paragraph,
Symbol,
TextAnnotation,
Vertex,
Word,
)
breaks = TextAnnotation.DetectedBreak.BreakType
symbols_hello = [Symbol(text=c) for c in "Hello"] + [
Symbol(
property=TextAnnotation.TextProperty(
detected_break=TextAnnotation.DetectedBreak(type_=breaks.SPACE)
)
)
]
symbols_world = [Symbol(text=c) for c in "World!"] + [
Symbol(
property=TextAnnotation.TextProperty(
detected_break=TextAnnotation.DetectedBreak(type_=breaks.LINE_BREAK)
)
)
]
words = [Word(symbols=symbols_hello), Word(symbols=symbols_world)]
bounding_box = BoundingPoly(
vertices=[Vertex(x=0, y=0), Vertex(x=0, y=10), Vertex(x=10, y=10), Vertex(x=10, y=0)]
)
paragraphs = [Paragraph(words=words, bounding_box=bounding_box)]
blocks = [Block(paragraphs=paragraphs)]
pages = [Page(blocks=blocks)]
return TextAnnotation(text="Hello World!", pages=pages)
@pytest.fixture()
def google_vision_client(google_vision_text_annotation):
Response = namedtuple("Response", "full_text_annotation")
class FakeGoogleVisionClient:
def document_text_detection(self, image, image_context):
return Response(full_text_annotation=google_vision_text_annotation)
class OCRAgentFakeGoogleVision(OCRAgentGoogleVision):
def __init__(self, language: Optional[str] = None):
self.client = FakeGoogleVisionClient()
self.language = language
return OCRAgentFakeGoogleVision()
def test_get_ocr_from_image_google_vision(google_vision_client):
image = Image.new("RGB", (100, 100))
ocr_agent = google_vision_client
ocr_text = ocr_agent.get_text_from_image(image)
assert ocr_text == "Hello World!"
def test_get_layout_from_image_google_vision(google_vision_client):
image = Image.new("RGB", (100, 100))
ocr_agent = google_vision_client
regions = ocr_agent.get_layout_from_image(image)
assert len(regions) == 1
assert regions.texts[0] == "Hello World!"
assert all(source == Source.OCR_GOOGLEVISION for source in regions.sources)
assert regions.x1[0] == 0
assert regions.y1[0] == 0
assert regions.x2[0] == 10
assert regions.y2[0] == 10
def test_get_layout_elements_from_image_google_vision(google_vision_client):
image = Image.new("RGB", (100, 100))
ocr_agent = google_vision_client
layout_elements = ocr_agent.get_layout_elements_from_image(image)
assert len(layout_elements) == 1
@pytest.fixture()
def mock_ocr_regions():
return TextRegions.from_list(
[
EmbeddedTextRegion.from_coords(10, 10, 90, 90, text="0", source=None),
EmbeddedTextRegion.from_coords(200, 200, 300, 300, text="1", source=None),
EmbeddedTextRegion.from_coords(500, 320, 600, 350, text="3", source=None),
]
)
@pytest.fixture()
def mock_out_layout(mock_embedded_text_regions):
return LayoutElements.from_list(
[
LayoutElement(
text="",
source=None,
type="Text",
bbox=r.bbox,
)
for r in mock_embedded_text_regions
]
)
def test_aggregate_ocr_text_by_block():
expected = "A Unified Toolkit"
ocr_layout = [
TextRegion.from_coords(0, 0, 20, 20, "A"),
TextRegion.from_coords(50, 50, 150, 150, "Unified"),
TextRegion.from_coords(150, 150, 300, 250, "Toolkit"),
TextRegion.from_coords(200, 250, 300, 350, "Deep"),
]
region = TextRegion.from_coords(0, 0, 250, 350, "")
text = ocr.aggregate_ocr_text_by_block(ocr_layout, region, 0.5)
assert text == expected
@pytest.mark.parametrize("zoom", [1, 0.1, 5, -1, 0])
def test_zoom_image(zoom):
image = Image.new("RGB", (100, 100))
width, height = image.size
new_image = zoom_image(image, zoom)
new_w, new_h = new_image.size
if zoom <= 0:
zoom = 1
assert new_w == np.round(width * zoom, 0)
assert new_h == np.round(height * zoom, 0)
@pytest.fixture()
def mock_layout(mock_embedded_text_regions):
return LayoutElements.from_list(
[
LayoutElement(text=r.text, type=ElementType.UNCATEGORIZED_TEXT, bbox=r.bbox)
for r in mock_embedded_text_regions
]
)
def test_supplement_layout_with_ocr_elements(mock_layout, mock_ocr_regions):
ocr_elements = [
LayoutElement(text=r.text, source=None, type=ElementType.UNCATEGORIZED_TEXT, bbox=r.bbox)
for r in mock_ocr_regions.as_list()
]
final_layout = ocr.supplement_layout_with_ocr_elements(mock_layout, mock_ocr_regions).as_list()
# Check if the final layout contains the original layout elements
for element in mock_layout.as_list():
assert element in final_layout
# Check if the final layout contains the OCR-derived elements
assert any(ocr_element in final_layout for ocr_element in ocr_elements)
# Check if the OCR-derived elements that are subregions of layout elements are removed
for element in mock_layout.as_list():
for ocr_element in ocr_elements:
if ocr_element.bbox.is_almost_subregion_of(
element.bbox,
env_config.OCR_LAYOUT_SUBREGION_THRESHOLD,
):
assert ocr_element not in final_layout
def test_merge_out_layout_with_ocr_layout(mock_out_layout, mock_ocr_regions):
ocr_elements = [
LayoutElement(text=r.text, source=None, type=ElementType.UNCATEGORIZED_TEXT, bbox=r.bbox)
for r in mock_ocr_regions.as_list()
]
input_layout_elements = mock_out_layout.as_list()
final_layout = ocr.merge_out_layout_with_ocr_layout(
mock_out_layout,
mock_ocr_regions,
).as_list()
# Check if the out layout's text attribute is updated with aggregated OCR text
assert final_layout[0].text == mock_ocr_regions.texts[2]
# Check if the final layout contains both original elements and OCR-derived elements
# The first element's text is modified by the ocr regions so it won't be the same as the input
assert all(element in final_layout for element in input_layout_elements[1:])
assert final_layout[0].bbox == input_layout_elements[0].bbox
assert any(element in final_layout for element in ocr_elements)
@pytest.mark.parametrize(
("padding", "expected_bbox"),
[
(5, (5, 15, 35, 45)),
(-3, (13, 23, 27, 37)),
(2.5, (7.5, 17.5, 32.5, 42.5)),
(-1.5, (11.5, 21.5, 28.5, 38.5)),
],
)
def test_pad_element_bboxes(padding, expected_bbox):
element = LayoutElement.from_coords(
x1=10,
y1=20,
x2=30,
y2=40,
text="",
source=None,
type=ElementType.UNCATEGORIZED_TEXT,
)
expected_original_element_bbox = (10, 20, 30, 40)
padded_element = pad_element_bboxes(element, padding)
padded_element_bbox = (
padded_element.bbox.x1,
padded_element.bbox.y1,
padded_element.bbox.x2,
padded_element.bbox.y2,
)
assert padded_element_bbox == expected_bbox
# make sure the original element has not changed
original_element_bbox = (element.bbox.x1, element.bbox.y1, element.bbox.x2, element.bbox.y2)
assert original_element_bbox == expected_original_element_bbox
@pytest.fixture()
def table_element():
table = LayoutElement.from_coords(x1=10, y1=20, x2=50, y2=70, text="I am a table", type="Table")
return table
@pytest.fixture()
def mock_ocr_layout():
return TextRegions.from_list(
[
TextRegion.from_coords(x1=15, y1=25, x2=35, y2=45, text="Token1"),
TextRegion.from_coords(x1=40, y1=30, x2=45, y2=50, text="Token2"),
]
)
def test_supplement_element_with_table_extraction():
from unstructured_inference.models import tables
tables.load_agent()
image = next(convert_pdf_to_images(example_doc_path("pdf/single_table.pdf")))
elements = LayoutElements(
element_coords=np.array([[215.00109863, 731.89996338, 1470.07739258, 972.83129883]]),
texts=np.array(["foo"]),
sources=np.array(["yolox_sg"]),
element_class_ids=np.array([0]),
element_class_id_map={0: "Table"},
)
supplemented = ocr.supplement_element_with_table_extraction(
elements=elements,
image=image,
tables_agent=tables.tables_agent,
ocr_agent=ocr.OCRAgent.get_agent(language="eng"),
)
assert supplemented.text_as_html[0].startswith("<table>")
def test_get_table_tokens(mock_ocr_layout):
with patch.object(OCRAgentTesseract, "get_layout_from_image", return_value=mock_ocr_layout):
ocr_agent = OCRAgent.get_agent(language="eng")
table_tokens = ocr.get_table_tokens(table_element_image=None, ocr_agent=ocr_agent)
expected_tokens = [
{
"bbox": [15, 25, 35, 45],
"text": "Token1",
"span_num": 0,
"line_num": 0,
"block_num": 0,
},
{
"bbox": [40, 30, 45, 50],
"text": "Token2",
"span_num": 1,
"line_num": 0,
"block_num": 0,
},
]
assert table_tokens == expected_tokens
def test_auto_zoom_not_exceed_tesseract_limit(monkeypatch):
monkeypatch.setenv("TESSERACT_MIN_TEXT_HEIGHT", "1000")
monkeypatch.setenv("TESSERACT_OPTIMUM_TEXT_HEIGHT", "100000")
monkeypatch.setattr(
OCRAgentTesseract,
"image_to_data_with_character_confidence_filter",
lambda *args, **kwargs: pd.DataFrame(
{
"left": [10, 20, 30, 0],
"top": [5, 15, 25, 0],
"width": [15, 25, 35, 0],
"height": [10, 20, 30, 0],
"text": ["Hello", "World", "!", ""],
},
),
)
image = Image.new("RGB", (1000, 1000))
ocr_agent = OCRAgentTesseract()
# tests that the code can run instead of oom and OCR results make sense
assert ocr_agent.get_layout_from_image(image).texts.tolist() == [
"Hello",
"World",
"!",
]
def test_merge_out_layout_with_cid_code(mock_out_layout, mock_ocr_regions):
# the code should ignore this invalid text and use ocr region's text
mock_out_layout.texts = mock_out_layout.texts.astype(object)
mock_out_layout.texts[0] = "(cid:10)(cid:5)?"
ocr_elements = [
LayoutElement(text=r.text, source=None, type=ElementType.UNCATEGORIZED_TEXT, bbox=r.bbox)
for r in mock_ocr_regions.as_list()
]
input_layout_elements = mock_out_layout.as_list()
# TODO (yao): refactor the tests to check the array data structure directly instead of
# converting them into lists first (this includes other tests in this file)
final_layout = ocr.merge_out_layout_with_ocr_layout(mock_out_layout, mock_ocr_regions).as_list()
# Check if the out layout's text attribute is updated with aggregated OCR text
assert final_layout[0].text == mock_ocr_regions.texts[2]
# Check if the final layout contains both original elements and OCR-derived elements
assert all(element in final_layout for element in input_layout_elements[1:])
assert any(element in final_layout for element in ocr_elements)
def _create_hocr_word_span(
characters: list[tuple[str, str]], word_bbox: tuple[int, int, int, int], namespace_map: dict
) -> etree.Element:
word_span = [
'<root xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">\n',
(
f"<span class='ocrx_word' title='"
f"bbox {word_bbox[0]} {word_bbox[1]} {word_bbox[2]} {word_bbox[3]}"
f"; x_wconf 64'>"
),
]
for char, x_conf in characters:
word_span.append(
f"<span class='ocrx_cinfo' title='x_bboxes 0 0 0 0; x_conf {x_conf}'>{char}</span>"
)
word_span.append("</span>")
word_span.append("</root>")
root = etree.fromstring("\n".join(word_span))
return root
def test_extract_word_from_hocr():
characters = [
("w", "99.0"),
("o", "98.5"),
("r", "97.5"),
("d", "96.0"),
("!", "50.0"),
("@", "45.0"),
]
word_bbox = (10, 9, 70, 22)
agent = OCRAgentTesseract()
word_span = _create_hocr_word_span(characters, word_bbox, agent.hocr_namespace)
text = agent.extract_word_from_hocr(word_span, 0.0)
assert text == "word!@"
text = agent.extract_word_from_hocr(word_span, 0.960)
assert text == "word"
text = agent.extract_word_from_hocr(word_span, 0.990)
assert text == "w"
text = agent.extract_word_from_hocr(word_span, 0.999)
assert text == ""
def test_hocr_to_dataframe():
characters = [
("w", "99.0"),
("o", "98.5"),
("r", "97.5"),
("d", "96.0"),
("!", "50.0"),
("@", "45.0"),
]
word_bbox = (10, 9, 70, 22)
agent = OCRAgentTesseract()
hocr = etree.tostring(_create_hocr_word_span(characters, word_bbox, agent.hocr_namespace))
df = agent.hocr_to_dataframe(hocr=hocr, character_confidence_threshold=0.960)
assert df.shape == (1, 5)
assert df["left"].iloc[0] == 10
assert df["top"].iloc[0] == 9
assert df["width"].iloc[0] == 60
assert df["height"].iloc[0] == 13
assert df["text"].iloc[0] == "word"
def test_hocr_to_dataframe_when_no_prediction_empty_df():
df = OCRAgentTesseract().hocr_to_dataframe(hocr="")
assert df.shape == (0, 5)
assert "left" in df.columns
assert "top" in df.columns
assert "width" in df.columns
assert "height" in df.columns
assert "text" in df.columns
@pytest.fixture
def mock_page(mock_ocr_layout, mock_layout):
mock_page = MagicMock(PageLayout)
mock_page.elements_array = mock_layout
return mock_page
def test_supplement_layout_with_ocr(mock_ocr_get_instance, mocker, mock_page):
from unstructured.partition.pdf_image.ocr import OCRAgent
mocker.patch.object(OCRAgent, "get_layout_from_image", return_value=mock_ocr_layout)
ocr.supplement_page_layout_with_ocr(
mock_page,
Image.new("RGB", (100, 100)),
infer_table_structure=True,
ocr_agent=OCR_AGENT_TESSERACT,
ocr_languages="eng",
table_ocr_agent=OCR_AGENT_PADDLE,
)
assert mock_ocr_get_instance.call_args_list[0][1] == {
"language": "eng",
"ocr_agent_module": OCR_AGENT_TESSERACT,
}
assert mock_ocr_get_instance.call_args_list[1][1] == {
"language": "en",
"ocr_agent_module": OCR_AGENT_PADDLE,
}
def test_pass_down_agents(mock_ocr_get_instance, mocker, mock_page):
from unstructured.partition.pdf_image.ocr import OCRAgent, PILImage
mocker.patch.object(OCRAgent, "get_layout_from_image", return_value=mock_ocr_layout)
mocker.patch.object(PILImage, "open", return_value=Image.new("RGB", (100, 100)))
doc = MagicMock(DocumentLayout)
doc.pages = [mock_page]
ocr.process_file_with_ocr(
"foo",
doc,
[],
infer_table_structure=True,
is_image=True,
ocr_agent=OCR_AGENT_PADDLE,
ocr_languages="eng",
table_ocr_agent=OCR_AGENT_TESSERACT,
)
assert mock_ocr_get_instance.call_args_list[0][1] == {
"language": "en",
"ocr_agent_module": OCR_AGENT_PADDLE,
}
assert mock_ocr_get_instance.call_args_list[1][1] == {
"language": "eng",
"ocr_agent_module": OCR_AGENT_TESSERACT,
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,464 @@
import base64
import io
import os
import tempfile
from unittest.mock import MagicMock, patch
import numpy as np
import pytest
from PIL import Image as PILImg
from unstructured_inference.inference import pdf_image
from test_unstructured.unit_utils import example_doc_path
from unstructured.documents.coordinates import PixelSpace
from unstructured.documents.elements import ElementMetadata, ElementType, Image, Table
from unstructured.errors import UnprocessableEntityError
from unstructured.partition.pdf_image import pdf_image_utils
@pytest.mark.parametrize("image_type", ["pil", "numpy_array"])
def test_write_image(image_type):
mock_pil_image = PILImg.new("RGB", (50, 50))
mock_numpy_image = np.zeros((50, 50, 3), np.uint8)
image_map = {
"pil": mock_pil_image,
"numpy_array": mock_numpy_image,
}
image = image_map[image_type]
with tempfile.TemporaryDirectory() as tmpdir:
output_image_path = os.path.join(tmpdir, "test_image.jpg")
pdf_image_utils.write_image(image, output_image_path)
assert os.path.exists(output_image_path)
# Additional check to see if the written image can be read
read_image = PILImg.open(output_image_path)
assert read_image is not None
@pytest.mark.parametrize("file_mode", ["filename", "rb"])
@pytest.mark.parametrize("path_only", [True, False])
def test_convert_pdf_to_image(file_mode, path_only):
filename = example_doc_path("pdf/embedded-images.pdf")
with tempfile.TemporaryDirectory() as tmpdir:
if file_mode == "filename":
images = pdf_image_utils.convert_pdf_to_image(
filename=filename,
file=None,
output_folder=tmpdir,
path_only=path_only,
)
else:
with open(filename, "rb") as f:
images = pdf_image_utils.convert_pdf_to_image(
filename="",
file=f,
output_folder=tmpdir,
path_only=path_only,
)
if path_only:
assert isinstance(images[0], str)
else:
assert isinstance(images[0], PILImg.Image)
def test_convert_pdf_to_image_raises_unprocessable_when_render_too_large():
with patch.object(
pdf_image_utils,
"render_pdf_to_image",
side_effect=pdf_image.PdfRenderTooLargeError("too many pixels"),
):
with pytest.raises(UnprocessableEntityError, match="too many pixels"):
pdf_image_utils.convert_pdf_to_image(filename="example.pdf")
def test_convert_pdf_to_images_raises_unprocessable_when_render_too_large():
with (
patch.object(pdf_image_utils.pdf2image, "pdfinfo_from_path", return_value={"Pages": 1}),
patch.object(
pdf_image_utils,
"render_pdf_to_image",
side_effect=pdf_image.PdfRenderTooLargeError("too many pixels"),
),
):
with pytest.raises(UnprocessableEntityError, match="too many pixels"):
list(pdf_image_utils.convert_pdf_to_images(filename="example.pdf"))
@pytest.mark.parametrize("file_mode", ["filename", "rb"])
@pytest.mark.parametrize("path_only", [True, False])
def test_convert_pdf_to_image_twice(file_mode, path_only):
filename = example_doc_path("pdf/embedded-images.pdf")
with tempfile.TemporaryDirectory() as tmpdir:
if file_mode == "filename":
images = pdf_image_utils.convert_pdf_to_image(
filename=filename,
file=None,
output_folder=tmpdir,
path_only=path_only,
)
images = pdf_image_utils.convert_pdf_to_image(
filename=filename,
file=None,
output_folder=tmpdir,
path_only=path_only,
)
else:
with open(filename, "rb") as f:
images = pdf_image_utils.convert_pdf_to_image(
filename="",
file=f,
output_folder=tmpdir,
path_only=path_only,
)
images = pdf_image_utils.convert_pdf_to_image(
filename="",
file=f,
output_folder=tmpdir,
path_only=path_only,
)
if path_only:
assert isinstance(images[0], str)
else:
assert isinstance(images[0], PILImg.Image)
def test_convert_pdf_to_image_raises_error():
filename = example_doc_path("embedded-images.pdf")
with pytest.raises(ValueError) as exc_info:
pdf_image_utils.convert_pdf_to_image(filename=filename, path_only=True, output_folder=None)
assert str(exc_info.value) == "output_folder must be specified if path_only is true"
def test_convert_pdf_to_image_rejects_both_filename_and_file():
filename = example_doc_path("pdf/embedded-images.pdf")
with tempfile.TemporaryDirectory() as tmpdir:
with open(filename, "rb") as f:
with pytest.raises(ValueError) as exc_info:
pdf_image_utils.convert_pdf_to_image(
filename=filename,
file=f,
output_folder=tmpdir,
path_only=True,
)
assert "Exactly one of" in str(exc_info.value)
@pytest.mark.parametrize(
("filename", "is_image"),
[
(example_doc_path("pdf/layout-parser-paper-fast.pdf"), False),
(example_doc_path("img/layout-parser-paper-fast.jpg"), True),
(example_doc_path("img/english-and-korean.png"), True),
],
)
@pytest.mark.parametrize("element_category_to_save", [ElementType.IMAGE, ElementType.TABLE])
@pytest.mark.parametrize("extract_image_block_to_payload", [False, True])
@pytest.mark.parametrize("horizontal_padding", [0, 20])
@pytest.mark.parametrize("vertical_padding", [0, 10])
def test_save_elements(
element_category_to_save,
extract_image_block_to_payload,
filename,
is_image,
horizontal_padding,
vertical_padding,
monkeypatch,
):
if horizontal_padding > 0:
monkeypatch.setenv("EXTRACT_IMAGE_BLOCK_CROP_HORIZONTAL_PAD", str(horizontal_padding))
if vertical_padding > 0:
monkeypatch.setenv("EXTRACT_IMAGE_BLOCK_CROP_VERTICAL_PAD", str(vertical_padding))
with tempfile.TemporaryDirectory() as tmpdir:
elements = [
Image(
text="Image Text 1",
coordinates=((78, 86), (78, 519), (512, 519), (512, 86)),
coordinate_system=PixelSpace(width=1575, height=1166),
metadata=ElementMetadata(page_number=1),
),
Image(
text="Image Text 2",
coordinates=((570, 86), (570, 519), (1003, 519), (1003, 86)),
coordinate_system=PixelSpace(width=1575, height=1166),
metadata=ElementMetadata(page_number=1),
),
Image(
text="Table 1",
coordinates=((1062, 86), (1062, 519), (1496, 519), (1496, 86)),
coordinate_system=PixelSpace(width=1575, height=1166),
metadata=ElementMetadata(page_number=1),
),
]
if not is_image:
# add a page 2 element
elements.append(
Table(
text="Table 2",
coordinates=((1062, 86), (1062, 519), (1496, 519), (1496, 86)),
coordinate_system=PixelSpace(width=1575, height=1166),
metadata=ElementMetadata(page_number=2),
),
)
pdf_image_utils.save_elements(
elements=elements,
starting_page_number=1,
element_category_to_save=element_category_to_save,
pdf_image_dpi=200,
filename=filename,
is_image=is_image,
output_dir_path=str(tmpdir),
extract_image_block_to_payload=extract_image_block_to_payload,
)
saved_elements = [el for el in elements if el.category == element_category_to_save]
for i, el in enumerate(saved_elements):
basename = "table" if el.category == ElementType.TABLE else "figure"
expected_image_path = os.path.join(
str(tmpdir), f"{basename}-{el.metadata.page_number}-{i + 1}.jpg"
)
if extract_image_block_to_payload:
assert isinstance(el.metadata.image_base64, str)
assert isinstance(el.metadata.image_mime_type, str)
image_bytes = base64.b64decode(el.metadata.image_base64)
image = PILImg.open(io.BytesIO(image_bytes))
x1, y1 = el.metadata.coordinates.points[0]
x2, y2 = el.metadata.coordinates.points[2]
width = x2 - x1
height = y2 - y1
assert image.width == width + 2 * horizontal_padding
assert image.height == height + 2 * vertical_padding
assert not el.metadata.image_path
assert not os.path.isfile(expected_image_path)
else:
assert os.path.isfile(expected_image_path)
image = PILImg.open(expected_image_path)
x1, y1 = el.metadata.coordinates.points[0]
x2, y2 = el.metadata.coordinates.points[2]
width = x2 - x1
height = y2 - y1
assert image.width == width + 2 * horizontal_padding
assert image.height == height + 2 * vertical_padding
assert el.metadata.image_path == expected_image_path
assert not el.metadata.image_base64
assert not el.metadata.image_mime_type
@pytest.mark.parametrize("storage_enabled", [False, True])
def test_save_elements_with_output_dir_path_none(monkeypatch, storage_enabled):
monkeypatch.setenv(
"GLOBAL_WORKING_DIR_ENABLED",
"true" if storage_enabled else "false",
)
with (
patch("PIL.Image.open"),
patch("unstructured.partition.pdf_image.pdf_image_utils.write_image"),
patch("unstructured.partition.pdf_image.pdf_image_utils.convert_pdf_to_image"),
tempfile.TemporaryDirectory() as tmpdir,
):
original_cwd = os.getcwd()
os.chdir(tmpdir)
pdf_image_utils.save_elements(
elements=[],
element_category_to_save="",
starting_page_number=1,
pdf_image_dpi=200,
filename="dummy.pdf",
output_dir_path=None,
)
# Verify that the images are saved in the expected directory
if storage_enabled:
from unstructured.partition.utils.config import env_config
expected_output_dir = os.path.join(env_config.GLOBAL_WORKING_PROCESS_DIR, "figures")
else:
expected_output_dir = os.path.join(tmpdir, "figures")
assert os.path.exists(expected_output_dir)
assert os.path.isdir(expected_output_dir)
os.chdir(original_cwd)
def test_write_image_raises_error():
with pytest.raises(ValueError):
pdf_image_utils.write_image("invalid_type", "test_image.jpg")
@pytest.mark.parametrize(
("text", "outcome"), [("", False), ("foo", True), (None, False), ("(cid:10)boo", False)]
)
def test_valid_text(text, outcome):
assert pdf_image_utils.valid_text(text) == outcome
@pytest.mark.parametrize(
("text", "expected"),
[
("base", 0.0),
("", 0.0),
("(cid:2)", 1.0),
("(cid:1)a", 0.5),
("c(cid:1)ab", 0.25),
],
)
def test_cid_ratio(text, expected):
assert pdf_image_utils.cid_ratio(text) == expected
@pytest.mark.parametrize(
("text", "expected"),
[
("base", False),
("(cid:2)", True),
("(cid:1234567890)", True),
("jkl;(cid:12)asdf", True),
],
)
def test_is_cid_present(text, expected):
assert pdf_image_utils.is_cid_present(text) == expected
def test_pad_bbox():
bbox = (100, 100, 200, 200)
padding = (10, 20) # Horizontal padding 10, Vertical padding 20
expected = (90, 80, 210, 220)
result = pdf_image_utils.pad_bbox(bbox, padding)
assert result == expected
@pytest.mark.parametrize(
("input_types", "expected"),
[
(None, []),
(["table", "image"], ["Table", "Image"]),
(["unknown"], ["Unknown"]),
(["Table", "image", "UnknOwn"], ["Table", "Image", "Unknown"]),
(["NarrativeText", "narrativetext"], ["NarrativeText", "NarrativeText"]),
],
)
def test_check_element_types_to_extract(input_types, expected):
assert pdf_image_utils.check_element_types_to_extract(input_types) == expected
def test_check_element_types_to_extract_raises_error():
with pytest.raises(TypeError) as exc_info:
pdf_image_utils.check_element_types_to_extract("not a list")
assert "must be a list" in str(exc_info.value)
class MockPageLayout:
def annotate(self, colors):
return "mock_image"
class MockDocumentLayout:
pages = [MockPageLayout(), MockPageLayout]
def test_annotate_layout_elements_with_image():
inferred_layout = MockPageLayout()
extracted_layout = MockPageLayout()
output_basename = "test_page"
page_number = 1
# Check if images for both layouts were saved
with (
tempfile.TemporaryDirectory() as tmpdir,
patch("unstructured.partition.pdf_image.pdf_image_utils.write_image") as mock_write_image,
):
pdf_image_utils.annotate_layout_elements_with_image(
inferred_page_layout=inferred_layout,
extracted_page_layout=extracted_layout,
output_dir_path=str(tmpdir),
output_f_basename=output_basename,
page_number=page_number,
)
expected_filenames = [
f"{output_basename}_{page_number}_inferred.jpg",
f"{output_basename}_{page_number}_extracted.jpg",
]
actual_calls = [call.args[1] for call in mock_write_image.call_args_list]
for expected_filename in expected_filenames:
assert any(expected_filename in actual_call for actual_call in actual_calls)
# Check if only the inferred layout image was saved if extracted layout is None
with (
tempfile.TemporaryDirectory() as tmpdir,
patch("unstructured.partition.pdf_image.pdf_image_utils.write_image") as mock_write_image,
):
pdf_image_utils.annotate_layout_elements_with_image(
inferred_page_layout=inferred_layout,
extracted_page_layout=None,
output_dir_path=str(tmpdir),
output_f_basename=output_basename,
page_number=page_number,
)
expected_filename = f"{output_basename}_{page_number}_inferred.jpg"
actual_calls = [call.args[1] for call in mock_write_image.call_args_list]
assert any(expected_filename in actual_call for actual_call in actual_calls)
assert len(actual_calls) == 1 # Only one image should be saved
@pytest.mark.parametrize(
("filename", "is_image"),
[
(example_doc_path("pdf/layout-parser-paper-fast.pdf"), False),
(example_doc_path("img/layout-parser-paper-fast.jpg"), True),
],
)
def test_annotate_layout_elements(filename, is_image):
inferred_document_layout = MockDocumentLayout
extracted_layout = [MagicMock(), MagicMock()]
with (
patch("PIL.Image.open"),
patch(
"unstructured.partition.pdf_image.pdf_image_utils.convert_pdf_to_image",
return_value=["/path/to/image1.jpg", "/path/to/image2.jpg"],
) as mock_pdf2image,
patch(
"unstructured.partition.pdf_image.pdf_image_utils.annotate_layout_elements_with_image"
) as mock_annotate_layout_elements_with_image,
):
pdf_image_utils.annotate_layout_elements(
inferred_document_layout=inferred_document_layout,
extracted_layout=extracted_layout,
filename=filename,
output_dir_path="/output",
pdf_image_dpi=200,
is_image=is_image,
)
if is_image:
mock_annotate_layout_elements_with_image.assert_called_once()
else:
assert mock_annotate_layout_elements_with_image.call_count == len(
mock_pdf2image.return_value
)
def test_annotate_layout_elements_file_not_found_error():
with pytest.raises(FileNotFoundError):
pdf_image_utils.annotate_layout_elements(
inferred_document_layout=MagicMock(),
extracted_layout=[],
filename="nonexistent.jpg",
output_dir_path="/output",
pdf_image_dpi=200,
is_image=True,
)
@pytest.mark.parametrize(
("text", "expected"),
[("test\tco\x0cn\ftrol\ncharacter\rs\b", "test control characters"), ("\"'\\", "\"'\\")],
)
def test_remove_control_characters(text, expected):
assert pdf_image_utils.remove_control_characters(text) == expected
@@ -0,0 +1,682 @@
import os
import tempfile
from unittest.mock import Mock, patch
import numpy as np
import pytest
from pdfminer.layout import LAParams, LTChar, LTContainer
from PIL import Image
from unstructured_inference.constants import IsExtracted
from unstructured_inference.constants import Source as InferenceSource
from unstructured_inference.inference.elements import (
EmbeddedTextRegion,
Rectangle,
TextRegion,
TextRegions,
)
from unstructured_inference.inference.layout import DocumentLayout, LayoutElement, PageLayout
from unstructured_inference.inference.layoutelement import LayoutElements
from test_unstructured.unit_utils import example_doc_path
from unstructured.partition.auto import partition
from unstructured.partition.pdf_image.pdfminer_processing import (
_deduplicate_ltchars,
_rotate_bboxes,
_validate_bbox,
aggregate_embedded_text_by_block,
bboxes1_is_almost_subregion_of_bboxes2,
boxes_self_iou,
clean_pdfminer_inner_elements,
get_widget_text_from_annots,
process_file_with_pdfminer,
remove_duplicate_elements,
text_is_embedded,
)
from unstructured.partition.utils.constants import Source
# A set of elements with pdfminer elements inside tables
deletable_elements_inside_table = [
LayoutElement(
bbox=Rectangle(0, 0, 100, 100),
text="Table with inner elements",
type="Table",
),
LayoutElement(bbox=Rectangle(50, 50, 70, 70), text="text1", source=Source.PDFMINER),
LayoutElement(bbox=Rectangle(70, 70, 80, 80), text="text2", source=Source.PDFMINER),
]
# A set of elements without pdfminer elements inside
# tables (no elements with source=Source.PDFMINER)
no_deletable_elements_inside_table = [
LayoutElement(
bbox=Rectangle(0, 0, 100, 100),
text="Table with inner elements",
type="Table",
source=InferenceSource.YOLOX,
),
LayoutElement(bbox=Rectangle(50, 50, 70, 70), text="text1", source=InferenceSource.YOLOX),
LayoutElement(bbox=Rectangle(70, 70, 80, 80), text="text2", source=InferenceSource.YOLOX),
]
# A set of elements with pdfminer elements inside tables and other
# elements with source=Source.PDFMINER
# Note: there is some elements with source=Source.PDFMINER are not inside tables
mix_elements_inside_table = [
LayoutElement(
bbox=Rectangle(0, 0, 100, 100),
text="Table1 with inner elements",
type="Table",
source=InferenceSource.YOLOX,
),
LayoutElement(bbox=Rectangle(50, 50, 70, 70), text="Inside table1"),
LayoutElement(bbox=Rectangle(70, 70, 80, 80), text="Inside table1", source=Source.PDFMINER),
LayoutElement(
bbox=Rectangle(150, 150, 170, 170),
text="Outside tables",
source=Source.PDFMINER,
),
LayoutElement(
bbox=Rectangle(180, 180, 200, 200),
text="Outside tables",
source=Source.PDFMINER,
),
LayoutElement(
bbox=Rectangle(0, 500, 100, 700),
text="Table2 with inner elements",
type="Table",
source=InferenceSource.YOLOX,
),
LayoutElement(bbox=Rectangle(0, 510, 50, 600), text="Inside table2", source=Source.PDFMINER),
LayoutElement(bbox=Rectangle(0, 550, 70, 650), text="Inside table2", source=Source.PDFMINER),
]
def test_rotate_bboxes_matches_pil_rotation_directions():
"""_rotate_bboxes mirrors PIL.Image.rotate(angle, expand=True) (counter-clockwise)."""
W, H = 100.0, 200.0 # portrait display-frame canvas
coords = np.array([[10.0, 20.0, 30.0, 60.0]])
# 0 / 360 are no-ops
assert np.array_equal(_rotate_bboxes(coords, 0, W, H), coords)
assert np.array_equal(_rotate_bboxes(coords, 360, W, H), coords)
# 90 CCW (expand): x' = y, y' = W - x
r90 = _rotate_bboxes(coords, 90, W, H)
assert np.allclose(r90, [[20.0, W - 30.0, 60.0, W - 10.0]])
# 180
r180 = _rotate_bboxes(coords, 180, W, H)
assert np.allclose(r180, [[W - 30.0, H - 60.0, W - 10.0, H - 20.0]])
# 270 CCW
assert np.allclose(_rotate_bboxes(coords, 270, W, H), [[H - 60.0, 10.0, H - 20.0, 30.0]])
# rotating 90 then 270 (about the post-rotation H x W canvas) restores the original box
assert np.allclose(_rotate_bboxes(r90, 270, H, W), coords)
# outputs remain valid bboxes (x1 < x2, y1 < y2)
for angle in (90, 180, 270):
r = _rotate_bboxes(coords, angle, W, H)
assert r[0, 0] < r[0, 2]
assert r[0, 1] < r[0, 3]
@pytest.mark.parametrize(
("bbox", "is_valid"),
[
([0, 1, 0, 1], False),
([0, 1, 1, 2], True),
([0, 1, 1, None], False),
([0, 1, 1, np.nan], False),
([0, 1, -1, 0], False),
([0, 1, -1, 2], False),
],
)
def test_valid_bbox(bbox, is_valid):
assert _validate_bbox(bbox) is is_valid
@pytest.mark.parametrize(
("elements", "length_extra_info", "expected_document_length"),
[
(deletable_elements_inside_table, 1, 1),
(no_deletable_elements_inside_table, 0, 3),
(mix_elements_inside_table, 2, 5),
],
)
def test_clean_pdfminer_inner_elements(elements, length_extra_info, expected_document_length):
# create a sample document with pdfminer elements inside tables
page = PageLayout(number=1, image=Image.new("1", (1, 1)))
page.elements_array = LayoutElements.from_list(elements)
document_with_table = DocumentLayout(pages=[page])
document = document_with_table
# call the function to clean the pdfminer inner elements
cleaned_doc = clean_pdfminer_inner_elements(document)
# check that the pdfminer elements were stored in the extra_info dictionary
assert len(cleaned_doc.pages[0].elements_array) == expected_document_length
elements_with_duplicate_images = [
LayoutElement(
bbox=Rectangle(0, 0, 100, 100),
text="Image1",
type="Image",
source=Source.PDFMINER,
),
LayoutElement(
bbox=Rectangle(10, 10, 110, 110), text="Image1", type="Image", source=Source.PDFMINER
),
LayoutElement(bbox=Rectangle(150, 150, 170, 170), text="Title1", type="Title"),
]
elements_without_duplicate_images = [
LayoutElement(
bbox=Rectangle(0, 0, 100, 100),
text="Sample image",
type="Image",
source=Source.PDFMINER,
),
LayoutElement(
bbox=Rectangle(10, 10, 110, 110),
text="Sample image with similar bbox",
type="Image",
source=Source.PDFMINER,
),
LayoutElement(
bbox=Rectangle(200, 200, 250, 250),
text="Sample image",
type="Image",
source=Source.PDFMINER,
),
LayoutElement(bbox=Rectangle(150, 150, 170, 170), text="Title1", type="Title"),
]
def test_aggregate_by_block():
expected = "Inside region1 Inside region2"
embedded_regions = TextRegions.from_list(
[
TextRegion.from_coords(0, 0, 300, 20, "Inside region1"),
TextRegion.from_coords(0, 20, 300, 80, None),
TextRegion.from_coords(0, 80, 200, 300, "Inside region2"),
TextRegion.from_coords(250, 250, 350, 350, "Outside region"),
]
)
embedded_regions.is_extracted_array = np.array([IsExtracted.TRUE] * 4)
target_region = TextRegions.from_list([TextRegion.from_coords(0, 0, 300, 300)])
text, extracted = aggregate_embedded_text_by_block(target_region, embedded_regions)
assert text == expected
assert extracted.value == "true"
def test_aggregate_only_partially_fill_target():
expected = "Inside region1"
embedded_regions = TextRegions.from_list(
[
TextRegion.from_coords(0, 0, 20, 20, "Inside region1"),
]
)
embedded_regions.is_extracted_array = np.array([IsExtracted.TRUE])
target_region = TextRegions.from_list([TextRegion.from_coords(0, 0, 300, 300)])
text, extracted = aggregate_embedded_text_by_block(target_region, embedded_regions)
assert text == expected
assert extracted.value == "partial"
def test_aggregate_not_filling_target():
embedded_regions = TextRegions.from_list(
[
TextRegion.from_coords(300, 0, 400, 20, "outside"),
]
)
embedded_regions.is_extracted_array = np.array([IsExtracted.TRUE])
target_region = TextRegions.from_list([TextRegion.from_coords(0, 0, 300, 300)])
text, extracted = aggregate_embedded_text_by_block(target_region, embedded_regions)
assert text == ""
assert extracted.value == "false"
@pytest.mark.parametrize(
("coords1", "coords2", "expected"),
[
(
[[0, 0, 10, 10], [10, 0, 20, 10], [10, 10, 20, 20]],
[[0, 0, 10, 10], [0, 0, 12, 12]],
[[True, True], [False, False], [False, False]],
),
(
[[0, 0, 10, 10], [10, 0, 20, 10], [10, 10, 20, 20]],
[[0, 0, 10, 10], [10, 10, 22, 22], [0, 0, 5, 5]],
[[True, False, False], [False, False, False], [False, True, False]],
),
(
[[0, 0, 10, 10], [10, 10, 10, 10]],
[[0, 0, 10, 10], [10, 10, 22, 22], [0, 0, 5, 5]],
[[True, False, False], [True, True, False]],
),
],
)
def test_bboxes1_is_almost_subregion_of_bboxes2(coords1, coords2, expected):
bboxes1 = [Rectangle(*row) for row in coords1]
bboxes2 = [Rectangle(*row) for row in coords2]
np.testing.assert_array_equal(
bboxes1_is_almost_subregion_of_bboxes2(bboxes1, bboxes2), expected
)
@pytest.mark.parametrize(
("coords", "threshold", "expected"),
[
(
[[0, 0, 10, 10], [2, 2, 12, 12], [10, 10, 20, 20]],
0.5,
[[True, True, False], [True, True, False], [False, False, True]],
),
(
[[0, 0, 10, 10], [2, 2, 12, 12], [10, 10, 20, 20]],
0.9,
[[True, False, False], [False, True, False], [False, False, True]],
),
(
[[0, 0, 10, 10], [10, 10, 10, 10]],
0.5,
[[True, False], [False, True]],
),
],
)
def test_boxes_self_iou(coords, threshold, expected):
bboxes = [Rectangle(*row) for row in coords]
np.testing.assert_array_equal(boxes_self_iou(bboxes, threshold), expected)
def test_remove_duplicate_elements():
sample_elements = TextRegions.from_list(
[
EmbeddedTextRegion(bbox=Rectangle(0, 0, 10, 10), text="Text 1"),
EmbeddedTextRegion(bbox=Rectangle(0, 0, 10, 10), text="Text 2"),
EmbeddedTextRegion(bbox=Rectangle(20, 20, 30, 30), text="Text 3"),
]
)
result = remove_duplicate_elements(sample_elements)
# Check that duplicates were removed and only 2 unique elements remain
assert len(result) == 2
assert result.texts.tolist() == ["Text 2", "Text 3"]
assert result.element_coords.tolist() == [[0, 0, 10, 10], [20, 20, 30, 30]]
def test_remove_duplicate_elements_dense_page_is_not_decimated():
"""Pages with more than ~2000 elements are chunked internally; the dedup mask for each
chunk must be offset by the chunk's global index. Otherwise rows in later chunks match
themselves and are wrongly dropped, decimating dense pages."""
# 2500 unique, non-overlapping boxes on a 50x50 grid (zero IoU between any two)
unique = [
EmbeddedTextRegion(
bbox=Rectangle((i % 50) * 20, (i // 50) * 20, (i % 50) * 20 + 10, (i // 50) * 20 + 10),
text=f"Text {i}",
)
for i in range(2500)
]
# one exact duplicate of the first box, appended last so the pair spans two chunks
duplicate = EmbeddedTextRegion(bbox=Rectangle(0, 0, 10, 10), text="Text 0 dup")
sample_elements = TextRegions.from_list([*unique, duplicate])
result = remove_duplicate_elements(sample_elements)
# only the single cross-chunk duplicate pair collapses; every unique box is kept
assert len(result) == 2500
# the later element of the duplicate pair is the one retained
assert "Text 0 dup" in result.texts.tolist()
assert "Text 0" not in result.texts.tolist()
def test_process_file_with_pdfminer():
layout, links = process_file_with_pdfminer(example_doc_path("pdf/layout-parser-paper-fast.pdf"))
assert len(layout)
assert "LayoutParser: A Unified Toolkit for Deep\n" in layout[0].texts
assert links[0][0]["url"] == "https://layout-parser.github.io"
def test_process_file_with_pdfminer_is_extracted_array():
layout, _ = process_file_with_pdfminer(example_doc_path("pdf/layout-parser-paper-fast.pdf"))
# first page contains rotated text that are considered low fidelity, i.e., is_extracted=partial
assert layout[0].is_extracted_array[0] is None
assert all(is_extracted is IsExtracted.TRUE for is_extracted in layout[1].is_extracted_array)
def test_process_file_hidden_ocr_text():
"""Test processing a PDF that contains hidden OCR text layer."""
layout, _ = process_file_with_pdfminer(example_doc_path("pdf/pdf-with-ocr-text.pdf"))
assert all(is_extracted is None for is_extracted in layout[0].is_extracted_array[:-1])
assert layout[0].is_extracted_array[-1] == IsExtracted.TRUE
def test_process_file_recovers_figure_overlay_text():
"""Text inside a Form XObject (LTFigure overlay) is recovered, not dropped.
Regression test: such text is real, embedded text held as loose LTChars that pdfminer does not
group into LTTextLine objects, so extract_text_objects (LTTextLine only) used to drop it. The
fixture has "Printed Name:" in the main content stream and "Jane Doe" inside a form XObject.
"""
layout, _ = process_file_with_pdfminer(example_doc_path("pdf/figure-overlay-text.pdf"))
texts = " ".join(str(t) for page in layout for t in page.texts if t)
assert "Printed Name:" in texts # main content stream
assert "Jane Doe" in texts # figure-overlay text (dropped before the fix)
# A synthetic AcroForm: filled text fields whose values live only in widget annotations
# (the page content stream is empty), plus one empty field that must be skipped.
SYNTHETIC_FORM_FIELDS = [
("name", "Jane Doe", (40, 700, 300, 720)),
("date of birth", "1990-01-01", (40, 650, 300, 670)),
("address", "123 Main Street", (40, 600, 300, 620)),
("phone", "", (40, 550, 300, 570)), # empty -> should be skipped
]
def _build_synthetic_form_pdf(path: str) -> None:
"""Write a 1-page PDF whose only text lives in AcroForm text-field (/Tx) widgets.
The page content stream is empty, so pdfminer's normal text pass yields nothing; the
values are reachable only through the widget annotations in ``page.annots``.
"""
from pypdf import PdfWriter
from pypdf.generic import (
ArrayObject,
DictionaryObject,
NameObject,
NumberObject,
TextStringObject,
)
writer = PdfWriter()
writer.add_blank_page(width=612, height=792)
page = writer.pages[0]
refs = []
for field_name, value, rect in SYNTHETIC_FORM_FIELDS:
widget = DictionaryObject()
widget[NameObject("/Type")] = NameObject("/Annot")
widget[NameObject("/Subtype")] = NameObject("/Widget")
widget[NameObject("/FT")] = NameObject("/Tx")
widget[NameObject("/T")] = TextStringObject(field_name)
widget[NameObject("/V")] = TextStringObject(value)
widget[NameObject("/Rect")] = ArrayObject([NumberObject(c) for c in rect])
refs.append(writer._add_object(widget))
page[NameObject("/Annots")] = ArrayObject(refs)
acro_form = DictionaryObject()
acro_form[NameObject("/Fields")] = ArrayObject(refs)
writer._root_object[NameObject("/AcroForm")] = writer._add_object(acro_form)
with open(path, "wb") as f:
writer.write(f)
def test_get_widget_text_from_annots_extracts_filled_text_fields():
"""The widget helper recovers filled /Tx field values and skips empty ones."""
from pdfminer.pdfpage import PDFPage
with tempfile.TemporaryDirectory() as tmp_dir:
pdf_path = os.path.join(tmp_dir, "form.pdf")
_build_synthetic_form_pdf(pdf_path)
with open(pdf_path, "rb") as fp:
page = next(PDFPage.get_pages(fp))
widgets = get_widget_text_from_annots(page.annots, height=792)
texts = [w["text"] for w in widgets]
assert texts == ["Jane Doe", "1990-01-01", "123 Main Street"] # empty "phone" skipped
# Every widget carries a valid bounding box.
assert all(_validate_bbox(w["bbox"]) for w in widgets)
def test_get_widget_text_from_annots_decodes_utf16_text_without_bom():
from pdfminer.psparser import PSLiteral
widgets = get_widget_text_from_annots(
[
{
"Subtype": PSLiteral("Widget"),
"FT": PSLiteral("Tx"),
"V": b"\xfe\xff\x00J\x00a\x00n\x00e",
"Rect": (10, 80, 90, 95),
}
],
height=100,
)
assert widgets == [{"text": "Jane", "bbox": (10.0, 5.0, 90.0, 20.0)}]
def test_get_widget_text_from_annots_decodes_choice_field_value_arrays():
from pdfminer.psparser import PSLiteral
widgets = get_widget_text_from_annots(
[
{
"Subtype": PSLiteral("Widget"),
"FT": PSLiteral("Ch"),
"V": [PSLiteral("ChoiceA"), b"\xfe\xff\x00C\x00h\x00o\x00i\x00c\x00e\x00B"],
"Rect": (10, 70, 90, 95),
}
],
height=100,
)
assert widgets == [{"text": "ChoiceA\nChoiceB", "bbox": (10.0, 5.0, 90.0, 30.0)}]
def test_get_widget_text_from_annots_inherits_field_type_and_value_from_parent():
"""FT/V absent on the widget are inherited by walking up the /Parent chain.
The intermediate parent is a direct dict (the case PDFObjRef-only traversal missed) and
the root parent carries the inherited /FT, exercising multi-level inheritance.
"""
from pdfminer.psparser import PSLiteral
root_parent = {"FT": PSLiteral("Tx")} # field type lives at the top of the hierarchy
mid_parent = {"V": b"\xfe\xff\x00J\x00a\x00n\x00e", "Parent": root_parent} # value mid-chain
widgets = get_widget_text_from_annots(
[
{
"Subtype": PSLiteral("Widget"),
"Parent": mid_parent, # neither FT nor V on the widget itself
"Rect": (10, 80, 90, 95),
}
],
height=100,
)
assert widgets == [{"text": "Jane", "bbox": (10.0, 5.0, 90.0, 20.0)}]
def test_process_file_with_pdfminer_recovers_form_field_text():
"""The extracted (hi_res) layer includes AcroForm field values as text regions."""
with tempfile.TemporaryDirectory() as tmp_dir:
pdf_path = os.path.join(tmp_dir, "form.pdf")
_build_synthetic_form_pdf(pdf_path)
layout, _ = process_file_with_pdfminer(pdf_path)
texts = [str(t) for t in layout[0].texts if t]
assert "Jane Doe" in texts
assert "1990-01-01" in texts
assert "123 Main Street" in texts
# Widget-sourced regions are marked as extracted text.
assert IsExtracted.TRUE in list(layout[0].is_extracted_array)
def test_partition_pdf_fast_recovers_form_field_text():
"""End-to-end: the fast strategy emits elements for filled form fields."""
from unstructured.partition.pdf import partition_pdf
with tempfile.TemporaryDirectory() as tmp_dir:
pdf_path = os.path.join(tmp_dir, "form.pdf")
_build_synthetic_form_pdf(pdf_path)
elements = partition_pdf(filename=pdf_path, strategy="fast")
blob = "\n".join(el.text for el in elements)
assert "Jane Doe" in blob
assert "1990-01-01" in blob
assert "123 Main Street" in blob
@patch("unstructured.partition.pdf_image.pdfminer_utils.LAParams", return_value=LAParams())
def test_laprams_are_passed_from_partition_to_pdfminer(pdfminer_mock):
partition(
filename=example_doc_path("pdf/layout-parser-paper-fast.pdf"),
pdfminer_line_margin=1.123,
pdfminer_char_margin=None,
pdfminer_line_overlap=0.0123,
pdfminer_word_margin=3.21,
)
assert pdfminer_mock.call_args.kwargs == {
"line_margin": 1.123,
"line_overlap": 0.0123,
"word_margin": 3.21,
}
def create_mock_ltchar(text, invisible=False, rotated=False):
"""Create a mock LTChar object"""
graphicstate = Mock()
matrix = (1, 0.5, 0, 1, 0, 0) if rotated else (1, 0, 0, 1, 0, 0)
char = LTChar(
matrix=matrix, # transformation matrix
font=Mock(), # you'd need to mock PDFFont
fontsize=12,
scaling=1,
rise=0,
text=text,
textwidth=10,
textdisp=(0, 1),
ncs=Mock(),
graphicstate=graphicstate,
)
if invisible:
char.rendermode = 3
else:
char.rendermode = 0
return char
def create_mock_ltcontainer(chars):
"""Create a mock LTContainer with LTChar objects"""
container = LTContainer(bbox=(0, 0, 1, 1))
# The container should be iterable
container.extend(chars)
return container
# Now you can use it in tests
def test_text_is_embedded():
chars = [
create_mock_ltchar("H"),
create_mock_ltchar("e"),
create_mock_ltchar("l"),
create_mock_ltchar("l", rotated=True),
create_mock_ltchar("o", invisible=True),
]
container = create_mock_ltcontainer(chars)
assert text_is_embedded(container, threshold=0.5)
assert not text_is_embedded(container, threshold=0.3)
# -- Tests for _deduplicate_ltchars (fake bold fix) --
def _create_positioned_ltchar(text: str, x0: float, y0: float) -> LTChar:
"""Create an LTChar with a specific position for deduplication testing."""
graphicstate = Mock()
# Matrix format: (a, b, c, d, e, f) where e=x, f=y for translation
matrix = (1, 0, 0, 1, x0, y0)
char = LTChar(
matrix=matrix,
font=Mock(),
fontsize=12,
scaling=1,
rise=0,
text=text,
textwidth=10,
textdisp=(0, 1),
ncs=Mock(),
graphicstate=graphicstate,
)
return char
class TestDeduplicateLtchars:
"""Tests for _deduplicate_ltchars function."""
def test_empty_list_returns_empty(self):
"""Empty character list should return empty list."""
result = _deduplicate_ltchars([], threshold=3.0)
assert result == []
def test_threshold_zero_disables_deduplication(self):
"""Threshold of 0 should disable deduplication and return original list."""
chars = [
_create_positioned_ltchar("A", 10.0, 20.0),
_create_positioned_ltchar("A", 10.5, 20.0), # Would be duplicate
]
result = _deduplicate_ltchars(chars, threshold=0)
assert len(result) == 2
def test_fake_bold_duplicates_removed(self):
"""Fake bold (double-rendered) characters should be deduplicated."""
# Simulate "AB" rendered as "AABB" with fake bold
chars = [
_create_positioned_ltchar("A", 10.0, 20.0),
_create_positioned_ltchar("A", 10.5, 20.0), # Duplicate - close position
_create_positioned_ltchar("B", 25.0, 20.0),
_create_positioned_ltchar("B", 25.5, 20.0), # Duplicate - close position
]
result = _deduplicate_ltchars(chars, threshold=3.0)
assert len(result) == 2
assert result[0].get_text() == "A"
assert result[1].get_text() == "B"
def test_legitimate_repeated_chars_preserved(self):
"""Legitimate repeated characters at different positions should be preserved."""
# "AA" where both A's are at legitimately different positions
chars = [
_create_positioned_ltchar("A", 10.0, 20.0),
_create_positioned_ltchar("A", 25.0, 20.0), # Far enough - not duplicate
]
result = _deduplicate_ltchars(chars, threshold=3.0)
assert len(result) == 2
def test_single_char_returns_single(self):
"""Single character should return single character."""
chars = [_create_positioned_ltchar("X", 10.0, 20.0)]
result = _deduplicate_ltchars(chars, threshold=3.0)
assert len(result) == 1
assert result[0].get_text() == "X"
def test_mixed_duplicates_and_normal(self):
"""Mix of duplicated and normal characters should be handled correctly."""
# "HELLO" where only H and L are fake-bold
chars = [
_create_positioned_ltchar("H", 10.0, 20.0),
_create_positioned_ltchar("H", 10.5, 20.0), # Duplicate
_create_positioned_ltchar("E", 20.0, 20.0), # Normal
_create_positioned_ltchar("L", 30.0, 20.0),
_create_positioned_ltchar("L", 30.5, 20.0), # Duplicate
_create_positioned_ltchar("L", 40.0, 20.0), # Second L (normal, different position)
_create_positioned_ltchar("O", 50.0, 20.0), # Normal
]
result = _deduplicate_ltchars(chars, threshold=3.0)
assert len(result) == 5
text = "".join(c.get_text() for c in result)
assert text == "HELLO"
@@ -0,0 +1,791 @@
from importlib import reload
from unittest.mock import MagicMock, patch
from pdfminer.layout import LTChar, LTContainer, LTFigure, LTLayoutContainer, LTTextLine
from pdfminer.pdftypes import PDFStream
from test_unstructured.unit_utils import example_doc_path
from unstructured.partition.pdf import partition_pdf
from unstructured.partition.pdf_image.pdfminer_utils import (
CustomPDFPageInterpreter,
_is_duplicate_char,
deduplicate_chars_in_text_line,
extract_text_objects,
get_text_with_deduplication,
)
from unstructured.partition.utils import config as partition_config
def _make_char():
return LTChar(
matrix=(1, 0, 0, 1, 0, 0),
font=MagicMock(),
fontsize=12,
scaling=1,
rise=0,
text="x",
textwidth=10,
textdisp=(0, 1),
ncs=MagicMock(),
graphicstate=MagicMock(),
)
def _make_interpreter(cur_item):
interp = object.__new__(CustomPDFPageInterpreter)
interp.device = MagicMock()
interp.device.cur_item = cur_item
interp.textstate = MagicMock()
interp.graphicstate = MagicMock()
return interp
def test_patch_render_mode_only_new_chars():
"""Only chars added after the snapshot index should be patched."""
page = LTLayoutContainer(bbox=(0, 0, 100, 100))
interp = _make_interpreter(page)
old_char = _make_char()
page.add(old_char)
interp.textstate.render = 3
interp._patch_current_chars_with_render_mode(start=1)
assert not hasattr(old_char, "rendermode")
def test_patch_render_mode_correct_value():
"""Chars in the patched range should get the current render mode."""
page = LTLayoutContainer(bbox=(0, 0, 100, 100))
interp = _make_interpreter(page)
char = _make_char()
page.add(char)
interp.textstate.render = 3
interp._patch_current_chars_with_render_mode(start=0)
assert char.rendermode == 3
def test_patch_render_mode_preserved_after_figure_with_text():
"""When cur_item reverts to the page after a figure that contained text ops,
previously patched chars must keep their original render mode."""
page = LTLayoutContainer(bbox=(0, 0, 100, 100))
interp = _make_interpreter(page)
# Text op on page with render_mode=0
char_a = _make_char()
page.add(char_a)
interp.textstate.render = 0
interp._patch_current_chars_with_render_mode(start=0)
# begin_figure — cur_item switches to figure
figure = LTFigure("test", (0, 0, 50, 50), (1, 0, 0, 1, 0, 0))
interp.device.cur_item = figure
# Text op inside figure
fig_char = _make_char()
figure.add(fig_char)
interp._patch_current_chars_with_render_mode(start=0)
# end_figure — cur_item reverts to page
interp.device.cur_item = page
# Render mode changes, new text op on page
interp.textstate.render = 3
char_b = _make_char()
page.add(char_b)
interp._patch_current_chars_with_render_mode(start=1)
assert char_a.rendermode == 0
assert char_b.rendermode == 3
def test_do_TJ_snapshots_before_super():
"""do_TJ should snapshot len(objs) before super() adds chars,
so only the newly added chars are patched."""
page = LTLayoutContainer(bbox=(0, 0, 100, 100))
interp = _make_interpreter(page)
old_char = _make_char()
page.add(old_char)
old_char.rendermode = 0
new_char = _make_char()
def fake_super_do_TJ(self, seq):
page.add(new_char)
interp.textstate.render = 3
interp.textstate.font = MagicMock()
with patch.object(CustomPDFPageInterpreter.__bases__[0], "do_TJ", fake_super_do_TJ):
interp.do_TJ([b"test"])
assert old_char.rendermode == 0
assert new_char.rendermode == 3
def test_extract_text_objects_nested_containers():
"""Test extract_text_objects with nested LTContainers."""
# Mock LTTextLine objects
mock_text_line1 = MagicMock(spec=LTTextLine)
mock_text_line2 = MagicMock(spec=LTTextLine)
# Mock inner container containing one LTTextLine
mock_inner_container = MagicMock(spec=LTContainer)
mock_inner_container.__iter__.return_value = [mock_text_line2]
# Mock outer container containing another LTTextLine and the inner container
mock_outer_container = MagicMock(spec=LTContainer)
mock_outer_container.__iter__.return_value = [mock_text_line1, mock_inner_container]
# Call the function with the outer container
result = extract_text_objects(mock_outer_container)
# Assert both text line objects are extracted, even from nested containers
assert len(result) == 2
assert mock_text_line1 in result
assert mock_text_line2 in result
# -- Tests for character deduplication (fake bold fix) --
def _create_mock_ltchar(
text: str, x0: float, y0: float, width: float = 6.0, height: float = 2.0
) -> MagicMock:
"""Helper to create a mock LTChar with specified text and position.
Includes x1, y1 so _is_duplicate_char overlap logic works (fake-bold detection
uses bounding box overlap). Default width/height give overlap ratio > 0.5 for
chars within threshold distance.
"""
mock_char = MagicMock(spec=LTChar)
mock_char.get_text.return_value = text
mock_char.x0 = x0
mock_char.y0 = y0
mock_char.x1 = x0 + width
mock_char.y1 = y0 + height
return mock_char
class TestIsDuplicateChar:
"""Tests for _is_duplicate_char function."""
def test_same_char_same_position_is_duplicate(self):
"""Two identical characters at the same position should be duplicates."""
char1 = _create_mock_ltchar("A", 10.0, 20.0)
char2 = _create_mock_ltchar("A", 10.0, 20.0)
assert _is_duplicate_char(char1, char2, threshold=3.0) is True
def test_same_char_close_position_is_duplicate(self):
"""Two identical characters at close positions should be duplicates."""
char1 = _create_mock_ltchar("B", 10.0, 20.0)
char2 = _create_mock_ltchar("B", 11.5, 21.0) # Within 3.0 threshold
assert _is_duplicate_char(char1, char2, threshold=3.0) is True
def test_same_char_far_position_not_duplicate(self):
"""Two identical characters at far positions should not be duplicates."""
char1 = _create_mock_ltchar("C", 10.0, 20.0)
char2 = _create_mock_ltchar("C", 15.0, 20.0) # 5.0 > 3.0 threshold
assert _is_duplicate_char(char1, char2, threshold=3.0) is False
def test_different_chars_same_position_not_duplicate(self):
"""Two different characters at the same position should not be duplicates."""
char1 = _create_mock_ltchar("A", 10.0, 20.0)
char2 = _create_mock_ltchar("B", 10.0, 20.0)
assert _is_duplicate_char(char1, char2, threshold=3.0) is False
def test_threshold_boundary(self):
"""Test behavior at exact threshold boundary."""
char1 = _create_mock_ltchar("X", 10.0, 20.0)
char2 = _create_mock_ltchar("X", 13.0, 20.0) # Exactly at threshold
# At threshold means NOT within threshold (uses < not <=)
assert _is_duplicate_char(char1, char2, threshold=3.0) is False
char3 = _create_mock_ltchar("X", 12.9, 20.0) # Just under threshold
assert _is_duplicate_char(char1, char3, threshold=3.0) is True
class TestDeduplicateCharsInTextLine:
"""Tests for deduplicate_chars_in_text_line function."""
def test_no_duplicates_returns_original(self):
"""Text line without duplicates should return original text."""
chars = [
_create_mock_ltchar("H", 10.0, 20.0),
_create_mock_ltchar("i", 15.0, 20.0),
]
mock_text_line = MagicMock(spec=LTTextLine)
mock_text_line.__iter__ = lambda self: iter(chars)
mock_text_line.get_text.return_value = "Hi"
result = deduplicate_chars_in_text_line(mock_text_line, threshold=3.0)
assert result == "Hi"
def test_fake_bold_duplicates_removed(self):
"""Fake bold text (each char doubled) should be deduplicated."""
# Simulates "BOLD" rendered as "BBOOLLDD" with duplicate positions
chars = [
_create_mock_ltchar("B", 10.0, 20.0),
_create_mock_ltchar("B", 10.5, 20.0), # Duplicate
_create_mock_ltchar("O", 20.0, 20.0),
_create_mock_ltchar("O", 20.5, 20.0), # Duplicate
_create_mock_ltchar("L", 30.0, 20.0),
_create_mock_ltchar("L", 30.5, 20.0), # Duplicate
_create_mock_ltchar("D", 40.0, 20.0),
_create_mock_ltchar("D", 40.5, 20.0), # Duplicate
]
mock_text_line = MagicMock(spec=LTTextLine)
mock_text_line.__iter__ = lambda self: iter(chars)
result = deduplicate_chars_in_text_line(mock_text_line, threshold=3.0)
assert result == "BOLD"
def test_threshold_zero_disables_deduplication(self):
"""Setting threshold to 0 should disable deduplication."""
mock_text_line = MagicMock(spec=LTTextLine)
mock_text_line.get_text.return_value = "BBOOLLDD"
result = deduplicate_chars_in_text_line(mock_text_line, threshold=0)
assert result == "BBOOLLDD"
def test_negative_threshold_disables_deduplication(self):
"""Setting negative threshold should disable deduplication."""
mock_text_line = MagicMock(spec=LTTextLine)
mock_text_line.get_text.return_value = "BBOOLLDD"
result = deduplicate_chars_in_text_line(mock_text_line, threshold=-1.0)
assert result == "BBOOLLDD"
def test_empty_text_line(self):
"""Empty text line should return original text."""
mock_text_line = MagicMock(spec=LTTextLine)
mock_text_line.__iter__ = lambda self: iter([])
mock_text_line.get_text.return_value = ""
result = deduplicate_chars_in_text_line(mock_text_line, threshold=3.0)
assert result == ""
def test_legitimate_repeated_chars_preserved(self):
"""Legitimate repeated characters (different positions) should be preserved."""
# "AA" where both A's are at different positions
chars = [
_create_mock_ltchar("A", 10.0, 20.0),
_create_mock_ltchar("A", 20.0, 20.0), # Different position, not duplicate
]
mock_text_line = MagicMock(spec=LTTextLine)
mock_text_line.__iter__ = lambda self: iter(chars)
result = deduplicate_chars_in_text_line(mock_text_line, threshold=3.0)
assert result == "AA"
class TestGetTextWithDeduplication:
"""Tests for get_text_with_deduplication function."""
def test_with_text_line(self):
"""Should properly deduplicate text from LTTextLine."""
chars = [
_create_mock_ltchar("H", 10.0, 20.0),
_create_mock_ltchar("H", 10.5, 20.0), # Duplicate
_create_mock_ltchar("i", 20.0, 20.0),
]
mock_text_line = MagicMock(spec=LTTextLine)
mock_text_line.__iter__ = lambda self: iter(chars)
result = get_text_with_deduplication(mock_text_line, threshold=3.0)
assert result == "Hi"
def test_with_container(self):
"""Should handle LTContainer with nested LTTextLine."""
chars = [
_create_mock_ltchar("T", 10.0, 20.0),
_create_mock_ltchar("T", 10.5, 20.0), # Duplicate
]
mock_text_line = MagicMock(spec=LTTextLine)
mock_text_line.__iter__ = lambda self: iter(chars)
mock_container = MagicMock(spec=LTContainer)
mock_container.__iter__ = lambda self: iter([mock_text_line])
result = get_text_with_deduplication(mock_container, threshold=3.0)
assert result == "T"
def test_with_generic_object(self):
"""Should fall back to get_text() for non-standard objects."""
mock_obj = MagicMock()
mock_obj.get_text.return_value = "fallback text"
result = get_text_with_deduplication(mock_obj, threshold=3.0)
assert result == "fallback text"
def test_without_get_text(self):
"""Should return empty string for objects without get_text."""
mock_obj = MagicMock(spec=[]) # No get_text method
result = get_text_with_deduplication(mock_obj, threshold=3.0)
assert result == ""
# -- Integration tests for fake-bold PDF deduplication --
class TestFakeBoldPdfIntegration:
"""Integration tests for fake-bold PDF deduplication using real PDF files.
The test PDF (fake-bold-sample.pdf) contains text rendered with the "fake bold"
technique where each character is drawn twice at slightly offset positions.
This causes text extraction to show doubled characters (e.g., "BBOOLLDD" instead
of "BOLD") unless deduplication is applied.
"""
def test_fake_bold_pdf_without_deduplication_shows_doubled_chars(self, monkeypatch):
"""Test that extraction WITHOUT deduplication shows doubled characters.
When PDF_CHAR_DUPLICATE_THRESHOLD is set to 0, deduplication is disabled
and the raw text shows the fake-bold doubled characters.
"""
monkeypatch.setenv("PDF_CHAR_DUPLICATE_THRESHOLD", "0")
reload(partition_config)
filename = example_doc_path("pdf/fake-bold-sample.pdf")
elements = partition_pdf(filename=filename, strategy="fast")
extracted_text = " ".join([el.text for el in elements])
# Without deduplication, fake-bold text appears with doubled characters
assert "BBOOLLDD" in extracted_text, (
"Without deduplication, fake-bold text should show doubled characters "
"like 'BBOOLLDD' instead of 'BOLD'"
)
def test_fake_bold_pdf_with_deduplication_shows_clean_text(self, monkeypatch):
"""Test that extraction WITH deduplication shows clean text.
When PDF_CHAR_DUPLICATE_THRESHOLD is set to default (2.0), deduplication
removes the duplicate characters and produces clean, readable text.
"""
monkeypatch.setenv("PDF_CHAR_DUPLICATE_THRESHOLD", "2.0")
reload(partition_config)
filename = example_doc_path("pdf/fake-bold-sample.pdf")
elements = partition_pdf(filename=filename, strategy="fast")
extracted_text = " ".join([el.text for el in elements])
# With deduplication, fake-bold text should be clean (no doubled chars)
assert "BOLD" in extracted_text, (
"With deduplication, text should contain clean 'BOLD' not 'BBOOLLDD'"
)
# Verify the doubled pattern is NOT present in the deduplicated fake-bold section
# Note: The PDF contains 'BBOOLLDD' as explanatory text, so we check for
# the specific pattern that would appear if deduplication failed on the
# fake-bold rendered text (e.g., "TTEEXXTT" from "TEXT")
assert "TTEEXXTT" not in extracted_text, (
"With deduplication, fake-bold 'TEXT' should not appear as 'TTEEXXTT'"
)
def test_fake_bold_deduplication_reduces_text_length(self, monkeypatch):
"""Test that deduplication reduces text length for fake-bold PDFs.
Compares extraction with and without deduplication to verify that
the deduplicated text is shorter due to removal of duplicate characters.
"""
filename = example_doc_path("pdf/fake-bold-sample.pdf")
# Extract WITHOUT deduplication (threshold=0)
monkeypatch.setenv("PDF_CHAR_DUPLICATE_THRESHOLD", "0")
reload(partition_config)
elements_no_dedup = partition_pdf(filename=filename, strategy="fast")
text_no_dedup = " ".join([el.text for el in elements_no_dedup])
# Extract WITH deduplication (threshold=2.0)
monkeypatch.setenv("PDF_CHAR_DUPLICATE_THRESHOLD", "2.0")
reload(partition_config)
elements_with_dedup = partition_pdf(filename=filename, strategy="fast")
text_with_dedup = " ".join([el.text for el in elements_with_dedup])
# Deduplicated text should be shorter than non-deduplicated text
assert len(text_with_dedup) < len(text_no_dedup), (
f"Deduplicated text ({len(text_with_dedup)} chars) should be shorter "
f"than non-deduplicated text ({len(text_no_dedup)} chars)"
)
# -- Tests for embedded CMap stream parsing --
class TestParseEmbeddedCmapStream:
"""Unit tests for _parse_embedded_cmap_stream.
pdfminer.six does not parse embedded Encoding CMap streams for CIDFonts.
_parse_embedded_cmap_stream is our workaround that extracts code-to-CID
mappings and writing mode from the raw CMap stream bytes.
"""
@staticmethod
def _parse(data: bytes):
from unstructured.partition.pdf_image.pdfminer_utils import _parse_embedded_cmap_stream
return _parse_embedded_cmap_stream(data)
def test_vertical_wmode_is_preserved(self):
"""Embedded CMaps with WMode=1 (vertical) should produce a vertical CMap."""
data = b"""
/WMode 1
1 begincodespacerange
<00> <0A>
endcodespacerange
1 begincidrange
<00> <0A> 0
endcidrange
"""
cmap = self._parse(data)
assert cmap.is_vertical() is True
assert len(cmap.code2cid) == 11
def test_horizontal_wmode_is_default(self):
"""CMaps without WMode or with WMode=0 should produce a horizontal CMap."""
data = b"""
1 begincodespacerange
<00> <05>
endcodespacerange
1 begincidrange
<00> <05> 0
endcidrange
"""
cmap = self._parse(data)
assert cmap.is_vertical() is False
def test_oversized_stream_returns_empty_cmap(self):
"""Streams exceeding the size cap should be rejected before any parsing."""
from unstructured.partition.pdf_image.pdfminer_utils import _MAX_CMAP_STREAM_BYTES
data = b"x" * (_MAX_CMAP_STREAM_BYTES + 1)
cmap = self._parse(data)
assert not cmap.code2cid
def test_reversed_range_is_skipped(self):
"""A begincidrange where end < start should be silently skipped."""
data = b"""
1 begincodespacerange
<00> <FF>
endcodespacerange
1 begincidrange
<80> <10> 0
endcidrange
"""
cmap = self._parse(data)
assert not cmap.code2cid
def test_mapping_cap_bounds_total_entries(self):
"""The total mapping count should be bounded across both cidrange and cidchar."""
from unittest.mock import patch
data = b"""
1 begincodespacerange
<0000> <FFFF>
endcodespacerange
1 begincidrange
<0000> <FFFF> 0
endcidrange
"""
# Use a small cap to verify bounding without allocating huge dicts
with patch("unstructured.partition.pdf_image.pdfminer_utils._MAX_CODE2CID_MAPPINGS", 100):
cmap = self._parse(data)
assert not cmap.code2cid # 65536 > 100: entire CMap discarded, not partial
def test_mapping_budget_second_range_discards_entire_cmap(self):
"""If a later cidrange would exceed the cap, reject the whole map (no holes)."""
from unittest.mock import patch
data = b"""
1 begincodespacerange
<00> <19>
endcodespacerange
2 begincidrange
<00> <09> 0
<0A> <19> 10
endcidrange
"""
with patch("unstructured.partition.pdf_image.pdfminer_utils._MAX_CODE2CID_MAPPINGS", 15):
cmap = self._parse(data)
assert not cmap.code2cid
class TestBoundedStreamDecode:
"""Tests for _decode_pdfstream_with_limit.
Verifies that oversized embedded CMap streams are rejected *before* full
materialization, and that the shared PDFStream object is never mutated.
"""
def test_oversized_flate_stream_rejected_before_materialization(self):
"""A small compressed payload that expands past the limit should be rejected
without fully materializing the output, and the stream should not be mutated."""
import zlib
from pdfminer.psparser import LIT
from unstructured.partition.pdf_image.pdfminer_utils import (
_decode_pdfstream_with_limit,
)
payload = zlib.compress(b"x" * 200)
stream = PDFStream({"Filter": LIT("FlateDecode")}, payload)
result = _decode_pdfstream_with_limit(stream, max_decoded_bytes=100)
assert result is None
# Stream object must not be mutated
assert stream.get_rawdata() is not None
assert stream.data is None
def test_normal_stream_decodes_within_limit(self):
"""A stream that fits within the limit should decode successfully."""
import zlib
from pdfminer.psparser import LIT
from unstructured.partition.pdf_image.pdfminer_utils import (
_decode_pdfstream_with_limit,
)
content = b"begincidrange <00> <05> 0 endcidrange"
payload = zlib.compress(content)
stream = PDFStream({"Filter": LIT("FlateDecode")}, payload)
result = _decode_pdfstream_with_limit(stream, max_decoded_bytes=1000)
assert result == content
def test_encrypted_stream_without_objid_returns_none(self):
"""Decipher requires objid/genno; missing values skip decode (no crash)."""
from unstructured.partition.pdf_image.pdfminer_utils import (
_decode_pdfstream_with_limit,
)
stream = PDFStream({}, b"encrypted-bytes", decipher=lambda *a: b"x")
stream.objid = None
stream.genno = 0
assert _decode_pdfstream_with_limit(stream, max_decoded_bytes=1000) is None
stream.objid = 1
stream.genno = None
assert _decode_pdfstream_with_limit(stream, max_decoded_bytes=1000) is None
def test_uncompressed_stream_returns_raw(self):
"""A stream with no filters should return the raw data directly."""
from unstructured.partition.pdf_image.pdfminer_utils import (
_decode_pdfstream_with_limit,
)
content = b"begincidrange <00> <05> 0 endcidrange"
stream = PDFStream({}, content)
result = _decode_pdfstream_with_limit(stream, max_decoded_bytes=1000)
assert result == content
class TestCustomPDFCIDFont:
"""Tests for CustomPDFCIDFont constructor-time CMap resolution."""
def test_vertical_wmode_sets_font_vertical_flag(self):
"""A vertical embedded CMap (WMode=1) should set font.vertical=True and
font.default_disp != 0 at construction time, not just on the CMap."""
import zlib
from pdfminer.pdfinterp import PDFResourceManager
from pdfminer.psparser import LIT
from unstructured.partition.pdf_image.pdfminer_utils import CustomPDFCIDFont
cmap_data = b"""/CIDInit /ProcSet findresource begin
12 dict begin
begincmap
/CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> def
/CMapName /Test-Vertical-H def
/CMapType 1 def
/WMode 1 def
1 begincodespacerange
<00> <0A>
endcodespacerange
1 begincidrange
<00> <0A> 0
endcidrange
endcmap
end
end"""
encoding_stream = PDFStream(
{
"Type": LIT("CMap"),
"CMapName": LIT("Test-Vertical-H"),
"CIDSystemInfo": {
"Registry": b"Adobe",
"Ordering": b"Identity",
"Supplement": 0,
},
"Filter": LIT("FlateDecode"),
},
zlib.compress(cmap_data),
)
tounicode_data = b"""/CIDInit /ProcSet findresource begin
12 dict begin
begincmap
/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def
/CMapName /Adobe-Identity-UCS def
/CMapType 2 def
1 begincodespacerange
<00> <FF>
endcodespacerange
1 beginbfrange
<00> <0A> <0041>
endbfrange
endcmap
end
end"""
tounicode_stream = PDFStream({}, tounicode_data)
spec = {
"Type": LIT("Font"),
"Subtype": LIT("CIDFontType2"),
"BaseFont": LIT("TestSans"),
"CIDSystemInfo": {
"Registry": b"Adobe",
"Ordering": b"Identity",
"Supplement": 0,
},
"FontDescriptor": {
"Type": LIT("FontDescriptor"),
"FontName": LIT("TestSans"),
"Flags": 32,
"FontBBox": [0, -200, 1000, 800],
"ItalicAngle": 0,
"Ascent": 800,
"Descent": -200,
"CapHeight": 700,
"StemV": 80,
},
"DW": 500,
"Encoding": encoding_stream,
"ToUnicode": tounicode_stream,
}
rsrcmgr = PDFResourceManager()
font = CustomPDFCIDFont(rsrcmgr, spec, strict=False)
assert font.cmap.is_vertical() is True
assert font.vertical is True
assert font.default_disp != 0
class TestCustomPDFResourceManager:
"""Tests for CustomPDFResourceManager font construction routing."""
def test_returns_custom_cidfont_for_cid_subtypes(self):
"""CIDFontType2 fonts should be constructed as CustomPDFCIDFont."""
from pdfminer.psparser import LIT
from unstructured.partition.pdf_image.pdfminer_utils import (
CustomPDFCIDFont,
CustomPDFResourceManager,
)
cmap_data = b"""begincmap
1 begincodespacerange <00> <05> endcodespacerange
1 begincidrange <00> <05> 0 endcidrange
endcmap"""
spec = {
"Type": LIT("Font"),
"Subtype": LIT("CIDFontType2"),
"BaseFont": LIT("TestSans"),
"CIDSystemInfo": {
"Registry": b"Adobe",
"Ordering": b"Identity",
"Supplement": 0,
},
"FontDescriptor": {
"Type": LIT("FontDescriptor"),
"FontName": LIT("TestSans"),
"Flags": 32,
"FontBBox": [0, -200, 1000, 800],
"ItalicAngle": 0,
"Ascent": 800,
"Descent": -200,
"CapHeight": 700,
"StemV": 80,
},
"DW": 500,
"Encoding": PDFStream(
{
"Type": LIT("CMap"),
"CMapName": LIT("Test-Custom-H"),
"CIDSystemInfo": {
"Registry": b"Adobe",
"Ordering": b"Identity",
"Supplement": 0,
},
},
cmap_data,
),
}
rsrcmgr = CustomPDFResourceManager()
font = rsrcmgr.get_font(1, spec)
assert isinstance(font, CustomPDFCIDFont)
def test_caches_font_on_repeated_calls(self):
"""Repeated get_font calls with the same objid should return the cached instance."""
from pdfminer.psparser import LIT
from unstructured.partition.pdf_image.pdfminer_utils import (
CustomPDFResourceManager,
)
cmap_data = b"""begincmap
1 begincodespacerange <00> <05> endcodespacerange
1 begincidrange <00> <05> 0 endcidrange
endcmap"""
spec = {
"Type": LIT("Font"),
"Subtype": LIT("CIDFontType2"),
"BaseFont": LIT("TestSans"),
"CIDSystemInfo": {
"Registry": b"Adobe",
"Ordering": b"Identity",
"Supplement": 0,
},
"FontDescriptor": {
"Type": LIT("FontDescriptor"),
"FontName": LIT("TestSans"),
"Flags": 32,
"FontBBox": [0, -200, 1000, 800],
"ItalicAngle": 0,
"Ascent": 800,
"Descent": -200,
"CapHeight": 700,
"StemV": 80,
},
"DW": 500,
"Encoding": PDFStream(
{
"Type": LIT("CMap"),
"CMapName": LIT("Test-Custom-H"),
"CIDSystemInfo": {
"Registry": b"Adobe",
"Ordering": b"Identity",
"Supplement": 0,
},
},
cmap_data,
),
}
rsrcmgr = CustomPDFResourceManager()
font1 = rsrcmgr.get_font(42, spec)
font2 = rsrcmgr.get_font(42, spec)
assert font1 is font2
+660
View File
@@ -0,0 +1,660 @@
import base64
import contextlib
import json
import os
import pathlib
from typing import Any
from unittest.mock import Mock
import pytest
import requests
from unstructured_client.general import General
from unstructured_client.models import shared
from unstructured_client.models.operations import PartitionRequest
from unstructured_client.models.shared import PartitionParameters
from unstructured_client.utils import retries
from unstructured.documents.elements import ElementType, NarrativeText
from unstructured.partition.api import (
DEFAULT_RETRIES_MAX_ELAPSED_TIME_SEC,
DEFAULT_RETRIES_MAX_INTERVAL_SEC,
get_retries_config,
partition_multiple_via_api,
partition_via_api,
)
from ..unit_utils import ANY, FixtureRequest, example_doc_path, method_mock
DIRECTORY = pathlib.Path(__file__).parent.resolve()
# NOTE(yao): point to paid API for now
API_URL = "https://api.unstructuredapp.io/general/v0/general"
is_in_ci = os.getenv("CI", "").lower() not in {"", "false", "f", "0"}
skip_not_on_main = os.getenv("GITHUB_REF_NAME", "").lower() != "main"
def test_partition_via_api_with_filename_correctly_calls_sdk(
request: FixtureRequest, expected_call_: list[Any]
):
partition_mock_ = method_mock(
request, General, "partition", return_value=FakeResponse(status_code=200)
)
elements = partition_via_api(filename=example_doc_path("eml/fake-email.eml"))
partition_mock_.assert_called_once_with(
expected_call_[0], request=expected_call_[1], retries=expected_call_[2]
)
assert isinstance(partition_mock_.call_args_list[0].args[0], General)
assert len(elements) == 1
assert elements[0] == NarrativeText("This is a test email to use for unit tests.")
assert elements[0].metadata.filetype == "message/rfc822"
def test_partition_via_api_with_file_correctly_calls_sdk(
request: FixtureRequest, expected_call_: list[Any]
):
partition_mock_ = method_mock(
request, General, "partition", return_value=FakeResponse(status_code=200)
)
with open(example_doc_path("eml/fake-email.eml"), "rb") as f:
elements = partition_via_api(
file=f, metadata_filename=example_doc_path("eml/fake-email.eml")
)
# Update the fixture content to match the format passed to partition_via_api
modified_expected_call = expected_call_[:]
modified_expected_call[1].partition_parameters.files.content = f
partition_mock_.assert_called_once_with(
modified_expected_call[0],
request=modified_expected_call[1],
retries=modified_expected_call[2],
)
assert isinstance(partition_mock_.call_args_list[0].args[0], General)
assert len(elements) == 1
assert elements[0] == NarrativeText("This is a test email to use for unit tests.")
assert elements[0].metadata.filetype == "message/rfc822"
def test_partition_via_api_warns_with_file_and_filename_and_calls_sdk(
request: FixtureRequest, expected_call_: list[Any], caplog: pytest.LogCaptureFixture
):
partition_mock_ = method_mock(
request, General, "partition", return_value=FakeResponse(status_code=200)
)
with open(example_doc_path("eml/fake-email.eml"), "rb") as f:
partition_via_api(file=f, file_filename=example_doc_path("eml/fake-email.eml"))
# Update the fixture content to match the format passed to partition_via_api
modified_expected_call = expected_call_[:]
modified_expected_call[1].partition_parameters.files.content = f
partition_mock_.assert_called_once_with(
modified_expected_call[0],
request=modified_expected_call[1],
retries=modified_expected_call[2],
)
assert "WARNING" in caplog.text
assert "The file_filename kwarg will be deprecated" in caplog.text
def test_partition_via_api_from_file_raises_with_metadata_and_file_and_filename():
filename = example_doc_path("eml/fake-email.eml")
with open(filename, "rb") as f, pytest.raises(ValueError):
partition_via_api(file=f, file_filename=filename, metadata_filename=filename)
def test_partition_via_api_from_file_raises_without_filename():
with open(example_doc_path("eml/fake-email.eml"), "rb") as f, pytest.raises(ValueError):
partition_via_api(file=f)
def test_partition_via_api_raises_with_bad_response(request: FixtureRequest):
partition_mock_ = method_mock(
request, General, "partition", return_value=FakeResponse(status_code=500)
)
with pytest.raises(ValueError):
partition_via_api(filename=example_doc_path("eml/fake-email.eml"))
partition_mock_.assert_called_once()
@pytest.mark.skipif(not is_in_ci, reason="Skipping test run outside of CI")
@pytest.mark.skipif(skip_not_on_main, reason="Skipping test run outside of main branch")
def test_partition_via_api_with_no_strategy():
test_file = example_doc_path("pdf/loremipsum-flat.pdf")
elements_no_strategy = partition_via_api(
filename=test_file,
strategy="auto",
api_key=get_api_key(),
# The url has changed since the 06/24 API release while the sdk defaults to the old url
api_url=API_URL,
skip_infer_table_types=["pdf"],
)
elements_hi_res = partition_via_api(
filename=test_file,
strategy="hi_res",
api_key=get_api_key(),
# The url has changed since the 06/24 API release while the sdk defaults to the old url
api_url=API_URL,
skip_infer_table_types=["pdf"],
)
elements_fast_res = partition_via_api(
filename=test_file,
strategy="fast",
api_key=get_api_key(),
# The url has changed since the 06/24 API release while the sdk defaults to the old url
api_url=API_URL,
skip_infer_table_types=["pdf"],
)
# confirm that hi_res strategy was not passed as default to partition by comparing outputs
# elements_hi_res[3].text =
# 'LayoutParser: A Unified Toolkit for Deep Learning Based Document Image Analysis'
# while elements_no_strategy[3].text = ']' (as of this writing)
assert len(elements_no_strategy) == len(elements_hi_res)
assert len(elements_hi_res) != len(elements_fast_res)
# NOTE(crag): slightly out scope assertion, but avoid extra API call
assert elements_hi_res[0].metadata.coordinates is None
@pytest.mark.skipif(not is_in_ci, reason="Skipping test run outside of CI")
@pytest.mark.skipif(skip_not_on_main, reason="Skipping test run outside of main branch")
def test_partition_via_api_with_image_hi_res_strategy_includes_coordinates():
# coordinates not included by default to limit payload size
elements = partition_via_api(
filename=example_doc_path("pdf/fake-memo.pdf"),
strategy="hi_res",
coordinates="true",
api_key=get_api_key(),
api_url=API_URL,
)
assert elements[0].metadata.coordinates is not None
@pytest.mark.skipif(not is_in_ci, reason="Skipping test run outside of CI")
@pytest.mark.skipif(skip_not_on_main, reason="Skipping test run outside of main branch")
def test_partition_via_api_image_block_extraction():
elements = partition_via_api(
filename=example_doc_path("pdf/embedded-images-tables.pdf"),
strategy="hi_res",
extract_image_block_types=["image", "table"],
api_key=get_api_key(),
# The url has changed since the 06/24 API release while the sdk defaults to the old url
api_url=API_URL,
)
image_elements = [el for el in elements if el.category == ElementType.IMAGE]
for el in image_elements:
assert el.metadata.image_base64 is not None
assert el.metadata.image_mime_type is not None
image_data = base64.b64decode(el.metadata.image_base64)
assert isinstance(image_data, bytes)
@pytest.mark.skipif(not is_in_ci, reason="Skipping test run outside of CI")
@pytest.mark.skipif(skip_not_on_main, reason="Skipping test run outside of main branch")
def test_partition_via_api_retries_config():
elements = partition_via_api(
filename=example_doc_path("pdf/embedded-images-tables.pdf"),
strategy="fast",
api_key=get_api_key(),
# The url has changed since the 06/24 API release while the sdk defaults to the old url
api_url=API_URL,
retries_initial_interval=5,
retries_max_interval=15,
retries_max_elapsed_time=100,
retries_connection_errors=True,
retries_exponent=1.5,
)
assert len(elements) > 0
# Note(austin) - This test is way too noisy against the hosted api
# def test_partition_via_api_invalid_request_data_kwargs():
# filename = os.path.join(DIRECTORY, "..", "..", "example-docs", "layout-parser-paper-fast.pdf")
# with pytest.raises(SDKError):
# partition_via_api(filename=filename, strategy="not_a_strategy")
def test_retries_config_with_parameters_set():
sdk = Mock()
retries_config = get_retries_config(
retries_connection_errors=True,
retries_exponent=1.75,
retries_initial_interval=20,
retries_max_elapsed_time=1000,
retries_max_interval=100,
sdk=sdk,
)
assert retries_config.retry_connection_errors
assert retries_config.backoff.exponent == 1.75
assert retries_config.backoff.initial_interval == 20
assert retries_config.backoff.max_elapsed_time == 1000
assert retries_config.backoff.max_interval == 100
def test_retries_config_none_parameters_return_empty_config():
sdk = Mock()
retries_config = get_retries_config(
retries_connection_errors=None,
retries_exponent=None,
retries_initial_interval=None,
retries_max_elapsed_time=None,
retries_max_interval=None,
sdk=sdk,
)
assert retries_config is None
def test_retry_config_with_empty_sdk_retry_config_returns_default():
sdk = Mock()
sdk.sdk_configuration.retry_config = None
retries_config = get_retries_config(
retries_connection_errors=True,
retries_exponent=1.88,
retries_initial_interval=3000,
retries_max_elapsed_time=None,
retries_max_interval=None,
sdk=sdk,
)
assert retries_config.retry_connection_errors
assert retries_config.backoff.exponent == 1.88
assert retries_config.backoff.initial_interval == 3000
assert retries_config.backoff.max_elapsed_time == DEFAULT_RETRIES_MAX_ELAPSED_TIME_SEC
assert retries_config.backoff.max_interval == DEFAULT_RETRIES_MAX_INTERVAL_SEC
def test_retries_config_with_no_parameters_set():
retry_config = retries.RetryConfig(
"backoff", retries.BackoffStrategy(3000, 720000, 1.88, 1800000), True
)
sdk = Mock()
sdk.sdk_configuration.retry_config = retry_config
retries_config = get_retries_config(
retries_connection_errors=True,
retries_exponent=None,
retries_initial_interval=None,
retries_max_elapsed_time=None,
retries_max_interval=None,
sdk=sdk,
)
assert retries_config.retry_connection_errors
assert retries_config.backoff.exponent == 1.88
assert retries_config.backoff.initial_interval == 3000
assert retries_config.backoff.max_elapsed_time == 1800000
assert retries_config.backoff.max_interval == 720000
def test_retries_config_cascade():
# notice max_interval is set to 0 which is incorrect - so the DEFAULT_RETRIES_MAX_INTERVAL_SEC
# should be used
retry_config = retries.RetryConfig(
"backoff", retries.BackoffStrategy(3000, 0, 1.88, None), True
)
sdk = Mock()
sdk.sdk_configuration.retry_config = retry_config
retries_config = get_retries_config(
retries_connection_errors=False,
retries_exponent=1.75,
retries_initial_interval=20,
retries_max_elapsed_time=None,
retries_max_interval=None,
sdk=sdk,
)
assert not retries_config.retry_connection_errors
assert retries_config.backoff.exponent == 1.75
assert retries_config.backoff.initial_interval == 20
assert retries_config.backoff.max_elapsed_time == DEFAULT_RETRIES_MAX_ELAPSED_TIME_SEC
assert retries_config.backoff.max_interval == DEFAULT_RETRIES_MAX_INTERVAL_SEC
def test_partition_multiple_via_api_with_single_filename(request: FixtureRequest):
partition_mock_ = method_mock(
request, requests, "post", return_value=FakeResponse(status_code=200)
)
filename = example_doc_path("eml/fake-email.eml")
elements = partition_multiple_via_api(filenames=[filename])
partition_mock_.assert_called_once_with(
"https://api.unstructured.io/general/v0/general",
headers={"ACCEPT": "application/json", "UNSTRUCTURED-API-KEY": ANY},
data={},
files=[("files", (example_doc_path("eml/fake-email.eml"), ANY, None))],
)
assert elements[0][0] == NarrativeText("This is a test email to use for unit tests.")
assert elements[0][0].metadata.filetype == "message/rfc822"
def test_partition_multiple_via_api_from_filenames(request: FixtureRequest):
partition_mock_ = method_mock(
request, requests, "post", return_value=FakeMultipleResponse(status_code=200)
)
filenames = [example_doc_path("eml/fake-email.eml"), example_doc_path("fake.docx")]
elements = partition_multiple_via_api(filenames=filenames)
partition_mock_.assert_called_once_with(
"https://api.unstructured.io/general/v0/general",
headers={"ACCEPT": "application/json", "UNSTRUCTURED-API-KEY": ANY},
data={},
files=[
("files", (example_doc_path("eml/fake-email.eml"), ANY, None)),
("files", (example_doc_path("fake.docx"), ANY, None)),
],
)
assert len(elements) == 2
assert elements[0][0] == NarrativeText("This is a test email to use for unit tests.")
assert elements[0][0].metadata.filetype == "message/rfc822"
def test_partition_multiple_via_api_from_files(request: FixtureRequest):
partition_mock_ = method_mock(
request, requests, "post", return_value=FakeMultipleResponse(status_code=200)
)
filenames = [example_doc_path("eml/fake-email.eml"), example_doc_path("fake.docx")]
with contextlib.ExitStack() as stack:
files = [stack.enter_context(open(filename, "rb")) for filename in filenames]
elements = partition_multiple_via_api(
files=files,
metadata_filenames=filenames,
)
partition_mock_.assert_called_once_with(
"https://api.unstructured.io/general/v0/general",
headers={"ACCEPT": "application/json", "UNSTRUCTURED-API-KEY": ANY},
data={},
files=[
("files", (example_doc_path("eml/fake-email.eml"), ANY, None)),
("files", (example_doc_path("fake.docx"), ANY, None)),
],
)
assert len(elements) == 2
assert elements[0][0] == NarrativeText("This is a test email to use for unit tests.")
assert elements[0][0].metadata.filetype == "message/rfc822"
def test_partition_multiple_via_api_warns_with_file_filename(
caplog: pytest.LogCaptureFixture, request: FixtureRequest
):
partition_mock_ = method_mock(
request, requests, "post", return_value=FakeMultipleResponse(status_code=200)
)
filenames = [example_doc_path("eml/fake-email.eml"), example_doc_path("fake.docx")]
with contextlib.ExitStack() as stack:
files = [stack.enter_context(open(filename, "rb")) for filename in filenames]
partition_multiple_via_api(
files=files,
file_filenames=filenames,
)
partition_mock_.assert_called_once_with(
"https://api.unstructured.io/general/v0/general",
headers={"ACCEPT": "application/json", "UNSTRUCTURED-API-KEY": ANY},
data={},
files=[
("files", (example_doc_path("eml/fake-email.eml"), ANY, None)),
("files", (example_doc_path("fake.docx"), ANY, None)),
],
)
assert "WARNING" in caplog.text
assert "The file_filenames kwarg will be deprecated" in caplog.text
def test_partition_multiple_via_api_raises_with_file_and_metadata_filename():
filenames = [example_doc_path("eml/fake-email.eml"), example_doc_path("fake.docx")]
with contextlib.ExitStack() as stack:
files = [stack.enter_context(open(filename, "rb")) for filename in filenames]
with pytest.raises(ValueError):
partition_multiple_via_api(
files=files,
metadata_filenames=filenames,
file_filenames=filenames,
)
def test_partition_multiple_via_api_raises_with_bad_response(request: FixtureRequest):
partition_mock_ = method_mock(
request, requests, "post", return_value=FakeMultipleResponse(status_code=500)
)
filenames = [example_doc_path("eml/fake-email.eml"), example_doc_path("fake.docx")]
with pytest.raises(ValueError):
partition_multiple_via_api(filenames=filenames)
partition_mock_.assert_called_once_with(
"https://api.unstructured.io/general/v0/general",
headers={"ACCEPT": "application/json", "UNSTRUCTURED-API-KEY": ANY},
data={},
files=[
("files", (example_doc_path("eml/fake-email.eml"), ANY, None)),
("files", (example_doc_path("fake.docx"), ANY, None)),
],
)
def test_partition_multiple_via_api_raises_with_content_types_size_mismatch():
filenames = [example_doc_path("eml/fake-email.eml"), example_doc_path("fake.docx")]
with pytest.raises(ValueError):
partition_multiple_via_api(
filenames=filenames,
content_types=["text/plain"],
)
def test_partition_multiple_via_api_from_files_raises_with_size_mismatch():
filenames = [example_doc_path("eml/fake-email.eml"), example_doc_path("fake.docx")]
with contextlib.ExitStack() as stack:
files = [stack.enter_context(open(filename, "rb")) for filename in filenames]
with pytest.raises(ValueError):
partition_multiple_via_api(
files=files,
metadata_filenames=filenames,
content_types=["text/plain"],
)
def test_partition_multiple_via_api_from_files_raises_without_filenames():
filenames = [example_doc_path("eml/fake-email.eml"), example_doc_path("fake.docx")]
with contextlib.ExitStack() as stack:
files = [stack.enter_context(open(filename, "rb")) for filename in filenames]
with pytest.raises(ValueError):
partition_multiple_via_api(
files=files,
)
def get_api_key():
api_key = os.getenv("UNS_API_KEY")
if api_key is None:
raise ValueError("UNS_API_KEY environment variable not set")
return api_key
@pytest.mark.skipif(not is_in_ci, reason="Skipping test run outside of CI")
@pytest.mark.skipif(skip_not_on_main, reason="Skipping test run outside of main branch")
def test_partition_multiple_via_api_valid_request_data_kwargs():
filenames = [
example_doc_path("fake-text.txt"),
example_doc_path("fake-email.txt"),
]
list_of_lists_of_elements = partition_multiple_via_api(
filenames=filenames,
strategy="fast",
api_key=get_api_key(),
api_url=API_URL,
)
# assert there is a list of elements for each file
assert len(list_of_lists_of_elements) == 2
assert isinstance(list_of_lists_of_elements[0], list)
assert isinstance(list_of_lists_of_elements[1], list)
@pytest.mark.skipif(not is_in_ci, reason="Skipping test run outside of CI")
def test_partition_multiple_via_api_invalid_request_data_kwargs():
filenames = [
example_doc_path("pdf/layout-parser-paper-fast.pdf"),
example_doc_path("img/layout-parser-paper-fast.jpg"),
]
with pytest.raises(ValueError):
partition_multiple_via_api(
filenames=filenames,
strategy="not_a_strategy",
api_key=get_api_key(),
# The url has changed since the 06/24 API release while the sdk defaults to the old url
api_url=API_URL,
)
MOCK_TEXT = """[
{
"element_id": "f49fbd614ddf5b72e06f59e554e6ae2b",
"text": "This is a test email to use for unit tests.",
"type": "NarrativeText",
"metadata": {
"sent_from": [
"Matthew Robinson <mrobinson@unstructured.io>"
],
"sent_to": [
"Matthew Robinson <mrobinson@unstructured.io>"
],
"subject": "Test Email",
"filename": "fake-email.eml",
"filetype": "message/rfc822"
}
}
]"""
class FakeResponse:
def __init__(self, status_code: int):
self.status_code = status_code
# The string representation of partitioned elements is nested in an additional
# layer in the new unstructured-client:
# `elements_from_json(text=response.raw_response.text)`
self.raw_response = FakeRawResponse()
self.headers = {"Content-Type": "application/json"}
def json(self):
return json.loads(self.text)
@property
def text(self):
return MOCK_TEXT
class FakeRawResponse:
def __init__(self):
self.text = MOCK_TEXT
class FakeMultipleResponse:
def __init__(self, status_code: int):
self.status_code = status_code
def json(self):
return json.loads(self.text)
@property
def text(self):
return """[
[
{
"element_id": "f49fbd614ddf5b72e06f59e554e6ae2b",
"text": "This is a test email to use for unit tests.",
"type": "NarrativeText",
"metadata": {
"sent_from": [
"Matthew Robinson <mrobinson@unstructured.io>"
],
"sent_to": [
"Matthew Robinson <mrobinson@unstructured.io>"
],
"subject": "Test Email",
"filename": "fake-email.eml",
"filetype": "message/rfc822"
}
}
],
[
{
"element_id": "f49fbd614ddf5b72e06f59e554e6ae2b",
"text": "This is a test email to use for unit tests.",
"type": "NarrativeText",
"metadata": {
"sent_from": [
"Matthew Robinson <mrobinson@unstructured.io>"
],
"sent_to": [
"Matthew Robinson <mrobinson@unstructured.io>"
],
"subject": "Test Email",
"filename": "fake-email.eml",
"filetype": "message/rfc822"
}
}
]
]"""
@pytest.fixture()
def expected_call_():
with open(example_doc_path("eml/fake-email.eml"), "rb") as f:
file_bytes = f.read()
return [
ANY,
PartitionRequest(
partition_parameters=PartitionParameters(
files=shared.Files(
content=file_bytes,
file_name=example_doc_path("eml/fake-email.eml"),
),
chunking_strategy=None,
combine_under_n_chars=None,
coordinates=False,
encoding=None,
extract_image_block_types=None,
gz_uncompressed_content_type=None,
hi_res_model_name=None,
include_orig_elements=None,
include_page_breaks=False,
languages=None,
max_characters=None,
multipage_sections=True,
new_after_n_chars=None,
ocr_languages=None,
output_format=shared.OutputFormat.APPLICATION_JSON,
overlap=0,
overlap_all=False,
pdf_infer_table_structure=True,
similarity_threshold=None,
skip_infer_table_types=None,
split_pdf_concurrency_level=5,
split_pdf_page=True,
starting_page_number=None,
strategy=shared.Strategy.HI_RES,
unique_element_ids=False,
xml_keep_tags=False,
)
),
None, # retries kwarg
]
+416
View File
@@ -0,0 +1,416 @@
# pyright: reportPrivateUsage=false
"""Tests for partition_audio (speech-to-text in multimodal pipeline)."""
from __future__ import annotations
import tempfile
from contextlib import contextmanager
from pathlib import Path
from typing import IO, Generator
from unittest.mock import MagicMock, patch
import pytest
from unstructured.documents.elements import NarrativeText
from unstructured.file_utils.model import FileType
from unstructured.partition.audio import partition_audio
# ================================================================================================
# Shared fixtures
# ================================================================================================
_ONE_SEGMENT = [{"text": "Hello, world.", "start": 0.0, "end": 1.0}]
@contextmanager
def _tmp_audio(suffix: str = ".wav", content: bytes = b"\x00" * 44) -> Generator[str, None, None]:
"""Yield the path to a temporary audio file, deleting it on exit."""
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
tmp.write(content)
tmp.flush()
path = tmp.name
try:
yield path
finally:
Path(path).unlink(missing_ok=True)
@pytest.fixture
def mock_stt_agent() -> Generator[MagicMock, None, None]:
"""Patch SpeechToTextAgent.get_agent and yield the mock agent instance.
Tests that only need a working agent (and don't care about call details) can use
this fixture directly. Tests that need to inspect mock calls should use
``@patch("unstructured.partition.audio.SpeechToTextAgent.get_agent")`` directly so
the mock is visible in the function signature.
"""
with patch("unstructured.partition.audio.SpeechToTextAgent.get_agent") as mock_get_agent:
agent = mock_get_agent.return_value
agent.transcribe_segments.return_value = _ONE_SEGMENT
yield agent
@pytest.fixture
def _clear_stt_cache() -> Generator[None, None, None]:
"""Clear SpeechToTextAgent.get_instance lru_cache before and after the test.
Use in any test that touches get_instance or get_agent directly so cached state
does not leak between tests (mirrors OCRAgent _clear_cache in test_ocr_interface).
Tests that patch get_instance (e.g. get_agent tests using mock_get_instance) never
call the real get_instance, so the cache is unchanged by them and this fixture is
a no-op for those; it matters for tests that call get_instance directly.
"""
from unstructured.partition.utils.speech_to_text.speech_to_text_interface import (
SpeechToTextAgent,
)
SpeechToTextAgent.get_instance.cache_clear()
yield
SpeechToTextAgent.get_instance.cache_clear()
# ================================================================================================
# Input validation
# ================================================================================================
def test_partition_audio_raises_with_neither_filename_nor_file():
with pytest.raises(ValueError, match="Exactly one of .* must be specified"):
partition_audio()
def test_partition_audio_raises_with_both_filename_and_file():
with _tmp_audio() as path:
with pytest.raises(ValueError, match="Exactly one of .* must be specified"):
with open(path, "rb") as f:
partition_audio(filename=path, file=f)
# ================================================================================================
# Core element output
# ================================================================================================
@patch("unstructured.partition.audio.SpeechToTextAgent.get_agent")
def test_partition_audio_from_filename_returns_transcript_elements(mock_get_agent):
mock_agent = mock_get_agent.return_value
mock_agent.transcribe_segments.return_value = [
{"text": "Hello, this is a test transcript.", "start": 0.0, "end": 2.5},
]
with _tmp_audio() as path:
elements = partition_audio(filename=path)
assert len(elements) == 1
assert isinstance(elements[0], NarrativeText)
assert elements[0].text == "Hello, this is a test transcript."
assert elements[0].metadata.detection_origin == "speech_to_text"
assert elements[0].metadata.segment_start_seconds == 0.0
assert elements[0].metadata.segment_end_seconds == 2.5
mock_get_agent.assert_called_once_with(None)
mock_agent.transcribe_segments.assert_called_once_with(path, language=None)
def test_partition_audio_empty_transcript_returns_empty_list(mock_stt_agent):
mock_stt_agent.transcribe_segments.return_value = []
with _tmp_audio() as path:
assert partition_audio(filename=path) == []
def test_partition_audio_returns_one_element_per_segment(mock_stt_agent):
mock_stt_agent.transcribe_segments.return_value = [
{"text": "First segment.", "start": 0.0, "end": 1.0},
{"text": "Second segment.", "start": 1.0, "end": 2.5},
{"text": "Third segment.", "start": 2.5, "end": 4.0},
]
with _tmp_audio() as path:
elements = partition_audio(filename=path)
assert len(elements) == 3
assert elements[0].text == "First segment."
assert elements[0].metadata.segment_start_seconds == 0.0
assert elements[0].metadata.segment_end_seconds == 1.0
assert elements[1].text == "Second segment."
assert elements[1].metadata.segment_start_seconds == 1.0
assert elements[1].metadata.segment_end_seconds == 2.5
assert elements[2].text == "Third segment."
assert elements[2].metadata.segment_start_seconds == 2.5
assert elements[2].metadata.segment_end_seconds == 4.0
def test_partition_audio_filters_whitespace_only_segments(mock_stt_agent):
mock_stt_agent.transcribe_segments.return_value = [
{"text": " ", "start": 0.0, "end": 0.5},
{"text": "Real content.", "start": 0.5, "end": 2.0},
{"text": "\t\n", "start": 2.0, "end": 2.5},
]
with _tmp_audio() as path:
elements = partition_audio(filename=path)
assert len(elements) == 1
assert elements[0].text == "Real content."
# ================================================================================================
# Temp-file lifecycle (file-object input)
# ================================================================================================
@patch("unstructured.partition.audio.SpeechToTextAgent.get_agent")
def test_partition_audio_from_file_uses_temp_path_and_cleans_up(mock_get_agent):
mock_agent = mock_get_agent.return_value
mock_agent.transcribe_segments.return_value = [
{"text": "From file object.", "start": 0.0, "end": 1.0},
]
captured_temp_path: list[str] = []
real_named_temp = tempfile.NamedTemporaryFile
def spy_named_temp(*args, **kwargs):
ctx = real_named_temp(*args, **kwargs)
captured_temp_path.append(ctx.name)
return ctx
with _tmp_audio() as path:
with open(path, "rb") as f:
with patch("unstructured.partition.audio.tempfile.NamedTemporaryFile", spy_named_temp):
elements = partition_audio(file=f, metadata_filename="recording.wav")
assert len(elements) == 1
assert elements[0].text == "From file object."
assert elements[0].metadata.filename == "recording.wav"
assert len(captured_temp_path) == 1, "expected exactly one temp file to be created"
assert not Path(captured_temp_path[0]).exists(), "temp file was not deleted after partitioning"
@patch("unstructured.partition.audio.SpeechToTextAgent.get_agent")
def test_partition_audio_cleans_up_temp_file_when_transcription_raises(mock_get_agent):
mock_agent = mock_get_agent.return_value
mock_agent.transcribe_segments.side_effect = RuntimeError("transcription failed")
captured_temp_path: list[str] = []
real_named_temp = tempfile.NamedTemporaryFile
def spy_named_temp(*args, **kwargs):
ctx = real_named_temp(*args, **kwargs)
captured_temp_path.append(ctx.name)
return ctx
with _tmp_audio() as path:
with open(path, "rb") as f:
with patch("unstructured.partition.audio.tempfile.NamedTemporaryFile", spy_named_temp):
with pytest.raises(RuntimeError, match="transcription failed"):
partition_audio(file=f)
assert len(captured_temp_path) == 1, "expected exactly one temp file to be created"
assert not Path(captured_temp_path[0]).exists(), "temp file was not deleted after exception"
# ================================================================================================
# MIME type / filetype metadata
# ================================================================================================
@patch("unstructured.partition.audio.SpeechToTextAgent.get_agent")
@patch("unstructured.partition.audio.detect_filetype")
def test_partition_audio_stamps_correct_mime_type_for_detected_format(mock_detect, mock_get_agent):
"""metadata.filetype reflects the actual detected audio format, not a hardcoded WAV."""
mock_detect.return_value = FileType.MP3
mock_get_agent.return_value.transcribe_segments.return_value = [
{"text": "MP3 content.", "start": 0.0, "end": 1.0},
]
with _tmp_audio(suffix=".mp3", content=b"\xff\xfb" + b"\x00" * 42) as path:
elements = partition_audio(filename=path)
assert len(elements) == 1
assert elements[0].metadata.filetype == FileType.MP3.mime_type # "audio/mpeg"
@patch("unstructured.partition.audio.SpeechToTextAgent.get_agent")
@patch("unstructured.partition.audio.detect_filetype")
def test_partition_audio_falls_back_to_wav_mime_type_when_format_undetectable(
mock_detect, mock_get_agent
):
"""When filetype detection returns UNK, metadata.filetype falls back to audio/wav."""
mock_detect.return_value = FileType.UNK
mock_get_agent.return_value.transcribe_segments.return_value = [
{"text": "Unknown format.", "start": 0.0, "end": 1.0},
]
with _tmp_audio() as path:
elements = partition_audio(filename=path)
assert len(elements) == 1
assert elements[0].metadata.filetype == FileType.WAV.mime_type # "audio/wav"
# ================================================================================================
# FileType model
# ================================================================================================
@pytest.mark.parametrize(
"file_type",
[
FileType.FLAC,
FileType.M4A,
FileType.MP3,
FileType.OGG,
FileType.OPUS,
FileType.WAV,
FileType.WEBM,
],
)
def test_audio_file_types_are_partitionable(file_type: FileType):
assert file_type.is_partitionable
assert file_type.partitioner_shortname == "audio"
assert file_type.partitioner_function_name == "partition_audio"
assert file_type.extra_name == "audio"
# STT agent deps are validated at runtime by the chosen agent, not at FileType level
assert file_type.importable_package_dependencies == ()
# ================================================================================================
# _audio_suffix helper
# ================================================================================================
@pytest.mark.parametrize(
("metadata_filename", "file_name", "expected"),
[
("recording.mp3", None, ".mp3"),
("recording.mp3", "other.wav", ".mp3"), # metadata_filename wins
(None, "audio.flac", ".flac"),
(None, None, ".wav"), # fallback
("noext", None, ".wav"), # no extension → fallback
],
)
def test_audio_suffix(metadata_filename, file_name, expected):
from io import BytesIO
from unstructured.partition.audio import _audio_suffix
f: IO[bytes] = BytesIO(b"")
if file_name is not None:
f.name = file_name # type: ignore[attr-defined]
assert _audio_suffix(f, metadata_filename) == expected
# ================================================================================================
# partition_audio parameter forwarding
# ================================================================================================
@patch("unstructured.partition.audio.SpeechToTextAgent.get_agent")
def test_partition_audio_forwards_custom_stt_agent_to_get_agent(mock_get_agent):
mock_get_agent.return_value.transcribe_segments.return_value = _ONE_SEGMENT
custom_module = (
"unstructured.partition.utils.speech_to_text.whisper_stt.SpeechToTextAgentWhisper"
)
with _tmp_audio() as path:
partition_audio(filename=path, stt_agent=custom_module)
mock_get_agent.assert_called_once_with(custom_module)
@patch("unstructured.partition.audio.SpeechToTextAgent.get_agent")
def test_partition_audio_forwards_language_to_transcribe_segments(mock_get_agent):
mock_agent = mock_get_agent.return_value
mock_agent.transcribe_segments.return_value = [
{"text": "Hola mundo.", "start": 0.0, "end": 1.5},
]
with _tmp_audio() as path:
elements = partition_audio(filename=path, language="es")
mock_agent.transcribe_segments.assert_called_once_with(path, language="es")
assert elements[0].text == "Hola mundo."
# ================================================================================================
# SpeechToTextAgent unit tests
# ================================================================================================
@pytest.mark.usefixtures("_clear_stt_cache")
class TestSpeechToTextAgentInterface:
"""Unit tests for the SpeechToTextAgent base class."""
def test_get_agent_uses_env_config_when_no_module_given(self):
import os
from unittest.mock import patch as _patch
from unstructured.partition.utils.speech_to_text.speech_to_text_interface import (
SpeechToTextAgent,
)
sentinel = "unstructured.partition.utils.speech_to_text.whisper_stt.SentinelAgent"
with (
_patch.dict(os.environ, {"STT_AGENT": sentinel}),
_patch.object(SpeechToTextAgent, "get_instance") as mock_get_instance,
):
SpeechToTextAgent.get_agent(None)
mock_get_instance.assert_called_once_with(sentinel)
def test_get_agent_passes_explicit_module_to_get_instance(self):
from unittest.mock import patch as _patch
from unstructured.partition.utils.speech_to_text.speech_to_text_interface import (
SpeechToTextAgent,
)
custom = "unstructured.partition.utils.speech_to_text.whisper_stt.SpeechToTextAgentWhisper"
with _patch.object(SpeechToTextAgent, "get_instance") as mock_get_instance:
SpeechToTextAgent.get_agent(custom)
mock_get_instance.assert_called_once_with(custom)
def test_get_instance_rejects_non_whitelisted_module(self):
from unstructured.partition.utils.speech_to_text.speech_to_text_interface import (
SpeechToTextAgent,
)
with pytest.raises(ValueError, match="must be in the whitelist"):
SpeechToTextAgent.get_instance("evil.module.EvilAgent")
def test_get_instance_rejects_whitelisted_module_attribute_that_is_not_a_class(self):
"""Whitelisted module exposes a non-class (e.g. function or constant) -> TypeError."""
from unittest.mock import MagicMock, patch
from unstructured.partition.utils.speech_to_text.speech_to_text_interface import (
SpeechToTextAgent,
)
agent_module = "unstructured.partition.utils.speech_to_text.whisper_stt.some_function"
mock_mod = MagicMock()
mock_mod.some_function = lambda x: x # not a class
with patch(
"unstructured.partition.utils.speech_to_text.speech_to_text_interface.importlib.import_module",
return_value=mock_mod,
):
with pytest.raises(TypeError, match="does not refer to a class"):
SpeechToTextAgent.get_instance(agent_module)
def test_get_instance_rejects_class_that_does_not_subclass_speech_to_text_agent(self):
"""Whitelisted module exposes a class not subclassing SpeechToTextAgent -> TypeError."""
from unittest.mock import MagicMock, patch
from unstructured.partition.utils.speech_to_text.speech_to_text_interface import (
SpeechToTextAgent,
)
agent_module = "unstructured.partition.utils.speech_to_text.whisper_stt.WrongClass"
mock_mod = MagicMock()
mock_mod.WrongClass = object # a class, but not a subclass of SpeechToTextAgent
with patch(
"unstructured.partition.utils.speech_to_text.speech_to_text_interface.importlib.import_module",
return_value=mock_mod,
):
with pytest.raises(TypeError, match="must be a subclass of SpeechToTextAgent"):
SpeechToTextAgent.get_instance(agent_module)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,142 @@
EXPECTED_TABLE = (
"<table>"
"<tr><td>Stanley Cups</td><td/><td/></tr>"
"<tr><td>Team</td><td>Location</td><td>Stanley Cups</td></tr>"
"<tr><td>Blues</td><td>STL</td><td>1</td></tr>"
"<tr><td>Flyers</td><td>PHI</td><td>2</td></tr>"
"<tr><td>Maple Leafs</td><td>TOR</td><td>13</td></tr>"
"</table>"
)
EXPECTED_TABLE_SEMICOLON_DELIMITER = (
"<table>"
"<tr><td>Year</td><td>Month</td><td>Revenue</td><td>Costs</td><td/></tr>"
"<tr><td>2022</td><td>1</td><td>123</td><td>-123</td><td/></tr>"
"<tr><td>2023</td><td>2</td><td>143,1</td><td>-814,38</td><td/></tr>"
"<tr><td>2024</td><td>3</td><td>215,32</td><td>-11,08</td><td/></tr>"
"</table>"
)
EXPECTED_TABLE_WITH_EMOJI = (
"<table>"
"<tr><td>Stanley Cups</td><td/><td/></tr>"
"<tr><td>Team</td><td>Location</td><td>Stanley Cups</td></tr>"
"<tr><td>Blues</td><td>STL</td><td>1</td></tr>"
"<tr><td>Flyers</td><td>PHI</td><td>2</td></tr>"
"<tr><td>Maple Leafs</td><td>TOR</td><td>13</td></tr>"
"<tr><td>👨\\U+1F3FB🔧</td><td>TOR</td><td>15</td></tr>"
"</table>"
)
EXPECTED_TABLE_XLSX = (
"<table>"
"<tr><td>Team</td><td>Location</td><td>Stanley Cups</td></tr>"
"<tr><td>Blues</td><td>STL</td><td>1</td></tr>"
"<tr><td>Flyers</td><td>PHI</td><td>2</td></tr>"
"<tr><td>Maple Leafs</td><td>TOR</td><td>13</td></tr>"
"</table>"
)
EXPECTED_TABLE_WITH_LINE_DELIMITER = (
"<table>"
"<tr><td>col1</td><td>col2</td><td>col3</td></tr>"
"<tr><td>a</td><td>b</td><td>c</td></tr>"
"<tr><td>d</td><td>e</td><td>f</td></tr>"
"<tr><td>g</td><td>h</td><td>i</td></tr>"
"</table>"
)
EXPECTED_TITLE = "Stanley Cups"
EXPECTED_TEXT = (
"Stanley Cups Team Location Stanley Cups Blues STL 1 Flyers PHI 2 Maple Leafs TOR 13"
)
EXPECTED_TEXT_XLSX = "Team Location Stanley Cups Blues STL 1 Flyers PHI 2 Maple Leafs TOR 13"
EXPECTED_TEXT_WITH_EMOJI = (
"Stanley Cups "
"Team Location Stanley Cups Blues STL 1 Flyers PHI 2 Maple Leafs TOR 13 👨\\U+1F3FB🔧 TOR 15"
)
EXPECTED_TEXT_SEMICOLON_DELIMITER = (
"Year Month Revenue Costs 2022 1 123 -123 2023 2 143,1 -814,38 2024 3 215,32 -11,08"
)
EXPECTED_TEXT_WITH_LINE_DELIMITER = "col1 col2 col3 a b c d e f g h i"
EXPECTED_XLS_TABLE = (
"<table><tr>"
"<td>MC</td>"
"<td>What is 2+2?</td>"
"<td>4</td>"
"<td>correct</td>"
"<td>3</td>"
"<td>incorrect</td>"
"<td/>"
"<td/>"
"<td/>"
"</tr><tr>" # -----
"<td>MA</td>"
"<td>What C datatypes are 8 bits? (assume i386)</td>"
"<td>int</td>"
"<td/>"
"<td>float</td>"
"<td/>"
"<td>double</td>"
"<td/>"
"<td>char</td>"
"</tr><tr>" # -----
"<td>TF</td>"
"<td>Bagpipes are awesome.</td>"
"<td>true</td>"
"<td/>"
"<td/>"
"<td/>"
"<td/>"
"<td/>"
"<td/>"
"</tr><tr>" # -----
"<td>ESS</td>"
"<td>How have the original Henry Hornbostel buildings influenced campus architecture and"
" design in the last 30 years?</td>"
"<td/>"
"<td/>"
"<td/>"
"<td/>"
"<td/>"
"<td/>"
"<td/>"
"</tr><tr>" # -----
"<td>ORD</td>"
"<td>Rank the following in their order of operation.</td>"
"<td>Parentheses</td>"
"<td>Exponents</td>"
"<td>Division</td>"
"<td>Addition</td>"
"<td/>"
"<td/>"
"<td/>"
"</tr><tr>" # -----
"<td>FIB</td>"
"<td>The student activities fee is</td>"
"<td>95</td>"
"<td>dollars for students enrolled in</td>"
"<td>19</td>"
"<td>units or more,</td>"
"<td/>"
"<td/>"
"<td/>"
"</tr><tr>" # -----
"<td>MAT</td>"
"<td>Match the lower-case greek letter with its capital form.</td>"
"<td>λ</td>"
"<td>Λ</td>"
"<td>α</td>"
"<td>γ</td>"
"<td>Γ</td>"
"<td>φ</td>"
"<td>Φ</td>"
"</tr></table>"
)
+329
View File
@@ -0,0 +1,329 @@
# pyright: reportPrivateUsage=false
from __future__ import annotations
import io
import pytest
from pytest_mock import MockFixture
from test_unstructured.partition.test_constants import (
EXPECTED_TABLE,
EXPECTED_TABLE_SEMICOLON_DELIMITER,
EXPECTED_TABLE_WITH_EMOJI,
EXPECTED_TABLE_WITH_LINE_DELIMITER,
EXPECTED_TEXT,
EXPECTED_TEXT_SEMICOLON_DELIMITER,
EXPECTED_TEXT_WITH_EMOJI,
EXPECTED_TEXT_WITH_LINE_DELIMITER,
EXPECTED_TEXT_XLSX,
)
from test_unstructured.unit_utils import (
FixtureRequest,
Mock,
assert_round_trips_through_JSON,
example_doc_path,
function_mock,
)
from unstructured.chunking.title import chunk_by_title
from unstructured.cleaners.core import clean_extra_whitespace
from unstructured.documents.elements import Table
from unstructured.partition.csv import _CsvPartitioningContext, partition_csv
from unstructured.partition.utils.constants import UNSTRUCTURED_INCLUDE_DEBUG_METADATA
EXPECTED_FILETYPE = "text/csv"
@pytest.mark.parametrize(
("filename", "expected_text", "expected_table"),
[
("stanley-cups.csv", EXPECTED_TEXT, EXPECTED_TABLE),
("stanley-cups-with-emoji.csv", EXPECTED_TEXT_WITH_EMOJI, EXPECTED_TABLE_WITH_EMOJI),
(
"table-semicolon-delimiter.csv",
EXPECTED_TEXT_SEMICOLON_DELIMITER,
EXPECTED_TABLE_SEMICOLON_DELIMITER,
),
(
"csv-with-line-delimiter.csv",
EXPECTED_TEXT_WITH_LINE_DELIMITER,
EXPECTED_TABLE_WITH_LINE_DELIMITER,
),
],
)
def test_partition_csv_from_filename(filename: str, expected_text: str, expected_table: str):
f_path = f"example-docs/{filename}"
elements = partition_csv(filename=f_path)
assert clean_extra_whitespace(elements[0].text) == expected_text
assert elements[0].metadata.text_as_html == expected_table
assert elements[0].metadata.filetype == EXPECTED_FILETYPE
assert elements[0].metadata.filename == filename
@pytest.mark.parametrize("infer_table_structure", [True, False])
def test_partition_csv_from_filename_infer_table_structure(infer_table_structure: bool):
f_path = "example-docs/stanley-cups.csv"
elements = partition_csv(filename=f_path, infer_table_structure=infer_table_structure)
table_element_has_text_as_html_field = (
hasattr(elements[0].metadata, "text_as_html")
and elements[0].metadata.text_as_html is not None
)
assert table_element_has_text_as_html_field == infer_table_structure
def test_partition_csv_from_filename_with_metadata_filename():
elements = partition_csv(example_doc_path("stanley-cups.csv"), metadata_filename="test")
assert clean_extra_whitespace(elements[0].text) == EXPECTED_TEXT
assert elements[0].metadata.filename == "test"
def test_partition_csv_with_encoding():
elements = partition_csv(example_doc_path("stanley-cups-utf-16.csv"), encoding="utf-16")
assert clean_extra_whitespace(elements[0].text) == EXPECTED_TEXT
@pytest.mark.parametrize(
("filename", "expected_text", "expected_table"),
[
("stanley-cups.csv", EXPECTED_TEXT, EXPECTED_TABLE),
("stanley-cups-with-emoji.csv", EXPECTED_TEXT_WITH_EMOJI, EXPECTED_TABLE_WITH_EMOJI),
],
)
def test_partition_csv_from_file(filename: str, expected_text: str, expected_table: str):
f_path = f"example-docs/{filename}"
with open(f_path, "rb") as f:
elements = partition_csv(file=f)
assert clean_extra_whitespace(elements[0].text) == expected_text
assert isinstance(elements[0], Table)
assert elements[0].metadata.text_as_html == expected_table
assert elements[0].metadata.filetype == EXPECTED_FILETYPE
assert elements[0].metadata.filename is None
if UNSTRUCTURED_INCLUDE_DEBUG_METADATA:
assert {element.metadata.detection_origin for element in elements} == {"csv"}
def test_partition_csv_from_file_with_metadata_filename():
with open(example_doc_path("stanley-cups.csv"), "rb") as f:
elements = partition_csv(file=f, metadata_filename="test")
assert clean_extra_whitespace(elements[0].text) == EXPECTED_TEXT
assert elements[0].metadata.filename == "test"
# -- .metadata.last_modified ---------------------------------------------------------------------
def test_partition_csv_from_file_path_gets_last_modified_from_filesystem(mocker: MockFixture):
filesystem_last_modified = "2029-07-05T09:24:28"
mocker.patch(
"unstructured.partition.csv.get_last_modified_date",
return_value=filesystem_last_modified,
)
elements = partition_csv(example_doc_path("stanley-cups.csv"))
assert elements[0].metadata.last_modified == filesystem_last_modified
def test_partition_csv_from_file_path_prefers_metadata_last_modified(mocker: MockFixture):
filesystem_last_modified = "2029-07-05T09:24:28"
metadata_last_modified = "2020-07-05T09:24:28"
mocker.patch(
"unstructured.partition.csv.get_last_modified_date", return_value=filesystem_last_modified
)
elements = partition_csv(
example_doc_path("stanley-cups.csv"), metadata_last_modified=metadata_last_modified
)
assert elements[0].metadata.last_modified == metadata_last_modified
def test_partition_csv_from_file_gets_last_modified_None():
with open(example_doc_path("stanley-cups.csv"), "rb") as f:
elements = partition_csv(file=f)
assert elements[0].metadata.last_modified is None
def test_partition_csv_from_file_prefers_metadata_last_modified():
metadata_last_modified = "2020-07-05T09:24:28"
with open(example_doc_path("stanley-cups.csv"), "rb") as f:
elements = partition_csv(file=f, metadata_last_modified=metadata_last_modified)
assert elements[0].metadata.last_modified == metadata_last_modified
# ------------------------------------------------------------------------------------------------
@pytest.mark.parametrize("filename", ["stanley-cups.csv", "stanley-cups-with-emoji.csv"])
def test_partition_csv_with_json(filename: str):
elements = partition_csv(filename=example_doc_path(filename))
assert_round_trips_through_JSON(elements)
def test_add_chunking_strategy_to_partition_csv_non_default():
filename = "example-docs/stanley-cups.csv"
elements = partition_csv(filename=filename)
chunk_elements = partition_csv(
filename,
chunking_strategy="by_title",
max_characters=9,
combine_text_under_n_chars=0,
include_header=False,
)
chunks = chunk_by_title(elements, max_characters=9, combine_text_under_n_chars=0)
assert chunk_elements != elements
assert chunk_elements == chunks
# NOTE (jennings) partition_csv returns a single TableElement per sheet,
# so leaving off additional tests for multiple languages like the other partitions
def test_partition_csv_element_metadata_has_languages():
filename = "example-docs/stanley-cups.csv"
elements = partition_csv(filename=filename, strategy="fast", include_header=False)
assert elements[0].metadata.languages == ["eng"]
def test_partition_csv_respects_languages_arg():
filename = "example-docs/stanley-cups.csv"
elements = partition_csv(
filename=filename, strategy="fast", languages=["deu"], include_header=False
)
assert elements[0].metadata.languages == ["deu"]
def test_partition_csv_header():
elements = partition_csv(
example_doc_path("stanley-cups.csv"), strategy="fast", include_header=True
)
table = elements[0]
assert table.text == "Stanley Cups Unnamed: 1 Unnamed: 2 " + EXPECTED_TEXT_XLSX
assert table.metadata.text_as_html is not None
# ================================================================================================
# UNIT-TESTS
# ================================================================================================
class Describe_CsvPartitioningContext:
"""Unit-test suite for `unstructured.partition.csv._CsvPartitioningContext`."""
# -- .load() ------------------------------------------------
def it_provides_a_validating_alternate_constructor(self):
ctx = _CsvPartitioningContext.load(
file_path=example_doc_path("stanley-cups.csv"),
file=None,
encoding=None,
include_header=True,
infer_table_structure=True,
)
assert isinstance(ctx, _CsvPartitioningContext)
def and_the_validating_constructor_raises_on_an_invalid_context(self):
with pytest.raises(ValueError, match="either file-path or file-like object must be prov"):
_CsvPartitioningContext.load(
file_path=None,
file=None,
encoding=None,
include_header=True,
infer_table_structure=True,
)
# -- .delimiter ---------------------------------------------
@pytest.mark.parametrize(
"file_name",
[
"stanley-cups.csv",
# -- Issue #2643: previously raised `_csv.Error: Could not determine delimiter` on
# -- this file
"csv-with-long-lines.csv",
],
)
def it_auto_detects_the_delimiter_for_a_comma_delimited_CSV_file(self, file_name: str):
ctx = _CsvPartitioningContext(example_doc_path(file_name))
assert ctx.delimiter == ","
def and_it_auto_detects_the_delimiter_for_a_semicolon_delimited_CSV_file(self):
ctx = _CsvPartitioningContext(example_doc_path("semicolon-delimited.csv"))
assert ctx.delimiter == ";"
def but_it_returns_None_as_the_delimiter_for_a_single_column_CSV_file(self):
ctx = _CsvPartitioningContext(example_doc_path("single-column.csv"))
assert ctx.delimiter is None
# -- .header ------------------------------------------------
@pytest.mark.parametrize(("include_header", "expected_value"), [(False, None), (True, 0)])
def it_identifies_the_header_row_based_on_include_header_arg(
self, include_header: bool, expected_value: int | None
):
assert _CsvPartitioningContext(include_header=include_header).header == expected_value
# -- .last_modified -----------------------------------------
def it_gets_last_modified_from_the_filesystem_when_a_path_is_provided(
self, get_last_modified_date_: Mock
):
filesystem_last_modified = "2024-08-04T02:23:53"
get_last_modified_date_.return_value = filesystem_last_modified
ctx = _CsvPartitioningContext(file_path="a/b/document.csv")
last_modified = ctx.last_modified
get_last_modified_date_.assert_called_once_with("a/b/document.csv")
assert last_modified == filesystem_last_modified
def and_it_falls_back_to_None_for_the_last_modified_date_when_file_path_is_not_provided(self):
file = io.BytesIO(b"abcdefg")
ctx = _CsvPartitioningContext(file=file)
last_modified = ctx.last_modified
assert last_modified is None
# -- .open() ------------------------------------------------
def it_provides_transparent_access_to_the_source_file_when_it_is_a_file_like_object(self):
with open(example_doc_path("stanley-cups.csv"), "rb") as f:
# -- read so file cursor is at end of file --
f.read()
ctx = _CsvPartitioningContext(file=f)
with ctx.open() as file:
assert file is f
# -- read cursor is reset to 0 on .open() context entry --
assert f.tell() == 0
assert file.read(14) == b"Stanley Cups,,"
assert f.tell() == 14
# -- and read cursor is reset to 0 on .open() context exit --
assert f.tell() == 0
def it_provides_transparent_access_to_the_source_file_when_it_is_a_file_path(self):
ctx = _CsvPartitioningContext(example_doc_path("stanley-cups.csv"))
with ctx.open() as file:
assert file.read(14) == b"Stanley Cups,,"
# -- .validate() --------------------------------------------
def it_raises_when_neither_file_path_nor_file_is_provided(self):
with pytest.raises(ValueError, match="either file-path or file-like object must be prov"):
_CsvPartitioningContext()._validate()
# -- fixtures --------------------------------------------------------------------------------
@pytest.fixture()
def get_last_modified_date_(self, request: FixtureRequest) -> Mock:
return function_mock(request, "unstructured.partition.csv.get_last_modified_date")
+283
View File
@@ -0,0 +1,283 @@
# pyright: reportPrivateUsage=false
"""Test suite for `unstructured.partition.doc` module."""
from __future__ import annotations
import pathlib
from typing import Any, Iterator
import pytest
from pytest_mock import MockFixture
from test_unstructured.unit_utils import (
ANY,
CaptureFixture,
FixtureRequest,
assert_round_trips_through_JSON,
example_doc_path,
method_mock,
)
from unstructured.chunking.basic import chunk_elements
from unstructured.documents.elements import (
Address,
CompositeElement,
Element,
ListItem,
NarrativeText,
Table,
TableChunk,
Text,
Title,
)
from unstructured.partition.doc import partition_doc
from unstructured.partition.docx import partition_docx
def test_partition_doc_matches_partition_docx(request: FixtureRequest):
doc_file_path = example_doc_path("simple.doc")
docx_file_path = example_doc_path("simple.docx")
assert partition_doc(doc_file_path) == partition_docx(docx_file_path)
# -- document-source (file or filename) ----------------------------------------------------------
def test_partition_doc_from_filename(expected_elements: list[Element], capsys: CaptureFixture[str]):
elements = partition_doc(example_doc_path("simple.doc"))
assert elements == expected_elements
assert all(e.metadata.file_directory == example_doc_path("") for e in elements)
assert capsys.readouterr().out == ""
assert capsys.readouterr().err == ""
def test_partition_doc_from_file_with_libre_office_filter(
expected_elements: list[Element], capsys: CaptureFixture[str]
):
with open(example_doc_path("simple.doc"), "rb") as f:
elements = partition_doc(file=f, libre_office_filter="MS Word 2007 XML")
assert elements == expected_elements
assert capsys.readouterr().out == ""
assert capsys.readouterr().err == ""
def test_partition_doc_from_file_with_no_libre_office_filter(
expected_elements: list[Element], capsys: CaptureFixture[str]
):
with open(example_doc_path("simple.doc"), "rb") as f:
elements = partition_doc(file=f, libre_office_filter=None)
assert elements == expected_elements
assert capsys.readouterr().out == ""
assert capsys.readouterr().err == ""
assert all(e.metadata.filename is None for e in elements)
def test_partition_doc_raises_when_both_a_filename_and_file_are_specified():
doc_file_path = example_doc_path("simple.doc")
with open(doc_file_path, "rb") as f:
with pytest.raises(ValueError, match="Exactly one of filename and file must be specified"):
partition_doc(filename=doc_file_path, file=f)
def test_partition_doc_raises_when_neither_a_file_path_nor_a_file_like_object_are_provided():
with pytest.raises(ValueError, match="Exactly one of filename and file must be specified"):
partition_doc()
def test_partition_raises_with_missing_doc(tmp_path: pathlib.Path):
doc_filename = str(tmp_path / "asdf.doc")
with pytest.raises(ValueError, match="asdf.doc does not exist"):
partition_doc(filename=doc_filename)
# -- .metadata.filename --------------------------------------------------------------------------
def test_partition_doc_from_filename_gets_filename_from_filename_arg():
elements = partition_doc(example_doc_path("simple.doc"))
assert len(elements) > 0
assert all(e.metadata.filename == "simple.doc" for e in elements)
def test_partition_doc_from_file_gets_filename_None():
with open(example_doc_path("simple.doc"), "rb") as f:
elements = partition_doc(file=f)
assert len(elements) > 0
assert all(e.metadata.filename is None for e in elements)
def test_partition_doc_from_filename_prefers_metadata_filename():
elements = partition_doc(example_doc_path("simple.doc"), metadata_filename="test")
assert len(elements) > 0
assert all(element.metadata.filename == "test" for element in elements)
def test_partition_doc_from_file_prefers_metadata_filename():
with open(example_doc_path("simple.doc"), "rb") as f:
elements = partition_doc(file=f, metadata_filename="test")
assert all(e.metadata.filename == "test" for e in elements)
# -- .metadata.filetype --------------------------------------------------------------------------
def test_partition_doc_gets_the_DOC_MIME_type_in_metadata_filetype():
DOC_MIME_TYPE = "application/msword"
elements = partition_doc(example_doc_path("simple.doc"))
assert all(e.metadata.filetype == DOC_MIME_TYPE for e in elements), (
f"Expected all elements to have '{DOC_MIME_TYPE}' as their filetype, but got:"
f" {repr(elements[0].metadata.filetype)}"
)
# -- .metadata.last_modified ---------------------------------------------------------------------
def test_partition_doc_pulls_last_modified_from_filesystem(mocker: MockFixture):
filesystem_last_modified = "2029-07-05T09:24:28"
mocker.patch(
"unstructured.partition.doc.get_last_modified_date", return_value=filesystem_last_modified
)
elements = partition_doc(example_doc_path("fake.doc"))
assert all(e.metadata.last_modified == filesystem_last_modified for e in elements)
def test_partition_doc_prefers_metadata_last_modified_when_provided(
mocker: MockFixture,
):
filesystem_last_modified = "2029-07-05T09:24:28"
metadata_last_modified = "2020-07-05T09:24:28"
mocker.patch(
"unstructured.partition.doc.get_last_modified_date", return_value=filesystem_last_modified
)
elements = partition_doc(
example_doc_path("simple.doc"), metadata_last_modified=metadata_last_modified
)
assert all(e.metadata.last_modified == metadata_last_modified for e in elements)
# -- language-recognition metadata ---------------------------------------------------------------
def test_partition_doc_adds_languages_metadata():
elements = partition_doc(example_doc_path("simple.doc"))
assert all(e.metadata.languages == ["eng"] for e in elements)
def test_partition_doc_respects_detect_language_per_element_arg():
elements = partition_doc(
example_doc_path("language-docs/eng_spa_mult.doc"), detect_language_per_element=True
)
assert [e.metadata.languages for e in elements] == [
["eng"],
["spa", "eng"],
["eng"],
["eng"],
["spa"],
]
# -- miscellaneous -------------------------------------------------------------------------------
@pytest.mark.parametrize(
("kwargs", "expected_value"),
[({}, "hi_res"), ({"strategy": None}, "hi_res"), ({"strategy": "auto"}, "auto")],
)
def test_partition_odt_forwards_strategy_arg_to_partition_docx(
request: FixtureRequest, kwargs: dict[str, Any], expected_value: str | None
):
from unstructured.partition.docx import _DocxPartitioner
def fake_iter_document_elements(self: _DocxPartitioner) -> Iterator[Element]:
yield Text(f"strategy == {self._opts.strategy}")
_iter_elements_ = method_mock(
request,
_DocxPartitioner,
"_iter_document_elements",
side_effect=fake_iter_document_elements,
)
(element,) = partition_doc(example_doc_path("simple.doc"), **kwargs)
_iter_elements_.assert_called_once_with(ANY)
assert element.text == f"strategy == {expected_value}"
def test_partition_doc_grabs_emphasized_texts():
expected_emphasized_text_contents = ["bold", "italic", "bold-italic", "bold-italic"]
expected_emphasized_text_tags = ["b", "i", "b", "i"]
elements = partition_doc(example_doc_path("fake-doc-emphasized-text.doc"))
assert isinstance(elements[0], Table)
assert elements[0].metadata.emphasized_text_contents == expected_emphasized_text_contents
assert elements[0].metadata.emphasized_text_tags == expected_emphasized_text_tags
assert elements[1] == NarrativeText("I am a bold italic bold-italic text.")
assert elements[1].metadata.emphasized_text_contents == expected_emphasized_text_contents
assert elements[1].metadata.emphasized_text_tags == expected_emphasized_text_tags
assert elements[2] == NarrativeText("I am a normal text.")
assert elements[2].metadata.emphasized_text_contents is None
assert elements[2].metadata.emphasized_text_tags is None
def test_partition_doc_round_trips_through_json():
"""Elements produced can be serialized then deserialized without loss."""
assert_round_trips_through_JSON(partition_doc(example_doc_path("simple.doc")))
def test_partition_doc_chunks_elements_when_chunking_strategy_is_specified():
document_path = example_doc_path("simple.doc")
elements = partition_doc(document_path)
chunks = partition_doc(document_path, chunking_strategy="basic")
# -- all chunks are chunk element-types --
assert all(isinstance(c, (CompositeElement, Table, TableChunk)) for c in chunks)
# -- chunks from partitioning match those produced by chunking elements in separate step --
assert chunks == chunk_elements(elements)
def test_partition_doc_assigns_deterministic_and_unique_element_ids():
document_path = example_doc_path("duplicate-paragraphs.doc")
ids = [element.id for element in partition_doc(document_path)]
ids_2 = [element.id for element in partition_doc(document_path)]
# -- ids should match even though partitioned separately --
assert ids == ids_2
# -- ids should be unique --
assert len(ids) == len(set(ids))
# == module-level fixtures =======================================================================
@pytest.fixture()
def expected_elements() -> list[Element]:
return [
Title("These are a few of my favorite things:"),
ListItem("Parrots"),
ListItem("Hockey"),
Text("Analysis"),
NarrativeText("This is my first thought. This is my second thought."),
NarrativeText("This is my third thought."),
Text("2023"),
Address("DOYLESTOWN, PA 18901"),
]
File diff suppressed because it is too large Load Diff
+636
View File
@@ -0,0 +1,636 @@
"""Test suite for `unstructured.partition.email` module."""
from __future__ import annotations
import io
import tempfile
from email.message import EmailMessage
from typing import Any
import pytest
from test_unstructured.unit_utils import (
FixtureRequest,
Mock,
assert_round_trips_through_JSON,
example_doc_path,
function_mock,
)
from unstructured.chunking.title import chunk_by_title
from unstructured.documents.elements import (
CompositeElement,
ListItem,
NarrativeText,
Table,
TableChunk,
Text,
Title,
)
from unstructured.partition.email import EmailPartitioningContext, partition_email
EXPECTED_OUTPUT = [
NarrativeText(text="This is a test email to use for unit tests."),
Text(text="Important points:"),
ListItem(text="Roses are red"),
ListItem(text="Violets are blue"),
]
def test_partition_email_from_filename_can_partition_an_RFC_822_email():
assert partition_email(example_doc_path("eml/simple-rfc-822.eml")) == [
NarrativeText("This is an RFC 822 email message."),
NarrativeText(
"An RFC 822 message is characterized by its simple, text-based format, which includes"
' a header and a body. The header contains structured fields such as "From", "To",'
' "Date", and "Subject", each followed by a colon and the corresponding information.'
" The body follows the header, separated by a blank line, and contains the main"
" content of the email."
),
NarrativeText(
"The structure ensures compatibility and readability across different email systems"
" and clients, adhering to the standards set by the Internet Engineering Task Force"
" (IETF)."
),
]
def test_partition_email_from_file_can_partition_an_email():
with open(example_doc_path("eml/fake-email.eml"), "rb") as f:
assert partition_email(file=f) == EXPECTED_OUTPUT
def test_partition_email_from_spooled_temp_file_can_partition_an_email():
with tempfile.SpooledTemporaryFile() as file:
with open(example_doc_path("eml/fake-email.eml"), "rb") as f:
file.write(f.read())
file.seek(0)
assert partition_email(file=file) == EXPECTED_OUTPUT
def test_partition_email_can_partition_an_HTML_only_email_with_Base64_ISO_8859_1_charset():
assert partition_email(example_doc_path("eml/mime-html-only.eml")) == [
NarrativeText("This is a text/html part."),
NarrativeText(
"The first emoticon, :) , was proposed by Scott Fahlman in 1982 to indicate just or"
" sarcasm in text emails."
),
NarrativeText(
"Gmail was launched by Google in 2004 with 1 GB of free storage, significantly more"
" than what other services offered at the time."
),
]
def test_extract_email_from_text_plain_matches_elements_extracted_from_text_html():
file_path = example_doc_path("eml/fake-email.eml")
elements_from_text = partition_email(file_path, content_source="text/plain")
elements_from_html = partition_email(file_path, content_source="text/html")
assert all(e.text == eo.text for e, eo in zip(elements_from_text, EXPECTED_OUTPUT))
assert elements_from_html == EXPECTED_OUTPUT
assert all(eh.text == et.text for eh, et in zip(elements_from_html, elements_from_text))
def test_partition_email_round_trips_via_json():
elements = partition_email(example_doc_path("eml/fake-email.eml"))
assert_round_trips_through_JSON(elements)
# -- transfer-encodings --------------------------------------------------------------------------
def test_partition_email_partitions_an_HTML_part_with_Base64_encoded_UTF_8_charset():
assert partition_email(example_doc_path("eml/fake-email-b64.eml")) == EXPECTED_OUTPUT
def test_partition_email_partitions_a_text_plain_part_with_Base64_encoded_windows_1255_charset():
elements = partition_email(
example_doc_path("eml/email-no-utf8-2008-07-16.062410.eml"),
content_source="text/plain",
)
assert len(elements) == 30
assert elements[1].text.startswith("אני חושב שזה לא יהיה מקצועי והוגן שאני אראה לך היכן")
def test_partition_email_partitions_an_html_part_with_quoted_printable_encoded_ISO_8859_1_charset():
elements = partition_email(
example_doc_path("eml/email-no-utf8-2014-03-17.111517.eml"),
content_source="text/html",
process_attachments=False,
)
assert len(elements) == 1
assert isinstance(elements[0], Table)
assert elements[0].text.startswith("Slava Gxyzxyz Hi Slava, The password for your Google")
# -- edge-cases ----------------------------------------------------------------------------------
def test_partition_email_accepts_a_whitespace_only_file():
"""Should produce no elements but should not raise an exception."""
assert partition_email(example_doc_path("eml/empty.eml")) == []
def test_partition_email_can_partition_an_empty_email():
assert (
partition_email(example_doc_path("eml/mime-no-body.eml"), process_attachments=False) == []
)
def test_partition_email_does_not_break_on_an_encrypted_message():
assert (
partition_email(example_doc_path("eml/fake-encrypted.eml"), process_attachments=False) == []
)
def test_partition_email_finds_content_when_it_is_marked_with_content_disposition_inline():
elements = partition_email(
example_doc_path("eml/email-inline-content-disposition.eml"), process_attachments=False
)
assert len(elements) == 1
e = elements[0]
assert isinstance(e, Text)
assert e.text == "This is a test of inline"
def test_partition_email_from_filename_malformed_encoding():
elements = partition_email(filename=example_doc_path("eml/fake-email-malformed-encoding.eml"))
assert elements == EXPECTED_OUTPUT
# -- error behaviors -----------------------------------------------------------------------------
def test_partition_email_raises_when_no_message_source_is_specified():
with pytest.raises(ValueError, match="no document specified; either a `filename` or `file`"):
partition_email()
def test_partition_email_raises_with_invalid_content_type():
with pytest.raises(ValueError, match="'application/json' is not a valid value for content_s"):
partition_email(example_doc_path("eml/fake-email.eml"), content_source="application/json")
# -- .metadata -----------------------------------------------------------------------------------
def test_partition_email_augments_message_body_elements_with_email_metadata():
elements = partition_email(example_doc_path("eml/mime-multi-to-cc-bcc.eml"))
assert all(
e.metadata.bcc_recipient == ["John <john@example.com>", "Mary <mary@example.com>"]
for e in elements
)
assert all(
e.metadata.cc_recipient == ["Tom <tom@example.com>", "Alice <alice@example.com>"]
for e in elements
)
assert all(e.metadata.email_message_id == "2143658709@example.com" for e in elements)
assert all(e.metadata.sent_from == ["sender@example.com"] for e in elements)
assert all(
e.metadata.sent_to == ["Bob <bob@example.com>", "Sue <sue@example.com>"] for e in elements
)
assert all(e.metadata.subject == "Example Plain-Text MIME Message" for e in elements)
# -- .metadata.filename --------------------------------------------------------------------------
def test_partition_email_from_filename_gets_filename_metadata_from_file_path():
elements = partition_email(example_doc_path("eml/fake-email.eml"))
assert all(e.metadata.filename == "fake-email.eml" for e in elements)
assert all(e.metadata.file_directory == example_doc_path("eml") for e in elements)
def test_partition_email_from_file_gets_filename_metadata_None():
with open(example_doc_path("eml/fake-email.eml"), "rb") as f:
elements = partition_email(file=f)
assert all(e.metadata.filename is None for e in elements)
assert all(e.metadata.file_directory is None for e in elements)
def test_partition_email_from_filename_prefers_metadata_filename():
elements = partition_email(
example_doc_path("eml/fake-email.eml"), metadata_filename="a/b/c.eml"
)
assert all(e.metadata.filename == "c.eml" for e in elements)
assert all(e.metadata.file_directory == "a/b" for e in elements)
def test_partition_email_from_file_prefers_metadata_filename():
with open(example_doc_path("eml/fake-email.eml"), "rb") as f:
elements = partition_email(file=f, metadata_filename="d/e/f.eml")
assert all(e.metadata.filename == "f.eml" for e in elements)
assert all(e.metadata.file_directory == "d/e" for e in elements)
# -- .metadata.filetype --------------------------------------------------------------------------
def test_partition_email_gets_the_EML_MIME_type_in_metadata_filetype_for_message_body_elements():
EML_MIME_TYPE = "message/rfc822"
elements = partition_email(example_doc_path("eml/fake-email.eml"))
assert all(e.metadata.filetype == EML_MIME_TYPE for e in elements), (
f"Expected all elements to have '{EML_MIME_TYPE}' as their filetype, but got:"
f" {repr(elements[0].metadata.filetype)}"
)
# -- .metadata.languages -------------------------------------------------------------------------
def test_partition_email_element_metadata_has_languages():
elements = partition_email(example_doc_path("eml/fake-email.eml"))
assert all(e.metadata.languages == ["eng"] for e in elements)
def test_partition_email_respects_languages_arg():
elements = partition_email(example_doc_path("eml/fake-email.eml"), languages=["deu"])
assert all(element.metadata.languages == ["deu"] for element in elements)
def test_partition_eml_respects_detect_language_per_element():
elements = partition_email(
example_doc_path("language-docs/eng_spa_mult.eml"),
detect_language_per_element=True,
)
# languages other than English and Spanish are detected by this partitioner,
# so this test is slightly different from the other partition tests
langs = {e.metadata.languages[0] for e in elements if e.metadata.languages is not None}
assert "eng" in langs
assert "spa" in langs
# -- .metadata.last_modified ---------------------------------------------------------------------
def test_partition_email_from_file_path_gets_last_modified_from_Date_header():
elements = partition_email(example_doc_path("eml/fake-email.eml"))
assert all(e.metadata.last_modified == "2022-12-16T22:04:16+00:00" for e in elements)
def test_partition_email_from_file_gets_last_modified_from_Date_header():
with open(example_doc_path("eml/fake-email.eml"), "rb") as f:
elements = partition_email(file=f)
assert all(e.metadata.last_modified == "2022-12-16T22:04:16+00:00" for e in elements)
def test_partition_email_from_file_path_prefers_metadata_last_modified():
metadata_last_modified = "2020-07-05T09:24:28"
elements = partition_email(
example_doc_path("eml/fake-email.eml"), metadata_last_modified=metadata_last_modified
)
assert all(e.metadata.last_modified == metadata_last_modified for e in elements)
def test_partition_email_from_file_prefers_metadata_last_modified():
metadata_last_modified = "2020-07-05T09:24:28"
with open(example_doc_path("eml/fake-email.eml"), "rb") as f:
elements = partition_email(file=f, metadata_last_modified=metadata_last_modified)
assert all(e.metadata.last_modified == metadata_last_modified for e in elements)
# -- chunking ------------------------------------------------------------------------------------
def test_partition_email_chunks_when_so_instructed():
"""Note it's actually the delegate partitioners that do the chunking."""
elements = partition_email(example_doc_path("eml/fake-email.txt"))
chunks = partition_email(example_doc_path("eml/fake-email.txt"), chunking_strategy="by_title")
separately_chunked_chunks = chunk_by_title(elements)
assert all(isinstance(c, (CompositeElement, Table, TableChunk)) for c in chunks)
assert chunks != elements
assert chunks == separately_chunked_chunks
def test_partition_email_chunks_attachments_too():
chunks = partition_email(
example_doc_path("eml/fake-email-attachment.eml"),
chunking_strategy="by_title",
process_attachments=True,
)
assert len(chunks) == 2
assert all(isinstance(c, CompositeElement) for c in chunks)
attachment_chunk = chunks[-1]
assert attachment_chunk.text == "Hey this is a fake attachment!"
assert attachment_chunk.metadata.filename == "fake-attachment.txt"
assert attachment_chunk.metadata.attached_to_filename == "fake-email-attachment.eml"
assert all(c.metadata.last_modified == "2022-12-23T18:08:48+00:00" for c in chunks)
# -- attachments ---------------------------------------------------------------------------------
def test_partition_email_also_partitions_attachments_when_so_instructed():
elements = partition_email(
example_doc_path("eml/email-equals-attachment-filename.eml"), process_attachments=True
)
assert elements == [
NarrativeText("Below is an example of an odd filename"),
Title("Odd filename"),
]
def test_partition_email_can_process_attachments():
elements = partition_email(
example_doc_path("eml/fake-email-attachment.eml"), process_attachments=True
)
assert elements == [
Text("Hello!"),
NarrativeText("Here's the attachments!"),
NarrativeText("It includes:"),
ListItem("Lots of whitespace"),
ListItem("Little to no content"),
ListItem("and is a quick read"),
Text("Best,"),
Text("Mallori"),
NarrativeText("Hey this is a fake attachment!"),
]
assert all(e.metadata.last_modified == "2022-12-23T18:08:48+00:00" for e in elements)
attachment_element = elements[-1]
assert attachment_element.text == "Hey this is a fake attachment!"
assert attachment_element.metadata.filename == "fake-attachment.txt"
assert attachment_element.metadata.attached_to_filename == "fake-email-attachment.eml"
def test_partition_email_silently_skips_attachments_it_cannot_partition():
"""Attachments that raise EXPECTED_ATTACHMENT_ERRORS (e.g. ImportError) are skipped."""
from unittest.mock import patch
with patch("unstructured.partition.auto.partition") as mock_partition:
mock_partition.side_effect = ImportError("No module named 'whisper'")
elements = partition_email(
example_doc_path("eml/mime-attach-mp3.eml"), process_attachments=True
)
# -- No exception; attachment skipped (ImportError is in EXPECTED_ATTACHMENT_ERRORS). --
assert elements == [
# -- the email body is partitioned --
NarrativeText("This is an email with an MP3 attachment."),
# -- no elements appear for the attachment --
]
# ================================================================================================
# ISOLATED UNIT TESTS
# ================================================================================================
class DescribeEmailPartitionerOptions:
"""Unit-test suite for `unstructured.partition.email.EmailPartitioningContext` objects."""
# -- .load() ---------------------------------
def it_provides_a_validating_constructor(self, ctx_args: dict[str, Any]):
ctx_args["file_path"] = example_doc_path("eml/fake-email.eml")
ctx = EmailPartitioningContext.load(**ctx_args)
assert isinstance(ctx, EmailPartitioningContext)
def but_it_raises_when_no_source_document_was_specified(self, ctx_args: dict[str, Any]):
with pytest.raises(ValueError, match="no document specified; either a `filename` or `fi"):
EmailPartitioningContext.load(**ctx_args)
def and_it_raises_when_a_file_open_for_reading_str_is_used(self, ctx_args: dict[str, Any]):
ctx_args["file"] = io.StringIO("abcdefg")
with pytest.raises(ValueError, match="file object must be opened in binary mode"):
EmailPartitioningContext.load(**ctx_args)
def and_it_raises_when_an_invalid_content_source_is_specified(self, ctx_args: dict[str, Any]):
ctx_args["file_path"] = example_doc_path("eml/fake-email.eml")
ctx_args["content_source"] = "application/json"
with pytest.raises(ValueError, match="'application/json' is not a valid value for conte"):
EmailPartitioningContext.load(**ctx_args)
# -- .bcc_addresses --------------------------
def it_provides_access_to_the_Bcc_addresses_when_present(self):
ctx = EmailPartitioningContext(example_doc_path("eml/mime-multi-to-cc-bcc.eml"))
assert ctx.bcc_addresses == ["John <john@example.com>", "Mary <mary@example.com>"]
def but_it_returns_None_when_there_are_no_Bcc_addresses(self):
ctx = EmailPartitioningContext(example_doc_path("eml/simple-rfc-822.eml"))
assert ctx.bcc_addresses is None
# -- .body_part ------------------------------
def it_returns_the_html_body_part_when_there_is_one_by_default(self):
ctx = EmailPartitioningContext(example_doc_path("eml/mime-different-plain-html.eml"))
body_part = ctx.body_part
assert isinstance(body_part, EmailMessage)
content = body_part.get_content()
assert isinstance(content, str)
assert content.startswith("<!DOCTYPE html>")
def but_it_returns_the_plain_text_body_part_when_there_is_one_when_so_requested(self):
ctx = EmailPartitioningContext(
example_doc_path("eml/mime-different-plain-html.eml"), content_source="text/plain"
)
body_part = ctx.body_part
assert isinstance(body_part, EmailMessage)
content = body_part.get_content()
assert isinstance(content, str)
assert content.startswith("This is the text/plain part.")
def and_it_returns_None_when_the_email_has_no_body(self):
ctx = EmailPartitioningContext(example_doc_path("eml/mime-no-body.eml"))
assert ctx.body_part is None
# -- .cc_addresses ---------------------------
def it_provides_access_to_the_Cc_addresses_when_present(self):
ctx = EmailPartitioningContext(example_doc_path("eml/mime-multi-to-cc-bcc.eml"))
assert ctx.cc_addresses == ["Tom <tom@example.com>", "Alice <alice@example.com>"]
def but_it_returns_None_when_there_are_no_Cc_addresses(self):
ctx = EmailPartitioningContext(example_doc_path("eml/simple-rfc-822.eml"))
assert ctx.cc_addresses is None
# -- .content_type_preference ----------------
@pytest.mark.parametrize(
("content_source", "expected_value"),
[
("text/html", ("html", "plain")),
("text/plain", ("plain", "html")),
],
)
def it_knows_whether_the_caller_prefers_the_HTML_or_plain_text_body(
self, content_source: str, expected_value: tuple[str, ...]
):
ctx = EmailPartitioningContext(content_source=content_source)
assert ctx.content_type_preference == expected_value
def and_it_defaults_to_preferring_the_HTML_body(self):
ctx = EmailPartitioningContext()
assert ctx.content_type_preference == ("html", "plain")
# -- .from -----------------------------------
def it_knows_the_From_address_of_the_email(self):
ctx = EmailPartitioningContext(example_doc_path("eml/mime-simple.eml"))
assert ctx.from_address == "sender@example.com"
# -- .message_id -----------------------------
def it_provides_access_to_the_Message_ID_when_present(self):
ctx = EmailPartitioningContext(example_doc_path("eml/mime-simple.eml"))
assert ctx.message_id == "1234567890@example.com"
def but_it_returns_None_when_there_is_no_Message_ID_header(self):
ctx = EmailPartitioningContext(example_doc_path("eml/simple-rfc-822.eml"))
assert ctx.message_id is None
# -- .metadata_file_path ---------------------
def it_uses_the_metadata_file_path_arg_value_when_one_was_provided(self):
ctx = EmailPartitioningContext(metadata_file_path="a/b/c.eml")
assert ctx.metadata_file_path == "a/b/c.eml"
def and_it_uses_the_file_path_arg_value_when_metadata_file_path_was_not_provided(self):
ctx = EmailPartitioningContext(file_path="x/y/z.eml")
assert ctx.metadata_file_path == "x/y/z.eml"
def and_it_returns_None_when_neither_file_path_was_provided(self):
ctx = EmailPartitioningContext()
assert ctx.metadata_file_path is None
# -- .metadata_last_modified -----------------
def it_uses_the_metadata_last_modified_arg_value_when_one_was_provided(self):
metadata_last_modified = "2023-04-08T12:18:07"
ctx = EmailPartitioningContext(metadata_last_modified=metadata_last_modified)
assert ctx.metadata_last_modified == metadata_last_modified
def and_it_uses_the_msg_Date_header_date_when_metadata_last_modified_was_not_provided(
self,
):
ctx = EmailPartitioningContext(example_doc_path("eml/simple-rfc-822.eml"))
assert ctx.metadata_last_modified == "2024-10-01T17:34:56+00:00"
@pytest.mark.parametrize(
("date_format", "expected_date"),
[
("test-iso-8601-date.eml", "2025-07-29T12:42:06+00:00"),
("test-rfc2822-date.eml", "2025-07-29T12:42:06+00:00"),
],
)
def and_it_correctly_parses_various_date_formats_like_the_ones_that_occur_in_the_wild(
self, date_format: str, expected_date: str
):
ctx = EmailPartitioningContext(example_doc_path(f"eml/{date_format}"))
assert ctx.metadata_last_modified == expected_date
def and_it_returns_none_when_date_header_is_invalid(self):
ctx = EmailPartitioningContext(example_doc_path("eml/test-invalid-date.eml"))
assert ctx._sent_date is None
def and_it_falls_back_to_filesystem_last_modified_when_no_Date_header_is_present(
self, get_last_modified_date_: Mock
):
"""Not an expected case as according to RFC 5322, the Date header is required."""
filesystem_last_modified = "2024-07-09T14:08:17"
get_last_modified_date_.return_value = filesystem_last_modified
ctx = EmailPartitioningContext(example_doc_path("eml/rfc822-no-date.eml"))
assert ctx.metadata_last_modified == filesystem_last_modified
def and_it_returns_None_when_no_last_modified_is_available(self):
with open(example_doc_path("eml/rfc822-no-date.eml"), "rb") as f:
ctx = EmailPartitioningContext(file=f)
assert ctx.metadata_last_modified is None
# -- .msg ------------------------------------
def it_loads_the_email_message_from_the_filesystem_when_a_path_is_provided(self):
ctx = EmailPartitioningContext(file_path=example_doc_path("eml/simple-rfc-822.eml"))
assert isinstance(ctx.msg, EmailMessage)
def and_it_loads_the_email_message_from_a_file_like_object_when_one_is_provided(self):
with open(example_doc_path("eml/simple-rfc-822.eml"), "rb") as f:
ctx = EmailPartitioningContext(file=f)
assert isinstance(ctx.msg, EmailMessage)
# -- .partitioning_kwargs --------------------
def it_passes_along_the_kwargs_it_received_on_construction(self):
kwargs = {"foo": "bar", "baz": "qux"}
ctx = EmailPartitioningContext(kwargs=kwargs)
assert ctx.partitioning_kwargs == kwargs
# -- .process_attachments --------------------
@pytest.mark.parametrize("process_attachments", [True, False])
def it_knows_whether_the_caller_wants_to_also_partition_attachments(
self, process_attachments: bool
):
ctx = EmailPartitioningContext(process_attachments=process_attachments)
assert ctx.process_attachments == process_attachments
def but_by_default_it_ignores_attachments(self):
ctx = EmailPartitioningContext()
assert ctx.process_attachments is False
# -- .subject --------------------------------
def it_provides_access_to_the_email_Subject_as_a_string(self):
ctx = EmailPartitioningContext(example_doc_path("eml/mime-word-encoded-subject.eml"))
assert ctx.subject == "Simple email with ☸☿ Unicode subject"
def but_it_returns_None_when_there_is_no_Subject_header(self):
ctx = EmailPartitioningContext(example_doc_path("eml/mime-no-subject.eml"))
assert ctx.subject is None
# -- .to_addresses ---------------------------
def it_provides_access_to_the_To_addresses_when_present(self):
ctx = EmailPartitioningContext(example_doc_path("eml/mime-multi-to-cc-bcc.eml"))
assert ctx.to_addresses == ["Bob <bob@example.com>", "Sue <sue@example.com>"]
def but_it_returns_None_when_there_are_no_To_addresses(self):
ctx = EmailPartitioningContext(example_doc_path("eml/mime-no-to.eml"))
assert ctx.to_addresses is None
# -- fixtures --------------------------------------------------------------------------------
@pytest.fixture()
def ctx_args(self) -> dict[str, Any]:
return {
"file_path": None,
"file": None,
"content_source": "text/html",
"metadata_file_path": None,
"metadata_last_modified": None,
"process_attachments": False,
"kwargs": {},
}
@pytest.fixture()
def get_last_modified_date_(self, request: FixtureRequest) -> Mock:
return function_mock(request, "unstructured.partition.email.get_last_modified_date")
+174
View File
@@ -0,0 +1,174 @@
from __future__ import annotations
from pytest_mock import MockFixture
from test_unstructured.unit_utils import assert_round_trips_through_JSON, example_doc_path
from unstructured.chunking.title import chunk_by_title
from unstructured.documents.elements import Table, Text
from unstructured.partition.epub import partition_epub
from unstructured.partition.utils.constants import UNSTRUCTURED_INCLUDE_DEBUG_METADATA
def test_partition_epub_from_filename():
elements = partition_epub(example_doc_path("simple.epub"))
assert len(elements) > 0
assert isinstance(elements[0], Text)
assert elements[1].text.startswith("a shared culture")
if UNSTRUCTURED_INCLUDE_DEBUG_METADATA:
assert {element.metadata.detection_origin for element in elements} == {"epub"}
def test_partition_epub_from_filename_returns_table_in_elements():
elements = partition_epub(example_doc_path("winter-sports.epub"))
assert elements[12] == Table(
"Contents. List of Illustrations (In certain versions of this etext [in certain\nbrowsers]"
" clicking on the image will bring up a larger\nversion.) (etext transcriber's note)"
)
def test_partition_epub_from_file():
with open(example_doc_path("winter-sports.epub"), "rb") as f:
elements = partition_epub(file=f)
assert len(elements) > 0
assert elements[2].text.startswith("The Project Gutenberg eBook of Winter Sports")
# -- .metadata.filename --------------------------------------------------------------------------
def test_partition_epub_from_filename_gets_filename_from_filename_arg():
elements = partition_epub(example_doc_path("simple.epub"))
assert len(elements) > 0
assert all(e.metadata.filename == "simple.epub" for e in elements)
def test_partition_epub_from_file_gets_filename_None():
with open(example_doc_path("simple.epub"), "rb") as f:
elements = partition_epub(file=f)
assert len(elements) > 0
assert all(e.metadata.filename is None for e in elements)
def test_partition_epub_from_filename_prefers_metadata_filename():
elements = partition_epub(example_doc_path("simple.epub"), metadata_filename="orig-name.epub")
assert len(elements) > 0
assert all(element.metadata.filename == "orig-name.epub" for element in elements)
def test_partition_epub_from_file_prefers_metadata_filename():
with open(example_doc_path("simple.epub"), "rb") as f:
elements = partition_epub(file=f, metadata_filename="orig-name.epub")
assert all(e.metadata.filename == "orig-name.epub" for e in elements)
# -- .metadata.filetype --------------------------------------------------------------------------
def test_partition_epub_gets_the_EPUB_MIME_type_in_metadata_filetype():
EPUB_MIME_TYPE = "application/epub"
elements = partition_epub(example_doc_path("simple.epub"))
assert all(e.metadata.filetype == EPUB_MIME_TYPE for e in elements), (
f"Expected all elements to have '{EPUB_MIME_TYPE}' as their filetype, but got:"
f" {repr(elements[0].metadata.filetype)}"
)
# -- .metadata.last_modified ---------------------------------------------------------------------
def test_partition_epub_from_file_path_gets_last_modified_from_filesystem(mocker: MockFixture):
filesystem_last_modified = "2024-06-14T16:01:29"
mocker.patch(
"unstructured.partition.epub.get_last_modified_date", return_value=filesystem_last_modified
)
elements = partition_epub(example_doc_path("winter-sports.epub"))
assert all(e.metadata.last_modified == filesystem_last_modified for e in elements)
def test_partition_epub_from_file_gets_last_modified_None():
with open(example_doc_path("simple.epub"), "rb") as f:
elements = partition_epub(file=f)
assert all(e.metadata.last_modified is None for e in elements)
def test_partition_epub_from_file_path_prefers_metadata_last_modified(mocker: MockFixture):
filesystem_last_modified = "2024-06-14T16:01:29"
metadata_last_modified = "2020-03-08T06:10:23"
mocker.patch(
"unstructured.partition.epub.get_last_modified_date", return_value=filesystem_last_modified
)
elements = partition_epub(
example_doc_path("winter-sports.epub"), metadata_last_modified=metadata_last_modified
)
assert all(e.metadata.last_modified == metadata_last_modified for e in elements)
def test_partition_epub_from_file_prefers_metadata_last_modified():
metadata_last_modified = "2020-03-08T06:10:23"
with open(example_doc_path("simple.epub"), "rb") as f:
elements = partition_epub(file=f, metadata_last_modified=metadata_last_modified)
assert all(e.metadata.last_modified is metadata_last_modified for e in elements)
# ------------------------------------------------------------------------------------------------
def test_partition_epub_with_json():
filename = "example-docs/winter-sports.epub"
elements = partition_epub(filename=filename)
assert_round_trips_through_JSON(elements)
def test_add_chunking_strategy_on_partition_epub():
file_path = example_doc_path("winter-sports.epub")
elements = partition_epub(file_path)
chunk_elements = partition_epub(file_path, chunking_strategy="by_title")
chunks = chunk_by_title(elements)
assert chunk_elements != elements
assert chunk_elements == chunks
def test_add_chunking_strategy_on_partition_epub_non_default():
file_path = example_doc_path("winter-sports.epub")
elements = partition_epub(filename=file_path)
chunk_elements = partition_epub(
file_path,
chunking_strategy="by_title",
max_characters=5,
new_after_n_chars=5,
combine_text_under_n_chars=0,
)
chunks = chunk_by_title(
elements,
max_characters=5,
new_after_n_chars=5,
combine_text_under_n_chars=0,
)
assert chunk_elements != elements
assert chunk_elements == chunks
def test_partition_epub_element_metadata_has_languages():
filename = example_doc_path("winter-sports.epub")
elements = partition_epub(filename=filename)
assert elements[0].metadata.languages == ["eng"]
def test_partition_epub_respects_detect_language_per_element():
filename = "example-docs/language-docs/eng_spa_mult.epub"
elements = partition_epub(filename=filename, detect_language_per_element=True)
langs = [element.metadata.languages for element in elements]
assert langs == [["eng"], ["spa", "eng"], ["eng"], ["eng"], ["spa"]]
+313
View File
@@ -0,0 +1,313 @@
"""Test-suite for `unstructured.partition.json` module."""
from __future__ import annotations
import os
import pathlib
import tempfile
import pytest
from pytest_mock import MockFixture
from test_unstructured.unit_utils import example_doc_path
from unstructured.documents.elements import CompositeElement
from unstructured.file_utils.model import FileType
from unstructured.partition.email import partition_email
from unstructured.partition.html import partition_html
from unstructured.partition.json import partition_json
from unstructured.partition.text import partition_text
from unstructured.partition.xml import partition_xml
from unstructured.staging.base import elements_to_json
DIRECTORY = pathlib.Path(__file__).parent.resolve()
is_in_docker = os.path.exists("/.dockerenv")
test_files = [
"fake-text.txt",
"fake-html.html",
"eml/fake-email.eml",
]
is_in_docker = os.path.exists("/.dockerenv")
def test_it_chunks_elements_when_a_chunking_strategy_is_specified():
chunks = partition_json(
"example-docs/spring-weather.html.json", chunking_strategy="basic", max_characters=1500
)
assert len(chunks) == 9
assert all(isinstance(ch, CompositeElement) for ch in chunks)
@pytest.mark.parametrize("filename", test_files)
def test_partition_json_from_filename(filename: str):
path = example_doc_path(filename)
elements = []
filetype = FileType.from_extension(os.path.splitext(path)[1])
if filetype == FileType.TXT:
elements = partition_text(filename=path)
if filetype == FileType.HTML:
elements = partition_html(filename=path)
if filetype == FileType.XML:
elements = partition_xml(filename=path)
if filetype == FileType.EML:
elements = partition_email(filename=path)
with tempfile.TemporaryDirectory() as tmpdir:
_filename = os.path.basename(filename)
test_path = os.path.join(tmpdir, _filename + ".json")
elements_to_json(elements, filename=test_path, indent=2)
test_elements = partition_json(filename=test_path)
assert len(elements) > 0
assert len(str(elements[0])) > 0
assert len(elements) == len(test_elements)
for i in range(len(elements)):
assert elements[i] == test_elements[i]
assert elements[i].metadata.filename == filename.split("/")[-1]
@pytest.mark.parametrize("filename", test_files)
def test_partition_json_from_filename_with_metadata_filename(filename: str):
path = example_doc_path(filename)
elements = []
filetype = FileType.from_extension(os.path.splitext(path)[1])
if filetype == FileType.TXT:
elements = partition_text(filename=path)
if filetype == FileType.HTML:
elements = partition_html(filename=path)
if filetype == FileType.XML:
elements = partition_xml(filename=path)
if filetype == FileType.EML:
elements = partition_email(filename=path)
with tempfile.TemporaryDirectory() as tmpdir:
_filename = os.path.basename(filename)
test_path = os.path.join(tmpdir, _filename + ".json")
elements_to_json(elements, filename=test_path, indent=2)
test_elements = partition_json(filename=test_path, metadata_filename="test")
assert len(test_elements) > 0
assert len(str(test_elements[0])) > 0
assert all(element.metadata.filename == "test" for element in test_elements)
@pytest.mark.parametrize("filename", test_files)
def test_partition_json_from_file(filename: str):
path = example_doc_path(filename)
elements = []
filetype = FileType.from_extension(os.path.splitext(path)[1])
if filetype == FileType.TXT:
elements = partition_text(filename=path)
if filetype == FileType.HTML:
elements = partition_html(filename=path)
if filetype == FileType.XML:
elements = partition_xml(filename=path)
if filetype == FileType.EML:
elements = partition_email(filename=path)
with tempfile.TemporaryDirectory() as tmpdir:
_filename = os.path.basename(filename)
test_path = os.path.join(tmpdir, _filename + ".json")
elements_to_json(elements, filename=test_path, indent=2)
with open(test_path, "rb") as f:
test_elements = partition_json(file=f)
assert len(elements) > 0
assert len(str(elements[0])) > 0
assert len(elements) == len(test_elements)
for i in range(len(elements)):
assert elements[i] == test_elements[i]
assert elements[i].metadata.filename == filename.split("/")[-1]
@pytest.mark.parametrize("filename", test_files)
def test_partition_json_from_file_with_metadata_filename(filename: str):
path = example_doc_path(filename)
elements = []
filetype = FileType.from_extension(os.path.splitext(path)[1])
if filetype == FileType.TXT:
elements = partition_text(filename=path)
if filetype == FileType.HTML:
elements = partition_html(filename=path)
if filetype == FileType.XML:
elements = partition_xml(filename=path)
if filetype == FileType.EML:
elements = partition_email(filename=path)
with tempfile.TemporaryDirectory() as tmpdir:
_filename = os.path.basename(filename)
test_path = os.path.join(tmpdir, _filename + ".json")
elements_to_json(elements, filename=test_path, indent=2)
with open(test_path, "rb") as f:
test_elements = partition_json(file=f, metadata_filename="test")
for i in range(len(test_elements)):
assert test_elements[i].metadata.filename == "test"
@pytest.mark.parametrize("filename", test_files)
def test_partition_json_from_text(filename: str):
path = example_doc_path(filename)
elements = []
filetype = FileType.from_extension(os.path.splitext(path)[1])
if filetype == FileType.TXT:
elements = partition_text(filename=path)
if filetype == FileType.HTML:
elements = partition_html(filename=path)
if filetype == FileType.XML:
elements = partition_xml(filename=path)
if filetype == FileType.EML:
elements = partition_email(filename=path)
with tempfile.TemporaryDirectory() as tmpdir:
_filename = os.path.basename(filename)
test_path = os.path.join(tmpdir, _filename + ".json")
elements_to_json(elements, filename=test_path, indent=2)
with open(test_path) as f:
text = f.read()
test_elements = partition_json(text=text)
assert len(elements) > 0
assert len(str(elements[0])) > 0
assert len(elements) == len(test_elements)
for i in range(len(elements)):
assert elements[i] == test_elements[i]
assert elements[i].metadata.filename == filename.split("/")[-1]
def test_partition_json_raises_with_none_specified():
with pytest.raises(ValueError):
partition_json()
def test_partition_json_works_with_empty_string():
assert partition_json(text="") == []
def test_partition_json_fails_with_empty_item():
with pytest.raises(ValueError):
partition_json(text="{}")
def test_partition_json_works_with_empty_list():
assert partition_json(text="[]") == []
def test_partition_json_raises_with_too_many_specified():
path = example_doc_path("fake-text.txt")
elements = []
filetype = FileType.from_extension(os.path.splitext(path)[1])
if filetype == FileType.TXT:
elements = partition_text(filename=path)
if filetype == FileType.HTML:
elements = partition_html(filename=path)
if filetype == FileType.XML:
elements = partition_xml(filename=path)
if filetype == FileType.EML:
elements = partition_email(filename=path)
with tempfile.TemporaryDirectory() as tmpdir:
test_path = os.path.join(tmpdir, "fake-text.txt.json")
elements_to_json(elements, filename=test_path, indent=2)
with open(test_path, "rb") as f:
text = f.read().decode("utf-8")
with pytest.raises(ValueError):
partition_json(filename=test_path, file=f)
with pytest.raises(ValueError):
partition_json(filename=test_path, text=text)
with pytest.raises(ValueError):
partition_json(file=f, text=text)
with pytest.raises(ValueError):
partition_json(filename=test_path, file=f, text=text)
# -- .metadata.last_modified ---------------------------------------------------------------------
def test_partition_json_from_file_path_gets_last_modified_from_filesystem(mocker: MockFixture):
filesystem_last_modified = "2029-07-05T09:24:28"
mocker.patch(
"unstructured.partition.json.get_last_modified_date", return_value=filesystem_last_modified
)
elements = partition_json(example_doc_path("spring-weather.html.json"))
assert all(e.metadata.last_modified == filesystem_last_modified for e in elements)
def test_partition_json_from_file_gets_last_modified_None():
with open("example-docs/spring-weather.html.json", "rb") as f:
elements = partition_json(file=f)
assert all(e.metadata.last_modified is None for e in elements)
def test_partition_json_from_text_gets_last_modified_None():
with open("example-docs/spring-weather.html.json") as f:
text = f.read()
elements = partition_json(text=text)
assert all(e.metadata.last_modified is None for e in elements)
def test_partition_json_from_file_path_prefers_metadata_last_modified(mocker: MockFixture):
filesystem_last_modified = "2029-07-05T09:24:28"
metadata_last_modified = "2020-07-05T09:24:28"
mocker.patch(
"unstructured.partition.json.get_last_modified_date", return_value=filesystem_last_modified
)
elements = partition_json(
"example-docs/spring-weather.html.json", metadata_last_modified=metadata_last_modified
)
assert all(e.metadata.last_modified == metadata_last_modified for e in elements)
def test_partition_json_from_file_prefers_metadata_last_modified():
metadata_last_modified = "2020-07-05T09:24:28"
with open(example_doc_path("spring-weather.html.json"), "rb") as f:
elements = partition_json(file=f, metadata_last_modified=metadata_last_modified)
assert all(e.metadata.last_modified == metadata_last_modified for e in elements)
def test_partition_json_from_text_prefers_metadata_last_modified():
metadata_last_modified = "2020-07-05T09:24:28"
with open("example-docs/spring-weather.html.json") as f:
text = f.read()
elements = partition_json(text=text, metadata_last_modified=metadata_last_modified)
assert all(e.metadata.last_modified == metadata_last_modified for e in elements)
# ------------------------------------------------------------------------------------------------
def test_partition_json_raises_with_unprocessable_json_array():
text = '[{"invalid": "schema"}]'
with pytest.raises(ValueError):
partition_json(text=text)
def test_partition_json_raises_with_unprocessable_json():
# NOTE(robinson) - This is unprocessable because it is not a list of dicts,
# per the Unstructured ISD format
text = '{"hi": "there"}'
with pytest.raises(ValueError):
partition_json(text=text)
def test_partition_json_raises_with_invalid_json():
text = '[{"hi": "there"}]]'
with pytest.raises(ValueError):
partition_json(text=text)
+399
View File
@@ -0,0 +1,399 @@
from __future__ import annotations
from typing import Any
from unittest.mock import patch
import pytest
from markdown.extensions.fenced_code import FencedCodeExtension
from pytest_mock import MockFixture
from test_unstructured.unit_utils import assert_round_trips_through_JSON, example_doc_path
from unstructured.chunking.title import chunk_by_title
from unstructured.documents.elements import ElementType, Title
from unstructured.partition.md import partition_md
from unstructured.partition.utils.constants import UNSTRUCTURED_INCLUDE_DEBUG_METADATA
def test_partition_md_from_filename():
filename = example_doc_path("README.md")
elements = partition_md(filename=filename)
assert len(elements) > 0
assert "PageBreak" not in [elem.category for elem in elements]
assert isinstance(elements[0], Title)
if UNSTRUCTURED_INCLUDE_DEBUG_METADATA:
assert {element.metadata.detection_origin for element in elements} == {"md"}
def test_partition_md_from_file():
filename = example_doc_path("README.md")
with open(filename, "rb") as f:
elements = partition_md(file=f)
assert len(elements) > 0
def test_partition_md_from_text():
with open(example_doc_path("README.md")) as f:
text = f.read()
elements = partition_md(text=text)
assert len(elements) > 0
assert all(e.metadata.filename is None for e in elements)
class MockResponse:
def __init__(self, text: str, status_code: int, headers: dict[str, Any] = {}):
self.text = text
self.status_code = status_code
self.ok = status_code < 300
self.headers = headers
def test_partition_md_from_url():
filename = example_doc_path("README.md")
with open(filename) as f:
text = f.read()
response = MockResponse(
text=text,
status_code=200,
headers={"Content-Type": "text/markdown"},
)
with patch("unstructured.partition.md.safe_get", return_value=response) as _:
elements = partition_md(url="https://fake.url")
assert len(elements) > 0
assert all(e.metadata.filename is None for e in elements)
def test_partition_md_from_url_raises_with_bad_status_code():
filename = example_doc_path("README.md")
with open(filename) as f:
text = f.read()
response = MockResponse(
text=text,
status_code=500,
headers={"Content-Type": "text/html"},
)
with (
patch("unstructured.partition.md.safe_get", return_value=response) as _,
pytest.raises(ValueError),
):
partition_md(url="https://fake.url")
def test_partition_md_from_url_raises_with_bad_content_type():
filename = example_doc_path("README.md")
with open(filename) as f:
text = f.read()
response = MockResponse(
text=text,
status_code=200,
headers={"Content-Type": "application/json"},
)
with (
patch("unstructured.partition.md.safe_get", return_value=response) as _,
pytest.raises(ValueError),
):
partition_md(url="https://fake.url")
def test_partition_md_raises_with_none_specified():
with pytest.raises(ValueError):
partition_md()
def test_partition_md_raises_with_too_many_specified():
filename = example_doc_path("README.md")
with open(filename) as f:
text = f.read()
with pytest.raises(ValueError):
partition_md(filename=filename, text=text)
# -- .metadata.filename --------------------------------------------------------------------------
def test_partition_md_from_filename_gets_filename_from_filename_arg():
elements = partition_md(example_doc_path("README.md"))
assert len(elements) > 0
assert all(e.metadata.filename == "README.md" for e in elements)
def test_partition_md_from_file_gets_filename_None():
with open(example_doc_path("README.md"), "rb") as f:
elements = partition_md(file=f)
assert len(elements) > 0
assert all(e.metadata.filename is None for e in elements)
def test_partition_md_from_filename_prefers_metadata_filename():
elements = partition_md(example_doc_path("README.md"), metadata_filename="orig-name.md")
assert len(elements) > 0
assert all(element.metadata.filename == "orig-name.md" for element in elements)
def test_partition_md_from_file_prefers_metadata_filename():
with open(example_doc_path("README.md"), "rb") as f:
elements = partition_md(file=f, metadata_filename="orig-name.md")
assert all(e.metadata.filename == "orig-name.md" for e in elements)
# -- .metadata.filetype --------------------------------------------------------------------------
def test_partition_md_gets_the_MD_MIME_type_in_metadata_filetype():
MD_MIME_TYPE = "text/markdown"
elements = partition_md(example_doc_path("README.md"))
assert all(e.metadata.filetype == MD_MIME_TYPE for e in elements), (
f"Expected all elements to have '{MD_MIME_TYPE}' as their filetype, but got:"
f" {repr(elements[0].metadata.filetype)}"
)
# -- .metadata.last_modified ---------------------------------------------------------------------
def test_partition_md_from_file_path_gets_last_modified_from_filesystem(mocker: MockFixture):
filesystem_last_modified = "2029-07-05T09:24:28"
mocker.patch(
"unstructured.partition.md.get_last_modified_date", return_value=filesystem_last_modified
)
elements = partition_md(example_doc_path("README.md"))
assert all(e.metadata.last_modified == filesystem_last_modified for e in elements)
def test_partition_md_from_file_gets_last_modified_None():
with open(example_doc_path("README.md"), "rb") as f:
elements = partition_md(file=f)
assert all(e.metadata.last_modified is None for e in elements)
def test_partition_md_from_text_gets_last_modified_None():
with open(example_doc_path("README.md")) as f:
text = f.read()
elements = partition_md(text=text)
assert all(e.metadata.last_modified is None for e in elements)
def test_partition_md_from_file_path_prefers_metadata_last_modified(mocker: MockFixture):
filesystem_last_modified = "2029-07-05T09:24:28"
metadata_last_modified = "2020-07-05T09:24:28"
mocker.patch(
"unstructured.partition.md.get_last_modified_date", return_value=filesystem_last_modified
)
elements = partition_md(
example_doc_path("README.md"), metadata_last_modified=metadata_last_modified
)
assert all(e.metadata.last_modified == metadata_last_modified for e in elements)
def test_partition_md_from_file_prefers_metadata_last_modified():
metadata_last_modified = "2020-07-05T09:24:28"
with open(example_doc_path("README.md"), "rb") as f:
elements = partition_md(file=f, metadata_last_modified=metadata_last_modified)
assert all(e.metadata.last_modified == metadata_last_modified for e in elements)
def test_partition_md_from_text_prefers_metadata_last_modified():
metadata_last_modified = "2020-07-05T09:24:28"
with open(example_doc_path("README.md")) as f:
text = f.read()
elements = partition_md(text=text, metadata_last_modified=metadata_last_modified)
assert all(e.metadata.last_modified == metadata_last_modified for e in elements)
# ------------------------------------------------------------------------------------------------
def test_partition_md_with_json():
with open(example_doc_path("README.md")) as f:
text = f.read()
elements = partition_md(text=text)
assert_round_trips_through_JSON(elements)
def test_add_chunking_strategy_by_title_on_partition_md():
filename = example_doc_path("README.md")
elements = partition_md(filename)
chunk_elements = partition_md(filename, chunking_strategy="by_title")
chunks = chunk_by_title(elements)
assert chunk_elements != elements
assert chunk_elements == chunks
def test_partition_md_element_metadata_has_languages():
filename = "example-docs/README.md"
elements = partition_md(filename=filename)
assert elements[0].metadata.languages == ["eng"]
def test_partition_md_respects_detect_language_per_element():
filename = "example-docs/language-docs/eng_spa_mult.md"
elements = partition_md(filename=filename, detect_language_per_element=True)
langs = [element.metadata.languages for element in elements]
assert langs == [["eng"], ["spa", "eng"], ["eng"], ["eng"], ["spa"]]
def test_partition_md_languages_empty_disables_detection():
"""Passing languages=[\"\"] disables language detection (no metadata.languages set)."""
filename = "example-docs/README.md"
elements = partition_md(filename=filename, languages=[""])
# When detection is disabled, metadata.languages should not be set (None)
for el in elements:
assert el.metadata.languages is None
def test_partition_md_parse_table():
filename = example_doc_path("simple-table.md")
elements = partition_md(filename=filename)
assert len(elements) > 0
assert elements[0].category == ElementType.TABLE
@pytest.mark.parametrize("filename", ["umlauts-utf8.md", "umlauts-non-utf8.md"])
def test_partition_md_with_umlauts(filename: str):
filename = example_doc_path(filename)
elements = partition_md(filename=filename)
assert len(elements) > 0
assert elements[-1].text.endswith("äöüß")
def test_partition_md_xml_processing_instruction():
xml_content = """```
<?xml version="1.0"?>
<sparql xmlns="http://www.w3.org/2005/sparql-results#">
<head></head>
<boolean>true</boolean>
</sparql>
```"""
elements = partition_md(text=xml_content)
assert len(elements) == 1
def test_partition_md_xml_processing_instruction_with_indents():
xml_content = """```
<?xml version="1.0"?>
<sparql xmlns="http://www.w3.org/2005/sparql-results#">
<head></head>
<boolean>true</boolean>
</sparql>
```"""
elements = partition_md(text=xml_content)
assert len(elements) == 1
def test_partition_md_non_xml_processing_instruction():
php_content = """```
<?php echo "hello"; ?>
```"""
elements = partition_md(text=php_content)
assert len(elements) == 1
def test_partition_fenced_code():
filename = example_doc_path("codeblock.md")
elements = partition_md(filename=filename)
# Should have 5 elements: 2 titles and 3 code blocks
assert len(elements) == 5
assert elements[0].text == "HTML Example"
expected_html = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sample HTML</title>
</head>
<body>
<h1>Hello, World!</h1>
<p>This is a simple HTML example.</p>
</body>
</html>"""
assert elements[1].text == expected_html
assert elements[2].text == "XML Example"
expected_xml = """<note>
<to>Tove</to>
<from>Jani</from>
<heading>Reminder</heading>
<body>Don't forget me this weekend!</body>
</note>"""
assert elements[3].text == expected_xml
assert elements[4].text == expected_xml
def test_partition_md_custom_extensions_parameter():
"""User can override markdown extensions via `extensions` kwarg (fixes #4006)."""
text = """```bash
# create the container
docker run -dt --name unstructured downloads.unstructured.io/unstructured-io/unstructured:latest
```"""
expected_body = (
"# create the container\n"
"docker run -dt --name unstructured "
"downloads.unstructured.io/unstructured-io/unstructured:latest"
)
# Without fenced_code, ``#`` inside the fence is parsed as a heading (undesired).
elements_tables_only = partition_md(text=text, extensions=["tables"])
assert any(isinstance(el, Title) for el in elements_tables_only)
# Default and explicit fenced_code keep the block as one element (CodeSnippet from HTML).
assert len(partition_md(text=text)) == 1
elements_fenced = partition_md(text=text, extensions=["fenced_code"])
assert len(elements_fenced) == 1
assert elements_fenced[0].category == ElementType.CODE_SNIPPET
assert elements_fenced[0].text == expected_body
# Extension instances (normal Python-Markdown API) match string extension names.
elements_instance = partition_md(text=text, extensions=[FencedCodeExtension()])
assert elements_instance == elements_fenced
def test_partition_md_extensions_not_list_raises():
with pytest.raises(ValueError, match="'extensions' must be a list"):
partition_md(text="# Hi", extensions=("tables",)) # type: ignore[arg-type]
def test_partition_md_extensions_invalid_item_raises():
with pytest.raises(ValueError, match="Each entry in 'extensions'"):
partition_md(text="# Hi", extensions=[42]) # type: ignore[list-item]
def test_partition_md_tables_only_differs_from_default_for_code_fence():
"""Without ``fenced_code``, ``#`` inside a fence can become a Title (see #4006)."""
text = """```bash
# line
```"""
default_el = partition_md(text=text)[0]
tables_only_els = partition_md(text=text, extensions=["tables"])
assert default_el.category == ElementType.CODE_SNIPPET
assert any(e.category == ElementType.TITLE for e in tables_only_els)
+614
View File
@@ -0,0 +1,614 @@
"""Test suite for `unstructured.partition.msg` module."""
from __future__ import annotations
import io
from typing import Any
import pytest
from oxmsg import Message
from test_unstructured.unit_utils import (
FixtureRequest,
LogCaptureFixture,
Mock,
assert_round_trips_through_JSON,
example_doc_path,
function_mock,
property_mock,
)
from unstructured.chunking.title import chunk_by_title
from unstructured.documents.elements import (
ElementMetadata,
ListItem,
NarrativeText,
Text,
)
from unstructured.partition.common import UnsupportedFileFormatError
from unstructured.partition.msg import MsgPartitionerOptions, partition_msg
EXPECTED_MSG_OUTPUT = [
NarrativeText(text="This is a test email to use for unit tests."),
Text(text="Important points:"),
ListItem(text="Roses are red"),
ListItem(text="Violets are blue"),
]
def test_partition_msg_from_filename():
filename = example_doc_path("fake-email.msg")
elements = partition_msg(filename=filename)
parent_id = elements[0].metadata.parent_id
assert elements == EXPECTED_MSG_OUTPUT
assert (
elements[0].metadata.to_dict()
== ElementMetadata(
coordinates=None,
filename=filename,
last_modified="2023-03-28T17:00:31+00:00",
page_number=None,
url=None,
sent_from=['"Matthew Robinson" <mrobinson@unstructured.io>'],
sent_to=["mrobinson@unstructured.io"],
subject="Test Email",
filetype="application/vnd.ms-outlook",
parent_id=parent_id,
languages=["eng"],
).to_dict()
)
def test_partition_msg_from_filename_returns_uns_elements():
filename = example_doc_path("fake-email.msg")
elements = partition_msg(filename=filename)
assert isinstance(elements[0], NarrativeText)
def test_partition_msg_from_filename_with_metadata_filename():
filename = example_doc_path("fake-email.msg")
elements = partition_msg(filename=filename, metadata_filename="test")
assert all(element.metadata.filename == "test" for element in elements)
def test_partition_msg_from_filename_with_text_content():
filename = example_doc_path("fake-email.msg")
elements = partition_msg(filename=filename)
assert str(elements[0]) == "This is a test email to use for unit tests."
assert elements[0].metadata.filename == "fake-email.msg"
assert elements[0].metadata.file_directory == example_doc_path("")
def test_partition_msg_raises_with_missing_file():
filename = example_doc_path("doesnt-exist.msg")
with pytest.raises(FileNotFoundError):
partition_msg(filename=filename)
def test_partition_msg_from_file():
filename = example_doc_path("fake-email.msg")
with open(filename, "rb") as f:
elements = partition_msg(file=f)
assert elements == EXPECTED_MSG_OUTPUT
for element in elements:
assert element.metadata.filename is None
def test_partition_msg_from_file_with_metadata_filename():
filename = example_doc_path("fake-email.msg")
with open(filename, "rb") as f:
elements = partition_msg(file=f, metadata_filename="test")
assert elements == EXPECTED_MSG_OUTPUT
for element in elements:
assert element.metadata.filename == "test"
def test_partition_msg_uses_file_path_when_both_are_specified():
elements = partition_msg(example_doc_path("fake-email.msg"), file=io.BytesIO(b"abcde"))
assert elements == EXPECTED_MSG_OUTPUT
def test_partition_msg_raises_with_neither():
with pytest.raises(ValueError):
partition_msg()
# -- attachments ---------------------------------------------------------------------------------
def test_partition_msg_can_process_attachments():
elements = partition_msg(
example_doc_path("fake-email-multiple-attachments.msg"), process_attachments=True
)
assert all(e.metadata.filename == "fake-email-multiple-attachments.msg" for e in elements[:5])
assert all(e.metadata.filename == "unstructured_logo.png" for e in elements[5:7])
assert all(e.metadata.filename == "dense_doc.pdf" for e in elements[7:343])
assert all(e.metadata.filename == "Engineering Onboarding.pptx" for e in elements[343:])
assert [e.text for e in elements[:5]] == [
"Here are those documents.",
"--",
"Mallori Harrell",
"Unstructured Technologies",
"Data Scientist",
]
assert [type(e).__name__ for e in elements][:10] == [
"NarrativeText",
"Text",
"Text",
"Text",
"Text",
"Image",
"Text",
"Text",
"Title",
"Title",
]
assert [type(e).__name__ for e in elements][-10:] == [
"Title",
"ListItem",
"ListItem",
"ListItem",
"ListItem",
"ListItem",
"ListItem",
"ListItem",
"ListItem",
"ListItem",
]
def test_partition_msg_silently_skips_attachments_it_cannot_partition(request: FixtureRequest):
function_mock(
request, "unstructured.partition.auto.partition", side_effect=UnsupportedFileFormatError()
)
elements = partition_msg(
example_doc_path("fake-email-multiple-attachments.msg"), process_attachments=True
)
# -- no exception is raised --
assert elements == [
# -- the email body is partitioned --
NarrativeText("Here are those documents."),
Text("--"),
Text("Mallori Harrell"),
Text("Unstructured Technologies"),
Text("Data Scientist"),
# -- no elements appear for the attachment(s) --
]
# -- .metadata.filename --------------------------------------------------------------------------
def test_partition_msg_from_filename_gets_filename_metadata_from_file_path():
elements = partition_msg(example_doc_path("fake-email.msg"))
assert all(e.metadata.filename == "fake-email.msg" for e in elements)
assert all(e.metadata.file_directory == example_doc_path("") for e in elements)
def test_partition_msg_from_file_gets_filename_metadata_None():
with open(example_doc_path("fake-email.msg"), "rb") as f:
elements = partition_msg(file=f)
assert all(e.metadata.filename is None for e in elements)
assert all(e.metadata.file_directory is None for e in elements)
def test_partition_msg_from_filename_prefers_metadata_filename():
elements = partition_msg(example_doc_path("fake-email.msg"), metadata_filename="a/b/c.msg")
assert all(e.metadata.filename == "c.msg" for e in elements)
assert all(e.metadata.file_directory == "a/b" for e in elements)
def test_partition_msg_from_file_prefers_metadata_filename():
with open(example_doc_path("fake-email.msg"), "rb") as f:
elements = partition_msg(file=f, metadata_filename="d/e/f.msg")
assert all(e.metadata.filename == "f.msg" for e in elements)
assert all(e.metadata.file_directory == "d/e" for e in elements)
# -- .metadata.filetype --------------------------------------------------------------------------
def test_partition_msg_gets_the_MSG_mime_type_in_metadata_filetype():
MSG_MIME_TYPE = "application/vnd.ms-outlook"
elements = partition_msg(example_doc_path("fake-email.msg"))
assert all(e.metadata.filetype == MSG_MIME_TYPE for e in elements), (
f"Expected all elements to have '{MSG_MIME_TYPE}' as their filetype, but got:"
f" {repr(elements[0].metadata.filetype)}"
)
# -- .metadata.last_modified ---------------------------------------------------------------------
def test_partition_msg_pulls_last_modified_from_message_sent_date():
elements = partition_msg(example_doc_path("fake-email.msg"))
assert all(e.metadata.last_modified == "2023-03-28T17:00:31+00:00" for e in elements)
def test_partition_msg_from_file_path_prefers_metadata_last_modified():
metadata_last_modified = "2020-07-05T09:24:28"
elements = partition_msg(
example_doc_path("fake-email.msg"), metadata_last_modified=metadata_last_modified
)
assert elements[0].metadata.last_modified == metadata_last_modified
def test_partition_msg_from_file_prefers_metadata_last_modified():
metadata_last_modified = "2020-07-05T09:24:28"
with open(example_doc_path("fake-email.msg"), "rb") as f:
elements = partition_msg(file=f, metadata_last_modified=metadata_last_modified)
assert all(e.metadata.last_modified == metadata_last_modified for e in elements)
# ------------------------------------------------------------------------------------------------
def test_partition_msg_with_json():
elements = partition_msg(example_doc_path("fake-email.msg"))
assert_round_trips_through_JSON(elements)
def test_partition_msg_with_pgp_encrypted_message(caplog: LogCaptureFixture):
elements = partition_msg(example_doc_path("fake-encrypted.msg"))
assert elements == []
assert "WARNING" in caplog.text
assert "Encrypted email detected" in caplog.text
def test_add_chunking_strategy_by_title_on_partition_msg():
filename = example_doc_path("fake-email.msg")
elements = partition_msg(filename=filename)
chunk_elements = partition_msg(filename, chunking_strategy="by_title")
chunks = chunk_by_title(elements)
assert chunk_elements != elements
assert chunk_elements == chunks
# -- language behaviors --------------------------------------------------------------------------
def test_partition_msg_element_metadata_has_languages():
filename = "example-docs/fake-email.msg"
elements = partition_msg(filename=filename)
assert elements[0].metadata.languages == ["eng"]
def test_partition_msg_respects_languages_arg():
filename = "example-docs/fake-email.msg"
elements = partition_msg(filename=filename, languages=["deu"])
assert all(element.metadata.languages == ["deu"] for element in elements)
def test_partition_msg_raises_TypeError_for_invalid_languages():
with pytest.raises(TypeError):
filename = "example-docs/fake-email.msg"
partition_msg(filename=filename, languages="eng")
# ================================================================================================
# ISOLATED UNIT TESTS
# ================================================================================================
# These test components used by `partition_msg()` in isolation such that all edge cases can be
# exercised.
# ================================================================================================
class DescribeMsgAttachmentFilenameSanitization:
"""Unit-test suite for filename sanitization in MSG attachments (GHSA-gm8q-m8mv-jj5m)."""
def it_sanitizes_path_traversal_attempts(self, request: FixtureRequest):
from unstructured.partition.msg import _AttachmentPartitioner
attachment = Mock()
attachment.file_name = "../../../etc/passwd"
attachment.file_bytes = b"malicious content"
attachment.last_modified = None
opts = Mock()
opts.metadata_last_modified = None
partitioner = _AttachmentPartitioner(attachment, opts)
assert partitioner._attachment_file_name == "passwd"
def it_sanitizes_absolute_unix_paths(self, request: FixtureRequest):
from unstructured.partition.msg import _AttachmentPartitioner
attachment = Mock()
attachment.file_name = "/etc/passwd"
attachment.file_bytes = b"malicious content"
attachment.last_modified = None
opts = Mock()
opts.metadata_last_modified = None
partitioner = _AttachmentPartitioner(attachment, opts)
assert partitioner._attachment_file_name == "passwd"
def it_sanitizes_absolute_windows_paths(self, request: FixtureRequest):
from unstructured.partition.msg import _AttachmentPartitioner
attachment = Mock()
attachment.file_name = "C:\\Windows\\System32\\config\\sam"
attachment.file_bytes = b"malicious content"
attachment.last_modified = None
opts = Mock()
opts.metadata_last_modified = None
partitioner = _AttachmentPartitioner(attachment, opts)
assert partitioner._attachment_file_name == "sam"
def it_removes_null_bytes_from_filenames(self, request: FixtureRequest):
from unstructured.partition.msg import _AttachmentPartitioner
attachment = Mock()
attachment.file_name = "file\x00.txt"
attachment.file_bytes = b"content"
attachment.last_modified = None
opts = Mock()
opts.metadata_last_modified = None
partitioner = _AttachmentPartitioner(attachment, opts)
assert partitioner._attachment_file_name == "file.txt"
assert "\x00" not in partitioner._attachment_file_name
def it_handles_dot_and_dotdot_filenames(self, request: FixtureRequest):
from unstructured.partition.msg import _AttachmentPartitioner
opts = Mock()
opts.metadata_last_modified = None
# Test single dot
attachment1 = Mock()
attachment1.file_name = "."
attachment1.file_bytes = b"content"
attachment1.last_modified = None
partitioner1 = _AttachmentPartitioner(attachment1, opts)
assert partitioner1._attachment_file_name == "unknown"
# Test double dot
attachment2 = Mock()
attachment2.file_name = ".."
attachment2.file_bytes = b"content"
attachment2.last_modified = None
partitioner2 = _AttachmentPartitioner(attachment2, opts)
assert partitioner2._attachment_file_name == "unknown"
def it_handles_missing_filename(self, request: FixtureRequest):
from unstructured.partition.msg import _AttachmentPartitioner
attachment = Mock()
attachment.file_name = None
attachment.file_bytes = b"content"
attachment.last_modified = None
opts = Mock()
opts.metadata_last_modified = None
partitioner = _AttachmentPartitioner(attachment, opts)
assert partitioner._attachment_file_name == "unknown"
def it_allows_valid_filenames_through(self, request: FixtureRequest):
from unstructured.partition.msg import _AttachmentPartitioner
attachment = Mock()
attachment.file_name = "document.pdf"
attachment.file_bytes = b"content"
attachment.last_modified = None
opts = Mock()
opts.metadata_last_modified = None
partitioner = _AttachmentPartitioner(attachment, opts)
assert partitioner._attachment_file_name == "document.pdf"
def it_handles_complex_path_traversal_with_mixed_separators(self, request: FixtureRequest):
from unstructured.partition.msg import _AttachmentPartitioner
attachment = Mock()
attachment.file_name = "..\\../\\..\\etc/passwd"
attachment.file_bytes = b"malicious content"
attachment.last_modified = None
opts = Mock()
opts.metadata_last_modified = None
partitioner = _AttachmentPartitioner(attachment, opts)
assert partitioner._attachment_file_name == "passwd"
def it_handles_empty_string_filename(self, request: FixtureRequest):
from unstructured.partition.msg import _AttachmentPartitioner
attachment = Mock()
attachment.file_name = ""
attachment.file_bytes = b"content"
attachment.last_modified = None
opts = Mock()
opts.metadata_last_modified = None
partitioner = _AttachmentPartitioner(attachment, opts)
assert partitioner._attachment_file_name == "unknown"
class DescribeMsgPartitionerOptions:
"""Unit-test suite for `unstructured.partition.msg.MsgPartitionerOptions` objects."""
# -- .extra_msg_metadata ---------------------
def it_provides_email_specific_metadata_to_add_to_each_element(self, opts_args: dict[str, Any]):
opts_args["file_path"] = example_doc_path("fake-email-with-cc-and-bcc.msg")
opts = MsgPartitionerOptions(**opts_args)
m = opts.extra_msg_metadata
assert m.bcc_recipient == ["hello@unstructured.io"]
assert m.cc_recipient == ["steve@unstructured.io"]
assert m.email_message_id == "14DDEF33-2BA7-4CDD-A4D8-E7C5873B37F2@gmail.com"
assert m.sent_from == ['"John" <johnjennings702@gmail.com>']
assert m.sent_to == [
"john-ctr@unstructured.io",
"steve@unstructured.io",
"hello@unstructured.io",
]
assert m.subject == "Fake email with cc and bcc recipients"
# -- .is_encrypted ---------------------------
@pytest.mark.parametrize(
("file_name", "expected_value"), [("fake-encrypted.msg", True), ("fake-email.msg", False)]
)
def it_knows_when_the_msg_is_encrypted(
self, file_name: str, expected_value: bool, opts_args: dict[str, Any]
):
opts_args["file_path"] = example_doc_path(file_name)
opts = MsgPartitionerOptions(**opts_args)
assert opts.is_encrypted is expected_value
# -- .metadata_file_path ---------------------
def it_uses_the_metadata_file_path_arg_when_provided(self, opts_args: dict[str, Any]):
opts_args["file_path"] = "x/y/z.msg"
opts_args["metadata_file_path"] = "a/b/c.msg"
opts = MsgPartitionerOptions(**opts_args)
assert opts.metadata_file_path == "a/b/c.msg"
def and_it_falls_back_to_the_MSG_file_path_arg_when_provided(self, opts_args: dict[str, Any]):
file_path = example_doc_path("fake-email.msg")
opts_args["file_path"] = file_path
opts = MsgPartitionerOptions(**opts_args)
assert opts.metadata_file_path == file_path
def but_it_returns_None_when_neither_path_is_available(self, opts_args: dict[str, Any]):
opts = MsgPartitionerOptions(**opts_args)
assert opts.metadata_file_path is None
# -- .metadata_last_modified -----------------
def it_uses_metadata_last_modified_when_provided_by_the_caller(self, opts_args: dict[str, Any]):
metadata_last_modified = "2024-03-05T17:02:53"
opts_args["metadata_last_modified"] = metadata_last_modified
opts_args["file_path"] = example_doc_path("fake-email.msg")
opts = MsgPartitionerOptions(**opts_args)
assert opts.metadata_last_modified == metadata_last_modified
def and_it_uses_the_message_Date_header_when_metadata_last_modified_is_not_provided(
self, opts_args: dict[str, Any]
):
opts_args["file_path"] = example_doc_path("fake-email.msg")
opts = MsgPartitionerOptions(**opts_args)
assert opts.metadata_last_modified == "2023-03-28T17:00:31+00:00"
@pytest.mark.parametrize("filesystem_last_modified", ["2024-06-03T20:12:53", None])
def and_it_uses_the_last_modified_date_from_the_source_file_when_the_message_has_no_sent_date(
self,
opts_args: dict[str, Any],
filesystem_last_modified: str | None,
Message_sent_date_: Mock,
_last_modified_prop_: Mock,
):
Message_sent_date_.return_value = None
_last_modified_prop_.return_value = filesystem_last_modified
opts_args["file_path"] = example_doc_path("fake-email.msg")
opts = MsgPartitionerOptions(**opts_args)
assert opts.metadata_last_modified == filesystem_last_modified
# -- .msg ------------------------------------
def it_loads_the_msg_document_from_a_file_path_when_provided(self, opts_args: dict[str, Any]):
opts_args["file_path"] = example_doc_path("fake-email.msg")
opts = MsgPartitionerOptions(**opts_args)
assert isinstance(opts.msg, Message)
def and_it_loads_the_msg_document_from_a_file_like_object_when_provided(
self, opts_args: dict[str, Any]
):
with open(example_doc_path("fake-email.msg"), "rb") as f:
opts_args["file"] = io.BytesIO(f.read())
opts = MsgPartitionerOptions(**opts_args)
assert isinstance(opts.msg, Message)
def but_it_raises_when_neither_is_provided(self, opts_args: dict[str, Any]):
with pytest.raises(ValueError, match="one of `file` or `filename` arguments must be prov"):
MsgPartitionerOptions(**opts_args).msg
# -- .partition_attachments ------------------
@pytest.mark.parametrize("partition_attachments", [True, False])
def it_knows_whether_attachments_should_also_be_partitioned(
self, partition_attachments: bool, opts_args: dict[str, Any]
):
opts_args["file_path"] = example_doc_path("fake-email.msg")
opts_args["partition_attachments"] = partition_attachments
opts = MsgPartitionerOptions(**opts_args)
assert opts.partition_attachments is partition_attachments
# -- .partitioning_kwargs --------------------
def it_provides_access_to_pass_through_kwargs_collected_by_the_partitioner_function(
self, opts_args: dict[str, Any]
):
opts_args["kwargs"] = {"foo": 42, "bar": "baz"}
opts = MsgPartitionerOptions(**opts_args)
assert opts.partitioning_kwargs == {"foo": 42, "bar": "baz"}
# -- fixtures --------------------------------------------------------------------------------
@pytest.fixture
def _last_modified_prop_(self, request: FixtureRequest):
return property_mock(request, MsgPartitionerOptions, "_last_modified")
@pytest.fixture
def Message_sent_date_(self, request: FixtureRequest):
return property_mock(request, Message, "sent_date")
@pytest.fixture
def opts_args(self) -> dict[str, Any]:
"""All default arguments for `MsgPartitionerOptions`.
Individual argument values can be changed to suit each test. Makes construction of opts more
compact for testing purposes.
"""
return {
"file": None,
"file_path": None,
"metadata_file_path": None,
"metadata_last_modified": None,
"partition_attachments": False,
"kwargs": {},
}
+311
View File
@@ -0,0 +1,311 @@
"""Test-suite for `unstructured.partition.ndjson` module."""
from __future__ import annotations
import os
import pathlib
import tempfile
import pytest
from pytest_mock import MockFixture
from test_unstructured.unit_utils import example_doc_path
from unstructured.documents.elements import CompositeElement
from unstructured.file_utils.model import FileType
from unstructured.partition.email import partition_email
from unstructured.partition.html import partition_html
from unstructured.partition.ndjson import partition_ndjson
from unstructured.partition.text import partition_text
from unstructured.partition.xml import partition_xml
from unstructured.staging.base import elements_to_ndjson
DIRECTORY = pathlib.Path(__file__).parent.resolve()
is_in_docker = os.path.exists("/.dockerenv")
test_files = [
"fake-text.txt",
"fake-html.html",
"eml/fake-email.eml",
]
is_in_docker = os.path.exists("/.dockerenv")
def test_it_chunks_elements_when_a_chunking_strategy_is_specified():
chunks = partition_ndjson(
example_doc_path("spring-weather.html.ndjson"),
chunking_strategy="basic",
max_characters=1500,
)
assert len(chunks) == 9
assert all(isinstance(ch, CompositeElement) for ch in chunks)
@pytest.mark.parametrize("filename", test_files)
def test_partition_ndjson_from_filename(filename: str):
path = example_doc_path(filename)
elements = []
filetype = FileType.from_extension(os.path.splitext(path)[1])
if filetype == FileType.TXT:
elements = partition_text(filename=path)
if filetype == FileType.HTML:
elements = partition_html(filename=path)
if filetype == FileType.XML:
elements = partition_xml(filename=path)
if filetype == FileType.EML:
elements = partition_email(filename=path)
with tempfile.TemporaryDirectory() as tmpdir:
_filename = os.path.basename(filename)
test_path = os.path.join(tmpdir, _filename + ".ndjson")
elements_to_ndjson(elements, filename=test_path)
test_elements = partition_ndjson(filename=test_path)
assert len(elements) > 0
assert len(str(elements[0])) > 0
assert len(elements) == len(test_elements)
for i in range(len(elements)):
assert elements[i] == test_elements[i]
assert elements[i].metadata.filename == filename.split("/")[-1]
@pytest.mark.parametrize("filename", test_files)
def test_partition_ndjson_from_filename_with_metadata_filename(filename: str):
path = example_doc_path(filename)
elements = []
filetype = FileType.from_extension(os.path.splitext(path)[1])
if filetype == FileType.TXT:
elements = partition_text(filename=path)
if filetype == FileType.HTML:
elements = partition_html(filename=path)
if filetype == FileType.XML:
elements = partition_xml(filename=path)
if filetype == FileType.EML:
elements = partition_email(filename=path)
with tempfile.TemporaryDirectory() as tmpdir:
_filename = os.path.basename(filename)
test_path = os.path.join(tmpdir, _filename + ".ndjson")
elements_to_ndjson(elements, filename=test_path)
test_elements = partition_ndjson(filename=test_path, metadata_filename="test")
assert len(test_elements) > 0
assert len(str(test_elements[0])) > 0
assert all(element.metadata.filename == "test" for element in test_elements)
@pytest.mark.parametrize("filename", test_files)
def test_partition_ndjson_from_file(filename: str):
path = example_doc_path(filename)
elements = []
filetype = FileType.from_extension(os.path.splitext(path)[1])
if filetype == FileType.TXT:
elements = partition_text(filename=path)
if filetype == FileType.HTML:
elements = partition_html(filename=path)
if filetype == FileType.XML:
elements = partition_xml(filename=path)
if filetype == FileType.EML:
elements = partition_email(filename=path)
with tempfile.TemporaryDirectory() as tmpdir:
_filename = os.path.basename(filename)
test_path = os.path.join(tmpdir, _filename + ".ndjson")
elements_to_ndjson(elements, filename=test_path)
with open(test_path, "rb") as f:
test_elements = partition_ndjson(file=f)
assert len(elements) > 0
assert len(str(elements[0])) > 0
assert len(elements) == len(test_elements)
for i in range(len(elements)):
assert elements[i] == test_elements[i]
assert elements[i].metadata.filename == filename.split("/")[-1]
@pytest.mark.parametrize("filename", test_files)
def test_partition_ndjson_from_file_with_metadata_filename(filename: str):
path = example_doc_path(filename)
elements = []
filetype = FileType.from_extension(os.path.splitext(path)[1])
if filetype == FileType.TXT:
elements = partition_text(filename=path)
if filetype == FileType.HTML:
elements = partition_html(filename=path)
if filetype == FileType.XML:
elements = partition_xml(filename=path)
if filetype == FileType.EML:
elements = partition_email(filename=path)
with tempfile.TemporaryDirectory() as tmpdir:
_filename = os.path.basename(filename)
test_path = os.path.join(tmpdir, _filename + ".ndjson")
elements_to_ndjson(elements, filename=test_path)
with open(test_path, "rb") as f:
test_elements = partition_ndjson(file=f, metadata_filename="test")
for i in range(len(test_elements)):
assert test_elements[i].metadata.filename == "test"
@pytest.mark.parametrize("filename", test_files)
def test_partition_ndjson_from_text(filename: str):
path = example_doc_path(filename)
elements = []
filetype = FileType.from_extension(os.path.splitext(path)[1])
if filetype == FileType.TXT:
elements = partition_text(filename=path)
if filetype == FileType.HTML:
elements = partition_html(filename=path)
if filetype == FileType.XML:
elements = partition_xml(filename=path)
if filetype == FileType.EML:
elements = partition_email(filename=path)
with tempfile.TemporaryDirectory() as tmpdir:
_filename = os.path.basename(filename)
test_path = os.path.join(tmpdir, _filename + ".ndjson")
elements_to_ndjson(elements, filename=test_path)
with open(test_path) as f:
text = f.read()
test_elements = partition_ndjson(text=text)
assert len(elements) > 0
assert len(str(elements[0])) > 0
assert len(elements) == len(test_elements)
for i in range(len(elements)):
assert elements[i] == test_elements[i]
assert elements[i].metadata.filename == filename.split("/")[-1]
def test_partition_json_raises_with_none_specified():
with pytest.raises(ValueError):
partition_ndjson()
def test_partition_ndjson_works_with_empty_string():
assert partition_ndjson(text="") == []
def test_partition_ndjson_fails_with_empty_item():
with pytest.raises(ValueError):
partition_ndjson(text="{}")
def test_partition_ndjson_fails_with_empty_list():
with pytest.raises(ValueError):
partition_ndjson(text="[]")
def test_partition_ndjson_raises_with_too_many_specified():
path = example_doc_path("fake-text.txt")
elements = []
filetype = FileType.from_extension(os.path.splitext(path)[1])
if filetype == FileType.TXT:
elements = partition_text(filename=path)
if filetype == FileType.HTML:
elements = partition_html(filename=path)
if filetype == FileType.XML:
elements = partition_xml(filename=path)
if filetype == FileType.EML:
elements = partition_email(filename=path)
with tempfile.TemporaryDirectory() as tmpdir:
test_path = os.path.join(tmpdir, "fake-text.txt.ndjson")
elements_to_ndjson(elements, filename=test_path)
with open(test_path, "rb") as f:
text = f.read().decode("utf-8")
with pytest.raises(ValueError):
partition_ndjson(filename=test_path, file=f)
with pytest.raises(ValueError):
partition_ndjson(filename=test_path, text=text)
with pytest.raises(ValueError):
partition_ndjson(file=f, text=text)
with pytest.raises(ValueError):
partition_ndjson(filename=test_path, file=f, text=text)
# -- .metadata.last_modified ---------------------------------------------------------------------
def test_partition_ndjson_from_file_path_gets_last_modified_from_filesystem(mocker: MockFixture):
filesystem_last_modified = "2029-07-05T09:24:28"
mocker.patch(
"unstructured.partition.ndjson.get_last_modified_date",
return_value=filesystem_last_modified,
)
elements = partition_ndjson(example_doc_path("spring-weather.html.ndjson"))
assert all(e.metadata.last_modified == filesystem_last_modified for e in elements)
def test_partition_ndjson_from_file_gets_last_modified_None():
with open(example_doc_path("spring-weather.html.ndjson"), "rb") as f:
elements = partition_ndjson(file=f)
assert all(e.metadata.last_modified is None for e in elements)
def test_partition_ndjson_from_text_gets_last_modified_None():
with open(example_doc_path("spring-weather.html.ndjson")) as f:
text = f.read()
elements = partition_ndjson(text=text)
assert all(e.metadata.last_modified is None for e in elements)
def test_partition_ndjson_from_file_path_prefers_metadata_last_modified(mocker: MockFixture):
filesystem_last_modified = "2029-07-05T09:24:28"
metadata_last_modified = "2020-07-05T09:24:28"
mocker.patch(
"unstructured.partition.ndjson.get_last_modified_date",
return_value=filesystem_last_modified,
)
elements = partition_ndjson(
example_doc_path("spring-weather.html.ndjson"),
metadata_last_modified=metadata_last_modified,
)
assert all(e.metadata.last_modified == metadata_last_modified for e in elements)
def test_partition_ndjson_from_file_prefers_metadata_last_modified():
metadata_last_modified = "2020-07-05T09:24:28"
with open(example_doc_path("spring-weather.html.ndjson"), "rb") as f:
elements = partition_ndjson(file=f, metadata_last_modified=metadata_last_modified)
assert all(e.metadata.last_modified == metadata_last_modified for e in elements)
def test_partition_ndjson_from_text_prefers_metadata_last_modified():
metadata_last_modified = "2020-07-05T09:24:28"
with open(example_doc_path("spring-weather.html.ndjson")) as f:
text = f.read()
elements = partition_ndjson(text=text, metadata_last_modified=metadata_last_modified)
assert all(e.metadata.last_modified == metadata_last_modified for e in elements)
# ------------------------------------------------------------------------------------------------
def test_partition_json_raises_with_unprocessable_json():
text = '{"invalid": "schema"}'
with pytest.raises(ValueError):
partition_ndjson(text=text)
def test_partition_json_raises_with_invalid_json():
text = '[{"hi": "there"}]]'
with pytest.raises(ValueError):
partition_ndjson(text=text)
+219
View File
@@ -0,0 +1,219 @@
# pyright: reportPrivateUsage=false
"""Test suite for `unstructured.partition.odt` module."""
from __future__ import annotations
from typing import Any, Iterator
import pytest
from pytest_mock import MockFixture
from test_unstructured.unit_utils import (
ANY,
FixtureRequest,
assert_round_trips_through_JSON,
example_doc_path,
method_mock,
)
from unstructured.chunking.basic import chunk_elements
from unstructured.documents.elements import (
CompositeElement,
Element,
NarrativeText,
Table,
TableChunk,
Text,
)
from unstructured.partition.docx import partition_docx
from unstructured.partition.odt import partition_odt
from unstructured.partition.utils.constants import UNSTRUCTURED_INCLUDE_DEBUG_METADATA
def test_partition_odt_matches_partition_docx():
odt_file_path = example_doc_path("simple.odt")
docx_file_path = example_doc_path("simple.docx")
assert partition_odt(odt_file_path) == partition_docx(docx_file_path)
# -- document-source (file or filename) ----------------------------------------------------------
def test_partition_odt_from_filename():
elements = partition_odt(example_doc_path("fake.odt"))
assert elements == [
NarrativeText("Lorem ipsum dolor sit amet."),
Table(
"Header row Mon Wed Fri Color Blue Red Green Time 1pm 2pm 3pm Leader Sarah Mark Ryan"
),
]
assert all(e.metadata.filename == "fake.odt" for e in elements)
if UNSTRUCTURED_INCLUDE_DEBUG_METADATA:
# -- document is ultimately partitioned by partition_docx() --
assert {e.metadata.detection_origin for e in elements} == {"docx"}
def test_partition_odt_from_file():
with open(example_doc_path("fake.odt"), "rb") as f:
elements = partition_odt(file=f)
assert elements == [
NarrativeText("Lorem ipsum dolor sit amet."),
Table(
"Header row Mon Wed Fri Color Blue Red Green Time 1pm 2pm 3pm Leader Sarah Mark Ryan"
),
]
# -- .metadata.filename --------------------------------------------------------------------------
def test_partition_odt_from_filename_gets_the_ODT_filename_in_metadata_not_the_DOCX_filename():
elements = partition_odt(example_doc_path("simple.odt"))
assert all(e.metadata.filename == "simple.odt" for e in elements), (
f"Expected all elements to have 'simple.odt' as their filename, but got:"
f" {repr(elements[0].metadata.filename)}"
)
def test_partition_odt_from_filename_with_metadata_filename():
elements = partition_odt(example_doc_path("fake.odt"), metadata_filename="test")
assert all(e.metadata.filename == "test" for e in elements)
def test_partition_odt_from_file_with_metadata_filename():
with open(example_doc_path("fake.odt"), "rb") as f:
elements = partition_odt(file=f, metadata_filename="test")
assert all(e.metadata.filename == "test" for e in elements)
# -- .metadata.filetype --------------------------------------------------------------------------
def test_partition_odt_gets_the_ODT_MIME_type_in_metadata_filetype():
ODT_MIME_TYPE = "application/vnd.oasis.opendocument.text"
elements = partition_odt(example_doc_path("simple.odt"))
assert all(e.metadata.filetype == ODT_MIME_TYPE for e in elements), (
f"Expected all elements to have '{ODT_MIME_TYPE}' as their filetype, but got:"
f" {repr(elements[0].metadata.filetype)}"
)
# -- .metadata.text_as_html ----------------------------------------------------------------------
@pytest.mark.parametrize("kwargs", [{}, {"infer_table_structure": True}])
def test_partition_odt_adds_text_as_html_when_infer_table_structure_is_omitted_or_True(
kwargs: dict[str, Any],
):
with open(example_doc_path("fake.odt"), "rb") as f:
elements = partition_odt(file=f, **kwargs)
table = elements[1]
assert isinstance(table, Table)
assert table.metadata.text_as_html is not None
assert table.metadata.text_as_html.startswith("<table>")
def test_partition_odt_suppresses_text_as_html_when_infer_table_structure_is_False():
with open(example_doc_path("fake.odt"), "rb") as f:
elements = partition_odt(file=f, infer_table_structure=False)
table = elements[1]
assert isinstance(table, Table)
assert table.metadata.text_as_html is None
# -- .metadata.last_modified ---------------------------------------------------------------------
def test_partition_odt_pulls_last_modified_from_filesystem(mocker: MockFixture):
filesystem_last_modified = "2029-07-05T09:24:28"
mocker.patch(
"unstructured.partition.odt.get_last_modified_date", return_value=filesystem_last_modified
)
elements = partition_odt(example_doc_path("fake.odt"))
assert all(e.metadata.last_modified == filesystem_last_modified for e in elements)
def test_partition_odt_prefers_metadata_last_modified_when_provided(mocker: MockFixture):
filesystem_last_modified = "2029-07-05T09:24:28"
metadata_last_modified = "2020-07-05T09:24:28"
mocker.patch(
"unstructured.partition.odt.get_last_modified_date", return_value=filesystem_last_modified
)
elements = partition_odt(
example_doc_path("simple.odt"), metadata_last_modified=metadata_last_modified
)
assert all(e.metadata.last_modified == metadata_last_modified for e in elements)
# -- .metadata.languages -------------------------------------------------------------------------
def test_partition_odt_adds_languages_metadata():
elements = partition_odt(example_doc_path("simple.odt"))
assert all(e.metadata.languages == ["eng"] for e in elements)
def test_partition_odt_respects_detect_language_per_element_arg():
elements = partition_odt(
example_doc_path("language-docs/eng_spa_mult.odt"), detect_language_per_element=True
)
assert [e.metadata.languages for e in elements] == [
["eng"],
["spa", "eng"],
["eng"],
["eng"],
["spa"],
]
# -- miscellaneous -------------------------------------------------------------------------------
@pytest.mark.parametrize(
("kwargs", "expected_value"),
[({}, "hi_res"), ({"strategy": None}, "hi_res"), ({"strategy": "auto"}, "auto")],
)
def test_partition_odt_forwards_strategy_arg_to_partition_docx(
request: FixtureRequest, kwargs: dict[str, Any], expected_value: str | None
):
from unstructured.partition.docx import _DocxPartitioner
def fake_iter_document_elements(self: _DocxPartitioner) -> Iterator[Element]:
yield Text(f"strategy == {self._opts.strategy}")
_iter_elements_ = method_mock(
request,
_DocxPartitioner,
"_iter_document_elements",
side_effect=fake_iter_document_elements,
)
(element,) = partition_odt(example_doc_path("simple.odt"), **kwargs)
_iter_elements_.assert_called_once_with(ANY)
assert element.text == f"strategy == {expected_value}"
def test_partition_odt_round_trips_through_json():
"""Elements produced can be serialized then deserialized without loss."""
assert_round_trips_through_JSON(partition_odt(example_doc_path("simple.odt")))
def test_partition_odt_chunks_elements_when_chunking_strategy_is_specified():
document_path = example_doc_path("simple.odt")
elements = partition_odt(document_path)
chunks = partition_odt(document_path, chunking_strategy="basic")
# -- all chunks are chunk element-types --
assert all(isinstance(c, (CompositeElement, Table, TableChunk)) for c in chunks)
# -- chunks from partitioning match those produced by chunking elements in separate step --
assert chunks == chunk_elements(elements)
+158
View File
@@ -0,0 +1,158 @@
from __future__ import annotations
from pathlib import Path
from pytest_mock import MockFixture
from test_unstructured.unit_utils import (
assert_round_trips_through_JSON,
example_doc_path,
find_text_in_elements,
)
from unstructured.chunking.title import chunk_by_title
from unstructured.documents.elements import Title
from unstructured.partition.org import partition_org
def test_partition_org_from_filename():
elements = partition_org(example_doc_path("README.org"))
assert elements[0] == Title("Example Docs")
assert elements[0].metadata.filetype == "text/org"
def test_partition_org_from_file():
with open(example_doc_path("README.org"), "rb") as f:
elements = partition_org(file=f)
assert elements[0] == Title("Example Docs")
# -- .metadata.filename --------------------------------------------------------------------------
def test_partition_org_from_filename_gets_filename_from_filename_arg():
elements = partition_org(example_doc_path("README.org"))
assert len(elements) > 0
assert all(e.metadata.filename == "README.org" for e in elements)
def test_partition_org_from_file_gets_filename_None():
with open(example_doc_path("README.org"), "rb") as f:
elements = partition_org(file=f)
assert len(elements) > 0
assert all(e.metadata.filename is None for e in elements)
def test_partition_org_from_filename_prefers_metadata_filename():
elements = partition_org(example_doc_path("README.org"), metadata_filename="orig-name.org")
assert len(elements) > 0
assert all(element.metadata.filename == "orig-name.org" for element in elements)
def test_partition_org_from_file_prefers_metadata_filename():
with open(example_doc_path("README.org"), "rb") as f:
elements = partition_org(file=f, metadata_filename="orig-name.org")
assert all(e.metadata.filename == "orig-name.org" for e in elements)
# -- .metadata.filetype --------------------------------------------------------------------------
def test_partition_org_gets_the_ORG_MIME_type_in_metadata_filetype():
ORG_MIME_TYPE = "text/org"
elements = partition_org(example_doc_path("README.org"))
assert all(e.metadata.filetype == ORG_MIME_TYPE for e in elements), (
f"Expected all elements to have '{ORG_MIME_TYPE}' as their filetype, but got:"
f" {repr(elements[0].metadata.filetype)}"
)
# -- .metadata.last_modified ---------------------------------------------------------------------
def test_partition_org_from_filename_gets_last_modified_from_filesystem(mocker: MockFixture):
filesystem_last_modified = "2024-06-14T16:01:29"
mocker.patch(
"unstructured.partition.org.get_last_modified_date", return_value=filesystem_last_modified
)
elements = partition_org(example_doc_path("README.org"))
assert all(e.metadata.last_modified == filesystem_last_modified for e in elements)
def test_partition_org_from_file_gets_last_modified_None():
with open(example_doc_path("README.org"), "rb") as f:
elements = partition_org(file=f)
assert all(e.metadata.last_modified is None for e in elements)
def test_partition_org_from_filename_prefers_metadata_last_modified(mocker: MockFixture):
filesystem_last_modified = "2020-08-04T06:11:47"
metadata_last_modified = "2024-06-14T16:01:29"
mocker.patch(
"unstructured.partition.org.get_last_modified_date", return_value=filesystem_last_modified
)
elements = partition_org(
example_doc_path("README.org"), metadata_last_modified=metadata_last_modified
)
assert all(e.metadata.last_modified == metadata_last_modified for e in elements)
def test_partition_org_from_file_prefers_metadata_last_modified():
metadata_last_modified = "2020-07-05T09:24:28"
with open(example_doc_path("README.org"), "rb") as f:
elements = partition_org(file=f, metadata_last_modified=metadata_last_modified)
assert all(e.metadata.last_modified == metadata_last_modified for e in elements)
# ------------------------------------------------------------------------------------------------
def test_partition_org_with_json():
elements = partition_org(example_doc_path("README.org"))
assert_round_trips_through_JSON(elements)
def test_add_chunking_strategy_by_title_on_partition_org():
file_path = example_doc_path("README.org")
elements = partition_org(file_path)
chunk_elements = partition_org(file_path, chunking_strategy="by_title")
chunks = chunk_by_title(elements)
assert chunk_elements != elements
assert chunk_elements == chunks
def test_partition_org_element_metadata_has_languages():
elements = partition_org(example_doc_path("README.org"))
assert elements[0].metadata.languages == ["eng"]
def test_partition_org_respects_detect_language_per_element():
elements = partition_org(
example_doc_path("language-docs/eng_spa_mult.org"), detect_language_per_element=True
)
langs = [element.metadata.languages for element in elements]
assert langs == [["eng"], ["spa", "eng"], ["eng"], ["eng"], ["spa"]]
def test_org_wont_include_external_files():
# Make sure our import file is in place (otherwise the import fails silently and test passes)
assert Path(example_doc_path("file_we_dont_want_imported")).exists()
elements = partition_org(example_doc_path("README-w-include.org"))
# The partition should contain some elements
assert elements
# We find something we expect to find from file we partitioned directly
assert find_text_in_elements("instructions", elements)
# But we don't find something from the file included within the file we partitioned directly
assert not find_text_in_elements("wombat", elements)
+180
View File
@@ -0,0 +1,180 @@
"""Test-suite for `unstructured.partition.ppt` module."""
from __future__ import annotations
import pytest
from pytest_mock import MockFixture
from test_unstructured.unit_utils import assert_round_trips_through_JSON, example_doc_path
from unstructured.chunking.title import chunk_by_title
from unstructured.documents.elements import ListItem, NarrativeText, PageBreak, Title
from unstructured.partition.ppt import partition_ppt
from unstructured.partition.utils.constants import UNSTRUCTURED_INCLUDE_DEBUG_METADATA
EXPECTED_PPT_OUTPUT = [
Title(text="Adding a Bullet Slide"),
ListItem(text="Find the bullet slide layout"),
ListItem(text="Use _TextFrame.text for first bullet"),
ListItem(text="Use _TextFrame.add_paragraph() for subsequent bullets"),
NarrativeText(text="Here is a lot of text!"),
NarrativeText(text="Here is some text in a text box!"),
]
def test_partition_ppt_from_filename():
elements = partition_ppt(example_doc_path("fake-power-point.ppt"))
assert elements == EXPECTED_PPT_OUTPUT
for element in elements:
assert element.metadata.filename == "fake-power-point.ppt"
if UNSTRUCTURED_INCLUDE_DEBUG_METADATA:
assert {element.metadata.detection_origin for element in elements} == {"pptx"}
def test_partition_ppt_raises_with_missing_file():
with pytest.raises(ValueError):
partition_ppt(example_doc_path("doesnt-exist.ppt"))
def test_partition_ppt_from_file():
with open(example_doc_path("fake-power-point.ppt"), "rb") as f:
elements = partition_ppt(file=f)
assert elements == EXPECTED_PPT_OUTPUT
for element in elements:
assert element.metadata.filename is None
def test_partition_ppt_from_file_with_metadata_filename():
with open(example_doc_path("fake-power-point.ppt"), "rb") as f:
elements = partition_ppt(file=f, metadata_filename="test")
assert elements == EXPECTED_PPT_OUTPUT
for element in elements:
assert element.metadata.filename == "test"
def test_partition_ppt_raises_with_both_specified():
filename = example_doc_path("fake-power-point.ppt")
with open(filename, "rb") as f, pytest.raises(ValueError):
partition_ppt(filename=filename, file=f)
def test_partition_ppt_raises_when_neither_file_path_or_file_is_provided():
with pytest.raises(ValueError):
partition_ppt()
# -- .metadata.filename --------------------------------------------------------------------------
def test_partition_ppt_from_filename_gets_filename_from_filename_arg():
elements = partition_ppt(example_doc_path("fake-power-point.ppt"))
assert len(elements) > 0
assert all(e.metadata.filename == "fake-power-point.ppt" for e in elements)
def test_partition_ppt_from_file_gets_filename_None():
with open(example_doc_path("fake-power-point.ppt"), "rb") as f:
elements = partition_ppt(file=f)
assert len(elements) > 0
assert all(e.metadata.filename is None for e in elements)
def test_partition_ppt_from_filename_prefers_metadata_filename():
elements = partition_ppt(example_doc_path("fake-power-point.ppt"), metadata_filename="test")
assert len(elements) > 0
assert all(element.metadata.filename == "test" for element in elements)
def test_partition_ppt_from_file_prefers_metadata_filename():
with open(example_doc_path("fake-power-point.ppt"), "rb") as f:
elements = partition_ppt(file=f, metadata_filename="test")
assert all(e.metadata.filename == "test" for e in elements)
# -- .metadata.last_modified ---------------------------------------------------------------------
def test_partition_ppt_from_file_path_gets_last_modified_from_filesystem(mocker: MockFixture):
filesystem_last_modified = "2024-05-01T15:37:28"
mocker.patch(
"unstructured.partition.ppt.get_last_modified_date", return_value=filesystem_last_modified
)
elements = partition_ppt(example_doc_path("fake-power-point.ppt"))
assert all(e.metadata.last_modified == filesystem_last_modified for e in elements)
def test_partition_ppt_from_file_gets_last_modified_None():
with open(example_doc_path("fake-power-point.ppt"), "rb") as f:
elements = partition_ppt(file=f)
assert all(e.metadata.last_modified is None for e in elements)
def test_partition_ppt_from_file_path_prefers_metadata_last_modified(mocker: MockFixture):
filesystem_last_modified = "2024-05-01T15:37:28"
metadata_last_modified = "2020-07-05T09:24:28"
mocker.patch(
"unstructured.partition.ppt.get_last_modified_date", return_value=filesystem_last_modified
)
elements = partition_ppt(
example_doc_path("fake-power-point.ppt"), metadata_last_modified=metadata_last_modified
)
assert all(e.metadata.last_modified == metadata_last_modified for e in elements)
def test_partition_ppt_from_file_prefers_metadata_last_modified():
metadata_last_modified = "2020-07-05T09:24:28"
with open(example_doc_path("fake-power-point.ppt"), "rb") as f:
elements = partition_ppt(file=f, metadata_last_modified=metadata_last_modified)
assert elements[0].metadata.last_modified == metadata_last_modified
# ------------------------------------------------------------------------------------------------
def test_partition_ppt_with_json():
elements = partition_ppt(example_doc_path("fake-power-point.ppt"))
assert_round_trips_through_JSON(elements)
def test_add_chunking_strategy_by_title_on_partition_ppt():
file_path = example_doc_path("fake-power-point.ppt")
elements = partition_ppt(file_path)
chunk_elements = partition_ppt(file_path, chunking_strategy="by_title")
chunks = chunk_by_title(elements)
assert chunk_elements != elements
assert chunk_elements == chunks
def test_partition_ppt_params():
"""Integration test of params: languages, include_page_break, and include_slide_notes."""
elements = partition_ppt(
example_doc_path("language-docs/eng_spa_mult.ppt"),
include_page_breaks=True,
include_slide_notes=True,
)
assert elements[0].metadata.languages == ["eng"]
assert any(isinstance(element, PageBreak) for element in elements)
# The example doc contains a slide note with the text "This is a slide note."
assert any(element.text == "This is a slide note." for element in elements)
def test_partition_ppt_respects_detect_language_per_element():
elements = partition_ppt(
example_doc_path("language-docs/eng_spa_mult.ppt"), detect_language_per_element=True
)
langs = [element.metadata.languages for element in elements]
# languages other than English and Spanish are detected by this partitioner,
# so this test is slightly different from the other partition tests
langs = {element.metadata.languages[0] for element in elements if element.metadata.languages}
assert "eng" in langs
assert "spa" in langs

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