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