效率工具

WPS AI 可以根据主题、文档或大纲生成整套 PPT

WPS AI 可以根据主题、文档或大纲生成整套 PPT。用自己的大纲来生成,内容最可控;但生成后可能漏掉章节、多出页面,或者残留“单击此处添加文本”。 这篇做 deckcheck.py:生成前检查大纲,生成后对照导出的 .pptx 检查覆盖情况。

BINBHO · 图文教程

WPS AI 可以根据主题、文档或大纲生成整套 PPT。用自己的大纲来生成,内容最可控;但生成后可能漏掉章节、多出页面,或者残留“单击此处添加文本”。 这篇做 deck_check.py:生成前检查大纲,生成后对照导出的 .pptx 检查覆盖情况。

先看清成品

  • 模拟导出的 PPT:漏掉的“成本与毛利”、多出的“谢谢观看”、第 3 页残留的“单击此处”全部被报出;改写过的标题(“核心数据概览”)仍能对上。
  • 修复:标题放在普通文本框里时,第一版把所有章节都判为缺失;现在改用字号最大的文本框作为标题。
  • pytest 3 项通过。

合规提醒:WPS AI 需要登录 WPS 账号,在官方客户端或网页版中使用,功能、次数和会员权益以官方说明为准(本文不列价格)。本文不调用任何非官方接口,也不自动化操作 WPS;脚本只在本地检查你自己的文件。把公司文档交给 AI 处理之前,请确认符合公司的数据安全规定。示例数据都是虚构的。

运行说明:box 上没有 WPS 客户端和账号,没有真正调用 WPS AI。本地脚本和测试都在 box 上真实运行过(LibreOffice 25.2.3、poppler-utils、python-pptx 1.0.2、openpyxl 3.1.5)。“AI 生成的”公式、摘要和 PPT 都是我自己写的样例,用来演示核对方法。

做完之后你会得到什么?

成果 说明
deck_check.py lint 检查大纲
deck_check.py cover 检查覆盖情况;有缺失或占位文字时退出码为 1
outline.md 示例大纲
make_mock_deck.py 生成模拟导出的 PPT(不是 WPS 的输出)
test_deck.py 3 项测试

需要准备哪些环境?

项目 说明
Python 3.9 以上,pip install openpyxl python-pptx pytest
LibreOffice A 篇用于无界面重算公式(apt install libreoffice-calc)
poppler-utils B 篇用于提取 PDF 文字(apt install poppler-utils)
WPS Office 最新版客户端,登录账号;旧版本可以在 ai.wps.cn 升级

关键事实

事实 来源
入口:WPS 演示 → WPS AI → AI 生成 PPT;可以输入主题、上传文档或粘贴大纲 WPS 社区官方教程
生成的大纲可以编辑,也可以调整层级 WPS 社区官方教程

WPS AI 接受什么样的大纲格式,官方教程没有给出规范。这里使用简单的 Markdown(# 标题 / ## 章节 / - 要点),粘贴后请在 WPS 的大纲编辑界面中确认层级。

文件结构

69-wps-ai-C-src/
├── deck_check.py
├── outline.md
├── make_mock_deck.py
└── test_deck.py

第 1 步:示例大纲

# 2026 年第三季度运营汇报

## 核心数据
- 营业收入 1,280 万元,同比增长 12.5%
- 新增注册用户 3.2 万人

## 渠道表现
- 线上渠道收入 760 万元,占比 59.4%

## 成本与毛利
- 营业成本 905 万元,毛利率 29.3%

## 第四季度计划
- 11 月 15 日上线新版会员系统
- 预计营业收入 1,400 万元

第 2 步:检查脚本

"""WPS AI“AI 生成 PPT”可以根据主题、文档或大纲生成整套幻灯片(WPS 社区官方教程)。这个工具做两件事:
1. lint:生成前检查 Markdown 大纲(# 标题 / ## 章节 / - 要点):层级、空章节、要点过长
2. cover:生成并导出 .pptx 后,检查每个章节是否都有对应的幻灯片、多出了哪些页、有没有残留的占位文字
只读本地文件,依赖 python-pptx。"""
import argparse, difflib, re, sys
from pptx import Presentation

PLACEHOLDER = re.compile(r"单击此处|点击此处|添加标题|添加文本|Click to add|Lorem ipsum", re.I)

def parse_outline(md):
    title, secs, cur = None, [], None
    for n, line in enumerate(md.splitlines(), 1):
        if m := re.match(r"^#\s+(.+)", line): title = m.group(1).strip()
        elif m := re.match(r"^##\s+(.+)", line): cur = {"title": m.group(1).strip(), "points": [], "line": n}; secs.append(cur)
        elif m := re.match(r"^\s*[-*]\s+(.+)", line):
            if cur is None: raise SystemExit(f"第 {n} 行:要点出现在任何 ## 章节之前")
            cur["points"].append(m.group(1).strip())
        elif re.match(r"^#{3,}\s", line): raise SystemExit(f"第 {n} 行:只支持 # 和 ## 两级标题,要点请用 - 列表")
    return title, secs

def width(s): return sum(2 if "\u4e00" <= ch <= "\u9fff" else 1 for ch in s)

def lint(md, max_points=6, max_width=40):
    title, secs = parse_outline(md); issues = []
    if not title: issues.append("缺少 # 总标题")
    if not secs: issues.append("没有 ## 章节")
    for s in secs:
        if not s["points"]: issues.append(f"「{s['title']}」没有要点")
        if len(s["points"]) > max_points: issues.append(f"「{s['title']}」有 {len(s['points'])} 个要点,超过 {max_points},考虑拆成两页")
        for p in s["points"]:
            if width(p) > max_width: issues.append(f"「{s['title']}」的要点太长({width(p)} 宽度):{p[:20]}…")
    dup = {t for t in [s["title"] for s in secs] if [s["title"] for s in secs].count(t) > 1}
    issues += [f"章节标题重复:{t}" for t in dup]
    return issues

def guess_title(slide):
    """没有标题占位符时(很多生成的幻灯片用普通文本框做标题),取字号最大的文本框;没有字号信息时取最上面的。"""
    cands = []
    for sh in slide.shapes:
        if not sh.has_text_frame or not sh.text_frame.text.strip(): continue
        sizes = [r.font.size.pt for p in sh.text_frame.paragraphs for r in p.runs if r.font.size]
        cands.append((max(sizes, default=0), -(sh.top or 0), sh.text_frame.text.strip().splitlines()[0]))
    return max(cands)[2] if cands else ""

def slides(path):
    out = []
    for i, sl in enumerate(Presentation(path).slides, 1):
        title = sl.shapes.title.text_frame.text.strip() if sl.shapes.title is not None and sl.shapes.title.has_text_frame else ""
        if not title:
            title = guess_title(sl)
        texts = [sh.text_frame.text for sh in sl.shapes if sh.has_text_frame]
        out.append({"no": i, "title": title, "text": "\n".join(texts)})
    return out

def cover(md, pptx_path, cutoff=0.6):
    _, secs = parse_outline(md); sl = slides(pptx_path); used, rep = set(), {"missing": [], "matched": [], "extra": [], "placeholder": []}
    for s in secs:
        best = max(sl, key=lambda x: difflib.SequenceMatcher(None, s["title"], x["title"]).ratio(), default=None)
        r = difflib.SequenceMatcher(None, s["title"], best["title"]).ratio() if best else 0
        if r >= cutoff: rep["matched"].append((s["title"], best["no"], round(r, 2))); used.add(best["no"])
        else: rep["missing"].append(s["title"])
    rep["extra"] = [(x["no"], x["title"]) for x in sl if x["no"] not in used and x["no"] != 1]
    rep["placeholder"] = [(x["no"], PLACEHOLDER.search(x["text"]).group()) for x in sl if PLACEHOLDER.search(x["text"])]
    return rep

def main(argv=None):
    ap = argparse.ArgumentParser(); sub = ap.add_subparsers(dest="cmd", required=True)
    l = sub.add_parser("lint"); l.add_argument("outline")
    c = sub.add_parser("cover"); c.add_argument("outline"); c.add_argument("pptx")
    a = ap.parse_args(argv); md = open(a.outline, encoding="utf-8").read()
    if a.cmd == "lint":
        iss = lint(md); [print("[WARN]", i) for i in iss]; print("大纲 OK" if not iss else f"{len(iss)} 条提醒"); return 1 if iss else 0
    r = cover(md, a.pptx)
    for t, no, ratio in r["matched"]: print(f"✓ 「{t}」→ 第 {no} 页(相似度 {ratio})")
    for t in r["missing"]: print(f"✗ 缺少章节「{t}」")
    for no, t in r["extra"]: print(f"+ 第 {no} 页「{t}」不在大纲中")
    for no, t in r["placeholder"]: print(f"! 第 {no} 页残留占位文字:{t}")
    return 1 if r["missing"] or r["placeholder"] else 0

if __name__ == "__main__":
    sys.exit(main())

第 3 步:模拟导出的 PPT

"""生成一个“模拟导出”的 pptx(我用 python-pptx 自己做的,不是 WPS AI 的真实输出):
漏掉“成本与毛利”,多一页“谢谢观看”,第 3 页残留占位文字,章节标题略有改写。"""
from pptx import Presentation

def build(path):
    p = Presentation()
    def add(title, body, layout=1):
        s = p.slides.add_slide(p.slide_layouts[layout]); s.shapes.title.text = title
        if layout == 1: s.placeholders[1].text = body
    add("2026 年第三季度运营汇报", "", layout=0)
    add("核心数据概览", "营业收入 1,280 万元")
    add("渠道表现分析", "单击此处添加文本")
    add("第四季度工作计划", "上线新版会员系统")
    add("谢谢观看", "", layout=0)
    p.save(path)

if __name__ == "__main__":
    build("mock_deck.pptx"); print("ok")

第 4 步:测试

import pytest
from pptx import Presentation
from pptx.util import Inches, Pt
import deck_check as D, make_mock_deck as M

MD = open("outline.md", encoding="utf-8").read()

def test_lint_ok_and_issues():
    assert D.lint(MD) == []
    bad = "## 甲\n## 乙\n- " + "很长的要点" * 10 + "\n## 乙\n- x\n"
    iss = D.lint(bad)
    assert any("总标题" in i for i in iss) and any("「甲」没有要点" in i for i in iss)
    assert any("太长" in i for i in iss) and any("重复" in i for i in iss)
    with pytest.raises(SystemExit): D.lint("# t\n### 三级\n")

def test_cover_mock(tmp_path):
    p = str(tmp_path / "d.pptx"); M.build(p); r = D.cover(MD, p)
    assert r["missing"] == ["成本与毛利"] and r["extra"] == [(5, "谢谢观看")] and r["placeholder"] == [(3, "单击此处")]

def test_textbox_titles(tmp_path):
    # 调试时发现:标题放在普通文本框里时,shapes.title 为空,所有章节都被判为缺失
    p = Presentation()
    for t in ["总标题", "核心数据", "渠道表现", "成本与毛利", "第四季度计划"]:
        s = p.slides.add_slide(p.slide_layouts[6])
        b = s.shapes.add_textbox(Inches(1), Inches(3), Inches(8), Inches(1)); b.text_frame.text = "正文:" + t
        b.text_frame.paragraphs[0].runs[0].font.size = Pt(14)
        h = s.shapes.add_textbox(Inches(1), Inches(0.5), Inches(8), Inches(1)); h.text_frame.text = t
        h.text_frame.paragraphs[0].runs[0].font.size = Pt(36)
    f = str(tmp_path / "t.pptx"); p.save(f)
    assert D.cover(MD, f)["missing"] == []

第 5 步:运行

$ python deck_check.py lint outline.md
大纲 OK
$ python make_mock_deck.py && python deck_check.py cover outline.md mock_deck.pptx
✓ 「核心数据」→ 第 2 页(相似度 0.8)
✓ 「渠道表现」→ 第 3 页(相似度 0.8)
✓ 「第四季度计划」→ 第 4 页(相似度 0.86)
✗ 缺少章节「成本与毛利」
+ 第 5 页「谢谢观看」不在大纲中
! 第 3 页残留占位文字:单击此处
$ python -m pytest -q
3 passed

怎么检查结果?

  • 在 WPS 中把生成的演示文稿另存为 .pptx,再运行 cover。
  • 第 1 页(封面)不算作“多出的页”。
  • 相似度阈值默认 0.6;标题改写较多时可以调低。

调试示例

标题放在文本框里时,所有章节都被判为缺失。 第一版只读取幻灯片的标题占位符(shapes.title)。我做了一份用普通文本框写标题的测试 PPT(36 号字的标题文本框 + 14 号字的正文文本框),结果:

{'missing': ['核心数据', '渠道表现', '成本与毛利', '第四季度计划'], 'matched': [], 'extra': [(2, ''), (3, ''), (4, ''), (5, '')], ...}

所有页的标题都是空的。很多模板和生成工具会用普通文本框做标题,所以这种情况并不少见。

修复:没有标题占位符时,取字号最大的文本框(字号相同时取位置最上面的)作为标题。修复后 4 个章节全部对上,相似度都是 1.0。

常见问题

现象 原因 处理
章节被判为缺失 标题改写得太多 调低 cutoff,或在大纲中使用更接近的标题
所有页标题都是空的 PPT 没有使用标题占位符 已自动改用字号最大的文本框;仍然不对时检查文件
残留占位文字 生成后没有填写 补写内容或删除该文本框
lint 报“只支持两级标题” 大纲用了 ### 改为 - 要点

模板规则

  • 用自己的大纲生成,内容可控。
  • 生成后检查覆盖情况和占位文字。
  • 数字和事实以原始材料为准(见 B 篇)。

项目资源

用途 地址
WPS AI 详细体验教程(WPS 社区官方帖) https://bbs.wps.cn/topic/2498
WPS AI 官网 https://ai.wps.cn/

大纲在自己手里,生成交给 WPS AI,检查交给脚本。