feat: PPT Agent Skill - 专业演示文稿全流程 AI 生成助手
模拟顶级 PPT 设计公司的完整工作流,输出高质量 HTML 演示文稿 + 可编辑矢量 PPTX。 - 6步Pipeline: 需求调研->资料搜集->大纲策划->策划稿->风格+配图+HTML设计稿->后处理 - 8种预置风格 + 7种Bento Grid布局 + 6种卡片类型 - 专业排版系统(7级字号) + 色彩比例法则(60-30-10) + 跨页视觉叙事 - 8种纯CSS数据可视化 + 5种配图融入技法 - HTML->SVG->PPTX 全自动转换管线
This commit is contained in:
124
README.md
Normal file
124
README.md
Normal file
@@ -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(保留 `<text>` 可编辑)
|
||||||
|
- `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 <slides_dir> -o preview.html
|
||||||
|
```
|
||||||
|
|
||||||
|
### html2svg.py -- HTML 转 SVG
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/html2svg.py <slides_dir> -o <svg_dir>
|
||||||
|
```
|
||||||
|
|
||||||
|
- 底层:Puppeteer + dom-to-svg(DOM 直接转 SVG,`<text>` 可编辑)
|
||||||
|
- 图片:自动读取 `<img>` 引用的文件转 base64 嵌入
|
||||||
|
- 降级:dom-to-svg 不可用时自动退回 Puppeteer PDF + pdf2svg
|
||||||
|
|
||||||
|
### svg2pptx.py -- SVG 转 PPTX
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/svg2pptx.py <svg_dir> -o output.pptx --html-dir <slides_dir>
|
||||||
|
```
|
||||||
|
|
||||||
|
- SVG 以 OOXML `asvg:svgBlip` 扩展原生嵌入 PPTX
|
||||||
|
- 同时生成 PNG 回退图(兼容旧版 Office)
|
||||||
|
- PPT 365 中右键 -> "转换为形状" 可编辑文字和形状
|
||||||
|
|
||||||
|
## 技术架构
|
||||||
|
|
||||||
|
```
|
||||||
|
HTML slides
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[Puppeteer] 打开 HTML -> [dom-to-svg] DOM 直接转 SVG
|
||||||
|
| (保留 <text> 元素,文字可编辑)
|
||||||
|
| (base64 内联图片)
|
||||||
|
| (color -> fill 属性后处理)
|
||||||
|
v
|
||||||
|
SVG files
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[python-pptx + lxml] OOXML svgBlip 嵌入
|
||||||
|
| (PNG 回退图兼容旧版 Office)
|
||||||
|
v
|
||||||
|
presentation.pptx
|
||||||
|
```
|
||||||
|
|
||||||
|
## 触发方式
|
||||||
|
|
||||||
|
在 Claude 对话中,以下表达会触发此 Skill:
|
||||||
|
|
||||||
|
- "帮我做个 PPT" / "做一个关于 X 的演示"
|
||||||
|
- "做 slides" / "做幻灯片" / "做汇报材料"
|
||||||
|
- "把这篇文档做成 PPT"
|
||||||
|
- "做培训课件" / "做路演 deck"
|
||||||
330
SKILL.md
Normal file
330
SKILL.md
Normal file
@@ -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,保留 `<text>` 可编辑)
|
||||||
|
```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` | 初次了解 | 核心理念与方法论 |
|
||||||
204
references/bento-grid.md
Normal file
204
references/bento-grid.md
Normal file
@@ -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
|
||||||
|
- 关键词用 `<strong>` 或 `<span class="highlight">` 高亮
|
||||||
|
- **最低要求**: 标题 + 至少 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 色, 居中步骤数字
|
||||||
|
- 连线: **真实 `<div>` 元素**(禁止 ::before/::after)
|
||||||
|
- 箭头: **内联 SVG `<polygon>`**(禁止 CSS border 三角形)
|
||||||
|
- **最低要求**: 至少 3 个步骤,每步标题 + 一句描述
|
||||||
|
|
||||||
|
### data_highlight(大数据高亮区)
|
||||||
|
- 用于封面或重点页的超大数据展示
|
||||||
|
- 数字: 64-80px, 900 weight, accent 色
|
||||||
|
- 副标题 + 补充数据行
|
||||||
|
- **最低要求**: 1 个超大数字 + 副标题 + 补充数据行
|
||||||
63
references/method.md
Normal file
63
references/method.md
Normal file
@@ -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(资料搜集)解决这个问题:先搜索再生成,每个数据点都有来源。宁可少放一条信息,也不编造一个数据。
|
||||||
901
references/prompts.md
Normal file
901
references/prompts.md
Normal file
@@ -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
|
||||||
|
- 关键词: 用 <strong> 或 <span class="highlight"> 包裹(背景 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, 居中显示步骤数字
|
||||||
|
- 连线: 节点之间用**真实 `<div>` 元素**作为连接线(height=2px, background=accent-color),**禁止**用 ::before/::after 伪元素画连线
|
||||||
|
- 箭头: 用内联 `<svg>` 三角形(`<polygon>` 或 `<path>`),**禁止**用 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 的风格 |
|
||||||
|
| 标题下划线 | `<div>` 4px 高, 40-60px 宽, accent 渐变, 在标题下方 4px 处 | 每页标题 |
|
||||||
|
| 卡片左侧强调线 | `<div>` 3-4px 宽, 100% 高, accent 色, position=absolute, left=0 | 文本卡片/引用 |
|
||||||
|
| 编号气泡 | `<div>` 32-40px 圆形, accent 色背景, 白色数字 | 步骤/列表序号 |
|
||||||
|
| 分隔渐隐线 | `<div>` 1px 高, linear-gradient(90deg, accent 30%, transparent) | 卡片内区域分隔 |
|
||||||
|
|
||||||
|
#### 深色风格专用
|
||||||
|
|
||||||
|
| 装饰 | 实现方式 | 效果 |
|
||||||
|
|------|---------|------|
|
||||||
|
| 角落装饰线 | `<div>` L 形边框(只显示两条边: border-top + border-left),accent 色 20% 透明度 | 页面四角层次感 |
|
||||||
|
| 光晕效果 | `<div>` radial-gradient 超大半透明圆(400-600px),accent 色 5-8% 透明度 | 关键区域背后的辉光 |
|
||||||
|
| 半透明数字水印 | `<div>` 超大号数字(120-160px), accent 色, opacity 0.03-0.05 | 页面层次感/章节标识 |
|
||||||
|
| 卡片分隔线 | `<div>` 1px solid rgba(255,255,255,0.05) | 卡片间微妙分界 |
|
||||||
|
|
||||||
|
#### 浅色风格专用
|
||||||
|
|
||||||
|
| 装饰 | 实现方式 | 效果 |
|
||||||
|
|------|---------|------|
|
||||||
|
| 渐变色块 | `<div>` 大面积弧形色块, accent 色 5-10% 透明度, border-radius 50% | 卡片一角的活泼感 |
|
||||||
|
| 细边框卡片 | border: 1px solid var(--card-border) | 清晰的区域划分 |
|
||||||
|
| 圆形图标底 | `<div>` 48px 圆形, accent 色 10% 透明度背景 + 内联 SVG 图标 | 替代纯文字列表 |
|
||||||
|
|
||||||
|
#### 统一页脚系统
|
||||||
|
|
||||||
|
每页(封面和章节封面除外)底部必须有统一页脚:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div style="position:absolute; bottom:20px; left:40px; right:40px;
|
||||||
|
display:flex; justify-content:space-between; align-items:center;">
|
||||||
|
<!-- 左侧:章节信息 -->
|
||||||
|
<span style="font-size:11px; color:var(--text-secondary); opacity:0.5;
|
||||||
|
letter-spacing:1px;">
|
||||||
|
PART 01 - 章节名称
|
||||||
|
</span>
|
||||||
|
<!-- 右侧:页码 + 品牌 -->
|
||||||
|
<span style="font-size:11px; color:var(--text-secondary); opacity:0.5;">
|
||||||
|
07 / 15 | 品牌名
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
页脚规则:
|
||||||
|
- 字号 11px, text-secondary 色, opacity 0.5(极其低调,不抢内容视线)
|
||||||
|
- 左侧显示当前 PART 编号 + 章节名
|
||||||
|
- 右侧显示 当前页/总页数 + 品牌名(如有)
|
||||||
|
- **封面页、章节封面不显示页脚**
|
||||||
|
|
||||||
|
### 配图融入设计(根据用户偏好决定是否配图)
|
||||||
|
|
||||||
|
配图是可选项,在需求调研阶段由用户决定:
|
||||||
|
- **不配图**: 跳过本节
|
||||||
|
- **只关键页**: 仅封面、章节封面、结束页配图
|
||||||
|
- **每页配图**: 所有页面都有图片融入
|
||||||
|
|
||||||
|
当需要配图时,图片不能像贴纸一样硬塞在页面里。必须通过**视觉融入技法**让图片与内容浑然一体。
|
||||||
|
|
||||||
|
**核心原则**:图片是**氛围的一部分**,不是独立的内容块。
|
||||||
|
|
||||||
|
#### 5 种融入技法(全部管线安全)
|
||||||
|
|
||||||
|
##### 1. 渐隐融合 -- 封面页/章节封面的首选
|
||||||
|
|
||||||
|
图片占页面右半部分,左侧边缘用渐变遮罩渐隐到背景色,让图片"消融"在背景中。
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div style="position:absolute; right:0; top:0; width:55%; height:100%; overflow:hidden;">
|
||||||
|
<img src="..." style="width:100%; height:100%; object-fit:cover; opacity:0.35;">
|
||||||
|
<!-- 左侧渐隐遮罩(真实div) -->
|
||||||
|
<div style="position:absolute; left:0; top:0; width:60%; height:100%;
|
||||||
|
background:linear-gradient(90deg, var(--bg-primary) 0%, transparent 100%);"></div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 2. 色调蒙版 -- 内容页大卡片
|
||||||
|
|
||||||
|
图片上覆盖半透明色调层,让图片染上主题色,同时降低视觉干扰。
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div style="position:relative; overflow:hidden; border-radius:var(--card-radius);">
|
||||||
|
<img src="..." style="width:100%; height:100%; object-fit:cover; position:absolute; top:0; left:0;">
|
||||||
|
<!-- 主题色蒙版 -->
|
||||||
|
<div style="position:absolute; top:0; left:0; width:100%; height:100%;
|
||||||
|
background:linear-gradient(135deg, rgba(11,17,32,0.85), rgba(15,23,42,0.6));"></div>
|
||||||
|
<!-- 内容在蒙版之上 -->
|
||||||
|
<div style="position:relative; z-index:1; padding:24px;">
|
||||||
|
<!-- 文字内容 -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 3. 氛围底图 -- 章节封面/数据页
|
||||||
|
|
||||||
|
图片作为整页超低透明度背景,营造氛围感。
|
||||||
|
|
||||||
|
```html
|
||||||
|
<img src="..." style="position:absolute; top:0; left:0; width:100%; height:100%;
|
||||||
|
object-fit:cover; opacity:0.08; pointer-events:none;">
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 4. 裁切视窗 -- 小卡片顶部
|
||||||
|
|
||||||
|
图片作为卡片头部的"窗口",用圆角裁切,底部渐隐到卡片背景。
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div style="position:relative; height:120px; overflow:hidden;
|
||||||
|
border-radius:var(--card-radius) var(--card-radius) 0 0;">
|
||||||
|
<img src="..." style="width:100%; height:100%; object-fit:cover;">
|
||||||
|
<div style="position:absolute; bottom:0; left:0; width:100%; height:50%;
|
||||||
|
background:linear-gradient(0deg, var(--card-bg-from), transparent);"></div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 5. 圆形/异形裁切 -- 数据卡片辅助
|
||||||
|
|
||||||
|
图片裁切为圆形或其他形状,作为装饰元素。
|
||||||
|
|
||||||
|
```html
|
||||||
|
<img src="..." style="width:80px; height:80px; border-radius:50%;
|
||||||
|
object-fit:cover; border:3px solid var(--accent-1);">
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 按页面类型选择技法
|
||||||
|
|
||||||
|
| 页面类型 | 推荐技法 | 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 规范
|
||||||
|
- 使用真实 `<img>` 标签(禁用 CSS background-image)
|
||||||
|
- 渐变遮罩用**真实 `<div>`**(禁用 ::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
|
||||||
|
<div style="position:relative; width:80px; height:80px;">
|
||||||
|
<svg width="80" height="80" viewBox="0 0 80 80">
|
||||||
|
<circle cx="40" cy="40" r="32" fill="none"
|
||||||
|
stroke="var(--card-bg-from)" stroke-width="10"/>
|
||||||
|
<circle cx="40" cy="40" r="32" fill="none"
|
||||||
|
stroke="var(--accent-1)" stroke-width="10"
|
||||||
|
stroke-dasharray="180.96 201.06" stroke-linecap="round"
|
||||||
|
transform="rotate(-90 40 40)"/>
|
||||||
|
<text x="40" y="40" text-anchor="middle" dominant-baseline="central"
|
||||||
|
fill="var(--text-primary)" font-size="16" font-weight="700">90%</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
计算公式: dasharray 第一个值 = 2 * PI * r * (百分比/100), 第二个值 = 2 * PI * r
|
||||||
|
|
||||||
|
### 4. 指标行(数字+标签+进度条 组合)
|
||||||
|
```html
|
||||||
|
<div style="display:flex; align-items:center; gap:12px; margin-bottom:10px;">
|
||||||
|
<span style="font-size:24px; font-weight:800; color:var(--accent-1);
|
||||||
|
font-variant-numeric:tabular-nums; min-width:60px;">87%</span>
|
||||||
|
<div style="flex:1;">
|
||||||
|
<div style="font-size:12px; color:var(--text-secondary); margin-bottom:4px;">用户满意度</div>
|
||||||
|
<div class="progress-bar"><div class="fill" style="width:87%"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 迷你折线图 Sparkline(趋势方向)
|
||||||
|
```html
|
||||||
|
<svg width="120" height="40" viewBox="0 0 120 40">
|
||||||
|
<!-- 面积填充 -->
|
||||||
|
<path d="M0,35 L20,28 L40,30 L60,20 L80,15 L100,10 L120,5 L120,40 L0,40 Z"
|
||||||
|
fill="var(--accent-1)" opacity="0.1"/>
|
||||||
|
<!-- 折线 -->
|
||||||
|
<polyline points="0,35 20,28 40,30 60,20 80,15 100,10 120,5"
|
||||||
|
fill="none" stroke="var(--accent-1)" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<!-- 终点圆点 -->
|
||||||
|
<circle cx="120" cy="5" r="3" fill="var(--accent-1)"/>
|
||||||
|
</svg>
|
||||||
|
```
|
||||||
|
用在数据数字旁边,占位小但信息量大。数据点坐标根据实际趋势调整 y 值(高=好 -> y 值小)。
|
||||||
|
|
||||||
|
### 6. 点阵图 Waffle Chart(百分比直觉化)
|
||||||
|
```html
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(10,1fr); gap:3px; width:100px;">
|
||||||
|
<!-- 67 个填充点 + 33 个空点 = 67% -->
|
||||||
|
<div style="width:8px; height:8px; border-radius:2px; background:var(--accent-1);"></div>
|
||||||
|
<!-- 重复填充点... -->
|
||||||
|
<div style="width:8px; height:8px; border-radius:2px; background:var(--card-bg-from);"></div>
|
||||||
|
<!-- 重复空点... -->
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
10x10 = 100 格,填充数量 = 百分比值。比进度条更直觉。
|
||||||
|
|
||||||
|
### 7. KPI 指标卡(数字+趋势箭头+标签)
|
||||||
|
```html
|
||||||
|
<div style="display:flex; align-items:baseline; gap:8px;">
|
||||||
|
<span style="font-size:40px; font-weight:800; color:var(--accent-1);
|
||||||
|
font-variant-numeric:tabular-nums;">2.4M</span>
|
||||||
|
<!-- 上升箭头(绿色=好) -->
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<polygon points="8,2 14,10 2,10" fill="#16A34A"/>
|
||||||
|
</svg>
|
||||||
|
<span style="font-size:14px; color:#16A34A; font-weight:600;">+12.3%</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px; color:var(--text-secondary); margin-top:4px;">月活跃用户数</div>
|
||||||
|
```
|
||||||
|
趋势箭头颜色:上升用绿色 #16A34A,下降用红色 #DC2626,持平用 text-secondary。
|
||||||
|
|
||||||
|
### 8. 评分指示器(5分制)
|
||||||
|
```html
|
||||||
|
<div style="display:flex; gap:6px;">
|
||||||
|
<!-- 4 个实心圆 + 1 个空心圆 = 4/5 分 -->
|
||||||
|
<div style="width:12px; height:12px; border-radius:50%; background:var(--accent-1);"></div>
|
||||||
|
<div style="width:12px; height:12px; border-radius:50%; background:var(--accent-1);"></div>
|
||||||
|
<div style="width:12px; height:12px; border-radius:50%; background:var(--accent-1);"></div>
|
||||||
|
<div style="width:12px; height:12px; border-radius:50%; background:var(--accent-1);"></div>
|
||||||
|
<div style="width:12px; height:12px; border-radius:50%; border:2px solid var(--accent-1); background:transparent;"></div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 可视化选择指南
|
||||||
|
|
||||||
|
| 数据类型 | 推荐可视化 |
|
||||||
|
|---------|----------|
|
||||||
|
| 百分比/完成度 | 进度条 或 环形百分比 |
|
||||||
|
| 两项对比 | 对比柱 |
|
||||||
|
| 时间趋势 | 迷你折线图 |
|
||||||
|
| 比例直觉化 | 点阵图 |
|
||||||
|
| 核心 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` 或 `H<sub>2</sub>O` | `H2O` | 用 Unicode 下标数字或 sub 标签 |
|
||||||
|
| 化学式上标 | `m²` 或 `m<sup>2</sup>` | `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. **化学式中的下标数字**:必须用 `<sub>` 标签或 Unicode 下标字符(₀₁₂₃₄₅₆₇₈₉),绝对不能用普通数字代替
|
||||||
|
|
||||||
|
## 页面级情感设计
|
||||||
|
|
||||||
|
不同页面类型有不同的情感目标:
|
||||||
|
|
||||||
|
| 页面类型 | 情感目标 | 设计要求 |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| 封面页 | 视觉冲击、专业信赖 | 大标题+配图、装饰元素要丰富、品牌感要强 |
|
||||||
|
| 目录页 | 清晰导航、预期管理 | 每章有图标/色块标识、章节编号醒目 |
|
||||||
|
| 章节封面 | 过渡、呼吸感 | PART 编号大号显示、引导语、留白充分 |
|
||||||
|
| 内容页 | 信息传递、数据说服 | 卡片密度高、数据可视化、要点清晰 |
|
||||||
|
| 结束页 | 总结回顾、行动号召 | 3-5 条核心要点回顾 + 明确的 CTA(联系方式/下一步) |
|
||||||
|
|
||||||
|
## PPTX 兼容的 CSS/HTML 约束(必须遵守)
|
||||||
|
|
||||||
|
本 HTML 最终会经过 dom-to-svg -> svg2pptx 管线转为 PowerPoint 原生形状。以下规则确保转换不丢失任何视觉元素:
|
||||||
|
|
||||||
|
### 禁止使用的 CSS 特性(dom-to-svg 不支持,会导致元素丢失)
|
||||||
|
|
||||||
|
| 禁止 | 原因 | 替代方案 |
|
||||||
|
|------|------|----------|
|
||||||
|
| `::before` / `::after` 伪元素(用于视觉装饰) | dom-to-svg 无法读取伪元素 | 改用**真实 `<div>`/`<span>` 元素** |
|
||||||
|
| `conic-gradient()` | dom-to-svg 不支持 | 改用**内联 SVG `<circle>` + stroke-dasharray** |
|
||||||
|
| CSS border 三角形(width:0 + border trick) | 转 SVG 后形状丢失 | 改用**内联 SVG `<polygon>`** |
|
||||||
|
| `-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 中 | 改用真实 `<span>` 元素 |
|
||||||
|
| 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`
|
||||||
|
- 内联 `<svg>` 元素(**推荐用于图表/箭头/图标**)
|
||||||
|
|
||||||
|
### 核心原则
|
||||||
|
> **凡是视觉上可见的元素,必须是真实的 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 文件(含 <!DOCTYPE html>、<head>、<style> 全内嵌)
|
||||||
|
- body 固定 width=1280px, height=720px
|
||||||
|
- 不使用外部 CSS/JS(全部内嵌)
|
||||||
|
- 不添加任何解释性文字
|
||||||
|
- 确保每张卡片的内容完整填充(不留空卡片)
|
||||||
|
- 数据卡片的数字要醒目突出(最大视觉权重)
|
||||||
|
- 所有颜色都通过 var(--xxx) 引用,不硬编码
|
||||||
|
- 浅色背景的卡片内文字必须是深色,深色背景的卡片内文字必须是浅色
|
||||||
|
- 数据卡片至少配一个 CSS 可视化元素(进度条/对比柱/环形图)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 演讲备注
|
||||||
|
|
||||||
|
为每页生成演讲提示(可选步骤)。
|
||||||
|
|
||||||
|
```text
|
||||||
|
你是一名演讲教练。请为以下 PPT 页面生成简洁的演讲备注。
|
||||||
|
|
||||||
|
## 页面标题
|
||||||
|
{{SLIDE_TITLE}}
|
||||||
|
|
||||||
|
## 页面内容
|
||||||
|
{{SLIDE_CONTENT}}
|
||||||
|
|
||||||
|
## 演讲备注要求
|
||||||
|
1. 每页 3-5 句话的演讲提示
|
||||||
|
2. 包括:开场过渡语、核心要传达的信息、可以用的比喻/故事/互动
|
||||||
|
3. 标注关键数据的口述表达(如:"这个数字意味着...")
|
||||||
|
4. 提示与下一页的衔接语
|
||||||
|
5. 整体风格:自然、自信、有节奏感
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt 使用流程
|
||||||
|
|
||||||
|
```
|
||||||
|
Step 1 -> 使用 Prompt #1(需求调研)
|
||||||
|
Step 2 -> 搜索(不需要专门 Prompt,直接使用搜索工具)
|
||||||
|
Step 3 -> 使用 Prompt #2(大纲架构师)
|
||||||
|
Step 4 -> 使用 Prompt #3(内容分配与策划稿)
|
||||||
|
Step 5a -> 使用 style-system.md 选择风格
|
||||||
|
Step 5b -> 如有 generate_image,为每页生成配图
|
||||||
|
Step 5c -> 使用 Prompt #4(HTML 设计稿),逐页生成
|
||||||
|
后处理 -> scripts/html_packager.py 合并预览 + scripts/html2svg.py 转 SVG
|
||||||
|
可选 -> 使用 Prompt #5(演讲备注)
|
||||||
|
```
|
||||||
373
references/style-system.md
Normal file
373
references/style-system.md
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
# 风格系统
|
||||||
|
|
||||||
|
## 风格数据模型
|
||||||
|
|
||||||
|
每套风格由以下字段定义:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"style_name": "风格名称",
|
||||||
|
"style_id": "dark_tech | xiaomi_orange | blue_white | royal_red | fresh_green | luxury_purple | minimal_gray | vibrant_rainbow",
|
||||||
|
"background": {
|
||||||
|
"primary": "#色值",
|
||||||
|
"gradient_to": "#色值"
|
||||||
|
},
|
||||||
|
"card": {
|
||||||
|
"gradient_from": "#色值",
|
||||||
|
"gradient_to": "#色值",
|
||||||
|
"border": "rgba(...)",
|
||||||
|
"border_radius": 12
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"primary": "#色值",
|
||||||
|
"secondary": "rgba(...)",
|
||||||
|
"title_size": 28,
|
||||||
|
"body_size": 14,
|
||||||
|
"card_title_size": 20
|
||||||
|
},
|
||||||
|
"accent": {
|
||||||
|
"primary": ["#渐变起", "#渐变止"],
|
||||||
|
"secondary": ["#渐变起", "#渐变止"]
|
||||||
|
},
|
||||||
|
"font_family": "字体族",
|
||||||
|
"grid_pattern": {
|
||||||
|
"enabled": true,
|
||||||
|
"size": 40,
|
||||||
|
"dot_radius": 1,
|
||||||
|
"dot_color": "#色值",
|
||||||
|
"dot_opacity": 0.05
|
||||||
|
},
|
||||||
|
"decorations": {
|
||||||
|
"corner_lines": false,
|
||||||
|
"glow_effects": false,
|
||||||
|
"description": "装饰元素描述"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 预置风格库
|
||||||
|
|
||||||
|
### 1. 暗黑科技 (dark_tech)
|
||||||
|
|
||||||
|
适用场景:技术产品介绍、AI/SaaS 平台、数据平台、开发者工具
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"style_name": "高阶暗黑科技风 (Premium Dark Mode)",
|
||||||
|
"style_id": "dark_tech",
|
||||||
|
"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)", "title_size": 28, "body_size": 14, "card_title_size": 20 },
|
||||||
|
"accent": { "primary": ["#22D3EE", "#3B82F6"], "secondary": ["#FDE047", "#F59E0B"] },
|
||||||
|
"font_family": "PingFang SC, Microsoft YaHei, system-ui, sans-serif",
|
||||||
|
"grid_pattern": { "enabled": true, "size": 40, "dot_radius": 1, "dot_color": "#FFFFFF", "dot_opacity": 0.05 },
|
||||||
|
"decorations": { "corner_lines": true, "glow_effects": true, "description": "角落装饰线条 + 强调色模糊光晕" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0B1120;
|
||||||
|
--bg-secondary: #0F172A;
|
||||||
|
--card-bg-from: #1E293B;
|
||||||
|
--card-bg-to: #0F172A;
|
||||||
|
--card-border: rgba(255,255,255,0.05);
|
||||||
|
--card-radius: 12px;
|
||||||
|
--text-primary: #FFFFFF;
|
||||||
|
--text-secondary: rgba(255,255,255,0.7);
|
||||||
|
--accent-1: #22D3EE;
|
||||||
|
--accent-2: #3B82F6;
|
||||||
|
--accent-3: #FDE047;
|
||||||
|
--accent-4: #F59E0B;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 小米橙 (xiaomi_orange)
|
||||||
|
|
||||||
|
适用场景:硬件产品、IoT 设备、消费电子、智能家居
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"style_name": "小米橙 (Xiaomi Orange)",
|
||||||
|
"style_id": "xiaomi_orange",
|
||||||
|
"background": { "primary": "#1A1A1A", "gradient_to": "#111111" },
|
||||||
|
"card": { "gradient_from": "#2A2A2A", "gradient_to": "#1A1A1A", "border": "rgba(255,105,0,0.15)", "border_radius": 16 },
|
||||||
|
"text": { "primary": "#FFFFFF", "secondary": "rgba(255,255,255,0.65)", "title_size": 28, "body_size": 14, "card_title_size": 20 },
|
||||||
|
"accent": { "primary": ["#FF6900", "#FF8C00"], "secondary": ["#FFFFFF", "#E0E0E0"] },
|
||||||
|
"font_family": "PingFang SC, Microsoft YaHei, system-ui, sans-serif",
|
||||||
|
"grid_pattern": { "enabled": false },
|
||||||
|
"decorations": { "corner_lines": false, "glow_effects": false, "description": "纯净简约,大面积留白,圆形图标元素" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--bg-primary: #1A1A1A;
|
||||||
|
--bg-secondary: #111111;
|
||||||
|
--card-bg-from: #2A2A2A;
|
||||||
|
--card-bg-to: #1A1A1A;
|
||||||
|
--card-border: rgba(255,105,0,0.15);
|
||||||
|
--card-radius: 16px;
|
||||||
|
--text-primary: #FFFFFF;
|
||||||
|
--text-secondary: rgba(255,255,255,0.65);
|
||||||
|
--accent-1: #FF6900;
|
||||||
|
--accent-2: #FF8C00;
|
||||||
|
--accent-3: #FFFFFF;
|
||||||
|
--accent-4: #E0E0E0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. 蓝白商务 (blue_white)
|
||||||
|
|
||||||
|
适用场景:企业介绍、培训课件、教育材料、医疗/金融行业
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"style_name": "蓝白商务 (Blue White Business)",
|
||||||
|
"style_id": "blue_white",
|
||||||
|
"background": { "primary": "#FFFFFF", "gradient_to": "#F8FAFC" },
|
||||||
|
"card": { "gradient_from": "#F1F5F9", "gradient_to": "#E2E8F0", "border": "rgba(37,99,235,0.12)", "border_radius": 12 },
|
||||||
|
"text": { "primary": "#1E293B", "secondary": "#64748B", "title_size": 28, "body_size": 14, "card_title_size": 20 },
|
||||||
|
"accent": { "primary": ["#2563EB", "#1D4ED8"], "secondary": ["#059669", "#047857"] },
|
||||||
|
"font_family": "PingFang SC, Microsoft YaHei, system-ui, sans-serif",
|
||||||
|
"grid_pattern": { "enabled": false },
|
||||||
|
"decorations": { "corner_lines": false, "glow_effects": false, "description": "清爽简洁,蓝色标题装饰条,卡片带浅色背景和细边框" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--bg-primary: #FFFFFF;
|
||||||
|
--bg-secondary: #F8FAFC;
|
||||||
|
--card-bg-from: #F1F5F9;
|
||||||
|
--card-bg-to: #E2E8F0;
|
||||||
|
--card-border: rgba(37,99,235,0.12);
|
||||||
|
--card-radius: 12px;
|
||||||
|
--text-primary: #1E293B;
|
||||||
|
--text-secondary: #64748B;
|
||||||
|
--accent-1: #2563EB;
|
||||||
|
--accent-2: #1D4ED8;
|
||||||
|
--accent-3: #059669;
|
||||||
|
--accent-4: #047857;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. 朱红宫墙 (royal_red)
|
||||||
|
|
||||||
|
适用场景:文化/历史主题、政务汇报、品牌故事、中国风
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"style_name": "朱红宫墙 (Royal Red)",
|
||||||
|
"style_id": "royal_red",
|
||||||
|
"background": { "primary": "#8B0000", "gradient_to": "#5C0000" },
|
||||||
|
"card": { "gradient_from": "#A52A2A", "gradient_to": "#7A0000", "border": "rgba(255,215,0,0.15)", "border_radius": 8 },
|
||||||
|
"text": { "primary": "#FFF8E7", "secondary": "rgba(255,248,231,0.75)", "title_size": 28, "body_size": 14, "card_title_size": 20 },
|
||||||
|
"accent": { "primary": ["#FFD700", "#FFA500"], "secondary": ["#FFF8E7", "#F5E6C8"] },
|
||||||
|
"font_family": "PingFang SC, STSong, SimSun, Microsoft YaHei, serif",
|
||||||
|
"grid_pattern": { "enabled": false },
|
||||||
|
"decorations": { "corner_lines": true, "glow_effects": false, "description": "金色角饰、祥云纹理,传统纹样装饰边框" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--bg-primary: #8B0000;
|
||||||
|
--bg-secondary: #5C0000;
|
||||||
|
--card-bg-from: #A52A2A;
|
||||||
|
--card-bg-to: #7A0000;
|
||||||
|
--card-border: rgba(255,215,0,0.15);
|
||||||
|
--card-radius: 8px;
|
||||||
|
--text-primary: #FFF8E7;
|
||||||
|
--text-secondary: rgba(255,248,231,0.75);
|
||||||
|
--accent-1: #FFD700;
|
||||||
|
--accent-2: #FFA500;
|
||||||
|
--accent-3: #FFF8E7;
|
||||||
|
--accent-4: #F5E6C8;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. 清新自然 (fresh_green)
|
||||||
|
|
||||||
|
适用场景:环保/可持续发展、健康/医疗/养生、食品/农业、美妆/护肤
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"style_name": "清新自然 (Fresh Green)",
|
||||||
|
"style_id": "fresh_green",
|
||||||
|
"background": { "primary": "#F0FDF4", "gradient_to": "#ECFDF5" },
|
||||||
|
"card": { "gradient_from": "#FFFFFF", "gradient_to": "#F0FDF4", "border": "rgba(22,163,74,0.12)", "border_radius": 16 },
|
||||||
|
"text": { "primary": "#14532D", "secondary": "#4B5563", "title_size": 28, "body_size": 14, "card_title_size": 20 },
|
||||||
|
"accent": { "primary": ["#16A34A", "#059669"], "secondary": ["#F59E0B", "#D97706"] },
|
||||||
|
"font_family": "PingFang SC, Microsoft YaHei, system-ui, sans-serif",
|
||||||
|
"grid_pattern": { "enabled": false },
|
||||||
|
"decorations": { "corner_lines": false, "glow_effects": false, "description": "轻柔圆角、叶片图标、自然渐变色块,清新透气感" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--bg-primary: #F0FDF4;
|
||||||
|
--bg-secondary: #ECFDF5;
|
||||||
|
--card-bg-from: #FFFFFF;
|
||||||
|
--card-bg-to: #F0FDF4;
|
||||||
|
--card-border: rgba(22,163,74,0.12);
|
||||||
|
--card-radius: 16px;
|
||||||
|
--text-primary: #14532D;
|
||||||
|
--text-secondary: #4B5563;
|
||||||
|
--accent-1: #16A34A;
|
||||||
|
--accent-2: #059669;
|
||||||
|
--accent-3: #F59E0B;
|
||||||
|
--accent-4: #D97706;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. 紫金奢华 (luxury_purple)
|
||||||
|
|
||||||
|
适用场景:时尚/奢侈品、高端服务/地产、设计/创意行业、品牌发布会
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"style_name": "紫金奢华 (Luxury Purple)",
|
||||||
|
"style_id": "luxury_purple",
|
||||||
|
"background": { "primary": "#120A2E", "gradient_to": "#1A0B3D" },
|
||||||
|
"card": { "gradient_from": "#2D1B69", "gradient_to": "#1A0B3D", "border": "rgba(192,132,252,0.1)", "border_radius": 12 },
|
||||||
|
"text": { "primary": "#F5F3FF", "secondary": "rgba(245,243,255,0.7)", "title_size": 28, "body_size": 14, "card_title_size": 20 },
|
||||||
|
"accent": { "primary": ["#A855F7", "#7C3AED"], "secondary": ["#F59E0B", "#D97706"] },
|
||||||
|
"font_family": "PingFang SC, Microsoft YaHei, system-ui, sans-serif",
|
||||||
|
"grid_pattern": { "enabled": true, "size": 50, "dot_radius": 1, "dot_color": "#A855F7", "dot_opacity": 0.03 },
|
||||||
|
"decorations": { "corner_lines": true, "glow_effects": true, "description": "紫色光晕、金色点缀、钻石切割线条装饰,极致奢华感" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--bg-primary: #120A2E;
|
||||||
|
--bg-secondary: #1A0B3D;
|
||||||
|
--card-bg-from: #2D1B69;
|
||||||
|
--card-bg-to: #1A0B3D;
|
||||||
|
--card-border: rgba(192,132,252,0.1);
|
||||||
|
--card-radius: 12px;
|
||||||
|
--text-primary: #F5F3FF;
|
||||||
|
--text-secondary: rgba(245,243,255,0.7);
|
||||||
|
--accent-1: #A855F7;
|
||||||
|
--accent-2: #7C3AED;
|
||||||
|
--accent-3: #F59E0B;
|
||||||
|
--accent-4: #D97706;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. 极简灰白 (minimal_gray)
|
||||||
|
|
||||||
|
适用场景:学术/研究报告、法务/合规、咨询/顾问报告、数据分析
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"style_name": "极简灰白 (Minimal Gray)",
|
||||||
|
"style_id": "minimal_gray",
|
||||||
|
"background": { "primary": "#FAFAFA", "gradient_to": "#F5F5F5" },
|
||||||
|
"card": { "gradient_from": "#FFFFFF", "gradient_to": "#FAFAFA", "border": "rgba(0,0,0,0.08)", "border_radius": 8 },
|
||||||
|
"text": { "primary": "#171717", "secondary": "#737373", "title_size": 28, "body_size": 14, "card_title_size": 20 },
|
||||||
|
"accent": { "primary": ["#171717", "#404040"], "secondary": ["#DC2626", "#B91C1C"] },
|
||||||
|
"font_family": "'Inter', 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif",
|
||||||
|
"grid_pattern": { "enabled": false },
|
||||||
|
"decorations": { "corner_lines": false, "glow_effects": false, "description": "纯净无装饰、大量留白、精确排版、红色仅用于关键数据强调" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--bg-primary: #FAFAFA;
|
||||||
|
--bg-secondary: #F5F5F5;
|
||||||
|
--card-bg-from: #FFFFFF;
|
||||||
|
--card-bg-to: #FAFAFA;
|
||||||
|
--card-border: rgba(0,0,0,0.08);
|
||||||
|
--card-radius: 8px;
|
||||||
|
--text-primary: #171717;
|
||||||
|
--text-secondary: #737373;
|
||||||
|
--accent-1: #171717;
|
||||||
|
--accent-2: #404040;
|
||||||
|
--accent-3: #DC2626;
|
||||||
|
--accent-4: #B91C1C;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. 活力彩虹 (vibrant_rainbow)
|
||||||
|
|
||||||
|
适用场景:社交/娱乐平台、营销/推广材料、年轻品牌、创意方案
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"style_name": "活力彩虹 (Vibrant Rainbow)",
|
||||||
|
"style_id": "vibrant_rainbow",
|
||||||
|
"background": { "primary": "#FFFFFF", "gradient_to": "#FFF7ED" },
|
||||||
|
"card": { "gradient_from": "#FFFFFF", "gradient_to": "#FFF1E6", "border": "rgba(251,146,60,0.15)", "border_radius": 20 },
|
||||||
|
"text": { "primary": "#1C1917", "secondary": "#57534E", "title_size": 28, "body_size": 14, "card_title_size": 20 },
|
||||||
|
"accent": { "primary": ["#F97316", "#EC4899"], "secondary": ["#8B5CF6", "#06B6D4"] },
|
||||||
|
"font_family": "'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif",
|
||||||
|
"grid_pattern": { "enabled": false },
|
||||||
|
"decorations": { "corner_lines": false, "glow_effects": false, "description": "多彩渐变色块、圆润大圆角、活力四溢的卡片配色(每张卡片可用不同 accent 色)" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--bg-primary: #FFFFFF;
|
||||||
|
--bg-secondary: #FFF7ED;
|
||||||
|
--card-bg-from: #FFFFFF;
|
||||||
|
--card-bg-to: #FFF1E6;
|
||||||
|
--card-border: rgba(251,146,60,0.15);
|
||||||
|
--card-radius: 20px;
|
||||||
|
--text-primary: #1C1917;
|
||||||
|
--text-secondary: #57534E;
|
||||||
|
--accent-1: #F97316;
|
||||||
|
--accent-2: #EC4899;
|
||||||
|
--accent-3: #8B5CF6;
|
||||||
|
--accent-4: #06B6D4;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 风格自动推断
|
||||||
|
|
||||||
|
当用户未指定风格时,根据主题关键词自动推断:
|
||||||
|
|
||||||
|
| 关键词匹配 | 推荐风格 |
|
||||||
|
|-----------|---------|
|
||||||
|
| AI、机器学习、深度学习、SaaS、云、平台、API、开发者、大模型、LLM、数据、算法 | dark_tech |
|
||||||
|
| 手机、硬件、IoT、智能家居、芯片、穿戴、电子、家电、机器人 | xiaomi_orange |
|
||||||
|
| 企业、公司、培训、教育、医疗、金融、银行、保险、制药、GLP、GMP、质量 | blue_white |
|
||||||
|
| 文化、历史、国风、中国、政务、党建、品牌故事、非遗、传统 | royal_red |
|
||||||
|
| 环保、绿色、可持续、健康、养生、食品、农业、有机、美妆、护肤、自然 | fresh_green |
|
||||||
|
| 时尚、奢侈品、高端、地产、设计、创意、艺术、珠宝、定制 | luxury_purple |
|
||||||
|
| 学术、研究、论文、报告、法务、合规、咨询、审计、数据分析、白皮书 | minimal_gray |
|
||||||
|
| 社交、娱乐、营销、推广、年轻、潮流、游戏、直播、短视频、电商 | vibrant_rainbow |
|
||||||
|
| 其他未匹配 | blue_white(最通用的默认风格) |
|
||||||
|
|
||||||
|
## 自定义风格
|
||||||
|
|
||||||
|
用户可以在 Step 1 的"补充需求"中指定品牌色:
|
||||||
|
|
||||||
|
> "品牌主色 #1DA1F2,背景用深色"
|
||||||
|
|
||||||
|
此时基于最接近的预置风格,替换对应的色值字段:
|
||||||
|
1. 将 accent.primary 替换为用户品牌色
|
||||||
|
2. 根据品牌色明度自动选择 background 深/浅
|
||||||
|
3. 其他字段保持预置风格的值
|
||||||
542
scripts/html2svg.py
Normal file
542
scripts/html2svg.py
Normal file
@@ -0,0 +1,542 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""HTML -> 真矢量 SVG 转换(文字保留为可编辑 <text> 元素)
|
||||||
|
|
||||||
|
核心方案:Puppeteer + dom-to-svg
|
||||||
|
- Puppeteer 在 headless 浏览器中打开 HTML
|
||||||
|
- dom-to-svg 直接将 DOM 树转为 SVG,保留 <text> 元素
|
||||||
|
- 不经过 PDF 中转,文字不会变成 path
|
||||||
|
|
||||||
|
降级方案:Puppeteer PDF + pdf2svg(文字变 path,不可编辑)
|
||||||
|
|
||||||
|
首次运行自动安装依赖(dom-to-svg, puppeteer, esbuild)。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python3 html2svg.py <html_dir_or_file> [-o output_dir]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Puppeteer + dom-to-svg bundle 注入脚本
|
||||||
|
CONVERT_SCRIPT = r"""
|
||||||
|
const puppeteer = require('puppeteer');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const config = JSON.parse(process.argv[2]);
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: 'new',
|
||||||
|
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu',
|
||||||
|
'--font-render-hinting=none']
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const item of config.files) {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1280, height: 720 });
|
||||||
|
|
||||||
|
await page.goto('file://' + item.html, {
|
||||||
|
waitUntil: 'networkidle0',
|
||||||
|
timeout: 30000
|
||||||
|
});
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
|
||||||
|
// 注入预打包的 dom-to-svg bundle
|
||||||
|
await page.addScriptTag({ path: config.bundlePath });
|
||||||
|
|
||||||
|
// 预处理:在 Node.js 端读取图片文件转 base64,传给浏览器替换 src
|
||||||
|
// (浏览器端 canvas.toDataURL 会因 file:// CORS 被阻止)
|
||||||
|
const imgSrcs = await page.evaluate(() => {
|
||||||
|
const imgs = document.querySelectorAll('img');
|
||||||
|
return Array.from(imgs).map(img => img.getAttribute('src') || '');
|
||||||
|
});
|
||||||
|
|
||||||
|
const imgDataMap = {};
|
||||||
|
for (const src of imgSrcs) {
|
||||||
|
if (!src) continue;
|
||||||
|
// 处理 file:// 和绝对路径
|
||||||
|
let filePath = src;
|
||||||
|
if (filePath.startsWith('file://')) filePath = filePath.slice(7);
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
const data = fs.readFileSync(filePath);
|
||||||
|
const ext = path.extname(filePath).slice(1) || 'png';
|
||||||
|
const mime = ext === 'jpg' ? 'image/jpeg' : `image/${ext}`;
|
||||||
|
imgDataMap[src] = `data:${mime};base64,${data.toString('base64')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(imgDataMap).length > 0) {
|
||||||
|
await page.evaluate((dataMap) => {
|
||||||
|
const imgs = document.querySelectorAll('img');
|
||||||
|
for (const img of imgs) {
|
||||||
|
const origSrc = img.getAttribute('src');
|
||||||
|
if (origSrc && dataMap[origSrc]) {
|
||||||
|
img.src = dataMap[origSrc];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, imgDataMap);
|
||||||
|
// 等待图片重新渲染
|
||||||
|
await new Promise(r => setTimeout(r, 300));
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 预处理:将 dom-to-svg 不支持的 CSS 特性转为真实 DOM ===
|
||||||
|
await page.evaluate(() => {
|
||||||
|
// 1. 物化伪元素 ::before / ::after -> 真实 span
|
||||||
|
// dom-to-svg 无法读取 CSS 伪元素,导致箭头/装饰丢失
|
||||||
|
const all = document.querySelectorAll('*');
|
||||||
|
for (const el of all) {
|
||||||
|
for (const pseudo of ['::before', '::after']) {
|
||||||
|
const style = getComputedStyle(el, pseudo);
|
||||||
|
const content = style.content;
|
||||||
|
if (!content || content === 'none' || content === '""' || content === "''") continue;
|
||||||
|
|
||||||
|
const w = parseFloat(style.width) || 0;
|
||||||
|
const h = parseFloat(style.height) || 0;
|
||||||
|
const bg = style.backgroundColor;
|
||||||
|
const border = style.borderTopWidth;
|
||||||
|
const borderColor = style.borderTopColor;
|
||||||
|
|
||||||
|
// 只处理有尺寸或有边框的伪元素(箭头/装饰块)
|
||||||
|
if ((w > 0 || h > 0 || parseFloat(border) > 0) && content !== 'normal') {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.style.display = style.display === 'none' ? 'none' : 'inline-block';
|
||||||
|
span.style.position = style.position;
|
||||||
|
span.style.width = style.width;
|
||||||
|
span.style.height = style.height;
|
||||||
|
span.style.backgroundColor = bg;
|
||||||
|
span.style.borderTop = style.borderTop;
|
||||||
|
span.style.borderRight = style.borderRight;
|
||||||
|
span.style.borderBottom = style.borderBottom;
|
||||||
|
span.style.borderLeft = style.borderLeft;
|
||||||
|
span.style.transform = style.transform;
|
||||||
|
span.style.top = style.top;
|
||||||
|
span.style.left = style.left;
|
||||||
|
span.style.right = style.right;
|
||||||
|
span.style.bottom = style.bottom;
|
||||||
|
span.style.borderRadius = style.borderRadius;
|
||||||
|
span.setAttribute('data-pseudo', pseudo);
|
||||||
|
|
||||||
|
// 文本内容(去掉引号)
|
||||||
|
const textContent = content.replace(/^["']|["']$/g, '');
|
||||||
|
if (textContent && textContent !== 'normal' && textContent !== 'none') {
|
||||||
|
span.textContent = textContent;
|
||||||
|
span.style.color = style.color;
|
||||||
|
span.style.fontSize = style.fontSize;
|
||||||
|
span.style.fontWeight = style.fontWeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pseudo === '::before') {
|
||||||
|
el.insertBefore(span, el.firstChild);
|
||||||
|
} else {
|
||||||
|
el.appendChild(span);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 将 conic-gradient 环形图转为 SVG
|
||||||
|
// 查找带有 conic-gradient 背景的元素
|
||||||
|
for (const el of document.querySelectorAll('*')) {
|
||||||
|
const bg = el.style.background || el.style.backgroundImage || '';
|
||||||
|
const computed = getComputedStyle(el);
|
||||||
|
const bgImage = computed.backgroundImage || '';
|
||||||
|
|
||||||
|
if (!bgImage.includes('conic-gradient')) continue;
|
||||||
|
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const size = Math.min(rect.width, rect.height);
|
||||||
|
if (size <= 0) continue;
|
||||||
|
|
||||||
|
// 解析 conic-gradient 的百分比和颜色
|
||||||
|
const match = bgImage.match(/conic-gradient\(([^)]+)\)/);
|
||||||
|
if (!match) continue;
|
||||||
|
|
||||||
|
const gradStr = match[1];
|
||||||
|
// 提取百分比(典型格式: #color 0% 75%, #color2 75% 100%)
|
||||||
|
const percMatch = gradStr.match(/([\d.]+)%/g);
|
||||||
|
let percentage = 75; // 默认
|
||||||
|
if (percMatch && percMatch.length >= 2) {
|
||||||
|
percentage = parseFloat(percMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取颜色
|
||||||
|
const colorMatch = gradStr.match(/(#[0-9a-fA-F]{3,8}|rgb[a]?\([^)]+\))/g);
|
||||||
|
const mainColor = colorMatch ? colorMatch[0] : '#4CAF50';
|
||||||
|
const bgColor = colorMatch && colorMatch.length > 1 ? colorMatch[1] : '#e0e0e0';
|
||||||
|
|
||||||
|
// 创建 SVG 替换
|
||||||
|
const svgNS = 'http://www.w3.org/2000/svg';
|
||||||
|
const svg = document.createElementNS(svgNS, 'svg');
|
||||||
|
svg.setAttribute('width', String(size));
|
||||||
|
svg.setAttribute('height', String(size));
|
||||||
|
svg.setAttribute('viewBox', `0 0 ${size} ${size}`);
|
||||||
|
svg.style.display = el.style.display || 'block';
|
||||||
|
svg.style.position = computed.position;
|
||||||
|
svg.style.top = computed.top;
|
||||||
|
svg.style.left = computed.left;
|
||||||
|
|
||||||
|
const cx = size / 2, cy = size / 2;
|
||||||
|
const r = size * 0.4;
|
||||||
|
const circumference = 2 * Math.PI * r;
|
||||||
|
const strokeWidth = size * 0.15;
|
||||||
|
|
||||||
|
// 背景圆环
|
||||||
|
const bgCircle = document.createElementNS(svgNS, 'circle');
|
||||||
|
bgCircle.setAttribute('cx', String(cx));
|
||||||
|
bgCircle.setAttribute('cy', String(cy));
|
||||||
|
bgCircle.setAttribute('r', String(r));
|
||||||
|
bgCircle.setAttribute('fill', 'none');
|
||||||
|
bgCircle.setAttribute('stroke', bgColor);
|
||||||
|
bgCircle.setAttribute('stroke-width', String(strokeWidth));
|
||||||
|
|
||||||
|
// 进度圆环
|
||||||
|
const fgCircle = document.createElementNS(svgNS, 'circle');
|
||||||
|
fgCircle.setAttribute('cx', String(cx));
|
||||||
|
fgCircle.setAttribute('cy', String(cy));
|
||||||
|
fgCircle.setAttribute('r', String(r));
|
||||||
|
fgCircle.setAttribute('fill', 'none');
|
||||||
|
fgCircle.setAttribute('stroke', mainColor);
|
||||||
|
fgCircle.setAttribute('stroke-width', String(strokeWidth));
|
||||||
|
fgCircle.setAttribute('stroke-dasharray', `${circumference * percentage / 100} ${circumference}`);
|
||||||
|
fgCircle.setAttribute('stroke-linecap', 'round');
|
||||||
|
fgCircle.setAttribute('transform', `rotate(-90 ${cx} ${cy})`);
|
||||||
|
|
||||||
|
svg.appendChild(bgCircle);
|
||||||
|
svg.appendChild(fgCircle);
|
||||||
|
|
||||||
|
// 保留子元素(如百分比文字),放到 foreignObject 不行
|
||||||
|
// 直接添加 SVG text
|
||||||
|
const textEl = el.querySelector('*');
|
||||||
|
if (el.textContent && el.textContent.trim()) {
|
||||||
|
const svgText = document.createElementNS(svgNS, 'text');
|
||||||
|
svgText.setAttribute('x', String(cx));
|
||||||
|
svgText.setAttribute('y', String(cy));
|
||||||
|
svgText.setAttribute('text-anchor', 'middle');
|
||||||
|
svgText.setAttribute('dominant-baseline', 'central');
|
||||||
|
svgText.setAttribute('fill', computed.color);
|
||||||
|
svgText.setAttribute('font-size', computed.fontSize);
|
||||||
|
svgText.setAttribute('font-weight', computed.fontWeight);
|
||||||
|
svgText.textContent = el.textContent.trim();
|
||||||
|
svg.appendChild(svgText);
|
||||||
|
}
|
||||||
|
|
||||||
|
el.style.background = 'none';
|
||||||
|
el.style.backgroundImage = 'none';
|
||||||
|
el.insertBefore(svg, el.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 将 CSS border 三角形箭头修复
|
||||||
|
// 查找宽高为 0 但有 border 的元素(CSS 三角形技巧)
|
||||||
|
for (const el of document.querySelectorAll('*')) {
|
||||||
|
const cs = getComputedStyle(el);
|
||||||
|
const w = parseFloat(cs.width);
|
||||||
|
const h = parseFloat(cs.height);
|
||||||
|
if (w !== 0 || h !== 0) continue;
|
||||||
|
|
||||||
|
const bt = parseFloat(cs.borderTopWidth) || 0;
|
||||||
|
const br = parseFloat(cs.borderRightWidth) || 0;
|
||||||
|
const bb = parseFloat(cs.borderBottomWidth) || 0;
|
||||||
|
const bl = parseFloat(cs.borderLeftWidth) || 0;
|
||||||
|
|
||||||
|
// 至少两个边框有宽度才是三角形
|
||||||
|
const borders = [bt, br, bb, bl].filter(v => v > 0);
|
||||||
|
if (borders.length < 2) continue;
|
||||||
|
|
||||||
|
const btc = cs.borderTopColor;
|
||||||
|
const brc = cs.borderRightColor;
|
||||||
|
const bbc = cs.borderBottomColor;
|
||||||
|
const blc = cs.borderLeftColor;
|
||||||
|
|
||||||
|
// 找有色边框(非 transparent)
|
||||||
|
const nonTransparent = [];
|
||||||
|
if (bt > 0 && !btc.includes('0)') && btc !== 'transparent') nonTransparent.push({dir: 'top', size: bt, color: btc});
|
||||||
|
if (br > 0 && !brc.includes('0)') && brc !== 'transparent') nonTransparent.push({dir: 'right', size: br, color: brc});
|
||||||
|
if (bb > 0 && !bbc.includes('0)') && bbc !== 'transparent') nonTransparent.push({dir: 'bottom', size: bb, color: bbc});
|
||||||
|
if (bl > 0 && !blc.includes('0)') && blc !== 'transparent') nonTransparent.push({dir: 'left', size: bl, color: blc});
|
||||||
|
|
||||||
|
if (nonTransparent.length !== 1) continue;
|
||||||
|
|
||||||
|
// 用实际尺寸的 div 替换
|
||||||
|
const arrow = nonTransparent[0];
|
||||||
|
const totalW = bl + br;
|
||||||
|
const totalH = bt + bb;
|
||||||
|
el.style.width = totalW + 'px';
|
||||||
|
el.style.height = totalH + 'px';
|
||||||
|
el.style.border = 'none';
|
||||||
|
|
||||||
|
// 用 SVG 绘制三角形
|
||||||
|
const svgNS = 'http://www.w3.org/2000/svg';
|
||||||
|
const svg = document.createElementNS(svgNS, 'svg');
|
||||||
|
svg.setAttribute('width', String(totalW));
|
||||||
|
svg.setAttribute('height', String(totalH));
|
||||||
|
svg.style.display = 'block';
|
||||||
|
svg.style.overflow = 'visible';
|
||||||
|
|
||||||
|
const polygon = document.createElementNS(svgNS, 'polygon');
|
||||||
|
let points = '';
|
||||||
|
if (arrow.dir === 'bottom') points = `0,0 ${totalW},0 ${totalW/2},${totalH}`;
|
||||||
|
else if (arrow.dir === 'top') points = `${totalW/2},0 0,${totalH} ${totalW},${totalH}`;
|
||||||
|
else if (arrow.dir === 'right') points = `0,0 ${totalW},${totalH/2} 0,${totalH}`;
|
||||||
|
else if (arrow.dir === 'left') points = `${totalW},0 0,${totalH/2} ${totalW},${totalH}`;
|
||||||
|
polygon.setAttribute('points', points);
|
||||||
|
polygon.setAttribute('fill', arrow.color);
|
||||||
|
svg.appendChild(polygon);
|
||||||
|
el.appendChild(svg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await new Promise(r => setTimeout(r, 300));
|
||||||
|
|
||||||
|
// === 执行 DOM -> SVG 转换 ===
|
||||||
|
let svgString = await page.evaluate(async () => {
|
||||||
|
const { documentToSVG, inlineResources } = window.__domToSvg;
|
||||||
|
const svgDoc = documentToSVG(document);
|
||||||
|
await inlineResources(svgDoc.documentElement);
|
||||||
|
|
||||||
|
// 后处理:将 <text> 的 color 属性转为 fill(SVG 标准)
|
||||||
|
const texts = svgDoc.querySelectorAll('text');
|
||||||
|
for (const t of texts) {
|
||||||
|
const c = t.getAttribute('color');
|
||||||
|
if (c && !t.getAttribute('fill')) {
|
||||||
|
t.setAttribute('fill', c);
|
||||||
|
t.removeAttribute('color');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new XMLSerializer().serializeToString(svgDoc);
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.writeFileSync(item.svg, svgString, 'utf-8');
|
||||||
|
console.log('SVG: ' + path.basename(item.html));
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log('Done: ' + config.files.length + ' SVGs');
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 降级 PDF 方案脚本
|
||||||
|
FALLBACK_SCRIPT = r"""
|
||||||
|
const puppeteer = require('puppeteer');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const config = JSON.parse(process.argv[2]);
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: 'new',
|
||||||
|
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu']
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const item of config.files) {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1280, height: 720 });
|
||||||
|
await page.goto('file://' + item.html, {
|
||||||
|
waitUntil: 'networkidle0',
|
||||||
|
timeout: 30000
|
||||||
|
});
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
await page.pdf({
|
||||||
|
path: item.pdf,
|
||||||
|
width: '1280px',
|
||||||
|
height: '720px',
|
||||||
|
printBackground: true,
|
||||||
|
preferCSSPageSize: true
|
||||||
|
});
|
||||||
|
console.log('PDF: ' + path.basename(item.html));
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
await browser.close();
|
||||||
|
console.log('Done: ' + config.files.length + ' PDFs');
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
# esbuild 打包入口
|
||||||
|
BUNDLE_ENTRY = """
|
||||||
|
import { documentToSVG, elementToSVG, inlineResources } from 'dom-to-svg';
|
||||||
|
window.__domToSvg = { documentToSVG, elementToSVG, inlineResources };
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_deps(work_dir: Path) -> tuple:
|
||||||
|
"""安装依赖,返回 (方案名, bundle路径)"""
|
||||||
|
# puppeteer
|
||||||
|
r = subprocess.run(
|
||||||
|
["node", "-e", "require('puppeteer')"],
|
||||||
|
capture_output=True, text=True, timeout=10, cwd=str(work_dir)
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
print("Installing puppeteer...")
|
||||||
|
subprocess.run(["npm", "install", "puppeteer"],
|
||||||
|
capture_output=True, text=True, timeout=180, cwd=str(work_dir))
|
||||||
|
|
||||||
|
# dom-to-svg
|
||||||
|
r = subprocess.run(
|
||||||
|
["node", "-e", "require('dom-to-svg')"],
|
||||||
|
capture_output=True, text=True, timeout=10, cwd=str(work_dir)
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
print("Installing dom-to-svg...")
|
||||||
|
subprocess.run(["npm", "install", "dom-to-svg"],
|
||||||
|
capture_output=True, text=True, timeout=60, cwd=str(work_dir))
|
||||||
|
r = subprocess.run(
|
||||||
|
["node", "-e", "require('dom-to-svg')"],
|
||||||
|
capture_output=True, text=True, timeout=10, cwd=str(work_dir)
|
||||||
|
)
|
||||||
|
|
||||||
|
if r.returncode != 0:
|
||||||
|
print("dom-to-svg unavailable, using pdf2svg fallback", file=sys.stderr)
|
||||||
|
return ("pdf2svg", None)
|
||||||
|
|
||||||
|
# 打包 dom-to-svg 为浏览器 bundle
|
||||||
|
bundle_path = work_dir / "dom-to-svg.bundle.js"
|
||||||
|
if not bundle_path.exists():
|
||||||
|
print("Building dom-to-svg browser bundle...")
|
||||||
|
entry_path = work_dir / ".bundle_entry.js"
|
||||||
|
entry_path.write_text(BUNDLE_ENTRY)
|
||||||
|
r = subprocess.run(
|
||||||
|
["npx", "-y", "esbuild", str(entry_path),
|
||||||
|
"--bundle", "--format=iife",
|
||||||
|
f"--outfile={bundle_path}", "--platform=browser"],
|
||||||
|
capture_output=True, text=True, timeout=60, cwd=str(work_dir)
|
||||||
|
)
|
||||||
|
if entry_path.exists():
|
||||||
|
entry_path.unlink()
|
||||||
|
if r.returncode != 0:
|
||||||
|
print(f"esbuild failed: {r.stderr}", file=sys.stderr)
|
||||||
|
return ("pdf2svg", None)
|
||||||
|
|
||||||
|
return ("dom-to-svg", str(bundle_path))
|
||||||
|
|
||||||
|
|
||||||
|
def convert_dom_to_svg(html_files, output_dir, work_dir, bundle_path):
|
||||||
|
"""用 dom-to-svg 方案转换"""
|
||||||
|
config = {
|
||||||
|
"bundlePath": bundle_path,
|
||||||
|
"files": [
|
||||||
|
{"html": str(f), "svg": str(output_dir / (f.stem + ".svg"))}
|
||||||
|
for f in html_files
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
script_path = work_dir / ".dom2svg_tmp.js"
|
||||||
|
script_path.write_text(CONVERT_SCRIPT)
|
||||||
|
|
||||||
|
try:
|
||||||
|
print(f"Converting {len(html_files)} HTML files (dom-to-svg, text editable)...")
|
||||||
|
r = subprocess.run(
|
||||||
|
["node", str(script_path), json.dumps(config)],
|
||||||
|
cwd=str(work_dir), timeout=300
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 验证是否有 <text> 元素
|
||||||
|
first_svg = output_dir / (html_files[0].stem + ".svg")
|
||||||
|
if first_svg.exists():
|
||||||
|
content = first_svg.read_text(errors="ignore")
|
||||||
|
text_count = content.count("<text ")
|
||||||
|
print(f"Text elements: {text_count} (editable in PPT)")
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
if script_path.exists():
|
||||||
|
script_path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def convert_pdf2svg(html_files, output_dir, work_dir):
|
||||||
|
"""降级方案:Puppeteer PDF + pdf2svg"""
|
||||||
|
if not shutil.which("pdf2svg"):
|
||||||
|
print("pdf2svg not found. Install: sudo apt install pdf2svg", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
pdf_tmp = work_dir / ".pdf_tmp"
|
||||||
|
pdf_tmp.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"files": [
|
||||||
|
{"html": str(f), "pdf": str(pdf_tmp / (f.stem + ".pdf"))}
|
||||||
|
for f in html_files
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
script_path = work_dir / ".fallback_tmp.js"
|
||||||
|
script_path.write_text(FALLBACK_SCRIPT)
|
||||||
|
|
||||||
|
try:
|
||||||
|
print(f"Step 1/2: HTML -> PDF ({len(html_files)} files)...")
|
||||||
|
r = subprocess.run(
|
||||||
|
["node", str(script_path), json.dumps(config)],
|
||||||
|
cwd=str(work_dir), timeout=300
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
print("Step 2/2: PDF -> SVG (WARNING: text becomes paths, NOT editable)...")
|
||||||
|
success = 0
|
||||||
|
for item in config["files"]:
|
||||||
|
svg_name = Path(item["pdf"]).stem + ".svg"
|
||||||
|
svg_path = output_dir / svg_name
|
||||||
|
r = subprocess.run(
|
||||||
|
["pdf2svg", item["pdf"], str(svg_path)],
|
||||||
|
capture_output=True, text=True, timeout=30
|
||||||
|
)
|
||||||
|
if r.returncode == 0:
|
||||||
|
print(f" OK {svg_name}")
|
||||||
|
success += 1
|
||||||
|
return success > 0
|
||||||
|
finally:
|
||||||
|
if script_path.exists():
|
||||||
|
script_path.unlink()
|
||||||
|
if pdf_tmp.exists():
|
||||||
|
shutil.rmtree(pdf_tmp)
|
||||||
|
|
||||||
|
|
||||||
|
def convert(html_dir: Path, output_dir: Path) -> bool:
|
||||||
|
"""主转换入口"""
|
||||||
|
if html_dir.is_file():
|
||||||
|
html_files = [html_dir]
|
||||||
|
work_dir = html_dir.parent.parent
|
||||||
|
else:
|
||||||
|
html_files = sorted(html_dir.glob("*.html"))
|
||||||
|
work_dir = html_dir.parent
|
||||||
|
|
||||||
|
if not html_files:
|
||||||
|
print(f"No HTML files in {html_dir}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
method, bundle_path = ensure_deps(work_dir)
|
||||||
|
|
||||||
|
if method == "dom-to-svg" and bundle_path:
|
||||||
|
ok = convert_dom_to_svg(html_files, output_dir, work_dir, bundle_path)
|
||||||
|
if ok:
|
||||||
|
print(f"\nDone! {len(html_files)} SVGs -> {output_dir}")
|
||||||
|
return True
|
||||||
|
print("dom-to-svg failed, falling back to pdf2svg...")
|
||||||
|
|
||||||
|
return convert_pdf2svg(html_files, output_dir, work_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: python3 html2svg.py <html_dir_or_file> [-o output_dir]")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
html_path = Path(sys.argv[1]).resolve()
|
||||||
|
if "-o" in sys.argv:
|
||||||
|
idx = sys.argv.index("-o")
|
||||||
|
output_dir = Path(sys.argv[idx + 1]).resolve()
|
||||||
|
else:
|
||||||
|
output_dir = (html_path.parent if html_path.is_file() else html_path.parent) / "svg"
|
||||||
|
|
||||||
|
success = convert(html_path, output_dir)
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
205
scripts/html_packager.py
Normal file
205
scripts/html_packager.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""HTML 打包工具 -- 将多页 HTML 合并为可翻页的单文件预览
|
||||||
|
|
||||||
|
每页 HTML 放在独立的 iframe srcdoc 中,CSS 完全隔离,零冲突。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python html_packager.py <slides_directory> [-o output.html] [--title "Title"]
|
||||||
|
python html_packager.py ppt-output/slides/ -o ppt-output/preview.html
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import html as html_module
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def inline_images(html_content: str, html_dir: Path) -> str:
|
||||||
|
"""将 HTML 中引用的本地图片转为 base64 内联。"""
|
||||||
|
def replace_src(match):
|
||||||
|
attr = match.group(1) # src= or url(
|
||||||
|
path_str = match.group(2)
|
||||||
|
closing = match.group(3) # " or )
|
||||||
|
|
||||||
|
# 处理绝对路径和相对路径
|
||||||
|
img_path = Path(path_str)
|
||||||
|
if not img_path.is_absolute():
|
||||||
|
img_path = html_dir / path_str
|
||||||
|
|
||||||
|
if img_path.exists() and img_path.is_file():
|
||||||
|
ext = img_path.suffix.lower().lstrip('.')
|
||||||
|
mime = {'jpg': 'image/jpeg', 'jpeg': 'image/jpeg',
|
||||||
|
'png': 'image/png', 'gif': 'image/gif',
|
||||||
|
'svg': 'image/svg+xml', 'webp': 'image/webp'
|
||||||
|
}.get(ext, f'image/{ext}')
|
||||||
|
data = base64.b64encode(img_path.read_bytes()).decode()
|
||||||
|
return f'{attr}data:{mime};base64,{data}{closing}'
|
||||||
|
return match.group(0)
|
||||||
|
|
||||||
|
# 匹配 src="..." 和 url(...)
|
||||||
|
html_content = re.sub(
|
||||||
|
r'(src=["\'])([^"\']+?)(["\'])',
|
||||||
|
replace_src, html_content)
|
||||||
|
html_content = re.sub(
|
||||||
|
r'(url\(["\']?)([^"\')\s]+?)(["\']?\))',
|
||||||
|
replace_src, html_content)
|
||||||
|
return html_content
|
||||||
|
|
||||||
|
|
||||||
|
def build_preview(slide_files: list, title: str = "PPT Preview") -> str:
|
||||||
|
"""构建可翻页的预览 HTML,每页用独立 iframe 实现 CSS 隔离。"""
|
||||||
|
slides_srcdoc = []
|
||||||
|
|
||||||
|
for f in slide_files:
|
||||||
|
html_dir = Path(f).parent
|
||||||
|
with open(f, "r", encoding="utf-8") as fh:
|
||||||
|
content = fh.read()
|
||||||
|
|
||||||
|
# 内联图片为 base64
|
||||||
|
content = inline_images(content, html_dir)
|
||||||
|
|
||||||
|
# 转义为 srcdoc 安全内容(& -> & " -> ")
|
||||||
|
escaped = html_module.escape(content, quote=True)
|
||||||
|
slides_srcdoc.append(escaped)
|
||||||
|
|
||||||
|
total = len(slides_srcdoc)
|
||||||
|
escaped_title = html_module.escape(title)
|
||||||
|
|
||||||
|
# 生成 iframe 列表
|
||||||
|
iframes = []
|
||||||
|
for i, srcdoc in enumerate(slides_srcdoc):
|
||||||
|
display = "block" if i == 0 else "none"
|
||||||
|
iframes.append(
|
||||||
|
f'<iframe class="slide-frame" id="slide-{i}" '
|
||||||
|
f'style="display:{display}" '
|
||||||
|
f'srcdoc="{srcdoc}" '
|
||||||
|
f'sandbox="allow-same-origin" '
|
||||||
|
f'frameborder="0" scrolling="no"></iframe>'
|
||||||
|
)
|
||||||
|
|
||||||
|
iframes_block = '\n'.join(iframes)
|
||||||
|
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{escaped_title}</title>
|
||||||
|
<style>
|
||||||
|
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||||
|
body {{
|
||||||
|
background: #0a0a0a;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
|
}}
|
||||||
|
.toolbar {{
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; height: 48px;
|
||||||
|
background: rgba(10,10,10,0.95); border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 16px;
|
||||||
|
z-index: 1000; backdrop-filter: blur(10px);
|
||||||
|
}}
|
||||||
|
.toolbar button {{
|
||||||
|
background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2);
|
||||||
|
color: #fff; padding: 6px 16px; border-radius: 6px; cursor: pointer;
|
||||||
|
font-size: 14px; transition: background 0.2s;
|
||||||
|
}}
|
||||||
|
.toolbar button:hover {{ background: rgba(255,255,255,0.2); }}
|
||||||
|
.toolbar button:disabled {{ opacity: 0.3; cursor: not-allowed; }}
|
||||||
|
.page-info {{ font-size: 14px; color: rgba(255,255,255,0.7); min-width: 80px; text-align: center; }}
|
||||||
|
.stage {{
|
||||||
|
margin-top: 60px; width: 90vw; max-width: 1280px;
|
||||||
|
aspect-ratio: 16/9; overflow: hidden;
|
||||||
|
border-radius: 8px; box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
||||||
|
background: #111; position: relative;
|
||||||
|
}}
|
||||||
|
.slide-frame {{
|
||||||
|
width: 1280px; height: 720px;
|
||||||
|
transform-origin: top left;
|
||||||
|
position: absolute; top: 0; left: 0;
|
||||||
|
border: none;
|
||||||
|
}}
|
||||||
|
.nav-hint {{
|
||||||
|
position: fixed; bottom: 12px;
|
||||||
|
color: rgba(255,255,255,0.25); font-size: 12px;
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button id="btn-prev" onclick="nav(-1)">Prev</button>
|
||||||
|
<span class="page-info" id="page-info">1 / {total}</span>
|
||||||
|
<button id="btn-next" onclick="nav(1)">Next</button>
|
||||||
|
</div>
|
||||||
|
<div class="stage" id="stage">
|
||||||
|
{iframes_block}
|
||||||
|
</div>
|
||||||
|
<div class="nav-hint">Arrow keys to navigate</div>
|
||||||
|
<script>
|
||||||
|
let cur = 0;
|
||||||
|
const frames = document.querySelectorAll('.slide-frame');
|
||||||
|
const total = frames.length;
|
||||||
|
const info = document.getElementById('page-info');
|
||||||
|
const stage = document.getElementById('stage');
|
||||||
|
|
||||||
|
function resize() {{
|
||||||
|
const sw = stage.clientWidth, sh = stage.clientHeight;
|
||||||
|
const scale = Math.min(sw / 1280, sh / 720);
|
||||||
|
frames.forEach(f => f.style.transform = 'scale(' + scale + ')');
|
||||||
|
}}
|
||||||
|
function show(i) {{
|
||||||
|
frames.forEach((f, idx) => f.style.display = idx === i ? 'block' : 'none');
|
||||||
|
info.textContent = (i+1) + ' / ' + total;
|
||||||
|
document.getElementById('btn-prev').disabled = i === 0;
|
||||||
|
document.getElementById('btn-next').disabled = i === total - 1;
|
||||||
|
}}
|
||||||
|
function nav(d) {{
|
||||||
|
const n = cur + d;
|
||||||
|
if (n >= 0 && n < total) {{ cur = n; show(cur); }}
|
||||||
|
}}
|
||||||
|
document.addEventListener('keydown', e => {{
|
||||||
|
if (e.key==='ArrowLeft'||e.key==='ArrowUp') nav(-1);
|
||||||
|
if (e.key==='ArrowRight'||e.key==='ArrowDown'||e.key===' ') nav(1);
|
||||||
|
}});
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
resize();
|
||||||
|
show(0);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
942
scripts/svg2pptx.py
Normal file
942
scripts/svg2pptx.py
Normal file
@@ -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 <svg_dir_or_file> -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 <path> -> 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()
|
||||||
Reference in New Issue
Block a user