"""
PDF 自动翻页工具 v4
跨平台 (macOS / Windows 11)
──────────────────────────────────────────────────────────────────────────────
热键后端优先级:pynput(Mac/Win 均可用,无需管理员权限)→ keyboard(仅 Win)
功能:
- tkinter 浮动 GUI,置顶开关可切换
- Ctrl+Shift+↑ 加速 / Ctrl+Shift+↓ 减速 / Ctrl+Shift+P 暂停/继续
(避免与 PDF 阅读器 F1/F2/F3 冲突)
- GUI 内 [-] [+] 按钮 + 暂停按钮,不依赖热键亦可操作
- PageDown / Space 手动翻页:始终重置计时(不会立即再自动翻页)
- STAY_GUARD = 2.0 s:鼠标触发防双翻保护窗口
- 分别统计自动翻页 / 手动翻页次数
- 独立统计阅读时间(不含暂停)和累计暂停时间
- 关闭时追加写入 scroll_history.json
- 鼠标左边缘触发(可选,适合轨迹球)
"""
import sys
import os
import json
import tkinter as tk
import tkinter.simpledialog as sd
from tkinter import ttk
import pyautogui
import time
import threading
from datetime import datetime, timedelta
# ── 平台 ──────────────────────────────────────────────────────────────────────
PLATFORM = sys.platform # 'darwin' | 'win32' | 'linux'
HISTORY_FILE = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "scroll_history.json"
)
# 字体(Segoe UI 仅 Windows;macOS 用 Helvetica)
_FONT_BOLD = ("Helvetica", 11, "bold") if PLATFORM == "darwin" else ("Segoe UI", 11, "bold")
_FONT_SMALL = ("Helvetica", 8) if PLATFORM == "darwin" else ("Segoe UI", 8)
# ── 热键后端 ──────────────────────────────────────────────────────────────────
try:
from pynput import keyboard as _pynput_kb
_BACKEND = "pynput"
except ImportError:
_pynput_kb = None
try:
import keyboard as _kb_module
_BACKEND = "keyboard"
except ImportError:
_kb_module = None
_BACKEND = "none"
class AutoScroller:
SPEED_MIN = 1.0 # 最短翻页间隔(秒)
SPEED_MAX = 120.0 # 最长翻页间隔(秒)
SPEED_STEP = 1.0 # 每次调速步长(秒)
STAY_GUARD = 2.0 # 鼠标触发防双翻保护窗口(秒);键盘无此限制
def __init__(self, interval: float):
self.interval = interval
self._timer = interval
# 翻页计数(自动 / 手动 分开统计)
self._auto_count = 0
self._manual_count = 0
# 时间统计
self._session_start = time.time()
self._total_pause_secs = 0.0
self._pause_start = None # 非 None 表示当前处于暂停中
self._running = True
self._lock = threading.Lock()
self._pause_evt = threading.Event()
self._pause_evt.set() # 初始:运行中
self._build_gui()
self._register_hotkeys()
threading.Thread(target=self._scroll_loop, daemon=True).start()
threading.Thread(target=self._mouse_loop, daemon=True).start()
self._refresh_gui() # 启动 GUI 周期刷新
# ── GUI ───────────────────────────────────────────────────────────────────
def _build_gui(self):
self.root = tk.Tk()
self.root.title("PDF 自动翻页 v4")
self.root.resizable(False, False)
# BooleanVar 必须在 Tk() 之后创建
self._mouse_enabled = tk.BooleanVar(value=False)
self._topmost_var = tk.BooleanVar(value=True)
self._topmost_var.trace_add("write", self._toggle_topmost)
self.root.attributes('-topmost', True)
frm = ttk.Frame(self.root, padding=12)
frm.pack(fill=tk.BOTH, expand=True)
# 状态行
self.sv_status = tk.StringVar(value="▶ 运行中")
self._lbl_status = ttk.Label(
frm, textvariable=self.sv_status,
font=_FONT_BOLD, foreground="#1a7a1a"
)
self._lbl_status.grid(row=0, column=0, columnspan=3, pady=(0, 8))
# 数据行(3列:标签 / 值 / 占位)
rows = [
("自动翻页", "sv_auto", "0 页"),
("手动翻页", "sv_manual", "0 页"),
("阅读时间", "sv_reading", "00:00:00"),
("暂停时间", "sv_pause_t", "00:00:00"),
("翻页间隔", "sv_interval", f"{self.interval:.0f} 秒"),
("下次翻页", "sv_countdown", f"{self.interval:.1f} 秒"),
]
for i, (lbl, attr, init) in enumerate(rows, start=1):
ttk.Label(frm, text=lbl + ":", anchor="e").grid(
row=i, column=0, sticky="e", pady=2
)
sv = tk.StringVar(value=init)
setattr(self, attr, sv)
ttk.Label(frm, textvariable=sv, anchor="w").grid(
row=i, column=1, columnspan=2, sticky="w", padx=(6, 0), pady=2
)
n = len(rows)
# ── 速度控制按钮行 ──────────────────────────────────────────────────
ttk.Separator(frm, orient="horizontal").grid(
row=n + 1, column=0, columnspan=3, sticky="ew", pady=(8, 4)
)
ctrl_frm = ttk.Frame(frm)
ctrl_frm.grid(row=n + 2, column=0, columnspan=3, pady=(0, 4))
ttk.Button(ctrl_frm, text=" − ", width=4,
command=self._slower).pack(side=tk.LEFT, padx=2)
ttk.Label(ctrl_frm, text="速度").pack(side=tk.LEFT, padx=4)
ttk.Button(ctrl_frm, text=" + ", width=4,
command=self._faster).pack(side=tk.LEFT, padx=2)
self._btn_pause = ttk.Button(
ctrl_frm, text="⏸ 暂停", width=8, command=self._toggle_pause
)
self._btn_pause.pack(side=tk.LEFT, padx=(10, 2))
# ── 选项 ────────────────────────────────────────────────────────────
ttk.Checkbutton(
frm, text="始终置顶",
variable=self._topmost_var
).grid(row=n + 3, column=0, columnspan=3, sticky="w")
ttk.Checkbutton(
frm, text="鼠标左边缘触发翻页",
variable=self._mouse_enabled
).grid(row=n + 4, column=0, columnspan=3, sticky="w")
# ── 热键提示 ─────────────────────────────────────────────────────────
ttk.Label(
frm,
text="Ctrl+Shift+↑ 加速 · Ctrl+Shift+↓ 减速 · Ctrl+Shift+P 暂停",
foreground="gray", font=_FONT_SMALL
).grid(row=n + 5, column=0, columnspan=3, pady=(4, 0))
backend_tip = {"pynput": "热键:pynput", "keyboard": "热键:keyboard", "none": "⚠ 未找到热键库"}
ttk.Label(
frm, text=backend_tip.get(_BACKEND, ""),
foreground="gray", font=_FONT_SMALL
).grid(row=n + 6, column=0, columnspan=3)
# ── 热键注册(跨平台)────────────────────────────────────────────────────
def _register_hotkeys(self):
"""
热键方案(避免与 PDF 阅读器冲突)
Ctrl+Shift+↑ → 加速(减小间隔)
Ctrl+Shift+↓ → 减速(增大间隔)
Ctrl+Shift+P → 暂停/继续
PageDown / Space → 手动翻页计时重置(pass-through,阅读器正常接收)
"""
if _BACKEND == "pynput":
_mods = {'ctrl': False, 'shift': False}
K = _pynput_kb.Key
def on_press(key):
try:
if key in (K.ctrl_l, K.ctrl_r, K.ctrl):
_mods['ctrl'] = True
elif key in (K.shift, K.shift_l, K.shift_r):
_mods['shift'] = True
elif _mods['ctrl'] and _mods['shift']:
if key == K.up:
self._faster()
elif key == K.down:
self._slower()
elif hasattr(key, 'char') and key.char in ('p', 'P'):
self._toggle_pause()
elif key == K.page_down:
self._on_manual()
elif key == K.space:
self._on_manual()
except Exception:
pass
def on_release(key):
try:
if key in (K.ctrl_l, K.ctrl_r, K.ctrl):
_mods['ctrl'] = False
elif key in (K.shift, K.shift_l, K.shift_r):
_mods['shift'] = False
except Exception:
pass
self._kb_listener = _pynput_kb.Listener(
on_press=on_press, on_release=on_release, daemon=True
)
self._kb_listener.start()
elif _BACKEND == "keyboard":
# suppress=True:Ctrl+Shift 组合键被本程序消费,不传给 PDF 阅读器
# suppress=False:PageDown/Space 仍透传,让阅读器正常翻页
_kb_module.add_hotkey('ctrl+shift+up', self._faster, suppress=True)
_kb_module.add_hotkey('ctrl+shift+down', self._slower, suppress=True)
_kb_module.add_hotkey('ctrl+shift+p', self._toggle_pause, suppress=True)
_kb_module.add_hotkey('pagedown', self._on_manual, suppress=False)
_kb_module.add_hotkey('space', self._on_manual, suppress=False)
# ── 后台翻页线程 ──────────────────────────────────────────────────────────
def _scroll_loop(self):
while self._running:
self._pause_evt.wait() # 暂停时在此阻塞,不消耗 CPU
time.sleep(0.1)
do_turn = False
with self._lock:
self._timer -= 0.1
if self._timer <= 0:
self._auto_count += 1
self._timer = self.interval
do_turn = True
if do_turn:
pyautogui.press('pagedown')
# ── 鼠标边缘触发线程 ─────────────────────────────────────────────────────
def _mouse_loop(self):
"""鼠标移到屏幕最左侧(x < 50)时触发翻页,离开后自动移回。"""
while self._running:
try:
if self._mouse_enabled.get() and self._pause_evt.is_set():
pos = pyautogui.position()
if pos.x < 50:
do_turn = False
with self._lock:
if self._timer > self.STAY_GUARD:
self._manual_count += 1
self._timer = self.interval
do_turn = True
if do_turn:
pyautogui.press('pagedown')
time.sleep(0.1)
pos2 = pyautogui.position()
if pos2.x <= 130:
pyautogui.moveTo(130, pos2.y)
except Exception:
pass
time.sleep(0.1)
# ── 热键回调 ──────────────────────────────────────────────────────────────
def _on_manual(self):
"""
键盘手动翻页(PageDown / Space):
始终重置计时器到完整间隔,确保手动翻后不会立即自动翻。
键盘本身已将按键传给 PDF 阅读器,此处不再额外 press()。
"""
with self._lock:
self._manual_count += 1
self._timer = self.interval
def _faster(self):
with self._lock:
self.interval = max(self.SPEED_MIN, self.interval - self.SPEED_STEP)
self._timer = min(self._timer, self.interval)
def _slower(self):
with self._lock:
self.interval = min(self.SPEED_MAX, self.interval + self.SPEED_STEP)
def _toggle_topmost(self, *_):
topmost = self._topmost_var.get()
self.root.attributes('-topmost', topmost)
def _toggle_pause(self):
if self._pause_evt.is_set():
self._pause_evt.clear() # → 暂停
with self._lock:
self._pause_start = time.time()
else:
self._pause_evt.set() # → 继续
with self._lock:
if self._pause_start is not None:
self._total_pause_secs += time.time() - self._pause_start
self._pause_start = None
# ── GUI 刷新(每 100 ms)─────────────────────────────────────────────────
def _refresh_gui(self):
now = time.time()
with self._lock:
auto_c = self._auto_count
manual_c = self._manual_count
interval = self.interval
countdown = max(0.0, self._timer)
pause_acc = self._total_pause_secs
p_start = self._pause_start
# 当前暂停段(如正在暂停)
current_pause = (now - p_start) if p_start is not None else 0.0
total_pause = pause_acc + current_pause
reading_time = max(0.0, (now - self._session_start) - total_pause)
paused = not self._pause_evt.is_set()
self.sv_auto.set(f"{auto_c} 页")
self.sv_manual.set(f"{manual_c} 页")
self.sv_reading.set(str(timedelta(seconds=int(reading_time))))
self.sv_pause_t.set(str(timedelta(seconds=int(total_pause))))
self.sv_interval.set(f"{interval:.0f} 秒")
self.sv_countdown.set("已暂停" if paused else f"{countdown:.1f} 秒")
if paused:
self.sv_status.set("⏸ 已暂停")
self._lbl_status.configure(foreground="#b05a00")
self._btn_pause.configure(text="▶ 继续")
else:
self.sv_status.set("▶ 运行中")
self._lbl_status.configure(foreground="#1a7a1a")
self._btn_pause.configure(text="⏸ 暂停")
self.root.after(100, self._refresh_gui)
# ── 历史记录 ──────────────────────────────────────────────────────────────
def _save_history(self):
now = time.time()
with self._lock:
pause_acc = self._total_pause_secs
p_start = self._pause_start
auto_c = self._auto_count
manual_c = self._manual_count
interval = self.interval
current_pause = (now - p_start) if p_start is not None else 0.0
total_pause = pause_acc + current_pause
reading_time = max(0.0, (now - self._session_start) - total_pause)
record = {
"date": datetime.now().strftime("%Y-%m-%d"),
"start": datetime.fromtimestamp(self._session_start).strftime("%H:%M:%S"),
"end": datetime.now().strftime("%H:%M:%S"),
"reading_seconds": int(reading_time),
"pause_seconds": int(total_pause),
"auto_turns": auto_c,
"manual_turns": manual_c,
"interval_final": round(interval, 1),
"platform": PLATFORM,
}
records = []
if os.path.exists(HISTORY_FILE):
try:
with open(HISTORY_FILE, "r", encoding="utf-8") as f:
records = json.load(f)
except Exception:
records = []
records.append(record)
try:
with open(HISTORY_FILE, "w", encoding="utf-8") as f:
json.dump(records, f, ensure_ascii=False, indent=2)
except Exception:
pass
# ── 生命周期 ──────────────────────────────────────────────────────────────
def run(self):
self.root.protocol("WM_DELETE_WINDOW", self._shutdown)
self.root.mainloop()
def _shutdown(self):
self._running = False
self._pause_evt.set() # 解除阻塞让翻页线程退出
self._save_history()
if _BACKEND == "pynput":
try:
self._kb_listener.stop()
except Exception:
pass
elif _BACKEND == "keyboard":
try:
_kb_module.unhook_all()
except Exception:
pass
self.root.destroy()
# ── 入口 ──────────────────────────────────────────────────────────────────────
def main():
tmp = tk.Tk()
tmp.withdraw()
interval = sd.askfloat(
"PDF 自动翻页",
"请输入翻页间隔(秒):",
minvalue=1.0, maxvalue=600.0, initialvalue=5.0,
parent=tmp,
)
tmp.destroy()
if interval is None:
return # 用户取消
AutoScroller(interval).run()
if __name__ == "__main__":
main()