Files
Templated-PPT-Master/runtime/ppt-master/scripts/svg_to_pptx/shape_boolean.py
T

1356 lines
42 KiB
Python

#!/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 ``<output_id>-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 "
"<svg>; 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"<path{attrs_to_xml(attributes)}/>"
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}>"