823 lines
35 KiB
Python
823 lines
35 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)
|
||
# 语义状态色(升/降)—— 数据趋势标注用,绿=正/红=负,业界通用约定。
|
||
# 不计入"商务红三色制"(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")
|