前言
水印(Watermark)是一种能让人识别纸上图案的技术,当光线照射纸张时,纸张上会显现出各种不同阴影,这些阴影组成的图案就是水印。
水印常常起到验证货币、护照、邮票、政府文件或者其他纸制文件的真实性的作用。
随着互联网的发展,我们经常在网页或者 App 上看到一些具有独特设计的文字或者图片,并且下载后依旧会存在于其中,这种常见的标识叫做水印。
随着人们版权意识的觉醒,越来越多的人为了防止信息泄露或知识产权被侵犯,人们会在发布的文章、文件、图片、音频、视频中添加水印。
水印内容可以包含多种编码后的信息,包括用户名、用户ID、时间等。比如我们只是想保存用户唯一的用户ID,需要把用户ID 用 md5 方法加密,就可以生成唯一标识。编码后的信息是不可逆的,但可以通过全局遍历所有用户的方式进行追溯,增加敏感数据外泄的门槛,对用户起到很好威慑作用;同时水印也能够很好的证明数字产品的版权所在, 能够作为盗版维权时的有力证据。
本文重点介绍前端如何使用程序合成手段添加水印。
明水印
明水印即可见水印,在图像、视频上添加的 Logo、Id 等特定标识信息,非常容易辨识。
基于DOM实现水印效果
效果:在页面上充满透明度较低的重复的代表身份的信息。
- 重复的dom元素覆盖实现
在页面上覆盖一个 position: fixed 的 div 盒子,在这个盒子内通过 js 循环生成小的水印 div,每个水印 div 内展示一个要显示的水印内容;
样式上盒子透明度设置较低,设置 pointer-events: none 样式实现点击穿透,设置 user-select: none 让文字无法被选中。
function loadWatermark(width, height, content) {
const box = document.getElementById('watermark-box');
const boxWidth = box.clientWidth;
const boxHeight = box.clientHeight;
for (let i = 0; i < Math.floor(boxHeight / height); i++) {
for (let j = 0; j < Math.floor(boxWidth / width); j++) {
const item = document.createElement('div');
item.style.width = width + 'px';
item.style.height = height + 'px';
item.innerText = content;
item.setAttribute('class', 'watermark');
box.appendChild(item);
}
}
}
window.onload = loadWatermark(300, 100, 'watermark');
- 背景图实现
canvas 的实现很简单,主要是利用 canvas 绘制一个水印,然后将它转化为 base64 的图片,通过 canvas.toDataURL() 来拿到文件流的 url ,然后将获取的 url 填充在一个元素的背景中,然后我们设置背景图片的属性为重复。
svg 与 canvas 生成背景图的方法类似,只不过是生成背景图的方法换成了通过 svg 生成,canvas 的兼容性略好于 svg。
// canvas生成图片
function createWatermark(width, height, content) {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, width, height); // 清除指定的矩形区域,然后这块区域会变的完全透明
ctx.fillStyle = '#000';
ctx.globalAlpha = 0.2;
ctx.font = '16px serif';
const angle = -15;
ctx.rotate(Math.PI / width * angle);
ctx.fillText(content, 0, 50);
return canvas.toDataURL();
}
// svg 生成图片
function createWatermark(width, height, content) {
const svgStr = `
<svg xmlns="http://www.w3.org/2000/svg" width="${width}px" height="${height}px">
<text x="0px" y="30px" dy="16px"
text-anchor="start"
stroke="#000"
stroke-opacity="0.2"
fill="none"
transform="rotate(-15)"
font-weight="100"
font-size="16"
>
${content}
</text>
</svg>`;
// window.btoa 返回一个 base-64 编码的字符串 encodeURIComponent 将字符转换成utf-8格式的编码 unescape 解码
return `data:image/svg+xml;base64,${window.btoa(unescape(encodeURIComponent(svgStr)))}`;
}
// 创建元素设置背景图
const watermark = document.createElement('div');
watermark.className = 'watermark';
watermark.style.backgroundImage = `url(${createWatermark(180, 100, 'watermark')})`;
这种水印方法存在一个问题,由于是前端生成 dom 元素覆盖到页面上的,对于知道控制台操作的人,可以在开发者工具中找到水印所在的元素,将元素整个删掉,水印就消失了,那么有什么办法能防御住这样的操作呢?
明水印的防御
Mutation Observer API 用来监视 DOM 变动,DOM 的任何变动,比如子节点的增减、属性的变动、文本内容的变动,这个 API 都可以得到通知。
Mutation Observer 有以下特点。
- 它等待所有脚本任务完成后,才会运行(即异步触发方式)。
- 它把 DOM 变动记录封装成一个数组进行处理,而不是一条条个别处理 DOM 变动。
- 它既可以观察 DOM 的所有类型变动,也可以指定只观察某一类变动。
注意:MutationObserver 只能监测到诸如属性改变、子结点变化等,对于自己本身被删除,是没有办法监听的,但是可以通过监测父结点来达到要求。
我们要实现效果主要观察的有三点
- 水印元素本身是否被移除
- 水印元素属性是否被篡改(display: none ...)
- 水印元素的子元素是否被移除和篡改 (element 生成的方式 )
(function () {
function __canvasWM({
container = document.body,
content = 'watermark',
...
} = {}) {
const base64Url = createWatermark(content); // 生成水印图片
const __wm = document.querySelector('.__wm');
const watermarkDiv = __wm || document.createElement('div');
const styleStr = `
position:fixed;
...
background-repeat:repeat;
background-image:url('${base64Url}')`;
watermarkDiv.setAttribute('style', styleStr);
watermarkDiv.classList.add('__wm');
if (!__wm) {
container.style.position = 'relative';
container.insertBefore(watermarkDiv, container.firstChild);
}
const MutationObserver = window.MutationObserver || window.WebKitMutationObserver;
if (MutationObserver) {
let mo = new MutationObserver(function () {
const __wm = document.querySelector('.__wm');
// 只在__wm元素变动才重新调用 __canvasWM
if ((__wm && __wm.getAttribute('style') !== styleStr) || !__wm) {
// 避免一直触发 关闭监听
mo.disconnect();
mo = null;
__canvasWM(JSON.parse(JSON.stringify(args)));
}
});
mo.observe(container, {
attributes: true,
subtree: true,
childList: true
});
}
};
window.__canvasWM = __canvasWM;
})();
当然,设置了 MutationObserver 之后也只是相对安全了一些,还是可以通过控制台禁用 js、复制 dom 元素、删除 水印相关的代码等方法来跳过我们的监听,总体来说在单纯的在前端页面上加水印总是可以通过一些骚操作来跳过的,防外行不防内行。
图片加水印
有时我们需要在图片上加水印用来标示归属或者其他信息,在图片上加水印的实现思路是,图片加载成功后画到 canvas 中,随后在 canvas 中绘制水印,完成后通过 canvas.toDataUrl() 方法获得 base64 并替换原来的图片路径。
(function () {
function __picWM({
url = '',
content = 'watermark',
cb = null,
...
} = {}) {
const img = new Image();
img.src = url;
img.crossOrigin = 'anonymous';
img.onload = function () {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
ctx.fillStyle = fillStyle;
ctx.fillText(content, img.width - textX, img.height - textY);
const base64Url = canvas.toDataURL();
cb && cb(base64Url);
}
}
window.__picWM = __picWM;
})();
__picWM({
url: 'https://pics4.baidu.com/feed/e61190ef76c6a7efb6ec01e0373d0157f1de6678.jpeg?token=c330d12246bdbb2fc320a687e35e7662&s=1414ED37191276C20A7CD2FC03005027',
content: 'vx_xxx',
cb: (base64Url) => {
document.querySelector('img').src = base64Url;
},
});
暗水印
暗水印是一种肉眼不可见的水印,能够很好的证明数字产品的版权所在, 能够作为盗版维权时的有力证据。
暗水印的特性
- 隐蔽性 由于不希望被察觉、不希望干扰用户体验、不希望被模仿等原因,我们的水印不可见,也就是隐匿性。
- 不易移除性 不易移除性跟鲁棒性有些相似, 不同的是:鲁棒性更加强调的是数字资源在传播过程中不要被不自觉地干扰和破坏。不易移除性是在别有用心者察觉了暗水印的存在后,不被他们自觉地移除或者破坏。
- 强健性 强健性通常也被称作鲁棒性,一般要能抗(压缩 、裁剪、涂画、旋转);需要说明的一点是,鲁棒性和隐蔽性通常不可兼得。
- 明确性 暗水印需要表示出明确的信息。
暗水印的生成方式有很多,常见的为RGB 分量值的小量变动、离散傅里叶变换(DFT) 、离散余弦变换(DCT) 和离散小波变换(DWT)等方法。
图片加隐性水印
本文主要介绍前端实现RGB 分量值的小量变动的思路。
图片的像素信息里存储着 RGB 的色值,对于RGB 分量值的小量变动,是肉眼无法分辨的,不会影响对图片的识别,我们可以对图片的 RGB 以一种特殊规则进行小量的改动。
首先我们需要创建一个规律,通过我们的规律将图片编码生成带隐形水印的图片;现在假设这个规律为,我们将所有像素的 R 通道的值为奇数的时候就是我们创建的通道密码(遍历原图片像素,将对应水印像素有信息的像素的 R 通道值都转成奇数,对应水印像素没有信息的像素 R 通道值都转成偶数),举个简单的例子:
例如我们把它当做是一个图形,每个格子是图片的像素点,格子里的数字是像素的 R 通道的值,现在我们要一个 "Z" 字母最大比例放进图像,按照我们的算法规则,我们的图像会变成下图的样子:
解码的时候,我们拿到所有的奇数像素将它渲染出来,正好是个 "Z" 字母,我们看下代码具体实现:
// 编码过程
<canvas id="canvas" width="220" height="220"></canvas>
// 水印的通道密码规则: 将所有像素的 R 通道的值设为奇数
const originalUrl = 'https://img1.baidu.com/it/u=649078162,2200738056&fm=253&fmt=auto&app=138&f=JPEG?w=220&h=220';
function mergeData(ctx, markData, color, imgData) {
let oData = imgData.data;
for (let i = 0; i < oData.length; i++) {
let bit;
let offset; // offset的作用是找到alpha通道值
switch (color) {
case 'R':
bit = 0;
offset = 3;
break;
case 'G':
bit = 1;
offset = 2;
break;
case 'B':
bit = 2;
offset = 1;
break;
}
// 处理 R 目标通道信息
if (i % 4 === bit) {
// 没有水印信息的像素,将其对应通道的值设置为偶数
if (markData[i + offset] === 0 && (oData[i] % 2 === 1)) { // 水印像素 R 通道没有值 && 图片像素 R 通道值为奇数
if (oData[i] === 255) {
oData[i]--;
} else {
oData[i]++;
}
// 有水印信息的像素,将其对应通道的值设置为奇数
} else if (markData[i + offset] !== 0 && (oData[i] % 2 === 0)) {
oData[i]++;
}
}
}
ctx.putImageData(imgData, 0, 0);
return document.getElementById('canvas').toDataURL();
}
function getMarkImage(url) {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
ctx.font = '40px Microsoft Yahei';
ctx.fillText('watermark', 0, 80);
const markData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height).data;
const img = new Image();
img.src = url;
img.crossOrigin = 'anonymous';
img.onload = function () {
ctx.drawImage(img, 0, 0);
const imgData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
// 水印合并到图片上
const newUrl = mergeData(ctx, markData, 'R', imgData);
// 生成新的隐形图片
const invisibleMaskImg = new Image();
invisibleMaskImg.src = newUrl;
invisibleMaskImg.id = 'newImg';
document.querySelector('.img-box2').appendChild(invisibleMaskImg);
};
}
getMarkImage(originalUrl);
// 解码过程
var processData = function (ctx, color, originalData) {
let data = originalData.data;
let bit;
switch (color) {
case 'R':
bit = 0;
break;
case 'G':
bit = 1;
break;
case 'B':
bit = 2;
break;
}
for (let i = 0; i < data.length; i++) {
if (i % 4 === bit) {
// R通道
if (data[i] % 2 === 0) {
data[i] = 0;
} else {
data[i] = 255;
}
} else if (i % 4 === 3) { // alpha 通道,不用处理
continue;
} else { // G B 通道
data[i] = 0;
}
}
ctx.putImageData(originalData, 0, 0);
};
function getMarkData() {
const ctx = document.getElementById('maskCanvas').getContext('2d');
const img = new Image();
const newImg = document.getElementById('newImg').src;
img.src = newImg;
img.crossOrigin = 'anonymous';
img.onload = function () {
ctx.drawImage(img, 0, 0);
const originalData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
processData(ctx, 'R', originalData);
};
}
正常情况下看这个图片是没有水印的,但是经过对应规则(上边例子对应的解密规则是:遍历图片的像素数据中对应的 R,奇数则将其 rgba 设置为(255,0,0,0),偶数则设置为(0,0,0,0)的解密处理后就可以看到水印了。
RGB 分量值的小量变动是一种比较简单的加密方式,当用户采用截图、旋转、保存图片后转换格式等方法获得图片后,图片的色值可能是会变化的,会影响水印效果。
在实际过程需要更专业的加密方式,例如利用傅里叶变化公式,来进行频域制定数字暗水印,大家可以去深入学习下,这里就不阐述了。
总结
本文主要介绍了明暗水印比较简单的实现方式,在实际的应用场景中,我们可以通过组合使用水印的方案,这样能最大程度给浏览者警示的作用,减少泄密的情况,即使泄密了,也有可能追踪到泄密者。