返回 ppt-master
styles.py
1 """Fill, stroke, and shadow XML builders for DrawingML conversion."""
2
3 from __future__ import annotations
4
5 import math
6 from xml.etree import ElementTree as ET
7
8 from pptx_shapes import validate_ooxml_line_width
9
10 from .context import ConvertContext
11 from .theme_colors import ThemeColorSpec, color_node_xml
12 from .utils import (
13 SVG_NS, ANGLE_UNIT,
14 px_to_emu, _f, _get_attr, parse_svg_length,
15 combine_opacity, parse_inline_style, parse_opacity, parse_stop_style,
16 classify_project_marker_shape,
17 is_project_radial_focus_point,
18 matrix_multiply, parse_svg_color, parse_transform_matrix, resolve_url_id,
19 parse_project_filter_params, project_filter_drawingml_coordinates,
20 parse_project_gradient_ratio, parse_project_linear_gradient_coordinate,
21 parse_project_stroke_dasharray, parse_project_stroke_enum,
22 quantize_ooxml_alpha, quantize_ooxml_unit_ratio,
23 )
24
25
26 def build_solid_fill(
27 color: str,
28 opacity: float | None = None,
29 theme_color_spec: ThemeColorSpec | None = None,
30 usage: str = "fill",
31 ) -> str:
32 """Build <a:solidFill> XML."""
33 alpha = ''
34 if opacity is not None and opacity < 1.0:
35 alpha = f'<a:alpha val="{quantize_ooxml_alpha(opacity)}"/>'
36 return (
37 '<a:solidFill>'
38 f'{color_node_xml(color, theme_color_spec, usage, alpha)}'
39 '</a:solidFill>'
40 )
41
42
43 def build_gradient_fill(
44 grad_elem: ET.Element,
45 opacity: float | None = None,
46 theme_color_spec: ThemeColorSpec | None = None,
47 usage: str = "fill",
48 ) -> str:
49 """Build <a:gradFill> from SVG linearGradient or radialGradient element."""
50 tag = grad_elem.tag.replace(f'{{{SVG_NS}}}', '')
51
52 stops_xml = []
53 for child in grad_elem:
54 child_tag = child.tag.replace(f'{{{SVG_NS}}}', '')
55 if child_tag != 'stop':
56 continue
57
58 offset_str = child.get('offset')
59 if offset_str is None:
60 raise ValueError('Gradient stop requires an explicit offset')
61 offset = parse_project_gradient_ratio(offset_str)
62 pos = quantize_ooxml_unit_ratio(offset)
63
64 # Parse color from style attribute or direct attributes
65 style = child.get('style', '')
66 style_values = parse_inline_style(style)
67 color, stop_opacity = parse_stop_style(style)
68 if not color:
69 color, color_alpha = parse_svg_color(child.get('stop-color', '#000000'))
70 stop_opacity *= color_alpha
71 if color is None:
72 color = '000000'
73
74 direct_stop_op = child.get('stop-opacity')
75 if direct_stop_op is not None and 'stop-opacity' not in style_values:
76 stop_opacity *= parse_opacity(
77 direct_stop_op,
78 allow_percentage=True,
79 )
80
81 alpha_xml = ''
82 effective_opacity = combine_opacity(stop_opacity, opacity)
83 if effective_opacity is not None:
84 alpha_xml = (
85 f'<a:alpha val="{quantize_ooxml_alpha(effective_opacity)}"/>'
86 )
87
88 stops_xml.append(
89 f'<a:gs pos="{pos}">'
90 f'{color_node_xml(color, theme_color_spec, usage, alpha_xml)}'
91 '</a:gs>'
92 )
93
94 if not stops_xml:
95 return ''
96
97 gs_list = '\n'.join(stops_xml)
98
99 if tag == 'linearGradient':
100 x1 = parse_project_linear_gradient_coordinate(
101 grad_elem.get('x1', '0')
102 )
103 y1 = parse_project_linear_gradient_coordinate(
104 grad_elem.get('y1', '0')
105 )
106 x2 = parse_project_linear_gradient_coordinate(
107 grad_elem.get('x2', '1')
108 )
109 y2 = parse_project_linear_gradient_coordinate(
110 grad_elem.get('y2', '0')
111 )
112
113 angle_rad = math.atan2(y2 - y1, x2 - x1)
114 angle_deg = math.degrees(angle_rad)
115 dml_angle = int((angle_deg % 360) * ANGLE_UNIT)
116
117 return f'''<a:gradFill>
118 <a:gsLst>{gs_list}</a:gsLst>
119 <a:lin ang="{dml_angle}" scaled="1"/>
120 </a:gradFill>'''
121
122 elif tag == 'radialGradient':
123 focus_x = parse_project_gradient_ratio(
124 grad_elem.get('fx', grad_elem.get('cx', '0.5'))
125 )
126 focus_y = parse_project_gradient_ratio(
127 grad_elem.get('fy', grad_elem.get('cy', '0.5'))
128 )
129 if not is_project_radial_focus_point(focus_x, focus_y):
130 raise ValueError(
131 'Radial gradient effective focus must lie within the '
132 'canonical circle centered at 0.5,0.5 with radius 0.5'
133 )
134 focus_l = quantize_ooxml_unit_ratio(focus_x)
135 focus_t = quantize_ooxml_unit_ratio(focus_y)
136 focus_r = 100000 - focus_l
137 focus_b = 100000 - focus_t
138 return f'''<a:gradFill>
139 <a:gsLst>{gs_list}</a:gsLst>
140 <a:path path="circle">
141 <a:fillToRect l="{focus_l}" t="{focus_t}" r="{focus_r}" b="{focus_b}"/>
142 </a:path>
143 </a:gradFill>'''
144
145 return ''
146
147
148 def build_fill_xml(
149 elem: ET.Element,
150 ctx: ConvertContext,
151 opacity: float | None = None,
152 usage: str = "fill",
153 ) -> str:
154 """Build fill XML for a shape element, with inherited style support."""
155 fill = _get_attr(elem, 'fill', ctx)
156 if fill is None:
157 fill = '#000000' # SVG default fill is black
158
159 if fill.strip().lower() in ('none', 'transparent'):
160 return '<a:noFill/>'
161
162 ref_id = resolve_url_id(fill)
163 if ref_id and ref_id in ctx.defs:
164 ref_elem = ctx.defs[ref_id]
165 ref_tag = ref_elem.tag.replace(f'{{{SVG_NS}}}', '')
166 if ref_tag == 'pattern':
167 patt_xml = build_pattern_fill(
168 ref_elem,
169 opacity,
170 ctx.theme_color_spec,
171 usage,
172 )
173 if patt_xml:
174 return patt_xml
175 return '<a:noFill/>'
176 return build_gradient_fill(
177 ref_elem,
178 opacity,
179 ctx.theme_color_spec,
180 usage,
181 )
182
183 color, color_alpha = parse_svg_color(fill)
184 if color:
185 return build_solid_fill(
186 color,
187 combine_opacity(opacity, color_alpha),
188 ctx.theme_color_spec,
189 usage,
190 )
191
192 return '<a:noFill/>'
193
194
195 def build_pattern_fill(
196 pattern_elem: ET.Element,
197 opacity: float | None = None,
198 theme_color_spec: ThemeColorSpec | None = None,
199 usage: str = "fill",
200 ) -> str:
201 """Build <a:pattFill> from an SVG <pattern> emitted by pptx_to_svg.
202
203 Reads the round-trip annotations (data-pptx-pattern / data-pptx-fg /
204 data-pptx-bg) when present. Falls back to inspecting the inner stroke /
205 rect colors when annotations are absent (hand-authored SVG).
206 """
207 prst = pattern_elem.get('data-pptx-pattern') or 'ltUpDiag'
208
209 paint_entries = []
210 for child in pattern_elem:
211 tag = child.tag.replace(f'{{{SVG_NS}}}', '')
212 style_values = parse_inline_style(child.get('style'))
213 object_opacity = parse_opacity(
214 style_values.get('opacity') or child.get('opacity')
215 )
216 for paint_attr in ('fill', 'stroke'):
217 paint = style_values.get(paint_attr) or child.get(paint_attr)
218 paint_hex, paint_alpha = parse_svg_color(paint) if paint else (None, 1.0)
219 if paint_hex is None:
220 continue
221 paint_opacity = parse_opacity(
222 style_values.get(f'{paint_attr}-opacity')
223 or child.get(f'{paint_attr}-opacity')
224 )
225 paint_entries.append({
226 'attr': paint_attr,
227 'alpha': paint_alpha,
228 'color': paint,
229 'hex': paint_hex,
230 'opacity': object_opacity * paint_opacity,
231 'tag': tag,
232 })
233
234 fallback_bg = next((
235 entry
236 for entry in paint_entries
237 if entry['tag'] == 'rect' and entry['attr'] == 'fill'
238 ), None)
239 fallback_fg = next((
240 entry for entry in paint_entries if entry['attr'] == 'stroke'
241 ), None)
242 if fallback_fg is None:
243 fallback_fg = next((
244 entry
245 for entry in paint_entries
246 if entry['attr'] == 'fill' and entry is not fallback_bg
247 ), None)
248
249 fg_color = pattern_elem.get('data-pptx-fg')
250 bg_color = pattern_elem.get('data-pptx-bg')
251 fg_from_metadata = bool(fg_color)
252 bg_from_metadata = bool(bg_color)
253 if not fg_color and fallback_fg is not None:
254 fg_color = fallback_fg['color']
255 if not bg_color and fallback_bg is not None:
256 bg_color = fallback_bg['color']
257
258 fg_hex, fg_alpha = parse_svg_color(fg_color) if fg_color else (None, 1.0)
259 bg_hex, bg_alpha = parse_svg_color(bg_color) if bg_color else (None, 1.0)
260 if not fg_hex:
261 return ''
262
263 bg_source = next((
264 entry
265 for entry in paint_entries
266 if entry['tag'] == 'rect'
267 and entry['attr'] == 'fill'
268 and entry['hex'] == bg_hex
269 ), None)
270 fg_source = next((
271 entry
272 for entry in paint_entries
273 if entry['attr'] == 'stroke' and entry['hex'] == fg_hex
274 ), None)
275 if fg_source is None:
276 fg_source = next((
277 entry
278 for entry in paint_entries
279 if entry['attr'] == 'fill'
280 and entry['hex'] == fg_hex
281 and entry is not bg_source
282 ), None)
283
284 fg_child_opacity = 1.0
285 if fg_source is not None:
286 fg_child_opacity = fg_source['opacity'] * (
287 fg_source['alpha'] if fg_from_metadata else 1.0
288 )
289 bg_child_opacity = 1.0
290 if bg_source is not None:
291 bg_child_opacity = bg_source['opacity'] * (
292 bg_source['alpha'] if bg_from_metadata else 1.0
293 )
294
295 fg_opacity = combine_opacity(opacity, fg_alpha, fg_child_opacity)
296 bg_opacity = combine_opacity(opacity, bg_alpha, bg_child_opacity)
297 fg_alpha_xml = (
298 f'<a:alpha val="{quantize_ooxml_alpha(fg_opacity)}"/>'
299 if fg_opacity is not None else ''
300 )
301 bg_alpha_xml = (
302 f'<a:alpha val="{quantize_ooxml_alpha(bg_opacity)}"/>'
303 if bg_opacity is not None else ''
304 )
305
306 fg_xml = color_node_xml(fg_hex, theme_color_spec, usage, fg_alpha_xml)
307 if bg_hex:
308 bg_xml = color_node_xml(bg_hex, theme_color_spec, usage, bg_alpha_xml)
309 else:
310 bg_xml = color_node_xml('FFFFFF', theme_color_spec, usage, bg_alpha_xml)
311
312 return (
313 f'<a:pattFill prst="{prst}">'
314 f'<a:fgClr>{fg_xml}</a:fgClr>'
315 f'<a:bgClr>{bg_xml}</a:bgClr>'
316 f'</a:pattFill>'
317 )
318
319
320 # ---------------------------------------------------------------------------
321 # Marker (arrow-head) support
322 # ---------------------------------------------------------------------------
323
324 def _marker_size_buckets(w_attr: float, h_attr: float) -> tuple[str, str]:
325 """Map SVG markerWidth / markerHeight to DrawingML (w, len) buckets.
326
327 DrawingML arrow-end sizing is categorical: sm / med / lg.
328 Width (perpendicular to the line) maps from markerHeight;
329 length (along the line) maps from markerWidth.
330 """
331
332 def bucket(v: float) -> str:
333 if v < 6:
334 return 'sm'
335 if v > 12:
336 return 'lg'
337 return 'med'
338
339 return bucket(h_attr), bucket(w_attr)
340
341
342 def _classify_marker(marker_elem: ET.Element) -> tuple[str, str, str] | None:
343 """Classify an SVG <marker> into a DrawingML line-end preset.
344
345 Returns (type, w, len) where:
346 type in {'triangle', 'stealth', 'diamond', 'oval', 'arrow'}
347 w, len in {'sm', 'med', 'lg'}
348 or None if the marker cannot be classified.
349
350 Current coverage is the five DrawingML line-end shapes: triangle, stealth,
351 arrow, diamond, and oval. Anything else returns ``None``.
352 """
353 mw = _f(marker_elem.get('markerWidth'), 3.0)
354 mh = _f(marker_elem.get('markerHeight'), 3.0)
355 w_bucket, len_bucket = _marker_size_buckets(mw, mh)
356
357 marker_type = classify_project_marker_shape(marker_elem)
358 if marker_type is None:
359 return None
360 return marker_type, w_bucket, len_bucket
361
362
363 def _emit_line_end(
364 elem: ET.Element,
365 ctx: ConvertContext,
366 which: str,
367 ) -> str:
368 """Build <a:headEnd> or <a:tailEnd> XML for an element's marker reference.
369
370 Args:
371 which: 'head' (SVG marker-start) or 'tail' (SVG marker-end).
372
373 Returns empty string if no marker, cannot resolve, or cannot classify.
374 """
375 attr = 'marker-start' if which == 'head' else 'marker-end'
376 ref = _get_attr(elem, attr, ctx)
377 if not ref or ref == 'none':
378 return ''
379
380 marker_id = resolve_url_id(ref)
381 if not marker_id or marker_id not in ctx.defs:
382 return ''
383
384 marker_elem = ctx.defs[marker_id]
385 tag = marker_elem.tag.replace(f'{{{SVG_NS}}}', '')
386 if tag != 'marker':
387 # ID collision with non-marker defs entry; ignore.
388 return ''
389
390 cls = _classify_marker(marker_elem)
391 if cls is None:
392 print(
393 f' Warning: marker "{marker_id}" shape cannot be classified; '
394 'skipping (supported: triangle, stealth, arrow, diamond, oval)'
395 )
396 return ''
397
398 typ, w_bucket, len_bucket = cls
399
400 # Reclassify size buckets based on markerUnits semantics:
401 #
402 # markerUnits="strokeWidth" (SVG default):
403 # markerWidth IS a ratio to stroke-width, and DrawingML headEnd/tailEnd
404 # also scale proportionally with line width. We should compare the ratio
405 # (markerWidth) directly against ratio-based thresholds — do NOT multiply
406 # by stroke-width, because that double-counts the scaling.
407 # Empirical DrawingML arrow ratios:
408 # sm ≈ 1.5× stroke-width → markerWidth ≤ 2.0
409 # med ≈ 2.5× stroke-width → markerWidth 2.0 – 3.5
410 # lg ≈ 3.5× stroke-width → markerWidth ≥ 3.5
411 #
412 # markerUnits="userSpaceOnUse":
413 # markerWidth/Height are absolute pixel values – keep the existing
414 # absolute-pixel thresholds from _marker_size_buckets (6 / 12).
415 marker_units = marker_elem.get('markerUnits', 'strokeWidth')
416 if marker_units != 'userSpaceOnUse':
417 mw = _f(marker_elem.get('markerWidth'), 3.0)
418 mh = _f(marker_elem.get('markerHeight'), 3.0)
419
420 def _ratio_bucket(v: float) -> str:
421 if v <= 2.0:
422 return 'sm'
423 if v >= 3.5:
424 return 'lg'
425 return 'med'
426
427 w_bucket = _ratio_bucket(mh) # h → perpendicular width
428 len_bucket = _ratio_bucket(mw) # w → length along line
429
430 dml_tag = 'headEnd' if which == 'head' else 'tailEnd'
431 return f'<a:{dml_tag} type="{typ}" w="{w_bucket}" len="{len_bucket}"/>'
432
433
434 def _effective_stroke_scale(elem: ET.Element, ctx: ConvertContext) -> float:
435 """Approximate the effective SVG geometry transform as one line-width scale."""
436 vector_effect = _get_attr(elem, 'vector-effect', ctx)
437 if vector_effect:
438 vector_effect = parse_project_stroke_enum(
439 'vector-effect',
440 vector_effect,
441 )
442 if vector_effect == 'non-scaling-stroke':
443 return 1.0
444
445 if ctx.use_transform_matrix:
446 matrix = ctx.transform_matrix
447 else:
448 matrix = (
449 ctx.scale_x, 0.0,
450 0.0, ctx.scale_y,
451 ctx.translate_x, ctx.translate_y,
452 )
453
454 # The context already contains ancestor transforms. Shape converters apply
455 # the leaf element's transform directly, so compose that local matrix once.
456 transform = elem.get('transform')
457 if transform:
458 matrix = matrix_multiply(matrix, parse_transform_matrix(transform))
459
460 # DrawingML has one line width. sqrt(|det|) equals the uniform scale for a
461 # similarity transform and the principal-scale geometric mean otherwise.
462 a, b, c, d, _e, _f = matrix
463 return math.sqrt(abs(a * d - b * c))
464
465
466 def build_stroke_xml(
467 elem: ET.Element,
468 ctx: ConvertContext,
469 opacity: float | None = None,
470 ) -> str:
471 """Build <a:ln> XML for stroke, with inherited style support."""
472 stroke = _get_attr(elem, 'stroke', ctx)
473 if not stroke or stroke.strip().lower() in ('none', 'transparent'):
474 return '<a:ln><a:noFill/></a:ln>'
475
476 source_width = parse_svg_length(_get_attr(elem, 'stroke-width', ctx), 1.0)
477 width_emu = px_to_emu(source_width * _effective_stroke_scale(elem, ctx))
478 validate_ooxml_line_width(width_emu)
479
480 # Dash pattern
481 dash_xml = ''
482 dasharray = _get_attr(elem, 'stroke-dasharray', ctx)
483 if dasharray:
484 parsed_dasharray = parse_project_stroke_dasharray(dasharray)
485 if parsed_dasharray is not None:
486 preset, values = parsed_dasharray
487 if preset:
488 dash_xml = f'<a:prstDash val="{preset}"/>'
489 else:
490 # The project contract normalizes compatible longer arrays to
491 # their first dash/gap pair before DrawingML quantization.
492 d_raw, sp_raw = values[:2]
493 sw = max(source_width, 0.001)
494 d_pct = max(1, round(d_raw / sw * 100000))
495 sp_pct = max(1, round(sp_raw / sw * 100000))
496 dash_xml = (
497 '<a:custDash>'
498 f'<a:ds d="{d_pct}" sp="{sp_pct}"/>'
499 '</a:custDash>'
500 )
501
502 # Line cap
503 cap_map = {'round': 'rnd', 'square': 'sq', 'butt': 'flat'}
504 cap_attr = ''
505 linecap = _get_attr(elem, 'stroke-linecap', ctx)
506 if linecap:
507 linecap = parse_project_stroke_enum('stroke-linecap', linecap)
508 cap_attr = f' cap="{cap_map[linecap]}"'
509
510 # Line join
511 join_xml = ''
512 linejoin = _get_attr(elem, 'stroke-linejoin', ctx)
513 if linejoin:
514 linejoin = parse_project_stroke_enum('stroke-linejoin', linejoin)
515 if linejoin == 'round':
516 join_xml = '<a:round/>'
517 elif linejoin == 'bevel':
518 join_xml = '<a:bevel/>'
519 elif linejoin == 'miter':
520 join_xml = '<a:miter lim="800000"/>'
521
522 # Line-end markers (SVG marker-start / marker-end → <a:headEnd>/<a:tailEnd>)
523 # DrawingML schema order is: fill → prstDash → join → headEnd → tailEnd,
524 # so these must be appended after join_xml.
525 head_end = _emit_line_end(elem, ctx, 'head')
526 tail_end = _emit_line_end(elem, ctx, 'tail')
527 line_ends = head_end + tail_end
528
529 # Gradient stroke
530 grad_id = resolve_url_id(stroke)
531 if grad_id and grad_id in ctx.defs:
532 grad_fill = build_gradient_fill(
533 ctx.defs[grad_id],
534 opacity,
535 ctx.theme_color_spec,
536 "stroke",
537 )
538 return f'<a:ln w="{width_emu}"{cap_attr}>{grad_fill}{dash_xml}{join_xml}{line_ends}</a:ln>'
539
540 # Solid color stroke
541 color, color_alpha = parse_svg_color(stroke)
542 if not color:
543 return '<a:ln><a:noFill/></a:ln>'
544
545 opacity = combine_opacity(opacity, color_alpha)
546 alpha_xml = ''
547 if opacity is not None and opacity < 1.0:
548 alpha_xml = f'<a:alpha val="{quantize_ooxml_alpha(opacity)}"/>'
549
550 color_xml = color_node_xml(color, ctx.theme_color_spec, "stroke", alpha_xml)
551 return f'''<a:ln w="{width_emu}"{cap_attr}>
552 <a:solidFill>{color_xml}</a:solidFill>{dash_xml}{join_xml}{line_ends}
553 </a:ln>'''
554
555
556 def _infer_shadow_alignment(dx: float, dy: float, threshold: float = 0.5) -> str:
557 """Infer outer shadow alignment from the SVG offset vector.
558
559 DrawingML applies alignment before blur/offset transforms, so we anchor the
560 shadow opposite to the dominant offset direction:
561 - diagonal offsets map to the opposite corner
562 - pure vertical offsets stay centered, matching common PPT shadow presets
563 - pure horizontal offsets anchor to the opposite side
564 """
565 if abs(dx) < threshold and abs(dy) < threshold:
566 return 'ctr'
567 if abs(dx) < threshold:
568 return 'ctr'
569 if abs(dy) < threshold:
570 return 'l' if dx > 0 else 'r'
571 if dx > 0 and dy > 0:
572 return 'tl'
573 if dx < 0 and dy > 0:
574 return 'tr'
575 if dx > 0 and dy < 0:
576 return 'bl'
577 return 'br'
578
579
580 def _shadow_dir_angle(dx: float, dy: float) -> int:
581 """Convert an SVG offset vector to DrawingML clockwise angle units.
582
583 OOXML angles are expressed in 60,000ths of a degree, with positive angles
584 rotating clockwise toward the positive Y axis. SVG uses the same screen
585 coordinate orientation (positive Y points downward), so the raw screen-space
586 vector angle can be mapped directly with atan2(dy, dx).
587 """
588 if abs(dx) < 0.001 and abs(dy) < 0.001:
589 return 0
590 angle_deg = math.degrees(math.atan2(dy, dx)) % 360
591 return int(angle_deg * ANGLE_UNIT)
592
593
594 def build_shadow_xml(
595 filter_elem: ET.Element,
596 opacity: float | None = None,
597 ) -> str:
598 """Build <a:effectLst> with <a:outerShdw> from SVG filter element.
599
600 SVG-to-DrawingML shadow mapping notes:
601 - SVG feGaussianBlur stdDeviation (σ) maps to DrawingML blurRad using a
602 2.0× scale. Rationale: σ is a standard deviation whose visual radius
603 is ~3σ, while DrawingML blurRad is an outer-spread pixel distance.
604 A 1.0× scale makes PowerPoint render sharp, concentrated shadows
605 ("heavy" visual). 2.0× matches the CSS drop-shadow↔box-shadow
606 convention and produces softer diffusion closer to the SVG preview.
607 - The algn attribute is inferred from the offset direction so that
608 the shadow aligns naturally with the shape edge.
609 """
610 if filter_elem is None:
611 return ''
612
613 p = parse_project_filter_params(filter_elem)
614 dx = p['dx']
615 dy = p['dy']
616 # For shadow, default dy to 4 if no offset was found
617 if not p['has_offset']:
618 dy = 4.0
619 p = {**p, 'dy': dy}
620
621 coordinates = project_filter_drawingml_coordinates(p, 'shadow')
622 blur_rad = coordinates['blurRad']
623 dist = coordinates['dist']
624 dir_angle = _shadow_dir_angle(dx, dy)
625 # PowerPoint renders outerShdw alpha slightly heavier than SVG's filter
626 # composite (different blending path). Scale by 0.75 to match the SVG
627 # preview after blur has been corrected to 2.0× σ.
628 opacity_multiplier = 1.0 if opacity is None else opacity
629 alpha_val = quantize_ooxml_alpha(
630 p['opacity'] * opacity_multiplier * 0.75
631 )
632 algn = _infer_shadow_alignment(dx, dy)
633
634 return f'''<a:effectLst>
635 <a:outerShdw blurRad="{blur_rad}" dist="{dist}" dir="{dir_angle}" algn="{algn}" rotWithShape="0">
636 <a:srgbClr val="{p['color']}"><a:alpha val="{alpha_val}"/></a:srgbClr>
637 </a:outerShdw>
638 </a:effectLst>'''
639
640
641 def build_glow_xml(
642 filter_elem: ET.Element,
643 opacity: float | None = None,
644 ) -> str:
645 """Build <a:effectLst> with <a:glow> from SVG filter element.
646
647 Used for filters that have feGaussianBlur without meaningful feOffset,
648 typically title glow or highlight effects.
649 """
650 if filter_elem is None:
651 return ''
652
653 p = parse_project_filter_params(filter_elem)
654 rad = project_filter_drawingml_coordinates(p, 'glow')['rad']
655 opacity_multiplier = 1.0 if opacity is None else opacity
656 alpha_val = quantize_ooxml_alpha(p['opacity'] * opacity_multiplier)
657
658 return f'''<a:effectLst>
659 <a:glow rad="{rad}">
660 <a:srgbClr val="{p['color']}"><a:alpha val="{alpha_val}"/></a:srgbClr>
661 </a:glow>
662 </a:effectLst>'''
663
664
665 def classify_filter_effect(filter_elem: ET.Element) -> str | None:
666 """Classify an SVG filter into a supported DrawingML effect kind."""
667 if filter_elem is None:
668 return None
669
670 p = parse_project_filter_params(filter_elem)
671 return 'shadow' if p['has_offset'] else 'glow'
672
673
674 def build_effect_xml(
675 filter_elem: ET.Element,
676 opacity: float | None = None,
677 ) -> str:
678 """Build effect XML by classifying the SVG filter as shadow or glow.
679
680 Classification rules:
681 - feOffset with non-zero dx/dy → outer shadow
682 - No feOffset or zero offset → glow effect
683 """
684 if filter_elem is None:
685 return ''
686
687 effect_kind = classify_filter_effect(filter_elem)
688 if effect_kind == 'shadow':
689 return build_shadow_xml(filter_elem, opacity)
690 if effect_kind == 'glow':
691 return build_glow_xml(filter_elem, opacity)
692 return ''
693
694
695 def get_element_opacity(
696 elem: ET.Element,
697 ctx: ConvertContext | None = None,
698 ) -> float | None:
699 """Get local opacity multiplied by any approximated ancestor group alpha."""
700 base = ctx.opacity_multiplier if ctx is not None else 1.0
701 if ctx is not None:
702 op = _get_attr(elem, 'opacity', ctx)
703 else:
704 op = parse_inline_style(elem.get('style')).get('opacity') or elem.get('opacity')
705 if op is None:
706 return base if base < 1.0 else None
707 val = base * parse_opacity(op)
708 return val if val < 1.0 else None
709
710
711 def get_fill_opacity(
712 elem: ET.Element,
713 ctx: ConvertContext | None = None,
714 ) -> float | None:
715 """Get effective fill opacity combining 'opacity' and 'fill-opacity'.
716
717 Returns:
718 Combined opacity value, or None if fully opaque.
719 """
720 base = ctx.opacity_multiplier if ctx is not None else 1.0
721
722 op = _get_attr(elem, 'opacity', ctx) if ctx else elem.get('opacity')
723 if op is not None:
724 base *= parse_opacity(op)
725
726 fill_op = _get_attr(elem, 'fill-opacity', ctx) if ctx else elem.get('fill-opacity')
727 if fill_op is not None:
728 base *= parse_opacity(fill_op)
729
730 return base if base < 1.0 else None
731
732
733 def get_stroke_opacity(
734 elem: ET.Element,
735 ctx: ConvertContext | None = None,
736 ) -> float | None:
737 """Get effective stroke opacity combining 'opacity' and 'stroke-opacity'.
738
739 Returns:
740 Combined opacity value, or None if fully opaque.
741 """
742 base = ctx.opacity_multiplier if ctx is not None else 1.0
743
744 op = _get_attr(elem, 'opacity', ctx) if ctx else elem.get('opacity')
745 if op is not None:
746 base *= parse_opacity(op)
747
748 stroke_op = _get_attr(elem, 'stroke-opacity', ctx) if ctx else elem.get('stroke-opacity')
749 if stroke_op is not None:
750 base *= parse_opacity(stroke_op)
751
752 return base if base < 1.0 else None
753
753 lines PYTHON