diff --git a/PROGRESS.md b/PROGRESS.md index 6a9ee93..ce18b49 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -23,6 +23,8 @@ ### 2026-06-04 +- **ppt skill 版式 helper 收进可 import 的模块 + 修中文字体没真生效**:逐页生成是「每页一个 run_python」,以前 ~150 行 helper(配色常量/`add_textbox`/`apply_brand` 等)要在每页里默写一遍 —— 烧 token 且长 deck 里坐标会漂(第 7 页 `apply_brand` 跟第 2 页对不上)。抽出 `skills/ppt/scripts/pptx_helpers.py`,每页 `import pptx_helpers as P` 调用;新增 `new_presentation`/`load`(按文件实际尺寸回填画布常量,逐页进程间同步)/`add_slide`/`set_palette`(默认商务红,`spec_path=` 自动取 spec 前 3 个 hex 作主/辅/强调)入口。**字体修复**:python-pptx `font.name` 只写 ``,中文字形走 `` 槽位没设 → 「指定微软雅黑却没真生效」的根因;`set_text` 改为同时写 latin=Arial + ea/cs=微软雅黑,中英混排各命中正确字体。改 `layouts.md`(helper 块换成 import 起手 + API 速查,9 个示例全改 `P.` 调用)、`icons.md` A5 示例、`SKILL.md` 资源/阶段二。冒烟测试过:`ea` 确写入、`set_palette` 覆盖生效、quality_check 正常解析。 +- **ppt `quality_check.py` 配色检查纳入形状填充色 + 改按三色制判定**:原来只数 `run.font.color`(文字色),品牌条/徽章/圆点/标签/底块的**填充色全漏**——而这些恰是最易跑偏处。加 `_shape_fill_hex`(取纯色实心填充,主题色/非实心挡掉)并入 `seen_colors`。同时把粗阈值「≤5 色」改成贴合三色制的「非灰阶色 ≤3」:`_is_neutral`(R/G/B 极差 ≤12 视为灰/黑/白)把中性色排除——否则一旦计入填充,合规商务红 deck(INK/GREY/HAIRLINE/BG/WHITE+3 红)轻松超 6 狂报假阳;spec 比对也只比非灰阶色。测试过:合规红 deck 无配色 warning、塞 4+ 彩色触发、ACCENT 强调线填充被正确捕获。 - **前端顶栏展示用户已用存储**:后端已有 `user_disk_usage` 表(后台 15min 扫描落库),但无对外查询口。加 `GET /v1/user/storage`(`Depends(require_user)`),返 `{bytes_used, file_count, limit_bytes, scanned_at}`,`limit_bytes` 由 `parse_bytes(quotas.disk_bytes_per_user)` 得(≤0/None=不限)。`disk_quota.get_user_usage` 扩为返 `(bytes,count,scanned_at)` 三元组(复用而非新开函数,顺手改唯一调用方 `check_disk_quota` 解包)。前端 `dev.html` 右侧「文件」面板底部钉一条进度条+文字指示器(`#pane-right` 改 flex 列让 `#file-list` 独占滚动、存储条钉底;`loadStorage()` 在 `enterApp` 拉一次;不限额时只显已用、隐进度条;超额变红;hover 显文件数+统计时间)。 - **sandbox 容器 env 收编到一处 + shell 也注入(修两个只读 rootfs 副作用)**:① `PYTHONPATH=/sandbox:/workspace` 原先只 `run_python` 注入,shell 里 `python -c "from skills..."` 撞 ModuleNotFoundError;② `--read-only` rootfs 下 `/home/zcbot` 不可写,matplotlib/fontconfig 往 `~/.config`/`~/.cache` 写缓存刷 "Read-only file system" / "No writable cache" 噪音。改:`executor_docker.py` 抽 `_CONTAINER_ENV = {PYTHONPATH, HOME=/tmp}`,shell/run_python/fs 三路共用(`-e` 确定性覆盖)—— `HOME=/tmp` 一刀让缓存落 tmpfs(matplotlib→/tmp/.config、fontconfig→/tmp/.cache),不用逐个 MPLCONFIGDIR/XDG_CACHE_HOME。纯代码改,重启 web 生效,免重建镜像。 diff --git a/skills/ppt/SKILL.md b/skills/ppt/SKILL.md index 9de498e..892fba9 100644 --- a/skills/ppt/SKILL.md +++ b/skills/ppt/SKILL.md @@ -8,8 +8,9 @@ description: 生成 PowerPoint 演示文稿 (.pptx) 文件。✅ 触发:用户 把材料变成可演示的 .pptx。**先定调,再出稿,再验收** —— 不要一口气把整份 deck 丢出去。 ## 资源 +- `scripts/pptx_helpers.py` —— **版式工具箱模块**:配色/字体常量 + `new_presentation`/`load`/`add_slide`/`set_palette` + `add_textbox`/`add_rect`/`add_dot`/`add_badge`/`page_title`/`apply_brand` 等 helper。每页 `import pptx_helpers as P` 调用,**不要把 helper 源码默写进 run_python** - `references/design_principles.md` —— 画布尺寸 + 字号/配色/留白/字数预算等硬规则 -- `references/layouts.md` —— 9 种版式的 python-pptx 起手代码 + 安全区/越界保护 + `apply_brand` 品牌条 +- `references/layouts.md` —— 9 种版式的调用示例(基于 `pptx_helpers`)+ helper API 速查 + 安全区/越界保护 + `apply_brand` 品牌条 - `references/icons.md` —— 业务图标两层:Iconify (在线/本地缓存) / unicode 字形兜底 - `assets/icons/` —— **只读**种子图标库 (skill 自带的商务红 tabler 集,见 `INDEX.md`;docker 沙盒里 skills 是只读挂载。新拉的图标写 `/assets/icons/`) - 素材摄取: 用 `markitdown` CLI 把 PDF/DOCX/PPTX/XLSX/HTML/URL 转干净 Markdown,统一落到 `/source/.md`(同 working_dir 多 task 共享 source 池) @@ -75,7 +76,7 @@ glob /*--*.spec.md → 按文件名字典序排,取最 每页流程: 1. 读 current spec(即使刚读过) 2. **图标先于版式**: 这一页要用什么概念图标? 先 `glob` 两处看有没有现成 —— 种子库 `/assets/icons/`(只读,`` 是 `load_skill` 头里的绝对路径)+ 本 task `/assets/icons/`;没有就 `python /scripts/fetch_icon.py --set tabler --color C00000 --size 128 -o /assets/icons/...` 拉一个(种子库只读,新图标落 task 目录);`add_picture` 嵌入。**几何形状(圆点/徽章/装饰线)不算图标,走 layouts.md helper 即可** -3. 写一个 `run_python` block,用 python-pptx 添加这一页 (载入已有 .pptx → append slide → save) +3. 写一个 `run_python` block 添加这一页:顶部 `import pptx_helpers as P`(`sys.path` 指到 `/scripts`)→ `prs = P.load(...)`(首页用 `P.new_presentation`)→ `P.set_palette(spec_path=...)`(每页重读 spec 注入配色)→ `P.add_slide` + 各 helper → `prs.save`。**helper 一律 `P.xxx` 调用,不默写源码**(防长 deck 漂移),起手见 `layouts.md §通用起手` 4. 报这一页:版式、标题、要点条数、用了哪些图标 5. 用户确认 / 微调后再下一页 6. 用户确认了**实质改动**(改版式 / 换图标 / 改文案要点 / 增删页 / 调主色)后,追加一行到 `/REVISIONS.md` —— 见 §修订日志 diff --git a/skills/ppt/references/icons.md b/skills/ppt/references/icons.md index 6d77b61..f538055 100644 --- a/skills/ppt/references/icons.md +++ b/skills/ppt/references/icons.md @@ -54,13 +54,15 @@ slide.shapes.add_picture( 需要"调研→设计→开发→测试→上线"这种横向流程时,**不要用 PowerPoint 内置 PENTAGON**(视觉陈旧),改用 Iconify 的 `chevron-right` + 文本组合: ```python -from pptx.util import Inches, Pt +from pptx.util import Inches +from pptx.enum.text import PP_ALIGN +# 假设页面顶部已 import pptx_helpers as P,且 slide 已建(见 layouts.md §通用起手) stages = ["调研","设计","开发","测试","上线"] icon_path = "/assets/icons/tabler_chevron-right_C00000_64.png" # 先 fetch_icon.py 拉到 task,种子库没有 chevron-right_64 for i, label in enumerate(stages): x = 0.7 + i * 2.4 - add_textbox(slide, x, 3.7, 1.8, 0.5, label, 16, bold=True, - color=PRIMARY, align=PP_ALIGN.CENTER, name=f"stage_{i}") + P.add_textbox(slide, x, 3.7, 1.8, 0.5, label, 16, bold=True, + color=P.PRIMARY, align=PP_ALIGN.CENTER, name=f"stage_{i}") if i < len(stages) - 1: # 节点间放 chevron slide.shapes.add_picture(icon_path, Inches(x + 1.85), Inches(3.7), width=Inches(0.4)) diff --git a/skills/ppt/references/layouts.md b/skills/ppt/references/layouts.md index 40d2e6e..157368e 100644 --- a/skills/ppt/references/layouts.md +++ b/skills/ppt/references/layouts.md @@ -1,188 +1,78 @@ # 9 种常用版式 (16:9, 13.33×7.5 in) -> **2.0 版本要点**:大幅减少满铺色块,引入 MSO_SHAPE 图标点缀,所有元素经 safe_area 校验不会越出画布。 +> **要点**:版式 helper 已全部收进 `scripts/pptx_helpers.py`,**不要再把 helper 源码默写进每页的 run_python** —— 每页只 `import pptx_helpers as P` 然后调用。这样长 deck 里不会出现第 7 页和第 2 页的 `apply_brand` 坐标对不上的漂移,也省 token。配色用 current spec(命名见 SKILL.md §阶段一)里的实际 hex —— 通过 `P.set_palette()` 注入,默认商务红。 -复制 → 改文案 → 跑。配色用 current spec(命名见 SKILL.md §阶段一)里的实际 hex 替换占位。 - -## 通用起手 + 安全辅助 +## 通用起手 (每页 run_python 顶部) ```python -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 +import sys +sys.path.insert(0, "/scripts") # 用 system prompt 注入的绝对路径替换 +import pptx_helpers as P -# ---- 配色 (商务红 — 硬约束默认) ---- -# ⛔ 不允许擅自换色:除非用户明确点名其它配色 (例:"做成蓝色") 或 spec 已写其它 hex, -# 否则就是这套商务红。禁止以"这个场景蓝色更专业"这类自我合理化做替换。 -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) -BG = RGBColor(0xFA, 0xFA, 0xFA) # 背景近白 -WHITE = RGBColor(255, 255, 255) +# —— 第一页(创建 deck)—— +prs = P.new_presentation("16:9") # 默认 16:9;可传 "4:3" / "9:16" / "3:4" +P.set_palette(spec_path="/--.spec.md") +slide = P.add_slide(prs) +# ... 见下面各 L 版式 ... +prs.save("/.pptx") -CN_FONT = "微软雅黑" -EN_FONT = "Arial" - -# ---- 画布与安全区 ---- -prs = Presentation() -prs.slide_width = Inches(13.33) -prs.slide_height = Inches(7.5) -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 # 11.93 -SAFE_H = SAFE_BOTTOM - SAFE_TOP # 6.5 -BLANK = prs.slide_layouts[6] - -def assert_inside(left, top, width, height, name=""): - """放置前调一次。越界直接报错而不是悄悄超出。""" - 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}") - -# ---- 文本辅助 (默认 word_wrap, shrink-to-fit 兜底) ---- -def set_text(tf, text, size, bold=False, color=INK, align=PP_ALIGN.LEFT, - font=CN_FONT): - tf.text = text - p = tf.paragraphs[0]; p.alignment = align - r = p.runs[0] - r.font.name = font; r.font.size = Pt(size); r.font.bold = bold - r.font.color.rgb = color - -def add_textbox(slide, left, top, width, height, text, size, - bold=False, color=INK, align=PP_ALIGN.LEFT, - anchor=MSO_ANCHOR.TOP, font=CN_FONT, shrink=True, - name="textbox"): - 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=ACCENT): - return add_shape(slide, MSO_SHAPE.OVAL, x, y, size, size, color, "dot") - -def add_accent_line(slide, x, y, length=1.0, thickness=0.05, color=ACCENT): - """标题下面那条强调线,替代大色块""" - return add_rect(slide, x, y, length, thickness, color, "accent_line") - -def add_badge(slide, x, y, num, diameter=0.7, fill=PRIMARY, fg=WHITE): - """编号徽章 (圆 + 数字)""" - c = add_shape(slide, MSO_SHAPE.OVAL, x, y, diameter, diameter, fill, "badge") - tf = c.text_frame; tf.text = str(num) - p = tf.paragraphs[0]; p.alignment = PP_ALIGN.CENTER - r = p.runs[0] - r.font.bold = True; r.font.size = Pt(int(diameter * 28)) - r.font.color.rgb = fg; r.font.name = 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, color=ACCENT) - if page_num is not None and total is not None: - add_textbox(slide, SAFE_LEFT, 7.0, 6, 0.4, footer, - 11, color=GREY_LIGHT, shrink=False, name="footer") - add_textbox(slide, 12.0, 7.0, 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"): - """统一品牌锚点。每个版式第一行调用,给一条窄的主色锚点 + 必要时浅底。 - kind: - cover —— 封面: 左侧主色长竖条 + 顶部短横 - inner —— 内页 (默认): 左侧主色窄条 (从标题到底部) - section —— 分章: 整页浅灰 + 左侧强调色粗竖条 - end —— 结尾: 整页浅灰 + 顶/底强调色短线 - """ - 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, 7.18, SAFE_W, 0.02, - RGBColor(0xDD, 0xDD, 0xDD), "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, 2.5, 0.08, 2.5, 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, 6.85, 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, 7.18, SAFE_W, 0.02, - RGBColor(0xDD, 0xDD, 0xDD), "brand_btm_hairline") +# —— 后续页(追加到已有 deck)—— +prs = P.load("/.pptx") # 从文件实际尺寸回填画布常量 +P.set_palette(spec_path="/--.spec.md") # 每页都重读 spec +slide = P.add_slide(prs) +# ... 见下面各 L 版式 ... +prs.save("/.pptx") ``` -> **要点**: -> - `assert_inside` 阻止任何越界。元素超出画布会立刻报 `ValueError`,而不是悄悄裁剪 -> - `add_textbox` 默认 `word_wrap=True` + `auto_size=TEXT_TO_SHAPE_AND_FIT_TEXT` —— 文字溢出自动缩字号 -> - `page_title` 用细线代替大块色填,所有内页统一调用 +⚠️ 一律用 `P.xxx`(不要 `from pptx_helpers import *`)—— `set_palette` 靠改模块属性覆盖配色,`import *` 会把旧绑定拷进页面命名空间导致覆盖不生效。 + +## Helper API 速查 (都在 `P.` 命名空间下) + +**画布 / 配色入口** +- `P.new_presentation(canvas="16:9")` → 建空 deck,设画布,回填 `P.SLIDE_W/H` 与安全区 +- `P.load(path)` → 载入已有 deck,按文件实际尺寸回填画布常量(逐页进程间自动同步) +- `P.add_slide(prs)` → 追加一张空白版式(layout 6)slide +- `P.set_palette(primary=, secondary=, accent=, cn_font=, en_font=, spec_path=)` → 覆盖主题色/字体;传 `spec_path` 自动从 spec.md 按文档顺序取前 3 个 #hex 作 主/辅/强调;**默认商务红,什么都不传无副作用** + +**颜色常量**:`P.PRIMARY` `P.SECONDARY` `P.ACCENT` `P.INK` `P.GREY` `P.GREY_LIGHT` `P.HAIRLINE` `P.BG` `P.WHITE` +**字体常量**:`P.CN_FONT`(微软雅黑) `P.EN_FONT`(Arial) +**画布常量**:`P.SLIDE_W` `P.SLIDE_H` `P.SAFE_LEFT/TOP/RIGHT/BOTTOM` `P.SAFE_W` `P.SAFE_H` + +**放置 helper**(全部内置 `assert_inside` 越界即报错) +- `P.add_textbox(slide, left, top, w, h, text, size, bold=False, color=P.INK, align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.TOP, font=None, shrink=True, name=...)` → 文本框;`font=None` 自动 latin=Arial + 东亚=微软雅黑(**中文真落到雅黑靠这个**),传 `font` 则两槽都用它(纯英文大字/数字) +- `P.add_rect(slide, left, top, w, h, fill, name=...)` → 无边线实心矩形 +- `P.add_shape(slide, kind, left, top, w, h, fill, name=...)` → 任意 MSO_SHAPE(`kind` 用 `MSO_SHAPE.XXX`) +- `P.add_dot(slide, x, y, size=0.18, color=P.ACCENT)` → 圆点(bullet 前缀) +- `P.add_accent_line(slide, x, y, length=1.0, thickness=0.05, color=P.ACCENT)` → 强调短线 +- `P.add_badge(slide, x, y, num, diameter=0.7, fill=P.PRIMARY, fg=P.WHITE)` → 编号徽章(圆+数字) +- `P.page_title(slide, text, page_num=None, total=None, footer="项目汇报")` → 内页标题+强调线(+可选页脚页码) +- `P.apply_brand(slide, kind)` → 品牌锚点,`kind` ∈ `"cover"/"inner"/"section"/"end"`;**每页第一行必调** +- `P.assert_inside(left, top, w, h, name="")` → 手动越界校验(上面的 helper 已内置) + +> `MSO_SHAPE` / `PP_ALIGN` / `MSO_ANCHOR` 等枚举若页面里要直接用,自行 `from pptx.enum.shapes import MSO_SHAPE` 等(`pptx_helpers` 内部已 import,但不重导出)。 --- ## L1 · 封面 (Cover) —— 主色长竖条锚点 ```python -slide = prs.slides.add_slide(BLANK) -apply_brand(slide, "cover") # 左侧主色长竖条 + 顶部短横 +slide = P.add_slide(prs) +P.apply_brand(slide, "cover") # 左侧主色长竖条 + 顶部短横 # 主标题 (避开左竖条) -add_textbox(slide, 0.9, 2.6, 11.9, 1.4, "项目名称 / 演示主题", - 44, bold=True, color=INK, name="cover_title") +P.add_textbox(slide, 0.9, 2.6, 11.9, 1.4, "项目名称 / 演示主题", + 44, bold=True, color=P.INK, name="cover_title") # 副标题 (灰色,弱化) -add_textbox(slide, 0.9, 4.1, 11.9, 0.6, "一句话副标题或定位", - 22, color=GREY, name="cover_sub") +P.add_textbox(slide, 0.9, 4.1, 11.9, 0.6, "一句话副标题或定位", + 22, color=P.GREY, name="cover_sub") # 汇报人 / 日期 -add_textbox(slide, 0.9, 6.4, 11.9, 0.4, - "汇报人 · 部门 · 2026-05-06", 14, color=GREY_LIGHT, - name="cover_meta") +P.add_textbox(slide, 0.9, 6.4, 11.9, 0.4, + "汇报人 · 部门 · 2026-05-06", 14, color=P.GREY_LIGHT, + name="cover_meta") # 右下角小图标点缀 (五角星,可选) -add_shape(slide, MSO_SHAPE.STAR_5_POINT, 12.2, 6.3, 0.5, 0.5, ACCENT, - "deco_star") +from pptx.enum.shapes import MSO_SHAPE +P.add_shape(slide, MSO_SHAPE.STAR_5_POINT, 12.2, 6.3, 0.5, 0.5, P.ACCENT, + "deco_star") ``` --- @@ -190,17 +80,18 @@ add_shape(slide, MSO_SHAPE.STAR_5_POINT, 12.2, 6.3, 0.5, 0.5, ACCENT, ## L2 · 目录 (Agenda) —— 编号徽章 + 文字 ```python -slide = prs.slides.add_slide(BLANK) -apply_brand(slide, "inner") -page_title(slide, "目录") +from pptx.enum.text import MSO_ANCHOR +slide = P.add_slide(prs) +P.apply_brand(slide, "inner") +P.page_title(slide, "目录") items = ["背景与现状", "核心问题", "解决方案", "实施计划", "预期成果"] for i, item in enumerate(items): y = 1.9 + i * 0.95 - add_badge(slide, SAFE_LEFT, y, i + 1, diameter=0.65) - add_textbox(slide, SAFE_LEFT + 1.0, y, SAFE_W - 1.0, 0.65, - item, 22, color=INK, anchor=MSO_ANCHOR.MIDDLE, - name=f"agenda_{i}") + P.add_badge(slide, P.SAFE_LEFT, y, i + 1, diameter=0.65) + P.add_textbox(slide, P.SAFE_LEFT + 1.0, y, P.SAFE_W - 1.0, 0.65, + item, 22, color=P.INK, anchor=MSO_ANCHOR.MIDDLE, + name=f"agenda_{i}") ``` --- @@ -208,22 +99,24 @@ for i, item in enumerate(items): ## L3 · 章节分隔 (Section Divider) —— 浅色背景 + 大字编号 ```python -slide = prs.slides.add_slide(BLANK) -apply_brand(slide, "section") # 整页浅灰 + 主色左竖条 + 强调装饰 -# 大编号 (主色,描边视觉感) -add_textbox(slide, 1.1, 2.0, 4, 2.5, "01", 160, bold=True, - color=PRIMARY, font=EN_FONT, name="sec_num") +from pptx.enum.text import MSO_ANCHOR +from pptx.enum.shapes import MSO_SHAPE +slide = P.add_slide(prs) +P.apply_brand(slide, "section") # 整页浅灰 + 主色左竖条 + 强调装饰 +# 大编号 (主色;font=EN_FONT 让数字走 Arial) +P.add_textbox(slide, 1.1, 2.0, 4, 2.5, "01", 160, bold=True, + color=P.PRIMARY, font=P.EN_FONT, name="sec_num") # 章节名 -add_textbox(slide, 5.5, 2.8, 7, 1.0, "背景与现状", - 44, bold=True, color=INK, anchor=MSO_ANCHOR.MIDDLE, - name="sec_title") +P.add_textbox(slide, 5.5, 2.8, 7, 1.0, "背景与现状", + 44, bold=True, color=P.INK, anchor=MSO_ANCHOR.MIDDLE, + name="sec_title") # 引言 -add_textbox(slide, 5.5, 4.0, 7, 0.6, - "本章讨论行业现状与机会窗口", 18, color=GREY, - name="sec_lead") +P.add_textbox(slide, 5.5, 4.0, 7, 0.6, + "本章讨论行业现状与机会窗口", 18, color=P.GREY, + name="sec_lead") # 装饰小图标 -add_shape(slide, MSO_SHAPE.RIGHT_ARROW, 5.5, 5.0, 0.6, 0.3, ACCENT, - "sec_arrow") +P.add_shape(slide, MSO_SHAPE.RIGHT_ARROW, 5.5, 5.0, 0.6, 0.3, P.ACCENT, + "sec_arrow") ``` --- @@ -231,9 +124,10 @@ add_shape(slide, MSO_SHAPE.RIGHT_ARROW, 5.5, 5.0, 0.6, 0.3, ACCENT, ## L4 · 要点 (Bullets) —— 圆点 + 文字,无大块色 ```python -slide = prs.slides.add_slide(BLANK) -apply_brand(slide, "inner") -page_title(slide, "核心结论") +from pptx.enum.text import MSO_ANCHOR +slide = P.add_slide(prs) +P.apply_brand(slide, "inner") +P.page_title(slide, "核心结论") bullets = [ "结论一:用一句话讲清楚", @@ -243,10 +137,10 @@ bullets = [ ] for i, b in enumerate(bullets): y = 2.0 + i * 0.95 - add_dot(slide, SAFE_LEFT + 0.05, y + 0.22, size=0.18, color=ACCENT) - add_textbox(slide, SAFE_LEFT + 0.45, y, SAFE_W - 0.45, 0.6, - b, 22, color=INK, anchor=MSO_ANCHOR.MIDDLE, - name=f"bullet_{i}") + P.add_dot(slide, P.SAFE_LEFT + 0.05, y + 0.22, size=0.18) + P.add_textbox(slide, P.SAFE_LEFT + 0.45, y, P.SAFE_W - 0.45, 0.6, + b, 22, color=P.INK, anchor=MSO_ANCHOR.MIDDLE, + name=f"bullet_{i}") ``` --- @@ -254,39 +148,39 @@ for i, b in enumerate(bullets): ## L5 · 双栏对比 (Two-Column) —— 中线分隔,小色块标签 ```python -slide = prs.slides.add_slide(BLANK) -apply_brand(slide, "inner") -page_title(slide, "现状 vs 改进后") +from pptx.enum.text import PP_ALIGN, MSO_ANCHOR +slide = P.add_slide(prs) +P.apply_brand(slide, "inner") +P.page_title(slide, "现状 vs 改进后") -mid_x = SLIDE_W / 2 +mid_x = P.SLIDE_W / 2 # 中间细分隔线 (替代两块大矩形) -add_rect(slide, mid_x - 0.02, 2.0, 0.04, 4.8, RGBColor(0xDD, 0xDD, 0xDD), - "divider") +P.add_rect(slide, mid_x - 0.02, 2.0, 0.04, 4.8, P.HAIRLINE, "divider") # 左栏小标签 (色块只占小区域) -add_rect(slide, SAFE_LEFT, 2.0, 0.8, 0.35, GREY, "left_tag") -add_textbox(slide, SAFE_LEFT, 2.0, 0.8, 0.35, "现状", 14, bold=True, - color=WHITE, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, - shrink=False, name="left_label") +P.add_rect(slide, P.SAFE_LEFT, 2.0, 0.8, 0.35, P.GREY, "left_tag") +P.add_textbox(slide, P.SAFE_LEFT, 2.0, 0.8, 0.35, "现状", 14, bold=True, + color=P.WHITE, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, + shrink=False, name="left_label") left_pts = ["问题 A: 描述", "问题 B: 描述", "问题 C: 描述"] for i, p in enumerate(left_pts): - add_dot(slide, SAFE_LEFT + 0.05, 2.7 + i * 0.7 + 0.18, color=GREY) - add_textbox(slide, SAFE_LEFT + 0.45, 2.7 + i * 0.7, - mid_x - SAFE_LEFT - 0.7, 0.55, p, 18, color=INK, - anchor=MSO_ANCHOR.MIDDLE, name=f"l_pt_{i}") + P.add_dot(slide, P.SAFE_LEFT + 0.05, 2.7 + i * 0.7 + 0.18, color=P.GREY) + P.add_textbox(slide, P.SAFE_LEFT + 0.45, 2.7 + i * 0.7, + mid_x - P.SAFE_LEFT - 0.7, 0.55, p, 18, color=P.INK, + anchor=MSO_ANCHOR.MIDDLE, name=f"l_pt_{i}") # 右栏小标签 -add_rect(slide, mid_x + 0.3, 2.0, 0.8, 0.35, PRIMARY, "right_tag") -add_textbox(slide, mid_x + 0.3, 2.0, 0.8, 0.35, "改进后", 14, bold=True, - color=WHITE, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, - shrink=False, name="right_label") +P.add_rect(slide, mid_x + 0.3, 2.0, 0.8, 0.35, P.PRIMARY, "right_tag") +P.add_textbox(slide, mid_x + 0.3, 2.0, 0.8, 0.35, "改进后", 14, bold=True, + color=P.WHITE, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, + shrink=False, name="right_label") right_pts = ["改善 A: 描述", "改善 B: 描述", "改善 C: 描述"] for i, p in enumerate(right_pts): - add_dot(slide, mid_x + 0.35, 2.7 + i * 0.7 + 0.18, color=ACCENT) - add_textbox(slide, mid_x + 0.75, 2.7 + i * 0.7, - SAFE_RIGHT - mid_x - 0.75, 0.55, p, 18, color=INK, - anchor=MSO_ANCHOR.MIDDLE, name=f"r_pt_{i}") + P.add_dot(slide, mid_x + 0.35, 2.7 + i * 0.7 + 0.18, color=P.ACCENT) + P.add_textbox(slide, mid_x + 0.75, 2.7 + i * 0.7, + P.SAFE_RIGHT - mid_x - 0.75, 0.55, p, 18, color=P.INK, + anchor=MSO_ANCHOR.MIDDLE, name=f"r_pt_{i}") ``` --- @@ -294,21 +188,22 @@ for i, p in enumerate(right_pts): ## L6 · 图表为主 (Chart-focus) —— 标题 + 一句结论 + 大图 ```python +from pptx.util import Inches +from pptx.enum.text import PP_ALIGN # chart.png 已用 matplotlib 生成 (见 design_principles.md §7) -slide = prs.slides.add_slide(BLANK) -apply_brand(slide, "inner") -page_title(slide, "季度营收持续增长") +slide = P.add_slide(prs) +P.apply_brand(slide, "inner") +P.page_title(slide, "季度营收持续增长") # 一句话结论 -add_textbox(slide, SAFE_LEFT, SAFE_TOP + 1.1, SAFE_W, 0.5, - "Q4 同比增长 158%,创历史新高", 18, color=GREY, - name="lead") -# 图表 (居中,占 9 寸宽,高度自适应) -slide.shapes.add_picture("chart.png", Inches(2.2), Inches(2.4), - width=Inches(8.9)) +P.add_textbox(slide, P.SAFE_LEFT, P.SAFE_TOP + 1.1, P.SAFE_W, 0.5, + "Q4 同比增长 158%,创历史新高", 18, color=P.GREY, name="lead") +# 图表 (居中,占 8.9 寸宽,高度自适应 —— 只给 width 等比缩放) +slide.shapes.add_picture("/slides/chart.png", Inches(2.2), + Inches(2.4), width=Inches(8.9)) # 数据来源 (右下角弱化) -add_textbox(slide, SAFE_LEFT, 6.95, SAFE_W, 0.4, - "数据来源: 公司年报 2025", 11, color=GREY_LIGHT, - align=PP_ALIGN.RIGHT, shrink=False, name="source") +P.add_textbox(slide, P.SAFE_LEFT, 6.95, P.SAFE_W, 0.4, + "数据来源: 公司年报 2025", 11, color=P.GREY_LIGHT, + align=PP_ALIGN.RIGHT, shrink=False, name="source") ``` --- @@ -318,21 +213,23 @@ add_textbox(slide, SAFE_LEFT, 6.95, SAFE_W, 0.4, > 之前用满铺图 + 半透明遮罩,效果不稳定。改成"图占 60% + 文字独立区"。 ```python -slide = prs.slides.add_slide(BLANK) -# 左侧图占 60% 宽 -slide.shapes.add_picture("hero.jpg", Inches(0), Inches(0), - width=Inches(8), height=Inches(7.5)) +from pptx.util import Inches +from pptx.enum.shapes import MSO_SHAPE +slide = P.add_slide(prs) +# 左侧图占 60% 宽 (只给 width 或 height 一项,避免变形;此处图需正好铺满左 8 寸高 7.5 寸时按素材比例取舍) +slide.shapes.add_picture("/slides/hero.jpg", Inches(0), Inches(0), + height=Inches(7.5)) # 右侧浅灰背景区放文字 -add_rect(slide, 8, 0, 5.33, 7.5, BG, "text_panel") -add_rect(slide, 8.4, 1.0, 0.06, 0.8, ACCENT, "deco_bar") # 装饰短线 -add_textbox(slide, 8.4, 2.0, 4.6, 1.6, "走进未来", 36, - bold=True, color=INK, name="img_title") -add_textbox(slide, 8.4, 3.8, 4.6, 1.5, - "用一两句话点出主旨,不要把演讲稿搬上来。", - 18, color=GREY, name="img_caption") +P.add_rect(slide, 8, 0, 5.33, 7.5, P.BG, "text_panel") +P.add_rect(slide, 8.4, 1.0, 0.06, 0.8, P.ACCENT, "deco_bar") # 装饰短线 +P.add_textbox(slide, 8.4, 2.0, 4.6, 1.6, "走进未来", 36, + bold=True, color=P.INK, name="img_title") +P.add_textbox(slide, 8.4, 3.8, 4.6, 1.5, + "用一两句话点出主旨,不要把演讲稿搬上来。", + 18, color=P.GREY, name="img_caption") # 图标:右下角的箭头,引导视线 -add_shape(slide, MSO_SHAPE.RIGHT_ARROW, 8.4, 6.5, 0.7, 0.35, ACCENT, - "img_cta") +P.add_shape(slide, MSO_SHAPE.RIGHT_ARROW, 8.4, 6.5, 0.7, 0.35, P.ACCENT, + "img_cta") ``` --- @@ -340,20 +237,21 @@ add_shape(slide, MSO_SHAPE.RIGHT_ARROW, 8.4, 6.5, 0.7, 0.35, ACCENT, ## L8 · 金句 / 大字 (Quote) —— 留白主导,装饰极简 ```python -slide = prs.slides.add_slide(BLANK) -apply_brand(slide, "inner") -# 左上大引号 (用 STAR 不合适;用字形) -add_textbox(slide, 0.8, 0.6, 1.5, 1.5, '"', 200, bold=True, - color=ACCENT, font=EN_FONT, shrink=False, name="quote_mark") +from pptx.enum.text import MSO_ANCHOR +slide = P.add_slide(prs) +P.apply_brand(slide, "inner") +# 左上大引号 (用字形;font=EN_FONT 走 Arial) +P.add_textbox(slide, 0.8, 0.6, 1.5, 1.5, '"', 200, bold=True, + color=P.ACCENT, font=P.EN_FONT, shrink=False, name="quote_mark") # 金句 (深色,留白多) -add_textbox(slide, 1.5, 2.7, 10.5, 2.0, - "把复杂留给我们,把简单留给用户。", 36, bold=True, - color=INK, anchor=MSO_ANCHOR.MIDDLE, name="quote_text") +P.add_textbox(slide, 1.5, 2.7, 10.5, 2.0, + "把复杂留给我们,把简单留给用户。", 36, bold=True, + color=P.INK, anchor=MSO_ANCHOR.MIDDLE, name="quote_text") # 装饰短线 -add_accent_line(slide, 1.5, 5.0, length=0.5, color=ACCENT) +P.add_accent_line(slide, 1.5, 5.0, length=0.5) # 出处 -add_textbox(slide, 1.5, 5.2, 10.5, 0.5, "—— 公司价值观 2025", - 16, color=GREY, name="quote_attr") +P.add_textbox(slide, 1.5, 5.2, 10.5, 0.5, "—— 公司价值观 2025", + 16, color=P.GREY, name="quote_attr") ``` --- @@ -363,16 +261,17 @@ add_textbox(slide, 1.5, 5.2, 10.5, 0.5, "—— 公司价值观 2025", > **不是可选** —— 任何 deck 都必须以这页收尾。无论是汇报、提案、路演,缺尾页等于"话没说完"。 ```python -slide = prs.slides.add_slide(BLANK) -apply_brand(slide, "end") # 整页浅灰 + 顶/底强调短线 -add_textbox(slide, 0, 2.5, SLIDE_W, 1.6, "Thank You", 80, bold=True, - color=PRIMARY, align=PP_ALIGN.CENTER, font=EN_FONT, - name="thanks") -add_textbox(slide, 0, 4.3, SLIDE_W, 0.6, "欢迎提问与讨论", - 22, color=ACCENT, align=PP_ALIGN.CENTER, name="qa") -add_textbox(slide, 0, 6.2, SLIDE_W, 0.5, - "联系方式 / 邮箱 / 公众号", 14, color=GREY_LIGHT, - align=PP_ALIGN.CENTER, name="contact") +from pptx.enum.text import PP_ALIGN +slide = P.add_slide(prs) +P.apply_brand(slide, "end") # 整页浅灰 + 顶/底强调短线 +P.add_textbox(slide, 0, 2.5, P.SLIDE_W, 1.6, "Thank You", 80, bold=True, + color=P.PRIMARY, align=PP_ALIGN.CENTER, font=P.EN_FONT, + name="thanks") +P.add_textbox(slide, 0, 4.3, P.SLIDE_W, 0.6, "欢迎提问与讨论", + 22, color=P.ACCENT, align=PP_ALIGN.CENTER, name="qa") +P.add_textbox(slide, 0, 6.2, P.SLIDE_W, 0.5, + "联系方式 / 邮箱 / 公众号", 14, color=P.GREY_LIGHT, + align=PP_ALIGN.CENTER, name="contact") ``` --- @@ -392,6 +291,7 @@ add_textbox(slide, 0, 6.2, SLIDE_W, 0.5, ## 三个常犯的越界场景 -1. **bullet 字数超额** —— 22pt 在 11.5 寸宽下每行约 50 个中文字。超过 1 行就溢出 0.7 in 高的框。**用 `assert_inside` + `auto_size=TEXT_TO_SHAPE_AND_FIT_TEXT` 兜底**;但根本解法是**字数压缩**(见 design_principles.md §字数预算) +1. **bullet 字数超额** —— 22pt 在 11.5 寸宽下每行约 50 个中文字。超过 1 行就溢出 0.7 in 高的框。`add_textbox` 内置 `assert_inside` + shrink-to-fit 兜底;但根本解法是**字数压缩**(见 design_principles.md §字数预算) 2. **标题占两行** —— 标题在 0.7 in 高的框里,32pt 单行高约 0.45 in,**两行就溢出**。中文标题 ≤ 30 字 3. **图片不等比拉伸** —— `add_picture(width=, height=)` 同时给会变形;**只给 width 或 height 一项** +``` diff --git a/skills/ppt/scripts/pptx_helpers.py b/skills/ppt/scripts/pptx_helpers.py new file mode 100644 index 0000000..e087914 --- /dev/null +++ b/skills/ppt/scripts/pptx_helpers.py @@ -0,0 +1,316 @@ +"""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") diff --git a/skills/ppt/scripts/quality_check.py b/skills/ppt/scripts/quality_check.py index 11bd8e7..4bfdb92 100644 --- a/skills/ppt/scripts/quality_check.py +++ b/skills/ppt/scripts/quality_check.py @@ -9,7 +9,8 @@ - 每页有标题 - 每页 bullet ≤ 5 条 - 文字字号 ≥ 14pt (除页脚) - - 颜色集合 ≤ 5 种 (粗略统计) + - 非灰阶(彩色)≤ 3 种 (三色制;文字色 + 形状填充色都计,灰阶/白不计) + - 出现 spec 之外的非灰阶色 (擅自换色 / 非主题色) - 没有 untitled / output / placeholder 等占位文件名 - **形状不越出画布边界** (left+width / top+height 超界即报) - **textbox 文本估算行数 > 框高度** —— 推断溢出 @@ -29,11 +30,37 @@ from pathlib import Path try: from pptx import Presentation from pptx.util import Pt + from pptx.enum.dml import MSO_FILL, MSO_COLOR_TYPE except ImportError: print("[fatal] pip install python-pptx", file=sys.stderr) sys.exit(2) +# ---- 颜色辅助 ---- + +def _is_neutral(hex6: str) -> bool: + """灰阶/黑/白判定:R/G/B 极差 ≤ 12 即视为中性色(三色制里不计入彩色)。""" + try: + r, g, b = int(hex6[0:2], 16), int(hex6[2:4], 16), int(hex6[4:6], 16) + except (ValueError, IndexError): + return False + return max(r, g, b) - min(r, g, b) <= 12 + + +def _shape_fill_hex(shape) -> str | None: + """取形状的纯色填充 hex(大写,无 #)。非实心 / 主题色 / 取不到 → None。""" + try: + fill = shape.fill + if fill.type != MSO_FILL.SOLID: + return None + fc = fill.fore_color + if fc.type != MSO_COLOR_TYPE.RGB: # 主题色访问 .rgb 会抛,先挡掉 + return None + return str(fc.rgb).upper() + except (TypeError, AttributeError, KeyError, ValueError): + return None + + # ---- spec 解析 (松散 markdown 解析,够用就行) ---- def parse_spec(spec_path: Path) -> dict: @@ -131,6 +158,11 @@ def check_pptx(path: Path, spec: dict) -> tuple[list, list]: f"(画布 {slide_h_in:.2f},shape 底 {top_in + h_in:.2f})" ) + # ---- 形状填充色 (品牌条/徽章/圆点/标签/底块) ---- + fill_hex = _shape_fill_hex(shape) + if fill_hex: + seen_colors.add(fill_hex) + if not shape.has_text_frame: continue tf = shape.text_frame @@ -211,16 +243,22 @@ def check_pptx(path: Path, spec: dict) -> tuple[list, list]: f"第 {idx} 页有 {small_font_count} 处字号 < 14pt,投影看不清" ) - if len(seen_colors) > 6: + # 三色制按"非灰阶色"判定:灰/黑/白不计 (design_principles §2「其他全部用灰阶」) + chromatic = {c for c in seen_colors if not _is_neutral(c)} + if len(chromatic) > 3: warnings.append( - f"颜色 {len(seen_colors)} 种 (含不同灰阶),理想 ≤ 5;考虑收敛到三色制" + f"非灰阶色 {len(chromatic)} 种 (三色制上限 3): " + f"{', '.join('#' + c for c in sorted(chromatic))};收敛到主/辅/强调三色" ) - if spec_colors and seen_colors: - unmatched = seen_colors - spec_colors - if len(unmatched) > 3: + if spec_colors: + spec_chromatic = {c for c in spec_colors if not _is_neutral(c)} + extra = chromatic - spec_chromatic + if extra: warnings.append( - f"出现 {len(unmatched)} 个 spec 之外的颜色,可能用了 matplotlib 默认色板" + f"出现 spec 之外的非灰阶色 {', '.join('#' + c for c in sorted(extra))};" + f"擅自换色 / 非主题色 (spec 定的是 " + f"{', '.join('#' + c for c in sorted(spec_chromatic))})" ) return errors, warnings