前端水印相关解析
一、问题背景
为了防止信息泄露或知识产权被侵犯,在web的世界里,对于页面和图片等增加水印处理是十分有必要的,水印的添加根据环境可以分为两大类,前端浏览器环境添加和后端服务环境添加,根据可见性,网页水印可以分为可见水印和不可见水印(盲水印/隐水印),简单对比一下这两种方式的特点:
前端浏览器加水印:
-
减轻服务端的压力,快速反应
-
安全系数较低,对于掌握一定前端知识的人来说可以通过各种骚操作跳过水印获取到源文件
-
适用场景:资源不跟某一个单独的用户绑定,而是一份资源,多个用户查看,需要在每一个用户查看的时候添加用户特有的水印,多用于某些机密文档或者展示机密信息的页面,水印的目的在于文档外流的时候可以追究到责任人
后端服务器加水印:
-
当遇到大文件密集水印,或是复杂水印,占用服务器内存、运算量,请求时间过长
-
安全性高,无法获取到加水印前的源文件
-
适用场景:资源为某个用户独有,一份原始资源只需要做一次处理,将其存储之后就无需再次处理,水印的目的在于标示资源的归属人 这里我们讨论前端浏览器环境添加
二、实现方案
1、可见性水印
1.1 “最简单“的水印
一种比较常见的简单水印场景是给文章、表格加上 logo 水印,用以申明版权。
这里想要的效果就是一个浅浅的 logo 平铺展示。实现起来也比较简单,只需制作一个半透明的 logo 图片,设为文章或者表格的背景图片即可。仅需一行 CSS 声明。
background-image:url("./logo.png");
实现图片平铺关键的 CSS 属性是 background-repeat,值为 repeat 时是平铺,这也是它的默认值,所以可以省略。
1.2 全页面水印
照葫芦画瓢,如果要给整个 Web 页面加上水印,是不是给页面的 body 元素设置背景图片平铺展示就可以了呢?
然而通常并不会这么处理,因为文章和表格内容多以文本为主,不会明显遮挡水印,而一个完整的页面往往还包含很多其他页面元素,比如图片、视频、控件等等,它们很可能会遮挡住背景图片,从而影响水印效果。
所以,为了避免被其他元素遮挡,针对页面的水印一般会使用一个层级比较高且覆盖整个页面的元素来承载。
div.watermark{ position: fixed; left:0; top:0; width: 100vw; height: 100vh; background-image:url("./logo.png"); opacity: .5; z-index: 3000; }
这样一来,其他元素就遮挡不住水印了。不过,这个 div 反过来可能会遮挡页面其他元素,影响页面元素操作。还需要一条关键的 CSS 声明来破解这个问题 :
pointer-events: none;
这个 CSS 声明会使该元素“可穿透”,“看得见、摸不着”,不再影响页面操作。
1.3 动态水印
很多时候,给页面加水印的目的并不是申明版权,而是为了支持溯源。此时水印的内容并不会只是一个 logo,通常会包含用户信息,比如用户名、UID、手机号等等。
这就意味着,每个用户的水印内容是不同的,无法通过提前准备好一张图片来满足了。这种场景往往需要根据用户信息动态生成图片。
我们来看下几种主流的动态生成水印图片的方式:
1.3.1 服务端方案
传统的方式是在服务端生成图片。页面上发起的图片请求中可以附带用户信息,服务端根据这些参数动态生成图片,并将图片数据作为该请求的响应返给页面,页面拿到后将其用作水印。
这种方式的优点是兼容性好,缺点是需要前后端配合,增加了页面请求和服务端资源开销,防攻击能力也较差。
1.3.2 重复的dom元素覆盖实现
从效果开始,要实现的效果是「在页面上充满透明度较低的重复的代表身份的信息」,第一时间想到的方案是在页面上覆盖一个position:fixed的div盒子,盒子透明度设置较低,设置pointer-events: none;样式实现点击穿透,在这个盒子内通过js循环生成小的水印div,每个水印div内展示一个要显示的水印内容,简单实现了一下
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title></title> <style> #watermark-box { position: fixed; top: 0; bottom: 0; left: 0; right: 0; font-size: 24px; font-weight: 700; display: flex; flex-wrap: wrap; overflow: hidden; user-select: none; pointer-events: none; opacity: 0.1; z-index: 999; } .watermark { text-align: center; } </style> </head> <body> <div> <h2> 机密内容- 机密内容- 机密内容- 机密内容- 机密内容- 机密内容- </h2> <br /> <h2> 机密内容- 机密内容- 机密内容- 机密内容- 机密内容- 机密内容- </h2> <br /> <h2 onclick="alert(1)"> 机密内容- 机密内容- 机密内容- 机密内容- 机密内容- 机密内容- 机密内容- </h2> <br /> </div> <div id="watermark-box"> </div> <script> function doWaterMark(width, height, content) { let box = document.getElementById("watermark-box"); let boxWidth = box.clientWidth, boxHeight = box.clientHeight; for (let i = 0; i < Math.floor(boxHeight / height); i++) { for (let j = 0; j < Math.floor(boxWidth / width); j++) { let next = document.createElement("div") next.setAttribute("class", "watermark") next.style.width = width + 'px' next.style.height = height + 'px' next.innerText = content box.appendChild(next) } } } window.onload = doWaterMark(300, 100, '水印123') </script> </body> </html>
页面效果是有了,但是这种方案需要要在js内循环创建多个dom元素,既不优雅也影响性能,于是考虑可不可以不生成这么多个元素。
1.3.3 Canvas输出背景图
HTML5 引入 Canvas 特性使得浏览器自身具备了绘图能力。经过多年的发展,主流浏览器基本都已可以提供良好的支持。通过 Canvas 可以轻松绘制图片,并可将图片数据导出,用于页面图片或背景。
const canvasElement = document.createElement('canvas'); const context = canvasElement.getContext('2d'); canvasElement.width = 200; canvasElement.height = 200; context.rotate((-30 * Math.PI) / 180); context.font = '400 26px Arial'; context.fillStyle = '#B9C0CA'; context.textAlign = 'center'; context.textBaseline = 'middle'; context.fillText('水印文字', 70, 130); const watermark = canvasElement.toDataURL('image/png');
通过上述示例代码可拿到水印图片的 data URI 数据,用作水印承载元素的背景图片平铺展示即可。
这种方式不需要服务端配合,在前端就可以完成,且有助于减少请求和服务端资源开销。曾经面临的浏览器兼容问题现在也不再是问题,该方案已逐渐流行起来。
第一步还是在页面上覆盖一个固定定位的盒子,然后创建一个canvas画布,绘制出一个水印区域,将这个水印通过toDataURL方法输出为一个图片,将这个图片设置为盒子的背景图,通过backgroud-repeat:repeat;样式实现填满整个屏幕的效果,简单实现的代码。
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title></title> </head> <body> <div id="info" onclick="alert(1)" > 123 </div> <script> (function () { function __canvasWM({ container = document.body, width = '300px', height = '200px', textAlign = 'center', textBaseline = 'middle', font = "20px Microsoft Yahei", fillStyle = 'rgba(184, 184, 184, 0.6)', content = '水印', rotate = '45', zIndex = 10000 } = {}) { const args = arguments[0]; const canvas = document.createElement('canvas'); canvas.setAttribute('width', width); canvas.setAttribute('height', height); const ctx = canvas.getContext("2d"); ctx.textAlign = textAlign; ctx.textBaseline = textBaseline; ctx.font = font; ctx.fillStyle = fillStyle; ctx.rotate(Math.PI / 180 * rotate); ctx.fillText(content, parseFloat(width) / 2, parseFloat(height) / 2); const base64Url = canvas.toDataURL(); const __wm = document.querySelector('.__wm'); const watermarkDiv = __wm || document.createElement("div"); const styleStr = ` position:fixed; top:0; left:0; bottom:0; right:0; width:100%; height:100%; z-index:${zIndex}; pointer-events:none; background-repeat:repeat; background-image:url('${base64Url}')`; watermarkDiv.setAttribute('style', styleStr); watermarkDiv.classList.add('__wm'); if (!__wm) { container.insertBefore(watermarkDiv, container.firstChild); } if (typeof module != 'undefined' && module.exports) { //CMD module.exports = __canvasWM; } else if (typeof define == 'function' && define.amd) { // AMD define(function () { return __canvasWM; }); } else { window.__canvasWM = __canvasWM; } })(); // 调用 __canvasWM({ content: '水印123' }); </script> </body> </html>
1.3.4 SVG 方案
对于纯文字的水印来说,有没有办法不生成图片而直接实现平铺呢?
动态创建大量 DOM 节点,通过 CSS 控制排列当然可以实现,但是繁琐且性能差,优雅更无从谈起。
不妨换个角度思考,有没有办法让文字不转成图片就可以用作 background-image 属性的值呢?这样就可以利用 background-repeat 实现平铺效果了。
这时候可以考虑使用 SVG,因为 SVG 具有文本和图像的双重特性。看上去是文本,然而在很多场景可以当做图片使用。
我们可以通过 SVG 的相关属性精准控制字体位置、大小、颜色、透明度和旋转角度等参数。如:
<svg width="200" height="200" xmlns="http://www.w3.org/2000/svg"><text x="50%" y="50%" font-size="30" fill="#a2a9b6" fill-opacity="0.3" font-family="system-ui, sans-serif" text-anchor="middle" dominant-baseline="middle" transform='rotate(-45, 100 100)'>水印文字</text></svg>
考虑到浏览器兼容问题,用作背景图片时,建议将 SVG 编码为 Base64(或转义特定字符):
background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48dGV4dCB4PSI1MCUiIHk9IjUwJSIgZm9udC1zaXplPSIzMCIgZmlsbD0iI2EyYTliNiIgZmlsbC1vcGFjaXR5PSIwLjMiIGZvbnQtZmFtaWx5PSJzeXN0ZW0tdWksIHNhbnMtc2VyaWYiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGRvbWluYW50LWJhc2VsaW5lPSJtaWRkbGUiIHRyYW5zZm9ybT0ncm90YXRlKC00NSwgMTAwIDEwMCknPuawtOWNsOaWh+WtlzwvdGV4dD48L3N2Zz4=");
svg实现背景图
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title></title> </head> <body> <div id="info" onclick="alert(1)"> 123 </div> <script> (function () { function __canvasWM({ container = document.body, width = '300px', height = '200px', textAlign = 'center', textBaseline = 'middle', font = "20px Microsoft Yahei", fillStyle = 'rgba(184, 184, 184, 0.6)', content = '水印', rotate = '45', zIndex = 10000, opacity = 0.3 } = {}) { const args = arguments[0]; const svgStr = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${width}"> <text x="50%" y="50%" dy="12px" text-anchor="middle" stroke="#000000" stroke-width="1" stroke-opacity="${opacity}" fill="none" transform="rotate(-45, 120 120)" style="font-size: ${font};"> ${content} </text> </svg>`; const base64Url = `data:image/svg+xml;base64,${window.btoa(unescape(encodeURIComponent(svgStr)))}`; const __wm = document.querySelector('.__wm'); const watermarkDiv = __wm || document.createElement("div"); const styleStr = ` position:fixed; top:0; left:0; bottom:0; right:0; width:100%; height:100%; z-index:${zIndex}; pointer-events:none; 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); } if (typeof module != 'undefined' && module.exports) { //CMD module.exports = __canvasWM; } else if (typeof define == 'function' && define.amd) { // AMD define(function () { return __canvasWM; }); } else { window.__canvasWM = __canvasWM; } })(); // 调用 __canvasWM({ content: '水印123' }); </script> </body> </html>
2、不可见水印
不可见水印也叫盲水印、隐水印,顾名思义是一种看不到的水印,看不到还要它做什么呢?其实,不可见水印在一些对安全性要求较高的场景意义还是蛮大的。不可见水印通常具有比可见水印更好的隐蔽性和鲁棒性(抗攻击性)。虽不可见,但通过一定的技术手段是可以将水印信息从其载体上提取出来的,这就使得其载体具备了溯源能力,在关键时刻往往能发挥大作用。
2.1 优势
我总结不可见水印相对可见水印至少有以下三个明显的优势:
-
更好的观感。可见水印总给人一种“膏药感”,部分人甚至会感到不适(不适程度主要与强迫症程度有关🐶),而不可见水印则不会有这个问题。
-
更佳的隐蔽性。用户基本感知不到水印的存在。
-
更强的抗攻击性。可见水印更容易受到攻击,而不可见水印除了隐蔽性比较强之外,其自身往往还具备比较强的抗攻击性。
不可见水印(盲水印)属于信息隐匿技术(也叫隐写术),历史悠久,手段繁多。在现代,随着计算机网络技术的发展,数字产品的信息安全和版权保护也已成为信息隐匿技术的一个重要课题。隐写术在数字音频、数字视频和数字图像领域有着非常广泛的应用。
2.2 图片的隐性水印
对于图片资源来说,显性水印会破坏图片的完整性,有些情况下我们想要在保留图片原本样式,这时可以添加隐藏水印。
简单实现思路是:图片的像素信息里存储着 RGB 的色值,对于RGB 分量值的小量变动,是肉眼无法分辨的,不会影响对图片的识别,我们可以对图片的RGB以一种特殊规则进行小量的改动。
通过canvas.getImageData()可以获取到图片的像素数据,首先在canvas中绘制出水印图,获取到其像素数据,然后通过canvas获取到原图片的像素数据,选定R、G、B其中一个如G,遍历原图片像素,将对应水印像素有信息的像素的G都转成奇数,对应水印像素没有信息的像素都转成偶数,处理完后转成base64并替换到页面上,这时隐形水印就加好了,正常情况下看这个图片是没有水印的,但是经过对应规则(上边例子对应的解密规则是:遍历图片的像素数据中对应的G,奇数则将其rgba设置为0,255,0,偶数则设置为0,0,0)的解密处理后就可以看到水印了。
这种方式下,当用户采用截图、保存图片后转换格式等方法获得图片后,图片的色值可能是会变化的,会影响水印效果 加水印代码实现:
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title></title> </head> <body> <canvas id="canvasText" width="256" height="256"></canvas> <canvas id="canvas" width="256" height="256"></canvas> <script> var ctx = document.getElementById('canvas').getContext('2d'); var ctxText = document.getElementById('canvasText').getContext('2d'); var textData; ctxText.font = '30px Microsoft Yahei'; ctxText.fillText('水印', 60, 130); textData = ctxText.getImageData(0, 0, ctxText.canvas.width, ctxText.canvas.height).data; var img = new Image(); var originalData; img.onload = function() { ctx.drawImage(img, 0, 0); // 获取指定区域的canvas像素信息 originalData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height); console.log(originalData); mergeData(textData,'G') console.log(document.getElementById('canvas').toDataURL()) }; img.src = './aa.jpeg'; var mergeData = function(newData, color){ var oData = originalData.data; var bit, offset; switch(color){ case 'R': bit = 0; offset = 3; break; case 'G': bit = 1; offset = 2; break; case 'B': bit = 2; offset = 1; break; } for(var i = 0; i < oData.length; i++){ if(i % 4 == bit){ // 只处理目标通道 if(newData[i + offset] === 0 && (oData[i] % 2 === 1)){ // 没有水印信息的像素,将其对应通道的值设置为偶数 if(oData[i] === 255){ oData[i]--; } else { oData[i]++; } } else if (newData[i + offset] !== 0 && (oData[i] % 2 === 0)){ // 有水印信息的像素,将其对应通道的值设置为奇数 if(oData[i] === 255){ oData[i]--; } else { oData[i]++; } } } } ctx.putImageData(originalData, 0, 0); } </script> </body> </html>
显示水印代码实现:
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title></title> </head> <body> <canvas id="canvas" width="256" height="256"></canvas> <script> var ctx = document.getElementById('canvas').getContext('2d'); var img = new Image(); var originalData; img.onload = function() { ctx.drawImage(img, 0, 0); // 获取指定区域的canvas像素信息 originalData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height); console.log(originalData); processData(originalData) }; img.src = './a.jpg'; var processData = function(originalData){ var data = originalData.data; for(var i = 0; i < data.length; i++){ if(i % 4 == 1){ if(data[i] % 2 === 0){ data[i] = 0; } else { data[i] = 255; } } else if(i % 4 === 3){ // alpha通道不做处理 continue; } else { // 关闭其他分量,不关闭也不影响答案,甚至更美观 o(^▽^)o data[i] = 0; } } // 将结果绘制到画布 ctx.putImageData(originalData, 0, 0); } </script> </body> </html>
更多参考:https://juejin.cn/post/7064201037321601032
三、水印安全
水印是用来保护信息安全的。信息要安全,首先要确保水印自身的安全,提高水印的防攻击(篡改、删除等)能力。
可见水印大都是基于 DOM 的,找到这个 DOM 节点,通过浏览器插件、抓包工具等在页面上注入一段 JavaScript 或者 CSS 代码对其进行篡改或删除并不困难。
可以考虑“监听”这种行为,一旦发生就马上修复,比如重新插入一个。那怎么实现这种“监听”呢?现代浏览器中有多种观察者(Observer),比如IntersectionObserver、PerformanceObserver、ResizeObserver、ReportingObserver、MutationObserver 等。其中,MutationObserver 就可以用来监听 DOM 变动(变动观察器,字面上就可以理解这是用来观察节点变化的。Mutation Observer API 用来监视 DOM 变动,DOM 的任何变动,比如子节点的增减、属性的变动、文本内容的变动,这个 API 都可以得到通知。)
所以可以使用 MutationObserver API 来监听水印元素 DOM 变化,一旦监听到 DOM 元素被修改或者删除,就立即重新插入一个。
但是MutationObserver只能监测到诸如属性改变、子结点变化等,对于自己本身被删除,是没有办法监听的,这里可以通过监测父结点来达到要求。监测代码的实现:
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 }) } }
整体代码
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title></title> </head> <body> <div id="info" onclick="alert(1)"> 123 </div> <script> (function () { function __canvasWM({ container = document.body, width = '300px', height = '200px', textAlign = 'center', textBaseline = 'middle', font = "20px Microsoft Yahei", fillStyle = 'rgba(184, 184, 184, 0.6)', content = '水印', rotate = '45', zIndex = 10000 } = {}) { const args = arguments[0]; const canvas = document.createElement('canvas'); canvas.setAttribute('width', width); canvas.setAttribute('height', height); const ctx = canvas.getContext("2d"); ctx.textAlign = textAlign; ctx.textBaseline = textBaseline; ctx.font = font; ctx.fillStyle = fillStyle; ctx.rotate(Math.PI / 180 * rotate); ctx.fillText(content, parseFloat(width) / 2, parseFloat(height) / 2); const base64Url = canvas.toDataURL(); const __wm = document.querySelector('.__wm'); const watermarkDiv = __wm || document.createElement("div"); const styleStr = ` position:fixed; top:0; left:0; bottom:0; right:0; width:100%; height:100%; z-index:${zIndex}; pointer-events:none; 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 }) } } if (typeof module != 'undefined' && module.exports) { //CMD module.exports = __canvasWM; } else if (typeof define == 'function' && define.amd) { // AMD define(function () { return __canvasWM; }); } else { window.__canvasWM = __canvasWM; } })(); // 调用 __canvasWM({ content: '水印123' }); </script> </body> </html>
参考