natural-pdf 0.1.40__py3-none-any.whl → 0.2.1.dev0__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 (55) hide show
  1. natural_pdf/__init__.py +6 -7
  2. natural_pdf/analyzers/__init__.py +6 -1
  3. natural_pdf/analyzers/guides.py +354 -258
  4. natural_pdf/analyzers/layout/layout_analyzer.py +2 -3
  5. natural_pdf/analyzers/layout/layout_manager.py +18 -4
  6. natural_pdf/analyzers/layout/paddle.py +11 -0
  7. natural_pdf/analyzers/layout/surya.py +2 -3
  8. natural_pdf/analyzers/shape_detection_mixin.py +25 -34
  9. natural_pdf/analyzers/text_structure.py +2 -2
  10. natural_pdf/classification/manager.py +1 -1
  11. natural_pdf/collections/mixins.py +3 -2
  12. natural_pdf/core/highlighting_service.py +743 -32
  13. natural_pdf/core/page.py +236 -383
  14. natural_pdf/core/page_collection.py +1249 -0
  15. natural_pdf/core/pdf.py +172 -83
  16. natural_pdf/{collections → core}/pdf_collection.py +18 -11
  17. natural_pdf/core/render_spec.py +335 -0
  18. natural_pdf/describe/base.py +1 -1
  19. natural_pdf/elements/__init__.py +1 -0
  20. natural_pdf/elements/base.py +108 -83
  21. natural_pdf/elements/{collections.py → element_collection.py} +566 -1487
  22. natural_pdf/elements/line.py +0 -1
  23. natural_pdf/elements/rect.py +0 -1
  24. natural_pdf/elements/region.py +318 -243
  25. natural_pdf/elements/text.py +9 -7
  26. natural_pdf/exporters/base.py +2 -2
  27. natural_pdf/exporters/original_pdf.py +1 -1
  28. natural_pdf/exporters/paddleocr.py +2 -4
  29. natural_pdf/exporters/searchable_pdf.py +3 -2
  30. natural_pdf/extraction/mixin.py +1 -3
  31. natural_pdf/flows/collections.py +1 -69
  32. natural_pdf/flows/element.py +4 -4
  33. natural_pdf/flows/flow.py +1200 -243
  34. natural_pdf/flows/region.py +707 -261
  35. natural_pdf/ocr/ocr_options.py +0 -2
  36. natural_pdf/ocr/utils.py +2 -1
  37. natural_pdf/qa/document_qa.py +21 -5
  38. natural_pdf/search/search_service_protocol.py +1 -1
  39. natural_pdf/selectors/parser.py +2 -2
  40. natural_pdf/tables/result.py +35 -1
  41. natural_pdf/text_mixin.py +7 -3
  42. natural_pdf/utils/debug.py +2 -1
  43. natural_pdf/utils/highlighting.py +1 -0
  44. natural_pdf/utils/layout.py +2 -2
  45. natural_pdf/utils/packaging.py +4 -3
  46. natural_pdf/utils/text_extraction.py +15 -12
  47. natural_pdf/utils/visualization.py +385 -0
  48. {natural_pdf-0.1.40.dist-info → natural_pdf-0.2.1.dev0.dist-info}/METADATA +7 -3
  49. {natural_pdf-0.1.40.dist-info → natural_pdf-0.2.1.dev0.dist-info}/RECORD +55 -53
  50. {natural_pdf-0.1.40.dist-info → natural_pdf-0.2.1.dev0.dist-info}/top_level.txt +0 -2
  51. optimization/memory_comparison.py +1 -1
  52. optimization/pdf_analyzer.py +2 -2
  53. {natural_pdf-0.1.40.dist-info → natural_pdf-0.2.1.dev0.dist-info}/WHEEL +0 -0
  54. {natural_pdf-0.1.40.dist-info → natural_pdf-0.2.1.dev0.dist-info}/entry_points.txt +0 -0
  55. {natural_pdf-0.1.40.dist-info → natural_pdf-0.2.1.dev0.dist-info}/licenses/LICENSE +0 -0
@@ -6,7 +6,7 @@ import io
6
6
  import logging # Added
7
7
  import os
8
8
  from dataclasses import dataclass, field
9
- from typing import Any, Dict, List, Optional, Tuple, Union
9
+ from typing import Any, Dict, List, Literal, Optional, Tuple, Union
10
10
 
11
11
  from colour import Color
12
12
  from PIL import Image, ImageDraw, ImageFont
@@ -17,6 +17,8 @@ try:
17
17
  except ImportError:
18
18
  Page = Any # Fallback if circular import issue arises during type checking
19
19
 
20
+ from natural_pdf.core.render_spec import RenderSpec
21
+
20
22
  # Import ColorManager and related utils
21
23
  from natural_pdf.utils.visualization import (
22
24
  ColorManager,
@@ -302,6 +304,134 @@ class HighlightRenderer:
302
304
  self.result_image = Image.alpha_composite(self.result_image, overlay)
303
305
 
304
306
 
307
+ class HighlightContext:
308
+ """
309
+ Context manager for accumulating highlights before displaying them together.
310
+
311
+ This allows for a clean syntax to show multiple highlight groups:
312
+
313
+ Example:
314
+ with pdf.highlights() as h:
315
+ h.add(page.find_all('table'), label='tables', color='blue')
316
+ h.add(page.find_all('text:bold'), label='bold text', color='red')
317
+ h.show() # Display all highlights together
318
+
319
+ Or for automatic display on exit:
320
+ with pdf.highlights(show=True) as h:
321
+ h.add(page.find_all('table'), label='tables')
322
+ h.add(page.find_all('text:bold'), label='bold')
323
+ # Automatically shows when exiting the context
324
+ """
325
+
326
+ def __init__(self, source, show_on_exit: bool = False):
327
+ """
328
+ Initialize the highlight context.
329
+
330
+ Args:
331
+ source: The source object (PDF, Page, PageCollection, etc.)
332
+ show_on_exit: If True, automatically show highlights when exiting context
333
+ """
334
+ self.source = source
335
+ self.show_on_exit = show_on_exit
336
+ self.highlight_groups = []
337
+ self._color_manager = ColorManager()
338
+
339
+ def add(
340
+ self,
341
+ elements,
342
+ label: Optional[str] = None,
343
+ color: Optional[Union[str, Tuple[int, int, int]]] = None,
344
+ **kwargs,
345
+ ) -> "HighlightContext":
346
+ """
347
+ Add a group of elements to highlight.
348
+
349
+ Args:
350
+ elements: Elements to highlight (can be ElementCollection, list, or single element)
351
+ label: Label for this highlight group
352
+ color: Color for this group (if None, uses color cycling)
353
+ **kwargs: Additional highlight parameters
354
+
355
+ Returns:
356
+ Self for method chaining
357
+ """
358
+ # Convert single element to list
359
+ if hasattr(elements, "elements"):
360
+ # It's an ElementCollection
361
+ element_list = elements.elements
362
+ elif isinstance(elements, list):
363
+ element_list = elements
364
+ else:
365
+ # Single element
366
+ element_list = [elements]
367
+
368
+ # Determine color if not specified
369
+ if color is None:
370
+ color = self._color_manager.get_color(label=label, force_cycle=True)
371
+
372
+ self.highlight_groups.append(
373
+ {"elements": element_list, "label": label, "color": color, **kwargs}
374
+ )
375
+
376
+ return self
377
+
378
+ def show(self, **kwargs) -> Optional[Image.Image]:
379
+ """
380
+ Display all accumulated highlights.
381
+
382
+ Args:
383
+ **kwargs: Additional parameters passed to the show method
384
+
385
+ Returns:
386
+ PIL Image with all highlights, or None if no source
387
+ """
388
+ if not self.source:
389
+ return None
390
+
391
+ # If source has the new unified show method, use it with highlights parameter
392
+ if hasattr(self.source, "show"):
393
+ return self.source.show(highlights=self.highlight_groups, **kwargs)
394
+ else:
395
+ # Fallback for objects without the new show method
396
+ logger.warning(
397
+ f"Object {type(self.source)} does not support unified show() with highlights"
398
+ )
399
+ return None
400
+
401
+ def render(self, **kwargs) -> Optional[Image.Image]:
402
+ """
403
+ Render all accumulated highlights (clean image without debug elements).
404
+
405
+ Args:
406
+ **kwargs: Additional parameters passed to the render method
407
+
408
+ Returns:
409
+ PIL Image with all highlights, or None if no source
410
+ """
411
+ if not self.source:
412
+ return None
413
+
414
+ # If source has the new unified render method, use it with highlights parameter
415
+ if hasattr(self.source, "render"):
416
+ return self.source.render(highlights=self.highlight_groups, **kwargs)
417
+ else:
418
+ # Fallback for objects without the new render method
419
+ logger.warning(
420
+ f"Object {type(self.source)} does not support unified render() with highlights"
421
+ )
422
+ return None
423
+
424
+ def __enter__(self) -> "HighlightContext":
425
+ """Enter the context."""
426
+ return self
427
+
428
+ def __exit__(self, exc_type, exc_val, exc_tb):
429
+ """Exit the context, optionally showing highlights."""
430
+ if self.show_on_exit and not exc_type:
431
+ self.show()
432
+ return False
433
+
434
+
305
435
  class HighlightingService:
306
436
  """
307
437
  Central service to manage highlight data and orchestrate rendering.
@@ -418,7 +548,7 @@ class HighlightingService:
418
548
  label: Optional[str] = None,
419
549
  use_color_cycling: bool = False,
420
550
  element: Optional[Any] = None,
421
- include_attrs: Optional[List[str]] = None,
551
+ annotate: Optional[List[str]] = None,
422
552
  existing: str = "append",
423
553
  ):
424
554
  """Adds a rectangular highlight."""
@@ -468,7 +598,7 @@ class HighlightingService:
468
598
  label=label,
469
599
  use_color_cycling=use_color_cycling,
470
600
  element=element,
471
- include_attrs=include_attrs,
601
+ annotate=annotate,
472
602
  existing=existing,
473
603
  )
474
604
 
@@ -480,7 +610,7 @@ class HighlightingService:
480
610
  label: Optional[str] = None,
481
611
  use_color_cycling: bool = False,
482
612
  element: Optional[Any] = None,
483
- include_attrs: Optional[List[str]] = None,
613
+ annotate: Optional[List[str]] = None,
484
614
  existing: str = "append",
485
615
  ):
486
616
  """Adds a polygonal highlight."""
@@ -501,7 +631,7 @@ class HighlightingService:
501
631
  label=label,
502
632
  use_color_cycling=use_color_cycling,
503
633
  element=element,
504
- include_attrs=include_attrs,
634
+ annotate=annotate,
505
635
  existing=existing,
506
636
  )
507
637
 
@@ -514,7 +644,7 @@ class HighlightingService:
514
644
  label: Optional[str],
515
645
  use_color_cycling: bool,
516
646
  element: Optional[Any],
517
- include_attrs: Optional[List[str]],
647
+ annotate: Optional[List[str]],
518
648
  existing: str,
519
649
  ):
520
650
  """Internal method to create and store a Highlight object."""
@@ -533,8 +663,8 @@ class HighlightingService:
533
663
 
534
664
  # Extract attributes from the element if requested
535
665
  attributes_to_draw = {}
536
- if element and include_attrs:
537
- for attr_name in include_attrs:
666
+ if element and annotate:
667
+ for attr_name in annotate:
538
668
  try:
539
669
  attr_value = getattr(element, attr_name, None)
540
670
  if attr_value is not None:
@@ -745,27 +875,59 @@ class HighlightingService:
745
875
  else:
746
876
  rendered_image = base_image_pil # No highlights, no OCR requested
747
877
 
748
- # --- Add Legend (Based ONLY on this page's highlights) ---
878
+ # --- Add Legend or Colorbar (Based ONLY on this page's highlights) ---
749
879
  if labels:
750
- # CHANGE: Create label_colors map only from highlights_on_page
751
- labels_colors_on_page: Dict[str, Tuple[int, int, int, int]] = {}
880
+ # Check if we have quantitative metadata (for colorbar)
881
+ quantitative_metadata = None
752
882
  for hl in highlights_on_page:
753
- if hl.label and hl.label not in labels_colors_on_page:
754
- labels_colors_on_page[hl.label] = hl.color
883
+ if hasattr(hl, "quantitative_metadata") and hl.quantitative_metadata:
884
+ quantitative_metadata = hl.quantitative_metadata
885
+ break
755
886
 
756
- if labels_colors_on_page: # Only add legend if there are labels on this page
757
- legend = create_legend(labels_colors_on_page)
758
- if legend: # Ensure create_legend didn't return None
887
+ if quantitative_metadata:
888
+ # Create colorbar for quantitative data
889
+ from natural_pdf.utils.visualization import create_colorbar
890
+
891
+ try:
892
+ colorbar = create_colorbar(
893
+ values=quantitative_metadata["values"],
894
+ colormap=quantitative_metadata["colormap"],
895
+ bins=quantitative_metadata["bins"],
896
+ orientation=(
897
+ "horizontal" if legend_position in ["top", "bottom"] else "vertical"
898
+ ),
899
+ )
759
900
  rendered_image = merge_images_with_legend(
760
- rendered_image, legend, legend_position
901
+ rendered_image, colorbar, legend_position
761
902
  )
762
903
  logger.debug(
763
- f"Added legend with {len(labels_colors_on_page)} labels for page {page_index}."
904
+ f"Added colorbar for quantitative attribute '{quantitative_metadata['attribute']}' on page {page_index}."
764
905
  )
906
+ except Exception as e:
907
+ logger.warning(f"Failed to create colorbar for page {page_index}: {e}")
908
+ # Fall back to regular legend
909
+ quantitative_metadata = None
910
+
911
+ if not quantitative_metadata:
912
+ # Create regular categorical legend
913
+ labels_colors_on_page: Dict[str, Tuple[int, int, int, int]] = {}
914
+ for hl in highlights_on_page:
915
+ if hl.label and hl.label not in labels_colors_on_page:
916
+ labels_colors_on_page[hl.label] = hl.color
917
+
918
+ if labels_colors_on_page: # Only add legend if there are labels on this page
919
+ legend = create_legend(labels_colors_on_page)
920
+ if legend: # Ensure create_legend didn't return None
921
+ rendered_image = merge_images_with_legend(
922
+ rendered_image, legend, legend_position
923
+ )
924
+ logger.debug(
925
+ f"Added legend with {len(labels_colors_on_page)} labels for page {page_index}."
926
+ )
927
+ else:
928
+ logger.debug(f"Legend creation returned None for page {page_index}.")
765
929
  else:
766
- logger.debug(f"Legend creation returned None for page {page_index}.")
767
- else:
768
- logger.debug(f"No labels found on page {page_index}, skipping legend.")
930
+ logger.debug(f"No labels found on page {page_index}, skipping legend.")
769
931
 
770
932
  return rendered_image
771
933
 
@@ -875,9 +1037,9 @@ class HighlightingService:
875
1037
  )
876
1038
  attrs_to_draw = {}
877
1039
  element = hl_data.get("element")
878
- include_attrs = hl_data.get("include_attrs")
879
- if element and include_attrs:
880
- for attr_name in include_attrs:
1040
+ annotate = hl_data.get("annotate")
1041
+ if element and annotate:
1042
+ for attr_name in annotate:
881
1043
  try:
882
1044
  attr_value = getattr(element, attr_name, None)
883
1045
  if attr_value is not None:
@@ -934,14 +1096,47 @@ class HighlightingService:
934
1096
 
935
1097
  legend = None
936
1098
  if labels:
937
- preview_labels = {h.label: h.color for h in preview_highlights if h.label}
938
- if preview_labels:
939
- legend = create_legend(preview_labels)
940
- final_image = merge_images_with_legend(
941
- rendered_image, legend, position=legend_position
942
- )
943
- else:
944
- final_image = rendered_image
1099
+ # Check if we have quantitative metadata (for colorbar)
1100
+ quantitative_metadata = None
1101
+ for hl_data in temporary_highlights:
1102
+ if "quantitative_metadata" in hl_data and hl_data["quantitative_metadata"]:
1103
+ quantitative_metadata = hl_data["quantitative_metadata"]
1104
+ break
1105
+
1106
+ if quantitative_metadata:
1107
+ # Create colorbar for quantitative data
1108
+ from natural_pdf.utils.visualization import create_colorbar
1109
+
1110
+ try:
1111
+ colorbar = create_colorbar(
1112
+ values=quantitative_metadata["values"],
1113
+ colormap=quantitative_metadata["colormap"],
1114
+ bins=quantitative_metadata["bins"],
1115
+ orientation=(
1116
+ "horizontal" if legend_position in ["top", "bottom"] else "vertical"
1117
+ ),
1118
+ )
1119
+ final_image = merge_images_with_legend(
1120
+ rendered_image, colorbar, position=legend_position
1121
+ )
1122
+ logger.debug(
1123
+ f"Added colorbar for quantitative attribute '{quantitative_metadata['attribute']}' on page {page_index}."
1124
+ )
1125
+ except Exception as e:
1126
+ logger.warning(f"Failed to create colorbar for page {page_index}: {e}")
1127
+ # Fall back to regular legend
1128
+ quantitative_metadata = None
1129
+
1130
+ if not quantitative_metadata:
1131
+ # Create regular categorical legend
1132
+ preview_labels = {h.label: h.color for h in preview_highlights if h.label}
1133
+ if preview_labels:
1134
+ legend = create_legend(preview_labels)
1135
+ final_image = merge_images_with_legend(
1136
+ rendered_image, legend, position=legend_position
1137
+ )
1138
+ else:
1139
+ final_image = rendered_image
945
1140
  else:
946
1141
  final_image = rendered_image
947
1142
 
@@ -953,3 +1148,519 @@ class HighlightingService:
953
1148
  raise
954
1149
 
955
1150
  return final_image
1151
+
1152
+ def unified_render(
1153
+ self,
1154
+ specs: List["RenderSpec"],
1155
+ resolution: float = 150,
1156
+ width: Optional[int] = None,
1157
+ labels: bool = True,
1158
+ label_format: Optional[str] = None,
1159
+ layout: Literal["stack", "grid", "single"] = "stack",
1160
+ stack_direction: Literal["vertical", "horizontal"] = "vertical",
1161
+ gap: int = 5,
1162
+ columns: Optional[int] = None,
1163
+ background_color: Tuple[int, int, int] = (255, 255, 255),
1164
+ legend_position: str = "right",
1165
+ **kwargs,
1166
+ ) -> Optional[Image.Image]:
1167
+ """
1168
+ Unified rendering method that processes RenderSpec objects.
1169
+
1170
+ This is the single entry point for all image generation in natural-pdf.
1171
+ It handles page rendering, cropping, highlighting, and layout of multiple images.
1172
+
1173
+ Args:
1174
+ specs: List of RenderSpec objects describing what to render
1175
+ resolution: DPI for rendering (default 150)
1176
+ width: Target width in pixels (overrides resolution)
1177
+ labels: Whether to show labels for highlights
1178
+ label_format: Format string for labels
1179
+ layout: How to arrange multiple images
1180
+ stack_direction: Direction for stack layout
1181
+ gap: Pixels between images
1182
+ columns: Number of columns for grid layout
1183
+ background_color: RGB color for background
1184
+ **kwargs: Additional parameters
1185
+
1186
+ Returns:
1187
+ PIL Image or None if nothing to render
1188
+ """
1189
+ from natural_pdf.core.render_spec import RenderSpec
1190
+
1191
+ if not specs:
1192
+ logger.warning("unified_render called with empty specs list")
1193
+ return None
1194
+
1195
+ # Process each spec into an image
1196
+ images = []
1197
+
1198
+ for spec_idx, spec in enumerate(specs):
1199
+ if not isinstance(spec, RenderSpec):
1200
+ logger.error(f"Invalid spec type at index {spec_idx}: {type(spec)}")
1201
+ continue
1202
+
1203
+ try:
1204
+ # Render the page
1205
+ page_image = self._render_spec(
1206
+ spec=spec,
1207
+ resolution=resolution,
1208
+ width=width,
1209
+ labels=labels,
1210
+ label_format=label_format,
1211
+ legend_position=legend_position,
1212
+ spec_index=spec_idx,
1213
+ **kwargs,
1214
+ )
1215
+
1216
+ if page_image:
1217
+ images.append(page_image)
1218
+
1219
+ except Exception as e:
1220
+ logger.error(f"Error rendering spec {spec_idx}: {e}", exc_info=True)
1221
+ continue
1222
+
1223
+ if not images:
1224
+ logger.warning("No images generated from specs")
1225
+ return None
1226
+
1227
+ # Single image - return directly
1228
+ if len(images) == 1:
1229
+ return images[0]
1230
+
1231
+ # Multiple images - apply layout
1232
+ if layout == "stack":
1233
+ return self._stack_images(
1234
+ images, direction=stack_direction, gap=gap, background_color=background_color
1235
+ )
1236
+ elif layout == "grid":
1237
+ return self._grid_images(
1238
+ images, columns=columns, gap=gap, background_color=background_color
1239
+ )
1240
+ else: # "single" - just return first image
1241
+ logger.warning(f"Multiple specs with layout='single', returning first image only")
1242
+ return images[0]
1243
+
1244
+ def _render_spec(
1245
+ self,
1246
+ spec: "RenderSpec",
1247
+ resolution: float,
1248
+ width: Optional[int],
1249
+ labels: bool,
1250
+ label_format: Optional[str],
1251
+ legend_position: str,
1252
+ spec_index: int,
1253
+ **kwargs,
1254
+ ) -> Optional[Image.Image]:
1255
+ """Render a single RenderSpec to an image."""
1256
+ # Get the page
1257
+ page = spec.page
1258
+ if not hasattr(page, "width") or not hasattr(page, "height"):
1259
+ logger.error(f"Spec {spec_index} page does not have width/height attributes")
1260
+ return None
1261
+
1262
+ # Calculate actual resolution/width
1263
+ if width is not None and page.width > 0:
1264
+ # Calculate resolution from width
1265
+ actual_resolution = (width / page.width) * 72
1266
+ else:
1267
+ # Use provided resolution or default
1268
+ actual_resolution = resolution if resolution is not None else 150
1269
+
1270
+ # Get base page image
1271
+ try:
1272
+ # Use render_plain_page for clean rendering
1273
+ logger.debug(
1274
+ f"Calling render_plain_page with page={page}, resolution={actual_resolution}"
1275
+ )
1276
+ page_image = render_plain_page(page, resolution=actual_resolution)
1277
+ except Exception as e:
1278
+ logger.error(f"Failed to render page: {e}")
1279
+ logger.error(f"Page: {page}, Resolution: {actual_resolution}, Width: {width}")
1280
+ import traceback
1281
+
1282
+ traceback.print_exc()
1283
+ return None
1284
+
1285
+ if page_image is None:
1286
+ return None
1287
+
1288
+ # Apply crop if specified
1289
+ if spec.crop_bbox:
1290
+ page_image = self._crop_image(
1291
+ page_image, spec.crop_bbox, page, actual_resolution / 72 # scale factor
1292
+ )
1293
+
1294
+ # Apply highlights if any
1295
+ if spec.highlights:
1296
+ page_image = self._apply_spec_highlights(
1297
+ page_image,
1298
+ spec.highlights,
1299
+ page,
1300
+ actual_resolution / 72, # scale factor
1301
+ labels=labels,
1302
+ label_format=label_format,
1303
+ spec_index=spec_index,
1304
+ crop_offset=spec.crop_bbox[:2] if spec.crop_bbox else None, # Pass crop offset
1305
+ )
1306
+
1307
+ # Add legend or colorbar if labels are enabled
1308
+ if labels:
1309
+ # Import visualization functions
1310
+ from natural_pdf.utils.visualization import (
1311
+ create_colorbar,
1312
+ create_legend,
1313
+ merge_images_with_legend,
1314
+ )
1315
+
1316
+ # Check if we have quantitative metadata (for colorbar)
1317
+ quantitative_metadata = None
1318
+ for highlight_data in spec.highlights:
1319
+ if (
1320
+ "quantitative_metadata" in highlight_data
1321
+ and highlight_data["quantitative_metadata"]
1322
+ ):
1323
+ quantitative_metadata = highlight_data["quantitative_metadata"]
1324
+ break
1325
+
1326
+ if quantitative_metadata:
1327
+ # Create colorbar for quantitative data
1328
+ try:
1329
+ colorbar = create_colorbar(
1330
+ values=quantitative_metadata["values"],
1331
+ colormap=quantitative_metadata["colormap"],
1332
+ bins=quantitative_metadata["bins"],
1333
+ orientation=(
1334
+ "horizontal" if legend_position in ["top", "bottom"] else "vertical"
1335
+ ),
1336
+ )
1337
+ page_image = merge_images_with_legend(
1338
+ page_image, colorbar, position=legend_position
1339
+ )
1340
+ logger.debug(
1341
+ f"Added colorbar for quantitative attribute '{quantitative_metadata['attribute']}' in spec {spec_index}."
1342
+ )
1343
+ except Exception as e:
1344
+ logger.warning(f"Failed to create colorbar for spec {spec_index}: {e}")
1345
+ # Fall back to regular legend
1346
+ quantitative_metadata = None
1347
+
1348
+ if not quantitative_metadata:
1349
+ # Create regular categorical legend
1350
+ spec_labels = {}
1351
+ for hl in spec.highlights:
1352
+ label = hl.get("label")
1353
+ color = hl.get("color")
1354
+ if label and color:
1355
+ # Process color to ensure it's an RGBA tuple
1356
+ processed_color = self._process_color_input(color)
1357
+ if processed_color:
1358
+ spec_labels[label] = processed_color
1359
+ else:
1360
+ # Fallback to color manager if processing fails
1361
+ spec_labels[label] = self._color_manager.get_color(label=label)
1362
+
1363
+ if spec_labels:
1364
+ legend = create_legend(spec_labels)
1365
+ if legend:
1366
+ page_image = merge_images_with_legend(
1367
+ page_image, legend, position=legend_position
1368
+ )
1369
+ logger.debug(
1370
+ f"Added legend with {len(spec_labels)} labels for spec {spec_index}."
1371
+ )
1372
+
1373
+ return page_image
1374
+
1375
+ def _crop_image(
1376
+ self,
1377
+ image: Image.Image,
1378
+ crop_bbox: Tuple[float, float, float, float],
1379
+ page: "Page",
1380
+ scale_factor: float,
1381
+ ) -> Image.Image:
1382
+ """Crop an image to the specified bbox."""
1383
+ # Convert PDF coordinates to pixel coordinates
1384
+ x0, y0, x1, y1 = crop_bbox
1385
+ pixel_bbox = (
1386
+ int(x0 * scale_factor),
1387
+ int(y0 * scale_factor),
1388
+ int(x1 * scale_factor),
1389
+ int(y1 * scale_factor),
1390
+ )
1391
+
1392
+ # Ensure valid crop bounds
1393
+ pixel_bbox = (
1394
+ max(0, pixel_bbox[0]),
1395
+ max(0, pixel_bbox[1]),
1396
+ min(image.width, pixel_bbox[2]),
1397
+ min(image.height, pixel_bbox[3]),
1398
+ )
1399
+
1400
+ if pixel_bbox[2] <= pixel_bbox[0] or pixel_bbox[3] <= pixel_bbox[1]:
1401
+ logger.warning(f"Invalid crop bounds: {crop_bbox}")
1402
+ return image
1403
+
1404
+ return image.crop(pixel_bbox)
1405
+
1406
+ def _apply_spec_highlights(
1407
+ self,
1408
+ image: Image.Image,
1409
+ highlights: List[Dict[str, Any]],
1410
+ page: "Page",
1411
+ scale_factor: float,
1412
+ labels: bool,
1413
+ label_format: Optional[str],
1414
+ spec_index: int,
1415
+ crop_offset: Optional[Tuple[float, float]] = None,
1416
+ ) -> Image.Image:
1417
+ """Apply highlights from a RenderSpec to an image."""
1418
+ # Convert to RGBA for transparency
1419
+ if image.mode != "RGBA":
1420
+ image = image.convert("RGBA")
1421
+
1422
+ # Create overlay for highlights
1423
+ overlay = Image.new("RGBA", image.size, (0, 0, 0, 0))
1424
+ draw = ImageDraw.Draw(overlay)
1425
+
1426
+ # Process each highlight
1427
+ for idx, highlight_dict in enumerate(highlights):
1428
+ # Get geometry
1429
+ bbox = highlight_dict.get("bbox")
1430
+ polygon = highlight_dict.get("polygon")
1431
+
1432
+ if bbox is None and polygon is None:
1433
+ logger.warning(f"Highlight {idx} has no geometry")
1434
+ continue
1435
+
1436
+ # Get color
1437
+ color = highlight_dict.get("color")
1438
+ label = highlight_dict.get("label")
1439
+
1440
+ if color is None:
1441
+ # Use label-based color assignment for consistency
1442
+ color = self._color_manager.get_color(label=label, force_cycle=False)
1443
+ else:
1444
+ # Process color input
1445
+ color = self._process_color_input(color)
1446
+ if color is None:
1447
+ color = self._color_manager.get_color(label=label, force_cycle=False)
1448
+
1449
+ # Generate label if needed
1450
+ if label is None and labels and label_format:
1451
+ # Generate label from format
1452
+ label = label_format.format(index=idx, spec_index=spec_index, total=len(highlights))
1453
+
1454
+ # Calculate offset for cropped images
1455
+ offset_x = 0
1456
+ offset_y = 0
1457
+ if crop_offset:
1458
+ offset_x = crop_offset[0] * scale_factor
1459
+ offset_y = crop_offset[1] * scale_factor
1460
+
1461
+ # Draw the highlight
1462
+ if polygon:
1463
+ # Scale polygon points and apply offset
1464
+ scaled_polygon = [
1465
+ (p[0] * scale_factor - offset_x, p[1] * scale_factor - offset_y)
1466
+ for p in polygon
1467
+ ]
1468
+ draw.polygon(
1469
+ scaled_polygon, fill=color, outline=(color[0], color[1], color[2], BORDER_ALPHA)
1470
+ )
1471
+ else:
1472
+ # Scale bbox and apply offset
1473
+ x0, y0, x1, y1 = bbox
1474
+ scaled_bbox = [
1475
+ x0 * scale_factor - offset_x,
1476
+ y0 * scale_factor - offset_y,
1477
+ x1 * scale_factor - offset_x,
1478
+ y1 * scale_factor - offset_y,
1479
+ ]
1480
+ draw.rectangle(
1481
+ scaled_bbox, fill=color, outline=(color[0], color[1], color[2], BORDER_ALPHA)
1482
+ )
1483
+
1484
+ # Draw attributes if present
1485
+ attributes_to_draw = highlight_dict.get("attributes_to_draw")
1486
+ if attributes_to_draw and scaled_bbox:
1487
+ self._draw_spec_attributes(draw, attributes_to_draw, scaled_bbox, scale_factor)
1488
+
1489
+ # Composite overlay onto image
1490
+ return Image.alpha_composite(image, overlay)
1491
+
1492
+ def _draw_spec_attributes(
1493
+ self,
1494
+ draw: ImageDraw.Draw,
1495
+ attributes: Dict[str, Any],
1496
+ bbox_scaled: List[float],
1497
+ scale_factor: float,
1498
+ ) -> None:
1499
+ """Draw attribute key-value pairs on the highlight."""
1500
+ try:
1501
+ # Slightly larger font, scaled
1502
+ font_size = max(10, int(8 * scale_factor))
1503
+ # Try to load a font
1504
+ try:
1505
+ font = ImageFont.truetype("Arial.ttf", font_size)
1506
+ except IOError:
1507
+ try:
1508
+ font = ImageFont.truetype("DejaVuSans.ttf", font_size)
1509
+ except IOError:
1510
+ font = ImageFont.load_default()
1511
+ font_size = 10 # Reset size for default font
1512
+ except Exception:
1513
+ font = ImageFont.load_default()
1514
+ font_size = 10
1515
+
1516
+ line_height = font_size + int(4 * scale_factor) # Scaled line spacing
1517
+ bg_padding = int(3 * scale_factor)
1518
+ max_width = 0
1519
+ text_lines = []
1520
+
1521
+ # Format attribute lines
1522
+ for name, value in attributes.items():
1523
+ if isinstance(value, float):
1524
+ value_str = f"{value:.2f}" # Format floats
1525
+ else:
1526
+ value_str = str(value)
1527
+ line = f"{name}: {value_str}"
1528
+ text_lines.append(line)
1529
+ try:
1530
+ # Calculate max width for background box
1531
+ max_width = max(max_width, draw.textlength(line, font=font))
1532
+ except AttributeError:
1533
+ # Fallback for older PIL versions
1534
+ bbox = draw.textbbox((0, 0), line, font=font)
1535
+ max_width = max(max_width, bbox[2] - bbox[0])
1536
+
1537
+ if not text_lines:
1538
+ return # Nothing to draw
1539
+
1540
+ total_height = line_height * len(text_lines)
1541
+
1542
+ # Position near top-right corner with padding
1543
+ x = bbox_scaled[2] - int(2 * scale_factor) - max_width
1544
+ y = bbox_scaled[1] + int(2 * scale_factor)
1545
+
1546
+ # Draw background rectangle (semi-transparent white)
1547
+ bg_x0 = x - bg_padding
1548
+ bg_y0 = y - bg_padding
1549
+ bg_x1 = x + max_width + bg_padding
1550
+ bg_y1 = y + total_height + bg_padding
1551
+ draw.rectangle(
1552
+ [bg_x0, bg_y0, bg_x1, bg_y1],
1553
+ fill=(255, 255, 255, 240),
1554
+ outline=(0, 0, 0, 180), # Light black outline
1555
+ width=1,
1556
+ )
1557
+
1558
+ # Draw text lines (black)
1559
+ current_y = y
1560
+ for line in text_lines:
1561
+ draw.text((x, current_y), line, fill=(0, 0, 0, 255), font=font)
1562
+ current_y += line_height
1563
+
1564
+ def _stack_images(
1565
+ self,
1566
+ images: List[Image.Image],
1567
+ direction: str,
1568
+ gap: int,
1569
+ background_color: Tuple[int, int, int],
1570
+ ) -> Image.Image:
1571
+ """Stack images vertically or horizontally."""
1572
+ if direction == "vertical":
1573
+ # Calculate dimensions
1574
+ max_width = max(img.width for img in images)
1575
+ total_height = sum(img.height for img in images) + gap * (len(images) - 1)
1576
+
1577
+ # Create canvas
1578
+ canvas = Image.new("RGB", (max_width, total_height), background_color)
1579
+
1580
+ # Paste images
1581
+ y_offset = 0
1582
+ for img in images:
1583
+ # Center horizontally
1584
+ x_offset = (max_width - img.width) // 2
1585
+ # Convert RGBA to RGB if needed
1586
+ if img.mode == "RGBA":
1587
+ # Create white background
1588
+ bg = Image.new("RGB", img.size, background_color)
1589
+ bg.paste(img, mask=img.split()[3]) # Use alpha channel as mask
1590
+ img = bg
1591
+ canvas.paste(img, (x_offset, y_offset))
1592
+ y_offset += img.height + gap
1593
+
1594
+ else: # horizontal
1595
+ # Calculate dimensions
1596
+ total_width = sum(img.width for img in images) + gap * (len(images) - 1)
1597
+ max_height = max(img.height for img in images)
1598
+
1599
+ # Create canvas
1600
+ canvas = Image.new("RGB", (total_width, max_height), background_color)
1601
+
1602
+ # Paste images
1603
+ x_offset = 0
1604
+ for img in images:
1605
+ # Center vertically
1606
+ y_offset = (max_height - img.height) // 2
1607
+ # Convert RGBA to RGB if needed
1608
+ if img.mode == "RGBA":
1609
+ bg = Image.new("RGB", img.size, background_color)
1610
+ bg.paste(img, mask=img.split()[3])
1611
+ img = bg
1612
+ canvas.paste(img, (x_offset, y_offset))
1613
+ x_offset += img.width + gap
1614
+
1615
+ return canvas
1616
+
1617
+ def _grid_images(
1618
+ self,
1619
+ images: List[Image.Image],
1620
+ columns: Optional[int],
1621
+ gap: int,
1622
+ background_color: Tuple[int, int, int],
1623
+ ) -> Image.Image:
1624
+ """Arrange images in a grid."""
1625
+ n_images = len(images)
1626
+
1627
+ # Determine grid dimensions
1628
+ if columns is None:
1629
+ # Auto-calculate columns for roughly square grid
1630
+ columns = int(n_images**0.5)
1631
+ if columns * columns < n_images:
1632
+ columns += 1
1633
+
1634
+ rows = (n_images + columns - 1) // columns # Ceiling division
1635
+
1636
+ # Get max dimensions for cells
1637
+ max_width = max(img.width for img in images)
1638
+ max_height = max(img.height for img in images)
1639
+
1640
+ # Calculate canvas size
1641
+ canvas_width = columns * max_width + (columns - 1) * gap
1642
+ canvas_height = rows * max_height + (rows - 1) * gap
1643
+
1644
+ # Create canvas
1645
+ canvas = Image.new("RGB", (canvas_width, canvas_height), background_color)
1646
+
1647
+ # Place images
1648
+ for idx, img in enumerate(images):
1649
+ row = idx // columns
1650
+ col = idx % columns
1651
+
1652
+ # Calculate position (centered in cell)
1653
+ cell_x = col * (max_width + gap)
1654
+ cell_y = row * (max_height + gap)
1655
+ x_offset = cell_x + (max_width - img.width) // 2
1656
+ y_offset = cell_y + (max_height - img.height) // 2
1657
+
1658
+ # Convert RGBA to RGB if needed
1659
+ if img.mode == "RGBA":
1660
+ bg = Image.new("RGB", img.size, background_color)
1661
+ bg.paste(img, mask=img.split()[3])
1662
+ img = bg
1663
+
1664
+ canvas.paste(img, (x_offset, y_offset))
1665
+
1666
+ return canvas