中期成果提交:PPT自动生成 Agent(感知-规划-行动-记忆闭环 + aura-ppt 渲染引擎 + Web 交互界面 + 测试用例 + AI 使用日志)

This commit is contained in:
T1-AISUYISIN
2026-08-31 09:53:11 +08:00
commit 883a89682b
50 changed files with 10355 additions and 0 deletions
+311
View File
@@ -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/做路演材料"等。支持四种模式:全流程生成(从
大纲到成品)、文档转PPTWord/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
# 方式1CSV/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吗?**
不需要。渲染引擎是纯本地Pythonpython-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不可以:
- ❌ 改用户的数据、术语、引用、限定词
- ❌ 编造用户未提供的信息
+296
View File
@@ -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`)。
+1870
View File
@@ -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.5blue/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
# 设置渐变填充(通过 XMLgradFill
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)
# 插入到 spPrfill 应位于 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()
+93
View File
@@ -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吗?** 不需要。纯本地 Pythonpython-pptx)。
**Q5: 如何加新设计系统?** 在 engine.py 的 DESIGN_SYSTEMS 注册表扩展(继承 DesignSystem 覆写令牌)。