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

本文折腾 hexo 图片添加水印功能,大部分代码沿用: nodejs 图片添加水印(png, jpeg, jpg, gif)

方案一

使用现有插件:https://github.com/SpiritLing/hexo-images-watermark

该插件依赖 sharp :https://github.com/lovell/sharp 对于我这种安装困难户来说,难搞哦。

安装问题可以参考作者写的安装教程:https://github.com/SpiritLing/hexo-images-watermark/issues/2

方案二

使用 jimp 造个轮子

本文仅处理图片水印,文字水印请参考后文介绍

步骤

  1. 首先安装依赖

    1
    npm install jimp gifwrap --save
  2. 新建文件 themes/landscape/scripts/image_watermark.js

    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
    const { deepMerge } = require('hexo-util');
    const watermark = require('../../../component/watermark/index');

    const defaultOptions = {
    // 保存的图片质量
    quality: 80,
    // 图片宽度小于 100 时不加水印
    minWidth: 100,
    // 图片高度小于 100 时不加水印
    minHeight: 100,
    // 旋转
    rotate: 0,
    // 水印 logo 图片
    logo: '',

    // 需要添加的图片类型
    include: ['*.jpg', '*.jpeg', '*.png', '*.gif'],
    // 文件名为 .watermark.png 禁止添加水印图片
    exclude: ['*.watermark.*'],
    // 文章链接,非文章链接不加水印
    articlePath: /^\d{4}-\d{2}-\d{2}/,
    };

    hexo.config.watermark = deepMerge(defaultOptions, hexo.config.watermark);
    hexo.extend.filter.register('after_generate', watermark);
  3. 新建文件 component/watermark/index.js

    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
    const fs = require('fs');
    const { isMatch } = require('micromatch');
    const { extname } = require('path');
    const Promise = require('bluebird');
    const { img, gif } = require('./watermark');

    const getBuffer = (hexo, path) => {
    return new Promise((resolve) => {
    const stream = hexo.route.get(path);
    const arr = [];
    stream.on('data', chunk => arr.push(chunk));
    stream.on('end', () => resolve(Buffer.concat(arr)));
    });
    }

    const getExtname = str => {
    if (typeof str !== 'string') return '';

    const ext = extname(str) || str;
    return ext[0] === '.' ? ext.slice(1) : ext;
    };

    module.exports = function () {
    const hexo = this;
    const config = hexo.config.watermark;

    if (!fs.existsSync(config.logo)) {
    // 带颜色的输出: https://www.jianshu.com/p/cca3e72c3ba7
    return console.log('\033[41;30m ERROR \033[40;31m Add watermark no logo image found \033[0m');
    }

    const route = hexo.route;

    const { include, exclude, articlePath } = config;

    // exclude image
    const routes = route.list().filter((path) => {
    // 如果文件没修改,则不再加水印
    if (!route.isModified(path)) {
    return false;
    }
    if (!articlePath.test(path)) {
    return false;
    }
    if (isMatch(path, exclude, { basename: true })) {
    return false;
    }
    return isMatch(path, include, {
    basename: true
    });
    });
    // 用 Promise 延迟执行,否则 build 命令水印在图片生成前执行会被覆盖
    return Promise.map(routes, async (path) => {
    const ext = getExtname(path);
    const buffer = await getBuffer(hexo, path);
    const arg = {
    input: buffer,
    logo: config.logo,
    quality: config.quality,
    rotate: config.rotate,
    minWidth: config.minWidth,
    minHeight: config.minHeight,
    };
    const newBuffer = ext === 'gif' ? await gif(arg) : await img(arg);
    if (!newBuffer) {
    return;
    }
    route.set(path, newBuffer);
    });
    }
  4. 新建文件 component/watermark/watermark.js

    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
    const Jimp = require('jimp');
    const { GifUtil, GifCodec } = require('gifwrap');
    const trueTo256 = require('./trueTo256');

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

    function getXY (img, logoImage) {
    // 如果logo小于图片 8/10 ,取 img.width * (8 / 10) 与图片宽度的最小值缩放
    logoImage.resize(Math.min(logoImage.bitmap.width, img.width * (8 / 10)), Jimp.AUTO);

    const margin = Math.min(img.width * LOGO_MARGIN_PERCENTAGE, img.height * LOGO_MARGIN_PERCENTAGE, 20);

    const X = img.width - logoImage.bitmap.width - margin;
    const Y = img.height - logoImage.bitmap.height - margin;

    return {
    X,
    Y,
    };
    }

    async function gif({
    input = '',
    logo = '',
    quality = 80,
    rotate = 0,
    } = {}) {
    const inputGif = await GifUtil.read(input);
    const logoImage = await Jimp.read(logo);

    logoImage.rotate(rotate);

    const { X, Y } = getXY({
    width: inputGif.width,
    height: inputGif.height,
    }, logoImage);

    // 给每一帧都打上水印
    inputGif.frames.forEach((frame, i) => {
    const jimpCopied = GifUtil.copyAsJimp(Jimp, frame);

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

    // 压缩图片
    jimpCopied.quality(quality);

    frame.bitmap = jimpCopied.bitmap;

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

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

    const codec = new GifCodec();
    return (await codec.encodeGif(inputGif.frames)).buffer;
    };

    async function img({
    input = '',
    logo = '',
    quality = 80,
    rotate = 0,
    minWidth = 0,
    minHeight = 0,
    } = {}) {
    const image = await Jimp.read(input);

    if (image.getWidth() < minWidth || image.getHeight() < minHeight) {
    return;
    }

    const logoImage = await Jimp.read(logo);

    logoImage.rotate(rotate);

    const { X, Y } = getXY({
    width: image.getWidth(),
    height: image.getHeight(),
    }, logoImage);

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

    // 压缩图片
    image.quality(quality);

    return await image.getBufferAsync(Jimp.AUTO);
    };

    module.exports = {
    gif,
    img
    };
  5. 新建文件 component/watermark/trueTo256.js

    这部分算法借鉴了大佬写的 Java 算法: https://www.jianshu.com/p/9188b4639a83
    详细说明参考 nodejs 图片添加水印(png, jpeg, jpg, gif)

    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
    182
    183
    184
    185
    186
    /**
    * 真彩色转 256 色
    * https://www.jianshu.com/p/9188b4639a83
    */

    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;
    };
  6. 添加配置 _config.yml

    1
    2
    3
    4
    # 水印
    watermark:
    # 此处需要改成你的 logo 文件地址
    logo: ./component/watermark/logo.png
  7. 重新运行项目即可。

    效果参考本站任意有图文章即可。

文字水印

  1. 使用 jimp.loadFont 绘制文字水印。

    问题:不能设置文字颜色大小等样式。

  2. 参考 hexo-images-watermark 方案,逻辑是先用 text-to-svg 将文本转为 svg ,在用 svg2png 将 svg 转为 png 图片获得 buffer 数据,再拿 buffer 绘制水印。

    问题:安装困难,svg2png 需要用到 PhantomJS

  3. 其他文字转图片的方案也有各自安装问题,比如使用 node-canvas 转换文字,安装 node-pre-gyp 困难。

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

如果您觉得文章不错,可以请我喝一杯咖啡!