Files
wehub-resource-sync 8cb1f9f479
Publish SDK (PyPI) / publish (push) Has been cancelled
Publish SDK (npm) / publish (@aionui/officecli-sdk) (push) Has been cancelled
SDK smoke / smoke (windows-latest) (push) Has been cancelled
Publish SDK (npm) / publish (@officecli/officecli-sdk) (push) Has been cancelled
Publish SDK (npm) / publish (@officecli/sdk) (push) Has been cancelled
Publish SDK (npm) / publish (officecli-sdk) (push) Has been cancelled
SDK smoke / smoke (macos-latest) (push) Has been cancelled
SDK smoke / smoke (ubuntu-latest) (push) Has been cancelled
Skill parity / diff (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:09:29 +08:00

464 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Morph PPT Helper Functions
Cross-platform replacement for morph-helpers.sh (Mac / Windows / Linux)
Usage (CLI):
python morph-helpers.py clone <deck> <from_slide> <to_slide>
python morph-helpers.py ghost <deck> <slide> <idx> [idx ...]
python morph-helpers.py verify <deck> <slide>
python morph-helpers.py final-check <deck>
Usage (import):
from morph_helpers import morph_clone_slide, morph_ghost_content, morph_verify_slide, morph_final_check
"""
import sys
import json
import subprocess
import argparse
import re
# Cross-platform color support (colorama optional)
try:
from colorama import init, Fore, Style
init(autoreset=True)
GREEN = Fore.GREEN
RED = Fore.RED
YELLOW = Fore.YELLOW
BLUE = Fore.CYAN
NC = Style.RESET_ALL
except ImportError:
GREEN = RED = YELLOW = BLUE = NC = ""
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _run(*args):
"""Run a command, return (returncode, stdout, stderr)."""
result = subprocess.run(list(args), capture_output=True, text=True)
return result.returncode, result.stdout, result.stderr
def _find_nested(data, key):
"""Recursively search a nested dict for a key, return its value or None."""
if isinstance(data, dict):
if key in data:
return data[key]
for v in data.values():
found = _find_nested(v, key)
if found is not None:
return found
return None
def _has_morph_transition(json_str):
"""Check whether JSON output from officecli contains transition=morph."""
if '"transition": "morph"' in json_str:
return True
try:
data = json.loads(json_str)
return _find_nested(data, "transition") == "morph"
except Exception:
return False
def _collect_shapes(children, callback):
"""Walk a shape tree depth-first, calling callback(child) for each node."""
for child in children:
callback(child)
if "children" in child:
_collect_shapes(child["children"], callback)
# ---------------------------------------------------------------------------
# morph_clone_slide
# ---------------------------------------------------------------------------
def morph_clone_slide(deck, from_slide, to_slide):
"""Clone slide and automatically set transition=morph, then verify.
Args:
deck: path to .pptx file
from_slide: source slide number (1-based)
to_slide: destination slide number (1-based)
"""
from_slide, to_slide = int(from_slide), int(to_slide)
print(f"{BLUE}Cloning slide {from_slide} -> {to_slide}...{NC}")
_run("officecli", "add", deck, "/", "--from", f"/slide[{from_slide}]")
print(f"{BLUE}Setting morph transition...{NC}")
_run("officecli", "set", deck, f"/slide[{to_slide}]", "--prop", "transition=morph")
print(f"{BLUE}Listing shapes for ghosting reference:{NC}")
rc, out, _ = _run("officecli", "get", deck, f"/slide[{to_slide}]", "--depth", "1")
print(out)
# Verify
print(f"{BLUE}Verifying transition...{NC}")
rc, out, _ = _run("officecli", "get", deck, f"/slide[{to_slide}]", "--json")
if not _has_morph_transition(out):
print(f"{RED}ERROR: Transition not set on slide {to_slide}!{NC}")
print(f"{RED} This slide will not have morph animation.{NC}")
sys.exit(1)
print(f"{GREEN}Transition verified on slide {to_slide}{NC}")
print()
# ---------------------------------------------------------------------------
# morph_ghost_content
# ---------------------------------------------------------------------------
def morph_ghost_content(deck, slide, *shapes):
"""Move shapes off-screen (x=36cm) to ghost them for morph animation.
Args:
deck: path to .pptx file
slide: slide number (1-based)
*shapes: one or more shape indices to ghost
"""
slide = int(slide)
shapes = [int(s) for s in shapes]
if not shapes:
print(f"{YELLOW}No shapes to ghost{NC}")
return
print(f"{BLUE}Ghosting {len(shapes)} content shape(s) on slide {slide}...{NC}")
for idx in shapes:
rc, _, _ = _run("officecli", "set", deck, f"/slide[{slide}]/shape[{idx}]", "--prop", "x=36cm")
if rc == 0:
print(f"{GREEN} Ghosted shape[{idx}]{NC}")
else:
print(f"{RED} Failed to ghost shape[{idx}]{NC}")
print(f"{GREEN}Ghosting complete{NC}")
print()
# ---------------------------------------------------------------------------
# morph_verify_slide
# ---------------------------------------------------------------------------
def _check_unghosted(data, prev_slide):
"""Return list of shapes with #s{prev_slide}- prefix not yet ghosted."""
unghosted = []
def visit(child):
name = child.get("format", {}).get("name", "")
x = child.get("format", {}).get("x", "")
path = child.get("path", "")
if f"#s{prev_slide}-" in name and x != "36cm":
unghosted.append(f"{path}: name={name}, x={x}")
if "children" in data:
_collect_shapes(data["children"], visit)
return unghosted
def _check_duplicates(prev_data, curr_data):
"""Return list of shapes with identical text+position on adjacent slides (excluding ghost zone)."""
SCENE_KEYWORDS = ["ring", "dot", "line", "circle", "rect", "slash",
"accent", "actor", "star", "triangle", "diamond"]
def extract(data):
boxes = []
def visit(child):
if child.get("type") != "textbox":
return
name = child.get("format", {}).get("name", "")
text = child.get("text", "").strip()
x = child.get("format", {}).get("x", "")
y = child.get("format", {}).get("y", "")
path = child.get("path", "")
if not text or len(text) < 6:
return
clean = name.replace("!!", "")
is_scene = any(kw in clean.lower() for kw in SCENE_KEYWORDS)
has_slide_pattern = any(f"s{i}-" in clean for i in range(1, 20))
if has_slide_pattern or not is_scene:
boxes.append({"path": path, "text": text[:50], "x": x, "y": y})
if "children" in data:
_collect_shapes(data["children"], visit)
return boxes
prev_boxes = extract(prev_data)
curr_boxes = extract(curr_data)
duplicates = []
for curr in curr_boxes:
for prev in prev_boxes:
if (curr["text"] == prev["text"]
and curr["x"] == prev["x"]
and curr["y"] == prev["y"]
and curr["x"] != "36cm"):
duplicates.append(
f"{curr['path']}: text='{curr['text']}...', pos=({curr['x']},{curr['y']})"
)
break
return duplicates
def morph_verify_slide(deck, slide):
"""Verify a slide has correct morph setup (transition + ghosting).
Uses two detection methods:
1. Name-based: shapes with #s{prev}- prefix must be at x=36cm
2. Duplicate text: same text+position on adjacent slides (catches missing # prefix)
Args:
deck: path to .pptx file
slide: slide number (1-based)
Returns:
True if all checks pass, False otherwise.
"""
slide = int(slide)
print(f"{BLUE}Verifying slide {slide}...{NC}")
has_error = False
# --- Check transition ---
rc, out, _ = _run("officecli", "get", deck, f"/slide[{slide}]", "--json")
curr_json_str = out
if not _has_morph_transition(curr_json_str):
print(f"{RED} Missing transition=morph{NC}")
print(f"{RED} Without this, slide will not animate!{NC}")
has_error = True
else:
print(f"{GREEN} Transition OK{NC}")
# --- Checks against previous slide ---
prev_slide = slide - 1
if prev_slide >= 1:
try:
curr_data = json.loads(curr_json_str).get("data", {})
# Method 1: name-based unghosted detection
unghosted = _check_unghosted(curr_data, prev_slide)
if unghosted:
print(f"{YELLOW} Warning: Found unghosted content from slide {prev_slide}:{NC}")
for item in unghosted:
print(f" {item}")
print(f"{YELLOW} These shapes should be ghosted to x=36cm{NC}")
has_error = True
else:
print(f"{GREEN} No unghosted content detected{NC}")
except Exception as e:
print(f"{RED} [helper] unghosted-check parse error: {e}{NC}", file=sys.stderr)
has_error = True
# Method 2: duplicate text/position detection (backup for missing # prefix)
try:
rc2, out2, _ = _run("officecli", "get", deck, f"/slide[{prev_slide}]", "--json")
prev_data = json.loads(out2).get("data", {})
curr_data = json.loads(curr_json_str).get("data", {})
duplicates = _check_duplicates(prev_data, curr_data)
if duplicates:
print(f"{YELLOW} Warning: Found duplicate content from slide {prev_slide} (same text at same position):{NC}")
for dup in duplicates:
print(f" {dup}")
print(f"{YELLOW} This might indicate:{NC}")
print(f"{YELLOW} 1. Content shapes missing '#sN-' prefix (can't detect for ghosting){NC}")
print(f"{YELLOW} 2. Forgot to ghost previous slide's content{NC}")
print(f"{YELLOW} 3. Forgot to add new content for this slide{NC}")
has_error = True
except Exception as e:
print(f"{RED} [helper] duplicate-check parse error: {e}{NC}", file=sys.stderr)
has_error = True
if not has_error:
print(f"{GREEN}Slide {slide} verification passed{NC}")
else:
print(f"{RED}Slide {slide} has issues - see above{NC}")
print()
return not has_error
# ---------------------------------------------------------------------------
# morph_final_check
# ---------------------------------------------------------------------------
def morph_final_check(deck):
"""Verify the entire deck: all slides (2+) must pass morph_verify_slide.
Also checks for M-2 ghost accumulation (shapes piled up at x≥34cm).
Args:
deck: path to .pptx file
Returns:
True if all slides pass, False otherwise.
"""
print(f"{BLUE}Final deck verification...{NC}")
print()
rc, out, _ = _run("officecli", "view", deck, "outline")
total_slides = 0
first_line = out.split("\n")[0] if out else ""
match = re.search(r"(\d+)\s+slides", first_line)
if match:
total_slides = int(match.group(1))
if total_slides == 0:
print(f"{RED}No slides found in deck{NC}")
return False
print(f"Total slides: {total_slides}")
print()
# --- New: Check for M-2 ghost accumulation ---
print(f"{BLUE}Checking ghost accumulation (M-2)...{NC}")
rc, out, _ = _run("officecli", "query", deck, "shape[x>=34cm]", "--json")
try:
data = json.loads(out).get("data", {})
ghost_count = len(data.get("results", []))
expected_max = max(50, total_slides * 4) # ~4 actors × slides
if ghost_count > expected_max:
print(f"{RED} REJECT: Found {ghost_count} accumulated ghost shapes (expected ≤ {expected_max}){NC}")
print(f"{RED} This is M-2 ghost accumulation — shapes moved to x≥34cm but not cleaned per-slide.{NC}")
print(f"{RED} See §Ghost Discipline & Actor Lifecycle in SKILL.md.{NC}")
return False
else:
print(f"{GREEN} Ghost count OK: {ghost_count} shapes (≤ {expected_max}){NC}")
except Exception as e:
print(f"{YELLOW} Warning: could not parse ghost count: {e}{NC}")
error_count = 0
for i in range(2, total_slides + 1):
if not morph_verify_slide(deck, i):
error_count += 1
print("=========================================")
if error_count == 0:
print(f"{GREEN}All slides verified successfully!{NC}")
print(f"{GREEN} Your morph animations should work correctly.{NC}")
return True
else:
print(f"{RED}Found issues in {error_count} slide(s){NC}")
print(f"{RED} Please fix the issues above before delivering.{NC}")
return False
# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------
def clean_ghost_accumulation(deck, threshold=50):
"""Remove ghost shapes exceeding threshold (M-2 fix).
Deletes shapes at x≥34cm, keeping only the first N (buffer for morph exit).
Args:
deck: path to .pptx
threshold: max ghosts to keep (default 50)
Returns:
Number of shapes deleted
"""
print(f"{BLUE}Cleaning ghost accumulation...{NC}")
rc, out, _ = _run("officecli", "query", deck, "shape[x>=34cm]", "--json")
try:
data = json.loads(out).get("data", {})
results = data.get("results", [])
ghost_count = len(results)
if ghost_count <= threshold:
print(f"{GREEN} Ghost count already OK: {ghost_count}{threshold}{NC}")
return 0
# Sort by slide (ascending) so we delete oldest/leftmost first
to_delete = results[threshold:]
print(f"{YELLOW} Deleting {len(to_delete)} shapes (keeping {threshold})...{NC}")
for shape in to_delete:
shape_id = shape.get("format", {}).get("id")
shape_name = shape.get("format", {}).get("name", "?")
if shape_id:
_run("officecli", "remove", deck, f"/shape[@id={shape_id}]")
print(f" Removed: {shape_name} ({shape_id})")
print(f"{GREEN} Cleaned {len(to_delete)} shapes. Verify with: final-check{NC}")
return len(to_delete)
except Exception as e:
print(f"{RED} Error: {e}{NC}", file=sys.stderr)
return 0
def main():
parser = argparse.ArgumentParser(
prog="morph-helpers.py",
description="Morph PPT Helper Functions — cross-platform (Mac / Windows / Linux)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
commands:
clone <deck> <from_slide> <to_slide> Clone slide and set morph transition
ghost <deck> <slide> <idx> [idx ...] Ghost multiple shapes off-screen (x=36cm)
verify <deck> <slide> Verify slide setup (transition + ghosting)
final-check <deck> Verify entire deck (+ M-2 ghost accumulation check)
clean-accumulation <deck> Remove excess ghost shapes (M-2 fix)
example:
python morph-helpers.py clone deck.pptx 1 2
python morph-helpers.py ghost deck.pptx 2 7 8 9
python morph-helpers.py verify deck.pptx 2
python morph-helpers.py final-check deck.pptx
python morph-helpers.py clean-accumulation deck.pptx
""",
)
sub = parser.add_subparsers(dest="command")
p = sub.add_parser("clone")
p.add_argument("deck")
p.add_argument("from_slide", type=int)
p.add_argument("to_slide", type=int)
p = sub.add_parser("ghost")
p.add_argument("deck")
p.add_argument("slide", type=int)
p.add_argument("shapes", nargs="+", type=int)
p = sub.add_parser("verify")
p.add_argument("deck")
p.add_argument("slide", type=int)
p = sub.add_parser("final-check")
p.add_argument("deck")
p = sub.add_parser("clean-accumulation")
p.add_argument("deck")
args = parser.parse_args()
if args.command == "clone":
morph_clone_slide(args.deck, args.from_slide, args.to_slide)
elif args.command == "ghost":
morph_ghost_content(args.deck, args.slide, *args.shapes)
elif args.command == "verify":
if not morph_verify_slide(args.deck, args.slide):
sys.exit(1)
elif args.command == "final-check":
if not morph_final_check(args.deck):
sys.exit(1)
elif args.command == "clean-accumulation":
clean_ghost_accumulation(args.deck)
else:
parser.print_help()
if __name__ == "__main__":
main()