microsoft/hve-core
Publicmirrored from https://github.com/microsoft/hve-coreAvailable
.github/skills/experimental/powerpoint/scripts/pptx_fills.py
358lines · modecode
| 1 | # Copyright (c) Microsoft Corporation. |
| 2 | # SPDX-License-Identifier: MIT |
| 3 | """Fill and line application/extraction utilities for PowerPoint skill scripts. |
| 4 | |
| 5 | Handles solid, gradient, and pattern fills plus line/border properties. |
| 6 | """ |
| 7 | |
| 8 | from lxml import etree |
| 9 | from pptx.enum.dml import MSO_FILL, MSO_LINE_DASH_STYLE, MSO_PATTERN_TYPE |
| 10 | from pptx.oxml.ns import qn |
| 11 | from pptx.util import Pt |
| 12 | from pptx_colors import ( |
| 13 | apply_color_spec, |
| 14 | apply_color_to_fill, |
| 15 | extract_color, |
| 16 | resolve_color, |
| 17 | rgb_to_hex, |
| 18 | ) |
| 19 | |
| 20 | DASH_STYLE_MAP = { |
| 21 | "solid": MSO_LINE_DASH_STYLE.SOLID, |
| 22 | "dash": MSO_LINE_DASH_STYLE.DASH, |
| 23 | "dash_dot": MSO_LINE_DASH_STYLE.DASH_DOT, |
| 24 | "dash_dot_dot": MSO_LINE_DASH_STYLE.DASH_DOT_DOT, |
| 25 | "long_dash": MSO_LINE_DASH_STYLE.LONG_DASH, |
| 26 | "long_dash_dot": MSO_LINE_DASH_STYLE.LONG_DASH_DOT, |
| 27 | "round_dot": MSO_LINE_DASH_STYLE.ROUND_DOT, |
| 28 | "square_dot": MSO_LINE_DASH_STYLE.SQUARE_DOT, |
| 29 | } |
| 30 | |
| 31 | DASH_STYLE_REVERSE = {v: k for k, v in DASH_STYLE_MAP.items()} |
| 32 | |
| 33 | |
| 34 | def apply_fill(shape, fill_spec, colors: dict): |
| 35 | """Apply fill specification to a shape or background. |
| 36 | |
| 37 | Supports: |
| 38 | str — solid fill via resolve_color() |
| 39 | dict with type: gradient — gradient fill with angle and stops |
| 40 | dict with type: pattern — pattern fill with fore/back colors |
| 41 | dict with type: solid — explicit solid fill |
| 42 | None — no fill (background) |
| 43 | """ |
| 44 | if fill_spec is None: |
| 45 | shape.fill.background() |
| 46 | return |
| 47 | |
| 48 | if isinstance(fill_spec, str): |
| 49 | shape.fill.solid() |
| 50 | color_spec = resolve_color(fill_spec, colors) |
| 51 | apply_color_to_fill(shape.fill, color_spec) |
| 52 | return |
| 53 | |
| 54 | if not isinstance(fill_spec, dict): |
| 55 | return |
| 56 | |
| 57 | fill_type = fill_spec.get("type", "solid") |
| 58 | |
| 59 | if fill_type == "solid": |
| 60 | _apply_solid_fill(shape, fill_spec, colors) |
| 61 | return |
| 62 | |
| 63 | if fill_type == "gradient": |
| 64 | _apply_gradient_fill(shape, fill_spec, colors) |
| 65 | return |
| 66 | |
| 67 | if fill_type == "pattern": |
| 68 | _apply_pattern_fill(shape, fill_spec, colors) |
| 69 | |
| 70 | |
| 71 | def _set_alpha_on_color_element(color_el, alpha_val: str): |
| 72 | """Set or update an alpha child element on a color XML element.""" |
| 73 | existing = color_el.find(qn("a:alpha")) |
| 74 | if existing is not None: |
| 75 | existing.set("val", alpha_val) |
| 76 | else: |
| 77 | etree.SubElement(color_el, qn("a:alpha")).set("val", alpha_val) |
| 78 | |
| 79 | |
| 80 | def _apply_solid_fill(shape, fill_spec: dict, colors: dict): |
| 81 | """Apply a solid fill with optional alpha.""" |
| 82 | shape.fill.solid() |
| 83 | color_spec = resolve_color(fill_spec.get("color", "#000000"), colors) |
| 84 | apply_color_to_fill(shape.fill, color_spec) |
| 85 | if "alpha" not in fill_spec: |
| 86 | return |
| 87 | alpha_val = str(int(fill_spec["alpha"] * 1000)) |
| 88 | solid_el = shape.fill._fill._solidFill |
| 89 | if solid_el is not None and len(solid_el) > 0: |
| 90 | _set_alpha_on_color_element(solid_el[0], alpha_val) |
| 91 | |
| 92 | |
| 93 | def _apply_gradient_fill(shape, fill_spec: dict, colors: dict): |
| 94 | """Apply a gradient fill with stops and optional per-stop alpha.""" |
| 95 | shape.fill.gradient() |
| 96 | shape.fill.gradient_angle = fill_spec.get("angle", 90) |
| 97 | stops_data = fill_spec.get("stops", []) |
| 98 | |
| 99 | existing_count = len(shape.fill.gradient_stops) |
| 100 | if len(stops_data) > existing_count: |
| 101 | gs_lst = shape.fill._fill._element.find(qn("a:gsLst")) |
| 102 | if gs_lst is not None: |
| 103 | for _ in range(len(stops_data) - existing_count): |
| 104 | new_gs = etree.SubElement(gs_lst, qn("a:gs")) |
| 105 | new_gs.set("pos", "0") |
| 106 | etree.SubElement(new_gs, qn("a:srgbClr")).set("val", "000000") |
| 107 | |
| 108 | for i, stop in enumerate(stops_data): |
| 109 | if i >= len(shape.fill.gradient_stops): |
| 110 | break |
| 111 | gs = shape.fill.gradient_stops[i] |
| 112 | color_spec = resolve_color(stop["color"], colors) |
| 113 | apply_color_spec(gs.color, color_spec) |
| 114 | gs.position = stop["position"] |
| 115 | if "alpha" in stop: |
| 116 | alpha_val = str(int(stop["alpha"] * 1000)) |
| 117 | gs_el = gs._element |
| 118 | color_el = gs_el[0] if len(gs_el) > 0 else None |
| 119 | if color_el is not None: |
| 120 | _set_alpha_on_color_element(color_el, alpha_val) |
| 121 | |
| 122 | |
| 123 | def _apply_pattern_fill(shape, fill_spec: dict, colors: dict): |
| 124 | """Apply a pattern fill with fore/back colors and optional alpha.""" |
| 125 | shape.fill.patterned() |
| 126 | pattern_name = fill_spec.get("pattern", "CROSS").upper() |
| 127 | shape.fill.pattern = getattr(MSO_PATTERN_TYPE, pattern_name, MSO_PATTERN_TYPE.CROSS) |
| 128 | fore_spec = resolve_color(fill_spec.get("fore_color", "#000000"), colors) |
| 129 | back_spec = resolve_color(fill_spec.get("back_color", "#FFFFFF"), colors) |
| 130 | apply_color_spec(shape.fill.fore_color, fore_spec) |
| 131 | apply_color_spec(shape.fill.back_color, back_spec) |
| 132 | |
| 133 | patt_el = shape.fill._fill._pattFill |
| 134 | if patt_el is None: |
| 135 | return |
| 136 | if "fore_alpha" in fill_spec: |
| 137 | fg = patt_el.find(qn("a:fgClr")) |
| 138 | if fg is not None and len(fg) > 0: |
| 139 | _set_alpha_on_color_element(fg[0], str(int(fill_spec["fore_alpha"] * 1000))) |
| 140 | if "back_alpha" in fill_spec: |
| 141 | bg = patt_el.find(qn("a:bgClr")) |
| 142 | if bg is not None and len(bg) > 0: |
| 143 | _set_alpha_on_color_element(bg[0], str(int(fill_spec["back_alpha"] * 1000))) |
| 144 | |
| 145 | |
| 146 | def extract_fill(fill) -> dict | str | None: |
| 147 | """Extract fill information from a shape's fill object. |
| 148 | |
| 149 | Returns: |
| 150 | str — hex color string for solid fills |
| 151 | dict — structured fill spec for gradient or pattern fills |
| 152 | None — no fill or background fill |
| 153 | """ |
| 154 | try: |
| 155 | fill_type = fill.type |
| 156 | if fill_type is None or fill_type == MSO_FILL.BACKGROUND: |
| 157 | return None |
| 158 | |
| 159 | if fill_type == MSO_FILL.SOLID: |
| 160 | color = extract_color(fill.fore_color) or rgb_to_hex(fill.fore_color.rgb) |
| 161 | # Check for alpha on the color element at XML level |
| 162 | try: |
| 163 | solid_el = fill._fill._solidFill |
| 164 | if solid_el is not None and len(solid_el) > 0: |
| 165 | alpha_el = solid_el[0].find(qn("a:alpha")) |
| 166 | if alpha_el is not None: |
| 167 | alpha_val = int(alpha_el.get("val", "100000")) |
| 168 | return { |
| 169 | "type": "solid", |
| 170 | "color": color, |
| 171 | "alpha": round(alpha_val / 1000, 1), |
| 172 | } |
| 173 | except (AttributeError, TypeError): |
| 174 | pass |
| 175 | return color |
| 176 | |
| 177 | if fill_type == MSO_FILL.GRADIENT: |
| 178 | stops = [] |
| 179 | for gs in fill.gradient_stops: |
| 180 | color = extract_color(gs.color) |
| 181 | if color is not None: |
| 182 | stop_data = { |
| 183 | "position": gs.position, |
| 184 | "color": color, |
| 185 | } |
| 186 | # Extract alpha from the gradient stop's color element |
| 187 | alpha_el = gs._element.find(".//" + qn("a:alpha")) |
| 188 | if alpha_el is not None: |
| 189 | alpha_val = int(alpha_el.get("val", "100000")) |
| 190 | stop_data["alpha"] = round(alpha_val / 1000, 1) |
| 191 | stops.append(stop_data) |
| 192 | result = {"type": "gradient", "stops": stops} |
| 193 | try: |
| 194 | result["angle"] = fill.gradient_angle |
| 195 | except ValueError: |
| 196 | pass |
| 197 | return result |
| 198 | |
| 199 | if fill_type == MSO_FILL.PATTERNED: |
| 200 | pattern_val = fill.pattern |
| 201 | pattern_name = "cross" |
| 202 | for attr in dir(MSO_PATTERN_TYPE): |
| 203 | if attr.startswith("_"): |
| 204 | continue |
| 205 | try: |
| 206 | if getattr(MSO_PATTERN_TYPE, attr) == pattern_val: |
| 207 | pattern_name = attr.lower() |
| 208 | break |
| 209 | except (AttributeError, TypeError): |
| 210 | pass |
| 211 | result = { |
| 212 | "type": "pattern", |
| 213 | "pattern": pattern_name, |
| 214 | "fore_color": extract_color(fill.fore_color) |
| 215 | or rgb_to_hex(fill.fore_color.rgb), |
| 216 | "back_color": extract_color(fill.back_color) |
| 217 | or rgb_to_hex(fill.back_color.rgb), |
| 218 | } |
| 219 | # Extract alpha from pattern fore/back color elements |
| 220 | try: |
| 221 | patt_el = fill._fill._pattFill |
| 222 | if patt_el is not None: |
| 223 | fg = patt_el.find(qn("a:fgClr")) |
| 224 | if fg is not None and len(fg) > 0: |
| 225 | alpha_el = fg[0].find(qn("a:alpha")) |
| 226 | if alpha_el is not None: |
| 227 | result["fore_alpha"] = round( |
| 228 | int(alpha_el.get("val", "100000")) / 1000, 1 |
| 229 | ) |
| 230 | bg = patt_el.find(qn("a:bgClr")) |
| 231 | if bg is not None and len(bg) > 0: |
| 232 | alpha_el = bg[0].find(qn("a:alpha")) |
| 233 | if alpha_el is not None: |
| 234 | result["back_alpha"] = round( |
| 235 | int(alpha_el.get("val", "100000")) / 1000, 1 |
| 236 | ) |
| 237 | except (AttributeError, TypeError): |
| 238 | pass |
| 239 | return result |
| 240 | except (AttributeError, TypeError): |
| 241 | pass |
| 242 | |
| 243 | return None |
| 244 | |
| 245 | |
| 246 | def apply_line(shape, elem: dict, colors: dict): |
| 247 | """Apply line/border properties from element definition. |
| 248 | |
| 249 | Reads line_color, line_width, and dash_style from elem dict. |
| 250 | """ |
| 251 | if "line_color" in elem: |
| 252 | color_spec = resolve_color(elem["line_color"], colors) |
| 253 | apply_color_spec(shape.line.color, color_spec) |
| 254 | shape.line.width = Pt(elem.get("line_width", 1)) |
| 255 | if "dash_style" in elem: |
| 256 | shape.line.dash_style = DASH_STYLE_MAP.get( |
| 257 | elem["dash_style"], MSO_LINE_DASH_STYLE.SOLID |
| 258 | ) |
| 259 | else: |
| 260 | shape.line.fill.background() |
| 261 | |
| 262 | |
| 263 | def extract_line(shape) -> dict: |
| 264 | """Extract line/border properties from a shape.""" |
| 265 | result = {} |
| 266 | try: |
| 267 | line = shape.line |
| 268 | if line.color and line.color.type is not None: |
| 269 | result["line_color"] = extract_color(line.color) or rgb_to_hex( |
| 270 | line.color.rgb |
| 271 | ) |
| 272 | if line.width: |
| 273 | result["line_width"] = round(line.width.pt, 1) |
| 274 | if line.dash_style and line.dash_style != MSO_LINE_DASH_STYLE.SOLID: |
| 275 | result["dash_style"] = DASH_STYLE_REVERSE.get(line.dash_style, "solid") |
| 276 | except (AttributeError, TypeError): |
| 277 | pass |
| 278 | return result |
| 279 | |
| 280 | |
| 281 | def extract_effect_list(shape) -> dict | None: |
| 282 | """Extract outer shadow effect from a shape's effectLst. |
| 283 | |
| 284 | Returns dict with shadow properties when present, None otherwise. |
| 285 | """ |
| 286 | try: |
| 287 | sp = shape._element |
| 288 | effect_lst = sp.find(".//" + qn("a:effectLst")) |
| 289 | if effect_lst is None or len(effect_lst) == 0: |
| 290 | return None |
| 291 | shadow = effect_lst.find(qn("a:outerShdw")) |
| 292 | if shadow is None: |
| 293 | return None |
| 294 | return parse_shadow_xml(shadow) |
| 295 | except (AttributeError, TypeError, IndexError): |
| 296 | return None |
| 297 | |
| 298 | |
| 299 | def apply_effect_list(shape, effect: dict): |
| 300 | """Apply outer shadow effect to a shape's spPr element.""" |
| 301 | if not effect or effect.get("type") != "outer_shadow": |
| 302 | return |
| 303 | sp_pr = shape._element.find(qn("p:spPr")) |
| 304 | if sp_pr is None: |
| 305 | sp_pr = shape._element.spPr |
| 306 | |
| 307 | existing = sp_pr.find(qn("a:effectLst")) |
| 308 | if existing is not None: |
| 309 | sp_pr.remove(existing) |
| 310 | |
| 311 | effect_lst = etree.SubElement(sp_pr, qn("a:effectLst")) |
| 312 | build_shadow_xml(effect_lst, effect) |
| 313 | |
| 314 | |
| 315 | def parse_shadow_xml(shadow) -> dict: |
| 316 | """Parse an outerShdw XML element into a shadow effect dict.""" |
| 317 | result = {"type": "outer_shadow"} |
| 318 | for attr in ("blurRad", "dist", "dir", "algn", "rotWithShape"): |
| 319 | val = shadow.get(attr) |
| 320 | if val is not None: |
| 321 | result[attr] = val |
| 322 | color_el = shadow[0] if len(shadow) > 0 else None |
| 323 | if color_el is not None: |
| 324 | tag = color_el.tag.split("}")[-1] |
| 325 | if tag == "prstClr": |
| 326 | result["color"] = color_el.get("val", "black") |
| 327 | result["color_type"] = "preset" |
| 328 | elif tag == "srgbClr": |
| 329 | result["color"] = "#" + color_el.get("val", "000000") |
| 330 | result["color_type"] = "rgb" |
| 331 | alpha_el = color_el.find(qn("a:alpha")) |
| 332 | if alpha_el is not None: |
| 333 | result["alpha"] = round(int(alpha_el.get("val", "100000")) / 1000, 1) |
| 334 | return result |
| 335 | |
| 336 | |
| 337 | def build_shadow_xml(parent, effect: dict): |
| 338 | """Build an outerShdw XML element under the given parent element.""" |
| 339 | shadow = etree.SubElement(parent, qn("a:outerShdw")) |
| 340 | for attr in ("blurRad", "dist", "dir", "algn", "rotWithShape"): |
| 341 | if attr in effect: |
| 342 | shadow.set(attr, str(effect[attr])) |
| 343 | |
| 344 | color_type = effect.get("color_type", "preset") |
| 345 | color_val = effect.get("color", "black") |
| 346 | if color_type == "preset": |
| 347 | color_el = etree.SubElement(shadow, qn("a:prstClr")) |
| 348 | color_el.set("val", color_val) |
| 349 | elif color_type == "rgb": |
| 350 | color_el = etree.SubElement(shadow, qn("a:srgbClr")) |
| 351 | color_el.set("val", color_val.lstrip("#")) |
| 352 | else: |
| 353 | color_el = etree.SubElement(shadow, qn("a:prstClr")) |
| 354 | color_el.set("val", "black") |
| 355 | |
| 356 | if "alpha" in effect: |
| 357 | alpha_sub = etree.SubElement(color_el, qn("a:alpha")) |
| 358 | alpha_sub.set("val", str(int(effect["alpha"] * 1000))) |
| 359 | |