返回 ppt-master
preset_shape_svg.py
根目录 / skills / ppt-master / scripts / preset_shape_svg.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - Preset Shape SVG Fragment Tool
4
5 Browse and inspect DrawingML presets, or print compact canonical native-preset
6 SVG groups to stdout for manual insertion into a hand-authored page or
7 template.
8
9 Usage:
10 python3 scripts/preset_shape_svg.py list [--grouped] [--search QUERY]
11 python3 scripts/preset_shape_svg.py describe PRESET [--compact]
12 python3 scripts/preset_shape_svg.py render PRESET --id ID --frame X Y W H
13 python3 scripts/preset_shape_svg.py render-batch --input FILE_OR_DASH
14
15 Examples:
16 python3 scripts/preset_shape_svg.py list --search arrow
17 python3 scripts/preset_shape_svg.py describe rightArrow --compact
18 python3 scripts/preset_shape_svg.py render rightArrow --id next-step \
19 --frame 160 210 320 112 --fill "#2563EB" --stroke none
20 python3 scripts/preset_shape_svg.py render-batch --input shapes.json
21
22 Dependencies:
23 None (only uses standard library and local PPT Master modules)
24 """
25
26 from __future__ import annotations
27
28 import argparse
29 import json
30 import sys
31 from pathlib import Path
32 from typing import Sequence
33
34 from console_encoding import configure_utf8_stdio
35 from pptx_shapes import CONNECTOR_PRESET_TYPES, get_preset_registry
36 from pptx_shapes.semantics import get_preset_shape_semantics
37 from pptx_to_svg.preset_authoring import render_preset_shape_fragment
38
39
40 configure_utf8_stdio()
41
42
43 _BATCH_ITEM_FIELDS = frozenset({
44 "preset",
45 "id",
46 "frame",
47 "object_kind",
48 "name",
49 "fill",
50 "fill_opacity",
51 "stroke",
52 "stroke_width",
53 "stroke_opacity",
54 "stroke_linecap",
55 "stroke_linejoin",
56 "filter_id",
57 "adjustments",
58 })
59
60
61 def build_parser() -> argparse.ArgumentParser:
62 parser = argparse.ArgumentParser(
63 description=(
64 "Print one compact canonical DrawingML preset SVG group. "
65 "This tool never writes SVG files or page layouts."
66 ),
67 formatter_class=argparse.RawDescriptionHelpFormatter,
68 )
69 subparsers = parser.add_subparsers(dest="command", required=True)
70
71 list_parser = subparsers.add_parser(
72 "list",
73 help="List preset names, optionally filtered by substring.",
74 )
75 list_parser.add_argument(
76 "--search",
77 default="",
78 help=(
79 "Case-insensitive preset-name substring; grouped output also "
80 "searches semantic text."
81 ),
82 )
83 list_parser.add_argument(
84 "--grouped",
85 action="store_true",
86 help=(
87 "Print a compact Office-category and semantic-group index with "
88 "one-line intent summaries and preset names as JSON."
89 ),
90 )
91
92 describe_parser = subparsers.add_parser(
93 "describe",
94 help="Print preset geometry and identity metadata as JSON.",
95 )
96 describe_parser.add_argument("preset", help="DrawingML preset name.")
97 describe_parser.add_argument(
98 "--compact",
99 action="store_true",
100 help=(
101 "Print one flat objective identity view with key geometry facts."
102 ),
103 )
104
105 render_parser = subparsers.add_parser(
106 "render",
107 help="Print one canonical authored-preset <g> fragment to stdout.",
108 )
109 render_parser.add_argument("preset", help="DrawingML preset name.")
110 render_parser.add_argument(
111 "--id",
112 required=True,
113 dest="element_id",
114 help="Stable unique SVG group id.",
115 )
116 render_parser.add_argument(
117 "--frame",
118 required=True,
119 nargs=4,
120 type=float,
121 metavar=("X", "Y", "WIDTH", "HEIGHT"),
122 help="SVG frame in the fragment's insertion coordinate space.",
123 )
124 render_parser.add_argument(
125 "--object-kind",
126 choices=("shape", "connector"),
127 default="shape",
128 help="Emit a normal shape or a native PowerPoint connector.",
129 )
130 render_parser.add_argument(
131 "--name",
132 help="Optional PowerPoint object name.",
133 )
134 render_parser.add_argument(
135 "--fill",
136 default="none",
137 help="Solid SVG fill, or none.",
138 )
139 render_parser.add_argument(
140 "--fill-opacity",
141 type=float,
142 help="Fill opacity from 0 to 1.",
143 )
144 render_parser.add_argument(
145 "--stroke",
146 default="none",
147 help="Solid SVG stroke, or none.",
148 )
149 render_parser.add_argument(
150 "--stroke-width",
151 type=float,
152 help="Stroke width in SVG page units; defaults to 1 when stroked.",
153 )
154 render_parser.add_argument(
155 "--stroke-opacity",
156 type=float,
157 help="Stroke opacity from 0 to 1.",
158 )
159 render_parser.add_argument(
160 "--stroke-linecap",
161 choices=("butt", "round", "square"),
162 )
163 render_parser.add_argument(
164 "--stroke-linejoin",
165 choices=("miter", "round", "bevel"),
166 )
167 render_parser.add_argument(
168 "--filter-id",
169 help=(
170 "Optional local SVG filter id for one registered native shadow or "
171 "glow; the complete page must define it in direct <defs>."
172 ),
173 )
174 render_parser.add_argument(
175 "--adjust",
176 action="append",
177 default=[],
178 metavar="NAME=FORMULA",
179 help=(
180 "DrawingML adjustment formula; repeat for multiple guides, "
181 "for example --adjust 'adj1=val 50000'."
182 ),
183 )
184
185 batch_parser = subparsers.add_parser(
186 "render-batch",
187 help="Print multiple canonical authored-preset fragments atomically.",
188 )
189 batch_parser.add_argument(
190 "--input",
191 required=True,
192 metavar="FILE_OR_DASH",
193 help=(
194 "UTF-8 JSON array of shape objects; use - to read stdin. "
195 "Use an 'adjustments' object such as "
196 "{\"adj\": \"val 42000\"}; the command prints no fragments "
197 "when any item is invalid."
198 ),
199 )
200 return parser
201
202
203 def main(argv: Sequence[str] | None = None) -> int:
204 parser = build_parser()
205 args = parser.parse_args(argv)
206 registry = get_preset_registry()
207
208 if args.command == "list":
209 if args.grouped:
210 payload = get_preset_shape_semantics().grouped(args.search)
211 if payload["preset_count"] == 0:
212 print(f"No preset semantics match {args.search!r}", file=sys.stderr)
213 return 1
214 print(json.dumps(payload, ensure_ascii=False, indent=2))
215 return 0
216 query = args.search.casefold().strip()
217 names = [
218 name for name in registry.names
219 if not query or query in name.casefold()
220 ]
221 if not names:
222 print(f"No preset names match {args.search!r}", file=sys.stderr)
223 return 1
224 print("\n".join(names))
225 return 0
226
227 if args.command == "describe":
228 if args.preset not in registry:
229 print(f"Unknown DrawingML preset: {args.preset!r}", file=sys.stderr)
230 return 1
231 definition = registry.get(args.preset)
232 adjustments = [
233 {"name": guide.name, "formula": guide.formula}
234 for guide in definition.adjustments
235 ]
236 connector_preset = definition.name in CONNECTOR_PRESET_TYPES
237 path_count = len(definition.paths)
238 connection_site_count = len(definition.connections)
239 has_text_rectangle = definition.text_rectangle is not None
240 semantics = get_preset_shape_semantics().describe(args.preset)
241 full_payload = {
242 "preset": definition.name,
243 "connector_preset": connector_preset,
244 "adjustments": adjustments,
245 "path_count": path_count,
246 "connection_site_count": connection_site_count,
247 "has_text_rectangle": has_text_rectangle,
248 "semantics": semantics,
249 }
250 payload = (
251 {
252 "preset": definition.name,
253 "identity": semantics["intent"],
254 "office_category": semantics["office_category_label"],
255 "family": semantics["label"],
256 "scope": semantics["scope"],
257 "literal_only": semantics["literal_only"],
258 "adjustments": adjustments,
259 "connector_preset": connector_preset,
260 "path_count": path_count,
261 "connection_site_count": connection_site_count,
262 "has_text_rectangle": has_text_rectangle,
263 }
264 if args.compact
265 else full_payload
266 )
267 print(json.dumps(payload, ensure_ascii=False, indent=2))
268 return 0
269
270 if args.command == "render-batch":
271 try:
272 items = _read_batch_items(args.input)
273 fragments = _render_batch_items(items)
274 except ValueError as exc:
275 print(f"Error: {exc}", file=sys.stderr)
276 return 1
277 print("\n".join(fragments))
278 return 0
279
280 try:
281 adjustments = _parse_adjustments(args.adjust)
282 style = _style_from_args(args)
283 fragment = render_preset_shape_fragment(
284 args.preset,
285 tuple(args.frame),
286 adjustments=adjustments,
287 object_kind=args.object_kind,
288 element_id=args.element_id,
289 name=args.name,
290 style=style,
291 filter_id=args.filter_id,
292 )
293 except ValueError as exc:
294 print(f"Error: {exc}", file=sys.stderr)
295 return 1
296 print(fragment)
297 return 0
298
299
300 def _parse_adjustments(values: Sequence[str]) -> dict[str, str]:
301 adjustments: dict[str, str] = {}
302 for value in values:
303 name, separator, formula = value.partition("=")
304 name = name.strip()
305 formula = formula.strip()
306 if not separator or not name or not formula:
307 raise ValueError(
308 f"Invalid adjustment {value!r}; expected NAME=FORMULA"
309 )
310 if name in adjustments:
311 raise ValueError(f"Duplicate adjustment guide: {name!r}")
312 adjustments[name] = formula
313 return adjustments
314
315
316 def _style_from_args(args: argparse.Namespace) -> dict[str, str]:
317 return _style_from_values(
318 fill=args.fill,
319 fill_opacity=args.fill_opacity,
320 stroke=args.stroke,
321 stroke_width=args.stroke_width,
322 stroke_opacity=args.stroke_opacity,
323 stroke_linecap=args.stroke_linecap,
324 stroke_linejoin=args.stroke_linejoin,
325 )
326
327
328 def _style_from_values(
329 *,
330 fill: object = "none",
331 fill_opacity: object | None = None,
332 stroke: object = "none",
333 stroke_width: object | None = None,
334 stroke_opacity: object | None = None,
335 stroke_linecap: object | None = None,
336 stroke_linejoin: object | None = None,
337 ) -> dict[str, str]:
338 fill_text = str(fill)
339 stroke_text = str(stroke)
340 style = {
341 "fill": fill_text,
342 "stroke": stroke_text,
343 }
344 if fill_opacity is not None:
345 style["fill-opacity"] = str(fill_opacity)
346 if stroke_text != "none":
347 style["stroke-width"] = str(
348 1.0 if stroke_width is None else stroke_width
349 )
350 elif stroke_width is not None:
351 raise ValueError("--stroke-width requires a non-none --stroke")
352 if stroke_opacity is not None:
353 style["stroke-opacity"] = str(stroke_opacity)
354 if stroke_linecap is not None:
355 style["stroke-linecap"] = str(stroke_linecap)
356 if stroke_linejoin is not None:
357 style["stroke-linejoin"] = str(stroke_linejoin)
358 return style
359
360
361 def _read_batch_items(input_path: str) -> list[object]:
362 """Read one JSON array from a path or stdin."""
363 try:
364 raw = (
365 sys.stdin.read()
366 if input_path == "-"
367 else Path(input_path).read_text(encoding="utf-8")
368 )
369 except OSError as exc:
370 raise ValueError(f"Cannot read batch input {input_path!r}: {exc}") from exc
371 try:
372 payload = json.loads(raw)
373 except json.JSONDecodeError as exc:
374 raise ValueError(
375 f"Invalid batch JSON at line {exc.lineno}, column {exc.colno}: {exc.msg}"
376 ) from exc
377 if not isinstance(payload, list) or not payload:
378 raise ValueError("Batch input must be a non-empty JSON array")
379 return payload
380
381
382 def _render_batch_items(items: Sequence[object]) -> list[str]:
383 """Validate every batch item, then return all fragments together."""
384 fragments: list[str] = []
385 element_ids = set()
386 for index, raw_item in enumerate(items):
387 label = f"items[{index}]"
388 if not isinstance(raw_item, dict):
389 raise ValueError(f"{label} must be a JSON object")
390 unknown = sorted(set(raw_item) - _BATCH_ITEM_FIELDS)
391 if unknown:
392 supported = ", ".join(sorted(_BATCH_ITEM_FIELDS))
393 raise ValueError(
394 f"{label} has unsupported fields: {', '.join(unknown)}; "
395 f"supported fields: {supported}"
396 )
397 missing = [
398 name for name in ("preset", "id", "frame")
399 if name not in raw_item
400 ]
401 if missing:
402 raise ValueError(f"{label} is missing: {', '.join(missing)}")
403
404 element_id = str(raw_item["id"])
405 if element_id in element_ids:
406 raise ValueError(f"{label} duplicates SVG element id {element_id!r}")
407 element_ids.add(element_id)
408 frame = raw_item["frame"]
409 if not isinstance(frame, list) or len(frame) != 4:
410 raise ValueError(f"{label}.frame must be a four-number JSON array")
411 adjustments = raw_item.get("adjustments", {})
412 if not isinstance(adjustments, dict):
413 raise ValueError(f"{label}.adjustments must be a JSON object")
414
415 try:
416 style = _style_from_values(
417 fill=raw_item.get("fill", "none"),
418 fill_opacity=raw_item.get("fill_opacity"),
419 stroke=raw_item.get("stroke", "none"),
420 stroke_width=raw_item.get("stroke_width"),
421 stroke_opacity=raw_item.get("stroke_opacity"),
422 stroke_linecap=raw_item.get("stroke_linecap"),
423 stroke_linejoin=raw_item.get("stroke_linejoin"),
424 )
425 fragment = render_preset_shape_fragment(
426 str(raw_item["preset"]),
427 tuple(frame),
428 adjustments=adjustments,
429 object_kind=str(raw_item.get("object_kind", "shape")),
430 element_id=element_id,
431 name=(
432 str(raw_item["name"])
433 if raw_item.get("name") is not None
434 else None
435 ),
436 style=style,
437 filter_id=(
438 str(raw_item["filter_id"])
439 if raw_item.get("filter_id") is not None
440 else None
441 ),
442 )
443 except (TypeError, ValueError) as exc:
444 raise ValueError(f"{label}: {exc}") from exc
445 fragments.append(fragment)
446 return fragments
447
448
449 if __name__ == "__main__":
450 raise SystemExit(main())
451
451 lines PYTHON