#!/usr/bin/env python
# -*- coding: utf-8 -*-

import sys,os,warnings,threading,tempfile,subprocess,time,shutil,platform
from pathlib import Path
from tkinter import *
from tkinter import ttk,filedialog,messagebox
from tkinter.scrolledtext import ScrolledText
from PIL import Image

os.environ['PYTHONWARNINGS']='ignore'
os.environ['PYTHONDONTWRITEBYTECODE']='1'
warnings.filterwarnings('ignore')

IS_WIN=platform.system()=='Windows'
HAS_WIN32COM=False
try:
    import win32com.client,pythoncom
    HAS_WIN32COM=True
except:pass

class Ctrl:
    def __init__(self):
        self.pause=threading.Event()
        self.pause.set()
        self.stop=False
        self.running=False
    def pause_(self):self.pause.clear()
    def resume(self):self.pause.set()
    def stop_(self):self.stop=True;self.pause.set()
    def reset(self):self.pause.set();self.stop=False;self.running=False
    def check(self):
        if self.stop:return False
        self.pause.wait()
        return not self.stop
    def should_stop(self):return self.stop

class PPTConv:
    def __init__(self,log=None,prog=None):
        self.log=log or (lambda x:None)
        self.prog=prog or (lambda c,t:None)
        self.ctrl=Ctrl()
        self.tools={'ms_ppt':False,'libre':None}
        self._checked=False
    def _check_tools(self):
        if self._checked:return
        self._checked=True
        self.log("检测Office组件...")
        if HAS_WIN32COM:
            try:pythoncom.CoInitialize()
            except:pass
            try:
                pa=win32com.client.Dispatch("PowerPoint.Application")
                pa.Quit()
                self.tools['ms_ppt']=True
                self.log("  Microsoft PowerPoint: 可用")
            except:self.log("  Microsoft PowerPoint: 不可用")
        libre_paths=[r"C:\Program Files\LibreOffice\program\soffice.exe",
                     r"C:\Program Files (x86)\LibreOffice\program\soffice.exe"]
        for p in libre_paths:
            if os.path.exists(p):
                self.tools['libre']=p
                self.log("  LibreOffice: 可用")
                break
        if not self.tools['ms_ppt'] and not self.tools['libre']:
            self.log("  警告: 未检测到可用的转换工具!")
            self.log("  请安装 Microsoft PowerPoint 或 LibreOffice")
    def _ppt_export_pdf(self,ppt_path,pdf_path):
        if self.tools['ms_ppt'] and HAS_WIN32COM:
            pa=None
            try:
                self.log("  使用 PowerPoint...")
                pythoncom.CoInitialize()
                pa=win32com.client.DispatchEx("PowerPoint.Application")
                pres=pa.Presentations.Open(os.path.abspath(ppt_path),ReadOnly=True,Untitled=False,WithWindow=False)
                pres.SaveAs(os.path.abspath(pdf_path),32)
                pres.Close()
                self.log("  PowerPoint导出完成")
                return True
            except Exception as e:
                self.log(f"  PowerPoint失败: {e}")
                return False
            finally:
                if pa:
                    try:pa.Quit()
                    except:pass
                    try:pythoncom.CoUninitialize()
                    except:pass
        if self.tools['libre']:
            try:
                self.log("  使用 LibreOffice...")
                od=os.path.dirname(os.path.abspath(pdf_path))
                subprocess.run([self.tools['libre'],'--headless','--convert-to','pdf','--outdir',od,os.path.abspath(ppt_path)],capture_output=True,timeout=300)
                exp=os.path.join(od,Path(ppt_path).stem+'.pdf')
                if os.path.exists(exp):
                    if exp!=pdf_path:shutil.move(exp,pdf_path)
                    return True
            except:pass
        return False
    def convert_file(self,ppt_path):
        try:
            ppt_path=os.path.abspath(ppt_path)
            pdf_path=os.path.join(os.path.dirname(ppt_path),Path(ppt_path).stem+'.pdf')
            self.log(f"转换: {os.path.basename(ppt_path)}")
            self._check_tools()
            if not self.tools['ms_ppt'] and not self.tools['libre']:
                self.log("  错误: 没有可用的转换工具")
                return False
            if self._ppt_export_pdf(ppt_path,pdf_path):
                self.log(f"  成功: {pdf_path}")
                return True
            else:
                self.log("  失败: 导出失败")
                return False
        except Exception as e:
            self.log(f"错误: {e}")
            return False
    def convert_folder(self,folder_path,recursive=False):
        folder_path=os.path.abspath(folder_path)
        self.log(f"="*50)
        self.log(f"扫描文件夹: {folder_path}")
        ppt_files=[]
        if recursive:
            for root,dirs,files in os.walk(folder_path):
                for f in files:
                    if f.lower().endswith(('.pptx','.ppt')):
                        ppt_files.append(os.path.join(root,f))
        else:
            for f in os.listdir(folder_path):
                fp=os.path.join(folder_path,f)
                if os.path.isfile(fp) and f.lower().endswith(('.pptx','.ppt')):
                    ppt_files.append(fp)
        if not ppt_files:
            self.log("  未找到PPT文件")
            return 0
        self.log(f"  找到 {len(ppt_files)} 个PPT文件")
        self.log("-"*50)
        success=0
        for i,f in enumerate(ppt_files,1):
            if not self.ctrl.check():break
            self.prog(i,len(ppt_files))
            if self.convert_file(f):success+=1
            self.prog(i,len(ppt_files))
        self.log("-"*50)
        self.log(f"完成: {success}/{len(ppt_files)} 个文件转换成功")
        return success

class MultiFolderSelector(Toplevel):
    def __init__(self,parent,initial_path=None):
        super().__init__(parent)
        self.title("选择文件夹")
        self.geometry("500x450")
        self.resizable(True,True)
        self.update_idletasks()
        x=(self.winfo_screenwidth()//2)-(500//2)
        y=(self.winfo_screenheight()//2)-(450//2)
        self.geometry(f"500x450+{x}+{y}")
        self.selected_folders=[]
        self.initial_path=initial_path or os.path.expanduser("~")
        self._build_ui()
        self._load_folders(self.initial_path)
        self.transient(parent)
        self.grab_set()
    def _build_ui(self):
        mf=ttk.Frame(self,padding="10")
        mf.pack(fill=BOTH,expand=True)
        pf=ttk.Frame(mf)
        pf.pack(fill=X,pady=(0,10))
        ttk.Label(pf,text="选择父文件夹:").pack(side=LEFT)
        self.path_var=StringVar(value=self.initial_path)
        self.entry=ttk.Entry(pf,textvariable=self.path_var,width=40)
        self.entry.pack(side=LEFT,padx=5,fill=X,expand=True)
        ttk.Button(pf,text="浏览",command=self._browse,width=8).pack(side=LEFT)
        ttk.Label(mf,text="按住 Ctrl/Shift 键可多选子文件夹,或双击进入子文件夹",foreground="gray",font=('微软雅黑',9)).pack(pady=(0,5))
        lf=ttk.Frame(mf)
        lf.pack(fill=BOTH,expand=True)
        sy=ttk.Scrollbar(lf)
        sy.pack(side=RIGHT,fill=Y)
        sx=ttk.Scrollbar(lf,orient=HORIZONTAL)
        sx.pack(side=BOTTOM,fill=X)
        self.lb=Listbox(lf,font=('Consolas',10),selectmode=EXTENDED,yscrollcommand=sy.set,xscrollcommand=sx.set)
        self.lb.pack(fill=BOTH,expand=True)
        sy.config(command=self.lb.yview)
        sx.config(command=self.lb.xview)
        self.lb.bind('<Double-Button-1>',self._enter)
        bf=ttk.Frame(mf)
        bf.pack(fill=X,pady=(10,0))
        ttk.Button(bf,text="添加选中文件夹",command=self._add_sel,width=15).pack(side=LEFT,padx=5)
        ttk.Button(bf,text="添加全部",command=self._add_all,width=12).pack(side=LEFT,padx=5)
        ttk.Button(bf,text="取消",command=self.destroy,width=10).pack(side=RIGHT,padx=5)
        self.status=ttk.Label(bf,text="",foreground="blue")
        self.status.pack(side=RIGHT,padx=10)
    def _browse(self):
        f=filedialog.askdirectory(initialdir=self.path_var.get())
        if f:
            self.path_var.set(f)
            self._load_folders(f)
    def _load_folders(self,parent):
        self.lb.delete(0,END)
        if not os.path.isdir(parent):return
        subs=[]
        try:
            for i in os.listdir(parent):
                if os.path.isdir(os.path.join(parent,i)):
                    subs.append(i)
        except:pass
        subs.sort(key=str.lower)
        if subs:
            self.lb.insert(END,".. (上级目录)")
            for s in subs:self.lb.insert(END,s)
            self.status.config(text=f"{len(subs)} 个子文件夹")
        else:
            self.lb.insert(END,"(无子文件夹)")
            self.status.config(text="无子文件夹")
    def _enter(self,e=None):
        sel=self.lb.curselection()
        if not sel:return
        name=self.lb.get(sel[0])
        if name==".. (上级目录)":
            p=os.path.dirname(self.path_var.get())
            if p and os.path.isdir(p):
                self.path_var.set(p)
                self._load_folders(p)
        else:
            np=os.path.join(self.path_var.get(),name)
            if os.path.isdir(np):
                self.path_var.set(np)
                self._load_folders(np)
    def _add_sel(self):
        sel=self.lb.curselection()
        if not sel:
            messagebox.showwarning("提示","请先选择文件夹")
            return
        p=self.path_var.get()
        for i in sel:
            name=self.lb.get(i)
            if name in [".. (上级目录)","(无子文件夹)"]:continue
            fp=os.path.join(p,name)
            if fp not in self.selected_folders:
                self.selected_folders.append(fp)
        self.destroy()
    def _add_all(self):
        p=self.path_var.get()
        added=0
        for i in range(self.lb.size()):
            name=self.lb.get(i)
            if name in [".. (上级目录)","(无子文件夹)"]:continue
            fp=os.path.join(p,name)
            if fp not in self.selected_folders:
                self.selected_folders.append(fp)
                added+=1
        if added>0:self.destroy()
        else:messagebox.showinfo("提示","没有可添加的文件夹")

class GUI:
    def __init__(self,root):
        self.root=root
        self.root.title("PPT批量转PDF工具")
        self.root.geometry("900x700")
        self.root.minsize(800,600)
        try:ttk.Style().theme_use('vista')
        except:
            try:ttk.Style().theme_use('clam')
            except:pass
        self.folders=[]
        self.counts={}
        self._build_ui()
        self.conv=PPTConv(self.log,self._prog)
        self.log("程序启动")
        self.log("="*50)
        threading.Thread(target=self._check_tools_thread,daemon=True).start()
    def _check_tools_thread(self):
        time.sleep(0.5)
        self.conv._check_tools()
    def _build_ui(self):
        mf=ttk.Frame(self.root,padding="10")
        mf.pack(fill=BOTH,expand=True)
        ttk.Label(mf,text="PPT批量转PDF工具",font=('微软雅黑',16,'bold')).pack(pady=(0,10))
        ff=ttk.LabelFrame(mf,text="文件夹列表",padding="10")
        ff.pack(fill=BOTH,expand=True,pady=(0,10))
        lf=ttk.Frame(ff)
        lf.pack(fill=BOTH,expand=True)
        sb=ttk.Scrollbar(lf)
        sb.pack(side=RIGHT,fill=Y)
        self.lb=Listbox(lf,font=('Consolas',10),yscrollcommand=sb.set,selectmode=EXTENDED)
        self.lb.pack(fill=BOTH,expand=True)
        sb.config(command=self.lb.yview)
        bf=ttk.Frame(ff)
        bf.pack(fill=X,pady=(10,0))
        ttk.Button(bf,text="添加文件夹",command=self.add_folder,width=12).pack(side=LEFT,padx=5)
        ttk.Button(bf,text="多选添加",command=self.add_multi,width=12).pack(side=LEFT,padx=5)
        ttk.Button(bf,text="移除选中",command=self.remove_sel,width=12).pack(side=LEFT,padx=5)
        ttk.Button(bf,text="清空列表",command=self.clear_all,width=12).pack(side=LEFT,padx=5)
        self.stats=ttk.Label(bf,text="共 0 个文件夹",foreground="gray")
        self.stats.pack(side=RIGHT,padx=10)
        of=ttk.LabelFrame(mf,text="选项",padding="10")
        of.pack(fill=X,pady=(0,10))
        self.rec=BooleanVar(value=False)
        ttk.Checkbutton(of,text="包含子文件夹",variable=self.rec).pack(side=LEFT,padx=20)
        self.ov=BooleanVar(value=False)
        ttk.Checkbutton(of,text="覆盖已存在的PDF",variable=self.ov).pack(side=LEFT,padx=20)
        pf=ttk.Frame(mf)
        pf.pack(fill=X,pady=(0,10))
        self.pv=DoubleVar()
        self.pb=ttk.Progressbar(pf,variable=self.pv,maximum=100)
        self.pb.pack(fill=X,side=LEFT,expand=True,padx=(0,10))
        self.pl=ttk.Label(pf,text="就绪",width=20)
        self.pl.pack(side=RIGHT)
        cf=ttk.Frame(mf)
        cf.pack(fill=X,pady=(0,10))
        self.start_btn=ttk.Button(cf,text="开始转换",command=self.start,width=15)
        self.start_btn.pack(side=LEFT,padx=5)
        ttk.Button(cf,text="暂停",command=self.pause,width=10).pack(side=LEFT,padx=5)
        ttk.Button(cf,text="继续",command=self.resume,width=10).pack(side=LEFT,padx=5)
        ttk.Button(cf,text="终止",command=self.stop,width=10).pack(side=LEFT,padx=5)
        ttk.Button(cf,text="清除日志",command=lambda:self.log_text.delete(1.0,END),width=10).pack(side=RIGHT,padx=5)
        lf2=ttk.LabelFrame(mf,text="日志",padding="5")
        lf2.pack(fill=BOTH,expand=True)
        self.log_text=ScrolledText(lf2,height=10,font=('Consolas',9),bg='#1e1e1e',fg='#d4d4d4')
        self.log_text.pack(fill=BOTH,expand=True)
    def add_folder(self):
        f=filedialog.askdirectory(title="选择包含PPT文件的文件夹")
        if f and f not in self.folders:
            cnt=self._count_ppt(f,self.rec.get())
            self.folders.append(f)
            self.counts[f]=cnt
            self.lb.insert(END,f"{os.path.basename(f)} ({cnt}个PPT)")
            self.lb.itemconfig(END,fg='green' if cnt>0 else 'red')
            self._upd_stats()
            self.log(f"添加: {f} ({cnt}个PPT)")
        elif f: self.log(f"跳过(已存在): {f}")
    def add_multi(self):
        d=MultiFolderSelector(self.root)
        self.root.wait_window(d)
        if d.selected_folders:
            added=0
            for f in d.selected_folders:
                if f not in self.folders:
                    cnt=self._count_ppt(f,self.rec.get())
                    self.folders.append(f)
                    self.counts[f]=cnt
                    self.lb.insert(END,f"{f} ({cnt}个PPT)")
                    self.lb.itemconfig(END,fg='green' if cnt>0 else 'red')
                    self.log(f"添加: {f} ({cnt}个PPT)")
                    added+=1
                else: self.log(f"跳过(已存在): {f}")
            if added>0:
                self._upd_stats()
                messagebox.showinfo("提示",f"成功添加 {added} 个文件夹")
    def _count_ppt(self,folder,recursive):
        c=0
        if recursive:
            for r,_,fs in os.walk(folder):
                for fn in fs:
                    if fn.lower().endswith(('.pptx','.ppt')):c+=1
        else:
            for fn in os.listdir(folder):
                if os.path.isfile(os.path.join(folder,fn)) and fn.lower().endswith(('.pptx','.ppt')):c+=1
        return c
    def _upd_stats(self):
        total=len(self.folders)
        ppts=sum(self.counts.values())
        self.stats.config(text=f"共 {total} 个文件夹, {ppts} 个PPT")
    def remove_sel(self):
        sel=self.lb.curselection()
        if not sel:
            messagebox.showwarning("提示","请先选择要移除的文件夹")
            return
        for i in reversed(sel):
            f=self.folders[i]
            self.log(f"移除: {f}")
            del self.counts[f]
            del self.folders[i]
            self.lb.delete(i)
        self._upd_stats()
    def clear_all(self):
        if self.folders and messagebox.askyesno("确认","确定要清空所有文件夹吗?"):
            self.folders.clear()
            self.counts.clear()
            self.lb.delete(0,END)
            self._upd_stats()
            self.log("已清空所有文件夹")
    def start(self):
        if self.conv.ctrl.running:
            messagebox.showwarning("提示","正在转换中,请稍候")
            return
        if not self.folders:
            messagebox.showwarning("提示","请先添加文件夹")
            return
        self.conv.ctrl.reset()
        self.pv.set(0)
        self.pl.config(text="处理中...")
        self.start_btn.config(state='disabled')
        threading.Thread(target=self._conv_thread,daemon=True).start()
    def _conv_thread(self):
        try:
            total_ppts=sum(self.counts.values())
            if total_ppts==0:
                self.log("没有找到任何PPT文件")
                self._done()
                return
            self.log(f"开始转换 {len(self.folders)} 个文件夹中的 {total_ppts} 个PPT文件")
            self.log("="*50)
            processed=0
            for f in self.folders:
                if self.conv.ctrl.should_stop():break
                cnt=self.counts.get(f,0)
                if cnt==0:continue
                self.conv.convert_folder(f,recursive=self.rec.get())
                processed+=cnt
                p=(processed/total_ppts)*100
                self.pv.set(p)
                self.pl.config(text=f"{processed}/{total_ppts} ({p:.0f}%)")
            self.pv.set(100)
            self.log("="*50)
            self.log("所有转换完成!")
        except Exception as e:
            self.log(f"错误: {e}")
        finally:
            self._done()
    def _done(self):
        self.root.after(0,lambda:setattr(self.conv.ctrl,'running',False) or self.start_btn.config(state='normal') or self.pl.config(text="完成"))
    def pause(self):
        if self.conv.ctrl.running:
            self.conv.ctrl.pause_()
            self.log("已暂停")
    def resume(self):
        self.conv.ctrl.resume()
        self.log("继续转换")
    def stop(self):
        if self.conv.ctrl.running:
            self.conv.ctrl.stop_()
            self.log("正在终止...")
    def log(self,msg):
        def _log():
            try:
                self.log_text.insert(END,msg+"\n")
                self.log_text.see(END)
            except:pass
        try:self.root.after(0,_log)
        except:print(msg)
    def _prog(self,c,t):
        def _up():
            try:
                p=(c/t*100) if t>0 else 0
                self.pv.set(p)
                self.pl.config(text=f"{c}/{t} ({p:.0f}%)")
            except:pass
        try:self.root.after(0,_up)
        except:pass

def main():
    root=Tk()
    app=GUI(root)
    root.update_idletasks()
    w=root.winfo_width()
    h=root.winfo_height()
    x=(root.winfo_screenwidth()//2)-(w//2)
    y=(root.winfo_screenheight()//2)-(h//2)
    root.geometry(f'{w}x{h}+{x}+{y}')
    root.mainloop()

if __name__=="__main__":
    main()


一键打包成EXE

pyinstaller --onefile --windowed ppt_to_pdf_converter.py
11111