做好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()