#!/usr/bin/env python3 """ PPT Master - Preset Shape SVG Markup Serialize evaluated DrawingML preset layers for lossless transport or compact project authoring. Usage: Import serialize_preset_layers from pptx_to_svg.preset_svg_markup. Examples: markup = serialize_preset_layers(layers, semantic_attrs, style_attrs) Dependencies: None (only uses standard library and local PPT Master modules) """ from __future__ import annotations from dataclasses import dataclass from typing import Mapping, Sequence from xml.etree import ElementTree as ET from pptx_shapes import svg_preset_preview_fingerprint from .preset_registry_to_svg import SvgPresetPath @dataclass(frozen=True) class PresetSvgMarkup: """Lossless hidden-carrier and visible-preview markup for one preset.""" carrier: str preview: str preview_hash: str @property def markup(self) -> str: """Return the carrier and preview in canonical document order.""" return f"{self.carrier}\n{self.preview}" def serialize_preset_layers( layers: Sequence[SvgPresetPath], semantic_attrs: Mapping[str, str], style_attrs: Mapping[str, str], ) -> PresetSvgMarkup: """Serialize one preset without duplicating its native PowerPoint object.""" detail_style_attrs = dict(style_attrs) preview_group_attrs = {"data-pptx-part": "geometry-preview"} for name in ("filter", "opacity"): value = detail_style_attrs.pop(name, None) if value is not None: preview_group_attrs[name] = value detail_layers: list[str] = [] for layer in layers: attrs = dict(detail_style_attrs) apply_preset_path_fill(attrs, layer.fill) if not layer.stroke: remove_stroke_attrs(attrs) attrs["stroke"] = "none" attrs["data-pptx-part"] = "geometry-detail" detail_layers.append( f'' ) preview = ( f'\n' + "\n".join(detail_layers) + "\n" ) preview_root = ET.fromstring( f'{preview}' ) preview_hash = svg_preset_preview_fingerprint(preview_root) carrier_attrs = { **style_attrs, **semantic_attrs, "data-pptx-preview-sha256": preview_hash, "data-pptx-part": "geometry", "visibility": "hidden", "pointer-events": "none", } combined_path = " ".join(layer.d for layer in layers) carrier = ( f'' ) return PresetSvgMarkup( carrier=carrier, preview=preview, preview_hash=preview_hash, ) def serialize_compact_preset_layers( layers: Sequence[SvgPresetPath], style_attrs: Mapping[str, str], ) -> str: """Serialize visible preset layers without transport-only duplication. Base paint lives once on the logical authored group and is inherited by each path. A path writes only the fill/stroke override required by its DrawingML layer. The authored-preset validator regenerates this exact markup from the registry, so no serialized fingerprint is needed. """ base_fill = style_attrs.get("fill", "none") base_stroke = style_attrs.get("stroke", "none") detail_layers: list[str] = [] for layer in layers: attrs: dict[str, str] = {} if layer.fill == "none": if base_fill != "none": attrs["fill"] = "none" elif layer.fill != "norm": derived_fill = {"fill": base_fill} apply_preset_path_fill(derived_fill, layer.fill) if derived_fill.get("fill") != base_fill: attrs["fill"] = derived_fill["fill"] if not layer.stroke and base_stroke != "none": attrs["stroke"] = "none" detail_layers.append( f'' ) return "\n".join(detail_layers) def apply_preset_path_fill(attrs: dict[str, str], mode: str) -> None: """Apply one DrawingML path fill mode to SVG presentation attributes.""" if mode == "none": attrs["fill"] = "none" attrs.pop("fill-opacity", None) return if mode == "norm": return color = attrs.get("fill", "") if not color.startswith("#") or len(color) != 7: return try: channels = tuple( int(color[offset:offset + 2], 16) for offset in (1, 3, 5) ) except ValueError: return if mode in {"darken", "darkenLess"}: factor = 0.65 if mode == "darken" else 0.82 adjusted = tuple(round(channel * factor) for channel in channels) elif mode in {"lighten", "lightenLess"}: amount = 0.4 if mode == "lighten" else 0.2 adjusted = tuple( round(channel + (255 - channel) * amount) for channel in channels ) else: return attrs["fill"] = "#" + "".join( f"{channel:02X}" for channel in adjusted ) def remove_stroke_attrs(attrs: dict[str, str]) -> None: """Remove inherited stroke and marker attributes from one path layer.""" for name in tuple(attrs): if name.startswith("stroke") or name in {"marker-start", "marker-end"}: attrs.pop(name, None) def attrs_to_xml(attrs: Mapping[str, str]) -> str: """Serialize SVG attributes in deterministic insertion order.""" return "".join( f' {name}="{_xml_escape(value)}"' for name, value in attrs.items() ) def _xml_escape(value: str) -> str: text = str(value) if any(not _is_xml_10_character(character) for character in text): raise ValueError("SVG markup contains an XML 1.0-incompatible character") return ( text .replace("&", "&") .replace("<", "<") .replace(">", ">") .replace('"', """) ) def _is_xml_10_character(character: str) -> bool: codepoint = ord(character) return ( codepoint in {0x09, 0x0A, 0x0D} or 0x20 <= codepoint <= 0xD7FF or 0xE000 <= codepoint <= 0xFFFD or 0x10000 <= codepoint <= 0x10FFFF )