| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | PPT Master - Authored Preset Shape Contract |
| 4 | |
| 5 | Build and validate compact canonical SVG groups for newly authored PowerPoint |
| 6 | preset shapes while retaining expanded authored input compatibility. |
| 7 | |
| 8 | Usage: |
| 9 | Import render_preset_shape_fragment or validate_authored_preset_group. |
| 10 | |
| 11 | Examples: |
| 12 | fragment = render_preset_shape_fragment( |
| 13 | "rightArrow", |
| 14 | (80, 120, 240, 96), |
| 15 | element_id="next-step", |
| 16 | style={"fill": "#2563EB", "stroke": "none"}, |
| 17 | ) |
| 18 | |
| 19 | Dependencies: |
| 20 | None (only uses standard library and local PPT Master modules) |
| 21 | """ |
| 22 | |
| 23 | from __future__ import annotations |
| 24 | |
| 25 | import math |
| 26 | import re |
| 27 | from typing import Mapping |
| 28 | from xml.etree import ElementTree as ET |
| 29 | |
| 30 | from pptx_shapes import ( |
| 31 | CONNECTOR_PRESET_TYPES, |
| 32 | OOXML_COORDINATE_MAX, |
| 33 | OOXML_COORDINATE_MIN, |
| 34 | SUPPORTED_OPERATORS, |
| 35 | get_preset_registry, |
| 36 | resolve_preset_preview_hash, |
| 37 | svg_preset_preview_fingerprint, |
| 38 | validate_ooxml_line_width, |
| 39 | validate_ooxml_xfrm, |
| 40 | ) |
| 41 | |
| 42 | from .emu_units import EMU_PER_PX, Xfrm, fmt_num |
| 43 | from .preset_registry_to_svg import render_preset_geometry |
| 44 | from .preset_svg_markup import ( |
| 45 | attrs_to_xml, |
| 46 | serialize_compact_preset_layers, |
| 47 | serialize_preset_layers, |
| 48 | ) |
| 49 | |
| 50 | |
| 51 | AUTHORING_ATTR = "data-pptx-authoring" |
| 52 | AUTHORING_VALUE = "preset" |
| 53 | _SVG_NAMESPACE = "http://www.w3.org/2000/svg" |
| 54 | _ID_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_.:-]*") |
| 55 | _PAINT_RE = re.compile(r"(?:none|#[0-9A-Fa-f]{6})") |
| 56 | _INTEGER_RE = re.compile(r"[+-]?\d+") |
| 57 | _ADJUSTMENT_PREFIX = "data-pptx-av-" |
| 58 | _STYLE_ATTRS = ( |
| 59 | "fill", |
| 60 | "fill-opacity", |
| 61 | "stroke", |
| 62 | "stroke-linecap", |
| 63 | "stroke-linejoin", |
| 64 | "stroke-opacity", |
| 65 | "stroke-width", |
| 66 | ) |
| 67 | _SEMANTIC_ATTRS = ( |
| 68 | AUTHORING_ATTR, |
| 69 | "data-pptx-object", |
| 70 | "data-pptx-prst", |
| 71 | "data-pptx-frame", |
| 72 | ) |
| 73 | _TEMPLATE_ATOM_ATTRS = ( |
| 74 | "data-pptx-layer", |
| 75 | "data-pptx-editable", |
| 76 | "data-pptx-carrier", |
| 77 | "data-pptx-role", |
| 78 | ) |
| 79 | |
| 80 | |
| 81 | def render_preset_shape_fragment( |
| 82 | preset: str, |
| 83 | frame: tuple[float, float, float, float], |
| 84 | *, |
| 85 | adjustments: Mapping[str, str | int | float] | None = None, |
| 86 | object_kind: str = "shape", |
| 87 | element_id: str, |
| 88 | name: str | None = None, |
| 89 | style: Mapping[str, str] | None = None, |
| 90 | ) -> str: |
| 91 | """Render one compact authored preset fragment for SVG insertion.""" |
| 92 | registry = get_preset_registry() |
| 93 | if preset not in registry: |
| 94 | raise ValueError(f"Unknown DrawingML preset shape: {preset!r}") |
| 95 | if _ID_RE.fullmatch(element_id) is None: |
| 96 | raise ValueError(f"Invalid SVG element id: {element_id!r}") |
| 97 | if object_kind not in {"shape", "connector"}: |
| 98 | raise ValueError("object_kind must be 'shape' or 'connector'") |
| 99 | if preset in CONNECTOR_PRESET_TYPES and object_kind != "connector": |
| 100 | raise ValueError( |
| 101 | f"Connector preset {preset!r} requires object_kind='connector'" |
| 102 | ) |
| 103 | if object_kind == "connector" and preset not in CONNECTOR_PRESET_TYPES: |
| 104 | raise ValueError( |
| 105 | f"Authored connector requires a connector preset, got {preset!r}" |
| 106 | ) |
| 107 | |
| 108 | x, y, width, height = _validate_frame(frame, object_kind) |
| 109 | adjustment_values = _normalize_adjustments(adjustments or {}) |
| 110 | _validate_adjustments(preset, adjustment_values) |
| 111 | registry.evaluate( |
| 112 | preset, |
| 113 | width, |
| 114 | height, |
| 115 | adjustments=adjustment_values, |
| 116 | ) |
| 117 | rendered = render_preset_geometry( |
| 118 | preset, |
| 119 | Xfrm(x=x, y=y, w=width, h=height), |
| 120 | adjustment_values, |
| 121 | ) |
| 122 | if not rendered.paths: |
| 123 | raise ValueError(f"Preset {preset!r} produced no visible SVG paths") |
| 124 | |
| 125 | frame_text = " ".join( |
| 126 | fmt_num(value, 8) for value in (x, y, width, height) |
| 127 | ) |
| 128 | semantic_attrs = { |
| 129 | AUTHORING_ATTR: AUTHORING_VALUE, |
| 130 | "data-pptx-object": object_kind, |
| 131 | "data-pptx-prst": preset, |
| 132 | "data-pptx-frame": frame_text, |
| 133 | } |
| 134 | if name: |
| 135 | semantic_attrs["data-pptx-shape-name"] = name |
| 136 | for guide_name, formula in adjustment_values.items(): |
| 137 | semantic_attrs[f"{_ADJUSTMENT_PREFIX}{guide_name}"] = str(formula) |
| 138 | |
| 139 | raw_style = dict(style or {}) |
| 140 | if "fill" not in raw_style or "stroke" not in raw_style: |
| 141 | raise ValueError( |
| 142 | "Compact authored preset requires explicit local fill and stroke" |
| 143 | ) |
| 144 | style_attrs = _validate_style(raw_style) |
| 145 | if ( |
| 146 | style_attrs.get("stroke", "none") != "none" |
| 147 | and "stroke-width" not in style_attrs |
| 148 | ): |
| 149 | style_attrs["stroke-width"] = "1" |
| 150 | if object_kind == "connector": |
| 151 | if style_attrs.get("fill", "none") != "none": |
| 152 | raise ValueError("Authored connector fill must be none") |
| 153 | if not _has_visible_stroke(style_attrs): |
| 154 | raise ValueError("Authored connector requires a visible stroke") |
| 155 | group_attrs = { |
| 156 | "id": element_id, |
| 157 | **semantic_attrs, |
| 158 | **style_attrs, |
| 159 | } |
| 160 | return ( |
| 161 | f'<g{attrs_to_xml(group_attrs)}>\n' |
| 162 | f"{serialize_compact_preset_layers(rendered.paths, style_attrs)}\n" |
| 163 | "</g>" |
| 164 | ) |
| 165 | |
| 166 | |
| 167 | def authored_preset_encoding(group: ET.Element) -> str | None: |
| 168 | """Return ``compact`` / ``expanded`` for an authored preset group.""" |
| 169 | if ( |
| 170 | _local_name(group.tag) != "g" |
| 171 | or group.get(AUTHORING_ATTR) != AUTHORING_VALUE |
| 172 | ): |
| 173 | return None |
| 174 | parts = { |
| 175 | child.get("data-pptx-part") |
| 176 | for child in group |
| 177 | if child.get("data-pptx-part") is not None |
| 178 | } |
| 179 | if parts: |
| 180 | return "expanded" |
| 181 | return "compact" |
| 182 | |
| 183 | |
| 184 | def validate_authored_preset_group(group: ET.Element) -> list[str]: |
| 185 | """Return authored-preset contract errors for one logical group.""" |
| 186 | encoding = authored_preset_encoding(group) |
| 187 | if encoding is None: |
| 188 | return [] |
| 189 | if encoding == "compact": |
| 190 | return _validate_compact_authored_preset_group(group) |
| 191 | return _validate_expanded_authored_preset_group(group) |
| 192 | |
| 193 | |
| 194 | def _validate_compact_authored_preset_group(group: ET.Element) -> list[str]: |
| 195 | """Validate the project-canonical compact authored-preset form.""" |
| 196 | errors: list[str] = [] |
| 197 | if not _is_svg_element(group, "g"): |
| 198 | return [f'{AUTHORING_ATTR}="{AUTHORING_VALUE}" requires an SVG <g>'] |
| 199 | element_id = group.get("id") |
| 200 | if element_id is None: |
| 201 | errors.append("Authored preset logical group requires a stable id") |
| 202 | elif _ID_RE.fullmatch(element_id) is None: |
| 203 | errors.append(f"Authored preset logical group has invalid id {element_id!r}") |
| 204 | |
| 205 | unexpected_group_attrs = sorted( |
| 206 | name for name in group.attrib |
| 207 | if _is_unexpected_group_attr(name, compact=True) |
| 208 | ) |
| 209 | if unexpected_group_attrs: |
| 210 | errors.append( |
| 211 | "Authored preset logical group has unsupported attributes: " |
| 212 | + ", ".join(unexpected_group_attrs) |
| 213 | ) |
| 214 | if group.get("data-pptx-preview-sha256") is not None: |
| 215 | errors.append( |
| 216 | "Compact authored preset derives preview integrity from the registry; " |
| 217 | "remove data-pptx-preview-sha256" |
| 218 | ) |
| 219 | if (group.text or "").strip(): |
| 220 | errors.append("Compact authored preset cannot contain text content") |
| 221 | |
| 222 | direct_children = list(group) |
| 223 | if not direct_children: |
| 224 | errors.append("Compact authored preset requires visible direct path layers") |
| 225 | return errors |
| 226 | if any(child.get("data-pptx-part") is not None for child in direct_children): |
| 227 | errors.append( |
| 228 | "Compact authored preset cannot mix transport carrier/preview markers" |
| 229 | ) |
| 230 | return errors |
| 231 | if any( |
| 232 | not _is_svg_element(child, "path") |
| 233 | for child in direct_children |
| 234 | ): |
| 235 | errors.append( |
| 236 | "Compact authored preset is atomic and may contain only direct SVG paths" |
| 237 | ) |
| 238 | return errors |
| 239 | if any(list(child) for child in direct_children): |
| 240 | errors.append("Compact authored preset paths cannot contain child elements") |
| 241 | if any( |
| 242 | (child.text or "").strip() or (child.tail or "").strip() |
| 243 | for child in direct_children |
| 244 | ): |
| 245 | errors.append( |
| 246 | "Compact authored preset paths may contain only whitespace around markup" |
| 247 | ) |
| 248 | |
| 249 | preset = group.get("data-pptx-prst") or "" |
| 250 | object_kind = group.get("data-pptx-object") or "" |
| 251 | if object_kind not in {"shape", "connector"}: |
| 252 | errors.append( |
| 253 | "Authored preset data-pptx-object must be 'shape' or 'connector'" |
| 254 | ) |
| 255 | if preset in CONNECTOR_PRESET_TYPES and object_kind != "connector": |
| 256 | errors.append( |
| 257 | f"Connector preset {preset!r} requires data-pptx-object='connector'" |
| 258 | ) |
| 259 | if object_kind == "connector" and preset not in CONNECTOR_PRESET_TYPES: |
| 260 | errors.append( |
| 261 | f"Authored connector requires a connector preset, got {preset!r}" |
| 262 | ) |
| 263 | |
| 264 | try: |
| 265 | frame = _parse_frame(group.get("data-pptx-frame"), object_kind) |
| 266 | canonical_frame = " ".join(fmt_num(value, 8) for value in frame) |
| 267 | if group.get("data-pptx-frame") != canonical_frame: |
| 268 | raise ValueError( |
| 269 | "Compact authored preset data-pptx-frame must use the helper's " |
| 270 | f"canonical spelling {canonical_frame!r}" |
| 271 | ) |
| 272 | adjustments = { |
| 273 | name[len(_ADJUSTMENT_PREFIX):]: value |
| 274 | for name, value in group.attrib.items() |
| 275 | if name.startswith(_ADJUSTMENT_PREFIX) |
| 276 | } |
| 277 | _validate_adjustments(preset, adjustments) |
| 278 | rendered = render_preset_geometry( |
| 279 | preset, |
| 280 | Xfrm(x=frame[0], y=frame[1], w=frame[2], h=frame[3]), |
| 281 | adjustments, |
| 282 | ) |
| 283 | raw_style = { |
| 284 | name: group.attrib[name] |
| 285 | for name in _STYLE_ATTRS |
| 286 | if name in group.attrib |
| 287 | } |
| 288 | if "fill" not in raw_style or "stroke" not in raw_style: |
| 289 | raise ValueError( |
| 290 | "Compact authored preset requires explicit local fill and stroke" |
| 291 | ) |
| 292 | style_attrs = _validate_style(raw_style) |
| 293 | noncanonical_style = sorted( |
| 294 | name for name, value in style_attrs.items() |
| 295 | if raw_style.get(name) != value |
| 296 | ) |
| 297 | if noncanonical_style: |
| 298 | raise ValueError( |
| 299 | "Compact authored preset style uses non-canonical values: " |
| 300 | + ", ".join(noncanonical_style) |
| 301 | ) |
| 302 | if style_attrs.get("stroke", "none") != "none" and ( |
| 303 | "stroke-width" not in style_attrs |
| 304 | ): |
| 305 | raise ValueError( |
| 306 | "Compact authored preset with a visible stroke requires stroke-width" |
| 307 | ) |
| 308 | if object_kind == "connector": |
| 309 | if style_attrs.get("fill", "none") != "none": |
| 310 | raise ValueError("Authored connector fill must be none") |
| 311 | if not _has_visible_stroke(style_attrs): |
| 312 | raise ValueError("Authored connector requires a visible stroke") |
| 313 | expected_markup = serialize_compact_preset_layers( |
| 314 | rendered.paths, |
| 315 | style_attrs, |
| 316 | ) |
| 317 | expected_root = ET.fromstring( |
| 318 | f'<g xmlns="http://www.w3.org/2000/svg">{expected_markup}</g>' |
| 319 | ) |
| 320 | except (ET.ParseError, ValueError) as exc: |
| 321 | errors.append(f"Cannot regenerate compact authored preset: {exc}") |
| 322 | return errors |
| 323 | |
| 324 | expected_children = list(expected_root) |
| 325 | if len(direct_children) != len(expected_children): |
| 326 | errors.append( |
| 327 | "Compact authored preset path count differs from registry output: " |
| 328 | f"expected {len(expected_children)}, found {len(direct_children)}" |
| 329 | ) |
| 330 | return errors |
| 331 | for index, (actual, expected) in enumerate( |
| 332 | zip(direct_children, expected_children), |
| 333 | start=1, |
| 334 | ): |
| 335 | if actual.attrib != expected.attrib: |
| 336 | errors.append( |
| 337 | f"Compact authored preset path {index} differs from registry output" |
| 338 | ) |
| 339 | return errors |
| 340 | |
| 341 | |
| 342 | def _validate_expanded_authored_preset_group(group: ET.Element) -> list[str]: |
| 343 | """Validate the legacy expanded authored-preset compatibility form.""" |
| 344 | if group.get(AUTHORING_ATTR) != AUTHORING_VALUE: |
| 345 | return [] |
| 346 | errors: list[str] = [] |
| 347 | if not _is_svg_element(group, "g"): |
| 348 | return [f'{AUTHORING_ATTR}="{AUTHORING_VALUE}" requires an SVG <g>'] |
| 349 | element_id = group.get("id") |
| 350 | if element_id is None: |
| 351 | errors.append("Authored preset logical group requires a stable id") |
| 352 | elif _ID_RE.fullmatch(element_id) is None: |
| 353 | errors.append(f"Authored preset logical group has invalid id {element_id!r}") |
| 354 | |
| 355 | unexpected_group_attrs = sorted( |
| 356 | name for name in group.attrib |
| 357 | if _is_unexpected_group_attr(name, compact=False) |
| 358 | ) |
| 359 | if unexpected_group_attrs: |
| 360 | errors.append( |
| 361 | "Authored preset logical group has unsupported attributes: " |
| 362 | + ", ".join(unexpected_group_attrs) |
| 363 | ) |
| 364 | |
| 365 | direct_children = list(group) |
| 366 | carriers = [ |
| 367 | child |
| 368 | for child in direct_children |
| 369 | if child.get("data-pptx-part") == "geometry" |
| 370 | ] |
| 371 | previews = [ |
| 372 | child |
| 373 | for child in direct_children |
| 374 | if child.get("data-pptx-part") == "geometry-preview" |
| 375 | ] |
| 376 | if len(carriers) != 1: |
| 377 | errors.append( |
| 378 | f"Authored preset requires exactly one direct geometry carrier; " |
| 379 | f"found {len(carriers)}" |
| 380 | ) |
| 381 | if len(previews) != 1: |
| 382 | errors.append( |
| 383 | f"Authored preset requires exactly one direct geometry preview; " |
| 384 | f"found {len(previews)}" |
| 385 | ) |
| 386 | allowed_children = set(carriers + previews) |
| 387 | foreign_children = [ |
| 388 | child for child in direct_children |
| 389 | if child not in allowed_children |
| 390 | ] |
| 391 | if foreign_children: |
| 392 | errors.append( |
| 393 | "Authored preset groups are atomic; place labels or decorations " |
| 394 | "in a parent group" |
| 395 | ) |
| 396 | if len(carriers) != 1 or len(previews) != 1: |
| 397 | return errors |
| 398 | |
| 399 | carrier = carriers[0] |
| 400 | preview = previews[0] |
| 401 | if not _is_svg_element(carrier, "path"): |
| 402 | errors.append("Authored preset geometry carrier must be an SVG <path>") |
| 403 | if not _is_svg_element(preview, "g"): |
| 404 | errors.append("Authored preset geometry preview must be an SVG <g>") |
| 405 | if carrier.get("visibility") != "hidden": |
| 406 | errors.append('Authored preset carrier requires visibility="hidden"') |
| 407 | if carrier.get("pointer-events") != "none": |
| 408 | errors.append('Authored preset carrier requires pointer-events="none"') |
| 409 | |
| 410 | for attr_name in _SEMANTIC_ATTRS: |
| 411 | if group.get(attr_name) != carrier.get(attr_name): |
| 412 | errors.append( |
| 413 | f"Authored preset group/carrier {attr_name} values differ" |
| 414 | ) |
| 415 | adjustment_names = { |
| 416 | name |
| 417 | for element in (group, carrier) |
| 418 | for name in element.attrib |
| 419 | if name.startswith(_ADJUSTMENT_PREFIX) |
| 420 | } |
| 421 | for attr_name in sorted(adjustment_names): |
| 422 | if group.get(attr_name) != carrier.get(attr_name): |
| 423 | errors.append( |
| 424 | f"Authored preset group/carrier {attr_name} values differ" |
| 425 | ) |
| 426 | |
| 427 | unexpected_carrier_attrs = [ |
| 428 | name |
| 429 | for name in carrier.attrib |
| 430 | if _is_unexpected_carrier_attr(name) |
| 431 | ] |
| 432 | if unexpected_carrier_attrs: |
| 433 | errors.append( |
| 434 | "Authored preset carrier has unsupported presentation attributes: " |
| 435 | + ", ".join(sorted(unexpected_carrier_attrs)) |
| 436 | ) |
| 437 | |
| 438 | preset = carrier.get("data-pptx-prst") or "" |
| 439 | object_kind = carrier.get("data-pptx-object") or "" |
| 440 | if object_kind not in {"shape", "connector"}: |
| 441 | errors.append( |
| 442 | "Authored preset data-pptx-object must be 'shape' or 'connector'" |
| 443 | ) |
| 444 | if preset in CONNECTOR_PRESET_TYPES and object_kind != "connector": |
| 445 | errors.append( |
| 446 | f"Connector preset {preset!r} requires data-pptx-object='connector'" |
| 447 | ) |
| 448 | if object_kind == "connector" and preset not in CONNECTOR_PRESET_TYPES: |
| 449 | errors.append( |
| 450 | f"Authored connector requires a connector preset, got {preset!r}" |
| 451 | ) |
| 452 | try: |
| 453 | frame = _parse_frame(carrier.get("data-pptx-frame"), object_kind) |
| 454 | adjustments = { |
| 455 | name[len(_ADJUSTMENT_PREFIX):]: value |
| 456 | for name, value in carrier.attrib.items() |
| 457 | if name.startswith(_ADJUSTMENT_PREFIX) |
| 458 | } |
| 459 | _validate_adjustments(preset, adjustments) |
| 460 | rendered = render_preset_geometry( |
| 461 | preset, |
| 462 | Xfrm(x=frame[0], y=frame[1], w=frame[2], h=frame[3]), |
| 463 | adjustments, |
| 464 | ) |
| 465 | style_attrs = _validate_style({ |
| 466 | name: carrier.attrib[name] |
| 467 | for name in _STYLE_ATTRS |
| 468 | if name in carrier.attrib |
| 469 | }) |
| 470 | if object_kind == "connector": |
| 471 | if style_attrs.get("fill", "none") != "none": |
| 472 | raise ValueError("Authored connector fill must be none") |
| 473 | if not _has_visible_stroke(style_attrs): |
| 474 | raise ValueError("Authored connector requires a visible stroke") |
| 475 | expected = serialize_preset_layers( |
| 476 | rendered.paths, |
| 477 | { |
| 478 | name: value |
| 479 | for name, value in carrier.attrib.items() |
| 480 | if name in _SEMANTIC_ATTRS |
| 481 | or name.startswith(_ADJUSTMENT_PREFIX) |
| 482 | or name == "data-pptx-shape-name" |
| 483 | }, |
| 484 | style_attrs, |
| 485 | ) |
| 486 | except ValueError as exc: |
| 487 | errors.append(f"Cannot regenerate authored preset preview: {exc}") |
| 488 | return errors |
| 489 | |
| 490 | if (carrier.get("d") or "").strip() != _carrier_path(rendered.paths): |
| 491 | errors.append("Authored preset carrier path differs from registry output") |
| 492 | actual_preview_hash = svg_preset_preview_fingerprint(group) |
| 493 | if actual_preview_hash != expected.preview_hash: |
| 494 | errors.append("Authored preset visible preview differs from registry output") |
| 495 | try: |
| 496 | stored_hash = resolve_preset_preview_hash(group) |
| 497 | except ValueError as exc: |
| 498 | errors.append(f"Invalid authored preset preview fingerprint: {exc}") |
| 499 | else: |
| 500 | if stored_hash != expected.preview_hash: |
| 501 | errors.append( |
| 502 | "Authored preset fingerprint does not match regenerated metadata" |
| 503 | ) |
| 504 | return errors |
| 505 | |
| 506 | |
| 507 | def validate_authored_preset_tree(root: ET.Element) -> list[str]: |
| 508 | """Return structural errors for every authored preset marker in one SVG.""" |
| 509 | errors: list[str] = [] |
| 510 | id_counts: dict[str, int] = {} |
| 511 | for element in root.iter(): |
| 512 | element_id = element.get("id") |
| 513 | if element_id: |
| 514 | id_counts[element_id] = id_counts.get(element_id, 0) + 1 |
| 515 | parents = { |
| 516 | child: parent |
| 517 | for parent in root.iter() |
| 518 | for child in parent |
| 519 | } |
| 520 | for element in root.iter(): |
| 521 | authoring = element.get(AUTHORING_ATTR) |
| 522 | if authoring is None: |
| 523 | continue |
| 524 | tag = _local_name(element.tag) |
| 525 | label = _element_label(element) |
| 526 | if authoring != AUTHORING_VALUE: |
| 527 | errors.append( |
| 528 | f"{label}: unsupported {AUTHORING_ATTR} value {authoring!r}" |
| 529 | ) |
| 530 | continue |
| 531 | if tag == "g": |
| 532 | errors.extend( |
| 533 | f"{label}: {error}" |
| 534 | for error in validate_authored_preset_group(element) |
| 535 | ) |
| 536 | element_id = element.get("id") |
| 537 | if element_id and id_counts.get(element_id, 0) > 1: |
| 538 | errors.append( |
| 539 | f"{label}: authored preset logical group id must be " |
| 540 | "globally unique" |
| 541 | ) |
| 542 | continue |
| 543 | if element.get("data-pptx-part") != "geometry": |
| 544 | errors.append( |
| 545 | f"{label}: authored preset metadata is allowed only on the " |
| 546 | "logical group and its direct geometry carrier" |
| 547 | ) |
| 548 | continue |
| 549 | parent = parents.get(element) |
| 550 | if ( |
| 551 | parent is None |
| 552 | or not _is_svg_element(parent, "g") |
| 553 | or parent.get(AUTHORING_ATTR) != AUTHORING_VALUE |
| 554 | ): |
| 555 | errors.append( |
| 556 | f"{label}: authored preset geometry carrier must be a direct " |
| 557 | "child of its authored logical group" |
| 558 | ) |
| 559 | return errors |
| 560 | |
| 561 | |
| 562 | def materialize_compact_authored_preset_tree(root: ET.Element) -> int: |
| 563 | """Expand validated compact authored presets in memory for conversion. |
| 564 | |
| 565 | Source SVG stays compact. The converter reuses the established lossless |
| 566 | carrier/preview path internally, so compact and expanded inputs share one |
| 567 | DrawingML implementation. |
| 568 | """ |
| 569 | materialized = 0 |
| 570 | for group in list(root.iter()): |
| 571 | if authored_preset_encoding(group) != "compact": |
| 572 | continue |
| 573 | errors = _validate_compact_authored_preset_group(group) |
| 574 | if errors: |
| 575 | raise ValueError("; ".join(errors)) |
| 576 | |
| 577 | preset = group.get("data-pptx-prst") or "" |
| 578 | object_kind = group.get("data-pptx-object") or "" |
| 579 | frame = _parse_frame(group.get("data-pptx-frame"), object_kind) |
| 580 | adjustments = { |
| 581 | name[len(_ADJUSTMENT_PREFIX):]: value |
| 582 | for name, value in group.attrib.items() |
| 583 | if name.startswith(_ADJUSTMENT_PREFIX) |
| 584 | } |
| 585 | rendered = render_preset_geometry( |
| 586 | preset, |
| 587 | Xfrm(x=frame[0], y=frame[1], w=frame[2], h=frame[3]), |
| 588 | adjustments, |
| 589 | ) |
| 590 | style_attrs = _validate_style({ |
| 591 | name: group.attrib[name] |
| 592 | for name in _STYLE_ATTRS |
| 593 | if name in group.attrib |
| 594 | }) |
| 595 | semantic_attrs = { |
| 596 | name: value |
| 597 | for name, value in group.attrib.items() |
| 598 | if name in _SEMANTIC_ATTRS |
| 599 | or name.startswith(_ADJUSTMENT_PREFIX) |
| 600 | or name == "data-pptx-shape-name" |
| 601 | } |
| 602 | markup = serialize_preset_layers( |
| 603 | rendered.paths, |
| 604 | semantic_attrs, |
| 605 | style_attrs, |
| 606 | ) |
| 607 | |
| 608 | for name in _STYLE_ATTRS: |
| 609 | group.attrib.pop(name, None) |
| 610 | group.set("data-pptx-preview-sha256", markup.preview_hash) |
| 611 | for child in list(group): |
| 612 | group.remove(child) |
| 613 | wrapper = ET.fromstring( |
| 614 | '<svg xmlns="http://www.w3.org/2000/svg">' |
| 615 | f"{markup.markup}" |
| 616 | "</svg>" |
| 617 | ) |
| 618 | for child in list(wrapper): |
| 619 | wrapper.remove(child) |
| 620 | group.append(child) |
| 621 | materialized += 1 |
| 622 | return materialized |
| 623 | |
| 624 | |
| 625 | def _validate_frame( |
| 626 | frame: tuple[float, float, float, float], |
| 627 | object_kind: str, |
| 628 | ) -> tuple[float, float, float, float]: |
| 629 | if len(frame) != 4: |
| 630 | raise ValueError("frame must contain x, y, width, and height") |
| 631 | values = tuple(float(value) for value in frame) |
| 632 | if not all(math.isfinite(value) for value in values): |
| 633 | raise ValueError("frame values must be finite") |
| 634 | width, height = values[2], values[3] |
| 635 | if object_kind == "connector": |
| 636 | if width < 0 or height < 0 or (width == 0 and height == 0): |
| 637 | raise ValueError( |
| 638 | "connector frame dimensions must be non-negative and not both zero" |
| 639 | ) |
| 640 | elif width <= 0 or height <= 0: |
| 641 | raise ValueError("shape frame width and height must be positive") |
| 642 | validate_ooxml_xfrm( |
| 643 | round(values[0] * EMU_PER_PX), |
| 644 | round(values[1] * EMU_PER_PX), |
| 645 | round(width * EMU_PER_PX), |
| 646 | round(height * EMU_PER_PX), |
| 647 | ) |
| 648 | return values |
| 649 | |
| 650 | |
| 651 | def _parse_frame( |
| 652 | raw: str | None, |
| 653 | object_kind: str, |
| 654 | ) -> tuple[float, float, float, float]: |
| 655 | if raw is None: |
| 656 | raise ValueError("authored preset requires data-pptx-frame") |
| 657 | parts = re.split(r"[\s,]+", raw.strip()) |
| 658 | if len(parts) != 4: |
| 659 | raise ValueError("data-pptx-frame must contain four numbers") |
| 660 | return _validate_frame(tuple(float(part) for part in parts), object_kind) |
| 661 | |
| 662 | |
| 663 | def _validate_style(style: Mapping[str, str]) -> dict[str, str]: |
| 664 | unknown = sorted(set(style) - set(_STYLE_ATTRS)) |
| 665 | if unknown: |
| 666 | raise ValueError(f"Unsupported authored preset style attributes: {unknown}") |
| 667 | normalized = {name: str(value).strip() for name, value in style.items()} |
| 668 | if not normalized: |
| 669 | raise ValueError("Authored preset requires explicit fill and/or stroke") |
| 670 | if normalized.get("fill", "none") == "none" and normalized.get( |
| 671 | "stroke", "none" |
| 672 | ) == "none": |
| 673 | raise ValueError("Authored preset cannot have both fill and stroke set to none") |
| 674 | for name in ("fill", "stroke"): |
| 675 | value = normalized.get(name, "none") |
| 676 | if _PAINT_RE.fullmatch(value) is None: |
| 677 | raise ValueError(f"{name} must be none or a six-digit HEX color") |
| 678 | normalized[name] = value.upper() if value != "none" else value |
| 679 | if normalized.get("stroke", "none") == "none": |
| 680 | unused_stroke_attrs = sorted( |
| 681 | name for name in normalized |
| 682 | if name.startswith("stroke-") |
| 683 | ) |
| 684 | if unused_stroke_attrs: |
| 685 | raise ValueError( |
| 686 | "Stroke presentation attributes require a visible stroke: " |
| 687 | + ", ".join(unused_stroke_attrs) |
| 688 | ) |
| 689 | if normalized.get("stroke-linecap") not in {None, "butt", "round", "square"}: |
| 690 | raise ValueError("stroke-linecap must be butt, round, or square") |
| 691 | if normalized.get("stroke-linejoin") not in {None, "miter", "round", "bevel"}: |
| 692 | raise ValueError("stroke-linejoin must be miter, round, or bevel") |
| 693 | for name in ("fill-opacity", "stroke-opacity"): |
| 694 | if name not in normalized: |
| 695 | continue |
| 696 | value = float(normalized[name]) |
| 697 | if not math.isfinite(value) or value < 0 or value > 1: |
| 698 | raise ValueError(f"{name} must be between 0 and 1") |
| 699 | normalized[name] = fmt_num(value, 6) |
| 700 | if "stroke-width" in normalized: |
| 701 | width = float(normalized["stroke-width"]) |
| 702 | if not math.isfinite(width) or width < 0: |
| 703 | raise ValueError("stroke-width must be finite and non-negative") |
| 704 | validate_ooxml_line_width(round(width * EMU_PER_PX)) |
| 705 | normalized["stroke-width"] = fmt_num(width, 6) |
| 706 | if normalized.get("fill", "none") == "none" and "fill-opacity" in normalized: |
| 707 | raise ValueError("fill-opacity requires a visible fill paint") |
| 708 | if not _has_visible_fill(normalized) and not _has_visible_stroke(normalized): |
| 709 | raise ValueError( |
| 710 | "Authored preset requires at least one non-transparent visible paint" |
| 711 | ) |
| 712 | return normalized |
| 713 | |
| 714 | |
| 715 | def _normalize_adjustments( |
| 716 | adjustments: Mapping[str, str | int | float], |
| 717 | ) -> dict[str, str]: |
| 718 | normalized: dict[str, str] = {} |
| 719 | for name, value in adjustments.items(): |
| 720 | if isinstance(value, bool): |
| 721 | raise ValueError(f"Adjustment {name!r} must not be boolean") |
| 722 | if isinstance(value, int): |
| 723 | formula = f"val {value}" |
| 724 | elif isinstance(value, float): |
| 725 | if not math.isfinite(value) or not value.is_integer(): |
| 726 | raise ValueError( |
| 727 | f"Numeric adjustment {name!r} must be a finite integer" |
| 728 | ) |
| 729 | formula = f"val {int(value)}" |
| 730 | else: |
| 731 | formula = str(value).strip() |
| 732 | if len(formula.split()) == 1: |
| 733 | formula = f"val {formula}" |
| 734 | normalized[str(name)] = formula |
| 735 | return normalized |
| 736 | |
| 737 | |
| 738 | def _validate_adjustments( |
| 739 | preset: str, |
| 740 | adjustments: Mapping[str, str | int | float], |
| 741 | ) -> None: |
| 742 | registry = get_preset_registry() |
| 743 | if preset not in registry: |
| 744 | raise ValueError(f"Unknown DrawingML preset shape: {preset!r}") |
| 745 | for name, formula in adjustments.items(): |
| 746 | if not isinstance(formula, str) or not formula.strip(): |
| 747 | raise ValueError(f"Adjustment {name!r} requires a formula") |
| 748 | parts = formula.split() |
| 749 | if parts[0] not in SUPPORTED_OPERATORS: |
| 750 | raise ValueError( |
| 751 | f"Adjustment {name!r} must use a DrawingML formula operator" |
| 752 | ) |
| 753 | if parts[0] == "val" and len(parts) == 2: |
| 754 | try: |
| 755 | float(parts[1]) |
| 756 | except ValueError: |
| 757 | pass |
| 758 | else: |
| 759 | if _INTEGER_RE.fullmatch(parts[1]) is None: |
| 760 | raise ValueError( |
| 761 | f"Adjustment {name!r} val operand must be an integer " |
| 762 | "coordinate" |
| 763 | ) |
| 764 | if not adjustments: |
| 765 | return |
| 766 | evaluated = registry.evaluate( |
| 767 | preset, |
| 768 | 100000, |
| 769 | 100000, |
| 770 | adjustments=adjustments, |
| 771 | ) |
| 772 | for name, value in evaluated.adjustments.items(): |
| 773 | if name not in adjustments: |
| 774 | continue |
| 775 | if not OOXML_COORDINATE_MIN <= value <= OOXML_COORDINATE_MAX: |
| 776 | raise ValueError( |
| 777 | f"Adjustment {name!r} evaluates outside OOXML coordinate range" |
| 778 | ) |
| 779 | |
| 780 | |
| 781 | def _has_visible_fill(style: Mapping[str, str]) -> bool: |
| 782 | return ( |
| 783 | style.get("fill", "none") != "none" |
| 784 | and float(style.get("fill-opacity", "1")) > 0 |
| 785 | ) |
| 786 | |
| 787 | |
| 788 | def _has_visible_stroke(style: Mapping[str, str]) -> bool: |
| 789 | return ( |
| 790 | style.get("stroke", "none") != "none" |
| 791 | and float(style.get("stroke-opacity", "1")) > 0 |
| 792 | and float(style.get("stroke-width", "1")) > 0 |
| 793 | ) |
| 794 | |
| 795 | |
| 796 | def _is_unexpected_carrier_attr(name: str) -> bool: |
| 797 | if name in { |
| 798 | "d", |
| 799 | "data-pptx-preview-sha256", |
| 800 | "data-pptx-part", |
| 801 | "data-pptx-shape-name", |
| 802 | "visibility", |
| 803 | "pointer-events", |
| 804 | *_SEMANTIC_ATTRS, |
| 805 | *_STYLE_ATTRS, |
| 806 | }: |
| 807 | return False |
| 808 | return not name.startswith(_ADJUSTMENT_PREFIX) |
| 809 | |
| 810 | |
| 811 | def _is_unexpected_group_attr(name: str, *, compact: bool) -> bool: |
| 812 | allowed = { |
| 813 | "id", |
| 814 | "transform", |
| 815 | "data-pptx-preview-sha256", |
| 816 | "data-pptx-shape-name", |
| 817 | *_SEMANTIC_ATTRS, |
| 818 | } |
| 819 | if compact: |
| 820 | allowed.update(_STYLE_ATTRS) |
| 821 | allowed.update(_TEMPLATE_ATOM_ATTRS) |
| 822 | if name in allowed: |
| 823 | return False |
| 824 | if name.startswith(_ADJUSTMENT_PREFIX): |
| 825 | return False |
| 826 | if name.startswith("data-pptx-runtime-") or name.startswith("aria-"): |
| 827 | return False |
| 828 | return name not in {"role", "tabindex"} |
| 829 | |
| 830 | |
| 831 | def _carrier_path(paths) -> str: |
| 832 | return " ".join(path.d for path in paths).strip() |
| 833 | |
| 834 | |
| 835 | def _local_name(tag: str) -> str: |
| 836 | return tag.rsplit("}", 1)[-1] |
| 837 | |
| 838 | |
| 839 | def _is_svg_element(element: ET.Element, local_name: str) -> bool: |
| 840 | return element.tag in { |
| 841 | local_name, |
| 842 | f"{{{_SVG_NAMESPACE}}}{local_name}", |
| 843 | } |
| 844 | |
| 845 | |
| 846 | def _element_label(element: ET.Element) -> str: |
| 847 | tag = _local_name(element.tag) |
| 848 | element_id = element.get("id") |
| 849 | if element_id: |
| 850 | return f'<{tag} id="{element_id}">' |
| 851 | return f"<{tag}>" |
| 852 |