小白也能看懂:小程序 Canvas 给图片添加水印的终极指南

在小程序开发中,给用户拍摄的图片或上传的图片添加"自带信息"的水印(如:打卡时间、地点、防伪标识等)是一个非常普遍的需求。

如果你是 Canvas 相关的"小白",一听到"图像处理"、"画布"就觉得头大,别慌!今天我们就用最通俗的语言和结构化的步骤,带你彻底搞懂如何在小程序中用 Canvas 给图片优雅地打上水印

💡 核心思路:像做手工一样加水印

给图片加水印,就像我们做手工一样,分四步走:

  1. 找相纸:你需要准备一个画布(Canvas)。
  2. 洗照片并贴满相纸:拿到原图,等比例贴在画布上。
  3. 贴胶布并写字:在相纸的某个角落,贴一块半透明的胶布,用白颜料在上面写上我们需要的水印信息。
  4. 重新拍张照:用相机把加工好的相纸拍下来,导出一张新的图片。

🛠️ 第一步:在页面里准备一块"隐形画布"

我们需要在前端模板里加上 <canvas> 标签。为了不影响页面的正常布局,我们通常会让它"默默在后台工作"(你可以通过样式把它移出屏幕外,或者利用 v-if 控制,但在小程序中建议给它动态设定尺寸)。

html 复制代码
<!-- 这是一个通用的 Vue/uniapp/Taro 模板示例 -->
<template>
  <view class="container">
    <button @click="takePhoto">拍照并加水印</button>

    <!-- 用于展示最后效果的图片 -->
    <image v-if="imgWithWatermark" :src="imgWithWatermark" mode="widthFix" />

    <!-- 制作水印的画板 -->
    <canvas
      canvas-id="wmCanvas"
      class="watermark_canvas"
      :style="'width:' + canvasWidth + 'px;height:' + canvasHeight + 'px;'"
      :width="canvasWidth"
      :height="canvasHeight"
    ></canvas>
  </view>
</template>

📸 第二步:获取原图片并决定相纸大小

图片有大有小,如果画布(Canvas)写死了宽高,图片就会被拉伸或者裁剪。在真机上,太大的图片如果没有控制尺寸,甚至会导致只渲染左上角。

所以我们先用 wx.getImageInfo 读取真实宽高,缩放控制在安全范围内。

javascript 复制代码
// 选择照片
const takePhoto = () => {
  wx.chooseMedia({
    count: 1,
    mediaType: ["image"],
    sourceType: ["camera", "album"],
    sizeType: ["compressed"],
    success: (res) => {
      const tempFilePath = res.tempFiles[0].tempFilePath;
      doWatermark(tempFilePath);
    },
  });
};

// 开始水印处理
const doWatermark = (imgPath) => {
  // 准备要打的水印文案
  const watermarkText = [
    `打卡人:张三`,
    `📍 地点:科技园某某大厦`,
    `⏰ 时间:2024-10-01 12:00:00`,
    `仅供学习交流使用`,
  ];

  wx.getImageInfo({
    src: imgPath,
    success: (imgInfo) => {
      // 【控制尺寸与比例缩放详解】
      // 1. 设定最大边长限制
      // 为什么是 1280?在很多旧款手机或微信小程序的底层实现中,Canvas 绘制过大的图片(比如 4K 分辨率的照片)
      // 极易导致内存溢出闪退,或者只绘制出图片的左上角。1280 是一个兼顾清晰度和性能的经典安全值。
      const maxSide = 1280;

      // 2. 计算缩放比例 (ratio)
      // Math.max(imgInfo.width, imgInfo.height):找出原照片较长的那一边(宽或长)。
      // maxSide / Math.max(...):算出如果要让最长边变成 1280,需要缩小多少倍。
      // Math.min(1, ...):如果原图本身比 1280 还小,算出来的比例会大于 1。
      // 这个 min(1) 确保了:对于本来就小的图片,我们保持原大小(不拉伸放大导致模糊);只有超大图才会被缩小。
      const ratio = Math.min(
        1,
        maxSide / Math.max(imgInfo.width, imgInfo.height),
      );

      // 3. 算出最终要绘制在 Canvas 上的实际宽和高
      // 原宽 x 缩放比例 = 实际绘制宽度。
      // Math.round:四舍五入取整,因为 Canvas 画布的像素长宽最好是整数,不能是小数(比如 800.5px)。
      // Math.max(1, ...):极端防御性编程,防止图片极度长条化导致算出来的高度等于 0 像素。最少也要保证 1 像素。
      const drawWidth = Math.max(1, Math.round(imgInfo.width * ratio));
      const drawHeight = Math.max(1, Math.round(imgInfo.height * ratio));

      // 更新画布大小到 vue/data 中
      this.canvasWidth = drawWidth;
      this.canvasHeight = drawHeight;

      // 我们等画布尺寸在页面上生效后,再开始画画
      this.$nextTick(() => {
        drawCanvas(imgPath, drawWidth, drawHeight, watermarkText);
      });
    },
  });
};

🎨 第三步:拿起画笔,开始绘制

画布大小定好了,我们开始调用 Canvas API 制图。为了保证文字在任何背景下都能看清,我们会先画一个半透明的黑色背景框,再在上面写白色文字。

javascript 复制代码
const drawCanvas = (imgPath, drawWidth, drawHeight, lines) => {
  // ⚠️ 避坑:真机上稍微延迟一下,确保 canvas 的宽高渲染完毕,否则可能出现大面积留白
  setTimeout(() => {
    // 【获取画布的画笔 (Context)】
    // wx.createCanvasContext 是小程序专门用来获取 Canvas 绘图上下文的 API。
    // 你可以把它理解为:我们找到了页面上 id="wmCanvas" 的那块相纸(Canvas 标签),
    // 并且向系统申请了一支全能的"智能画笔" ctx。
    // 接下来的 ctx.drawImage、ctx.setFillStyle 等操作,都是这支画笔在画布上工作。
    // 第二个参数 `this` 在 Vue/组件环境里必传,它告诉系统去当前组件的作用域里找这个 Canvas 标签,不然可能找不到。
    const ctx = wx.createCanvasContext("wmCanvas", this);

    // 【动态计算文字排版与尺寸详解】
    // 为什么不直接写死 fontSize = 16 呢?
    // 因为前面的代码对超大图片进行了等比例缩小,如果图片被缩小得很厉害,写死的 16 号字可能会显得太大;
    // 反之,如果用户传了一张很小的图(没被缩小),16 号字可能会显得像芝麻一样小。
    // 所以,这里我们要让字体大小"跟着画布宽度走",保持一个稳定的视觉观感比例。

    // 1. 计算基准字号 (fontSize)
    // drawWidth * 0.038:规定字号大概占整个画板宽度的 3.8% 左右,这是一个看着比较舒服的比例。
    // Math.max(16, ...):防御性限制,就算图片再小,字号也不能小于 16px,否则人眼就看不清了。
    const fontSize = Math.max(16, Math.round(drawWidth * 0.038));

    // 2. 计算行高 (lineHeight) 和 各种边距 (Padding)
    // 行高设定为字号的 1.5 倍,这是业内长文本排版最常用的黄金阅读间距。
    const lineHeight = Math.round(fontSize * 1.5);
    // textPadding:文字距离黑框左侧边缘的留白宽度。
    const textPadding = Math.round(fontSize * 0.8);
    // boxPadding:黑框上下的留白宽度,以及黑框距离图片最底部的安全距离。
    const boxPadding = Math.round(fontSize * 0.9);

    // 3. 计算半透明黑框的整体高和宽
    // 高度 (boxHeight) = 上下留白的 Padding × 2 + 每一行字的高度 × 总行数。这样黑框就能完美包裹住所有文字内容了。
    const boxHeight = boxPadding * 2 + lineHeight * lines.length;
    // 宽度 (boxWidth) = 画板宽度的 92%。给黑框左右各留出 4% 的空隙,不至于让黑框死板地顶到图片最边缘。
    const boxWidth = Math.round(drawWidth * 0.92);

    // 【计算黑框在画板上的绝对坐标位置】
    // 在 Canvas 里,画任何东西都需要用坐标 (x, y) 来定位,原点 (0, 0) 在左上角。

    // 1. 水平居中 (boxX)
    // 整体宽度减去黑框宽度,剩下的是左右两边的总空白。除以 2,就是左边需要预留的 X 坐标偏移量。
    // 例如:(1000 - 920) / 2 = 40。那么只要从 x=40 开始画框,右边肯定也会正好剩下 40,完美居中!
    const boxX = Math.round((drawWidth - boxWidth) / 2);

    // 2. 贴近底部 (boxY)
    // drawHeight 顾名思义是最底部的 Y 坐标。
    // 减去整个黑框的高度,意味着把框"托"上来了;然后再减去 boxPadding(预留的安全边距),
    // 意味着黑框不会死死贴着图片的下边沿,而是往上方悬浮了一段距离,显得更有呼吸感。
    const boxY = drawHeight - boxHeight - boxPadding;

    // 【步骤 1:把原图片画满整个 Canvas 相纸】
    // ctx.drawImage(图片路径, X轴起始位, Y轴起始位, 指定绘制宽度, 指定绘制高度)
    // 这里的 0, 0 表示从相纸的绝对左上角开始贴图,占满我们计算好的 drawWidth 和 drawHeight。
    ctx.drawImage(imgPath, 0, 0, drawWidth, drawHeight);

    // 【步骤 2:画一个半透明的黑色背景框】
    // 为什么要有这个黑框?因为用户的图片可能是纯白的,如果上面的字体也是白色的,水印就会完全看不见!
    // 垫一层 30% 透明度 (0.3) 的黑底,任何背景下都能看清白字,这是一个极佳的用户体验细节。
    ctx.setFillStyle("rgba(0, 0, 0, 0.3)"); // 把画笔沾上这种半透明黑色颜料

    // ctx.fillRect(X位置, Y位置, 矩形宽度, 矩形高度);
    // 拿着黑笔,在前面算好的坐标 (boxX, boxY) 处,画一个实心的长方形。
    ctx.fillRect(boxX, boxY, boxWidth, boxHeight);

    // 【步骤 3:准备写字】
    // 换一把纯白色的笔,设置好拿捏得死死的字号大小。
    ctx.setFillStyle("#ffffff");
    ctx.setFontSize(fontSize);

    // 【步骤 4:循环把每一行文字写上去】
    lines.forEach((line, index) => {
      // ⚠️ 极其关键的一步:计算文字的真实 Y 坐标!
      // 很多人画图发现字挤在一起或者偏上/偏下,就是这里没算对。
      // 在 Canvas 里,文字默认是"基于底部基线(Baseline)"对齐的,非常难受。

      // 我们来一步步拆解这行巨长公式:
      // (1) boxY + boxPadding:这是黑框内部,最顶部的可写字区域。
      // (2) lineHeight * (index + 1):第一行 index=0 (行高x1),第二行 index=1 (行高x2)。意思是每换一行,就往下挪一行的距离。
      // (3) - (lineHeight - fontSize) / 2:微调!因为行间距往往大于字号本身(比如字高 16,行距占位 24)。
      //     多出来的 8px 需要平均分摊到文字的上下,这样文字在每一"行"里才能绝对垂直居中!
      const textY =
        boxY +
        boxPadding +
        lineHeight * (index + 1) -
        (lineHeight - fontSize) / 2;

      // ctx.fillText(文本内容, X坐标开始位置, Y坐标开始位置)
      // 在黑框左边缘 (boxX) 加上我们预留好的留白 (textPadding) 处下笔。
      ctx.fillText(line, boxX + textPadding, textY);
    });

    // 【步骤 5:发号施令,让画笔真正干活】
    // ctx.draw(boolean 是否保留上次绘制, 回调函数)
    // 前面写的 drawImage, fillRect 等全都是在"打草稿记录指令",并不会真正显示出来。
    // 只有调用了 ctx.draw(),系统才会"刷"地一下把所有步骤画到 Canvas 上!
    // false 表示:每次都擦干净黑板重新画,不要保留之前旧的斑马线。
    // 回调函数 () => {}:画完了之后要干嘛?当然是通知下一步(导出图片)啦!
    ctx.draw(false, () => {
      exportImage(drawWidth, drawHeight);
    });
  }, 100);
};

📤 第四步:快照导出,大功告成

最后一步,在 ctx.draw 的回调里,用 wx.canvasToTempFilePath 给这个画布拍个照,生成一张全新的图片路径!

javascript 复制代码
const exportImage = (drawWidth, drawHeight) => {
  wx.canvasToTempFilePath(
    {
      canvasId: "wmCanvas",
      x: 0,
      y: 0,
      width: drawWidth,
      height: drawHeight,
      destWidth: drawWidth,
      destHeight: drawHeight,
      fileType: "jpg", // jpg 比 png 体积小
      quality: 0.9, // 控制一下质量,兼顾清晰与体积
      success: (res) => {
        // 这里就拿到了最终带有水印的图片路径!
        this.imgWithWatermark = res.tempFilePath;
        wx.showToast({ title: "水印添加成功", icon: "success" });
      },
      fail: (err) => {
        console.error(err);
        wx.showToast({ title: "水印生成失败", icon: "none" });
      },
    },
    this,
  );
};

🎁 完整可用代码

为了方便你直接参考,这里提供一个合并后的通用的 Vue 小程序组件(基于 Taro / uniapp 等跨端框架兼容语法):

html 复制代码
<template>
  <view class="watermark-page">
    <view class="btn-wrap">
      <button @tap="takePhoto" type="primary">拍摄并生成水印图</button>
    </view>

    <view class="preview" v-if="imgWithWatermark">
      <view class="title">最终效果图:</view>
      <image class="result-img" mode="widthFix" :src="imgWithWatermark"></image>
    </view>

    <!-- 隐藏在视区之外的画布 -->
    <canvas
      canvas-id="wmCanvas"
      class="watermark-canvas"
      :style="'width:' + canvasWidth + 'px;height:' + canvasHeight + 'px;'"
      :width="canvasWidth"
      :height="canvasHeight"
    ></canvas>
  </view>
</template>

<script>
  export default {
    data() {
      return {
        imgWithWatermark: "",
        canvasWidth: 300,
        canvasHeight: 300,
      };
    },
    methods: {
      takePhoto() {
        wx.chooseMedia({
          count: 1,
          mediaType: ["image"],
          sourceType: ["camera", "album"],
          sizeType: ["compressed"],
          success: (res) => {
            const tempFile = res.tempFiles[0];
            this.doWatermark(tempFile.tempFilePath);
          },
        });
      },

      // 获取当前时间的格式化字符串
      formatCurrentTime() {
        const d = new Date();
        const p = (num) => num.toString().padStart(2, "0");
        return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
      },

      doWatermark(imgPath) {
        // 通用的配置数据
        const lines = [
          `拍摄人:李开发者`,
          `当前项目:前端 Canvas 研究`,
          `拍摄时间:${this.formatCurrentTime()}`,
          `未经允许,严禁盗图验证`,
        ];

        wx.getImageInfo({
          src: imgPath,
          success: (imgInfo) => {
            // 控制极限大小,防止真机崩溃
            const maxSide = 1200;
            const ratio = Math.min(
              1,
              maxSide / Math.max(imgInfo.width, imgInfo.height),
            );
            const drawWidth = Math.max(1, Math.round(imgInfo.width * ratio));
            const drawHeight = Math.max(1, Math.round(imgInfo.height * ratio));

            this.canvasWidth = drawWidth;
            this.canvasHeight = drawHeight;

            this.$nextTick(() => {
              // 延迟等待 Canvas DOM 渲染宽高完毕
              setTimeout(() => {
                const ctx = wx.createCanvasContext("wmCanvas", this);

                // 动态计算间距与字号
                const fontSize = Math.max(14, Math.round(drawWidth * 0.038));
                const lineHeight = Math.round(fontSize * 1.5);
                const textPadding = Math.round(fontSize * 0.8);
                const boxPadding = Math.round(fontSize * 0.9);
                const boxHeight = boxPadding * 2 + lineHeight * lines.length;
                const boxWidth = Math.round(drawWidth * 0.92);
                const boxX = Math.round((drawWidth - boxWidth) / 2);
                const boxY = drawHeight - boxHeight - boxPadding;

                // 铺底图
                ctx.drawImage(imgInfo.path, 0, 0, drawWidth, drawHeight);

                // 画黑底半透明背景
                ctx.setFillStyle("rgba(0, 0, 0, 0.25)");
                ctx.fillRect(boxX, boxY, boxWidth, boxHeight);

                // 准备写字
                ctx.setFillStyle("#ffffff");
                ctx.setFontSize(fontSize);

                lines.forEach((line, index) => {
                  const textY =
                    boxY +
                    boxPadding +
                    lineHeight * (index + 1) -
                    (lineHeight - fontSize) / 2;
                  ctx.fillText(line, boxX + textPadding, textY);
                });

                ctx.draw(false, () => {
                  wx.canvasToTempFilePath(
                    {
                      canvasId: "wmCanvas",
                      x: 0,
                      y: 0,
                      width: drawWidth,
                      height: drawHeight,
                      destWidth: drawWidth,
                      destHeight: drawHeight,
                      fileType: "jpg",
                      quality: 0.9,
                      success: (res) => {
                        this.imgWithWatermark = res.tempFilePath;
                      },
                      fail: (err) => {
                        console.error("生成失败", err);
                      },
                    },
                    this,
                  );
                });
              }, 100);
            });
          },
        });
      },
    },
  };
</script>

<style>
  .watermark-page {
    padding: 20px;
  }
  .btn-wrap {
    margin-bottom: 20px;
  }
  .result-img {
    width: 100%;
    margin-top: 10px;
    border-radius: 8px;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  }
  .title {
    font-size: 16px;
    font-weight: bold;
    color: #333;
  }
  /* 最关键的一步!把 canvas 定位到屏幕外,或者通过透明度隐藏,避免干扰页面布局 */
  .watermark-canvas {
    position: fixed;
    top: -9999px;
    left: -9999px;
    opacity: 0;
  }
</style>
相关推荐
Mapmost1 小时前
“汛”速响应:流域洪水仿真分析,如何实现淹没过程的精准推演?
前端
梁大虎1 小时前
Electrobun 开发必看:CEF 依赖下载失败?手动解压一招搞定!
前端·javascript·后端
青青家的小灰灰2 小时前
拒绝 Prop Drilling 与隐式耦合:Vue 组件通讯的全景指南与最佳实践
前端·javascript·vue.js
代码老中医2 小时前
我赌5年后,90%的CRUD页面都是AI生成的
前端
bluceli2 小时前
前端监控与错误追踪实战指南:构建稳定应用的终极方案
前端·监控
streaker3032 小时前
多 IDE/Agent 环境下的 Skill 管理方案
前端·javascript·ai编程
Mintopia2 小时前
密集信息展示:表格与布局的取舍与实践指南
前端
牛奶2 小时前
从一行字到改变世界:HTTP这三十年都经历了什么?
前端·http·http3
OpenTiny社区2 小时前
以界面重构文字,GenUI 正式发布!
前端·vue.js·ai编程