398 lines
16 KiB
Markdown
398 lines
16 KiB
Markdown
# 9 种常用版式 (16:9, 13.33×7.5 in)
|
||
|
||
> **2.0 版本要点**:大幅减少满铺色块,引入 MSO_SHAPE 图标点缀,所有元素经 safe_area 校验不会越出画布。
|
||
|
||
复制 → 改文案 → 跑。配色用 current spec(命名见 SKILL.md §阶段一)里的实际 hex 替换占位。
|
||
|
||
## 通用起手 + 安全辅助
|
||
|
||
```python
|
||
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, MSO_AUTO_SIZE
|
||
from pptx.enum.shapes import MSO_SHAPE
|
||
|
||
# ---- 配色 (商务红 — 硬约束默认) ----
|
||
# ⛔ 不允许擅自换色:除非用户明确点名其它配色 (例:"做成蓝色") 或 spec 已写其它 hex,
|
||
# 否则就是这套商务红。禁止以"这个场景蓝色更专业"这类自我合理化做替换。
|
||
PRIMARY = RGBColor(0xC0, 0x00, 0x00) # 深红 - 标题/强调/关键数据
|
||
SECONDARY = RGBColor(0xE1, 0x55, 0x54) # 砖红 - 次要图形
|
||
ACCENT = RGBColor(0xFF, 0xC1, 0x07) # 金黄 - 关键数据点/CTA
|
||
INK = RGBColor(0x1F, 0x1F, 0x1F)
|
||
GREY = RGBColor(0x59, 0x59, 0x59)
|
||
GREY_LIGHT = RGBColor(0x88, 0x88, 0x88)
|
||
BG = RGBColor(0xFA, 0xFA, 0xFA) # 背景近白
|
||
WHITE = RGBColor(255, 255, 255)
|
||
|
||
CN_FONT = "微软雅黑"
|
||
EN_FONT = "Arial"
|
||
|
||
# ---- 画布与安全区 ----
|
||
prs = Presentation()
|
||
prs.slide_width = Inches(13.33)
|
||
prs.slide_height = Inches(7.5)
|
||
SLIDE_W = 13.33
|
||
SLIDE_H = 7.5
|
||
MARGIN_X = 0.7 # 左右
|
||
MARGIN_Y = 0.5 # 上下
|
||
SAFE_LEFT = MARGIN_X
|
||
SAFE_TOP = MARGIN_Y
|
||
SAFE_RIGHT = SLIDE_W - MARGIN_X
|
||
SAFE_BOTTOM = SLIDE_H - MARGIN_Y
|
||
SAFE_W = SAFE_RIGHT - SAFE_LEFT # 11.93
|
||
SAFE_H = SAFE_BOTTOM - SAFE_TOP # 6.5
|
||
BLANK = prs.slide_layouts[6]
|
||
|
||
def assert_inside(left, top, width, height, name=""):
|
||
"""放置前调一次。越界直接报错而不是悄悄超出。"""
|
||
if left < 0 or top < 0:
|
||
raise ValueError(f"[{name}] 左/上为负: ({left}, {top})")
|
||
if left + width > SLIDE_W + 1e-3:
|
||
raise ValueError(f"[{name}] 右越界: {left}+{width} > {SLIDE_W}")
|
||
if top + height > SLIDE_H + 1e-3:
|
||
raise ValueError(f"[{name}] 下越界: {top}+{height} > {SLIDE_H}")
|
||
|
||
# ---- 文本辅助 (默认 word_wrap, shrink-to-fit 兜底) ----
|
||
def set_text(tf, text, size, bold=False, color=INK, align=PP_ALIGN.LEFT,
|
||
font=CN_FONT):
|
||
tf.text = text
|
||
p = tf.paragraphs[0]; p.alignment = align
|
||
r = p.runs[0]
|
||
r.font.name = font; r.font.size = Pt(size); r.font.bold = bold
|
||
r.font.color.rgb = color
|
||
|
||
def add_textbox(slide, left, top, width, height, text, size,
|
||
bold=False, color=INK, align=PP_ALIGN.LEFT,
|
||
anchor=MSO_ANCHOR.TOP, font=CN_FONT, shrink=True,
|
||
name="textbox"):
|
||
assert_inside(left, top, width, height, name)
|
||
tb = slide.shapes.add_textbox(Inches(left), Inches(top),
|
||
Inches(width), Inches(height))
|
||
tf = tb.text_frame
|
||
tf.vertical_anchor = anchor
|
||
tf.word_wrap = True
|
||
if shrink:
|
||
# 文字超出框高时自动收缩字号 (兜底,不替代字数预算)
|
||
tf.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE
|
||
set_text(tf, text, size, bold, color, align, font)
|
||
return tb
|
||
|
||
# ---- 形状辅助 (无边线实心填充) ----
|
||
def add_rect(slide, left, top, width, height, fill, name="rect"):
|
||
assert_inside(left, top, width, height, name)
|
||
s = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(left), Inches(top),
|
||
Inches(width), Inches(height))
|
||
s.fill.solid(); s.fill.fore_color.rgb = fill
|
||
s.line.fill.background()
|
||
return s
|
||
|
||
def add_shape(slide, kind, left, top, width, height, fill, name="shape"):
|
||
assert_inside(left, top, width, height, name)
|
||
s = slide.shapes.add_shape(kind, Inches(left), Inches(top),
|
||
Inches(width), Inches(height))
|
||
s.fill.solid(); s.fill.fore_color.rgb = fill
|
||
s.line.fill.background()
|
||
return s
|
||
|
||
def add_dot(slide, x, y, size=0.18, color=ACCENT):
|
||
return add_shape(slide, MSO_SHAPE.OVAL, x, y, size, size, color, "dot")
|
||
|
||
def add_accent_line(slide, x, y, length=1.0, thickness=0.05, color=ACCENT):
|
||
"""标题下面那条强调线,替代大色块"""
|
||
return add_rect(slide, x, y, length, thickness, color, "accent_line")
|
||
|
||
def add_badge(slide, x, y, num, diameter=0.7, fill=PRIMARY, fg=WHITE):
|
||
"""编号徽章 (圆 + 数字)"""
|
||
c = add_shape(slide, MSO_SHAPE.OVAL, x, y, diameter, diameter, fill, "badge")
|
||
tf = c.text_frame; tf.text = str(num)
|
||
p = tf.paragraphs[0]; p.alignment = PP_ALIGN.CENTER
|
||
r = p.runs[0]
|
||
r.font.bold = True; r.font.size = Pt(int(diameter * 28))
|
||
r.font.color.rgb = fg; r.font.name = EN_FONT
|
||
return c
|
||
|
||
# ---- 标题套件 (内页通用) ----
|
||
def page_title(slide, text, page_num=None, total=None, footer="项目汇报"):
|
||
add_textbox(slide, SAFE_LEFT, SAFE_TOP, SAFE_W, 0.7, text,
|
||
32, bold=True, color=PRIMARY, name="title")
|
||
add_accent_line(slide, SAFE_LEFT, SAFE_TOP + 0.85,
|
||
length=0.8, color=ACCENT)
|
||
if page_num is not None and total is not None:
|
||
add_textbox(slide, SAFE_LEFT, 7.0, 6, 0.4, footer,
|
||
11, color=GREY_LIGHT, shrink=False, name="footer")
|
||
add_textbox(slide, 12.0, 7.0, 1.2, 0.4, f"{page_num} / {total}",
|
||
11, color=GREY_LIGHT, align=PP_ALIGN.RIGHT,
|
||
shrink=False, name="page_num")
|
||
|
||
# ---- 品牌条 (每页起手必调,确保不是裸白纸) ----
|
||
def apply_brand(slide, kind="inner"):
|
||
"""统一品牌锚点。每个版式第一行调用,给一条窄的主色锚点 + 必要时浅底。
|
||
kind:
|
||
cover —— 封面: 左侧主色长竖条 + 顶部短横
|
||
inner —— 内页 (默认): 左侧主色窄条 (从标题到底部)
|
||
section —— 分章: 整页浅灰 + 左侧强调色粗竖条
|
||
end —— 结尾: 整页浅灰 + 顶/底强调色短线
|
||
"""
|
||
if kind == "cover":
|
||
# 顶部短主色横线 + 左侧主色长竖条 + 底部细灰线
|
||
add_rect(slide, 0, 0, 0.18, SLIDE_H, PRIMARY, "brand_left_bar")
|
||
add_rect(slide, 0.7, 0.6, 0.8, 0.06, PRIMARY, "brand_top_line")
|
||
add_rect(slide, SAFE_LEFT, 7.18, SAFE_W, 0.02,
|
||
RGBColor(0xDD, 0xDD, 0xDD), "brand_btm_hairline")
|
||
elif kind == "section":
|
||
add_rect(slide, 0, 0, SLIDE_W, SLIDE_H, BG, "brand_bg")
|
||
add_rect(slide, 0, 0, 0.18, SLIDE_H, PRIMARY, "brand_left_bar")
|
||
add_rect(slide, 0.7, 2.5, 0.08, 2.5, ACCENT, "brand_section_bar")
|
||
elif kind == "end":
|
||
add_rect(slide, 0, 0, SLIDE_W, SLIDE_H, BG, "brand_bg")
|
||
add_rect(slide, SAFE_LEFT, 0.6, 0.8, 0.06, ACCENT, "brand_top_line")
|
||
add_rect(slide, SAFE_RIGHT - 0.8, 6.85, 0.8, 0.06, ACCENT,
|
||
"brand_btm_line")
|
||
else: # inner — 默认内页
|
||
# 左侧主色窄条 (从标题区到页脚)
|
||
add_rect(slide, 0, 0, 0.10, SLIDE_H, PRIMARY, "brand_left_bar")
|
||
# 底部一条细灰线 (页脚分隔)
|
||
add_rect(slide, SAFE_LEFT, 7.18, SAFE_W, 0.02,
|
||
RGBColor(0xDD, 0xDD, 0xDD), "brand_btm_hairline")
|
||
```
|
||
|
||
> **要点**:
|
||
> - `assert_inside` 阻止任何越界。元素超出画布会立刻报 `ValueError`,而不是悄悄裁剪
|
||
> - `add_textbox` 默认 `word_wrap=True` + `auto_size=TEXT_TO_SHAPE_AND_FIT_TEXT` —— 文字溢出自动缩字号
|
||
> - `page_title` 用细线代替大块色填,所有内页统一调用
|
||
|
||
---
|
||
|
||
## L1 · 封面 (Cover) —— 主色长竖条锚点
|
||
|
||
```python
|
||
slide = prs.slides.add_slide(BLANK)
|
||
apply_brand(slide, "cover") # 左侧主色长竖条 + 顶部短横
|
||
|
||
# 主标题 (避开左竖条)
|
||
add_textbox(slide, 0.9, 2.6, 11.9, 1.4, "项目名称 / 演示主题",
|
||
44, bold=True, color=INK, name="cover_title")
|
||
# 副标题 (灰色,弱化)
|
||
add_textbox(slide, 0.9, 4.1, 11.9, 0.6, "一句话副标题或定位",
|
||
22, color=GREY, name="cover_sub")
|
||
# 汇报人 / 日期
|
||
add_textbox(slide, 0.9, 6.4, 11.9, 0.4,
|
||
"汇报人 · 部门 · 2026-05-06", 14, color=GREY_LIGHT,
|
||
name="cover_meta")
|
||
# 右下角小图标点缀 (五角星,可选)
|
||
add_shape(slide, MSO_SHAPE.STAR_5_POINT, 12.2, 6.3, 0.5, 0.5, ACCENT,
|
||
"deco_star")
|
||
```
|
||
|
||
---
|
||
|
||
## L2 · 目录 (Agenda) —— 编号徽章 + 文字
|
||
|
||
```python
|
||
slide = prs.slides.add_slide(BLANK)
|
||
apply_brand(slide, "inner")
|
||
page_title(slide, "目录")
|
||
|
||
items = ["背景与现状", "核心问题", "解决方案", "实施计划", "预期成果"]
|
||
for i, item in enumerate(items):
|
||
y = 1.9 + i * 0.95
|
||
add_badge(slide, SAFE_LEFT, y, i + 1, diameter=0.65)
|
||
add_textbox(slide, SAFE_LEFT + 1.0, y, SAFE_W - 1.0, 0.65,
|
||
item, 22, color=INK, anchor=MSO_ANCHOR.MIDDLE,
|
||
name=f"agenda_{i}")
|
||
```
|
||
|
||
---
|
||
|
||
## L3 · 章节分隔 (Section Divider) —— 浅色背景 + 大字编号
|
||
|
||
```python
|
||
slide = prs.slides.add_slide(BLANK)
|
||
apply_brand(slide, "section") # 整页浅灰 + 主色左竖条 + 强调装饰
|
||
# 大编号 (主色,描边视觉感)
|
||
add_textbox(slide, 1.1, 2.0, 4, 2.5, "01", 160, bold=True,
|
||
color=PRIMARY, font=EN_FONT, name="sec_num")
|
||
# 章节名
|
||
add_textbox(slide, 5.5, 2.8, 7, 1.0, "背景与现状",
|
||
44, bold=True, color=INK, anchor=MSO_ANCHOR.MIDDLE,
|
||
name="sec_title")
|
||
# 引言
|
||
add_textbox(slide, 5.5, 4.0, 7, 0.6,
|
||
"本章讨论行业现状与机会窗口", 18, color=GREY,
|
||
name="sec_lead")
|
||
# 装饰小图标
|
||
add_shape(slide, MSO_SHAPE.RIGHT_ARROW, 5.5, 5.0, 0.6, 0.3, ACCENT,
|
||
"sec_arrow")
|
||
```
|
||
|
||
---
|
||
|
||
## L4 · 要点 (Bullets) —— 圆点 + 文字,无大块色
|
||
|
||
```python
|
||
slide = prs.slides.add_slide(BLANK)
|
||
apply_brand(slide, "inner")
|
||
page_title(slide, "核心结论")
|
||
|
||
bullets = [
|
||
"结论一:用一句话讲清楚",
|
||
"结论二:具体数据支撑,如增长 27%",
|
||
"结论三:对未来的判断,简洁有力",
|
||
"结论四:可选第四条,不要超过 5 条",
|
||
]
|
||
for i, b in enumerate(bullets):
|
||
y = 2.0 + i * 0.95
|
||
add_dot(slide, SAFE_LEFT + 0.05, y + 0.22, size=0.18, color=ACCENT)
|
||
add_textbox(slide, SAFE_LEFT + 0.45, y, SAFE_W - 0.45, 0.6,
|
||
b, 22, color=INK, anchor=MSO_ANCHOR.MIDDLE,
|
||
name=f"bullet_{i}")
|
||
```
|
||
|
||
---
|
||
|
||
## L5 · 双栏对比 (Two-Column) —— 中线分隔,小色块标签
|
||
|
||
```python
|
||
slide = prs.slides.add_slide(BLANK)
|
||
apply_brand(slide, "inner")
|
||
page_title(slide, "现状 vs 改进后")
|
||
|
||
mid_x = SLIDE_W / 2
|
||
|
||
# 中间细分隔线 (替代两块大矩形)
|
||
add_rect(slide, mid_x - 0.02, 2.0, 0.04, 4.8, RGBColor(0xDD, 0xDD, 0xDD),
|
||
"divider")
|
||
|
||
# 左栏小标签 (色块只占小区域)
|
||
add_rect(slide, SAFE_LEFT, 2.0, 0.8, 0.35, GREY, "left_tag")
|
||
add_textbox(slide, SAFE_LEFT, 2.0, 0.8, 0.35, "现状", 14, bold=True,
|
||
color=WHITE, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE,
|
||
shrink=False, name="left_label")
|
||
left_pts = ["问题 A: 描述", "问题 B: 描述", "问题 C: 描述"]
|
||
for i, p in enumerate(left_pts):
|
||
add_dot(slide, SAFE_LEFT + 0.05, 2.7 + i * 0.7 + 0.18, color=GREY)
|
||
add_textbox(slide, SAFE_LEFT + 0.45, 2.7 + i * 0.7,
|
||
mid_x - SAFE_LEFT - 0.7, 0.55, p, 18, color=INK,
|
||
anchor=MSO_ANCHOR.MIDDLE, name=f"l_pt_{i}")
|
||
|
||
# 右栏小标签
|
||
add_rect(slide, mid_x + 0.3, 2.0, 0.8, 0.35, PRIMARY, "right_tag")
|
||
add_textbox(slide, mid_x + 0.3, 2.0, 0.8, 0.35, "改进后", 14, bold=True,
|
||
color=WHITE, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE,
|
||
shrink=False, name="right_label")
|
||
right_pts = ["改善 A: 描述", "改善 B: 描述", "改善 C: 描述"]
|
||
for i, p in enumerate(right_pts):
|
||
add_dot(slide, mid_x + 0.35, 2.7 + i * 0.7 + 0.18, color=ACCENT)
|
||
add_textbox(slide, mid_x + 0.75, 2.7 + i * 0.7,
|
||
SAFE_RIGHT - mid_x - 0.75, 0.55, p, 18, color=INK,
|
||
anchor=MSO_ANCHOR.MIDDLE, name=f"r_pt_{i}")
|
||
```
|
||
|
||
---
|
||
|
||
## L6 · 图表为主 (Chart-focus) —— 标题 + 一句结论 + 大图
|
||
|
||
```python
|
||
# chart.png 已用 matplotlib 生成 (见 design_principles.md §7)
|
||
slide = prs.slides.add_slide(BLANK)
|
||
apply_brand(slide, "inner")
|
||
page_title(slide, "季度营收持续增长")
|
||
# 一句话结论
|
||
add_textbox(slide, SAFE_LEFT, SAFE_TOP + 1.1, SAFE_W, 0.5,
|
||
"Q4 同比增长 158%,创历史新高", 18, color=GREY,
|
||
name="lead")
|
||
# 图表 (居中,占 9 寸宽,高度自适应)
|
||
slide.shapes.add_picture("chart.png", Inches(2.2), Inches(2.4),
|
||
width=Inches(8.9))
|
||
# 数据来源 (右下角弱化)
|
||
add_textbox(slide, SAFE_LEFT, 6.95, SAFE_W, 0.4,
|
||
"数据来源: 公司年报 2025", 11, color=GREY_LIGHT,
|
||
align=PP_ALIGN.RIGHT, shrink=False, name="source")
|
||
```
|
||
|
||
---
|
||
|
||
## L7 · 图片为主 (Image-focus) —— 文字在图旁,不压图
|
||
|
||
> 之前用满铺图 + 半透明遮罩,效果不稳定。改成"图占 60% + 文字独立区"。
|
||
|
||
```python
|
||
slide = prs.slides.add_slide(BLANK)
|
||
# 左侧图占 60% 宽
|
||
slide.shapes.add_picture("hero.jpg", Inches(0), Inches(0),
|
||
width=Inches(8), height=Inches(7.5))
|
||
# 右侧浅灰背景区放文字
|
||
add_rect(slide, 8, 0, 5.33, 7.5, BG, "text_panel")
|
||
add_rect(slide, 8.4, 1.0, 0.06, 0.8, ACCENT, "deco_bar") # 装饰短线
|
||
add_textbox(slide, 8.4, 2.0, 4.6, 1.6, "走进未来", 36,
|
||
bold=True, color=INK, name="img_title")
|
||
add_textbox(slide, 8.4, 3.8, 4.6, 1.5,
|
||
"用一两句话点出主旨,不要把演讲稿搬上来。",
|
||
18, color=GREY, name="img_caption")
|
||
# 图标:右下角的箭头,引导视线
|
||
add_shape(slide, MSO_SHAPE.RIGHT_ARROW, 8.4, 6.5, 0.7, 0.35, ACCENT,
|
||
"img_cta")
|
||
```
|
||
|
||
---
|
||
|
||
## L8 · 金句 / 大字 (Quote) —— 留白主导,装饰极简
|
||
|
||
```python
|
||
slide = prs.slides.add_slide(BLANK)
|
||
apply_brand(slide, "inner")
|
||
# 左上大引号 (用 STAR 不合适;用字形)
|
||
add_textbox(slide, 0.8, 0.6, 1.5, 1.5, '"', 200, bold=True,
|
||
color=ACCENT, font=EN_FONT, shrink=False, name="quote_mark")
|
||
# 金句 (深色,留白多)
|
||
add_textbox(slide, 1.5, 2.7, 10.5, 2.0,
|
||
"把复杂留给我们,把简单留给用户。", 36, bold=True,
|
||
color=INK, anchor=MSO_ANCHOR.MIDDLE, name="quote_text")
|
||
# 装饰短线
|
||
add_accent_line(slide, 1.5, 5.0, length=0.5, color=ACCENT)
|
||
# 出处
|
||
add_textbox(slide, 1.5, 5.2, 10.5, 0.5, "—— 公司价值观 2025",
|
||
16, color=GREY, name="quote_attr")
|
||
```
|
||
|
||
---
|
||
|
||
## L9 · 结尾 / Q&A —— 浅底 + 大字,**强制必有**
|
||
|
||
> **不是可选** —— 任何 deck 都必须以这页收尾。无论是汇报、提案、路演,缺尾页等于"话没说完"。
|
||
|
||
```python
|
||
slide = prs.slides.add_slide(BLANK)
|
||
apply_brand(slide, "end") # 整页浅灰 + 顶/底强调短线
|
||
add_textbox(slide, 0, 2.5, SLIDE_W, 1.6, "Thank You", 80, bold=True,
|
||
color=PRIMARY, align=PP_ALIGN.CENTER, font=EN_FONT,
|
||
name="thanks")
|
||
add_textbox(slide, 0, 4.3, SLIDE_W, 0.6, "欢迎提问与讨论",
|
||
22, color=ACCENT, align=PP_ALIGN.CENTER, name="qa")
|
||
add_textbox(slide, 0, 6.2, SLIDE_W, 0.5,
|
||
"联系方式 / 邮箱 / 公众号", 14, color=GREY_LIGHT,
|
||
align=PP_ALIGN.CENTER, name="contact")
|
||
```
|
||
|
||
---
|
||
|
||
## 选版式速查
|
||
|
||
```
|
||
有数据 ≥ 3 点 → L6 (Chart-focus)
|
||
对比类 (前/后, A/B) → L5 (Two-Column)
|
||
要点 ≤ 5 条 → L4 (Bullets)
|
||
转场 / 换章 → L3 (Section Divider)
|
||
首页 → L1 (Cover)
|
||
末页 → L9 (Q&A)
|
||
有大图 / 视觉优先 → L7 (Image-focus)
|
||
观点强调 / 名言 → L8 (Quote)
|
||
```
|
||
|
||
## 三个常犯的越界场景
|
||
|
||
1. **bullet 字数超额** —— 22pt 在 11.5 寸宽下每行约 50 个中文字。超过 1 行就溢出 0.7 in 高的框。**用 `assert_inside` + `auto_size=TEXT_TO_SHAPE_AND_FIT_TEXT` 兜底**;但根本解法是**字数压缩**(见 design_principles.md §字数预算)
|
||
2. **标题占两行** —— 标题在 0.7 in 高的框里,32pt 单行高约 0.45 in,**两行就溢出**。中文标题 ≤ 30 字
|
||
3. **图片不等比拉伸** —— `add_picture(width=, height=)` 同时给会变形;**只给 width 或 height 一项**
|