natural-pdf 0.1.16__py3-none-any.whl → 0.1.18__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.
@@ -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
@@ -8,6 +8,7 @@ from PIL import Image
8
8
 
9
9
  # Import selector parsing functions
10
10
  from natural_pdf.selectors.parser import parse_selector, selector_to_filter_func
11
+ from natural_pdf.describe.mixin import DescribeMixin
11
12
 
12
13
  if TYPE_CHECKING:
13
14
  from natural_pdf.core.page import Page
@@ -412,7 +413,7 @@ class DirectionalMixin:
412
413
  return new_region
413
414
 
414
415
 
415
- class Element(DirectionalMixin):
416
+ class Element(DirectionalMixin, DescribeMixin):
416
417
  """
417
418
  Base class for all PDF elements.
418
419