返回 ppt-master
effect_to_svg.py
根目录 / skills / ppt-master / scripts / pptx_to_svg / effect_to_svg.py
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
411 lines PYTHON