#!/usr/bin/env python3 """ PPT Master - Authored Preset Shape Contract Build and validate compact canonical SVG groups for newly authored PowerPoint preset shapes while retaining expanded authored input compatibility. Usage: Import render_preset_shape_fragment or validate_authored_preset_group. Examples: fragment = render_preset_shape_fragment( "rightArrow", (80, 120, 240, 96), element_id="next-step", style={"fill": "#2563EB", "stroke": "none"}, ) Dependencies: None (only uses standard library and local PPT Master modules) """ from __future__ import annotations import math import re from typing import Mapping from xml.etree import ElementTree as ET from pptx_shapes import ( CONNECTOR_PRESET_TYPES, OOXML_COORDINATE_MAX, OOXML_COORDINATE_MIN, SUPPORTED_OPERATORS, get_preset_registry, resolve_preset_preview_hash, svg_preset_preview_fingerprint, validate_ooxml_line_width, validate_ooxml_xfrm, ) from .emu_units import EMU_PER_PX, Xfrm, fmt_num from .preset_registry_to_svg import render_preset_geometry from .preset_svg_markup import ( attrs_to_xml, serialize_compact_preset_layers, serialize_preset_layers, ) AUTHORING_ATTR = "data-pptx-authoring" AUTHORING_VALUE = "preset" _SVG_NAMESPACE = "http://www.w3.org/2000/svg" _ID_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_.:-]*") _PAINT_RE = re.compile(r"(?:none|#[0-9A-Fa-f]{6})") _INTEGER_RE = re.compile(r"[+-]?\d+") _ADJUSTMENT_PREFIX = "data-pptx-av-" _STYLE_ATTRS = ( "fill", "fill-opacity", "stroke", "stroke-linecap", "stroke-linejoin", "stroke-opacity", "stroke-width", ) _SEMANTIC_ATTRS = ( AUTHORING_ATTR, "data-pptx-object", "data-pptx-prst", "data-pptx-frame", ) _TEMPLATE_ATOM_ATTRS = ( "data-pptx-layer", "data-pptx-editable", "data-pptx-carrier", "data-pptx-role", ) def render_preset_shape_fragment( preset: str, frame: tuple[float, float, float, float], *, adjustments: Mapping[str, str | int | float] | None = None, object_kind: str = "shape", element_id: str, name: str | None = None, style: Mapping[str, str] | None = None, ) -> str: """Render one compact authored preset fragment for SVG insertion.""" registry = get_preset_registry() if preset not in registry: raise ValueError(f"Unknown DrawingML preset shape: {preset!r}") if _ID_RE.fullmatch(element_id) is None: raise ValueError(f"Invalid SVG element id: {element_id!r}") if object_kind not in {"shape", "connector"}: raise ValueError("object_kind must be 'shape' or 'connector'") if preset in CONNECTOR_PRESET_TYPES and object_kind != "connector": raise ValueError( f"Connector preset {preset!r} requires object_kind='connector'" ) if object_kind == "connector" and preset not in CONNECTOR_PRESET_TYPES: raise ValueError( f"Authored connector requires a connector preset, got {preset!r}" ) x, y, width, height = _validate_frame(frame, object_kind) adjustment_values = _normalize_adjustments(adjustments or {}) _validate_adjustments(preset, adjustment_values) registry.evaluate( preset, width, height, adjustments=adjustment_values, ) rendered = render_preset_geometry( preset, Xfrm(x=x, y=y, w=width, h=height), adjustment_values, ) if not rendered.paths: raise ValueError(f"Preset {preset!r} produced no visible SVG paths") frame_text = " ".join( fmt_num(value, 8) for value in (x, y, width, height) ) semantic_attrs = { AUTHORING_ATTR: AUTHORING_VALUE, "data-pptx-object": object_kind, "data-pptx-prst": preset, "data-pptx-frame": frame_text, } if name: semantic_attrs["data-pptx-shape-name"] = name for guide_name, formula in adjustment_values.items(): semantic_attrs[f"{_ADJUSTMENT_PREFIX}{guide_name}"] = str(formula) raw_style = dict(style or {}) if "fill" not in raw_style or "stroke" not in raw_style: raise ValueError( "Compact authored preset requires explicit local fill and stroke" ) style_attrs = _validate_style(raw_style) if ( style_attrs.get("stroke", "none") != "none" and "stroke-width" not in style_attrs ): style_attrs["stroke-width"] = "1" if object_kind == "connector": if style_attrs.get("fill", "none") != "none": raise ValueError("Authored connector fill must be none") if not _has_visible_stroke(style_attrs): raise ValueError("Authored connector requires a visible stroke") group_attrs = { "id": element_id, **semantic_attrs, **style_attrs, } return ( f'\n' f"{serialize_compact_preset_layers(rendered.paths, style_attrs)}\n" "" ) def authored_preset_encoding(group: ET.Element) -> str | None: """Return ``compact`` / ``expanded`` for an authored preset group.""" if ( _local_name(group.tag) != "g" or group.get(AUTHORING_ATTR) != AUTHORING_VALUE ): return None parts = { child.get("data-pptx-part") for child in group if child.get("data-pptx-part") is not None } if parts: return "expanded" return "compact" def validate_authored_preset_group(group: ET.Element) -> list[str]: """Return authored-preset contract errors for one logical group.""" encoding = authored_preset_encoding(group) if encoding is None: return [] if encoding == "compact": return _validate_compact_authored_preset_group(group) return _validate_expanded_authored_preset_group(group) def _validate_compact_authored_preset_group(group: ET.Element) -> list[str]: """Validate the project-canonical compact authored-preset form.""" errors: list[str] = [] if not _is_svg_element(group, "g"): return [f'{AUTHORING_ATTR}="{AUTHORING_VALUE}" requires an SVG '] element_id = group.get("id") if element_id is None: errors.append("Authored preset logical group requires a stable id") elif _ID_RE.fullmatch(element_id) is None: errors.append(f"Authored preset logical group has invalid id {element_id!r}") unexpected_group_attrs = sorted( name for name in group.attrib if _is_unexpected_group_attr(name, compact=True) ) if unexpected_group_attrs: errors.append( "Authored preset logical group has unsupported attributes: " + ", ".join(unexpected_group_attrs) ) if group.get("data-pptx-preview-sha256") is not None: errors.append( "Compact authored preset derives preview integrity from the registry; " "remove data-pptx-preview-sha256" ) if (group.text or "").strip(): errors.append("Compact authored preset cannot contain text content") direct_children = list(group) if not direct_children: errors.append("Compact authored preset requires visible direct path layers") return errors if any(child.get("data-pptx-part") is not None for child in direct_children): errors.append( "Compact authored preset cannot mix transport carrier/preview markers" ) return errors if any( not _is_svg_element(child, "path") for child in direct_children ): errors.append( "Compact authored preset is atomic and may contain only direct SVG paths" ) return errors if any(list(child) for child in direct_children): errors.append("Compact authored preset paths cannot contain child elements") if any( (child.text or "").strip() or (child.tail or "").strip() for child in direct_children ): errors.append( "Compact authored preset paths may contain only whitespace around markup" ) preset = group.get("data-pptx-prst") or "" object_kind = group.get("data-pptx-object") or "" if object_kind not in {"shape", "connector"}: errors.append( "Authored preset data-pptx-object must be 'shape' or 'connector'" ) if preset in CONNECTOR_PRESET_TYPES and object_kind != "connector": errors.append( f"Connector preset {preset!r} requires data-pptx-object='connector'" ) if object_kind == "connector" and preset not in CONNECTOR_PRESET_TYPES: errors.append( f"Authored connector requires a connector preset, got {preset!r}" ) try: frame = _parse_frame(group.get("data-pptx-frame"), object_kind) canonical_frame = " ".join(fmt_num(value, 8) for value in frame) if group.get("data-pptx-frame") != canonical_frame: raise ValueError( "Compact authored preset data-pptx-frame must use the helper's " f"canonical spelling {canonical_frame!r}" ) adjustments = { name[len(_ADJUSTMENT_PREFIX):]: value for name, value in group.attrib.items() if name.startswith(_ADJUSTMENT_PREFIX) } _validate_adjustments(preset, adjustments) rendered = render_preset_geometry( preset, Xfrm(x=frame[0], y=frame[1], w=frame[2], h=frame[3]), adjustments, ) raw_style = { name: group.attrib[name] for name in _STYLE_ATTRS if name in group.attrib } if "fill" not in raw_style or "stroke" not in raw_style: raise ValueError( "Compact authored preset requires explicit local fill and stroke" ) style_attrs = _validate_style(raw_style) noncanonical_style = sorted( name for name, value in style_attrs.items() if raw_style.get(name) != value ) if noncanonical_style: raise ValueError( "Compact authored preset style uses non-canonical values: " + ", ".join(noncanonical_style) ) if style_attrs.get("stroke", "none") != "none" and ( "stroke-width" not in style_attrs ): raise ValueError( "Compact authored preset with a visible stroke requires stroke-width" ) if object_kind == "connector": if style_attrs.get("fill", "none") != "none": raise ValueError("Authored connector fill must be none") if not _has_visible_stroke(style_attrs): raise ValueError("Authored connector requires a visible stroke") expected_markup = serialize_compact_preset_layers( rendered.paths, style_attrs, ) expected_root = ET.fromstring( f'{expected_markup}' ) except (ET.ParseError, ValueError) as exc: errors.append(f"Cannot regenerate compact authored preset: {exc}") return errors expected_children = list(expected_root) if len(direct_children) != len(expected_children): errors.append( "Compact authored preset path count differs from registry output: " f"expected {len(expected_children)}, found {len(direct_children)}" ) return errors for index, (actual, expected) in enumerate( zip(direct_children, expected_children), start=1, ): if actual.attrib != expected.attrib: errors.append( f"Compact authored preset path {index} differs from registry output" ) return errors def _validate_expanded_authored_preset_group(group: ET.Element) -> list[str]: """Validate the legacy expanded authored-preset compatibility form.""" if group.get(AUTHORING_ATTR) != AUTHORING_VALUE: return [] errors: list[str] = [] if not _is_svg_element(group, "g"): return [f'{AUTHORING_ATTR}="{AUTHORING_VALUE}" requires an SVG '] element_id = group.get("id") if element_id is None: errors.append("Authored preset logical group requires a stable id") elif _ID_RE.fullmatch(element_id) is None: errors.append(f"Authored preset logical group has invalid id {element_id!r}") unexpected_group_attrs = sorted( name for name in group.attrib if _is_unexpected_group_attr(name, compact=False) ) if unexpected_group_attrs: errors.append( "Authored preset logical group has unsupported attributes: " + ", ".join(unexpected_group_attrs) ) direct_children = list(group) carriers = [ child for child in direct_children if child.get("data-pptx-part") == "geometry" ] previews = [ child for child in direct_children if child.get("data-pptx-part") == "geometry-preview" ] if len(carriers) != 1: errors.append( f"Authored preset requires exactly one direct geometry carrier; " f"found {len(carriers)}" ) if len(previews) != 1: errors.append( f"Authored preset requires exactly one direct geometry preview; " f"found {len(previews)}" ) allowed_children = set(carriers + previews) foreign_children = [ child for child in direct_children if child not in allowed_children ] if foreign_children: errors.append( "Authored preset groups are atomic; place labels or decorations " "in a parent group" ) if len(carriers) != 1 or len(previews) != 1: return errors carrier = carriers[0] preview = previews[0] if not _is_svg_element(carrier, "path"): errors.append("Authored preset geometry carrier must be an SVG ") if not _is_svg_element(preview, "g"): errors.append("Authored preset geometry preview must be an SVG ") if carrier.get("visibility") != "hidden": errors.append('Authored preset carrier requires visibility="hidden"') if carrier.get("pointer-events") != "none": errors.append('Authored preset carrier requires pointer-events="none"') for attr_name in _SEMANTIC_ATTRS: if group.get(attr_name) != carrier.get(attr_name): errors.append( f"Authored preset group/carrier {attr_name} values differ" ) adjustment_names = { name for element in (group, carrier) for name in element.attrib if name.startswith(_ADJUSTMENT_PREFIX) } for attr_name in sorted(adjustment_names): if group.get(attr_name) != carrier.get(attr_name): errors.append( f"Authored preset group/carrier {attr_name} values differ" ) unexpected_carrier_attrs = [ name for name in carrier.attrib if _is_unexpected_carrier_attr(name) ] if unexpected_carrier_attrs: errors.append( "Authored preset carrier has unsupported presentation attributes: " + ", ".join(sorted(unexpected_carrier_attrs)) ) preset = carrier.get("data-pptx-prst") or "" object_kind = carrier.get("data-pptx-object") or "" if object_kind not in {"shape", "connector"}: errors.append( "Authored preset data-pptx-object must be 'shape' or 'connector'" ) if preset in CONNECTOR_PRESET_TYPES and object_kind != "connector": errors.append( f"Connector preset {preset!r} requires data-pptx-object='connector'" ) if object_kind == "connector" and preset not in CONNECTOR_PRESET_TYPES: errors.append( f"Authored connector requires a connector preset, got {preset!r}" ) try: frame = _parse_frame(carrier.get("data-pptx-frame"), object_kind) adjustments = { name[len(_ADJUSTMENT_PREFIX):]: value for name, value in carrier.attrib.items() if name.startswith(_ADJUSTMENT_PREFIX) } _validate_adjustments(preset, adjustments) rendered = render_preset_geometry( preset, Xfrm(x=frame[0], y=frame[1], w=frame[2], h=frame[3]), adjustments, ) style_attrs = _validate_style({ name: carrier.attrib[name] for name in _STYLE_ATTRS if name in carrier.attrib }) if object_kind == "connector": if style_attrs.get("fill", "none") != "none": raise ValueError("Authored connector fill must be none") if not _has_visible_stroke(style_attrs): raise ValueError("Authored connector requires a visible stroke") expected = serialize_preset_layers( rendered.paths, { name: value for name, value in carrier.attrib.items() if name in _SEMANTIC_ATTRS or name.startswith(_ADJUSTMENT_PREFIX) or name == "data-pptx-shape-name" }, style_attrs, ) except ValueError as exc: errors.append(f"Cannot regenerate authored preset preview: {exc}") return errors if (carrier.get("d") or "").strip() != _carrier_path(rendered.paths): errors.append("Authored preset carrier path differs from registry output") actual_preview_hash = svg_preset_preview_fingerprint(group) if actual_preview_hash != expected.preview_hash: errors.append("Authored preset visible preview differs from registry output") try: stored_hash = resolve_preset_preview_hash(group) except ValueError as exc: errors.append(f"Invalid authored preset preview fingerprint: {exc}") else: if stored_hash != expected.preview_hash: errors.append( "Authored preset fingerprint does not match regenerated metadata" ) return errors def validate_authored_preset_tree(root: ET.Element) -> list[str]: """Return structural errors for every authored preset marker in one SVG.""" errors: list[str] = [] id_counts: dict[str, int] = {} for element in root.iter(): element_id = element.get("id") if element_id: id_counts[element_id] = id_counts.get(element_id, 0) + 1 parents = { child: parent for parent in root.iter() for child in parent } for element in root.iter(): authoring = element.get(AUTHORING_ATTR) if authoring is None: continue tag = _local_name(element.tag) label = _element_label(element) if authoring != AUTHORING_VALUE: errors.append( f"{label}: unsupported {AUTHORING_ATTR} value {authoring!r}" ) continue if tag == "g": errors.extend( f"{label}: {error}" for error in validate_authored_preset_group(element) ) element_id = element.get("id") if element_id and id_counts.get(element_id, 0) > 1: errors.append( f"{label}: authored preset logical group id must be " "globally unique" ) continue if element.get("data-pptx-part") != "geometry": errors.append( f"{label}: authored preset metadata is allowed only on the " "logical group and its direct geometry carrier" ) continue parent = parents.get(element) if ( parent is None or not _is_svg_element(parent, "g") or parent.get(AUTHORING_ATTR) != AUTHORING_VALUE ): errors.append( f"{label}: authored preset geometry carrier must be a direct " "child of its authored logical group" ) return errors def materialize_compact_authored_preset_tree(root: ET.Element) -> int: """Expand validated compact authored presets in memory for conversion. Source SVG stays compact. The converter reuses the established lossless carrier/preview path internally, so compact and expanded inputs share one DrawingML implementation. """ materialized = 0 for group in list(root.iter()): if authored_preset_encoding(group) != "compact": continue errors = _validate_compact_authored_preset_group(group) if errors: raise ValueError("; ".join(errors)) preset = group.get("data-pptx-prst") or "" object_kind = group.get("data-pptx-object") or "" frame = _parse_frame(group.get("data-pptx-frame"), object_kind) adjustments = { name[len(_ADJUSTMENT_PREFIX):]: value for name, value in group.attrib.items() if name.startswith(_ADJUSTMENT_PREFIX) } rendered = render_preset_geometry( preset, Xfrm(x=frame[0], y=frame[1], w=frame[2], h=frame[3]), adjustments, ) style_attrs = _validate_style({ name: group.attrib[name] for name in _STYLE_ATTRS if name in group.attrib }) semantic_attrs = { name: value for name, value in group.attrib.items() if name in _SEMANTIC_ATTRS or name.startswith(_ADJUSTMENT_PREFIX) or name == "data-pptx-shape-name" } markup = serialize_preset_layers( rendered.paths, semantic_attrs, style_attrs, ) for name in _STYLE_ATTRS: group.attrib.pop(name, None) group.set("data-pptx-preview-sha256", markup.preview_hash) for child in list(group): group.remove(child) wrapper = ET.fromstring( '' f"{markup.markup}" "" ) for child in list(wrapper): wrapper.remove(child) group.append(child) materialized += 1 return materialized def _validate_frame( frame: tuple[float, float, float, float], object_kind: str, ) -> tuple[float, float, float, float]: if len(frame) != 4: raise ValueError("frame must contain x, y, width, and height") values = tuple(float(value) for value in frame) if not all(math.isfinite(value) for value in values): raise ValueError("frame values must be finite") width, height = values[2], values[3] if object_kind == "connector": if width < 0 or height < 0 or (width == 0 and height == 0): raise ValueError( "connector frame dimensions must be non-negative and not both zero" ) elif width <= 0 or height <= 0: raise ValueError("shape frame width and height must be positive") validate_ooxml_xfrm( round(values[0] * EMU_PER_PX), round(values[1] * EMU_PER_PX), round(width * EMU_PER_PX), round(height * EMU_PER_PX), ) return values def _parse_frame( raw: str | None, object_kind: str, ) -> tuple[float, float, float, float]: if raw is None: raise ValueError("authored preset requires data-pptx-frame") parts = re.split(r"[\s,]+", raw.strip()) if len(parts) != 4: raise ValueError("data-pptx-frame must contain four numbers") return _validate_frame(tuple(float(part) for part in parts), object_kind) def _validate_style(style: Mapping[str, str]) -> dict[str, str]: unknown = sorted(set(style) - set(_STYLE_ATTRS)) if unknown: raise ValueError(f"Unsupported authored preset style attributes: {unknown}") normalized = {name: str(value).strip() for name, value in style.items()} if not normalized: raise ValueError("Authored preset requires explicit fill and/or stroke") if normalized.get("fill", "none") == "none" and normalized.get( "stroke", "none" ) == "none": raise ValueError("Authored preset cannot have both fill and stroke set to none") for name in ("fill", "stroke"): value = normalized.get(name, "none") if _PAINT_RE.fullmatch(value) is None: raise ValueError(f"{name} must be none or a six-digit HEX color") normalized[name] = value.upper() if value != "none" else value if normalized.get("stroke", "none") == "none": unused_stroke_attrs = sorted( name for name in normalized if name.startswith("stroke-") ) if unused_stroke_attrs: raise ValueError( "Stroke presentation attributes require a visible stroke: " + ", ".join(unused_stroke_attrs) ) if normalized.get("stroke-linecap") not in {None, "butt", "round", "square"}: raise ValueError("stroke-linecap must be butt, round, or square") if normalized.get("stroke-linejoin") not in {None, "miter", "round", "bevel"}: raise ValueError("stroke-linejoin must be miter, round, or bevel") for name in ("fill-opacity", "stroke-opacity"): if name not in normalized: continue value = float(normalized[name]) if not math.isfinite(value) or value < 0 or value > 1: raise ValueError(f"{name} must be between 0 and 1") normalized[name] = fmt_num(value, 6) if "stroke-width" in normalized: width = float(normalized["stroke-width"]) if not math.isfinite(width) or width < 0: raise ValueError("stroke-width must be finite and non-negative") validate_ooxml_line_width(round(width * EMU_PER_PX)) normalized["stroke-width"] = fmt_num(width, 6) if normalized.get("fill", "none") == "none" and "fill-opacity" in normalized: raise ValueError("fill-opacity requires a visible fill paint") if not _has_visible_fill(normalized) and not _has_visible_stroke(normalized): raise ValueError( "Authored preset requires at least one non-transparent visible paint" ) return normalized def _normalize_adjustments( adjustments: Mapping[str, str | int | float], ) -> dict[str, str]: normalized: dict[str, str] = {} for name, value in adjustments.items(): if isinstance(value, bool): raise ValueError(f"Adjustment {name!r} must not be boolean") if isinstance(value, int): formula = f"val {value}" elif isinstance(value, float): if not math.isfinite(value) or not value.is_integer(): raise ValueError( f"Numeric adjustment {name!r} must be a finite integer" ) formula = f"val {int(value)}" else: formula = str(value).strip() if len(formula.split()) == 1: formula = f"val {formula}" normalized[str(name)] = formula return normalized def _validate_adjustments( preset: str, adjustments: Mapping[str, str | int | float], ) -> None: registry = get_preset_registry() if preset not in registry: raise ValueError(f"Unknown DrawingML preset shape: {preset!r}") for name, formula in adjustments.items(): if not isinstance(formula, str) or not formula.strip(): raise ValueError(f"Adjustment {name!r} requires a formula") parts = formula.split() if parts[0] not in SUPPORTED_OPERATORS: raise ValueError( f"Adjustment {name!r} must use a DrawingML formula operator" ) if parts[0] == "val" and len(parts) == 2: try: float(parts[1]) except ValueError: pass else: if _INTEGER_RE.fullmatch(parts[1]) is None: raise ValueError( f"Adjustment {name!r} val operand must be an integer " "coordinate" ) if not adjustments: return evaluated = registry.evaluate( preset, 100000, 100000, adjustments=adjustments, ) for name, value in evaluated.adjustments.items(): if name not in adjustments: continue if not OOXML_COORDINATE_MIN <= value <= OOXML_COORDINATE_MAX: raise ValueError( f"Adjustment {name!r} evaluates outside OOXML coordinate range" ) def _has_visible_fill(style: Mapping[str, str]) -> bool: return ( style.get("fill", "none") != "none" and float(style.get("fill-opacity", "1")) > 0 ) def _has_visible_stroke(style: Mapping[str, str]) -> bool: return ( style.get("stroke", "none") != "none" and float(style.get("stroke-opacity", "1")) > 0 and float(style.get("stroke-width", "1")) > 0 ) def _is_unexpected_carrier_attr(name: str) -> bool: if name in { "d", "data-pptx-preview-sha256", "data-pptx-part", "data-pptx-shape-name", "visibility", "pointer-events", *_SEMANTIC_ATTRS, *_STYLE_ATTRS, }: return False return not name.startswith(_ADJUSTMENT_PREFIX) def _is_unexpected_group_attr(name: str, *, compact: bool) -> bool: allowed = { "id", "transform", "data-pptx-preview-sha256", "data-pptx-shape-name", *_SEMANTIC_ATTRS, } if compact: allowed.update(_STYLE_ATTRS) allowed.update(_TEMPLATE_ATOM_ATTRS) if name in allowed: return False if name.startswith(_ADJUSTMENT_PREFIX): return False if name.startswith("data-pptx-runtime-") or name.startswith("aria-"): return False return name not in {"role", "tabindex"} def _carrier_path(paths) -> str: return " ".join(path.d for path in paths).strip() def _local_name(tag: str) -> str: return tag.rsplit("}", 1)[-1] def _is_svg_element(element: ET.Element, local_name: str) -> bool: return element.tag in { local_name, f"{{{_SVG_NAMESPACE}}}{local_name}", } def _element_label(element: ET.Element) -> str: tag = _local_name(element.tag) element_id = element.get("id") if element_id: return f'<{tag} id="{element_id}">' return f"<{tag}>"