https://github.com/lovell/sharp 是 Node.js 平台上相当热门的一个图像处理库, 其实际上是基于 C 语言编写 的 https://github.com/jcupitt/libvips 库封装而来, 因此高性能也成了 sharp 的一大卖点. sharp 可以方便地实现常见的图片编辑操作, 如裁剪, 格式转换, 旋转变换, 滤镜添加等. 当然, 网络上相关的文章比较多, sharp 的官方文档 http://sharp.pixelplumbing.com/en/stable/ 也比较详细, 所以这不是本文的重点. 这里主要是想记录一下我在使用 sharp 过程中遇到的一些稍复杂的图片处理需求的解决方案, 希望分享出来能够对大家有所帮助.
sharp 基础
sharp 整体采用流式处理模式, 其在读入图像数据后经过一系列的处理加工然后输出结果. 我们看一个简单的示例就能理解:
- const sharp = require('sharp');
- sharp('input.jpg')
- .rotate()
- .resize(200)
- .toBuffer()
- .then( data => ... )
- .catch( err => ... );
sharp 几乎所有的函数接口都挂载在 Sharp 实例上, 因此图像处理的第一步操作一定是读入图片数据 (sharp 函数接受图片本地路径或者图片 Buffer 数据作为参数) 并将其转换为 Sharp 实例, 然后才是如流水线一般的加工. 因此, 这里应该提供一个预处理函数, 将服务端接收到的图片转换为 Sharp 实例:
- /**
- *
- * @param { String | Buffer } inputImg 图片本地路径或图片 Buffer 数据
- * @return { Sharp }
- */
- async convert2Sharp(inputImg) {
- return sharp(inputImg)
- }
然后就可以进行具体的图像处理.
添加水印
后端实现
添加水印功能应该算是比较常见的图片处理需求了. sharp 在图像合成方面只提供了一个函数: overlayWith, 其接受一个图片参数 (同样是图片本地路径字符串或者图片 Buffer 数据) 以及一个可选的 options 配置对象 (可配置水印图片的位置等信息) 然后将该图片覆盖到原图上. 逻辑上也比较简单, 我们的代码如下所示:
- /**
- * 添加水印
- * @param { Sharp } img 原图
- * @param { String } watermarkRaw 水印图片
- * @param { top } 水印距图片上边缘距离
- * @param { left } 水印距图片左边缘距离
- */
- async watermark(img, { watermarkRaw, top, left }) {
- const watermarkImg = await watermarkRaw.toBuffer()
- return img
- .overlayWith(watermarkImg, { top, left })
- }
这里简单起见只支持配置水印图片的位置, sharp 还支持更复杂的配置参数比如是否重复粘贴多个水印图片, 是否只在 α 信道粘贴水印图片等, 具体可参见 overlayWith 的文档.
前端实现
这里还需要顺带提一下前端的实现. 当然, 如果服务端是按照固定规则给图片添加水印 (比如新浪微博里图片水印放置在固定的位置), 前端就不必做什么了. 但是某些场景下(比如在线图片编辑类工具中) 用户添加水印的时候会期望能够在前端获得所见即所得的体验. 这个时候如果用户添加完水印并且选好位置后, 必须将数据发送至服务端处理再得到处理结果, 势必会影响整个服务的流畅性. 幸运的是强大的 html5 让前端的功能越来越丰富, 借助 canvas 我们就能在前端实现添加水印的功能. 具体的实现细节并不难, 主要就是借助了 canvas 提供的 drawImage 方法, 看一下示例:
- var canvas = document.getElementById("canvas");
- var ctx = canvas.getContext('2d');
- // img: 底图
- // watermarkImg: 水印图片
- // x, y 是画布上放置 img 的坐标
- ctx.drawImage(img, x, y);
- ctx.drawImage(watermarkImg, x, y);
实际上, 整个添加水印的功能 (选择原图, 选择水印图片, 设置水印图片位置, 获得添加水印后的图片) 是可以完全由前端完成的. 当然, 为了追求服务端功能的完整性, 还是建议使用前端展示 + 后端处理的模式.
粘贴文字
粘贴文字的需求实际上与添加水印比较类似. 唯一不同的是添加的水印图片换成了文字, 以及我们可能需要对文字的大小, 字体等做一些调整. 思路也比较容易想到, 把文字转换成图片形式即可. 这里我们用到了 text-to-svg 库, 作用是将文字转换成 svg. 利用 svg 的特点我们可以很方便地设置文字的字体大小, 颜色等. 然后调用 Buffer.from 将 svg 转换为 sharp 可以使用的 buffer 数据. 最后就是和上面的水印添加一样的步骤了.
- const Text2SVG = require('text-to-svg')
- /**
- * 粘贴文字
- * @param { Sharp } img
- * @param { String } text 待粘贴文字
- * @param { Number } fontSize 文字大小
- * @param { String } color 文字颜色
- * @param { Number } left 文字距图片左边缘距离
- * @param { Number } top 文字距图片上边缘距离
- */
- async pasteText(img, {
text, fontSize, color, left, top,
- }) {
- const text2SVG = Text2SVG.loadSync()
- const attributes = { fill: color }
- const options = {
- fontSize,
- anchor: 'top',
- attributes,
- }
- const svg = Buffer.from(text2SVG.getSVG(text, options))
- return img
- .overlayWith(svg, { left, top })
- }
拼接图片
拼接图片的操作相对来说最为复杂. 这里我们提供了两个配置项: 拼接模式 (水平 / 垂直) 以及背景颜色. 拼接模式比较好理解, 无非是水平或是垂直排列图片. 背景颜色则用于填充留白处. 拼接图片时, 图片以根据轴线居中排列. 以水平排列图片为例, 示意图如下:
这里也没有 sharp 提供的现成函数, 一切还是用唯一的 overlayWith 解决. overlayWith 的用法是将一张图粘贴至另一张图上, 这与我们拼接图片的需求略有差异. 我们需要转换一下思维: 可以预先创建一张底图, 背景颜色可以根据配置值确定, 然后将所有待拼接图片粘贴至其上, 即可满足要求.
首先我们需要读取所有待拼接图片的长与宽. 假设拼接模式为水平拼接, 那么最终生成的图片的宽度为所有图片宽度之和, 高度则取所有图片中的最大高度(垂直拼接的话则反过来):
- let totalWidth = 0
- let totalHeight = 0
- let maxWidth = 0
- let maxHeight = 0
- const imgMetadataList = []
- // 获取所有图片的宽和高, 计算和及最大值
- for (let i = 0, j = imgList.length; i <j; i += i) {
- const { width, height } = await imgList[i].metadata()
- imgMetadataList.push({ width, height })
- totalHeight += height
- totalWidth += width
- maxHeight = Math.max(maxHeight, height)
- maxWidth = Math.max(maxWidth, width)
- }
然后我们用得到的宽度和高度数据新建一个背景颜色为传入配置 (或默认白色) 的 base 图片:
- const baseOpt = {
- width: mode === 'horizontal' ? totalWidth : maxWidth,
- height: mode === 'vertical' ? totalHeight : maxHeight,
- channels: 4,
- background: background || {
- r: 255, g: 255, b: 255, alpha: 1,
- },
- }
- const base = sharp({
- create: baseOpt,
- }).jpeg().toBuffer()
然后在 base 图片的基础上重复调用 overlayWith 函数, 将待拼接图片逐个粘贴至 base 图片上. 这里需要注意的是图片的摆放位置, 前面也提到过, 我们会将图片根据主轴线进行居中对齐, 所以每次摆放图片时都需要进行 top 和 left 的计算(一个是居中的计算, 一个是随着图片摆放顺序进行偏移的计算), 当然, 弄明白了原理之后就是小学数学题, 没有太多可讲的. 另一个需要注意的则是 overlayWith 每次只能完成两张图片之间的合成, 因此我们用到了 reduce 方法, 持续地将图片粘贴至底图上, 并将结果作为下一次的输入.
- imgMetadataList.unshift({ width: 0, height: 0 })
- let imgIndex = 0
- const result = await imgList.reduce(async (input, overlay) => {
- const offsetOpt = {}
- if (mode === 'horizontal') {
- offsetOpt.left = imgMetadataList[imgIndex++].width
- offsetOpt.top = (maxHeight - imgMetadataList[imgIndex].height) / 2
- } else {
- offsetOpt.top = imgMetadataList[imgIndex++].height
- offsetOpt.left = (maxWidth - imgMetadataList[imgIndex].width) / 2
- }
- overlay = await overlay.toBuffer()
- return input.then(data => sharp(data).overlayWith(overlay, offsetOpt).jpeg().toBuffer())
- }, base)
- return result
以下是拼接图片函数的完整实现:
- /**
- * 拼接图片
- * @param { Array<Sharp> } imgList
- * @param { String } mode 拼接模式: horizontal(水平)/vertical(垂直)
- * @param { Object } background 背景颜色 格式为 {r: 0-255, g: 0-255, b: 0-255, alpha: 0-1} 默认 {r: 255, g: 255, b: 255, alpha: 1}
- */
- async joinImage(imgList, { mode, background }) {
- let totalWidth = 0
- let totalHeight = 0
- let maxWidth = 0
- let maxHeight = 0
- const imgMetadataList = []
- // 获取所有图片的宽和高, 计算和及最大值
- for (let i = 0, j = imgList.length; i <j; i += i) {
- const { width, height } = await imgList[i].metadata()
- imgMetadataList.push({ width, height })
- totalHeight += height
- totalWidth += width
- maxHeight = Math.max(maxHeight, height)
- maxWidth = Math.max(maxWidth, width)
- }
- const baseOpt = {
- width: mode === 'horizontal' ? totalWidth : maxWidth,
- height: mode === 'vertical' ? totalHeight : maxHeight,
- channels: 4,
- background: background || {
- r: 255, g: 255, b: 255, alpha: 1,
- },
- }
- const base = sharp({
- create: baseOpt,
- }).jpeg().toBuffer()
- // 获取图片的原始尺寸用于偏移
- imgMetadataList.unshift({ width: 0, height: 0 })
- let imgIndex = 0
- const result = await imgList.reduce(async (input, overlay) => {
- const offsetOpt = {}
- if (mode === 'horizontal') {
- offsetOpt.left = imgMetadataList[imgIndex++].width
- offsetOpt.top = (maxHeight - imgMetadataList[imgIndex].height) / 2
- } else {
- offsetOpt.top = imgMetadataList[imgIndex++].height
- offsetOpt.left = (maxWidth - imgMetadataList[imgIndex].width) / 2
- }
- overlay = await overlay.toBuffer()
- return input.then(data => sharp(data).overlayWith(overlay, offsetOpt).jpeg().toBuffer())
- }, base)
- return result
- },
以上就是个人在使用 sharp 过程中总结的一些实用操作. 实际上 sharp 还有很多高级的功能我并没有用到, 正应了 "二八定律":80% 的需求常常是通过 20% 的功能完成的. sharp 更多的用法以后如果还有机会折腾, 会继续跟大家分享~
来源: https://juejin.im/post/5b0bd60e6fb9a00a1610e4be