为了解决个人的 OCR 识别和翻译的需求,我使用 Electron 编写过一个 OCR识别翻译 程序,这个程序的截图功能是通过调用微信截图的 dll 实现的。微信截图的 dll 是一个截图工具,需要通过鼠标框选来截图,不能指定截取区域,也不能跨平台,所以我准备直接通过 Electron 来截取图片,不再使用微信截图的 dll。

鼠标框选截图的程序,一般都是先截取全屏,然后裁剪出选择的区域。在 Electron 中,截图需要通过 Node.js 实现,主进程的 Node.js 截图完成后通过 ipc 传给渲染进程,渲染进程通过鼠标选择裁剪区域,裁剪图片可以在渲染进程通过 canvas 裁剪,也可以在主进程通过 Node.js 裁剪。canvas 除了能裁剪图片外也能添加文字或涂鸦,我这里也会通过 canvas 裁剪。

访问 https://github.com/changbin1997/xiaoma-screenshot 可以查看 Electron 截图 demo,软件我已经打包好了,在 Releases 可以直接下载使用。

创建项目

进入你要存放项目文件的目录,初始化项目:

npm init -y

安装 Electron:

npm install electron --save-dev

package.json 中的 scripts 中加入 Electron 的启动命令:

{
  "scripts": {
    "start": "electron ."
  }
}

安装截图模块 screenshot-desktop

npm install screenshot-desktop --save

Node.js 有很多个开源的截图模块,screenshot-desktop 的体积比较小,只有几百 KB,打包到 Electron 也不会占空间。

创建窗口

可以在项目目录创建一个 index.js 作为项目的入口文件,在 index.js 中创建一个主窗口:

const { app, BrowserWindow, Tray, Menu } = require('electron');
const path = require('path');

let mainWindow = null; // 用来保存主窗口
let tray = null; // 用来存放系统托盘

app.on('ready', async () => {
  // 创建主窗口
  mainWindow = new BrowserWindow({
    fullscreen: true,
    autoHideMenuBar: true,
    show: false,
    alwaysOnTop: true,
    webPreferences: {
      contextIsolation: true,
      webSecurity: false,
      preload: path.join(__dirname, 'preload.js')
    }
  });

  // 创建系统托盘
  tray = new Tray(path.join(__dirname, 'assets', 'favicon.ico'));
  // 菜单模板
  const menu = [
    {label: '退出', role: 'quit'}
  ];
  // 给系统托盘设置菜单
  tray.setContextMenu(Menu.buildFromTemplate(menu));
  // 给托盘图标设置气球提示
  tray.setToolTip('截图');

  // 加载 HTML
  await mainWindow.loadFile(path.join('assets', 'index.html'));

  // 窗口关闭
  mainWindow.on('closed', () => {
    mainWindow = null;
  });
});

上面创建了一个全屏的窗口,窗口的标题栏和菜单栏都是隐藏的,软件打开后窗口也是隐藏的,截图完成后才会显示图片裁剪窗口。

因为窗口是隐藏的,为了方便操作,我加入了一个托盘图标,托盘图标的菜单可以直接退出。创建托盘图标的时候,我传入了一个 favicon.ico,这个 favicon.ico 就是托盘图标使用的图标,支持 png 图片和 ico 图标。更多有关托盘图标的说明可以看我之前写过的 Electron 最小化到系统托盘

现在的 Electron 已经不能在渲染进程直接引入 Electron 和 node 模块,需要通过 preload.js 设置要在渲染进程使用的模块。一般的 preload.js 都是设置 ipc 相关的模块,需要用到 Electron 模块和 node 功能的时候,渲染进程通过 ipc 给主进程发请求。

上面创建窗口的时候,preload 指定的是项目目录下的 preload.js,下面在项目目录创建一个 preload.js,在 preload.js 中设置使用的模块方法:

const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('electronAPI', {
  onResponse: (channel, listener) => {
    ipcRenderer.on(channel, listener);
  },
  'ipc-invoke': (channel, listener) => {
    ipcRenderer.invoke(channel, listener);
  }
});

上面的 preload.js 设置了两个方法,onResponse 调用的是 ipcRenderer.on,主要用来监听主进程发来的 ipc。ipc-invoke 调用的是 ipcRenderer.invoke ,用来向主进程发送 ipc。上面设置的 onResponseipc-invoke 都会被挂载到渲染进程的 window

实现截图

为了避免入口文件的内容过多,我这里会把截图和导出截图之类的放到一个单独的 Screenshot.js 模块里,然后在入口文件调用 Screenshot 模块。

下面就是 Screenshot.js 的内容:

const screenshotDesktop = require('screenshot-desktop');
const { dialog, nativeImage, clipboard, BrowserWindow } = require('electron');
const fs = require('fs');
const path = require('path');

module.exports = class Screenshot {
  // 全屏截图
  screenshot() {
    // 通过 promise 返回
    return new Promise((resolve) => {
      // 使用 screenshot-desktop 截取图片
      screenshotDesktop().then(result => {
        // 成功就返回图片
        resolve(result);
      }).catch(error => {
        // 出错就通过对话框显示错误信息
        dialog.showMessageBoxSync(BrowserWindow.getFocusedWindow(), {
          message: error.message,
          type: 'error',
          buttons: ['关闭'],
          title: '截图出错'
        });
        resolve(false);
      })
    });
  }

  // 导出图片
  exportImage(base64Image) {
    // 默认文件名,使用当前的时间戳作为文件名
    const defaultName = Math.round(new Date().getTime() / 1000) + '.png';
    // 显示文件对话框
    const fileName = dialog.showSaveDialogSync(BrowserWindow.getFocusedWindow(), {
      title: '保存位置选择',
      buttonLabel: '保存',
      defaultPath: path.join(process.cwd(), defaultName)
    });
    // 如果没有输入文件名就直接返回
    if (fileName === undefined) return false;
    // 删除文件的 base64 信息
    const imgData = base64Image.replace(/^data:image\/png;base64,/, '');

    try {
      // 导出图片文件
      fs.writeFileSync(fileName, imgData, 'base64');
      return true;
    } catch (error) {
      // 出错就通过对话框显示错误信息
      dialog.showMessageBoxSync(BrowserWindow.getFocusedWindow(), {
        message: error.message,
        type: 'error',
        buttons: ['关闭'],
        title: '保存图片出错'
      });
      return false;
    }
  }

  // 把图片拷贝到剪贴板
  copyImage() {
    // 把 base64 图片转换为 nativeImage 图片对象
    const imgData = nativeImage.createFromDataURL(base64Img);
    // 把图片写入剪贴板
    clipboard.writeImage(imgData);
    return true;
  }
}

上面的模块包含 screenshotexportImagecopyImage 三个方法。

screenshot

screenshot 就是截图。可以直接调用 screenshot-desktop,screenshot-desktop 成功截图后会通过 Promise 返回一个 Buffer,这个 Buffer 会通过 ipc 传到渲染进程处理。

exportImage

exportImage 是导出图片。渲染进程的 canvas 处理完成后会把 base64 的图片通过 ipc 传到主进程,我上面使用时间戳生成一个默认的文件名,然后调用 Electron 的文件对话框来输入文件名,然后使用 Node.js 的 fs.writeFileSync 写入文件,如果文件不存在就会创建文件。

copyImage

copyImage 就是把图片拷贝到剪贴板,很多截图工具都可以直接把截取的图片拷贝到剪贴板,剪贴板里的图片可以直接粘贴到 Office 和一些图片处理软件使用。Electron 的 nativeImage.createFromDataURL 可以把渲染进程传过来的 base64 图片转换为图片对象,Electron 的 clipboard.writeImage 可以把图片写入到剪贴板。

下面就在入口文件中引入上面写的 Screenshot.js 模块使用:

const { app, BrowserWindow, Tray, Menu, ipcMain, globalShortcut } = require('electron');
const path = require('path');
const Screenshot = require('./Screenshot');

let mainWindow = null; // 用来保存主窗口
let tray = null; // 用来存放系统托盘
const screenshot = new Screenshot();

app.on('ready', async () => {
  // 创建主窗口
  mainWindow = new BrowserWindow({
    fullscreen: true,
    autoHideMenuBar: true,
    show: false,
    alwaysOnTop: true,
    webPreferences: {
      contextIsolation: true,
      webSecurity: false,
      preload: path.join(__dirname, 'preload.js')
    }
  });

  // 创建系统托盘
  tray = new Tray(path.join(__dirname, 'assets', 'favicon.ico'));
  // 菜单模板
  const menu = [
    {label: '退出', role: 'quit'}
  ];
  // 给系统托盘设置菜单
  tray.setContextMenu(Menu.buildFromTemplate(menu));
  // 给托盘图标设置气球提示
  tray.setToolTip('截图');

  // 加载 HTML
  await mainWindow.loadFile(path.join('assets', 'index.html'));

  // 注册 F1 全局快捷键作为截图的快捷键
  globalShortcut.register('F1', async () => {
    // 调用截图
    const result = await screenshot.screenshot();
    if (!result) return false;
    // 截图成功就把图片传给渲染进程处理
    mainWindow.webContents.send('screenshot', result);
    // 显示窗口
    mainWindow.show();
  });

  // 隐藏窗口的 IPC 请求
  ipcMain.handle('hide', () => {
    mainWindow.hide();
  });

  // 窗口关闭
  mainWindow.on('closed', () => {
    mainWindow = null;
  });
});

// 导出图片请求
ipcMain.handle('export-image', (ev, args) => {
  return screenshot.exportImage(args);
});

// 拷贝图片请求
ipcMain.handle('copy-image', (ev, args) => {
  return screenshot.copyImage(args);
});

我上面使用 Electron 的 globalShortcut 注册了一个全局快捷键 F1,按下 F1 后会调用 screenshot.screenshot() 方法截图,截图成功后把图片传给渲染进程,然后显示窗口用于裁剪图片。Electron 的 globalShortcut.register 注册的是全局快捷键,即便窗口最小化或不是焦点窗口,快捷键也能生效。

使用 Electron 的 ipcMain.handle 可以在主进程监听 ipc,第一个参数是 ipc 名称,第二个参数是一个收到 ipc 时的处理函数,函数的第二个参数 args 可以获取渲染进程传过来的数据。我上面监听了三个 ipc,hide 用来隐藏窗口,渲染进程如果选择取消截图就会隐藏窗口。export-image 用来导出图片,渲染进程裁剪完图片后点击 保存 就会发送 export-image 的 ipc 来导出图片。copy-image 是把图片拷贝到剪贴板,渲染进程裁剪完图片后点击 完成 就会发送 copy-image 的 ipc 把图片拷贝到剪贴板。

裁剪图片

创建一个 HTML 文件,添加几个元素:

<canvas id="canvas" style="display: none;"></canvas>
<!--显示截取的全屏图片-->
<img src="" alt="图片" id="img" draggable="false">
<!--图片选择框-->
<div id="select-box"></div>
<!--遮罩层-->
<div id="overlay"></div>
<!--图片操作按钮-->
<div role="toolbar" id="toolbar">
  <button type="button" id="clear-btn">取消</button>
  <button type="button" id="export-btn">保存</button>
  <button type="button" id="copy-btn">完成</button>
</div>

给上面的 HTML 添加一些 CSS 样式:

/*清除元素的默认 margin 和 padding*/
* {
  margin: 0;
  padding: 0;
}

html, body {
  overflow: hidden;
}

/*图片显示*/
#img {
  width: 100%;
  height: 100%;
  position: absolute;
  top: 0;
  left: 0;
  user-select: none;
}

/**图片上方的遮罩层*/
#overlay {
  width: 100%;
  height: 100%;
  position: fixed;
  top: 0;
  left: 0;
  z-index: 2;
  background: rgba(0, 0, 0, 0.3);
}

/*图片区域选择框*/
#select-box {
  border: 1px solid #0000FF;
  cursor: move;
  display: none;
  position: fixed;
  width: 0;
  height: 0;
  z-index: 3;
}

/*图片操作工具栏*/
#toolbar {
  width: 120px;
  height: 26px;
  position: fixed;
  z-index: 4;
  display: none;
  align-items: center;
  justify-content: space-around;
  background: #F2F3F5;
  border: 1px solid #dee2e6;
}

为了避免这篇文章里的代码过多,我上面的 CSS 写的比较简单,没有给按钮加样式,也没有使用图标之类的,外观可能会比较简陋,如果需要看更完整的代码可以到 Github 里看 demo。

下面是一些 CSS 说明:

id 为 #img 的 img 用来显示图片,截图完成后会通过这个 img 显示全屏的截图图片,Electron 的软件窗口是全屏显示的,这个 img 的尺寸和窗口尺寸是一样的。

id 为 #overlay 的 div 是一个半透明的遮罩层,遮罩在 img 的上方,尺寸和窗口尺寸也是一样的。

id 为 #select-box 的 div 就是选择框,默认是隐藏的,截图完成后鼠标在遮罩层上按下就会显示,选择框的尺寸会根据鼠标的移动改变,鼠标放开后选择框的尺寸就会固定,图片选择完成。

id 为 #toolbar 的 div 是图片操作工具栏,里面有三个按钮,图片操作工具栏也是隐藏的,图片选择完成后才会显示,图片操作工具栏的位置会在图片选择框的下方或上方。

创建一个 js 文件在 HTML 中引入,下面就是图片裁剪和发送 ipc 保存图片:

const canvasEl = document.querySelector('#canvas');
const ctx = canvasEl.getContext('2d');  // 获取 canvas 的上下文
const img = document.querySelector('#img');
const overlayEl = document.querySelector('#overlay');  // 图片遮罩层
const toolbarEl = document.querySelector('#toolbar');  // 图片工具栏
const selectBoxEl = document.querySelector('#select-box');  // 图片选择框
let imgSelected = false;  // 图片区域选择完成

// 主进程截图完成
window.electronAPI.onResponse('screenshot', (ev, result) => {
  // 在 img 中显示图片
  const blob = new Blob([result], {type: 'image/png'});
  const imgUrl = URL.createObjectURL(blob);
  img.src = imgUrl;
});

let mouseActive = false;  // 鼠标是否按下
const selectBoxPosition = {x: 0, y: 0};  // 鼠标按下的位置,也就是图片选择框的起始位置

// 图像显示区域鼠标按下
overlayEl.addEventListener('mousedown', ev => {
  // 如果图片还没有选择完成
  if (!imgSelected) {
    // 显示图片区域选择框
    selectBoxEl.style.display = 'block';
    // 图片选择框的起始位置就是鼠标按下的位置
    selectBoxEl.style.left = ev.clientX + 'px';
    selectBoxEl.style.top = ev.clientY + 'px';
    // 图片选择框默认的尺寸为 0
    selectBoxEl.style.width = 0;
    selectBoxEl.style.height = 0;
    // 图片选择框的背景图片显示截取的图片
    selectBoxEl.style.backgroundImage = `url(${img.src})`;
    // 图片选择框的背景图片的显示位置就是鼠标按下的位置
    selectBoxEl.style.backgroundPosition = `-${ev.clientX}px -${ev.clientY}px`;
    // 把图片选择框的起始位置添加到 selectBoxPosition
    selectBoxPosition.x = ev.clientX;
    selectBoxPosition.y = ev.clientY;
    // 设置鼠标按下的状态
    mouseActive = true;
  }
});

// 图像显示区域鼠标移动
document.addEventListener('mousemove', ev => {
  if (!imgSelected && mouseActive) {
    // 缩放图片选择框
    selectBoxEl.style.width = ev.clientX - selectBoxPosition.x + 'px';
    selectBoxEl.style.height = ev.clientY - selectBoxPosition.y + 'px';
  }
});

// 图像显示区域鼠标放开
document.addEventListener('mouseup', () => {
  mouseActive = false;  // 取消鼠标的按下状态
  // 显示图片操作工具栏
  toolbarEl.style.display = 'flex';
  // 设置图片工具栏的 top,显示在图片选择框下方
  toolbarEl.style.top = selectBoxEl.offsetTop + selectBoxEl.offsetHeight + 'px';
  // 设置图片工具栏的 left
  toolbarEl.style.left = selectBoxEl.offsetLeft + selectBoxEl.offsetWidth - toolbarEl.offsetWidth + 'px';
  if (!imgSelected) imgSelected = true;  // 把图片选择状态设置为完成
});

// 取消截图按钮点击
document.querySelector('#clear-btn').addEventListener('click', () => {
  // 隐藏图片工具栏
  toolbarEl.style.display = 'none';
  // 隐藏 canvas
  canvasEl.style.display = 'none';
  // 把图片选择的状态设置为未选择
  imgSelected = false;
  // 隐藏图片选择框
  selectBoxEl.style.display = 'none';
  // 发送 ipc 到主进程隐藏窗口
  window.electronAPI['ipc-invoke']('hide');
});

// 保存图片点击
document.querySelector('#export-btn').addEventListener('click', async () => {
  // 使用 canvas 截取图片
  screenshot();
  // 把 canvas 的图片转换为 base64 图片数据
  const imgData = canvasEl.toDataURL('image/png');
  // 把图片传给主进程
  await window.electronAPI['ipc-invoke']('export-img', imgData);
  // 把图片选择的状态设置为未完成
  imgSelected = false;
  // 调用取消截图按钮来隐藏窗口
  document.querySelector('#clear-btn').click();
});

// 拷贝图片点击
document.querySelector('#copy-btn').addEventListener('click', async () => {
  // 使用 canvas 截取图片
  screenshot();
  // 把 canvas 的图片转换为 base64 图片数据
  const imgData = canvasEl.toDataURL('image/png');
  // 把图片通过 ipc 传给主进程
  await window.electronAPI['ipc-invoke']('copy-img', imgData);
  imgSelected = false;
  screenshotCompleted = false;
  document.querySelector('#clear-btn').click();
});

// ESC 见隐藏窗口
document.addEventListener('keydown', ev => {
  if (ev.key === 'Escape' || ev.keyCode === 27) {
    hideDialog();
    document.querySelector('#clear-btn').click();
  }
});

// 把图片截取到 canvas
function screenshot() {
  // 获取选择框的位置和尺寸
  const sX = selectBoxEl.offsetLeft;
  const sY = selectBoxEl.offsetTop;
  const sW = selectBoxEl.offsetWidth;
  const sH = selectBoxEl.offsetHeight;
  // 显示 canvas
  canvasEl.style.display = 'inline';
  // 把 canvas 的尺寸和位置设置为图片选择框的尺寸和位置
  canvasEl.width = sW;
  canvasEl.height = sH;
  canvasEl.style.top = selectBoxEl.offsetTop + 'px';
  canvasEl.style.left = selectBoxEl.offsetLeft + 'px';
  // 隐藏图片选择框
  selectBoxEl.style.display = 'none';
  // 从 img 截取图片到 canvas 显示
  ctx.drawImage(img, sX, sY, sW, sH, 0, 0, canvasEl.width, canvasEl.height);
}

上面就是图片的选择和截取,我这里写的也是比较简单,图片选择完成后不能移动位置,也不能涂鸦,如果要添加涂鸦功能可以参考我之前写过的 Canvas 实现照片涂鸦

打包

如果要把 Electron 打包成可直接运行的 exe 程序可以使用 electron-builder,关于 electron-builder 的使用可以看我之前写过的 使用 electron-builder 打包 Electron 应用,也可以看我 Github 上的截图demo 的 package.json ,我这里就不详细的写了。

需要注意的是打包的时候需要手动配置两个静态文件,一个是 Windows 截图的配置文件,一个是 .bat 的批处理文件,如果不配置的话,拷贝到其他电脑上可能无法正常截图。

可以在 package.jsonbuild 打包配置里添加配置:

{
  "build": {
    "extraFiles": [
      {
        "from": "node_modules/screenshot-desktop/lib/win32/screenCapture_1.3.2.bat",
        "to": "resources/app.asar.unpacked/node_modules/screenshot-desktop/lib/win32/screenCapture_1.3.2.bat"
      },
      {
        "from": "node_modules/screenshot-desktop/lib/win32/app.manifest",
        "to": "resources/app.asar.unpacked/node_modules/screenshot-desktop/lib/win32/app.manifest"
      }
    ]
  }
}

主要就是把两个静态文件直接移动到打包软件的目录里。