1356 lines
42 KiB
Python
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}>"
|