返回 ppt-master
shape_boolean.py
根目录 / skills / ppt-master / scripts / svg_to_pptx / shape_boolean.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - Shape Boolean Core
4
5 Resolve closed SVG or text-outline operands into SVG-root-coordinate paths and
6 apply PowerPoint-compatible merge-shapes operations without mutating the source.
7 Callers insert returned paths at the original z-order under the final semantic
8 or structured parent, never under the old transformed ancestor.
9
10 Usage:
11 Import render_boolean_svg_fragments from svg_to_pptx.shape_boolean.
12
13 Examples:
14 fragment = render_boolean_svg_fragments(
15 "projects/demo/svg_output/slide-01.svg",
16 operation="intersect",
17 source_ids=("shape-a", "shape-b"),
18 output_id="shape-overlap",
19 )
20
21 Dependencies:
22 skia-pathops, local PPT Master modules, and uharfbuzz for text operands
23 """
24
25 from __future__ import annotations
26
27 import math
28 import re
29 from collections.abc import Mapping, Sequence
30 from pathlib import Path
31 from typing import Any
32 from xml.etree import ElementTree as ET
33
34 from pptx_to_svg.emu_units import Xfrm
35 from pptx_to_svg.preset_authoring import (
36 authored_preset_encoding,
37 validate_authored_preset_group,
38 )
39 from pptx_to_svg.preset_registry_to_svg import render_preset_geometry
40 from pptx_to_svg.preset_svg_markup import attrs_to_xml
41
42 from .drawingml.context import AffineMatrix, IDENTITY_MATRIX
43 from .drawingml.paths import (
44 PathCommand,
45 normalize_path_commands,
46 parse_svg_path,
47 parse_svg_points,
48 svg_path_to_absolute,
49 transform_path_commands,
50 )
51 from .drawingml.utils import (
52 format_project_geometry_length,
53 matrix_multiply,
54 parse_inline_style,
55 parse_opacity,
56 parse_project_geometry_length,
57 parse_project_stroke_dasharray,
58 parse_project_stroke_enum,
59 parse_transform_matrix,
60 )
61
62
63 BOOLEAN_OPERATIONS = frozenset({
64 "combine",
65 "fragment",
66 "intersect",
67 "subtract",
68 "union",
69 })
70
71 _BOOLEAN_TAGS = frozenset({"circle", "ellipse", "path", "polygon", "rect"})
72 _DEFINITION_TAGS = frozenset({
73 "clipPath",
74 "defs",
75 "marker",
76 "mask",
77 "pattern",
78 "symbol",
79 })
80 _ID_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_.:-]*")
81 _STYLE_OVERRIDE_ATTRS = (
82 "fill",
83 "fill-opacity",
84 "opacity",
85 "stroke",
86 "stroke-dasharray",
87 "stroke-width",
88 "stroke-opacity",
89 "stroke-linecap",
90 "stroke-linejoin",
91 )
92 _PRESENTATION_ATTRS = (
93 *_STYLE_OVERRIDE_ATTRS,
94 "vector-effect",
95 )
96 _ROUNDTRIP_TRANSPORT_ATTRS = frozenset({
97 "data-pptx-custgeom",
98 "data-pptx-custgeom-ref",
99 "data-pptx-geometry-kind",
100 "data-pptx-native",
101 "data-pptx-native-ref",
102 "data-pptx-native-source",
103 "data-pptx-native-status",
104 "data-pptx-part",
105 "data-pptx-shape-id",
106 "data-pptx-shape-scope",
107 "data-pptx-source-ref",
108 })
109 _RESULT_PRECISION = 6
110 _BEZIER_QUARTER_K = 0.5522847498307936
111
112
113 def render_boolean_svg_fragments(
114 svg_file: str | Path,
115 *,
116 operation: str,
117 source_ids: Sequence[str],
118 output_id: str,
119 style: Mapping[str, str] | None = None,
120 font_dirs: Sequence[str | Path] = (),
121 ) -> str:
122 """Return canonical SVG path fragments for one merge-shapes operation.
123
124 The first source is the primary shape: subtract removes all later sources
125 from it, and every result inherits its effective presentation attributes.
126 All transforms are baked into SVG-root coordinates, so callers insert the
127 result at the original z-order under the final semantic or structured
128 parent, never under the old transformed ancestor. Fragment returns
129 top-to-bottom, left-to-right sibling paths named ``<output_id>-1`` onward;
130 every other operation returns one path named exactly ``output_id``.
131 """
132 if not isinstance(operation, str):
133 raise ValueError("Shape Boolean operation must be a string")
134 normalized_operation = operation.strip().lower()
135 if normalized_operation not in BOOLEAN_OPERATIONS:
136 allowed = ", ".join(sorted(BOOLEAN_OPERATIONS))
137 raise ValueError(
138 f"Unsupported shape Boolean operation {operation!r}; use {allowed}"
139 )
140 source_id_list = _validate_source_ids(source_ids)
141 _validate_output_id(output_id)
142 style_overrides = _validate_style_overrides(style)
143
144 source_path = Path(svg_file)
145 try:
146 root = ET.parse(source_path).getroot()
147 except ET.ParseError as exc:
148 raise ValueError(f"Cannot parse SVG {source_path}: {exc}") from exc
149 if _local_name(root.tag) != "svg":
150 raise ValueError(f"Shape Boolean source must have an SVG root: {source_path}")
151 if any(_local_name(element.tag) == "style" for element in root.iter()):
152 raise ValueError(
153 "Shape Boolean does not resolve embedded CSS; use explicit SVG "
154 "presentation attributes on the operands"
155 )
156
157 parents = {
158 child: parent
159 for parent in root.iter()
160 for child in list(parent)
161 }
162 elements_by_id, duplicate_ids = _index_element_ids(root)
163 selected: list[ET.Element] = []
164 for source_id in source_id_list:
165 if source_id in duplicate_ids:
166 raise ValueError(
167 f"Shape Boolean source id {source_id!r} is not unique"
168 )
169 element = elements_by_id.get(source_id)
170 if element is None:
171 raise ValueError(f"Shape Boolean source id not found: {source_id!r}")
172 selected.append(element)
173
174 pathops = _load_pathops()
175 operands = [
176 _element_to_pathops(
177 element,
178 parents,
179 pathops,
180 font_dirs=font_dirs,
181 )
182 for element in selected
183 ]
184 inherited_style = _materialize_baked_stroke_style(
185 _effective_style(selected[0], parents),
186 _combined_transform(_element_chain(selected[0], parents)),
187 )
188 inherited_style.update(style_overrides)
189
190 if normalized_operation == "fragment":
191 results = _fragment_paths(operands, pathops)
192 fragments: list[str] = []
193 for index, result in enumerate(results, start=1):
194 result_id = f"{output_id}-{index}"
195 _validate_result_id_collision(
196 result_id,
197 elements_by_id,
198 source_id_list,
199 )
200 fragments.append(
201 _serialize_result_path(
202 result,
203 result_id,
204 inherited_style,
205 pathops,
206 )
207 )
208 return "\n".join(fragments)
209
210 result = _merge_paths(normalized_operation, operands, pathops)
211 if _path_is_empty(result):
212 raise ValueError(
213 f"Shape Boolean {normalized_operation} produced no filled area"
214 )
215 _validate_result_id_collision(output_id, elements_by_id, source_id_list)
216 return _serialize_result_path(
217 result,
218 output_id,
219 inherited_style,
220 pathops,
221 )
222
223
224 def _validate_source_ids(source_ids: Sequence[str]) -> list[str]:
225 if isinstance(source_ids, (str, bytes)):
226 raise ValueError("Shape Boolean source_ids must be a sequence of ids")
227 values = list(source_ids)
228 if len(values) < 2:
229 raise ValueError("Shape Boolean requires at least two source ids")
230 if any(not isinstance(value, str) or not value for value in values):
231 raise ValueError("Shape Boolean source ids must be non-empty strings")
232 if len(set(values)) != len(values):
233 raise ValueError("Shape Boolean source ids must be unique")
234 return values
235
236
237 def _validate_output_id(output_id: str) -> None:
238 if not isinstance(output_id, str) or _ID_RE.fullmatch(output_id) is None:
239 raise ValueError(f"Invalid SVG output id: {output_id!r}")
240
241
242 def _validate_style_overrides(
243 style: Mapping[str, str] | None,
244 ) -> dict[str, str]:
245 if style is None:
246 return {}
247 unknown = sorted(set(style) - set(_STYLE_OVERRIDE_ATTRS))
248 if unknown:
249 raise ValueError(
250 "Unsupported shape Boolean style override(s): "
251 + ", ".join(unknown)
252 )
253 normalized: dict[str, str] = {}
254 for name in _STYLE_OVERRIDE_ATTRS:
255 if name not in style:
256 continue
257 value = style[name]
258 if not isinstance(value, str) or not value.strip():
259 raise ValueError(
260 f"Shape Boolean style override {name!r} must be a non-empty string"
261 )
262 normalized[name] = value.strip()
263 return normalized
264
265
266 def _index_element_ids(
267 root: ET.Element,
268 ) -> tuple[dict[str, ET.Element], set[str]]:
269 elements: dict[str, ET.Element] = {}
270 duplicates: set[str] = set()
271 for element in root.iter():
272 element_id = element.get("id")
273 if not element_id:
274 continue
275 if element_id in elements:
276 duplicates.add(element_id)
277 else:
278 elements[element_id] = element
279 return elements, duplicates
280
281
282 def _validate_result_id_collision(
283 result_id: str,
284 elements_by_id: Mapping[str, ET.Element],
285 source_ids: Sequence[str],
286 ) -> None:
287 if result_id in elements_by_id and result_id not in source_ids:
288 raise ValueError(
289 f"Shape Boolean result id already exists outside the sources: "
290 f"{result_id!r}"
291 )
292
293
294 def _load_pathops() -> Any:
295 try:
296 import pathops
297 except ImportError as exc:
298 raise RuntimeError(
299 "Shape Boolean operations require skia-pathops. Install the "
300 "project requirements, or run: pip install skia-pathops"
301 ) from exc
302 required = (
303 "FillType",
304 "Path",
305 "PathOp",
306 "PathOpsError",
307 "PathVerb",
308 "op",
309 "simplify",
310 )
311 missing = [name for name in required if not hasattr(pathops, name)]
312 if missing:
313 raise RuntimeError(
314 "Installed skia-pathops is incompatible; missing: "
315 + ", ".join(missing)
316 )
317 return pathops
318
319
320 def _element_to_pathops(
321 element: ET.Element,
322 parents: Mapping[ET.Element, ET.Element],
323 pathops: Any,
324 *,
325 font_dirs: Sequence[str | Path] = (),
326 ) -> Any:
327 chain = _element_chain(element, parents)
328 _validate_source_location(element, chain)
329 matrix = _combined_transform(chain)
330 tag = _local_name(element.tag)
331
332 if tag == "g":
333 commands = _compact_preset_commands(element, matrix)
334 elif tag in _BOOLEAN_TAGS:
335 commands = _shape_commands(element)
336 commands = transform_path_commands(commands, matrix)
337 elif tag == "text":
338 from .text_outline import text_element_to_path_commands
339
340 commands = text_element_to_path_commands(
341 element,
342 parents,
343 font_dirs=font_dirs,
344 )
345 commands = transform_path_commands(commands, matrix)
346 else:
347 supported = ", ".join((*sorted(_BOOLEAN_TAGS), "text"))
348 raise ValueError(
349 f"Shape Boolean source {_element_label(element)} must be a "
350 f"supported {supported}, or a compact authored preset group"
351 )
352
353 result = _simplify_path(_commands_to_pathops(commands, pathops), pathops)
354 if _path_is_empty(result):
355 raise ValueError(
356 f"Shape Boolean source {_element_label(element)} has no filled area"
357 )
358 return result
359
360
361 def _element_chain(
362 element: ET.Element,
363 parents: Mapping[ET.Element, ET.Element],
364 ) -> list[ET.Element]:
365 chain = [element]
366 current = element
367 while current in parents:
368 current = parents[current]
369 chain.append(current)
370 chain.reverse()
371 return chain
372
373
374 def _validate_source_location(
375 element: ET.Element,
376 chain: Sequence[ET.Element],
377 ) -> None:
378 definition_ancestor = next(
379 (
380 ancestor
381 for ancestor in chain[:-1]
382 if _local_name(ancestor.tag) in _DEFINITION_TAGS
383 ),
384 None,
385 )
386 if definition_ancestor is not None:
387 raise ValueError(
388 f"Shape Boolean source {_element_label(element)} is inside "
389 f"<{_local_name(definition_ancestor.tag)}>"
390 )
391 nested_svg_count = sum(
392 1 for ancestor in chain if _local_name(ancestor.tag) == "svg"
393 )
394 if nested_svg_count > 1:
395 raise ValueError(
396 f"Shape Boolean source {_element_label(element)} is inside a nested "
397 "<svg>; materialize its viewport transform first"
398 )
399 for ancestor in chain[:-1]:
400 if (
401 _local_name(ancestor.tag) == "g"
402 and authored_preset_encoding(ancestor) is not None
403 ):
404 raise ValueError(
405 f"Shape Boolean source {_element_label(element)} is inside an "
406 "authored preset; select the preset group's id"
407 )
408 _validate_geometry_presentation(element, chain)
409
410
411 def _validate_geometry_presentation(
412 element: ET.Element,
413 chain: Sequence[ET.Element],
414 ) -> None:
415 fill_rule = "nonzero"
416 for current in chain:
417 if current.get("class") is not None:
418 raise ValueError(
419 f"Shape Boolean source {_element_label(element)} uses class; "
420 "materialize its computed presentation first"
421 )
422 transport_attrs = sorted(
423 name
424 for name in _ROUNDTRIP_TRANSPORT_ATTRS
425 if current.get(name) is not None
426 )
427 if transport_attrs:
428 raise ValueError(
429 f"Shape Boolean source {_element_label(element)} carries "
430 "PPTX import/round-trip metadata "
431 f"({', '.join(transport_attrs)}); author or materialize an "
432 "ordinary SVG shape first"
433 )
434 inline = parse_inline_style(current.get("style"))
435 inline_transform = inline.get("transform")
436 if (
437 inline_transform is not None
438 and inline_transform.strip().lower() not in {"", "none"}
439 ):
440 raise ValueError(
441 f"Shape Boolean source {_element_label(element)} uses a CSS "
442 "transform; write it as an SVG transform attribute first"
443 )
444 display = inline.get("display", current.get("display"))
445 if display is not None and display.strip().lower() == "none":
446 raise ValueError(
447 f"Shape Boolean source {_element_label(element)} is not "
448 "displayed"
449 )
450 visibility = inline.get("visibility", current.get("visibility"))
451 if visibility is not None and visibility.strip().lower() in {
452 "collapse",
453 "hidden",
454 }:
455 raise ValueError(
456 f"Shape Boolean source {_element_label(element)} is not visible"
457 )
458 for name in ("clip-path", "mask"):
459 raw = inline.get(name, current.get(name))
460 if raw is not None and raw.strip().lower() not in {"", "none"}:
461 raise ValueError(
462 f"Shape Boolean source {_element_label(element)} uses "
463 f"{name}; materialize the visible contour first"
464 )
465 raw_filter = inline.get("filter", current.get("filter"))
466 if raw_filter is not None and raw_filter.strip().lower() not in {
467 "",
468 "none",
469 }:
470 raise ValueError(
471 f"Shape Boolean source {_element_label(element)} uses filter; "
472 "reapply the effect after materializing the result"
473 )
474 raw_dash_offset = inline.get(
475 "stroke-dashoffset",
476 current.get("stroke-dashoffset"),
477 )
478 if raw_dash_offset is not None:
479 raise ValueError(
480 f"Shape Boolean source {_element_label(element)} uses "
481 "stroke-dashoffset; dashed-arc phase cannot be materialized "
482 "as filled Boolean geometry"
483 )
484 raw_fill_rule = inline.get("fill-rule", current.get("fill-rule"))
485 if (
486 raw_fill_rule is not None
487 and raw_fill_rule.strip().lower() != "inherit"
488 ):
489 fill_rule = raw_fill_rule.strip().lower()
490 if fill_rule != "nonzero":
491 raise ValueError(
492 f"Shape Boolean source {_element_label(element)} uses "
493 f"fill-rule={fill_rule!r}; use explicit nonzero contour direction"
494 )
495
496
497 def _combined_transform(chain: Sequence[ET.Element]) -> AffineMatrix:
498 matrix = IDENTITY_MATRIX
499 for element in chain[1:]:
500 transform = element.get("transform")
501 if transform:
502 matrix = matrix_multiply(
503 matrix,
504 parse_transform_matrix(transform),
505 )
506 return matrix
507
508
509 def _materialize_baked_stroke_style(
510 style: Mapping[str, str],
511 matrix: AffineMatrix,
512 ) -> dict[str, str]:
513 """Keep stroke metrics visually stable after baking a geometry transform."""
514 materialized = dict(style)
515 raw_vector_effect = materialized.get("vector-effect")
516 vector_effect = None
517 if raw_vector_effect is not None:
518 try:
519 vector_effect = parse_project_stroke_enum(
520 "vector-effect",
521 raw_vector_effect,
522 )
523 except ValueError as exc:
524 raise ValueError(
525 f"Shape Boolean primary vector-effect "
526 f"{raw_vector_effect!r} {exc}"
527 ) from exc
528 materialized["vector-effect"] = vector_effect
529
530 stroke = materialized.get("stroke")
531 if not stroke or stroke.strip().lower() in {"none", "transparent"}:
532 return materialized
533 if vector_effect == "non-scaling-stroke":
534 return materialized
535
536 a, b, c, d, _e, _f = matrix
537 scale = math.sqrt(abs(a * d - b * c))
538 if not math.isfinite(scale):
539 raise ValueError(
540 "Shape Boolean primary transform produces a non-finite stroke scale"
541 )
542 if scale == 1.0:
543 return materialized
544
545 raw_width = materialized.get("stroke-width", "1")
546 try:
547 source_width = parse_project_geometry_length(
548 raw_width,
549 "stroke-width",
550 )
551 except ValueError as exc:
552 raise ValueError(
553 f"Shape Boolean primary stroke-width {raw_width!r} {exc}"
554 ) from exc
555 materialized["stroke-width"] = format_project_geometry_length(
556 source_width * scale
557 )
558
559 raw_dasharray = materialized.get("stroke-dasharray")
560 if raw_dasharray is None or raw_dasharray.strip().lower() == "none":
561 return materialized
562 try:
563 parsed_dasharray = parse_project_stroke_dasharray(raw_dasharray)
564 except ValueError as exc:
565 raise ValueError(
566 f"Shape Boolean primary stroke-dasharray {raw_dasharray!r} {exc}"
567 ) from exc
568 if parsed_dasharray is not None:
569 _preset, values = parsed_dasharray
570 materialized["stroke-dasharray"] = " ".join(
571 format_project_geometry_length(value * scale)
572 for value in values
573 )
574 return materialized
575
576
577 def _shape_commands(element: ET.Element) -> list[PathCommand]:
578 tag = _local_name(element.tag)
579 if tag == "path":
580 raw_path = element.get("d")
581 if raw_path is None:
582 raise ValueError(f"{_element_label(element)} requires d")
583 commands = normalize_path_commands(
584 svg_path_to_absolute(parse_svg_path(raw_path))
585 )
586 _require_closed_commands(commands, _element_label(element))
587 return commands
588
589 if tag == "polygon":
590 points = parse_svg_points(
591 element.get("points", ""),
592 min_points=3,
593 )
594 commands = [PathCommand("M", [*points[0]])]
595 commands.extend(
596 PathCommand("L", [x, y])
597 for x, y in points[1:]
598 )
599 commands.append(PathCommand("Z", []))
600 return commands
601
602 if tag == "rect":
603 return _rect_commands(element)
604
605 if tag == "circle":
606 cx = _geometry_value(element, "cx", default=0.0)
607 cy = _geometry_value(element, "cy", default=0.0)
608 radius = _geometry_value(element, "r", required=True)
609 if radius <= 0:
610 raise ValueError(f"{_element_label(element)} r must be greater than 0")
611 return _ellipse_commands(cx, cy, radius, radius)
612
613 if tag == "ellipse":
614 cx = _geometry_value(element, "cx", default=0.0)
615 cy = _geometry_value(element, "cy", default=0.0)
616 radius_x = _geometry_value(element, "rx", required=True)
617 radius_y = _geometry_value(element, "ry", required=True)
618 if radius_x <= 0 or radius_y <= 0:
619 raise ValueError(
620 f"{_element_label(element)} rx and ry must be greater than 0"
621 )
622 return _ellipse_commands(cx, cy, radius_x, radius_y)
623
624 raise AssertionError(f"Unhandled Boolean source tag: {tag}")
625
626
627 def _geometry_value(
628 element: ET.Element,
629 attribute: str,
630 *,
631 default: float | None = None,
632 required: bool = False,
633 ) -> float:
634 raw = element.get(attribute)
635 if raw is None:
636 if required:
637 raise ValueError(f"{_element_label(element)} requires {attribute}")
638 if default is None:
639 raise AssertionError("Geometry attribute requires a default")
640 return default
641 try:
642 return parse_project_geometry_length(raw, attribute)
643 except ValueError as exc:
644 raise ValueError(
645 f"{_element_label(element)} {attribute}: {exc}"
646 ) from exc
647
648
649 def _rect_commands(element: ET.Element) -> list[PathCommand]:
650 x = _geometry_value(element, "x", default=0.0)
651 y = _geometry_value(element, "y", default=0.0)
652 width = _geometry_value(element, "width", required=True)
653 height = _geometry_value(element, "height", required=True)
654 if width <= 0 or height <= 0:
655 raise ValueError(
656 f"{_element_label(element)} width and height must be greater than 0"
657 )
658
659 raw_rx = element.get("rx")
660 raw_ry = element.get("ry")
661 radius_x = (
662 _geometry_value(element, "rx", required=True)
663 if raw_rx is not None else 0.0
664 )
665 radius_y = (
666 _geometry_value(element, "ry", required=True)
667 if raw_ry is not None else 0.0
668 )
669 if raw_rx is not None and raw_ry is None:
670 radius_y = radius_x
671 elif raw_ry is not None and raw_rx is None:
672 radius_x = radius_y
673 if radius_x < 0 or radius_y < 0:
674 raise ValueError(
675 f"{_element_label(element)} rx and ry must not be negative"
676 )
677
678 radius_x = min(radius_x, width / 2.0)
679 radius_y = min(radius_y, height / 2.0)
680 if radius_x <= 0 or radius_y <= 0:
681 return [
682 PathCommand("M", [x, y]),
683 PathCommand("L", [x + width, y]),
684 PathCommand("L", [x + width, y + height]),
685 PathCommand("L", [x, y + height]),
686 PathCommand("Z", []),
687 ]
688
689 kx = radius_x * _BEZIER_QUARTER_K
690 ky = radius_y * _BEZIER_QUARTER_K
691 right = x + width
692 bottom = y + height
693 return [
694 PathCommand("M", [x + radius_x, y]),
695 PathCommand("L", [right - radius_x, y]),
696 PathCommand(
697 "C",
698 [
699 right - radius_x + kx,
700 y,
701 right,
702 y + radius_y - ky,
703 right,
704 y + radius_y,
705 ],
706 ),
707 PathCommand("L", [right, bottom - radius_y]),
708 PathCommand(
709 "C",
710 [
711 right,
712 bottom - radius_y + ky,
713 right - radius_x + kx,
714 bottom,
715 right - radius_x,
716 bottom,
717 ],
718 ),
719 PathCommand("L", [x + radius_x, bottom]),
720 PathCommand(
721 "C",
722 [
723 x + radius_x - kx,
724 bottom,
725 x,
726 bottom - radius_y + ky,
727 x,
728 bottom - radius_y,
729 ],
730 ),
731 PathCommand("L", [x, y + radius_y]),
732 PathCommand(
733 "C",
734 [
735 x,
736 y + radius_y - ky,
737 x + radius_x - kx,
738 y,
739 x + radius_x,
740 y,
741 ],
742 ),
743 PathCommand("Z", []),
744 ]
745
746
747 def _ellipse_commands(
748 center_x: float,
749 center_y: float,
750 radius_x: float,
751 radius_y: float,
752 ) -> list[PathCommand]:
753 kx = radius_x * _BEZIER_QUARTER_K
754 ky = radius_y * _BEZIER_QUARTER_K
755 return [
756 PathCommand("M", [center_x + radius_x, center_y]),
757 PathCommand(
758 "C",
759 [
760 center_x + radius_x,
761 center_y + ky,
762 center_x + kx,
763 center_y + radius_y,
764 center_x,
765 center_y + radius_y,
766 ],
767 ),
768 PathCommand(
769 "C",
770 [
771 center_x - kx,
772 center_y + radius_y,
773 center_x - radius_x,
774 center_y + ky,
775 center_x - radius_x,
776 center_y,
777 ],
778 ),
779 PathCommand(
780 "C",
781 [
782 center_x - radius_x,
783 center_y - ky,
784 center_x - kx,
785 center_y - radius_y,
786 center_x,
787 center_y - radius_y,
788 ],
789 ),
790 PathCommand(
791 "C",
792 [
793 center_x + kx,
794 center_y - radius_y,
795 center_x + radius_x,
796 center_y - ky,
797 center_x + radius_x,
798 center_y,
799 ],
800 ),
801 PathCommand("Z", []),
802 ]
803
804
805 def _compact_preset_commands(
806 group: ET.Element,
807 matrix: AffineMatrix,
808 ) -> list[PathCommand]:
809 encoding = authored_preset_encoding(group)
810 if encoding != "compact":
811 raise ValueError(
812 f"Shape Boolean source {_element_label(group)} must be a compact "
813 "authored preset group"
814 )
815 errors = validate_authored_preset_group(group)
816 if errors:
817 raise ValueError(
818 f"Invalid compact authored preset {_element_label(group)}: "
819 + "; ".join(errors)
820 )
821 if group.get("data-pptx-object") != "shape":
822 raise ValueError(
823 f"Shape Boolean source {_element_label(group)} must be a shape, "
824 "not a connector"
825 )
826
827 frame = tuple(
828 float(value)
829 for value in (group.get("data-pptx-frame") or "").split()
830 )
831 if len(frame) != 4:
832 raise ValueError(
833 f"Compact authored preset {_element_label(group)} has an invalid "
834 "data-pptx-frame"
835 )
836 adjustments = {
837 name[len("data-pptx-av-"):]: value
838 for name, value in group.attrib.items()
839 if name.startswith("data-pptx-av-")
840 }
841 rendered = render_preset_geometry(
842 group.get("data-pptx-prst") or "",
843 Xfrm(x=frame[0], y=frame[1], w=frame[2], h=frame[3]),
844 adjustments,
845 )
846 children = list(group)
847 if len(children) != len(rendered.paths):
848 raise ValueError(
849 f"Compact authored preset {_element_label(group)} layer count "
850 "differs from its registry geometry"
851 )
852
853 commands: list[PathCommand] = []
854 for child, layer in zip(children, rendered.paths):
855 if layer.fill == "none":
856 continue
857 raw_path = child.get("d")
858 if raw_path is None:
859 raise ValueError(
860 f"Compact authored preset {_element_label(group)} contains a "
861 "path without d"
862 )
863 child_commands = normalize_path_commands(
864 svg_path_to_absolute(parse_svg_path(raw_path))
865 )
866 child_commands = _close_filled_commands(
867 child_commands,
868 f"{_element_label(group)} preset layer",
869 )
870 commands.extend(transform_path_commands(child_commands, matrix))
871 if not commands:
872 raise ValueError(
873 f"Compact authored preset {_element_label(group)} has no filled "
874 "silhouette"
875 )
876 return commands
877
878
879 def _close_filled_commands(
880 commands: Sequence[PathCommand],
881 label: str,
882 ) -> list[PathCommand]:
883 closed: list[PathCommand] = []
884 open_subpath = False
885 drew_segment = False
886 for command in commands:
887 if command.cmd == "M":
888 if open_subpath:
889 if not drew_segment:
890 raise ValueError(f"{label} has an empty subpath")
891 closed.append(PathCommand("Z", []))
892 open_subpath = True
893 drew_segment = False
894 elif command.cmd in {"L", "C"}:
895 if not open_subpath:
896 raise ValueError(f"{label} draws before its first move")
897 drew_segment = True
898 elif command.cmd == "Z":
899 if not open_subpath:
900 raise ValueError(f"{label} closes without an open subpath")
901 if not drew_segment:
902 raise ValueError(f"{label} has an empty subpath")
903 open_subpath = False
904 closed.append(command)
905 if open_subpath:
906 if not drew_segment:
907 raise ValueError(f"{label} has an empty subpath")
908 closed.append(PathCommand("Z", []))
909 if not closed:
910 raise ValueError(f"{label} has no drawable contour")
911 return closed
912
913
914 def _require_closed_commands(
915 commands: Sequence[PathCommand],
916 label: str,
917 ) -> None:
918 open_subpath = False
919 drew_segment = False
920 closed_contour = False
921 for command in commands:
922 if command.cmd == "M":
923 if open_subpath:
924 raise ValueError(f"{label} contains an open subpath")
925 open_subpath = True
926 drew_segment = False
927 elif command.cmd in {"L", "C"}:
928 if not open_subpath:
929 raise ValueError(f"{label} draws before its first move")
930 drew_segment = True
931 elif command.cmd == "Z":
932 if not open_subpath:
933 raise ValueError(f"{label} closes without an open subpath")
934 if not drew_segment:
935 raise ValueError(f"{label} has an empty subpath")
936 open_subpath = False
937 closed_contour = True
938 if open_subpath:
939 raise ValueError(f"{label} contains an open subpath")
940 if not closed_contour:
941 raise ValueError(f"{label} has no drawable closed contour")
942
943
944 def _commands_to_pathops(
945 commands: Sequence[PathCommand],
946 pathops: Any,
947 ) -> Any:
948 result = pathops.Path(fillType=pathops.FillType.WINDING)
949 for command in commands:
950 args = command.args
951 if command.cmd == "M":
952 result.moveTo(args[0], args[1])
953 elif command.cmd == "L":
954 result.lineTo(args[0], args[1])
955 elif command.cmd == "C":
956 result.cubicTo(*args)
957 elif command.cmd == "Z":
958 result.close()
959 else:
960 raise AssertionError(
961 f"Unexpected normalized path command: {command.cmd}"
962 )
963 return result
964
965
966 def _merge_paths(
967 operation: str,
968 operands: Sequence[Any],
969 pathops: Any,
970 ) -> Any:
971 operator_by_name = {
972 "combine": pathops.PathOp.XOR,
973 "intersect": pathops.PathOp.INTERSECTION,
974 "subtract": pathops.PathOp.DIFFERENCE,
975 "union": pathops.PathOp.UNION,
976 }
977 operator = operator_by_name[operation]
978 result = operands[0]
979 for operand in operands[1:]:
980 result = _path_op(result, operand, operator, pathops)
981 if _path_is_empty(result) and operation in {"intersect", "subtract"}:
982 break
983 return result
984
985
986 def _path_op(
987 first: Any,
988 second: Any,
989 operator: Any,
990 pathops: Any,
991 ) -> Any:
992 try:
993 return pathops.op(
994 first,
995 second,
996 operator,
997 fix_winding=True,
998 keep_starting_points=False,
999 clockwise=False,
1000 )
1001 except pathops.PathOpsError as exc:
1002 raise ValueError(f"Skia path Boolean operation failed: {exc}") from exc
1003
1004
1005 def _simplify_path(path: Any, pathops: Any) -> Any:
1006 """Resolve winding and self-intersections before filled-area checks."""
1007 try:
1008 return pathops.simplify(
1009 path,
1010 fix_winding=True,
1011 keep_starting_points=False,
1012 clockwise=False,
1013 )
1014 except pathops.PathOpsError as exc:
1015 raise ValueError(f"Skia path simplification failed: {exc}") from exc
1016
1017
1018 def _fragment_paths(
1019 operands: Sequence[Any],
1020 pathops: Any,
1021 ) -> list[Any]:
1022 pieces: list[Any] = []
1023 covered: Any | None = None
1024 for operand in operands:
1025 split_pieces: list[Any] = []
1026 for piece in pieces:
1027 difference = _path_op(
1028 piece,
1029 operand,
1030 pathops.PathOp.DIFFERENCE,
1031 pathops,
1032 )
1033 intersection = _path_op(
1034 piece,
1035 operand,
1036 pathops.PathOp.INTERSECTION,
1037 pathops,
1038 )
1039 if not _path_is_empty(difference):
1040 split_pieces.append(difference)
1041 if not _path_is_empty(intersection):
1042 split_pieces.append(intersection)
1043
1044 unique = (
1045 operand
1046 if covered is None
1047 else _path_op(
1048 operand,
1049 covered,
1050 pathops.PathOp.DIFFERENCE,
1051 pathops,
1052 )
1053 )
1054 if not _path_is_empty(unique):
1055 split_pieces.append(unique)
1056 pieces = split_pieces
1057 covered = (
1058 operand
1059 if covered is None
1060 else _path_op(
1061 covered,
1062 operand,
1063 pathops.PathOp.UNION,
1064 pathops,
1065 )
1066 )
1067
1068 components = [
1069 component
1070 for piece in pieces
1071 for component in _connected_components(piece, pathops)
1072 if not _path_is_empty(component)
1073 ]
1074 if not components:
1075 raise ValueError("Shape Boolean fragment produced no filled area")
1076 components.sort(key=_path_sort_key)
1077 return components
1078
1079
1080 def _connected_components(path: Any, pathops: Any) -> list[Any]:
1081 contours = [
1082 contour
1083 for contour in path.contours
1084 if not _path_is_empty(contour)
1085 ]
1086 if len(contours) <= 1:
1087 return contours
1088
1089 parents: list[int | None] = [None] * len(contours)
1090 for child_index, child in enumerate(contours):
1091 child_bounds = child.bounds
1092 child_points = child.firstPoints
1093 if child_bounds is None or not child_points:
1094 continue
1095 candidates: list[tuple[float, int]] = []
1096 for parent_index, candidate in enumerate(contours):
1097 if child_index == parent_index:
1098 continue
1099 candidate_bounds = candidate.bounds
1100 if candidate_bounds is None:
1101 continue
1102 if not _bounds_contain(candidate_bounds, child_bounds):
1103 continue
1104 if candidate.area <= child.area:
1105 continue
1106 if candidate.contains(child_points[0]):
1107 candidates.append((candidate.area, parent_index))
1108 if candidates:
1109 parents[child_index] = min(candidates)[1]
1110
1111 depths = [
1112 _contour_depth(index, parents)
1113 for index in range(len(contours))
1114 ]
1115 components: list[Any] = []
1116 for index, contour in enumerate(contours):
1117 if depths[index] % 2:
1118 continue
1119 component = pathops.Path(fillType=pathops.FillType.WINDING)
1120 component.addPath(contour)
1121 for child_index, parent_index in enumerate(parents):
1122 if parent_index == index and depths[child_index] % 2:
1123 component.addPath(contours[child_index])
1124 components.append(component)
1125 return components
1126
1127
1128 def _bounds_contain(
1129 outer: tuple[float, float, float, float],
1130 inner: tuple[float, float, float, float],
1131 ) -> bool:
1132 scale = max(
1133 *(abs(value) for value in outer),
1134 *(abs(value) for value in inner),
1135 1.0,
1136 )
1137 tolerance = scale * 1e-7
1138 return (
1139 outer[0] <= inner[0] + tolerance
1140 and outer[1] <= inner[1] + tolerance
1141 and outer[2] >= inner[2] - tolerance
1142 and outer[3] >= inner[3] - tolerance
1143 )
1144
1145
1146 def _contour_depth(
1147 index: int,
1148 parents: Sequence[int | None],
1149 ) -> int:
1150 depth = 0
1151 seen = {index}
1152 parent = parents[index]
1153 while parent is not None:
1154 if parent in seen:
1155 raise ValueError("Shape Boolean fragment produced cyclic contours")
1156 seen.add(parent)
1157 depth += 1
1158 parent = parents[parent]
1159 return depth
1160
1161
1162 def _path_is_empty(path: Any) -> bool:
1163 bounds = path.bounds
1164 if bounds is None or len(path) == 0:
1165 return True
1166 width = bounds[2] - bounds[0]
1167 height = bounds[3] - bounds[1]
1168 scale = max(abs(width), abs(height), 1.0)
1169 return (
1170 width <= 0
1171 or height <= 0
1172 or not math.isfinite(path.area)
1173 or path.area <= scale * scale * 1e-12
1174 )
1175
1176
1177 def _path_sort_key(
1178 path: Any,
1179 ) -> tuple[
1180 float,
1181 float,
1182 float,
1183 float,
1184 float,
1185 tuple[tuple[str, tuple[float, ...]], ...],
1186 ]:
1187 bounds = path.bounds
1188 signature = tuple(
1189 (
1190 str(verb),
1191 tuple(
1192 round(coordinate, _RESULT_PRECISION)
1193 for point in points
1194 for coordinate in point
1195 ),
1196 )
1197 for verb, points in path
1198 )
1199 if bounds is None:
1200 return (
1201 math.inf,
1202 math.inf,
1203 math.inf,
1204 math.inf,
1205 math.inf,
1206 signature,
1207 )
1208 return (
1209 round(bounds[1], _RESULT_PRECISION),
1210 round(bounds[0], _RESULT_PRECISION),
1211 round(bounds[3], _RESULT_PRECISION),
1212 round(bounds[2], _RESULT_PRECISION),
1213 round(path.area, _RESULT_PRECISION),
1214 signature,
1215 )
1216
1217
1218 def _effective_style(
1219 element: ET.Element,
1220 parents: Mapping[ET.Element, ET.Element],
1221 ) -> dict[str, str]:
1222 values: dict[str, str] = {}
1223 opacity = 1.0
1224 parent_opacity = 1.0
1225 chain = _element_chain(element, parents)
1226 for index, current in enumerate(chain):
1227 inline = parse_inline_style(current.get("style"))
1228 raw_opacity = inline.get("opacity", current.get("opacity"))
1229 if raw_opacity is None:
1230 local_opacity = 1.0
1231 elif raw_opacity.strip() == "inherit":
1232 local_opacity = parent_opacity
1233 else:
1234 local_opacity = parse_opacity(raw_opacity)
1235 if index > 0:
1236 opacity *= local_opacity
1237 parent_opacity = local_opacity
1238 for name in _PRESENTATION_ATTRS:
1239 if name == "opacity":
1240 continue
1241 raw = inline.get(name, current.get(name))
1242 if raw is not None and raw.strip() != "inherit":
1243 values[name] = raw.strip()
1244 if opacity < 1.0:
1245 values["opacity"] = _format_number(opacity)
1246 return {
1247 name: values[name]
1248 for name in _PRESENTATION_ATTRS
1249 if name in values
1250 }
1251
1252
1253 def _serialize_result_path(
1254 path: Any,
1255 result_id: str,
1256 style: Mapping[str, str],
1257 pathops: Any,
1258 ) -> str:
1259 path_data = _pathops_to_svg_path(path, pathops)
1260 attributes = {
1261 "id": result_id,
1262 "d": path_data,
1263 **style,
1264 }
1265 return f"<path{attrs_to_xml(attributes)}/>"
1266
1267
1268 def _pathops_to_svg_path(path: Any, pathops: Any) -> str:
1269 path.convertConicsToQuads(0.05)
1270 parts: list[str] = []
1271 current: tuple[float, float] | None = None
1272 subpath_start: tuple[float, float] | None = None
1273 open_subpath = False
1274
1275 for verb, points in path:
1276 if verb == pathops.PathVerb.MOVE:
1277 point = points[0]
1278 parts.append(
1279 f"M {_format_number(point[0])} {_format_number(point[1])}"
1280 )
1281 current = point
1282 subpath_start = point
1283 open_subpath = True
1284 elif verb == pathops.PathVerb.LINE:
1285 point = points[0]
1286 parts.append(
1287 f"L {_format_number(point[0])} {_format_number(point[1])}"
1288 )
1289 current = point
1290 elif verb == pathops.PathVerb.QUAD:
1291 if current is None:
1292 raise ValueError("Shape Boolean result quadratic has no start point")
1293 control, end = points
1294 control_1 = (
1295 current[0] + (control[0] - current[0]) * 2.0 / 3.0,
1296 current[1] + (control[1] - current[1]) * 2.0 / 3.0,
1297 )
1298 control_2 = (
1299 end[0] + (control[0] - end[0]) * 2.0 / 3.0,
1300 end[1] + (control[1] - end[1]) * 2.0 / 3.0,
1301 )
1302 parts.append(
1303 "C "
1304 f"{_format_point(control_1)} "
1305 f"{_format_point(control_2)} "
1306 f"{_format_point(end)}"
1307 )
1308 current = end
1309 elif verb == pathops.PathVerb.CUBIC:
1310 control_1, control_2, end = points
1311 parts.append(
1312 "C "
1313 f"{_format_point(control_1)} "
1314 f"{_format_point(control_2)} "
1315 f"{_format_point(end)}"
1316 )
1317 current = end
1318 elif verb == pathops.PathVerb.CLOSE:
1319 parts.append("Z")
1320 current = subpath_start
1321 open_subpath = False
1322 else:
1323 raise ValueError(f"Unsupported skia-pathops result verb: {verb}")
1324
1325 if open_subpath:
1326 raise ValueError("Shape Boolean engine returned an open contour")
1327 if not parts:
1328 raise ValueError("Shape Boolean engine returned no path commands")
1329 return " ".join(parts)
1330
1331
1332 def _format_point(point: tuple[float, float]) -> str:
1333 return f"{_format_number(point[0])} {_format_number(point[1])}"
1334
1335
1336 def _format_number(value: float) -> str:
1337 if not math.isfinite(value):
1338 raise ValueError("Shape Boolean result contains a non-finite coordinate")
1339 rounded = round(value, _RESULT_PRECISION)
1340 if abs(rounded) < 10 ** (-_RESULT_PRECISION):
1341 return "0"
1342 if rounded == int(rounded):
1343 return str(int(rounded))
1344 return f"{rounded:.{_RESULT_PRECISION}f}".rstrip("0").rstrip(".")
1345
1346
1347 def _local_name(tag: object) -> str:
1348 raw = str(tag)
1349 return raw.rsplit("}", 1)[-1] if "}" in raw else raw
1350
1351
1352 def _element_label(element: ET.Element) -> str:
1353 tag = _local_name(element.tag)
1354 element_id = element.get("id")
1355 return f"<{tag} id={element_id!r}>" if element_id else f"<{tag}>"
1356
1356 lines PYTHON