natural-pdf 0.1.15__py3-none-any.whl → 0.1.17__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.
Files changed (44) hide show
  1. natural_pdf/__init__.py +31 -0
  2. natural_pdf/analyzers/layout/gemini.py +137 -162
  3. natural_pdf/analyzers/layout/layout_manager.py +9 -5
  4. natural_pdf/analyzers/layout/layout_options.py +77 -7
  5. natural_pdf/analyzers/layout/paddle.py +318 -165
  6. natural_pdf/analyzers/layout/table_structure_utils.py +78 -0
  7. natural_pdf/analyzers/shape_detection_mixin.py +770 -405
  8. natural_pdf/classification/mixin.py +2 -8
  9. natural_pdf/collections/pdf_collection.py +25 -30
  10. natural_pdf/core/highlighting_service.py +47 -32
  11. natural_pdf/core/page.py +119 -76
  12. natural_pdf/core/pdf.py +19 -22
  13. natural_pdf/describe/__init__.py +21 -0
  14. natural_pdf/describe/base.py +457 -0
  15. natural_pdf/describe/elements.py +411 -0
  16. natural_pdf/describe/mixin.py +84 -0
  17. natural_pdf/describe/summary.py +186 -0
  18. natural_pdf/elements/base.py +11 -10
  19. natural_pdf/elements/collections.py +116 -51
  20. natural_pdf/elements/region.py +204 -127
  21. natural_pdf/exporters/paddleocr.py +38 -13
  22. natural_pdf/flows/__init__.py +3 -3
  23. natural_pdf/flows/collections.py +303 -132
  24. natural_pdf/flows/element.py +277 -132
  25. natural_pdf/flows/flow.py +33 -16
  26. natural_pdf/flows/region.py +142 -79
  27. natural_pdf/ocr/engine_doctr.py +37 -4
  28. natural_pdf/ocr/engine_easyocr.py +23 -3
  29. natural_pdf/ocr/engine_paddle.py +281 -30
  30. natural_pdf/ocr/engine_surya.py +8 -3
  31. natural_pdf/ocr/ocr_manager.py +75 -76
  32. natural_pdf/ocr/ocr_options.py +52 -87
  33. natural_pdf/search/__init__.py +25 -12
  34. natural_pdf/search/lancedb_search_service.py +91 -54
  35. natural_pdf/search/numpy_search_service.py +86 -65
  36. natural_pdf/search/searchable_mixin.py +2 -2
  37. natural_pdf/selectors/parser.py +125 -81
  38. natural_pdf/widgets/__init__.py +1 -1
  39. natural_pdf/widgets/viewer.py +205 -449
  40. {natural_pdf-0.1.15.dist-info → natural_pdf-0.1.17.dist-info}/METADATA +27 -45
  41. {natural_pdf-0.1.15.dist-info → natural_pdf-0.1.17.dist-info}/RECORD +44 -38
  42. {natural_pdf-0.1.15.dist-info → natural_pdf-0.1.17.dist-info}/WHEEL +0 -0
  43. {natural_pdf-0.1.15.dist-info → natural_pdf-0.1.17.dist-info}/licenses/LICENSE +0 -0
  44. {natural_pdf-0.1.15.dist-info → natural_pdf-0.1.17.dist-info}/top_level.txt +0 -0
@@ -2,9 +2,10 @@ import logging
2
2
  from typing import TYPE_CHECKING, Any, List, Optional, Tuple, Union, cast
3
3
 
4
4
  if TYPE_CHECKING:
5
+ from natural_pdf.core.page import Page as PhysicalPage # For type checking physical_object.page
5
6
  from natural_pdf.elements.base import Element as PhysicalElement
6
7
  from natural_pdf.elements.region import Region as PhysicalRegion
7
- from natural_pdf.core.page import Page as PhysicalPage # For type checking physical_object.page
8
+
8
9
  from .flow import Flow
9
10
  from .region import FlowRegion
10
11
 
@@ -27,14 +28,14 @@ class FlowElement:
27
28
  natural_pdf.elements.region.Region object.
28
29
  flow: The Flow instance this element is part of.
29
30
  """
30
- if not (hasattr(physical_object, 'bbox') and hasattr(physical_object, 'page')):
31
+ if not (hasattr(physical_object, "bbox") and hasattr(physical_object, "page")):
31
32
  raise TypeError(
32
33
  f"physical_object must be a valid PDF element-like object with 'bbox' and 'page' attributes. Got {type(physical_object)}"
33
34
  )
34
35
  self.physical_object: Union["PhysicalElement", "PhysicalRegion"] = physical_object
35
36
  self.flow: "Flow" = flow
36
37
 
37
- # --- Properties to delegate to the physical_object ---
38
+ # --- Properties to delegate to the physical_object ---
38
39
  @property
39
40
  def bbox(self) -> Tuple[float, float, float, float]:
40
41
  return self.physical_object.bbox
@@ -65,33 +66,37 @@ class FlowElement:
65
66
 
66
67
  @property
67
68
  def text(self) -> Optional[str]:
68
- return getattr(self.physical_object, 'text', None)
69
-
69
+ return getattr(self.physical_object, "text", None)
70
+
70
71
  @property
71
72
  def page(self) -> Optional["PhysicalPage"]:
72
73
  """Returns the physical page of the underlying element."""
73
- return getattr(self.physical_object, 'page', None)
74
+ return getattr(self.physical_object, "page", None)
74
75
 
75
76
  def _flow_direction(
76
77
  self,
77
78
  direction: str, # "above", "below", "left", "right"
78
79
  size: Optional[float] = None,
79
- cross_size_ratio: Optional[float] = None, # Default to None for full flow width
80
- cross_size_absolute: Optional[float] = None,
81
- cross_alignment: str = "center", # "start", "center", "end"
80
+ cross_size_ratio: Optional[float] = None, # Default to None for full flow width
81
+ cross_size_absolute: Optional[float] = None,
82
+ cross_alignment: str = "center", # "start", "center", "end"
82
83
  until: Optional[str] = None,
83
84
  include_endpoint: bool = True,
84
85
  **kwargs,
85
86
  ) -> "FlowRegion":
86
- from .region import FlowRegion # Runtime import for return if not stringized, but stringizing is safer
87
87
  # Ensure correct import for creating new PhysicalRegion instances if needed
88
- from natural_pdf.elements.region import Region as PhysicalRegion_Class # Runtime import
88
+ from natural_pdf.elements.region import Region as PhysicalRegion_Class # Runtime import
89
89
 
90
- collected_constituent_regions: List[PhysicalRegion_Class] = [] # PhysicalRegion_Class is runtime
91
- boundary_element_hit: Optional["PhysicalElement"] = None # Stringized
92
- # Ensure remaining_size is float, even if size is int.
93
- remaining_size = float(size) if size is not None else float('inf')
90
+ from .region import ( # Runtime import for return if not stringized, but stringizing is safer
91
+ FlowRegion,
92
+ )
94
93
 
94
+ collected_constituent_regions: List[PhysicalRegion_Class] = (
95
+ []
96
+ ) # PhysicalRegion_Class is runtime
97
+ boundary_element_hit: Optional["PhysicalElement"] = None # Stringized
98
+ # Ensure remaining_size is float, even if size is int.
99
+ remaining_size = float(size) if size is not None else float("inf")
95
100
 
96
101
  # 1. Identify Starting Segment and its index
97
102
  start_segment_index = -1
@@ -107,276 +112,416 @@ class FlowElement:
107
112
  break
108
113
  obj_bbox = self.physical_object.bbox
109
114
  seg_bbox = segment_in_flow.bbox
110
- if not (obj_bbox[2] < seg_bbox[0] or obj_bbox[0] > seg_bbox[2] or \
111
- obj_bbox[3] < seg_bbox[1] or obj_bbox[1] > seg_bbox[3]):
112
- if start_segment_index == -1:
113
- start_segment_index = i
114
-
115
+ if not (
116
+ obj_bbox[2] < seg_bbox[0]
117
+ or obj_bbox[0] > seg_bbox[2]
118
+ or obj_bbox[3] < seg_bbox[1]
119
+ or obj_bbox[1] > seg_bbox[3]
120
+ ):
121
+ if start_segment_index == -1:
122
+ start_segment_index = i
123
+
115
124
  if start_segment_index == -1:
116
- page_num_str = str(self.physical_object.page.page_number) if self.physical_object.page else 'N/A'
125
+ page_num_str = (
126
+ str(self.physical_object.page.page_number) if self.physical_object.page else "N/A"
127
+ )
117
128
  logger.warning(
118
129
  f"FlowElement's physical object {self.physical_object.bbox} on page {page_num_str} "
119
130
  f"not found within any flow segment. Cannot perform directional operation '{direction}'."
120
131
  )
121
132
  # Need FlowRegion for the return type, ensure it's available or stringized
122
- from .region import FlowRegion as RuntimeFlowRegion
133
+ from .region import FlowRegion as RuntimeFlowRegion
134
+
123
135
  return RuntimeFlowRegion(
124
136
  flow=self.flow,
125
137
  constituent_regions=[],
126
138
  source_flow_element=self,
127
- boundary_element_found=None
139
+ boundary_element_found=None,
128
140
  )
129
141
 
130
142
  is_primary_vertical = self.flow.arrangement == "vertical"
131
143
  segment_iterator: range
132
144
 
133
145
  if direction == "below":
134
- if not is_primary_vertical: raise NotImplementedError("'below' is for vertical flows.")
135
- is_forward = True
146
+ if not is_primary_vertical:
147
+ raise NotImplementedError("'below' is for vertical flows.")
148
+ is_forward = True
136
149
  segment_iterator = range(start_segment_index, len(self.flow.segments))
137
150
  elif direction == "above":
138
- if not is_primary_vertical: raise NotImplementedError("'above' is for vertical flows.")
151
+ if not is_primary_vertical:
152
+ raise NotImplementedError("'above' is for vertical flows.")
139
153
  is_forward = False
140
154
  segment_iterator = range(start_segment_index, -1, -1)
141
155
  elif direction == "right":
142
- if is_primary_vertical: raise NotImplementedError("'right' is for horizontal flows.")
156
+ if is_primary_vertical:
157
+ raise NotImplementedError("'right' is for horizontal flows.")
143
158
  is_forward = True
144
159
  segment_iterator = range(start_segment_index, len(self.flow.segments))
145
160
  elif direction == "left":
146
- if is_primary_vertical: raise NotImplementedError("'left' is for horizontal flows.")
161
+ if is_primary_vertical:
162
+ raise NotImplementedError("'left' is for horizontal flows.")
147
163
  is_forward = False
148
164
  segment_iterator = range(start_segment_index, -1, -1)
149
165
  else:
150
- raise ValueError(f"Internal error: Invalid direction '{direction}' for _flow_direction.")
166
+ raise ValueError(
167
+ f"Internal error: Invalid direction '{direction}' for _flow_direction."
168
+ )
151
169
 
152
170
  for current_segment_idx in segment_iterator:
153
- if remaining_size <= 0 and size is not None: break
154
- if boundary_element_hit: break
171
+ if remaining_size <= 0 and size is not None:
172
+ break
173
+ if boundary_element_hit:
174
+ break
155
175
 
156
176
  current_segment: PhysicalRegion_Class = self.flow.segments[current_segment_idx]
157
177
  segment_contribution: Optional[PhysicalRegion_Class] = None
158
-
159
- op_source: Union["PhysicalElement", PhysicalRegion_Class] # Stringized PhysicalElement
178
+
179
+ op_source: Union["PhysicalElement", PhysicalRegion_Class] # Stringized PhysicalElement
160
180
  op_direction_params: dict = {
161
- "direction": direction, "until": until, "include_endpoint": include_endpoint, **kwargs
181
+ "direction": direction,
182
+ "until": until,
183
+ "include_endpoint": include_endpoint,
184
+ **kwargs,
162
185
  }
163
-
186
+
164
187
  # --- Cross-size logic: Default to "full" if no specific ratio or absolute is given ---
165
188
  cross_size_for_op: Union[str, float]
166
189
  if cross_size_absolute is not None:
167
190
  cross_size_for_op = cross_size_absolute
168
- elif cross_size_ratio is not None: # User explicitly provided a ratio
169
- base_cross_dim = self.physical_object.width if is_primary_vertical else self.physical_object.height
191
+ elif cross_size_ratio is not None: # User explicitly provided a ratio
192
+ base_cross_dim = (
193
+ self.physical_object.width
194
+ if is_primary_vertical
195
+ else self.physical_object.height
196
+ )
170
197
  cross_size_for_op = base_cross_dim * cross_size_ratio
171
- else: # Default case: neither absolute nor ratio provided, so use "full"
198
+ else: # Default case: neither absolute nor ratio provided, so use "full"
172
199
  cross_size_for_op = "full"
173
200
  op_direction_params["cross_size"] = cross_size_for_op
174
201
 
175
202
  if current_segment_idx == start_segment_index:
176
- op_source = self.physical_object
203
+ op_source = self.physical_object
177
204
  op_direction_params["size"] = remaining_size if size is not None else None
178
- op_direction_params["include_source"] = False
205
+ op_direction_params["include_source"] = False
179
206
 
180
207
  source_for_op_call = op_source
181
208
  if not isinstance(source_for_op_call, PhysicalRegion_Class):
182
- if hasattr(source_for_op_call, 'to_region'):
209
+ if hasattr(source_for_op_call, "to_region"):
183
210
  source_for_op_call = source_for_op_call.to_region()
184
- else:
185
- logger.error(f"FlowElement: Cannot convert op_source {type(op_source)} to region.")
211
+ else:
212
+ logger.error(
213
+ f"FlowElement: Cannot convert op_source {type(op_source)} to region."
214
+ )
186
215
  continue
187
-
216
+
188
217
  # 1. Perform directional operation *without* 'until' initially to get basic shape.
189
218
  initial_op_params = {
190
219
  "direction": direction,
191
220
  "size": remaining_size if size is not None else None,
192
221
  "cross_size": cross_size_for_op,
193
- "cross_alignment": cross_alignment, # Pass alignment
222
+ "cross_alignment": cross_alignment, # Pass alignment
194
223
  "include_source": False,
195
224
  # Pass other relevant kwargs if Region._direction uses them (e.g. strict_type)
196
- **{k: v for k, v in kwargs.items() if k in ['strict_type', 'first_match_only']}
225
+ **{k: v for k, v in kwargs.items() if k in ["strict_type", "first_match_only"]},
197
226
  }
198
227
  initial_region_from_op = source_for_op_call._direction(**initial_op_params)
199
228
 
200
229
  # 2. Clip this initial region to the current flow segment's boundaries.
201
230
  clipped_search_area = current_segment.clip(initial_region_from_op)
202
- segment_contribution = clipped_search_area # Default contribution
231
+ segment_contribution = clipped_search_area # Default contribution
203
232
 
204
233
  # 3. If 'until' is specified, search for it *within* the clipped_search_area.
205
- if until and clipped_search_area and clipped_search_area.width > 0 and clipped_search_area.height > 0:
234
+ if (
235
+ until
236
+ and clipped_search_area
237
+ and clipped_search_area.width > 0
238
+ and clipped_search_area.height > 0
239
+ ):
206
240
  # kwargs for find_all are the general kwargs passed to _flow_direction
207
241
  until_matches = clipped_search_area.find_all(until, **kwargs)
208
-
242
+
209
243
  if until_matches:
210
244
  potential_hit: Optional["PhysicalElement"] = None
211
- if direction == "below": potential_hit = until_matches.sort(key=lambda m: m.top).first
212
- elif direction == "above": potential_hit = until_matches.sort(key=lambda m: m.bottom, reverse=True).first
213
- elif direction == "right": potential_hit = until_matches.sort(key=lambda m: m.x0).first
214
- elif direction == "left": potential_hit = until_matches.sort(key=lambda m: m.x1, reverse=True).first
215
-
245
+ if direction == "below":
246
+ potential_hit = until_matches.sort(key=lambda m: m.top).first
247
+ elif direction == "above":
248
+ potential_hit = until_matches.sort(
249
+ key=lambda m: m.bottom, reverse=True
250
+ ).first
251
+ elif direction == "right":
252
+ potential_hit = until_matches.sort(key=lambda m: m.x0).first
253
+ elif direction == "left":
254
+ potential_hit = until_matches.sort(
255
+ key=lambda m: m.x1, reverse=True
256
+ ).first
257
+
216
258
  if potential_hit:
217
- boundary_element_hit = potential_hit # Set the overall boundary flag
259
+ boundary_element_hit = potential_hit # Set the overall boundary flag
218
260
  # Adjust segment_contribution to stop at this boundary_element_hit.
219
261
  if is_primary_vertical:
220
262
  if direction == "below":
221
- edge = boundary_element_hit.bottom if include_endpoint else (boundary_element_hit.top - 1)
263
+ edge = (
264
+ boundary_element_hit.bottom
265
+ if include_endpoint
266
+ else (boundary_element_hit.top - 1)
267
+ )
222
268
  else: # direction == "above"
223
- edge = boundary_element_hit.top if include_endpoint else (boundary_element_hit.bottom + 1)
269
+ edge = (
270
+ boundary_element_hit.top
271
+ if include_endpoint
272
+ else (boundary_element_hit.bottom + 1)
273
+ )
224
274
  segment_contribution = segment_contribution.clip(
225
275
  bottom=edge if direction == "below" else None,
226
- top=edge if direction == "above" else None
276
+ top=edge if direction == "above" else None,
227
277
  )
228
- else:
278
+ else:
229
279
  if direction == "right":
230
- edge = boundary_element_hit.x1 if include_endpoint else (boundary_element_hit.x0 - 1)
280
+ edge = (
281
+ boundary_element_hit.x1
282
+ if include_endpoint
283
+ else (boundary_element_hit.x0 - 1)
284
+ )
231
285
  else: # direction == "left"
232
- edge = boundary_element_hit.x0 if include_endpoint else (boundary_element_hit.x1 + 1)
286
+ edge = (
287
+ boundary_element_hit.x0
288
+ if include_endpoint
289
+ else (boundary_element_hit.x1 + 1)
290
+ )
233
291
  segment_contribution = segment_contribution.clip(
234
292
  right=edge if direction == "right" else None,
235
- left=edge if direction == "left" else None
293
+ left=edge if direction == "left" else None,
236
294
  )
237
- else:
295
+ else:
238
296
  candidate_region_in_segment = current_segment
239
297
  if until and not boundary_element_hit:
240
298
  until_matches = candidate_region_in_segment.find_all(until, **kwargs)
241
299
  if until_matches:
242
300
  potential_hit = None
243
- if direction == "below": potential_hit = until_matches.sort(key=lambda m: m.top).first
244
- elif direction == "above": potential_hit = until_matches.sort(key=lambda m: m.bottom, reverse=True).first
245
- elif direction == "right": potential_hit = until_matches.sort(key=lambda m: m.x0).first
246
- elif direction == "left": potential_hit = until_matches.sort(key=lambda m: m.x1, reverse=True).first
247
-
301
+ if direction == "below":
302
+ potential_hit = until_matches.sort(key=lambda m: m.top).first
303
+ elif direction == "above":
304
+ potential_hit = until_matches.sort(
305
+ key=lambda m: m.bottom, reverse=True
306
+ ).first
307
+ elif direction == "right":
308
+ potential_hit = until_matches.sort(key=lambda m: m.x0).first
309
+ elif direction == "left":
310
+ potential_hit = until_matches.sort(
311
+ key=lambda m: m.x1, reverse=True
312
+ ).first
313
+
248
314
  if potential_hit:
249
315
  boundary_element_hit = potential_hit
250
316
  if is_primary_vertical:
251
317
  if direction == "below":
252
- edge = boundary_element_hit.bottom if include_endpoint else (boundary_element_hit.top - 1)
318
+ edge = (
319
+ boundary_element_hit.bottom
320
+ if include_endpoint
321
+ else (boundary_element_hit.top - 1)
322
+ )
253
323
  else: # direction == "above"
254
- edge = boundary_element_hit.top if include_endpoint else (boundary_element_hit.bottom + 1)
255
- candidate_region_in_segment = candidate_region_in_segment.clip(bottom=edge if direction == "below" else None, top=edge if direction == "above" else None)
256
- else:
324
+ edge = (
325
+ boundary_element_hit.top
326
+ if include_endpoint
327
+ else (boundary_element_hit.bottom + 1)
328
+ )
329
+ candidate_region_in_segment = candidate_region_in_segment.clip(
330
+ bottom=edge if direction == "below" else None,
331
+ top=edge if direction == "above" else None,
332
+ )
333
+ else:
257
334
  if direction == "right":
258
- edge = boundary_element_hit.x1 if include_endpoint else (boundary_element_hit.x0 - 1)
335
+ edge = (
336
+ boundary_element_hit.x1
337
+ if include_endpoint
338
+ else (boundary_element_hit.x0 - 1)
339
+ )
259
340
  else: # direction == "left"
260
- edge = boundary_element_hit.x0 if include_endpoint else (boundary_element_hit.x1 + 1)
261
- candidate_region_in_segment = candidate_region_in_segment.clip(right=edge if direction == "right" else None, left=edge if direction == "left" else None)
341
+ edge = (
342
+ boundary_element_hit.x0
343
+ if include_endpoint
344
+ else (boundary_element_hit.x1 + 1)
345
+ )
346
+ candidate_region_in_segment = candidate_region_in_segment.clip(
347
+ right=edge if direction == "right" else None,
348
+ left=edge if direction == "left" else None,
349
+ )
262
350
  segment_contribution = candidate_region_in_segment
263
351
 
264
- if segment_contribution and segment_contribution.width > 0 and segment_contribution.height > 0 and size is not None:
352
+ if (
353
+ segment_contribution
354
+ and segment_contribution.width > 0
355
+ and segment_contribution.height > 0
356
+ and size is not None
357
+ ):
265
358
  current_part_consumed_size = 0.0
266
- if is_primary_vertical:
359
+ if is_primary_vertical:
267
360
  current_part_consumed_size = segment_contribution.height
268
361
  if current_part_consumed_size > remaining_size:
269
- new_edge = (segment_contribution.top + remaining_size) if is_forward else (segment_contribution.bottom - remaining_size)
270
- segment_contribution = segment_contribution.clip(bottom=new_edge if is_forward else None, top=new_edge if not is_forward else None)
271
- current_part_consumed_size = remaining_size
272
- else:
362
+ new_edge = (
363
+ (segment_contribution.top + remaining_size)
364
+ if is_forward
365
+ else (segment_contribution.bottom - remaining_size)
366
+ )
367
+ segment_contribution = segment_contribution.clip(
368
+ bottom=new_edge if is_forward else None,
369
+ top=new_edge if not is_forward else None,
370
+ )
371
+ current_part_consumed_size = remaining_size
372
+ else:
273
373
  current_part_consumed_size = segment_contribution.width
274
374
  if current_part_consumed_size > remaining_size:
275
- new_edge = (segment_contribution.x0 + remaining_size) if is_forward else (segment_contribution.x1 - remaining_size)
276
- segment_contribution = segment_contribution.clip(right=new_edge if is_forward else None, left=new_edge if not is_forward else None)
277
- current_part_consumed_size = remaining_size
375
+ new_edge = (
376
+ (segment_contribution.x0 + remaining_size)
377
+ if is_forward
378
+ else (segment_contribution.x1 - remaining_size)
379
+ )
380
+ segment_contribution = segment_contribution.clip(
381
+ right=new_edge if is_forward else None,
382
+ left=new_edge if not is_forward else None,
383
+ )
384
+ current_part_consumed_size = remaining_size
278
385
  remaining_size -= current_part_consumed_size
279
-
280
- if segment_contribution and segment_contribution.width > 0 and segment_contribution.height > 0:
386
+
387
+ if (
388
+ segment_contribution
389
+ and segment_contribution.width > 0
390
+ and segment_contribution.height > 0
391
+ ):
281
392
  collected_constituent_regions.append(segment_contribution)
282
393
 
283
394
  # If boundary was hit in this segment, and we are not on the start segment (where we might still collect part of it)
284
395
  # or if we are on the start segment AND the contribution became zero (e.g. until was immediate)
285
- if boundary_element_hit and (current_segment_idx != start_segment_index or not segment_contribution or (segment_contribution.width <= 0 or segment_contribution.height <= 0)):
286
- break # Stop iterating through more segments
287
-
288
- is_logically_last_segment = (is_forward and current_segment_idx == len(self.flow.segments) - 1) or \
289
- (not is_forward and current_segment_idx == 0)
396
+ if boundary_element_hit and (
397
+ current_segment_idx != start_segment_index
398
+ or not segment_contribution
399
+ or (segment_contribution.width <= 0 or segment_contribution.height <= 0)
400
+ ):
401
+ break # Stop iterating through more segments
402
+
403
+ is_logically_last_segment = (
404
+ is_forward and current_segment_idx == len(self.flow.segments) - 1
405
+ ) or (not is_forward and current_segment_idx == 0)
290
406
  if not is_logically_last_segment and self.flow.segment_gap > 0 and size is not None:
291
- if remaining_size > 0 :
292
- remaining_size -= self.flow.segment_gap
293
-
294
- from .region import FlowRegion as RuntimeFlowRegion # Ensure it's available for return
407
+ if remaining_size > 0:
408
+ remaining_size -= self.flow.segment_gap
409
+
410
+ from .region import FlowRegion as RuntimeFlowRegion # Ensure it's available for return
411
+
295
412
  return RuntimeFlowRegion(
296
413
  flow=self.flow,
297
414
  constituent_regions=collected_constituent_regions,
298
415
  source_flow_element=self,
299
- boundary_element_found=boundary_element_hit
416
+ boundary_element_found=boundary_element_hit,
300
417
  )
301
418
 
302
- # --- Public Directional Methods ---
419
+ # --- Public Directional Methods ---
303
420
  # These will largely mirror DirectionalMixin but call _flow_direction.
304
421
 
305
422
  def above(
306
423
  self,
307
- height: Optional[float] = None,
308
- width_ratio: Optional[float] = None,
309
- width_absolute: Optional[float] = None,
310
- width_alignment: str = "center",
424
+ height: Optional[float] = None,
425
+ width_ratio: Optional[float] = None,
426
+ width_absolute: Optional[float] = None,
427
+ width_alignment: str = "center",
311
428
  until: Optional[str] = None,
312
429
  include_endpoint: bool = True,
313
430
  **kwargs,
314
- ) -> "FlowRegion": # Stringized
431
+ ) -> "FlowRegion": # Stringized
315
432
  if self.flow.arrangement == "vertical":
316
433
  return self._flow_direction(
317
- direction="above", size=height, cross_size_ratio=width_ratio,
318
- cross_size_absolute=width_absolute, cross_alignment=width_alignment,
319
- until=until, include_endpoint=include_endpoint, **kwargs,
434
+ direction="above",
435
+ size=height,
436
+ cross_size_ratio=width_ratio,
437
+ cross_size_absolute=width_absolute,
438
+ cross_alignment=width_alignment,
439
+ until=until,
440
+ include_endpoint=include_endpoint,
441
+ **kwargs,
442
+ )
443
+ else:
444
+ raise NotImplementedError(
445
+ "'above' in a horizontal flow is ambiguous with current 1D flow logic and not yet implemented."
320
446
  )
321
- else:
322
- raise NotImplementedError("'above' in a horizontal flow is ambiguous with current 1D flow logic and not yet implemented.")
323
447
 
324
448
  def below(
325
449
  self,
326
- height: Optional[float] = None,
450
+ height: Optional[float] = None,
327
451
  width_ratio: Optional[float] = None,
328
452
  width_absolute: Optional[float] = None,
329
453
  width_alignment: str = "center",
330
454
  until: Optional[str] = None,
331
455
  include_endpoint: bool = True,
332
456
  **kwargs,
333
- ) -> "FlowRegion": # Stringized
457
+ ) -> "FlowRegion": # Stringized
334
458
  if self.flow.arrangement == "vertical":
335
459
  return self._flow_direction(
336
- direction="below", size=height, cross_size_ratio=width_ratio,
337
- cross_size_absolute=width_absolute, cross_alignment=width_alignment,
338
- until=until, include_endpoint=include_endpoint, **kwargs,
460
+ direction="below",
461
+ size=height,
462
+ cross_size_ratio=width_ratio,
463
+ cross_size_absolute=width_absolute,
464
+ cross_alignment=width_alignment,
465
+ until=until,
466
+ include_endpoint=include_endpoint,
467
+ **kwargs,
468
+ )
469
+ else:
470
+ raise NotImplementedError(
471
+ "'below' in a horizontal flow is ambiguous with current 1D flow logic and not yet implemented."
339
472
  )
340
- else:
341
- raise NotImplementedError("'below' in a horizontal flow is ambiguous with current 1D flow logic and not yet implemented.")
342
473
 
343
474
  def left(
344
475
  self,
345
- width: Optional[float] = None,
346
- height_ratio: Optional[float] = None,
347
- height_absolute: Optional[float] = None,
348
- height_alignment: str = "center",
476
+ width: Optional[float] = None,
477
+ height_ratio: Optional[float] = None,
478
+ height_absolute: Optional[float] = None,
479
+ height_alignment: str = "center",
349
480
  until: Optional[str] = None,
350
481
  include_endpoint: bool = True,
351
482
  **kwargs,
352
- ) -> "FlowRegion": # Stringized
483
+ ) -> "FlowRegion": # Stringized
353
484
  if self.flow.arrangement == "horizontal":
354
485
  return self._flow_direction(
355
- direction="left", size=width, cross_size_ratio=height_ratio,
356
- cross_size_absolute=height_absolute, cross_alignment=height_alignment,
357
- until=until, include_endpoint=include_endpoint, **kwargs,
486
+ direction="left",
487
+ size=width,
488
+ cross_size_ratio=height_ratio,
489
+ cross_size_absolute=height_absolute,
490
+ cross_alignment=height_alignment,
491
+ until=until,
492
+ include_endpoint=include_endpoint,
493
+ **kwargs,
494
+ )
495
+ else:
496
+ raise NotImplementedError(
497
+ "'left' in a vertical flow is ambiguous with current 1D flow logic and not yet implemented."
358
498
  )
359
- else:
360
- raise NotImplementedError("'left' in a vertical flow is ambiguous with current 1D flow logic and not yet implemented.")
361
499
 
362
500
  def right(
363
501
  self,
364
- width: Optional[float] = None,
502
+ width: Optional[float] = None,
365
503
  height_ratio: Optional[float] = None,
366
504
  height_absolute: Optional[float] = None,
367
505
  height_alignment: str = "center",
368
506
  until: Optional[str] = None,
369
507
  include_endpoint: bool = True,
370
508
  **kwargs,
371
- ) -> "FlowRegion": # Stringized
509
+ ) -> "FlowRegion": # Stringized
372
510
  if self.flow.arrangement == "horizontal":
373
511
  return self._flow_direction(
374
- direction="right", size=width, cross_size_ratio=height_ratio,
375
- cross_size_absolute=height_absolute, cross_alignment=height_alignment,
376
- until=until, include_endpoint=include_endpoint, **kwargs,
512
+ direction="right",
513
+ size=width,
514
+ cross_size_ratio=height_ratio,
515
+ cross_size_absolute=height_absolute,
516
+ cross_alignment=height_alignment,
517
+ until=until,
518
+ include_endpoint=include_endpoint,
519
+ **kwargs,
520
+ )
521
+ else:
522
+ raise NotImplementedError(
523
+ "'right' in a vertical flow is ambiguous with current 1D flow logic and not yet implemented."
377
524
  )
378
- else:
379
- raise NotImplementedError("'right' in a vertical flow is ambiguous with current 1D flow logic and not yet implemented.")
380
525
 
381
526
  def __repr__(self) -> str:
382
- return f"<FlowElement for {self.physical_object.__class__.__name__} {self.bbox} in {self.flow}>"
527
+ return f"<FlowElement for {self.physical_object.__class__.__name__} {self.bbox} in {self.flow}>"