Node图像处理——使用Jimp及node-qrcode生成图片上传

4,906 阅读6分钟

Node图片处理——Jimp配合node-qrcode生成图片上传总结

上周产品那边来了一个需求,需要基于原图针对不同用户生成不同二维码以及文案,并生成新图片,让用户能够保存。接到这个需求时,心里不仅没有拒绝的意思,反而有点小兴奋 ~ 因为又能探索一下新东西。

大致效果如下,原图:

效果图:

试水canvas

刚开始打算在前端用canvas生成图片。我们都知道canvas有合成图片的功能,核心是drawImagetoDataURL这两个方法。

大致思路是:

  1. 使用drawImage将生成的二维码合并到原图的指定位置
  2. 使用fillText方法生成文案
  3. toDataURL将图片转成base64
  4. 使用 atob 以及 Uint8Array 将其转为Buffer进行上传。

不过最终该方案没有走通,因为不同手机尺寸比例不统一,生成的二维码的位置无法准确地定位到指定位置,因此采用了另一种方案:node层生成图片。

node搞起

在node层就无需考虑适配的问题了,因为只有一个基准,也就是原图。生成二维码及文案的尺寸、位置都可以直接写死。经过调研,node图像处理库最出名的有两个,分别是:JimpSharp,最终选用Jimp,因为Sharp没安装上😓。二维码库倒是很多,最终决定选用 node-qrcode

开搞!

主要步骤就两步,如下:

  1. 生成图片
  2. 读取图片并上传

下面分解这两步讲解

生成图片

生成图片是最麻烦的。步骤比较多:

  1. 使用qrcode生成二维码 Buffer
  2. 包装二维码Buffer为Jimp对象
  3. 生成文案
  4. 合成图片并保存

大部分都是调用Jimp及qrcode的api,还有一些node的原生api,如使用Buffer.from将base64转为Buffer。感兴趣的可以去参阅它们的文档:

由于生成图片步骤较多,每一步都依赖上一步的结果,并且都是异步的,如果使用回调的话就彻底陷入回调地狱了😓,因此主要想说的是代码组织方式。不怕大家笑话,我的第一版代码是这样的🤣:

// 生成二维码Buffer
const codeBuffer = yield new Promise((resolve, reject) => {
  Qrcode.toDataURL(url, {}, (err, url) => {
    // 注意:这里必须把“data:image/png;base64,”这一段去掉才能转成正确的buffer
    const res = Buffer.from(url.replace(/.+,/, ''), 'base64')
    err ? reject(err) : resolve(res)
  })
}).catch(() => {})
// 生成文字
const textJimp = yield new Promise((resolve, reject) => {
  new Jimp(textBgWidth, config.textBgHeight, +`0xFF${config.textBgColor}`, (err, image) => {
    Jimp.loadFont(config.fontPath).then((font) => {
      resolve(image.print(font, config.textPadding, 10, textContent, 10))
    })
  })
})
// 将二维码Buffer包装成Jimp对象
const codeJimp = yield new Promise((resolve, reject) => {
  Jimp.read(codeBuffer).then((res) => {
    if (res) {
      resolve(res.resize(config.codeWidth, config.codeWidth))
    } else {
      reject('包装buffer失败')
    }
  })
}).catch(() => {})

yield new Promise((resolve, reject) => {
  Jimp.read(config.originImgPath).then(img => {
    img.composite(codeJimp, config.codeLeft, config.codeTop)
       .composite(textJimp, config.textLeft, config.textTop)
       // 由于fs.createReadStream不能接受Buffer作为参数,只能将生成的图片临时保存到本地
       .write(config.tempFilePath, () => {
         // resolve()
         reject('保存图片失败!')
       })
  })
}).catch((err) => {
  console.log('保存图片出错:', err)
})

因为我们使用的node前后端分离框架 grace 的版本是支持 generator 语法的,所以想到了使用 yield 来将异步操作同步展示,但还是看起来太繁琐了😓,必须重构!

promise 登场!

使用 promise 的链式调用语法,结构就会清晰很多,改写后代码是这样的:

// 组合多个异步I/O
const imgResult = yield generateCode(href)
  // 生成二维码Buffer
  .then((res) => {
    codeBuffer = res;
    // 包装二维码Buffer为Jimp对象
    return wrapCodeBuffer(codeBuffer, imgConfig);
  })
  .then((res) => {
    codeJimp = res;
    // 生成文字
    return generateText(textBgWidth, textContent, imgConfig);
  })
  .then((res) => {
    textJimp = res;
    // 组合并生成图片
    return compositeImg(imgConfig, textJimp, codeJimp);
  })
  // 成功
  .then(() => true)
  // 中途出错
  .catch((err) => {
    return false;
  });

瞬间优雅的许多 ~
实现方法也很简单,就是让每个步骤的方法都返回一个 promise 即可,拿该方法为例:

/**
 * 包装二维码Buffer为Jimp对象
 * @param  {Buffer} codeBuffer  [二维码Buffer对象]
 * @param  {Object} config      [配置对象]
 * @return {Promise}
 */
function wrapCodeBuffer(codeBuffer, config) {
  return new Promise((resolve, reject) => {
    Jimp.read(codeBuffer).then((res) => {
      if (res) {
        resolve(res.resize(config.codeWidth, config.codeWidth));
      } else {
        reject('包装二维码Buffer失败');
      }
    });
  });
}

上传图片

接下来是使用node上传图片。由于使用的后端接口是基于FormData方式的,所以要在node层模拟一个FormData上传请求。
起初是完全懵逼的,因为对http协议的这块标准一直是一知半解。在前端使用FormData上传图片时我们经常能看到请求体是这样的:

------WebKitFormBoundarywQMoN5B2ZNAD6uqN
Content-Disposition: form-data; name="file"; filename="avatar.jpeg"
Content-Type: image/jpeg

------WebKitFormBoundarywQMoN5B2ZNAD6uqN--

请求头的Content-Type是这样的:

Content-Type: multipart/form-data; boundary=----WebKitFormBoundarywQMoN5B2ZNAD6uqN

看起来挺复杂的,尤其是这个------WebKitFormBoundarywQMoN5B2ZNAD6uqN--到底是个什么鬼😢。

别急,先从我的这个上传方法讲起:

/**
 * 上传图片方法
 * @param  {ClientRequest} request  [由http.request方法返回的对象]
 * @param  {Object}        config   [配置对象]
 * @param  {String}        cookies  [用户请求时所带的所有cookie]
 * @return
 */
function uploadImg(request, config, cookies = '') {
  // 模拟form-data请求后端接口上传图片
  const boundaryKey = Math.random().toString(16);
  const endData = '\r\n----' + boundaryKey + '--';
  let contentLength = 0,
      content = '';
  content += '\r\n----' + boundaryKey + '\r\n' +
            'Content-Type: application/octet-stream\r\n' +
            'Content-Disposition: form-data; name="file"; ' +
            'filename="bg_invite.png"; \r\n' +
            'Content-Transfer-Encoding: binary\r\n\r\n';
  let contentBinary = Buffer.from(content, 'utf-8');
  // 获取上传内容总大小
  contentLength = fs.statSync(config.tempFilePath).size + Buffer.byteLength(contentBinary) + Buffer.byteLength(endData);
  // 设置请求头
  request.setHeader('Content-Type', 'multipart/form-data; boundary=--' + boundaryKey);
  request.setHeader('Content-Length', contentLength);
  request.setHeader('Cookie', cookies);
  request.write(contentBinary);
  const fileStream = fs.createReadStream(config.tempFilePath, { bufferSize: 4 * 1024 });
  fileStream.on('end', () => {
    // 发送请求
    request.end(endData);
  });
  fileStream.pipe(request);
}

可以看到,这个方法其实就是构造了请求,拆分下来就如下几件事:

  • 构造请求头
  • 计算上传内容总大小
  • 将文件以流的形式写入http.ClientRequest对象

先说请求头,FormData形式的请求Content-Type为multipart/form-data,并且一定要提供boundary字段。可是为什么呢?

我们都知道默认提交表单时,Content-Type是application/x-www-form-urlencoded,并且参数都是已类似name=John&age=12这种形式在请求体中传递的,参数是以&分割的。这里的boundary的作用就跟&一样,是用来分割多个参数的,并且是可以自定义的,而在浏览器中,是浏览器为我们自动生成的,这就知道了上文中那个boundary是怎么回事了 ~

再看每个boundary之间的内容,也就是每个字段,其中还有Content-type及Content-Disposition字段我们很陌生。

Content-Type跟http协议的Content-Type是一样的,只不过在multipart/form-data类型中,我们可以手动指定每个参数的Content-Type。方法中的字段值为application/octet-stream,就是告诉Server这部分内容是字节流,因为我们需要以字节流的形式上传图片。

而Content-Disposition是每个参数必须的选项,并且值必须为form-data。该头其实还有其他用途,可以参阅MDN的官方文档

接下来是计算Content-Length。这里主要使用了node的fs模块,以及Buffer模块的api,都很好理解,查看文档即可。

最后是将图片写入http.ClientRequest对象中。该对象是由node的http.request方法返回,并且是一个可写流。引用node官方文档的话:

ClientRequest 实例是一个可写流。 如果需要通过 POST 请求上传一个文件,则写入到 ClientRequest 对象。

最后再调用http.ClientRequest对象的end方法,即可完成请求对象的写入,就发出请求啦 ~

至此,一个Node合成图片并上传的需求完成!过程中收获非常多!

生命不息折腾不止!