返回 ppt-master
color_resolver.py
根目录 / skills / ppt-master / scripts / pptx_to_svg / color_resolver.py
1 """DrawingML color resolution.
2
3 Resolves any of the 6 OOXML color types (srgbClr, schemeClr, sysClr, prstClr,
4 hslClr, scrgbClr) plus the registered luminance, saturation, hue, alpha,
5 grayscale, complement, and inverse modifiers into an (#RRGGBB, alpha) pair.
6
7 Theme palette is resolved from the slide master's a:clrMap and theme1.xml's
8 a:clrScheme.
9 """
10
11 from __future__ import annotations
12
13 import colorsys
14 import re
15 from collections.abc import Callable
16 from xml.etree import ElementTree as ET
17
18 from .emu_units import NS
19 from .ooxml_loader import PartRef
20
21
22 # ---------------------------------------------------------------------------
23 # Preset color names (DrawingML <a:prstClr val="...">)
24 # ---------------------------------------------------------------------------
25
26 # Source: complete ECMA-376 ST_PresetColorVal enumeration (190 values).
27 PRST_COLORS = {
28 "aliceBlue": "F0F8FF", "antiqueWhite": "FAEBD7", "aqua": "00FFFF",
29 "aquamarine": "7FFFD4", "azure": "F0FFFF", "beige": "F5F5DC",
30 "bisque": "FFE4C4", "black": "000000", "blanchedAlmond": "FFEBCD",
31 "blue": "0000FF", "blueViolet": "8A2BE2", "brown": "A52A2A",
32 "burlyWood": "DEB887", "cadetBlue": "5F9EA0", "chartreuse": "7FFF00",
33 "chocolate": "D2691E", "coral": "FF7F50", "cornflowerBlue": "6495ED",
34 "cornsilk": "FFF8DC", "crimson": "DC143C", "cyan": "00FFFF",
35 "darkBlue": "00008B", "darkCyan": "008B8B", "darkGoldenrod": "B8860B",
36 "darkGray": "A9A9A9", "darkGrey": "A9A9A9", "darkGreen": "006400",
37 "darkKhaki": "BDB76B", "darkMagenta": "8B008B", "darkOliveGreen": "556B2F",
38 "darkOrange": "FF8C00", "darkOrchid": "9932CC", "darkRed": "8B0000",
39 "darkSalmon": "E9967A", "darkSeaGreen": "8FBC8F", "darkSlateBlue": "483D8B",
40 "darkSlateGray": "2F4F4F", "darkSlateGrey": "2F4F4F",
41 "darkTurquoise": "00CED1", "darkViolet": "9400D3", "deepPink": "FF1493",
42 "deepSkyBlue": "00BFFF", "dimGray": "696969", "dimGrey": "696969",
43 "dkBlue": "00008B", "dkCyan": "008B8B", "dkGoldenrod": "B8860B",
44 "dkGray": "A9A9A9", "dkGrey": "A9A9A9", "dkGreen": "006400",
45 "dkKhaki": "BDB76B", "dkMagenta": "8B008B", "dkOliveGreen": "556B2F",
46 "dkOrange": "FF8C00", "dkOrchid": "9932CC", "dkRed": "8B0000",
47 "dkSalmon": "E9967A", "dkSeaGreen": "8FBC8F", "dkSlateBlue": "483D8B",
48 "dkSlateGray": "2F4F4F", "dkSlateGrey": "2F4F4F",
49 "dkTurquoise": "00CED1", "dkViolet": "9400D3",
50 "dodgerBlue": "1E90FF", "firebrick": "B22222", "floralWhite": "FFFAF0",
51 "forestGreen": "228B22", "fuchsia": "FF00FF", "gainsboro": "DCDCDC",
52 "ghostWhite": "F8F8FF", "gold": "FFD700", "goldenrod": "DAA520",
53 "gray": "808080", "grey": "808080", "green": "008000",
54 "greenYellow": "ADFF2F", "honeydew": "F0FFF0", "hotPink": "FF69B4",
55 "indianRed": "CD5C5C", "indigo": "4B0082", "ivory": "FFFFF0",
56 "khaki": "F0E68C", "lavender": "E6E6FA", "lavenderBlush": "FFF0F5",
57 "lawnGreen": "7CFC00", "lemonChiffon": "FFFACD",
58 "lightBlue": "ADD8E6", "lightCoral": "F08080", "lightCyan": "E0FFFF",
59 "lightGoldenrodYellow": "FAFAD2", "lightGray": "D3D3D3",
60 "lightGrey": "D3D3D3", "lightGreen": "90EE90", "lightPink": "FFB6C1",
61 "lightSalmon": "FFA07A", "lightSeaGreen": "20B2AA",
62 "lightSkyBlue": "87CEFA", "lightSlateGray": "778899",
63 "lightSlateGrey": "778899", "lightSteelBlue": "B0C4DE",
64 "lightYellow": "FFFFE0", "ltBlue": "ADD8E6", "ltCoral": "F08080",
65 "ltCyan": "E0FFFF", "ltGoldenrodYellow": "FAFAD2", "ltGray": "D3D3D3",
66 "ltGrey": "D3D3D3", "ltGreen": "90EE90", "ltPink": "FFB6C1",
67 "ltSalmon": "FFA07A", "ltSeaGreen": "20B2AA", "ltSkyBlue": "87CEFA",
68 "ltSlateGray": "778899", "ltSlateGrey": "778899",
69 "ltSteelBlue": "B0C4DE", "ltYellow": "FFFFE0",
70 "lime": "00FF00", "limeGreen": "32CD32", "linen": "FAF0E6",
71 "magenta": "FF00FF", "maroon": "800000", "medAquamarine": "66CDAA",
72 "medBlue": "0000CD", "medOrchid": "BA55D3", "medPurple": "9370DB",
73 "medSeaGreen": "3CB371", "medSlateBlue": "7B68EE",
74 "medSpringGreen": "00FA9A", "medTurquoise": "48D1CC",
75 "medVioletRed": "C71585", "mediumAquamarine": "66CDAA",
76 "mediumBlue": "0000CD", "mediumOrchid": "BA55D3", "mediumPurple": "9370DB",
77 "mediumSeaGreen": "3CB371", "mediumSlateBlue": "7B68EE",
78 "mediumSpringGreen": "00FA9A", "mediumTurquoise": "48D1CC",
79 "mediumVioletRed": "C71585",
80 "midnightBlue": "191970", "mintCream": "F5FFFA", "mistyRose": "FFE4E1",
81 "moccasin": "FFE4B5", "navajoWhite": "FFDEAD", "navy": "000080",
82 "oldLace": "FDF5E6", "olive": "808000", "oliveDrab": "6B8E23",
83 "orange": "FFA500", "orangeRed": "FF4500", "orchid": "DA70D6",
84 "paleGoldenrod": "EEE8AA", "paleGreen": "98FB98", "paleTurquoise": "AFEEEE",
85 "paleVioletRed": "DB7093", "papayaWhip": "FFEFD5", "peachPuff": "FFDAB9",
86 "peru": "CD853F", "pink": "FFC0CB", "plum": "DDA0DD",
87 "powderBlue": "B0E0E6", "purple": "800080", "red": "FF0000",
88 "rosyBrown": "BC8F8F", "royalBlue": "4169E1", "saddleBrown": "8B4513",
89 "salmon": "FA8072", "sandyBrown": "F4A460", "seaGreen": "2E8B57",
90 "seaShell": "FFF5EE", "sienna": "A0522D", "silver": "C0C0C0",
91 "skyBlue": "87CEEB", "slateBlue": "6A5ACD", "slateGray": "708090",
92 "slateGrey": "708090", "snow": "FFFAFA", "springGreen": "00FF7F",
93 "steelBlue": "4682B4", "tan": "D2B48C", "teal": "008080",
94 "thistle": "D8BFD8", "tomato": "FF6347", "turquoise": "40E0D0",
95 "violet": "EE82EE", "wheat": "F5DEB3", "white": "FFFFFF",
96 "whiteSmoke": "F5F5F5", "yellow": "FFFF00", "yellowGreen": "9ACD32",
97 }
98
99
100 # Scheme color name normalization
101 SCHEME_ALIASES = {
102 "bg1": "lt1", "bg2": "lt2",
103 "tx1": "dk1", "tx2": "dk2",
104 }
105 _THEME_SCHEME_SLOTS = (
106 "dk1",
107 "lt1",
108 "dk2",
109 "lt2",
110 "accent1",
111 "accent2",
112 "accent3",
113 "accent4",
114 "accent5",
115 "accent6",
116 "hlink",
117 "folHlink",
118 )
119 _COLOR_MAP_SOURCES = (
120 "bg1",
121 "tx1",
122 "bg2",
123 "tx2",
124 "accent1",
125 "accent2",
126 "accent3",
127 "accent4",
128 "accent5",
129 "accent6",
130 "hlink",
131 "folHlink",
132 )
133 COLOR_TAGS = (
134 "srgbClr",
135 "schemeClr",
136 "sysClr",
137 "prstClr",
138 "hslClr",
139 "scrgbClr",
140 )
141 _COLOR_QNAMES = frozenset({
142 f"{{{NS['a']}}}{name}"
143 for name in COLOR_TAGS
144 })
145 _THEME_COLOR_TAGS = _COLOR_QNAMES - {f"{{{NS['a']}}}schemeClr"}
146 _SRGB_HEX_RE = re.compile(r"[0-9A-Fa-f]{6}")
147 _OOXML_INTEGER_RE = re.compile(r"[+-]?[0-9]+")
148 _OOXML_INT_MIN = -(2**31)
149 _OOXML_INT_MAX = 2**31 - 1
150 _COLOR_MODIFIER_PERCENT_RANGES = {
151 "alpha": (0, 100000),
152 "alphaMod": (0, _OOXML_INT_MAX),
153 "alphaOff": (-100000, 100000),
154 "hueMod": (0, _OOXML_INT_MAX),
155 "lumMod": (_OOXML_INT_MIN, _OOXML_INT_MAX),
156 "lumOff": (_OOXML_INT_MIN, _OOXML_INT_MAX),
157 "satMod": (_OOXML_INT_MIN, _OOXML_INT_MAX),
158 "satOff": (_OOXML_INT_MIN, _OOXML_INT_MAX),
159 "shade": (0, 100000),
160 "tint": (0, 100000),
161 }
162 _COLOR_MODIFIER_FLAG_NAMES = frozenset({"comp", "gray", "inv"})
163 _SCHEME_COLOR_VALUES = {
164 "bg1",
165 "tx1",
166 "bg2",
167 "tx2",
168 "accent1",
169 "accent2",
170 "accent3",
171 "accent4",
172 "accent5",
173 "accent6",
174 "hlink",
175 "folHlink",
176 "phClr",
177 "dk1",
178 "lt1",
179 "dk2",
180 "lt2",
181 }
182 _SYSTEM_COLOR_VALUES = {
183 "scrollBar",
184 "background",
185 "activeCaption",
186 "inactiveCaption",
187 "menu",
188 "window",
189 "windowFrame",
190 "menuText",
191 "windowText",
192 "captionText",
193 "activeBorder",
194 "inactiveBorder",
195 "appWorkspace",
196 "highlight",
197 "highlightText",
198 "btnFace",
199 "btnShadow",
200 "grayText",
201 "btnText",
202 "inactiveCaptionText",
203 "btnHighlight",
204 "3dDkShadow",
205 "3dLight",
206 "infoText",
207 "infoBk",
208 "hotLight",
209 "gradientActiveCaption",
210 "gradientInactiveCaption",
211 "menuHighlight",
212 "menuBar",
213 }
214
215
216 # ---------------------------------------------------------------------------
217 # ColorPalette
218 # ---------------------------------------------------------------------------
219
220 class ColorPalette:
221 """Resolves scheme colors via the master's a:clrMap + theme1's a:clrScheme.
222
223 a:clrMap remaps presentation-level scheme names (bg1/tx1) to theme-level
224 names (lt1/dk1) — this is rarely overridden but must be honored.
225 """
226
227 def __init__(
228 self,
229 master: PartRef | None,
230 theme: PartRef | None,
231 *,
232 strict: bool = True,
233 diagnostic_sink: Callable[[str, str, str], None] | None = None,
234 ) -> None:
235 self.scheme: dict[str, str] = {}
236 self.clr_map: dict[str, str] = {}
237 self.strict = strict
238 self.diagnostic_sink = diagnostic_sink
239 if theme is not None:
240 try:
241 self._load_scheme(theme)
242 except ValueError as exc:
243 if strict:
244 raise
245 self.scheme.clear()
246 self._diagnose(
247 "theme-color-scheme-normalized",
248 str(exc),
249 "read recognized theme color slots",
250 )
251 self._load_scheme_compatible(theme)
252 if master is not None:
253 try:
254 self._load_clr_map(master)
255 except ValueError as exc:
256 if strict:
257 raise
258 self.clr_map.clear()
259 self._diagnose(
260 "theme-color-map-normalized",
261 str(exc),
262 "read recognized color-map entries",
263 )
264 self._load_clr_map_compatible(master)
265
266 def _diagnose(self, code: str, message: str, fallback: str) -> None:
267 if self.diagnostic_sink is not None:
268 self.diagnostic_sink(code, message, fallback)
269
270 def _load_scheme_compatible(self, theme: PartRef) -> None:
271 """Read recognizable theme slots without requiring canonical wrapper XML."""
272 clr_scheme = theme.xml.find(".//a:clrScheme", NS)
273 if clr_scheme is None:
274 return
275 for slot in clr_scheme:
276 if not isinstance(slot.tag, str):
277 continue
278 name = slot.tag.rsplit("}", 1)[-1]
279 color = next(
280 (
281 child
282 for child in slot
283 if isinstance(child.tag, str)
284 and child.tag.rsplit("}", 1)[-1] in COLOR_TAGS
285 ),
286 None,
287 )
288 hex_, _alpha = resolve_color(color, None, strict=False)
289 if hex_ is not None:
290 self.scheme[name] = hex_[1:]
291
292 def _load_clr_map_compatible(self, master: PartRef) -> None:
293 """Read valid known clrMap entries and ignore unrelated extensions."""
294 clr_map = master.xml.find("p:clrMap", NS)
295 if clr_map is None:
296 return
297 for source in _COLOR_MAP_SOURCES:
298 target = clr_map.attrib.get(source)
299 if target in _THEME_SCHEME_SLOTS:
300 self.clr_map[source] = target
301
302 def _load_scheme(self, theme: PartRef) -> None:
303 theme_root = theme.xml
304 if theme_root.tag != f"{{{NS['a']}}}theme":
305 raise ValueError(f"{theme.path}: expected a:theme root")
306
307 theme_elements = theme_root.findall("a:themeElements", NS)
308 if len(theme_elements) != 1:
309 raise ValueError(
310 f"{theme.path}: expected exactly one direct a:themeElements"
311 )
312 clr_schemes = theme_elements[0].findall("a:clrScheme", NS)
313 if len(clr_schemes) != 1:
314 raise ValueError(
315 f"{theme.path}: expected exactly one direct a:clrScheme"
316 )
317 clr_scheme = clr_schemes[0]
318 children = list(clr_scheme)
319 expected_tags = [
320 f"{{{NS['a']}}}{name}" for name in _THEME_SCHEME_SLOTS
321 ]
322 if (
323 set(clr_scheme.attrib) != {"name"}
324 or (clr_scheme.text or "").strip()
325 or [child.tag for child in children] != expected_tags
326 or any((child.tail or "").strip() for child in children)
327 ):
328 raise ValueError(
329 f"{theme.path}: invalid DrawingML color scheme structure"
330 )
331
332 for name, slot in zip(_THEME_SCHEME_SLOTS, children):
333 color_children = list(slot)
334 if (
335 slot.attrib
336 or (slot.text or "").strip()
337 or len(color_children) != 1
338 or color_children[0].tag not in _THEME_COLOR_TAGS
339 or (color_children[0].tail or "").strip()
340 ):
341 raise ValueError(
342 f"{theme.path}: invalid DrawingML theme color slot {name!r}"
343 )
344 try:
345 hex_, alpha = resolve_color(color_children[0], None)
346 except ValueError as exc:
347 raise ValueError(
348 f"{theme.path}: invalid DrawingML theme color slot "
349 f"{name!r}: {exc}"
350 ) from exc
351 if hex_ is None:
352 raise ValueError(
353 f"{theme.path}: unresolved DrawingML theme color slot {name!r}"
354 )
355 if alpha != 1.0:
356 raise ValueError(
357 f"{theme.path}: non-opaque DrawingML theme color slot {name!r}"
358 )
359 self.scheme[name] = hex_[1:]
360
361 def _load_clr_map(self, master: PartRef) -> None:
362 master_root = master.xml
363 if master_root.tag != f"{{{NS['p']}}}sldMaster":
364 raise ValueError(f"{master.path}: expected p:sldMaster root")
365 clr_maps = master_root.findall("p:clrMap", NS)
366 if len(clr_maps) != 1:
367 raise ValueError(
368 f"{master.path}: expected exactly one direct p:clrMap"
369 )
370 clr_map = clr_maps[0]
371 children = list(clr_map)
372 if (
373 set(clr_map.attrib) != set(_COLOR_MAP_SOURCES)
374 or children
375 or (clr_map.text or "").strip()
376 ):
377 raise ValueError(
378 f"{master.path}: invalid DrawingML color map structure"
379 )
380 for source in _COLOR_MAP_SOURCES:
381 target = clr_map.attrib[source]
382 if target not in _THEME_SCHEME_SLOTS:
383 raise ValueError(
384 f"{master.path}: invalid DrawingML color map target "
385 f"{source}={target!r}"
386 )
387 self.clr_map[source] = target
388
389 def resolve_scheme(self, name: str) -> str | None:
390 """scheme name (e.g. 'accent1', 'bg1') -> 'RRGGBB'. None on miss."""
391 # apply clrMap remap (bg1 -> lt1, tx1 -> dk1, etc.)
392 mapped = self.clr_map.get(name, name)
393 # canonical alias (bg1 -> lt1)
394 mapped = SCHEME_ALIASES.get(mapped, mapped)
395 return self.scheme.get(mapped)
396
397
398 # ---------------------------------------------------------------------------
399 # Color resolution
400 # ---------------------------------------------------------------------------
401
402 def validate_no_fill(elem: ET.Element) -> None:
403 """Require the empty DrawingML a:noFill leaf contract."""
404 if elem.attrib or list(elem) or (elem.text or "").strip():
405 raise ValueError("Invalid DrawingML noFill structure")
406
407
408 def resolve_solid_fill_color(
409 elem: ET.Element,
410 palette: ColorPalette | None,
411 *,
412 placeholder_hex: str | None = None,
413 ) -> tuple[str, float]:
414 """Resolve one explicit color from a closed DrawingML solid fill."""
415 children = list(elem)
416 if not elem.attrib and not children and not (elem.text or "").strip():
417 raise ValueError(
418 "DrawingML solid fill requires one explicit color"
419 )
420 if (
421 elem.attrib
422 or len(children) != 1
423 or children[0].tag not in _COLOR_QNAMES
424 or (elem.text or "").strip()
425 or (children[0].tail or "").strip()
426 ):
427 raise ValueError("Invalid DrawingML solid fill structure")
428 hex_, alpha = resolve_color(
429 children[0],
430 palette,
431 placeholder_hex=placeholder_hex,
432 )
433 if hex_ is None:
434 raise ValueError("DrawingML solid fill color cannot be resolved")
435 return hex_, alpha
436
437
438 def find_color_elem(parent: ET.Element | None) -> ET.Element | None:
439 """Return the sole direct registered color, rejecting ambiguous aliases."""
440 if parent is None:
441 return None
442 colors: list[ET.Element] = []
443 for child in list(parent):
444 if not isinstance(child.tag, str):
445 continue
446 name = child.tag.rsplit("}", 1)[-1]
447 if name not in COLOR_TAGS:
448 continue
449 if child.tag not in _COLOR_QNAMES:
450 raise ValueError(
451 f"Invalid DrawingML color child namespace: {child.tag!r}"
452 )
453 colors.append(child)
454 if len(colors) > 1:
455 raise ValueError(
456 "DrawingML color container has multiple direct color children"
457 )
458 return colors[0] if colors else None
459
460
461 def resolve_color(
462 color_elem: ET.Element | None,
463 palette: ColorPalette | None,
464 *,
465 placeholder_hex: str | None = None,
466 strict: bool | None = None,
467 ) -> tuple[str | None, float]:
468 """Resolve a color element to (#RRGGBB, alpha).
469
470 Args:
471 color_elem: a:srgbClr / a:schemeClr / etc. May be None.
472 palette: ColorPalette for resolving schemeClr.
473 placeholder_hex: when a child uses schemeClr val="phClr" (placeholder
474 color used inside theme styles), substitute this hex.
475
476 Returns:
477 (hex string with leading '#', alpha in [0,1]) or (None, 1.0) on failure.
478 """
479 if color_elem is None:
480 return None, 1.0
481
482 effective_strict = palette.strict if strict is None and palette is not None else strict
483 if effective_strict is None:
484 effective_strict = True
485 try:
486 return _resolve_color_closed(
487 color_elem,
488 palette,
489 placeholder_hex=placeholder_hex,
490 )
491 except ValueError as exc:
492 if effective_strict:
493 raise
494 if palette is not None:
495 palette._diagnose(
496 "color-structure-normalized",
497 str(exc),
498 "retain recognized color attributes and modifiers",
499 )
500 compatible = _compatible_color_element(color_elem)
501 if compatible is None:
502 return None, 1.0
503 try:
504 return _resolve_color_closed(
505 compatible,
506 palette,
507 placeholder_hex=placeholder_hex,
508 )
509 except (KeyError, TypeError, ValueError):
510 return None, 1.0
511
512
513 def _resolve_color_closed(
514 color_elem: ET.Element,
515 palette: ColorPalette | None,
516 *,
517 placeholder_hex: str | None = None,
518 ) -> tuple[str | None, float]:
519 """Resolve the registered closed DrawingML color grammar."""
520
521 tag = color_elem.tag.split("}", 1)[-1]
522 base_hex: str | None = None
523 alpha: float = 1.0
524
525 if tag == "srgbClr":
526 base_hex = _srgb_hex_value(color_elem)
527 elif tag == "schemeClr":
528 name = _scheme_color_name(color_elem)
529 if name == "phClr":
530 base_hex = _normalize_hex(placeholder_hex) if placeholder_hex else None
531 elif palette is not None:
532 base_hex = palette.resolve_scheme(name)
533 elif tag == "sysClr":
534 base_hex = _system_color_fallback(color_elem)
535 elif tag == "prstClr":
536 base_hex = _preset_color_hex(color_elem)
537 elif tag == "hslClr":
538 base_hex = _hsl_color_hex(color_elem)
539 elif tag == "scrgbClr":
540 base_hex = _scrgb_color_hex(color_elem)
541
542 if base_hex is None:
543 return None, 1.0
544
545 # Apply modifiers (children of the color element).
546 base_hex, alpha = _apply_modifiers(base_hex, color_elem)
547 return f"#{base_hex}", alpha
548
549
550 def _compatible_color_element(color_elem: ET.Element) -> ET.Element | None:
551 """Return a canonical clone containing only recognized color semantics."""
552 if not isinstance(color_elem.tag, str):
553 return None
554 tag = color_elem.tag.rsplit("}", 1)[-1]
555 base_attributes = {
556 "srgbClr": ("val",),
557 "schemeClr": ("val",),
558 "sysClr": ("val", "lastClr"),
559 "prstClr": ("val",),
560 "hslClr": ("hue", "sat", "lum"),
561 "scrgbClr": ("r", "g", "b"),
562 }.get(tag)
563 if base_attributes is None or color_elem.tag != f"{{{NS['a']}}}{tag}":
564 return None
565
566 clone = ET.Element(color_elem.tag)
567 for attr in base_attributes:
568 value = color_elem.attrib.get(attr)
569 if value is not None:
570 clone.set(attr, value)
571
572 for child in color_elem:
573 if not isinstance(child.tag, str):
574 continue
575 name = child.tag.rsplit("}", 1)[-1]
576 if child.tag != f"{{{NS['a']}}}{name}":
577 continue
578 if name in _COLOR_MODIFIER_FLAG_NAMES:
579 clone.append(ET.Element(child.tag))
580 continue
581 if name not in _COLOR_MODIFIER_PERCENT_RANGES and name != "hueOff":
582 continue
583 value = child.attrib.get("val")
584 if value is None:
585 continue
586 modifier = ET.Element(child.tag)
587 modifier.set("val", value)
588 clone.append(modifier)
589 return clone
590
591
592 def _srgb_hex_value(color_elem: ET.Element) -> str:
593 """Parse the closed six-digit DrawingML sRGB base token."""
594 children = list(color_elem)
595 if (
596 color_elem.tag != f"{{{NS['a']}}}srgbClr"
597 or set(color_elem.attrib) != {"val"}
598 or (color_elem.text or "").strip()
599 or any((child.tail or "").strip() for child in children)
600 ):
601 raise ValueError("Invalid DrawingML sRGB color structure")
602 raw = color_elem.attrib["val"]
603 if _SRGB_HEX_RE.fullmatch(raw) is None:
604 raise ValueError(f"Invalid DrawingML sRGB color value: {raw!r}")
605 return raw.upper()
606
607
608 def _scheme_color_name(color_elem: ET.Element) -> str:
609 """Parse one closed DrawingML scheme-color reference."""
610 children = list(color_elem)
611 if (
612 color_elem.tag != f"{{{NS['a']}}}schemeClr"
613 or set(color_elem.attrib) != {"val"}
614 or (color_elem.text or "").strip()
615 or any((child.tail or "").strip() for child in children)
616 ):
617 raise ValueError("Invalid DrawingML scheme color structure")
618 name = color_elem.attrib["val"]
619 if name not in _SCHEME_COLOR_VALUES:
620 raise ValueError(f"Invalid DrawingML scheme color value: {name!r}")
621 return name
622
623
624 def _system_color_fallback(color_elem: ET.Element) -> str:
625 """Parse a registered system color with a portable six-digit fallback."""
626 children = list(color_elem)
627 if (
628 color_elem.tag != f"{{{NS['a']}}}sysClr"
629 or set(color_elem.attrib) - {"val", "lastClr"}
630 or "val" not in color_elem.attrib
631 or (color_elem.text or "").strip()
632 or any((child.tail or "").strip() for child in children)
633 ):
634 raise ValueError("Invalid DrawingML system color structure")
635 name = color_elem.attrib["val"]
636 if name not in _SYSTEM_COLOR_VALUES:
637 raise ValueError(f"Invalid DrawingML system color value: {name!r}")
638 fallback = color_elem.get("lastClr")
639 if fallback is None:
640 raise ValueError(
641 "DrawingML system color requires a lastClr fallback"
642 )
643 if _SRGB_HEX_RE.fullmatch(fallback) is None:
644 raise ValueError(
645 f"Invalid DrawingML system color fallback: {fallback!r}"
646 )
647 return fallback.upper()
648
649
650 def _preset_color_hex(color_elem: ET.Element) -> str:
651 """Resolve one exact value from the complete DrawingML preset enum."""
652 children = list(color_elem)
653 if (
654 color_elem.tag != f"{{{NS['a']}}}prstClr"
655 or set(color_elem.attrib) != {"val"}
656 or (color_elem.text or "").strip()
657 or any((child.tail or "").strip() for child in children)
658 ):
659 raise ValueError("Invalid DrawingML preset color structure")
660 name = color_elem.attrib["val"]
661 hex_ = PRST_COLORS.get(name)
662 if hex_ is None:
663 raise ValueError(f"Invalid DrawingML preset color value: {name!r}")
664 return hex_
665
666
667 def _hsl_color_hex(color_elem: ET.Element) -> str:
668 """Resolve one closed DrawingML HSL base color."""
669 children = list(color_elem)
670 if (
671 color_elem.tag != f"{{{NS['a']}}}hslClr"
672 or set(color_elem.attrib) != {"hue", "sat", "lum"}
673 or (color_elem.text or "").strip()
674 or any((child.tail or "").strip() for child in children)
675 ):
676 raise ValueError("Invalid DrawingML HSL color structure")
677 hue = _bounded_integer_attribute(
678 color_elem,
679 "hue",
680 minimum=0,
681 maximum=21_599_999,
682 label="HSL color",
683 )
684 saturation = _bounded_integer_attribute(
685 color_elem,
686 "sat",
687 minimum=0,
688 maximum=100000,
689 label="HSL color",
690 )
691 luminance = _bounded_integer_attribute(
692 color_elem,
693 "lum",
694 minimum=0,
695 maximum=100000,
696 label="HSL color",
697 )
698 return _hsl_to_hex(
699 hue / 21_600_000.0,
700 saturation / 100000.0,
701 luminance / 100000.0,
702 )
703
704
705 def _scrgb_color_hex(color_elem: ET.Element) -> str:
706 """Resolve one closed linear-light DrawingML scRGB base color."""
707 children = list(color_elem)
708 if (
709 color_elem.tag != f"{{{NS['a']}}}scrgbClr"
710 or set(color_elem.attrib) != {"r", "g", "b"}
711 or (color_elem.text or "").strip()
712 or any((child.tail or "").strip() for child in children)
713 ):
714 raise ValueError("Invalid DrawingML scRGB color structure")
715 channels = [
716 _bounded_integer_attribute(
717 color_elem,
718 attr,
719 minimum=0,
720 maximum=100000,
721 label="scRGB color",
722 )
723 / 100000.0
724 for attr in ("r", "g", "b")
725 ]
726 return _linear_rgb01_to_hex(*channels)
727
728
729 def _bounded_integer_attribute(
730 elem: ET.Element,
731 attr: str,
732 *,
733 minimum: int,
734 maximum: int,
735 label: str,
736 ) -> int:
737 """Parse one required bounded DrawingML integer attribute."""
738 raw = elem.attrib[attr]
739 token = raw.strip()
740 if _OOXML_INTEGER_RE.fullmatch(token) is None:
741 raise ValueError(f"Invalid DrawingML {label} {attr}: {raw!r}")
742 value = int(token)
743 if not minimum <= value <= maximum:
744 raise ValueError(f"Invalid DrawingML {label} {attr}: {raw!r}")
745 return value
746
747
748 def _parse_color_modifier(child: ET.Element) -> tuple[str, int | None]:
749 """Parse one modifier from the closed DrawingML color subset."""
750 tag = child.tag.split("}", 1)[-1]
751 if child.tag != f"{{{NS['a']}}}{tag}":
752 raise ValueError(f"invalid-color-modifier-namespace:{tag}")
753 if tag in _COLOR_MODIFIER_FLAG_NAMES:
754 if child.attrib or list(child) or (child.text or "").strip():
755 raise ValueError(f"invalid-color-modifier-structure:{tag}")
756 return tag, None
757
758 bounds = _COLOR_MODIFIER_PERCENT_RANGES.get(tag)
759 if tag == "hueOff":
760 bounds = (_OOXML_INT_MIN, _OOXML_INT_MAX)
761 elif bounds is None:
762 raise ValueError(f"unsupported-color-modifier:{tag}")
763 if (
764 set(child.attrib) != {"val"}
765 or list(child)
766 or (child.text or "").strip()
767 ):
768 raise ValueError(f"invalid-color-modifier-structure:{tag}")
769 try:
770 value = _bounded_integer_attribute(
771 child,
772 "val",
773 minimum=bounds[0],
774 maximum=bounds[1],
775 label=f"{tag} color modifier",
776 )
777 except ValueError:
778 raise ValueError(f"invalid-{tag}-val:{child.attrib['val']!r}") from None
779 return tag, value
780
781
782 def _apply_modifiers(hex_color: str, color_elem: ET.Element) -> tuple[str, float]:
783 """Apply the registered DrawingML color modifiers in document order."""
784 r, g, b = _hex_to_rgb01(hex_color)
785 # Convert to HSL for luminance/saturation ops; convert back when emitting.
786 h, lum, sat = colorsys.rgb_to_hls(r, g, b)
787 alpha = 1.0
788
789 for child in list(color_elem):
790 if not isinstance(child.tag, str):
791 continue
792 tag, value = _parse_color_modifier(child)
793
794 if tag == "tint":
795 # Tint blends toward white. r' = r + (1-r)*(1-val) is the common
796 # interpretation; OOXML actually defines tint on luminance.
797 # Use luminance-based tint per ECMA-376.
798 ratio = value / 100000.0
799 lum = lum * ratio + (1.0 - ratio)
800 elif tag == "shade":
801 # Shade blends toward black on luminance.
802 lum = lum * (value / 100000.0)
803 elif tag == "lumMod":
804 lum = lum * (value / 100000.0)
805 elif tag == "lumOff":
806 lum = lum + (value / 100000.0)
807 elif tag == "satMod":
808 sat = sat * (value / 100000.0)
809 elif tag == "satOff":
810 sat = sat + (value / 100000.0)
811 elif tag == "hueMod":
812 h = (h * (value / 100000.0)) % 1.0
813 elif tag == "hueOff":
814 h = (h + value / 21_600_000.0) % 1.0
815 elif tag == "alpha":
816 alpha = value / 100000.0
817 elif tag == "alphaMod":
818 alpha = max(0.0, min(1.0, alpha * (value / 100000.0)))
819 elif tag == "alphaOff":
820 alpha = max(0.0, min(1.0, alpha + (value / 100000.0)))
821 elif tag == "gray":
822 # Convert to grayscale based on luminance.
823 sat = 0.0
824 elif tag == "comp":
825 # Complement: hue rotated 180°
826 h = (h + 0.5) % 1.0
827 elif tag == "inv":
828 # RGB invert
829 rr, gg, bb = colorsys.hls_to_rgb(h, lum, sat)
830 rr, gg, bb = 1.0 - rr, 1.0 - gg, 1.0 - bb
831 h, lum, sat = colorsys.rgb_to_hls(rr, gg, bb)
832
833 # Clamp luminance / saturation to [0,1]
834 lum = max(0.0, min(1.0, lum))
835 sat = max(0.0, min(1.0, sat))
836
837 rr, gg, bb = colorsys.hls_to_rgb(h, lum, sat)
838 return _rgb01_to_hex(rr, gg, bb), alpha
839
840
841 # ---------------------------------------------------------------------------
842 # Hex / RGB / HSL helpers
843 # ---------------------------------------------------------------------------
844
845 def _normalize_hex(value: str | None) -> str | None:
846 """Strip '#' and validate 6-digit hex. Return uppercase or None on failure."""
847 if value is None:
848 return None
849 v = value.strip()
850 if v.startswith("#"):
851 v = v[1:]
852 if len(v) == 3:
853 v = "".join(c * 2 for c in v)
854 if len(v) != 6:
855 return None
856 try:
857 int(v, 16)
858 except ValueError:
859 return None
860 return v.upper()
861
862
863 def _hex_to_rgb01(hex_color: str) -> tuple[float, float, float]:
864 h = _normalize_hex(hex_color) or "000000"
865 r = int(h[0:2], 16) / 255.0
866 g = int(h[2:4], 16) / 255.0
867 b = int(h[4:6], 16) / 255.0
868 return r, g, b
869
870
871 def _rgb01_to_hex(r: float, g: float, b: float) -> str:
872 r = max(0.0, min(1.0, r))
873 g = max(0.0, min(1.0, g))
874 b = max(0.0, min(1.0, b))
875 return f"{int(round(r * 255)):02X}{int(round(g * 255)):02X}{int(round(b * 255)):02X}"
876
877
878 def _linear_rgb01_to_hex(r: float, g: float, b: float) -> str:
879 """Encode linear-light RGB channels as an SVG sRGB hex color."""
880 encoded = (
881 12.92 * channel
882 if channel <= 0.0031308
883 else 1.055 * channel ** (1.0 / 2.4) - 0.055
884 for channel in (r, g, b)
885 )
886 return _rgb01_to_hex(*encoded)
887
888
889 def _hsl_to_hex(h: float, s: float, l: float) -> str:
890 h = h % 1.0 if h != 1.0 else 1.0
891 s = max(0.0, min(1.0, s))
892 l = max(0.0, min(1.0, l))
893 r, g, b = colorsys.hls_to_rgb(h, l, s)
894 return _rgb01_to_hex(r, g, b)
895
895 lines PYTHON