natural-pdf 0.1.15__py3-none-any.whl → 0.1.17__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- natural_pdf/__init__.py +31 -0
- natural_pdf/analyzers/layout/gemini.py +137 -162
- natural_pdf/analyzers/layout/layout_manager.py +9 -5
- natural_pdf/analyzers/layout/layout_options.py +77 -7
- natural_pdf/analyzers/layout/paddle.py +318 -165
- natural_pdf/analyzers/layout/table_structure_utils.py +78 -0
- natural_pdf/analyzers/shape_detection_mixin.py +770 -405
- natural_pdf/classification/mixin.py +2 -8
- natural_pdf/collections/pdf_collection.py +25 -30
- natural_pdf/core/highlighting_service.py +47 -32
- natural_pdf/core/page.py +119 -76
- natural_pdf/core/pdf.py +19 -22
- natural_pdf/describe/__init__.py +21 -0
- natural_pdf/describe/base.py +457 -0
- natural_pdf/describe/elements.py +411 -0
- natural_pdf/describe/mixin.py +84 -0
- natural_pdf/describe/summary.py +186 -0
- natural_pdf/elements/base.py +11 -10
- natural_pdf/elements/collections.py +116 -51
- natural_pdf/elements/region.py +204 -127
- natural_pdf/exporters/paddleocr.py +38 -13
- natural_pdf/flows/__init__.py +3 -3
- natural_pdf/flows/collections.py +303 -132
- natural_pdf/flows/element.py +277 -132
- natural_pdf/flows/flow.py +33 -16
- natural_pdf/flows/region.py +142 -79
- natural_pdf/ocr/engine_doctr.py +37 -4
- natural_pdf/ocr/engine_easyocr.py +23 -3
- natural_pdf/ocr/engine_paddle.py +281 -30
- natural_pdf/ocr/engine_surya.py +8 -3
- natural_pdf/ocr/ocr_manager.py +75 -76
- natural_pdf/ocr/ocr_options.py +52 -87
- natural_pdf/search/__init__.py +25 -12
- natural_pdf/search/lancedb_search_service.py +91 -54
- natural_pdf/search/numpy_search_service.py +86 -65
- natural_pdf/search/searchable_mixin.py +2 -2
- natural_pdf/selectors/parser.py +125 -81
- natural_pdf/widgets/__init__.py +1 -1
- natural_pdf/widgets/viewer.py +205 -449
- {natural_pdf-0.1.15.dist-info → natural_pdf-0.1.17.dist-info}/METADATA +27 -45
- {natural_pdf-0.1.15.dist-info → natural_pdf-0.1.17.dist-info}/RECORD +44 -38
- {natural_pdf-0.1.15.dist-info → natural_pdf-0.1.17.dist-info}/WHEEL +0 -0
- {natural_pdf-0.1.15.dist-info → natural_pdf-0.1.17.dist-info}/licenses/LICENSE +0 -0
- {natural_pdf-0.1.15.dist-info → natural_pdf-0.1.17.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,411 @@
|
|
1
|
+
"""
|
2
|
+
Element-specific describe functions.
|
3
|
+
"""
|
4
|
+
|
5
|
+
import logging
|
6
|
+
from collections import Counter
|
7
|
+
from typing import TYPE_CHECKING, Any, Dict, List
|
8
|
+
|
9
|
+
if TYPE_CHECKING:
|
10
|
+
from natural_pdf.elements.base import Element
|
11
|
+
|
12
|
+
logger = logging.getLogger(__name__)
|
13
|
+
|
14
|
+
|
15
|
+
def describe_text_elements(elements: List["Element"]) -> Dict[str, Any]:
|
16
|
+
"""
|
17
|
+
Describe text elements with typography and OCR analysis.
|
18
|
+
|
19
|
+
Args:
|
20
|
+
elements: List of text elements
|
21
|
+
|
22
|
+
Returns:
|
23
|
+
Dictionary with text analysis sections
|
24
|
+
"""
|
25
|
+
if not elements:
|
26
|
+
return {"message": "No text elements found"}
|
27
|
+
|
28
|
+
result = {}
|
29
|
+
|
30
|
+
# Source breakdown
|
31
|
+
sources = Counter()
|
32
|
+
ocr_elements = []
|
33
|
+
|
34
|
+
for element in elements:
|
35
|
+
source = getattr(element, 'source', 'unknown')
|
36
|
+
sources[source] += 1
|
37
|
+
if source == 'ocr':
|
38
|
+
ocr_elements.append(element)
|
39
|
+
|
40
|
+
if len(sources) > 1:
|
41
|
+
result['sources'] = dict(sources)
|
42
|
+
|
43
|
+
# Typography analysis
|
44
|
+
typography = _analyze_typography(elements)
|
45
|
+
if typography:
|
46
|
+
result['typography'] = typography
|
47
|
+
|
48
|
+
# OCR quality analysis
|
49
|
+
if ocr_elements:
|
50
|
+
ocr_quality = _analyze_ocr_quality(ocr_elements)
|
51
|
+
if ocr_quality:
|
52
|
+
result['ocr_quality'] = ocr_quality
|
53
|
+
|
54
|
+
return result
|
55
|
+
|
56
|
+
|
57
|
+
def describe_rect_elements(elements: List["Element"]) -> Dict[str, Any]:
|
58
|
+
"""
|
59
|
+
Describe rectangle elements with size and style analysis.
|
60
|
+
|
61
|
+
Args:
|
62
|
+
elements: List of rectangle elements
|
63
|
+
|
64
|
+
Returns:
|
65
|
+
Dictionary with rectangle analysis
|
66
|
+
"""
|
67
|
+
if not elements:
|
68
|
+
return {"message": "No rectangle elements found"}
|
69
|
+
|
70
|
+
result = {}
|
71
|
+
|
72
|
+
# Size analysis
|
73
|
+
sizes = []
|
74
|
+
stroke_count = 0
|
75
|
+
fill_count = 0
|
76
|
+
colors = Counter()
|
77
|
+
stroke_widths = []
|
78
|
+
|
79
|
+
for element in elements:
|
80
|
+
# Size
|
81
|
+
width = getattr(element, 'width', 0)
|
82
|
+
height = getattr(element, 'height', 0)
|
83
|
+
if width and height:
|
84
|
+
sizes.append((width, height))
|
85
|
+
|
86
|
+
# Style properties - use RectangleElement properties
|
87
|
+
stroke = getattr(element, 'stroke', None)
|
88
|
+
if stroke and stroke != (0, 0, 0): # Check if stroke color exists and isn't black
|
89
|
+
stroke_count += 1
|
90
|
+
fill = getattr(element, 'fill', None)
|
91
|
+
if fill and fill != (0, 0, 0): # Check if fill color exists and isn't black
|
92
|
+
fill_count += 1
|
93
|
+
|
94
|
+
# Stroke width
|
95
|
+
stroke_width = getattr(element, 'stroke_width', 0)
|
96
|
+
if stroke_width > 0:
|
97
|
+
stroke_widths.append(stroke_width)
|
98
|
+
|
99
|
+
# Color - use the element's stroke/fill properties
|
100
|
+
color = stroke or fill
|
101
|
+
if color:
|
102
|
+
if isinstance(color, (tuple, list)):
|
103
|
+
if color == (0, 0, 0) or color == (0.0, 0.0, 0.0):
|
104
|
+
colors['black'] += 1
|
105
|
+
elif color == (1, 1, 1) or color == (1.0, 1.0, 1.0):
|
106
|
+
colors['white'] += 1
|
107
|
+
else:
|
108
|
+
colors[str(color)] += 1
|
109
|
+
else:
|
110
|
+
colors[str(color)] += 1
|
111
|
+
|
112
|
+
# Size statistics
|
113
|
+
if sizes:
|
114
|
+
widths = [s[0] for s in sizes]
|
115
|
+
heights = [s[1] for s in sizes]
|
116
|
+
result['size_stats'] = {
|
117
|
+
'width_range': f"{min(widths):.0f}-{max(widths):.0f}",
|
118
|
+
'height_range': f"{min(heights):.0f}-{max(heights):.0f}",
|
119
|
+
'avg_area': f"{sum(w*h for w,h in sizes)/len(sizes):.0f} sq pts"
|
120
|
+
}
|
121
|
+
|
122
|
+
# Style breakdown
|
123
|
+
style_info = {}
|
124
|
+
if stroke_count:
|
125
|
+
style_info['stroke'] = stroke_count
|
126
|
+
if fill_count:
|
127
|
+
style_info['fill'] = fill_count
|
128
|
+
if stroke_widths:
|
129
|
+
stroke_width_counts = Counter(stroke_widths)
|
130
|
+
# Convert float keys to strings to avoid formatting issues
|
131
|
+
stroke_width_dict = {str(k): v for k, v in stroke_width_counts.most_common()}
|
132
|
+
style_info['stroke_widths'] = stroke_width_dict
|
133
|
+
if colors:
|
134
|
+
style_info['colors'] = dict(colors.most_common(5))
|
135
|
+
|
136
|
+
if style_info:
|
137
|
+
result['styles'] = style_info
|
138
|
+
|
139
|
+
return result
|
140
|
+
|
141
|
+
|
142
|
+
def describe_line_elements(elements: List["Element"]) -> Dict[str, Any]:
|
143
|
+
"""
|
144
|
+
Describe line elements with length and style analysis.
|
145
|
+
|
146
|
+
Args:
|
147
|
+
elements: List of line elements
|
148
|
+
|
149
|
+
Returns:
|
150
|
+
Dictionary with line analysis
|
151
|
+
"""
|
152
|
+
if not elements:
|
153
|
+
return {"message": "No line elements found"}
|
154
|
+
|
155
|
+
result = {}
|
156
|
+
|
157
|
+
lengths = []
|
158
|
+
widths = []
|
159
|
+
colors = Counter()
|
160
|
+
|
161
|
+
for element in elements:
|
162
|
+
# Calculate length
|
163
|
+
x0 = getattr(element, 'x0', 0)
|
164
|
+
y0 = getattr(element, 'top', 0)
|
165
|
+
x1 = getattr(element, 'x1', 0)
|
166
|
+
y1 = getattr(element, 'bottom', 0)
|
167
|
+
|
168
|
+
length = ((x1 - x0) ** 2 + (y1 - y0) ** 2) ** 0.5
|
169
|
+
if length > 0:
|
170
|
+
lengths.append(length)
|
171
|
+
|
172
|
+
# Line width - use the element's width property
|
173
|
+
width = getattr(element, 'width', 0) # LineElement has a width property
|
174
|
+
if width:
|
175
|
+
widths.append(width)
|
176
|
+
|
177
|
+
# Color - use the element's color property
|
178
|
+
color = getattr(element, 'color', None) # LineElement has a color property
|
179
|
+
if color:
|
180
|
+
if isinstance(color, (tuple, list)):
|
181
|
+
if color == (0, 0, 0) or color == (0.0, 0.0, 0.0):
|
182
|
+
colors['black'] += 1
|
183
|
+
else:
|
184
|
+
colors[str(color)] += 1
|
185
|
+
else:
|
186
|
+
colors[str(color)] += 1
|
187
|
+
|
188
|
+
# Length statistics
|
189
|
+
if lengths:
|
190
|
+
result['length_stats'] = {
|
191
|
+
'min': f"{min(lengths):.0f}",
|
192
|
+
'max': f"{max(lengths):.0f}",
|
193
|
+
'avg': f"{sum(lengths)/len(lengths):.0f}"
|
194
|
+
}
|
195
|
+
|
196
|
+
# Width statistics
|
197
|
+
if widths:
|
198
|
+
width_counts = Counter(widths)
|
199
|
+
# Convert float keys to strings to avoid formatting issues
|
200
|
+
result['line_widths'] = {str(k): v for k, v in width_counts.most_common()}
|
201
|
+
|
202
|
+
# Orientation analysis
|
203
|
+
horizontal_count = sum(1 for el in elements if getattr(el, 'is_horizontal', False))
|
204
|
+
vertical_count = sum(1 for el in elements if getattr(el, 'is_vertical', False))
|
205
|
+
diagonal_count = len(elements) - horizontal_count - vertical_count
|
206
|
+
|
207
|
+
if horizontal_count or vertical_count or diagonal_count:
|
208
|
+
orientation_info = {}
|
209
|
+
if horizontal_count:
|
210
|
+
orientation_info['horizontal'] = horizontal_count
|
211
|
+
if vertical_count:
|
212
|
+
orientation_info['vertical'] = vertical_count
|
213
|
+
if diagonal_count:
|
214
|
+
orientation_info['diagonal'] = diagonal_count
|
215
|
+
result['orientations'] = orientation_info
|
216
|
+
|
217
|
+
# Colors
|
218
|
+
if colors:
|
219
|
+
result['colors'] = dict(colors.most_common())
|
220
|
+
|
221
|
+
return result
|
222
|
+
|
223
|
+
|
224
|
+
def describe_region_elements(elements: List["Element"]) -> Dict[str, Any]:
|
225
|
+
"""
|
226
|
+
Describe region elements with type and metadata analysis.
|
227
|
+
|
228
|
+
Args:
|
229
|
+
elements: List of region elements
|
230
|
+
|
231
|
+
Returns:
|
232
|
+
Dictionary with region analysis
|
233
|
+
"""
|
234
|
+
if not elements:
|
235
|
+
return {"message": "No region elements found"}
|
236
|
+
|
237
|
+
result = {}
|
238
|
+
|
239
|
+
# Region types
|
240
|
+
types = Counter()
|
241
|
+
sizes = []
|
242
|
+
metadata_keys = set()
|
243
|
+
|
244
|
+
for element in elements:
|
245
|
+
# Type
|
246
|
+
region_type = getattr(element, 'type', 'unknown')
|
247
|
+
types[region_type] += 1
|
248
|
+
|
249
|
+
# Size
|
250
|
+
width = getattr(element, 'width', 0)
|
251
|
+
height = getattr(element, 'height', 0)
|
252
|
+
if width and height:
|
253
|
+
sizes.append(width * height)
|
254
|
+
|
255
|
+
# Metadata keys
|
256
|
+
if hasattr(element, 'metadata') and element.metadata:
|
257
|
+
metadata_keys.update(element.metadata.keys())
|
258
|
+
|
259
|
+
# Type breakdown
|
260
|
+
if types:
|
261
|
+
result['types'] = dict(types.most_common())
|
262
|
+
|
263
|
+
# Size statistics
|
264
|
+
if sizes:
|
265
|
+
result['size_stats'] = {
|
266
|
+
'min_area': f"{min(sizes):.0f} sq pts",
|
267
|
+
'max_area': f"{max(sizes):.0f} sq pts",
|
268
|
+
'avg_area': f"{sum(sizes)/len(sizes):.0f} sq pts"
|
269
|
+
}
|
270
|
+
|
271
|
+
# Metadata
|
272
|
+
if metadata_keys:
|
273
|
+
result['metadata_keys'] = sorted(list(metadata_keys))
|
274
|
+
|
275
|
+
return result
|
276
|
+
|
277
|
+
|
278
|
+
def _analyze_typography(elements: List["Element"]) -> Dict[str, Any]:
|
279
|
+
"""Analyze typography patterns in text elements."""
|
280
|
+
fonts = Counter()
|
281
|
+
sizes = Counter()
|
282
|
+
styles = {'bold': 0, 'italic': 0}
|
283
|
+
colors = Counter()
|
284
|
+
|
285
|
+
for element in elements:
|
286
|
+
# Font family - use TextElement's font_family property for cleaner names
|
287
|
+
font_family = getattr(element, 'font_family', None)
|
288
|
+
fontname = getattr(element, 'fontname', 'Unknown')
|
289
|
+
display_font = font_family if font_family and font_family != fontname else fontname
|
290
|
+
if display_font:
|
291
|
+
fonts[display_font] += 1
|
292
|
+
|
293
|
+
# Size
|
294
|
+
size = getattr(element, 'size', None)
|
295
|
+
if size:
|
296
|
+
# Round to nearest 0.5
|
297
|
+
rounded_size = round(size * 2) / 2
|
298
|
+
sizes[f"{rounded_size}pt"] += 1
|
299
|
+
|
300
|
+
# Styles
|
301
|
+
if getattr(element, 'bold', False):
|
302
|
+
styles['bold'] += 1
|
303
|
+
if getattr(element, 'italic', False):
|
304
|
+
styles['italic'] += 1
|
305
|
+
|
306
|
+
# Color - use TextElement's color property
|
307
|
+
color = getattr(element, 'color', None)
|
308
|
+
if color:
|
309
|
+
if isinstance(color, (tuple, list)):
|
310
|
+
if color == (0, 0, 0) or color == (0.0, 0.0, 0.0):
|
311
|
+
colors['black'] += 1
|
312
|
+
elif color == (1, 1, 1) or color == (1.0, 1.0, 1.0):
|
313
|
+
colors['white'] += 1
|
314
|
+
else:
|
315
|
+
colors['other'] += 1
|
316
|
+
else:
|
317
|
+
colors[str(color)] += 1
|
318
|
+
|
319
|
+
result = {}
|
320
|
+
|
321
|
+
# Fonts
|
322
|
+
if fonts:
|
323
|
+
result['fonts'] = dict(fonts.most_common(10))
|
324
|
+
|
325
|
+
# Sizes (as horizontal table)
|
326
|
+
if sizes:
|
327
|
+
result['sizes'] = dict(sizes.most_common())
|
328
|
+
|
329
|
+
# Styles
|
330
|
+
style_list = []
|
331
|
+
if styles['bold']:
|
332
|
+
style_list.append(f"{styles['bold']} bold")
|
333
|
+
if styles['italic']:
|
334
|
+
style_list.append(f"{styles['italic']} italic")
|
335
|
+
if style_list:
|
336
|
+
result['styles'] = ", ".join(style_list)
|
337
|
+
|
338
|
+
# Colors
|
339
|
+
if colors and len(colors) > 1: # Only show if there are multiple colors
|
340
|
+
result['colors'] = dict(colors.most_common())
|
341
|
+
|
342
|
+
return result
|
343
|
+
|
344
|
+
|
345
|
+
def _analyze_ocr_quality(elements: List["Element"]) -> Dict[str, Any]:
|
346
|
+
"""Analyze OCR quality metrics."""
|
347
|
+
confidences = []
|
348
|
+
|
349
|
+
for element in elements:
|
350
|
+
confidence = getattr(element, 'confidence', None)
|
351
|
+
if confidence is not None:
|
352
|
+
confidences.append(confidence)
|
353
|
+
|
354
|
+
if not confidences:
|
355
|
+
return {}
|
356
|
+
|
357
|
+
result = {}
|
358
|
+
|
359
|
+
# Basic stats
|
360
|
+
result['confidence_stats'] = {
|
361
|
+
'mean': f"{sum(confidences)/len(confidences):.2f}",
|
362
|
+
'min': f"{min(confidences):.2f}",
|
363
|
+
'max': f"{max(confidences):.2f}"
|
364
|
+
}
|
365
|
+
|
366
|
+
# Threshold analysis with ASCII bars
|
367
|
+
thresholds = [
|
368
|
+
('99%+', 0.99),
|
369
|
+
('95%+', 0.95),
|
370
|
+
('90%+', 0.90),
|
371
|
+
]
|
372
|
+
|
373
|
+
element_count = len(elements)
|
374
|
+
threshold_bars = {}
|
375
|
+
|
376
|
+
for label, threshold in thresholds:
|
377
|
+
count = sum(1 for c in confidences if c >= threshold)
|
378
|
+
percentage = count / element_count
|
379
|
+
|
380
|
+
# Create ASCII bar (40 characters wide)
|
381
|
+
filled_chars = int(percentage * 40)
|
382
|
+
empty_chars = 40 - filled_chars
|
383
|
+
bar = '█' * filled_chars + '░' * empty_chars
|
384
|
+
|
385
|
+
# Format: "95%+ (32/43) 74%: `████████████████████████████████░░░░░░░░`"
|
386
|
+
threshold_bars[f"{label} ({count}/{element_count}) {percentage:.0%}"] = f"`{bar}`"
|
387
|
+
|
388
|
+
result['quality_distribution'] = threshold_bars
|
389
|
+
|
390
|
+
# Show lowest quality items
|
391
|
+
element_confidences = []
|
392
|
+
for element in elements:
|
393
|
+
confidence = getattr(element, 'confidence', None)
|
394
|
+
if confidence is not None:
|
395
|
+
# Get text content for display
|
396
|
+
text = getattr(element, 'text', '').strip()
|
397
|
+
if text:
|
398
|
+
# Truncate long text
|
399
|
+
display_text = text[:50] + "..." if len(text) > 50 else text
|
400
|
+
element_confidences.append((confidence, display_text))
|
401
|
+
|
402
|
+
if element_confidences:
|
403
|
+
# Sort by confidence (lowest first) and take bottom 10
|
404
|
+
lowest_quality = sorted(element_confidences, key=lambda x: x[0])[:10]
|
405
|
+
if lowest_quality:
|
406
|
+
lowest_items = {}
|
407
|
+
for i, (confidence, text) in enumerate(lowest_quality, 1):
|
408
|
+
lowest_items[f"#{i}"] = f"**{confidence:.2f}**: {text}"
|
409
|
+
result['lowest_scoring'] = lowest_items
|
410
|
+
|
411
|
+
return result
|
@@ -0,0 +1,84 @@
|
|
1
|
+
"""
|
2
|
+
Mixin for describe functionality.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from typing import TYPE_CHECKING
|
6
|
+
|
7
|
+
if TYPE_CHECKING:
|
8
|
+
from natural_pdf.describe.summary import ElementSummary, InspectionSummary
|
9
|
+
|
10
|
+
|
11
|
+
class DescribeMixin:
|
12
|
+
"""
|
13
|
+
Mixin providing describe functionality for pages, collections, and regions.
|
14
|
+
|
15
|
+
Classes that inherit from this mixin get:
|
16
|
+
- .describe() method for high-level summaries
|
17
|
+
- .inspect() method for detailed tabular views (collections only)
|
18
|
+
"""
|
19
|
+
|
20
|
+
def describe(self) -> "ElementSummary":
|
21
|
+
"""
|
22
|
+
Describe this object with type-specific analysis.
|
23
|
+
|
24
|
+
Returns:
|
25
|
+
ElementSummary with analysis appropriate for the object type
|
26
|
+
"""
|
27
|
+
from natural_pdf.describe import describe_page, describe_collection, describe_region, describe_element
|
28
|
+
|
29
|
+
# Determine the appropriate describe function based on class type
|
30
|
+
class_name = self.__class__.__name__
|
31
|
+
|
32
|
+
if class_name == 'Page':
|
33
|
+
return describe_page(self)
|
34
|
+
elif class_name == 'ElementCollection':
|
35
|
+
return describe_collection(self)
|
36
|
+
elif class_name == 'Region':
|
37
|
+
return describe_region(self)
|
38
|
+
else:
|
39
|
+
# Check if it's an individual element (inherits from Element base class)
|
40
|
+
from natural_pdf.elements.base import Element
|
41
|
+
if isinstance(self, Element):
|
42
|
+
return describe_element(self)
|
43
|
+
|
44
|
+
# Fallback - try to determine based on available methods/attributes
|
45
|
+
if hasattr(self, 'get_elements') and hasattr(self, 'width') and hasattr(self, 'height'):
|
46
|
+
# Looks like a page or region
|
47
|
+
if hasattr(self, 'number'):
|
48
|
+
return describe_page(self) # Page
|
49
|
+
else:
|
50
|
+
return describe_region(self) # Region
|
51
|
+
elif hasattr(self, '__iter__') and hasattr(self, '__len__'):
|
52
|
+
# Looks like a collection
|
53
|
+
return describe_collection(self)
|
54
|
+
else:
|
55
|
+
# Unknown type - create a basic summary
|
56
|
+
from natural_pdf.describe.summary import ElementSummary
|
57
|
+
data = {
|
58
|
+
"object_type": class_name,
|
59
|
+
"message": f"Describe not fully implemented for {class_name}"
|
60
|
+
}
|
61
|
+
return ElementSummary(data, f"{class_name} Summary")
|
62
|
+
|
63
|
+
|
64
|
+
class InspectMixin:
|
65
|
+
"""
|
66
|
+
Mixin providing inspect functionality for collections.
|
67
|
+
|
68
|
+
Classes that inherit from this mixin get:
|
69
|
+
- .inspect() method for detailed tabular element views
|
70
|
+
"""
|
71
|
+
|
72
|
+
def inspect(self, limit: int = 30) -> "InspectionSummary":
|
73
|
+
"""
|
74
|
+
Inspect elements with detailed tabular view.
|
75
|
+
|
76
|
+
Args:
|
77
|
+
limit: Maximum elements per type to show (default: 30)
|
78
|
+
|
79
|
+
Returns:
|
80
|
+
InspectionSummary with element tables showing coordinates,
|
81
|
+
properties, and other details for each element
|
82
|
+
"""
|
83
|
+
from natural_pdf.describe import inspect_collection
|
84
|
+
return inspect_collection(self, limit=limit)
|
@@ -0,0 +1,186 @@
|
|
1
|
+
"""
|
2
|
+
Summary objects for describe functionality.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from typing import Any, Dict, List, Union
|
6
|
+
|
7
|
+
|
8
|
+
class ElementSummary:
|
9
|
+
"""
|
10
|
+
Container for element summary data with markdown rendering.
|
11
|
+
|
12
|
+
Automatically renders as markdown in Jupyter notebooks and provides
|
13
|
+
access to underlying data as dictionaries.
|
14
|
+
"""
|
15
|
+
|
16
|
+
def __init__(self, data: Dict[str, Any], title: str = "Summary"):
|
17
|
+
"""
|
18
|
+
Initialize summary with data and optional title.
|
19
|
+
|
20
|
+
Args:
|
21
|
+
data: Dictionary containing summary sections
|
22
|
+
title: Title for the summary display
|
23
|
+
"""
|
24
|
+
self.data = data
|
25
|
+
self.title = title
|
26
|
+
|
27
|
+
def __str__(self) -> str:
|
28
|
+
"""String representation as markdown."""
|
29
|
+
return self._to_markdown()
|
30
|
+
|
31
|
+
def __repr__(self) -> str:
|
32
|
+
"""Repr as markdown for better display."""
|
33
|
+
return self._to_markdown()
|
34
|
+
|
35
|
+
def _repr_markdown_(self) -> str:
|
36
|
+
"""Jupyter notebook markdown rendering."""
|
37
|
+
return self._to_markdown()
|
38
|
+
|
39
|
+
def to_dict(self) -> Dict[str, Any]:
|
40
|
+
"""Return underlying data as dictionary."""
|
41
|
+
return self.data.copy()
|
42
|
+
|
43
|
+
def _to_markdown(self) -> str:
|
44
|
+
"""Convert data to markdown format."""
|
45
|
+
lines = [f"## {self.title}", ""]
|
46
|
+
|
47
|
+
for section_name, section_data in self.data.items():
|
48
|
+
lines.extend(self._format_section(section_name, section_data))
|
49
|
+
lines.append("") # Empty line between sections
|
50
|
+
|
51
|
+
return "\n".join(lines).rstrip()
|
52
|
+
|
53
|
+
def _format_section(self, name: str, data: Any) -> List[str]:
|
54
|
+
"""Format a single section as markdown."""
|
55
|
+
# Use bold text instead of headers for more compact display
|
56
|
+
section_title = name.replace('_', ' ').title()
|
57
|
+
|
58
|
+
if isinstance(data, dict):
|
59
|
+
lines = [f"**{section_title}**:"]
|
60
|
+
lines.extend(self._format_dict(data, indent=" "))
|
61
|
+
elif isinstance(data, list):
|
62
|
+
lines = [f"**{section_title}**: {', '.join(str(item) for item in data)}"]
|
63
|
+
else:
|
64
|
+
lines = [f"**{section_title}**: {data}"]
|
65
|
+
|
66
|
+
return lines
|
67
|
+
|
68
|
+
def _format_dict(self, data: Dict[str, Any], indent: str = "") -> List[str]:
|
69
|
+
"""Format dictionary as markdown list."""
|
70
|
+
lines = []
|
71
|
+
|
72
|
+
for key, value in data.items():
|
73
|
+
key_display = key.replace('_', ' ')
|
74
|
+
|
75
|
+
if isinstance(value, dict):
|
76
|
+
# Nested dict - always format as list items
|
77
|
+
lines.append(f"{indent}- **{key_display}**:")
|
78
|
+
for subkey, subvalue in value.items():
|
79
|
+
subkey_display = subkey.replace('_', ' ')
|
80
|
+
if isinstance(subvalue, dict):
|
81
|
+
# Another level of nesting
|
82
|
+
lines.append(f"{indent} - **{subkey_display}**:")
|
83
|
+
for subsubkey, subsubvalue in subvalue.items():
|
84
|
+
subsubkey_display = subsubkey.replace('_', ' ')
|
85
|
+
lines.append(f"{indent} - {subsubkey_display}: {subsubvalue}")
|
86
|
+
else:
|
87
|
+
lines.append(f"{indent} - {subkey_display}: {subvalue}")
|
88
|
+
elif isinstance(value, list):
|
89
|
+
if len(value) <= 5:
|
90
|
+
value_str = ", ".join(str(v) for v in value)
|
91
|
+
lines.append(f"{indent}- **{key_display}**: {value_str}")
|
92
|
+
else:
|
93
|
+
lines.append(f"{indent}- **{key_display}**: {len(value)} items")
|
94
|
+
else:
|
95
|
+
lines.append(f"{indent}- **{key_display}**: {value}")
|
96
|
+
|
97
|
+
return lines
|
98
|
+
|
99
|
+
def _format_list(self, data: List[Any]) -> List[str]:
|
100
|
+
"""Format list as markdown."""
|
101
|
+
lines = []
|
102
|
+
for item in data:
|
103
|
+
if isinstance(item, dict):
|
104
|
+
# Could be table rows
|
105
|
+
lines.append(f"- {item}")
|
106
|
+
else:
|
107
|
+
lines.append(f"- {item}")
|
108
|
+
return lines
|
109
|
+
|
110
|
+
|
111
|
+
|
112
|
+
def _format_horizontal_table(self, title: str, data: Dict[str, Any]) -> List[str]:
|
113
|
+
"""Format dict as horizontal table."""
|
114
|
+
headers = list(data.keys())
|
115
|
+
values = list(data.values())
|
116
|
+
|
117
|
+
# Create table
|
118
|
+
header_row = "| " + " | ".join(headers) + " |"
|
119
|
+
separator = "|" + "|".join("------" for _ in headers) + "|"
|
120
|
+
value_row = "| " + " | ".join(str(v) for v in values) + " |"
|
121
|
+
|
122
|
+
return [
|
123
|
+
f"- **{title}**:",
|
124
|
+
"",
|
125
|
+
header_row,
|
126
|
+
separator,
|
127
|
+
value_row,
|
128
|
+
""
|
129
|
+
]
|
130
|
+
|
131
|
+
|
132
|
+
class InspectionSummary(ElementSummary):
|
133
|
+
"""
|
134
|
+
Summary for element inspection with tabular data.
|
135
|
+
"""
|
136
|
+
|
137
|
+
def _format_section(self, name: str, data: Any) -> List[str]:
|
138
|
+
"""Format inspection section with element tables."""
|
139
|
+
section_title = name.replace('_', ' ').title()
|
140
|
+
|
141
|
+
if isinstance(data, dict) and 'elements' in data:
|
142
|
+
# This is an element table section - use ### header for inspect
|
143
|
+
elements = data['elements']
|
144
|
+
lines = [f"### {section_title}"]
|
145
|
+
if elements:
|
146
|
+
lines.extend(self._format_element_table(elements, data.get('columns', [])))
|
147
|
+
# Add note if truncated
|
148
|
+
if 'note' in data:
|
149
|
+
lines.append(f"_{data['note']}_")
|
150
|
+
else:
|
151
|
+
lines.append("No elements found.")
|
152
|
+
else:
|
153
|
+
# Regular section formatting
|
154
|
+
lines = [f"**{section_title}**: {data}"]
|
155
|
+
|
156
|
+
return lines
|
157
|
+
|
158
|
+
def _format_element_table(self, elements: List[Dict[str, Any]], columns: List[str]) -> List[str]:
|
159
|
+
"""Format elements as markdown table."""
|
160
|
+
if not elements or not columns:
|
161
|
+
return ["No elements to display."]
|
162
|
+
|
163
|
+
lines = [""] # Empty line before table
|
164
|
+
|
165
|
+
# Table header
|
166
|
+
header_row = "| " + " | ".join(columns) + " |"
|
167
|
+
separator = "|" + "|".join("------" for _ in columns) + "|"
|
168
|
+
lines.extend([header_row, separator])
|
169
|
+
|
170
|
+
# Table rows
|
171
|
+
for element in elements:
|
172
|
+
row_values = []
|
173
|
+
for col in columns:
|
174
|
+
value = element.get(col, "")
|
175
|
+
if value is None:
|
176
|
+
value = ""
|
177
|
+
elif isinstance(value, float):
|
178
|
+
value = str(int(round(value)))
|
179
|
+
elif isinstance(value, str) and len(value) > 50:
|
180
|
+
value = value[:50] + "..."
|
181
|
+
row_values.append(str(value))
|
182
|
+
|
183
|
+
row = "| " + " | ".join(row_values) + " |"
|
184
|
+
lines.append(row)
|
185
|
+
|
186
|
+
return lines
|