返回 ppt-master
preset_authoring.py
根目录 / skills / ppt-master / scripts / pptx_to_svg / preset_authoring.py
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
852 lines PYTHON