feat: improve skill import and runtime delivery

This commit is contained in:
lofyer
2026-08-07 21:01:17 +08:00
parent 954b42ef55
commit 417a9fccb6
63 changed files with 6136 additions and 527 deletions
@@ -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()