返回 ppt-master
shape_boolean_svg.py
根目录 / skills / ppt-master / scripts / shape_boolean_svg.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - Shape Boolean SVG Fragment Tool
4
5 Combine closed SVG shapes or resolvable text outlines and print the resulting
6 canonical SVG path fragment to stdout. Result geometry is in SVG-root coordinate
7 space: replace the operands at their original z-order under the final semantic
8 or structured parent, never under the old transformed ancestor. The source SVG
9 is read-only; this tool never rewrites the page.
10
11 Usage:
12 python3 scripts/shape_boolean_svg.py render SVG_FILE \
13 --operation OPERATION --source ID --source ID --id OUTPUT_ID
14
15 Examples:
16 python3 scripts/shape_boolean_svg.py render slide.svg \
17 --operation intersect --source circle --source card --id overlap
18 python3 scripts/shape_boolean_svg.py render slide.svg \
19 --operation subtract --source body --source cutout --id result \
20 --fill "#2563EB" --stroke none
21 python3 scripts/shape_boolean_svg.py render cover.svg \
22 --operation subtract --source scrim --source chapter-number --id reveal
23
24 Dependencies:
25 skia-pathops, local PPT Master modules, and uharfbuzz for text operands
26 """
27
28 from __future__ import annotations
29
30 import argparse
31 import math
32 import sys
33 from pathlib import Path
34 from typing import Sequence
35
36 from console_encoding import configure_utf8_stdio
37
38
39 configure_utf8_stdio()
40
41
42 def build_parser() -> argparse.ArgumentParser:
43 parser = argparse.ArgumentParser(
44 description=(
45 "Print the Boolean result of closed SVG shapes or resolvable text "
46 "outlines as canonical SVG path fragments in SVG-root coordinate "
47 "space. Insert the result at the original z-order under the final "
48 "semantic or structured parent, never under the old transformed "
49 "ancestor. The source file is never modified."
50 ),
51 formatter_class=argparse.RawDescriptionHelpFormatter,
52 )
53 subparsers = parser.add_subparsers(dest="command", required=True)
54
55 render_parser = subparsers.add_parser(
56 "render",
57 help="Print SVG-root-coordinate Boolean-result paths to stdout.",
58 )
59 render_parser.add_argument(
60 "svg_file",
61 type=Path,
62 help="Source SVG containing the operand elements.",
63 )
64 render_parser.add_argument(
65 "--operation",
66 required=True,
67 choices=("union", "combine", "fragment", "intersect", "subtract"),
68 help="PowerPoint-compatible merge-shapes operation.",
69 )
70 render_parser.add_argument(
71 "--source",
72 action="append",
73 required=True,
74 dest="source_ids",
75 metavar="ID",
76 help=(
77 "Operand element id in merge order; repeat at least twice. "
78 "The first operand is primary for subtract and style inheritance."
79 ),
80 )
81 render_parser.add_argument(
82 "--id",
83 required=True,
84 dest="output_id",
85 help="Stable id for the result, or the base id for fragment results.",
86 )
87 render_parser.add_argument(
88 "--font-dir",
89 action="append",
90 default=[],
91 dest="font_dirs",
92 type=Path,
93 metavar="PATH",
94 help=(
95 "Additional font directory for text operands; repeat as needed. "
96 "Explicit directories are searched before system font directories."
97 ),
98 )
99 render_parser.add_argument(
100 "--fill",
101 help="Override the primary shape's solid SVG fill, or use none.",
102 )
103 render_parser.add_argument(
104 "--fill-opacity",
105 type=float,
106 help="Override fill opacity from 0 to 1.",
107 )
108 render_parser.add_argument(
109 "--stroke",
110 help="Override the primary shape's solid SVG stroke, or use none.",
111 )
112 render_parser.add_argument(
113 "--stroke-width",
114 type=float,
115 help="Override stroke width in SVG page units.",
116 )
117 render_parser.add_argument(
118 "--stroke-opacity",
119 type=float,
120 help="Override stroke opacity from 0 to 1.",
121 )
122 return parser
123
124
125 def main(argv: Sequence[str] | None = None) -> int:
126 parser = build_parser()
127 args = parser.parse_args(argv)
128
129 try:
130 style = _style_from_args(args)
131 _validate_render_args(args)
132 from svg_to_pptx.shape_boolean import (
133 render_boolean_svg_fragments,
134 )
135
136 fragment = render_boolean_svg_fragments(
137 args.svg_file,
138 operation=args.operation,
139 source_ids=args.source_ids,
140 output_id=args.output_id,
141 style=style or None,
142 font_dirs=args.font_dirs,
143 )
144 except (OSError, RuntimeError, ValueError) as exc:
145 print(f"Error: {exc}", file=sys.stderr)
146 return 1
147
148 print(fragment)
149 return 0
150
151
152 def _validate_render_args(args: argparse.Namespace) -> None:
153 if len(args.source_ids) < 2:
154 raise ValueError("--source must be repeated at least twice")
155 if len(set(args.source_ids)) != len(args.source_ids):
156 raise ValueError("--source ids must be unique")
157 if not args.output_id.strip():
158 raise ValueError("--id must not be empty")
159 for font_dir in args.font_dirs:
160 if not font_dir.is_dir():
161 raise ValueError(f"--font-dir is not a directory: {font_dir}")
162
163
164 def _style_from_args(args: argparse.Namespace) -> dict[str, str]:
165 style: dict[str, str] = {}
166 if args.fill is not None:
167 style["fill"] = args.fill
168 if args.fill_opacity is not None:
169 _validate_opacity("--fill-opacity", args.fill_opacity)
170 style["fill-opacity"] = str(args.fill_opacity)
171 if args.stroke is not None:
172 style["stroke"] = args.stroke
173 if args.stroke_width is not None:
174 if not math.isfinite(args.stroke_width) or args.stroke_width < 0:
175 raise ValueError("--stroke-width must be greater than or equal to 0")
176 style["stroke-width"] = str(args.stroke_width)
177 if args.stroke_opacity is not None:
178 _validate_opacity("--stroke-opacity", args.stroke_opacity)
179 style["stroke-opacity"] = str(args.stroke_opacity)
180 return style
181
182
183 def _validate_opacity(option: str, value: float) -> None:
184 if not math.isfinite(value) or not 0 <= value <= 1:
185 raise ValueError(f"{option} must be between 0 and 1")
186
187
188 if __name__ == "__main__":
189 raise SystemExit(main())
190
190 lines PYTHON