| 1 | """Command-line interface: analyze / scaffold / check-plan / apply / validate.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import argparse |
| 6 | import re |
| 7 | import sys |
| 8 | from datetime import datetime |
| 9 | from pathlib import Path |
| 10 | |
| 11 | _SCRIPTS_DIR = Path(__file__).resolve().parents[1] |
| 12 | if str(_SCRIPTS_DIR) not in sys.path: |
| 13 | sys.path.insert(0, str(_SCRIPTS_DIR)) |
| 14 | |
| 15 | from attribution_guard import require_skill_integrity # noqa: E402 |
| 16 | from console_encoding import configure_utf8_stdio # noqa: E402 |
| 17 | from pptx_transitions import ( # noqa: E402 |
| 18 | LEGACY_TRANSITION_KEYS, |
| 19 | NATIVE_TRANSITION_KEYS, |
| 20 | ) |
| 21 | |
| 22 | configure_utf8_stdio() |
| 23 | |
| 24 | if __package__ in {None, ''}: |
| 25 | import types |
| 26 | |
| 27 | package_dir = Path(__file__).resolve().parent |
| 28 | while str(package_dir) in sys.path: |
| 29 | sys.path.remove(str(package_dir)) |
| 30 | scripts_dir = Path(__file__).resolve().parents[1] |
| 31 | if str(scripts_dir) not in sys.path: |
| 32 | sys.path.insert(0, str(scripts_dir)) |
| 33 | package = types.ModuleType("template_fill_pptx") |
| 34 | package.__path__ = [str(package_dir)] # type: ignore[attr-defined] |
| 35 | sys.modules.setdefault("template_fill_pptx", package) |
| 36 | __package__ = "template_fill_pptx" |
| 37 | |
| 38 | from .analyzer import analyze_pptx |
| 39 | from .applier import apply_plan |
| 40 | from .checker import check_plan, print_check_report |
| 41 | from .ooxml import _load_json, _write_json |
| 42 | from .scaffolder import scaffold_plan |
| 43 | from .transitions import ( |
| 44 | DEFAULT_TRANSITION, |
| 45 | DEFAULT_TRANSITION_DURATION, |
| 46 | KEEP_TRANSITION, |
| 47 | ) |
| 48 | from .validator import print_validate_report, validate_project |
| 49 | |
| 50 | |
| 51 | def _parse_slide_list(value: str | None) -> list[int] | None: |
| 52 | if not value: |
| 53 | return None |
| 54 | slides: list[int] = [] |
| 55 | for part in value.split(","): |
| 56 | part = part.strip() |
| 57 | if not part: |
| 58 | continue |
| 59 | if "-" in part: |
| 60 | start, end = part.split("-", 1) |
| 61 | slides.extend(range(int(start), int(end) + 1)) |
| 62 | else: |
| 63 | slides.append(int(part)) |
| 64 | return slides |
| 65 | |
| 66 | |
| 67 | def _timestamped_pptx_path(path: Path) -> Path: |
| 68 | if path.suffix.lower() != ".pptx": |
| 69 | return path |
| 70 | if re.search(r"_\d{8}_\d{6}$", path.stem): |
| 71 | return path |
| 72 | timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| 73 | return path.with_name(f"{path.stem}_{timestamp}{path.suffix}") |
| 74 | |
| 75 | |
| 76 | def _plan_confirmed(plan: dict) -> bool: |
| 77 | return plan.get("status") == "confirmed" |
| 78 | |
| 79 | |
| 80 | def build_parser() -> argparse.ArgumentParser: |
| 81 | parser = argparse.ArgumentParser( |
| 82 | description="Analyze and fill native PPTX templates without converting slides to SVG.", |
| 83 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 84 | ) |
| 85 | subparsers = parser.add_subparsers(dest="command", required=True) |
| 86 | |
| 87 | analyze = subparsers.add_parser("analyze", help="Extract slide library JSON from a PPTX") |
| 88 | analyze.add_argument("pptx_file", help="Source PPTX file") |
| 89 | analyze.add_argument("-o", "--output", required=True, help="Output <stem>.slide_library.json path") |
| 90 | |
| 91 | scaffold = subparsers.add_parser("scaffold", help="Create an editable fill plan skeleton") |
| 92 | scaffold.add_argument("library_json", help="<stem>.slide_library.json from analyze") |
| 93 | scaffold.add_argument("-o", "--output", required=True, help="Output fill_plan.json path") |
| 94 | scaffold.add_argument( |
| 95 | "--slides", |
| 96 | help="Comma/range source slide list, e.g. 1,3,5-7. Defaults to first six slides.", |
| 97 | ) |
| 98 | scaffold.add_argument( |
| 99 | "--include-empty", |
| 100 | action="store_true", |
| 101 | help="Include empty text slots in the scaffold. Defaults to text-bearing slots only.", |
| 102 | ) |
| 103 | |
| 104 | check = subparsers.add_parser("check-plan", help="Check a fill plan against source slot capacity") |
| 105 | check.add_argument("library_json", help="<stem>.slide_library.json from analyze") |
| 106 | check.add_argument("plan_json", help="Fill plan JSON") |
| 107 | check.add_argument("-o", "--output", help="Optional JSON report output path") |
| 108 | |
| 109 | apply = subparsers.add_parser("apply", help="Apply fill plan and write a new PPTX") |
| 110 | apply.add_argument("pptx_file", help="Source PPTX file") |
| 111 | apply.add_argument("plan_json", help="Fill plan JSON") |
| 112 | apply.add_argument( |
| 113 | "-o", |
| 114 | "--output", |
| 115 | required=True, |
| 116 | help=( |
| 117 | "Output PPTX path. A _YYYYMMDD_HHMMSS timestamp is appended " |
| 118 | "automatically unless the stem already ends with one." |
| 119 | ), |
| 120 | ) |
| 121 | apply.add_argument( |
| 122 | "--transition", |
| 123 | choices=[ |
| 124 | *NATIVE_TRANSITION_KEYS, |
| 125 | *LEGACY_TRANSITION_KEYS, |
| 126 | "none", |
| 127 | KEEP_TRANSITION, |
| 128 | ], |
| 129 | default=DEFAULT_TRANSITION, |
| 130 | help=( |
| 131 | "Page-to-page transition policy for every cloned slide " |
| 132 | "(per-slide 'transition' in the plan overrides this). " |
| 133 | "Use a PowerPoint-native key; old names are compatibility inputs. " |
| 134 | f"Default: {DEFAULT_TRANSITION} (preserve the source). " |
| 135 | "Use 'none' to remove visual motion." |
| 136 | ), |
| 137 | ) |
| 138 | apply.add_argument( |
| 139 | "--transition-duration", |
| 140 | type=float, |
| 141 | default=DEFAULT_TRANSITION_DURATION, |
| 142 | help="Transition duration in seconds (default: 0.5).", |
| 143 | ) |
| 144 | apply.add_argument( |
| 145 | "--force", |
| 146 | action="store_true", |
| 147 | help="apply without a confirmed fill plan (deliberate recovery/debug only)", |
| 148 | ) |
| 149 | |
| 150 | validate = subparsers.add_parser("validate", help="Read back and validate the latest project export") |
| 151 | validate.add_argument("project_path", help="Template-fill project directory") |
| 152 | |
| 153 | return parser |
| 154 | |
| 155 | |
| 156 | def main(argv: list[str] | None = None) -> int: |
| 157 | require_skill_integrity() |
| 158 | parser = build_parser() |
| 159 | args = parser.parse_args(argv) |
| 160 | try: |
| 161 | if args.command == "analyze": |
| 162 | pptx_path = Path(args.pptx_file).expanduser().resolve() |
| 163 | if not pptx_path.exists(): |
| 164 | print(f"Error: file does not exist: {pptx_path}", file=sys.stderr) |
| 165 | return 1 |
| 166 | library = analyze_pptx(pptx_path) |
| 167 | _write_json(Path(args.output).expanduser().resolve(), library) |
| 168 | print(f"Analyzed {library['slide_count']} slides -> {args.output}", file=sys.stderr) |
| 169 | return 0 |
| 170 | |
| 171 | if args.command == "scaffold": |
| 172 | library = _load_json(Path(args.library_json).expanduser().resolve()) |
| 173 | plan = scaffold_plan( |
| 174 | library, |
| 175 | _parse_slide_list(args.slides), |
| 176 | include_empty=args.include_empty, |
| 177 | ) |
| 178 | _write_json(Path(args.output).expanduser().resolve(), plan) |
| 179 | print(f"Plan scaffold -> {args.output}", file=sys.stderr) |
| 180 | return 0 |
| 181 | |
| 182 | if args.command == "check-plan": |
| 183 | library = _load_json(Path(args.library_json).expanduser().resolve()) |
| 184 | plan = _load_json(Path(args.plan_json).expanduser().resolve()) |
| 185 | report = check_plan(library, plan) |
| 186 | print_check_report(report) |
| 187 | if args.output: |
| 188 | _write_json(Path(args.output).expanduser().resolve(), report) |
| 189 | print(f"Check report -> {args.output}", file=sys.stderr) |
| 190 | return 0 if report["summary"]["error"] == 0 else 1 |
| 191 | |
| 192 | if args.command == "apply": |
| 193 | pptx_path = Path(args.pptx_file).expanduser().resolve() |
| 194 | plan = _load_json(Path(args.plan_json).expanduser().resolve()) |
| 195 | if not _plan_confirmed(plan) and not args.force: |
| 196 | print( |
| 197 | "Error: fill plan is not confirmed: " |
| 198 | f"{Path(args.plan_json).expanduser().resolve()} " |
| 199 | '(set status to "confirmed" after user approval, or pass --force)', |
| 200 | file=sys.stderr, |
| 201 | ) |
| 202 | return 1 |
| 203 | output_path = _timestamped_pptx_path(Path(args.output).expanduser().resolve()) |
| 204 | apply_plan( |
| 205 | pptx_path, |
| 206 | plan, |
| 207 | output_path, |
| 208 | transition=args.transition, |
| 209 | transition_duration=args.transition_duration, |
| 210 | ) |
| 211 | print(f"Template-filled PPTX -> {output_path}", file=sys.stderr) |
| 212 | return 0 |
| 213 | |
| 214 | if args.command == "validate": |
| 215 | report = validate_project(Path(args.project_path)) |
| 216 | print_validate_report(report) |
| 217 | return 0 if report["summary"]["error"] == 0 else 1 |
| 218 | except RuntimeError as exc: |
| 219 | print(f"Error: {exc}", file=sys.stderr) |
| 220 | return 1 |
| 221 | |
| 222 | parser.print_help() |
| 223 | return 1 |
| 224 | |
| 225 | |
| 226 | if __name__ == "__main__": |
| 227 | raise SystemExit(main()) |
| 228 |