From 5e23feacad6b7fb454019930a36acb99f465ad01 Mon Sep 17 00:00:00 2001 From: sunbigfly Date: Sat, 21 Mar 2026 02:55:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20PPT=20Agent=20Skill=20-=20=E4=B8=93?= =?UTF-8?q?=E4=B8=9A=E6=BC=94=E7=A4=BA=E6=96=87=E7=A8=BF=E5=85=A8=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=20AI=20=E7=94=9F=E6=88=90=E5=8A=A9=E6=89=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 模拟顶级 PPT 设计公司的完整工作流,输出高质量 HTML 演示文稿 + 可编辑矢量 PPTX。 - 6步Pipeline: 需求调研->资料搜集->大纲策划->策划稿->风格+配图+HTML设计稿->后处理 - 8种预置风格 + 7种Bento Grid布局 + 6种卡片类型 - 专业排版系统(7级字号) + 色彩比例法则(60-30-10) + 跨页视觉叙事 - 8种纯CSS数据可视化 + 5种配图融入技法 - HTML->SVG->PPTX 全自动转换管线 --- README.md | 124 +++++ SKILL.md | 330 +++++++++++++ references/bento-grid.md | 204 ++++++++ references/method.md | 63 +++ references/prompts.md | 901 +++++++++++++++++++++++++++++++++++ references/style-system.md | 373 +++++++++++++++ scripts/html2svg.py | 542 +++++++++++++++++++++ scripts/html_packager.py | 205 ++++++++ scripts/svg2pptx.py | 942 +++++++++++++++++++++++++++++++++++++ 9 files changed, 3684 insertions(+) create mode 100644 README.md create mode 100644 SKILL.md create mode 100644 references/bento-grid.md create mode 100644 references/method.md create mode 100644 references/prompts.md create mode 100644 references/style-system.md create mode 100644 scripts/html2svg.py create mode 100644 scripts/html_packager.py create mode 100644 scripts/svg2pptx.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..23bded6 --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# PPT Agent -- 专业演示文稿全流程 AI 生成助手 + +模仿万元/页级别 PPT 设计公司的完整工作流,输出高质量 HTML 演示文稿 + 可编辑矢量 PPTX。 + +## 工作流概览 + +``` +需求调研 -> 资料搜集 -> 大纲策划 -> 策划稿 -> 风格+配图+HTML设计稿 -> 后处理(SVG+PPTX) +``` + +## 输出产物 + +| 文件 | 说明 | +|------|------| +| `preview.html` | 浏览器翻页预览(自动生成) | +| `presentation.pptx` | PPTX 文件,PPT 365 中右键"转换为形状"可编辑 | +| `svg/*.svg` | 单页矢量 SVG,可直接拖入 PPT | +| `slides/*.html` | 单页 HTML 源文件 | + +## 环境依赖 + +### 必须 + +- **Node.js** >= 18(Puppeteer + dom-to-svg 需要) +- **Python** >= 3.8(脚本执行) +- **python-pptx**(PPTX 生成) + +### 自动安装(首次运行自动处理) + +- `puppeteer` -- Headless Chrome +- `dom-to-svg` -- DOM 转 SVG(保留 `` 可编辑) +- `esbuild` -- 将 dom-to-svg 打包为浏览器 bundle + +### 可选(降级方案) + +- `pdf2svg` -- 当 dom-to-svg 不可用时的降级方案(文字变 path,不可编辑) +- `inkscape` -- SVG 转 EMF(备用) + +### 一键安装 + +```bash +# Python 依赖 +pip install python-pptx lxml Pillow + +# Node.js 依赖(首次运行脚本时自动安装,也可手动提前安装) +npm install puppeteer dom-to-svg + +# 可选:降级方案 +sudo apt install pdf2svg +``` + +## 目录结构 + +``` +ppt-agent-workflow-san/ + SKILL.md # 主工作流指令(Agent 入口) + README.md # 本文件 + references/ + prompts.md # 5 套 Prompt 模板 + style-system.md # 8 种预置风格 + CSS 变量 + bento-grid.md # 7 种布局规格 + 卡片类型 + method.md # 核心方法论 + scripts/ + html_packager.py # 多页 HTML 合并为翻页预览 + html2svg.py # HTML -> SVG(dom-to-svg,保留文字可编辑) + svg2pptx.py # SVG -> PPTX(OOXML 原生 SVG 嵌入) +``` + +## 脚本用法 + +### html_packager.py -- 合并预览 + +```bash +python3 scripts/html_packager.py -o preview.html +``` + +### html2svg.py -- HTML 转 SVG + +```bash +python3 scripts/html2svg.py -o +``` + +- 底层:Puppeteer + dom-to-svg(DOM 直接转 SVG,`` 可编辑) +- 图片:自动读取 `` 引用的文件转 base64 嵌入 +- 降级:dom-to-svg 不可用时自动退回 Puppeteer PDF + pdf2svg + +### svg2pptx.py -- SVG 转 PPTX + +```bash +python3 scripts/svg2pptx.py -o output.pptx --html-dir +``` + +- SVG 以 OOXML `asvg:svgBlip` 扩展原生嵌入 PPTX +- 同时生成 PNG 回退图(兼容旧版 Office) +- PPT 365 中右键 -> "转换为形状" 可编辑文字和形状 + +## 技术架构 + +``` +HTML slides + | + v +[Puppeteer] 打开 HTML -> [dom-to-svg] DOM 直接转 SVG + | (保留 元素,文字可编辑) + | (base64 内联图片) + | (color -> fill 属性后处理) + v +SVG files + | + v +[python-pptx + lxml] OOXML svgBlip 嵌入 + | (PNG 回退图兼容旧版 Office) + v +presentation.pptx +``` + +## 触发方式 + +在 Claude 对话中,以下表达会触发此 Skill: + +- "帮我做个 PPT" / "做一个关于 X 的演示" +- "做 slides" / "做幻灯片" / "做汇报材料" +- "把这篇文档做成 PPT" +- "做培训课件" / "做路演 deck" diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..2c22772 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,330 @@ +--- +name: ppt-agent +description: 专业 PPT 演示文稿全流程 AI 生成助手。模拟顶级 PPT 设计公司的完整工作流(需求调研 -> 资料搜集 -> 大纲策划 -> 策划稿 -> 设计稿),输出高质量 HTML 格式演示文稿。当用户提到制作 PPT、做演示文稿、做 slides、做幻灯片、做汇报材料、做培训课件、做路演 deck、做产品介绍页面时触发此技能。即使用户只说"帮我做个关于 X 的介绍"或"我要给老板汇报 Y",只要暗示需要结构化的多页演示内容,都应该触发。也适用于用户说"帮我把这篇文档做成 PPT"、"把这个主题做成演示"等需要将内容转化为演示格式的场景。 +--- + +# PPT Agent -- 专业演示文稿全流程生成 + +## 核心理念 + +模仿专业 PPT 设计公司(报价万元/页级别)的完整工作流,而非"给个大纲套模板": + +1. **先调研后生成** -- 用真实数据填充内容,不凭空杜撰 +2. **策划与设计分离** -- 先验证信息结构,再做视觉包装 +3. **内容驱动版式** -- Bento Grid 卡片式布局,每页由内容决定版式 +4. **全局风格一致** -- 先定风格再逐页生成,保证跨页统一 +5. **智能配图** -- 利用图片生成能力为每页配插图(绝大多数环境都有此能力) + +--- + +## 环境感知 + +开始工作前自省 agent 拥有的工具能力: + +| 能力 | 降级策略 | +|------|---------| +| **信息获取**(搜索/URL/文档/知识库) | 全部缺失 -> 依赖用户提供材料 | +| **图片生成**(绝大多数环境都有) | 缺失 -> 纯 CSS 装饰替代 | +| **文件输出** | 必须有 | +| **脚本执行**(Python/Node.js) | 缺失 -> 跳过自动打包和 SVG 转换 | + +**原则**:检查实际可调用的工具列表,有什么用什么。 + +--- + +## 路径约定 + +整个流程中反复用到以下路径,在 Step 1 完成后立即确定: + +| 变量 | 含义 | 获取方式 | +|------|------|---------| +| `SKILL_DIR` | 本 SKILL.md 所在目录的绝对路径 | 即触发 Skill 时读取 SKILL.md 的目录 | +| `OUTPUT_DIR` | 产物输出根目录 | 用户当前工作目录下的 `ppt-output/`(首次使用时 `mkdir -p` 创建) | + +后续所有路径均基于这两个变量,不再重复说明。 + +--- + +## 输入模式与复杂度判断 + +### 入口判断 + +| 入口 | 示例 | 从哪步开始 | +|------|------|-----------| +| 纯主题 | "做一个 Dify 企业介绍 PPT" | Step 1 完整流程 | +| 主题 + 需求 | "15 页 AI 安全 PPT,暗黑风" | Step 1(跳部分已知问题)| +| 源材料 | "把这篇报告做成 PPT" | Step 1(材料为主)| +| 已有大纲 | "我有大纲了,生成设计稿" | Step 4 或 5 | + +### 跳步规则 + +跳过前置步骤时,必须补全对应依赖产物: + +| 起始步骤 | 缺失依赖 | 补全方式 | +|---------|---------|---------| +| Step 4 | 每页内容文本 | 先用 Prompt #3 为每页生成内容分配 | +| Step 5 | 策划稿 JSON | 用户提供或先执行 Step 4 | + +### 复杂度自适应 + +根据目标页数自动调整流程粒度: + +| 规模 | 页数 | 调研 | 搜索 | 策划 | 生成 | +|------|------|------|------|------|------| +| **轻量** | <= 8 页 | 3 题精简版(场景+受众+补充信息) | 3-5 个查询 | Step 3 可与 Step 4 合并一步完成 | 逐页生成 | +| **标准** | 9-18 页 | 完整 7 题 | 8-12 个查询 | 完整流程 | 按 Part 分批,每批 3-5 页 | +| **大型** | > 18 页 | 完整 7 题 | 10-15 个查询 | 完整流程 | 按 Part 分批,每批 3-5 页,批间确认 | + +--- + +## 6 步 Pipeline + +### Step 1: 需求调研 [STOP -- 必须等用户回复] + +> **禁止跳过。** 无论主题多简单,都必须提问并等用户回复后才能继续。不替用户做决定。 + +**执行**:使用 `references/prompts.md` Prompt #1 +1. 搜索主题背景资料(3-5 条) +2. 根据复杂度选择完整 7 题或精简 3 题,一次性发给用户 +3. **等待用户回复**(阻断点) +4. 整理为需求 JSON + +**7 题三层递进结构**(轻量模式只问第 1、2、7 题): + +| 层级 | 问题 | 决定什么 | +|------|------|---------| +| 场景层 | 1. 演示场景(现场/自阅/培训) | 信息密度和视觉风格 | +| 场景层 | 2. 核心受众(动态生成画像) | 专业深度和说服策略 | +| 场景层 | 3. 期望行动(决策/理解/执行/改变认知) | 内容编排的最终导向 | +| 内容层 | 4. 叙事结构(问题->方案/科普/对比/时间线) | 大纲骨架逻辑 | +| 内容层 | 5. 内容侧重(搜索结果动态生成,可多选) | 各 Part 主题权重 | +| 内容层 | 6. 说服力要素(数据/案例/权威/方法,可多选) | 卡片内容类型偏好 | +| 执行层 | 7. 补充信息(演讲人/品牌色/必含/必避/页数/配图偏好) | 具体执行细节 | + +**产物**:需求 JSON(topic + requirements) + +--- + +### Step 2: 资料搜集 + +> 盘点所有信息获取能力,全部用上。 + +**执行**: +1. 根据主题规划查询(数量参考复杂度表) +2. 用所有可用的信息获取工具并行搜索 +3. 每组结果摘要总结 + +**产物**:搜索结果集合 JSON + +--- + +### Step 3: 大纲策划 + +**执行**:使用 `references/prompts.md` Prompt #2(大纲架构师 v2.0) + +**方法论**:金字塔原理 -- 结论先行、以上统下、归类分组、逻辑递进 + +**自检**:页数符合要求 / 每 part >= 2 页 / 要点有数据支撑 + +**产物**:`[PPT_OUTLINE]` JSON + +--- + +### Step 4: 内容分配 + 策划稿 [建议等用户确认] + +> 将内容分配和策划稿生成合为一步。在思考每页应该放什么内容的同时,决定布局和卡片类型,更自然高效。 + +**执行**:使用 `references/prompts.md` Prompt #3(内容分配与策划稿) + +**要点**: +- 将搜索素材精准映射到每页 +- 为每页设计多层次内容结构(主卡片 40-100 字 + 数据亮点 + 辅助要点) +- 同时确定 page_type / layout_hint / cards[] 结构 +- **每个内容页至少 3 张卡片 + 2 种 card_type + 1 张 data 卡片** +- 布局选择参考 `references/bento-grid.md` 的决策矩阵 + +向用户展示策划稿概览,建议等用户确认后再进入 Step 5。 + +**产物**:每页策划卡 JSON 数组 -> 保存为 `OUTPUT_DIR/planning.json` + +--- + +### Step 5: 风格决策 + 设计稿生成 + +分三个子步骤,**顺序不可颠倒**: + +#### 5a. 风格决策 + +**执行**:阅读 `references/style-system.md`,选择或推断风格 + +根据主题关键词匹配 8 种预置风格之一(暗黑科技 / 小米橙 / 蓝白商务 / 朱红宫墙 / 清新自然 / 紫金奢华 / 极简灰白 / 活力彩虹),详细匹配规则和完整 JSON 定义见 `references/style-system.md`。 + +**产物**:风格定义 JSON -> 保存为 `OUTPUT_DIR/style.json` + +#### 5b. 智能配图(根据用户偏好) + +> 在需求调研(Step 1 第 7 题)中确认用户的配图偏好后执行。如果用户选择"不需要配图"则跳过。 + +##### 配图时机 + +在生成每页 HTML **之前**,先为该页生成配图。每页至少 1 张(封面页、章节封面必须有),生成后保存到 `OUTPUT_DIR/images/`。 + +##### generate_image 提示词构造公式 + +提示词必须同时满足 **4 个维度**,按以下公式组装: + +``` +[内容主题] + [视觉风格] + [画面构图] + [技术约束] +``` + +| 维度 | 说明 | 示例 | +|------|------|------| +| 内容主题 | 从该页策划稿 JSON 的核心概念提炼,具体到场景/对象 | "DMSO molecular purification process, crystallization flask with clear liquid" | +| 视觉风格 | 与 style.json 的配色方案和情感基调对齐 | 暗黑科技 -> "deep blue dark tech background, subtle cyan glow, futuristic" | +| 画面构图 | 根据图片在页面中的放置方式决定 | 右侧半透明 -> "clean composition, main subject on left, fade to transparent on right" | +| 技术约束 | 固定后缀,确保输出质量 | "no text, no watermark, high quality, professional illustration" | + +##### 风格与配图关键词对应 + +| PPT 风格 | 配图风格关键词 | +|---------|--------------| +| 暗黑科技 | dark tech background, neon glow, futuristic, digital, cyber | +| 小米橙 | minimal dark background, warm orange accent, clean product shot, modern | +| 蓝白商务 | clean professional, light blue, corporate, minimal, bright | +| 朱红宫墙 | traditional Chinese, elegant red gold, ink painting, cultural | +| 清新自然 | fresh green, organic, nature, soft light, watercolor | +| 紫金奢华 | luxury, purple gold, premium, elegant, metallic | +| 极简灰白 | minimal, grayscale, clean, geometric, academic | +| 活力彩虹 | colorful, vibrant, energetic, playful, gradient, pop art | + +##### 按页面类型调整 + +| 页面类型 | 图片特征 | Prompt 额外关键词 | +|---------|---------|-----------------| +| 封面页 | 主题概览,视觉冲击 | "hero image, wide composition, dramatic lighting" | +| 章节封面 | 该章主题的象征性视觉 | "symbolic, conceptual, centered composition" | +| 内容页 | 辅助说明,不喧宾夺主 | "supporting illustration, subtle, background-suitable" | +| 数据页 | 抽象数据可视化氛围 | "abstract data visualization, flowing lines, tech" | + +##### 禁止事项 +- 禁止图片中出现文字(AI 生成的文字质量差) +- 禁止与页面配色冲突的颜色(暗色主题配暗色图,亮色主题配亮色图) +- 禁止与内容无关的装饰图(每张图必须与该页内容有语义关联) +- 禁止重复使用相同 prompt(每页图片必须独特) + +**产物**:`OUTPUT_DIR/images/` 下的配图文件 + +#### 5c. 逐页 HTML 设计稿生成 + +**执行**:使用 `references/prompts.md` Prompt #4 + `references/bento-grid.md` + +> **禁止跳过策划稿直接生成。** 每页必须先有 Step 4 的结构 JSON。 + +**每页 Prompt 组装公式**: +``` +Prompt #4 模板 ++ 风格定义 JSON(5a 产物)[必须] ++ 该页策划稿 JSON(Step 4 产物,含 cards[]/card_type/position/layout_hint)[必须] ++ 该页内容文本(Step 4 产物)[必须] ++ 配图路径(5b 产物)[可选 -- 无配图时省略 IMAGE_INFO 块] +``` + +**核心设计约束**(完整清单见 Prompt #4 内部): +- 画布 1280x720px,overflow:hidden +- 所有颜色通过 CSS 变量引用,禁止硬编码 +- 凡视觉可见元素必须是真实 DOM 节点,图形优先用内联 SVG +- 禁止 `::before`/`::after` 伪元素用于视觉装饰、禁止 `conic-gradient`、禁止 CSS border 三角形 +- 配图融入设计:渐隐融合/色调蒙版/氛围底图/裁切视窗/圆形裁切(技法详见 Prompt #4) + +**分批策略**:按 Part 为单位分批生成,每批 3-5 页。每批完成后将 HTML 写入 `OUTPUT_DIR/slides/` 目录,再开始下一批。避免上下文爆炸的同时保证同一 Part 内的风格一致性。 + +**跨页视觉叙事**(让 PPT 有节奏感,不只是独立页面的堆砌): + +| 策略 | 规则 | 原因 | +|------|------|------| +| **密度交替** | 高密度页(混合网格/英雄式)后面跟低密度页(章节封面/单一焦点),形成张弛有度的节奏 | 连续 3+ 页高密度内容会导致观众视觉疲劳 | +| **章节色彩递进** | Part 1 卡片主用 accent-1,Part 2 用 accent-2,Part 3 用 accent-3 ... 每章换一种 accent 主色 | 通过颜色让受众无意识感知章节切换 | +| **封面-结尾呼应** | 结束页的视觉元素与封面页形成呼应(相同装饰图案、对称布局),给出完整闭环感 | 首尾呼应是最基本的叙事美学 | +| **渐进揭示** | 同一概念跨多页展开时,视觉复杂度应递增(第1页简单色块 -> 第2页加数据 -> 第3页完整图表) | 引导观众逐步深入理解 | + +**产物**:每页一个 HTML 文件 -> `OUTPUT_DIR/slides/` + +--- + +### Step 6: 后处理 [必做 -- HTML 生成完后立即执行] + +> **禁止跳过。** HTML 生成完后必须自动执行以下四步,不要停在 preview.html 就结束。 + +``` +slides/*.html --> preview.html --> svg/*.svg --> presentation.pptx +``` + +**依赖检查**(首次运行自动执行): +```bash +pip install python-pptx lxml Pillow 2>/dev/null +``` + +**依次执行**: + +1. **合并预览** -- 运行 `html_packager.py` + ```bash + python3 SKILL_DIR/scripts/html_packager.py OUTPUT_DIR/slides/ -o OUTPUT_DIR/preview.html + ``` + +2. **SVG 转换** -- 运行 `html2svg.py`(DOM 直接转 SVG,保留 `` 可编辑) + ```bash + python3 SKILL_DIR/scripts/html2svg.py OUTPUT_DIR/slides/ -o OUTPUT_DIR/svg/ + ``` + 底层用 dom-to-svg(自动安装),首次运行会 esbuild 打包。 + **降级**:如果 Node.js 不可用或 dom-to-svg 安装失败,跳过此步和步骤 3,只输出 preview.html。 + +3. **PPTX 生成** -- 运行 `svg2pptx.py`(OOXML 原生 SVG 嵌入,PPT 365 可编辑) + ```bash + python3 SKILL_DIR/scripts/svg2pptx.py OUTPUT_DIR/svg/ -o OUTPUT_DIR/presentation.pptx --html-dir OUTPUT_DIR/slides/ + ``` + PPT 365 中右键图片 -> "转换为形状" 即可编辑文字和形状。 + +4. **通知用户** -- 告知产物位置和使用方式: + - `preview.html` -- 浏览器打开即可翻页预览 + - `presentation.pptx` -- PPTX(右键 -> "转换为形状" 可编辑) + - `svg/` -- 每个 SVG 也可单独拖入 PPT + - **如果步骤 2-3 被降级跳过**,说明原因并告知用户手动安装 Node.js 后可重新运行 + +**产物**:preview.html + svg/*.svg + presentation.pptx + +--- + +## 输出目录结构 + +``` +ppt-output/ + slides/ # 每页 HTML + svg/ # 矢量 SVG(可导入 PPT 编辑) + images/ # AI 配图 + preview.html # 可翻页预览 + presentation.pptx # 可编辑 PPTX(右键"转换为形状") + outline.json # 大纲 + planning.json # 策划稿 + style.json # 风格定义 +``` + +--- + +## 质量自检 + +| 维度 | 检查项 | +|------|-------| +| 内容 | 每页 >= 2 信息卡片 / >= 60% 内容页含数据 / 章节有递进 | +| 视觉 | 全局风格一致 / 配图风格统一 / 卡片不重叠 / 文字不溢出 | +| 技术 | CSS 变量统一 / SVG 友好约束遵守 / HTML 可被 Puppeteer 渲染 | + +--- + +## Reference 文件索引 + +| 文件 | 何时阅读 | 关键内容 | +|------|---------|---------| +| `references/prompts.md` | 每步生成前 | 5 套 Prompt 模板(调研/大纲/策划/设计/备注)| +| `references/style-system.md` | Step 5a | 8 种预置风格 + CSS 变量 + 风格 JSON 模型 | +| `references/bento-grid.md` | Step 5c | 7 种布局精确坐标 + 5 种卡片类型 + 决策矩阵 | +| `references/method.md` | 初次了解 | 核心理念与方法论 | diff --git a/references/bento-grid.md b/references/bento-grid.md new file mode 100644 index 0000000..f2244dd --- /dev/null +++ b/references/bento-grid.md @@ -0,0 +1,204 @@ +# Bento Grid 布局系统 + +## 画布参数 + +``` +固定画布: width=1280px, height=720px +标题区: x=40, y=20, w=1200, h=50 +内容区: x=40, y=80, w=1200, h=580 +卡片间距: gap=20px +卡片圆角: border-radius=12px +卡片内边距: padding=24px +``` + +## CSS Grid 实现 + +所有布局通过 CSS Grid 精确实现。内容区容器统一定义: + +```css +.content-area { + position: absolute; + left: 40px; top: 80px; + width: 1200px; height: 580px; + display: grid; + gap: 20px; +} +``` + +## 页面类型布局 + +### 封面页 (cover) +- 大标题居中或左对齐, font-size=48-56px, accent-primary 色 +- 副标题 font-size=24px +- 演讲人/日期/公司 底部小号文字 font-size=16px +- 装饰: 品牌色块、几何线条、配图(渐隐融合技法) +- **不使用 Bento Grid**,自由排版 + +### 目录页 (toc) +- 2-5 个等大卡片网格 + +| 卡片数 | grid-template-columns | 单卡尺寸 | +|-------|----------------------|---------| +| 2 | 1fr 1fr | 590x540 | +| 3 | repeat(3, 1fr) | 387x540 | +| 4 | 1fr 1fr / 1fr 1fr (2x2) | 590x260 | +| 5 | repeat(3, 1fr) / repeat(2, 1fr) (3+2) | 混合 | + +### 章节封面 (section) +- "PART 0X" font-size=20px, accent-primary, letter-spacing=2px +- 标题 font-size=44px, font-weight=700 +- 导语 font-size=18px, color=text-secondary +- 大量留白,营造呼吸感 +- **不使用 Bento Grid**,居中排版 + +### 结束页 (end) +- 标题 font-size=44px 居中 +- 核心要点 3-5 个, font-size=18px +- 联系方式/CTA 底部 + +--- + +## 7 种内容页布局 + +所有基于内容区 (1200x580px, 起始坐标 40,80)。 + +### 1. 单一焦点 + +适用: 1个核心论点/大数据全屏展示 + +```css +.content-area { grid-template: 1fr / 1fr; } +/* 卡片: 1200x580 */ +``` + +### 2. 50/50 对称 + +适用: 对比、并列概念 + +```css +.content-area { grid-template: 1fr / 1fr 1fr; } +/* 左: 590x580 | 右: 590x580 */ +``` + +### 3. 非对称两栏 (2/3 + 1/3) + +适用: 主次关系。**最常用的布局。** + +```css +.content-area { grid-template: 1fr / 2fr 1fr; } +/* 主: 790x580 | 辅: 390x580 */ +``` + +### 4. 三栏等宽 + +适用: 3个并列比较 + +```css +.content-area { grid-template: 1fr / repeat(3, 1fr); } +/* 卡1: 387x580 | 卡2: 387x580 | 卡3: 386x580 */ +``` + +### 5. 主次结合 (大 + 两小) + +适用: 层级关系。**推荐:信息层次丰富时优先选择。** + +```css +.content-area { grid-template: 1fr 1fr / 2fr 1fr; } +/* 主: 790x580 (span 2 rows) | 辅1: 390x280 | 辅2: 390x280 */ +``` + +主卡片需设置 `grid-row: 1 / -1;` 跨两行。 + +### 6. 顶部英雄式 + +适用: 总分关系。**推荐:总分结构清晰时优先选择。** + +**3子项版(最常用)**: +```css +.content-area { grid-template: auto 1fr / repeat(3, 1fr); } +/* 英雄: 1200x260 (span 3 cols) | 子1-3: 387x300 */ +``` + +**4子项版**: +```css +.content-area { grid-template: auto 1fr / repeat(4, 1fr); } +/* 英雄: 1200x260 (span 4 cols) | 子1-4: 285x300 */ +``` + +**2子项版**: +```css +.content-area { grid-template: auto 1fr / 1fr 1fr; } +/* 英雄: 1200x280 (span 2 cols) | 子1-2: 590x280 */ +``` + +英雄卡片需设置 `grid-column: 1 / -1;` 跨所有列。 + +### 7. 混合网格 + +适用: 高信息密度, 4-6个异构块。**推荐:信息密度最高时优先选择。** + +**2x3 网格**: +```css +.content-area { grid-template: repeat(3, 1fr) / 1fr 1fr; } +/* 6个卡片: 各 590x180 */ +``` + +可通过 `grid-row`/`grid-column` 的 span 让个别卡片跨行/跨列,形成大小混搭效果。 + +**关键约束**: 所有卡片不得超出内容区边界(x+w<=1240, y+h<=660),间距>=20px,禁止重叠。 + +--- + +## 布局决策矩阵 + +| 内容特征 | 推荐布局 | 卡片数 | +|---------|---------|-------| +| 1 个核心论点/数据 | 单一焦点 | 1 | +| 2 个对比/并列 | 50/50 对称 | 2 | +| 主概念 + 补充 | 非对称两栏 | 2 | +| 3 个并列要素 | 三栏等宽 | 3 | +| 1 核心 + 2 辅助 | 主次结合 | 3 | +| 综述 + 3-4 子项 | 顶部英雄式 | 4-5 | +| 4-6 异构块 | 混合网格 | 4-6 | + +**选择优先级**:避免"单一焦点"(除非确实只有一个全屏内容)。内容>=3块时,优先选择主次结合/英雄式/混合网格。 + +--- + +## 6 种卡片内容类型 + +### text(文本卡片) +- 标题: h3, 18-20px, 700 weight +- 正文: p, 13-14px, line-height 1.8 +- 关键词用 `` 或 `` 高亮 +- **最低要求**: 标题 + 至少 2 段正文(每段 30-50 字) + +### data(数据卡片) +- 核心数字: 36-48px, 800 weight, accent 色 +- 单位/标签: 14-16px, text-secondary +- 补充解读: 13px +- 推荐搭配一个 CSS 可视化(进度条/对比柱/环形图) +- **最低要求**: 核心数字 + 单位 + 趋势 + 解读 + 可视化 + +### list(列表卡片) +- 圆点: 6-8px 圆点, accent 色 +- 文字: 13px, line-height 1.6 +- 交替使用不同 accent 色圆点增加层次感 +- **最低要求**: 至少 4 条列表项,每条 15-30 字 + +### tag_cloud(标签云) +- 容器: flex-wrap, gap=8px +- 标签: 圆角胶囊形, 12px, accent 色边框 +- **最低要求**: 至少 5 个标签 + +### process(流程卡片) +- 节点: 32px 圆形, accent 色, 居中步骤数字 +- 连线: **真实 `
` 元素**(禁止 ::before/::after) +- 箭头: **内联 SVG ``**(禁止 CSS border 三角形) +- **最低要求**: 至少 3 个步骤,每步标题 + 一句描述 + +### data_highlight(大数据高亮区) +- 用于封面或重点页的超大数据展示 +- 数字: 64-80px, 900 weight, accent 色 +- 副标题 + 补充数据行 +- **最低要求**: 1 个超大数字 + 副标题 + 补充数据行 diff --git a/references/method.md b/references/method.md new file mode 100644 index 0000000..a3dc489 --- /dev/null +++ b/references/method.md @@ -0,0 +1,63 @@ +# 核心方法论 + +> 来源:LINUX DO 论坛 Sandun 分享(7年PPT教学 + 3年AI产品经验) + +## 核心论断 + +> PPT 的灵魂是内容,不是皮囊。 + +## 方法论要点 + +### 1. 从问题开始,不是从模板开始 + +先问清楚:给谁看?为什么做?希望对方记住什么?有哪些不能说错的事实? + +这不是浪费时间 -- 一份精准的需求定义能让后续所有步骤的质量翻倍。专业 PPT 公司收费过万/页,其中至少 30% 的价值来自需求调研。 + +### 2. 内容先行,设计随后 + +推迟精美视觉,直到故事线经得起推敲。策划稿阶段只验证信息结构。 + +为什么这很重要:如果在设计完成后才发现内容逻辑有问题,修改成本是策划阶段的 5-10 倍。先用低成本的文字草稿验证结构,确认无误后再投入设计资源。 + +### 3. 插入策划稿中间层 + +典型工具从大纲直接跳到成品。本方法插入一个中间产物: +- **每页的目的**:这页最想让观众记住什么? +- **核心信息**:标题 + 主卡片内容 + 数据亮点 +- **证据支撑**:来自搜索的真实数据 +- **布局形式**:几张卡片、什么类型、如何排列 +- **层级关系**:主次分明,不是所有信息平铺 + +这是最大的实际质量提升点。策划稿是"地基",没有牢固的地基,再华丽的设计也是空中楼阁。 + +### 4. 用模型能理解的布局语言 + +Bento Grid 卡片式布局是 AI 最容易理解和掌握的设计语言: +- 将页面定义为卡片、容器、层级和间距 +- 让内容驱动布局选择(不是选个模板再往里填字) +- 给出明确的尺寸/间距/强调规则 + +为什么选 Bento Grid 而不是传统幻灯片布局:传统 PPT 布局过于自由,AI 容易"画歪"。卡片式布局天然带有网格约束,AI 在约束内的发挥反而更出色 -- 就像十四行诗比自由诗更容易写出精品。 + +### 5. 阶段间使用结构化输出 + +每个步骤用 JSON 作为数据传递格式,而非自然语言: +- 需求 -> 需求描述 JSON +- 搜索 -> 资料集合 JSON +- 大纲 -> PPT_OUTLINE JSON +- 策划 -> 策划卡 JSON 数组 +- 设计 -> HTML 文件 + +JSON 的好处是**无歧义**。自然语言在传递过程中会信息损耗,JSON 的每个字段都有确切含义,下一步可以精准读取。 + +### 6. 一致性通过共享风格保证 + +先定风格(配色/字体/装饰),再像"生产乐高积木"一样批量生成。 +每页共享同一套 CSS 变量定义,确保 15 页 PPT 的视觉语言完全统一。 + +### 7. 真实数据填充,杜绝幻觉 + +对 AI PPT 最常见的抱怨是"内容空洞废话多"。根源是没有真实数据支撑。 + +本方法通过 Step 2(资料搜集)解决这个问题:先搜索再生成,每个数据点都有来源。宁可少放一条信息,也不编造一个数据。 diff --git a/references/prompts.md b/references/prompts.md new file mode 100644 index 0000000..592ff0f --- /dev/null +++ b/references/prompts.md @@ -0,0 +1,901 @@ +# 可复用 Prompt 模板集 + +使用前替换所有 `{{PLACEHOLDER}}` 占位符。 + +## 目录 + +1. [需求调研 Prompt](#1-需求调研) +2. [大纲架构师 v2.0](#2-大纲架构师) +3. [内容分配与策划稿 Prompt](#3-内容分配与策划稿) +4. [HTML 设计稿生成 Prompt](#4-html-设计稿生成) +5. [演讲备注 Prompt](#5-演讲备注) + +--- + +## 1. 需求调研 + +当用户只给了一个主题时使用。先搜索背景资料,再用专业顾问视角进行深度需求访谈。 + +```text +你是一名顶级 PPT 咨询顾问(10 年演示设计经验,服务过世界 500 强)。用户给了一个主题,你的任务是通过专业访谈挖掘真实需求,而不是直接问"要多少页"这种浅层问题。 + +## 输入 +- 用户主题:{{TOPIC}} +- 背景资料(来自搜索): +{{BACKGROUND_CONTEXT}} + +## 访谈设计原则 +- 围绕"谁看 -> 为什么看 -> 看完要做什么"递进 +- 每个问题都直接影响后续内容策略(不问无用的问题) +- 选项基于搜索结果动态生成,展示你的专业洞察 +- 问题之间有逻辑递进,前一题的答案影响后一题的选项 + +## 7 个深度问题(分三层递进) + +### 第一层:场景与受众(决定整体策略方向) + +1. **演示场景** -- 决定信息密度、节奏和视觉风格 + - A. 现场演讲(会议/路演/汇报 -- 观众注意力有限,需要冲击力强的视觉 + 精简文字) + - B. 自阅文档(发给领导/客户/合作方 -- 需要信息完整、逻辑自洽、能脱离讲者独立理解) + - C. 培训教学(内训/课程/工作坊 -- 需要结构化知识点 + 案例 + 可操作步骤) + - D. 其他(请描述场景) + +2. **核心受众** -- 决定专业深度和说服策略 + - A-D: 根据搜索结果推断的 3-4 种最可能的受众画像(示例:"技术决策者(CTO/架构师)" / "投资人/商业决策者" / "一线执行团队" / "非专业公众") + - 每个画像附一句"他们最关心什么"的注释 + +3. **看完之后,你最希望观众做什么?** -- 决定内容编排的最终导向 + - A. 做出决策(审批/购买/投资/合作) + - B. 理解并记住核心信息 + - C. 掌握具体方法/流程并执行 + - D. 改变认知/态度(对某个议题形成新的看法) + - E. 自定义 + +### 第二层:内容策略(决定信息架构和深度) + +4. **叙事结构** -- 决定大纲的骨架逻辑 + - A. 问题 -> 方案 -> 效果(经典 B2B 说服结构) + - B. 是什么 -> 为什么重要 -> 怎么做(知识科普/培训结构) + - C. 全景 -> 聚焦 -> 行动(先展示大图,再深入核心,最后收敛行动项) + - D. 对比论证(现状 vs 方案 / 竞品 vs 我们 / 过去 vs 未来) + - E. 时间线/发展史(按时间主线叙事) + - F. 自定义结构 + +5. **内容侧重** -- 决定每个 Part 的主题权重 + - A-D: 根据搜索结果中发现的核心维度动态生成 3-4 个选项 + - 每个选项附带一句从搜索结果中提炼的关键发现 + - 可多选:选择 2-3 个作为重点,其余作为辅助 + +6. **说服力要素** -- 决定卡片内容的类型偏好 + - A. 硬数据驱动(市场规模/增长率/ROI/性能指标 -- 适合理性决策者) + - B. 案例故事(客户成功案例/使用场景/前后对比 -- 适合需要共鸣的场合) + - C. 权威背书(行业排名/权威机构认证/媒体报道/专家评价) + - D. 流程方法(分步骤的操作指南/实施路径/技术架构图) + - 可多选 + +### 第三层:执行细节 + +7. **补充信息**(自由文本,以下为提示项): + - 演讲人姓名 / 职位 + - 日期 / 场合名称 + - 公司/机构名称 / Logo / 品牌色 + - 页数偏好(留空则由 AI 根据内容量决定) + - 必须包含的内容(如特定产品线、某个项目成果) + - 必须回避的内容(如敏感竞品、未公开数据) + - 视觉风格偏好(如公司有品牌规范) + - **AI 配图偏好**: + - A. 不需要配图(纯文字/数据驱动) + - B. 只在关键页面配图(封面 + 章节封面,约 3-5 张) + - C. 每页都配图(全页氛围感最强,生成时间较长) + - D. 用户提供图片素材(请提供图片路径) + +## 输出格式 +以"内容需求单"形式一次性展示所有问题。每题格式: + +**[N/7] 问题标题** +问题描述(一句话解释为什么问这个) +- A. 选项1(附注释) +- B. 选项2 +- ... + +在问卷前附一段简短的背景分析(2-3 句话,让用户知道你已经做了功课)。 + +## 注意事项 +- 选项必须基于搜索结果动态生成,不能千篇一律 +- 每个选项的注释体现你的专业洞察(而不是废话) +- 保持语气专业、精准、不啰嗦 +- 问卷总长度控制在一屏可读完(不要写成论文) +``` + +--- + +## 2. 大纲架构师 + +核心 Prompt。输出 PPT 大纲 JSON。 + +```text +# Role: 顶级的PPT结构架构师 + +## Profile +- 版本:2.0 (Context-Aware) +- 专业:PPT逻辑结构设计 +- 特长:运用金字塔原理,结合背景调研信息构建清晰的演示逻辑 + +## Goals +基于用户提供的 PPT主题、目标受众、演示目的与背景信息,设计一份逻辑严密、层次清晰的PPT大纲。 + +## Core Methodology: 金字塔原理 +1. 结论先行:每个部分以核心观点开篇 +2. 以上统下:上层观点是下层内容的总结 +3. 归类分组:同一层级的内容属于同一逻辑范畴 +4. 逻辑递进:内容按照某种逻辑顺序展开(时间/重要性/因果) + +## 重要:利用调研信息 +你将获得关于主题的搜索摘要。请参考这些信息来规划大纲,使其切合当前的市场现状或技术事实,而不是凭空捏造。 +例如:如果调研显示"某技术已过时",则不要将其作为核心推荐。 + +## 输入 +- PPT主题:{{TOPIC}} +- 受众:{{AUDIENCE}} +- 目的:{{PURPOSE}} +- 风格:{{STYLE}} +- 页数要求:{{PAGE_REQUIREMENTS}} +- 内容侧重:{{EMPHASIS}} +- 竞品对比:{{COMPETITOR}} +- 背景信息与搜索资料: +{{CONTEXT}} + +## 输出规范 +请严格按照以下JSON格式输出,结果用 [PPT_OUTLINE] 和 [/PPT_OUTLINE] 包裹: + +[PPT_OUTLINE] +{ + "ppt_outline": { + "cover": { + "title": "引人注目的主标题(要有冲击力,不超过15字)", + "sub_title": "副标题(补充说明,不超过25字)", + "presenter": "演讲人(如有)", + "date": "日期(如有)", + "company": "公司/机构名(如有)" + }, + "table_of_contents": { + "title": "目录", + "content": ["第一部分标题", "第二部分标题", "..."] + }, + "parts": [ + { + "part_title": "第一部分:章节标题", + "part_goal": "这一部分要说明什么(一句话)", + "pages": [ + { + "title": "页面标题(有吸引力,不超过15字)", + "goal": "这一页的核心结论", + "content": ["要点1(含数据支撑)", "要点2", "要点3"], + "data_needs": ["需要的数据/案例类型"] + } + ] + } + ], + "end_page": { + "title": "总结与展望", + "content": ["核心回顾要点1", "核心回顾要点2", "行动号召/联系方式"] + } + } +} +[/PPT_OUTLINE] + +## Constraints +1. 必须严格遵循JSON格式 +2. 页数要求:{{PAGE_REQUIREMENTS}} +3. 每个 part 下至少 2 页内容页 +4. 封面页标题要有冲击力和记忆点 +5. 各 part 之间要有递进逻辑,不能只是并列堆砌 +6. content 中的要点应有搜索数据支撑,标注数据来源 +``` + +--- + +## 3. 内容分配与策划稿 + +将搜索素材精准映射到大纲的每一页,同时生成可执行的策划稿结构。这一步将"内容填充"和"结构设计"合为一体 -- 在思考每页该放什么内容的同时,决定布局和卡片类型,既避免信息在传递中损耗,也减少一轮完整的 LLM 调用。 + +```text +你是一名资深PPT内容架构师兼策划师。你的任务是将搜索资料精准分配到PPT每一页,并同时设计出每页的结构化策划卡。 + +核心目标:每页内容必须"填得满"且结构清晰。一页专业 PPT 不只是一个观点加几行字,而是一个核心论点 + 多维度的支撑 + 印象深刻的数据亮点 + 清晰的布局结构。 + +## 输入 +- PPT主题:{{TOPIC}} +- 受众:{{AUDIENCE}} +- PPT大纲JSON: +{{OUTLINE_JSON}} +- 搜索资料集合: +{{SEARCH_RESULTS}} + +## 任务 + +### 第一步:为每页分配内容 + +遍历大纲每页,执行以下操作: +1. **匹配**:从搜索结果中找到与该页 content 关键词最相关的资料片段 +2. **扩展**:围绕核心论点,从搜索资料中挖掘 3-5 个不同维度的支撑内容 + - 数据维度:具体数字、百分比、排名、对比(如"同比增长 47%") + - 案例维度:具体事例、引用、成功/失败案例 + - 分类维度:将信息拆分为 3-5 个子分类/步骤/要素 + - 对比维度:before/after、竞品对比、行业基准 +3. **改写**:将资料改写为适合PPT展示的精炼文本 + - 主卡片内容:40-100 字(包含完整论点和关键数据) + - 辅助标签/要点:每个 10-30 字 + - 使用短句和关键词 +4. **补充**:主动从搜索结果中补充大纲未覆盖但相关的数据点 +5. **指定卡片类型**:每条内容标注建议的 card_type + +### 第二步:为每页设计策划结构 + +在内容分配完成的基础上,为每页设计可供设计执行的策划卡: + +#### 布局选择指南 +根据内容特征选择最合适的布局(优先选择高信息密度布局): +- 1 个核心论点/数据 -> 单一焦点(仅用于极特殊的全屏展示) +- 2 个对比概念 -> 50/50 对称 +- 主概念 + 补充说明 -> 非对称两栏(2/3 + 1/3)-- 最常用 +- 3 个并列要素 -> 三栏等宽 +- 1 个核心 + 2 个辅助数据/列表 -> **主次结合**(推荐:信息层次丰富) +- 1 个综述 + 3-4 个子项 -> **顶部英雄式**(推荐:总分结构清晰) +- 4-6 个异构信息块 -> **混合网格**(推荐:信息密度最高) + +## 输出格式 + +为每页输出一个 JSON 对象,整体组成 JSON 数组。每个对象同时包含"内容"和"策划结构": + +```json +{ + "page_number": 1, + "page_type": "cover | toc | section | content | end", + "title": "页面标题", + "goal": "这页最想让观众记住什么", + "layout_hint": "布局建议(如:主次结合 / 英雄式 + 下方三栏 / 混合网格)", + "content_summary": { + "core_argument": "一句话核心论点", + "main_content": "40-100字的主卡片内容", + "data_highlights": [ + {"value": "具体数字", "label": "标签", "interpretation": "一句解读"} + ], + "supporting_points": ["辅助要点1", "辅助要点2", "辅助要点3"], + "quote_or_conclusion": "一句有力的结论或权威引用(可选)" + }, + "cards": [ + { + "position": "位置描述(top-left / top-right / bottom-left 等)", + "card_type": "text | data | list | chart_placeholder | tag_cloud | process", + "title": "卡片标题(12字内)", + "content": "卡片正文(80字内)", + "data_points": ["具体数据"], + "emphasis_keywords": ["需要强调的关键词"] + } + ], + "design_notes": "设计注意事项(哪些内容不能弱化,哪些可做装饰)" +} +``` + +## 硬性要求 +- 每个内容页 cards[] 数组至少 **3 张卡片** +- 每个内容页至少使用 **2 种不同的 card_type**(不能全是 text) +- 每个内容页至少 **1 张 data 类型卡片**(突出数字的视觉冲击力) +- 每个内容页至少包含 **1 个数据亮点**(content_summary.data_highlights 中具体数字) +- >= 70% 的内容页应包含标签/列表类辅助信息 +- 避免使用"单一焦点"布局,除非该页确实只需要一个全屏图表 +- 零幻觉:所有数据必须来自搜索结果 +- 覆盖所有页面(封面到结束页) +``` + +--- + +## 4. HTML 设计稿生成 + +核心设计 Prompt。每次调用生成一页完整 HTML 页面。调用前必须注入完整的风格定义和策划稿结构 JSON。 + +```text +你是一名精通信息架构与现代 Web 设计的顶级演示文稿设计师。你的目标是将内容转化为一张高质量、结构化、具备高级感和专业感的 HTML 演示页面 -- 达到专业设计公司 1 万+/页的视觉水准。 + +## 全局风格定义 +{{STYLE_DEFINITION}} + +(示例: +{ + "style_name": "高阶暗黑科技风", + "background": { "primary": "#0B1120", "gradient_to": "#0F172A" }, + "card": { "gradient_from": "#1E293B", "gradient_to": "#0F172A", "border": "rgba(255,255,255,0.05)", "border_radius": 12 }, + "text": { "primary": "#FFFFFF", "secondary": "rgba(255,255,255,0.7)" }, + "accent": { "primary": ["#22D3EE", "#3B82F6"], "secondary": ["#FDE047", "#F59E0B"] }, + "grid_dot": { "color": "#FFFFFF", "opacity": 0.05, "size": 40 } +} +将这些值必须一一映射为 CSS 变量,确保全部页面风格一致。) + +## 策划稿结构 +{{PLANNING_JSON}} + +(即 Prompt #3 输出的该页 JSON,包含 page_type、layout_hint、cards[]、每张卡片的 card_type/position/content/data_points。严格按照策划稿的卡片数量、类型和位置关系来设计。) + +## 页面内容 +{{PAGE_CONTENT}} + +## 配图信息(如有) +{{IMAGE_INFO}} + +--- + +## 画布规范(不可修改) + +- 固定尺寸: width=1280px, height=720px, overflow=hidden +- 标题区: 左上 40px 边距, y=20~70, 最大高度 50px +- 内容区: padding 40px, y 从 80px 起, 可用高度 580px, 可用宽度 1200px +- 页脚区: 底部 40px 边距内,高度 20px + +## 排版系统(Typography Scale) + +专业 PPT 的排版不是随意选字号,而是遵循严格的层级阶梯。每一级字号都有明确的用途和间距规则: + +| 层级 | 用途 | 字号 | 字重 | 行高 | 颜色 | +|------|------|------|------|------|------| +| H0 | 封面主标题 | 48-56px | 900 | 1.1 | --text-primary | +| H1 | 页面主标题 | 28px | 700 | 1.2 | --text-primary | +| H2 | 卡片标题 | 18-20px | 700 | 1.3 | --text-primary | +| Body | 正文段落 | 13-14px | 400 | 1.8 | --text-secondary | +| Caption | 辅助标注/脚注/来源 | 12px | 400 | 1.5 | --text-secondary, opacity 0.6 | +| Overline | PART 标识/标签前缀 | 11-12px | 700, letter-spacing: 2-3px | 1.0 | --accent-1 | +| Data | 数据数字 | 36-48px (卡片) / 64-80px (高亮) | 800-900 | 1.0 | --accent-1 | + +### 排版间距层级(卡片内部) + +不同层级的内容之间,间距也分层级。间距体现信息的亲疏关系: + +| 位置 | 间距 | 原因 | +|------|------|------| +| 卡片标题 -> 正文 | 16px | 标题和内容是不同层级,需要明确分隔 | +| 正文段落之间 | 12px | 同级内容,间距较小 | +| 数据数字 -> 标签 | 8px | 数字和标签紧密关联 | +| 数据标签 -> 解读文字 | 12px | 解读是补充信息 | +| 列表项之间 | 10px | 列表项平等并列 | +| 最后一个内容块 -> 卡片底部 | >= 16px | 避免内容贴底 | + +### 中英文混排规则 + +- 中文和英文/数字之间自动加一个半角空格(如:"增长率达到 47.3%") +- 数据数字推荐使用 `font-variant-numeric: tabular-nums` 让数字等宽对齐 +- 大号数据数字(36px+)建议用 `font-family: 'Inter', 'DIN', var(--font-family)` 让数字更有冲击力 + +## 色彩比例法则(60-30-10) + +这是设计界的铁律,决定页面是"高级"还是"花哨": + +| 比例 | 角色 | 应用范围 | 效果 | +|------|------|---------|------| +| **60%** | 主色(背景) | 页面背景 `--bg-primary` | 奠定基调 | +| **30%** | 辅色(内容区) | 卡片背景 `--card-bg-from/to` | 承载信息 | +| **10%** | 强调色(点缀) | `--accent-1` ~ `--accent-4` | 引导视线 | + +### accent 色使用约束 + +强调色是"调味料",用多了就毁了整道菜: + +- **允许使用 accent 色的元素**:标题下划线/竖线(3-4px)、数据数字颜色、标签边框/文字、进度条填充、PART 编号、圆点/节点、图标背景 +- **禁止使用 accent 色的元素**:大面积卡片背景、正文段落文字、大面积色块填充 +- **同页限制**:同一页面最多同时使用 2 种 accent 色(--accent-1 和 --accent-2),不要 4 个全用 +- **每个卡片**:最多使用 1 种 accent 色作为主题色 + +## Bento Grid 布局系统 + +根据 layout_hint 选择布局,用 CSS Grid 精确实现。所有坐标基于内容区(40px padding)。 + +### 布局映射表 + +| layout_hint | CSS grid-template | 卡片尺寸 | +|-------------|------------------|---------| +| 单一焦点 | 1fr / 1fr | 1200x580 | +| 50/50 对称 | 1fr 1fr / 1fr | 各 590x580 | +| 非对称两栏 (2/3+1/3) | 2fr 1fr / 1fr | 790+390 x 580 | +| 三栏等宽 | repeat(3, 1fr) / 1fr | 各 387x580 | +| 主次结合 | 2fr 1fr / 1fr 1fr | 790x580 + 390x280x2 | +| 英雄式+3子 | 1fr / auto 1fr 然后 repeat(3,1fr) | 1200x260 + 387x300x3 | +| 混合网格 | 自定义 grid-row/column span | 尺寸由内容决定 | + +间距: gap=20px | 圆角: border-radius=12px | 内边距: padding=24px + +## 6 种卡片类型的 HTML 实现 + +### text(文本卡片) +- 标题: h3, font-size=18-20px, font-weight=700, color=text-primary +- 正文: p, font-size=13-14px, line-height=1.8, color=text-secondary +- 关键词: 用 包裹(背景 accent-primary 10% 透明度) + +### data(数据卡片) +- 核心数字: font-size=36-48px, font-weight=800, 使用 accent-primary 渐变色(background: linear-gradient; -webkit-background-clip 除外) + - SVG 友好替代: 直接用 color=accent-primary,不要用 -webkit-background-clip: text +- 单位/标签: font-size=14-16px, color=text-secondary +- 补充说明: font-size=13px, 在数字下方 + +### list(列表卡片) +- 列表项: display=flex, gap=10px +- 圆点: min-width=6-8px, height=6-8px, border-radius=50%, background=accent-primary +- 文字: font-size=13px, color=text-secondary, line-height=1.6 +- 交替使用不同 accent 色的圆点增加层次感 + +### tag_cloud(标签云) +- 容器: display=flex, flex-wrap=wrap, gap=8px +- 标签: display=inline-block, padding=4px 12px, border-radius=9999px +- 标签边框: border=1px solid accent-primary 30%透明, color=accent-primary, font-size=12px + +### process(流程卡片) +- 步骤: display=flex 水平排列,或垂直排列 +- 节点: width/height=32px, border-radius=50%, background=accent-primary, 居中显示步骤数字 +- 连线: 节点之间用**真实 `
` 元素**作为连接线(height=2px, background=accent-color),**禁止**用 ::before/::after 伪元素画连线 +- 箭头: 用内联 `` 三角形(`` 或 ``),**禁止**用 CSS border 技巧画三角形 +- 标签: font-size=12-13px, margin-top=8px + +### data_highlight(大数据高亮区) +- 用于封面或重点页的超大数据展示 +- 数字: font-size=64-80px, font-weight=900 +- 用 accent 颜色直接上色(避免 -webkit-background-clip: text) + +## 视觉设计原则 + +### 渐变使用约束(慎用渐变) +渐变用不好比纯色更丑。遵循以下限制: +- **允许渐变的场景**:页面背景(大面积微妙过渡)、强调色竖线/横线(3-4px 窄条)、进度条填充 +- **禁止渐变的场景**:正文文字颜色、小尺寸图标填充、卡片背景(除非暗色系微妙过渡)、按钮 +- **渐变方向**:同一页面内所有渐变方向保持一致(统一 135deg 或 180deg) +- **渐变色差**:两端颜色色相差不超过 60 度(如蓝-青可以,蓝-橙禁止),亮度差不超过 20% +- **首选纯色**:当不确定渐变效果时,用 accent 纯色(`var(--accent-1)`)替代 + +### 层次感 +- 页面标题(H1): 28px, 700 weight, 左上固定位,搭配 accent 色的标题下划线或角标 +- Overline 标记(如"PART 0X"): 11-12px, 700 weight, letter-spacing=2-3px, accent 色 +- 卡片标题(H2) > 数据数字(Data) > 正文(Body) > 辅助标注(Caption) -- 严格遵循排版阶梯 + +### 装饰元素词汇表 + +以下是专业 PPT 中常用的装饰元素。每页至少使用 2-3 种装饰元素,但不要过度堆砌。所有装饰必须使用真实 DOM 节点。 + +#### 基础装饰(所有风格通用) + +| 装饰 | 实现方式 | 使用时机 | +|------|---------|--------| +| 背景网格点阵 | radial-gradient(circle, dot-color dot-size, transparent dot-size), background-size=grid-size | grid_pattern.enabled=true 的风格 | +| 标题下划线 | `
` 4px 高, 40-60px 宽, accent 渐变, 在标题下方 4px 处 | 每页标题 | +| 卡片左侧强调线 | `
` 3-4px 宽, 100% 高, accent 色, position=absolute, left=0 | 文本卡片/引用 | +| 编号气泡 | `
` 32-40px 圆形, accent 色背景, 白色数字 | 步骤/列表序号 | +| 分隔渐隐线 | `
` 1px 高, linear-gradient(90deg, accent 30%, transparent) | 卡片内区域分隔 | + +#### 深色风格专用 + +| 装饰 | 实现方式 | 效果 | +|------|---------|------| +| 角落装饰线 | `
` L 形边框(只显示两条边: border-top + border-left),accent 色 20% 透明度 | 页面四角层次感 | +| 光晕效果 | `
` radial-gradient 超大半透明圆(400-600px),accent 色 5-8% 透明度 | 关键区域背后的辉光 | +| 半透明数字水印 | `
` 超大号数字(120-160px), accent 色, opacity 0.03-0.05 | 页面层次感/章节标识 | +| 卡片分隔线 | `
` 1px solid rgba(255,255,255,0.05) | 卡片间微妙分界 | + +#### 浅色风格专用 + +| 装饰 | 实现方式 | 效果 | +|------|---------|------| +| 渐变色块 | `
` 大面积弧形色块, accent 色 5-10% 透明度, border-radius 50% | 卡片一角的活泼感 | +| 细边框卡片 | border: 1px solid var(--card-border) | 清晰的区域划分 | +| 圆形图标底 | `
` 48px 圆形, accent 色 10% 透明度背景 + 内联 SVG 图标 | 替代纯文字列表 | + +#### 统一页脚系统 + +每页(封面和章节封面除外)底部必须有统一页脚: + +```html +
+ + + PART 01 - 章节名称 + + + + 07 / 15 | 品牌名 + +
+``` + +页脚规则: +- 字号 11px, text-secondary 色, opacity 0.5(极其低调,不抢内容视线) +- 左侧显示当前 PART 编号 + 章节名 +- 右侧显示 当前页/总页数 + 品牌名(如有) +- **封面页、章节封面不显示页脚** + +### 配图融入设计(根据用户偏好决定是否配图) + +配图是可选项,在需求调研阶段由用户决定: +- **不配图**: 跳过本节 +- **只关键页**: 仅封面、章节封面、结束页配图 +- **每页配图**: 所有页面都有图片融入 + +当需要配图时,图片不能像贴纸一样硬塞在页面里。必须通过**视觉融入技法**让图片与内容浑然一体。 + +**核心原则**:图片是**氛围的一部分**,不是独立的内容块。 + +#### 5 种融入技法(全部管线安全) + +##### 1. 渐隐融合 -- 封面页/章节封面的首选 + +图片占页面右半部分,左侧边缘用渐变遮罩渐隐到背景色,让图片"消融"在背景中。 + +```html +
+ + +
+
+``` + +##### 2. 色调蒙版 -- 内容页大卡片 + +图片上覆盖半透明色调层,让图片染上主题色,同时降低视觉干扰。 + +```html +
+ + +
+ +
+ +
+
+``` + +##### 3. 氛围底图 -- 章节封面/数据页 + +图片作为整页超低透明度背景,营造氛围感。 + +```html + +``` + +##### 4. 裁切视窗 -- 小卡片顶部 + +图片作为卡片头部的"窗口",用圆角裁切,底部渐隐到卡片背景。 + +```html +
+ +
+
+``` + +##### 5. 圆形/异形裁切 -- 数据卡片辅助 + +图片裁切为圆形或其他形状,作为装饰元素。 + +```html + +``` + +#### 按页面类型选择技法 + +| 页面类型 | 推荐技法 | opacity 范围 | +|---------|---------|-------------| +| 封面页 | 渐隐融合 | 0.25-0.40 | +| 章节封面 | 氛围底图 或 渐隐融合 | 0.05-0.15 | +| 英雄卡片 | 色调蒙版 | 图片0.3 + 蒙版0.7 | +| 大卡片(>=50%宽) | 色调蒙版 或 裁切视窗 | 0.15-0.30 | +| 小卡片(<400px) | 裁切视窗 或 圆形裁切 | 0.8-1.0 | +| 数据页 | 氛围底图 | 0.05-0.10 | + +#### 图片 HTML 规范 +- 使用真实 `` 标签(禁用 CSS background-image) +- 渐变遮罩用**真实 `
`**(禁用 ::before/::after) +- `object-fit: cover`,`border-radius` 与容器一致 +- 图片使用**绝对路径**(由 agent 生成图片后填入) + +**禁止**: +- 禁止图片直接裸露在卡片角落(无融入效果) +- 禁止图片占据整个卡片且无蒙版(文字不可读) +- 禁止图片与背景色有明显的矩形边界线 + +## 对比度安全规则(必须遵守) + +文字颜色必须与其直接背景形成足够对比度,否则用户看不清: + +| 背景类型 | 文字颜色要求 | +|---------|------------| +| 深色背景 (--bg-primary 亮度 < 40%) | 标题用 --text-primary(白色/浅色), 正文用 --text-secondary(70%白) | +| 浅色背景 (--bg-primary 亮度 > 60%) | 标题用 --text-primary(深色/黑色), 正文用 --text-secondary(灰色) | +| 卡片内部 | 跟随卡片背景明暗选择文字色 | +| accent 色文字 | 只能用于标题/标签/数据数字,不能用于大段正文 | + +**禁止行为**: +- 禁止深色背景 + 深色文字(如黑底黑字、深蓝底深灰字) +- 禁止浅色背景 + 白色文字 +- 禁止硬编码颜色值,所有颜色必须通过 CSS 变量引用 + +## 纯 CSS 数据可视化(推荐使用) + +数据卡片不要只放一个大数字。用纯 CSS/SVG 实现轻量数据可视化,让数字更有冲击力。以下是 8 种可视化类型,根据数据特征选择: + +### 1. 进度条(表示百分比/完成度) +```css +.progress-bar { + height: 8px; border-radius: 4px; + background: var(--card-bg-from); + overflow: hidden; +} +.progress-bar .fill { + height: 100%; border-radius: 4px; + background: linear-gradient(90deg, var(--accent-1), var(--accent-2)); + /* width 用内联 style 设置百分比 */ +} +``` + +### 2. 对比柱(两项对比) +```css +.compare-bar { + display: flex; gap: 4px; align-items: flex-end; + height: 60px; +} +.compare-bar .bar { + flex: 1; border-radius: 4px 4px 0 0; + /* height 用内联 style 设置百分比 */ +} +``` + +### 3. 环形百分比(必须用内联 SVG,禁止 conic-gradient) +```html +
+ + + + 90% + +
+``` +计算公式: dasharray 第一个值 = 2 * PI * r * (百分比/100), 第二个值 = 2 * PI * r + +### 4. 指标行(数字+标签+进度条 组合) +```html +
+ 87% +
+
用户满意度
+
+
+
+``` + +### 5. 迷你折线图 Sparkline(趋势方向) +```html + + + + + + + + +``` +用在数据数字旁边,占位小但信息量大。数据点坐标根据实际趋势调整 y 值(高=好 -> y 值小)。 + +### 6. 点阵图 Waffle Chart(百分比直觉化) +```html +
+ +
+ +
+ +
+``` +10x10 = 100 格,填充数量 = 百分比值。比进度条更直觉。 + +### 7. KPI 指标卡(数字+趋势箭头+标签) +```html +
+ 2.4M + + + + + +12.3% +
+
月活跃用户数
+``` +趋势箭头颜色:上升用绿色 #16A34A,下降用红色 #DC2626,持平用 text-secondary。 + +### 8. 评分指示器(5分制) +```html +
+ +
+
+
+
+
+
+``` + +### 可视化选择指南 + +| 数据类型 | 推荐可视化 | +|---------|----------| +| 百分比/完成度 | 进度条 或 环形百分比 | +| 两项对比 | 对比柱 | +| 时间趋势 | 迷你折线图 | +| 比例直觉化 | 点阵图 | +| 核心 KPI | KPI 指标卡 | +| 多指标并排 | 指标行(多行堆叠) | +| 评级/评分 | 评分指示器 | + +## 内容密度要求 + +每张卡片不能只有一个标题和一句话,必须信息充实: + +| 卡片类型 | 最低内容要求 | +|---------|------------| +| text | 标题 + 至少 2 段正文(每段 30-50 字)或 标题 + 3-5 条要点 | +| data | 核心数字 + 单位 + 变化趋势(升/降/持平) + 一句解读 + 进度条/对比可视化 | +| list | 至少 4 条列表项,每条 15-30 字 | +| process | 至少 3 个步骤,每步有标题+一句描述 | +| tag_cloud | 至少 5 个标签 | +| data_highlight | 1 个超大数字 + 副标题 + 补充数据行 | + +**禁止**:空白卡片、只有标题没有内容的卡片、只有一句话的卡片 + +## 特殊字符与单位符号处理(必须遵守) + +专业内容中大量使用特殊字符、单位符号、上下标。这些符号必须正确输出,否则在 SVG/PPTX 中会乱码或丢失: + +| 类型 | 正确写法 | 错误写法 | 说明 | +|------|----------|----------|------| +| 温度 | `25–40 °C` 或 `25–40 °C` | `25-40 oC` | 用 Unicode 度符号而不是字母 o | +| 百分比 | `99.9%` | `99.9 %`(前面加空格) | 数字和 % 之间不加空格 | +| ppm | `100 ppm` | `100ppm` | 数字和单位之间加空格 | +| 化学式下标 | `H₂O` 或 `H2O` | `H2O` | 用 Unicode 下标数字或 sub 标签 | +| 化学式上标 | `m²` 或 `m2` | `m2` | 用 Unicode 上标或 sup 标签 | +| 大于等于 | `≥ 99.9%` 或 `>=99.9%` | `> =99.9%` | 不要在 > 和 = 之间加空格 | +| 微米 | `0.22 μm` | `0.22 um` | 用 Unicode mu 而不是字母 u | + +### 规则 +1. **优先用 Unicode 直接字符**(° ² ³ μ ≥ ≤ ₂ ₃),而不是 HTML 实体,因为 Unicode 在 SVG/PPTX 中渲染最可靠 +2. **数字与单位之间**:英文单位前加一个半角空格(`100 ppm`),符号单位紧跟(`99.9%`、`25°C`) +3. **化学式中的下标数字**:必须用 `` 标签或 Unicode 下标字符(₀₁₂₃₄₅₆₇₈₉),绝对不能用普通数字代替 + +## 页面级情感设计 + +不同页面类型有不同的情感目标: + +| 页面类型 | 情感目标 | 设计要求 | +|---------|---------|---------| +| 封面页 | 视觉冲击、专业信赖 | 大标题+配图、装饰元素要丰富、品牌感要强 | +| 目录页 | 清晰导航、预期管理 | 每章有图标/色块标识、章节编号醒目 | +| 章节封面 | 过渡、呼吸感 | PART 编号大号显示、引导语、留白充分 | +| 内容页 | 信息传递、数据说服 | 卡片密度高、数据可视化、要点清晰 | +| 结束页 | 总结回顾、行动号召 | 3-5 条核心要点回顾 + 明确的 CTA(联系方式/下一步) | + +## PPTX 兼容的 CSS/HTML 约束(必须遵守) + +本 HTML 最终会经过 dom-to-svg -> svg2pptx 管线转为 PowerPoint 原生形状。以下规则确保转换不丢失任何视觉元素: + +### 禁止使用的 CSS 特性(dom-to-svg 不支持,会导致元素丢失) + +| 禁止 | 原因 | 替代方案 | +|------|------|----------| +| `::before` / `::after` 伪元素(用于视觉装饰) | dom-to-svg 无法读取伪元素 | 改用**真实 `
`/`` 元素** | +| `conic-gradient()` | dom-to-svg 不支持 | 改用**内联 SVG `` + stroke-dasharray** | +| CSS border 三角形(width:0 + border trick) | 转 SVG 后形状丢失 | 改用**内联 SVG ``** | +| `-webkit-background-clip: text` | 渐变文字不可转换 | 改用 `color: var(--accent-1)` 纯色 | +| `mask-image` / `-webkit-mask-image` | SVG 转换后形状丢失 | 改用 `clip-path` 或 `border-radius` | +| `mix-blend-mode` | 不被 SVG 支持 | 改用 `opacity` 叠加 | +| `filter: blur()` | 光栅化导致模糊区域变位图 | 改用 `opacity` 或 `box-shadow` | +| `content: '文字'`(伪元素文本) | 不会出现在 SVG 中 | 改用真实 `` 元素 | +| CSS `counter()` / `counter-increment` | 伪元素依赖 | 改用真实 HTML 文本 | + +### 安全可用的 CSS 特性 +- `linear-gradient` 背景 +- `radial-gradient` 背景(纯装饰用途) +- `border-radius`, `box-shadow` +- `opacity` +- 普通 `color`, `font-size`, `font-weight`, `letter-spacing` +- `border` 属性(用于边框,不是三角形) +- `clip-path` +- `transform: translate/rotate/scale` +- 内联 `` 元素(**推荐用于图表/箭头/图标**) + +### 核心原则 +> **凡是视觉上可见的元素,必须是真实的 DOM 节点。** 伪元素仅可用于不影响视觉输出的用途(如 clearfix)。 +> **需要图形(箭头/环图/图标/三角形)时,优先用内联 SVG。** + +## CSS 变量模板 + +所有颜色值必须通过 CSS 变量引用,禁止硬编码 hex/rgb 值(唯一例外:transparent 和白色透明度 rgba(255,255,255,0.x))。 + +```css +:root { + --bg-primary: {{background.primary}}; + --bg-secondary: {{background.gradient_to}}; + --card-bg-from: {{card.gradient_from}}; + --card-bg-to: {{card.gradient_to}}; + --card-border: {{card.border}}; + --card-radius: {{card.border_radius}}px; + --text-primary: {{text.primary}}; + --text-secondary: {{text.secondary}}; + --accent-1: {{accent.primary[0]}}; + --accent-2: {{accent.primary[1]}}; + --accent-3: {{accent.secondary[0]}}; + --accent-4: {{accent.secondary[1]}}; + --grid-dot-color: {{grid_dot.color}}; + --grid-dot-opacity: {{grid_dot.opacity}}; + --grid-size: {{grid_dot.size}}px; +} +``` + +## 输出要求 +- 输出完整 HTML 文件(含 、 + + +
+ + 1 / {total} + +
+
+{iframes_block} +
+ + + +""" + + +def main(): + parser = argparse.ArgumentParser(description="HTML Packager for PPT Agent") + parser.add_argument("path", help="Directory containing slide HTML files") + parser.add_argument("-o", "--output", default=None, help="Output HTML file") + parser.add_argument("--title", default="PPT Preview", help="Title") + args = parser.parse_args() + + slides_dir = Path(args.path) + if not slides_dir.is_dir(): + print(f"Error: {slides_dir} is not a directory", file=sys.stderr) + sys.exit(1) + + html_files = sorted(slides_dir.glob("*.html")) + if not html_files: + print(f"Error: No HTML files in {slides_dir}", file=sys.stderr) + sys.exit(1) + + output_path = args.output or str(slides_dir.parent / "preview.html") + + result = build_preview(html_files, title=args.title) + + with open(output_path, "w", encoding="utf-8") as f: + f.write(result) + + print(f"Created: {output_path} ({len(html_files)} slides)") + + +if __name__ == "__main__": + main() diff --git a/scripts/svg2pptx.py b/scripts/svg2pptx.py new file mode 100644 index 0000000..de099d4 --- /dev/null +++ b/scripts/svg2pptx.py @@ -0,0 +1,942 @@ +#!/usr/bin/env python3 +"""SVG to PPTX -- 将 SVG 元素解析为原生 OOXML 形状 + +支持: rect, text+tspan, circle, ellipse, line, path, image(data URI + file) + linearGradient, radialGradient, transform(translate/scale/matrix) + group opacity 传递, 首屏 rect 自动设为幻灯片背景 + +用法: + python svg2pptx.py -o output.pptx +""" + +import argparse +import base64 +import io +import math +import re +import sys +from pathlib import Path + +from lxml import etree +from pptx import Presentation +from pptx.util import Emu + +# ------------------------------------------------------------------- +# 常量 +# ------------------------------------------------------------------- +SVG_NS = 'http://www.w3.org/2000/svg' +XLINK_NS = 'http://www.w3.org/1999/xlink' +NS = { + 'a': 'http://schemas.openxmlformats.org/drawingml/2006/main', + 'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships', + 'p': 'http://schemas.openxmlformats.org/presentationml/2006/main', +} +EMU_PX = 9525 +SLIDE_W = 12192000 +SLIDE_H = 6858000 + +# CSS 完整命名颜色表(常用子集) +CSS_COLORS = { + 'aliceblue': 'f0f8ff', 'antiquewhite': 'faebd7', 'aqua': '00ffff', + 'aquamarine': '7fffd4', 'azure': 'f0ffff', 'beige': 'f5f5dc', + 'bisque': 'ffe4c4', 'black': '000000', 'blanchedalmond': 'ffebcd', + 'blue': '0000ff', 'blueviolet': '8a2be2', 'brown': 'a52a2a', + 'burlywood': 'deb887', 'cadetblue': '5f9ea0', 'chartreuse': '7fff00', + 'chocolate': 'd2691e', 'coral': 'ff7f50', 'cornflowerblue': '6495ed', + 'cornsilk': 'fff8dc', 'crimson': 'dc143c', 'cyan': '00ffff', + 'darkblue': '00008b', 'darkcyan': '008b8b', 'darkgoldenrod': 'b8860b', + 'darkgray': 'a9a9a9', 'darkgreen': '006400', 'darkgrey': 'a9a9a9', + 'darkkhaki': 'bdb76b', 'darkmagenta': '8b008b', 'darkolivegreen': '556b2f', + 'darkorange': 'ff8c00', 'darkorchid': '9932cc', 'darkred': '8b0000', + 'darksalmon': 'e9967a', 'darkseagreen': '8fbc8f', 'darkslateblue': '483d8b', + 'darkslategray': '2f4f4f', 'darkturquoise': '00ced1', 'darkviolet': '9400d3', + 'deeppink': 'ff1493', 'deepskyblue': '00bfff', 'dimgray': '696969', + 'dodgerblue': '1e90ff', 'firebrick': 'b22222', 'floralwhite': 'fffaf0', + 'forestgreen': '228b22', 'fuchsia': 'ff00ff', 'gainsboro': 'dcdcdc', + 'ghostwhite': 'f8f8ff', 'gold': 'ffd700', 'goldenrod': 'daa520', + 'gray': '808080', 'green': '008000', 'greenyellow': 'adff2f', + 'grey': '808080', 'honeydew': 'f0fff0', 'hotpink': 'ff69b4', + 'indianred': 'cd5c5c', 'indigo': '4b0082', 'ivory': 'fffff0', + 'khaki': 'f0e68c', 'lavender': 'e6e6fa', 'lawngreen': '7cfc00', + 'lemonchiffon': 'fffacd', 'lightblue': 'add8e6', 'lightcoral': 'f08080', + 'lightcyan': 'e0ffff', 'lightgoldenrodyellow': 'fafad2', 'lightgray': 'd3d3d3', + 'lightgreen': '90ee90', 'lightpink': 'ffb6c1', 'lightsalmon': 'ffa07a', + 'lightseagreen': '20b2aa', 'lightskyblue': '87cefa', 'lightslategray': '778899', + 'lightsteelblue': 'b0c4de', 'lightyellow': 'ffffe0', 'lime': '00ff00', + 'limegreen': '32cd32', 'linen': 'faf0e6', 'magenta': 'ff00ff', + 'maroon': '800000', 'mediumaquamarine': '66cdaa', 'mediumblue': '0000cd', + 'mediumorchid': 'ba55d3', 'mediumpurple': '9370db', 'mediumseagreen': '3cb371', + 'mediumslateblue': '7b68ee', 'mediumspringgreen': '00fa9a', + 'mediumturquoise': '48d1cc', 'mediumvioletred': 'c71585', 'midnightblue': '191970', + 'mintcream': 'f5fffa', 'mistyrose': 'ffe4e1', 'moccasin': 'ffe4b5', + 'navajowhite': 'ffdead', 'navy': '000080', 'oldlace': 'fdf5e6', + 'olive': '808000', 'olivedrab': '6b8e23', 'orange': 'ffa500', + 'orangered': 'ff4500', 'orchid': 'da70d6', 'palegoldenrod': 'eee8aa', + 'palegreen': '98fb98', 'paleturquoise': 'afeeee', 'palevioletred': 'db7093', + 'papayawhip': 'ffefd5', 'peachpuff': 'ffdab9', 'peru': 'cd853f', + 'pink': 'ffc0cb', 'plum': 'dda0dd', 'powderblue': 'b0e0e6', + 'purple': '800080', 'rebeccapurple': '663399', 'red': 'ff0000', + 'rosybrown': 'bc8f8f', 'royalblue': '4169e1', 'saddlebrown': '8b4513', + 'salmon': 'fa8072', 'sandybrown': 'f4a460', 'seagreen': '2e8b57', + 'seashell': 'fff5ee', 'sienna': 'a0522d', 'silver': 'c0c0c0', + 'skyblue': '87ceeb', 'slateblue': '6a5acd', 'slategray': '708090', + 'snow': 'fffafa', 'springgreen': '00ff7f', 'steelblue': '4682b4', + 'tan': 'd2b48c', 'teal': '008080', 'thistle': 'd8bfd8', + 'tomato': 'ff6347', 'turquoise': '40e0d0', 'violet': 'ee82ee', + 'wheat': 'f5deb3', 'white': 'ffffff', 'whitesmoke': 'f5f5f5', + 'yellow': 'ffff00', 'yellowgreen': '9acd32', +} + +# 字体回退链 +FONT_FALLBACK = { + 'PingFang SC': 'Microsoft YaHei', + 'SF Pro Display': 'Arial', + 'Helvetica Neue': 'Arial', + 'Helvetica': 'Arial', + 'system-ui': 'Microsoft YaHei', + 'sans-serif': 'Microsoft YaHei', +} + + +def px(v): + return int(float(v) * EMU_PX) + +def font_sz(svg_px): + return max(100, int(float(svg_px) * 75)) + +def strip_unit(v): + return re.sub(r'[a-z%]+', '', str(v)) + +def resolve_font(ff_str): + """解析 font-family 字符串,返回 PPT 可用字体。""" + ff_str = ff_str.replace('"', '').replace('"', '').replace("'", '') + fonts = [f.strip() for f in ff_str.split(',') if f.strip()] + for f in fonts: + if f in FONT_FALLBACK: + return FONT_FALLBACK[f] + if f and f not in ('sans-serif', 'serif', 'monospace', 'system-ui'): + return f + return 'Microsoft YaHei' + + +# ------------------------------------------------------------------- +# 颜色解析(完整 CSS 命名颜色) +# ------------------------------------------------------------------- +def parse_color(s): + if not s or s.strip() == 'none': + return None + s = s.strip() + if s.startswith('url('): + m = re.search(r'#([\w-]+)', s) + return ('grad', m.group(1)) if m else None + m = re.match(r'rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)', s) + if m: + r, g, b = int(m.group(1)), int(m.group(2)), int(m.group(3)) + a = float(m.group(4)) if m.group(4) else 1.0 + return (f'{r:02x}{g:02x}{b:02x}', int(a * 100000)) + if s.startswith('#'): + h = s[1:] + if len(h) == 3: + h = h[0]*2 + h[1]*2 + h[2]*2 + return (h.lower().ljust(6, '0')[:6], 100000) + c = CSS_COLORS.get(s.lower()) + return (c, 100000) if c else None + + +# ------------------------------------------------------------------- +# OOXML 元素构造 +# ------------------------------------------------------------------- +def _el(tag, attrib=None, text=None, children=None): + pre, local = tag.split(':') if ':' in tag else ('a', tag) + el = etree.Element(f'{{{NS[pre]}}}{local}') + if attrib: + for k, v in attrib.items(): + el.set(k, str(v)) + if text is not None: + el.text = str(text) + for c in (children or []): + if c is not None: + el.append(c) + return el + +def _srgb(hex6, alpha=100000): + el = _el('a:srgbClr', {'val': hex6}) + if alpha < 100000: + el.append(_el('a:alpha', {'val': str(alpha)})) + return el + +def make_fill(fill_str, grads, opacity=1.0): + c = parse_color(fill_str) + if c is None: + return _el('a:noFill') + if c[0] == 'grad': + gdef = grads.get(c[1]) + return _make_grad(gdef) if gdef else _el('a:noFill') + hex6, alpha = c + alpha = int(alpha * opacity) + return _el('a:solidFill', children=[_srgb(hex6, alpha)]) + +def _make_grad(gdef): + gs_lst = _el('a:gsLst') + for stop in gdef['stops']: + pos = int(stop['offset'] * 1000) + sc = parse_color(stop['color_str']) + if not sc or sc[0] == 'grad': + continue + hex6, alpha = sc + alpha = int(alpha * stop.get('opacity', 1.0)) + gs_lst.append(_el('a:gs', {'pos': str(pos)}, children=[_srgb(hex6, alpha)])) + + if gdef.get('type') == 'radial': + # 径向渐变 + path = _el('a:path', {'path': 'circle'}, children=[ + _el('a:fillToRect', {'l': '50000', 't': '50000', 'r': '50000', 'b': '50000'}) + ]) + return _el('a:gradFill', {'rotWithShape': '1'}, children=[gs_lst, path]) + else: + # 线性渐变 + dx = gdef.get('x2', 1) - gdef.get('x1', 0) + dy = gdef.get('y2', 1) - gdef.get('y1', 0) + ang = int(math.degrees(math.atan2(dy, dx)) * 60000) + if ang < 0: + ang += 21600000 + lin = _el('a:lin', {'ang': str(ang), 'scaled': '0'}) + return _el('a:gradFill', children=[gs_lst, lin]) + +def make_line(stroke_str, stroke_w=1): + c = parse_color(stroke_str) + if not c or c[0] == 'grad': + return None + hex6, alpha = c + w = max(1, int(float(strip_unit(stroke_w)) * 12700)) + return _el('a:ln', {'w': str(w)}, + children=[_el('a:solidFill', children=[_srgb(hex6, alpha)])]) + +def make_shape(sid, name, x, y, cx, cy, preset='rect', + fill_el=None, line_el=None, rx=0, geom_el=None): + sp = _el('p:sp') + sp.append(_el('p:nvSpPr', children=[ + _el('p:cNvPr', {'id': str(sid), 'name': name}), + _el('p:cNvSpPr'), _el('p:nvPr'), + ])) + sp_pr = _el('p:spPr') + sp_pr.append(_el('a:xfrm', children=[ + _el('a:off', {'x': str(max(0, int(x))), 'y': str(max(0, int(y)))}), + _el('a:ext', {'cx': str(max(0, int(cx))), 'cy': str(max(0, int(cy)))}), + ])) + if geom_el is not None: + sp_pr.append(geom_el) + else: + geom = _el('a:prstGeom', {'prst': preset}) + av = _el('a:avLst') + if preset == 'roundRect' and rx > 0: + shorter = max(min(cx, cy), 1) + adj = min(50000, int(rx / (shorter / 2) * 50000)) + av.append(_el('a:gd', {'name': 'adj', 'fmla': f'val {adj}'})) + geom.append(av) + sp_pr.append(geom) + sp_pr.append(fill_el if fill_el is not None else _el('a:noFill')) + if line_el is not None: + sp_pr.append(line_el) + sp.append(sp_pr) + return sp + +def make_textbox(sid, name, x, y, cx, cy, paragraphs): + """paragraphs = [[{text,sz,bold,hex,alpha,font}, ...], ...]""" + sp = _el('p:sp') + sp.append(_el('p:nvSpPr', children=[ + _el('p:cNvPr', {'id': str(sid), 'name': name}), + _el('p:cNvSpPr', {'txBox': '1'}), _el('p:nvPr'), + ])) + sp.append(_el('p:spPr', children=[ + _el('a:xfrm', children=[ + _el('a:off', {'x': str(max(0, int(x))), 'y': str(max(0, int(y)))}), + _el('a:ext', {'cx': str(max(0, int(cx))), 'cy': str(max(0, int(cy)))}), + ]), + _el('a:prstGeom', {'prst': 'rect'}, children=[_el('a:avLst')]), + _el('a:noFill'), _el('a:ln', children=[_el('a:noFill')]), + ])) + tx = _el('p:txBody', children=[ + _el('a:bodyPr', {'wrap': 'none', 'lIns': '0', 'tIns': '0', + 'rIns': '0', 'bIns': '0', 'anchor': 't'}), + _el('a:lstStyle'), + ]) + for runs in paragraphs: + p_el = _el('a:p') + for run in runs: + rpr_a = {'lang': 'zh-CN', 'dirty': '0'} + if run.get('sz'): + rpr_a['sz'] = str(run['sz']) + if run.get('bold'): + rpr_a['b'] = '1' + rpr = _el('a:rPr', rpr_a) + rpr.append(_el('a:solidFill', children=[ + _srgb(run.get('hex', '000000'), run.get('alpha', 100000)) + ])) + font = run.get('font', 'Microsoft YaHei') + rpr.append(_el('a:latin', {'typeface': font})) + rpr.append(_el('a:ea', {'typeface': font})) + p_el.append(_el('a:r', children=[rpr, _el('a:t', text=run.get('text', ''))])) + tx.append(p_el) + sp.append(tx) + return sp + + +# ------------------------------------------------------------------- +# SVG Path 解析器 -> OOXML custGeom +# ------------------------------------------------------------------- +_PATH_RE = re.compile(r'([mMzZlLhHvVcCsSqQtTaA])|([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)') + +def parse_path_to_custgeom(d_str, bbox): + """SVG path d -> OOXML a:custGeom 元素。bbox=(x,y,w,h) 用于坐标偏移。""" + bx, by, bw, bh = bbox + scale = 100000 # OOXML 路径坐标空间 + + def coord(v, is_x=True): + base = bw if is_x else bh + offset = bx if is_x else by + if base <= 0: + return 0 + return int((float(v) - offset) / base * scale) + + tokens = _PATH_RE.findall(d_str) + items = [] + for cmd_match, num_match in tokens: + if cmd_match: + items.append(cmd_match) + elif num_match: + items.append(float(num_match)) + + path_el = _el('a:path', {'w': str(scale), 'h': str(scale)}) + i = 0 + cx_p, cy_p = 0, 0 # current point (absolute) + cmd = None + rel = False + + while i < len(items): + if isinstance(items[i], str): + cmd = items[i].lower() + rel = items[i].islower() + i += 1 + if cmd == 'z': + path_el.append(_el('a:close')) + continue + + if cmd is None: + i += 1 + continue + + try: + if cmd == 'm': + x, y = float(items[i]), float(items[i+1]) + if rel: + x += cx_p; y += cy_p + cx_p, cy_p = x, y + path_el.append(_el('a:moveTo', children=[ + _el('a:pt', {'x': str(coord(x, True)), 'y': str(coord(y, False))}) + ])) + i += 2 + cmd = 'l' # implicit lineTo after moveTo + + elif cmd == 'l': + x, y = float(items[i]), float(items[i+1]) + if rel: + x += cx_p; y += cy_p + cx_p, cy_p = x, y + path_el.append(_el('a:lnTo', children=[ + _el('a:pt', {'x': str(coord(x, True)), 'y': str(coord(y, False))}) + ])) + i += 2 + + elif cmd == 'h': + x = float(items[i]) + if rel: + x += cx_p + cx_p = x + path_el.append(_el('a:lnTo', children=[ + _el('a:pt', {'x': str(coord(cx_p, True)), 'y': str(coord(cy_p, False))}) + ])) + i += 1 + + elif cmd == 'v': + y = float(items[i]) + if rel: + y += cy_p + cy_p = y + path_el.append(_el('a:lnTo', children=[ + _el('a:pt', {'x': str(coord(cx_p, True)), 'y': str(coord(cy_p, False))}) + ])) + i += 1 + + elif cmd == 'c': + x1, y1 = float(items[i]), float(items[i+1]) + x2, y2 = float(items[i+2]), float(items[i+3]) + x, y = float(items[i+4]), float(items[i+5]) + if rel: + x1 += cx_p; y1 += cy_p + x2 += cx_p; y2 += cy_p + x += cx_p; y += cy_p + cx_p, cy_p = x, y + path_el.append(_el('a:cubicBezTo', children=[ + _el('a:pt', {'x': str(coord(x1, True)), 'y': str(coord(y1, False))}), + _el('a:pt', {'x': str(coord(x2, True)), 'y': str(coord(y2, False))}), + _el('a:pt', {'x': str(coord(x, True)), 'y': str(coord(y, False))}), + ])) + i += 6 + + elif cmd in ('s', 'q', 't', 'a'): + # 简化处理:跳过复杂曲线 + skip = {'s': 4, 'q': 4, 't': 2, 'a': 7}.get(cmd, 2) + i += skip + else: + i += 1 + except (IndexError, ValueError): + i += 1 + + cust_geom = _el('a:custGeom', children=[ + _el('a:avLst'), _el('a:gdLst'), _el('a:ahLst'), _el('a:cxnLst'), + _el('a:rect', {'l': 'l', 't': 't', 'r': 'r', 'b': 'b'}), + _el('a:pathLst', children=[path_el]), + ]) + return cust_geom + + +# ------------------------------------------------------------------- +# SVG -> PPTX 转换器 +# ------------------------------------------------------------------- +class SvgConverter: + def __init__(self, on_progress=None): + self.sid = 100 + self.grads = {} + self.bg_set = False # 是否已设置幻灯片背景 + self.on_progress = on_progress # 进度回调 (i, total, filename) + self.stats = {'shapes': 0, 'skipped': 0, 'errors': 0} + + def _id(self): + self.sid += 1 + return self.sid + + def convert(self, svg_path, slide): + self.bg_set = False + self.stats = {'shapes': 0, 'skipped': 0, 'errors': 0} + tree = etree.parse(str(svg_path)) + root = tree.getroot() + self._parse_grads(root) + sp_tree = None + for d in slide._element.iter(): + if d.tag.endswith('}spTree'): + sp_tree = d + break + if sp_tree is None: + return + self._walk(root, sp_tree, 0, 0, 1.0, slide) + + def _parse_grads(self, root): + self.grads = {} + pct = lambda v: float(v.rstrip('%')) / 100 if '%' in str(v) else float(v) + for g in root.iter(f'{{{SVG_NS}}}linearGradient'): + gid = g.get('id') + if not gid: + continue + stops = [] + for s in g.findall(f'{{{SVG_NS}}}stop'): + off = s.get('offset', '0%') + off = float(off.rstrip('%')) if '%' in off else float(off) * 100 + stops.append({'offset': off, 'color_str': s.get('stop-color', '#000'), + 'opacity': float(s.get('stop-opacity', '1'))}) + self.grads[gid] = { + 'type': 'linear', 'stops': stops, + 'x1': pct(g.get('x1', '0%')), 'y1': pct(g.get('y1', '0%')), + 'x2': pct(g.get('x2', '100%')), 'y2': pct(g.get('y2', '100%')), + } + for g in root.iter(f'{{{SVG_NS}}}radialGradient'): + gid = g.get('id') + if not gid: + continue + stops = [] + for s in g.findall(f'{{{SVG_NS}}}stop'): + off = s.get('offset', '0%') + off = float(off.rstrip('%')) if '%' in off else float(off) * 100 + stops.append({'offset': off, 'color_str': s.get('stop-color', '#000'), + 'opacity': float(s.get('stop-opacity', '1'))}) + self.grads[gid] = {'type': 'radial', 'stops': stops} + + def _tag(self, el): + t = el.tag + return t.split('}')[1] if isinstance(t, str) and '}' in t else (t if isinstance(t, str) else '') + + def _parse_transform(self, el): + """解析 transform -> (dx, dy, sx, sy)。""" + t = el.get('transform', '') + dx, dy, sx, sy = 0.0, 0.0, 1.0, 1.0 + # translate + m = re.search(r'translate\(\s*([\d.\-]+)[,\s]+([\d.\-]+)', t) + if m: + dx, dy = float(m.group(1)), float(m.group(2)) + # scale + m = re.search(r'scale\(\s*([\d.\-]+)(?:[,\s]+([\d.\-]+))?\s*\)', t) + if m: + sx = float(m.group(1)) + sy = float(m.group(2)) if m.group(2) else sx + # matrix(a,b,c,d,e,f) -> e=translateX, f=translateY + m = re.search(r'matrix\(\s*([\d.\-]+)[,\s]+([\d.\-]+)[,\s]+([\d.\-]+)[,\s]+([\d.\-]+)[,\s]+([\d.\-]+)[,\s]+([\d.\-]+)', t) + if m: + dx = float(m.group(5)) + dy = float(m.group(6)) + sx = float(m.group(1)) + sy = float(m.group(4)) + return dx, dy, sx, sy + + def _walk(self, el, sp, ox, oy, group_opacity, slide): + tag = self._tag(el) + try: + if tag == 'rect': + self._rect(el, sp, ox, oy, group_opacity, slide) + elif tag == 'text': + self._text(el, sp, ox, oy, group_opacity) + elif tag == 'circle': + self._circle(el, sp, ox, oy, group_opacity) + elif tag == 'ellipse': + self._ellipse(el, sp, ox, oy, group_opacity) + elif tag == 'line': + self._line(el, sp, ox, oy) + elif tag == 'path': + self._path(el, sp, ox, oy, group_opacity) + elif tag == 'image': + self._image(el, sp, ox, oy, slide) + elif tag == 'g': + dx, dy, sx, sy = self._parse_transform(el) + el_opacity = float(el.get('opacity', '1')) + child_opacity = group_opacity * el_opacity + # scale 只应用于 delta,不缩放父级偏移 + new_ox = ox + dx + new_oy = oy + dy + for c in el: + self._walk(c, sp, new_ox, new_oy, + child_opacity, slide) + elif tag in ('defs', 'style', 'linearGradient', 'radialGradient', + 'stop', 'pattern', 'clipPath', 'filter', 'mask'): + pass # 跳过定义元素(不跳过被 mask 的内容元素) + else: + for c in el: + self._walk(c, sp, ox, oy, group_opacity, slide) + except Exception as e: + self.stats['errors'] += 1 + print(f" Warning: {tag} element failed: {e}", file=sys.stderr) + + def _rect(self, el, sp, ox, oy, opacity, slide): + x = float(el.get('x', 0)) + ox + y = float(el.get('y', 0)) + oy + w = float(el.get('width', 0)) + h = float(el.get('height', 0)) + if w <= 0 or h <= 0: + return + + # 过滤面积 < 4px 的纯装饰元素 + if w < 4 and h < 4: + self.stats['skipped'] += 1 + return + + fill_s = el.get('fill', '') + stroke_s = el.get('stroke', '') + c = parse_color(fill_s) + + # 跳过全透明无边框矩形 + if c and c[0] != 'grad' and c[1] == 0 and not stroke_s: + return + + el_opacity = float(el.get('opacity', '1')) * opacity + + # 首个全屏 rect -> 幻灯片背景 + if not self.bg_set and w >= 1270 and h >= 710: + self.bg_set = True + bg = slide._element.find(f'.//{{{NS["p"]}}}bg') + if bg is None: + cSld = slide._element.find(f'{{{NS["p"]}}}cSld') + if cSld is not None: + bg_el = _el('p:bg', children=[ + _el('p:bgPr', children=[ + make_fill(fill_s, self.grads, el_opacity), + _el('a:effectLst'), + ]) + ]) + cSld.insert(0, bg_el) + return # 不再作为形状添加 + + r = max(float(el.get('rx', 0)), float(el.get('ry', 0))) + preset = 'roundRect' if r > 0 else 'rect' + fill_el = make_fill(fill_s, self.grads, el_opacity) + line_el = make_line(stroke_s, el.get('stroke-width', '1')) if stroke_s else None + shape = make_shape(self._id(), f'R{self.sid}', + px(x), px(y), px(w), px(h), + preset=preset, fill_el=fill_el, line_el=line_el, rx=px(r)) + sp.append(shape) + self.stats['shapes'] += 1 + + def _text(self, el, sp, ox, oy, opacity): + """每个 tspan 保持独立文本框,保留精确 x/y 坐标。""" + fill_s = el.get('fill', el.get('color', '')) + fsz = el.get('font-size', '14px').replace('px', '') + fw = el.get('font-weight', '') + ff = el.get('font-family', '') + baseline = el.get('dominant-baseline', '') + + tspans = list(el.findall(f'{{{SVG_NS}}}tspan')) + + if tspans: + for ts in tspans: + txt = ts.text + if not txt or not txt.strip(): + continue + x = float(ts.get('x', 0)) + ox + y = float(ts.get('y', 0)) + oy + tlen = float(ts.get('textLength', 0)) + ts_fsz = ts.get('font-size', fsz).replace('px', '') + ts_fw = ts.get('font-weight', fw) + ts_fill = ts.get('fill', fill_s) + ts_ff = ts.get('font-family', ff) + fh = float(ts_fsz) + if 'after-edge' in baseline: + y -= fh + c = parse_color(ts_fill) + hex6 = c[0] if c and c[0] != 'grad' else '000000' + alpha = c[1] if c and c[0] != 'grad' else 100000 + alpha = int(alpha * opacity) + cx_v = px(tlen) if tlen > 0 else px(len(txt) * float(ts_fsz) * 0.7) + cy_v = px(fh * 1.5) + run = { + 'text': txt.strip(), 'sz': font_sz(ts_fsz), + 'bold': ts_fw in ('bold', '700', '800', '900'), + 'hex': hex6, 'alpha': alpha, + 'font': resolve_font(ts_ff), + } + shape = make_textbox(self._id(), f'T{self.sid}', + px(x), px(y), cx_v, cy_v, [[run]]) + sp.append(shape) + self.stats['shapes'] += 1 + + elif el.text and el.text.strip(): + x = float(el.get('x', 0)) + ox + y = float(el.get('y', 0)) + oy + fh = float(fsz) + if 'after-edge' in baseline: + y -= fh + c = parse_color(fill_s) + hex6 = c[0] if c and c[0] != 'grad' else '000000' + alpha = c[1] if c and c[0] != 'grad' else 100000 + alpha = int(alpha * opacity) + txt = el.text.strip() + run = { + 'text': txt, 'sz': font_sz(fsz), + 'bold': fw in ('bold', '700', '800', '900'), + 'hex': hex6, 'alpha': alpha, 'font': resolve_font(ff), + } + shape = make_textbox(self._id(), f'T{self.sid}', + px(x), px(y), + px(len(txt) * float(fsz) * 0.7), + px(fh * 1.5), [[run]]) + sp.append(shape) + self.stats['shapes'] += 1 + + def _circle(self, el, sp, ox, oy, opacity): + cx_v = float(el.get('cx', 0)) + ox + cy_v = float(el.get('cy', 0)) + oy + r = float(el.get('r', 0)) + if r <= 0 or r < 2: + self.stats['skipped'] += 1 + return + + el_opacity = float(el.get('opacity', '1')) * opacity + fill_s = el.get('fill', '') + stroke_s = el.get('stroke', '') + stroke_w_s = el.get('stroke-width', '1') + dasharray = el.get('stroke-dasharray', '') + + # 环形图特殊处理:fill=none + stroke + dasharray -> OOXML arc + 粗描边 + if (fill_s == 'none' or not fill_s) and stroke_s and dasharray: + sw = float(strip_unit(stroke_w_s)) + # 解析 dasharray (格式: "188.1 188.5" 或 "113.097px, 150.796px") + dash_parts = [float(strip_unit(p.strip())) for p in dasharray.replace(',', ' ').split() if p.strip()] + if len(dash_parts) >= 2: + circumference = 2 * math.pi * r + arc_len = dash_parts[0] + angle_pct = min(arc_len / circumference, 1.0) + + # 检查 rotate transform + transform = el.get('transform', '') + start_angle = 0 + rot_m = re.search(r'rotate\(\s*([\d.\-]+)', transform) + if rot_m: + start_angle = float(rot_m.group(1)) + + # SVG -> PowerPoint 角度转换 + # SVG rotate(-90) = 从 12 点钟方向开始 + # PowerPoint arc: adj1=startAngle, adj2=endAngle (从3点钟顺时针, 60000单位/度) + ppt_start = (start_angle + 90) % 360 + sweep = angle_pct * 360 + ppt_end = (ppt_start + sweep) % 360 + + adj1 = int(ppt_start * 60000) + adj2 = int(ppt_end * 60000) + + # 用 arc 预设 (只画弧线轮廓) + 粗描边 = 环形弧 + geom = _el('a:prstGeom', {'prst': 'arc'}) + av = _el('a:avLst') + av.append(_el('a:gd', {'name': 'adj1', 'fmla': f'val {adj1}'})) + av.append(_el('a:gd', {'name': 'adj2', 'fmla': f'val {adj2}'})) + geom.append(av) + + # 描边颜色 = SVG 的 stroke 颜色 + stroke_color = parse_color(stroke_s) + ln_children = [] + if stroke_color and stroke_color[0] != 'grad': + ln_children.append(_el('a:solidFill', children=[ + _srgb(stroke_color[0], int(stroke_color[1] * el_opacity)) + ])) + ln_children.append(_el('a:round')) + line_el = _el('a:ln', {'w': str(int(sw * 12700))}, children=ln_children) + + shape = _el('p:sp') + shape.append(_el('p:nvSpPr', children=[ + _el('p:cNvPr', {'id': str(self._id()), 'name': f'Arc{self.sid}'}), + _el('p:cNvSpPr'), _el('p:nvPr'), + ])) + sp_pr = _el('p:spPr') + sp_pr.append(_el('a:xfrm', children=[ + _el('a:off', {'x': str(max(0, px(cx_v - r))), + 'y': str(max(0, px(cy_v - r)))}), + _el('a:ext', {'cx': str(px(2 * r)), + 'cy': str(px(2 * r))}), + ])) + sp_pr.append(geom) + sp_pr.append(_el('a:noFill')) + sp_pr.append(line_el) + shape.append(sp_pr) + sp.append(shape) + self.stats['shapes'] += 1 + return + + # fill=none + stroke (无dasharray) -> 空心圆 + 粗描边 + if (fill_s == 'none' or not fill_s) and stroke_s and stroke_s != 'none': + sw = float(strip_unit(stroke_w_s)) + stroke_color = parse_color(stroke_s) + ln_children = [] + if stroke_color and stroke_color[0] != 'grad': + ln_children.append(_el('a:solidFill', children=[ + _srgb(stroke_color[0], int(stroke_color[1] * el_opacity)) + ])) + ln_children.append(_el('a:round')) + line_el = _el('a:ln', {'w': str(int(sw * 12700))}, children=ln_children) + + sp.append(make_shape(self._id(), f'C{self.sid}', + px(cx_v - r), px(cy_v - r), px(2*r), px(2*r), + preset='ellipse', + fill_el=_el('a:noFill'), + line_el=line_el)) + self.stats['shapes'] += 1 + return + + # 普通圆形 + fill_el = make_fill(fill_s, self.grads, el_opacity) + line_el = make_line(stroke_s, stroke_w_s) if stroke_s and stroke_s != 'none' else None + sp.append(make_shape(self._id(), f'C{self.sid}', + px(cx_v - r), px(cy_v - r), px(2*r), px(2*r), + preset='ellipse', fill_el=fill_el, line_el=line_el)) + self.stats['shapes'] += 1 + + def _ellipse(self, el, sp, ox, oy, opacity): + cx_v = float(el.get('cx', 0)) + ox + cy_v = float(el.get('cy', 0)) + oy + rx = float(el.get('rx', 0)) + ry = float(el.get('ry', 0)) + if rx <= 0 or ry <= 0: + return + el_opacity = float(el.get('opacity', '1')) * opacity + fill_el = make_fill(el.get('fill', ''), self.grads, el_opacity) + sp.append(make_shape(self._id(), f'E{self.sid}', + px(cx_v - rx), px(cy_v - ry), px(2*rx), px(2*ry), + preset='ellipse', fill_el=fill_el)) + self.stats['shapes'] += 1 + + def _line(self, el, sp, ox, oy): + x1 = float(el.get('x1', 0)) + ox + y1 = float(el.get('y1', 0)) + oy + x2 = float(el.get('x2', 0)) + ox + y2 = float(el.get('y2', 0)) + oy + line_el = make_line(el.get('stroke', '#000'), el.get('stroke-width', '1')) + if line_el is None: + return + mx, my = min(x1, x2), min(y1, y2) + w, h = abs(x2 - x1) or 1, abs(y2 - y1) or 1 + shape = make_shape(self._id(), f'L{self.sid}', + px(mx), px(my), px(w), px(h), + preset='line', fill_el=_el('a:noFill'), line_el=line_el) + xfrm = shape.find(f'.//{{{NS["a"]}}}xfrm') + if x1 > x2: + xfrm.set('flipH', '1') + if y1 > y2: + xfrm.set('flipV', '1') + sp.append(shape) + self.stats['shapes'] += 1 + + def _path(self, el, sp, ox, oy, opacity): + """SVG -> OOXML custGeom 形状。""" + d = el.get('d', '') + if not d or 'nan' in d: + return + # 计算 bounding box(简化:从 path 数据提取所有数字坐标) + nums = re.findall(r'[+-]?(?:\d+\.?\d*|\.\d+)', d) + if len(nums) < 4: + return + coords = [float(n) for n in nums] + xs = coords[0::2] + ys = coords[1::2] if len(coords) > 1 else [0] + bx, by = min(xs), min(ys) + bw = max(xs) - bx or 1 + bh = max(ys) - by or 1 + + # 过滤极小路径 + if bw < 4 and bh < 4: + self.stats['skipped'] += 1 + return + + geom_el = parse_path_to_custgeom(d, (bx, by, bw, bh)) + el_opacity = float(el.get('opacity', '1')) * opacity + fill_el = make_fill(el.get('fill', ''), self.grads, el_opacity) + line_el = make_line(el.get('stroke', ''), el.get('stroke-width', '1')) if el.get('stroke') else None + + shape = make_shape(self._id(), f'P{self.sid}', + px(bx + ox), px(by + oy), px(bw), px(bh), + fill_el=fill_el, line_el=line_el, geom_el=geom_el) + sp.append(shape) + self.stats['shapes'] += 1 + + def _image(self, el, sp, ox, oy, slide): + href = el.get(f'{{{XLINK_NS}}}href') or el.get('href', '') + x = float(el.get('x', 0)) + ox + y = float(el.get('y', 0)) + oy + w = float(el.get('width', 0)) + h = float(el.get('height', 0)) + if not href or w <= 0 or h <= 0: + return + + img_source = None + if href.startswith('data:'): + m = re.match(r'data:image/\w+;base64,(.*)', href, re.DOTALL) + if m: + img_source = io.BytesIO(base64.b64decode(m.group(1))) + elif href.startswith('file://'): + p = Path(href.replace('file://', '')) + if p.exists(): + img_source = str(p) + elif not href.startswith('http'): + p = Path(href) + if p.exists(): + img_source = str(p) + + if img_source is None: + return + + # 获取图片原始尺寸以计算宽高比 + try: + from PIL import Image as PILImage + if isinstance(img_source, io.BytesIO): + img_source.seek(0) + pil_img = PILImage.open(img_source) + img_w, img_h = pil_img.size + # 不 close -- PIL close 会关掉底层 BytesIO + del pil_img + img_source.seek(0) + else: + with PILImage.open(img_source) as pil_img: + img_w, img_h = pil_img.size + except ImportError: + # 没有 PIL,退回直接拉伸 + pic = slide.shapes.add_picture(img_source, + Emu(px(x)), Emu(px(y)), + Emu(px(w)), Emu(px(h))) + self.stats['shapes'] += 1 + return + + # object-fit: cover -- 按比例放大到覆盖容器,然后裁剪 + container_w = px(w) + container_h = px(h) + img_ratio = img_w / img_h + container_ratio = container_w / container_h + + if img_ratio > container_ratio: + # 图片更宽 -> 按高度填满,裁剪左右 + scale_h = container_h + scale_w = int(scale_h * img_ratio) + else: + # 图片更高 -> 按宽度填满,裁剪上下 + scale_w = container_w + scale_h = int(scale_w / img_ratio) + + # 放置缩放后的图片(居中裁剪) + offset_x = (scale_w - container_w) / 2 + offset_y = (scale_h - container_h) / 2 + + pic = slide.shapes.add_picture(img_source, + Emu(px(x)), Emu(px(y)), + Emu(scale_w), Emu(scale_h)) + + # 用 crop 实现裁剪(值为比例 0.0-1.0) + if scale_w > 0 and scale_h > 0: + crop_lr = offset_x / scale_w # 左右各裁多少比例 + crop_tb = offset_y / scale_h # 上下各裁多少比例 + pic.crop_left = crop_lr + pic.crop_right = crop_lr + pic.crop_top = crop_tb + pic.crop_bottom = crop_tb + + self.stats['shapes'] += 1 + + +# ------------------------------------------------------------------- +# 主流程 +# ------------------------------------------------------------------- +def convert(svg_input, output_path, on_progress=None): + svg_input = Path(svg_input) + if svg_input.is_file(): + svg_files = [svg_input] + elif svg_input.is_dir(): + svg_files = sorted(svg_input.glob('*.svg')) + else: + print(f"Error: {svg_input} not found", file=sys.stderr) + sys.exit(1) + + if not svg_files: + print("Error: No SVG files found", file=sys.stderr) + sys.exit(1) + + prs = Presentation() + prs.slide_width = Emu(SLIDE_W) + prs.slide_height = Emu(SLIDE_H) + blank = prs.slide_layouts[6] + converter = SvgConverter(on_progress=on_progress) + total = len(svg_files) + + for i, svg_file in enumerate(svg_files): + slide = prs.slides.add_slide(blank) + converter.convert(svg_file, slide) + s = converter.stats + print(f" [{i+1}/{total}] {svg_file.name} " + f"({s['shapes']} shapes, {s['skipped']} skipped, {s['errors']} errors)") + if on_progress: + on_progress(i + 1, total, svg_file.name) + + prs.save(str(output_path)) + print(f"Saved: {output_path} ({total} slides)") + + +def main(): + parser = argparse.ArgumentParser(description="SVG to PPTX (native shapes)") + parser.add_argument('svg', help='SVG file or directory') + parser.add_argument('-o', '--output', default='presentation.pptx') + args = parser.parse_args() + convert(args.svg, args.output) + + +if __name__ == '__main__': + main()