zcbot/skills/ppt/scripts/pptx_helpers.py

317 lines
13 KiB
Python

"""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, "<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")
# —— 后续页(追加)——
prs = P.load("<task_dir>/<topic>.pptx") # 从文件实际尺寸回填画布常量
P.set_palette(spec_path="<task_dir>/...spec.md") # 每页都重读 spec(同 SKILL.md 规则)
slide = P.add_slide(prs)
...
prs.save("<task_dir>/<topic>.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 = "微软雅黑" # 中文字形走 <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 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` 只写 <a:latin>。中文字形走 <a:ea>
槽位,不设的话会落到主题默认字体 —— 这就是「指定了微软雅黑却没真生效」的根因。
这里 latin=英文体、ea/cs=中文体,中英混排各自命中正确字体。
"""
run.font.size = Pt(size)
run.font.bold = bold
run.font.color.rgb = color
run.font.name = latin_font # <a:latin>
rPr = run._r.get_or_add_rPr()
for tag in ("a:ea", "a:cs"):
el = rPr.find(qn(tag))
if el is None:
el = rPr.makeelement(qn(tag), {})
rPr.append(el)
el.set("typeface", ea_font)
def set_text(tf, text, size, bold=False, color=INK, align=PP_ALIGN.LEFT,
font=None) -> None:
"""写单段文本并设样式。font=None → 拉丁 EN_FONT + 东亚 CN_FONT(推荐);
传 font 则 latin 与 ea 都用它(纯英文大字 / 纯数字时用)。"""
latin = font or EN_FONT
ea = font or CN_FONT
tf.text = text
p = tf.paragraphs[0]
p.alignment = align
_apply_run_font(p.runs[0], size, bold, color, latin, ea)
def add_textbox(slide, left, top, width, height, text, size,
bold=False, color=INK, align=PP_ALIGN.LEFT,
anchor=MSO_ANCHOR.TOP, font=None, shrink=True,
name="textbox"):
"""加文本框。默认 word_wrap + shrink-to-fit 兜底(不替代字数预算)。"""
assert_inside(left, top, width, height, name)
tb = slide.shapes.add_textbox(Inches(left), Inches(top),
Inches(width), Inches(height))
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")