"""pptx_helpers.py — PPT skill 的共享版式工具箱(卡片式视觉系统)。 整 deck 在一个 `build_deck.py` 里构建,每页一个小函数,这些 helper 统一在 `P.` 命名空间下调用 —— 既省 token,又保证长 deck 里坐标/配色不漂移。 用法(在 build_deck.py 顶部): import sys; sys.path.insert(0, "/scripts") # 用 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="/...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("/.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 = "微软雅黑" # 中文字形走 槽位 EN_FONT = "Arial" # 拉丁字形走 槽位 # ============================================================ # 画布与安全区 (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` 只写 。中文字形走 槽位,不设的话会落到主题默认字体 —— 这就是「指定了微软雅黑却没真生效」的根因。 这里 latin=英文体、ea/cs=中文体,中英混排各自命中正确字体。 """ run.font.size = Pt(size) run.font.bold = bold run.font.color.rgb = color run.font.name = latin_font # 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: """给形状加柔和外投影(写 )。 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")