#!/usr/bin/env python3 """ PPT Master - Shape Boolean SVG Fragment Tool Combine closed SVG shapes or resolvable text outlines and print the resulting canonical SVG path fragment to stdout. Result geometry is in SVG-root coordinate space: replace the operands at their original z-order under the final semantic or structured parent, never under the old transformed ancestor. The source SVG is read-only; this tool never rewrites the page. Usage: python3 scripts/shape_boolean_svg.py render SVG_FILE \ --operation OPERATION --source ID --source ID --id OUTPUT_ID Examples: python3 scripts/shape_boolean_svg.py render slide.svg \ --operation intersect --source circle --source card --id overlap python3 scripts/shape_boolean_svg.py render slide.svg \ --operation subtract --source body --source cutout --id result \ --fill "#2563EB" --stroke none python3 scripts/shape_boolean_svg.py render cover.svg \ --operation subtract --source scrim --source chapter-number --id reveal Dependencies: skia-pathops, local PPT Master modules, and uharfbuzz for text operands """ from __future__ import annotations import argparse import math import sys from pathlib import Path from typing import Sequence from console_encoding import configure_utf8_stdio configure_utf8_stdio() def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description=( "Print the Boolean result of closed SVG shapes or resolvable text " "outlines as canonical SVG path fragments in SVG-root coordinate " "space. Insert the result at the original z-order under the final " "semantic or structured parent, never under the old transformed " "ancestor. The source file is never modified." ), formatter_class=argparse.RawDescriptionHelpFormatter, ) subparsers = parser.add_subparsers(dest="command", required=True) render_parser = subparsers.add_parser( "render", help="Print SVG-root-coordinate Boolean-result paths to stdout.", ) render_parser.add_argument( "svg_file", type=Path, help="Source SVG containing the operand elements.", ) render_parser.add_argument( "--operation", required=True, choices=("union", "combine", "fragment", "intersect", "subtract"), help="PowerPoint-compatible merge-shapes operation.", ) render_parser.add_argument( "--source", action="append", required=True, dest="source_ids", metavar="ID", help=( "Operand element id in merge order; repeat at least twice. " "The first operand is primary for subtract and style inheritance." ), ) render_parser.add_argument( "--id", required=True, dest="output_id", help="Stable id for the result, or the base id for fragment results.", ) render_parser.add_argument( "--font-dir", action="append", default=[], dest="font_dirs", type=Path, metavar="PATH", help=( "Additional font directory for text operands; repeat as needed. " "Explicit directories are searched before system font directories." ), ) render_parser.add_argument( "--fill", help="Override the primary shape's solid SVG fill, or use none.", ) render_parser.add_argument( "--fill-opacity", type=float, help="Override fill opacity from 0 to 1.", ) render_parser.add_argument( "--stroke", help="Override the primary shape's solid SVG stroke, or use none.", ) render_parser.add_argument( "--stroke-width", type=float, help="Override stroke width in SVG page units.", ) render_parser.add_argument( "--stroke-opacity", type=float, help="Override stroke opacity from 0 to 1.", ) return parser def main(argv: Sequence[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) try: style = _style_from_args(args) _validate_render_args(args) from svg_to_pptx.shape_boolean import ( render_boolean_svg_fragments, ) fragment = render_boolean_svg_fragments( args.svg_file, operation=args.operation, source_ids=args.source_ids, output_id=args.output_id, style=style or None, font_dirs=args.font_dirs, ) except (OSError, RuntimeError, ValueError) as exc: print(f"Error: {exc}", file=sys.stderr) return 1 print(fragment) return 0 def _validate_render_args(args: argparse.Namespace) -> None: if len(args.source_ids) < 2: raise ValueError("--source must be repeated at least twice") if len(set(args.source_ids)) != len(args.source_ids): raise ValueError("--source ids must be unique") if not args.output_id.strip(): raise ValueError("--id must not be empty") for font_dir in args.font_dirs: if not font_dir.is_dir(): raise ValueError(f"--font-dir is not a directory: {font_dir}") def _style_from_args(args: argparse.Namespace) -> dict[str, str]: style: dict[str, str] = {} if args.fill is not None: style["fill"] = args.fill if args.fill_opacity is not None: _validate_opacity("--fill-opacity", args.fill_opacity) style["fill-opacity"] = str(args.fill_opacity) if args.stroke is not None: style["stroke"] = args.stroke if args.stroke_width is not None: if not math.isfinite(args.stroke_width) or args.stroke_width < 0: raise ValueError("--stroke-width must be greater than or equal to 0") style["stroke-width"] = str(args.stroke_width) if args.stroke_opacity is not None: _validate_opacity("--stroke-opacity", args.stroke_opacity) style["stroke-opacity"] = str(args.stroke_opacity) return style def _validate_opacity(option: str, value: float) -> None: if not math.isfinite(value) or not 0 <= value <= 1: raise ValueError(f"{option} must be between 0 and 1") if __name__ == "__main__": raise SystemExit(main())