From 593596fc72c491118c8a3cfc3850562d03e68140 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xos=C3=A9?= Date: Sun, 18 Oct 2026 19:03:32 +0200 Subject: [PATCH] Add PageSnap Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 + pagesnap.py | 822 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 825 insertions(+) create mode 100644 .gitignore create mode 100644 pagesnap.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee0d21c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +__pycache__/ +pagesnap_paths.conf diff --git a/pagesnap.py b/pagesnap.py new file mode 100644 index 0000000..f970536 --- /dev/null +++ b/pagesnap.py @@ -0,0 +1,822 @@ +#!/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()