commit 5b0d45bf1d46ea535019ae4b5376d5c872456af5 Author: binaryify Date: Wed Aug 5 16:25:24 2026 +0800 chore: 1.0.0 release diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..35cfed5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Python-generated files +__pycache__/ +*.py[cod] +*$py.class +.pytest_cache/ + +# macOS metadata +.DS_Store + +# Packaged artifacts +*.tgz +*.zip + +# Local skill install metadata +_user_meta.json diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..acdaf25 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Binaryify Zhuang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..10fbbc1 --- /dev/null +++ b/README.md @@ -0,0 +1,204 @@ +# open-kimi-ppt-skills + +[简体中文](README.md) | [English](README_EN.md) + +[![npm version](https://img.shields.io/npm/v/open-kimi-ppt-skills)](https://www.npmjs.com/package/open-kimi-ppt-skills) +[![node](https://img.shields.io/badge/node-%3E%3D18-brightgreen)](https://nodejs.org) + +逆向 Kimi Slides 实现的非官方演示文稿 Skill:让 AI Coding Agent 能够创建、编辑、复刻、读取并导出 PPT/PPTX。**每次生成默认同时交付两份成果:可继续编辑的 PPTD 项目和开箱即用的 PPTX 成品**(自动嵌入字体、写入淡入淡出切换动画),并提供本地在线 PPTD 编辑器支持随时手动导出。支持 Codex、Claude Code、Cursor、CodeBuddy 等任何兼容 SKILL.md 规范的 Agent。 + +> [!IMPORTANT] +> 本项目通过逆向分析 Kimi Slides Skill、PPTD 格式以及公开网页编辑器的前端行为与通信协议实现,并非 Kimi 或 Moonshot AI 的官方项目,也未获得其认可或支持。项目依赖的公开前端资源和兼容协议可能随 Kimi 更新而失效,仅供学习与研究使用。 + +## 功能特性 + +- **PPTD 生成**:让 Agent 生成完整、可继续编辑的 PPTD 项目,支持从零创作、风格迁移、模板复用、图片/PDF 复刻。 +- **PPTX 生成**:默认同步生成 PPTX 成品,自动嵌入字体并写入淡入淡出切换动画。 +- **视觉质检**:多模态模型在导出 PPTX 前自动导出整份页面图片、拼接总览图逐项核查(变形、遮挡、出界、对比度、排版、文字溢出),问题页面修复后复检,直至全部通过。 +- **在线编辑**:通过浏览器查看和编辑本地 PPTD 项目,自动保存,可配置页面切换动画。 +- **手动导出**:在编辑器中随时手动导出 PPTX。 +- **格式互转**:将现有 PPTX 转换为 PPTD 后继续修改。 +- **安全可控**:本地编辑仅在用户明确授权的项目目录内读写文件。 + +## 为什么选 open-kimi-ppt + +常见 PPT Skill 大致分三类:用代码库直接拼 OOXML / pptxgenjs、整页生成图片再塞进 PPTX、或输出网页 HTML 翻页。open-kimi-ppt 走的是 **PPTD 中间层 + 真实可编辑 PPTX** 路线,兼顾 Agent 好写、人好看、PowerPoint 能改。 + +| | open-kimi-ppt | 代码拼 PPTX(如 pptxgenjs) | 整页图片 PPT | 网页 HTML PPT | +| --- | --- | --- | --- | --- | +| 交付物 | PPTD 项目 + PPTX | 多为仅 PPTX | 多为仅 PPTX | 单文件 HTML | +| Agent 友好度 | YAML 逐页描述,结构清晰 | 坐标/API 细节多,易排版翻车 | 依赖出图模型与提示词 | HTML/CSS 模板约束强 | +| PowerPoint 可编辑 | 文本、形状、图片可继续改 | 可编辑,但难二次精修 | 整页位图,难改字 | 不是原生 PPTX | +| 视觉质量 | 真实版式 + 导出前多模态质检 | 依赖 Agent 手调布局 | 画面统一,偏海报感 | 动效强,适合演示分享 | +| 二次编辑 | 浏览器可视化编辑 + 自动保存 | 主要靠改代码重导出 | 基本需重新出图 | 改 HTML 源码 | +| 适用场景 | 要交可改的正式 PPTX,又要好看 | 结构化汇报、模板填充 | 视觉统一的海报风讲稿 | 浏览器内演讲 / 发布会 | + +更具体地说,相对其他方案的优势是: + +1. **中间格式为 Agent 设计**:PPTD 用 YAML 描述主题、布局与元素,比直接写 OOXML / pptxgenjs 更稳,也比「整页渲一张图」更可局部修改。 +2. **默认同交两份成果**:可继续迭代的 PPTD 项目 + 开箱即用的 PPTX(嵌字体、淡入淡出切换),不是只给半成品。 +3. **PPTX 真能改**:导出后文本框、形状仍可在 PowerPoint / WPS 里编辑,不像图片型 PPT 只能当海报。 +4. **有本地可视化编辑器**:浏览器里预览、微调、配切换动画并手动再导出,不需要每次都让 Agent 重跑全流程。 +5. **导出前强制视觉质检**:整页截图 + 总览图检查遮挡、出界、对比度、溢出等问题,修完再出 PPTX。 +6. **不绑官方模型,成本更低**:相对官方 Kimi Slides,你可以在任意兼容 Agent 里使用 DeepSeek 等低成本模型;即便模型不支持多模态,只要按 PPTD 规范生成,也能做出不错的成品(有多模态时再做视觉质检会更稳)。 + +[![DeepSeek 生成 Liquid Glass 风格 PPT](docs/images/example-deepseek-liquid-glass.png)](docs/images/example-deepseek-liquid-glass.png) + +*上图:在 CodeBuddy / WorkBuddy 中使用 DeepSeek-V4-Flash 生成 Apple Liquid Glass 风格 PPT 的实际效果。* + +### 关于风格与主题 + +本 Skill **不预设固定主题或模板**。风格由你决定。 + +> [!TIP] +> **强烈建议在 Prompt 里写明 PPT 风格,或直接附上参考 PPT / PPTX 模板。** 有风格约束或模板参照时,出品会明显更好、更稳定;只给主题不给风格时,Agent 会自行发挥,效果容易波动。 + +常见两种用法: + +1. **在 Prompt 里描述风格**:例如「深色科技风」「杂志排版」「苹果 liquid glass」「极简留白 + 大字报」等; +2. **提供参考模板**:上传现有 PPT / PPTX / 截图,让 Agent 迁移配色、版式与风格。 + +两者可以一起用:先给模板定调,再用一句话补充本次要强化的风格。 + +## 界面预览 + +| 在线编辑 PPTD | 导出 PPTX | +| :---: | :---: | +| [![在线编辑 PPTD](docs/images/editor-overview.png)](docs/images/editor-overview.png) | [![导出 PPTX](docs/images/export-pptx.png)](docs/images/export-pptx.png) | + +## 什么是 PPTD + +PPTD 是一种基于 YAML 的演示文稿 DSL,是 OOXML 之上的简化抽象层:保留主题、页面布局、元素位置等核心信息,去除了 Master 等复杂嵌套,每页自包含、所见即所得。完整的格式定义见 [reference/pptd.md](skills/open-kimi-ppt/reference/pptd.md)。 + +一个完整的 PPTD 项目目录结构如下: + +```text +deck/ + deck.pptd # 清单文件 + pages/ # 每页一个 .page 文件 + media/ # 本地媒体资源(如有) + deck.pptx # 默认同步生成的 PPTX 成品 +``` + +## 安装 + +需要 Node.js 18 或更高版本。也可以直接对 AI 说:`帮我从 github 安装 open-kimi-ppt skills`,或者说:`帮我安装 https://github.com/Binaryify/open-kimi-ppt-skill`。 + +直接通过 npx 安装: + +```bash +npx open-kimi-ppt-skills install +``` + +也可以先全局安装 CLI: + +```bash +npm install --global open-kimi-ppt-skills +open-kimi-ppt-skills install +``` + +默认安装到跨 Agent 的通用目录 `~/.agents/skills/open-kimi-ppt`,支持该目录的 Agent 可直接发现。如果你的 Agent 使用专属 Skills 目录,可通过 `--target` 指定: + +```bash +# Codex +open-kimi-ppt-skills install --target ~/.codex/skills + +# Claude Code +open-kimi-ppt-skills install --target ~/.claude/skills + +# Cursor +open-kimi-ppt-skills install --target ~/.cursor/skills + +# CodeBuddy +open-kimi-ppt-skills install --target ~/.codebuddy/skills +``` + +### 更新 + +Skill 有更新时,用 `--force` 覆盖本地已安装版本即可: + +```bash +npx open-kimi-ppt-skills@latest install --force +``` + +若安装时指定过 `--target`,更新时带上相同路径: + +```bash +npx open-kimi-ppt-skills@latest install --force --target ~/.claude/skills +``` + +也可以直接对 AI 说:`帮我更新 open-kimi-ppt skill`。更新只替换 Skill 文件,不会影响你已生成的 PPTD / PPTX 项目。 + +## 使用 + +### 让 Agent 生成 PPT + +安装完成后,直接向 Agent 描述需求即可。**默认交付物始终是两份**:完整的 PPTD 项目目录(可继续编辑)和对应的 PPTX 成品文件;只有明确要求只输出 PPTD 时才会跳过 PPTX 生成。 + +为了更稳定的出品,Prompt 里最好带上风格(如「深色产品发布风」),或附上参考 PPT 模板;只写主题、不给风格时效果更容易波动。 + +#### Prompt 示例 + +**示例 1:小米 YU7(约 8 页,图片作背景)** + +```text +使用 open-kimi-ppt 做一个介绍小米 yu7的 PPT,要求图片做背景,素材从网上找,8 页左右 +``` + +| 在线编辑 PPTD | 导出 PPTX | +| :---: | :---: | +| [![小米 YU7 在线编辑](docs/images/example-yu7-editor.png)](docs/images/example-yu7-editor.png) | [![小米 YU7 导出 PPTX](docs/images/example-yu7-pptx.png)](docs/images/example-yu7-pptx.png) | + +**示例 2:DJI Pocket 4(图片作背景)** + +```text +使用 open-kimi-ppt 帮我生成DJI Pocket4 的 PPT,要求图片做背景,素材从网上找 +``` + +| 在线编辑 PPTD | 导出 PPTX | +| :---: | :---: | +| [![DJI Pocket 4 在线编辑](docs/images/example-dji-pocket4-editor.png)](docs/images/example-dji-pocket4-editor.png) | [![DJI Pocket 4 导出 PPTX](docs/images/example-dji-pocket4-pptx.png)](docs/images/example-dji-pocket4-pptx.png) | + +### 在线编辑与手动导出 + +启动本地编辑器: + +```bash +npx open-kimi-ppt-skills serve +``` + +然后打开 ,选择包含 `.pptd` 清单、`pages/` 和 `media/` 的完整项目文件夹,即可在浏览器中查看、编辑项目并导出 PPTX。仓库自带的 [example/dji-pocket4](example/dji-pocket4) 是一个完整的 18 页示例项目,可直接打开体验。 + +```bash +# 启动后自动打开浏览器 +npx open-kimi-ppt-skills serve --open + +# 使用其他端口 +npx open-kimi-ppt-skills serve --port 56000 +``` + +可写目录需要使用支持 File System Access API 的 Chromium 系浏览器;其他浏览器会回退为只读文件夹上传。按 `Ctrl+C` 停止服务。 + +## 工作原理与安全边界 + +- CLI 只在 `127.0.0.1` 启动静态文件服务,不会监听局域网地址。 +- 浏览器只在用户主动授权后读取完整 PPTD 项目目录。 +- 保存回调只允许修改 `.pptd` 和 `.page` 文件,并拒绝绝对路径与 `..` 路径越界。 +- PPTD 内容由本地宿主交给公开的 Kimi 网页编辑器处理;远程图片、字体和编辑器资源仍可能从对应服务器加载。 +- 本项目不会提供或注入 Kimi 登录令牌,也不会访问用户的 Kimi 私有文稿。 + +## 兼容性说明 + +这是针对当前公开实现的兼容宿主,不是稳定的官方 SDK。Kimi 更新前端资源哈希、PPTD 格式或 iframe/RPC 协议后,本项目可能需要同步升级。成功生成 PPTX 也不代表 PowerPoint、WPS 和 Keynote 对所有动画效果都能完全一致地播放。 + +## 本地开发 + +```bash +npm install --global . +npm test +npm run pack:check +``` + +## 声明 + +Kimi、Kimi Slides 及相关商标归其权利人所有。 diff --git a/README_EN.md b/README_EN.md new file mode 100644 index 0000000..aa8d8a4 --- /dev/null +++ b/README_EN.md @@ -0,0 +1,186 @@ +# open-kimi-ppt-skills + +[简体中文](README.md) | [English](README_EN.md) + +[![npm version](https://img.shields.io/npm/v/open-kimi-ppt-skills)](https://www.npmjs.com/package/open-kimi-ppt-skills) +[![node](https://img.shields.io/badge/node-%3E%3D18-brightgreen)](https://nodejs.org) + +An unofficial presentation skill for AI coding agents, reverse-engineered from Kimi Slides. It lets your agent create, edit, replicate, read, and export PPT/PPTX files. **Every run delivers two outputs by default: an editable PPTD project and a ready-to-use PPTX** — fonts embedded and fade transitions included — plus a local in-browser PPTD editor with manual PPTX export. Works with Codex, Claude Code, Cursor, CodeBuddy, and any agent that supports the SKILL.md format. + +> [!IMPORTANT] +> This project is implemented by reverse-engineering the Kimi Slides skill, the PPTD format, and the frontend behavior and communication protocol of the publicly accessible web editor. It is not an official Kimi or Moonshot AI project and is not endorsed or supported by them. Public frontend resources and compatibility contracts used by this project may change without notice. Provided for learning and research purposes only. + +## Features + +- **PPTD generation**: let your agent generate complete, editable PPTD projects — from scratch, with style transfer, template reuse, or replication from images/PDFs. +- **PPTX generation**: produce a matching PPTX by default, with fonts embedded and fade transitions written automatically. +- **Visual QA**: with a multimodal model, the skill exports every page as an image, stitches them into an overview sheet, and checks each page (distortion, occlusion, out-of-bounds elements, contrast, layout consistency, text overflow) before PPTX export — fixing and re-checking until every page passes. +- **Online editing**: view and edit local PPTD projects in a browser, with autosave and configurable slide transitions. +- **Manual export**: export PPTX manually from the editor at any time. +- **Format conversion**: convert existing PPTX files to PPTD for further editing. +- **Secure by design**: local editing only reads and writes project directories explicitly authorized by the user. + +## Why open-kimi-ppt + +Most PPT skills fall into three buckets: assemble OOXML / pptxgenjs in code, render each slide as a full-bleed image, or ship a swipeable HTML deck. open-kimi-ppt takes a **PPTD intermediate layer + real editable PPTX** path — easy for agents to write, good to look at, and still editable in PowerPoint. + +| | open-kimi-ppt | Code-built PPTX (e.g. pptxgenjs) | Full-slide image PPT | Web HTML PPT | +| --- | --- | --- | --- | --- | +| Deliverable | PPTD project + PPTX | Usually PPTX only | Usually PPTX only | Single HTML file | +| Agent-friendly | Clear per-page YAML | Lots of coordinates/API detail | Depends on image models & prompts | Strong HTML/CSS template constraints | +| Editable in PowerPoint | Text, shapes, images stay editable | Editable, but hard to refine later | Flat bitmaps — hard to reword | Not native PPTX | +| Visual quality | Real layouts + multimodal QA before export | Relies on agent layout tuning | Cohesive, poster-like | Strong motion; great for live demos | +| Re-editing | Browser visual editor + autosave | Mostly re-run code | Usually regenerate images | Edit HTML source | +| Best for | Formal PPTX you still need to tweak | Structured reports / template fills | Visually unified poster decks | In-browser talks / launches | + +In short: + +1. **DSL built for agents** — PPTD describes theme, layout, and elements in YAML, more stable than raw OOXML / pptxgenjs, and more locally editable than full-slide images. +2. **Two deliverables by default** — an iterable PPTD project plus a ready-to-open PPTX (embedded fonts, fade transitions). +3. **Truly editable PPTX** — text boxes and shapes remain editable in PowerPoint / WPS, unlike image-only decks. +4. **Local visual editor** — preview, tweak, set transitions, and re-export in the browser without rerunning the whole agent flow. +5. **Visual QA before export** — full-page screenshots plus an overview sheet catch occlusion, overflow, contrast, and layout issues before PPTX is written. +6. **Not locked to the official model — lower cost** — unlike official Kimi Slides, you can run this in any compatible agent with cheaper models such as DeepSeek. Even without multimodal vision, a model that follows the PPTD spec can still produce strong decks (multimodal helps more with the visual QA pass). + +[![DeepSeek generating a Liquid Glass-style PPT](docs/images/example-deepseek-liquid-glass.png)](docs/images/example-deepseek-liquid-glass.png) + +*Above: an Apple Liquid Glass-style deck generated with DeepSeek-V4-Flash in CodeBuddy / WorkBuddy.* + +### Style and themes + +This skill **does not ship a fixed theme or template**. You choose the look. + +> [!TIP] +> **Best results come from stating a PPT style in the prompt, or attaching a reference PPT / PPTX template.** With a style constraint or template to follow, output quality is clearly better and more stable. Topic-only prompts leave the agent free to invent a look, so results vary more. + +Common approaches: + +1. **Describe the style in the prompt** — e.g. dark tech, magazine layout, Apple liquid glass, minimal big-type poster slides; +2. **Provide a reference template** — upload an existing PPT / PPTX / screenshot and ask the agent to transfer colors, layout, and overall style. + +You can combine both: lock the look with a template, then add one line about the style you want to emphasize. + +## Screenshots + +| Edit PPTD online | Export PPTX | +| :---: | :---: | +| [![Edit PPTD online](docs/images/editor-overview.png)](docs/images/editor-overview.png) | [![Export PPTX](docs/images/export-pptx.png)](docs/images/export-pptx.png) | + +## What is PPTD + +PPTD is a YAML-based presentation DSL — a simplified abstraction layer over OOXML. It preserves the essentials (theme, page layout, element positions) while dropping complex nesting such as Masters; every page is self-contained — what you see is what you get. See [reference/pptd.md](skills/open-kimi-ppt/reference/pptd.md) for the complete definition. + +A complete PPTD project looks like this: + +```text +deck/ + deck.pptd # manifest + pages/ # one .page file per slide + media/ # local media assets (if any) + deck.pptx # PPTX generated by default +``` + +## Install + +Node.js 18 or later is required. You can also ask your agent: `Install the open-kimi-ppt skills from GitHub for me.`, or: `Install https://github.com/Binaryify/open-kimi-ppt-skill for me.` + +Install directly with npx: + +```bash +npx open-kimi-ppt-skills install +``` + +Alternatively, install the CLI globally: + +```bash +npm install --global open-kimi-ppt-skills +open-kimi-ppt-skills install +``` + +By default the installer targets the shared, agent-agnostic directory `~/.agents/skills/open-kimi-ppt`, which compatible agents discover directly. If your agent uses its own skills directory, point `--target` at it: + +```bash +# Codex +open-kimi-ppt-skills install --target ~/.codex/skills + +# Claude Code +open-kimi-ppt-skills install --target ~/.claude/skills + +# Cursor +open-kimi-ppt-skills install --target ~/.cursor/skills + +# CodeBuddy +open-kimi-ppt-skills install --target ~/.codebuddy/skills +``` + +### Update + +When the skill is updated, overwrite your local install with `--force`: + +```bash +npx open-kimi-ppt-skills@latest install --force +``` + +If you originally installed with `--target`, pass the same path again: + +```bash +npx open-kimi-ppt-skills@latest install --force --target ~/.claude/skills +``` + +You can also ask your agent: `Update the open-kimi-ppt skill for me.` Updating only replaces the skill files; it does not touch PPTD / PPTX projects you already generated. + +## Usage + +### Generate a presentation with your agent + +Once installed, just describe what you need. **You always get two deliverables by default**: the complete, editable PPTD project directory and the matching PPTX file. PPTX generation is skipped only when you explicitly ask for PPTD-only output. + +For more stable quality, put a style in the prompt (e.g. “dark product-launch look”) or attach a reference PPT template; topic-only prompts without style guidance tend to vary more. + +```text +Use open-kimi-ppt to create a liquid-glass-style deck about the history of Apple. +``` + +### Edit online and export manually + +Start the local editor: + +```bash +npx open-kimi-ppt-skills serve +``` + +Then open and choose a complete project folder containing the `.pptd` manifest, `pages/`, and `media/` to view, edit, and export PPTX in the browser. The bundled [example/dji-pocket4](example/dji-pocket4) project — a complete 18-page deck — is ready to open for a quick tour. + +```bash +# Open the browser after startup +npx open-kimi-ppt-skills serve --open + +# Use another port +npx open-kimi-ppt-skills serve --port 56000 +``` + +Writable folder access requires a Chromium-based browser with the File System Access API. Other browsers fall back to read-only folder upload. Press `Ctrl+C` to stop the server. + +## How it works and security boundaries + +- The CLI serves static files on `127.0.0.1` only and does not listen on LAN interfaces. +- The browser reads a complete PPTD project directory only after explicit user authorization. +- Save callbacks may only modify `.pptd` and `.page` files; absolute paths and `..` traversal are rejected. +- The local host passes PPTD content to the public Kimi web editor. Remote images, fonts, and editor resources may still be fetched from their respective servers. +- This project does not provide or inject Kimi login tokens and does not access private Kimi documents. + +## Compatibility + +This is a compatibility host for the current public implementation, not a stable official SDK. Updates to Kimi frontend asset hashes, the PPTD format, or the iframe/RPC protocol may require a corresponding project update. Successfully generating a PPTX does not guarantee identical animation playback in PowerPoint, WPS, and Keynote. + +## Local development + +```bash +npm install --global . +npm test +npm run pack:check +``` + +## Legal + +Kimi, Kimi Slides, and related trademarks belong to their respective owners. diff --git a/bin/open-kimi-ppt-skills.js b/bin/open-kimi-ppt-skills.js new file mode 100755 index 0000000..42478c6 --- /dev/null +++ b/bin/open-kimi-ppt-skills.js @@ -0,0 +1,165 @@ +#!/usr/bin/env node + +import { cpSync, existsSync, mkdtempSync, mkdirSync, renameSync, rmSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { startEditorServer } from "../lib/editor-server.js"; + +const SKILL_NAME = "open-kimi-ppt"; +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const sourceDirectory = join(packageRoot, "skills", SKILL_NAME); + +function printHelp(command) { + if (command === "serve") { + console.log(`Start the local PPTD editor and exporter. + +Usage: + open-kimi-ppt-skills serve [options] + +Options: + --port HTTP port (default: 55173) + --open Open the editor in the default browser + -h, --help Show this help +`); + return; + } + + console.log(`Install ${SKILL_NAME} for your AI coding agent or start its local editor. + +Usage: + open-kimi-ppt-skills [install] [options] + open-kimi-ppt-skills serve [options] + +Install options: + --target Skills directory (default: ~/.agents/skills) + --force Replace an existing installation + +Run "open-kimi-ppt-skills serve --help" for server options. +`); +} + +function parseArguments(arguments_) { + const args = [...arguments_]; + const command = args[0] === "install" || args[0] === "serve" ? args.shift() : "install"; + const options = command === "serve" + ? { command, open: false, port: 55173 } + : { command, force: false, target: undefined }; + + while (args.length > 0) { + const argument = args.shift(); + + if (command === "install" && argument === "--force") { + options.force = true; + continue; + } + + if (command === "install" && argument === "--target") { + const target = args.shift(); + if (!target || target.startsWith("-")) { + throw new Error("--target requires a directory"); + } + options.target = resolve(target); + continue; + } + + if (command === "serve" && argument === "--port") { + const port = Number(args.shift()); + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new Error("--port must be an integer between 1 and 65535"); + } + options.port = port; + continue; + } + + if (command === "serve" && argument === "--open") { + options.open = true; + continue; + } + + if (argument === "--help" || argument === "-h") { + options.help = true; + continue; + } + + throw new Error(`unknown argument: ${argument}`); + } + + return options; +} + +function openBrowser(url) { + const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open"; + const args = process.platform === "win32" ? ["/c", "start", "", url] : [url]; + const child = spawn(command, args, { detached: true, stdio: "ignore" }); + child.on("error", (error) => console.warn(`Could not open the browser: ${error.message}`)); + child.unref(); +} + +function defaultSkillsDirectory() { + return join(homedir(), ".agents", "skills"); +} + +function installSkill({ force, target }) { + if (!existsSync(join(sourceDirectory, "SKILL.md"))) { + throw new Error(`packaged skill is incomplete: ${sourceDirectory}`); + } + + const skillsDirectory = target ?? defaultSkillsDirectory(); + const destination = join(skillsDirectory, SKILL_NAME); + + if (existsSync(destination) && !force) { + throw new Error( + `${destination} already exists; rerun with --force to replace it`, + ); + } + + mkdirSync(skillsDirectory, { recursive: true }); + const stagingRoot = mkdtempSync(join(skillsDirectory, `.${SKILL_NAME}-`)); + const stagedSkill = join(stagingRoot, SKILL_NAME); + + try { + cpSync(sourceDirectory, stagedSkill, { + recursive: true, + filter: (source) => ![".DS_Store", "_user_meta.json"].includes(basename(source)), + }); + + if (force) { + rmSync(destination, { recursive: true, force: true }); + } + + renameSync(stagedSkill, destination); + } finally { + rmSync(stagingRoot, { recursive: true, force: true }); + } + + console.log(`Installed ${SKILL_NAME} to ${destination}`); +} + +async function main() { + const options = parseArguments(process.argv.slice(2)); + if (options.help) { + printHelp(options.command); + return; + } + + if (options.command === "install") { + installSkill(options); + return; + } + + const { server, url } = await startEditorServer({ port: options.port }); + console.log(`Open Kimi PPT editor is running at ${url}`); + console.log("Press Ctrl+C to stop the server."); + if (options.open) openBrowser(url); + + const shutdown = () => server.close(() => process.exit(0)); + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); +} + +main().catch((error) => { + console.error(`Error: ${error.message}`); + process.exitCode = 1; +}); diff --git a/docs/images/editor-overview.png b/docs/images/editor-overview.png new file mode 100644 index 0000000..c033361 Binary files /dev/null and b/docs/images/editor-overview.png differ diff --git a/docs/images/example-deepseek-liquid-glass.png b/docs/images/example-deepseek-liquid-glass.png new file mode 100644 index 0000000..5b69a57 Binary files /dev/null and b/docs/images/example-deepseek-liquid-glass.png differ diff --git a/docs/images/example-dji-pocket4-editor.png b/docs/images/example-dji-pocket4-editor.png new file mode 100644 index 0000000..12f08c1 Binary files /dev/null and b/docs/images/example-dji-pocket4-editor.png differ diff --git a/docs/images/example-dji-pocket4-pptx.png b/docs/images/example-dji-pocket4-pptx.png new file mode 100644 index 0000000..4d7d64b Binary files /dev/null and b/docs/images/example-dji-pocket4-pptx.png differ diff --git a/docs/images/example-yu7-editor.png b/docs/images/example-yu7-editor.png new file mode 100644 index 0000000..1b7bdd8 Binary files /dev/null and b/docs/images/example-yu7-editor.png differ diff --git a/docs/images/example-yu7-pptx.png b/docs/images/example-yu7-pptx.png new file mode 100644 index 0000000..115cb64 Binary files /dev/null and b/docs/images/example-yu7-pptx.png differ diff --git a/docs/images/export-pptx.png b/docs/images/export-pptx.png new file mode 100644 index 0000000..94143c9 Binary files /dev/null and b/docs/images/export-pptx.png differ diff --git a/editor/app.js b/editor/app.js new file mode 100644 index 0000000..03672ff --- /dev/null +++ b/editor/app.js @@ -0,0 +1,798 @@ +import { + assertWritableChangePath, + basename, + dirname, + extractPagePaths, + joinDeckPath, + normalizeRelativePath, + titleFromManifest, +} from "./lib.js"; + +const EDITOR_ORIGIN = "https://www.kimi.com"; +const PENPAL_MODULE = "https://statics.moonshot.cn/neo-design/assets/penpal-C4NjirZE.js"; +const MAX_TEXT_BYTES = 20 * 1024 * 1024; +const MAX_IMAGE_BYTES = 20 * 1024 * 1024; +const MAX_CHANGE_COUNT = 600; + +const elements = { + frame: document.querySelector("#editor"), + openFolder: document.querySelector("#open-folder"), + openDemo: document.querySelector("#open-demo"), + reload: document.querySelector("#reload-deck"), + documentTitle: document.querySelector("#document-title"), + documentPath: document.querySelector("#document-path"), + connectionStatus: document.querySelector("#connection-status"), + connectionLabel: document.querySelector("#connection-label"), + saveState: document.querySelector("#save-state"), + loadingCard: document.querySelector("#loading-card"), + loadingTitle: document.querySelector("#loading-title"), + loadingMessage: document.querySelector("#loading-message"), + activityPanel: document.querySelector("#activity-panel"), + activityList: document.querySelector("#activity-list"), + toggleActivity: document.querySelector("#toggle-activity"), + closeActivity: document.querySelector("#close-activity"), + openDialog: document.querySelector("#open-dialog"), + closeOpenDialog: document.querySelector("#close-open-dialog"), + uploadDropzone: document.querySelector("#upload-dropzone"), + chooseWritableFolder: document.querySelector("#choose-writable-folder"), + uploadFolder: document.querySelector("#upload-folder"), + deckDialog: document.querySelector("#deck-dialog"), + deckOptions: document.querySelector("#deck-options"), + folderFallback: document.querySelector("#folder-fallback"), + toastRegion: document.querySelector("#toast-region"), +}; + +const state = { + remote: null, + connection: null, + source: "demo", + directoryHandle: null, + fileIndex: new Map(), + memoryFiles: new Map(), + manifestPath: "presentation.pptd", + manifestDirectory: "", + manifestContent: "", + deckTitle: "演示文稿", + lastDeckPayload: null, + saveQueue: Promise.resolve(), + imageCache: new Map(), + dragDepth: 0, + readOnlyFallback: false, +}; + +function setConnection(kind, label) { + elements.connectionStatus.className = `status-pill status-${kind}`; + elements.connectionLabel.textContent = label; + document.documentElement.dataset.connection = kind; +} + +function setSaveState(label, kind = "idle") { + elements.saveState.textContent = label; + elements.saveState.dataset.kind = kind; + document.documentElement.dataset.saveState = kind; +} + +function setLoading(visible, title, message) { + elements.loadingCard.classList.toggle("is-hidden", !visible); + elements.loadingCard.setAttribute("aria-hidden", String(!visible)); + if (title) elements.loadingTitle.textContent = title; + if (message) elements.loadingMessage.textContent = message; +} + +function formatTime(date = new Date()) { + return new Intl.DateTimeFormat("zh-CN", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }).format(date); +} + +function addActivity(message, tone = "info") { + const item = document.createElement("li"); + item.dataset.tone = tone; + item.innerHTML = ``; + item.querySelector("span").textContent = message; + elements.activityList.prepend(item); + while (elements.activityList.children.length > 40) { + elements.activityList.lastElementChild.remove(); + } +} + +function toast(message, tone = "info") { + const item = document.createElement("div"); + item.className = `toast toast-${tone}`; + item.textContent = message; + elements.toastRegion.append(item); + requestAnimationFrame(() => item.classList.add("is-visible")); + setTimeout(() => { + item.classList.remove("is-visible"); + setTimeout(() => item.remove(), 220); + }, 3200); +} + +function editorQuery() { + return new URLSearchParams({ + sdkMode: "ppt-editor", + pptPlatform: "neodeck-local", + functional: JSON.stringify({ + fullscreen: true, + present: true, + export: true, + close: false, + annotation: false, + feedback: false, + share: false, + versionHistory: false, + }), + sdkSaveMode: "external", + sdkImageMode: "external", + }); +} + +function demoDeck() { + const manifest = JSON.stringify({ + version: "v2", + title: "NeoDeck Local", + size: [960, 540], + pages: ["pages/01.page", "pages/02.page"], + }); + const pages = [ + { + path: "pages/01.page", + content: JSON.stringify({ + pageType: "content", + background: { color: "#f7f8fc" }, + elements: [ + { + elementId: "eyebrow", + elementType: "text", + bounds: [92, 96, 776, 40], + content: { + text: '

NEODECK LOCAL

', + }, + }, + { + elementId: "title", + elementType: "text", + bounds: [92, 155, 776, 142], + content: { + text: '

直接打开和保存
本地 PPTD

', + }, + }, + { + elementId: "subtitle", + elementType: "text", + bounds: [94, 330, 660, 58], + content: { + text: '

第三方宿主 · 本地文件 · Kimi neo-ppt 编辑内核

', + }, + }, + ], + animations: [ + { + elementId: "title", + effect: "fade-in", + trigger: "onClick", + direction: "up", + easing: "ease-in-out", + durationMs: 700, + }, + ], + }), + }, + { + path: "pages/02.page", + content: JSON.stringify({ + pageType: "content", + background: { color: "#171923" }, + elements: [ + { + elementId: "second-title", + elementType: "text", + bounds: [100, 120, 760, 96], + content: { + text: '

文件系统桥接已经就绪

', + }, + }, + { + elementId: "steps", + elementType: "text", + bounds: [102, 250, 730, 165], + content: { + text: '

① 选择完整的 PPTD 项目文件夹

② 编辑器自动读取清单、页面与素材

③ 自动保存变更到原目录

', + }, + }, + ], + }), + }, + ]; + return { + id: "neodeck-demo", + title: "NeoDeck Local", + manifestPath: "presentation.pptd", + manifestContent: manifest, + pages, + basePath: "", + isCreate: true, + }; +} + +function requireRemote() { + if (!state.remote) throw new Error("编辑器还没有连接完成"); + return state.remote; +} + +async function setDeck(payload, sourceLabel) { + const remote = requireRemote(); + setLoading(true, "正在载入 PPTD", sourceLabel); + state.lastDeckPayload = payload; + state.manifestContent = payload.manifestContent; + state.manifestPath = payload.manifestPath; + state.manifestDirectory = dirname(payload.manifestPath); + state.deckTitle = payload.title; + elements.documentTitle.textContent = payload.title; + elements.documentPath.textContent = sourceLabel; + elements.reload.disabled = false; + + await remote.setPPTD(payload.id, { + pptdContent: payload.manifestContent, + pages: payload.pages, + basePath: payload.basePath, + pptdPath: payload.manifestPath, + isCreate: true, + }); + await remote.setEditable(true); + const status = await remote.getSlideStatus(); + setLoading(false); + addActivity(`已载入「${payload.title}」,共 ${payload.pages.length} 页`, "success"); + document.documentElement.dataset.deckStatus = "ready"; + window.dispatchEvent(new CustomEvent("neodeck:ready", { detail: status })); +} + +async function openDemo() { + state.source = "demo"; + state.directoryHandle = null; + state.fileIndex.clear(); + state.imageCache.clear(); + state.memoryFiles.clear(); + state.readOnlyFallback = false; + const payload = demoDeck(); + state.memoryFiles.set(payload.manifestPath, payload.manifestContent); + for (const page of payload.pages) state.memoryFiles.set(page.path, page.content); + await setDeck(payload, "内置示例 · 修改保存在本页内存"); + setSaveState("示例 · 内存保存", "memory"); +} + +async function indexDirectory(directoryHandle) { + const index = new Map(); + async function walk(handle, prefix = "") { + for await (const [name, entry] of handle.entries()) { + if (name === ".DS_Store") continue; + const path = prefix ? `${prefix}/${name}` : name; + if (entry.kind === "directory") await walk(entry, path); + else index.set(normalizeRelativePath(path), entry); + } + } + await walk(directoryHandle); + return index; +} + +function indexFallbackFiles(fileList) { + const index = new Map(); + const files = [...fileList]; + const firstPath = files[0]?.webkitRelativePath || files[0]?.name || ""; + const rootName = firstPath.includes("/") ? firstPath.split("/")[0] : ""; + for (const file of files) { + let path = file.webkitRelativePath || file.name; + if (rootName && path.startsWith(`${rootName}/`)) path = path.slice(rootName.length + 1); + index.set(normalizeRelativePath(path), { kind: "file", getFile: async () => file }); + } + return index; +} + +async function chooseManifest(paths) { + if (paths.length === 1) return paths[0]; + elements.deckOptions.replaceChildren(); + const choice = new Promise((resolve) => { + for (const path of paths) { + const button = document.createElement("button"); + button.className = "deck-option"; + button.type = "button"; + button.innerHTML = `P`; + button.querySelector("strong").textContent = basename(path); + button.querySelector("small").textContent = path; + button.addEventListener("click", () => { + elements.deckDialog.close(); + resolve(path); + }); + elements.deckOptions.append(button); + } + elements.deckDialog.addEventListener( + "close", + () => { + if (elements.deckDialog.returnValue === "cancel") resolve(null); + }, + { once: true }, + ); + }); + elements.deckDialog.showModal(); + return choice; +} + +async function textFromIndexedFile(path) { + const entry = state.fileIndex.get(normalizeRelativePath(path)); + if (!entry) throw new Error(`找不到文件:${path}`); + const file = await entry.getFile(); + if (file.size > MAX_TEXT_BYTES) throw new Error(`文件超过 20 MiB:${path}`); + return file.text(); +} + +async function loadManifest(manifestPath, sourceLabel) { + const manifestContent = await textFromIndexedFile(manifestPath); + const pagePaths = extractPagePaths(manifestContent); + const manifestDirectory = dirname(manifestPath); + const pages = []; + const missing = []; + for (const pagePath of pagePaths.slice(0, 500)) { + const indexedPath = joinDeckPath(manifestDirectory, pagePath); + try { + pages.push({ path: pagePath, content: await textFromIndexedFile(indexedPath) }); + } catch { + missing.push(pagePath); + } + } + if (pages.length === 0) throw new Error(".pptd 清单引用的页面均无法读取"); + if (missing.length) toast(`跳过 ${missing.length} 个无法读取的页面`, "warning"); + + const fallbackTitle = basename(manifestPath).replace(/\.pptd$/i, ""); + const title = titleFromManifest(manifestContent, fallbackTitle); + await setDeck( + { + id: fallbackTitle, + title, + manifestPath, + manifestContent, + pages, + basePath: manifestDirectory ? `${manifestDirectory}/` : "", + isCreate: true, + }, + sourceLabel, + ); + setSaveState(state.readOnlyFallback ? "只读打开" : "自动保存已开启", state.readOnlyFallback ? "readonly" : "saved"); +} + +async function openDirectoryHandle(directoryHandle, label = directoryHandle.name) { + setLoading(true, "正在扫描文件夹", label); + let permission = await directoryHandle.queryPermission?.({ mode: "readwrite" }); + if (permission !== "granted") permission = await directoryHandle.requestPermission?.({ mode: "readwrite" }); + if (permission !== "granted") throw new Error("没有获得文件夹读写权限"); + + state.source = "directory"; + state.directoryHandle = directoryHandle; + state.readOnlyFallback = false; + state.fileIndex = await indexDirectory(directoryHandle); + state.imageCache.clear(); + const manifests = [...state.fileIndex.keys()].filter((path) => path.toLowerCase().endsWith(".pptd")); + if (manifests.length === 0) throw new Error("文件夹里没有找到 .pptd 清单文件"); + const manifestPath = await chooseManifest(manifests.sort()); + if (!manifestPath) { + setLoading(false); + return; + } + await loadManifest(manifestPath, `${label}/${manifestPath}`); +} + +async function openFallbackFiles(files) { + state.source = "fallback"; + state.directoryHandle = null; + state.readOnlyFallback = true; + state.fileIndex = indexFallbackFiles(files); + state.imageCache.clear(); + const manifests = [...state.fileIndex.keys()].filter((path) => path.toLowerCase().endsWith(".pptd")); + if (manifests.length === 0) throw new Error("文件夹里没有找到 .pptd 清单文件"); + const manifestPath = await chooseManifest(manifests.sort()); + if (manifestPath) await loadManifest(manifestPath, `${manifestPath} · 只读兼容模式`); +} + +async function openDirectoryHandleReadOnly(directoryHandle, label = directoryHandle.name) { + setLoading(true, "正在扫描文件夹", label); + state.source = "fallback"; + state.directoryHandle = null; + state.readOnlyFallback = true; + state.fileIndex = await indexDirectory(directoryHandle); + state.imageCache.clear(); + const manifests = [...state.fileIndex.keys()].filter((path) => path.toLowerCase().endsWith(".pptd")); + if (manifests.length === 0) throw new Error("文件夹里没有找到 .pptd 清单文件"); + const manifestPath = await chooseManifest(manifests.sort()); + if (manifestPath) await loadManifest(manifestPath, `${label}/${manifestPath} · 拖放只读模式`); +} + +async function pickDirectory() { + try { + if ("showDirectoryPicker" in window) { + const directoryHandle = await window.showDirectoryPicker({ id: "neodeck-project", mode: "readwrite" }); + setOpenDialog(false); + await openDirectoryHandle(directoryHandle); + } else { + elements.folderFallback.click(); + } + } catch (error) { + if (error?.name === "AbortError") return; + handleError(error, "打开文件夹失败"); + } +} + +async function openDroppedFolder(handles) { + const usableHandles = handles.filter(Boolean); + if (usableHandles.length !== 1 || usableHandles[0].kind !== "directory") { + throw new Error("只能拖入一个完整的 PPTD 项目文件夹,不能单独拖入 .pptd 文件"); + } + + const directoryHandle = usableHandles[0]; + try { + setOpenDialog(false); + await openDirectoryHandle(directoryHandle); + } catch (error) { + setOpenDialog(false); + toast("无法以可写模式载入,正在尝试只读打开", "warning"); + await openDirectoryHandleReadOnly(directoryHandle); + } +} + +function resolveIndexedPath(requestedPath) { + if (typeof requestedPath !== "string" || !requestedPath.trim()) return null; + if (/^(?:data:image\/|https?:\/\/|blob:)/i.test(requestedPath)) return requestedPath; + let candidate = requestedPath.replace(/^file:\/\/+/, "").replaceAll("\\", "/"); + try { + candidate = decodeURIComponent(candidate); + } catch { + // Preserve literal percent signs. + } + + const directCandidates = []; + try { + directCandidates.push(normalizeRelativePath(candidate)); + } catch { + const parts = candidate.split("/").filter(Boolean); + for (let index = 0; index < parts.length; index += 1) { + try { + directCandidates.push(normalizeRelativePath(parts.slice(index).join("/"))); + } catch { + // Keep looking for a safe suffix. + } + } + } + try { + directCandidates.push(joinDeckPath(state.manifestDirectory, candidate)); + } catch { + // Absolute paths may still match a safe indexed suffix below. + } + for (const path of directCandidates) if (state.fileIndex.has(path)) return path; + for (const path of state.fileIndex.keys()) { + if (directCandidates.some((candidatePath) => path.endsWith(`/${candidatePath}`) || path === candidatePath)) return path; + } + return null; +} + +function fileToDataUrl(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener("load", () => resolve(String(reader.result))); + reader.addEventListener("error", () => reject(reader.error || new Error("图片读取失败"))); + reader.readAsDataURL(file); + }); +} + +async function resolveImage(requestedPath) { + if (/^(?:data:image\/|https?:\/\/|blob:)/i.test(requestedPath)) return requestedPath; + const path = resolveIndexedPath(requestedPath); + if (!path) return ""; + const entry = state.fileIndex.get(path); + const file = await entry.getFile(); + if (file.size > MAX_IMAGE_BYTES) return ""; + const cacheKey = `${path}:${file.lastModified}:${file.size}`; + if (!state.imageCache.has(cacheKey)) state.imageCache.set(cacheKey, fileToDataUrl(file)); + return state.imageCache.get(cacheKey); +} + +async function getImages(payload = {}) { + const paths = Array.isArray(payload.filePath) ? payload.filePath : []; + const result = await Promise.all(paths.map((path) => resolveImage(path).catch(() => ""))); + const misses = result.filter((value) => !value).length; + if (misses) addActivity(`${paths.length} 张图片中有 ${misses} 张未找到`, "warning"); + return result; +} + +async function getHandleForPath(root, path, create = false) { + const parts = normalizeRelativePath(path).split("/"); + const fileName = parts.pop(); + let directory = root; + for (const part of parts) directory = await directory.getDirectoryHandle(part, { create }); + return { directory, fileName, file: await directory.getFileHandle(fileName, { create }) }; +} + +async function readBackup(path) { + try { + const { file } = await getHandleForPath(state.directoryHandle, path, false); + return { existed: true, bytes: await (await file.getFile()).arrayBuffer() }; + } catch (error) { + if (error?.name === "NotFoundError") return { existed: false, bytes: null }; + throw error; + } +} + +async function writeFile(path, content) { + const { file } = await getHandleForPath(state.directoryHandle, path, true); + const writable = await file.createWritable(); + try { + await writable.write(content); + } finally { + await writable.close(); + } + state.fileIndex.set(path, file); +} + +async function deleteFile(path) { + const parts = normalizeRelativePath(path).split("/"); + const fileName = parts.pop(); + let directory = state.directoryHandle; + for (const part of parts) directory = await directory.getDirectoryHandle(part, { create: false }); + try { + await directory.removeEntry(fileName); + } catch (error) { + if (error?.name !== "NotFoundError") throw error; + } + state.fileIndex.delete(path); +} + +async function rollbackChanges(backups) { + const entries = [...backups.entries()].reverse(); + for (const [path, backup] of entries) { + if (backup.existed) await writeFile(path, backup.bytes); + else await deleteFile(path); + } +} + +async function persistChanges(payload = {}) { + const changes = Array.isArray(payload.changes) ? payload.changes : []; + if (changes.length === 0) return { fileContent: payload.fileContent, lastModifiedTime: Date.now() }; + if (changes.length > MAX_CHANGE_COUNT) throw new Error(`一次保存不能超过 ${MAX_CHANGE_COUNT} 个文件`); + + const normalized = changes.map((change) => { + const path = assertWritableChangePath(state.manifestDirectory, change.path); + const operation = change.operate === "delete" ? "delete" : "put"; + const content = operation === "put" ? String(change.content ?? "") : null; + if (content && new Blob([content]).size > MAX_TEXT_BYTES) throw new Error(`保存内容超过 20 MiB:${path}`); + return { path, operation, content }; + }); + + if (state.source === "demo") { + for (const change of normalized) { + if (change.operation === "delete") state.memoryFiles.delete(change.path); + else state.memoryFiles.set(change.path, change.content); + } + setSaveState("刚刚保存到内存", "memory"); + addActivity(`已在内存保存 ${normalized.length} 项变更`, "success"); + return { fileContent: payload.fileContent, lastModifiedTime: Date.now() }; + } + + if (state.readOnlyFallback || !state.directoryHandle) { + throw new Error("当前是只读兼容模式,请用支持文件夹授权的 Chromium 浏览器打开"); + } + + const permission = await state.directoryHandle.queryPermission({ mode: "readwrite" }); + if (permission !== "granted") throw new Error("项目文件夹的写入权限已经失效"); + + setSaveState("正在保存…", "saving"); + const backups = new Map(); + try { + for (const change of normalized) backups.set(change.path, await readBackup(change.path)); + for (const change of normalized) { + if (change.operation === "delete") await deleteFile(change.path); + else await writeFile(change.path, change.content); + } + } catch (error) { + await rollbackChanges(backups).catch((rollbackError) => { + addActivity(`回滚未完全成功:${rollbackError.message}`, "error"); + }); + throw error; + } + + setSaveState("刚刚已保存", "saved"); + addActivity(`已保存 ${normalized.length} 个文件变更`, "success"); + return { fileContent: payload.fileContent, lastModifiedTime: Date.now() }; +} + +function onSave(payload) { + const operation = state.saveQueue.then(() => persistChanges(payload)); + state.saveQueue = operation.catch(() => undefined); + return operation.catch((error) => { + setSaveState("保存失败", "error"); + addActivity(error.message, "error"); + toast(`保存失败:${error.message}`, "error"); + throw error; + }); +} + +async function toggleFullscreen(value) { + if (value === false || document.fullscreenElement) { + if (document.fullscreenElement) await document.exitFullscreen(); + return false; + } + await document.documentElement.requestFullscreen(); + return true; +} + +function handleError(error, context = "操作失败") { + const message = error?.message || String(error); + setLoading(false); + addActivity(`${context}:${message}`, "error"); + toast(`${context}:${message}`, "error"); + console.error(context, error); +} + +async function connectEditor() { + setConnection("connecting", "连接中"); + addActivity("正在连接公开 neo-ppt 编辑器"); + elements.frame.src = `${EDITOR_ORIGIN}/neo-ppt/?${editorQuery()}`; + + try { + const [{ i: connect, r: WindowMessenger }] = await Promise.all([ + import(PENPAL_MODULE), + new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("编辑器 iframe 加载超时")), 20_000); + elements.frame.addEventListener( + "load", + () => { + clearTimeout(timeout); + resolve(); + }, + { once: true }, + ); + }), + ]); + + const messenger = new WindowMessenger({ + remoteWindow: elements.frame.contentWindow, + allowedOrigins: [EDITOR_ORIGIN], + }); + state.connection = connect({ + messenger, + methods: { + close() { + toast("当前文稿由 NeoDeck Local 托管"); + }, + reenter() { + return state.lastDeckPayload ? setDeck(state.lastDeckPayload, elements.documentPath.textContent) : undefined; + }, + toggleFullScreen: toggleFullscreen, + showFeedback() {}, + sendPrompt() { + toast("AI 提示词回传尚未接入", "warning"); + }, + showMessage(payload) { + const message = typeof payload === "string" ? payload : payload?.message || payload?.content; + if (message) toast(message); + }, + hideMessage() {}, + onSave, + getImages, + setAnnotationMode() {}, + setAnnotationCurrentPage() {}, + upsertAnnotation() {}, + removeAnnotation() {}, + clearAnnotations() {}, + }, + }); + state.remote = await Promise.race([ + state.connection.promise, + new Promise((_, reject) => setTimeout(() => reject(new Error("RPC 握手超时")), 20_000)), + ]); + await state.remote.setSlideConfig({ editable: true, locale: "zh-CN", theme: "light" }); + setConnection("ready", "已连接"); + addActivity("编辑器 RPC 握手完成", "success"); + await openDemo(); + } catch (error) { + setConnection("error", "连接失败"); + setLoading(true, "无法连接编辑器", "请检查网络,或 Kimi 是否更新了公开前端资源。"); + handleError(error, "编辑器连接失败"); + } +} + +function setActivityPanel(open) { + elements.activityPanel.classList.toggle("is-open", open); + elements.activityPanel.setAttribute("aria-hidden", String(!open)); +} + +function setOpenDialog(open) { + if (open && !elements.openDialog.open) elements.openDialog.showModal(); + if (!open && elements.openDialog.open) elements.openDialog.close(); + elements.openDialog.dataset.dropState = "idle"; + elements.uploadDropzone.classList.remove("is-dragging"); +} + +elements.openFolder.addEventListener("click", () => setOpenDialog(true)); +elements.closeOpenDialog.addEventListener("click", () => setOpenDialog(false)); +elements.chooseWritableFolder.addEventListener("click", pickDirectory); +elements.uploadFolder.addEventListener("click", () => elements.folderFallback.click()); +elements.uploadDropzone.addEventListener("click", () => elements.folderFallback.click()); +elements.openDialog.addEventListener("click", (event) => { + if (event.target === elements.openDialog) setOpenDialog(false); +}); +elements.openDemo.addEventListener("click", () => openDemo().catch((error) => handleError(error, "示例载入失败"))); +elements.reload.addEventListener("click", () => { + if (!state.lastDeckPayload) return; + const reload = state.source === "directory" ? loadManifest(state.manifestPath, elements.documentPath.textContent) : setDeck(state.lastDeckPayload, elements.documentPath.textContent); + reload.catch((error) => handleError(error, "重新载入失败")); +}); +elements.toggleActivity.addEventListener("click", () => setActivityPanel(!elements.activityPanel.classList.contains("is-open"))); +elements.closeActivity.addEventListener("click", () => setActivityPanel(false)); +elements.folderFallback.addEventListener("change", () => { + if (elements.folderFallback.files?.length) { + setOpenDialog(false); + openFallbackFiles(elements.folderFallback.files).catch((error) => handleError(error, "上传文件夹失败")); + } + elements.folderFallback.value = ""; +}); + +window.addEventListener("dragenter", (event) => { + event.preventDefault(); + state.dragDepth += 1; + setOpenDialog(true); + elements.openDialog.dataset.dropState = "active"; + elements.uploadDropzone.classList.add("is-dragging"); +}); +window.addEventListener("dragover", (event) => event.preventDefault()); +window.addEventListener("dragleave", () => { + state.dragDepth = Math.max(0, state.dragDepth - 1); + if (state.dragDepth === 0) { + elements.openDialog.dataset.dropState = "idle"; + elements.uploadDropzone.classList.remove("is-dragging"); + } +}); +window.addEventListener("drop", async (event) => { + event.preventDefault(); + state.dragDepth = 0; + elements.openDialog.dataset.dropState = "loading"; + elements.uploadDropzone.classList.remove("is-dragging"); + try { + const handles = await Promise.all([...event.dataTransfer.items].map((item) => item.getAsFileSystemHandle?.())); + if (handles.some(Boolean)) await openDroppedFolder(handles); + else if (event.dataTransfer.files?.length) { + const files = [...event.dataTransfer.files]; + const roots = new Set(files.map((file) => file.webkitRelativePath?.split("/")[0]).filter(Boolean)); + const isSingleFolder = files.length > 0 && roots.size === 1 && files.every((file) => file.webkitRelativePath?.includes("/")); + if (!isSingleFolder) throw new Error("只能拖入一个完整的 PPTD 项目文件夹,不能单独拖入 .pptd 文件"); + setOpenDialog(false); + await openFallbackFiles(files); + } else throw new Error("请拖入包含 .pptd、pages 和 media 的完整项目文件夹"); + } catch (error) { + handleError(error, "拖放打开失败"); + } finally { + elements.openDialog.dataset.dropState = "idle"; + } +}); + +window.addEventListener("beforeunload", (event) => { + if (elements.saveState.dataset.kind === "saving") { + event.preventDefault(); + event.returnValue = ""; + } +}); + +window.neoDeck = { + openDemo, + getSlideStatus: () => state.remote?.getSlideStatus(), + get status() { + return { + connected: Boolean(state.remote), + source: state.source, + title: state.deckTitle, + manifestPath: state.manifestPath, + indexedFiles: state.fileIndex.size, + saveState: elements.saveState.dataset.kind, + }; + }, +}; + +connectEditor(); diff --git a/editor/index.html b/editor/index.html new file mode 100644 index 0000000..00bc6d1 --- /dev/null +++ b/editor/index.html @@ -0,0 +1,150 @@ + + + + + + + + NeoDeck Local + + + +
+
+
+ N + + NeoDeck + LOCAL + +
+ +
+ + + +
+ +
+
等待编辑器连接
+
当前使用演示文稿
+
+ +
+ + + 连接中 + + 未保存到本地 + +
+
+ +
+ + +
+ +

正在连接 neo-ppt

+

编辑器就绪后会自动载入示例文稿。

+
+ + +
+
+ + +
+
+
+ P +
+

打开 PPTD 项目文件夹

+

请选择或拖入包含完整项目结构的文件夹

+
+
+ +
+ + + +
+ + +
+ +
+ + PPTD 项目由 .pptd 清单、pages 页面目录和 media 素材目录组成,必须上传整个文件夹。若提示含系统文件,请移除 .DS_Store。 +
+
+
+ + +
+
+ P +
+

选择 PPTD 文稿

+

这个文件夹里发现了多个 .pptd 清单,请选择一个。

+
+
+
+
+ +
+
+
+ + +
+ + + + diff --git a/editor/lib.js b/editor/lib.js new file mode 100644 index 0000000..cc3cb74 --- /dev/null +++ b/editor/lib.js @@ -0,0 +1,117 @@ +export function normalizeRelativePath(value) { + if (typeof value !== "string") { + throw new TypeError("文件路径必须是字符串"); + } + + let path = value.trim().replaceAll("\\", "/"); + try { + path = decodeURIComponent(path); + } catch { + // Keep the original path when it contains a literal percent sign. + } + + path = path.replace(/^file:\/\/+/, "").replace(/^\.\//, ""); + if (!path || path.includes("\0") || path.startsWith("/") || /^[A-Za-z]:\//.test(path)) { + throw new Error(`不允许的绝对路径:${value}`); + } + + const parts = []; + for (const part of path.split("/")) { + if (!part || part === ".") continue; + if (part === "..") throw new Error(`不允许越过项目目录:${value}`); + parts.push(part); + } + + if (parts.length === 0) throw new Error(`无效文件路径:${value}`); + return parts.join("/"); +} + +export function dirname(path) { + const normalized = normalizeRelativePath(path); + const index = normalized.lastIndexOf("/"); + return index === -1 ? "" : normalized.slice(0, index); +} + +export function basename(path) { + const normalized = normalizeRelativePath(path); + return normalized.slice(normalized.lastIndexOf("/") + 1); +} + +export function joinDeckPath(base, path) { + const child = normalizeRelativePath(path); + if (!base) return child; + const normalizedBase = normalizeRelativePath(base); + if (child === normalizedBase || child.startsWith(`${normalizedBase}/`)) return child; + return normalizeRelativePath(`${normalizedBase}/${child}`); +} + +function unquoteYamlScalar(value) { + const text = value.trim(); + if (text.startsWith('"')) { + const match = text.match(/^"(?:\\.|[^"\\])*"/); + if (match) { + try { + return JSON.parse(match[0]); + } catch { + return match[0].slice(1, -1); + } + } + } + if (text.startsWith("'")) { + const end = text.indexOf("'", 1); + if (end !== -1) return text.slice(1, end).replaceAll("''", "'"); + } + return text.replace(/\s+#.*$/, "").trim(); +} + +export function extractPagePaths(manifestText) { + const trimmed = manifestText.trim(); + if (!trimmed) throw new Error("PPTD manifest 是空文件"); + + if (trimmed.startsWith("{")) { + const parsed = JSON.parse(trimmed); + if (!Array.isArray(parsed.pages)) throw new Error("PPTD manifest 缺少 pages 数组"); + return parsed.pages.map((path) => normalizeRelativePath(String(path))); + } + + const lines = manifestText.replaceAll("\r\n", "\n").split("\n"); + const pageHeader = lines.findIndex((line) => /^(\s*)pages\s*:\s*(?:#.*)?$/.test(line)); + if (pageHeader === -1) throw new Error("PPTD manifest 缺少 pages 列表"); + + const headerIndent = lines[pageHeader].match(/^\s*/)[0].length; + const pages = []; + for (let index = pageHeader + 1; index < lines.length; index += 1) { + const line = lines[index]; + if (!line.trim() || line.trimStart().startsWith("#")) continue; + const indent = line.match(/^\s*/)[0].length; + if (indent <= headerIndent && !/^\s*-/.test(line)) break; + const match = line.match(/^\s*-\s*(.+?)\s*$/); + if (!match) break; + const path = unquoteYamlScalar(match[1]); + if (path) pages.push(normalizeRelativePath(path)); + } + + if (pages.length === 0) throw new Error("PPTD manifest 的 pages 列表为空"); + return pages; +} + +export function titleFromManifest(manifestText, fallback) { + const trimmed = manifestText.trim(); + if (trimmed.startsWith("{")) { + try { + return JSON.parse(trimmed).title || fallback; + } catch { + return fallback; + } + } + const match = manifestText.match(/^title\s*:\s*(.+?)\s*$/m); + return match ? unquoteYamlScalar(match[1]) : fallback; +} + +export function assertWritableChangePath(base, requestedPath) { + const path = joinDeckPath(base, requestedPath); + if (!/\.(?:pptd|page)$/i.test(path)) { + throw new Error(`为安全起见,仅允许编辑 .pptd/.page:${requestedPath}`); + } + return path; +} diff --git a/editor/styles.css b/editor/styles.css new file mode 100644 index 0000000..6ed8e6e --- /dev/null +++ b/editor/styles.css @@ -0,0 +1,783 @@ +:root { + font-family: + Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", + "Microsoft YaHei", sans-serif; + color: #1d2433; + background: #eef1f6; + font-synthesis: none; + --purple: #6c5ce7; + --purple-dark: #5746d8; + --border: #e3e7ef; + --muted: #7b8495; + --shadow: 0 18px 60px rgba(34, 42, 61, 0.16); +} + +* { + box-sizing: border-box; +} + +html, +body { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; +} + +button, +input { + font: inherit; +} + +button { + -webkit-tap-highlight-color: transparent; +} + +.app-shell { + display: grid; + grid-template-rows: 56px minmax(0, 1fr); + width: 100%; + height: 100%; + background: #fff; +} + +.topbar { + z-index: 30; + display: grid; + grid-template-columns: auto auto minmax(180px, 1fr) auto; + align-items: center; + gap: 18px; + padding: 0 14px; + border-bottom: 1px solid var(--border); + background: rgba(255, 255, 255, 0.96); + box-shadow: 0 1px 0 rgba(25, 32, 48, 0.02); +} + +.brand { + display: flex; + align-items: center; + gap: 9px; + min-width: 126px; +} + +.brand-mark, +.dialog-mark { + display: grid; + place-items: center; + width: 31px; + height: 31px; + border-radius: 10px; + color: #fff; + font-size: 15px; + font-weight: 800; + background: linear-gradient(145deg, #8172f2, #5b49da 72%); + box-shadow: 0 7px 18px rgba(108, 92, 231, 0.27); +} + +.brand-copy { + display: flex; + align-items: baseline; + gap: 5px; + white-space: nowrap; +} + +.brand-copy strong { + font-size: 15px; + letter-spacing: -0.02em; +} + +.brand-copy small { + color: #9aa2b1; + font-size: 8px; + font-weight: 800; + letter-spacing: 0.12em; +} + +.toolbar, +.topbar-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.button, +.icon-button { + border: 1px solid var(--border); + color: #3a4251; + background: #fff; + cursor: pointer; + transition: 150ms ease; +} + +.button:hover, +.icon-button:hover { + border-color: #cfd5e1; + background: #f7f8fb; + transform: translateY(-1px); +} + +.button:focus-visible, +.icon-button:focus-visible, +.deck-option:focus-visible { + outline: 3px solid rgba(108, 92, 231, 0.24); + outline-offset: 2px; +} + +.button:disabled, +.icon-button:disabled { + opacity: 0.4; + cursor: default; + transform: none; +} + +.button { + height: 34px; + padding: 0 13px; + border-radius: 9px; + font-size: 13px; + font-weight: 600; +} + +.button-primary { + border-color: var(--purple); + color: #fff; + background: var(--purple); + box-shadow: 0 5px 13px rgba(108, 92, 231, 0.18); +} + +.button-primary:hover { + border-color: var(--purple-dark); + background: var(--purple-dark); +} + +.button-icon { + margin-right: 5px; + font-size: 16px; + line-height: 0; +} + +.icon-button { + display: grid; + place-items: center; + width: 34px; + height: 34px; + padding: 0; + border-radius: 9px; + font-size: 17px; +} + +.document-meta { + min-width: 0; + text-align: center; +} + +.document-title, +.document-path { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.document-title { + color: #2f3746; + font-size: 13px; + font-weight: 650; +} + +.document-path { + margin-top: 2px; + color: #9aa2b1; + font-size: 10px; +} + +.status-pill { + display: inline-flex; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 10px; + border: 1px solid #e7eaf0; + border-radius: 999px; + color: #697386; + background: #f8f9fb; + font-size: 11px; + font-weight: 650; + white-space: nowrap; +} + +.status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #f5a623; + box-shadow: 0 0 0 3px rgba(245, 166, 35, 0.12); +} + +.status-ready .status-dot { + background: #20b486; + box-shadow: 0 0 0 3px rgba(32, 180, 134, 0.12); +} + +.status-error .status-dot { + background: #ef5b5b; + box-shadow: 0 0 0 3px rgba(239, 91, 91, 0.12); +} + +.save-state { + min-width: 76px; + color: #8992a2; + font-size: 11px; + text-align: right; + white-space: nowrap; +} + +.save-state[data-kind="saving"] { + color: #8b72e8; +} + +.save-state[data-kind="saved"] { + color: #15956f; +} + +.save-state[data-kind="error"] { + color: #d94848; +} + +.editor-stage { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #f0f2f6; +} + +#editor { + display: block; + width: 100%; + height: 100%; + border: 0; + background: #fff; +} + +.loading-card { + position: absolute; + z-index: 18; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: + radial-gradient(circle at 50% 42%, rgba(108, 92, 231, 0.08), transparent 34%), + #f7f8fb; + transition: opacity 200ms ease; +} + +.loading-card.is-hidden { + visibility: hidden; + pointer-events: none; + opacity: 0; +} + +.loading-card h1 { + margin: 19px 0 7px; + color: #252c3a; + font-size: 19px; + letter-spacing: -0.02em; +} + +.loading-card p { + margin: 0; + color: #8a93a3; + font-size: 13px; +} + +.loading-orbit { + width: 34px; + height: 34px; + border: 3px solid rgba(108, 92, 231, 0.14); + border-top-color: var(--purple); + border-radius: 50%; + animation: spin 800ms linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.activity-panel { + position: absolute; + z-index: 35; + top: 12px; + right: 12px; + bottom: 12px; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + width: min(350px, calc(100% - 24px)); + overflow: hidden; + border: 1px solid rgba(220, 224, 232, 0.9); + border-radius: 16px; + background: rgba(255, 255, 255, 0.98); + box-shadow: var(--shadow); + opacity: 0; + pointer-events: none; + transform: translateX(calc(100% + 24px)); + transition: 200ms ease; +} + +.activity-panel.is-open { + opacity: 1; + pointer-events: auto; + transform: translateX(0); +} + +.activity-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + border-bottom: 1px solid var(--border); +} + +.activity-header div { + display: flex; + flex-direction: column; + gap: 3px; +} + +.activity-header strong { + font-size: 15px; +} + +.activity-header small { + color: var(--muted); + font-size: 11px; +} + +.activity-list { + margin: 0; + padding: 9px 16px; + overflow: auto; + list-style: none; +} + +.activity-list li { + display: grid; + grid-template-columns: 62px minmax(0, 1fr); + gap: 8px; + padding: 10px 0; + border-bottom: 1px solid #f0f2f5; + color: #535d6d; + font-size: 12px; + line-height: 1.5; +} + +.activity-list time { + color: #a0a8b5; + font-variant-numeric: tabular-nums; +} + +.activity-list li[data-tone="success"] span { + color: #148264; +} + +.activity-list li[data-tone="warning"] span { + color: #a16a0b; +} + +.activity-list li[data-tone="error"] span { + color: #cc4545; +} + +.activity-footer { + padding: 13px 16px; + border-top: 1px solid var(--border); + color: #8b94a3; + background: #fafbfc; + font-size: 10px; + line-height: 1.6; +} + +.open-dialog, +.deck-dialog { + width: min(520px, calc(100vw - 32px)); + padding: 0; + border: 0; + border-radius: 18px; + color: #273041; + background: #fff; + box-shadow: var(--shadow); +} + +.open-dialog { + width: min(620px, calc(100vw - 32px)); +} + +.open-dialog::backdrop, +.deck-dialog::backdrop { + background: rgba(24, 29, 42, 0.38); + backdrop-filter: blur(4px); +} + +.deck-dialog form { + padding: 22px; +} + +.open-dialog-shell { + padding: 22px; +} + +.open-dialog-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.upload-dropzone { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + margin: 22px 0 14px; + padding: 28px 22px 24px; + border: 1.5px dashed #c9c2f3; + border-radius: 16px; + color: #353d4c; + background: linear-gradient(180deg, #faf9ff, #f7f7fb); + cursor: pointer; + transition: 160ms ease; +} + +.upload-dropzone:hover, +.upload-dropzone.is-dragging { + border-color: var(--purple); + background: #f3f0ff; + box-shadow: inset 0 0 0 3px rgba(108, 92, 231, 0.08); + transform: translateY(-1px); +} + +.upload-dropzone strong { + margin-top: 15px; + font-size: 16px; +} + +.upload-dropzone > span:not(.upload-illustration) { + margin-top: 6px; + color: #81899a; + font-size: 12px; +} + +.upload-dropzone em { + margin-top: 12px; + color: var(--purple); + font-size: 11px; + font-style: normal; + font-weight: 650; +} + +.upload-illustration { + position: relative; + display: block; + width: 68px; + height: 55px; +} + +.upload-file { + position: absolute; + inset: 0 auto auto 5px; + display: grid; + place-items: center; + width: 42px; + height: 52px; + border-radius: 10px; + color: #fff; + background: linear-gradient(145deg, #8172f2, #5b49da); + box-shadow: 0 10px 25px rgba(108, 92, 231, 0.26); + font-size: 14px; + font-weight: 800; +} + +.upload-arrow { + position: absolute; + right: 1px; + bottom: -2px; + display: grid; + place-items: center; + width: 30px; + height: 30px; + border: 4px solid #faf9ff; + border-radius: 50%; + color: var(--purple); + background: #fff; + box-shadow: 0 5px 16px rgba(54, 62, 82, 0.14); + font-size: 16px; + font-weight: 800; +} + +.open-choice-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.open-choice { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 11px; + min-width: 0; + padding: 12px; + border: 1px solid var(--border); + border-radius: 13px; + color: #303849; + text-align: left; + background: #fff; + cursor: pointer; + transition: 150ms ease; +} + +.open-choice:hover { + border-color: #cfc8f7; + background: #faf9ff; + transform: translateY(-1px); +} + +.open-choice > span:nth-child(2) { + display: flex; + flex-direction: column; + min-width: 0; +} + +.open-choice strong { + font-size: 13px; +} + +.open-choice small { + margin-top: 3px; + overflow: hidden; + color: #8b94a3; + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.choice-icon { + display: grid; + place-items: center; + width: 34px; + height: 34px; + border-radius: 10px; + color: #fff; + background: var(--purple); + font-size: 14px; +} + +.choice-icon-muted { + color: #6659c8; + background: #eeeafd; +} + +.open-dialog-note { + display: grid; + grid-template-columns: auto 1fr; + gap: 8px; + margin-top: 16px; + padding: 11px 12px; + border-radius: 10px; + color: #7b8495; + background: #f6f7f9; + font-size: 10px; + line-height: 1.55; +} + +.dialog-heading { + display: flex; + align-items: center; + gap: 13px; +} + +.dialog-heading h2, +.dialog-heading p { + margin: 0; +} + +.dialog-heading h2 { + font-size: 18px; +} + +.dialog-heading p { + margin-top: 4px; + color: var(--muted); + font-size: 12px; +} + +.deck-options { + display: grid; + gap: 8px; + max-height: 340px; + margin: 20px 0; + overflow: auto; +} + +.deck-option { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 11px; + width: 100%; + padding: 11px; + border: 1px solid var(--border); + border-radius: 12px; + color: inherit; + text-align: left; + background: #fff; + cursor: pointer; +} + +.deck-option:hover { + border-color: #cfc8f7; + background: #faf9ff; +} + +.deck-option > span:nth-child(2) { + display: flex; + flex-direction: column; + min-width: 0; +} + +.deck-option strong, +.deck-option small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.deck-option strong { + font-size: 13px; +} + +.deck-option small { + margin-top: 3px; + color: var(--muted); + font-size: 10px; +} + +.deck-file-icon { + display: grid; + place-items: center; + width: 32px; + height: 38px; + border-radius: 8px; + color: #fff; + background: #f06a45; + font-size: 12px; + font-weight: 800; +} + +.dialog-actions { + display: flex; + justify-content: flex-end; +} + +.toast-region { + position: fixed; + z-index: 100; + right: 18px; + bottom: 18px; + display: grid; + gap: 8px; + pointer-events: none; +} + +.toast { + max-width: min(420px, calc(100vw - 36px)); + padding: 11px 14px; + border: 1px solid #e3e7ef; + border-radius: 11px; + color: #394253; + background: rgba(255, 255, 255, 0.98); + box-shadow: 0 12px 34px rgba(35, 43, 60, 0.18); + opacity: 0; + transform: translateY(8px); + transition: 180ms ease; + font-size: 12px; +} + +.toast.is-visible { + opacity: 1; + transform: translateY(0); +} + +.toast-error { + border-color: #f1c7c7; + color: #b43e3e; +} + +.toast-warning { + border-color: #efd8ad; + color: #966512; +} + +@media (max-width: 920px) { + .topbar { + grid-template-columns: auto auto 1fr; + gap: 10px; + } + + .document-meta { + display: none; + } + + .topbar-actions { + justify-self: end; + } + + .save-state { + display: none; + } +} + +@media (max-width: 650px) { + .app-shell { + grid-template-rows: 52px minmax(0, 1fr); + } + + .topbar { + padding: 0 8px; + } + + .brand-copy, + .toolbar .button:not(.button-primary), + .toolbar .icon-button, + .status-pill { + display: none; + } + + .brand { + min-width: auto; + } + + .button-primary { + max-width: 185px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .open-choice-row { + grid-template-columns: 1fr; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/example/dji-pocket4/DJI Osmo Pocket 4 产品深度解读.pptx b/example/dji-pocket4/DJI Osmo Pocket 4 产品深度解读.pptx new file mode 100644 index 0000000..9d715ff Binary files /dev/null and b/example/dji-pocket4/DJI Osmo Pocket 4 产品深度解读.pptx differ diff --git a/example/dji-pocket4/dji-pocket4.pptd b/example/dji-pocket4/dji-pocket4.pptd new file mode 100644 index 0000000..044dbd4 --- /dev/null +++ b/example/dji-pocket4/dji-pocket4.pptd @@ -0,0 +1,53 @@ +version: v2 +title: DJI Osmo Pocket 4 产品深度解读 +size: [960, 540] +theme: + colors: + bg: "#0C0C0E" + surface: "#17171C" + line: "#2A2A31" + text: "#F2F0EA" + muted: "#8E8E96" + accent: "#D9A441" + textStyles: + kicker: + fontSize: 10 + color: "$accent" + letterSpacing: 3 + pageTitle: + fontSize: 36 + color: "$text" + lineHeight: 1.35 + body: + fontSize: 13 + color: "$muted" + lineHeight: 1.75 + statNum: + fontSize: 20 + color: "$text" + statLabel: + fontSize: 10 + color: "$muted" + headerLabel: + fontSize: 9 + color: "$muted" + letterSpacing: 3 +pages: + - pages/01_cover.page + - pages/02_highlights.page + - pages/03_toc.page + - pages/04_chapter1.page + - pages/05_cmos.page + - pages/06_dlog.page + - pages/07_slowmo.page + - pages/08_zoom.page + - pages/09_chapter2.page + - pages/10_tracking.page + - pages/11_control.page + - pages/12_gimbal.page + - pages/13_chapter3.page + - pages/14_audio.page + - pages/15_battery.page + - pages/16_accessories.page + - pages/17_specs.page + - pages/18_price.page diff --git a/example/dji-pocket4/media/accessories.jpg b/example/dji-pocket4/media/accessories.jpg new file mode 100644 index 0000000..c45ea1e Binary files /dev/null and b/example/dji-pocket4/media/accessories.jpg differ diff --git a/example/dji-pocket4/media/activetrack.jpg b/example/dji-pocket4/media/activetrack.jpg new file mode 100644 index 0000000..e01f053 Binary files /dev/null and b/example/dji-pocket4/media/activetrack.jpg differ diff --git a/example/dji-pocket4/media/day_street.jpg b/example/dji-pocket4/media/day_street.jpg new file mode 100644 index 0000000..b0be3b9 Binary files /dev/null and b/example/dji-pocket4/media/day_street.jpg differ diff --git a/example/dji-pocket4/media/film_dancer.jpg b/example/dji-pocket4/media/film_dancer.jpg new file mode 100644 index 0000000..01b2d19 Binary files /dev/null and b/example/dji-pocket4/media/film_dancer.jpg differ diff --git a/example/dji-pocket4/media/hero_sunset.jpg b/example/dji-pocket4/media/hero_sunset.jpg new file mode 100644 index 0000000..6773967 Binary files /dev/null and b/example/dji-pocket4/media/hero_sunset.jpg differ diff --git a/example/dji-pocket4/media/horse_dusk.jpg b/example/dji-pocket4/media/horse_dusk.jpg new file mode 100644 index 0000000..aeb954b Binary files /dev/null and b/example/dji-pocket4/media/horse_dusk.jpg differ diff --git a/example/dji-pocket4/media/horse_dusk_bl.jpg b/example/dji-pocket4/media/horse_dusk_bl.jpg new file mode 100644 index 0000000..080cb2f Binary files /dev/null and b/example/dji-pocket4/media/horse_dusk_bl.jpg differ diff --git a/example/dji-pocket4/media/inhand.jpg b/example/dji-pocket4/media/inhand.jpg new file mode 100644 index 0000000..7a8a505 Binary files /dev/null and b/example/dji-pocket4/media/inhand.jpg differ diff --git a/example/dji-pocket4/media/mic3.jpg b/example/dji-pocket4/media/mic3.jpg new file mode 100644 index 0000000..09217e1 Binary files /dev/null and b/example/dji-pocket4/media/mic3.jpg differ diff --git a/example/dji-pocket4/media/night_vlog.jpg b/example/dji-pocket4/media/night_vlog.jpg new file mode 100644 index 0000000..88872e1 Binary files /dev/null and b/example/dji-pocket4/media/night_vlog.jpg differ diff --git a/example/dji-pocket4/media/night_vlog_bl.jpg b/example/dji-pocket4/media/night_vlog_bl.jpg new file mode 100644 index 0000000..5121667 Binary files /dev/null and b/example/dji-pocket4/media/night_vlog_bl.jpg differ diff --git a/example/dji-pocket4/media/osmoaudio.jpg b/example/dji-pocket4/media/osmoaudio.jpg new file mode 100644 index 0000000..9fd0c24 Binary files /dev/null and b/example/dji-pocket4/media/osmoaudio.jpg differ diff --git a/example/dji-pocket4/media/picnic.jpg b/example/dji-pocket4/media/picnic.jpg new file mode 100644 index 0000000..192cdbb Binary files /dev/null and b/example/dji-pocket4/media/picnic.jpg differ diff --git a/example/dji-pocket4/media/portrait.jpg b/example/dji-pocket4/media/portrait.jpg new file mode 100644 index 0000000..1592c13 Binary files /dev/null and b/example/dji-pocket4/media/portrait.jpg differ diff --git a/example/dji-pocket4/media/product_black.jpg b/example/dji-pocket4/media/product_black.jpg new file mode 100644 index 0000000..ff66580 Binary files /dev/null and b/example/dji-pocket4/media/product_black.jpg differ diff --git a/example/dji-pocket4/media/product_sq.jpg b/example/dji-pocket4/media/product_sq.jpg new file mode 100644 index 0000000..d78953c Binary files /dev/null and b/example/dji-pocket4/media/product_sq.jpg differ diff --git a/example/dji-pocket4/media/slowmo_rollers.jpg b/example/dji-pocket4/media/slowmo_rollers.jpg new file mode 100644 index 0000000..0218867 Binary files /dev/null and b/example/dji-pocket4/media/slowmo_rollers.jpg differ diff --git a/example/dji-pocket4/media/slowmo_skate.jpg b/example/dji-pocket4/media/slowmo_skate.jpg new file mode 100644 index 0000000..eb007fe Binary files /dev/null and b/example/dji-pocket4/media/slowmo_skate.jpg differ diff --git a/example/dji-pocket4/media/tripod_street.jpg b/example/dji-pocket4/media/tripod_street.jpg new file mode 100644 index 0000000..e543694 Binary files /dev/null and b/example/dji-pocket4/media/tripod_street.jpg differ diff --git a/example/dji-pocket4/media/tripod_street_bl.jpg b/example/dji-pocket4/media/tripod_street_bl.jpg new file mode 100644 index 0000000..c01f1a6 Binary files /dev/null and b/example/dji-pocket4/media/tripod_street_bl.jpg differ diff --git a/example/dji-pocket4/media/vlog_dusk.jpg b/example/dji-pocket4/media/vlog_dusk.jpg new file mode 100644 index 0000000..7d1baa0 Binary files /dev/null and b/example/dji-pocket4/media/vlog_dusk.jpg differ diff --git a/example/dji-pocket4/pages/01_cover.page b/example/dji-pocket4/pages/01_cover.page new file mode 100644 index 0000000..29301ca --- /dev/null +++ b/example/dji-pocket4/pages/01_cover.page @@ -0,0 +1,66 @@ +pageType: cover +background: + type: solid + color: "$bg" +elements: + - elementId: hero + elementType: image + bounds: [0, 0, 960, 540] + src: "media/hero_sunset.jpg" + fit: {mode: cover} + - elementId: overlay + elementType: shape + bounds: [0, 0, 960, 540] + shapeName: rect + fill: + type: gradient + gradientType: linear + angle: 90 + stops: + - {position: 0, color: "#0C0C0E59"} + - {position: 0.55, color: "#0C0C0E40"} + - {position: 1, color: "#0C0C0EF2"} + - elementId: meta + elementType: text + bounds: [48, 40, 620, 16] + content: + fontSize: 10.5 + color: "#F2F0EACC" + letterSpacing: 2 + wrap: false + align: [left, middle] + text: DJI 大疆创新 · 2026.04.16 深圳发布 + - elementId: title-bar + elementType: shape + bounds: [48, 262, 56, 4] + shapeName: rect + fill: {type: solid, color: "$accent"} + - elementId: title + elementType: text + bounds: [48, 282, 780, 200] + content: + fontSize: 76 + color: "$text" + lineHeight: 1.24 + align: [left, top] + text: | +

一寸万象

+

光影随行

+ - elementId: subtitle + elementType: text + bounds: [48, 490, 700, 18] + content: + fontSize: 13 + color: "#F2F0EACC" + wrap: false + align: [left, middle] + text: Osmo Pocket 4 · 一英寸口袋云台相机 产品深度解读 + - elementId: price-note + elementType: text + bounds: [580, 510, 348, 14] + content: + fontSize: 10 + color: "#8E8E96CC" + wrap: false + align: [right, middle] + text: 标准套装 ¥2999 起 · 全能套装 ¥3799 diff --git a/example/dji-pocket4/pages/02_highlights.page b/example/dji-pocket4/pages/02_highlights.page new file mode 100644 index 0000000..ef59a53 --- /dev/null +++ b/example/dji-pocket4/pages/02_highlights.page @@ -0,0 +1,151 @@ +pageType: content +background: + type: solid + color: "$bg" +elements: + - elementId: hd-bar + elementType: shape + bounds: [48, 34, 4, 12] + shapeName: rect + fill: {type: solid, color: "$accent"} + - elementId: hd-label + elementType: text + bounds: [60, 32, 260, 15] + content: + style: "$headerLabel" + wrap: false + align: [left, middle] + text: DJI OSMO POCKET 4 + - elementId: page-num + elementType: text + bounds: [856, 510, 72, 12] + content: + fontSize: 9 + color: "$muted" + wrap: false + align: [right, middle] + text: 02 / 18 + - elementId: kicker + elementType: text + bounds: [48, 92, 400, 14] + content: + style: "$kicker" + wrap: false + align: [left, middle] + text: 核心亮点 KEY HIGHLIGHTS + - elementId: statement + elementType: text + bounds: [48, 122, 430, 130] + content: + fontSize: 42 + color: "$text" + lineHeight: 1.4 + align: [left, top] + text: | +

把电影机,

+

装进口袋。

+ - elementId: desc + elementType: text + bounds: [48, 272, 400, 52] + content: + fontSize: 12.5 + color: "$muted" + lineHeight: 1.8 + align: [left, top] + text: 延续 Pocket 系列便携形态,一英寸大底与全面进化的影像规格,让专业级创作随手可得。 + - elementId: divider + elementType: shape + bounds: [486, 96, 1, 380] + shapeName: rect + fill: {type: solid, color: "$line"} + - elementId: n1 + elementType: text + bounds: [520, 100, 392, 48] + content: + fontSize: 40 + color: "$text" + wrap: false + align: [left, middle] + text: | +

1 英寸 CMOS

+ - elementId: n1-label + elementType: text + bounds: [520, 152, 392, 16] + content: + fontSize: 11 + color: "$muted" + wrap: false + align: [left, middle] + text: 堆栈式传感器 · f/2.0 大光圈 + - elementId: row-line1 + elementType: shape + bounds: [520, 190, 392, 1] + shapeName: rect + fill: {type: solid, color: "$line"} + - elementId: n2 + elementType: text + bounds: [520, 196, 392, 48] + content: + fontSize: 40 + color: "$text" + wrap: false + align: [left, middle] + text: | +

4K/240 fps

+ - elementId: n2-label + elementType: text + bounds: [520, 248, 392, 16] + content: + fontSize: 11 + color: "$muted" + wrap: false + align: [left, middle] + text: 慢动作录制 · 上代的 2 倍 + - elementId: row-line2 + elementType: shape + bounds: [520, 286, 392, 1] + shapeName: rect + fill: {type: solid, color: "$line"} + - elementId: n3 + elementType: text + bounds: [520, 292, 392, 48] + content: + fontSize: 40 + color: "$text" + wrap: false + align: [left, middle] + text: | +

14

+ - elementId: n3-label + elementType: text + bounds: [520, 344, 392, 16] + content: + fontSize: 11 + color: "$muted" + wrap: false + align: [left, middle] + text: 动态范围 · 10-bit D-Log 色彩 + - elementId: row-line3 + elementType: shape + bounds: [520, 382, 392, 1] + shapeName: rect + fill: {type: solid, color: "$line"} + - elementId: n4 + elementType: text + bounds: [520, 388, 392, 48] + content: + fontSize: 40 + color: "$text" + wrap: false + align: [left, middle] + text: | +

107 GB

+ - elementId: n4-label + elementType: text + bounds: [520, 440, 392, 16] + content: + fontSize: 11 + color: "$muted" + wrap: false + align: [left, middle] + text: 内置存储 · 800MB/s 极速导出 diff --git a/example/dji-pocket4/pages/03_toc.page b/example/dji-pocket4/pages/03_toc.page new file mode 100644 index 0000000..6beb9ff --- /dev/null +++ b/example/dji-pocket4/pages/03_toc.page @@ -0,0 +1,172 @@ +pageType: table_of_contents +background: + type: solid + color: "$bg" +elements: + - elementId: hd-bar + elementType: shape + bounds: [48, 34, 4, 12] + shapeName: rect + fill: {type: solid, color: "$accent"} + - elementId: hd-label + elementType: text + bounds: [60, 32, 260, 15] + content: + style: "$headerLabel" + wrap: false + align: [left, middle] + text: DJI OSMO POCKET 4 + - elementId: page-num + elementType: text + bounds: [856, 510, 72, 12] + content: + fontSize: 9 + color: "$muted" + wrap: false + align: [right, middle] + text: 03 / 18 + - elementId: kicker + elementType: text + bounds: [48, 88, 400, 14] + content: + style: "$kicker" + wrap: false + align: [left, middle] + text: 目录 CONTENTS + - elementId: toc1-num + elementType: text + bounds: [48, 132, 72, 34] + content: + fontSize: 26 + color: "$accent" + wrap: false + align: [left, middle] + text: "01" + - elementId: toc1-title + elementType: text + bounds: [136, 132, 420, 30] + content: + fontSize: 20 + color: "$text" + wrap: false + align: [left, middle] + text: "影像画质" + - elementId: toc1-sub + elementType: text + bounds: [136, 166, 430, 16] + content: + fontSize: 11 + color: "$muted" + wrap: false + align: [left, middle] + text: 一英寸 CMOS · 14 档动态范围 · 4K/240fps · 2 倍无损变焦 + - elementId: toc1-line + elementType: shape + bounds: [48, 200, 480, 1] + shapeName: rect + fill: {type: solid, color: "$line"} + - elementId: toc2-num + elementType: text + bounds: [48, 222, 72, 34] + content: + fontSize: 26 + color: "$accent" + wrap: false + align: [left, middle] + text: "02" + - elementId: toc2-title + elementType: text + bounds: [136, 222, 420, 30] + content: + fontSize: 20 + color: "$text" + wrap: false + align: [left, middle] + text: "智能与操控" + - elementId: toc2-sub + elementType: text + bounds: [136, 256, 430, 16] + content: + fontSize: 11 + color: "$muted" + wrap: false + align: [left, middle] + text: 智能跟随 7.0 · 旋屏开拍 · 屏下双按键 · 三轴增稳 + - elementId: toc2-line + elementType: shape + bounds: [48, 290, 480, 1] + shapeName: rect + fill: {type: solid, color: "$line"} + - elementId: toc3-num + elementType: text + bounds: [48, 312, 72, 34] + content: + fontSize: 26 + color: "$accent" + wrap: false + align: [left, middle] + text: "03" + - elementId: toc3-title + elementType: text + bounds: [136, 312, 420, 30] + content: + fontSize: 20 + color: "$text" + wrap: false + align: [left, middle] + text: "音频与生态" + - elementId: toc3-sub + elementType: text + bounds: [136, 346, 430, 16] + content: + fontSize: 11 + color: "$muted" + wrap: false + align: [left, middle] + text: OsmoAudio 四声道 · 107GB 存储 · 240 分钟续航 · 配件 + - elementId: toc3-line + elementType: shape + bounds: [48, 380, 480, 1] + shapeName: rect + fill: {type: solid, color: "$line"} + - elementId: toc4-num + elementType: text + bounds: [48, 402, 72, 34] + content: + fontSize: 26 + color: "$accent" + wrap: false + align: [left, middle] + text: "04" + - elementId: toc4-title + elementType: text + bounds: [136, 402, 420, 30] + content: + fontSize: 20 + color: "$text" + wrap: false + align: [left, middle] + text: "规格与售价" + - elementId: toc4-sub + elementType: text + bounds: [136, 436, 430, 16] + content: + fontSize: 11 + color: "$muted" + wrap: false + align: [left, middle] + text: 全参数一览 · 标准套装 ¥2999 · 全能套装 ¥3799 + - elementId: toc-img + elementType: image + bounds: [600, 96, 312, 312] + src: "media/product_sq.jpg" + fit: {mode: cover} + - elementId: toc-img-caption + elementType: text + bounds: [600, 416, 312, 14] + content: + fontSize: 9 + color: "$muted" + wrap: false + align: [center, middle] + text: Osmo Pocket 4 · 一英寸口袋云台相机 diff --git a/example/dji-pocket4/pages/04_chapter1.page b/example/dji-pocket4/pages/04_chapter1.page new file mode 100644 index 0000000..15654bc --- /dev/null +++ b/example/dji-pocket4/pages/04_chapter1.page @@ -0,0 +1,77 @@ +pageType: chapter +background: + type: solid + color: "$bg" +elements: + - elementId: bg-img + elementType: image + bounds: [0, 0, 960, 540] + src: "media/vlog_dusk.jpg" + fit: {mode: cover} + - elementId: dim + elementType: shape + bounds: [0, 0, 960, 540] + shapeName: rect + fill: {type: solid, color: "#0C0C0E80"} + - elementId: bottom-grad + elementType: shape + bounds: [0, 0, 960, 540] + shapeName: rect + fill: + type: gradient + gradientType: linear + angle: 90 + stops: + - {position: 0, color: "#0C0C0E00"} + - {position: 1, color: "#0C0C0ED9"} + - elementId: ch-num + elementType: text + bounds: [48, 118, 400, 190] + content: + fontSize: 150 + color: "$accent" + wrap: false + align: [left, middle] + text: "01" + - elementId: ch-bar + elementType: shape + bounds: [48, 316, 56, 4] + shapeName: rect + fill: {type: solid, color: "$accent"} + - elementId: ch-eng + elementType: text + bounds: [48, 334, 400, 16] + content: + fontSize: 12 + color: "$accent" + letterSpacing: 4 + wrap: false + align: [left, middle] + text: IMAGE QUALITY + - elementId: ch-title + elementType: text + bounds: [48, 356, 500, 64] + content: + fontSize: 50 + color: "$text" + wrap: false + align: [left, middle] + text: "影像画质" + - elementId: ch-sub + elementType: text + bounds: [48, 434, 500, 20] + content: + fontSize: 13 + color: "#F2F0EACC" + wrap: false + align: [left, middle] + text: 一英寸大底的全面进化 + - elementId: page-num + elementType: text + bounds: [856, 510, 72, 12] + content: + fontSize: 9 + color: "#F2F0EA99" + wrap: false + align: [right, middle] + text: 04 / 18 diff --git a/example/dji-pocket4/pages/05_cmos.page b/example/dji-pocket4/pages/05_cmos.page new file mode 100644 index 0000000..e00e8f7 --- /dev/null +++ b/example/dji-pocket4/pages/05_cmos.page @@ -0,0 +1,109 @@ +pageType: content +background: + type: solid + color: "$bg" +elements: + - elementId: side-img + elementType: image + bounds: [492, 0, 468, 540] + src: "media/night_vlog_bl.jpg" + fit: {mode: cover} + - elementId: hd-bar + elementType: shape + bounds: [48, 34, 4, 12] + shapeName: rect + fill: {type: solid, color: "$accent"} + - elementId: hd-label + elementType: text + bounds: [60, 32, 260, 15] + content: + style: "$headerLabel" + wrap: false + align: [left, middle] + text: DJI OSMO POCKET 4 + - elementId: page-num + elementType: text + bounds: [856, 510, 72, 12] + content: + fontSize: 9 + color: "#F2F0EA99" + wrap: false + align: [right, middle] + text: 05 / 18 + - elementId: kicker + elementType: text + bounds: [48, 88, 400, 14] + content: + style: "$kicker" + wrap: false + align: [left, middle] + text: 影像画质 IMAGE QUALITY + - elementId: title + elementType: text + bounds: [48, 116, 430, 100] + content: + style: "$pageTitle" + align: [left, top] + text: | +

一英寸 CMOS,

+

夜景更纯净

+ - elementId: body + elementType: text + bounds: [48, 236, 396, 96] + content: + style: "$body" + align: [left, top] + text: 全新升级的一英寸堆栈式 CMOS,配合 f/2.0 大光圈,感光性能与浅景深效果显著提升——暗光夜景纯净通透,人像虚化柔和立体。 + - elementId: stats-line + elementType: shape + bounds: [48, 352, 396, 1] + shapeName: rect + fill: {type: solid, color: "$line"} + - elementId: stat1 + elementType: text + bounds: [48, 368, 124, 26] + content: + style: "$statNum" + wrap: false + align: [left, middle] + text: "f/2.0" + - elementId: stat1-label + elementType: text + bounds: [48, 398, 124, 14] + content: + style: "$statLabel" + wrap: false + align: [left, middle] + text: 最大光圈 + - elementId: stat2 + elementType: text + bounds: [184, 368, 124, 26] + content: + style: "$statNum" + wrap: false + align: [left, middle] + text: "3700 万" + - elementId: stat2-label + elementType: text + bounds: [184, 398, 124, 14] + content: + style: "$statLabel" + wrap: false + align: [left, middle] + text: 照片像素 + - elementId: stat3 + elementType: text + bounds: [320, 368, 124, 26] + content: + style: "$statNum" + wrap: false + align: [left, middle] + text: "7680×4320" + - elementId: stat3-label + elementType: text + bounds: [320, 398, 124, 14] + content: + style: "$statLabel" + wrap: false + align: [left, middle] + text: 照片最高分辨率 diff --git a/example/dji-pocket4/pages/06_dlog.page b/example/dji-pocket4/pages/06_dlog.page new file mode 100644 index 0000000..83ef940 --- /dev/null +++ b/example/dji-pocket4/pages/06_dlog.page @@ -0,0 +1,118 @@ +pageType: content +background: + type: solid + color: "$bg" +elements: + - elementId: side-img + elementType: image + bounds: [0, 0, 468, 540] + src: "media/horse_dusk_bl.jpg" + fit: {mode: cover} + - elementId: hd-bar + elementType: shape + bounds: [48, 34, 4, 12] + shapeName: rect + fill: {type: solid, color: "$accent"} + - elementId: hd-label + elementType: text + bounds: [60, 32, 260, 15] + content: + style: "$headerLabel" + wrap: false + align: [left, middle] + text: DJI OSMO POCKET 4 + - elementId: page-num + elementType: text + bounds: [856, 510, 72, 12] + content: + fontSize: 9 + color: "$muted" + wrap: false + align: [right, middle] + text: 06 / 18 + - elementId: kicker + elementType: text + bounds: [536, 88, 376, 14] + content: + style: "$kicker" + wrap: false + align: [left, middle] + text: 影像画质 IMAGE QUALITY + - elementId: title + elementType: text + bounds: [536, 116, 376, 100] + content: + style: "$pageTitle" + align: [left, top] + text: | +

14 档动态范围,

+

光影层次分明

+ - elementId: body + elementType: text + bounds: [536, 236, 376, 110] + content: + style: "$body" + align: [left, top] + text: 大光比场景下,高光与阴影细节兼得。支持 10-bit D-Log 专业色彩模式(上代为 D-Log M),色彩过渡平滑自然,为后期调色留足电影级空间。 + - elementId: stats-line + elementType: shape + bounds: [536, 360, 376, 1] + shapeName: rect + fill: {type: solid, color: "$line"} + - elementId: stat1 + elementType: text + bounds: [536, 376, 120, 26] + content: + style: "$statNum" + wrap: false + align: [left, middle] + text: "14 档" + - elementId: stat1-label + elementType: text + bounds: [536, 406, 120, 14] + content: + style: "$statLabel" + wrap: false + align: [left, middle] + text: 动态范围 + - elementId: stat2 + elementType: text + bounds: [664, 376, 120, 26] + content: + style: "$statNum" + wrap: false + align: [left, middle] + text: "10-bit" + - elementId: stat2-label + elementType: text + bounds: [664, 406, 120, 14] + content: + style: "$statLabel" + wrap: false + align: [left, middle] + text: D-Log 色彩 + - elementId: stat3 + elementType: text + bounds: [792, 376, 120, 26] + content: + style: "$statNum" + wrap: false + align: [left, middle] + text: "+2 档" + - elementId: stat3-label + elementType: text + bounds: [792, 406, 120, 14] + content: + style: "$statLabel" + wrap: false + align: [left, middle] + text: 低光能力提升 + - elementId: footnote + elementType: text + bounds: [536, 498, 376, 12] + content: + fontSize: 9 + color: "$muted" + wrap: false + align: [left, middle] + text: 数据来源:DJI 官方新闻稿,2026-04 diff --git a/example/dji-pocket4/pages/07_slowmo.page b/example/dji-pocket4/pages/07_slowmo.page new file mode 100644 index 0000000..7253066 --- /dev/null +++ b/example/dji-pocket4/pages/07_slowmo.page @@ -0,0 +1,123 @@ +pageType: content +background: + type: solid + color: "$bg" +elements: + - elementId: img1 + elementType: image + bounds: [512, 48, 400, 232] + src: "media/slowmo_skate.jpg" + fit: {mode: cover} + - elementId: img2 + elementType: image + bounds: [512, 296, 400, 196] + src: "media/slowmo_rollers.jpg" + fit: {mode: cover} + - elementId: hd-bar + elementType: shape + bounds: [48, 34, 4, 12] + shapeName: rect + fill: {type: solid, color: "$accent"} + - elementId: hd-label + elementType: text + bounds: [60, 32, 260, 15] + content: + style: "$headerLabel" + wrap: false + align: [left, middle] + text: DJI OSMO POCKET 4 + - elementId: page-num + elementType: text + bounds: [856, 510, 72, 12] + content: + fontSize: 9 + color: "$muted" + wrap: false + align: [right, middle] + text: 07 / 18 + - elementId: kicker + elementType: text + bounds: [48, 88, 400, 14] + content: + style: "$kicker" + wrap: false + align: [left, middle] + text: 影像画质 IMAGE QUALITY + - elementId: big-num + elementType: text + bounds: [48, 112, 420, 132] + content: + color: "$accent" + align: [left, middle] + wrap: false + text: | +

240 fps

+ - elementId: title + elementType: text + bounds: [48, 262, 420, 40] + content: + fontSize: 26 + color: "$text" + wrap: false + align: [left, middle] + text: "4K 高帧率,慢放每一瞬" + - elementId: body + elementType: text + bounds: [48, 312, 400, 92] + content: + style: "$body" + align: [left, top] + text: 慢动作规格由 4K/120fps 翻倍至 4K/240fps,最高 10 倍慢放——曾专属于万元级电影机的规格,如今装进 190g 的口袋机身。 + - elementId: stats-line + elementType: shape + bounds: [48, 424, 400, 1] + shapeName: rect + fill: {type: solid, color: "$line"} + - elementId: stat1 + elementType: text + bounds: [48, 440, 124, 26] + content: + style: "$statNum" + wrap: false + align: [left, middle] + text: "10×" + - elementId: stat1-label + elementType: text + bounds: [48, 470, 124, 14] + content: + style: "$statLabel" + wrap: false + align: [left, middle] + text: 最高慢放 + - elementId: stat2 + elementType: text + bounds: [184, 440, 124, 26] + content: + style: "$statNum" + wrap: false + align: [left, middle] + text: "" + - elementId: stat2-label + elementType: text + bounds: [184, 470, 124, 14] + content: + style: "$statLabel" + wrap: false + align: [left, middle] + text: 较上代提升 + - elementId: stat3 + elementType: text + bounds: [320, 440, 140, 26] + content: + style: "$statNum" + wrap: false + align: [left, middle] + text: "4K/120" + - elementId: stat3-label + elementType: text + bounds: [320, 470, 140, 14] + content: + style: "$statLabel" + wrap: false + align: [left, middle] + text: Pocket 3 规格 diff --git a/example/dji-pocket4/pages/08_zoom.page b/example/dji-pocket4/pages/08_zoom.page new file mode 100644 index 0000000..f09ac22 --- /dev/null +++ b/example/dji-pocket4/pages/08_zoom.page @@ -0,0 +1,109 @@ +pageType: content +background: + type: solid + color: "$bg" +elements: + - elementId: side-img + elementType: image + bounds: [528, 48, 384, 444] + src: "media/portrait.jpg" + fit: {mode: cover} + - elementId: hd-bar + elementType: shape + bounds: [48, 34, 4, 12] + shapeName: rect + fill: {type: solid, color: "$accent"} + - elementId: hd-label + elementType: text + bounds: [60, 32, 260, 15] + content: + style: "$headerLabel" + wrap: false + align: [left, middle] + text: DJI OSMO POCKET 4 + - elementId: page-num + elementType: text + bounds: [856, 510, 72, 12] + content: + fontSize: 9 + color: "$muted" + wrap: false + align: [right, middle] + text: 08 / 18 + - elementId: kicker + elementType: text + bounds: [48, 88, 400, 14] + content: + style: "$kicker" + wrap: false + align: [left, middle] + text: 影像画质 IMAGE QUALITY + - elementId: title + elementType: text + bounds: [48, 116, 430, 100] + content: + style: "$pageTitle" + align: [left, top] + text: | +

2 倍无损变焦,

+

人像更有电影感

+ - elementId: body + elementType: text + bounds: [48, 236, 400, 130] + content: + style: "$body" + align: [left, top] + text: 一键切换 1×/2× 无损变焦,特写人像锐利清晰;最高 4× 数码变焦,远景近物皆在眼前。内置多种胶片影调风格,一键复刻电影质感;新增美肤滤镜,磨皮、美白与肤色冷暖随心调,搭配外置补光灯,逆光暗光也能柔光美颜。 + - elementId: stats-line + elementType: shape + bounds: [48, 392, 400, 1] + shapeName: rect + fill: {type: solid, color: "$line"} + - elementId: stat1 + elementType: text + bounds: [48, 408, 124, 26] + content: + style: "$statNum" + wrap: false + align: [left, middle] + text: "" + - elementId: stat1-label + elementType: text + bounds: [48, 438, 124, 14] + content: + style: "$statLabel" + wrap: false + align: [left, middle] + text: 无损变焦 + - elementId: stat2 + elementType: text + bounds: [184, 408, 124, 26] + content: + style: "$statNum" + wrap: false + align: [left, middle] + text: "" + - elementId: stat2-label + elementType: text + bounds: [184, 438, 124, 14] + content: + style: "$statLabel" + wrap: false + align: [left, middle] + text: 数码变焦 + - elementId: stat3 + elementType: text + bounds: [320, 408, 140, 26] + content: + style: "$statNum" + wrap: false + align: [left, middle] + text: "1 键" + - elementId: stat3-label + elementType: text + bounds: [320, 438, 140, 14] + content: + style: "$statLabel" + wrap: false + align: [left, middle] + text: 变焦按键切换 diff --git a/example/dji-pocket4/pages/09_chapter2.page b/example/dji-pocket4/pages/09_chapter2.page new file mode 100644 index 0000000..e31c8f8 --- /dev/null +++ b/example/dji-pocket4/pages/09_chapter2.page @@ -0,0 +1,77 @@ +pageType: chapter +background: + type: solid + color: "$bg" +elements: + - elementId: bg-img + elementType: image + bounds: [0, 0, 960, 540] + src: "media/film_dancer.jpg" + fit: {mode: cover} + - elementId: dim + elementType: shape + bounds: [0, 0, 960, 540] + shapeName: rect + fill: {type: solid, color: "#0C0C0E80"} + - elementId: bottom-grad + elementType: shape + bounds: [0, 0, 960, 540] + shapeName: rect + fill: + type: gradient + gradientType: linear + angle: 90 + stops: + - {position: 0, color: "#0C0C0E00"} + - {position: 1, color: "#0C0C0ED9"} + - elementId: ch-num + elementType: text + bounds: [48, 118, 400, 190] + content: + fontSize: 150 + color: "$accent" + wrap: false + align: [left, middle] + text: "02" + - elementId: ch-bar + elementType: shape + bounds: [48, 316, 56, 4] + shapeName: rect + fill: {type: solid, color: "$accent"} + - elementId: ch-eng + elementType: text + bounds: [48, 334, 400, 16] + content: + fontSize: 12 + color: "$accent" + letterSpacing: 4 + wrap: false + align: [left, middle] + text: SMART & CONTROL + - elementId: ch-title + elementType: text + bounds: [48, 356, 500, 64] + content: + fontSize: 50 + color: "$text" + wrap: false + align: [left, middle] + text: "智能与操控" + - elementId: ch-sub + elementType: text + bounds: [48, 434, 500, 20] + content: + fontSize: 13 + color: "#F2F0EACC" + wrap: false + align: [left, middle] + text: 一个人,就是一支摄制组 + - elementId: page-num + elementType: text + bounds: [856, 510, 72, 12] + content: + fontSize: 9 + color: "#F2F0EA99" + wrap: false + align: [right, middle] + text: 09 / 18 diff --git a/example/dji-pocket4/pages/10_tracking.page b/example/dji-pocket4/pages/10_tracking.page new file mode 100644 index 0000000..0ba8b3c --- /dev/null +++ b/example/dji-pocket4/pages/10_tracking.page @@ -0,0 +1,109 @@ +pageType: content +background: + type: solid + color: "$bg" +elements: + - elementId: side-img + elementType: image + bounds: [528, 48, 384, 444] + src: "media/activetrack.jpg" + fit: {mode: cover} + - elementId: hd-bar + elementType: shape + bounds: [48, 34, 4, 12] + shapeName: rect + fill: {type: solid, color: "$accent"} + - elementId: hd-label + elementType: text + bounds: [60, 32, 260, 15] + content: + style: "$headerLabel" + wrap: false + align: [left, middle] + text: DJI OSMO POCKET 4 + - elementId: page-num + elementType: text + bounds: [856, 510, 72, 12] + content: + fontSize: 9 + color: "$muted" + wrap: false + align: [right, middle] + text: 10 / 18 + - elementId: kicker + elementType: text + bounds: [48, 88, 400, 14] + content: + style: "$kicker" + wrap: false + align: [left, middle] + text: 智能与操控 SMART & CONTROL + - elementId: title + elementType: text + bounds: [48, 116, 430, 100] + content: + style: "$pageTitle" + align: [left, top] + text: | +

智能跟随 7.0,

+

镜头锁定主角

+ - elementId: body + elementType: text + bounds: [48, 236, 400, 136] + content: + style: "$body" + align: [left, top] + text: ActiveTrack 7.0 支持最高 4 倍变焦跟随,人群中也能精准锁定主体。开启「锁定主角追焦」,镜头自动锁定目标并持续跟焦,轻点屏幕即可切换对象;「登记主角优先」,让重要的人始终清晰。挥挥手,手势控制即刻开拍。 + - elementId: stats-line + elementType: shape + bounds: [48, 396, 400, 1] + shapeName: rect + fill: {type: solid, color: "$line"} + - elementId: stat1 + elementType: text + bounds: [48, 412, 124, 26] + content: + style: "$statNum" + wrap: false + align: [left, middle] + text: "7.0" + - elementId: stat1-label + elementType: text + bounds: [48, 442, 124, 14] + content: + style: "$statLabel" + wrap: false + align: [left, middle] + text: 智能跟随 + - elementId: stat2 + elementType: text + bounds: [184, 412, 124, 26] + content: + style: "$statNum" + wrap: false + align: [left, middle] + text: "" + - elementId: stat2-label + elementType: text + bounds: [184, 442, 124, 14] + content: + style: "$statLabel" + wrap: false + align: [left, middle] + text: 变焦跟随 + - elementId: stat3 + elementType: text + bounds: [320, 412, 140, 26] + content: + style: "$statNum" + wrap: false + align: [left, middle] + text: "0 接触" + - elementId: stat3-label + elementType: text + bounds: [320, 442, 140, 14] + content: + style: "$statLabel" + wrap: false + align: [left, middle] + text: 手势控制开拍 diff --git a/example/dji-pocket4/pages/11_control.page b/example/dji-pocket4/pages/11_control.page new file mode 100644 index 0000000..5640d99 --- /dev/null +++ b/example/dji-pocket4/pages/11_control.page @@ -0,0 +1,119 @@ +pageType: content +background: + type: solid + color: "$bg" +elements: + - elementId: side-img + elementType: image + bounds: [528, 48, 384, 444] + src: "media/inhand.jpg" + fit: {mode: cover} + - elementId: hd-bar + elementType: shape + bounds: [48, 34, 4, 12] + shapeName: rect + fill: {type: solid, color: "$accent"} + - elementId: hd-label + elementType: text + bounds: [60, 32, 260, 15] + content: + style: "$headerLabel" + wrap: false + align: [left, middle] + text: DJI OSMO POCKET 4 + - elementId: page-num + elementType: text + bounds: [856, 510, 72, 12] + content: + fontSize: 9 + color: "$muted" + wrap: false + align: [right, middle] + text: 11 / 18 + - elementId: kicker + elementType: text + bounds: [48, 88, 400, 14] + content: + style: "$kicker" + wrap: false + align: [left, middle] + text: 智能与操控 SMART & CONTROL + - elementId: title + elementType: text + bounds: [48, 116, 460, 52] + content: + style: "$pageTitle" + wrap: false + align: [left, middle] + text: "轻转屏幕,即刻开拍" + - elementId: body + elementType: text + bounds: [48, 180, 420, 74] + content: + style: "$body" + align: [left, top] + text: 三轴云台随旋屏即刻响应,横竖拍无缝切换;2.0 英寸 OLED 触控屏,1000 尼特亮度,阳光下依然清晰可见。 + - elementId: f1-name + elementType: text + bounds: [48, 272, 400, 22] + content: + fontSize: 15 + color: "$text" + wrap: false + align: [left, middle] + text: "屏下双按键" + - elementId: f1-desc + elementType: text + bounds: [48, 298, 420, 16] + content: + fontSize: 11 + color: "$muted" + wrap: false + align: [left, middle] + text: 变焦键单击切换 1×/2×、双击 4×;自定义键长按触发预设 + - elementId: f1-line + elementType: shape + bounds: [48, 326, 420, 1] + shapeName: rect + fill: {type: solid, color: "$line"} + - elementId: f2-name + elementType: text + bounds: [48, 348, 400, 22] + content: + fontSize: 15 + color: "$text" + wrap: false + align: [left, middle] + text: "五维摇杆" + - elementId: f2-desc + elementType: text + bounds: [48, 374, 420, 16] + content: + fontSize: 11 + color: "$muted" + wrap: false + align: [left, middle] + text: 单击返回菜单,双击云台回中,三击切换前后镜头朝向 + - elementId: f2-line + elementType: shape + bounds: [48, 402, 420, 1] + shapeName: rect + fill: {type: solid, color: "$line"} + - elementId: f3-name + elementType: text + bounds: [48, 424, 400, 22] + content: + fontSize: 15 + color: "$text" + wrap: false + align: [left, middle] + text: "旋转屏开拍" + - elementId: f3-desc + elementType: text + bounds: [48, 450, 420, 16] + content: + fontSize: 11 + color: "$muted" + wrap: false + align: [left, middle] + text: 轻转屏幕云台即刻响应,竖拍横拍随心切换 diff --git a/example/dji-pocket4/pages/12_gimbal.page b/example/dji-pocket4/pages/12_gimbal.page new file mode 100644 index 0000000..5ee7936 --- /dev/null +++ b/example/dji-pocket4/pages/12_gimbal.page @@ -0,0 +1,109 @@ +pageType: content +background: + type: solid + color: "$bg" +elements: + - elementId: side-img + elementType: image + bounds: [0, 0, 468, 540] + src: "media/tripod_street_bl.jpg" + fit: {mode: cover} + - elementId: hd-bar + elementType: shape + bounds: [48, 34, 4, 12] + shapeName: rect + fill: {type: solid, color: "$accent"} + - elementId: hd-label + elementType: text + bounds: [60, 32, 260, 15] + content: + style: "$headerLabel" + wrap: false + align: [left, middle] + text: DJI OSMO POCKET 4 + - elementId: page-num + elementType: text + bounds: [856, 510, 72, 12] + content: + fontSize: 9 + color: "$muted" + wrap: false + align: [right, middle] + text: 12 / 18 + - elementId: kicker + elementType: text + bounds: [536, 88, 376, 14] + content: + style: "$kicker" + wrap: false + align: [left, middle] + text: 智能与操控 SMART & CONTROL + - elementId: title + elementType: text + bounds: [536, 116, 376, 100] + content: + style: "$pageTitle" + align: [left, top] + text: | +

三轴机械增稳,

+

稳如滑轨

+ - elementId: body + elementType: text + bounds: [536, 236, 376, 116] + content: + style: "$body" + align: [left, top] + text: 三轴机械增稳云台,增稳精度 ±0.005°,最大操控转速 180°/s。走动、奔跑、车载跟拍,画面始终平稳顺滑——机械增稳不裁切画面,画质零损失。 + - elementId: stats-line + elementType: shape + bounds: [536, 364, 376, 1] + shapeName: rect + fill: {type: solid, color: "$line"} + - elementId: stat1 + elementType: text + bounds: [536, 380, 120, 26] + content: + style: "$statNum" + wrap: false + align: [left, middle] + text: "±0.005°" + - elementId: stat1-label + elementType: text + bounds: [536, 410, 120, 14] + content: + style: "$statLabel" + wrap: false + align: [left, middle] + text: 增稳精度 + - elementId: stat2 + elementType: text + bounds: [664, 380, 120, 26] + content: + style: "$statNum" + wrap: false + align: [left, middle] + text: "180°/s" + - elementId: stat2-label + elementType: text + bounds: [664, 410, 120, 14] + content: + style: "$statLabel" + wrap: false + align: [left, middle] + text: 最大操控转速 + - elementId: stat3 + elementType: text + bounds: [792, 380, 120, 26] + content: + style: "$statNum" + wrap: false + align: [left, middle] + text: "-120°~70°" + - elementId: stat3-label + elementType: text + bounds: [792, 410, 120, 14] + content: + style: "$statLabel" + wrap: false + align: [left, middle] + text: 云台俯仰范围 diff --git a/example/dji-pocket4/pages/13_chapter3.page b/example/dji-pocket4/pages/13_chapter3.page new file mode 100644 index 0000000..63e7c21 --- /dev/null +++ b/example/dji-pocket4/pages/13_chapter3.page @@ -0,0 +1,77 @@ +pageType: chapter +background: + type: solid + color: "$bg" +elements: + - elementId: bg-img + elementType: image + bounds: [0, 0, 960, 540] + src: "media/picnic.jpg" + fit: {mode: cover} + - elementId: dim + elementType: shape + bounds: [0, 0, 960, 540] + shapeName: rect + fill: {type: solid, color: "#0C0C0E99"} + - elementId: bottom-grad + elementType: shape + bounds: [0, 0, 960, 540] + shapeName: rect + fill: + type: gradient + gradientType: linear + angle: 90 + stops: + - {position: 0, color: "#0C0C0E00"} + - {position: 1, color: "#0C0C0ED9"} + - elementId: ch-num + elementType: text + bounds: [48, 118, 400, 190] + content: + fontSize: 150 + color: "$accent" + wrap: false + align: [left, middle] + text: "03" + - elementId: ch-bar + elementType: shape + bounds: [48, 316, 56, 4] + shapeName: rect + fill: {type: solid, color: "$accent"} + - elementId: ch-eng + elementType: text + bounds: [48, 334, 400, 16] + content: + fontSize: 12 + color: "$accent" + letterSpacing: 4 + wrap: false + align: [left, middle] + text: AUDIO & ECOSYSTEM + - elementId: ch-title + elementType: text + bounds: [48, 356, 500, 64] + content: + fontSize: 50 + color: "$text" + wrap: false + align: [left, middle] + text: "音频与生态" + - elementId: ch-sub + elementType: text + bounds: [48, 434, 560, 20] + content: + fontSize: 13 + color: "#F2F0EACC" + wrap: false + align: [left, middle] + text: 从声音、存储到续航的全链路体验 + - elementId: page-num + elementType: text + bounds: [856, 510, 72, 12] + content: + fontSize: 9 + color: "#F2F0EA99" + wrap: false + align: [right, middle] + text: 13 / 18 diff --git a/example/dji-pocket4/pages/14_audio.page b/example/dji-pocket4/pages/14_audio.page new file mode 100644 index 0000000..59f2f31 --- /dev/null +++ b/example/dji-pocket4/pages/14_audio.page @@ -0,0 +1,114 @@ +pageType: content +background: + type: solid + color: "$bg" +elements: + - elementId: img1 + elementType: image + bounds: [520, 110, 392, 186] + src: "media/osmoaudio.jpg" + fit: {mode: cover} + - elementId: img2 + elementType: image + bounds: [520, 312, 392, 180] + src: "media/mic3.jpg" + fit: {mode: cover} + - elementId: hd-bar + elementType: shape + bounds: [48, 34, 4, 12] + shapeName: rect + fill: {type: solid, color: "$accent"} + - elementId: hd-label + elementType: text + bounds: [60, 32, 260, 15] + content: + style: "$headerLabel" + wrap: false + align: [left, middle] + text: DJI OSMO POCKET 4 + - elementId: page-num + elementType: text + bounds: [856, 510, 72, 12] + content: + fontSize: 9 + color: "$muted" + wrap: false + align: [right, middle] + text: 14 / 18 + - elementId: kicker + elementType: text + bounds: [48, 88, 400, 14] + content: + style: "$kicker" + wrap: false + align: [left, middle] + text: 音频与生态 AUDIO & ECOSYSTEM + - elementId: title + elementType: text + bounds: [48, 116, 430, 100] + content: + style: "$pageTitle" + align: [left, top] + text: | +

OsmoAudio,

+

声临其境

+ - elementId: body + elementType: text + bounds: [48, 236, 400, 136] + content: + style: "$body" + align: [left, top] + text: OsmoAudio 直连 DJI Mic 3 发射器,无需接收器即可四声道同步录制;新增空间音频与变焦拾音,声音随画面远近变化,临场感倍增。机身三麦克风阵列,48kHz 16-bit 高清收音。 + - elementId: stats-line + elementType: shape + bounds: [48, 396, 400, 1] + shapeName: rect + fill: {type: solid, color: "$line"} + - elementId: stat1 + elementType: text + bounds: [48, 412, 124, 26] + content: + style: "$statNum" + wrap: false + align: [left, middle] + text: "4 声道" + - elementId: stat1-label + elementType: text + bounds: [48, 442, 124, 14] + content: + style: "$statLabel" + wrap: false + align: [left, middle] + text: 同步录制 + - elementId: stat2 + elementType: text + bounds: [184, 412, 124, 26] + content: + style: "$statNum" + wrap: false + align: [left, middle] + text: "空间音频" + - elementId: stat2-label + elementType: text + bounds: [184, 442, 124, 14] + content: + style: "$statLabel" + wrap: false + align: [left, middle] + text: 变焦拾音 + - elementId: stat3 + elementType: text + bounds: [320, 412, 140, 26] + content: + style: "$statNum" + wrap: false + align: [left, middle] + text: "Mic 3" + - elementId: stat3-label + elementType: text + bounds: [320, 442, 140, 14] + content: + style: "$statLabel" + wrap: false + align: [left, middle] + text: 无线直连 diff --git a/example/dji-pocket4/pages/15_battery.page b/example/dji-pocket4/pages/15_battery.page new file mode 100644 index 0000000..3768f1c --- /dev/null +++ b/example/dji-pocket4/pages/15_battery.page @@ -0,0 +1,130 @@ +pageType: content +background: + type: solid + color: "$bg" +elements: + - elementId: hd-bar + elementType: shape + bounds: [48, 34, 4, 12] + shapeName: rect + fill: {type: solid, color: "$accent"} + - elementId: hd-label + elementType: text + bounds: [60, 32, 260, 15] + content: + style: "$headerLabel" + wrap: false + align: [left, middle] + text: DJI OSMO POCKET 4 + - elementId: page-num + elementType: text + bounds: [856, 510, 72, 12] + content: + fontSize: 9 + color: "$muted" + wrap: false + align: [right, middle] + text: 15 / 18 + - elementId: kicker + elementType: text + bounds: [48, 88, 400, 14] + content: + style: "$kicker" + wrap: false + align: [left, middle] + text: 音频与生态 AUDIO & ECOSYSTEM + - elementId: title + elementType: text + bounds: [48, 114, 500, 48] + content: + fontSize: 34 + color: "$text" + wrap: false + align: [left, middle] + text: "存储与续航,一步到位" + - elementId: grid-vline + elementType: shape + bounds: [479, 202, 1, 276] + shapeName: rect + fill: {type: solid, color: "$line"} + - elementId: grid-hline + elementType: shape + bounds: [48, 336, 864, 1] + shapeName: rect + fill: {type: solid, color: "$line"} + - elementId: c1-num + elementType: text + bounds: [48, 210, 420, 56] + content: + fontSize: 48 + color: "$text" + wrap: false + align: [left, middle] + text: | +

107 GB

+ - elementId: c1-label + elementType: text + bounds: [48, 278, 420, 36] + content: + fontSize: 11.5 + color: "$muted" + lineHeight: 1.55 + align: [left, top] + text: 内置存储空间,另支持 microSD 卡扩展至 1TB + - elementId: c2-num + elementType: text + bounds: [504, 210, 408, 56] + content: + fontSize: 48 + color: "$text" + wrap: false + align: [left, middle] + text: | +

800 MB/s

+ - elementId: c2-label + elementType: text + bounds: [504, 278, 408, 36] + content: + fontSize: 11.5 + color: "$muted" + lineHeight: 1.55 + align: [left, top] + text: USB 3.1 有线极速导出,Wi-Fi 6 无线传输 + - elementId: c3-num + elementType: text + bounds: [48, 356, 420, 56] + content: + fontSize: 48 + color: "$text" + wrap: false + align: [left, middle] + text: | +

240 min

+ - elementId: c3-label + elementType: text + bounds: [48, 424, 420, 36] + content: + fontSize: 11.5 + color: "$muted" + lineHeight: 1.55 + align: [left, top] + text: 满电连续拍摄时长,全天创作不断电 + - elementId: c4-num + elementType: text + bounds: [504, 356, 408, 56] + content: + fontSize: 48 + color: "$text" + wrap: false + align: [left, middle] + text: | +

18 min

+ - elementId: c4-label + elementType: text + bounds: [504, 424, 408, 36] + content: + fontSize: 11.5 + color: "$muted" + lineHeight: 1.55 + align: [left, top] + text: PD 快充充至 80%,32 分钟充满 100% diff --git a/example/dji-pocket4/pages/16_accessories.page b/example/dji-pocket4/pages/16_accessories.page new file mode 100644 index 0000000..f01df3a --- /dev/null +++ b/example/dji-pocket4/pages/16_accessories.page @@ -0,0 +1,151 @@ +pageType: content +background: + type: solid + color: "$bg" +elements: + - elementId: acc-img + elementType: image + bounds: [48, 108, 500, 364] + src: "media/accessories.jpg" + fit: {mode: cover} + - elementId: hd-bar + elementType: shape + bounds: [48, 34, 4, 12] + shapeName: rect + fill: {type: solid, color: "$accent"} + - elementId: hd-label + elementType: text + bounds: [60, 32, 260, 15] + content: + style: "$headerLabel" + wrap: false + align: [left, middle] + text: DJI OSMO POCKET 4 + - elementId: page-num + elementType: text + bounds: [856, 510, 72, 12] + content: + fontSize: 9 + color: "$muted" + wrap: false + align: [right, middle] + text: 16 / 18 + - elementId: kicker + elementType: text + bounds: [588, 88, 324, 14] + content: + style: "$kicker" + wrap: false + align: [left, middle] + text: 音频与生态 AUDIO & ECOSYSTEM + - elementId: title + elementType: text + bounds: [588, 114, 324, 44] + content: + fontSize: 30 + color: "$text" + wrap: false + align: [left, middle] + text: "配件全家桶" + - elementId: body + elementType: text + bounds: [588, 166, 324, 44] + content: + fontSize: 12 + color: "$muted" + lineHeight: 1.65 + align: [left, top] + text: 丰富配件覆盖补光、收音、续航与收纳,按需组合,适配多元创作场景。 + - elementId: a1-mark + elementType: shape + bounds: [588, 234, 12, 2] + shapeName: rect + fill: {type: solid, color: "$accent"} + - elementId: a1 + elementType: text + bounds: [608, 226, 304, 20] + content: + fontSize: 12.5 + color: "$text" + wrap: false + align: [left, middle] + text: DJI Mic 3 发射器 + - elementId: a2-mark + elementType: shape + bounds: [588, 262, 12, 2] + shapeName: rect + fill: {type: solid, color: "$accent"} + - elementId: a2 + elementType: text + bounds: [608, 254, 304, 20] + content: + fontSize: 12.5 + color: "$text" + wrap: false + align: [left, middle] + text: 外置补光灯 · 美颜柔光镜 + - elementId: a3-mark + elementType: shape + bounds: [588, 290, 12, 2] + shapeName: rect + fill: {type: solid, color: "$accent"} + - elementId: a3 + elementType: text + bounds: [608, 282, 304, 20] + content: + fontSize: 12.5 + color: "$text" + wrap: false + align: [left, middle] + text: 增广镜 · 磁吸 ND 镜套装 + - elementId: a4-mark + elementType: shape + bounds: [588, 318, 12, 2] + shapeName: rect + fill: {type: solid, color: "$accent"} + - elementId: a4 + elementType: text + bounds: [608, 310, 304, 20] + content: + fontSize: 12.5 + color: "$text" + wrap: false + align: [left, middle] + text: 迷你三脚架 · 拓展转接件 + - elementId: a5-mark + elementType: shape + bounds: [588, 346, 12, 2] + shapeName: rect + fill: {type: solid, color: "$accent"} + - elementId: a5 + elementType: text + bounds: [608, 338, 304, 20] + content: + fontSize: 12.5 + color: "$text" + wrap: false + align: [left, middle] + text: 续航手柄 · 1/4″ 螺纹手柄 + - elementId: a6-mark + elementType: shape + bounds: [588, 374, 12, 2] + shapeName: rect + fill: {type: solid, color: "$accent"} + - elementId: a6 + elementType: text + bounds: [608, 366, 304, 20] + content: + fontSize: 12.5 + color: "$text" + wrap: false + align: [left, middle] + text: 收纳包 · 保护壳 · 云台夹 + - elementId: acc-note + elementType: text + bounds: [588, 416, 324, 56] + content: + fontSize: 10.5 + color: "$muted" + lineHeight: 1.6 + align: [left, top] + text: 全能套装一站配齐核心配件;所有配件均可在 DJI 官方商城单独选购。 diff --git a/example/dji-pocket4/pages/17_specs.page b/example/dji-pocket4/pages/17_specs.page new file mode 100644 index 0000000..3762669 --- /dev/null +++ b/example/dji-pocket4/pages/17_specs.page @@ -0,0 +1,114 @@ +pageType: content +background: + type: solid + color: "$bg" +elements: + - elementId: hd-bar + elementType: shape + bounds: [48, 34, 4, 12] + shapeName: rect + fill: {type: solid, color: "$accent"} + - elementId: hd-label + elementType: text + bounds: [60, 32, 260, 15] + content: + style: "$headerLabel" + wrap: false + align: [left, middle] + text: DJI OSMO POCKET 4 + - elementId: page-num + elementType: text + bounds: [856, 510, 72, 12] + content: + fontSize: 9 + color: "$muted" + wrap: false + align: [right, middle] + text: 17 / 18 + - elementId: kicker + elementType: text + bounds: [48, 88, 400, 14] + content: + style: "$kicker" + wrap: false + align: [left, middle] + text: 规格与售价 SPECS & PRICE + - elementId: title + elementType: text + bounds: [48, 114, 500, 44] + content: + fontSize: 34 + color: "$text" + wrap: false + align: [left, middle] + text: "技术规格全览" + - elementId: spec-table + elementType: table + bounds: [48, 176, 540, 322] + columnWidths: [0.3, 0.7] + rowHeights: [0.083, 0.083, 0.083, 0.083, 0.083, 0.083, 0.083, 0.083, 0.084, 0.084, 0.084, 0.084] + style: + fontSize: 10.5 + bodyColor: "$text" + headerBold: false + firstColumnColor: "$muted" + border: [null, null, {style: solid, width: 0.75, color: "$line"}, null] + rows: + - - content: {text: 机身尺寸, align: [left, middle]} + - content: {text: 144.2 × 44.4 × 33.5 mm, align: [left, middle]} + - - content: {text: 机身重量, align: [left, middle]} + - content: {text: 190.5 g, align: [left, middle]} + - - content: {text: 传感器, align: [left, middle]} + - content: {text: 1 英寸堆栈式 CMOS, align: [left, middle]} + - - content: {text: 镜头, align: [left, middle]} + - content: {text: 等效 20 mm · f/2.0 光圈, align: [left, middle]} + - - content: {text: 照片, align: [left, middle]} + - content: {text: 约 3700 万像素(7680 × 4320), align: [left, middle]} + - - content: {text: 视频, align: [left, middle]} + - content: {text: 4K/240fps 慢动作 · 1080p/240fps, align: [left, middle]} + - - content: {text: 动态范围, align: [left, middle]} + - content: {text: 14 档 · 10-bit D-Log 色彩, align: [left, middle]} + - - content: {text: 屏幕, align: [left, middle]} + - content: {text: 2.0″ OLED 触控旋转屏 · 1000 尼特, align: [left, middle]} + - - content: {text: 存储, align: [left, middle]} + - content: {text: 内置 107GB · microSD 扩展至 1TB, align: [left, middle]} + - - content: {text: 电池, align: [left, middle]} + - content: {text: 1545mAh · 240 分钟续航 · 18 分钟充至 80%, align: [left, middle]} + - - content: {text: 传输, align: [left, middle]} + - content: {text: USB 3.1 800MB/s · Wi-Fi 6 · 蓝牙, align: [left, middle]} + - - content: {text: 音频, align: [left, middle]} + - content: {text: 三麦克风 · OsmoAudio 四声道, align: [left, middle]} + - elementId: product-img + elementType: image + bounds: [628, 176, 284, 160] + src: "media/product_black.jpg" + fit: {mode: cover} + - elementId: product-caption + elementType: text + bounds: [628, 344, 284, 14] + content: + fontSize: 9 + color: "$muted" + wrap: false + align: [center, middle] + text: Osmo Pocket 4 标准套装 + - elementId: product-price + elementType: text + bounds: [628, 366, 284, 44] + content: + color: "$accent" + align: [center, middle] + wrap: false + text: | +

¥2999

+ - elementId: product-note + elementType: text + bounds: [628, 420, 284, 60] + content: + fontSize: 10 + color: "$muted" + lineHeight: 1.6 + align: [center, top] + text: | +

2026.04.16 国行现货开售

+

全球其他地区 04.22 起发售

diff --git a/example/dji-pocket4/pages/18_price.page b/example/dji-pocket4/pages/18_price.page new file mode 100644 index 0000000..8438944 --- /dev/null +++ b/example/dji-pocket4/pages/18_price.page @@ -0,0 +1,139 @@ +pageType: final +background: + type: solid + color: "$bg" +elements: + - elementId: hd-bar + elementType: shape + bounds: [48, 34, 4, 12] + shapeName: rect + fill: {type: solid, color: "$accent"} + - elementId: hd-label + elementType: text + bounds: [60, 32, 260, 15] + content: + style: "$headerLabel" + wrap: false + align: [left, middle] + text: DJI OSMO POCKET 4 + - elementId: page-num + elementType: text + bounds: [856, 510, 72, 12] + content: + fontSize: 9 + color: "$muted" + wrap: false + align: [right, middle] + text: 18 / 18 + - elementId: kicker + elementType: text + bounds: [48, 88, 400, 14] + content: + style: "$kicker" + wrap: false + align: [left, middle] + text: 规格与售价 SPECS & PRICE + - elementId: title + elementType: text + bounds: [48, 114, 560, 48] + content: + fontSize: 34 + color: "$text" + wrap: false + align: [left, middle] + text: "2999 元起,增配降价" + - elementId: p1-label + elementType: text + bounds: [48, 196, 200, 22] + content: + fontSize: 15 + color: "$text" + wrap: false + align: [left, middle] + text: "标准套装" + - elementId: p1-price + elementType: text + bounds: [48, 224, 300, 56] + content: + fontSize: 44 + color: "$accent" + wrap: false + align: [left, middle] + text: "¥2999" + - elementId: p1-items + elementType: text + bounds: [48, 290, 380, 60] + content: + fontSize: 10.5 + color: "$muted" + lineHeight: 1.7 + align: [left, top] + text: | +

主相机 · 云台夹 · 1/4″ 螺纹手柄

+

便携收纳袋 · USB-C PD 快充线(USB 3.1)

+ - elementId: price-vline + elementType: shape + bounds: [479, 196, 1, 154] + shapeName: rect + fill: {type: solid, color: "$line"} + - elementId: p2-label + elementType: text + bounds: [520, 196, 200, 22] + content: + fontSize: 15 + color: "$text" + wrap: false + align: [left, middle] + text: "全能套装" + - elementId: p2-price + elementType: text + bounds: [520, 224, 300, 56] + content: + fontSize: 44 + color: "$accent" + wrap: false + align: [left, middle] + text: "¥3799" + - elementId: p2-items + elementType: text + bounds: [520, 290, 392, 60] + content: + fontSize: 10.5 + color: "$muted" + lineHeight: 1.7 + align: [left, top] + text: | +

含标准套装全部配件,另加 DJI Mic 3 发射器、

+

补光灯、增广镜、迷你三脚架、收纳包

+ - elementId: mid-line + elementType: shape + bounds: [48, 384, 864, 1] + shapeName: rect + fill: {type: solid, color: "$line"} + - elementId: closing + elementType: text + bounds: [48, 406, 864, 48] + content: + fontSize: 28 + color: "$text" + wrap: false + align: [center, middle] + text: "大片生活,即刻呈现。" + - elementId: teaser + elementType: text + bounds: [48, 462, 864, 20] + content: + fontSize: 13 + color: "$accent" + wrap: false + align: [center, middle] + text: 彩蛋 · 双摄版 Osmo Pocket 4P,年内登场 + - elementId: source + elementType: text + bounds: [48, 510, 720, 12] + content: + fontSize: 9 + color: "$muted" + wrap: false + align: [left, middle] + text: 资料来源:DJI 官网新闻稿(2026-04-16)· DJI 官方商城 · 中关村在线;图片来自 DJI 官方 CDN diff --git a/example/yu7-ppt/media/bg_cabin.png b/example/yu7-ppt/media/bg_cabin.png new file mode 100644 index 0000000..cc2aaf7 Binary files /dev/null and b/example/yu7-ppt/media/bg_cabin.png differ diff --git a/example/yu7-ppt/media/bg_cover.jpg b/example/yu7-ppt/media/bg_cover.jpg new file mode 100644 index 0000000..54deac2 Binary files /dev/null and b/example/yu7-ppt/media/bg_cover.jpg differ diff --git a/example/yu7-ppt/media/bg_data.png b/example/yu7-ppt/media/bg_data.png new file mode 100644 index 0000000..9f8baa3 Binary files /dev/null and b/example/yu7-ppt/media/bg_data.png differ diff --git a/example/yu7-ppt/media/bg_exterior.jpg b/example/yu7-ppt/media/bg_exterior.jpg new file mode 100644 index 0000000..1708dcb Binary files /dev/null and b/example/yu7-ppt/media/bg_exterior.jpg differ diff --git a/example/yu7-ppt/media/bg_final.jpg b/example/yu7-ppt/media/bg_final.jpg new file mode 100644 index 0000000..97cb16d Binary files /dev/null and b/example/yu7-ppt/media/bg_final.jpg differ diff --git a/example/yu7-ppt/media/bg_had.jpg b/example/yu7-ppt/media/bg_had.jpg new file mode 100644 index 0000000..d241cfc Binary files /dev/null and b/example/yu7-ppt/media/bg_had.jpg differ diff --git a/example/yu7-ppt/media/bg_performance.jpg b/example/yu7-ppt/media/bg_performance.jpg new file mode 100644 index 0000000..02e6d30 Binary files /dev/null and b/example/yu7-ppt/media/bg_performance.jpg differ diff --git a/example/yu7-ppt/media/bg_safety.jpg b/example/yu7-ppt/media/bg_safety.jpg new file mode 100644 index 0000000..a499c03 Binary files /dev/null and b/example/yu7-ppt/media/bg_safety.jpg differ diff --git a/example/yu7-ppt/pages/1_cover.page b/example/yu7-ppt/pages/1_cover.page new file mode 100644 index 0000000..7710697 --- /dev/null +++ b/example/yu7-ppt/pages/1_cover.page @@ -0,0 +1,80 @@ +pageType: cover +background: + type: image + src: "media/bg_cover.jpg" + fit: {mode: cover} +elements: + - elementId: overlay + elementType: shape + bounds: [0, 0, 960, 540] + shapeName: rect + fill: + type: gradient + gradientType: linear + angle: 0 + stops: + - {position: 0, color: "#000000F2"} + - {position: 0.5, color: "#00000099"} + - {position: 1, color: "#00000000"} + - elementId: meta-top + elementType: text + bounds: [60, 46, 500, 22] + content: + fontSize: 12 + color: "$w85" + letterSpacing: 3 + text: "XIAOMI EV · 小米汽车" + - elementId: accent-line + elementType: shape + bounds: [60, 86, 56, 3] + shapeName: rect + fill: {type: solid, color: "$primary"} + - elementId: cover-title + elementType: text + bounds: [60, 150, 660, 130] + content: + style: "$coverTitle" + align: [left, middle] + text: "小米 YU7" + - elementId: cover-en + elementType: text + bounds: [60, 282, 660, 34] + content: + fontSize: 20 + color: "$w70" + letterSpacing: 10 + text: "XIAOMI YU7" + - elementId: slogan + elementType: text + bounds: [60, 330, 640, 46] + content: + fontSize: 17 + color: "$white" + letterSpacing: 2 + text: "御风而行 · 豪华高性能纯电 SUV" + - elementId: desc + elementType: text + bounds: [60, 380, 560, 44] + content: + fontSize: 13 + color: "$w70" + lineHeight: 1.7 + text: | + 传承家族设计 · 澎湃性能 · 越级智能 · 全维安全 + 重新定义豪华高性能 SUV 新标准 + - elementId: bottom-meta + elementType: text + bounds: [60, 488, 500, 20] + content: + fontSize: 11 + color: "$w60" + letterSpacing: 1 + text: "2025.06 上市 · 小米汽车科技有限公司" + - elementId: page-num + elementType: text + bounds: [850, 500, 60, 20] + content: + fontSize: 11 + color: "$w50" + align: [right, middle] + text: "01 / 08" diff --git a/example/yu7-ppt/pages/2_overview.page b/example/yu7-ppt/pages/2_overview.page new file mode 100644 index 0000000..76f163d --- /dev/null +++ b/example/yu7-ppt/pages/2_overview.page @@ -0,0 +1,195 @@ +pageType: content +background: + type: image + src: "media/bg_data.png" + fit: {mode: cover} +elements: + - elementId: overlay + elementType: shape + bounds: [0, 0, 960, 540] + shapeName: rect + fill: + type: gradient + gradientType: linear + angle: 90 + stops: + - {position: 0, color: "#000000E6"} + - {position: 0.65, color: "#000000B3"} + - {position: 1, color: "#00000080"} + - elementId: label + elementType: text + bounds: [60, 48, 500, 22] + content: + style: "$label" + text: "YU7 AT A GLANCE · 一图看懂" + - elementId: title + elementType: text + bounds: [60, 88, 700, 58] + content: + style: "$pageTitle" + text: "核心实力,一眼读懂" + - elementId: subtitle + elementType: text + bounds: [60, 148, 760, 26] + content: + fontSize: 15 + color: "$w70" + text: "五大核心指标,重新定义豪华高性能纯电 SUV 的标准" + - elementId: divider + elementType: line + bounds: [60, 196, 840, 1] + viewBox: [840, 1] + points: "0,0.5 840,0.5" + border: {style: solid, width: 1, color: "#FFFFFF2E"} + - elementId: stat1-line + elementType: shape + bounds: [60, 236, 40, 3] + shapeName: rect + fill: {type: solid, color: "$primary"} + - elementId: stat2-line + elementType: shape + bounds: [230, 236, 40, 3] + shapeName: rect + fill: {type: solid, color: "$primary"} + - elementId: stat3-line + elementType: shape + bounds: [400, 236, 40, 3] + shapeName: rect + fill: {type: solid, color: "$primary"} + - elementId: stat4-line + elementType: shape + bounds: [570, 236, 40, 3] + shapeName: rect + fill: {type: solid, color: "$primary"} + - elementId: stat5-line + elementType: shape + bounds: [740, 236, 40, 3] + shapeName: rect + fill: {type: solid, color: "$primary"} + - elementId: stat1 + elementType: text + bounds: [60, 252, 150, 60] + content: + align: [left, middle] + text: | +

23.35万起

+ - elementId: stat1-label + elementType: text + bounds: [60, 318, 150, 22] + content: + fontSize: 14 + color: "$w85" + text: "起售价格" + - elementId: stat1-desc + elementType: text + bounds: [60, 344, 152, 40] + content: + fontSize: 12 + color: "$w50" + lineHeight: 1.5 + text: "官网在售价(2026.08)" + - elementId: stat2 + elementType: text + bounds: [230, 252, 150, 60] + content: + align: [left, middle] + text: | +

835km

+ - elementId: stat2-label + elementType: text + bounds: [230, 318, 150, 22] + content: + fontSize: 14 + color: "$w85" + text: "CLTC 最高续航" + - elementId: stat2-desc + elementType: text + bounds: [230, 344, 152, 40] + content: + fontSize: 12 + color: "$w50" + lineHeight: 1.5 + text: "全系长续航,入门即长续航" + - elementId: stat3 + elementType: text + bounds: [400, 252, 150, 60] + content: + align: [left, middle] + text: | +

897V

+ - elementId: stat3-label + elementType: text + bounds: [400, 318, 150, 22] + content: + fontSize: 14 + color: "$w85" + text: "Max 峰值电压" + - elementId: stat3-desc + elementType: text + bounds: [400, 344, 152, 40] + content: + fontSize: 12 + color: "$w50" + lineHeight: 1.5 + text: "全系碳化硅高压平台" + - elementId: stat4 + elementType: text + bounds: [570, 252, 150, 60] + content: + align: [left, middle] + text: | +

0.245

+ - elementId: stat4-label + elementType: text + bounds: [570, 318, 150, 22] + content: + fontSize: 14 + color: "$w85" + text: "超低风阻系数" + - elementId: stat4-desc + elementType: text + bounds: [570, 344, 152, 40] + content: + fontSize: 12 + color: "$w50" + lineHeight: 1.5 + text: "10 组风道 · 40+ 处优化" + - elementId: stat5 + elementType: text + bounds: [740, 252, 150, 60] + content: + align: [left, middle] + text: | +

3944km

+ - elementId: stat5-label + elementType: text + bounds: [740, 318, 150, 22] + content: + fontSize: 14 + color: "$w85" + text: "24h 耐力挑战" + - elementId: stat5-desc + elementType: text + bounds: [740, 344, 152, 40] + content: + fontSize: 12 + color: "$w50" + lineHeight: 1.5 + text: "中汽中心·华诚认证" + - elementId: note + elementType: text + bounds: [60, 468, 840, 40] + content: + fontSize: 11 + color: "$w50" + lineHeight: 1.6 + text: | + 数据来源:小米汽车官网 xiaomiev.com/yu7,2026 年 8 月抓取。续航、电压等数据来自小米汽车实验室,配置区分车型版本。 + - elementId: page-num + elementType: text + bounds: [850, 500, 60, 20] + content: + fontSize: 11 + color: "$w50" + align: [right, middle] + text: "02 / 08" diff --git a/example/yu7-ppt/pages/3_exterior.page b/example/yu7-ppt/pages/3_exterior.page new file mode 100644 index 0000000..155f0c3 --- /dev/null +++ b/example/yu7-ppt/pages/3_exterior.page @@ -0,0 +1,110 @@ +pageType: content +background: + type: image + src: "media/bg_exterior.jpg" + fit: {mode: cover} +elements: + - elementId: overlay + elementType: shape + bounds: [0, 0, 960, 540] + shapeName: rect + fill: + type: gradient + gradientType: linear + angle: 0 + stops: + - {position: 0, color: "#000000F0"} + - {position: 0.6, color: "#0000008C"} + - {position: 1, color: "#00000000"} + - elementId: label + elementType: text + bounds: [60, 48, 500, 22] + content: + style: "$label" + text: "EXTERIOR DESIGN · 外观设计" + - elementId: title + elementType: text + bounds: [60, 88, 640, 56] + content: + style: "$pageTitle" + text: "优雅动感,一脉相承" + - elementId: lead + elementType: text + bounds: [60, 148, 560, 44] + content: + fontSize: 14 + color: "$w70" + lineHeight: 1.7 + text: | + 1:3 头身比与舒展的宽体低趴姿态,搭配流畅车身曲线; + 镂空水滴大灯与光环尾灯,一眼经典,再看独特。 + - elementId: row1-num + elementType: text + bounds: [60, 232, 220, 52] + content: + align: [left, middle] + text: | +

1:3 头身比

+ - elementId: row1-desc + elementType: text + bounds: [300, 236, 380, 44] + content: + fontSize: 13 + color: "$w70" + lineHeight: 1.6 + text: "延续汽车工业沉淀百年的长车头豪华比例,蓄势待发" + - elementId: row2-num + elementType: text + bounds: [60, 302, 220, 52] + content: + align: [left, middle] + text: | +

3.11㎡ 蚌式铝机盖

+ - elementId: row2-desc + elementType: text + bounds: [300, 306, 380, 44] + content: + fontSize: 13 + color: "$w70" + lineHeight: 1.6 + text: "量产最大蚌式铝机盖,与整车型面浑然一体" + - elementId: row3-num + elementType: text + bounds: [60, 372, 220, 52] + content: + align: [left, middle] + text: | +

0.245 风阻系数

+ - elementId: row3-desc + elementType: text + bounds: [300, 376, 380, 44] + content: + fontSize: 13 + color: "$w70" + lineHeight: 1.6 + text: "10 组贯穿式风道 · 40+ 处空气动力学优化,带来更好续航" + - elementId: extra + elementType: text + bounds: [60, 450, 560, 40] + content: + fontSize: 12 + color: "$w60" + letterSpacing: 1 + lineHeight: 1.7 + text: "镂空水滴大灯 · 光环尾灯 · 电动内翻式门把手 · 22° 上翘鸭尾" + - elementId: note + elementType: text + bounds: [60, 492, 560, 20] + content: + fontSize: 11 + color: "$w50" + text: | + 图源:小米汽车官网 · 风阻数据来自中国汽研重庆风洞实验室 + - elementId: page-num + elementType: text + bounds: [850, 500, 60, 20] + content: + fontSize: 11 + color: "$w50" + align: [right, middle] + text: "03 / 08" diff --git a/example/yu7-ppt/pages/4_performance.page b/example/yu7-ppt/pages/4_performance.page new file mode 100644 index 0000000..37077c2 --- /dev/null +++ b/example/yu7-ppt/pages/4_performance.page @@ -0,0 +1,130 @@ +pageType: content +background: + type: image + src: "media/bg_performance.jpg" + fit: {mode: cover} +elements: + - elementId: overlay + elementType: shape + bounds: [0, 0, 960, 540] + shapeName: rect + fill: + type: gradient + gradientType: linear + angle: 0 + stops: + - {position: 0, color: "#000000F0"} + - {position: 0.55, color: "#00000096"} + - {position: 1, color: "#00000026"} + - elementId: label + elementType: text + bounds: [60, 48, 520, 22] + content: + style: "$label" + text: "PERFORMANCE & HANDLING · 性能与操控" + - elementId: title + elementType: text + bounds: [60, 88, 640, 56] + content: + style: "$pageTitle" + text: "自信从容,强大内蕴" + - elementId: lead + elementType: text + bounds: [60, 148, 480, 44] + content: + fontSize: 14 + color: "$w70" + lineHeight: 1.7 + text: | + 从电机到底盘,以强悍性能与稳定掌控, + 回应每一种向前的渴望。 + - elementId: p1-title + elementType: text + bounds: [60, 226, 460, 30] + content: + fontSize: 18 + color: "$white" + bold: true + letterSpacing: 1 + text: "小米超级电机 V6s Plus" + - elementId: p1-desc + elementType: text + bounds: [60, 258, 460, 40] + content: + fontSize: 13 + color: "$w70" + lineHeight: 1.6 + text: "分段磁钢 + 电压过调制技术,转速更快、功率更高,性能全面提升" + - elementId: p2-title + elementType: text + bounds: [60, 310, 460, 30] + content: + fontSize: 18 + color: "$white" + bold: true + letterSpacing: 1 + text: "小米蛟龙底盘" + - elementId: p2-desc + elementType: text + bounds: [60, 342, 460, 40] + content: + fontSize: 13 + color: "$w70" + lineHeight: 1.6 + text: "闭式双腔空气弹簧 + 连续阻尼可变减振器,运动与舒适兼得" + - elementId: p3-title + elementType: text + bounds: [60, 394, 460, 30] + content: + fontSize: 18 + color: "$white" + bold: true + letterSpacing: 1 + text: "Brembo® 前四活塞固定卡钳" + - elementId: p3-desc + elementType: text + bounds: [60, 426, 460, 40] + content: + fontSize: 13 + color: "$w70" + lineHeight: 1.6 + text: "强劲制动力,兼顾性能与安全,山路与高速皆得心应手" + - elementId: endurance-num + elementType: text + bounds: [620, 320, 300, 80] + content: + align: [right, middle] + text: | +

3944 km

+ - elementId: endurance-label + elementType: text + bounds: [620, 402, 300, 22] + content: + fontSize: 14 + color: "$w85" + align: [right, middle] + text: "24 小时耐力挑战总行驶里程" + - elementId: endurance-note + elementType: text + bounds: [620, 428, 300, 20] + content: + fontSize: 11 + color: "$w50" + align: [right, middle] + text: "YU7 Max 测试车 · 中汽中心·华诚认证" + - elementId: note + elementType: text + bounds: [60, 490, 700, 20] + content: + fontSize: 11 + color: "$w50" + text: | + 数据来源:小米汽车官网 xiaomiev.com/yu7 · 配置区分车型版本 + - elementId: page-num + elementType: text + bounds: [850, 500, 60, 20] + content: + fontSize: 11 + color: "$w50" + align: [right, middle] + text: "04 / 08" diff --git a/example/yu7-ppt/pages/5_cabin.page b/example/yu7-ppt/pages/5_cabin.page new file mode 100644 index 0000000..7d81478 --- /dev/null +++ b/example/yu7-ppt/pages/5_cabin.page @@ -0,0 +1,133 @@ +pageType: content +background: + type: image + src: "media/bg_cabin.png" + fit: {mode: cover} +elements: + - elementId: overlay + elementType: shape + bounds: [0, 0, 960, 540] + shapeName: rect + fill: + type: gradient + gradientType: linear + angle: 0 + stops: + - {position: 0, color: "#00000000"} + - {position: 0.45, color: "#00000059"} + - {position: 1, color: "#000000E6"} + - elementId: label + elementType: text + bounds: [520, 48, 380, 22] + content: + style: "$label" + align: [right, middle] + text: "SMART CABIN · 智能座舱" + - elementId: title + elementType: text + bounds: [520, 88, 380, 56] + content: + style: "$pageTitle" + align: [right, middle] + text: "澎湃智能座舱" + - elementId: lead + elementType: text + bounds: [520, 148, 380, 40] + content: + fontSize: 14 + color: "$w70" + align: [right, middle] + lineHeight: 1.6 + text: "小米澎湃 OS · 人车家全生态,科技、豪华与舒适皆备" + - elementId: c1-title + elementType: text + bounds: [520, 214, 380, 28] + content: + fontSize: 17 + color: "$white" + bold: true + align: [right, middle] + letterSpacing: 1 + text: "小米天际屏全景显示" + - elementId: c1-desc + elementType: text + bounds: [520, 244, 380, 34] + content: + fontSize: 12 + color: "$w70" + align: [right, middle] + lineHeight: 1.6 + text: "三块 Mini LED · 1.1m 超宽 · 108PPD · 1200nits" + - elementId: c2-title + elementType: text + bounds: [520, 288, 380, 28] + content: + fontSize: 17 + color: "$white" + bold: true + align: [right, middle] + letterSpacing: 1 + text: "第三代骁龙8 移动平台" + - elementId: c2-desc + elementType: text + bounds: [520, 318, 380, 34] + content: + fontSize: 12 + color: "$w70" + align: [right, middle] + lineHeight: 1.6 + text: "4nm 制程 · 开机快、启动快、升级更快" + - elementId: c3-title + elementType: text + bounds: [520, 362, 380, 28] + content: + fontSize: 17 + color: "$white" + bold: true + align: [right, middle] + letterSpacing: 1 + text: "超级小爱 · 五音区语音交互" + - elementId: c3-desc + elementType: text + bounds: [520, 392, 380, 34] + content: + fontSize: 12 + color: "$w70" + align: [right, middle] + lineHeight: 1.6 + text: "端侧大模型 · 全车声纹识别 · 车外语音控制" + - elementId: c4-title + elementType: text + bounds: [520, 436, 380, 28] + content: + fontSize: 17 + color: "$white" + bold: true + align: [right, middle] + letterSpacing: 1 + text: "25 扬声器豪华音响系统" + - elementId: c4-desc + elementType: text + bounds: [520, 466, 380, 34] + content: + fontSize: 12 + color: "$w70" + align: [right, middle] + lineHeight: 1.6 + text: "支持杜比全景声 · 私享音乐座舱" + - elementId: note + elementType: text + bounds: [60, 492, 440, 20] + content: + fontSize: 11 + color: "$w50" + text: | + 图源:小米汽车官网 · 配置区分车型版本 + - elementId: page-num + elementType: text + bounds: [850, 500, 60, 20] + content: + fontSize: 11 + color: "$w50" + align: [right, middle] + text: "05 / 08" diff --git a/example/yu7-ppt/pages/6_had.page b/example/yu7-ppt/pages/6_had.page new file mode 100644 index 0000000..68447e6 --- /dev/null +++ b/example/yu7-ppt/pages/6_had.page @@ -0,0 +1,159 @@ +pageType: content +background: + type: image + src: "media/bg_had.jpg" + fit: {mode: cover} +elements: + - elementId: overlay + elementType: shape + bounds: [0, 0, 960, 540] + shapeName: rect + fill: + type: gradient + gradientType: linear + angle: 90 + stops: + - {position: 0, color: "#000000E6"} + - {position: 0.7, color: "#000000A6"} + - {position: 1, color: "#00000073"} + - elementId: label + elementType: text + bounds: [60, 48, 520, 22] + content: + style: "$label" + text: "XIAOMI HAD · 智能驾驶" + - elementId: title + elementType: text + bounds: [60, 88, 700, 56] + content: + style: "$pageTitle" + text: "全场景智能驾驶,AI 全面赋能" + - elementId: lead + elementType: text + bounds: [60, 148, 620, 44] + content: + fontSize: 14 + color: "$w70" + lineHeight: 1.7 + text: | + 自研底层算法,坚持长期投入;全系标配旗舰级辅助驾驶硬件, + 让驾驶更聪明、更安全。 + - elementId: s1-line + elementType: shape + bounds: [60, 236, 40, 3] + shapeName: rect + fill: {type: solid, color: "$primary"} + - elementId: s2-line + elementType: shape + bounds: [270, 236, 40, 3] + shapeName: rect + fill: {type: solid, color: "$primary"} + - elementId: s3-line + elementType: shape + bounds: [480, 236, 40, 3] + shapeName: rect + fill: {type: solid, color: "$primary"} + - elementId: s4-line + elementType: shape + bounds: [690, 236, 40, 3] + shapeName: rect + fill: {type: solid, color: "$primary"} + - elementId: sensor1 + elementType: text + bounds: [60, 252, 180, 56] + content: + align: [left, middle] + text: | +

1

+ - elementId: sensor1-label + elementType: text + bounds: [60, 314, 180, 22] + content: + fontSize: 14 + color: "$w85" + text: "激光雷达 · 全系标配" + - elementId: sensor2 + elementType: text + bounds: [270, 252, 180, 56] + content: + align: [left, middle] + text: | +

1

+ - elementId: sensor2-label + elementType: text + bounds: [270, 314, 180, 22] + content: + fontSize: 14 + color: "$w85" + text: "4D 毫米波雷达" + - elementId: sensor3 + elementType: text + bounds: [480, 252, 180, 56] + content: + align: [left, middle] + text: | +

11

+ - elementId: sensor3-label + elementType: text + bounds: [480, 314, 180, 22] + content: + fontSize: 14 + color: "$w85" + text: "高清摄像头" + - elementId: sensor4 + elementType: text + bounds: [690, 252, 180, 56] + content: + align: [left, middle] + text: | +

12

+ - elementId: sensor4-label + elementType: text + bounds: [690, 314, 180, 22] + content: + fontSize: 14 + color: "$w85" + text: "超声波雷达" + - elementId: point1 + elementType: text + bounds: [60, 372, 400, 36] + content: + fontSize: 14 + color: "$w70" + lineHeight: 1.6 + text: | +

NVIDIA DRIVE AGX Thor™ 车载计算平台

+ - elementId: point2 + elementType: text + bounds: [60, 410, 400, 36] + content: + fontSize: 14 + color: "$w70" + lineHeight: 1.6 + text: | +

7 颗超透防眩摄像头,暗光环境、异形障碍物看得更准

+ - elementId: point3 + elementType: text + bounds: [60, 448, 400, 36] + content: + fontSize: 14 + color: "$w70" + lineHeight: 1.6 + text: | +

支持自动紧急转向辅助 · 全项低速防碰撞辅助

+ - elementId: note + elementType: text + bounds: [60, 492, 640, 20] + content: + fontSize: 11 + color: "$w50" + text: | + 来源:小米汽车官网 · 辅助驾驶不能代替驾驶员,请随时注意路况 + - elementId: page-num + elementType: text + bounds: [850, 500, 60, 20] + content: + fontSize: 11 + color: "$w50" + align: [right, middle] + text: "06 / 08" diff --git a/example/yu7-ppt/pages/7_safety.page b/example/yu7-ppt/pages/7_safety.page new file mode 100644 index 0000000..ef8c231 --- /dev/null +++ b/example/yu7-ppt/pages/7_safety.page @@ -0,0 +1,164 @@ +pageType: content +background: + type: image + src: "media/bg_safety.jpg" + fit: {mode: cover} +elements: + - elementId: overlay + elementType: shape + bounds: [0, 0, 960, 540] + shapeName: rect + fill: + type: gradient + gradientType: linear + angle: 90 + stops: + - {position: 0, color: "#000000E6"} + - {position: 0.35, color: "#0000008C"} + - {position: 0.75, color: "#00000059"} + - {position: 1, color: "#000000CC"} + - elementId: label + elementType: text + bounds: [60, 48, 520, 22] + content: + style: "$label" + text: "SAFETY · 安全设计" + - elementId: title + elementType: text + bounds: [60, 88, 700, 56] + content: + style: "$pageTitle" + text: "铠甲笼式防护,全面可靠" + - elementId: lead + elementType: text + bounds: [60, 148, 700, 44] + content: + fontSize: 14 + color: "$w70" + lineHeight: 1.7 + text: | + 以中欧双五星、中保研 3G+ 为设计目标,被动安全保护 720° 全面升级。 + - elementId: a1-line + elementType: shape + bounds: [60, 240, 40, 3] + shapeName: rect + fill: {type: solid, color: "$primary"} + - elementId: a2-line + elementType: shape + bounds: [270, 240, 40, 3] + shapeName: rect + fill: {type: solid, color: "$primary"} + - elementId: a3-line + elementType: shape + bounds: [480, 240, 40, 3] + shapeName: rect + fill: {type: solid, color: "$primary"} + - elementId: a4-line + elementType: shape + bounds: [690, 240, 40, 3] + shapeName: rect + fill: {type: solid, color: "$primary"} + - elementId: s1-num + elementType: text + bounds: [60, 256, 180, 56] + content: + align: [left, middle] + text: | +

2200 MPa

+ - elementId: s1-label + elementType: text + bounds: [60, 318, 180, 22] + content: + fontSize: 14 + color: "$w85" + text: "小米超强钢" + - elementId: s1-desc + elementType: text + bounds: [60, 344, 180, 44] + content: + fontSize: 12 + color: "$w50" + lineHeight: 1.5 + text: "内嵌式防滚架 · A 柱承载 +35%" + - elementId: s2-num + elementType: text + bounds: [270, 256, 180, 56] + content: + align: [left, middle] + text: | +

20

+ - elementId: s2-label + elementType: text + bounds: [270, 318, 180, 22] + content: + fontSize: 14 + color: "$w85" + text: "主动安全配置" + - elementId: s2-desc + elementType: text + bounds: [270, 344, 180, 44] + content: + fontSize: 12 + color: "$w50" + lineHeight: 1.5 + text: "全系标配 · 全项低速防碰撞辅助" + - elementId: s3-num + elementType: text + bounds: [480, 256, 180, 56] + content: + align: [left, middle] + text: | +

50 项+

+ - elementId: s3-label + elementType: text + bounds: [480, 318, 180, 22] + content: + fontSize: 14 + color: "$w85" + text: "被动安全开发测试" + - elementId: s3-desc + elementType: text + bounds: [480, 344, 180, 44] + content: + fontSize: 12 + color: "$w50" + lineHeight: 1.5 + text: "覆盖 C-NCAP / C-IASI 全工况" + - elementId: s4-num + elementType: text + bounds: [690, 256, 180, 56] + content: + align: [left, middle] + text: | +

12.1

+ - elementId: s4-label + elementType: text + bounds: [690, 318, 180, 22] + content: + fontSize: 14 + color: "$w85" + text: "电池气凝胶隔热" + - elementId: s4-desc + elementType: text + bounds: [690, 344, 180, 44] + content: + fontSize: 12 + color: "$w50" + lineHeight: 1.5 + text: "防弹涂层 · 18 层硬核物理防护" + - elementId: note + elementType: text + bounds: [60, 492, 700, 20] + content: + fontSize: 11 + color: "$w50" + text: | + 来源:小米汽车官网 · 数据来自小米汽车实验室,配置区分车型版本 + - elementId: page-num + elementType: text + bounds: [850, 500, 60, 20] + content: + fontSize: 11 + color: "$w50" + align: [right, middle] + text: "07 / 08" diff --git a/example/yu7-ppt/pages/8_space_final.page b/example/yu7-ppt/pages/8_space_final.page new file mode 100644 index 0000000..2c43805 --- /dev/null +++ b/example/yu7-ppt/pages/8_space_final.page @@ -0,0 +1,141 @@ +pageType: final +background: + type: image + src: "media/bg_final.jpg" + fit: {mode: cover} +elements: + - elementId: overlay + elementType: shape + bounds: [0, 0, 960, 540] + shapeName: rect + fill: + type: gradient + gradientType: linear + angle: 90 + stops: + - {position: 0, color: "#00000070"} + - {position: 0.55, color: "#000000AD"} + - {position: 1, color: "#000000F0"} + - elementId: label + elementType: text + bounds: [60, 46, 520, 22] + content: + style: "$label" + text: "SPACE & ECOLOGY · 空间与生态" + - elementId: title + elementType: text + bounds: [60, 84, 720, 56] + content: + style: "$pageTitle" + text: "全能实力,从容远行" + - elementId: lead + elementType: text + bounds: [60, 144, 720, 30] + content: + fontSize: 14 + color: "$w70" + text: "宽敞空间、多变场景与全场景生态,让出行不再精打细算" + - elementId: st1-line + elementType: shape + bounds: [60, 208, 40, 3] + shapeName: rect + fill: {type: solid, color: "$primary"} + - elementId: st2-line + elementType: shape + bounds: [300, 208, 40, 3] + shapeName: rect + fill: {type: solid, color: "$primary"} + - elementId: st3-line + elementType: shape + bounds: [540, 208, 40, 3] + shapeName: rect + fill: {type: solid, color: "$primary"} + - elementId: space1-num + elementType: text + bounds: [60, 224, 200, 56] + content: + align: [left, middle] + text: | +

141 L

+ - elementId: space1-label + elementType: text + bounds: [60, 286, 200, 22] + content: + fontSize: 14 + color: "$w85" + text: "电动前备箱" + - elementId: space2-num + elementType: text + bounds: [300, 224, 200, 56] + content: + align: [left, middle] + text: | +

1970 L

+ - elementId: space2-label + elementType: text + bounds: [300, 286, 200, 22] + content: + fontSize: 14 + color: "$w85" + text: "最大储物容积" + - elementId: space3-num + elementType: text + bounds: [540, 224, 200, 56] + content: + align: [left, middle] + text: | +

36

+ - elementId: space3-label + elementType: text + bounds: [540, 286, 200, 22] + content: + fontSize: 14 + color: "$w85" + text: "全车收纳位" + - elementId: eco + elementType: text + bounds: [60, 340, 840, 34] + content: + fontSize: 15 + color: "$w85" + text: | +

基于小米澎湃 OS 的人车家全生态 · 磁吸生态配件 · 车顶滑轨 · 27W 供电螺纹接口

+ - elementId: gt-line + elementType: shape + bounds: [60, 392, 56, 3] + shapeName: rect + fill: {type: solid, color: "$primary"} + - elementId: gt + elementType: text + bounds: [60, 404, 620, 34] + content: + fontSize: 17 + color: "$white" + bold: true + letterSpacing: 1 + text: "YU7 GT 即将预售 —— 纽博格林北环赛道 SUV 圈速新纪录" + - elementId: cta + elementType: text + bounds: [60, 448, 420, 36] + content: + fontSize: 20 + color: "$primary" + bold: true + letterSpacing: 2 + text: "立即预约试驾 →" + - elementId: note + elementType: text + bounds: [60, 492, 700, 20] + content: + fontSize: 11 + color: "$w50" + text: | + 图源与数据:小米汽车官网 xiaomiev.com/yu7 + - elementId: page-num + elementType: text + bounds: [850, 500, 60, 20] + content: + fontSize: 11 + color: "$w50" + align: [right, middle] + text: "08 / 08" diff --git a/example/yu7-ppt/yu7.pptd b/example/yu7-ppt/yu7.pptd new file mode 100644 index 0000000..729f580 --- /dev/null +++ b/example/yu7-ppt/yu7.pptd @@ -0,0 +1,52 @@ +version: v2 +title: 小米 YU7 · 豪华高性能纯电 SUV +size: [960, 540] +theme: + colors: + primary: "#FF6900" + white: "#FFFFFF" + w85: "#FFFFFFD9" + w70: "#FFFFFFB3" + w60: "#FFFFFF99" + w50: "#FFFFFF80" + w40: "#FFFFFF66" + dark: "#000000" + textStyles: + label: + fontSize: 12 + color: "$primary" + bold: true + letterSpacing: 4 + pageTitle: + fontSize: 42 + color: "$white" + bold: true + letterSpacing: 1 + coverTitle: + fontSize: 86 + color: "$white" + bold: true + letterSpacing: 8 + body: + fontSize: 15 + color: "$w70" + lineHeight: 1.6 + statNum: + fontSize: 38 + color: "$white" + bold: true + statLabel: + fontSize: 14 + color: "$w85" + note: + fontSize: 11 + color: "$w50" +pages: + - pages/1_cover.page + - pages/2_overview.page + - pages/3_exterior.page + - pages/4_performance.page + - pages/5_cabin.page + - pages/6_had.page + - pages/7_safety.page + - pages/8_space_final.page diff --git a/example/yu7-ppt/yu7.pptx b/example/yu7-ppt/yu7.pptx new file mode 100644 index 0000000..bd83e4f Binary files /dev/null and b/example/yu7-ppt/yu7.pptx differ diff --git a/lib/editor-server.js b/lib/editor-server.js new file mode 100644 index 0000000..e4164bc --- /dev/null +++ b/lib/editor-server.js @@ -0,0 +1,95 @@ +import { createReadStream, statSync } from "node:fs"; +import { createServer } from "node:http"; +import { dirname, extname, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const defaultEditorDirectory = resolve(packageRoot, "editor"); + +const contentTypes = new Map([ + [".css", "text/css; charset=utf-8"], + [".html", "text/html; charset=utf-8"], + [".js", "text/javascript; charset=utf-8"], + [".json", "application/json; charset=utf-8"], + [".svg", "image/svg+xml"], +]); + +function respond(response, statusCode, message) { + response.writeHead(statusCode, { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + }); + response.end(message); +} + +export function createEditorServer({ editorDirectory = defaultEditorDirectory } = {}) { + const root = resolve(editorDirectory); + const rootPrefix = `${root}${sep}`; + + return createServer((request, response) => { + if (request.method !== "GET" && request.method !== "HEAD") { + respond(response, 405, "Method Not Allowed"); + return; + } + + let pathname; + try { + pathname = decodeURIComponent(new URL(request.url ?? "/", "http://localhost").pathname); + } catch { + respond(response, 400, "Bad Request"); + return; + } + + if (pathname.endsWith("/")) pathname += "index.html"; + const filePath = resolve(root, `.${pathname}`); + if (filePath !== root && !filePath.startsWith(rootPrefix)) { + respond(response, 403, "Forbidden"); + return; + } + + let stats; + try { + stats = statSync(filePath); + } catch { + respond(response, 404, "Not Found"); + return; + } + + if (!stats.isFile()) { + respond(response, 404, "Not Found"); + return; + } + + response.writeHead(200, { + "Content-Type": contentTypes.get(extname(filePath).toLowerCase()) ?? "application/octet-stream", + "Content-Length": stats.size, + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + }); + + if (request.method === "HEAD") { + response.end(); + return; + } + + const stream = createReadStream(filePath); + stream.on("error", () => response.destroy()); + stream.pipe(response); + }); +} + +export function startEditorServer({ host = "127.0.0.1", port = 55173 } = {}) { + const server = createEditorServer(); + + return new Promise((resolvePromise, reject) => { + const onError = (error) => reject(error); + server.once("error", onError); + server.listen(port, host, () => { + server.off("error", onError); + const address = server.address(); + const actualPort = typeof address === "object" && address ? address.port : port; + resolvePromise({ server, url: `http://${host}:${actualPort}/` }); + }); + }); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a314116 --- /dev/null +++ b/package.json @@ -0,0 +1,50 @@ +{ + "name": "open-kimi-ppt-skills", + "version": "1.0.0", + "description": "Unofficial reverse-engineered Kimi Slides skill for AI coding agents with local PPTD editing and PPTX export.", + "license": "MIT", + "author": "binaryify", + "homepage": "https://github.com/binaryify/open-kimi-ppt-skill#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/binaryify/open-kimi-ppt-skill.git" + }, + "bugs": { + "url": "https://github.com/binaryify/open-kimi-ppt-skill/issues" + }, + "keywords": [ + "ppt", + "pptx", + "slides", + "presentation", + "pptd", + "kimi", + "agent-skill", + "codex", + "claude-code", + "cursor", + "codebuddy" + ], + "type": "module", + "bin": { + "open-kimi-ppt-skills": "bin/open-kimi-ppt-skills.js" + }, + "files": [ + "bin/", + "editor/", + "lib/", + "skills/open-kimi-ppt/SKILL.md", + "skills/open-kimi-ppt/reference/", + "skills/open-kimi-ppt/scripts/", + "README.md", + "README_EN.md" + ], + "scripts": { + "test": "node --test", + "pack:check": "npm pack --dry-run", + "prepublishOnly": "npm test" + }, + "engines": { + "node": ">=18" + } +} diff --git a/skills/open-kimi-ppt/SKILL.md b/skills/open-kimi-ppt/SKILL.md new file mode 100644 index 0000000..423de62 --- /dev/null +++ b/skills/open-kimi-ppt/SKILL.md @@ -0,0 +1,155 @@ +--- +name: open-kimi-ppt +description: Create, edit, replicate, read, and export presentations. For every PPT task, the default deliverables are BOTH (1) a self-contained PPTD project folder containing the .pptd manifest plus pages/media dependencies and (2) a locally generated .pptx with embedded fonts and fade slide transitions. Use for any presentation, PowerPoint, PPT/PPTX, slide deck, PPTD, infographic, or poster task unless the user explicitly requests another format. Deliver with normal local file/folder links using absolute paths. +--- + +# Definition +open-kimi-ppt is a presentation creation and export skill built around Moonshot AI's PPTD format and browser-side PPTX writer. It defines a YAML-format intermediate DSL (`.pptd`) that abstracts OOXML and keeps each page self-contained. + +**The default output is not PPTD-only.** Unless the user explicitly opts out, always produce both: + +1. the complete editable PPTD project directory (`.pptd` + `pages/` + `media/` and other referenced dependencies); +2. the matching locally generated `.pptx`, with font embedding enabled and fade slide transitions applied by default. + +Existing PPTX files may also be converted into PPTD for editing, after which both outputs are delivered again. + +## The pptd format +The .pptd format is a simplified abstraction layer over OOXML that follows basic YAML syntax. This abstraction preserves the core content of OOXML (theme, page layout, element positions and definitions, etc.) while removing complex nesting logic such as Masters; every page is self-contained — what you see is what you get. Read reference/pptd.md for the complete definition of this DSL. + +## PPT production workflow + +### 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. + +### step2. Understand the user's requirements +Understand the user's requirements based on the context: +1. First determine the purpose of the request + - Create a PPT: create a new presentation (from scratch, or from an existing pptx template) + - Edit a PPT: edit the user's uploaded PPT (local modifications, single-page beautification, etc.) + - Replicate a PPT: replicate a presentation from a non-pptx format (images, PDF, etc.) into pptd format + +2. Then determine the design direction + - Self-directed design: no preference, or only simple style constraints given; you need to fill in or create the design + - Design system: the user provides a complete and detailed design scheme covering all color, font, layout, and component specifications + - Use a template: a template is provided and must be used + - Style transfer: a style reference source is provided (images, web pages, etc.) + +3. Then determine the input type + - Topic only: only a PPT topic direction or content requirements for the presentation are given, with no concrete content + - Full document: the user provides a complete document (paper, research report, press release, etc.) + - Outline: the user provides a page-by-page outline, speech script, or similar content + * When the "user input type" is [Full document] or [Outline] and it is not specified whether expansion is allowed: since a page-by-page outline, speech script, or user document can hardly support the full content of a presentation, prefer using search to expand with more relevant material, cases, etc., unless the user explicitly says not to expand + +4. Page count + - If the user requests a specific page count, the user's requirement takes priority + - Page-by-page outline/script provided: match the number of pages in the outline/script + - When a complete and relatively structured document is provided / when only a topic is provided: decide the page count yourself based on the document content / search results + +### step3. Generate the presentation based on the user's requirements + +Before generating, first read `reference/pptd.md` to understand the pptd format definition and constraints. + +#### Replicating a PPT +- Analyze the images to estimate element positions, fonts and sizes, etc., and **replicate 1:1 as closely as possible**. +- When an image contains elements that are hard to replicate directly and cannot be approximated with icons/shapes (e.g., photos, avatars), you may use tools such as bash or python to crop and screenshot the original image + +#### Editing a PPT +- Convert the user's uploaded pptx file to .pptd format +- Review the converted pages (structure and key visual details). Read a few key pages individually afterwards. +- Locate the pages to edit, and be careful not to affect parts outside the intended scope. +> Conversion from pptx to pptd is not perfectly lossless. If the user later reports format errors, garbled content, etc., compare against the original pptx and repair the pptd with reference to the comparison + +#### Generating a PPT +When generating a PPT, adopt different production approaches for different user [design directions] +##### Self-directed design +1. Read the design guide `reference/slides_categories.md`, and read the scenario document corresponding to the user's query +2. Produce the presentation based on the above + +#### Generating content in other formats +- When the user explicitly asks for an infographic, poster, or a highly visual single-page design, read `reference/general-poster.md` and implement it as a single-page or few-page editable PPTD; when the user only asks for an image, still build it with PPTD first, then output the image via screenshot or rendering. Do not load this reference file for ordinary PPT requests. + +##### Design system +1. Read the general constraints section of the `reference/slides_categories.md` guide, and read the scenario document corresponding to the user's query as the design foundation +2. Read the user-provided design system document as the presentation style. It is strictly forbidden to reference or mix in other design styles +3. Produce the presentation with reference to the above + +##### Using a template +1. Convert the user's uploaded pptx file into pptd form +2. Review the converted pages to understand the template's visual style (color scheme, font style, element characteristics, layout characteristics, content density, etc.) +3. Identify page types; focus on reading special pages such as the cover, summary pages, and section dividers (single-page screenshots, .page files), extracting their page layouts, content structures, reusable components (icons, shapes, smartart, reusable body layout schemes, etc.), and element styles (e.g., whitespace/line/card separators, square/rounded corners, etc.) +4. Produce the presentation using the template + +##### Style transfer +1. Analyze the reference file's visual style (color scheme, font style, element characteristics, layout characteristics, content density, etc.), page layouts, content structures, reusable components (icons, shapes, smartart, reusable body layout schemes, etc.), and element styles (e.g., whitespace/line/card separators, square/rounded corners, etc.). +- If the user provides a style reference URL, do not only read the text content; refer to and learn from the page's visual effect more to help understand the style +2. Produce the presentation using the reference file's style characteristics. You are encouraged to reuse illustrations, fonts, font-size hierarchies, elements, etc. from the original pdf/url + +### 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: + + ```bash + python3 ~/.agents/skills/open-kimi-ppt/scripts/export_images.py \ + /abs/path/project/deck.pptd \ + --output /abs/path/project/.qa-images + ``` + + The script prints a JSON summary mapping each stitched label (`P1`…`Pn`, 1-based page order) to its `.page` file. + - Read the stitched overview image (`.qa-images/overview.jpg`) and check every page against this list: + 1. 图片是否清晰、不变形(无拉伸、压缩、模糊) + 2. 文字是否压在关键画面(人脸、产品主体、Logo 等)上 + 3. 元素坐标是否超出页面边界 + 4. 边界与配色对比是否足够(文字与背景、相邻色块之间) + 5. 排版是否统一(对齐、间距、字号层级、页边距) + 6. 文字是否可能溢出文本框(文本过长、行距过密、字号过大) + 7. 内容是否被上层元素遮挡 + - For any suspicious page, read its full-resolution image (`.qa-images/pages/.jpeg`) to confirm the problem before editing. + - Fix issues in the corresponding `.page` file, then re-run `scripts/export_images.py --force` and review the new overview; repeat until every page passes. + - Do not export the PPTX until the visual review passes. `.qa-images/` is an intermediate QA artifact and may be deleted after delivery. +3. When the model cannot read images, fall back to a structural review of the generated pages (bounds, overflow-prone long text, contrast, hierarchy, layout density) over multiple rounds, and state that image-based visual QA was skipped. + +### step5. PPT output and delivery +1. Always produce a self-contained project directory. Keep the `.pptd` manifest and every referenced dependency together; never deliver a standalone manifest without its referenced files. Use this layout unless an existing project already has a valid equivalent structure: + + ```text + deck/ + deck.pptd + pages/ + *.page + media/ + * # when the deck has local media + deck.pptx # generated by default + ``` + +2. Generate the `.pptx` by default after PPTD validation, even when the user only asks to create or edit a presentation. Skip PPTX export only when the user explicitly requests PPTD-only output or the environment cannot run the exporter; in the latter case, report the exact blocker and still deliver the complete PPTD project. +3. Deliver with normal clickable local links using absolute paths. In the final response, link all of the following: + - the project directory; + - 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. +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`. +6. Export command: + + ```bash + python3 ~/.agents/skills/open-kimi-ppt/scripts/export_pptx.py \ + /abs/path/project/deck.pptd \ + --output /abs/path/project/deck.pptx + ``` + + 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 `python3`, PyYAML, `npm`, `agent-browser`, Chrome/Chromium, and network access to `www.kimi.com` plus `statics.moonshot.cn`; the image-based visual QA step additionally requires Pillow, auto-installed with `pip --user` when missing; + - before browser export, `export_pptx.py` checks `agent-browser --version`; when it is missing or below `0.33.2`, it installs `agent-browser@latest` globally with npm, then verifies the resulting version before continuing; + - 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. +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 `` 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-skills 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. +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-skills 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. diff --git a/skills/open-kimi-ppt/reference/fonts.md b/skills/open-kimi-ppt/reference/fonts.md new file mode 100644 index 0000000..8a1b186 --- /dev/null +++ b/skills/open-kimi-ppt/reference/fonts.md @@ -0,0 +1,47 @@ +# Font system + +## Selection principles + +1. Language matching: when the user's query is in Chinese or a Chinese PPT deliverable is requested, both Chinese and English fonts need to be specified; otherwise, only English fonts need to be set +2. Selection approach: it is recommended to prioritize highly readable fonts for body text, and to use stylized fonts plus special treatments (all caps, widened letter spacing, bold, italics, etc.) in titles or special pages to strengthen the style +3. The font combination must support the overall visual style positioning +4. Name consistency: **make sure font names are exactly identical, including capitalization and spaces, so that the model can use them correctly** + +## Font list + +### English fonts + +| Font name | Font type | Style and characteristics | Suitable scenarios | Stylized font | +|---|---|---|---|---| +| Liter | Sans-serif | Modern neo-grotesque style; low contrast, balanced proportions, optimized for screens; clean and rational | Tech, product | No | +| HedvigLettersSans | Sans-serif | A "non-designer perspective" design; slightly irregular with a distinctive personality and strong brand character | Creative design, branding | No | +| Oranienbaum | Serif | Modern high-contrast serif; strongly geometric with elegant lines and a classical temperament | Culture, art, fashion | No | +| QuattrocentoSans | Sans-serif | Classic, elegant sans-serif; gentle, highly readable, clear at small sizes | Academic, corporate, education | No | +| SortsMillGoudy | Serif | Revival of the classical print style of Goudy Old Style; soft serifs, pleasant to read | Literature, humanities | No | +| Unna | Serif | Neoclassical serif with a pronounced vertical rhythm; elegant yet forceful | Literature, publishing, academia | Yes | +| Coda | Sans-serif | Rounded and friendly; soft curves, high openness | Business, friendly brand tone | Yes | +| Jersey15 | Sans-serif (sports style) | Sports jersey style; squared structure with a noticeable grid feel (supports English + digits only) | Sports, tech showcase pages | Yes | +| Jersey20Charted | Pixel font (grid shading) | Sports-number style with a shaded grid that reinforces an athletic texture (supports English + digits only) | Sports, mechanical, decorative showcase pages | Yes | + +### Chinese fonts + +| Font name | Font type | Style and characteristics | Suitable scenarios | Usage restriction | +|---|---|---|---|---| +| MiSans | Sans-serif (heiti) | Xiaomi system font; clean and modern, variable weights, excellent on-screen rendering (multilingual support) | Tech, corporate, product | No | +| Noto Sans SC | Sans-serif (heiti) | A branch of Source Han Sans; well-regulated structure, neutral style, extremely broad coverage. **Because it is so widely used, use it as little as possible** | Report-style | No | +| 思源宋体 | Serif (songti) | Source Han Serif; refined songti structure with stroke contrast; elegant reading experience (multilingual support) | Literature, design, formal presentations | No | +| 阿里妈妈刀隶体 | Calligraphy (clerical script) | Alimama DaoLi; clerical-script style with blade-like strokes; strength and archaic simplicity combined | China-chic, culture, art displays | No | +| 阿里妈妈东方大楷 | Calligraphy (regular script) | Alimama Dongfang Dakai; based on the Yan style of calligraphy; full and rounded, heavy and powerful | Culture, brand launches, guofeng (Chinese-style) themes | No | +| 阿里妈妈数黑体 | Sans-serif (heiti) | Alimama ShuHei; geometric heiti; uniform and regular with a strong commercial feel | Business, tech, e-commerce | No | +| 站酷文艺体 | Handwriting | Zcool WenYi; clean and fresh with a slight handwritten feel and a strong literary/artistic vibe | Light design, lifestyle | No | +| 飞波正点体 | Calligraphy (brush) | Feibo Zhengdian; brush-writing style with heavy strokes, full of power | Film posters, e-commerce, brand display | No | +| 得意黑 | Sans-serif (slanted heiti) | Smiley Sans; tall, slanted heiti combining humanist and geometric qualities with a strong modern feel. **Does not support a non-italic upright style** | Creative tech, brand display | No | +| 霞鹜新致宋 | Serif (songti) | LXGW XinZhiSong; based on IPAmj Mincho; bright, elegant, well-structured | Literature, classical style, print style | No | + +### Mixed CJK–Latin fonts + +| Font name | Font type | Style and characteristics | Suitable scenarios | Usage restriction | +|---|---|---|---|---| +| 精品点阵体 | Pixel font | Jingpin Dianzhen; 9×9 dot-matrix pixel style with an extremely retro electronic feel | Games, tech, pixel art | Yes | +| LXGW Bright | Serif (fangsong/kaiti) | LXGW WenKai family; combines fangsong and kaiti characteristics; gentle and clear letterforms | Literature, education, humanities | No | +| ZCOOL KuaiLe | Handwriting (rounded) | ZCOOL KuaiLe; lively, cute, playful and cartoonish; youthful energy | Anime, children, entertainment | No | diff --git a/skills/open-kimi-ppt/reference/general-poster.md b/skills/open-kimi-ppt/reference/general-poster.md new file mode 100644 index 0000000..870a6ba --- /dev/null +++ b/skills/open-kimi-ppt/reference/general-poster.md @@ -0,0 +1,241 @@ +# General Infographics and Posters + +Use this guide only when the user explicitly requests an infographic, poster, or a highly visual single-page design. Do not load this guide for ordinary PPT requests. + +This guide defines only the design decision process and quality baselines; it does not prescribe fixed color schemes, fonts, maps, timelines, grids, or page skeletons. Design independently based on the subject, audience, materials, and usage scenario; never copy a sample by default just because it is more detailed. + +## Production Workflow + +### 1. Understand the Subject and Communication Goals + +First confirm: + +- What this poster should make readers see, understand, remember, or do. +- The primary audience, viewing distance, display medium, and available reading time. +- The size, aspect ratio, colors, style, content, and output format explicitly requested by the user. +- The core relationship the material truly needs to express: time, space, process, contrast, hierarchy, object structure, or pure spirit/atmosphere conveyance. +- Whether the available images, data, and verifiable facts are sufficient to support the chosen form of expression. + +### 2. Explore Different Directions First, Then Choose One + +Before writing any `.page` file or placing elements, internally form 2–3 visual directions with substantive differences. They should differ in at least two of the following: + +- Primary visual focus: image, typography, object, data, map/relationship diagram, or abstract graphics. +- Information structure: linear narrative, radial hub, local zoom, layered section, juxtaposed comparison, image matrix, or a single scene. +- Spatial organization: full-bleed, off-axis, symmetric, high-density, generous whitespace, or zoned structure. + +At least one candidate direction should treat the entire canvas as one visual field, scene, or object relationship, rather than first splitting the content into containers of equal width and height. Cards may appear among the candidates, but must not automatically become the default just because the information has three or four items. + +Before finalizing a direction, compare which primary visual medium best carries the content. Whenever people, artifacts, materials, environments, or event scenes are important evidence for the subject, actually examine at least 2 existing images, search results, generated images, proofs, or reference screenshots that differ substantively in composition or visual language; whenever process, hierarchy, system, propagation, causality, contrast, or spatial relationships are central, compare at least 2 editable vector or SmartArt-like figure drafts with clearly different structures. After comparing subject recognizability, thumbnail memorability, information capacity, compositional coexistence, and editability, choose photography/real-object imagery, generated images, custom vectors, structured figures, or a hybrid solution. Do not default to pure vectors just because PPTD vectors are easier to implement or because searching/generating assets takes time, and do not force in images unrelated to the content just to "have assets." + +Choose one direction based on the subject's characteristics, the user's tone, asset quality, and the primary reading task. You don't need to deliver three designs, but don't start building the moment you see the first workable option. + +Available grammars include but are not limited to typography-led, photography-led, object/section annotation, archival collage, data field, spatial map, or relationship diagram. These are angles for thinking, not templates that must be applied. + +### 3. Write `style.md` in the Project + +For a newly created poster/infographic, create a brief `style.md` in the PPTD project root before writing `.page` files. It is the design brief for the current task — not a new skill file, not a hard process gate, and not an extra deliverable. + +Review the user's assets, actual image candidates, and the fonts available in `reference/fonts.md` before finalizing `style.md`. Keep the content short, specific, and actionable; it typically includes: + +```markdown +# Style Brief +- Goal and audience: +- Output scenario and canvas: +- Core message in one sentence: +- Chosen visual concept/metaphor: +- Primary visual focus: +- Poster-level visual move (why this is not an ordinary content page): +- Visual DNA (background/material, typographic/geometric temperament, how images coexist with structured information): +- Primary visual medium and asset/SmartArt-like figure candidates compared, with reasons for the choice: +- Reading order and composition: +- Color scheme and each color's role: +- Chinese and English fonts, size hierarchy, and usage: +- Image selection, cropping, and treatment: +- Graphics/chart/connector language (if needed): +- Information density, whitespace, and alignment: +- Tropes to avoid: +``` + +- Explain why this direction was chosen, and record in one sentence the main reason the other directions were not chosen; also state which trope the chosen direction is most likely to degenerate into (for example, an ordinary technical report, a retro propaganda page, a commercial promotion, or a uniform card wall), so it can be checked against during screenshot review. +- When the user has already provided a complete design system, template, or strong style constraints, `style.md` only converts them into actionable decisions; do not invent new styles that conflict with the user's requirements. +- After viewing actual images or discovering asset limitations, you may update `style.md` once before refining the pages. Do not turn it into a lengthy process log. +- Creating a new `style.md` is not mandatory when making local edits to an existing artifact; it is only needed when redesigning the overall visual. + +## Canvas Ratio and Orientation + +Determine the output scenario and canvas first, then design the grid, font sizes, and content structure. Do not equate a poster with portrait orientation by default, and do not default to inheriting PPT's 16:9. + +1. When the user explicitly specifies a size, orientation, or ratio, strictly use the user's requirement. +2. When the user has not specified, choose on your own based on the deployment scenario, information richness, primary reading order, and relationship structure. +3. When there is too much information, prioritize distilling, layering, or splitting into a few pages; do not cram everything into a single page with extremely small font sizes. +4. Once the canvas is chosen, compose directly for that ratio; do not build it in 16:9 first and then stretch or crop the whole thing into another ratio. + +| Common ratio | PPTD reference size | Common scenarios | +|---|---:|---| +| 9:16 portrait | `[540, 960]` | Mobile reading, social media, vertical narrative | +| 3:4 portrait | `[720, 960]` | Print-like posters, person/object hero visuals | +| 1:1 square | `[720, 720]` | Social media, centered structures, moderate information volume | +| 4:3 landscape | `[720, 540]` | Higher-density text-image content, document embedding | +| 16:9 landscape | `[960, 540]` | Widescreen display, horizontal relationships and parallel multi-column layouts | + +A PPTD canvas can use any `[width, height]`; the sizes in the table are references, not enumerated restrictions. + +## Content Relationships and Graphic Selection + +First state the page's core message in one sentence, then choose the graphic that best expresses it. Use a structure diagram only when the relationship itself is central; do not add maps, timelines, routes, flowcharts, matrices, or indexes just to look rich. Parallel relationships do not automatically equal cards: first consider groups of objects in the same scene, sequences of images/symbols, scale differences, spatial adjacency, color bands, rule lines, or typographic rhythm. + +When the core message is process, hierarchy, system, propagation, causality, contrast, or spatial relationships, treat structured graphics (SmartArt-like figures), maps, routes, or method diagrams as primary visual material. The chosen structure diagram should occupy enough area and participate in the whole-page composition, implemented with editable PPTD elements where possible; generating native OOXML SmartArt objects is not required, but do not let it degenerate into a row of rounded boxes, generic icons, and thin arrows just for the sake of "editability." It can form one continuous visual field together with the main object, scene imagery, close-up details, and anchored captions. + +| Core relationship | Possible expressions | +|---|---| +| Time evolution, stage changes | Timeline, milestones, before/after states | +| Paths, propagation, migration | Maps, routes, flows, or networks | +| Steps, mechanisms, methods | Process, swimlanes, input–process–output | +| Differences between objects or viewpoints | Juxtaposed comparison, matrix, scale axis | +| Hierarchy, composition, systems | Tree hierarchy, architecture, layered section | +| Physical objects, products, paper method details | Object annotation, local zoom, visual anatomy | +| Multiple facts or samples | Image matrix, taxonomy map, data field | +| Emotion, ideas, attitudes | Single strong image, pure typographic composition, abstract form | + +- When the user explicitly requests a "map," provide a geographic base or outline that can establish spatial relationships; do not pass off an abstract polyline as a map. +- For routes, propagation, and migration, choose on your own among a full-bleed map, a local locator, a radial network, land-sea layering, or multiple branch routes based on semantics; do not default to thin horizontal lines. +- For architecture diagrams, UML, and paper method figures, first ensure the nodes, interfaces, directions, grouping, and legends are correct before visual beautification. + +## Design Freedom and Quality Baselines + +- Colors, fonts, image treatment, and composition arise from the subject and `style.md`. Unless the user explicitly requests it, do not default to black-white-silver-gray, a single-point fluorescent accent, giant cropped type, archival micro-labels, or thin-line timelines. +- A poster should first establish one dominant visual grammar, with other elements serving it. Do not cram the hero image, giant type, map, timeline, icon matrix, and data charts in all at once just to seem "rich." +- Establish at least one visual memory point that remains recognizable in a full-page thumbnail or at a distance, carried by the hero image, giant type, a large object, a full-bleed color field, a scene, or the core relationship diagram. If the thumbnail shows only a title and a set of regular boxes, recompose. +- When producing multiple pieces on different subjects in succession, do not default to reusing the previous one's main composition, accent-color roles, image proportion, and linear graphics. +- Grids, whitespace, cards, collages, asymmetry, or full-bleed are all optional means, not the default correct answer. Avoid stuffing every category of content below the title into equal-width, equal-height rectangles with rounded corners, outlines, or shadows, forming a "three-card/four-card + CTA" slide or app-interface skeleton. Use containers only when the container itself has clear semantics and will not form a uniform card wall; otherwise prefer organizing content with layout zones, proportion, color blocks, rule lines, spatial adjacency, object groups, or typographic rhythm. +- A page may have very little text, or it may have several high-density modules; but the core message, primary visual focus, and reading path must be clear. Organize rich content primarily through scale hierarchy, local zooms, captions, and spatial relationships; do not disguise sparse content as rich with repeated cards or oversized padding, and do not compress a large number of facts into unreadably small text. +- Body text, annotations, legends, and sources must be readable in the actual output scenario. When there is too much information, cut, layer, or split pages; do not solve it with tiny font sizes. +- Data, facts, dates, locations, labels, units, and sources must stay faithful to the user's materials or verifiable information. For precise figures that cannot be confirmed, use qualitative statements, approximations, or omit them; do not fabricate statistics, coordinates, issue numbers, or publication details for an "archival feel." + +### Few-shot: Multiple Complete Briefs and Anonymous Visual-Move Fragments + +The following complete samples are used to expand the design moves, primary visual media, and risk awareness you can draw on; they are not fixed visual families, subject mappings, or templates awaiting selection. The concept name, visual DNA, colors, fonts, canvas, and composition of an actual brief must be re-derived from the current subject, assets, and reading relationships; you may recombine a few compatible moves across samples, or devise your own entirely — do not just pick the most similar label and copy the whole set. + +#### Poster Editorial + +```markdown +# Style Brief — Poster Editorial +- Goal and audience: designed for quick browsing and short dwell; make the subject memorable first, then let readers discover archival details. +- Output scenario and canvas: choose landscape or portrait based on the actual medium; do not take a 16:9 layout and crop it. +- Core message: string together the object, time, and context with one judgment that takes a position; do not use section-style empty titles. +- Visual concept: "a contemporary, editorially curated object archive"; calm, sharp, with print tension, without imitating a real magazine cover. +- Primary visual focus: choose only one of three as the protagonist — a high-contrast object image, an object silhouette, or giant type. +- Reading order and composition: use a rigorous grid with a clear dense-versus-sparse counterpoint; the title, exhibit item, and micro captions form three scales, but not every poster is required to have a route or timeline. +- Color: Editorial White `#F7F6F2` and Deep Black `#0B0B0B` carry the main relationship; silver-gray is used only for secondary information; choose at most one small-area accent from Signal Red `#F20505`, Fluoro Lime `#D6FF00`, or Acid Orange `#FF4A1C`. +- Fonts: the main title uses 得意黑/阿里妈妈数黑体 with HedvigLettersSans; the body uses MiSans/Liter; at most three font families, and the numbering font does not carry body text. +- Image treatment: prefer images with strong contours, close-ups, and room for bold cropping; they may be converted to black and white or desaturated, but keep the object's recognition points. +- Graphic language: use hairlines, short labels, and small nodes only when content relationships need them; maps must have a spatial base, and data charts must have real data. +- Information density: keep one clear visual breathing area; archival details may be dense, but body text and captions must be readable at the actual output size. +- Must avoid: rounded-card walls, evenly distributed accent colors, fake logos/issue numbers/archives, and stacking giant type, a map, a timeline, and an icon matrix all at once just to seem rich. +- Reason for choosing: when the subject needs to be highly memorable while retaining traceable details, this direction is superior to a generic commercial infographic. +``` + +#### Geometric Composition + +```markdown +# Style Brief — Geometric Composition +- Visual concept: translate the core concept into tension among circles, squares, lines, proportions, and glyphs; do not use geometric blocks to decorate empty space. +- Primary visual focus: one geometric relationship or glyph composition occupying the main area, not multiple evenly distributed colored rectangles. +- Poster-level visual move: let the title pass through, embed into, or cut across the main geometry, so text and composition together become the image, instead of the title hovering above the content. +- Visual DNA: decide color roles and typographic temperament from the subject; establish order with proportion, axes, numbering, and scale, with factual information taking its place along the geometric relationships. +- Information organization: bind concept differences, proportions, stages, or a work index to shape relationships; secondary material is tucked into the edges or a narrow index band. +- Must avoid: evenly tiling primary colors, a children's-building-block feel, random circles and squares unrelated to the content, and degenerating the geometric composition into a colored card wall. +``` + +#### Material Evidence + +```markdown +# Style Brief — Material Evidence +- Visual concept: treat materials, artifacts, surface traces, or the making process as evidence, letting tactile quality and provenance relationships tell the content together. +- Primary visual focus: a sufficiently large macro, scan, section, or artifact detail; other images relate to it as slices, sequences, or provenance evidence. +- Poster-level visual move: let the texture or object span the main extent of the canvas, with captions anchored directly to visible details; do not shrink high-quality assets into specimen cards. +- Visual DNA: extract color, grain, and line language from the actual object; fonts and labels serve the material's temperament; maps, genealogies, or processes appear only when relationships need them. +- Information organization: organize facts through source labels, local zooms, material genealogy, craft steps, or propagation paths, keeping one clear breathing area. +- Must avoid: uniform brown retro templates, travel souvenir albums, forged old archives, decorative seals, and a row of equally sized object cards. +``` + +#### Dark-Field Signals + +```markdown +# Style Brief — Dark-Field Signals +- Visual concept: in a dark field, use localized light sources, trajectories, scales, or rhythmic signals to establish a sense of direction; black is space, not background fill. +- Primary visual focus: one brightly lit object, motion image, glowing trail, or high-contrast glyph form, with other elements building distance and speed around it. +- Poster-level visual move: let a signal line, path, or band of light cross the canvas and connect fact nodes, so the subject and direction of motion remain recognizable from a distance. +- Visual DNA: accent colors serve only nodes, directions, numbers, or warnings; numeric fonts, coordinates, and scanning elements must serve reading rather than create tech noise. +- Information organization: organize route, time, frequency, cohort, or status data along a common coordinate field; keep a stable high-contrast safe zone for body text. +- Must avoid: full-page neon, bloom effects, cyber dashboards, small gray text on black, and packing every data group into a glowing panel. +``` + +#### Scientific Observation + +```markdown +# Style Brief — Scientific Observation +- Visual concept: treat the page as a verifiable observation field, using objects, sections, relationships, and scale to explain a mechanism, rather than presenting a popular-science column. +- Primary visual focus: a main specimen, an ecological section, a system relationship, or a local zoom; readers see the object first, then understand the mechanism along the annotations. +- Poster-level visual move: let scale, depth, hierarchy, flow direction, or causality run through the whole page, with imagery and legend sharing the same spatial coordinates. +- Visual DNA: colors distinguish objects, relationships, and risks; annotation lines, units, legends, sources, and confidence boundaries stay readable and checkable. +- Information organization: mechanisms, specimens, and evidence are organized through spatial adjacency and connecting relationships; use a small amount of comparison when necessary, and do not default to three-column cards. +- Must avoid: technical-report front pages, white dashboards, equal-width info boxes, decorative data, and unverifiable precise figures. +``` + +#### Dynamic Collage + +```markdown +# Style Brief — Dynamic Collage +- Visual concept: use directional glyphs, cropped imagery, color bands, and event fragments to create a manifesto feel, with rhythm coming from content conflicts rather than decorative noise. +- Primary visual focus: one boldly cropped title, figure/object silhouette, or high-contrast image; do not let multiple assets compete evenly for attention. +- Poster-level visual move: interweave the title, imagery, and a diagonal or offset structure to form a direction and posture recognizable from a distance. +- Visual DNA: maintain one dominant contrast relationship; collage edges, numbering, event nodes, and short phrases share a unified logic of angle, weight, and spacing. +- Information organization: compress viewpoints, impacts, events, or stages into a few directional band sequences; body text returns to a stable grid to stay readable. +- Must avoid: promotional ads, price tags, meaningless torn paper, slogan stacking, too many angles, and enlarging all text at once. +``` + +#### Detachable Anonymous Move Fragments + +The following provide only local compositional knowledge, not complete directions; do not name the fragments, and do not match subjects to them one by one. First state the current page's core message and dominant relationship, then adapt one or two of these moves as needed, or devise your own entirely: + +- Let the title pass through, embed into, or cut across a main geometry that carries information; facts take their place along proportions, axes, numbering, or shape relationships — the geometry is not just space-filling decoration. +- Let objects, textures, macros, or sections span the main extent of the canvas, with captions anchored directly to visible details; other images form slice, sequence, or provenance relationships rather than shrinking into a row of specimen cards. +- Let routes, light bands, or motion trails cross the canvas and connect fact nodes while retaining the necessary geographic, scene, branch, and time information; paths can be formed by imagery, terrain, or spatial relationships rather than defaulting to thin-line charts. +- Let specimens, sections, scale, legends, and causal relationships share one continuous observation field; when immersion matters, preserve the continuity of the environment rather than degenerating into a "main image left + evidence box right" technical report. +- Interweave a recognizable title or object, cropped imagery, and a directional structure; events and viewpoints unfold along a unified rhythm, and avoid letting the title's recognizability drown in collage noise. + +In an actual task, regenerate `style.md` from the subject, assets, and reading relationships, using a concept name unique to the current task. Do not first look through the fragments for the "most similar category," and do not write "selected the so-and-so family" in the brief. Keep one dominant visual grammar, with other moves only as compatible support; the final direction must explain why it suits the current content, rather than merely swapping the sample's subject text and accent color. + +## Imagery, Copyright, and Authenticity + +- Prefer images with a clear subject, usable room for cropping, and genuine semantic relevance to the content. Ensure objects and evidence are correct before stylizing. +- When relying on images to determine composition, actually view the candidate images before deciding subject placement, crop lines, and text safe areas; do not decide from keywords or imagination alone. +- Once a good enough candidate image has been found or generated, let it truly enter the main composition; do not swap specific people, artifacts, materials, or environments back into generic vector icons, silhouettes, or schematic stand-ins just to save implementation effort. +- When choosing photography or real-object imagery as the primary visual grammar, let key images occupy enough area on the canvas to create a memorable impression; do not shrink all high-quality assets into small circular images inside cards or decorative thumbnails. Multiple images can form an overall relationship through cropping, overlapping, matrixing, sequencing, or local zoom. +- When choosing custom vectors as the primary visual grammar, let contours, proportions, connections, scale, or annotations express the specific information of the current subject; do not pass off generic icons, default process boxes, or decorative geometry as content. Complex vector figures should likewise have a clear visual focus and a thumbnail memory point. +- When mixing imagery and vectors, define the division of labor clearly: imagery carries object evidence, materials, people, or environments; vectors carry relationships, paths, scale, legends, and annotations — and anchor them to each other within the same composition, rather than splitting into two unrelated blocks above and below. +- Use self-owned, licensed, officially citable, or generated images. Do not forge real magazines, institutions, brand logos, issue numbers, watermarks, or attribution. +- AI images must not pose as historical archives, scientific evidence, or real data. Label them "AI-generated illustration" when necessary. + +## Editable Delivery and Verification + +- Keep text, shapes, connectors, data labels, and legends as editable PPTD elements as much as possible; do not merge the entire infographic into a single bitmap. +- When the user only wants images such as PNG or JPG, still complete the editable source in PPTD first, then screenshot or render it to an image. +- Check out-of-bounds, overflow, overlap, and structural issues against the pptd format rules; visually review focus, reading order, contrast, font sizes, and image cropping. +- Look at the full-page overview first, then the details: the overview should reveal the subject and at least one visual memory point; if the main impression is a title, regular cards, and button-like color blocks, restructure first instead of only fine-tuning font sizes and spacing. +- After fixing problem pages, validate and review again, and only then package the PPTX or output images. + +## Pre-Production Checklist + +- Have you explored different directions based on the subject and written a specific, actionable `style.md` in the project? +- Do the canvas ratio and orientation fit the user's requirements, the deployment scenario, the content structure, and the reading order? +- Are the core message, primary visual focus, and dominant visual grammar clear? +- Did you actually compare hero-image or structure-diagram candidates before finalizing, and does the chosen asset/figure truly enter the main composition? +- Is there a poster-level visual move recognizable at thumbnail size, rather than an ordinary content page, card wall, or app interface? +- Do the map, timeline, routes, flowcharts, matrix, and index genuinely express core relationships, rather than serving as decoration or density filler? +- Do the colors, fonts, image treatment, composition, and graphic language come from the current subject, rather than defaulting to reusing the previous poster case? +- Are facts, data, dates, locations, units, captions, sources, and copyright boundaries complete and verifiable? +- Are body text, annotations, and legends readable at the actual output size, without relying on extremely small fonts to cram in information? +- Does the visual result in the screenshot truly match `style.md`, rather than merely passing the syntax check? diff --git a/skills/open-kimi-ppt/reference/pptd.md b/skills/open-kimi-ppt/reference/pptd.md new file mode 100644 index 0000000..d350929 --- /dev/null +++ b/skills/open-kimi-ppt/reference/pptd.md @@ -0,0 +1,1886 @@ +# PPTD Format Specification + +PPTD (PPT-DSL) is a YAML abstraction layer for PowerPoint presentations, used to describe, generate, and edit slides in an AI-friendly way, with lossless bidirectional conversion to and from PPTX + +--- + +## Conventions in This Document +- Uses **TS interfaces** to describe structures, with **field tables** and **minimal YAML examples** to aid understanding +- **Default values** are annotated in TS end-of-line comments as `// default: X`. X may be a literal (`1` / `"top"` / `[0, 0]`) or a descriptive phrase (`not applied` / `not shown` / `falls back along the inheritance chain` / `auto-adapts to chart size`, etc.) +- **Constraints** are annotated in TS end-of-line comments or below the TS block as `// constraint: ...`, uniformly using interval or inequality notation (`[0, 1]` / `> 0`) or textual descriptions +--- + +## 1. Global Conventions + +### Syntax +- Uses **YAML 1.2** syntax +- For special characters such as `:`, `#`, `{`, `}`, the value must be wrapped in quotes or written with a block scalar instead +- For fields with many special characters such as `content.text`, a block scalar (`|`) should be used as its own block, to prevent content like `style="..."` from being parsed incorrectly + +### Coordinate System and Units +- All geometry and size units are **px**; the origin `(0, 0)` is the top-left corner of the page +- Recommended sizes: 16:9 → `[960, 540]`; 4:3 → `[720, 540]` +- This specification defines 1px = 1pt (i.e., `fontSize: 18` is 18pt in PPTX) +- Element stacking order is determined by the order of the `Page.elements` array; the later an element, the higher its layer + +### Style Priority and Default Values + +For property values that conflict, the first source with a value is found by searching the following priorities from top to bottom; when none of the levels is set, fall back to the default values at the end of that section. + +> The following rule applies to all subsections of this section: `lineHeight` (a multiple) and `lineHeightPx` (fixed px) are mutually exclusive; when both are set, `lineHeightPx` takes precedence. + +#### 1. Text Styles Inside a Text Box + +**Priority chain:** +1. Rich-text semantic tags such as ``, ``, `` in [Text.content.text](#textcontent) +2. Inline properties set in `` +3. Paragraph properties set in `

` +4. **Style fields set directly on [Text.content](#textcontent)** (distinct from the theme style referenced by `style`; including `color`, `fontSize`, `fontFamily`, `bold`, `italic`, `backgroundColor`, `lineHeight`, `lineHeightPx`, `letterSpacing`, `marginTop`) +5. The [TextStyleConfig](#textstyleconfig) theme style referenced by [Text.content.style](#textcontent) +6. Default values: + +| Property | Default value | +|---|---| +| color | `#000000` | +| backgroundColor | Not applied | +| fontSize | `18` | +| fontFamily | `"MiSans"` | +| bold | `false` | +| italic | `false` | +| lineHeight | `1` | +| lineHeightPx | Not applied | +| letterSpacing | `0` | +| marginTop | `0` | + +#### 2. Table Cell Styles + +**Priority chain:** +1. Rich-text semantic tags such as ``, ``, `` in [Cell.text](#cell) +2. Inline properties set in `` +3. Paragraph properties set in `

` +4. [Cell](#cell) inline fields +5. The [TextStyleConfig](#textstyleconfig) referenced by [Cell.textStyle](#cell) (**applies only to text fields**; does not include `fill` / `border` / `align`) +6. Position-category styles of [TableStyleConfig](#tablestyleconfig) + - On row vs column conflicts, [TableStyleConfig.rowOverColumn](#tablestyleconfig) decides the winner; default `true` = row wins + - Row categories: `TableStyleConfig.firstRowStyle` / `TableStyleConfig.lastRowStyle` + - Column categories: `TableStyleConfig.firstColumnStyle` / `TableStyleConfig.lastColumnStyle` +7. [TableStyleConfig.bodyStyles](#tablestyleconfig): applies to data rows other than the first and last rows, cycled by data-row index +8. [TableStyleConfig.cellStyle](#tablestyleconfig): the baseline cell style for the whole table +9. Default values + +| Property | Default value | +|---|---| +| color | `#000000` | +| backgroundColor | Not applied | +| fontSize | Auto-adapts based on cell height | +| fontFamily | `"MiSans"` | +| bold | `false` | +| italic | `false` | +| lineHeight | `1` | +| lineHeightPx | Not applied | +| letterSpacing | `0` | +| marginTop | `0` | +| fill | Not applied (transparent) | +| border | `{style: solid, width: 1, color: "#000000"}` | +| align | `["center", "middle"]` | + +#### 3. Chart Styles + +Charts involve multiple kinds of styles (series body colors, fonts, data labels, axis/legend visibility, etc.), each with its own independent priority chain, explained below. + +**3.1 Series body color priority chain:** +1. A series' explicit `fill` / `lineColor` / `areaColor` (field names differ per type; see [Color Mechanism](#52-color-mechanism)) +2. The same-named field for the corresponding type in [Chart.seriesDefaults](#seriesdefaults) +3. The [Theme.colors](#theme) theme color cycle (colors are picked in the order the series appear in the array) + +> [scatter](#scatter) is an exception: marker color resolves as `marker.fill > series.fill > theme color cycle`; marker.border likewise takes precedence over series.border. +> +> For each type's specific color fields, derivation rules, and role mappings, see [§5.2 Color Mechanism](#52-color-mechanism). + +**3.2 Font priority chain:** +1. Sub-component `fontFamily` ([TitleConfig](#titleconfig) / [LegendConfig](#legendconfig) / [DataLabelConfig](#datalabelconfig) / [AxisConfig.label](#axisconfig) / [SpokeAxisConfig.label](#spokeaxisconfig)) +2. [Chart.fontFamily](#chart) +3. Theme default ([Theme](#theme) or the PPTX master font) + +**3.3 dataLabels priority chain:** +1. `series[i].dataLabels` +2. [Chart.dataLabels](#chart) (global default) +3. Not shown (equivalent to `show: false`) + +> Sub-fields follow a **one-level shallow merge**: `series.dataLabels` only overrides the sub-fields it explicitly provides; unprovided ones fall back from [Chart.dataLabels](#chart); if neither provides them, the per-type default applies (see [dataLabels.content value quick reference](#55-datalabelscontent-value-quick-reference)). + +**3.4 `seriesDefaults` merge rules:** + +[Chart.seriesDefaults](#seriesdefaults)`[type]` provides common defaults for all series of that type, merged with each series via a **one-level deep merge**: +- **Scalar fields** (string / number / boolean): the series' explicit value overrides defaults +- **Object fields** (`marker` / `dataLabels` / `border` / `upBars` / `downBars` / `totalBars` / gradient `fill` objects, etc.): recursive one-level shallow merge — same-named fields of defaults and series are spread respectively, with sub-fields overridden by the nearest source +- **Array fields** (`fill: []` / `colorScheme: []`): the series replaces defaults as a whole, with no element-level merging +- `type` and `encode` are not allowed inside seriesDefaults +- Only multi-series types support seriesDefaults: `bar / line / area / scatter / bubble / candlestick / radar` + +Counterintuitive example: +```yaml +seriesDefaults: + bar: {marker: {shape: circle, size: 8}} +series: + - type: bar + marker: {size: 12} # after merge: {shape: circle, size: 12}, not {size: 12} +``` + +**3.5 `boolean | Config` field convention:** + +Fields of the form `boolean | XxxConfig` (`marker` / `legend` / `AxisConfig.label / axisLine / gridLine` / `SpokeAxisConfig.label / axisLine / gridLine` / `colorbar`) uniformly follow: +- `false` = off +- `true` = on with default configuration +- Object `{...}` = on + custom configuration + +> **The only exception**: [scatter.marker](#scatter) cannot be `false` (a scatter plot without a marker has nothing to render). + +--- + +### Multi-File Structure +A PPTD project consists of a main entry file and individual page files: +``` +project/ + slides_name.pptd # main entry (size/theme/title + page reference list) + media/ # media resources such as images and videos + pages/ # page file directory + 1_cover.page # one .page file per page + 2_intro.page +``` +**Path rules:** +1. **Fully self-contained**: all referenced files must be located inside the folder containing the `.pptd` file; **referencing files outside the directory is not allowed** +2. **Only relative paths are supported** (relative to the directory containing the `.pptd` file): + - The `pages` list in `.pptd`: `pages/1_cover.page` + - Image paths in `.page`: `media/image1.jpg` +3. **Media supports URLs**: `Image.src`, and the [ImageFill](#fill).src of `background` / `fill`, may be `https://...` (only jpg/jpeg/png/gif supported) + +**Main entry is required:** everything must be loaded through the `.pptd` main entry file; a `.page` cannot be passed alone to the `convert`/`check` commands + +--- + +## 2. Shared Types +The following types are reused in multiple places and are defined together up front. Element sections reference them by type name without repeating the expansion + +### Color +```ts +type Color = string; +``` +> Supports opaque **HEX6** (`#RRGGBB`), alpha **HEX8** (`#RRGGBBAA`), and [Theme.colors](#theme) theme color references (e.g. `$primary`) + +### FontFamily +```ts +type FontFamily = string | { latin: string; ea: string }; +``` +| Form | Example | Description | +|------|------|------| +| String | `"MiSans"` | Chinese and English use the same font uniformly | +| Object | `{latin: "Arial", ea: "MiSans"}` | Explicitly specify Latin (latin) and East Asian (ea) fonts separately | + +See [fonts.md](./fonts.md) for the list of available fonts + +### Alignment +```ts +type HorizontalAlign = "left" | "center" | "right" | "justify" | "distributed"; +type VerticalAlign = "top" | "middle" | "bottom"; +type Alignment = [HorizontalAlign, VerticalAlign]; +``` +| Value | Description | +|----|------| +| `left` / `center` / `right` | Horizontal left / center / right alignment | +| `justify` | Justified (last line not stretched) | +| `distributed` | Distributed (last line stretched) | +| `top` / `middle` / `bottom` | Vertical top / middle / bottom alignment | + +### LineStyle +```ts +type LineStyle = "solid" | "dash" | "dot"; +``` + +### Border +```ts +interface Border { + style?: LineStyle; // default: "solid" + width?: number; // default: 1 + color?: Color; // default: "#000000" +} +``` + +#### BorderSpec +[Cell](#cell) and [CellStyle](#cellstyle) support an array form of `Border` to set the four side borders separately + +```ts +type BorderSpec = null + | Border + | [Border | null, Border | null] + | [Border | null, Border | null, Border | null, Border | null]; +``` + +| Form | Meaning | +|------|------| +| `null` | Explicit clear: no border on any of the four sides (used to override a border set higher up the inheritance chain)| +| `Border` | Same on all four sides | +| Two-element array `[Border\|null, Border\|null]` | `[top-bottom, left-right]` | +| Four-element array `[Border\|null, Border\|null, Border\|null, Border\|null]` | `[top, right, bottom, left]` (clockwise) | + +> A `null` inside the array means no border at the corresponding position; a top-level `null` clears everything. + +### Shadow + +```ts +interface Shadow { + blur: number; // blur radius + color: Color; + offset?: [number, number]; // default: [0, 0]; [x, y] offset +} +``` + +### ColorStop + +```ts +interface ColorStop { + position: number; // constraint: [0, 1] + color: Color; +} +``` + +### ImageFit / ImageCrop + +```ts +interface ImageFit { + mode: "fill" | "contain" | "cover"; +} + +interface ImageCrop { + left?: number; + top?: number; + right?: number; + bottom?: number; +} +``` + +> **Constraint:** the four fields of `ImageCrop` are analogous, default 0. A positive value crops inward from the corresponding edge proportionally (inset); a negative value expands outward toward the corresponding edge proportionally and pads with transparent pixels (outset). Must ensure `left + right < 1` and `top + bottom < 1`, otherwise the source rectangle degenerates. + +| ImageFit.mode | Description | +|---|---| +| `cover` | Fills the container, keeps aspect ratio, may crop | +| `contain` | Shows the image completely, keeps aspect ratio, may leave blank space | +| `fill` | Stretches to fill, may distort | + +### Fill + +```ts +type Fill = SolidFill | GradientFill | ImageFill; + +interface SolidFill { + type: "solid"; + color: Color; +} + +interface GradientFill { + type: "gradient"; + gradientType: "linear" | "radial"; + stops: ColorStop[]; // constraint: at least 2 + angle?: number; // default: 0; only effective for linear +} + +interface ImageFill { + type: "image"; + src: string; // URL or relative path + fit?: ImageFit; // default: {mode: "cover"} + crop?: ImageCrop; // always applied; see the rendering order with fit below + opacity?: number; // default: 1; constraint: [0, 1] +} +``` + +> `GradientFill.angle` takes values in `[0, 360)`; `0` means left to right, increasing clockwise. Examples: `90` = top→bottom, `180` = right→left. + +> **ImageFill rendering order:** `crop` (adjust the source rectangle proportionally: positive values crop inward, negative values expand outward and pad with transparent pixels) → `fit` (adapt to the fill container per mode). The specific semantics of each `fit.mode` value are consistent with the "rendering logic" discussion in the [Image](#image-image) section. + +**Examples:** + +```yaml +# Solid +fill: + type: solid + color: "$primary" + +# Gradient +fill: + type: gradient + gradientType: linear + angle: 90 + stops: + - {position: 0, color: "$primary"} + - {position: 1, color: "$accent"} + +# Image +fill: + type: image + src: "media/bg.jpg" + fit: {mode: cover} + opacity: 0.9 +``` + +--- + +## 3. Main Entry File (.pptd) + +### Presentation + +```ts +interface Presentation { + version: "v2"; // required, fixed to "v2" (version identifier) + title?: string; // default: no title + size: [number, number]; // [width, height]; 16:9 recommended [960, 540], 4:3 recommended [720, 540] + theme?: Theme; + pages: string[]; // list of relative paths to page files, e.g. "pages/cover.page" +} +``` + +**Example:** + +```yaml +version: v2 +title: Annual Work Summary +size: [960, 540] +theme: + colors: + primary: "#2563EB" + accent: "#F59E0B" + text: "#1F2937" + textStyles: + title: + fontSize: 40 + color: "$primary" + body: + fontSize: 18 + color: "$text" + lineHeight: 1.6 + tableStyles: + default: + firstRowStyle: + fill: {type: solid, color: "$primary"} + color: "#ffffff" + bold: true + bodyStyles: + - {fill: {type: solid, color: "#f8fafc"}} + - {fill: {type: solid, color: "#ffffff"}} +pages: + - pages/1_cover.page + - pages/2_content.page +``` + +### Theme + +The theme centrally manages colors, text styles, and table styles. Use `$` in relevant fields to reference the theme: + +| Theme type | Referencing field | Example | +|---|---|---| +| `colors` | Any [Color](#color) field | `$primary` | +| `textStyles` | [TextContent.style](#textcontent) / [Cell.textStyle](#cell) | `$title` | +| `tableStyles` | [Table.style](#table-table) | `$default` | + +```ts +interface Theme { + colors?: Record; + textStyles?: Record; + tableStyles?: Record; +} +``` + +#### TextStyleConfig + +```ts +interface TextStyleConfig { + color?: Color; + fontSize?: number; + fontFamily?: FontFamily; + bold?: boolean; // bold + italic?: boolean; // italic + backgroundColor?: Color; // text background color (e.g., text highlight) + lineHeight?: number; // line-height multiple + lineHeightPx?: number; // fixed line height (px); when it conflicts with lineHeight, lineHeightPx prevails + letterSpacing?: number; + marginTop?: number; +} +``` + +> Unset fields fall back along the inheritance chain (see [Style Priority and Default Values](#style-priority-and-default-values) for details) + +#### CellStyle + +```ts +interface CellStyle extends TextStyleConfig { + // —— Inherits all properties of TextStyleConfig —— + // color / fontSize / fontFamily / bold / italic / backgroundColor / lineHeight / lineHeightPx / letterSpacing / marginTop + + // —— CellStyle-specific —— + fill?: Fill; // background fill + border?: BorderSpec; // border + align?: Alignment; // text alignment +} +``` + +> Unset fields fall back along the inheritance chain (see [Style Priority and Default Values](#style-priority-and-default-values) for details) + +#### TableStyleConfig + +```ts +interface TableStyleConfig { + // —— Cell style: applied to every cell —— + cellStyle?: CellStyle; + + // —— Row category overrides —— + firstRowStyle?: CellStyle; // first-row style + lastRowStyle?: CellStyle; // last-row style + + // —— Column category overrides —— + firstColumnStyle?: CellStyle; + lastColumnStyle?: CellStyle; + + // —— Alternating row styles —— + bodyStyles?: CellStyle[]; // data rows other than the first/last row apply these cyclically by data-row index + + // —— Cross-category rule —— + rowOverColumn?: boolean; // default: true; whether the row style wins when a cell is covered by both row and column rules +} +``` +> **Row/column style rules**: category styles such as `firstRowStyle` / `lastRowStyle` / `firstColumnStyle` / `lastColumnStyle` mean **apply the style independently to every matching cell**, not apply the style to the first row/last column as a whole +> - Writing `firstRowStyle.border: {style: solid, width: 2}` → **every cell of the first row** gets a border on all four sides +> - To add an outer frame to the first row as a whole, use per-side BorderSpec: `border: [, null, , null]`, then set borders separately on the first-column and last-column cells of the first row + + +> For fallback rules, see [Style Priority and Default Values](#style-priority-and-default-values) + +## 4. Page Files (.page) + +### Page + +```ts +interface Page { + pageType?: "cover" | "table_of_contents" | "chapter" | "content" | "final" | string; // default: none; category label (does not affect rendering); preset values are recognized as the corresponding page type, arbitrary custom strings are also allowed + background?: Fill; // default: {type: solid, color: "#FFFFFF"} (white solid fill) + notes?: string; // default: none; speaker notes; plain text + elements: Element[]; // the later an element, the higher its layer +} +``` + +**Example:** + +```yaml +pageType: cover +background: + type: solid + color: "$primary" +notes: Speaker notes +elements: + - elementId: title1 + elementType: text + bounds: [100, 200, 760, 80] + content: + style: "$title" + align: [center, middle] + text: Hello World +``` + +--- + +## 5. Elements + +### ElementBase + +Common properties of all elements. + +```ts +interface ElementBase { + elementId: string; // constraint: unique within the same page; unique element ID + elementType: "text" | "shape" | "line" | "image" | "icon" | "table" | "chart"; // element type + bounds: [number, number, number, number]; // element size and position, [x, y, width, height] +} + +type Element = Text | Shape | Line | Image | Icon | Table | Chart; +``` + +--- + +### Text (text box) + +```ts +interface Text extends ElementBase { + elementType: "text"; + rotation?: number; // default: 0; degrees, clockwise rotation + opacity?: number; // default: 1; constraint: [0, 1] + flip?: [boolean, boolean]; // default: [false, false]; [horizontal flip, vertical flip] + content: TextContent; +} +``` + +#### TextContent + +```ts +interface TextContent { + text: string; // rich text string (block scalar) + style?: string; // references theme.textStyles, written as "$key" (e.g. "$title") + + // —— Style fields (when unset, fall back along the inheritance chain) —— + color?: Color; + fontSize?: number; + fontFamily?: FontFamily; + bold?: boolean; // bold: true=on, false/unset=off + italic?: boolean; // italic: true=on, false/unset=off + backgroundColor?: Color; // text background color (e.g., text highlight) + lineHeight?: number; // line-height multiple + lineHeightPx?: number; // fixed line height (px) + letterSpacing?: number; + marginTop?: number; + + // —— Layout fields —— + textDirection?: "horizontal" | "vertical"; // default: "horizontal" + wrap?: boolean; // default: true; when false, no wrapping, and the part beyond bounds.width overflows the element boundary; explicitly setting false is recommended for single-line text + align?: Alignment; // default: ["left", "top"] + + // —— Visual decoration (unset = not applied) —— + gradient?: GradientFill; // text gradient (applied to the text itself) + shadow?: Shadow; // text shadow +} +``` + +**Examples:** + +```yaml +# Basic: theme style + plain text +- elementId: title-1 + elementType: text + bounds: [100, 50, 760, 80] + content: + style: "$title" + align: [center, middle] + text: Annual Work Summary + +# Rich text + inline property overrides +- elementId: body-1 + elementType: text + bounds: [100, 200, 600, 200] + content: + fontSize: 20 + color: "$text" + lineHeight: 1.6 + align: [left, top] + text: | +

Key achievement: completed 3 key projects

+

—— FY2024

+ +# Text gradient + shadow +- elementId: hero-text + elementType: text + bounds: [100, 100, 760, 120] + content: + align: [center, middle] + gradient: + type: gradient + gradientType: linear + angle: 90 + stops: + - {position: 0, color: "$primary"} + - {position: 1, color: "$accent"} + shadow: + blur: 6 + color: "#00000040" + offset: [0, 3] + text: | +

FUTURE

+``` + +#### Rich Text Rules + +`TextContent.text` and `Cell.text` follow the rich text rules below for paragraph splitting and for setting paragraph or inline styles. + +**Supported tags** + +| Tag | Description | Example | +|------|------|------| +| `

` | Paragraph; may carry paragraph styles | `

paragraph

` | +| `` | Inline style; use this tag to set inline styles | `red` | +| `` | Bold | `important` | +| `` | Italic | `emphasis` | +| `` | Underline | `underline` | +| `` | Strikethrough | `deleted` | +| `` | Superscript | `E=mc2` | +| `` | Subscript | `H2O` | +| `` | Hyperlink; supports `https://`, `http://`, `mailto:`; once set, the hyperlink text style (blue with underline) is applied automatically | `link` | +| `