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