返回 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 _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
895 lines PYTHON