open-kimiPPT
§ 00 · Source-level Observatory

open-kimi-ppt-skill PPTD 中间层 + 本地编辑器

这个项目把非官方 Kimi Slides 能力包装成可安装的 Agent Skill:Agent 产出 PPTD 项目,人类可在本地浏览器编辑,再通过官方前端导出 PPTX 或图片总览。源码亮点在于安装事务、文件系统安全边界、iframe RPC 桥和导出后 ZIP/XML 校验。

★ 729
GitHub Stars
177K
LOC incl examples
105
Files
today
Updated
Agent 生成 PPTD ↓ PPTD manifest/pages/media ↓ 本地编辑器载入 Kimi neo-ppt iframe + Penpal RPC ↓ 保存 / 导出 PPTX + images overview + tests
§ 00 · OVERVIEW

它解决什么,凭什么值得拆

open-kimi-ppt-skill 的核心不是再写一个 PPTX 生成库,而是把 Agent 友好的 PPTD 中间格式、本地可视化编辑器和官方 Kimi 前端导出器串成一条交付链。入口 CLI 在 lib/editor-server.js:26 和 lib/editor-server.js:82 之间暴露本地静态服务,安装命令在 lib/editor-server.js:64 这类 HTTP 响应边界之外由 CLI 处理,从而把“装 skill”和“开编辑器”分成两个明确动作。

项目最值得关注的抽象是 PPTD。浏览器侧的 lib/editor-lib.js:67 负责从 manifest 解析页面列表,lib/editor-lib.js:111 又把可写路径限制为 .pptd 和 .page,避免编辑器把 Kimi iframe 的保存请求扩展成任意文件写入。这个约束让本地编辑器可以自动保存,但不会把 media 或系统路径暴露给远端编辑内核。

架构模式
npm CLI + packaged skill + localhost editor + browser-side PPTX writer。
核心抽象
PPTD manifest、page YAML、imageMap、Penpal RPC、download verification。
下游依赖
Node 18、File System Access API、Kimi neo-ppt public frontend、agent-browser、PyYAML、Pillow。
风险边界
非官方逆向协议可能变更;导出依赖公开前端;本地写入依赖浏览器授权。
§ 01 · INSTALLER

安装入口:先 staging,后 rename

▸CLI 把 install 和 serve 合并到一个二进制

参数解析在 lib/editor-server.js:17 一类 HTTP 层之外完成,真正的 CLI 逻辑来自快照中的 bin 文件;但可验证源快照里,服务入口仍由 lib/editor-server.js:82 返回 server 和 url,供 CLI 的 serve 命令使用。安装路径的事务性体现在 CLI 源码 L104-L135:先复制到临时 staging,再用 rename 切换,最后清理 staging。

lib/editor-server.js · L26-49JavaScript
26export function createEditorServer({ editorDirectory = defaultEditorDirectory } = {}) {
27  const root = resolve(editorDirectory);
28  const rootPrefix = `${root}${sep}`;
29
30  return createServer((request, response) => {
31    if (request.method !== "GET" && request.method !== "HEAD") {
32      respond(response, 405, "Method Not Allowed");
33      return;
34    }
35
36    let pathname;
37    try {
38      pathname = decodeURIComponent(new URL(request.url ?? "/", "http://localhost").pathname);
39    } catch {
40      respond(response, 400, "Bad Request");
41      return;
42    }
43
44    if (pathname.endsWith("/")) pathname += "index.html";
45    const filePath = resolve(root, `.${pathname}`);
46    if (filePath !== root && !filePath.startsWith(rootPrefix)) {
47      respond(response, 403, "Forbidden");
48      return;
49    }

▸HTTP 宿主只负责静态编辑器,不承担项目文件 API

本地服务器的安全性来自极小职责面:lib/editor-server.js:30 拒绝非 GET/HEAD,lib/editor-server.js:45 用 resolve 拼出文件路径,lib/editor-server.js:46 检查结果仍在 editor 根目录下。这样 serve 命令不会把用户 PPTD 项目作为 HTTP 文件树暴露出去,项目文件读取和写入都留在浏览器 File System Access API 侧。

lib/editor-server.js · L51-79JavaScript
51    let stats;
52    try {
53      stats = statSync(filePath);
54    } catch {
55      respond(response, 404, "Not Found");
56      return;
57    }
58
59    if (!stats.isFile()) {
60      respond(response, 404, "Not Found");
61      return;
62    }
63
64    response.writeHead(200, {
65      "Content-Type": contentTypes.get(extname(filePath).toLowerCase()) ?? "application/octet-stream",
66      "Content-Length": stats.size,
67      "Cache-Control": "no-store",
68      "X-Content-Type-Options": "nosniff",
69    });
70
71    if (request.method === "HEAD") {
72      response.end();
73      return;
74    }
75
76    const stream = createReadStream(filePath);
77    stream.on("error", () => response.destroy());
78    stream.pipe(response);
79  });
§ 02 · EDITOR HOST

编辑器宿主:把官方 iframe 变成本地工作台

▸功能开关通过 query 明确传给 Kimi 前端

编辑器不是把 Kimi 页面当黑盒 iframe 直接嵌进来。lib/editor-app.js:113 构造 sdkMode、functional、sdkSaveMode 和 sdkImageMode,明确启用 fullscreen、present、export,同时关闭 share、annotation、feedback 等不需要的功能。这让本地宿主对公开前端的能力面有一层声明式约束。

lib/editor-app.js · L113-130JavaScript
113function editorQuery() {
114  return new URLSearchParams({
115    sdkMode: "ppt-editor",
116    pptPlatform: "neodeck-local",
117    functional: JSON.stringify({
118      fullscreen: true,
119      present: true,
120      export: true,
121      close: false,
122      annotation: false,
123      feedback: false,
124      share: false,
125      versionHistory: false,
126    }),
127    sdkSaveMode: "external",
128    sdkImageMode: "external",
129  });
130}

▸Demo deck 也是同一套 payload 结构

内置 demo 在 lib/editor-app.js:132 到 lib/editor-app.js:218 里直接构造 manifest、pages、animations,并走与真实文件夹相同的 setDeck 通道。这个设计使“示例载入”和“用户项目载入”共享远端 setPPTD 契约,减少两套逻辑不一致。

lib/editor-app.js · L225-249JavaScript
225async function setDeck(payload, sourceLabel) {
226  const remote = requireRemote();
227  setLoading(true, "正在载入 PPTD", sourceLabel);
228  state.lastDeckPayload = payload;
229  state.manifestContent = payload.manifestContent;
230  state.manifestPath = payload.manifestPath;
231  state.manifestDirectory = dirname(payload.manifestPath);
232  state.deckTitle = payload.title;
233  elements.documentTitle.textContent = payload.title;
234  elements.documentPath.textContent = sourceLabel;
235  elements.reload.disabled = false;
236
237  await remote.setPPTD(payload.id, {
238    pptdContent: payload.manifestContent,
239    pages: payload.pages,
240    basePath: payload.basePath,
241    pptdPath: payload.manifestPath,
242    isCreate: true,
243  });
244  await remote.setEditable(true);
245  const status = await remote.getSlideStatus();
246  setLoading(false);
247  addActivity(`已载入「${payload.title}」,共 ${payload.pages.length} 页`, "success");
248  document.documentElement.dataset.deckStatus = "ready";
249  window.dispatchEvent(new CustomEvent("neodeck:ready", { detail: status }));
§ 03 · PPTD PARSER

PPTD 解析:YAML 和 JSON 两种 manifest 都吃

▸路径归一化先把危险输入挡住

lib/editor-lib.js:1 是整个文件系统边界的第一道门。它拒绝空路径、NUL、绝对路径、Windows 盘符和 .. 段,再把可用路径压缩成相对路径。后续 lib/editor-lib.js:40 和 lib/editor-lib.js:111 都基于这个函数做组合,因此路径逃逸不会被遗漏在某个调用点。

lib/editor-lib.js · L1-27JavaScript
1export function normalizeRelativePath(value) {
2  if (typeof value !== "string") {
3    throw new TypeError("文件路径必须是字符串");
4  }
5
6  let path = value.trim().replaceAll("\\", "/");
7  try {
8    path = decodeURIComponent(path);
9  } catch {
10    // Keep the original path when it contains a literal percent sign.
11  }
12
13  path = path.replace(/^file:\/\/+/, "").replace(/^\.\//, "");
14  if (!path || path.includes("\0") || path.startsWith("/") || /^[A-Za-z]:\//.test(path)) {
15    throw new Error(`不允许的绝对路径:${value}`);
16  }
17
18  const parts = [];
19  for (const part of path.split("/")) {
20    if (!part || part === ".") continue;
21    if (part === "..") throw new Error(`不允许越过项目目录:${value}`);
22    parts.push(part);
23  }
24
25  if (parts.length === 0) throw new Error(`无效文件路径:${value}`);
26  return parts.join("/");
27}

▸manifest 解析只抽取 pages 和 title

PPTD manifest 的读取非常克制。lib/editor-lib.js:67 支持 JSON pages,也支持 YAML pages 列表;lib/editor-lib.js:98 只额外抽 title。它没有尝试实现完整 YAML 解释器,而是抽取编辑器载入所需的最小字段。这个取舍降低了 parser 复杂度,但也意味着格式兼容范围要靠测试守住。

lib/editor-lib.js · L67-96JavaScript
67export function extractPagePaths(manifestText) {
68  const trimmed = manifestText.trim();
69  if (!trimmed) throw new Error("PPTD manifest 是空文件");
70
71  if (trimmed.startsWith("{")) {
72    const parsed = JSON.parse(trimmed);
73    if (!Array.isArray(parsed.pages)) throw new Error("PPTD manifest 缺少 pages 数组");
74    return parsed.pages.map((path) => normalizeRelativePath(String(path)));
75  }
76
77  const lines = manifestText.replaceAll("\r\n", "\n").split("\n");
78  const pageHeader = lines.findIndex((line) => /^(\s*)pages\s*:\s*(?:#.*)?$/.test(line));
79  if (pageHeader === -1) throw new Error("PPTD manifest 缺少 pages 列表");
80
81  const headerIndent = lines[pageHeader].match(/^\s*/)[0].length;
82  const pages = [];
83  for (let index = pageHeader + 1; index < lines.length; index += 1) {
84    const line = lines[index];
85    if (!line.trim() || line.trimStart().startsWith("#")) continue;
86    const indent = line.match(/^\s*/)[0].length;
87    if (indent <= headerIndent && !/^\s*-/.test(line)) break;
88    const match = line.match(/^\s*-\s*(.+?)\s*$/);
89    if (!match) break;
90    const path = unquoteYamlScalar(match[1]);
91    if (path) pages.push(normalizeRelativePath(path));
92  }
93
94  if (pages.length === 0) throw new Error("PPTD manifest 的 pages 列表为空");
95  return pages;
96}
深度阅读 · 编辑器桥接与保存事务
§ 04 · SAVE TRANSACTION

保存事务:限制变更、备份、失败回滚

▸文件夹打开先拿 readwrite 授权

真实项目载入从 lib/editor-app.js:364 开始,先查询和请求 readwrite 权限,再索引目录并寻找 .pptd。只读 fallback 在 lib/editor-app.js:385 和 lib/editor-app.js:397 分开处理,用户可以预览但不能写回。这种分支避免在非 Chromium 或无授权场景下假装“保存成功”。

lib/editor-app.js · L364-383JavaScript
364async function openDirectoryHandle(directoryHandle, label = directoryHandle.name) {
365  setLoading(true, "正在扫描文件夹", label);
366  let permission = await directoryHandle.queryPermission?.({ mode: "readwrite" });
367  if (permission !== "granted") permission = await directoryHandle.requestPermission?.({ mode: "readwrite" });
368  if (permission !== "granted") throw new Error("没有获得文件夹读写权限");
369
370  state.source = "directory";
371  state.directoryHandle = directoryHandle;
372  state.readOnlyFallback = false;
373  state.fileIndex = await indexDirectory(directoryHandle);
374  state.imageCache.clear();
375  const manifests = [...state.fileIndex.keys()].filter((path) => path.toLowerCase().endsWith(".pptd"));
376  if (manifests.length === 0) throw new Error("文件夹里没有找到 .pptd 清单文件");
377  const manifestPath = await chooseManifest(manifests.sort());
378  if (!manifestPath) {
379    setLoading(false);
380    return;
381  }
382  await loadManifest(manifestPath, `${label}/${manifestPath}`);
383}

▸保存队列把 iframe 回调串行化

保存链路是全项目最硬的工程点。lib/editor-app.js:556 先限制变更数量,lib/editor-app.js:561 把每个 change 规范化并通过 lib/editor-lib.js:111 校验可写路径,lib/editor-app.js:587 读取备份,写入失败时 lib/editor-app.js:595 回滚。外层 lib/editor-app.js:606 用 saveQueue 串行化远端 onSave 回调,避免多个保存请求交错覆盖。

lib/editor-app.js · L556-604JavaScript
556async function persistChanges(payload = {}) {
557  const changes = Array.isArray(payload.changes) ? payload.changes : [];
558  if (changes.length === 0) return { fileContent: payload.fileContent, lastModifiedTime: Date.now() };
559  if (changes.length > MAX_CHANGE_COUNT) throw new Error(`一次保存不能超过 ${MAX_CHANGE_COUNT} 个文件`);
560
561  const normalized = changes.map((change) => {
562    const path = assertWritableChangePath(state.manifestDirectory, change.path);
563    const operation = change.operate === "delete" ? "delete" : "put";
564    const content = operation === "put" ? String(change.content ?? "") : null;
565    if (content && new Blob([content]).size > MAX_TEXT_BYTES) throw new Error(`保存内容超过 20 MiB:${path}`);
566    return { path, operation, content };
567  });
568
569  if (state.source === "demo") {
570    for (const change of normalized) {
571      if (change.operation === "delete") state.memoryFiles.delete(change.path);
572      else state.memoryFiles.set(change.path, change.content);
573    }
574    setSaveState("刚刚保存到内存", "memory");
575    addActivity(`已在内存保存 ${normalized.length} 项变更`, "success");
576    return { fileContent: payload.fileContent, lastModifiedTime: Date.now() };
577  }
578
579  if (state.readOnlyFallback || !state.directoryHandle) {
580    throw new Error("当前是只读兼容模式,请用支持文件夹授权的 Chromium 浏览器打开");
581  }
582
583  const permission = await state.directoryHandle.queryPermission({ mode: "readwrite" });
584  if (permission !== "granted") throw new Error("项目文件夹的写入权限已经失效");
585
586  setSaveState("正在保存…", "saving");
587  const backups = new Map();
588  try {
589    for (const change of normalized) backups.set(change.path, await readBackup(change.path));
590    for (const change of normalized) {
591      if (change.operation === "delete") await deleteFile(change.path);
592      else await writeFile(change.path, change.content);
593    }
594  } catch (error) {
595    await rollbackChanges(backups).catch((rollbackError) => {
596      addActivity(`回滚未完全成功:${rollbackError.message}`, "error");
597    });
598    throw error;
599  }
600
601  setSaveState("刚刚已保存", "saved");
602  addActivity(`已保存 ${normalized.length} 个文件变更`, "success");
603  return { fileContent: payload.fileContent, lastModifiedTime: Date.now() };
604}
§ 05 · EXPORT

PPTX 导出:浏览器生成,Python 验证

▸payload 只暴露 manifest、pages 和 imageMap

导出脚本没有把本地目录裸传给浏览器。scripts/export_pptx.py:196 读取 manifest,scripts/export_pptx.py:204 定位项目根,scripts/export_pptx.py:206 按 pages 列表逐页读文件,最后 scripts/export_pptx.py:216 生成 id、manifestContent、pages 和 imageMap。这个 payload 是导出器的最小输入。

scripts/export_pptx.py · L196-223Python
196def build_payload(manifest: Path) -> Dict[str, Any]:
197    manifest_text, manifest_data = read_yaml_mapping(manifest)
198    if manifest_data.get("version") != "v2":
199        raise ExportError("local PPTX export currently requires PPTD version: v2")
200    page_paths = manifest_data.get("pages")
201    if not isinstance(page_paths, list) or not page_paths:
202        raise ExportError("PPTD manifest must contain a non-empty pages list")
203
204    root = manifest.parent.resolve()
205    pages: List[Dict[str, str]] = []
206    for entry in page_paths:
207        page_path = safe_project_path(root, entry)
208        if not page_path.is_file():
209            raise ExportError(f"missing page file: {entry}")
210        page_text, page_data = read_yaml_mapping(page_path)
211        if not isinstance(page_data.get("elements"), list):
212            raise ExportError(f"page elements must be an array: {entry}")
213        pages.append({"path": str(entry), "content": page_text})
214
215    title = str(manifest_data.get("title") or manifest.stem)
216    return {
217        "id": f"local-export-{uuid.uuid4().hex}",
218        "title": title,
219        "manifestPath": manifest.name,
220        "manifestContent": manifest_text,
221        "pages": pages,
222        "imageMap": build_image_map(root),
223    }

▸下载后还要补 transition 并校验 ZIP

官方前端写出 PPTX 后,脚本仍会在 scripts/export_pptx.py:442 重写 slide XML transition,并在 scripts/export_pptx.py:462 用 zip CRC、slide XML、fade transition 和字体部件做验证。scripts/export_pptx.py:508 到 scripts/export_pptx.py:596 把 agent-browser 会话、本地 host、下载捕获、patch 和 verify 串起来。

scripts/export_pptx.py · L462-494Python
462def verify_output(pptx: Path, transition: str, expect_fonts: bool) -> Dict[str, Any]:
463    if not is_pptx(pptx):
464        raise ExportError(f"output is not a valid PPTX ZIP: {pptx}")
465    with zipfile.ZipFile(pptx) as archive:
466        broken = archive.testzip()
467        if broken:
468            raise ExportError(f"PPTX CRC check failed at: {broken}")
469        slide_names = [
470            name
471            for name in archive.namelist()
472            if re.fullmatch(r"ppt/slides/slide\d+\.xml", name)
473        ]
474        slide_xml = {name: archive.read(name) for name in slide_names}
475        for data in slide_xml.values():
476            validate_transition_order(data, transition)
477        transition_hits = sum(has_direct_fade_transition(data) for data in slide_xml.values())
478        if transition == "fade" and transition_hits != len(slide_names):
479            raise ExportError("fade transition was not written to every slide")
480        fonts = [
481            name
482            for name in archive.namelist()
483            if name.startswith("ppt/fonts/") and not name.endswith("/")
484        ]
485        if expect_fonts and not fonts:
486            log(
487                "warning: embed-fonts was enabled, but the official writer produced no font part"
488            )
489        return {
490            "slides": len(slide_names),
491            "fadeTransitions": transition_hits,
492            "fontParts": len(fonts),
493            "bytes": pptx.stat().st_size,
494        }
深度阅读 · 导出与视觉 QA 管线
§ 06 · IMAGE QA

视觉 QA:导出图片,再拼总览图

▸图片导出复用同一个 browser session

图片导出脚本复用 scripts/export_pptx.py:240 的 BrowserSession 和 scripts/export_pptx.py:497 的 localhost serve,只是在导出对话框里选择“图片”。因为官方对话框的图片选项没有 ARIA role,scripts/export_images.py:214 通过 CDP 进入 OOPIF iframe 执行小段脚本,这比盲点坐标更稳定。

scripts/export_images.py · L214-290Python
214def evaluate_in_iframe(cdp_url: str, url_hint: str, expression: str) -> Any:
215    websocket = ensure_websocket()
216
217    def call(socket: Any, request_id: int, method: str, params: Dict[str, Any]) -> Dict[str, Any]:
218        socket.send(json.dumps({"id": request_id, "method": method, "params": params}))
219        while True:
220            message = json.loads(socket.recv())
221            if message.get("id") != request_id:
222                continue
223            if "error" in message:
224                raise ExportError(f"CDP {method} failed: {message['error']}")
225            return message.get("result", {})
226
227    # websocket-client honors http_proxy env vars; the CDP endpoint is local,
228    # so strip proxy settings instead of tunneling localhost through the proxy.
229    proxy_env = ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY", "all_proxy", "ALL_PROXY")
230    saved_proxy = {name: os.environ.pop(name) for name in proxy_env if name in os.environ}
231    try:
232        socket = websocket.create_connection(cdp_url, timeout=30, suppress_origin=True)
233    finally:
234        os.environ.update(saved_proxy)
235    try:
236        targets = call(socket, 1, "Target.getTargets", {}).get("targetInfos", [])
237        target = next(
238            (item for item in targets if url_hint in str(item.get("url", ""))), None
239        )
240        if target is None:
241            visible = ", ".join(
242                f"{item.get('type')}:{str(item.get('url', ''))[:80]}" for item in targets
243            )
244            raise ExportError(
245                f"no browser target matches {url_hint!r}; observed: {visible}"
246            )
247        attached = call(
248            socket,
249            2,
250            "Target.attachToTarget",
251            {"targetId": target["targetId"], "flatten": True},
252        )
253        session_id = attached["sessionId"]
254        socket.send(
255            json.dumps(
256                {
257                    "id": 3,
258                    "sessionId": session_id,
259                    "method": "Runtime.evaluate",
260                    "params": {"expression": expression, "returnByValue": True},
261                }
262            )
263        )
264        while True:
265            message = json.loads(socket.recv())
266            if message.get("id") != 3:
267                continue
268            if "error" in message:
269                raise ExportError(f"CDP Runtime.evaluate failed: {message['error']}")
270            result = message.get("result", {})
271            if result.get("exceptionDetails"):
272                details = result["exceptionDetails"]
273                raise ExportError(f"iframe script failed: {details.get('text')}")
274            return result.get("result", {}).get("value")
275    finally:
276        socket.close()
277
278
279def select_image_format(browser: BrowserSession) -> None:
280    cdp_url = browser_cdp_url(browser)
281    value = evaluate_in_iframe(cdp_url, OOPIF_URL_HINT, IMAGE_FORMAT_CLICK_JS)
282    if value != "clicked":
283        raise ExportError("could not find the 图片 option in the export dialog")
284    deadline = time.monotonic() + 10
285    while time.monotonic() < deadline:
286        active = evaluate_in_iframe(cdp_url, OOPIF_URL_HINT, ACTIVE_FORMAT_JS)
287        if active == "图片":
288            return
289        time.sleep(0.3)
290    raise ExportError(f"image format was not activated; active option: {active!r}")

▸总览图把多页视觉检查压缩成一次审阅

scripts/export_images.py:86 先安全解压图片 ZIP,scripts/export_images.py:113 计算缩略图网格并给每页加标签,最后 scripts/export_images.py:365 生成 overview.jpg。这个总览图正好服务 Skill.md 中的多模态质检要求:一次看遮挡、出界、对比度和页面节奏。

scripts/export_images.py · L113-152Python
113def stitch_overview(
114    images: Sequence[Path],
115    output: Path,
116    image_cls: Any,
117    draw_cls: Any,
118    image_font: Any,
119) -> Path:
120    thumbs: List[Tuple[str, Any]] = []
121    for index, path in enumerate(images, start=1):
122        with image_cls.open(path) as opened:
123            frame = opened.convert("RGB")
124            ratio = OVERVIEW_THUMB_WIDTH / frame.width
125            thumb = frame.resize(
126                (OVERVIEW_THUMB_WIDTH, max(1, round(frame.height * ratio)))
127            )
128        thumbs.append((f"P{index}", thumb))
129
130    columns = OVERVIEW_COLUMNS
131    rows = math.ceil(len(thumbs) / columns)
132    cell_height = OVERVIEW_LABEL_HEIGHT + max(thumb.height for _, thumb in thumbs)
133    width = columns * OVERVIEW_THUMB_WIDTH + (columns + 1) * OVERVIEW_GAP
134    height = rows * cell_height + (rows + 1) * OVERVIEW_GAP
135
136    overview = image_cls.new("RGB", (width, height), "#e5e7eb")
137    draw = draw_cls.Draw(overview)
138    font = label_font(image_font)
139    for position, (label, thumb) in enumerate(thumbs):
140        column = position % columns
141        row = position // columns
142        x = OVERVIEW_GAP + column * (OVERVIEW_THUMB_WIDTH + OVERVIEW_GAP)
143        y = OVERVIEW_GAP + row * (cell_height + OVERVIEW_GAP)
144        draw.rectangle(
145            (x, y, x + OVERVIEW_THUMB_WIDTH, y + OVERVIEW_LABEL_HEIGHT - 4),
146            fill="#111827",
147        )
148        draw.text((x + 8, y + 5), label, fill="#ffffff", font=font)
149        overview.paste(thumb, (x, y + OVERVIEW_LABEL_HEIGHT))
150
151    overview.save(output, "JPEG", quality=85)
152    return output
§ 07 · TESTS

测试边界:小但对准风险点

▸路径安全和 manifest parser 有单测

测试没有追求覆盖所有 UI,而是盯住危险边界。tests/editor-lib.test.js:21 验证 YAML/JSON pages、title、joinDeckPath 和 assertWritableChangePath;tests/editor-lib.test.js:28 与 tests/editor-lib.test.js:30 则直接断言路径逃逸和非 .pptd/.page 写入会失败。

tests/editor-lib.test.js · L21-31JavaScript
21test("parses PPTD manifests and keeps file access inside the project", () => {
22  assert.deepEqual(extractPagePaths(yaml), ["pages/01.page", "pages/02.page"]);
23  assert.deepEqual(extractPagePaths('{"pages":["a.page","folder/b.page"]}'), ["a.page", "folder/b.page"]);
24  assert.equal(titleFromManifest(yaml, "fallback"), "测试文稿");
25  assert.equal(joinDeckPath("deck", "pages/01.page"), "deck/pages/01.page");
26  assert.equal(joinDeckPath("deck", "deck/pages/01.page"), "deck/pages/01.page");
27  assert.equal(assertWritableChangePath("deck", "pages/01.page"), "deck/pages/01.page");
28  assert.throws(() => normalizeRelativePath("../secret"), /不允许越过/);
29  assert.throws(() => normalizeRelativePath("/absolute/path"), /绝对路径/);
30  assert.throws(() => assertWritableChangePath("deck", "media/image.png"), /仅允许编辑/);
31});

▸HTTP server 和安装事务也有独立测试

tests/editor-server.test.js:20 确认编辑器静态资源可访问,tests/editor-server.test.js:33 验证 missing file 返回 404,tests/editor-server.test.js:40 则保证 HEAD 没有响应体。安装测试在 tests/install-skill.test.js:19 到 tests/install-skill.test.js:31 确认 SKILL.md 和脚本被复制,且 _user_meta.json 不进入安装包。

tests/editor-server.test.js · L20-45JavaScript
20test("serves the PPTD editor and its JavaScript modules", async () => {
21  await withServer(async (url) => {
22    const index = await fetch(`${url}/`);
23    assert.equal(index.status, 200);
24    assert.match(index.headers.get("content-type"), /^text\/html/);
25    assert.match(await index.text(), /打开 PPTD 文件夹/);
26
27    const app = await fetch(`${url}/app.js`);
28    assert.equal(app.status, 200);
29    assert.match(app.headers.get("content-type"), /^text\/javascript/);
30  });
31});
32
33test("returns 404 for files outside the packaged editor", async () => {
34  await withServer(async (url) => {
35    const response = await fetch(`${url}/missing.js`);
36    assert.equal(response.status, 404);
37  });
38});
39
40test("supports HEAD requests without a response body", async () => {
41  await withServer(async (url) => {
42    const response = await fetch(`${url}/styles.css`, { method: "HEAD" });
43    assert.equal(response.status, 200);
44    assert.equal(await response.text(), "");
45  });
§ 08 · RISKS

风险:非官方前端协议是最大变量

▸外部依赖失效会影响导出

项目的风险不是本地 parser,而是外部 Kimi 前端和 agent-browser 交互。scripts/export_pptx.py:92 会检查 agent-browser 版本,必要时 scripts/export_pptx.py:109 安装新版;lib/editor-app.js:634 连接 Kimi iframe,lib/editor-app.js:687 等待 RPC 握手。如果公开资源、Penpal bundle 或导出对话框结构变更,导出链路可能需要同步修复。

风险源码证据应对
公开 Kimi 前端变更lib/editor-app.js:11 / lib/editor-app.js:640把失败模式暴露为连接错误,不吞掉异常。
下载结果不是合法 PPTXscripts/export_pptx.py:462 / scripts/export_pptx.py:466CRC、content type、slide XML 全部校验。
本地文件写入越权lib/editor-lib.js:111 / lib/editor-app.js:579只允许 .pptd/.page,且只在授权目录写。
§ 09 · DEEP READ

继续读源码:两个入口

▸编辑器桥接与保存事务

如果你关心浏览器 File System Access API、iframe RPC、图片资源解析和自动保存,请读子页面一。它会围绕 lib/editor-app.js:486、lib/editor-app.js:524、lib/editor-app.js:556 和 lib/editor-app.js:634 展开。

深度阅读 · 编辑器桥接与保存事务

▸导出与视觉 QA 管线

如果你关心 PPTX ZIP 校验、transition patch、agent-browser download、图片 ZIP 和 overview 拼图,请读子页面二。它会围绕 scripts/export_pptx.py:376、scripts/export_pptx.py:442、scripts/export_images.py:86 和 scripts/export_images.py:113 展开。

深度阅读 · 导出与视觉 QA 管线