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
+21 -15
View File
@@ -24,8 +24,8 @@ Default delivery includes PPTX export (and optional `npx open-kimi-ppt-skill ser
1. **Node.js 18+**: run `node --version`. If `node` is missing or the major version is below 18, **stop immediately**, tell the user to install Node.js 18+ from https://nodejs.org (or their OS package manager), and do not continue with PPTX export / `npx` until it is available. Only continue with PPTD-only output when the user explicitly opts out of PPTX.
2. **npm / npx**: run `npm --version`. They ship with Node.js; if missing, treat Node.js as not installed correctly and guide the user to reinstall/fix PATH.
3. **python3**: run `python3 --version` (on Windows, `python` may be the correct command). Needed for `export_pptx.py` / `export_images.py`.
4. **Chrome / Chromium / Edge**: needed by `agent-browser` for PPTX export and visual QA. If export later fails with a browser-launch error, ask the user to install a Chromium-based browser.
5. Soft deps are auto-handled by the scripts when missing: **PyYAML**, **agent-browser** (≥0.33.2 via npm), and for image QA **Pillow** + **websocket-client**. Network access to `www.kimi.com` and `statics.moonshot.cn` is still required at export time.
4. **Chrome / Chromium / Edge**: needed by `agent-browser` for **image QA** (`export_images.py`) and optional `--browser` PPTX path. Default PPTX export uses local WASM and does **not** require a browser. If image QA later fails with a browser-launch error, ask the user to install a Chromium-based browser.
5. Soft deps are auto-handled by the scripts when missing: **PyYAML**, **agent-browser** (≥0.33.2 via npm, only for browser/image paths), and for image QA **Pillow** + **websocket-client**. **PPTX / image export / manual edit are offline** against the local neo-ppt mirror + patched WASM. Network is only needed if the deck references remote images/fonts.
### step1. Read the context thoroughly
Read **all files uploaded by the user**, the provided URLs, and the pptd format guide `reference/pptd.md` to fully understand the user's requirements.
@@ -125,7 +125,7 @@ When generating a PPT, adopt different production approaches for different user
### step4. PPT validation
1. Validate the generated pptd against the format definition in `reference/pptd.md` (required fields, types, bounds, theme tokens, resource paths, etc.) and repair issues over multiple rounds
2. Visual review with exported page images — **required before PPTX export when the model supports image input (multimodal)**:
- Run `scripts/export_images.py`. It loads the deck into Kimi's public editor, chooses 导出 → 图片, downloads the images ZIP, unzips it, and stitches all pages into one overview image:
- Run `scripts/export_images.py`. It loads the deck into the **local neo-ppt editor** (same offline mirror as `serve`), chooses 导出 → 图片, downloads the images ZIP, unzips it, and stitches all pages into one overview image. Needs Chromium via `agent-browser`, but **does not** call `www.kimi.com`.
```bash
python3 ~/.agents/skills/open-kimi-ppt/scripts/export_images.py \
@@ -166,30 +166,36 @@ When generating a PPT, adopt different production approaches for different user
- the `.pptd` manifest;
- the `pages/` directory and `media/` directory when present;
- the generated `.pptx` file.
4. PPTX conversion: use `scripts/export_pptx.py`. It opens a temporary localhost SDK host, loads the `.pptd` into Kimi's public editor, invokes the same browser-side OOXML writer as the official Download tab, saves the resulting PPTX locally, and validates the ZIP/slide structure.
4. PPTX conversion: use `scripts/export_pptx.py`. **Default path is local patched official WASM** (`scripts/local-export/export-pptd.mjs --no-sign`): no cookie, no signature API, no browser UI. Optional `--browser` uses the **local** neo-ppt mirror (same as `serve`), still offline — not `www.kimi.com`.
5. Default PPTX options:
- page transition: `fade` (淡入淡出), written to every slide after the official browser export;
- font embedding: enabled whenever the official writer exposes/supports it;
- these defaults may be explicitly overridden with `--transition none` or `--no-embed-fonts`.
- page transition: `fade` (淡入淡出), written to every slide after export;
- font embedding: available on the browser path; local WASM path prioritizes reliability over embed;
- override with `--transition none` or force browser UI with `--browser`.
6. Export command:
```bash
# preferred: local patched WASM (offline)
python3 ~/.agents/skills/open-kimi-ppt/scripts/export_pptx.py \
/abs/path/project/deck.pptd \
--output /abs/path/project/deck.pptx
# optional: local neo-ppt browser UI (also offline)
python3 ~/.agents/skills/open-kimi-ppt/scripts/export_pptx.py \
/abs/path/project/deck.pptd \
--output /abs/path/project/deck.pptx --browser
```
A project directory may be passed instead of the manifest only when it contains exactly one `.pptd` file.
Existing output files are not overwritten unless `--force` is passed.
7. Local export requirements and boundaries:
- requires **Node.js 18+** (`node` / `npm` / `npx`), `python3`, a Chromium-based browser, and network access to `www.kimi.com` plus `statics.moonshot.cn`;
- before browser export, `export_pptx.py` checks Node.js 18+ and `npm`, then checks `agent-browser --version`; when `agent-browser` is missing or below `0.33.2`, it installs `agent-browser@latest` globally with npm; **PyYAML** is auto-installed with `pip --user` when missing; the image-based visual QA step additionally auto-installs Pillow and websocket-client the same way;
- the PPTD document itself is provided to the public editor iframe through the localhost SDK bridge, not uploaded to a server-side PPTX conversion endpoint;
- remote images, icons, or fonts referenced by the deck may still be fetched from their respective hosts;
- local PNG/JPEG/GIF/SVG files inside the PPTD project are supplied to the iframe as data URLs;
- do not claim PowerPoint/WPS/Keynote playback compatibility solely because ZIP validation succeeds.
7. Offline model (本地编辑器 + 本地导出):
- **PPTX export (default)**: local patched WASM via `scripts/local-export/export-pptd.mjs --no-sign`. Canonical binary: package `editor/neo-ppt/assets/pptd_wasm_bg-DPPWdROu.wasm` (skill install copies it into `scripts/local-export/pptd_wasm_bg.wasm`). Requires **Node.js 18+** only.
- **Image QA / visual review**: `scripts/export_images.py` drives the **local** neo-ppt editor via agent-browser (Chromium required; no `www.kimi.com`).
- **Manual edit / preview**: `npx open-kimi-ppt-skill serve` serves the same offline neo-ppt mirror.
- Browser PPTX path (`--browser`) uses the same local editor host; auto-installs `agent-browser@latest` when missing/outdated; **PyYAML** auto-installed with `pip --user` when missing; image QA additionally auto-installs Pillow and websocket-client.
- Local PNG/JPEG/GIF/SVG files inside the PPTD project are resolved by the local exporter / injected as data URLs for the editor host.
- Do not claim PowerPoint/WPS/Keynote playback compatibility solely because ZIP validation succeeds.
8. After export, verify that the output exists and report the generated path. Confirm that every slide has exactly one root-level fade transition in valid CT_Slide order (`cSld`, optional `clrMapOvr`, `transition`, optional `timing/extLst`) and that the PPTX ZIP passes integrity checks. A byte-string search for `<p:fade>` is insufficient because Office ignores transitions nested inside `cSld`. For higher-risk decks, additionally inspect font parts and representative rendered/opened pages as appropriate.
9. When the user wants to open, edit, save, or export a PPTD project manually, start the local browser editor with `npx open-kimi-ppt-skill serve`. Ask the user to open `http://127.0.0.1:55173/` and authorize the complete PPTD project directory. Use a Chromium-based browser for writable access; folder-upload fallback is read-only. The local host only serves the editor shell, while the embedded public Kimi editor and remote assets still require network access.
9. When the user wants to open, edit, save, or export a PPTD project manually, start the local browser editor with `npx open-kimi-ppt-skill serve`. Ask the user to open `http://127.0.0.1:55173/` and authorize the complete PPTD project directory. Use a Chromium-based browser for writable access; folder-upload fallback is read-only. The host serves the **offline neo-ppt mirror** plus local patched WASM export — no `www.kimi.com`. Remote images or fonts referenced by the deck may still be fetched from their hosts.
10. After completing and delivering any presentation, always end the final response with a concise optional next step telling the user that they can run `npx open-kimi-ppt-skill serve` to view or edit the PPTD project, configure slide transition animations, and export PPTX manually. Keep this reminder in addition to, not instead of, the required project and file links.
11. Element animations (`page.animations` in PPTD — entrance / emphasis / exit / motion-path; see `reference/pptd.md` §6): use them only when the user explicitly requests animations, or when the deck is clearly intended for live presentation / slideshow playback and animation provides a clear benefit for staged disclosure, process demonstration, causal explanation, pacing, visual impact, or brand storytelling. By default, do not add element animations to reading-oriented, self-study, print, or primarily send-and-browse decks. Prefer 1–3 animation groups per page and simple effects such as fade, fly, and zoom. This is separate from the default PPTX slide-level fade page transition written by `export_pptx.py`.
12. Speaker notes (`notes` on each `.page`): use them only when the user explicitly requests them; otherwise, do not add them.
@@ -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))
@@ -202,9 +202,9 @@ class ExportPptxTests(unittest.TestCase):
Path("/tmp/downloads"),
)
with patch.object(session, "run") as run:
session.open("http://127.0.0.1:9/export_host.html")
session.open("http://127.0.0.1:9/?ndExport=1")
run.assert_called_once_with(
["open", "http://127.0.0.1:9/export_host.html"],
["open", "http://127.0.0.1:9/?ndExport=1"],
timeout=90,
)