2025年12月29日 星期一

用Python做一個圖片檢視及簡易編輯的軟體

 做好py檔後,打包成exe

pyinstaller --noconsole --onefile pic.py    #單檔

pyinstaller --clean --noconsole --onedir pic.py    #資料夾

打包後的 exe 檔下載

https://drive.google.com/file/d/1vYvlk5DJgdI0cpWFZ5uDcd-2lQxr3ILp/view?usp=sharing

 原始碼

import tkinter as tk
from tkinter import filedialog, messagebox, colorchooser
from PIL import Image, ImageTk, ImageChops, ImageGrab, ImageDraw, ImageFont, ImageOps
import os
import io
import sys
import win32clipboard
import win32con

# --- 引用進階功能庫 ---
try:
    from PyQt6.QtWidgets import QApplication
    from html2image import Html2Image
except ImportError:
    pass
# ------------------

class PhotoViewerEditor:
    def __init__(self, root):
        self.root = root
        self.root.title("chocho 圖片編輯器")
       
        self.default_w = 1280
        self.default_h = 900
       
        self.root.geometry(f"{self.default_w}x{self.default_h}")
        self.root.config(bg="#2b2b2b")

        # --- 圖片與狀態變數 ---
        self.image_files = []
        self.current_index = 0
        self.original_image = None
        self.shown_image = None
        self.valid_extensions = ('.jpg', '.jpeg', '.png', '.bmp', '.webp', '.gif')
       
        # 顯示參數
        self.imscale = 1.0
        self.delta = 1.3
        self.img_x = 0
        self.img_y = 0
        self.last_mouse_x = 0
        self.last_mouse_y = 0

        # --- 歷史紀錄 (Undo) ---
        self.undo_stack = []
        self.max_undo_steps = 20  # 最多紀錄 20 步

        # --- 模式狀態 ---
        # 1. 裁切
        self.is_cropping = False
        self.crop_start_xy = None
        self.crop_rect_id = None
       
        # 2. 調整大小
        self.is_resizing = False
        self._updating_resize = False

        # 3. 加文字
        self.is_adding_text = False
        self.text_preview_id = None  
        self.text_bg_rect_id = None  
        self.cur_text_pos = (0, 0)
        self.default_text_color = "#FF0000"
        self.default_bg_color = "#000000"
        self.var_bg_transparent = None

        # 4. 繪圖
        self.is_drawing = False
        self.draw_shape_type = 'line' # 'line', 'oval', 'rectangle'
        self.draw_start_xy = None
        self.draw_preview_id = None
        self.draw_color = "#FF0000"
        self.var_draw_width = tk.IntVar(value=5)

        # -------------------
        # UI 建構
        self.canvas = tk.Canvas(root, bg="#2b2b2b", highlightthickness=0)
        self.canvas.pack(fill="both", expand=True)

        btn_frame = tk.Frame(root, bg="#2b2b2b")
        btn_frame.pack(side="bottom", fill="x", pady=10)
       
        self.info_label = tk.Label(btn_frame, text="Ready", bg="#2b2b2b", fg="white", font=("Arial", 10))
        self.info_label.pack(side="top", pady=5)

        self.main_btn_container = tk.Frame(btn_frame, bg="#2b2b2b")
        self.main_btn_container.pack(side="top")

        # --- 導覽區按鈕 ---
        nav_group = tk.Frame(self.main_btn_container, bg="#2b2b2b")
        nav_group.pack(side="left", padx=10)
        tk.Button(nav_group, text="上一張", command=self.show_prev, font=("Arial", 11)).pack(side="left")
        tk.Button(nav_group, text="開啟", command=self.open_file_dialog, font=("Arial", 11, "bold")).pack(side="left", padx=5)
        tk.Button(nav_group, text="📋圖片貼上", command=self.paste_image_from_clipboard, font=("Arial", 11, "bold"), bg="#f0ad4e").pack(side="left", padx=5)
        tk.Button(nav_group, text="📊文字轉圖", command=self.paste_sheet_as_image, font=("Arial", 11, "bold"), bg="#ffeb3b").pack(side="left", padx=5)
        tk.Button(nav_group, text="下一張", command=self.show_next, font=("Arial", 11)).pack(side="left")

        tk.Frame(self.main_btn_container, width=2, bg="#555555").pack(side="left", fill="y", padx=10)

        # --- 編輯工具區按鈕 ---
        edit_group = tk.Frame(self.main_btn_container, bg="#2b2b2b")
        edit_group.pack(side="left", padx=10)
        tk.Button(edit_group, text="左轉", command=self.rotate_left, font=("Arial", 11), bg="#e0e0e0").pack(side="left")
        tk.Button(edit_group, text="右轉", command=self.rotate_right, font=("Arial", 11), bg="#e0e0e0").pack(side="left", padx=5)
       
        self.btn_crop_mode = tk.Button(edit_group, text="圖片裁切", command=self.enter_crop_mode, font=("Arial", 11), bg="#e0e0e0")
        self.btn_crop_mode.pack(side="left", padx=5)
        self.btn_resize_mode = tk.Button(edit_group, text="變更大小", command=self.enter_resize_mode, font=("Arial", 11), bg="#e0e0e0")
        self.btn_resize_mode.pack(side="left", padx=5)
        self.btn_text_mode = tk.Button(edit_group, text="加上文字", command=self.enter_text_mode, font=("Arial", 11), bg="#e0e0e0")
        self.btn_text_mode.pack(side="left", padx=5)
        # 繪圖按鈕
        self.btn_draw_mode = tk.Button(edit_group, text="✏️繪製圖形", command=self.enter_draw_mode, font=("Arial", 11), bg="#e0e0e0")
        self.btn_draw_mode.pack(side="left", padx=5)

        tk.Frame(self.main_btn_container, width=2, bg="#555555").pack(side="left", fill="y", padx=10)

        # --- 存檔操作區按鈕 ---
        action_group = tk.Frame(self.main_btn_container, bg="#2b2b2b")
        action_group.pack(side="left", padx=10)
        # 復原按鈕
        tk.Button(action_group, text="↩️復原", command=self.do_undo, font=("Arial", 11), bg="#ff9800", fg="black").pack(side="left", padx=5)
        tk.Button(action_group, text="複製", command=self.copy_to_clipboard, font=("Arial", 11), bg="#4a90e2", fg="white").pack(side="left")
        tk.Button(action_group, text="另存", command=self.save_as_image, font=("Arial", 11), bg="#5cb85c", fg="white").pack(side="left", padx=5)
        tk.Button(action_group, text="💾儲存", command=self.save_image, font=("Arial", 11), bg="#d9534f", fg="white").pack(side="left", padx=5)

        # 建構各個子功能的控制面板 (預設隱藏)
        self.build_crop_control_panel(btn_frame)
        self.build_resize_control_panel(btn_frame)
        self.build_text_control_panel(btn_frame)
        self.build_draw_control_panel(btn_frame)

        # --- 綁定快捷鍵 ---
        self.root.bind("<Left>", lambda e: self.show_prev())
        self.root.bind("<Right>", lambda e: self.show_next())
        self.root.bind("<Control-c>", lambda e: self.copy_to_clipboard())
        self.root.bind("<Control-s>", lambda e: self.save_image())
        self.root.bind("<Escape>", lambda e: self.exit_all_modes())
        self.root.bind("<Control-v>", lambda e: self.paste_image_from_clipboard())
        self.root.bind("<F2>", lambda e: self.paste_sheet_as_image())
        self.root.bind("<Control-z>", lambda e: self.do_undo()) # 復原快捷鍵

        # --- 綁定滑鼠事件 ---
        self.canvas.bind("<MouseWheel>", self.wheel)
        self.canvas.bind("<ButtonPress-1>", self.on_mouse_down)
        self.canvas.bind("<B1-Motion>", self.on_mouse_drag)
        self.canvas.bind("<ButtonRelease-1>", self.on_mouse_up)

        # 如果有從命令列帶入檔案
        if len(sys.argv) > 1 and os.path.exists(sys.argv[1]):
            self.load_specific_file(sys.argv[1])

    # ==========================================
    #             Undo / Redo 邏輯
    # ==========================================
    def push_undo(self):
        """備份當前圖片狀態"""
        if not self.original_image: return
        self.undo_stack.append(self.original_image.copy())
        if len(self.undo_stack) > self.max_undo_steps:
            self.undo_stack.pop(0)
        self.info_label.config(text=f"已備份 (Undo步驟: {len(self.undo_stack)})")

    def do_undo(self):
        """執行復原"""
        if not self.undo_stack:
            self.info_label.config(text="⚠️ 無法復原:沒有歷史紀錄")
            return
       
        last_image = self.undo_stack.pop()
        self.original_image = last_image
        self.exit_all_modes()
        self.show_image() # 保持縮放與位置
        self.info_label.config(text=f"✅ 已復原 (剩餘步驟: {len(self.undo_stack)})")

    # ==========================================
    #             UI 面板建構區
    # ==========================================
    def build_text_control_panel(self, parent):
        self.text_control_frame = tk.Frame(parent, bg="#2b2b2b")
       
        tk.Label(self.text_control_frame, text="文字:", fg="white", bg="#2b2b2b").pack(side="left")
        self.var_text_content = tk.StringVar(value="請輸入文字")
        self.var_text_content.trace_add("write", lambda *args: self.update_text_preview())
        tk.Entry(self.text_control_frame, textvariable=self.var_text_content, width=15).pack(side="left", padx=5)

        tk.Label(self.text_control_frame, text="大小:", fg="white", bg="#2b2b2b").pack(side="left")
        self.var_text_size = tk.IntVar(value=60)
        sp = tk.Spinbox(self.text_control_frame, from_=10, to=500, textvariable=self.var_text_size, width=5, command=self.update_text_preview)
        sp.pack(side="left")
        sp.bind('<Return>', lambda e: self.update_text_preview())

        self.btn_pick_color = tk.Button(self.text_control_frame, text="字色", bg=self.default_text_color, fg="white", command=self.choose_text_color)
        self.btn_pick_color.pack(side="left", padx=(10, 5))

        self.var_bg_transparent = tk.BooleanVar(value=True)
        tk.Checkbutton(self.text_control_frame, text="透明背景", variable=self.var_bg_transparent,
                       bg="#2b2b2b", fg="white", selectcolor="#2b2b2b", activebackground="#2b2b2b",
                       command=self.update_text_preview).pack(side="left", padx=5)

        self.btn_pick_bg_color = tk.Button(self.text_control_frame, text="底色", bg=self.default_bg_color, fg="black", command=self.choose_bg_color)
        self.btn_pick_bg_color.pack(side="left", padx=5)

        tk.Button(self.text_control_frame, text="✅ 確認", command=self.confirm_text, font=("Arial", 11), bg="#5cb85c", fg="white").pack(side="left", padx=10)
        tk.Button(self.text_control_frame, text="❌ 取消", command=self.exit_all_modes, font=("Arial", 11), bg="#d9534f", fg="white").pack(side="left")

    def build_draw_control_panel(self, parent):
        self.draw_control_frame = tk.Frame(parent, bg="#2b2b2b")
       
        tk.Label(self.draw_control_frame, text="形狀:", fg="white", bg="#2b2b2b").pack(side="left")
       
        self.btn_shape_line = tk.Button(self.draw_control_frame, text="直線", command=lambda: self.set_draw_shape('line'), bg="#e0e0e0", relief="sunken")
        self.btn_shape_line.pack(side="left", padx=2)
        self.btn_shape_oval = tk.Button(self.draw_control_frame, text="橢圓", command=lambda: self.set_draw_shape('oval'), bg="#e0e0e0")
        self.btn_shape_oval.pack(side="left", padx=2)
        self.btn_shape_rect = tk.Button(self.draw_control_frame, text="矩形", command=lambda: self.set_draw_shape('rectangle'), bg="#e0e0e0")
        self.btn_shape_rect.pack(side="left", padx=2)

        tk.Label(self.draw_control_frame, text="粗細:", fg="white", bg="#2b2b2b").pack(side="left", padx=(10,0))
        sp_width = tk.Spinbox(self.draw_control_frame, from_=1, to=50, textvariable=self.var_draw_width, width=3)
        sp_width.pack(side="left", padx=5)

        self.btn_pick_draw_color = tk.Button(self.draw_control_frame, text="顏色", bg=self.draw_color, fg="white", command=self.choose_draw_color)
        self.btn_pick_draw_color.pack(side="left", padx=10)

        tk.Button(self.draw_control_frame, text="完成/退出", command=self.exit_all_modes, font=("Arial", 11), bg="#5cb85c", fg="white").pack(side="left", padx=20)

    def build_crop_control_panel(self, parent):
        self.crop_control_frame = tk.Frame(parent, bg="#2b2b2b")
        tk.Label(self.crop_control_frame, text="X:", fg="white", bg="#2b2b2b").pack(side="left")
        self.var_crop_x = tk.IntVar(value=0)
        tk.Entry(self.crop_control_frame, textvariable=self.var_crop_x, width=5).pack(side="left")
        tk.Label(self.crop_control_frame, text="Y:", fg="white", bg="#2b2b2b").pack(side="left")
        self.var_crop_y = tk.IntVar(value=0)
        tk.Entry(self.crop_control_frame, textvariable=self.var_crop_y, width=5).pack(side="left")
        tk.Label(self.crop_control_frame, text="W:", fg="white", bg="#2b2b2b").pack(side="left")
        self.var_crop_w = tk.IntVar(value=0)
        tk.Entry(self.crop_control_frame, textvariable=self.var_crop_w, width=5).pack(side="left")
        tk.Label(self.crop_control_frame, text="H:", fg="white", bg="#2b2b2b").pack(side="left")
        self.var_crop_h = tk.IntVar(value=0)
        entry_h = tk.Entry(self.crop_control_frame, textvariable=self.var_crop_h, width=5)
        entry_h.pack(side="left")
        entry_h.bind('<Return>', lambda e: self.draw_rect_from_inputs())
        tk.Button(self.crop_control_frame, text="預覽", command=self.draw_rect_from_inputs).pack(side="left", padx=5)
        tk.Button(self.crop_control_frame, text="✅ 裁切", command=self.confirm_crop, bg="#5cb85c", fg="white").pack(side="left", padx=5)
        tk.Button(self.crop_control_frame, text="❌ 取消", command=self.exit_all_modes, bg="#d9534f", fg="white").pack(side="left")

    def build_resize_control_panel(self, parent):
        self.resize_control_frame = tk.Frame(parent, bg="#2b2b2b")
        self.var_resize_w = tk.StringVar(value="0")
        self.var_resize_h = tk.StringVar(value="0")
        self.var_aspect_ratio = tk.BooleanVar(value=True)
        tk.Label(self.resize_control_frame, text="寬:", fg="white", bg="#2b2b2b").pack(side="left")
        tk.Entry(self.resize_control_frame, textvariable=self.var_resize_w, width=6).pack(side="left")
        tk.Label(self.resize_control_frame, text="高:", fg="white", bg="#2b2b2b").pack(side="left")
        tk.Entry(self.resize_control_frame, textvariable=self.var_resize_h, width=6).pack(side="left")
        self.var_resize_w.trace_add("write", self.on_resize_w_change)
        self.var_resize_h.trace_add("write", self.on_resize_h_change)
        tk.Checkbutton(self.resize_control_frame, text="保持比例", variable=self.var_aspect_ratio, bg="#2b2b2b", fg="white", selectcolor="#2b2b2b", activebackground="#2b2b2b").pack(side="left", padx=5)
        tk.Button(self.resize_control_frame, text="✅ 調整", command=self.confirm_resize, bg="#5cb85c", fg="white").pack(side="left", padx=5)
        tk.Button(self.resize_control_frame, text="❌ 取消", command=self.exit_all_modes, bg="#d9534f", fg="white").pack(side="left")

    # ==========================================
    #             功能進入與退出
    # ==========================================
    def enter_draw_mode(self):
        if not self.original_image: return
        self.exit_all_modes()
        self.is_drawing = True
        self.main_btn_container.pack_forget()
        self.draw_control_frame.pack(side="top")
        self.canvas.config(cursor="crosshair")
        self.info_label.config(text="繪圖模式:拖曳繪製,完成後可按 Ctrl+Z 復原。")
        self.set_draw_shape(self.draw_shape_type)

    def exit_draw_mode(self):
        self.is_drawing = False
        self.draw_control_frame.pack_forget()
        self.main_btn_container.pack(side="top")
        self.canvas.config(cursor="")
        if self.draw_preview_id:
            self.canvas.delete(self.draw_preview_id)
            self.draw_preview_id = None

    def enter_text_mode(self):
        if not self.original_image: return
        self.exit_all_modes()
        self.is_adding_text = True
        self.main_btn_container.pack_forget()
        self.text_control_frame.pack(side="top")
        self.info_label.config(text="文字模式:點擊畫面設定位置,確認後可按 Ctrl+Z 復原。")
        self.cur_text_pos = (self.canvas.winfo_width() / 2, self.canvas.winfo_height() / 2)
        self.var_text_content.set("請輸入文字")
        self.update_text_preview()

    def exit_text_mode(self):
        self.is_adding_text = False
        self.text_control_frame.pack_forget()
        self.main_btn_container.pack(side="top")
        self.clear_preview_objects()

    def enter_crop_mode(self):
        if not self.original_image: return
        self.exit_all_modes()
        self.is_cropping = True
        self.main_btn_container.pack_forget()
        self.crop_control_frame.pack(side="top")
        self.canvas.config(cursor="crosshair")
        self.info_label.config(text="裁切模式:拖曳滑鼠選擇區域。")
        self.var_crop_x.set(0)
        self.var_crop_y.set(0)
        self.var_crop_w.set(self.original_image.width)
        self.var_crop_h.set(self.original_image.height)
        self.draw_rect_from_inputs()

    def exit_crop_mode(self):
        self.is_cropping = False
        self.crop_control_frame.pack_forget()
        self.main_btn_container.pack(side="top")
        self.canvas.config(cursor="")
        if self.crop_rect_id:
            self.canvas.delete(self.crop_rect_id)
            self.crop_rect_id = None

    def enter_resize_mode(self):
        if not self.original_image: return
        self.exit_all_modes()
        self.is_resizing = True
        self.main_btn_container.pack_forget()
        self.resize_control_frame.pack(side="top")
        self.info_label.config(text="調整大小模式")
        self._updating_resize = True
        self.var_resize_w.set(str(self.original_image.width))
        self.var_resize_h.set(str(self.original_image.height))
        self._updating_resize = False

    def exit_resize_mode(self):
        self.is_resizing = False
        self.resize_control_frame.pack_forget()
        self.main_btn_container.pack(side="top")

    def exit_all_modes(self):
        if self.is_cropping: self.exit_crop_mode()
        if self.is_resizing: self.exit_resize_mode()
        if self.is_adding_text: self.exit_text_mode()
        if self.is_drawing: self.exit_draw_mode()
        self.update_title_info()

    # ==========================================
    #             繪圖與編輯邏輯
    # ==========================================
    def set_draw_shape(self, shape_type):
        self.draw_shape_type = shape_type
        self.btn_shape_line.config(relief="raised", bg="#e0e0e0")
        self.btn_shape_oval.config(relief="raised", bg="#e0e0e0")
        self.btn_shape_rect.config(relief="raised", bg="#e0e0e0")
       
        if shape_type == 'line': self.btn_shape_line.config(relief="sunken", bg="#cccccc")
        elif shape_type == 'oval': self.btn_shape_oval.config(relief="sunken", bg="#cccccc")
        elif shape_type == 'rectangle': self.btn_shape_rect.config(relief="sunken", bg="#cccccc")

    def choose_draw_color(self):
        color = colorchooser.askcolor(color=self.draw_color, title="選擇繪圖顏色")
        if color[1]:
            self.draw_color = color[1]
            self.btn_pick_draw_color.config(bg=self.draw_color)

    def draw_start(self, event):
        self.draw_start_xy = (self.canvas.canvasx(event.x), self.canvas.canvasy(event.y))

    def draw_dragging(self, event):
        if not self.draw_start_xy: return
        cur_x = self.canvas.canvasx(event.x)
        cur_y = self.canvas.canvasy(event.y)
       
        if self.draw_preview_id:
            self.canvas.delete(self.draw_preview_id)
           
        width = self.var_draw_width.get()
        if self.draw_shape_type == 'line':
            self.draw_preview_id = self.canvas.create_line(self.draw_start_xy[0], self.draw_start_xy[1], cur_x, cur_y, fill=self.draw_color, width=width)
        elif self.draw_shape_type in ['oval', 'rectangle']:
            x1 = min(self.draw_start_xy[0], cur_x)
            y1 = min(self.draw_start_xy[1], cur_y)
            x2 = max(self.draw_start_xy[0], cur_x)
            y2 = max(self.draw_start_xy[1], cur_y)
            if self.draw_shape_type == 'oval':
                self.draw_preview_id = self.canvas.create_oval(x1, y1, x2, y2, outline=self.draw_color, width=width)
            else:
                self.draw_preview_id = self.canvas.create_rectangle(x1, y1, x2, y2, outline=self.draw_color, width=width)

    def draw_end(self, event):
        if not self.original_image or not self.draw_start_xy or not self.draw_preview_id: return
       
        # ★ Undo Point
        self.push_undo()

        self.canvas.delete(self.draw_preview_id)
        self.draw_preview_id = None
       
        cur_x = self.canvas.canvasx(event.x)
        cur_y = self.canvas.canvasy(event.y)
        start_x, start_y = self.draw_start_xy

        min_x, min_y, _, _ = self.get_image_canvas_bounds()
        real_start_x = int((start_x - min_x) / self.imscale)
        real_start_y = int((start_y - min_y) / self.imscale)
        real_end_x = int((cur_x - min_x) / self.imscale)
        real_end_y = int((cur_y - min_y) / self.imscale)
       
        width = int(self.var_draw_width.get() / self.imscale)
        if width < 1: width = 1

        draw = ImageDraw.Draw(self.original_image)
       
        if self.draw_shape_type == 'line':
            draw.line([(real_start_x, real_start_y), (real_end_x, real_end_y)], fill=self.draw_color, width=width)
        elif self.draw_shape_type in ['oval', 'rectangle']:
            x0 = min(real_start_x, real_end_x)
            y0 = min(real_start_y, real_end_y)
            x1 = max(real_start_x, real_end_x)
            y1 = max(real_start_y, real_end_y)
           
            if x1 - x0 < 1 or y1 - y0 < 1: return

            if self.draw_shape_type == 'oval':
                draw.ellipse([x0, y0, x1, y1], outline=self.draw_color, width=width)
            else:
                draw.rectangle([x0, y0, x1, y1], outline=self.draw_color, width=width)
       
        self.show_image()
        self.draw_start_xy = None
        self.info_label.config(text="✅ 圖形已繪製!")

    def confirm_text(self):
        if not self.original_image or not self.text_preview_id: return
       
        # ★ Undo Point
        self.push_undo()

        try:
            text = self.var_text_content.get()
            size = self.var_text_size.get()
            color = self.default_text_color
            bg_color = self.default_bg_color
            is_transparent = self.var_bg_transparent.get()
           
            try:
                font_path = os.path.join(os.environ['WINDIR'], 'Fonts', 'msjh.ttc')
                font = ImageFont.truetype(font_path, size)
            except:
                try: font = ImageFont.truetype("arial.ttf", size)
                except: font = ImageFont.load_default()

            min_x, min_y, _, _ = self.get_image_canvas_bounds()
            canvas_txt_x, canvas_txt_y = self.cur_text_pos
            real_x = int((canvas_txt_x - min_x) / self.imscale)
            real_y = int((canvas_txt_y - min_y) / self.imscale)

            draw = ImageDraw.Draw(self.original_image)

            if not is_transparent:
                try:
                    if hasattr(draw, 'textbbox'):
                        bbox = draw.textbbox((real_x, real_y), text, font=font)
                    else:
                        w, h = draw.textsize(text, font=font)
                        bbox = (real_x, real_y, real_x + w, real_y + h)
                except:
                    w = size * len(text)
                    h = size
                    bbox = (real_x, real_y, real_x + w, real_y + h)
                pad = 5
                rect_bbox = (bbox[0] - pad, bbox[1] - pad, bbox[2] + pad, bbox[3] + pad)
                draw.rectangle(rect_bbox, fill=bg_color)

            draw.text((real_x, real_y), text, fill=color, font=font)
            self.exit_text_mode()
            self.show_image()
            self.info_label.config(text="✅ 文字已加入!")
        except Exception as e:
            messagebox.showerror("錯誤", f"加入文字失敗:{e}")

    def confirm_crop(self):
        if not self.original_image: return
        self.push_undo() # ★ Undo Point
        try:
            x, y = self.var_crop_x.get(), self.var_crop_y.get()
            w, h = self.var_crop_w.get(), self.var_crop_h.get()
            self.original_image = self.original_image.crop((x, y, x + w, y + h))
            self.exit_crop_mode()
            self.fit_to_window()
            self.info_label.config(text=f"已裁切: {w}x{h}")
        except: pass

    def confirm_resize(self):
        if not self.original_image: return
        self.push_undo() # ★ Undo Point
        try:
            w = int(self.var_resize_w.get())
            h = int(self.var_resize_h.get())
            self.original_image = self.original_image.resize((w, h), Image.Resampling.LANCZOS)
            self.exit_resize_mode()
            self.fit_to_window()
            self.info_label.config(text=f"已調整: {w}x{h}")
        except: pass

    def rotate_left(self):
        if not self.original_image: return
        self.push_undo() # ★ Undo Point
        self.original_image = self.original_image.transpose(Image.Transpose.ROTATE_90)
        self.fit_to_window()

    def rotate_right(self):
        if not self.original_image: return
        self.push_undo() # ★ Undo Point
        self.original_image = self.original_image.transpose(Image.Transpose.ROTATE_270)
        self.fit_to_window()

    # ==========================================
    #             滑鼠與顯示核心
    # ==========================================
    def wheel(self, event):
        if not self.original_image or self.is_cropping or self.is_resizing or self.is_adding_text or self.is_drawing:
            return

        mouse_x = self.canvas.canvasx(event.x)
        mouse_y = self.canvas.canvasy(event.y)
        old_scale = self.imscale

        if event.delta > 0 or event.num == 4:
            new_scale = old_scale * self.delta
        elif event.delta < 0 or event.num == 5:
            new_scale = old_scale / self.delta
        else:
            new_scale = old_scale

        if new_scale < 0.05: new_scale = 0.05
        self.imscale = new_scale

        self.img_x = mouse_x - (mouse_x - self.img_x) * (new_scale / old_scale)
        self.img_y = mouse_y - (mouse_y - self.img_y) * (new_scale / old_scale)
        self.show_image()

    def move_start(self, event):
        self.last_mouse_x = event.x
        self.last_mouse_y = event.y

    def move_move(self, event):
        dx = event.x - self.last_mouse_x
        dy = event.y - self.last_mouse_y
        self.img_x += dx
        self.img_y += dy
        self.show_image()
        self.last_mouse_x = event.x
        self.last_mouse_y = event.y

    def show_image(self):
        if not self.original_image: return
        nw, nh = int(self.original_image.size[0]*self.imscale), int(self.original_image.size[1]*self.imscale)
        if nw<=0 or nh<=0: return
        self.shown_image = ImageTk.PhotoImage(self.original_image.resize((nw, nh), Image.Resampling.LANCZOS))
        self.canvas.delete("all")
        self.canvas.create_image(self.img_x, self.img_y, image=self.shown_image, anchor="center")
       
        if self.crop_rect_id and self.is_cropping:
            c = self.canvas.coords(self.crop_rect_id)
            self.crop_rect_id = self.canvas.create_rectangle(*c, outline="red", width=2, dash=(5,3))
        if self.is_adding_text:
            self.update_text_preview()

    def get_image_canvas_bounds(self):
        if not self.original_image: return 0, 0, 0, 0
        orig_w, orig_h = self.original_image.size
        disp_w = orig_w * self.imscale
        disp_h = orig_h * self.imscale
        min_x = self.img_x - (disp_w / 2)
        min_y = self.img_y - (disp_h / 2)
        max_x = min_x + disp_w
        max_y = min_y + disp_h
        return min_x, min_y, max_x, max_y

    def on_mouse_down(self, event):
        if self.is_cropping: self.crop_start(event)
        elif self.is_drawing: self.draw_start(event)
        elif self.is_adding_text:
            self.cur_text_pos = (event.x, event.y)
            self.update_text_preview()
        elif not self.is_resizing: self.move_start(event)

    def on_mouse_drag(self, event):
        if self.is_cropping: self.crop_dragging(event)
        elif self.is_drawing: self.draw_dragging(event)
        elif self.is_adding_text:
            self.cur_text_pos = (event.x, event.y)
            self.update_text_preview()
        elif not self.is_resizing: self.move_move(event)

    def on_mouse_up(self, event):
        if self.is_cropping: self.crop_end(event)
        elif self.is_drawing: self.draw_end(event)

    # ==========================================
    #             檔案與剪貼簿
    # ==========================================
    def paste_image_from_clipboard(self):
        try:
            img = ImageGrab.grabclipboard()
            if isinstance(img, Image.Image):
                self.exit_all_modes()
                self.undo_stack = [] # 貼上新圖時清空歷史
                self.original_image = img.convert("RGB")
                self.image_files = []
                self.current_index = 0
                self.fit_to_window()
                self.info_label.config(text="✅ 已從剪貼簿貼上圖片!")
                self.root.title("剪貼簿圖片 (尚未儲存)")
            elif isinstance(img, list) and len(img) > 0 and os.path.isfile(img[0]):
                ext = os.path.splitext(img[0])[1].lower()
                if ext in self.valid_extensions:
                    self.load_specific_file(img[0])
            else:
                try:
                    qt_app = QApplication.instance()
                    if qt_app is None: qt_app = QApplication(sys.argv)
                    mime = qt_app.clipboard().mimeData()
                    if mime.hasHtml():
                         if messagebox.askyesno("提示", "偵測到文字資料,是否執行「文字轉圖」?"):
                             self.paste_sheet_as_image()
                    else:
                        messagebox.showinfo("提示", "剪貼簿無圖片資料。")
                except:
                    messagebox.showinfo("提示", "無法存取剪貼簿或無有效資料。")
        except Exception as e: messagebox.showerror("錯誤", str(e))

    def paste_sheet_as_image(self):
        self.info_label.config(text="⏳ 正在轉換...")
        self.root.update_idletasks()
        try:
            qt_app = QApplication.instance()
            if qt_app is None: qt_app = QApplication(sys.argv)
            clipboard = qt_app.clipboard()
            mime_data = clipboard.mimeData()
            if not mime_data.hasHtml():
                messagebox.showwarning("提示", "剪貼簿中沒有 HTML 表格資料。")
                self.info_label.config(text="Ready")
                return
            html_content = mime_data.html()
            styled_html = f"<html><head><meta charset='utf-8'><style>body{{background-color:white;padding:10px;display:inline-block;}}table{{border-collapse:collapse;}}</style></head><body>{html_content}</body></html>"
            temp_filename = "temp_sheet_paste.png"
            hti = Html2Image()
            hti.output_path = os.getcwd()
            hti.screenshot(html_str=styled_html, save_as=temp_filename, size=(2000, 2000))
            if os.path.exists(temp_filename):
                self.exit_all_modes()
                self.undo_stack = []
                new_img = Image.open(temp_filename).convert("RGB")
               
                bg = Image.new(new_img.mode, new_img.size, new_img.getpixel((0,0)))
                diff = ImageChops.difference(new_img, bg)
                diff = ImageChops.add(diff, diff, 2.0, -100)
                bbox = diff.getbbox()
                if bbox: new_img = new_img.crop(bbox)
               
                self.original_image = new_img
                self.image_files = []
                self.current_index = 0
                self.fit_to_window()
                self.info_label.config(text="✅ 轉換成功!")
                self.root.title("試算表圖片")
                try: os.remove(temp_filename)
                except: pass
        except Exception as e: messagebox.showerror("錯誤", str(e))

    def copy_to_clipboard(self):
        if not self.original_image: return
        try:
            output = io.BytesIO()
            self.original_image.convert("RGB").save(output, "BMP")
            data = output.getvalue()[14:]
            output.close()
            win32clipboard.OpenClipboard()
            win32clipboard.EmptyClipboard()
            win32clipboard.SetClipboardData(win32con.CF_DIB, data)
            win32clipboard.CloseClipboard()
            self.info_label.config(text="✅ 已複製!")
        except Exception as e: messagebox.showerror("錯誤", str(e))

    def load_specific_file(self, file_path):
        folder_path = os.path.dirname(file_path)
        try: all_files = os.listdir(folder_path)
        except: all_files = []
        self.image_files = []
        for f in all_files:
            if f.lower().endswith(self.valid_extensions):
                self.image_files.append(os.path.join(folder_path, f))
        self.image_files.sort()
        abs_path = os.path.abspath(file_path)
        try:
            clean_list = [os.path.abspath(p) for p in self.image_files]
            self.current_index = clean_list.index(abs_path)
        except:
            self.current_index = 0
            if abs_path not in clean_list: self.image_files = [abs_path]
        self.load_image_by_index()

    def open_file_dialog(self):
        file_path = filedialog.askopenfilename(filetypes=[("Image Files", "*.jpg;*.jpeg;*.png;*.bmp;*.webp")])
        if not file_path: return
        self.load_specific_file(file_path)

    def load_image_by_index(self):
        if not self.image_files: return
        self.exit_all_modes()
        self.undo_stack = [] # 換圖時清空歷史
        try:
            self.original_image = Image.open(self.image_files[self.current_index])
            self.fit_to_window()
            self.update_title_info()
        except Exception as e: messagebox.showerror("錯誤", str(e))

    def save_as_image(self):
        if not self.original_image: return
        if self.is_cropping or self.is_resizing or self.is_adding_text or self.is_drawing:
            messagebox.showinfo("提示", "請先完成或取消編輯模式。")
            return
        orig_ext = os.path.splitext(self.image_files[self.current_index])[1] if self.image_files else ".png"
        file_path = filedialog.asksaveasfilename(defaultextension=orig_ext, filetypes=[("Images", "*.jpg;*.png;*.bmp")])
        if not file_path: return
        try:
            self.original_image.save(file_path)
            messagebox.showinfo("成功", f"檔案已儲存:{os.path.basename(file_path)}")
        except Exception as e: messagebox.showerror("錯誤", str(e))

    def save_image(self):
        if not self.original_image: return
        if self.is_cropping or self.is_resizing or self.is_adding_text or self.is_drawing:
            messagebox.showinfo("提示", "請先完成或取消編輯模式。")
            return
        if not self.image_files:
            self.save_as_image()
            return
        try:
            self.original_image.save(self.image_files[self.current_index])
            messagebox.showinfo("成功", "已覆寫儲存")
        except Exception as e: messagebox.showerror("錯誤", str(e))

    # ==========================================
    #             輔助功能
    # ==========================================
    def update_title_info(self):
        if not self.original_image: return
        name = os.path.basename(self.image_files[self.current_index]) if self.image_files else "New Image"
        self.root.title(f"{name}")
        self.info_label.config(text=f"{name} - {self.original_image.size}")

    def clear_preview_objects(self):
        if self.text_preview_id:
            self.canvas.delete(self.text_preview_id)
            self.text_preview_id = None
        if self.text_bg_rect_id:
            self.canvas.delete(self.text_bg_rect_id)
            self.text_bg_rect_id = None

    def update_text_preview(self):
        if not self.is_adding_text: return
        self.clear_preview_objects()
        content = self.var_text_content.get()
        try: display_size = int(self.var_text_size.get() * self.imscale)
        except: display_size = 20

        self.text_preview_id = self.canvas.create_text(
            self.cur_text_pos[0], self.cur_text_pos[1],
            text=content,
            fill=self.default_text_color,
            font=("Microsoft JhengHei", display_size, "bold"),
            anchor="nw"
        )

        if not self.var_bg_transparent.get():
            bbox = self.canvas.bbox(self.text_preview_id)
            if bbox:
                pad = 5
                x1, y1, x2, y2 = bbox
                rect_bbox = (x1 - pad, y1 - pad, x2 + pad, y2 + pad)
                self.text_bg_rect_id = self.canvas.create_rectangle(rect_bbox, fill=self.default_bg_color, outline="")
                self.canvas.tag_lower(self.text_bg_rect_id, self.text_preview_id)

    def draw_rect_from_inputs(self):
        if not self.original_image or not self.is_cropping: return
        try:
            ix, iy = self.var_crop_x.get(), self.var_crop_y.get()
            iw, ih = self.var_crop_w.get(), self.var_crop_h.get()
        except: return
        min_x, min_y, _, _ = self.get_image_canvas_bounds()
        canvas_x1 = min_x + (ix * self.imscale)
        canvas_y1 = min_y + (iy * self.imscale)
        canvas_x2 = canvas_x1 + (iw * self.imscale)
        canvas_y2 = canvas_y1 + (ih * self.imscale)
        if self.crop_rect_id: self.canvas.delete(self.crop_rect_id)
        self.crop_rect_id = self.canvas.create_rectangle(canvas_x1, canvas_y1, canvas_x2, canvas_y2, outline="red", width=2, dash=(5, 3))

    def crop_start(self, event):
        min_x, min_y, max_x, max_y = self.get_image_canvas_bounds()
        sx = max(min_x, min(self.canvas.canvasx(event.x), max_x))
        sy = max(min_y, min(self.canvas.canvasy(event.y), max_y))
        self.crop_start_xy = (sx, sy)
        if self.crop_rect_id: self.canvas.delete(self.crop_rect_id)
        self.crop_rect_id = self.canvas.create_rectangle(sx, sy, sx, sy, outline="red", width=2, dash=(5, 3))

    def crop_dragging(self, event):
        if not self.crop_rect_id: return
        min_x, min_y, max_x, max_y = self.get_image_canvas_bounds()
        cx = max(min_x, min(self.canvas.canvasx(event.x), max_x))
        cy = max(min_y, min(self.canvas.canvasy(event.y), max_y))
        self.canvas.coords(self.crop_rect_id, self.crop_start_xy[0], self.crop_start_xy[1], cx, cy)
        x1, y1, x2, y2 = self.canvas.coords(self.crop_rect_id)
        self.var_crop_x.set(int((min(x1,x2)-min_x)/self.imscale))
        self.var_crop_y.set(int((min(y1,y2)-min_y)/self.imscale))
        self.var_crop_w.set(int(abs(x2-x1)/self.imscale))
        self.var_crop_h.set(int(abs(y2-y1)/self.imscale))

    def crop_end(self, event): pass

    def on_resize_w_change(self, *args):
        if self._updating_resize or not self.var_aspect_ratio.get() or not self.original_image: return
        try:
            new_w = int(self.var_resize_w.get())
            if new_w > 0:
                orig_w, orig_h = self.original_image.size
                ratio = orig_h / orig_w
                new_h = int(new_w * ratio)
                self._updating_resize = True
                self.var_resize_h.set(str(new_h))
                self._updating_resize = False
        except: pass

    def on_resize_h_change(self, *args):
        if self._updating_resize or not self.var_aspect_ratio.get() or not self.original_image: return
        try:
            new_h = int(self.var_resize_h.get())
            if new_h > 0:
                orig_w, orig_h = self.original_image.size
                ratio = orig_w / orig_h
                new_w = int(new_h * ratio)
                self._updating_resize = True
                self.var_resize_w.set(str(new_w))
                self._updating_resize = False
        except: pass

    def choose_text_color(self):
        color = colorchooser.askcolor(color=self.default_text_color, title="選擇文字顏色")
        if color[1]:
            self.default_text_color = color[1]
            self.btn_pick_color.config(bg=self.default_text_color)
            self.update_text_preview()

    def choose_bg_color(self):
        color = colorchooser.askcolor(color=self.default_bg_color, title="選擇背景顏色")
        if color[1]:
            self.default_bg_color = color[1]
            self.btn_pick_bg_color.config(bg=self.default_bg_color)
            self.var_bg_transparent.set(False)
            self.update_text_preview()

    def fit_to_window(self):
        if not self.original_image: return
        win_w = self.canvas.winfo_width()
        win_h = self.canvas.winfo_height()
        if win_w < 50: win_w = self.default_w
        if win_h < 50: win_h = self.default_h
        im_w, im_h = self.original_image.size
        self.imscale = min(win_w / im_w, win_h / im_h)
        self.img_x, self.img_y = win_w / 2, win_h / 2
        self.show_image()

    def show_prev(self):
        if self.image_files:
            self.current_index = (self.current_index - 1) % len(self.image_files)
            self.load_image_by_index()

    def show_next(self):
        if self.image_files:
            self.current_index = (self.current_index + 1) % len(self.image_files)
            self.load_image_by_index()

if __name__ == "__main__":
    root = tk.Tk()
    app = PhotoViewerEditor(root)
    root.mainloop()

搜尋此網誌