Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 0.22.28

### Fixes

- **Stop misclassifying multi-line JSON files as NDJSON**: `is_ndjson_processable` previously returned `True` for any text starting with `{`, so `.json` and `.ipynb` files containing a single multi-line JSON object (e.g. Jupyter notebooks) were routed to `partition_ndjson`, which then crashed in its `splitlines()`-based parser.

## 0.22.27

### Fixes
Expand Down
42 changes: 42 additions & 0 deletions test_unstructured/partition/pdf_image/test_ocr.py
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,48 @@ def test_get_table_tokens(mock_ocr_layout):
assert table_tokens == expected_tokens


def test_get_table_tokens_prefers_extracted_regions_over_ocr(mock_ocr_layout):
extracted_regions = TextRegions.from_list(
[
EmbeddedTextRegion.from_coords(
20, 30, 40, 50, text="AUTOSAR Administration", source=None
),
EmbeddedTextRegion.from_coords(45, 30, 55, 50, text="2.1.0", source=None),
]
)
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=Image.new("RGB", (80, 80)),
ocr_agent=ocr_agent,
extracted_regions=extracted_regions,
table_bbox=(10, 20, 70, 70),
padding=0,
)

assert [token["text"] for token in table_tokens] == ["AUTOSAR Administration", "2.1.0"]
assert table_tokens[0]["bbox"] == [10, 10, 30, 30]


def test_get_table_tokens_falls_back_to_ocr_when_extracted_is_sparse(mock_ocr_layout):
extracted_regions = TextRegions.from_list(
[
EmbeddedTextRegion.from_coords(20, 30, 40, 50, text="only-one-token", source=None),
]
)
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=Image.new("RGB", (80, 80)),
ocr_agent=ocr_agent,
extracted_regions=extracted_regions,
table_bbox=(10, 20, 70, 70),
padding=0,
)

assert [token["text"] for token in table_tokens] == ["Token1", "Token2"]


def test_auto_zoom_not_exceed_tesseract_limit(monkeypatch):
monkeypatch.setenv("TESSERACT_MIN_TEXT_HEIGHT", "1000")
monkeypatch.setenv("TESSERACT_OPTIMUM_TEXT_HEIGHT", "100000")
Expand Down
2 changes: 1 addition & 1 deletion unstructured/__version__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.22.27" # pragma: no cover
__version__ = "0.22.28" # pragma: no cover
120 changes: 112 additions & 8 deletions unstructured/partition/pdf_image/ocr.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,17 +312,26 @@ def supplement_element_with_table_extraction(
table_elements = elements.slice(table_ele_indices)
padding = env_config.TABLE_IMAGE_CROP_PAD
for i, element_coords in enumerate(table_elements.element_coords):
table_bbox = (
float(element_coords[0]),
float(element_coords[1]),
float(element_coords[2]),
float(element_coords[3]),
)
cropped_image = image.crop(
(
element_coords[0] - padding,
element_coords[1] - padding,
element_coords[2] + padding,
element_coords[3] + padding,
table_bbox[0] - padding,
table_bbox[1] - padding,
table_bbox[2] + padding,
table_bbox[3] + padding,
),
)
table_tokens = get_table_tokens(
table_element_image=cropped_image,
ocr_agent=ocr_agent,
extracted_regions=extracted_regions,
table_bbox=table_bbox,
padding=padding,
)
tatr_cells = tables_agent.predict(
cropped_image, ocr_tokens=table_tokens, result_format="cells"
Expand All @@ -344,13 +353,15 @@ def supplement_element_with_table_extraction(
def get_table_tokens(
table_element_image: PILImage.Image,
ocr_agent: OCRAgent,
extracted_regions: Optional[TextRegions] = None,
table_bbox: Optional[tuple[float, float, float, float]] = None,
padding: float = 0,
) -> List[dict[str, Any]]:
"""Get OCR tokens from either paddleocr or tesseract"""

"""Get table tokens, preferring embedded PDF text when coverage is sufficient."""
ocr_layout = ocr_agent.get_layout_from_image(image=table_element_image)
table_tokens = []
ocr_tokens = []
for i, text in enumerate(ocr_layout.texts):
table_tokens.append(
ocr_tokens.append(
{
"bbox": [
ocr_layout.x1[i],
Expand All @@ -367,6 +378,99 @@ def get_table_tokens(
}
)

if extracted_regions is None or table_bbox is None:
return ocr_tokens

extracted_tokens = _get_table_tokens_from_extracted_regions(
extracted_regions=extracted_regions,
table_bbox=table_bbox,
table_image_size=table_element_image.size,
padding=padding,
)
if _prefer_extracted_table_tokens(extracted_tokens, ocr_tokens):
return extracted_tokens

return ocr_tokens


def _prefer_extracted_table_tokens(
extracted_tokens: List[dict[str, Any]],
ocr_tokens: List[dict[str, Any]],
token_ratio_threshold: float = 0.8,
text_ratio_threshold: float = 0.8,
) -> bool:
"""Choose extracted tokens only when they have comparable coverage to OCR."""
if not extracted_tokens:
return False
if not ocr_tokens:
return True

extracted_count = len(extracted_tokens)
ocr_count = len(ocr_tokens)
extracted_chars = sum(len(str(token.get("text", ""))) for token in extracted_tokens)
ocr_chars = sum(len(str(token.get("text", ""))) for token in ocr_tokens)

return (
extracted_count >= token_ratio_threshold * ocr_count
and extracted_chars >= text_ratio_threshold * ocr_chars
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Embedded tokens lose preference

Medium Severity

The _prefer_extracted_table_tokens function compares token counts across different granularities (line-level for embedded text vs. word-level for OCR). This can cause complete embedded table text to be incorrectly rejected, leading to a fallback to OCR and reintroducing OCR-related substitutions.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 321bfaa. Configure here.



def _get_table_tokens_from_extracted_regions(
extracted_regions: TextRegions,
table_bbox: tuple[float, float, float, float],
table_image_size: tuple[int, int],
padding: float,
) -> List[dict[str, Any]]:
if len(extracted_regions) == 0:
return []

mask = (
bboxes1_is_almost_subregion_of_bboxes2(
extracted_regions.element_coords,
np.array([table_bbox]),
env_config.OCR_LAYOUT_SUBREGION_THRESHOLD,
)
.sum(axis=1)
.astype(bool)
)
if not np.any(mask):
return []

selected_regions = extracted_regions.slice(mask)
left = table_bbox[0] - padding
top = table_bbox[1] - padding
width, height = table_image_size

valid = [
(idx, text) for idx, text in enumerate(selected_regions.texts) if text and str(text).strip()
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low-fidelity text can replace OCR

Medium Severity

_get_table_tokens_from_extracted_regions() accepts any non-empty PDFMiner text and ignores extraction quality. Invisible or low-fidelity OCR layers in scanned PDFs can be preferred over fresh OCR, so the intended OCR fallback for scanned documents is bypassed.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 321bfaa. Configure here.

if not valid:
return []

# Keep deterministic reading order (top-to-bottom then left-to-right).
sorted_indices = sorted(
valid,
key=lambda item: (selected_regions.y1[item[0]], selected_regions.x1[item[0]]),
)
table_tokens = []
for span_num, (idx, text) in enumerate(sorted_indices):
x1 = max(0, min(width, int(round(selected_regions.x1[idx] - left))))
y1 = max(0, min(height, int(round(selected_regions.y1[idx] - top))))
x2 = max(0, min(width, int(round(selected_regions.x2[idx] - left))))
y2 = max(0, min(height, int(round(selected_regions.y2[idx] - top))))
if x2 <= x1 or y2 <= y1:
continue
table_tokens.append(
{
"bbox": [x1, y1, x2, y2],
"text": str(text),
"span_num": span_num,
"line_num": 0,
"block_num": 0,
}
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-contiguous span_num when tokens are skipped

Medium Severity

In _get_table_tokens_from_extracted_regions, span_num is assigned from enumerate(sorted_indices), but when a token is skipped by the continue on the degenerate-bbox check, the counter still increments, producing gaps in span_num values (e.g., [0, 2, 4] instead of [0, 1, 2]). The OCR path always produces contiguous span numbers. Downstream consumers (table transformer) receive tokens whose span_num field is inconsistent with the contract established by the OCR path, which could affect cell-to-token assignment or reading-order logic.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1fda8ae. Configure here.


return table_tokens


Expand Down
Loading