1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AcrobatでPDFを読むときの「ガクン」問題をHammerspoonで解決する

1
Posted at

課題

論文を読むときはPDFが好きで、Adobe Acrobatを愛用している。しかし長年悩まされている問題があった。

問題:ページ境界での「ガクン」

Acrobatには「ページごとに表示」と「スクロール表示」の2つの表示モードがある。自分の好みは断然スクロール表示なのだが、PDF側にページごとの表示設定が埋め込まれていると、Acrobat側の初期設定を無視してそちらが優先されてしまう。

これが地味にストレスになる。

ページの終わりの方を読んでいると、突然ガクンとページが切り替わる。今どこを読んでいたか見失う。特に数式や図表が多い論文だと、前後の文脈を行き来しながら読むので、この挙動は本当につらい。

毎回手動で「表示」→「ページ表示」→「スクロールを有効にする」を選択すればいいのだが、論文を開くたびにこれをやるのは面倒すぎる。

解決策:Hammerspoonで自動化

macOSユーザーならHammerspoonという強力な自動化ツールが使える。Luaスクリプトでキーボード操作やGUI操作を自動化できる。

今回はChatGPTの力を借りて、以下の動作をするスクリプトを書いた:

  1. Acrobatがアクティブになったタイミングで自動的にスクロール表示に切り替える
  2. Control+7(キーバインドは変更可)で手動でも切り替えられる

実装

~/.hammerspoon/init.luaに以下のようなコードを追加する:

-- Acrobat: 「表示 > ページ表示 > スクロールを有効にする」を未チェック時だけON(GUI)
-- 手動: Ctrl+7
-- 自動: Acrobatが前面(activated) / Acrobatウィンドウの生成・フォーカス・タイトル変化
-- レート制限: 30秒窓で2回まで。3回目以降は30秒クールダウン。

local HOTKEY_MODS, HOTKEY_KEY = {"ctrl"}, "7"

-- 連打防止(内部)
local MIN_GAP_SEC = 0.6
local lastRun = 0

-- 自動トリガーの抑制(2回発火したら30秒待つ)
local BURST_WINDOW_SEC = 30
local BURST_MAX = 2
local COOLDOWN_SEC = 30
local burstCount = 0
local burstStart = 0
local cooldownUntil = 0

local function nowSec() return hs.timer.secondsSinceEpoch() end

local function allowAutoRun()
  local t = nowSec()
  if t < cooldownUntil then return false end

  if burstStart == 0 or (t - burstStart) > BURST_WINDOW_SEC then
    burstStart = t
    burstCount = 0
  end

  burstCount = burstCount + 1
  if burstCount > BURST_MAX then
    cooldownUntil = t + COOLDOWN_SEC
    return false
  end
  return true
end

local function collect(e, pred, out, d)
  out, d = out or {}, d or 0
  if not e or d > 60 then return out end
  if pred(e) then out[#out+1] = e end
  local ch = e.AXChildren
  if ch then for _,c in ipairs(ch) do collect(c, pred, out, d+1) end end
  return out
end

local function menubarItem(ax, title)
  local mb = collect(ax, function(x) return x.AXRole=="AXMenuBar" end)[1]
  if not mb or not mb.AXChildren then return end
  for _,c in ipairs(mb.AXChildren) do
    if c.AXRole=="AXMenuBarItem" and c.AXTitle==title then return c end
  end
end

local function findMenuItem(ax, title)
  for _,m in ipairs(collect(ax, function(x) return x.AXRole=="AXMenu" end)) do
    for _,it in ipairs(collect(m, function(x) return x.AXRole=="AXMenuItem" end)) do
      if it.AXTitle==title then return it end
    end
  end
end

local function press(e) if e then e:performAction("AXPress") end end
local function show(e)  if e then e:performAction("AXShowMenu") end end

local function isAcrobat(app)
  if not app then return false end
  local bid = app:bundleID() or ""
  local name = app:name() or ""
  return (bid:find("com%.adobe%.Acrobat") ~= nil)
      or (bid:find("com%.adobe%.Reader") ~= nil)
      or (name:find("Acrobat") ~= nil)
end

local function ensureScrollOn(app)
  local t = nowSec()
  if (t - lastRun) < MIN_GAP_SEC then return false end
  lastRun = t

  app = app or hs.application.frontmostApplication()
  if not isAcrobat(app) then return false end

  local ax = hs.axuielement.applicationElement(app)

  local view = menubarItem(ax, "表示"); if not view then return false end
  press(view); hs.timer.usleep(150000)

  local page = findMenuItem(ax, "ページ表示"); if not page then return false end
  show(page); hs.timer.usleep(150000)

  local scroll = findMenuItem(ax, "スクロールを有効にする")
  if not scroll then
    press(page); hs.timer.usleep(150000)
    scroll = findMenuItem(ax, "スクロールを有効にする")
  end
  if not scroll then return false end

  local mark = scroll.AXMenuItemMarkChar
  if not (type(mark)=="string" and mark~="") then press(scroll) end
  return true
end

-- 手動
hs.hotkey.bind(HOTKEY_MODS, HOTKEY_KEY, function()
  ensureScrollOn()
end)

-- 自動:少し待って数回リトライ(AcrobatのUI準備待ち)
local function kick(app, isAuto)
  if isAuto and not allowAutoRun() then return end
  if not isAcrobat(app) then return end

  local tries = {0.15, 0.45, 0.9}
  for _,dt in ipairs(tries) do
    hs.timer.doAfter(dt, function()
      local a = hs.application.frontmostApplication() or app
      if isAcrobat(a) then
        a:activate()
        ensureScrollOn(a)
      end
    end)
  end
end

-- 1) 他アプリ→Acrobatに切替(activated)
hs.application.watcher.new(function(appName, event, app)
  if event ~= hs.application.watcher.activated then return end
  -- app が nil の場合があるので前面を取り直す
  local a = hs.application.frontmostApplication() or app or (appName and hs.application.get(appName))
  if a and isAcrobat(a) then
    kick(a, true)
  end
end):start()

-- 2) Acrobatで何か開いた/フォーカスが変わった(window系イベント)
hs.window.filter.new(false)
  :subscribe({hs.window.filter.windowCreated, hs.window.filter.windowFocused, hs.window.filter.windowTitleChanged},
    function(win)
      local a = win and win:application() or hs.application.frontmostApplication()
      if a and isAcrobat(a) then
        kick(a, true)
      end
    end)

コードを保存したら、メニューバーのHammerspoonアイコンから「Reload Config」を選択。

使い方

  • 自動モード: Acrobatに切り替えるだけで勝手にスクロール表示になる
  • 手動モード: Control+7を押すとその場で切り替え

キーバインドを変えたい場合は、最後の行の{"ctrl"}, "7"を好みに変更する。例えば{"cmd", "shift"}, "s"にすればCmd+Shift+Sになる。

注意点

  • メニュー項目の名前は日本語環境を想定している。英語環境なら"View", "Page Display", "Enable Scrolling"などに変更が必要
  • Acrobatのバージョンによってメニュー構造が異なる可能性がある
  • ChatGPTで書いたコードなので細かい挙動は完全に把握していないが、とりあえず動いている

まとめ

小さな不満でも積み重なるとストレスになる。Hammerspoonのようなツールを使えば、アプリケーション側が対応していない細かいカスタマイズも自力で解決できる。

論文を読む時間が少しでも快適になれば、それだけ内容に集中できる。こういう環境整備は地味だけど大事だと思う。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?