批量把图片合并到一个pptx文件里的py脚本

# -*- 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()

posted @ 2026-06-17 22:56  _迷途  阅读(22)  评论(0)    收藏  举报