| 1 | import tkinter as tk |
| 2 | from tkinter import messagebox |
| 3 | from PIL import Image, ImageTk |
| 4 | import requests |
| 5 | import pdf2image |
| 6 | import io |
| 7 | import csv |
| 8 | import threading |
| 9 | import queue |
| 10 | import time |
| 11 | import os |
| 12 | |
| 13 | # --- Configuration --- |
| 14 | INPUT_FILE = "pdf-urls.txt" |
| 15 | OUTPUT_CSV = "pdf-urls-results.csv" |
| 16 | MAX_DIMENSION = 500 |
| 17 | ANIMATION_DURATION_MS = 2000 # 2 seconds total loop |
| 18 | MAX_PAGES = 10 |
| 19 | PRELOAD_BUFFER = 10 # How many PDFs to process in advance |
| 20 | |
| 21 | |
| 22 | class PDFProcessor(threading.Thread): |
| 23 | """Background thread to download and process PDFs.""" |
| 24 | |
| 25 | def __init__(self, url_queue, result_queue): |
| 26 | super().__init__() |
| 27 | self.url_queue = url_queue |
| 28 | self.result_queue = result_queue |
| 29 | self.daemon = True # Kill thread when main app closes |
| 30 | |
| 31 | def run(self): |
| 32 | while True: |
| 33 | try: |
| 34 | url = self.url_queue.get() |
| 35 | if url is None: |
| 36 | break # Sentinel to stop |
| 37 | |
| 38 | print(f"Processing: {url}") |
| 39 | |
| 40 | # 1. Download PDF |
| 41 | try: |
| 42 | response = requests.get(url, timeout=10) |
| 43 | response.raise_for_status() |
| 44 | pdf_bytes = response.content |
| 45 | except Exception as e: |
| 46 | print(f"Error downloading {url}: {e}") |
| 47 | self.result_queue.put(("error", url, str(e))) |
| 48 | continue |
| 49 | |
| 50 | # 2. Convert to Images (First 10 pages) |
| 51 | try: |
| 52 | # poppler_path=r'C:\Program Files\poppler-xx\bin' # UNCOMMENT AND SET IF WINDOWS PATH ISSUES |
| 53 | images = pdf2image.convert_from_bytes( |
| 54 | pdf_bytes, first_page=1, last_page=MAX_PAGES, fmt="jpeg" |
| 55 | ) |
| 56 | except Exception as e: |
| 57 | print(f"Error converting PDF {url}: {e}") |
| 58 | self.result_queue.put(("error", url, "PDF Conversion Failed")) |
| 59 | continue |
| 60 | |
| 61 | # 3. Resize Images (Thumbnailing) |
| 62 | processed_frames = [] |
| 63 | for img in images: |
| 64 | img.thumbnail((MAX_DIMENSION, MAX_DIMENSION)) |
| 65 | processed_frames.append(img) |
| 66 | |
| 67 | # 4. Put ready data into queue |
| 68 | self.result_queue.put(("success", url, processed_frames)) |
| 69 | |
| 70 | except Exception as e: |
| 71 | print(f"Unexpected error: {e}") |
| 72 | |
| 73 | |
| 74 | class TriageApp: |
| 75 | def __init__(self, root): |
| 76 | self.root = root |
| 77 | self.root.title("PDF Triage Tool") |
| 78 | self.root.geometry("600x700") |
| 79 | |
| 80 | # Data Structures |
| 81 | self.url_queue = queue.Queue() |
| 82 | self.ready_queue = queue.Queue(maxsize=PRELOAD_BUFFER) |
| 83 | |
| 84 | self.current_url = None |
| 85 | self.current_frames = [] |
| 86 | self.animation_running = False |
| 87 | self.frame_index = 0 |
| 88 | |
| 89 | # Load URLs |
| 90 | self.load_urls() |
| 91 | |
| 92 | # GUI Components |
| 93 | self.setup_ui() |
| 94 | |
| 95 | # Start Worker Thread |
| 96 | self.worker = PDFProcessor(self.url_queue, self.ready_queue) |
| 97 | self.worker.start() |
| 98 | |
| 99 | # Start Polling for Content |
| 100 | self.check_queue() |
| 101 | |
| 102 | def load_urls(self): |
| 103 | try: |
| 104 | with open(INPUT_FILE, "r") as f: |
| 105 | urls = [line.strip() for line in f if line.strip()] |
| 106 | |
| 107 | # Check which are already done |
| 108 | done_urls = set() |
| 109 | if os.path.exists(OUTPUT_CSV): |
| 110 | with open(OUTPUT_CSV, "r") as f: |
| 111 | reader = csv.reader(f) |
| 112 | for row in reader: |
| 113 | if row: |
| 114 | done_urls.add(row[0]) |
| 115 | |
| 116 | count = 0 |
| 117 | for u in urls: |
| 118 | if u not in done_urls: |
| 119 | self.url_queue.put(u) |
| 120 | count += 1 |
| 121 | |
| 122 | print(f"Queued {count} URLs for processing.") |
| 123 | |
| 124 | except FileNotFoundError: |
| 125 | messagebox.showerror("Error", f"Could not find {INPUT_FILE}") |
| 126 | |
| 127 | def setup_ui(self): |
| 128 | # 1. Info Frame |
| 129 | self.info_frame = tk.Frame(self.root, pady=10) |
| 130 | self.info_frame.pack(fill=tk.X) |
| 131 | |
| 132 | self.lbl_url = tk.Label( |
| 133 | self.info_frame, |
| 134 | text="Waiting for worker...", |
| 135 | wraplength=550, |
| 136 | font=("Arial", 10), |
| 137 | ) |
| 138 | self.lbl_url.pack() |
| 139 | |
| 140 | # 2. Image Display Area |
| 141 | self.img_container = tk.Frame(self.root, width=500, height=500, bg="#e0e0e0") |
| 142 | self.img_container.pack(pady=10) |
| 143 | self.img_container.pack_propagate(False) # Don't shrink |
| 144 | |
| 145 | self.lbl_image = tk.Label(self.img_container, bg="#e0e0e0") |
| 146 | self.lbl_image.pack(expand=True) |
| 147 | |
| 148 | # 3. Controls Frame |
| 149 | self.btn_frame = tk.Frame(self.root, pady=20) |
| 150 | self.btn_frame.pack(fill=tk.X) |
| 151 | self.btn_frame.columnconfigure(0, weight=1) |
| 152 | self.btn_frame.columnconfigure(1, weight=1) |
| 153 | self.btn_frame.columnconfigure(2, weight=1) |
| 154 | |
| 155 | # Buttons |
| 156 | self.btn_del = tk.Button( |
| 157 | self.btn_frame, |
| 158 | text="Delete (D)", |
| 159 | bg="#ffcccc", |
| 160 | fg="red", |
| 161 | command=lambda: self.vote("Delete"), |
| 162 | height=2, |
| 163 | ) |
| 164 | self.btn_del.grid(row=0, column=0, sticky="ew", padx=5) |
| 165 | |
| 166 | self.btn_web = tk.Button( |
| 167 | self.btn_frame, |
| 168 | text="Webize (J)", |
| 169 | bg="#ccffcc", |
| 170 | fg="green", |
| 171 | command=lambda: self.vote("Webize"), |
| 172 | height=2, |
| 173 | ) |
| 174 | self.btn_web.grid(row=0, column=1, sticky="ew", padx=5) |
| 175 | |
| 176 | self.btn_fix = tk.Button( |
| 177 | self.btn_frame, |
| 178 | text="Fix (F)", |
| 179 | bg="#ffeebb", |
| 180 | fg="#cc6600", |
| 181 | command=lambda: self.vote("Fix"), |
| 182 | height=2, |
| 183 | ) |
| 184 | self.btn_fix.grid(row=0, column=2, sticky="ew", padx=5) |
| 185 | |
| 186 | # Keyboard Shortcuts |
| 187 | self.root.bind("<d>", lambda e: self.vote("Delete")) |
| 188 | self.root.bind("<j>", lambda e: self.vote("Webize")) |
| 189 | self.root.bind("<f>", lambda e: self.vote("Fix")) |
| 190 | |
| 191 | def check_queue(self): |
| 192 | """Polls the ready queue to see if the next PDF is ready.""" |
| 193 | if self.current_url is None: |
| 194 | try: |
| 195 | # Non-blocking get |
| 196 | status, url, data = self.ready_queue.get_nowait() |
| 197 | |
| 198 | if status == "error": |
| 199 | # Skip errors, log them, maybe save to CSV as 'Error', and recurse |
| 200 | self.log_to_csv(url, f"Error: {data}") |
| 201 | self.check_queue() |
| 202 | else: |
| 203 | self.load_new_content(url, data) |
| 204 | except queue.Empty: |
| 205 | if self.url_queue.empty() and self.ready_queue.empty(): |
| 206 | self.lbl_url.config(text="All Done! No more URLs.") |
| 207 | self.lbl_image.config(image="", text="Done") |
| 208 | else: |
| 209 | self.lbl_url.config(text="Loading next PDF...") |
| 210 | self.root.after(500, self.check_queue) |
| 211 | else: |
| 212 | # We have content, no need to check queue |
| 213 | pass |
| 214 | |
| 215 | def load_new_content(self, url, frames): |
| 216 | self.current_url = url |
| 217 | self.current_frames = frames |
| 218 | self.lbl_url.config(text=url) |
| 219 | self.frame_index = 0 |
| 220 | |
| 221 | if frames: |
| 222 | # Calculate speed: 2000ms / number of frames |
| 223 | self.delay = int(ANIMATION_DURATION_MS / len(frames)) |
| 224 | self.animation_running = True |
| 225 | self.animate() |
| 226 | else: |
| 227 | self.lbl_image.config(text="Empty PDF or No Images") |
| 228 | |
| 229 | def animate(self): |
| 230 | if not self.animation_running or not self.current_frames: |
| 231 | return |
| 232 | |
| 233 | # Prepare image for Tkinter |
| 234 | pil_img = self.current_frames[self.frame_index] |
| 235 | tk_img = ImageTk.PhotoImage(pil_img) |
| 236 | |
| 237 | # Update Label |
| 238 | self.lbl_image.configure(image=tk_img) |
| 239 | self.lbl_image.image = tk_img # Keep reference to prevent GC |
| 240 | |
| 241 | # Increment index |
| 242 | self.frame_index = (self.frame_index + 1) % len(self.current_frames) |
| 243 | |
| 244 | # Schedule next frame |
| 245 | self.root.after(self.delay, self.animate) |
| 246 | |
| 247 | def vote(self, choice): |
| 248 | if not self.current_url: |
| 249 | return |
| 250 | |
| 251 | # Save Result |
| 252 | self.log_to_csv(self.current_url, choice) |
| 253 | |
| 254 | # Reset State |
| 255 | self.animation_running = False |
| 256 | self.current_url = None |
| 257 | self.current_frames = [] |
| 258 | self.lbl_image.config(image="") |
| 259 | |
| 260 | # Get next |
| 261 | self.check_queue() |
| 262 | |
| 263 | def log_to_csv(self, url, choice): |
| 264 | try: |
| 265 | with open(OUTPUT_CSV, "a", newline="") as f: |
| 266 | writer = csv.writer(f) |
| 267 | writer.writerow([url, choice]) |
| 268 | except Exception as e: |
| 269 | messagebox.showerror("Error", f"Could not save to CSV: {e}") |
| 270 | |
| 271 | |
| 272 | if __name__ == "__main__": |
| 273 | root = tk.Tk() |
| 274 | app = TriageApp(root) |
| 275 | root.mainloop() |
| 276 |