commit
593596fc72
2 files changed
+825
No files matched your search
@@ -0,0 +1,3 @@
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
pagesnap_paths.conf
|
||||
+822
@@ -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()
|
||||
Reference in new issue
Block a user