护航隐私!小程序纯前端“证件加水印”:OffscreenCanvas 全屏平铺实战

1. 背景与痛点:证件"裸奔"的风险

在日常生活中,我们经常需要上传身份证、驾照或房产证照片来办理各种业务。然而,直接发送原图存在巨大的安全隐患:

  • 被二次盗用:不法分子可能将你的证件照用于网贷、注册账号等非法用途。
  • 服务器隐私泄露:如果使用在线工具加水印,图片必须上传到第三方服务器,这就好比"把钥匙交给陌生人保管",风险不可控。

为了解决这一痛点,可利用小程序的 OffscreenCanvas 能力,在用户手机本地毫秒级合成水印,图片数据永远不会离开用户手机

2. 核心思路:离屏渲染 + 矩阵平铺

实现全屏倾斜水印,主要难点在于坐标计算性能平衡。我们的方案如下:

  1. 离屏渲染 (OffscreenCanvas): 使用离屏画布在内存中处理,避免页面闪烁,且支持高性能的 2D 渲染模式。
  2. 智能 DPR 降级: 沿用我们之前文章提到的防爆内存策略。证件照通常分辨率很高,必须计算安全尺寸,防止 Canvas 内存溢出闪退。
  3. 矩阵平铺算法 : 不简单的旋转画布,而是采用 "保存环境 -> 平移 -> 旋转 -> 绘制 -> 恢复环境" 的策略,在一个网格循环中将文字铺满全屏,确保无论图片比例如何,水印都能均匀分布。

3. 硬核代码实现

以下是封装好的 watermarkUtils.js。包含了智能 DPR 计算全屏水印绘制的核心逻辑。

javascript 复制代码
// utils/watermarkUtils.js

// 1. 获取系统基础信息
const wxt = {
  dpr: wx.getSystemInfoSync().pixelRatio || 2
};

// 图片缓存,避免重复加载
const cacheCanvasImageMap = new Map();

/**
 * 内部方法:获取/创建 Canvas Image 对象
 */
async function getCanvasImage(canvas, imageUrl) {
  if (cacheCanvasImageMap.has(imageUrl)) return cacheCanvasImageMap.get(imageUrl);
  
  // 兼容性处理:若不支持 Promise.withResolvers,请改用 new Promise
  const { promise, resolve, reject } = Promise.withResolvers();
  const image = canvas.createImage();
  image.onload = () => {
    cacheCanvasImageMap.set(imageUrl, image);
    resolve(image);
  };
  image.onerror = (e) => reject(new Error(`加载失败: ${e.errMsg}`));
  image.src = imageUrl;
  await promise;
  return image;
}

/**
 * 给图片添加全屏倾斜水印
 * @param {string} imageUrl 图片路径
 * @param {string} text 水印文字,如 "仅供办理租房业务使用"
 * @param {object} options 配置项 { color, size, opacity }
 */
export async function addWatermark(imageUrl, text = '仅供办理业务使用', options = {}) {
  // 默认配置
  const config = {
    color: '#aaaaaa',
    opacity: 0.5,
    fontSize: 0, // 0 表示自动计算
    gap: 100,    // 水印间距
    ...options
  };

  const offscreenCanvas = wx.createOffscreenCanvas({ type: '2d' });
  const image = await getCanvasImage(offscreenCanvas, imageUrl);
  const { width, height } = image;

  // --- ⚡️ 性能优化:智能 DPR 计算 (防止大图闪退) ---
  const LIMIT_SIZE = 4096; 
  let useDpr = wxt.dpr;
  if (Math.max(width, height) * useDpr > LIMIT_SIZE) {
    useDpr = LIMIT_SIZE / Math.max(width, height);
  }

  // 设置画布尺寸
  offscreenCanvas.width = width * useDpr;
  offscreenCanvas.height = height * useDpr;

  const ctx = offscreenCanvas.getContext('2d');
  ctx.scale(useDpr, useDpr);
  
  // 1. 绘制底图
  ctx.drawImage(image, 0, 0, width, height);

  // 2. 配置水印样式
  // 自动计算字号:约为图片宽度的 4%
  const fontSize = config.fontSize || Math.floor(width * 0.04); 
  ctx.font = `bold ${fontSize}px sans-serif`;
  ctx.fillStyle = config.color;
  ctx.globalAlpha = config.opacity;
  ctx.textBaseline = 'middle';
  ctx.textAlign = 'center';

  // 3. 计算平铺逻辑
  // 旋转 45 度后,覆盖范围需要比原图大,这里简单取对角线长度作为边界
  const maxSize = Math.sqrt(width * width + height * height);
  // 步长 = 文字宽度 + 间距
  const step = ctx.measureText(text).width + config.gap; 
  
  // 4. 循环绘制水印
  // 从负坐标开始绘制,确保旋转后边缘也有水印
  for (let x = -maxSize; x < maxSize; x += step) {
    for (let y = -maxSize; y < maxSize; y += step) {
      ctx.save();
      
      // 核心变换:平移到网格点 -> 旋转 -> 绘制
      ctx.translate(x, y);
      ctx.rotate(-45 * Math.PI / 180); // 逆时针旋转 45 度
      ctx.fillText(text, 0, 0);
      
      ctx.restore();
    }
  }

  // 5. 导出图片
  const res = await wx.canvasToTempFilePath({
    canvas: offscreenCanvas,
    fileType: 'jpg',
    quality: 0.8, // 稍微压缩以减小体积
  });

  return res.tempFilePath;
}

4. 业务调用示例

在小程序页面中,用户选择图片并输入水印文字后,实时预览效果。

javascript 复制代码
// pages/watermark/index.js
import { addWatermark } from '../../utils/watermarkUtils';

Page({
  data: {
    originImg: '',
    resultImg: '',
    watermarkText: '仅供本次业务使用 他用无效'
  },

  async onAddWatermark() {
    if (!this.data.originImg) return;

    wx.showLoading({ title: '安全合成中...' });
    
    try {
      const tempFilePath = await addWatermark(
        this.data.originImg, 
        this.data.watermarkText,
        {
          color: '#ffffff', // 白色水印
          opacity: 0.4,     // 半透明
          gap: 120          // 间距疏松一点
        }
      );
      
      this.setData({ resultImg: tempFilePath });
      
    } catch (err) {
      console.error(err);
      wx.showToast({ title: '合成失败', icon: 'none' });
    } finally {
      wx.hideLoading();
    }
  }
})

5. 避坑与实战经验

  1. 自动字号的重要性 : 不要写死 fontSize = 20px。用户上传的图片分辨率差异极大(有的 500px 宽,有的 4000px 宽)。最佳实践是根据图片宽度动态计算字号 (如 width * 0.04),这样无论处理缩略图还是 4K 原图,水印比例看起来都是协调的。
  2. 平铺范围的陷阱 : 因为文字需要旋转 45 度,如果循环只从 0width,图片的左下角和右上角可能会出现空白。代码中我们从 -maxSize(负数区域)开始循环,确保旋转后的文字能完全覆盖画布的每一个角落。
  3. 隐私第一 : 在工具的 UI 界面上,建议显著提示 "纯本地处理,无上传服务器",这能极大地增加用户的信任感,提升工具的使用率。

写在最后

通过帮小忙工具箱的这个实践案例,我们可以看到,利用小程序强大的 Canvas 能力,开发者完全可以在保护用户隐私的前提下,提供专业级的图片处理服务。

技术不只是代码,更是对用户安全的守护。 希望这篇分享能帮你在小程序中实现更安全、更高效的功能!

相关推荐
AY呀2 小时前
# 🌟 JavaScript原型与原型链终极指南:从Function到Object的完整闭环解析 ,深入理解JavaScript原型系统核心
前端·javascript·面试
用户434662153132 小时前
无废话之 useState、useRef、useReducer 的使用场景与选择指南
前端
GinoWi2 小时前
HTML标签 - 表格标签
前端
氤氲息2 小时前
鸿蒙 ArkTs 的WebView如何与JS交互
javascript·交互·harmonyos
码是生活2 小时前
老板:能不能别手动复制路由了?我:写个脚本自动扫描
前端·node.js
chushiyunen2 小时前
未设置X-XSS-Protection响应头安全漏洞
前端·xss
叫我詹躲躲2 小时前
别再用mixin了!Vue3自定义Hooks让逻辑复用爽到飞起
javascript·vue.js
文心快码BaiduComate2 小时前
Comate Spec模式实测:让AI编程更精准可靠
前端·后端·前端框架
菥菥爱嘻嘻2 小时前
组件测试--React Testing Library的学习
前端·学习·react.js