背景
在古茗日常业务中,经常会给加盟商下发各种资料,例如:奶茶的配方、设备的清洗、卫生的标准等等等。这些资料都是一些内部资料,从信息安全维度不能被泄露和盗取出去。所以会给下发的资料加上水印。这些资料可能是纯文本,也可能是文本加图片的。因此,我们要做好以下两个方面:
- 通过对页面增加水印,可以从系统级别防止别人盗取我们的页面信息
- 通过对单独的图片加水印 - 防止图片保存时没有水印
页面水印
方案设计
实现页面水印的方式有很多,可以看一些常用页面加水印的方案,具体如下:
- 方案一:fixed 定位的 div 元素,重复渲染 div 元素来添加水印。会创建很多无关的 DOM 元素
- 方案二:fixed 定位 canvas 元素,重复填充水印。始终会创建一个无关的 canvas 元素
- 方案三:canvas + 伪类。不会创建无关元素,且兼容性好
- 方案四:svg + 伪类。不会创建无关元素,但兼容性略差于 canvas
这些方案,都有一个通用的缺点,那就是将元素删掉,或者将类名删掉,都能去除页面水印。
基于实现成本和安全性维度的考虑,最终方案选型:方案三 ,同时增加了通过MutationObserver - Web API 接口参考 | MDN 解决了删除类名导致水印删除的问题。
核心功能点:
- 把签名信息,通过 Canvas 生成背景图
- 利用伪类将背景图添加到需要生成水印的区域上
- 通过 MutationObserver , 解决了删除类名导致水印删除的问题
代码实现
把签名信息,通过Canvas生成背景图
- 利用 Canvas 来绘制背景图,背景内容为水印的内容
- 通过 toDataURL 将 Canvas 转换成图片,格式为 image/png
js
interface IImgOptions {
content: string[]; // 水印的内容,可传递多个水印
canvasHeight: number; // 画布的高度
canvasWidth: number; // 画布的宽度
}
const createImgBase = (options: IImgOptions) => {
const { content, canvasHeight, canvasWidth } = options;
const canvas = document.createElement('canvas'); // 创建一个画布
const ctx = canvas.getContext('2d');
// 设置画布的宽高
canvas.width = canvasHeight;
canvas.height = canvasWidth;
if (ctx) {
ctx.rotate((-10 * Math.PI) / 180); // 偏移一点距离
ctx.fillStyle = 'rgba(100,100,100,0.2)'; // 设置绘制的颜色
ctx.font = '40px'; // 设置字体的大小
// 遍历水印内容
content.forEach((text, index) => {
ctx.fillText(text, 10, 30 * (index + 1)); // 拉开30的间距
});
}
return canvas.toDataURL('image/png'); // 转换程data url,可供img直接使用
};
利用伪类将背景图添加到整个页面上
- 给需要添加水印元素添加一个对应的伪元素,将第一步通过 Canvas 生成的 data url 作为背景
- 创建一个 style 元素,将伪元素放在 style.innerHTML 中,然后 appendChild 到 head 中,此时,页面水印就加完了
js
const genWaterMark = ({
content,
className,
canvasHeight = 140,
canvasWidth = 150,
}) => {
const dataURL = createImgBase({ content, canvasHeight, canvasWidth });
const defaultStyle = document.createElement('style');
defaultStyle.innerHTML = `.${className}::after {
content: '';
display: block;
width: 100%;
height: 100vh;
background-image: url(${dataURL});
background-repeat: repeat;
pointer-events: none;
position: fixed;
top: 0;
left: 0;
}`;
document.head.appendChild(defaultStyle);
};
// 使用方式
const Content = () => {
useEffect(() => {
genWaterMark({
content: [userName, userPhone, '内部机密材料', '严禁外泄!'],
className: 'my-page-container',
});
}, []);
return (
<div className="my-page-container" id="my-page-container">
<div className="my-info">
<div className="title">这是测试标题</div>
<div className="content">
// ...我想这是机密内容 * n
</div>
</div>
</div>
)
}
// css样式
.my-page-container {
height: calc(100vh - 104px);
overflow: hidden;
.my-info {
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
padding: 24px;
overflow-y: auto;
// .title & .content 一些不重要的css
}
页面效果如下:脱敏处理,截图未展示姓名和手机号。
利用MutationObserver,防止被人删除className
js
const listenerDOMChange = (className: string) => {
const targetNode = document.querySelector(`.${className}`);
const observer = new MutationObserver((mutationsList) => {
for (let mutation of mutationsList) {
if (mutation.type === 'attributes' && mutation.attributeName === 'class' && targetNode) { // 监听属性并且属性名为class的变更
const curClassVal = targetNode.getAttribute('class') || '';
if (curClassVal.indexOf(className) === -1) { // 监听到className被删除了,手动加回去
targetNode.setAttribute('class', `${className} ${curClassVal}`);
}
}
}
});
observer.observe(targetNode as Node, {
attributes: true,
});
};
const genWaterMark = ({
content,
className,
canvasHeight = 140,
canvasWidth = 150,
}: IWaterMark) => {
// 监听class的变更
listenerDOMChange(className);
const dataURL = createImgBase({ content, canvasHeight, canvasWidth });
const defaultStyle = document.createElement('style');
// 省略
document.head.appendChild(defaultStyle);
};
注意点
注意点①:
-
问题:
上述方案的水印是占据整个页面的,但有些水印期望是在特定区域的。
-
解决方案:
利用定位,实现在特定区域增加水印
js
// 通过设置position: absolute来实现
const genWaterMark = ({
content,
className,
canvasHeight = 140,
canvasWidth = 150,
}: IWaterMark) => {
const dataURL = createImgBase({ content, canvasHeight, canvasWidth });
const defaultStyle = document.createElement('style');
defaultStyle.innerHTML = `.${className}::after {
content: '';
display: block;
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
background-image: url(${dataURL});
background-repeat: repeat;
pointer-events: none;
}`;
document.head.appendChild(defaultStyle);
};
// 使用方式
const Content = () => {
useEffect(() => {
genWaterMark({
content: [userName, userPhone, '内部机密材料', '严禁外泄!'],
className: 'wait-task-wrap',
});
}, []);
return (
<View className="my-page-container" id="my-page-container">
// ...一些不重要的代码
<View className="wait-task-wrap"></View>
</View>
)
}
// css样式
.wait-task-wrap {
// 一些不重要的样式
position: relative;
}
页面效果如下:
3、图片水印
3.1 方案设计
在资料中,存在很多图片,但页面水印,对图片你来说就我们要对图片进行预览并且支持保存。此时页面背景水印就没有用啦,我们下载下来的图片还是不带水印的。针对这种现象,我们有以下一些常用的解决方案
- 方案一:服务端添加水印,安全,但是服务端压力大且性能慢
- 方案二:借助 oss 添加水印,简便但是不通用
- 方案三:canvas 方案,安全但性能慢
本文着重介绍后两种前端添加水印的方式。
代码实现
借助oss
将oss地址转成带水印的oss地址
js
// oss水印中的文字进行url安全的base64编码
const getSafeBase64Code = (name: string) => {
return window
.btoa(unescape(encodeURIComponent(name)))
.replace(/+/g, '-')
.replace(//+/g, '_');
};
const genOSSImageWaterMark = (imgSrc: string) => {
const { userName, userPhone } = JSON.parse(window.localStorage.getItem('userInfo') || '{}');
return `${imgSrc}?x-oss-process=image/watermark,text_${getSafeBase64Code(
`${userName}-${userPhone}`
)},rotate_325,t_10,color_000000,size_30,fill_1,g_nw,x_30,y_30`;
};
// 使用
const ImageWaterMark = () => {
return (
<Image
src={genOSSImageWaterMark('xxx图片地址xxx')}
/>
)
}
页面效果如下:
注意点
注意点①
- 问题:有些图片某些区域是透明的,导致透明的区域上不了色。(效果如图一)
- 解决方案:ui 告诉我们,png 图片导出默认是透明的,但是 jpg 默认会将透明的地方填充白色的背景,所以,我们查阅对图片进行格式转换的参数说明及实例_对象存储-阿里云帮助中心文档得出,只需要加上 x-oss-process=image/format,jpg, 对之前的 genOSSImageWaterMark 进行改造,对非 jpg 的图片都转成 jpg 的图片
js
const genOSSImageWaterMark = (imgSrc: string) => {
const imgType = imgSrc.split('.').slice(-1)[0];
const { userName, userPhone } = JSON.parse(window.localStorage.getItem('userInfo') || '{}');
return `${imgSrc}?${
imgType !== 'jpg' ? 'x-oss-process=image/format,jpg,' : ''
}x-oss-process=image/watermark,text_${getSafeBase64Code(
`${userName}-${userPhone}`
)},rotate_325,t_10,color_000000,size_30,fill_1,g_nw,x_30,y_30`;
};
效果如下:
注意点②
- 问题:字体写死,导致水印在大图上特别小,小图上特别大。(效果如图二)
- 解决方案:根据图片比,计算字体大小。
js
interface IImageProps {
width: number;
height: number;
}
// 获取图片的宽高
const getImageWH = async (src): Promise<IImageProps> => {
const img = new Image();
img.src = src;
await new Promise((resolve) => (img.onload = resolve)); // 等图片加载完
return new Promise((resolve) => {
resolve({
width: img.width,
height: img.height,
});
});
};
const genOSSImageWaterMark = async (imgSrc: string) => {
const { userName, userPhone } = JSON.parse(window.localStorage.getItem('userInfo') || '{}');
const imgType = imgSrc.split('.').slice(-1)[0];
const { width, height } = await getImageWH(imgSrc);
const min = Math.min(width, height);
// 根据官网上的测试图片,宽度为400,设置字体为10,水印展示效果很好,所以图片比为40
const size = Math.ceil(min / 40);
const src = `${imgSrc}?${
imgType !== 'jpg' ? 'x-oss-process=image/format,jpg,' : ''
}x-oss-process=image/watermark,text_${getSafeBase64Code(
`${userName}-${userPhone}`
)},rotate_325,t_100,color_ff0000,size_${size},fill_1,g_nw,x_30,y_30`;
return src;
};
效果如下:
注意点③
-
问题:
用户直接将后缀删了,水印也就没了
-
解决方式:
oss 设置安全级别,不带水印不可访问
通过canvas给图片增加水印
技术方案设计
- 图片路径转成 canvas
- canvas 添加水印
- canvas 转成 img
代码实现
js
const genOSSImageWaterMark = async (imgSrc: string) => {
const canvas = document.createElement('canvas');
// ① 图片路径转成canvas
await imgSrc2Canvas(canvas, imgSrc);
// ② canvas添加水印
addWatermark(canvas);
// ③ canvas转成img
return canvas.toDataURL('image/png');
};
使用
js
const genOSSImageWaterMark = async (imgSrc: string) => {
const canvas = document.createElement('canvas');
await imgSrc2Canvas(canvas, imgSrc);
addWatermark(canvas);
return canvas.toDataURL('image/png');
};
图片路径转成canvas
js
const imgSrc2Canvas = (cav: HTMLCanvasElement, imgSrc: string) => {
return new Promise(async (resolve) => {
const image = new Image();
image.src = imgSrc;
// ① 为图片设置crossOrigin属性,防止Failed to execute 'toDataURL' on 'HTMLCanvasElement'
image.setAttribute('crossOrigin', 'anonymous');
// ② 解决渲染图片为透明图层
await new Promise((resolve) => (image.onload = resolve));
cav.width = image.width;
cav.height = image.height;
const ctx = cav.getContext('2d');
if (ctx) {
ctx.drawImage(image, 0, 0);
}
resolve(cav);
});
};
canvas添加文字水印
-
通过二维数组的渲染,来填充文本
-
- 通过画布的宽度以及水印的宽度来计算 X 轴的渲染次数
- 通过画布的宽度以及你想打印的疏密程度来计算 Y 轴的渲染次数
js
const addWatermark = async (canvas, imgSrc) => {
const { userName, userPhone } = JSON.parse(window.localStorage.getItem('userInfo') || '{}');
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'rgba(100,100,100,0.2)'; // 字体颜色
ctx.font = `24px serif`;
ctx.translate(0, 0);
ctx.rotate((5 * Math.PI) / 180); // 旋转角度
const repeatX = Math.floor(canvas.width / 240); // 100 为每个水印的基本宽度
const repeatY = Math.floor(canvas.height / 150);
for (let i = 0; i < repeatX; i++) {
for (let j = 1; j < repeatY; j++) {
ctx.fillText(`${userName}-${userPhone}`, 240 * 2 * i, 150 * j); // 控制水印的疏密
}
}
};
页面效果如下:
注意点
注意点①:
- 问题:页面报错如下
- 原因:当 img 元素的 src 不符合同源准则时,会阻止读取 canvas 的内容。因为此时 img 元素放在 canvas 中时,canvas 元素会被标记为被污染的,而在被污染的 canvas 中调用 toDataUrl 将会报错
- 解决方案:
js
// 为image设置crossOrigin属性
image.setAttribute('crossOrigin', 'anonymous');
注意点②:
- 问题:渲染的图片为透明的图片
- 原因:图片还未渲染完,就返回了 canvas。
- 解决方案:等图片渲染完了,再开始画到 canvas 中
js
await new Promise((resolve) => (image.onload = resolve));
总结
本文主要讲了两个话题:页面水印 & 图片水印。页面水印很简单,基本上就是利用 canvas 渲染水印,再利用伪类将 canvas 的水印渲染在特定的区域。图片相对而言会复杂一些,在渲染水印之前,得先把图片渲染上去,针对大图,性能可能会慢一点。所以,如果对水印要求不是很严格并且图片是存储在 oss 的,那利用 oss 来加水印也不失为一种好选择。但如果从安全性来考虑,那肯定是服务端加水印会更合适一点。
最后
📚 小茗文章推荐:
关注公众号「Goodme前端团队」,获取更多干货实践,欢迎交流分享~