# -*- coding: utf-8 -*-
"""
合并图片到 PPTX 工具(后台无弹窗版)
- 使用 python-pptx 直接生成 .pptx 文件,不启动 PowerPoint、无任何弹窗,纯后台。
- 支持选择文件夹或多张图片;按文件名/修改时间排序,正序/倒序(默认文件名正序)。
- 可选择图片填充方式:适应 / 填充(裁剪) / 拉伸 / 居中(原始大小)。
- 每张图片生成一页幻灯片,底部进度条显示进度。
依赖:
pip install python-pptx pillow
"""
import os
import sys
import threading
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tif", ".tiff", ".webp")
# 依赖检测
try:
from pptx import Presentation
from pptx.util import Emu
HAS_PPTX = True
except Exception:
HAS_PPTX = False
try:
from PIL import Image
HAS_PIL = True
except Exception:
HAS_PIL = False
EMU_PER_INCH = 914400
def get_image_size(path):
"""返回 (宽, 高) 像素,失败返回 None。"""
try:
with Image.open(path) as im:
return im.size
except Exception:
return None
class App:
# 填充方式: 值 -> 显示文本
FILL_MODES = [
("fit", "适应(完整显示,留白)"),
("fill", "填充(铺满,裁剪超出部分)"),
("stretch", "拉伸(铺满,可能变形)"),
("center", "居中(原始大小)"),
]
def __init__(self, root):
self.root = root
root.title("图片合并到 PPTX(后台生成)")
root.geometry("660x600")
root.minsize(580, 560)
self.files = []
self.input_mode = tk.StringVar(value="folder") # folder / files
self.sort_key = tk.StringVar(value="name") # name / mtime
self.sort_order = tk.StringVar(value="asc") # asc / desc
self.fill_mode = tk.StringVar(value="fit") # fit/fill/stretch/center
self.output_path = tk.StringVar(value="")
self._build_ui()
# ---------------- UI ----------------
def _build_ui(self):
pad = {"padx": 8, "pady": 4}
# 输入方式
f_input = ttk.LabelFrame(self.root, text="输入方式")
f_input.pack(fill="x", **pad)
ttk.Radiobutton(f_input, text="选择文件夹", value="folder",
variable=self.input_mode).pack(side="left", padx=10, pady=6)
ttk.Radiobutton(f_input, text="选择多个图片", value="files",
variable=self.input_mode).pack(side="left", padx=10, pady=6)
ttk.Button(f_input, text="选择...", command=self.on_select_input).pack(side="left", padx=10)
ttk.Button(f_input, text="清空", command=self.on_clear).pack(side="left", padx=4)
# 排序方式
f_sort = ttk.LabelFrame(self.root, text="排序方式")
f_sort.pack(fill="x", **pad)
ttk.Label(f_sort, text="依据:").pack(side="left", padx=(10, 2))
ttk.Radiobutton(f_sort, text="文件名", value="name",
variable=self.sort_key, command=self.resort).pack(side="left", padx=6)
ttk.Radiobutton(f_sort, text="修改时间", value="mtime",
variable=self.sort_key, command=self.resort).pack(side="left", padx=6)
ttk.Separator(f_sort, orient="vertical").pack(side="left", fill="y", padx=10, pady=4)
ttk.Radiobutton(f_sort, text="正序", value="asc",
variable=self.sort_order, command=self.resort).pack(side="left", padx=6)
ttk.Radiobutton(f_sort, text="倒序", value="desc",
variable=self.sort_order, command=self.resort).pack(side="left", padx=6)
# 填充方式
f_fill = ttk.LabelFrame(self.root, text="图片填充方式")
f_fill.pack(fill="x", **pad)
for val, label in self.FILL_MODES:
ttk.Radiobutton(f_fill, text=label, value=val,
variable=self.fill_mode).pack(anchor="w", padx=12, pady=1)
# 文件列表
f_list = ttk.LabelFrame(self.root, text="待合并图片(按当前排序)")
f_list.pack(fill="both", expand=True, **pad)
self.listbox = tk.Listbox(f_list)
self.listbox.pack(side="left", fill="both", expand=True, padx=(6, 0), pady=6)
sb = ttk.Scrollbar(f_list, orient="vertical", command=self.listbox.yview)
sb.pack(side="right", fill="y", pady=6)
self.listbox.config(yscrollcommand=sb.set)
# 输出
f_out = ttk.LabelFrame(self.root, text="输出文件")
f_out.pack(fill="x", **pad)
ttk.Entry(f_out, textvariable=self.output_path).pack(
side="left", fill="x", expand=True, padx=(8, 4), pady=6)
ttk.Button(f_out, text="另存为...", command=self.on_select_output).pack(side="left", padx=6)
# 进度条 + 状态
f_prog = ttk.Frame(self.root)
f_prog.pack(fill="x", **pad)
self.progress = ttk.Progressbar(f_prog, mode="determinate")
self.progress.pack(fill="x", padx=8, pady=(6, 2))
self.status = ttk.Label(f_prog, text="就绪")
self.status.pack(anchor="w", padx=8)
# 操作按钮
f_btn = ttk.Frame(self.root)
f_btn.pack(fill="x", **pad)
self.btn_run = ttk.Button(f_btn, text="开始生成", command=self.on_run)
self.btn_run.pack(side="right", padx=8, pady=6)
# ---------------- 事件 ----------------
def on_select_input(self):
if self.input_mode.get() == "folder":
folder = filedialog.askdirectory(title="选择包含图片的文件夹")
if not folder:
return
files = [os.path.join(folder, n) for n in os.listdir(folder)
if n.lower().endswith(IMAGE_EXTS)]
self.files = files
else:
sel = filedialog.askopenfilenames(
title="选择图片",
filetypes=[("图片文件", " ".join("*" + e for e in IMAGE_EXTS)),
("所有文件", "*.*")])
if not sel:
return
self.files = list(sel)
if not self.files:
messagebox.showwarning("提示", "未找到任何图片。")
self.resort()
def on_clear(self):
self.files = []
self.refresh_list()
self.set_status("已清空")
def resort(self):
if not self.files:
self.refresh_list()
return
reverse = self.sort_order.get() == "desc"
if self.sort_key.get() == "name":
self.files.sort(key=lambda p: os.path.basename(p).lower(), reverse=reverse)
else:
self.files.sort(key=lambda p: os.path.getmtime(p), reverse=reverse)
self.refresh_list()
def refresh_list(self):
self.listbox.delete(0, tk.END)
for p in self.files:
self.listbox.insert(tk.END, os.path.basename(p))
self.set_status(f"共 {len(self.files)} 张图片")
def on_select_output(self):
path = filedialog.asksaveasfilename(
title="选择输出 PPTX 文件",
defaultextension=".pptx",
filetypes=[("PowerPoint 演示文稿", "*.pptx")],
initialfile="合并图片.pptx")
if path:
self.output_path.set(path)
def on_run(self):
if not self.files:
messagebox.showwarning("提示", "请先选择图片。")
return
if not self.output_path.get():
messagebox.showwarning("提示", "请先选择输出文件。")
return
self.btn_run.config(state="disabled")
self.progress.config(maximum=len(self.files), value=0)
threading.Thread(target=self._generate_worker, daemon=True).start()
# ---------------- 生成(纯后台,无弹窗) ----------------
def _generate_worker(self):
files = list(self.files)
out = self.output_path.get()
mode = self.fill_mode.get()
try:
self._ui(lambda: self.set_status("正在创建演示文稿..."))
prs = Presentation()
# 幻灯片尺寸:依据首图比例,长边 10 英寸
sw, sh = self._decide_slide_size(files[0])
prs.slide_width = Emu(int(sw))
prs.slide_height = Emu(int(sh))
blank_layout = prs.slide_layouts[6] # 空白版式
for i, path in enumerate(files, start=1):
self._ui(lambda i=i, path=path:
self.set_status(f"正在添加 ({i}/{len(files)}): {os.path.basename(path)}"))
slide = prs.slides.add_slide(blank_layout)
self._place_picture(slide, path, sw, sh, mode)
self._ui(lambda i=i: self.progress.config(value=i))
self._ui(lambda: self.set_status("正在保存..."))
prs.save(os.path.abspath(out))
self._ui(lambda: self._on_done(True, out))
except Exception as e:
self._ui(lambda e=e: self._on_done(False, repr(e)))
def _decide_slide_size(self, first_path):
"""返回幻灯片 (宽, 高) EMU,基于首图比例,长边 10 英寸。"""
size = get_image_size(first_path)
long_emu = 10 * EMU_PER_INCH
if not size:
return 13.333 * EMU_PER_INCH, 7.5 * EMU_PER_INCH # 默认 16:9
iw, ih = size
if iw >= ih:
return long_emu, long_emu * ih / iw
return long_emu * iw / ih, long_emu
def _place_picture(self, slide, path, sw, sh, mode):
"""按填充方式把图片放入幻灯片。sw/sh 为幻灯片尺寸(EMU)。"""
size = get_image_size(path)
# 无法获取尺寸时,统一按铺满处理
if not size:
slide.shapes.add_picture(path, 0, 0, width=Emu(int(sw)), height=Emu(int(sh)))
return
iw, ih = size
if mode == "stretch":
slide.shapes.add_picture(path, 0, 0, width=Emu(int(sw)), height=Emu(int(sh)))
return
if mode == "center":
# 原始像素大小(按 96 DPI 换算),居中;过大则退化为“适应”
w = iw / 96.0 * EMU_PER_INCH
h = ih / 96.0 * EMU_PER_INCH
if w > sw or h > sh:
scale = min(sw / w, sh / h)
w, h = w * scale, h * scale
left, top = (sw - w) / 2, (sh - h) / 2
slide.shapes.add_picture(path, Emu(int(left)), Emu(int(top)),
width=Emu(int(w)), height=Emu(int(h)))
return
if mode == "fill":
# 铺满整页并按比例裁剪超出部分(无变形)
pic = slide.shapes.add_picture(path, 0, 0, width=Emu(int(sw)), height=Emu(int(sh)))
ai = iw / ih # 图片宽高比
as_ = sw / sh # 幻灯片宽高比
if ai > as_: # 图片更宽 -> 裁左右
crop = (1 - as_ / ai) / 2.0
pic.crop_left = crop
pic.crop_right = crop
elif ai < as_: # 图片更高 -> 裁上下
crop = (1 - ai / as_) / 2.0
pic.crop_top = crop
pic.crop_bottom = crop
return
# 默认 fit:等比缩放完整放入,居中留白
scale = min(sw / iw, sh / ih)
w, h = iw * scale, ih * scale
left, top = (sw - w) / 2, (sh - h) / 2
slide.shapes.add_picture(path, Emu(int(left)), Emu(int(top)),
width=Emu(int(w)), height=Emu(int(h)))
def _on_done(self, ok, info):
self.btn_run.config(state="normal")
if ok:
self.progress.config(value=self.progress["maximum"])
self.set_status("完成")
if messagebox.askyesno("完成", f"已生成:\n{info}\n\n是否打开所在文件夹?"):
try:
os.startfile(os.path.dirname(os.path.abspath(info)))
except Exception:
pass
else:
self.progress.config(value=0)
self.set_status("失败")
messagebox.showerror("生成失败", info)
# ---------------- 工具 ----------------
def set_status(self, text):
self.status.config(text=text)
def _ui(self, func):
self.root.after(0, func)
def main():
missing = []
if not HAS_PPTX:
missing.append("python-pptx")
if not HAS_PIL:
missing.append("pillow")
if missing:
root = tk.Tk()
root.withdraw()
messagebox.showerror(
"缺少依赖",
"缺少以下 Python 库,无法运行:\n " + "、".join(missing) +
"\n\n请在命令行执行:\n pip install python-pptx pillow")
root.destroy()
sys.exit(1)
root = tk.Tk()
App(root)
root.mainloop()
if __name__ == "__main__":
main()