| 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 |