feat: 1.3.0 release, fix export error
This commit is contained in:
@@ -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>
|
||||
@@ -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."
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user