uniapp 使用 canvas 绘制海报

661 阅读10分钟

封装原因

在项目中有时候会需要前端画出一个宣传海报来,这时候就会使用到canvas来制作了

目前仅封装了绘制以下图形

  • 图片
  • 矩形
  • 文字
  • 头像

参数

  • width:canvas的宽度(因为是基于uniapp封装的,所以单位是rpx)
  • height:canvas的高度(因为是基于uniapp封装的,所以单位是rpx)
  • drawArr:绘制素材的配置数组

配置项

drawArr数组对象参数

  • type 元素类型(字符串)可选值:image 图片、rect 矩形、arc 圆形、avatar 头像、text 文字
  • drawOptions 元素绘制参数(对象)

注意:因为是基于uniapp封装的,所以配置项的属性的单位是rpx(1px = 2rpx)

drawOptions的参数(image 图片)

属性说明可选值
left元素距离canvas左侧的距离数字或center,center表示水平居中,例如:10,'center'
right元素距离canvas右侧的距离数字,例如:10
top元素距离canvas顶部的距离数字或center,center表示垂直居中,例如:10,'center'
bottom元素距离canvas底部的距离数字,例如:10
width元素宽度数字或字符串,例如:10、'100%'
height元素高度数字或字符串,例如:10、'100%'
url图片的网络地址字符串

drawOptions的参数(rect 矩形)

属性说明可选值
left元素距离canvas左侧的距离数字或center,center表示水平居中,例如:10,'center'
right元素距离canvas右侧的距离数字,例如:10
top元素距离canvas顶部的距离数字或center,center表示垂直居中,例如:10,'center'
bottom元素距离canvas底部的距离数字,例如:10
width元素宽度数字,例如:10
height元素高度数字,例如:10
type绘制的类型字符串,例如:'fill'(fill填充、stroke描边)
fillStyle填充颜色字符串,例如:'#333333'
strokeStyle描边颜色字符串,例如:'#333333'
isFillet是否有圆角布尔值,例如:true
radius圆角值数字,例如:10

drawOptions的参数(arc 圆形)

属性说明可选值
left元素距离canvas左侧的距离数字或center,center表示水平居中,例如:10,'center'
right元素距离canvas右侧的距离数字,例如:10
top元素距离canvas顶部的距离数字或center,center表示垂直居中,例如:10,'center'
bottom元素距离canvas底部的距离数字,例如:10
type绘制的类型字符串,例如:'fill'(fill填充、stroke描边)
fillStyle填充颜色字符串,例如:'#333333'
strokeStyle描边颜色字符串,例如:'#333333'
radius半径数字,例如:10

drawOptions的参数(avatar 头像)

属性说明可选值
left元素距离canvas左侧的距离数字或center,center表示水平居中,例如:10,'center'
right元素距离canvas右侧的距离数字,例如:10
top元素距离canvas顶部的距离数字或center,center表示垂直居中,例如:10,'center'
bottom元素距离canvas底部的距离数字,例如:10
width头像的宽度数字,例如:10
height头像的高度数字,例如:10
url头像的网络地址字符串

drawOptions的参数(text 文字)

属性说明可选值
left元素距离canvas左侧的距离数字或center,center表示水平居中,例如:10,'center'
right元素距离canvas右侧的距离数字,例如:10
top元素距离canvas顶部的距离数字或center,center表示垂直居中,例如:10,'center'
bottom元素距离canvas底部的距离数字,例如:10
type绘制的类型字符串,例如:'fill'(fill填充、stroke描边)
fillStyle填充颜色字符串,例如:'#333333'
strokeStyle描边颜色字符串,例如:'#333333'
text文本内容(仅绘制text时有效)字符串,例如:'你好,世界'
maxLine文本最大行数(仅绘制text时有效)数字,例如:2
maxWidth文本最大宽度(仅绘制text时有效)数字,例如:100
fontSize文本字体大小(仅绘制text时有效)数字,例如:30
italic文本是否斜体(仅绘制text时有效)布尔类型,例如:true
bold文本是否加粗(仅绘制text时有效)布尔类型,例如:true
fontFamily文本字体(仅绘制text时有效)字符串,例如:'PingFang SC'
lineHeight文本行高(仅绘制text时有效)数字,例如:1.2
underlineOption文本是否设置下划线以及下划线的配置(仅绘制text时有效)对象
deleteLineOption文本是否设置删除线以及删除线的配置(仅绘制text时有效)对象
specialTreatment文本是否进行特殊处理(仅绘制text时有效)对象数组
drawOptions的参数(text 文字)参数解释

underlineOption和deleteLineOption:

数据结构如下:

image.png

  • color:线条的颜色
  • offset:偏移量,下划线的偏移量是下划线距离文字底部的距离
  • 删除线不需要偏移量,因为删除线一般都是在文字中间的
  • size:线条的大小

specialTreatment:

数据结构如下:

image.png

目前只写了两种特殊处理

  • 改变特定文字的颜色
  • 指定换行标识:文本渲染时遇到指定的换行标识就立即换行

示例结构的效果如下图

image.png

注意:

  • 在上图的示例中文本内容是:默认文本/好的哈/大家好/打开就 ,遇到/字符就自动换行了
  • 如果声明指定了换行字符,请手动计算maxLine的值,计算公式:换行字符数量+1

如果需要其他的处理,可以根据需要自行修改源码

代码

<template>
	<div
		class="canvas-box"
		:style="{
			width: props.width + 'rpx',
			height: props.height + 'rpx',
		}"
		@click.stop=""
	>
		<template v-if="canvansWidth && !canvasImageUrl">
			<canvas
				:style="{
					width: canvansWidth + 'px',
					height: canvansHeight + 'px',
				}"
				canvas-id="myCanvas"
				class="myCanvas"
			></canvas>
		</template>
		<image v-if="canvasImageUrl" :src="canvasImageUrl" mode="widthFix" show-menu-by-longpress />
	</div>
</template>

<script setup>
	import { ref, onMounted, computed, watch, getCurrentInstance, nextTick } from 'vue';
	import { onLoad } from '@dcloudio/uni-app';
	const { proxy } = getCurrentInstance();
	let example = proxy;

	const props = defineProps({
		drawArr: {
			type: Array,
			default: [],
			required: true,
		},
		width: {
			type: Number,
			required: true,
		},
		height: {
			type: Number,
			required: true,
		},
	});

	const emit = defineEmits(['canvasDrawComplete']);

	onMounted(async () => {
		uni.showLoading({ title: '绘制中...' });
		init();
		await startDrawing();
		canvasImageUrl.value = await getCanvasImageUrl();
		emit('canvasDrawComplete', canvasImageUrl.value);
		uni.hideLoading();
	});

	let drawArr = ref('');
	let canvansWidth = ref(0); // 画布宽度
	let canvansHeight = ref(0); // 画布高度
	let ctx = null; // 画布
	let canvasImageUrl = ref(''); // 生成的图片路径

	// 初始化
	function init() {
		drawArr.value = JSON.parse(JSON.stringify(props.drawArr));
		canvansWidth.value = props.width / 2;
		canvansHeight.value = props.height / 2;
		ctx = uni.createCanvasContext('myCanvas', example);
		handleData();
		// 给画板添加默认的背景色,如果不需要背景色,可以注释掉
		ctx.setFillStyle('#fff');
		ctx.fillRect(0, 0, canvansWidth.value, canvansHeight.value);
		ctx.draw(true);
		ctx.restore();
	}

	// 处理数据
	function handleData() {
		// 获取绘制元素的x,y坐标
		for (let index = 0; index < drawArr.value.length; index++) {
			let element = drawArr.value[index];
			switch (element.type) {
				case 'image':
				case 'rect':
					element.drawOptions.width = getWidth(element.drawOptions.width);
					element.drawOptions.height = getHeight(element.drawOptions.height);
					element.drawOptions.x = getX(element.drawOptions);
					element.drawOptions.y = getY(element.drawOptions);
					break;
				case 'arc':
					element.drawOptions.radius = element.drawOptions.radius / 2;
					element.drawOptions.x = getArcX(element.drawOptions);
					element.drawOptions.y = getArcY(element.drawOptions);
					break;
				case 'avatar':
					element.drawOptions.width = getWidth(element.drawOptions.width);
					element.drawOptions.height = getHeight(element.drawOptions.height);
					element.drawOptions.radius = element.drawOptions.width / 2;
					element.drawOptions.x = getArcX(element.drawOptions);
					element.drawOptions.y = getArcY(element.drawOptions);
					break;
				case 'text':
					element.drawOptions.fontSize = element.drawOptions.fontSize / 2 || 16;
					element.drawOptions.maxLine = element.drawOptions.maxLine || 1;
					element.drawOptions.lineHeight = element.drawOptions.lineHeight || 1.2;
					element.drawOptions.italic = element.drawOptions.italic ? 'italic' : 'normal';
					element.drawOptions.bold = element.drawOptions.bold ? 'bold' : 'normal';
					element.drawOptions.fontFamily = element.drawOptions.fontFamily || 'PingFang SC';
					element.drawOptions.maxWidth = element.drawOptions.maxWidth / 2 || canvansWidth.value;
					// 特殊处理 改变文字颜色
					element.drawOptions.changeColorList = element.drawOptions.specialTreatment.find(
						(item) => item.key == 'changeTextColor'
					)
						? element.drawOptions.specialTreatment.find((item) => item.key == 'changeTextColor')
								.list
						: '';
					// 特殊处理 换行符
					element.drawOptions.lineFeed = element.drawOptions.specialTreatment.find(
						(item) => item.key == 'lineFeed'
					)
						? element.drawOptions.specialTreatment.find((item) => item.key == 'lineFeed').text
						: '';
					break;
			}
		}
	}

	function getWidth(width) {
		if (typeof width === 'number') {
			return width / 2;
		} else {
			return canvansWidth.value * (width.match(/\d+/)[0] / 100);
		}
	}
	function getHeight(height) {
		if (typeof height === 'number') {
			return height / 2;
		} else {
			return canvansHeight.value * (height.match(/\d+/)[0] / 100);
		}
	}
	function getX(drawOptions) {
		let { left, right, width } = drawOptions;
		let x = '';
		if (left || left === 0) {
			if (typeof left === 'number') {
				x = left / 2;
			} else {
				x = canvansWidth.value / 2 - width / 2;
			}
		} else {
			x = canvansWidth.value - (right / 2 + width);
		}
		return x;
	}
	function getY(drawOptions) {
		let { top, bottom, height } = drawOptions;
		let y = '';
		if (top || top === 0) {
			if (typeof top === 'number') {
				y = top / 2;
			} else {
				y = canvansHeight.value / 2 - height / 2;
			}
		} else {
			y = canvansHeight.value - (bottom / 2 + height);
		}
		return y;
	}
	function getArcX(drawOptions) {
		let { left, right, radius } = drawOptions;
		let x = '';
		if (left || left === 0) {
			if (typeof left === 'number') {
				x = left / 2 + radius;
			} else {
				x = canvansWidth.value / 2;
			}
		} else if (left === 0) {
			x = radius;
		} else {
			x = canvansWidth.value - (right / 2 + radius);
		}
		return x;
	}
	function getArcY(drawOptions) {
		let { top, bottom, radius } = drawOptions;
		let y = '';
		if (top) {
			if (typeof top === 'number') {
				y = top / 2 + radius;
			} else {
				y = canvansHeight.value / 2;
			}
		} else if (top === 0) {
			y = radius;
		} else {
			y = canvansHeight.value - (bottom / 2 + radius);
		}
		return y;
	}

	// 开始绘制
	async function startDrawing() {
		for (let index = 0; index < drawArr.value.length; index++) {
			const element = drawArr.value[index];
			await draw(element);
		}
	}

	// 绘制
	async function draw(data) {
		switch (data.type) {
			case 'image':
				let imageUrl = await getUrl(data.drawOptions.url);
				drawImage(data, imageUrl);
				break;
			case 'text':
				drawText(data);
				break;
			case 'rect':
				drawRect(data);
				break;
			case 'arc':
				drawArc(data);
				break;
			case 'avatar':
				let avatarUrl = await getUrl(data.drawOptions.url);
				drawAvatar(data, avatarUrl);
				break;
		}
	}

	// 获取图片url
	function getUrl(url) {
		return new Promise((resolve, reject) => {
			uni.downloadFile({
				url: url,
				success: (res) => {
					resolve(res.tempFilePath);
				},
			});
		});
	}

	// 绘制图片
	function drawImage(data, url) {
		ctx.save();
		let { x, y, width, height } = data.drawOptions;
		ctx.drawImage(url, x, y, width, height);
		ctx.draw(true);
		ctx.restore();
	}

	// 绘制矩形
	function drawRect(data) {
		ctx.save();
		let { x, y, width, height, type, fillStyle, strokeStyle, isFillet, radius } = data.drawOptions;
		// 绘制圆角
		if (isFillet) {
			ctx.beginPath();
			switch (type) {
				case 'fill':
					// ctx.fillStyle = 'transparent';
					ctx.fillStyle = fillStyle;
					break;
				case 'stroke':
					// ctx.strokeStyle = 'transparent';
					ctx.strokeStyle = strokeStyle;
					break;
			}
			// 左上角
			ctx.arc(x + radius, y + radius, radius, Math.PI, Math.PI * 1.5);
			// 右上角
			ctx.arc(x + width - radius, y + radius, radius, Math.PI * 1.5, Math.PI * 2);
			// 右下角
			ctx.arc(x + width - radius, y + height - radius, radius, 0, Math.PI * 0.5);
			// 左下角
			ctx.arc(x + radius, y + height - radius, radius, Math.PI * 0.5, Math.PI);
			ctx.closePath();
			switch (type) {
				case 'fill':
					ctx.fill();
					break;
				case 'stroke':
					ctx.stroke();
					break;
			}
			ctx.clip();
		}
		// 绘制矩形
		switch (type) {
			case 'fill':
				ctx.fillStyle = fillStyle;
				ctx.fillRect(x, y, width, height);
				break;
			case 'stroke':
				ctx.strokeStyle = strokeStyle;
				ctx.strokeRect(x, y, width, height);
				break;
		}
		ctx.draw(true);
		ctx.restore();
	}

	// 绘制圆
	function drawArc(data) {
		ctx.save();
		let { x, y, radius, type, fillStyle, strokeStyle } = data.drawOptions;
		switch (type) {
			case 'fill':
				ctx.fillStyle = fillStyle;
				ctx.arc(x, y, radius, 0, Math.PI * 2);
				ctx.fill();
				break;
			case 'stroke':
				ctx.strokeStyle = strokeStyle;
				ctx.arc(x, y, radius, 0, Math.PI * 2);
				ctx.stroke();
				break;
		}
		ctx.draw(true);
		ctx.restore();
	}

	// 绘制头像
	function drawAvatar(data, url) {
		ctx.save();
		let { x, y, width, height, radius } = data.drawOptions;
		// 绘制背景圆
		ctx.arc(x, y, radius, 0, Math.PI * 2);
		ctx.clip();
		// 绘制头像图片
		ctx.drawImage(url, x - radius, y - radius, width, height);
		ctx.draw(true);
		ctx.restore();
	}

	// 绘制文字
	function drawText(data) {
		ctx.save();
		let {
			text,
			maxLine,
			maxWidth,
			type,
			fontSize,
			lineHeight,
			fillStyle,
			strokeStyle,
			italic,
			bold,
			fontFamily,
			underlineOption,
			deleteLineOption,
			changeColorList,
			lineFeed,
		} = data.drawOptions;
		ctx.font = `${italic} ${bold} ${fontSize}px ${fontFamily}`;
		// 兼容部分iOS设备字号不生效问题
		ctx.setFontSize(fontSize);
		// 获取xy坐标轴一定要在样式后面,因为字号/加粗/斜体会影响宽度和高度
		data.drawOptions.x = getTextX(data.drawOptions);
		data.drawOptions.y = getTextY(data.drawOptions);
		let { x, y } = data.drawOptions;
		let handleTextArr = textHandle(text, maxLine, maxWidth, lineFeed);
		for (let i = 0; i < handleTextArr.length; i++) {
			if (type == 'fill') {
				ctx.fillStyle = fillStyle;
			} else {
				ctx.strokeStyle = strokeStyle;
			}
			let ny = y + (i + 1) * (fontSize * lineHeight);
			for (let index = 0; index < handleTextArr[i].length; index++) {
				// 特殊处理 改变特定文字颜色 开始
				if (changeColorList) {
					let special = changeColorList.find((item) => item.text == handleTextArr[i][index]);
					if (special) {
						if (type == 'fill') {
							ctx.fillStyle = special.color;
						} else {
							ctx.strokeStyle = special.color;
						}
					} else {
						if (type == 'fill') {
							ctx.fillStyle = fillStyle;
						} else {
							ctx.strokeStyle = strokeStyle;
						}
					}
				}
				// 特殊处理 改变特定文字颜色 结束
				if (index) {
					let lastTextW = ctx.measureText(handleTextArr[i].slice(0, index)).width;
					if (type == 'fill') {
						ctx.fillText(handleTextArr[i][index], x + lastTextW, ny);
					} else {
						ctx.strokeText(handleTextArr[i][index], x + lastTextW, ny);
					}
				} else {
					if (type == 'fill') {
						ctx.fillText(handleTextArr[i][index], x, ny);
					} else {
						ctx.strokeText(handleTextArr[i][index], x, ny);
					}
				}
			}

			// 绘制下划线
			if (underlineOption) {
				const textW = ctx.measureText(handleTextArr[i]).width;
				ctx.beginPath();
				ctx.lineWidth = underlineOption.size; // 设置下划线粗细
				ctx.moveTo(x, ny + underlineOption.offset);
				ctx.lineTo(x + textW, ny + underlineOption.offset);
				ctx.strokeStyle = underlineOption.color;
				ctx.stroke();
			}

			// 绘制删除线
			if (deleteLineOption) {
				const textW = ctx.measureText(handleTextArr[i]).width;
				ctx.beginPath();
				ctx.lineWidth = deleteLineOption.size; // 设置删除线粗细
				ctx.moveTo(x, ny - fontSize / 2 + deleteLineOption.size);
				ctx.lineTo(x + textW, ny - fontSize / 2 + deleteLineOption.size);
				ctx.strokeStyle = deleteLineOption.color;
				ctx.stroke();
			}
		}
		ctx.draw(true);
		ctx.restore();
	}
	// 文本处理
	function textHandle(text, maxLine, maxWidth, lineFeed) {
		let rowLength = 0;
		let textStr = '';
		let textArr = [];
		for (let index = 0; index < text.length; index++) {
			if (textArr.length == maxLine) break;
			let t = text[index];
			let tWidth = ctx.measureText(t).width;
			// 特殊处理 换行符 开始
			if (lineFeed) {
				// 检查是否是特殊字符,如果是则强制换行,但不包含特殊字符本身
				if (t === lineFeed) {
					if (textStr) {
						textArr.push(textStr);
						textStr = '';
						rowLength = 0;
					}
					continue; // 跳过特殊字符,不将其加入文本中
				}
			}
			// 特殊处理 换行符 结束
			textStr += t;
			rowLength += tWidth;
			if (rowLength == maxWidth) {
				textArr.push(textStr);
				textStr = '';
				rowLength = 0;
			} else if (rowLength > maxWidth) {
				textStr = textStr.slice(0, -1);
				textArr.push(textStr);
				textStr = t;
				rowLength = tWidth;
			}
		}
		textArr.push(textStr);
		if (textArr.length > maxLine) {
			textArr[maxLine - 1] = textArr[maxLine - 1].slice(0, -1) + '...';
			textArr = textArr.slice(0, maxLine);
		}
		return textArr;
	}
	function getTextX(drawOptions) {
		let { left, right, text, maxLine, maxWidth, lineFeed } = drawOptions;
		let x = '';
		if (left || left === 0) {
			if (typeof left === 'number') {
				x = left / 2;
			} else {
				let rowLength = getTextWidth(text, maxLine, maxWidth, lineFeed);
				x = canvansWidth.value / 2 - rowLength / 2;
			}
		} else {
			let rowLength = getTextWidth(text, maxLine, maxWidth, lineFeed);
			x = canvansWidth.value - (right / 2 + rowLength);
		}
		function getTextWidth(text, maxLine, maxWidth, lineFeed) {
			let textArr = textHandle(text, maxLine, maxWidth, lineFeed);
			let textItem = textArr[0];
			let rowLength = 0;
			for (let index = 0; index < textItem.length; index++) {
				const t = textItem[index];
				rowLength += ctx.measureText(t).width;
			}
			rowLength = Math.floor(rowLength);
			if (rowLength > maxWidth) {
				rowLength = maxWidth;
			}
			return rowLength;
		}
		return x;
	}
	function getTextY(drawOptions) {
		let { top, bottom, text, fontSize, lineHeight, maxLine, maxWidth, lineFeed } = drawOptions;
		let y = '';
		if (top || top === 0) {
			if (typeof top === 'number') {
				y = top / 2;
			} else {
				let textHeight = getTextHeight(text, maxLine, maxWidth, fontSize, lineHeight, lineFeed);
				y = canvansHeight.value / 2 - textHeight / 2;
			}
		} else {
			let textHeight = getTextHeight(text, maxLine, maxWidth, fontSize, lineHeight, lineFeed);
			y = canvansHeight.value - (bottom / 2 + textHeight);
		}
		function getTextHeight(text, maxLine, maxWidth, fontSize, lineHeight, lineFeed) {
			let textArr = textHandle(text, maxLine, maxWidth, lineFeed);
			let textHeight = textArr.length * (fontSize * lineHeight);
			return textHeight;
		}
		return y;
	}

	// 下载图片
	function getCanvasImageUrl() {
		return new Promise((resolve, reject) => {
			setTimeout(() => {
				uni.canvasToTempFilePath(
					{
						canvasId: 'myCanvas',
						success: (res) => {
							// console.log('转换图片成功', res.tempFilePath);
							resolve(res.tempFilePath);
						},
						fail: (err) => {
							console.error('转换图片失败', err);
							reject(err);
						},
					},
					example
				);
			}, 500);
		});
	}
</script>

<style lang="scss" scoped>
	.canvas-box {
		position: relative;
		.myCanvas {
			position: absolute;
			left: -200%;
			top: -200%;
		}
		image {
			width: 100%;
			height: 100%;
		}
	}
</style>

页面使用

<myCanvas
			:drawArr="drawArr"
			:width="690"
			:height="996"
			@canvasDrawComplete="canvasDrawComplete"
		></myCanvas>
		<button @click="downloadCanvasImageUrl">{{ canvasImageUrl ? '保存图片' : '绘制中...' }}</button>
                
                import myCanvas from './myCanvas.vue';
	let drawArr = ref([]);
	let canvasImageUrl = ref(''); // canvas画布转换成图片的url
	function canvasDrawComplete(url) {
		canvasImageUrl.value = url;
	}
	function downloadCanvasImageUrl() {
		if (!canvasImageUrl.value) return;
		uni.saveImageToPhotosAlbum({
			filePath: canvasImageUrl.value,
			success: () => {
				uni.showToast({ title: '保存成功', icon: 'none' });
			},
			fail: (err) => {
				uni.showToast({ title: '保存失败', icon: 'none' });
			},
		});
	}
        

注意:drawArr这个数组中保存的是需要渲染的元素,但是这个渲染的元素有渲染顺序,因为后渲染的会覆盖先渲染的(位置一样的情况),就像画画一样,后面画的肯定会挡住前面画的(在同一个位置作画的情况)

实例:比如我先在画布上渲染了一行文字,然后我又在画布上渲染了一个图片,这个图片的大小是画布的大小,这就会导致文字渲染不出来了

所以,使用时要判断一下元素的渲染顺序,再根据渲染顺序来写入drawArr这个数组的值