feat: 1.3.0 release, fix export error

This commit is contained in:
binaryify
2026-08-07 12:25:28 +08:00
parent c32890fe09
commit e37d865f70
235 changed files with 2823 additions and 2011 deletions
@@ -1,175 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Open Kimi PPT Local Export</title>
<style>
html,
body,
iframe {
width: 100%;
height: 100%;
margin: 0;
border: 0;
}
#status {
position: fixed;
z-index: 10;
top: 8px;
left: 50%;
transform: translateX(-50%);
padding: 4px 10px;
border-radius: 6px;
background: rgb(0 0 0 / 72%);
color: white;
font: 12px/1.5 system-ui, sans-serif;
pointer-events: none;
}
</style>
</head>
<body>
<div id="status">starting</div>
<iframe id="editor" allow="fullscreen; clipboard-read; clipboard-write"></iframe>
<script type="module">
import {
i as connect,
r as WindowMessenger,
} from "https://statics.moonshot.cn/neo-design/assets/penpal-C4NjirZE.js";
const status = document.querySelector("#status");
const frame = document.querySelector("#editor");
const editorOrigin = "https://www.kimi.com";
const setStatus = (value) => {
status.textContent = value;
document.documentElement.dataset.deckStatus = value;
};
const normalizePath = (value) => {
if (typeof value !== "string") return "";
if (/^(?:data:image\/|https?:\/\/|blob:)/i.test(value)) return value;
let candidate = value.replace(/^file:\/\/+/, "").replaceAll("\\", "/");
try {
candidate = decodeURIComponent(candidate);
} catch {
// Preserve literal percent signs.
}
const parts = [];
for (const part of candidate.split("/")) {
if (!part || part === ".") continue;
if (part === "..") parts.pop();
else parts.push(part);
}
return parts.join("/");
};
const resolveImage = (requestedPath, imageMap) => {
if (/^(?:data:image\/|https?:\/\/|blob:)/i.test(requestedPath || "")) {
return requestedPath;
}
const normalized = normalizePath(requestedPath);
if (imageMap[normalized]) return imageMap[normalized];
const suffix = Object.keys(imageMap).find(
(path) => normalized.endsWith(`/${path}`) || path.endsWith(`/${normalized}`),
);
return suffix ? imageMap[suffix] : "";
};
try {
const payload = await fetch("./payload.json", { cache: "no-store" }).then((response) => {
if (!response.ok) throw new Error(`payload HTTP ${response.status}`);
return response.json();
});
const query = new URLSearchParams({
sdkMode: "ppt-editor",
pptPlatform: "open-kimi-ppt-local-export",
functional: JSON.stringify({
fullscreen: false,
present: false,
export: true,
close: false,
annotation: false,
feedback: false,
share: false,
versionHistory: false,
}),
sdkSaveMode: "external",
sdkImageMode: "external",
});
frame.addEventListener(
"load",
async () => {
setStatus("iframe-loaded");
try {
const messenger = new WindowMessenger({
remoteWindow: frame.contentWindow,
allowedOrigins: [editorOrigin],
});
const connection = connect({
messenger,
methods: {
close() {},
reenter() {},
toggleFullScreen(value) {
return value;
},
showFeedback() {},
sendPrompt() {},
showMessage() {},
hideMessage() {},
onSave(savePayload) {
return {
fileContent: savePayload?.fileContent,
lastModifiedTime: Date.now(),
};
},
getImages(imagePayload = {}) {
const paths = Array.isArray(imagePayload.filePath) ? imagePayload.filePath : [];
return paths.map((path) => resolveImage(path, payload.imageMap || {}));
},
setAnnotationMode() {},
setAnnotationCurrentPage() {},
upsertAnnotation() {},
removeAnnotation() {},
clearAnnotations() {},
},
});
const remote = await connection.promise;
window.exportRemote = remote;
await remote.setSlideConfig({
editable: true,
locale: "zh-CN",
theme: "light",
slideId: payload.id,
});
await remote.setPPTD(payload.id, {
pptdContent: payload.manifestContent,
pages: payload.pages,
pptdPath: payload.manifestPath,
basePath: "",
isCreate: true,
});
await remote.setEditable(true);
window.exportSlideStatus = await remote.getSlideStatus();
setStatus("ready");
} catch (error) {
window.exportHostError = String(error?.stack || error);
setStatus("error");
}
},
{ once: true },
);
frame.src = `${editorOrigin}/neo-ppt/?${query}`;
} catch (error) {
window.exportHostError = String(error?.stack || error);
setStatus("error");
}
</script>
</body>
</html>
+49 -48
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env python3
"""Export a PPTD project as page images through Kimi's public editor for visual QA.
"""Export a PPTD project as page images through the local neo-ppt mirror for visual QA.
Reuses the same localhost SDK host and agent-browser flow as export_pptx.py, but
chooses 图片 in the export dialog, captures the images ZIP, unzips it, and stitches
all pages into a single overview image that a multimodal model can review.
Reuses the localhost local-editor host from export_pptx.py (no www.kimi.com), chooses
图片 in the export dialog, captures the images ZIP, unzips it, and stitches all pages
into a single overview image that a multimodal model can review.
"""
from __future__ import annotations
@@ -23,7 +23,6 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Tuple
from export_pptx import (
HOST_TEMPLATE,
BrowserSession,
ExportError,
build_payload,
@@ -34,7 +33,7 @@ from export_pptx import (
log,
ref_by_name,
run_command,
serve,
serve_local_editor,
temporary_directory,
wait_for_export_dialog,
)
@@ -44,6 +43,32 @@ OVERVIEW_COLUMNS = 3
OVERVIEW_THUMB_WIDTH = 640
OVERVIEW_LABEL_HEIGHT = 32
OVERVIEW_GAP = 12
PAGE_URL_HINT = "127.0.0.1"
# The export dialog's 图片 format option is a plain <div class="radio-group-item">
# without an ARIA role, so agent-browser's interactive snapshot never exposes it.
# Local editor is same-origin (no kimi iframe); CDP Runtime.evaluate on the page works.
IMAGE_FORMAT_CLICK_JS = """
(() => {
const items = [...document.querySelectorAll('.radio-group-item')];
const pool = items.length
? items
: [...document.querySelectorAll('div,span,label,button')].filter(
(el) => el.children.length === 0
);
const target = pool.find((el) => el.textContent.trim() === '图片');
if (!target) return null;
target.click();
return 'clicked';
})()
""".strip()
ACTIVE_FORMAT_JS = """
(() => {
const active = document.querySelector('.radio-group-item.active');
return active ? active.textContent.trim() : null;
})()
""".strip()
def ensure_pillow() -> Tuple[Any, Any, Any]:
@@ -151,34 +176,6 @@ def stitch_overview(
return output
OOPIF_URL_HINT = "kimi.com/neo-ppt"
# The export dialog's 图片 format option is a plain <div class="radio-group-item">
# without an ARIA role, so agent-browser's interactive snapshot never exposes it
# and cross-origin iframe rules block page-level eval. Clicking it requires CDP.
IMAGE_FORMAT_CLICK_JS = """
(() => {
const items = [...document.querySelectorAll('.radio-group-item')];
const pool = items.length
? items
: [...document.querySelectorAll('div,span,label,button')].filter(
(el) => el.children.length === 0
);
const target = pool.find((el) => el.textContent.trim() === '图片');
if (!target) return null;
target.click();
return 'clicked';
})()
""".strip()
ACTIVE_FORMAT_JS = """
(() => {
const active = document.querySelector('.radio-group-item.active');
return active ? active.textContent.trim() : null;
})()
""".strip()
def ensure_websocket() -> Any:
try:
import websocket
@@ -207,7 +204,7 @@ def browser_cdp_url(browser: BrowserSession) -> str:
return match.group(0)
def evaluate_in_iframe(cdp_url: str, url_hint: str, expression: str) -> Any:
def evaluate_in_page(cdp_url: str, url_hint: str, expression: str) -> Any:
websocket = ensure_websocket()
def call(socket: Any, request_id: int, method: str, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -230,8 +227,14 @@ def evaluate_in_iframe(cdp_url: str, url_hint: str, expression: str) -> Any:
os.environ.update(saved_proxy)
try:
targets = call(socket, 1, "Target.getTargets", {}).get("targetInfos", [])
target = next(
(item for item in targets if url_hint in str(item.get("url", ""))), None
page_targets = [
item
for item in targets
if item.get("type") == "page" and url_hint in str(item.get("url", ""))
]
target = page_targets[0] if page_targets else next(
(item for item in targets if url_hint in str(item.get("url", ""))),
None,
)
if target is None:
visible = ", ".join(
@@ -266,7 +269,7 @@ def evaluate_in_iframe(cdp_url: str, url_hint: str, expression: str) -> Any:
result = message.get("result", {})
if result.get("exceptionDetails"):
details = result["exceptionDetails"]
raise ExportError(f"iframe script failed: {details.get('text')}")
raise ExportError(f"page script failed: {details.get('text')}")
return result.get("result", {}).get("value")
finally:
socket.close()
@@ -274,12 +277,13 @@ def evaluate_in_iframe(cdp_url: str, url_hint: str, expression: str) -> Any:
def select_image_format(browser: BrowserSession) -> None:
cdp_url = browser_cdp_url(browser)
value = evaluate_in_iframe(cdp_url, OOPIF_URL_HINT, IMAGE_FORMAT_CLICK_JS)
value = evaluate_in_page(cdp_url, PAGE_URL_HINT, IMAGE_FORMAT_CLICK_JS)
if value != "clicked":
raise ExportError("could not find the 图片 option in the export dialog")
deadline = time.monotonic() + 10
active = None
while time.monotonic() < deadline:
active = evaluate_in_iframe(cdp_url, OOPIF_URL_HINT, ACTIVE_FORMAT_JS)
active = evaluate_in_page(cdp_url, PAGE_URL_HINT, ACTIVE_FORMAT_JS)
if active == "图片":
return
time.sleep(0.3)
@@ -307,16 +311,12 @@ def export_images(
temp_dir = Path(temp_name)
download_dir = temp_dir / "downloads"
download_dir.mkdir()
shutil.copy2(HOST_TEMPLATE, temp_dir / HOST_TEMPLATE.name)
(temp_dir / "payload.json").write_text(
json.dumps(payload, ensure_ascii=False), encoding="utf-8"
)
server, thread, url = serve(temp_dir)
server, thread, url = serve_local_editor(payload)
session = f"open-kimi-ppt-images-{os.getpid()}-{uuid.uuid4().hex[:8]}"
browser = BrowserSession(agent_browser, session, temp_dir, download_dir)
downloads = default_downloads_dir()
try:
log("opening the public Kimi slide editor")
log("opening the local neo-ppt editor")
browser.open(url)
browser.run(
[
@@ -337,7 +337,7 @@ def export_images(
started_at = time.time() - 1.0
download_ref = ref_by_name(dialog, "下载", "button")
log("rendering page images in the browser")
log("rendering page images in the local editor")
browser.run(["click", f"@{download_ref}"], timeout=300)
downloaded = find_download(
(downloads, download_dir, temp_dir),
@@ -380,13 +380,14 @@ def export_images(
"overview": str(overview),
"output": str(output),
"images": mapping,
"exporter": "browser-local-editor",
}
def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Export a PPTD project as page images via Kimi's public editor, unzip "
"Export a PPTD project as page images via the local neo-ppt editor, unzip "
"them, and stitch an overview image for visual QA."
)
)
+211 -20
View File
@@ -1,9 +1,14 @@
#!/usr/bin/env python3
"""Export a PPTD project through Kimi's public browser-side PPTX writer.
"""Export a PPTD project to PPTX.
The script uses a temporary localhost SDK host and agent-browser. It never uploads
the PPTD project as a document. Referenced remote resources may still be fetched by
the official editor. Local image files are exposed to the iframe as data URLs.
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
@@ -28,9 +33,9 @@ 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
HOST_TEMPLATE = Path(__file__).with_name("export_host.html")
IMAGE_MIME = {
".png": "image/png",
".jpg": "image/jpeg",
@@ -49,6 +54,11 @@ 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):
@@ -452,6 +462,25 @@ class BrowserSession:
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)
@@ -704,7 +733,27 @@ def verify_output(pptx: Path, transition: str, expect_fonts: bool) -> Dict[str,
}
def serve(directory: Path) -> Tuple[ThreadingHTTPServer, threading.Thread, str]:
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
)
@@ -712,7 +761,137 @@ def serve(directory: Path) -> Tuple[ThreadingHTTPServer, threading.Thread, str]:
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
host, port = server.server_address
return server, thread, f"http://{host}:{port}/export_host.html"
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(
@@ -722,7 +901,15 @@ def export_pptx(
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()
@@ -741,16 +928,12 @@ def export_pptx(
temp_dir = Path(temp_name)
download_dir = temp_dir / "downloads"
download_dir.mkdir()
shutil.copy2(HOST_TEMPLATE, temp_dir / HOST_TEMPLATE.name)
(temp_dir / "payload.json").write_text(
json.dumps(payload, ensure_ascii=False), encoding="utf-8"
)
server, thread, url = serve(temp_dir)
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 public Kimi slide editor")
log("opening the local neo-ppt editor")
browser.open(url)
browser.run(
[
@@ -770,18 +953,18 @@ def export_pptx(
if state is not None:
switch_ref, checked, disabled = state
if disabled and checked != embed_fonts:
log("warning: the official font switch is disabled for this deck")
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 official export dialog exposed no font switch")
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 browser")
log("generating PPTX in the local editor")
browser.run(["click", f"@{download_ref}"], timeout=180)
downloaded = find_download(
(downloads, download_dir, temp_dir),
@@ -811,14 +994,16 @@ def export_pptx(
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 using Kimi's public browser-side writer. "
"Defaults: fade transition and embedded fonts."
"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")
@@ -835,13 +1020,18 @@ def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
dest="embed_fonts",
action="store_true",
default=True,
help="embed fonts when available (default)",
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",
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",
@@ -868,6 +1058,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
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)
@@ -0,0 +1,16 @@
# Local patched WASM PPTX exporter
- `export-pptd.mjs` — Node entry (official WASM glue, `--no-sign`)
- Patched WASM **canonical path** (repo / npm package):
`editor/neo-ppt/assets/pptd_wasm_bg-DPPWdROu.wasm`
Skill install copies it to `scripts/local-export/pptd_wasm_bg.wasm`.
- Used by `../export_pptx.py` as the **default** export path
```bash
# from repo: resolves editor/neo-ppt/assets/… automatically
node export-pptd.mjs /path/to/project -o out.pptx --no-sign
# or pass explicitly
node export-pptd.mjs /path/to/project -o out.pptx --no-sign \
--wasm ../../../../editor/neo-ppt/assets/pptd_wasm_bg-DPPWdROu.wasm
```
@@ -0,0 +1,686 @@
#!/usr/bin/env node
/**
* Offline PPTD → PPTX exporter using Kimi's public pptd-wasm (no webpage UI).
*
* What this does:
* 1. Load a PPTD project from disk (manifest + pages + media)
* 2. Resolve images to bytes
* 3. Optionally request an export signature from Kimi API (or use --no-sign)
* 4. Call the official WASM writer: exportPPTDToPPTXBytes(pptd, options, signature)
* 5. Write .pptx
*
* Image ZIP export (pixel screenshots) still needs a DOM renderer; use --images
* with Playwright against a local static render, or keep using official editor for QA shots.
*
* Usage:
* node export-pptd.mjs <projectDirOr.pptd> -o out.pptx
* node export-pptd.mjs ./xiaomi-yu7 -o yu7.pptx --cookie "session=..."
* node export-pptd.mjs ./xiaomi-yu7 -o yu7.pptx --no-sign # may fail inside WASM if signature is verified
*
* Env:
* KIMI_COOKIE auth cookie string for /apiv2/utils/v1/signatures
* KIMI_ORIGIN default https://www.kimi.com
*/
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { createRequire } from 'node:module';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
// ---------- minimal YAML (enough for PPTD) via dynamic import of yaml if present ----------
async function loadYaml() {
try {
const yaml = await import('yaml');
return yaml.parse;
} catch {
try {
const yaml = require('js-yaml');
return (s) => yaml.load(s);
} catch {
// fallback: python3 + PyYAML
return (text) => {
const { spawnSync } = require('node:child_process');
const r = spawnSync(
'python3',
['-c', 'import sys,yaml,json; print(json.dumps(yaml.safe_load(sys.stdin.read()), ensure_ascii=False))'],
{ input: text, encoding: 'utf8', maxBuffer: 50 * 1024 * 1024 },
);
if (r.status !== 0) throw new Error('python yaml failed: ' + (r.stderr || r.stdout));
return JSON.parse(r.stdout);
};
}
}
}
/** Canonical patched WASM lives in the editor mirror; skill install copies it here. */
const CANONICAL_WASM_NAME = 'pptd_wasm_bg-DPPWdROu.wasm';
function resolveDefaultWasmPath() {
const candidates = [
path.join(__dirname, 'pptd_wasm_bg.wasm'), // installed skill copy
path.join(__dirname, CANONICAL_WASM_NAME),
// monorepo / npm package: scripts/local-export → package root
path.join(__dirname, '..', '..', '..', '..', 'editor', 'neo-ppt', 'assets', CANONICAL_WASM_NAME),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate;
}
return candidates[0];
}
// ---------- CLI ----------
function parseArgs(argv) {
const args = {
input: null,
output: null,
cookie: process.env.KIMI_COOKIE || '',
origin: process.env.KIMI_ORIGIN || 'https://www.kimi.com',
noSign: false,
embedFonts: false,
transition: 'fade',
wasmPath: null,
};
const a = [...argv];
while (a.length) {
const x = a.shift();
if (x === '-o' || x === '--output') args.output = a.shift();
else if (x === '--cookie') args.cookie = a.shift();
else if (x === '--origin') args.origin = a.shift();
else if (x === '--no-sign') args.noSign = true;
else if (x === '--embed-fonts') args.embedFonts = true;
else if (x === '--transition') args.transition = a.shift();
else if (x === '--wasm') args.wasmPath = a.shift();
else if (x === '-h' || x === '--help') args.help = true;
else if (!x.startsWith('-') && !args.input) args.input = x;
else throw new Error(`Unknown arg: ${x}`);
}
if (!args.wasmPath) args.wasmPath = resolveDefaultWasmPath();
return args;
}
// ---------- PPTD project load ----------
function findManifest(input) {
const p = path.resolve(input);
if (fs.statSync(p).isFile() && p.endsWith('.pptd')) return p;
const files = fs.readdirSync(p).filter((f) => f.endsWith('.pptd'));
if (files.length === 1) return path.join(p, files[0]);
if (files.length === 0) throw new Error(`No .pptd in ${p}`);
throw new Error(`Multiple .pptd in ${p}: ${files.join(', ')}`);
}
async function loadProject(manifestPath, parseYaml) {
const root = path.dirname(manifestPath);
const manifestText = fs.readFileSync(manifestPath, 'utf8');
const manifest = parseYaml(manifestText);
if (!manifest || manifest.version !== 'v2') {
throw new Error('Only PPTD version: v2 is supported');
}
if (!Array.isArray(manifest.pages) || !manifest.pages.length) {
throw new Error('manifest.pages must be a non-empty array');
}
const pages = [];
for (const rel of manifest.pages) {
const pagePath = path.join(root, rel);
if (!fs.existsSync(pagePath)) throw new Error(`Missing page: ${rel}`);
const page = parseYaml(fs.readFileSync(pagePath, 'utf8'));
if (!page || !Array.isArray(page.elements)) {
throw new Error(`Invalid page elements: ${rel}`);
}
// Match official nt()/rt() shape: embed full page objects
pages.push({
...page,
pagePath: rel.replace(/\\/g, '/'),
elements: page.elements.map(normalizeElement),
});
}
return {
...manifest,
version: 'v2',
pages,
pptdFileName: path.basename(manifestPath),
};
}
function normalizeElement(el) {
// Official it()/at(): ensure custom shapes get viewBox from bounds
if (el.elementType === 'shape') {
return ensureViewBox(el, [el.bounds?.[2], el.bounds?.[3]]);
}
if (el.elementType === 'image' && el.cropShape) {
return {
...el,
cropShape: ensureViewBox(el.cropShape, [el.bounds?.[2], el.bounds?.[3]]),
};
}
return el;
}
function ensureViewBox(shape, size) {
if (
shape.shapeName !== 'custom' ||
!shape.path ||
shape.viewBox ||
String(shape.path).includes(';')
) {
return shape;
}
return { ...shape, viewBox: size };
}
// ---------- image resolve (official Bt + Ht) ----------
function collectImageSrcs(pptd) {
const set = new Set();
const visit = (obj) => {
if (obj && typeof obj.src === 'string' && obj.src) set.add(obj.src);
};
for (const page of pptd.pages ?? []) {
if (page.background?.type === 'image') visit(page.background);
for (const el of page.elements ?? []) {
if (el?.elementType === 'image') visit(el);
if (el?.fill?.type === 'image') visit(el.fill);
}
}
return [...set];
}
function extFromSrc(src, contentType = '') {
const m = /\.(png|jpe?g|gif|svg)(?:$|[?#])/i.exec(src);
const byPath = m?.[1]?.toLowerCase();
const byCt = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/gif': 'gif',
'image/svg+xml': 'svg',
}[String(contentType).split(';')[0].trim()];
let ext = byPath || byCt || 'png';
if (ext === 'jpeg') ext = 'jpg';
return `.${ext}`;
}
async function resolveImages(pptd, projectRoot) {
const srcs = collectImageSrcs(pptd);
const images = {}; // path -> Uint8Array
const remap = new Map();
await Promise.all(
srcs.map(async (src, idx) => {
let bytes;
let ext = extFromSrc(src);
if (/^(https?:\/\/|data:)/i.test(src)) {
if (src.startsWith('data:')) {
const m = /^data:([^;,]+)?(;base64)?,(.*)$/s.exec(src);
if (!m) return;
const ct = m[1] || '';
ext = extFromSrc(src, ct);
bytes = m[2]
? Buffer.from(m[3], 'base64')
: Buffer.from(decodeURIComponent(m[3]));
} else {
const res = await fetch(src);
if (!res.ok) {
console.warn(`[warn] image fetch failed ${res.status}: ${src}`);
return;
}
ext = extFromSrc(src, res.headers.get('content-type') || '');
bytes = Buffer.from(await res.arrayBuffer());
}
} else {
// local relative to project root
const local = path.join(
projectRoot,
src.replace(/^file:\/\/+/, '').replace(/\\/g, '/'),
);
if (!fs.existsSync(local)) {
console.warn(`[warn] missing local image: ${src}`);
return;
}
bytes = fs.readFileSync(local);
ext = extFromSrc(src);
}
const key = `wasm-assets/img-${idx}${ext}`;
images[key] = new Uint8Array(bytes);
remap.set(src, key);
}),
);
// rewrite src in place
const rewrite = (obj) => {
if (obj && typeof obj.src === 'string' && remap.has(obj.src)) {
obj.src = remap.get(obj.src);
}
};
for (const page of pptd.pages ?? []) {
if (page.background?.type === 'image') rewrite(page.background);
for (const el of page.elements ?? []) {
if (el?.elementType === 'image') rewrite(el);
if (el?.fill?.type === 'image') rewrite(el.fill);
}
}
// shape path;viewBox split (official Kt/Gt)
for (const page of pptd.pages ?? []) {
for (const el of page.elements ?? []) {
if (el?.elementType === 'shape') splitCustomPath(el);
if (el?.elementType === 'image' && el.cropShape) splitCustomPath(el.cropShape);
}
}
return images;
}
function splitCustomPath(shape) {
if (shape?.shapeName !== 'custom' || typeof shape.path !== 'string' || shape.viewBox)
return;
const t = shape.path.indexOf(';');
if (t < 0) return;
const [w, h] = shape.path
.slice(0, t)
.split(',')
.map(Number);
if (!(w > 0) || !(h > 0)) return;
shape.viewBox = [w, h];
shape.path = shape.path.slice(t + 1);
}
// ---------- signature (official pptdExportSignature) ----------
function sha256Hex(text) {
return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
}
async function requestSignature(dataString, { origin, cookie }) {
const hash = sha256Hex(dataString);
const headers = {
'Content-Type': 'application/json',
Accept: 'application/json',
};
if (cookie) headers.Cookie = cookie;
const res = await fetch(`${origin}/apiv2/utils/v1/signatures`, {
method: 'POST',
headers,
body: JSON.stringify({ data: hash }),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(
`Signature HTTP ${res.status}: ${body.slice(0, 300)}\n` +
`Pass a valid logged-in Cookie via --cookie or KIMI_COOKIE.`,
);
}
const json = await res.json();
if (!json.signature) {
throw new Error('Signature response missing signature field');
}
return json.signature;
}
// ---------- WASM glue (Node port of kimiDesign / wasm-bindgen) ----------
async function loadWasmExporter(wasmPath) {
const wasmBytes = fs.readFileSync(wasmPath);
let exportsRef;
let memory;
let cachedUint8;
let cachedDataView;
const textDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
const textEncoder = new TextEncoder();
let heap = Array(1024).fill(undefined);
heap.push(undefined, null, true, false);
let heapNext = heap.length;
let S = 0;
function getUint8() {
if (!cachedUint8 || cachedUint8.byteLength === 0) {
cachedUint8 = new Uint8Array(memory.buffer);
}
return cachedUint8;
}
function getDataView() {
if (
!cachedDataView ||
cachedDataView.buffer.detached === true ||
(cachedDataView.buffer.detached === undefined &&
cachedDataView.buffer !== memory.buffer)
) {
cachedDataView = new DataView(memory.buffer);
}
return cachedDataView;
}
function addHeapObject(obj) {
if (heapNext === heap.length) heap.push(heap.length + 1);
const idx = heapNext;
heapNext = heap[idx];
heap[idx] = obj;
return idx;
}
function takeObject(idx) {
const ret = heap[idx];
dropObject(idx);
return ret;
}
function dropObject(idx) {
if (idx < 1028) return;
heap[idx] = heapNext;
heapNext = idx;
}
function getObject(idx) {
return heap[idx];
}
function isLikeNone(x) {
return x == null;
}
function passStringToWasm(arg, malloc, realloc) {
if (realloc === undefined) {
const buf = textEncoder.encode(arg);
const ptr = malloc(buf.length, 1) >>> 0;
getUint8()
.subarray(ptr, ptr + buf.length)
.set(buf);
S = buf.length;
return ptr;
}
let len = arg.length;
let ptr = malloc(len, 1) >>> 0;
const mem = getUint8();
let offset = 0;
for (; offset < len; offset++) {
const code = arg.charCodeAt(offset);
if (code > 127) break;
mem[ptr + offset] = code;
}
if (offset !== len) {
if (offset !== 0) arg = arg.slice(offset);
ptr = realloc(ptr, len, (len = offset + arg.length * 3), 1) >>> 0;
const view = getUint8().subarray(ptr + offset, ptr + len);
const ret = textEncoder.encodeInto(arg, view);
offset += ret.written;
ptr = realloc(ptr, len, offset, 1) >>> 0;
}
S = offset;
return ptr;
}
function getStringFromWasm(ptr, len) {
ptr = ptr >>> 0;
return textDecoder.decode(getUint8().subarray(ptr, ptr + len));
}
function getArrayU8FromWasm(ptr, len) {
ptr = ptr >>> 0;
return getUint8().subarray(ptr / 1, ptr / 1 + len);
}
function handleError(f, args) {
try {
return f.apply(null, args);
} catch (e) {
exportsRef.__wbindgen_export3(addHeapObject(e));
}
}
const imports = {
'./pptd_wasm_bg.js': {
__wbg___wbindgen_boolean_get_fa956cfa2d1bd751(arg0) {
const v = getObject(arg0);
const x = typeof v === 'boolean' ? v : undefined;
return isLikeNone(x) ? 0xffffff : +!!x;
},
__wbg___wbindgen_is_function_1ff95bcc5517c252(arg0) {
return typeof getObject(arg0) === 'function';
},
__wbg___wbindgen_is_null_ea9085d691f535d3(arg0) {
return getObject(arg0) === null;
},
__wbg___wbindgen_is_object_a27215656b807791(arg0) {
const val = getObject(arg0);
return typeof val === 'object' && val !== null;
},
__wbg___wbindgen_is_undefined_c05833b95a3cf397(arg0) {
return getObject(arg0) === undefined;
},
__wbg___wbindgen_number_get_394265ed1e1b84ee(arg0, arg1) {
const obj = getObject(arg1);
const val = typeof obj === 'number' ? obj : undefined;
getDataView().setFloat64(arg0 + 8, isLikeNone(val) ? 0 : val, true);
getDataView().setInt32(arg0 + 0, !isLikeNone(val), true);
},
__wbg___wbindgen_string_get_b0ca35b86a603356(arg0, arg1) {
const obj = getObject(arg1);
const val = typeof obj === 'string' ? obj : undefined;
var ptr = isLikeNone(val)
? 0
: passStringToWasm(val, exportsRef.__wbindgen_export, exportsRef.__wbindgen_export2);
var len = S;
getDataView().setInt32(arg0 + 4, len, true);
getDataView().setInt32(arg0 + 0, ptr, true);
},
__wbg___wbindgen_throw_344f42d3211c4765(arg0, arg1) {
throw new Error(getStringFromWasm(arg0, arg1));
},
__wbg_call_a6e5c5dce5018821() {
return handleError(function (arg0, arg1, arg2) {
return addHeapObject(getObject(arg0).call(getObject(arg1), getObject(arg2)));
}, arguments);
},
__wbg_call_e3b662382210db98() {
return handleError(function (arg0, arg1, arg2, arg3) {
return addHeapObject(
getObject(arg0).call(getObject(arg1), getObject(arg2), getObject(arg3)),
);
}, arguments);
},
__wbg_from_13e323c65fc8f464(arg0) {
return addHeapObject(Array.from(getObject(arg0)));
},
__wbg_get_78f252d074a84d0b() {
return handleError(function (arg0, arg1) {
return addHeapObject(Reflect.get(getObject(arg0), getObject(arg1)));
}, arguments);
},
__wbg_get_unchecked_6e0ad6d2a41b06f6(arg0, arg1) {
return addHeapObject(getObject(arg0)[arg1 >>> 0]);
},
__wbg_instanceof_ArrayBuffer_4480b9e0068a8adb(arg0) {
let result;
try {
result = getObject(arg0) instanceof ArrayBuffer;
} catch {
result = false;
}
return result;
},
__wbg_instanceof_Uint8Array_309b927aaf7a3fc7(arg0) {
let result;
try {
result = getObject(arg0) instanceof Uint8Array;
} catch {
result = false;
}
return result;
},
__wbg_length_1f0964f4a5e2c6d8(arg0) {
return getObject(arg0).length;
},
__wbg_length_370319915dc99107(arg0) {
return getObject(arg0).length;
},
__wbg_new_cd45aabdf6073e84(arg0) {
return addHeapObject(new Uint8Array(getObject(arg0)));
},
__wbg_new_da52cf8fe3429cb2() {
return addHeapObject({});
},
__wbg_new_from_slice_77cdfb7977362f3c(arg0, arg1) {
return addHeapObject(getArrayU8FromWasm(arg0, arg1).slice());
},
__wbg_prototypesetcall_4770620bbe4688a0(arg0, arg1, arg2) {
Uint8Array.prototype.set.call(getArrayU8FromWasm(arg0, arg1), getObject(arg2));
},
__wbg_set_8535240470bf2500() {
return handleError(function (arg0, arg1, arg2) {
return Reflect.set(getObject(arg0), getObject(arg1), getObject(arg2));
}, arguments);
},
__wbg_stringify_b54333f60f1e4ead() {
return handleError(function (arg0) {
return addHeapObject(JSON.stringify(getObject(arg0)));
}, arguments);
},
// note: official name may be slightly different hash suffix — also register alternate
__wbg_stringify_b54333f60f1e4dad() {
return handleError(function (arg0) {
return addHeapObject(JSON.stringify(getObject(arg0)));
}, arguments);
},
__wbindgen_cast_0000000000000001(arg0) {
return addHeapObject(arg0);
},
__wbindgen_cast_0000000000000002(arg0, arg1) {
return addHeapObject(getStringFromWasm(arg0, arg1));
},
__wbindgen_object_drop_ref(arg0) {
takeObject(arg0);
},
},
};
const { instance } = await WebAssembly.instantiate(wasmBytes, imports);
exportsRef = instance.exports;
memory = exportsRef.memory;
cachedUint8 = null;
cachedDataView = null;
function exportPPTDToPPTXBytes(pptd, options, signature) {
const retptr = exportsRef.__wbindgen_add_to_stack_pointer(-16);
try {
const ptr0 = isLikeNone(signature)
? 0
: passStringToWasm(
signature,
exportsRef.__wbindgen_export,
exportsRef.__wbindgen_export2,
);
const len0 = S;
exportsRef.exportPPTDToPPTXBytes(
retptr,
addHeapObject(pptd),
addHeapObject(options),
ptr0,
len0,
);
const r0 = getDataView().getInt32(retptr + 0, true);
const r1 = getDataView().getInt32(retptr + 4, true);
const r2 = getDataView().getInt32(retptr + 8, true);
if (r2) throw takeObject(r1);
return takeObject(r0);
} finally {
exportsRef.__wbindgen_add_to_stack_pointer(16);
}
}
return { exportPPTDToPPTXBytes };
}
// ---------- main ----------
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help || !args.input) {
console.log(`Usage: node export-pptd.mjs <pptd|projectDir> -o out.pptx [options]
Options:
-o, --output PATH output .pptx
--cookie STRING Kimi session cookie for signature API
--origin URL default https://www.kimi.com
--no-sign skip signature request (WASM may reject)
--transition fade|none
--wasm PATH path to patched pptd_wasm (default: resolve from editor mirror)
--embed-fonts reserved (font embedding needs font blobs)
Env: KIMI_COOKIE, KIMI_ORIGIN`);
process.exit(args.help ? 0 : 1);
}
const parseYaml = await loadYaml();
const manifestPath = findManifest(args.input);
const projectRoot = path.dirname(manifestPath);
const output =
args.output ||
path.join(projectRoot, path.basename(manifestPath, '.pptd') + '.offline.pptx');
console.log('[1/5] load PPTD', manifestPath);
let pptd = await loadProject(manifestPath, parseYaml);
console.log('[2/5] resolve images');
// deep clone so we can rewrite src
pptd = JSON.parse(JSON.stringify(pptd));
const images = await resolveImages(pptd, projectRoot);
console.log(` ${Object.keys(images).length} image(s)`);
console.log('[3/5] signature');
let signature = '';
const payloadForSign = JSON.stringify(pptd);
if (args.noSign) {
signature = 'offline-bypass-' + sha256Hex(payloadForSign).slice(0, 32);
console.warn(' --no-sign: using synthetic signature (WASM may still verify)');
} else {
signature = await requestSignature(payloadForSign, {
origin: args.origin,
cookie: args.cookie,
});
console.log(' got signature, length', signature.length);
}
console.log('[4/5] load WASM', args.wasmPath);
if (!fs.existsSync(args.wasmPath)) {
throw new Error(
`patched WASM not found: ${args.wasmPath}\n` +
`Expected editor/neo-ppt/assets/${CANONICAL_WASM_NAME} (repo/npm) ` +
`or scripts/local-export/pptd_wasm_bg.wasm (after skill install).`,
);
}
const { exportPPTDToPPTXBytes } = await loadWasmExporter(args.wasmPath);
console.log('[5/5] export PPTX');
const options = {
fonts: [],
customFonts: [],
images,
fileName: path.basename(output),
slideTransition: {
effect: args.transition === 'none' ? 'none' : args.transition,
speed: 'fast',
},
chartImages: [],
};
let bytes;
try {
bytes = exportPPTDToPPTXBytes(pptd, options, signature);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes('pptd export signature') || msg.toLowerCase().includes('signature')) {
throw new Error(
`WASM signature check failed: ${msg}\n` +
`Provide a valid Kimi login cookie: --cookie "..." or KIMI_COOKIE=...`,
);
}
throw e;
}
const outBuf = Buffer.from(bytes);
fs.writeFileSync(output, outBuf);
console.log('Wrote', output, `(${outBuf.length} bytes)`);
// quick zip sanity
if (outBuf[0] !== 0x50 || outBuf[1] !== 0x4b) {
console.warn('[warn] output does not start with PK (not a ZIP/PPTX?)');
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
@@ -0,0 +1,9 @@
import yaml, json, pathlib
root = pathlib.Path("/home/workdir/artifacts/xiaomi-yu7")
m = yaml.safe_load((root / "xiaomi-yu7.pptd").read_text())
pages = []
for rel in m["pages"]:
p = yaml.safe_load((root / rel).read_text())
p["pagePath"] = rel
pages.append(p)
print(json.dumps({**m, "version": "v2", "pages": pages}, ensure_ascii=False))