简介:屏幕取词常见于翻译词典与学习工具,实现的关键通常落在系统级 API 钩子上。围绕屏幕取词场景的 Windows API 钩子源码工程,面向需要实现划词翻译或屏幕取词功能的中高级开发者,也适合希望深入理解系统钩子机制的编程学习者。资源包含 31 个文件,以头文件与源码文件为主,另有工程配置、资源脚本、动态库、静态库、可执行文件和说明文档,压缩包仅 145KB,整体结构紧凑,便于通读源码和对照产物。示例完整演示了从安装系统钩子、监听鼠标与键盘事件,到获取光标位置并读取窗口文本的取词过程,还涉及钩子动态库的注入方式与工程编译细节;包内的可执行文件和动态库可直接运行,方便观察实际截获效果。对于想学习 Windows 消息机制、钩子编写和桌面应用扩展的开发者来说,这份代码带有完整工程结构与运行示例,可作为屏幕取词相关开发的有效起点。目前已有 257 人学习,适合作为入门参考。
1. API Hook 划词与屏幕取词:它到底解决什么问题
打开一份只读PDF,想复制一段话,却发现阅读器的选中复制被禁用;对着一个自绘界面的程序里某个英文缩写,想知道意思,却发现常规取词工具一个字符都取不到。这类需求落到Windows上,最终都要绕回同一个底子:API Hook。所谓API Hook,简单说就是在一个进程正常调用系统函数之前先经过你的代码,你把参数拿出来看一眼、记下来,再放行。划词翻译和屏幕取词,一个是“用户选中文本之后把词拿走”,一个是“文本还在画布上就被拦了一份”,不少人把它们混为一谈,但这两条技术路线在落地上差得很远。这篇笔记面向准备在Windows客户端里做划词、取词功能的开发者,把两条路的原理、动手步骤和几个高频的坑一次讲清。
2. 先分清两条技术路线:划词与取词不是同一件事
拿到需求别急着搜hook注入的代码,先回答一个问题:你到底是做划词,还是做取词。这两个词虽然总被摆在一起,但它们拦截的目标完全不同,代码的侵入性也完全不在一个量级。
2.1 划词翻译的标准链路:鼠标钩子 + 剪贴板 + 翻译接口
划词翻译的输入时机很明确:用户按住左键拖了一下,松手,就算一次划词。所以标准做法是监听鼠标事件,而不是去拦截文本绘制。常见做法是调用 SetWindowsHookEx 挂一个 WH_MOUSE_LL(低级鼠标钩子),在 WM_LBUTTONUP 里判断按下与抬起的位置差,超过阈值就认为发生了一次有效拖选。这里有个省事的处理:不需要自己去拿选中的文本内容,直接向当前焦点窗口发一条 WM_COPY,让目标程序自己把选中内容放进剪贴板,然后再打开剪贴板读出来。
这条链路的好处是干净,不依赖对方控件类型,只要能响应标准编辑消息的控件都能工作。真正的分叉出现在覆盖范围上:WM_COPY 对标准 Edit、RichEdit、浏览器输入框都有效,但遇到禁止复制的文档阅读器、或者自定义绘制的控件时,这条路就断了。到这一步才轮到 API Hook 出场——要么 Hook 剪贴板相关调用,要么干脆 Hook 文本绘制函数。我见过不少团队的方案,把覆盖范围当成卖点,凡是常规方式取不到的,就用兜底策略临时启用取词 Hook。所以技术选型的第一步,是先定义你自己的覆盖目标:只支持普通编辑框,划词翻译用上面这套就够了;要覆盖任意窗口里的文字,就必须进入下面这条取词路线。
2.2 屏幕取词的 API Hook 路径:拦截文本绘制函数
屏幕取词的核心难点不是“鼠标指到哪”,而是“这段文字是谁画出来的、在哪画的、画的什么内容”。在 GDI 时代,屏幕上能看到的每一个字符,几乎都要经过 TextOutA/W、ExtTextOutA/W、DrawTextA/W 这几个输出函数。经典取词方案就是对它们做 inline hook,在目标进程真正绘制之前截获字符串和坐标,形成一张“屏幕文字表”。鼠标位置落在哪一行文本的矩形里,就认为鼠标指向那一段。这套方案历史悠久,效果也直接:不需要目标程序配合,也不需要它响应任何消息。
但是这条路人人都能做,做得好不好全在细节。TextOut 只负责把一句完整的话输出出来,你要“取词”还得自己按空白分词;它还只负责告诉“这次调用画了什么”,并不告诉你文本有没有被裁剪、被覆盖、被别的窗口挡住;字体、对齐方式、ClearType 渲染都会影响命中矩形的高度和宽度。成熟的取词模块也不会只挂一个函数,而是把 GDI 文本输出族一股脑全挂了,再用一套规则去合并相邻的文本片段。另外注意,API Hook 按实现又分几层:SetWindowsHookEx 的消息钩子是最轻量的,不需要注入代码;IAT Hook 是改写导入表地址,只对显式从某个 DLL 导入函数的模块生效;inline hook 则是直接改写函数入口的机器码,跳进你的函数,覆盖面最广但最容易被杀毒软件盯上。屏幕取词基本都要走 inline hook,因为它不关心目标程序怎么拿到的函数,只关心那个函数本身的行为。
2.3 为什么不能只依赖 WM_GETTEXT:自绘控件与新式渲染
有经验的开发者会反问:为什么不用 WM_GETTEXT 把窗口文本直接取回来?这个问题的答案是,WM_GETTEXT 对绝大多数自绘控件、DirectUI 界面、游戏和部分 PDF 阅读器根本不回应。它们只有绘制逻辑,没有“文本容器”的概念。某公司内部的信息系统就是典型自绘列表,Spy++ 看所有窗口类都是一样的,列表项里的文字全是自己画的,WM_GETTEXT 只能拿到空串,那时唯一能下手的地方就是绘制函数。
另外要注意新一代渲染路径已经不走 GDI 了。Direct2D/DirectWrite 渲染的文本不经过 TextOut,这也就是为什么现在很多新工具直接把 OCR 作为主力,不做 Hook——不是 Hook 不好,是新渲染路径把 hook 的抓手砍掉了。做一个判断然后把架构定住:能用消息拿文本就绝不 Hook;必须 Hook 时锁定 GDI 绘制路径;再为跑 DirectWrite 的程序留一个 OCR 兜底。这个判断顺序比抄一段 Hook 代码重要得多,也直接决定后面的工作量差异。
3. 实现划词翻译:从鼠标钩子到回传选中文本
先把划词翻译这一路走通。目标拆成三步:监控鼠标拖选、判定有效选择、从目标窗口取回选中文本。每一步都有陷阱,我按顺序来。
3.1 安装 WH_MOUSE_LL 钩子:最小可用的鼠标监听
低级鼠标钩子不需要注入任何 DLL,回调就在你自己的进程里执行,所以这一步几乎没有玄学成分,只需要注意三件事:第四个参数传 0 表示全局监听;回调里必须调用 CallNextHookEx 把事件继续传下去;安装钩子之后,所在线程必须持续运行,否则钩子会被系统静默卸载。
#include <windows.h> static POINT g_ptPressDown = { 0, 0 }; static bool g_bInDrag = false; // 低级鼠标钩子回调,nCode == HC_ACTION 时才处理 LRESULT CALLBACK LowLevelMouseProc(int nCode, WPARAM wParam, LPARAM lParam) { if (nCode == HC_ACTION) { MSLLHOOKSTRUCT* pData = (MSLLHOOKSTRUCT*)lParam; if (wParam == WM_LBUTTONDOWN) { g_ptPressDown = pData->pt; // 记录按下时的屏幕坐标 g_bInDrag = true; } else if (wParam == WM_LBUTTONUP && g_bInDrag) { // 用按下与抬起之间的距离差判断是否发生拖选 int dx = (int)(pData->pt.x - g_ptPressDown.x); int dy = (int)(pData->pt.y - g_ptPressDown.y); if (dx * dx + dy * dy > 25) { // 距离平方超过 25,约等于 5 像素 OnWordSelectedEx(pData->pt); } g_bInDrag = false; } } // 低级钩子必须放行,否则系统全局鼠标行为会被卡住 return CallNextHookEx(NULL, nCode, wParam, lParam); } // 安装低层鼠标钩子;线程传 0 表示全局监听 HHOOK g_hMouseHook = SetWindowsHookEx(WH_MOUSE_LL, LowLevelMouseProc, GetModuleHandle(NULL), 0);逻辑说明:回调里先存下按下坐标,抬起时计算欧氏距离,只有超过 5 像素才认为是拖选而不是单击。这是最朴素的判定。参数说明:WH_MOUSE_LL 是低级钩子,回调运行在安装者进程的消息线程里,第三个参数虽然可以传 GetModuleHandle(NULL),但它只是占位;一个常见的翻车点是回调里做重量级操作,这个回调运行在系统分发的上下文中,一个 Sleep(50) 用户立刻觉得全系统鼠标发粘。
3.2 判定一次有效选词:双击选词与拖选并存
上面代码只覆盖了从左到右拖选。实际使用中有反向拖选(从右往左),这不影响坐标差,一样能识别。真正容易误判的是双击选词和三击选行:这两种操作按下抬起的坐标差几乎为零,但用户确实选中了一个词。我一般会在 WM_LBUTTONDBLCLK 里直接当作一次有效选词处理,同时过滤掉按住窗口拖动的场景——判定规则简单粗暴:按下和抬起的时间差超过 800 毫秒,同时移动距离又超过了阈值,才算正经的拖选操作。
另一个设计原则:取词后弹窗的动作一定要 PostMessage 回主线程去做,不要在钩子回调里直接弹 UI。低级鼠标钩子回调的线程上下文很特殊,直接创建窗口会让目标窗口拒绝失焦,后续 WM_COPY 拿不到正确的焦点控件。这个坑的表现是第一次取词能用,第二次点击后整个界面像被冻住一样,很多人会误以为是钩子泄漏,其实是弹窗时机的问题。
3.3 读取选中文本的三种方式与取舍
取回文本有三条路,按侵入性从低到高排。第一优先是发 WM_COPY 给焦点控件,让目标程序自己把选中内容放进剪贴板,代码短、兼容性最好,副作用是覆盖剪贴板,所以取完要立刻恢复。第二套是对标准编辑控件走 EM_GETSEL 拿选中范围,再配合 WM_GETTEXT 取全量文本自行截取,这套的好处是不碰剪贴板,可惜只对标准 Edit/RichEdit 有效。第三套是 UI Automation,通过 TextPattern 拿选中文本,能覆盖不少暴露了 UIA 接口的自绘控件,通吃 Win32 和部分 XAML 界面,代价是单次调用几十毫秒,高频调用会有明显卡顿。
WM_COPY 加剪贴板恢复的完整流程:
HWND hFore = GetForegroundWindow(); DWORD dwThreadId = GetWindowThreadProcessId(hFore, NULL); GUITHREADINFO gti = { sizeof(GUITHREADINFO) }; GetGUIThreadInfo(dwThreadId, >i); // 取线程级 UI 信息 HWND hFocused = gti.hwndFocus; // 真正的焦点子窗口 if (!hFocused) hFocused = hFore; // 先保存旧剪贴板,防止 WM_COPY 覆盖用户正在用的内容 HANDLE hOldData = nullptr; if (OpenClipboard(hFore)) { hOldData = GetClipboardData(CF_UNICODETEXT); CloseClipboard(); } SendMessage(hFocused, WM_COPY, 0, 0); // 让目标控件把选中内容复制走 if (OpenClipboard(NULL)) { HANDLE hData = GetClipboardData(CF_UNICODETEXT); if (hData) { wchar_t* pText = (wchar_t*)GlobalLock(hData); if (pText) { // 到这里 pText 就是用户拖选出来的文本 GlobalUnlock(hData); } } CloseClipboard(); } // 恢复旧剪贴板数据,尽力而为,失败不阻塞 if (hOldData) { OpenClipboard(hFore); SetClipboardData(CF_UNICODETEXT, hOldData); CloseClipboard(); }逻辑说明:先通过 GetGUIThreadInfo 拿到真正有焦点的子窗口,很多坑都出在拿 GetForegroundWindow 当目标,结果 WM_COPY 发给了主窗口而不是输入框。参数说明:OpenClipboard 的窗口句柄传 NULL 是合法用法,传真实窗口句柄能减少与其它剪贴板操作互相干扰的概率;GetClipboardData 拿到的是系统对象句柄,需要 GlobalLock 到内存地址再读。恢复剪贴板这里写成尽力而为,是因为 OpenClipboard 在个别目标程序抢占时会失败,失败时宁可让用户重新复制一次,也不要阻塞整个取词流程。
4. 实现屏幕取词:以 HOOK TextOutW 为例
划词翻译解决的是“用户已经选好了”这个场景,屏幕取词要解决的是“鼠标指过去就拿词”这个更野的需求。这一章进入真正的 inline hook 领地。
4.1 哪个函数画出了屏幕上的字:GDI 文本输出族
要拦截屏幕上的字,先要知道这些字是从哪些函数出来的。GDI 环境下,往设备上涂文字的入口基本锁定在 TextOutA/W、ExtTextOutA/W、DrawTextA/W,另外还有少量控件内部会走 DrawTextEx 和 PolyTextOutW。它们参数不同,优先级也不同:ExtTextOut 最复杂也最常用,比 TextOut 多了字符间距数组和裁剪矩形参数;DrawText 是格式化文本块,自动处理换行和对齐,Hook 它拿到的坐标是整个绘制矩形的左上角,而不是一行内的基线位置。
对多数常规窗口程序,Hook 住 TextOutW 和 ExtTextOutW 已经能覆盖八成。这里必须提一个容易被忽略的点:目标程序如果调的是 TextOutA,拿到的字符串是当前代码页编码(简中系统下是 GBK),你要先 MultiByteToWideChar 转成 UTF-16 再进登记表,否则后面分词和命中显示全是乱码。反过来,如果你只挂了 Unicode 版本而目标程序清一色走 ANSI,表会一直空着。所以我的习惯是 A/W 两个版本一起挂,入口处统一转码再走同一个登记逻辑。
4.2 手写 inline hook 还是用现成库:我选 Detours
inline hook 的本质是改写目标函数入口的机器码,把它前几条指令替换成一条跳转,跳到你写的替身函数里。手写这套逻辑要求自己做指令长度反汇编、计算相对偏移、保存被覆盖指令,每个目标系统版本都要重新测试,所以我一般直接用现成的 hook 库。这类库背后的原理是把原函数入口的若干条指令搬到一块私有内存里,再在原入口写入一条无条件跳转,这样替身函数调用“原函数”时不会递归,也不破坏原有调用约定。
先看最核心的 Hook 主体:
#include <windows.h> #include <detours.h> static decltype(&TextOutW) RealTextOutW = TextOutW; // 保存原函数指针 // 替身函数:先登记这次绘制,再放行到原函数 BOOL WINAPI HookedTextOutW(HDC hdc, int x, int y, LPCWSTR lpString, int c) { // GDI 约定:c 传 -1 表示字符串以 \0 结束,这里做长度兜底 int length = (c > 0) ? c : (int)wcslen(lpString); if (length > 0 && lpString) { HWND hwnd = WindowFromDC(hdc); // 由 DC 反查所属窗口 POINT ptS = { x, y }; if (hwnd) { ClientToScreen(hwnd, &ptS); // 客户区坐标转屏幕坐标 } // 把文本和屏幕坐标登记进全局表,命中判定由另一个线程做 g_capture.AddText(hwnd, ptS.x, ptS.y, lpString, length); } return RealTextOutW(hdc, x, y, lpString, c); } // ANSI 分支:先转 UTF-16 再走同一个登记流程 BOOL WINAPI HookedTextOutA(HDC hdc, int x, int y, LPCSTR lpString, int c) { int length = (c > 0) ? c : (int)strlen(lpString); if (length > 0 && lpString) { int wLen = MultiByteToWideChar(CP_ACP, 0, lpString, length, NULL, 0); wchar_t* pWide = (wchar_t*)malloc((wLen + 1) * sizeof(wchar_t)); if (pWide) { MultiByteToWideChar(CP_ACP, 0, lpString, length, pWide, wLen); pWide[wLen] = 0; g_capture.AddTextUtf16(hdc, x, y, pWide, wLen); free(pWide); } } return RealTextOutA(hdc, x, y, lpString, c); }逻辑说明:替身函数在放行之前,把“画了哪个字符串、画在哪个窗口的哪个位置”登记下来。注意一定不能直接调用 TextOutW 原始符号,那会再次进入 Hook 入口形成无限递归;这里调用的是 RealTextOutW,它指向被库搬到私有内存的原始指令。参数说明:c 为长度,-1 是常见取值;坐标 x,y 是逻辑坐标,在窗口 DC 上就是客户区坐标,要交给鼠标命中判定必须 ClientToScreen 转一次。
4.3 把坐标换算成屏幕坐标:命中判定与文本对齐
登记表的结构是一行文本加一个屏幕矩形。命中判定就是反查:把鼠标屏幕坐标放进所有矩形里找包含关系,这个逻辑本身不难,难在矩形算得准不准。TextOut 的 x,y 默认是文本左上角还是基线左下角,取决于 DC 当前的 TA_* 对齐标志,可以通过 GetTextAlign 查出来。如果不处理对齐标志,命中框在垂直方向会整体偏移几像素,用户感知就是“鼠标指在字母上沿不出词,指在下沿才出”,特别像延迟,其实是对齐偏了。
处理办法是登记文本时把 DC 的对齐模式一并记录下来,算矩形时按 TA_BASELINE 这类标志把基线换算回左上角。ExtTextOut 更复杂,它带一个可选的裁切矩形参数,目标传了矩形就直接用矩形做命中框,没传就按字体字符宽度累加估算。注意命中判定不要每帧全表遍历,绘制密集的应用一秒能新增几百条记录,我的做法是按窗口分桶,每个窗口内部按 y 坐标维护一个有序链表,鼠标坐标进来先定位窗口再按 y 二分,最后在同一行附近做 x 范围检查,单次命中耗时控制在微秒级。取词这种操作最怕的就是把简单查询做成全表扫描。
5. API Hook 取词避坑:注入失败、乱码、剪贴板冲突与64位兼容
前面两章把两条路走通了,但真正把方案推向能用,要过五道坎。这些坑都是常见的真实翻车现场,每条按现象、原因、解决三段式列出,做的时候对照着查就行。
5.1 现象:Hook 注入到目标进程后毫无反应,取词表一直为空
目标程序正常启动、界面正常绘制,但你的登记表一个文本都没进。原因通常有三种:DLL 位数与目标进程不匹配;DetourTransactionBegin 之后漏调 DetourTransactionCommit,所有 Attach 被回滚;DLL 的 DllMain 里贴了 MessageBox 调试代码,弹窗卡住了注入线程。把排查顺序固定住:先用 Dump 工具确认目标进程是 32 位还是 64 位,再在 DllMain 里用 OutputDebugString 输出加载成功标记,最后检查每个 Detour 事务的返回值。这套流程下来九成问题能定位,剩下的再怀疑是杀毒软件拦截了 LoadLibrary 远程线程。
5.2 现象:取到的词是乱码,显示成“锟斤拷”或方框
原因是编码处理出了问题。常见是把 TextOutA 拿到的 ANSI 字节原样按 UTF-16 存进登记表,或者把代码页硬编码成 CP_UTF8,而中文系统下的实际 ANSI 代码页是 CP_ACP(936)。解决并不复杂,在 ANSI 分支里先 MultiByteToWideChar(CP_ACP, 0, ...) 统一转成 UTF-16,再进登记流程;代码页别写死,从 GetACP() 动态取。这个坑的隐蔽点在于它不报错,只在界面上显示出问题,所以单元测试里一定要放一条中文加一条特殊符号的用例。
5.3 现象:Hook 之后目标程序文字消失或绘制卡顿
目标程序本来正常,装上 Hook 后界面文字开始闪、某个区域干脆不重绘。原因多半是你 Hook 函数里做了锁、分配内存、打日志之类的重量级操作,在目标进程的绘制线程里形成阻塞;还有一个原因是目标程序自带完整性校验,检测到入口被改写后主动放弃绘制。解决思路是把登记逻辑做成无锁环形缓冲:替身函数里只做“指针拷贝 + 索引自增”,把字符串逻辑拷贝、分词、坐标换算全部放到另一个消费线程。环形缓冲要用原子操作做索引,绝不能在 Hook 回调里 new 或 DeleteFile。
5.4 现象:剪贴板被反复清空,用户原来的复制内容不见了
这是划词翻译 WM_COPY 路线的老毛病。全文开头那套“先保存再恢复”的逻辑在大多数场景有效,但如果取词间隔很密(比如用户连续在不同程序里划词),每次 OpenClipboard 失败的概率会上升,一旦中间某次恢复失败,用户剪贴板里的旧内容就丢了。解决是双管齐下:一方面把剪贴板恢复做成独立队列,失败时重试而不是放弃;另一方面,能走 EM_GETSEL + WM_GETTEXT 的窗口就走这套,减少对剪贴板的依赖。产品体验上还要给用户一个开关,把“划词时读剪贴板”做成可关闭的隐私项,这个坑解决不解决,直接决定工具能不能被办公用户长期信任。
5.5 现象:32 位取词模块对 64 位程序完全无效
原因很简单:Windows 进程位宽隔离,32 位模块不能注入 64 位进程,CreateRemoteThread 会直接失败,错误码是 5 或者 193。这个坑属于方案设计问题,代码层救不了。解决必须在发布包里放两套 DLL,32 位和 64 位各一份,注入前根据目标进程的位数选择对应模块。顺手把权限问题一并处理:往高权限进程注入时,你的宿主进程也需要同等权限,否则会报拒绝访问,这需要在安装或启动时申请管理员权限。这是最容易踩的合规线,处理不好会导致产品被安全软件反复弹窗。
6. 从取词到可用功能:命中率验证、右键菜单与DPI注意
代码能跑只是第一步,交付前要能回答“这个取词到底准不准”。我建议做一个诊断模式:选定一个包含中文、英文、数字混排的网页,让它自动滚动,Hook 端把所有登记的文本块输出成日志;同时每隔 200 毫秒截一次屏,用离线 OCR 识别同一屏幕的文字作为对照真值。对比两个数据集里锚点词(比如固定的标题和按钮文字)的位置命中率,命中率低于 80% 就说明你的登记表有系统性的坐标偏移或者漏 Hook 的分支。
然后是交互增强。取词成功后的弹菜单,不要在钩子进程里直接调 TrackPopupMenu,把命中结果 PostMessage 回宿主主窗口,由主窗口弹菜单。这样既避免钩子线程的 shell 交互问题,也便于菜单逻辑统一维护。菜单项至少要包含“翻译”“复制”“加入生词本”三个动作,其中“复制”要复制的是取到的词而不是让用户再手动选一遍,这一步是产品好感的来源。
最后说 DPI。Per-Monitor V2 DPI 环境下,GDI 的客户区坐标和物理屏幕坐标之间不再是 1:1,取词登记的矩形必须乘以目标窗口所在的监视器 DPI 缩放比例,否则在多显示器场景下位置会整体偏移。这一步放在命中判定之前做,从 MonitorFromWindow 拿 DPI 值再换算。我最后养成的习惯是:任何坐标转换统一封装成一个函数,禁止散落在登记和命中代码里,不然一改 DPI 就要全局翻一次。做取词功能最容易犯的毛病是贪多,我一个早期的版本试图把所有 TextOut 都收全,结果一个复杂列表滚动起来每秒几千次绘制,内存肉眼可见地涨到几百兆。后来改成只登记鼠标周围 300 像素内的文本块,内存和 CPU 全部回到正常水位——取词取的是眼前这块屏幕,不是这台机器上所有文字。希望这篇笔记能帮你少踩几个坑,把 Hook 用得克制又准确。
本文还有配套的精品资源,点击获取