diff --git a/plugins/f-provider/.github/assets/code-translate.gif b/plugins/f-provider/.github/assets/code-translate.gif new file mode 100644 index 00000000..eafc30f8 Binary files /dev/null and b/plugins/f-provider/.github/assets/code-translate.gif differ diff --git a/plugins/f-provider/.github/assets/img-translate.gif b/plugins/f-provider/.github/assets/img-translate.gif new file mode 100644 index 00000000..61d37c85 Binary files /dev/null and b/plugins/f-provider/.github/assets/img-translate.gif differ diff --git a/plugins/f-provider/.github/assets/screenshot-ocr.gif b/plugins/f-provider/.github/assets/screenshot-ocr.gif new file mode 100644 index 00000000..b7b3c178 Binary files /dev/null and b/plugins/f-provider/.github/assets/screenshot-ocr.gif differ diff --git a/plugins/f-provider/.github/workflows/release.yml b/plugins/f-provider/.github/workflows/release.yml new file mode 100644 index 00000000..817abbd3 --- /dev/null +++ b/plugins/f-provider/.github/workflows/release.yml @@ -0,0 +1,69 @@ +# Build the native OCR runtime (wechat_ocr.node + wco_data) on Windows, bundle +# it into native.zip, and publish native.zip as a GitHub Release asset. +# +# Scope: this workflow ONLY builds & ships the native bits. The frontend plugin +# is released independently (npm run build) — its plugin.json carries the +# native.downloadUrl / sha256 / version, maintained by hand there. +# +# Triggered by pushing a `v*` tag (e.g. v1.0.1). The tag becomes the Release +# name; the Release carries a single asset: dist/native.zip. +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write # required to create the Release and upload the asset + +jobs: + release: + # Pin to windows-2022 (VS 2022). windows-latest now ships VS 2026, which + # node-gyp 10.x (bundled in Node 20) cannot detect — see node-gyp #3282. + runs-on: windows-2022 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Setup Python (for node-gyp) + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install frontend dependencies + run: npm install + + # copy:native writes into dist/, so produce it first (vite empties dist + # on build — run frontend BEFORE the native copy step, never after). + - name: Build frontend (produces dist/) + run: npm run build + + - name: Build native (fetch wco_data + compile wechat_ocr.node) + run: npm run build:native + shell: bash + + - name: Copy native assets into dist/ and bundle native.zip + run: npm run copy:native + + - name: Resolve version + id: ver + shell: bash + run: | + # v1.0.1 -> 1.0.1 + ver="${GITHUB_REF_NAME#v}" + echo "version=$ver" >> "$GITHUB_OUTPUT" + + - name: Upload native.zip to a GitHub Release + uses: softprops/action-gh-release@v2 + with: + name: ${{ steps.ver.outputs.version }} + tag_name: ${{ github.ref_name }} + files: | + dist/native.zip diff --git a/plugins/f-provider/.gitignore b/plugins/f-provider/.gitignore new file mode 100644 index 00000000..2186b30c --- /dev/null +++ b/plugins/f-provider/.gitignore @@ -0,0 +1,20 @@ +node_modules/ +dist/ + +# Native OCR build output & dependencies +native/build/ +native/node_modules/ + +# Compiled native addon (product of build; do not commit) +native/**/*.node +*.node + +# Tencent proprietary runtime files — never committed. Obtain locally +# (see native/wco_data/README.md) and drop here before building/running. +native/wco_data/*.dll +native/wco_data/*.exe +native/wco_data/*.nas +native/wco_data/*.xnet +native/wco_data/RecDict +native/wco_data/sohu_simp.txt +native/wco_data/x64.config diff --git a/plugins/f-provider/README.md b/plugins/f-provider/README.md new file mode 100644 index 00000000..dd45bffa --- /dev/null +++ b/plugins/f-provider/README.md @@ -0,0 +1,241 @@ +# ztools-f-provider + +
+ +Logo + +**一个 ZTools 提供商插件,把本地 OCR 与多家翻译封装为可复用的「OCR / 翻译提供商」** + +_微信 OCR 离线识别 · 百度 / 谷歌 / 有道 / 微软翻译 · 代码命名翻译_ + +[![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE) +[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-blue)](#-平台与限制) +[![ZTools](https://img.shields.io/badge/ZTools-插件-orange)](https://github.com/ZToolsCenter/ZTools) + +
+ +--- + +## ✨ 特性 + +- 🔍 **离线 OCR** - 复用本机微信内置 OCR 引擎,无需联网即可识别图片文字,带坐标逐行结果 +- 🌐 **多家翻译** - 百度 / 谷歌 / 有道 / 微软四种翻译提供商,统一接口、可设默认、凭据隔离 +- 🧩 **Provider 抽象** - 以「提供商」形式注册进 ZTools,主程序与任意插件均可复用 +- ⌨️ **代码翻译** - 把中文(或任意文本)翻译为英文,并生成 8 种代码命名风格候选,纯键盘操作 +- 📸 **截图识别** - 进入即自动截屏框选区域,识别出文字并可视化悬浮在原图上,可点选复制 +- 🖼️ **图片识别** - 拖入 / 选择图片即识别,canvas 绘图 + 透明文字层,支持全屏缩放拖动 +- 🔁 **自动翻译** - 原文变化 1s 自动重译,支持语言互换、自动推断目标语言 +- 🔒 **凭据安全** - 敏感凭据按插件命名空间隔离存入 `ztools.dbStorage` +- 🌍 **跨平台** - 翻译部分全平台可用,OCR 部分依赖 Windows 原生运行时 + +## 📸 预览 + +
+ + + + + + + + +
+ 截图识别演示 +

截图识别 - 进入即截屏 → OCR → 文字悬浮可复制

+
+ 图片识别与翻译演示 +

图片识别与翻译 - 拖图识别 + 多提供商对比

+
+ 代码翻译演示 +

代码翻译 - 中文 → 英文 → 8 种命名风格,↑↓ 切换、回车粘贴

+
+
+ +## 🚀 快速开始 + +> `npm install` 只安装前端依赖;native 的依赖在 `build:native` 时自动安装,**不会**联网下载专有文件,也不会编译原生模块。 + +### 安装依赖 + +```bash +npm install # 仅装前端依赖;native 的依赖会在 build 时自动安装 +``` + +### 构建 + +```bash +npm run build # 前端打包:vue-tsc + vite build,产物在 dist/ +npm run build:native # 构建 native 模块(Windows 原生 OCR 运行时) +``` + +native 部分由 GitHub Actions 在打 tag 时自动构建并发布。 + +### 使用 + +1. 安装插件后,OCR 引擎会在首次调用时自动拉起(仅 Windows) +2. 在「设置 → 提供商」中启用 / 设为默认 OCR 与翻译提供商 +3. 主搜索框输入 `ZTools 提供商` 进入管理页,配置翻译凭据 + +## 🔌 作为 Provider 接入 + +本插件在 `plugin.json` 声明了 5 个 provider:`ocr` 与 `baidu` / `google` / `youdao` / `microsoft`(均为 `translation` 类型),并在 preload 中调用 `ztools.registerProvider(, handler)` 注册实现。 + +- 安装后,「设置 → 提供商」的「OCR」「翻译」tab 会分别列出这些 provider,可启用 / 设为默认 +- 任何插件都可通过消费方 API 复用: + +```js +// 便捷封装(走默认 OCR 提供商) +const { text, blocks, confidence } = await ztools.ocr('/path/to/image.png') + +// 便捷封装(走默认翻译提供商) +const { text, detectedFrom } = await ztools.translate('hello', { from: 'en', to: 'zh-CN' }) + +// 或显式指定某个 provider(key 即 plugin.json 的声明 key) +await ztools.providers.invokeProvider('baidu', { text: 'hello', from: 'en', to: 'zh-CN' }) +``` + +**OCR 契约**:输入 `{ image, lang? }`(`image` 为本地路径 / `data:` URI / `http(s)` URL),输出 `{ text, blocks?, confidence? }`。 + +**翻译契约**(对齐宿主 `TranslationInput/Output`):输入 `{ text, from?, to? }`(`from`/`to` 为语言码字符串,缺省视为自动检测 / 默认目标),输出 `{ text, detectedFrom? }`。语言码使用中性字符串:`auto` / `zh-CN` / `zh-TW` / `yue` / `en` / `ja` / `ko` / `fr` / `es` / `ru` / `de` / `it` / `tr` / `pt-PT` / `pt-BR` / `vi` / `id` / `th` / `ms` / `ar` / `hi` / `mn-Cyrl` / `mn-Mong` / `km` / `nb` / `nn` / `fa` / `sv` / `pl` / `nl` / `uk` / `uz`,各 provider 内部再映射到自家 API 的语种代码。 + +### 翻译凭据配置 + +| Provider | 是否需要凭据 | 说明 | +| --- | --- | --- | +| 百度 | ✅ | AppID / AppKey | +| 有道 | ✅ | AppKey / AppSecret | +| 微软 | ✅ | 鉴权方案(Edge Token 或 Signature) | +| 谷歌 | ❌ | 免费反代端点,无需凭据 | + +凭据在「ZTools 提供商管理」入口(feature `code: manage`)侧边栏的「翻译设置」子页填写并保存。敏感字段统一存入 `ztools.dbStorage`(按插件命名空间隔离),键名 `translate.`。 + +## 🧩 功能详解 + +本插件提供**三个 feature**: + +### `code: manage` — 管理页 + +通过不同类型 cmd 承载多种入口,全部进入同一管理页,根据进入方式自动切到对应 tab: + +- **关键词进入**(`text` 型 cmd `ZTools 提供商`):可被搜索(支持拼音),默认打开「设置」tab +- **图片进入**(`img` / `files` 匹配型 cmd):拖入或选择图片文件后,自动切到「识别」tab,展示原图预览并用该图片跑 OCR,展示带坐标的逐行结果。仅 Windows +- **文本翻译进入**(`regex` 型 cmd `翻译`,`match: ^[\s\S]*$`):在主搜索框输入任意文本即可命中,进入后自动切到「翻译」tab,预填该文本并触发一次翻译 + +管理页侧边栏子页(无分组,平铺): + +- **设置** - OCR 引擎 + 翻译服务卡片网格(凭据 / 鉴权方案) +- **识别** - 选图 / 拖拽 / 粘贴识别,画布绘制原图 + 透明文字层可点选复制;超级面板选图会先转 data URI 展示原图 +- **翻译** - 单 provider 实用翻译器,原文/译文左右结构、原文可编辑、顶部「自动翻译」开关——开启后原文变化 1s 自动重译,支持语言互换、自动推断目标语言 +- **批量测试** - 四 provider 并发对比,供验证凭据 + +> OCR 子页在非 Windows 下打开会显示「引擎未就绪」并引导下载;翻译相关子页全平台可用。 + +### `code: code-translate` — 代码翻译 + +把选中的中文文本(或任意文本)翻译为英文,再按多种代码命名风格生成候选列表,纯键盘操作: + +- **进入**(`regex` 型 cmd `代码翻译`,`match: ^[\s\S]*$`):主搜索框输入任意文本即可命中 +- **流程** - 含中文的文本经 translation provider(默认 microsoft → google → baidu → youdao 降级)翻译到英文(`auto` → `en`);纯 ASCII 文本跳过翻译直接转换 +- **候选风格(8 种)** - camelCase、PascalCase、snake_case、CONSTANT_CASE、kebab-case、camel_Snake(混合下划线+驼峰)、Pascal_Snake、flatcase(全小写无分隔)、UPPERFLAT(全大写无分隔) +- **键盘** - `↑` / `↓`(或 `Tab` / `Shift+Tab`)环形切换候选、`Enter` 确认、`Esc` 取消;也支持鼠标 hover/click +- **确认动作** - `Enter` 调 `ztools.hideMainWindowPasteText` 把结果粘贴回原光标位置并退出;粘贴失败时回退到复制 + Toast 提示 +- **兜底** - provider 翻译失败时用原文做风格转换,保证候选列表非空 + +### `code: screen-ocr` — 截图识别 + +进入即自动调起系统截屏,框选区域后自动跑微信 OCR,可视化与「识别」页完全一致(canvas 绘图 + 透明文字层悬浮 + 全屏缩放/拖动 + 结果列表): + +- **进入**(`text` 型 cmd `截图识别文字`):可被搜索(支持拼音)。仅 Windows(`platform: ["win32"]`) +- **流程** - 进入即自动触发 `ztools.screenCapture` → 用户框选屏幕区域 → 截图回调返回 base64 → 自动调 `ocrImageDetail` 识别 → canvas 绘制截图 + 透明文字层(按坐标悬浮,鼠标 hover 预览 / 点击复制)+ 结果列表双向高亮。顶部「重新截图」可反复截 +- **可视化**(复用 `OcrImageViewer`)- 图上文字鼠标悬浮弹出预览、点击复制;右下角「⛶」全屏预览,支持滚轮缩放(以鼠标为锚点)、拖动、按钮缩放/复位 +- **取消** - 截屏时按 `Esc` 取消(不报错,回到待截图态);非截屏态按 `Esc` 退出插件 +- **复制** - 点击单行复制该行;「复制全部」复制全部识别文字 +- **引擎未就绪** - 渲染 native 引擎下载卡片(允许下载自救),下载就绪后自动补一次截屏 + +> 💡 **关于 cmd 类型**:`text` 型 cmd(label 即搜索关键字)是唯一能进入 ZTools 主搜索列表的 cmd 类型;`img`/`files`/`regex`/`over`/`window` 仅在对应场景匹配时出现。因此若需要让插件「能被搜到且能打开」,至少要有一个 feature 携带 `text` 型 cmd。 + +## 🛠️ 技术栈 + +- **框架**: Vue 3 + TypeScript + Vite +- **UI**: ztools-ui(与宿主一致的组件库) +- **Provider 注册**: ZTools `ztools.registerProvider` API +- **原生模块**: C++ (Node-API / node-addon-api) + - 微信 OCR 运行时(mmmojo IPC 桥接) + - 手写 protobuf 编解码 + - 任务队列 + 回调 +- **翻译**: 移植自 [STranslate](https://github.com/ZGGSONG/STranslate) 的纯 Node.js 实现 + +## 📁 项目结构 + +``` +f-provider/ +├── native/ # 原生模块工程 +│ ├── binding.gyp # node-gyp 构建配置 +│ ├── package.json # node-addon-api 依赖 +│ ├── index.js # .node 加载入口(require) +│ ├── scripts/ +│ │ └── fetch-wco-data.cjs # build 时自动获取 OCR 运行时数据 +│ ├── src/ # C++ 源 +│ │ ├── addon.cc # N-API 绑定(init/ocr/dispose) +│ │ ├── mmmojo.{h,cc} # IPC 桥接库动态加载封装 +│ │ ├── ocr_manager.{h,cc} # 任务队列 + 回调 +│ │ ├── pb.{h,cc} # 手写 protobuf 编解码 +│ │ └── ocr_protobuf.proto # 消息定义(文档用) +│ └── wco_data/ # OCR 运行时(git-ignored,build 时自动获取) +├── public/ +│ ├── plugin.json # 声明 providers.{ocr,baidu,google,youdao,microsoft} + 三个 feature +│ ├── preload/services.js # registerProvider(...) + 归一化输入 +│ └── logo.png +├── src/ # Vue 前端(交互式 feature,全部基于 ztools-ui) +│ ├── App.vue # 按 action.code 分流 +│ ├── main.ts # 注册 ztools-ui + 同步宿主主题 +│ ├── components/ # SettingLayout / EngineStatusCard / OcrImageViewer / GlobalFeedback +│ ├── composables/ # useNativeEngine / useCaseConvert +│ ├── views/ # Settings / RecognizeTest / Translate / TranslateTest / CodeTranslate / ScreenOcr +│ └── Manage/index.vue # manage feature 容器 +├── scripts/copy-native.mjs # 构建后把 native 资源拷进 dist/ +└── package.json +``` + +## 📋 平台与限制 + +| 功能 | Windows | macOS | Linux | +| --- | :---: | :---: | :---: | +| OCR(微信引擎) | ✅ | ❌ | ❌ | +| 截图识别 | ✅ | ❌ | ❌ | +| 翻译(百度/谷歌/有道/微软) | ✅ | ✅ | ✅ | +| 翻译设置 / 批量测试 | ✅ | ✅ | ✅ | + +- **OCR** 仅 **Windows x64**(依赖原生运行时)。`plugin.json` 中 `screen-ocr` feature 已用 `platform: ["win32"]` 标注;`manage` feature 不限制平台,非 Windows 下可打开但「识别」子页会显示引擎未就绪 +- **翻译** provider 为纯 Node.js 实现,跨平台无原生依赖,其设置 / 测试子页随 `manage` feature 全平台可访问 +- OCR 首次调用时拉起引擎子进程;长时间不再使用时 preload 会按需 `dispose` 释放 + +## 🐛 问题反馈 + +遇到问题?请在 [Issues](https://github.com/Particaly/ztools-f-provider/issues) 中反馈。 + +提交 Issue 时请包含: + +- 操作系统版本 +- 插件版本 +- 复现步骤 +- 错误日志(如有) + +## 💝 致谢 + +- [STranslate](https://github.com/ZGGSONG/STranslate) - 翻译实现移植来源 +- [ZTools](https://github.com/ZToolsCenter/ZTools) - Provider 抽象与契约 +- [Vue.js](https://vuejs.org/) - 渐进式 JavaScript 框架 +- [Vite](https://vite.dev/) - 下一代前端构建工具 + +## 📄 许可证 + +代码部分采用 [MIT License](./LICENSE) 许可证。 + +--- + +
+ +**如果这个插件对你有帮助,请给个 Star ⭐️** + +
diff --git a/plugins/f-provider/index.html b/plugins/f-provider/index.html new file mode 100644 index 00000000..eaa17316 --- /dev/null +++ b/plugins/f-provider/index.html @@ -0,0 +1,11 @@ + + + + + + + +
+ + + diff --git a/plugins/f-provider/native/.gitignore b/plugins/f-provider/native/.gitignore new file mode 100644 index 00000000..8d7bfc7a --- /dev/null +++ b/plugins/f-provider/native/.gitignore @@ -0,0 +1,13 @@ +# node-gyp build output +build/ + +# Compiled native addon (product of build) +*.node + +# node modules +node_modules/ + +# Proprietary Tencent files — never committed. These must be obtained locally +# (see README) and dropped into wco_data/ before running/rebuilding. +wco_data/ +!wco_data/.gitkeep diff --git a/plugins/f-provider/native/binding.gyp b/plugins/f-provider/native/binding.gyp new file mode 100644 index 00000000..8bb21361 --- /dev/null +++ b/plugins/f-provider/native/binding.gyp @@ -0,0 +1,30 @@ +{ + "targets": [ + { + "target_name": "wechat_ocr", + "sources": [ + "src/addon.cc", + "src/mmmojo.cc", + "src/ocr_manager.cc", + "src/pb.cc" + ], + "include_dirs": [ + "= 21" + } + } + } +} diff --git a/plugins/f-provider/native/package.json b/plugins/f-provider/native/package.json new file mode 100644 index 00000000..15b8bc2f --- /dev/null +++ b/plugins/f-provider/native/package.json @@ -0,0 +1,16 @@ +{ + "name": "wechat-ocr-native", + "version": "1.0.0", + "description": "Native addon binding WeChat's built-in OCR (mmmojo + WeChatOCR.exe) for JS.", + "private": true, + "main": "index.js", + "scripts": { + "fetch:wco": "node scripts/fetch-wco-data.cjs", + "build": "node scripts/fetch-wco-data.cjs && node-gyp rebuild", + "rebuild": "node-gyp rebuild", + "clean": "node-gyp clean" + }, + "dependencies": { + "node-addon-api": "^8.0.0" + } +} diff --git a/plugins/f-provider/native/scripts/fetch-wco-data.cjs b/plugins/f-provider/native/scripts/fetch-wco-data.cjs new file mode 100644 index 00000000..4fb00317 --- /dev/null +++ b/plugins/f-provider/native/scripts/fetch-wco-data.cjs @@ -0,0 +1,154 @@ +'use strict'; +// 自动获取腾讯专有 OCR 运行时文件 (wco_data/)。 +// +// 这些文件归腾讯所有,不随仓库分发,也不在 NuGet 包 wechatocr 的托管代码里—— +// 它们被打包在该 NuGet 包的 content/wco_data/ 下(与 STranslate 项目的获取方式一致: +// 还原依赖时自动落盘)。本脚本在 native 构建(npm run build)时调用,把 wco_data/ +// 提取到当前 native 目录,使原生模块 wechat_ocr.node 在运行时能找到 WeChatOCR.exe +// + Model。 +// +// 行为: +// * 幂等:wco_data/WeChatOCR.exe 已存在则直接跳过(除非 --force)。 +// * 容错:网络/解压失败时打印清晰告警并退出码 0,不阻断构建(离线环境下仍允许 +// 先完成 node-gyp 编译;wco_data 缺失时插件前端会提示用户手动下载)。 +// * 校验:提取后复检关键文件,缺则告警。 +// +// 用法:node scripts/fetch-wco-data.cjs [--force] [--version 1.0.4] +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +// ── 配置 ──────────────────────────────────────────────────────────────── +// 携带 wco_data 的 NuGet 包。源同步自 STranslate (Directory.Packages.props)。 +const DEFAULT_VERSION = '1.0.4'; +const PKG_ID = 'wechatocr'; + +// 关键文件清单(与 preload/services.js 的 nativeStatus() 保持一致)。 +const REQUIRED = ['WeChatOCR.exe', 'mmmojo_64.dll']; + +function log(...a) { console.log('[fetch-wco-data]', ...a); } +function warn(...a) { console.warn('[fetch-wco-data] WARNING:', ...a); } + +function parseArgs(argv) { + const out = { force: false, version: DEFAULT_VERSION }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--force') out.force = true; + else if (a === '--version') out.version = argv[++i] || DEFAULT_VERSION; + } + return out; +} + +// 下载 nupkg(141MB 级),支持跟随重定向,带进度。 +// 用 Web ReadableStream 的异步迭代消费 fetch 的 body(Node 18+ 的 fetch 返回的是 +// Web 流,不是 Node stream,不能直接 .on('data')/.pipe())。 +async function download(url, dest) { + const res = await fetch(url, { redirect: 'follow' }); + if (!res.ok) throw new Error('HTTP ' + res.status); + const total = Number(res.headers.get('content-length')) || 0; + if (!res.body) throw new Error('响应无 body'); + + const file = fs.createWriteStream(dest); + let loaded = 0; + let lastLog = 0; + for await (const chunk of res.body) { + loaded += chunk.length; + const now = Date.now(); + // 每 1.5s 打印一次进度,避免刷屏。 + if (now - lastLog > 1500) { + lastLog = now; + const pct = total > 0 ? Math.round((loaded / total) * 100) : '?'; + const mb = (loaded / 1048576).toFixed(1); + log(`downloading ${mb} MB (${pct}%)`); + } + if (!file.write(chunk)) { + await new Promise((r) => file.once('drain', r)); + } + } + await new Promise((resolve, reject) => { + file.on('error', (err) => { + try { fs.unlinkSync(dest); } catch (_) {} + reject(err); + }); + file.end(() => resolve()); + }); +} + +// 用 PowerShell [System.IO.Compression.ZipFile] 选择性提取 zip 中 content/wco_data/* +// 到目标目录(带 [Content_Types] 等的 nupkg 直接用 Expand-Archive 会整个解压, +// 这里只取需要的子树,避免在磁盘上铺开整个包)。 +function extractWcoData(zipPath, destDir) { + const ps = ` +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.IO.Compression.FileSystem +$zip = [System.IO.Compression.ZipFile]::OpenRead('${zipPath.replace(/'/g, "''")}') +try { + $entries = $zip.Entries | Where-Object { $_.FullName -like 'content/wco_data/*' } + foreach ($e in $entries) { + # 相对路径:去掉 'content/' 前缀 -> wco_data/... + $rel = $e.FullName.Substring('content/'.Length) + $dest = Join-Path '${destDir.replace(/'/g, "''")}' $rel + $dir = Split-Path -Parent $dest + if ($dir -and !(Test-Path $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null } + [System.IO.Compression.ZipFileExtensions]::ExtractToFile($e, $dest, $true) + } +} finally { $zip.Dispose() } +`; + const r = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps], { + encoding: 'utf8', + shell: false, + }); + if (r.status !== 0) { + const detail = (r.stderr || r.stdout || '').toString().trim(); + throw new Error('解压失败' + (detail ? ': ' + detail : '')); + } +} + +function checkReady(wcoDir) { + const missing = REQUIRED.filter((f) => !fs.existsSync(path.join(wcoDir, f))); + return { ready: missing.length === 0, missing }; +} + +async function main() { + const opts = parseArgs(process.argv.slice(2)); + const nativeDir = path.resolve(__dirname, '..'); + const wcoDir = path.join(nativeDir, 'wco_data'); + const exe = path.join(wcoDir, 'WeChatOCR.exe'); + + // 幂等:已存在且未强制则跳过。 + if (!opts.force && fs.existsSync(exe)) { + log('wco_data/WeChatOCR.exe 已存在,跳过下载。'); + return; + } + + const url = `https://api.nuget.org/v3-flatcontainer/${PKG_ID}/${opts.version}/${PKG_ID}.${opts.version}.nupkg`; + const tmpZip = path.join(os.tmpdir(), `wechatocr.${opts.version}.${Date.now()}.nupkg`); + + log(`从 NuGet 下载 WeChatOcr ${opts.version}(含 wco_data,~141MB)...`); + log('URL:', url); + try { + await download(url, tmpZip); + log('下载完成,正在提取 wco_data/...'); + + fs.mkdirSync(wcoDir, { recursive: true }); + extractWcoData(tmpZip, nativeDir); + + const { ready, missing } = checkReady(wcoDir); + if (!ready) { + warn('提取完成但缺少关键文件:', missing.join(', ')); + warn('插件运行时可能不可用,请检查网络后重试 `npm run build`。'); + } else { + log('wco_data/ 就绪。'); + } + } catch (e) { + // 容错:不阻断构建。用户离线时仍能完成 node-gyp 编译。 + warn('获取 wco_data 失败:', e && e.message ? e.message : String(e)); + warn('原生模块仍会编译,但 OCR 运行时缺失。'); + warn('联网后可重新运行 `npm run build`(或 `npm run fetch:wco`)自动获取,或在插件内点击「下载」。'); + } finally { + try { fs.unlinkSync(tmpZip); } catch (_) {} + } +} + +main(); diff --git a/plugins/f-provider/native/src/addon.cc b/plugins/f-provider/native/src/addon.cc new file mode 100644 index 00000000..d07d136e --- /dev/null +++ b/plugins/f-provider/native/src/addon.cc @@ -0,0 +1,194 @@ +// N-API bindings for wechat_ocr.node. +// +// Exports (synchronous boot, async recognition): +// init(dataDir) -> true | throws boot the OCR engine once +// ocr(imagePath) -> Promise recognize one image +// dispose() shut the engine down +// +// mmojo delivers results on its own worker thread, so each in-flight `ocr` +// wraps a ThreadSafeFunction to hop the result back onto the JS loop. +#include +#include + +#include +#include +#include + +#include "ocr_manager.h" + +namespace { + +// Global engine state. Lazily created by init(); torn down by dispose() or at +// module unload. Guarded so ocr()/dispose()/init() are safe to interleave. +struct Engine { + std::unique_ptr manager; +}; + +std::mutex g_engineMutex; +std::unique_ptr g_engine; + +// Per-promise context. The result is filled in on the mmojo thread, then the +// ThreadSafeFunction schedules a JS-thread call to settle the promise. +struct OcrContext { + // Constructed explicitly from the JS-thread side; see Ocr(). + Napi::Promise::Deferred deferred; + Napi::ThreadSafeFunction tsfn; + wechat_ocr::OcrOutcome outcome; + bool settled = false; + OcrContext(Napi::Promise::Deferred d, Napi::ThreadSafeFunction t) + : deferred(d), tsfn(t) {} +}; + +// Runs on the JS main thread (via tsfn) to settle the promise. +void CallJs(Napi::Env env, Napi::Function, OcrContext* ctx) { + if (!env || !ctx || ctx->settled) return; + ctx->settled = true; + + const auto& outcome = ctx->outcome; + if (outcome.ok) { + auto result = Napi::Object::New(env); + result.Set("ok", Napi::Boolean::New(env, true)); + result.Set("taskId", Napi::Number::New(env, outcome.taskId)); + + auto lines = Napi::Array::New(env, outcome.result.lines.size()); + for (size_t i = 0; i < outcome.result.lines.size(); ++i) { + const auto& line = outcome.result.lines[i]; + auto obj = Napi::Object::New(env); + // single_str_utf8 is raw UTF-8 bytes from the protobuf `bytes` field; + // Napi::String interprets std::string as UTF-8, so this is a direct copy. + obj.Set("text", Napi::String::New(env, line.text)); + obj.Set("rate", Napi::Number::New(env, line.rate)); + obj.Set("left", Napi::Number::New(env, line.left)); + obj.Set("top", Napi::Number::New(env, line.top)); + obj.Set("right", Napi::Number::New(env, line.right)); + obj.Set("bottom", Napi::Number::New(env, line.bottom)); + + auto box = Napi::Array::New(env, line.boxPoints.size()); + for (size_t j = 0; j < line.boxPoints.size(); ++j) { + auto pt = Napi::Object::New(env); + pt.Set("x", Napi::Number::New(env, line.boxPoints[j].x)); + pt.Set("y", Napi::Number::New(env, line.boxPoints[j].y)); + box.Set(j, pt); + } + obj.Set("boxPoints", box); + lines.Set(i, obj); + } + result.Set("lines", lines); + ctx->deferred.Resolve(result); + } else { + auto err = Napi::Object::New(env); + err.Set("ok", Napi::Boolean::New(env, false)); + err.Set("error", Napi::String::New(env, outcome.errorMessage)); + err.Set("taskId", Napi::Number::New(env, outcome.taskId)); + ctx->deferred.Resolve(err); // resolve with {ok:false} for ergonomic awaiting + } + ctx->tsfn.Release(); + delete ctx; +} + +// JS-facing init(dataDir). dataDir = wco_data directory. +Napi::Value InitEngine(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 1 || !info[0].IsString()) { + Napi::TypeError::New(env, "init(dataDir) expects a string").ThrowAsJavaScriptException(); + return env.Undefined(); + } + std::string utf8Dir = info[0].As().Utf8Value(); + + // Convert UTF-8 path to wide for Win32 + mmojo. + int wlen = MultiByteToWideChar(CP_UTF8, 0, utf8Dir.c_str(), -1, nullptr, 0); + std::wstring dataDir(wlen - 1, L'\0'); // exclude the NUL terminator + MultiByteToWideChar(CP_UTF8, 0, utf8Dir.c_str(), -1, &dataDir[0], wlen); + + std::lock_guard lock(g_engineMutex); + if (g_engine && g_engine->manager) { + return Napi::Boolean::New(env, true); // already started + } + + // Pick the dll that matches the process arch: x64 -> mmmojo_64.dll, else mmmojo.dll. +#if defined(_WIN64) + std::wstring dllPath = dataDir + L"\\mmmojo_64.dll"; +#else + std::wstring dllPath = dataDir + L"\\mmmojo.dll"; +#endif + + auto manager = std::make_unique(dataDir, dllPath); + std::string err; + if (!manager->Start(&err)) { + Napi::Error::New(env, "WeChatOCR init failed: " + err).ThrowAsJavaScriptException(); + return env.Undefined(); + } + + g_engine = std::make_unique(); + g_engine->manager = std::move(manager); + return Napi::Boolean::New(env, true); +} + +// JS-facing ocr(imagePath) -> Promise. +Napi::Value Ocr(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + auto deferred = Napi::Promise::Deferred::New(env); + + if (info.Length() < 1 || !info[0].IsString()) { + deferred.Reject(Napi::TypeError::New(env, "ocr(imagePath) expects a string").Value()); + return deferred.Promise(); + } + + std::string utf8Path = info[0].As().Utf8Value(); + int wlen = MultiByteToWideChar(CP_UTF8, 0, utf8Path.c_str(), -1, nullptr, 0); + std::wstring imagePath(wlen - 1, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, utf8Path.c_str(), -1, &imagePath[0], wlen); + + wechat_ocr::OcrManager* manager = nullptr; + { + std::lock_guard lock(g_engineMutex); + if (g_engine) manager = g_engine->manager.get(); + } + if (!manager) { + auto err = Napi::Object::New(env); + err.Set("ok", Napi::Boolean::New(env, false)); + err.Set("error", Napi::String::New(env, "OCR engine not initialized")); + deferred.Resolve(err); + return deferred.Promise(); + } + + auto tsfn = Napi::ThreadSafeFunction::New(env, Napi::Function(), "wechat_ocr_cb", 0, 1); + auto ctx = new OcrContext(deferred, tsfn); + + // Hold a handle to ctx so we can fail synchronously if Submit rejects. + int taskId = manager->Submit(imagePath, [ctx](const wechat_ocr::OcrOutcome& outcome) { + // mmojo callback thread: copy result into ctx, hop to JS thread. + ctx->outcome = outcome; + ctx->tsfn.NonBlockingCall(ctx, CallJs); + }); + + if (taskId < 0) { + // Failed to enqueue: settle the promise with a failure object right away. + auto err = Napi::Object::New(env); + err.Set("ok", Napi::Boolean::New(env, false)); + err.Set("error", Napi::String::New(env, "OCR task queue full or engine not connected")); + deferred.Resolve(err); + ctx->tsfn.Release(); + delete ctx; + } + return deferred.Promise(); +} + +Napi::Value Dispose(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + std::lock_guard lock(g_engineMutex); + if (g_engine && g_engine->manager) g_engine->manager->Shutdown(); + g_engine.reset(); + return env.Undefined(); +} + +Napi::Object Initialize(Napi::Env env, Napi::Object exports) { + exports.Set("init", Napi::Function::New(env, InitEngine)); + exports.Set("ocr", Napi::Function::New(env, Ocr)); + exports.Set("dispose", Napi::Function::New(env, Dispose)); + return exports; +} + +} // namespace + +NODE_API_MODULE(wechat_ocr, Initialize) diff --git a/plugins/f-provider/native/src/mmmojo.cc b/plugins/f-provider/native/src/mmmojo.cc new file mode 100644 index 00000000..f4788082 --- /dev/null +++ b/plugins/f-provider/native/src/mmmojo.cc @@ -0,0 +1,73 @@ +#include "mmmojo.h" + +#include + +namespace wechat_ocr { + +namespace { + +MmmojoApi g_api{}; +HMODULE g_module = nullptr; +std::string g_loadError; + +template +T Resolve(const char* name) { + return reinterpret_cast(GetProcAddress(g_module, name)); +} + +} // namespace + +bool MmmojoLoad(const std::wstring& dllPath) { + if (g_module) return true; + + // mmmojo_64.dll has sibling dependencies (it pulls in DLLs that live next to + // WeChatOCR.exe). The original C# loader calls SetDllDirectory(dir) first; + // we mirror that so those imports resolve from wco_data/. + std::wstring dir = dllPath.substr(0, dllPath.find_last_of(L"\\/")); + ::SetDllDirectoryW(dir.c_str()); + + // LOAD_WITH_ALTERED_SEARCH_PATH makes the loader also search the DLL's own + // directory for its dependencies — mmojo needs this because it dlopens sibling + // modules at runtime. + g_module = LoadLibraryExW(dllPath.c_str(), nullptr, LOAD_WITH_ALTERED_SEARCH_PATH); + if (!g_module) { + DWORD gle = GetLastError(); + g_loadError = "LoadLibrary failed for mmmojo (err=" + std::to_string(gle) + + ", path=" + std::string(dir.begin(), dir.end()) + ")"; + return false; + } + +#define RESOLVE(field, type, name) g_api.field = Resolve(name) + RESOLVE(InitializeMMMojo, void(__cdecl*)(int, char**), "InitializeMMMojo"); + RESOLVE(ShutdownMMMojo, void(__cdecl*)(), "ShutdownMMMojo"); + RESOLVE(CreateMMMojoEnvironment, void*(__cdecl*)(), "CreateMMMojoEnvironment"); + RESOLVE(SetMMMojoEnvironmentCallbacks, void(__cdecl*)(void*, int, void*), "SetMMMojoEnvironmentCallbacks"); + RESOLVE(SetMMMojoEnvironmentInitParams, void(__cdecl*)(void*, int, void*), "SetMMMojoEnvironmentInitParams"); + RESOLVE(AppendMMSubProcessSwitchNative, void(__cdecl*)(void*, const char*, const wchar_t*), "AppendMMSubProcessSwitchNative"); + RESOLVE(StartMMMojoEnvironment, void(__cdecl*)(void*), "StartMMMojoEnvironment"); + RESOLVE(StopMMMojoEnvironment, void(__cdecl*)(void*), "StopMMMojoEnvironment"); + RESOLVE(RemoveMMMojoEnvironment, void(__cdecl*)(void*), "RemoveMMMojoEnvironment"); + RESOLVE(GetMMMojoReadInfoRequest, void*(__cdecl*)(void*, uint32_t*), "GetMMMojoReadInfoRequest"); + RESOLVE(GetMMMojoReadInfoMethod, int(__cdecl*)(void*), "GetMMMojoReadInfoMethod"); + RESOLVE(RemoveMMMojoReadInfo, void(__cdecl*)(void*), "RemoveMMMojoReadInfo"); + RESOLVE(CreateMMMojoWriteInfo, void*(__cdecl*)(int, int, uint32_t), "CreateMMMojoWriteInfo"); + RESOLVE(GetMMMojoWriteInfoRequest, void*(__cdecl*)(void*, uint32_t), "GetMMMojoWriteInfoRequest"); + RESOLVE(RemoveMMMojoWriteInfo, void(__cdecl*)(void*), "RemoveMMMojoWriteInfo"); + RESOLVE(SendMMMojoWriteInfo, bool(__cdecl*)(void*, void*), "SendMMMojoWriteInfo"); +#undef RESOLVE + + // Sanity check the critical symbols. + if (!g_api.CreateMMMojoEnvironment || !g_api.StartMMMojoEnvironment || !g_api.SendMMMojoWriteInfo) { + g_loadError = "mmmojo.dll missing required exports"; + FreeLibrary(g_module); + g_module = nullptr; + return false; + } + return true; +} + +const MmmojoApi& Mmmojo() { return g_api; } + +const std::string& MmmojoLoadError() { return g_loadError; } + +} // namespace wechat_ocr diff --git a/plugins/f-provider/native/src/mmmojo.h b/plugins/f-provider/native/src/mmmojo.h new file mode 100644 index 00000000..22d1a15a --- /dev/null +++ b/plugins/f-provider/native/src/mmmojo.h @@ -0,0 +1,87 @@ +// Dynamic loader for WeChat's mmmojo(mmmojo_64).dll. +// +// Mirrors WeChatOcr/MmmojoDll.cs: every exported function we need is resolved +// at runtime via LoadLibrary/GetProcAddress so the .node has no link-time +// dependency on the DLL (which is proprietary and lives in wco_data/). +#pragma once + +#include +#include +#include + +namespace wechat_ocr { + +// Callback signatures — copied verbatim from DefaultCallbacks.cs so the function +// pointers handed back to mmmojo match its calling convention. +typedef void(__cdecl* MMReadPushDelegate)(uint32_t requestId, void* requestInfo, void* userData); +typedef void(__cdecl* MMReadPullDelegate)(uint32_t requestId, void* requestInfo, void* userData); +typedef void(__cdecl* MMReadSharedDelegate)(uint32_t requestId, void* requestInfo, void* userData); +typedef void(__cdecl* MMRemoteConnectDelegate)(bool isConnected, void* userData); +typedef void(__cdecl* MMRemoteDisconnectDelegate)(void* userData); +typedef void(__cdecl* MMRemoteProcessLaunchedDelegate)(void* userData); +typedef void(__cdecl* MMRemoteProcessLaunchFailedDelegate)(int errorCode, void* userData); +typedef void(__cdecl* MMRemoteMojoErrorDelegate)(void* errorBuf, int errorSize, void* userData); + +enum class MMMojoCallbackType { + kMMUserData = 0, + kMMReadPush, + kMMReadPull, + kMMReadShared, + kMMRemoteConnect, + kMMRemoteDisconnect, + kMMRemoteProcessLaunched, + kMMRemoteProcessLaunchFailed, + kMMRemoteMojoError +}; + +enum class MMMojoEnvironmentInitParamType { + kMMHostProcess = 0, + kMMLoopStartThread, + kMMExePath, + kMMLogPath, + kMMLogToStderr, + kMMAddNumMessagepipe, + kMMSetDisconnectHandlers, + kMMDisableDefaultPolicy = 1000, + kMMElevated, + kMMCompatible +}; + +enum class MMMojoInfoMethod { + kMMNone = 0, + kMMPush, + kMMPullReq, + kMMPullResp, + kMMShared +}; + +// Lazily loads the DLL on first use and resolves all symbols. Returns true if +// every required symbol was found (cached after the first success). +bool MmmojoLoad(const std::wstring& dllPath); + +// Human-readable reason for the most recent MmmojoLoad failure (empty on success). +const std::string& MmmojoLoadError(); + +// Resolved function pointers (only valid after MmmojoLoad() == true). +struct MmmojoApi { + void(__cdecl* InitializeMMMojo)(int argc, char** argv); + void(__cdecl* ShutdownMMMojo)(); + void*(__cdecl* CreateMMMojoEnvironment)(); + void(__cdecl* SetMMMojoEnvironmentCallbacks)(void* env, int type, void* callback); + void(__cdecl* SetMMMojoEnvironmentInitParams)(void* env, int type, void* param); + void(__cdecl* AppendMMSubProcessSwitchNative)(void* env, const char* switchStr, const wchar_t* value); + void(__cdecl* StartMMMojoEnvironment)(void* env); + void(__cdecl* StopMMMojoEnvironment)(void* env); + void(__cdecl* RemoveMMMojoEnvironment)(void* env); + void*(__cdecl* GetMMMojoReadInfoRequest)(void* readInfo, uint32_t* requestDataSize); + int(__cdecl* GetMMMojoReadInfoMethod)(void* readInfo); + void(__cdecl* RemoveMMMojoReadInfo)(void* readInfo); + void*(__cdecl* CreateMMMojoWriteInfo)(int method, int sync, uint32_t requestId); + void*(__cdecl* GetMMMojoWriteInfoRequest)(void* writeInfo, uint32_t requestDataSize); + void(__cdecl* RemoveMMMojoWriteInfo)(void* writeInfo); + bool(__cdecl* SendMMMojoWriteInfo)(void* env, void* writeInfo); +}; + +const MmmojoApi& Mmmojo(); + +} // namespace wechat_ocr diff --git a/plugins/f-provider/native/src/ocr_manager.cc b/plugins/f-provider/native/src/ocr_manager.cc new file mode 100644 index 00000000..bf8347b3 --- /dev/null +++ b/plugins/f-provider/native/src/ocr_manager.cc @@ -0,0 +1,197 @@ +#include "ocr_manager.h" + +#include +#include +#include + +#include "mmmojo.h" + +namespace wechat_ocr { + +OcrManager::OcrManager(const std::wstring& dataDir, const std::wstring& dllPath) + : dataDir_(dataDir), dllPath_(dllPath) { + // WeChatOCR.exe lives directly under wco_data/. + exePath_ = dataDir_ + L"\\WeChatOCR.exe"; + for (int i = 0; i < kOcrMaxTaskId; ++i) idleIds_.push(i); +} + +OcrManager::~OcrManager() { Shutdown(); } + +bool OcrManager::Start(std::string* err) { + if (running_.load()) return true; + + if (!MmmojoLoad(dllPath_)) { + if (err) *err = "failed to load mmmojo dll: " + MmmojoLoadError(); + return false; + } + + const auto& api = Mmmojo(); + + // Stash self-pointer as mmmojo user-data so static callbacks reach us. + // Equivalent to C# SetCallbackUsrData(GCHandle.ToIntPtr(...)). + void* userData = this; + + api.InitializeMMMojo(0, nullptr); + env_ = api.CreateMMMojoEnvironment(); + if (!env_) { + if (err) *err = "CreateMMMojoEnvironment failed"; + return false; + } + + // Register callbacks. Keep the delegates alive as members — mmmojo holds the + // raw pointers for the lifetime of the environment. + api.SetMMMojoEnvironmentCallbacks(env_, static_cast(MMMojoCallbackType::kMMUserData), userData); + + connectCb_ = &OcrManager::OnRemoteConnect; + disconnectCb_ = &OcrManager::OnRemoteDisconnect; + readPushCb_ = &OcrManager::OnReadPush; + + api.SetMMMojoEnvironmentCallbacks(env_, static_cast(MMMojoCallbackType::kMMRemoteConnect), + reinterpret_cast(connectCb_)); + api.SetMMMojoEnvironmentCallbacks(env_, static_cast(MMMojoCallbackType::kMMRemoteDisconnect), + reinterpret_cast(disconnectCb_)); + api.SetMMMojoEnvironmentCallbacks(env_, static_cast(MMMojoCallbackType::kMMReadPush), + reinterpret_cast(readPushCb_)); + + // Host process + exe path (mirrors XPluginManager.InitMmMojoEnv). + api.SetMMMojoEnvironmentInitParams(env_, static_cast(MMMojoEnvironmentInitParamType::kMMHostProcess), + reinterpret_cast(static_cast(1))); + api.SetMMMojoEnvironmentInitParams(env_, static_cast(MMMojoEnvironmentInitParamType::kMMExePath), + const_cast(exePath_.c_str())); + + // --user-lib-dir= : tells the OCR exe where to find Model/. + std::string key = "user-lib-dir"; + // value is a wide string; mmmojo expects the wide bytes. + api.AppendMMSubProcessSwitchNative(env_, key.c_str(), dataDir_.c_str()); + + api.StartMMMojoEnvironment(env_); + running_.store(true); + + // Wait for the child to connect (DoOcrTask polls the same flag in C#). + for (int i = 0; i < 50 && !connected_.load(); ++i) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + if (!connected_.load()) { + if (err) *err = "WeChatOCR.exe did not connect in time"; + Shutdown(); + return false; + } + return true; +} + +int OcrManager::GetIdleTaskId() { + std::lock_guard lock(mutex_); + if (idleIds_.empty()) return -1; + int id = idleIds_.front(); + idleIds_.pop(); + return id; +} + +void OcrManager::SetTaskIdle(int taskId) { + std::lock_guard lock(mutex_); + idleIds_.push(taskId); + pending_.erase(taskId); +} + +int OcrManager::Submit(const std::wstring& imagePath, ResultCallback cb) { + if (!running_.load() || !connected_.load()) return -1; + + // WeChatOCR expects an ANSI path on the protobuf wire (pic_path is `string`). + // Convert the wide path to UTF-8 to be safe with non-ASCII characters. + std::string picPath; + int len = WideCharToMultiByte(CP_UTF8, 0, imagePath.c_str(), -1, nullptr, 0, nullptr, nullptr); + if (len > 0) { + picPath.resize(len - 1); + WideCharToMultiByte(CP_UTF8, 0, imagePath.c_str(), -1, &picPath[0], len, nullptr, nullptr); + } + + int taskId = GetIdleTaskId(); + if (taskId < 0) return -1; + + { + std::lock_guard lock(mutex_); + pending_[taskId] = {imagePath, std::move(cb)}; + } + + std::string payload = EncodeOcrRequest(taskId, picPath); + const auto& api = Mmmojo(); + void* writeInfo = api.CreateMMMojoWriteInfo(static_cast(MMMojoInfoMethod::kMMPush), 0, 1); + void* req = api.GetMMMojoWriteInfoRequest(writeInfo, static_cast(payload.size())); + std::memcpy(req, payload.data(), payload.size()); + bool sent = api.SendMMMojoWriteInfo(env_, writeInfo); + // NOTE: do NOT call RemoveMMMojoWriteInfo here — removing the write-info + // before mmojo has dispatched the request causes the OCR task to be dropped + // (the C# reference SendPbSerializedData omits this call too). + (void)sent; + return taskId; +} + +void OcrManager::Shutdown() { + if (!running_.exchange(false)) return; + const auto& api = Mmmojo(); + if (env_) { + api.StopMMMojoEnvironment(env_); + api.RemoveMMMojoEnvironment(env_); + env_ = nullptr; + } + connected_.store(false); + std::lock_guard lock(mutex_); + // Fail any still-pending tasks. + for (auto& kv : pending_) { + OcrOutcome outcome; + outcome.ok = false; + outcome.taskId = kv.first; + outcome.errorMessage = "OCR engine shut down"; + if (kv.second.callback) kv.second.callback(outcome); + } + pending_.clear(); +} + +// ---- mmmojo callbacks (fire on mmojo's worker thread) ---- + +void __cdecl OcrManager::OnRemoteConnect(bool isConnected, void* userData) { + auto* self = static_cast(userData); + if (self) self->connected_.store(isConnected); +} + +void __cdecl OcrManager::OnRemoteDisconnect(void* userData) { + auto* self = static_cast(userData); + if (self) self->connected_.store(false); +} + +void __cdecl OcrManager::OnReadPush(uint32_t requestId, void* requestInfo, void* userData) { + auto* self = static_cast(userData); + if (!self || !requestInfo) return; + + const auto& api = Mmmojo(); + uint32_t pbSize = 0; + void* pbData = api.GetMMMojoReadInfoRequest(requestInfo, &pbSize); + if (pbSize <= 20 || !pbData) { + api.RemoveMMMojoReadInfo(requestInfo); + return; + } + + OcrResult parsed; + bool parsedOk = DecodeOcrResponse(static_cast(pbData), pbSize, parsed); + api.RemoveMMMojoReadInfo(requestInfo); + if (!parsedOk) return; + + // Route the result to the submitter by task id. + ResultCallback cb; + { + std::lock_guard lock(self->mutex_); + auto it = self->pending_.find(parsed.taskId); + if (it == self->pending_.end()) return; + cb = std::move(it->second.callback); + self->pending_.erase(it); + self->idleIds_.push(parsed.taskId); + } + + OcrOutcome outcome; + outcome.ok = true; + outcome.taskId = parsed.taskId; + outcome.result = std::move(parsed); + if (cb) cb(outcome); +} + +} // namespace wechat_ocr diff --git a/plugins/f-provider/native/src/ocr_manager.h b/plugins/f-provider/native/src/ocr_manager.h new file mode 100644 index 00000000..f1b71c04 --- /dev/null +++ b/plugins/f-provider/native/src/ocr_manager.h @@ -0,0 +1,94 @@ +// OcrManager — the heart of the bridge. Ported from WeChatOcr's +// OcrManager.cs + XPluginManager.cs + DefaultCallbacks.cs. +// +// Responsibilities: +// * Boot a mmmojo environment that launches WeChatOCR.exe as a child process. +// * Wait for the child to connect, then push protobuf-encoded OCR tasks. +// * On the kMMReadPush callback (which fires on mmmojo's own thread), parse +// the response and hand the result back to the caller via the callback. +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "mmmojo.h" +#include "pb.h" + +namespace wechat_ocr { + +// Outcome of a single OCR task. `errorMessage` is set when ok == false. +struct OcrOutcome { + bool ok = false; + int taskId = -1; + OcrResult result; + std::string errorMessage; +}; + +using ResultCallback = std::function; + +class OcrManager { + public: + static constexpr int kOcrMaxTaskId = 32; + + // `dataDir` is the wco_data/ directory (contains WeChatOCR.exe + Model). + // `dllPath` is the full path to mmmojo_64.dll (or mmmojo.dll). + OcrManager(const std::wstring& dataDir, const std::wstring& dllPath); + ~OcrManager(); + + OcrManager(const OcrManager&) = delete; + OcrManager& operator=(const OcrManager&) = delete; + + // Boot the environment. Returns false (with a message in *err) on failure. + bool Start(std::string* err); + + // Submit `imagePath` for recognition; `cb` is invoked (on mmmojo's thread) + // with the outcome. Returns the assigned task id, or -1 if the queue is full + // / not connected. + int Submit(const std::wstring& imagePath, ResultCallback cb); + + void Shutdown(); + + private: + // mmmojo callbacks (static, trampoline into the instance via user-data). + static void __cdecl OnRemoteConnect(bool isConnected, void* userData); + static void __cdecl OnRemoteDisconnect(void* userData); + static void __cdecl OnReadPush(uint32_t requestId, void* requestInfo, void* userData); + + int GetIdleTaskId(); + void SetTaskIdle(int taskId); + + // ---- resolved at construction ---- + std::wstring dataDir_; + std::wstring dllPath_; + std::wstring exePath_; // /WeChatOCR.exe + + // ---- runtime state ---- + void* env_ = nullptr; + std::atomic running_{false}; + std::atomic connected_{false}; + std::mutex mutex_; + + // idle task ids (0..31) waiting to be assigned. + std::queue idleIds_; + // pending tasks: taskId -> {imagePath, callback}. Only one in flight at a + // time per id, but we keep the map to route the async response back. + struct Pending { + std::wstring imagePath; + ResultCallback callback; + }; + std::map pending_; + + // Keep function pointers alive (mmojo callbacks store raw pointers). + MMRemoteConnectDelegate connectCb_; + MMRemoteDisconnectDelegate disconnectCb_; + MMReadPushDelegate readPushCb_; +}; + +} // namespace wechat_ocr diff --git a/plugins/f-provider/native/src/ocr_protobuf.proto b/plugins/f-provider/native/src/ocr_protobuf.proto new file mode 100644 index 00000000..64974cf5 --- /dev/null +++ b/plugins/f-provider/native/src/ocr_protobuf.proto @@ -0,0 +1,58 @@ +syntax = "proto3"; + +package ocr_protobuf; + +// OcrResponse / OcrRequest 定义,源自 ZGGSONG/WeChatOcr 的 ocr_protobuf.proto。 +// WeChatOCR.exe 通过 mmmojo 传递的字节流即为此消息的序列化结果。 + +message OcrResponse { + int32 type = 1; // 第一次运行OCR会有push一次type1, 正常OCR结束type0 + int32 task_id = 2; + int32 err_code = 3; + + message OcrResult { + message ResultPos { // 四个角的坐标 左上 右上 右下 左下 + message PosXY { + float x = 1; + float y = 2; + } + repeated PosXY pos = 1; + } + + message SingleResult { // SingleResult是一行结果 OneResult是单字的 + ResultPos single_pos = 1; + bytes single_str_utf8 = 2; // UTF8格式的字符串(base64编码的UTF8字节) + float single_rate = 3; // 单行的识别率 + + message OneResult { + ResultPos one_pos = 1; + bytes one_str_utf8 = 2; + } + repeated OneResult one_result = 4; + float left = 5; // 识别矩形的left\top\right\bottom的坐标 + float top = 6; + float right = 7; + float bottom = 8; + int32 unknown_0 = 9; // 未知 + ResultPos unknown_pos = 10; // 未知 + } + + repeated SingleResult single_result = 1; // repeated 每行的结果 + int32 unknown_1 = 2; + int32 unknown_2 = 3; + } + + OcrResult ocr_result = 4; +} + + +message OcrRequest { + int32 unknow = 1; // 必定为0 + int32 task_id = 2; + + message PicPaths { + repeated string pic_path = 1; // 不一定是repeated 猜的 + } + + PicPaths pic_path = 3; +} diff --git a/plugins/f-provider/native/src/pb.cc b/plugins/f-provider/native/src/pb.cc new file mode 100644 index 00000000..1773977c --- /dev/null +++ b/plugins/f-provider/native/src/pb.cc @@ -0,0 +1,280 @@ +// Minimal protobuf wire-format codec. See pb.h for rationale. +#include "pb.h" + +#include + +namespace wechat_ocr { + +// ---------- low-level writers ---------- + +static void WriteVarint(std::string& out, uint64_t v) { + while (v >= 0x80) { + out.push_back(static_cast((v & 0x7F) | 0x80)); + v >>= 7; + } + out.push_back(static_cast(v)); +} + +static void WriteTag(std::string& out, uint32_t fieldNumber, uint32_t wireType) { + WriteVarint(out, (static_cast(fieldNumber) << 3) | wireType); +} + +static void WriteFixed32(std::string& out, uint32_t fieldNumber, float value) { + uint32_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + WriteTag(out, fieldNumber, 5); + for (int i = 0; i < 4; ++i) out.push_back(static_cast((bits >> (i * 8)) & 0xFF)); +} + +static void WriteString(std::string& out, uint32_t fieldNumber, const std::string& value) { + WriteTag(out, fieldNumber, 2); + WriteVarint(out, value.size()); + out.append(value); +} + +// ---------- low-level readers ---------- + +namespace { + +// A cursor over a byte buffer. All reads are bounds-checked and the parser +// bails out (returns false) on truncation rather than reading OOB. +class Reader { + public: + Reader(const uint8_t* data, std::size_t size) : data_(data), size_(size) {} + + bool empty() const { return pos_ >= size_; } + + bool ReadByte(uint8_t& out) { + if (pos_ >= size_) return false; + out = data_[pos_++]; + return true; + } + + bool ReadVarint(uint64_t& out) { + out = 0; + int shift = 0; + uint8_t b = 0; + do { + if (pos_ >= size_) return false; + b = data_[pos_++]; + out |= static_cast(b & 0x7F) << shift; + shift += 7; + if (shift > 64) return false; // malformed varint + } while (b & 0x80); + return true; + } + + bool ReadFixed32(uint32_t& out) { + if (pos_ + 4 > size_) return false; + out = 0; + for (int i = 0; i < 4; ++i) out |= static_cast(data_[pos_ + i]) << (i * 8); + pos_ += 4; + return true; + } + + bool ReadBytes(std::vector& out, std::size_t n) { + if (pos_ + n > size_) return false; + out.assign(data_ + pos_, data_ + pos_ + n); + pos_ += n; + return true; + } + + bool Skip(std::size_t n) { + if (pos_ + n > size_) return false; + pos_ += n; + return true; + } + + private: + const uint8_t* data_; + std::size_t size_; + std::size_t pos_ = 0; +}; + +} // namespace + +// ---------- request encoder ---------- + +std::string EncodeOcrRequest(int taskId, const std::string& picPath) { + // PicPaths { repeated string pic_path = 1 } + std::string picPaths; + WriteString(picPaths, 1, picPath); + + // OcrRequest { unknow=1:int32, task_id=2:int32, pic_path=3:PicPaths } + std::string out; + WriteTag(out, 1, 0); // field 1 (unknow), varint; value 0 omitted-by-default, but + WriteVarint(out, 0); // the C# client explicitly sets 0, so we mirror it. + WriteTag(out, 2, 0); // field 2 (task_id), varint + WriteVarint(out, static_cast(taskId)); + WriteString(out, 3, picPaths); // field 3 (pic_path), length-delimited + return out; +} + +// ---------- response decoder ---------- + +// Recursively skip a field given its wire type. Returns false on malformed data. +static bool SkipField(Reader& r, uint32_t wireType) { + switch (wireType) { + case 0: { // varint + uint64_t v; + return r.ReadVarint(v); + } + case 1: { // 64-bit + return r.Skip(8); + } + case 2: { // length-delimited + uint64_t len; + if (!r.ReadVarint(len)) return false; + return r.Skip(static_cast(len)); + } + case 5: { // 32-bit + return r.Skip(4); + } + default: + return false; // unknown wire type + } +} + +// Parse SingleResult.message -> OcrLine. +static bool ParseSingleResult(const uint8_t* data, std::size_t size, OcrLine& line) { + Reader r(data, size); + while (!r.empty()) { + uint64_t tag; + if (!r.ReadVarint(tag)) return false; + uint32_t fieldNumber = tag >> 3; + uint32_t wireType = tag & 0x7; + + switch (fieldNumber) { + case 2: { // single_str_utf8 (bytes, base64 of UTF8 text) + uint64_t len; + if (wireType != 2 || !r.ReadVarint(len)) return false; + std::vector bytes; + if (!r.ReadBytes(bytes, static_cast(len))) return false; + // The server returns base64-encoded UTF-8 here (see C# ParseOcrResult). + // We pass the raw (still base64) bytes to JS and decode there. + line.text.assign(reinterpret_cast(bytes.data()), bytes.size()); + break; + } + case 3: { // single_rate (float) + uint32_t bits; + if (wireType != 5 || !r.ReadFixed32(bits)) return false; + std::memcpy(&line.rate, &bits, sizeof(bits)); + break; + } + case 5: { // left + uint32_t bits; + if (wireType != 5 || !r.ReadFixed32(bits)) return false; + std::memcpy(&line.left, &bits, sizeof(bits)); + break; + } + case 6: { // top + uint32_t bits; + if (wireType != 5 || !r.ReadFixed32(bits)) return false; + std::memcpy(&line.top, &bits, sizeof(bits)); + break; + } + case 7: { // right + uint32_t bits; + if (wireType != 5 || !r.ReadFixed32(bits)) return false; + std::memcpy(&line.right, &bits, sizeof(bits)); + break; + } + case 8: { // bottom + uint32_t bits; + if (wireType != 5 || !r.ReadFixed32(bits)) return false; + std::memcpy(&line.bottom, &bits, sizeof(bits)); + break; + } + case 1: // single_pos (ResultPos) -> derive 4 box points below from l/t/r/b + case 4: // one_result (per-character) -> ignored for line view + case 9: // unknown_0 + case 10: // unknown_pos + if (!SkipField(r, wireType)) return false; + break; + default: + if (!SkipField(r, wireType)) return false; + break; + } + } + + // Build 4 box points (lt, rt, rb, lb) from the bounding rect, matching the + // original C# Converter(x,y,w,h). + const float w = line.right - line.left; + const float h = line.bottom - line.top; + line.boxPoints.push_back({line.left, line.top}); + line.boxPoints.push_back({line.left + w, line.top}); + line.boxPoints.push_back({line.left + w, line.top + h}); + line.boxPoints.push_back({line.left, line.top + h}); + return true; +} + +// Parse OcrResult.message (field 4 of OcrResponse). +static bool ParseOcrResult(const uint8_t* data, std::size_t size, OcrResult& out) { + Reader r(data, size); + while (!r.empty()) { + uint64_t tag; + if (!r.ReadVarint(tag)) return false; + uint32_t fieldNumber = tag >> 3; + uint32_t wireType = tag & 0x7; + + switch (fieldNumber) { + case 1: { // repeated SingleResult + uint64_t len; + if (wireType != 2 || !r.ReadVarint(len)) return false; + std::vector bytes; + if (!r.ReadBytes(bytes, static_cast(len))) return false; + OcrLine line; + if (ParseSingleResult(bytes.data(), bytes.size(), line)) out.lines.push_back(std::move(line)); + break; + } + case 2: // unknown_1 + case 3: // unknown_2 + if (!SkipField(r, wireType)) return false; + break; + default: + if (!SkipField(r, wireType)) return false; + break; + } + } + return true; +} + +bool DecodeOcrResponse(const uint8_t* data, std::size_t size, OcrResult& out) { + Reader r(data, size); + while (!r.empty()) { + uint64_t tag; + if (!r.ReadVarint(tag)) return false; + uint32_t fieldNumber = tag >> 3; + uint32_t wireType = tag & 0x7; + + switch (fieldNumber) { + case 2: { // task_id + uint64_t v; + if (wireType != 0 || !r.ReadVarint(v)) return false; + out.taskId = static_cast(v); + break; + } + case 3: { // err_code + uint64_t v; + if (wireType != 0 || !r.ReadVarint(v)) return false; + out.errCode = static_cast(v); + break; + } + case 4: { // ocr_result + uint64_t len; + if (wireType != 2 || !r.ReadVarint(len)) return false; + std::vector bytes; + if (!r.ReadBytes(bytes, static_cast(len))) return false; + ParseOcrResult(bytes.data(), bytes.size(), out); + break; + } + case 1: // type + default: + if (!SkipField(r, wireType)) return false; + break; + } + } + return true; +} + +} // namespace wechat_ocr diff --git a/plugins/f-provider/native/src/pb.h b/plugins/f-provider/native/src/pb.h new file mode 100644 index 00000000..91007050 --- /dev/null +++ b/plugins/f-provider/native/src/pb.h @@ -0,0 +1,44 @@ +// Minimal protobuf wire-format codec for WeChatOCR's request/response. +// +// We hand-roll the wire format instead of pulling in libprotobuf: the schema +// (ocr_protobuf.proto) is tiny and stable, and avoiding the library keeps the +// .node build dependency-free (apart from mmmojo/WeChatOCR.exe themselves). +// +// Wire types: 0=varint, 1=64bit, 2=length-delimited, 5=32bit. +#pragma once + +#include +#include +#include +#include + +namespace wechat_ocr { + +struct BoxPoint { + float x = 0; + float y = 0; +}; + +struct OcrLine { + std::string text; + float rate = 0; + float left = 0; + float top = 0; + float right = 0; + float bottom = 0; + std::vector boxPoints; +}; + +struct OcrResult { + int taskId = 0; + int errCode = 0; + std::vector lines; +}; + +// Serialize an OcrRequest { unknow=0, task_id, pic_path{[picPath]} }. +std::string EncodeOcrRequest(int taskId, const std::string& picPath); + +// Parse an OcrResponse byte buffer into `out`. Returns false on malformed data. +bool DecodeOcrResponse(const uint8_t* data, std::size_t size, OcrResult& out); + +} // namespace wechat_ocr diff --git a/plugins/f-provider/native/test_e2e.js b/plugins/f-provider/native/test_e2e.js new file mode 100644 index 00000000..d384772b --- /dev/null +++ b/plugins/f-provider/native/test_e2e.js @@ -0,0 +1,36 @@ +// End-to-end OCR test. Run: node test_e2e.js +const path = require('node:path') +const fs = require('node:fs') +const addon = require('./index.js') + +const dataDir = path.join(__dirname, 'wco_data') +const imgPath = process.argv[2] || 'C:\\Users\\98014\\AppData\\Local\\Temp\\ocr_test.png' + +if (!fs.existsSync(path.join(dataDir, 'WeChatOCR.exe'))) { + console.error('ERROR: wco_data missing WeChatOCR.exe at', dataDir) + process.exit(1) +} + +console.log('init()...', dataDir) +addon.init(dataDir) +console.log('init ok') + +console.log('ocr()...', imgPath) +const t0 = Date.now() +addon.ocr(imgPath).then((res) => { + console.log('ocr done in', Date.now() - t0, 'ms') + console.log('ok:', res.ok, 'taskId:', res.taskId) + if (res.lines) { + for (const line of res.lines) { + // text is already decoded UTF-8 from the native addon + console.log(` [${line.rate.toFixed(3)}] "${line.text}" box=(${line.left.toFixed(0)},${line.top.toFixed(0)})-(${line.right.toFixed(0)},${line.bottom.toFixed(0)})`) + } + } else { + console.log(' error:', res.error) + } + addon.dispose() +}).catch((e) => { + console.error('ocr rejected:', e) + addon.dispose() + process.exit(1) +}) diff --git a/plugins/f-provider/package-lock.json b/plugins/f-provider/package-lock.json new file mode 100644 index 00000000..aea77643 --- /dev/null +++ b/plugins/f-provider/package-lock.json @@ -0,0 +1,1603 @@ +{ + "name": "wechat-ocr-provider", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wechat-ocr-provider", + "version": "1.0.0", + "dependencies": { + "vue": "^3.5.13", + "vue-router": "^4.6.4", + "ztools-ui": "^0.1.3" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "@ztools-center/ztools-api-types": "^1.0.1", + "typescript": "^5.3.0", + "vite": "^6.0.11", + "vue-tsc": "^2.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz", + "integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.39", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz", + "integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz", + "integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.39", + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz", + "integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.39.tgz", + "integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.39.tgz", + "integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz", + "integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/runtime-core": "3.5.39", + "@vue/shared": "3.5.39", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.39.tgz", + "integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "vue": "3.5.39" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz", + "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz", + "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz", + "integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz", + "integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==", + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@ztools-center/ztools-api-types": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@ztools-center/ztools-api-types/-/ztools-api-types-1.0.3.tgz", + "integrity": "sha512-dv1eOAIasAupqKaQL/gESk1i2+RebdM/1gvZhrvH2D/bo3enCUsAGJ8nrHnlCLBSOGB81eC/SU0IH8BNsUlmvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz", + "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-sfc": "3.5.39", + "@vue/runtime-dom": "3.5.39", + "@vue/server-renderer": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/ztools-ui": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/ztools-ui/-/ztools-ui-0.1.3.tgz", + "integrity": "sha512-Pr+3y1QUtf4lE74U49vE440sgaptYHcngieu2rYfK1n4KuYXV07Xqd9Yn7Mm4UpUilE1G/tW2H4JTLh4Uf9pYA==", + "dependencies": { + "@vueuse/core": "^12.7.0", + "marked": "^15.0.7" + }, + "peerDependencies": { + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "peerDependenciesMeta": { + "vue-router": { + "optional": true + } + } + } + } +} diff --git a/plugins/f-provider/package.json b/plugins/f-provider/package.json new file mode 100644 index 00000000..c980475d --- /dev/null +++ b/plugins/f-provider/package.json @@ -0,0 +1,24 @@ +{ + "name": "wechat-ocr-provider", + "version": "1.0.0", + "description": "基于微信内置 OCR 引擎的离线图片文字识别 ZTools Provider", + "type": "module", + "scripts": { + "dev": "vite", + "build:native": "cd native && npm install && npm run build", + "copy:native": "node scripts/copy-native.mjs", + "build": "vue-tsc && vite build" + }, + "dependencies": { + "vue": "^3.5.13", + "vue-router": "^4.6.4", + "ztools-ui": "^0.1.3" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "@ztools-center/ztools-api-types": "^1.0.1", + "typescript": "^5.3.0", + "vite": "^6.0.11", + "vue-tsc": "^2.0.0" + } +} diff --git a/plugins/f-provider/public/logo.png b/plugins/f-provider/public/logo.png new file mode 100644 index 00000000..85455145 Binary files /dev/null and b/plugins/f-provider/public/logo.png differ diff --git a/plugins/f-provider/public/plugin.json b/plugins/f-provider/public/plugin.json new file mode 100644 index 00000000..54644693 --- /dev/null +++ b/plugins/f-provider/public/plugin.json @@ -0,0 +1,98 @@ +{ + "$schema": "node_modules/@ztools-center/ztools-api-types/resource/ztools.schema.json", + "name": "f-provider", + "title": "ZTools 提供商", + "description": "OCR + 翻译提供商集合(百度/谷歌/有道/微软翻译、微信 OCR)", + "author": "kaineooo", + "version": "1.0.0", + "main": "index.html", + "preload": "preload/services.js", + "logo": "logo.png", + "development": { + "main": "http://localhost:5173" + }, + "providers": { + "ocr": { + "type": "ocr", + "label": "微信 OCR", + "description": "调用本机微信内置 OCR 引擎,离线识别图片文字" + }, + "baidu": { + "type": "translation", + "label": "百度翻译", + "description": "百度翻译 API(需 AppID/AppKey)" + }, + "google": { + "type": "translation", + "label": "谷歌翻译", + "description": "谷歌翻译(免费反代端点,无需凭据)" + }, + "youdao": { + "type": "translation", + "label": "有道翻译", + "description": "有道翻译 API(需 AppKey/AppSecret)" + }, + "microsoft": { + "type": "translation", + "label": "微软翻译", + "description": "微软 Edge 翻译(免密钥,两种鉴权方案)" + } + }, + "features": [ + { + "code": "manage", + "explain": "OCR 识别与翻译(关键词进入管理;图片/选中文本进入识别或翻译)", + "icon": "logo.png", + "cmds": [ + { + "label": "ZTools 提供商" + }, + { + "type": "img", + "label": "识别图片文字" + }, + { + "type": "files", + "extensions": ["png", "jpg", "jpeg", "gif", "bmp", "webp"], + "maxLength": 1, + "label": "识别图片文字" + }, + { + "type": "regex", + "label": "翻译", + "match": "^[\\s\\S]*$", + "minLength": 1 + } + ] + }, + { + "code": "code-translate", + "explain": "把选中文本翻译为代码命名(多种风格,↑↓ 切换、回车粘贴)", + "icon": "logo.png", + "cmds": [ + { + "type": "regex", + "label": "代码翻译", + "match": "^[\\s\\S]*$", + "minLength": 1 + } + ] + }, + { + "code": "screen-ocr", + "explain": "截屏并识别图中文字(自动截屏→OCR)", + "icon": "logo.png", + "platform": ["win32"], + "cmds": [ + { + "label": "截图识别文字" + } + ] + } + ], + "native": { + "downloadUrl": "https://github.com/kaineooo/f-provider/releases/download/v1.0.0/native.zip", + "sha256": "", + "version": "1.0.0" + } +} diff --git a/plugins/f-provider/public/preload/package.json b/plugins/f-provider/public/preload/package.json new file mode 100644 index 00000000..5bbefffb --- /dev/null +++ b/plugins/f-provider/public/preload/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/plugins/f-provider/public/preload/services.js b/plugins/f-provider/public/preload/services.js new file mode 100644 index 00000000..0d5cdd6b --- /dev/null +++ b/plugins/f-provider/public/preload/services.js @@ -0,0 +1,794 @@ +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const https = require('node:https') +const http = require('node:http') +const crypto = require('node:crypto') +const { URL } = require('node:url') + +// native.zip 下载加速镜像前缀。下载前对它们并发竞速(谁先返回响应头谁胜出), +// 用选中的镜像走完整下载,规避 GitHub Release 直连慢的问题。 +// 格式为「前缀 + 完整原始 URL」,如: +// https://gh-proxy.org/https://github.com/Particaly/ztools-f-provider/releases/download/v1.0.0/native.zip +const GH_PROXY_HOSTS = [ + 'https://gh-proxy.org/', + 'https://v4.gh-proxy.org/', + 'https://v6.gh-proxy.org/', + 'https://cdn.gh-proxy.org/' +] + + +// ────────────────────────────────────────────────────────────────────────── +// f-provider: 微信 OCR Provider +// +// 通过原生模块 wechat_ocr.node 调用本机微信内置 OCR 引擎 +// (mmmojo.dll + WeChatOCR.exe)实现离线图片文字识别。 +// +// 既作为 Provider(在 plugin.json 的 providers.ocr 声明)供主程序聚合调用, +// 也提供一个交互式 feature(拖入图片 → 识别 → 展示)作为可视化入口。 +// ────────────────────────────────────────────────────────────────────────── + +// 通过 window 对象向渲染进程注入 nodejs 能力 +window.services = { + // 读文件 + readFile(file) { + return fs.readFileSync(file, { encoding: 'utf-8' }) + }, + // 读图片二进制并返回 data URI(供 / 直接预览)。 + // 超级面板「选择文件」入口拿到的是本地 path,渲染进程无法直接加载, + // 故由 preload(Node 侧)读取并编码为 base64 data URI 返回。 + readFileAsDataURL(file) { + const buf = fs.readFileSync(file) + // 取扩展名映射 mime;未知类型兜底为 image/png + const ext = path.extname(file).toLowerCase().replace(/^\./, '') + const mimeMap = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + bmp: 'image/bmp', + webp: 'image/webp', + svg: 'image/svg+xml' + } + const mime = mimeMap[ext] || 'image/png' + return 'data:' + mime + ';base64,' + buf.toString('base64') + }, + // 文本写入到下载目录 + writeTextFile(text) { + const filePath = path.join(window.ztools.getPath('downloads'), Date.now().toString() + '.txt') + fs.writeFileSync(filePath, text, { encoding: 'utf-8' }) + return filePath + }, + // 图片写入到下载目录 + writeImageFile(base64Url) { + const matchs = /^data:image\/([a-z]{1,20});base64,/i.exec(base64Url) + if (!matchs) return + const filePath = path.join( + window.ztools.getPath('downloads'), + Date.now().toString() + '.' + matchs[1] + ) + fs.writeFileSync(filePath, base64Url.substring(matchs[0].length), { encoding: 'base64' }) + return filePath + }, + + // ─── 微信 OCR(基于 wechat_ocr.node 原生模块)───────────────────────── + // 原生模块懒加载:首次调用时才 require + init,避免插件加载即拉起 + // WeChatOCR.exe 子进程。 + _ocrAddon: null, + _ocrDataDir() { + // preload 文件位于 /preload/services.js + // wco_data 位于 /native/wco_data + return path.join(__dirname, '..', 'native', 'wco_data') + }, + _ocrEnsure() { + if (this._ocrAddon) return this._ocrAddon + const nativeEntry = path.join(__dirname, '..', 'native', 'index.js') + this._ocrAddon = require(nativeEntry) + this._ocrAddon.init(this._ocrDataDir()) + return this._ocrAddon + }, + + // 把任意 image 输入(本地路径 / data URI / http(s) URL)归一化为本地临时文件路径。 + // 识别完成后由调用方负责删除。 + async _ocrMaterialize(image) { + if (typeof image !== 'string' || !image) throw new Error('image 为空') + + // 本地路径:直接返回 + if (!/^data:/i.test(image) && !/^https?:\/\//i.test(image)) { + if (!fs.existsSync(image)) throw new Error('图片文件不存在: ' + image) + return image + } + + // data URI:解码写临时文件 + const dataMatch = /^data:image\/([a-z]{1,20});base64,/i.exec(image) + if (dataMatch) { + const ext = dataMatch[1] === 'jpeg' ? 'jpg' : dataMatch[1] + const tmp = path.join(os.tmpdir(), `ztools-wechat-ocr-${Date.now()}.${ext}`) + fs.writeFileSync(tmp, image.substring(dataMatch[0].length), { encoding: 'base64' }) + return tmp + } + + // http(s) URL:下载到临时文件 + return new Promise((resolve, reject) => { + const tmp = path.join(os.tmpdir(), `ztools-wechat-ocr-${Date.now()}.png`) + const file = fs.createWriteStream(tmp) + const client = image.startsWith('https') ? https : http + const req = client.get(image, (res) => { + if (res.statusCode !== 200) { + file.close() + try { fs.unlinkSync(tmp) } catch (_) {} + reject(new Error('下载图片失败: HTTP ' + res.statusCode)) + return + } + res.pipe(file) + file.on('finish', () => file.close(() => resolve(tmp))) + }) + req.on('error', (err) => { + file.close() + try { fs.unlinkSync(tmp) } catch (_) {} + reject(err) + }) + }) + }, + + // 核心 OCR:image 为 本地路径 / data URI / http(s) URL。 + // 返回 provider 契约结构 { text, blocks?, confidence? };失败抛错。 + async ocrRecognize(image /*, lang */) { + const addon = this._ocrEnsure() + const tmpFile = await this._ocrMaterialize(image) + const isTemp = tmpFile !== image + try { + const result = await addon.ocr(tmpFile) + if (!result.ok) throw new Error(result.error || '微信 OCR 识别失败') + const lines = result.lines || [] + return { + text: lines.map((l) => l.text).join('\n'), + blocks: lines.map((l) => l.text), + confidence: lines.length + ? lines.reduce((s, l) => s + (l.rate || 0), 0) / lines.length + : 0 + } + } finally { + if (isTemp) { + try { fs.unlinkSync(tmpFile) } catch (_) {} + } + } + }, + + // 交互式 feature 使用的版本:返回带坐标的明细结构。 + async ocrImageDetail(image) { + const addon = this._ocrEnsure() + const tmpFile = await this._ocrMaterialize(image) + const isTemp = tmpFile !== image + try { + const result = await addon.ocr(tmpFile) + if (!result.ok) return { ok: false, error: result.error } + return { ok: true, taskId: result.taskId, lines: result.lines || [] } + } catch (e) { + return { ok: false, error: String(e && e.message ? e.message : e), lines: [] } + } finally { + if (isTemp) { + try { fs.unlinkSync(tmpFile) } catch (_) {} + } + } + }, + + // 释放 OCR 引擎(停止 WeChatOCR.exe 子进程) + ocrDispose() { + if (this._ocrAddon) { + try { this._ocrAddon.dispose() } catch (_) {} + this._ocrAddon = null + } + }, + + // ─── native 引擎下载/状态管理 ───────────────────────────────────────── + // 插件初始不带 native;前端展示下载状态,用户点击下载后下载 native.zip + // 并解压到插件根目录(zip 顶层为 native/,解压后落到 /native/)。 + _pluginRoot() { + // preload 文件位于 /preload/services.js + return path.join(__dirname, '..') + }, + _nativeDir() { + return path.join(this._pluginRoot(), 'native') + }, + // 读 plugin.json 的 native 配置块(缓存) + _nativeConfig() { + if (this._nativeConfigCache) return this._nativeConfigCache + try { + const raw = fs.readFileSync(path.join(this._pluginRoot(), 'plugin.json'), 'utf8') + const cfg = JSON.parse(raw) + this._nativeConfigCache = (cfg && cfg.native) || {} + } catch (_) { + this._nativeConfigCache = {} + } + return this._nativeConfigCache + }, + _nativeConfigCache: null, + + // 检查 native 引擎是否就绪。真值来源 = 关键文件存在与否(不靠 dbStorage 记忆,避免漂移)。 + nativeStatus() { + const dir = this._nativeDir() + const nodeFile = path.join(dir, 'build', 'Release', 'wechat_ocr.node') + const exe = path.join(dir, 'wco_data', 'WeChatOCR.exe') + const missing = [] + if (!fs.existsSync(nodeFile)) missing.push('build/Release/wechat_ocr.node') + if (!fs.existsSync(exe)) missing.push('wco_data/WeChatOCR.exe') + return { + ready: missing.length === 0, + missing, + version: this._nativeConfig().version || null + } + }, + + // 并发竞速选最快的加速镜像。对每个代理前缀拼出完整 URL 并发 GET,谁先返回 + // 响应头谁胜出(不消费 body,立即 abort 其余)。返回选中的完整 URL。 + // 全部失败/超时则回退原始 URL(直连兜底,保证永不卡死)。 + // 仅对 github.com 的 URL 启用代理;其余域名原样返回。 + _pickFastestMirror(rawUrl) { + // 非 github.com URL(或非法 URL)跳过代理。 + try { + const u = new URL(rawUrl) + if (!/github\.com$/i.test(u.hostname) && u.hostname !== 'github.com') { + return Promise.resolve(rawUrl) + } + } catch (_) { + return Promise.resolve(rawUrl) + } + + const TIMEOUT_MS = 8000 + const candidates = GH_PROXY_HOSTS.map((prefix) => prefix + rawUrl) + + return new Promise((resolve) => { + let settled = false // 是否已选出胜者 + const reqs = [] + const timers = [] + + const finish = (url) => { + if (settled) return + settled = true + // 立即 abort 其余在途请求,清理定时器。 + timers.forEach((t) => clearTimeout(t)) + reqs.forEach((r) => { try { r.destroy() } catch (_) {} }) + resolve(url) + } + + candidates.forEach((url) => { + let parsed + try { parsed = new URL(url) } catch (_) { return } + const req = https.get(parsed, () => { + // 收到响应头即定胜负(不论状态码,能握手就算可达)。 + finish(url) + }) + reqs.push(req) + req.on('error', () => {}) // 单个失败不影响其余;最终兜底处理 + // 超时:到点仍未握手,单独 destroy,等其余或兜底。 + const timer = setTimeout(() => { try { req.destroy() } catch (_) {} }, TIMEOUT_MS) + timers.push(timer) + }) + + // 所有候选都失败/超时 → 回退原始 URL 直连。 + Promise.all( + reqs.map( + (r) => + new Promise((res) => { + if (r.destroyed) return res() + r.on('close', () => res()) + r.on('error', () => res()) + }) + ) + ).then(() => { + if (!settled) finish(rawUrl) + }) + }) + }, + + // 下载 native.zip 到临时目录,支持 3xx 重定向跟随(兼容 GitHub release 跳 CDN)。 + // onProgress({ phase, percent, loaded, total }) 用于上报进度。 + _downloadFile(url, dest, onProgress, maxRedirects) { + maxRedirects = maxRedirects == null ? 5 : maxRedirects + return new Promise((resolve, reject) => { + let parsed + try { parsed = new URL(url) } catch (e) { reject(e); return } + const client = parsed.protocol === 'https:' ? https : http + const req = client.get(parsed, (res) => { + // 重定向 + if ( + res.statusCode >= 300 && + res.statusCode < 400 && + res.headers.location + ) { + res.resume() + if (maxRedirects <= 0) { + reject(new Error('下载重定向次数过多')) + return + } + const next = new URL(res.headers.location, parsed).toString() + this._downloadFile(next, dest, onProgress, maxRedirects - 1) + .then(resolve, reject) + return + } + if (res.statusCode !== 200) { + res.resume() + reject(new Error('下载失败: HTTP ' + res.statusCode)) + return + } + const total = Number(res.headers['content-length']) || 0 + let loaded = 0 + const file = fs.createWriteStream(dest) + res.on('data', (chunk) => { + loaded += chunk.length + if (onProgress) { + onProgress({ + phase: 'downloading', + loaded, + total, + percent: total > 0 ? Math.min(100, Math.round((loaded / total) * 100)) : 0 + }) + } + }) + res.pipe(file) + file.on('finish', () => file.close(() => resolve())) + file.on('error', (err) => { + try { fs.unlinkSync(dest) } catch (_) {} + reject(err) + }) + }) + req.on('error', reject) + }) + }, + + // 流式 sha256 校验 + _sha256File(filePath) { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('sha256') + const stream = fs.createReadStream(filePath) + stream.on('data', (d) => hash.update(d)) + stream.on('end', () => resolve(hash.digest('hex'))) + stream.on('error', reject) + }) + }, + + // 用 PowerShell Expand-Archive 解压 zip 到插件根目录(-Force 幂等覆盖)。 + _extractZip(zipPath, destDir) { + const { spawnSync } = require('node:child_process') + const psScript = + 'Expand-Archive -Path ' + + "'" + zipPath.replace(/'/g, "''") + "'" + + ' -DestinationPath ' + + "'" + destDir.replace(/'/g, "''") + "'" + + ' -Force' + const r = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', psScript], { + encoding: 'utf8', + shell: false + }) + if (r.status !== 0) { + const detail = (r.stderr || r.stdout || '').toString().trim() + throw new Error('解压失败' + (detail ? ': ' + detail : '')) + } + }, + + // 主流程:下载 + 校验 + 解压 + 复检。 + // onProgress({ phase, percent, loaded, total }) -> Promise<{ ok, error? }> + async nativeDownload(onProgress) { + const cfg = this._nativeConfig() + if (!cfg.downloadUrl) { + return { ok: false, error: '未配置 native 下载地址,请在 plugin.json 中设置 native.downloadUrl' } + } + // 释放可能已加载的旧引擎,避免解压覆盖 .node 后引用悬空。 + if (this._ocrAddon) { + try { this._ocrAddon.dispose() } catch (_) {} + this._ocrAddon = null + } + const tmpZip = path.join(os.tmpdir(), `wechat-ocr-native-${Date.now()}.zip`) + try { + // 下载阶段:先并发竞速选最快的 gh-proxy 镜像(透明,不报进度)。 + if (onProgress) onProgress({ phase: 'downloading', percent: 0, loaded: 0, total: 0 }) + const downloadUrl = await this._pickFastestMirror(cfg.downloadUrl) + await this._downloadFile(downloadUrl, tmpZip, onProgress) + + // 可选 sha256 校验 + if (cfg.sha256) { + const sum = await this._sha256File(tmpZip) + if (sum.toLowerCase() !== String(cfg.sha256).toLowerCase()) { + return { ok: false, error: '校验和不匹配,文件可能已损坏' } + } + } + + // 解压阶段(zip 顶层为 native/,解压到插件根目录即还原) + if (onProgress) onProgress({ phase: 'extracting', percent: 0, loaded: 0, total: 0 }) + this._extractZip(tmpZip, this._pluginRoot()) + + // 复检关键文件 + const status = this.nativeStatus() + if (!status.ready) { + return { + ok: false, + error: '解压完成但缺少关键文件: ' + status.missing.join(', ') + } + } + return { ok: true } + } catch (e) { + return { ok: false, error: String(e && e.message ? e.message : e) } + } finally { + try { fs.unlinkSync(tmpZip) } catch (_) {} + } + }, + + // 删除已下载的 native 目录(便于重新下载/释放空间)。 + nativeRemove() { + const dir = this._nativeDir() + if (this._ocrAddon) { + try { this._ocrAddon.dispose() } catch (_) {} + this._ocrAddon = null + } + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }) + return true + } + return false + }, + + // ─── 翻译 Providers(百度/谷歌/有道/微软)────────────────────────────────── +// 契约(对齐宿主 src/shared/providerShared.ts TranslationInput/Output): +// input { text, from?, to? } from/to 为语言码字符串,缺省视为 'auto' +// output { text, detectedFrom? } +// +// 凭据存储: +// - 敏感字段(百度 AppID/AppKey、有道 AppKey/AppSecret)走 ztools.dbCryptoStorage +// - 非敏感(微软鉴权模式、各 provider 是否启用)走 ztools.dbStorage +// - 统一键名 'translate.',值为对象 +// +// 语言码使用宿主契约里的中性字符串(auto/zh-CN/zh-TW/en/ja/...), +// 各 provider 内部再映射到自家 API 的语种代码。 + +// 通用 HTTP 请求:支持 JSON / form-urlencoded / 查询参数 / 3xx 跟随。 +// 返回 { status, headers, body };非 2xx 抛错。 +async _httpRequest(method, url, opts) { + opts = opts || {} + const maxRedirects = opts.maxRedirects == null ? 5 : opts.maxRedirects + const timeoutMs = opts.timeoutMs || 15000 + + const buildQS = (query) => { + if (!query) return '' + const sp = new URLSearchParams() + for (const [k, v] of Object.entries(query)) sp.append(k, String(v)) + const s = sp.toString() + return s ? '?' + s : '' + } + + const doOnce = (targetUrl) => + new Promise((resolve, reject) => { + let parsed + try { parsed = new URL(targetUrl) } catch (e) { reject(e); return } + const client = parsed.protocol === 'https:' ? https : http + const headers = Object.assign({}, opts.headers || {}) + // 微软等端点会校验 User-Agent,缺省或 Node 默认 UA 会被拒(400 Client Browser Version not supported)。 + // 这里给一个 Chrome UA 兜底,调用方可显式覆盖。 + if (!headers['User-Agent'] && !headers['user-agent']) { + headers['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36' + } + let bodyBuf = null + + if (opts.json !== undefined) { + bodyBuf = Buffer.from(JSON.stringify(opts.json), 'utf8') + headers['Content-Type'] = headers['Content-Type'] || 'application/json' + headers['Content-Length'] = bodyBuf.length + } else if (opts.form !== undefined) { + bodyBuf = Buffer.from(new URLSearchParams(opts.form).toString(), 'utf8') + headers['Content-Type'] = headers['Content-Type'] || 'application/x-www-form-urlencoded' + headers['Content-Length'] = bodyBuf.length + } else if (opts.body !== undefined) { + bodyBuf = Buffer.from(String(opts.body), 'utf8') + headers['Content-Length'] = bodyBuf.length + } + + const reqPath = parsed.pathname + (parsed.search || buildQS(opts.query)) + const reqOpts = { + method, + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: reqPath, + headers + } + const req = client.request(reqOpts, (res) => { + // 3xx 跟随 + if ( + res.statusCode >= 300 && + res.statusCode < 400 && + res.headers.location + ) { + res.resume() + if (maxRedirects <= 0) { + reject(new Error('重定向次数过多')) + return + } + const next = new URL(res.headers.location, parsed).toString() + this._httpRequest(method, next, Object.assign({}, opts, { maxRedirects: maxRedirects - 1 })) + .then(resolve, reject) + return + } + const chunks = [] + res.on('data', (c) => chunks.push(c)) + res.on('end', () => { + const buf = Buffer.concat(chunks) + resolve({ status: res.statusCode, headers: res.headers, body: buf.toString('utf8') }) + }) + }) + req.on('error', reject) + req.setTimeout(timeoutMs, () => { + req.destroy(new Error('请求超时')) + }) + if (bodyBuf) req.write(bodyBuf) + req.end() + }) + + const ret = await doOnce(url) + if (ret.status >= 200 && ret.status < 300) return ret + throw new Error(`HTTP ${ret.status}: ${ret.body.slice(0, 500)}`) +}, + +// 语言映射:把宿主中性语言码映射到各 provider 自家语种码。未映射返回 null(不支持)。 +// 移植自 STranslate 四个翻译插件的 GetSourceLanguage/GetTargetLanguage。 +TRANSLATE_LANG_MAP: { + baidu: { + auto: 'auto', 'zh-CN': 'zh', 'zh-TW': 'cht', yue: 'yue', en: 'en', ja: 'jp', + ko: 'kor', fr: 'fra', es: 'spa', ru: 'ru', de: 'de', it: 'it', tr: 'tr', + 'pt-PT': 'pt', 'pt-BR': 'pot', vi: 'vie', id: 'id', th: 'th', ms: 'may', + ar: 'ar', hi: 'hi', 'mn-Cyrl': null, 'mn-Mong': null, km: 'hkm', + nb: 'nob', nn: 'nno', fa: 'per', sv: 'swe', pl: 'pl', nl: 'nl', uk: 'ukr', uz: 'uz' + }, + google: { + auto: 'auto', 'zh-CN': 'zh-CN', 'zh-TW': 'zh-TW', yue: 'yue', en: 'en', ja: 'ja', + ko: 'ko', fr: 'fr', es: 'es', ru: 'ru', de: 'de', it: 'it', tr: 'tr', + 'pt-PT': 'pt', 'pt-BR': 'pt', vi: 'vi', id: 'id', th: 'th', ms: 'ms', + ar: 'ar', hi: 'hi', 'mn-Cyrl': 'mn', 'mn-Mong': 'mn', km: 'km', + nb: 'no', nn: 'no', fa: 'fa', sv: 'sv', pl: 'pl', nl: 'nl', uk: 'uk', uz: 'uz' + }, + youdao: { + auto: 'auto', 'zh-CN': 'zh-CHS', 'zh-TW': 'zh-CHT', yue: 'yue', en: 'en', ja: 'jp', + ko: 'ko', fr: 'fr', es: 'es', ru: 'ru', de: 'de', it: 'it', tr: 'tr', + 'pt-PT': 'pt', 'pt-BR': 'pt', vi: 'vie', id: 'id', th: 'th', ms: 'ms', + ar: 'ar', hi: 'hi', 'mn-Cyrl': 'mn', 'mn-Mong': 'mn', km: 'km', + nb: 'no', nn: 'no', fa: 'fa', sv: 'sv', pl: 'pl', nl: 'nl', uk: 'uk', uz: 'uz' + }, + microsoft: { + auto: 'auto', 'zh-CN': 'zh-Hans', 'zh-TW': 'zh-Hant', yue: null, en: 'en', ja: 'ja', + ko: 'ko', fr: 'fr', es: 'es', ru: 'ru', de: 'de', it: 'it', tr: 'tr', + 'pt-PT': 'pt-pt', 'pt-BR': 'pt', vi: 'vi', id: 'id', th: 'th', ms: 'ms', + ar: 'ar', hi: null, 'mn-Cyrl': 'mn-Cyrl', 'mn-Mong': 'mn-Mong', km: 'km', + nb: 'nb', nn: 'nb', fa: 'fa', sv: 'sv', pl: 'pl', nl: 'nl', uk: 'uk', uz: 'uz' + } +}, + +// 读某 provider 的设置(合并默认值)。全部走 ztools.dbStorage(按插件命名空间隔离)。 +// 微软:默认 signature。edge 端点会按 Chrome UA 版本号风控(旧版本号被拒 400 +// Client Browser Version not supported),signature 走 HMACSHA256 不依赖 UA,更稳。 +// 曾保存过 requestMode='edge' 的老用户在这里一次性迁移到 signature。 +getTranslateSettings(provider) { + const defaults = { + baidu: { appID: '', appKey: '' }, + google: {}, + youdao: { appKey: '', appSecret: '' }, + microsoft: { requestMode: 'signature' } // 'signature' | 'edge' + } + const base = defaults[provider] || {} + const stored = window.ztools.dbStorage.getItem('translate.' + provider) || {} + const merged = Object.assign({}, base, stored) + if (provider === 'microsoft' && merged.requestMode === 'edge') { + merged.requestMode = 'signature' + } + return merged +}, + +// 写某 provider 的设置到 ztools.dbStorage。 +setTranslateSettings(provider, data) { + data = data || {} + window.ztools.dbStorage.setItem('translate.' + provider, data) +}, + +// 把中性语言码映射到 provider 自家码;不支持的语种返回 null。 +_mapLang(provider, lang) { + if (!lang || lang === 'auto') return 'auto' + const m = this.TRANSLATE_LANG_MAP[provider] || {} + return Object.prototype.hasOwnProperty.call(m, lang) ? m[lang] : null +}, + +// 目标语言兜底:调用方(如超级面板)未传 to 时,按文本内容推断合理目标语言。 +// 规则——以中文为主(CJK 占非空白字符 > 50%)→ 翻译到英文;其余(纯外文、或中外混合但中文不占多数)→ 翻译到中文。 +// 阈值与宿主 translationManager.isMostlyChinese 保持一致,保证内置与插件翻译体验统一。 +_resolveDefaultTargetLang(text) { + const t = text || '' + const cjkMatches = t.match(/[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/g) + const nonWhitespace = t.replace(/\s/g, '').length + const isMostlyChinese = cjkMatches && nonWhitespace > 0 + ? cjkMatches.length / nonWhitespace > 0.5 + : false + return isMostlyChinese ? 'en' : 'zh-CN' +}, + +// 百度翻译:GET /api/trans/vip/translate,sign=md5(appid+q+salt+appkey) +async translateBaidu(text, from, to) { + if (!to) to = this._resolveDefaultTargetLang(text) // 未指定目标语言时按内容推断(中→英,其余→中) + const { appID, appKey } = this.getTranslateSettings('baidu') + if (!appID || !appKey) throw new Error('百度翻译未配置 AppID/AppKey,请在「翻译提供商」设置页填写') + const sf = this._mapLang('baidu', from) + const st = this._mapLang('baidu', to) + if (sf === null) throw new Error('百度翻译不支持源语言: ' + from) + if (st === null) throw new Error('百度翻译不支持目标语言: ' + to) + const salt = String(Math.floor(Math.random() * 100000)) + const sign = crypto.createHash('md5').update(appID + text + salt + appKey, 'utf8').digest('hex') + const resp = await this._httpRequest('GET', + 'https://fanyi-api.baidu.com/api/trans/vip/translate', + { query: { q: text, from: sf, to: st, appid: appID, salt, sign } }) + const data = JSON.parse(resp.body) + if (data.error_code) throw new Error(`${data.error_code}: ${data.error_msg || 'unknown'}`) + if (!Array.isArray(data.trans_result)) throw new Error('百度翻译返回异常: ' + resp.body.slice(0, 200)) + const out = data.trans_result.map((x) => x.dst).join('\n') + return { text: out, detectedFrom: from } +}, + +// 谷歌翻译:POST googlet.deno.dev/translate,JSON,无凭据 +async translateGoogle(text, from, to) { + if (!to) to = this._resolveDefaultTargetLang(text) // 未指定目标语言时按内容推断(中→英,其余→中) + const sf = this._mapLang('google', from) + const st = this._mapLang('google', to) + if (sf === null) throw new Error('谷歌翻译不支持源语言: ' + from) + if (st === null) throw new Error('谷歌翻译不支持目标语言: ' + to) + const resp = await this._httpRequest('POST', 'https://googlet.deno.dev/translate', { + json: { text, source_lang: sf, target_lang: st } + }) + const data = JSON.parse(resp.body) + if (typeof data.data !== 'string') throw new Error('谷歌翻译返回异常: ' + resp.body.slice(0, 200)) + return { text: data.data, detectedFrom: from } +}, + +// 有道翻译:POST openapi.youdao.com/api,form 表单 +// sign = sha256(appKey + input(q) + salt + curtime + appSecret) +// input(q) = len<=20 ? q : q.slice(0,10)+len+q.slice(-10) +async translateYoudao(text, from, to) { + if (!to) to = this._resolveDefaultTargetLang(text) // 未指定目标语言时按内容推断(中→英,其余→中) + const { appKey, appSecret } = this.getTranslateSettings('youdao') + if (!appKey || !appSecret) throw new Error('有道翻译未配置 AppKey/AppSecret,请在「翻译提供商」设置页填写') + const sf = this._mapLang('youdao', from) + const st = this._mapLang('youdao', to) + if (sf === null) throw new Error('有道翻译不支持源语言: ' + from) + if (st === null) throw new Error('有道翻译不支持目标语言: ' + to) + const salt = crypto.randomUUID() + const curtime = String(Math.floor(Date.now() / 1000)) + const input = text.length <= 20 ? text : text.slice(0, 10) + text.length + text.slice(-10) + const signStr = appKey + input + salt + curtime + appSecret + const sign = crypto.createHash('sha256').update(signStr, 'utf8').digest('hex').toUpperCase() + const resp = await this._httpRequest('POST', 'https://openapi.youdao.com/api', { + form: { q: text, from: sf, to: st, appKey, salt, sign, signType: 'v3', curtime } + }) + const data = JSON.parse(resp.body) + if (data.errorCode && data.errorCode !== '0') { + throw new Error('有道翻译错误码 ' + data.errorCode + ': ' + (data.msg || '')) + } + if (!Array.isArray(data.translation) || !data.translation.length) { + throw new Error('有道翻译返回异常: ' + resp.body.slice(0, 200)) + } + return { text: data.translation[0], detectedFrom: from } +}, + +// 微软翻译:两种鉴权方案(默认 signature) +// - signature: 用 MSTranslatorAndroidApp + HMACSHA256 生成 X-MT-Signature,调 api.cognitive.microsofttranslator.com +// - edge: GET edge.microsoft.com/translate/auth 拿 Bearer token,再调 api-edge.cognitive.microsofttranslator.com +// edge 端点会按 Chrome UA 版本号风控,旧版本号被拒(400 Client Browser Version not supported),故仅作兜底。 +// 两者都 POST /translate?api-version=3.0&to=&from=,body=[{Text}] +_msEdgeToken: null, +_msEdgeTokenExpiresAt: 0, +_msPrivateKey: Buffer.from([ + 0xa2, 0x29, 0x3a, 0x3d, 0xd0, 0xdd, 0x32, 0x73, + 0x97, 0x7a, 0x64, 0xdb, 0xc2, 0xf3, 0x27, 0xf5, + 0xd7, 0xbf, 0x87, 0xd9, 0x45, 0x9d, 0xf0, 0x5a, + 0x09, 0x66, 0xc6, 0x30, 0xc6, 0x6a, 0xaa, 0x84, + 0x9a, 0x41, 0xaa, 0x94, 0x3a, 0xa8, 0xd5, 0x1a, + 0x6e, 0x4d, 0xaa, 0xc9, 0xa3, 0x70, 0x12, 0x35, + 0xc7, 0xeb, 0x12, 0xf6, 0xe8, 0x23, 0x07, 0x9e, + 0x47, 0x10, 0x95, 0x91, 0x88, 0x55, 0xd8, 0x17 +]), + +async _msGetEdgeToken() { + const now = Date.now() + if (this._msEdgeToken && now < this._msEdgeTokenExpiresAt - 60000) { + return this._msEdgeToken + } + const resp = await this._httpRequest('GET', 'https://edge.microsoft.com/translate/auth', { + timeoutMs: 10000 + }) + const token = resp.body.trim().replace(/^"|"$/g, '') + if (!token) throw new Error('获取微软 Edge token 失败') + this._msEdgeToken = token + this._msEdgeTokenExpiresAt = now + 5 * 60 * 1000 + return token +}, + +_msBuildSignature(requestPath) { + const guid = crypto.randomUUID().replace(/-/g, '') + const escapedUrl = encodeURIComponent(requestPath) + // 对齐 C# 实现:取 RFC1123 字符串,格式 "ddd, dd MMM yyyy HH:mm:ss GMT" + const dateStr = (function () { + const d = new Date() + const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] + const pad = (n) => (n < 10 ? '0' + n : '' + n) + return ( + days[d.getUTCDay()] + ', ' + pad(d.getUTCDate()) + ' ' + months[d.getUTCMonth()] + + ' ' + d.getUTCFullYear() + ' ' + pad(d.getUTCHours()) + ':' + pad(d.getUTCMinutes()) + + ':' + pad(d.getUTCSeconds()) + ' GMT' + ) + })() + const signSrc = ('MSTranslatorAndroidApp' + escapedUrl + dateStr + guid).toLowerCase() + const hash = crypto.createHmac('sha256', this._msPrivateKey).update(signSrc, 'utf8').digest('base64') + return 'MSTranslatorAndroidApp::' + hash + '::' + dateStr + '::' + guid +}, + +async translateMicrosoft(text, from, to) { + if (!to) to = this._resolveDefaultTargetLang(text) // 未指定目标语言时按内容推断(中→英,其余→中) + const { requestMode } = this.getTranslateSettings('microsoft') + const sf = this._mapLang('microsoft', from) + const st = this._mapLang('microsoft', to) + if (st === null) throw new Error('微软翻译不支持目标语言: ' + to) + // microsoft 不支持粤语;from=null 时不带 from 参数(API 自动检测) + if (sf === null && from && from !== 'auto') throw new Error('微软翻译不支持源语言: ' + from) + + const endpoint = requestMode === 'signature' + ? 'api.cognitive.microsofttranslator.com' + : 'api-edge.cognitive.microsofttranslator.com' + let path = `/translate?api-version=3.0&to=${encodeURIComponent(st)}` + if (sf && sf !== 'auto') path += `&from=${encodeURIComponent(sf)}` + + // Edge Token / Signature 端点都会校验 User-Agent,必须带 Chrome UA。 + const headers = { + 'Content-Type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36' + } + if (requestMode === 'signature') { + headers['X-MT-Signature'] = this._msBuildSignature(endpoint + path) + } else { + const token = await this._msGetEdgeToken() + headers['Authorization'] = 'Bearer ' + token + } + + const resp = await this._httpRequest('POST', `https://${endpoint}${path}`, { + json: [{ Text: text }], + headers, + timeoutMs: 15000 + }) + const arr = JSON.parse(resp.body) + if (!Array.isArray(arr) || !arr.length || !arr[0].translations || !arr[0].translations.length) { + throw new Error('微软翻译返回异常: ' + resp.body.slice(0, 200)) + } + return { text: arr[0].translations[0].text, detectedFrom: from } + } +} + +// ─── 注册 Providers ────────────────────────────────────────────────────── +// OCR 契约:input { image, lang? } -> { text, blocks?, confidence? } +// image 可为 本地路径 / data URI / http(s) URL。 +ztools.registerProvider('ocr', async (input) => { + const { image } = input || {} + return await window.services.ocrRecognize(image) +}) + +// 翻译契约(对齐宿主 TranslationInput/Output): +// input { text, from?, to? } -> { text, detectedFrom? } +// 注意:handler 内不能用 this(this 在 registerProvider 回调里不是 services), +// 必须显式经 window.services.xxx 调用,才能正确解析方法内的 this。 +ztools.registerProvider('baidu', async (input) => { + const { text, from, to } = input || {} + return await window.services.translateBaidu(text, from, to) +}) +ztools.registerProvider('google', async (input) => { + const { text, from, to } = input || {} + return await window.services.translateGoogle(text, from, to) +}) +ztools.registerProvider('youdao', async (input) => { + const { text, from, to } = input || {} + return await window.services.translateYoudao(text, from, to) +}) +ztools.registerProvider('microsoft', async (input) => { + const { text, from, to } = input || {} + return await window.services.translateMicrosoft(text, from, to) +}) diff --git a/plugins/f-provider/scripts/copy-native.mjs b/plugins/f-provider/scripts/copy-native.mjs new file mode 100644 index 00000000..18c2c709 --- /dev/null +++ b/plugins/f-provider/scripts/copy-native.mjs @@ -0,0 +1,97 @@ +// Post-build: copy the native addon + wco_data into dist/ so the packaged +// plugin can load wechat_ocr.node and find WeChatOCR.exe + Model at runtime, +// then produce a distributable native.zip. +// +// Layout after copy + zip: +// dist/ +// native/ <- 解压版,方便开发调试 +// index.js +// package.json +// build/Release/wechat_ocr.node +// wco_data/ (mmmojo_64.dll, WeChatOCR.exe, Model/, ...) +// native.zip <- 分发版(顶层含 native/ 目录,解压即还原) +// preload/services.js (vite already copied from public/) +// +// 设计:插件初始不带 native,用户在前端点击「下载」后下载 native.zip 并解压到 +// 插件根目录(顶层 native/ 解压后落到 /native/)。 +import { spawnSync } from 'node:child_process' +import { cpSync, existsSync, mkdirSync, rmSync, readdirSync, statSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const root = resolve(__dirname, '..') +const nativeDir = join(root, 'native') +const distDir = join(root, 'dist') +const distNative = join(distDir, 'native') +const distZip = join(distDir, 'native.zip') + +if (!existsSync(distDir)) { + console.warn('[copy-native] dist/ not found, skipping') + process.exit(0) +} + +mkdirSync(distNative, { recursive: true }) + +// Copy only what the packaged plugin needs at runtime — NOT the build +// intermediate files (.obj/.tlog/node_modules) which are large and useless +// outside the build. +cpSync(join(nativeDir, 'index.js'), join(distNative, 'index.js'), { force: true }) +cpSync(join(nativeDir, 'package.json'), join(distNative, 'package.json'), { force: true }) + +// The compiled addon. +const nodeSrc = join(nativeDir, 'build', 'Release', 'wechat_ocr.node') +if (existsSync(nodeSrc)) { + mkdirSync(join(distNative, 'build', 'Release'), { recursive: true }) + cpSync(nodeSrc, join(distNative, 'build', 'Release', 'wechat_ocr.node'), { force: true }) +} + +// The proprietary runtime files (wco_data), if present. +const wcoSrc = join(nativeDir, 'wco_data') +if (existsSync(wcoSrc)) { + cpSync(wcoSrc, join(distNative, 'wco_data'), { recursive: true, force: true }) +} + +// Sanity check: warn if the addon or runtime files are missing. +const nodeFile = join(distNative, 'build', 'Release', 'wechat_ocr.node') +let nodeReady = existsSync(nodeFile) +if (!nodeReady) { + console.warn('[copy-native] WARNING: build/Release/wechat_ocr.node not found.') + console.warn(' Run `npm run build:native` first.') +} +const exe = join(distNative, 'wco_data', 'WeChatOCR.exe') +let wcoReady = existsSync(exe) +if (!wcoReady) { + console.warn('[copy-native] WARNING: wco_data/WeChatOCR.exe not found.') + console.warn(' Run `cd native && npm run build` to auto-fetch wco_data from NuGet.') +} + +console.log('[copy-native] native assets copied to dist/native') + +// ── 打包 native.zip(分发版)────────────────────────────────────────── +// zip 顶层包含 native/ 目录,用户下载后解压到插件根目录即可还原结构。 +// native/ 缺关键文件时跳过打包并告警(避免分发残缺包)。 +if (nodeReady && wcoReady) { + // 清理旧的 zip + if (existsSync(distZip)) rmSync(distZip, { force: true }) + + // Compress-Archive 的 -Path 参数带通配,但 dist/ 下只有 native 一个目录, + // 直接传 dist/native 的父目录 + native 会打包成 顶层/native/... 不对。 + // 正确做法:对 dist/ 执行,-Path 指向 native 子目录(含末尾通配 * 不带顶级), + // 但我们需要保留顶级 native/ 目录,所以传 dist\native(目录本身)。 + const psScript = `Compress-Archive -Path '${distNative.replace(/'/g, "''")}' -DestinationPath '${distZip.replace(/'/g, "''")}' -Force` + const r = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', psScript], { + encoding: 'utf8', + shell: false + }) + if (r.status !== 0 || !existsSync(distZip)) { + console.warn('[copy-native] WARNING: 生成 native.zip 失败。') + if (r.stderr) console.warn(' ' + r.stderr.trim()) + if (r.stdout) console.warn(' ' + r.stdout.trim()) + } else { + const sizeMB = (statSync(distZip).size / 1024 / 1024).toFixed(1) + console.log(`[copy-native] dist/native.zip generated (${sizeMB} MB)`) + } +} else { + console.warn('[copy-native] native assets incomplete, skipping native.zip packaging.') +} diff --git a/plugins/f-provider/src/App.vue b/plugins/f-provider/src/App.vue new file mode 100644 index 00000000..c227a745 --- /dev/null +++ b/plugins/f-provider/src/App.vue @@ -0,0 +1,59 @@ + + + diff --git a/plugins/f-provider/src/Manage/index.vue b/plugins/f-provider/src/Manage/index.vue new file mode 100644 index 00000000..d0c16a75 --- /dev/null +++ b/plugins/f-provider/src/Manage/index.vue @@ -0,0 +1,135 @@ + + + diff --git a/plugins/f-provider/src/assets/baidu.png b/plugins/f-provider/src/assets/baidu.png new file mode 100644 index 00000000..23bea1c5 Binary files /dev/null and b/plugins/f-provider/src/assets/baidu.png differ diff --git a/plugins/f-provider/src/assets/google.png b/plugins/f-provider/src/assets/google.png new file mode 100644 index 00000000..256f7677 Binary files /dev/null and b/plugins/f-provider/src/assets/google.png differ diff --git a/plugins/f-provider/src/assets/microsoft.png b/plugins/f-provider/src/assets/microsoft.png new file mode 100644 index 00000000..2cad595a Binary files /dev/null and b/plugins/f-provider/src/assets/microsoft.png differ diff --git a/plugins/f-provider/src/assets/wechat.png b/plugins/f-provider/src/assets/wechat.png new file mode 100644 index 00000000..a83b6484 Binary files /dev/null and b/plugins/f-provider/src/assets/wechat.png differ diff --git a/plugins/f-provider/src/assets/youdao.png b/plugins/f-provider/src/assets/youdao.png new file mode 100644 index 00000000..b9ec93c4 Binary files /dev/null and b/plugins/f-provider/src/assets/youdao.png differ diff --git a/plugins/f-provider/src/components/EngineStatusCard.vue b/plugins/f-provider/src/components/EngineStatusCard.vue new file mode 100644 index 00000000..ffe144ad --- /dev/null +++ b/plugins/f-provider/src/components/EngineStatusCard.vue @@ -0,0 +1,238 @@ + + + + + diff --git a/plugins/f-provider/src/components/GlobalFeedback.vue b/plugins/f-provider/src/components/GlobalFeedback.vue new file mode 100644 index 00000000..3fedce0c --- /dev/null +++ b/plugins/f-provider/src/components/GlobalFeedback.vue @@ -0,0 +1,36 @@ + + + diff --git a/plugins/f-provider/src/components/OcrImageViewer.vue b/plugins/f-provider/src/components/OcrImageViewer.vue new file mode 100644 index 00000000..f7be45ab --- /dev/null +++ b/plugins/f-provider/src/components/OcrImageViewer.vue @@ -0,0 +1,846 @@ + + + + + + + + diff --git a/plugins/f-provider/src/components/ProviderLogo.vue b/plugins/f-provider/src/components/ProviderLogo.vue new file mode 100644 index 00000000..15b8e950 --- /dev/null +++ b/plugins/f-provider/src/components/ProviderLogo.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/plugins/f-provider/src/components/SettingLayout.vue b/plugins/f-provider/src/components/SettingLayout.vue new file mode 100644 index 00000000..8f32e127 --- /dev/null +++ b/plugins/f-provider/src/components/SettingLayout.vue @@ -0,0 +1,201 @@ + + + + + diff --git a/plugins/f-provider/src/composables/useCaseConvert.ts b/plugins/f-provider/src/composables/useCaseConvert.ts new file mode 100644 index 00000000..53d7932e --- /dev/null +++ b/plugins/f-provider/src/composables/useCaseConvert.ts @@ -0,0 +1,146 @@ +/** + * 命名风格转换器(纯函数):把任意英文/混合字符串拆成词,再按 8 种代码命名风格重组。 + * + * 设计为纯函数模块,无 Vue 响应式依赖,可被任意视图直接 import 复用。 + * 语义翻译由 translation provider 完成;这里只负责把英文短语(provider 译文) + * 转换成 camelCase / snake_case 等命名风格候选。 + */ + +/** 风格唯一标识。 */ +export type CaseStyleKey = + | 'camelCase' + | 'PascalCase' + | 'snake_case' + | 'CONSTANT_CASE' + | 'kebab-case' + | 'camel_Snake' + | 'Pascal_Snake' + | 'flatcase' + | 'UPPERFLAT' + +/** 风格定义:key + 展示名 + 由 token 数组生成最终字符串的格式化函数。 */ +export interface CaseStyle { + key: CaseStyleKey + /** 中文展示名(候选列表右侧标注用)。 */ + label: string + /** 由小写 token 数组生成该风格的字符串。 */ + format: (tokens: string[]) => string +} + +/** + * 把任意字符串拆成小写词数组(tokenize)。 + * 兼容以下边界: + * - 空白 / 下划线 / 短横线 / 其它标点作为分隔符 + * - camelCase / PascalCase 内部大小写边界(`userId` → `user id`) + * - 连续大写缩写(`HTTPServer` → `http server`) + * - 字母与数字边界(`userId2Name` → `user id 2 name`) + * - CJK 字符作为一个整体 token 保留(兜底:provider 译不出英文时原文仍可用) + * + * 例:`'userLoginRetry HTTPServer v2'` → `['user', 'login', 'retry', 'http', 'server', 'v', '2']` + */ +export function tokenize(input: string): string[] { + if (!input) return [] + // 第一步:在大小写/数字/CJK 边界插入分隔符,统一成空格分隔的串再 split。 + // (?<=[a-z])(?=[A-Z]) 小写→大写边界:user|Id + // (?<=[A-Z])(?=[A-Z][a-z]) 连续大写末尾:HTTP|Server + // (?<=[0-9])(?=[A-Za-z]) 数字→字母:2|Name + // (?<=[A-Za-z])(?=[0-9]) 字母→数字:v|2 + // CJK 与非 CJK 之间也切一刀:用户|Name / Name|用户 + const SPLIT_RE = /(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|(?<=[0-9])(?=[A-Za-z])|(?<=[A-Za-z])(?=[0-9])|([\u4e00-\u9fff\u3400-\u4dbf]+)|(?<=[\u4e00-\u9fff\u3400-\u4dbf])(?=[A-Za-z0-9])|(?<=[A-Za-z0-9])(?=[\u4e00-\u9fff\u3400-\u4dbf])/g + // 上面 CJK 捕获组会把整段中文带走;用 replace 把它包成「 空格+中文+空格 」 + const withBoundaries = input + .replace(SPLIT_RE, (m, cjk) => (cjk ? ' ' + cjk + ' ' : ' ')) + // 把所有非字母数字 CJK 的字符(标点、空白、-_等)统一成空格 + .replace(/[^A-Za-z0-9\u4e00-\u9fff\u3400-\u4dbf]+/g, ' ') + .trim() + if (!withBoundaries) return [] + return withBoundaries + .split(' ') + .map((t) => t.trim()) + .filter(Boolean) + .map((t) => (/[\u4e00-\u9fff\u3400-\u4dbf]/.test(t) ? t : t.toLowerCase())) +} + +// ─── 大小写工具 ────────────────────────────────────────────────────── +const cap = (w: string): string => (w ? w[0].toUpperCase() + w.slice(1) : w) + +// ─── 8 种命名风格格式化器 ──────────────────────────────────────────── +export const STYLES: CaseStyle[] = [ + { + key: 'camelCase', + label: '驼峰 camelCase', + // 首词小写、其余词首字母大写:userLoginRetry + format: (t) => t.map((w, i) => (i === 0 ? w : cap(w))).join('') + }, + { + key: 'PascalCase', + label: '大驼峰 PascalCase', + format: (t) => t.map(cap).join('') + }, + { + key: 'snake_case', + label: '下划线 snake_case', + format: (t) => t.join('_') + }, + { + key: 'CONSTANT_CASE', + label: '常量 CONSTANT_CASE', + format: (t) => t.map((w) => w.toUpperCase()).join('_') + }, + { + key: 'kebab-case', + label: '短横线 kebab-case', + format: (t) => t.join('-') + }, + { + key: 'camel_Snake', + label: '混合 camel_Snake', + // 首词小写、其余词大写,以下划线连接:user_Login_Retry + format: (t) => t.map((w, i) => (i === 0 ? w : cap(w))).join('_') + }, + { + key: 'Pascal_Snake', + label: '帕斯卡蛇 Pascal_Snake', + format: (t) => t.map(cap).join('_') + }, + { + key: 'flatcase', + label: '全小写 flatcase', + format: (t) => t.join('') + }, + { + key: 'UPPERFLAT', + label: '全大写 UPPERFLAT', + format: (t) => t.map((w) => w.toUpperCase()).join('') + } +] + +/** 单条候选:风格 key + 展示名 + 转换后的字符串。 */ +export interface CaseCandidate { + key: CaseStyleKey + label: string + value: string +} + +/** + * 由英文短语生成全部风格的候选列表(保持 STYLES 顺序)。 + * 输入为空时返回空数组(调用方负责兜底)。 + */ +export function toCandidates(englishText: string): CaseCandidate[] { + const tokens = tokenize(englishText) + if (!tokens.length) return [] + return STYLES.map((s) => ({ + key: s.key, + label: s.label, + value: s.format(tokens) + })) +} + +/** + * 判断文本是否「基本是英文」(含数字/标点,但无 CJK)。 + * 用于代码翻译视图决定是否跳过 provider 翻译直接走风格转换。 + */ +export function isMostlyAscii(text: string): boolean { + if (!text) return false + return !/[\u4e00-\u9fff\u3400-\u4dbf]/.test(text) +} diff --git a/plugins/f-provider/src/composables/useNativeEngine.ts b/plugins/f-provider/src/composables/useNativeEngine.ts new file mode 100644 index 00000000..f860dbde --- /dev/null +++ b/plugins/f-provider/src/composables/useNativeEngine.ts @@ -0,0 +1,122 @@ +import { ref, computed } from 'vue' + +/** + * native OCR 引擎状态机:复用给「引擎管理」「识别测试」「快捷识别」三处视图。 + * + * 流程:checking -> (missing | ready) -> downloading/extracting -> ready + */ + +export type NativeState = + | 'checking' + | 'missing' + | 'downloading' + | 'extracting' + | 'ready' + | 'error' + +export function useNativeEngine() { + const nativeState = ref('checking') + const downloadPercent = ref(0) + const downloadLoaded = ref(0) + const downloadTotal = ref(0) + const nativeError = ref('') + const nativeVersion = ref(null) + const nativeMissing = ref([]) + + // 检查 native 引擎状态(按文件存在性判断) + async function checkNative() { + nativeState.value = 'checking' + try { + const status = window.services.nativeStatus() + nativeVersion.value = status.version + nativeMissing.value = status.missing || [] + nativeState.value = status.ready ? 'ready' : 'missing' + } catch (_) { + // preload 方法缺失等异常:保守地进入 missing,避免阻塞 + nativeState.value = 'missing' + } + } + + // 下载并解压 native 引擎 + async function downloadNative(): Promise { + if (nativeState.value === 'downloading' || nativeState.value === 'extracting') { + return false + } + nativeState.value = 'downloading' + downloadPercent.value = 0 + downloadLoaded.value = 0 + downloadTotal.value = 0 + nativeError.value = '' + try { + const result = await window.services.nativeDownload((progress) => { + if (progress.phase === 'downloading') { + nativeState.value = 'downloading' + downloadPercent.value = progress.percent + downloadLoaded.value = progress.loaded + downloadTotal.value = progress.total + } else if (progress.phase === 'extracting') { + nativeState.value = 'extracting' + } + }) + if (result.ok) { + nativeState.value = 'ready' + return true + } else { + nativeState.value = 'error' + nativeError.value = result.error || '下载失败' + return false + } + } catch (err: any) { + nativeState.value = 'error' + nativeError.value = err?.message ? String(err.message) : String(err) + return false + } + } + + // 删除已下载的 native 引擎,回到 missing 态 + function removeNative() { + try { + window.services.nativeRemove() + } catch (_) {} + nativeState.value = 'missing' + checkNative() + } + + const nativeReady = computed(() => nativeState.value === 'ready') + const isBusy = computed( + () => nativeState.value === 'downloading' || nativeState.value === 'extracting' + ) + + // 把字节格式化为人类可读 + function formatBytes(bytes: number): string { + if (!bytes || bytes <= 0) return '' + const units = ['B', 'KB', 'MB', 'GB'] + let i = 0 + let n = bytes + while (n >= 1024 && i < units.length - 1) { + n /= 1024 + i++ + } + return `${n.toFixed(1)} ${units[i]}` + } + + return { + // state + nativeState, + downloadPercent, + downloadLoaded, + downloadTotal, + nativeError, + nativeVersion, + nativeMissing, + // computed + nativeReady, + isBusy, + // actions + checkNative, + downloadNative, + removeNative, + // utils + formatBytes + } +} diff --git a/plugins/f-provider/src/env.d.ts b/plugins/f-provider/src/env.d.ts new file mode 100644 index 00000000..251a76ac --- /dev/null +++ b/plugins/f-provider/src/env.d.ts @@ -0,0 +1,156 @@ +/// +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent, Record, unknown> + export default component +} + +// Preload services 类型声明(对应 public/preload/services.js),全局可用。 +declare global { + /** OCR 识别结果中的一行文本及其位置信息(交互式 feature 用)。 */ + interface OcrLine { + /** 该行识别文字(UTF-8 解码后的明文)。 */ + text: string + /** 识别置信度(0~1)。 */ + rate: number + /** 识别矩形包围盒(像素坐标)。 */ + left: number + top: number + right: number + bottom: number + /** 四个角点坐标(左上、右上、右下、左下)。 */ + boxPoints: { x: number; y: number }[] + } + + /** OCR Provider 契约输出:{ text, blocks?, confidence? } */ + interface OcrProviderOutput { + text: string + blocks?: string[] + confidence?: number + } + + /** 交互式 feature 用的明细返回结构(ok=false 时不抛错)。 */ + interface OcrDetailResult { + ok: boolean + error?: string + taskId?: number + lines: OcrLine[] + } + + /** native 引擎就绪状态。真值来源 = 关键文件是否存在。 */ + interface NativeStatus { + /** 引擎是否就绪(.node 与 WeChatOCR.exe 均存在)。 */ + ready: boolean + /** 缺失的关键文件相对路径列表(ready=true 时为空)。 */ + missing: string[] + /** plugin.json 中配置的 native 版本号。 */ + version: string | null + } + + /** 下载/解压进度上报。 */ + interface NativeDownloadProgress { + /** 阶段:downloading 下载中 / extracting 解压中。 */ + phase: 'downloading' | 'extracting' + /** 进度百分比(0~100;total 未知时 downloading 阶段为 0)。 */ + percent: number + /** 已下载字节数。 */ + loaded: number + /** 总字节数(content-length,未知为 0)。 */ + total: number + } + + /** nativeDownload 返回结果。 */ + interface NativeDownloadResult { + ok: boolean + error?: string + } + + // ─── 翻译 Provider 相关 ─────────────────────────────────────────────── + /** 翻译 Provider 输出(对齐宿主 TranslationOutput)。 */ + interface TranslateProviderOutput { + text: string + detectedFrom?: string + } + + /** 翻译 Provider 名称(即 plugin.json providers 字段的 key)。 */ + type TranslateProviderName = 'baidu' | 'google' | 'youdao' | 'microsoft' + + /** 微软翻译鉴权方案。 */ + type MicrosoftRequestMode = 'edge' | 'signature' + + /** 各 provider 的设置(凭据 + 非敏感配置)。 */ + interface TranslateSettingsMap { + baidu: { appID: string; appKey: string } + google: Record + youdao: { appKey: string; appSecret: string } + microsoft: { requestMode: MicrosoftRequestMode } + } + + interface Services { + readFile: (file: string) => string + /** 读图片二进制并返回 data URI(供 / 直接预览本地 path 图片)。 */ + readFileAsDataURL: (file: string) => string + writeTextFile: (text: string) => string + writeImageFile: (base64Url: string) => string | undefined + /** + * OCR Provider 核心能力:image 为 本地路径 / data URI / http(s) URL。 + * 返回 provider 契约结构;失败抛错。 + */ + ocrRecognize: (image: string, lang?: string) => Promise + /** + * 交互式 feature 用:返回带坐标的明细结构(ok=false 时不抛错)。 + */ + ocrImageDetail: (image: string, lang?: string) => Promise + /** 释放 OCR 引擎(停止 WeChatOCR.exe 子进程)。 */ + ocrDispose: () => void + /** 检查 native 引擎是否就绪(按文件存在性判断)。 */ + nativeStatus: () => NativeStatus + /** + * 下载 native.zip 并解压到插件根目录。全程通过 onProgress 上报进度。 + * 流程:下载(带重定向)→ 可选 sha256 校验 → PowerShell 解压 → 复检。 + */ + nativeDownload: (onProgress?: (progress: NativeDownloadProgress) => void) => Promise + /** 删除已下载的 native 目录(释放旧引擎、便于重新下载)。 */ + nativeRemove: () => boolean + + // ─── 翻译 ─── + /** 通用 HTTP 请求;非 2xx 抛错。 */ + _httpRequest: ( + method: string, + url: string, + opts?: { + headers?: Record + query?: Record + json?: unknown + form?: Record + body?: string + timeoutMs?: number + maxRedirects?: number + } + ) => Promise<{ status: number; headers: Record; body: string }> + /** 语言映射表(provider -> 中性码 -> 自家码;null 表示不支持)。 */ + TRANSLATE_LANG_MAP: Record> + /** 读某 provider 的设置(合并默认值)。 */ + getTranslateSettings:

(provider: P) => TranslateSettingsMap[P] + /** 写某 provider 的设置。 */ + setTranslateSettings:

(provider: P, data: TranslateSettingsMap[P]) => void + /** 中性语言码 -> provider 自家码;不支持的语种返回 null。 */ + _mapLang: (provider: TranslateProviderName, lang: string | undefined) => string | null + /** 百度翻译。 */ + translateBaidu: (text: string, from?: string, to?: string) => Promise + /** 谷歌翻译。 */ + translateGoogle: (text: string, from?: string, to?: string) => Promise + /** 有道翻译。 */ + translateYoudao: (text: string, from?: string, to?: string) => Promise + /** 微软翻译。 */ + translateMicrosoft: (text: string, from?: string, to?: string) => Promise + } + + interface Window { + services: Services + } +} + +export {} diff --git a/plugins/f-provider/src/main.css b/plugins/f-provider/src/main.css new file mode 100644 index 00000000..d0b52833 --- /dev/null +++ b/plugins/f-provider/src/main.css @@ -0,0 +1,42 @@ +/* 基础重置;按钮 / 输入等组件样式由 ztools-ui/style 提供 */ +html, +body { + margin: 0; + padding: 0; + height: 100%; +} + +#app { + height: 100vh; +} + +/* 滚动条样式(跟随明暗主题) */ +@media (prefers-color-scheme: light) { + body { + background-color: #f4f4f4; + } + + ::-webkit-scrollbar-track-piece { + background-color: #f4f4f4; + } + + ::-webkit-scrollbar-thumb { + border-color: #f4f4f4; + } +} + +@media (prefers-color-scheme: dark) { + &::-webkit-scrollbar-track-piece { + background-color: #303133; + } + + &::-webkit-scrollbar-thumb { + background-color: #666; + border-color: #303133; + } + + body { + background-color: #303133; + color: #fff; + } +} diff --git a/plugins/f-provider/src/main.ts b/plugins/f-provider/src/main.ts new file mode 100644 index 00000000..4eb4da52 --- /dev/null +++ b/plugins/f-provider/src/main.ts @@ -0,0 +1,11 @@ +import { createApp } from 'vue' +import ZToolsUI from 'ztools-ui' +import 'ztools-ui/style' +import { useZtoolsTheme } from 'ztools-ui' +import './main.css' +import App from './App.vue' + +// 同步宿主主题:html.dark / data-material / os-* / theme-* / --primary-color +useZtoolsTheme() + +createApp(App).use(ZToolsUI).mount('#app') diff --git a/plugins/f-provider/src/views/CodeTranslate.vue b/plugins/f-provider/src/views/CodeTranslate.vue new file mode 100644 index 00000000..93ff7003 --- /dev/null +++ b/plugins/f-provider/src/views/CodeTranslate.vue @@ -0,0 +1,431 @@ + + + + + diff --git a/plugins/f-provider/src/views/RecognizeTest.vue b/plugins/f-provider/src/views/RecognizeTest.vue new file mode 100644 index 00000000..a4a2592e --- /dev/null +++ b/plugins/f-provider/src/views/RecognizeTest.vue @@ -0,0 +1,343 @@ + + + + + diff --git a/plugins/f-provider/src/views/ScreenOcr.vue b/plugins/f-provider/src/views/ScreenOcr.vue new file mode 100644 index 00000000..a8925353 --- /dev/null +++ b/plugins/f-provider/src/views/ScreenOcr.vue @@ -0,0 +1,238 @@ + + + + + diff --git a/plugins/f-provider/src/views/Settings.vue b/plugins/f-provider/src/views/Settings.vue new file mode 100644 index 00000000..a054afd7 --- /dev/null +++ b/plugins/f-provider/src/views/Settings.vue @@ -0,0 +1,682 @@ + + + + + diff --git a/plugins/f-provider/src/views/Translate.vue b/plugins/f-provider/src/views/Translate.vue new file mode 100644 index 00000000..17cadeaa --- /dev/null +++ b/plugins/f-provider/src/views/Translate.vue @@ -0,0 +1,477 @@ + + + + + diff --git a/plugins/f-provider/src/views/TranslateTest.vue b/plugins/f-provider/src/views/TranslateTest.vue new file mode 100644 index 00000000..37da3291 --- /dev/null +++ b/plugins/f-provider/src/views/TranslateTest.vue @@ -0,0 +1,242 @@ + + + + + diff --git a/plugins/f-provider/tsconfig.json b/plugins/f-provider/tsconfig.json new file mode 100644 index 00000000..39b73bc6 --- /dev/null +++ b/plugins/f-provider/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "strict": false, + "noImplicitAny": false, + "types": ["@ztools-center/ztools-api-types"] + }, + "include": ["src"] +} diff --git a/plugins/f-provider/vite.config.js b/plugins/f-provider/vite.config.js new file mode 100644 index 00000000..7bfab103 --- /dev/null +++ b/plugins/f-provider/vite.config.js @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [vue()], + base: './' +})