zcbot/skills/ppt/scripts/pptx_helpers.py

823 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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)
# 语义状态色(升/降)—— 数据趋势标注用,绿=正/红=负,业界通用约定。
# 不计入"商务红三色制"(quality_check 把绿色当语义状态色豁免)。
GOOD = RGBColor(0x1E, 0x9E, 0x62) # 增长 / 正向
BAD = RGBColor(0xD1, 0x34, 0x38) # 下降 / 风险
# —— 从主/辅/强调派生的明暗色阶 (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.42) # 加深:渐变要肉眼看得出深浅,别两端几乎同色
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:
"""写文本并设样式。**多行(含 \\n)时每一段都上色** —— 否则 `\\n` 产生的
第 2 段会继承主题默认色(踩过:封面副标题第二行变暗色看不见)。
font=None → 拉丁 EN_FONT + 东亚 CN_FONT;传 font 则两槽都用它(纯英文大字/数字)。"""
latin = font or EN_FONT
ea = font or CN_FONT
tf.text = text
for p in tf.paragraphs:
p.alignment = align
for r in p.runs:
_apply_run_font(r, 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=False, border=None, accent=None, accent_w=0.07,
name="card"):
"""圆角卡片。**视觉手段单选**(投影 / 描边 / 底色 三选一,不叠加 = 模板味)。
- 默认**平卡**:白底卡自动描发丝边定义边界(不投影)。平铺网格里的对等卡都该这样。
- shadow=True:**只给真正"悬浮"的卡**(照片上的卡、被挑出的推荐项);
pptmaster 铁律:每页 ≤2-3 个投影元素,对等网格卡一律平。
- fill 传 PRIMARY_WASH/SOFT 等浅底时,底色即是手段,不再描边。
- accent:左内缘细竖条(语义标记,标"这一张")—— 有它就不再自动描边。
"""
is_white = fill is None
fill = SURFACE if fill is None else fill
card = add_round_rect(slide, left, top, width, height, fill, radius, name)
if shadow:
set_shadow(card) # 手段:投影(悬浮卡专用)
else:
if border is None: # 手段:描边(仅白底平卡且无 accent 时自动)
border = is_white and accent is None
if border:
set_line(card, HAIRLINE, 1.0)
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, baseline=None,
delta=None, delta_dir=None, value_color=None, card=True,
value_size=40, name="kpi"):
"""KPI 数字卡:大号数字 + 标签 +(对比基准)+(升降趋势)。
**数据语境化铁律**(pptmaster):数字不要孤立出现。尽量给:
- baseline:对比基准,如 "行业均值 82%" / "上季 1.0M"(灰色小字)
- delta:趋势标注,如 "12.3%" / "+11pt";delta_dir 'up'/'down'/'flat' 决定升降色
(绿=正 / 红=负 / 灰=平);不传 delta_dir 则从 delta 开头的 +/-/↑/↓ 推断。
数据页优先 2-4 张并排,比小柱图信息密度与质感都高。value 走 EN_FONT。
"""
if card:
add_card(slide, left, top, width, height, fill=SURFACE, name=name + "_card")
pad = 0.28
add_textbox(slide, left + pad, top + pad, width - 2 * pad, height * 0.42,
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 + height * 0.42, width - 2 * pad,
0.36, label, 15, color=INK, anchor=MSO_ANCHOR.TOP,
name=name + "_label")
yb = top + height - 0.42
if delta:
if delta_dir is None:
s = str(delta)
delta_dir = ("up" if (s[:1] in "+↑" or "" in s or "" in s)
else "down" if (s[:1] in "-↓" or "" in s or "" in s)
else "flat")
dcol = GOOD if delta_dir == "up" else BAD if delta_dir == "down" else GREY
add_textbox(slide, left + pad, yb, width - 2 * pad, 0.34, str(delta),
13, bold=True, color=dcol, shrink=False, name=name + "_delta")
yb -= 0.32
if baseline:
add_textbox(slide, left + pad, yb, width - 2 * pad, 0.32, str(baseline),
12, color=GREY_LIGHT, shrink=False, name=name + "_base")
def add_takeaway(slide, text, top=None, name="takeaway"):
"""Takeaway Box:标题下一句话**结论**(浅主色底 + 左主色短条)。
咨询风内容页标配 —— 把"这页要讲什么"压成一句可带走的结论(pyramid 结论先行)。
例:"Q4 同比增 158%,创历史新高" 而不是 "营收情况"
"""
y = (SAFE_TOP + 1.0) if top is None else top
add_round_rect(slide, SAFE_LEFT, y, SAFE_W, 0.6, PRIMARY_WASH, radius=0.05,
name=name)
add_round_rect(slide, SAFE_LEFT, y, 0.09, 0.6, PRIMARY, radius=0.02,
name=name + "_bar")
add_textbox(slide, SAFE_LEFT + 0.32, y, SAFE_W - 0.6, 0.6, text, 16,
bold=True, color=INK, anchor=MSO_ANCHOR.MIDDLE,
name=name + "_txt")
def add_source(slide, text, name="source"):
"""数据来源标注(右下角弱化)。**含数据的页必标**(咨询风硬规则)。"""
add_textbox(slide, SAFE_LEFT, SLIDE_H - 0.48, SAFE_W, 0.32,
f"来源:{text}", 11, color=GREY_LIGHT, align=PP_ALIGN.RIGHT,
shrink=False, name=name)
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")
# ============================================================
# 组合版式件:均衡网格 / 时间轴 / 流程闭环 / 背景图
# —— 模型直接调一个函数,别再手摆参差网格 / 用卡片硬凑时间线
# ============================================================
import math as _math
import os as _os
_GRID_COLS = {1: 1, 2: 2, 3: 3, 4: 2, 5: 3, 6: 3, 7: 4, 8: 4, 9: 3}
def _unpack(item, keys, defaults):
if isinstance(item, dict):
return [item.get(k, d) for k, d in zip(keys, defaults)]
vals = list(item) + list(defaults)
return vals[:len(keys)]
def add_card_grid(slide, items, top, height, cols=None, gap=0.35,
icon_dir=None, icon_color="C00000", accent=None,
title_size=18, body_size=14, name="grid"):
"""一次摆 N 张概念卡,**自动均衡行列**(2×2 / 2×3,不再手摆参差 3+2)。
items: 每项 {icon, title, body} 或 (icon, title, body);icon 是图标名
(去 tabler_ 前缀,如 'target'),配 icon_dir 找 PNG;None 则不放图标。
top/height: 网格纵向区域(如 标题下 ~1.95 → 底部留页脚约 6.9)。
布局自适应:**单行**(rows=1)图标顶置成高特征卡;**多行**图标左置成横向卡
(正文拿到整卡高度,不会被顶置图标挤溢出)。正文请保持精炼(≤ ~18 字/卡)。
"""
n = len(items)
if cols is None:
cols = _GRID_COLS.get(n, 4)
rows = _math.ceil(n / cols)
cw = (SAFE_W - gap * (cols - 1)) / cols
ch = (height - gap * (rows - 1)) / rows
pad = 0.32
icon_top = rows == 1
for i, it in enumerate(items):
icon, title, body = _unpack(it, ("icon", "title", "body"), (None, "", ""))
r, c = divmod(i, cols)
x = SAFE_LEFT + c * (cw + gap)
y = top + r * (ch + gap)
add_card(slide, x, y, cw, ch, accent=accent, name=f"{name}_card_{i}")
has_icon = bool(icon and icon_dir)
png = (_os.path.join(str(icon_dir), f"tabler_{icon}_{icon_color}_128.png")
if has_icon else None)
if icon_top:
tile = max(0.95, min(1.3, cw * 0.30))
if has_icon:
add_icon_tile(slide, x + pad, y + 0.4, tile, png_path=png,
name=f"{name}_tile_{i}")
ty = y + 0.4 + tile + 0.2
else:
ty = y + pad
tx, tw = x + pad, cw - 2 * pad
add_textbox(slide, tx, ty, tw, 0.45, title, title_size, bold=True,
color=INK, name=f"{name}_t_{i}")
add_textbox(slide, tx, ty + 0.5, tw, y + ch - 0.25 - (ty + 0.5),
body, body_size, color=GREY, name=f"{name}_b_{i}")
else:
# 多行:图标左置,文字竖直居中(整卡高度给文字,不会被挤)
tile = max(0.72, min(0.95, ch * 0.46, cw * 0.26))
if has_icon:
add_icon_tile(slide, x + pad, y + (ch - tile) / 2, tile,
png_path=png, name=f"{name}_tile_{i}")
tx = x + pad + tile + 0.26
else:
tx = x + pad
tw = x + cw - pad - tx
blk = 1.15 # 文字块估高,用于竖直居中
ty = y + max(pad, (ch - blk) / 2)
add_textbox(slide, tx, ty, tw, 0.42, title, title_size, bold=True,
color=INK, name=f"{name}_t_{i}")
add_textbox(slide, tx, ty + 0.46, tw, y + ch - pad - (ty + 0.46),
body, body_size, color=GREY, name=f"{name}_b_{i}")
def add_timeline(slide, nodes, y=3.2, name="tl"):
"""横向时间轴:主轴线 + 均布节点(年份 pill 在上,标题/说明在下)。
nodes: list of {year, title, body} 或 (year, title, body)。3-6 个最佳。
发展历程 / 路线图 / 里程碑类内容用它,**别塞进卡片网格**。
"""
n = len(nodes)
x0 = SAFE_LEFT + 0.4
x1 = SAFE_RIGHT - 0.4
span = x1 - x0
add_rect(slide, x0, y, span, 0.035, PRIMARY, "tl_axis")
step = span / (n - 1) if n > 1 else 0
for i, nd in enumerate(nodes):
year, title, body = _unpack(nd, ("year", "title", "body"), ("", "", ""))
cx = x0 + i * step
d = 0.26
add_shape(slide, MSO_SHAPE.OVAL, cx - d / 2, y + 0.0175 - d / 2, d, d,
PRIMARY, f"tl_dot_{i}")
pw = 1.15
px = max(0.2, min(cx - pw / 2, SLIDE_W - pw - 0.2))
add_pill(slide, px, y - 0.66, pw, 0.42, str(year), fill=ACCENT,
fg=INK, size=14, name=f"tl_year_{i}")
bw = min(2.5, step * 0.96) if n > 1 else 3.0
tx = max(0.2, min(cx - bw / 2, SLIDE_W - bw - 0.2))
add_textbox(slide, tx, y + 0.42, bw, 0.45, title, 16, bold=True,
color=INK, align=PP_ALIGN.CENTER, name=f"tl_t_{i}")
add_textbox(slide, tx, y + 0.92, bw, 1.4, body, 14, color=GREY,
align=PP_ALIGN.CENTER, name=f"tl_b_{i}")
def add_cycle(slide, steps, cx=None, cy=4.5, radius=1.55, center_label=None,
name="cyc"):
"""流程闭环:节点沿圆环顺时针均布 + 可选中心词 + 浅环连线。
steps: list of {title, body} 或 (title, body)。4-6 个最佳。
"感知-规划-执行-反馈"这类**循环**用它,别做成平铺卡片(丢了闭环语义)。
"""
n = len(steps)
if cx is None:
cx = SLIDE_W / 2
ry = radius * 0.80 # 纵向压扁成椭圆,16:9 上更协调
ring = add_shape(slide, MSO_SHAPE.OVAL, cx - radius, cy - ry,
2 * radius, 2 * ry, WHITE, name + "_ring")
ring.fill.background()
set_line(ring, HAIRLINE, 1.5)
if center_label:
cd = radius * 0.80
add_shape(slide, MSO_SHAPE.OVAL, cx - cd / 2, cy - cd / 2, cd, cd,
PRIMARY_WASH, name + "_hub")
add_textbox(slide, cx - cd / 2, cy - cd / 2, cd, cd, center_label, 17,
bold=True, color=PRIMARY, align=PP_ALIGN.CENTER,
anchor=MSO_ANCHOR.MIDDLE, name=name + "_hublabel")
nd = 1.0
for i, st in enumerate(steps):
title, body = _unpack(st, ("title", "body"), ("", ""))
ang = _math.radians(-90 + i * 360 / n)
nx = cx + radius * _math.cos(ang)
ny = cy + ry * _math.sin(ang)
add_badge(slide, nx - nd / 2, ny - nd / 2, i + 1, diameter=nd)
lw = 2.1
lx = max(0.2, min(nx - lw / 2, SLIDE_W - lw - 0.2))
ly = (ny - nd / 2 - 0.46) if ny <= cy else (ny + nd / 2 + 0.06)
ly = max(0.2, min(ly, SLIDE_H - 0.4))
add_textbox(slide, lx, ly, lw, 0.4, title, 15, bold=True, color=INK,
align=PP_ALIGN.CENTER, name=f"{name}_t_{i}")
def add_toc(slide, items, top=2.2, row_h=None, name="toc"):
"""贯通整宽的目录:每行 = 序号 + 标题 +(右侧副标)+ 发丝分隔线。
items: 每项 (title, caption) 或 {title, caption} 或纯 title 字符串。
"左侧一列编号圆点"铺满版面、信息更足(副标给每章一句定位)。
"""
n = len(items)
if row_h is None:
row_h = min(0.95, (SAFE_BOTTOM - 0.2 - top) / n)
for i, it in enumerate(items):
if isinstance(it, str):
title, cap = it, ""
else:
title, cap = _unpack(it, ("title", "caption"), ("", ""))
y = top + i * row_h
add_textbox(slide, SAFE_LEFT, y, 1.05, row_h - 0.18, f"{i + 1:02d}", 34,
bold=True, color=PRIMARY, font=EN_FONT,
anchor=MSO_ANCHOR.MIDDLE, name=f"{name}_n_{i}")
add_textbox(slide, SAFE_LEFT + 1.25, y, 6.3, row_h - 0.18, title, 21,
bold=True, color=INK, anchor=MSO_ANCHOR.MIDDLE,
name=f"{name}_t_{i}")
if cap:
add_textbox(slide, SAFE_LEFT + 8.0, y, SAFE_W - 8.0, row_h - 0.18,
cap, 15, color=GREY, align=PP_ALIGN.RIGHT,
anchor=MSO_ANCHOR.MIDDLE, name=f"{name}_c_{i}")
add_divider(slide, SAFE_LEFT, y + row_h - 0.1, SAFE_W)
def add_picture_bg(slide, png):
"""整页铺一张渲染好的高清背景图(混合方案:背景图 + 其上原生可编辑文字)。
封面/章节用:先 `add_picture_bg(slide, bg.png)`,再叠 `add_textbox` 文字。
背景不可改但文字仍能在 PPT 里编辑 —— editable 前提下拿到的最佳观感。
"""
if png and Path(str(png)).exists():
return slide.shapes.add_picture(str(png), Inches(0), Inches(0),
width=Inches(SLIDE_W),
height=Inches(SLIDE_H))
return None
# ============================================================
# 演讲者备注
# ============================================================
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")