我前几天折腾了一个自己挺喜欢的小东西:在 macOS 上按一个快捷键,框选一块区域,直接把图里的字识别出来,然后塞进剪贴板。
功能不复杂,真正有意思的是另一件事:我几乎不会 Lua,这个东西还是做出来了。而且我全程基本没怎么敲代码,主要靠嘴说。准确点说,是我把需求、偏好、报错现象讲给 Codex,它去查、去试、去改,我负责盯方向和验收。
这篇不想再空讲“AI 改变生产方式”这种大话了,没劲。就讲四件事:
- 这个 OCR 最后是怎么实现的
- 中间我为什么放弃了几条看起来更直觉的路
- 后来我为什么又加了一个菜单栏入口
- 最后能直接用的 Lua 脚本长什么样
我想要的,其实不是 OCR
我想要的是一个顺手的动作。
按下 Ctrl + D,或者点一下菜单栏里的 OCR 图标,框一下,文字就进剪贴板。没有额外窗口,没有一堆配置,没有“请把图片拖到这里”,也没有成功以后再弹个提示来打断我。
这点很重要。因为很多工具的问题不在“不能用”,而在“每次用都要被它打扰一下”。
所以我一开始就把要求卡得比较死:
- 快捷键触发
- 菜单栏图标触发
- 支持框选截图
- 尽量只用 macOS 自带能力
- 不装额外 OCR 软件
- 主要逻辑收在一个 Lua 脚本里
- 成功时别弹窗,失败再说
你看,真正难的不是“OCR 能不能做”,而是把链路收得够短。
一开始最容易想到的路,反而不是我想要的
最直觉的方案当然是装个 tesseract 之类的东西,截图之后把图片丢过去识别。
这条路能不能走?当然能。
但我很快就不想走了。原因也很简单:我只是想做个平时自己顺手用的小 OCR,不想为了这点事再引一套额外依赖。你现在觉得装一次也就装一次,等你哪天把它分享给别人,或者自己换台机器,就知道这种“也不复杂”的依赖到底有多烦。
所以目标很快就变成了:
Hammerspoon 负责交互 + macOS Vision 负责识别
后来我又补了一个菜单栏入口。快捷键适合手已经在键盘上的时候用,菜单栏图标适合鼠标正在屏幕上移动的时候用。两条入口最后都走同一个 captureSelectionAndRecognize(),这样不会有两套 OCR 逻辑互相分叉。
这条路的好处是干净。系统本来就有的能力,能用就别再往外搬。
真正麻烦的地方,不是识别,而是桥接
如果只是讲原理,这个东西其实只有三段:
- Hammerspoon 绑定快捷键,调用系统截图
- 截图落到临时文件
- 调用 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.05,C 的右侧也补了短横,不然它看起来太像一个半圆。
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 图标,就能用。
这份脚本到底干了什么
如果把上面一大段压成几句人话,其实就是:
- 按
Ctrl + D,或者点击菜单栏 OCR 图标 - 调
screencapture -i -x让你框选 - 把截图存成临时 PNG
- 现场生成一段 AppleScript
- AppleScript 通过 Vision 识别文字
- 把返回结果塞进剪贴板
- 删掉临时图片和临时脚本
所以这东西虽然叫 ocr.lua,本质上其实是个调度器。真正干识别的是 Vision,真正把 Vision 调起来的是 AppleScriptObjC。
这次最让我有感觉的,不是 Lua
说到底,我这次不是学会了 Lua。
我只是第一次特别明确地感受到,很多过去“得先学会 X,才能开始做 Y”的事情,现在已经没那么绝对了。
当然,前提不是你可以什么都不懂。恰恰相反,你得更清楚自己到底要什么。
比如这次真正由我拍板的,不是某个 API 怎么写,而是这些事:
- 我不要第三方 OCR 依赖
- 我不要成功弹窗
- 我希望主要逻辑最后还是收在一个 Lua 脚本里
- 哪条路虽然能跑,但结构太脏,我不要
- 菜单栏图标要内联在 Lua 里,不额外扔文件
这些判断以前也重要,只是以前你还得自己把所有实现细节一并扛下来。现在实现这部分,可以明显往 AI 那边分了。
这就是我为什么会说,这玩意儿是“用嘴做出来的”。
不是说我一点没参与。恰恰相反,我参与得很深,只是参与的位置变了。
最后
这篇原本只写了思路,没把脚本贴出来,确实差一口气。技术文讲到“这里其实能做”,结果不放代码,读者看完多半只会想一句:行吧,那你倒是把东西给我。
现在补上了,文章才算完整。
而且说实话,这个 OCR 本身没多大。真正让我上头的是另一件事:我不会 Lua,但我还是把它做出来了。这个感觉挺新鲜,也挺实在。