natural-pdf 0.1.38__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- natural_pdf/__init__.py +11 -6
- natural_pdf/analyzers/__init__.py +6 -1
- natural_pdf/analyzers/guides.py +354 -258
- natural_pdf/analyzers/layout/layout_analyzer.py +2 -3
- natural_pdf/analyzers/layout/layout_manager.py +18 -4
- natural_pdf/analyzers/layout/paddle.py +11 -0
- natural_pdf/analyzers/layout/surya.py +2 -3
- natural_pdf/analyzers/shape_detection_mixin.py +25 -34
- natural_pdf/analyzers/text_structure.py +2 -2
- natural_pdf/classification/manager.py +1 -1
- natural_pdf/collections/mixins.py +3 -2
- natural_pdf/core/highlighting_service.py +743 -32
- natural_pdf/core/page.py +252 -399
- natural_pdf/core/page_collection.py +1249 -0
- natural_pdf/core/pdf.py +231 -89
- natural_pdf/{collections → core}/pdf_collection.py +18 -11
- natural_pdf/core/render_spec.py +335 -0
- natural_pdf/describe/base.py +1 -1
- natural_pdf/elements/__init__.py +1 -0
- natural_pdf/elements/base.py +108 -83
- natural_pdf/elements/{collections.py → element_collection.py} +575 -1372
- natural_pdf/elements/line.py +0 -1
- natural_pdf/elements/rect.py +0 -1
- natural_pdf/elements/region.py +405 -280
- natural_pdf/elements/text.py +9 -7
- natural_pdf/exporters/base.py +2 -2
- natural_pdf/exporters/original_pdf.py +1 -1
- natural_pdf/exporters/paddleocr.py +2 -4
- natural_pdf/exporters/searchable_pdf.py +3 -2
- natural_pdf/extraction/mixin.py +1 -3
- natural_pdf/flows/collections.py +1 -69
- natural_pdf/flows/element.py +25 -0
- natural_pdf/flows/flow.py +1658 -19
- natural_pdf/flows/region.py +757 -263
- natural_pdf/ocr/ocr_options.py +0 -2
- natural_pdf/ocr/utils.py +2 -1
- natural_pdf/qa/document_qa.py +21 -5
- natural_pdf/search/search_service_protocol.py +1 -1
- natural_pdf/selectors/parser.py +35 -2
- natural_pdf/tables/result.py +35 -1
- natural_pdf/text_mixin.py +101 -0
- natural_pdf/utils/debug.py +2 -1
- natural_pdf/utils/highlighting.py +1 -0
- natural_pdf/utils/layout.py +2 -2
- natural_pdf/utils/packaging.py +4 -3
- natural_pdf/utils/text_extraction.py +15 -12
- natural_pdf/utils/visualization.py +385 -0
- {natural_pdf-0.1.38.dist-info → natural_pdf-0.2.0.dist-info}/METADATA +7 -3
- {natural_pdf-0.1.38.dist-info → natural_pdf-0.2.0.dist-info}/RECORD +55 -52
- optimization/memory_comparison.py +1 -1
- optimization/pdf_analyzer.py +2 -2
- {natural_pdf-0.1.38.dist-info → natural_pdf-0.2.0.dist-info}/WHEEL +0 -0
- {natural_pdf-0.1.38.dist-info → natural_pdf-0.2.0.dist-info}/entry_points.txt +0 -0
- {natural_pdf-0.1.38.dist-info → natural_pdf-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {natural_pdf-0.1.38.dist-info → natural_pdf-0.2.0.dist-info}/top_level.txt +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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
537
|
-
for attr_name in
|
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
|
-
#
|
751
|
-
|
880
|
+
# Check if we have quantitative metadata (for colorbar)
|
881
|
+
quantitative_metadata = None
|
752
882
|
for hl in highlights_on_page:
|
753
|
-
if hl
|
754
|
-
|
883
|
+
if hasattr(hl, "quantitative_metadata") and hl.quantitative_metadata:
|
884
|
+
quantitative_metadata = hl.quantitative_metadata
|
885
|
+
break
|
755
886
|
|
756
|
-
if
|
757
|
-
|
758
|
-
|
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,
|
901
|
+
rendered_image, colorbar, legend_position
|
761
902
|
)
|
762
903
|
logger.debug(
|
763
|
-
f"Added
|
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"
|
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
|
-
|
879
|
-
if element and
|
880
|
-
for attr_name in
|
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
|
-
|
938
|
-
|
939
|
-
|
940
|
-
|
941
|
-
|
942
|
-
|
943
|
-
|
944
|
-
|
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
|