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