#!/usr/bin/env python3 """Export a PPTD project to PPTX. Default path (preferred): local patched official WASM writer scripts/local-export/export-pptd.mjs --no-sign → offline, no cookie, no signature API, no browser UI. Optional --browser path: local neo-ppt mirror via agent-browser (same UI as `npx open-kimi-ppt-skill serve`, no www.kimi.com). Image QA (`export_images.py`) uses the same local editor host. """ from __future__ import annotations import argparse import base64 import json import mimetypes import os import re import shutil import socket import subprocess import sys import tempfile import threading import time import urllib.request import uuid import zipfile import xml.etree.ElementTree as ET from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple from urllib.parse import urlparse SKILL_DIR = Path(__file__).resolve().parent.parent IMAGE_MIME = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".svg": "image/svg+xml", } MAX_IMAGE_BYTES = 20 * 1024 * 1024 MAX_EMBEDDED_MEDIA_BYTES = 200 * 1024 * 1024 PPTX_CONTENT_TYPE = ( "application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml" ) FADE_TRANSITION_XML = ( '' ) MIN_AGENT_BROWSER_VERSION = (0, 33, 2) MIN_NODE_MAJOR = 18 NODE_INSTALL_HINT = "Install Node.js 18+ from https://nodejs.org, then retry." EDITOR_MISSING_HINT = ( "local neo-ppt editor not found. Re-run " "`npx open-kimi-ppt-skill install` (copies editor into the skill) " "or set OPEN_KIMI_PPT_EDITOR to the editor directory." ) class ExportError(RuntimeError): pass class QuietHandler(SimpleHTTPRequestHandler): def log_message(self, _format: str, *_args: Any) -> None: return def log(message: str) -> None: print(f"[open-kimi-ppt] {message}", file=sys.stderr, flush=True) def run_command( command: Sequence[str], *, cwd: Optional[Path] = None, env: Optional[Dict[str, str]] = None, timeout: int = 90, ) -> subprocess.CompletedProcess[str]: """Capture merged stdout/stderr via a temp file. On Windows, agent-browser's detached daemon can inherit a PIPE handle and prevent EOF, deadlocking ``subprocess.run(stdout=PIPE)``. Decoding with the system locale (GBK on zh-CN Windows) can also raise UnicodeDecodeError. Writing to a UTF-8 file avoids both failures. """ handle, sink_path = tempfile.mkstemp(prefix="open-kimi-ppt-", suffix=".log") os.close(handle) sink = Path(sink_path) output = "" try: with sink.open("w", encoding="utf-8", errors="replace") as out: returncode = subprocess.call( list(command), cwd=str(cwd) if cwd is not None else None, env=env, stdout=out, stderr=subprocess.STDOUT, timeout=timeout, ) output = sink.read_text(encoding="utf-8", errors="replace") except subprocess.TimeoutExpired as exc: try: output = sink.read_text(encoding="utf-8", errors="replace") except OSError: output = "" raise subprocess.TimeoutExpired( cmd=list(command), timeout=timeout, output=output, ) from exc finally: try: sink.unlink(missing_ok=True) except OSError: # WinError 32: daemon may still hold the log file handle. pass return subprocess.CompletedProcess(list(command), returncode, output, None) def temporary_directory(prefix: str) -> Any: # ignore_cleanup_errors avoids masking the real export error when a Windows # browser daemon still holds files under the temp tree (Python 3.10+). try: return tempfile.TemporaryDirectory(prefix=prefix, ignore_cleanup_errors=True) except TypeError: return tempfile.TemporaryDirectory(prefix=prefix) def default_downloads_dir() -> Path: home = Path.home() candidates: List[Path] = [] user_profile = os.environ.get("USERPROFILE") if user_profile: candidates.append(Path(user_profile) / "Downloads") candidates.extend((home / "Downloads", home / "下载")) for path in candidates: if path.is_dir(): return path return home / "Downloads" def ensure_pyyaml() -> Any: try: import yaml except ImportError: log("PyYAML is required; installing pyyaml with pip --user") process = run_command( [sys.executable, "-m", "pip", "install", "--user", "pyyaml"], timeout=300, ) if process.returncode != 0: raise ExportError( "failed to install PyYAML with pip --user:\n" f"{process.stdout[-2000:]}\n" "Install it manually with: python3 -m pip install --user pyyaml" ) import yaml return yaml yaml = ensure_pyyaml() def parse_version(output: str) -> Tuple[int, int, int]: match = re.search(r"(\d+)\.(\d+)\.(\d+)\b", output) if not match: raise ExportError(f"could not parse agent-browser version from: {output.strip()}") return tuple(int(part) for part in match.groups()) def parse_node_version(output: str) -> Tuple[int, int, int]: match = re.search(r"v?(\d+)\.(\d+)\.(\d+)\b", output) if not match: raise ExportError(f"could not parse Node.js version from: {output.strip()}") return tuple(int(part) for part in match.groups()) def read_agent_browser_version(executable: str) -> Tuple[int, int, int]: process = run_command([executable, "--version"], timeout=30) if process.returncode != 0: raise ExportError(f"agent-browser --version failed:\n{process.stdout[-2000:]}") return parse_version(process.stdout) def ensure_nodejs() -> str: executable = shutil.which("node") if not executable: raise ExportError(f"Node.js is not installed or not on PATH. {NODE_INSTALL_HINT}") process = run_command([executable, "--version"], timeout=30) if process.returncode != 0: raise ExportError(f"node --version failed:\n{process.stdout[-2000:]}") version = parse_node_version(process.stdout) if version[0] < MIN_NODE_MAJOR: raise ExportError( f"Node.js {MIN_NODE_MAJOR}+ is required; found " f"{'.'.join(map(str, version))} ({process.stdout.strip()}). {NODE_INSTALL_HINT}" ) npm = shutil.which("npm") if not npm: raise ExportError( "npm is not installed or not on PATH. " f"npm ships with Node.js. {NODE_INSTALL_HINT}" ) log(f"Node.js version: {'.'.join(map(str, version))}") return executable def ensure_agent_browser() -> str: ensure_nodejs() executable = shutil.which("agent-browser") version = read_agent_browser_version(executable) if executable else None if version is not None and version >= MIN_AGENT_BROWSER_VERSION: log(f"agent-browser version: {'.'.join(map(str, version))}") return executable npm = shutil.which("npm") if not npm: reason = "not installed" if version is None else ".".join(map(str, version)) raise ExportError( f"agent-browser {reason}; npm is required to install agent-browser@latest. " f"npm ships with Node.js. {NODE_INSTALL_HINT}" ) current = "not installed" if version is None else ".".join(map(str, version)) minimum = ".".join(map(str, MIN_AGENT_BROWSER_VERSION)) log(f"agent-browser {current} is below {minimum}; installing agent-browser@latest") process = run_command( [npm, "install", "-g", "agent-browser@latest"], timeout=300, ) if process.returncode != 0: raise ExportError(f"failed to install agent-browser@latest:\n{process.stdout[-4000:]}") executable = shutil.which("agent-browser") if not executable: raise ExportError("agent-browser@latest installed, but executable is not on PATH") version = read_agent_browser_version(executable) if version < MIN_AGENT_BROWSER_VERSION: raise ExportError( "agent-browser@latest is still below the required version " f"{minimum}: {'.'.join(map(str, version))}" ) log(f"agent-browser upgraded to {'.'.join(map(str, version))}") return executable DEBUG_CHROME_PORT = 9337 CHROME_CANDIDATES = ( r"C:\Program Files\Google\Chrome\Application\chrome.exe", r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", str(Path.home() / "AppData" / "Local" / "Google" / "Chrome" / "Application" / "chrome.exe"), r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", r"C:\Program Files\Microsoft\Edge\Application\msedge.exe", ) def cdp_alive(port: int) -> bool: try: with urllib.request.urlopen(f"http://127.0.0.1:{port}/json/version", timeout=2): return True except OSError: return False def ensure_debug_chrome() -> Optional[int]: """Return a CDP port for agent-browser to connect to (Windows only). On Windows agent-browser cannot launch Chrome itself: the Chrome launcher process hands off to a child and exits, which agent-browser mistakes for a crash ("Chrome exited early without writing DevToolsActivePort"). The export therefore always drives an externally started browser. An already-working AGENT_BROWSER_CDP wins; otherwise a dedicated debug instance is started (or reused) on port 9337. The instance is left running on purpose: relaunching with the same profile joins the existing browser, so repeated exports reuse one instance instead of piling up processes. """ if sys.platform != "win32": return None explicit = os.environ.get("AGENT_BROWSER_CDP") if explicit: try: if cdp_alive(int(explicit)): return int(explicit) except ValueError: pass log(f"AGENT_BROWSER_CDP={explicit} is not answering; starting a debug browser instead") port = DEBUG_CHROME_PORT if cdp_alive(port): return port with socket.socket() as probe: if probe.connect_ex(("127.0.0.1", port)) == 0: # Port taken by something that is not a CDP endpoint. with socket.socket() as spare: spare.bind(("127.0.0.1", 0)) port = spare.getsockname()[1] executable = next((c for c in CHROME_CANDIDATES if Path(c).is_file()), None) if executable is None: raise ExportError( "no Chrome or Edge found to drive the export; install Google Chrome, " "or start a browser with --remote-debugging-port yourself and set " "AGENT_BROWSER_CDP to that port" ) profile = Path(tempfile.gettempdir()) / "okp-cdp-profile" log(f"starting debug browser on port {port}: {executable}") subprocess.Popen( [ executable, f"--user-data-dir={profile}", f"--remote-debugging-port={port}", "--no-first-run", "--no-default-browser-check", "--window-position=-2400,0", ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP, ) deadline = time.monotonic() + 20 while time.monotonic() < deadline: if cdp_alive(port): return port time.sleep(0.5) raise ExportError(f"debug browser did not open CDP port {port} within 20s") def find_manifest(source: Path) -> Path: source = source.expanduser().resolve() if source.is_file(): if source.suffix.lower() != ".pptd": raise ExportError(f"input must be a .pptd file or project directory: {source}") return source if not source.is_dir(): raise ExportError(f"input does not exist: {source}") manifests = sorted(source.rglob("*.pptd")) if not manifests: raise ExportError(f"no .pptd manifest found under: {source}") if len(manifests) > 1: choices = "\n ".join(str(path) for path in manifests[:20]) raise ExportError( "multiple .pptd manifests found; pass one manifest explicitly:\n " + choices ) return manifests[0] def read_yaml_mapping(path: Path) -> Tuple[str, Dict[str, Any]]: text = path.read_text(encoding="utf-8") try: value = yaml.safe_load(text) except yaml.YAMLError as exc: raise ExportError(f"invalid YAML in {path}: {exc}") from exc if not isinstance(value, dict): raise ExportError(f"expected a YAML mapping in {path}") return text, value def safe_project_path(root: Path, relative: str) -> Path: if not isinstance(relative, str) or not relative.strip(): raise ExportError("page path must be a non-empty string") candidate = (root / relative).resolve() try: candidate.relative_to(root) except ValueError as exc: raise ExportError(f"project path escapes the PPTD directory: {relative}") from exc return candidate def build_image_map(root: Path) -> Dict[str, str]: image_map: Dict[str, str] = {} total = 0 for path in sorted(root.rglob("*")): if not path.is_file() or path.suffix.lower() not in IMAGE_MIME: continue size = path.stat().st_size if size > MAX_IMAGE_BYTES: log(f"skip local image over 20 MiB: {path.relative_to(root)}") continue if total + size > MAX_EMBEDDED_MEDIA_BYTES: raise ExportError( "local image payload exceeds 200 MiB; reduce media size or use remote URLs" ) data = base64.b64encode(path.read_bytes()).decode("ascii") rel = path.relative_to(root).as_posix() image_map[rel] = f"data:{IMAGE_MIME[path.suffix.lower()]};base64,{data}" total += size if image_map: log(f"prepared {len(image_map)} local image resource(s), {total} bytes") return image_map def build_payload(manifest: Path) -> Dict[str, Any]: manifest_text, manifest_data = read_yaml_mapping(manifest) if manifest_data.get("version") != "v2": raise ExportError("local PPTX export currently requires PPTD version: v2") page_paths = manifest_data.get("pages") if not isinstance(page_paths, list) or not page_paths: raise ExportError("PPTD manifest must contain a non-empty pages list") root = manifest.parent.resolve() pages: List[Dict[str, str]] = [] for entry in page_paths: page_path = safe_project_path(root, entry) if not page_path.is_file(): raise ExportError(f"missing page file: {entry}") page_text, page_data = read_yaml_mapping(page_path) if not isinstance(page_data.get("elements"), list): raise ExportError(f"page elements must be an array: {entry}") pages.append({"path": str(entry), "content": page_text}) title = str(manifest_data.get("title") or manifest.stem) return { "id": f"local-export-{uuid.uuid4().hex}", "title": title, "manifestPath": manifest.name, "manifestContent": manifest_text, "pages": pages, "imageMap": build_image_map(root), } def json_result(output: str) -> Dict[str, Any]: for line in reversed(output.splitlines()): line = line.strip() if not line.startswith("{"): continue try: value = json.loads(line) except json.JSONDecodeError: continue if isinstance(value, dict): return value raise ExportError(f"agent-browser returned no JSON object:\n{output[-2000:]}") class BrowserSession: def __init__( self, executable: str, session: str, cwd: Path, download_dir: Path, cdp_port: Optional[int] = None, ): self.executable = executable self.session = session self.cwd = cwd # Kept as a search root fallback; not passed to agent-browser. On Windows, # --download-path can be rewritten to a \\?\ path that cancels Chrome downloads. self.download_dir = download_dir self.env = os.environ.copy() self.env.setdefault("AGENT_BROWSER_DEFAULT_TIMEOUT", "60000") self.env.setdefault("AGENT_BROWSER_IDLE_TIMEOUT_MS", "180000") # Local editor host is 127.0.0.1; corporate HTTP(S)_PROXY would otherwise # intercept and 403 the offline export page. for key in ( "http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "all_proxy", "socks5_proxy", "SOCKS5_PROXY", ): self.env.pop(key, None) no_proxy = self.env.get("NO_PROXY") or self.env.get("no_proxy") or "" parts = {p.strip() for p in no_proxy.split(",") if p.strip()} parts.update({"127.0.0.1", "localhost", "::1"}) joined = ",".join(sorted(parts)) self.env["NO_PROXY"] = joined self.env["no_proxy"] = joined if cdp_port is not None: self.env["AGENT_BROWSER_CDP"] = str(cdp_port) def run( self, args: Sequence[str], *, timeout: int = 90, check: bool = True, ) -> subprocess.CompletedProcess[str]: command = [self.executable, "--session", self.session, *args] process = run_command(command, cwd=self.cwd, env=self.env, timeout=timeout) if check and process.returncode != 0: raise ExportError( f"agent-browser command failed ({process.returncode}): " f"{' '.join(args)}\n{process.stdout[-4000:]}" ) return process def open(self, url: str) -> None: # Avoid --download-path: agent-browser ≤0.33.2 + Chrome may cancel downloads # when given a verbatim Windows path. Files land in the default Downloads folder. self.run(["open", url], timeout=90) def snapshot(self) -> Dict[str, Any]: process = self.run(["snapshot", "-i", "-C", "--json"]) return json_result(process.stdout) def close(self) -> None: self.run(["close"], timeout=20, check=False) def snapshot_data(snapshot: Dict[str, Any]) -> Dict[str, Any]: data = snapshot.get("data") if not isinstance(data, dict): raise ExportError(f"invalid agent-browser snapshot: {snapshot}") return data def ref_by_name(snapshot: Dict[str, Any], name: str, role: Optional[str] = None) -> str: refs = snapshot_data(snapshot).get("refs") if not isinstance(refs, dict): raise ExportError("snapshot contains no interactive refs") matches = [] for ref, metadata in refs.items(): if not isinstance(metadata, dict) or metadata.get("name") != name: continue if role is not None and str(metadata.get("role", "")).lower() != role.lower(): continue matches.append(ref) if not matches: raise ExportError(f"could not find {role or 'element'} named {name!r}") return matches[-1] def switch_state(snapshot: Dict[str, Any]) -> Optional[Tuple[str, bool, bool]]: text = str(snapshot_data(snapshot).get("snapshot") or "") match = re.search(r"switch \[(?P[^\]]*?)ref=(?Pe\d+)\]", text) if not match: return None attrs = match.group("attrs") return match.group("ref"), "checked=true" in attrs, "disabled" in attrs def wait_for_export_dialog(browser: BrowserSession, timeout: float = 20.0) -> Dict[str, Any]: deadline = time.monotonic() + timeout last: Optional[Dict[str, Any]] = None while time.monotonic() < deadline: last = browser.snapshot() try: ref_by_name(last, "下载", "button") return last except ExportError: time.sleep(0.35) raise ExportError(f"export dialog did not become ready: {last}") def is_pptx(path: Path) -> bool: if not path.is_file() or path.name.endswith(".crdownload"): return False try: with zipfile.ZipFile(path) as archive: if "ppt/presentation.xml" not in archive.namelist(): return False content_types = archive.read("[Content_Types].xml") return PPTX_CONTENT_TYPE.encode("utf-8") in content_types except (OSError, KeyError, zipfile.BadZipFile): return False def find_download( search_roots: Iterable[Path], timeout: float = 150.0, accept: Callable[[Path], bool] = is_pptx, *, since: Optional[float] = None, ) -> Path: deadline = time.monotonic() + timeout last_sizes: Dict[Path, int] = {} stable: Dict[Path, int] = {} while time.monotonic() < deadline: # Snapshot stats while collecting and tolerate races everywhere: the # search roots include the live Downloads folder, where Chrome renames # .crdownload files away between directory listing and stat(). entries: List[Tuple[Path, float, int]] = [] for root in search_roots: if not root.exists(): continue for path in root.rglob("*"): if not path.is_file(): continue try: info = path.stat() except OSError: continue entries.append((path, info.st_mtime, info.st_size)) for path, mtime, size in sorted(entries, key=lambda entry: entry[1], reverse=True): if since is not None and mtime < since: continue if size == last_sizes.get(path) and size > 0: stable[path] = stable.get(path, 0) + 1 else: stable[path] = 0 last_sizes[path] = size if stable[path] >= 1 and accept(path): return path time.sleep(0.5) visible = "\n ".join(str(path) for path in last_sizes) or "(none)" raise ExportError(f"timed out waiting for download; observed files:\n {visible}") def replace_transition(slide_xml: bytes, transition: str) -> bytes: text = slide_xml.decode("utf-8") pattern = re.compile( r"]*(?:/>|>.*?)", re.DOTALL ) text = pattern.sub("", text) if transition == "none": return text.encode("utf-8") # CT_Slide requires transition as a direct child after cSld/clrMapOvr and # before timing/extLst. Searching for the first p:extLst is incorrect: # shapes may contain their own nested extLst inside cSld, causing Office to # ignore a transition inserted there. color_map = re.search( r"]*(?:/>|>.*?)", text, re.DOTALL ) common_slide = re.search( r"]*(?:/>|>.*?)", text, re.DOTALL ) anchor = color_map or common_slide if anchor is None: raise ExportError("slide XML has no cSld/clrMapOvr insertion anchor") position = anchor.end() return (text[:position] + FADE_TRANSITION_XML + text[position:]).encode("utf-8") def root_child_names(slide_xml: bytes) -> List[str]: try: root = ET.fromstring(slide_xml) except ET.ParseError as exc: raise ExportError(f"invalid slide XML: {exc}") from exc return [child.tag.rsplit("}", 1)[-1] for child in root] def has_direct_fade_transition(slide_xml: bytes) -> bool: try: root = ET.fromstring(slide_xml) except ET.ParseError as exc: raise ExportError(f"invalid slide XML: {exc}") from exc transition = next( (child for child in root if child.tag.rsplit("}", 1)[-1] == "transition"), None, ) if transition is None: return False return any(child.tag.rsplit("}", 1)[-1] == "fade" for child in transition) def validate_transition_order(slide_xml: bytes, transition: str) -> None: names = root_child_names(slide_xml) transition_indexes = [index for index, name in enumerate(names) if name == "transition"] if transition == "none": if transition_indexes: raise ExportError("transition=none left a root-level transition") return if len(transition_indexes) != 1 or not has_direct_fade_transition(slide_xml): raise ExportError("slide does not contain exactly one root-level fade transition") transition_index = transition_indexes[0] for required_before in ("cSld", "clrMapOvr"): if required_before in names and names.index(required_before) > transition_index: raise ExportError(f"{required_before} appears after transition") for required_after in ("timing", "extLst"): if required_after in names and names.index(required_after) < transition_index: raise ExportError(f"{required_after} appears before transition") def patch_transitions(pptx: Path, transition: str) -> int: temporary = pptx.with_name(f".{pptx.name}.{uuid.uuid4().hex}.tmp") slide_count = 0 try: with zipfile.ZipFile(pptx, "r") as source, zipfile.ZipFile(temporary, "w") as target: target.comment = source.comment for info in source.infolist(): data = source.read(info.filename) if re.fullmatch(r"ppt/slides/slide\d+\.xml", info.filename): data = replace_transition(data, transition) slide_count += 1 target.writestr(info, data, compress_type=info.compress_type) if slide_count == 0: raise ExportError("exported PPTX contains no slide XML") temporary.replace(pptx) finally: temporary.unlink(missing_ok=True) return slide_count def verify_output(pptx: Path, transition: str, expect_fonts: bool) -> Dict[str, Any]: if not is_pptx(pptx): raise ExportError(f"output is not a valid PPTX ZIP: {pptx}") with zipfile.ZipFile(pptx) as archive: broken = archive.testzip() if broken: raise ExportError(f"PPTX CRC check failed at: {broken}") slide_names = [ name for name in archive.namelist() if re.fullmatch(r"ppt/slides/slide\d+\.xml", name) ] slide_xml = {name: archive.read(name) for name in slide_names} for data in slide_xml.values(): validate_transition_order(data, transition) transition_hits = sum(has_direct_fade_transition(data) for data in slide_xml.values()) if transition == "fade" and transition_hits != len(slide_names): raise ExportError("fade transition was not written to every slide") fonts = [ name for name in archive.namelist() if name.startswith("ppt/fonts/") and not name.endswith("/") ] if expect_fonts and not fonts: log( "warning: embed-fonts was enabled, but the official writer produced no font part" ) return { "slides": len(slide_names), "fadeTransitions": transition_hits, "fontParts": len(fonts), "bytes": pptx.stat().st_size, } def resolve_editor_root() -> Path: """Locate the offline neo-ppt mirror (package editor/ or skill-installed copy).""" env = os.environ.get("OPEN_KIMI_PPT_EDITOR") candidates: List[Path] = [] if env: candidates.append(Path(env).expanduser().resolve()) candidates.append(SKILL_DIR / "editor") # monorepo / npm package: skills/open-kimi-ppt → package root candidates.append(SKILL_DIR.parent.parent / "editor") for candidate in candidates: if (candidate / "index.html").is_file(): return candidate raise ExportError(EDITOR_MISSING_HINT) def serve( directory: Path, *, entry: str = "index.html", ) -> Tuple[ThreadingHTTPServer, threading.Thread, str]: """Legacy static serve (tests / callers). Prefer serve_local_editor for exports.""" handler = lambda *args, **kwargs: QuietHandler( # noqa: E731 *args, directory=str(directory), **kwargs ) server = ThreadingHTTPServer(("127.0.0.1", 0), handler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() host, port = server.server_address return server, thread, f"http://{host}:{port}/{entry.lstrip('/')}" def serve_local_editor( payload: Dict[str, Any], ) -> Tuple[ThreadingHTTPServer, threading.Thread, str]: """Serve the offline editor and inject payload.json for headless export.""" editor_root = resolve_editor_root() payload_bytes = json.dumps(payload, ensure_ascii=False).encode("utf-8") class LocalEditorHandler(QuietHandler): def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, directory=str(editor_root), **kwargs) def _is_payload(self) -> bool: path = urlparse(self.path).path return path in ("/payload.json", "payload.json") def do_GET(self) -> None: # noqa: N802 if self._is_payload(): self.send_response(200) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(payload_bytes))) self.send_header("Cache-Control", "no-store") self.send_header("Access-Control-Allow-Origin", "*") self.end_headers() self.wfile.write(payload_bytes) return return SimpleHTTPRequestHandler.do_GET(self) def do_HEAD(self) -> None: # noqa: N802 if self._is_payload(): self.send_response(200) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(payload_bytes))) self.send_header("Cache-Control", "no-store") self.end_headers() return return SimpleHTTPRequestHandler.do_HEAD(self) server = ThreadingHTTPServer(("127.0.0.1", 0), LocalEditorHandler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() host, port = server.server_address url = f"http://{host}:{port}/?ndExport=1" log(f"local editor host: {url} (root={editor_root})") return server, thread, url LOCAL_EXPORT_DIR = Path(__file__).resolve().parent / "local-export" LOCAL_EXPORT_MJS = LOCAL_EXPORT_DIR / "export-pptd.mjs" # Canonical patched WASM: editor/neo-ppt/assets/ (repo/npm). Skill install copies it # into local-export/pptd_wasm_bg.wasm for ~/.agents|~/.claude skills trees. CANONICAL_WASM_NAME = "pptd_wasm_bg-DPPWdROu.wasm" def resolve_local_wasm() -> Path: package_root = Path(__file__).resolve().parents[3] candidates = [ LOCAL_EXPORT_DIR / "pptd_wasm_bg.wasm", LOCAL_EXPORT_DIR / CANONICAL_WASM_NAME, SKILL_DIR / "editor" / "neo-ppt" / "assets" / CANONICAL_WASM_NAME, package_root / "editor" / "neo-ppt" / "assets" / CANONICAL_WASM_NAME, ] for candidate in candidates: if candidate.is_file(): return candidate raise ExportError( "patched WASM not found. Expected " f"editor/neo-ppt/assets/{CANONICAL_WASM_NAME} (repo/npm package) or " f"{LOCAL_EXPORT_DIR / 'pptd_wasm_bg.wasm'} (after skill install)." ) def export_pptx_local( source: Path, output: Path, transition: str, force: bool = False, ) -> Dict[str, Any]: """Export via local patched official WASM (no cookie / no browser UI).""" if not LOCAL_EXPORT_MJS.is_file(): raise ExportError(f"local exporter missing: {LOCAL_EXPORT_MJS}") wasm_path = resolve_local_wasm() node = shutil.which("node") if not node: raise ExportError("node is required for local WASM export") manifest = find_manifest(source) output = output.expanduser().resolve() output.parent.mkdir(parents=True, exist_ok=True) if output.exists() and not force: raise ExportError(f"output already exists (pass --force to replace it): {output}") log(f"local WASM export: {manifest} → {output}") log(f"defaults: transition={transition} (no-sign / signature bypassed)") # Pass project directory so media paths resolve relative to the deck root. project_dir = manifest.parent if manifest.is_file() else source cmd = [ node, str(LOCAL_EXPORT_MJS), str(project_dir), "-o", str(output), "--no-sign", "--transition", transition if transition in ("fade", "none") else "fade", "--wasm", str(wasm_path), ] process = subprocess.run( cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=300, ) if process.returncode != 0: raise ExportError( f"local WASM export failed ({process.returncode}):\n{process.stdout[-4000:]}" ) slide_count = patch_transitions(output, transition) summary = verify_output(output, transition, expect_fonts=False) summary["transitionPatchedSlides"] = slide_count summary["output"] = str(output) summary["exporter"] = "local-wasm-patched" log(f"local export ok: {output} ({output.stat().st_size} bytes)") return summary def export_pptx( source: Path, output: Path, transition: str, embed_fonts: bool, keep_download: bool = False, force: bool = False, prefer_local: bool = True, ) -> Dict[str, Any]: """Prefer local patched WASM; fall back to local neo-ppt browser UI.""" if prefer_local: try: return export_pptx_local(source, output, transition, force=force) except ExportError as exc: log(f"local WASM export unavailable ({exc}); falling back to local browser editor") manifest = find_manifest(source) payload = build_payload(manifest) output = output.expanduser().resolve() output.parent.mkdir(parents=True, exist_ok=True) if output.exists() and not force: raise ExportError(f"output already exists (pass --force to replace it): {output}") agent_browser = ensure_agent_browser() cdp_port = ensure_debug_chrome() log(f"manifest: {manifest}") log( f"defaults: transition={transition}, embed_fonts={'on' if embed_fonts else 'off'}" ) with temporary_directory(prefix="open-kimi-ppt-export-") as temp_name: temp_dir = Path(temp_name) download_dir = temp_dir / "downloads" download_dir.mkdir() server, thread, url = serve_local_editor(payload) session = f"open-kimi-ppt-export-{os.getpid()}-{uuid.uuid4().hex[:8]}" browser = BrowserSession(agent_browser, session, temp_dir, download_dir, cdp_port) downloads = default_downloads_dir() try: log("opening the local neo-ppt editor") browser.open(url) browser.run( [ "wait", "--fn", 'document.documentElement.dataset.deckStatus === "ready"', ], timeout=120, ) browser.run(["set", "viewport", "1280", "720"]) snapshot = browser.snapshot() export_ref = ref_by_name(snapshot, "导出", "button") browser.run(["click", f"@{export_ref}"]) dialog = wait_for_export_dialog(browser) state = switch_state(dialog) if state is not None: switch_ref, checked, disabled = state if disabled and checked != embed_fonts: log("warning: the font switch is disabled for this deck") elif checked != embed_fonts: browser.run(["click", f"@{switch_ref}"]) dialog = wait_for_export_dialog(browser) elif embed_fonts: log("warning: the export dialog exposed no font switch") # Plain click (not agent-browser `download`) so Chrome saves to the # default Downloads folder; --download-path is broken on some Windows setups. started_at = time.time() - 1.0 download_ref = ref_by_name(dialog, "下载", "button") log("generating PPTX in the local editor") browser.run(["click", f"@{download_ref}"], timeout=180) downloaded = find_download( (downloads, download_dir, temp_dir), timeout=90, since=started_at, ) shutil.copy2(downloaded, output) if keep_download: debug_copy = output.with_name(f"{output.stem}.browser-raw.pptx") if debug_copy.exists() and not force: raise ExportError( f"raw debug output already exists (pass --force): {debug_copy}" ) shutil.copy2(downloaded, debug_copy) try: if downloaded.resolve().parent == downloads.resolve(): downloaded.unlink(missing_ok=True) except OSError: pass finally: browser.close() server.shutdown() server.server_close() thread.join(timeout=2) slide_count = patch_transitions(output, transition) summary = verify_output(output, transition, embed_fonts) summary["transitionPatchedSlides"] = slide_count summary["output"] = str(output) summary["exporter"] = "browser-local-editor" return summary def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace: parser = argparse.ArgumentParser( description=( "Export a PPTD project to PPTX. " "Default: local patched official WASM (offline). " "Optional --browser uses the local neo-ppt mirror (also offline)." ) ) parser.add_argument("input", type=Path, help=".pptd manifest or project directory") parser.add_argument("--output", "-o", type=Path, help="output .pptx path") parser.add_argument( "--transition", choices=("fade", "none"), default="fade", help="slide transition written to every slide (default: fade)", ) font_group = parser.add_mutually_exclusive_group() font_group.add_argument( "--embed-fonts", dest="embed_fonts", action="store_true", default=True, help="embed fonts when available (browser path; default)", ) font_group.add_argument( "--no-embed-fonts", dest="embed_fonts", action="store_false", help="disable font embedding (browser path)", ) parser.add_argument( "--browser", action="store_true", help="force local neo-ppt browser UI path instead of Node WASM", ) parser.add_argument( "--keep-browser-raw", action="store_true", help="also keep the unpatched browser download beside the output", ) parser.add_argument( "--force", action="store_true", help="replace an existing output file", ) return parser.parse_args(argv) def main(argv: Optional[Sequence[str]] = None) -> int: args = parse_args(argv) try: manifest = find_manifest(args.input) output = args.output or manifest.with_suffix(".pptx") summary = export_pptx( args.input, output, args.transition, args.embed_fonts, args.keep_browser_raw, args.force, prefer_local=not args.browser, ) except (ExportError, OSError, subprocess.SubprocessError) as exc: print(f"open-kimi-ppt export failed: {exc}", file=sys.stderr) return 1 print(json.dumps(summary, ensure_ascii=False, indent=2)) return 0 if __name__ == "__main__": raise SystemExit(main())