多此一举生成器 - 从开发到使用

cover

百瓶技术团队 \color{#FBBC05}{百瓶技术团队} 公众号 @ 百瓶技术 \color{#FBBC05}{公众号 @ 百瓶技术}

「这是我参与11月更文挑战的第4天,活动详情查看:2021最后一次更文挑战

为什么做这个工具

上次想到这么无厘头的项目还是在上次,今天给大家分享一个非常具有废话文学精神的项目 ——《多此一举生成器》。

想到做这个项目的起因是在跟朋友聊天的时候,经常截图自己发的话来发言,大概就是这样:

每次都要输入一遍并且发送出去才能截图,十分的不方便,所以就想到是否能做一个工具,可以在输入文字后自动生成一张聊天截图。

在地球上,每过 60 秒,就过去了 1 分钟,事不宜迟,让我们马上开始吧。

生成截图

初始化项目

我们将使用 Node.js 来开发这个工具,首先新建工作目录并初始化 npm 项目:

mkdir redundant-tools && cd redundant-tools
npm init
复制代码

npm 安装 canvas 包,让 Node 支持 canvas 绘图:

npm i canvas
复制代码

tips:因为 canvas 包需要根据本地 Node 版本编译原生模块,所以需要 gcc 环境,如果环境不完整可能会安装失败,这时候需要根据 官方www.npmjs.com/package/can… )指示安装对应环境。

扫描二维码关注公众号,回复: 13461634 查看本文章

新建 index.js:

const { createCanvas, loadImage } = require('canvas');
const [chatText = '', theme = 'light'] = process.argv.slice(2);
// 获取聊天文字内容、截图主题
const { projectPath, headUrl } = process.env;
// 获取项目路径、头像

const canvas = createCanvas(200, 200);
const ctx = canvas.getContext('2d');

const chatLineHeight = 42; // 聊天文本行高
const globalPadding = 20; // 截图内边距
const bubblePadding = [17, 26, 24]; // 气泡内边距,上下、右、左
const bubbleRightMargin = 10; // 气泡和头像的距离
const headSize = 64; // 头像大小

const bgColor = theme === 'light' ? '#f3f3f3' : '#111111'; // 背景颜色
const bubbleBgColor = theme === 'light' ? '#ffffff' : '#38b267'; // 气泡背景色
const arrowBg =
  theme === 'light' // 箭头图片 base64
    ? 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAQBAMAAADdUfNzAAAAJ1BMVEUAAAD///////////////////////////////////////////////+uPUo5AAAADHRSTlMAxZgZBuVmZDnkOjj2+7DuAAAANElEQVQI12NQYQCBgwYg8sxsMHmmAEwecgCRZ4TB5NEGEHnmGII8ugAsC1EJ1xUBNw1iMgCMDCJG2u/1pgAAAABJRU5ErkJggg=='
    : 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAQBAMAAADdUfNzAAAAJ1BMVEUAAAA4smc4smc4smc4smc4smc4smc4smc4smc4smc4smc4smc4smfNgyS7AAAADHRSTlMAxZgZBuVmZDnkOjj2+7DuAAAANElEQVQI12NQYQCBgwYg8sxsMHmmAEwecgCRZ4TB5NEGEHnmGII8ugAsC1EJ1xUBNw1iMgCMDCJG2u/1pgAAAABJRU5ErkJggg==';

function main() {
  // 开始绘制
}

main();
复制代码

分析截图构成

一张截图由 背景头像气泡聊天文字 组成,其中背景和气泡颜色会根据 主题(dark/light) 变化。

首先在绘制之前,我们需要知道最终绘制出来的截图尺寸,用于设置 canvas 的宽高。

影响截图宽高最重要的因素就是聊天文字的内容,文字内容多,则宽度更宽,当到达一定界限后,文字内容会换行,换行以后就会影响截图的高度了。

计算高度

截图的最终高度 = 截图内边距(20px * 2) + 气泡内边距(17px * 2) + 行数 * 行高(42px)。

那么怎么知道聊天内容文字的行数呢?

canvascontext 提供了一个 measureText 方法,用于测算文字渲染的宽度。入参为文字内容,会返回一个只有 widthobject

tips:字体 familysize 都将影响最终测算结果。

下面是仿 Mac OS 微信客户端聊天文字样式的文字行数及高度测算:

const chatLineHeight = 42;
const globalPadding = 20;
const bubblePadding = [17, 26, 24];

function wrapText(text) {
  const lines = [];
  let testLine = '';
  let prevPoint = 0;
  for (let i = 0; i < text.length; i += 1) {
    testLine += text[i];
    let { width: testWidth } = ctx.measureText(testLine);
    if (testWidth > 830) {
      // 830 为微信聊天气泡内文字单行最大宽度
      lines.push(text.substring(prevPoint, i));
      testLine = '';
      prevPoint = i;
      i -= 1;
    }
  }
  if (testLine) {
    lines.push(testLine);
  }

  return lines;
}

ctx.font = '28px "PingFangSC-Regular, sans-serif, Arial, SF Pro Display"';
const lines = wrapText(chatText);
// lines为换行后的聊天文字数组
const [paddingTop, paddingRight, paddingLeft] = bubblePadding;
const canvasHeight =
  lines.length * chatLineHeight + globalPadding * 2 + paddingTop * 2;
// 得到最终高度
复制代码

计算宽度

截图最终宽度 = 截图内边距(20px * 2) + 文字内容宽度 + 气泡左内边距(24px) + 气泡右内边距(26px) + 头像大小(64px) + 气泡与头像距离(10px)。

因为我们设置的单行最大宽度为 830px,小于等于 830px 的文字只有一行,超过则为多行,那么可以判断 lineslength,大于 1 的文字宽度取 830px,小于 1 的取 measureText 方法返回的 width

const bubbleRightMargin = 10; // 气泡和头像的距离
const headSize = 64; // 头像大小

const { width: lineWidth } =
  lines.length > 1 ? { width: 830 } : ctx.measureText(lines?.[0] ?? '');
const canvasWidth =
  Math.ceil(lineWidth) +
  globalPadding * 2 +
  paddingLeft +
  paddingRight +
  bubbleRightMargin +
  headSize;
// 得到最终宽度
复制代码

得到真实宽高以后重新设置 canvas 的尺寸:

// 重设 canvas 大小
ctx.canvas.width = canvasWidth;
ctx.canvas.height = canvasHeight;
复制代码

工具函数

这边准备了几个常用的 canvas 绘制工具函数:

// 图片填充模式
function aspectFill(w, h, s) {
  if (w / s >= h) {
    return {
      sx: (w - h * s) / 2,
      sy: 0,
      sw: h * s,
      sh: h,
    };
  }
  return {
    sx: 0,
    sy: (h - w / s) / 2,
    sw: w,
    sh: w / s,
  };
}

// 在指定位置绘制图片
function drawImageFill(ctx, img, x, y, w, h) {
  const result = aspectFill(img.width, img.height, w / h);
  ctx.drawImage(img, result.sx, result.sy, result.sw, result.sh, x, y, w, h);
}

// 绘制圆角矩形
function drawRoundRectPath(ctx, width, height, radius) {
  ctx.beginPath(0);
  // 从右下角顺时针绘制,弧度从0到1/2PI
  ctx.arc(width - radius, height - radius, radius, 0, Math.PI / 2);

  // 矩形下边线
  ctx.lineTo(radius, height);

  // 左下角圆弧,弧度从1/2PI到PI
  ctx.arc(radius, height - radius, radius, Math.PI / 2, Math.PI);

  // 矩形左边线
  ctx.lineTo(0, radius);

  // 左上角圆弧,弧度从PI到3/2PI
  ctx.arc(radius, radius, radius, Math.PI, (Math.PI * 3) / 2);

  // 上边线
  ctx.lineTo(width - radius, 0);

  // 右上角圆弧
  ctx.arc(width - radius, radius, radius, (Math.PI * 3) / 2, Math.PI * 2);

  // 右边线
  ctx.lineTo(width, height - radius);
  ctx.closePath();
}

// 绘制带圆角的图片
function drawImageWidthRadios(ctx, img, x, y, w, h, r) {
  if (2 * r > w || 2 * r > h) {
    return false;
  }

  ctx.save();
  ctx.translate(x, y);
  // eslint-disable-next-line no-use-before-define
  drawRoundRectPath(ctx, w, h, r);
  ctx.clip();
  // eslint-disable-next-line no-use-before-define
  drawImageFill(ctx, img, 0, 0, w, h);
  ctx.restore();
}
复制代码

下面开始绘制截图啦~

背景

绘制一个跟 canvas 一样大的方形,填充主题背景色。

// 绘制底色
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
复制代码

气泡

原来想的是用安卓 .9.png 的思路,做一个可拉伸的图片来实现气泡,根据文字内容伸缩尺寸,如下图:

然后突然格局打开了,气泡不就是一个圆角矩形加一个三角形么,然后就更换了实现方式。

首先需要知道我们需要画多大的圆角矩形,在哪里画这个圆角矩形。

圆角矩形的尺寸计算跟 canvas 尺寸计算如出一辙:

// 气泡大小
const bubbleWidth = Math.ceil(lineWidth) + paddingLeft + paddingRight - 10;
// 这里减 10 是为箭头预留位置
const bubbleHeight = lines.length * chatLineHeight + paddingTop * 2;
复制代码

绘制的起点则取截图内边距(20px,20px):

// 绘制气泡背景
ctx.save();
ctx.translate(globalPadding, globalPadding);
drawRoundRectPath(ctx, bubbleWidth, bubbleHeight, 8);
ctx.clip();
ctx.fillStyle = bubbleBgColor;
ctx.fillRect(0, 0, bubbleWidth, bubbleHeight);
ctx.restore();
复制代码

然后给气泡加上箭头,箭头是由 png 图片转换的 base64 数据,使用 canvas 提供的 loadImage 方法加载,需要计算出绘制的位置,代码如下:

const arrowImage = await loadImage(arrowBg); // 加载箭头图片
// 气泡箭头起始位置
const arrowY = 24 + globalPadding;
// 24px 为箭头与气泡顶部的距离
const arrowX = canvasWidth - globalPadding - headSize - 20;
// 这边减 20px 为气泡和头像的距离+箭头自身宽度

// 绘制气泡箭头
ctx.drawImage(arrowImage, 0, 0, 10, 16, arrowX, arrowY, 10, 16);
复制代码

这样我们就得到了一个预留了头像位置的聊天气泡。

头像

头像是可配置的,方便其他同学使用的时候换上自己的头像,所以头像的地址将从运行时的环境变量中获取。然后同样使用 loadImage 加载,在指定位置绘制。

头像的横向起始位置 = 截图总宽度 - 截图内边距(20px) - 头像大小(64px)

头像的纵向起始位置则取截图内边距(20px)

// 头像横向位置
const headX = canvasWidth - globalPadding - headSize;

// 绘制头像
drawImageWidthRadios(
  ctx,
  headImage,
  headX,
  globalPadding,
  headSize,
  headSize,
  4
);
复制代码

这样头像就加好啦,万事俱备,只欠写字。

聊天文字

上面我们已经进行了聊天文字的分行,现在只需要将文字逐行绘制在气泡中,就完事儿了。

// 绘制聊天文字
function drawChatText(ctx, x, y, lines) {
  lines.forEach((line, index) => {
    const drawY = y + chatLineHeight * index;
    ctx.beginPath();
    ctx.font = '28px "PingFangSC-Regular, sans-serif, Arial, SF Pro Display"';
    ctx.fillStyle = '#05110a';
    ctx.textBaseline = 'top';
    // 划重点,这里需要将文字的基线设置在上方,不然绘制的文字跟我们想象的位置有所出入,会偏上。
    ctx.fillText(line, x, drawY);
    ctx.closePath();
  });
}

// 文本起始位置
const textStartX = globalPadding + paddingLeft;
const textStartY = globalPadding + paddingTop;

// 绘制文字
drawChatText(ctx, textStartX, textStartY, lines);
复制代码

这是绘制一行文字的截图:

这是多行文字的截图:

保存文件

绘制完的图片还在 canvas 中,我们需要将它保存到本地,可以使用 canvas 提供的 toBuffer 方法,将内容输出为二进制流。然后结合 Nodefs 模块即可完成保存。

const fs = require('fs');

// 保存图片到本地
fs.writeFileSync(`${projectPath}/output.png`, canvas.toBuffer());
复制代码

至此,《多此一举生成器》的功能已经开发完成。

便捷使用

让我们尝试在项目根目录下运行:

headUrl=https://fmcat-images.oss-cn-hangzhou.aliyuncs.com/head.png projectPath=. node index.js 这是黑色主题的文字 dark
复制代码

这样在根目录下就生成了一张名为 output.png 的图片,在聊天工具中选择图片就能发送了。

但是这样使用起来太繁琐,一个不够便捷的工具不能算一个好工具。

如果能输入聊天内容后直接生成图片,并把图片复制到剪贴板,然后直接在聊天软件粘贴岂不美哉?

为了实现上面的想法,需要引入工作流工具。

工作流

工作中常见的的工作流工具有 Jenkins、阿里云效中的流水线、GitHub Action 等等。配置完成后可以通过特定动作、时间触发流程,完成指定任务,比如项目发布、构建部署、产品输出等等。

这些工具大部分都是在远端环境,无法操作本地环境的剪贴板。

我们将使用一些更便捷,能够和操作系统结合紧密的个人工作流工具来达成目的。

这边仅介绍 Mac OS 下的工具,使用 Windows 的同学可以自行探索。

Alfred

这可以称得上是 Mac OS 上最能提升效率的工具,提供文件、软件搜索比系统自带的更好用,还有剪贴板历史记录、快捷计算器等等便捷功能。

它最强大的功能当属 Workflows,用户可以自己编辑流程,直接支持多种语言,不支持的语言可以通过 bash 运行,开发完的工作流可以导出文件用于分享。社区繁荣,GitHub 上有很多开源的 Workflows 项目可以使用、学习。

比如有道翻译,导入工作流文件以后,配置有道官方申请的 keysecret,使用设置好的快捷键唤起输入框,输入要翻译的文字即可。

心动不如行动,下面我们使用 Alfred Workflows 来配置一个能够便捷使用《多此一举生成器》的工作流。

外部变量

在开发时预留的外部参数分为 运行参数、环境变量

让我们再来回顾一下运行工具的命令:

headUrl=https://fmcat-images.oss-cn-hangzhou.aliyuncs.com/head.png projectPath=. node index.js 这是黑色主题的文字 dark
复制代码

运行参数

chatText:生成截图的聊天文字,定义为命令中 index.js 后面的第一个参数。

theme:截图的主题,将影响背景色和气泡颜色,定义为命令中 index.js 后面的第二个参数。

通过下面的代码获取外部传入的值。

const [chatText = '', theme = 'light'] = process.argv.slice(2);
复制代码

环境变量

相对于运行参数,环境变量的值不需要频繁变化,只需要在项目配置时设置一次。

projectPath:项目路径,用于指定 index.js 和截图输出的 output.png 文件位置。

headUrl:设置截图中用户头像的 URL 地址。

新建 Workflows

打开 Alfred 主面板,选择 Workflows 选项卡,新建一个使用关键词触发运行命令的模板。

注意 Bundle Id 需要唯一,后续更新时会判断是否为同一个 Workflows。

右上方可以设置图标,让你的 Workflows 更具辨识度。

设置环境变量

在编辑区的右上方有个 [χ] 按钮,可以打开环境变量编辑面板。

点击右下角的加号按钮依次添加 headUrlnodePathprojectPath 环境变量。

这边需要 nodePath 是因为 Alfred 的默认 bash 环境中找不到 node 命令所在地址,需要我们手动指定一下。

设置关键词

双击 Keyword 流程块打开详情编辑面板。

保存后在 Alfred 输入框中输入 dd 就能看到我们的命令了。

根据主题不同,可以设置两个关键词。

接着在空白区右键,新建一个变量处理块,在下方变量列表中新建一个 theme,值为 dark,与 dd 关键词连接。

Argument 中的 {query} 为前方 keyword 块截取的聊天文字。

变量处理块会将内容带给下一个流程块。

同理,theme 值为 light 的变量块与 dl 关键词连接。

判断

项目目录未设置、用户头像未设置都不能顺利运行工具,所以需要提前判断,通知用户进行设置。

在编辑区空白区域右键新建 Conditional 块。

通过 {var:变量名称} 语法取环境变量中的 projectPathheadUrl,判断条件满足会对外暴露一个流程端口,用于连接后续的流程。

在变量为空的接口上连接了一个调用通知的块,可以通过空白区域右键新建,填写对应的通知文本。

现在我们把环境变量中的 projectPath 置空,再次运行工具,将会得到一个提醒设置项目路径的通知。

运行脚本

projectPathheadUrl 都非空时,会走 else 的端口,后面再连接一个对 nodePath 的判断,两个端口连接的都是 Run Script 块,用于运行代码。

下面对比一下两者有什么区别:

nodePath 为空时,会先执行 source ~/.zshrc ,因为目前 Mac OS 默认的终端是 zsh,且假设使用工具的人都是同行,配置了 nvm。如果有不同环境,请自行配置。

nodePath 非空时,执行代码如下:

手动配置 nodePath 后运行速度会比自动引入更快,这边还是建议手动配置一下。

然后就是跟手动运行项目一样的命令,只是命令中的部分内容被替换成了前面配置的变量。

取环境变量为 $ 开头,后面跟环境变量名称,此处的 $1 为上一个块传进来的 query

更进一步

完成上面的运行工具就能在项目根目录生成聊天截图了,但是要使用这张图片还是很不方便,需要到项目目录下复制。

能不能把生成的图片直接复制到剪贴板呢?答案是可以的,但是实现的过程很曲折。

首先考虑的是能否使用 Node 来完成这步操作,翻阅文档并没有相关的 API,在 npm 上搜索了很久,只找到能操作文字复制到剪贴板的包。

当我要放弃的时候,想到了 AppleScript,这是苹果自家的脚本语言,可以用于调用系统 APP 完成指定功能。

翻阅文档发现 Finder 有相关的 API 可以实现复制文件到剪贴板,实现起来也很简单,只需要知道文件的绝对路径,就可以完成操作。

我们先使用变量处理块把图片的绝对路径拼接好,传给代码运行块

代码运行块中代码如下:

运行时 {query} 会被替换成拼好的图片绝对路径,这句代码的意思是执行 AppleScript,告诉 Finder 将指定图片设置到剪贴板。

代码运行块后面再连接一个通知块,通知用户图片已经生成完成并且复制到剪贴板了。

流程总览如下。

现在我们只需要唤起 Alfred 输入框,输入 dd/dl 聊天文字 ,然后回车等待通知生成完成以后就可以在聊天工具中粘贴发送了。

快捷指令

Apple 于 iOS 11 添加了 捷径 App,后改名为快捷指令,用于实现一些流程化操作,大大提高了 iOS 的可玩性。

如今 Mas OS 12 上也支持了快捷指令,我们可以使用快捷指令来配置《多此一举生成器》,跟 Alfred 的配置方式大同小异,只是在变量设置、获取方面有一些不同。

编辑

运行

将编辑好的快捷指令拖动到菜单栏文件夹,在菜单栏中就可以看到快捷指令的图标了。

点击即可运行,首先会弹出一个选择主题的弹窗,选择后点完成。

接着是要求输入聊天内容的弹窗,输入后点击完成,就开始执行工具脚本了。

执行完成会收到通知。

最终生成的聊天截图:

小结

虽然《多此一举生成器》这个项目没有很大的实用性,只是一个无厘头的搞笑项目,但还是有一些技术含量的。

用好工作流工具,能让你事半功倍,可以把多种技术结合起来,把不可能变为可能。不论是自己写小工具还是团队项目开发,都能有用武之地。

目前项目已经开源,附带了配置好的 Alfred Workflows 和 快捷指令链接,朋友们可以开箱即用。项目地址在下面:

https://github.com/RongleCat/fmcat-redundant-generator
复制代码

好了朋友们,马上就要涨潮了,下次再见~

更多精彩请关注我们的公众号“ 百瓶技术 ”,有不定期福利呦!

猜你喜欢

转载自juejin.im/post/7035916076651118623