用Swift写一个MAC上的截图OCR工具

想法来源

工作中,很多人喜欢把截图添附到Confluence里面,而这些图片里面的文字,一旦被需要,却很难像文本一样简单选择复制出来。

于是自然而然想到,要是有一个截图工具,能够对截取的图中的文字进行识别就好了。

这样就产生了一个需求: 编写一个能够在MacOS上运行的截图并识别的工具。

对这个需求再具体话一点,可以得到以下几个关键要素。

  1. 能运行在我现在的OS 10.15.4 上

  2. 这个工具不需要界面,只需要显示在右上角status bar中。

  3. 这个工具由两部分组成,截图,以及对截图的内容进行文字识别,最后识别的内容保存到剪贴板中。

可行性调查

首先截图,我们可以用mac自带的截图工具。

在swift代码中,新建一个进程然后调用截图工具。

1
2
3
4
5
let task = Process()
task.launchPath = "/usr/sbin/screencapture"
task.arguments = ["-i", "-r", "-c"]
task.launch()
task.waitUntilExit()

使用option -c 可以使截图直接保存到剪贴板中,之后我们的程序可以直接访问剪贴板获取图片。

接着如何实现OCR?

这里自然而然查到可以用apple的Vision Framework。

稍微改编一下这里的例子,就行了。

当然,最关键的,得看一下剪贴板如何调用。

MacOS中,剪贴板是所有程序公用的,编程的时候,可以通过NSPasteboard来访问。

1
2
3
4
5
6
7
//获取剪贴板
let pasteboard = NSPasteboard.general
// 获取剪贴板中的图片
if let readData = pasteboard.data(forType: NSPasteboard.PasteboardType.tiff),
let cbImage = CIImage(data: readData) {
// 执行识别文字
}

那如何把识别出来的问题重新放回剪贴板呢?

1
2
pasteboard.clearContents()
pasteboard.setString('text I want to copy to pasteboard', forType: .string)

好了,关键点都梳理清楚了。

还有一些并不关键的,放到下一节说。

开始编写

首先打开xcode,创建一个mac os 的app。

由于我们需要的是一个status bar only的程序,我们并不需要任何窗口,仅需要一个下拉菜单,于是可以参考
这里
来建一个项目的雏形。

AppDelegate.swift中创建一个菜单。

1
var statusItem: NSStatusItem?

然后override awakeFromNib

1
2
3
4
5
6
override func awakeFromNib() {
super.awakeFromNib()

statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
statusItem?.button?.title = "ScreenOCR"
}

在storyboard上
删除不需要的菜单项目。

创建一个

1
@IBOutlet weak var menu: NSMenu?

通过IBOutlet把menu关联到storyboard上的菜单中。

然后删除windowView,这样就没有了讨厌的窗口。不过我们会发现dock上还是会显示app的图标,所以我们在Info.plist中还得添加一行。
在末尾点击加号,然后选择Application is agent (UIElement), 然后在value的地方选择Yes。这样就不会有图标了。

接下来就是把我们上面整理好的逻辑添加上去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import Cocoa
import Vision
import CoreImage

@main
class AppDelegate: NSObject, NSApplicationDelegate {

var statusItem: NSStatusItem?
@IBOutlet weak var menu: NSMenu?
let pasteboard = NSPasteboard.general

func applicationDidFinishLaunching(_ aNotification: Notification) {
// Insert code here to initialize your application
}

func applicationWillTerminate(_ aNotification: Notification) {
// Insert code here to tear down your application
}

override func awakeFromNib() {
super.awakeFromNib()

//我们在这里向菜单里添加一个新的项目。当用户点击这个或者使用快捷键,就自动调用takeScreenShot这个function。
menu?.insertItem(withTitle: "Take screenshot", action: #selector(takeScreenShot), keyEquivalent: "4", at: 0)
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
statusItem?.button?.title = "OCR"
if let menu = menu {
statusItem?.menu = menu
}

}

@objc func takeScreenShot() {
let task = Process()
task.launchPath = "/usr/sbin/screencapture"
task.arguments = ["-i", "-r", "-c"]
task.launch()
task.waitUntilExit()
if let readData = pasteboard.data(forType: NSPasteboard.PasteboardType.tiff),
let cbImage = CIImage(data: readData)
{
let requestHandler = VNImageRequestHandler(ciImage: cbImage)
let request = VNRecognizeTextRequest(completionHandler: recognizeTextHandler)
do {
// Perform the text-recognition request.
try requestHandler.perform([request])
} catch {
print("Unable to perform the requests: \(error).")
}
}


}

func recognizeTextHandler(request: VNRequest, error: Error?) {
guard let observations =
request.results as? [VNRecognizedTextObservation] else {
return
}
let recognizedStrings = observations.compactMap { observation in
// Return the string of the top VNRecognizedText instance.
return observation.topCandidates(1).first?.string
}

// Process the recognized strings.
pasteboard.clearContents()
pasteboard.setString(recognizedStrings[0], forType: .string)
}
}

总结

能够从解决问题出发进行针对性的学习,还是学习的最大动力。否则看了也忘了,学了也没用。
有了基础的知识能力之后,遇到需要解决的问题,往往需要的只是组合,以及稍微再学习一点额外的东西。
继续加油吧。

使用“apple watch complication”显示当前比特币价格

有时候我们想要扫一眼手表就可以看到当前的比特币价格。这个时候,我们可以考虑把价格显示到apple watch的complication这个widget中。

complication 是什么

所谓的complication其实就是显示在自定义表盘上的这些模块了。其中有大有小。比如下面的例子,除了时间以外,
有表示日期,温度,运动量的小模块,也有表示日程安排的大模块。

那么为了表示我们需要的比特币价格的模块,有哪些方法呢?

方法1:通过WatchConnectivity

WatchConnectivity 这个Framework用来实现ios和watchos的通信。

这个时候很显然我们要自己建一个ios的application,然后通过这个application把当前的比特币价格传送给apple watch,然后表示在表盘的complications上面。 用xcode新建watch application然后勾选上complications的选项这个就不说了。

ios获取数据

我们看到bitflyer提供了realtime的PubNub的subscribe key。利用这个Key我们可以连续获得ticker。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//AppDelegate.swift

import PubNub

// 需要 delegate PNObjectEventListener

// ...省略

let configuration = PNConfiguration(publishKey: "demo", subscribeKey: "sub-c-52a9ab50-291b-11e5-baaa-0619f8945a4f")
self.client = PubNub.clientWithConfiguration(configuration)
self.client.addListener(self)
self.client.subscribeToChannels(["lightning_ticker_BTC_JPY"], withPresence: false)

// 添加实现delegate的fucntion

func client(_ client: PubNub, didReceiveMessage message: PNMessageResult) {

let bicoinInfo = message.data.message as! NSDictionary

// bitcoinInfo是一个词典类型,保存了诸如`best_ask`, `best_bid`, `ltp` 之类的情报。
// bitcoinInfo["ltp"] => 299900
// 我们需要把这个东西传给apple watch。
}

ios传送数据

导入 WatchConnectivity, 实现WCSessionDelegate

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 导入
import WatchConnectivity

// 初始化
if WCSession.isSupported() {
self.session.delegate = self
self.session.activate()
}

// 实现delegate
func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) {
// 将当前的tick保存在lastestTick中。
// 获取 lastTick
self.session.transferUserInfo(["tick": lastestTick])
}

func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
}

watchos extension接受数据

watchos 需要接受ios传来的数据并刷新当前的complications来表示最新的价格。

这里注意一点是complications的更新,在watchos里面会有一个计数,一天更新超过一定的次数就无法更新了。
在上面的ios的实现中,我们通过didReceiveMessage 来触发传输给watchos的信息,所以我们在这里要主动发送一个空的信息,来让ios把信息发过来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 同样需要导入WatchConnectivity

import WatchConnectivity

// 初始化

func applicationDidFinishLaunching() {
if WCSession.isSupported() {
self.session.delegate = self
self.session.activate()
}
// Perform any final initialization of your application.
}

// 实现delegate

func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
}
func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) {

}
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) {
let lastTick = userInfo["tick"] as! NSDictionary
// 更新持有的tick数据
TickData.setTick(tick: lastTick)
let complicationServer = CLKComplicationServer.sharedInstance()
guard let activeComplications = complicationServer.activeComplications else {
return
}

for complication in activeComplications {
complicationServer.reloadTimeline(for: complication)
}

}

注意我们这里主要实现didReceiveUserInfo,这个函数实现对通过transferUserInfo传过来的数据进行处理。

在具体的处理实现中,我们获取传来的lastTick, 然后通过complicationServer.reloadTimeline来刷新complication。

watchos complication 模版和渲染

ComplicationController.swift,我们可以看到xcode已经帮我们delegate了CLKComplicationDataSource

首先我们得决定当我们没有任何数据的时候,我们的complication表示什么内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// MARK: - Placeholder Templates

func getLocalizableSampleTemplate(for complication: CLKComplication, withHandler handler: @escaping (CLKComplicationTemplate?) -> Void) {
// This method will be called once per supported complication, and the results will be cached
let template = CLKComplicationTemplateModularLargeStandardBody()
let image = UIImage(named: "bitcoin")

template.headerImageProvider =
CLKImageProvider(onePieceImage: image!)

template.headerTextProvider =
CLKSimpleTextProvider(text: "BTC/JPY")
template.body1TextProvider =
CLKSimpleTextProvider(text: "0")

handler(template)
}

CLKComplicationTemplateModularLargeStandardBody()用来新建一个ModularLarge尺寸的模版,这个模版的就是我们上图看到的日程安排那块的大小。

接着我们实现getCurrentTimelineEntry

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// MARK: - Timeline Population

func getCurrentTimelineEntry(for complication: CLKComplication, withHandler handler: @escaping (CLKComplicationTimelineEntry?) -> Void) {

// Call the handler with the current timeline entry
let lastTick = TickData.getTick()
let ltp = String(describing: lastTick["ltp"] as! Int)
let template = CLKComplicationTemplateModularLargeStandardBody()
let image = UIImage(named: "bitcoin")

template.headerImageProvider =
CLKImageProvider(onePieceImage: image!)

template.headerTextProvider =
CLKSimpleTextProvider(text: "BTC/JPY")
template.body1TextProvider =
CLKSimpleTextProvider(text: ltp)

let entry = CLKComplicationTimelineEntry.init(date: Date(), complicationTemplate: template)

handler(entry)

}

在这里做的就是通过lastTick这个Datasource把数据获取数据,然后建立一个模版,和时间一起封装成一个CLKComplicationTimelineEntry的实例然后交给handler。

到这里,我们已经可以在complication上面显示比特币的价格了。

手表截图

方法2 使用PushOver的Glances API

上面的实装要是了解原理也不是那么复杂,但是等我们真正build这个application,上传,审核,发布,那可是很长一段岁月。
更何况往往我们并没有那么多耐心去折腾。

很多时候,稍微花一点钱,我们的问题很快就可以迎刃而解。比如我们可以使用PushOver的glances api来实现在complication上表示自定义信息的需求。

文档已经很详细就不多说了,唯一需要注意的,还是complication不接受太频繁的更新这一点。

手表截图2

总结

技术往往是达成目的的手段。当我们一味追求技术的时候,往往会忽视一些最根本的问题。
有些东西,如果是花钱就能解决的问题,那么不妨考虑一下花这个钱好了。