前端通过 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 string
image.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;
}
};
现在就可以愉快享用了,祝君好运