| 1 | """Convert the closed DrawingML effect subset into project SVG filters. |
| 2 | |
| 3 | One classifiable outer shadow or one glow maps to the public shadow/glow |
| 4 | contract. Every other source effect receives explicit blocking metadata; the |
| 5 | importer never relabels it as another native effect or drops it silently. |
| 6 | """ |
| 7 | |
| 8 | from __future__ import annotations |
| 9 | |
| 10 | import math |
| 11 | import re |
| 12 | from dataclasses import dataclass |
| 13 | from xml.etree import ElementTree as ET |
| 14 | |
| 15 | from pptx_effects import unsupported_effect_metadata |
| 16 | from pptx_shapes.formula import OOXML_COORDINATE_MAX |
| 17 | |
| 18 | from .color_resolver import COLOR_TAGS, ColorPalette, resolve_color |
| 19 | from .emu_units import NS, emu_to_px, fmt_num, format_ooxml_alpha |
| 20 | |
| 21 | |
| 22 | _OOXML_INTEGER_RE = re.compile(r"[+-]?\d+") |
| 23 | _OOXML_HEX_COLOR_RE = re.compile(r"[0-9A-Fa-f]{6}") |
| 24 | _DRAWINGML_NAMESPACE = NS["a"] |
| 25 | _DRAWINGML_TAG_PREFIX = f"{{{_DRAWINGML_NAMESPACE}}}" |
| 26 | _EFFECT_CONTAINER_NAMES = frozenset({"effectLst", "effectDag"}) |
| 27 | _OUTER_SHADOW_ATTRIBUTES = frozenset({ |
| 28 | "algn", |
| 29 | "blurRad", |
| 30 | "dir", |
| 31 | "dist", |
| 32 | "kx", |
| 33 | "ky", |
| 34 | "rotWithShape", |
| 35 | "sx", |
| 36 | "sy", |
| 37 | }) |
| 38 | _OUTER_SHADOW_ALIGNMENTS = frozenset({ |
| 39 | "b", |
| 40 | "bl", |
| 41 | "br", |
| 42 | "ctr", |
| 43 | "l", |
| 44 | "r", |
| 45 | "t", |
| 46 | "tl", |
| 47 | "tr", |
| 48 | }) |
| 49 | |
| 50 | |
| 51 | @dataclass(frozen=True) |
| 52 | class EffectResult: |
| 53 | """One supported SVG filter or one explicit unsupported-source marker.""" |
| 54 | |
| 55 | filter_id: str | None = None |
| 56 | defs: tuple[str, ...] = () |
| 57 | metadata: tuple[tuple[str, str], ...] = () |
| 58 | |
| 59 | @classmethod |
| 60 | def unsupported(cls, reason: str) -> "EffectResult": |
| 61 | return cls(metadata=tuple(unsupported_effect_metadata(reason).items())) |
| 62 | |
| 63 | |
| 64 | def unsupported_target_effect_metadata( |
| 65 | sp_pr: ET.Element | None, |
| 66 | target: str, |
| 67 | ) -> dict[str, str]: |
| 68 | """Mark source effects that cannot attach to this SVG target type.""" |
| 69 | if sp_pr is None: |
| 70 | return {} |
| 71 | effect_names: list[str] = [] |
| 72 | for container in sp_pr: |
| 73 | if ( |
| 74 | not isinstance(container.tag, str) |
| 75 | or _local_name(container) not in _EFFECT_CONTAINER_NAMES |
| 76 | ): |
| 77 | continue |
| 78 | container_name = _local_name(container) |
| 79 | if container_name == "effectDag": |
| 80 | effect_names.append(container_name) |
| 81 | continue |
| 82 | effect_names.extend( |
| 83 | _local_name(child) |
| 84 | for child in container |
| 85 | if isinstance(child.tag, str) |
| 86 | ) |
| 87 | if not effect_names: |
| 88 | return {} |
| 89 | return unsupported_effect_metadata( |
| 90 | f"unsupported-effect-target:{target}:" + ",".join(effect_names) |
| 91 | ) |
| 92 | |
| 93 | |
| 94 | def convert_effects( |
| 95 | sp_pr: ET.Element | None, |
| 96 | palette: ColorPalette | None, |
| 97 | *, |
| 98 | id_prefix: str = "fx", |
| 99 | id_seq: list[int] | None = None, |
| 100 | target_rotation_degrees: float = 0.0, |
| 101 | ) -> EffectResult: |
| 102 | """Return one supported filter or blocking metadata for source effects.""" |
| 103 | if sp_pr is None: |
| 104 | return EffectResult() |
| 105 | containers = [ |
| 106 | child |
| 107 | for child in sp_pr |
| 108 | if isinstance(child.tag, str) |
| 109 | and _local_name(child) in _EFFECT_CONTAINER_NAMES |
| 110 | ] |
| 111 | if not containers: |
| 112 | return EffectResult() |
| 113 | container_names = [_local_name(child) for child in containers] |
| 114 | if len(containers) != 1: |
| 115 | return EffectResult.unsupported( |
| 116 | "multiple-effect-containers:" + ",".join(container_names) |
| 117 | ) |
| 118 | container = containers[0] |
| 119 | container_name = container_names[0] |
| 120 | if not container.tag.startswith(_DRAWINGML_TAG_PREFIX): |
| 121 | return EffectResult.unsupported( |
| 122 | f"invalid-effect-container-namespace:{container_name}" |
| 123 | ) |
| 124 | if container_name == "effectDag": |
| 125 | return EffectResult.unsupported("unsupported-effect-container:effectDag") |
| 126 | effects = [ |
| 127 | child |
| 128 | for child in container |
| 129 | if isinstance(child.tag, str) |
| 130 | ] |
| 131 | if not effects: |
| 132 | return EffectResult() |
| 133 | names = [child.tag.split("}", 1)[-1] for child in effects] |
| 134 | if len(effects) != 1: |
| 135 | return EffectResult.unsupported( |
| 136 | "multiple-effects:" + ",".join(names) |
| 137 | ) |
| 138 | |
| 139 | effect = effects[0] |
| 140 | effect_name = names[0] |
| 141 | if not effect.tag.startswith(_DRAWINGML_TAG_PREFIX): |
| 142 | return EffectResult.unsupported( |
| 143 | f"invalid-effect-namespace:{effect_name}" |
| 144 | ) |
| 145 | try: |
| 146 | if effect_name == "outerShdw": |
| 147 | unsupported_attributes = _unsupported_outer_shadow_attributes( |
| 148 | effect, |
| 149 | target_rotation_degrees=target_rotation_degrees, |
| 150 | ) |
| 151 | if unsupported_attributes: |
| 152 | return EffectResult.unsupported( |
| 153 | "unsupported-effect-attributes:outerShdw:" |
| 154 | + ",".join(unsupported_attributes) |
| 155 | ) |
| 156 | primitives = _outer_shadow(effect, palette) |
| 157 | elif effect_name == "glow": |
| 158 | primitives = _glow(effect, palette) |
| 159 | else: |
| 160 | return EffectResult.unsupported( |
| 161 | f"unsupported-effect:{effect_name}" |
| 162 | ) |
| 163 | except (OverflowError, TypeError, ValueError) as exc: |
| 164 | return EffectResult.unsupported( |
| 165 | f"invalid-effect:{effect_name}:{exc}" |
| 166 | ) |
| 167 | |
| 168 | if id_seq is None: |
| 169 | id_seq = [0] |
| 170 | id_seq[0] += 1 |
| 171 | filter_id = f"{id_prefix}{id_seq[0]}" |
| 172 | |
| 173 | # Filter region needs to extend beyond the bounding box to render shadows |
| 174 | # and glows; choose generous defaults. |
| 175 | filter_x = "-25%" |
| 176 | filter_y = "-25%" |
| 177 | filter_w = "150%" |
| 178 | filter_h = "150%" |
| 179 | |
| 180 | defs_xml = ( |
| 181 | f'<filter id="{filter_id}" x="{filter_x}" y="{filter_y}" ' |
| 182 | f'width="{filter_w}" height="{filter_h}">' |
| 183 | + primitives |
| 184 | + "</filter>" |
| 185 | ) |
| 186 | return EffectResult(filter_id=filter_id, defs=(defs_xml,)) |
| 187 | |
| 188 | |
| 189 | def _color_alpha(elem: ET.Element, palette: ColorPalette | None) -> tuple[str, float]: |
| 190 | direct_children = [ |
| 191 | child |
| 192 | for child in elem |
| 193 | if isinstance(child.tag, str) |
| 194 | ] |
| 195 | colors = [ |
| 196 | child |
| 197 | for child in direct_children |
| 198 | if _local_name(child) in COLOR_TAGS |
| 199 | ] |
| 200 | if len(colors) != 1: |
| 201 | reason = "missing-color" if not colors else "multiple-colors" |
| 202 | raise ValueError(reason) |
| 203 | if len(direct_children) != 1: |
| 204 | extras = [ |
| 205 | _local_name(child) |
| 206 | for child in direct_children |
| 207 | if child is not colors[0] |
| 208 | ] |
| 209 | raise ValueError("unexpected-effect-child:" + ",".join(extras)) |
| 210 | color = colors[0] |
| 211 | if not color.tag.startswith(_DRAWINGML_TAG_PREFIX): |
| 212 | raise ValueError(f"invalid-color-namespace:{_local_name(color)}") |
| 213 | _validate_color(color, palette) |
| 214 | hex_, alpha = resolve_color(color, palette) |
| 215 | if hex_ is None: |
| 216 | raise ValueError(f"unresolvable-color:{_local_name(color)}") |
| 217 | return hex_, alpha |
| 218 | |
| 219 | |
| 220 | def _validate_color( |
| 221 | color: ET.Element, |
| 222 | palette: ColorPalette | None, |
| 223 | ) -> None: |
| 224 | """Validate the color subset resolved into one SVG filter paint.""" |
| 225 | color_name = _local_name(color) |
| 226 | if color_name == "srgbClr": |
| 227 | raw = color.get("val", "") |
| 228 | if _OOXML_HEX_COLOR_RE.fullmatch(raw) is None: |
| 229 | raise ValueError(f"invalid-color:{color_name}") |
| 230 | elif color_name == "schemeClr": |
| 231 | raw = (color.get("val") or "").strip() |
| 232 | resolved = palette.resolve_scheme(raw) if palette is not None else None |
| 233 | if resolved is None or _OOXML_HEX_COLOR_RE.fullmatch(resolved) is None: |
| 234 | raise ValueError(f"unresolvable-color:{color_name}") |
| 235 | elif color_name == "sysClr": |
| 236 | if not (color.get("val") or "").strip(): |
| 237 | raise ValueError(f"invalid-color:{color_name}") |
| 238 | raw = color.get("lastClr") or "" |
| 239 | if _OOXML_HEX_COLOR_RE.fullmatch(raw) is None: |
| 240 | raise ValueError(f"unresolvable-color:{color_name}") |
| 241 | elif color_name == "hslClr": |
| 242 | _required_integer(color, "hue", 0, 21_599_999) |
| 243 | _required_integer(color, "sat", 0, 100000) |
| 244 | _required_integer(color, "lum", 0, 100000) |
| 245 | elif color_name == "scrgbClr": |
| 246 | for attr in ("r", "g", "b"): |
| 247 | _required_integer(color, attr, 0, 100000) |
| 248 | elif not (color.get("val") or "").strip(): |
| 249 | raise ValueError(f"invalid-color:{color_name}") |
| 250 | |
| 251 | def _required_integer( |
| 252 | elem: ET.Element, |
| 253 | attr: str, |
| 254 | minimum: int, |
| 255 | maximum: int, |
| 256 | ) -> int: |
| 257 | raw = elem.get(attr) |
| 258 | if raw is None: |
| 259 | raise ValueError(f"missing-{_local_name(elem)}-{attr}") |
| 260 | token = raw.strip() |
| 261 | if _OOXML_INTEGER_RE.fullmatch(token) is None: |
| 262 | raise ValueError(f"invalid-{_local_name(elem)}-{attr}") |
| 263 | value = int(token) |
| 264 | if not minimum <= value <= maximum: |
| 265 | raise ValueError(f"invalid-{_local_name(elem)}-{attr}") |
| 266 | return value |
| 267 | |
| 268 | |
| 269 | def _local_name(elem: ET.Element) -> str: |
| 270 | return elem.tag.rsplit("}", 1)[-1] |
| 271 | |
| 272 | |
| 273 | def _effect_integer( |
| 274 | elem: ET.Element, |
| 275 | attr: str, |
| 276 | *, |
| 277 | default: int = 0, |
| 278 | non_negative: bool = False, |
| 279 | maximum: int | None = None, |
| 280 | ) -> int: |
| 281 | raw = elem.get(attr) |
| 282 | if raw is None: |
| 283 | value = default |
| 284 | else: |
| 285 | token = raw.strip() |
| 286 | if _OOXML_INTEGER_RE.fullmatch(token) is None: |
| 287 | raise ValueError(f"{attr}={raw!r}") |
| 288 | value = int(token) |
| 289 | if ( |
| 290 | (non_negative and value < 0) |
| 291 | or (maximum is not None and value > maximum) |
| 292 | ): |
| 293 | raise ValueError(f"{attr}={raw!r}") |
| 294 | return value |
| 295 | |
| 296 | |
| 297 | def _unsupported_outer_shadow_attributes( |
| 298 | elem: ET.Element, |
| 299 | *, |
| 300 | target_rotation_degrees: float, |
| 301 | ) -> tuple[str, ...]: |
| 302 | """Return source shadow attributes the local SVG filter cannot preserve.""" |
| 303 | unsupported = set(elem.attrib) - _OUTER_SHADOW_ATTRIBUTES |
| 304 | neutral_transforms = ( |
| 305 | ("sx", 100000), |
| 306 | ("sy", 100000), |
| 307 | ("kx", 0), |
| 308 | ("ky", 0), |
| 309 | ) |
| 310 | for attr, neutral in neutral_transforms: |
| 311 | if attr in elem.attrib and _effect_integer(elem, attr) != neutral: |
| 312 | unsupported.add(attr) |
| 313 | |
| 314 | raw_alignment = elem.get("algn") |
| 315 | if ( |
| 316 | raw_alignment is not None |
| 317 | and raw_alignment.strip() not in _OUTER_SHADOW_ALIGNMENTS |
| 318 | ): |
| 319 | raise ValueError(f"algn={raw_alignment!r}") |
| 320 | |
| 321 | raw_rotates = elem.get("rotWithShape") |
| 322 | if raw_rotates is None: |
| 323 | rotates_with_shape = True |
| 324 | else: |
| 325 | token = raw_rotates.strip() |
| 326 | if token in {"1", "true"}: |
| 327 | rotates_with_shape = True |
| 328 | elif token in {"0", "false"}: |
| 329 | rotates_with_shape = False |
| 330 | else: |
| 331 | raise ValueError(f"rotWithShape={raw_rotates!r}") |
| 332 | target_is_rotated = not math.isclose( |
| 333 | math.remainder(target_rotation_degrees, 360.0), |
| 334 | 0.0, |
| 335 | abs_tol=1e-9, |
| 336 | ) |
| 337 | if rotates_with_shape and target_is_rotated: |
| 338 | # CT_OuterShadowEffect defaults rotWithShape to true, while the local |
| 339 | # SVG-to-PPTX mapping writes false. Blocking the visible distinction |
| 340 | # prevents the next export from changing the source shadow direction. |
| 341 | unsupported.add("rotWithShape") |
| 342 | |
| 343 | return tuple(sorted(unsupported)) |
| 344 | |
| 345 | |
| 346 | def _direction_offset(elem: ET.Element) -> tuple[float, float]: |
| 347 | """Read dir / dist into (dx, dy) px.""" |
| 348 | direction_units = _effect_integer( |
| 349 | elem, |
| 350 | "dir", |
| 351 | non_negative=True, |
| 352 | maximum=21_599_999, |
| 353 | ) |
| 354 | dist_emu = _effect_integer( |
| 355 | elem, |
| 356 | "dist", |
| 357 | non_negative=True, |
| 358 | maximum=OOXML_COORDINATE_MAX, |
| 359 | ) |
| 360 | direction_deg = direction_units / 60000.0 |
| 361 | dist_px = emu_to_px(dist_emu) |
| 362 | rad = math.radians(direction_deg) |
| 363 | return dist_px * math.cos(rad), dist_px * math.sin(rad) |
| 364 | |
| 365 | |
| 366 | def _blur_radius(elem: ET.Element, attr: str) -> float: |
| 367 | return emu_to_px(_effect_integer( |
| 368 | elem, |
| 369 | attr, |
| 370 | non_negative=True, |
| 371 | maximum=OOXML_COORDINATE_MAX, |
| 372 | )) |
| 373 | |
| 374 | |
| 375 | def _outer_shadow( |
| 376 | elem: ET.Element, |
| 377 | palette: ColorPalette | None, |
| 378 | ) -> str: |
| 379 | dx, dy = _direction_offset(elem) |
| 380 | blur = _blur_radius(elem, "blurRad") |
| 381 | dx_token = fmt_num(dx, 8) |
| 382 | dy_token = fmt_num(dy, 8) |
| 383 | if abs(float(dx_token)) <= 0.01 and abs(float(dy_token)) <= 0.01: |
| 384 | raise ValueError("offset-is-not-classifiable") |
| 385 | color, alpha = _color_alpha(elem, palette) |
| 386 | # std deviation ~= blur radius / 2 (rough; PowerPoint shadows are larger) |
| 387 | std = blur / 2.0 |
| 388 | # Use feDropShadow for compactness — it's well-supported in modern browsers. |
| 389 | return ( |
| 390 | f'<feDropShadow dx="{dx_token}" dy="{dy_token}" ' |
| 391 | f'stdDeviation="{fmt_num(std, 8)}" ' |
| 392 | f'flood-color="{color}" ' |
| 393 | f'flood-opacity="{format_ooxml_alpha(alpha)}"/>' |
| 394 | ) |
| 395 | |
| 396 | |
| 397 | def _glow(elem: ET.Element, palette: ColorPalette | None) -> str: |
| 398 | if elem.get("rad") is None: |
| 399 | raise ValueError("missing-rad") |
| 400 | rad = _blur_radius(elem, "rad") |
| 401 | color, alpha = _color_alpha(elem, palette) |
| 402 | # svg_to_pptx maps stdDeviation directly back to a:glow@rad. |
| 403 | std = rad |
| 404 | return ( |
| 405 | f'<feGaussianBlur in="SourceAlpha" stdDeviation="{fmt_num(std, 8)}" result="blurred"/>' |
| 406 | f'<feFlood flood-color="{color}" ' |
| 407 | f'flood-opacity="{format_ooxml_alpha(alpha)}" result="flood"/>' |
| 408 | f'<feComposite in="flood" in2="blurred" operator="in" result="glow"/>' |
| 409 | f'<feMerge><feMergeNode in="glow"/><feMergeNode in="SourceGraphic"/></feMerge>' |
| 410 | ) |
| 411 |