nodejs 图片添加水印(png, jpeg, jpg, gif)

nodejs 作为一个脚本语言,图片处理这方面有点弱鸡,无法跟 php 这种本身集成了图片 api 的语言相比。

不过好在有 https://www.npmjs.com/ ,上面有全世界的大佬写的各种高大上的插件使用。

本文踩在巨人的肩上介绍 nodejs 添加图片水印的几种方式。

方案一:使用云处理

如果图片私有性要求不高,也不嫌弃注册各种云麻烦,那么这种方式比较适合。

国内 七牛云:https://developer.qiniu.com/dora/api/1316/image-watermarking-processing-watermark
国外 Cloudinary:https://cloudinary.com/documentation/node_image_manipulation

方案二:使用 nodejs 插件

注意:程序添加水印都有一个通病,添加水印之后的图片体积至少是原图的2倍以上。

  1. node-canvas:https://github.com/Automattic/node-canvas

问题:需要安装 node-pre-gyp ,依赖系统,各种安装困难,难搞哦。

  1. gm:https://github.com/aheckmann/gm

问题:很久不更新了。

  1. node-images:https://github.com/zhangyuanwei/node-images

问题:不支持 gif 图片。

优点:轻量级,不依赖系统,国内大佬写的。

使用简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var images = require("images");

/**
* 添加水印
* @param srcImg 源图
* @param watermarkImg 水印图
* @param x 添加水印水平位置x
* @param y 添加水印垂直位置y
*/
var imageAddWatermark = function(srcImg,watermarkImg,x,y){
images(srcImg).draw(images(watermarkImg), x, y).save(output);
};

var srcImg = './img.jpg';
var watermarkImg = './logo.png';
var output = './out.jpg';
imageAddWatermark(srcImg, watermarkImg, 10, 10);
  1. jimp:https://github.com/oliver-moran/jimp

问题:不支持 gif 图片。

优点:功能齐全,不依赖系统,国外大佬写的,有可选用的 gif 代替方案,不过不成熟,如果添加水印之后图片颜色超过 256 色,保存会报错,需要添加颜色转换。

gif 方案:https://github.com/jtlapp/gifwrap

多番考虑,最终选用 jimp 做水印效果

jimp 支持的图片类型有: image/jpeg,image/png,image/bmp,image/x-ms-bmp,image/tiff

安装

1
npm install jimp --save-dev

问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
npm ERR! path D:\jimp\node_modules\.staging\exif-parser-158ca68b\test\starfish.jpg
npm ERR! code EPERM
npm ERR! errno -4048
npm ERR! syscall unlink
npm ERR! Error: EPERM: operation not permitted, unlink 'D:\jimp\node_modules\.staging\exif-parser-158ca68b\test\starfish.jpg
'
npm ERR! { [Error: EPERM: operation not permitted, unlink 'D:\jimp\node_modules\.staging\exif-parser-158ca68b\test\starfish
.jpg']
npm ERR! cause:
npm ERR! { Error: EPERM: operation not permitted, unlink 'D:\jimp\node_modules\.staging\exif-parser-158ca68b\test\starfis
h.jpg'
npm ERR! errno: -4048,
npm ERR! code: 'EPERM',
npm ERR! syscall: 'unlink',
npm ERR! path:
npm ERR! 'D:\\jimp\\node_modules\\.staging\\exif-parser-158ca68b\\test\\starfish.jpg' },
npm ERR! stack:
npm ERR! 'Error: EPERM: operation not permitted, unlink \'D:\\jimp\\node_modules\\.staging\\exif-parser-158ca68b\\tes
t\\starfish.jpg\'',
npm ERR! errno: -4048,
npm ERR! code: 'EPERM',
npm ERR! syscall: 'unlink',
npm ERR! path:
npm ERR! 'D:\\jimp\\node_modules\\.staging\\exif-parser-158ca68b\\test\\starfish.jpg' }
npm ERR!
npm ERR! The operation was rejected by your operating system.
npm ERR! It's possible that the file was already in use (by a text editor or antivirus),
npm ERR! or that you lack permissions to access it.
npm ERR!
npm ERR! If you believe this might be a permissions issue, please double-check the
npm ERR! permissions of the file and its containing directories, or try running
npm ERR! the command again as root/Administrator (though this is not recommended).

以上问题,大概是文件被占用,结束任务,重新开始即可。

jpg 与 png 图片水印

水印代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
const Jimp = require('jimp');

// 需要添加的水印图片路径
// const ORIGINAL_IMAGE = './img/test.png';
const ORIGINAL_IMAGE = './img/test.jpg';

// 水印logo路径
const LOGO = './img/logo.png';

// 水印距离右下角百分比
const LOGO_MARGIN_PERCENTAGE = 5 / 100;

const main = async () => {
const [image, logo] = await Promise.all([
Jimp.read(ORIGINAL_IMAGE),
Jimp.read(LOGO)
]);

// 将 logo 等比缩小 10 倍
// logo.resize(inputGif.width / 10, Jimp.AUTO);

const xMargin = image.bitmap.width * LOGO_MARGIN_PERCENTAGE;
const yMargin = image.bitmap.width * LOGO_MARGIN_PERCENTAGE;

const X = image.bitmap.width - logo.bitmap.width - xMargin;
const Y = image.bitmap.height - logo.bitmap.height - yMargin;

return image.composite(logo, X, Y, [
{
mode: Jimp.BLEND_SOURCE_OVER,
opacitySource: 0.1,
opacityDest: 1
}
]);
};

main().then(image => {
const FILENAME = 'new_name.' + image.getExtension();

// 压缩图片 0-100
image.quality(80);

return image.write(FILENAME, (err) => {
if (err) {
return console.error(err);
};
console.log('水印成功:', FILENAME);
});
});

gif 图片水印

安装 gifwrap

1
npm install gifwrap --save-dev

水印代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
const Jimp = require('jimp');
const { GifUtil } = require('gifwrap');
const trueTo256 = require('./trueTo256');

// 需要添加的水印图片路径
const ORIGINAL_IMAGE = './img/test.gif';

// 水印logo路径
const LOGO = './img/logo.png';

// 水印距离右下角百分比
const LOGO_MARGIN_PERCENTAGE = 5 / 100;

async function main () {
const logo = await Jimp.read(LOGO);

return GifUtil.read(ORIGINAL_IMAGE).then(inputGif => {
// 将 logo 等比缩小 10 倍
// logo.resize(inputGif.width / 10, Jimp.AUTO);

const xMargin = inputGif.width * LOGO_MARGIN_PERCENTAGE;
const yMargin = inputGif.height * LOGO_MARGIN_PERCENTAGE;

const X = inputGif.width - logo.bitmap.width - xMargin;
const Y = inputGif.height - logo.bitmap.height - yMargin;

// 给每一帧都打上水印
inputGif.frames.forEach((frame, i) => {
// 只为第一帧添加水印,可能会出现水印被覆盖问题
/* if (i !== 0) {
return;
} */
const jimpCopied = GifUtil.copyAsJimp(Jimp, frame);

// 计算获得的坐标再减去每一帧偏移位置,为实际添加水印坐标
jimpCopied.composite(logo, X - frame.xOffset, Y - frame.yOffset, [{
mode: Jimp.BLEND_SOURCE_OVER,
opacitySource: 0.1,
opacityDest: 1
}]);

// 压缩图片 0-100
jimpCopied.quality(80);

frame.bitmap = jimpCopied.bitmap;

// 输出每一帧图片
// jimpCopied.write(`${i}.png`);

// 真彩色转 256 色
frame.bitmap = trueTo256(frame.bitmap);
});

// 不使用 trueTo256 也可以使用自带的 quantizeWu 进行颜色转换,不过自带的算法运行需要更多的时间,没有 trueTo256 快
// GifUtil.quantizeWu(inputGif.frames);

return inputGif;
});
}

main().then(inputGif => {
// Pass inputGif to write() to preserve the original GIF's specs.
const FILENAME = 'new_name.gif';
return GifUtil.write(FILENAME, inputGif.frames, inputGif).then(outputGif => {
console.log('水印成功:', FILENAME);
}).catch((err) => {
if (err) {
return console.error('水印失败:', err);
}
});
});

真彩色转 256 色 算法

上面代码中的 trueTo256.js 为 真彩色转 256 色 算法,这部分代码参考了大佬写的 Java 算法(用流行色算法实现转换): https://www.jianshu.com/p/9188b4639a83

大致的算法如下:

  1. 准备一个长度为4096的数组,代表4096种颜色。对图中的每一个像素,取R,G,B的最高四位,拼成一个12位的整数,对应的数组元素加1。
  2. 全部统计完后,就得到了这4096种颜色的使用频率。这其中,可能有一些颜色一次也没用到,即对应的数组元素为零。将这些为零的数组元素清除出去。
  3. 将这剩余的数按从大到小的顺序排列,这样,前256种颜色就是用的最多的颜色,它们将作为调色板上的256种颜色。
  4. 对于剩下的颜色并不是简单的丢弃,而是用前面的256种颜色中的一种来代替,代替的原则是找有最小平方误差的那个。

源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
function colorTransfer(rgb) {
var r = (rgb & 0x0F00000) >> 12;
var g = (rgb & 0x000F000) >> 8;
var b = (rgb & 0x00000F0) >> 4;
return (r | g | b);
};

function colorRevert(rgb) {
var r = (rgb & 0x0F00) << 12;
var g = (rgb & 0x000F0) << 8;
var b = (rgb & 0x00000F) << 4;
return (r | g | b);
}

function getDouble(a, b) {
var red = ((a & 0x0F00) >> 8) - ((b & 0x0F00) >> 8);
var grn = ((a & 0x00F0) >> 4) - ((b & 0x00F0) >> 4);
var blu = (a & 0x000F) - (b & 0x000F);
return red * red + blu * blu + grn * grn;
}

function getSimulatorColor(rgb, rgbs, m) {
var r = 0;
var lest = getDouble(rgb, rgbs[r]);
for (var i = 1; i < m; i++) {
var d2 = getDouble(rgb, rgbs[i]);
if (lest > d2) {
lest = d2;
r = i;
}
}
return rgbs[r];
}

function transferTo256(rgbs) {
var n = 4096;
var m = 256;
var colorV = new Array(n);
var colorIndex = new Array(n);

//初始化
for (var i = 0; i < n; i++) {
colorV[i] = 0;
colorIndex[i] = i;
}

//颜色转换
for (var x = 0; x < rgbs.length; x++) {
for (var y = 0; y < rgbs[x].length; y++) {
rgbs[x][y] = colorTransfer(rgbs[x][y]);
colorV[rgbs[x][y]]++;
}
}

//出现频率排序
var exchange;
var r;
for (var i = 0; i < n; i++) {
exchange = false;
for (var j = n - 2; j >= i; j--) {
if (colorV[colorIndex[j + 1]] > colorV[colorIndex[j]]) {
r = colorIndex[j];
colorIndex[j] = colorIndex[j + 1];
colorIndex[j + 1] = r;
exchange = true;
}
}
if (!exchange) break;
}

//颜色排序位置
for (var i = 0; i < n; i++) {
colorV[colorIndex[i]] = i;
}

for (var x = 0; x < rgbs.length; x++) {
for (var y = 0; y < rgbs[x].length; y++) {
if (colorV[rgbs[x][y]] >= m) {
rgbs[x][y] = colorRevert(getSimulatorColor(rgbs[x][y], colorIndex, m));
} else {
rgbs[x][y] = colorRevert(rgbs[x][y]);
}
}
}
return rgbs;
}

// 获取 rgba int 值
function getRgbaInt(bitmap, x, y) {
const bi = (y * bitmap.width + x) * 4;
return bitmap.data.readUInt32BE(bi, true);
}

// 设置 rgba int 值
function setRgbaInt(bitmap, x, y, rgbaInt) {
const bi = (y * bitmap.width + x) * 4;
return bitmap.data.writeUInt32BE(rgbaInt, bi);
}

// int 值转为 rgba
function intToRGBA (i) {
let rgba = {};

rgba.r = Math.floor(i / Math.pow(256, 3));
rgba.g = Math.floor((i - rgba.r * Math.pow(256, 3)) / Math.pow(256, 2));
rgba.b = Math.floor(
(i - rgba.r * Math.pow(256, 3) - rgba.g * Math.pow(256, 2)) /
Math.pow(256, 1)
);
rgba.a = Math.floor(
(i -
rgba.r * Math.pow(256, 3) -
rgba.g * Math.pow(256, 2) -
rgba.b * Math.pow(256, 1)) /
Math.pow(256, 0)
);
return rgba;
};

// rgba int 转为 rgb int
function rgbaIntToRgbInt (i) {
const r = Math.floor(i / Math.pow(256, 3));
const g = Math.floor((i - r * Math.pow(256, 3)) / Math.pow(256, 2));
const b = Math.floor(
(i - r * Math.pow(256, 3) - g * Math.pow(256, 2)) /
Math.pow(256, 1)
);

return r * Math.pow(256, 2) +
g * Math.pow(256, 1) +
b * Math.pow(256, 0);
};

// rgb int 转为 rgba int
function rgbIntToRgbaInt (i, a) {
const r = Math.floor(i / Math.pow(256, 2));
const g = Math.floor((i - r * Math.pow(256, 2)) / Math.pow(256, 1));
const b = Math.floor(
(i - r * Math.pow(256, 2) - g * Math.pow(256, 1)) /
Math.pow(256, 0)
);
return r * Math.pow(256, 3) +
g * Math.pow(256, 2) +
b * Math.pow(256, 1) +
a * Math.pow(256, 0);
};

/**
* @interface Bitmap { data: Buffer; width: number; height: number;}
* @param {Bitmap} bitmap
*/
module.exports = function (bitmap) {
const width = bitmap.width;
const height = bitmap.height;

let rgbs = new Array();
let alphas = new Array();

for (let x = 0; x < width; x++) {
rgbs[x] = rgbs[x] || [];
alphas[x] = alphas[x] || [];
for (let y = 0; y < height; y++) {
// 由于真彩色转 256色 算法是使用 int rgb 计算,所以需要把获取到的 int rgba 转为 int rgb
const rgbaInt = getRgbaInt(bitmap, x, y);
rgbs[x][y] = rgbaIntToRgbInt(rgbaInt);
alphas[x][y] = intToRGBA(rgbaInt).a;
}
}

// 颜色转换
const color = transferTo256(rgbs);

for (let x = 0; x < width; x++) {
for (let y = 0; y < height; y++) {
// 写入转换后的颜色
setRgbaInt(bitmap, x, y, rgbIntToRgbaInt(color[x][y], alphas[x][y]));
}
}

return bitmap;
};

水印效果

png 原图:

水印图:

jpg 原图:

水印图:

gif 原图:

水印图:

完整源码

下载

文件说明:

1
2
3
4
5
6
/watermark
--/img -------- 一些用于测试的图片
--/trueTo256.js -------- nodejs 真彩色转 256 色算法
--/png.js -------- jpg 与 png 图片水印
--/gif.js -------- gif 图片水印
--/package.json -------- package

(文章完)

本文由 linx(544819896@qq.com) 创作,采用 CC BY 4.0 CN协议 进行许可。 可自由转载、引用,但需署名作者且注明文章出处。本文链接为: https://blog.jijian.link/2020-04-17/nodejs-watermark/

如果您觉得文章不错,可以点击文章中的广告支持一下!