|
| 1 | +import tkinter as tk |
| 2 | +from tkinter import filedialog, messagebox |
| 3 | +import ttkbootstrap as tb |
| 4 | +from PIL import Image, ImageDraw, ImageFont, ImageTk |
| 5 | +import os |
| 6 | + |
| 7 | +# Optional: Drag-and-drop support |
| 8 | +try: |
| 9 | + from tkinterdnd2 import DND_FILES, TkinterDnD |
| 10 | + DND_AVAILABLE = True |
| 11 | +except ImportError: |
| 12 | + DND_AVAILABLE = False |
| 13 | + print("tkinterdnd2 not installed. Drag-and-drop disabled (install via pip install tkinterdnd2)") |
| 14 | + |
| 15 | +# ================= HELPERS ================= |
| 16 | +def show_error(title, msg): |
| 17 | + messagebox.showerror(title, msg) |
| 18 | + |
| 19 | +def show_info(title, msg): |
| 20 | + messagebox.showinfo(title, msg) |
| 21 | + |
| 22 | +def add_placeholder(entry, text): |
| 23 | + entry.insert(0, text) |
| 24 | + entry.config(foreground="grey") |
| 25 | + def clear(_): |
| 26 | + if entry.get() == text: |
| 27 | + entry.delete(0, "end") |
| 28 | + entry.config(foreground="black") |
| 29 | + def restore(_): |
| 30 | + if not entry.get(): |
| 31 | + entry.insert(0, text) |
| 32 | + entry.config(foreground="grey") |
| 33 | + entry.bind("<FocusIn>", clear) |
| 34 | + entry.bind("<FocusOut>", restore) |
| 35 | + |
| 36 | +# ================= ROOT WINDOW ================= |
| 37 | +if DND_AVAILABLE: |
| 38 | + app = TkinterDnD.Tk() |
| 39 | + app.title("Per-Thumbnail Watermark Tool") |
| 40 | + app.geometry("1200x600") |
| 41 | +else: |
| 42 | + app = tb.Window("Per-Thumbnail Watermark Tool", themename="flatly", size=(1200, 600)) |
| 43 | + |
| 44 | +# ================= DATA ================= |
| 45 | +image_data = [] # List of dicts: {"path":..., "position":..., "thumbnail":..., "preview":..., "selected":...} |
| 46 | + |
| 47 | +# ================= SPLIT PANEL ================= |
| 48 | +left_panel = tb.Labelframe(app, text="Image Preview (Drag & Drop / Browse)", padding=10) |
| 49 | +left_panel.pack(side="left", fill="both", expand=True, padx=10, pady=10) |
| 50 | + |
| 51 | +right_panel = tb.Labelframe(app, text="Global Watermark Settings", padding=10) |
| 52 | +right_panel.pack(side="right", fill="y", padx=10, pady=10) |
| 53 | + |
| 54 | +# ================= SCROLLABLE GALLERY ================= |
| 55 | +canvas = tk.Canvas(left_panel) |
| 56 | +scrollbar = tk.Scrollbar(left_panel, orient="vertical", command=canvas.yview) |
| 57 | +scrollable_frame = tk.Frame(canvas) |
| 58 | +scrollable_frame.bind( |
| 59 | + "<Configure>", |
| 60 | + lambda e: canvas.configure(scrollregion=canvas.bbox("all")) |
| 61 | +) |
| 62 | +canvas.create_window((0,0), window=scrollable_frame, anchor="nw") |
| 63 | +canvas.configure(yscrollcommand=scrollbar.set) |
| 64 | +canvas.pack(side="left", fill="both", expand=True) |
| 65 | +scrollbar.pack(side="right", fill="y") |
| 66 | + |
| 67 | +# ================= IMAGE MANAGEMENT ================= |
| 68 | +def add_images(paths): |
| 69 | + for path in paths: |
| 70 | + if os.path.isfile(path) and path not in [d["path"] for d in image_data]: |
| 71 | + ext = os.path.splitext(path)[1].lower() |
| 72 | + if ext in [".png", ".jpg", ".jpeg", ".bmp", ".gif"]: |
| 73 | + image_data.append({ |
| 74 | + "path": path, |
| 75 | + "position": "Bottom-Right", |
| 76 | + "thumbnail": None, |
| 77 | + "preview": None, |
| 78 | + "selected": False |
| 79 | + }) |
| 80 | + update_gallery() |
| 81 | + |
| 82 | +def browse_images(): |
| 83 | + paths = filedialog.askopenfilenames(filetypes=[("Image Files", "*.png *.jpg *.jpeg *.bmp *.gif")]) |
| 84 | + add_images(paths) |
| 85 | + |
| 86 | +# ================= DRAG & DROP ================= |
| 87 | +def drop_files(event): |
| 88 | + if DND_AVAILABLE: |
| 89 | + # Handle Windows-style paths with braces and spaces |
| 90 | + data = event.data |
| 91 | + if data.startswith("{") and data.endswith("}"): |
| 92 | + files = [f.strip("{}") for f in data.split("} {")] |
| 93 | + else: |
| 94 | + files = data.split() |
| 95 | + add_images(files) |
| 96 | + |
| 97 | +def remove_selected(): |
| 98 | + global image_data |
| 99 | + image_data = [d for d in image_data if not d["selected"]] |
| 100 | + update_gallery() |
| 101 | + |
| 102 | +# ================= GLOBAL WATERMARK SETTINGS ================= |
| 103 | +tb.Label(right_panel, text="Watermark Text").pack(anchor="w", pady=(0,2)) |
| 104 | +watermark_text = tb.Entry(right_panel, width=40) |
| 105 | +watermark_text.pack(anchor="w", pady=(0,5)) |
| 106 | +add_placeholder(watermark_text, "Enter watermark text") |
| 107 | + |
| 108 | +tb.Label(right_panel, text="Font Size").pack(anchor="w", pady=(5,2)) |
| 109 | +font_size = tb.Entry(right_panel, width=20) |
| 110 | +font_size.pack(anchor="w", pady=(0,5)) |
| 111 | +add_placeholder(font_size, "36") |
| 112 | + |
| 113 | +tb.Label(right_panel, text="Opacity (0-255)").pack(anchor="w", pady=(5,2)) |
| 114 | +opacity = tb.Entry(right_panel, width=20) |
| 115 | +opacity.pack(anchor="w", pady=(0,5)) |
| 116 | +add_placeholder(opacity, "128") |
| 117 | + |
| 118 | +tb.Label(right_panel, text="Output Folder").pack(anchor="w", pady=(5,2)) |
| 119 | +output_path_var = tk.StringVar() |
| 120 | +tb.Entry(right_panel, textvariable=output_path_var, width=40).pack(anchor="w", pady=(0,5)) |
| 121 | +select_btn = tb.Button(right_panel, text="Select Folder", bootstyle="success", command=lambda: output_path_var.set(filedialog.askdirectory())) |
| 122 | +select_btn.pack(anchor="w", pady=(0,5)) |
| 123 | + |
| 124 | +# ================= THUMBNAIL PREVIEW ================= |
| 125 | +def generate_preview(image_dict): |
| 126 | + try: |
| 127 | + img = Image.open(image_dict["path"]).convert("RGBA") |
| 128 | + img.thumbnail((150,150)) |
| 129 | + preview = img.copy() |
| 130 | + text = watermark_text.get().strip() |
| 131 | + if text and text.lower() != "enter watermark text": |
| 132 | + try: |
| 133 | + size = int(font_size.get()) |
| 134 | + except: |
| 135 | + size = 36 |
| 136 | + try: |
| 137 | + alpha = int(opacity.get()) |
| 138 | + if not (0 <= alpha <= 255): |
| 139 | + alpha = 128 |
| 140 | + except: |
| 141 | + alpha = 128 |
| 142 | + watermark_layer = Image.new("RGBA", preview.size, (255,255,255,0)) |
| 143 | + draw = ImageDraw.Draw(watermark_layer) |
| 144 | + try: |
| 145 | + font = ImageFont.truetype("arial.ttf", size) |
| 146 | + except: |
| 147 | + font = ImageFont.load_default() |
| 148 | + bbox = draw.textbbox((0,0), text, font=font) |
| 149 | + text_width = bbox[2]-bbox[0] |
| 150 | + text_height = bbox[3]-bbox[1] |
| 151 | + pos = image_dict.get("position","Bottom-Right") |
| 152 | + x = y = 0 |
| 153 | + if pos=="Top-Left": x,y=10,10 |
| 154 | + elif pos=="Top-Right": x,y=preview.width-text_width-10,10 |
| 155 | + elif pos=="Bottom-Left": x,y=10,preview.height-text_height-10 |
| 156 | + elif pos=="Bottom-Right": x,y=preview.width-text_width-10,preview.height-text_height-10 |
| 157 | + elif pos=="Center": x,y=(preview.width-text_width)//2,(preview.height-text_height)//2 |
| 158 | + draw.text((x,y), text, fill=(255,255,255,alpha), font=font) |
| 159 | + preview = Image.alpha_composite(preview, watermark_layer) |
| 160 | + image_dict["preview"] = ImageTk.PhotoImage(preview) |
| 161 | + except Exception as e: |
| 162 | + print(f"Preview error: {e}") |
| 163 | + |
| 164 | +# ================= GALLERY UPDATE (grid version) ================= |
| 165 | +def update_gallery(): |
| 166 | + for widget in scrollable_frame.winfo_children(): |
| 167 | + widget.destroy() |
| 168 | + columns = 5 |
| 169 | + for idx, d in enumerate(image_data): |
| 170 | + generate_preview(d) |
| 171 | + lbl_frame = tk.Frame(scrollable_frame, bd=2, relief="groove") |
| 172 | + lbl_frame.grid(row=idx//columns, column=idx%columns, padx=5, pady=5) |
| 173 | + lbl = tk.Label(lbl_frame, image=d["preview"]) |
| 174 | + lbl.image = d["preview"] |
| 175 | + lbl.pack() |
| 176 | + # Position combobox per thumbnail |
| 177 | + pos_cb = tb.Combobox(lbl_frame, values=["Top-Left","Top-Right","Bottom-Left","Bottom-Right","Center"], |
| 178 | + state="readonly", width=12) |
| 179 | + pos_cb.set(d.get("position","Bottom-Right")) |
| 180 | + pos_cb.pack(pady=5) |
| 181 | + def cb_callback(event, img_dict=d, label=lbl): |
| 182 | + img_dict["position"] = pos_cb.get() |
| 183 | + generate_preview(img_dict) |
| 184 | + label.configure(image=img_dict["preview"]) |
| 185 | + label.image = img_dict["preview"] |
| 186 | + pos_cb.bind("<<ComboboxSelected>>", cb_callback) |
| 187 | + # Selection toggle |
| 188 | + def toggle_select(e, img_dict=d, frame=lbl_frame): |
| 189 | + img_dict["selected"] = not img_dict["selected"] |
| 190 | + frame.config(bd=4, relief="sunken" if img_dict["selected"] else "groove") |
| 191 | + lbl_frame.bind("<Button-1>", toggle_select) |
| 192 | + lbl.bind("<Button-1>", toggle_select) |
| 193 | + |
| 194 | +def process_image(d, text, size, alpha, out_dir): |
| 195 | + try: |
| 196 | + img = Image.open(d["path"]).convert("RGBA") |
| 197 | + watermark_layer = Image.new("RGBA", img.size, (255,255,255,0)) |
| 198 | + draw = ImageDraw.Draw(watermark_layer) |
| 199 | + |
| 200 | + try: |
| 201 | + font = ImageFont.truetype("arial.ttf", size) |
| 202 | + except: |
| 203 | + font = ImageFont.load_default() |
| 204 | + |
| 205 | + bbox = draw.textbbox((0,0), text, font=font) |
| 206 | + text_width = bbox[2] - bbox[0] |
| 207 | + text_height = bbox[3] - bbox[1] |
| 208 | + |
| 209 | + pos = d.get("position","Bottom-Right") |
| 210 | + if pos=="Top-Left": x,y=10,10 |
| 211 | + elif pos=="Top-Right": x,y=img.width-text_width-10,10 |
| 212 | + elif pos=="Bottom-Left": x,y=10,img.height-text_height-10 |
| 213 | + elif pos=="Bottom-Right": x,y=img.width-text_width-10,img.height-text_height-10 |
| 214 | + else: x,y=(img.width-text_width)//2,(img.height-text_height)//2 |
| 215 | + |
| 216 | + draw.text((x,y), text, fill=(255,255,255,alpha), font=font) |
| 217 | + |
| 218 | + watermarked = Image.alpha_composite(img, watermark_layer) |
| 219 | + base_name = os.path.basename(d["path"]) |
| 220 | + watermarked.convert("RGB").save(os.path.join(out_dir, base_name)) |
| 221 | + |
| 222 | + progress_var.set(progress_var.get() + 1) |
| 223 | + app.update_idletasks() |
| 224 | + |
| 225 | + except Exception as e: |
| 226 | + print(f"Failed: {d['path']} -> {e}") |
| 227 | + |
| 228 | +def reset_progress_ui(): |
| 229 | + apply_btn.config(state="normal") |
| 230 | + progress_bar.pack_forget() |
| 231 | + progress_var.set(0) |
| 232 | + |
| 233 | +# ================= APPLY WATERMARK ================= |
| 234 | +def apply_watermark(): |
| 235 | + progress_bar.pack(fill="x", pady=(0, 5)) |
| 236 | + progress_bar["maximum"] = len(image_data) |
| 237 | + progress_var.set(0) |
| 238 | + select_btn.config(state="disabled") |
| 239 | + browse_btn.config(state="disabled") |
| 240 | + remove_btn.config(state="disabled") |
| 241 | + apply_btn.config(state="disabled") |
| 242 | + app.update_idletasks() |
| 243 | + |
| 244 | + out_dir = output_path_var.get().strip() |
| 245 | + if not out_dir or not os.path.isdir(out_dir): |
| 246 | + show_error("Error", "Please select a valid output folder.") |
| 247 | + reset_progress_ui() |
| 248 | + return |
| 249 | + text = watermark_text.get().strip() |
| 250 | + if not text or text.lower()=="enter watermark text": |
| 251 | + show_error("Error", "Please enter watermark text.") |
| 252 | + return |
| 253 | + try: |
| 254 | + size = int(font_size.get()) |
| 255 | + except: |
| 256 | + size = 36 |
| 257 | + try: |
| 258 | + alpha = int(opacity.get()) |
| 259 | + if not (0<=alpha<=255): alpha=128 |
| 260 | + except: |
| 261 | + alpha=128 |
| 262 | + count=0 |
| 263 | + for d in image_data: |
| 264 | + process_image(d, text, size, alpha, out_dir) |
| 265 | + count += 1 |
| 266 | + show_info("Done", f"Watermarked {count} image(s) successfully.") |
| 267 | + select_btn.config(state="normal") |
| 268 | + browse_btn.config(state="normal") |
| 269 | + remove_btn.config(state="normal") |
| 270 | + apply_btn.config(state="normal") |
| 271 | + progress_bar.pack_forget() |
| 272 | + progress_var.set(0) |
| 273 | + |
| 274 | +# ================= BUTTONS ================= |
| 275 | +browse_btn= tb.Button( |
| 276 | + right_panel, |
| 277 | + text="Browse Images", |
| 278 | + bootstyle="info", |
| 279 | + command=browse_images |
| 280 | +) |
| 281 | +browse_btn.pack(pady=5, fill="x") |
| 282 | + |
| 283 | +remove_btn = tb.Button( |
| 284 | + right_panel, |
| 285 | + text="Remove Selected", |
| 286 | + bootstyle="danger", |
| 287 | + command=remove_selected |
| 288 | +) |
| 289 | +remove_btn.pack(pady=5, fill="x") |
| 290 | + |
| 291 | +apply_btn = tb.Button( |
| 292 | + right_panel, |
| 293 | + text="Apply Watermark to All", |
| 294 | + bootstyle="success", |
| 295 | + command=apply_watermark |
| 296 | +) |
| 297 | +apply_btn.pack(pady=(10, 5), fill="x") |
| 298 | + |
| 299 | +# Progress bar (hidden initially) |
| 300 | +progress_var = tk.IntVar(value=0) |
| 301 | +progress_bar = tb.Progressbar( |
| 302 | + right_panel, |
| 303 | + variable=progress_var, |
| 304 | + maximum=100, |
| 305 | + mode="determinate" |
| 306 | +) |
| 307 | +progress_bar.pack(fill="x", pady=(0, 5)) |
| 308 | +progress_bar.pack_forget() # hide until needed |
| 309 | + |
| 310 | +# ================= DRAG & DROP ================= |
| 311 | +if DND_AVAILABLE: |
| 312 | + left_panel.drop_target_register(DND_FILES) |
| 313 | + left_panel.dnd_bind("<<Drop>>", drop_files) |
| 314 | + |
| 315 | +update_gallery() |
| 316 | +app.mainloop() |
0 commit comments