返回 ppt-master
fill_to_svg.py
根目录 / skills / ppt-master / scripts / pptx_to_svg / fill_to_svg.py
1 """DrawingML fill -> SVG fill conversion.
2
3 Handles:
4 - <a:solidFill> -> fill="#XXXXXX" (+ fill-opacity)
5 - <a:noFill/> -> fill="none"
6 - <a:gradFill> -> linearGradient/radialGradient in <defs>, fill="url(#id)"
7 - <a:blipFill> -> handled by pic_to_svg (this module short-circuits)
8
9 Returned FillResult is a struct of attribute dict + optional <defs> XML so the
10 slide assembler can collect gradient defs without conflicting IDs.
11 """
12
13 from __future__ import annotations
14
15 import math
16 import re
17 from dataclasses import dataclass, field
18 from decimal import Decimal
19 from xml.etree import ElementTree as ET
20
21 from .color_resolver import (
22 COLOR_TAGS,
23 ColorPalette,
24 find_color_elem,
25 resolve_color,
26 resolve_solid_fill_color,
27 validate_no_fill,
28 )
29 from .emu_units import (
30 ANGLE_UNIT,
31 NS,
32 PERCENT_UNIT,
33 fmt_num,
34 format_ooxml_alpha,
35 format_ooxml_unit_ratio,
36 )
37
38
39 _OOXML_INTEGER_RE = re.compile(r"[+-]?[0-9]+")
40 _OOXML_PERCENT_LITERAL_RE = re.compile(
41 r"[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)%"
42 )
43 _OOXML_FULL_CIRCLE = 360 * ANGLE_UNIT
44 _OOXML_PERCENTAGE_MIN = Decimal(-(2**31)) / Decimal(PERCENT_UNIT)
45 _OOXML_PERCENTAGE_MAX = Decimal(2**31 - 1) / Decimal(PERCENT_UNIT)
46 _SVG_RADIAL_FOCUS_TOLERANCE = Decimal(1) / Decimal(PERCENT_UNIT)
47 _DRAWINGML_FILL_NAMES = (
48 "noFill",
49 "solidFill",
50 "gradFill",
51 "blipFill",
52 "pattFill",
53 "grpFill",
54 )
55 _DRAWINGML_FILL_TAGS = {
56 f"{{{NS['a']}}}{name}": name for name in _DRAWINGML_FILL_NAMES
57 }
58
59
60 @dataclass
61 class FillResult:
62 """Resolved fill: SVG attributes to apply + optional <defs> entries."""
63
64 attrs: dict[str, str] = field(default_factory=dict)
65 defs: list[str] = field(default_factory=list) # XML strings of <linearGradient>/<radialGradient>
66
67 @classmethod
68 def none_fill(cls) -> "FillResult":
69 return cls(attrs={"fill": "none"})
70
71 @classmethod
72 def inherit(cls) -> "FillResult":
73 # No fill resolved — let caller decide whether to default
74 return cls()
75
76
77 def resolve_fill(
78 sp_pr: ET.Element | None,
79 palette: ColorPalette | None,
80 *,
81 id_prefix: str = "g",
82 id_seq: list[int] | None = None,
83 placeholder_hex: str | None = None,
84 ) -> FillResult:
85 """Inspect <p:spPr>'s fill children and emit an SVG fill descriptor.
86
87 Args:
88 sp_pr: <p:spPr> or any element that may directly hold a fill child.
89 palette: ColorPalette for scheme color resolution.
90 id_prefix: prefix for generated gradient IDs.
91 id_seq: external counter (single-element list) so callers can share
92 unique gradient IDs across the whole slide.
93
94 Returns:
95 FillResult. If no recognized fill is found, result.attrs is empty —
96 the caller should apply its own default (typically transparent /
97 inherit from the source SVG).
98 """
99 if sp_pr is None:
100 return FillResult.inherit()
101
102 handlers = {
103 "noFill": _resolve_no_fill,
104 "solidFill": _resolve_solid_fill,
105 "gradFill": _resolve_grad_fill,
106 "blipFill": _resolve_blip_fill,
107 "pattFill": _resolve_patt_fill,
108 }
109
110 fill_name = _drawingml_fill_name(sp_pr)
111 fill_elem = sp_pr if fill_name is not None else None
112 if fill_elem is None:
113 fill_children = []
114 for child in sp_pr:
115 child_name = _drawingml_fill_name(child)
116 if child_name is not None:
117 fill_children.append((child, child_name))
118 if len(fill_children) > 1:
119 raise ValueError(
120 "DrawingML container must contain at most one fill"
121 )
122 if not fill_children:
123 return FillResult.inherit()
124 fill_elem, fill_name = fill_children[0]
125
126 if fill_name == "grpFill":
127 raise ValueError("Unsupported DrawingML fill: grpFill")
128 return handlers[fill_name](
129 fill_elem,
130 palette,
131 id_prefix,
132 id_seq,
133 placeholder_hex,
134 )
135
136
137 def _drawingml_fill_name(elem: ET.Element) -> str | None:
138 """Return one exact DrawingML fill tag name or reject a namespace alias."""
139 name = _DRAWINGML_FILL_TAGS.get(elem.tag)
140 if name is not None:
141 return name
142 local_name = (
143 elem.tag.rsplit("}", 1)[-1]
144 if isinstance(elem.tag, str)
145 else ""
146 )
147 if local_name in _DRAWINGML_FILL_NAMES:
148 raise ValueError(
149 f"Invalid DrawingML fill element namespace: {local_name}"
150 )
151 return None
152
153
154 # ---------------------------------------------------------------------------
155 # Per-fill handlers
156 # ---------------------------------------------------------------------------
157
158 def _resolve_no_fill(elem, _palette, _prefix, _seq, _placeholder_hex) -> FillResult:
159 validate_no_fill(elem)
160 return FillResult.none_fill()
161
162
163 def _resolve_solid_fill(elem: ET.Element, palette: ColorPalette | None,
164 _prefix: str, _seq, placeholder_hex: str | None) -> FillResult:
165 hex_, alpha = resolve_solid_fill_color(
166 elem,
167 palette,
168 placeholder_hex=placeholder_hex,
169 )
170 attrs: dict[str, str] = {"fill": hex_}
171 if alpha < 1.0:
172 attrs["fill-opacity"] = format_ooxml_alpha(alpha)
173 return FillResult(attrs=attrs)
174
175
176 def _resolve_grad_fill(elem: ET.Element, palette: ColorPalette | None,
177 prefix: str, seq, placeholder_hex: str | None) -> FillResult:
178 """Convert <a:gradFill> to an SVG linearGradient or radialGradient."""
179 _validate_gradient_attributes(elem)
180 _validate_gradient_rotation(elem)
181 _validate_gradient_flip(elem)
182 _validate_gradient_tile_rect(elem)
183 if seq is None:
184 seq = [0]
185 seq[0] += 1
186 grad_id = f"{prefix}grad{seq[0]}"
187
188 # Stops
189 gs_lists = elem.findall("a:gsLst", NS)
190 if len(gs_lists) != 1:
191 raise ValueError(
192 "DrawingML gradient fill requires exactly one gsLst"
193 )
194 gradient_stops = [
195 (gs, _gradient_stop_position(gs))
196 for gs in _gradient_stop_list(gs_lists[0])
197 ]
198 if any(
199 current < previous
200 for (_, previous), (_, current) in zip(
201 gradient_stops,
202 gradient_stops[1:],
203 )
204 ):
205 message = "DrawingML gradient stop positions must be nondecreasing"
206 if palette is None or palette.strict:
207 raise ValueError(message)
208 palette._diagnose(
209 "gradient-stop-order-normalized",
210 message,
211 "sort gradient stops by position while preserving equal positions",
212 )
213 gradient_stops.sort(key=lambda item: item[1])
214 stops_xml = []
215 for gs, pos_pct in gradient_stops:
216 color_elem = _gradient_stop_color(gs)
217 hex_, alpha = resolve_color(
218 color_elem,
219 palette,
220 placeholder_hex=placeholder_hex,
221 )
222 if hex_ is None:
223 raise ValueError(
224 "DrawingML gradient stop color cannot be resolved"
225 )
226 opacity_attr = (
227 f' stop-opacity="{format_ooxml_alpha(alpha)}"'
228 if alpha < 1.0
229 else ""
230 )
231 stops_xml.append(
232 f'<stop offset="{format_ooxml_unit_ratio(pos_pct)}" '
233 f'stop-color="{hex_}"{opacity_attr}/>'
234 )
235 # Linear vs radial vs path
236 linear_directions = elem.findall("a:lin", NS)
237 path_directions = elem.findall("a:path", NS)
238 if len(linear_directions) + len(path_directions) > 1:
239 raise ValueError(
240 "DrawingML gradient fill must contain at most one lin/path "
241 "direction"
242 )
243 _validate_gradient_child_structure(elem)
244 lin = linear_directions[0] if linear_directions else None
245 rad = path_directions[0] if path_directions else None
246
247 if lin is not None:
248 # ang is 1/60000 deg. 0° = horizontal left-to-right.
249 _validate_linear_gradient_structure(lin)
250 angle = _linear_gradient_angle(lin)
251 _validate_linear_gradient_scaling(lin, angle)
252 angle_deg = angle / ANGLE_UNIT
253 x1, y1, x2, y2 = _angle_to_unit_endpoints(angle_deg)
254 defs_xml = (
255 f'<linearGradient id="{grad_id}" '
256 f'x1="{fmt_num(x1, 4)}" y1="{fmt_num(y1, 4)}" '
257 f'x2="{fmt_num(x2, 4)}" y2="{fmt_num(y2, 4)}">'
258 + "".join(stops_xml)
259 + "</linearGradient>"
260 )
261 elif rad is not None:
262 _validate_path_gradient_structure(rad)
263 _validate_path_gradient_type(rad)
264 focus = _validate_path_gradient_focus(rad)
265 focus_attrs = ""
266 if focus is not None:
267 focus_x = focus["l"]
268 focus_y = focus["t"]
269 point_focus = (
270 focus_x + focus["r"] == Decimal(1)
271 and focus_y + focus["b"] == Decimal(1)
272 and Decimal(0) <= focus_x <= Decimal(1)
273 and Decimal(0) <= focus_y <= Decimal(1)
274 and (
275 (focus_x - Decimal("0.5")) ** 2
276 + (focus_y - Decimal("0.5")) ** 2
277 <= Decimal("0.25") + _SVG_RADIAL_FOCUS_TOLERANCE
278 )
279 )
280 if (
281 point_focus
282 and (
283 focus_x != Decimal("0.5")
284 or focus_y != Decimal("0.5")
285 )
286 ):
287 focus_attrs = (
288 f' fx="{format_ooxml_unit_ratio(float(focus_x))}"'
289 f' fy="{format_ooxml_unit_ratio(float(focus_y))}"'
290 )
291 elif not point_focus and palette is not None:
292 palette._diagnose(
293 "path-gradient-focus-normalized",
294 "DrawingML path gradient focus is not one point within "
295 "the canonical SVG radial circle",
296 "center the radial gradient while preserving its stops",
297 )
298 # Treat as radial regardless of path="circle" / "rect" / "shape" — SVG
299 # only has circle/ellipse. Point-style fillToRect retains its focus;
300 # the outer center and radius remain normalized.
301 defs_xml = (
302 f'<radialGradient id="{grad_id}" cx="0.5" cy="0.5" '
303 f'r="0.5"{focus_attrs}>'
304 + "".join(stops_xml)
305 + "</radialGradient>"
306 )
307 else:
308 # No direction specified — default to horizontal linear
309 defs_xml = (
310 f'<linearGradient id="{grad_id}" x1="0" y1="0" x2="1" y2="0">'
311 + "".join(stops_xml)
312 + "</linearGradient>"
313 )
314
315 return FillResult(
316 attrs={"fill": f"url(#{grad_id})"},
317 defs=[defs_xml],
318 )
319
320
321 def _gradient_stop_position(gs: ET.Element) -> float:
322 """Parse one required DrawingML fixed-percentage stop position."""
323 raw = gs.get("pos")
324 if raw is None:
325 raise ValueError("DrawingML gradient stop requires a pos attribute")
326 token = raw.strip()
327 if _OOXML_INTEGER_RE.fullmatch(token) is None:
328 raise ValueError(
329 f"Invalid DrawingML gradient stop position: {raw!r}"
330 )
331 position = int(token)
332 if not 0 <= position <= PERCENT_UNIT:
333 raise ValueError(
334 f"DrawingML gradient stop position={position} is outside "
335 f"0..{PERCENT_UNIT}"
336 )
337 return position / PERCENT_UNIT
338
339
340 def _gradient_stop_list(gs_list: ET.Element) -> list[ET.Element]:
341 """Validate one DrawingML gradient-stop list and return its stops."""
342 stops = list(gs_list)
343 stop_tag = f"{{{NS['a']}}}gs"
344 if (
345 gs_list.attrib
346 or (gs_list.text or "").strip()
347 or any(
348 stop.tag != stop_tag or (stop.tail or "").strip()
349 for stop in stops
350 )
351 ):
352 raise ValueError("Invalid DrawingML gradient stop list structure")
353 if len(stops) < 2:
354 raise ValueError(
355 "DrawingML gradient fill requires at least two color stops"
356 )
357 return stops
358
359
360 def _gradient_stop_color(gs: ET.Element) -> ET.Element:
361 """Return the single registered color child of one gradient stop."""
362 children = list(gs)
363 color_tags = {f"{{{NS['a']}}}{name}" for name in COLOR_TAGS}
364 if (
365 set(gs.attrib) != {"pos"}
366 or len(children) != 1
367 or children[0].tag not in color_tags
368 or (gs.text or "").strip()
369 or (children[0].tail or "").strip()
370 ):
371 raise ValueError("Invalid DrawingML gradient stop structure")
372 return children[0]
373
374
375 def _linear_gradient_angle(lin: ET.Element) -> int:
376 """Parse one optional DrawingML positive fixed angle."""
377 raw = lin.get("ang")
378 if raw is None:
379 return 0
380 token = raw.strip()
381 if _OOXML_INTEGER_RE.fullmatch(token) is None:
382 raise ValueError(f"Invalid DrawingML linear gradient angle: {raw!r}")
383 angle = int(token)
384 if not 0 <= angle < _OOXML_FULL_CIRCLE:
385 raise ValueError(
386 f"DrawingML linear gradient angle={angle} is outside "
387 f"0..{_OOXML_FULL_CIRCLE - 1}"
388 )
389 return angle
390
391
392 def _validate_linear_gradient_structure(lin: ET.Element) -> None:
393 """Require one leaf a:lin with only the registered attributes."""
394 unsupported = sorted(set(lin.attrib) - {"ang", "scaled"})
395 if unsupported or list(lin) or (lin.text or "").strip():
396 details = ", ".join(unsupported) if unsupported else "payload"
397 raise ValueError(
398 f"Invalid DrawingML linear gradient structure: {details}"
399 )
400
401
402 def _validate_linear_gradient_scaling(
403 lin: ET.Element,
404 angle: int,
405 ) -> None:
406 """Require a scaling mode representable by unit-box SVG geometry."""
407 scaled = _parse_ooxml_boolean(
408 lin.get("scaled"),
409 default=False,
410 label="linear gradient scaled value",
411 )
412 quarter_turn = 90 * ANGLE_UNIT
413 if not scaled and angle % quarter_turn:
414 raise ValueError(
415 "Unscaled non-cardinal DrawingML linear gradients are not "
416 "representable by the normalized SVG mapping"
417 )
418
419
420 def _validate_gradient_rotation(gradient: ET.Element) -> None:
421 """Require a gradient that rotates with its containing shape."""
422 rotates_with_shape = _parse_ooxml_boolean(
423 gradient.get("rotWithShape"),
424 default=True,
425 label="gradient rotWithShape value",
426 )
427 if not rotates_with_shape:
428 raise ValueError(
429 "DrawingML gradients that do not rotate with their shape are "
430 "not representable by the local SVG mapping"
431 )
432
433
434 def _validate_gradient_attributes(gradient: ET.Element) -> None:
435 """Reject attributes outside the registered gradient-fill contract."""
436 unsupported = sorted(set(gradient.attrib) - {"flip", "rotWithShape"})
437 if unsupported:
438 raise ValueError(
439 "Unsupported DrawingML gradient fill attribute(s): "
440 + ", ".join(unsupported)
441 )
442
443
444 def _validate_gradient_child_structure(gradient: ET.Element) -> None:
445 """Require the registered DrawingML gradient-fill child sequence."""
446 namespace = NS["a"]
447 gs_list = f"{{{namespace}}}gsLst"
448 linear = f"{{{namespace}}}lin"
449 path = f"{{{namespace}}}path"
450 tile_rect = f"{{{namespace}}}tileRect"
451 child_tags = tuple(child.tag for child in gradient)
452 allowed_sequences = {
453 (gs_list,),
454 (gs_list, linear),
455 (gs_list, path),
456 (gs_list, tile_rect),
457 (gs_list, linear, tile_rect),
458 (gs_list, path, tile_rect),
459 }
460 if (
461 child_tags not in allowed_sequences
462 or (gradient.text or "").strip()
463 or any((child.tail or "").strip() for child in gradient)
464 ):
465 raise ValueError(
466 "Invalid DrawingML gradient fill child structure"
467 )
468
469
470 def _validate_gradient_flip(gradient: ET.Element) -> None:
471 """Reject gradient tile flipping absent from project SVG."""
472 flip = gradient.get("flip", "none")
473 if flip != "none":
474 raise ValueError(f"Unsupported DrawingML gradient flip: {flip!r}")
475
476
477 def _validate_gradient_tile_rect(gradient: ET.Element) -> None:
478 """Accept only the full-area gradient tile rectangle."""
479 tile_rects = gradient.findall("a:tileRect", NS)
480 if len(tile_rects) > 1:
481 raise ValueError(
482 "DrawingML gradient fill must contain at most one tileRect"
483 )
484 if not tile_rects:
485 return
486 values = _relative_rect_values(
487 tile_rects[0],
488 label="gradient tileRect",
489 )
490 for value in values.values():
491 if value != 0:
492 raise ValueError(
493 "Non-zero DrawingML gradient tileRect is not representable "
494 "by the project SVG gradient mapping"
495 )
496
497
498 def _validate_path_gradient_focus(
499 path: ET.Element,
500 ) -> dict[str, Decimal] | None:
501 """Validate and return one path-gradient focus rectangle."""
502 focus_rects = path.findall("a:fillToRect", NS)
503 if len(focus_rects) > 1:
504 raise ValueError(
505 "DrawingML path gradient must contain at most one fillToRect"
506 )
507 if not focus_rects:
508 return None
509 values = _relative_rect_values(
510 focus_rects[0],
511 label="path gradient fillToRect",
512 )
513 return {
514 edge: values.get(edge, Decimal(0))
515 for edge in ("l", "t", "r", "b")
516 }
517
518
519 def _relative_rect_values(
520 rect: ET.Element,
521 *,
522 label: str,
523 ) -> dict[str, Decimal]:
524 """Validate one DrawingML relative-rectangle leaf and parse its edges."""
525 if (
526 set(rect.attrib) - {"l", "t", "r", "b"}
527 or list(rect)
528 or (rect.text or "").strip()
529 ):
530 raise ValueError(f"Invalid DrawingML {label} structure")
531 return {
532 edge: _parse_ooxml_percentage(raw, label=f"{label} {edge}")
533 for edge, raw in rect.attrib.items()
534 }
535
536
537 def _parse_ooxml_percentage(raw: str, *, label: str) -> Decimal:
538 """Parse one DrawingML ST_Percentage as an exact normalized ratio."""
539 token = raw.strip()
540 if _OOXML_INTEGER_RE.fullmatch(token) is not None:
541 value = Decimal(token) / Decimal(PERCENT_UNIT)
542 elif _OOXML_PERCENT_LITERAL_RE.fullmatch(token) is not None:
543 value = Decimal(token[:-1]) / Decimal(100)
544 else:
545 raise ValueError(f"Invalid DrawingML {label}: {raw!r}")
546 if not _OOXML_PERCENTAGE_MIN <= value <= _OOXML_PERCENTAGE_MAX:
547 raise ValueError(f"Invalid DrawingML {label}: {raw!r}")
548 return value
549
550
551 def _parse_ooxml_boolean(
552 raw: str | None,
553 *,
554 default: bool,
555 label: str,
556 ) -> bool:
557 """Parse one W3C XML Schema boolean without permissive aliases."""
558 if raw is None:
559 return default
560 token = raw.strip()
561 if token in {"1", "true"}:
562 return True
563 if token in {"0", "false"}:
564 return False
565 raise ValueError(f"Invalid DrawingML {label}: {raw!r}")
566
567
568 def _validate_path_gradient_type(path: ET.Element) -> None:
569 """Require one registered DrawingML path-shade enum value."""
570 path_type = path.get("path", "rect")
571 if path_type not in {"circle", "rect", "shape"}:
572 raise ValueError(
573 f"Unsupported DrawingML path gradient type: {path_type!r}"
574 )
575
576
577 def _validate_path_gradient_structure(path: ET.Element) -> None:
578 """Require only the registered path attribute and focus rectangle."""
579 unsupported = sorted(set(path.attrib) - {"path"})
580 children = list(path)
581 fill_to_rect_tag = f"{{{NS['a']}}}fillToRect"
582 has_invalid_payload = (
583 (path.text or "").strip()
584 or any(
585 child.tag != fill_to_rect_tag or (child.tail or "").strip()
586 for child in children
587 )
588 )
589 if unsupported or has_invalid_payload:
590 details = ", ".join(unsupported) if unsupported else "payload"
591 raise ValueError(
592 f"Invalid DrawingML path gradient structure: {details}"
593 )
594
595
596 def _resolve_blip_fill(_elem, _palette, _prefix, _seq, _placeholder_hex) -> FillResult:
597 """blipFill on <p:spPr> means a shape filled with an image — handled at
598 pic_to_svg level. For now mark as transparent so the shape's outline
599 still draws and pic_to_svg can layer the image on top.
600 """
601 return FillResult.none_fill()
602
603
604 def _resolve_patt_fill(elem: ET.Element, palette: ColorPalette | None,
605 prefix, seq, placeholder_hex: str | None) -> FillResult:
606 """Pattern fills (<a:pattFill prst="..."/> with fg/bg colors)."""
607 fg = elem.find("a:fgClr", NS)
608 bg = elem.find("a:bgClr", NS)
609 fg_hex, fg_alpha = resolve_color(
610 find_color_elem(fg), palette, placeholder_hex=placeholder_hex,
611 )
612 bg_hex, bg_alpha = resolve_color(
613 find_color_elem(bg), palette, placeholder_hex=placeholder_hex,
614 )
615 if fg_hex is None:
616 return FillResult.inherit()
617
618 prst = elem.attrib.get("prst", "")
619 geom = _pattern_foreground(prst, fg_hex, fg_alpha)
620 if geom is None:
621 # Unsupported preset → degrade to solid fg color so the shape at
622 # least carries the right tone. Round-trip will lose the texture.
623 attrs: dict[str, str] = {"fill": fg_hex}
624 if fg_alpha < 1.0:
625 attrs["fill-opacity"] = format_ooxml_alpha(fg_alpha)
626 return FillResult(attrs=attrs)
627 tile_w, tile_h, fg_svg = geom
628
629 if seq is None:
630 seq = [0]
631 seq[0] += 1
632 pattern_id = f"{prefix}patt{seq[0]}"
633 bg_rect = ""
634 if bg_hex is not None:
635 bg_opacity = (
636 f' fill-opacity="{format_ooxml_alpha(bg_alpha)}"'
637 if bg_alpha < 1.0 else ""
638 )
639 bg_rect = (
640 f'<rect width="{tile_w}" height="{tile_h}" '
641 f'fill="{bg_hex}"{bg_opacity}/>'
642 )
643 # Tag with data attributes so the reverse exporter can rebuild <a:pattFill>
644 # faithfully (preset + fg/bg colors) instead of inferring from path geometry.
645 bg_attr = f' data-pptx-bg="{bg_hex}"' if bg_hex is not None else ""
646 pattern_xml = (
647 f'<pattern id="{pattern_id}" patternUnits="userSpaceOnUse" '
648 f'width="{tile_w}" height="{tile_h}" '
649 f'data-pptx-pattern="{prst}" data-pptx-fg="{fg_hex}"{bg_attr}>'
650 f'{bg_rect}{fg_svg}</pattern>'
651 )
652 return FillResult(
653 attrs={"fill": f"url(#{pattern_id})"},
654 defs=[pattern_xml],
655 )
656
657
658 # ---------------------------------------------------------------------------
659 # Per-preset SVG geometry for <a:pattFill prst="...">
660 #
661 # Each handler returns (tile_w, tile_h, foreground_svg). The caller wraps with
662 # the background rect + <pattern> element. None means "unsupported preset" and
663 # the caller degrades to a solid fg color.
664 # ---------------------------------------------------------------------------
665
666 def _pattern_foreground(prst: str, fg: str,
667 fg_alpha: float) -> tuple[int, int, str] | None:
668 stroke_op = (
669 f' stroke-opacity="{format_ooxml_alpha(fg_alpha)}"'
670 if fg_alpha < 1.0 else ""
671 )
672 fill_op = (
673 f' fill-opacity="{format_ooxml_alpha(fg_alpha)}"'
674 if fg_alpha < 1.0 else ""
675 )
676
677 # Diagonal stripes — tile size and stroke width pick the visual weight.
678 diag = {
679 "ltUpDiag": (8, 1.0, "up", False),
680 "dkUpDiag": (8, 2.0, "up", False),
681 "wdUpDiag": (16, 1.0, "up", False),
682 "dashUpDiag": (8, 1.0, "up", True),
683 "ltDnDiag": (8, 1.0, "dn", False),
684 "dkDnDiag": (8, 2.0, "dn", False),
685 "wdDnDiag": (16, 1.0, "dn", False),
686 "dashDnDiag": (8, 1.0, "dn", True),
687 }
688 if prst in diag:
689 tile, sw, direction, dashed = diag[prst]
690 dash = ' stroke-dasharray="3 2"' if dashed else ""
691 if direction == "up":
692 d = f"M -2 {tile} L {tile} -2 M 0 {tile + 2} L {tile + 2} 0"
693 else:
694 d = f"M -2 0 L {tile} {tile + 2} M 0 -2 L {tile + 2} {tile}"
695 return tile, tile, (
696 f'<path d="{d}" stroke="{fg}"{stroke_op} '
697 f'stroke-width="{fmt_num(sw)}" fill="none"{dash}/>'
698 )
699
700 # Horizontal / vertical lines.
701 line_specs = {
702 "horz": ("h", 8, 1.0, False),
703 "ltHorz": ("h", 8, 0.5, False),
704 "dkHorz": ("h", 8, 2.0, False),
705 "narHorz": ("h", 4, 1.0, False),
706 "dashHorz": ("h", 8, 1.0, True),
707 "vert": ("v", 8, 1.0, False),
708 "ltVert": ("v", 8, 0.5, False),
709 "dkVert": ("v", 8, 2.0, False),
710 "narVert": ("v", 4, 1.0, False),
711 "dashVert": ("v", 8, 1.0, True),
712 }
713 if prst in line_specs:
714 axis, tile, sw, dashed = line_specs[prst]
715 dash = ' stroke-dasharray="3 2"' if dashed else ""
716 mid = tile / 2.0
717 if axis == "h":
718 line = (
719 f'<line x1="0" y1="{fmt_num(mid)}" '
720 f'x2="{tile}" y2="{fmt_num(mid)}"'
721 )
722 else:
723 line = (
724 f'<line x1="{fmt_num(mid)}" y1="0" '
725 f'x2="{fmt_num(mid)}" y2="{tile}"'
726 )
727 return tile, tile, (
728 f'{line} stroke="{fg}"{stroke_op} '
729 f'stroke-width="{fmt_num(sw)}"{dash}/>'
730 )
731
732 # Grids / crosses.
733 if prst == "cross":
734 return 8, 8, (
735 f'<line x1="0" y1="4" x2="8" y2="4" stroke="{fg}"{stroke_op} stroke-width="1"/>'
736 f'<line x1="4" y1="0" x2="4" y2="8" stroke="{fg}"{stroke_op} stroke-width="1"/>'
737 )
738 if prst == "diagCross":
739 d = (
740 "M -2 8 L 8 -2 M 0 10 L 10 0 "
741 "M -2 0 L 8 10 M 0 -2 L 10 8"
742 )
743 return 8, 8, (
744 f'<path d="{d}" stroke="{fg}"{stroke_op} stroke-width="1" fill="none"/>'
745 )
746 if prst in ("smGrid", "lgGrid"):
747 tile = 4 if prst == "smGrid" else 16
748 # Lines along top + left edges; tiles together produce a uniform grid.
749 return tile, tile, (
750 f'<path d="M 0 0 L {tile} 0 M 0 0 L 0 {tile}" '
751 f'stroke="{fg}"{stroke_op} stroke-width="0.5" fill="none"/>'
752 )
753 if prst == "dotGrid":
754 # Dots at corners → tiling yields a uniform dot grid.
755 return 8, 8, (
756 f'<circle cx="0" cy="0" r="1" fill="{fg}"{fill_op}/>'
757 )
758 if prst == "dotDmnd":
759 return 8, 8, (
760 f'<circle cx="0" cy="0" r="1" fill="{fg}"{fill_op}/>'
761 f'<circle cx="4" cy="4" r="1" fill="{fg}"{fill_op}/>'
762 )
763
764 # Percentage shading — single centered dot whose area matches the target
765 # density. Approximates PowerPoint's stipple without per-tile artwork.
766 if prst.startswith("pct"):
767 try:
768 pct = float(prst[3:])
769 except ValueError:
770 return None
771 pct = max(0.0, min(pct, 100.0))
772 tile = 8
773 radius = math.sqrt(pct / 100.0 * tile * tile / math.pi)
774 radius = max(0.3, min(radius, tile / 2.0))
775 return tile, tile, (
776 f'<circle cx="{tile / 2}" cy="{tile / 2}" '
777 f'r="{fmt_num(radius, 3)}" fill="{fg}"{fill_op}/>'
778 )
779
780 return None
781
782
783 def _hex_distance(a: str, b: str) -> float:
784 """Euclidean distance between two #RRGGBB colors."""
785 try:
786 ar, ag, ab = int(a[1:3], 16), int(a[3:5], 16), int(a[5:7], 16)
787 br, bg, bb = int(b[1:3], 16), int(b[3:5], 16), int(b[5:7], 16)
788 except (ValueError, IndexError):
789 return 255.0
790 return math.sqrt((ar - br) ** 2 + (ag - bg) ** 2 + (ab - bb) ** 2)
791
792
793 # ---------------------------------------------------------------------------
794 # Geometry helpers
795 # ---------------------------------------------------------------------------
796
797 def _angle_to_unit_endpoints(angle_deg: float) -> tuple[float, float, float, float]:
798 """Convert a DrawingML linear gradient angle to SVG x1/y1/x2/y2 in unit box.
799
800 DrawingML 0° = horizontal pointing right; angle is clockwise.
801 SVG default linearGradient is also unit-box (objectBoundingBox).
802 """
803 rad = math.radians(angle_deg % 360)
804 cos_a = math.cos(rad)
805 sin_a = math.sin(rad)
806 # Center of unit box
807 cx, cy = 0.5, 0.5
808 # Half-extent in the direction of the angle vector.
809 # We project the unit box onto the angle direction; the line endpoints are
810 # the projections of the box corners.
811 half = abs(cos_a) * 0.5 + abs(sin_a) * 0.5
812 x1 = cx - cos_a * half
813 y1 = cy - sin_a * half
814 x2 = cx + cos_a * half
815 y2 = cy + sin_a * half
816 return x1, y1, x2, y2
817
817 lines PYTHON