ARTICLE · 1323284
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()















































































