编程技术文章分享与教程

网站首页 > 技术文章 正文

「前端添加水印」你真的了解全面吗?

hmc789 2024-11-11 12:49:28 技术文章 2 ℃

背景

在古茗日常业务中,经常会给加盟商下发各种资料,例如:奶茶的配方、设备的清洗、卫生的标准等等等。这些资料都是一些内部资料,从信息安全维度不能被泄露和盗取出去。所以会给下发的资料加上水印。这些资料可能是纯文本,也可能是文本加图片的。因此,我们要做好以下两个方面:

  • 通过对页面增加水印,可以从系统级别防止别人盗取我们的页面信息
  • 通过对单独的图片加水印 - 防止图片保存时没有水印

页面水印

方案设计

实现页面水印的方式有很多,可以看一些常用页面加水印的方案,具体如下:

  • 方案一:fixed 定位的 div 元素,重复渲染 div 元素来添加水印。会创建很多无关的 DOM 元素
  • 方案二:fixed 定位 canvas 元素,重复填充水印。始终会创建一个无关的 canvas 元素
  • 方案三:canvas + 伪类。不会创建无关元素,且兼容性好
  • 方案四:svg + 伪类。不会创建无关元素,但兼容性略差于 canvas

这些方案,都有一个通用的缺点,那就是将元素删掉,或者将类名删掉,都能去除页面水印。

基于实现成本和安全性维度的考虑,最终方案选型:方案三,同时增加了通过MutationObserver - Web API 接口参考 | MDN 解决了删除类名导致水印删除的问题。

核心功能点:

  • 把签名信息,通过 Canvas 生成背景图
  • 利用伪类将背景图添加到需要生成水印的区域上
  • 通过 MutationObserver , 解决了删除类名导致水印删除的问题

代码实现

把签名信息,通过Canvas生成背景图

  • 利用 Canvas 来绘制背景图,背景内容为水印的内容
  • 通过 toDataURL 将 Canvas 转换成图片,格式为 image/png
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 中,此时,页面水印就加完了
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

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);
};

注意点

注意点①:

  • 问题:
  • 上述方案的水印是占据整个页面的,但有些水印期望是在特定区域的。
  • 解决方案:
  • 利用定位,实现在特定区域增加水印
// 通过设置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地址

// 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 的图片
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`;
};

效果如下:

注意点②

  • 问题:字体写死,导致水印在大图上特别小,小图上特别大。(效果如图二)
  • 解决方案:根据图片比,计算字体大小。
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

代码实现

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');
};

使用

const genOSSImageWaterMark = async (imgSrc: string) => {
  const canvas = document.createElement('canvas');
  await imgSrc2Canvas(canvas, imgSrc);
  addWatermark(canvas);

  return canvas.toDataURL('image/png');
};

图片路径转成canvas

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 轴的渲染次数
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 将会报错
  • 解决方案:
// 为image设置crossOrigin属性
image.setAttribute('crossOrigin', 'anonymous');

注意点②:

  • 问题:渲染的图片为透明的图片
  • 原因:图片还未渲染完,就返回了 canvas。
  • 解决方案:等图片渲染完了,再开始画到 canvas 中
await new Promise((resolve) => (image.onload = resolve));

总结

本文主要讲了两个话题:页面水印 & 图片水印。页面水印很简单,基本上就是利用 canvas 渲染水印,再利用伪类将 canvas 的水印渲染在特定的区域。图片相对而言会复杂一些,在渲染水印之前,得先把图片渲染上去,针对大图,性能可能会慢一点。所以,如果对水印要求不是很严格并且图片是存储在 oss 的,那利用 oss 来加水印也不失为一种好选择。但如果从安全性来考虑,那肯定是服务端加水印会更合适一点。


原文链接:https://juejin.cn/post/7302724955699822631

Tags:

标签列表
最新留言