"""Convert the closed DrawingML effect subset into project SVG filters. One classifiable outer shadow or one glow maps to the public shadow/glow contract. Every other source effect receives explicit blocking metadata; the importer never relabels it as another native effect or drops it silently. """ from __future__ import annotations import math import re from dataclasses import dataclass from xml.etree import ElementTree as ET from pptx_effects import unsupported_effect_metadata from pptx_shapes.formula import OOXML_COORDINATE_MAX from .color_resolver import COLOR_TAGS, ColorPalette, resolve_color from .emu_units import NS, emu_to_px, fmt_num, format_ooxml_alpha _OOXML_INTEGER_RE = re.compile(r"[+-]?\d+") _OOXML_HEX_COLOR_RE = re.compile(r"[0-9A-Fa-f]{6}") _DRAWINGML_NAMESPACE = NS["a"] _DRAWINGML_TAG_PREFIX = f"{{{_DRAWINGML_NAMESPACE}}}" _EFFECT_CONTAINER_NAMES = frozenset({"effectLst", "effectDag"}) _OUTER_SHADOW_ATTRIBUTES = frozenset({ "algn", "blurRad", "dir", "dist", "kx", "ky", "rotWithShape", "sx", "sy", }) _OUTER_SHADOW_ALIGNMENTS = frozenset({ "b", "bl", "br", "ctr", "l", "r", "t", "tl", "tr", }) @dataclass(frozen=True) class EffectResult: """One supported SVG filter or one explicit unsupported-source marker.""" filter_id: str | None = None defs: tuple[str, ...] = () metadata: tuple[tuple[str, str], ...] = () @classmethod def unsupported(cls, reason: str) -> "EffectResult": return cls(metadata=tuple(unsupported_effect_metadata(reason).items())) def unsupported_target_effect_metadata( sp_pr: ET.Element | None, target: str, ) -> dict[str, str]: """Mark source effects that cannot attach to this SVG target type.""" if sp_pr is None: return {} effect_names: list[str] = [] for container in sp_pr: if ( not isinstance(container.tag, str) or _local_name(container) not in _EFFECT_CONTAINER_NAMES ): continue container_name = _local_name(container) if container_name == "effectDag": effect_names.append(container_name) continue effect_names.extend( _local_name(child) for child in container if isinstance(child.tag, str) ) if not effect_names: return {} return unsupported_effect_metadata( f"unsupported-effect-target:{target}:" + ",".join(effect_names) ) def convert_effects( sp_pr: ET.Element | None, palette: ColorPalette | None, *, id_prefix: str = "fx", id_seq: list[int] | None = None, target_rotation_degrees: float = 0.0, ) -> EffectResult: """Return one supported filter or blocking metadata for source effects.""" if sp_pr is None: return EffectResult() containers = [ child for child in sp_pr if isinstance(child.tag, str) and _local_name(child) in _EFFECT_CONTAINER_NAMES ] if not containers: return EffectResult() container_names = [_local_name(child) for child in containers] if len(containers) != 1: return EffectResult.unsupported( "multiple-effect-containers:" + ",".join(container_names) ) container = containers[0] container_name = container_names[0] if not container.tag.startswith(_DRAWINGML_TAG_PREFIX): return EffectResult.unsupported( f"invalid-effect-container-namespace:{container_name}" ) if container_name == "effectDag": return EffectResult.unsupported("unsupported-effect-container:effectDag") effects = [ child for child in container if isinstance(child.tag, str) ] if not effects: return EffectResult() names = [child.tag.split("}", 1)[-1] for child in effects] if len(effects) != 1: return EffectResult.unsupported( "multiple-effects:" + ",".join(names) ) effect = effects[0] effect_name = names[0] if not effect.tag.startswith(_DRAWINGML_TAG_PREFIX): return EffectResult.unsupported( f"invalid-effect-namespace:{effect_name}" ) try: if effect_name == "outerShdw": unsupported_attributes = _unsupported_outer_shadow_attributes( effect, target_rotation_degrees=target_rotation_degrees, ) if unsupported_attributes: return EffectResult.unsupported( "unsupported-effect-attributes:outerShdw:" + ",".join(unsupported_attributes) ) primitives = _outer_shadow(effect, palette) elif effect_name == "glow": primitives = _glow(effect, palette) else: return EffectResult.unsupported( f"unsupported-effect:{effect_name}" ) except (OverflowError, TypeError, ValueError) as exc: return EffectResult.unsupported( f"invalid-effect:{effect_name}:{exc}" ) if id_seq is None: id_seq = [0] id_seq[0] += 1 filter_id = f"{id_prefix}{id_seq[0]}" # Filter region needs to extend beyond the bounding box to render shadows # and glows; choose generous defaults. filter_x = "-25%" filter_y = "-25%" filter_w = "150%" filter_h = "150%" defs_xml = ( f'' + primitives + "" ) return EffectResult(filter_id=filter_id, defs=(defs_xml,)) def _color_alpha(elem: ET.Element, palette: ColorPalette | None) -> tuple[str, float]: direct_children = [ child for child in elem if isinstance(child.tag, str) ] colors = [ child for child in direct_children if _local_name(child) in COLOR_TAGS ] if len(colors) != 1: reason = "missing-color" if not colors else "multiple-colors" raise ValueError(reason) if len(direct_children) != 1: extras = [ _local_name(child) for child in direct_children if child is not colors[0] ] raise ValueError("unexpected-effect-child:" + ",".join(extras)) color = colors[0] if not color.tag.startswith(_DRAWINGML_TAG_PREFIX): raise ValueError(f"invalid-color-namespace:{_local_name(color)}") _validate_color(color, palette) hex_, alpha = resolve_color(color, palette) if hex_ is None: raise ValueError(f"unresolvable-color:{_local_name(color)}") return hex_, alpha def _validate_color( color: ET.Element, palette: ColorPalette | None, ) -> None: """Validate the color subset resolved into one SVG filter paint.""" color_name = _local_name(color) if color_name == "srgbClr": raw = color.get("val", "") if _OOXML_HEX_COLOR_RE.fullmatch(raw) is None: raise ValueError(f"invalid-color:{color_name}") elif color_name == "schemeClr": raw = (color.get("val") or "").strip() resolved = palette.resolve_scheme(raw) if palette is not None else None if resolved is None or _OOXML_HEX_COLOR_RE.fullmatch(resolved) is None: raise ValueError(f"unresolvable-color:{color_name}") elif color_name == "sysClr": if not (color.get("val") or "").strip(): raise ValueError(f"invalid-color:{color_name}") raw = color.get("lastClr") or "" if _OOXML_HEX_COLOR_RE.fullmatch(raw) is None: raise ValueError(f"unresolvable-color:{color_name}") elif color_name == "hslClr": _required_integer(color, "hue", 0, 21_599_999) _required_integer(color, "sat", 0, 100000) _required_integer(color, "lum", 0, 100000) elif color_name == "scrgbClr": for attr in ("r", "g", "b"): _required_integer(color, attr, 0, 100000) elif not (color.get("val") or "").strip(): raise ValueError(f"invalid-color:{color_name}") def _required_integer( elem: ET.Element, attr: str, minimum: int, maximum: int, ) -> int: raw = elem.get(attr) if raw is None: raise ValueError(f"missing-{_local_name(elem)}-{attr}") token = raw.strip() if _OOXML_INTEGER_RE.fullmatch(token) is None: raise ValueError(f"invalid-{_local_name(elem)}-{attr}") value = int(token) if not minimum <= value <= maximum: raise ValueError(f"invalid-{_local_name(elem)}-{attr}") return value def _local_name(elem: ET.Element) -> str: return elem.tag.rsplit("}", 1)[-1] def _effect_integer( elem: ET.Element, attr: str, *, default: int = 0, non_negative: bool = False, maximum: int | None = None, ) -> int: raw = elem.get(attr) if raw is None: value = default else: token = raw.strip() if _OOXML_INTEGER_RE.fullmatch(token) is None: raise ValueError(f"{attr}={raw!r}") value = int(token) if ( (non_negative and value < 0) or (maximum is not None and value > maximum) ): raise ValueError(f"{attr}={raw!r}") return value def _unsupported_outer_shadow_attributes( elem: ET.Element, *, target_rotation_degrees: float, ) -> tuple[str, ...]: """Return source shadow attributes the local SVG filter cannot preserve.""" unsupported = set(elem.attrib) - _OUTER_SHADOW_ATTRIBUTES neutral_transforms = ( ("sx", 100000), ("sy", 100000), ("kx", 0), ("ky", 0), ) for attr, neutral in neutral_transforms: if attr in elem.attrib and _effect_integer(elem, attr) != neutral: unsupported.add(attr) raw_alignment = elem.get("algn") if ( raw_alignment is not None and raw_alignment.strip() not in _OUTER_SHADOW_ALIGNMENTS ): raise ValueError(f"algn={raw_alignment!r}") raw_rotates = elem.get("rotWithShape") if raw_rotates is None: rotates_with_shape = True else: token = raw_rotates.strip() if token in {"1", "true"}: rotates_with_shape = True elif token in {"0", "false"}: rotates_with_shape = False else: raise ValueError(f"rotWithShape={raw_rotates!r}") target_is_rotated = not math.isclose( math.remainder(target_rotation_degrees, 360.0), 0.0, abs_tol=1e-9, ) if rotates_with_shape and target_is_rotated: # CT_OuterShadowEffect defaults rotWithShape to true, while the local # SVG-to-PPTX mapping writes false. Blocking the visible distinction # prevents the next export from changing the source shadow direction. unsupported.add("rotWithShape") return tuple(sorted(unsupported)) def _direction_offset(elem: ET.Element) -> tuple[float, float]: """Read dir / dist into (dx, dy) px.""" direction_units = _effect_integer( elem, "dir", non_negative=True, maximum=21_599_999, ) dist_emu = _effect_integer( elem, "dist", non_negative=True, maximum=OOXML_COORDINATE_MAX, ) direction_deg = direction_units / 60000.0 dist_px = emu_to_px(dist_emu) rad = math.radians(direction_deg) return dist_px * math.cos(rad), dist_px * math.sin(rad) def _blur_radius(elem: ET.Element, attr: str) -> float: return emu_to_px(_effect_integer( elem, attr, non_negative=True, maximum=OOXML_COORDINATE_MAX, )) def _outer_shadow( elem: ET.Element, palette: ColorPalette | None, ) -> str: dx, dy = _direction_offset(elem) blur = _blur_radius(elem, "blurRad") dx_token = fmt_num(dx, 8) dy_token = fmt_num(dy, 8) if abs(float(dx_token)) <= 0.01 and abs(float(dy_token)) <= 0.01: raise ValueError("offset-is-not-classifiable") color, alpha = _color_alpha(elem, palette) # std deviation ~= blur radius / 2 (rough; PowerPoint shadows are larger) std = blur / 2.0 # Use feDropShadow for compactness — it's well-supported in modern browsers. return ( f'' ) def _glow(elem: ET.Element, palette: ColorPalette | None) -> str: if elem.get("rad") is None: raise ValueError("missing-rad") rad = _blur_radius(elem, "rad") color, alpha = _color_alpha(elem, palette) # svg_to_pptx maps stdDeviation directly back to a:glow@rad. std = rad return ( f'' f'' f'' f'' )