natural-pdf 25.3.16__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 (109) hide show
  1. examples/__init__.py +3 -0
  2. examples/another_exclusion_example.py +20 -0
  3. examples/basic_usage.py +190 -0
  4. examples/boundary_exclusion_test.py +137 -0
  5. examples/boundary_inclusion_fix_test.py +157 -0
  6. examples/chainable_layout_example.py +70 -0
  7. examples/color_basic_test.py +49 -0
  8. examples/color_name_example.py +71 -0
  9. examples/color_test.py +62 -0
  10. examples/debug_ocr.py +91 -0
  11. examples/direct_ocr_test.py +148 -0
  12. examples/direct_paddle_test.py +99 -0
  13. examples/direct_qa_example.py +165 -0
  14. examples/document_layout_analysis.py +123 -0
  15. examples/document_qa_example.py +185 -0
  16. examples/exclusion_count_debug.py +128 -0
  17. examples/exclusion_debug.py +107 -0
  18. examples/exclusion_example.py +150 -0
  19. examples/exclusion_optimization_example.py +190 -0
  20. examples/extract_text_test.py +128 -0
  21. examples/font_aware_example.py +101 -0
  22. examples/font_variant_example.py +124 -0
  23. examples/footer_overlap_test.py +124 -0
  24. examples/highlight_all_example.py +82 -0
  25. examples/highlight_attributes_test.py +114 -0
  26. examples/highlight_confidence_display.py +122 -0
  27. examples/highlight_demo.py +110 -0
  28. examples/highlight_float_test.py +71 -0
  29. examples/highlight_test.py +147 -0
  30. examples/highlighting_example.py +123 -0
  31. examples/image_width_example.py +84 -0
  32. examples/improved_api_example.py +128 -0
  33. examples/layout_confidence_display_test.py +65 -0
  34. examples/layout_confidence_test.py +82 -0
  35. examples/layout_coordinate_debug.py +258 -0
  36. examples/layout_highlight_test.py +77 -0
  37. examples/logging_example.py +70 -0
  38. examples/ocr_comprehensive.py +193 -0
  39. examples/ocr_debug_example.py +87 -0
  40. examples/ocr_default_test.py +97 -0
  41. examples/ocr_engine_comparison.py +235 -0
  42. examples/ocr_example.py +89 -0
  43. examples/ocr_simplified_params.py +79 -0
  44. examples/ocr_visualization.py +102 -0
  45. examples/ocr_visualization_test.py +121 -0
  46. examples/paddle_layout_example.py +315 -0
  47. examples/paddle_layout_simple.py +74 -0
  48. examples/paddleocr_example.py +224 -0
  49. examples/page_collection_example.py +103 -0
  50. examples/polygon_highlight_example.py +83 -0
  51. examples/position_methods_example.py +134 -0
  52. examples/region_boundary_test.py +73 -0
  53. examples/region_exclusion_test.py +149 -0
  54. examples/region_expand_example.py +109 -0
  55. examples/region_image_example.py +116 -0
  56. examples/region_ocr_test.py +119 -0
  57. examples/region_sections_example.py +115 -0
  58. examples/school_books.py +49 -0
  59. examples/school_books_all.py +52 -0
  60. examples/scouring.py +36 -0
  61. examples/section_extraction_example.py +232 -0
  62. examples/simple_document_qa.py +97 -0
  63. examples/spatial_navigation_example.py +108 -0
  64. examples/table_extraction_example.py +135 -0
  65. examples/table_structure_detection.py +155 -0
  66. examples/tatr_cells_test.py +56 -0
  67. examples/tatr_ocr_table_test.py +94 -0
  68. examples/text_search_example.py +122 -0
  69. examples/text_style_example.py +110 -0
  70. examples/tiny-text.py +61 -0
  71. examples/until_boundaries_example.py +156 -0
  72. examples/until_example.py +112 -0
  73. examples/very_basics.py +15 -0
  74. natural_pdf/__init__.py +55 -0
  75. natural_pdf/analyzers/__init__.py +9 -0
  76. natural_pdf/analyzers/document_layout.py +736 -0
  77. natural_pdf/analyzers/text_structure.py +153 -0
  78. natural_pdf/core/__init__.py +3 -0
  79. natural_pdf/core/page.py +2376 -0
  80. natural_pdf/core/pdf.py +572 -0
  81. natural_pdf/elements/__init__.py +3 -0
  82. natural_pdf/elements/base.py +553 -0
  83. natural_pdf/elements/collections.py +770 -0
  84. natural_pdf/elements/line.py +124 -0
  85. natural_pdf/elements/rect.py +122 -0
  86. natural_pdf/elements/region.py +1366 -0
  87. natural_pdf/elements/text.py +304 -0
  88. natural_pdf/ocr/__init__.py +62 -0
  89. natural_pdf/ocr/easyocr_engine.py +254 -0
  90. natural_pdf/ocr/engine.py +158 -0
  91. natural_pdf/ocr/paddleocr_engine.py +263 -0
  92. natural_pdf/qa/__init__.py +3 -0
  93. natural_pdf/qa/document_qa.py +405 -0
  94. natural_pdf/selectors/__init__.py +4 -0
  95. natural_pdf/selectors/parser.py +360 -0
  96. natural_pdf/templates/__init__.py +1 -0
  97. natural_pdf/templates/ocr_debug.html +517 -0
  98. natural_pdf/utils/__init__.py +4 -0
  99. natural_pdf/utils/highlighting.py +605 -0
  100. natural_pdf/utils/ocr.py +515 -0
  101. natural_pdf/utils/reading_order.py +227 -0
  102. natural_pdf/utils/visualization.py +151 -0
  103. natural_pdf-25.3.16.dist-info/LICENSE +21 -0
  104. natural_pdf-25.3.16.dist-info/METADATA +268 -0
  105. natural_pdf-25.3.16.dist-info/RECORD +109 -0
  106. natural_pdf-25.3.16.dist-info/WHEEL +5 -0
  107. natural_pdf-25.3.16.dist-info/top_level.txt +3 -0
  108. tests/__init__.py +3 -0
  109. tests/test_pdf.py +39 -0
@@ -0,0 +1,605 @@
1
+ """
2
+ Highlighting utilities for natural-pdf.
3
+ """
4
+ from typing import List, Dict, Tuple, Optional, Union, Any, Set
5
+ from PIL import Image, ImageDraw, ImageFont
6
+ import io
7
+ import math
8
+ import os
9
+ from .visualization import get_next_highlight_color, create_legend, merge_images_with_legend, reset_highlight_colors
10
+
11
+ class Highlight:
12
+ """
13
+ Represents a single highlight with color and optional label.
14
+ """
15
+ def __init__(self,
16
+ bbox: Tuple[float, float, float, float],
17
+ color: Tuple[int, int, int, int],
18
+ label: Optional[str] = None,
19
+ polygon: Optional[List[Tuple[float, float]]] = None):
20
+ """
21
+ Initialize a highlight.
22
+
23
+ Args:
24
+ bbox: Bounding box (x0, top, x1, bottom)
25
+ color: RGBA color tuple (0-255 integers)
26
+ label: Optional label for this highlight
27
+ polygon: Optional polygon points for non-rectangular highlights
28
+ """
29
+ self.bbox = bbox
30
+ self.polygon = polygon
31
+
32
+ # Ensure color values are integers in 0-255 range
33
+ if isinstance(color, tuple):
34
+ # Convert values to integers in 0-255 range
35
+ processed_color = []
36
+ for i, c in enumerate(color):
37
+ if isinstance(c, float):
38
+ # 0.0-1.0 float format
39
+ if c <= 1.0:
40
+ processed_color.append(int(c * 255))
41
+ # Already in 0-255 range but as float
42
+ else:
43
+ processed_color.append(int(c))
44
+ else:
45
+ processed_color.append(c)
46
+
47
+ # Default alpha value if needed
48
+ if len(processed_color) == 3:
49
+ processed_color.append(100) # Default alpha
50
+
51
+ self.color = tuple(processed_color)
52
+ else:
53
+ # Default if invalid color is provided
54
+ self.color = (255, 255, 0, 100) # Yellow with semi-transparency
55
+
56
+ self.label = label
57
+
58
+ # New attributes for displaying element properties
59
+ self.element = None # Will be set after initialization
60
+ self.include_attrs = None # Will be set after initialization
61
+
62
+ @property
63
+ def is_polygon(self) -> bool:
64
+ """Check if this highlight uses polygon coordinates."""
65
+ return self.polygon is not None and len(self.polygon) >= 3
66
+
67
+ def __repr__(self) -> str:
68
+ """String representation of the highlight."""
69
+ if self.is_polygon:
70
+ prefix = "PolygonHighlight"
71
+ else:
72
+ prefix = "Highlight"
73
+
74
+ if self.label:
75
+ return f"<{prefix} bbox={self.bbox} label='{self.label}'>"
76
+ else:
77
+ return f"<{prefix} bbox={self.bbox}>"
78
+
79
+ class HighlightManager:
80
+ """
81
+ Manages highlights for a page.
82
+ """
83
+ def __init__(self, page: 'Page'):
84
+ """
85
+ Initialize a highlight manager.
86
+
87
+ Args:
88
+ page: The page to manage highlights for
89
+ """
90
+ self._page = page
91
+ self._highlights: List[Highlight] = []
92
+ self._labels_colors: Dict[str, Tuple[int, int, int, int]] = {}
93
+
94
+ def add_polygon_highlight(self,
95
+ polygon: List[Tuple[float, float]],
96
+ color: Optional[Tuple[Union[int, float], Union[int, float], Union[int, float], Union[int, float]]] = None,
97
+ label: Optional[str] = None,
98
+ cycle_colors: bool = False,
99
+ element: Optional[Any] = None,
100
+ include_attrs: Optional[List[str]] = None,
101
+ existing: str = 'append') -> None:
102
+ """
103
+ Add a polygon highlight to the page.
104
+
105
+ Args:
106
+ polygon: List of (x, y) coordinate tuples defining the polygon
107
+ color: RGBA color tuple (0-255 or 0.0-1.0), or None to use automatic color
108
+ label: Optional label for this highlight
109
+ cycle_colors: Force color cycling even with no label (default: False)
110
+ element: The original element being highlighted (for attribute access)
111
+ include_attrs: List of attribute names to display on the highlight (e.g., ['confidence', 'type'])
112
+ existing: How to handle existing highlights - 'append' (default) or 'replace'
113
+ """
114
+ # Calculate bounding box from polygon
115
+ if polygon:
116
+ x_coords = [p[0] for p in polygon]
117
+ y_coords = [p[1] for p in polygon]
118
+ bbox = (min(x_coords), min(y_coords), max(x_coords), max(y_coords))
119
+ else:
120
+ # If invalid polygon, use dummy bbox
121
+ bbox = (0, 0, 1, 1)
122
+
123
+ # Get appropriate color
124
+ final_color = self._get_highlight_color(color, label, cycle_colors)
125
+
126
+ # Clear existing highlights if replacing
127
+ if existing == 'replace':
128
+ self.clear_highlights()
129
+
130
+ # Create highlight with polygon
131
+ highlight = Highlight(bbox, final_color, label, polygon)
132
+
133
+ # Add element and attrs to the highlight
134
+ highlight.element = element
135
+
136
+ # No automatic display - only show attributes if explicitly requested
137
+ highlight.include_attrs = include_attrs
138
+
139
+ self._highlights.append(highlight)
140
+
141
+ def add_highlight(self,
142
+ bbox: Tuple[float, float, float, float],
143
+ color: Optional[Tuple[Union[int, float], Union[int, float], Union[int, float], Union[int, float]]] = None,
144
+ label: Optional[str] = None,
145
+ cycle_colors: bool = False,
146
+ element: Optional[Any] = None,
147
+ include_attrs: Optional[List[str]] = None,
148
+ existing: str = 'append') -> None:
149
+ """
150
+ Add a highlight to the page.
151
+
152
+ Args:
153
+ bbox: Bounding box (x0, top, x1, bottom)
154
+ color: RGBA color tuple (0-255 or 0.0-1.0), or None to use automatic color
155
+ label: Optional label for this highlight
156
+ cycle_colors: Force color cycling even with no label (default: False)
157
+ element: The original element being highlighted (for attribute access)
158
+ include_attrs: List of attribute names to display on the highlight (e.g., ['confidence', 'type'])
159
+ existing: How to handle existing highlights - 'append' (default) or 'replace'
160
+ """
161
+ # Get appropriate color
162
+ final_color = self._get_highlight_color(color, label, cycle_colors)
163
+
164
+ # Clear existing highlights if replacing
165
+ if existing == 'replace':
166
+ self.clear_highlights()
167
+
168
+ # Create highlight
169
+ highlight = Highlight(bbox, final_color, label)
170
+
171
+ # Add element and attrs to the highlight
172
+ highlight.element = element
173
+
174
+ # No automatic display - only show attributes if explicitly requested
175
+ highlight.include_attrs = include_attrs
176
+
177
+ self._highlights.append(highlight)
178
+
179
+ def _get_highlight_color(self,
180
+ color: Optional[Tuple[Union[int, float], Union[int, float], Union[int, float], Union[int, float]]] = None,
181
+ label: Optional[str] = None,
182
+ cycle_colors: bool = False) -> Tuple[int, int, int, int]:
183
+ """
184
+ Determine the appropriate color for a highlight based on rules.
185
+
186
+ Args:
187
+ color: RGBA color tuple (0-255 or 0.0-1.0), or None to use automatic color
188
+ label: Optional label for this highlight
189
+ cycle_colors: Force color cycling even with no label (default: False)
190
+
191
+ Returns:
192
+ RGBA color tuple with values as integers 0-255
193
+ """
194
+ # Color selection logic:
195
+ # 1. If explicit color is provided, use it
196
+ # 2. If label exists in color map, use that color (consistency)
197
+ # 3. If label is provided but new, get a new color and store it
198
+ # 4. If no label & cycle_colors=True, get next color
199
+ # 5. If no label & cycle_colors=False, use default yellow highlight
200
+
201
+ if color is not None:
202
+ # Explicit color takes precedence
203
+ # Convert from 0.0-1.0 to 0-255 if needed
204
+ if isinstance(color[0], float) and color[0] <= 1.0:
205
+ highlight_color = (
206
+ int(color[0] * 255),
207
+ int(color[1] * 255),
208
+ int(color[2] * 255),
209
+ int(color[3] * 255) if len(color) > 3 else 100
210
+ )
211
+ else:
212
+ highlight_color = color
213
+ elif label is not None and label in self._labels_colors:
214
+ # Use existing color for this label
215
+ highlight_color = self._labels_colors[label]
216
+ elif label is not None:
217
+ # New label, get a new color and store it
218
+ highlight_color = get_next_highlight_color()
219
+ self._labels_colors[label] = highlight_color
220
+ elif cycle_colors:
221
+ # No label but cycling requested
222
+ highlight_color = get_next_highlight_color()
223
+ else:
224
+ # Default case: no label, no cycling - use yellow
225
+ highlight_color = (255, 255, 0, 100) # Default yellow highlight
226
+
227
+ return highlight_color
228
+
229
+ def clear_highlights(self) -> None:
230
+ """Clear all highlights."""
231
+ self._highlights = []
232
+ self._labels_colors = {}
233
+ reset_highlight_colors()
234
+
235
+ def get_highlighted_image(self,
236
+ scale: float = 2.0,
237
+ labels: bool = True,
238
+ legend_position: str = 'right',
239
+ render_ocr: bool = False) -> Image.Image:
240
+ """
241
+ Get an image of the page with highlights.
242
+
243
+ Args:
244
+ scale: Scale factor for rendering
245
+ labels: Whether to include a legend for labels
246
+ legend_position: Position of the legend ('right', 'bottom', 'top', 'left')
247
+ render_ocr: Whether to render OCR text with white background boxes
248
+
249
+ Returns:
250
+ PIL Image with the highlighted page
251
+ """
252
+ # Get the raw page image with higher resolution for clearer results
253
+ page_image = self._page._page.to_image(resolution=72 * scale)
254
+
255
+ # Convert to PIL Image in RGBA mode for transparency
256
+ img_bytes = io.BytesIO()
257
+ page_image.save(img_bytes, format='PNG')
258
+ img_bytes.seek(0)
259
+ pil_image = Image.open(img_bytes).convert('RGBA')
260
+
261
+ # Create a transparent overlay
262
+ overlay = Image.new('RGBA', pil_image.size, (0, 0, 0, 0))
263
+ draw = ImageDraw.Draw(overlay)
264
+
265
+ # Draw each highlight
266
+ for highlight in self._highlights:
267
+ if highlight.is_polygon:
268
+ # Scale polygon coordinates
269
+ scaled_polygon = [(p[0] * scale, p[1] * scale) for p in highlight.polygon]
270
+
271
+ # Draw polygon with the highlight color
272
+ draw.polygon(scaled_polygon, fill=highlight.color)
273
+
274
+ # Draw attribute text if requested
275
+ if highlight.element and highlight.include_attrs:
276
+ # Calculate bounding box from polygon for text positioning
277
+ x_coords = [p[0] for p in scaled_polygon]
278
+ y_coords = [p[1] for p in scaled_polygon]
279
+ bbox_scaled = [min(x_coords), min(y_coords), max(x_coords), max(y_coords)]
280
+
281
+ self._draw_element_attributes(
282
+ draw,
283
+ highlight.element,
284
+ highlight.include_attrs,
285
+ bbox_scaled,
286
+ scale
287
+ )
288
+ else:
289
+ # Regular rectangle highlight
290
+ x0, top, x1, bottom = highlight.bbox
291
+
292
+ # Scale the bbox to match the rendered image
293
+ x0_scaled = x0 * scale
294
+ top_scaled = top * scale
295
+ x1_scaled = x1 * scale
296
+ bottom_scaled = bottom * scale
297
+
298
+ # Draw the rectangle
299
+ draw.rectangle([x0_scaled, top_scaled, x1_scaled, bottom_scaled],
300
+ fill=highlight.color)
301
+
302
+ # Draw attribute text if requested
303
+ if highlight.element and highlight.include_attrs:
304
+ self._draw_element_attributes(
305
+ draw,
306
+ highlight.element,
307
+ highlight.include_attrs,
308
+ [x0_scaled, top_scaled, x1_scaled, bottom_scaled],
309
+ scale
310
+ )
311
+
312
+ # Combine the original image with the highlight overlay
313
+ highlighted_image = Image.alpha_composite(pil_image, overlay)
314
+
315
+ # Add OCR text rendering if requested
316
+ if render_ocr:
317
+ highlighted_image = self._render_ocr_text(highlighted_image, scale)
318
+
319
+ # Add legend if requested and there are labeled highlights
320
+ if labels and self._labels_colors:
321
+ legend = create_legend(self._labels_colors)
322
+ highlighted_image = merge_images_with_legend(highlighted_image, legend, legend_position)
323
+
324
+ return highlighted_image
325
+
326
+ def _render_ocr_text(self, image: Image.Image, scale: float = 2.0) -> Image.Image:
327
+ """
328
+ Render OCR text on the image with white background boxes.
329
+
330
+ Args:
331
+ image: Base image to render text on
332
+ scale: Scale factor for rendering
333
+
334
+ Returns:
335
+ PIL Image with OCR text rendered
336
+ """
337
+ # First check for OCR text elements from the selector approach
338
+ ocr_elements = self._page.find_all('text[source=ocr]')
339
+
340
+ # If that doesn't work, try checking the _elements dict directly
341
+ if not ocr_elements or len(ocr_elements) == 0:
342
+ # Check if page has elements loaded
343
+ if hasattr(self._page, '_elements') and self._page._elements is not None:
344
+ # Look for OCR elements in various possible locations
345
+ if 'ocr_text' in self._page._elements and self._page._elements['ocr_text']:
346
+ ocr_elements = self._page._elements['ocr_text']
347
+ elif 'ocr' in self._page._elements and self._page._elements['ocr']:
348
+ ocr_elements = self._page._elements['ocr']
349
+
350
+ # If still no elements, try to run extract_ocr_elements first
351
+ if not ocr_elements or len(ocr_elements) == 0:
352
+ try:
353
+ ocr_elements = self._page.extract_ocr_elements()
354
+ except Exception as e:
355
+ print(f"Error extracting OCR elements: {e}")
356
+
357
+ # Final check if we have OCR elements
358
+ if not ocr_elements or len(ocr_elements) == 0:
359
+ raise ValueError("No OCR elements found. Run OCR on the page before rendering.")
360
+
361
+ # Create a new overlay for OCR text
362
+ overlay = Image.new('RGBA', image.size, (0, 0, 0, 0))
363
+ draw = ImageDraw.Draw(overlay)
364
+
365
+ # Try to use a common sans-serif font
366
+ try:
367
+ font_path = None
368
+ for path in ["Arial.ttf", "DejaVuSans.ttf", "Helvetica.ttf", "FreeSans.ttf"]:
369
+ if os.path.exists(path):
370
+ font_path = path
371
+ break
372
+
373
+ if not font_path:
374
+ # Use the default font
375
+ font = ImageFont.load_default()
376
+ else:
377
+ # Use the found font
378
+ font = ImageFont.truetype(font_path, 12) # Default size, will be scaled
379
+ except Exception:
380
+ # Fallback to default font
381
+ font = ImageFont.load_default()
382
+
383
+ # Process each OCR element
384
+ for element in ocr_elements:
385
+ # Get the element's bounding box
386
+ x0, top, x1, bottom = element.bbox
387
+
388
+ # Scale the bbox to match the rendered image
389
+ x0_scaled = x0 * scale
390
+ top_scaled = top * scale
391
+ x1_scaled = x1 * scale
392
+ bottom_scaled = bottom * scale
393
+
394
+ # Calculate text size for optimal rendering
395
+ box_width = x1_scaled - x0_scaled
396
+ box_height = bottom_scaled - top_scaled
397
+
398
+ # Calculate font size based on box height (approx 90% of box height)
399
+ # Use a higher percentage to make text larger
400
+ font_size = int(box_height * 0.9)
401
+ if font_size < 9: # Higher minimum readable size
402
+ font_size = 9
403
+
404
+ # Create a font of the appropriate size
405
+ try:
406
+ if font_path:
407
+ sized_font = ImageFont.truetype(font_path, font_size)
408
+ else:
409
+ # If no truetype font, use default and scale as best as possible
410
+ sized_font = font
411
+ except Exception:
412
+ sized_font = font
413
+
414
+ # Measure text to check for overflow
415
+ try:
416
+ # Get text width with the sized font
417
+ text_width = draw.textlength(element.text, font=sized_font)
418
+
419
+ # If text is too wide, scale down font size - but only if significantly too wide
420
+ # Allow more overflow (1.25 instead of 1.1)
421
+ if text_width > box_width * 1.25:
422
+ # Less aggressive reduction
423
+ reduction_factor = box_width / text_width
424
+ # Don't shrink below 75% of original calculated size
425
+ font_size = max(10, max(int(font_size * 0.75), int(font_size * reduction_factor)))
426
+ try:
427
+ if font_path:
428
+ sized_font = ImageFont.truetype(font_path, font_size)
429
+ except Exception:
430
+ pass # Keep current font if failed
431
+ except Exception:
432
+ # If text measurement fails, continue with current font
433
+ pass
434
+
435
+ # Add padding to the white background box
436
+ padding = max(2, int(font_size * 0.1))
437
+
438
+ # Draw white background (slightly larger than text area)
439
+ draw.rectangle(
440
+ [x0_scaled - padding,
441
+ top_scaled - padding,
442
+ x1_scaled + padding,
443
+ bottom_scaled + padding],
444
+ fill=(255, 255, 255, 240) # Slightly transparent white
445
+ )
446
+
447
+ # Draw black text - centered horizontally and vertically
448
+ try:
449
+ # Calculate text dimensions for centering
450
+ if hasattr(sized_font, "getbbox"):
451
+ # PIL 9.2.0+ method
452
+ text_bbox = sized_font.getbbox(element.text)
453
+ text_width = text_bbox[2] - text_bbox[0]
454
+ text_height = text_bbox[3] - text_bbox[1]
455
+ else:
456
+ # Fallback for older PIL versions
457
+ text_width = draw.textlength(element.text, font=sized_font)
458
+ text_height = font_size # Approximate
459
+
460
+ # Center the text both horizontally and vertically
461
+ text_x = x0_scaled + (box_width - text_width) / 2
462
+ text_y = top_scaled + (box_height - text_height) / 2
463
+
464
+ # Don't let text go out of bounds
465
+ text_x = max(x0_scaled, text_x)
466
+ text_y = max(top_scaled, text_y)
467
+
468
+ except Exception:
469
+ # Fallback if calculation fails
470
+ text_x = x0_scaled
471
+ text_y = top_scaled
472
+
473
+ draw.text(
474
+ (text_x, text_y),
475
+ element.text,
476
+ fill=(0, 0, 0, 255),
477
+ font=sized_font,
478
+ )
479
+
480
+ # Combine the original image with the OCR text overlay
481
+ result_image = Image.alpha_composite(image, overlay)
482
+ return result_image
483
+
484
+ def _draw_element_attributes(self,
485
+ draw: ImageDraw.Draw,
486
+ element: Any,
487
+ attr_names: List[str],
488
+ bbox_scaled: List[float],
489
+ scale: float) -> None:
490
+ """
491
+ Draw element attributes as text on the highlight.
492
+
493
+ Args:
494
+ draw: PIL ImageDraw object to draw on
495
+ element: Element being highlighted
496
+ attr_names: List of attribute names to display
497
+ bbox_scaled: Scaled bounding box [x0, top, x1, bottom]
498
+ scale: Scale factor for rendering
499
+ """
500
+ # Try to load the font
501
+ try:
502
+ # Load a monospace font for attribute display with larger size
503
+ try:
504
+ # Try to get a default system font
505
+ # Increase font size for better readability
506
+ font_size = max(16, int(16 * scale))
507
+ font = ImageFont.truetype("DejaVuSansMono.ttf", font_size)
508
+ except Exception:
509
+ # Fall back to default PIL font
510
+ font = ImageFont.load_default()
511
+ font_size = 16
512
+ except Exception:
513
+ # If font loading fails entirely
514
+ return
515
+
516
+ # Center attributes near the top of the highlight for better visibility
517
+ # Calculate dimensions
518
+ width = bbox_scaled[2] - bbox_scaled[0]
519
+ height = bbox_scaled[3] - bbox_scaled[1]
520
+
521
+ # For confidence only, center the text horizontally
522
+ if attr_names == ['confidence']:
523
+ x = bbox_scaled[0] + (width / 2) # Will be adjusted after measuring text
524
+ else:
525
+ # Left-align attributes at the top of the highlight
526
+ x = bbox_scaled[0] + 8 # More padding from left edge
527
+
528
+ y = bbox_scaled[1] + 8 # More padding from top
529
+
530
+ # White background for better readability
531
+ background_color = (255, 255, 255, 255) # Solid white
532
+ text_color = (0, 0, 0, 255) # Black
533
+
534
+ # Process each requested attribute
535
+ attr_text_lines = []
536
+ for attr_name in attr_names:
537
+ # Try to get the attribute value
538
+ try:
539
+ attr_value = getattr(element, attr_name, None)
540
+ if attr_value is not None:
541
+ # Special formatting for confidence
542
+ if attr_name == 'confidence' and isinstance(attr_value, float):
543
+ # Just display the value without the name for confidence
544
+ attr_text = f"{attr_value:.2f}"
545
+ # Format other numeric values
546
+ elif isinstance(attr_value, float):
547
+ attr_value = f"{attr_value:.2f}"
548
+ attr_text = f"{attr_name}: {attr_value}"
549
+ # Regular formatting for other attributes
550
+ else:
551
+ attr_text = f"{attr_name}: {attr_value}"
552
+
553
+ attr_text_lines.append(attr_text)
554
+ except Exception:
555
+ continue
556
+
557
+ if not attr_text_lines:
558
+ return
559
+
560
+ # Calculate text height for background
561
+ line_height = font_size + 4 # Add more padding between lines
562
+ total_height = line_height * len(attr_text_lines)
563
+
564
+ # Special case for confidence only - center it horizontally
565
+ if attr_names == ['confidence'] and len(attr_text_lines) == 1:
566
+ text_width = draw.textlength(attr_text_lines[0], font=font)
567
+ center_x = bbox_scaled[0] + (width / 2)
568
+ x = center_x - (text_width / 2) # Center the text
569
+
570
+ # Draw a solid background for the text with more padding
571
+ bg_padding = 8 # Even more padding for confidence
572
+ bg_width = text_width + (bg_padding * 2)
573
+
574
+ # Make background box
575
+ draw.rectangle(
576
+ [x - bg_padding,
577
+ y - bg_padding,
578
+ x + text_width + bg_padding,
579
+ y + font_size + bg_padding],
580
+ fill=background_color,
581
+ outline=(0, 0, 0, 255), # Add a black outline
582
+ width=1 # 1px outline
583
+ )
584
+
585
+ # Draw centered confidence value
586
+ draw.text((x, y), attr_text_lines[0], fill=text_color, font=font)
587
+ else:
588
+ # Draw a solid background for the text with more padding
589
+ bg_padding = 6 # Increased padding
590
+ bg_width = max(draw.textlength(line, font=font) for line in attr_text_lines) + (bg_padding * 2)
591
+ draw.rectangle(
592
+ [x - bg_padding,
593
+ y - bg_padding,
594
+ x + bg_width,
595
+ y + total_height + bg_padding],
596
+ fill=background_color,
597
+ outline=(0, 0, 0, 255), # Add a black outline
598
+ width=1 # 1px outline
599
+ )
600
+
601
+ # Draw each attribute line
602
+ current_y = y
603
+ for line in attr_text_lines:
604
+ draw.text((x, current_y), line, fill=text_color, font=font)
605
+ current_y += line_height