zcbot/skills/ppt/scripts/pptx_helpers.py

592 lines
24 KiB
Python

"""pptx_helpers.py — PPT skill 的共享版式工具箱(卡片式视觉系统)。
整 deck 在一个 `build_deck.py` 里构建,每页一个小函数,这些 helper 统一在
`P.` 命名空间下调用 —— 既省 token,又保证长 deck 里坐标/配色不漂移。
用法(在 build_deck.py 顶部):
import sys; sys.path.insert(0, "<skill_dir>/scripts") # <skill_dir> 用 system prompt 注入值
import pptx_helpers as P
prs = P.new_presentation("16:9") # 默认 16:9,可传 4:3 / 9:16 / 3:4
P.set_palette(spec_path="<task_dir>/...spec.md") # 默认商务红;spec 覆盖了才需要
slide = P.add_slide(prs)
P.apply_brand(slide, "cover")
P.add_textbox(slide, 0.9, 2.6, 11.9, 1.4, "标题", 44, bold=True, color=P.INK)
prs.save("<task_dir>/<topic>.pptx")
视觉系统(相对老版"平矩形 + 圆点 bullet"的升级):
- **卡片**:`add_card` 圆角 + 柔和投影 + 可选底色/边线/强调条 —— 内容页主力容器
- **色阶**:`set_palette` 从主/辅/强调派生 wash/soft/dark 明暗阶,白底之外有层次
- **渐变**:`add_gradient_rect` 用于封面/章节大色块(原生可编辑,非图片)
- **组件**:`add_kpi`(数字卡) `add_pill`(胶囊标签) `add_icon_tile`(图标底块)
`add_eyebrow`(小标签) `add_chevron`(流程箭头) `add_notes`(演讲者备注)
⚠️ 一律用 `P.xxx` 访问颜色常量与函数 —— set_palette 靠改模块属性生效,
`from pptx_helpers import *` 会把旧绑定拷进页面命名空间,覆盖配色不生效。
"""
from __future__ import annotations
import re
from pathlib import Path
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
from pptx.oxml.ns import qn
# ============================================================
# 配色 (商务红 — 硬约束默认)
# ============================================================
# ⛔ 不允许擅自换色:除非用户明确点名其它配色 或 spec 已写其它 hex,否则就是这套红。
# 要换走 set_palette(),禁止以"这场景蓝色更专业"这类自我合理化做替换。
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)
HAIRLINE = RGBColor(0xDD, 0xDD, 0xDD) # 细分隔线
BG = RGBColor(0xFA, 0xFA, 0xFA) # 背景近白
WHITE = RGBColor(0xFF, 0xFF, 0xFF)
# —— 从主/辅/强调派生的明暗色阶 (set_palette 里按当前三色重算) ——
# 卡片底色 / 章节渐变 / 标签底 都从这套阶取,避免"白底 + 纯红"两极、缺中间层次。
PRIMARY_WASH = RGBColor(0xF7, 0xE6, 0xE6) # 主色 92% 兑白 —— 整页/大区域浅底
PRIMARY_SOFT = RGBColor(0xF0, 0xCC, 0xCC) # 主色 80% 兑白 —— 卡片/标签浅底
PRIMARY_DARK = RGBColor(0x8A, 0x00, 0x00) # 主色压暗 —— 渐变深端
ACCENT_SOFT = RGBColor(0xFF, 0xEC, 0xB8) # 强调 80% 兑白 —— 高亮底
SURFACE = RGBColor(0xFF, 0xFF, 0xFF) # 卡片面(白,衬在 BG 上靠投影浮起)
CN_FONT = "微软雅黑" # 中文字形走 <a:ea> 槽位
EN_FONT = "Arial" # 拉丁字形走 <a:latin> 槽位
# ============================================================
# 画布与安全区 (new_presentation / load 会按实际尺寸回填这些)
# ============================================================
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
SAFE_H = SAFE_BOTTOM - SAFE_TOP
_CANVAS = {
"16:9": (13.33, 7.5),
"4:3": (10.0, 7.5),
"9:16": (7.5, 13.33),
"3:4": (7.5, 10.0),
}
def _recompute_safe() -> None:
global SAFE_LEFT, SAFE_TOP, SAFE_RIGHT, SAFE_BOTTOM, SAFE_W, SAFE_H
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
SAFE_H = SAFE_BOTTOM - SAFE_TOP
def new_presentation(canvas: str = "16:9") -> Presentation:
"""建空白 deck 并设画布尺寸,同步回填模块的安全区常量。第一页用。"""
global SLIDE_W, SLIDE_H
if canvas not in _CANVAS:
raise ValueError(f"未知画布 {canvas!r},支持 {list(_CANVAS)}")
SLIDE_W, SLIDE_H = _CANVAS[canvas]
_recompute_safe()
prs = Presentation()
prs.slide_width = Inches(SLIDE_W)
prs.slide_height = Inches(SLIDE_H)
return prs
def load(path) -> Presentation:
"""载入已有 deck,并按文件实际尺寸回填模块画布常量(逐页进程间自动同步)。"""
global SLIDE_W, SLIDE_H
prs = Presentation(str(path))
SLIDE_W = prs.slide_width / 914400
SLIDE_H = prs.slide_height / 914400
_recompute_safe()
return prs
def add_slide(prs: Presentation):
"""追加一张空白版式(layout 6)的 slide。"""
return prs.slides.add_slide(prs.slide_layouts[6])
# ============================================================
# 配色覆盖 (默认商务红;spec 写了别的色才调) + 色阶派生
# ============================================================
def _to_rgb(h: str) -> RGBColor:
return RGBColor.from_string(h.lstrip("#").upper())
def _mix(c1: RGBColor, c2: RGBColor, t: float) -> RGBColor:
"""线性混合:t=0 → c1,t=1 → c2。"""
return RGBColor(
round(c1[0] + (c2[0] - c1[0]) * t),
round(c1[1] + (c2[1] - c1[1]) * t),
round(c1[2] + (c2[2] - c1[2]) * t),
)
def tint(c: RGBColor, pct: float) -> RGBColor:
"""提亮:pct=0.85 → 兑 85% 白(越大越浅)。"""
return _mix(c, WHITE, pct)
def shade(c: RGBColor, pct: float) -> RGBColor:
"""压暗:pct=0.2 → 混 20% 黑(越大越深)。"""
return _mix(c, RGBColor(0, 0, 0), pct)
def _recompute_ramp() -> None:
"""按当前 PRIMARY/ACCENT 重算明暗色阶。set_palette 末尾调。"""
global PRIMARY_WASH, PRIMARY_SOFT, PRIMARY_DARK, ACCENT_SOFT
PRIMARY_WASH = tint(PRIMARY, 0.92)
PRIMARY_SOFT = tint(PRIMARY, 0.80)
PRIMARY_DARK = shade(PRIMARY, 0.22)
ACCENT_SOFT = tint(ACCENT, 0.78)
def set_palette(primary: str | None = None, secondary: str | None = None,
accent: str | None = None, cn_font: str | None = None,
en_font: str | None = None, spec_path=None) -> None:
"""覆盖主题色 / 字体,并重算派生色阶。整 deck 设一次。
- 显式传 primary/secondary/accent(hex,带不带 # 都行)即覆盖对应色。
- 传 spec_path:从 spec.md 按文档顺序取前 3 个 #hex 作 主/辅/强调
(spec 模板里配色行是 hex 唯一出现处)。找不到则保持商务红默认。
- 都不传 = 维持商务红,无副作用。
"""
global PRIMARY, SECONDARY, ACCENT, CN_FONT, EN_FONT
if spec_path:
p = Path(spec_path)
if p.exists():
hexes = re.findall(r"#([0-9A-Fa-f]{6})", p.read_text(encoding="utf-8"))
if len(hexes) >= 1 and primary is None:
primary = hexes[0]
if len(hexes) >= 2 and secondary is None:
secondary = hexes[1]
if len(hexes) >= 3 and accent is None:
accent = hexes[2]
if primary:
PRIMARY = _to_rgb(primary)
if secondary:
SECONDARY = _to_rgb(secondary)
if accent:
ACCENT = _to_rgb(accent)
if cn_font:
CN_FONT = cn_font
if en_font:
EN_FONT = en_font
_recompute_ramp()
# ============================================================
# 安全区校验
# ============================================================
def assert_inside(left, top, width, height, name="") -> None:
"""放置前调一次。越界直接报错而不是悄悄超出。"""
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}")
# ============================================================
# 文本辅助
# ============================================================
def _apply_run_font(run, size, bold, color, latin_font, ea_font) -> None:
"""设字号/粗细/颜色 + 同时设 latin(拉丁)与 ea/cs(东亚)字体。
关键:python-pptx 的 `run.font.name = x` 只写 <a:latin>。中文字形走 <a:ea>
槽位,不设的话会落到主题默认字体 —— 这就是「指定了微软雅黑却没真生效」的根因。
这里 latin=英文体、ea/cs=中文体,中英混排各自命中正确字体。
"""
run.font.size = Pt(size)
run.font.bold = bold
run.font.color.rgb = color
run.font.name = latin_font # <a:latin>
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", ea_font)
def set_text(tf, text, size, bold=False, color=INK, align=PP_ALIGN.LEFT,
font=None) -> None:
"""写单段文本并设样式。font=None → 拉丁 EN_FONT + 东亚 CN_FONT(推荐);
传 font 则 latin 与 ea 都用它(纯英文大字 / 纯数字时用)。"""
latin = font or EN_FONT
ea = font or CN_FONT
tf.text = text
p = tf.paragraphs[0]
p.alignment = align
_apply_run_font(p.runs[0], size, bold, color, latin, ea)
def add_textbox(slide, left, top, width, height, text, size,
bold=False, color=INK, align=PP_ALIGN.LEFT,
anchor=MSO_ANCHOR.TOP, font=None, shrink=True,
name="textbox"):
"""加文本框。默认 word_wrap + shrink-to-fit 兜底(不替代字数预算)。"""
assert_inside(left, top, width, height, name)
tb = slide.shapes.add_textbox(Inches(left), Inches(top),
Inches(width), Inches(height))
tb.name = name # 语义名写进 pptx —— quality_check 按名豁免标签 / 计 bullet 靠这个
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.name = name
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.name = name
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=None):
return add_shape(slide, MSO_SHAPE.OVAL, x, y, size, size,
ACCENT if color is None else color, "dot")
def add_accent_line(slide, x, y, length=1.0, thickness=0.05, color=None):
"""标题下面那条强调线,替代大色块。"""
return add_rect(slide, x, y, length, thickness,
ACCENT if color is None else color, "accent_line")
def add_badge(slide, x, y, num, diameter=0.7, fill=None, fg=None):
"""编号徽章 (圆 + 数字)。"""
c = add_shape(slide, MSO_SHAPE.OVAL, x, y, diameter, diameter,
PRIMARY if fill is None else fill, "badge")
tf = c.text_frame
tf.text = str(num)
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.CENTER
_apply_run_font(p.runs[0], int(diameter * 28), True,
WHITE if fg is None else fg, EN_FONT, EN_FONT)
return c
# ============================================================
# 视觉质感:投影 / 圆角 / 渐变 / 描边
# ============================================================
def set_shadow(shape, blur=0.10, dist=0.045, dir_deg=90, alpha=0.26,
color="000000") -> None:
"""给形状加柔和外投影(写 <a:effectLst><a:outerShdw>)。
blur/dist 单位英寸;dir_deg 投影方向(90=正下,默认);alpha 不透明度(0-1)。
卡片靠这个从背景"浮起",是平矩形与卡片观感的关键差。
"""
spPr = shape._element.spPr
for el in spPr.findall(qn("a:effectLst")):
spPr.remove(el)
eff = spPr.makeelement(qn("a:effectLst"), {})
shd = eff.makeelement(qn("a:outerShdw"), {
"blurRad": str(int(Inches(blur))),
"dist": str(int(Inches(dist))),
"dir": str(int(dir_deg * 60000)),
"rotWithShape": "0",
})
clr = shd.makeelement(qn("a:srgbClr"), {"val": color})
a = clr.makeelement(qn("a:alpha"), {"val": str(int(alpha * 100000))})
clr.append(a)
shd.append(clr)
eff.append(shd)
spPr.append(eff)
def set_line(shape, color, weight=0.75) -> None:
"""给形状描边(weight 单位 pt)。weight=0 / color=None 走无边线。"""
if color is None:
shape.line.fill.background()
return
shape.line.color.rgb = color
shape.line.width = Pt(weight)
def _round_adj(shape, radius_in) -> None:
"""把圆角矩形的圆角设成约 radius_in 英寸(adjustments[0] 是相对短边的比例)。"""
try:
short = min(shape.width, shape.height) / 914400.0
if short > 0:
shape.adjustments[0] = max(0.0, min(0.5, radius_in / short))
except (IndexError, ZeroDivisionError):
pass
def add_round_rect(slide, left, top, width, height, fill, radius=0.10,
name="round_rect"):
"""无边线圆角矩形。radius 单位英寸(约 0.08-0.14 观感最稳)。"""
assert_inside(left, top, width, height, name)
s = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE,
Inches(left), Inches(top),
Inches(width), Inches(height))
s.name = name
s.fill.solid()
s.fill.fore_color.rgb = fill
s.line.fill.background()
_round_adj(s, radius)
return s
def add_gradient_rect(slide, left, top, width, height, c1, c2, angle=90,
rounded=False, radius=0.10, name="gradient"):
"""渐变矩形(原生可编辑,非图片)。封面/章节大色块用。
angle:渐变方向(度,0=左→右,90=上→下)。rounded=True 走圆角。
"""
assert_inside(left, top, width, height, name)
kind = MSO_SHAPE.ROUNDED_RECTANGLE if rounded else MSO_SHAPE.RECTANGLE
s = slide.shapes.add_shape(kind, Inches(left), Inches(top),
Inches(width), Inches(height))
s.name = name
s.line.fill.background()
if rounded:
_round_adj(s, radius)
s.fill.gradient()
stops = s.fill.gradient_stops
stops[0].color.rgb = c1
stops[0].position = 0.0
stops[1].color.rgb = c2
stops[1].position = 1.0
try:
s.fill.gradient_angle = float(angle)
except (ValueError, TypeError):
pass
return s
def add_bg(slide, color=None):
"""整页背景色块(每页第一笔铺,后续元素叠其上)。默认近白 BG。"""
return add_rect(slide, 0, 0, SLIDE_W, SLIDE_H,
BG if color is None else color, "bg")
# ============================================================
# 卡片 (内容页主力容器) + 组件
# ============================================================
def add_card(slide, left, top, width, height, fill=None, radius=0.12,
shadow=True, border=False, accent=None, accent_w=0.07,
name="card"):
"""圆角卡片:白面 + 柔和投影 + 可选发丝边 + 可选左侧强调竖条。
- fill:卡片底色,默认 SURFACE(白)。想要浅色卡传 PRIMARY_SOFT / PRIMARY_WASH。
- accent:给个颜色则在卡片左内缘加一条竖强调条(常用 PRIMARY / ACCENT)。
内容(标题/正文/图标)再叠到卡片上,自己按 left+0.3 内边距摆。
"""
card = add_round_rect(slide, left, top, width, height,
SURFACE if fill is None else fill, radius, name)
if shadow:
set_shadow(card)
if border:
set_line(card, HAIRLINE, 0.75)
if accent is not None:
# 左内缘一条圆角竖强调条(无投影,纯色)
add_round_rect(slide, left + 0.18, top + 0.22, accent_w,
max(0.4, height - 0.44), accent, radius=0.04,
name=name + "_accent")
return card
def add_icon_tile(slide, x, y, size=0.9, png_path=None, fill=None,
radius=0.12, name="icon_tile"):
"""图标底块:圆角浅色方块 + 居中图标 PNG(没 PNG 就只出底块)。
fill 默认 PRIMARY_SOFT(主色浅底)。图标按 ~58% 居中,留呼吸。
业务概念页(战略/能力/模块)用它替代"光秃秃图标""只有圆点"
"""
tile = add_round_rect(slide, x, y, size, size,
PRIMARY_SOFT if fill is None else fill, radius,
name)
if png_path and Path(str(png_path)).exists():
ic = size * 0.56
off = (size - ic) / 2
slide.shapes.add_picture(str(png_path), Inches(x + off),
Inches(y + off), width=Inches(ic))
return tile
def add_icon(slide, png_path, x, y, size=0.6):
"""直接摆图标 PNG(方形源,只给 width 等比)。底块版用 add_icon_tile。"""
if png_path and Path(str(png_path)).exists():
return slide.shapes.add_picture(str(png_path), Inches(x), Inches(y),
width=Inches(size))
return None
def add_pill(slide, x, y, width, height, text, fill=None, fg=None, size=12,
name="pill"):
"""胶囊标签 / chip:全圆角小块 + 居中文字。分类标签、状态、eyebrow 用。"""
s = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE,
Inches(x), Inches(y),
Inches(width), Inches(height))
s.name = name
s.fill.solid()
s.fill.fore_color.rgb = PRIMARY if fill is None else fill
s.line.fill.background()
s.adjustments[0] = 0.5 # 全圆角
tf = s.text_frame
tf.word_wrap = False
tf.margin_top = tf.margin_bottom = 0
set_text(tf, text, size, bold=True, color=WHITE if fg is None else fg,
align=PP_ALIGN.CENTER)
tf.paragraphs[0].alignment = PP_ALIGN.CENTER
s.text_frame.vertical_anchor = MSO_ANCHOR.MIDDLE
return s
def add_eyebrow(slide, x, y, text, color=None, size=13, width=4.0):
"""小标签 / kicker:标题上方一行弱化前缀(如『核心结论 / 01』)。"""
return add_textbox(slide, x, y, width, 0.35, text, size, bold=True,
color=PRIMARY if color is None else color,
shrink=False, name="eyebrow")
def add_kpi(slide, left, top, width, height, value, label, sub=None,
value_color=None, card=True, value_size=40, name="kpi"):
"""KPI 数字卡:大号数字 + 下方标签 + 可选小注(同比/单位)。
数据页把"小柱状图 / 一行结论"升级成 2-4 张并排数字卡,信息密度与质感都更高。
value 走 EN_FONT(数字/百分号更紧致)。
"""
if card:
add_card(slide, left, top, width, height, fill=SURFACE, radius=0.12,
shadow=True, name=name + "_card")
pad = 0.28
vh = height * 0.5
add_textbox(slide, left + pad, top + pad, width - 2 * pad, vh, str(value),
value_size, bold=True,
color=PRIMARY if value_color is None else value_color,
font=EN_FONT, anchor=MSO_ANCHOR.BOTTOM, shrink=False,
name=name + "_val")
add_textbox(slide, left + pad, top + pad + vh, width - 2 * pad, 0.4, label,
15, color=INK, anchor=MSO_ANCHOR.TOP, name=name + "_label")
if sub:
add_textbox(slide, left + pad, top + height - 0.5, width - 2 * pad,
0.35, sub, 12, color=GREY_LIGHT, shrink=False,
name=name + "_sub")
def add_chevron(slide, x, y, width=0.55, height=0.5, color=None):
"""流程箭头(步骤之间的指向)。"""
return add_shape(slide, MSO_SHAPE.CHEVRON, x, y, width, height,
(GREY_LIGHT if color is None else color), "chevron")
def add_divider(slide, x, y, length, vertical=False, color=None):
"""细分隔线(横/竖)。"""
c = HAIRLINE if color is None else color
if vertical:
return add_rect(slide, x, y, 0.02, length, c, "divider")
return add_rect(slide, x, y, length, 0.02, c, "divider")
# ============================================================
# 演讲者备注
# ============================================================
def add_notes(slide, text) -> None:
"""写演讲者备注(演示时可见,正式产物标配)。每页给 2-4 句口述要点。"""
slide.notes_slide.notes_text_frame.text = text or ""
# ============================================================
# 标题套件 (内页通用)
# ============================================================
def page_title(slide, text, page_num=None, total=None, footer="项目汇报",
eyebrow=None):
"""内页标题 + 强调线 (+ 可选 eyebrow 小标签 + 页脚页码)。
eyebrow:标题上方一行弱化前缀(章节名 / 分类),给则标题整体下移。
"""
ty = SAFE_TOP
if eyebrow:
add_eyebrow(slide, SAFE_LEFT, SAFE_TOP, eyebrow)
ty = SAFE_TOP + 0.4
add_textbox(slide, SAFE_LEFT, ty, SAFE_W, 0.7, text,
32, bold=True, color=PRIMARY, name="title")
add_accent_line(slide, SAFE_LEFT, ty + 0.85, length=0.8)
if page_num is not None and total is not None:
add_textbox(slide, SAFE_LEFT, SLIDE_H - 0.5, 6, 0.4, footer,
11, color=GREY_LIGHT, shrink=False, name="footer")
add_textbox(slide, SLIDE_W - 1.33, SLIDE_H - 0.5, 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"):
"""统一品牌锚点。每个版式第一行调用。
cover —— 右侧主色→深主色渐变大块 + 左侧细强调短线(现代封面)
inner —— (默认) 近白底 + 左侧主色窄条 + 底部细灰线
section —— 主色→深主色整页渐变 + 强调装饰条(章节大色块)
end —— 浅底 + 顶/底强调短线
"""
btm = SLIDE_H - 0.32
if kind == "cover":
add_bg(slide, WHITE)
# 右侧约 38% 宽的渐变色块,封面从"白纸加条"升级成有视觉重量的构图
bw = SLIDE_W * 0.40
add_gradient_rect(slide, SLIDE_W - bw, 0, bw, SLIDE_H,
PRIMARY, PRIMARY_DARK, angle=60, name="cover_block")
add_rect(slide, SAFE_LEFT, 0.7, 0.55, 0.07, ACCENT, "brand_top_line")
add_rect(slide, SAFE_LEFT, btm, SLIDE_W - bw - SAFE_LEFT - 0.3, 0.02,
HAIRLINE, "brand_btm_hairline")
elif kind == "section":
add_gradient_rect(slide, 0, 0, SLIDE_W, SLIDE_H,
PRIMARY, PRIMARY_DARK, angle=55, name="section_bg")
add_rect(slide, 0.7, SLIDE_H / 3, 0.09, SLIDE_H / 3, ACCENT,
"brand_section_bar")
elif kind == "end":
add_bg(slide, PRIMARY_WASH)
add_rect(slide, SAFE_LEFT, 0.6, 0.8, 0.06, ACCENT, "brand_top_line")
add_rect(slide, SAFE_RIGHT - 0.8, SLIDE_H - 0.65, 0.8, 0.06, ACCENT,
"brand_btm_line")
else: # inner
add_bg(slide, BG)
add_rect(slide, 0, 0, 0.10, SLIDE_H, PRIMARY, "brand_left_bar")
add_rect(slide, SAFE_LEFT, btm, SAFE_W, 0.02, HAIRLINE,
"brand_btm_hairline")