前端通过 canvas 实现给图片打水印
一、背景
最近做了一个需求,小程序拍照上传图片时需要打水印,内容是:当时的时间、经纬度和地址
个人认为打水印后端去做比较好一点,不过领导决定了前端来做,那我就开始调研实现
实用技术栈:uniapp-小程序,Vue3&vant3-表单页
二、实现-封装一些方法
- 实现思路
封装upload组件,在自检change方法中做处理,先处理图片->传给服务->回显
- 因为上传组件的参数不是 base64 类型,所以要转换一下
const blob = new Blob([blobimage], {type: blobimage.type || "image/jpg",
}); //类型一定要写!!
const reader = new FileReader();
reader.readAsDataURL(blob);
- 最后处理完还要在转回去
// 加完水印 转成file
const base64toFile = (data) => {const dataArr = data.split(",");const byteString = atob(dataArr[1]);const u8Arr = new Uint8Array(byteString.length);for (let i = 0; i < byteString.length; i++) {u8Arr[i] = byteString.charCodeAt(i);}return new File([u8Arr], "1231231" + ".jpg", {type: "image/jpeg",endings: "native",}); //返回文件流
};
1.创建 image,用来挂在图片
const loadImageFromBase64 = (blobimage): Promise<HTMLImageElement> => {return new Promise((resolve, reject) => {const blob = new Blob([blobimage], {type: blobimage.type || 'image/jpg',}) //类型一定要写!!const reader = new FileReader()reader.readAsDataURL(blob)reader.onload = () => {const image = new Image()image.src = reader.result as stringimage.onload = () => resolve(image)image.onerror = () =>reject(new Error('Failed to load image from Base64'))}reader.onerror = (error) => reject(error)})}
2.绘制 canvas
async function imgToCanvas(blobimage, content) {try {const image = await loadImageFromBase64(blobimage);// 创建img元素// 创建canvas DOM元素,并设置其宽高和图片一样const canvas = document.createElement("canvas");const ctx = canvas.getContext("2d");canvas.width = image.width;canvas.height = image.height;// 坐标(0,0) 表示从此处开始绘制,相当于偏移。ctx.drawImage(image, 0, 0, image.width, image.height);// 由于图片像素大小不一致,文字大小通过比例计算const fontSize = Math.floor(canvas.width / 30);// // 添加水印ctx.font = `${fontSize}px Arial`;// 计算水印颜色---见下方const color = getMainColor(canvas);ctx.fillStyle = color;// 判断地址超长---见下方// 因为要将地址内容设置为水印,考虑长度问题,要计算截取,吧地址内容折行content = isLongAddress(ctx, content, canvas, 0);const keys = Object.keys(content);let num = 0;for (let i = keys.length; i > 0; i--) {const key = keys[i - 1];if (!content[key]) continue;// 计算文本宽度const textWidth = ctx.measureText(content[key]).width;// PhotoWatermarkStyleEnum.LEFT_BOTTOM 这个是我们系统配置项,用来控制水印是左边还是右边const x =photoWatermarkStyle === PhotoWatermarkStyleEnum.LEFT_BOTTOM? 20: canvas.width - textWidth - 20;num++;const y = canvas.height - fontSize * num * 1.2;ctx.fillText(content[key], x, y);}// 把文件转换为 file 类型const file = base64toFile(canvas.toDataURL("image/png"));return file;} catch (error) {console.error("Error adding watermark:", error);throw error;}
}
3.计算水印颜色
// 计算主色调
function getMainColor(canvas) {const ctx = canvas.getContext("2d");// 获取像素数据const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);const data = imageData.data;const colorCount = {};// 分析像素数据for (let i = 0; i < data.length; i += 4) {const r = data[i];const g = data[i + 1];const b = data[i + 2];const key = `${r},${g},${b}`;colorCount[key] = (colorCount[key] || 0) + 1;}// 确定主色调let mainColor = null;let maxCount = 0;for (const key in colorCount) {if (colorCount[key] > maxCount) {maxCount = colorCount[key];mainColor = key;}}if (!mainColor) return `rgb(0,0,0)`;// 这里简化处理,直接取反色作为水印颜色const result = `${mainColor}`.split(",");const color = result.length? {r: parseInt(result[0], 10),g: parseInt(result[1], 10),b: parseInt(result[2], 10),}: {r: 255,g: 255,b: 255,};// 计算亮度const brightness = color.r * 0.299 + color.g * 0.587 + color.b * 0.114;// 根据亮度决定水印颜色if (brightness > 128) {// 如果主色调较亮,则使用深色水印return "rgb(102, 102, 102)"; // 黑色水印} else {// 如果主色调较暗,则使用浅色水印return "rgb(200,200,200)"; // 白色水印}
}
4.判断地址是否超长,处理水印内容
// 判断地址超长
function isLongAddress(ctx, content, canvas, num) {let obj = cloneDeep(content);const key = num ? `loc${num}` : "loc";const text = obj[key];const textWidth = ctx.measureText(text).width;// 截取的文本 - 前段let textBefore = "";// 截取的文本 - 后段let textAfter = "";if (textWidth < canvas.width) return obj;// 宽度比 -- Math.floor 是为了防止截取的文本过长,留点空余量const ratio = Math.floor((canvas.width / textWidth) * 10) / 10;// 要截取的文本百分比const ratio1 = Number(ratio.toFixed(1));// 计算要截取的文本长度const len = Math.floor(text.length * ratio1);// 截取文本textBefore = text.slice(0, len);textAfter = text.slice(len, text.length);obj[key] = textBefore;Object.assign(obj, { [`loc${num + 1}`]: textAfter });// 这里做递归,,以免水印内容截取后半段还超长obj = isLongAddress(ctx, obj, canvas, num + 1);return obj;
}
三、使用
- 计算水印内容
// 计算水印内容
const watermarkContent = async () => {const content = {time: "",lat_lon: "",loc: "",};// 时间if (photoWatermarkContent.includes(PhotoWatermarkContentEnum.TIME)) {const date = new Date();const year = date.getFullYear();const month = date.getMonth() + 1;const day = date.getDate();const hour = date.getHours();const min = date.getMinutes();const sec = date.getSeconds();content.time = `${year}-${month}-${day} ${hour}:${min}:${sec}`;}// 经纬度---当前逻辑是小程序webview中实现,涉及到一些坑,后边会专门出一期const res = await getLocationInfo();content.lat_lon = res.lat_lon || "";content.loc = res.loc || "";return content;
};
- 处理文件
// rawFileList 文件列表
const setWatermark = async (rawFileList) => {try {// 是否仅拍照上传const camera =Object.keys(uplaodOptions).includes("uploadRequirement") &&uplaodOptions["uploadRequirement"] === IUploadRequirement.CAMERA;if (camera && rawFileList.length) {// 计算要加水印的内容const content = await watermarkContent();const file = await imgToCanvas(rawFileList[0].file, content);if (!file) return rawFileList;rawFileList[0].file = file;return rawFileList;// return rawFileList} else {return rawFileList;}} catch (error) {return rawFileList;}
};
现在就可以愉快享用了,祝君好运