"""pptx_helpers.py — PPT skill 的共享版式工具箱。 逐页生成时**每页一个 run_python**(载入已有 .pptx → append 一页 → save), 这些 helper 以前要在每页里重新默写一遍 —— 既烧 token 又会在长 deck 里漂移 (第 7 页的 apply_brand 坐标和第 2 页写得不一样)。收进本模块后,每页只 import。 用法(在 run_python block 顶部): 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") # —— 后续页(追加)—— prs = P.load("/.pptx") # 从文件实际尺寸回填画布常量 P.set_palette(spec_path="/...spec.md") # 每页都重读 spec(同 SKILL.md 规则) slide = P.add_slide(prs) ... prs.save("/.pptx") ⚠️ 一律用 `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 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) 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 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: """覆盖主题色 / 字体。逐页生成时每页都调一次(对齐 SKILL.md「每页重读 spec」)。 - 显式传 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 # ============================================================ # 安全区校验 # ============================================================ 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: """写单段文本并设样式。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)) tf = tb.text_frame tf.vertical_anchor = anchor tf.word_wrap = True if shrink: tf.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE set_text(tf, text, size, bold, color, align, font) return tb # ============================================================ # 形状辅助 (无边线实心填充) # ============================================================ def add_rect(slide, left, top, width, height, fill, name="rect"): assert_inside(left, top, width, height, name) s = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(left), Inches(top), Inches(width), Inches(height)) s.fill.solid() s.fill.fore_color.rgb = fill s.line.fill.background() return s def add_shape(slide, kind, left, top, width, height, fill, name="shape"): assert_inside(left, top, width, height, name) s = slide.shapes.add_shape(kind, Inches(left), Inches(top), Inches(width), Inches(height)) s.fill.solid() s.fill.fore_color.rgb = fill s.line.fill.background() return s def add_dot(slide, x, y, size=0.18, color=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 page_title(slide, text, page_num=None, total=None, footer="项目汇报"): add_textbox(slide, SAFE_LEFT, SAFE_TOP, SAFE_W, 0.7, text, 32, bold=True, color=PRIMARY, name="title") add_accent_line(slide, SAFE_LEFT, SAFE_TOP + 0.85, length=0.8) 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_rect(slide, 0, 0, 0.18, SLIDE_H, PRIMARY, "brand_left_bar") add_rect(slide, 0.7, 0.6, 0.8, 0.06, PRIMARY, "brand_top_line") add_rect(slide, SAFE_LEFT, btm, SAFE_W, 0.02, HAIRLINE, "brand_btm_hairline") elif kind == "section": add_rect(slide, 0, 0, SLIDE_W, SLIDE_H, BG, "brand_bg") add_rect(slide, 0, 0, 0.18, SLIDE_H, PRIMARY, "brand_left_bar") add_rect(slide, 0.7, SLIDE_H / 3, 0.08, SLIDE_H / 3, ACCENT, "brand_section_bar") elif kind == "end": add_rect(slide, 0, 0, SLIDE_W, SLIDE_H, BG, "brand_bg") add_rect(slide, SAFE_LEFT, 0.6, 0.8, 0.06, ACCENT, "brand_top_line") add_rect(slide, SAFE_RIGHT - 0.8, SLIDE_H - 0.65, 0.8, 0.06, ACCENT, "brand_btm_line") else: # inner add_rect(slide, 0, 0, 0.10, SLIDE_H, PRIMARY, "brand_left_bar") add_rect(slide, SAFE_LEFT, btm, SAFE_W, 0.02, HAIRLINE, "brand_btm_hairline")