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.
@@ -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}: Found {len(ocr_elements)} existing OCR elements to remove."
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(f"Region {self.bbox}: No existing OCR elements found to remove.")
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": result["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": float(result.get("confidence", 0.0)),
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
 
@@ -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: Whether to keep blank characters (default: True)
160
- **kwargs: Additional extraction parameters
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
- Text content
165
+ The text content, optionally stripped.
164
166
  """
165
- # For text elements, keep_blank_chars doesn't affect anything as we're
166
- # simply returning the text property. Included for API consistency.
167
- return self.text
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
  """
@@ -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