natural-pdf 0.1.12__py3-none-any.whl → 0.1.13__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.
@@ -18,6 +18,7 @@ from typing import (
18
18
  Union,
19
19
  overload,
20
20
  )
21
+ import hashlib
21
22
 
22
23
  from pdfplumber.utils.geometry import objects_to_bbox
23
24
 
@@ -37,6 +38,8 @@ from natural_pdf.export.mixin import ExportMixin
37
38
  from natural_pdf.ocr import OCROptions
38
39
  from natural_pdf.ocr.utils import _apply_ocr_correction_to_elements
39
40
  from natural_pdf.selectors.parser import parse_selector, selector_to_filter_func
41
+ from natural_pdf.analyzers.shape_detection_mixin import ShapeDetectionMixin
42
+ from tqdm.auto import tqdm
40
43
 
41
44
  # Potentially lazy imports for optional dependencies needed in save_pdf
42
45
  try:
@@ -46,8 +49,6 @@ except ImportError:
46
49
 
47
50
  try:
48
51
  from natural_pdf.exporters.searchable_pdf import create_searchable_pdf
49
-
50
- pass
51
52
  except ImportError:
52
53
  create_searchable_pdf = None
53
54
 
@@ -64,6 +65,7 @@ if TYPE_CHECKING:
64
65
  from natural_pdf.core.page import Page
65
66
  from natural_pdf.core.pdf import PDF # ---> ADDED PDF type hint
66
67
  from natural_pdf.elements.region import Region
68
+ from natural_pdf.elements.text import TextElement # Ensure TextElement is imported
67
69
 
68
70
  T = TypeVar("T")
69
71
  P = TypeVar("P", bound="Page")
@@ -1586,13 +1588,162 @@ class ElementCollection(
1586
1588
 
1587
1589
  return all_data
1588
1590
 
1591
+ def to_text_elements(
1592
+ self,
1593
+ text_content_func: Optional[Callable[["Region"], Optional[str]]] = None,
1594
+ source_label: str = "derived_from_region",
1595
+ object_type: str = "word",
1596
+ default_font_size: float = 10.0,
1597
+ default_font_name: str = "RegionContent",
1598
+ confidence: Optional[float] = None,
1599
+ add_to_page: bool = False # Default is False
1600
+ ) -> "ElementCollection[TextElement]":
1601
+ """
1602
+ Converts each Region in this collection to a TextElement.
1589
1603
 
1590
- class PageCollection(Generic[P], ApplyMixin):
1591
- """
1592
- A collection of PDF pages with cross-page operations.
1604
+ Args:
1605
+ text_content_func: A callable that takes a Region and returns its text
1606
+ (or None). If None, all created TextElements will
1607
+ have text=None.
1608
+ source_label: The 'source' attribute for the new TextElements.
1609
+ object_type: The 'object_type' for the TextElement's data dict.
1610
+ default_font_size: Placeholder font size.
1611
+ default_font_name: Placeholder font name.
1612
+ confidence: Confidence score.
1613
+ add_to_page: If True (default is False), also adds the created
1614
+ TextElements to their respective page's element manager.
1615
+
1616
+ Returns:
1617
+ A new ElementCollection containing the created TextElement objects.
1618
+ """
1619
+ from natural_pdf.elements.region import Region # Local import for type checking if needed or to resolve circularity
1620
+ from natural_pdf.elements.text import TextElement # Ensure TextElement is imported for type hint if not in TYPE_CHECKING
1593
1621
 
1594
- This class provides methods for working with multiple pages, such as finding
1595
- elements across pages, extracting text from page ranges, and more.
1622
+ new_text_elements: List["TextElement"] = []
1623
+ if not self.elements: # Accesses self._elements via property
1624
+ return ElementCollection([])
1625
+
1626
+ page_context_for_adding: Optional["Page"] = None
1627
+ if add_to_page:
1628
+ # Try to determine a consistent page context if adding elements
1629
+ first_valid_region_with_page = next(
1630
+ (el for el in self.elements if isinstance(el, Region) and hasattr(el, 'page') and el.page is not None),
1631
+ None
1632
+ )
1633
+ if first_valid_region_with_page:
1634
+ page_context_for_adding = first_valid_region_with_page.page
1635
+ else:
1636
+ logger.warning("Cannot add TextElements to page: No valid Region with a page attribute found in collection, or first region's page is None.")
1637
+ add_to_page = False # Disable adding if no valid page context can be determined
1638
+
1639
+ for element in self.elements: # Accesses self._elements via property/iterator
1640
+ if isinstance(element, Region):
1641
+ text_el = element.to_text_element(
1642
+ text_content=text_content_func,
1643
+ source_label=source_label,
1644
+ object_type=object_type,
1645
+ default_font_size=default_font_size,
1646
+ default_font_name=default_font_name,
1647
+ confidence=confidence
1648
+ )
1649
+ new_text_elements.append(text_el)
1650
+
1651
+ if add_to_page:
1652
+ if not hasattr(text_el, 'page') or text_el.page is None:
1653
+ logger.warning(f"TextElement created from region {element.bbox} has no page attribute. Cannot add to page.")
1654
+ continue
1655
+
1656
+ if page_context_for_adding and text_el.page == page_context_for_adding:
1657
+ if hasattr(page_context_for_adding, '_element_mgr') and page_context_for_adding._element_mgr is not None:
1658
+ add_as_type = "words" if object_type == "word" else "chars" if object_type == "char" else object_type
1659
+ page_context_for_adding._element_mgr.add_element(text_el, element_type=add_as_type)
1660
+ else:
1661
+ page_num_str = str(page_context_for_adding.page_number) if hasattr(page_context_for_adding, 'page_number') else 'N/A'
1662
+ logger.error(f"Page context for region {element.bbox} (Page {page_num_str}) is missing '_element_mgr'. Cannot add TextElement.")
1663
+ elif page_context_for_adding and text_el.page != page_context_for_adding:
1664
+ current_page_num_str = str(text_el.page.page_number) if hasattr(text_el.page, 'page_number') else "Unknown"
1665
+ context_page_num_str = str(page_context_for_adding.page_number) if hasattr(page_context_for_adding, 'page_number') else "N/A"
1666
+ logger.warning(f"TextElement for region {element.bbox} from page {current_page_num_str} "
1667
+ f"not added as it's different from collection's inferred page context {context_page_num_str}.")
1668
+ elif not page_context_for_adding:
1669
+ logger.warning(f"TextElement for region {element.bbox} created, but no page context was determined for adding.")
1670
+ else:
1671
+ logger.warning(f"Skipping element {type(element)}, not a Region.")
1672
+
1673
+ if add_to_page and page_context_for_adding:
1674
+ page_num_str = str(page_context_for_adding.page_number) if hasattr(page_context_for_adding, 'page_number') else 'N/A'
1675
+ logger.info(f"Created and added {len(new_text_elements)} TextElements to page {page_num_str}.")
1676
+ elif add_to_page and not page_context_for_adding:
1677
+ logger.info(f"Created {len(new_text_elements)} TextElements, but could not add to page as page context was not determined or was inconsistent.")
1678
+ else: # add_to_page is False
1679
+ logger.info(f"Created {len(new_text_elements)} TextElements (not added to page).")
1680
+
1681
+ return ElementCollection(new_text_elements)
1682
+
1683
+ def trim(self, padding: int = 1, threshold: float = 0.95, resolution: float = 150, show_progress: bool = True) -> "ElementCollection":
1684
+ """
1685
+ Trim visual whitespace from each region in the collection.
1686
+
1687
+ Applies the trim() method to each element in the collection,
1688
+ returning a new collection with the trimmed regions.
1689
+
1690
+ Args:
1691
+ padding: Number of pixels to keep as padding after trimming (default: 1)
1692
+ threshold: Threshold for considering a row/column as whitespace (0.0-1.0, default: 0.95)
1693
+ resolution: Resolution for image rendering in DPI (default: 150)
1694
+ show_progress: Whether to show a progress bar for the trimming operation
1695
+
1696
+ Returns:
1697
+ New ElementCollection with trimmed regions
1698
+ """
1699
+ return self.apply(
1700
+ lambda element: element.trim(padding=padding, threshold=threshold, resolution=resolution),
1701
+ show_progress=show_progress
1702
+ )
1703
+
1704
+ def clip(
1705
+ self,
1706
+ obj: Optional[Any] = None,
1707
+ left: Optional[float] = None,
1708
+ top: Optional[float] = None,
1709
+ right: Optional[float] = None,
1710
+ bottom: Optional[float] = None,
1711
+ ) -> "ElementCollection":
1712
+ """
1713
+ Clip each element in the collection to the specified bounds.
1714
+
1715
+ This method applies the clip operation to each individual element,
1716
+ returning a new collection with the clipped elements.
1717
+
1718
+ Args:
1719
+ obj: Optional object with bbox properties (Region, Element, TextElement, etc.)
1720
+ left: Optional left boundary (x0) to clip to
1721
+ top: Optional top boundary to clip to
1722
+ right: Optional right boundary (x1) to clip to
1723
+ bottom: Optional bottom boundary to clip to
1724
+
1725
+ Returns:
1726
+ New ElementCollection containing the clipped elements
1727
+
1728
+ Examples:
1729
+ # Clip each element to another region's bounds
1730
+ clipped_elements = collection.clip(container_region)
1731
+
1732
+ # Clip each element to specific coordinates
1733
+ clipped_elements = collection.clip(left=100, right=400)
1734
+
1735
+ # Mix object bounds with specific overrides
1736
+ clipped_elements = collection.clip(obj=container, bottom=page.height/2)
1737
+ """
1738
+ return self.apply(
1739
+ lambda element: element.clip(obj=obj, left=left, top=top, right=right, bottom=bottom)
1740
+ )
1741
+
1742
+
1743
+ class PageCollection(Generic[P], ApplyMixin, ShapeDetectionMixin):
1744
+ """
1745
+ Represents a collection of Page objects, often from a single PDF document.
1746
+ Provides methods for batch operations on these pages.
1596
1747
  """
1597
1748
 
1598
1749
  def __init__(self, pages: List[P]):
@@ -1921,7 +2072,7 @@ class PageCollection(Generic[P], ApplyMixin):
1921
2072
  end_elements=None,
1922
2073
  new_section_on_page_break=False,
1923
2074
  boundary_inclusion="both",
1924
- ) -> List["Region"]:
2075
+ ) -> "ElementCollection[Region]":
1925
2076
  """
1926
2077
  Extract sections from a page collection based on start/end elements.
1927
2078
 
@@ -2214,7 +2365,7 @@ class PageCollection(Generic[P], ApplyMixin):
2214
2365
  region.start_element = start_element
2215
2366
  sections.append(region)
2216
2367
 
2217
- return sections
2368
+ return ElementCollection(sections)
2218
2369
 
2219
2370
  def _gather_analysis_data(
2220
2371
  self,
@@ -28,6 +28,11 @@ class LineElement(Element):
28
28
  """
29
29
  super().__init__(obj, page)
30
30
 
31
+ @property
32
+ def source(self) -> Optional[str]:
33
+ """Get the source of this line element (e.g., 'pdf', 'detected')."""
34
+ return self._obj.get("source")
35
+
31
36
  @property
32
37
  def type(self) -> str:
33
38
  """Element type."""
@@ -13,6 +13,7 @@ from natural_pdf.classification.manager import ClassificationManager # Keep for
13
13
  from natural_pdf.classification.mixin import ClassificationMixin
14
14
  from natural_pdf.elements.base import DirectionalMixin
15
15
  from natural_pdf.extraction.mixin import ExtractionMixin # Import extraction mixin
16
+ from natural_pdf.elements.text import TextElement # ADDED IMPORT
16
17
  from natural_pdf.ocr.utils import _apply_ocr_correction_to_elements # Import utility
17
18
  from natural_pdf.selectors.parser import parse_selector, selector_to_filter_func
18
19
  from natural_pdf.utils.locks import pdf_render_lock # Import the lock
@@ -20,11 +21,12 @@ from natural_pdf.utils.locks import pdf_render_lock # Import the lock
20
21
  # Import new utils
21
22
  from natural_pdf.utils.text_extraction import filter_chars_spatially, generate_text_layout
22
23
 
23
- # --- NEW: Import tqdm utility --- #
24
- from natural_pdf.utils.tqdm_utils import get_tqdm
25
-
24
+ from tqdm.auto import tqdm
26
25
  # --- End Classification Imports --- #
27
26
 
27
+ # --- Shape Detection Mixin --- #
28
+ from natural_pdf.analyzers.shape_detection_mixin import ShapeDetectionMixin
29
+ # --- End Shape Detection Mixin --- #
28
30
 
29
31
  if TYPE_CHECKING:
30
32
  # --- NEW: Add Image type hint for classification --- #
@@ -33,6 +35,7 @@ if TYPE_CHECKING:
33
35
  from natural_pdf.core.page import Page
34
36
  from natural_pdf.elements.collections import ElementCollection
35
37
  from natural_pdf.elements.text import TextElement
38
+ from natural_pdf.elements.base import Element # Added for type hint
36
39
 
37
40
  # Import OCRManager conditionally to avoid circular imports
38
41
  try:
@@ -44,7 +47,7 @@ except ImportError:
44
47
  logger = logging.getLogger(__name__)
45
48
 
46
49
 
47
- class Region(DirectionalMixin, ClassificationMixin, ExtractionMixin):
50
+ class Region(DirectionalMixin, ClassificationMixin, ExtractionMixin, ShapeDetectionMixin):
48
51
  """
49
52
  Represents a rectangular region on a page.
50
53
  """
@@ -720,14 +723,36 @@ class Region(DirectionalMixin, ClassificationMixin, ExtractionMixin):
720
723
  Returns:
721
724
  PIL Image of just this region
722
725
  """
726
+ # Handle the case where user wants the cropped region to have a specific width
727
+ page_kwargs = kwargs.copy()
728
+ effective_resolution = resolution # Start with the provided resolution
729
+
730
+ if crop_only and 'width' in kwargs:
731
+ target_width = kwargs['width']
732
+ # Calculate what resolution is needed to make the region crop have target_width
733
+ region_width_points = self.width # Region width in PDF points
734
+
735
+ if region_width_points > 0:
736
+ # Calculate scale needed: target_width / region_width_points
737
+ required_scale = target_width / region_width_points
738
+ # Convert scale to resolution: scale * 72 DPI
739
+ effective_resolution = required_scale * 72.0
740
+ page_kwargs.pop('width') # Remove width parameter to avoid conflicts
741
+ logger.debug(f"Region {self.bbox}: Calculated required resolution {effective_resolution:.1f} DPI for region crop width {target_width}")
742
+ else:
743
+ logger.warning(f"Region {self.bbox}: Invalid region width {region_width_points}, using original resolution")
744
+
723
745
  # First get the full page image with highlights if requested
724
746
  page_image = self._page.to_image(
725
- scale=scale, resolution=resolution, include_highlights=include_highlights, **kwargs
747
+ scale=scale, resolution=effective_resolution, include_highlights=include_highlights, **page_kwargs
726
748
  )
727
749
 
728
- # Calculate the crop coordinates - apply resolution scaling factor
729
- # PDF coordinates are in points (1/72 inch), but image is scaled by resolution
730
- scale_factor = resolution / 72.0 # Scale based on DPI
750
+ # Calculate the actual scale factor used by the page image
751
+ if page_image.width > 0 and self._page.width > 0:
752
+ scale_factor = page_image.width / self._page.width
753
+ else:
754
+ # Fallback to resolution-based calculation if dimensions are invalid
755
+ scale_factor = resolution / 72.0
731
756
 
732
757
  # Apply scaling to the coordinates
733
758
  x0 = int(self.x0 * scale_factor)
@@ -874,6 +899,233 @@ class Region(DirectionalMixin, ClassificationMixin, ExtractionMixin):
874
899
  image.save(filename)
875
900
  return self
876
901
 
902
+ def trim(self, padding: int = 1, threshold: float = 0.95, resolution: float = 150, pre_shrink: float = 0.5) -> "Region":
903
+ """
904
+ Trim visual whitespace from the edges of this region.
905
+
906
+ Similar to Python's string .strip() method, but for visual whitespace in the region image.
907
+ Uses pixel analysis to detect rows/columns that are predominantly whitespace.
908
+
909
+ Args:
910
+ padding: Number of pixels to keep as padding after trimming (default: 1)
911
+ threshold: Threshold for considering a row/column as whitespace (0.0-1.0, default: 0.95)
912
+ Higher values mean more strict whitespace detection.
913
+ E.g., 0.95 means if 95% of pixels in a row/column are white, consider it whitespace.
914
+ resolution: Resolution for image rendering in DPI (default: 150)
915
+ pre_shrink: Amount to shrink region before trimming, then expand back after (default: 0.5)
916
+ This helps avoid detecting box borders/slivers as content.
917
+
918
+ Returns:
919
+ New Region with visual whitespace trimmed from all edges
920
+
921
+ Example:
922
+ # Basic trimming with 1 pixel padding and 0.5px pre-shrink
923
+ trimmed = region.trim()
924
+
925
+ # More aggressive trimming with no padding and no pre-shrink
926
+ tight = region.trim(padding=0, threshold=0.9, pre_shrink=0)
927
+
928
+ # Conservative trimming with more padding
929
+ loose = region.trim(padding=3, threshold=0.98)
930
+ """
931
+ # Pre-shrink the region to avoid box slivers
932
+ work_region = self.expand(left=-pre_shrink, right=-pre_shrink, top=-pre_shrink, bottom=-pre_shrink) if pre_shrink > 0 else self
933
+
934
+ # Get the region image
935
+ image = work_region.to_image(resolution=resolution, crop_only=True, include_highlights=False)
936
+
937
+ if image is None:
938
+ logger.warning(f"Region {self.bbox}: Could not generate image for trimming. Returning original region.")
939
+ return self
940
+
941
+ # Convert to grayscale for easier analysis
942
+ import numpy as np
943
+
944
+ # Convert PIL image to numpy array
945
+ img_array = np.array(image.convert('L')) # Convert to grayscale
946
+ height, width = img_array.shape
947
+
948
+ if height == 0 or width == 0:
949
+ logger.warning(f"Region {self.bbox}: Image has zero dimensions. Returning original region.")
950
+ return self
951
+
952
+ # Normalize pixel values to 0-1 range (255 = white = 1.0, 0 = black = 0.0)
953
+ normalized = img_array.astype(np.float32) / 255.0
954
+
955
+ # Find content boundaries by analyzing row and column averages
956
+
957
+ # Analyze rows (horizontal strips) to find top and bottom boundaries
958
+ row_averages = np.mean(normalized, axis=1) # Average each row
959
+ content_rows = row_averages < threshold # True where there's content (not whitespace)
960
+
961
+ # Find first and last rows with content
962
+ content_row_indices = np.where(content_rows)[0]
963
+ if len(content_row_indices) == 0:
964
+ # No content found, return a minimal region at the center
965
+ logger.warning(f"Region {self.bbox}: No content detected during trimming. Returning center point.")
966
+ center_x = (self.x0 + self.x1) / 2
967
+ center_y = (self.top + self.bottom) / 2
968
+ return Region(self.page, (center_x, center_y, center_x, center_y))
969
+
970
+ top_content_row = max(0, content_row_indices[0] - padding)
971
+ bottom_content_row = min(height - 1, content_row_indices[-1] + padding)
972
+
973
+ # Analyze columns (vertical strips) to find left and right boundaries
974
+ col_averages = np.mean(normalized, axis=0) # Average each column
975
+ content_cols = col_averages < threshold # True where there's content
976
+
977
+ content_col_indices = np.where(content_cols)[0]
978
+ if len(content_col_indices) == 0:
979
+ # No content found in columns either
980
+ logger.warning(f"Region {self.bbox}: No column content detected during trimming. Returning center point.")
981
+ center_x = (self.x0 + self.x1) / 2
982
+ center_y = (self.top + self.bottom) / 2
983
+ return Region(self.page, (center_x, center_y, center_x, center_y))
984
+
985
+ left_content_col = max(0, content_col_indices[0] - padding)
986
+ right_content_col = min(width - 1, content_col_indices[-1] + padding)
987
+
988
+ # Convert trimmed pixel coordinates back to PDF coordinates
989
+ scale_factor = resolution / 72.0 # Scale factor used in to_image()
990
+
991
+ # Calculate new PDF coordinates and ensure they are Python floats
992
+ trimmed_x0 = float(work_region.x0 + (left_content_col / scale_factor))
993
+ trimmed_top = float(work_region.top + (top_content_row / scale_factor))
994
+ trimmed_x1 = float(work_region.x0 + ((right_content_col + 1) / scale_factor)) # +1 because we want inclusive right edge
995
+ trimmed_bottom = float(work_region.top + ((bottom_content_row + 1) / scale_factor)) # +1 because we want inclusive bottom edge
996
+
997
+ # Ensure the trimmed region doesn't exceed the work region boundaries
998
+ final_x0 = max(work_region.x0, trimmed_x0)
999
+ final_top = max(work_region.top, trimmed_top)
1000
+ final_x1 = min(work_region.x1, trimmed_x1)
1001
+ final_bottom = min(work_region.bottom, trimmed_bottom)
1002
+
1003
+ # Ensure valid coordinates (width > 0, height > 0)
1004
+ if final_x1 <= final_x0 or final_bottom <= final_top:
1005
+ logger.warning(f"Region {self.bbox}: Trimming resulted in invalid dimensions. Returning original region.")
1006
+ return self
1007
+
1008
+ # Create the trimmed region
1009
+ trimmed_region = Region(self.page, (final_x0, final_top, final_x1, final_bottom))
1010
+
1011
+ # Expand back by the pre_shrink amount to restore original positioning
1012
+ if pre_shrink > 0:
1013
+ trimmed_region = trimmed_region.expand(left=pre_shrink, right=pre_shrink, top=pre_shrink, bottom=pre_shrink)
1014
+
1015
+ # Copy relevant metadata
1016
+ trimmed_region.region_type = self.region_type
1017
+ trimmed_region.normalized_type = self.normalized_type
1018
+ trimmed_region.confidence = self.confidence
1019
+ trimmed_region.model = self.model
1020
+ trimmed_region.name = self.name
1021
+ trimmed_region.label = self.label
1022
+ trimmed_region.source = "trimmed" # Indicate this is a derived region
1023
+ trimmed_region.parent_region = self
1024
+
1025
+ logger.debug(f"Region {self.bbox}: Trimmed to {trimmed_region.bbox} (padding={padding}, threshold={threshold}, pre_shrink={pre_shrink})")
1026
+ return trimmed_region
1027
+
1028
+ def clip(
1029
+ self,
1030
+ obj: Optional[Any] = None,
1031
+ left: Optional[float] = None,
1032
+ top: Optional[float] = None,
1033
+ right: Optional[float] = None,
1034
+ bottom: Optional[float] = None,
1035
+ ) -> "Region":
1036
+ """
1037
+ Clip this region to specific bounds, either from another object with bbox or explicit coordinates.
1038
+
1039
+ The clipped region will be constrained to not exceed the specified boundaries.
1040
+ You can provide either an object with bounding box properties, specific coordinates, or both.
1041
+ When both are provided, explicit coordinates take precedence.
1042
+
1043
+ Args:
1044
+ obj: Optional object with bbox properties (Region, Element, TextElement, etc.)
1045
+ left: Optional left boundary (x0) to clip to
1046
+ top: Optional top boundary to clip to
1047
+ right: Optional right boundary (x1) to clip to
1048
+ bottom: Optional bottom boundary to clip to
1049
+
1050
+ Returns:
1051
+ New Region with bounds clipped to the specified constraints
1052
+
1053
+ Examples:
1054
+ # Clip to another region's bounds
1055
+ clipped = region.clip(container_region)
1056
+
1057
+ # Clip to any element's bounds
1058
+ clipped = region.clip(text_element)
1059
+
1060
+ # Clip to specific coordinates
1061
+ clipped = region.clip(left=100, right=400)
1062
+
1063
+ # Mix object bounds with specific overrides
1064
+ clipped = region.clip(obj=container, bottom=page.height/2)
1065
+ """
1066
+ from natural_pdf.elements.base import extract_bbox
1067
+
1068
+ # Start with current region bounds
1069
+ clip_x0 = self.x0
1070
+ clip_top = self.top
1071
+ clip_x1 = self.x1
1072
+ clip_bottom = self.bottom
1073
+
1074
+ # Apply object constraints if provided
1075
+ if obj is not None:
1076
+ obj_bbox = extract_bbox(obj)
1077
+ if obj_bbox is not None:
1078
+ obj_x0, obj_top, obj_x1, obj_bottom = obj_bbox
1079
+ # Constrain to the intersection with the provided object
1080
+ clip_x0 = max(clip_x0, obj_x0)
1081
+ clip_top = max(clip_top, obj_top)
1082
+ clip_x1 = min(clip_x1, obj_x1)
1083
+ clip_bottom = min(clip_bottom, obj_bottom)
1084
+ else:
1085
+ logger.warning(
1086
+ f"Region {self.bbox}: Cannot extract bbox from clipping object {type(obj)}. "
1087
+ "Object must have bbox property or x0/top/x1/bottom attributes."
1088
+ )
1089
+
1090
+ # Apply explicit coordinate constraints (these take precedence)
1091
+ if left is not None:
1092
+ clip_x0 = max(clip_x0, left)
1093
+ if top is not None:
1094
+ clip_top = max(clip_top, top)
1095
+ if right is not None:
1096
+ clip_x1 = min(clip_x1, right)
1097
+ if bottom is not None:
1098
+ clip_bottom = min(clip_bottom, bottom)
1099
+
1100
+ # Ensure valid coordinates
1101
+ if clip_x1 <= clip_x0 or clip_bottom <= clip_top:
1102
+ logger.warning(
1103
+ f"Region {self.bbox}: Clipping resulted in invalid dimensions "
1104
+ f"({clip_x0}, {clip_top}, {clip_x1}, {clip_bottom}). Returning minimal region."
1105
+ )
1106
+ # Return a minimal region at the clip area's top-left
1107
+ return Region(self.page, (clip_x0, clip_top, clip_x0, clip_top))
1108
+
1109
+ # Create the clipped region
1110
+ clipped_region = Region(self.page, (clip_x0, clip_top, clip_x1, clip_bottom))
1111
+
1112
+ # Copy relevant metadata
1113
+ clipped_region.region_type = self.region_type
1114
+ clipped_region.normalized_type = self.normalized_type
1115
+ clipped_region.confidence = self.confidence
1116
+ clipped_region.model = self.model
1117
+ clipped_region.name = self.name
1118
+ clipped_region.label = self.label
1119
+ clipped_region.source = "clipped" # Indicate this is a derived region
1120
+ clipped_region.parent_region = self
1121
+
1122
+ logger.debug(
1123
+ f"Region {self.bbox}: Clipped to {clipped_region.bbox} "
1124
+ f"(constraints: obj={type(obj).__name__ if obj else None}, "
1125
+ f"left={left}, top={top}, right={right}, bottom={bottom})"
1126
+ )
1127
+ return clipped_region
1128
+
877
1129
  def get_elements(
878
1130
  self, selector: Optional[str] = None, apply_exclusions=True, **kwargs
879
1131
  ) -> List["Element"]:
@@ -1261,8 +1513,6 @@ class Region(DirectionalMixin, ClassificationMixin, ExtractionMixin):
1261
1513
  unique_tops = cluster_coords(tops)
1262
1514
  unique_lefts = cluster_coords(lefts)
1263
1515
 
1264
- # --- Setup tqdm --- #
1265
- tqdm = get_tqdm()
1266
1516
  # Determine iterable for tqdm
1267
1517
  cell_iterator = cell_dicts
1268
1518
  if show_progress:
@@ -1777,7 +2027,7 @@ class Region(DirectionalMixin, ClassificationMixin, ExtractionMixin):
1777
2027
 
1778
2028
  def get_sections(
1779
2029
  self, start_elements=None, end_elements=None, boundary_inclusion="both"
1780
- ) -> List["Region"]:
2030
+ ) -> "ElementCollection[Region]":
1781
2031
  """
1782
2032
  Get sections within this region based on start/end elements.
1783
2033
 
@@ -1897,7 +2147,7 @@ class Region(DirectionalMixin, ClassificationMixin, ExtractionMixin):
1897
2147
  section = self.get_section_between(start_element, end_element, boundary_inclusion)
1898
2148
  sections.append(section)
1899
2149
 
1900
- return sections
2150
+ return ElementCollection(sections)
1901
2151
 
1902
2152
  def create_cells(self):
1903
2153
  """
@@ -2413,3 +2663,94 @@ class Region(DirectionalMixin, ClassificationMixin, ExtractionMixin):
2413
2663
  return ElementCollection(cell_regions)
2414
2664
 
2415
2665
  # --- END NEW METHOD ---
2666
+
2667
+ def to_text_element(
2668
+ self,
2669
+ text_content: Optional[Union[str, Callable[["Region"], Optional[str]]]] = None,
2670
+ source_label: str = "derived_from_region",
2671
+ object_type: str = "word", # Or "char", controls how it's categorized
2672
+ default_font_size: float = 10.0,
2673
+ default_font_name: str = "RegionContent",
2674
+ confidence: Optional[float] = None, # Allow overriding confidence
2675
+ add_to_page: bool = False # NEW: Option to add to page
2676
+ ) -> "TextElement":
2677
+ """
2678
+ Creates a new TextElement object based on this region's geometry.
2679
+
2680
+ The text for the new TextElement can be provided directly,
2681
+ generated by a callback function, or left as None.
2682
+
2683
+ Args:
2684
+ text_content:
2685
+ - If a string, this will be the text of the new TextElement.
2686
+ - If a callable, it will be called with this region instance
2687
+ and its return value (a string or None) will be the text.
2688
+ - If None (default), the TextElement's text will be None.
2689
+ source_label: The 'source' attribute for the new TextElement.
2690
+ object_type: The 'object_type' for the TextElement's data dict
2691
+ (e.g., "word", "char").
2692
+ default_font_size: Placeholder font size if text is generated.
2693
+ default_font_name: Placeholder font name if text is generated.
2694
+ confidence: Confidence score for the text. If text_content is None,
2695
+ defaults to 0.0. If text is provided/generated, defaults to 1.0
2696
+ unless specified.
2697
+ add_to_page: If True, the created TextElement will be added to the
2698
+ region's parent page. (Default: False)
2699
+
2700
+ Returns:
2701
+ A new TextElement instance.
2702
+
2703
+ Raises:
2704
+ ValueError: If the region does not have a valid 'page' attribute.
2705
+ """
2706
+ actual_text: Optional[str] = None
2707
+ if isinstance(text_content, str):
2708
+ actual_text = text_content
2709
+ elif callable(text_content):
2710
+ try:
2711
+ actual_text = text_content(self)
2712
+ except Exception as e:
2713
+ logger.error(f"Error executing text_content callback for region {self.bbox}: {e}", exc_info=True)
2714
+ actual_text = None # Ensure actual_text is None on error
2715
+
2716
+ final_confidence = confidence
2717
+ if final_confidence is None:
2718
+ final_confidence = 1.0 if actual_text is not None and actual_text.strip() else 0.0
2719
+
2720
+ if not hasattr(self, 'page') or self.page is None:
2721
+ raise ValueError("Region must have a valid 'page' attribute to create a TextElement.")
2722
+
2723
+ elem_data = {
2724
+ "text": actual_text,
2725
+ "x0": self.x0,
2726
+ "top": self.top,
2727
+ "x1": self.x1,
2728
+ "bottom": self.bottom,
2729
+ "width": self.width,
2730
+ "height": self.height,
2731
+ "object_type": object_type,
2732
+ "page_number": self.page.page_number,
2733
+ "stroking_color": getattr(self, 'stroking_color', (0,0,0)),
2734
+ "non_stroking_color": getattr(self, 'non_stroking_color', (0,0,0)),
2735
+ "fontname": default_font_name,
2736
+ "size": default_font_size,
2737
+ "upright": True,
2738
+ "direction": 1,
2739
+ "adv": self.width,
2740
+ "source": source_label,
2741
+ "confidence": final_confidence,
2742
+ "_char_dicts": []
2743
+ }
2744
+ text_element = TextElement(elem_data, self.page)
2745
+
2746
+ if add_to_page:
2747
+ if hasattr(self.page, '_element_mgr') and self.page._element_mgr is not None:
2748
+ add_as_type = "words" if object_type == "word" else "chars" if object_type == "char" else object_type
2749
+ # REMOVED try-except block around add_element
2750
+ self.page._element_mgr.add_element(text_element, element_type=add_as_type)
2751
+ logger.debug(f"TextElement created from region {self.bbox} and added to page {self.page.page_number} as {add_as_type}.")
2752
+ else:
2753
+ page_num_str = str(self.page.page_number) if hasattr(self.page, 'page_number') else 'N/A'
2754
+ logger.warning(f"Cannot add TextElement to page: Page {page_num_str} for region {self.bbox} is missing '_element_mgr'.")
2755
+
2756
+ return text_element