#!/usr/bin/env python3 """ PPT Master - Shape Boolean Core Resolve closed SVG or text-outline operands into SVG-root-coordinate paths and apply PowerPoint-compatible merge-shapes operations without mutating the source. Callers insert returned paths at the original z-order under the final semantic or structured parent, never under the old transformed ancestor. Usage: Import render_boolean_svg_fragments from svg_to_pptx.shape_boolean. Examples: fragment = render_boolean_svg_fragments( "projects/demo/svg_output/slide-01.svg", operation="intersect", source_ids=("shape-a", "shape-b"), output_id="shape-overlap", ) Dependencies: skia-pathops, local PPT Master modules, and uharfbuzz for text operands """ from __future__ import annotations import math import re from collections.abc import Mapping, Sequence from pathlib import Path from typing import Any from xml.etree import ElementTree as ET from pptx_to_svg.emu_units import Xfrm from pptx_to_svg.preset_authoring import ( authored_preset_encoding, validate_authored_preset_group, ) from pptx_to_svg.preset_registry_to_svg import render_preset_geometry from pptx_to_svg.preset_svg_markup import attrs_to_xml from .drawingml.context import AffineMatrix, IDENTITY_MATRIX from .drawingml.paths import ( PathCommand, normalize_path_commands, parse_svg_path, parse_svg_points, svg_path_to_absolute, transform_path_commands, ) from .drawingml.utils import ( format_project_geometry_length, matrix_multiply, parse_inline_style, parse_opacity, parse_project_geometry_length, parse_project_stroke_dasharray, parse_project_stroke_enum, parse_transform_matrix, ) BOOLEAN_OPERATIONS = frozenset({ "combine", "fragment", "intersect", "subtract", "union", }) _BOOLEAN_TAGS = frozenset({"circle", "ellipse", "path", "polygon", "rect"}) _DEFINITION_TAGS = frozenset({ "clipPath", "defs", "marker", "mask", "pattern", "symbol", }) _ID_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_.:-]*") _STYLE_OVERRIDE_ATTRS = ( "fill", "fill-opacity", "opacity", "stroke", "stroke-dasharray", "stroke-width", "stroke-opacity", "stroke-linecap", "stroke-linejoin", ) _PRESENTATION_ATTRS = ( *_STYLE_OVERRIDE_ATTRS, "vector-effect", ) _ROUNDTRIP_TRANSPORT_ATTRS = frozenset({ "data-pptx-custgeom", "data-pptx-custgeom-ref", "data-pptx-geometry-kind", "data-pptx-native", "data-pptx-native-ref", "data-pptx-native-source", "data-pptx-native-status", "data-pptx-part", "data-pptx-shape-id", "data-pptx-shape-scope", "data-pptx-source-ref", }) _RESULT_PRECISION = 6 _BEZIER_QUARTER_K = 0.5522847498307936 def render_boolean_svg_fragments( svg_file: str | Path, *, operation: str, source_ids: Sequence[str], output_id: str, style: Mapping[str, str] | None = None, font_dirs: Sequence[str | Path] = (), ) -> str: """Return canonical SVG path fragments for one merge-shapes operation. The first source is the primary shape: subtract removes all later sources from it, and every result inherits its effective presentation attributes. All transforms are baked into SVG-root coordinates, so callers insert the result at the original z-order under the final semantic or structured parent, never under the old transformed ancestor. Fragment returns top-to-bottom, left-to-right sibling paths named ``-1`` onward; every other operation returns one path named exactly ``output_id``. """ if not isinstance(operation, str): raise ValueError("Shape Boolean operation must be a string") normalized_operation = operation.strip().lower() if normalized_operation not in BOOLEAN_OPERATIONS: allowed = ", ".join(sorted(BOOLEAN_OPERATIONS)) raise ValueError( f"Unsupported shape Boolean operation {operation!r}; use {allowed}" ) source_id_list = _validate_source_ids(source_ids) _validate_output_id(output_id) style_overrides = _validate_style_overrides(style) source_path = Path(svg_file) try: root = ET.parse(source_path).getroot() except ET.ParseError as exc: raise ValueError(f"Cannot parse SVG {source_path}: {exc}") from exc if _local_name(root.tag) != "svg": raise ValueError(f"Shape Boolean source must have an SVG root: {source_path}") if any(_local_name(element.tag) == "style" for element in root.iter()): raise ValueError( "Shape Boolean does not resolve embedded CSS; use explicit SVG " "presentation attributes on the operands" ) parents = { child: parent for parent in root.iter() for child in list(parent) } elements_by_id, duplicate_ids = _index_element_ids(root) selected: list[ET.Element] = [] for source_id in source_id_list: if source_id in duplicate_ids: raise ValueError( f"Shape Boolean source id {source_id!r} is not unique" ) element = elements_by_id.get(source_id) if element is None: raise ValueError(f"Shape Boolean source id not found: {source_id!r}") selected.append(element) pathops = _load_pathops() operands = [ _element_to_pathops( element, parents, pathops, font_dirs=font_dirs, ) for element in selected ] inherited_style = _materialize_baked_stroke_style( _effective_style(selected[0], parents), _combined_transform(_element_chain(selected[0], parents)), ) inherited_style.update(style_overrides) if normalized_operation == "fragment": results = _fragment_paths(operands, pathops) fragments: list[str] = [] for index, result in enumerate(results, start=1): result_id = f"{output_id}-{index}" _validate_result_id_collision( result_id, elements_by_id, source_id_list, ) fragments.append( _serialize_result_path( result, result_id, inherited_style, pathops, ) ) return "\n".join(fragments) result = _merge_paths(normalized_operation, operands, pathops) if _path_is_empty(result): raise ValueError( f"Shape Boolean {normalized_operation} produced no filled area" ) _validate_result_id_collision(output_id, elements_by_id, source_id_list) return _serialize_result_path( result, output_id, inherited_style, pathops, ) def _validate_source_ids(source_ids: Sequence[str]) -> list[str]: if isinstance(source_ids, (str, bytes)): raise ValueError("Shape Boolean source_ids must be a sequence of ids") values = list(source_ids) if len(values) < 2: raise ValueError("Shape Boolean requires at least two source ids") if any(not isinstance(value, str) or not value for value in values): raise ValueError("Shape Boolean source ids must be non-empty strings") if len(set(values)) != len(values): raise ValueError("Shape Boolean source ids must be unique") return values def _validate_output_id(output_id: str) -> None: if not isinstance(output_id, str) or _ID_RE.fullmatch(output_id) is None: raise ValueError(f"Invalid SVG output id: {output_id!r}") def _validate_style_overrides( style: Mapping[str, str] | None, ) -> dict[str, str]: if style is None: return {} unknown = sorted(set(style) - set(_STYLE_OVERRIDE_ATTRS)) if unknown: raise ValueError( "Unsupported shape Boolean style override(s): " + ", ".join(unknown) ) normalized: dict[str, str] = {} for name in _STYLE_OVERRIDE_ATTRS: if name not in style: continue value = style[name] if not isinstance(value, str) or not value.strip(): raise ValueError( f"Shape Boolean style override {name!r} must be a non-empty string" ) normalized[name] = value.strip() return normalized def _index_element_ids( root: ET.Element, ) -> tuple[dict[str, ET.Element], set[str]]: elements: dict[str, ET.Element] = {} duplicates: set[str] = set() for element in root.iter(): element_id = element.get("id") if not element_id: continue if element_id in elements: duplicates.add(element_id) else: elements[element_id] = element return elements, duplicates def _validate_result_id_collision( result_id: str, elements_by_id: Mapping[str, ET.Element], source_ids: Sequence[str], ) -> None: if result_id in elements_by_id and result_id not in source_ids: raise ValueError( f"Shape Boolean result id already exists outside the sources: " f"{result_id!r}" ) def _load_pathops() -> Any: try: import pathops except ImportError as exc: raise RuntimeError( "Shape Boolean operations require skia-pathops. Install the " "project requirements, or run: pip install skia-pathops" ) from exc required = ( "FillType", "Path", "PathOp", "PathOpsError", "PathVerb", "op", "simplify", ) missing = [name for name in required if not hasattr(pathops, name)] if missing: raise RuntimeError( "Installed skia-pathops is incompatible; missing: " + ", ".join(missing) ) return pathops def _element_to_pathops( element: ET.Element, parents: Mapping[ET.Element, ET.Element], pathops: Any, *, font_dirs: Sequence[str | Path] = (), ) -> Any: chain = _element_chain(element, parents) _validate_source_location(element, chain) matrix = _combined_transform(chain) tag = _local_name(element.tag) if tag == "g": commands = _compact_preset_commands(element, matrix) elif tag in _BOOLEAN_TAGS: commands = _shape_commands(element) commands = transform_path_commands(commands, matrix) elif tag == "text": from .text_outline import text_element_to_path_commands commands = text_element_to_path_commands( element, parents, font_dirs=font_dirs, ) commands = transform_path_commands(commands, matrix) else: supported = ", ".join((*sorted(_BOOLEAN_TAGS), "text")) raise ValueError( f"Shape Boolean source {_element_label(element)} must be a " f"supported {supported}, or a compact authored preset group" ) result = _simplify_path(_commands_to_pathops(commands, pathops), pathops) if _path_is_empty(result): raise ValueError( f"Shape Boolean source {_element_label(element)} has no filled area" ) return result def _element_chain( element: ET.Element, parents: Mapping[ET.Element, ET.Element], ) -> list[ET.Element]: chain = [element] current = element while current in parents: current = parents[current] chain.append(current) chain.reverse() return chain def _validate_source_location( element: ET.Element, chain: Sequence[ET.Element], ) -> None: definition_ancestor = next( ( ancestor for ancestor in chain[:-1] if _local_name(ancestor.tag) in _DEFINITION_TAGS ), None, ) if definition_ancestor is not None: raise ValueError( f"Shape Boolean source {_element_label(element)} is inside " f"<{_local_name(definition_ancestor.tag)}>" ) nested_svg_count = sum( 1 for ancestor in chain if _local_name(ancestor.tag) == "svg" ) if nested_svg_count > 1: raise ValueError( f"Shape Boolean source {_element_label(element)} is inside a nested " "; materialize its viewport transform first" ) for ancestor in chain[:-1]: if ( _local_name(ancestor.tag) == "g" and authored_preset_encoding(ancestor) is not None ): raise ValueError( f"Shape Boolean source {_element_label(element)} is inside an " "authored preset; select the preset group's id" ) _validate_geometry_presentation(element, chain) def _validate_geometry_presentation( element: ET.Element, chain: Sequence[ET.Element], ) -> None: fill_rule = "nonzero" for current in chain: if current.get("class") is not None: raise ValueError( f"Shape Boolean source {_element_label(element)} uses class; " "materialize its computed presentation first" ) transport_attrs = sorted( name for name in _ROUNDTRIP_TRANSPORT_ATTRS if current.get(name) is not None ) if transport_attrs: raise ValueError( f"Shape Boolean source {_element_label(element)} carries " "PPTX import/round-trip metadata " f"({', '.join(transport_attrs)}); author or materialize an " "ordinary SVG shape first" ) inline = parse_inline_style(current.get("style")) inline_transform = inline.get("transform") if ( inline_transform is not None and inline_transform.strip().lower() not in {"", "none"} ): raise ValueError( f"Shape Boolean source {_element_label(element)} uses a CSS " "transform; write it as an SVG transform attribute first" ) display = inline.get("display", current.get("display")) if display is not None and display.strip().lower() == "none": raise ValueError( f"Shape Boolean source {_element_label(element)} is not " "displayed" ) visibility = inline.get("visibility", current.get("visibility")) if visibility is not None and visibility.strip().lower() in { "collapse", "hidden", }: raise ValueError( f"Shape Boolean source {_element_label(element)} is not visible" ) for name in ("clip-path", "mask"): raw = inline.get(name, current.get(name)) if raw is not None and raw.strip().lower() not in {"", "none"}: raise ValueError( f"Shape Boolean source {_element_label(element)} uses " f"{name}; materialize the visible contour first" ) raw_filter = inline.get("filter", current.get("filter")) if raw_filter is not None and raw_filter.strip().lower() not in { "", "none", }: raise ValueError( f"Shape Boolean source {_element_label(element)} uses filter; " "reapply the effect after materializing the result" ) raw_dash_offset = inline.get( "stroke-dashoffset", current.get("stroke-dashoffset"), ) if raw_dash_offset is not None: raise ValueError( f"Shape Boolean source {_element_label(element)} uses " "stroke-dashoffset; dashed-arc phase cannot be materialized " "as filled Boolean geometry" ) raw_fill_rule = inline.get("fill-rule", current.get("fill-rule")) if ( raw_fill_rule is not None and raw_fill_rule.strip().lower() != "inherit" ): fill_rule = raw_fill_rule.strip().lower() if fill_rule != "nonzero": raise ValueError( f"Shape Boolean source {_element_label(element)} uses " f"fill-rule={fill_rule!r}; use explicit nonzero contour direction" ) def _combined_transform(chain: Sequence[ET.Element]) -> AffineMatrix: matrix = IDENTITY_MATRIX for element in chain[1:]: transform = element.get("transform") if transform: matrix = matrix_multiply( matrix, parse_transform_matrix(transform), ) return matrix def _materialize_baked_stroke_style( style: Mapping[str, str], matrix: AffineMatrix, ) -> dict[str, str]: """Keep stroke metrics visually stable after baking a geometry transform.""" materialized = dict(style) raw_vector_effect = materialized.get("vector-effect") vector_effect = None if raw_vector_effect is not None: try: vector_effect = parse_project_stroke_enum( "vector-effect", raw_vector_effect, ) except ValueError as exc: raise ValueError( f"Shape Boolean primary vector-effect " f"{raw_vector_effect!r} {exc}" ) from exc materialized["vector-effect"] = vector_effect stroke = materialized.get("stroke") if not stroke or stroke.strip().lower() in {"none", "transparent"}: return materialized if vector_effect == "non-scaling-stroke": return materialized a, b, c, d, _e, _f = matrix scale = math.sqrt(abs(a * d - b * c)) if not math.isfinite(scale): raise ValueError( "Shape Boolean primary transform produces a non-finite stroke scale" ) if scale == 1.0: return materialized raw_width = materialized.get("stroke-width", "1") try: source_width = parse_project_geometry_length( raw_width, "stroke-width", ) except ValueError as exc: raise ValueError( f"Shape Boolean primary stroke-width {raw_width!r} {exc}" ) from exc materialized["stroke-width"] = format_project_geometry_length( source_width * scale ) raw_dasharray = materialized.get("stroke-dasharray") if raw_dasharray is None or raw_dasharray.strip().lower() == "none": return materialized try: parsed_dasharray = parse_project_stroke_dasharray(raw_dasharray) except ValueError as exc: raise ValueError( f"Shape Boolean primary stroke-dasharray {raw_dasharray!r} {exc}" ) from exc if parsed_dasharray is not None: _preset, values = parsed_dasharray materialized["stroke-dasharray"] = " ".join( format_project_geometry_length(value * scale) for value in values ) return materialized def _shape_commands(element: ET.Element) -> list[PathCommand]: tag = _local_name(element.tag) if tag == "path": raw_path = element.get("d") if raw_path is None: raise ValueError(f"{_element_label(element)} requires d") commands = normalize_path_commands( svg_path_to_absolute(parse_svg_path(raw_path)) ) _require_closed_commands(commands, _element_label(element)) return commands if tag == "polygon": points = parse_svg_points( element.get("points", ""), min_points=3, ) commands = [PathCommand("M", [*points[0]])] commands.extend( PathCommand("L", [x, y]) for x, y in points[1:] ) commands.append(PathCommand("Z", [])) return commands if tag == "rect": return _rect_commands(element) if tag == "circle": cx = _geometry_value(element, "cx", default=0.0) cy = _geometry_value(element, "cy", default=0.0) radius = _geometry_value(element, "r", required=True) if radius <= 0: raise ValueError(f"{_element_label(element)} r must be greater than 0") return _ellipse_commands(cx, cy, radius, radius) if tag == "ellipse": cx = _geometry_value(element, "cx", default=0.0) cy = _geometry_value(element, "cy", default=0.0) radius_x = _geometry_value(element, "rx", required=True) radius_y = _geometry_value(element, "ry", required=True) if radius_x <= 0 or radius_y <= 0: raise ValueError( f"{_element_label(element)} rx and ry must be greater than 0" ) return _ellipse_commands(cx, cy, radius_x, radius_y) raise AssertionError(f"Unhandled Boolean source tag: {tag}") def _geometry_value( element: ET.Element, attribute: str, *, default: float | None = None, required: bool = False, ) -> float: raw = element.get(attribute) if raw is None: if required: raise ValueError(f"{_element_label(element)} requires {attribute}") if default is None: raise AssertionError("Geometry attribute requires a default") return default try: return parse_project_geometry_length(raw, attribute) except ValueError as exc: raise ValueError( f"{_element_label(element)} {attribute}: {exc}" ) from exc def _rect_commands(element: ET.Element) -> list[PathCommand]: x = _geometry_value(element, "x", default=0.0) y = _geometry_value(element, "y", default=0.0) width = _geometry_value(element, "width", required=True) height = _geometry_value(element, "height", required=True) if width <= 0 or height <= 0: raise ValueError( f"{_element_label(element)} width and height must be greater than 0" ) raw_rx = element.get("rx") raw_ry = element.get("ry") radius_x = ( _geometry_value(element, "rx", required=True) if raw_rx is not None else 0.0 ) radius_y = ( _geometry_value(element, "ry", required=True) if raw_ry is not None else 0.0 ) if raw_rx is not None and raw_ry is None: radius_y = radius_x elif raw_ry is not None and raw_rx is None: radius_x = radius_y if radius_x < 0 or radius_y < 0: raise ValueError( f"{_element_label(element)} rx and ry must not be negative" ) radius_x = min(radius_x, width / 2.0) radius_y = min(radius_y, height / 2.0) if radius_x <= 0 or radius_y <= 0: return [ PathCommand("M", [x, y]), PathCommand("L", [x + width, y]), PathCommand("L", [x + width, y + height]), PathCommand("L", [x, y + height]), PathCommand("Z", []), ] kx = radius_x * _BEZIER_QUARTER_K ky = radius_y * _BEZIER_QUARTER_K right = x + width bottom = y + height return [ PathCommand("M", [x + radius_x, y]), PathCommand("L", [right - radius_x, y]), PathCommand( "C", [ right - radius_x + kx, y, right, y + radius_y - ky, right, y + radius_y, ], ), PathCommand("L", [right, bottom - radius_y]), PathCommand( "C", [ right, bottom - radius_y + ky, right - radius_x + kx, bottom, right - radius_x, bottom, ], ), PathCommand("L", [x + radius_x, bottom]), PathCommand( "C", [ x + radius_x - kx, bottom, x, bottom - radius_y + ky, x, bottom - radius_y, ], ), PathCommand("L", [x, y + radius_y]), PathCommand( "C", [ x, y + radius_y - ky, x + radius_x - kx, y, x + radius_x, y, ], ), PathCommand("Z", []), ] def _ellipse_commands( center_x: float, center_y: float, radius_x: float, radius_y: float, ) -> list[PathCommand]: kx = radius_x * _BEZIER_QUARTER_K ky = radius_y * _BEZIER_QUARTER_K return [ PathCommand("M", [center_x + radius_x, center_y]), PathCommand( "C", [ center_x + radius_x, center_y + ky, center_x + kx, center_y + radius_y, center_x, center_y + radius_y, ], ), PathCommand( "C", [ center_x - kx, center_y + radius_y, center_x - radius_x, center_y + ky, center_x - radius_x, center_y, ], ), PathCommand( "C", [ center_x - radius_x, center_y - ky, center_x - kx, center_y - radius_y, center_x, center_y - radius_y, ], ), PathCommand( "C", [ center_x + kx, center_y - radius_y, center_x + radius_x, center_y - ky, center_x + radius_x, center_y, ], ), PathCommand("Z", []), ] def _compact_preset_commands( group: ET.Element, matrix: AffineMatrix, ) -> list[PathCommand]: encoding = authored_preset_encoding(group) if encoding != "compact": raise ValueError( f"Shape Boolean source {_element_label(group)} must be a compact " "authored preset group" ) errors = validate_authored_preset_group(group) if errors: raise ValueError( f"Invalid compact authored preset {_element_label(group)}: " + "; ".join(errors) ) if group.get("data-pptx-object") != "shape": raise ValueError( f"Shape Boolean source {_element_label(group)} must be a shape, " "not a connector" ) frame = tuple( float(value) for value in (group.get("data-pptx-frame") or "").split() ) if len(frame) != 4: raise ValueError( f"Compact authored preset {_element_label(group)} has an invalid " "data-pptx-frame" ) adjustments = { name[len("data-pptx-av-"):]: value for name, value in group.attrib.items() if name.startswith("data-pptx-av-") } rendered = render_preset_geometry( group.get("data-pptx-prst") or "", Xfrm(x=frame[0], y=frame[1], w=frame[2], h=frame[3]), adjustments, ) children = list(group) if len(children) != len(rendered.paths): raise ValueError( f"Compact authored preset {_element_label(group)} layer count " "differs from its registry geometry" ) commands: list[PathCommand] = [] for child, layer in zip(children, rendered.paths): if layer.fill == "none": continue raw_path = child.get("d") if raw_path is None: raise ValueError( f"Compact authored preset {_element_label(group)} contains a " "path without d" ) child_commands = normalize_path_commands( svg_path_to_absolute(parse_svg_path(raw_path)) ) child_commands = _close_filled_commands( child_commands, f"{_element_label(group)} preset layer", ) commands.extend(transform_path_commands(child_commands, matrix)) if not commands: raise ValueError( f"Compact authored preset {_element_label(group)} has no filled " "silhouette" ) return commands def _close_filled_commands( commands: Sequence[PathCommand], label: str, ) -> list[PathCommand]: closed: list[PathCommand] = [] open_subpath = False drew_segment = False for command in commands: if command.cmd == "M": if open_subpath: if not drew_segment: raise ValueError(f"{label} has an empty subpath") closed.append(PathCommand("Z", [])) open_subpath = True drew_segment = False elif command.cmd in {"L", "C"}: if not open_subpath: raise ValueError(f"{label} draws before its first move") drew_segment = True elif command.cmd == "Z": if not open_subpath: raise ValueError(f"{label} closes without an open subpath") if not drew_segment: raise ValueError(f"{label} has an empty subpath") open_subpath = False closed.append(command) if open_subpath: if not drew_segment: raise ValueError(f"{label} has an empty subpath") closed.append(PathCommand("Z", [])) if not closed: raise ValueError(f"{label} has no drawable contour") return closed def _require_closed_commands( commands: Sequence[PathCommand], label: str, ) -> None: open_subpath = False drew_segment = False closed_contour = False for command in commands: if command.cmd == "M": if open_subpath: raise ValueError(f"{label} contains an open subpath") open_subpath = True drew_segment = False elif command.cmd in {"L", "C"}: if not open_subpath: raise ValueError(f"{label} draws before its first move") drew_segment = True elif command.cmd == "Z": if not open_subpath: raise ValueError(f"{label} closes without an open subpath") if not drew_segment: raise ValueError(f"{label} has an empty subpath") open_subpath = False closed_contour = True if open_subpath: raise ValueError(f"{label} contains an open subpath") if not closed_contour: raise ValueError(f"{label} has no drawable closed contour") def _commands_to_pathops( commands: Sequence[PathCommand], pathops: Any, ) -> Any: result = pathops.Path(fillType=pathops.FillType.WINDING) for command in commands: args = command.args if command.cmd == "M": result.moveTo(args[0], args[1]) elif command.cmd == "L": result.lineTo(args[0], args[1]) elif command.cmd == "C": result.cubicTo(*args) elif command.cmd == "Z": result.close() else: raise AssertionError( f"Unexpected normalized path command: {command.cmd}" ) return result def _merge_paths( operation: str, operands: Sequence[Any], pathops: Any, ) -> Any: operator_by_name = { "combine": pathops.PathOp.XOR, "intersect": pathops.PathOp.INTERSECTION, "subtract": pathops.PathOp.DIFFERENCE, "union": pathops.PathOp.UNION, } operator = operator_by_name[operation] result = operands[0] for operand in operands[1:]: result = _path_op(result, operand, operator, pathops) if _path_is_empty(result) and operation in {"intersect", "subtract"}: break return result def _path_op( first: Any, second: Any, operator: Any, pathops: Any, ) -> Any: try: return pathops.op( first, second, operator, fix_winding=True, keep_starting_points=False, clockwise=False, ) except pathops.PathOpsError as exc: raise ValueError(f"Skia path Boolean operation failed: {exc}") from exc def _simplify_path(path: Any, pathops: Any) -> Any: """Resolve winding and self-intersections before filled-area checks.""" try: return pathops.simplify( path, fix_winding=True, keep_starting_points=False, clockwise=False, ) except pathops.PathOpsError as exc: raise ValueError(f"Skia path simplification failed: {exc}") from exc def _fragment_paths( operands: Sequence[Any], pathops: Any, ) -> list[Any]: pieces: list[Any] = [] covered: Any | None = None for operand in operands: split_pieces: list[Any] = [] for piece in pieces: difference = _path_op( piece, operand, pathops.PathOp.DIFFERENCE, pathops, ) intersection = _path_op( piece, operand, pathops.PathOp.INTERSECTION, pathops, ) if not _path_is_empty(difference): split_pieces.append(difference) if not _path_is_empty(intersection): split_pieces.append(intersection) unique = ( operand if covered is None else _path_op( operand, covered, pathops.PathOp.DIFFERENCE, pathops, ) ) if not _path_is_empty(unique): split_pieces.append(unique) pieces = split_pieces covered = ( operand if covered is None else _path_op( covered, operand, pathops.PathOp.UNION, pathops, ) ) components = [ component for piece in pieces for component in _connected_components(piece, pathops) if not _path_is_empty(component) ] if not components: raise ValueError("Shape Boolean fragment produced no filled area") components.sort(key=_path_sort_key) return components def _connected_components(path: Any, pathops: Any) -> list[Any]: contours = [ contour for contour in path.contours if not _path_is_empty(contour) ] if len(contours) <= 1: return contours parents: list[int | None] = [None] * len(contours) for child_index, child in enumerate(contours): child_bounds = child.bounds child_points = child.firstPoints if child_bounds is None or not child_points: continue candidates: list[tuple[float, int]] = [] for parent_index, candidate in enumerate(contours): if child_index == parent_index: continue candidate_bounds = candidate.bounds if candidate_bounds is None: continue if not _bounds_contain(candidate_bounds, child_bounds): continue if candidate.area <= child.area: continue if candidate.contains(child_points[0]): candidates.append((candidate.area, parent_index)) if candidates: parents[child_index] = min(candidates)[1] depths = [ _contour_depth(index, parents) for index in range(len(contours)) ] components: list[Any] = [] for index, contour in enumerate(contours): if depths[index] % 2: continue component = pathops.Path(fillType=pathops.FillType.WINDING) component.addPath(contour) for child_index, parent_index in enumerate(parents): if parent_index == index and depths[child_index] % 2: component.addPath(contours[child_index]) components.append(component) return components def _bounds_contain( outer: tuple[float, float, float, float], inner: tuple[float, float, float, float], ) -> bool: scale = max( *(abs(value) for value in outer), *(abs(value) for value in inner), 1.0, ) tolerance = scale * 1e-7 return ( outer[0] <= inner[0] + tolerance and outer[1] <= inner[1] + tolerance and outer[2] >= inner[2] - tolerance and outer[3] >= inner[3] - tolerance ) def _contour_depth( index: int, parents: Sequence[int | None], ) -> int: depth = 0 seen = {index} parent = parents[index] while parent is not None: if parent in seen: raise ValueError("Shape Boolean fragment produced cyclic contours") seen.add(parent) depth += 1 parent = parents[parent] return depth def _path_is_empty(path: Any) -> bool: bounds = path.bounds if bounds is None or len(path) == 0: return True width = bounds[2] - bounds[0] height = bounds[3] - bounds[1] scale = max(abs(width), abs(height), 1.0) return ( width <= 0 or height <= 0 or not math.isfinite(path.area) or path.area <= scale * scale * 1e-12 ) def _path_sort_key( path: Any, ) -> tuple[ float, float, float, float, float, tuple[tuple[str, tuple[float, ...]], ...], ]: bounds = path.bounds signature = tuple( ( str(verb), tuple( round(coordinate, _RESULT_PRECISION) for point in points for coordinate in point ), ) for verb, points in path ) if bounds is None: return ( math.inf, math.inf, math.inf, math.inf, math.inf, signature, ) return ( round(bounds[1], _RESULT_PRECISION), round(bounds[0], _RESULT_PRECISION), round(bounds[3], _RESULT_PRECISION), round(bounds[2], _RESULT_PRECISION), round(path.area, _RESULT_PRECISION), signature, ) def _effective_style( element: ET.Element, parents: Mapping[ET.Element, ET.Element], ) -> dict[str, str]: values: dict[str, str] = {} opacity = 1.0 parent_opacity = 1.0 chain = _element_chain(element, parents) for index, current in enumerate(chain): inline = parse_inline_style(current.get("style")) raw_opacity = inline.get("opacity", current.get("opacity")) if raw_opacity is None: local_opacity = 1.0 elif raw_opacity.strip() == "inherit": local_opacity = parent_opacity else: local_opacity = parse_opacity(raw_opacity) if index > 0: opacity *= local_opacity parent_opacity = local_opacity for name in _PRESENTATION_ATTRS: if name == "opacity": continue raw = inline.get(name, current.get(name)) if raw is not None and raw.strip() != "inherit": values[name] = raw.strip() if opacity < 1.0: values["opacity"] = _format_number(opacity) return { name: values[name] for name in _PRESENTATION_ATTRS if name in values } def _serialize_result_path( path: Any, result_id: str, style: Mapping[str, str], pathops: Any, ) -> str: path_data = _pathops_to_svg_path(path, pathops) attributes = { "id": result_id, "d": path_data, **style, } return f"" def _pathops_to_svg_path(path: Any, pathops: Any) -> str: path.convertConicsToQuads(0.05) parts: list[str] = [] current: tuple[float, float] | None = None subpath_start: tuple[float, float] | None = None open_subpath = False for verb, points in path: if verb == pathops.PathVerb.MOVE: point = points[0] parts.append( f"M {_format_number(point[0])} {_format_number(point[1])}" ) current = point subpath_start = point open_subpath = True elif verb == pathops.PathVerb.LINE: point = points[0] parts.append( f"L {_format_number(point[0])} {_format_number(point[1])}" ) current = point elif verb == pathops.PathVerb.QUAD: if current is None: raise ValueError("Shape Boolean result quadratic has no start point") control, end = points control_1 = ( current[0] + (control[0] - current[0]) * 2.0 / 3.0, current[1] + (control[1] - current[1]) * 2.0 / 3.0, ) control_2 = ( end[0] + (control[0] - end[0]) * 2.0 / 3.0, end[1] + (control[1] - end[1]) * 2.0 / 3.0, ) parts.append( "C " f"{_format_point(control_1)} " f"{_format_point(control_2)} " f"{_format_point(end)}" ) current = end elif verb == pathops.PathVerb.CUBIC: control_1, control_2, end = points parts.append( "C " f"{_format_point(control_1)} " f"{_format_point(control_2)} " f"{_format_point(end)}" ) current = end elif verb == pathops.PathVerb.CLOSE: parts.append("Z") current = subpath_start open_subpath = False else: raise ValueError(f"Unsupported skia-pathops result verb: {verb}") if open_subpath: raise ValueError("Shape Boolean engine returned an open contour") if not parts: raise ValueError("Shape Boolean engine returned no path commands") return " ".join(parts) def _format_point(point: tuple[float, float]) -> str: return f"{_format_number(point[0])} {_format_number(point[1])}" def _format_number(value: float) -> str: if not math.isfinite(value): raise ValueError("Shape Boolean result contains a non-finite coordinate") rounded = round(value, _RESULT_PRECISION) if abs(rounded) < 10 ** (-_RESULT_PRECISION): return "0" if rounded == int(rounded): return str(int(rounded)) return f"{rounded:.{_RESULT_PRECISION}f}".rstrip("0").rstrip(".") def _local_name(tag: object) -> str: raw = str(tag) return raw.rsplit("}", 1)[-1] if "}" in raw else raw def _element_label(element: ET.Element) -> str: tag = _local_name(element.tag) element_id = element.get("id") return f"<{tag} id={element_id!r}>" if element_id else f"<{tag}>"