461bf6fd40
CI / lint (3.11) (push) Has been cancelled
CI / lint (3.12) (push) Has been cancelled
CI / lint (3.13) (push) Has been cancelled
CI / shellcheck (push) Has been cancelled
CI / shfmt (push) Has been cancelled
CI / setup (3.11) (push) Has been cancelled
CI / setup (3.12) (push) Has been cancelled
CI / setup (3.13) (push) Has been cancelled
CI / check-licenses (3.12) (push) Has been cancelled
CI / test_unit (3.11) (push) Has been cancelled
CI / test_unit (3.12) (push) Has been cancelled
CI / test_unit (3.13) (push) Has been cancelled
CI / test_unit_no_extras (3.11) (push) Has been cancelled
CI / test_unit_no_extras (3.12) (push) Has been cancelled
CI / test_json_to_html (3.12) (push) Has been cancelled
CI / test_unit_no_extras (3.13) (push) Has been cancelled
CI / test_unit_dependency_extras (csv, 3.12, --extra csv) (push) Has been cancelled
CI / test_unit_dependency_extras (xlsx, 3.11, --extra xlsx) (push) Has been cancelled
CI / test_unit_dependency_extras (xlsx, 3.12, --extra xlsx) (push) Has been cancelled
CI / test_unit_dependency_extras (csv, 3.11, --extra csv) (push) Has been cancelled
CI / test_unit_dependency_extras (csv, 3.13, --extra csv) (push) Has been cancelled
CI / test_unit_dependency_extras (docx, 3.11, --extra docx) (push) Has been cancelled
CI / test_unit_dependency_extras (docx, 3.12, --extra docx) (push) Has been cancelled
CI / test_unit_dependency_extras (docx, 3.13, --extra docx) (push) Has been cancelled
CI / test_unit_dependency_extras (markdown, 3.11, --extra md) (push) Has been cancelled
CI / test_unit_dependency_extras (markdown, 3.12, --extra md) (push) Has been cancelled
CI / test_unit_dependency_extras (markdown, 3.13, --extra md) (push) Has been cancelled
CI / test_unit_dependency_extras (odt, 3.11, --extra odt) (push) Has been cancelled
CI / test_unit_dependency_extras (odt, 3.12, --extra odt) (push) Has been cancelled
CI / test_unit_dependency_extras (odt, 3.13, --extra odt) (push) Has been cancelled
CI / test_unit_dependency_extras (pdf-image, 3.11, --extra pdf --extra image --extra paddleocr) (push) Has been cancelled
CI / test_unit_dependency_extras (pdf-image, 3.12, --extra pdf --extra image --extra paddleocr) (push) Has been cancelled
CI / test_unit_dependency_extras (pdf-image, 3.13, --extra pdf --extra image --extra paddleocr) (push) Has been cancelled
CI / test_unit_dependency_extras (pptx, 3.11, --extra pptx) (push) Has been cancelled
CI / test_unit_dependency_extras (pptx, 3.12, --extra pptx) (push) Has been cancelled
CI / test_unit_dependency_extras (pptx, 3.13, --extra pptx) (push) Has been cancelled
CI / test_unit_dependency_extras (pypandoc, 3.11, --extra epub --extra org --extra rtf --extra rst) (push) Has been cancelled
CI / test_unit_dependency_extras (pypandoc, 3.12, --extra epub --extra org --extra rtf --extra rst) (push) Has been cancelled
CI / test_unit_dependency_extras (pypandoc, 3.13, --extra epub --extra org --extra rtf --extra rst) (push) Has been cancelled
Build And Push Docker Image / set-short-sha (push) Has been cancelled
Partition Benchmark / setup (push) Has been cancelled
Partition Benchmark / Measure and compare partition() runtime (push) Has been cancelled
CI / test_unit_dependency_extras (xlsx, 3.13, --extra xlsx) (push) Has been cancelled
CI / test_ingest_src (3.12) (push) Has been cancelled
CI / test_json_to_markdown (3.12) (push) Has been cancelled
CI / changelog (push) Has been cancelled
CI / test_dockerfile (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Build And Push Docker Image / build-images (linux/amd64, opensource-linux-8core) (push) Has been cancelled
Build And Push Docker Image / build-images (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build And Push Docker Image / publish-images (push) Has been cancelled
683 lines
24 KiB
Python
683 lines
24 KiB
Python
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"
|