1122 lines
43 KiB
Python
1122 lines
43 KiB
Python
#!/usr/bin/env python3
|
|
"""PPT Master stateless SVG contract checks.
|
|
|
|
Validates the SVG property surface shared with the native DrawingML exporter.
|
|
Each check receives an explicit XML root or source string and appends findings
|
|
to the supplied result dictionary.
|
|
|
|
Usage:
|
|
Import checks from ``svg_quality.svg_contracts``.
|
|
|
|
Examples:
|
|
from svg_quality.svg_contracts import check_paint_compatibility
|
|
|
|
Dependencies:
|
|
Standard library plus local PPT Master SVG-to-PPTX modules.
|
|
"""
|
|
|
|
import copy
|
|
import math
|
|
import re
|
|
from collections import Counter, defaultdict
|
|
from typing import Dict, List
|
|
from xml.etree import ElementTree as ET
|
|
|
|
from .xml_support import (
|
|
XLINK_NS,
|
|
element_label as _element_label,
|
|
local_name as _local_name,
|
|
)
|
|
|
|
try:
|
|
from pptx_effects import (
|
|
EFFECT_REASON_ATTR as _EFFECT_REASON_ATTR,
|
|
EFFECT_STATUS_ATTR as _EFFECT_STATUS_ATTR,
|
|
project_effect_status_errors as _project_effect_status_errors,
|
|
)
|
|
except ImportError:
|
|
_EFFECT_REASON_ATTR = "data-pptx-effect-reason"
|
|
_EFFECT_STATUS_ATTR = "data-pptx-effect-status"
|
|
_project_effect_status_errors = None
|
|
|
|
try:
|
|
from svg_to_pptx.drawingml.utils import (
|
|
DRAWINGML_TEXT_FONT_SIZE_MAX as _DRAWINGML_TEXT_FONT_SIZE_MAX,
|
|
DRAWINGML_TEXT_FONT_SIZE_MIN as _DRAWINGML_TEXT_FONT_SIZE_MIN,
|
|
PROJECT_OPACITY_PROPERTIES as _OPACITY_PROPERTIES,
|
|
PROJECT_PAINT_PROPERTIES as _PAINT_PROPERTIES,
|
|
PROJECT_PERCENTAGE_OPACITY_PROPERTIES as _PERCENTAGE_OPACITY_PROPERTIES,
|
|
format_project_geometry_length as _format_project_geometry_length,
|
|
format_project_image_aspect_ratio as _format_project_image_aspect_ratio,
|
|
format_project_opacity as _format_project_opacity,
|
|
font_px_to_hpt as _font_px_to_hpt,
|
|
is_canonical_project_geometry_length as _is_canonical_project_geometry_length,
|
|
is_project_opacity_default_form as _is_project_opacity_default_form,
|
|
is_project_paint_default_form as _is_project_paint_default_form,
|
|
iter_project_geometry_lengths as _iter_project_geometry_lengths,
|
|
iter_project_image_aspect_ratios as _iter_project_image_aspect_ratios,
|
|
iter_project_opacities as _iter_project_opacities,
|
|
iter_project_paints as _iter_project_paints,
|
|
iter_project_stroke_styles as _iter_project_stroke_styles,
|
|
iter_project_transforms as _iter_project_transforms,
|
|
noncanonical_stroke_dash_numbers as _noncanonical_stroke_dash_numbers,
|
|
noncanonical_transform_numbers as _noncanonical_transform_numbers,
|
|
parse_inline_style as _parse_inline_style,
|
|
parse_project_geometry_length as _parse_project_geometry_length,
|
|
parse_project_image_aspect_ratio as _parse_project_image_aspect_ratio,
|
|
parse_project_opacity as _parse_project_opacity,
|
|
parse_project_paint as _parse_project_paint,
|
|
parse_project_stroke_dasharray as _parse_project_stroke_dasharray,
|
|
parse_project_stroke_enum as _parse_project_stroke_enum,
|
|
parse_svg_length as _parse_export_length,
|
|
project_definition_errors as _project_definition_errors,
|
|
project_filter_errors as _project_filter_errors,
|
|
project_gradient_errors as _project_gradient_errors,
|
|
project_image_aspect_ratio_errors as _project_image_aspect_ratio_errors,
|
|
project_mask_errors as _project_mask_errors,
|
|
project_marker_errors as _project_marker_errors,
|
|
project_opacity_errors as _project_opacity_errors,
|
|
project_paint_errors as _project_paint_errors,
|
|
project_paint_reference_errors as _project_paint_reference_errors,
|
|
project_stroke_style_errors as _project_stroke_style_errors,
|
|
project_transform_errors as _project_transform_errors,
|
|
)
|
|
except ImportError:
|
|
_DRAWINGML_TEXT_FONT_SIZE_MAX = None
|
|
_DRAWINGML_TEXT_FONT_SIZE_MIN = None
|
|
_OPACITY_PROPERTIES = None
|
|
_PAINT_PROPERTIES = None
|
|
_PERCENTAGE_OPACITY_PROPERTIES = None
|
|
_format_project_geometry_length = None
|
|
_format_project_image_aspect_ratio = None
|
|
_format_project_opacity = None
|
|
_font_px_to_hpt = None
|
|
_is_canonical_project_geometry_length = None
|
|
_is_project_opacity_default_form = None
|
|
_is_project_paint_default_form = None
|
|
_iter_project_geometry_lengths = None
|
|
_iter_project_image_aspect_ratios = None
|
|
_iter_project_opacities = None
|
|
_iter_project_paints = None
|
|
_iter_project_stroke_styles = None
|
|
_iter_project_transforms = None
|
|
_noncanonical_stroke_dash_numbers = None
|
|
_noncanonical_transform_numbers = None
|
|
_parse_inline_style = None
|
|
_parse_project_geometry_length = None
|
|
_parse_project_image_aspect_ratio = None
|
|
_parse_project_opacity = None
|
|
_parse_project_paint = None
|
|
_parse_project_stroke_dasharray = None
|
|
_parse_project_stroke_enum = None
|
|
_parse_export_length = None
|
|
_project_definition_errors = None
|
|
_project_filter_errors = None
|
|
_project_gradient_errors = None
|
|
_project_image_aspect_ratio_errors = None
|
|
_project_mask_errors = None
|
|
_project_marker_errors = None
|
|
_project_opacity_errors = None
|
|
_project_paint_errors = None
|
|
_project_paint_reference_errors = None
|
|
_project_stroke_style_errors = None
|
|
_project_transform_errors = None
|
|
|
|
try:
|
|
from svg_to_pptx.drawingml.paths import (
|
|
iter_project_freeform_geometry as _iter_project_freeform_geometry,
|
|
noncanonical_path_numbers as _noncanonical_path_numbers,
|
|
noncanonical_points_numbers as _noncanonical_points_numbers,
|
|
project_gradient_geometry_errors as _project_gradient_geometry_errors,
|
|
)
|
|
except ImportError:
|
|
_iter_project_freeform_geometry = None
|
|
_noncanonical_path_numbers = None
|
|
_noncanonical_points_numbers = None
|
|
_project_gradient_geometry_errors = None
|
|
|
|
try:
|
|
from svg_to_pptx.drawingml.elements import (
|
|
project_clip_path_errors as _project_clip_path_errors,
|
|
project_nested_svg_crop_errors as _project_nested_svg_crop_errors,
|
|
)
|
|
except ImportError:
|
|
_project_clip_path_errors = None
|
|
_project_nested_svg_crop_errors = None
|
|
|
|
try:
|
|
from svg_to_pptx.drawingml.text_properties import (
|
|
project_text_property_diagnostics as _project_text_property_diagnostics,
|
|
)
|
|
except ImportError:
|
|
_project_text_property_diagnostics = None
|
|
|
|
try:
|
|
from svg_to_pptx.geometry_properties import (
|
|
materialize_inline_geometry_properties as _materialize_inline_geometry_properties,
|
|
validate_inline_geometry_properties as _validate_inline_geometry_properties,
|
|
)
|
|
except ImportError:
|
|
_materialize_inline_geometry_properties = None
|
|
_validate_inline_geometry_properties = None
|
|
|
|
try:
|
|
from svg_to_pptx.use_expander import (
|
|
UseExpansionError as _UseExpansionError,
|
|
expand_local_use_references as _expand_local_use_references,
|
|
validate_local_use_references as _validate_local_use_references,
|
|
)
|
|
except ImportError:
|
|
_UseExpansionError = None
|
|
_expand_local_use_references = None
|
|
_validate_local_use_references = None
|
|
|
|
_CANONICAL_PAINT_ALPHA_PROPERTY = {
|
|
"fill": "fill-opacity",
|
|
"stroke": "stroke-opacity",
|
|
"stop-color": "stop-opacity",
|
|
"flood-color": "flood-opacity",
|
|
}
|
|
_SUPPORTED_INLINE_STYLE_PROPERTIES = frozenset({
|
|
"cx", "cy", "fill", "fill-opacity", "filter", "flood-color",
|
|
"flood-opacity", "font-family", "font-size", "font-style", "font-weight",
|
|
"height", "letter-spacing", "opacity", "r", "rx", "ry",
|
|
"shape-rendering", "stop-color", "stop-opacity", "stroke",
|
|
"stroke-dasharray", "stroke-linecap", "stroke-linejoin", "stroke-opacity",
|
|
"stroke-width", "text-anchor", "text-decoration", "vector-effect",
|
|
"width", "x", "y",
|
|
})
|
|
_BAKE_REQUIRED_VISUAL_PROPERTIES = frozenset({
|
|
"backdrop-filter",
|
|
"isolation",
|
|
"mix-blend-mode",
|
|
})
|
|
_SHARED_FAIL_CLOSED_STYLE_PROPERTIES = frozenset({"mask"})
|
|
|
|
|
|
def check_forbidden_elements(
|
|
content: str,
|
|
root: ET.Element,
|
|
result: Dict,
|
|
) -> None:
|
|
"""Check forbidden elements (blocklist)"""
|
|
content_lower = content.lower()
|
|
elems = list(root.iter())
|
|
local_names = {_local_name(elem).lower() for elem in elems}
|
|
|
|
# ============================================================
|
|
# Forbidden elements blocklist - PPT incompatible
|
|
# ============================================================
|
|
|
|
# Style system
|
|
if 'style' in local_names:
|
|
result['errors'].append("Detected forbidden <style> element (use inline attributes instead)")
|
|
if re.search(r'\bclass\s*=', content):
|
|
result['errors'].append("Detected forbidden class attribute (use inline styles instead)")
|
|
# id attribute: only report error when <style> also exists (id is harmful only with CSS selectors)
|
|
# id inside <defs> for linearGradient/filter etc. is required, Inkscape also auto-adds id to elements,
|
|
# standalone id attributes have no impact on PPT export
|
|
if 'style' in local_names and re.search(r'\bid\s*=', content):
|
|
result['errors'].append(
|
|
"Detected id attribute used with <style> (CSS selectors forbidden, use inline styles instead)"
|
|
)
|
|
if re.search(r'<\?xml-stylesheet\b', content_lower):
|
|
result['errors'].append("Detected forbidden xml-stylesheet (external CSS references forbidden)")
|
|
if re.search(r'<link[^>]*rel\s*=\s*["\']stylesheet["\']', content_lower):
|
|
result['errors'].append("Detected forbidden <link rel=\"stylesheet\"> (external CSS references forbidden)")
|
|
if re.search(r'@import\s+', content_lower):
|
|
result['errors'].append("Detected forbidden @import (external CSS references forbidden)")
|
|
if _validate_inline_geometry_properties is None:
|
|
result['warnings'].append(
|
|
"Unable to import inline geometry validator; "
|
|
"native export will still validate geometry styles."
|
|
)
|
|
else:
|
|
geometry_errors = _validate_inline_geometry_properties(root)
|
|
for error in geometry_errors:
|
|
result['errors'].append(f"Invalid inline geometry property: {error}")
|
|
if not geometry_errors:
|
|
_materialize_inline_geometry_properties(root)
|
|
|
|
# Structure / nesting
|
|
if 'foreignobject' in local_names:
|
|
result['errors'].append(
|
|
"Detected forbidden <foreignObject> element (use <tspan> for manual line breaks)")
|
|
has_generic_use = any(
|
|
_local_name(elem).lower() == 'use' and elem.get('data-icon') is None
|
|
for elem in elems
|
|
)
|
|
if has_generic_use:
|
|
if _validate_local_use_references is None:
|
|
result['warnings'].append(
|
|
"Detected local <use> references, but the shared validator "
|
|
"could not be imported; native export will still validate them."
|
|
)
|
|
else:
|
|
for error in _validate_local_use_references(root):
|
|
result['errors'].append(f"Invalid local <use> reference: {error}")
|
|
# Text / fonts
|
|
if 'textpath' in local_names:
|
|
result['errors'].append("Detected forbidden <textPath> element (path text is incompatible with PPT)")
|
|
if '@font-face' in content_lower:
|
|
result['errors'].append("Detected forbidden @font-face (use system font stack)")
|
|
|
|
# Animation / interaction
|
|
if any(name.startswith('animate') for name in local_names):
|
|
result['errors'].append(
|
|
"Detected forbidden SMIL animation element <animate*> "
|
|
"(SVG animations are not exported)"
|
|
)
|
|
if 'set' in local_names:
|
|
result['errors'].append("Detected forbidden SMIL animation element <set> (SVG animations are not exported)")
|
|
if 'script' in local_names:
|
|
result['errors'].append("Detected forbidden <script> element (scripts and event handlers forbidden)")
|
|
if re.search(r'\bon\w+\s*=', content): # onclick, onload etc.
|
|
result['errors'].append("Detected forbidden event attributes (e.g., onclick, onload)")
|
|
|
|
# Other discouraged elements
|
|
if 'iframe' in local_names:
|
|
result['errors'].append("Detected <iframe> element (should not appear in SVG)")
|
|
|
|
|
|
def check_paint_compatibility(
|
|
root: ET.Element,
|
|
result: Dict,
|
|
) -> None:
|
|
"""Reject unsupported paint and advise one generated-SVG spelling.
|
|
|
|
The exporter parser owns compatibility. Any paint it can parse remains
|
|
valid input; the checker only warns when that spelling differs from the
|
|
generated-SVG default (uppercase ``#RRGGBB`` plus explicit alpha).
|
|
"""
|
|
helpers = (
|
|
_PAINT_PROPERTIES,
|
|
_PERCENTAGE_OPACITY_PROPERTIES,
|
|
_format_project_opacity,
|
|
_is_project_paint_default_form,
|
|
_iter_project_paints,
|
|
_parse_inline_style,
|
|
_parse_project_opacity,
|
|
_parse_project_paint,
|
|
_project_paint_errors,
|
|
)
|
|
if any(helper is None for helper in helpers):
|
|
result['warnings'].append(
|
|
"Unable to import svg_to_pptx paint parsers; skipped paint syntax check"
|
|
)
|
|
return
|
|
|
|
result['errors'].extend(_project_paint_errors(root))
|
|
recommendations: Counter[tuple[str, str, str]] = Counter()
|
|
recommendation_examples: Dict[tuple[str, str, str], List[str]] = defaultdict(list)
|
|
|
|
def remember_example(store: Dict, key: tuple, label: str) -> None:
|
|
labels = store[key]
|
|
if label not in labels and len(labels) < 3:
|
|
labels.append(label)
|
|
|
|
for elem, name, raw_value, source in _iter_project_paints(root):
|
|
try:
|
|
kind, normalized, color_alpha = _parse_project_paint(
|
|
raw_value,
|
|
name,
|
|
)
|
|
except ValueError:
|
|
continue
|
|
if _is_project_paint_default_form(raw_value, name):
|
|
continue
|
|
|
|
source_label = f'{_element_label(elem)} {source}'
|
|
if kind == 'none':
|
|
replacement = f'{name}="none"'
|
|
elif kind == 'reference':
|
|
replacement = f'{name}="url(#{normalized})"'
|
|
elif name in {'fill', 'stroke'} and raw_value.strip().lower() == 'transparent':
|
|
replacement = f'{name}="none"'
|
|
else:
|
|
replacement = f'{name}="#{normalized}"'
|
|
alpha_name = _CANONICAL_PAINT_ALPHA_PROPERTY.get(name)
|
|
if color_alpha < 1.0 and alpha_name is not None:
|
|
style_values = _parse_inline_style(elem.get('style'))
|
|
existing_alpha_raw = (
|
|
style_values.get(alpha_name) or elem.get(alpha_name)
|
|
)
|
|
if existing_alpha_raw is None:
|
|
existing_alpha = 1.0
|
|
else:
|
|
try:
|
|
existing_alpha = _parse_project_opacity(
|
|
existing_alpha_raw,
|
|
allow_percentage=(
|
|
alpha_name in _PERCENTAGE_OPACITY_PROPERTIES
|
|
),
|
|
)
|
|
except ValueError:
|
|
existing_alpha = None
|
|
effective_alpha = (
|
|
color_alpha * existing_alpha
|
|
if existing_alpha is not None else color_alpha
|
|
)
|
|
replacement += (
|
|
f' {alpha_name}="'
|
|
f'{_format_project_opacity(effective_alpha)}"'
|
|
)
|
|
elif color_alpha < 1.0:
|
|
replacement += (
|
|
'; put alpha on the matching pattern child fill/stroke '
|
|
'opacity'
|
|
)
|
|
|
|
key = (name, raw_value, replacement)
|
|
recommendations[key] += 1
|
|
remember_example(recommendation_examples, key, source_label)
|
|
|
|
for (name, raw_value, replacement), count in sorted(recommendations.items()):
|
|
examples = ', '.join(
|
|
recommendation_examples[(name, raw_value, replacement)]
|
|
)
|
|
result['warnings'].append(
|
|
f"Recommendation: {name}={raw_value!r} is converter-compatible "
|
|
f"in {count} location(s) ({examples}); generated SVG should "
|
|
f"prefer {replacement}. No change is required for export."
|
|
)
|
|
|
|
|
|
def check_reference_spelling(root: ET.Element, result: Dict) -> None:
|
|
"""Recommend SVG 2 ``href`` while retaining legacy XLink input."""
|
|
labels = []
|
|
xlink_href = f'{{{XLINK_NS}}}href'
|
|
for elem in root.iter():
|
|
if _local_name(elem).lower() not in {'image', 'use'}:
|
|
continue
|
|
if elem.get(xlink_href) is not None:
|
|
labels.append(_element_label(elem))
|
|
if labels:
|
|
examples = ', '.join(labels[:3])
|
|
suffix = f' (+{len(labels) - 3} more)' if len(labels) > 3 else ''
|
|
result['warnings'].append(
|
|
f"Recommendation: legacy xlink:href is supported on {len(labels)} "
|
|
f"reference(s) ({examples}{suffix}); generated SVG should prefer "
|
|
"href. No change is required for export."
|
|
)
|
|
|
|
|
|
def check_opacity_values(
|
|
root: ET.Element,
|
|
result: Dict,
|
|
) -> None:
|
|
"""Reject malformed opacity and advise generated-SVG values."""
|
|
helpers = (
|
|
_PERCENTAGE_OPACITY_PROPERTIES,
|
|
_format_project_opacity,
|
|
_is_project_opacity_default_form,
|
|
_iter_project_opacities,
|
|
_parse_inline_style,
|
|
_parse_project_opacity,
|
|
_project_opacity_errors,
|
|
)
|
|
if any(helper is None for helper in helpers):
|
|
result['warnings'].append(
|
|
"Unable to import svg_to_pptx opacity validators; native "
|
|
"export will still validate opacity syntax."
|
|
)
|
|
return
|
|
|
|
result['errors'].extend(_project_opacity_errors(root))
|
|
recommendations: Counter[tuple[str, str, str]] = Counter()
|
|
examples: Dict[tuple[str, str, str], List[str]] = defaultdict(list)
|
|
fidelity_warnings: set[str] = set()
|
|
|
|
for elem, property_name, raw, source in _iter_project_opacities(root):
|
|
try:
|
|
value = _parse_project_opacity(
|
|
raw,
|
|
allow_percentage=(
|
|
property_name in _PERCENTAGE_OPACITY_PROPERTIES
|
|
),
|
|
)
|
|
except ValueError:
|
|
continue
|
|
if _is_project_opacity_default_form(raw):
|
|
continue
|
|
normalized = _format_project_opacity(value)
|
|
key = (property_name, raw, normalized)
|
|
recommendations[key] += 1
|
|
label = f'{_element_label(elem)} {source}'
|
|
if label not in examples[key] and len(examples[key]) < 3:
|
|
examples[key].append(label)
|
|
|
|
for elem in root.iter():
|
|
if _local_name(elem).lower() != 'g':
|
|
continue
|
|
style_values = _parse_inline_style(elem.get('style'))
|
|
raw_opacity = (
|
|
style_values['opacity']
|
|
if 'opacity' in style_values else elem.get('opacity')
|
|
)
|
|
if raw_opacity is None:
|
|
continue
|
|
try:
|
|
opacity = _parse_project_opacity(raw_opacity)
|
|
except ValueError:
|
|
continue
|
|
if opacity < 1.0:
|
|
fidelity_warnings.add(
|
|
f"Fidelity warning: {_element_label(elem)} uses group "
|
|
f"opacity={raw_opacity!r}. The converter distributes this "
|
|
"alpha to descendants and cannot preserve isolated group "
|
|
"compositing; generated SVG should prefer descendant alpha. "
|
|
"Existing input remains convertible and does not require "
|
|
"modification."
|
|
)
|
|
|
|
for (property_name, raw, normalized), count in sorted(
|
|
recommendations.items()
|
|
):
|
|
shown_examples = ', '.join(
|
|
examples[(property_name, raw, normalized)]
|
|
)
|
|
result['warnings'].append(
|
|
f"Recommendation: {property_name}={raw!r} is "
|
|
f"converter-compatible in {count} location(s) "
|
|
f"({shown_examples}); generated SVG should prefer "
|
|
f'{property_name}="{normalized}". No change is required '
|
|
"for export."
|
|
)
|
|
result['warnings'].extend(sorted(fidelity_warnings))
|
|
|
|
|
|
def check_authoring_property_contract(
|
|
root: ET.Element,
|
|
result: Dict,
|
|
) -> None:
|
|
"""Validate inline CSS and attributes against the authoring surface."""
|
|
errors: set[str] = set()
|
|
validated_value_properties = set(_OPACITY_PROPERTIES or ())
|
|
validated_value_properties.update(_PAINT_PROPERTIES or ())
|
|
for elem in root.iter():
|
|
label = _element_label(elem)
|
|
for fragment in (elem.get('style') or '').split(';'):
|
|
fragment = fragment.strip()
|
|
if not fragment:
|
|
continue
|
|
if ':' not in fragment:
|
|
if fragment.lower() not in validated_value_properties:
|
|
errors.add(
|
|
f"{label} has malformed inline style declaration "
|
|
f"{fragment!r}"
|
|
)
|
|
continue
|
|
name, value = fragment.split(':', 1)
|
|
name = name.strip().lower()
|
|
value = value.strip()
|
|
if not name or not value:
|
|
if name not in validated_value_properties:
|
|
errors.add(
|
|
f"{label} has malformed inline style declaration "
|
|
f"{fragment!r}"
|
|
)
|
|
continue
|
|
if name in _BAKE_REQUIRED_VISUAL_PROPERTIES:
|
|
errors.add(
|
|
f"{label} uses Bake-required visual property {name!r}; "
|
|
"bake the effect or rebuild it with supported geometry"
|
|
)
|
|
elif (
|
|
name not in _SUPPORTED_INLINE_STYLE_PROPERTIES
|
|
and name not in _SHARED_FAIL_CLOSED_STYLE_PROPERTIES
|
|
):
|
|
errors.add(
|
|
f"{label} uses unsupported inline style property {name!r}; "
|
|
"native PPTX export would ignore it"
|
|
)
|
|
if '!important' in value.lower():
|
|
errors.add(
|
|
f"{label} inline style property {name!r} cannot use !important"
|
|
)
|
|
|
|
for attr_name in elem.attrib:
|
|
local_attr = attr_name.rsplit('}', 1)[-1]
|
|
if local_attr in _BAKE_REQUIRED_VISUAL_PROPERTIES:
|
|
errors.add(
|
|
f"{label} uses Bake-required visual attribute {local_attr!r}; "
|
|
"bake the effect or rebuild it with supported geometry"
|
|
)
|
|
|
|
result['errors'].extend(sorted(errors))
|
|
|
|
|
|
def check_text_property_contract(
|
|
root: ET.Element,
|
|
result: Dict,
|
|
) -> None:
|
|
"""Validate text property names and values with the export contract."""
|
|
if _project_text_property_diagnostics is None:
|
|
result['warnings'].append(
|
|
"Unable to import the shared text-property validator; native "
|
|
"export will still validate text properties."
|
|
)
|
|
return
|
|
|
|
errors: set[str] = set()
|
|
recommendations: Counter[tuple[str, str, str]] = Counter()
|
|
examples: Dict[tuple[str, str, str], List[str]] = defaultdict(list)
|
|
for diagnostic in _project_text_property_diagnostics(root):
|
|
if diagnostic.severity == 'error':
|
|
errors.add(diagnostic.message)
|
|
continue
|
|
if diagnostic.canonical is None:
|
|
continue
|
|
key = (
|
|
diagnostic.name,
|
|
diagnostic.raw,
|
|
diagnostic.canonical,
|
|
)
|
|
recommendations[key] += 1
|
|
if (
|
|
diagnostic.label not in examples[key]
|
|
and len(examples[key]) < 3
|
|
):
|
|
examples[key].append(diagnostic.label)
|
|
|
|
result['errors'].extend(sorted(errors))
|
|
for (name, raw, canonical), count in sorted(recommendations.items()):
|
|
shown_examples = ', '.join(examples[(name, raw, canonical)])
|
|
result['warnings'].append(
|
|
f"Recommendation: text property {name}={raw!r} is "
|
|
f"converter-compatible in {count} location(s) "
|
|
f"({shown_examples}); generated SVG should prefer "
|
|
f'{name}="{canonical}". No change is required for export.'
|
|
)
|
|
|
|
|
|
def check_definition_contract(
|
|
root: ET.Element,
|
|
result: Dict,
|
|
) -> None:
|
|
"""Require conditional definitions to be direct, uniquely identified defs."""
|
|
if _project_definition_errors is None:
|
|
result['warnings'].append(
|
|
"Unable to import the shared definition validator; native "
|
|
"export will still validate local definitions."
|
|
)
|
|
return
|
|
result['errors'].extend(_project_definition_errors(root))
|
|
|
|
|
|
def check_paint_reference_contract(
|
|
root: ET.Element,
|
|
result: Dict,
|
|
) -> None:
|
|
"""Validate paint-server resolution and native target contexts."""
|
|
if _project_paint_reference_errors is None:
|
|
result['warnings'].append(
|
|
"Unable to import the shared paint-reference validator; native "
|
|
"export will still validate local paint references."
|
|
)
|
|
return
|
|
result['errors'].extend(_project_paint_reference_errors(root))
|
|
|
|
|
|
def check_marker_contract(
|
|
root: ET.Element,
|
|
result: Dict,
|
|
) -> None:
|
|
"""Validate marker references against the native line-end contract."""
|
|
if _project_marker_errors is None:
|
|
result['warnings'].append(
|
|
'Unable to import the shared marker validator; native export '
|
|
'will still validate line-end markers.'
|
|
)
|
|
return
|
|
result['errors'].extend(_project_marker_errors(root))
|
|
|
|
|
|
def check_clip_path_contract(
|
|
root: ET.Element,
|
|
result: Dict,
|
|
) -> None:
|
|
"""Validate image clip paths against the native picture geometry mapping."""
|
|
if _project_clip_path_errors is None:
|
|
result['errors'].append(
|
|
'Unable to import the clip-path validator; cannot verify '
|
|
'native picture geometry references'
|
|
)
|
|
return
|
|
result['errors'].extend(_project_clip_path_errors(root))
|
|
|
|
|
|
def check_mask_contract(root: ET.Element, result: Dict) -> None:
|
|
"""Reject SVG masks through the native exporter's shared validator."""
|
|
if _project_mask_errors is None:
|
|
result['errors'].append(
|
|
'Unable to import the shared mask validator; cannot verify '
|
|
'that native PPTX export will preserve all visible effects'
|
|
)
|
|
return
|
|
result['errors'].extend(_project_mask_errors(root))
|
|
|
|
|
|
def check_filter_effects(root: ET.Element, result: Dict) -> None:
|
|
"""Validate filters against the native shadow/glow approximation."""
|
|
if _project_filter_errors is None:
|
|
result['warnings'].append(
|
|
"Unable to import the shared filter validator; native export "
|
|
"will still validate shadow/glow filters."
|
|
)
|
|
return
|
|
result['errors'].extend(_project_filter_errors(root))
|
|
|
|
|
|
def check_imported_effect_status(
|
|
root: ET.Element,
|
|
result: Dict,
|
|
) -> None:
|
|
"""Reject source PPTX effects that have no faithful SVG mapping."""
|
|
if _project_effect_status_errors is None:
|
|
if any(
|
|
elem.get(_EFFECT_STATUS_ATTR) is not None
|
|
or elem.get(_EFFECT_REASON_ATTR) is not None
|
|
for elem in root.iter()
|
|
):
|
|
result['errors'].append(
|
|
'Unable to import the PPTX effect-status validator; '
|
|
'cannot verify imported effect fidelity'
|
|
)
|
|
return
|
|
result['errors'].extend(_project_effect_status_errors(root))
|
|
|
|
|
|
def check_gradient_interfaces(root: ET.Element, result: Dict) -> None:
|
|
"""Validate the normalized native gradient authoring interface."""
|
|
if (
|
|
_project_gradient_errors is None
|
|
or _project_gradient_geometry_errors is None
|
|
):
|
|
result['warnings'].append(
|
|
"Unable to import the shared gradient validator; native export "
|
|
"will still validate gradient definitions."
|
|
)
|
|
return
|
|
gradient_errors = set(_project_gradient_errors(root))
|
|
gradient_errors.update(_project_gradient_geometry_errors(root))
|
|
if (
|
|
_expand_local_use_references is not None
|
|
and _UseExpansionError is not None
|
|
):
|
|
expanded_root = copy.deepcopy(root)
|
|
try:
|
|
_expand_local_use_references(expanded_root)
|
|
except _UseExpansionError:
|
|
# The local-reference check owns the actionable diagnostic.
|
|
pass
|
|
else:
|
|
gradient_errors.update(
|
|
_project_gradient_geometry_errors(expanded_root)
|
|
)
|
|
result['errors'].extend(sorted(gradient_errors))
|
|
|
|
|
|
def check_geometry_length_values(
|
|
root: ET.Element,
|
|
result: Dict,
|
|
) -> None:
|
|
"""Reject invalid project geometry and advise the unitless spelling."""
|
|
if (
|
|
_format_project_geometry_length is None
|
|
or _is_canonical_project_geometry_length is None
|
|
or _iter_project_geometry_lengths is None
|
|
or _parse_project_geometry_length is None
|
|
):
|
|
result['warnings'].append(
|
|
"Unable to import svg_to_pptx geometry length validators; "
|
|
"native export will still validate project geometry."
|
|
)
|
|
return
|
|
|
|
errors: set[str] = set()
|
|
recommendations: Counter[tuple[str, str, str]] = Counter()
|
|
examples: Dict[tuple[str, str, str], List[str]] = defaultdict(list)
|
|
|
|
for elem, attribute, raw, source in _iter_project_geometry_lengths(root):
|
|
label = f'{_element_label(elem)} {source}'
|
|
try:
|
|
value = _parse_project_geometry_length(raw, attribute)
|
|
except ValueError as exc:
|
|
errors.add(f"{label} {attribute}={raw!r}: {exc}")
|
|
continue
|
|
if _is_canonical_project_geometry_length(raw):
|
|
continue
|
|
normalized = _format_project_geometry_length(value)
|
|
key = (attribute, raw, normalized)
|
|
recommendations[key] += 1
|
|
if label not in examples[key] and len(examples[key]) < 3:
|
|
examples[key].append(label)
|
|
|
|
result['errors'].extend(sorted(errors))
|
|
for (attribute, raw, normalized), count in sorted(recommendations.items()):
|
|
shown_examples = ', '.join(examples[(attribute, raw, normalized)])
|
|
result['warnings'].append(
|
|
f"Recommendation: project geometry {attribute}={raw!r} is "
|
|
f"converter-compatible in {count} location(s) ({shown_examples}); "
|
|
f"generated SVG should prefer the unitless px spelling "
|
|
f'{attribute}="{normalized}". No change is required for export.'
|
|
)
|
|
|
|
|
|
def check_stroke_style_values(
|
|
root: ET.Element,
|
|
result: Dict,
|
|
) -> None:
|
|
"""Reject invalid line styles and advise project-canonical spellings."""
|
|
helpers = (
|
|
_format_project_geometry_length,
|
|
_is_canonical_project_geometry_length,
|
|
_iter_project_stroke_styles,
|
|
_noncanonical_stroke_dash_numbers,
|
|
_parse_project_geometry_length,
|
|
_parse_project_stroke_dasharray,
|
|
_parse_project_stroke_enum,
|
|
_project_stroke_style_errors,
|
|
)
|
|
if any(helper is None for helper in helpers):
|
|
result['warnings'].append(
|
|
"Unable to import svg_to_pptx line-style validators; native "
|
|
"export will still validate line-presentation syntax."
|
|
)
|
|
return
|
|
|
|
result['errors'].extend(_project_stroke_style_errors(root))
|
|
recommendations: Counter[tuple[str, str, str, str]] = Counter()
|
|
examples: Dict[tuple[str, str, str, str], List[str]] = defaultdict(list)
|
|
|
|
for elem, attribute, raw, source in _iter_project_stroke_styles(root):
|
|
label = f'{_element_label(elem)} {source}'
|
|
normalized = None
|
|
reason = ''
|
|
|
|
if attribute == 'stroke-dasharray':
|
|
try:
|
|
parsed = _parse_project_stroke_dasharray(
|
|
raw,
|
|
allow_zero_gap=True,
|
|
)
|
|
noncanonical = _noncanonical_stroke_dash_numbers(raw)
|
|
except ValueError:
|
|
continue
|
|
if parsed is None:
|
|
if raw != 'none':
|
|
normalized = 'none'
|
|
reason = 'remove surrounding whitespace'
|
|
else:
|
|
preset, values = parsed
|
|
longer_custom = preset is None and len(values) > 2
|
|
if noncanonical or longer_custom or raw != raw.strip():
|
|
kept_values = values[:2] if longer_custom else values
|
|
normalized = ' '.join(
|
|
_format_project_geometry_length(value)
|
|
for value in kept_values
|
|
)
|
|
reasons = []
|
|
if noncanonical:
|
|
reasons.append('use ordinary decimal numbers')
|
|
if longer_custom:
|
|
reasons.append(
|
|
'make the first-pair export normalization explicit'
|
|
)
|
|
if raw != raw.strip():
|
|
reasons.append('remove surrounding whitespace')
|
|
reason = '; '.join(reasons)
|
|
elif attribute == 'stroke-dashoffset':
|
|
try:
|
|
value = _parse_project_geometry_length(raw, attribute)
|
|
except ValueError:
|
|
continue
|
|
if not _is_canonical_project_geometry_length(raw):
|
|
normalized = _format_project_geometry_length(value)
|
|
reason = 'use the unitless px spelling'
|
|
else:
|
|
try:
|
|
value = _parse_project_stroke_enum(attribute, raw)
|
|
except ValueError:
|
|
continue
|
|
if raw != value:
|
|
normalized = value
|
|
reason = 'remove surrounding whitespace'
|
|
|
|
if normalized is None:
|
|
continue
|
|
key = (attribute, raw, normalized, reason)
|
|
recommendations[key] += 1
|
|
if label not in examples[key] and len(examples[key]) < 3:
|
|
examples[key].append(label)
|
|
|
|
for (attribute, raw, normalized, reason), count in sorted(
|
|
recommendations.items()
|
|
):
|
|
shown_examples = ', '.join(
|
|
examples[(attribute, raw, normalized, reason)]
|
|
)
|
|
result['warnings'].append(
|
|
f"Recommendation: line style {attribute}={raw!r} is "
|
|
f"converter-compatible in {count} location(s) "
|
|
f"({shown_examples}); generated SVG should prefer "
|
|
f'{attribute}="{normalized}" to {reason}. No change is '
|
|
"required for export."
|
|
)
|
|
|
|
|
|
def check_image_aspect_ratio_values(
|
|
root: ET.Element,
|
|
result: Dict,
|
|
) -> None:
|
|
"""Reject ambiguous image fit/crop values and advise canonical forms."""
|
|
helpers = (
|
|
_format_project_image_aspect_ratio,
|
|
_iter_project_image_aspect_ratios,
|
|
_parse_project_image_aspect_ratio,
|
|
_project_image_aspect_ratio_errors,
|
|
)
|
|
if any(helper is None for helper in helpers):
|
|
result['warnings'].append(
|
|
"Unable to import svg_to_pptx image aspect-ratio validators; "
|
|
"native export will still validate image fit/crop syntax."
|
|
)
|
|
return
|
|
|
|
result['errors'].extend(_project_image_aspect_ratio_errors(root))
|
|
recommendations: Counter[tuple[str, str]] = Counter()
|
|
examples: Dict[tuple[str, str], List[str]] = defaultdict(list)
|
|
|
|
for elem, raw in _iter_project_image_aspect_ratios(root):
|
|
try:
|
|
align, mode = _parse_project_image_aspect_ratio(raw)
|
|
except ValueError:
|
|
continue
|
|
normalized = _format_project_image_aspect_ratio(align, mode)
|
|
if raw == normalized:
|
|
continue
|
|
key = (raw, normalized)
|
|
recommendations[key] += 1
|
|
label = _element_label(elem)
|
|
if label not in examples[key] and len(examples[key]) < 3:
|
|
examples[key].append(label)
|
|
|
|
for (raw, normalized), count in sorted(recommendations.items()):
|
|
shown_examples = ', '.join(examples[(raw, normalized)])
|
|
result['warnings'].append(
|
|
f"Recommendation: image preserveAspectRatio={raw!r} is "
|
|
f"converter-compatible in {count} location(s) "
|
|
f"({shown_examples}); generated SVG should prefer "
|
|
f'preserveAspectRatio="{normalized}". No change is required '
|
|
"for export."
|
|
)
|
|
|
|
|
|
def check_nested_svg_crop_contract(
|
|
root: ET.Element,
|
|
result: Dict,
|
|
) -> None:
|
|
"""Reserve nested SVG for the imported picture-crop transport."""
|
|
if _project_nested_svg_crop_errors is None:
|
|
result['errors'].append(
|
|
'Unable to import the nested SVG crop validator; cannot '
|
|
'verify imported picture-crop wrappers'
|
|
)
|
|
return
|
|
result['errors'].extend(_project_nested_svg_crop_errors(root))
|
|
|
|
|
|
def check_freeform_geometry_values(
|
|
root: ET.Element,
|
|
result: Dict,
|
|
) -> None:
|
|
"""Reject malformed path/points syntax and advise decimal spelling."""
|
|
helpers = (
|
|
_format_project_geometry_length,
|
|
_iter_project_freeform_geometry,
|
|
_noncanonical_path_numbers,
|
|
_noncanonical_points_numbers,
|
|
)
|
|
if any(helper is None for helper in helpers):
|
|
result['warnings'].append(
|
|
"Unable to import svg_to_pptx freeform geometry validators; "
|
|
"native export will still validate path and points syntax."
|
|
)
|
|
return
|
|
|
|
errors: set[str] = set()
|
|
recommendations: Counter[tuple[str, str, str]] = Counter()
|
|
examples: Dict[tuple[str, str, str], List[str]] = defaultdict(list)
|
|
|
|
for elem, attribute, raw, min_points in _iter_project_freeform_geometry(root):
|
|
label = _element_label(elem)
|
|
try:
|
|
if raw is None:
|
|
tag = _local_name(elem)
|
|
raise ValueError(f'<{tag}> requires {attribute}')
|
|
if attribute == 'd':
|
|
compatible_numbers = _noncanonical_path_numbers(raw)
|
|
else:
|
|
required_points = min_points or 2
|
|
compatible_numbers = _noncanonical_points_numbers(
|
|
raw,
|
|
min_points=required_points,
|
|
)
|
|
except ValueError as exc:
|
|
errors.add(f'{label} {attribute}: {exc}')
|
|
continue
|
|
|
|
for number in compatible_numbers:
|
|
normalized = _format_project_geometry_length(float(number))
|
|
key = (attribute, number, normalized)
|
|
recommendations[key] += 1
|
|
if label not in examples[key] and len(examples[key]) < 3:
|
|
examples[key].append(label)
|
|
|
|
result['errors'].extend(sorted(errors))
|
|
for (attribute, raw, normalized), count in sorted(recommendations.items()):
|
|
shown_examples = ', '.join(examples[(attribute, raw, normalized)])
|
|
result['warnings'].append(
|
|
f"Recommendation: freeform geometry {attribute} numeric token "
|
|
f"{raw!r} is converter-compatible in {count} occurrence(s) "
|
|
f"({shown_examples}); generated SVG should prefer the ordinary "
|
|
f"decimal spelling {normalized!r}. No change is required for export."
|
|
)
|
|
|
|
|
|
def check_transform_values(
|
|
root: ET.Element,
|
|
result: Dict,
|
|
) -> None:
|
|
"""Reject invalid transforms and advise ordinary decimal spelling."""
|
|
helpers = (
|
|
_format_project_geometry_length,
|
|
_iter_project_transforms,
|
|
_noncanonical_transform_numbers,
|
|
_project_transform_errors,
|
|
)
|
|
if any(helper is None for helper in helpers):
|
|
result['warnings'].append(
|
|
"Unable to import svg_to_pptx transform validators; "
|
|
"native export will still validate transform syntax."
|
|
)
|
|
return
|
|
|
|
transform_errors = set(_project_transform_errors(root))
|
|
if (
|
|
not transform_errors
|
|
and _expand_local_use_references is not None
|
|
and _UseExpansionError is not None
|
|
):
|
|
expanded_root = copy.deepcopy(root)
|
|
try:
|
|
_expand_local_use_references(expanded_root)
|
|
except _UseExpansionError:
|
|
# The local-reference check owns the actionable diagnostic.
|
|
pass
|
|
else:
|
|
transform_errors.update(_project_transform_errors(expanded_root))
|
|
result['errors'].extend(
|
|
f'Invalid SVG transform: {error}'
|
|
for error in sorted(transform_errors)
|
|
)
|
|
|
|
recommendations: Counter[tuple[str, str]] = Counter()
|
|
examples: Dict[tuple[str, str], List[str]] = defaultdict(list)
|
|
for elem, raw in _iter_project_transforms(root):
|
|
try:
|
|
compatible_numbers = _noncanonical_transform_numbers(raw)
|
|
except ValueError:
|
|
continue
|
|
for number in compatible_numbers:
|
|
normalized = _format_project_geometry_length(float(number))
|
|
key = (number, normalized)
|
|
recommendations[key] += 1
|
|
label = _element_label(elem)
|
|
if label not in examples[key] and len(examples[key]) < 3:
|
|
examples[key].append(label)
|
|
|
|
for (raw, normalized), count in sorted(recommendations.items()):
|
|
shown_examples = ', '.join(examples[(raw, normalized)])
|
|
result['warnings'].append(
|
|
f"Recommendation: transform numeric token {raw!r} is "
|
|
f"converter-compatible in {count} occurrence(s) "
|
|
f"({shown_examples}); generated SVG should prefer the ordinary "
|
|
f"decimal spelling {normalized!r}. No change is required for export."
|
|
)
|
|
|
|
|
|
def check_font_size_values(content: str, result: Dict) -> None:
|
|
"""Keep supported font-size units compatible and recommend unitless px."""
|
|
canonical_re = re.compile(r'^(?:\d+(?:\.\d+)?|\.\d+)$')
|
|
values = set()
|
|
|
|
for match in re.finditer(r'\bfont-size\s*=\s*(["\'])(.*?)\1', content, re.IGNORECASE):
|
|
values.add(match.group(2).strip())
|
|
|
|
for match in re.finditer(r'\bfont-size\s*:\s*([^;"\']+)', content, re.IGNORECASE):
|
|
values.add(match.group(1).strip())
|
|
|
|
if _parse_export_length is None:
|
|
result['warnings'].append(
|
|
"Unable to import svg_to_pptx length parser; skipped font-size syntax check"
|
|
)
|
|
return
|
|
|
|
unsupported = set()
|
|
drawingml_out_of_range = set()
|
|
compatible_noncanonical = set()
|
|
for raw in values:
|
|
try:
|
|
parsed_px = _parse_export_length(raw, math.nan, font_size=16)
|
|
except (TypeError, ValueError):
|
|
unsupported.add(raw)
|
|
continue
|
|
if not math.isfinite(parsed_px) or parsed_px < 0:
|
|
unsupported.add(raw)
|
|
continue
|
|
if _font_px_to_hpt is not None:
|
|
try:
|
|
_font_px_to_hpt(parsed_px)
|
|
except ValueError:
|
|
drawingml_out_of_range.add(raw)
|
|
continue
|
|
if not canonical_re.fullmatch(raw):
|
|
compatible_noncanonical.add(raw)
|
|
|
|
if unsupported:
|
|
shown_values = sorted(unsupported)
|
|
shown = ', '.join(shown_values[:5])
|
|
more = len(shown_values) - 5
|
|
suffix = f" (+{more} more)" if more > 0 else ""
|
|
result['errors'].append(
|
|
f"Unsupported font-size value(s): {shown}{suffix}. Use a finite "
|
|
"non-negative SVG length supported by svg_to_pptx."
|
|
)
|
|
|
|
if drawingml_out_of_range:
|
|
shown_values = sorted(drawingml_out_of_range)
|
|
shown = ', '.join(shown_values[:5])
|
|
more = len(shown_values) - 5
|
|
suffix = f" (+{more} more)" if more > 0 else ""
|
|
result['errors'].append(
|
|
f"font-size value(s) {shown}{suffix} are outside the DrawingML "
|
|
f"range sz={_DRAWINGML_TEXT_FONT_SIZE_MIN}.."
|
|
f"{_DRAWINGML_TEXT_FONT_SIZE_MAX} (1..4000pt); PowerPoint would "
|
|
"repair the exported file. Do not use tiny transparent text as "
|
|
"a placeholder carrier: leave a text carrier blank or use the "
|
|
"composite object proxy contract."
|
|
)
|
|
|
|
if compatible_noncanonical:
|
|
shown_values = sorted(compatible_noncanonical)
|
|
shown = ', '.join(shown_values[:5])
|
|
more = len(shown_values) - 5
|
|
suffix = f" (+{more} more)" if more > 0 else ""
|
|
result['warnings'].append(
|
|
f"Recommendation: font-size value(s) {shown}{suffix} are "
|
|
"converter-compatible; generated SVG should prefer unitless px "
|
|
"values such as font-size=\"28\". No change is required for export."
|
|
)
|