823 lines
36 KiB
Python
823 lines
36 KiB
Python
#!/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()
|