From 4cc30109fa8a10de74a4cbe677ffd648e152dcbc Mon Sep 17 00:00:00 2001 From: "J.S.Patrick" <980141374@qq.com> Date: Wed, 23 Sep 2026 11:10:46 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=88=AA?= =?UTF-8?q?=E5=9B=BE=E7=BF=BB=E8=AF=91=E5=8A=9F=E8=83=BD=E4=B8=8E=20Provid?= =?UTF-8?q?er=20=E6=A1=A5=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Provider 桥接:原生层(任意 native 线程)可反向调用 JS 侧注册的方法(翻译/OCR 等 provider) - 截图翻译(Windows):编辑态工具栏新增「翻译」按钮,原生编排 OCR(行级坐标)→ 逐行翻译 → 译文覆盖原文字区域 - 翻译聚类算法(translate_cluster)将 OCR 行块聚合为段落级译区,减少覆盖碎块 - 新增轻量 logger 及 JS 绑定,便于原生层日志输出 - 新增 test-translate / test-provider-bridge / test-logger 测试与 translate-cluster 自测工具 --- .gitignore | 1 + README.md | 163 ++- binding.gyp | 2 + index.js | 183 +++ scripts/diag-ocr.cjs | 36 + scripts/run-translate-cluster-selftest.cmd | 22 + src/binding_mac.cpp | 43 + src/binding_windows.cpp | 107 +- src/logger.h | 396 ++++++ src/logger_binding.h | 129 ++ src/provider_bridge.h | 395 ++++++ src/screenshot/algo/translate_cluster.cpp | 377 +++++ .../algo/translate_cluster_internal.h | 46 + src/screenshot/windows/capture_context.h | 11 + src/screenshot/windows/capture_windows.cpp | 4 + src/screenshot/windows/internal.h | 25 +- src/screenshot/windows/lc_session_windows.cpp | 16 +- src/screenshot/windows/output_windows.cpp | 21 +- .../windows/overlay_input_windows.cpp | 17 +- .../windows/overlay_paint_windows.cpp | 3 + src/screenshot/windows/sc_types.h | 34 + src/screenshot/windows/session_windows.cpp | 30 + src/screenshot/windows/translate_windows.cpp | 1212 +++++++++++++++++ src/screenshot/windows/wndproc_windows.cpp | 9 +- test/test-logger.js | 121 ++ test/test-ocr.png | Bin 0 -> 2973 bytes test/test-provider-bridge.js | 104 ++ test/test-translate.js | 123 ++ test/translate-cluster-selftest.cpp | 187 +++ 29 files changed, 3799 insertions(+), 18 deletions(-) create mode 100644 scripts/diag-ocr.cjs create mode 100644 scripts/run-translate-cluster-selftest.cmd create mode 100644 src/logger.h create mode 100644 src/logger_binding.h create mode 100644 src/provider_bridge.h create mode 100644 src/screenshot/algo/translate_cluster.cpp create mode 100644 src/screenshot/algo/translate_cluster_internal.h create mode 100644 src/screenshot/windows/translate_windows.cpp create mode 100644 test/test-logger.js create mode 100644 test/test-ocr.png create mode 100644 test/test-provider-bridge.js create mode 100644 test/test-translate.js create mode 100644 test/translate-cluster-selftest.cpp diff --git a/.gitignore b/.gitignore index d1d2522..82918d5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ build/ *.dylib *.log +*.obj .DS_Store # 构建产物:由 scripts/gen-icons.js 从 src/assets/*.svg 生成 diff --git a/README.md b/README.md index 888a0b6..3aa70e7 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ macOS 和 Windows 原生 API 的 Node.js 封装,使用 Swift + Win32 API + Nod 11. **鼠标模拟** - 模拟鼠标移动、点击操作 12. **取色器** - 全屏取色工具 13. **设置文件窗口地址栏** - 跳转 Finder/Explorer 或文件选择对话框到指定路径 +14. **Provider 桥接** - 让原生层(任意 native 线程上的 C++/Swift 代码)调用 JS 侧注册的方法(如宿主应用的翻译 / OCR 提供商) +15. **截图翻译** - 编辑态工具栏「翻译」按钮:原生编排 OCR(行级坐标)→ 逐行翻译 → 译文覆盖原文字区域(依赖 Provider 桥接,Windows) ## 🔧 系统要求 @@ -341,7 +343,7 @@ WindowManager.simulatePaste(); **平台差异**: - **交互流程一致**:全屏暗化覆盖层 + 拖拽选区/单击智能窗口吸附 → autoConfirm=true 松手直接出图, autoConfirm=false 进入编辑态(工具栏 16 按钮、矩形/圆形/箭头/画笔/文字/马赛克标注、撤销/重做、 - 选区圆角手柄)→ 确定/保存/取消/长截图 + 选区圆角手柄)→ 确定/保存/取消/长截图/翻译(翻译仅 Windows,依赖 Provider 桥接,见下文) - **macOS**: - 需要屏幕录制权限(未授权时首次 `start()` 弹系统授权框,拒绝后回调 `{ success: false, error: ... }`) - 另需辅助功能权限:ESC/右键兜底取消(覆盖层失焦时仍可取消)、长截图滚轮观察与自动滚动(CGEventTap) @@ -427,6 +429,161 @@ contents.forEach((item, index) => { - ✅ 支持文件资源管理器/Finder 中选中的文件 - ✅ 支持图像编辑器中选中的图像区域 +### Provider 桥接(原生层调用 JS 方法) + +让原生层(任意 native 线程上的 C++/Swift 代码)反向调用 JS 侧注册的方法,并获得异步结果。 +典型场景:宿主应用把 provider 能力(如 `providerManager.invoke('translation' / 'ocr')`) +交给原生模块,原生业务(截图工具条、事件钩子等)在 native 线程上直接发起调用。 + +JS 侧注册 handler: + +```javascript +const { ProviderBridge } = require('ztools-native-api'); + +// 注册后,原生层即可调用这里注册的能力;返回值自动 JSON 序列化回传 +ProviderBridge.start(async (type, input) => { + switch (type) { + case 'translation': + return { text: '你好' }; + case 'ocr': + // 截图翻译依赖行级坐标块,见下文「截图翻译」 + return { text: '识别到的文字', blocks: [{ text: 'Hello', left: 10, top: 5, right: 90, bottom: 25 }] }; + default: + throw new Error('unknown provider type: ' + type); // 原生侧收到该错误 + } +}); + +// 从 JS 侧验证链路(走真实原生线程路径): +const result = await ProviderBridge.invokeFromNative('translation', { text: 'hello' }); + +// 不再需要时停止(等待中的原生调用会立即收到错误) +ProviderBridge.stop(); +``` + +原生侧(C++)调用方式(见 `src/provider_bridge.h`): + +```cpp +#include "provider_bridge.h" + +// 在任意 native 线程上(严禁在 JS 主线程调用,内部会拒绝): +ztools_provider_bridge::InvokeResult r = + ztools_provider_bridge::Invoke("translation", "{\"text\":\"hello\"}", 5000); +if (r.ok) { + // r.value 为 JS handler 返回值的 JSON 字符串 +} +``` + +约束与行为: +- 原生调用阻塞等待 JS 结果,超时(默认 15s,可指定)返回 `timed out`;迟到的 JS 结果会被丢弃 +- handler 抛错 / reject 会以错误字符串回传原生侧 +- 支持并发调用(按 `seq` 配对请求与结果);`stop()` 会立即让等待中的调用失败 +- 数据以 JSON 字符串穿越桥接,请保持入参/返回值可 JSON 序列化 + +### 截图翻译(编辑态工具栏「翻译」按钮,依赖 Provider 桥接) + +编辑态(`autoConfirm: false`)工具栏的「翻译」按钮:点击后原生层自动完成 +**OCR 识别选区文字 → 逐行翻译 → 译文覆盖原文字区域**,确认/保存导出的图像同样包含译文覆盖。 +覆盖展示中再次点击「翻译」会清掉旧结果并按当前选区重跑。 + +编排发生在原生层:先调 type=`ocr` 拿到带行级坐标(top/left/right/bottom)的文本行数组, +再对每一行分别调 type=`translation`(translation 契约入参为单条 text,多行需逐行请求), +最后在原生侧把译文与坐标合并、按行高适配字号渲染。OCR/翻译渠道沿用用户在设置页 +选择的默认提供商——宿主只需把桥接类型透传给 providerManager: + +```javascript +ProviderBridge.start(async (type, input) => { + // 宿主透传(示意):OCR/翻译渠道由用户在设置页选择的默认 provider 决定 + if (type === 'ocr') return providerManager.invoke('ocr', input); + if (type === 'translation') return providerManager.invoke('translation', input); + throw new Error('unknown provider type: ' + type); +}); +``` + +provider 约定(原生侧已按此实现): + +```javascript +// type = 'ocr':入参为选区裁剪图(物理像素分辨率) +// { image: 'data:image/png;base64,...' } +// 返回行级文本块,坐标为提交图像内像素坐标(兼容 x/y/width/height 命名): +{ + text: 'Hello World\n第二行', + blocks: [ + { text: 'Hello World', left: 24, top: 33, right: 395, bottom: 66 }, + { text: '第二行', left: 24, top: 80, right: 260, bottom: 112 }, + ], + confidence: 0.98, +} + +// type = 'translation':入参单条文本(from/to 缺省用 provider 默认) +// { text: 'Hello World' } +// 返回: +{ text: '你好,世界' } +``` + +- OCR 未返回坐标(blocks 为字符串数组或仅有 text,如纯文本 AI 识别)时走整图兜底: + 整段文本一次翻译,以整个选区为单一覆盖面板 +- 逐行翻译串行执行;单行失败跳过该行(日志可见),全部失败才整体报错 + +交互细节: +- 点击「翻译」→ 选区上方出现「正在识别并翻译…」进度气泡(OCR 超时 30 秒、单行翻译超时 15 秒) +- 成功 → 每个文字行以白底圆角面板盖住原文并排入译文,字号按行高适配 + (装不下时自动扩展/缩小字号/省略号截断) +- 失败(OCR/翻译出错、未识别到文字、超时等)→ 深红文字错误气泡,约 4 秒后自动消失 +- **平台**: ✅ Windows(macOS 编辑态工具栏暂无翻译按钮) + + +## 🗒️ 原生日志(Logger) + +原生模块不再「黑盒」:绑定层、截图会话、剪贴板/窗口/鼠标监控、快捷键、翻译(OCR/翻译 +provider 调用)等关键调用、状态变化与失败路径都会写入日志文件,便于用户侧排查问题。 + +### 日志文件 + +- 位置:**系统临时目录**下的 `ztools-native.log` + - Windows: `%TEMP%\ztools-native.log`(`GetTempPathW`) + - macOS: `$TMPDIR/ztools-native.log`(兜底 `/tmp`) +- 大小限制:**当前文件最大 10MB**;写满时整体轮转为 `ztools-native.log.old` + (先删除旧 `.old` 再改名,任何时刻最多两个文件) +- 每条日志单次写入并立即落盘,原生崩溃时已写内容不丢 +- 格式:`2026-09-22 13:14:39.040 [info] [tid 30828] [core] === ztools_native loaded (pid=40144, win32) ===` +- 多进程(宿主 + 测试脚本)同时写同一文件时按行追加,偶发交错可接受 + +### 日志等级 + +`trace < debug < info < warn < error`(另有 `off` = 完全关闭),默认 `info`。 +两种控制方式: + +```bash +# 方式一:环境变量(进程启动前设置) +ZTOOLS_LOG_LEVEL=debug node app.js +``` + +```javascript +// 方式二:JS 运行时设置(覆盖环境变量) +const { Logger } = require('ztools-native-api'); + +Logger.setLevel('debug'); // 运行时切换等级 +Logger.getLevel(); // -> 'debug' +Logger.isLevelEnabled('trace'); // -> true +Logger.getPath(); // -> 日志文件绝对路径(可展示给用户) +Logger.write('info', 'app', '与原生日志同一文件,统一时间线'); +``` + +埋点覆盖(tag 说明): + +| tag | 内容 | +|-----|------| +| `core` | 模块加载(含 pid/平台)、Swift 动态库加载(macOS) | +| `clipboard` | 监控启停、暂停/恢复、防抖后的剪贴板变化 | +| `window` | 监控启停、前台切换/激活结果 | +| `mouse` | 监控启停、长按触发 | +| `shortcut` | 快捷键监听启停、注册/注销结果、热键触发 | +| `screenshot` | 会话请求/预抓帧/覆盖层创建/初始化失败/结果输出 | +| `longcapture` | 长截图开始/中止/完成(帧数与输出尺寸) | +| `translate` | OCR/翻译请求、provider 调用失败、结果块数 | +| `provider` | Provider 桥接启停、每次 invoke 的结果/超时 | +| `uwp` | 包监听启停、install/update/uninstall 事件、应用启动 | +| `logger` | 通过 JS setLogLevel 的等级变更 | ## 🧪 测试 @@ -437,7 +594,11 @@ npm test node test/test-keyboard.js # 完整键盘测试 node test/test-keyboard-simple.js # 简单键盘测试 node test/test-selected-content.js # 获取选中内容测试 +node test/test-provider-bridge.js # Provider 桥接(原生层调用 JS 方法)测试 node test/test-screenshot-mac.js # macOS 区域截图交互测试(真机手工验收,见脚本头说明) +node test/test-translate.js # 截图翻译交互测试(mock provider,真机手工验收,见脚本头说明; + # MODE=plain 环境变量切换无坐标兜底模式) +node test/test-logger.js # 原生日志(等级/文件/轮转)测试 ``` ## ⚠️ 平台差异 diff --git a/binding.gyp b/binding.gyp index bcf3914..83da16d 100644 --- a/binding.gyp +++ b/binding.gyp @@ -38,8 +38,10 @@ "src/screenshot/windows/annotations_windows.cpp", "src/screenshot/windows/mosaic_windows.cpp", "src/screenshot/windows/output_windows.cpp", + "src/screenshot/windows/translate_windows.cpp", "src/screenshot/algo/lc_match_core.cpp", "src/screenshot/algo/lc_stitch_state.cpp", + "src/screenshot/algo/translate_cluster.cpp", "src/screenshot/windows/lc_frame_io_windows.cpp", "src/screenshot/windows/lc_panel_ui_windows.cpp", "src/screenshot/windows/lc_toolbar_ui_windows.cpp", diff --git a/index.js b/index.js index 98e59f1..83b83a7 100644 --- a/index.js +++ b/index.js @@ -4,6 +4,91 @@ const os = require('os'); const addon = require('./build/Release/ztools_native.node'); const platform = os.platform(); +/** + * 原生层日志管理。 + * + * 原生模块(N-API 绑定、截图会话、监控线程等)的关键调用、状态变化与失败路径 + * 都会写入系统临时目录下的 ztools-native.log(不超过 10MB,写满后整体轮转为 + * ztools-native.log.old)。通过本类可查询/修改日志等级、获取日志文件路径, + * 也可以让 JS 侧把日志写进同一个文件(统一时间线,便于与原生日志对齐排查)。 + * + * 等级:trace < debug < info < warn < error(另有 off = 完全关闭)。 + * 默认 info;可用环境变量 ZTOOLS_LOG_LEVEL(进程启动前设置)或 setLevel() + * (运行时覆盖)控制。 + */ +class Logger { + /** + * 支持的日志等级列表 + * @returns {string[]} ['trace', 'debug', 'info', 'warn', 'error', 'off'] + */ + static get levels() { + return ['trace', 'debug', 'info', 'warn', 'error', 'off']; + } + + /** + * 运行时设置日志输出等级(覆盖 ZTOOLS_LOG_LEVEL 环境变量) + * @param {string} level - 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'off' + * @throws {TypeError} level 不是合法等级字符串时抛出 + */ + static setLevel(level) { + if (typeof level !== 'string' || !Logger.levels.includes(level.toLowerCase())) { + throw new TypeError(`level must be one of: ${Logger.levels.join(', ')}`); + } + addon.setLogLevel(level.toLowerCase()); + } + + /** + * 查询当前日志输出等级 + * @returns {string} 当前等级字符串 + */ + static getLevel() { + return addon.getLogLevel(); + } + + /** + * 查询某等级当前是否会被写入 + * @param {string} level - 等级字符串 + * @returns {boolean} 该等级日志当前是否输出 + */ + static isLevelEnabled(level) { + if (typeof level !== 'string' || !Logger.levels.includes(level.toLowerCase())) { + throw new TypeError(`level must be one of: ${Logger.levels.join(', ')}`); + } + return addon.isLogLevelEnabled(level.toLowerCase()); + } + + /** + * 获取原生日志文件完整路径(系统临时目录下 ztools-native.log) + * @returns {string} 日志文件绝对路径 + * @example + * console.log('原生日志:', Logger.getPath()); + */ + static getPath() { + return addon.getLogFilePath(); + } + + /** + * 向原生日志文件写入一条日志(与原生日志同一文件、同一格式) + * @param {string} level - 'trace' | 'debug' | 'info' | 'warn' | 'error' + * @param {string} tag - 短模块名(如 'app') + * @param {string} message - 消息内容(不建议包含超大文本/敏感信息) + * @example + * Logger.write('info', 'app', '截图流程开始'); + */ + static write(level, tag, message) { + if (typeof level !== 'string' || !Logger.levels.slice(0, 5).includes(level.toLowerCase())) { + throw new TypeError(`level must be one of: ${Logger.levels.slice(0, 5).join(', ')}`); + } + if (typeof tag !== 'string' || !tag) { + throw new TypeError('tag must be a non-empty string'); + } + if (typeof message !== 'string') { + throw new TypeError('message must be a string'); + } + addon.logWrite(level.toLowerCase(), tag, message); + } +} + class ClipboardMonitor { constructor() { this._callback = null; @@ -494,6 +579,9 @@ class ScreenCapture { * 拼接无帧数/像素上限,可持续合并至用户主动结束 * @param {number} [options.longCapture.interval=250] - 滚轮停止后等待内容稳定的毫秒数(50~2000,采样防抖; * 滚动进行中也会按不低于 min(interval, 250)ms 的节拍主动采样,保证相邻帧有大重叠区域) + * 编辑态工具栏另有「翻译」按钮(仅 Windows):OCR 识别选区文字 → 翻译 → 译文覆盖原文字区域, + * 依赖 ProviderBridge 注册默认启用的 ocr/translation provider(约定见 README「截图翻译」); + * 确认/保存导出的图像同样包含译文覆盖 * @param {Function} [callback] - 截图完成时的回调函数 * - 参数: { success: boolean, x?: number, y?: number, x2?: number, y2?: number, width?: number, height?: number, base64?: string, error?: string } * - success: 是否成功截图 @@ -729,8 +817,102 @@ function launchCuiShell(shell, currentDirectory) { return addon.launchCuiShell(shell, currentDirectory); } +/** + * Provider 桥接层:让原生层(任意 native 线程上的 C++/Swift 代码)调用 JS 侧注册的方法。 + * + * 典型用法(宿主侧,如 ZTools 主进程把 provider 能力交给原生层): + * const { ProviderBridge } = require('ztools-native-api'); + * ProviderBridge.start(async (type, input) => { + * // type: 'translation' | 'ocr' 等(由宿主与原生侧自行约定) + * // 返回值会以 JSON 序列化后回传给原生线程 + * return await providerManager.invoke(type, input); + * }); + * + * 内置约定(Windows 截图工具栏「翻译」按钮依赖以下两个类型,详见 README「截图翻译」): + * - ocr: { image } => { text, blocks: [{ text, left, top, right, bottom }] } + * (blocks 为行级坐标块;provider 只回整段 text 时原生自动走整图兜底) + * - translation: { text } => { text }(OCR 多行结果由原生逐行分别调用) + * + * 原生侧(C++/Swift)调用方式见 src/provider_bridge.h 的 + * ztools_provider_bridge::Invoke(type, inputJson, timeoutMs)。 + * + * 注意:原生侧的 Invoke 严禁在 JS 主线程上调用(内部检测并直接报错),事件回调 + * 线程、消息循环线程等 native 线程均可安全调用。 + */ +const ProviderBridge = { + /** + * 启动桥接,注册 JS 侧 handler。重复启动会抛错;stop 之后可重新 start。 + * @param {Function} handler - (type: string, input: any) => Promise; + * 返回值(或 reject 的错误信息)会回传给发起调用的原生线程 + * @returns {void} 无返回值 + * @throws {TypeError} handler 不是函数时抛出 + * @throws {Error} 桥接已启动时抛出 + */ + start(handler) { + if (typeof handler !== 'function') { + throw new TypeError('ProviderBridge.start requires a handler function'); + } + addon.startProviderBridge((type, inputJson, seq) => { + Promise.resolve() + .then(() => handler(type, JSON.parse(inputJson))) + .then((result) => { + addon.resolveProviderBridge(seq, JSON.stringify(result === undefined ? null : result)); + }) + .catch((err) => { + addon.rejectProviderBridge(seq, err instanceof Error ? err.message : String(err)); + }); + }); + }, + + /** + * 停止桥接并丢弃 handler。已在等待的原生调用会立即收到 "provider bridge stopped" 错误。 + * @returns {void} 无返回值 + */ + stop() { + addon.stopProviderBridge(); + }, + + /** + * 查询桥接是否就绪。 + * @returns {boolean} 已 start 且未 stop 时为 true + */ + isReady() { + return addon.isProviderBridgeReady(); + }, + + /** + * 从 JS 侧发起一次“原生发起”的调用:走真实原生线程 → JS 线程的完整通路, + * 用于验证桥接链路(原生业务代码应直接调用 C++ 的 Invoke,而不是本方法)。 + * @param {string} type - 能力类型 + * @param {any} input - 入参(自动 JSON 序列化传给 handler) + * @param {number} [timeoutMs=15000] - 原生侧等待结果的超时毫秒数 + * @returns {Promise} handler 的返回值;超时或 handler 抛错时 Promise 被 reject + */ + invokeFromNative(type, input, timeoutMs = 15000) { + return new Promise((resolve, reject) => { + addon.invokeProviderFromNative( + type, + JSON.stringify(input === undefined ? null : input), + timeoutMs, + (err, resultJson) => { + if (err) { + reject(new Error(err)); + return; + } + try { + resolve(JSON.parse(resultJson)); + } catch (parseErr) { + reject(parseErr); + } + } + ); + }); + } +}; + // 导出所有类 module.exports = { + Logger, ClipboardMonitor, WindowMonitor, WindowManager, @@ -741,6 +923,7 @@ module.exports = { UwpManager, MuiResolver, WindowsShortcutScanner, + ProviderBridge, getSelectedContent, launchCuiShell }; diff --git a/scripts/diag-ocr.cjs b/scripts/diag-ocr.cjs new file mode 100644 index 0000000..b4b7cc3 --- /dev/null +++ b/scripts/diag-ocr.cjs @@ -0,0 +1,36 @@ +// 诊断脚本:在本机直接加载 dist 里安装的微信 OCR addon + 新 preload 的 ocrRecognize, +// 对真实图片跑一次识别,打印返回的 blocks 结构(验证坐标字段是否到位)。 +'use strict' +const fs = require('node:fs') +const path = require('node:path') + +const ROOT = path.resolve(__dirname, '..') +const PLUGIN = path.resolve(ROOT, '..', 'ZTools-plugins-collection', 'f-provider', 'dist') +const src = fs.readFileSync(path.join(PLUGIN, 'preload', 'services.js'), 'utf8') + +const sandboxWindow = { __registered: {} } +sandboxWindow.ztools = { + registerProvider: (k, h) => { sandboxWindow.__registered[k] = h }, +} +new Function('sandboxWindow', 'require', ` + const window = sandboxWindow + const ztools = window.ztools + ${src} +`)(sandboxWindow, require) + +const services = sandboxWindow.services +// 诊断环境无宿主 userData:直接把 native 目录指向插件 dist 内置 addon(与宿主 +// 下载到 userData 后的布局同构),绕过 getPath 解析。 +services._nativeDir = () => path.join(PLUGIN, 'native') +services.getTranslateSettings = () => ({ appID: 'x', appKey: 'y', appSecret: 'z', model: '', systemPrompt: '' }) + +async function main() { + const image = path.join(ROOT, 'test', 'test-ocr.png') + const ocr = await services.ocrRecognize(image) + console.log('text =', JSON.stringify(ocr.text)) + console.log('blocks =', JSON.stringify(ocr.blocks, null, 2)) + console.log('confidence =', ocr.confidence) + services.ocrDispose() +} + +main().catch((e) => { console.error('FAILED:', e); process.exit(1) }) diff --git a/scripts/run-translate-cluster-selftest.cmd b/scripts/run-translate-cluster-selftest.cmd new file mode 100644 index 0000000..9a986e0 --- /dev/null +++ b/scripts/run-translate-cluster-selftest.cmd @@ -0,0 +1,22 @@ +@echo off +rem Run the offline selftest for the translate line-clustering algorithm: +rem compiles the pure algorithm unit together with the selftest into a +rem standalone executable with MSVC and runs it (no node / node-gyp needed). +rem Usage: scripts\run-translate-cluster-selftest.cmd +setlocal +cd /d "%~dp0.." + +call "%ProgramFiles%\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvars64.bat" >nul 2>&1 +if errorlevel 1 ( + echo [selftest] vcvars64 not found, MSVC 2022 Community required & exit /b 1 +) +if not exist build mkdir build + +rem /Fo:build\ 让 cl 的中间 .obj 落在 build/(已 gitignore),避免污染仓库根目录 +cl /nologo /EHsc /std:c++17 /W4 /utf-8 ^ + /I src\screenshot\algo ^ + test\translate-cluster-selftest.cpp src\screenshot\algo\translate_cluster.cpp ^ + /Fe:build\translate-cluster-selftest.exe /Fo:build\ +if errorlevel 1 exit /b 1 + +build\translate-cluster-selftest.exe diff --git a/src/binding_mac.cpp b/src/binding_mac.cpp index 12f0d14..2ebb781 100644 --- a/src/binding_mac.cpp +++ b/src/binding_mac.cpp @@ -6,6 +6,9 @@ #include #include // For usleep +#include "provider_bridge.h" +#include "logger_binding.h" + // Swift 动态库函数类型定义 typedef void (*ClipboardCallback)(); // 无参数回调 typedef void (*WindowCallback)(const char *); // 带JSON字符串参数回调 @@ -95,6 +98,7 @@ void CallJs(napi_env env, napi_value js_callback, void *context, void *data) { // Swift 回调 -> 推送到线程安全队列 void OnClipboardChanged() { if (tsfn != nullptr && !g_isPaused) { + ZLOG_DEBUG("clipboard", "changed -> notify JS"); // 不需要传递数据 napi_call_threadsafe_function(tsfn, nullptr, napi_tsfn_nonblocking); } @@ -149,6 +153,7 @@ void CallWindowJs(napi_env env, napi_value js_callback, void *context, // Swift 窗口回调 -> 推送到线程安全队列 void OnWindowChanged(const char *jsonStr) { if (windowTsfn != nullptr && jsonStr != nullptr) { + ZLOG_DEBUG("window", "changed -> notify JS"); // 复制字符串 char *jsonCopy = strdup(jsonStr); napi_call_threadsafe_function(windowTsfn, jsonCopy, napi_tsfn_nonblocking); @@ -193,6 +198,7 @@ bool LoadSwiftLibrary(Napi::Env env) { for (const auto &path : paths) { swiftLibHandle = dlopen(path.c_str(), RTLD_NOW); if (swiftLibHandle != nullptr) { + ZLOG_INFO("core", "swift library loaded: %s", path.c_str()); break; } lastError = dlerror(); @@ -207,6 +213,7 @@ bool LoadSwiftLibrary(Napi::Env env) { } errorMsg += "Last error: " + lastError; + ZLOG_ERROR("core", "%s", errorMsg.c_str()); Napi::Error::New(env, errorMsg).ThrowAsJavaScriptException(); return false; } @@ -273,6 +280,7 @@ bool LoadSwiftLibrary(Napi::Env env) { !setClipboardFilesFunc || !fetchFileIconFunc || !primeScreenshotFrameFunc || !startRegionCaptureFunc || !abortLongCaptureFunc) { + ZLOG_ERROR("core", "failed to load Swift function symbols"); Napi::Error::New(env, "Failed to load Swift functions") .ThrowAsJavaScriptException(); dlclose(swiftLibHandle); @@ -314,6 +322,7 @@ Napi::Value StartMonitor(const Napi::CallbackInfo &info) { // 启动 Swift 监控 startMonitorFunc(OnClipboardChanged); + ZLOG_INFO("clipboard", "monitor started (swift)"); return env.Undefined(); } @@ -332,6 +341,7 @@ Napi::Value StopMonitor(const Napi::CallbackInfo &info) { } g_isPaused = false; // 重置暂停状态 + ZLOG_INFO("clipboard", "monitor stopped"); return env.Undefined(); } @@ -340,6 +350,7 @@ Napi::Value StopMonitor(const Napi::CallbackInfo &info) { Napi::Value PauseMonitor(const Napi::CallbackInfo &info) { Napi::Env env = info.Env(); g_isPaused = true; + ZLOG_DEBUG("clipboard", "paused"); return env.Undefined(); } @@ -347,6 +358,7 @@ Napi::Value PauseMonitor(const Napi::CallbackInfo &info) { Napi::Value ResumeMonitor(const Napi::CallbackInfo &info) { Napi::Env env = info.Env(); g_isPaused = false; + ZLOG_DEBUG("clipboard", "resumed"); return env.Undefined(); } @@ -632,11 +644,13 @@ Napi::Value GetActiveWindow(const Napi::CallbackInfo &info) { char *jsonStr = getActiveWindowFunc(); if (jsonStr == nullptr) { + ZLOG_DEBUG("window", "getActiveWindow -> null"); return env.Null(); } std::string jsonString(jsonStr); free(jsonStr); + ZLOG_DEBUG("window", "getActiveWindow -> %s", jsonString.c_str()); return ParseJsonValue(env, jsonString); } @@ -656,6 +670,8 @@ Napi::Value ActivateWindow(const Napi::CallbackInfo &info) { std::string bundleId = info[0].As().Utf8Value(); int success = activateWindowFunc(bundleId.c_str()); + ZLOG_INFO("window", "activate \"%s\" -> %s", bundleId.c_str(), + success == 1 ? "ok" : "failed"); return Napi::Boolean::New(env, success == 1); } @@ -691,6 +707,7 @@ Napi::Value StartWindowMonitor(const Napi::CallbackInfo &info) { // 启动 Swift 窗口监控 startWindowMonitorFunc(OnWindowChanged); + ZLOG_INFO("window", "monitor started (swift)"); return env.Undefined(); } @@ -708,6 +725,7 @@ Napi::Value StopWindowMonitor(const Napi::CallbackInfo &info) { windowTsfn = nullptr; } + ZLOG_INFO("window", "monitor stopped"); return env.Undefined(); } @@ -1417,6 +1435,7 @@ Napi::Value PrimeScreenshotFrame(const Napi::CallbackInfo &info) { } const bool success = primeScreenshotFrameFunc() == 1; + ZLOG_DEBUG("screenshot", "prime frame -> %d", success ? 1 : 0); return Napi::Boolean::New(env, success); } @@ -1433,6 +1452,7 @@ Napi::Value StartRegionCaptureWithPrimedFrame(const Napi::CallbackInfo &info) { // 重入保护(对齐 Windows g_isCapturing):会话进行中再次 start 直接抛错 if (g_screenshotInProgress) { + ZLOG_WARN("screenshot", "start requested while a session is already in progress"); Napi::Error::New(env, "Screenshot already in progress") .ThrowAsJavaScriptException(); return env.Undefined(); @@ -1484,6 +1504,7 @@ Napi::Value StartRegionCaptureWithPrimedFrame(const Napi::CallbackInfo &info) { env, callback, nullptr, resource_name, 0, 1, nullptr, nullptr, nullptr, CallScreenshotJs, &screenshotTsfn); if (status != napi_ok) { + ZLOG_ERROR("screenshot", "create threadsafe function failed"); Napi::Error::New(env, "Failed to create threadsafe function") .ThrowAsJavaScriptException(); return env.Undefined(); @@ -1497,10 +1518,13 @@ Napi::Value StartRegionCaptureWithPrimedFrame(const Napi::CallbackInfo &info) { "}}"; g_screenshotInProgress = true; + ZLOG_INFO("screenshot", "capture session requested (autoConfirm=%d, longCaptureInterval=%dms)", + autoConfirm ? 1 : 0, lcInterval); int accepted = startRegionCaptureFunc(optionsJson.c_str(), OnScreenshotResult); if (accepted != 1) { // Swift 拒绝受理(重入兜底/参数非法):回滚会话状态,保证下次 start 可用; // 此路径 Swift 不会回调,TSFN 必须就地释放防泄漏 + ZLOG_WARN("screenshot", "swift rejected capture session"); ReleaseScreenshotTsfn(); g_screenshotInProgress = false; } @@ -1526,6 +1550,10 @@ Napi::Value AbortLongCapture(const Napi::CallbackInfo &info) { // 模块初始化 Napi::Object Init(Napi::Env env, Napi::Object exports) { + // 应用 ZTOOLS_LOG_LEVEL 环境变量(须在其他日志写入前调用) + ztools_log::InitFromEnv(); + ZLOG_INFO("core", "=== ztools_native loaded (pid=%u, darwin) ===", ztools_log::ProcessId()); + ZLOG_DEBUG("core", "log file: %s", ztools_log::FilePath().c_str()); exports.Set("startMonitor", Napi::Function::New(env, StartMonitor)); exports.Set("stopMonitor", Napi::Function::New(env, StopMonitor)); exports.Set("pauseMonitor", Napi::Function::New(env, PauseMonitor)); @@ -1564,6 +1592,21 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) { exports.Set("startRegionCaptureWithPrimedFrame", Napi::Function::New(env, StartRegionCaptureWithPrimedFrame)); exports.Set("abortLongCapture", Napi::Function::New(env, AbortLongCapture)); + // Provider 桥接:让原生层(任意 native 线程)调用 JS 侧注册的方法 + exports.Set("startProviderBridge", + Napi::Function::New(env, ztools_provider_bridge::StartProviderBridge)); + exports.Set("stopProviderBridge", + Napi::Function::New(env, ztools_provider_bridge::StopProviderBridge)); + exports.Set("resolveProviderBridge", + Napi::Function::New(env, ztools_provider_bridge::ResolveProviderBridge)); + exports.Set("rejectProviderBridge", + Napi::Function::New(env, ztools_provider_bridge::RejectProviderBridge)); + exports.Set("isProviderBridgeReady", + Napi::Function::New(env, ztools_provider_bridge::IsProviderBridgeReady)); + exports.Set("invokeProviderFromNative", + Napi::Function::New(env, ztools_provider_bridge::InvokeProviderFromNative)); + // 日志管理:等级查询/设置、日志文件路径、JS 侧写入同一日志文件 + ztools_log_binding::Register(env, exports); return exports; } diff --git a/src/binding_windows.cpp b/src/binding_windows.cpp index 18d19ac..72e17a7 100644 --- a/src/binding_windows.cpp +++ b/src/binding_windows.cpp @@ -41,6 +41,8 @@ #pragma comment(lib, "uiautomationcore.lib") #include "screenshot/windows/screenshot_windows.h" +#include "provider_bridge.h" +#include "logger_binding.h" // DWMWA_CLOAKED 在较新的 Windows SDK 中定义,为了兼容性手动定义 #ifndef DWMWA_CLOAKED @@ -176,6 +178,7 @@ LRESULT CALLBACK ClipboardWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lPa KillTimer(hwnd, CLIPBOARD_DEBOUNCE_TIMER_ID); // 仅在未暂停时触发回调 if (g_tsfn != nullptr && !g_isPaused) { + ZLOG_DEBUG("clipboard", "changed -> notify JS (debounced)"); napi_call_threadsafe_function(g_tsfn, nullptr, napi_tsfn_nonblocking); } } @@ -236,6 +239,7 @@ Napi::Value StartMonitor(const Napi::CallbackInfo& info) { ); g_isMonitoring = true; + ZLOG_INFO("clipboard", "monitor starting"); // 启动消息循环线程 g_messageThread = std::thread([]() { @@ -246,6 +250,7 @@ Napi::Value StartMonitor(const Napi::CallbackInfo& info) { wc.lpszClassName = L"ZToolsClipboardMonitor"; if (!RegisterClassW(&wc)) { + ZLOG_ERROR("clipboard", "RegisterClassW failed"); return; } @@ -259,18 +264,21 @@ Napi::Value StartMonitor(const Napi::CallbackInfo& info) { ); if (g_hwnd == NULL) { + ZLOG_ERROR("clipboard", "create message window failed"); UnregisterClassW(L"ZToolsClipboardMonitor", GetModuleHandle(NULL)); return; } // 注册剪贴板监听 if (!AddClipboardFormatListener(g_hwnd)) { + ZLOG_ERROR("clipboard", "AddClipboardFormatListener failed"); DestroyWindow(g_hwnd); UnregisterClassW(L"ZToolsClipboardMonitor", GetModuleHandle(NULL)); return; } // 消息循环 + ZLOG_INFO("clipboard", "monitor started"); MSG msg; while (g_isMonitoring && GetMessageW(&msg, NULL, 0, 0)) { TranslateMessage(&msg); @@ -307,6 +315,7 @@ Napi::Value StopMonitor(const Napi::CallbackInfo& info) { g_tsfn = nullptr; } + ZLOG_INFO("clipboard", "monitor stopped"); return env.Undefined(); } @@ -314,6 +323,7 @@ Napi::Value StopMonitor(const Napi::CallbackInfo& info) { Napi::Value PauseMonitor(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); g_isPaused = true; + ZLOG_DEBUG("clipboard", "paused"); return env.Undefined(); } @@ -321,6 +331,7 @@ Napi::Value PauseMonitor(const Napi::CallbackInfo& info) { Napi::Value ResumeMonitor(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); g_isPaused = false; + ZLOG_DEBUG("clipboard", "resumed"); return env.Undefined(); } @@ -532,8 +543,11 @@ void CALLBACK WinEventProc( WindowInfo* info = GetWindowInfo(hwnd); if (info != nullptr) { g_lastMonitoredTitle = info->title; + ZLOG_DEBUG("window", "foreground changed: %s", info->title.c_str()); // 通过线程安全函数传递到 JS napi_call_threadsafe_function(g_windowTsfn, info, napi_tsfn_nonblocking); + } else { + ZLOG_WARN("window", "GetWindowInfo failed on foreground change"); } } // 处理窗口标题变化事件 @@ -647,11 +661,13 @@ Napi::Value StartWindowMonitor(const Napi::CallbackInfo& info) { ); if (status != napi_ok) { + ZLOG_ERROR("window", "create threadsafe function failed"); Napi::Error::New(env, "Failed to create threadsafe function").ThrowAsJavaScriptException(); return env.Undefined(); } g_isWindowMonitoring = true; + ZLOG_INFO("window", "monitor starting"); // 启动消息循环线程(钩子将在线程内设置) g_windowMessageThread = std::thread(WindowMonitorThread); @@ -661,6 +677,7 @@ Napi::Value StartWindowMonitor(const Napi::CallbackInfo& info) { // 检查是否成功启动 if (!g_isWindowMonitoring) { + ZLOG_ERROR("window", "set window event hook failed"); if (g_windowMessageThread.joinable()) { g_windowMessageThread.join(); } @@ -670,6 +687,8 @@ Napi::Value StartWindowMonitor(const Napi::CallbackInfo& info) { return env.Undefined(); } + ZLOG_INFO("window", "monitor started"); + // 立即回调当前激活的窗口 HWND currentWindow = GetForegroundWindow(); if (currentWindow != NULL) { @@ -677,6 +696,7 @@ Napi::Value StartWindowMonitor(const Napi::CallbackInfo& info) { WindowInfo* info = GetWindowInfo(currentWindow); if (info != nullptr) { g_lastMonitoredTitle = info->title; + ZLOG_DEBUG("window", "current foreground: %s", info->title.c_str()); napi_call_threadsafe_function(g_windowTsfn, info, napi_tsfn_nonblocking); } } @@ -710,6 +730,7 @@ Napi::Value StopWindowMonitor(const Napi::CallbackInfo& info) { g_lastMonitoredWindow = NULL; g_lastMonitoredTitle.clear(); + ZLOG_INFO("window", "monitor stopped"); return env.Undefined(); } @@ -793,6 +814,7 @@ Napi::Value GetActiveWindowInfo(const Napi::CallbackInfo& info) { // 获取前台窗口句柄 HWND hwnd = GetForegroundWindow(); if (hwnd == NULL) { + ZLOG_DEBUG("window", "getActiveWindow: no foreground window"); return env.Null(); } @@ -896,6 +918,7 @@ Napi::Value GetActiveWindowInfo(const Napi::CallbackInfo& info) { // 窗口句柄(用于 COM IShellWindows 查询 Explorer 目录路径) result.Set("hwnd", Napi::Number::New(env, (double)(uint64_t)hwnd)); + ZLOG_DEBUG("window", "getActiveWindow -> hwnd=%p", hwnd); return result; } @@ -947,6 +970,7 @@ Napi::Value ActivateWindow(const Napi::CallbackInfo& info) { EnumWindows(EnumWindowsCallback, (LPARAM)&args); if (args.foundWindow == NULL) { + ZLOG_WARN("window", "activate: no visible window for pid %lu", (unsigned long)processId); return Napi::Boolean::New(env, false); } @@ -990,7 +1014,10 @@ Napi::Value ActivateWindow(const Napi::CallbackInfo& info) { // 验证是否成功 HWND newForeground = GetForegroundWindow(); - return Napi::Boolean::New(env, newForeground == hwnd); + const bool activated = (newForeground == hwnd); + ZLOG_INFO("window", "activate pid %lu -> %s", (unsigned long)processId, + activated ? "ok" : "failed"); + return Napi::Boolean::New(env, activated); } // ==================== 剪贴板文件功能 ==================== @@ -1023,6 +1050,7 @@ Napi::Value GetClipboardFiles(const Napi::CallbackInfo& info) { // 打开剪贴板失败 if (!clipboardOpened) { // Windows 11: 剪贴板可能被系统或其他程序占用 + ZLOG_WARN("clipboard", "getClipboardFiles: open clipboard failed after retries"); return result; // 返回空数组 } @@ -1086,12 +1114,14 @@ Napi::Value GetClipboardFiles(const Napi::CallbackInfo& info) { } CloseClipboard(); + ZLOG_DEBUG("clipboard", "getClipboardFiles -> %u files", (unsigned)fileCount); return result; } // 设置剪贴板中的文件列表 Napi::Value SetClipboardFiles(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); + ZLOG_DEBUG("clipboard", "setClipboardFiles requested"); // 参数验证:需要一个数组参数 if (info.Length() < 1 || !info[0].IsArray()) { @@ -1225,10 +1255,12 @@ Napi::Value SetClipboardFiles(const Napi::CallbackInfo& info) { if (hResult == NULL) { GlobalFree(hGlobal); + ZLOG_WARN("clipboard", "setClipboardFiles: SetClipboardData failed"); return Napi::Boolean::New(env, false); } // 注意:成功后不要释放 hGlobal,剪贴板会接管内存 + ZLOG_DEBUG("clipboard", "setClipboardFiles -> %zu files", filePaths.size()); return Napi::Boolean::New(env, true); } @@ -1516,6 +1548,8 @@ void MouseMonitorThread() { if (elapsed >= g_mouseLongPressMs) { g_mouseLongPressTriggered = true; + ZLOG_INFO("mouse", "long press triggered (%s, %ld ms)", + g_mouseButtonType.c_str(), (long)elapsed); if (g_mouseTsfn != nullptr) { napi_call_threadsafe_function(g_mouseTsfn, nullptr, napi_tsfn_nonblocking); } @@ -1609,6 +1643,9 @@ Napi::Value StartMouseMonitor(const Napi::CallbackInfo& info) { g_mouseReplayOnRelease = false; g_isMouseMonitoring = true; + ZLOG_INFO("mouse", "monitor started (button=%s, longPressMs=%d)", + g_mouseButtonType.c_str(), g_mouseLongPressMs); + // 启动监控线程 g_mouseMessageThread = std::thread(MouseMonitorThread); @@ -1644,6 +1681,7 @@ Napi::Value StopMouseMonitor(const Napi::CallbackInfo& info) { g_mouseButtonType.clear(); g_mouseLongPressMs = 0; + ZLOG_INFO("mouse", "monitor stopped"); return env.Undefined(); } @@ -2003,6 +2041,7 @@ void OptimizedShortcutThread() { OptimizedShortcutTrigger* trigger = new OptimizedShortcutTrigger(); trigger->shortcut = it->second.shortcut; trigger->primed = PrimeScreenshotFrameNow(); + ZLOG_INFO("shortcut", "hotkey triggered: %s", trigger->shortcut.c_str()); if (g_optimizedShortcutTsfn != nullptr) { napi_call_threadsafe_function(g_optimizedShortcutTsfn, trigger, napi_tsfn_nonblocking); } else { @@ -2103,12 +2142,14 @@ Napi::Value EnsureOptimizedShortcutListener(const Napi::CallbackInfo& info) { return env.Undefined(); } + ZLOG_INFO("shortcut", "listener started"); return env.Undefined(); } // 停止 native 优化快捷键监听并释放资源。 Napi::Value StopOptimizedShortcutListener(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); + ZLOG_INFO("shortcut", "listener stopping"); StopOptimizedShortcutListenerInternal("optimized shortcut listener stopped"); return env.Undefined(); } @@ -2176,6 +2217,10 @@ Napi::Value RegisterOptimizedShortcut(const Napi::CallbackInfo& info) { const OptimizedShortcutRegistrationResult result = g_optimizedShortcutRefreshResult; g_optimizedShortcutRefreshCompleted = false; + ZLOG_INFO("shortcut", "register \"%s\" -> %s%s%s", shortcut.c_str(), + result.success ? "ok" : "failed", + result.error.empty() ? "" : " (", result.error.c_str(), + result.error.empty() ? "" : ")"); return CreateOptimizedShortcutRegistrationResult(env, result.success, result.error); } @@ -2241,6 +2286,10 @@ Napi::Value UnregisterOptimizedShortcut(const Napi::CallbackInfo& info) { const OptimizedShortcutRegistrationResult result = g_optimizedShortcutRefreshResult; g_optimizedShortcutRefreshCompleted = false; + ZLOG_INFO("shortcut", "unregister \"%s\" -> %s%s%s", shortcut.c_str(), + result.success ? "ok" : "failed", + result.error.empty() ? "" : " (", result.error.c_str(), + result.error.empty() ? "" : ")"); return CreateOptimizedShortcutRegistrationResult(env, result.success, result.error); } @@ -2518,6 +2567,7 @@ Napi::Value GetSelectedContent(const Napi::CallbackInfo& info) { item.Set("type", "text"); item.Set("data", uiaText); result.Set(uint32_t(0), item); + ZLOG_DEBUG("selection", "got text via UIA (%zu chars)", uiaText.size()); return result; } @@ -2666,6 +2716,8 @@ Napi::Value GetSelectedContent(const Napi::CallbackInfo& info) { g_isPaused = false; } + ZLOG_DEBUG("selection", "getSelectedContent -> %u items (via ctrl+c)", + (unsigned)result.Length()); return result; } @@ -2699,6 +2751,7 @@ Napi::Value SimulatePaste(const Napi::CallbackInfo& info) { // 发送输入事件 UINT result = SendInput(4, inputs, sizeof(INPUT)); + ZLOG_DEBUG("input", "simulatePaste -> %u/4 events", result); // 返回是否成功(应该发送了4个事件) return Napi::Boolean::New(env, result == 4); } @@ -2777,6 +2830,7 @@ Napi::Value SimulateKeyboardTap(const Napi::CallbackInfo& info) { // 发送输入事件 UINT result = SendInput(static_cast(eventCount), inputs.data(), sizeof(INPUT)); + ZLOG_DEBUG("input", "simulateKeyboardTap \"%s\" -> %u/%zu events", key.c_str(), result, eventCount); // 返回是否成功 return Napi::Boolean::New(env, result == eventCount); } @@ -3021,6 +3075,7 @@ static void QueueUwpPackageChange(const char* type, const winrt::hstring& packag auto event = std::make_unique(); event->type = type; event->packageFullName = winrt::to_string(packageFullName); + ZLOG_INFO("uwp", "package %s: %s", type, event->packageFullName.c_str()); // 与停止流程串行,确保 TSFN 不会在排队过程中被释放。 std::lock_guard lock(g_uwpPackageMonitorMutex); @@ -3168,6 +3223,7 @@ static Napi::Value StartUwpPackageMonitor(const Napi::CallbackInfo& info) { &g_uwpPackageTsfn ); if (status != napi_ok) { + ZLOG_ERROR("uwp", "create package monitor callback failed"); Napi::Error::New(env, "Failed to create UWP package monitor callback").ThrowAsJavaScriptException(); return env.Undefined(); } @@ -3196,12 +3252,15 @@ static Napi::Value StartUwpPackageMonitor(const Napi::CallbackInfo& info) { if (!startError.empty()) { // 初始化失败时先回收线程和 TSFN,再把错误返回 JavaScript。 + ZLOG_ERROR("uwp", "package monitor start failed: %s", startError.c_str()); if (g_uwpPackageMonitorThread.joinable()) { g_uwpPackageMonitorThread.join(); } napi_release_threadsafe_function(g_uwpPackageTsfn, napi_tsfn_release); g_uwpPackageTsfn = nullptr; Napi::Error::New(env, startError).ThrowAsJavaScriptException(); + } else { + ZLOG_INFO("uwp", "package monitor started"); } return env.Undefined(); @@ -3238,6 +3297,7 @@ static Napi::Value StopUwpPackageMonitor(const Napi::CallbackInfo& info) { g_uwpPackageMonitorError.clear(); } + ZLOG_DEBUG("uwp", "package monitor stopped"); return env.Undefined(); } @@ -3880,6 +3940,8 @@ Napi::Value LaunchUwpApp(const Napi::CallbackInfo& info) { CoUninitialize(); } + ZLOG_INFO("uwp", "launch \"%s\" -> %s (pid=%lu, hresult=0x%08X)", appIdUtf8.c_str(), + SUCCEEDED(hr) ? "ok" : "failed", (unsigned long)pid, (unsigned)hr); return CreateUwpLaunchResult( env, SUCCEEDED(hr), @@ -4205,10 +4267,12 @@ class IconWorker : public Napi::AsyncWorker { } void OnOK() override { if (result_.empty()) { + ZLOG_WARN("icon", "getFileIcon -> empty (path=%ls)", path_.c_str()); auto emptyBuffer = Napi::Buffer::New(Env(), 0); deferred_.Resolve(emptyBuffer); return; } + ZLOG_DEBUG("icon", "getFileIcon -> %zu bytes (path=%ls)", result_.size(), path_.c_str()); auto buffer = Napi::Buffer::Copy( Env(), result_.data(), result_.size()); deferred_.Resolve(buffer); @@ -4330,6 +4394,7 @@ Napi::Value ResolveMuiStrings(const Napi::CallbackInfo& info) { Napi::Array refs = info[0].As(); Napi::Object result = Napi::Object::New(env); + uint32_t resolvedCount = 0; for (uint32_t i = 0; i < refs.Length(); i++) { Napi::Value val = refs[i]; if (!val.IsString()) continue; @@ -4352,8 +4417,10 @@ Napi::Value ResolveMuiStrings(const Napi::CallbackInfo& info) { WideCharToMultiByte(CP_UTF8, 0, resolved.c_str(), -1, &resolvedUtf8[0], utf8Size, NULL, NULL); result.Set(refUtf8, Napi::String::New(env, resolvedUtf8)); + resolvedCount++; } + ZLOG_DEBUG("mui", "resolveMuiStrings -> %u/%u resolved", resolvedCount, refs.Length()); return result; } @@ -4369,6 +4436,8 @@ struct ColorPickerResult { void CallColorPickerJs(napi_env env, napi_value js_callback, void* context, void* data) { if (env != nullptr && js_callback != nullptr && data != nullptr) { ColorPickerResult* result = static_cast(data); + ZLOG_INFO("colorpicker", "finished: success=%d hex=%s", result->success ? 1 : 0, + result->success ? result->hex.c_str() : "-"); Napi::Env napiEnv(env); Napi::Object obj = Napi::Object::New(napiEnv); @@ -4806,6 +4875,7 @@ Napi::Value StartColorPicker(const Napi::CallbackInfo& info) { ); g_isColorPickerActive = true; + ZLOG_INFO("colorpicker", "started"); // 启动取色器线程 g_colorPickerThread = std::thread(ColorPickerThreadFunc); @@ -4822,6 +4892,7 @@ Napi::Value StopColorPicker(const Napi::CallbackInfo& info) { } g_isColorPickerActive = false; + ZLOG_INFO("colorpicker", "stop requested"); if (g_colorPickerWindow != NULL) { PostMessage(g_colorPickerWindow, WM_CLOSE, 0, 0); @@ -5392,6 +5463,7 @@ Napi::Value SetAddressBar(const Napi::CallbackInfo& info) { std::wstring address = Utf8ToWideString(addressUtf8); if (address.empty() || !IsFileLocationWindow(targetHwnd)) { + ZLOG_WARN("explorer", "setAddressBar: not a file location window (hwnd=%p)", targetHwnd); return Napi::Boolean::New(env, false); } @@ -5399,10 +5471,14 @@ Napi::Value SetAddressBar(const Napi::CallbackInfo& info) { GetClassNameW(targetHwnd, className, 256); if (wcscmp(className, L"CabinetWClass") == 0 || wcscmp(className, L"ExploreWClass") == 0) { - return Napi::Boolean::New(env, NavigateExplorerWindow(targetHwnd, address)); + const bool navigated = NavigateExplorerWindow(targetHwnd, address); + ZLOG_INFO("explorer", "setAddressBar (Explorer, hwnd=%p) -> %s", targetHwnd, + navigated ? "ok" : "failed"); + return Napi::Boolean::New(env, navigated); } if (!FocusTargetWindow(targetHwnd)) { + ZLOG_WARN("explorer", "setAddressBar: focus failed (hwnd=%p)", targetHwnd); return Napi::Boolean::New(env, false); } @@ -5413,6 +5489,8 @@ Napi::Value SetAddressBar(const Napi::CallbackInfo& info) { Sleep(30); success = SendVirtualKeyTap(VK_RETURN) && success; + ZLOG_INFO("explorer", "setAddressBar (dialog, hwnd=%p) -> %s", targetHwnd, + success ? "ok" : "failed"); return Napi::Boolean::New(env, success); } @@ -5939,6 +6017,7 @@ Napi::Value ScanWindowsShortcuts(const Napi::CallbackInfo& info) { } ResolveShortcutTargetsInParallel(entries); + ZLOG_DEBUG("shortcut", "scanWindowsShortcuts -> %zu entries", entries.size()); Napi::Array result = Napi::Array::New(env, entries.size()); for (uint32_t i = 0; i < entries.size(); i++) { const auto& entry = entries[i]; @@ -5955,9 +6034,11 @@ Napi::Value ScanWindowsShortcuts(const Napi::CallbackInfo& info) { return result; } catch (const std::exception& error) { + ZLOG_ERROR("shortcut", "scanWindowsShortcuts failed: %s", error.what()); Napi::Error::New(env, std::string("Windows shortcut scan failed: ") + error.what()) .ThrowAsJavaScriptException(); } catch (...) { + ZLOG_ERROR("shortcut", "scanWindowsShortcuts failed with an unknown native error"); Napi::Error::New(env, "Windows shortcut scan failed with an unknown native error") .ThrowAsJavaScriptException(); } @@ -6498,6 +6579,9 @@ class ExplorerLaunchWorker : public Napi::AsyncWorker { } void OnOK() override { + ZLOG_INFO("explorer", "launchViaExplorer %ls -> %s (hresult=0x%08X, stage=%s)", + target_.c_str(), SUCCEEDED(result_.hr) ? "ok" : "failed", + (unsigned)result_.hr, result_.stage); Napi::Object result = Napi::Object::New(Env()); result.Set("success", Napi::Boolean::New(Env(), SUCCEEDED(result_.hr))); result.Set( @@ -6509,6 +6593,8 @@ class ExplorerLaunchWorker : public Napi::AsyncWorker { } void OnError(const Napi::Error& error) override { + ZLOG_ERROR("explorer", "launchViaExplorer %ls rejected: %s", target_.c_str(), + error.Message().c_str()); deferred_.Reject(error.Value()); } @@ -6774,6 +6860,8 @@ Napi::Value LaunchCuiShell(const Napi::CallbackInfo& info) if (!created) { errorCode = GetLastError(); + ZLOG_ERROR("shell", "launchCuiShell %ls: CreateProcessW failed (error %lu)", + applicationPath.c_str(), (unsigned long)errorCode); Napi::Error::New( env, "CreateProcessW failed (Windows error " + @@ -6784,11 +6872,17 @@ Napi::Value LaunchCuiShell(const Napi::CallbackInfo& info) CloseHandle(processInfo.hThread); CloseHandle(processInfo.hProcess); + ZLOG_INFO("shell", "launchCuiShell %ls (cwd=%ls, pid=%lu)", applicationPath.c_str(), + currentDirectory.c_str(), (unsigned long)processInfo.dwProcessId); return Napi::Boolean::New(env, true); } // 模块初始化 Napi::Object Init(Napi::Env env, Napi::Object exports) { + // 应用 ZTOOLS_LOG_LEVEL 环境变量(须在其他日志写入前调用) + ztools_log::InitFromEnv(); + ZLOG_INFO("core", "=== ztools_native loaded (pid=%u, win32) ===", ztools_log::ProcessId()); + ZLOG_DEBUG("core", "log file: %s", ztools_log::FilePath().c_str()); exports.Set("startMonitor", Napi::Function::New(env, StartMonitor)); exports.Set("stopMonitor", Napi::Function::New(env, StopMonitor)); exports.Set("pauseMonitor", Napi::Function::New(env, PauseMonitor)); @@ -6841,6 +6935,15 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) { exports.Set("launchViaExplorer", Napi::Function::New(env, LaunchViaExplorer)); exports.Set("getSelectedContent", Napi::Function::New(env, GetSelectedContent)); exports.Set("launchCuiShell", Napi::Function::New(env, LaunchCuiShell)); + // Provider 桥接:让原生层(任意 native 线程)调用 JS 侧注册的方法 + exports.Set("startProviderBridge", Napi::Function::New(env, ztools_provider_bridge::StartProviderBridge)); + exports.Set("stopProviderBridge", Napi::Function::New(env, ztools_provider_bridge::StopProviderBridge)); + exports.Set("resolveProviderBridge", Napi::Function::New(env, ztools_provider_bridge::ResolveProviderBridge)); + exports.Set("rejectProviderBridge", Napi::Function::New(env, ztools_provider_bridge::RejectProviderBridge)); + exports.Set("isProviderBridgeReady", Napi::Function::New(env, ztools_provider_bridge::IsProviderBridgeReady)); + exports.Set("invokeProviderFromNative", Napi::Function::New(env, ztools_provider_bridge::InvokeProviderFromNative)); + // 日志管理:等级查询/设置、日志文件路径、JS 侧写入同一日志文件 + ztools_log_binding::Register(env, exports); return exports; } diff --git a/src/logger.h b/src/logger.h new file mode 100644 index 0000000..544c0de --- /dev/null +++ b/src/logger.h @@ -0,0 +1,396 @@ +// ZTools 原生层日志模块(header-only,Windows / macOS 共用) +// +// 目标:让原生层(N-API 绑定、截图会话、监控线程等)不再像黑盒, +// 关键调用、状态变化与失败路径都能落到临时目录里的日志文件,便于用户侧排查。 +// +// 行为约定: +// - 日志文件:<系统临时目录>/ztools-native.log +// Windows 为 GetTempPathW(%TMP%/%TEMP%),macOS 为 $TMPDIR(兜底 /tmp); +// - 大小限制:当前文件不超过 10MB;写满时整体轮转为 ztools-native.log.old +// (先删除旧 .old 再改名;改名失败时退化为截断重开),任何时刻最多两个文件; +// - 等级:trace < debug < info < warn < error,另有 off 表示完全关闭。 +// 默认 info;可用环境变量 ZTOOLS_LOG_LEVEL(进程启动前设置)或 JS 侧 +// setLogLevel()(运行时覆盖)控制,等级过滤在格式化之前完成,开销极低; +// - 线程安全:内部一把互斥锁串行化写入,任意 native 线程(监控回调线程、 +// 截图会话线程等)均可直接调用;每条日志单次写入并 fflush,崩溃前已写内容不丢; +// - 格式:2026-09-22 12:34:56.789 [info] [tid 12345] [tag] message; +// 多进程(宿主 + 测试脚本)同时写同一文件时按行追加,偶发交错可接受。 +// +// 用法(原生代码): +// #include "logger.h" +// ZLOG_INFO("clipboard", "monitor started"); +// ZLOG_DEBUG("screenshot", "session requested (autoConfirm=%d)", autoConfirm); +// if (ztools_log::Enabled(ztools_log::kDebug)) { ... } // 昂贵参数构造可先用此门控 + +#ifndef ZTOOLS_LOGGER_H_ +#define ZTOOLS_LOGGER_H_ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#else +#include +#include +#include +#include +#endif + +#if defined(__GNUC__) || defined(__clang__) +// 编译期检查 Write 的 printf 格式串与实参是否匹配(fmt 为第 3 个参数) +#define ZTOOLS_LOG_PRINTF_ATTR __attribute__((format(printf, 3, 4))) +#else +#define ZTOOLS_LOG_PRINTF_ATTR +#endif + +namespace ztools_log { + +// 日志等级:数值越小越详细;过滤规则为「消息等级 >= 当前等级」才写入。 +enum Level { + kTrace = 0, + kDebug = 1, + kInfo = 2, + kWarn = 3, + kError = 4, + kOff = 5, // 仅供等级设置使用,普通消息不会以此等级发出 +}; + +// 解析等级字符串(大小写不敏感,兼容 warning/none 别名);无法识别返回 false 且不改动 out +inline bool LevelFromString(const char* name, Level* out) { + if (name == nullptr || out == nullptr) { + return false; + } + std::string v; + for (const char* p = name; *p != '\0'; ++p) { + v.push_back((*p >= 'A' && *p <= 'Z') ? (char)(*p - 'A' + 'a') : *p); + } + Level parsed; + if (v == "trace") parsed = kTrace; + else if (v == "debug") parsed = kDebug; + else if (v == "info") parsed = kInfo; + else if (v == "warn" || v == "warning") parsed = kWarn; + else if (v == "error") parsed = kError; + else if (v == "off" || v == "none") parsed = kOff; + else return false; + *out = parsed; + return true; +} + +// 等级枚举 → 可读字符串(getLogLevel 导出与日志行前缀共用) +inline const char* LevelToString(Level level) { + switch (level) { + case kTrace: return "trace"; + case kDebug: return "debug"; + case kInfo: return "info"; + case kWarn: return "warn"; + case kError: return "error"; + default: return "off"; + } +} + +namespace internal { + +// 单个日志文件上限:超过后整体轮转为 .old 重新开写(当前文件保证不超过该值) +const uint64_t kMaxFileBytes = 10ULL * 1024 * 1024; +// 单条日志格式化后的上限字节数,防御性截断超大内容(如误传的 base64) +const size_t kMaxMessageBytes = 8192; + +inline std::mutex& FileMutex() { + static std::mutex m; + return m; +} + +// 当前等级(原子读写;Write 的过滤判断无锁) +inline std::atomic& LevelRef() { + static std::atomic level(kInfo); + return level; +} + +// JS 侧是否已显式 SetLevel(显式设置后环境变量不再覆盖) +inline std::atomic& LevelExplicit() { + static std::atomic flag(false); + return flag; +} + +// 环境变量是否已应用(仅在 FileMutex 保护下访问) +inline bool& EnvApplied() { + static bool flag = false; + return flag; +} + +// 日志文件运行状态(所有字段仅在 FileMutex 保护下访问) +struct FileState { + FILE* fp = nullptr; + uint64_t bytes = 0; // 当前文件已写字节数(打开时按现有文件初始化) + std::string pathUtf8; // 完整路径(UTF-8,供 JS 展示) +#ifdef _WIN32 + std::wstring pathWide; // 完整路径(供 _wfopen/_wrename 使用) +#endif + bool openFailedReported = false; // 打开失败只向 stderr 报一次,避免刷屏 +}; + +inline FileState& State() { + static FileState s; + return s; +} + +// UTF-16 → UTF-8(仅 Windows,用于把临时目录路径转成可展示/传给 JS 的形式) +#ifdef _WIN32 +inline std::string WideToUtf8(const std::wstring& w) { + if (w.empty()) return std::string(); + int n = WideCharToMultiByte(CP_UTF8, 0, w.c_str(), (int)w.size(), nullptr, 0, nullptr, nullptr); + std::string out; + if (n > 0) { + out.resize((size_t)n); + WideCharToMultiByte(CP_UTF8, 0, w.c_str(), (int)w.size(), &out[0], n, nullptr, nullptr); + } + return out; +} +#endif + +// 计算日志文件完整路径(不打开文件;结果缓存,只在首次调用时求值) +inline void ComputePathLocked() { + FileState& s = State(); + if (!s.pathUtf8.empty()) { + return; + } +#ifdef _WIN32 + wchar_t tempDir[MAX_PATH] = L""; + DWORD n = GetTempPathW(MAX_PATH, tempDir); + if (n == 0 || n >= MAX_PATH) { + n = 0; + tempDir[0] = L'\0'; + } + s.pathWide = std::wstring(tempDir) + L"ztools-native.log"; + s.pathUtf8 = WideToUtf8(s.pathWide); +#else + const char* tmp = std::getenv("TMPDIR"); + std::string dir = (tmp != nullptr && tmp[0] != '\0') ? tmp : "/tmp"; + if (dir[dir.size() - 1] != '/') { + dir += '/'; + } + s.pathUtf8 = dir + "ztools-native.log"; +#endif +} + +// 以追加模式打开日志文件;成功时把已写字节数同步为当前文件大小 +inline bool OpenFileLocked() { + FileState& s = State(); + if (s.fp != nullptr) { + return true; + } + ComputePathLocked(); +#ifdef _WIN32 + s.fp = _wfopen(s.pathWide.c_str(), L"ab"); +#else + s.fp = std::fopen(s.pathUtf8.c_str(), "ab"); +#endif + if (s.fp != nullptr) { + if (std::fseek(s.fp, 0, SEEK_END) == 0) { + const long size = std::ftell(s.fp); + s.bytes = size > 0 ? (uint64_t)size : 0; + } else { + s.bytes = 0; + } + } + return s.fp != nullptr; +} + +// 轮转:关闭当前文件 → 删除旧 .old → 当前文件改名为 .old → 以截断模式重建。 +// 改名失败(极少见,如其他进程持有 .old)时退化为直接截断重建,保证不超上限。 +inline void RotateLocked() { + FileState& s = State(); + if (s.fp != nullptr) { + std::fclose(s.fp); + s.fp = nullptr; + } + s.bytes = 0; + ComputePathLocked(); +#ifdef _WIN32 + const std::wstring oldPath = s.pathWide + L".old"; + _wremove(oldPath.c_str()); + _wrename(s.pathWide.c_str(), oldPath.c_str()); + s.fp = _wfopen(s.pathWide.c_str(), L"wb"); +#else + const std::string oldPath = s.pathUtf8 + ".old"; + std::remove(oldPath.c_str()); + std::rename(s.pathUtf8.c_str(), oldPath.c_str()); + s.fp = std::fopen(s.pathUtf8.c_str(), "wb"); +#endif +} + +// 当前本地时间 "YYYY-MM-DD HH:MM:SS.mmm" +inline void FormatTimestamp(char* buf, size_t bufSize) { +#ifdef _WIN32 + SYSTEMTIME st; + GetLocalTime(&st); + std::snprintf(buf, bufSize, "%04u-%02u-%02u %02u:%02u:%02u.%03u", + (unsigned)st.wYear, (unsigned)st.wMonth, (unsigned)st.wDay, + (unsigned)st.wHour, (unsigned)st.wMinute, (unsigned)st.wSecond, + (unsigned)st.wMilliseconds); +#else + struct timeval tv; + std::gettimeofday(&tv, nullptr); + struct tm tmv; + localtime_r(&tv.tv_sec, &tmv); + std::snprintf(buf, bufSize, "%04d-%02d-%02d %02d:%02d:%02d.%03d", + tmv.tm_year + 1900, tmv.tm_mon + 1, tmv.tm_mday, + tmv.tm_hour, tmv.tm_min, tmv.tm_sec, (int)(tv.tv_usec / 1000)); +#endif +} + +// 当前线程短标识(仅用于区分并发日志来源;Windows 用系统线程 id) +inline unsigned CurrentThreadId() { +#ifdef _WIN32 + return (unsigned)GetCurrentThreadId(); +#else + const size_t h = std::hash()(std::this_thread::get_id()); + return (unsigned)(h & 0xFFFFFFFFu); +#endif +} + +// 当前进程 id(会话起始行会打印,便于区分宿主与测试进程) +inline unsigned ProcessId() { +#ifdef _WIN32 + return (unsigned)GetCurrentProcessId(); +#else + return (unsigned)getpid(); +#endif +} + +// 应用环境变量 ZTOOLS_LOG_LEVEL(trace/debug/info/warn/error/off,大小写不敏感)。 +// 仅应用一次;JS 侧已显式 SetLevel 时跳过。须在 FileMutex 保护下调用。 +inline void ApplyEnvLevelLocked() { + if (EnvApplied() || LevelExplicit().load(std::memory_order_relaxed)) { + return; + } + EnvApplied() = true; + const char* value = std::getenv("ZTOOLS_LOG_LEVEL"); + if (value == nullptr || value[0] == '\0') { + return; + } + Level parsed = kInfo; + if (LevelFromString(value, &parsed)) { + LevelRef().store(parsed, std::memory_order_relaxed); + } +} + +} // namespace internal + +// ==================== 公共 API ==================== + +// 消息等级是否达到当前输出等级(供调用方跳过昂贵的参数构造) +inline bool Enabled(Level level) { + return level >= internal::LevelRef().load(std::memory_order_relaxed); +} + +// 运行时设置输出等级(JS 侧 setLogLevel 导出);设置后环境变量不再生效 +inline void SetLevel(Level level) { + internal::LevelExplicit().store(true, std::memory_order_relaxed); + internal::LevelRef().store(level, std::memory_order_relaxed); +} + +// 当前输出等级 +inline Level GetLevel() { + return (Level)internal::LevelRef().load(std::memory_order_relaxed); +} + +// 日志文件完整路径(UTF-8)。路径在首次调用时确定并缓存;不打开文件。 +inline std::string FilePath() { + std::lock_guard lock(internal::FileMutex()); + internal::ComputePathLocked(); + return internal::State().pathUtf8; +} + +// 模块加载时调用一次:应用 ZTOOLS_LOG_LEVEL 环境变量(未设置则保持默认 info) +inline void InitFromEnv() { + std::lock_guard lock(internal::FileMutex()); + internal::ApplyEnvLevelLocked(); +} + +// 写一条日志(线程安全,任意 native 线程可调用)。 +// level 低于当前输出等级时直接丢弃;fmt 为 printf 风格; +// tag 建议用短模块名(如 "clipboard" / "screenshot" / "longcapture")。 +inline void Write(Level level, const char* tag, const char* fmt, ...) ZTOOLS_LOG_PRINTF_ATTR; + +inline void Write(Level level, const char* tag, const char* fmt, ...) { + if (!Enabled(level)) { + return; + } + + // 先格式化消息体(无锁;等级过滤已挡掉绝大部分调用) + char message[internal::kMaxMessageBytes]; + va_list args; + va_start(args, fmt); + int n = std::vsnprintf(message, sizeof(message), fmt, args); + va_end(args); + if (n < 0) { + return; + } + if ((size_t)n >= sizeof(message)) { + n = (int)sizeof(message) - 1; // 超长防御性截断 + } + + char ts[40]; + internal::FormatTimestamp(ts, sizeof(ts)); + + char line[internal::kMaxMessageBytes + 128]; + int lineLen = std::snprintf(line, sizeof(line), "%s [%s] [tid %u] [%s] %.*s\n", ts, + LevelToString(level), internal::CurrentThreadId(), + tag != nullptr ? tag : "native", n, message); + if (lineLen < 0) { + return; + } + if ((size_t)lineLen >= sizeof(line)) { + lineLen = (int)sizeof(line) - 1; + } + + std::lock_guard lock(internal::FileMutex()); + internal::ApplyEnvLevelLocked(); + internal::FileState& s = internal::State(); + if (!internal::OpenFileLocked()) { + if (!s.openFailedReported) { + s.openFailedReported = true; + std::fprintf(stderr, "[ztools-native] failed to open log file: %s\n", + s.pathUtf8.c_str()); + } + return; + } + if (s.bytes + (uint64_t)lineLen > internal::kMaxFileBytes) { + internal::RotateLocked(); + if (s.fp == nullptr) { + return; + } + } + const size_t toWrite = (size_t)lineLen; + if (std::fwrite(line, 1, toWrite, s.fp) == toWrite) { + s.bytes += toWrite; + // 逐条落盘:原生层崩溃时已写内容不丢;本模块日志量低,开销可接受 + std::fflush(s.fp); + } +} + +// 当前进程 id(公共 API,供绑定层打会话起始行) +inline unsigned ProcessId() { + return internal::ProcessId(); +} + +} // namespace ztools_log + +// ==================== 日志宏(推荐调用形式) ==================== + +#define ZLOG_TRACE(tag, ...) ::ztools_log::Write(::ztools_log::kTrace, (tag), __VA_ARGS__) +#define ZLOG_DEBUG(tag, ...) ::ztools_log::Write(::ztools_log::kDebug, (tag), __VA_ARGS__) +#define ZLOG_INFO(tag, ...) ::ztools_log::Write(::ztools_log::kInfo, (tag), __VA_ARGS__) +#define ZLOG_WARN(tag, ...) ::ztools_log::Write(::ztools_log::kWarn, (tag), __VA_ARGS__) +#define ZLOG_ERROR(tag, ...) ::ztools_log::Write(::ztools_log::kError, (tag), __VA_ARGS__) + +#endif // ZTOOLS_LOGGER_H_ diff --git a/src/logger_binding.h b/src/logger_binding.h new file mode 100644 index 0000000..5d61697 --- /dev/null +++ b/src/logger_binding.h @@ -0,0 +1,129 @@ +// ZTools 日志模块的 N-API 绑定层(header-only,Windows / macOS 绑定共用) +// +// 把 src/logger.h 的能力暴露给 JS 侧: +// - setLogLevel(level) 运行时设置输出等级,覆盖环境变量 +// - getLogLevel() 查询当前输出等级 +// - getLogFilePath() 查询日志文件完整路径(宿主可展示给用户) +// - isLogLevelEnabled(level) 查询某等级当前是否会被写入 +// - logWrite(level, tag, msg) JS 侧向同一日志文件写入一条日志 +// (与原生日志同一文件/格式,得到统一时间线) +// +// 本头文件应只被各平台 binding 主编译单元包含一次(内部均为 inline 函数, +// 依赖 napi.h 与 logger.h)。 + +#ifndef ZTOOLS_LOGGER_BINDING_H_ +#define ZTOOLS_LOGGER_BINDING_H_ + +#include + +#include + +#include "logger.h" + +namespace ztools_log_binding { + +/** + * 设置日志输出等级。 + * @param info[0] level: "trace" | "debug" | "info" | "warn" | "error" | "off"(大小写不敏感) + * @returns 无返回值;level 非法时抛出 JS 异常 + */ +inline Napi::Value SetLogLevel(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 1 || !info[0].IsString()) { + Napi::TypeError::New(env, "Expected a level string (trace/debug/info/warn/error/off)") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + const std::string level = info[0].As().Utf8Value(); + ztools_log::Level parsed; + if (!ztools_log::LevelFromString(level.c_str(), &parsed)) { + Napi::TypeError::New(env, "level must be one of: trace, debug, info, warn, error, off") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + ztools_log::SetLevel(parsed); + ZLOG_INFO("logger", "level set to %s via JS", ztools_log::LevelToString(parsed)); + return env.Undefined(); +} + +/** + * 查询当前日志输出等级。 + * @returns 当前等级字符串("trace"/"debug"/"info"/"warn"/"error"/"off") + */ +inline Napi::Value GetLogLevel(const Napi::CallbackInfo& info) { + return Napi::String::New(info.Env(), ztools_log::LevelToString(ztools_log::GetLevel())); +} + +/** + * 查询日志文件完整路径(系统临时目录下 ztools-native.log)。 + * @returns 日志文件绝对路径(UTF-8) + */ +inline Napi::Value GetLogFilePath(const Napi::CallbackInfo& info) { + return Napi::String::New(info.Env(), ztools_log::FilePath()); +} + +/** + * 查询某等级当前是否会被写入(受 setLogLevel / 环境变量影响)。 + * @param info[0] level: 等级字符串 + * @returns boolean;level 非法时抛出 JS 异常 + */ +inline Napi::Value IsLogLevelEnabled(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 1 || !info[0].IsString()) { + Napi::TypeError::New(env, "Expected a level string (trace/debug/info/warn/error/off)") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + const std::string level = info[0].As().Utf8Value(); + ztools_log::Level parsed; + if (!ztools_log::LevelFromString(level.c_str(), &parsed)) { + Napi::TypeError::New(env, "level must be one of: trace, debug, info, warn, error, off") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + return Napi::Boolean::New(env, ztools_log::Enabled(parsed)); +} + +/** + * JS 侧向原生日志文件写入一条日志(与原生日志同一文件、同一格式)。 + * @param info[0] level: 等级字符串(低于当前输出等级时丢弃) + * @param info[1] tag: 短模块名(如 "app") + * @param info[2] message: 消息内容(不建议包含超大文本/敏感信息) + * @returns 无返回值;参数非法时抛出 JS 异常 + */ +inline Napi::Value LogWrite(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 3 || !info[0].IsString() || !info[1].IsString() || !info[2].IsString()) { + Napi::TypeError::New(env, "Expected (level: string, tag: string, message: string)") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + const std::string level = info[0].As().Utf8Value(); + const std::string tag = info[1].As().Utf8Value(); + const std::string message = info[2].As().Utf8Value(); + ztools_log::Level parsed; + if (!ztools_log::LevelFromString(level.c_str(), &parsed) || parsed == ztools_log::kOff) { + Napi::TypeError::New(env, "level must be one of: trace, debug, info, warn, error") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + ztools_log::Write(parsed, tag.c_str(), "%s", message.c_str()); + return env.Undefined(); +} + +/** + * 把日志管理导出注册到模块 exports(各平台 Init 中调用)。 + * @param env N-API 环境 + * @param exports 模块导出对象 + */ +inline void Register(Napi::Env env, Napi::Object exports) { + exports.Set("setLogLevel", Napi::Function::New(env, SetLogLevel)); + exports.Set("getLogLevel", Napi::Function::New(env, GetLogLevel)); + exports.Set("getLogFilePath", Napi::Function::New(env, GetLogFilePath)); + exports.Set("isLogLevelEnabled", Napi::Function::New(env, IsLogLevelEnabled)); + exports.Set("logWrite", Napi::Function::New(env, LogWrite)); +} + +} // namespace ztools_log_binding + +#endif // ZTOOLS_LOGGER_BINDING_H_ diff --git a/src/provider_bridge.h b/src/provider_bridge.h new file mode 100644 index 0000000..08ce46c --- /dev/null +++ b/src/provider_bridge.h @@ -0,0 +1,395 @@ +// ZTools Provider 桥接层 +// +// 让原生层(任意 native 线程上的 C++/Swift 代码)能够调用 JS 侧注册的方法, +// 典型用途是调用宿主应用(如 ZTools 主进程)提供的 provider 能力(翻译 / OCR 等)。 +// +// 工作方式: +// 1. JS 侧调用 startProviderBridge(dispatcher) 注册分发函数,签名为 +// (type: string, inputJson: string, seq: number) => void, +// JS 负责执行实际逻辑,并按 seq 通过 resolveProviderBridge / rejectProviderBridge 回传结果; +// 2. 原生线程调用 InvokeProvider(type, inputJson, timeoutMs) 发起调用: +// 请求经线程安全函数调度到 JS 线程执行,原生线程阻塞等待结果(带超时); +// 3. JS 执行完成后回传结果 JSON 字符串,原生侧拿到 InvokeResult。 +// +// 线程约束: +// - InvokeProvider 严禁在 JS 主线程调用(阻塞等待会与调度回调互相等待造成死锁), +// 内部检测到 JS 线程调用时立即返回错误; +// - 其余导出函数(start / stop / resolve / reject 等)按常规 N-API 导出在 JS 线程上调用。 + +#ifndef ZTOOLS_PROVIDER_BRIDGE_H_ +#define ZTOOLS_PROVIDER_BRIDGE_H_ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "logger.h" + +namespace ztools_provider_bridge { + +/** 原生侧一次 provider 调用的结果。 */ +struct InvokeResult { + bool ok = false; // 是否成功拿到 JS 侧结果 + std::string value; // 成功时的结果 JSON 字符串 + std::string error; // 失败原因(未启动 / 正在关闭 / 队列不可用 / 超时 / JS 侧报错等) +}; + +namespace internal { + +/** 投递给 JS 线程的一次调用请求(BridgeCallJs 处理完后释放)。 */ +struct InvokeJob { + uint64_t seq; + std::string type; + std::string inputJson; +}; + +/** invokeProviderFromNative 的回调载荷(NativeInvokeCallJs 处理完后释放)。 */ +struct NativeInvokePayload { + napi_threadsafe_function tsfn; + bool ok; + std::string value; + std::string error; +}; + +// 桥接全局状态:本头文件在各平台只被 binding 主编译单元包含一次, +// 使用函数内静态保证唯一切片,避免 ODR 问题 +inline std::mutex& Mutex() { + static std::mutex m; + return m; +} +inline napi_threadsafe_function& Tsfn() { + static napi_threadsafe_function tsfn = nullptr; + return tsfn; +} +inline std::thread::id& JsThreadId() { + static std::thread::id tid; + return tid; +} +inline std::atomic& NextSeq() { + static std::atomic seq(0); + return seq; +} +inline std::map>>& Pending() { + static std::map>> pending; + return pending; +} + +// 在 JS 线程上执行:取出请求,调用 JS 分发函数 dispatcher(type, inputJson, seq) +inline void BridgeCallJs(napi_env env, napi_value js_callback, void* /*context*/, void* data) { + auto* job = static_cast(data); + if (job == nullptr) { + return; + } + if (env != nullptr && js_callback != nullptr) { + napi_value global = nullptr; + napi_get_global(env, &global); + napi_value argv[3]; + napi_create_string_utf8(env, job->type.c_str(), NAPI_AUTO_LENGTH, &argv[0]); + napi_create_string_utf8(env, job->inputJson.c_str(), NAPI_AUTO_LENGTH, &argv[1]); + napi_create_double(env, static_cast(job->seq), &argv[2]); + napi_call_function(env, global, js_callback, 3, argv, nullptr); + } + delete job; +} + +// 取出指定 seq 的 pending promise;不存在(已超时 / 已停止)时返回空 +inline std::shared_ptr> TakePending(uint64_t seq) { + std::lock_guard lock(Mutex()); + auto it = Pending().find(seq); + if (it == Pending().end()) { + return nullptr; + } + auto promise = it->second; + Pending().erase(it); + return promise; +} + +} // namespace internal + +// ==================== 原生侧调用入口(供本插件内的原生模块使用) ==================== + +/** 桥接是否已启动(JS 侧已注册分发函数)。 */ +inline bool IsReady() { + std::lock_guard lock(internal::Mutex()); + return internal::Tsfn() != nullptr; +} + +/** + * 原生线程调用 JS 侧 provider 方法并阻塞等待结果。 + * @param type 能力类型(如 "translation" / "ocr",由 JS 侧自行约定) + * @param inputJson 入参 JSON 字符串 + * @param timeoutMs 等待 JS 侧结果的超时时间(毫秒) + * @returns 调用结果;失败时 ok=false 且 error 说明原因 + */ +inline InvokeResult Invoke(const std::string& type, const std::string& inputJson, + uint32_t timeoutMs) { + napi_threadsafe_function tsfn = nullptr; + { + std::lock_guard lock(internal::Mutex()); + tsfn = internal::Tsfn(); + if (tsfn == nullptr) { + ZLOG_WARN("provider", "invoke \"%s\" rejected: bridge not started", type.c_str()); + return InvokeResult{false, "", "provider bridge not started"}; + } + // JS 线程上阻塞等待会与 BridgeCallJs 互相等待,直接拒绝 + if (std::this_thread::get_id() == internal::JsThreadId()) { + ZLOG_WARN("provider", "invoke \"%s\" rejected: called on JS main thread", type.c_str()); + return InvokeResult{false, "", "InvokeProvider must not be called on the JS main thread"}; + } + // 增加引用计数,防止等待期间被 stopProviderBridge 释放底层函数 + if (napi_acquire_threadsafe_function(tsfn) != napi_ok) { + ZLOG_WARN("provider", "invoke \"%s\" rejected: bridge is closing", type.c_str()); + return InvokeResult{false, "", "provider bridge is closing"}; + } + } + + ZLOG_DEBUG("provider", "invoke \"%s\" (timeout %ums, input %zu bytes)", type.c_str(), + timeoutMs, inputJson.size()); + const uint64_t seq = internal::NextSeq().fetch_add(1) + 1; + auto promise = std::make_shared>(); + auto future = promise->get_future(); + { + std::lock_guard lock(internal::Mutex()); + internal::Pending()[seq] = promise; + } + + auto* job = new internal::InvokeJob{seq, type, inputJson}; + if (napi_call_threadsafe_function(tsfn, job, napi_tsfn_nonblocking) != napi_ok) { + // 桥接正在关闭,请求未入队(CallJs 不会处理该 job),此处代为清理 + ZLOG_WARN("provider", "invoke \"%s\" failed: queue unavailable", type.c_str()); + internal::TakePending(seq); + delete job; + napi_release_threadsafe_function(tsfn, napi_tsfn_release); + return InvokeResult{false, "", "provider bridge queue unavailable"}; + } + + InvokeResult result; + if (future.wait_for(std::chrono::milliseconds(timeoutMs)) == std::future_status::timeout) { + // 超时后移除登记;JS 迟到回传的结果会被 TakePending 丢弃 + ZLOG_ERROR("provider", "invoke \"%s\" timed out after %ums", type.c_str(), timeoutMs); + internal::TakePending(seq); + result = InvokeResult{false, "", "provider invocation timed out"}; + } else { + result = future.get(); + } + napi_release_threadsafe_function(tsfn, napi_tsfn_release); + if (result.ok) { + ZLOG_DEBUG("provider", "invoke \"%s\" ok (result %zu bytes)", type.c_str(), + result.value.size()); + } else { + ZLOG_WARN("provider", "invoke \"%s\" failed: %s", type.c_str(), result.error.c_str()); + } + return result; +} + +// ==================== N-API 导出(在 JS 线程上被调用) ==================== + +/** + * 启动桥接:注册 JS 分发函数。 + * @param info[0] dispatcher: (type: string, inputJson: string, seq: number) => void, + * JS 侧负责执行逻辑并按 seq 调 resolveProviderBridge / rejectProviderBridge + * @returns 无返回值;参数非法或重复启动时抛出 JS 异常 + */ +inline Napi::Value StartProviderBridge(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 1 || !info[0].IsFunction()) { + Napi::TypeError::New(env, "Expected a dispatcher function").ThrowAsJavaScriptException(); + return env.Undefined(); + } + { + std::lock_guard lock(internal::Mutex()); + if (internal::Tsfn() != nullptr) { + Napi::Error::New(env, "Provider bridge already started").ThrowAsJavaScriptException(); + return env.Undefined(); + } + } + + napi_value callback = info[0]; + napi_value resource_name; + napi_create_string_utf8(env, "ProviderBridgeCallback", NAPI_AUTO_LENGTH, &resource_name); + + napi_threadsafe_function tsfn = nullptr; + if (napi_create_threadsafe_function(env, callback, nullptr, resource_name, 0, 1, nullptr, + nullptr, nullptr, internal::BridgeCallJs, + &tsfn) != napi_ok) { + Napi::Error::New(env, "Failed to create provider bridge").ThrowAsJavaScriptException(); + return env.Undefined(); + } + + { + std::lock_guard lock(internal::Mutex()); + internal::Tsfn() = tsfn; + // 记录 JS 主线程 id,供 Invoke 做死锁防护 + internal::JsThreadId() = std::this_thread::get_id(); + } + ZLOG_INFO("provider", "bridge started"); + return env.Undefined(); +} + +/** + * 停止桥接:所有还在等待的原生调用立即以错误结束,已入队的请求仍会被处理完。 + * @returns 无返回值 + */ +inline Napi::Value StopProviderBridge(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + napi_threadsafe_function tsfn = nullptr; + { + std::lock_guard lock(internal::Mutex()); + tsfn = internal::Tsfn(); + internal::Tsfn() = nullptr; + } + // 让所有还在等待的原生线程立即失败,而不是挂到超时 + std::map>> pending; + { + std::lock_guard lock(internal::Mutex()); + pending.swap(internal::Pending()); + } + for (auto& entry : pending) { + entry.second->set_value(InvokeResult{false, "", "provider bridge stopped"}); + } + ZLOG_INFO("provider", "bridge stopped (%zu pending call(s) failed)", pending.size()); + if (tsfn != nullptr) { + napi_release_threadsafe_function(tsfn, napi_tsfn_release); + } + return env.Undefined(); +} + +/** + * JS 侧回传成功结果。 + * @param info[0] seq 请求序号(与分发出的 seq 一致) + * @param info[1] resultJson 结果 JSON 字符串 + * @returns 无返回值;seq 已超时或不存在时静默丢弃 + */ +inline Napi::Value ResolveProviderBridge(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsString()) { + Napi::TypeError::New(env, "Expected (seq: number, resultJson: string)") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + const uint64_t seq = static_cast(info[0].As().DoubleValue()); + const std::string value = info[1].As().Utf8Value(); + if (auto promise = internal::TakePending(seq)) { + promise->set_value(InvokeResult{true, value, ""}); + } + return env.Undefined(); +} + +/** + * JS 侧回传失败。 + * @param info[0] seq 请求序号(与分发出的 seq 一致) + * @param info[1] error 错误说明 + * @returns 无返回值;seq 已超时或不存在时静默丢弃 + */ +inline Napi::Value RejectProviderBridge(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsString()) { + Napi::TypeError::New(env, "Expected (seq: number, error: string)") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + const uint64_t seq = static_cast(info[0].As().DoubleValue()); + const std::string error = info[1].As().Utf8Value(); + if (auto promise = internal::TakePending(seq)) { + promise->set_value(InvokeResult{false, "", error}); + } + return env.Undefined(); +} + +/** + * 查询桥接是否就绪。 + * @returns 就绪返回 true,未启动(或已停止)返回 false + */ +inline Napi::Value IsProviderBridgeReady(const Napi::CallbackInfo& info) { + return Napi::Boolean::New(info.Env(), IsReady()); +} + +// ==================== 供 JS 侧验证桥接通路的回调式入口 ==================== + +// 把原生线程上的调用结果带回 JS 线程并调用回调 +inline void NativeInvokeCallJs(napi_env env, napi_value js_callback, void* /*context*/, + void* data) { + auto* payload = static_cast(data); + if (payload == nullptr) { + return; + } + if (env != nullptr && js_callback != nullptr) { + napi_value global = nullptr; + napi_get_global(env, &global); + napi_value argv[2] = {nullptr, nullptr}; + if (payload->ok) { + napi_get_null(env, &argv[0]); + napi_create_string_utf8(env, payload->value.c_str(), NAPI_AUTO_LENGTH, &argv[1]); + } else { + napi_create_string_utf8(env, payload->error.c_str(), NAPI_AUTO_LENGTH, &argv[0]); + napi_get_null(env, &argv[1]); + } + napi_call_function(env, global, js_callback, 2, argv, nullptr); + } + // 与创建时的 initial_thread_count=1 配对,由消费方释放;随后释放载荷 + napi_release_threadsafe_function(payload->tsfn, napi_tsfn_release); + delete payload; +} + +/** + * 在独立原生线程上发起一次 provider 调用,完成后在 JS 线程回调结果。 + * 走的是与 Invoke 完全相同的真实通路,主要用于从 JS 验证桥接; + * 真正的原生业务代码应直接调用 Invoke。 + * @param info[0] type 能力类型 + * @param info[1] inputJson 入参 JSON 字符串 + * @param info[2] timeoutMs 超时毫秒数(可选,默认 15000) + * @param info[3] callback: (error: string | null, resultJson: string | null) => void + * @returns 无返回值 + */ +inline Napi::Value InvokeProviderFromNative(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 4 || !info[0].IsString() || !info[1].IsString() || !info[3].IsFunction()) { + Napi::TypeError::New(env, + "Expected (type: string, inputJson: string, timeoutMs?: number, " + "callback: function)") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + const std::string type = info[0].As().Utf8Value(); + const std::string inputJson = info[1].As().Utf8Value(); + const uint32_t timeoutMs = + info.Length() >= 3 && info[2].IsNumber() ? info[2].As().Uint32Value() : 15000u; + + napi_value callback = info[3]; + napi_value resource_name; + napi_create_string_utf8(env, "ProviderBridgeNativeInvoke", NAPI_AUTO_LENGTH, &resource_name); + + napi_threadsafe_function tsfn = nullptr; + if (napi_create_threadsafe_function(env, callback, nullptr, resource_name, 0, 1, nullptr, + nullptr, nullptr, NativeInvokeCallJs, + &tsfn) != napi_ok) { + Napi::Error::New(env, "Failed to create native invoke callback") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + + // 生产者线程只负责投递;TSFN 由 NativeInvokeCallJs(消费方)释放 + std::thread([type, inputJson, timeoutMs, tsfn]() { + InvokeResult result = Invoke(type, inputJson, timeoutMs); + auto* payload = new internal::NativeInvokePayload{tsfn, result.ok, result.value, result.error}; + if (napi_call_threadsafe_function(tsfn, payload, napi_tsfn_nonblocking) != napi_ok) { + // 投递失败时 CallJs 不会执行,此处代为释放,避免泄漏 + napi_release_threadsafe_function(tsfn, napi_tsfn_release); + delete payload; + } + }).detach(); + + return env.Undefined(); +} + +} // namespace ztools_provider_bridge + +#endif // ZTOOLS_PROVIDER_BRIDGE_H_ diff --git a/src/screenshot/algo/translate_cluster.cpp b/src/screenshot/algo/translate_cluster.cpp new file mode 100644 index 0000000..a59c25d --- /dev/null +++ b/src/screenshot/algo/translate_cluster.cpp @@ -0,0 +1,377 @@ +// 截图翻译子系统:OCR 文本行 → 段落的空间聚类实现(契约见 +// translate_cluster_internal.h)。 +// +// 整体流水线: +// 1) 基础数据预处理:抽取每行几何量(左/右/上/下/宽/高),按 (top,left) +// 排成阅读序,后续所有逻辑都在该序列上进行; +// 2) 估计页面正常行高:全部行高的中位数(对标题/脚注等离群行高鲁棒); +// 3) 按垂直位置建立候选相邻关系:每行找「正下方且横向有实质重叠的最近行」 +// 作为唯一相邻判定对象——横向无重叠的另一栏不会成为候选,天然适配 +// 多栏版面;同行(侧边并排)的行被「下方偏移」条件排除; +// 4) 计算相邻文本行的多维特征:行距 pitch(上缘到上缘)/ 几何空隙 vGap +// (上行下缘到下行上缘)/ 行高比 hRatio / 左缘差 leftDiff / 右端差 +// rightShort(下行右缘超过上行右缘的量,段末短行信号); +// 5) 判断「连续关系」还是「段落边界」:R1~R4 阈值规则(见 k* 常量组注释) +// 全部通过才判连续,任一命中即边界; +// 6) 将连续文本行聚合成段落:连续判定构成单向链(每行至多一进一出), +// 无前驱的行作段首沿链收集成员,段行数超上限处强制截断; +// 7) 对段落结果进行二次修正:孤行吸收(单行段若与后段紧排/对齐/同高且 +// 右端接近后段右缘——即满宽行而非段末短行——则并回,典型目标是行距 +// 中位数被版面混排污染导致的误切)+ 段行高以成员中位数平滑(标题混入 +// 时不抬高正文段的字号)。 +// +// 本文件为纯算法:只依赖 translate_cluster_internal.h 与 C++ 标准库, +// 不触碰 internal.h(napi / GDI+ 依赖链);所有调参常量集中在下方常量组 +// 定义并附判定语义,供真实数据驱动的阈值调优。 +#include "translate_cluster_internal.h" + +#include +#include +#include +#include + +namespace { + +// ==================== 调参常量(尺度为 ×medianH / ×pitchRef) ==================== + +// R1 行高一致:相邻行高之比低于该值的对(标题↔正文、正文↔脚注)判边界 + +constexpr double kHeightRatioMin = 0.62; + +// R2 垂直节奏:pitch 超过 1.5×max(pitchRef, medianH) 判边界;几何空隙 vGap +// 超过 max(0.9×medianH, 0.55×pitchRef) 判边界(对段落间距最可靠的信号, +// 紧排中文文档 vGap≈0 不触发,靠缩进/短行判定);pitch 绝对上限 3.5×medianH +// 兜底(行距估计被污染时仍保住「离谱即边界」) + +constexpr double kPitchTolerance = 1.5; +constexpr double kGapRatioMaxH = 0.9; +constexpr double kGapRatioMaxPitch = 0.55; +constexpr double kPitchAbsMaxRatio = 3.5; + +// R3 左缘对齐:相邻行左缘差超过 0.7×medianH 判边界(段首缩进/悬挂缩进/ +// 换栏都会表现为左缘跳变;段内行的左缘抖动远小于该容差) + +constexpr double kLeftAlignTolerance = 0.7; + +// R4 段末短行:上行右端比下行右端短 ≥2×medianH 且行间略有空隙 +// (vGap ≥ 0.25×medianH)判边界——两端对齐排版的段末行显著短于下一行, +// 是段边界信号;紧排(vGap≈0)时不触发(中文密排文档靠 R3 缩进判段), +// 避免左对齐(右边不齐)排版的段内随机短行被误切 + +constexpr double kShortEndWidth = 2.0; +constexpr double kShortEndGap = 0.25; + +// 候选相邻判定:横向重叠 ≥ 0.2×min(两行宽);下行上缘需比本行上缘低 +// ≥ 0.5×min(两行高)(排除同一视觉行内侧边并排的行) + +constexpr double kOverlapRatioMin = 0.2; +constexpr double kBelowOffsetRatio = 0.5; + +// 段行数上限:超过处在链上强制截断(防病态整栏合并,也约束译文面板规模) + +constexpr int kMaxParaLines = 20; + +// 二次修正——孤行吸收:单行段 A 紧跟段 B,若 vGap ≤ 0.9×medianH、行高比 +// ≥ 0.7、左缘差 ≤ 0.3×medianH、且 A 右端已达 B 最大右缘 - 1.5×medianH +// (满宽行,非段末短行),则并回 B。四个条件合取:只回收被行距阈值误切的 +// 满宽行,真正的独立短段(右端远离后段右缘)与 R4 判出的段末短行均不回收 + +constexpr double kOrphanGapMax = 0.9; +constexpr double kOrphanHeightMin = 0.7; +constexpr double kOrphanLeftTol = 0.3; +constexpr double kOrphanRightTol = 1.5; + +// ==================== 小工具 ==================== + +// 行几何量:聚类内部按阅读序操作的视图(text 指回输入,避免整段拷贝) + +struct LineBox { + int src = -1; // 输入 lines 的原始下标 + double l = 0, t = 0, r = 0, b = 0; // 左/上/右/下(x、x+w、y、y+h) + double w = 0, h = 0; + const std::string* text = nullptr; +}; + +// 中位数:空输入返回 0;偶数个取中间两数均值(nth_element 部分排序) + +double MedianOf(std::vector v) { + if (v.empty()) return 0.0; + size_t mid = v.size() / 2; + std::nth_element(v.begin(), v.begin() + (std::ptrdiff_t)mid, v.end()); + double upper = v[mid]; + if (v.size() % 2 == 1) return upper; + double lower = *std::max_element(v.begin(), v.begin() + (std::ptrdiff_t)mid); + return (lower + upper) / 2.0; +} + +// 取 UTF-8 字符串首码点(无效/截断序列返回 0) + +unsigned DecodeFirstCodepoint(const std::string& s) { + if (s.empty()) return 0; + unsigned char c0 = (unsigned char)s[0]; + size_t need = c0 >= 0xF0 ? 4 : c0 >= 0xE0 ? 3 : c0 >= 0xC0 ? 2 : 1; + if (need > s.size()) return 0; + unsigned cp = c0 >= 0xF0 ? (c0 & 0x07u) : c0 >= 0xE0 ? (c0 & 0x0Fu) + : c0 >= 0xC0 ? (c0 & 0x1Fu) : c0; + for (size_t i = 1; i < need; i++) { + unsigned char ci = (unsigned char)s[i]; + if ((ci & 0xC0) != 0x80) return 0; + cp = (cp << 6) | (ci & 0x3Fu); + } + return cp; +} + +// 取 UTF-8 字符串末码点:从尾部回溯越过 continuation 字节(10xxxxxx,至多 3 个) +// 找到起始字节后按首码点解码 + +unsigned DecodeLastCodepoint(const std::string& s) { + if (s.empty()) return 0; + size_t i = s.size() - 1; + size_t back = 0; + while (i > 0 && ((unsigned char)s[i] & 0xC0) == 0x80 && back < 3) { + i--; + back++; + } + return DecodeFirstCodepoint(s.substr(i)); +} + +// 是否 CJK 码点(部首/注音/表意区/兼容表意/全角形式/扩展区;与 +// translate_windows.cpp 的 IsCjkForWrap 同源判定,供拼接规则使用) + +bool IsCjkCodepoint(unsigned cp) { + return (cp >= 0x2E80 && cp <= 0x9FFF) + || (cp >= 0xF900 && cp <= 0xFAFF) + || (cp >= 0xFF00 && cp <= 0xFFEF) + || (cp >= 0x20000 && cp <= 0x2FA1F); +} + +// 去首尾 ASCII 空白 + +std::string TcTrim(const std::string& s) { + size_t b = s.find_first_not_of(" \t\r\n\f\v"); + if (b == std::string::npos) return std::string(); + size_t e = s.find_last_not_of(" \t\r\n\f\v"); + return s.substr(b, e - b + 1); +} + +// 段落文本拼接:成员行按阅读序连接,CJK ↔ 任意文字直接连接(中文换行处 +// 无空格),拉丁词之间补一个空格;空白成员跳过 + +std::string JoinParaText(const std::vector& seq, const std::vector& members) { + std::string joined; + for (int m : members) { + std::string piece = TcTrim(*seq[m].text); + if (piece.empty()) continue; + if (!joined.empty() + && !IsCjkCodepoint(DecodeLastCodepoint(joined)) + && !IsCjkCodepoint(DecodeFirstCodepoint(piece))) { + joined += ' '; + } + joined += piece; + } + return joined; +} + +} // namespace + +// ==================== 主流程 ==================== + +// 文本行 → 段落聚类主入口(流水线步骤见文件头部注释)。 + +TcClusterResult TcClusterLines(const std::vector& lines) { + TcClusterResult out; + const int n = (int)lines.size(); + if (n == 0) return out; + + // ---- 1) 基础数据预处理:几何量抽取 + 按 (top,left) 排阅读序 ---- + std::vector seq; + seq.reserve(n); + for (int i = 0; i < n; i++) { + LineBox lb; + lb.src = i; + lb.l = lines[i].x; + lb.t = lines[i].y; + lb.w = lines[i].w; + lb.h = lines[i].h; + lb.r = lb.l + lb.w; + lb.b = lb.t + lb.h; + lb.text = &lines[i].text; + seq.push_back(lb); + } + std::sort(seq.begin(), seq.end(), [](const LineBox& a, const LineBox& b) { + if (a.t != b.t) return a.t < b.t; + return a.l < b.l; + }); + + // ---- 2) 估计页面正常行高:全部行高中位数 ---- + std::vector heights; + heights.reserve(n); + for (const LineBox& s : seq) heights.push_back(s.h); + const double medianH = MedianOf(heights); + out.medianLineH = medianH; + + // ---- 3) 候选相邻:每行找「正下方且横向有实质重叠的最近行」 ---- + // O(n²) 双扫(截图选区内文本行数量级 ~百,可忽略);横向无重叠的行 + // (另一栏/另一列)不进入候选,同行并排的行被下方偏移条件排除。 + std::vector below(n, -1); + for (int i = 0; i < n; i++) { + double bestTop = std::numeric_limits::max(); + int best = -1; + for (int j = 0; j < n; j++) { + if (j == i) continue; + const double minH2 = (std::min)(seq[i].h, seq[j].h); + if (seq[j].t - seq[i].t < kBelowOffsetRatio * minH2) continue; + const double overlap = + (std::min)(seq[i].r, seq[j].r) - (std::max)(seq[i].l, seq[j].l); + if (overlap < kOverlapRatioMin * (std::min)(seq[i].w, seq[j].w)) continue; + if (seq[j].t < bestTop) { + bestTop = seq[j].t; + best = j; + } + } + below[i] = best; + } + + // ---- 3b) 估计页面正常行距:行高一致相邻对的 pitch 中位数 ---- + // 过滤行高比不达标(kHeightRatioMin)的对,避免「大标题→正文」类离群对 + // 抬高行距估计;无可统计对时 pitchRef=0(后续阈值退化为按行高尺度)。 + std::vector pitches; + for (int i = 0; i < n; i++) { + const int j = below[i]; + if (j < 0) continue; + if ((std::min)(seq[i].h, seq[j].h) + < kHeightRatioMin * (std::max)(seq[i].h, seq[j].h)) continue; + pitches.push_back(seq[j].t - seq[i].t); + } + const double pitchRef = MedianOf(pitches); + out.medianPitch = pitchRef; + const double pitchMax = kPitchTolerance * (std::max)(pitchRef, medianH); + const double gapMax = (std::max)(kGapRatioMaxH * medianH, kGapRatioMaxPitch * pitchRef); + + // ---- 4)+5) 相邻对多维特征与连续/边界判定 ---- + // link[i]:i 判定为连续的下邻行;pred[j]:j 的连续前驱(每行至多一进一出, + // 后到的前驱候选让位给先到者——同列中只有最近的上行会选中同一下行)。 + std::vector link(n, -1), pred(n, -1); + for (int i = 0; i < n; i++) { + const int j = below[i]; + if (j < 0 || pred[j] >= 0) continue; + const double pitch = seq[j].t - seq[i].t; + const double vGap = seq[j].t - seq[i].b; + const double hRatio = + (std::min)(seq[i].h, seq[j].h) / (std::max)(seq[i].h, seq[j].h); + const double leftDiff = seq[j].l - seq[i].l; + const double rightShort = seq[j].r - seq[i].r; + const bool continuous = + hRatio >= kHeightRatioMin // R1 行高一致 + && pitch <= pitchMax // R2a 行距节奏 + && pitch <= kPitchAbsMaxRatio * medianH // R2c 行距绝对上限 + && vGap <= gapMax // R2b 几何空隙 + && std::fabs(leftDiff) <= kLeftAlignTolerance * medianH // R3 左缘对齐 + && !(rightShort >= kShortEndWidth * medianH // R4 段末短行 + && vGap >= kShortEndGap * medianH); + if (!continuous) { + out.boundaryCount++; + continue; + } + link[i] = j; + pred[j] = i; + } + + // ---- 6) 聚合成段:未消费行作段首沿 link 链收集成员;行数超上限截断 ---- + // 扫描按阅读序(t 升序),链方向严格向下,故到达任一未消费行时其上游链 + // 必已消费完毕;截断后链上剩余节点由外层循环作为新段首接管,行不丢失。 + std::vector> paras; + { + std::vector consumed(n, false); + for (int i = 0; i < n; i++) { + if (consumed[i]) continue; + std::vector members; + int cur = i; + while (cur >= 0 && !consumed[cur]) { + consumed[cur] = true; + members.push_back(cur); + if ((int)members.size() >= kMaxParaLines) break; + cur = link[cur]; + } + paras.push_back(std::move(members)); + } + } + + // ---- 7) 二次修正——孤行吸收 ---- + // 单行段 A 的候选下邻(below)所在段 B 若满足吸收四条件(见 kOrphan* + // 常量注释),则把 A 的行并入 B 的头部。按空间下邻而非段列表顺序寻找 + // 吸收对象:多栏版面的阅读序交错,列表相邻的两段未必空间相邻。 + // 合并后可能与前段再构成吸收关系,循环至稳定。 + bool merged = true; + while (merged) { + merged = false; + std::vector paraOf(n, -1); // seq 下标 → 段下标(合并后重建) + for (size_t pi = 0; pi < paras.size(); pi++) { + for (int m : paras[pi]) paraOf[m] = (int)pi; + } + for (size_t a = 0; a < paras.size() && !merged; a++) { + const std::vector& A = paras[a]; + if (A.size() != 1) continue; + const LineBox& la = seq[A[0]]; + const int j = below[A[0]]; + if (j < 0) continue; + const int bp = paraOf[j]; + if (bp < 0 || bp == (int)a) continue; + const std::vector& B = paras[bp]; + if ((int)(A.size() + B.size()) > kMaxParaLines) continue; + const LineBox& lb = seq[j]; + const double vGap = lb.t - la.b; + const double hRatio = (std::min)(la.h, lb.h) / (std::max)(la.h, lb.h); + double maxRightB = lb.r; + for (int m : B) maxRightB = (std::max)(maxRightB, seq[m].r); + if (vGap <= kOrphanGapMax * medianH + && hRatio >= kOrphanHeightMin + && std::fabs(lb.l - la.l) <= kOrphanLeftTol * medianH + && la.r >= maxRightB - kOrphanRightTol * medianH) { + std::vector combined; + combined.reserve(A.size() + B.size()); + combined.insert(combined.end(), A.begin(), A.end()); + combined.insert(combined.end(), B.begin(), B.end()); + paras[bp] = std::move(combined); + paras.erase(paras.begin() + (std::ptrdiff_t)a); + merged = true; + } + } + } + + // 吸收可能把段首上移(列表顺序不再等于阅读序),按各段首成员位置重排 + std::stable_sort(paras.begin(), paras.end(), + [&seq](const std::vector& a, const std::vector& b) { + if (seq[a[0]].t != seq[b[0]].t) return seq[a[0]].t < seq[b[0]].t; + return seq[a[0]].l < seq[b[0]].l; + }); + + // ---- 8) 产出段落对象:联合框 / 行高中位数平滑 / 文本拼接 ---- + out.paragraphs.reserve(paras.size()); + for (const std::vector& members : paras) { + TcParagraph p; + double L = std::numeric_limits::max(); + double T = std::numeric_limits::max(); + double R = std::numeric_limits::lowest(); + double Bm = std::numeric_limits::lowest(); + std::vector memberHeights; + memberHeights.reserve(members.size()); + for (int m : members) { + const LineBox& s = seq[m]; + L = (std::min)(L, s.l); + T = (std::min)(T, s.t); + R = (std::max)(R, s.r); + Bm = (std::max)(Bm, s.b); + memberHeights.push_back(s.h); + p.lineIdx.push_back(s.src); + } + p.x = L; + p.y = T; + p.w = R - L; + p.h = Bm - T; + p.lineHeight = MedianOf(memberHeights); + p.text = JoinParaText(seq, members); + out.paragraphs.push_back(std::move(p)); + } + return out; +} diff --git a/src/screenshot/algo/translate_cluster_internal.h b/src/screenshot/algo/translate_cluster_internal.h new file mode 100644 index 0000000..a6bc4c4 --- /dev/null +++ b/src/screenshot/algo/translate_cluster_internal.h @@ -0,0 +1,46 @@ +// 截图翻译子系统:OCR 文本行 → 段落的空间聚类(纯算法,跨平台)。 +// +// 拆分动机:OCR 识别结果只按行给出(行级坐标 + 行文本),逐行翻译会把一句 +// 话的多个分行拆散翻译、逐行覆盖会把一个段落渲染成多个碎面板。本模块按 +// 空间关系把「同一段落」的连续文本行聚类还原,供调用方(translate_windows.cpp) +// 以段落为单位发起翻译并渲染整段覆盖面板。 +// +// 与长截图的 lc_match_core.cpp 同级的纯算法单元:只依赖 C++ 标准库,不触碰 +// internal.h(napi / GDI+ 依赖链),双平台可共用;输入输出均为纯数据结构, +// 天然可单测(test/translate-cluster-selftest.cpp)。 +#pragma once + +#include +#include + +// 聚类输入:一行 OCR 文本(提交图像内像素坐标;x/y 为左上角,w/h 为宽高, +// 与 translate_windows.cpp 的 OcrRawBlock 同形,由调用方直接转换) + +struct TcLine { + std::string text; + double x = 0, y = 0, w = 0, h = 0; +}; + +// 聚类产出的一个段落 + +struct TcParagraph { + std::vector lineIdx; // 成员行下标(输入 lines 的原始下标,按阅读序) + double x = 0, y = 0, w = 0, h = 0; // 成员行包围盒并集(提交图像内像素坐标) + double lineHeight = 0; // 段落原文行高估计(成员行高中位数,译文覆盖字号适配依据) + std::string text; // 成员行文本按 CJK/拉丁规则拼接的结果(翻译请求体) +}; + +// 聚类结果 + 全局统计(供调用方日志观测与阈值调优) + +struct TcClusterResult { + std::vector paragraphs; // 段落(按首行阅读序排列) + double medianLineH = 0; // 页面正常行高估计(全部行高的中位数) + double medianPitch = 0; // 页面正常行距估计(行高一致相邻对的 pitch 中位数) + int boundaryCount = 0; // 判为段落边界的相邻对数量 +}; + +// 主入口:OCR 文本行数组 → 段落数组。纯函数(无副作用、无平台依赖): +// 输入为空 → 输出空;单行 → 输出单行段落; +// 判定流水线(预处理 → 行高/行距估计 → 候选相邻 → 多维特征 → 连续/边界 +// 判定 → 链式聚合 → 孤行吸收等二次修正)见 translate_cluster.cpp 头部注释。 +TcClusterResult TcClusterLines(const std::vector& lines); diff --git a/src/screenshot/windows/capture_context.h b/src/screenshot/windows/capture_context.h index 0ba891e..a335846 100644 --- a/src/screenshot/windows/capture_context.h +++ b/src/screenshot/windows/capture_context.h @@ -156,6 +156,17 @@ struct CaptureContext { RECT popupRect = {}; SCPopupMetrics popupMetrics; + // ---- 翻译覆盖(工具栏「翻译」按钮:OCR 识别选区文字 → 翻译 → 译文覆盖原区域)---- + // 状态机与数据结构见 sc_types.h;全部字段仅截图线程读写(工作线程经结果槽 + + // WM_SCREENSHOT_TRANSLATE_RESULT 消息回投,见 translate_windows.cpp)。 + TranslateOverlayState translateState = TRL_Idle; // 翻译覆盖状态机 + std::vector translateBlocks; // 译文覆盖块(TRL_Shown 时有效) + bool translateStatusShown = false; // 状态气泡是否在屏(进度/错误) + bool translateStatusError = false; // 气泡是否为错误态(错误 TTL 自动消失) + DWORD translateStatusAt = 0; // 气泡出现时刻(TTL 计时) + RECT translateStatusRect = {}; // 气泡矩形(backDC 相对坐标) + std::wstring translateStatusText; // 气泡文本 + // ---- 文字输入(CS_TextEditing)---- std::wstring textBuf; // 正在输入的文字缓冲 int textAnchorX = 0, textAnchorY = 0; // 文字锚点(绝对虚拟屏幕坐标) diff --git a/src/screenshot/windows/capture_windows.cpp b/src/screenshot/windows/capture_windows.cpp index f123f0d..580e809 100644 --- a/src/screenshot/windows/capture_windows.cpp +++ b/src/screenshot/windows/capture_windows.cpp @@ -1,6 +1,8 @@ // 截图模块:屏幕捕获管线(预截屏、GDI 采集、DPI/显示器/窗口枚举) #include "internal.h" +#include "../../logger.h" + static void ReleasePrimedScreenshotFrameLocked() { if (g_primedScreenshotFrame.bitmap) { DeleteObject(g_primedScreenshotFrame.bitmap); @@ -152,6 +154,7 @@ bool PrimeScreenshotFrameNow() { int vx = 0, vy = 0, vw = 0, vh = 0; double dpiScale = 1.0; if (!CaptureVirtualScreen(memDC, bitmap, vx, vy, vw, vh, dpiScale)) { + ZLOG_WARN("screenshot", "prime frame: CaptureVirtualScreen failed"); return false; } @@ -169,6 +172,7 @@ bool PrimeScreenshotFrameNow() { g_primedScreenshotFrame.dpiScale = dpiScale; g_primedScreenshotFrame.capturedAt = std::chrono::steady_clock::now(); g_primedScreenshotFrame.valid = true; + ZLOG_DEBUG("screenshot", "prime frame ok (%dx%d)", vw, vh); return true; } diff --git a/src/screenshot/windows/internal.h b/src/screenshot/windows/internal.h index ad3f1f0..906a90e 100644 --- a/src/screenshot/windows/internal.h +++ b/src/screenshot/windows/internal.h @@ -66,6 +66,11 @@ namespace Gdiplus { #define WM_LONGCAPTURE_RUN (WM_APP + 200) +// 翻译完成回投消息:翻译工作线程经「单飞行结果槽 + PostMessage」通知截图线程 +// 取走结果并落到 CaptureContext(槽协议见 translate_windows.cpp) + +#define WM_SCREENSHOT_TRANSLATE_RESULT (WM_APP + 201) + // ---- 可变全局变量(定义见各归属 .cpp)---- // 访问规则(此前线程归属散落在各字段注释,现集中声明): // 线程模型——本模块仅两条线程访问这些全局:JS 线程(NAPI 导出 start/abort 等)与 @@ -196,6 +201,19 @@ void DrawToolbar(HDC hdc, const RECT& toolbarRect, int hoverBtn, int activeTool, // Draw 在 OnPaint 工具栏之后调用(气泡画进 backDC,盖在工具栏/子菜单之上)。 void TickToolbarTooltip(CaptureContext* ctx, HWND overlayWnd); void DrawToolbarTooltip(HDC hdc, CaptureContext* ctx); +// ---- 工具栏「翻译」按钮(OCR 识别选区文字 → 翻译 → 译文覆盖原区域,见 translate_windows.cpp)---- +// 点击入口(截图线程):裁剪选区编码 PNG → 转入 TRL_Busy → 起翻译工作线程 +bool BeginTranslateOverlay(CaptureContext* ctx, HWND overlayHwnd); +// WM_SCREENSHOT_TRANSLATE_RESULT 接管(截图线程):从结果槽取走翻译结果落到 ctx +void HandleTranslateJobResult(CaptureContext* ctx, HWND hwnd); +// 会话空闲循环节拍:错误状态气泡超时收起 +void TickTranslateStatus(CaptureContext* ctx, HWND hwnd); +// OnPaint 入口:确认态下绘制译文覆盖块与状态气泡(标注之上、轮廓/工具栏之下) +void DrawTranslateOverlay(HDC hdc, CaptureContext* ctx); +// 导出合成入口:把译文覆盖块合成进最终位图(finalDC 原点 = 选区左上角,逻辑像素) +void CompositeTranslateBlocks(HDC finalDC, const std::vector& blocks, const RECT& rect); +// 会话收尾清槽:释放仍在结果槽中未被取走的翻译结果(防跨会话泄漏) +void TeardownTranslateJobs(); bool MosaicBlitRect(HDC targetDC, HDC srcDC, int dstX0, int dstY0, int dstW, int dstH, int srcAbsX0, int srcAbsY0, int blockPx, int virtualX, int virtualY, double dpiScale); void FreeMosaicBase(CaptureContext* ctx); void InitMosaicBrushCursors(CaptureContext* ctx); @@ -216,8 +234,10 @@ bool EncodeHBitmapPng(HBITMAP hBitmap, std::string* base64Out, std::string* rawO // 因此同时把画刷原点复位到 (0,0) 避免抖动(MSDN 推荐配套调用)。 void SetHalftoneStretchMode(HDC dc); +// translateBlocks:已展示的翻译覆盖块,会随标注一起合成进导出图像(默认空 = 无翻译内容)。 ScreenshotResult* ExtractRegionResult(HDC memDC, const RECT& rect, int vx, int vy, - double dpiScale, const std::vector& anns, int radius, int mosaicSizeIdx); + double dpiScale, const std::vector& anns, int radius, int mosaicSizeIdx, + const std::vector& translateBlocks = {}); // 统一的 ScreenshotResult 发射口:字段参数化构造结果并经截图会话 TSFN 回传 JS。 // 内部统一处理守卫:TSFN 未就绪或 napi_tsfn_nonblocking 因队列满返回非 napi_ok 时, // 自行 delete 分配的 result 防泄漏(CallScreenshotJs 只在成功入队时才 delete)。 @@ -233,7 +253,8 @@ bool RunLongCapture(LongCaptureContext* c); void LongCaptureAbort(); std::wstring PromptSaveFilePath(HWND hwndOwner); bool SaveRegionToPngFile(HDC memDC, const RECT& rect, int vx, int vy, double dpiScale, - const std::vector& anns, const std::wstring& filePath, int radius, int mosaicSizeIdx); + const std::vector& anns, const std::wstring& filePath, int radius, int mosaicSizeIdx, + const std::vector& translateBlocks = {}); void ClampCornerRadius(CaptureContext* ctx); bool CalcAnnotationsBounds(std::vector& anns, RECT& out, HDC hdc); RECT MeasureTextAnnotation(HDC hdc, Annotation& a); diff --git a/src/screenshot/windows/lc_session_windows.cpp b/src/screenshot/windows/lc_session_windows.cpp index 55fd814..6102787 100644 --- a/src/screenshot/windows/lc_session_windows.cpp +++ b/src/screenshot/windows/lc_session_windows.cpp @@ -3,9 +3,12 @@ // 本文件定义长截图会话级全局状态(g_longCtx / 控制窗口句柄 / 参数默认值), // 手动滚动捕获主循环 RunLongCapture,以及会话初始化 BeginLongCapture、 // JS 线程中止 LongCaptureAbort 与清理入口 DestroyLongCaptureContext。 +// 截图模块拆分文件统一经由 internal.h 引入公共依赖;logger.h 单独引入 #include "internal.h" #include "long_capture_internal.h" +#include "../../logger.h" + // g_longControlWindow 为侧边小地图面板(完成/取消按钮也在此)。 // g_longCtx 由 BeginLongCapture 创建、WM_LONGCAPTURE_RUN 完成后释放; @@ -44,6 +47,9 @@ void LongCaptureEmitFailure() { void BeginLongCapture(CaptureContext* ctx, HWND overlayHwnd) { LongCaptureContext* lc = new LongCaptureContext(); lc->interval = g_lcInterval; + ZLOG_INFO("longcapture", "begin (interval=%dms, selection=(%ld,%ld)-(%ld,%ld))", + g_lcInterval, (long)ctx->selection.left, (long)ctx->selection.top, + (long)ctx->selection.right, (long)ctx->selection.bottom); lc->vx = ctx->virtualX; lc->vy = ctx->virtualY; lc->vw = ctx->virtualW; lc->vh = ctx->virtualH; lc->dpiScale = ctx->dpiScale; @@ -297,7 +303,11 @@ bool RunLongCapture(LongCaptureContext* c) { LongCaptureUnregisterWheelObserver(); - if (c->abortFlag.load()) { LongCaptureEmitFailure(); return false; } + if (c->abortFlag.load()) { + ZLOG_INFO("longcapture", "aborted by user after %d frames", c->frameCount.load()); + LongCaptureEmitFailure(); + return false; + } // 拼接 + 输出(复用截图输出管线): // · 完成并复制:拼接结果缩放回逻辑尺寸 → base64 + 剪贴板; @@ -315,6 +325,10 @@ bool RunLongCapture(LongCaptureContext* c) { } c->success = ok; + ZLOG_INFO("longcapture", "finished: ok=%d frames=%d out=%dx%d %s", ok ? 1 : 0, + c->frameCount.load(), c->outWidth, c->outHeight, + savedToFile ? "(saved to file)" : "(clipboard)"); + // 回调(统一走 EmitScreenshotResult:TSFN 空 / nonblocking 失败均自动释放防泄漏; // 原代码先 new 后判 TSFN 且不查 nonblocking 返回值,TSFN 为空或队列满时 result 泄漏 // —— 此处一并修复)。 diff --git a/src/screenshot/windows/output_windows.cpp b/src/screenshot/windows/output_windows.cpp index d043c93..079e7b3 100644 --- a/src/screenshot/windows/output_windows.cpp +++ b/src/screenshot/windows/output_windows.cpp @@ -319,6 +319,7 @@ static bool SaveArgbBitmapToClipboard(HBITMAP hbmp, int w, int h, static HBITMAP BuildRoundedArgbFinal(HDC memDC, const RECT& rect, int vx, int vy, double dpiScale, const std::vector& anns, int radius, int mosaicSizeIdx, + const std::vector& translateBlocks, HDC& outDC, void*& outBits, int& outW, int& outH) { outDC = NULL; outBits = NULL; outW = 0; outH = 0; int width = rect.right - rect.left; @@ -402,6 +403,8 @@ static HBITMAP BuildRoundedArgbFinal(HDC memDC, const RECT& rect, int vx, int vy } CompositeAnnotations(finalDC, memDC, anns, rect, vx, vy, dpiScale, SC_MOSAIC_SIZES[mosaicSizeIdx]); + // 翻译覆盖块随标注一起合成(白底面板盖住原文字 + 译文,见 translate_windows.cpp) + CompositeTranslateBlocks(finalDC, translateBlocks, rect); // 圆角蒙版:同尺寸 32bpp DIB,不透明黑底 + GDI+ 填白圆角路径,取 RGB 通道作 coverage int r = (std::min)(radius, (std::min)(finalW, finalH) / 2); @@ -467,6 +470,7 @@ static HBITMAP BuildRoundedArgbFinal(HDC memDC, const RECT& rect, int vx, int vy static bool ComposeSelectedBitmap(HDC memDC, const RECT& rect, int vx, int vy, double dpiScale, const std::vector& anns, int mosaicSizeIdx, + const std::vector& translateBlocks, HDC& outFinalDC, HBITMAP& outFinalBmp) { outFinalDC = NULL; outFinalBmp = NULL; int width = rect.right - rect.left; @@ -516,6 +520,8 @@ static bool ComposeSelectedBitmap(HDC memDC, const RECT& rect, int vx, int vy, // 合成标注进最终图像(finalDC 原点 = 选区原点,标注为绝对坐标,偏移 = -rect.left/top) CompositeAnnotations(finalDC, memDC, anns, rect, vx, vy, dpiScale, SC_MOSAIC_SIZES[mosaicSizeIdx]); + // 翻译覆盖块随标注一起合成(圆角路径在后续 radius>0 分支处理,此处为不透明路径) + CompositeTranslateBlocks(finalDC, translateBlocks, rect); } ReleaseDC(NULL, screenDC); @@ -536,11 +542,12 @@ static bool ComposeSelectedBitmap(HDC memDC, const RECT& rect, int vx, int vy, // anns:可选的标注列表,会合成进最终 PNG(选区相对坐标,finalDC 原点 = 选区原点)。 // radius>0 走圆角透明导出(32bpp ARGB + 圆角蒙版 + PNG/PNG剪贴板);radius==0 维持不透明位图路径不变。 // mosaicSizeIdx:马赛克块大小索引(显式传参,消除对全局 g_captureCtx 的穿透耦合)。 +// translateBlocks:已展示的翻译覆盖块,随标注一起合成进导出图像(默认空 = 无翻译内容)。 // 拷贝/缩放失败时 success 保持 false 且不产出 base64/剪贴板数据(资源全量清理)。 ScreenshotResult* ExtractRegionResult(HDC memDC, const RECT& rect, int vx, int vy, double dpiScale, const std::vector& anns, - int radius, int mosaicSizeIdx) { + int radius, int mosaicSizeIdx, const std::vector& translateBlocks) { ScreenshotResult* result = new ScreenshotResult(); result->success = false; int width = rect.right - rect.left; @@ -558,7 +565,7 @@ ScreenshotResult* ExtractRegionResult(HDC memDC, const RECT& rect, if (radius > 0) { HDC fDC = NULL; void* fBits = NULL; int fw = 0, fh = 0; HBITMAP fbmp = BuildRoundedArgbFinal(memDC, rect, vx, vy, dpiScale, anns, radius, - mosaicSizeIdx, fDC, fBits, fw, fh); + mosaicSizeIdx, translateBlocks, fDC, fBits, fw, fh); if (fbmp && fBits) { std::string rawPng, b64; if (EncodePremulArgbPng(fBits, fw, fh, fw * 4, &b64, &rawPng)) { @@ -574,7 +581,7 @@ ScreenshotResult* ExtractRegionResult(HDC memDC, const RECT& rect, // radius==0:原有不透明位图路径(公共合成,收口至 ComposeSelectedBitmap) HDC finalDC = NULL; HBITMAP finalBmp = NULL; if (ComposeSelectedBitmap(memDC, rect, vx, vy, dpiScale, anns, mosaicSizeIdx, - finalDC, finalBmp)) { + translateBlocks, finalDC, finalBmp)) { // 生成 base64 result->base64 = BitmapToBase64Png(finalBmp); // 复制到剪贴板 @@ -666,11 +673,13 @@ std::wstring PromptSaveFilePath(HWND hwndOwner) { // 将已合成标注的选区 HBITMAP 保存为 PNG 文件。 // 返回 true 表示保存成功。 // mosaicSizeIdx:马赛克块大小索引(显式传参,消除对全局 g_captureCtx 的穿透耦合)。 +// translateBlocks:已展示的翻译覆盖块,随标注一起合成进落盘图像(默认空 = 无翻译内容)。 // 选区拷贝/缩放任一环节失败时同样返回 false 并清理全部已建 GDI 资源,不会落盘黑图。 bool SaveRegionToPngFile(HDC memDC, const RECT& rect, int vx, int vy, double dpiScale, const std::vector& anns, - const std::wstring& filePath, int radius, int mosaicSizeIdx) { + const std::wstring& filePath, int radius, int mosaicSizeIdx, + const std::vector& translateBlocks) { int width = rect.right - rect.left; int height = rect.bottom - rect.top; if (width <= 0 || height <= 0 || filePath.empty()) return false; @@ -679,7 +688,7 @@ bool SaveRegionToPngFile(HDC memDC, const RECT& rect, int vx, int vy, if (radius > 0) { HDC fDC = NULL; void* fBits = NULL; int fw = 0, fh = 0; HBITMAP fbmp = BuildRoundedArgbFinal(memDC, rect, vx, vy, dpiScale, anns, radius, - mosaicSizeIdx, fDC, fBits, fw, fh); + mosaicSizeIdx, translateBlocks, fDC, fBits, fw, fh); bool ok = false; if (fbmp && fBits) { CLSID pngClsid; @@ -697,7 +706,7 @@ bool SaveRegionToPngFile(HDC memDC, const RECT& rect, int vx, int vy, HDC finalDC = NULL; HBITMAP finalBmp = NULL; bool ok = false; if (ComposeSelectedBitmap(memDC, rect, vx, vy, dpiScale, anns, mosaicSizeIdx, - finalDC, finalBmp)) { + translateBlocks, finalDC, finalBmp)) { // 用 GDI+ 保存为 PNG 文件(GDI+ 已由会话级 InitGdipResources 启动) Gdiplus::Bitmap* bmp = Gdiplus::Bitmap::FromHBITMAP(finalBmp, NULL); if (bmp) { diff --git a/src/screenshot/windows/overlay_input_windows.cpp b/src/screenshot/windows/overlay_input_windows.cpp index 87541d5..3f06d78 100644 --- a/src/screenshot/windows/overlay_input_windows.cpp +++ b/src/screenshot/windows/overlay_input_windows.cpp @@ -213,11 +213,11 @@ LRESULT OnLButtonDown(HWND hwnd, CaptureContext* ctx) { } } } - // 确定:提取选区并完成截图 + // 确定:提取选区并完成截图(含已展示的翻译覆盖块) if (b == TB_Confirm) { ScreenshotResult* result = ExtractRegionResult(ctx->memDC, ctx->selection, ctx->virtualX, ctx->virtualY, ctx->dpiScale, ctx->annotations, ctx->selectionCornerRadius, - ctx->mosaicSizeIdx); + ctx->mosaicSizeIdx, ctx->translateBlocks); // 统一走 EmitScreenshotResult:TSFN 空 / nonblocking 失败均自动释放 result 防泄漏。 EmitScreenshotResult(result->success, result->x, result->y, result->x2, result->y2, result->width, result->height, result->base64); @@ -339,7 +339,8 @@ LRESULT OnLButtonDown(HWND hwnd, CaptureContext* ctx) { if (!filePath.empty()) { bool saved = SaveRegionToPngFile(ctx->memDC, ctx->selection, ctx->virtualX, ctx->virtualY, ctx->dpiScale, - ctx->annotations, filePath, ctx->selectionCornerRadius, ctx->mosaicSizeIdx); + ctx->annotations, filePath, ctx->selectionCornerRadius, ctx->mosaicSizeIdx, + ctx->translateBlocks); // 无论保存成功与否,均关闭截图窗口(用户已选择保存路径) // 通过回调告知 JS 结果(成功/失败),不回传路径。 // 统一走 EmitScreenshotResult:TSFN 空 / nonblocking 失败均自动释放防泄漏 @@ -358,6 +359,12 @@ LRESULT OnLButtonDown(HWND hwnd, CaptureContext* ctx) { // 用户取消保存对话框:不关闭,留在编辑态 return 0; } + // 翻译:OCR 识别选区文字 → 翻译 → 译文覆盖原文字区域(流程见 translate_windows.cpp)。 + // 进行中重复点击忽略;译文展示中再次点击退出翻译状态(清除覆盖,不重跑)。 + if (b == TB_Translate) { + BeginTranslateOverlay(ctx, hwnd); + return 0; + } // 长截图:复用当前选区进入手动滚动捕获(隐藏覆盖层 → 预览面板 → 滚轮增量拼接) if (b == TB_LongCapture) { BeginLongCapture(ctx, hwnd); @@ -1164,7 +1171,7 @@ LRESULT OnLButtonUp(HWND hwnd, CaptureContext* ctx) { if (ctx->autoConfirm) { ScreenshotResult* result = ExtractRegionResult(ctx->memDC, finalRect, ctx->virtualX, ctx->virtualY, ctx->dpiScale, ctx->annotations, ctx->selectionCornerRadius, - ctx->mosaicSizeIdx); + ctx->mosaicSizeIdx, ctx->translateBlocks); // 统一走 EmitScreenshotResult:TSFN 空 / nonblocking 失败均自动释放 result 防泄漏。 EmitScreenshotResult(result->success, result->x, result->y, result->x2, result->y2, result->width, result->height, result->base64); @@ -1321,7 +1328,7 @@ LRESULT OnKeyDown(HWND hwnd, WPARAM wParam, CaptureContext* ctx) { if (ctx->state == CS_Confirmed) { ScreenshotResult* result = ExtractRegionResult(ctx->memDC, ctx->selection, ctx->virtualX, ctx->virtualY, ctx->dpiScale, ctx->annotations, ctx->selectionCornerRadius, - ctx->mosaicSizeIdx); + ctx->mosaicSizeIdx, ctx->translateBlocks); // 统一走 EmitScreenshotResult:TSFN 空 / nonblocking 失败均自动释放 result 防泄漏。 EmitScreenshotResult(result->success, result->x, result->y, result->x2, result->y2, result->width, result->height, result->base64); diff --git a/src/screenshot/windows/overlay_paint_windows.cpp b/src/screenshot/windows/overlay_paint_windows.cpp index a76916f..9a798d3 100644 --- a/src/screenshot/windows/overlay_paint_windows.cpp +++ b/src/screenshot/windows/overlay_paint_windows.cpp @@ -415,6 +415,9 @@ LRESULT OnPaint(HWND hwnd, CaptureContext* ctx) { } } } + // 翻译覆盖(工具栏「翻译」按钮):译文块盖住原文字区域 + 进行中/错误状态气泡。 + // 画在标注之上、选区轮廓/工具栏/tooltip 之下,确认态/绘制/文字编辑各态均保持展示。 + DrawTranslateOverlay(backDC, ctx); // 确认态边框和调整手柄最后绘制,避免马赛克及其他标注覆盖交互轮廓。 DrawConfirmedBorder(backDC, curSelRect, ctx->gdi, ctx->selectionCornerRadius); // 正在拖拽倒角手柄(CS_Resizing + 角手柄):此时隐藏选区 resize 手柄,仅留被拖的倒角手柄。 diff --git a/src/screenshot/windows/sc_types.h b/src/screenshot/windows/sc_types.h index 848fe3b..cbbc621 100644 --- a/src/screenshot/windows/sc_types.h +++ b/src/screenshot/windows/sc_types.h @@ -7,6 +7,7 @@ #include #include #include +#include static const auto SC_PRIMED_FRAME_TTL = std::chrono::seconds(2); @@ -205,6 +206,39 @@ struct ScreenshotResult { std::string base64; }; +// ==================== 翻译覆盖(工具栏「翻译」按钮) ==================== + +// 翻译覆盖状态机:点击「翻译」→ Busy(OCR+翻译进行中,显示进度气泡)→ +// Shown(译文块覆盖原文字区域)或回退 Idle(失败,错误气泡数秒后自动消失); +// Shown 下再次点击「翻译」清除覆盖回到 Idle(切换语义,不重跑) + +enum TranslateOverlayState { + TRL_Idle = 0, // 无翻译内容 + TRL_Busy = 1, // 识别/翻译进行中 + TRL_Shown = 2 // 译文覆盖展示中 +}; + +// 一个译文覆盖块:OCR 识别出的原文字区域(绝对虚拟屏幕逻辑坐标,与标注同坐标系, +// 选区移动/缩放时保持不动)+ 译文文本 + 原段落版面特征。绘制与导出合成见 +// translate_windows.cpp:字号按段落独立搜索(密度对齐原段落,见 FitParagraph), +// 搜索含多次真实排版测量,结果缓存在 fit* 字段(键 = 绘制时框宽高)。 + +struct TranslateBlock { + RECT box; // 原文字区域(绝对虚拟屏幕逻辑坐标,已与选区求交) + std::wstring text; // 译文 + double lineHeightPx = 0; // 原文单行行高估计(逻辑像素;成员行高中位数, + // 作为原字号估计 / 字号搜索锚点的基准) + // ---- 原段落版面特征(来自聚类成员行;整图兜底块 origLineCount = 0 表示未知)---- + int origLineCount = 0; // 原段落行数 + double origWidthUtil = 0; // 原平均行宽利用率 = 成员行宽均值 / 段宽 + double origVDensity = 0; // 原垂直密度 = 行数 × 中位行高 / 段高 + // ---- 排版缓存(绘制线程写;fitKeyW/H 命中时复用以下三字段,免每帧重排)---- + int fitKeyW = -1, fitKeyH = -1; // 缓存键:绘制时(与选区交集后的)框宽高 + int fitFontPx = 0; // 搜索定出的最终字号 + float fitLineH = 0.0f; // 该字号下行高(font.GetHeight) + std::vector fitLines; // 最终折行结果 +}; + // GDI 资源缓存 struct SCGdiResources { diff --git a/src/screenshot/windows/session_windows.cpp b/src/screenshot/windows/session_windows.cpp index 1dee2b4..8b13ced 100644 --- a/src/screenshot/windows/session_windows.cpp +++ b/src/screenshot/windows/session_windows.cpp @@ -1,6 +1,8 @@ // 截图模块:会话线程、TSFN 桥与 NAPI 入口(公共 API 见 screenshot_windows.h) #include "internal.h" +#include "../../logger.h" + // 全局变量 - 区域截图 HWND g_screenshotOverlayWindow = NULL; @@ -234,6 +236,12 @@ static void CallScreenshotJs(napi_env env, napi_value js_callback, void* context // success=false 时坐标/尺寸/base64 全置 0/空(与取消路径语义一致)。 void EmitScreenshotResult(bool success, int x, int y, int x2, int y2, int width, int height, const std::string& base64) { + if (success) { + ZLOG_INFO("screenshot", "session result: success rect=(%d,%d)-(%d,%d) size=%dx%d pngBytes=%zu", + x, y, x2, y2, width, height, base64.size()); + } else { + ZLOG_INFO("screenshot", "session result: cancelled/failed"); + } ScreenshotResult* result = new ScreenshotResult(); result->success = success; result->x = x; result->y = y; @@ -253,6 +261,7 @@ void EmitScreenshotResult(bool success, int x, int y, int x2, int y2, // 会话初始化失败快速回传:构造 {success:false} 结果并经 EmitScreenshotResult 回传 JS, // 唤醒 await 方避免永久挂起(早退路径统一收口点)。 void FailFast() { + ZLOG_ERROR("screenshot", "capture session init failed (fail-fast)"); EmitScreenshotResult(false); } @@ -289,16 +298,20 @@ void ScreenshotCaptureThread() { HBITMAP screenBitmap = NULL; int vx, vy, vw, vh; if (!AcquireScreenshotBase(memDC, screenBitmap, vx, vy, vw, vh, dpiScale)) { + ZLOG_ERROR("screenshot", "AcquireScreenshotBase failed (virtual screen %dx%d)", vw, vh); FailFast(); ReleaseScreenshotTsfn(); g_isCapturing = false; return; } + ZLOG_INFO("screenshot", "session thread started (virtual screen %dx%d at %d,%d, uiScale=%.2f)", + vw, vh, vx, vy, uiScale); // 创建双缓冲 HDC backDC = NULL; HBITMAP backBmp = NULL; if (!CreateBackBuffer(backDC, backBmp, vw, vh)) { + ZLOG_ERROR("screenshot", "CreateBackBuffer failed (%dx%d)", vw, vh); DeleteDC(memDC); DeleteObject(screenBitmap); FailFast(); @@ -363,6 +376,7 @@ void ScreenshotCaptureThread() { // GDI+ 会话级初始化(必须在 InitMosaicBrushCursors 及任何 GDI+ 调用之前): // 会话内单次 Startup,避免每帧反复初始化导致拖拽卡顿。 if (!InitGdipResources(&ctx)) { + ZLOG_ERROR("screenshot", "InitGdipResources failed"); gdi.Cleanup(); ctx.iconCache.Cleanup(); DeleteDC(backDC); DeleteObject(backBmp); @@ -442,6 +456,7 @@ void ScreenshotCaptureThread() { wc.lpszClassName = L"ZToolsScreenshotOverlay"; if (!RegisterClassExW(&wc)) { + ZLOG_ERROR("screenshot", "RegisterClassExW(overlay) failed"); gdi.Cleanup(); ctx.iconCache.Cleanup(); DeleteDC(backDC); DeleteObject(backBmp); @@ -464,6 +479,7 @@ void ScreenshotCaptureThread() { ); if (g_screenshotOverlayWindow == NULL) { + ZLOG_ERROR("screenshot", "create overlay window failed"); UnregisterClassW(L"ZToolsScreenshotOverlay", GetModuleHandle(NULL)); gdi.Cleanup(); ctx.iconCache.Cleanup(); @@ -478,6 +494,7 @@ void ScreenshotCaptureThread() { ShowWindow(g_screenshotOverlayWindow, SW_SHOW); SetForegroundWindow(g_screenshotOverlayWindow); + ZLOG_INFO("screenshot", "overlay shown (autoConfirm=%d)", ctx.autoConfirm ? 1 : 0); // 消息循环 MSG msg; @@ -492,6 +509,9 @@ void ScreenshotCaptureThread() { // 工具栏 title 式 tooltip 停顿轮询(悬停 ~0.5s 出气泡;内部按状态门控) TickToolbarTooltip(&ctx, g_screenshotOverlayWindow); + // 翻译状态气泡节拍(错误气泡超时收起;进度气泡由结果消息收起) + TickTranslateStatus(&ctx, g_screenshotOverlayWindow); + // 文字编辑态:光标闪烁(每 500ms 切换) if (ctx.state == CS_TextEditing) { DWORD now = GetTickCount(); @@ -523,7 +543,12 @@ void ScreenshotCaptureThread() { } // 清理 + ZLOG_INFO("screenshot", "session message loop exited (state=%s)", + ctx.state == CS_Done ? "done" : (ctx.state == CS_Cancelled ? "cancelled" : "quit")); g_captureCtx = nullptr; + // 翻译结果槽清零:仍在槽中未被取走的翻译结果在此释放,防止跨会话泄漏 + //(迟到的发布者随后 PostMessage 失败会自行回收,协议见 translate_windows.cpp) + TeardownTranslateJobs(); gdi.Cleanup(); ctx.iconCache.Cleanup(); FreeMosaicBase(&ctx); @@ -565,6 +590,7 @@ Napi::Value StartRegionCaptureWithPrimedFrame(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); if (g_isCapturing) { + ZLOG_WARN("screenshot", "start requested while a session is already in progress"); Napi::Error::New(env, "Screenshot already in progress").ThrowAsJavaScriptException(); return env.Undefined(); } @@ -594,6 +620,7 @@ Napi::Value StartRegionCaptureWithPrimedFrame(const Napi::CallbackInfo& info) { ); if (status != napi_ok) { + ZLOG_ERROR("screenshot", "create threadsafe function failed"); Napi::Error::New(env, "Failed to create threadsafe function").ThrowAsJavaScriptException(); return env.Undefined(); } @@ -625,6 +652,8 @@ Napi::Value StartRegionCaptureWithPrimedFrame(const Napi::CallbackInfo& info) { g_lcInterval = lcInterval; g_isCapturing = true; + ZLOG_INFO("screenshot", "capture session requested (autoConfirm=%d, longCaptureInterval=%dms)", + autoConfirm ? 1 : 0, lcInterval); g_screenshotThread = std::thread(ScreenshotCaptureThread); g_screenshotThread.detach(); @@ -636,6 +665,7 @@ Napi::Value StartRegionCaptureWithPrimedFrame(const Napi::CallbackInfo& info) { // 仅设置中止标记:由滚动循环在下一帧检查点退出,并按失败结果回调 JS。 Napi::Value AbortLongCapture(const Napi::CallbackInfo& info) { + ZLOG_INFO("longcapture", "abort requested from JS"); LongCaptureAbort(); return info.Env().Undefined(); } diff --git a/src/screenshot/windows/translate_windows.cpp b/src/screenshot/windows/translate_windows.cpp new file mode 100644 index 0000000..31aeb24 --- /dev/null +++ b/src/screenshot/windows/translate_windows.cpp @@ -0,0 +1,1212 @@ +// 截图模块:工具栏「翻译」按钮(OCR 识别选区文字 → 翻译 → 译文覆盖原文字区域)。 +// +// 流程(点击 TB_Translate 后): +// 1. 截图线程(覆盖层窗口过程所在线程)把选区按物理分辨率从预截屏位图裁出并编码为 +// PNG base64(GDI/GDI+ 调用全部留在截图线程内,规避跨线程使用会话级 GDI+ 的 +// 生命周期竞争),随后立即转入 TRL_Busy 状态并显示进度气泡; +// 2. 工作线程经 Provider 桥(src/provider_bridge.h)原生编排两步 provider 调用: +// a. type="ocr" 提交选区图像,识别结果为带行级坐标(top/left/right/bottom, +// 提交图像内像素坐标)的文本行数组——行高即原文字高度,是译文覆盖定位 +// 与字号适配的依据; +// b. 文本行先经空间关系聚类还原成段落(algo/translate_cluster.cpp:行高/行距 +// 估计 → 候选相邻 → 多维特征判定连续/边界 → 聚合 + 孤行吸收二次修正, +// 解决 OCR 按行输出导致的段落信息丢失),再对每个段落调用一次 +// type="translation"(契约入参为单条 text,段落文本已按 CJK/拉丁规则拼接; +// 段为单位串行调用以尊重 provider 限流),在原生层把译文与对应段落的成员行 +// 联合框合并成可渲染块,段落原文行高中位数随块带给渲染层定字号; +// OCR provider 未返回坐标(如纯文本 AI 识别)时走整图兜底:整段文本一次 +// 翻译、以整个选区为单一面板; +// 3. 结果打包后经「单飞行结果槽 + PostMessage」回投截图线程:译文块坐标换算回 +// 选区绝对逻辑坐标存入 ctx(连同原段落版面特征:行数/行宽利用率/垂直密度), +// 进入 TRL_Shown;OnPaint 在标注之上绘制译文覆盖面板(白底盖住原文字), +// 译文字号按段落独立搜索:以原字号估计为锚点,可行性(总高不超段高)二分 + +// 候选真实排版测量评分,取综合视觉密度最接近原段落者(见 sctranslatedraw:: +// FitParagraph),确认/保存导出时同样合成进最终图像。TRL_Shown 下再次点击 +// 「翻译」为切换退出:清除覆盖回到 TRL_Idle 并取消按钮激活,不重跑翻译; +// 需重译时再点一次重新发起即可。 +// +// provider 约定(宿主 JS 侧 ProviderBridge.start 的 handler 直接映射 providerManager.invoke): +// ocr: 入参 {"image":"data:image/png;base64,..."} +// (选区裁剪,物理像素分辨率) +// => 返回 {"text":"..","blocks":[{"text":"行文本","left":L,"top":T, +// "right":R,"bottom":B},...],"confidence":0.9},blocks 为行级对象数组, +// 坐标为提交图像内像素坐标;仅回 text / blocks 为字符串数组(无坐标) +// 时自动走整图兜底 +// translation: 入参 {"text":"行文本"}(from/to 缺省用 provider 默认:自动检测 +// 源语言、翻译到默认目标语言) +// => 返回 {"text":"译文","detectedFrom":"en"(可选)} +// +// 线程模型:工作线程只持有任务入参的拷贝(图像 base64 / 选区矩形 / DPI / 窗口句柄), +// 不触碰 CaptureContext;结果经互斥锁保护的单飞行槽(g_translateSlot)交还截图线程, +// 会话收尾时由 TeardownTranslateJobs 清槽,窗口销毁导致的投递失败由发布方自行回收, +// 不存在跨线程共享可变状态。 +#include "internal.h" +#include "../algo/translate_cluster_internal.h" +#include "../../provider_bridge.h" +#include "../../logger.h" + +#include + +// OCR provider 调用等待 JS 侧结果的超时(大图 base64 + 识别引擎耗时,取宽裕值) + +static const DWORD SC_OCR_TIMEOUT_MS = 30000; + +// 单行翻译 provider 调用等待 JS 侧结果的超时(逐行串行调用,每行独立计时) + +static const DWORD SC_TRANSLATE_LINE_TIMEOUT_MS = 15000; + +// 错误状态气泡自动隐藏时长 + +static const DWORD SC_TRANSLATE_ERROR_TTL_MS = 4000; + +// ==================== 极简 JSON 解析(provider 返回值解析专用) ==================== +// 项目不引入第三方 JSON 库,这里实现一个覆盖 JSON 全语法的递归下降解析器: +// null/bool/number/string(含 \uXXXX 代理对)/array/object。数值按 double 存储, +// 字符串解码为 UTF-8。仅在本文件使用。 + +namespace scminijson { + +struct Value { + enum Type { NUL, BOOL, NUM, STR, ARR, OBJ }; + Type type = NUL; + bool boolean = false; + double number = 0.0; + std::string str; + std::vector arr; + std::vector> members; // 保持出现顺序(小块解析无需哈希) + + // 取对象成员(不存在或非对象时返回 nullptr) + const Value* find(const char* key) const { + if (type != OBJ) return nullptr; + for (const auto& kv : members) { + if (kv.first == key) return &kv.second; + } + return nullptr; + } +}; + +struct Parser { + const char* p = nullptr; + const char* end = nullptr; + + bool Parse(const std::string& text, Value& out) { + p = text.data(); + end = p + text.size(); + SkipWs(); + if (!ParseValue(out)) return false; + SkipWs(); + return p == end; // 尾部不允许多余内容 + } + +private: + void SkipWs() { + while (p < end && (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r')) p++; + } + bool Consume(char c) { + if (p < end && *p == c) { p++; return true; } + return false; + } + bool ParseValue(Value& v) { + if (p >= end) return false; + switch (*p) { + case '{': return ParseObject(v); + case '[': return ParseArray(v); + case '"': + v.type = Value::STR; + return ParseString(v.str); + case 't': + v.type = Value::BOOL; v.boolean = true; + return ParseLiteral("true"); + case 'f': + v.type = Value::BOOL; v.boolean = false; + return ParseLiteral("false"); + case 'n': + v.type = Value::NUL; + return ParseLiteral("null"); + default: + return ParseNumber(v); + } + } + bool ParseLiteral(const char* lit) { + size_t n = strlen(lit); + if ((size_t)(end - p) < n || strncmp(p, lit, n) != 0) return false; + p += n; + return true; + } + bool ParseNumber(Value& v) { + if (p >= end) return false; + char* stop = nullptr; + double d = strtod(p, &stop); + if (stop == p) return false; + v.type = Value::NUM; + v.number = d; + p = stop; + return true; + } + // UTF-8 编码一个码点 + static void AppendCodepoint(std::string& out, unsigned cp) { + if (cp < 0x80) { + out.push_back((char)cp); + } else if (cp < 0x800) { + out.push_back((char)(0xC0 | (cp >> 6))); + out.push_back((char)(0x80 | (cp & 0x3F))); + } else if (cp < 0x10000) { + out.push_back((char)(0xE0 | (cp >> 12))); + out.push_back((char)(0x80 | ((cp >> 6) & 0x3F))); + out.push_back((char)(0x80 | (cp & 0x3F))); + } else { + out.push_back((char)(0xF0 | (cp >> 18))); + out.push_back((char)(0x80 | ((cp >> 12) & 0x3F))); + out.push_back((char)(0x80 | ((cp >> 6) & 0x3F))); + out.push_back((char)(0x80 | (cp & 0x3F))); + } + } + bool ParseHex4(unsigned& out) { + if (end - p < 4) return false; + unsigned v = 0; + for (int i = 0; i < 4; i++) { + char c = *p++; + v <<= 4; + if (c >= '0' && c <= '9') v |= (unsigned)(c - '0'); + else if (c >= 'a' && c <= 'f') v |= (unsigned)(c - 'a' + 10); + else if (c >= 'A' && c <= 'F') v |= (unsigned)(c - 'A' + 10); + else return false; + } + out = v; + return true; + } + bool ParseString(std::string& out) { + if (!Consume('"')) return false; + out.clear(); + while (p < end) { + char c = *p++; + if (c == '"') return true; + if (c != '\\') { out.push_back(c); continue; } + if (p >= end) return false; + char e = *p++; + switch (e) { + case '"': out.push_back('"'); break; + case '\\': out.push_back('\\'); break; + case '/': out.push_back('/'); break; + case 'b': out.push_back('\b'); break; + case 'f': out.push_back('\f'); break; + case 'n': out.push_back('\n'); break; + case 'r': out.push_back('\r'); break; + case 't': out.push_back('\t'); break; + case 'u': { + unsigned cp = 0; + if (!ParseHex4(cp)) return false; + // 代理对:高代理后必须跟 \uDC00-\uDFFF 的低代理,合成真实码点 + if (cp >= 0xD800 && cp <= 0xDBFF) { + if (end - p < 6 || p[0] != '\\' || p[1] != 'u') return false; + p += 2; + unsigned lo = 0; + if (!ParseHex4(lo)) return false; + if (lo < 0xDC00 || lo > 0xDFFF) return false; + cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00); + } else if (cp >= 0xDC00 && cp <= 0xDFFF) { + return false; // 孤立低代理 + } + AppendCodepoint(out, cp); + break; + } + default: + return false; + } + } + return false; // 未闭合 + } + bool ParseArray(Value& v) { + if (!Consume('[')) return false; + v.type = Value::ARR; + SkipWs(); + if (Consume(']')) return true; + while (true) { + v.arr.emplace_back(); + SkipWs(); + if (!ParseValue(v.arr.back())) return false; + SkipWs(); + if (Consume(',')) continue; + return Consume(']'); + } + } + bool ParseObject(Value& v) { + if (!Consume('{')) return false; + v.type = Value::OBJ; + SkipWs(); + if (Consume('}')) return true; + while (true) { + SkipWs(); + std::string key; + if (p >= end || *p != '"') return false; + if (!ParseString(key)) return false; + SkipWs(); + if (!Consume(':')) return false; + v.members.emplace_back(); + v.members.back().first = std::move(key); + SkipWs(); + if (!ParseValue(v.members.back().second)) return false; + SkipWs(); + if (Consume(',')) continue; + return Consume('}'); + } + } +}; + +} // namespace scminijson + +// ==================== 小工具 ==================== + +// UTF-8 → UTF-16(provider 返回的文本/错误信息消费用);转换失败按原样可用部分截断 + +static std::wstring Utf8ToWideForTranslate(const std::string& s) { + if (s.empty()) return std::wstring(); + int n = MultiByteToWideChar(CP_UTF8, 0, s.data(), (int)s.size(), NULL, 0); + if (n <= 0) return std::wstring(); + std::wstring out(n, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, s.data(), (int)s.size(), &out[0], n); + return out; +} + +// 是否 CJK 字符(基本区 + 扩展区粗判):换行算法据此允许在任意 CJK 字符后断行 + +static bool IsCjkForWrap(wchar_t c) { + return c >= 0x2E80 && c <= 0x9FFF // 部首/中日韩部首/拼音/基本区 + || c >= 0x3000 && c <= 0x303F // 中日韩标点 + || c >= 0xF900 && c <= 0xFAFF // 兼容表意 + || c >= 0xFF00 && c <= 0xFFEF; // 全角形式 +} + +// ==================== 翻译任务数据与结果槽 ==================== + +// 工作线程任务入参(截图线程在点击时打包,所有权随 detach 转移给工作线程) + +struct TranslateJobInput { + std::string imageBase64; // data:image/png;base64,...(选区裁剪,物理像素分辨率) + int imageW = 0; // 提交图像宽(物理像素,兜底单块坐标用) + int imageH = 0; + RECT selection = {}; // 选区矩形(绝对虚拟屏幕逻辑坐标) + double dpiScale = 1.0; + HWND overlayHwnd = NULL; // 截图覆盖层窗口(结果回投目标) +}; + +// 工作线程产出(经结果槽移交截图线程,由接管方释放) + +struct TranslateJobResult { + bool ok = false; + std::wstring error; // ok=false 时的用户可读错误 + std::vector blocks; // ok=true 时的译文块(绝对逻辑坐标) +}; + +// 单飞行结果槽:同一时刻至多一个翻译任务在跑(点击侧 TRL_Busy 门禁保证), +// 故单指针即可;工作线程发布、截图线程取走,互斥保护跨线程交接。 + +static std::mutex g_translateSlotMutex; +static TranslateJobResult* g_translateSlot = nullptr; + +// 发布结果:先落槽再投递消息。消息投递失败(覆盖层已销毁)时消息永不会被处理, +// 由发布方回收;投递成功但消息仍被销毁冲刷(DestroyWindow 清队)的极小竞争窗口 +// 里,残留槽由会话收尾的 TeardownTranslateJobs 兜底释放(见下)。 + +static void PublishTranslateResult(TranslateJobResult* res, HWND hwnd) { + { + std::lock_guard lock(g_translateSlotMutex); + delete g_translateSlot; // 兜底清残留(正常时序下恒为空) + g_translateSlot = res; + } + if (hwnd == NULL || !PostMessageW(hwnd, WM_SCREENSHOT_TRANSLATE_RESULT, 0, 0)) { + std::lock_guard lock(g_translateSlotMutex); + delete g_translateSlot; + g_translateSlot = nullptr; + } +} + +// 会话收尾清槽(截图线程在窗口销毁后调用):释放仍在槽中未被取走的结果, +// 使跨会话泄漏上界为零(迟到的发布者随后会因 PostMessage 失败自行回收)。 + +void TeardownTranslateJobs() { + std::lock_guard lock(g_translateSlotMutex); + delete g_translateSlot; + g_translateSlot = nullptr; +} + +// ==================== provider 响应解析与调用辅助 ==================== + +// OCR 文本行(提交图像内的像素坐标;text 先为 OCR 原文,翻译成功后替换为译文)。 +// lineH 为该块的原文单行行高估计(段落块 = 成员行高中位数),orig* 为原段落 +// 版面特征(行数 / 行宽利用率 / 垂直密度,由聚类成员行统计),随块流向下 +// 渲染层作为字号搜索的密度对齐目标(兜底块 origLineCount = 0 表示未知)。 + +struct OcrRawBlock { + std::string text; + double x = 0, y = 0, w = 0, h = 0; + double lineH = 0; + int origLineCount = 0; + double origWidthUtil = 0; + double origVDensity = 0; +}; + +// 从 ocr 响应解析文本行:{"text":"..","blocks":[{text,left,top,right,bottom},..],"confidence":..}。 +// 兼容 x/y/width/height 坐标命名与 blocks 为纯字符串数组的历史契约: +// - 带有效坐标与文本的对象行 → outLines(逐行翻译/覆盖定位依据) +// - 字符串行 / 缺坐标的对象行 → outPlain(无坐标,走整图兜底整段翻译) +// 空白文本行直接丢弃(不参与翻译也不参与兜底拼接)。 + +static void ParseOcrBlocks(const scminijson::Value& root, + std::vector& outLines, + std::vector& outPlain) { + const scminijson::Value* blocks = root.find("blocks"); + if (!blocks || blocks->type != scminijson::Value::ARR) return; + for (const scminijson::Value& b : blocks->arr) { + if (b.type == scminijson::Value::STR) { + if (b.str.find_first_not_of(" \t\r\n") != std::string::npos) outPlain.push_back(b.str); + continue; + } + if (b.type != scminijson::Value::OBJ) continue; + const scminijson::Value* t = b.find("text"); + if (!t || t->type != scminijson::Value::STR) continue; + if (t->str.find_first_not_of(" \t\r\n") == std::string::npos) continue; + OcrRawBlock rb; + rb.text = t->str; + if (const auto* v = b.find("left")) if (v->type == scminijson::Value::NUM) rb.x = v->number; + if (const auto* v = b.find("top")) if (v->type == scminijson::Value::NUM) rb.y = v->number; + if (const auto* v = b.find("x")) if (v->type == scminijson::Value::NUM) rb.x = v->number; + if (const auto* v = b.find("y")) if (v->type == scminijson::Value::NUM) rb.y = v->number; + if (rb.w <= 0) { + if (const auto* v = b.find("right")) if (v->type == scminijson::Value::NUM) rb.w = v->number - rb.x; + } + if (rb.h <= 0) { + if (const auto* v = b.find("bottom")) if (v->type == scminijson::Value::NUM) rb.h = v->number - rb.y; + } + if (rb.w <= 0) { + if (const auto* v = b.find("width")) if (v->type == scminijson::Value::NUM) rb.w = v->number; + } + if (rb.h <= 0) { + if (const auto* v = b.find("height")) if (v->type == scminijson::Value::NUM) rb.h = v->number; + } + if (rb.w > 0 && rb.h > 0) outLines.push_back(std::move(rb)); + else outPlain.push_back(std::move(rb.text)); // 有文本无坐标:并兜底文本 + } +} + +// 把字符串安全编码为 JSON 字符串字面量内容(不含两侧引号): +// 引号/反斜杠/控制字符转义,UTF-8 字节透传。供构造 translation 请求用。 + +static std::string JsonEscapeForTranslate(const std::string& s) { + static const char* HEX = "0123456789abcdef"; + std::string out; + out.reserve(s.size() + 8); + for (char c : s) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\b': out += "\\b"; break; + case '\f': out += "\\f"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if ((unsigned char)c < 0x20) { + out += "\\u00"; + out.push_back(HEX[((unsigned char)c >> 4) & 0xF]); + out.push_back(HEX[(unsigned char)c & 0xF]); + } else { + out.push_back(c); + } + } + } + return out; +} + +// 调用 translation provider 翻译一段文本(OCR 单行或兜底整段)。 +// 入参 {"text":".."}(from/to 缺省走 provider 默认:自动检测源语言、默认目标语言), +// 返回 {"text":"译文",..}。任何失败返回 false 并给出可读错误(errOut 可为空串)。 + +static bool InvokeTranslation(const std::string& text, std::string& textOut, std::string& errOut) { + std::string req = "{\"text\":\"" + JsonEscapeForTranslate(text) + "\"}"; + ztools_provider_bridge::InvokeResult tr = + ztools_provider_bridge::Invoke("translation", req, SC_TRANSLATE_LINE_TIMEOUT_MS); + if (!tr.ok) { + errOut = tr.error; + return false; + } + scminijson::Value root; + if (!scminijson::Parser().Parse(tr.value, root)) { + errOut = "翻译结果解析失败"; + return false; + } + const scminijson::Value* t = root.find("text"); + if (!t || t->type != scminijson::Value::STR) { + errOut = "翻译结果缺少 text 字段"; + return false; + } + textOut = t->str; + return true; +} + +// ==================== 工作线程主流程 ==================== + +// 原生编排(OCR 行级识别 → 逐行翻译 → 坐标合并)→ 坐标换算 → 发布结果。 +// 任何阶段失败都以 ok=false + 中文错误收束,由截图线程转为错误气泡展示(数秒后自动消失)。 + +static void TranslateWorkerMain(TranslateJobInput* job) { + std::unique_ptr in(job); + auto res = std::make_unique(); + + // 译文块(提交图像内的像素坐标;text 已替换为译文) + std::vector translated; + + do { + // ---- 0) 桥接就绪检查(宿主未注册 ProviderBridge 时给出可操作提示)---- + if (!ztools_provider_bridge::IsReady()) { + ZLOG_WARN("translate", "provider bridge not ready"); + res->error = L"翻译服务未就绪(未注册 ProviderBridge)"; + break; + } + + // ---- 1) OCR:提交选区图像,取回带行级坐标的文本行 ---- + ZLOG_INFO("translate", "ocr request (image %dx%d, %zu bytes)", + in->imageW, in->imageH, in->imageBase64.size()); + std::string ocrReq = "{\"image\":\"" + in->imageBase64 + "\"}"; + ztools_provider_bridge::InvokeResult ocr = + ztools_provider_bridge::Invoke("ocr", ocrReq, SC_OCR_TIMEOUT_MS); + if (!ocr.ok) { + ZLOG_ERROR("translate", "ocr invoke failed: %s", ocr.error.c_str()); + res->error = L"文字识别失败:" + Utf8ToWideForTranslate(ocr.error); + break; + } + scminijson::Value ocrRoot; + if (!scminijson::Parser().Parse(ocr.value, ocrRoot)) { + res->error = L"识别结果解析失败"; + break; + } + std::vector lines; // 带坐标的文本行 + std::vector plainTexts; // 无坐标的文本(兜底用) + ParseOcrBlocks(ocrRoot, lines, plainTexts); + if (lines.empty() && plainTexts.empty()) { + ZLOG_INFO("translate", "ocr returned no text"); + res->error = L"未识别到文字"; + break; + } + ZLOG_INFO("translate", "ocr ok (%zu line blocks, %zu plain)", lines.size(), plainTexts.size()); + + std::string lastErr; + if (!lines.empty()) { + // ---- 2a) 文本行聚类成段落 → 逐段翻译 ---- + // OCR 只按行给结果:逐行翻译会把跨行句子拆散(丢失段落上下文)、 + // 逐行覆盖会把段落渲染成碎面板。先按空间关系把连续文本行聚成段落 + // (判定流水线见 algo/translate_cluster.cpp 头部注释),再以段落为 + // 单位翻译与覆盖。串行请求以尊重 provider 限流;单段失败跳过(记 + // 日志),全部失败才整体报错。 + std::vector clusterInput; + clusterInput.reserve(lines.size()); + for (const OcrRawBlock& rb : lines) { + TcLine tl; + tl.text = rb.text; + tl.x = rb.x; + tl.y = rb.y; + tl.w = rb.w; + tl.h = rb.h; + clusterInput.push_back(std::move(tl)); + } + TcClusterResult clustered = TcClusterLines(clusterInput); + ZLOG_INFO("translate", + "cluster: %zu lines -> %zu paragraphs (medianH=%.1f, pitch=%.1f, boundaries=%d)", + lines.size(), clustered.paragraphs.size(), + clustered.medianLineH, clustered.medianPitch, clustered.boundaryCount); + + for (size_t i = 0; i < clustered.paragraphs.size(); i++) { + const TcParagraph& para = clustered.paragraphs[i]; + std::string outText; + if (!InvokeTranslation(para.text, outText, lastErr)) { + ZLOG_WARN("translate", "para %zu (%zu lines) translation failed: %s", + i, para.lineIdx.size(), lastErr.c_str()); + continue; + } + if (outText.find_first_not_of(" \t\r\n") == std::string::npos) { + ZLOG_WARN("translate", "para %zu translation returned empty", i); + continue; + } + OcrRawBlock tb; + tb.x = para.x; + tb.y = para.y; + tb.w = para.w; + tb.h = para.h; + tb.lineH = para.lineHeight; + // 原段落版面特征(渲染层字号搜索的密度对齐目标): + // 行数 = 成员行数;行宽利用率 = 成员行宽均值 / 段宽(原段行均多满); + // 垂直密度 = 行数 × 中位行高 / 段高(原段纵向多满,含行距观感) + int pn = (int)para.lineIdx.size(); + double sumLineW = 0; + for (int li : para.lineIdx) sumLineW += clusterInput[li].w; + tb.origLineCount = pn; + if (para.w > 0.5 && pn > 0) { + tb.origWidthUtil = (sumLineW / pn) / para.w; + if (tb.origWidthUtil > 1) tb.origWidthUtil = 1; + } + if (para.h > 0.5 && para.lineHeight > 0) { + tb.origVDensity = (pn * para.lineHeight) / para.h; + if (tb.origVDensity > 1) tb.origVDensity = 1; + } + tb.text = std::move(outText); + translated.push_back(std::move(tb)); + } + if (translated.empty()) { + res->error = L"翻译失败:" + Utf8ToWideForTranslate(lastErr); + break; + } + ZLOG_INFO("translate", "translation done (%zu/%zu paragraphs)", + translated.size(), clustered.paragraphs.size()); + } else { + // ---- 2b) 整图兜底:OCR 未给坐标(如纯文本 AI 识别)时整段翻译, + // 以整个选区为单一覆盖面板(行级定位能力由 provider 决定,此处无法补救) + std::string wholeText; + if (const auto* t = ocrRoot.find("text"); + t && t->type == scminijson::Value::STR) { + wholeText = t->str; + } + if (wholeText.find_first_not_of(" \t\r\n") == std::string::npos) { + std::string joined; + for (size_t i = 0; i < plainTexts.size(); i++) { + if (i > 0) joined += "\n"; + joined += plainTexts[i]; + } + wholeText = std::move(joined); + } + std::string outText; + if (!InvokeTranslation(wholeText, outText, lastErr)) { + ZLOG_ERROR("translate", "whole-text translation failed: %s", lastErr.c_str()); + res->error = L"翻译失败:" + Utf8ToWideForTranslate(lastErr); + break; + } + OcrRawBlock fb; + fb.x = 0.0; + fb.y = 0.0; + fb.w = (double)in->imageW; + fb.h = (double)in->imageH; + fb.text = std::move(outText); + translated.push_back(std::move(fb)); + ZLOG_INFO("translate", "whole-text fallback ok"); + } + + // ---- 3) 坐标换算:图像像素(物理)→ 选区绝对逻辑坐标,并夹回选区内 ---- + double ds = in->dpiScale > 0.01 ? in->dpiScale : 1.0; + for (const OcrRawBlock& rb : translated) { + RECT box = {}; + box.left = in->selection.left + (int)(rb.x / ds + 0.5); + box.top = in->selection.top + (int)(rb.y / ds + 0.5); + box.right = box.left + (int)(rb.w / ds + 0.5); + box.bottom = box.top + (int)(rb.h / ds + 0.5); + RECT clipped = {}; + if (!IntersectRect(&clipped, &box, &in->selection)) continue; + if (clipped.right - clipped.left < 2 || clipped.bottom - clipped.top < 2) continue; + + TranslateBlock tb; + tb.box = clipped; + tb.text = Utf8ToWideForTranslate(rb.text); + if (tb.text.find_first_not_of(L" \t\r\n") == std::wstring::npos) continue; + if (rb.lineH > 0) tb.lineHeightPx = rb.lineH / ds; + tb.origLineCount = rb.origLineCount; + tb.origWidthUtil = rb.origWidthUtil; + tb.origVDensity = rb.origVDensity; + res->blocks.push_back(std::move(tb)); + } + if (res->blocks.empty()) { + ZLOG_INFO("translate", "no visible blocks after clipping"); + res->error = L"未识别到文字"; + break; + } + res->ok = true; + ZLOG_INFO("translate", "done: %zu blocks rendered", res->blocks.size()); + } while (false); + + PublishTranslateResult(res.release(), in->overlayHwnd); +} + +// ==================== 状态气泡(进度 / 错误) ==================== + +// 设置状态气泡文本并计算气泡矩形(backDC 相对坐标):水平居中于选区、置于选区上沿 +// 外侧(放不下转选区内顶部)。尺寸测量与工具栏 tooltip 同款(GDI DrawTextW + +// 12px×ds 微软雅黑)。调用方负责触发重绘。 + +static void SetTranslateStatus(CaptureContext* ctx, const wchar_t* text, bool isError) { + ctx->translateStatusText = text ? text : L""; + ctx->translateStatusError = isError; + ctx->translateStatusShown = !ctx->translateStatusText.empty(); + ctx->translateStatusRect = {}; + ctx->translateStatusAt = GetTickCount(); + if (!ctx->translateStatusShown) return; + + double ds = ctx->dpiScale; + auto sc = [ds](int v) { return (int)(v * ds + 0.5); }; + int padX = sc(8), padY = sc(5); + HDC screen = GetDC(NULL); + if (screen) { + HFONT fnt = CreateFontW(-sc(12), 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, + DEFAULT_CHARSET, 0, 0, CLEARTYPE_QUALITY, 0, SC_FONT_FACE); + HGDIOBJ old = SelectObject(screen, fnt); + RECT tr = {0, 0, 0, 0}; + DrawTextW(screen, ctx->translateStatusText.c_str(), -1, &tr, DT_CALCRECT | DT_SINGLELINE); + SelectObject(screen, old); + DeleteObject(fnt); + ReleaseDC(NULL, screen); + int w = tr.right - tr.left + padX * 2 + 2; + int h = tr.bottom - tr.top + padY * 2 + 2; + int selL = ctx->selection.left - ctx->virtualX; + int selT = ctx->selection.top - ctx->virtualY; + int selW = ctx->selection.right - ctx->selection.left; + int x = selL + (selW - w) / 2; + if (x < sc(4)) x = sc(4); + if (x + w > ctx->virtualW - sc(4)) x = ctx->virtualW - sc(4) - w; + int y = selT - h - sc(6); + if (y < sc(4)) y = selT + sc(6); // 上方放不下:转选区内顶部 + ctx->translateStatusRect = {x, y, x + w, y + h}; + } +} + +// 空闲循环节拍(会话空闲循环每拍调用):错误气泡超过 TTL 后收起(进度气泡常驻 +// 至结果到达)。只失效气泡自身矩形,局部刷新。 + +void TickTranslateStatus(CaptureContext* ctx, HWND hwnd) { + if (!ctx || !hwnd || !ctx->translateStatusShown || !ctx->translateStatusError) return; + if (GetTickCount() - ctx->translateStatusAt < SC_TRANSLATE_ERROR_TTL_MS) return; + RECT r = InflateRectBy(ctx->translateStatusRect, 2); + ctx->translateStatusShown = false; + ctx->translateStatusText.clear(); + InvalidateRect(hwnd, &r, FALSE); +} + +// 绘制状态气泡:深色圆角底 + 居中文本(错误态淡红文本),样式与工具栏 tooltip 一致 + +static void DrawTranslateStatusBubble(HDC hdc, CaptureContext* ctx) { + double ds = ctx->dpiScale; + const RECT& rc = ctx->translateStatusRect; + Gdiplus::Graphics graphics(hdc); + graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias); + Gdiplus::GraphicsPath path; + AddRoundedRect(path, rc.left, rc.top, rc.right - rc.left, rc.bottom - rc.top, + (int)(4 * ds + 0.5)); + Gdiplus::SolidBrush bg(Gdiplus::Color(255, 41, 41, 41)); + graphics.FillPath(&bg, &path); + Gdiplus::FontFamily ff(SC_FONT_FACE); + Gdiplus::Font fnt(&ff, (Gdiplus::REAL)(12 * ds + 0.5), + Gdiplus::FontStyleRegular, Gdiplus::UnitPixel); + Gdiplus::StringFormat sf; + sf.SetAlignment(Gdiplus::StringAlignmentCenter); + sf.SetLineAlignment(Gdiplus::StringAlignmentCenter); + Gdiplus::RectF layout((Gdiplus::REAL)rc.left, (Gdiplus::REAL)rc.top, + (Gdiplus::REAL)(rc.right - rc.left), + (Gdiplus::REAL)(rc.bottom - rc.top)); + Gdiplus::Color textColor = ctx->translateStatusError + ? Gdiplus::Color(255, 255, 160, 160) + : Gdiplus::Color(255, 255, 255, 255); + Gdiplus::SolidBrush textBrush(textColor); + graphics.SetTextRenderingHint(Gdiplus::TextRenderingHintAntiAliasGridFit); + graphics.DrawString(ctx->translateStatusText.c_str(), -1, &fnt, layout, &sf, &textBrush); +} + +// ==================== 译文覆盖绘制(OnPaint 与导出合成共用) ==================== + +namespace sctranslatedraw { + +// 译文面板内边距 / 圆角(逻辑像素) + +static const int PANEL_PAD = 4; +static const int PANEL_RADIUS = 4; + +// 译文字号搜索区间(逻辑像素):以原字号估计为锚点,在区间内做可行性二分 + +// 候选评分(见 FitParagraph),锚点与端点均只作约束/兜底,不直接决定字号。 + +static const int FONT_MIN_PX = 9; +static const int FONT_MAX_PX = 48; + +// ---- 原段落版面特征:字号搜索的「密度对齐」目标 ---- +// 由 OCR 行聚类随块带下(TranslateBlock.orig*,worker 侧从成员行统计)。 +// boxW/boxH 为绘制时实际框(与选区交集后),利用率类特征均相对该框。 + +struct ParaFeatures { + bool valid = false; // 是否具备行级特征(整图兜底块 = false,用合成密度目标) + float boxW = 0.0f; + float boxH = 0.0f; + float fontPx = 0.0f; // 原字号估计(中位行高 − 双内边距;软约束锚点) + int lineCount = 0; // 原行数 + float widthUtil = 0.0f; // 原平均行宽利用率(成员行宽均值 / 段宽) + float vDensity = 0.0f; // 原垂直密度(行数 × 中位行高 / 段高) +}; + +// ---- 某一候选字号下的整段真实排版测量 ---- +// 按段落宽度真实折行后统计:实际行数 / 总高 / 逐行实际宽度,并派生各项利用率 +// 与高度可行性,供候选评分与最终排版复用。 + +struct LayoutMeasure { + std::vector lines; // 折行结果 + std::vector lineWidths; // 每行实际宽(去行尾空白) + float lineH = 0.0f; // 行高(font.GetHeight,含字体默认行距) + float totalH = 0.0f; // 行数 × 行高 + float widthUtil = 0.0f; // 平均行宽 / 段宽 + float heightUtil = 0.0f; // 总高 / 段高 + float lastUtil = 0.0f; // 末行宽 / 段宽 + bool fitsHeight = false; // 总高 ≤ 文本区高(段高 − 双内边距) +}; + +// 用逐字符宽度贪心折行。字符宽按「前缀累宽差分」测量(与 GDI+ 渲染宽度同源), +// 断行点:CJK 字符后、空格后;拉丁单词整体不下断时回退硬断。 +// 返回的行不含换行符;maxW 为可用文本宽度(逻辑像素)。 + +static std::vector WrapText(Gdiplus::Graphics& g, const Gdiplus::Font& font, + const std::wstring& text, float maxW) { + std::vector lines; + size_t n = text.size(); + if (n == 0 || maxW <= 1) { + if (n > 0) lines.push_back(text); + return lines; + } + // 前缀累宽:widths[i] = 前 i 个字符的渲染宽度 + std::vector widths(n + 1, 0.0f); + Gdiplus::StringFormat sf; + sf.SetFormatFlags(Gdiplus::StringFormatFlagsMeasureTrailingSpaces); + Gdiplus::RectF layout(0.0f, 0.0f, 65536.0f, 65536.0f), bound; + for (size_t i = 1; i <= n; i++) { + g.MeasureString(text.c_str(), (INT)i, &font, layout, &sf, &bound); + widths[i] = bound.X + bound.Width; + } + size_t lineStart = 0; // 当前行起点 + size_t lastBreak = 0; // 行内最后一个可断点之后的起点(空格后 / CJK 字符边界后) + bool hasBreak = false; + auto breakableAfter = [&](size_t idx) { // idx 为字符下标;返回其后是否可断 + wchar_t c = text[idx]; + return c == L' ' || IsCjkForWrap(c); + }; + for (size_t i = 0; i < n; i++) { + if (breakableAfter(i)) { + lastBreak = i + 1; + hasBreak = true; + } + float lineW = widths[i + 1] - widths[lineStart]; + if (lineW <= maxW) continue; + // 溢出:优先在行内最后一个可断点断开(可断点在行首则硬断在当前字符前) + if (hasBreak && lastBreak > lineStart) { + lines.emplace_back(text, lineStart, lastBreak - lineStart); + lineStart = lastBreak; + } else if (i > lineStart) { + lines.emplace_back(text, lineStart, i - lineStart); + lineStart = i; + } else { + lines.emplace_back(text, lineStart, 1); + lineStart = i + 1; + } + // 新行继承断点状态:断点前移后重扫(简单起见直接清零,行首断点无意义) + hasBreak = false; + lastBreak = lineStart; + // 补齐新行起点的前缀宽度基准(widths 与 lineStart 配合已足够,无需额外处理) + } + if (lineStart < n) lines.emplace_back(text, lineStart, n - lineStart); + if (lines.empty()) lines.push_back(text); + return lines; +} + +// 测单行渲染宽度(先去行尾空白,避免折行断在空格后虚增利用率)。 +// 与 WrapText 的前缀累宽同用 MeasureString + MeasureTrailingSpaces,测量同源。 + +static float MeasureLineWidth(Gdiplus::Graphics& g, const Gdiplus::Font& font, + const std::wstring& line) { + size_t end = line.size(); + while (end > 0 && (line[end - 1] == L' ' || line[end - 1] == L'\t')) end--; + if (end == 0) return 0.0f; + Gdiplus::StringFormat sf; + sf.SetFormatFlags(Gdiplus::StringFormatFlagsMeasureTrailingSpaces); + Gdiplus::RectF layout(0.0f, 0.0f, 65536.0f, 65536.0f), bound; + g.MeasureString(line.c_str(), (INT)end, &font, layout, &sf, &bound); + return bound.X + bound.Width; +} + +// 候选字号真实排版测量:按段宽折行 → 行高/总高/逐行宽 → 各利用率与高度可行性。 +// availW/availH 为文本区(框 − 双内边距),利用率相对整框(与原段特征同基准)。 + +static LayoutMeasure MeasureAt(Gdiplus::Graphics& g, const Gdiplus::FontFamily& ff, + const std::wstring& text, int fontPx, + const ParaFeatures& pf, float availW, float availH) { + LayoutMeasure m; + Gdiplus::Font font(&ff, (Gdiplus::REAL)fontPx, Gdiplus::FontStyleRegular, Gdiplus::UnitPixel); + m.lines = WrapText(g, font, text, availW); + m.lineH = font.GetHeight(&g); + if (m.lineH < 1.0f) m.lineH = (float)fontPx; + m.totalH = m.lines.size() * m.lineH; + m.fitsHeight = m.totalH <= availH + 0.5f; + if (pf.boxW > 1.0f && pf.boxH > 1.0f) { + float sumW = 0.0f; + m.lineWidths.reserve(m.lines.size()); + for (const std::wstring& line : m.lines) { + float w = MeasureLineWidth(g, font, line); + m.lineWidths.push_back(w); + sumW += w; + } + m.widthUtil = sumW / (float)m.lines.size() / pf.boxW; + if (m.widthUtil > 1.0f) m.widthUtil = 1.0f; + m.heightUtil = m.totalH / pf.boxH; + m.lastUtil = m.lineWidths.back() / pf.boxW; + } + return m; +} + +// 综合视觉密度评分(越小越优):目标不是最大字号,而是排版观感最接近原段落。 +// 各项均为相对量纲(比例差 / 相对字号差 / 比例罚项),权重经验取值: +// 垂直密度差 1.0(最直观的纵向满/空观感)> 字号锚点偏离 0.75(不明显偏离 +// 原字号)> 行宽利用率差 0.5 > 末行过短罚项 0.4(≥2 行且末行宽占比 +// < 0.15 时,对不足部分线性罚,避免孤字/孤词收尾)。 + +static float ScoreMeasure(const LayoutMeasure& m, int fontPx, const ParaFeatures& pf) { + float s = 1.00f * fabsf(m.heightUtil - pf.vDensity); + s += 0.50f * fabsf(m.widthUtil - pf.widthUtil); + float anchor = pf.fontPx > 1.0f ? pf.fontPx : 1.0f; + s += 0.75f * fabsf((float)fontPx - pf.fontPx) / anchor; + if (m.lines.size() >= 2 && m.lastUtil < 0.15f) s += 0.40f * (0.15f - m.lastUtil); + return s; +} + +// 段落字号搜索(每段独立,绝不整图统一字号): +// 1) 可行性硬约束 = 真实排版总高不超段落文本区高;字号对总高单调 +// (字号越大行高越大且行数不减),故先二分出最大可行字号 maxFit; +// 2) 候选集 = 锚点(原字号估计,若可行)∪ maxFit 及其下探 1~3 档 ∪ 锚点↔maxFit +// 插值点;锚点不可行(译文比原文长)时补 [下限, maxFit] 中点覆盖稀疏原段; +// 3) 逐候选真实排版测量,仅在可行字号中按 ScoreMeasure 取密度最接近者。 +// 连下限字号都装不下时退回下限(面板扩高 + 末行截断由绘制侧兜底)。 +// 返回最终字号,mOut 带回该字号下的完整测量供最终排版。 + +static int FitParagraph(Gdiplus::Graphics& g, const Gdiplus::FontFamily& ff, + const std::wstring& text, const ParaFeatures& pf, + float availW, float availH, LayoutMeasure& mOut) { + LayoutMeasure atMin = MeasureAt(g, ff, text, FONT_MIN_PX, pf, availW, availH); + if (!atMin.fitsHeight) { // 下限也装不下:退回下限,溢出走兜底 + mOut = std::move(atMin); + return FONT_MIN_PX; + } + int maxFit = FONT_MIN_PX; + { + int lo = FONT_MIN_PX, hi = FONT_MAX_PX; + while (lo <= hi) { + int mid = (lo + hi) / 2; + if (MeasureAt(g, ff, text, mid, pf, availW, availH).fitsHeight) { + maxFit = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + } + int anchor = (int)(pf.fontPx + 0.5f); + if (anchor < FONT_MIN_PX) anchor = FONT_MIN_PX; + if (anchor > FONT_MAX_PX) anchor = FONT_MAX_PX; + std::vector cands; + auto push = [&](int v) { + if (v < FONT_MIN_PX || v > FONT_MAX_PX) return; + for (int c : cands) if (c == v) return; + cands.push_back(v); + }; + push(anchor); + push(maxFit); + push(maxFit - 1); + push(maxFit - 2); + push(maxFit - 3); + if (anchor < maxFit) { + int span = maxFit - anchor; + push(anchor + span / 2); + push(anchor + span / 4); + push(anchor + span * 3 / 4); + } else if (anchor > maxFit) { + push(FONT_MIN_PX + (maxFit - FONT_MIN_PX) / 2); + } + int best = maxFit; + float bestScore = 3.4e38f; + LayoutMeasure bestM; + for (int v : cands) { + LayoutMeasure m = MeasureAt(g, ff, text, v, pf, availW, availH); + if (!m.fitsHeight) continue; + float s = ScoreMeasure(m, v, pf); + if (s < bestScore) { + bestScore = s; + best = v; + bestM = std::move(m); + } + } + if (bestM.lines.empty()) bestM = MeasureAt(g, ff, text, best, pf, availW, availH); + mOut = std::move(bestM); + return best; +} + +// 绘制单个译文覆盖面板:白底圆角面板盖住原段落区域,整段译文按搜索出的字号 +// 折行排版(密度对齐而非最大字号,见 FitParagraph)。搜索含十次级真实排版 +// 测量,结果按「绘制时框宽高」缓存在块内(选区不变时每帧直接复用);仅当 +// 连下限字号都装不下时面板才向下扩展(不超 clip)并截断末行,属兜底路径。 +// tb.box/clip 为绝对逻辑坐标,ox/oy 为绘制平移(OnPaint = -虚拟屏原点, +// 导出 = -选区左上角),交集与排版均在平移后坐标系内进行。 + +static void DrawBlockPanel(HDC hdc, TranslateBlock& tb, const RECT& clip, float ox, float oy) { + // 与裁剪边界(选区)求交:选区缩小后落在界外的块整体跳过,跨界块按交集绘制 + RECT box = { tb.box.left + (int)ox, tb.box.top + (int)oy, + tb.box.right + (int)ox, tb.box.bottom + (int)oy }; + RECT clipOff = { clip.left + (int)ox, clip.top + (int)oy, + clip.right + (int)ox, clip.bottom + (int)oy }; + RECT boxI = {}; + if (!IntersectRect(&boxI, &box, &clipOff)) return; + int boxW = boxI.right - boxI.left; + int boxH = boxI.bottom - boxI.top; + if (boxW < 2 || boxH < 2) return; + + Gdiplus::Graphics graphics(hdc); + graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias); + graphics.SetTextRenderingHint(Gdiplus::TextRenderingHintAntiAliasGridFit); + + int pad = PANEL_PAD; + int availW = boxW - pad * 2; + if (availW < 8) availW = 8; + float availH = (float)boxH - pad * 2; + float maxPanelBottom = (float)clipOff.bottom; + + // ---- 字号搜索(带缓存:键 = 绘制时框宽高,几何变化才重排)---- + if (tb.fitKeyW != boxW || tb.fitKeyH != boxH || tb.fitLines.empty() + || tb.fitFontPx < FONT_MIN_PX) { + ParaFeatures pf; + pf.boxW = (float)boxW; + pf.boxH = (float)boxH; + pf.fontPx = tb.lineHeightPx > 1.0 ? (float)(tb.lineHeightPx - pad * 2) + : (float)(boxH - pad * 2); + if (pf.fontPx < (float)FONT_MIN_PX) pf.fontPx = (float)FONT_MIN_PX; + if (pf.fontPx > (float)FONT_MAX_PX) pf.fontPx = (float)FONT_MAX_PX; + if (tb.origLineCount > 0) { + pf.valid = true; + pf.lineCount = tb.origLineCount; + pf.widthUtil = (float)tb.origWidthUtil; + pf.vDensity = (float)tb.origVDensity; + } else { + // 整图兜底块无行级特征:合成「接近占满」的密度目标 + pf.lineCount = 1; + pf.widthUtil = 0.95f; + pf.vDensity = 0.95f; + } + LayoutMeasure m; + int fontPx = FitParagraph(graphics, Gdiplus::FontFamily(SC_FONT_FACE), tb.text, + pf, (float)availW, availH, m); + tb.fitKeyW = boxW; + tb.fitKeyH = boxH; + tb.fitFontPx = fontPx; + tb.fitLineH = m.lineH; + tb.fitLines = std::move(m.lines); + } + + int fontPx = tb.fitFontPx; + float lineH = tb.fitLineH > 0.5f ? tb.fitLineH : (float)fontPx; + const std::vector& lines = tb.fitLines; + + // 面板高度:装得下时严格取原段落高(覆盖框即原段框,保持原版面占位); + // 装不下(下限字号仍溢出)才向下扩展到裁剪边界 + float neededH = lines.size() * lineH + pad * 2; + float panelH = (float)boxH; + if (neededH > panelH) { + panelH = neededH; + if (boxI.top + panelH > maxPanelBottom) panelH = maxPanelBottom - boxI.top; + if (panelH < (float)boxH) panelH = (float)boxH; + } + + // 面板:近实心白底盖住原文字 + 浅灰描边 + Gdiplus::RectF panel((Gdiplus::REAL)boxI.left, (Gdiplus::REAL)boxI.top, + (Gdiplus::REAL)boxW, panelH); + { + Gdiplus::GraphicsPath path; + AddRoundedRect(path, (int)panel.X, (int)panel.Y, (int)panel.Width, (int)panel.Height, + PANEL_RADIUS); + Gdiplus::SolidBrush fill(Gdiplus::Color(250, 255, 255, 255)); + graphics.FillPath(&fill, &path); + Gdiplus::Pen border(Gdiplus::Color(255, 205, 205, 205), 1.0f); + graphics.DrawPath(&border, &path); + } + + // 逐行绘制译文:可见行数受面板高度约束;放不下的行截断为「…」 + Gdiplus::FontFamily ff(SC_FONT_FACE); + Gdiplus::Font font(&ff, (Gdiplus::REAL)fontPx, Gdiplus::FontStyleRegular, Gdiplus::UnitPixel); + Gdiplus::SolidBrush textBrush(Gdiplus::Color(255, 51, 51, 51)); + Gdiplus::StringFormat sf; + sf.SetAlignment(Gdiplus::StringAlignmentNear); + sf.SetLineAlignment(Gdiplus::StringAlignmentNear); + sf.SetFormatFlags(Gdiplus::StringFormatFlagsNoWrap); + sf.SetTrimming(Gdiplus::StringTrimmingNone); + + int visible = (int)((panelH - pad * 2) / lineH); + if (visible < 1) visible = 1; + float y = panel.Y + pad; + for (int i = 0; i < (int)lines.size() && i < visible; i++) { + std::wstring line = lines[i]; + if (i == visible - 1 && (size_t)visible < lines.size()) { + // 还有未展示的行:当前行末尾追加省略号 + line += L"…"; + } + Gdiplus::RectF layout(panel.X + pad, y, (Gdiplus::REAL)availW, lineH); + graphics.DrawString(line.c_str(), (INT)line.size(), &font, layout, &sf, &textBrush); + y += lineH; + } +} + +} // namespace sctranslatedraw + +// 在指定 DC 上按偏移绘制全部译文覆盖块。hdc 的坐标系为逻辑像素: +// - OnPaint(backDC):origin = 虚拟屏幕左上角,ox/oy = -virtualX/-virtualY +// - 导出合成(finalDC):origin = 选区左上角,ox/oy = -rect.left/-rect.top +// clip 为选区矩形(绝对逻辑坐标),约束面板向下扩展的边界。 + +static void DrawTranslateBlocksAt(HDC hdc, std::vector& blocks, + const RECT& clip, float ox, float oy) { + // 逐块绘制:块坐标为绝对值,平移量直接传给面板(交集/排版在平移后坐标系内 + // 进行,字号搜索缓存写回块本身,几何不变时每帧复用) + for (TranslateBlock& tb : blocks) { + sctranslatedraw::DrawBlockPanel(hdc, tb, clip, ox, oy); + } +} + +// OnPaint 入口:确认态下绘制译文覆盖(结果块)与状态气泡(进度/错误)。 +// 画在标注之上、选区轮廓/工具栏/tooltip 之下。 + +void DrawTranslateOverlay(HDC hdc, CaptureContext* ctx) { + if (!ctx) return; + if (ctx->translateState == TRL_Shown && !ctx->translateBlocks.empty()) { + RECT clip = ctx->selection; + DrawTranslateBlocksAt(hdc, ctx->translateBlocks, clip, + (float)-ctx->virtualX, (float)-ctx->virtualY); + } + if (ctx->translateStatusShown && !ctx->translateStatusText.empty() + && IsValidRect(ctx->translateStatusRect)) { + DrawTranslateStatusBubble(hdc, ctx); + } +} + +// 导出合成入口(确认提取/保存落盘共用):把译文覆盖块合成进最终位图。 +// finalDC 原点 = 选区左上角(逻辑像素,已完成 DPI 下采样)。 + +void CompositeTranslateBlocks(HDC finalDC, const std::vector& blocks, + const RECT& rect) { + if (!finalDC || blocks.empty()) return; + // 排版缓存需要可写块:导出为一次性路径,拷贝后缓存不回写调用方 + std::vector mut = blocks; + DrawTranslateBlocksAt(finalDC, mut, rect, (float)-rect.left, (float)-rect.top); +} + +// ==================== 点击入口与结果接管 ==================== + +// 清除已展示的译文覆盖(重跑翻译前 / 交互需要时调用)。仅清状态,不负责重绘。 + +static void ClearTranslateOverlay(CaptureContext* ctx) { + ctx->translateBlocks.clear(); + if (ctx->translateState == TRL_Shown) ctx->translateState = TRL_Idle; +} + +// 工具栏「翻译」按钮点击入口(截图线程调用):裁剪选区 → 编码 PNG → 转入进行中 +// 状态并起工作线程。重复点击忽略(进行中)/ 退出翻译状态并清掉覆盖(已展示,与 +// 矩形/文字等工具一致的切换语义)。返回 false 表示未能发起(裁剪/编码失败等), +// 调用方无需额外处理。 + +bool BeginTranslateOverlay(CaptureContext* ctx, HWND overlayHwnd) { + if (!ctx) return false; + if (ctx->translateState == TRL_Busy) return false; // 进行中忽略重复点击 + if (ctx->translateState == TRL_Shown) { + // 展示中再次点击:退出翻译状态(清除译文覆盖并取消按钮激活),不重跑翻译 + ClearTranslateOverlay(ctx); + ctx->activeTool = -1; + InvalidateRect(overlayHwnd, NULL, FALSE); + return true; + } + RECT sel = ctx->selection; + int w = sel.right - sel.left, h = sel.bottom - sel.top; + if (w <= 0 || h <= 0) return false; + double ds = ctx->dpiScale; + + // ---- 物理分辨率裁剪选区(预截屏位图 → 独立 32bpp DIB)---- + // 坐标换算与导出管线同式;GDI 调用全部在本线程完成,工作线程只拿 base64。 + int px = (int)((sel.left - ctx->virtualX) * ds + 0.5); + int py = (int)((sel.top - ctx->virtualY) * ds + 0.5); + int pw = (int)(w * ds + 0.5); + int ph = (int)(h * ds + 0.5); + if (pw <= 0 || ph <= 0) return false; + + std::string imageBase64; + { + HDC screenDC = GetDC(NULL); + if (!screenDC) return false; + BITMAPINFO bi = {}; + bi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + bi.bmiHeader.biWidth = pw; + bi.bmiHeader.biHeight = -ph; // top-down + bi.bmiHeader.biPlanes = 1; + bi.bmiHeader.biBitCount = 32; + bi.bmiHeader.biCompression = BI_RGB; + void* bits = NULL; + HBITMAP cropBmp = CreateDIBSection(screenDC, &bi, DIB_RGB_COLORS, &bits, NULL, 0); + HDC cropDC = cropBmp ? CreateCompatibleDC(screenDC) : NULL; + // 必须把 DIB 选进内存 DC(与导出管线同式):否则 BitBlt 写入默认 1x1 单色位图, + // DIB 保持全零,编码出纯黑图(BitBlt 仍返回 TRUE,不会被 captured 判定拦截) + HGDIOBJ oldCropBmp = cropDC ? SelectObject(cropDC, cropBmp) : NULL; + bool captured = cropDC && bits && + BitBlt(cropDC, 0, 0, pw, ph, ctx->memDC, px, py, SRCCOPY); + if (captured) { + // BitBlt 后 alpha 字节不可靠(与导出管线同因),统一置 255 再编码 + BYTE* p = (BYTE*)bits; + for (int i = 0; i < pw * ph; i++) p[i * 4 + 3] = 255; + imageBase64 = BitmapToBase64Png(cropBmp); + } + if (cropDC && oldCropBmp) SelectObject(cropDC, oldCropBmp); + if (cropDC) DeleteDC(cropDC); + if (cropBmp) DeleteObject(cropBmp); + ReleaseDC(NULL, screenDC); + } + if (imageBase64.empty()) { + SetTranslateStatus(ctx, L"选区图像提取失败", true); + InvalidateRect(overlayHwnd, NULL, FALSE); + return false; + } + + // ---- 转入进行中:高亮按钮 + 进度气泡 ---- + ctx->translateState = TRL_Busy; + ctx->activeTool = TB_Translate; + SetTranslateStatus(ctx, L"正在识别并翻译…", false); + InvalidateRect(overlayHwnd, NULL, FALSE); + + // ---- 起工作线程:入参所有权随 detach 转移 ---- + auto* job = new TranslateJobInput(); + job->imageBase64 = std::move(imageBase64); + job->imageW = pw; + job->imageH = ph; + job->selection = sel; + job->dpiScale = ds; + job->overlayHwnd = overlayHwnd; + std::thread(TranslateWorkerMain, job).detach(); + return true; +} + +// WM_SCREENSHOT_TRANSLATE_RESULT 接管(截图线程调用):取走结果槽负载并落到 +// ctx——成功 → 译文块存入并进入展示态;失败 → 状态回退并弹出错误气泡(TTL 自动消失)。 + +void HandleTranslateJobResult(CaptureContext* ctx, HWND hwnd) { + if (!ctx) return; + TranslateJobResult* res = nullptr; + { + std::lock_guard lock(g_translateSlotMutex); + res = g_translateSlot; + g_translateSlot = nullptr; + } + if (!res) return; + std::unique_ptr guard(res); + // 会话收尾后理论上不会再收到消息;状态非进行中(已取消/已清)时静默丢弃 + if (ctx->translateState != TRL_Busy) return; + + if (!res->ok) { + ctx->translateState = TRL_Idle; + ctx->activeTool = -1; + SetTranslateStatus(ctx, res->error.c_str(), true); + InvalidateRect(hwnd, NULL, FALSE); + return; + } + ctx->translateBlocks = std::move(res->blocks); + ctx->translateState = TRL_Shown; + // 收起进度气泡(保留 activeTool 高亮:覆盖展示中按钮保持激活观感) + ctx->translateStatusShown = false; + ctx->translateStatusText.clear(); + InvalidateRect(hwnd, NULL, FALSE); +} diff --git a/src/screenshot/windows/wndproc_windows.cpp b/src/screenshot/windows/wndproc_windows.cpp index a92a3f3..5d7fffa 100644 --- a/src/screenshot/windows/wndproc_windows.cpp +++ b/src/screenshot/windows/wndproc_windows.cpp @@ -248,7 +248,7 @@ LRESULT CALLBACK ScreenshotOverlayWndProc(HWND hwnd, UINT msg, WPARAM wParam, LP if ((ctx->state == CS_Confirmed) && PointInRect(ctx->mouseX, ctx->mouseY, ctx->selection)) { ScreenshotResult* result = ExtractRegionResult(ctx->memDC, ctx->selection, ctx->virtualX, ctx->virtualY, ctx->dpiScale, ctx->annotations, ctx->selectionCornerRadius, - ctx->mosaicSizeIdx); + ctx->mosaicSizeIdx, ctx->translateBlocks); // 统一走 EmitScreenshotResult:TSFN 空 / nonblocking 失败均自动释放 result 防泄漏。 EmitScreenshotResult(result->success, result->x, result->y, result->x2, result->y2, result->width, result->height, result->base64); @@ -259,6 +259,13 @@ LRESULT CALLBACK ScreenshotOverlayWndProc(HWND hwnd, UINT msg, WPARAM wParam, LP return 0; } + case WM_SCREENSHOT_TRANSLATE_RESULT: { + // 翻译工作线程完成回投:取走结果槽负载并落到 ctx(成功 → 译文覆盖展示; + // 失败 → 错误气泡,数秒自动消失)。见 translate_windows.cpp。 + HandleTranslateJobResult(ctx, hwnd); + return 0; + } + case WM_RBUTTONDOWN: { ctx->state = CS_Cancelled; // 回调失败结果(统一走 EmitScreenshotResult) diff --git a/test/test-logger.js b/test/test-logger.js new file mode 100644 index 0000000..6af4f39 --- /dev/null +++ b/test/test-logger.js @@ -0,0 +1,121 @@ +// 原生日志模块测试:日志文件、等级控制、JS 侧写入、轮转上限 +// 运行:node test/test-logger.js +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { execFileSync } = require('child_process'); + +const { Logger, WindowManager } = require('..'); + +let passed = 0; +let failed = 0; + +function check(name, condition, detail) { + if (condition) { + console.log(`✓ ${name}`); + passed++; + } else { + console.error(`✗ ${name}${detail ? `: ${detail}` : ''}`); + failed++; + } +} + +function expectThrows(name, fn) { + try { + fn(); + check(name, false, '未抛出异常'); + } catch (err) { + check(name, err instanceof TypeError, `应抛 TypeError,实际: ${err}`); + } +} + +console.log('=== 原生日志模块测试 ===\n'); + +// 1) 日志文件路径:位于系统临时目录下 +const logPath = Logger.getPath(); +console.log(`日志文件: ${logPath}`); +check('getPath 返回字符串', typeof logPath === 'string' && logPath.length > 0); +check('getPath 指向临时目录', path.dirname(logPath) === path.resolve(path.dirname(logPath)) && + logPath.toLowerCase().startsWith(os.tmpdir().toLowerCase())); +check('日志文件名为 ztools-native.log', path.basename(logPath) === 'ztools-native.log'); + +// 2) 默认等级 info +check('默认等级为 info', Logger.getLevel() === 'info'); +check('info 等级启用', Logger.isLevelEnabled('info') === true); +check('debug 等级默认关闭', Logger.isLevelEnabled('debug') === false); + +// 3) 运行时切换等级 +Logger.setLevel('debug'); +check('setLevel(debug) 后 getLevel 为 debug', Logger.getLevel() === 'debug'); +check('debug 等级启用', Logger.isLevelEnabled('debug') === true); + +Logger.setLevel('off'); +check('setLevel(off) 后任何等级都不输出', Logger.isLevelEnabled('error') === false); +Logger.write('error', 'test-logger', 'OFF-MARKER-不应出现'); + +Logger.setLevel('warn'); +check('setLevel(warn) 后 warn 启用', Logger.isLevelEnabled('warn') === true); +check('setLevel(warn) 后 info 关闭', Logger.isLevelEnabled('info') === false); + +Logger.setLevel('trace'); +check('setLevel(trace) 后 trace 启用', Logger.isLevelEnabled('trace') === true); +Logger.setLevel('info'); + +// 4) 非法参数 +expectThrows('setLevel(非法等级) 抛 TypeError', () => Logger.setLevel('verbose')); +expectThrows('isLevelEnabled(123) 抛 TypeError', () => Logger.isLevelEnabled(123)); +expectThrows('write 缺少 tag 抛 TypeError', () => Logger.write('info', '', 'x')); +expectThrows('write(off) 抛 TypeError', () => Logger.write('off', 'app', 'x')); + +// 5) 触发几条原生日志 + JS 侧写入 +WindowManager.getActiveWindow(); +Logger.write('info', 'test-logger', 'JS 侧写入测试'); +Logger.write('error', 'test-logger', 'JS 侧 error 写入测试'); + +// 等待落盘(逐条 fflush,通常立即完成) +const deadline = Date.now() + 3000; +let content = ''; +while (Date.now() < deadline) { + try { + content = fs.readFileSync(logPath, 'utf8'); + } catch (err) { + content = ''; + } + if (content.includes('JS 侧写入测试')) break; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100); +} + +check('日志文件已创建', fs.existsSync(logPath)); +check('会话起始行存在', content.includes('=== ztools_native loaded')); +check('包含日志等级标签', content.includes('[info]')); +check('包含 tag 字段', content.includes('[test-logger]')); +check('包含 JS 侧写入内容', content.includes('JS 侧写入测试')); +check('时间戳格式正确', /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}/.test(content)); +check('off 等级期间的日志未写入', !content.includes('OFF-MARKER')); + +// 6) 环境变量:子进程中设置 ZTOOLS_LOG_LEVEL=debug,getLevel 应读到 debug +const envDebug = { ...process.env, ZTOOLS_LOG_LEVEL: 'debug' }; +const out = execFileSync( + process.execPath, + ['-e', 'const { Logger } = require(process.cwd()); console.log(Logger.getLevel());'], + { env: envDebug, encoding: 'utf8', cwd: path.join(__dirname, '..') } +); +check('环境变量 ZTOOLS_LOG_LEVEL=debug 生效', out.trim() === 'debug'); + +// 7) 轮转:单条消息截断到 8KB,写入约 12MB 后应轮转出 .old 且当前文件不超上限 +console.log('\n(轮转测试:写入约 12MB 日志,可能需要数秒...)'); +Logger.setLevel('info'); +const bigMessage = 'x'.repeat(200000); // 会被截断为单条约 8KB +for (let i = 0; i < 1500; i++) { + Logger.write('info', 'rotate-test', bigMessage); +} +const oldPath = logPath + '.old'; +check('轮转后生成 .old 文件', fs.existsSync(oldPath)); +const currentSize = fs.statSync(logPath).size; +check(`当前文件不超过 10MB(实际 ${(currentSize / 1024 / 1024).toFixed(2)}MB)`, + currentSize <= 10 * 1024 * 1024); +check('当前文件重新从新内容开始', + !fs.readFileSync(logPath, 'utf8').includes('JS 侧写入测试')); + +console.log(`\n=== 结果: ${passed} 通过, ${failed} 失败 ===`); +process.exit(failed > 0 ? 1 : 0); diff --git a/test/test-ocr.png b/test/test-ocr.png new file mode 100644 index 0000000000000000000000000000000000000000..aca68bf5c7120655038a0df4f9d20e146e24cc4a GIT binary patch literal 2973 zcmd5;`BxI?8pdU`#?)3d_m(@&G^y$2q^447mYMr5iWHirA{Js2;gXe=3D&)4YUns+ zxu9T)sEBBu(J5RqMH^gjh74s1R0I`}i{5+A{S)r|@-63l=l!1VdEWQ=-unT5XZ61| z{T2WK=zE{@3<3Z&Bh>56EjsGaHuPbSdeT4yojna`95kO-Z?qFm`JMs*ns9oGi{GgC zThq=(A^-pb^+AIkjXroy8~~u_;O%)TBoi#0N)DTB->DODw=c)U0P!xwVb@^N&RaG< z)!|6QX(`wTn5rm>c38}(I?Lwu0XZT5vp@iVbhb+qaPB*Z2H@Z>5&-acs~XTd?IVDk zI87tKNe2x*fOTokH-MrGe}iL~Q(bEd(nX0xDxuO8-&1g_t7{6bx&IIm4rEw-eK8J) z%fp=t=jT_2$jksB8(azGCaslab#}U0VKJ7Xm3<$%ygo)%m>s-;A|+YbQK3N9kqQ>i z@Jp>ufy~$cYHW2gV_An>nu?IUtwc4nbR*nec%Ag@DjVffC`zB*1;Vs}`T~jP$rElV zhp^}(N!yLME*Ghzn{+{Z)circ?0mH^j(n+^Semg~WkL+37f$LeDP!*5loXk2?9fOK zBC@BY#K0rd&(FX+3QgP;s>lNWc@8?H>#$YDPf$CY#TD9C_~apBOJsR*@*J7rIH+5Z z!H*WDD-DDWU)h#H2dwu)e0j?g6YGu-aBDh!NJY(GtE*`-9{&uJIH&hRtB05eG{c15 z&aTq)i8U4=)C`3k7c+#KqAUh3(M0*ew%;RPwI?m@P0v_wrH6`6O*m@q*BpBRrqhJ6 zvmHlqM_%@!Ze*dAS2MS7iwPfeWe2ZCPswvbx4NtGSq&~c`&V~umKzt*Yo#?Lpu*6$ zmIze^9q#Bd=s>a^yGH#VU5wp^+|((5dL-M3y+3W)R$v#=F~a3x$IY`AP>BLP_-?AK zU*J3hlr!L9uau!PgD!ibl&PPozu}a)mSk;DZGtx~3q3*a{~Rm9H*#bL@$L@KoYz$$ z`FRri=|6KrJCSQ9eB2*Rc5eOh+_|L+lPyytxhXx;=+A`UssZ~{HoaV_)s%e%QcN7< z_6=`nSf}{@a@Gat7t2)UQDk&c4`&F&wCoR;Bb~<9pASWyTy6pxxDENivy>!v= zij#P;=^~@woXf1*7QsB(yff$l?tPL_`E1^h0EG>5%`D|l1UZg~*c4Vpf$l@(e8Z(* zg{B&hvaKP7z%F2v#Cm>1}MG@Sf0S!g@?4(ap_ffMUi z!Qy7#{gPxIjcG0^>E>HzP_OfsjvbT6)N1^q_{X8=?zzKg(hmvv2gQzU;XwTj!P%~m z7-JHzvqTr8tjZWs6GQ7*AIlLh;?X-t*XHV%GI$=_4`RnZxvYbc_mX0F3F$xgLSaM>V|O9Odv@TRINtC_pi4CB z#LqPdqGXm3{6`rZ2HHE5QJC{PML%NTPGVh*F@6K>BZ^08t=>hk8^PKMPwq@$uF2k@ znfvN6zRfC@364y|+65nzUMZ&d)B{Um;)|%^XYuH2y99iD`D<+V5NJdl@?OrK7XGgz z{Nt=n>4(ACEkop0*Vtpypyep9&TOVjhUY7TpE&gU(pWPN+nD6XMZxt9dET$&aHyba zkoK|Ujo(!0j@P}S5%z{xbi%q^V6nm%m+IIvRc?+i1$V^hokP033pF)N*$RAkL00Dx zA}>o+k?q%GtagC2x3*=sw!bq$hjYR6jL27IEq&KVR6V$W_a-?{vJXH)TZlaTah4^l zfM7Wuq8zv`mWNv%hSy^a1#>G*{=CKb1o(jz*Fsj>Xw)lgM_HNv(5Ft*W(5Aw3~zNj zYsU&~L+A+|0#kLt!=K1bLvscKQ(lq=wEsG=6gSyz<;x8YL znciE$i2dDRQuk@u3b_lSTedL3FPWx0EBG~r?sMAp9ljiYIK-)?ors}129 z8(=GGk{fO2q`Hpynqx|RIAlSjb{??%OcF+Mb=9Y;6QZQY9Y#Dylj;dKIU}ZDDzBtTtjtfNpk1)(=<QWDS`)GnxIsk?Tu)=J`EDO zGe)e?wH;3g3&q?9U5dlKsi=60>*hmvNec6pyf9&wnX>f9l<_V1ZZ72@gHeEV5V~p8 zMZYeV-=vd`Cqo8qZzy`sPv$J9k?rNkrwvNM`totd2GwIF#wr2< zHT)_s{n=p$({zfJcZ-=X6o2TnDq)=yW>FO%gIew*?rbt{t?un^!vB8V-2boBT-UlP X?s}rXHz!@)QUkot_<1&-hTQr$ILx~G literal 0 HcmV?d00001 diff --git a/test/test-provider-bridge.js b/test/test-provider-bridge.js new file mode 100644 index 0000000..9cee52c --- /dev/null +++ b/test/test-provider-bridge.js @@ -0,0 +1,104 @@ +/** + * Provider 桥接层端到端测试 + * + * 验证链路:JS 发起 invokeFromNative → 原生线程 → 线程安全函数调度回 JS 线程 + * → JS handler 执行 → 结果按 seq 回传原生 → 原生线程返回 → JS Promise resolve + * + * 运行:node test/test-provider-bridge.js + */ +const assert = require('assert'); +const { ProviderBridge } = require('..'); + +async function testNotStarted() { + assert.strictEqual(ProviderBridge.isReady(), false, 'bridge should not be ready before start'); + await assert.rejects( + () => ProviderBridge.invokeFromNative('echo', {}, 1000), + /provider bridge not started/, + 'invoke before start should fail' + ); + console.log('✓ 未启动时调用给出明确错误'); +} + +async function testEcho() { + const seen = []; + ProviderBridge.start(async (type, input) => { + seen.push({ type, input }); + if (type === 'echo') { + return { echoed: input, from: 'js-handler' }; + } + if (type === 'slow') { + await new Promise((resolve) => setTimeout(resolve, 200)); + return { delayed: true }; + } + if (type === 'fail') { + throw new Error('handler exploded'); + } + throw new Error('unknown type: ' + type); + }); + + assert.strictEqual(ProviderBridge.isReady(), true, 'bridge should be ready after start'); + + const result = await ProviderBridge.invokeFromNative('echo', { text: 'hello', n: 42 }); + assert.deepStrictEqual(result, { echoed: { text: 'hello', n: 42 }, from: 'js-handler' }); + assert.strictEqual(seen.length, 1); + assert.strictEqual(seen[0].type, 'echo'); + console.log('✓ 原生线程 → JS handler → 原生线程 的往返调用成功,数据完整'); +} + +async function testConcurrency() { + const inputs = [1, 2, 3, 4, 5, 6, 7, 8]; + const results = await Promise.all( + inputs.map((n) => ProviderBridge.invokeFromNative('echo', { n })) + ); + results.forEach((r, i) => { + assert.strictEqual(r.echoed.n, inputs[i], `concurrent call ${i} payload mismatch`); + }); + console.log('✓ 并发调用按 seq 正确配对,无串扰'); +} + +async function testHandlerError() { + await assert.rejects( + () => ProviderBridge.invokeFromNative('fail', {}, 5000), + /handler exploded/, + 'handler error should propagate' + ); + console.log('✓ handler 抛错正确回传为调用失败'); +} + +async function testTimeout() { + await assert.rejects( + () => ProviderBridge.invokeFromNative('slow', {}, 50), + /timed out/, + 'slow handler with short timeout should time out' + ); + // 超时后再用足够长的超时调一次,确认桥接未被超时破坏(迟到结果被丢弃) + const ok = await ProviderBridge.invokeFromNative('slow', {}, 5000); + assert.deepStrictEqual(ok, { delayed: true }); + console.log('✓ 超时正确返回,且超时不影响后续调用'); +} + +async function testStop() { + ProviderBridge.stop(); + assert.strictEqual(ProviderBridge.isReady(), false, 'bridge should not be ready after stop'); + + // stop 后重新 start 仍可正常工作 + ProviderBridge.start(async () => ({ ok: true })); + const result = await ProviderBridge.invokeFromNative('anything', {}); + assert.deepStrictEqual(result, { ok: true }); + ProviderBridge.stop(); + console.log('✓ stop 后状态清理正确,且支持重新启动'); +} + +(async () => { + await testNotStarted(); + await testEcho(); + await testConcurrency(); + await testHandlerError(); + await testTimeout(); + await testStop(); + console.log('\n全部 Provider 桥接测试通过 🎉'); + process.exit(0); +})().catch((err) => { + console.error('\n测试失败:', err); + process.exit(1); +}); diff --git a/test/test-translate.js b/test/test-translate.js new file mode 100644 index 0000000..519e14f --- /dev/null +++ b/test/test-translate.js @@ -0,0 +1,123 @@ +// 截图翻译(编辑态工具栏「翻译」按钮)交互式测试:Windows 真机手工验收。 +// +// 验证目标:原生层「ocr(行级坐标)→ 文本行空间聚类成段落 → 逐段 translation」 +// 编排链路(聚类算法的纯逻辑回归见 scripts/run-translate-cluster-selftest.cmd)。 +// 1. 本脚本注册 mock ProviderBridge(不依赖真实 provider / 插件); +// 2. 启动 autoConfirm:false 截图,框选任意区域 → 进入编辑态; +// 3. 点击工具栏「翻译」按钮 → 应出现「正在识别并翻译…」气泡,随后译文以 +// 白底圆角面板覆盖在选区上(mock 坐标按提交图像尺寸生成,恒在选区内); +// 4. 点「确定」导出:译文面板合成进最终图像;ESC/取消则放弃。 +// 5. 控制台会打印桥接调用序列与每次 translation 的入参(可核对段落拼接结果: +// 英文段内空格连接、中文紧排直连)。 +// +// 运行方式: +// node test/test-translate.js # 默认:6 行英文 → 2 个段落 +// # (段间距 + 段末短行触发边界判定) +// MODE=cjk node test/test-translate.js # 中文紧排 + 第二段首行缩进 → 2 个段落 +// MODE=plain node test/test-translate.js # 无坐标模式:OCR 只回整段 text, +// # 验证整图兜底(整选区单面板) +// +// 真实微信 OCR 契约(含坐标)请另跑:node scripts/diag-ocr.cjs +'use strict'; + +const { ScreenCapture, ProviderBridge, Logger } = require('..'); + +const MODE = process.env.MODE === 'cjk' ? 'cjk' : process.env.MODE === 'plain' ? 'plain' : 'para'; +const calls = []; + +// PNG IHDR 宽高(base64 data URI):mock OCR 需要按提交图像像素尺寸生成行级坐标 +function pngSize(dataUri) { + const b64 = String(dataUri).replace(/^data:image\/[a-z]+;base64,/i, ''); + const buf = Buffer.from(b64, 'base64'); + if (buf.length < 24 || buf.readUInt32BE(0) !== 0x89504e47) return { width: 400, height: 200 }; + return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) }; +} + +// 生成一个文本行 block(比例坐标 → 提交图像内像素坐标) +function mkBlock(text, l, t, r, b, W, H) { + return { + text, + left: Math.round(l * W), + top: Math.round(t * H), + right: Math.round(r * W), + bottom: Math.round(b * H), + }; +} + +// 按模式生成行级布局(比例值):行高 ~4.5% 图高、段内行距 ~6% 图高 +function layoutFor(mode, W, H) { + if (mode === 'cjk') { + // 第一段 4 行紧排(vGap≈1.5% 图高)满宽;第二段首行缩进 15% 图宽且段间距 + // 收紧(vGap 2.5% < 0.9×行高,不触发段间距边界)→ 边界只能由缩进判定 + const rows1 = [0.08, 0.14, 0.2, 0.26].map((t, i) => mkBlock(`第${i + 1}行内容`, 0.1, t, 0.9, t + 0.045, W, H)); + const rows2 = [0.33, 0.39].map((t, i) => mkBlock(`缩进段${i + 1}`, 0.25, t, 0.85, t + 0.045, W, H)); + return [...rows1, ...rows2]; + } + // 默认:英文两段。段内 pitch 6%、行高 4.5%(vGap 1.5%);段间距 5.5% + // (vGap 超过 0.9×行高 → 边界)且第一段末行右端仅 50%(段末短行信号) + const rows1 = [0.08, 0.14, 0.2, 0.26].map((t, i) => + mkBlock(`English line ${i + 1} of the first`, 0.1, t, 0.9, t + 0.045, W, H)); + rows1[3].right = Math.round(0.5 * W); // 段末短行 + const rows2 = [0.36, 0.42].map((t, i) => + mkBlock(`Second para line ${i + 1}`, 0.1, t, 0.85, t + 0.045, W, H)); + return [...rows1, ...rows2]; +} + +const EXPECT_PARAS = MODE === 'plain' ? 1 : 2; + +console.log('=== 截图翻译交互式测试(%s模式) ===\n', + MODE === 'plain' ? '无坐标兜底' : MODE === 'cjk' ? '中文紧排+缩进' : '英文两段落'); +console.log('说明:'); +console.log('1. 框选任意区域进入编辑态(工具栏出现)'); +console.log('2. 点击工具栏「翻译」按钮 → 等待译文覆盖面板出现'); +console.log('3. 点「确定」导出(译文合成进图像)或 ESC 取消'); +console.log(`4. 原生日志(tag=translate)会打印 cluster 统计:${Logger.getPath()}\n`); + +ProviderBridge.start(async (type, input) => { + calls.push(type); + console.log(`[bridge] invoke type="${type}"`); + + if (type === 'ocr') { + if (MODE === 'plain') { + // 无坐标契约:只有整段 text(模拟纯文本 AI 识别)→ 原生走整图兜底 + return { text: 'Hello World, this is the fallback whole text.' }; + } + const { width, height } = pngSize(input.image); + console.log(`[bridge] image ${width}x${height}`); + return { + text: 'mock', + blocks: layoutFor(MODE, width, height), + confidence: 0.99, + }; + } + if (type === 'translation') { + console.log(`[bridge] text="${input.text}"`); + return { text: `【译】${input.text}` }; + } + throw new Error(`unknown provider type: ${type}`); +}); + +setTimeout(() => { + ScreenCapture.start({ autoConfirm: false }, (result) => { + ProviderBridge.stop(); + + console.log('\n截图会话结束:'); + if (result.success) { + console.log(`✅ 导出成功 ${result.width}x${result.height},译文面板应已合成进图像(base64 可另存查看)`); + } else { + console.log(`❌ 已取消/失败: ${result.error || ''}`); + } + const ocrCount = calls.filter((t) => t === 'ocr').length; + const trCount = calls.filter((t) => t === 'translation').length; + console.log(`桥接调用序列: ${calls.join(' -> ') || '(未点击翻译)'}`); + console.log(` ocr=${ocrCount} 次, translation=${trCount} 次` + + `(行级模式应 1 次 ocr + ${EXPECT_PARAS} 次 translation(按段,非按行);无坐标模式 1 + 1)`); + process.exit(0); + }); +}, 1000); + +setTimeout(() => { + console.log('\n⏱️ 超时:120 秒内未完成,程序退出'); + ProviderBridge.stop(); + process.exit(1); +}, 120000); diff --git a/test/translate-cluster-selftest.cpp b/test/translate-cluster-selftest.cpp new file mode 100644 index 0000000..1587b87 --- /dev/null +++ b/test/translate-cluster-selftest.cpp @@ -0,0 +1,187 @@ +// 截图翻译子系统:文本行聚类(translate_cluster.cpp)离线自测。 +// 不依赖 node-addon / 平台 UI:直接与纯算法单元一起编译为独立可执行文件, +// 断言段落划分、联合框、行高平滑与 CJK/拉丁拼接规则。 +// +// 运行(MSVC,自动调用 vcvars): +// scripts\run-translate-cluster-selftest.cmd +// 或任意编译器手动编译: +// cl /EHsc /std:c++17 /utf-8 /I src\screenshot\algo ^ +// test\translate-cluster-selftest.cpp src\screenshot\algo\translate_cluster.cpp +#include "translate_cluster_internal.h" + +#include +#include +#include + +static int g_total = 0; +static int g_failed = 0; + +// 断言:失败时打印定位与说明,不中断后续用例 +#define TC_CHECK(cond, msg) \ + do { \ + g_total++; \ + if (!(cond)) { \ + g_failed++; \ + std::printf("FAIL %s:%d %s\n", __FILE__, __LINE__, (msg)); \ + } \ + } while (0) + +// 构造一行(左上角 + 宽高) +static TcLine MkLine(const char* text, double x, double y, double w, double h) { + TcLine l; + l.text = text; + l.x = x; + l.y = y; + l.w = w; + l.h = h; + return l; +} + +// 断言段落的成员行下标(输入原始下标,按阅读序) +static void ExpectMembers(const TcParagraph& p, const std::vector& want, const char* tag) { + TC_CHECK(p.lineIdx == want, tag); +} + +int main() { + // ---- T1:紧排中文同段(vGap≈0、左缘对齐、右缘对齐)→ 3 行并 1 段, + // 文本无空格直连(CJK 拼接规则)---- + { + std::vector in = { + MkLine("\u7b2c\u4e00\u884c", 100, 0, 600, 20), + MkLine("\u7b2c\u4e8c\u884c", 100, 22, 600, 20), + MkLine("\u7b2c\u4e09\u884c", 100, 44, 600, 20), + }; + TcClusterResult r = TcClusterLines(in); + TC_CHECK(r.paragraphs.size() == 1, "T1 one paragraph"); + ExpectMembers(r.paragraphs[0], {0, 1, 2}, "T1 members"); + TC_CHECK(r.paragraphs[0].x == 100 && r.paragraphs[0].y == 0, "T1 box origin"); + TC_CHECK(r.paragraphs[0].w == 600 && r.paragraphs[0].h == 64, "T1 box size"); + TC_CHECK(r.paragraphs[0].lineHeight == 20, "T1 line height"); + TC_CHECK(r.paragraphs[0].text == "\u7b2c\u4e00\u884c\u7b2c\u4e8c\u884c\u7b2c\u4e09\u884c", + "T1 cjk join without spaces"); + TC_CHECK(r.boundaryCount == 0, "T1 no boundary"); + } + + // ---- T2:两端对齐英文两段(末行短 + 段间空隙)→ R4 段末短行切段, + // 段内拉丁词空格拼接 ---- + { + std::vector in = { + MkLine("The quick brown", 100, 0, 600, 20), + MkLine("fox jumps over", 100, 22, 600, 20), + MkLine("the lazy dog", 100, 44, 600, 20), + MkLine("end.", 100, 66, 200, 20), // 段末短行(r=300) + MkLine("New paragraph", 100, 96, 600, 20), // 段间距 vGap=10 + MkLine("starts here.", 100, 118, 550, 20), + }; + TcClusterResult r = TcClusterLines(in); + TC_CHECK(r.paragraphs.size() == 2, "T2 two paragraphs"); + ExpectMembers(r.paragraphs[0], {0, 1, 2, 3}, "T2 para1 members"); + ExpectMembers(r.paragraphs[1], {4, 5}, "T2 para2 members"); + TC_CHECK(r.paragraphs[0].text == + "The quick brown fox jumps over the lazy dog end.", "T2 latin join"); + TC_CHECK(r.paragraphs[1].text == "New paragraph starts here.", "T2 para2 join"); + TC_CHECK(r.boundaryCount == 1, "T2 one boundary"); + } + + // ---- T3:紧排中文缩进分界(vGap≈0、第二段首行缩进 2 字)→ R3 左缘跳变切段 ---- + { + std::vector in = { + MkLine("\u7b2c\u4e00\u6bb5", 100, 0, 600, 20), + MkLine("\u7b2c\u4e8c\u884c", 100, 22, 600, 20), + MkLine("\u6bb5\u843d\u672b\u5c3e", 100, 44, 600, 20), + MkLine("\u7f29\u8fdb\u65b0\u6bb5", 140, 66, 560, 20), // 左缘 +40 = 2×medianH + }; + TcClusterResult r = TcClusterLines(in); + TC_CHECK(r.paragraphs.size() == 2, "T3 indent splits"); + ExpectMembers(r.paragraphs[0], {0, 1, 2}, "T3 para1 members"); + ExpectMembers(r.paragraphs[1], {3}, "T3 para2 members"); + } + + // ---- T4:标题 + 正文(行高/行距跳变)→ 分开;正文段行高按成员中位数 + // 平滑为 20(不被标题的 40 抬高)---- + { + std::vector in = { + MkLine("Chapter One", 100, 0, 600, 40), + MkLine("body line 1", 100, 54, 600, 20), + MkLine("body line 2", 100, 76, 600, 20), + MkLine("body line 3", 100, 98, 600, 20), + }; + TcClusterResult r = TcClusterLines(in); + TC_CHECK(r.paragraphs.size() == 2, "T4 title separated"); + ExpectMembers(r.paragraphs[0], {0}, "T4 title members"); + ExpectMembers(r.paragraphs[1], {1, 2, 3}, "T4 body members"); + TC_CHECK(r.paragraphs[0].lineHeight == 40, "T4 title line height"); + TC_CHECK(r.paragraphs[1].lineHeight == 20, "T4 body line height smoothed"); + TC_CHECK(r.medianLineH == 20, "T4 median height"); + } + + // ---- T5:双栏并排(横向无重叠)→ 各自成段互不串联 ---- + { + std::vector in = { + MkLine("col1 a", 100, 0, 280, 20), + MkLine("col1 b", 100, 22, 280, 20), + MkLine("col1 c", 100, 44, 280, 20), + MkLine("col2 a", 420, 0, 280, 20), + MkLine("col2 b", 420, 22, 280, 20), + MkLine("col2 c", 420, 44, 280, 20), + }; + TcClusterResult r = TcClusterLines(in); + TC_CHECK(r.paragraphs.size() == 2, "T5 two columns"); + ExpectMembers(r.paragraphs[0], {0, 1, 2}, "T5 col1 members"); + ExpectMembers(r.paragraphs[1], {3, 4, 5}, "T5 col2 members"); + } + + // ---- T6:单行 → 单行段落,行高即行高 ---- + { + std::vector in = {MkLine(" only one ", 50, 60, 300, 24)}; + TcClusterResult r = TcClusterLines(in); + TC_CHECK(r.paragraphs.size() == 1, "T6 one paragraph"); + ExpectMembers(r.paragraphs[0], {0}, "T6 members"); + TC_CHECK(r.paragraphs[0].lineHeight == 24, "T6 line height"); + TC_CHECK(r.paragraphs[0].text == "only one", "T6 trimmed text"); + } + + // ---- T7:孤行吸收——左栏行距 36 超过被右栏密集行距拉低的中位数阈值而误切, + // 误切出的单行段是满宽行(右端≈后段右缘)→ 二次修正并回 ---- + { + std::vector in = { + MkLine("l1", 100, 0, 280, 20), + MkLine("l2", 100, 36, 280, 20), // pitch 36 > 1.5×pitchRef(22) + MkLine("l3", 100, 58, 280, 20), + MkLine("c1", 420, 0, 280, 20), // 右栏紧排,拉低 pitchRef + MkLine("c2", 420, 22, 280, 20), + MkLine("c3", 420, 44, 280, 20), + MkLine("c4", 420, 66, 280, 20), + }; + TcClusterResult r = TcClusterLines(in); + TC_CHECK(r.paragraphs.size() == 2, "T7 orphan absorbed"); + ExpectMembers(r.paragraphs[0], {0, 1, 2}, "T7 col1 merged"); + ExpectMembers(r.paragraphs[1], {3, 4, 5, 6}, "T7 col2 members"); + } + + // ---- T8:行数上限——25 行紧排长链截断为 20 + 5 ---- + { + std::vector in; + for (int i = 0; i < 25; i++) in.push_back(MkLine("x", 100, i * 22.0, 600, 20)); + TcClusterResult r = TcClusterLines(in); + TC_CHECK(r.paragraphs.size() == 2, "T8 capped"); + TC_CHECK(r.paragraphs[0].lineIdx.size() == 20, "T8 first cap 20"); + TC_CHECK(r.paragraphs[1].lineIdx.size() == 5, "T8 remainder 5"); + } + + // ---- T9:左对齐(右边不齐)排版——段内随机短行不触发 R4(vGap 紧排)---- + { + std::vector in = { + MkLine("ragged long line here", 100, 0, 600, 20), + MkLine("shorter", 100, 22, 400, 20), // 右端短 200,但 vGap=2 紧排 + MkLine("another full line", 100, 44, 600, 20), + }; + TcClusterResult r = TcClusterLines(in); + TC_CHECK(r.paragraphs.size() == 1, "T9 ragged stays one paragraph"); + ExpectMembers(r.paragraphs[0], {0, 1, 2}, "T9 members"); + } + + std::printf("%s: %d checks, %d failed\n", + g_failed == 0 ? "PASS" : "FAIL", g_total, g_failed); + return g_failed == 0 ? 0 : 1; +} From 1e65d3c2ebad11c6a15c430378078fed99ed5210 Mon Sep 17 00:00:00 2001 From: "J.S.Patrick" <980141374@qq.com> Date: Thu, 24 Sep 2026 10:40:39 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20macOS=20=E6=88=AA=E5=9B=BE=E7=BF=BB?= =?UTF-8?q?=E8=AF=91=E4=B8=8E=20Provider=20=E5=BC=82=E6=AD=A5=E6=A1=A5?= =?UTF-8?q?=E6=8E=A5=EF=BC=8CmacOS=20=E6=88=AA=E5=9B=BE=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E6=94=B9=E9=9D=9E=E9=98=BB=E5=A1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Provider 桥(provider_bridge.h)新增异步通路:InvokeAsync/CancelAsync 按 requestId 受理与取消,与同步 Invoke 共用 pending 表与 seq 序号空间, 取消/超时后晚到的 JS 回传按 seq 落空静默丢弃;双平台导出 invokeProviderAsyncFromNative/cancelProviderAsyncFromNative, JS 侧新增 ProviderBridge.invokeFromNativeAsync - macOS 新增截图翻译(ScreenshotTranslateMac.swift + translate_bridge_mac C ABI shim):全异步状态机(OCR → 聚类 → 有限并发逐段翻译 → 布局渲染), 经 ztoolsRegisterNativeBridge 注入的原生桥函数指针发起 provider 异步调用, 结果经 ztoolsSwiftProviderResultHandler 回投;构建脚本与 CI 同步纳入新源 - macOS 截图会话改为非阻塞生命周期:start() 立即返回,AppKit 事件与周期任务 由宿主主事件循环驱动(Electron 主进程为标准宿主),会话期间可用本进程定时器 触发 abortLongCapture;新增 test/electron-host.cjs 验收宿主 - Windows 译文字号搜索简化为 min(装进段框的最大字号, 原文行距对应字号), 移除行宽利用率/垂直密度对齐;OCR 仅回整段 text 时改走整图兜底不再误报无文字 - 取色器保存并恢复宿主激活策略(修复 Electron 宿主 Dock 图标被永久隐藏); logger.h 修复 std::gettimeofday 在 macOS 工具链不可编译 - 新增 Provider 异步桥端到端测试(test-provider-async-bridge.js),README 补充双平台截图翻译与 macOS 非阻塞会话说明 --- .github/workflows/build.yml | 5 +- README.md | 88 +- index.js | 84 +- scripts/build-swift.sh | 9 +- scripts/diag-ocr.cjs | 2 +- src/ZToolsNative.swift | 18 +- src/binding_mac.cpp | 87 ++ src/binding_windows.cpp | 2 + src/logger.h | 3 +- src/provider_bridge.h | 422 ++++- .../macos/ScreenshotLCPanelMac.swift | 2 +- .../macos/ScreenshotLCToolbarMac.swift | 6 +- .../macos/ScreenshotLongCaptureMac.swift | 30 +- src/screenshot/macos/ScreenshotMac.swift | 54 +- .../macos/ScreenshotOutputMac.swift | 27 +- .../macos/ScreenshotOverlayMac.swift | 137 +- src/screenshot/macos/ScreenshotPaintMac.swift | 5 + src/screenshot/macos/ScreenshotTextMac.swift | 2 +- .../macos/ScreenshotToolbarMac.swift | 11 +- .../macos/ScreenshotTranslateMac.swift | 1370 +++++++++++++++++ src/screenshot/macos/translate_bridge_mac.cpp | 100 ++ src/screenshot/macos/translate_bridge_mac.h | 74 + src/screenshot/windows/sc_types.h | 13 +- src/screenshot/windows/translate_windows.cpp | 268 ++-- test/electron-host.cjs | 40 + test/test-provider-async-bridge.js | 217 +++ test/test-screenshot.js | 4 + test/test-translate.js | 11 +- 28 files changed, 2725 insertions(+), 366 deletions(-) create mode 100644 src/screenshot/macos/ScreenshotTranslateMac.swift create mode 100644 src/screenshot/macos/translate_bridge_mac.cpp create mode 100644 src/screenshot/macos/translate_bridge_mac.h create mode 100644 test/electron-host.cjs create mode 100644 test/test-provider-async-bridge.js diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index de613ab..a3365f0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,13 +38,15 @@ jobs: run: | mkdir -p lib - # 长截图算法层 C++ 对象(与 scripts/build-swift.sh + # 长截图 + 翻译聚类算法层 C++ 对象(与 scripts/build-swift.sh # 逐字一致,两处编译参数必须同步修改。Apple clang 的 C++ 不支持 -Osize, # 取等价的 -Oz) LC_CXX_SOURCES=( src/screenshot/algo/lc_match_core.cpp src/screenshot/algo/lc_stitch_state.cpp src/screenshot/macos/lc_bridge_mac.cpp + src/screenshot/algo/translate_cluster.cpp + src/screenshot/macos/translate_bridge_mac.cpp ) LC_CXX_FLAGS=(-c -std=c++17 -Oz -DNDEBUG -I src/screenshot/algo) @@ -77,6 +79,7 @@ jobs: src/screenshot/macos/ScreenshotTextMac.swift src/screenshot/macos/ScreenshotMosaicMac.swift src/screenshot/macos/ScreenshotOutputMac.swift + src/screenshot/macos/ScreenshotTranslateMac.swift src/screenshot/macos/ScreenshotLongCaptureMac.swift src/screenshot/macos/ScreenshotLCPanelMac.swift src/screenshot/macos/ScreenshotLCToolbarMac.swift diff --git a/README.md b/README.md index 3aa70e7..1665846 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ macOS 和 Windows 原生 API 的 Node.js 封装,使用 Swift + Win32 API + Nod 12. **取色器** - 全屏取色工具 13. **设置文件窗口地址栏** - 跳转 Finder/Explorer 或文件选择对话框到指定路径 14. **Provider 桥接** - 让原生层(任意 native 线程上的 C++/Swift 代码)调用 JS 侧注册的方法(如宿主应用的翻译 / OCR 提供商) -15. **截图翻译** - 编辑态工具栏「翻译」按钮:原生编排 OCR(行级坐标)→ 逐行翻译 → 译文覆盖原文字区域(依赖 Provider 桥接,Windows) +15. **截图翻译** - 编辑态工具栏「翻译」按钮:原生编排 OCR(行级坐标)→ 聚类成段 → 逐段翻译 → 译文覆盖原文字区域(依赖 Provider 桥接,Windows/macOS) ## 🔧 系统要求 @@ -343,12 +343,15 @@ WindowManager.simulatePaste(); **平台差异**: - **交互流程一致**:全屏暗化覆盖层 + 拖拽选区/单击智能窗口吸附 → autoConfirm=true 松手直接出图, autoConfirm=false 进入编辑态(工具栏 16 按钮、矩形/圆形/箭头/画笔/文字/马赛克标注、撤销/重做、 - 选区圆角手柄)→ 确定/保存/取消/长截图/翻译(翻译仅 Windows,依赖 Provider 桥接,见下文) + 选区圆角手柄)→ 确定/保存/取消/长截图/翻译(依赖 Provider 桥接,见下文) - **macOS**: - 需要屏幕录制权限(未授权时首次 `start()` 弹系统授权框,拒绝后回调 `{ success: false, error: ... }`) - 另需辅助功能权限:ESC/右键兜底取消(覆盖层失焦时仍可取消)、长截图滚轮观察与自动滚动(CGEventTap) - - `start()` 会**阻塞 JS 主线程**直至会话收束(覆盖层事件循环运行在调用线程上); - 长截图会话期间需从中止时,请从另一进程/线程调用 `abortLongCapture()` + - `start()` 为**非阻塞**调用:创建会话后立即返回,结果经 callback 异步送达;会话期间 + AppKit 事件与 Node/libuv 事件循环均由宿主进程自己的主事件循环驱动(Electron 主进程 + 天然满足),会话中可正常使用定时器/异步逻辑(如定时调用 `abortLongCapture()`)。 + 纯 Node 宿主不驱动 macOS 主事件循环,无法运行截图会话——交互式验收请用 + `npx electron test/electron-host.cjs test/test-screenshot.js` - 选区坐标为屏幕全局逻辑坐标(左上原点);输出图像为逻辑尺寸(Retina 下内部按物理像素捕获后缩回) - **Windows**: 全屏遮罩 + 拖拽选区 + 编辑标注 + 长截图全功能(坐标系为虚拟屏绝对坐标) - 会话进行中重复调用 `start()` 会抛出 `Error('Screenshot already in progress')`(双平台一致) @@ -367,7 +370,7 @@ ScreenCapture.start((result) => { #### `ScreenCapture.abortLongCapture()` 中止进行中的长截图滚动捕获。滚动捕获会以失败结果(`success: false`)回调后结束(ESC/工具栏取消同语义:取消 = 失败收束);无进行中的长截图时为安全空操作。 -- **平台**: ✅ Windows 和 macOS。macOS 的 `start()` 阻塞 JS 主线程期间,可从另一进程/工作线程调用(参考 `test/test-screenshot-mac.js` 的子进程注入示例) +- **平台**: ✅ Windows 和 macOS。macOS 的 `start()` 为非阻塞调用,会话期间本进程定时器/异步逻辑照常运转,可直接在进程内调用 --- @@ -479,15 +482,43 @@ if (r.ok) { - 支持并发调用(按 `seq` 配对请求与结果);`stop()` 会立即让等待中的调用失败 - 数据以 JSON 字符串穿越桥接,请保持入参/返回值可 JSON 序列化 +除同步 `Invoke` 外,桥接层另提供**异步 RPC**(macOS 截图翻译的生产通路):原生侧 +`InvokeAsync(requestId, type, inputJson, callback, userData)` 发起后立即返回,结果由 +JS 侧回传时经 callback 异步送达(恰好一次);`CancelAsync(requestId)` 取消请求, +此后 JS 晚到的结果按 `seq` 查表落空被静默丢弃。异步 API 非阻塞,可在包括 JS 主线程 +在内的任意线程调用;超时由调用方驱动(deadline 扫描 + CancelAsync)。从 JS 侧可走 +真实通路验证: + +```javascript +// 异步通路验证(原生 InvokeAsync + requestId + 看门狗超时 + CancelAsync 丢晚到): +const result = await ProviderBridge.invokeFromNativeAsync('translation', { text: 'hello' }, 1001, 5000); +ProviderBridge.cancelFromNative(1001); // 取消在飞请求(幂等) +``` + +对应的原生侧用法(见 `src/provider_bridge.h`): + +```cpp +// 异步:立即返回受理与否,结果经 callback(requestId, ok, value, error) 回投 +ztools_provider_bridge::InvokeAccept a = + ztools_provider_bridge::InvokeAsync(1001, "translation", "{\"text\":\"hi\"}", + MyCallback, myUserData); +if (!a.accepted) { /* 不会触发回调,当场收尾 */ } +ztools_provider_bridge::CancelAsync(1001); // 取消:晚到结果被丢弃 +``` + ### 截图翻译(编辑态工具栏「翻译」按钮,依赖 Provider 桥接) 编辑态(`autoConfirm: false`)工具栏的「翻译」按钮:点击后原生层自动完成 -**OCR 识别选区文字 → 逐行翻译 → 译文覆盖原文字区域**,确认/保存导出的图像同样包含译文覆盖。 -覆盖展示中再次点击「翻译」会清掉旧结果并按当前选区重跑。 +**OCR 识别选区文字 → 空间聚类还原段落 → 逐段翻译 → 译文覆盖原文字区域**,确认/保存导出的 +图像同样包含译文覆盖。覆盖展示中再次点击「翻译」为切换退出(清掉译文覆盖、取消按钮激活, +不重跑);需要重译时再点一次重新发起。 编排发生在原生层:先调 type=`ocr` 拿到带行级坐标(top/left/right/bottom)的文本行数组, -再对每一行分别调 type=`translation`(translation 契约入参为单条 text,多行需逐行请求), -最后在原生侧把译文与坐标合并、按行高适配字号渲染。OCR/翻译渠道沿用用户在设置页 +再把文本行按空间关系聚类还原成段落(`src/screenshot/algo/translate_cluster.cpp`,跨平台 +共用:行高/行距估计 → 候选相邻 → 多维特征判定连续/边界 → 聚合 + 孤行吸收,解决 OCR 按行 +输出导致的段落信息丢失),然后对每个段落分别调一次 type=`translation`(段落文本已按 CJK/拉丁 +规则拼接;macOS 以有限并发逐段调用、Windows 串行调用以尊重 provider 限流),最后在原生侧把译文与对应段落的成员行 +联合框合并成可渲染块、按段落版面特征适配字号渲染。OCR/翻译渠道沿用用户在设置页 选择的默认提供商——宿主只需把桥接类型透传给 providerManager: ```javascript @@ -522,14 +553,24 @@ provider 约定(原生侧已按此实现): - OCR 未返回坐标(blocks 为字符串数组或仅有 text,如纯文本 AI 识别)时走整图兜底: 整段文本一次翻译,以整个选区为单一覆盖面板 -- 逐行翻译串行执行;单行失败跳过该行(日志可见),全部失败才整体报错 +- 单段失败/超时跳过该段(日志可见),全部失败才整体报错;macOS 为有限并发 + (同时在飞 ≤2,完成即补发),Windows 保持逐段串行(尊重 provider 限流) +- macOS 全链路为**异步 RPC**(requestId 驱动):每个请求有唯一 `jobId`/`requestId`, + 晚到结果(已超时/已取消/任务已换代)按 requestId 匹配落空直接丢弃,绝不污染当前 UI 交互细节: -- 点击「翻译」→ 选区上方出现「正在识别并翻译…」进度气泡(OCR 超时 30 秒、单行翻译超时 15 秒) -- 成功 → 每个文字行以白底圆角面板盖住原文并排入译文,字号按行高适配 - (装不下时自动扩展/缩小字号/省略号截断) +- 点击「翻译」→ 选区上方出现「正在识别并翻译…」进度气泡(OCR 超时 30 秒、单段翻译超时 15 秒) +- 成功 → 每个段落以白底圆角面板盖住原文并排入译文,译文字号按段落独立搜索 + (以原字号估计为锚点,可行性二分 + 候选真实排版测量评分,取综合视觉密度最接近原段落者; + 装不下时向下扩高并省略号截断) - 失败(OCR/翻译出错、未识别到文字、超时等)→ 深红文字错误气泡,约 4 秒后自动消失 -- **平台**: ✅ Windows(macOS 编辑态工具栏暂无翻译按钮) +- **平台**: ✅ Windows 和 macOS(全功能对等)。macOS 截图会话为**非阻塞生命周期对象** + (`start()` 立即返回,AppKit 事件与周期任务由宿主进程主事件循环驱动,交互式验收 + 请用 Electron 宿主,见上文「平台差异」);翻译全链路为**异步 RPC**(requestId 驱动): + 每个请求有唯一 `jobId`/`requestId`,provider 的 Promise/网络请求由宿主进程自身的 + 事件循环驱动(Electron 主进程消息泵在会话期间持续运转 libuv 与 NSApp),原生侧 + 不手动泵 `uv_run`/V8 微任务——纯 Node 宿主无法运行 macOS 截图会话, + 请在 Electron 宿主中使用截图翻译 ## 🗒️ 原生日志(Logger) @@ -595,9 +636,12 @@ node test/test-keyboard.js # 完整键盘测试 node test/test-keyboard-simple.js # 简单键盘测试 node test/test-selected-content.js # 获取选中内容测试 node test/test-provider-bridge.js # Provider 桥接(原生层调用 JS 方法)测试 -node test/test-screenshot-mac.js # macOS 区域截图交互测试(真机手工验收,见脚本头说明) +node test/test-provider-async-bridge.js # Provider 异步桥接(requestId/超时/取消/晚到丢弃/并发)测试 +node test/test-screenshot.js # 区域截图交互测试(真机手工验收;macOS 用 + # npx electron test/electron-host.cjs test/test-screenshot.js) node test/test-translate.js # 截图翻译交互测试(mock provider,真机手工验收,见脚本头说明; - # MODE=plain 环境变量切换无坐标兜底模式) + # MODE=plain 环境变量切换无坐标兜底模式;macOS 用 + # npx electron test/electron-host.cjs test/test-translate.js) node test/test-logger.js # 原生日志(等级/文件/轮转)测试 ``` @@ -611,7 +655,7 @@ node test/test-logger.js # 原生日志(等级/文件/轮转)测 | **键盘模拟** | ✅ 需要辅助功能权限 | ✅ 无需特殊权限 | | **区域截图** | ✅ 支持(全功能:选区 + 标注 + 圆角导出 + 保存) | ✅ 支持(全功能:选区 + 标注 + 圆角导出 + 保存) | | **长截图** | ✅ 支持(拼接/自动滚动/小地图/裁剪) | ✅ 支持(拼接/自动滚动/小地图/裁剪) | -| **截图线程模型** | ⚠️ `start()` 阻塞 JS 主线程直至会话收束(覆盖层事件循环在调用线程上) | 独立捕获线程,`start()` 立即返回 | +| **截图线程模型** | ✅ 非阻塞会话(`start()` 立即返回;AppKit 事件与 Node 事件循环均由宿主主事件循环驱动,需 Electron 等 NSApplication 宿主) | 独立捕获线程,`start()` 立即返回 | | **截图标注** | ✅ 矩形/椭圆/箭头/画笔/文字(IME)/马赛克,行为对齐 | ✅ 同左 | | **截图输出** | ✅ PNG base64 + 剪贴板(原生支持透明 alpha)+ 保存对话框 + 圆角透明导出 | ✅ 同左(圆角透明走 `CF_DIB(V4)+PNG` 双格式) | | **获取选中内容** | ✅ 支持(模拟复制) | ✅ 支持(UI Automation + 剪贴板回退) | @@ -634,10 +678,12 @@ node test/test-logger.js # 原生日志(等级/文件/轮转)测 - **截图完整体验还建议授予辅助功能权限**: - ESC/右键兜底取消(覆盖层失焦时仍可取消,未授权时降级为覆盖层自身按键处理) - 长截图的滚轮观察与自动滚动(CGEventTap) -- **macOS 截图会话会阻塞 JS 主线程**:`start()` 从调用起阻塞至会话收束(回调在其后触发); - Electron/Node 宿主如需在会话期间执行其他逻辑,请放在 worker 线程或提前调度 -- **交互测试脚本**:`node test/test-screenshot-mac.js`(权限预检 / 成功回调契约 / 编辑态与 - ESC 取消 / 长截图 abort,全交互式,需在真机上按脚本指引执行) +- **macOS 截图会话为非阻塞生命周期对象**:`start()` 创建会话后立即返回,结果经回调异步送达; + 会话期间 AppKit 事件(覆盖层交互/绘制)与周期任务(翻译回投、插入符闪烁、长截图采样) + 均由宿主进程自己的主事件循环驱动——Electron 主进程天然满足;纯 Node 宿主不运转 + macOS 主事件循环,无法运行截图会话(交互式验收用 `npx electron test/electron-host.cjs …`) +- **交互测试脚本**:`node test/test-screenshot.js`(权限预检 / 成功回调契约 / 编辑态与 + ESC 取消 / 长截图 abort,全交互式,需在真机上按脚本指引执行;macOS 需 Electron 宿主) ### Windows - Process ID 每次启动都会变化,不适合持久化存储 diff --git a/index.js b/index.js index 83b83a7..036438e 100644 --- a/index.js +++ b/index.js @@ -579,7 +579,7 @@ class ScreenCapture { * 拼接无帧数/像素上限,可持续合并至用户主动结束 * @param {number} [options.longCapture.interval=250] - 滚轮停止后等待内容稳定的毫秒数(50~2000,采样防抖; * 滚动进行中也会按不低于 min(interval, 250)ms 的节拍主动采样,保证相邻帧有大重叠区域) - * 编辑态工具栏另有「翻译」按钮(仅 Windows):OCR 识别选区文字 → 翻译 → 译文覆盖原文字区域, + * 编辑态工具栏另有「翻译」按钮(Windows/macOS):OCR 识别选区文字 → 翻译 → 译文覆盖原文字区域, * 依赖 ProviderBridge 注册默认启用的 ocr/translation provider(约定见 README「截图翻译」); * 确认/保存导出的图像同样包含译文覆盖 * @param {Function} [callback] - 截图完成时的回调函数 @@ -598,8 +598,11 @@ class ScreenCapture { * 马赛克/撤销重做/选区圆角)、圆角透明导出、保存对话框与长截图全子系统均已实现, * 行为与 Windows 版对齐;差异项见 README「平台差异」表 * - 额外需要辅助功能权限:ESC/右键兜底取消、长截图滚轮观察与 autoScroll(CGEventTap) - * - macOS 的 start() 会阻塞 JS 主线程直至会话收束(覆盖层事件循环运行在调用线程上), - * 会话期间无法用本进程定时器触发 abortLongCapture,需要时请从另一进程/线程调用 + * - macOS 的 start() 为**非阻塞**调用:创建会话后立即返回,结果经 callback 异步送达; + * 会话期间 AppKit 事件与 Node/libuv 事件循环均由宿主进程自身的主事件循环驱动 + * (Electron 主进程天然满足),会话期间可正常使用本进程定时器/异步逻辑 + * (如定时触发 abortLongCapture)。纯 Node 宿主不驱动 macOS 主事件循环, + * 无法运行截图会话,请在 Electron 宿主中使用(见 README「平台差异」) * * @example * // 默认:框选/点选完成即出图,不再二次编辑 @@ -633,8 +636,8 @@ class ScreenCapture { /** * 中止进行中的长截图滚动捕获(Windows / macOS 双平台) * 滚动捕获会以失败结果(success: false)回调后结束(ESC/取消同语义:取消 = 失败收束); - * 无进行中的长截图时为安全空操作。可在任意线程/进程调用(macOS 会话期间 JS 主线程 - * 被阻塞时,从另一进程或工作线程调用即可,见 test/test-screenshot-mac.js 的注入示例) + * 无进行中的长截图时为安全空操作,可在任意线程/进程调用(macOS start() 为非阻塞 + * 调用,会话期间本进程定时器/异步逻辑照常运转,可直接调用本方法) */ static abortLongCapture() { addon.abortLongCapture(); @@ -828,10 +831,10 @@ function launchCuiShell(shell, currentDirectory) { * return await providerManager.invoke(type, input); * }); * - * 内置约定(Windows 截图工具栏「翻译」按钮依赖以下两个类型,详见 README「截图翻译」): + * 内置约定(截图工具栏「翻译」按钮依赖以下两个类型,详见 README「截图翻译」): * - ocr: { image } => { text, blocks: [{ text, left, top, right, bottom }] } * (blocks 为行级坐标块;provider 只回整段 text 时原生自动走整图兜底) - * - translation: { text } => { text }(OCR 多行结果由原生逐行分别调用) + * - translation: { text } => { text }(OCR 多行结果由原生按聚类段落分别调用) * * 原生侧(C++/Swift)调用方式见 src/provider_bridge.h 的 * ztools_provider_bridge::Invoke(type, inputJson, timeoutMs)。 @@ -853,10 +856,18 @@ const ProviderBridge = { throw new TypeError('ProviderBridge.start requires a handler function'); } addon.startProviderBridge((type, inputJson, seq) => { - Promise.resolve() - .then(() => handler(type, JSON.parse(inputJson))) - .then((result) => { - addon.resolveProviderBridge(seq, JSON.stringify(result === undefined ? null : result)); + // handler 同步启动:分发即开始执行(含同步抛错经 reject 回传),异步续体由 + // 宿主事件循环的常规回调边界推进并排空 microtask + let result; + try { + result = handler(type, JSON.parse(inputJson)); + } catch (err) { + addon.rejectProviderBridge(seq, err instanceof Error ? err.message : String(err)); + return; + } + Promise.resolve(result) + .then((value) => { + addon.resolveProviderBridge(seq, JSON.stringify(value === undefined ? null : value)); }) .catch((err) => { addon.rejectProviderBridge(seq, err instanceof Error ? err.message : String(err)); @@ -881,7 +892,7 @@ const ProviderBridge = { }, /** - * 从 JS 侧发起一次“原生发起”的调用:走真实原生线程 → JS 线程的完整通路, + * 从 JS 侧发起一次"原生发起"的调用:走真实原生线程 → JS 线程的完整通路, * 用于验证桥接链路(原生业务代码应直接调用 C++ 的 Invoke,而不是本方法)。 * @param {string} type - 能力类型 * @param {any} input - 入参(自动 JSON 序列化传给 handler) @@ -907,6 +918,55 @@ const ProviderBridge = { } ); }); + }, + + /** + * 从 JS 侧发起一次"原生发起"的异步调用:走与 macOS 截图翻译完全相同的异步通路 + * (原生 InvokeAsync + requestId + 看门狗超时 + CancelAsync 丢弃晚到结果), + * 用于验证异步桥接(原生业务代码应直接调用 C++ 的 InvokeAsync,而不是本方法)。 + * @param {string} type - 能力类型 + * @param {any} input - 入参(自动 JSON 序列化传给 handler) + * @param {number} requestId - 请求号(调用方保证唯一;用于 cancelFromNative 取消) + * @param {number} [timeoutMs=15000] - 超时毫秒数(到时取消请求并 reject;handler + * 晚到的 resolve/reject 被原生侧丢弃) + * @returns {Promise} handler 的返回值;桥接未就绪/超时/handler 抛错时 reject + */ + invokeFromNativeAsync(type, input, requestId, timeoutMs = 15000) { + return new Promise((resolve, reject) => { + let settled = false; + const finish = (fn, arg) => { + if (settled) return; // 晚到结果(已超时/已取消)静默丢弃 + settled = true; + fn(arg); + }; + addon.invokeProviderAsyncFromNative( + type, + JSON.stringify(input === undefined ? null : input), + requestId, + timeoutMs, + (err, resultJson) => { + if (err) { + finish(reject, new Error(err)); + return; + } + try { + finish(resolve, JSON.parse(resultJson)); + } catch (parseErr) { + finish(reject, parseErr); + } + } + ); + }); + }, + + /** + * 取消一个经 invokeFromNativeAsync 发起的请求(幂等;已回调/已取消时为 false)。 + * 取消后 handler 晚到的 resolve/reject 被原生侧按 requestId 丢弃。 + * @param {number} requestId - 请求号 + * @returns {boolean} 是否确由本次调用完成取消 + */ + cancelFromNative(requestId) { + return addon.cancelProviderAsyncFromNative(requestId); } }; diff --git a/scripts/build-swift.sh b/scripts/build-swift.sh index 0b03fb4..94a8424 100755 --- a/scripts/build-swift.sh +++ b/scripts/build-swift.sh @@ -21,14 +21,18 @@ echo "📱 当前架构: $ARCH" # 长截图算法层 C++ 对象(lc_match_core + lc_stitch_state # 纯算法层与 lc_bridge C ABI shim,clang++ 编出 .o 后随 swiftc -emit-library 链入 # dylib;Swift 侧经 src/screenshot/macos/LCBridgeMac.swift 的 @_silgen_name 调用。 +# 翻译聚类算法(translate_cluster)与其 C ABI shim 同链路(ScreenshotTranslateMac.swift +# 经 @_silgen_name 调用)。 # 注意:本段与 .github/workflows/build.yml「Build Swift library」步骤内联实现逐字一致 # (两处编译参数必须同步修改)。Apple clang 的 C++ 不支持 -Osize,取等价的 -Oz。 -# -I:算法层头文件目录(lc_bridge_mac.cpp 位于 macos/,需检索 ../algo 的 -# long_capture_internal.h / lc_platform.h)。 +# -I:算法层头文件目录(lc_bridge_mac.cpp / translate_bridge_mac.cpp 位于 macos/, +# 需检索 ../algo 的 long_capture_internal.h / translate_cluster_internal.h / lc_platform.h)。 LC_CXX_SOURCES=( src/screenshot/algo/lc_match_core.cpp src/screenshot/algo/lc_stitch_state.cpp src/screenshot/macos/lc_bridge_mac.cpp + src/screenshot/algo/translate_cluster.cpp + src/screenshot/macos/translate_bridge_mac.cpp ) LC_CXX_FLAGS=(-c -std=c++17 -Oz -DNDEBUG -I src/screenshot/algo) @@ -63,6 +67,7 @@ SWIFT_SOURCES=( src/screenshot/macos/ScreenshotTextMac.swift src/screenshot/macos/ScreenshotMosaicMac.swift src/screenshot/macos/ScreenshotOutputMac.swift + src/screenshot/macos/ScreenshotTranslateMac.swift src/screenshot/macos/ScreenshotLongCaptureMac.swift src/screenshot/macos/ScreenshotLCPanelMac.swift src/screenshot/macos/ScreenshotLCToolbarMac.swift diff --git a/scripts/diag-ocr.cjs b/scripts/diag-ocr.cjs index b4b7cc3..45f760c 100644 --- a/scripts/diag-ocr.cjs +++ b/scripts/diag-ocr.cjs @@ -5,7 +5,7 @@ const fs = require('node:fs') const path = require('node:path') const ROOT = path.resolve(__dirname, '..') -const PLUGIN = path.resolve(ROOT, '..', 'ZTools-plugins-collection', 'f-provider', 'dist') +const PLUGIN = path.resolve(ROOT, '..', 'ZTools-collection', 'f-provider', 'dist') const src = fs.readFileSync(path.join(PLUGIN, 'preload', 'services.js'), 'utf8') const sandboxWindow = { __registered: {} } diff --git a/src/ZToolsNative.swift b/src/ZToolsNative.swift index 0782e87..21598ae 100644 --- a/src/ZToolsNative.swift +++ b/src/ZToolsNative.swift @@ -1170,6 +1170,9 @@ private var colorPickerCallback: ColorPickerCallback? = nil private var colorPickerWindow: NSWindow? = nil private var colorPickerView: ColorPickerGridView? = nil private var isColorPickerActive = false +/// 取色启动前宿主进程的激活策略(accessory 运行期间的保存值,stop 时恢复; +/// 嵌入宿主如 Electron 为 .regular,不恢复会永久隐藏宿主 Dock 图标) +private var colorPickerSavedPolicy: NSApplication.ActivationPolicy? = nil private var lastColorPickerUpdateTime: TimeInterval = 0 private let colorPickerUpdateInterval: TimeInterval = 1.0 / 30.0 // 30 FPS private var colorPickerEventTap: CFMachPort? = nil @@ -1532,9 +1535,15 @@ public func startColorPicker(_ callback: ColorPickerCallback?) { colorPickerCallback = callback isColorPickerActive = true - // 确保 NSApplication 完整初始化(N-API 调用在主线程上) + // 确保 NSApplication 完整初始化(N-API 调用在主线程上)。 + // accessory 策略记录宿主原值,stopColorPicker 收束时恢复——嵌入宿主 + //(如 Electron 主进程,policy = .regular)不恢复会永久隐藏宿主 Dock 图标。 let app = NSApplication.shared - app.setActivationPolicy(.accessory) + let currentPolicy = app.activationPolicy() + if currentPolicy != .accessory { + colorPickerSavedPolicy = currentPolicy + app.setActivationPolicy(.accessory) + } app.finishLaunching() // 在主线程创建窗口(AppKit 要求 NSWindow 必须在主线程创建) @@ -1650,6 +1659,11 @@ public func stopColorPicker() { colorPickerWindow = nil colorPickerView = nil colorPickerCallback = nil + // 恢复宿主激活策略(取色以 accessory 运行;见 startColorPicker 的保存点) + if let saved = colorPickerSavedPolicy { + colorPickerSavedPolicy = nil + NSApplication.shared.setActivationPolicy(saved) + } print("Color picker stopped") } diff --git a/src/binding_mac.cpp b/src/binding_mac.cpp index 2ebb781..93f937d 100644 --- a/src/binding_mac.cpp +++ b/src/binding_mac.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -42,6 +43,14 @@ typedef void (*ScreenshotResultCB)(const char *); // 截图会 typedef int (*PrimeScreenshotFrameFunc)(); // 预抓整屏帧(返回 1/0) typedef int (*StartRegionCaptureFunc)(const char *, ScreenshotResultCB); // 启动区域截图会话(返回 1 受理/0 拒绝) typedef void (*AbortLongCaptureFunc)(); // 中止长截图滚动捕获 +typedef int32_t (*ProviderInvokeAsyncCB)(uint64_t, const char *, const char *, + char **); // Swift 侧 provider 异步调用(invokeAsync 包装) +typedef void (*ProviderCancelCB)(uint64_t); // Swift 侧 provider 请求取消包装 +typedef int32_t (*ProviderReadyCB)(void); // Swift 侧 provider 桥就绪查询 +typedef int32_t (*RegisterNativeBridgeFunc)(ProviderInvokeAsyncCB, ProviderCancelCB, + ProviderReadyCB); // 注册原生桥函数指针(进程级一次) +typedef void (*ProviderResultForSwiftFn)(uint64_t, int32_t, const char *, + const char *); // Swift 侧结果接收(bridge 异步回投目标) // 全局变量 static void *swiftLibHandle = nullptr; @@ -85,6 +94,62 @@ static AbortLongCaptureFunc abortLongCaptureFunc = nullptr; // JS 线程串行读写,Swift 侧仅通过回调间接复位,无需更强同步。 static std::atomic g_screenshotInProgress(false); +// ==================== 原生桥 C 包装(供 Swift dylib 经函数指针调用) ==================== +// 背景:Provider 桥(provider_bridge.h)的 TSFN/登记表存活于本 .node 模块内, +// Swift dylib 无法直接链接;库加载时经 ztoolsRegisterNativeBridge 把下列包装的 +// 函数指针注入 Swift 侧(进程级一次)。macOS 截图翻译走异步通路(InvokeAsync + +// requestId + 结果回调),Swift 发起后立即返回,不阻塞等待 JS——截图会话为 +// 非阻塞生命周期对象,N-API 调用栈立即返回,宿主进程(Electron 主进程等)的 +// 事件循环照常运转 Node/libuv/V8 与 AppKit,TSFN 派发与 provider 网络请求由 +// 宿主自身驱动,无需截图模块补驱动。 +// 约定:errorOut 为 malloc 分配的 C 字符串,调用方(Swift)用毕 free。 + +// provider 桥就绪查询(转发 ztools_provider_bridge::IsReady)。 +extern "C" int32_t ZtoolsProviderIsReadyForSwift() { + return ztools_provider_bridge::IsReady() ? 1 : 0; +} + +// Swift 侧结果接收函数(dlsym 自 dylib 的 ztoolsSwiftProviderResultHandler, +// LoadSwiftLibrary 时解析;库加载后恒有效)。回投线程 = 触发来源线程(JS 线程), +// Swift 实现只做线程安全入队。 +static ProviderResultForSwiftFn providerResultForSwift = nullptr; + +// 桥异步回调适配:把 InvokeAsync 的结果转投给 Swift 侧结果接收函数。 +static void ProviderResultTrampoline(uint64_t requestId, bool ok, const char *valueJson, + const char *error, void * /*userData*/) { + if (providerResultForSwift != nullptr) { + providerResultForSwift(requestId, ok ? 1 : 0, valueJson, error); + } +} + +// provider 桥异步调用(转发 ztools_provider_bridge::InvokeAsync):非阻塞, +// 可在包括 JS 主线程在内的任意线程调用;受理后结果经 +// ztoolsSwiftProviderResultHandler 异步回投 Swift,恰好一次(拒绝时不回投)。 +// 超时由 Swift 侧驱动(deadline 扫描 + ZtoolsProviderCancelRequestForSwift)。 +// 返回 1 已受理 / 0 拒绝(*errorOut 置为可读错误)。 +extern "C" int32_t ZtoolsProviderInvokeAsyncForSwift(uint64_t requestId, const char *type, + const char *inputJson, char **errorOut) { + if (errorOut != nullptr) *errorOut = nullptr; + if (providerResultForSwift == nullptr) { + if (errorOut != nullptr) *errorOut = strdup("provider result handler not registered"); + return 0; + } + ztools_provider_bridge::InvokeAccept accept = ztools_provider_bridge::InvokeAsync( + requestId, type != nullptr ? type : "", inputJson != nullptr ? inputJson : "", + ProviderResultTrampoline, nullptr); + if (!accept.accepted) { + if (errorOut != nullptr) *errorOut = strdup(accept.error.c_str()); + return 0; + } + return 1; +} + +// 取消一个进行中的异步 provider 请求(转发 ztools_provider_bridge::CancelAsync): +// 登记立即移除,JS 晚到的回传被桥静默丢弃(不触发 Swift 回调);幂等。 +extern "C" void ZtoolsProviderCancelRequestForSwift(uint64_t requestId) { + ztools_provider_bridge::CancelAsync(requestId); +} + // 在主线程调用 JS 回调 void CallJs(napi_env env, napi_value js_callback, void *context, void *data) { if (env != nullptr && js_callback != nullptr) { @@ -269,6 +334,24 @@ bool LoadSwiftLibrary(Napi::Env env) { (StartRegionCaptureFunc)dlsym(swiftLibHandle, "startRegionCaptureWithPrimedFrame"); abortLongCaptureFunc = (AbortLongCaptureFunc)dlsym(swiftLibHandle, "abortLongCapture"); + // 原生桥注册(截图翻译用:provider 异步调用 / 请求取消 / 就绪查询三个包装的 + // 函数指针注入 Swift;结果回投方向经 dlsym 解析 Swift 侧导出)。 + // 注册失败不阻断库加载(旧 dylib 无该导出时仅翻译功能降级),日志记录。 + providerResultForSwift = + (ProviderResultForSwiftFn)dlsym(swiftLibHandle, "ztoolsSwiftProviderResultHandler"); + if (providerResultForSwift == nullptr) { + ZLOG_WARN("core", "ztoolsSwiftProviderResultHandler not found (translate disabled)"); + } + RegisterNativeBridgeFunc registerNativeBridge = + (RegisterNativeBridgeFunc)dlsym(swiftLibHandle, "ztoolsRegisterNativeBridge"); + if (registerNativeBridge != nullptr) { + int32_t registered = registerNativeBridge(ZtoolsProviderInvokeAsyncForSwift, + ZtoolsProviderCancelRequestForSwift, + ZtoolsProviderIsReadyForSwift); + ZLOG_INFO("core", "native bridge registered for swift dylib -> %d", registered); + } else { + ZLOG_WARN("core", "ztoolsRegisterNativeBridge not found (translate disabled)"); + } if (!startMonitorFunc || !stopMonitorFunc || !startWindowMonitorFunc || !stopWindowMonitorFunc || !getActiveWindowFunc || !activateWindowFunc || @@ -1605,6 +1688,10 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) { Napi::Function::New(env, ztools_provider_bridge::IsProviderBridgeReady)); exports.Set("invokeProviderFromNative", Napi::Function::New(env, ztools_provider_bridge::InvokeProviderFromNative)); + exports.Set("invokeProviderAsyncFromNative", + Napi::Function::New(env, ztools_provider_bridge::InvokeProviderAsyncFromNative)); + exports.Set("cancelProviderAsyncFromNative", + Napi::Function::New(env, ztools_provider_bridge::CancelProviderAsyncFromNative)); // 日志管理:等级查询/设置、日志文件路径、JS 侧写入同一日志文件 ztools_log_binding::Register(env, exports); return exports; diff --git a/src/binding_windows.cpp b/src/binding_windows.cpp index 72e17a7..fcbec47 100644 --- a/src/binding_windows.cpp +++ b/src/binding_windows.cpp @@ -6942,6 +6942,8 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) { exports.Set("rejectProviderBridge", Napi::Function::New(env, ztools_provider_bridge::RejectProviderBridge)); exports.Set("isProviderBridgeReady", Napi::Function::New(env, ztools_provider_bridge::IsProviderBridgeReady)); exports.Set("invokeProviderFromNative", Napi::Function::New(env, ztools_provider_bridge::InvokeProviderFromNative)); + exports.Set("invokeProviderAsyncFromNative", Napi::Function::New(env, ztools_provider_bridge::InvokeProviderAsyncFromNative)); + exports.Set("cancelProviderAsyncFromNative", Napi::Function::New(env, ztools_provider_bridge::CancelProviderAsyncFromNative)); // 日志管理:等级查询/设置、日志文件路径、JS 侧写入同一日志文件 ztools_log_binding::Register(env, exports); return exports; diff --git a/src/logger.h b/src/logger.h index 544c0de..07e012e 100644 --- a/src/logger.h +++ b/src/logger.h @@ -238,7 +238,8 @@ inline void FormatTimestamp(char* buf, size_t bufSize) { (unsigned)st.wMilliseconds); #else struct timeval tv; - std::gettimeofday(&tv, nullptr); + // gettimeofday 为 POSIX 全局命名空间函数(std::gettimeofday 非标准,macOS 工具链不提供) + gettimeofday(&tv, nullptr); struct tm tmv; localtime_r(&tv.tv_sec, &tmv); std::snprintf(buf, bufSize, "%04d-%02d-%02d %02d:%02d:%02d.%03d", diff --git a/src/provider_bridge.h b/src/provider_bridge.h index 08ce46c..931412e 100644 --- a/src/provider_bridge.h +++ b/src/provider_bridge.h @@ -7,13 +7,22 @@ // 1. JS 侧调用 startProviderBridge(dispatcher) 注册分发函数,签名为 // (type: string, inputJson: string, seq: number) => void, // JS 负责执行实际逻辑,并按 seq 通过 resolveProviderBridge / rejectProviderBridge 回传结果; -// 2. 原生线程调用 InvokeProvider(type, inputJson, timeoutMs) 发起调用: -// 请求经线程安全函数调度到 JS 线程执行,原生线程阻塞等待结果(带超时); -// 3. JS 执行完成后回传结果 JSON 字符串,原生侧拿到 InvokeResult。 +// 2. 原生侧有两种发起方式(共用同一张 pending 表与 seq 序号空间,互不冲突): +// a. 同步:Invoke(type, inputJson, timeoutMs)——发起线程阻塞等待结果(带超时); +// b. 异步:InvokeAsync(requestId, type, inputJson, callback, userData)——立即返回 +// 受理与否,结果由 JS 侧回传时经 callback(requestId, ok, value, error, userData) +// 异步送达(调用线程通常是 JS 线程);请求可经 CancelAsync(requestId) 取消, +// 取消/超时后晚到的 JS 结果按 seq 查表落空被静默丢弃,绝不重复回调。 +// 超时本身不在此层实现(无独立定时器),由调用方(如 Swift 会话定时器 +// 逐拍扫描 deadline 后调 CancelAsync)按需驱动; +// 3. JS 执行完成后回传结果 JSON 字符串,原生侧拿到 InvokeResult / 异步回调。 // // 线程约束: -// - InvokeProvider 严禁在 JS 主线程调用(阻塞等待会与调度回调互相等待造成死锁), +// - Invoke(同步)严禁在 JS 主线程调用(阻塞等待会与调度回调互相等待造成死锁), // 内部检测到 JS 线程调用时立即返回错误; +// - InvokeAsync(异步)非阻塞,可在包括 JS 主线程在内的任意线程调用; +// callback 可能从 JS 线程触发,实现方只允许做线程安全的转投(如入队), +// 不得在 callback 里直接操作 UI; // - 其余导出函数(start / stop / resolve / reject 等)按常规 N-API 导出在 JS 线程上调用。 #ifndef ZTOOLS_PROVIDER_BRIDGE_H_ @@ -42,11 +51,22 @@ struct InvokeResult { std::string error; // 失败原因(未启动 / 正在关闭 / 队列不可用 / 超时 / JS 侧报错等) }; +/** + * 异步调用结果回调。InvokeAsync 受理后恰好触发一次: + * JS 侧回传(resolve/reject,通常在 JS 线程)或 stopProviderBridge 收尾时。 + * CancelAsync 主动取消不触发回调(取消方已知状态),晚到的 JS 回传被静默丢弃。 + * 回调线程不保证是原生发起线程,实现方必须线程安全(只做入队/转投,禁止碰 UI)。 + */ +using InvokeAsyncCallback = void (*)(uint64_t requestId, bool ok, const char* valueJson, + const char* error, void* userData); + namespace internal { -/** 投递给 JS 线程的一次调用请求(BridgeCallJs 处理完后释放)。 */ +/** 投递给 JS 线程的一次调用请求(BridgeCallJs 处理完后释放)。 + * requestId > 0 表示异步调用(结果按 requestId 回投);0 表示同步调用(走 promise)。 */ struct InvokeJob { uint64_t seq; + uint64_t requestId; std::string type; std::string inputJson; }; @@ -59,6 +79,32 @@ struct NativeInvokePayload { std::string error; }; +/** invokeProviderAsyncFromNative 的回调载荷(NativeAsyncCallJs 处理完后释放)。 */ +struct NativeAsyncPayload { + napi_threadsafe_function tsfn; + bool ok; + std::string value; + std::string error; +}; + +/** invokeProviderAsyncFromNative 的上下文(异步回调与超时看门狗竞争一次性所有权)。 */ +struct NativeAsyncContext { + uint64_t requestId; + uint32_t timeoutMs; + napi_threadsafe_function cbTsfn; + std::atomic delivered; +}; + +/** pending 表条目:同步调用(promise 非空)或异步调用(requestId > 0)。 */ +struct PendingEntry { + std::shared_ptr> promise; // 同步路径:等待方 future + uint64_t requestId = 0; // 异步路径:调用方请求号(0 = 同步) + InvokeAsyncCallback callback = nullptr; + void* userData = nullptr; + napi_threadsafe_function tsfn = nullptr; // 异步路径:受理时 acquire 的 TSFN 引用, + // 条目终结(回传/取消/停止)时配对释放 +}; + // 桥接全局状态:本头文件在各平台只被 binding 主编译单元包含一次, // 使用函数内静态保证唯一切片,避免 ODR 问题 inline std::mutex& Mutex() { @@ -77,10 +123,15 @@ inline std::atomic& NextSeq() { static std::atomic seq(0); return seq; } -inline std::map>>& Pending() { - static std::map>> pending; +inline std::map& Pending() { + static std::map pending; return pending; } +// 异步 requestId → seq 反查(取消与去重用;seq 是对 JS dispatcher 的统一序号) +inline std::map& AsyncSeqIndex() { + static std::map index; + return index; +} // 在 JS 线程上执行:取出请求,调用 JS 分发函数 dispatcher(type, inputJson, seq) inline void BridgeCallJs(napi_env env, napi_value js_callback, void* /*context*/, void* data) { @@ -89,6 +140,10 @@ inline void BridgeCallJs(napi_env env, napi_value js_callback, void* /*context*/ return; } if (env != nullptr && js_callback != nullptr) { + ZLOG_DEBUG("provider", "dispatch seq=%llu req=%llu type=\"%s\" (%zu bytes)", + static_cast(job->seq), + static_cast(job->requestId), job->type.c_str(), + job->inputJson.size()); napi_value global = nullptr; napi_get_global(env, &global); napi_value argv[3]; @@ -100,16 +155,40 @@ inline void BridgeCallJs(napi_env env, napi_value js_callback, void* /*context*/ delete job; } -// 取出指定 seq 的 pending promise;不存在(已超时 / 已停止)时返回空 -inline std::shared_ptr> TakePending(uint64_t seq) { - std::lock_guard lock(Mutex()); - auto it = Pending().find(seq); - if (it == Pending().end()) { - return nullptr; +// 原子摘取指定 seq 的 pending 条目并同步清理异步反查表;不存在(已超时 / 已取消 / +// 已停止)时返回 false——晚到的 JS 回传正是靠此落空被静默丢弃。 +// out 可为 nullptr(只需移除登记、不取条目内容的调用方)。 +inline bool TakePending(uint64_t seq, PendingEntry* out) { + uint64_t asyncRequestId = 0; + { + std::lock_guard lock(Mutex()); + auto it = Pending().find(seq); + if (it == Pending().end()) { + return false; + } + asyncRequestId = it->second.requestId; + if (out != nullptr) { + *out = it->second; + } + Pending().erase(it); + } + if (asyncRequestId != 0) { + std::lock_guard lock(Mutex()); + AsyncSeqIndex().erase(asyncRequestId); + } + return true; +} + +// 终结一个异步条目(调用方已持有摘出的条目,无锁):释放 TSFN 引用并触发回调 +inline void DeliverAsync(PendingEntry& entry, bool ok, const std::string& value, + const std::string& error) { + if (entry.tsfn != nullptr) { + napi_release_threadsafe_function(entry.tsfn, napi_tsfn_release); + entry.tsfn = nullptr; + } + if (entry.callback != nullptr) { + entry.callback(entry.requestId, ok, value.c_str(), error.c_str(), entry.userData); } - auto promise = it->second; - Pending().erase(it); - return promise; } } // namespace internal @@ -158,14 +237,15 @@ inline InvokeResult Invoke(const std::string& type, const std::string& inputJson auto future = promise->get_future(); { std::lock_guard lock(internal::Mutex()); - internal::Pending()[seq] = promise; + internal::PendingEntry& entry = internal::Pending()[seq]; + entry.promise = promise; } - auto* job = new internal::InvokeJob{seq, type, inputJson}; + auto* job = new internal::InvokeJob{seq, 0, type, inputJson}; if (napi_call_threadsafe_function(tsfn, job, napi_tsfn_nonblocking) != napi_ok) { // 桥接正在关闭,请求未入队(CallJs 不会处理该 job),此处代为清理 ZLOG_WARN("provider", "invoke \"%s\" failed: queue unavailable", type.c_str()); - internal::TakePending(seq); + internal::TakePending(seq, nullptr); delete job; napi_release_threadsafe_function(tsfn, napi_tsfn_release); return InvokeResult{false, "", "provider bridge queue unavailable"}; @@ -175,7 +255,7 @@ inline InvokeResult Invoke(const std::string& type, const std::string& inputJson if (future.wait_for(std::chrono::milliseconds(timeoutMs)) == std::future_status::timeout) { // 超时后移除登记;JS 迟到回传的结果会被 TakePending 丢弃 ZLOG_ERROR("provider", "invoke \"%s\" timed out after %ums", type.c_str(), timeoutMs); - internal::TakePending(seq); + internal::TakePending(seq, nullptr); result = InvokeResult{false, "", "provider invocation timed out"}; } else { result = future.get(); @@ -190,6 +270,116 @@ inline InvokeResult Invoke(const std::string& type, const std::string& inputJson return result; } +/** InvokeAsync 的受理结果:accepted=false 时不会触发回调,error 说明拒绝原因。 */ +struct InvokeAccept { + bool accepted = false; + std::string error; +}; + +/** + * 原生侧异步调用 JS 侧 provider 方法:立即返回受理与否,不阻塞等待。 + * 与同步 Invoke 共用同一 seq 序号空间(对 JS dispatcher 的 seq 互不冲突); + * 结果经 callback 异步送达(通常在 JS 线程触发),恰好一次。 + * 非阻塞(只做查表 + TSFN 入队),可在包括 JS 主线程在内的任意线程调用。 + * @param requestId 调用方请求号(须在调用方上下文内唯一,重复将被拒绝) + * @param type 能力类型(如 "translation" / "ocr",由 JS 侧自行约定) + * @param inputJson 入参 JSON 字符串 + * @param callback 结果回调(必填;触发线程不保证是发起线程) + * @param userData 回调透传指针(桥不解释、不持有所有权) + * @returns 受理结果;拒绝时 callback 不触发,调用方当场收尾 + */ +inline InvokeAccept InvokeAsync(uint64_t requestId, const std::string& type, + const std::string& inputJson, + InvokeAsyncCallback callback, void* userData) { + if (callback == nullptr) { + return InvokeAccept{false, "async callback required"}; + } + napi_threadsafe_function tsfn = nullptr; + { + std::lock_guard lock(internal::Mutex()); + tsfn = internal::Tsfn(); + if (tsfn == nullptr) { + ZLOG_WARN("provider", "async invoke req=%llu \"%s\" rejected: bridge not started", + static_cast(requestId), type.c_str()); + return InvokeAccept{false, "provider bridge not started"}; + } + if (internal::AsyncSeqIndex().count(requestId) != 0) { + return InvokeAccept{false, "duplicate requestId"}; + } + // 持有 TSFN 引用直至条目终结(回传/取消/停止时释放),防止等待期间被 + // stopProviderBridge 释放底层函数 + if (napi_acquire_threadsafe_function(tsfn) != napi_ok) { + ZLOG_WARN("provider", "async invoke req=%llu \"%s\" rejected: bridge is closing", + static_cast(requestId), type.c_str()); + return InvokeAccept{false, "provider bridge is closing"}; + } + } + + const uint64_t seq = internal::NextSeq().fetch_add(1) + 1; + { + std::lock_guard lock(internal::Mutex()); + internal::PendingEntry& entry = internal::Pending()[seq]; + entry.requestId = requestId; + entry.callback = callback; + entry.userData = userData; + entry.tsfn = tsfn; + internal::AsyncSeqIndex()[requestId] = seq; + } + ZLOG_DEBUG("provider", "async invoke req=%llu seq=%llu \"%s\" dispatched (%zu bytes)", + static_cast(requestId), static_cast(seq), + type.c_str(), inputJson.size()); + + auto* job = new internal::InvokeJob{seq, requestId, type, inputJson}; + if (napi_call_threadsafe_function(tsfn, job, napi_tsfn_nonblocking) != napi_ok) { + // 桥接正在关闭,请求未入队(CallJs 不会处理该 job),此处代为清理 + ZLOG_WARN("provider", "async invoke req=%llu \"%s\" failed: queue unavailable", + static_cast(requestId), type.c_str()); + internal::PendingEntry leftover; + internal::TakePending(seq, &leftover); + if (leftover.tsfn != nullptr) { + napi_release_threadsafe_function(leftover.tsfn, napi_tsfn_release); + } + delete job; + return InvokeAccept{false, "provider bridge queue unavailable"}; + } + return InvokeAccept{true, ""}; +} + +/** + * 取消一个进行中的异步请求:登记立即移除,此后 JS 晚到的回传按 seq 查表落空、 + * 被静默丢弃,绝不触发 callback(取消方已知状态,无需再通知)。 + * 幂等:请求不存在 / 已完成 / 已取消时返回 false。 + * 注意:这不会终止 JS 侧已经在跑的 Promise / 网络请求(桥无法深入 JS 执行), + * 只保证取消方的生命周期不受晚到结果影响。 + * @param requestId 调用方请求号 + * @returns 是否确由本次调用完成取消 + */ +inline bool CancelAsync(uint64_t requestId) { + internal::PendingEntry entry; + { + std::lock_guard lock(internal::Mutex()); + auto indexIt = internal::AsyncSeqIndex().find(requestId); + if (indexIt == internal::AsyncSeqIndex().end()) { + return false; + } + const uint64_t seq = indexIt->second; + auto it = internal::Pending().find(seq); + if (it == internal::Pending().end()) { + internal::AsyncSeqIndex().erase(indexIt); + return false; + } + entry = it->second; + internal::Pending().erase(it); + internal::AsyncSeqIndex().erase(indexIt); + } + if (entry.tsfn != nullptr) { + napi_release_threadsafe_function(entry.tsfn, napi_tsfn_release); + } + ZLOG_DEBUG("provider", "async invoke req=%llu cancelled (late result will be discarded)", + static_cast(requestId)); + return true; +} + // ==================== N-API 导出(在 JS 线程上被调用) ==================== /** @@ -236,26 +426,33 @@ inline Napi::Value StartProviderBridge(const Napi::CallbackInfo& info) { /** * 停止桥接:所有还在等待的原生调用立即以错误结束,已入队的请求仍会被处理完。 + * 同步等待线程经 promise 唤醒;异步请求经 callback 收到 "provider bridge stopped" + * (回调线程 = 本函数调用线程,即 JS 线程)。 * @returns 无返回值 */ inline Napi::Value StopProviderBridge(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); napi_threadsafe_function tsfn = nullptr; + std::map pending; { std::lock_guard lock(internal::Mutex()); tsfn = internal::Tsfn(); internal::Tsfn() = nullptr; - } - // 让所有还在等待的原生线程立即失败,而不是挂到超时 - std::map>> pending; - { - std::lock_guard lock(internal::Mutex()); pending.swap(internal::Pending()); + internal::AsyncSeqIndex().clear(); } + size_t asyncCount = 0; for (auto& entry : pending) { - entry.second->set_value(InvokeResult{false, "", "provider bridge stopped"}); + if (entry.second.promise != nullptr) { + // 让同步等待线程立即失败,而不是挂到超时 + entry.second.promise->set_value(InvokeResult{false, "", "provider bridge stopped"}); + } else { + asyncCount++; + internal::DeliverAsync(entry.second, false, "", "provider bridge stopped"); + } } - ZLOG_INFO("provider", "bridge stopped (%zu pending call(s) failed)", pending.size()); + ZLOG_INFO("provider", "bridge stopped (%zu sync / %zu async pending call(s) failed)", + pending.size() - asyncCount, asyncCount); if (tsfn != nullptr) { napi_release_threadsafe_function(tsfn, napi_tsfn_release); } @@ -266,7 +463,7 @@ inline Napi::Value StopProviderBridge(const Napi::CallbackInfo& info) { * JS 侧回传成功结果。 * @param info[0] seq 请求序号(与分发出的 seq 一致) * @param info[1] resultJson 结果 JSON 字符串 - * @returns 无返回值;seq 已超时或不存在时静默丢弃 + * @returns 无返回值;seq 已超时 / 已取消或不存在时静默丢弃 */ inline Napi::Value ResolveProviderBridge(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); @@ -277,8 +474,18 @@ inline Napi::Value ResolveProviderBridge(const Napi::CallbackInfo& info) { } const uint64_t seq = static_cast(info[0].As().DoubleValue()); const std::string value = info[1].As().Utf8Value(); - if (auto promise = internal::TakePending(seq)) { - promise->set_value(InvokeResult{true, value, ""}); + internal::PendingEntry entry; + if (!internal::TakePending(seq, &entry)) { + // 已超时 / 已取消 / 已停止:晚到结果按约定静默丢弃 + ZLOG_DEBUG("provider", "late resolve seq=%llu discarded", static_cast(seq)); + return env.Undefined(); + } + if (entry.promise != nullptr) { + entry.promise->set_value(InvokeResult{true, value, ""}); + } else { + ZLOG_DEBUG("provider", "async invoke req=%llu ok (result %zu bytes)", + static_cast(entry.requestId), value.size()); + internal::DeliverAsync(entry, true, value, ""); } return env.Undefined(); } @@ -287,7 +494,7 @@ inline Napi::Value ResolveProviderBridge(const Napi::CallbackInfo& info) { * JS 侧回传失败。 * @param info[0] seq 请求序号(与分发出的 seq 一致) * @param info[1] error 错误说明 - * @returns 无返回值;seq 已超时或不存在时静默丢弃 + * @returns 无返回值;seq 已超时 / 已取消或不存在时静默丢弃 */ inline Napi::Value RejectProviderBridge(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); @@ -298,8 +505,17 @@ inline Napi::Value RejectProviderBridge(const Napi::CallbackInfo& info) { } const uint64_t seq = static_cast(info[0].As().DoubleValue()); const std::string error = info[1].As().Utf8Value(); - if (auto promise = internal::TakePending(seq)) { - promise->set_value(InvokeResult{false, "", error}); + internal::PendingEntry entry; + if (!internal::TakePending(seq, &entry)) { + ZLOG_DEBUG("provider", "late reject seq=%llu discarded", static_cast(seq)); + return env.Undefined(); + } + if (entry.promise != nullptr) { + entry.promise->set_value(InvokeResult{false, "", error}); + } else { + ZLOG_DEBUG("provider", "async invoke req=%llu failed: %s", + static_cast(entry.requestId), error.c_str()); + internal::DeliverAsync(entry, false, "", error); } return env.Undefined(); } @@ -390,6 +606,146 @@ inline Napi::Value InvokeProviderFromNative(const Napi::CallbackInfo& info) { return env.Undefined(); } +// ==================== 供 JS 侧验证异步桥接通路的回调式入口 ==================== + +// 把异步调用结果带回 JS 线程并调用回调(与 NativeInvokeCallJs 同构) +inline void NativeAsyncCallJs(napi_env env, napi_value js_callback, void* /*context*/, + void* data) { + auto* payload = static_cast(data); + if (payload == nullptr) { + return; + } + if (env != nullptr && js_callback != nullptr) { + napi_value global = nullptr; + napi_get_global(env, &global); + napi_value argv[2] = {nullptr, nullptr}; + if (payload->ok) { + napi_get_null(env, &argv[0]); + napi_create_string_utf8(env, payload->value.c_str(), NAPI_AUTO_LENGTH, &argv[1]); + } else { + napi_create_string_utf8(env, payload->error.c_str(), NAPI_AUTO_LENGTH, &argv[0]); + napi_get_null(env, &argv[1]); + } + napi_call_function(env, global, js_callback, 2, argv, nullptr); + } + // 与创建时的 initial_thread_count=1 配对,由消费方释放;随后释放载荷 + napi_release_threadsafe_function(payload->tsfn, napi_tsfn_release); + delete payload; +} + +// 异步完成回投(桥触发,通常在 JS 线程):一次性所有权竞争(delivered 原子置位), +// 赢者把结果经 ctx->cbTsfn 送回 JS 并释放 ctx +inline void NativeAsyncDeliver(internal::NativeAsyncContext* ctx, bool ok, + const char* value, const char* error) { + bool expected = false; + if (!ctx->delivered.compare_exchange_strong(expected, true)) { + return; // 另一方(超时看门狗 / 桥回调)已投递 + } + auto* payload = + new internal::NativeAsyncPayload{ctx->cbTsfn, ok, value ? value : "", error ? error : ""}; + if (napi_call_threadsafe_function(ctx->cbTsfn, payload, napi_tsfn_nonblocking) != napi_ok) { + napi_release_threadsafe_function(ctx->cbTsfn, napi_tsfn_release); + delete payload; + } + delete ctx; +} + +// 桥异步回调适配(InvokeAsync 的 callback,userData = NativeAsyncContext*) +inline void NativeAsyncBridgeCallback(uint64_t requestId, bool ok, const char* valueJson, + const char* error, void* userData) { + (void)requestId; + auto* ctx = static_cast(userData); + if (ctx == nullptr) { + return; + } + NativeAsyncDeliver(ctx, ok, valueJson, error); +} + +/** + * 在当前线程(含 JS 线程)发起一次异步 provider 调用,结果经 callback 回到 JS。 + * 与 macOS 截图翻译的生产通路完全一致(InvokeAsync + 看门狗超时 + CancelAsync + * 丢弃晚到结果),用于从 JS 验证异步桥接;真正的原生业务代码应直接调用 + * InvokeAsync。 + * @param info[0] type 能力类型 + * @param info[1] inputJson 入参 JSON 字符串 + * @param info[2] requestId 请求号(调用方保证唯一,用于 cancelProviderAsyncFromNative 取消) + * @param info[3] timeoutMs 超时毫秒数(到时取消请求并以 timeout 错误回调; + * JS 侧晚到的 resolve/reject 被桥丢弃) + * @param info[4] callback: (error: string | null, resultJson: string | null) => void + * @returns boolean:true 已受理(结果将经 callback 异步送达);false 立即拒绝 + * (callback 不会被调用) + */ +inline Napi::Value InvokeProviderAsyncFromNative(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 5 || !info[0].IsString() || !info[1].IsString() || + !info[2].IsNumber() || !info[3].IsNumber() || !info[4].IsFunction()) { + Napi::TypeError::New(env, + "Expected (type: string, inputJson: string, requestId: number, " + "timeoutMs: number, callback: function)") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + const std::string type = info[0].As().Utf8Value(); + const std::string inputJson = info[1].As().Utf8Value(); + const uint64_t requestId = static_cast(info[2].As().Int64Value()); + const uint32_t timeoutMs = info[3].As().Uint32Value(); + if (requestId == 0) { + Napi::TypeError::New(env, "requestId must be a positive number") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + + napi_value callback = info[4]; + napi_value resource_name; + napi_create_string_utf8(env, "ProviderBridgeNativeInvokeAsync", NAPI_AUTO_LENGTH, + &resource_name); + napi_threadsafe_function cbTsfn = nullptr; + if (napi_create_threadsafe_function(env, callback, nullptr, resource_name, 0, 1, nullptr, + nullptr, nullptr, NativeAsyncCallJs, + &cbTsfn) != napi_ok) { + Napi::Error::New(env, "Failed to create native async callback") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + auto* ctx = new internal::NativeAsyncContext{requestId, timeoutMs, cbTsfn, false}; + + InvokeAccept accept = InvokeAsync(requestId, type, inputJson, NativeAsyncBridgeCallback, ctx); + if (!accept.accepted) { + // 拒绝路径:桥不会回调,此处代为释放(TSFN 引用由本函数自建自释) + napi_release_threadsafe_function(cbTsfn, napi_tsfn_release); + delete ctx; + Napi::Error::New(env, accept.error).ThrowAsJavaScriptException(); + return Napi::Boolean::New(env, false); + } + + // 超时看门狗(镜像 Swift 泵循环的 deadline 扫描):到时取消请求,晚到的 JS + // 回传因登记已移除被桥静默丢弃 + std::thread([ctx]() { + std::this_thread::sleep_for(std::chrono::milliseconds(ctx->timeoutMs)); + if (!CancelAsync(ctx->requestId)) { + return; // 已完成(回调已投递或正在投递),ctx 归属对方 + } + NativeAsyncDeliver(ctx, false, "", "provider invocation timed out"); + }).detach(); + + return Napi::Boolean::New(env, true); +} + +/** + * 取消一个经 invokeProviderAsyncFromNative 发起的异步请求(幂等)。 + * @param info[0] requestId 请求号 + * @returns boolean:是否确由本次调用完成取消(已回调 / 已取消时为 false) + */ +inline Napi::Value CancelProviderAsyncFromNative(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 1 || !info[0].IsNumber()) { + Napi::TypeError::New(env, "Expected (requestId: number)").ThrowAsJavaScriptException(); + return env.Undefined(); + } + const uint64_t requestId = static_cast(info[0].As().Int64Value()); + return Napi::Boolean::New(env, CancelAsync(requestId)); +} + } // namespace ztools_provider_bridge #endif // ZTOOLS_PROVIDER_BRIDGE_H_ diff --git a/src/screenshot/macos/ScreenshotLCPanelMac.swift b/src/screenshot/macos/ScreenshotLCPanelMac.swift index b495297..4e30f66 100644 --- a/src/screenshot/macos/ScreenshotLCPanelMac.swift +++ b/src/screenshot/macos/ScreenshotLCPanelMac.swift @@ -19,7 +19,7 @@ import CoreGraphics // // 刷新时机(LongCapturePanelUpdate / InvalidateRect 对齐):拼接成功(contentChanged = // 尺寸重算 + 重绘)、tentative 跟踪变化(trackingChanged = 仅重绘)、裁剪应用 -//(contentChanged)。全部由泵循环在主线程驱动(CATransaction 隐式提交)。 +//(contentChanged)。全部由会话定时器在主线程驱动(CATransaction 隐式提交)。 // MARK: - 面板视图 diff --git a/src/screenshot/macos/ScreenshotLCToolbarMac.swift b/src/screenshot/macos/ScreenshotLCToolbarMac.swift index bee422c..4b71b51 100644 --- a/src/screenshot/macos/ScreenshotLCToolbarMac.swift +++ b/src/screenshot/macos/ScreenshotLCToolbarMac.swift @@ -22,7 +22,7 @@ import CoreGraphics // - title 式 tooltip:悬停 500ms(LC_TIP_DELAY_MS)深色圆角气泡,锚定目标上方/下方 // - 按钮按下-抬起同目标校验(防「进入长截图瞬间残留的鼠标抬起」误触自动滚动等按钮) // -// 事件模型:NOACTIVATE 浮层无焦点,hover/菜单/tooltip 全部由泵循环 100ms UI 节拍轮询 +// 事件模型:NOACTIVATE 浮层无焦点,hover/菜单/tooltip 全部由会话 tick 100ms UI 节拍轮询 // (NSEvent.mouseLocation / pressedMouseButtons,对齐 Windows LongCaptureToolbarUiTick 的 // GetAsyncKeyState + GetCursorPos 轮询);点击由浮层视图 mouseDown/Up 承接。 @@ -208,7 +208,7 @@ final class ScreenshotLCTipView: NSView { /// 长截图工具栏控制器:底条/popover/tooltip 三浮层 + 全部 UI 状态,按钮动作回话给 /// ScreenshotLongCaptureSession(finish/save/abort 标志 + 自动滚动/方向/裁剪)。 -/// 100ms UI 节拍由长截图会话泵循环驱动(uiTick)。 +/// 100ms UI 节拍由长截图会话 tick 驱动(uiTick)。 final class ScreenshotLCToolbarController { private weak var session: ScreenshotLongCaptureSession? @@ -487,7 +487,7 @@ final class ScreenshotLCToolbarController { // MARK: UI 维护节拍(100ms;LongCaptureToolbarUiTick 逐段移植) - /// UI 维护节拍(由长截图会话泵循环每 100ms 调用): + /// UI 维护节拍(由长截图会话 tick 每 100ms 调用): /// 1) popover 展开时检测「窗口外左键按下」并关闭(底条∪popover 区域外的透底不算); /// 2) 悬停意图:方向/裁剪锚点按钮停留 300ms 后展开(另一菜单已展开时直接切换;扫过 /// 不误触);离开「锚点∪popover」超过 250ms 后收起(宽限期足够跨过透底间隙); diff --git a/src/screenshot/macos/ScreenshotLongCaptureMac.swift b/src/screenshot/macos/ScreenshotLongCaptureMac.swift index 7235ce0..d8bc91c 100644 --- a/src/screenshot/macos/ScreenshotLongCaptureMac.swift +++ b/src/screenshot/macos/ScreenshotLongCaptureMac.swift @@ -19,13 +19,13 @@ import ApplicationServices // - 终止全集:完成 / 取消 / ESC / abortLongCapture(对齐 lc_session_windows.cpp 主循环各 break 分支; // 拼接无帧数/像素上限,可持续合并至用户主动结束) // -// 线程模型:长截图会话由覆盖层会话的手动泵(pumpTick → lcTick)在主线程驱动,算法层调用 +// 线程模型:长截图会话由覆盖层会话的定时器 tick(sessionTick → lcTick)在主线程驱动,算法层调用 // 全部串行(LCAlgorithmSession 线程约定);CGEventTap 回调线程只写滚轮缓冲(锁内), -// 泵循环逐拍消费(沿用覆盖层会话的事件模式)。 +// 会话定时器逐拍消费(沿用覆盖层会话的事件模式)。 // // 与 Windows 的既知语义差异(均为有意映射,其余逐条对齐): // - Windows 在独立捕获线程用阻塞式 LongCaptureWaitMessages 等待重试间隔;macOS 无第二 -// 消息循环,重试梯以「下次尝试时刻」在泵循环 tick 中推进,UI 同期保持响应。 +// 消息循环,重试梯以「下次尝试时刻」在会话定时器 tick 中推进,UI 同期保持响应。 // - autoScroll 注入语义差异见 tickAutoScroll 注释。 // MARK: - 常量(Windows 出处集中标注) @@ -94,9 +94,9 @@ func lcNSRect(fromCG rect: CGRect) -> NSRect { return NSRect(x: rect.minX, y: top - rect.height, width: rect.width, height: rect.height) } -// MARK: - 滚轮观察缓冲(tap 回调线程只写、泵循环消费) +// MARK: - 滚轮观察缓冲(tap 回调线程只写、会话定时器消费) -/// 滚轮事件原始增量条目(tap 回调线程合并写入;方向解析延迟到泵消费时按当前模式进行, +/// 滚轮事件原始增量条目(tap 回调线程合并写入;方向解析延迟到消费时按当前模式进行, /// 避免 tap 线程读会话状态)。line* 为整数行增量(滚轮一格 = ±1 行),point* 为像素级 /// 增量(触控板亚行滚动),shift = 事件携带的 Shift 修饰键。 struct ScreenshotLCWheelSample { @@ -132,7 +132,7 @@ final class ScreenshotLCWheelBuffer { } } - /// 取出并清空缓冲(泵循环消费;无未消费事件返回 nil)。 + /// 取出并清空缓冲(会话定时器消费;无未消费事件返回 nil)。 func take() -> ScreenshotLCWheelSample? { lock.lock() defer { lock.unlock() } @@ -167,7 +167,7 @@ private func lcWheelTapCallback( /// 长截图滚轮观察 tap(对齐 lc_frame_io_windows.cpp LongCaptureRegisterWheelObserver 的 Raw Input /// RIDEV_INPUTSINK:非前台也接收广播、不拦截输入)。macOS 用 `.listenOnly` CGEventTap 监听 /// scrollWheel——事件原样放行(照常送达选区下的目标窗口),这里只被动解析方向与时机。 -/// tap 回调线程只把原始增量写入共享缓冲(ScreenshotLCWheelBuffer),泵循环逐拍消费。 +/// tap 回调线程只把原始增量写入共享缓冲(ScreenshotLCWheelBuffer),会话定时器逐拍消费。 final class ScreenshotLCWheelTap { private let buffer: ScreenshotLCWheelBuffer private var tap: CFMachPort? @@ -331,7 +331,7 @@ final class ScreenshotLCMaskView: NSView { /// 算法层会话与全部会话侧簿记字段(lc_session_windows.cpp 中归属会话层的 wheelPending/lastDir/ /// noChangeCount/reachedBottom/weakTries/frameCount/autoFailStreak 等;拼接累计状态全部 /// 在算法层 LCAlgorithmSession 内,会话层绝不直接触碰)。生命周期:beginLongCapture 创建 -/// → start()(蒙版/首帧/浮层/tap)→ lcTick() 由覆盖层泵循环逐拍驱动 → 终止条件收束 +/// → start()(蒙版/首帧/浮层/tap)→ lcTick() 由覆盖层会话定时器逐拍驱动 → 终止条件收束 /// (完成/保存成功 → success 回调;取消/ESC/abort/失败 → 整会话 {success:false} 收束, /// 对齐 wndproc_windows.cpp WM_LONGCAPTURE_RUN 结束后 ctx->state = CS_Done + DestroyWindow)。 final class ScreenshotLongCaptureSession { @@ -397,7 +397,7 @@ final class ScreenshotLongCaptureSession { private var lastFailReason: LCFailReason = .none private var lastResult: LCTryStitchResult? - // ---- 工具栏动作标志(工具栏控制器在主线程泵内置位;lc->finishFlag/saveFlag/abortFlag)---- + // ---- 工具栏动作标志(工具栏控制器在主线程会话 tick 内置位;lc->finishFlag/saveFlag/abortFlag)---- var finishRequested = false var saveRequested = false @@ -469,7 +469,7 @@ final class ScreenshotLongCaptureSession { /// 启动长截图会话:隐藏覆盖层 → 创建灰蒙版 → 算法层会话 + 首帧基准 → 小地图/工具栏 /// → 滚轮观察 tap。任一关键步骤失败时 FailFast 收束整个截图会话(对齐 Windows /// LongCaptureInitFirstFrame 失败 → LongCaptureEmitFailure → 会话清理,矩阵 #48)。 - /// - Returns: true 会话就绪(state 置 .longCapturing 后由泵循环驱动);false 已收束 + /// - Returns: true 会话就绪(state 置 .longCapturing 后由会话定时器驱动);false 已收束 func start() -> Bool { guard let ov = overlay else { return false } @@ -574,9 +574,9 @@ final class ScreenshotLongCaptureSession { return true } - // MARK: 泵循环驱动(Windows RunLongCapture 主循环的 tick 化等价物) + // MARK: 会话 tick 驱动(Windows RunLongCapture 主循环的 tick 化等价物) - /// 泵循环逐拍入口(覆盖层 pumpTick 在 .longCapturing 态调用):检查点消费 + 采样轮推进 + /// 会话逐拍入口(覆盖层 sessionTick 在 .longCapturing 态调用):检查点消费 + 采样轮推进 /// + autoScroll 节拍 + UI 维护节拍。单次调用内不做任何阻塞等待(重试梯以「下次尝试 /// 时刻」表达,等价 Windows LongCaptureWaitMessages 的有界等待)。 func lcTick() { @@ -601,7 +601,7 @@ final class ScreenshotLongCaptureSession { if ended { return } } - // 消费滚轮观察缓冲(tap 线程只写缓冲,方向解析在泵线程按当前模式进行) + // 消费滚轮观察缓冲(tap 线程只写缓冲,方向解析在主线程按当前模式进行) drainWheel(now: now) // 采样轮(三条件触发 + 重试梯推进 + 结局簿记) @@ -626,7 +626,7 @@ final class ScreenshotLongCaptureSession { // MARK: 滚轮消费(lc_panel_ui_windows.cpp WM_INPUT 分支逐条照搬) /// 解析并消费滚轮观察缓冲:纵向模式消费纵滚轮(忽略横滚轮);横向模式消费横滚轮与 - /// Shift+纵滚轮。方向解析在泵线程按当前模式进行(tap 线程只缓冲原始增量)。 + /// Shift+纵滚轮。方向解析在主线程按当前模式进行(tap 线程只缓冲原始增量)。 /// 行增量按 1 行 = 1 notch 归一到 Windows WHEEL_DELTA=120 计入软先验(只参与候选 /// 排序加分,绝不约束搜索范围);亚行像素级滚动(触控板慢滚,行增量 = 0)只驱动 /// 采样时机与方向,不计入 notch 先验,保持 px/notch 估计的量纲诚实。 @@ -1258,7 +1258,7 @@ extension ScreenshotOverlaySession { longCaptureFlag.set() // event tap 的 ESC 从此刻起路由到 longCancelFlag } - /// 取消长截图(泵循环消费 ESC/兜底取消标志):整会话按失败收束(对齐 Windows + /// 取消长截图(会话定时器消费 ESC/兜底取消标志):整会话按失败收束(对齐 Windows /// lc_session_windows.cpp 取消路径 abortFlag → LongCaptureEmitFailure → 会话 CS_Done 结束; /// 不回编辑态)。 func cancelLongCaptureSession() { diff --git a/src/screenshot/macos/ScreenshotMac.swift b/src/screenshot/macos/ScreenshotMac.swift index 1233282..3a946a1 100644 --- a/src/screenshot/macos/ScreenshotMac.swift +++ b/src/screenshot/macos/ScreenshotMac.swift @@ -9,11 +9,18 @@ import CoreGraphics // - 屏幕录制权限预检/请求 // - prime() 预抓帧(2 秒 TTL、互斥锁保护、锁内所有权转移,对齐 Windows capture_windows.cpp) // - start() 闭环:权限 →(预抓帧或现场重抓)整屏底图 → 多屏覆盖层选区会话 -// (ScreenshotOverlayMac.swift:手动泵主循环 + 选区状态机)→ 确认时按选区裁剪底图 -// → PNG 编码 → NSPasteboard 写入 → 经 C++ screenshotTsfn 回调契约结果 +// (ScreenshotOverlayMac.swift:非阻塞生命周期对象,AppKit 事件与 16ms 定时器 +// 由宿主主事件循环驱动)→ 确认时按选区裁剪底图 → PNG 编码 → NSPasteboard 写入 +// → 经 C++ screenshotTsfn 回调契约结果 // - abortLongCapture() 中止标记:锁内置标志 set/consume/reset(长截图采样循环 // 在检查点消费并按失败结果收束会话,对齐 Windows LongCaptureAbort 语义) // +// 线程模型:start() 为非阻塞启动——前置检查(权限/底图抓取)在 N-API 调用栈内 +// 同步完成(有界短耗时),会话交互期不占用 JS 主线程:AppKit 事件(鼠标/键盘/ +// 绘制)与周期任务(定时器)全部由宿主进程自己的主事件循环驱动(Electron 主进程 +// 的 Chromium 消息泵天然运转 NSApp 与 libuv;纯 Node 宿主不驱动 AppKit 主循环, +// 无法使用 macOS 截图会话,见 README「平台差异」)。 +// // 坐标系约定:会话内统一 CG 全局坐标(左上原点、逻辑点), // 回调的 x/y/x2/y2/width/height 均为逻辑尺寸;base64 图像为物理像素(Retina 2x, // PNG 携带 DPI 元数据供看图应用按逻辑尺寸显示,见 ScreenshotOutputMac.swift)。 @@ -280,16 +287,17 @@ struct ScreenshotSessionOptions { // MARK: - 会话入口(覆盖层与选区) -/// 启动覆盖层选区会话的统一入口:权限预检 → NSApplication 初始化 → 底图获取(预抓帧优先) -/// → 窗口吸附枚举 → 多屏覆盖层 + 手动泵主循环(ScreenshotOverlayMac.swift)。 -/// 任一前置失败都恰好回调一次 {success:false, error:...}(FailFast 语义); -/// 会话正常结束(确认/取消)由覆盖层会话负责回调并复位重入标志。 +/// 启动覆盖层选区会话的非阻塞入口:权限预检 → NSApplication 初始化 → 底图获取(预抓帧优先) +/// → 窗口吸附枚举 → 创建多屏覆盖层会话(ScreenshotOverlayMac.swift:start 后自持有, +/// 由宿主主事件循环驱动)→ 立即返回。任一前置失败都恰好回调一次 {success:false, error:...} +/// (FailFast 语义)并复位重入标志;会话正常结束(确认/取消)由覆盖层会话负责回调并复位。 /// - Parameters: /// - options: 已解析的会话选项(autoConfirm / longCapture 参数均生效) /// - callback: C++ 层注册的结果回调(JSON 字符串参数) -func runOverlayCaptureSession(options: ScreenshotSessionOptions, callback: ScreenshotResultCallback) { - // 单一出口:结果 JSON 回调一次并复位会话标志(重入保护随之解除) - func finish(_ payload: String) { +func startOverlayCaptureSession(options: ScreenshotSessionOptions, callback: ScreenshotResultCallback) { + // 单一出口(仅覆盖前置失败路径):结果 JSON 回调一次并复位会话标志; + // 会话创建成功后由 ScreenshotOverlaySession.finish 承担同一职责。 + func finishEarly(_ payload: String) { payload.withCString { cStr in callback(cStr) } @@ -302,10 +310,10 @@ func runOverlayCaptureSession(options: ScreenshotSessionOptions, callback: Scree return "{\"success\":false,\"error\":\"\(error)\"}" } - // 0) AppKit 主线程硬要求:NSWindow 创建与手动泵事件循环只能在主线程执行 + // 0) AppKit 主线程硬要求:NSWindow 创建与事件处理只能在主线程执行 // (线程模型:start() 的 N-API 调用线程即 AppKit 主线程)。 guard Thread.isMainThread else { - finish(failurePayload("screenshot session must run on the main thread")) + finishEarly(failurePayload("screenshot session must run on the main thread")) return } @@ -315,30 +323,30 @@ func runOverlayCaptureSession(options: ScreenshotSessionOptions, callback: Scree if !CGPreflightScreenCaptureAccess() { _ = CGRequestScreenCaptureAccess() if !CGPreflightScreenCaptureAccess() { - finish(failurePayload("screen recording permission required")) + finishEarly(failurePayload("screen recording permission required")) return } } - // 2) NSApplication 初始化(取色器模式:accessory policy;Node 主线程不跑 NSRunLoop, - // 覆盖层窗口由会话内的手动泵循环驱动) + // 2) NSApplication 基础初始化(激活策略切换由会话 start 负责、finish 恢复; + // 纯 Node 进程默认 prohibited,不切换窗口无法正常服务) let app = NSApplication.shared - app.setActivationPolicy(.accessory) app.finishLaunching() // 3) 底图获取:优先消费 prime() 预抓帧(未过期),过期/未命中时现场重抓 // (对齐 Windows AcquireScreenshotBase:预抓帧命中即用,否则 CaptureVirtualScreen 兜底) guard let baseFrame = primedFrameStore.consume() ?? screenshotBackend.captureVirtualScreenBase() else { - finish(failurePayload("failed to capture screen")) + finishEarly(failurePayload("failed to capture screen")) return } guard let virtualBounds = ScreenshotGeometry.virtualScreenBounds() else { - finish(failurePayload("failed to query screen layout")) + finishEarly(failurePayload("failed to query screen layout")) return } - // 4) 覆盖层选区会话(会话内手动泵直至确认/取消;结束前回调恰好一次) + // 4) 覆盖层选区会话:start 后立即返回——会话由宿主主事件循环驱动(AppKit 事件 + // 回调 + 16ms 定时器 tick),结束前回调恰好一次并自清理(含激活策略恢复) let session = ScreenshotOverlaySession( options: options, callback: callback, @@ -349,7 +357,6 @@ func runOverlayCaptureSession(options: ScreenshotSessionOptions, callback: Scree // 会话初始化失败:start 内部已 FailFast 回调并复位标志 return } - session.runEventPump() } // MARK: - C 导出(binding_mac.cpp 经 dlsym 调用) @@ -367,8 +374,9 @@ public func primeScreenshotFrame() -> Int32 { } /// 启动区域截图会话(覆盖层与选区;对齐 Windows startRegionCaptureWithPrimedFrame 导出)。 -/// 与 Windows 会话线程模型的差异:macOS 的 N-API 调用线程即 AppKit 主线程,覆盖层 -/// 会话在本调用内以手动泵循环运行直至确认/取消,故本函数阻塞至会话结束。 +/// 非阻塞:前置检查(权限/底图)同步完成后创建会话并立即返回,会话交互期由宿主 +/// 进程的主事件循环驱动(AppKit 事件回调 + 16ms 定时器),结果经回调异步送达—— +/// N-API 调用栈不再长期占用 JS 主线程,Node/libuv/V8 始终由宿主自身事件循环驱动。 /// - Parameters: /// - optionsJson: C++ 层解析并钳制后的 options JSON(autoConfirm / longCapture 参数) /// - callback: 结果回调;会话出口恰好回调一次(成功/失败均必达——FailFast 语义) @@ -390,12 +398,12 @@ public func startRegionCaptureWithPrimedFrame( screenshotStateLock.unlock() let options = ScreenshotSessionOptions.parse(from: optionsJson.map { String(cString: $0) }) - runOverlayCaptureSession(options: options, callback: callback) + startOverlayCaptureSession(options: options, callback: callback) return 1 } /// 请求中止进行中的长截图滚动捕获(对齐 Windows LongCaptureAbort:锁内置 abortFlag, -/// 与清理互斥防 use-after-free;长截图采样循环在下一检查点(泵循环 tick)消费本标志, +/// 与清理互斥防 use-after-free;长截图采样循环在下一检查点(会话定时器 tick)消费本标志, /// 销毁长截图浮层并按失败结果收束整个会话回调 JS {success:false})。 /// 无长截图会话时置位后即被下次会话创建的 reset 清除,等价 Windows 的空指针分支。 @_cdecl("abortLongCapture") diff --git a/src/screenshot/macos/ScreenshotOutputMac.swift b/src/screenshot/macos/ScreenshotOutputMac.swift index fc7826e..9dd550e 100644 --- a/src/screenshot/macos/ScreenshotOutputMac.swift +++ b/src/screenshot/macos/ScreenshotOutputMac.swift @@ -58,7 +58,7 @@ func scMakeDefaultScreenshotName() -> String { /// 置灰不可选、用户未输扩展名时自动补 .png) /// - 覆盖提示:NSSavePanel 对已存在文件自带「替换确认」弹窗,与 Windows OFN_OVERWRITEPROMPT /// 同为系统对话框自带覆盖确认(非自绘),语义一致 -/// 须在主线程调用(runModal 为模态事件循环,运行在会话泵所在的主线程;模态期间会话泵 +/// 须在主线程调用(runModal 为模态事件循环,运行在会话所在的主线程;模态期间会话 /// 暂停属预期,对齐 Windows GetSaveFileNameW 模态循环)。调用方须先临时降覆盖层/工具栏 /// 浮层族层级(Windows 弹出前摘除 TOPMOST 的等价处理,见 ScreenshotOverlaySession. /// saveSelectionToFile 与 duckOverlayLevelsForSaveModal)。 @@ -213,19 +213,24 @@ extension ScreenshotOverlaySession { ctx.interpolationQuality = .high ctx.draw(cropped, in: CGRect(x: 0, y: 0, width: CGFloat(width), height: CGFloat(height))) - // 标注合成(原 compositeAnnotationsToPng 并入统一管线;无标注时跳过,保持 - // 直角空标注路径行为不变):raw CGContext 原点在左下,翻转为左上原点后复用 - // 覆盖层同款绘制函数(final 位图原点 = 选区左上角,ox/oy 对齐 CompositeAnnotations)。 - // 马赛克先从裁剪底图现场重算,矢量/文字标注清晰覆盖其上(对齐 CompositeAnnotations - // 的马赛克先行揭示 + 标注覆盖次序)。 - if !annotations.isEmpty { + // 标注 + 译文覆盖合成(原 compositeAnnotationsToPng 并入统一管线;两者均无时 + // 跳过,保持直角空标注路径行为不变):raw CGContext 原点在左下,翻转为左上 + // 原点后复用覆盖层同款绘制函数(final 位图原点 = 选区左上角,ox/oy 对齐 + // CompositeAnnotations / CompositeTranslateBlocks)。 + // 马赛克先从裁剪底图现场重算,矢量/文字标注清晰覆盖其上;译文面板最后合成 + //(对齐 Windows 导出管线 DrawAnnotations → CompositeTranslateBlocks 次序)。 + let hasTranslateOutput = translateState == .shown && !translateBlocks.isEmpty + if !annotations.isEmpty || hasTranslateOutput { ctx.saveGState() ctx.translateBy(x: 0, y: CGFloat(height)) ctx.scaleBy(x: 1, y: -1) - compositeMosaicLayer(ctx, cropped: cropped, region: sel) - for a in annotations { - scDrawAnnotation(ctx, a, ox: -sel.minX, oy: -sel.minY) + if !annotations.isEmpty { + compositeMosaicLayer(ctx, cropped: cropped, region: sel) + for a in annotations { + scDrawAnnotation(ctx, a, ox: -sel.minX, oy: -sel.minY) + } } + compositeTranslateBlocks(ctx, sel: sel) ctx.restoreGState() } @@ -249,7 +254,7 @@ extension ScreenshotOverlaySession { /// 1) 弹出前临时下调覆盖层/工具栏浮层族窗口层级 + event tap 放行模态期间按键 /// (对齐 Windows 弹出前摘除 TOPMOST:PromptSaveFilePath 注释「覆盖层是 WS_EX_TOPMOST /// 全屏窗口,通用对话框可能被遮挡」,L608-609/L632-636) - /// 2) NSSavePanel 模态(runModal 在主线程=会话泵线程执行;模态期间会话泵暂停属预期, + /// 2) NSSavePanel 模态(runModal 在主线程=会话主线程执行;模态期间会话定时器暂停属预期, /// 对齐 GetSaveFileNameW 模态循环;面板自成模态,键盘/鼠标不会误触覆盖层) /// 3) 取消对话框:层级恢复(defer)、回到编辑态继续会话、无回调(对齐 Windows /// L358「用户取消保存对话框:不关闭,留在编辑态」) diff --git a/src/screenshot/macos/ScreenshotOverlayMac.swift b/src/screenshot/macos/ScreenshotOverlayMac.swift index feb3c48..b7fa1ee 100644 --- a/src/screenshot/macos/ScreenshotOverlayMac.swift +++ b/src/screenshot/macos/ScreenshotOverlayMac.swift @@ -6,7 +6,11 @@ import ApplicationServices // // 本文件承载会话主体(绘制扩展见 ScreenshotPaintMac.swift): // - 多屏覆盖层:每个 NSScreen 一个无边框透明 NSWindow,共享同一会话状态单例 -// - 手动泵主循环:NSApp.nextEvent/sendEvent 驱动 AppKit 事件直至会话收束(取色器模式) +// - 非阻塞会话:start() 创建窗口/事件源/逐拍定时器后立即返回,AppKit 事件 +// (鼠标/键盘/绘制)由宿主进程自己的主事件循环分发(Electron 主进程的 +// Chromium 消息泵天然驱动 NSApp),会话只在事件回调里做短促状态推进; +// 周期性任务(插入符闪烁/翻译邮箱 drain/长截图采样/工具栏 tooltip)由 +// 主 RunLoop 上的 16ms 定时器(sessionTick)驱动 // - 选区状态机:Idle → Selecting → Confirmed → (Resizing | Moving) → Done/Cancelled // (对齐 Windows internal.h CaptureState) // - 鼠标/键盘交互由覆盖层 NSView 处理;ESC 与右键取消另有 CGEventTap 兜底(失焦仍可取消) @@ -72,7 +76,7 @@ struct ScreenshotRGB: Equatable { let b: UInt8 } -/// 线程安全的取消标志:CGEventTap 回调在 tap 自有后台线程置位,泵循环(主线程)逐拍消费。 +/// 线程安全的取消标志:CGEventTap 回调在 tap 自有后台线程置位,会话逐拍定时器(主线程)消费。 final class ScreenshotAtomicFlag { private let lock = NSLock() private var value = false @@ -272,7 +276,7 @@ private func screenshotOverlayEventTapCallback( } /// 覆盖层 CGEventTap 兜底(对齐 Windows 失焦后 GetAsyncKeyState 轮询兜底,矩阵 #49): -/// 会话期间拦截 ESC keyDown 与右键按下并置取消标志(泵循环 ≤16ms 内消费收束), +/// 会话期间拦截 ESC keyDown 与右键按下并置取消标志(会话定时器 ≤16ms 内消费收束), /// 解决覆盖层失焦时 ESC/右键仍可取消。文字编辑态例外:ESC 放行给覆盖层视图 ///(NSTextInputClient 键系路径清缓冲回确认态——不是取消截图)。 /// 长截图态例外:ESC 置 longCancelFlag 取消长截图(整会话 {success:false} 收束, @@ -283,7 +287,7 @@ private func screenshotOverlayEventTapCallback( final class ScreenshotOverlayEventTap { /// 共享取消标志(与会话交换的唯一通道)。 let cancelFlag: ScreenshotAtomicFlag - /// 文字编辑态标志(编辑态 ESC 放行不取消;由泵循环/状态迁移在主线程同步)。 + /// 文字编辑态标志(编辑态 ESC 放行不取消;由会话定时器/状态迁移在主线程同步)。 let textEditingFlag: ScreenshotAtomicFlag /// 保存对话框模态标志(模态期间 ESC/右键放行给 NSSavePanel 自消费,不触发会话取消; /// 主线程保存流在 runModal 前后同步置位/复位——对齐 Windows GetSaveFileNameW 模态 @@ -291,7 +295,7 @@ final class ScreenshotOverlayEventTap { let saveModalFlag: ScreenshotAtomicFlag /// 长截图态标志(置位期间 ESC → longCancelFlag 而非取消整个会话;主线程同步)。 let longCaptureFlag: ScreenshotAtomicFlag - /// 长截图取消标志(长截图态下 ESC 置位;泵循环消费 → 取消长截图,整会话失败收束)。 + /// 长截图取消标志(长截图态下 ESC 置位;会话定时器消费 → 取消长截图,整会话失败收束)。 let longCancelFlag: ScreenshotAtomicFlag private var tap: CFMachPort? private var runLoopSource: CFRunLoopSource? @@ -428,8 +432,10 @@ final class ScreenshotOverlayEventTap { /// 覆盖层选区会话主体。每个 NSScreen 一个覆盖层窗口共享本实例;选区用 CG 全局 /// 逻辑坐标跨屏统一表达,事件按窗口换算,绘制把 CG 坐标平移为窗口本地坐标。 -/// 生命周期:runOverlayCaptureSession 创建 → start()(窗口 + event tap)→ runEventPump() -/// 手动泵直至 confirmSelection/cancelSession 收束 → finish() 完整清理并回调恰好一次。 +/// 生命周期:startOverlayCaptureSession 创建 → start()(窗口 + event tap + 逐拍定时器 +/// + 激活策略切换 + 自持有)后立即返回;此后 AppKit 事件回调与 16ms 定时器 tick +/// 推进状态直至 confirmSelection/cancelSession 收束 → finish() 完整清理、回调恰好 +/// 一次并释放自持有(会话对象随之析构)。 final class ScreenshotOverlaySession { // ---- 会话配置与基础设施(以下成员供 ScreenshotPaintMac.swift 的绘制扩展跨文件访问)---- let options: ScreenshotSessionOptions @@ -511,14 +517,14 @@ final class ScreenshotOverlaySession { var fontSizeIdx = SC.defaultFontIdx // 当前选中字号索引(文字工具子菜单) /// 上帧插入符矩形(CG 全局坐标;isNull = 无缓存),供闪烁/键系局部失效(对齐 lastCaretRect) var lastCaretRect = CGRect.null - /// 编辑态 ESC 放行标志(event tap 后台线程读取;主线程在状态迁移/泵循环同步) + /// 编辑态 ESC 放行标志(event tap 后台线程读取;主线程在状态迁移/会话定时器同步) let textEditingFlag = ScreenshotAtomicFlag() /// 保存对话框模态标志(模态期间 event tap 放行 ESC/右键给保存面板;保存流在 /// runModal 前后置位/复位,见 ScreenshotOutputMac.swift 的 saveSelectionToFile) let saveModalFlag = ScreenshotAtomicFlag() - /// 长截图态标志(event tap 据此把 ESC 路由到 longCancelFlag;泵循环同步置位/复位) + /// 长截图态标志(event tap 据此把 ESC 路由到 longCancelFlag;会话定时器同步置位/复位) let longCaptureFlag = ScreenshotAtomicFlag() - /// 长截图取消标志(长截图态下 ESC 由 event tap 置位;泵循环消费 → 取消长截图整会话失败收束) + /// 长截图取消标志(长截图态下 ESC 由 event tap 置位;会话定时器消费 → 取消长截图整会话失败收束) let longCancelFlag = ScreenshotAtomicFlag() /// 长截图滚动捕获会话(进入长截图时创建,收束/取消后置 nil。 /// 定义见 ScreenshotLongCaptureMac.swift) @@ -533,6 +539,19 @@ final class ScreenshotOverlaySession { /// 工具栏/子菜单/tooltip 浮层族控制器(生命周期挂会话 start/finish;lazy 便于引用 self) lazy var toolbar = ScreenshotToolbarController(session: self) + // ---- 截图翻译(对齐 CaptureContext 的 TRL_* 字段组;逻辑见 ScreenshotTranslateMac.swift)---- + var translateState: ScreenshotTranslateState = .idle // 异步状态机(idle/ocrPending/clustering/translationPending/layout/shown) + var translateBlocks: [ScreenshotTranslateBlock] = [] // 译文覆盖块(CG 全局逻辑坐标) + var translateStatusShown = false // 状态气泡(进度/错误)可见 + var translateStatusError = false // 气泡是否错误态(淡红文本 + TTL) + var translateStatusText = "" // 气泡文本 + var translateStatusRect = CGRect.null // 气泡矩形(CG 全局逻辑坐标) + var translateStatusAt: TimeInterval = 0 // 气泡展示时刻(TTL 判定) + /// 进行中的异步翻译任务(requestId 驱动的 OCR/翻译请求登记与队列;会话主线程 + /// 独占读写,provider 回投经全局邮箱转入。会话收尾 cancelTranslateJob 取消全部 + /// 在飞请求,对齐 Windows TeardownTranslateJobs——晚到回投被 requestId 反查丢弃) + var translateJob: ScreenshotTranslateJob? + // 脏区追踪(上帧浮层并集;局部失效与绘制共用几何,等价 Windows last*Rect 语义) private var lastIdleOverlayRect: CGRect? @@ -555,9 +574,20 @@ final class ScreenshotOverlaySession { // MARK: 生命周期 - /// 创建多屏覆盖层窗口并显示,启动 ESC/右键 event tap 兜底。 - /// - Returns: true 会话就绪(随后 runEventPump);false 初始化失败(内部已 FailFast 回调并复位标志) + /// 创建多屏覆盖层窗口并显示,启动 ESC/右键 event tap 兜底、逐拍定时器与自持有, + /// 然后立即返回——会话此后完全由宿主主事件循环驱动(AppKit 事件回调 + 定时器 tick), + /// 不再存在任何阻塞调用栈。 + /// - Returns: true 会话已就绪并开始运行;false 初始化失败(内部已 FailFast 回调并复位标志) func start() -> Bool { + // 宿主激活策略切换:覆盖层以 accessory 策略运行(不作为常规应用出现在 + // Dock/切换器,对齐 Windows 无窗口线程行为;纯 Node 宿主默认 prohibited, + // 不切换窗口无法正常服务)。finish 时恢复快照。 + let app = NSApplication.shared + let currentPolicy = app.activationPolicy() + if currentPolicy != .accessory { + savedActivationPolicy = currentPolicy + app.setActivationPolicy(.accessory) + } guard setupOverlayWindows() else { finish("{\"success\":false,\"error\":\"failed to create overlay windows\"}") return false @@ -574,38 +604,30 @@ final class ScreenshotOverlaySession { longCaptureFlag: longCaptureFlag, longCancelFlag: longCancelFlag) eventTap?.start() + // 逐拍定时器(16ms,对齐原泵循环节奏):兜底取消标志消费、插入符闪烁、翻译 + // 邮箱 drain 与超时扫描、长截图采样轮、工具栏 tooltip 轮询等周期任务。 + let timer = Timer(timeInterval: 0.016, repeats: true) { [weak self] _ in + self?.sessionTick() + } + RunLoop.main.add(timer, forMode: .default) + tickTimer = timer + selfRetain = self return true } - /// 手动泵主循环(取色器模式扩展):Node 主线程不跑 NSRunLoop, - /// 会话期间由本循环驱动 AppKit 事件分发(窗口绘制/鼠标/键盘),直至确认或取消收束。 - /// nextEvent 带超时返回,保证泵循环能逐拍消费 CGEventTap 兜底取消标志 - ///(对齐 Windows 空闲循环的 GetAsyncKeyState 轮询节奏)。 + /// 逐拍任务(原泵循环 pumpTick 的定时器化等价物):消费兜底取消标志 + 工具栏 + /// 浮层族的状态/位置同步与 tooltip 轮询(tooltip 定时进 tick,走定时任务位) + /// + 编辑态插入符 500ms 闪烁(对齐 Windows 空闲循环 GetTickCount 分支) + /// + 编辑态标志同步(event tap ESC 放行)。 /// - /// 功耗审计结论:16ms 超时是有意的事件等待节奏而非忙等——无事件时线程阻塞在 - /// nextEvent 上(空闲唤醒率上限 62.5/s),且泵循环仅在有截图会话期间运行(本就阻塞 - /// JS 主线程的瞬态交互期)。不能拉长超时换功耗:cancelFlag/event tap 兜底取消、 - /// 插入符闪烁、长截图采样轮都依赖 ≤16ms 的逐拍消费(event tap 注释的契约), - /// 拉长即违反响应性兜底;取色器用阻塞式 nextEvent(until: nil) 是其无轮询任务的 - /// 特例,不适用本会话。 - func runEventPump() { - while isRunning { - autoreleasepool { - if let event = NSApp.nextEvent(matching: .any, until: Date(timeIntervalSinceNow: 0.016), - inMode: .default, dequeue: true) { - NSApp.sendEvent(event) - } - pumpTick() - } - } - } - - /// 泵循环逐拍任务:消费兜底取消标志 + 工具栏浮层族的状态/位置同步与 tooltip 轮询 - ///(tooltip 定时进 pumpTick,走泵循环定时任务位)+ 编辑态插入符 500ms 闪烁 - ///(对齐 Windows 空闲循环 GetTickCount 分支)+ 编辑态标志同步(event tap ESC 放行)。 - private func pumpTick() { + /// 功耗审计结论:16ms 节奏是有意的逐拍消费频率(继承原泵契约)——cancelFlag/ + /// event tap 兜底取消、插入符闪烁、长截图采样轮都依赖 ≤16ms 的逐拍消费, + /// 拉长即违反响应性兜底。定时器挂在主 RunLoop default mode:宿主(Electron + /// 主进程等)正常驱动主事件循环时逐拍触发;保存对话框模态期间暂停(对齐 + /// 原泵在 runModal 内停转的行为)。 + private func sessionTick() { guard isRunning else { return } - // 长截图态:泵循环驱动长截图采样/autoScroll/浮层刷新(ScreenshotLongCaptureMac.swift + // 长截图态:tick 驱动长截图采样/autoScroll/浮层刷新(ScreenshotLongCaptureMac.swift // 的 lcTick;对齐 Windows RunLongCapture 在覆盖层窗口过程内自泵消息的语义)。 // ESC(event tap → longCancelFlag)取消长截图:整会话 {success:false} 收束 //(对齐 lc_session_windows.cpp abortFlag → LongCaptureEmitFailure);cancelFlag 兜底同义。 @@ -633,6 +655,10 @@ final class ScreenshotOverlaySession { } // 编辑态插入符 500ms 闪烁(局部失效光标区域) tickTextCaret(now: ProcessInfo.processInfo.systemUptime) + // 截图翻译:provider 异步回投路由 + 超时扫描 + 错误气泡 TTL + //(对齐 Windows 空闲循环的 TickTranslateStatus + WM_SCREENSHOT_TRANSLATE_RESULT; + // Node/JS 侧事件循环由宿主自身驱动,结果邮箱在 tickTranslate 内 drain) + tickTranslate(now: ProcessInfo.processInfo.systemUptime) // 工具栏可见性随状态同步(对齐 OnPaint:Confirmed/Moving/Drawing/TextEditing 显示, // Resizing 隐藏);!toolbarPlaced 时随选区自动重算位置(toolbarPlaced 语义)。 let toolbarVisible = (state == .confirmed || state == .moving || state == .drawing @@ -689,12 +715,27 @@ final class ScreenshotOverlaySession { return true } - /// 会话统一出口:停 event tap → 销毁长截图会话 → 销毁工具栏浮层 → 销毁窗口 - /// → 回调(恰好一次)→ 复位重入标志(会话结束顺序;对齐 Windows 捕获线程末尾清理)。 + // ---- 非阻塞生命周期(自持有 + 逐拍定时器 + 激活策略)---- + /// 会话自持有:非阻塞会话没有调用栈锚点,start() 置为 self 防止无人引用被析构, + /// finish() 置 nil 释放(会话恰好一次收束后对象随窗口/视图引用图一并释放)。 + private var selfRetain: ScreenshotOverlaySession? + /// 逐拍定时器(主 RunLoop default mode):承载原泵循环的周期任务(插入符闪烁/ + /// 翻译邮箱 drain/超时扫描/长截图采样轮/工具栏 tooltip),16ms 对齐原泵节奏。 + /// default mode 下保存对话框模态期间暂停(对齐原泵在 runModal 内停转的行为)。 + private var tickTimer: Timer? + /// 会话启动前的宿主激活策略快照(finish 时恢复;嵌入宿主如 Electron 主进程 + /// policy = .regular,不恢复会永久隐藏宿主 Dock 图标)。 + private var savedActivationPolicy: NSApplication.ActivationPolicy? + + /// 会话统一出口:停定时器 → 停 event tap → 销毁长截图会话 → 销毁工具栏浮层 → + /// 销毁窗口 → 恢复激活策略 → 回调(恰好一次)→ 复位重入标志并释放自持有 + ///(会话结束顺序;对齐 Windows 捕获线程末尾清理)。 /// (internal:ScreenshotOutputMac.swift 的保存流 saveSelectionToFile 复用。) func finish(_ payload: String) { guard !hasFinished else { return } hasFinished = true + tickTimer?.invalidate() + tickTimer = nil NSCursor.arrow.set() // 长截图会话随会话收束销毁(abort/save/finish 收束路径已在 LC 侧先行清理, // 此处兜底防残余窗口/滚轮 tap 泄漏) @@ -705,18 +746,24 @@ final class ScreenshotOverlaySession { toolbar.destroy() ScreenshotMosaicCursors.reset() // 圆环光标随会话生命周期释放 mosaicBaseCache = nil // 马赛克 base 随会话释放 + cancelTranslateJob() // 翻译任务取消(对齐 TeardownTranslateJobs) for window in windows { window.orderOut(nil) window.contentView = nil } windows.removeAll() views.removeAll() + if let saved = savedActivationPolicy { + savedActivationPolicy = nil + NSApplication.shared.setActivationPolicy(saved) // 恢复宿主激活策略,幂等 + } payload.withCString { cStr in callback(cStr) } screenshotStateLock.lock() screenshotSessionActive = false screenshotStateLock.unlock() + selfRetain = nil } /// 构造失败回调 JSON(macOS 契约新增可选 error 字段,不改既有字段)。 @@ -1480,8 +1527,10 @@ final class ScreenshotOverlaySession { invalidateAll() toolbar.refresh() case .translate: - // 翻译:占位图标,无点击处理(与 Windows TB_Translate 行为一致) - break + // 翻译:OCR 识别选区文字 → 翻译 → 译文覆盖原文字区域(对齐 Windows + // TB_Translate 分支;Shown 态再点 = 切换退出,见 ScreenshotTranslateMac.swift) + beginTranslateOverlay() + return case .longCapture: // 长截图:进入长截图滚动捕获(隐藏覆盖层 → 灰蒙版 + 小地图 + 长截图 // 工具栏接管;会话不销毁,完成/保存成功按成功收束、取消/ESC/abort 按失败收束)。 @@ -1597,7 +1646,7 @@ final class ScreenshotOverlaySession { state = .confirmed invalidateAll() // 工具栏:确认态默认回到纯选择模式(对齐 EnterConfirmed 的 activeTool 缺省 TB_Drag), - // 工具栏/子菜单浮层随状态机在 pumpTick 同步显隐与位置(首拍即出现)。 + // 工具栏/子菜单浮层随状态机在会话定时器 tick 同步显隐与位置(首拍即出现)。 if activeTool == nil { activeTool = .drag } toolbar.syncVisibility(true) toolbar.syncPlacement() diff --git a/src/screenshot/macos/ScreenshotPaintMac.swift b/src/screenshot/macos/ScreenshotPaintMac.swift index 8155268..ce263cb 100644 --- a/src/screenshot/macos/ScreenshotPaintMac.swift +++ b/src/screenshot/macos/ScreenshotPaintMac.swift @@ -522,6 +522,11 @@ extension ScreenshotOverlaySession { //(对齐 OnPaint CS_TextEditing 分支) paintTextEditingLayer(ctx: ctx, view: view) + // 译文覆盖层:译文面板(白底盖原文字)+ 状态气泡(进度/错误)。画在标注/ + // 文字编辑之上、选区边框/手柄之下(对齐 OnPaint 的 DrawTranslateOverlay 层序, + // 确认/绘制/文字编辑各态均保持展示;见 ScreenshotTranslateMac.swift) + paintTranslateOverlay(ctx: ctx, view: view) + // 确认边框(对齐 DrawConfirmedBorder:radius≥1 用圆角路径,否则 1px 直角框) paintConfirmedBorder(ctx: ctx, view: view) diff --git a/src/screenshot/macos/ScreenshotTextMac.swift b/src/screenshot/macos/ScreenshotTextMac.swift index c173bbf..cd2033d 100644 --- a/src/screenshot/macos/ScreenshotTextMac.swift +++ b/src/screenshot/macos/ScreenshotTextMac.swift @@ -554,7 +554,7 @@ extension ScreenshotOverlaySession { // MARK: 闪烁 / 局部失效 / 渲染 - /// 泵循环逐拍任务:编辑态插入符 500ms 闪烁(对齐 session_windows.cpp 空闲循环分支), + /// 会话定时器逐拍任务:编辑态插入符 500ms 闪烁(对齐 session_windows.cpp 空闲循环分支), /// 仅失效光标附近区域。 /// - Parameter now: 单调时钟(ProcessInfo.systemUptime) func tickTextCaret(now: TimeInterval) { diff --git a/src/screenshot/macos/ScreenshotToolbarMac.swift b/src/screenshot/macos/ScreenshotToolbarMac.swift index 5079be4..aad7973 100644 --- a/src/screenshot/macos/ScreenshotToolbarMac.swift +++ b/src/screenshot/macos/ScreenshotToolbarMac.swift @@ -106,7 +106,7 @@ enum ScreenshotToolButton: Int { case brush // TB_Brush 画笔 case mosaic // TB_Mosaic 马赛克 case text // TB_Text 文字 - case translate // TB_Translate 翻译(占位,无点击处理,与 Windows 一致) + case translate // TB_Translate 翻译(OCR → 翻译 → 译文覆盖,与 Windows 一致) case longCapture // TB_LongCapture 长截图 case separator1 // TB_Separator1 分隔线 case undo // TB_Undo 撤销 @@ -811,8 +811,9 @@ final class ScreenshotToolbarController { // MARK: 按钮可用态 - /// 按钮是否可用(马赛克/文字可用;保存可用;长截图仍置灰; - /// 撤销/重做按快照栈状态;翻译占位无点击处理但保留 hover/tooltip 与 Windows 一致)。 + /// 按钮是否可用(马赛克/文字可用;保存可用;长截图已启用; + /// 撤销/重做按快照栈状态;翻译可用——未注册 ProviderBridge 时点击由 + /// 原生层转为错误气泡提示)。 /// 保存按钮启用规则对齐 Windows:overlay_input_windows.cpp 的 TB_Save 分支无任何 /// 禁用判定(工具栏仅在确认态出现,编辑态恒可用),故此处恒返回 true。 func isButtonEnabled(_ button: ScreenshotToolButton) -> Bool { @@ -821,7 +822,7 @@ final class ScreenshotToolbarController { case .save: return true // 保存对话框(Windows 编辑态恒可用) case .undo: return !(session?.undoStack.isEmpty ?? true) case .redo: return !(session?.redoStack.isEmpty ?? true) - default: return true // mosaic/text/cancel/confirm/drag/矢量工具/translate(占位) + default: return true // mosaic/text/cancel/confirm/drag/矢量工具/translate } } @@ -840,7 +841,7 @@ final class ScreenshotToolbarController { return ScreenshotGeometry.cgPoint(fromNS: NSEvent.mouseLocation) } - /// 泵循环逐拍轮询(对齐 TickToolbarTooltip:覆盖层不接收稳定鼠标流,停顿判定靠轮询): + /// 会话定时器逐拍轮询(对齐 TickToolbarTooltip:覆盖层不接收稳定鼠标流,停顿判定靠轮询): /// 1) hover 高亮变化重绘工具栏;2) 停顿满 500ms 显示 tooltip 气泡; /// 3) 工具栏/子菜单区域接管光标(箭头/把手四向/子菜单手型)。 /// - Parameter now: 单调时钟(ProcessInfo.systemUptime) diff --git a/src/screenshot/macos/ScreenshotTranslateMac.swift b/src/screenshot/macos/ScreenshotTranslateMac.swift new file mode 100644 index 0000000..5039553 --- /dev/null +++ b/src/screenshot/macos/ScreenshotTranslateMac.swift @@ -0,0 +1,1370 @@ +import Foundation +import AppKit +import CoreGraphics +import CoreText +import ImageIO + +// MARK: - 截图翻译子系统(macOS;Windows 基准 translate_windows.cpp) +// +// 本文件承载截图翻译的 macOS 实现(对齐 Windows translate_windows.cpp 全流程): +// - 原生桥函数指针:binding_mac.cpp 库加载时经 ztoolsRegisterNativeBridge 注入 +// provider 异步调用 / 请求取消 / 就绪查询三个 C 包装;结果回投经本文件导出的 +// ztoolsSwiftProviderResultHandler(bridge 异步回调 → 全局邮箱)送回会话主线程; +// - 状态机(全异步,由 provider 回调与会话定时器 deadline 扫描推进,会话主线程独占): +// Idle --点击「翻译」--> OcrPending --OCR 回调--> Clustering(瞬态) +// --聚类完成--> TranslationPending --末段回调--> Layout(瞬态)--> Shown; +// 任何阶段失败 → Idle(错误气泡 4s TTL);进行中任何 pending 态可取消 → Idle; +// Shown 再点 = 切换退出(清覆盖、取消按钮激活,不重跑); +// - 异步 RPC:Swift 发起 InvokeAsync(requestId, type, payload) 后立即返回,JS 侧 +// 完成后按 requestId 异步回投;requestId/jobId 全局自增唯一,晚到结果(已超时/ +// 已取消/任务已换代)经 requestId 反查落空直接丢弃,绝不污染当前 UI 状态; +// - 翻译队列:段落聚类完成后入 pending 队列,按 maxConcurrency=2 有限并发逐段 +// 翻译(不要求严格顺序返回,requestId→paragraphIndex 写回,渲染按原始顺序); +// - 超时:每个在飞请求带 deadline(会话定时器逐拍扫描),到时 CancelRequest(requestId) +// + 按失败收束该段(底层 JS Promise/网络无法真取消,但晚到回调被桥丢弃); +// - 取消:会话收尾 / 退出翻译态时 cancelJob(全部在飞请求取消、队列清空); +// - 渲染:白底圆角面板盖原段落 + 译文按段落区域自适应字号折行排版(CoreText +// 度量;字号 = min(能装进段框的最大字号, 原文行距对应字号),每段独立随其 +// 框高与行数变化),OnPaint 画在标注之上、选区边框之下;导出合成进最终 +// 图像(物理分辨率重渲染)。 +// +// 线程模型(非阻塞会话): +// - 截图会话为非阻塞生命周期对象:start 后 JS 主线程立即返回,Node/libuv/V8 与 +// AppKit 事件全部由宿主进程自己的主事件循环驱动(Electron 主进程的 Chromium +// 消息泵同时运转 NSApp 与 libuv),截图模块不再泵 uv_run/V8 微任务; +// - Swift 发起 InvokeAsync 后立即返回,宿主事件循环自行推进 JS provider 的 +// Promise 链 / 定时器 / 网络 I/O,并把结果经 TSFN 派发回投; +// - 结果回投:provider 回调(JS 线程触发;Electron 主进程内 JS 线程即主线程) +// → 全局结果邮箱(NSLock,上限防膨胀)→ 会话 16ms 定时器 tick drain +// → 状态机推进 / 晚到结果丢弃。UI 状态变更全部发生在会话主线程, +// 无跨线程共享可变状态。 +// +// 字体差异:Windows 用微软雅黑;macOS 系统字体(CJK 回落苹方),度量数值不逐像素 +// 一致,排版计算结构同构(与 ScreenshotTextMac.swift 同款处理)。 +// +// provider 约定(与 Windows 完全一致,宿主 JS 侧 ProviderBridge.start 的 handler): +// ocr: 入参 {"image":"data:image/png;base64,..."}(选区裁剪,物理像素) +// => {"text":"..","blocks":[{"text":..,"left":..,"top":..,"right":.. +// "bottom":..},..]},blocks 为行级对象(提交图像内像素坐标); +// 仅回 text / blocks 为字符串数组(无坐标)时走整图兜底 +// translation: 入参 {"text":"段落文本"} => {"text":"译文"} +// +// 坐标系:块坐标为 CG 全局逻辑坐标(左上原点、整数点,对齐选区坐标系)。 + +// MARK: - 原生桥函数指针(binding_mac.cpp 库加载时注入,进程级一次) + +/// provider 桥异步调用签名(C 侧 ZtoolsProviderInvokeAsyncForSwift): +/// 返回 1 已受理(结果将经 ztoolsSwiftProviderResultHandler 异步回投,恰好一次)/ +/// 0 立即拒绝(*errorOut 为 malloc 的可读错误,不会触发回投;调用方 free)。 +/// 非阻塞,可在任意线程(含会话主线程 = JS 主线程)调用。 +public typealias ScNativeProviderInvokeAsyncFn = @convention(c) ( + UInt64, UnsafePointer?, UnsafePointer?, + UnsafeMutablePointer?>? +) -> Int32 +/// provider 请求取消签名(C 侧 ZtoolsProviderCancelRequestForSwift): +/// 移除请求登记,此后晚到的 JS 结果被桥静默丢弃(不触发回投);幂等。 +public typealias ScNativeProviderCancelFn = @convention(c) (UInt64) -> Void +/// provider 桥就绪查询签名(C 侧 ZtoolsProviderIsReadyForSwift)。 +public typealias ScNativeProviderReadyFn = @convention(c) () -> Int32 +/// provider 结果回投签名(Swift 侧 ztoolsSwiftProviderResultHandler,C 桥调用): +/// requestId 匹配发起方;ok=1 时 valueJson 为结果 JSON,否则 error 为可读错误。 +/// 触发线程 = 桥回调来源线程(通常为 JS 线程),实现只做线程安全入队。 +public typealias ScNativeProviderResultFn = @convention(c) ( + UInt64, Int32, UnsafePointer?, UnsafePointer? +) -> Void + +/// 注入的原生桥函数指针(模块级;未注入 = .node 侧为旧版本,翻译降级为错误气泡)。 +var scNativeProviderInvokeAsync: ScNativeProviderInvokeAsyncFn? = nil +var scNativeProviderCancel: ScNativeProviderCancelFn? = nil +var scNativeProviderReady: ScNativeProviderReadyFn? = nil + +/// 原生桥注册入口(binding_mac.cpp 的 LoadSwiftLibrary 在 dlsym 后调用,进程一次)。 +/// - Parameters: +/// - invokeAsync: provider 桥异步调用包装 +/// - cancel: provider 请求取消包装 +/// - ready: provider 桥就绪查询包装 +/// - Returns: 1 已注册(指针任一为空时仍注册非空者并返回 0) +@_cdecl("ztoolsRegisterNativeBridge") +public func ztoolsRegisterNativeBridge( + _ invokeAsync: ScNativeProviderInvokeAsyncFn?, + _ cancel: ScNativeProviderCancelFn?, + _ ready: ScNativeProviderReadyFn? +) -> Int32 { + scNativeProviderInvokeAsync = invokeAsync + scNativeProviderCancel = cancel + scNativeProviderReady = ready + return (invokeAsync != nil && cancel != nil && ready != nil) ? 1 : 0 +} + +// MARK: - provider 结果邮箱(桥回调线程 → 会话主线程的唯一交接点) + +/// 一次异步 provider 调用的回投结果(值拷贝,跨线程无共享状态)。 +struct ScProviderAsyncResult { + let requestId: UInt64 + let ok: Bool + let valueJson: String + let error: String + let deliveredAt: TimeInterval // systemUptime(诊断耗时用) +} + +/// 全局结果邮箱:ztoolsSwiftProviderResultHandler(桥回调线程,通常为 JS 线程) +/// 只做加锁入队;会话定时器逐拍 drain 后按 requestId 路由。容量上限防「会话已收 +/// 尾且无后续消费者」的残留膨胀——超限丢最旧并记日志。 +final class ScProviderResultMailbox { + static let shared = ScProviderResultMailbox() + private let lock = NSLock() + private var queue: [ScProviderAsyncResult] = [] + private let capacity = 64 + + /// 入队一条结果(桥回调线程调用;超限丢最旧)。 + func post(_ result: ScProviderAsyncResult) { + lock.lock() + queue.append(result) + if queue.count > capacity { + queue.removeFirst(queue.count - capacity) + } + lock.unlock() + } + + /// 取走全部积压结果(会话主线程调用,读后清空)。 + func drain() -> [ScProviderAsyncResult] { + lock.lock() + defer { lock.unlock() } + let r = queue + queue.removeAll() + return r + } +} + +/// provider 结果回投入口(C 桥调用,线程 = 桥回调来源线程,通常为 JS 线程)。 +/// 只做字符串拷贝 + 入邮箱;绝不在此线程触碰任何 UI / 会话状态。 +@_cdecl("ztoolsSwiftProviderResultHandler") +public func ztoolsSwiftProviderResultHandler( + _ requestId: UInt64, _ ok: Int32, + _ valueJson: UnsafePointer?, _ error: UnsafePointer? +) { + let result = ScProviderAsyncResult( + requestId: requestId, + ok: ok == 1, + valueJson: valueJson.map { String(cString: $0) } ?? "", + error: error.map { String(cString: $0) } ?? "", + deliveredAt: ProcessInfo.processInfo.systemUptime) + ScProviderResultMailbox.shared.post(result) +} + +// MARK: - requestId / jobId 分配与结构化日志 + +/// 全局 id 分配器(NSLock 保护;低频调用,锁开销可忽略)。 +private let scTranslateIdLock = NSLock() +private var scTranslateNextJobId: UInt64 = 0 +private var scTranslateNextRequestId: UInt64 = 0 + +/// 分配下一个翻译任务号(进程内单调递增,跨会话唯一)。 +func scAllocateTranslateJobId() -> UInt64 { + scTranslateIdLock.lock() + defer { scTranslateIdLock.unlock() } + scTranslateNextJobId += 1 + return scTranslateNextJobId +} + +/// 分配下一个 provider 请求号(进程内单调递增,跨会话唯一)。 +func scAllocateTranslateRequestId() -> UInt64 { + scTranslateIdLock.lock() + defer { scTranslateIdLock.unlock() } + scTranslateNextRequestId += 1 + return scTranslateNextRequestId +} + +/// 请求生命周期日志([translate][job=J][req=R][type][para=P] EVENT detail)。 +/// 任何一次「翻译卡住」都能凭 jobId/requestId 定位到具体阶段。 +func scTLog(_ event: String, job: UInt64? = nil, req: UInt64? = nil, + type: String? = nil, para: Int? = nil, _ detail: String = "") { + var line = "[translate]" + if let j = job { line += "[job=\(j)]" } + if let r = req { line += "[req=\(r)]" } + if let t = type { line += "[\(t)]" } + if let p = para { line += "[para=\(p)]" } + line += " \(event)" + if !detail.isEmpty { line += " \(detail)" } + print(line) +} + +// MARK: - 聚类算法 C ABI 镜像(translate_bridge_mac.h 的 Swift 同构布局) + +/// 聚类输入行(镜像 TcLineC;text 指针仅在 tcClusterLines 调用期间有效)。 +struct TcLineC { + var text: UnsafePointer? = nil + var x: Double = 0 + var y: Double = 0 + var w: Double = 0 + var h: Double = 0 +} + +/// 聚类产出的段落(镜像 TcParagraphC;text/lineIdx 由 C 层分配,随释放函数回收)。 +struct TcParagraphC { + var x: Double = 0 + var y: Double = 0 + var w: Double = 0 + var h: Double = 0 + var lineHeight: Double = 0 + var text: UnsafeMutablePointer? = nil + var lineIdx: UnsafeMutablePointer? = nil + var lineCount: Int32 = 0 +} + +/// 聚类结果(镜像 TcClusterResultC)。 +struct TcClusterResultC { + var paragraphCount: Int32 = 0 + var paragraphs: UnsafeMutablePointer? = nil + var medianLineH: Double = 0 + var medianPitch: Double = 0 + var boundaryCount: Int32 = 0 +} + +@_silgen_name("tc_abi_version") +private func tcAbiVersionC() -> Int32 +@_silgen_name("tc_cluster_lines") +private func tcClusterLinesC(_ lines: UnsafePointer?, _ lineCount: Int32, + _ out: UnsafeMutablePointer?) -> Int32 +@_silgen_name("tc_cluster_free_result") +private func tcClusterFreeResultC(_ result: UnsafeMutablePointer?) + +// MARK: - 常量(Windows translate_windows.cpp 出处集中标注) + +extension SC { + /// OCR provider 调用超时(translate_windows.cpp SC_OCR_TIMEOUT_MS) + static let translateOcrTimeoutMs: UInt32 = 30000 + /// 单段翻译 provider 调用超时(SC_TRANSLATE_LINE_TIMEOUT_MS) + static let translateLineTimeoutMs: UInt32 = 15000 + /// 段落翻译有限并发上限(并发请求出队列补充;尊重 provider 限流) + static let translateMaxConcurrency = 2 + /// 错误状态气泡自动隐藏时长(SC_TRANSLATE_ERROR_TTL_MS) + static let translateErrorTtlSec: TimeInterval = 4.0 + /// 译文面板内边距 / 圆角(sctranslatedraw PANEL_PAD / PANEL_RADIUS) + static let translatePanelPad: CGFloat = 4 + static let translatePanelRadius: CGFloat = 4 + /// 译文字号搜索区间(sctranslatedraw FONT_MIN_PX / FONT_MAX_PX) + static let translateFontMinPx = 9 + static let translateFontMaxPx = 48 + /// 整图兜底块(无行级特征)的默认字号上限(sctranslatedraw FALLBACK_FONT_PX) + static let translateFallbackFontPx: CGFloat = 16 + /// 状态气泡内边距与字号(SetTranslateStatus padX=8/padY=5、12px 字体) + static let translateBubblePadX: CGFloat = 8 + static let translateBubblePadY: CGFloat = 5 + static let translateBubbleFontPx: CGFloat = 12 + static let translateBubbleRadius: CGFloat = 4 +} + +// MARK: - 翻译状态与数据结构 + +/// 翻译覆盖状态机(异步化后的显式状态;对齐设计文档,UI 语义对齐 Windows 的 +/// TRL_Idle/Busy/Shown 三态)。状态推进只发生在会话主线程:由 provider 回调 +/// (邮箱 drain)或 deadline 扫描驱动,不存在「等待某个同步函数返回」的推进。 +/// Clustering / Layout 为会话主线程内同步完成的瞬态(跨拍不持久,仅用于日志与防御)。 +enum ScreenshotTranslateState { + case idle // 无覆盖 + case ocrPending // OCR 请求在飞(进度气泡) + case clustering // OCR 已回:行聚类成段落(瞬态) + case translationPending // 段落翻译队列推进中(进度气泡) + case layout // 全部段落已回:坐标换算 + 构块(瞬态) + case shown // 译文覆盖展示中 + + /// 是否处于「进行中」聚合态(对应旧 TRL_Busy 的门禁/进度气泡语义)。 + var isBusy: Bool { + return self == .ocrPending || self == .clustering + || self == .translationPending || self == .layout + } +} + +/// 一个译文覆盖块(对齐 Windows sc_types.h 的 TranslateBlock): +/// box 为 CG 全局逻辑坐标(整数点);origLineCount 为原段落行数(行距对齐 +/// 字号搜索的输入;整图兜底块 origLineCount = 0 → 用默认字号上限); +/// fit* 为排版缓存(键 = 绘制时框宽高,几何不变每帧复用)。 +struct ScreenshotTranslateBlock { + var box = CGRect.null + var text = "" + var origLineCount = 0 + // 排版缓存 + var fitKeyW = 0 + var fitKeyH = 0 + var fitFontPx = 0 + var fitLineH: CGFloat = 0 + var fitLines: [String] = [] +} + +// MARK: - 异步翻译任务(Job Manager,会话主线程独占读写) + +/// 工作任务入参(点击时打包;全部为值类型,跨线程无共享状态)。 +struct ScreenshotTranslateJobInput { + let imageBase64: String // data:image/png;base64,...(选区裁剪,物理像素) + let imageW: Int + let imageH: Int + let selection: CGRect // 选区(CG 全局逻辑坐标) + let scale: CGFloat // 物理/逻辑缩放比(baseFrame.scale) +} + +/// 一个在飞的 provider 请求(requestId 驱动的生命周期载体)。 +struct ScTranslateRequest { + enum Kind { + case ocr // OCR 全图识别 + case paragraph(Int) // 聚类段落翻译(下标为段落序) + case wholeText // 整图兜底单段翻译 + } + let requestId: UInt64 + let kind: Kind + let startedAt: TimeInterval // systemUptime(耗时日志) + let deadline: TimeInterval // 到期后会话定时器取消该请求(毫秒精度足够) +} + +/// 一个翻译任务:OCR → 聚类 → 段落有限并发翻译 → 构块。 +/// 仅会话主线程读写(邮箱是唯一跨线程入口),无锁;晚到结果经 requestId 反查 +/// inFlight 落空被丢弃(任务已取消/已失败/已换代时自然免疫)。 +final class ScreenshotTranslateJob { + let jobId: UInt64 + let input: ScreenshotTranslateJobInput + let startedAt = ProcessInfo.processInfo.systemUptime + + /// 在飞请求(requestId → 请求信息;回投/超时/取消的统一登记表)。 + var inFlight: [UInt64: ScTranslateRequest] = [:] + + // ---- 翻译阶段状态(聚类完成后填充)---- + var paragraphs: [ScClusterParagraph] = [] // 聚类段落(渲染序) + var isWholeFallback = false // 整图兜底(无坐标 OCR)单段模式 + var wholeFallbackText = "" // 整图兜底待译文本(OCR 回投时写入) + var paraResults: [Int: String] = [:] // 段落序 → 译文(不要求按序返回) + var paraFailed = 0 // 失败/超时/空译文段数 + var lastError = "" // 最近一次失败原因(全失败时报错用) + var nextPara = 0 // 待发射的下一个段落下标 + + init(jobId: UInt64, input: ScreenshotTranslateJobInput) { + self.jobId = jobId + self.input = input + } + + /// 翻译阶段是否已收束(队列发完 + 无在飞)。 + var translationDrained: Bool { + return isWholeFallback + ? inFlight.isEmpty + : nextPara >= paragraphs.count && inFlight.isEmpty + } +} + +// MARK: - provider 异步调用辅助(会话主线程) + +/// provider 桥是否就绪(对齐 Windows IsReady 门禁:未注册时给出可操作提示)。 +func scProviderBridgeReady() -> Bool { + guard let ready = scNativeProviderReady else { return false } + return ready() == 1 +} + +/// 发射一个异步 provider 请求(立即返回;结果经邮箱回投,由会话定时器路由)。 +/// - Parameters: +/// - job: 归属任务(登记 inFlight) +/// - kind: 请求种类(决定超时时长与回投路由) +/// - type: 能力类型("ocr" / "translation") +/// - payload: 入参对象(JSONSerialization 序列化) +/// - now: 单调时钟(deadline 登记) +/// - Returns: 已发射的请求;桥未就绪/受理被拒时返回 nil(调用方按失败收束) +func scLaunchProviderRequest(job: ScreenshotTranslateJob, kind: ScTranslateRequest.Kind, + type: String, payload: [String: Any], + now: TimeInterval) -> ScTranslateRequest? { + guard let invoke = scNativeProviderInvokeAsync else { + scTLog("REQUEST_START", job: job.jobId, type: type, "rejected: 翻译服务未就绪(原生桥未注册)") + return nil + } + var isOcrKind = false + if case .ocr = kind { isOcrKind = true } + let timeoutMs: UInt32 = isOcrKind ? SC.translateOcrTimeoutMs + : SC.translateLineTimeoutMs + let requestId = scAllocateTranslateRequestId() + let paraIdx: Int? + if case .paragraph(let i) = kind { paraIdx = i } else { paraIdx = nil } + scTLog("REQUEST_START", job: job.jobId, req: requestId, type: type, para: paraIdx, + "timeout=\(timeoutMs)ms") + + guard let inputData = try? JSONSerialization.data(withJSONObject: payload), + let inputJson = String(data: inputData, encoding: .utf8) else { + scTLog("REQUEST_START", job: job.jobId, req: requestId, type: type, + "rejected: 请求构造失败") + return nil + } + let request = ScTranslateRequest(requestId: requestId, kind: kind, + startedAt: now, deadline: now + Double(timeoutMs) / 1000.0) + var errorPtr: UnsafeMutablePointer? = nil + let accepted = invoke(requestId, type, inputJson, &errorPtr) + defer { if let p = errorPtr { free(p) } } + guard accepted == 1 else { + let message = errorPtr.map { String(cString: $0) } ?? "provider 调用失败" + scTLog("REQUEST_START", job: job.jobId, req: requestId, type: type, + "rejected: \(message)") + return nil + } + job.inFlight[requestId] = request + return request +} + +/// 解析 provider 回投的 JSON 根对象(失败返回 nil)。 +func scParseProviderJson(_ json: String) -> Any? { + guard let data = json.data(using: .utf8) else { return nil } + return try? JSONSerialization.jsonObject(with: data) +} + +/// 从 translation 回投 JSON 提取译文({"text":"译文"};缺失/为空返回 nil)。 +func scExtractTranslatedText(_ root: Any?) -> String? { + guard let obj = root as? [String: Any], let out = obj["text"] as? String else { + return nil + } + return out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : out +} + +/// OCR 文本行(提交图像内像素坐标;对齐 Windows OcrRawBlock;text 先为原文, +/// 翻译成功后替换为译文;origLineCount 为原段落行数,由聚类成员行统计, +/// 渲染层行距对齐字号搜索的输入)。 +struct ScOcrRawBlock { + var text = "" + var x: Double = 0 + var y: Double = 0 + var w: Double = 0 + var h: Double = 0 + var origLineCount = 0 +} + +// MARK: - OCR 响应解析(对齐 Windows ParseOcrBlocks) + +/// 从 ocr 响应解析文本行(对齐 ParseOcrBlocks,兼容 x/y/width/height 命名与 +/// blocks 为纯字符串数组的历史契约):带坐标对象行 → outLines;字符串行 / +/// 缺坐标行 → outPlain(整图兜底);空白文本行直接丢弃。 +/// - Parameter root: OCR 响应反序列化的根对象 +/// - Returns: 带坐标行 + 无坐标文本 +func scParseOcrBlocks(_ root: Any?) -> (lines: [ScOcrRawBlock], plain: [String]) { + var lines: [ScOcrRawBlock] = [] + var plain: [String] = [] + guard let obj = root as? [String: Any], let blocks = obj["blocks"] as? [Any] else { + return (lines, plain) + } + for b in blocks { + if let s = b as? String { + if !s.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + plain.append(s) + } + continue + } + guard let bo = b as? [String: Any], let t = bo["text"] as? String else { continue } + if t.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { continue } + var rb = ScOcrRawBlock() + rb.text = t + if let v = bo["left"] as? Double { rb.x = v } + if let v = bo["top"] as? Double { rb.y = v } + if let v = bo["x"] as? Double { rb.x = v } + if let v = bo["y"] as? Double { rb.y = v } + if rb.w <= 0, let v = bo["right"] as? Double { rb.w = v - rb.x } + if rb.h <= 0, let v = bo["bottom"] as? Double { rb.h = v - rb.y } + if rb.w <= 0, let v = bo["width"] as? Double { rb.w = v } + if rb.h <= 0, let v = bo["height"] as? Double { rb.h = v } + if rb.w > 0 && rb.h > 0 { + lines.append(rb) + } else { + plain.append(rb.text) // 有文本无坐标:并兜底文本 + } + } + return (lines, plain) +} + +// MARK: - 任务阶段处理(会话主线程;由 provider 回调驱动,纯数据处理无 UI 触碰) + +/// OCR 回投的处置结论:进入段落翻译 / 整图兜底单段 / 任务失败。 +enum ScOcrOutcome { + case paragraphs(clustered: ScClusterOutcome) // 行聚类完成 → 段落队列 + case wholeFallback // OCR 无坐标 → 整段翻译(文本已存 job) + case failed(error: String) +} + +/// OCR 回投处理(对齐旧 worker 流程的 1/2 前置段):解析文本行 → 空文本守卫 → +/// 聚类(瞬态 clustering);无坐标走整图兜底。副作用:成功时填充 job 的 +/// paragraphs/isWholeFallback(段落路径)。 +/// - Parameters: +/// - job: 当前任务(成功时写入聚类产物) +/// - request: 触发本处理的在飞请求(日志用) +/// - result: provider 回投 +/// - Returns: 处置结论(failed 时带中文错误) +func scProcessOcrResult(job: ScreenshotTranslateJob, request: ScTranslateRequest, + result: ScProviderAsyncResult) -> ScOcrOutcome { + if !result.ok { + return .failed(error: "文字识别失败:" + result.error) + } + guard let root = scParseProviderJson(result.valueJson) else { + return .failed(error: "识别结果解析失败") + } + let (lines, plainTexts) = scParseOcrBlocks(root) + if lines.isEmpty && plainTexts.isEmpty { + // 仅整段 text(无 blocks,纯文本 AI 识别契约)仍可走 2b 整图兜底; + // 连 text 也无内容才是真正的「未识别到文字」(对齐 Windows 同款守卫) + let wholeText = (root as? [String: Any])?["text"] as? String ?? "" + if wholeText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return .failed(error: "未识别到文字") + } + } + + if !lines.isEmpty { + // ---- 文本行聚类成段落(逐行翻译拆散段落上下文、逐行覆盖渲染碎面板, + // 故先聚类再以段落为单位翻译与覆盖;单段失败跳过,全失败才报错)---- + let clustered = scClusterLinesCocoa(lines) + scTLog("CLUSTER", job: job.jobId, + "\(lines.count) lines -> \(clustered.paragraphs.count) paragraphs " + + "(medianH=\(clustered.medianLineH), pitch=\(clustered.medianPitch), " + + "boundaries=\(clustered.boundaryCount))") + if clustered.paragraphs.isEmpty { + return .failed(error: "未识别到文字") + } + job.paragraphs = clustered.paragraphs + return .paragraphs(clustered: clustered) + } + + // ---- 整图兜底:OCR 未给坐标(如纯文本 AI 识别)时整段翻译 ---- + job.isWholeFallback = true + var wholeText = "" + if let obj = root as? [String: Any], let t = obj["text"] as? String, + !t.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + wholeText = t + } else { + wholeText = plainTexts.joined(separator: "\n") + } + job.wholeFallbackText = wholeText + return .wholeFallback +} + +/// 依据任务翻译产物构建译文原始块(图像物理像素坐标系;段落行数随块带下, +/// 供渲染层行距对齐的字号搜索用):段落路径按原始段落顺序、仅含有译文者; +/// 整图兜底为整选区单块。 +/// - Parameter job: 已收束的翻译任务 +/// - Returns: 原始块数组 + 全失败标记(有失败时附带最近错误) +func scCollectTranslatedRawBlocks(_ job: ScreenshotTranslateJob) + -> (blocks: [ScOcrRawBlock], allFailed: Bool, lastError: String) { + var translated: [ScOcrRawBlock] = [] + if job.isWholeFallback { + guard let outText = job.paraResults[0] else { + return ([], true, job.lastError) + } + var fb = ScOcrRawBlock() + fb.x = 0 + fb.y = 0 + fb.w = Double(job.input.imageW) + fb.h = Double(job.input.imageH) + fb.text = outText + translated.append(fb) + return (translated, false, "") + } + for (i, para) in job.paragraphs.enumerated() { + guard let outText = job.paraResults[i] else { continue } + var tb = ScOcrRawBlock() + tb.x = para.x + tb.y = para.y + tb.w = para.w + tb.h = para.h + // 原段落行数(渲染层行距对齐字号搜索的输入) + tb.origLineCount = para.lineCount + tb.text = outText + translated.append(tb) + } + return (translated, translated.isEmpty && job.paraFailed > 0, job.lastError) +} + +/// 坐标换算:图像像素(物理)→ 选区绝对逻辑坐标,并夹回选区内 +///(对齐旧 worker 流程第 3 步;丢弃换算后过小的块与空文本块)。 +/// - Parameters: +/// - translated: 译文原始块(图像物理像素坐标系) +/// - input: 任务入参(选区/缩放比) +/// - Returns: 展示块(CG 全局逻辑坐标) +func scConvertToDisplayBlocks(_ translated: [ScOcrRawBlock], + _ input: ScreenshotTranslateJobInput) -> [ScreenshotTranslateBlock] { + var blocks: [ScreenshotTranslateBlock] = [] + let ds = input.scale > 0.01 ? input.scale : 1.0 + for rb in translated { + let x = input.selection.minX + CGFloat(rb.x / ds).rounded() + let y = input.selection.minY + CGFloat(rb.y / ds).rounded() + let w = CGFloat(rb.w / ds).rounded() + let h = CGFloat(rb.h / ds).rounded() + let clipped = CGRect(x: x, y: y, width: w, height: h).intersection(input.selection) + if clipped.isNull || clipped.width < 2 || clipped.height < 2 { continue } + + var tb = ScreenshotTranslateBlock() + tb.box = clipped + tb.text = rb.text + if tb.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { continue } + tb.origLineCount = rb.origLineCount + blocks.append(tb) + } + return blocks +} + +/// Swift 侧聚类段落(C ABI 产物的免拷贝视图,文本已转 String、下标已转 [Int])。 +struct ScClusterParagraph { + var x: Double = 0 + var y: Double = 0 + var w: Double = 0 + var h: Double = 0 + var lineHeight: Double = 0 + var text: String = "" + var lineIdx: [Int] = [] + var lineCount: Int = 0 +} + +struct ScClusterOutcome { + var paragraphs: [ScClusterParagraph] = [] + var medianLineH: Double = 0 + var medianPitch: Double = 0 + var boundaryCount: Int = 0 +} + +/// 经 C ABI 调用 TcClusterLines(algo/translate_cluster.cpp)把 OCR 行聚成段落。 +/// 输入行的 text 以 strdup 暂存保证调用期间指针有效,调用后立即释放; +/// 段落数组的 C 侧内存随 tc_cluster_free_result 整体回收。 +/// - Parameter lines: OCR 带坐标文本行 +/// - Returns: 段落数组 + 全局统计(失败返回空段落) +func scClusterLinesCocoa(_ lines: [ScOcrRawBlock]) -> ScClusterOutcome { + var outcome = ScClusterOutcome() + // strdup 暂存 C 字符串(Swift String 缓冲不保证稳定地址/结尾符) + var cTexts: [UnsafeMutablePointer] = [] + cTexts.reserveCapacity(lines.count) + for rb in lines { + rb.text.utf8CString.withUnsafeBufferPointer { buf in + // 带 NUL 的 UTF-8 → strdup(baseAddress 对空串亦非 nil,指向 NUL) + if let base = buf.baseAddress { + cTexts.append(strdup(base)) + } else { + cTexts.append(strdup("")) + } + } + } + defer { for p in cTexts { free(p) } } + + var cLines: [TcLineC] = [] + cLines.reserveCapacity(lines.count) + for (i, rb) in lines.enumerated() { + cLines.append(TcLineC(text: UnsafePointer(cTexts[i]), x: rb.x, y: rb.y, w: rb.w, h: rb.h)) + } + var result = TcClusterResultC() + guard tcClusterLinesC(&cLines, Int32(cLines.count), &result) == 1 else { + return outcome + } + defer { tcClusterFreeResultC(&result) } + outcome.medianLineH = result.medianLineH + outcome.medianPitch = result.medianPitch + outcome.boundaryCount = Int(result.boundaryCount) + if let paras = result.paragraphs { + for i in 0.. (lineH: CGFloat, ascent: CGFloat) { + let attr = NSAttributedString(string: "Ag", + attributes: [.font: NSFont.systemFont(ofSize: fontPx)]) + let line = CTLineCreateWithAttributedString(attr) + var ascent: CGFloat = 0, descent: CGFloat = 0, leading: CGFloat = 0 + _ = CTLineGetTypographicBounds(line, &ascent, &descent, &leading) + var lineH = ascent + descent + leading + if lineH < 1 { lineH = fontPx } + return (lineH, ascent) +} + +/// 是否 CJK 字符(对齐 IsCjkForWrap,UTF-16 码元粗判):换行算法据此允许在 +/// 任意 CJK 字符后断行。 +private func scIsCjkForWrap(_ c: unichar) -> Bool { + return (c >= 0x2E80 && c <= 0x9FFF) // 部首/中日韩部首/拼音/基本区 + || (c >= 0x3000 && c <= 0x303F) // 中日韩标点 + || (c >= 0xF900 && c <= 0xFAFF) // 兼容表意 + || (c >= 0xFF00 && c <= 0xFFEF) // 全角形式 +} + +/// 前缀累宽:widths[i] = 前 i 个 UTF-16 单元的渲染宽度(含尾随空格,对齐 +/// WrapText 的 MeasureTrailingSpaces 前缀测量;逐前缀 CTLine 测量,短段落可接受)。 +private func scTranslatePrefixWidths(_ text: String, fontPx: CGFloat) -> [CGFloat] { + let ns = text as NSString + let attr = NSAttributedString(string: text, attributes: [.font: NSFont.systemFont(ofSize: fontPx)]) + if ns.length == 0 { return [0] } + var widths: [CGFloat] = [0] + widths.reserveCapacity(ns.length + 1) + for i in 1...ns.length { + let prefix = attr.attributedSubstring(from: NSRange(location: 0, length: i)) + let line = CTLineCreateWithAttributedString(prefix) + widths.append(CGFloat(CTLineGetTypographicBounds(line, nil, nil, nil))) + } + return widths +} + +/// 逐字符宽度贪心折行(对齐 WrapText):断点 = CJK 字符后 / 空格后,拉丁长词 +/// 整体不下断时回退硬断;返回的行不含换行符。maxW 为可用文本宽度(逻辑像素)。 +private func scTranslateWrapText(_ text: String, fontPx: CGFloat, maxW: CGFloat, + prefixWidths: [CGFloat]) -> [String] { + let ns = text as NSString + let n = ns.length + if n == 0 || maxW <= 1 { + return n > 0 ? [text] : [] + } + var lines: [String] = [] + var lineStart = 0 + var lastBreak = 0 + var hasBreak = false + for i in 0.. lineStart { + lines.append(ns.substring(with: NSRange(location: lineStart, length: lastBreak - lineStart))) + lineStart = lastBreak + } else if i > lineStart { + lines.append(ns.substring(with: NSRange(location: lineStart, length: i - lineStart))) + lineStart = i + } else { + lines.append(ns.substring(with: NSRange(location: lineStart, length: 1))) + lineStart = i + 1 + } + hasBreak = false + lastBreak = lineStart + } + if lineStart < n { + lines.append(ns.substring(with: NSRange(location: lineStart, length: n - lineStart))) + } + if lines.isEmpty { lines.append(text) } + return lines +} + +/// 测单行渲染宽度(先去行尾空白,避免状态气泡按含尾随空格的宽度虚增)。 +/// 状态气泡(setTranslateStatus)的宽度测量用。 +private func scTranslateMeasureLineWidth(_ line: String, fontPx: CGFloat) -> CGFloat { + var s = Substring(line) + while let last = s.last, last == " " || last == "\t" { s.removeLast() } + if s.isEmpty { return 0 } + let attr = NSAttributedString(string: String(s), + attributes: [.font: NSFont.systemFont(ofSize: fontPx)]) + let cl = CTLineCreateWithAttributedString(attr) + return CGFloat(CTLineGetTypographicBounds(cl, nil, nil, nil)) +} + +/// 候选字号真实排版测量(对齐 MeasureAt):按段宽折行 → 行高/总高 → 高度 +/// 可行性。fitH 为高度可行域 = 段框高(非「框高 − 双内边距」:OCR 框紧贴 +/// 字形,单行段减去双内边距后连最小字号一行都放不下,字号会被钉死在下限; +/// 内边距成为垂直余量,由绘制侧垂直居中吸收)。 +private func scTranslateMeasureAt(_ text: String, fontPx: Int, + availW: CGFloat, fitH: CGFloat) -> ScLayoutMeasure { + var m = ScLayoutMeasure() + let fpx = CGFloat(fontPx) + let (lineH, _) = scTranslateFontMetrics(fpx) + m.lines = scTranslateWrapText(text, fontPx: fpx, maxW: availW, + prefixWidths: scTranslatePrefixWidths(text, fontPx: fpx)) + m.lineH = lineH + m.totalH = CGFloat(m.lines.count) * lineH + m.fitsHeight = m.totalH <= fitH + 0.5 + return m +} + +/// 字体排版行高比(typographic 行高 ÷ 字号,系统字体 ≈ 1.2):把「原文行距」 +/// 换算为字号的除数(行距 ÷ 行高比 = 与该行距观感一致的字号)。运行时用探针 +/// 字号实测一次,不依赖具体字体的硬编码行距。 +private func scTranslateLineHeightRatio() -> CGFloat { + let probe: CGFloat = 24 + let (lineH, _) = scTranslateFontMetrics(probe) + return lineH > 1 ? lineH / probe : 1.2 +} + +/// 段落字号搜索(对齐 FitParagraph;每段独立,绝不整图统一字号): +/// 字号由段落自身几何决定,不依赖「OCR 行框 → 原字号」的换算假设(行框语义 +/// 随 provider 而异,紧贴字形 / 整字高框都有,乘系数估算会在整字高框上系统性 +/// 高估,且同页正文行高一致导致各段字号被钉到同一个高估值——过大且统一): +/// 1) 可行性硬约束 = 真实排版总高不超段框高(fitH);字号对总高单调(字号 +/// 越大行高越大且行数不减),故先二分出最大可行字号 maxFit(译文比原文 +/// 长时收缩到装得下为止); +/// 2) 行距对齐上限 cap = 段框高 ÷ 原文行数 ÷ 行高比:译文的行距与原段落 +/// 行距一致,短译文(折行后行数少于原文)不会被放大去撑满段框; +/// 3) 字号 = min(maxFit, cap)。段框高与行数每段不同,字号随段落区域自适应。 +/// 整图兜底块(origLineCount = 0,无行级特征)用默认字号上限;连下限字号都 +/// 装不下时退回下限,溢出由绘制侧兜底。 +/// - Parameters: +/// - text: 段落译文 +/// - origLineCount: 原段落行数(聚类成员行数;兜底块为 0) +/// - availW: 可用文本宽度(段框宽 − 双内边距) +/// - fitH: 高度可行域(段框高,见 scTranslateMeasureAt) +/// - Returns: 最终字号 + 该字号下的完整测量(供最终排版) +private func scTranslateFitParagraph(_ text: String, origLineCount: Int, + availW: CGFloat, fitH: CGFloat) + -> (fontPx: Int, measure: ScLayoutMeasure) { + let fontMin = SC.translateFontMinPx + let fontMax = SC.translateFontMaxPx + let atMin = scTranslateMeasureAt(text, fontPx: fontMin, availW: availW, fitH: fitH) + if !atMin.fitsHeight { + return (fontMin, atMin) // 下限也装不下:退回下限,溢出走兜底 + } + var maxFit = fontMin + var lo = fontMin, hi = fontMax + while lo <= hi { + let mid = (lo + hi) / 2 + if scTranslateMeasureAt(text, fontPx: mid, availW: availW, fitH: fitH).fitsHeight { + maxFit = mid + lo = mid + 1 + } else { + hi = mid - 1 + } + } + var cap = fontMax + if origLineCount > 0 { + cap = Int((fitH / CGFloat(origLineCount) / scTranslateLineHeightRatio()).rounded(.down)) + } else { + cap = Int(SC.translateFallbackFontPx) // 兜底块无行级特征:默认字号上限 + } + if cap < fontMin { cap = fontMin } + if cap > fontMax { cap = fontMax } + let fontPx = min(maxFit, cap) + return (fontPx, scTranslateMeasureAt(text, fontPx: fontPx, availW: availW, fitH: fitH)) +} + +// MARK: - 译文面板与气泡绘制(覆盖层 OnPaint 与导出合成共用) + +/// 在已翻转(左上原点)的上下文中绘制单行译文(Core Text 标准翻转绘制法)。 +private func scTranslateDrawLine(_ ctx: CGContext, _ line: String, fontPx: CGFloat, + color: NSColor, x: CGFloat, baselineY: CGFloat) { + guard !line.isEmpty else { return } + let attr = NSAttributedString(string: line, attributes: [ + .font: NSFont.systemFont(ofSize: fontPx), + .foregroundColor: color, + ]) + let cl = CTLineCreateWithAttributedString(attr) + ctx.saveGState() + ctx.textMatrix = .identity + ctx.translateBy(x: x, y: baselineY) + ctx.scaleBy(x: 1, y: -1) + ctx.textPosition = .zero + CTLineDraw(cl, ctx) + ctx.restoreGState() +} + +/// 译文面板配色(对齐 DrawBlockPanel / DrawTranslateStatusBubble)。 +private enum ScTranslateStyle { + static let panelFill = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 250.0 / 255.0) + static let panelBorder = NSColor(srgbRed: 205.0 / 255.0, green: 205.0 / 255.0, + blue: 205.0 / 255.0, alpha: 1) + static let panelText = NSColor(srgbRed: 51.0 / 255.0, green: 51.0 / 255.0, + blue: 51.0 / 255.0, alpha: 1) + static let bubbleBg = NSColor(srgbRed: 41.0 / 255.0, green: 41.0 / 255.0, + blue: 41.0 / 255.0, alpha: 1) + static let bubbleText = NSColor.white + static let bubbleTextError = NSColor(srgbRed: 1, green: 160.0 / 255.0, + blue: 160.0 / 255.0, alpha: 1) +} + +/// 绘制单个译文覆盖面板(对齐 DrawBlockPanel):白底圆角面板盖住原段落区域, +/// 整段译文按搜索出的字号折行排版;字号搜索结果按「绘制时框宽高」缓存在块内 +/// (选区不变时每帧复用);连下限字号都装不下时面板才向下扩展(不超 clip)并 +/// 截断末行。tb.box/clip 为绝对逻辑坐标,ox/oy 为绘制平移(覆盖层 = -视图原点, +/// 导出 = -选区左上角),交集与排版均在平移后坐标系内进行。 +/// - Parameters: +/// - ctx: 已翻转(左上原点)的目标上下文 +/// - tb: 译文块(排版缓存随调用写回) +/// - clip: 选区矩形(绝对逻辑坐标,约束面板向下扩展的边界) +/// - ox/oy: 绘制平移量 +func scTranslateDrawBlockPanel(_ ctx: CGContext, _ tb: inout ScreenshotTranslateBlock, + clip: CGRect, ox: CGFloat, oy: CGFloat) { + // 与裁剪边界(选区)求交:选区缩小后落在界外的块整体跳过,跨界块按交集绘制 + let box = tb.box.offsetBy(dx: ox, dy: oy) + let clipOff = clip.offsetBy(dx: ox, dy: oy) + let boxI = box.intersection(clipOff) + if boxI.isNull { return } + let boxW = boxI.width + let boxH = boxI.height + if boxW < 2 || boxH < 2 { return } + let keyW = Int(boxW.rounded(.down)) + let keyH = Int(boxH.rounded(.down)) + + let pad = SC.translatePanelPad + var availW = boxW - pad * 2 + if availW < 8 { availW = 8 } + // 高度可行域 = 段框高(非框高 − 双内边距,见 scTranslateMeasureAt); + // 垂直内边距由绘制侧垂直居中吸收 + let fitH = boxH + let maxPanelBottom = clipOff.maxY + + // ---- 字号搜索(带缓存:键 = 绘制时框宽高,几何变化才重排)---- + if tb.fitKeyW != keyW || tb.fitKeyH != keyH || tb.fitLines.isEmpty + || tb.fitFontPx < SC.translateFontMinPx { + let (fontPx, m) = scTranslateFitParagraph(tb.text, origLineCount: tb.origLineCount, + availW: availW, fitH: fitH) + tb.fitKeyW = keyW + tb.fitKeyH = keyH + tb.fitFontPx = fontPx + tb.fitLineH = m.lineH + tb.fitLines = m.lines + } + + let fontPx = tb.fitFontPx + let lineH = tb.fitLineH > 0.5 ? tb.fitLineH : CGFloat(fontPx) + let lines = tb.fitLines + + // 面板高度:文字总高不超过段框高(字号搜索的可行性约束)时严格取原段落高 + // (覆盖框即原段框,保持原版面占位),文字垂直居中、垂直内边距让位; + // 下限字号仍溢出(可行性早退的兜底路径)才向下扩展到裁剪边界 + let textH = CGFloat(lines.count) * lineH + var panelH = boxH + if textH > panelH { + panelH = textH + pad * 2 + if boxI.minY + panelH > maxPanelBottom { panelH = maxPanelBottom - boxI.minY } + if panelH < boxH { panelH = boxH } + } + let padY = max(0, (panelH - textH) / 2) + + // 面板:近实心白底盖住原文字 + 浅灰描边 + let panel = CGRect(x: boxI.minX, y: boxI.minY, width: boxW, height: panelH) + let path = CGPath(roundedRect: panel, cornerWidth: SC.translatePanelRadius, + cornerHeight: SC.translatePanelRadius, transform: nil) + ctx.addPath(path) + ctx.setFillColor(ScTranslateStyle.panelFill.cgColor) + ctx.fillPath() + ctx.addPath(path) + ctx.setStrokeColor(ScTranslateStyle.panelBorder.cgColor) + ctx.setLineWidth(1) + ctx.strokePath() + + // 逐行绘制译文:默认全显(可行字号必在框内),仅兜底扩高被裁剪边界 + // 压回时按剩余高度截断,末行截断追加「…」 + let (_, ascent) = scTranslateFontMetrics(CGFloat(fontPx)) + var visible = lines.count + if CGFloat(visible) * lineH > panelH - padY * 2 + 0.5 { + visible = Int(((panelH - padY * 2) / lineH).rounded(.down)) + if visible < 1 { visible = 1 } + } + var y = panel.minY + padY + for i in 0.. 0, sel.height > 0 else { return } + + // ---- 物理分辨率裁剪选区(预截屏底图 → PNG base64,本线程完成)---- + guard let cropped = cropSelectionPhysical(sel), + let dataUri = scEncodePngDataUri(cropped) else { + setTranslateStatus("选区图像提取失败", isError: true) + invalidateAll() + return + } + + // ---- 转入进行中:高亮按钮 + 进度气泡 + 发射 OCR 请求后立即返回 ---- + translateState = .ocrPending + activeTool = .translate + setTranslateStatus("正在识别并翻译…", isError: false) + invalidateAll() + + let input = ScreenshotTranslateJobInput( + imageBase64: dataUri, + imageW: cropped.width, + imageH: cropped.height, + selection: CGRect(x: sel.minX.rounded(), y: sel.minY.rounded(), + width: sel.width.rounded(), height: sel.height.rounded()), + scale: baseFrame.scale) + let job = ScreenshotTranslateJob(jobId: scAllocateTranslateJobId(), input: input) + translateJob = job + scTLog("JOB_START", job: job.jobId, + "image \(input.imageW)x\(input.imageH), sel \(input.selection), scale \(input.scale)") + guard scLaunchProviderRequest(job: job, kind: .ocr, type: "ocr", + payload: ["image": input.imageBase64], + now: ProcessInfo.processInfo.systemUptime) != nil else { + failTranslateJob(job, error: "翻译服务调用失败") // 具体原因见 REQUEST_START 日志 + return + } + } + + /// 会话定时器逐拍任务(对齐 TickTranslateStatus + WM_SCREENSHOT_TRANSLATE_RESULT 接管): + /// 1) 邮箱 drain:provider 异步回投经 requestId 路由到当前任务(晚到/未知丢弃); + /// 2) deadline 扫描:在飞请求超时 → CancelRequest + 按失败收束该请求; + /// 3) 错误气泡 TTL 到期收起(局部失效)。 + /// - Parameter now: 单调时钟(ProcessInfo.systemUptime) + func tickTranslate(now: TimeInterval) { + guard isRunning else { return } + handleProviderResults(now: now) + if let job = translateJob, translateState.isBusy { + scanTranslateDeadlines(job: job, now: now) + } + if translateStatusShown && translateStatusError + && now - translateStatusAt >= SC.translateErrorTtlSec { + let rect = scInflate(translateStatusRect, 2) + translateStatusShown = false + translateStatusText = "" + invalidate(rect) + } + } + + /// 邮箱 drain + 逐条路由(会话主线程)。requestId 不在当前任务在飞表 = 晚到/已取消/ + /// 已换代 → 丢弃(REQUEST_DISCARD)。 + private func handleProviderResults(now: TimeInterval) { + let results = ScProviderResultMailbox.shared.drain() + guard !results.isEmpty else { return } + for result in results { + guard let job = translateJob, translateState.isBusy, + let request = job.inFlight[result.requestId] else { + scTLog("REQUEST_DISCARD", req: result.requestId, + "no active request (late/cancelled/job-replaced), ok=\(result.ok)") + continue + } + job.inFlight.removeValue(forKey: result.requestId) // 恰好一次消费 + let elapsedMs = Int((result.deliveredAt - request.startedAt) * 1000) + let paraIdx: Int? + if case .paragraph(let i) = request.kind { paraIdx = i } else { paraIdx = nil } + var reqType = "translation" + if case .ocr = request.kind { reqType = "ocr" } + scTLog(result.ok ? "REQUEST_CALLBACK ok" : "REQUEST_CALLBACK error", + job: job.jobId, req: result.requestId, + type: reqType, para: paraIdx, + "elapsed=\(elapsedMs)ms") + switch request.kind { + case .ocr: + handleOcrCallback(job: job, request: request, result: result, now: now) + case .paragraph, .wholeText: + handleTranslationCallback(job: job, request: request, result: result) + } + } + } + + /// OCR 回投处理:聚类/兜底分流(clustering 瞬态)→ 进入 TranslationPending 并 + /// 首轮泵队列;失败直接收束任务。 + private func handleOcrCallback(job: ScreenshotTranslateJob, request: ScTranslateRequest, + result: ScProviderAsyncResult, now: TimeInterval) { + guard translateState == .ocrPending else { + scTLog("REQUEST_DISCARD", job: job.jobId, req: request.requestId, + "ocr result after state left ocrPending") + return // 防御:状态已被接管(超时收束/取消后晚到) + } + switch scProcessOcrResult(job: job, request: request, result: result) { + case .paragraphs: + translateState = .clustering // 瞬态:聚类已在 scProcessOcrResult 同步完成 + translateState = .translationPending + pumpTranslationQueue(job: job, now: now) + case .wholeFallback: + translateState = .translationPending + pumpTranslationQueue(job: job, now: now) + case .failed(let error): + failTranslateJob(job, error: error) + } + } + + /// 段落翻译回投处理:按 requestId→paragraphIndex 写回(不要求按序返回); + /// 单段失败/空译文跳过(对齐「单段失败跳过,全失败才报错」)。 + private func handleTranslationCallback(job: ScreenshotTranslateJob, + request: ScTranslateRequest, + result: ScProviderAsyncResult) { + let index: Int + switch request.kind { + case .paragraph(let i): index = i + case .wholeText: index = 0 + case .ocr: return // 不可达(路由已分流) + } + if !result.ok { + job.paraFailed += 1 + job.lastError = result.error + scTLog("PROVIDER_ERROR", job: job.jobId, req: result.requestId, + type: "translation", para: index, result.error) + } else if let text = scExtractTranslatedText(scParseProviderJson(result.valueJson)) { + job.paraResults[index] = text + } else { + job.paraFailed += 1 + job.lastError = "翻译结果缺少 text 字段" + scTLog("PROVIDER_ERROR", job: job.jobId, req: result.requestId, + type: "translation", para: index, job.lastError) + } + // 队列补发 + 收束检查(收束在 pumpTranslationQueue 内统一判定) + pumpTranslationQueue(job: job, now: ProcessInfo.processInfo.systemUptime) + } + + /// 翻译队列泵:从 pending 队列补发请求维持 maxConcurrency 有限并发;全部收束 + /// (发完 + 无在飞)时进入 Layout 瞬态并最终化任务。 + private func pumpTranslationQueue(job: ScreenshotTranslateJob, now: TimeInterval) { + guard translateState == .translationPending, translateJob?.jobId == job.jobId else { + return // 防御:任务已取消/失败/换代 + } + while job.inFlight.count < SC.translateMaxConcurrency { + if job.isWholeFallback { + // 兜底单段:仅首次进入时发射一次 + if !job.inFlight.isEmpty || job.paraResults[0] != nil || job.paraFailed > 0 { + break + } + let text = job.wholeFallbackText + if scLaunchProviderRequest(job: job, kind: .wholeText, type: "translation", + payload: ["text": text], now: now) == nil { + job.paraFailed += 1 + job.lastError = "翻译服务调用失败" + } + break + } + guard job.nextPara < job.paragraphs.count else { break } + let index = job.nextPara + job.nextPara += 1 + if scLaunchProviderRequest(job: job, kind: .paragraph(index), type: "translation", + payload: ["text": job.paragraphs[index].text], + now: now) == nil { + // 受理失败按该段失败收束(不阻断其余段落;具体原因见 REQUEST_START 日志) + job.paraFailed += 1 + job.lastError = "翻译服务调用失败" + } + } + if job.translationDrained { + finishTranslationJob(job) + } + } + + /// deadline 扫描:在飞请求到期 → CancelRequest(桥移除登记,晚到回投被丢弃) + /// + 按请求种类收束失败路径(OCR 超时 → 任务失败;段落超时 → 该段失败并补发)。 + private func scanTranslateDeadlines(job: ScreenshotTranslateJob, now: TimeInterval) { + var timedOut: [ScTranslateRequest] = [] + for (_, request) in job.inFlight where now >= request.deadline { + timedOut.append(request) + } + guard !timedOut.isEmpty else { return } + var jobFailed = false + for request in timedOut { + job.inFlight.removeValue(forKey: request.requestId) + scNativeProviderCancel?(request.requestId) + let elapsedMs = Int((now - request.startedAt) * 1000) + let paraIdx: Int? + if case .paragraph(let i) = request.kind { paraIdx = i } else { paraIdx = nil } + var reqType = "translation" + if case .ocr = request.kind { reqType = "ocr" } + scTLog("REQUEST_TIMEOUT", job: job.jobId, req: request.requestId, + type: reqType, para: paraIdx, + "elapsed=\(elapsedMs)ms, cancelled (late result will be discarded)") + switch request.kind { + case .ocr: + failTranslateJob(job, error: "文字识别超时") + jobFailed = true + case .paragraph, .wholeText: + job.paraFailed += 1 + job.lastError = "翻译超时" + } + } + guard !jobFailed else { return } + // 段落超时后补发 + 收束检查 + pumpTranslationQueue(job: job, now: now) + } + + /// 任务最终化(Layout 瞬态):构块 + 坐标换算 → 成功进入 Shown; + /// 全失败/空块 → 失败收束。 + private func finishTranslationJob(_ job: ScreenshotTranslateJob) { + guard translateJob?.jobId == job.jobId else { return } // 防御:已换代 + translateState = .layout + let (raw, allFailed, lastError) = scCollectTranslatedRawBlocks(job) + let blocks = scConvertToDisplayBlocks(raw, job.input) + let elapsedMs = Int((ProcessInfo.processInfo.systemUptime - job.startedAt) * 1000) + if allFailed { + failTranslateJob(job, error: "翻译失败:" + lastError) + return + } + if blocks.isEmpty { + failTranslateJob(job, error: "未识别到文字") + return + } + translateBlocks = blocks + translateState = .shown + translateJob = nil + // 收起进度气泡(保留 activeTool 高亮:覆盖展示中按钮保持激活观感) + translateStatusShown = false + translateStatusText = "" + scTLog("JOB_COMPLETE", job: job.jobId, + "blocks=\(blocks.count) failedParas=\(job.paraFailed) elapsed=\(elapsedMs)ms") + invalidateAll() + } + + /// 任务失败收束:取消全部在飞请求(晚到回投经 requestId 反查落空丢弃)→ 状态 + /// 回 Idle + 错误气泡(TTL 自动消失)。 + private func failTranslateJob(_ job: ScreenshotTranslateJob, error: String) { + cancelJobRequests(job) + translateJob = nil + translateState = .idle + activeTool = nil + scTLog("JOB_FAILED", job: job.jobId, error) + setTranslateStatus(error, isError: true) + invalidateAll() + } + + /// 取消一个任务的全部在飞请求(桥侧登记移除;不触发回调,晚到结果被桥丢弃)。 + private func cancelJobRequests(_ job: ScreenshotTranslateJob) { + for (_, request) in job.inFlight { + scNativeProviderCancel?(request.requestId) + } + job.inFlight.removeAll() + } + + /// 翻译任务取消(对齐 TeardownTranslateJobs):会话收尾 / 退出翻译态时调用。 + /// 之后到达的回投经 requestId 反查(translateJob 已置 nil / 状态非进行中)落空, + /// 在 handleProviderResults 中按晚到结果丢弃,绝不重新改变 UI 状态。 + func cancelTranslateJob() { + guard let job = translateJob else { return } + let inFlightCount = job.inFlight.count + for (_, request) in job.inFlight { + scNativeProviderCancel?(request.requestId) + } + job.inFlight.removeAll() + translateJob = nil + translateState = .idle + scTLog("JOB_CANCEL", job: job.jobId, "\(inFlightCount) in-flight cancelled") + // 邮箱残留(回投先于取消入队)随下一次 drain 丢弃;无会话内消费者时靠上限防膨胀 + } + + /// 设置状态气泡文本并计算气泡矩形(对齐 SetTranslateStatus):水平居中于选区、 + /// 置于选区上沿外侧(放不下转选区内顶部),钳制虚拟屏内。调用方负责触发重绘。 + private func setTranslateStatus(_ text: String, isError: Bool) { + translateStatusText = text + translateStatusError = isError + translateStatusShown = !text.isEmpty + translateStatusRect = .null + translateStatusAt = ProcessInfo.processInfo.systemUptime + guard translateStatusShown else { return } + + let fontPx = SC.translateBubbleFontPx + let textW = scTranslateMeasureLineWidth(text, fontPx: fontPx) + let (lineH, _) = scTranslateFontMetrics(fontPx) + let w = ceil(textW) + SC.translateBubblePadX * 2 + 2 + let h = ceil(lineH) + SC.translateBubblePadY * 2 + 2 + let sel = selection + var x = sel.minX + (sel.width - w) / 2 + if x < 4 { x = 4 } + if x + w > virtualBounds.maxX - 4 { x = virtualBounds.maxX - 4 - w } + var y = sel.minY - h - 6 + if y < 4 { y = sel.minY + 6 } // 上方放不下:转选区内顶部 + translateStatusRect = CGRect(x: x, y: y, width: w, height: h) + } + + /// 译文覆盖层绘制入口(paintConfirmedOverlay 在标注/文字编辑层之后、确认边框 + /// 之前调用,对齐 Windows OnPaint 的 DrawTranslateOverlay 层序;确认/绘制/文字 + /// 编辑各态均保持展示)。 + /// - Parameters: + /// - ctx: 视图 CG 上下文(已翻转,左上原点) + /// - view: 当前绘制的覆盖层视图 + func paintTranslateOverlay(ctx: CGContext, view: OverlayScreenshotView) { + let ox = -view.cgOrigin.x + let oy = -view.cgOrigin.y + if translateState == .shown && !translateBlocks.isEmpty { + for i in translateBlocks.indices { + scTranslateDrawBlockPanel(ctx, &translateBlocks[i], clip: selection, ox: ox, oy: oy) + } + } + if translateStatusShown && !translateStatusText.isEmpty && !translateStatusRect.isNull { + scTranslateDrawStatusBubble(ctx, rect: translateStatusRect.offsetBy(dx: ox, dy: oy), + text: translateStatusText, isError: translateStatusError) + } + } + + /// 导出合成入口(对齐 CompositeTranslateBlocks,确认提取/保存落盘共用):把译文 + /// 覆盖块合成进最终图像。调用方已完成上下文翻转(左上原点)与 scaleBy—— + /// 坐标系 = 选区左上角原点、逻辑点;排版缓存为一次性拷贝,不回写会话状态。 + /// - Parameters: + /// - ctx: 输出位图上下文(已翻转、已缩放) + /// - sel: 选区(CG 全局逻辑坐标) + func compositeTranslateBlocks(_ ctx: CGContext, sel: CGRect) { + guard translateState == .shown, !translateBlocks.isEmpty else { return } + var blocks = translateBlocks + for i in blocks.indices { + scTranslateDrawBlockPanel(ctx, &blocks[i], clip: sel, ox: -sel.minX, oy: -sel.minY) + } + } +} + +// MARK: - PNG base64 编码辅助 + +/// CGImage → PNG data URI(对齐 Windows BitmapToBase64Png 的编码角色; +/// ImageIO 单次编码,base64 拼接 data URL 前缀)。 +/// - Parameter image: 物理像素图(选区裁剪产物) +/// - Returns: "data:image/png;base64,...";编码失败返回 nil +func scEncodePngDataUri(_ image: CGImage) -> String? { + guard let data = CFDataCreateMutable(nil, 0), + let dest = CGImageDestinationCreateWithData(data, "public.png" as CFString, 1, nil) + else { return nil } + CGImageDestinationAddImage(dest, image, nil) + guard CGImageDestinationFinalize(dest) else { return nil } + return "data:image/png;base64," + (data as Data).base64EncodedString() +} diff --git a/src/screenshot/macos/translate_bridge_mac.cpp b/src/screenshot/macos/translate_bridge_mac.cpp new file mode 100644 index 0000000..a61c682 --- /dev/null +++ b/src/screenshot/macos/translate_bridge_mac.cpp @@ -0,0 +1,100 @@ +// 截图翻译算法层 C ABI shim 实现(translate_cluster 的 macOS 桥)。 +// 仅做参数打包/解包与内存分配转发,业务逻辑全部在 algo/translate_cluster.cpp。 +// 仅 macOS 构建链编译(build-swift.sh / CI)。 +#include "translate_bridge_mac.h" + +#include +#include +#include + +#include "../algo/translate_cluster_internal.h" + +int32_t tc_abi_version(void) { + return TC_ABI_VERSION; +} + +int32_t tc_cluster_lines(const TcLineC* lines, int32_t lineCount, + TcClusterResultC* out) { + if (out == nullptr || (lines == nullptr && lineCount > 0) || lineCount < 0) { + return 0; + } + std::vector input; + input.reserve(static_cast(lineCount)); + for (int32_t i = 0; i < lineCount; i++) { + TcLine tl; + tl.text = lines[i].text ? lines[i].text : ""; + tl.x = lines[i].x; + tl.y = lines[i].y; + tl.w = lines[i].w; + tl.h = lines[i].h; + input.push_back(std::move(tl)); + } + TcClusterResult clustered = TcClusterLines(input); + + // 输出数组一次性 calloc,成员字符串/下标数组逐段分配;任一分配失败整体回滚, + // 保证 out 要么完整可用、要么原样清零(无部分填充的悬垂指针)。 + TcClusterResultC result = {}; + result.paragraphCount = static_cast(clustered.paragraphs.size()); + result.medianLineH = clustered.medianLineH; + result.medianPitch = clustered.medianPitch; + result.boundaryCount = clustered.boundaryCount; + if (!clustered.paragraphs.empty()) { + TcParagraphC* paras = static_cast( + calloc(clustered.paragraphs.size(), sizeof(TcParagraphC))); + if (paras == nullptr) { + return 0; + } + bool ok = true; + for (size_t i = 0; i < clustered.paragraphs.size() && ok; i++) { + const TcParagraph& p = clustered.paragraphs[i]; + char* text = static_cast(calloc(p.text.size() + 1, 1)); + int32_t* idx = static_cast( + calloc(p.lineIdx.size() > 0 ? p.lineIdx.size() : 1, sizeof(int32_t))); + if (text == nullptr || idx == nullptr) { + free(text); + free(idx); + ok = false; + break; + } + memcpy(text, p.text.c_str(), p.text.size()); + for (size_t k = 0; k < p.lineIdx.size(); k++) { + idx[k] = p.lineIdx[k]; + } + paras[i].x = p.x; + paras[i].y = p.y; + paras[i].w = p.w; + paras[i].h = p.h; + paras[i].lineHeight = p.lineHeight; + paras[i].text = text; + paras[i].lineIdx = idx; + paras[i].lineCount = static_cast(p.lineIdx.size()); + } + if (!ok) { + for (size_t i = 0; i < clustered.paragraphs.size(); i++) { + free(paras[i].text); + free(paras[i].lineIdx); + } + free(paras); + return 0; + } + result.paragraphs = paras; + } + *out = result; + return 1; +} + +void tc_cluster_free_result(TcClusterResultC* result) { + if (result == nullptr) return; + if (result->paragraphs != nullptr) { + for (int32_t i = 0; i < result->paragraphCount; i++) { + free(result->paragraphs[i].text); + free(result->paragraphs[i].lineIdx); + } + free(result->paragraphs); + } + result->paragraphs = nullptr; + result->paragraphCount = 0; + result->medianLineH = 0; + result->medianPitch = 0; + result->boundaryCount = 0; +} diff --git a/src/screenshot/macos/translate_bridge_mac.h b/src/screenshot/macos/translate_bridge_mac.h new file mode 100644 index 0000000..52bfa59 --- /dev/null +++ b/src/screenshot/macos/translate_bridge_mac.h @@ -0,0 +1,74 @@ +// 截图翻译算法层 C ABI shim(translate_cluster 的 macOS 桥)。 +// +// 定位:把 algo/translate_cluster.cpp(纯算法层,只依赖 C++ 标准库)的 +// TcClusterLines 打包为固定布局的 C 接口,供 macOS 侧 Swift(经 @_silgen_name) +// 链入 libZToolsNative.dylib 调用。本层不含任何业务逻辑——只做参数打包/解包 +// 转发,纪律与 lc_bridge_mac.h 一致:结构体全部固定宽度标量、字段顺序即内存 +// 布局(自然对齐)、Swift 侧按同序镜像。仅在 macOS 构建链(build-swift.sh / +// CI)编译;Windows 侧直接编入 .node(binding.gyp win 分支),不包含本文件。 +// +// 内存纪律: +// · 输入 TcLineC.text 指针由调用方持有,仅在 tc_cluster_lines 调用期间有效; +// · 输出 TcClusterResultC 内的 paragraphs/lineIdx/text 均由本层 calloc 分配, +// 调用方用毕必须以 tc_cluster_free_result 整体释放(free 单个字段等价, +// 均为 malloc 家族分配);tc_cluster_lines 对 out 中残留指针先做防御性释放。 +#ifndef TRANSLATE_BRIDGE_MAC_H +#define TRANSLATE_BRIDGE_MAC_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// ABI 版本(结构体布局或语义变更时递增;Swift 侧启动时可校验)。 +#define TC_ABI_VERSION 1 + +// 聚类输入:一行 OCR 文本(提交图像内像素坐标;镜像 algo 的 TcLine, +// text 为 UTF-8 C 字符串,调用期间有效)。 +typedef struct TcLineC { + const char* text; + double x; + double y; + double w; + double h; +} TcLineC; + +// 聚类产出的一个段落(镜像 TcParagraph;lineIdx 为成员行下标数组, +// text/lineIdx 由本层分配,随 tc_cluster_free_result 释放)。 +typedef struct TcParagraphC { + double x; // 成员行包围盒并集(提交图像内像素坐标) + double y; + double w; + double h; + double lineHeight; // 段落原文行高估计(成员行高中位数) + char* text; // 成员行文本按 CJK/拉丁规则拼接的结果(UTF-8) + int32_t* lineIdx; // 成员行下标(输入 lines 的原始下标,按阅读序) + int32_t lineCount; // 成员行数(lineIdx 长度;版本面特征统计用) +} TcParagraphC; + +// 聚类结果 + 全局统计(镜像 TcClusterResult)。 +typedef struct TcClusterResultC { + int32_t paragraphCount; // paragraphs 数组长度 + TcParagraphC* paragraphs; // 段落数组(本层分配) + double medianLineH; // 页面正常行高估计(全部行高中位数) + double medianPitch; // 页面正常行距估计 + int32_t boundaryCount; // 判为段落边界的相邻对数量 +} TcClusterResultC; + +// ABI 版本查询(返回 TC_ABI_VERSION)。 +int32_t tc_abi_version(void); + +// 主入口:OCR 文本行数组 → 段落数组(转发 TcClusterLines 纯函数)。 +// 返回 1 成功(out 已填充)/ 0 参数非法(out 不变)。 +int32_t tc_cluster_lines(const TcLineC* lines, int32_t lineCount, + TcClusterResultC* out); + +// 释放 tc_cluster_lines 产出的结果(对已清零/从未填充的 out 安全 no-op)。 +void tc_cluster_free_result(TcClusterResultC* result); + +#ifdef __cplusplus +} // extern "C" +#endif + +#endif // TRANSLATE_BRIDGE_MAC_H diff --git a/src/screenshot/windows/sc_types.h b/src/screenshot/windows/sc_types.h index cbbc621..2d1921e 100644 --- a/src/screenshot/windows/sc_types.h +++ b/src/screenshot/windows/sc_types.h @@ -219,19 +219,14 @@ enum TranslateOverlayState { }; // 一个译文覆盖块:OCR 识别出的原文字区域(绝对虚拟屏幕逻辑坐标,与标注同坐标系, -// 选区移动/缩放时保持不动)+ 译文文本 + 原段落版面特征。绘制与导出合成见 -// translate_windows.cpp:字号按段落独立搜索(密度对齐原段落,见 FitParagraph), -// 搜索含多次真实排版测量,结果缓存在 fit* 字段(键 = 绘制时框宽高)。 +// 选区移动/缩放时保持不动)+ 译文文本 + 原段落行数。绘制与导出合成见 +// translate_windows.cpp:字号按段落独立搜索(行距对齐 + 框高可行性收缩,见 +// FitParagraph),搜索含多次真实排版测量,结果缓存在 fit* 字段(键 = 绘制时框宽高)。 struct TranslateBlock { RECT box; // 原文字区域(绝对虚拟屏幕逻辑坐标,已与选区求交) std::wstring text; // 译文 - double lineHeightPx = 0; // 原文单行行高估计(逻辑像素;成员行高中位数, - // 作为原字号估计 / 字号搜索锚点的基准) - // ---- 原段落版面特征(来自聚类成员行;整图兜底块 origLineCount = 0 表示未知)---- - int origLineCount = 0; // 原段落行数 - double origWidthUtil = 0; // 原平均行宽利用率 = 成员行宽均值 / 段宽 - double origVDensity = 0; // 原垂直密度 = 行数 × 中位行高 / 段高 + int origLineCount = 0; // 原段落行数(整图兜底块 = 0 表示未知,用默认字号上限) // ---- 排版缓存(绘制线程写;fitKeyW/H 命中时复用以下三字段,免每帧重排)---- int fitKeyW = -1, fitKeyH = -1; // 缓存键:绘制时(与选区交集后的)框宽高 int fitFontPx = 0; // 搜索定出的最终字号 diff --git a/src/screenshot/windows/translate_windows.cpp b/src/screenshot/windows/translate_windows.cpp index 31aeb24..c7eda7b 100644 --- a/src/screenshot/windows/translate_windows.cpp +++ b/src/screenshot/windows/translate_windows.cpp @@ -13,17 +13,16 @@ // 解决 OCR 按行输出导致的段落信息丢失),再对每个段落调用一次 // type="translation"(契约入参为单条 text,段落文本已按 CJK/拉丁规则拼接; // 段为单位串行调用以尊重 provider 限流),在原生层把译文与对应段落的成员行 -// 联合框合并成可渲染块,段落原文行高中位数随块带给渲染层定字号; +// 联合框合并成可渲染块,段落原文行数随块带给渲染层定字号; // OCR provider 未返回坐标(如纯文本 AI 识别)时走整图兜底:整段文本一次 // 翻译、以整个选区为单一面板; // 3. 结果打包后经「单飞行结果槽 + PostMessage」回投截图线程:译文块坐标换算回 -// 选区绝对逻辑坐标存入 ctx(连同原段落版面特征:行数/行宽利用率/垂直密度), -// 进入 TRL_Shown;OnPaint 在标注之上绘制译文覆盖面板(白底盖住原文字), -// 译文字号按段落独立搜索:以原字号估计为锚点,可行性(总高不超段高)二分 + -// 候选真实排版测量评分,取综合视觉密度最接近原段落者(见 sctranslatedraw:: -// FitParagraph),确认/保存导出时同样合成进最终图像。TRL_Shown 下再次点击 -// 「翻译」为切换退出:清除覆盖回到 TRL_Idle 并取消按钮激活,不重跑翻译; -// 需重译时再点一次重新发起即可。 +// 选区绝对逻辑坐标存入 ctx(连同原段落行数),进入 TRL_Shown;OnPaint 在标注 +// 之上绘制译文覆盖面板(白底盖住原文字),译文字号按段落独立搜索:min(能装进 +// 段框的最大字号, 原文行距对应字号)——行距对齐保证观感与原版面一致、可行性 +// 收缩保证译文变长时放得下(见 sctranslatedraw::FitParagraph),确认/保存导出 +// 时同样合成进最终图像。TRL_Shown 下再次点击「翻译」为切换退出:清除覆盖 +// 回到 TRL_Idle 并取消按钮激活,不重跑翻译;需重译时再点一次重新发起即可。 // // provider 约定(宿主 JS 侧 ProviderBridge.start 的 handler 直接映射 providerManager.invoke): // ocr: 入参 {"image":"data:image/png;base64,..."} @@ -333,17 +332,13 @@ void TeardownTranslateJobs() { // ==================== provider 响应解析与调用辅助 ==================== // OCR 文本行(提交图像内的像素坐标;text 先为 OCR 原文,翻译成功后替换为译文)。 -// lineH 为该块的原文单行行高估计(段落块 = 成员行高中位数),orig* 为原段落 -// 版面特征(行数 / 行宽利用率 / 垂直密度,由聚类成员行统计),随块流向下 -// 渲染层作为字号搜索的密度对齐目标(兜底块 origLineCount = 0 表示未知)。 +// origLineCount 为原段落行数(段落块 = 聚类成员行数),随块流向下渲染层,作为 +// 行距对齐字号搜索的输入(兜底块 origLineCount = 0 表示未知)。 struct OcrRawBlock { std::string text; double x = 0, y = 0, w = 0, h = 0; - double lineH = 0; int origLineCount = 0; - double origWidthUtil = 0; - double origVDensity = 0; }; // 从 ocr 响应解析文本行:{"text":"..","blocks":[{text,left,top,right,bottom},..],"confidence":..}。 @@ -484,9 +479,16 @@ static void TranslateWorkerMain(TranslateJobInput* job) { std::vector plainTexts; // 无坐标的文本(兜底用) ParseOcrBlocks(ocrRoot, lines, plainTexts); if (lines.empty() && plainTexts.empty()) { - ZLOG_INFO("translate", "ocr returned no text"); - res->error = L"未识别到文字"; - break; + // 仅整段 text(无 blocks,纯文本 AI 识别契约)仍可走 2b 整图兜底; + // 连 text 也无内容才是真正的「未识别到文字」 + const auto* t = ocrRoot.find("text"); + bool hasWholeText = t && t->type == scminijson::Value::STR + && t->str.find_first_not_of(" \t\r\n") != std::string::npos; + if (!hasWholeText) { + ZLOG_INFO("translate", "ocr returned no text"); + res->error = L"未识别到文字"; + break; + } } ZLOG_INFO("translate", "ocr ok (%zu line blocks, %zu plain)", lines.size(), plainTexts.size()); @@ -532,22 +534,7 @@ static void TranslateWorkerMain(TranslateJobInput* job) { tb.y = para.y; tb.w = para.w; tb.h = para.h; - tb.lineH = para.lineHeight; - // 原段落版面特征(渲染层字号搜索的密度对齐目标): - // 行数 = 成员行数;行宽利用率 = 成员行宽均值 / 段宽(原段行均多满); - // 垂直密度 = 行数 × 中位行高 / 段高(原段纵向多满,含行距观感) - int pn = (int)para.lineIdx.size(); - double sumLineW = 0; - for (int li : para.lineIdx) sumLineW += clusterInput[li].w; - tb.origLineCount = pn; - if (para.w > 0.5 && pn > 0) { - tb.origWidthUtil = (sumLineW / pn) / para.w; - if (tb.origWidthUtil > 1) tb.origWidthUtil = 1; - } - if (para.h > 0.5 && para.lineHeight > 0) { - tb.origVDensity = (pn * para.lineHeight) / para.h; - if (tb.origVDensity > 1) tb.origVDensity = 1; - } + tb.origLineCount = (int)para.lineIdx.size(); // 原段落行数(渲染层字号搜索用) tb.text = std::move(outText); translated.push_back(std::move(tb)); } @@ -605,10 +592,7 @@ static void TranslateWorkerMain(TranslateJobInput* job) { tb.box = clipped; tb.text = Utf8ToWideForTranslate(rb.text); if (tb.text.find_first_not_of(L" \t\r\n") == std::wstring::npos) continue; - if (rb.lineH > 0) tb.lineHeightPx = rb.lineH / ds; tb.origLineCount = rb.origLineCount; - tb.origWidthUtil = rb.origWidthUtil; - tb.origVDensity = rb.origVDensity; res->blocks.push_back(std::move(tb)); } if (res->blocks.empty()) { @@ -714,39 +698,25 @@ namespace sctranslatedraw { static const int PANEL_PAD = 4; static const int PANEL_RADIUS = 4; -// 译文字号搜索区间(逻辑像素):以原字号估计为锚点,在区间内做可行性二分 + -// 候选评分(见 FitParagraph),锚点与端点均只作约束/兜底,不直接决定字号。 +// 译文字号搜索区间(逻辑像素):min(能装进段框的最大字号, 原文行距对应字号), +// 见 FitParagraph;端点只作钳制,不直接决定字号。 static const int FONT_MIN_PX = 9; static const int FONT_MAX_PX = 48; -// ---- 原段落版面特征:字号搜索的「密度对齐」目标 ---- -// 由 OCR 行聚类随块带下(TranslateBlock.orig*,worker 侧从成员行统计)。 -// boxW/boxH 为绘制时实际框(与选区交集后),利用率类特征均相对该框。 - -struct ParaFeatures { - bool valid = false; // 是否具备行级特征(整图兜底块 = false,用合成密度目标) - float boxW = 0.0f; - float boxH = 0.0f; - float fontPx = 0.0f; // 原字号估计(中位行高 − 双内边距;软约束锚点) - int lineCount = 0; // 原行数 - float widthUtil = 0.0f; // 原平均行宽利用率(成员行宽均值 / 段宽) - float vDensity = 0.0f; // 原垂直密度(行数 × 中位行高 / 段高) -}; +// 整图兜底块(无行级特征)的默认字号上限。 + +static const int FALLBACK_FONT_PX = 16; // ---- 某一候选字号下的整段真实排版测量 ---- -// 按段落宽度真实折行后统计:实际行数 / 总高 / 逐行实际宽度,并派生各项利用率 -// 与高度可行性,供候选评分与最终排版复用。 +// 按段落宽度真实折行后统计:实际行数 / 行高 / 总高与高度可行性,供字号搜索 +// 与最终排版复用。 struct LayoutMeasure { std::vector lines; // 折行结果 - std::vector lineWidths; // 每行实际宽(去行尾空白) float lineH = 0.0f; // 行高(font.GetHeight,含字体默认行距) float totalH = 0.0f; // 行数 × 行高 - float widthUtil = 0.0f; // 平均行宽 / 段宽 - float heightUtil = 0.0f; // 总高 / 段高 - float lastUtil = 0.0f; // 末行宽 / 段宽 - bool fitsHeight = false; // 总高 ≤ 文本区高(段高 − 双内边距) + bool fitsHeight = false; // 总高 ≤ 段框高(垂直内边距由绘制侧让位) }; // 用逐字符宽度贪心折行。字符宽按「前缀累宽差分」测量(与 GDI+ 渲染宽度同源), @@ -805,78 +775,52 @@ static std::vector WrapText(Gdiplus::Graphics& g, const Gdiplus::F return lines; } -// 测单行渲染宽度(先去行尾空白,避免折行断在空格后虚增利用率)。 -// 与 WrapText 的前缀累宽同用 MeasureString + MeasureTrailingSpaces,测量同源。 - -static float MeasureLineWidth(Gdiplus::Graphics& g, const Gdiplus::Font& font, - const std::wstring& line) { - size_t end = line.size(); - while (end > 0 && (line[end - 1] == L' ' || line[end - 1] == L'\t')) end--; - if (end == 0) return 0.0f; - Gdiplus::StringFormat sf; - sf.SetFormatFlags(Gdiplus::StringFormatFlagsMeasureTrailingSpaces); - Gdiplus::RectF layout(0.0f, 0.0f, 65536.0f, 65536.0f), bound; - g.MeasureString(line.c_str(), (INT)end, &font, layout, &sf, &bound); - return bound.X + bound.Width; -} - -// 候选字号真实排版测量:按段宽折行 → 行高/总高/逐行宽 → 各利用率与高度可行性。 -// availW/availH 为文本区(框 − 双内边距),利用率相对整框(与原段特征同基准)。 +// 候选字号真实排版测量:按段宽折行 → 行高/总高 → 高度可行性。 +// availW 为文本区宽(框 − 双内边距);fitH 为高度可行域 = 段框高(非 +// 「框高 − 双内边距」:OCR 框紧贴字形,单行段减去双内边距后连最小字号一行 +// 都放不下,字号会被钉死在下限;内边距成为垂直余量,由绘制侧垂直居中吸收)。 static LayoutMeasure MeasureAt(Gdiplus::Graphics& g, const Gdiplus::FontFamily& ff, const std::wstring& text, int fontPx, - const ParaFeatures& pf, float availW, float availH) { + float availW, float fitH) { LayoutMeasure m; Gdiplus::Font font(&ff, (Gdiplus::REAL)fontPx, Gdiplus::FontStyleRegular, Gdiplus::UnitPixel); m.lines = WrapText(g, font, text, availW); m.lineH = font.GetHeight(&g); if (m.lineH < 1.0f) m.lineH = (float)fontPx; m.totalH = m.lines.size() * m.lineH; - m.fitsHeight = m.totalH <= availH + 0.5f; - if (pf.boxW > 1.0f && pf.boxH > 1.0f) { - float sumW = 0.0f; - m.lineWidths.reserve(m.lines.size()); - for (const std::wstring& line : m.lines) { - float w = MeasureLineWidth(g, font, line); - m.lineWidths.push_back(w); - sumW += w; - } - m.widthUtil = sumW / (float)m.lines.size() / pf.boxW; - if (m.widthUtil > 1.0f) m.widthUtil = 1.0f; - m.heightUtil = m.totalH / pf.boxH; - m.lastUtil = m.lineWidths.back() / pf.boxW; - } + m.fitsHeight = m.totalH <= fitH + 0.5f; return m; } -// 综合视觉密度评分(越小越优):目标不是最大字号,而是排版观感最接近原段落。 -// 各项均为相对量纲(比例差 / 相对字号差 / 比例罚项),权重经验取值: -// 垂直密度差 1.0(最直观的纵向满/空观感)> 字号锚点偏离 0.75(不明显偏离 -// 原字号)> 行宽利用率差 0.5 > 末行过短罚项 0.4(≥2 行且末行宽占比 -// < 0.15 时,对不足部分线性罚,避免孤字/孤词收尾)。 - -static float ScoreMeasure(const LayoutMeasure& m, int fontPx, const ParaFeatures& pf) { - float s = 1.00f * fabsf(m.heightUtil - pf.vDensity); - s += 0.50f * fabsf(m.widthUtil - pf.widthUtil); - float anchor = pf.fontPx > 1.0f ? pf.fontPx : 1.0f; - s += 0.75f * fabsf((float)fontPx - pf.fontPx) / anchor; - if (m.lines.size() >= 2 && m.lastUtil < 0.15f) s += 0.40f * (0.15f - m.lastUtil); - return s; +// 字体排版行高比(GetHeight / fontPx,微软雅黑 ≈ 1.3):把「原文行距」换算为 +// 字号的除数(行距 ÷ 行高比 = 与该行距观感一致的字号)。运行时用探针字号实测 +// 一次,不依赖具体字体的硬编码行距。 + +static float LineHeightRatio(Gdiplus::Graphics& g, const Gdiplus::FontFamily& ff) { + Gdiplus::Font probe(&ff, 24.0f, Gdiplus::FontStyleRegular, Gdiplus::UnitPixel); + float h = probe.GetHeight(&g); + return h > 1.0f ? h / 24.0f : 1.3f; } -// 段落字号搜索(每段独立,绝不整图统一字号): -// 1) 可行性硬约束 = 真实排版总高不超段落文本区高;字号对总高单调 -// (字号越大行高越大且行数不减),故先二分出最大可行字号 maxFit; -// 2) 候选集 = 锚点(原字号估计,若可行)∪ maxFit 及其下探 1~3 档 ∪ 锚点↔maxFit -// 插值点;锚点不可行(译文比原文长)时补 [下限, maxFit] 中点覆盖稀疏原段; -// 3) 逐候选真实排版测量,仅在可行字号中按 ScoreMeasure 取密度最接近者。 -// 连下限字号都装不下时退回下限(面板扩高 + 末行截断由绘制侧兜底)。 +// 段落字号搜索(每段独立,绝不整图统一字号):字号由段落自身几何决定,不依赖 +// 「OCR 行框 → 原字号」的换算假设(行框语义随 provider 而异,紧贴字形 / 整字高 +// 框都有,乘系数估算会在整字高框上系统性高估,且同页正文行高一致导致各段字号 +// 被钉到同一个高估值——过大且统一): +// 1) 可行性硬约束 = 真实排版总高不超段框高(fitH,见 MeasureAt);字号对 +// 总高单调(字号越大行高越大且行数不减),故先二分出最大可行字号 maxFit +// (译文比原文长时收缩到装得下为止); +// 2) 行距对齐上限 cap = 段框高 ÷ 原文行数 ÷ 行高比:译文的行距与原段落行距 +// 一致,短译文(折行后行数少于原文)不会被放大去撑满段框; +// 3) 字号 = min(maxFit, cap)。段框高与行数每段不同,字号随段落区域自适应。 +// 整图兜底块(origLineCount = 0,无行级特征)用默认字号上限;连下限字号都装 +// 不下时退回下限(面板扩高 + 末行截断由绘制侧兜底)。 // 返回最终字号,mOut 带回该字号下的完整测量供最终排版。 static int FitParagraph(Gdiplus::Graphics& g, const Gdiplus::FontFamily& ff, - const std::wstring& text, const ParaFeatures& pf, - float availW, float availH, LayoutMeasure& mOut) { - LayoutMeasure atMin = MeasureAt(g, ff, text, FONT_MIN_PX, pf, availW, availH); + const std::wstring& text, int origLineCount, + float availW, float fitH, LayoutMeasure& mOut) { + LayoutMeasure atMin = MeasureAt(g, ff, text, FONT_MIN_PX, availW, fitH); if (!atMin.fitsHeight) { // 下限也装不下:退回下限,溢出走兜底 mOut = std::move(atMin); return FONT_MIN_PX; @@ -886,7 +830,7 @@ static int FitParagraph(Gdiplus::Graphics& g, const Gdiplus::FontFamily& ff, int lo = FONT_MIN_PX, hi = FONT_MAX_PX; while (lo <= hi) { int mid = (lo + hi) / 2; - if (MeasureAt(g, ff, text, mid, pf, availW, availH).fitsHeight) { + if (MeasureAt(g, ff, text, mid, availW, fitH).fitsHeight) { maxFit = mid; lo = mid + 1; } else { @@ -894,48 +838,21 @@ static int FitParagraph(Gdiplus::Graphics& g, const Gdiplus::FontFamily& ff, } } } - int anchor = (int)(pf.fontPx + 0.5f); - if (anchor < FONT_MIN_PX) anchor = FONT_MIN_PX; - if (anchor > FONT_MAX_PX) anchor = FONT_MAX_PX; - std::vector cands; - auto push = [&](int v) { - if (v < FONT_MIN_PX || v > FONT_MAX_PX) return; - for (int c : cands) if (c == v) return; - cands.push_back(v); - }; - push(anchor); - push(maxFit); - push(maxFit - 1); - push(maxFit - 2); - push(maxFit - 3); - if (anchor < maxFit) { - int span = maxFit - anchor; - push(anchor + span / 2); - push(anchor + span / 4); - push(anchor + span * 3 / 4); - } else if (anchor > maxFit) { - push(FONT_MIN_PX + (maxFit - FONT_MIN_PX) / 2); + int cap = FONT_MAX_PX; + if (origLineCount > 0) { + cap = (int)(fitH / (float)origLineCount / LineHeightRatio(g, ff)); + } else { + cap = FALLBACK_FONT_PX; // 兜底块无行级特征:默认字号上限 } - int best = maxFit; - float bestScore = 3.4e38f; - LayoutMeasure bestM; - for (int v : cands) { - LayoutMeasure m = MeasureAt(g, ff, text, v, pf, availW, availH); - if (!m.fitsHeight) continue; - float s = ScoreMeasure(m, v, pf); - if (s < bestScore) { - bestScore = s; - best = v; - bestM = std::move(m); - } - } - if (bestM.lines.empty()) bestM = MeasureAt(g, ff, text, best, pf, availW, availH); - mOut = std::move(bestM); - return best; + if (cap < FONT_MIN_PX) cap = FONT_MIN_PX; + if (cap > FONT_MAX_PX) cap = FONT_MAX_PX; + int fontPx = maxFit < cap ? maxFit : cap; + mOut = MeasureAt(g, ff, text, fontPx, availW, fitH); + return fontPx; } // 绘制单个译文覆盖面板:白底圆角面板盖住原段落区域,整段译文按搜索出的字号 -// 折行排版(密度对齐而非最大字号,见 FitParagraph)。搜索含十次级真实排版 +// 折行排版(行距对齐 + 框高可行性收缩,见 FitParagraph)。搜索含十次级真实排版 // 测量,结果按「绘制时框宽高」缓存在块内(选区不变时每帧直接复用);仅当 // 连下限字号都装不下时面板才向下扩展(不超 clip)并截断末行,属兜底路径。 // tb.box/clip 为绝对逻辑坐标,ox/oy 为绘制平移(OnPaint = -虚拟屏原点, @@ -960,33 +877,17 @@ static void DrawBlockPanel(HDC hdc, TranslateBlock& tb, const RECT& clip, float int pad = PANEL_PAD; int availW = boxW - pad * 2; if (availW < 8) availW = 8; - float availH = (float)boxH - pad * 2; + // 高度可行域 = 段框高(非框高 − 双内边距,见 MeasureAt); + // 垂直内边距由绘制侧垂直居中吸收 + float fitH = (float)boxH; float maxPanelBottom = (float)clipOff.bottom; // ---- 字号搜索(带缓存:键 = 绘制时框宽高,几何变化才重排)---- if (tb.fitKeyW != boxW || tb.fitKeyH != boxH || tb.fitLines.empty() || tb.fitFontPx < FONT_MIN_PX) { - ParaFeatures pf; - pf.boxW = (float)boxW; - pf.boxH = (float)boxH; - pf.fontPx = tb.lineHeightPx > 1.0 ? (float)(tb.lineHeightPx - pad * 2) - : (float)(boxH - pad * 2); - if (pf.fontPx < (float)FONT_MIN_PX) pf.fontPx = (float)FONT_MIN_PX; - if (pf.fontPx > (float)FONT_MAX_PX) pf.fontPx = (float)FONT_MAX_PX; - if (tb.origLineCount > 0) { - pf.valid = true; - pf.lineCount = tb.origLineCount; - pf.widthUtil = (float)tb.origWidthUtil; - pf.vDensity = (float)tb.origVDensity; - } else { - // 整图兜底块无行级特征:合成「接近占满」的密度目标 - pf.lineCount = 1; - pf.widthUtil = 0.95f; - pf.vDensity = 0.95f; - } LayoutMeasure m; int fontPx = FitParagraph(graphics, Gdiplus::FontFamily(SC_FONT_FACE), tb.text, - pf, (float)availW, availH, m); + tb.origLineCount, (float)availW, fitH, m); tb.fitKeyW = boxW; tb.fitKeyH = boxH; tb.fitFontPx = fontPx; @@ -998,15 +899,18 @@ static void DrawBlockPanel(HDC hdc, TranslateBlock& tb, const RECT& clip, float float lineH = tb.fitLineH > 0.5f ? tb.fitLineH : (float)fontPx; const std::vector& lines = tb.fitLines; - // 面板高度:装得下时严格取原段落高(覆盖框即原段框,保持原版面占位); - // 装不下(下限字号仍溢出)才向下扩展到裁剪边界 - float neededH = lines.size() * lineH + pad * 2; + // 面板高度:文字总高不超过段框高(字号搜索的可行性约束)时严格取原段落高 + // (覆盖框即原段框,保持原版面占位),文字垂直居中、垂直内边距让位; + // 下限字号仍溢出(可行性早退的兜底路径)才向下扩展到裁剪边界 + float textH = lines.size() * lineH; float panelH = (float)boxH; - if (neededH > panelH) { - panelH = neededH; + if (textH > panelH) { + panelH = textH + pad * 2; if (boxI.top + panelH > maxPanelBottom) panelH = maxPanelBottom - boxI.top; if (panelH < (float)boxH) panelH = (float)boxH; } + float padY = (panelH - textH) / 2; + if (padY < 0) padY = 0; // 面板:近实心白底盖住原文字 + 浅灰描边 Gdiplus::RectF panel((Gdiplus::REAL)boxI.left, (Gdiplus::REAL)boxI.top, @@ -1031,9 +935,13 @@ static void DrawBlockPanel(HDC hdc, TranslateBlock& tb, const RECT& clip, float sf.SetFormatFlags(Gdiplus::StringFormatFlagsNoWrap); sf.SetTrimming(Gdiplus::StringTrimmingNone); - int visible = (int)((panelH - pad * 2) / lineH); - if (visible < 1) visible = 1; - float y = panel.Y + pad; + // 默认全显(可行字号必在框内),仅兜底扩高被裁剪边界压回时按剩余高度截断 + int visible = (int)lines.size(); + if ((float)visible * lineH > panelH - padY * 2 + 0.5f) { + visible = (int)((panelH - padY * 2) / lineH); + if (visible < 1) visible = 1; + } + float y = panel.Y + padY; for (int i = 0; i < (int)lines.size() && i < visible; i++) { std::wstring line = lines[i]; if (i == visible - 1 && (size_t)visible < lines.size()) { diff --git a/test/electron-host.cjs b/test/electron-host.cjs new file mode 100644 index 0000000..838cf52 --- /dev/null +++ b/test/electron-host.cjs @@ -0,0 +1,40 @@ +/** + * Electron 宿主(macOS 截图会话的验收环境) + * + * macOS 截图会话为非阻塞生命周期对象:AppKit 事件(覆盖层鼠标/键盘/绘制)与 + * 周期任务(翻译回投 drain/插入符闪烁/长截图采样)都由宿主进程自己的主事件循环 + * 驱动。Electron 主进程的 Chromium 消息泵天然同时运转 NSApp 与 libuv,是这套 + * 架构的标准宿主;纯 Node 宿主不运转 macOS 主事件循环,无法运行截图会话。 + * + * 用法(在仓库根目录): + * npx electron test/electron-host.cjs test/test-translate.js + * npx electron test/electron-host.cjs test/test-screenshot.js + * + * 说明: + * - 需要本地可用的 electron(npx 会按需拉取,或全局/项目内已安装); + * - 目标脚本照常按普通 Node 脚本编写(require('..') 等),无需感知 Electron; + * - 进程退出交由目标脚本自身控制(截图测试在回调/超时里 process.exit)。 + */ +'use strict'; + +const path = require('path'); +const { app } = require('electron'); + +const target = process.argv[2]; +if (!target) { + console.error('用法: npx electron test/electron-host.cjs [args...]'); + app.exit(1); +} + +const targetPath = path.resolve(process.cwd(), target); +// 让目标脚本读到干净的 argv([electron, target, ...rest]) +process.argv = [process.argv[0], targetPath, ...process.argv.slice(3)]; + +app.whenReady().then(() => { + try { + require(targetPath); + } catch (err) { + console.error('目标脚本加载失败:', err); + app.exit(1); + } +}); diff --git a/test/test-provider-async-bridge.js b/test/test-provider-async-bridge.js new file mode 100644 index 0000000..e01b120 --- /dev/null +++ b/test/test-provider-async-bridge.js @@ -0,0 +1,217 @@ +/** + * Provider 异步桥接端到端测试(macOS 截图翻译的生产通路) + * + * 验证链路:JS 发起 invokeFromNativeAsync → 原生 InvokeAsync(requestId 登记)→ + * TSFN 调度回 JS 线程 → JS handler 执行(Promise/setTimeout)→ resolve/reject 按 + * seq 回传 → 原生侧按 requestId 回调 → JS Promise settle; + * 超时走原生看门狗 CancelAsync(晚到的 JS 回传被桥按 seq 落空丢弃)。 + * + * 覆盖场景(对应重构设计的 Provider Mock 验证清单): + * 1 同步返回 / 2 Promise.resolve / 3 延迟 Promise(setTimeout) + * 5 超时(handler 故意超过 timeout)+ 7 晚到结果丢弃 + * 6 取消(请求发出后立即取消,无回调) + * 8 多请求并发(乱序返回 + 有限并发模拟 + 20 段压力) + * 附:同步通路回归(invokeFromNative)与重复 requestId 拒绝 + * + * 运行:node test/test-provider-async-bridge.js + */ +const assert = require('assert'); +const { ProviderBridge } = require('..'); + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function main() { + assert.strictEqual(ProviderBridge.isReady(), false, 'bridge should not be ready before start'); + let nextReq = 100; + const req = () => ++nextReq; + + // ---- Mock Provider:按 type 分发各测试语义 ---- + const handler = async (type, input) => { + switch (type) { + case 'sync': // Test 1:同步返回 + return { text: `sync:${input.text}` }; + case 'promise': // Test 2:Promise.resolve + return Promise.resolve({ text: `promise:${input.text}` }); + case 'delayed': // Test 3:延迟 Promise + await sleep(input.ms); + return { text: `delayed:${input.text}` }; + case 'never': // Test 5/7:永不 resolve(超时 + 晚到) + await sleep(10_000); + return { text: 'late result' }; + case 'para': // Test 8:段落翻译(乱序延迟) + await sleep(input.ms); + return { text: `译${input.i}` }; + case 'fail': + throw new Error('mock provider failure'); + default: + throw new Error('unknown type: ' + type); + } + }; + ProviderBridge.start(handler); + assert.strictEqual(ProviderBridge.isReady(), true); + + // ---- Test 1/2:同步返回与 Promise.resolve ---- + { + const a = await ProviderBridge.invokeFromNativeAsync('sync', { text: '你好' }, req()); + const b = await ProviderBridge.invokeFromNativeAsync('promise', { text: '世界' }, req()); + assert.strictEqual(a.text, 'sync:你好'); + assert.strictEqual(b.text, 'promise:世界'); + console.log('✓ [Test 1/2] 同步返回 / Promise.resolve 均正常回投'); + } + + // ---- Test 3:延迟 Promise(setTimeout 驱动,宿主事件循环自由前进)---- + { + const start = Date.now(); + const r = await ProviderBridge.invokeFromNativeAsync('delayed', { text: 'x', ms: 250 }, req()); + const elapsed = Date.now() - start; + assert.strictEqual(r.text, 'delayed:x'); + assert.ok(elapsed >= 200, `delayed result should not arrive early (elapsed=${elapsed}ms)`); + console.log(`✓ [Test 3] 延迟 Promise 正常回投(elapsed=${elapsed}ms)`); + } + + // ---- handler 抛错经 reject 回传 ---- + { + await assert.rejects( + () => ProviderBridge.invokeFromNativeAsync('fail', {}, req()), + /mock provider failure/, + 'handler error should propagate' + ); + console.log('✓ handler 抛错经异步回调回传为 reject'); + } + + // ---- Test 5 + 7:超时 + 晚到结果丢弃 ---- + { + const start = Date.now(); + // timeout 300ms,handler 10s 后才 resolve:先收到 timeout 错误; + // 10s 的晚到 resolve 到达时登记已移除,被桥丢弃(不会再 settle 本 Promise) + const p = ProviderBridge.invokeFromNativeAsync('never', {}, req(), 300); + const err = await p.then( + () => { throw new Error('should have timed out'); }, + (e) => e + ); + const elapsed = Date.now() - start; + assert.ok(/timed out/.test(err.message), `expected timeout error, got: ${err.message}`); + assert.ok(elapsed < 2000, `timeout should fire near deadline (elapsed=${elapsed}ms)`); + console.log(`✓ [Test 5] 超时按期触发(elapsed=${elapsed}ms, error="${err.message}")`); + // Test 7:等 700ms 确认晚到结果不会二次 settle / 崩溃(登记已被 CancelAsync 移除) + await sleep(700); + console.log('✓ [Test 7] 晚到结果被桥丢弃,无二次回调/崩溃'); + } + + // ---- Test 6:立即取消(无回调)---- + { + let callbacks = 0; + const rid = req(); + const p = ProviderBridge.invokeFromNativeAsync('delayed', { text: 'c', ms: 200 }, rid, 5000); + p.then( + () => { callbacks++; }, + () => { callbacks++; } + ); + const cancelled = ProviderBridge.cancelFromNative(rid); + assert.strictEqual(cancelled, true, 'first cancel should claim the request'); + assert.strictEqual(ProviderBridge.cancelFromNative(rid), false, 'second cancel is a no-op'); + await sleep(600); // 超过 handler 的 200ms 延迟:晚到 resolve 被丢弃 + assert.strictEqual(callbacks, 0, 'cancelled request must never deliver a callback'); + console.log('✓ [Test 6] 取消后无回调,重复取消幂等,晚到结果丢弃'); + } + + // ---- 重复 requestId 拒绝 ---- + { + const rid = req(); + const first = ProviderBridge.invokeFromNativeAsync('delayed', { text: 'd1', ms: 150 }, rid, 5000); + let rejected = false; + try { + await ProviderBridge.invokeFromNativeAsync('delayed', { text: 'd2', ms: 0 }, rid, 5000); + } catch (e) { + rejected = /duplicate requestId/.test(e.message); + } + assert.ok(rejected, 'duplicate requestId should be rejected'); + assert.strictEqual((await first).text, 'delayed:d1'); + console.log('✓ 重复 requestId 被拒绝,原请求不受影响'); + } + + // ---- Test 8a:乱序返回按 requestId 正确配对 ---- + { + const inputs = [0, 1, 2, 3, 4, 5, 6, 7]; + const delays = [300, 250, 200, 150, 100, 50, 0, 0]; // 后发先回 + const results = await Promise.all( + inputs.map((i) => ProviderBridge.invokeFromNativeAsync( + 'para', { i, ms: delays[i] }, req(), 5000)) + ); + results.forEach((r, i) => { + assert.strictEqual(r.text, `译${i}`, `result ${i} mismatched (got ${r.text})`); + }); + console.log('✓ [Test 8a] 乱序返回按 requestId 正确配对(8 请求无串扰)'); + } + + // ---- Test 8b:有限并发队列模拟(Swift Job Manager 语义:max=2,完成后补发)---- + { + const N = 10, MAX = 2, UNIT = 120; + let inFlight = 0, peak = 0, next = 0; + const results = new Array(N); + const start = Date.now(); + const allDone = new Promise((resolve) => { + const pump = () => { + while (inFlight < MAX && next < N) { + const i = next++; + inFlight++; + peak = Math.max(peak, inFlight); + ProviderBridge.invokeFromNativeAsync('para', { i, ms: UNIT }, req(), 5000) + .then((r) => { results[i] = r.text; }) + .then(() => { + inFlight--; + if (next >= N && inFlight === 0) { + resolve(); + } else { + pump(); + } + }) + .catch((e) => resolve(e)); // 失败也收束,交由断言暴露 + } + }; + pump(); + }); + await allDone; + const elapsed = Date.now() - start; + for (let i = 0; i < N; i++) assert.strictEqual(results[i], `译${i}`, `para ${i} mismatched`); + assert.ok(peak <= MAX, `concurrency must stay <= ${MAX} (peak=${peak})`); + assert.strictEqual(peak, MAX, `queue should actually reach max concurrency (peak=${peak})`); + assert.ok(elapsed >= (N / MAX) * UNIT * 0.9, + `bounded queue takes ~N/MAX units, got ${elapsed}ms`); + console.log(`✓ [Test 8b] 有限并发模拟:${N} 段 max=${MAX} peak=${peak} 总耗时=${elapsed}ms,结果按序写回`); + } + + // ---- Test 8c:20 段压力(全并发发起,验证不无限失败/不串扰)---- + { + const N = 20; + const results = await Promise.all( + Array.from({ length: N }, (_, i) => + ProviderBridge.invokeFromNativeAsync('para', { i, ms: (i % 5) * 40 }, req(), 5000)) + ); + results.forEach((r, i) => assert.strictEqual(r.text, `译${i}`)); + console.log(`✓ [Test 8c] ${N} 段并发压力:全部按 requestId 配对成功`); + } + + // ---- 同步通路回归(Windows 生产路径共用 pending 表,须不受影响)---- + { + const r = await ProviderBridge.invokeFromNative('sync', { text: '旧链路' }, 2000); + assert.strictEqual(r.text, 'sync:旧链路'); + const mixed = await Promise.all([ + ProviderBridge.invokeFromNative('delayed', { text: 'm1', ms: 80 }, 2000), + ProviderBridge.invokeFromNativeAsync('delayed', { text: 'm2', ms: 80 }, req(), 2000), + ]); + assert.strictEqual(mixed[0].text, 'delayed:m1'); + assert.strictEqual(mixed[1].text, 'delayed:m2'); + console.log('✓ 同步 Invoke 回归正常,同步/异步混发互不干扰'); + } + + ProviderBridge.stop(); + assert.strictEqual(ProviderBridge.isReady(), false); + console.log('\n全部异步桥接测试通过 ✅'); + process.exit(0); +} + +main().catch((err) => { + console.error('\n测试失败 ❌:', err); + process.exit(1); +}); diff --git a/test/test-screenshot.js b/test/test-screenshot.js index 08893b9..6cdb7a0 100644 --- a/test/test-screenshot.js +++ b/test/test-screenshot.js @@ -7,6 +7,10 @@ console.log('2. 鼠标会变成十字光标'); console.log('3. 拖拽鼠标选择要截图的区域'); console.log('4. 释放鼠标后截图会自动保存到剪贴板'); console.log('5. 按 ESC 键可以取消截图'); +if (process.platform === 'darwin') { + console.log('\n⚠️ macOS:截图会话依赖宿主进程驱动 macOS 主事件循环,请用 Electron 宿主运行:'); + console.log(' npx electron test/electron-host.cjs test/test-screenshot.js'); +} console.log('\n准备启动...\n'); setTimeout(() => { diff --git a/test/test-translate.js b/test/test-translate.js index 519e14f..9d44d85 100644 --- a/test/test-translate.js +++ b/test/test-translate.js @@ -1,4 +1,4 @@ -// 截图翻译(编辑态工具栏「翻译」按钮)交互式测试:Windows 真机手工验收。 +// 截图翻译(编辑态工具栏「翻译」按钮)交互式测试:真机手工验收。 // // 验证目标:原生层「ocr(行级坐标)→ 文本行空间聚类成段落 → 逐段 translation」 // 编排链路(聚类算法的纯逻辑回归见 scripts/run-translate-cluster-selftest.cmd)。 @@ -10,6 +10,15 @@ // 5. 控制台会打印桥接调用序列与每次 translation 的入参(可核对段落拼接结果: // 英文段内空格连接、中文紧排直连)。 // +// 平台注意: +// - Windows:截图 UI 跑在独立线程,JS 事件循环照常前进,本脚本开箱即用。 +// - macOS:截图会话为非阻塞生命周期对象,AppKit 事件与 Node 事件循环都依赖 +// 宿主进程自己的主事件循环驱动——纯 Node 宿主不运转 macOS 主事件循环, +// 请用 Electron 宿主运行本测试(macOS 全功能截图翻译的验收环境): +// npx electron test/electron-host.cjs test/test-translate.js +// (翻译为异步 RPC,requestId 驱动;桥接层语义(同步/Promise/延迟/超时/取消/ +// 晚到丢弃/并发)另由 test/test-provider-async-bridge.js 覆盖,后者无需 Electron)。 +// // 运行方式: // node test/test-translate.js # 默认:6 行英文 → 2 个段落 // # (段间距 + 段末短行触发边界判定)