feat: improve skill import and runtime delivery
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
---
|
||||
name: product-presentation
|
||||
version: 1.0.0
|
||||
description: |
|
||||
生成面向特定受众的产品介绍 PPT、逐页叙事和演讲备注。用于产品发布、客户宣讲、
|
||||
售前交流或内部汇报;不负责现场产品操作步骤,操作型演示应使用 sales-demo-kit。
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
- Execute
|
||||
compatibility: 生成 PPTX 需 python-pptx 1.0+
|
||||
---
|
||||
|
||||
# 产品介绍 PPT
|
||||
|
||||
## 必要输入
|
||||
|
||||
- 已核验的产品事实、功能、参数、案例授权和适用边界。
|
||||
- 受众角色、演示目标、场合、时长、页数和品牌要求。
|
||||
- 可用图片、架构图、截图及其授权范围。
|
||||
|
||||
先写一句演示目标,例如“让技术负责人理解部署边界并同意进入 POC”,再决定页序。
|
||||
|
||||
## 推荐叙事
|
||||
|
||||
按任务选择,不机械套用全部页面:
|
||||
|
||||
1. 封面与本次议题。
|
||||
2. 受众当前面临的具体问题。
|
||||
3. 产品定位和适用范围。
|
||||
4. 核心工作流或架构。
|
||||
5. 关键能力,按场景组织,不按后台菜单朗读。
|
||||
6. 已核验参数、兼容性或安全边界。
|
||||
7. 获准公开的案例或验证结果。
|
||||
8. 部署、交付和下一步。
|
||||
|
||||
## 页面纪律
|
||||
|
||||
- 一页只有一个结论,标题直接写该页内容。
|
||||
- 正文优先 3 至 5 个要点,每个要点只表达一个信息。
|
||||
- 数字必须带条件和来源,不使用无依据的百分比。
|
||||
- 规划能力、试用能力和当前能力使用不同标识。
|
||||
- 讲稿可以补充上下文,但不能引入幻灯片中没有依据的新事实。
|
||||
- 不把产品介绍写成按钮操作手册,也不使用满页功能清单。
|
||||
|
||||
## 生成 PPTX
|
||||
|
||||
先探测可用的 Python 3 解释器:Windows 优先使用 `python`,macOS/Linux
|
||||
优先使用 `python3`。下文 `<python>` 表示探测成功的解释器命令。
|
||||
|
||||
复制并填写 `templates/deck.example.json`:
|
||||
|
||||
```bash
|
||||
<python> -m pip install -r "<skill-dir>/requirements.txt"
|
||||
<python> "<skill-dir>/scripts/build_pptx.py" \
|
||||
--input ./build/deck.json \
|
||||
--output ./dist/product-presentation.pptx
|
||||
```
|
||||
|
||||
`deck.json` 与配图是中间产物,放在 `build/`;交付物 PPTX 放在 `dist/`。复核用
|
||||
PDF 和页面截图一律写入 `build/check/`,不得与 PPTX 同目录。
|
||||
|
||||
构建器支持封面、章节页、要点页、双栏页、指标页、图片页和收尾页。它负责稳定
|
||||
排版,不负责补写内容;`deck.json` 中的文字必须先通过事实审查。
|
||||
|
||||
除章节页外每页必须提供非空 `notes`,否则构建失败。图片必须放在 `deck.json`
|
||||
所在目录内,超过 40MB 或 8000 万像素会被拒绝;其余图片按版面尺寸和 150 DPI
|
||||
重采样后嵌入,避免生成超大文件。
|
||||
|
||||
## 视觉复核
|
||||
|
||||
1. 使用 LibreOffice 或 PowerPoint 打开并导出 PDF;该 PDF 仅用于复核,交付物是
|
||||
PPTX,复核后应删除。LibreOffice 需安装 Impress 组件,仅装 Writer 时无法转换。
|
||||
2. 检查文字溢出、孤行、图片拉伸、低清截图和字号过小。
|
||||
3. 快速朗读全套讲稿,确认时间预算和页面转场自然。
|
||||
4. 核对所有数字、版本、案例名称和产品状态与事实清单一致。
|
||||
|
||||
## 完成标准
|
||||
|
||||
PPTX 可打开;页数和时长符合简报;每页目标明确;视觉层级统一;备注完整;无
|
||||
未经授权的客户信息、竞品结论或内部证据路径。
|
||||
@@ -0,0 +1 @@
|
||||
python-pptx>=1.0,<2
|
||||
@@ -0,0 +1,590 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a 16:9 product presentation from a reviewed deck JSON file."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
from pptx import Presentation
|
||||
from pptx.dml.color import RGBColor
|
||||
from pptx.enum.shapes import MSO_SHAPE
|
||||
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
|
||||
from pptx.util import Inches, Pt
|
||||
|
||||
|
||||
DEFAULT_THEME = {
|
||||
"primary": "1F3864",
|
||||
"accent": "C00000",
|
||||
"background": "FFFFFF",
|
||||
"text": "1A1A1A",
|
||||
"muted": "666666",
|
||||
"light": "F2F5F9",
|
||||
"font_zh": "Microsoft YaHei",
|
||||
"font_en": "Arial",
|
||||
}
|
||||
SLIDE_TYPES = {
|
||||
"title",
|
||||
"section",
|
||||
"bullets",
|
||||
"two-column",
|
||||
"metrics",
|
||||
"image",
|
||||
"closing",
|
||||
}
|
||||
NOTES_OPTIONAL_TYPES = {"section"}
|
||||
MAX_IMAGE_BYTES = 40 * 1024 * 1024
|
||||
MAX_IMAGE_PIXELS = 80_000_000
|
||||
RENDER_DPI = 150
|
||||
|
||||
|
||||
def color(value):
|
||||
value = value.lstrip("#")
|
||||
if not re.fullmatch(r"[0-9a-fA-F]{6}", value):
|
||||
raise ValueError(f"颜色必须是六位十六进制值:{value!r}")
|
||||
return RGBColor.from_string(value.upper())
|
||||
|
||||
|
||||
def nonempty(value, path):
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(f"{path} 必须是非空字符串")
|
||||
return value.strip()
|
||||
|
||||
|
||||
def load_deck(path):
|
||||
input_path = Path(path).expanduser().resolve()
|
||||
with input_path.open(encoding="utf-8") as handle:
|
||||
deck = json.load(handle)
|
||||
if not isinstance(deck, dict):
|
||||
raise ValueError("deck 根节点必须是对象")
|
||||
nonempty(deck.get("title"), "title")
|
||||
slides = deck.get("slides")
|
||||
if not isinstance(slides, list) or not slides:
|
||||
raise ValueError("slides 必须是非空数组")
|
||||
theme = {**DEFAULT_THEME, **deck.get("theme", {})}
|
||||
for key in ("primary", "accent", "background", "text", "muted", "light"):
|
||||
color(theme[key])
|
||||
for index, slide in enumerate(slides):
|
||||
validate_slide(slide, index)
|
||||
deck["theme"] = theme
|
||||
return input_path, deck
|
||||
|
||||
|
||||
def validate_bullets(values, path, maximum=6):
|
||||
if not isinstance(values, list) or not values:
|
||||
raise ValueError(f"{path} 必须是非空数组")
|
||||
if len(values) > maximum:
|
||||
raise ValueError(f"{path} 最多 {maximum} 项")
|
||||
for index, value in enumerate(values):
|
||||
text = nonempty(value, f"{path}[{index}]")
|
||||
if len(text) > 120:
|
||||
raise ValueError(f"{path}[{index}] 超过 120 字")
|
||||
|
||||
|
||||
def validate_slide(slide, index):
|
||||
path = f"slides[{index}]"
|
||||
if not isinstance(slide, dict):
|
||||
raise ValueError(f"{path} 必须是对象")
|
||||
slide_type = slide.get("type")
|
||||
if slide_type not in SLIDE_TYPES:
|
||||
raise ValueError(f"{path}.type 未知:{slide_type!r}")
|
||||
nonempty(slide.get("title"), f"{path}.title")
|
||||
if slide_type in {"bullets", "closing"}:
|
||||
validate_bullets(slide.get("bullets"), f"{path}.bullets")
|
||||
elif slide_type == "two-column":
|
||||
for side in ("left", "right"):
|
||||
column = slide.get(side)
|
||||
if not isinstance(column, dict):
|
||||
raise ValueError(f"{path}.{side} 必须是对象")
|
||||
nonempty(column.get("title"), f"{path}.{side}.title")
|
||||
validate_bullets(
|
||||
column.get("bullets"),
|
||||
f"{path}.{side}.bullets",
|
||||
maximum=5,
|
||||
)
|
||||
elif slide_type == "metrics":
|
||||
metrics = slide.get("metrics")
|
||||
if not isinstance(metrics, list) or not 1 <= len(metrics) <= 4:
|
||||
raise ValueError(f"{path}.metrics 必须包含 1 至 4 项")
|
||||
for metric_index, metric in enumerate(metrics):
|
||||
if not isinstance(metric, dict):
|
||||
raise ValueError(
|
||||
f"{path}.metrics[{metric_index}] 必须是对象"
|
||||
)
|
||||
for field in ("value", "label", "detail"):
|
||||
nonempty(
|
||||
metric.get(field),
|
||||
f"{path}.metrics[{metric_index}].{field}",
|
||||
)
|
||||
elif slide_type == "image":
|
||||
nonempty(slide.get("image"), f"{path}.image")
|
||||
if slide_type not in NOTES_OPTIONAL_TYPES:
|
||||
nonempty(slide.get("notes"), f"{path}.notes")
|
||||
|
||||
|
||||
def add_run_font(run, theme, size, bold=False, color_value=None):
|
||||
run.font.name = theme["font_en"]
|
||||
run.font.size = Pt(size)
|
||||
run.font.bold = bold
|
||||
run.font.color.rgb = color(color_value or theme["text"])
|
||||
run.font._element.set("lang", "zh-CN")
|
||||
|
||||
|
||||
def add_textbox(
|
||||
slide,
|
||||
theme,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
text="",
|
||||
size=24,
|
||||
bold=False,
|
||||
color_value=None,
|
||||
align=PP_ALIGN.LEFT,
|
||||
vertical=MSO_ANCHOR.TOP,
|
||||
margin=0.08,
|
||||
):
|
||||
shape = slide.shapes.add_textbox(
|
||||
Inches(x),
|
||||
Inches(y),
|
||||
Inches(width),
|
||||
Inches(height),
|
||||
)
|
||||
frame = shape.text_frame
|
||||
frame.clear()
|
||||
frame.margin_left = Inches(margin)
|
||||
frame.margin_right = Inches(margin)
|
||||
frame.margin_top = Inches(margin)
|
||||
frame.margin_bottom = Inches(margin)
|
||||
frame.vertical_anchor = vertical
|
||||
paragraph = frame.paragraphs[0]
|
||||
paragraph.alignment = align
|
||||
run = paragraph.add_run()
|
||||
run.text = text
|
||||
add_run_font(run, theme, size, bold=bold, color_value=color_value)
|
||||
return shape
|
||||
|
||||
|
||||
def set_background(slide, theme, key="background"):
|
||||
fill = slide.background.fill
|
||||
fill.solid()
|
||||
fill.fore_color.rgb = color(theme[key])
|
||||
|
||||
|
||||
def add_header(slide, theme, title):
|
||||
add_textbox(
|
||||
slide,
|
||||
theme,
|
||||
0.65,
|
||||
0.35,
|
||||
11.9,
|
||||
0.65,
|
||||
title,
|
||||
size=26,
|
||||
bold=True,
|
||||
color_value=theme["primary"],
|
||||
)
|
||||
line = slide.shapes.add_shape(
|
||||
MSO_SHAPE.RECTANGLE,
|
||||
Inches(0.65),
|
||||
Inches(1.07),
|
||||
Inches(1.15),
|
||||
Inches(0.06),
|
||||
)
|
||||
line.fill.solid()
|
||||
line.fill.fore_color.rgb = color(theme["accent"])
|
||||
line.line.fill.background()
|
||||
|
||||
|
||||
def add_footer(slide, deck, number):
|
||||
footer = deck.get("footer", "")
|
||||
theme = deck["theme"]
|
||||
if footer:
|
||||
add_textbox(
|
||||
slide,
|
||||
theme,
|
||||
0.65,
|
||||
7.08,
|
||||
10.8,
|
||||
0.22,
|
||||
footer,
|
||||
size=8.5,
|
||||
color_value=theme["muted"],
|
||||
)
|
||||
add_textbox(
|
||||
slide,
|
||||
theme,
|
||||
12.0,
|
||||
7.02,
|
||||
0.55,
|
||||
0.25,
|
||||
str(number),
|
||||
size=9,
|
||||
color_value=theme["muted"],
|
||||
align=PP_ALIGN.RIGHT,
|
||||
)
|
||||
|
||||
|
||||
def add_bullet_frame(slide, theme, x, y, width, height, bullets, size=21):
|
||||
shape = slide.shapes.add_textbox(
|
||||
Inches(x),
|
||||
Inches(y),
|
||||
Inches(width),
|
||||
Inches(height),
|
||||
)
|
||||
frame = shape.text_frame
|
||||
frame.clear()
|
||||
frame.word_wrap = True
|
||||
frame.margin_left = Inches(0.12)
|
||||
frame.margin_right = Inches(0.08)
|
||||
for index, item in enumerate(bullets):
|
||||
paragraph = frame.paragraphs[0] if index == 0 else frame.add_paragraph()
|
||||
paragraph.text = item
|
||||
paragraph.level = 0
|
||||
paragraph.space_after = Pt(12)
|
||||
paragraph.line_spacing = 1.12
|
||||
paragraph.font.size = Pt(size)
|
||||
paragraph.font.name = theme["font_en"]
|
||||
paragraph.font.color.rgb = color(theme["text"])
|
||||
return shape
|
||||
|
||||
|
||||
def add_notes(slide, notes):
|
||||
if not notes:
|
||||
return
|
||||
notes_frame = slide.notes_slide.notes_text_frame
|
||||
notes_frame.text = notes
|
||||
|
||||
|
||||
def render_title(slide, deck, item):
|
||||
theme = deck["theme"]
|
||||
set_background(slide, theme, "primary")
|
||||
add_textbox(
|
||||
slide,
|
||||
theme,
|
||||
0.9,
|
||||
1.75,
|
||||
11.5,
|
||||
1.3,
|
||||
item["title"],
|
||||
size=34,
|
||||
bold=True,
|
||||
color_value="FFFFFF",
|
||||
vertical=MSO_ANCHOR.MIDDLE,
|
||||
)
|
||||
subtitle = item.get("subtitle", deck.get("subtitle", ""))
|
||||
if subtitle:
|
||||
add_textbox(
|
||||
slide,
|
||||
theme,
|
||||
0.95,
|
||||
3.2,
|
||||
10.8,
|
||||
0.8,
|
||||
subtitle,
|
||||
size=20,
|
||||
color_value="DCE6F1",
|
||||
)
|
||||
author = deck.get("author", "")
|
||||
if author:
|
||||
add_textbox(
|
||||
slide,
|
||||
theme,
|
||||
0.95,
|
||||
6.35,
|
||||
10.0,
|
||||
0.35,
|
||||
author,
|
||||
size=12,
|
||||
color_value="DCE6F1",
|
||||
)
|
||||
|
||||
|
||||
def render_section(slide, deck, item):
|
||||
theme = deck["theme"]
|
||||
set_background(slide, theme, "light")
|
||||
add_textbox(
|
||||
slide,
|
||||
theme,
|
||||
1.0,
|
||||
2.2,
|
||||
11.2,
|
||||
1.0,
|
||||
item["title"],
|
||||
size=32,
|
||||
bold=True,
|
||||
color_value=theme["primary"],
|
||||
align=PP_ALIGN.CENTER,
|
||||
vertical=MSO_ANCHOR.MIDDLE,
|
||||
)
|
||||
if item.get("subtitle"):
|
||||
add_textbox(
|
||||
slide,
|
||||
theme,
|
||||
1.5,
|
||||
3.35,
|
||||
10.2,
|
||||
0.7,
|
||||
item["subtitle"],
|
||||
size=18,
|
||||
color_value=theme["muted"],
|
||||
align=PP_ALIGN.CENTER,
|
||||
)
|
||||
|
||||
|
||||
def render_bullets(slide, deck, item):
|
||||
theme = deck["theme"]
|
||||
set_background(slide, theme)
|
||||
add_header(slide, theme, item["title"])
|
||||
add_bullet_frame(slide, theme, 0.9, 1.45, 11.5, 5.2, item["bullets"])
|
||||
|
||||
|
||||
def render_two_column(slide, deck, item):
|
||||
theme = deck["theme"]
|
||||
set_background(slide, theme)
|
||||
add_header(slide, theme, item["title"])
|
||||
for x, column in ((0.75, item["left"]), (6.78, item["right"])):
|
||||
panel = slide.shapes.add_shape(
|
||||
MSO_SHAPE.ROUNDED_RECTANGLE,
|
||||
Inches(x),
|
||||
Inches(1.55),
|
||||
Inches(5.55),
|
||||
Inches(4.95),
|
||||
)
|
||||
panel.fill.solid()
|
||||
panel.fill.fore_color.rgb = color(theme["light"])
|
||||
panel.line.color.rgb = color("D8E0EA")
|
||||
add_textbox(
|
||||
slide,
|
||||
theme,
|
||||
x + 0.3,
|
||||
1.8,
|
||||
4.95,
|
||||
0.5,
|
||||
column["title"],
|
||||
size=20,
|
||||
bold=True,
|
||||
color_value=theme["primary"],
|
||||
)
|
||||
add_bullet_frame(
|
||||
slide,
|
||||
theme,
|
||||
x + 0.25,
|
||||
2.5,
|
||||
5.0,
|
||||
3.55,
|
||||
column["bullets"],
|
||||
size=17,
|
||||
)
|
||||
|
||||
|
||||
def render_metrics(slide, deck, item):
|
||||
theme = deck["theme"]
|
||||
set_background(slide, theme)
|
||||
add_header(slide, theme, item["title"])
|
||||
metrics = item["metrics"]
|
||||
gap = 0.25
|
||||
total_width = 11.8
|
||||
width = (total_width - gap * (len(metrics) - 1)) / len(metrics)
|
||||
for index, metric in enumerate(metrics):
|
||||
x = 0.75 + index * (width + gap)
|
||||
panel = slide.shapes.add_shape(
|
||||
MSO_SHAPE.ROUNDED_RECTANGLE,
|
||||
Inches(x),
|
||||
Inches(1.75),
|
||||
Inches(width),
|
||||
Inches(4.45),
|
||||
)
|
||||
panel.fill.solid()
|
||||
panel.fill.fore_color.rgb = color(theme["light"])
|
||||
panel.line.color.rgb = color("D8E0EA")
|
||||
add_textbox(
|
||||
slide,
|
||||
theme,
|
||||
x + 0.15,
|
||||
2.05,
|
||||
width - 0.3,
|
||||
1.0,
|
||||
metric["value"],
|
||||
size=29,
|
||||
bold=True,
|
||||
color_value=theme["accent"],
|
||||
align=PP_ALIGN.CENTER,
|
||||
vertical=MSO_ANCHOR.MIDDLE,
|
||||
)
|
||||
add_textbox(
|
||||
slide,
|
||||
theme,
|
||||
x + 0.15,
|
||||
3.15,
|
||||
width - 0.3,
|
||||
0.6,
|
||||
metric["label"],
|
||||
size=17,
|
||||
bold=True,
|
||||
color_value=theme["primary"],
|
||||
align=PP_ALIGN.CENTER,
|
||||
)
|
||||
add_textbox(
|
||||
slide,
|
||||
theme,
|
||||
x + 0.2,
|
||||
4.0,
|
||||
width - 0.4,
|
||||
1.45,
|
||||
metric["detail"],
|
||||
size=12,
|
||||
color_value=theme["muted"],
|
||||
align=PP_ALIGN.CENTER,
|
||||
)
|
||||
|
||||
|
||||
def prepare_image(image_path, max_width_in, max_height_in, staging_dir):
|
||||
"""Normalize orientation and cap pixels so decks stay a usable size."""
|
||||
if image_path.stat().st_size > MAX_IMAGE_BYTES:
|
||||
raise ValueError(
|
||||
f"图片超过 {MAX_IMAGE_BYTES // (1024 * 1024)}MB:{image_path.name}"
|
||||
)
|
||||
with Image.open(image_path) as image:
|
||||
if image.width * image.height > MAX_IMAGE_PIXELS:
|
||||
raise ValueError(f"图片像素数过大:{image_path.name}")
|
||||
image = ImageOps.exif_transpose(image)
|
||||
width, height = image.size
|
||||
scale = min(max_width_in / width, max_height_in / height)
|
||||
target = (
|
||||
max(1, round(width * scale * RENDER_DPI)),
|
||||
max(1, round(height * scale * RENDER_DPI)),
|
||||
)
|
||||
if target[0] >= width and target[1] >= height:
|
||||
if image_path.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif"}:
|
||||
return image_path, width, height
|
||||
target = (width, height)
|
||||
resized = image.resize(target, Image.LANCZOS)
|
||||
if resized.mode not in ("RGB", "RGBA", "L"):
|
||||
resized = resized.convert("RGBA")
|
||||
staged = staging_dir / f"{image_path.stem}-{target[0]}x{target[1]}.png"
|
||||
resized.save(staged, format="PNG", optimize=True)
|
||||
return staged, width, height
|
||||
|
||||
|
||||
def render_image(slide, deck, item, base_dir, staging_dir):
|
||||
theme = deck["theme"]
|
||||
set_background(slide, theme)
|
||||
add_header(slide, theme, item["title"])
|
||||
image_path = (base_dir / item["image"]).resolve()
|
||||
if base_dir != image_path and base_dir not in image_path.parents:
|
||||
raise ValueError(f"图片必须位于 deck.json 目录内:{image_path}")
|
||||
if not image_path.is_file():
|
||||
raise FileNotFoundError(f"图片不存在:{image_path}")
|
||||
max_width, max_height = 11.5, 5.35
|
||||
source, width, height = prepare_image(
|
||||
image_path, max_width, max_height, staging_dir
|
||||
)
|
||||
scale = min(max_width / width, max_height / height)
|
||||
draw_width, draw_height = width * scale, height * scale
|
||||
slide.shapes.add_picture(
|
||||
str(source),
|
||||
Inches((13.333 - draw_width) / 2),
|
||||
Inches(1.35 + (5.35 - draw_height) / 2),
|
||||
width=Inches(draw_width),
|
||||
height=Inches(draw_height),
|
||||
)
|
||||
if item.get("caption"):
|
||||
add_textbox(
|
||||
slide,
|
||||
theme,
|
||||
1.0,
|
||||
6.55,
|
||||
11.3,
|
||||
0.3,
|
||||
item["caption"],
|
||||
size=10,
|
||||
color_value=theme["muted"],
|
||||
align=PP_ALIGN.CENTER,
|
||||
)
|
||||
|
||||
|
||||
def render_closing(slide, deck, item):
|
||||
theme = deck["theme"]
|
||||
set_background(slide, theme, "primary")
|
||||
add_textbox(
|
||||
slide,
|
||||
theme,
|
||||
0.9,
|
||||
1.2,
|
||||
11.5,
|
||||
0.9,
|
||||
item["title"],
|
||||
size=32,
|
||||
bold=True,
|
||||
color_value="FFFFFF",
|
||||
align=PP_ALIGN.CENTER,
|
||||
)
|
||||
add_bullet_frame(
|
||||
slide,
|
||||
{**theme, "text": "FFFFFF"},
|
||||
2.0,
|
||||
2.55,
|
||||
9.3,
|
||||
3.2,
|
||||
item["bullets"],
|
||||
size=20,
|
||||
)
|
||||
|
||||
|
||||
RENDERERS = {
|
||||
"title": render_title,
|
||||
"section": render_section,
|
||||
"bullets": render_bullets,
|
||||
"two-column": render_two_column,
|
||||
"metrics": render_metrics,
|
||||
"closing": render_closing,
|
||||
}
|
||||
|
||||
|
||||
def build_presentation(input_path, deck, output_path):
|
||||
output = Path(output_path).expanduser().resolve()
|
||||
if output.suffix.lower() != ".pptx":
|
||||
raise ValueError("输出必须使用 .pptx 扩展名")
|
||||
if output == input_path:
|
||||
raise ValueError("输出不能覆盖 deck.json")
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
presentation = Presentation()
|
||||
presentation.slide_width = Inches(13.333)
|
||||
presentation.slide_height = Inches(7.5)
|
||||
blank = presentation.slide_layouts[6]
|
||||
with tempfile.TemporaryDirectory(prefix="deck-images-") as staging:
|
||||
staging_dir = Path(staging)
|
||||
for index, item in enumerate(deck["slides"], 1):
|
||||
slide = presentation.slides.add_slide(blank)
|
||||
if item["type"] == "image":
|
||||
render_image(slide, deck, item, input_path.parent, staging_dir)
|
||||
else:
|
||||
RENDERERS[item["type"]](slide, deck, item)
|
||||
if item["type"] != "title":
|
||||
add_footer(slide, deck, index)
|
||||
add_notes(slide, item.get("notes", ""))
|
||||
presentation.save(output)
|
||||
return output
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="从 deck.json 生成产品介绍 PPTX")
|
||||
parser.add_argument("--input", required=True, help="deck.json")
|
||||
parser.add_argument("--output", required=True, help="输出 .pptx")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
input_path, deck = load_deck(args.input)
|
||||
output = build_presentation(input_path, deck, args.output)
|
||||
print(f"saved: {output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"title": "示例产品介绍",
|
||||
"subtitle": "面向目标受众的产品说明",
|
||||
"author": "产品团队",
|
||||
"footer": "示例产品",
|
||||
"theme": {
|
||||
"primary": "1F3864",
|
||||
"accent": "C00000",
|
||||
"background": "FFFFFF",
|
||||
"text": "1A1A1A",
|
||||
"font_zh": "Microsoft YaHei",
|
||||
"font_en": "Arial"
|
||||
},
|
||||
"slides": [
|
||||
{
|
||||
"type": "title",
|
||||
"title": "示例产品介绍",
|
||||
"subtitle": "面向目标受众的产品说明",
|
||||
"notes": "说明本次交流目标和议程。"
|
||||
},
|
||||
{
|
||||
"type": "bullets",
|
||||
"title": "目标场景与问题",
|
||||
"bullets": [
|
||||
"说明目标角色当前需要完成的任务",
|
||||
"说明现有流程中的可验证问题",
|
||||
"说明本次介绍覆盖和不覆盖的范围"
|
||||
],
|
||||
"notes": "不要使用宏大行业背景替代具体问题。",
|
||||
"evidence_ids": [
|
||||
"CLM-001"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "two-column",
|
||||
"title": "产品工作方式",
|
||||
"left": {
|
||||
"title": "输入与条件",
|
||||
"bullets": [
|
||||
"部署环境",
|
||||
"数据与接口条件"
|
||||
]
|
||||
},
|
||||
"right": {
|
||||
"title": "动作与结果",
|
||||
"bullets": [
|
||||
"用户触发的具体动作",
|
||||
"系统产生的可观察结果"
|
||||
]
|
||||
},
|
||||
"notes": "结合当前版本说明工作流。"
|
||||
},
|
||||
{
|
||||
"type": "metrics",
|
||||
"title": "已核验指标",
|
||||
"metrics": [
|
||||
{
|
||||
"value": "参数值",
|
||||
"label": "指标名称",
|
||||
"detail": "版本、条件和统计口径"
|
||||
},
|
||||
{
|
||||
"value": "参数值",
|
||||
"label": "指标名称",
|
||||
"detail": "版本、条件和统计口径"
|
||||
}
|
||||
],
|
||||
"notes": "只使用事实清单中有证据的指标。"
|
||||
},
|
||||
{
|
||||
"type": "closing",
|
||||
"title": "下一步",
|
||||
"bullets": [
|
||||
"确认适用场景和边界",
|
||||
"确定验证材料与责任人"
|
||||
],
|
||||
"notes": "给出明确、可执行的后续动作。"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import copy
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
from pptx import Presentation
|
||||
|
||||
|
||||
SKILL_DIR = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = SKILL_DIR / "scripts" / "build_pptx.py"
|
||||
SPEC = importlib.util.spec_from_file_location("build_pptx", SCRIPT)
|
||||
build_pptx = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(build_pptx)
|
||||
|
||||
|
||||
class BuildPptxTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
template = SKILL_DIR / "templates" / "deck.example.json"
|
||||
cls.deck = json.loads(template.read_text(encoding="utf-8"))
|
||||
|
||||
def test_builds_example_deck(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
input_path = root / "deck.json"
|
||||
input_path.write_text(
|
||||
json.dumps(self.deck, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
loaded_path, deck = build_pptx.load_deck(input_path)
|
||||
output = build_pptx.build_presentation(
|
||||
loaded_path,
|
||||
deck,
|
||||
root / "deck.pptx",
|
||||
)
|
||||
presentation = Presentation(output)
|
||||
self.assertEqual(len(presentation.slides), len(self.deck["slides"]))
|
||||
all_text = "\n".join(
|
||||
shape.text
|
||||
for slide in presentation.slides
|
||||
for shape in slide.shapes
|
||||
if hasattr(shape, "text")
|
||||
)
|
||||
self.assertIn("目标场景与问题", all_text)
|
||||
self.assertIn("下一步", all_text)
|
||||
for slide, item in zip(presentation.slides, self.deck["slides"]):
|
||||
self.assertEqual(
|
||||
slide.notes_slide.notes_text_frame.text.strip(),
|
||||
item["notes"].strip(),
|
||||
)
|
||||
|
||||
def test_requires_notes_outside_section_slides(self):
|
||||
deck = copy.deepcopy(self.deck)
|
||||
deck["slides"][1].pop("notes")
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "deck.json"
|
||||
path.write_text(json.dumps(deck, ensure_ascii=False), encoding="utf-8")
|
||||
with self.assertRaisesRegex(ValueError, r"slides\[1\]\.notes"):
|
||||
build_pptx.load_deck(path)
|
||||
|
||||
def test_downsamples_large_image_slide(self):
|
||||
deck = copy.deepcopy(self.deck)
|
||||
deck["slides"].insert(
|
||||
3,
|
||||
{
|
||||
"type": "image",
|
||||
"title": "参考架构",
|
||||
"image": "architecture.png",
|
||||
"caption": "示例架构图",
|
||||
"notes": "说明组件边界。",
|
||||
},
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
Image.new("RGB", (5000, 3000), "white").save(root / "architecture.png")
|
||||
path = root / "deck.json"
|
||||
path.write_text(json.dumps(deck, ensure_ascii=False), encoding="utf-8")
|
||||
loaded_path, loaded = build_pptx.load_deck(path)
|
||||
output = build_pptx.build_presentation(
|
||||
loaded_path, loaded, root / "deck.pptx"
|
||||
)
|
||||
presentation = Presentation(output)
|
||||
self.assertEqual(len(presentation.slides), len(deck["slides"]))
|
||||
pictures = [
|
||||
shape
|
||||
for slide in presentation.slides
|
||||
for shape in slide.shapes
|
||||
if shape.shape_type == 13
|
||||
]
|
||||
self.assertEqual(len(pictures), 1)
|
||||
with Image.open(io.BytesIO(pictures[0].image.blob)) as embedded:
|
||||
self.assertLess(embedded.width, 5000)
|
||||
|
||||
def test_rejects_image_outside_deck_directory(self):
|
||||
deck = copy.deepcopy(self.deck)
|
||||
deck["slides"].insert(
|
||||
1,
|
||||
{
|
||||
"type": "image",
|
||||
"title": "外部图片",
|
||||
"image": "../outside.png",
|
||||
"notes": "越界图片。",
|
||||
},
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp) / "deck"
|
||||
root.mkdir()
|
||||
Image.new("RGB", (10, 10), "white").save(Path(tmp) / "outside.png")
|
||||
path = root / "deck.json"
|
||||
path.write_text(json.dumps(deck, ensure_ascii=False), encoding="utf-8")
|
||||
loaded_path, loaded = build_pptx.load_deck(path)
|
||||
with self.assertRaisesRegex(ValueError, "必须位于"):
|
||||
build_pptx.build_presentation(
|
||||
loaded_path, loaded, root / "deck.pptx"
|
||||
)
|
||||
|
||||
def test_rejects_too_many_bullets(self):
|
||||
deck = copy.deepcopy(self.deck)
|
||||
deck["slides"][1]["bullets"] = [f"项目 {index}" for index in range(7)]
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "deck.json"
|
||||
path.write_text(
|
||||
json.dumps(deck, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "最多 6 项"):
|
||||
build_pptx.load_deck(path)
|
||||
|
||||
def test_rejects_unknown_slide_type(self):
|
||||
deck = copy.deepcopy(self.deck)
|
||||
deck["slides"][0]["type"] = "unknown"
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "deck.json"
|
||||
path.write_text(
|
||||
json.dumps(deck, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "未知"):
|
||||
build_pptx.load_deck(path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user