☰
纯C实现Linux屏幕取词翻译器:从X11截图到OCR识别与翻译展示
2026/10/21 4:27:18 网站建设 项目流程

简介:这是一份基于C语言实现的Linux屏幕取词翻译工具源码包,面向Linux开发者与桌面工具爱好者,解决终端或图形界面下快速取词并调用在线翻译的需求。程序内置Bing与Google翻译模块,支持取词检测、快捷键响应、窗口位置设置与个性化配置,整体设计涉及系统级鼠标监听、界面绘制和API集成。资源共141个文件,压缩包约16.7MB,主体为C源文件(40个)与头文件(25个),并配套makefile构建脚本、UI资源、Shell辅助脚本以及PNG/GIF操作演示图,目录划分清晰,便于按模块阅读。项目同时附有README、许可证与配置样例,方便快速上手。目前已有124人学习下载,适合希望理解Linux屏幕取词实现原理、翻译服务接入方式,以及提升C语言项目组织能力的开发者参考。

1. 一个纯 C 的 Linux 屏幕取词翻译器:鼠标悬停就能从屏幕底下把单词抠出来

在 Linux 下读英文文档,鼠标划过生词想立刻知道意思,很多人第一反应是装一个带图形界面的词典软件。但这个项目反着来——用纯 C 语言直接调 X11 底层接口,把鼠标底下几十像素的屏幕区域截成图片,交给 OCR 引擎识别成单词,再走 HTTP 翻译接口,最后弹出一个无边框小窗显示释义。整条链路没有 Qt、没有 Electron,编译产物只有几百 KB,装进轻量桌面或嵌入式 Linux 都合适。它把「取词」这件事拆成了截图、识别、翻译、展示四个可替换模块,每一块都能单独换掉。适合想学 X11 编程的 C 开发者,也适合需要在无重量级 UI 框架环境下做屏幕取词插件的人。


2. 整体架构:一条从鼠标位置到翻译结果的完整数据链路

2.1 模块划分与目录结构

拿到源码先看目录,这个项目的职责边界划分得很清晰:

dict/ ├── src/ │ ├── main.c # 主循环:监听鼠标事件,调度整个流程 │ ├── capture.c # 屏幕截图:XGetImage 抠出光标附近图像 │ ├── capture.h │ ├── ocr.c # OCR 识别:OCR 引擎 C API 封装 │ ├── ocr.h │ ├── translate.c # 翻译请求:libcurl POST + JSON 解析 │ ├── translate.h │ ├── ui.c # 结果展示:X11 无边框弹窗 │ ├── ui.h │ └── config.c # 配置读取:坐标偏移、API key、语言包路径 ├── config.ini # 运行配置样例 ├── Makefile └── README.md

模块之间通过头文件暴露的接口通信,运行时数据流是单向的:main.c 拿到光标坐标,交给 capture.c 产出临时 PPM 图片;ocr.c 读图片产出字符串;translate.c 把字符串翻译成中文;ui.c 把中文画到屏幕上。任何一级的产出都是下一级的输入,没有全局状态,没有跨模块共享变量。

这种设计有一个直接好处:每一层都能单独测试。项目里提供了 --test 模式,可以跳过截图直接喂一张图片,这对追查 OCR 和翻译问题特别有用。你在二次开发时如果只想替换翻译后端,接口是char *translate(const char *word, const char *from, const char *to),换实现不影响其它层。

2.2 技术选型:X11 截图、OCR 引擎与 HTTP 接口怎么搭在一起

屏幕取词在 Linux 上最关键的难点是「怎么拿到鼠标底下的字」。Windows 取词能 Hook 文本渲染链路,Linux 的 X11 没有统一文本 Hook 机制,所以这个项目选了一条更通用但也更「笨」的路线:截图像素,再用 OCR 识别。缺点是需要 OCR 这一步,多一次计算开销;优点是完全不依赖应用类型——终端、浏览器、PDF 阅读器里的字都能抓,因为抓的是屏幕本身。

截图层用 Xlib 的 XGetImage,把指定坐标、指定尺寸的屏幕区域拷成 XImage 结构体,逐像素读 RGB 写 PPM。为什么不存 PNG?因为 PPM 格式简单到可以用 fwrite 硬写,零依赖,不用引入第三方图像库。实际产品里如果要降低内存拷贝开销,可以换 XShmGetImage 走共享内存,但那个要处理 IPC 权限,代码量翻一倍,对几十像素的小图没必要。

OCR 环节用的是开源的 Tesseract 引擎 C API,支持语言包热切换,识别英文单词的准确率足够。要注意的是它对中文、代码符号容易翻车,所以默认只开 eng 语言包。翻译环节用 libcurl 发 POST 请求,JSON 解析用 json-c,这两个库在几乎所有发行版仓库里都有现成包。展示直接用 XCreateSimpleWindow 配合 override_redirect,绕开窗口管理器,做出一个不抢焦点的临时浮层。

选型时还有一个容易忽略的点:整套程序没有引入任何 GUI 框架。这决定了它能跑在只装了 X server 的极简环境里,也能被嵌入到别的 C 程序里作为一个子模块调用。如果需要改成 GTK 气泡或 Qt 悬浮窗,只要替换 ui.c 一个文件,这就是我们把 GUI 隔离到最外层的回报。

2.3 主循环与触发策略:悬停和热键两条路

main.c 支持两种触发模式,通过配置的 mode 字段切换。悬停模式监听鼠标事件,记录每次 MotionNotify 的时间戳,如果鼠标在某个位置停留超过阈值(比如 300ms),就触发一次取词。热键模式用 XGrabKey 注册全局组合键,按下后立刻在当前位置截图。两种模式共用同一个取词主流程,改触发方式不动业务代码。

悬停模式的实现核心在事件循环:

// main.c —— 悬停触发的事件循环骨架 Display *dpy = XOpenDisplay(NULL); XEvent ev; int last_x = 0, last_y = 0; struct timeval last_move; XSelectInput(dpy, DefaultRootWindow(dpy), PointerMotionMask); while (1) { XNextEvent(dpy, &ev); if (ev.type == MotionNotify) { // 记录移动时间和最新光标坐标(根窗口坐标,非相对坐标) last_x = ev.xmotion.x_root; last_y = ev.xmotion.y_root; gettimeofday(&last_move, NULL); } else if (ev.type == KeyPress && ev.xkey.keycode == hotkey) { // 热键模式:立即在当前位置取词 on_word_pick(last_x, last_y); } // 悬停判断:当前时间与最后移动时间差超过阈值就触发 struct timeval now; gettimeofday(&now, NULL); long diff_ms = (now.tv_sec - last_move.tv_sec) * 1000 + (now.tv_usec - last_move.tv_usec) / 1000; if (diff_ms > hover_delay_ms && last_x > 0) { on_word_pick(last_x, last_y); // 取完词要重置基准时间,否则会连续触发多次 last_move.tv_sec = now.tv_sec; last_move.tv_usec = now.tv_usec; } }

逻辑说明:主循环在完成 XOpenDisplay 后,用 XSelectInput 在根窗口上订阅 PointerMotionMask,这样鼠标在桌面上任意位置移动都会产生 MotionNotify。悬停判断放在循环末尾,通过 gettimeofday 算时间差,不额外开定时器线程。取词成功后把 last_move 重置为当前时间,避免 300ms 后同一个位置反复触发。

参数说明:hotkey 是通过 XStringToKeysym 加 XKeysymToKeycode 转换成 keycode 的,配置里写 ctrl+g 就会在启动时完成全局热键注册。hover_delay_ms 就是去抖时间,终端里快速移动光标经常误触的话,把它调到 500ms 才稳;如果觉得响应慢,降到 200ms 也行,但误触率会明显上升。


3. 核心模块拆解:从 XGetImage 到翻译 JSON 的每一行关键代码

3.1 屏幕截图模块:把光标邻域抠成 PPM 图片

capture.c 是这个项目里最贴近底层的一层,它直接面对 X11 的像素格式。核心函数如下:

// capture.c —— 截取以 (cx, cy) 为中心、半径为 r 的正方形区域 int capture_region(Display *dpy, int cx, int cy, int r, const char *out_path) { Window root = DefaultRootWindow(dpy); // 注意:X11 坐标系原点在屏幕左上角,这里用的是屏幕绝对坐标 XImage *img = XGetImage(dpy, root, cx - r, cy - r, r * 2, r * 2, AllPlanes, ZPixmap); if (!img) { fprintf(stderr, "[capture] XGetImage failed\n"); return -1; } FILE *fp = fopen(out_path, "wb"); if (!fp) { XDestroyImage(img); return -1; } // PPM 二进制格式:P6 + 宽高 + 最大色值,之后像素逐行排列 fprintf(fp, "P6\n%d %d\n255\n", img->width, img->height); for (int y = 0; y < img->height; y++) { for (int x = 0; x < img->width; x++) { unsigned long pixel = XGetPixel(img, x, y); // 默认 32 位深下 RGB 各占 8 位;24 位深需要查 mask unsigned char red = (pixel >> 16) & 0xFF; unsigned char green = (pixel >> 8) & 0xFF; unsigned char blue = pixel & 0xFF; fputc(red, fp); fputc(green, fp); fputc(blue, fp); } } fclose(fp); XDestroyImage(img); return 0; }

逻辑说明:XGetImage 的第四、第五个参数是截图区域左上角坐标,所以传入 cx - r 和 cy - r;第六、第七个参数是宽高,这里直接取 2r。AllPlanes 表示取所有颜色平面,ZPixmap 表示返回的是像素数据而不是位图掩码,这两个参数在普通屏幕截图场景基本是固定写法。逐像素读 RGB 用的是 XGetPixel,它帮你处理了视觉类掩码的差异,比自己算偏移省心。

参数说明:r 是取词半径,默认 40 像素。小于 30 容易把单词截掉一半,OCR 输出残缺;超过 60 会把旁边一两列的字符也框进去。如果你用 1440p 屏幕、字号 16 左右,40 是比较稳的值。要是系统开了 HiDPI 缩放,物理像素和逻辑像素差了 2 倍,这个 r 也要跟着乘缩放系数,具体在第 5 章展开。

3.2 OCR 识别模块:OCR 引擎 C API 的初始化与调用

OCR 层封装得非常薄,但有两个容易被忽略的细节:tessdata 路径一定要对,识别结果的释放方式比较特殊。

// ocr.c —— 用 OCR 引擎 C API 从 PPM 图片识别英文单词 #include <tesseract/capi.h> // 返回的字符串需要调用方自己释放 char *ocr_recognize(const char *image_path, const char *lang) { TessBaseAPI *api = TessBaseAPICreate(); if (!api) { fprintf(stderr, "[ocr] create api failed\n"); return NULL; } // 第二个参数是 tessdata 目录,发行版安装路径可能不同 if (TessBaseAPIInit3(api, "/usr/share/tesseract-ocr/5/tessdata", lang) != 0) { fprintf(stderr, "[ocr] init failed, lang=%s\n", lang); TessBaseAPIDelete(api); return NULL; } // 直接吃图片文件路径,内部会自己解码 PPM TessBaseAPISetImage2(api, image_path); TessBaseAPIRecognize(api, NULL); char *utf8_text = TessBaseAPIGetUTF8Text(api); // 先做一次去尾空白,避免翻译接口收到多余换行符 if (utf8_text) { char *p = utf8_text + strlen(utf8_text); while (p > utf8_text && (p[-1] == '\n' || p[-1] == ' ')) { *--p = '\0'; } } TessBaseAPIEnd(api); TessBaseAPIDelete(api); return utf8_text; }

逻辑说明:TessBaseAPIInit3 的第三个参数是语言代码,默认 eng;TessBaseAPISetImage2 可以直接读文件路径,省去先解码成内部图像对象的步骤。TessBaseAPIGetUTF8Text 返回的是 C++ 侧分配的内存,官方文档要求用 delete[] 释放,在纯 C 代码里直接 free 会造成分配器不匹配的问题。很多从 C++ 项目移植过来的代码都栽在这,安全的做法是把这一层编译成 C++,或者干脆拷贝一份字符串再释放原对象。

参数说明:lang 传空字符串时会加载 tessdata 目录里所有语言包,识别速度和准确率都会变差,别这么干。tessdata 路径因发行版而异:Debian 系新版一般是 /usr/share/tesseract-ocr/5/tessdata,老版本可能是 4 开头。建议把路径挪到配置里,换机器不用改代码。识别结果里除了单词还可能带数字、标点,英文字典场景建议在后处理里过滤掉纯符号串。

3.3 翻译请求模块:libcurl 封装签名、POST 与 JSON 解析

翻译是唯一一个需要联网的模块。主流翻译服务都提供签名校验,基本套路是 appKey、salt、query、secret 拼成一个字符串取 MD5 作为 sign。实际调用代码:

// translate.c —— 向翻译服务发起 POST 请求 #include <curl/curl.h> #include <json-c/json.h> #include <openssl/md5.h> typedef struct { char *data; size_t len; } resp_buffer; static size_t write_callback(void *ptr, size_t size, size_t nmemb, void *userdata) { size_t total = size * nmemb; resp_buffer *buf = (resp_buffer *)userdata; buf->data = realloc(buf->data, buf->len + total + 1); if (!buf->data) return 0; memcpy(buf->data + buf->len, ptr, total); buf->len += total; buf->data[buf->len] = '\0'; return total; } // 计算 MD5,输出 32 位小写十六进制字符串 static void md5_hex(const char *input, char out[33]) { unsigned char digest[16]; MD5((const unsigned char *)input, strlen(input), digest); for (int i = 0; i < 16; i++) { sprintf(out + i * 2, "%02x", digest[i]); } out[32] = '\0'; } char *translate_word(const char *word, const char *from, const char *to) { CURL *curl = curl_easy_init(); if (!curl) return NULL; // 构造签名参数:salt 每次请求必须不同,否则会被服务端拒绝 char salt[32]; snprintf(salt, sizeof(salt), "%ld", (long)time(NULL)); // 拼接顺序各家文档略有差异,按你申请的那家文档来 char sign_src[512]; snprintf(sign_src, sizeof(sign_src), "%s%s%s%s", APP_KEY, word, salt, SECRET_KEY); char sign[33]; md5_hex(sign_src, sign); // 查询词做一次 URL 编码,防止特殊字符破坏表单 char *escaped = curl_easy_escape(curl, word, 0); char post_data[1024]; snprintf(post_data, sizeof(post_data), "q=%s&from=%s&to=%s&appKey=%s&salt=%s&sign=%s", escaped, from, to, APP_KEY, salt, sign); curl_free(escaped); resp_buffer buf = {0}; curl_easy_setopt(curl, CURLOPT_URL, TRANSLATE_API_URL); curl_easy_setopt(curl, CURLOPT_POSTFIELDS, post_data); curl_easy_setopt(curl, CURLOPT_TIMEOUT, 5L); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &buf); CURLcode res = curl_easy_perform(curl); if (res != CURLE_OK) { fprintf(stderr, "[translate] curl error: %s\n", curl_easy_strerror(res)); free(buf.data); curl_easy_cleanup(curl); return NULL; } curl_easy_cleanup(curl); // 解析 JSON,取出第一个翻译结果 struct json_object *root = json_tokener_parse(buf.data); if (!root) { fprintf(stderr, "[translate] invalid json: %s\n", buf.data); free(buf.data); return NULL; } struct json_object *translation; if (json_object_object_get_ex(root, "translation", &translation)) { // translation 通常是数组,取第一个元素 struct json_object *first = json_object_array_get_idx(translation, 0); char *result = strdup(json_object_get_string(first)); json_object_put(root); free(buf.data); return result; } json_object_put(root); free(buf.data); return NULL; }

逻辑说明:整个调用流程是「构造签名 → 拼 POST 表单 → 同步请求 → 解析 JSON」。write_callback 把 HTTP 响应体所有分片收到一个动态分配的字符串里,这是 libcurl 最常见的用法。JSON 层面,先拿到 root 对象,再取 translation 字段,该字段在多数翻译服务里是数组,取第一个元素就是最常用的译文。

参数说明:CURLOPT_TIMEOUT 设成 5 秒非常重要——取词操作如果卡在网络上,主循环会被阻塞,鼠标后续移动全部停住。salt 必须保证每次请求不同,用时间戳 + 进程号拼接是常见做法,同一个 salt 重复提交会被服务端判定为重放,返回签名错误。APP_KEY 和 SECRET_KEY 在这里是宏,正式部署建议改成从配置读,避免把密钥写进二进制被人用 strings 扒出来。

3.4 结果展示模块:一个无装饰的 X11 弹窗

取词结果的展示要「不打扰」,不能抢键盘焦点,不能出现在任务栏。做法是用 XCreateSimpleWindow 创建窗口,然后强制 override_redirect:

// ui.c —— 在屏幕 (x, y) 附近弹出一个无边框文本窗口 void show_popup(const char *text, int x, int y) { Display *dpy = XOpenDisplay(NULL); if (!dpy) return; int screen = DefaultScreen(dpy); Window root = RootWindow(dpy, screen); int width = 400, height = 120; Window win = XCreateSimpleWindow(dpy, root, x, y, width, height, 1, BlackPixel(dpy, screen), WhitePixel(dpy, screen)); // override_redirect 让窗口管理器不接管这个窗口,不出现边框和任务栏 XSetWindowAttributes attrs; attrs.override_redirect = True; XChangeWindowAttributes(dpy, win, CWOverrideRedirect, &attrs); // 只关心按键和曝光事件,曝光时重画文本 XSelectInput(dpy, win, KeyPressMask | ExposureMask); XStoreName(dpy, win, "dict-popup"); XMapWindow(dpy, win); XFlush(dpy); GC gc = XCreateGC(dpy, win, 0, NULL); XSetForeground(dpy, gc, BlackPixel(dpy, screen)); // 文本按 20 像素行高逐行绘制,换行符作为分行依据 char *copy = strdup(text); int row = 0; char *line = strtok(copy, "\n"); while (line) { XDrawString(dpy, win, gc, 8, 16 + row * 20, line, strlen(line)); row++; line = strtok(NULL, "\n"); } XFreeGC(dpy, gc); free(copy); // 事件循环:按任意键关闭,Expose 事件自动重绘 XEvent ev; while (1) { XNextEvent(dpy, &ev); if (ev.type == KeyPress) { break; } } XDestroyWindow(dpy, win); XCloseDisplay(dpy); }

逻辑说明:override_redirect 是关键属性,设置了它之后窗口管理器不再接管这个窗口,所以没有标题栏、没有边框、不占任务栏。事件循环里收到 KeyPress 就退出并销毁窗口,Expose 事件实际上会触发默认重绘,不用额外处理。有一个体验细节:窗口没有抢焦点,只有鼠标点进窗口或按快捷键时才能收到按键;如果希望它 3 秒自动消失,可以在循环里加一个时间判断。

参数说明:窗口坐标 x、y 是从 main 传进来的光标屏幕坐标,这里直接当左上角用,实际建议调用时传 y + 16,做个偏移,否则弹窗会盖住鼠标本身。窗口宽 400 高 120,翻译结果长于 6 行会被截断;一个简单的补丁是先用换行符数出总行数,再动态计算 height。


4. 编译运行与参数调优:从零把项目跑起来

4.1 依赖安装与 Makefile 编译

这个项目的依赖不多但都比较底层,Debian 系发行版安装命令如下:

sudo apt install libx11-dev tesseract-ocr libtesseract-dev \ libcurl4-openssl-dev libjson-c-dev openssl # 确认能访问 X 显示: echo $DISPLAY # 应输出类似 :0 或 :1

安装完依赖后直接 make:

make ./dict --help

Makefile 里核心编译配置:

CC = gcc CFLAGS = -Wall -O2 -I./src LDFLAGS = -lX11 -ltesseract -lcurl -ljson-c -lcrypto SRCS = src/main.c src/capture.c src/ocr.c src/translate.c src/ui.c src/config.c OBJS = $(SRCS:.c=.o) dict: $(OBJS) $(CC) -o $@ $(OBJS) $(LDFLAGS)

逻辑说明:-lcrypto 来自 OpenSSL,MD5 计算依赖它;-ltesseract 来自 libtesseract-dev。如果链接时报找不到头文件或符号,先检查 libtesseract-dev 和 tesseract-ocr 是否版本匹配——发行版仓库里有时候主程序和开发库来自不同版本路径,用 dpkg -l 查一下就行。编译期报错和运行期找不到语言包是两回事,后者在第 5 章讲。

4.2 配置文件与运行参数说明

程序启动时读项目根目录的 config.ini,关键配置项:

[ocr] tessdata_path = /usr/share/tesseract-ocr/5/tessdata lang = eng radius = 40 [trigger] mode = hover ; hover 悬停触发 / hotkey 热键触发 hover_delay_ms = 300 ; 悬停多久算一次取词 hotkey = ctrl+g [translate] api_url = https://your-translate-service.example/api app_key = your_app_key secret_key = your_secret_key from = en to = zh [ui] popup_width = 400 popup_height = 120

参数说明:radius 影响 OCR 输入,想识别更长的词组调大到 60,想减少误触调到 30。hover_delay_ms 是体感最明显的参数——300ms 是个人觉得比较平衡的值,设太小鼠标划过普通文本时频繁弹窗,设太大等待让人烦躁。from 和 to 是语言代码,反过来配to = en就能做成中译英的取词工具,这个项目天然支持双向,只是默认英文翻译成中文。

这里提醒一句:api_url 和密钥不要提交进 git。常见做法是 config.ini 里留模板值,真实密钥放到用户目录的 ~/.config/dict/config.ini,config.c 读取时优先找用户目录,找不到再回退到项目目录。

4.3 验证闭环:一张截图的完整日志与三截排查法

跑起来后先做最小验证:准备一张英文单词的 PNG 图片,用./dict --test sample.png直接喂给 OCR 和翻译链路,绕开截图部分。这个测试模式在 main.c 里,输出格式类似:

[trace] OCR input: sample.png [trace] OCR output: "architecture" [trace] translate query: architecture -> 架构 [trace] popup shown at (834, 512)

如果 OCR 输出为空,先检查 tessdata 路径和语言包是否安装;如果翻译结果为空,先手动 curl 一下你的 API URL 看返回格式是不是和代码里解析的字段一致。把链路拆成截图、识别、翻译三截验证,比一口气 Debug 整个流程省事得多,这是我的血泪经验——第一次跑这个项目时一直怀疑截图模块,结果根因是翻译接口的 salt 重复,白白浪费了半天。

三截都通之后再做悬停测试,用 xdotool 模拟鼠标移动:

# 需要 xdotool 做自动化鼠标控制 xdotool mousemove 800 500 sleep 0.5

把鼠标移到屏幕上任意英文单词上停住,观察 /tmp/dict_tmp.ppm 是否生成、日志是否打印 OCR 结果。如果这一步没反应,八成是主循环没收到 MotionNotify 事件,回去检查 XSelectInput 是否把 PointerMotionMask 订阅到了根窗口上。


5. 避坑指南:屏幕取词项目最常见的 5 个坑

做这个项目过程中踩的坑不少,挑 5 个最典型的记录一下,每条按「现象 → 原因 → 解决」展开。

5.1 XGetImage 一直返回 NULL

现象:程序启动后截图总是失败,日志打[capture] XGetImage failed,但同一个 X server 下其它 GUI 程序运行正常。

原因:两种可能。一是程序跑在 SSH 会话里且 DISPLAY 环境变量没导出,XOpenDisplay 拿到的是空指针;二是 X server 拒绝了这个客户端的访问权限,常见于 xhost 限制了本地来源。

解决:先echo $DISPLAY确认有值,再用xhost +local:放开本地访问权限。如果跑在远程机器上,改ssh -X重连并确认 DISPLAY 正确继承。这个排查顺序能覆盖九成以上的情况,排完还不行再怀疑 X 授权文件的问题。

5.2 高分辨率屏幕上取词位置总偏一截

现象:鼠标停在英文单词上,OCR 识别出的却是上面一行或下面一行的内容,坐标明显「错位」。

原因:X11 的坐标单位是物理像素,但现代桌面在 HiDPI 下会用缩放因子渲染(常见 2.0 或 1.5)。鼠标事件获取到的坐标已经是缩放后的逻辑坐标,直接传给 XGetImage 用的却是物理坐标,两者差了一个缩放倍数,截图区域整体偏移。

解决:读取 Xft.dpi 或通过 Xrandr 查询当前缩放因子,把鼠标坐标乘以该值再传给截图接口。坦白说没有通用检测方法,这个项目里加了一个 scale 配置项,默认 1.0,HiDPI 环境下手动调成 2.0,靠用户配置最省事,别指望自动适配所有桌面环境。

5.3 OCR 把连续代码识别成乱码

现象:取词对象是代码里的标识符,比如get_foo_bar,OCR 结果变成getfoobar或者把下划线丢掉。

原因:OCR 引擎面向印刷体英文训练,对代码字体里的下划线、驼峰命名这种紧凑字符的切分能力有限;代码还常用等宽字体,字符分割模型会误判连贯笔画。

解决:对 OCR 结果做后处理。在 ocr.c 里加一条规则:如果识别结果里同时包含字母和数字、且总长小于 32,就保留;否则丢弃。更彻底的办法是跳过 OCR,对支持选中复制的应用用 XSendEvent 模拟 Ctrl+C 取剪贴板,这样准确率百分之百,但需要应用配合,实现成本高不少。

5.4 翻译接口报「签名错误」

现象:日志打印服务端返回错误码,提示 sign check failed,但本地核对签名逻辑和文档描述一致。

原因:经典的「salt 重放」问题。一开始写的代码用固定 salt,服务端判定是重复请求直接拒绝;还有一次把 sign_src 的拼接顺序写错,MD5 出来必然对不上账。

解决:salt 用时间戳加进程号拼接,保证每次请求唯一;拼接顺序严格按「appKey + query + salt + secret」来,多一个字符都不行。还有一个隐藏点:query 参与签名时用原始明文,不是 URL 编码后的字符串,很多人折在这——编码发生在拼 post_data 的时候,签名用的是编码前的原文。

5.5 弹窗出现后一闪而过或者带着边框

现象:翻译结果都出来了,但弹窗闪一下就没了;或者弹窗确实出现,却有标题栏和边框,看起来像普通窗口。

原因:一闪而过是因为事件循环提前退出——窗口映射后 X server 会发来 Expose 事件,如果事件循环只等 KeyPress、没把 Expose 处理掉就 break,窗口马上被销毁;带着边框则说明 override_redirect 没生效,XChangeWindowAttributes 的 value_mask 写错或者根本没调。

解决:事件循环里把 Expose 事件 continue 掉,只在 KeyPress 时退出;设置 override_redirect 时 value_mask 用 CWOverrideRedirect。另外 XFlush 之后窗口未必立刻完成映射,必要时加一个很短的重试等待,土办法但有效,尤其是窗口管理器比较慢的轻量桌面上。


6. 二次开发:把这个工具改造成你自己的取词插件

拿到源码别急着跑完就丢,这个项目的模块边界很干净,改造空间很大。最常见的三条路是改触发、换翻译、存历史。

改触发方式是性价比最高的一步。默认的悬停模式在终端和编辑器里误触率不低,我一般会改成「按住 Ctrl 再移动鼠标才触发」。实现上就是放弃 MotionNotify 时间戳判断,改在 main.c 里监听 KeyPress 拿到 Ctrl 状态,再配合 XQueryPointer 获取当前光标位置。这样日常划代码时完全无感,真需要取词时按一下 Ctrl 就行,改动量大概三四十行。

换翻译后端是第二优先级的改造。默认实现走公网翻译服务,内网环境或者不想联网时用不了。离线方案可以接本地词典库:把 translate_word 的实现从一个 HTTP 函数换成 SQLite 查询,接口签名和返回类型保持char *,main.c 一行不用改。我实际做过一次,词典命中率对常见英文单词超过八成,查不到的词再回调公网接口做兜底,相当于两级缓存。

加生词本是让工具从「查完就忘」变成「越用越值」的关键一步。在 translate 成功后,把 word、释义、时间戳插进一张 SQLite 表,弹窗里加一个「按 S 收藏」的按键。事件循环原本只等 KeyPress,改成判断按键的 keycode 是不是 S 键,是就写库。折腾完这个功能我才意识到,屏幕取词的价值一半在即时翻译,另一半在沉淀生词,否则关掉弹窗等于白查。

验证改造是否合格,别用肉眼盯着屏幕看。我自己的习惯是每次改动后强制跑三件事:一是./dict --test sample.png验证 OCR 到翻译的链路没断;二是用 xdotool 模拟一段悬停操作,确认不会误触发;三是把日志级别调到最细,盯一遍完整取词流程,确认每一步的中间产物都符合预期。从那以后我每次改代码都走这三步验证,哪怕只动了一行配置读取也不跳过。希望帮到你,取词工具真正好不好用,全看这些细节抠得够不够。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询