返回 ppt-master
svg_contracts.py
根目录 / skills / ppt-master / scripts / svg_quality / svg_contracts.py
1 #!/usr/bin/env python3
2 """PPT Master stateless SVG contract checks.
3
4 Validates the SVG property surface shared with the native DrawingML exporter.
5 Each check receives an explicit XML root or source string and appends findings
6 to the supplied result dictionary.
7
8 Usage:
9 Import checks from ``svg_quality.svg_contracts``.
10
11 Examples:
12 from svg_quality.svg_contracts import check_paint_compatibility
13
14 Dependencies:
15 Standard library plus local PPT Master SVG-to-PPTX modules.
16 """
17
18 import copy
19 import math
20 import re
21 from collections import Counter, defaultdict
22 from typing import Dict, List
23 from xml.etree import ElementTree as ET
24
25 from .xml_support import (
26 XLINK_NS,
27 element_label as _element_label,
28 local_name as _local_name,
29 )
30
31 try:
32 from pptx_effects import (
33 EFFECT_REASON_ATTR as _EFFECT_REASON_ATTR,
34 EFFECT_STATUS_ATTR as _EFFECT_STATUS_ATTR,
35 project_effect_status_errors as _project_effect_status_errors,
36 )
37 except ImportError:
38 _EFFECT_REASON_ATTR = "data-pptx-effect-reason"
39 _EFFECT_STATUS_ATTR = "data-pptx-effect-status"
40 _project_effect_status_errors = None
41
42 try:
43 from svg_to_pptx.drawingml.utils import (
44 DRAWINGML_TEXT_FONT_SIZE_MAX as _DRAWINGML_TEXT_FONT_SIZE_MAX,
45 DRAWINGML_TEXT_FONT_SIZE_MIN as _DRAWINGML_TEXT_FONT_SIZE_MIN,
46 PROJECT_OPACITY_PROPERTIES as _OPACITY_PROPERTIES,
47 PROJECT_PAINT_PROPERTIES as _PAINT_PROPERTIES,
48 PROJECT_PERCENTAGE_OPACITY_PROPERTIES as _PERCENTAGE_OPACITY_PROPERTIES,
49 format_project_geometry_length as _format_project_geometry_length,
50 format_project_image_aspect_ratio as _format_project_image_aspect_ratio,
51 format_project_opacity as _format_project_opacity,
52 font_px_to_hpt as _font_px_to_hpt,
53 is_canonical_project_geometry_length as _is_canonical_project_geometry_length,
54 is_project_opacity_default_form as _is_project_opacity_default_form,
55 is_project_paint_default_form as _is_project_paint_default_form,
56 iter_project_geometry_lengths as _iter_project_geometry_lengths,
57 iter_project_image_aspect_ratios as _iter_project_image_aspect_ratios,
58 iter_project_opacities as _iter_project_opacities,
59 iter_project_paints as _iter_project_paints,
60 iter_project_stroke_styles as _iter_project_stroke_styles,
61 iter_project_transforms as _iter_project_transforms,
62 noncanonical_stroke_dash_numbers as _noncanonical_stroke_dash_numbers,
63 noncanonical_transform_numbers as _noncanonical_transform_numbers,
64 parse_inline_style as _parse_inline_style,
65 parse_project_geometry_length as _parse_project_geometry_length,
66 parse_project_image_aspect_ratio as _parse_project_image_aspect_ratio,
67 parse_project_opacity as _parse_project_opacity,
68 parse_project_paint as _parse_project_paint,
69 parse_project_stroke_dasharray as _parse_project_stroke_dasharray,
70 parse_project_stroke_enum as _parse_project_stroke_enum,
71 parse_svg_length as _parse_export_length,
72 project_definition_errors as _project_definition_errors,
73 project_filter_errors as _project_filter_errors,
74 project_gradient_errors as _project_gradient_errors,
75 project_image_aspect_ratio_errors as _project_image_aspect_ratio_errors,
76 project_mask_errors as _project_mask_errors,
77 project_marker_errors as _project_marker_errors,
78 project_opacity_errors as _project_opacity_errors,
79 project_paint_errors as _project_paint_errors,
80 project_paint_reference_errors as _project_paint_reference_errors,
81 project_stroke_style_errors as _project_stroke_style_errors,
82 project_transform_errors as _project_transform_errors,
83 )
84 except ImportError:
85 _DRAWINGML_TEXT_FONT_SIZE_MAX = None
86 _DRAWINGML_TEXT_FONT_SIZE_MIN = None
87 _OPACITY_PROPERTIES = None
88 _PAINT_PROPERTIES = None
89 _PERCENTAGE_OPACITY_PROPERTIES = None
90 _format_project_geometry_length = None
91 _format_project_image_aspect_ratio = None
92 _format_project_opacity = None
93 _font_px_to_hpt = None
94 _is_canonical_project_geometry_length = None
95 _is_project_opacity_default_form = None
96 _is_project_paint_default_form = None
97 _iter_project_geometry_lengths = None
98 _iter_project_image_aspect_ratios = None
99 _iter_project_opacities = None
100 _iter_project_paints = None
101 _iter_project_stroke_styles = None
102 _iter_project_transforms = None
103 _noncanonical_stroke_dash_numbers = None
104 _noncanonical_transform_numbers = None
105 _parse_inline_style = None
106 _parse_project_geometry_length = None
107 _parse_project_image_aspect_ratio = None
108 _parse_project_opacity = None
109 _parse_project_paint = None
110 _parse_project_stroke_dasharray = None
111 _parse_project_stroke_enum = None
112 _parse_export_length = None
113 _project_definition_errors = None
114 _project_filter_errors = None
115 _project_gradient_errors = None
116 _project_image_aspect_ratio_errors = None
117 _project_mask_errors = None
118 _project_marker_errors = None
119 _project_opacity_errors = None
120 _project_paint_errors = None
121 _project_paint_reference_errors = None
122 _project_stroke_style_errors = None
123 _project_transform_errors = None
124
125 try:
126 from svg_to_pptx.drawingml.paths import (
127 iter_project_freeform_geometry as _iter_project_freeform_geometry,
128 noncanonical_path_numbers as _noncanonical_path_numbers,
129 noncanonical_points_numbers as _noncanonical_points_numbers,
130 project_gradient_geometry_errors as _project_gradient_geometry_errors,
131 )
132 except ImportError:
133 _iter_project_freeform_geometry = None
134 _noncanonical_path_numbers = None
135 _noncanonical_points_numbers = None
136 _project_gradient_geometry_errors = None
137
138 try:
139 from svg_to_pptx.drawingml.elements import (
140 project_clip_path_errors as _project_clip_path_errors,
141 project_nested_svg_crop_errors as _project_nested_svg_crop_errors,
142 )
143 except ImportError:
144 _project_clip_path_errors = None
145 _project_nested_svg_crop_errors = None
146
147 try:
148 from svg_to_pptx.drawingml.text_properties import (
149 project_text_property_diagnostics as _project_text_property_diagnostics,
150 )
151 except ImportError:
152 _project_text_property_diagnostics = None
153
154 try:
155 from svg_to_pptx.geometry_properties import (
156 materialize_inline_geometry_properties as _materialize_inline_geometry_properties,
157 validate_inline_geometry_properties as _validate_inline_geometry_properties,
158 )
159 except ImportError:
160 _materialize_inline_geometry_properties = None
161 _validate_inline_geometry_properties = None
162
163 try:
164 from svg_to_pptx.use_expander import (
165 UseExpansionError as _UseExpansionError,
166 expand_local_use_references as _expand_local_use_references,
167 validate_local_use_references as _validate_local_use_references,
168 )
169 except ImportError:
170 _UseExpansionError = None
171 _expand_local_use_references = None
172 _validate_local_use_references = None
173
174 _CANONICAL_PAINT_ALPHA_PROPERTY = {
175 "fill": "fill-opacity",
176 "stroke": "stroke-opacity",
177 "stop-color": "stop-opacity",
178 "flood-color": "flood-opacity",
179 }
180 _SUPPORTED_INLINE_STYLE_PROPERTIES = frozenset({
181 "cx", "cy", "fill", "fill-opacity", "filter", "flood-color",
182 "flood-opacity", "font-family", "font-size", "font-style", "font-weight",
183 "height", "letter-spacing", "opacity", "r", "rx", "ry",
184 "shape-rendering", "stop-color", "stop-opacity", "stroke",
185 "stroke-dasharray", "stroke-linecap", "stroke-linejoin", "stroke-opacity",
186 "stroke-width", "text-anchor", "text-decoration", "vector-effect",
187 "width", "x", "y",
188 })
189 _BAKE_REQUIRED_VISUAL_PROPERTIES = frozenset({
190 "backdrop-filter",
191 "isolation",
192 "mix-blend-mode",
193 })
194 _SHARED_FAIL_CLOSED_STYLE_PROPERTIES = frozenset({"mask"})
195
196
197 def check_forbidden_elements(
198 content: str,
199 root: ET.Element,
200 result: Dict,
201 ) -> None:
202 """Check forbidden elements (blocklist)"""
203 content_lower = content.lower()
204 elems = list(root.iter())
205 local_names = {_local_name(elem).lower() for elem in elems}
206
207 # ============================================================
208 # Forbidden elements blocklist - PPT incompatible
209 # ============================================================
210
211 # Style system
212 if 'style' in local_names:
213 result['errors'].append("Detected forbidden <style> element (use inline attributes instead)")
214 if re.search(r'\bclass\s*=', content):
215 result['errors'].append("Detected forbidden class attribute (use inline styles instead)")
216 # id attribute: only report error when <style> also exists (id is harmful only with CSS selectors)
217 # id inside <defs> for linearGradient/filter etc. is required, Inkscape also auto-adds id to elements,
218 # standalone id attributes have no impact on PPT export
219 if 'style' in local_names and re.search(r'\bid\s*=', content):
220 result['errors'].append(
221 "Detected id attribute used with <style> (CSS selectors forbidden, use inline styles instead)"
222 )
223 if re.search(r'<\?xml-stylesheet\b', content_lower):
224 result['errors'].append("Detected forbidden xml-stylesheet (external CSS references forbidden)")
225 if re.search(r'<link[^>]*rel\s*=\s*["\']stylesheet["\']', content_lower):
226 result['errors'].append("Detected forbidden <link rel=\"stylesheet\"> (external CSS references forbidden)")
227 if re.search(r'@import\s+', content_lower):
228 result['errors'].append("Detected forbidden @import (external CSS references forbidden)")
229 if _validate_inline_geometry_properties is None:
230 result['warnings'].append(
231 "Unable to import inline geometry validator; "
232 "native export will still validate geometry styles."
233 )
234 else:
235 geometry_errors = _validate_inline_geometry_properties(root)
236 for error in geometry_errors:
237 result['errors'].append(f"Invalid inline geometry property: {error}")
238 if not geometry_errors:
239 _materialize_inline_geometry_properties(root)
240
241 # Structure / nesting
242 if 'foreignobject' in local_names:
243 result['errors'].append(
244 "Detected forbidden <foreignObject> element (use <tspan> for manual line breaks)")
245 has_generic_use = any(
246 _local_name(elem).lower() == 'use' and elem.get('data-icon') is None
247 for elem in elems
248 )
249 if has_generic_use:
250 if _validate_local_use_references is None:
251 result['warnings'].append(
252 "Detected local <use> references, but the shared validator "
253 "could not be imported; native export will still validate them."
254 )
255 else:
256 for error in _validate_local_use_references(root):
257 result['errors'].append(f"Invalid local <use> reference: {error}")
258 # Text / fonts
259 if 'textpath' in local_names:
260 result['errors'].append("Detected forbidden <textPath> element (path text is incompatible with PPT)")
261 if '@font-face' in content_lower:
262 result['errors'].append("Detected forbidden @font-face (use system font stack)")
263
264 # Animation / interaction
265 if any(name.startswith('animate') for name in local_names):
266 result['errors'].append(
267 "Detected forbidden SMIL animation element <animate*> "
268 "(SVG animations are not exported)"
269 )
270 if 'set' in local_names:
271 result['errors'].append("Detected forbidden SMIL animation element <set> (SVG animations are not exported)")
272 if 'script' in local_names:
273 result['errors'].append("Detected forbidden <script> element (scripts and event handlers forbidden)")
274 if re.search(r'\bon\w+\s*=', content): # onclick, onload etc.
275 result['errors'].append("Detected forbidden event attributes (e.g., onclick, onload)")
276
277 # Other discouraged elements
278 if 'iframe' in local_names:
279 result['errors'].append("Detected <iframe> element (should not appear in SVG)")
280
281
282 def check_paint_compatibility(
283 root: ET.Element,
284 result: Dict,
285 ) -> None:
286 """Reject unsupported paint and advise one generated-SVG spelling.
287
288 The exporter parser owns compatibility. Any paint it can parse remains
289 valid input; the checker only warns when that spelling differs from the
290 generated-SVG default (uppercase ``#RRGGBB`` plus explicit alpha).
291 """
292 helpers = (
293 _PAINT_PROPERTIES,
294 _PERCENTAGE_OPACITY_PROPERTIES,
295 _format_project_opacity,
296 _is_project_paint_default_form,
297 _iter_project_paints,
298 _parse_inline_style,
299 _parse_project_opacity,
300 _parse_project_paint,
301 _project_paint_errors,
302 )
303 if any(helper is None for helper in helpers):
304 result['warnings'].append(
305 "Unable to import svg_to_pptx paint parsers; skipped paint syntax check"
306 )
307 return
308
309 result['errors'].extend(_project_paint_errors(root))
310 recommendations: Counter[tuple[str, str, str]] = Counter()
311 recommendation_examples: Dict[tuple[str, str, str], List[str]] = defaultdict(list)
312
313 def remember_example(store: Dict, key: tuple, label: str) -> None:
314 labels = store[key]
315 if label not in labels and len(labels) < 3:
316 labels.append(label)
317
318 for elem, name, raw_value, source in _iter_project_paints(root):
319 try:
320 kind, normalized, color_alpha = _parse_project_paint(
321 raw_value,
322 name,
323 )
324 except ValueError:
325 continue
326 if _is_project_paint_default_form(raw_value, name):
327 continue
328
329 source_label = f'{_element_label(elem)} {source}'
330 if kind == 'none':
331 replacement = f'{name}="none"'
332 elif kind == 'reference':
333 replacement = f'{name}="url(#{normalized})"'
334 elif name in {'fill', 'stroke'} and raw_value.strip().lower() == 'transparent':
335 replacement = f'{name}="none"'
336 else:
337 replacement = f'{name}="#{normalized}"'
338 alpha_name = _CANONICAL_PAINT_ALPHA_PROPERTY.get(name)
339 if color_alpha < 1.0 and alpha_name is not None:
340 style_values = _parse_inline_style(elem.get('style'))
341 existing_alpha_raw = (
342 style_values.get(alpha_name) or elem.get(alpha_name)
343 )
344 if existing_alpha_raw is None:
345 existing_alpha = 1.0
346 else:
347 try:
348 existing_alpha = _parse_project_opacity(
349 existing_alpha_raw,
350 allow_percentage=(
351 alpha_name in _PERCENTAGE_OPACITY_PROPERTIES
352 ),
353 )
354 except ValueError:
355 existing_alpha = None
356 effective_alpha = (
357 color_alpha * existing_alpha
358 if existing_alpha is not None else color_alpha
359 )
360 replacement += (
361 f' {alpha_name}="'
362 f'{_format_project_opacity(effective_alpha)}"'
363 )
364 elif color_alpha < 1.0:
365 replacement += (
366 '; put alpha on the matching pattern child fill/stroke '
367 'opacity'
368 )
369
370 key = (name, raw_value, replacement)
371 recommendations[key] += 1
372 remember_example(recommendation_examples, key, source_label)
373
374 for (name, raw_value, replacement), count in sorted(recommendations.items()):
375 examples = ', '.join(
376 recommendation_examples[(name, raw_value, replacement)]
377 )
378 result['warnings'].append(
379 f"Recommendation: {name}={raw_value!r} is converter-compatible "
380 f"in {count} location(s) ({examples}); generated SVG should "
381 f"prefer {replacement}. No change is required for export."
382 )
383
384
385 def check_reference_spelling(root: ET.Element, result: Dict) -> None:
386 """Recommend SVG 2 ``href`` while retaining legacy XLink input."""
387 labels = []
388 xlink_href = f'{{{XLINK_NS}}}href'
389 for elem in root.iter():
390 if _local_name(elem).lower() not in {'image', 'use'}:
391 continue
392 if elem.get(xlink_href) is not None:
393 labels.append(_element_label(elem))
394 if labels:
395 examples = ', '.join(labels[:3])
396 suffix = f' (+{len(labels) - 3} more)' if len(labels) > 3 else ''
397 result['warnings'].append(
398 f"Recommendation: legacy xlink:href is supported on {len(labels)} "
399 f"reference(s) ({examples}{suffix}); generated SVG should prefer "
400 "href. No change is required for export."
401 )
402
403
404 def check_opacity_values(
405 root: ET.Element,
406 result: Dict,
407 ) -> None:
408 """Reject malformed opacity and advise generated-SVG values."""
409 helpers = (
410 _PERCENTAGE_OPACITY_PROPERTIES,
411 _format_project_opacity,
412 _is_project_opacity_default_form,
413 _iter_project_opacities,
414 _parse_inline_style,
415 _parse_project_opacity,
416 _project_opacity_errors,
417 )
418 if any(helper is None for helper in helpers):
419 result['warnings'].append(
420 "Unable to import svg_to_pptx opacity validators; native "
421 "export will still validate opacity syntax."
422 )
423 return
424
425 result['errors'].extend(_project_opacity_errors(root))
426 recommendations: Counter[tuple[str, str, str]] = Counter()
427 examples: Dict[tuple[str, str, str], List[str]] = defaultdict(list)
428 fidelity_warnings: set[str] = set()
429
430 for elem, property_name, raw, source in _iter_project_opacities(root):
431 try:
432 value = _parse_project_opacity(
433 raw,
434 allow_percentage=(
435 property_name in _PERCENTAGE_OPACITY_PROPERTIES
436 ),
437 )
438 except ValueError:
439 continue
440 if _is_project_opacity_default_form(raw):
441 continue
442 normalized = _format_project_opacity(value)
443 key = (property_name, raw, normalized)
444 recommendations[key] += 1
445 label = f'{_element_label(elem)} {source}'
446 if label not in examples[key] and len(examples[key]) < 3:
447 examples[key].append(label)
448
449 for elem in root.iter():
450 if _local_name(elem).lower() != 'g':
451 continue
452 style_values = _parse_inline_style(elem.get('style'))
453 raw_opacity = (
454 style_values['opacity']
455 if 'opacity' in style_values else elem.get('opacity')
456 )
457 if raw_opacity is None:
458 continue
459 try:
460 opacity = _parse_project_opacity(raw_opacity)
461 except ValueError:
462 continue
463 if opacity < 1.0:
464 fidelity_warnings.add(
465 f"Fidelity warning: {_element_label(elem)} uses group "
466 f"opacity={raw_opacity!r}. The converter distributes this "
467 "alpha to descendants and cannot preserve isolated group "
468 "compositing; generated SVG should prefer descendant alpha. "
469 "Existing input remains convertible and does not require "
470 "modification."
471 )
472
473 for (property_name, raw, normalized), count in sorted(
474 recommendations.items()
475 ):
476 shown_examples = ', '.join(
477 examples[(property_name, raw, normalized)]
478 )
479 result['warnings'].append(
480 f"Recommendation: {property_name}={raw!r} is "
481 f"converter-compatible in {count} location(s) "
482 f"({shown_examples}); generated SVG should prefer "
483 f'{property_name}="{normalized}". No change is required '
484 "for export."
485 )
486 result['warnings'].extend(sorted(fidelity_warnings))
487
488
489 def check_authoring_property_contract(
490 root: ET.Element,
491 result: Dict,
492 ) -> None:
493 """Validate inline CSS and attributes against the authoring surface."""
494 errors: set[str] = set()
495 validated_value_properties = set(_OPACITY_PROPERTIES or ())
496 validated_value_properties.update(_PAINT_PROPERTIES or ())
497 for elem in root.iter():
498 label = _element_label(elem)
499 for fragment in (elem.get('style') or '').split(';'):
500 fragment = fragment.strip()
501 if not fragment:
502 continue
503 if ':' not in fragment:
504 if fragment.lower() not in validated_value_properties:
505 errors.add(
506 f"{label} has malformed inline style declaration "
507 f"{fragment!r}"
508 )
509 continue
510 name, value = fragment.split(':', 1)
511 name = name.strip().lower()
512 value = value.strip()
513 if not name or not value:
514 if name not in validated_value_properties:
515 errors.add(
516 f"{label} has malformed inline style declaration "
517 f"{fragment!r}"
518 )
519 continue
520 if name in _BAKE_REQUIRED_VISUAL_PROPERTIES:
521 errors.add(
522 f"{label} uses Bake-required visual property {name!r}; "
523 "bake the effect or rebuild it with supported geometry"
524 )
525 elif (
526 name not in _SUPPORTED_INLINE_STYLE_PROPERTIES
527 and name not in _SHARED_FAIL_CLOSED_STYLE_PROPERTIES
528 ):
529 errors.add(
530 f"{label} uses unsupported inline style property {name!r}; "
531 "native PPTX export would ignore it"
532 )
533 if '!important' in value.lower():
534 errors.add(
535 f"{label} inline style property {name!r} cannot use !important"
536 )
537
538 for attr_name in elem.attrib:
539 local_attr = attr_name.rsplit('}', 1)[-1]
540 if local_attr in _BAKE_REQUIRED_VISUAL_PROPERTIES:
541 errors.add(
542 f"{label} uses Bake-required visual attribute {local_attr!r}; "
543 "bake the effect or rebuild it with supported geometry"
544 )
545
546 result['errors'].extend(sorted(errors))
547
548
549 def check_text_property_contract(
550 root: ET.Element,
551 result: Dict,
552 ) -> None:
553 """Validate text property names and values with the export contract."""
554 if _project_text_property_diagnostics is None:
555 result['warnings'].append(
556 "Unable to import the shared text-property validator; native "
557 "export will still validate text properties."
558 )
559 return
560
561 errors: set[str] = set()
562 recommendations: Counter[tuple[str, str, str]] = Counter()
563 examples: Dict[tuple[str, str, str], List[str]] = defaultdict(list)
564 for diagnostic in _project_text_property_diagnostics(root):
565 if diagnostic.severity == 'error':
566 errors.add(diagnostic.message)
567 continue
568 if diagnostic.canonical is None:
569 continue
570 key = (
571 diagnostic.name,
572 diagnostic.raw,
573 diagnostic.canonical,
574 )
575 recommendations[key] += 1
576 if (
577 diagnostic.label not in examples[key]
578 and len(examples[key]) < 3
579 ):
580 examples[key].append(diagnostic.label)
581
582 result['errors'].extend(sorted(errors))
583 for (name, raw, canonical), count in sorted(recommendations.items()):
584 shown_examples = ', '.join(examples[(name, raw, canonical)])
585 result['warnings'].append(
586 f"Recommendation: text property {name}={raw!r} is "
587 f"converter-compatible in {count} location(s) "
588 f"({shown_examples}); generated SVG should prefer "
589 f'{name}="{canonical}". No change is required for export.'
590 )
591
592
593 def check_definition_contract(
594 root: ET.Element,
595 result: Dict,
596 ) -> None:
597 """Require conditional definitions to be direct, uniquely identified defs."""
598 if _project_definition_errors is None:
599 result['warnings'].append(
600 "Unable to import the shared definition validator; native "
601 "export will still validate local definitions."
602 )
603 return
604 result['errors'].extend(_project_definition_errors(root))
605
606
607 def check_paint_reference_contract(
608 root: ET.Element,
609 result: Dict,
610 ) -> None:
611 """Validate paint-server resolution and native target contexts."""
612 if _project_paint_reference_errors is None:
613 result['warnings'].append(
614 "Unable to import the shared paint-reference validator; native "
615 "export will still validate local paint references."
616 )
617 return
618 result['errors'].extend(_project_paint_reference_errors(root))
619
620
621 def check_marker_contract(
622 root: ET.Element,
623 result: Dict,
624 ) -> None:
625 """Validate marker references against the native line-end contract."""
626 if _project_marker_errors is None:
627 result['warnings'].append(
628 'Unable to import the shared marker validator; native export '
629 'will still validate line-end markers.'
630 )
631 return
632 result['errors'].extend(_project_marker_errors(root))
633
634
635 def check_clip_path_contract(
636 root: ET.Element,
637 result: Dict,
638 ) -> None:
639 """Validate image clip paths against the native picture geometry mapping."""
640 if _project_clip_path_errors is None:
641 result['errors'].append(
642 'Unable to import the clip-path validator; cannot verify '
643 'native picture geometry references'
644 )
645 return
646 result['errors'].extend(_project_clip_path_errors(root))
647
648
649 def check_mask_contract(root: ET.Element, result: Dict) -> None:
650 """Reject SVG masks through the native exporter's shared validator."""
651 if _project_mask_errors is None:
652 result['errors'].append(
653 'Unable to import the shared mask validator; cannot verify '
654 'that native PPTX export will preserve all visible effects'
655 )
656 return
657 result['errors'].extend(_project_mask_errors(root))
658
659
660 def check_filter_effects(root: ET.Element, result: Dict) -> None:
661 """Validate filters against the native shadow/glow approximation."""
662 if _project_filter_errors is None:
663 result['warnings'].append(
664 "Unable to import the shared filter validator; native export "
665 "will still validate shadow/glow filters."
666 )
667 return
668 result['errors'].extend(_project_filter_errors(root))
669
670
671 def check_imported_effect_status(
672 root: ET.Element,
673 result: Dict,
674 ) -> None:
675 """Reject source PPTX effects that have no faithful SVG mapping."""
676 if _project_effect_status_errors is None:
677 if any(
678 elem.get(_EFFECT_STATUS_ATTR) is not None
679 or elem.get(_EFFECT_REASON_ATTR) is not None
680 for elem in root.iter()
681 ):
682 result['errors'].append(
683 'Unable to import the PPTX effect-status validator; '
684 'cannot verify imported effect fidelity'
685 )
686 return
687 result['errors'].extend(_project_effect_status_errors(root))
688
689
690 def check_gradient_interfaces(root: ET.Element, result: Dict) -> None:
691 """Validate the normalized native gradient authoring interface."""
692 if (
693 _project_gradient_errors is None
694 or _project_gradient_geometry_errors is None
695 ):
696 result['warnings'].append(
697 "Unable to import the shared gradient validator; native export "
698 "will still validate gradient definitions."
699 )
700 return
701 gradient_errors = set(_project_gradient_errors(root))
702 gradient_errors.update(_project_gradient_geometry_errors(root))
703 if (
704 _expand_local_use_references is not None
705 and _UseExpansionError is not None
706 ):
707 expanded_root = copy.deepcopy(root)
708 try:
709 _expand_local_use_references(expanded_root)
710 except _UseExpansionError:
711 # The local-reference check owns the actionable diagnostic.
712 pass
713 else:
714 gradient_errors.update(
715 _project_gradient_geometry_errors(expanded_root)
716 )
717 result['errors'].extend(sorted(gradient_errors))
718
719
720 def check_geometry_length_values(
721 root: ET.Element,
722 result: Dict,
723 ) -> None:
724 """Reject invalid project geometry and advise the unitless spelling."""
725 if (
726 _format_project_geometry_length is None
727 or _is_canonical_project_geometry_length is None
728 or _iter_project_geometry_lengths is None
729 or _parse_project_geometry_length is None
730 ):
731 result['warnings'].append(
732 "Unable to import svg_to_pptx geometry length validators; "
733 "native export will still validate project geometry."
734 )
735 return
736
737 errors: set[str] = set()
738 recommendations: Counter[tuple[str, str, str]] = Counter()
739 examples: Dict[tuple[str, str, str], List[str]] = defaultdict(list)
740
741 for elem, attribute, raw, source in _iter_project_geometry_lengths(root):
742 label = f'{_element_label(elem)} {source}'
743 try:
744 value = _parse_project_geometry_length(raw, attribute)
745 except ValueError as exc:
746 errors.add(f"{label} {attribute}={raw!r}: {exc}")
747 continue
748 if _is_canonical_project_geometry_length(raw):
749 continue
750 normalized = _format_project_geometry_length(value)
751 key = (attribute, raw, normalized)
752 recommendations[key] += 1
753 if label not in examples[key] and len(examples[key]) < 3:
754 examples[key].append(label)
755
756 result['errors'].extend(sorted(errors))
757 for (attribute, raw, normalized), count in sorted(recommendations.items()):
758 shown_examples = ', '.join(examples[(attribute, raw, normalized)])
759 result['warnings'].append(
760 f"Recommendation: project geometry {attribute}={raw!r} is "
761 f"converter-compatible in {count} location(s) ({shown_examples}); "
762 f"generated SVG should prefer the unitless px spelling "
763 f'{attribute}="{normalized}". No change is required for export.'
764 )
765
766
767 def check_stroke_style_values(
768 root: ET.Element,
769 result: Dict,
770 ) -> None:
771 """Reject invalid line styles and advise project-canonical spellings."""
772 helpers = (
773 _format_project_geometry_length,
774 _is_canonical_project_geometry_length,
775 _iter_project_stroke_styles,
776 _noncanonical_stroke_dash_numbers,
777 _parse_project_geometry_length,
778 _parse_project_stroke_dasharray,
779 _parse_project_stroke_enum,
780 _project_stroke_style_errors,
781 )
782 if any(helper is None for helper in helpers):
783 result['warnings'].append(
784 "Unable to import svg_to_pptx line-style validators; native "
785 "export will still validate line-presentation syntax."
786 )
787 return
788
789 result['errors'].extend(_project_stroke_style_errors(root))
790 recommendations: Counter[tuple[str, str, str, str]] = Counter()
791 examples: Dict[tuple[str, str, str, str], List[str]] = defaultdict(list)
792
793 for elem, attribute, raw, source in _iter_project_stroke_styles(root):
794 label = f'{_element_label(elem)} {source}'
795 normalized = None
796 reason = ''
797
798 if attribute == 'stroke-dasharray':
799 try:
800 parsed = _parse_project_stroke_dasharray(
801 raw,
802 allow_zero_gap=True,
803 )
804 noncanonical = _noncanonical_stroke_dash_numbers(raw)
805 except ValueError:
806 continue
807 if parsed is None:
808 if raw != 'none':
809 normalized = 'none'
810 reason = 'remove surrounding whitespace'
811 else:
812 preset, values = parsed
813 longer_custom = preset is None and len(values) > 2
814 if noncanonical or longer_custom or raw != raw.strip():
815 kept_values = values[:2] if longer_custom else values
816 normalized = ' '.join(
817 _format_project_geometry_length(value)
818 for value in kept_values
819 )
820 reasons = []
821 if noncanonical:
822 reasons.append('use ordinary decimal numbers')
823 if longer_custom:
824 reasons.append(
825 'make the first-pair export normalization explicit'
826 )
827 if raw != raw.strip():
828 reasons.append('remove surrounding whitespace')
829 reason = '; '.join(reasons)
830 elif attribute == 'stroke-dashoffset':
831 try:
832 value = _parse_project_geometry_length(raw, attribute)
833 except ValueError:
834 continue
835 if not _is_canonical_project_geometry_length(raw):
836 normalized = _format_project_geometry_length(value)
837 reason = 'use the unitless px spelling'
838 else:
839 try:
840 value = _parse_project_stroke_enum(attribute, raw)
841 except ValueError:
842 continue
843 if raw != value:
844 normalized = value
845 reason = 'remove surrounding whitespace'
846
847 if normalized is None:
848 continue
849 key = (attribute, raw, normalized, reason)
850 recommendations[key] += 1
851 if label not in examples[key] and len(examples[key]) < 3:
852 examples[key].append(label)
853
854 for (attribute, raw, normalized, reason), count in sorted(
855 recommendations.items()
856 ):
857 shown_examples = ', '.join(
858 examples[(attribute, raw, normalized, reason)]
859 )
860 result['warnings'].append(
861 f"Recommendation: line style {attribute}={raw!r} is "
862 f"converter-compatible in {count} location(s) "
863 f"({shown_examples}); generated SVG should prefer "
864 f'{attribute}="{normalized}" to {reason}. No change is '
865 "required for export."
866 )
867
868
869 def check_image_aspect_ratio_values(
870 root: ET.Element,
871 result: Dict,
872 ) -> None:
873 """Reject ambiguous image fit/crop values and advise canonical forms."""
874 helpers = (
875 _format_project_image_aspect_ratio,
876 _iter_project_image_aspect_ratios,
877 _parse_project_image_aspect_ratio,
878 _project_image_aspect_ratio_errors,
879 )
880 if any(helper is None for helper in helpers):
881 result['warnings'].append(
882 "Unable to import svg_to_pptx image aspect-ratio validators; "
883 "native export will still validate image fit/crop syntax."
884 )
885 return
886
887 result['errors'].extend(_project_image_aspect_ratio_errors(root))
888 recommendations: Counter[tuple[str, str]] = Counter()
889 examples: Dict[tuple[str, str], List[str]] = defaultdict(list)
890
891 for elem, raw in _iter_project_image_aspect_ratios(root):
892 try:
893 align, mode = _parse_project_image_aspect_ratio(raw)
894 except ValueError:
895 continue
896 normalized = _format_project_image_aspect_ratio(align, mode)
897 if raw == normalized:
898 continue
899 key = (raw, normalized)
900 recommendations[key] += 1
901 label = _element_label(elem)
902 if label not in examples[key] and len(examples[key]) < 3:
903 examples[key].append(label)
904
905 for (raw, normalized), count in sorted(recommendations.items()):
906 shown_examples = ', '.join(examples[(raw, normalized)])
907 result['warnings'].append(
908 f"Recommendation: image preserveAspectRatio={raw!r} is "
909 f"converter-compatible in {count} location(s) "
910 f"({shown_examples}); generated SVG should prefer "
911 f'preserveAspectRatio="{normalized}". No change is required '
912 "for export."
913 )
914
915
916 def check_nested_svg_crop_contract(
917 root: ET.Element,
918 result: Dict,
919 ) -> None:
920 """Reserve nested SVG for the imported picture-crop transport."""
921 if _project_nested_svg_crop_errors is None:
922 result['errors'].append(
923 'Unable to import the nested SVG crop validator; cannot '
924 'verify imported picture-crop wrappers'
925 )
926 return
927 result['errors'].extend(_project_nested_svg_crop_errors(root))
928
929
930 def check_freeform_geometry_values(
931 root: ET.Element,
932 result: Dict,
933 ) -> None:
934 """Reject malformed path/points syntax and advise decimal spelling."""
935 helpers = (
936 _format_project_geometry_length,
937 _iter_project_freeform_geometry,
938 _noncanonical_path_numbers,
939 _noncanonical_points_numbers,
940 )
941 if any(helper is None for helper in helpers):
942 result['warnings'].append(
943 "Unable to import svg_to_pptx freeform geometry validators; "
944 "native export will still validate path and points syntax."
945 )
946 return
947
948 errors: set[str] = set()
949 recommendations: Counter[tuple[str, str, str]] = Counter()
950 examples: Dict[tuple[str, str, str], List[str]] = defaultdict(list)
951
952 for elem, attribute, raw, min_points in _iter_project_freeform_geometry(root):
953 label = _element_label(elem)
954 try:
955 if raw is None:
956 tag = _local_name(elem)
957 raise ValueError(f'<{tag}> requires {attribute}')
958 if attribute == 'd':
959 compatible_numbers = _noncanonical_path_numbers(raw)
960 else:
961 required_points = min_points or 2
962 compatible_numbers = _noncanonical_points_numbers(
963 raw,
964 min_points=required_points,
965 )
966 except ValueError as exc:
967 errors.add(f'{label} {attribute}: {exc}')
968 continue
969
970 for number in compatible_numbers:
971 normalized = _format_project_geometry_length(float(number))
972 key = (attribute, number, normalized)
973 recommendations[key] += 1
974 if label not in examples[key] and len(examples[key]) < 3:
975 examples[key].append(label)
976
977 result['errors'].extend(sorted(errors))
978 for (attribute, raw, normalized), count in sorted(recommendations.items()):
979 shown_examples = ', '.join(examples[(attribute, raw, normalized)])
980 result['warnings'].append(
981 f"Recommendation: freeform geometry {attribute} numeric token "
982 f"{raw!r} is converter-compatible in {count} occurrence(s) "
983 f"({shown_examples}); generated SVG should prefer the ordinary "
984 f"decimal spelling {normalized!r}. No change is required for export."
985 )
986
987
988 def check_transform_values(
989 root: ET.Element,
990 result: Dict,
991 ) -> None:
992 """Reject invalid transforms and advise ordinary decimal spelling."""
993 helpers = (
994 _format_project_geometry_length,
995 _iter_project_transforms,
996 _noncanonical_transform_numbers,
997 _project_transform_errors,
998 )
999 if any(helper is None for helper in helpers):
1000 result['warnings'].append(
1001 "Unable to import svg_to_pptx transform validators; "
1002 "native export will still validate transform syntax."
1003 )
1004 return
1005
1006 transform_errors = set(_project_transform_errors(root))
1007 if (
1008 not transform_errors
1009 and _expand_local_use_references is not None
1010 and _UseExpansionError is not None
1011 ):
1012 expanded_root = copy.deepcopy(root)
1013 try:
1014 _expand_local_use_references(expanded_root)
1015 except _UseExpansionError:
1016 # The local-reference check owns the actionable diagnostic.
1017 pass
1018 else:
1019 transform_errors.update(_project_transform_errors(expanded_root))
1020 result['errors'].extend(
1021 f'Invalid SVG transform: {error}'
1022 for error in sorted(transform_errors)
1023 )
1024
1025 recommendations: Counter[tuple[str, str]] = Counter()
1026 examples: Dict[tuple[str, str], List[str]] = defaultdict(list)
1027 for elem, raw in _iter_project_transforms(root):
1028 try:
1029 compatible_numbers = _noncanonical_transform_numbers(raw)
1030 except ValueError:
1031 continue
1032 for number in compatible_numbers:
1033 normalized = _format_project_geometry_length(float(number))
1034 key = (number, normalized)
1035 recommendations[key] += 1
1036 label = _element_label(elem)
1037 if label not in examples[key] and len(examples[key]) < 3:
1038 examples[key].append(label)
1039
1040 for (raw, normalized), count in sorted(recommendations.items()):
1041 shown_examples = ', '.join(examples[(raw, normalized)])
1042 result['warnings'].append(
1043 f"Recommendation: transform numeric token {raw!r} is "
1044 f"converter-compatible in {count} occurrence(s) "
1045 f"({shown_examples}); generated SVG should prefer the ordinary "
1046 f"decimal spelling {normalized!r}. No change is required for export."
1047 )
1048
1049
1050 def check_font_size_values(content: str, result: Dict) -> None:
1051 """Keep supported font-size units compatible and recommend unitless px."""
1052 canonical_re = re.compile(r'^(?:\d+(?:\.\d+)?|\.\d+)$')
1053 values = set()
1054
1055 for match in re.finditer(r'\bfont-size\s*=\s*(["\'])(.*?)\1', content, re.IGNORECASE):
1056 values.add(match.group(2).strip())
1057
1058 for match in re.finditer(r'\bfont-size\s*:\s*([^;"\']+)', content, re.IGNORECASE):
1059 values.add(match.group(1).strip())
1060
1061 if _parse_export_length is None:
1062 result['warnings'].append(
1063 "Unable to import svg_to_pptx length parser; skipped font-size syntax check"
1064 )
1065 return
1066
1067 unsupported = set()
1068 drawingml_out_of_range = set()
1069 compatible_noncanonical = set()
1070 for raw in values:
1071 try:
1072 parsed_px = _parse_export_length(raw, math.nan, font_size=16)
1073 except (TypeError, ValueError):
1074 unsupported.add(raw)
1075 continue
1076 if not math.isfinite(parsed_px) or parsed_px < 0:
1077 unsupported.add(raw)
1078 continue
1079 if _font_px_to_hpt is not None:
1080 try:
1081 _font_px_to_hpt(parsed_px)
1082 except ValueError:
1083 drawingml_out_of_range.add(raw)
1084 continue
1085 if not canonical_re.fullmatch(raw):
1086 compatible_noncanonical.add(raw)
1087
1088 if unsupported:
1089 shown_values = sorted(unsupported)
1090 shown = ', '.join(shown_values[:5])
1091 more = len(shown_values) - 5
1092 suffix = f" (+{more} more)" if more > 0 else ""
1093 result['errors'].append(
1094 f"Unsupported font-size value(s): {shown}{suffix}. Use a finite "
1095 "non-negative SVG length supported by svg_to_pptx."
1096 )
1097
1098 if drawingml_out_of_range:
1099 shown_values = sorted(drawingml_out_of_range)
1100 shown = ', '.join(shown_values[:5])
1101 more = len(shown_values) - 5
1102 suffix = f" (+{more} more)" if more > 0 else ""
1103 result['errors'].append(
1104 f"font-size value(s) {shown}{suffix} are outside the DrawingML "
1105 f"range sz={_DRAWINGML_TEXT_FONT_SIZE_MIN}.."
1106 f"{_DRAWINGML_TEXT_FONT_SIZE_MAX} (1..4000pt); PowerPoint would "
1107 "repair the exported file. Do not use tiny transparent text as "
1108 "a placeholder carrier: leave a text carrier blank or use the "
1109 "composite object proxy contract."
1110 )
1111
1112 if compatible_noncanonical:
1113 shown_values = sorted(compatible_noncanonical)
1114 shown = ', '.join(shown_values[:5])
1115 more = len(shown_values) - 5
1116 suffix = f" (+{more} more)" if more > 0 else ""
1117 result['warnings'].append(
1118 f"Recommendation: font-size value(s) {shown}{suffix} are "
1119 "converter-compatible; generated SVG should prefer unitless px "
1120 "values such as font-size=\"28\". No change is required for export."
1121 )
1122
1122 lines PYTHON