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"