natural-pdf 0.1.23__py3-none-any.whl → 0.1.24__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- natural_pdf/analyzers/shape_detection_mixin.py +40 -0
- natural_pdf/core/highlighting_service.py +4 -4
- natural_pdf/core/page.py +16 -2
- natural_pdf/describe/base.py +11 -1
- natural_pdf/describe/summary.py +26 -0
- natural_pdf/elements/base.py +2 -2
- natural_pdf/elements/collections.py +139 -100
- natural_pdf/elements/region.py +133 -12
- natural_pdf/elements/text.py +15 -7
- natural_pdf/flows/region.py +116 -1
- natural_pdf/qa/document_qa.py +162 -105
- natural_pdf/utils/text_extraction.py +34 -14
- {natural_pdf-0.1.23.dist-info → natural_pdf-0.1.24.dist-info}/METADATA +2 -1
- {natural_pdf-0.1.23.dist-info → natural_pdf-0.1.24.dist-info}/RECORD +18 -18
- {natural_pdf-0.1.23.dist-info → natural_pdf-0.1.24.dist-info}/WHEEL +0 -0
- {natural_pdf-0.1.23.dist-info → natural_pdf-0.1.24.dist-info}/entry_points.txt +0 -0
- {natural_pdf-0.1.23.dist-info → natural_pdf-0.1.24.dist-info}/licenses/LICENSE +0 -0
- {natural_pdf-0.1.23.dist-info → natural_pdf-0.1.24.dist-info}/top_level.txt +0 -0
natural_pdf/elements/region.py
CHANGED
@@ -1210,6 +1210,24 @@ class Region(DirectionalMixin, ClassificationMixin, ExtractionMixin, ShapeDetect
|
|
1210
1210
|
# Try lattice first, then fall back to stream if no meaningful results
|
1211
1211
|
logger.debug(f"Region {self.bbox}: Auto-detecting table extraction method...")
|
1212
1212
|
|
1213
|
+
# --- NEW: Prefer already-created table_cell regions if they exist --- #
|
1214
|
+
try:
|
1215
|
+
cell_regions_in_table = [
|
1216
|
+
c
|
1217
|
+
for c in self.page.find_all("region[type=table_cell]", apply_exclusions=False)
|
1218
|
+
if self.intersects(c)
|
1219
|
+
]
|
1220
|
+
except Exception as _cells_err:
|
1221
|
+
cell_regions_in_table = [] # Fallback silently
|
1222
|
+
|
1223
|
+
if cell_regions_in_table:
|
1224
|
+
logger.debug(
|
1225
|
+
f"Region {self.bbox}: Found {len(cell_regions_in_table)} pre-computed table_cell regions – using 'cells' method."
|
1226
|
+
)
|
1227
|
+
return self._extract_table_from_cells(cell_regions_in_table)
|
1228
|
+
|
1229
|
+
# --------------------------------------------------------------- #
|
1230
|
+
|
1213
1231
|
try:
|
1214
1232
|
logger.debug(f"Region {self.bbox}: Trying 'lattice' method first...")
|
1215
1233
|
lattice_result = self.extract_table(
|
@@ -1905,19 +1923,19 @@ class Region(DirectionalMixin, ClassificationMixin, ExtractionMixin, ShapeDetect
|
|
1905
1923
|
logger.info(
|
1906
1924
|
f"Region {self.bbox}: Removing existing OCR elements before applying new OCR."
|
1907
1925
|
)
|
1908
|
-
# Find all OCR elements in this region
|
1909
|
-
ocr_selector = "text[source=ocr]"
|
1910
|
-
ocr_elements = self.find_all(ocr_selector)
|
1911
1926
|
|
1927
|
+
# Remove existing OCR word elements strictly inside this region
|
1928
|
+
ocr_selector = "text[source=ocr]"
|
1929
|
+
ocr_elements = self.find_all(ocr_selector, apply_exclusions=False)
|
1912
1930
|
if ocr_elements:
|
1931
|
+
removed_count = ocr_elements.remove()
|
1913
1932
|
logger.info(
|
1914
|
-
f"Region {self.bbox}:
|
1933
|
+
f"Region {self.bbox}: Removed {removed_count} existing OCR word elements in region before re-applying OCR."
|
1915
1934
|
)
|
1916
|
-
# Remove these elements from their page
|
1917
|
-
removed_count = ocr_elements.remove()
|
1918
|
-
logger.info(f"Region {self.bbox}: Removed {removed_count} OCR elements.")
|
1919
1935
|
else:
|
1920
|
-
logger.info(
|
1936
|
+
logger.info(
|
1937
|
+
f"Region {self.bbox}: No existing OCR word elements found within region to remove."
|
1938
|
+
)
|
1921
1939
|
|
1922
1940
|
ocr_mgr = self.page._parent._ocr_manager
|
1923
1941
|
|
@@ -1978,8 +1996,17 @@ class Region(DirectionalMixin, ClassificationMixin, ExtractionMixin, ShapeDetect
|
|
1978
1996
|
page_top = self.top + (img_top * scale_y)
|
1979
1997
|
page_x1 = self.x0 + (img_x1 * scale_x)
|
1980
1998
|
page_bottom = self.top + (img_bottom * scale_y)
|
1999
|
+
raw_conf = result.get("confidence")
|
2000
|
+
# Convert confidence to float unless it is None/invalid
|
2001
|
+
try:
|
2002
|
+
confidence_val = float(raw_conf) if raw_conf is not None else None
|
2003
|
+
except (TypeError, ValueError):
|
2004
|
+
confidence_val = None
|
2005
|
+
|
2006
|
+
text_val = result.get("text") # May legitimately be None in detect_only mode
|
2007
|
+
|
1981
2008
|
element_data = {
|
1982
|
-
"text":
|
2009
|
+
"text": text_val,
|
1983
2010
|
"x0": page_x0,
|
1984
2011
|
"top": page_top,
|
1985
2012
|
"x1": page_x1,
|
@@ -1988,7 +2015,7 @@ class Region(DirectionalMixin, ClassificationMixin, ExtractionMixin, ShapeDetect
|
|
1988
2015
|
"height": page_bottom - page_top,
|
1989
2016
|
"object_type": "word",
|
1990
2017
|
"source": "ocr",
|
1991
|
-
"confidence":
|
2018
|
+
"confidence": confidence_val,
|
1992
2019
|
"fontname": "OCR",
|
1993
2020
|
"size": round(pdf_height) if pdf_height > 0 else 10.0,
|
1994
2021
|
"page_number": self.page.number,
|
@@ -2324,12 +2351,12 @@ class Region(DirectionalMixin, ClassificationMixin, ExtractionMixin, ShapeDetect
|
|
2324
2351
|
|
2325
2352
|
def ask(
|
2326
2353
|
self,
|
2327
|
-
question: str,
|
2354
|
+
question: Union[str, List[str], Tuple[str, ...]],
|
2328
2355
|
min_confidence: float = 0.1,
|
2329
2356
|
model: str = None,
|
2330
2357
|
debug: bool = False,
|
2331
2358
|
**kwargs,
|
2332
|
-
) -> Dict[str, Any]:
|
2359
|
+
) -> Union[Dict[str, Any], List[Dict[str, Any]]]:
|
2333
2360
|
"""
|
2334
2361
|
Ask a question about the region content using document QA.
|
2335
2362
|
|
@@ -2870,4 +2897,98 @@ class Region(DirectionalMixin, ClassificationMixin, ExtractionMixin, ShapeDetect
|
|
2870
2897
|
self.metadata = {}
|
2871
2898
|
self.metadata["analysis"] = value
|
2872
2899
|
|
2900
|
+
# ------------------------------------------------------------------
|
2901
|
+
# New helper: build table from pre-computed table_cell regions
|
2902
|
+
# ------------------------------------------------------------------
|
2903
|
+
|
2904
|
+
def _extract_table_from_cells(self, cell_regions: List["Region"]) -> List[List[Optional[str]]]:
|
2905
|
+
"""Construct a table (list-of-lists) from table_cell regions.
|
2906
|
+
|
2907
|
+
This assumes each cell Region has metadata.row_index / col_index as written by
|
2908
|
+
detect_table_structure_from_lines(). If these keys are missing we will
|
2909
|
+
fall back to sorting by geometry.
|
2910
|
+
"""
|
2911
|
+
if not cell_regions:
|
2912
|
+
return []
|
2913
|
+
|
2914
|
+
# Attempt to use explicit indices first
|
2915
|
+
all_row_idxs = []
|
2916
|
+
all_col_idxs = []
|
2917
|
+
for cell in cell_regions:
|
2918
|
+
try:
|
2919
|
+
r_idx = int(cell.metadata.get("row_index"))
|
2920
|
+
c_idx = int(cell.metadata.get("col_index"))
|
2921
|
+
all_row_idxs.append(r_idx)
|
2922
|
+
all_col_idxs.append(c_idx)
|
2923
|
+
except Exception:
|
2924
|
+
# Not all cells have indices – clear the lists so we switch to geometric sorting
|
2925
|
+
all_row_idxs = []
|
2926
|
+
all_col_idxs = []
|
2927
|
+
break
|
2928
|
+
|
2929
|
+
if all_row_idxs and all_col_idxs:
|
2930
|
+
num_rows = max(all_row_idxs) + 1
|
2931
|
+
num_cols = max(all_col_idxs) + 1
|
2932
|
+
|
2933
|
+
# Initialise blank grid
|
2934
|
+
table_grid: List[List[Optional[str]]] = [[None] * num_cols for _ in range(num_rows)]
|
2935
|
+
|
2936
|
+
for cell in cell_regions:
|
2937
|
+
try:
|
2938
|
+
r_idx = int(cell.metadata.get("row_index"))
|
2939
|
+
c_idx = int(cell.metadata.get("col_index"))
|
2940
|
+
text_val = cell.extract_text(layout=False, apply_exclusions=False).strip()
|
2941
|
+
table_grid[r_idx][c_idx] = text_val if text_val else None
|
2942
|
+
except Exception as _err:
|
2943
|
+
# Skip problematic cell
|
2944
|
+
continue
|
2945
|
+
|
2946
|
+
return table_grid
|
2947
|
+
|
2948
|
+
# ------------------------------------------------------------------
|
2949
|
+
# Fallback: derive order purely from geometry if indices are absent
|
2950
|
+
# ------------------------------------------------------------------
|
2951
|
+
# Sort unique centers to define ordering
|
2952
|
+
try:
|
2953
|
+
import numpy as np
|
2954
|
+
except ImportError:
|
2955
|
+
logger.warning("NumPy required for geometric cell ordering; returning empty result.")
|
2956
|
+
return []
|
2957
|
+
|
2958
|
+
# Build arrays of centers
|
2959
|
+
centers = np.array([
|
2960
|
+
[(c.x0 + c.x1) / 2.0, (c.top + c.bottom) / 2.0] for c in cell_regions
|
2961
|
+
])
|
2962
|
+
xs = centers[:, 0]
|
2963
|
+
ys = centers[:, 1]
|
2964
|
+
|
2965
|
+
# Cluster unique row Y positions and column X positions with a tolerance
|
2966
|
+
def _cluster(vals, tol=1.0):
|
2967
|
+
sorted_vals = np.sort(vals)
|
2968
|
+
groups = [[sorted_vals[0]]]
|
2969
|
+
for v in sorted_vals[1:]:
|
2970
|
+
if abs(v - groups[-1][-1]) <= tol:
|
2971
|
+
groups[-1].append(v)
|
2972
|
+
else:
|
2973
|
+
groups.append([v])
|
2974
|
+
return [np.mean(g) for g in groups]
|
2975
|
+
|
2976
|
+
row_centers = _cluster(ys)
|
2977
|
+
col_centers = _cluster(xs)
|
2978
|
+
|
2979
|
+
num_rows = len(row_centers)
|
2980
|
+
num_cols = len(col_centers)
|
2981
|
+
|
2982
|
+
table_grid: List[List[Optional[str]]] = [[None] * num_cols for _ in range(num_rows)]
|
2983
|
+
|
2984
|
+
# Assign each cell to nearest row & col center
|
2985
|
+
for cell, (cx, cy) in zip(cell_regions, centers):
|
2986
|
+
row_idx = int(np.argmin([abs(cy - rc) for rc in row_centers]))
|
2987
|
+
col_idx = int(np.argmin([abs(cx - cc) for cc in col_centers]))
|
2988
|
+
|
2989
|
+
text_val = cell.extract_text(layout=False, apply_exclusions=False).strip()
|
2990
|
+
table_grid[row_idx][col_idx] = text_val if text_val else None
|
2991
|
+
|
2992
|
+
return table_grid
|
2993
|
+
|
2873
2994
|
|
natural_pdf/elements/text.py
CHANGED
@@ -151,20 +151,28 @@ class TextElement(Element):
|
|
151
151
|
# Default to black
|
152
152
|
return (0, 0, 0)
|
153
153
|
|
154
|
-
def extract_text(self, keep_blank_chars=True, **kwargs) -> str:
|
154
|
+
def extract_text(self, keep_blank_chars=True, strip: Optional[bool] = True, **kwargs) -> str:
|
155
155
|
"""
|
156
156
|
Extract text from this element.
|
157
157
|
|
158
158
|
Args:
|
159
|
-
keep_blank_chars:
|
160
|
-
|
159
|
+
keep_blank_chars: Retained for API compatibility (unused).
|
160
|
+
strip: If True (default) remove leading/trailing whitespace. Users may
|
161
|
+
pass ``strip=False`` to preserve whitespace exactly as stored.
|
162
|
+
**kwargs: Accepted for forward-compatibility and ignored here.
|
161
163
|
|
162
164
|
Returns:
|
163
|
-
|
165
|
+
The text content, optionally stripped.
|
164
166
|
"""
|
165
|
-
#
|
166
|
-
|
167
|
-
|
167
|
+
# Basic retrieval
|
168
|
+
result = self.text or ""
|
169
|
+
|
170
|
+
# Apply optional stripping – align with global convention where simple
|
171
|
+
# element extraction is stripped by default.
|
172
|
+
if strip:
|
173
|
+
result = result.strip()
|
174
|
+
|
175
|
+
return result
|
168
176
|
|
169
177
|
def contains(self, substring: str, case_sensitive: bool = True) -> bool:
|
170
178
|
"""
|
natural_pdf/flows/region.py
CHANGED
@@ -1,5 +1,5 @@
|
|
1
1
|
import logging
|
2
|
-
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union
|
2
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union, Callable
|
3
3
|
|
4
4
|
from pdfplumber.utils.geometry import objects_to_bbox # For calculating combined bbox
|
5
5
|
|
@@ -519,3 +519,118 @@ class FlowRegion:
|
|
519
519
|
)
|
520
520
|
except Exception:
|
521
521
|
return True # If error during check, assume empty to be safe
|
522
|
+
|
523
|
+
# ------------------------------------------------------------------
|
524
|
+
# Table extraction helpers (delegates to underlying physical regions)
|
525
|
+
# ------------------------------------------------------------------
|
526
|
+
|
527
|
+
def extract_table(
|
528
|
+
self,
|
529
|
+
method: Optional[str] = None,
|
530
|
+
table_settings: Optional[dict] = None,
|
531
|
+
use_ocr: bool = False,
|
532
|
+
ocr_config: Optional[dict] = None,
|
533
|
+
text_options: Optional[Dict] = None,
|
534
|
+
cell_extraction_func: Optional[Callable[["PhysicalRegion"], Optional[str]]] = None,
|
535
|
+
show_progress: bool = False,
|
536
|
+
**kwargs,
|
537
|
+
) -> List[List[Optional[str]]]:
|
538
|
+
"""Extracts a single logical table from the FlowRegion.
|
539
|
+
|
540
|
+
This is a convenience wrapper that iterates through the constituent
|
541
|
+
physical regions **in flow order**, calls their ``extract_table``
|
542
|
+
method, and concatenates the resulting rows. It mirrors the public
|
543
|
+
interface of :pymeth:`natural_pdf.elements.region.Region.extract_table`.
|
544
|
+
|
545
|
+
Args:
|
546
|
+
method, table_settings, use_ocr, ocr_config, text_options, cell_extraction_func, show_progress:
|
547
|
+
Same as in :pymeth:`Region.extract_table` and are forwarded as-is
|
548
|
+
to each physical region.
|
549
|
+
**kwargs: Additional keyword arguments forwarded to the underlying
|
550
|
+
``Region.extract_table`` implementation.
|
551
|
+
|
552
|
+
Returns:
|
553
|
+
A list of rows (``List[List[Optional[str]]]``). Rows returned from
|
554
|
+
consecutive constituent regions are appended in document order. If
|
555
|
+
no tables are detected in any region, an empty list is returned.
|
556
|
+
"""
|
557
|
+
|
558
|
+
if table_settings is None:
|
559
|
+
table_settings = {}
|
560
|
+
if text_options is None:
|
561
|
+
text_options = {}
|
562
|
+
|
563
|
+
if not self.constituent_regions:
|
564
|
+
return []
|
565
|
+
|
566
|
+
aggregated_rows: List[List[Optional[str]]] = []
|
567
|
+
|
568
|
+
for region in self.constituent_regions:
|
569
|
+
try:
|
570
|
+
region_rows = region.extract_table(
|
571
|
+
method=method,
|
572
|
+
table_settings=table_settings.copy(), # Avoid side-effects
|
573
|
+
use_ocr=use_ocr,
|
574
|
+
ocr_config=ocr_config,
|
575
|
+
text_options=text_options.copy(),
|
576
|
+
cell_extraction_func=cell_extraction_func,
|
577
|
+
show_progress=show_progress,
|
578
|
+
**kwargs,
|
579
|
+
)
|
580
|
+
|
581
|
+
# ``region_rows`` can legitimately be [] if no table found.
|
582
|
+
if region_rows:
|
583
|
+
aggregated_rows.extend(region_rows)
|
584
|
+
except Exception as e:
|
585
|
+
logger.error(
|
586
|
+
f"FlowRegion.extract_table: Error extracting table from constituent region {region}: {e}",
|
587
|
+
exc_info=True,
|
588
|
+
)
|
589
|
+
|
590
|
+
return aggregated_rows
|
591
|
+
|
592
|
+
def extract_tables(
|
593
|
+
self,
|
594
|
+
method: Optional[str] = None,
|
595
|
+
table_settings: Optional[dict] = None,
|
596
|
+
**kwargs,
|
597
|
+
) -> List[List[List[Optional[str]]]]:
|
598
|
+
"""Extract **all** tables from the FlowRegion.
|
599
|
+
|
600
|
+
This simply chains :pymeth:`Region.extract_tables` over each physical
|
601
|
+
region and concatenates their results, preserving flow order.
|
602
|
+
|
603
|
+
Args:
|
604
|
+
method, table_settings: Forwarded to underlying ``Region.extract_tables``.
|
605
|
+
**kwargs: Additional keyword arguments forwarded.
|
606
|
+
|
607
|
+
Returns:
|
608
|
+
A list where each item is a full table (list of rows). The order of
|
609
|
+
tables follows the order of the constituent regions in the flow.
|
610
|
+
"""
|
611
|
+
|
612
|
+
if table_settings is None:
|
613
|
+
table_settings = {}
|
614
|
+
|
615
|
+
if not self.constituent_regions:
|
616
|
+
return []
|
617
|
+
|
618
|
+
all_tables: List[List[List[Optional[str]]]] = []
|
619
|
+
|
620
|
+
for region in self.constituent_regions:
|
621
|
+
try:
|
622
|
+
region_tables = region.extract_tables(
|
623
|
+
method=method,
|
624
|
+
table_settings=table_settings.copy(),
|
625
|
+
**kwargs,
|
626
|
+
)
|
627
|
+
# ``region_tables`` is a list (possibly empty).
|
628
|
+
if region_tables:
|
629
|
+
all_tables.extend(region_tables)
|
630
|
+
except Exception as e:
|
631
|
+
logger.error(
|
632
|
+
f"FlowRegion.extract_tables: Error extracting tables from constituent region {region}: {e}",
|
633
|
+
exc_info=True,
|
634
|
+
)
|
635
|
+
|
636
|
+
return all_tables
|