Release standalone PPT template workflow 0.5.0

This commit is contained in:
2026-10-10 21:02:47 +08:00
commit 4e3271ff62
303 changed files with 190946 additions and 0 deletions
@@ -0,0 +1,227 @@
"""Command-line interface: analyze / scaffold / check-plan / apply / validate."""
from __future__ import annotations
import argparse
import re
import sys
from datetime import datetime
from pathlib import Path
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from attribution_guard import require_skill_integrity # noqa: E402
from console_encoding import configure_utf8_stdio # noqa: E402
from pptx_transitions import ( # noqa: E402
LEGACY_TRANSITION_KEYS,
NATIVE_TRANSITION_KEYS,
)
configure_utf8_stdio()
if __package__ in {None, ''}:
import types
package_dir = Path(__file__).resolve().parent
while str(package_dir) in sys.path:
sys.path.remove(str(package_dir))
scripts_dir = Path(__file__).resolve().parents[1]
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
package = types.ModuleType("template_fill_pptx")
package.__path__ = [str(package_dir)] # type: ignore[attr-defined]
sys.modules.setdefault("template_fill_pptx", package)
__package__ = "template_fill_pptx"
from .analyzer import analyze_pptx
from .applier import apply_plan
from .checker import check_plan, print_check_report
from .ooxml import _load_json, _write_json
from .scaffolder import scaffold_plan
from .transitions import (
DEFAULT_TRANSITION,
DEFAULT_TRANSITION_DURATION,
KEEP_TRANSITION,
)
from .validator import print_validate_report, validate_project
def _parse_slide_list(value: str | None) -> list[int] | None:
if not value:
return None
slides: list[int] = []
for part in value.split(","):
part = part.strip()
if not part:
continue
if "-" in part:
start, end = part.split("-", 1)
slides.extend(range(int(start), int(end) + 1))
else:
slides.append(int(part))
return slides
def _timestamped_pptx_path(path: Path) -> Path:
if path.suffix.lower() != ".pptx":
return path
if re.search(r"_\d{8}_\d{6}$", path.stem):
return path
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
return path.with_name(f"{path.stem}_{timestamp}{path.suffix}")
def _plan_confirmed(plan: dict) -> bool:
return plan.get("status") == "confirmed"
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Analyze and fill native PPTX templates without converting slides to SVG.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
subparsers = parser.add_subparsers(dest="command", required=True)
analyze = subparsers.add_parser("analyze", help="Extract slide library JSON from a PPTX")
analyze.add_argument("pptx_file", help="Source PPTX file")
analyze.add_argument("-o", "--output", required=True, help="Output <stem>.slide_library.json path")
scaffold = subparsers.add_parser("scaffold", help="Create an editable fill plan skeleton")
scaffold.add_argument("library_json", help="<stem>.slide_library.json from analyze")
scaffold.add_argument("-o", "--output", required=True, help="Output fill_plan.json path")
scaffold.add_argument(
"--slides",
help="Comma/range source slide list, e.g. 1,3,5-7. Defaults to first six slides.",
)
scaffold.add_argument(
"--include-empty",
action="store_true",
help="Include empty text slots in the scaffold. Defaults to text-bearing slots only.",
)
check = subparsers.add_parser("check-plan", help="Check a fill plan against source slot capacity")
check.add_argument("library_json", help="<stem>.slide_library.json from analyze")
check.add_argument("plan_json", help="Fill plan JSON")
check.add_argument("-o", "--output", help="Optional JSON report output path")
apply = subparsers.add_parser("apply", help="Apply fill plan and write a new PPTX")
apply.add_argument("pptx_file", help="Source PPTX file")
apply.add_argument("plan_json", help="Fill plan JSON")
apply.add_argument(
"-o",
"--output",
required=True,
help=(
"Output PPTX path. A _YYYYMMDD_HHMMSS timestamp is appended "
"automatically unless the stem already ends with one."
),
)
apply.add_argument(
"--transition",
choices=[
*NATIVE_TRANSITION_KEYS,
*LEGACY_TRANSITION_KEYS,
"none",
KEEP_TRANSITION,
],
default=DEFAULT_TRANSITION,
help=(
"Page-to-page transition policy for every cloned slide "
"(per-slide 'transition' in the plan overrides this). "
"Use a PowerPoint-native key; old names are compatibility inputs. "
f"Default: {DEFAULT_TRANSITION} (preserve the source). "
"Use 'none' to remove visual motion."
),
)
apply.add_argument(
"--transition-duration",
type=float,
default=DEFAULT_TRANSITION_DURATION,
help="Transition duration in seconds (default: 0.5).",
)
apply.add_argument(
"--force",
action="store_true",
help="apply without a confirmed fill plan (deliberate recovery/debug only)",
)
validate = subparsers.add_parser("validate", help="Read back and validate the latest project export")
validate.add_argument("project_path", help="Template-fill project directory")
return parser
def main(argv: list[str] | None = None) -> int:
require_skill_integrity()
parser = build_parser()
args = parser.parse_args(argv)
try:
if args.command == "analyze":
pptx_path = Path(args.pptx_file).expanduser().resolve()
if not pptx_path.exists():
print(f"Error: file does not exist: {pptx_path}", file=sys.stderr)
return 1
library = analyze_pptx(pptx_path)
_write_json(Path(args.output).expanduser().resolve(), library)
print(f"Analyzed {library['slide_count']} slides -> {args.output}", file=sys.stderr)
return 0
if args.command == "scaffold":
library = _load_json(Path(args.library_json).expanduser().resolve())
plan = scaffold_plan(
library,
_parse_slide_list(args.slides),
include_empty=args.include_empty,
)
_write_json(Path(args.output).expanduser().resolve(), plan)
print(f"Plan scaffold -> {args.output}", file=sys.stderr)
return 0
if args.command == "check-plan":
library = _load_json(Path(args.library_json).expanduser().resolve())
plan = _load_json(Path(args.plan_json).expanduser().resolve())
report = check_plan(library, plan)
print_check_report(report)
if args.output:
_write_json(Path(args.output).expanduser().resolve(), report)
print(f"Check report -> {args.output}", file=sys.stderr)
return 0 if report["summary"]["error"] == 0 else 1
if args.command == "apply":
pptx_path = Path(args.pptx_file).expanduser().resolve()
plan = _load_json(Path(args.plan_json).expanduser().resolve())
if not _plan_confirmed(plan) and not args.force:
print(
"Error: fill plan is not confirmed: "
f"{Path(args.plan_json).expanduser().resolve()} "
'(set status to "confirmed" after user approval, or pass --force)',
file=sys.stderr,
)
return 1
output_path = _timestamped_pptx_path(Path(args.output).expanduser().resolve())
apply_plan(
pptx_path,
plan,
output_path,
transition=args.transition,
transition_duration=args.transition_duration,
)
print(f"Template-filled PPTX -> {output_path}", file=sys.stderr)
return 0
if args.command == "validate":
report = validate_project(Path(args.project_path))
print_validate_report(report)
return 0 if report["summary"]["error"] == 0 else 1
except RuntimeError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
parser.print_help()
return 1
if __name__ == "__main__":
raise SystemExit(main())