Skip to content

Commit 945c186

Browse files
fix: prefer embedded PDF text over OCR for hi_res table tokens
1 parent 199f255 commit 945c186

4 files changed

Lines changed: 161 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
## 0.22.27
2+
3+
### Fixes
4+
5+
- **Prefer embedded PDF text for hi-res table cell content**: When `partition_pdf(..., strategy="hi_res", infer_table_structure=True)` processes digital PDFs, table tokenization now uses PDFMiner extracted text inside table regions before OCR fallback. This prevents OCR substitutions in table HTML (for example, replacing `AUTOSAR Administration` with incorrect OCR output) while preserving OCR behavior for scanned tables.
6+
17
## 0.22.26
28

39
### Enhancements

test_unstructured/partition/pdf_image/test_ocr.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,48 @@ def test_get_table_tokens(mock_ocr_layout):
486486
assert table_tokens == expected_tokens
487487

488488

489+
def test_get_table_tokens_prefers_extracted_regions_over_ocr(mock_ocr_layout):
490+
extracted_regions = TextRegions.from_list(
491+
[
492+
EmbeddedTextRegion.from_coords(
493+
20, 30, 40, 50, text="AUTOSAR Administration", source=None
494+
),
495+
EmbeddedTextRegion.from_coords(45, 30, 55, 50, text="2.1.0", source=None),
496+
]
497+
)
498+
with patch.object(OCRAgentTesseract, "get_layout_from_image", return_value=mock_ocr_layout):
499+
ocr_agent = OCRAgent.get_agent(language="eng")
500+
table_tokens = ocr.get_table_tokens(
501+
table_element_image=Image.new("RGB", (80, 80)),
502+
ocr_agent=ocr_agent,
503+
extracted_regions=extracted_regions,
504+
table_bbox=(10, 20, 70, 70),
505+
padding=0,
506+
)
507+
508+
assert [token["text"] for token in table_tokens] == ["AUTOSAR Administration", "2.1.0"]
509+
assert table_tokens[0]["bbox"] == [10, 10, 30, 30]
510+
511+
512+
def test_get_table_tokens_falls_back_to_ocr_when_extracted_is_sparse(mock_ocr_layout):
513+
extracted_regions = TextRegions.from_list(
514+
[
515+
EmbeddedTextRegion.from_coords(20, 30, 40, 50, text="only-one-token", source=None),
516+
]
517+
)
518+
with patch.object(OCRAgentTesseract, "get_layout_from_image", return_value=mock_ocr_layout):
519+
ocr_agent = OCRAgent.get_agent(language="eng")
520+
table_tokens = ocr.get_table_tokens(
521+
table_element_image=Image.new("RGB", (80, 80)),
522+
ocr_agent=ocr_agent,
523+
extracted_regions=extracted_regions,
524+
table_bbox=(10, 20, 70, 70),
525+
padding=0,
526+
)
527+
528+
assert [token["text"] for token in table_tokens] == ["Token1", "Token2"]
529+
530+
489531
def test_auto_zoom_not_exceed_tesseract_limit(monkeypatch):
490532
monkeypatch.setenv("TESSERACT_MIN_TEXT_HEIGHT", "1000")
491533
monkeypatch.setenv("TESSERACT_OPTIMUM_TEXT_HEIGHT", "100000")

unstructured/__version__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.22.26" # pragma: no cover
1+
__version__ = "0.22.27" # pragma: no cover

unstructured/partition/pdf_image/ocr.py

Lines changed: 112 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -312,17 +312,26 @@ def supplement_element_with_table_extraction(
312312
table_elements = elements.slice(table_ele_indices)
313313
padding = env_config.TABLE_IMAGE_CROP_PAD
314314
for i, element_coords in enumerate(table_elements.element_coords):
315+
table_bbox = (
316+
float(element_coords[0]),
317+
float(element_coords[1]),
318+
float(element_coords[2]),
319+
float(element_coords[3]),
320+
)
315321
cropped_image = image.crop(
316322
(
317-
element_coords[0] - padding,
318-
element_coords[1] - padding,
319-
element_coords[2] + padding,
320-
element_coords[3] + padding,
323+
table_bbox[0] - padding,
324+
table_bbox[1] - padding,
325+
table_bbox[2] + padding,
326+
table_bbox[3] + padding,
321327
),
322328
)
323329
table_tokens = get_table_tokens(
324330
table_element_image=cropped_image,
325331
ocr_agent=ocr_agent,
332+
extracted_regions=extracted_regions,
333+
table_bbox=table_bbox,
334+
padding=padding,
326335
)
327336
tatr_cells = tables_agent.predict(
328337
cropped_image, ocr_tokens=table_tokens, result_format="cells"
@@ -344,13 +353,15 @@ def supplement_element_with_table_extraction(
344353
def get_table_tokens(
345354
table_element_image: PILImage.Image,
346355
ocr_agent: OCRAgent,
356+
extracted_regions: Optional[TextRegions] = None,
357+
table_bbox: Optional[tuple[float, float, float, float]] = None,
358+
padding: float = 0,
347359
) -> List[dict[str, Any]]:
348-
"""Get OCR tokens from either paddleocr or tesseract"""
349-
360+
"""Get table tokens, preferring embedded PDF text when coverage is sufficient."""
350361
ocr_layout = ocr_agent.get_layout_from_image(image=table_element_image)
351-
table_tokens = []
362+
ocr_tokens = []
352363
for i, text in enumerate(ocr_layout.texts):
353-
table_tokens.append(
364+
ocr_tokens.append(
354365
{
355366
"bbox": [
356367
ocr_layout.x1[i],
@@ -367,6 +378,99 @@ def get_table_tokens(
367378
}
368379
)
369380

381+
if extracted_regions is None or table_bbox is None:
382+
return ocr_tokens
383+
384+
extracted_tokens = _get_table_tokens_from_extracted_regions(
385+
extracted_regions=extracted_regions,
386+
table_bbox=table_bbox,
387+
table_image_size=table_element_image.size,
388+
padding=padding,
389+
)
390+
if _prefer_extracted_table_tokens(extracted_tokens, ocr_tokens):
391+
return extracted_tokens
392+
393+
return ocr_tokens
394+
395+
396+
def _prefer_extracted_table_tokens(
397+
extracted_tokens: List[dict[str, Any]],
398+
ocr_tokens: List[dict[str, Any]],
399+
token_ratio_threshold: float = 0.8,
400+
text_ratio_threshold: float = 0.8,
401+
) -> bool:
402+
"""Choose extracted tokens only when they have comparable coverage to OCR."""
403+
if not extracted_tokens:
404+
return False
405+
if not ocr_tokens:
406+
return True
407+
408+
extracted_count = len(extracted_tokens)
409+
ocr_count = len(ocr_tokens)
410+
extracted_chars = sum(len(str(token.get("text", ""))) for token in extracted_tokens)
411+
ocr_chars = sum(len(str(token.get("text", ""))) for token in ocr_tokens)
412+
413+
return (
414+
extracted_count >= token_ratio_threshold * ocr_count
415+
and extracted_chars >= text_ratio_threshold * ocr_chars
416+
)
417+
418+
419+
def _get_table_tokens_from_extracted_regions(
420+
extracted_regions: TextRegions,
421+
table_bbox: tuple[float, float, float, float],
422+
table_image_size: tuple[int, int],
423+
padding: float,
424+
) -> List[dict[str, Any]]:
425+
if len(extracted_regions) == 0:
426+
return []
427+
428+
mask = (
429+
bboxes1_is_almost_subregion_of_bboxes2(
430+
extracted_regions.element_coords,
431+
np.array([table_bbox]),
432+
env_config.OCR_LAYOUT_SUBREGION_THRESHOLD,
433+
)
434+
.sum(axis=1)
435+
.astype(bool)
436+
)
437+
if not np.any(mask):
438+
return []
439+
440+
selected_regions = extracted_regions.slice(mask)
441+
left = table_bbox[0] - padding
442+
top = table_bbox[1] - padding
443+
width, height = table_image_size
444+
445+
valid = [
446+
(idx, text) for idx, text in enumerate(selected_regions.texts) if text and str(text).strip()
447+
]
448+
if not valid:
449+
return []
450+
451+
# Keep deterministic reading order (top-to-bottom then left-to-right).
452+
sorted_indices = sorted(
453+
valid,
454+
key=lambda item: (selected_regions.y1[item[0]], selected_regions.x1[item[0]]),
455+
)
456+
table_tokens = []
457+
for span_num, (idx, text) in enumerate(sorted_indices):
458+
x1 = max(0, min(width, int(round(selected_regions.x1[idx] - left))))
459+
y1 = max(0, min(height, int(round(selected_regions.y1[idx] - top))))
460+
x2 = max(0, min(width, int(round(selected_regions.x2[idx] - left))))
461+
y2 = max(0, min(height, int(round(selected_regions.y2[idx] - top))))
462+
if x2 <= x1 or y2 <= y1:
463+
continue
464+
table_tokens.append(
465+
{
466+
"bbox": [x1, y1, x2, y2],
467+
"text": str(text),
468+
"span_num": span_num,
469+
"line_num": 0,
470+
"block_num": 0,
471+
}
472+
)
473+
370474
return table_tokens
371475

372476

0 commit comments

Comments
 (0)