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

190 lines
6.2 KiB
Python

#!/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())