返回 ppt-master
preset_svg_markup.py
根目录 / skills / ppt-master / scripts / pptx_to_svg / preset_svg_markup.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - Preset Shape SVG Markup
4
5 Serialize evaluated DrawingML preset layers for lossless transport or compact
6 project authoring.
7
8 Usage:
9 Import serialize_preset_layers from pptx_to_svg.preset_svg_markup.
10
11 Examples:
12 markup = serialize_preset_layers(layers, semantic_attrs, style_attrs)
13
14 Dependencies:
15 None (only uses standard library and local PPT Master modules)
16 """
17
18 from __future__ import annotations
19
20 from dataclasses import dataclass
21 from typing import Mapping, Sequence
22 from xml.etree import ElementTree as ET
23
24 from pptx_shapes import svg_preset_preview_fingerprint
25
26 from .preset_registry_to_svg import SvgPresetPath
27
28
29 @dataclass(frozen=True)
30 class PresetSvgMarkup:
31 """Lossless hidden-carrier and visible-preview markup for one preset."""
32
33 carrier: str
34 preview: str
35 preview_hash: str
36
37 @property
38 def markup(self) -> str:
39 """Return the carrier and preview in canonical document order."""
40 return f"{self.carrier}\n{self.preview}"
41
42
43 def serialize_preset_layers(
44 layers: Sequence[SvgPresetPath],
45 semantic_attrs: Mapping[str, str],
46 style_attrs: Mapping[str, str],
47 ) -> PresetSvgMarkup:
48 """Serialize one preset without duplicating its native PowerPoint object."""
49 detail_style_attrs = dict(style_attrs)
50 preview_group_attrs = {"data-pptx-part": "geometry-preview"}
51 for name in ("filter", "opacity"):
52 value = detail_style_attrs.pop(name, None)
53 if value is not None:
54 preview_group_attrs[name] = value
55
56 detail_layers: list[str] = []
57 for layer in layers:
58 attrs = dict(detail_style_attrs)
59 apply_preset_path_fill(attrs, layer.fill)
60 if not layer.stroke:
61 remove_stroke_attrs(attrs)
62 attrs["stroke"] = "none"
63 attrs["data-pptx-part"] = "geometry-detail"
64 detail_layers.append(
65 f'<path d="{_xml_escape(layer.d)}"{attrs_to_xml(attrs)}/>'
66 )
67
68 preview = (
69 f'<g{attrs_to_xml(preview_group_attrs)}>\n'
70 + "\n".join(detail_layers)
71 + "\n</g>"
72 )
73 preview_root = ET.fromstring(
74 f'<svg xmlns="http://www.w3.org/2000/svg">{preview}</svg>'
75 )
76 preview_hash = svg_preset_preview_fingerprint(preview_root)
77 carrier_attrs = {
78 **style_attrs,
79 **semantic_attrs,
80 "data-pptx-preview-sha256": preview_hash,
81 "data-pptx-part": "geometry",
82 "visibility": "hidden",
83 "pointer-events": "none",
84 }
85 combined_path = " ".join(layer.d for layer in layers)
86 carrier = (
87 f'<path d="{_xml_escape(combined_path)}"'
88 f'{attrs_to_xml(carrier_attrs)}/>'
89 )
90 return PresetSvgMarkup(
91 carrier=carrier,
92 preview=preview,
93 preview_hash=preview_hash,
94 )
95
96
97 def serialize_compact_preset_layers(
98 layers: Sequence[SvgPresetPath],
99 style_attrs: Mapping[str, str],
100 ) -> str:
101 """Serialize visible preset layers without transport-only duplication.
102
103 Base paint lives once on the logical authored group and is inherited by
104 each path. A path writes only the fill/stroke override required by its
105 DrawingML layer. The authored-preset validator regenerates this exact
106 markup from the registry, so no serialized fingerprint is needed.
107 """
108 base_fill = style_attrs.get("fill", "none")
109 base_stroke = style_attrs.get("stroke", "none")
110 detail_layers: list[str] = []
111 for layer in layers:
112 attrs: dict[str, str] = {}
113 if layer.fill == "none":
114 if base_fill != "none":
115 attrs["fill"] = "none"
116 elif layer.fill != "norm":
117 derived_fill = {"fill": base_fill}
118 apply_preset_path_fill(derived_fill, layer.fill)
119 if derived_fill.get("fill") != base_fill:
120 attrs["fill"] = derived_fill["fill"]
121 if not layer.stroke and base_stroke != "none":
122 attrs["stroke"] = "none"
123 detail_layers.append(
124 f'<path d="{_xml_escape(layer.d)}"{attrs_to_xml(attrs)}/>'
125 )
126 return "\n".join(detail_layers)
127
128
129 def apply_preset_path_fill(attrs: dict[str, str], mode: str) -> None:
130 """Apply one DrawingML path fill mode to SVG presentation attributes."""
131 if mode == "none":
132 attrs["fill"] = "none"
133 attrs.pop("fill-opacity", None)
134 return
135 if mode == "norm":
136 return
137 color = attrs.get("fill", "")
138 if not color.startswith("#") or len(color) != 7:
139 return
140 try:
141 channels = tuple(
142 int(color[offset:offset + 2], 16)
143 for offset in (1, 3, 5)
144 )
145 except ValueError:
146 return
147 if mode in {"darken", "darkenLess"}:
148 factor = 0.65 if mode == "darken" else 0.82
149 adjusted = tuple(round(channel * factor) for channel in channels)
150 elif mode in {"lighten", "lightenLess"}:
151 amount = 0.4 if mode == "lighten" else 0.2
152 adjusted = tuple(
153 round(channel + (255 - channel) * amount)
154 for channel in channels
155 )
156 else:
157 return
158 attrs["fill"] = "#" + "".join(
159 f"{channel:02X}" for channel in adjusted
160 )
161
162
163 def remove_stroke_attrs(attrs: dict[str, str]) -> None:
164 """Remove inherited stroke and marker attributes from one path layer."""
165 for name in tuple(attrs):
166 if name.startswith("stroke") or name in {"marker-start", "marker-end"}:
167 attrs.pop(name, None)
168
169
170 def attrs_to_xml(attrs: Mapping[str, str]) -> str:
171 """Serialize SVG attributes in deterministic insertion order."""
172 return "".join(
173 f' {name}="{_xml_escape(value)}"'
174 for name, value in attrs.items()
175 )
176
177
178 def _xml_escape(value: str) -> str:
179 text = str(value)
180 if any(not _is_xml_10_character(character) for character in text):
181 raise ValueError("SVG markup contains an XML 1.0-incompatible character")
182 return (
183 text
184 .replace("&", "&amp;")
185 .replace("<", "&lt;")
186 .replace(">", "&gt;")
187 .replace('"', "&quot;")
188 )
189
190
191 def _is_xml_10_character(character: str) -> bool:
192 codepoint = ord(character)
193 return (
194 codepoint in {0x09, 0x0A, 0x0D}
195 or 0x20 <= codepoint <= 0xD7FF
196 or 0xE000 <= codepoint <= 0xFFFD
197 or 0x10000 <= codepoint <= 0x10FFFF
198 )
199
199 lines PYTHON