#!/usr/bin/env python3 """ batch.py - Robust folder batch driver for the pptx-to-html skill. Converts every .pptx/.ppt/.pptm in a folder by invoking convert.py once PER FILE in its own isolated PowerPoint instance, guarded by a per-file timeout. This way a single problematic deck (one that makes PowerPoint hang on Open or Export) is killed and skipped instead of blocking the whole batch. Progress is written directly to --log (flushed per line) so it can be tailed live. A machine-readable summary is written to --result as JSON. Usage: python batch.py --input-dir "" --out "" \ --log "" --result "" [--timeout 300] [--scale 2.0] [--skip-existing] Safety: records pre-existing POWERPNT.EXE PIDs before each file and, on timeout, kills ONLY the new instance it spawned (never the user's already-open PowerPoint). Uses PID-based taskkill only. """ import argparse import csv import datetime import io import json import os import subprocess import sys import time EXTS = (".pptx", ".ppt", ".pptm") def powerpnt_pids(): try: out = subprocess.run( ["tasklist", "/FI", "IMAGENAME eq POWERPNT.EXE", "/FO", "CSV", "/NH"], capture_output=True, text=True, timeout=30).stdout except Exception: return set() pids = set() for row in csv.reader(io.StringIO(out)): if len(row) >= 2 and row[1].isdigit(): pids.add(int(row[1])) return pids def kill_pid(pid): try: subprocess.run(["taskkill", "/PID", str(pid), "/F", "/T"], capture_output=True, text=True, timeout=30) except Exception: pass def main(): ap = argparse.ArgumentParser(description="Robust folder batch driver for pptx-to-html.") ap.add_argument("--input-dir", required=True) ap.add_argument("--out", required=True) ap.add_argument("--log", required=True) ap.add_argument("--result", required=True) ap.add_argument("--timeout", type=int, default=300, help="Per-file timeout in seconds.") ap.add_argument("--scale", default="2.0") ap.add_argument("--skip-existing", action="store_true") ap.add_argument("--processed-dir", default="", help="If set, move each source deck here after a successful conversion.") args = ap.parse_args() convert_py = os.path.join(os.path.dirname(os.path.abspath(__file__)), "convert.py") os.makedirs(args.out, exist_ok=True) files = sorted( f for f in os.listdir(args.input_dir) if f.lower().endswith(EXTS) and not f.startswith("~$") and os.path.isfile(os.path.join(args.input_dir, f))) total = len(files) logf = open(args.log, "w", encoding="utf-8", buffering=1) # line-buffered def log(msg): stamp = datetime.datetime.now().strftime("%H:%M:%S") logf.write("[%s] %s\n" % (stamp, msg)) logf.flush() log("BATCH START: %d files, timeout=%ds, scale=%s" % (total, args.timeout, args.scale)) log("source: %s" % args.input_dir) log("output: %s" % args.out) if args.processed_dir: log("processed: %s" % args.processed_dir) processed, errors, skipped = [], [], [] for i, name in enumerate(files, start=1): src = os.path.join(args.input_dir, name) base = os.path.splitext(name)[0] out_html = os.path.join(args.out, base + ".html") if args.skip_existing and os.path.exists(out_html) and os.path.getsize(out_html) > 0: skipped.append(name) log("[%d/%d] skip (exists): %s" % (i, total, name)) continue log("[%d/%d] start: %s" % (i, total, name)) baseline = powerpnt_pids() t0 = time.time() cmd = [sys.executable, convert_py, "--input", src, "--out", args.out, "--scale", args.scale] if args.processed_dir: cmd += ["--processed-dir", args.processed_dir] proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) try: out_s, _err_s = proc.communicate(timeout=args.timeout) dt = time.time() - t0 slides = None moved = False try: summary = json.loads(out_s) if summary.get("processed"): slides = summary["processed"][0].get("slides") moved = bool(summary["processed"][0].get("moved_to")) except Exception: summary = None if os.path.exists(out_html) and os.path.getsize(out_html) > 0: processed.append({"file": name, "slides": slides, "moved": moved}) extra = " -> moved" if moved else "" log("[%d/%d] done: %s (%s slides, %.0fs)%s" % (i, total, name, slides, dt, extra)) else: msg = "" if summary and summary.get("errors"): msg = summary["errors"][0].get("error", "") errors.append({"file": name, "error": "no output; %s" % msg}) log("[%d/%d] ERROR: %s -> no output produced. %s" % (i, total, name, msg)) except subprocess.TimeoutExpired: proc.kill() try: proc.communicate(timeout=15) except Exception: pass # Kill only the PowerPoint instance we spawned for this file. orphans = powerpnt_pids() - baseline for pid in orphans: kill_pid(pid) errors.append({"file": name, "error": "timeout after %ds" % args.timeout}) log("[%d/%d] TIMEOUT: %s (killed %d orphan PowerPoint proc)" % (i, total, name, len(orphans))) time.sleep(2) summary = { "total": total, "converted": len(processed), "errors": errors, "skipped": skipped, "processed": processed, } with open(args.result, "w", encoding="utf-8") as f: json.dump(summary, f, indent=2) log("BATCH COMPLETE: %d converted, %d errors, %d skipped (of %d)" % (len(processed), len(errors), len(skipped), total)) logf.close() return 0 if __name__ == "__main__": sys.exit(main())