中期成果提交:PPT自动生成 Agent(感知-规划-行动-记忆闭环 + aura-ppt 渲染引擎 + Web 交互界面 + 测试用例 + AI 使用日志)
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
---
|
||||
name: aura-ppt
|
||||
description: >
|
||||
专业的演示文稿(PPT/PPTX)生成与美化技能。综合国际顶级AI PPT工具的能力:
|
||||
Gamma的设计感、WPS AI的中文排版理解、7牛AI PPT的内容保真、Beautiful.ai的
|
||||
无损导出——全部通过本地python-pptx引擎实现,零云端依赖、数据不出本机。
|
||||
触发场景:用户要求"生成PPT/制作演示文稿/做一份汇报材料/把文档转成PPT/
|
||||
美化这个PPT/给方案配一份PPT/做路演材料"等。支持四种模式:全流程生成(从
|
||||
大纲到成品)、文档转PPT(Word/Markdown/文本)、已有PPT美化、数据图表页。
|
||||
适用于中文/日文/英文汇报、方案陈述、管理层汇报、技术分享等场景。
|
||||
---
|
||||
|
||||
# Aura PPT — 本地AI演示文稿引擎
|
||||
|
||||
## 核心定位
|
||||
|
||||
**一站式本地PPT生成技能**:把国际AI PPT工具的顶级能力编码为可复现的本地渲染引擎。
|
||||
|
||||
| 能力来源 | 编码进技能的方式 |
|
||||
|---|---|
|
||||
| Gamma 设计感 | 品牌级设计系统(design_system.py):统一配色、字体层级、间距网格、组件库 |
|
||||
| WPS AI 中文理解 | 中文排版规范(references/chinese-typography.md):中英混排、标点、字体回退 |
|
||||
| 7牛AI PPT 内容保真 | **内容保真规则**:用户提供的数据/术语/数字 100% 原样保留,禁止改写 |
|
||||
| Beautiful.ai 无损导出 | python-pptx 原生输出,零转换损失,交付后完全可编辑 |
|
||||
| python-pptx 可控性 | 全矢量形状,任何人可用 PowerPoint/WPS 二次编辑 |
|
||||
|
||||
**硬性合规优势**:全程本地渲染,数据不出本机——适合处理客户机密材料(对日外包、NDA场景)。
|
||||
|
||||
---
|
||||
|
||||
## 触发条件
|
||||
|
||||
以下场景应调用本技能(只要用户意图包含其一):
|
||||
|
||||
- "生成/做一份 PPT / 演示文稿 / 幻灯片"
|
||||
- "把这个文档/方案/报告转成 PPT"
|
||||
- "美化 / 优化 / 重排这个 PPT"
|
||||
- "给 XX 配一份汇报材料 / 路演材料 / 提案"
|
||||
- "做一份管理层汇报 / 技术分享 / 项目总结"
|
||||
|
||||
若用户只是问PPT工具选型、PPT技巧问答(非生成请求)→ 不触发。
|
||||
|
||||
---
|
||||
|
||||
## 四种工作模式
|
||||
|
||||
| 模式 | 触发词 | 流程 |
|
||||
|---|---|---|
|
||||
| **A. 全流程生成** | 从零生成、做一份 | 大纲 → 页面规划(JSON) → 渲染 → 验证 |
|
||||
| **B. 文档转PPT** | 把XX转成PPT | 解析源文档 → 提取结构 → 页面规划 → 渲染 |
|
||||
| **C. 已有PPT美化** | 美化、优化、重排 | 打开现有PPTX → 重新应用设计系统 → 输出 |
|
||||
| **D. 数据图表页** | 数据可视化、图表 | 解析数据(CSV/Excel) → 图表类型选择 → 渲染 |
|
||||
|
||||
---
|
||||
|
||||
## 工作流
|
||||
|
||||
### Step 1:需求澄清(30秒内完成,不要过度提问)
|
||||
|
||||
确认以下最小集,其余用合理默认值:
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| 页数 | 8-12页 | 内容多可自动扩展 |
|
||||
| 设计系统 | `aura-dark`(品牌深色) | 见 references/design-system.md |
|
||||
| 语言 | 跟随用户内容(中文/日文/英文) | 中日双语混合时开启术语对齐 |
|
||||
| 输出目录 | 用户工作目录 | 询问一次即可 |
|
||||
| 16:9 / 4:3 | 16:9 | 现代汇报默认 |
|
||||
|
||||
**如果用户提到"公司/品牌"→ 询问是否有品牌色/Logo**,有则调用 `assets/` 下的品牌配置。
|
||||
|
||||
### Step 2:规划页面结构(Plan JSON)
|
||||
|
||||
生成结构化页面规划。**这是内容保真的关键步骤**:
|
||||
|
||||
```
|
||||
plan.json 结构:
|
||||
{
|
||||
"title": "演示文稿标题",
|
||||
"design": "aura-dark",
|
||||
"mode": "generate | doc2ppt | improve | charts",
|
||||
"source_doc": "可选:源文档路径",
|
||||
"slides": [
|
||||
{
|
||||
"type": "cover | toc | section | content | two_column |
|
||||
table | chart | timeline | quote | end",
|
||||
"title": "页面标题",
|
||||
"subtitle": "副标题(可选)",
|
||||
"content": "正文要点(每行一个要点,支持 **加粗** 和 - 子项)",
|
||||
"data": {"表格数据/图表数据,可选"},
|
||||
"chart": {"type": "bar|line|pie|donut", "source": "csv路径或内联数据"},
|
||||
"notes": "演讲者备注(可选)"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3:调用渲染引擎
|
||||
|
||||
```bash
|
||||
# 标准生成(使用内置设计系统)
|
||||
python scripts/engine.py --plan plan.json --output output.pptx [--design aura-dark]
|
||||
|
||||
# 模板生成(继承指定PPTX的母版背景/页脚/Logo/版式)
|
||||
python scripts/engine.py --plan plan.json --output output.pptx --template 模板.pptx
|
||||
```
|
||||
|
||||
`--template` 模板模式:
|
||||
- 打开指定 PPTX 作为模板,**继承其母版、版式、背景、页脚、Logo**
|
||||
- 自动清空模板自带的旧幻灯片,只保留母版结构
|
||||
- 用模板的"白紙"版式创建页面,引擎的绘制元素叠加其上
|
||||
- 适合:公司已有固定PPT模板/母版,希望新生成的内容保持公司风格一致
|
||||
- 模板模式下不绘制引擎的纯色背景(继承模板背景)
|
||||
|
||||
**区域对齐**:引擎的 header/body/footer 坐标严格遵循设计系统的 `layout` 配置。
|
||||
`report-jp` 的 layout 已与公司母版对齐(参考 010_【本部長報告会】):
|
||||
|
||||
| 区域 | 模板规格 | layout 配置 |
|
||||
|---|---|---|
|
||||
| Header 章节行 | y0.32 (20pt 含编号) | header_top=0.32 |
|
||||
| Header 主题行 | y0.88 (16pt) | subtitle_top=0.88 |
|
||||
| Body 起始 | y1.74 | body_top=1.60 |
|
||||
| Body 结束 | y6.4 页脚条上方 | body_bottom=6.30 |
|
||||
| Footer | y6.4~7.1 | footer_top=7.05 |
|
||||
| 页码 | y7.16 右下角 | page_num_y=7.16 |
|
||||
|
||||
report-jp 的 header 采用模板风格:**章节行20pt在上 + 主题行16pt在下**(与
|
||||
"竖条+大标题"的通用风格不同),通过 `_header_jp()` 专用方法渲染。
|
||||
|
||||
### Step 4:验证输出(必须执行)
|
||||
|
||||
```bash
|
||||
python scripts/engine.py --verify output.pptx [--plan plan.json]
|
||||
```
|
||||
|
||||
验证项:
|
||||
1. ✅ 页数符合预期
|
||||
2. ✅ 所有文本无溢出(超出页面边界检测)
|
||||
3. ✅ **内容保真**:用户提供的数据/术语与源输入逐项比对(有差异即报错)
|
||||
4. ✅ 图表数据点完整
|
||||
|
||||
---
|
||||
|
||||
## 内容保真规则(最高优先级,不可违反)
|
||||
|
||||
> 灵感来源:7牛AI PPT 的"保持原文"模式。**AI只负责结构、排版、设计,绝不改写用户的数据。**
|
||||
|
||||
1. **数字原样保留**:用户提供的任何数值(`96.8%`、`¥5,000万`、`2024年3月`)在最终PPT中必须逐字符一致
|
||||
2. **术语原样保留**:专有名词、产品名、客户名、日文术语(`テスト仕様書`、`要件定義`)不得意译、不得替换
|
||||
3. **禁止四舍五入**:`23.6%` 不得写成 `约24%`;`3.14` 不得写成 `3`
|
||||
4. **引用原样保留**:用户提供的引用、条款、数据来源标注,一字不改
|
||||
5. **结构可重组,内容不可改写**:AI 可以调整内容的组织方式(分页、分组、层级),但不得修改内容本身的含义和数据
|
||||
6. **LLM生成的新内容**(用户未提供、由AI补充的过渡句/总结)必须与用户内容区分:可用 `[AI补充]` 标注或在演讲者备注中说明
|
||||
|
||||
**验证**:渲染后必须运行 `--verify`,自动比对源数据与输出文本。
|
||||
|
||||
---
|
||||
|
||||
## 中文排版规范(中文场景必读)
|
||||
|
||||
> 灵感来源:WPS AI 的中文理解。遵循 references/chinese-typography.md 的完整规则。
|
||||
|
||||
核心规则速览:
|
||||
1. **中英混排加空格**:`使用 Python 开发`(中文与英文/数字之间插入半角空格)
|
||||
2. **标点规范**:中文语境用全角标点(,。:;);引号用「」或“”
|
||||
3. **日文文本**:日文与中文之间不加空格;日文假名用全角;保留日文原字符(テスト、仕様書)
|
||||
4. **数字**:千位分隔(1,000,000);百分比紧跟数字(96.8%)
|
||||
5. **字体**:中文微软雅黑、日文Yu Gothic/游ゴシック、英文Segoe UI;字体回退链见 references
|
||||
6. **标题层级**:封面标题40pt+ → 章节标题32pt → 页标题28pt → 正文16-18pt → 注释12pt
|
||||
7. **行长**:每行不超过28个中文字符,长文自动换行
|
||||
8. **段距**:正文行距1.3-1.5,段间距8-12pt
|
||||
|
||||
---
|
||||
|
||||
## 设计系统
|
||||
|
||||
内置 3 套设计系统(见 references/design-system.md):
|
||||
|
||||
| 系统名 | 风格 | 适用 |
|
||||
|---|---|---|
|
||||
| `aura-dark`(默认) | 深色高级(深蓝黑 + 电光青) | 科技感、对外提案、产品发布 |
|
||||
| `business-report` | **浅色商务(白底 + 火焰蓝 + 商务红)** | 商务报告、管理层汇报、咨询风格 |
|
||||
| `report-jp` | **日式商务报告(法人蓝 + 浅天蓝卡片 + Meiryo UI)** | **日本客户汇报、本部報告会、对日业务** |
|
||||
|
||||
设计系统选型建议:
|
||||
- 用户提到"日本/日方/報告会/本部/日企" → 用 `report-jp`(10.83×7.5in 非标准比例,Meiryo UI 字体,章节编号页眉,支持「機密情報」机密标记)
|
||||
- 用户提到"商务报告/汇报风格/正式/咨询/打印" → 用 `business-report`
|
||||
- 用户提到"科技感/高级/对外/发布会" → 用 `aura-dark`
|
||||
|
||||
选择方式:plan.json 的 `design` 字段;CLI 用 `--design`。
|
||||
|
||||
### 通用 plan.json 顶层字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `design` | 设计系统名(aura-dark / business-report / report-jp) |
|
||||
| `company` | 页脚公司名 |
|
||||
| `date` | 页脚日期 |
|
||||
| `notes_template` | true 时自动为无备注页面生成结构化演讲备注 |
|
||||
| `confidential` | 机密标记文本(如「機密情報」「社外秘」),显示在每页右上角红色标签 |
|
||||
|
||||
### 页面类型(每种支持 section_no / confidential 字段)
|
||||
|
||||
cover / toc / section / content / two_column / table / chart / timeline / **flowchart** / quote / end
|
||||
|
||||
**flowchart 流程图**(参考日企报告会资料):
|
||||
- 横向流程:`content: ["阶段A|描述", "阶段B|描述", ...]`
|
||||
- 泳道模式:`rows: ["泳道1", "泳道2"], content: [["步骤A","步骤B"], ["步骤C"]]`
|
||||
- 浅天蓝卡片 + 法人蓝标题 + 箭头连接,日企报告标准样式
|
||||
|
||||
### 高级能力(引擎内置,plan 字段触发)
|
||||
|
||||
**cover 封面大数字**(`stats` 字段):
|
||||
```json
|
||||
"stats": [["362件", "概要設計案を作成"], ["5案件", "に横断参画"]]
|
||||
```
|
||||
渲染为右侧竖排大数字卡片墙(彩色顶条+大数字+说明),标题区自动压缩。
|
||||
|
||||
**content 要点卡片**(自动识别):
|
||||
- `content` 中 `**标题** | 描述` 格式的行 → 自动渲染为**卡片网格**(2列,左色条+标题+描述+投影)
|
||||
- 纯文本行 → 普通文本布局;混合 → 卡片区+底部说明
|
||||
- 卡片≥3行时字号自动压缩防溢出
|
||||
|
||||
**table 混合页**(`charts` 字段):
|
||||
- 表格下方渲染一个或多个图表(`chart` 单图 / `charts` 多图并排)
|
||||
|
||||
**chart 多图**(`charts` 字段):
|
||||
```json
|
||||
"charts": [{"type": "bar", "title": "图1", "data": {...}}, {"type": "bar", "title": "图2", "data": {...}}]
|
||||
```
|
||||
- 多图并排,每个带独立子标题
|
||||
- 柱状图:**渐变柱体**(顶部提亮45%→底部原色)+ 水平网格线 + 数值/类别标签
|
||||
|
||||
**全局视觉能力**:
|
||||
- 卡片柔和投影(`add_shape_shadow`):所有卡片容器自动带投影,立体感
|
||||
- 协调渐变色板:每个设计系统 6 色光谱序列(冷→暖过渡)
|
||||
- 圆角卡片(radius 8%)
|
||||
- 页眉装饰线(report-jp:主色细线+左端锚点块)
|
||||
- 章节页装饰(竖条+横线)
|
||||
- 内容区背景层(report-jp 模板模式:body 区极浅色分区)
|
||||
|
||||
### 日式页眉(report-jp 特色)
|
||||
|
||||
plan 页面支持 `section_no` 字段(如 "1"),渲染为日式页眉编号:
|
||||
```
|
||||
1. 【トピックス】AI能力構築の全体像のご報告(1/2)
|
||||
```
|
||||
配合 `confidential` 字段显示右上角「機密情報」红色标记。
|
||||
|
||||
组件库(三种系统共用):
|
||||
- 封面/目录/章节/内容页/双栏/表格/图表/时间线/流程图/引言/结束页
|
||||
- 统一网格:页边距 0.5in,垂直节奏 0.2in 网格
|
||||
- 圆角卡片(radius 8%)+ 柔和投影
|
||||
- 图表渐变柱体 + 协调色板
|
||||
|
||||
如需其他风格,在 engine.py 的 DESIGN_SYSTEMS 注册表中扩展(继承 DesignSystem 覆写令牌)。
|
||||
|
||||
---
|
||||
|
||||
## 数据图表页(模式D)
|
||||
|
||||
```bash
|
||||
# 方式1:CSV/Excel 数据源
|
||||
python scripts/engine.py --plan plan.json --output out.pptx
|
||||
# plan.json 中 slide.chart = {"type": "bar", "source": "data.csv", "title": "季度营收"}
|
||||
|
||||
# 方式2:内联数据
|
||||
# slide.chart = {"type": "pie", "data": {"A": 30, "B": 45, "C": 25}}
|
||||
```
|
||||
|
||||
支持的图表类型:bar(柱状)、line(折线)、pie(饼图)、donut(环形)、stacked_bar(堆叠柱)。
|
||||
|
||||
图表规范:
|
||||
- 数据标签必须显示实际数值(不省略)
|
||||
- 轴标签用中文
|
||||
- 图例位置统一右下角
|
||||
- 色板取自设计系统的 6 色序列
|
||||
|
||||
---
|
||||
|
||||
## 已有PPT美化(模式C)
|
||||
|
||||
```bash
|
||||
python scripts/engine.py --improve input.pptx --output output.pptx [--design aura-dark]
|
||||
```
|
||||
|
||||
美化规则:
|
||||
- 统一背景色与主题色
|
||||
- 统一标题字体/字号/颜色
|
||||
- 统一正文排版(字体、行距、段落间距)
|
||||
- 添加页脚/页码(可选)
|
||||
- **内容零改动**:只动样式,不动文字内容
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Q1:内容保真和AI生成冲突怎么办?**
|
||||
不冲突。规则是:**用户提供的内容一个字不改;AI补充的新内容明确标注**。汇报材料中,数据准确 > 文字华丽。
|
||||
|
||||
**Q2:支持日文PPT吗?**
|
||||
支持。日文字体(Yu Gothic)已纳入字体回退链;日文内容原样渲染。中日混合时遵循"日文与中文间不加空格"规则。
|
||||
|
||||
**Q3:输出文件能在WPS/PowerPoint里编辑吗?**
|
||||
能。python-pptx 输出标准 PPTX,所有元素都是原生矢量形状,可完全二次编辑——这正是"导出无损"的实现方式。
|
||||
|
||||
**Q4:需要网络/API吗?**
|
||||
不需要。渲染引擎是纯本地Python(python-pptx)。LLM负责规划,引擎负责渲染。客户机密材料可以放心处理。
|
||||
|
||||
**Q5:为什么不用AI生图做背景?**
|
||||
本技能采用"矢量形状 + 设计系统"路线:文字清晰、可编辑、无版权风险、渲染确定性。需要AI插画背景时,可用 image-generation 技能单独生成图片后插入。
|
||||
Binary file not shown.
@@ -0,0 +1,84 @@
|
||||
# aura-ppt 中文排版规范(Chinese Typography Rules)
|
||||
|
||||
> 灵感来源:WPS AI 中文理解(9.5分)的排版能力。目标是让生成的PPT在中文场景下"看起来就是中国人做的"。
|
||||
|
||||
## 1. 中英混排规则
|
||||
|
||||
| 场景 | 规则 | 正确 | 错误 |
|
||||
|---|---|---|---|
|
||||
| 中文与英文之间 | 加半角空格 | 使用 Python 开发 | 使用Python开发 |
|
||||
| 中文与数字之间 | 加半角空格 | 共 25 个项目 | 共25个项目 |
|
||||
| 中文与括号之间 | 不加空格 | 系统(SaaS 模式) | 系统 (SaaS 模式) |
|
||||
| 全角标点后 | 不加空格 | 项目,包括 | 项目, 包括 |
|
||||
|
||||
## 2. 标点规范
|
||||
|
||||
- 中文语境:全角标点 `,。:;!?()「」`
|
||||
- 引用书名:`《》`;强调引用:`「」` 或 `“”`
|
||||
- 英文/数字语境:半角标点 `,.:;!?()`
|
||||
- 列表序号:`1. 2. 3.` 后跟半角空格
|
||||
- **禁止**:中文文本中出现半角逗号、句号(`,` 不能写成 `,`)
|
||||
|
||||
## 3. 日文文本规则(对日业务)
|
||||
|
||||
- 日文与中文之间**不加空格**(区别于中英混排)
|
||||
- 日文假名(ひらがな/カタカナ)保持全角原样
|
||||
- 日文术语原样保留:`要件定義書`、`テスト仕様書`、`結合テスト`、`納品物`
|
||||
- 日文引号用「」;中文引号用「」或“”(两者视觉接近,混排时可统一)
|
||||
- 中日双语并列时建议:中文为主行,日文为副行(括号内或下方小字)
|
||||
|
||||
## 4. 数字与单位
|
||||
|
||||
- 千位分隔:`1,000,000`(四个零以上必须分隔)
|
||||
- 百分比:紧贴数字 `96.8%`(**不得四舍五入**,见内容保真规则)
|
||||
- 金额:中文用 `万元/亿元`,日文用 `万円/億円`
|
||||
- 日期:中文 `2024年3月15日`;日文 `2024年3月15日`(可互用)
|
||||
- 版本号:`v1.2.0` 保持原样
|
||||
|
||||
## 5. 字体回退链
|
||||
|
||||
```
|
||||
中文:微软雅黑 (Microsoft YaHei) → 思源黑体 → 系统默认
|
||||
日文:Yu Gothic (游ゴシック) → メイリオ (Meiryo) → 微软雅黑
|
||||
英文:Segoe UI → Arial → Helvetica
|
||||
```
|
||||
|
||||
**字体选择逻辑**:
|
||||
- 页面文本含中文字符 → 用中文字体(日文字体也能显示中文,但笔画风格不同)
|
||||
- 页面文本仅日文 → 用日文字体(Yu Gothic),注意日本汉字与中文字形差异(如 骨/骨、直/直)
|
||||
- 页面文本纯英文/数字 → 用英文字体
|
||||
- 同一文本框内中日混合 → 统一用中文字体(微软雅黑对日文假名支持良好)
|
||||
|
||||
## 6. 字号层级
|
||||
|
||||
| 层级 | 字号 | 字重 | 用途 |
|
||||
|---|---|---|---|
|
||||
| 封面主标题 | 40-44pt | Bold | 仅封面 |
|
||||
| 章节页标题 | 32-36pt | Bold | section 页 |
|
||||
| 页面标题 | 24-28pt | Bold | header |
|
||||
| 小标题 | 18-20pt | SemiBold | 卡片标题 |
|
||||
| 正文 | 15-17pt | Regular | 主体内容 |
|
||||
| 辅助文字 | 12-13pt | Regular | 图例、注释 |
|
||||
| 脚注 | 10-11pt | Regular | 备注、来源 |
|
||||
|
||||
## 7. 排版规则
|
||||
|
||||
- **行长限制**:每行不超过 28 个中文字符(约 14.5cm 宽度 @16pt)
|
||||
- **行距**:正文 1.3-1.5 倍;标题 1.1-1.2 倍
|
||||
- **段距**:段间 8-12pt(比行距略大,形成视觉分组)
|
||||
- **对齐**:中文正文用左对齐(禁两端对齐导致字距拉伸);标题左对齐
|
||||
- **避头尾**:行首禁标点(。,、;:)——用换行处理
|
||||
- **孤行控制**:禁止标题出现在页面最后一行
|
||||
|
||||
## 8. 中日双语排版示例
|
||||
|
||||
```
|
||||
正确示例(中文主行 + 日文副行):
|
||||
テスト工程の品質向上
|
||||
测试工程品质提升
|
||||
(テスト仕様書 23 件 → 欠陥検出率 96.8%)
|
||||
|
||||
错误示例:
|
||||
混合排版且无逻辑分组,中英日混杂在一个段落中,
|
||||
读者难以快速定位关键信息
|
||||
```
|
||||
@@ -0,0 +1,115 @@
|
||||
# aura-ppt 内容保真规则(Content Fidelity Rules)
|
||||
|
||||
> 灵感来源:7牛AI PPT"保持原文"模式(内容保真实测最优)。
|
||||
> **最高原则:AI只负责结构、排版、设计,绝不改写用户的数据与术语。**
|
||||
|
||||
## 为什么内容保真如此重要
|
||||
|
||||
对日外包场景的汇报材料通常包含:
|
||||
- 客户合同的精确数字(工数、单价、納期)
|
||||
- 测试结果数据(欠陥検出率、テストケース数)
|
||||
- 日本客户的专业术语(仕様書、結合テスト、納品物)
|
||||
- 法律/合规条款原文
|
||||
|
||||
**任何一处数字被"润色",都可能造成客户信任危机或合规问题。** 这正是我们把内容保真设为最高优先级的原因。
|
||||
|
||||
## 保真规则(按优先级排序)
|
||||
|
||||
### R1. 数字逐字符保留
|
||||
|
||||
用户提供的任何数值在最终PPT中必须**逐字符一致**:
|
||||
|
||||
```
|
||||
输入: 96.8% 输出: 96.8% ❌ 禁止: 约97% / 近97% / 96.8%左右
|
||||
输入: 23.6% 输出: 23.6% ❌ 禁止: 24% / 23% / 约24%
|
||||
输入: ¥5,000万 输出: ¥5,000万 ❌ 禁止: 5000万 / ¥5000万 / 数千万
|
||||
输入: 3.14 输出: 3.14 ❌ 禁止: 3 / 3.1 / π
|
||||
输入: 2024年3月 输出: 2024年3月 ❌ 禁止: 2024年Q1 / 2024年 / 去年3月
|
||||
```
|
||||
|
||||
### R2. 术语原样保留
|
||||
|
||||
专有名词、产品名、客户名、日文术语**不得意译、不得替换、不得加释义**:
|
||||
|
||||
```
|
||||
输入: テスト仕様書 输出: テスト仕様書 ❌ 禁止: 测试规格书 / 测试规格说明书
|
||||
输入: 要件定義 输出: 要件定義 ❌ 禁止: 需求定义(除非用户明确要求双语)
|
||||
输入: [顧客A] 输出: [顧客A] ❌ 禁止: 客户A / 甲方 / 某客户
|
||||
输入: SAP ERP 输出: SAP ERP ❌ 禁止: SAP / 企业资源计划系统
|
||||
```
|
||||
|
||||
### R3. 引用与条款原文保留
|
||||
|
||||
用户提供的引用、条款、数据来源标注,**一字不改**,包括标点:
|
||||
|
||||
```
|
||||
输入: 「納品物とは、要件定義書、設計書、テスト仕様書及びプログラム一式をいう。」
|
||||
输出: 必须逐字呈现(含「」与日文标点)
|
||||
```
|
||||
|
||||
### R4. 结构可重组,内容不可改写
|
||||
|
||||
允许:
|
||||
- ✅ 重新分页、分组、调整层级(把5个要点分到2页)
|
||||
- ✅ 提炼副标题、加导航性文字("本章节将说明…")
|
||||
- ✅ 调整顺序(按逻辑重排用户内容)
|
||||
|
||||
禁止:
|
||||
- ❌ 改写用户句子含义(保持原意是底线)
|
||||
- ❌ 合并/拆分用户数据点
|
||||
- ❌ 为"更简洁"删除用户的限定词(如"约"、"可能"、"部分")
|
||||
- ❌ 补充用户未提供且无依据的数据
|
||||
|
||||
### R5. AI补充内容必须区分
|
||||
|
||||
LLM为连接上下文而生成的新内容(过渡句、总结、建议),必须与用户内容可区分:
|
||||
- 方式1:加 `[AI补充]` 前缀(用户可见时)
|
||||
- 方式2:放入演讲者备注(notes字段),正文只放用户内容
|
||||
- 方式3:内容基调明显不同(如"本章总结"这类导航句)
|
||||
|
||||
**推荐方式2**:正文保真,AI的加工藏在备注里。
|
||||
|
||||
## 实施机制
|
||||
|
||||
### 渲染引擎内置检查(--verify)
|
||||
|
||||
```
|
||||
验证流程:
|
||||
1. 从 plan.json 提取所有源数字(正则: -?\d+[\d,]*\.?\d*\s*[%%]?)
|
||||
2. 从输出PPTX提取全部文本
|
||||
3. 逐一比对:每个源数字必须出现在输出文本中
|
||||
4. 术语检查:含中日文字符的长片段(≥6字符)必须原样出现
|
||||
5. 任何缺失 → 验证失败 → 返回缺失清单
|
||||
|
||||
运行:
|
||||
python scripts/engine.py --verify output.pptx --plan plan.json
|
||||
```
|
||||
|
||||
### 流程保障
|
||||
|
||||
1. **plan.json 是唯一内容源**:渲染引擎只读 plan.json,不产生任何内容
|
||||
2. **LLM只负责规划**:LLM把用户内容结构化进 plan.json,不改写内容本身
|
||||
3. **渲染零改写**:engine.py 是纯渲染器,代码中不存在任何文本改写逻辑
|
||||
4. **生成后必验证**:每次生成自动跑 --verify,偏差立即报告
|
||||
|
||||
## 常见违规与正确做法
|
||||
|
||||
| 场景 | 错误(违规) | 正确 |
|
||||
|---|---|---|
|
||||
| 数字美化 | 32个数据点"96.8%"→"约97%" | 96.8% 原样 |
|
||||
| 术语翻译 | 要件定義→需求定义 | 要件定義(除非用户要求双语) |
|
||||
| 删除限定 | "部分系统不支持"→"系统不支持" | 保留"部分" |
|
||||
| 补数据 | 用户没说→AI加"市占率30%" | 不添加无依据数据 |
|
||||
| 简化条款 | 合并两条合同条款 | 逐字呈现 |
|
||||
|
||||
## 与"AI辅助"的边界
|
||||
|
||||
内容保真 ≠ 拒绝一切AI加工。AI可以:
|
||||
- ✅ 优化组织(分页、分组、加导航)
|
||||
- ✅ 补充演讲者备注(notes)
|
||||
- ✅ 生成目录、章节标题、过渡句(明确为AI补充)
|
||||
- ✅ 设计排版(字色、字号、图表类型)
|
||||
|
||||
AI不可以:
|
||||
- ❌ 改用户的数据、术语、引用、限定词
|
||||
- ❌ 编造用户未提供的信息
|
||||
@@ -0,0 +1,296 @@
|
||||
# aura-ppt 设计系统规范
|
||||
|
||||
> 灵感来源:Gamma 设计质量(9.2分)。内置多套深度定制的设计系统。
|
||||
|
||||
## 设计系统清单
|
||||
|
||||
| 系统名 | 风格 | 适用场景 |
|
||||
|---|---|---|
|
||||
| `aura-dark`(默认) | 深色高级(深蓝黑+电光青) | 科技感、对外提案、产品发布 |
|
||||
| `business-report` | 浅色商务(白底+火焰蓝+商务红) | **商务报告、管理层汇报、咨询风格** |
|
||||
| `report-jp` | 日式商务报告(法人蓝+浅天蓝卡片+Meiryo UI) | **日本客户汇报、本部報告会、对日业务** |
|
||||
|
||||
---
|
||||
|
||||
## 0. 全局视觉规范(所有设计系统共用)
|
||||
|
||||
### 卡片视觉
|
||||
- **柔和投影**:所有卡片容器自动带外投影(blur 90000 / dist 45000 / 25%透明度深灰),立体悬浮感
|
||||
- **圆角**:radius 8%(圆角矩形 adjustment)
|
||||
- 卡片左色条(随图表色板序号变色)
|
||||
|
||||
### 图表视觉
|
||||
- **渐变柱体**:柱状图垂直渐变(顶部提亮45% → 底部原色),模拟立体感(XML gradFill)
|
||||
- **协调色板**:每系统6色光谱序列(冷→暖过渡),相邻柱区分度清晰
|
||||
- 水平网格线(3条辅助)+ 数值标签 + 类别标签(防截断)
|
||||
|
||||
### 排版层级(typography)
|
||||
统一语义化字号,修改只需改设计系统 `typography` 字典:
|
||||
|
||||
| 语义 | 字号 | 用途 |
|
||||
|---|---|---|
|
||||
| cover_title | 38 | 封面主标题 |
|
||||
| cover_subtitle | 18 | 封面副标题 |
|
||||
| section_title | 34 | 章节页标题 |
|
||||
| page_title | 22 | 页面标题 |
|
||||
| card_title | 16 | 卡片标题 |
|
||||
| body | 15 | 正文 |
|
||||
| body_small | 13 | 双栏/卡片内文字 |
|
||||
| table_head / table_cell | 13 / 12 | 表格 |
|
||||
| chart_label / chart_cat | 12 / 9 | 图表标签 |
|
||||
| footer / notes | 9 / 10 | 页脚/辅助 |
|
||||
|
||||
### 页面结构装饰
|
||||
- report-jp 页眉装饰线(主色细线 + 左端1.6in锚点块)
|
||||
- 章节页装饰(左侧竖条 + 底部横线)
|
||||
- 内容区背景层(模板模式:body 区极浅色分区,与母版背景区分)
|
||||
|
||||
---
|
||||
|
||||
## 3. report-jp 设计系统(日式商务报告)
|
||||
|
||||
> 参考:日本本部报告会资料(010_【本部長報告会】2026年7月度_AI推進部.pptx)。
|
||||
> 法人蓝 #0B3D91 + 浅天蓝卡片 #B7DEE8 + Meiryo UI 字体,日企报告标准样式。
|
||||
|
||||
### 设计令牌
|
||||
|
||||
| 令牌 | 色值 | 用途 |
|
||||
|---|---|---|
|
||||
| `page` | **10.83 × 7.5 in**(非标准比例) | 页面画布 |
|
||||
| `bg` | `#FFFFFF` | 页面背景(纯白) |
|
||||
| `bg2` | `#F5F7FB` | 交替行/次级背景 |
|
||||
| `panel` | `#E8EAF6` | 面板(浅蓝灰) |
|
||||
| `panel2` | `#DDE3F2` | 面板暗 |
|
||||
| `card_fill` | `#B7DEE8` | **卡片填充(浅天蓝)** |
|
||||
| `line` | `#C9D4E8` | 分隔线 |
|
||||
| `text` | `#1A1A1A` | 正文(黑色,日企惯例) |
|
||||
| `text_dim` | `#556070` | 辅助文本 |
|
||||
| `primary` | `#0B3D91` | 主色:**法人蓝** |
|
||||
| `secondary` | `#1976D2` | 次级:Material蓝 |
|
||||
| `accent` | `#C00000` | 强调红(警示/机密) |
|
||||
| `success` | `#2E7D32` | 正向(绿) |
|
||||
| `danger` | `#C00000` | 警示(红) |
|
||||
|
||||
### 图表色板(协调渐变:蓝→青→绿→金→橙)
|
||||
|
||||
```
|
||||
#0B3D91 深法人蓝 → #2E74B5 中蓝 → #0099CC 青蓝 → #26A69A 青绿 → #F2B134 琥珀金 → #EF6C00 橙
|
||||
```
|
||||
|
||||
**渐变柱体**:柱状图使用垂直渐变(顶部提亮45% → 底部原色),模拟立体感。
|
||||
|
||||
### 字体
|
||||
|
||||
```
|
||||
日文:Meiryo UI(日企标准,微软雅黑日文版)
|
||||
中文:微软雅黑 | 英文:Segoe UI
|
||||
```
|
||||
|
||||
### 特色组件
|
||||
|
||||
1. **日式页眉**:页面 plan 支持 `section_no`(如 "1"),渲染为 `1. 标题(x/y)` 编号格式
|
||||
2. **机密标记**:plan 顶层或页面支持 `confidential`(如「機密情報」「社外秘」),右上角红色标签
|
||||
3. **流程图**:`flowchart` 页面类型,浅天蓝卡片 + 法人蓝标题 + 箭头(支持横向流程与泳道两种模式)
|
||||
4. **卡片式布局**:参考PPT的浅天蓝卡片(#B7DEE8)作为内容容器
|
||||
|
||||
### 风格要点
|
||||
- 法人蓝(#0B3D91)是日本企业标准的"企业蓝",庄重、权威
|
||||
- 正文用黑色(日企报告惯例,不同于中式报告的深灰)
|
||||
- 卡片用浅天蓝而非浅灰——日式报告特有的清爽感
|
||||
- 标题字号相对 16:9 版本略小(22pt),因为页面更窄(10.83in)
|
||||
- 机密标记红色标签是日企报告标配(機密情報/社外秘/取扱注意)
|
||||
|
||||
---
|
||||
|
||||
## 1. business-report 设计系统(商务报告风格 · 火焰蓝)
|
||||
|
||||
> 灵感来源:麦肯锡/BCG 咨询报告、PowerPoint 商务模板。浅色底、克制的信息层次、清晰可打印。
|
||||
> 主色为**火焰蓝**(消防救援标志蓝 `#1E50A2`)——庄重、专业、辨识度高。
|
||||
|
||||
### 设计令牌
|
||||
|
||||
| 令牌 | 色值 | 用途 |
|
||||
|---|---|---|
|
||||
| `bg` | `#FFFFFF` | 页面背景(纯白) |
|
||||
| `bg2` | `#F5F6FA` | 交替行/次级背景 |
|
||||
| `panel` | `#F4F6FA` | 卡片/面板背景(浅灰) |
|
||||
| `panel2` | `#ECEFF5` | 面板暗色变体 |
|
||||
| `line` | `#D8DEE9` | 分隔线/边框(浅灰) |
|
||||
| `text` | `#1F2937` | 主文本(深灰) |
|
||||
| `text_dim` | `#6B7280` | 辅助文本(中灰) |
|
||||
| `primary` | `#1E50A2` | 主色:**火焰蓝**(标题/强调) |
|
||||
| `accent` | `#C00000` | 强调色:商务红(关键数字/警示) |
|
||||
| `secondary` | `#3B78C4` | 次级:亮蓝 |
|
||||
| `success` | `#228B22` | 正向(墨绿) |
|
||||
| `danger` | `#C00000` | 警示(红) |
|
||||
|
||||
### 图表色板(火焰蓝系列)
|
||||
|
||||
```
|
||||
#1E50A2 火焰蓝 → #3B78C4 亮蓝 → #C00000 红 → #ED7D31 橙 → #70AD47 绿 → #7030A0 紫
|
||||
```
|
||||
|
||||
### 风格要点
|
||||
- **白色底 + 火焰蓝标题**:商务报告经典组合,庄重专业,打印友好
|
||||
- **商务红点睛**:与火焰蓝形成经典"蓝+红"组合,只用于关键数字、强调语句、风险标识
|
||||
- **浅灰卡片**:F4F6FA 面板 + D8DEE9 细边框,层次靠灰阶而非色块
|
||||
- **克制的装饰**:无光晕、无渐变背景,纯网格对齐——信息优先
|
||||
- **表格**:表头火焰蓝底白字(primary),交替行 bg2/panel
|
||||
- **pill 文字**:浅色系统下 pill 上文字用白色(深色系统用底色)
|
||||
|
||||
---
|
||||
|
||||
## 2. aura-dark 设计系统(深色高级)
|
||||
|
||||
### 设计令牌(Design Tokens)
|
||||
|
||||
| 令牌 | 色值 | 用途 |
|
||||
|---|---|---|
|
||||
| `bg` | `#0B0E14` | 页面背景(深蓝黑) |
|
||||
| `bg2` | `#11151F` | 交替行/次级背景 |
|
||||
| `panel` | `#171D2B` | 卡片/面板背景 |
|
||||
| `panel2` | `#121722` | 卡片暗色变体 |
|
||||
| `line` | `#273042` | 分隔线/边框 |
|
||||
| `text` | `#F0F2F5` | 主文本(近白) |
|
||||
| `text_dim` | `#8A93A3` | 辅助文本(灰蓝) |
|
||||
| `primary` | `#2ECCFF` | 主色:电光青(强调/标题/链接) |
|
||||
| `accent` | `#FFC94D` | 强调色:琥珀金(里程碑/高亮) |
|
||||
| `secondary` | `#A78BFA` | 辅助色:霓虹紫(次强调) |
|
||||
| `success` | `#3DD68C` | 成功/正向(绿色) |
|
||||
| `danger` | `#FF6B6B` | 警示/风险(红色) |
|
||||
|
||||
### 图表色板(6色序列)
|
||||
|
||||
```
|
||||
#2ECCFF → #FFC94D → #A78BFA → #3DD68C → #FF8A65 → #5C9DFF
|
||||
```
|
||||
|
||||
### 度量
|
||||
|
||||
| 令牌 | 值 | 说明 |
|
||||
|---|---|---|
|
||||
| `margin` | 0.55 in | 页面四边距 |
|
||||
| `grid` | 0.2 in | 垂直节奏网格 |
|
||||
| `radius` | 6% | 圆角卡片弧度 |
|
||||
| `content_w` | 12.23 in | 内容区宽度(16:9) |
|
||||
| `page` | 13.333 × 7.5 in | 16:9 画布 |
|
||||
|
||||
### 字体
|
||||
|
||||
```
|
||||
标题: 微软雅黑 Bold(日文场景: Yu Gothic Bold)
|
||||
正文: 微软雅黑 Regular(日文场景: Yu Gothic)
|
||||
英文/数字: Segoe UI
|
||||
```
|
||||
|
||||
## 2. 组件库
|
||||
|
||||
### 2.1 封面(cover)
|
||||
|
||||
```
|
||||
[左上光晕装饰] [kicker 标签: 14pt 主色]
|
||||
[主标题: 42pt Bold 近白]
|
||||
[副标题: 18pt 灰蓝]
|
||||
[内容要点: 15pt 灰蓝, 行距1.5]
|
||||
[主色横线: 2.8in × 2.5pt]
|
||||
[底部深色条: 0.6in 高] [备注/日期: 11pt]
|
||||
```
|
||||
|
||||
### 2.2 目录(toc)
|
||||
|
||||
```
|
||||
[页头: 左侧主色竖条 + 标题 + 分隔线]
|
||||
[内容区: 2列网格, 卡片 5.9×1.35in]
|
||||
每卡片: [编号 28pt 主色] + [条目 16pt Bold]
|
||||
```
|
||||
|
||||
### 2.3 章节页(section)
|
||||
|
||||
```
|
||||
[kicker: "SECTION 01" 14pt 主色]
|
||||
[标题: 36pt Bold]
|
||||
[内容: 16pt 灰蓝]
|
||||
留白为主,营造节奏感(章节间呼吸)
|
||||
```
|
||||
|
||||
### 2.4 内容页(content)
|
||||
|
||||
```
|
||||
[页头: 标题 26pt Bold + 可选副标题 + 分隔线]
|
||||
[正文: 16pt, 行距1.4, 段距8pt]
|
||||
[要点: 每个要点一行, 支持 **加粗** 强调]
|
||||
[页脚: 页码 10pt 右下角]
|
||||
```
|
||||
|
||||
### 2.5 双栏页(two_column)
|
||||
|
||||
```
|
||||
[两卡片 5.9in 宽, 间隔0.4in]
|
||||
每卡片: [栏目标题 18pt Bold 主色] + [内容 14pt 行距1.4]
|
||||
```
|
||||
|
||||
### 2.6 表格页(table)
|
||||
|
||||
```
|
||||
[表头: 深色背景 + 主色文字 13pt Bold, 居中]
|
||||
[交替行: bg2/panel]
|
||||
[单元格: 12pt, 垂直居中]
|
||||
[行高: 0.62in, 表头高0.55in]
|
||||
```
|
||||
|
||||
### 2.7 图表页(chart)
|
||||
|
||||
```
|
||||
[图表卡片: panel背景, 圆角]
|
||||
[图例: 右上角横排, 色块+标签 11pt]
|
||||
[数据标签: 11pt Bold 显示实际值(不省略)]
|
||||
[柱状图: 圆角柱, 2倍柱宽间距]
|
||||
[折线图: 主色3pt线 + 数据点圆点]
|
||||
[饼图: 扇区 + 数值标签]
|
||||
```
|
||||
|
||||
### 2.8 时间线(timeline)
|
||||
|
||||
```
|
||||
[水平主线: 2pt 灰蓝]
|
||||
[节点: 主色圆点 0.16in]
|
||||
[阶段标题: 13pt Bold 主色]
|
||||
[描述: 10pt 灰蓝, 最多2行]
|
||||
```
|
||||
|
||||
### 2.9 引言页(quote)
|
||||
|
||||
```
|
||||
[左侧主色竖条 0.09×2.2in]
|
||||
[引言: 26pt Bold, 行距1.4]
|
||||
[出处: 13pt Bold 琥珀金]
|
||||
```
|
||||
|
||||
### 2.10 结束页(end)
|
||||
|
||||
```
|
||||
[标题: 40pt Bold "谢谢" / "Thank you"]
|
||||
[内容: 16pt 灰蓝]
|
||||
[主色横线: 2.8in × 2.5pt]
|
||||
```
|
||||
|
||||
## 3. 布局原则
|
||||
|
||||
1. **留白优先**:每页元素不超过 6 个视觉组,大量留白营造高级感
|
||||
2. **对齐网格**:所有元素对齐 0.2in 网格,杜绝随意摆放
|
||||
3. **主色克制**:主色只用于强调(标题条、关键数字、链接),大面积用中性色
|
||||
4. **视觉节奏**:章节页制造呼吸感,内容页信息密度适中
|
||||
5. **统一性**:同类型页面必须使用相同组件与布局(可预测性)
|
||||
|
||||
## 4. 对比度与可读性
|
||||
|
||||
- 正文对比度:`#F0F2F5` on `#0B0E14`(≈15.8:1,远超 WCAG AA)
|
||||
- 主色文本 on 深底:`#2ECCFF` on `#0B0E14`(≈9.1:1)
|
||||
- 辅助文本 on 深底:`#8A93A3` on `#0B0E14`(≈4.6:1,满足 AA)
|
||||
- 琥珀金 on 深底:`#FFC94D` on `#0B0E14`(≈11.5:1)
|
||||
- **禁止**:主色/琥珀金作为大面积背景(只用于点缀与强调)
|
||||
|
||||
## 5. 扩展设计系统
|
||||
|
||||
新增设计系统时,在 `engine.py` 的 `DesignSystem` 类中创建同名子类并重写令牌。
|
||||
plan.json 的 `design` 字段引用系统名(默认 `aura-dark`)。
|
||||
@@ -0,0 +1,1870 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
aura-ppt 渲染引擎
|
||||
综合 AI PPT 工具优点:Gamma 设计感 / WPS 中文排版 / 7牛 内容保真 / Beautiful.ai 无损导出
|
||||
用法:
|
||||
python engine.py --plan plan.json --output out.pptx [--design aura-dark]
|
||||
python engine.py --improve input.pptx --output out.pptx [--design aura-dark]
|
||||
python engine.py --verify output.pptx [--plan plan.json]
|
||||
"""
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from pptx import Presentation
|
||||
from pptx.util import Inches, Pt, Emu
|
||||
from pptx.dml.color import RGBColor
|
||||
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
|
||||
from pptx.enum.shapes import MSO_SHAPE
|
||||
from pptx.oxml.ns import qn
|
||||
except ImportError:
|
||||
sys.exit("[aura-ppt] 缺少依赖: pip install python-pptx")
|
||||
|
||||
# ---------------------------------------------------------------- 设计系统
|
||||
class DesignSystem:
|
||||
"""aura-dark: 品牌深色设计系统(默认,深度定制)"""
|
||||
|
||||
def __init__(self):
|
||||
self.name = "aura-dark"
|
||||
self.dark = True # 是否为深色系
|
||||
self.glow = True # 是否绘制左上光晕装饰
|
||||
self.bg = RGBColor(0x0B, 0x0E, 0x14)
|
||||
self.bg2 = RGBColor(0x11, 0x15, 0x1F)
|
||||
self.panel = RGBColor(0x17, 0x1D, 0x2B)
|
||||
self.panel2 = RGBColor(0x12, 0x17, 0x22)
|
||||
self.line = RGBColor(0x27, 0x30, 0x42)
|
||||
self.text = RGBColor(0xF0, 0xF2, 0xF5)
|
||||
self.text_dim = RGBColor(0x8A, 0x93, 0xA3)
|
||||
self.primary = RGBColor(0x2E, 0xCC, 0xFF) # 电光青
|
||||
self.accent = RGBColor(0xFF, 0xC9, 0x4D) # 琥珀金
|
||||
self.secondary = RGBColor(0xA7, 0x8B, 0xFA) # 霓虹紫
|
||||
self.success = RGBColor(0x3D, 0xD6, 0x8C)
|
||||
self.danger = RGBColor(0xFF, 0x6B, 0x6B)
|
||||
# 卡片填充(深色系统的流程图卡片色)
|
||||
self.card_fill = RGBColor(0x1F, 0x2A, 0x3D)
|
||||
# 图表色板(协调渐变色系:青→蓝→紫→绿→橙→金)
|
||||
self.chart_colors = [
|
||||
RGBColor(0x2E, 0xCC, 0xFF), RGBColor(0x5C, 0x9D, 0xFF),
|
||||
RGBColor(0xA7, 0x8B, 0xFA), RGBColor(0x3D, 0xD6, 0x8C),
|
||||
RGBColor(0xFF, 0x8A, 0x65), RGBColor(0xFF, 0xC9, 0x4D),
|
||||
]
|
||||
# 字体回退链:中 → 日 → 英
|
||||
self.font_cn = "Microsoft YaHei"
|
||||
self.font_jp = "Yu Gothic"
|
||||
self.font_en = "Segoe UI"
|
||||
self.margin = Inches(0.55)
|
||||
self.page_w = Inches(13.333)
|
||||
self.page_h = Inches(7.5)
|
||||
# 区域布局(inches,相对页面)
|
||||
# 模板对齐模式:布局坐标与公司PPT母版一致
|
||||
self.layout = {
|
||||
"header_top": 0.46, # 页眉标题顶部
|
||||
"header_h": 0.65, # 页眉标题高度
|
||||
"subtitle_top": 1.05, # 副标题顶部
|
||||
"divider_y": 1.48, # 分隔线y
|
||||
"body_top": 1.85, # 正文内容顶部
|
||||
"body_bottom": 6.35, # 正文内容底部(footer上方)
|
||||
"footer_top": 7.08, # 页脚文字顶部
|
||||
"page_num_y": 7.16, # 页码顶部
|
||||
"page_num_x": 0.9, # 页码距右侧距离
|
||||
}
|
||||
# 统一排版层级(字号语义化,保证一致性)
|
||||
self.typography = {
|
||||
"cover_title": 38, # 封面主标题
|
||||
"cover_subtitle": 18, # 封面副标题
|
||||
"cover_body": 15, # 封面内容
|
||||
"section_title": 34, # 章节页标题
|
||||
"section_kicker": 14, # 章节页kicker
|
||||
"page_title": 22, # 页面标题
|
||||
"page_subtitle": 12, # 页面副标题
|
||||
"card_title": 16, # 卡片标题
|
||||
"body": 15, # 正文
|
||||
"body_small": 13, # 小正文(双栏/卡片内)
|
||||
"table_head": 13, # 表头
|
||||
"table_cell": 12, # 表格单元格
|
||||
"chart_label": 12, # 图表数值标签
|
||||
"chart_cat": 9, # 图表类别标签
|
||||
"timeline_title": 13, # 时间线阶段标题
|
||||
"timeline_desc": 10, # 时间线描述
|
||||
"footer": 9, # 页脚
|
||||
"notes": 10, # 备注/辅助
|
||||
}
|
||||
|
||||
def font_for(self, text):
|
||||
"""统一字体:默认全部使用中文字体(微软雅黑,含拉丁字形,混排统一)
|
||||
report-jp 日文场景使用日文字体"""
|
||||
has_jp = re.search(r'[\u3040-\u30ff\u31f0-\u31ff]', text)
|
||||
if self.name == "report-jp" and has_jp:
|
||||
return self.font_jp
|
||||
return self.font_cn
|
||||
|
||||
|
||||
class BusinessReport(DesignSystem):
|
||||
"""business-report: 商务报告风格(浅色 · 咨询报告质感)
|
||||
灵感来源:麦肯锡/四大咨询报告、PowerPoint 商务模板。
|
||||
特点:白色底、火焰蓝主色、商务红强调、清晰表格、克制的信息层次。
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "business-report"
|
||||
self.dark = False
|
||||
self.glow = False # 浅色风格不画光晕
|
||||
self.bg = RGBColor(0xFF, 0xFF, 0xFF) # 纯白底
|
||||
self.bg2 = RGBColor(0xF5, 0xF6, 0xFA) # 极浅灰(交替行)
|
||||
self.panel = RGBColor(0xF4, 0xF6, 0xFA) # 浅灰面板
|
||||
self.panel2 = RGBColor(0xEC, 0xEF, 0xF5) # 面板暗
|
||||
self.line = RGBColor(0xD8, 0xDE, 0xE9) # 浅灰分隔线
|
||||
self.text = RGBColor(0x1F, 0x29, 0x37) # 深灰文本
|
||||
self.text_dim = RGBColor(0x6B, 0x72, 0x80) # 辅助灰
|
||||
self.primary = RGBColor(0x1E, 0x50, 0xA2) # 火焰蓝(主色)
|
||||
self.accent = RGBColor(0xC0, 0x00, 0x00) # 商务红(强调)
|
||||
self.secondary = RGBColor(0x3B, 0x78, 0xC4) # 亮蓝(次级)
|
||||
self.success = RGBColor(0x22, 0x8B, 0x22) # 墨绿
|
||||
self.danger = RGBColor(0xC0, 0x00, 0x00) # 红
|
||||
# 卡片填充(浅蓝灰,用于流程图卡片)
|
||||
self.card_fill = RGBColor(0xE8, 0xEE, 0xF7)
|
||||
# 图表色板(火焰蓝系列)
|
||||
self.chart_colors = [
|
||||
RGBColor(0x1E, 0x50, 0xA2), RGBColor(0x3B, 0x78, 0xC4),
|
||||
RGBColor(0xC0, 0x00, 0x00), RGBColor(0xED, 0x7D, 0x31),
|
||||
RGBColor(0x70, 0xAD, 0x47), RGBColor(0x70, 0x30, 0xA0),
|
||||
]
|
||||
|
||||
|
||||
class ReportJP(DesignSystem):
|
||||
"""report-jp: 日式商务报告风格
|
||||
参考:日本本部报告会资料(法人蓝 #0B3D91 + 浅天蓝卡片 #B7DEE8 + Meiryo UI)。
|
||||
特点:非标准页面比例(10.83x7.5in)、章节编号页眉、机密标记、卡片+流程图布局。
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "report-jp"
|
||||
self.dark = False
|
||||
self.glow = False
|
||||
self.page_w = Inches(10.83) # 参考PPT比例
|
||||
self.page_h = Inches(7.5)
|
||||
self.bg = RGBColor(0xFF, 0xFF, 0xFF) # 纯白底
|
||||
self.bg2 = RGBColor(0xF5, 0xF7, 0xFB) # 极浅蓝灰(交替行)
|
||||
self.panel = RGBColor(0xE8, 0xEA, 0xF6) # 浅蓝灰面板
|
||||
self.panel2 = RGBColor(0xDD, 0xE3, 0xF2) # 面板暗
|
||||
self.line = RGBColor(0xC9, 0xD4, 0xE8) # 浅蓝分隔线
|
||||
self.text = RGBColor(0x1A, 0x1A, 0x1A) # 黑色正文(日企惯例)
|
||||
self.text_dim = RGBColor(0x55, 0x60, 0x70) # 辅助灰
|
||||
self.primary = RGBColor(0x0B, 0x3D, 0x91) # 法人蓝(主色)
|
||||
self.accent = RGBColor(0xC0, 0x00, 0x00) # 强调红(警示)
|
||||
self.secondary = RGBColor(0x19, 0x76, 0xD2) # Material蓝(次级)
|
||||
self.success = RGBColor(0x2E, 0x7D, 0x32) # 绿
|
||||
self.danger = RGBColor(0xC0, 0x00, 0x00) # 红
|
||||
# 卡片填充浅天蓝(参考PPT主填充)
|
||||
self.card_fill = RGBColor(0xB7, 0xDE, 0xE8)
|
||||
# 图表色板(协调渐变色系:蓝→青→绿→金→橙,冷暖过渡)
|
||||
self.chart_colors = [
|
||||
RGBColor(0x0B, 0x3D, 0x91), # 深法人蓝(主)
|
||||
RGBColor(0x2E, 0x74, 0xB5), # 中蓝
|
||||
RGBColor(0x00, 0x99, 0xCC), # 青蓝
|
||||
RGBColor(0x26, 0xA6, 0x9A), # 青绿
|
||||
RGBColor(0xF2, 0xB1, 0x34), # 琥珀金
|
||||
RGBColor(0xEF, 0x6C, 0x00), # 橙
|
||||
]
|
||||
# 字体:日企标准 Meiryo UI(对日业务核心字体)
|
||||
self.font_cn = "Microsoft YaHei"
|
||||
self.font_jp = "Meiryo UI"
|
||||
self.font_en = "Segoe UI"
|
||||
# 区域布局:与公司PPT母版对齐(参考 010_【本部長報告会】)
|
||||
# 模板规格:章节行 y0.32(20pt) / 主题行 y0.88(16pt) / 卡片标题 y1.74
|
||||
# 页脚条 y6.4~7.1 / 页码 y7.16 右下角 / 安全区 x0.38~10.61
|
||||
self.layout = {
|
||||
"header_top": 0.32, # 章节行顶部(模板 y0.32,20pt)
|
||||
"header_h": 0.45, # 章节行高度
|
||||
"subtitle_top": 0.88, # 主题行顶部(模板 y0.88,16pt)
|
||||
"divider_y": 1.55, # 分隔线y(不用,模板无分隔线)
|
||||
"body_top": 1.60, # 正文内容顶部(模板卡片标题 y1.74 上方留白)
|
||||
"body_bottom": 6.30, # 正文内容底部(模板页脚条 y6.4)
|
||||
"footer_top": 7.05, # 页脚文字顶部
|
||||
"page_num_y": 7.16, # 页码顶部(模板页码 y7.16~7.57)
|
||||
"page_num_x": 0.57, # 页码距右侧距离(模板页码 x10.26)
|
||||
}
|
||||
|
||||
def font_for(self, text):
|
||||
"""日式报告统一 Meiryo UI(含拉丁字形,日中文混排统一)"""
|
||||
return self.font_jp
|
||||
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# 项目五色板设计系统(2026-08-24 PPT自动生成Agent 移植扩展)
|
||||
# 继承 business-report 浅色骨架,按产品五色板覆写主次色/卡片/图表色
|
||||
# 对应 PRD US-2.5:blue/green/purple/orange/gray
|
||||
# ----------------------------------------------------------------
|
||||
class _CorpPalette(BusinessReport):
|
||||
"""五色板公共骨架:浅色商务风,子类只换颜色令牌"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = self._palette_name()
|
||||
self._apply_palette()
|
||||
|
||||
def _palette_name(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def _apply_palette(self):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class CorpBlue(_CorpPalette):
|
||||
"""corp-blue: 商务蓝(产品默认配色,对应品牌色 #1565C0/#2196F3)"""
|
||||
|
||||
def _palette_name(self):
|
||||
return "corp-blue"
|
||||
|
||||
def _apply_palette(self):
|
||||
self.primary = RGBColor(0x15, 0x65, 0xC0)
|
||||
self.secondary = RGBColor(0x21, 0x96, 0xF3)
|
||||
self.card_fill = RGBColor(0xE3, 0xF2, 0xFD)
|
||||
self.panel = RGBColor(0xF0, 0xF6, 0xFC)
|
||||
self.panel2 = RGBColor(0xE3, 0xF2, 0xFD)
|
||||
self.chart_colors = [
|
||||
RGBColor(0x15, 0x65, 0xC0), RGBColor(0x21, 0x96, 0xF3),
|
||||
RGBColor(0x26, 0xC6, 0xDA), RGBColor(0x66, 0xBB, 0x6A),
|
||||
RGBColor(0xFF, 0xA7, 0x26), RGBColor(0xAB, 0x47, 0xBC),
|
||||
]
|
||||
|
||||
|
||||
class CorpGreen(_CorpPalette):
|
||||
"""corp-green: 清新绿(#34A853/#81C995)"""
|
||||
|
||||
def _palette_name(self):
|
||||
return "corp-green"
|
||||
|
||||
def _apply_palette(self):
|
||||
self.primary = RGBColor(0x2E, 0x7D, 0x32)
|
||||
self.secondary = RGBColor(0x34, 0xA8, 0x53)
|
||||
self.card_fill = RGBColor(0xE8, 0xF5, 0xE9)
|
||||
self.panel = RGBColor(0xF1, 0xF8, 0xF2)
|
||||
self.panel2 = RGBColor(0xE8, 0xF5, 0xE9)
|
||||
self.chart_colors = [
|
||||
RGBColor(0x2E, 0x7D, 0x32), RGBColor(0x34, 0xA8, 0x53),
|
||||
RGBColor(0x81, 0xC9, 0x95), RGBColor(0x00, 0x89, 0x7B),
|
||||
RGBColor(0xF9, 0xA8, 0x25), RGBColor(0x5C, 0x6B, 0xC0),
|
||||
]
|
||||
|
||||
|
||||
class CorpPurple(_CorpPalette):
|
||||
"""corp-purple: 科技紫(#7C3AED/#A78BFA)"""
|
||||
|
||||
def _palette_name(self):
|
||||
return "corp-purple"
|
||||
|
||||
def _apply_palette(self):
|
||||
self.primary = RGBColor(0x5B, 0x21, 0xB6)
|
||||
self.secondary = RGBColor(0x7C, 0x3A, 0xED)
|
||||
self.card_fill = RGBColor(0xED, 0xE9, 0xFE)
|
||||
self.panel = RGBColor(0xF5, 0xF3, 0xFF)
|
||||
self.panel2 = RGBColor(0xED, 0xE9, 0xFE)
|
||||
self.chart_colors = [
|
||||
RGBColor(0x5B, 0x21, 0xB6), RGBColor(0x7C, 0x3A, 0xED),
|
||||
RGBColor(0xA7, 0x8B, 0xFA), RGBColor(0xEC, 0x48, 0x99),
|
||||
RGBColor(0xF5, 0x9E, 0x0B), RGBColor(0x0E, 0xA5, 0xE9),
|
||||
]
|
||||
|
||||
|
||||
class CorpOrange(_CorpPalette):
|
||||
"""corp-orange: 活力橙(#F59E0B/#FBBF24)"""
|
||||
|
||||
def _palette_name(self):
|
||||
return "corp-orange"
|
||||
|
||||
def _apply_palette(self):
|
||||
self.primary = RGBColor(0xD9, 0x77, 0x06)
|
||||
self.secondary = RGBColor(0xF5, 0x9E, 0x0B)
|
||||
self.card_fill = RGBColor(0xFE, 0xF3, 0xC7)
|
||||
self.panel = RGBColor(0xFF, 0xFB, 0xEB)
|
||||
self.panel2 = RGBColor(0xFE, 0xF3, 0xC7)
|
||||
self.chart_colors = [
|
||||
RGBColor(0xD9, 0x77, 0x06), RGBColor(0xF5, 0x9E, 0x0B),
|
||||
RGBColor(0xFB, 0xBF, 0x24), RGBColor(0xEF, 0x44, 0x44),
|
||||
RGBColor(0x10, 0xB9, 0x81), RGBColor(0x63, 0x66, 0xF1),
|
||||
]
|
||||
|
||||
|
||||
class CorpGray(_CorpPalette):
|
||||
"""corp-gray: 深灰稳重(#374151/#6B7280),图表以灰阶+蓝红点睛区分系列"""
|
||||
|
||||
def _palette_name(self):
|
||||
return "corp-gray"
|
||||
|
||||
def _apply_palette(self):
|
||||
self.primary = RGBColor(0x37, 0x41, 0x51)
|
||||
self.secondary = RGBColor(0x6B, 0x72, 0x80)
|
||||
self.card_fill = RGBColor(0xF3, 0xF4, 0xF6)
|
||||
self.panel = RGBColor(0xF5, 0xF6, 0xFA)
|
||||
self.panel2 = RGBColor(0xE5, 0xE7, 0xEB)
|
||||
self.accent = RGBColor(0xC0, 0x00, 0x00)
|
||||
self.chart_colors = [
|
||||
RGBColor(0x1F, 0x29, 0x37), RGBColor(0x4B, 0x55, 0x63),
|
||||
RGBColor(0x9C, 0xA3, 0xAF), RGBColor(0x15, 0x65, 0xC0),
|
||||
RGBColor(0xC0, 0x00, 0x00), RGBColor(0x0E, 0xA5, 0xE9),
|
||||
]
|
||||
|
||||
|
||||
# 设计系统注册表
|
||||
DESIGN_SYSTEMS = {
|
||||
"aura-dark": DesignSystem,
|
||||
"business-report": BusinessReport,
|
||||
"report-jp": ReportJP,
|
||||
"corp-blue": CorpBlue,
|
||||
"corp-green": CorpGreen,
|
||||
"corp-purple": CorpPurple,
|
||||
"corp-orange": CorpOrange,
|
||||
"corp-gray": CorpGray,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 工具函数
|
||||
def set_run_font(run, size, bold, color, font_name):
|
||||
run.font.size = Pt(size)
|
||||
run.font.bold = bold
|
||||
run.font.color.rgb = color
|
||||
run.font.name = font_name
|
||||
rPr = run._r.get_or_add_rPr()
|
||||
for tag in ('a:ea', 'a:cs'):
|
||||
el = rPr.find(qn(tag))
|
||||
if el is None:
|
||||
el = rPr.makeelement(qn(tag), {})
|
||||
rPr.append(el)
|
||||
el.set('typeface', font_name)
|
||||
|
||||
|
||||
def add_textbox(slide, x, y, w, h, ds, anchor=MSO_ANCHOR.TOP):
|
||||
tb = slide.shapes.add_textbox(x, y, w, h)
|
||||
tf = tb.text_frame
|
||||
tf.word_wrap = True
|
||||
tf.vertical_anchor = anchor
|
||||
tf.margin_left = Pt(4)
|
||||
tf.margin_right = Pt(4)
|
||||
tf.margin_top = Pt(2)
|
||||
tf.margin_bottom = Pt(2)
|
||||
return tb, tf
|
||||
|
||||
|
||||
def add_text(slide, x, y, w, h, text, ds, size=16, bold=False,
|
||||
color=None, align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.TOP,
|
||||
line_spacing=1.25, space_after=6):
|
||||
"""支持 **加粗** 与 - 子项标记"""
|
||||
if color is None:
|
||||
color = ds.text
|
||||
tb, tf = add_textbox(slide, x, y, w, h, ds, anchor)
|
||||
lines = str(text).split('\n')
|
||||
for i, line in enumerate(lines):
|
||||
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
|
||||
p.alignment = align
|
||||
p.line_spacing = line_spacing
|
||||
p.space_after = Pt(space_after)
|
||||
# 解析 **bold** 与 - 子项
|
||||
is_sub = line.strip().startswith('-')
|
||||
content = line.strip()[1:].strip() if is_sub else line
|
||||
indent = 0 if not is_sub else 1
|
||||
if indent:
|
||||
p.level = 1
|
||||
parts = re.split(r'(\*\*.*?\*\*)', content)
|
||||
for part in parts:
|
||||
if not part:
|
||||
continue
|
||||
r = p.add_run()
|
||||
if part.startswith('**') and part.endswith('**'):
|
||||
r.text = part[2:-2]
|
||||
set_run_font(r, size, True, color or ds.text, ds.font_for(part))
|
||||
else:
|
||||
r.text = part
|
||||
set_run_font(r, size, bold, color or ds.text, ds.font_for(part))
|
||||
return tb
|
||||
|
||||
|
||||
def add_rect(slide, x, y, w, h, ds, fill=None, line=None, radius=False,
|
||||
line_w=1.0, shadow=False):
|
||||
st = MSO_SHAPE.ROUNDED_RECTANGLE if radius else MSO_SHAPE.RECTANGLE
|
||||
shp = slide.shapes.add_shape(st, x, y, w, h)
|
||||
if radius:
|
||||
try:
|
||||
shp.adjustments[0] = 0.08
|
||||
except Exception:
|
||||
pass
|
||||
if fill is None:
|
||||
shp.fill.background()
|
||||
else:
|
||||
shp.fill.solid()
|
||||
shp.fill.fore_color.rgb = fill
|
||||
if line is None:
|
||||
shp.line.fill.background()
|
||||
else:
|
||||
shp.line.color.rgb = line
|
||||
shp.line.width = Pt(line_w)
|
||||
shp.shadow.inherit = False
|
||||
if shadow:
|
||||
add_shape_shadow(shp, ds)
|
||||
tf = shp.text_frame
|
||||
tf.word_wrap = True
|
||||
tf.margin_left = Pt(8)
|
||||
tf.margin_right = Pt(8)
|
||||
tf.margin_top = Pt(4)
|
||||
tf.margin_bottom = Pt(4)
|
||||
return shp
|
||||
|
||||
|
||||
def add_shape_shadow(shp, ds, blur=90000, dist=45000, alpha=25000):
|
||||
"""给形状添加柔和外投影(XML 层操作)"""
|
||||
import lxml.etree as etree
|
||||
spPr = shp._element.spPr
|
||||
# 移除已有 effectLst
|
||||
for el in spPr.findall(qn('a:effectLst')):
|
||||
spPr.remove(el)
|
||||
effect = etree.SubElement(spPr, qn('a:effectLst'))
|
||||
outer = etree.SubElement(effect, qn('a:outerShdw'))
|
||||
outer.set('blurRad', str(blur))
|
||||
outer.set('dist', str(dist))
|
||||
outer.set('dir', '5400000')
|
||||
outer.set('rotWithShape', '0')
|
||||
srgb = etree.SubElement(outer, qn('a:srgbClr'))
|
||||
srgb.set('val', '1F2937')
|
||||
alpha_el = etree.SubElement(srgb, qn('a:alpha'))
|
||||
alpha_el.set('val', str(alpha))
|
||||
|
||||
|
||||
def add_gradient_rect(slide, x, y, w, h, ds, color_top, color_bottom,
|
||||
radius=False, angle=90):
|
||||
"""垂直渐变填充矩形(顶部亮色 → 底部深色,模拟立体柱体)"""
|
||||
st = MSO_SHAPE.ROUNDED_RECTANGLE if radius else MSO_SHAPE.RECTANGLE
|
||||
shp = slide.shapes.add_shape(st, x, y, w, h)
|
||||
if radius:
|
||||
try:
|
||||
shp.adjustments[0] = 0.12
|
||||
except Exception:
|
||||
pass
|
||||
shp.shadow.inherit = False
|
||||
# 设置渐变填充(通过 XML:gradFill)
|
||||
spPr = shp._element.spPr
|
||||
# 移除已有填充
|
||||
for tag in ('a:noFill', 'a:solidFill', 'a:gradFill', 'a:pattFill'):
|
||||
el = spPr.find(qn(tag))
|
||||
if el is not None:
|
||||
spPr.remove(el)
|
||||
grad = spPr.makeelement(qn('a:gradFill'), {})
|
||||
gsLst = spPr.makeelement(qn('a:gsLst'), {})
|
||||
# 渐变停止点:顶部亮色(0%) → 底部深色(100%)
|
||||
stops = [
|
||||
(0, color_top),
|
||||
(100, color_bottom),
|
||||
]
|
||||
for pos, color in stops:
|
||||
gs = spPr.makeelement(qn('a:gs'), {'pos': str(pos)})
|
||||
srgb = spPr.makeelement(qn('a:srgbClr'), {'val': '%02X%02X%02X' % (
|
||||
color[0], color[1], color[2])})
|
||||
gs.append(srgb)
|
||||
gsLst.append(gs)
|
||||
grad.append(gsLst)
|
||||
# 线性渐变方向
|
||||
lin = spPr.makeelement(qn('a:lin'), {
|
||||
'ang': str(angle * 60000), 'scaled': '1'})
|
||||
grad.append(lin)
|
||||
# 插入到 spPr(fill 应位于 line 之前)
|
||||
line_el = spPr.find(qn('a:ln'))
|
||||
if line_el is not None:
|
||||
line_el.addprevious(grad)
|
||||
else:
|
||||
spPr.append(grad)
|
||||
# 无边框
|
||||
shp.line.fill.background()
|
||||
tf = shp.text_frame
|
||||
tf.word_wrap = True
|
||||
return shp
|
||||
|
||||
|
||||
def add_shape(slide, st, x, y, w, h, ds, fill=None, line=None):
|
||||
shp = slide.shapes.add_shape(st, x, y, w, h)
|
||||
if fill is None:
|
||||
shp.fill.background()
|
||||
else:
|
||||
shp.fill.solid()
|
||||
shp.fill.fore_color.rgb = fill
|
||||
if line is None:
|
||||
shp.line.fill.background()
|
||||
else:
|
||||
shp.line.color.rgb = line
|
||||
shp.line.width = Pt(1.0)
|
||||
shp.shadow.inherit = False
|
||||
return shp
|
||||
|
||||
|
||||
def pill(slide, x, y, w, h, text, ds, fill, text_color=None, size=12):
|
||||
p = add_rect(slide, x, y, w, h, ds, fill=fill, radius=True)
|
||||
tf = p.text_frame
|
||||
tf.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||||
p0 = tf.paragraphs[0]
|
||||
p0.alignment = PP_ALIGN.CENTER
|
||||
r = p0.add_run()
|
||||
r.text = text
|
||||
# 深色系: pill 上文字用底色(深色);浅色系: pill 上文字用白色
|
||||
if text_color is None:
|
||||
text_color = ds.bg if ds.dark else RGBColor(0xFF, 0xFF, 0xFF)
|
||||
set_run_font(r, size, True, text_color, ds.font_for(text))
|
||||
return p
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 页面渲染器
|
||||
class SlideRenderer:
|
||||
def __init__(self, prs, ds, template=False):
|
||||
self.prs = prs
|
||||
self.ds = ds
|
||||
self.template = template
|
||||
# 使用模板时选择空白版式(L6 白紙);无模板时用 blank index 6
|
||||
self.blank = prs.slide_layouts[6]
|
||||
self.margin = ds.margin
|
||||
self.content_w = Emu(int(ds.page_w - 2 * ds.margin))
|
||||
# 统一字号体系(设计系统 typography)
|
||||
self.ty = getattr(ds, "typography", {})
|
||||
|
||||
def tp(self, key, default=14):
|
||||
"""获取统一排版字号"""
|
||||
return self.ty.get(key, default)
|
||||
|
||||
def new(self):
|
||||
s = self.prs.slides.add_slide(self.blank)
|
||||
if not self.template:
|
||||
# 非模板模式:绘制纯色背景(模板模式继承母版背景)
|
||||
add_rect(s, 0, 0, self.ds.page_w, self.ds.page_h, self.ds,
|
||||
fill=self.ds.bg)
|
||||
# 左上光晕装饰(仅深色系)
|
||||
if self.ds.glow:
|
||||
for i in range(5, 0, -1):
|
||||
add_shape(s, MSO_SHAPE.OVAL,
|
||||
Inches(-1.0 + i * 0.05), Inches(-1.3 + i * 0.05),
|
||||
Inches(2.6 - i * 0.1), Inches(2.6 - i * 0.1),
|
||||
self.ds, fill=None, line=self.ds.primary)
|
||||
elif self.ds.name == "report-jp":
|
||||
# report-jp 模板模式:内容区极浅蓝灰背景(视觉分区,区别于纯白母版)
|
||||
lay = self.ds.layout
|
||||
bg_top = Inches(lay["body_top"] - 0.15)
|
||||
bg_h = Emu(int(Inches(lay["body_bottom"] + 0.15) - bg_top))
|
||||
add_rect(s, self.margin, bg_top, self.content_w, bg_h,
|
||||
self.ds, fill=RGBColor(0xF7, 0xF9, 0xFC))
|
||||
return s
|
||||
|
||||
def header(self, s, title, subtitle=None, page=None, total=None,
|
||||
section_no=None, confidential=None):
|
||||
"""页眉:设计系统 layout 坐标。
|
||||
report-jp 采用模板风格:章节行20pt在上(y0.32) + 主题行在下(y0.88)
|
||||
"""
|
||||
lay = self.ds.layout
|
||||
# 模板风格:章节行(编号+标题)在顶部小字
|
||||
if self.ds.name == "report-jp":
|
||||
self._header_jp(s, title, subtitle, section_no, confidential)
|
||||
return
|
||||
header_top = Inches(lay["header_top"])
|
||||
header_h = Inches(lay["header_h"])
|
||||
sub_top = Inches(lay["subtitle_top"])
|
||||
div_y = Inches(lay["divider_y"])
|
||||
add_rect(s, self.margin, header_top, Inches(0.07), header_h,
|
||||
self.ds, fill=self.ds.primary)
|
||||
# 日式页眉:章节编号 + 标题
|
||||
if section_no:
|
||||
add_text(s, Inches(0.2), header_top, Inches(0.4), header_h,
|
||||
f"{section_no}.", self.ds, size=self.tp('section_kicker', 14),
|
||||
bold=True, color=self.ds.primary)
|
||||
title_x = Inches(0.65)
|
||||
else:
|
||||
title_x = Inches(0.82)
|
||||
# 标题宽度:到机密标记左侧为止(动态适配页面宽度)
|
||||
title_w = Emu(int(self.ds.page_w - Inches(1.35) - title_x))
|
||||
add_text(s, title_x, header_top, title_w, header_h,
|
||||
title, self.ds, size=self.tp('page_title', 22), bold=True)
|
||||
if subtitle:
|
||||
sub_w = Emu(int(self.ds.page_w - Inches(1.35) - title_x))
|
||||
add_text(s, title_x, sub_top, sub_w, Inches(0.35),
|
||||
subtitle, self.ds, size=self.tp('page_subtitle', 12),
|
||||
color=self.ds.text_dim)
|
||||
add_rect(s, self.margin, div_y, self.content_w, Pt(1.2),
|
||||
self.ds, fill=self.ds.line)
|
||||
# 机密标记(右上角,红色标签)
|
||||
if confidential:
|
||||
# 根据页面宽度动态定位(右上角)
|
||||
cw = Inches(0.9)
|
||||
cx = Emu(int(self.ds.page_w - Inches(1.0)))
|
||||
label = add_rect(s, cx, header_top - Inches(0.04), cw, Inches(0.34),
|
||||
self.ds, fill=self.ds.danger)
|
||||
tf = label.text_frame
|
||||
tf.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||||
p0 = tf.paragraphs[0]
|
||||
p0.alignment = PP_ALIGN.CENTER
|
||||
r = p0.add_run()
|
||||
r.text = confidential
|
||||
set_run_font(r, 9, True, RGBColor(0xFF, 0xFF, 0xFF),
|
||||
self.ds.font_for(confidential))
|
||||
|
||||
def _header_jp(self, s, title, subtitle, section_no, confidential):
|
||||
"""report-jp 模板风格页眉:
|
||||
章节行 y0.32 (20pt 含编号) → 主题行 y0.88 (16pt) → 装饰线 y1.52
|
||||
"""
|
||||
# 章节行(编号+标题 单行,20pt)
|
||||
if section_no:
|
||||
head_text = f"{section_no}. {title}"
|
||||
else:
|
||||
head_text = title
|
||||
add_text(s, Inches(0.30), Inches(0.32), Inches(10.0), Inches(0.45),
|
||||
head_text, self.ds, size=20, bold=False,
|
||||
color=self.ds.text)
|
||||
# 主题/副标题行(y0.88,16pt,模板为页面说明)
|
||||
if subtitle:
|
||||
add_text(s, Inches(0.38), Inches(0.88), Inches(10.0), Inches(0.6),
|
||||
subtitle, self.ds, size=16, bold=False,
|
||||
color=self.ds.text)
|
||||
# 精致装饰线(页眉与内容区视觉分隔,主色细线+渐变尾)
|
||||
line_y = Inches(1.52)
|
||||
add_rect(s, self.margin, line_y, self.content_w, Pt(1.2),
|
||||
self.ds, fill=self.ds.line)
|
||||
# 装饰线左端主色块(视觉锚点)
|
||||
add_rect(s, self.margin, line_y - Inches(0.015),
|
||||
Inches(1.6), Pt(2.8), self.ds, fill=self.ds.primary)
|
||||
# 机密标记(右上角,与模板位置一致 y0.42)
|
||||
if confidential:
|
||||
cw = Inches(0.9)
|
||||
cx = Emu(int(self.ds.page_w - Inches(1.0)))
|
||||
label = add_rect(s, cx, Inches(0.42), cw, Inches(0.34),
|
||||
self.ds, fill=self.ds.danger)
|
||||
tf = label.text_frame
|
||||
tf.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||||
p0 = tf.paragraphs[0]
|
||||
p0.alignment = PP_ALIGN.CENTER
|
||||
r = p0.add_run()
|
||||
r.text = confidential
|
||||
set_run_font(r, 9, True, RGBColor(0xFF, 0xFF, 0xFF),
|
||||
self.ds.font_for(confidential))
|
||||
|
||||
def footer(self, s, idx, total, company="", date_text="", skip=False):
|
||||
"""页脚:公司名(左)+ 页码(右),商务报告惯例。skip 用于封面/结束页"""
|
||||
if skip:
|
||||
return
|
||||
lay = self.ds.layout
|
||||
fy = Inches(lay["footer_top"])
|
||||
foot_x = self.margin
|
||||
if company:
|
||||
add_text(s, foot_x, fy, Inches(4.0), Inches(0.3),
|
||||
company, self.ds, size=9, color=self.ds.text_dim)
|
||||
foot_x = Emu(int(foot_x + Inches(4.2)))
|
||||
if date_text:
|
||||
add_text(s, foot_x, fy, Inches(3.5), Inches(0.3),
|
||||
date_text, self.ds, size=9, color=self.ds.text_dim)
|
||||
if idx:
|
||||
# 页码:与模板对齐(右下角)
|
||||
px = Emu(int(self.ds.page_w - Inches(lay["page_num_x"]) - Inches(0.6)))
|
||||
py = Inches(lay["page_num_y"])
|
||||
add_text(s, px, py, Inches(0.6), Inches(0.35),
|
||||
f"{idx:02d}", self.ds, size=11,
|
||||
color=self.ds.text_dim, align=PP_ALIGN.RIGHT)
|
||||
|
||||
def render(self, slide_plan, idx, total, company="", date_text="",
|
||||
notes_template=False):
|
||||
stype = slide_plan.get("type", "content")
|
||||
fn = getattr(self, f"r_{stype}", self.r_content)
|
||||
fn(slide_plan, idx, total)
|
||||
# 页脚(封面/结束页不显示页码)——页码定位随页面宽度
|
||||
skip_footer = stype in ("cover", "end")
|
||||
self.footer(self.prs.slides[-1], idx, total, company, date_text,
|
||||
skip=skip_footer)
|
||||
# 将 notes 写入真正的演讲者备注区(不显示在页面上)
|
||||
notes_text = slide_plan.get("notes")
|
||||
if notes_template and not notes_text:
|
||||
notes_text = self._auto_notes(slide_plan, idx, total)
|
||||
if notes_text:
|
||||
try:
|
||||
notes_slide = self.prs.slides[-1].notes_slide
|
||||
tf = notes_slide.notes_text_frame
|
||||
tf.text = str(notes_text)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _auto_notes(self, p, idx, total):
|
||||
"""自动生成结构化演讲者备注(当 plan 未提供 notes 时)
|
||||
模板:要点 / 数据提醒 / 过渡提示"""
|
||||
lines = []
|
||||
t = p.get("type", "content")
|
||||
title = p.get("title", "")
|
||||
if title:
|
||||
lines.append(f"【本页主题】{title}")
|
||||
# 数据提醒:提取 content 中的数字(排除目录序号/短编号)
|
||||
nums = []
|
||||
for key in ("content", "data"):
|
||||
v = p.get(key)
|
||||
if isinstance(v, list):
|
||||
for item in v:
|
||||
if isinstance(item, str):
|
||||
for m in re.findall(r'\d+(?:[.,]\d+)?\s*[%%]?', item):
|
||||
m = m.strip()
|
||||
# 排除目录编号 "01." 类
|
||||
if re.fullmatch(r'\d{1,2}', m) and not re.search(r'[%%]', m):
|
||||
continue
|
||||
if m and m not in nums:
|
||||
nums.append(m)
|
||||
if nums:
|
||||
lines.append("【数据提醒】本页包含关键数字:" + "、".join(nums[:8]))
|
||||
# 内容要点(去掉标记)
|
||||
points = []
|
||||
for key in ("content",):
|
||||
v = p.get(key)
|
||||
if isinstance(v, list):
|
||||
for item in v:
|
||||
if isinstance(item, str) and item.strip():
|
||||
pts = item.strip()
|
||||
pts = pts.replace('**', '') # 先移除加粗标记
|
||||
pts = re.sub(r'^[-*]\s*', '', pts) # 再去行首符号
|
||||
if len(pts) > 3:
|
||||
points.append(pts)
|
||||
if points:
|
||||
lines.append("【核心要点】")
|
||||
for pt in points[:3]:
|
||||
lines.append(f" · {pt[:50]}")
|
||||
# 过渡提示
|
||||
lines.append("【过渡提示】总结本页要点后自然过渡到下一页。")
|
||||
return "\n".join(lines)
|
||||
|
||||
# ---- cover(支持 stats 大数字统计 + 装饰图形)
|
||||
def r_cover(self, p, idx, total):
|
||||
s = self.new()
|
||||
pw = self.ds.page_w
|
||||
# 底部渐变条
|
||||
add_rect(s, Inches(0), Inches(6.9), pw, Inches(0.6),
|
||||
self.ds, fill=self.ds.panel2)
|
||||
# 右上角装饰圆环(视觉焦点)
|
||||
add_shape(s, MSO_SHAPE.OVAL,
|
||||
Emu(int(pw - Inches(2.2))), Inches(-0.8),
|
||||
Inches(3.0), Inches(3.0), self.ds,
|
||||
fill=None, line=self.ds.primary)
|
||||
add_text(s, self.margin, Inches(0.8), Inches(5.0), Inches(0.4),
|
||||
p.get("kicker", ""), self.ds,
|
||||
size=self.tp('section_kicker', 14),
|
||||
color=self.ds.primary)
|
||||
# 标题宽度自适应页面
|
||||
title_w = Emu(int(pw - 2 * self.margin))
|
||||
# 有 stats 时标题区压缩(数字墙占右侧)
|
||||
has_stats = p.get("stats")
|
||||
if has_stats:
|
||||
title_w = Emu(int(pw - self.margin - Inches(3.6)))
|
||||
# 标题字号按长度和可用宽度自适应(长标题/窄页面自动缩小)
|
||||
cover_title = p.get("title", "演示文稿")
|
||||
base_sz = self.tp('cover_title', 38)
|
||||
title_chars = len(cover_title)
|
||||
if title_chars > 20:
|
||||
cover_sz = min(base_sz, 26)
|
||||
elif title_chars > 14:
|
||||
cover_sz = min(base_sz, 30)
|
||||
else:
|
||||
cover_sz = base_sz
|
||||
# stats 存在时标题区更窄,再缩一档
|
||||
if has_stats and cover_sz > 24:
|
||||
cover_sz = min(cover_sz, 24)
|
||||
add_text(s, self.margin, Inches(2.2), title_w, Inches(1.4),
|
||||
cover_title, self.ds,
|
||||
size=cover_sz, bold=True, line_spacing=1.2)
|
||||
if p.get("subtitle"):
|
||||
add_text(s, self.margin, Inches(3.6), title_w, Inches(0.6),
|
||||
p["subtitle"], self.ds,
|
||||
size=self.tp('cover_subtitle', 18),
|
||||
color=self.ds.text_dim)
|
||||
if p.get("content"):
|
||||
add_text(s, self.margin, Inches(4.4),
|
||||
Emu(int(title_w - Inches(0.5))), Inches(1.6),
|
||||
"\n".join(p["content"]), self.ds,
|
||||
size=self.tp('cover_body', 15),
|
||||
color=self.ds.text_dim, line_spacing=1.5)
|
||||
add_rect(s, self.margin, Inches(5.7), Inches(2.8), Pt(2.5),
|
||||
self.ds, fill=self.ds.primary)
|
||||
# 大数字统计墙(右侧,封面视觉焦点)
|
||||
if has_stats:
|
||||
self._cover_stats(s, p["stats"], pw)
|
||||
|
||||
def _cover_stats(self, s, stats, pw):
|
||||
"""封面大数字统计(右侧竖排卡片)"""
|
||||
x = Emu(int(pw - Inches(3.3)))
|
||||
y0 = Inches(1.9)
|
||||
card_w = Inches(2.7)
|
||||
card_h = Inches(1.15)
|
||||
for i, (num, label) in enumerate(stats):
|
||||
y = y0 + i * (card_h + Inches(0.22))
|
||||
# 数字卡片
|
||||
add_rect(s, x, y, card_w, card_h, self.ds,
|
||||
fill=self.ds.panel, radius=True, shadow=True)
|
||||
add_rect(s, x, y, card_w, Inches(0.06), self.ds,
|
||||
fill=self.ds.chart_colors[i % 6])
|
||||
add_text(s, x, y + Inches(0.08), card_w, Inches(0.62),
|
||||
num, self.ds, size=26, bold=True,
|
||||
color=self.ds.chart_colors[i % 6],
|
||||
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
|
||||
add_text(s, x + Inches(0.1), y + Inches(0.74),
|
||||
card_w - Inches(0.2), Inches(0.35),
|
||||
label, self.ds, size=11,
|
||||
color=self.ds.text_dim, align=PP_ALIGN.CENTER)
|
||||
|
||||
# ---- toc
|
||||
def r_toc(self, p, idx, total):
|
||||
s = self.new()
|
||||
self.header(s, p.get("title", "目录"),
|
||||
p.get("subtitle"), idx, total,
|
||||
section_no=p.get("section_no"),
|
||||
confidential=p.get("confidential"))
|
||||
items = p.get("content", [])
|
||||
x0, y0 = self.margin, Inches(self.ds.layout["body_top"])
|
||||
# 卡片宽度自适应:两列 + 间距
|
||||
gap = Inches(0.3)
|
||||
cw = Emu(int((self.content_w - gap) / 2))
|
||||
ch = Inches(1.35)
|
||||
for i, item in enumerate(items):
|
||||
col, row = i % 2, i // 2
|
||||
x = x0 + col * (cw + gap)
|
||||
y = y0 + row * (ch + Inches(0.28))
|
||||
card = add_rect(s, x, y, cw, ch, self.ds, fill=self.ds.panel,
|
||||
radius=True, shadow=True)
|
||||
# 左侧色条(彩色,随条目序号变化)
|
||||
add_rect(s, x, y, Inches(0.07), ch, self.ds,
|
||||
fill=self.ds.chart_colors[i % 6])
|
||||
num = item.split('.')[0] if '.' in item else f"{i+1:02d}"
|
||||
title = item.split('.', 1)[1].strip() if '.' in item else item
|
||||
# 序号:彩色圆形徽章
|
||||
badge_r = Inches(0.34)
|
||||
badge = add_shape(s, MSO_SHAPE.OVAL,
|
||||
x + Inches(0.3), y + Inches(0.34),
|
||||
badge_r * 2, badge_r * 2, self.ds,
|
||||
fill=self.ds.chart_colors[i % 6])
|
||||
btf = badge.text_frame
|
||||
btf.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||||
bp = btf.paragraphs[0]
|
||||
bp.alignment = PP_ALIGN.CENTER
|
||||
br = bp.add_run()
|
||||
br.text = num
|
||||
set_run_font(br, 16, True, RGBColor(0xFF, 0xFF, 0xFF),
|
||||
self.ds.font_cn)
|
||||
# 条目标题(徽章右侧)
|
||||
add_text(s, x + Inches(1.05), y + Inches(0.4),
|
||||
cw - Inches(1.3), Inches(0.6),
|
||||
title, self.ds,
|
||||
size=self.tp('body', 14), bold=True)
|
||||
|
||||
# ---- section
|
||||
def r_section(self, p, idx, total):
|
||||
s = self.new()
|
||||
pw = self.ds.page_w
|
||||
title_w = Emu(int(pw - 2 * self.margin))
|
||||
# 左侧装饰竖条
|
||||
add_rect(s, self.margin, Inches(2.4), Inches(0.1), Inches(1.6),
|
||||
self.ds, fill=self.ds.primary)
|
||||
add_text(s, Emu(int(self.margin + Inches(0.4))), Inches(2.6),
|
||||
Inches(5.0), Inches(0.5),
|
||||
p.get("kicker", "SECTION"), self.ds,
|
||||
size=self.tp('section_kicker', 14),
|
||||
color=self.ds.primary)
|
||||
add_text(s, Emu(int(self.margin + Inches(0.4))), Inches(3.1),
|
||||
title_w, Inches(1.2),
|
||||
p.get("title", ""), self.ds,
|
||||
size=self.tp('section_title', 34), bold=True)
|
||||
if p.get("content"):
|
||||
add_text(s, Emu(int(self.margin + Inches(0.4))), Inches(4.5),
|
||||
Emu(int(title_w - Inches(0.5))), Inches(1.2),
|
||||
"\n".join(p["content"]), self.ds,
|
||||
size=self.tp('body', 16), color=self.ds.text_dim)
|
||||
# 底部主色横线(视觉收尾)
|
||||
add_rect(s, self.margin, Inches(5.9), Inches(2.8), Pt(2.5),
|
||||
self.ds, fill=self.ds.primary)
|
||||
|
||||
# ---- content(智能布局:自动识别要点卡片 / 普通文本)
|
||||
def r_content(self, p, idx, total):
|
||||
s = self.new()
|
||||
self.header(s, p.get("title", ""), p.get("subtitle"), idx, total,
|
||||
section_no=p.get("section_no"),
|
||||
confidential=p.get("confidential"))
|
||||
content = p.get("content", [])
|
||||
y0 = Inches(self.ds.layout["body_top"])
|
||||
body_h = Inches(self.ds.layout["body_bottom"] - self.ds.layout["body_top"])
|
||||
|
||||
# 右侧配图(plan 页面级 image 字段,文件存在才启用左文右图布局)
|
||||
img_path = p.get("image")
|
||||
has_img = bool(img_path) and Path(str(img_path)).exists()
|
||||
avail_w = Emu(int(self.content_w * 0.58)) if has_img else self.content_w
|
||||
|
||||
# 智能检测:是否有 **标题** 开头的要点(渲染为卡片网格)
|
||||
items = []
|
||||
plain_lines = []
|
||||
for line in content:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("**") and "**" in stripped[2:]:
|
||||
# 提取 **标题** 和后续内容
|
||||
end = stripped.find("**", 2)
|
||||
title = stripped[2:end]
|
||||
rest = stripped[end+2:].strip()
|
||||
items.append((title, rest))
|
||||
elif stripped:
|
||||
plain_lines.append(stripped)
|
||||
|
||||
if items and not plain_lines:
|
||||
# 卡片网格布局(2列,每卡片带色条+标题+描述)
|
||||
self._content_cards(s, items, y0, body_h, w=avail_w)
|
||||
elif items and plain_lines:
|
||||
# 混合:要点卡片 + 底部说明文字
|
||||
self._content_cards(s, items, y0, Emu(int(body_h * 0.8)), w=avail_w)
|
||||
if plain_lines:
|
||||
add_text(s, self.margin,
|
||||
Emu(int(y0 + body_h * 0.85)),
|
||||
avail_w, Emu(int(body_h * 0.15)),
|
||||
"\n".join(plain_lines), self.ds,
|
||||
size=self.tp('body_small', 12),
|
||||
color=self.ds.text_dim, line_spacing=1.3)
|
||||
else:
|
||||
# 普通文本布局
|
||||
add_text(s, self.margin, y0, avail_w, body_h,
|
||||
"\n".join(content), self.ds, size=self.tp('body', 15),
|
||||
line_spacing=1.4, space_after=8)
|
||||
|
||||
if has_img:
|
||||
self._place_image(s, str(img_path), y0, body_h)
|
||||
|
||||
def _place_image(self, s, img_path, y0, body_h):
|
||||
"""在内容区右侧放置配图:宽占 42%,等比缩放、垂直居中,带细边框。
|
||||
图片缺失/损坏时静默跳过(返回 False),不影响整页渲染。"""
|
||||
zone_w = Emu(int(self.content_w * 0.40))
|
||||
zx = Emu(int(self.margin + self.content_w - zone_w))
|
||||
max_h = Emu(int(body_h - Inches(0.12)))
|
||||
try:
|
||||
pic = s.shapes.add_picture(img_path, zx, y0, width=zone_w)
|
||||
except Exception:
|
||||
return False
|
||||
if pic.height > max_h:
|
||||
ratio = max_h / pic.height
|
||||
new_w = Emu(int(pic.width * ratio))
|
||||
pic.height = max_h
|
||||
pic.width = new_w
|
||||
pic.left = Emu(int(zx + int((zone_w - new_w) / 2)))
|
||||
if pic.height < body_h:
|
||||
pic.top = Emu(int(y0 + int((body_h - pic.height) / 2)))
|
||||
try:
|
||||
pic.line.color.rgb = self.ds.line
|
||||
pic.line.width = Pt(1)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
def _content_cards(self, s, items, y0, body_h, w=None):
|
||||
"""要点卡片网格:2列 × N行,每卡片带左侧色条+标题+描述"""
|
||||
n = len(items)
|
||||
if not n:
|
||||
return
|
||||
grid_w = w if w is not None else self.content_w
|
||||
gap = Inches(0.3)
|
||||
col_w = Emu(int((grid_w - gap) / 2))
|
||||
# 卡片高度自适应(最多3行卡片)
|
||||
rows = (n + 1) // 2
|
||||
card_h = Emu(int((body_h - gap * (rows - 1)) / rows))
|
||||
# 卡片多时压缩字号(避免描述溢出)
|
||||
desc_sz = self.tp('body_small', 12)
|
||||
title_sz = self.tp('card_title', 16)
|
||||
desc_ls = 1.35
|
||||
if rows >= 3 or card_h < Inches(1.5):
|
||||
desc_sz = max(10, desc_sz - 1)
|
||||
title_sz = max(13, title_sz - 1)
|
||||
desc_ls = 1.25
|
||||
for i, (title, desc) in enumerate(items):
|
||||
col, row = i % 2, i // 2
|
||||
x = self.margin + col * (col_w + gap)
|
||||
y = y0 + row * (card_h + gap)
|
||||
# 卡片背景(带柔和投影)
|
||||
add_rect(s, x, y, col_w, card_h, self.ds,
|
||||
fill=self.ds.panel, radius=True, shadow=True)
|
||||
# 左侧色条
|
||||
add_rect(s, x, y, Inches(0.07), card_h, self.ds,
|
||||
fill=self.ds.chart_colors[i % 6])
|
||||
# 标题
|
||||
add_text(s, x + Inches(0.28), y + Inches(0.12),
|
||||
col_w - Inches(0.5), Inches(0.38),
|
||||
title, self.ds, size=title_sz,
|
||||
bold=True, color=self.ds.primary, line_spacing=1.15)
|
||||
# 描述
|
||||
if desc:
|
||||
add_text(s, x + Inches(0.28), y + Inches(0.56),
|
||||
col_w - Inches(0.5), card_h - Inches(0.72),
|
||||
desc, self.ds, size=desc_sz,
|
||||
color=self.ds.text, line_spacing=desc_ls)
|
||||
|
||||
# ---- two_column
|
||||
def r_two_column(self, p, idx, total):
|
||||
s = self.new()
|
||||
self.header(s, p.get("title", ""), p.get("subtitle"), idx, total,
|
||||
section_no=p.get("section_no"),
|
||||
confidential=p.get("confidential"))
|
||||
cols = p.get("content", [])
|
||||
gap = Inches(0.35)
|
||||
cw = Emu(int((self.content_w - gap) / 2))
|
||||
body_top = Inches(self.ds.layout["body_top"])
|
||||
body_bottom = Inches(self.ds.layout["body_bottom"])
|
||||
card_h = Emu(int(body_bottom - body_top))
|
||||
# 窄页面/长内容时字号自动适配:卡片可用高度不足则缩小
|
||||
card_font = 13
|
||||
if cw < Inches(5.0):
|
||||
card_font = 12
|
||||
for ci in range(2):
|
||||
x = self.margin + ci * (cw + gap)
|
||||
y = body_top
|
||||
card = add_rect(s, x, y, cw, card_h, self.ds,
|
||||
fill=self.ds.panel, radius=True, shadow=True)
|
||||
head = p.get(f"col{ci+1}_title", f"栏目 {ci+1}")
|
||||
add_text(s, x + Inches(0.3), y + Inches(0.25), cw - Inches(0.6),
|
||||
Inches(0.5), head, self.ds, size=self.tp('card_title', 16),
|
||||
bold=True, color=self.ds.primary)
|
||||
if ci < len(cols):
|
||||
# 文本区自适应卡片剩余高度
|
||||
text_top = y + Inches(0.9)
|
||||
text_h = Emu(int(card_h - Inches(1.15)))
|
||||
add_text(s, x + Inches(0.3), text_top,
|
||||
cw - Inches(0.6), text_h,
|
||||
"\n".join(cols[ci]), self.ds,
|
||||
size=self.tp('body_small', 13),
|
||||
line_spacing=1.3, space_after=6)
|
||||
|
||||
# ---- table(支持可选底部图表 chart)
|
||||
def r_table(self, p, idx, total):
|
||||
s = self.new()
|
||||
self.header(s, p.get("title", ""), p.get("subtitle"), idx, total,
|
||||
section_no=p.get("section_no"),
|
||||
confidential=p.get("confidential"))
|
||||
data = p.get("data", [])
|
||||
if not data:
|
||||
return
|
||||
rows, cols = len(data), len(data[0])
|
||||
lay = self.ds.layout
|
||||
x0, y0 = self.margin, Inches(lay["body_top"])
|
||||
tw = self.content_w
|
||||
body_avail = Emu(int(Inches(lay["body_bottom"]) - Inches(lay["body_top"])))
|
||||
# 表格+图表混合时,表格占上部 58%,图表占下部 42%
|
||||
has_chart = p.get("chart") or p.get("charts")
|
||||
if has_chart:
|
||||
table_area = Emu(int(body_avail * 0.58))
|
||||
else:
|
||||
table_area = body_avail
|
||||
# 行高自适应:表格头 + (rows-1) 行 分配 table_area
|
||||
th = Inches(0.5)
|
||||
rh = Emu(int((table_area - th) / max(rows - 1, 1)))
|
||||
# 行高下限保护:按 12pt 文字+边距计算最小行高(约0.42in)
|
||||
# 空间不足时优先压缩表头,保证数据行有足够高度
|
||||
min_rh = Inches(0.42)
|
||||
if rh < min_rh:
|
||||
rh = min_rh
|
||||
# 表头高度 = 剩余空间(若负则回到可接受最小值)
|
||||
th_remain = Emu(int(table_area - rh * (rows - 1)))
|
||||
th = th_remain if th_remain > Inches(0.35) else Inches(0.35)
|
||||
cw = Emu(int(tw / cols))
|
||||
# header
|
||||
for c in range(cols):
|
||||
add_rect(s, x0 + c * cw, y0, cw, th, self.ds,
|
||||
fill=self.ds.panel2)
|
||||
add_text(s, x0 + c * cw + Inches(0.1), y0 + Inches(0.12),
|
||||
cw - Inches(0.2), Inches(0.35), str(data[0][c]),
|
||||
self.ds, size=self.tp('table_head', 13), bold=True,
|
||||
color=self.ds.primary,
|
||||
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
|
||||
for r in range(1, rows):
|
||||
cy = y0 + th + (r - 1) * rh
|
||||
fill = self.ds.bg2 if r % 2 == 0 else self.ds.panel
|
||||
for c in range(cols):
|
||||
add_rect(s, x0 + c * cw, cy, cw, rh - Inches(0.04),
|
||||
self.ds, fill=fill)
|
||||
add_text(s, x0 + c * cw + Inches(0.1), cy + Inches(0.1),
|
||||
cw - Inches(0.2), rh - Inches(0.2),
|
||||
str(data[r][c]), self.ds,
|
||||
size=self.tp('table_cell', 12),
|
||||
color=self.ds.text, align=PP_ALIGN.CENTER,
|
||||
anchor=MSO_ANCHOR.MIDDLE)
|
||||
# 底部图表(单个或多个并排)
|
||||
if has_chart:
|
||||
table_actual_h = Emu(int(th + rh * (rows - 1)))
|
||||
chart_top = Emu(int(y0 + table_actual_h + Inches(0.2)))
|
||||
chart_bottom = Emu(int(Inches(lay["body_bottom"])))
|
||||
if p.get("charts"):
|
||||
self._draw_charts(s, p.get("charts"), chart_top,
|
||||
chart_bottom - chart_top)
|
||||
elif p.get("chart"):
|
||||
self._draw_charts(s, [p.get("chart")], chart_top,
|
||||
chart_bottom - chart_top)
|
||||
|
||||
# ---- two_table(左右双表并排)
|
||||
def r_two_table(self, p, idx, total):
|
||||
"""左右两个表格并排。plan 结构:
|
||||
tables: [{title, data, col_ratio?}, ...] 或 table1/table2
|
||||
"""
|
||||
s = self.new()
|
||||
self.header(s, p.get("title", ""), p.get("subtitle"), idx, total,
|
||||
section_no=p.get("section_no"),
|
||||
confidential=p.get("confidential"))
|
||||
tables = p.get("tables", [])
|
||||
if not tables:
|
||||
return
|
||||
lay = self.ds.layout
|
||||
y0 = Inches(lay["body_top"])
|
||||
body_bottom = Inches(lay["body_bottom"])
|
||||
gap = Inches(0.3)
|
||||
n = len(tables)
|
||||
# 表格宽度:等分或按 col_ratio
|
||||
ratios = [t.get("col_ratio", 1) for t in tables]
|
||||
total_r = sum(ratios)
|
||||
gap_total = gap * (n - 1)
|
||||
avail_w = self.content_w - Emu(int(gap_total))
|
||||
widths = [Emu(int(avail_w * r / total_r)) for r in ratios]
|
||||
for ti, t in enumerate(tables):
|
||||
tdata = t.get("data", [])
|
||||
if not tdata:
|
||||
continue
|
||||
rows, cols = len(tdata), len(tdata[0])
|
||||
tx = self.margin + sum(widths[:ti]) + ti * gap
|
||||
tw = widths[ti]
|
||||
# 子标题(表格上方)
|
||||
sub_title = t.get("title", "")
|
||||
if sub_title:
|
||||
add_text(s, tx, y0, tw, Inches(0.3),
|
||||
sub_title, self.ds, size=11, bold=True,
|
||||
color=self.ds.primary, align=PP_ALIGN.CENTER)
|
||||
# 表头高度:长文本(日文/多字符)加高,支持换行
|
||||
head_sz = 10
|
||||
if tw < Inches(4.5) or cols >= 5:
|
||||
head_sz = 9
|
||||
th = Inches(0.42)
|
||||
# 检查表头是否有长文本需要更高
|
||||
head_max_len = max(len(str(tdata[0][c])) for c in range(cols))
|
||||
if head_max_len > 10:
|
||||
th = Inches(0.55)
|
||||
# 行高:按表格区域分配(双表用全部 body 高度)
|
||||
table_area = Emu(int(body_bottom - y0 - Inches(0.42)))
|
||||
rh = Emu(int((table_area - th) / max(rows - 1, 1)))
|
||||
cell_sz = 10
|
||||
if tw < Inches(4.5) or cols >= 5:
|
||||
cell_sz = 9
|
||||
min_rh = Inches(cell_sz / 72 * 1.6 + 0.12)
|
||||
if rh < min_rh:
|
||||
rh = min_rh
|
||||
cw = Emu(int(tw / cols))
|
||||
# 表头
|
||||
for c in range(cols):
|
||||
add_rect(s, tx + c * cw, y0, cw, th, self.ds,
|
||||
fill=self.ds.panel2)
|
||||
add_text(s, tx + c * cw + Inches(0.04), y0 + Inches(0.05),
|
||||
cw - Inches(0.08), th - Inches(0.08),
|
||||
str(tdata[0][c]), self.ds, size=head_sz,
|
||||
bold=True, color=self.ds.primary,
|
||||
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE,
|
||||
line_spacing=1.0)
|
||||
# 数据行
|
||||
for r in range(1, rows):
|
||||
cy = y0 + th + (r - 1) * rh
|
||||
fill = self.ds.bg2 if r % 2 == 0 else self.ds.panel
|
||||
for c in range(cols):
|
||||
add_rect(s, tx + c * cw, cy, cw, rh - Inches(0.02),
|
||||
self.ds, fill=fill)
|
||||
add_text(s, tx + c * cw + Inches(0.04), cy + Inches(0.04),
|
||||
cw - Inches(0.08), rh - Inches(0.08),
|
||||
str(tdata[r][c]), self.ds, size=cell_sz,
|
||||
color=self.ds.text, align=PP_ALIGN.CENTER,
|
||||
anchor=MSO_ANCHOR.MIDDLE, line_spacing=1.0)
|
||||
|
||||
# ---- chart(支持单图 chart 或多图 charts 数组)
|
||||
def r_chart(self, p, idx, total):
|
||||
s = self.new()
|
||||
self.header(s, p.get("title", ""), p.get("subtitle"), idx, total,
|
||||
section_no=p.get("section_no"),
|
||||
confidential=p.get("confidential"))
|
||||
lay = self.ds.layout
|
||||
cx, cy = self.margin, Inches(lay["body_top"])
|
||||
cw = self.content_w
|
||||
ch = Emu(int(Inches(lay["body_bottom"]) - Inches(lay["body_top"])))
|
||||
if p.get("charts"):
|
||||
self._draw_charts(s, p.get("charts"), cy, ch)
|
||||
elif p.get("chart"):
|
||||
self._draw_charts(s, [p.get("chart")], cy, ch)
|
||||
|
||||
def _draw_charts(self, s, charts, cy, ch):
|
||||
"""绘制一个或多个图表(并排布局)"""
|
||||
n = len(charts)
|
||||
if not n:
|
||||
return
|
||||
gap = Inches(0.3)
|
||||
cw = Emu(int((self.content_w - gap * (n - 1)) / n))
|
||||
cx = self.margin
|
||||
for i, chart in enumerate(charts):
|
||||
x = cx + i * (cw + gap)
|
||||
self._draw_chart_single(s, chart, x, cy, cw, ch)
|
||||
|
||||
def _draw_chart_single(self, s, chart, cx, cy, cw, ch):
|
||||
"""绘制单个图表(带标题),供单图/多图/表格混合调用"""
|
||||
ctype = chart.get("type", "bar")
|
||||
data = self._resolve_chart_data(chart)
|
||||
if not data:
|
||||
return
|
||||
# 面板背景
|
||||
panel = add_rect(s, cx, cy, cw, ch, self.ds, fill=self.ds.panel,
|
||||
radius=True, shadow=True)
|
||||
# 子标题(图表标题,顶部居中)
|
||||
sub_title = chart.get("title", "")
|
||||
if sub_title:
|
||||
add_text(s, cx + Inches(0.2), cy + Inches(0.12),
|
||||
cw - Inches(0.4), Inches(0.35),
|
||||
sub_title, self.ds, size=self.tp('card_title', 14),
|
||||
bold=True, color=self.ds.primary,
|
||||
align=PP_ALIGN.CENTER)
|
||||
# 绘图区(面板内,标题下方)
|
||||
inner_cy = Emu(int(cy + Inches(0.5)))
|
||||
inner_ch = Emu(int(ch - Inches(0.55)))
|
||||
# 图例:仅饼图/多系列时显示
|
||||
show_legend = ctype == "pie" or len(data) > 6
|
||||
if show_legend:
|
||||
legend_y = Emu(int(inner_cy + Inches(0.05)))
|
||||
for i, (k, _v) in enumerate(data.items()):
|
||||
lx = cx + Inches(0.3) + i * Inches(1.8)
|
||||
add_shape(s, MSO_SHAPE.RECTANGLE, lx, legend_y + Inches(0.05),
|
||||
Inches(0.2), Inches(0.2), self.ds,
|
||||
fill=self.ds.chart_colors[i % 6])
|
||||
add_text(s, lx + Inches(0.28), legend_y, Inches(1.4),
|
||||
Inches(0.3), k, self.ds,
|
||||
size=self.tp('table_cell', 10),
|
||||
color=self.ds.text_dim)
|
||||
inner_cy = Emu(int(inner_cy + Inches(0.35)))
|
||||
inner_ch = Emu(int(inner_ch - Inches(0.35)))
|
||||
if ctype == "pie":
|
||||
self._draw_pie(s, cx, inner_cy, cw, inner_ch, data)
|
||||
elif ctype == "line":
|
||||
self._draw_line(s, cx, inner_cy, cw, inner_ch, data)
|
||||
else: # bar
|
||||
self._draw_bar(s, cx, inner_cy, cw, inner_ch, data)
|
||||
|
||||
def _resolve_chart_data(self, chart):
|
||||
src = chart.get("source")
|
||||
if src and os.path.exists(src):
|
||||
ext = os.path.splitext(src)[1].lower()
|
||||
if ext == ".csv":
|
||||
with open(src, "r", encoding="utf-8-sig") as f:
|
||||
reader = csv.reader(f)
|
||||
rows = list(reader)
|
||||
if rows:
|
||||
labels = [r[0] for r in rows[1:]] or [r[0] for r in rows]
|
||||
values = [float(r[1]) for r in rows[1:]]
|
||||
return dict(zip(labels, values))
|
||||
return chart.get("data", {})
|
||||
|
||||
def _draw_bar(self, s, cx, cy, cw, ch, data):
|
||||
items = list(data.items())
|
||||
if not items:
|
||||
return
|
||||
# 绘图区布局:标题/图例下方留出类别标签空间
|
||||
label_h = Inches(0.55) # 类别标签区(支持2行)
|
||||
plot_x = cx + Inches(0.7)
|
||||
plot_y = cy + Inches(0.7)
|
||||
plot_w = cw - Inches(1.4)
|
||||
plot_h = ch - Inches(0.7) - label_h - Inches(0.15)
|
||||
max_v = max(v for _, v in items) or 1
|
||||
n = len(items)
|
||||
# 柱宽:按数据量自适应
|
||||
bar_gap = Inches(0.22) if n <= 6 else Inches(0.14)
|
||||
bar_w = Emu(int((plot_w - bar_gap * (n - 1)) / n))
|
||||
# 网格线(3条水平辅助线,增强可读性)
|
||||
for gi in range(1, 4):
|
||||
gy = plot_y + Emu(int(plot_h * gi / 4))
|
||||
add_rect(s, plot_x, gy, plot_w, Pt(0.75), self.ds,
|
||||
fill=self.ds.line)
|
||||
# 基线
|
||||
base_y = plot_y + plot_h
|
||||
add_rect(s, plot_x, base_y, plot_w, Pt(2), self.ds,
|
||||
fill=self.ds.line)
|
||||
max_i = max(range(n), key=lambda i: items[i][1]) if n else 0
|
||||
for i, (k, v) in enumerate(items):
|
||||
hgt = Emu(int(plot_h * (v / max_v)))
|
||||
# 最小柱高(避免0值柱不可见)
|
||||
if hgt < Inches(0.08):
|
||||
hgt = Inches(0.08)
|
||||
bx = plot_x + i * (bar_w + bar_gap)
|
||||
by = base_y - hgt
|
||||
base_color = self.ds.chart_colors[i % 6]
|
||||
# 渐变柱体:顶部提亮40% → 底部原色(立体感)
|
||||
lighter = RGBColor(
|
||||
min(255, int(base_color[0] + (255 - base_color[0]) * 0.45)),
|
||||
min(255, int(base_color[1] + (255 - base_color[1]) * 0.45)),
|
||||
min(255, int(base_color[2] + (255 - base_color[2]) * 0.45)))
|
||||
add_gradient_rect(s, bx, by, bar_w, hgt, self.ds,
|
||||
lighter, base_color, radius=True)
|
||||
# 数值标签(柱顶上方)
|
||||
add_text(s, bx, by - Inches(0.36), bar_w, Inches(0.3),
|
||||
str(v), self.ds,
|
||||
size=self.tp('chart_label', 12), bold=True,
|
||||
color=self.ds.text, align=PP_ALIGN.CENTER)
|
||||
# 类别标签(柱下方,支持2行)
|
||||
add_text(s, bx - Inches(0.05),
|
||||
base_y + Inches(0.08),
|
||||
bar_w + Inches(0.1), label_h,
|
||||
k, self.ds, size=self.tp('chart_cat', 9),
|
||||
color=self.ds.text_dim, align=PP_ALIGN.CENTER,
|
||||
anchor=MSO_ANCHOR.TOP, line_spacing=1.15)
|
||||
|
||||
def _draw_line(self, s, cx, cy, cw, ch, data):
|
||||
items = list(data.items())
|
||||
if len(items) < 2:
|
||||
self._draw_bar(s, cx, cy, cw, ch, data)
|
||||
return
|
||||
plot_x = cx + Inches(0.9)
|
||||
plot_y = cy + Inches(1.0)
|
||||
plot_w = cw - Inches(1.6)
|
||||
plot_h = ch - Inches(1.6)
|
||||
max_v = max(v for _, v in items) or 1
|
||||
min_v = min(v for _, v in items) or 0
|
||||
span = (max_v - min_v) or 1
|
||||
pts = []
|
||||
step = Emu(int(plot_w / (len(items) - 1)))
|
||||
for i, (k, v) in enumerate(items):
|
||||
px = plot_x + i * step
|
||||
py = plot_y + plot_h - Emu(int(plot_h * ((v - min_v) / span)))
|
||||
pts.append((px, py))
|
||||
# 连线
|
||||
for i in range(len(pts) - 1):
|
||||
x1, y1 = pts[i]
|
||||
x2, y2 = pts[i + 1]
|
||||
mid_y = Emu(int((y1 + y2) / 2))
|
||||
ln = add_shape(s, MSO_SHAPE.RECTANGLE, x1, y1,
|
||||
Emu(max(int(x2 - x1), 1000)), Pt(3), self.ds,
|
||||
fill=self.ds.primary)
|
||||
# 点与标签
|
||||
for i, ((px, py), (k, v)) in enumerate(zip(pts, items)):
|
||||
add_shape(s, MSO_SHAPE.OVAL, px - Inches(0.07),
|
||||
py - Inches(0.07), Inches(0.14), Inches(0.14),
|
||||
self.ds, fill=self.ds.chart_colors[i % 6])
|
||||
add_text(s, px - Inches(0.5), py - Inches(0.4), Inches(1.0),
|
||||
Inches(0.3), str(v), self.ds, size=10, bold=True,
|
||||
color=self.ds.text, align=PP_ALIGN.CENTER)
|
||||
add_text(s, px - Inches(0.6), plot_y + plot_h + Inches(0.06),
|
||||
Inches(1.2), Inches(0.3), k, self.ds, size=9,
|
||||
color=self.ds.text_dim, align=PP_ALIGN.CENTER)
|
||||
|
||||
def _draw_pie(self, s, cx, cy, cw, ch, data):
|
||||
items = list(data.items())
|
||||
total = sum(v for _, v in items) or 1
|
||||
r = Inches(1.6)
|
||||
pcx = cx + Inches(3.0)
|
||||
pcy = cy + Inches(2.6)
|
||||
angle = -90.0
|
||||
from pptx.enum.shapes import MSO_SHAPE as MS
|
||||
for i, (k, v) in enumerate(items):
|
||||
frac = v / total
|
||||
sweep = frac * 360.0
|
||||
shp = add_shape(s, MS.PIE, pcx - r, pcy - r, 2 * r, 2 * r,
|
||||
self.ds, fill=self.ds.chart_colors[i % 6])
|
||||
try:
|
||||
shp.adjustments[0] = angle
|
||||
shp.adjustments[1] = angle + sweep
|
||||
except Exception:
|
||||
pass
|
||||
# 标签
|
||||
mid = math.radians(angle + sweep / 2)
|
||||
lx = pcx + Emu(int(r * 0.62 * math.cos(mid))) - Inches(0.5)
|
||||
ly = pcy + Emu(int(r * 0.62 * math.sin(mid))) - Inches(0.15)
|
||||
add_text(s, lx, ly, Inches(1.0), Inches(0.3),
|
||||
f"{k} {v}", self.ds, size=10, bold=True,
|
||||
color=self.ds.text, align=PP_ALIGN.CENTER)
|
||||
angle += sweep
|
||||
|
||||
# ---- timeline
|
||||
def r_timeline(self, p, idx, total):
|
||||
s = self.new()
|
||||
self.header(s, p.get("title", ""), p.get("subtitle"), idx, total,
|
||||
section_no=p.get("section_no"),
|
||||
confidential=p.get("confidential"))
|
||||
items = p.get("content", [])
|
||||
n = len(items)
|
||||
if not n:
|
||||
return
|
||||
x0 = self.margin
|
||||
# 时间线主线位于 body 区域中部
|
||||
body_top = self.ds.layout["body_top"]
|
||||
body_bottom = self.ds.layout["body_bottom"]
|
||||
y_line = Inches(body_top + (body_bottom - body_top) * 0.28)
|
||||
line_w = self.content_w
|
||||
# 渐变主线(深→浅)
|
||||
add_rect(s, x0, y_line, line_w, Pt(3), self.ds, fill=self.ds.line)
|
||||
cw = Emu(int(line_w / n))
|
||||
for i, item in enumerate(items):
|
||||
x = x0 + i * cw
|
||||
cx = x + Inches(0.2)
|
||||
# 双圈节点:外圈描边 + 内圈实心
|
||||
add_shape(s, MSO_SHAPE.OVAL, cx - Inches(0.12), y_line - Inches(0.12),
|
||||
Inches(0.24), Inches(0.24), self.ds,
|
||||
fill=self.ds.bg, line=self.ds.primary)
|
||||
add_shape(s, MSO_SHAPE.OVAL, cx - Inches(0.06), y_line - Inches(0.06),
|
||||
Inches(0.12), Inches(0.12), self.ds,
|
||||
fill=self.ds.primary)
|
||||
# 序号(节点下方小徽章)
|
||||
add_text(s, cx - Inches(0.3), y_line - Inches(0.5), Inches(0.6),
|
||||
Inches(0.25), f"{i+1:02d}", self.ds,
|
||||
size=self.tp('timeline_desc', 9), bold=True,
|
||||
color=self.ds.text_dim, align=PP_ALIGN.CENTER)
|
||||
# 阶段
|
||||
parts = item.split('\n')
|
||||
stage = parts[0] if parts else ""
|
||||
desc = '\n'.join(parts[1:]) if len(parts) > 1 else ""
|
||||
add_text(s, x, y_line + Inches(0.25), cw - Inches(0.1),
|
||||
Inches(0.4), stage, self.ds,
|
||||
size=self.tp('timeline_title', 13), bold=True,
|
||||
color=self.ds.primary, align=PP_ALIGN.LEFT)
|
||||
if desc:
|
||||
add_text(s, x, y_line + Inches(0.65), cw - Inches(0.15),
|
||||
Inches(1.5), desc, self.ds,
|
||||
size=self.tp('timeline_desc', 10),
|
||||
color=self.ds.text_dim, line_spacing=1.3)
|
||||
|
||||
# ---- flowchart(流程/泳道图,参考日企报告会资料)
|
||||
def r_flowchart(self, p, idx, total):
|
||||
s = self.new()
|
||||
self.header(s, p.get("title", ""), p.get("subtitle"), idx, total,
|
||||
section_no=p.get("section_no"),
|
||||
confidential=p.get("confidential"))
|
||||
rows = p.get("rows", []) # 泳道标题列表
|
||||
steps = p.get("content", []) # 步骤列表 ["阶段A", "阶段B", ...]
|
||||
x0, y0 = self.margin, Inches(self.ds.layout["body_top"])
|
||||
cw = self.content_w
|
||||
# 可用高度(到 body_bottom)
|
||||
avail_h = self.ds.layout["body_bottom"] - self.ds.layout["body_top"]
|
||||
if rows:
|
||||
# 泳道模式:rows 为行标题,content 为每行步骤列表
|
||||
rh = Emu(int((Inches(avail_h) - Inches(0.3) * (len(rows) - 1)) / len(rows)))
|
||||
for ri, row_title in enumerate(rows):
|
||||
ry = y0 + ri * (rh + Inches(0.3))
|
||||
# 行标题
|
||||
add_rect(s, x0, ry, Inches(1.5), rh, self.ds,
|
||||
fill=self.ds.primary, radius=True)
|
||||
add_text(s, x0, ry, Inches(1.5), rh, row_title,
|
||||
self.ds, size=self.tp('body_small', 12), bold=True,
|
||||
color=RGBColor(0xFF, 0xFF, 0xFF),
|
||||
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
|
||||
# 该行步骤
|
||||
row_steps = steps[ri] if ri < len(steps) else []
|
||||
if isinstance(row_steps, str):
|
||||
row_steps = [row_steps]
|
||||
lane_w = Emu(int((cw - Inches(1.5) - Inches(0.2)
|
||||
- Inches(0.3) * max(len(row_steps) - 1, 0))
|
||||
/ max(len(row_steps), 1)))
|
||||
for si, step in enumerate(row_steps):
|
||||
sx = x0 + Inches(1.5) + Inches(0.1) + si * (lane_w + Inches(0.3))
|
||||
step_parts = str(step).split('\n')
|
||||
st_title = step_parts[0]
|
||||
st_desc = '\n'.join(step_parts[1:])
|
||||
card = add_rect(s, sx, ry, lane_w, rh, self.ds,
|
||||
fill=self.ds.card_fill, radius=True,
|
||||
shadow=True)
|
||||
title_h = Emu(int(rh * 0.35))
|
||||
# 序号徽章(左上角,小尺寸)
|
||||
badge = add_rect(s, sx + Inches(0.06), ry + Inches(0.08),
|
||||
Inches(0.24), Inches(0.22), self.ds,
|
||||
fill=self.ds.primary, radius=True)
|
||||
bt = badge.text_frame
|
||||
bt.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||||
bp = bt.paragraphs[0]
|
||||
bp.alignment = PP_ALIGN.CENTER
|
||||
br = bp.add_run()
|
||||
br.text = str(si + 1)
|
||||
set_run_font(br, 9, True, RGBColor(0xFF, 0xFF, 0xFF),
|
||||
self.ds.font_cn)
|
||||
# 标题区(徽章右侧开始)
|
||||
add_text(s, sx + Inches(0.38), ry + Inches(0.08),
|
||||
lane_w - Inches(0.5), title_h - Inches(0.1),
|
||||
st_title, self.ds,
|
||||
size=self.tp('body_small', 12), bold=True,
|
||||
color=self.ds.primary,
|
||||
align=PP_ALIGN.CENTER,
|
||||
anchor=MSO_ANCHOR.MIDDLE, line_spacing=1.1)
|
||||
if st_desc:
|
||||
# 描述区:卡片下部 65%(自动换行,垂直居中)
|
||||
desc_h = Emu(int(rh - title_h))
|
||||
add_text(s, sx + Inches(0.08),
|
||||
ry + title_h + Inches(0.04),
|
||||
lane_w - Inches(0.16),
|
||||
desc_h - Inches(0.1),
|
||||
st_desc, self.ds,
|
||||
size=self.tp('timeline_desc', 10),
|
||||
color=self.ds.text_dim,
|
||||
align=PP_ALIGN.CENTER,
|
||||
anchor=MSO_ANCHOR.MIDDLE, line_spacing=1.2)
|
||||
if si < len(row_steps) - 1:
|
||||
add_shape(s, MSO_SHAPE.RIGHT_ARROW,
|
||||
sx + lane_w + Inches(0.04),
|
||||
ry + Emu(int(rh / 2)) - Inches(0.1),
|
||||
Inches(0.22), Inches(0.2),
|
||||
self.ds, fill=self.ds.secondary)
|
||||
else:
|
||||
# 横向流程模式:steps 为 ["阶段A|描述", ...]
|
||||
n = len(steps)
|
||||
if not n:
|
||||
return
|
||||
# 步骤多时自动分行(每行最多4个)
|
||||
per_row = 4 if n > 4 else n
|
||||
n_rows = (n + per_row - 1) // per_row
|
||||
bh = Inches(min(1.3, (avail_h - 0.35 * (n_rows - 1)) / n_rows))
|
||||
row_gap = Inches(0.35)
|
||||
total_h = n_rows * bh + (n_rows - 1) * row_gap
|
||||
by0 = y0 + Emu(int((Inches(avail_h) - total_h) / 2))
|
||||
bw = Emu(int((cw - Inches(0.35) * (per_row - 1)) / per_row))
|
||||
for i, step in enumerate(steps):
|
||||
r_i, c_i = divmod(i, per_row)
|
||||
bx = x0 + c_i * (bw + Inches(0.35))
|
||||
by = by0 + r_i * (bh + row_gap)
|
||||
parts = str(step).split('\n')
|
||||
st_title = parts[0]
|
||||
st_desc = '\n'.join(parts[1:])
|
||||
card = add_rect(s, bx, by, bw, bh, self.ds,
|
||||
fill=self.ds.card_fill, radius=True,
|
||||
shadow=True)
|
||||
# 标题区 40%
|
||||
title_h = Emu(int(bh * 0.4))
|
||||
# 序号徽章(左上角,小尺寸)
|
||||
badge = add_rect(s, bx + Inches(0.06), by + Inches(0.08),
|
||||
Inches(0.24), Inches(0.22), self.ds,
|
||||
fill=self.ds.primary, radius=True)
|
||||
bt = badge.text_frame
|
||||
bt.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||||
bp = bt.paragraphs[0]
|
||||
bp.alignment = PP_ALIGN.CENTER
|
||||
br = bp.add_run()
|
||||
br.text = str(i + 1)
|
||||
set_run_font(br, 9, True, RGBColor(0xFF, 0xFF, 0xFF),
|
||||
self.ds.font_cn)
|
||||
# 标题(徽章右侧开始,避免重叠)——窄卡片自动缩小字号
|
||||
title_sz = 12
|
||||
desc_sz = 10
|
||||
if bw < Inches(1.6):
|
||||
title_sz = 9
|
||||
desc_sz = 8
|
||||
elif bw < Inches(2.2):
|
||||
title_sz = 10
|
||||
desc_sz = 9
|
||||
add_text(s, bx + Inches(0.38), by + Inches(0.08),
|
||||
bw - Inches(0.48), title_h - Inches(0.12),
|
||||
st_title, self.ds, size=title_sz, bold=True,
|
||||
color=self.ds.primary, align=PP_ALIGN.CENTER,
|
||||
anchor=MSO_ANCHOR.MIDDLE, line_spacing=1.05)
|
||||
if st_desc:
|
||||
add_text(s, bx + Inches(0.08), by + title_h + Inches(0.04),
|
||||
bw - Inches(0.16), bh - title_h - Inches(0.1),
|
||||
st_desc, self.ds, size=desc_sz,
|
||||
color=self.ds.text_dim,
|
||||
align=PP_ALIGN.CENTER,
|
||||
anchor=MSO_ANCHOR.MIDDLE, line_spacing=1.15)
|
||||
if c_i < per_row - 1 and i < n - 1:
|
||||
add_shape(s, MSO_SHAPE.RIGHT_ARROW,
|
||||
bx + bw + Inches(0.04),
|
||||
by + Emu(int(bh / 2)) - Inches(0.1),
|
||||
Inches(0.27), Inches(0.2),
|
||||
self.ds, fill=self.ds.secondary)
|
||||
|
||||
# ---- quote
|
||||
def r_quote(self, p, idx, total):
|
||||
s = self.new()
|
||||
pw = self.ds.page_w
|
||||
# 左侧竖条 + 引文区(动态宽度,适配10.83in窄页面)
|
||||
bar_x = self.margin
|
||||
text_x = Emu(int(self.margin + Inches(0.6)))
|
||||
text_w = Emu(int(pw - text_x - self.margin))
|
||||
add_rect(s, bar_x, Inches(2.3), Inches(0.09), Inches(2.2),
|
||||
self.ds, fill=self.ds.primary)
|
||||
title = p.get("title", "")
|
||||
# 引文字号按长度自适应
|
||||
if len(title) > 60:
|
||||
title_sz = 18
|
||||
elif len(title) > 40:
|
||||
title_sz = 20
|
||||
else:
|
||||
title_sz = 24
|
||||
add_text(s, text_x, Inches(2.6), text_w, Inches(1.6),
|
||||
title, self.ds, size=title_sz, bold=True,
|
||||
line_spacing=1.35)
|
||||
if p.get("content"):
|
||||
add_text(s, text_x, Inches(4.3), text_w, Inches(1.0),
|
||||
"\n".join(p["content"]), self.ds,
|
||||
size=self.tp('body', 15), color=self.ds.text_dim)
|
||||
if p.get("notes"):
|
||||
add_text(s, text_x, Inches(5.4), text_w, Inches(0.4),
|
||||
p["notes"], self.ds,
|
||||
size=self.tp('timeline_title', 13), bold=True,
|
||||
color=self.ds.accent)
|
||||
|
||||
# ---- end
|
||||
def r_end(self, p, idx, total):
|
||||
s = self.new()
|
||||
pw = self.ds.page_w
|
||||
title_w = Emu(int(pw - 2 * self.margin))
|
||||
title = p.get("title", "谢谢")
|
||||
# 标题字号按长度自适应(长句缩小,避免溢出)
|
||||
title_len = len(title)
|
||||
if title_len > 28:
|
||||
title_sz = 22
|
||||
elif title_len > 18:
|
||||
title_sz = 26
|
||||
else:
|
||||
title_sz = 34
|
||||
# 标题区高度也自适应(最长支持2-3行)
|
||||
title_h = Inches(1.6)
|
||||
add_text(s, self.margin, Inches(2.7), title_w, title_h,
|
||||
title, self.ds, size=title_sz, bold=True,
|
||||
align=PP_ALIGN.CENTER, line_spacing=1.3)
|
||||
if p.get("content"):
|
||||
add_text(s, self.margin, Inches(4.5),
|
||||
Emu(int(title_w - Inches(0.5))), Inches(1.2),
|
||||
"\n".join(p["content"]), self.ds, size=16,
|
||||
color=self.ds.text_dim, align=PP_ALIGN.CENTER)
|
||||
add_rect(s, self.margin, Inches(5.7), Inches(2.8), Pt(2.5),
|
||||
self.ds, fill=self.ds.primary)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 内容保真验证
|
||||
def verify_fidelity(plan_path, pptx_path, report_only=False):
|
||||
"""验证输出PPT中用户提供的数字/术语是否原样保留"""
|
||||
with open(plan_path, "r", encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
prs = Presentation(pptx_path)
|
||||
# 收集源数据(数字+关键术语)
|
||||
source_numbers = set()
|
||||
source_text = []
|
||||
def collect(obj, skip_keys=None):
|
||||
skip_keys = skip_keys or {"notes", "image"}
|
||||
if isinstance(obj, dict):
|
||||
for k, v in obj.items():
|
||||
if k in skip_keys:
|
||||
continue # notes 是演讲者备注;image 是本地配图路径,均不属于内容保真范围
|
||||
collect(v)
|
||||
elif isinstance(obj, list):
|
||||
for v in obj:
|
||||
collect(v)
|
||||
elif isinstance(obj, str):
|
||||
source_text.append(obj)
|
||||
for m in re.findall(r'-?\d+[\d,]*\.?\d*\s*[%%]?', obj):
|
||||
m = m.strip()
|
||||
# 排除目录编号/序号(如 "01."、"1." 紧跟标点或行首)
|
||||
if re.fullmatch(r'\d{1,2}\.?\s*', m):
|
||||
continue
|
||||
source_numbers.add(m)
|
||||
collect(plan.get("slides", []))
|
||||
# 注意:plan 顶层 title 是元数据(封面等用页面级 title),不纳入术语保真检查
|
||||
|
||||
# 收集输出文本
|
||||
out_text = []
|
||||
for slide in prs.slides:
|
||||
for shp in slide.shapes:
|
||||
if shp.has_text_frame:
|
||||
out_text.append(shp.text_frame.text)
|
||||
out_all = "\n".join(out_text)
|
||||
|
||||
# 数字保真检查
|
||||
missing = []
|
||||
for num in source_numbers:
|
||||
if num.replace(' ', '') not in out_all.replace(' ', ''):
|
||||
missing.append(num)
|
||||
# 术语检查(非纯数字的内容片段,取较长的)
|
||||
term_missing = []
|
||||
# 已知的合法结构变换:渲染器会拆分目录编号、去掉"-"子项前缀
|
||||
def normalize(s):
|
||||
s = re.sub(r'^\d{1,2}\.\s*', '', s) # 去目录编号 "01. "
|
||||
s = re.sub(r'^-\s*', '', s) # 去列表前缀 "- "
|
||||
s = s.replace('**', '')
|
||||
return s.strip()
|
||||
|
||||
for t in source_text:
|
||||
t = t.strip()
|
||||
if len(t) < 6 or not re.search(r'[\u4e00-\u9fff\u3040-\u30ff]', t):
|
||||
continue
|
||||
if '\n' in t or '**' in t:
|
||||
continue
|
||||
norm = normalize(t)
|
||||
if not norm:
|
||||
continue
|
||||
# 输出中必须能找到该术语的规范化形式(整体或核心子串)
|
||||
out_norm = normalize(out_all)
|
||||
if norm in out_all or norm in out_norm:
|
||||
continue
|
||||
# 宽松匹配:取前8个字符作为指纹
|
||||
fingerprint = norm[:8]
|
||||
if fingerprint and fingerprint in out_norm:
|
||||
continue
|
||||
term_missing.append(t)
|
||||
|
||||
# notes 写入检查:plan 中有 notes 的页面,输出备注区必须非空
|
||||
missing_notes = []
|
||||
plan_slides = plan.get("slides", [])
|
||||
out_slides = list(prs.slides)
|
||||
for idx, sp in enumerate(plan_slides):
|
||||
if not sp.get("notes"):
|
||||
continue
|
||||
if idx < len(out_slides):
|
||||
slide = out_slides[idx]
|
||||
try:
|
||||
if not (slide.has_notes_slide and
|
||||
slide.notes_slide.notes_text_frame.text.strip()):
|
||||
missing_notes.append(idx + 1)
|
||||
except Exception:
|
||||
missing_notes.append(idx + 1)
|
||||
if report_only:
|
||||
return {"missing_numbers": missing[:10],
|
||||
"missing_terms": term_missing[:10],
|
||||
"missing_notes": missing_notes,
|
||||
"total_slides": len(prs.slides)}
|
||||
return (len(missing) == 0 and len(term_missing) == 0
|
||||
and len(missing_notes) == 0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 主流程
|
||||
def get_design(design_name="aura-dark"):
|
||||
"""从注册表获取设计系统实例;未知名称回退到 aura-dark"""
|
||||
cls = DESIGN_SYSTEMS.get(design_name, DesignSystem)
|
||||
return cls()
|
||||
|
||||
|
||||
def render(plan_path, output_path, design_name="aura-dark",
|
||||
template_path=None):
|
||||
with open(plan_path, "r", encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
# plan.json 中的 design 字段优先(未指定时用 CLI 参数)
|
||||
if plan.get("design"):
|
||||
design_name = plan["design"]
|
||||
ds = get_design(design_name)
|
||||
# 模板模式:用指定PPTX作为母版模板(继承背景/页脚/Logo)
|
||||
using_template = template_path is not None
|
||||
if using_template:
|
||||
prs = Presentation(template_path)
|
||||
# 清空模板自带的旧幻灯片(只保留母版/版式)
|
||||
# 通过 drop_rel 移除旧 slide 关系 + 移除 sldIdLst 引用
|
||||
from pptx.oxml.ns import qn as _qn
|
||||
sldIdLst = prs.slides._sldIdLst
|
||||
for sldId in list(sldIdLst):
|
||||
rId = sldId.get(_qn('r:id'))
|
||||
sldIdLst.remove(sldId)
|
||||
if rId:
|
||||
try:
|
||||
prs.part.drop_rel(rId)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
prs = Presentation()
|
||||
prs.slide_width = ds.page_w
|
||||
prs.slide_height = ds.page_h
|
||||
renderer = SlideRenderer(prs, ds, template=using_template)
|
||||
slides = plan.get("slides", [])
|
||||
total = len(slides)
|
||||
company = plan.get("company", "")
|
||||
date_text = plan.get("date", "")
|
||||
notes_template = plan.get("notes_template", False)
|
||||
global_confidential = plan.get("confidential", "")
|
||||
for i, sp in enumerate(slides, 1):
|
||||
# 顶层机密标记注入页面(页面级字段优先)
|
||||
sp = dict(sp)
|
||||
if "confidential" not in sp and global_confidential:
|
||||
sp["confidential"] = global_confidential
|
||||
renderer.render(sp, i, total, company=company,
|
||||
date_text=date_text, notes_template=notes_template)
|
||||
prs.save(output_path)
|
||||
return total
|
||||
|
||||
|
||||
def improve(input_path, output_path, design_name="aura-dark"):
|
||||
"""美化已有PPT:统一背景、标题样式、正文排版(内容零改动)"""
|
||||
ds = get_design(design_name)
|
||||
prs = Presentation(input_path)
|
||||
for slide in prs.slides:
|
||||
# 统一背景
|
||||
bg = slide.background
|
||||
bg.fill.solid()
|
||||
bg.fill.fore_color.rgb = ds.bg
|
||||
for shp in slide.shapes:
|
||||
if shp.has_text_frame and shp.text_frame.text.strip():
|
||||
tf = shp.text_frame
|
||||
text = tf.text
|
||||
font_size = None
|
||||
for p in tf.paragraphs:
|
||||
for r in p.runs:
|
||||
if r.font.size:
|
||||
font_size = r.font.size.pt
|
||||
break
|
||||
if font_size:
|
||||
break
|
||||
size = font_size or 16
|
||||
# 重设字体
|
||||
for p in tf.paragraphs:
|
||||
p.line_spacing = 1.3
|
||||
for r in p.runs:
|
||||
r.font.name = ds.font_cn
|
||||
rPr = r._r.get_or_add_rPr()
|
||||
for tag in ('a:ea', 'a:cs'):
|
||||
el = rPr.find(qn(tag))
|
||||
if el is None:
|
||||
el = rPr.makeelement(qn(tag), {})
|
||||
rPr.append(el)
|
||||
el.set('typeface', ds.font_cn)
|
||||
if r.font.size is None:
|
||||
r.font.size = Pt(size)
|
||||
if r.font.color is None or \
|
||||
r.font.color.rgb == RGBColor(0, 0, 0):
|
||||
r.font.color.rgb = ds.text
|
||||
prs.save(output_path)
|
||||
return len(prs.slides)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="aura-ppt 渲染引擎")
|
||||
ap.add_argument("--plan", help="plan JSON 路径")
|
||||
ap.add_argument("--output", help="输出 PPTX 路径")
|
||||
ap.add_argument("--improve", help="美化模式:输入 PPTX 路径")
|
||||
ap.add_argument("--design", default="aura-dark", help="设计系统名称")
|
||||
ap.add_argument("--template", help="模板PPTX路径(继承其母版背景/页脚/Logo)")
|
||||
ap.add_argument("--verify", help="验证模式:输出 PPTX 路径")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.verify:
|
||||
if not args.plan:
|
||||
print("[aura-ppt] --verify 需要 --plan 配合")
|
||||
sys.exit(1)
|
||||
result = verify_fidelity(args.plan, args.verify, report_only=True)
|
||||
print(f"[aura-ppt] 验证完成: {result['total_slides']} 页")
|
||||
if result["missing_numbers"]:
|
||||
print(f"[aura-ppt] ⚠ 数字保真失败: {result['missing_numbers']}")
|
||||
sys.exit(2)
|
||||
if result["missing_terms"]:
|
||||
print(f"[aura-ppt] ⚠ 术语保真失败: {result['missing_terms']}")
|
||||
sys.exit(3)
|
||||
if result.get("missing_notes"):
|
||||
print(f"[aura-ppt] ⚠ 备注缺失: {result['missing_notes']}")
|
||||
sys.exit(4)
|
||||
print("[aura-ppt] ✅ 内容保真验证通过(数字+术语+备注全部完整)")
|
||||
sys.exit(0)
|
||||
|
||||
if args.improve:
|
||||
if not args.output:
|
||||
print("[aura-ppt] 美化模式需要 --output")
|
||||
sys.exit(1)
|
||||
n = improve(args.improve, args.output, args.design)
|
||||
print(f"[aura-ppt] 美化完成: {n} 页 → {args.output}")
|
||||
sys.exit(0)
|
||||
|
||||
if not args.plan or not args.output:
|
||||
ap.print_help()
|
||||
sys.exit(1)
|
||||
n = render(args.plan, args.output, args.design, args.template)
|
||||
mode = "模板生成" if args.template else "生成"
|
||||
print(f"[aura-ppt] {mode}完成: {n} 页 → {args.output}")
|
||||
if os.path.exists(args.plan):
|
||||
ok = verify_fidelity(args.plan, args.output)
|
||||
if not ok:
|
||||
print("[aura-ppt] ⚠ 注意: 检测到内容保真偏差,请运行 --verify 查看详情")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,93 @@
|
||||
# aura-ppt 技能包使用说明
|
||||
|
||||
> 本地AI演示文稿引擎 | 综合 Gamma 设计感 / WPS 中文理解 / 7牛 内容保真 / Beautiful.ai 无损导出
|
||||
|
||||
## 安装位置
|
||||
|
||||
```
|
||||
C:\Users\xunhe\.config\opencode\skills\aura-ppt\
|
||||
├── SKILL.md ← 技能指令(触发条件/工作流/内容保真规则)
|
||||
├── references/
|
||||
│ ├── chinese-typography.md ← 中文排版规范
|
||||
│ ├── content-fidelity.md ← 内容保真规则(数据零改写)
|
||||
│ └── design-system.md ← 设计系统 + 全局视觉规范
|
||||
└── scripts/
|
||||
└── engine.py ← 渲染引擎(1600+行,全部能力实现)
|
||||
```
|
||||
|
||||
备份副本:`D:\AIDEPT\讯和能力知识库构建\aura-ppt\`(目录)+ `aura-ppt技能包.zip`
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 1. 创建 plan.json(见下方结构)
|
||||
# 2. 生成 PPT
|
||||
python scripts/engine.py --plan plan.json --output out.pptx
|
||||
|
||||
# 3. 验证(必须执行)
|
||||
python scripts/engine.py --verify out.pptx --plan plan.json
|
||||
```
|
||||
|
||||
## 三种设计系统
|
||||
|
||||
| 系统名 | 风格 | 适用 | 比例 |
|
||||
|---|---|---|---|
|
||||
| `business-report` | 火焰蓝商务(白底+火焰蓝+红) | 中文管理层汇报 | 16:9 |
|
||||
| `report-jp` | 日式商务(法人蓝+浅天蓝卡片+Meiryo UI) | 日本客户/总部 | 10.83×7.5 |
|
||||
| `aura-dark` | 深色高级(深蓝黑+电光青) | 科技感/发布会 | 16:9 |
|
||||
|
||||
plan.json 的 `design` 字段选择。
|
||||
|
||||
## 使用模板母版
|
||||
|
||||
```bash
|
||||
python scripts/engine.py --plan plan.json --output out.pptx \
|
||||
--template "公司模板.pptx" # 继承母版背景/页脚/Logo
|
||||
```
|
||||
|
||||
## plan.json 结构
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "标题",
|
||||
"design": "business-report",
|
||||
"notes_template": true,
|
||||
"slides": [
|
||||
{"type": "cover", "title": "主标题", "stats": [["70%", "说明"]]},
|
||||
{"type": "toc", "content": ["01. 章节", "02. 章节"]},
|
||||
{"type": "content", "content": ["**要点标题** | 描述"]},
|
||||
{"type": "table", "data": [[...]], "charts": [{"type":"bar","data":{}}]},
|
||||
{"type": "two_table", "tables": [{"title":"表1","data":[[...]]}, {"title":"表2","data":[[...]]}]},
|
||||
{"type": "chart", "chart": {"type": "bar", "data": {}}},
|
||||
{"type": "flowchart", "rows": ["泳道1"], "content": [["步骤A", "步骤B"]]},
|
||||
{"type": "timeline", "content": ["Phase 0\n描述"]},
|
||||
{"type": "quote", "title": "引言"},
|
||||
{"type": "end", "title": "谢谢"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 全部能力清单
|
||||
|
||||
- **封面大数字墙**(stats):右侧数字卡片(70%/6个/30-50%)
|
||||
- **要点卡片**:content 中 `**标题** | 描述` 自动渲染卡片网格
|
||||
- **双表并排**(two_table):左右表格,表头/字号自适应
|
||||
- **表格+图表混合**:table 支持 chart/charts 字段
|
||||
- **渐变柱体**:柱状图垂直渐变+网格线
|
||||
- **卡片投影**:所有卡片柔和阴影
|
||||
- **泳道流程图**:3层泳道 / 横向流程(窄卡自动缩字号)
|
||||
- **时间线**:双圈节点
|
||||
- **内容保真验证**:数字+术语+备注三合一
|
||||
- **演讲者备注**:自动生成(主题/数据/要点/过渡)
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Q1: 数据会被AI改写吗?** 不会。渲染引擎是纯代码,plan.json 中的数据逐字符保留,验证器强制校验。
|
||||
|
||||
**Q2: 能处理日文吗?** 能。report-jp 用 Meiryo UI,日文标题/表格自适应。
|
||||
|
||||
**Q3: 输出能编辑吗?** 能。python-pptx 输出标准 PPTX,全矢量可编辑。
|
||||
|
||||
**Q4: 需要网络/API吗?** 不需要。纯本地 Python(python-pptx)。
|
||||
|
||||
**Q5: 如何加新设计系统?** 在 engine.py 的 DESIGN_SYSTEMS 注册表扩展(继承 DesignSystem 覆写令牌)。
|
||||
Reference in New Issue
Block a user