import os import re import threading import queue from urllib.parse import urlparse, unquote from io import BytesIO import tkinter as tk from tkinter import ttk, messagebox import requests from PIL import Image, ImageOps APP_NAME = "ImageDownloader" THUMB_W, THUMB_H = 304, 201 TIMEOUT = 25 # seconds UA = "ImageDownloader/1.0 (+Tkinter; Single download; Kelvin workflow)" def desktop_output_dir() -> str: home = os.path.expanduser("~") desktop = os.path.join(home, "Desktop") out_dir = os.path.join(desktop, APP_NAME) os.makedirs(out_dir, exist_ok=True) return out_dir def safe_filename(name: str, max_len: int = 120) -> str: name = (name or "").strip().strip(".") name = re.sub(r"[\\/:*?\"<>|\n\r\t]+", " ", name) name = re.sub(r"\s+", " ", name).strip() if not name: name = "untitled" if len(name) > max_len: name = name[:max_len].rstrip() return name def filename_stem_from_url(url: str) -> str: try: p = urlparse(url) path = unquote(p.path or "") base = os.path.basename(path) if not base: return "image" base = base.split("?")[0].split("#")[0] stem, _ext = os.path.splitext(base) return safe_filename(stem) if stem else "image" except Exception: return "image" def ensure_unique_path(path: str) -> str: if not os.path.exists(path): return path root, ext = os.path.splitext(path) i = 2 while True: candidate = f"{root}-{i}{ext}" if not os.path.exists(candidate): return candidate i += 1 def make_ok_thumbnail(img: Image.Image) -> Image.Image: """ 產生 OK 縮圖: 1) 將原圖縮放到固定 310x207(目前為「拉伸」到固定尺寸) 2) 加 2px 黑框 3) 再加 1px 白框 """ resized = img.resize((THUMB_W, THUMB_H), Image.LANCZOS) bordered = ImageOps.expand(resized, border=2, fill="black") bordered = ImageOps.expand(bordered, border=1, fill="white") return bordered class ImageDownloaderApp(tk.Tk): def __init__(self): super().__init__() self.title(APP_NAME) self.geometry("860x540") self.minsize(780, 500) self.out_dir = desktop_output_dir() self.msg_q: queue.Queue = queue.Queue() self.worker_thread: threading.Thread | None = None self._apply_dark_theme() self._build_ui() self._poll_queue() # -------- Dark Theme -------- def _apply_dark_theme(self): self.C_BG = "#111318" self.C_PANEL = "#151923" self.C_ENTRY = "#1B2230" self.C_TEXT = "#E6E6E6" self.C_MUTED = "#A7B0C0" self.C_ACCENT = "#2D7DFF" self.C_BORDER = "#2A3242" self.configure(bg=self.C_BG) style = ttk.Style(self) try: style.theme_use("clam") except Exception: pass style.configure("TFrame", background=self.C_BG) style.configure("Panel.TFrame", background=self.C_PANEL) style.configure("TLabel", background=self.C_BG, foreground=self.C_TEXT) style.configure("Muted.TLabel", background=self.C_BG, foreground=self.C_MUTED) style.configure("Title.TLabel", background=self.C_BG, foreground=self.C_TEXT, font=("Helvetica", 15, "bold")) style.configure( "TButton", background=self.C_PANEL, foreground=self.C_TEXT, bordercolor=self.C_BORDER, focuscolor="", padding=(12, 8), ) style.map( "TButton", background=[("active", "#1E2432"), ("disabled", "#202636")], foreground=[("disabled", "#7B8599")], ) style.configure( "Accent.TButton", background=self.C_ACCENT, foreground="#FFFFFF", bordercolor=self.C_ACCENT, focuscolor="", padding=(12, 8), ) style.map( "Accent.TButton", background=[("active", "#236BE0"), ("disabled", "#2A3A5F")], foreground=[("disabled", "#B9C7FF")], ) style.configure( "TEntry", fieldbackground=self.C_ENTRY, background=self.C_ENTRY, foreground=self.C_TEXT, insertcolor=self.C_TEXT, bordercolor=self.C_BORDER, lightcolor=self.C_BORDER, darkcolor=self.C_BORDER, padding=(10, 8), ) style.configure( "TCheckbutton", background=self.C_PANEL, foreground=self.C_TEXT, ) style.configure( "TProgressbar", background=self.C_ACCENT, troughcolor="#202636", bordercolor=self.C_BORDER, lightcolor=self.C_BORDER, darkcolor=self.C_BORDER, ) # -------- UI -------- def _build_ui(self): pad = 12 top = ttk.Frame(self) top.pack(fill="x", padx=pad, pady=(pad, 8)) title = ttk.Label(top, text="圖片下載工具", style="Title.TLabel") title.pack(anchor="w") sub = ttk.Label(top, text=f"輸出資料夾:{self.out_dir}", style="Muted.TLabel") sub.pack(anchor="w", pady=(6, 0)) panel = ttk.Frame(self, style="Panel.TFrame") panel.pack(fill="both", expand=True, padx=pad, pady=(0, 10)) form = ttk.Frame(panel, style="Panel.TFrame") form.pack(fill="x", padx=pad, pady=(pad, 8)) ttk.Label(form, text="圖片名稱", background=self.C_PANEL, foreground=self.C_TEXT).grid(row=0, column=0, sticky="w") self.ent_name = ttk.Entry(form) self.ent_name.grid(row=1, column=0, sticky="ew", pady=(6, 0)) ttk.Label(form, text="圖片網址", background=self.C_PANEL, foreground=self.C_TEXT).grid(row=2, column=0, sticky="w", pady=(12, 0)) self.ent_url = ttk.Entry(form) self.ent_url.grid(row=3, column=0, sticky="ew", pady=(6, 0)) form.columnconfigure(0, weight=1) # Checkbox: output OK thumbnail opts = ttk.Frame(panel, style="Panel.TFrame") opts.pack(fill="x", padx=pad, pady=(0, 8)) self.var_make_ok = tk.BooleanVar(value=True) # 預設勾選:輸出 OK 縮圖 self.chk_ok = ttk.Checkbutton( opts, text=f"輸出縮圖 + 2px 黑框 + 1px 白框", variable=self.var_make_ok ) self.chk_ok.pack(side="left") controls = ttk.Frame(panel, style="Panel.TFrame") controls.pack(fill="x", padx=pad, pady=(0, 8)) self.btn_start = ttk.Button(controls, text="下載", style="Accent.TButton", command=self.on_start) self.btn_start.pack(side="left") self.btn_clear = ttk.Button(controls, text="清空", command=self.on_clear) self.btn_clear.pack(side="left", padx=(10, 0)) self.btn_open = ttk.Button(controls, text="開啟資料夾", command=self.on_open_folder) self.btn_open.pack(side="left", padx=(10, 0)) self.progress = ttk.Progressbar(controls, mode="indeterminate") self.progress.pack(side="right", fill="x", expand=True, padx=(10, 0)) bottom = ttk.Frame(panel, style="Panel.TFrame") bottom.pack(fill="both", expand=True, padx=pad, pady=(0, pad)) self.lbl_status = ttk.Label(bottom, text="狀態:等待中", background=self.C_PANEL, foreground=self.C_TEXT) self.lbl_status.pack(anchor="w") self.log = tk.Text( bottom, height=12, wrap="word", bg="#0F1218", fg=self.C_TEXT, insertbackground=self.C_TEXT, relief="solid", bd=1, highlightthickness=1, highlightbackground=self.C_BORDER, highlightcolor=self.C_BORDER, ) self.log.pack(fill="both", expand=True, pady=(8, 0)) self.log.configure(state="disabled") self.ent_name.focus_set() # -------- Helpers -------- def log_line(self, s: str): self.log.configure(state="normal") self.log.insert("end", s + "\n") self.log.see("end") self.log.configure(state="disabled") def set_status(self, s: str): self.lbl_status.configure(text=f"狀態:{s}") def on_clear(self): self.ent_name.delete(0, "end") self.ent_url.delete(0, "end") self.ent_name.focus_set() def on_open_folder(self): try: if os.name == "posix": os.system(f'open "{self.out_dir}"') else: os.startfile(self.out_dir) # type: ignore[attr-defined] except Exception as e: messagebox.showerror("錯誤", f"無法開啟資料夾:{e}") # -------- Main Action -------- def on_start(self): if self.worker_thread and self.worker_thread.is_alive(): messagebox.showinfo("進行中", "目前正在下載中。") return name = self.ent_name.get().strip() url = self.ent_url.get().strip() if not url: messagebox.showwarning("缺少網址", "請先輸入圖片網址。") self.ent_url.focus_set() return # UI busy self.btn_start.configure(state="disabled") self.progress.start(10) self.set_status("下載中…") make_ok = bool(self.var_make_ok.get()) self.worker_thread = threading.Thread(target=self._worker, args=(name, url, make_ok), daemon=True) self.worker_thread.start() def _worker(self, name: str, url: str, make_ok: bool): session = requests.Session() session.headers.update({"User-Agent": UA}) self.msg_q.put(("log", f"開始:{url}")) self.msg_q.put(("status", "下載中…")) try: result = self._process_one(session, name, url, make_ok) self.msg_q.put(("log", f"原圖PNG:{result['png_path']}")) if make_ok: self.msg_q.put(("log", f"縮圖PNG:{result['ok_path']}")) self.msg_q.put(("log", f"原圖尺寸:{result['size'][0]} × {result['size'][1]} px")) if make_ok: self.msg_q.put(("status", "下載完成,已輸出 2 個檔案!")) else: self.msg_q.put(("status", "下載完成,已輸出 1 個檔案!")) self.msg_q.put(("success", None)) except Exception as e: self.msg_q.put(("log", f"失敗:{e}")) self.msg_q.put(("status", f"失敗:{e}")) self.msg_q.put(("done", None)) def _process_one(self, session: requests.Session, name: str, url: str, make_ok: bool) -> dict: r = session.get(url, timeout=TIMEOUT) r.raise_for_status() ct = (r.headers.get("Content-Type") or "").lower() if "text/html" in ct or (r.text and r.text.lstrip().startswith("<")): raise RuntimeError("此網址看起來是網頁,請貼上圖片檔網址(jpg/png/webp 等)。") img = Image.open(BytesIO(r.content)) # Normalize for PNG output if img.mode not in ("RGB", "RGBA"): img = img.convert("RGBA") # Filename: prefer user name, else URL stem title = safe_filename(name) if name.strip() else filename_stem_from_url(url) # Save original PNG png_path = os.path.join(self.out_dir, f"{title}.png") png_path = ensure_unique_path(png_path) img.save(png_path, format="PNG") ok_path = None if make_ok: ok_img = make_ok_thumbnail(img) ok_title = os.path.splitext(os.path.basename(png_path))[0] ok_path = os.path.join(self.out_dir, f"OK-{ok_title}.png") ok_path = ensure_unique_path(ok_path) ok_img.save(ok_path, format="PNG") return { "title": os.path.splitext(os.path.basename(png_path))[0], "size": img.size, "png_path": png_path, "ok_path": ok_path, } # -------- UI Queue Polling -------- def _poll_queue(self): try: while True: kind, payload = self.msg_q.get_nowait() if kind == "log": self.log_line(str(payload)) elif kind == "status": self.set_status(str(payload)) elif kind == "success": # Success: clear both fields self.on_clear() elif kind == "done": self.progress.stop() self.btn_start.configure(state="normal") except queue.Empty: pass self.after(120, self._poll_queue) if __name__ == "__main__": try: app = ImageDownloaderApp() app.mainloop() except Exception as e: messagebox.showerror("啟動失敗", str(e))