我前几天折腾了一个自己挺喜欢的小东西:在 macOS 上按一个快捷键,框选一块区域,直接把图里的字识别出来,然后塞进剪贴板。

功能不复杂,真正有意思的是另一件事:我几乎不会 Lua,这个东西还是做出来了。而且我全程基本没怎么敲代码,主要靠嘴说。准确点说,是我把需求、偏好、报错现象讲给 Codex,它去查、去试、去改,我负责盯方向和验收。

这篇不想再空讲“AI 改变生产方式”这种大话了,没劲。就讲四件事:

  1. 这个 OCR 最后是怎么实现的
  2. 中间我为什么放弃了几条看起来更直觉的路
  3. 后来我为什么又加了一个菜单栏入口
  4. 最后能直接用的 Lua 脚本长什么样

我想要的,其实不是 OCR

我想要的是一个顺手的动作。

按下 Ctrl + D,或者点一下菜单栏里的 OCR 图标,框一下,文字就进剪贴板。没有额外窗口,没有一堆配置,没有“请把图片拖到这里”,也没有成功以后再弹个提示来打断我。

这点很重要。因为很多工具的问题不在“不能用”,而在“每次用都要被它打扰一下”。

所以我一开始就把要求卡得比较死:

  • 快捷键触发
  • 菜单栏图标触发
  • 支持框选截图
  • 尽量只用 macOS 自带能力
  • 不装额外 OCR 软件
  • 主要逻辑收在一个 Lua 脚本里
  • 成功时别弹窗,失败再说

你看,真正难的不是“OCR 能不能做”,而是把链路收得够短。

一开始最容易想到的路,反而不是我想要的

最直觉的方案当然是装个 tesseract 之类的东西,截图之后把图片丢过去识别。

这条路能不能走?当然能。

但我很快就不想走了。原因也很简单:我只是想做个平时自己顺手用的小 OCR,不想为了这点事再引一套额外依赖。你现在觉得装一次也就装一次,等你哪天把它分享给别人,或者自己换台机器,就知道这种“也不复杂”的依赖到底有多烦。

所以目标很快就变成了:

Hammerspoon 负责交互 + macOS Vision 负责识别

后来我又补了一个菜单栏入口。快捷键适合手已经在键盘上的时候用,菜单栏图标适合鼠标正在屏幕上移动的时候用。两条入口最后都走同一个 captureSelectionAndRecognize(),这样不会有两套 OCR 逻辑互相分叉。

这条路的好处是干净。系统本来就有的能力,能用就别再往外搬。

真正麻烦的地方,不是识别,而是桥接

如果只是讲原理,这个东西其实只有三段:

  1. Hammerspoon 绑定快捷键,调用系统截图
  2. 截图落到临时文件
  3. 调用 macOS 自带的 Vision 做文字识别,再把结果写进剪贴板

问题出在第三步。

我一开始以为,既然 Hammerspoon 里跑的是 Lua,那能不能直接在 Lua 里把 Vision 框架调起来?后来确认,不行,至少在现成这套环境里不行。Hammerspoon 的 Lua 很适合做调度,但不是那种你想调什么 Objective-C 框架就能直接调什么的运行时。

这时候如果硬拧,就会开始出现一种很烦的局面:Lua 一点,Shell 一点,AppleScript 一点,再夹点别的东西,最后能跑是能跑,但结构越来越丑。

我中间最不满意的也就是这个。

后来把思路拧回来以后,事情反而顺了:

  • Lua 继续当主入口
  • Lua 负责截图、权限判断、临时文件、剪贴板
  • Lua 动态生成一段临时 AppleScript
  • osascript 执行这段脚本
  • AppleScriptObjC 去调 Vision

也就是说,表面上我还是只维护一个 ocr.lua,真正的 OCR 识别交给系统原生框架。这个结构我能接受,因为它至少是收敛的。

这几个细节,不处理的话用起来会很烦

代码真正写出来以后,后面花时间的地方反而是一些小事。

1. 屏幕录制权限得先拦

系统截图这一步如果没有权限,用户看到的体验会很差。与其等它报个莫名其妙的错,不如先检查一次 screenRecordingState,没权限就直接提示去系统设置里开。

2. 成功别弹窗

我一开始的版本,识别成功之后会弹一个提示。这个设计我后来很快就否了。

原因很朴素:成功本来就是正常路径。正常路径还出来刷存在感,这种交互我自己都嫌烦。最后只保留了失败和取消时的提示,成功就安静地把文字写进剪贴板。

3. 识别结果直接进剪贴板

如果 OCR 完了还得再点一次复制,那这个工具就已经输了。快捷动作之所以有价值,就是因为它能少一次手。

4. 菜单栏入口也要走同一条逻辑

后来我觉得只靠快捷键还不够。有时候手在鼠标上,点菜单栏比按组合键自然;有时候快捷键也可能和别的软件习惯冲突。所以我又加了一个菜单栏 icon。

这里我不想再丢一个单独的图标文件进去。最后的做法是把一段 18x18 的 SVG 直接内联在 Lua 脚本里,再用 hs.base64.encode() 拼成 data URL,交给 hs.image.imageFromURL() 生成菜单栏图标。

这个图标还是扫描框加 OCR 三个字母。线宽收细到了 1.05C 的右侧也补了短横,不然它看起来太像一个半圆。

5. 识别语言别乱开

我最后只给了这几个识别语言:

zh-Hans
zh-Hant
en-US

我平时用它,中文和英文够了。语言开太多,有时候反而容易给自己添乱。

最终脚本

脚本现在就在我本机的 ~/.hammerspoon/ocr.lua 里,能直接用。核心逻辑就是下面这份。

把它放到 ~/.hammerspoon/ocr.lua

local hs = hs
local hotkey = hs.hotkey
local task = hs.task
local alert = hs.alert
local pasteboard = hs.pasteboard
local timer = hs.timer
local screenRecordingState = hs.screenRecordingState
local menubar = hs.menubar
local image = hs.image
local base64 = hs.base64

local ocr = {}

local screenshotCommand = "/usr/sbin/screencapture"
local osascriptCommand = "/usr/bin/osascript"
local menuBarIconSvg = [[
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18">
  <g fill="none" stroke="black" stroke-width="1.05" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke">
    <path d="M2.4 5.1V3.6C2.4 2.9 2.9 2.4 3.6 2.4H5.1"/>
    <path d="M12.9 2.4H14.4C15.1 2.4 15.6 2.9 15.6 3.6V5.1"/>
    <path d="M15.6 12.9V14.4C15.6 15.1 15.1 15.6 14.4 15.6H12.9"/>
    <path d="M5.1 15.6H3.6C2.9 15.6 2.4 15.1 2.4 14.4V12.9"/>
    <circle cx="5.1" cy="9" r="1.9"/>
    <path d="M10.65 7H10.25C9.1 7 8.25 7.8 8.25 9C8.25 10.2 9.1 11 10.25 11H10.65"/>
    <path d="M12.1 11V7H13.55C14.35 7 14.9 7.5 14.9 8.2C14.9 8.95 14.35 9.45 13.55 9.45H12.1"/>
    <path d="M13.45 9.45L14.9 11"/>
  </g>
</svg>
]]

-- Cleanup stale temp files on load
for f in io.popen('ls /tmp/ocr_* 2>/dev/null'):lines() do
  os.remove(f)
end

local function trim(s) return (s or ""):gsub("^%s+", ""):gsub("%s+$", "") end

local function tmp(suffix)
  return "/tmp/ocr_" .. tostring(os.time()) .. "_" .. tostring(math.random(100000)) .. (suffix or "")
end

local function q(s) return '"' .. tostring(s):gsub("\\", "\\\\"):gsub('"', '\\"') .. '"' end

local function buildAppleScript(imagePath)
  local qp = q(imagePath)
  return table.concat({
    'use framework "Foundation"',
    'use framework "Vision"',
    'use scripting additions',
    "",
    "set theFile to current application's |NSURL|'s fileURLWithPath:" .. qp,
    "set requestHandler to current application's VNImageRequestHandler's alloc()'s initWithURL:theFile options:(missing value)",
    "set theRequest to current application's VNRecognizeTextRequest's alloc()'s init()",
    "theRequest's setRecognitionLevel:(current application's VNRequestTextRecognitionLevelAccurate)",
    'theRequest\'s setRecognitionLanguages:{"zh-Hans", "zh-Hant", "en-US"}',
    "theRequest's setUsesLanguageCorrection:false",
    "requestHandler's performRequests:(current application's NSArray's arrayWithObject:(theRequest)) |error|:(missing value)",
    "set theResults to theRequest's results()",
    'if theResults is missing value then return ""',
    "set theArray to current application's NSMutableArray's new()",
    "repeat with aResult in theResults",
    "    set theCandidates to aResult's topCandidates:1",
    "    if (theCandidates's |count|()) > 0 then",
    "        (theArray's addObject:(((theCandidates's objectAtIndex:0)'s |string|())))",
    "    end if",
    "end repeat",
    "return (theArray's componentsJoinedByString:linefeed) as text",
  }, "\n")
end

local function runOCR(imagePath, callback)
  local scriptPath = tmp(".applescript")
  local f, err = io.open(scriptPath, "w")
  if not f then
    os.remove(scriptPath)
    os.remove(imagePath)
    callback(nil, "OCR failed")
    return
  end
  f:write(buildAppleScript(imagePath))
  f:close()

  local t = task.new(osascriptCommand, function(exitCode, stdout, stderr)
    os.remove(scriptPath)

    if exitCode ~= 0 then
      os.remove(imagePath)
      callback(nil, "OCR failed")
      return
    end

    callback(stdout:gsub("\r?\n$", ""), nil)
  end, {scriptPath})

  if not t then
    os.remove(scriptPath)
    os.remove(imagePath)
    callback(nil, "OCR failed")
    return
  end

  t:start()
end

function ocr.runOnImage(imagePath)
  runOCR(imagePath, function(text, err)
    os.remove(imagePath)

    if not text then
      alert.show(err, 3)
      return
    end

    if text == "" then
      alert.show("No text found", 2)
      return
    end

    pasteboard.setContents(text)
  end)
end

function ocr.captureSelectionAndRecognize()
  if not screenRecordingState(false) then
    screenRecordingState(true)
    alert.show("Screen capture denied", 3)
    return
  end

  local imagePath = tmp(".png")
  local t = task.new(screenshotCommand, function(exitCode, _, stderr)
    if exitCode ~= 0 then
      os.remove(imagePath)
      alert.show("Screen capture failed", 1.8)
      return
    end

    timer.doAfter(0.05, function()
      ocr.runOnImage(imagePath)
    end)
  end, {"-i", "-x", imagePath})

  if not t then
    os.remove(imagePath)
    alert.show("Screen capture failed", 2)
    return
  end

  t:start()
end

local modifiers = {"ctrl"}
local triggerKey = "d"

if hotkey.assignable(modifiers, triggerKey) then
  hotkey.bind(modifiers, triggerKey, nil, function()
    ocr.captureSelectionAndRecognize()
  end)
else
  alert.show("Ctrl+" .. triggerKey .. " is reserved", 3)
end

local function createMenuBarItem()
  local item = menubar.new(true, "ocr_menu_bar")
  if not item then
    alert.show("OCR menu bar item failed", 3)
    return nil
  end

  local icon = image.imageFromURL("data:image/svg+xml;base64," .. base64.encode(menuBarIconSvg))
  if icon then
    item:setIcon(icon, true)
  else
    item:setTitle("OCR")
  end

  item:setTooltip("OCR selection")
  item:setClickCallback(function()
    ocr.captureSelectionAndRecognize()
  end)

  return item
end

ocr.menuBar = createMenuBarItem()

return ocr

然后在 ~/.hammerspoon/init.lua 里加一句:

require("ocr")

重载 Hammerspoon 之后,按 Ctrl + D,或者点菜单栏里的 OCR 图标,就能用。

这份脚本到底干了什么

如果把上面一大段压成几句人话,其实就是:

  1. Ctrl + D,或者点击菜单栏 OCR 图标
  2. screencapture -i -x 让你框选
  3. 把截图存成临时 PNG
  4. 现场生成一段 AppleScript
  5. AppleScript 通过 Vision 识别文字
  6. 把返回结果塞进剪贴板
  7. 删掉临时图片和临时脚本

所以这东西虽然叫 ocr.lua,本质上其实是个调度器。真正干识别的是 Vision,真正把 Vision 调起来的是 AppleScriptObjC。

这次最让我有感觉的,不是 Lua

说到底,我这次不是学会了 Lua。

我只是第一次特别明确地感受到,很多过去“得先学会 X,才能开始做 Y”的事情,现在已经没那么绝对了。

当然,前提不是你可以什么都不懂。恰恰相反,你得更清楚自己到底要什么。

比如这次真正由我拍板的,不是某个 API 怎么写,而是这些事:

  • 我不要第三方 OCR 依赖
  • 我不要成功弹窗
  • 我希望主要逻辑最后还是收在一个 Lua 脚本里
  • 哪条路虽然能跑,但结构太脏,我不要
  • 菜单栏图标要内联在 Lua 里,不额外扔文件

这些判断以前也重要,只是以前你还得自己把所有实现细节一并扛下来。现在实现这部分,可以明显往 AI 那边分了。

这就是我为什么会说,这玩意儿是“用嘴做出来的”。

不是说我一点没参与。恰恰相反,我参与得很深,只是参与的位置变了。

最后

这篇原本只写了思路,没把脚本贴出来,确实差一口气。技术文讲到“这里其实能做”,结果不放代码,读者看完多半只会想一句:行吧,那你倒是把东西给我。

现在补上了,文章才算完整。

而且说实话,这个 OCR 本身没多大。真正让我上头的是另一件事:我不会 Lua,但我还是把它做出来了。这个感觉挺新鲜,也挺实在。