夜雨聆风学习资料网

ARTICLE · 1323284

python代码批量给多个ppt文档各对象加动画演讲者备注

python代码批量给多个ppt文档各对象加动画演讲者备注

import tkinter as tk

from tkinter import ttk, filedialog, messagebox

from pptx import Presentation

from lxml import etree

import os

import json

import re

import threading

from functools import partial

from operator import attrgetter

# ============ 命名空间常量 ============

NS_P = "http://schemas.openxmlformats.org/presentationml/2006/main"

NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main"

NSMAP = {"p": NS_P, "a": NS_A}

P_TAG = f"{{{NS_P}}}"

# ============ XML 模板(一次性编译为 bytes,避免循环内 encode) ============

TIMING_SKELETON = (

                      '<p:timing xmlns:p="%s" xmlns:a="%s">'

                      '<p:tnLst><p:par><p:cTn id="1" dur="indefinite" restart="never" nodeType="tmRoot">'

                      '<p:childTnLst><p:seq concurrent="1" nextAc="seek">'

                      '<p:cTn id="2" dur="indefinite" nodeType="mainSeq"><p:childTnLst/></p:cTn>'

                      '<p:prevCondLst><p:cond evt="onPrev" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:prevCondLst>'

                      '<p:nextCondLst><p:cond evt="onNext" delay="0"/></p:nextCondLst>'

                      '</p:seq></p:childTnLst></p:cTn></p:par></p:tnLst></p:timing>'

                  ) % (NS_P, NS_A)

ANIM_TEMPLATE = (

                    '<p:par xmlns:p="%s" xmlns:a="%s"><p:cTn id="{id}" fill="hold" nodeType="clickPar">'

                    '<p:stCondLst><p:cond delay="0"/></p:stCondLst><p:childTnLst>'

                    '<p:par><p:cTn id="{id2}" fill="hold" nodeType="afterEffect" presetID="{preset_id}" '

                    'presetClass="{preset_class}" presetSubtype="0" grpId="0" {extra_attrs}>'

                    '<p:stCondLst><p:cond delay="{delay}"/></p:stCondLst><p:childTnLst>'

                    '<p:par><p:cTn id="{id3}" fill="hold"><p:stCondLst><p:cond delay="0"/></p:stCondLst>'

                    '<p:childTnLst><p:animEffect transition="{transition}" filter="{filter}">'

                    '<p:cBhvr><p:cTn id="{id4}" dur="{dur}" fill="hold"/><p:tgtEl><p:spTgt spid="{spid}"/></p:tgtEl>'

                    '</p:cBhvr></p:animEffect></p:childTnLst></p:cTn></p:par>'

                    '</p:childTnLst></p:cTn></p:par></p:childTnLst></p:cTn></p:par>'

                ) % (NS_P, NS_A)

LOOP_ANIM_TEMPLATE = (

                         '<p:par xmlns:p="%s" xmlns:a="%s"><p:cTn id="{id}" fill="hold" nodeType="afterEffect" '

                         'presetID="{preset_id}" presetClass="{preset_class}" presetSubtype="0" grpId="0" {extra_attrs}>'

                         '<p:stCondLst><p:cond delay="{delay}"/></p:stCondLst><p:childTnLst>'

                         '<p:par><p:cTn id="{id2}" fill="hold"><p:stCondLst><p:cond delay="0"/></p:stCondLst>'

                         '<p:childTnLst><p:animEffect transition="{transition}" filter="{filter}">'

                         '<p:cBhvr><p:cTn id="{id3}" dur="{dur}" fill="hold"/><p:tgtEl><p:spTgt spid="{spid}"/></p:tgtEl>'

                         '</p:cBhvr></p:animEffect></p:childTnLst></p:cTn></p:par>'

                         '</p:childTnLst></p:cTn></p:par>'

                     ) % (NS_P, NS_A)

LOOP_WRAPPER_TEMPLATE = (

                            '<p:par xmlns:p="%s" xmlns:a="%s"><p:cTn id="{id}" fill="hold" nodeType="afterEffect" '

                            'repeatCount="indefinite"><p:stCondLst><p:cond delay="0"/></p:stCondLst>'

                            '<p:childTnLst/></p:cTn></p:par>'

                        ) % (NS_P, NS_A)

LOOP_WRAPPER_ONCE_TEMPLATE = (

                                 '<p:par xmlns:p="%s" xmlns:a="%s"><p:cTn id="{id}" fill="hold" nodeType="afterEffect">'

                                 '<p:stCondLst><p:cond delay="0"/></p:stCondLst><p:childTnLst/></p:cTn></p:par>'

                             ) % (NS_P, NS_A)

BUILD_CHART_TEMPLATE = (

                           '<p:bldChart xmlns:p="%s" xmlns:a="%s" bld="{bld}" spid="{spid}">'

                           '<p:graphicElLst><p:graphicEl groupId="0"><p:custDataLst/></p:graphicEl></p:graphicElLst>'

                           '</p:bldChart>'

                       ) % (NS_P, NS_A)

CONFIG_FILE = "ppt_anim_config.json"

# ============ 动画字典(含预计算字段) ============

ANIM_MAP = {

    "擦除/生长 (Wipe)": {"id": "22", "filter": "wipe({dir})", "cls": "entr", "trans": "in", "dir_type": "standard"},

    "淡入 (Fade)": {"id": "10", "filter": "fade", "cls": "entr", "trans": "in", "dir_type": "none"},

    "飞入 (Fly In)": {"id": "2", "filter": "slide({fly_dir})", "cls": "entr", "trans": "in", "dir_type": "fly"},

    "缩放 (Zoom)": {"id": "53", "filter": "zoom({zoom_dir})", "cls": "entr", "trans": "in", "dir_type": "zoom"},

    "浮入 (Float In)": {"id": "42", "filter": "float({fly_dir})", "cls": "entr", "trans": "in", "dir_type": "fly"},

    "劈裂 (Split)": {"id": "14", "filter": "barn({dir})", "cls": "entr", "trans": "in", "dir_type": "standard"},

    "轮子 (Wheel)": {"id": "21", "filter": "wheel(1)", "cls": "entr", "trans": "in", "dir_type": "none"},

    "百叶窗 (Blinds)": {"id": "4", "filter": "blinds({dir})", "cls": "entr", "trans": "in", "dir_type": "standard"},

    "随机线条 (Random Bars)": {"id": "13", "filter": "randombar({dir})", "cls": "entr", "trans": "in",

                               "dir_type": "standard"},

    "出现 (Appear)": {"id": "1", "filter": "appear", "cls": "entr", "trans": "in", "dir_type": "none"},

    "脉冲 (Pulse)": {"id": "31", "filter": "pulse", "cls": "emph", "trans": "none", "dir_type": "none"},

    "闪烁 (Teeter)": {"id": "33", "filter": "teeter", "cls": "emph", "trans": "none", "dir_type": "none"},

    "淡出 (Fade Out)": {"id": "10", "filter": "fade", "cls": "exit", "trans": "out", "dir_type": "none"},

    "收缩 (Shrink)": {"id": "53", "filter": "zoom(out)", "cls": "exit", "trans": "out", "dir_type": "none"},

}

ANIM_OPTIONS = list(ANIM_MAP.keys())

DIR_MAP_STANDARD = [

    "right (从左到右)", "left (从右到左)", "up (从下到上)", "down (从上到下)",

    "rightup (左下到右上)", "rightdown (左上到右下)", "leftup (右下到左上)", "leftdown (右上到左下)",

]

DIR_MAP_FLY = [

    "right (从左侧飞入)", "left (从右侧飞入)", "up (从底部飞入)", "down (从顶部飞入)",

    "rightup (从左下飞入)", "rightdown (从左上飞入)", "leftup (从右下飞入)", "leftdown (从右上飞入)",

]

DIR_MAP_ZOOM = [

    "center (从中心放大)", "in (向中心收缩)", "out (向外放大)",

    "horizontal (水平展开)", "vertical (垂直展开)",

]

DIR_TYPE_MAP = {"standard": DIR_MAP_STANDARD, "fly": DIR_MAP_FLY, "zoom": DIR_MAP_ZOOM}

FLY_DIR_MAP = {

    "right": "fromLeft", "left": "fromRight", "up": "fromBottom", "down": "fromTop",

    "rightup": "fromBottomLeft", "rightdown": "fromTopLeft",

    "leftup": "fromBottomRight", "leftdown": "fromTopRight",

}

ZOOM_DIR_MAP = {"center": "in", "in": "in", "out": "out",

                "horizontal": "in(horizontal)", "vertical": "in(vertical)"}

STD_DIR_SET = {"rightup", "rightdown", "leftup", "leftdown"}

BUILD_OPTIONS = [

    "category (按类别)", "series (按系列)", "seriesCategory (按系列中的类别)",

    "categorySeries (按类别中的系列)", "categoryEl (按类别元素)", "seriesEl (按系列元素)",

    "allAtOnce (整体一起)", "oneByOne (逐个显示)", "chart (整个图表)",

    "grid (按网格)", "dataPoint (按数据点)", "level (按层级)", "custom (自定义)",

]

NO_BLD_LVL = frozenset({"allAtOnce", "chart"})

PLAY_MODE_DESC = {"normal": "普通模式", "loop": "循环模式(无限循环)", "once": "循环结构·单次播放"}

# ============ 规则行(UI 组件) ============

class RuleRow:

    __slots__ = ("frame", "var_start", "var_end", "var_anim", "var_wipe",

                 "var_build", "lbl_wipe", "cb_wipe", "cb_anim")

    def __init__(self, parent):

        self.frame = ttk.Frame(parent)

        self.var_start = tk.StringVar(value="1")

        self.var_end = tk.StringVar(value="全部")

        self.var_anim = tk.StringVar(value=ANIM_OPTIONS[0])

        self.var_wipe = tk.StringVar(value=DIR_MAP_STANDARD[3])

        self.var_build = tk.StringVar(value=BUILD_OPTIONS[0])

        f = self.frame

        ttk.Label(f, text="页码").pack(side=tk.LEFT)

        ttk.Entry(f, textvariable=self.var_start, width=5).pack(side=tk.LEFT, padx=(4, 2))

        ttk.Label(f, text="~").pack(side=tk.LEFT)

        ttk.Entry(f, textvariable=self.var_end, width=6).pack(side=tk.LEFT, padx=(2, 8))

        ttk.Label(f, text="动画").pack(side=tk.LEFT)

        self.cb_anim = ttk.Combobox(f, textvariable=self.var_anim, values=ANIM_OPTIONS,

                                    state="readonly", width=17)

        self.cb_anim.pack(side=tk.LEFT, padx=(4, 8))

        self.cb_anim.bind("<<ComboboxSelected>>", self._on_anim_change)

        self.lbl_wipe = ttk.Label(f, text="方向")

        self.lbl_wipe.pack(side=tk.LEFT)

        self.cb_wipe = ttk.Combobox(f, textvariable=self.var_wipe, state="readonly", width=19)

        self.cb_wipe.pack(side=tk.LEFT, padx=(4, 8))

        ttk.Label(f, text="构建").pack(side=tk.LEFT)

        ttk.Combobox(f, textvariable=self.var_build, values=BUILD_OPTIONS,

                     state="readonly", width=22).pack(side=tk.LEFT, padx=(4, 6))

        self._on_anim_change()

    def _on_anim_change(self, event=None):

        dt = ANIM_MAP.get(self.var_anim.get(), {}).get("dir_type", "none")

        if dt == "none":

            self.lbl_wipe.pack_forget()

            self.cb_wipe.pack_forget()

        else:

            self.lbl_wipe.pack(side=tk.LEFT)

            self.cb_wipe.pack(side=tk.LEFT, padx=(4, 8))

            opts = DIR_TYPE_MAP.get(dt, DIR_MAP_STANDARD)

            self.cb_wipe["values"] = opts

            if self.var_wipe.get() not in opts:

                self.var_wipe.set(opts[0])

    def get(self):

        return {

            "start": self.var_start.get().strip(),

            "end": self.var_end.get().strip(),

            "anim": self.var_anim.get(),

            "wipe": self.var_wipe.get(),

            "build": self.var_build.get(),

        }

    def set(self, d):

        self.var_start.set(str(d.get("start", "1")))

        self.var_end.set(str(d.get("end", "全部")))

        self.var_anim.set(d.get("anim", ANIM_OPTIONS[0]))

        self.var_wipe.set(d.get("wipe", DIR_MAP_STANDARD[3]))

        self.var_build.set(d.get("build", BUILD_OPTIONS[0]))

        self._on_anim_change()

# ============ 完美圆形勾选控件(Canvas 绘制,无背景杂色) ============

class RoundCheck(tk.Frame):

    def __init__(self, parent, text, var, **kw):

        super().__init__(parent, **kw)

        self.var = var

        self.text = text

        # 完美兼容 ttk 控件的背景色获取方式

        try:

            # 尝试获取 Tk 系统的默认窗口背景色(如 ttk 的默认底色)

            bg = self.tk.call('ttk::style', 'lookup', 'TFrame', '-background')

            if not bg:

                bg = self.winfo_toplevel().cget("background")

        except Exception:

            bg = "#f0f0f0"  # 兜底默认浅灰

        self.configure(background=bg)

        # 尺寸设置

        self.c_size = 12  # 圆圈直径

        self.box_size = 16  # 整体控件高度

        self.gap = 5  # 圆圈与文字的间距

        self.canvas = tk.Canvas(self, width=self.c_size, height=self.c_size,

                                bg=bg, highlightthickness=0, bd=0, cursor="hand2")

        self.canvas.pack(side=tk.LEFT, pady=(2, 0))  # 微调垂直居中

        self.lbl = tk.Label(self, text=self.text, bg=bg, font=("Microsoft YaHei UI", 9),

                            cursor="hand2")

        self.lbl.pack(side=tk.LEFT, padx=(self.gap, 0))

        # 绑定点击事件

        self.canvas.bind("<Button-1>", self._toggle)

        self.lbl.bind("<Button-1>", self._toggle)

        # 监听变量改变(无论是点击还是代码 set),自动重绘

        self.var.trace_add("write", lambda *args: self._draw())

        self._draw()

    def _toggle(self, event):

        self.var.set(not self.var.get())

    def _draw(self):

        self.canvas.delete("all")

        x0, y0 = 1, 1

        x1, y1 = self.c_size - 1, self.c_size - 1

        if self.var.get():

            # 选中状态:实心黑点

            self.canvas.create_oval(x0, y0, x1, y1, fill="black", outline="black")

        else:

            # 未选中状态:空心圆(白底+黑边),与后面的系统单选框视觉完全一致

            self.canvas.create_oval(x0, y0, x1, y1, fill="white", outline="#333", width=1)

# ============ 主 GUI ============

class PptChartAnimatorGUI:

    def __init__(self, root):

        self.root = root

        self.root.title("PPT 批量动画引擎 · 多文件 / 图表·图片·表格·文本 / 循环·单次")

        self.root.geometry("1280x1020")

        self.root.minsize(1100, 840)

        # 状态变量

        self.ppt_files = []

        self.output_dir = tk.StringVar()

        self.play_mode = tk.StringVar(value="normal")

        self.speed_mode = tk.StringVar(value="1.0x (原速)")

        self.custom_speed = tk.StringVar(value="1.0")

        self.base_duration = tk.StringVar(value="500")

        # 参与动画的对象类型(默认只勾选图表)

        self.anim_chart = tk.BooleanVar(value=True)

        self.anim_pic = tk.BooleanVar(value=False)

        self.anim_table = tk.BooleanVar(value=False)

        self.anim_text = tk.BooleanVar(value=False)

        self.rule_rows = []

        self.log_text = None

        self.progress = None

        self.btn_scan = None

        self.btn_run = None

        self._pending_rules = self.load_config()

        self._setup_style()

        self.build_ui()

    # ---------- 样式 ----------

    def _setup_style(self):

        style = ttk.Style(self.root)

        try:

            style.theme_use("clam")

        except Exception:

            pass

        style.configure(".", font=("Microsoft YaHei UI", 9))

        style.configure("TLabelframe", padding=6)

        style.configure("TLabelframe.Label", foreground="#1f4e79",

                        font=("Microsoft YaHei UI", 9, "bold"))

        style.configure("TButton", padding=(10, 4))

        style.configure("Primary.TButton", font=("Microsoft YaHei UI", 9, "bold"))

        style.configure("TNotebook.Tab", padding=(12, 6))

        style.map("Primary.TButton",

                  background=[("active", "#2e7d32"), ("!active", "#388e3c")],

                  foreground=[("!disabled", "white")])

    # ---------- 配置 ----------

    def load_config(self):

        rules = None

        if os.path.exists(CONFIG_FILE):

            try:

                with open(CONFIG_FILE, "r", encoding="utf-8") as f:

                    cfg = json.load(f)

                self.speed_mode.set(cfg.get("speed_mode", self.speed_mode.get()))

                self.custom_speed.set(cfg.get("custom_speed", self.custom_speed.get()))

                self.base_duration.set(cfg.get("base_duration", self.base_duration.get()))

                self.play_mode.set(cfg.get("play_mode",

                                           "loop" if cfg.get("loop_mode") else "normal"))

                self.anim_chart.set(cfg.get("anim_chart", True))

                self.anim_pic.set(cfg.get("anim_pic", False))

                self.anim_table.set(cfg.get("anim_table", False))

                self.anim_text.set(cfg.get("anim_text", False))

                rules = cfg.get("rules")

            except Exception:

                pass

        return rules

    def save_config(self):

        cfg = {

            "speed_mode": self.speed_mode.get(),

            "custom_speed": self.custom_speed.get(),

            "base_duration": self.base_duration.get(),

            "play_mode": self.play_mode.get(),

            "loop_mode": self.play_mode.get() == "loop",

            "anim_chart": self.anim_chart.get(),

            "anim_pic": self.anim_pic.get(),

            "anim_table": self.anim_table.get(),

            "anim_text": self.anim_text.get(),

            "rules": [r.get() for r in self.rule_rows],

        }

        try:

            with open(CONFIG_FILE, "w", encoding="utf-8") as f:

                json.dump(cfg, f, ensure_ascii=False, indent=4)

        except Exception:

            pass

    def log(self, msg):

        self.log_text.insert(tk.END, msg + "\n")

        self.log_text.see(tk.END)

    def log_async(self, msg):

        self.root.after(0, partial(self.log, msg))

    # ---------- 形状分类 ----------

    @staticmethod

    def _classify_shape(shape):

        """返回 'chart' / 'pic' / 'table' / 'text' / None"""

        if shape.has_chart:

            return "chart"

        if shape.has_table:

            return "table"

        if shape.shape_type == 13:  # MSO_SHAPE_TYPE.PICTURE

            return "pic"

        try:

            if shape.has_text_frame and shape.shape_type == 17:  # TEXT_BOX

                return "text"

        except Exception:

            pass

        return None

    # ---------- UI ----------

    def build_ui(self):

        main = ttk.Frame(self.root, padding=12)

        main.pack(fill=tk.BOTH, expand=True)

        # 1. 文件(多选)

        ff = ttk.LabelFrame(main, text=" ① 文件设置(支持多选) ", padding=10)

        ff.pack(fill=tk.X, pady=(0, 8))

        ff.columnconfigure(0, weight=1)

        list_frame = ttk.Frame(ff)

        list_frame.grid(row=0, column=0, sticky="nsew")

        list_frame.columnconfigure(0, weight=1)

        self.file_listbox = tk.Listbox(list_frame, height=5, selectmode=tk.EXTENDED,

                                       font=("Consolas", 9))

        self.file_listbox.grid(row=0, column=0, sticky="nsew")

        sb_files = ttk.Scrollbar(list_frame, orient=tk.VERTICAL,

                                 command=self.file_listbox.yview)

        self.file_listbox.configure(yscrollcommand=sb_files.set)

        sb_files.grid(row=0, column=1, sticky="ns")

        file_btns = ttk.Frame(ff)

        file_btns.grid(row=0, column=1, sticky="ns", padx=(8, 0))

        ttk.Button(file_btns, text="➕ 添加 PPT", command=self.add_ppt_files).pack(fill=tk.X, pady=1)

        ttk.Button(file_btns, text="➖ 移除选中", command=self.remove_selected_files).pack(fill=tk.X, pady=1)

        ttk.Button(file_btns, text="🧹 清空列表", command=self.clear_ppt_files).pack(fill=tk.X, pady=1)

        ttk.Label(ff, text="输出目录:").grid(row=1, column=0, sticky="w", pady=(10, 0))

        out_row = ttk.Frame(ff)

        out_row.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(2, 0))

        out_row.columnconfigure(0, weight=1)

        ttk.Entry(out_row, textvariable=self.output_dir).grid(row=0, column=0, sticky="ew")

        ttk.Button(out_row, text="选择目录…", command=self.select_output_dir).grid(row=0, column=1, padx=(8, 0))

        ttk.Label(ff, text="输出命名:原文件名_anim.pptx;未选目录时默认与源文件同目录。",

                  foreground="#888").grid(row=3, column=0, columnspan=2, sticky="w", pady=(4, 0))

        # 2. 规则

        rf = ttk.LabelFrame(main, text=" ② 分段动画规则(按页码范围,先匹配先生效) ", padding=10)

        rf.pack(fill=tk.X, pady=(0, 8))

        self.rules_container = ttk.Frame(rf)

        self.rules_container.pack(fill=tk.X)

        br = ttk.Frame(rf)

        br.pack(fill=tk.X, pady=(8, 0))

        ttk.Button(br, text="➕ 添加规则", command=self.add_rule_row).pack(side=tk.LEFT)

        ttk.Button(br, text="🧹 清空规则", command=self.clear_rule_rows).pack(side=tk.LEFT, padx=8)

        ttk.Label(br, text="提示:结束页可填「全部」或留空表示到最后一页",

                  foreground="#888").pack(side=tk.LEFT, padx=8)

        # 3. 参与动画的对象类型

        tf = ttk.LabelFrame(main, text=" ③ 参与动画的对象类型(可多选,默认仅图表) ", padding=10)

        tf.pack(fill=tk.X, pady=(0, 8))

        RoundCheck(tf, "图表", self.anim_chart).pack(side=tk.LEFT, padx=(0, 18))

        RoundCheck(tf, "图片", self.anim_pic).pack(side=tk.LEFT, padx=(0, 18))

        RoundCheck(tf, "表格", self.anim_table).pack(side=tk.LEFT, padx=(0, 18))

        RoundCheck(tf, "文本", self.anim_text).pack(side=tk.LEFT, padx=(0, 18))

        ttk.Label(tf, text="(未勾选类型保持原样,不注入动画)",

                  foreground="#888").pack(side=tk.LEFT, padx=(8, 0))

        # 4. 速度与播放模式

        sf = ttk.LabelFrame(main, text=" ④ 速度与播放模式 ", padding=10)

        sf.pack(fill=tk.X, pady=(0, 8))

        sf.columnconfigure(5, weight=1)

        ttk.Label(sf, text="播放速度:").grid(row=0, column=0, sticky="w")

        cb_speed = ttk.Combobox(sf, textvariable=self.speed_mode, state="readonly", width=18,

                                values=["0.2x (1/5 原速)", "0.25x (1/4 原速)", "0.33x (1/3 原速)",

                                        "0.5x (1/2 原速)", "1.0x (原速)", "1.5x (快速)",

                                        "2.0x (极快)", "custom (自定义)"])

        cb_speed.grid(row=0, column=1, padx=6)

        cb_speed.bind("<<ComboboxSelected>>", self._on_speed_change)

        self.lbl_custom = ttk.Label(sf, text="输入倍速:")

        self.lbl_custom.grid(row=0, column=2, sticky="w", padx=(20, 0))

        self.ent_custom = ttk.Entry(sf, textvariable=self.custom_speed, width=10)

        self.ent_custom.grid(row=0, column=3, padx=6)

        ttk.Label(sf, text="基准时长(ms):").grid(row=0, column=4, sticky="w", padx=(20, 0))

        ttk.Entry(sf, textvariable=self.base_duration, width=10).grid(row=0, column=5, padx=6, sticky="w")

        if "custom" not in self.speed_mode.get():

            self.lbl_custom.grid_remove()

            self.ent_custom.grid_remove()

        mf = ttk.Frame(sf)

        mf.grid(row=1, column=0, columnspan=6, sticky="w", pady=(10, 0))

        ttk.Label(mf, text="播放模式:").pack(side=tk.LEFT)

        ttk.Radiobutton(mf, text="普通(每对象点击触发)",

                        variable=self.play_mode, value="normal").pack(side=tk.LEFT, padx=(8, 16))

        ttk.Radiobutton(mf, text="循环(自动循环直到切页)",

                        variable=self.play_mode, value="loop").pack(side=tk.LEFT, padx=(0, 16))

        ttk.Radiobutton(mf, text="循环结构·单次播放",

                        variable=self.play_mode, value="once").pack(side=tk.LEFT)

        # 操作按钮

        btn = ttk.Frame(main)

        btn.pack(fill=tk.X, pady=(0, 8))

        self.btn_scan = ttk.Button(btn, text="▶  1. 读取并扫描 PPT",

                                   command=self.scan_ppt_chart, style="Primary.TButton")

        self.btn_scan.pack(side=tk.LEFT, padx=(0, 8), ipadx=10)

        self.btn_run = ttk.Button(btn, text="⚡  2. 批量注入动画",

                                  command=self.start_apply_thread, style="Primary.TButton")

        self.btn_run.pack(side=tk.LEFT, ipadx=10)

        ttk.Button(btn, text="清空日志",

                   command=lambda: self.log_text.delete(1.0, tk.END)).pack(side=tk.RIGHT)

        # 进度

        pf = ttk.Frame(main)

        pf.pack(fill=tk.X, pady=(0, 8))

        self.progress = ttk.Progressbar(pf, orient="horizontal", mode="determinate")

        self.progress.pack(side=tk.LEFT, fill=tk.X, expand=True)

        self.progress_lbl = ttk.Label(pf, text=" 0% ", width=6, anchor="e")

        self.progress_lbl.pack(side=tk.RIGHT)

        # 日志

        lf = ttk.LabelFrame(main, text=" 运行日志 ", padding=8)

        lf.pack(fill=tk.BOTH, expand=True)

        self.log_text = tk.Text(lf, wrap="word", height=10, font=("Consolas", 9),

                                background="#1e1e1e", foreground="#d4d4d4",

                                insertbackground="white", borderwidth=0)

        sb = ttk.Scrollbar(lf, orient=tk.VERTICAL, command=self.log_text.yview)

        self.log_text.configure(yscrollcommand=sb.set)

        self.log_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)

        sb.pack(side=tk.RIGHT, fill=tk.Y)

        self.log_text.tag_configure("ok", foreground="#6ecb6e")

        self.log_text.tag_configure("warn", foreground="#e6c46e")

        self.log_text.tag_configure("err", foreground="#f07171")

        if self._pending_rules:

            for d in self._pending_rules:

                self.add_rule_row(d)

        else:

            self.add_rule_row()

    def _on_speed_change(self, event=None):

        if "custom" in self.speed_mode.get():

            self.lbl_custom.grid()

            self.ent_custom.grid()

        else:

            self.lbl_custom.grid_remove()

            self.ent_custom.grid_remove()

    def add_rule_row(self, data=None):

        row = RuleRow(self.rules_container)

        if data:

            row.set(data)

        row.frame.pack(fill=tk.X, pady=2)

        def _del():

            row.frame.destroy()

            try:

                self.rule_rows.remove(row)

            except ValueError:

                pass

        ttk.Button(row.frame, text="✕", width=3, command=_del).pack(side=tk.LEFT, padx=(8, 0))

        self.rule_rows.append(row)

    def clear_rule_rows(self):

        for r in self.rule_rows:

            r.frame.destroy()

        self.rule_rows.clear()

    # ---------- 文件选择(多选) ----------

    def add_ppt_files(self):

        fps = filedialog.askopenfilenames(

            title="选择 PowerPoint 文档(可多选)",

            filetypes=[("PowerPoint", "*.pptx"), ("所有文件", "*.*")])

        if not fps:

            return

        added = 0

        for fp in fps:

            fp = os.path.normpath(fp)

            if fp not in self.ppt_files:

                self.ppt_files.append(fp)

                self.file_listbox.insert(tk.END, fp)

                added += 1

        if added:

            self.log(f"➕ 已添加 {added} 个文件,当前共 {len(self.ppt_files)} 个。")

        else:

            self.log("ℹ️ 选中的文件已在列表中,未重复添加。")

        if not self.output_dir.get() and self.ppt_files:

            self.output_dir.set(os.path.dirname(self.ppt_files[0]))

    def remove_selected_files(self):

        idxs = list(self.file_listbox.curselection())

        if not idxs:

            messagebox.showinfo("提示", "请先在列表中选择要移除的文件。")

            return

        for i in reversed(idxs):

            self.file_listbox.delete(i)

            del self.ppt_files[i]

        self.log(f"➖ 已移除 {len(idxs)} 个文件,剩余 {len(self.ppt_files)} 个。")

    def clear_ppt_files(self):

        self.ppt_files.clear()

        self.file_listbox.delete(0, tk.END)

        self.log("🧹 文件列表已清空。")

    def select_output_dir(self):

        d = filedialog.askdirectory(title="选择输出目录")

        if d:

            self.output_dir.set(d)

    # ---------- 扫描 ----------

    def scan_ppt_chart(self):

        if not self.ppt_files:

            messagebox.showwarning("提示", "请先添加至少一个 PPT 文件!")

            return

        self.log("=" * 60)

        self.log(f"🔍 开始扫描 {len(self.ppt_files)} 个文件…")

        total_slides = 0

        total_c = total_p = total_t = total_x = 0

        ok_files = 0

        for i, fp in enumerate(self.ppt_files, 1):

            try:

                prs = Presentation(fp)

                n_slides = len(prs.slides)

                n_c = n_p = n_t = n_x = 0

                slide_lines = []

                for idx, slide in enumerate(prs.slides):

                    c = t = p = x = 0

                    for sh in slide.shapes:

                        k = self._classify_shape(sh)

                        if k == "chart":

                            c += 1

                        elif k == "pic":

                            p += 1

                        elif k == "table":

                            t += 1

                        elif k == "text":

                            x += 1

                    if c or p or t or x:

                        slide_lines.append(

                            f"       幻灯片 {idx + 1:02d}:图表 {c} / 图片 {p} / 表格 {t} / 文本 {x}")

                    n_c += c;

                    n_p += p;

                    n_t += t;

                    n_x += x

                total_slides += n_slides

                total_c += n_c;

                total_p += n_p;

                total_t += n_t;

                total_x += n_x

                ok_files += 1

                self.log(f"  [{i}/{len(self.ppt_files)}] ✅ {os.path.basename(fp)}"

                         f"  —— {n_slides} 页 / 图表 {n_c} / 图片 {n_p} / 表格 {n_t} / 文本 {n_x}")

                for line in slide_lines:

                    self.log(line)

            except Exception as e:

                self.log(f"  [{i}/{len(self.ppt_files)}] ❌ 读取失败: {os.path.basename(fp)} —— {e}")

        self.log(f"\n📊 扫描完成:成功 {ok_files}/{len(self.ppt_files)} 个文件,"

                 f"合计 {total_slides} 页 / 图表 {total_c} / 图片 {total_p} / 表格 {total_t} / 文本 {total_x}。")

        if total_c + total_p + total_t + total_x == 0:

            messagebox.showwarning("提示", "所选文件中未发现图表 / 图片 / 表格 / 文本。")

        self.log("=" * 60)

    # ---------- 核心处理 ----------

    def _force_reset_timing(self, slide):

        el = slide._element

        old = el.find(f"{P_TAG}timing")

        if old is not None:

            el.remove(old)

        new_timing = etree.fromstring(TIMING_SKELETON.encode("utf-8"))

        el.append(new_timing)

        main_seq_ctn = None

        for ctn in new_timing.iter(f"{P_TAG}cTn"):

            if ctn.get("nodeType") == "mainSeq":

                main_seq_ctn = ctn

                break

        child_tn = main_seq_ctn.find(f"{P_TAG}childTnLst")

        bld_lst = etree.SubElement(new_timing.find(f"{P_TAG}tnLst"), f"{P_TAG}bldLst")

        return child_tn, bld_lst

    def _parse_range(self, start, end, total):

        s = start.strip()

        if not s:

            raise ValueError("起始页不能为空")

        try:

            s_no = int(s)

        except ValueError:

            raise ValueError(f"起始页 '{s}' 不是数字")

        e = end.strip()

        if not e or e == "全部" or e.lower() == "all":

            e_no = total

        else:

            try:

                e_no = int(e)

            except ValueError:

                raise ValueError(f"结束页 '{e}' 不是数字")

        if s_no < 1 or e_no < 1:

            raise ValueError("页码必须 >= 1")

        if s_no > e_no:

            s_no, e_no = e_no, s_no

        if s_no > total:

            raise ValueError(f"起始页 {s_no} 超出总页数 {total}")

        return s_no, min(e_no, total)

    def _resolve_rules_for_total(self, total):

        if not self.rule_rows:

            raise ValueError("至少需要一条规则")

        parsed = []

        for i, r in enumerate(self.rule_rows):

            d = r.get()

            try:

                s, e = self._parse_range(d["start"], d["end"], total)

            except Exception as ex:

                raise ValueError(f"第 {i + 1} 条规则页码错误: {ex}")

            parsed.append((s, e, d["anim"], d["wipe"], d["build"]))

        return parsed

    @staticmethod

    def _anim_params(anim_name, wipe_name):

        conf = ANIM_MAP.get(anim_name, ANIM_MAP[ANIM_OPTIONS[0]])

        key = wipe_name.split(" ", 1)[0]

        if conf["dir_type"] == "fly":

            actual = FLY_DIR_MAP.get(key, "fromLeft")

            filter_code = conf["filter"].format(fly_dir=actual)

        elif conf["dir_type"] == "zoom":

            actual = ZOOM_DIR_MAP.get(key, "in")

            filter_code = conf["filter"].format(zoom_dir=actual)

        elif conf["dir_type"] == "standard":

            actual = key if key in STD_DIR_SET else key

            filter_code = conf["filter"].format(dir=actual)

        else:

            filter_code = conf["filter"]

        return conf["id"], filter_code, conf["cls"], conf["trans"]

    # ---------- 开始 ----------

    def start_apply_thread(self):

        if not self.ppt_files:

            messagebox.showwarning("提示", "请先添加至少一个 PPT 文件")

            return

        if not self.rule_rows:

            messagebox.showwarning("提示", "请至少添加一条动画规则")

            return

        if not (self.anim_chart.get() or self.anim_pic.get()

                or self.anim_table.get() or self.anim_text.get()):

            messagebox.showwarning("提示", "请至少勾选一种参与动画的对象类型(图表/图片/表格/文本)")

            return

        try:

            base_dur = int(self.base_duration.get())

            sm = self.speed_mode.get()

            if "custom" in sm:

                factor = float(self.custom_speed.get())

                if factor <= 0:

                    raise ValueError("速度必须大于0")

            else:

                m = re.search(r"([\d.]+)x", sm)

                factor = float(m.group(1)) if m else 1.0

            final_dur = max(int(base_dur / factor), 50)

        except Exception as e:

            messagebox.showerror("参数错误", f"速度或时长解析错误: {e}")

            return

        try:

            first_prs = Presentation(self.ppt_files[0])

            first_total = len(first_prs.slides)

            self._resolve_rules_for_total(first_total)

        except Exception as e:

            messagebox.showerror("规则错误", str(e))

            return

        self.save_config()

        self.btn_scan.config(state=tk.DISABLED)

        self.btn_run.config(state=tk.DISABLED)

        self.progress["value"] = 0

        self.progress_lbl.config(text=" 0% ")

        want_chart = self.anim_chart.get()

        want_pic = self.anim_pic.get()

        want_table = self.anim_table.get()

        want_text = self.anim_text.get()

        threading.Thread(

            target=self.do_apply_animation,

            args=(final_dur, want_chart, want_pic, want_table, want_text),

            daemon=True

        ).start()

    def do_apply_animation(self, final_dur, want_chart, want_pic, want_table, want_text):

        after = self.root.after

        total_files = len(self.ppt_files)

        play_mode = self.play_mode.get()

        mode_desc = PLAY_MODE_DESC.get(play_mode, "普通模式")

        structured = play_mode in ("loop", "once")

        out_dir = self.output_dir.get().strip()

        grand_objs = 0

        success_files = 0

        failed_files = []

        for f_idx, in_path in enumerate(self.ppt_files, 1):

            file_base = int((f_idx - 1) * 100 / total_files)

            after(0, partial(self.progress.configure, value=file_base))

            after(0, partial(self.progress_lbl.config, text=f" {file_base}% "))

            base_name = os.path.basename(in_path)

            self.log_async(f"\n{'=' * 60}\n[{f_idx}/{total_files}] 📂 处理文件: {base_name}")

            try:

                prs = Presentation(in_path)

                total = len(prs.slides)

                rules = self._resolve_rules_for_total(total)

            except Exception as e:

                self.log_async(f"  ❌ 打开或解析规则失败: {e}")

                failed_files.append((base_name, str(e)))

                continue

            this_dir = out_dir if out_dir else os.path.dirname(in_path)

            os.makedirs(this_dir, exist_ok=True)

            stem, ext = os.path.splitext(base_name)

            out_file = os.path.join(this_dir, f"{stem}_anim{ext}")

            self.global_anim_id = 3

            file_objs = 0

            try:

                for s_idx, slide in enumerate(prs.slides):

                    slide_no = s_idx + 1

                    inner_pct = int((s_idx + 1) * 100 / max(total, 1))

                    overall = file_base + int(inner_pct * (100 / total_files) / 100)

                    after(0, partial(self.progress.configure, value=overall))

                    after(0, partial(self.progress_lbl.config, text=f" {overall}% "))

                    matched = None

                    for rs, re_, ra, rw, rb in rules:

                        if rs <= slide_no <= re_:

                            matched = (ra, rw, rb)

                            break

                    if matched is None:

                        continue

                    anim_name, wipe_name, build_name = matched

                    preset_id, filter_code, preset_class, transition = self._anim_params(anim_name, wipe_name)

                    build_raw = build_name.split(" ", 1)[0]

                    add_bld_lvl = build_raw not in NO_BLD_LVL

                    tr = slide._element.find(f"{P_TAG}transition")

                    if tr is not None:

                        slide._element.remove(tr)

                    targets = []

                    for sh in slide.shapes:

                        k = self._classify_shape(sh)

                        if k == "chart" and want_chart:

                            targets.append(sh)

                        elif k == "pic" and want_pic:

                            targets.append(sh)

                        elif k == "table" and want_table:

                            targets.append(sh)

                        elif k == "text" and want_text:

                            targets.append(sh)

                    if not targets:

                        continue

                    targets.sort(key=attrgetter("top"))

                    try:

                        child_tn, bld_lst = self._force_reset_timing(slide)

                        if structured:

                            file_objs += self._inject_structured(

                                child_tn, bld_lst, targets, play_mode, preset_id,

                                preset_class, filter_code, transition, final_dur,

                                add_bld_lvl, build_raw)

                        else:

                            file_objs += self._inject_normal(

                                child_tn, bld_lst, targets, preset_id, preset_class,

                                filter_code, transition, final_dur, add_bld_lvl, build_raw)

                        n_c = sum(1 for s in targets if s.has_chart)

                        n_t = sum(1 for s in targets if s.has_table)

                        n_x = sum(1 for s in targets if self._classify_shape(s) == "text")

                        n_p = len(targets) - n_c - n_t - n_x

                        self.log_async(

                            f"  >> 幻灯片 {slide_no:02d}: 规则[{anim_name.split(' ')[0]}/{build_raw}] "

                            f"处理 图表 {n_c} / 图片 {n_p} / 表格 {n_t} / 文本 {n_x}")

                    except Exception as e:

                        self.log_async(f"  ⚠️ 幻灯片 {slide_no:02d} 处理失败: {e}")

                prs.save(out_file)

                grand_objs += file_objs

                success_files += 1

                self.log_async(f"  ✅ 已保存: {out_file}(本文件对象 {file_objs} 个)")

            except Exception as e:

                self.log_async(f"  ❌ 保存失败: {e}")

                failed_files.append((base_name, str(e)))

            finally:

                try:

                    del prs

                except Exception:

                    pass

        after(0, partial(self.progress.configure, value=100))

        after(0, partial(self.progress_lbl.config, text=" 100% "))

        self.log_async("\n" + "=" * 60)

        self.log_async(f"🎉 全部完成!模式:{mode_desc}")

        self.log_async(f"   成功文件:{success_files}/{total_files},"

                       f"累计注入对象:{grand_objs} 个")

        if failed_files:

            self.log_async("   以下文件处理失败:")

            for name, err in failed_files:

                self.log_async(f"     - {name}: {err}")

        after(0, partial(messagebox.showinfo, "完成",

                         f"处理完成。\n成功文件:{success_files}/{total_files}\n"

                         f"累计注入对象:{grand_objs} 个\n模式:{mode_desc}\n\n"

                         f"请打开输出目录验证效果。"))

        after(0, self.reset_buttons)

    def _inject_normal(self, child_tn, bld_lst, targets, preset_id, preset_class,

                       filter_code, transition, dur, add_bld_lvl, build_raw):

        fromstring = etree.fromstring

        last = len(targets) - 1

        cnt = 0

        extra = ' bldLvl="1"' if add_bld_lvl else ""

        for pass_idx in (0, 1):

            for i, shape in enumerate(targets):

                spid = str(shape.shape_id)

                bid = self.global_anim_id

                self.global_anim_id += 4

                cur_dur = 99999999 if (pass_idx == 1 and i == last) else dur

                xml = ANIM_TEMPLATE.format(

                    id=bid, id2=bid + 1, id3=bid + 2, id4=bid + 3,

                    preset_id=preset_id, preset_class=preset_class,

                    extra_attrs=extra, delay="0",

                    transition=transition, filter=filter_code,

                    dur=cur_dur, spid=spid,

                )

                child_tn.append(fromstring(xml.encode("utf-8")))

                if pass_idx == 0 and shape.has_chart:

                    bld_lst.append(fromstring(

                        BUILD_CHART_TEMPLATE.format(bld=build_raw, spid=spid).encode("utf-8")))

                cnt += 1

        return cnt

    def _inject_structured(self, child_tn, bld_lst, targets, play_mode,

                           preset_id, preset_class, filter_code, transition,

                           dur, add_bld_lvl, build_raw):

        fromstring = etree.fromstring

        wrap_id = self.global_anim_id

        self.global_anim_id += 1

        tpl = LOOP_WRAPPER_TEMPLATE if play_mode == "loop" else LOOP_WRAPPER_ONCE_TEMPLATE

        wrapper = fromstring(tpl.format(id=wrap_id).encode("utf-8"))

        wrap_child = wrapper.find(f"{P_TAG}cTn").find(f"{P_TAG}childTnLst")

        child_tn.append(wrapper)

        extra = ' bldLvl="1"' if add_bld_lvl else ""

        for shape in targets:

            spid = str(shape.shape_id)

            id1 = self.global_anim_id

            id2 = id1 + 1

            id3 = id1 + 2

            self.global_anim_id += 3

            xml = LOOP_ANIM_TEMPLATE.format(

                id=id1, id2=id2, id3=id3,

                preset_id=preset_id, preset_class=preset_class,

                extra_attrs=extra, delay="0",

                transition=transition, filter=filter_code,

                dur=dur, spid=spid,

            )

            wrap_child.append(fromstring(xml.encode("utf-8")))

            if shape.has_chart:

                bld_lst.append(fromstring(

                    BUILD_CHART_TEMPLATE.format(bld=build_raw, spid=spid).encode("utf-8")))

        return len(targets)

    def reset_buttons(self):

        self.btn_scan.config(state=tk.NORMAL)

        self.btn_run.config(state=tk.NORMAL)

if __name__ == "__main__":

    root = tk.Tk()

    app = PptChartAnimatorGUI(root)

    root.mainloop()

回首页,搜索你想要的文档

在首页搜索框输入关键词,快速定位资料

文库首页搜索指引 立即搜索

相关学习资料