在小程序开发中,给用户拍摄的图片或上传的图片添加“自带信息”的水印(如:打卡时间、地点、防伪标识等)是一个非常普遍的需求。
如果你是 Canvas 相关的“小白”,一听到“图像处理”、“画布”就觉得头大,别慌!今天我们就用最通俗的语言和结构化的步骤,带你彻底搞懂如何在小程序中用 Canvas 给图片优雅地打上水印。
💡 核心思路:像做手工一样加水印
给图片加水印,就像我们做手工一样,分四步走:
- 找相纸:你需要准备一个画布(Canvas)。
- 洗照片并贴满相纸:拿到原图,等比例贴在画布上。
- 贴胶布并写字:在相纸的某个角落,贴一块半透明的胶布,用白颜料在上面写上我们需要的水印信息。
- 重新拍张照:用相机把加工好的相纸拍下来,导出一张新的图片。
🛠️ 第一步:在页面里准备一块“隐形画布”
我们需要在前端模板里加上 <canvas> 标签。为了不影响页面的正常布局,我们通常会让它“默默在后台工作”(你可以通过样式把它移出屏幕外,或者利用 v-if 控制,但在小程序中建议给它动态设定尺寸)。
<!-- 这是一个通用的 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 读取真实宽高,缩放控制在安全范围内。
// 选择照片
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 制图。为了保证文字在任何背景下都能看清,我们会先画一个半透明的黑色背景框,再在上面写白色文字。
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 给这个画布拍个照,生成一张全新的图片路径!
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 等跨端框架兼容语法):
<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>