Files

234 lines
7.2 KiB
Python

#!/usr/bin/env python3
"""
PPT Master - Preset Shape SVG Fragment Tool
List DrawingML presets or print one compact canonical native-preset SVG group to
stdout for manual insertion into a hand-authored page or template.
Usage:
python3 scripts/preset_shape_svg.py list [--search QUERY]
python3 scripts/preset_shape_svg.py describe PRESET
python3 scripts/preset_shape_svg.py render PRESET --id ID --frame X Y W H
Examples:
python3 scripts/preset_shape_svg.py list --search arrow
python3 scripts/preset_shape_svg.py describe rightArrow
python3 scripts/preset_shape_svg.py render rightArrow --id next-step \
--frame 160 210 320 112 --fill "#2563EB" --stroke none
Dependencies:
None (only uses standard library and local PPT Master modules)
"""
from __future__ import annotations
import argparse
import json
import sys
from typing import Sequence
from console_encoding import configure_utf8_stdio
from pptx_shapes import CONNECTOR_PRESET_TYPES, get_preset_registry
from pptx_to_svg.preset_authoring import render_preset_shape_fragment
configure_utf8_stdio()
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Print one compact canonical DrawingML preset SVG group. "
"This tool never writes SVG files or page layouts."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
subparsers = parser.add_subparsers(dest="command", required=True)
list_parser = subparsers.add_parser(
"list",
help="List preset names, optionally filtered by substring.",
)
list_parser.add_argument(
"--search",
default="",
help="Case-insensitive preset-name substring.",
)
describe_parser = subparsers.add_parser(
"describe",
help="Print preset adjustment and path metadata as JSON.",
)
describe_parser.add_argument("preset", help="DrawingML preset name.")
render_parser = subparsers.add_parser(
"render",
help="Print one canonical authored-preset <g> fragment to stdout.",
)
render_parser.add_argument("preset", help="DrawingML preset name.")
render_parser.add_argument(
"--id",
required=True,
dest="element_id",
help="Stable unique SVG group id.",
)
render_parser.add_argument(
"--frame",
required=True,
nargs=4,
type=float,
metavar=("X", "Y", "WIDTH", "HEIGHT"),
help="SVG frame in the fragment's insertion coordinate space.",
)
render_parser.add_argument(
"--object-kind",
choices=("shape", "connector"),
default="shape",
help="Emit a normal shape or a native PowerPoint connector.",
)
render_parser.add_argument(
"--name",
help="Optional PowerPoint object name.",
)
render_parser.add_argument(
"--fill",
default="none",
help="Solid SVG fill, or none.",
)
render_parser.add_argument(
"--fill-opacity",
type=float,
help="Fill opacity from 0 to 1.",
)
render_parser.add_argument(
"--stroke",
default="none",
help="Solid SVG stroke, or none.",
)
render_parser.add_argument(
"--stroke-width",
type=float,
help="Stroke width in SVG page units; defaults to 1 when stroked.",
)
render_parser.add_argument(
"--stroke-opacity",
type=float,
help="Stroke opacity from 0 to 1.",
)
render_parser.add_argument(
"--stroke-linecap",
choices=("butt", "round", "square"),
)
render_parser.add_argument(
"--stroke-linejoin",
choices=("miter", "round", "bevel"),
)
render_parser.add_argument(
"--adjust",
action="append",
default=[],
metavar="NAME=FORMULA",
help=(
"DrawingML adjustment formula; repeat for multiple guides, "
"for example --adjust 'adj1=val 50000'."
),
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
registry = get_preset_registry()
if args.command == "list":
query = args.search.casefold().strip()
names = [
name for name in registry.names
if not query or query in name.casefold()
]
if not names:
print(f"No preset names match {args.search!r}", file=sys.stderr)
return 1
print("\n".join(names))
return 0
if args.command == "describe":
if args.preset not in registry:
print(f"Unknown DrawingML preset: {args.preset!r}", file=sys.stderr)
return 1
definition = registry.get(args.preset)
payload = {
"preset": definition.name,
"connector_preset": definition.name in CONNECTOR_PRESET_TYPES,
"adjustments": [
{"name": guide.name, "formula": guide.formula}
for guide in definition.adjustments
],
"path_count": len(definition.paths),
"connection_site_count": len(definition.connections),
"has_text_rectangle": definition.text_rectangle is not None,
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 0
try:
adjustments = _parse_adjustments(args.adjust)
style = _style_from_args(args)
fragment = render_preset_shape_fragment(
args.preset,
tuple(args.frame),
adjustments=adjustments,
object_kind=args.object_kind,
element_id=args.element_id,
name=args.name,
style=style,
)
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
print(fragment)
return 0
def _parse_adjustments(values: Sequence[str]) -> dict[str, str]:
adjustments: dict[str, str] = {}
for value in values:
name, separator, formula = value.partition("=")
name = name.strip()
formula = formula.strip()
if not separator or not name or not formula:
raise ValueError(
f"Invalid adjustment {value!r}; expected NAME=FORMULA"
)
if name in adjustments:
raise ValueError(f"Duplicate adjustment guide: {name!r}")
adjustments[name] = formula
return adjustments
def _style_from_args(args: argparse.Namespace) -> dict[str, str]:
style = {
"fill": args.fill,
"stroke": args.stroke,
}
if args.fill_opacity is not None:
style["fill-opacity"] = str(args.fill_opacity)
if args.stroke != "none":
style["stroke-width"] = str(
1.0 if args.stroke_width is None else args.stroke_width
)
elif args.stroke_width is not None:
raise ValueError("--stroke-width requires a non-none --stroke")
if args.stroke_opacity is not None:
style["stroke-opacity"] = str(args.stroke_opacity)
if args.stroke_linecap is not None:
style["stroke-linecap"] = args.stroke_linecap
if args.stroke_linejoin is not None:
style["stroke-linejoin"] = args.stroke_linejoin
return style
if __name__ == "__main__":
raise SystemExit(main())