Add PageSnap

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
XoséandClaude Opus 5.5 committed 2026-10-18 19:03:32 +02:00
commit 593596fc72
2 files changed
+825

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
.DS_Store
__pycache__/
pagesnap_paths.conf
+822
View File
@@ -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("<Configure>", lambda _:
self.canvas.configure(scrollregion=self.canvas.bbox("all")))
self.canvas.bind("<Configure>", lambda e:
self.canvas.itemconfig(self._win, width=e.width))
self.canvas.bind_all("<MouseWheel>", 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("<Escape>", 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("<<ComboboxSelected>>",
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("<ButtonPress-1>", lambda e: setattr(self, '_drag_idx', self._lb.nearest(e.y)))
self._lb.bind("<B1-Motion>", self._lb_drag)
self._lb.bind("<ButtonRelease-1>", 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("<Button-1>", lambda e: cmd() if lbl._enabled else None)
lbl.bind("<Enter>", lambda e: lbl.configure(bg=self._lighten(bg)) if lbl._enabled else None)
lbl.bind("<Leave>", 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("<Button-1>", 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("<Button-1>", 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("<Return>", lambda e: confirm())
win.bind("<Escape>", 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("<Button-1>", lambda e: self.destroy())
close.pack(pady=(0, 10))
if __name__ == "__main__":
PageSnap().mainloop()