#!/usr/bin/env python3 """ PageSnap — macOS Auto Screenshot + OCR PDF Exporter ==================================================== Requirements: pip install pyobjc-framework-Quartz pyobjc-framework-Cocoa pillow pytesseract pypdf brew install tesseract ghostscript """ import tkinter as tk from tkinter import ttk, filedialog, messagebox import threading, subprocess, os, io, glob, shutil, time, sys from datetime import datetime from pathlib import Path # ── Resolve binary paths ────────────────────────────────────────────────────── def _resolve_paths(): conf_candidates = [ os.path.normpath(os.path.join( os.path.dirname(sys.executable), '..', 'Resources', 'pagesnap_paths.conf')), os.path.join(os.path.dirname(os.path.abspath(__file__)), 'pagesnap_paths.conf'), ] paths = {} for p in conf_candidates: if os.path.exists(p): with open(p) as f: for line in f: if '=' in line: k, v = line.strip().split('=', 1) paths[k.strip()] = v.strip() break def _find(name): if name in paths: return paths[name] for prefix in ('/opt/homebrew/bin', '/usr/local/bin'): p = os.path.join(prefix, name) if os.path.isfile(p): return p return name return {'tesseract': _find('tesseract'), 'gs': _find('gs')} BINS = _resolve_paths() # ── Screenshot via Quartz + Cocoa ───────────────────────────────────────────── try: import Quartz.CoreGraphics as CG from Cocoa import NSBitmapImageRep, NSPNGFileType QUARTZ_OK = True except Exception: QUARTZ_OK = False def get_mouse_pos(): """Return current mouse position as (x, y) integers.""" if QUARTZ_OK: loc = CG.CGEventGetLocation(CG.CGEventCreate(None)) return int(loc.x), int(loc.y) return 0, 0 def take_screenshot(region=None): """ Capture the screen (or a region) using Quartz CGWindowListCreateImage and convert to a PIL Image via NSBitmapImageRep → PNG bytes. No raw pointer arithmetic, no pyautogui, no permission dialogs. """ if not QUARTZ_OK: raise RuntimeError("Quartz/Cocoa not available") if region: x, y, w, h = int(region[0]), int(region[1]), int(region[2]), int(region[3]) bounds = CG.CGRect((x, y), (w, h)) else: bounds = CG.CGDisplayBounds(CG.CGMainDisplayID()) img_ref = CG.CGWindowListCreateImage( bounds, CG.kCGWindowListOptionOnScreenOnly, CG.kCGNullWindowID, CG.kCGWindowImageDefault ) if img_ref is None: raise RuntimeError( "Screenshot returned None — ensure Screen Recording is enabled in " "System Settings → Privacy & Security → Screen Recording") rep = NSBitmapImageRep.alloc().initWithCGImage_(img_ref) ns_data = rep.representationUsingType_properties_(NSPNGFileType, None) png_bytes = bytes(ns_data) from PIL import Image return Image.open(io.BytesIO(png_bytes)).convert("RGB") # ── Key press via osascript (no Accessibility permission needed) ────────────── KEY_CODES = { "pagedown": "key code 121", "pageup": "key code 116", "down": "key code 125", "up": "key code 126", "left": "key code 123", "right": "key code 124", "space": "key code 49", "return": "key code 36", "tab": "key code 48", "period": "key code 47", "n": 'keystroke "n"', } def press_key(key): """ Send a keystroke to the frontmost application using AppleScript via System Events. Requires Accessibility permission for whatever app is running this script (Terminal/iTerm/Python launcher). """ cmd = KEY_CODES.get(key, f'keystroke "{key}"') script = f'tell application "System Events" to {cmd}' r = subprocess.run(["osascript", "-e", script], capture_output=True, text=True, timeout=4) return r.returncode == 0, r.stderr.strip() # ── Optional imports ────────────────────────────────────────────────────────── try: from PIL import Image, ImageTk, ImageEnhance except ImportError: Image = ImageTk = ImageEnhance = None try: import pytesseract pytesseract.pytesseract.tesseract_cmd = BINS['tesseract'] except ImportError: pytesseract = None try: from pypdf import PdfWriter, PdfReader except ImportError: PdfWriter = PdfReader = None # ── Dependency checks ───────────────────────────────────────────────────────── def check_deps(): m = [] if not QUARTZ_OK: m.append("pyobjc-framework-Quartz + pyobjc-framework-Cocoa") if not Image: m.append("pillow") if not pytesseract: m.append("pytesseract") if not PdfWriter: m.append("pypdf") return m def has_tesseract(): t = BINS['tesseract'] return os.path.isfile(t) or bool(shutil.which(t)) def has_ghostscript(): g = BINS['gs'] return os.path.isfile(g) or bool(shutil.which(g)) # ── Colour palette ──────────────────────────────────────────────────────────── BG = "#1c1c1e" PANEL = "#2c2c2e" BORDER = "#3a3a3c" ACCENT = "#0a84ff" ACCENT2 = "#bf5af2" SUCCESS = "#30d158" WARNING = "#ffd60a" DANGER = "#ff453a" TEXT = "#f2f2f7" MUTED = "#98989f" BTN_FG = "#ffffff" MONO = ("Menlo", 10) SMALL = ("Helvetica Neue", 11) BIG = ("Helvetica Neue", 18, "bold") # ── Scrollable frame ────────────────────────────────────────────────────────── class ScrollFrame(tk.Frame): def __init__(self, parent, **kw): super().__init__(parent, bg=BG, **kw) self.canvas = tk.Canvas(self, bg=BG, highlightthickness=0, bd=0) vsb = tk.Scrollbar(self, orient="vertical", command=self.canvas.yview) self.canvas.configure(yscrollcommand=vsb.set) vsb.pack(side="right", fill="y") self.canvas.pack(side="left", fill="both", expand=True) self.inner = tk.Frame(self.canvas, bg=BG) self._win = self.canvas.create_window((0, 0), window=self.inner, anchor="nw") self.inner.bind("", lambda _: self.canvas.configure(scrollregion=self.canvas.bbox("all"))) self.canvas.bind("", lambda e: self.canvas.itemconfig(self._win, width=e.width)) self.canvas.bind_all("", self._scroll) def _scroll(self, e): self.canvas.yview_scroll(int(-1 * (e.delta / 120)), "units") # ── Main App ────────────────────────────────────────────────────────────────── class PageSnap(tk.Tk): def __init__(self): super().__init__() self.title("PageSnap") self.configure(bg=BG) self.resizable(True, True) sw, sh = self.winfo_screenwidth(), self.winfo_screenheight() ww, wh = 700, min(sh - 80, 860) self.geometry(f"{ww}x{wh}+{(sw-ww)//2}+40") self.minsize(600, 500) self.region = None self.stop_event = threading.Event() self.page_count = 0 self._drag_idx = None self.output_dir = str(Path.home() / "Desktop" / "pagesnap_captures") self._pdf_base = str(Path.home() / "Desktop") self._build_ui() self._check_env() # Press Esc anywhere in the window to stop capture self.bind_all("", lambda e: self.stop_event.set()) # ── UI ──────────────────────────────────────────────────────────────────── def _build_ui(self): # Top status bar top = tk.Frame(self, bg="#111113", pady=10) top.pack(fill="x") tk.Label(top, text="PageSnap", font=BIG, bg="#111113", fg=TEXT).pack(side="left", padx=20) self._sdot = tk.Label(top, text="●", font=("Helvetica", 14), bg="#111113", fg=MUTED) self._slbl = tk.Label(top, text="Ready", font=SMALL, bg="#111113", fg=MUTED) self._sdot.pack(side="right", padx=(0, 16)) self._slbl.pack(side="right") sf = ScrollFrame(self); sf.pack(fill="both", expand=True) body = sf.inner # ── Section 1: Region ───────────────────────────────────────────────── self._sec(body, "1 Capture Region") rc = self._card(body) self._region_lbl = tk.Label(rc, text="No region selected", font=MONO, bg=PANEL, fg=MUTED, anchor="w") self._region_lbl.pack(fill="x", padx=14, pady=(12, 6)) r = tk.Frame(rc, bg=PANEL); r.pack(fill="x", padx=14, pady=(0, 12)) self._btn(r, "Select Region", self._pick_region, ACCENT).pack(side="left", padx=(0, 8)) self._btn(r, "Preview", self._preview, BORDER).pack(side="left") # ── Section 2: Capture ──────────────────────────────────────────────── self._sec(body, "2 Capture Settings") cc = self._card(body) g = tk.Frame(cc, bg=PANEL); g.pack(fill="x", padx=14, pady=12) self._lbl(g, 0, "Page delay (s)") self.delay_var = tk.DoubleVar(value=1.5) self._spin(g, 0, self.delay_var, 0.5, 10.0, 0.5) self._lbl(g, 1, "Max pages (0 = ∞)") self.maxp_var = tk.IntVar(value=0) self._spin(g, 1, self.maxp_var, 0, 9999, 1) self._lbl(g, 2, "Page advance key") self.key_var = tk.StringVar(value="pagedown") KEY_LABELS = { "pagedown": "Page Down", "pageup": "Page Up", "down": "↓ Arrow", "up": "↑ Arrow", "left": "← Arrow", "right": "→ Arrow", "space": "Space", "return": "Enter", "tab": "Tab", "period": ". (Period)", "n": "N key", } self._key_map = {v: k for k, v in KEY_LABELS.items()} cb = ttk.Combobox(g, values=list(KEY_LABELS.values()), width=14, font=MONO, state="readonly") cb.set("Page Down") cb.grid(row=2, column=1, sticky="w", padx=10, pady=3) cb.bind("<>", lambda e: self.key_var.set(self._key_map[cb.get()])) self._lbl(g, 3, "Save images to") dr = tk.Frame(g, bg=PANEL); dr.grid(row=3, column=1, sticky="w", padx=10, pady=3) self.dir_var = tk.StringVar(value=self.output_dir) tk.Entry(dr, textvariable=self.dir_var, width=26, font=MONO, bg=BORDER, fg=TEXT, insertbackground=TEXT, relief="flat").pack(side="left") self._btn(dr, "…", self._browse_dir, BORDER, px=6).pack(side="left", padx=(5, 0)) br = tk.Frame(cc, bg=PANEL); br.pack(fill="x", padx=14, pady=(0, 12)) self._start_btn = self._btn(br, "▶ Start Capture", self._start_capture, ACCENT) self._start_btn.pack(side="left", padx=(0, 8)) self._stop_btn = self._btn(br, "■ Stop", self._stop_capture, DANGER) self._stop_btn.pack(side="left") self._stop_btn.set_enabled(False) # ── Section 3: Export ───────────────────────────────────────────────── self._sec(body, "3 Export to PDF") ef = self._card(body) eg = tk.Frame(ef, bg=PANEL); eg.pack(fill="x", padx=14, pady=12) self._lbl(eg, 0, "OCR language") self.lang_var = tk.StringVar(value="eng") tk.Entry(eg, textvariable=self.lang_var, width=8, font=MONO, bg=BORDER, fg=TEXT, insertbackground=TEXT, relief="flat").grid(row=0, column=1, sticky="w", padx=10, pady=3) tk.Label(eg, text="eng · spa · fra · deu…", font=("Menlo", 9), bg=PANEL, fg=MUTED).grid(row=0, column=2, sticky="w") self._lbl(eg, 1, "Pre-OCR optimise") self.pre_var = tk.StringVar(value="balanced") ttk.Combobox(eg, textvariable=self.pre_var, values=["none", "balanced", "aggressive"], width=11, font=MONO, state="readonly" ).grid(row=1, column=1, sticky="w", padx=10, pady=3) self._pre_note = tk.Label(eg, font=("Menlo", 9), bg=PANEL, fg=MUTED) self._pre_note.grid(row=1, column=2, sticky="w") self.pre_var.trace_add("write", lambda *_: self._upd_pre()) self._upd_pre() self._lbl(eg, 2, "PDF compression") self.comp_var = tk.StringVar(value="ebook") ttk.Combobox(eg, textvariable=self.comp_var, values=["none", "screen", "ebook", "printer"], width=11, font=MONO, state="readonly" ).grid(row=2, column=1, sticky="w", padx=10, pady=3) self._comp_note = tk.Label(eg, font=("Menlo", 9), bg=PANEL, fg=MUTED) self._comp_note.grid(row=2, column=2, sticky="w") self.comp_var.trace_add("write", lambda *_: self._upd_comp()) self._upd_comp() self._gs_lbl = tk.Label(eg, font=("Menlo", 9), bg=PANEL) self._gs_lbl.grid(row=3, column=0, columnspan=3, sticky="w", pady=(4, 0)) self._refresh_gs() self._lbl(eg, 4, "Output PDF") pr = tk.Frame(eg, bg=PANEL) pr.grid(row=4, column=1, columnspan=2, sticky="w", padx=10, pady=(10, 3)) self.pdf_var = tk.StringVar(value="") tk.Entry(pr, textvariable=self.pdf_var, width=32, font=MONO, bg=BORDER, fg=TEXT, insertbackground=TEXT, relief="flat").pack(side="left") self._btn(pr, "…", self._browse_pdf, BORDER, px=6).pack(side="left", padx=(5, 0)) # File list tk.Label(ef, text="Input files (drag to reorder)", font=("Menlo", 9), bg=PANEL, fg=MUTED).pack(anchor="w", padx=14, pady=(8, 2)) lf = tk.Frame(ef, bg=PANEL); lf.pack(fill="x", padx=14) sb = tk.Scrollbar(lf, bg=BORDER, troughcolor=BG, relief="flat") sb.pack(side="right", fill="y") self._lb = tk.Listbox(lf, height=5, selectmode="extended", bg="#111113", fg=TEXT, font=MONO, relief="flat", bd=0, highlightthickness=0, activestyle="none", selectbackground=ACCENT, selectforeground=BTN_FG, yscrollcommand=sb.set) self._lb.pack(side="left", fill="both", expand=True) sb.config(command=self._lb.yview) self._lb.bind("", lambda e: setattr(self, '_drag_idx', self._lb.nearest(e.y))) self._lb.bind("", self._lb_drag) self._lb.bind("", lambda e: setattr(self, '_drag_idx', None)) fbr = tk.Frame(ef, bg=PANEL); fbr.pack(fill="x", padx=14, pady=(6, 4)) for lbl, cmd in [("+ Files", self._add_files), ("+ Folder", self._add_folder), ("↑", self._move_up), ("↓", self._move_down), ("✕ Remove", self._remove_sel), ("Clear", self._clear_files), ("Auto-fill", self._autofill)]: self._btn(fbr, lbl, cmd, BORDER, px=8, py=4).pack(side="left", padx=(0, 5)) self._count_lbl = tk.Label(ef, text="No files queued", font=("Menlo", 9), bg=PANEL, fg=MUTED) self._count_lbl.pack(anchor="w", padx=14, pady=(2, 10)) exr = tk.Frame(ef, bg=PANEL); exr.pack(fill="x", padx=14, pady=(0, 14)) self._btn(exr, "⬇ Export PDF", self._export_pdf, SUCCESS, fg="#000000").pack(side="left") # Log self._sec(body, "Log") lc = self._card(body) self._log_box = tk.Text(lc, height=7, bg="#111113", fg=TEXT, font=MONO, relief="flat", wrap="word", insertbackground=TEXT, state="disabled", padx=10, pady=8) self._log_box.pack(fill="both", padx=2, pady=2) for tag, col in [("ok", SUCCESS), ("warn", WARNING), ("err", DANGER), ("info", ACCENT)]: self._log_box.tag_config(tag, foreground=col) tk.Frame(body, bg=BG, height=20).pack() # ── Widget helpers ──────────────────────────────────────────────────────── def _sec(self, p, t): f = tk.Frame(p, bg=BG); f.pack(fill="x", padx=24, pady=(16, 4)) tk.Label(f, text=t.upper(), font=("Menlo", 9, "bold"), bg=BG, fg=ACCENT).pack(side="left") tk.Frame(f, bg=BORDER, height=1).pack(side="left", fill="x", expand=True, padx=(8, 0), pady=5) def _card(self, p): o = tk.Frame(p, bg=BORDER, padx=1, pady=1); o.pack(fill="x", padx=24, pady=2) i = tk.Frame(o, bg=PANEL); i.pack(fill="both") return i def _btn(self, parent, text, cmd, bg=BORDER, fg=BTN_FG, px=12, py=6): """Label-based button — respects custom colours on macOS Aqua theme.""" lbl = tk.Label(parent, text=text, bg=bg, fg=fg, font=SMALL, padx=px, pady=py, cursor="hand2", relief="flat") lbl._enabled = True lbl._bg, lbl._fg = bg, fg lbl.bind("", lambda e: cmd() if lbl._enabled else None) lbl.bind("", lambda e: lbl.configure(bg=self._lighten(bg)) if lbl._enabled else None) lbl.bind("", lambda e: lbl.configure(bg=lbl._bg)) def set_enabled(v): lbl._enabled = v lbl.configure(fg=lbl._fg if v else MUTED, cursor="hand2" if v else "arrow") lbl.set_enabled = set_enabled return lbl @staticmethod def _lighten(c): try: r, g, b = int(c[1:3], 16), int(c[3:5], 16), int(c[5:7], 16) return f"#{min(255,r+25):02x}{min(255,g+25):02x}{min(255,b+25):02x}" except: return c def _lbl(self, g, row, text): tk.Label(g, text=text, font=SMALL, bg=PANEL, fg=MUTED, anchor="w").grid(row=row, column=0, sticky="w", pady=3, padx=(0, 4)) def _spin(self, g, row, var, lo, hi, inc): tk.Spinbox(g, from_=lo, to=hi, increment=inc, textvariable=var, width=7, font=MONO, bg=BORDER, fg=TEXT, insertbackground=TEXT, relief="flat", buttonbackground=BORDER ).grid(row=row, column=1, sticky="w", padx=10, pady=3) def _upd_pre(self): self._pre_note.config(text={ "none": "images sent as-is", "balanced": "resize → 1800px, sharpen ← recommended", "aggressive": "resize → 1800px, grayscale, sharpen", }.get(self.pre_var.get(), "")) def _upd_comp(self): self._comp_note.config(text={ "none": "no compression", "screen": "~72 dpi — smallest", "ebook": "~150 dpi — good balance ✓", "printer": "~300 dpi — high quality", }.get(self.comp_var.get(), "")) def _refresh_gs(self): ok = has_ghostscript() self._gs_lbl.config( fg=SUCCESS if ok else WARNING, text="✓ Ghostscript ready" if ok else "⚠ Ghostscript missing — brew install ghostscript") def _log(self, msg, tag=""): ts = datetime.now().strftime("%H:%M:%S") self._log_box.config(state="normal") self._log_box.insert("end", f"[{ts}] {msg}\n", tag) self._log_box.see("end") self._log_box.config(state="disabled") def _ui_log(self, msg, tag=""): self.after(0, lambda: self._log(msg, tag)) def _set_status(self, text, color=MUTED): self._slbl.config(text=text, fg=color) self._sdot.config(fg=color) def _ui_status(self, text, color=MUTED): self.after(0, lambda: self._set_status(text, color)) def _browse_dir(self): d = filedialog.askdirectory(initialdir=self.dir_var.get()) if d: self.dir_var.set(d) def _browse_pdf(self): p = filedialog.asksaveasfilename( defaultextension=".pdf", filetypes=[("PDF", "*.pdf")], initialdir=self._pdf_base, initialfile=f"pagesnap_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf") if p: self.pdf_var.set(p) # ── Environment check ───────────────────────────────────────────────────── def _check_env(self): m = check_deps() if m: self._log(f"Missing packages: {', '.join(m)}", "err") self._log("pip install " + " ".join(m), "warn") else: self._log("All dependencies ready.", "ok") self._log( f"Tesseract: {BINS['tesseract']}" if has_tesseract() else "Tesseract missing — brew install tesseract", "ok" if has_tesseract() else "warn") # ── Region picker ───────────────────────────────────────────────────────── def _pick_region(self): corners = [] def step(n): label = ["TOP-LEFT", "BOTTOM-RIGHT"][n] win = tk.Toplevel(self) win.title(f"Step {n+1} of 2") win.configure(bg=PANEL) win.resizable(False, False) win.attributes("-topmost", True) sw, sh = win.winfo_screenwidth(), win.winfo_screenheight() win.geometry(f"420x170+{(sw-420)//2}+{(sh-170)//2}") hint = f"Top-left locked at {corners[0]}\n\n" if n == 1 and corners else "" tk.Label(win, text=f"{hint}Move your mouse to the {label} corner\n" f"of the area you want to capture,\n" f"then press Enter to confirm.", font=SMALL, bg=PANEL, fg=TEXT, justify="center", pady=18).pack(fill="x") br = tk.Frame(win, bg=PANEL); br.pack(pady=(0, 14)) ok_lbl = tk.Label(br, text="Confirm [Enter]", bg=ACCENT, fg=BTN_FG, font=SMALL, padx=14, pady=6, cursor="hand2") ok_lbl.bind("", lambda e: confirm()) ok_lbl.pack(side="left", padx=(0, 8)) ca_lbl = tk.Label(br, text="Cancel [Esc]", bg=BORDER, fg=TEXT, font=SMALL, padx=14, pady=6, cursor="hand2") ca_lbl.bind("", lambda e: win.destroy()) ca_lbl.pack(side="left") def confirm(): x, y = get_mouse_pos() corners.append((x, y)) win.destroy() if len(corners) == 1: self.after(50, lambda: step(1)) else: self._finalise_region(corners) win.bind("", lambda e: confirm()) win.bind("", lambda e: win.destroy()) win.protocol("WM_DELETE_WINDOW", win.destroy) win.focus_force() step(0) def _finalise_region(self, corners): (x1, y1), (x2, y2) = corners left = int(min(x1, x2)) top = int(min(y1, y2)) width = int(abs(x2 - x1)) height = int(abs(y2 - y1)) if width < 10 or height < 10: messagebox.showwarning("PageSnap", "Region too small — please try again.") return self.region = (left, top, width, height) self._region_lbl.config( text=f"x={left} y={top} w={width} h={height}", fg=TEXT) self._log(f"Region set: {self.region}", "ok") def _preview(self): if not self.region: messagebox.showinfo("PageSnap", "Select a region first."); return try: PreviewWindow(self, take_screenshot(region=self.region)) except Exception as e: messagebox.showerror("PageSnap", f"Screenshot failed:\n{e}") # ── Capture ─────────────────────────────────────────────────────────────── def _start_capture(self): if not self.region: messagebox.showwarning("PageSnap", "Select a region first."); return if not QUARTZ_OK: messagebox.showerror("PageSnap", "Quartz/Cocoa not available.\n" "Run: pip install pyobjc-framework-Quartz pyobjc-framework-Cocoa") return os.makedirs(self.dir_var.get(), exist_ok=True) self.stop_event.clear() self.page_count = 0 self._start_btn.set_enabled(False) self._stop_btn.set_enabled(True) self._set_status("Capturing…", ACCENT) self._log(f"Capture starting → {self.dir_var.get()}", "info") threading.Thread(target=self._cap_loop, daemon=True).start() def _cap_loop(self): out = self.dir_var.get() delay = self.delay_var.get() maxp = self.maxp_var.get() key = self.key_var.get() ts = datetime.now().strftime("%Y%m%d_%H%M%S") for i in range(3, 0, -1): self._ui_status(f"Starting in {i}… switch to your browser!", WARNING) time.sleep(1) if self.stop_event.is_set(): self._finish_cap() return page = 1 while not self.stop_event.is_set(): if maxp and page > maxp: self._ui_log(f"Reached max {maxp} pages.", "ok") break try: shot = take_screenshot(region=self.region) fname = f"{ts}_page_{page:04d}.png" shot.save(os.path.join(out, fname), optimize=True) self.page_count = page self._ui_log(f"Page {page:>4} → {fname}", "ok") self._ui_status(f"Captured {page} page(s)…", ACCENT) except Exception as e: self._ui_log(f"Screenshot error on page {page}: {e}", "err") # Continue — don't abort the whole capture for one bad frame page += 1 time.sleep(delay) continue try: ok, err = press_key(key) if not ok and err: self._ui_log(f"Key press warning: {err}", "warn") except Exception as e: self._ui_log(f"Key press error: {e}", "warn") time.sleep(delay) page += 1 self._finish_cap() def _finish_cap(self): def _ui(): self._start_btn.set_enabled(True) self._stop_btn.set_enabled(False) self._set_status(f"Done — {self.page_count} pages captured", SUCCESS) self._log(f"Capture complete. {self.page_count} pages saved.", "ok") self.after(0, _ui) def _stop_capture(self): self.stop_event.set() # ── File list ───────────────────────────────────────────────────────────── def _upd_count(self): n = self._lb.size() self._count_lbl.config( text=f"{n} file(s) queued" if n else "No files queued", fg=TEXT if n else MUTED) def _add_files(self): files = filedialog.askopenfilenames( title="Select images", filetypes=[("Images", "*.png *.jpg *.jpeg *.tiff *.bmp"), ("All", "*.*")]) ex = set(self._lb.get(0, "end")) for f in files: if f not in ex: self._lb.insert("end", f) self._upd_count() def _add_folder(self): folder = filedialog.askdirectory(title="Select folder") if not folder: return found = sorted(set(f for ext in ("*.png", "*.jpg", "*.jpeg", "*.tiff", "*.bmp") for f in glob.glob(os.path.join(folder, ext)))) ex = set(self._lb.get(0, "end")) added = 0 for f in found: if f not in ex: self._lb.insert("end", f); added += 1 self._upd_count() self._log(f"Added {added} file(s) from {os.path.basename(folder)}", "ok") def _autofill(self): self._lb.delete(0, "end") found = sorted(set(f for ext in ("*.png", "*.jpg", "*.jpeg") for f in glob.glob(os.path.join(self.dir_var.get(), ext)))) for f in found: self._lb.insert("end", f) self._upd_count() self._log(f"Auto-filled {self._lb.size()} file(s)", "ok") def _move_up(self): sel = list(self._lb.curselection()) if not sel or sel[0] == 0: return for i in sel: v = self._lb.get(i); self._lb.delete(i) self._lb.insert(i - 1, v); self._lb.selection_set(i - 1) def _move_down(self): sel = list(self._lb.curselection()) if not sel or sel[-1] == self._lb.size() - 1: return for i in reversed(sel): v = self._lb.get(i); self._lb.delete(i) self._lb.insert(i + 1, v); self._lb.selection_set(i + 1) def _remove_sel(self): for i in reversed(self._lb.curselection()): self._lb.delete(i) self._upd_count() def _clear_files(self): self._lb.delete(0, "end"); self._upd_count() def _lb_drag(self, e): new, old = self._lb.nearest(e.y), self._drag_idx if new != old and old is not None: v = self._lb.get(old); self._lb.delete(old) self._lb.insert(new, v) self._lb.selection_clear(0, "end") self._lb.selection_set(new) self._drag_idx = new # ── Pre-OCR optimisation ────────────────────────────────────────────────── def _preprocess(self, img, mode): if img.mode in ("RGBA", "LA", "P"): img = img.convert("RGB") if mode == "none": return img if img.width > 1800: img = img.resize((1800, int(img.height * 1800 / img.width)), Image.LANCZOS) img = ImageEnhance.Sharpness(img).enhance(1.4) if mode == "aggressive": img = img.convert("L").convert("RGB") return img # ── PDF Export ──────────────────────────────────────────────────────────── def _export_pdf(self): pngs = list(self._lb.get(0, "end")) if not pngs: pngs = sorted(glob.glob(os.path.join(self.dir_var.get(), "*.png"))) if not pngs: messagebox.showwarning("PageSnap", "No files queued. Add files or use Auto-fill.") return if not has_tesseract(): messagebox.showerror("PageSnap", f"Tesseract not found.\nExpected at: {BINS['tesseract']}\n" "Run: brew install tesseract") return if not PdfWriter: messagebox.showerror("PageSnap", "pypdf not installed.\nRun: pip install pypdf") return out_pdf = self.pdf_var.get().strip() if not out_pdf: out_pdf = os.path.join(self._pdf_base, f"pagesnap_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf") self.pdf_var.set(out_pdf) if os.path.exists(out_pdf): if not messagebox.askyesno("PageSnap", f"File already exists:\n{out_pdf}\n\nOverwrite?"): return compress = self.comp_var.get() lang = self.lang_var.get().strip() or "eng" premode = self.pre_var.get() self._log(f"Exporting {len(pngs)} file(s) | pre={premode} | compress={compress}", "info") self._set_status("Exporting…", ACCENT2) threading.Thread(target=self._export_loop, args=(pngs, out_pdf, lang, compress, premode), daemon=True).start() def _export_loop(self, pngs, out_pdf, lang, compress, premode): ui = lambda fn: self.after(0, fn) pages, failed = [], 0 for i, path in enumerate(pngs, 1): name = os.path.basename(path) ui(lambda i=i, n=name: ( self._log(f"OCR [{i}/{len(pngs)}] {n}", ""), self._set_status(f"OCR {i}/{len(pngs)}…", ACCENT2) )) try: img = Image.open(path) orig_kb = os.path.getsize(path) // 1024 img = self._preprocess(img, premode) buf = io.BytesIO() img.save(buf, format="JPEG", quality=92, optimize=True) new_kb = buf.tell() // 1024 buf.seek(0) pdf_bytes = pytesseract.image_to_pdf_or_hocr( Image.open(buf), extension="pdf", lang=lang) if not pdf_bytes or len(pdf_bytes) < 100: raise ValueError("Tesseract returned empty output") pages.append(pdf_bytes) ui(lambda o=orig_kb, n=new_kb: self._log(f" ✓ {o}KB → {n}KB", "ok")) except Exception as e: failed += 1 ui(lambda e=e: self._log(f" ✗ {e}", "err")) if not pages: ui(lambda: (self._log("No pages OCR'd — aborting.", "err"), self._set_status("Export failed", DANGER))) return if failed: ui(lambda: self._log(f"⚠ {failed} page(s) skipped.", "warn")) ui(lambda: self._log(f"Merging {len(pages)} pages…", "info")) try: writer = PdfWriter() for pb in pages: for page in PdfReader(io.BytesIO(pb)).pages: writer.add_page(page) use_gs = compress != "none" and has_ghostscript() raw_pdf = out_pdf.replace(".pdf", "_raw.pdf") if use_gs else out_pdf with open(raw_pdf, "wb") as f: writer.write(f) ui(lambda: self._log(f"Merged: {os.path.getsize(raw_pdf) // 1024} KB", "")) if use_gs: ui(lambda: self._log(f"Ghostscript ({compress})…", "info")) res = subprocess.run([ BINS['gs'], "-sDEVICE=pdfwrite", "-dCompatibilityLevel=1.4", f"-dPDFSETTINGS=/{compress}", "-dNOPAUSE", "-dQUIET", "-dBATCH", f"-sOutputFile={out_pdf}", raw_pdf ], capture_output=True, text=True) os.remove(raw_pdf) if res.returncode != 0: ui(lambda: self._log(f"GS error: {res.stderr.strip()}", "err")) shutil.copy(raw_pdf, out_pdf) else: ui(lambda: self._log("Compression done.", "ok")) size_mb = os.path.getsize(out_pdf) / 1_048_576 ui(lambda: ( self._log(f"✓ Saved: {out_pdf} ({size_mb:.2f} MB)", "ok"), self._set_status(f"Done — {size_mb:.2f} MB", SUCCESS) )) subprocess.run(["open", out_pdf]) except Exception as e: ui(lambda e=e: ( self._log(f"Export failed: {e}", "err"), self._set_status("Export failed", DANGER) )) # ── Preview window ───────────────────────────────────────────────────────────── class PreviewWindow(tk.Toplevel): def __init__(self, parent, img): super().__init__(parent) self.title("Region Preview"); self.configure(bg=BG) img.thumbnail((700, 500), Image.LANCZOS) photo = ImageTk.PhotoImage(img) lbl = tk.Label(self, image=photo, bg=BG) lbl.image = photo; lbl.pack(padx=10, pady=10) close = tk.Label(self, text="Close", bg=BORDER, fg=BTN_FG, font=SMALL, padx=14, pady=6, cursor="hand2") close.bind("", lambda e: self.destroy()) close.pack(pady=(0, 10)) if __name__ == "__main__": PageSnap().mainloop()